feat(cli): install in one command, and call the CLI ludic

Getting started meant cloning the repository, bootstrapping a compiler and
learning a task runner called `x`. That is a contributor's workflow handed to
everyone who wants to try the language.

Installing is now one command:

    curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh

install.sh puts a complete toolchain — compiler, CLI, engine runtime, bundled
ludic.* packages, formatter, language server — in ~/.ludic and adds it to PATH.
Prebuilt artifacts are checksum-verified; where a platform has none, or the
release predates this layout, it bootstraps from the compiler's own IR seed with
clang. The docs site publishes the script beside the pages that quote it, so the
page and the script can never come from different releases.

`x` becomes `ludic`, and the surface splits by audience. A user of the language
sees `new`, `run`, `build`, `test`, `add`, `fmt`, `lsp`, `doctor`, `upgrade`;
`ludic new` scaffolds a project that builds and plays as it stands. Everything
the toolchain repo needs moved under `ludic dev` — build, test, reseed,
bootstrap-cfree, docs-gen, release — unchanged apart from the namespace. Those
tasks read arguments one position further along, so dispatch_dev sets a shift
and commands use arg_n()/arg_total() rather than each knowing its own depth.

Release artifacts become complete install roots (bin/ beside runtime/, packages/
and VERSION) rather than bare binaries, which is what the installer unpacks.
`ludic dev test` asserts the whole shape: it stages an install, puts it on PATH
with no LUDIC_HOME, and runs new -> build -> test through it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-05 22:01:52 +03:00
parent 005cc39394
commit aca263642d
54 changed files with 1802 additions and 670 deletions

View file

