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

@ -0,0 +1,232 @@
# 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/ludic` / `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}`
# ---- ludic get: resolve, fetch, store, link, lock -----------------------------
if shq(`( cd {proj} && {envp} {root}/bin/ludic get > {work}/get.out 2>&1 )`) {
ok("ludic get resolves + fetches the dependency graph")
} else {
bad2("ludic 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`))
}
# ---- ludic verify: content integrity against the store ------------------------
if shq(`( cd {proj} && {envp} {root}/bin/ludic verify > {work}/verify.out 2>&1 )`) {
ok("ludic verify passes on an untouched store")
} else { bad2("ludic 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/ludic verify > {work}/verify2.out 2>&1 )`) {
ok("ludic verify detects a tampered store entry")
} else { bad("ludic verify missed a tampered store entry") }
# heal the store: a content-addressed entry is keyed by its hash-named dir, so
# `ludic 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/ludic get > /dev/null 2>&1 )`)
if shq(`( cd {proj} && {envp} {root}/bin/ludic verify > {work}/verify3.out 2>&1 )`) {
ok("re-fetching heals a tampered store entry (ludic 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 `ludic build` — 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/ludic 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("ludic 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/ludic 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/ludic 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())
# ---- ludic vendor: hermetic copy ---------------------------------------------
run(`( cd {proj} && {envp} {root}/bin/ludic get > /dev/null 2>&1 )`)
if shq(`( cd {proj} && {envp} {root}/bin/ludic vendor > {work}/vendor.out 2>&1 )`) and file_exists(`{proj}/vendor/example.test/util/util.ludic`) {
ok("ludic vendor copies the resolved packages into ./vendor")
} else { bad2("ludic 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/ludic 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("ludic 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 (ludic 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/ludic build-lib manapack.ludic > {work}/bl.out 2>&1 )`) and file_exists(`{mp}/lib/{tgt}/libmanapack.dylib`) {
ok("ludic build-lib compiles a package module to a per-target dylib")
} else { bad2("ludic 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/ludic get > {work}/get3.out 2>&1 )`) and file_exists(`{proj3}/ludic_modules/example.test/manapack/lib/{tgt}/libmanapack.dylib`) {
ok("ludic get fetches + links a prebuilt (kind prebuilt) package for the target")
} else { bad2("prebuilt ludic 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 `ludic 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/ludic 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()
}