@ -1,232 +0,0 @@
# pkg_test.ludic — the package-manager suite (issue #63), hermetic and offline.
#
# It stands up throwaway git repositories under /tmp and points the package
# manager at them with LUDIC_PKG_PROXY, so the whole fetch → MVS resolve →
# content-addressed store → project link → compile → run → verify pipeline runs
# with no network. Every check drives a fresh `bin/x` / `bin/ludicc` process the
# way a real project would.
# write `body` to `path`, creating parent directories
function pt_write(path: pointer, body: pointer) -> void {
run(`mkdir -p "$(dirname {path})"`)
write_file(path, body)
}
# commit the current tree of `dir` and tag it `tag`
function pt_commit_tag(dir: pointer, tag: pointer) -> void {
run(`git -C {dir} add -A`)
run(`git -C {dir} -c user.email=t@t.test -c user.name=tester commit -q -m {tag}`)
run(`git -C {dir} tag {tag}`)
}
function cmd_test_pkg() -> int {
PASS = 0
FAIL = 0
print("== package manager (issue #63): fetch + MVS resolve + store + link + build ==")
if not shq("command -v git >/dev/null 2>&1") {
skip("git unavailable — package-manager suite needs git")
return report()
}
let root = capture_line("pwd")
let work = `{tmp_dir()}/pkg_test`
let proxy = `{work}/proxy`
let store = `{work}/store`
let proj = `{work}/proj`
run(`rm -rf {work}`)
run(`mkdir -p {proxy} {store} {proj}`)
# ---- fixture packages -----------------------------------------------------
# example.test/util: a leaf source package, two published versions.
let util = `{proxy}/example.test/util`
run(`git -C {util} init -q 2>/dev/null || ( mkdir -p {util} && git -C {util} init -q )`)
pt_write(`{util}/package.ludic`, `package "example.test/util"` + nl() + `version "1.0.0"` + nl() + "kind source" + nl() + `provides "Util"` + nl())
pt_write(`{util}/util.ludic`, "function util_tag() -> pointer { return \"u1\" }\n")
pt_commit_tag(util, "v1.0.0")
pt_write(`{util}/package.ludic`, `package "example.test/util"` + nl() + `version "1.2.0"` + nl() + "kind source" + nl() + `provides "Util"` + nl())
pt_write(`{util}/util.ludic`, "function util_tag() -> pointer { return \"u12\" }\n")
pt_commit_tag(util, "v1.2.0")
# example.test/greeter: requires util >=1.0.0, provides Greet.
let greeter = `{proxy}/example.test/greeter`
run(`mkdir -p {greeter} && git -C {greeter} init -q`)
pt_write(`{greeter}/package.ludic`, `package "example.test/greeter"` + nl() + `version "1.0.0"` + nl() + "kind source" + nl() + `provides "Greet"` + nl() + `require "example.test/util" "1.0.0"` + nl())
pt_write(`{greeter}/greet.ludic`, "function greet_hello() -> pointer { return \"hi\" }\n")
pt_commit_tag(greeter, "v1.0.0")
# ---- the consumer project -------------------------------------------------
# requires util 1.2.0 directly; greeter (which requires util 1.0.0) is added
# too. MVS must pick util 1.2.0 — the greatest required minimum.
pt_write(`{proj}/package.ludic`, `package "app"` + nl() + `version "0.0.0"` + nl() + `require "example.test/greeter" "1.0.0"` + nl() + `require "example.test/util" "1.2.0"` + nl())
pt_write(`{proj}/app.ludic`, "program App {\n import \"example.test/greeter/greet.ludic\"\n import \"example.test/util/util.ludic\"\n entry { print(greet_hello()); print(util_tag()) }\n}\n")
let envp = `LUDIC_PKG_PROXY={proxy} LUDIC_STORE={store}`
# ---- x get: resolve, fetch, store, link, lock -----------------------------
if shq(`( cd {proj} && {envp} {root}/bin/x get > {work}/get.out 2>&1 )`) {
ok("x get resolves + fetches the dependency graph")
} else {
bad2("x get failed", capture_line(`tail -2 {work}/get.out`))
}
var lock = read_file(`{proj}/package.lock.ludic`)
if lock == null { lock = "" }
if s_contains(lock, "example.test/greeter") and s_contains(lock, "example.test/util") {
ok("package.lock.ludic pins the whole build list")
} else { bad("lockfile is missing a resolved package") }
# MVS: util resolves to 1.2.0 (max of the 1.0.0 and 1.2.0 minimums), not 1.0.0
if s_contains(lock, `"example.test/util" version "1.2.0"`) {
ok("MVS selects util 1.2.0 (greatest required minimum)")
} else { bad2("MVS picked the wrong util version", capture_line(`grep util {proj}/package.lock.ludic`)) }
if s_contains(lock, "sha256:") { ok("lockfile records content hashes") } else { bad("lockfile has no content hash") }
# the project view links into the content-addressed store
if file_exists(`{proj}/ludic_modules/example.test/greeter/greet.ludic`) and file_exists(`{proj}/ludic_modules/example.test/util/util.ludic`) {
ok("ludic_modules/ links the store entries into the project")
} else { bad("ludic_modules view was not linked") }
# ---- compile + run the consumer against the fetched packages --------------
if shq(`( cd {proj} && LUDIC_MODULES=ludic_modules {root}/bin/ludicc app.ludic -o app > {work}/build.out 2>&1 )`) {
ok("consumer compiles against the linked packages (do_import module-root fallback)")
let got = capture_line(`( cd {proj} && ./app )`)
if got == "hi u12" { ok("the running program uses the MVS-selected package code (\"hi u12\")") }
else { bad2("program output mismatch", `got [{got}] want [hi u12]`) }
} else {
bad2("consumer build failed", capture_line(`tail -2 {work}/build.out`))
}
# ---- x verify: content integrity against the store ------------------------
if shq(`( cd {proj} && {envp} {root}/bin/x verify > {work}/verify.out 2>&1 )`) {
ok("x verify passes on an untouched store")
} else { bad2("x verify failed unexpectedly", capture_line(`tail -2 {work}/verify.out`)) }
# tamper with a stored file — verify must now flag it
let uhash = capture_line(`grep 'example.test/util' {proj}/package.lock.ludic | sed 's/.*sha256://; s/\".*//'`)
run(`echo tampered >> {store}/{uhash}/util.ludic`)
if not shq(`( cd {proj} && {envp} {root}/bin/x verify > {work}/verify2.out 2>&1 )`) {
ok("x verify detects a tampered store entry")
} else { bad("x verify missed a tampered store entry") }
# heal the store: a content-addressed entry is keyed by its hash-named dir, so
# `x get` trusts an existing one — drop it first, then re-fetch a clean copy.
run(`rm -rf {store}/{uhash}`)
run(`( cd {proj} && {envp} {root}/bin/x get > /dev/null 2>&1 )`)
if shq(`( cd {proj} && {envp} {root}/bin/x verify > {work}/verify3.out 2>&1 )`) {
ok("re-fetching heals a tampered store entry (x verify green again)")
} else { bad2("store did not heal after re-fetch", capture_line(`tail -1 {work}/verify3.out`)) }
# ---- a package that exports ECS: a component, a model and a system --------
# Ludic is AOT, so a source package's declarations compile straight into the
# consumer's compile-time ECS — no ABI seam. This proves a consumer *game* can
# use a package's property (component), model, @OnSpawn handler (system) and a
# plain function together. The engine runtime (runtime/native/*.ludic) resolves
# relative to the compile CWD, so — exactly like `x app` — the game is compiled
# from the repo root with the package linked in via an absolute LUDIC_MODULES.
let epkg = `{proxy}/example.test/enginepkg`
run(`mkdir -p {epkg} && git -C {epkg} init -q`)
pt_write(`{epkg}/package.ludic`, `package "example.test/enginepkg"` + nl() + `version "1.0.0"` + nl() + "kind source" + nl() + `provides "EnginePkg"` + nl())
pt_write(`{epkg}/lib.ludic`, "property Health { hp: int = 0, max: int = 0 }\nmodel Unit { Health }\n@Public @OnSpawn(Unit) handler Init { Health.hp = Health.max }\nfunction bonus() -> int { return 7 }\n")
pt_commit_tag(epkg, "v1.0.0")
let proj2 = `{work}/proj2`
pt_write(`{proj2}/package.ludic`, `package "game"` + nl() + `version "0.0.0"` + nl() + `require "example.test/enginepkg" "1.0.0"` + nl())
pt_write(`{proj2}/game.ludic`, "program G {\n import \"example.test/enginepkg/lib.ludic\"\n entry {\n spawn Unit { Health { max: 50 } }\n let H = world_prop_id(\"Health\")\n let hp = world_field_id(H, \"hp\")\n let e = world_query_next(H, 0)\n print(world_get(e, H, hp))\n print(bonus())\n }\n}\n")
if shq(`( cd {proj2} && {envp} {root}/bin/x get > {work}/get2.out 2>&1 )`) {
if shq(`( cd {root} && LUDIC_MODULES={proj2}/ludic_modules {root}/bin/ludicc --headless {proj2}/game.ludic -o {proj2}/game > {work}/gbuild.out 2>&1 )`) {
let got = capture_line(`{proj2}/game`)
if got == "50 7" {
ok("a consumer game uses the package's component + model + system + function (\"50 7\")")
} else { bad2("imported ECS produced wrong output", `got [{got}] want [50 7]`) }
} else { bad2("game consuming an ECS package failed to build", capture_line(`tail -2 {work}/gbuild.out`)) }
} else { bad2("x get for the ECS package failed", capture_line(`tail -1 {work}/get2.out`)) }
# ---- namespace collision policy (v1: hard error) --------------------------
let dupe = `{proxy}/example.test/dupe`
run(`mkdir -p {dupe} && git -C {dupe} init -q`)
pt_write(`{dupe}/package.ludic`, `package "example.test/dupe"` + nl() + `version "1.0.0"` + nl() + "kind source" + nl() + `provides "Util"` + nl())
pt_write(`{dupe}/dupe.ludic`, "function dupe_tag() -> pointer { return \"d\" }\n")
pt_commit_tag(dupe, "v1.0.0")
if not shq(`( cd {proj} && {envp} {root}/bin/x add example.test/dupe@1.0.0 > {work}/dupe.out 2>&1 )`) and shq(`grep -q collision {work}/dupe.out`) {
ok("two packages claiming the same Foo.* namespace is a hard error")
} else { bad2("collision not rejected", capture_line(`tail -1 {work}/dupe.out`)) }
# drop the bad dep again so the manifest is clean
pt_write(`{proj}/package.ludic`, `package "app"` + nl() + `version "0.0.0"` + nl() + `require "example.test/greeter" "1.0.0"` + nl() + `require "example.test/util" "1.2.0"` + nl())
# ---- prebuilt target matrix ----------------------------------------------
let pb = `{proxy}/example.test/prebuilt`
run(`mkdir -p {pb} && git -C {pb} init -q`)
pt_write(`{pb}/package.ludic`, `package "example.test/prebuilt"` + nl() + `version "1.0.0"` + nl() + "kind prebuilt" + nl() + `provides "Blob"` + nl() + `targets "made-up-target"` + nl())
pt_write(`{pb}/README`, "a prebuilt lib shipping no artifact for this host\n")
pt_commit_tag(pb, "v1.0.0")
if not shq(`( cd {proj} && {envp} {root}/bin/x add example.test/prebuilt@1.0.0 > {work}/pb.out 2>&1 )`) and shq(`grep -q 'prebuilt lib' {work}/pb.out`) {
ok("a prebuilt lib with no artifact for the build target is a hard error")
} else { bad2("prebuilt target matrix not enforced", capture_line(`tail -1 {work}/pb.out`)) }
pt_write(`{proj}/package.ludic`, `package "app"` + nl() + `version "0.0.0"` + nl() + `require "example.test/greeter" "1.0.0"` + nl() + `require "example.test/util" "1.2.0"` + nl())
# ---- x vendor: hermetic copy ---------------------------------------------
run(`( cd {proj} && {envp} {root}/bin/x get > /dev/null 2>&1 )`)
if shq(`( cd {proj} && {envp} {root}/bin/x vendor > {work}/vendor.out 2>&1 )`) and file_exists(`{proj}/vendor/example.test/util/util.ludic`) {
ok("x vendor copies the resolved packages into ./vendor")
} else { bad2("x vendor failed", capture_line(`tail -1 {work}/vendor.out`)) }
# ---- package-declarable engine-system + namespace (issue #62) -------------
# A source package registers a compile-time engine system (@EngineSystem) and a
# Foo.* namespace (@Namespace) with no compiler edit; a consumer game imports it
# and both take effect. Source path → runs everywhere (not dylib/macOS-gated).
let cp = `{proxy}/example.test/coachpack`
run(`mkdir -p {cp} && git -C {cp} init -q`)
pt_write(`{cp}/package.ludic`, `package "example.test/coachpack"` + nl() + `version "1.0.0"` + nl() + "kind source" + nl() + `provides "Coach"` + nl())
pt_write(`{cp}/coach.ludic`, "property Score { value: int = 0 }\n@EngineSystem(Score, Update) function esys_score() -> void {\n let P = world_prop_id(\"Score\")\n let e = world_query_next(P, 0)\n if e >= 0 { world_set(e, P, 0, world_get(e, P, 0) + 1) }\n}\n@Namespace(Coach) function coach_bonus() -> int { return 99 }\n")
pt_commit_tag(cp, "v1.0.0")
let proj4 = `{work}/proj4`
pt_write(`{proj4}/package.ludic`, `package "game"` + nl() + `version "0.0.0"` + nl() + `require "example.test/coachpack" "1.0.0"` + nl())
pt_write(`{proj4}/game.ludic`, "program Game {\n import \"example.test/coachpack/coach.ludic\"\n model Player { Score }\n @OnSpawn(Player) handler Init { }\n var frames: int = 0\n handler Boot phase Start { let e = world_spawn(world_model_id(\"Player\")) }\n handler Run phase Update {\n frames = frames + 1\n if frames >= 5 {\n let P = world_prop_id(\"Score\")\n let e = world_query_next(P, 0)\n print(world_get(e, P, 0))\n print(Coach.bonus())\n quit()\n }\n }\n}\n")
if shq(`( cd {proj4} && {envp} {root}/bin/x get > {work}/get4.out 2>&1 )`) {
run(`LUDIC_HOME={root} LUDIC_MODULES={proj4}/ludic_modules {root}/bin/ludicc --headless {proj4}/game.ludic -o {proj4}/game 2>{work}/gc4.err`)
if file_exists(`{proj4}/game`) {
let got = capture_line(`printf 'aaaaa' | {proj4}/game`)
if got == "4 99" {
ok("a package registers an engine-system (@EngineSystem) + a namespace (@Namespace) with no compiler edit (\"4 99\")")
} else { bad2("package hooks produced wrong output", `got [{got}] want [4 99]`) }
} else { bad2("consumer of a @EngineSystem/@Namespace package failed to build", capture_line(`tail -1 {work}/gc4.err`)) }
} else { bad2("x get for the coach package failed", capture_line(`tail -1 {work}/get4.out`)) }
# ---- prebuilt binary package (issue #64): exported function + system + component
# A module is compiled to a dylib (x build-lib), fetched as a `kind prebuilt`
# dependency, and linked into a consumer game that never saw its source. At load
# the module registers a dynamic component and an @System(Update); the consumer
# also calls an exported function via `extern`. Native/dylib only → macOS-gated.
if not is_darwin() {
skip("prebuilt binary packages (dylib) — native/macOS only")
return report()
}
let tgt = `native-{capture_line("uname -m")}`
let mp = `{proxy}/example.test/manapack`
run(`mkdir -p {mp} && git -C {mp} init -q`)
pt_write(`{mp}/package.ludic`, `package "example.test/manapack"` + nl() + `version "1.0.0"` + nl() + "kind prebuilt" + nl() + `provides "Mana"` + nl() + `targets "{tgt}"` + nl())
pt_write(`{mp}/manapack.ludic`, "program ManaPack {\n function module_init() -> void { world_register_prop(\"Mana\", 2) }\n @System(Update) function mana_regen() -> void {\n let M = world_prop_id(\"Mana\")\n let e = world_query_next(M, 0)\n if e >= 0 { let v = world_get(e, M, 0); world_set(e, M, 0, v + 1) }\n }\n function mana_bonus() -> int { return 42 }\n}\n")
if shq(`( cd {mp} && LUDICC={root}/bin/ludicc {root}/bin/x build-lib manapack.ludic > {work}/bl.out 2>&1 )`) and file_exists(`{mp}/lib/{tgt}/libmanapack.dylib`) {
ok("x build-lib compiles a package module to a per-target dylib")
} else { bad2("x build-lib failed", capture_line(`tail -1 {work}/bl.out`)) }
pt_commit_tag(mp, "v1.0.0")
let proj3 = `{work}/proj3`
pt_write(`{proj3}/package.ludic`, `package "game"` + nl() + `version "0.0.0"` + nl() + `require "example.test/manapack" "1.0.0"` + nl())
pt_write(`{proj3}/game.ludic`, "program Game {\n property Dummy { x: int = 0 }\n model Ent { Dummy }\n @OnSpawn(Ent) handler Init { }\n extern function mana_bonus() -> int = \"fn_mana_bonus\"\n var frames: int = 0\n handler Boot phase Start {\n let e = world_spawn(world_model_id(\"Ent\"))\n let M = world_prop_id(\"Mana\")\n world_attach_dyn(e, M)\n world_set(e, M, 0, 0)\n }\n handler Run phase Update {\n frames = frames + 1\n if frames >= 4 {\n let M = world_prop_id(\"Mana\")\n let e = world_query_next(M, 0)\n print(world_get(e, M, 0))\n print(mana_bonus())\n quit()\n }\n }\n}\n")
if shq(`( cd {proj3} && {envp} {root}/bin/x get > {work}/get3.out 2>&1 )`) and file_exists(`{proj3}/ludic_modules/example.test/manapack/lib/{tgt}/libmanapack.dylib`) {
ok("x get fetches + links a prebuilt (kind prebuilt) package for the target")
} else { bad2("prebuilt x get failed", capture_line(`tail -1 {work}/get3.out`)) }
# compile the ECS consumer from the repo root (so runtime/native resolves), then
# link the module dylib via `x link-flags` (a real external build's link step).
run(`LUDIC_HOME={root} {root}/bin/ludicc --headless {proj3}/game.ludic --emit-llvm -o {proj3}/game.ll 2>{work}/gc.err`)
let flags = capture_line(`( cd {proj3} && LUDIC_STORE={store} {root}/bin/x link-flags )`)
if shq(`{cc()} -O2 -Wno-override-module {proj3}/game.ll {flags} -o {proj3}/game 2>{work}/gl.err`) {
let got = capture_line(`printf 'aaaaaa' | {proj3}/game`)
if got == "3 42" {
ok("a consumer links the prebuilt module: its system mutates the world + its function is callable (\"3 42\")")
} else { bad2("prebuilt consumer output mismatch", `got [{got}] want [3 42]`) }
} else { bad2("prebuilt consumer link failed", capture_line(`tail -1 {work}/gl.err`)) }
return report()
}