The CLI's shell commands go through shell(), which is run() on POSIX and a scratch script handed to Git for Windows' bash on Windows (exit codes read directly there). compile_app links through `ludicc -o` on Windows, ludic run starts the .exe, and ludic-dev build and ensure_ludicc assemble selfhost/ludicc.win.seed.ll, which ludic-dev reseed now writes beside the macOS seed. ludic bundle makes build/<name>/ with a GUI-subsystem exe carrying the .ico beside `app icon` as an llvm-rc resource, game.lpak and packs.index; ludicc gains --gui and --link, and quotes its whole link line for cmd.exe. Verified: ludic-dev test 135/135, selfhost-test 32/32; on the PC a checkout bootstraps from the Windows seed, ludic-dev build makes the toolchain, and `ludic bundle` makes build/Maroon Lake/, which runs from its own folder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
746 lines
26 KiB
Text
746 lines
26 KiB
Text
# pkg.ludic — the Ludic package manager (issue #63), the `ludic add`/`get`/
|
|
# `update`/`verify`/`vendor` commands.
|
|
#
|
|
# It realises the v1 direction decided in the RFC:
|
|
#
|
|
# * URL-as-identity, no registry — the import path IS the git location
|
|
# (git.workshopsoft.io/user/pkg). A `git tag` is publishing.
|
|
# * Minimum Version Selection (MVS, Go-style) — a require is a *minimum*; the
|
|
# resolver picks, per module, the greatest of the required minimums, then
|
|
# the reachable closure at those versions. Deterministic, no SAT solver.
|
|
# * A content-addressed global store + per-project symlinks (pnpm-style): one
|
|
# immutable store keyed by a content hash, each project a light linked view
|
|
# under ludic_modules/ (which the compiler resolves imports against).
|
|
# * package.ludic (declared deps + provided namespaces + kind + targets) and
|
|
# package.lock.ludic (resolved versions + content hashes) as the manifest
|
|
# and lockfile.
|
|
#
|
|
# Fetching drives plain `git` through the `run`/`capture` intrinsics — no new
|
|
# infrastructure. A source package's Ludic is compiled together with the
|
|
# consumer via the module-root import fallback in the compiler (do_import); a
|
|
# prebuilt lib declares its shipped targets and is resolved against the build
|
|
# target. Everything is content-verified against the lockfile by `ludic verify`.
|
|
|
|
# ---- the manifest / lock model ----------------------------------------------
|
|
|
|
property Dep { module: pointer = "", ver: pointer = "" }
|
|
|
|
property Manifest {
|
|
module: pointer = "",
|
|
ver: pointer = "",
|
|
kind: pointer = "source", # source | prebuilt
|
|
hash: pointer = "", # content hash, filled in after a snapshot
|
|
provides: []pointer, # the Foo.* namespace(s) this package registers
|
|
targets: []pointer, # prebuilt: the targets it ships (native-arm64, wasm32, …)
|
|
deps: []Dep,
|
|
packs: []pointer, # `pack "<dir>"` — asset roots that go into the .lpak
|
|
app: []pointer # `app <key> "<value>"` — flattened key, value, key, value…
|
|
}
|
|
|
|
function manifest_new() -> Manifest {
|
|
let m = new Manifest
|
|
m.module = ""
|
|
m.ver = ""
|
|
m.kind = "source"
|
|
m.hash = ""
|
|
m.provides = new []pointer
|
|
m.targets = new []pointer
|
|
m.deps = new []Dep
|
|
m.packs = new []pointer
|
|
m.app = new []pointer
|
|
return m
|
|
}
|
|
|
|
# a bare newline string (the lexer has no reliable \n inside interpolation)
|
|
function nl() -> pointer { let b = bytes(2); b[0] = '\n'; b[1] = 0; return b }
|
|
|
|
# ---- version arithmetic (semver major.minor.patch, pre-release ignored) ------
|
|
|
|
# drop a leading 'v' (118) from a tag/version string
|
|
function strip_v(s: pointer) -> pointer {
|
|
if slen(s) > 0 and s[0] == 'v' { return sslice(s, 1, slen(s)) }
|
|
return s
|
|
}
|
|
|
|
# major/minor/patch as three ints (missing fields are 0); stops at -/+ metadata
|
|
function ver_nums(s: pointer) -> []int {
|
|
let v = strip_v(s)
|
|
var parts = new []int
|
|
var cur = 0
|
|
let n = slen(v)
|
|
var i = 0
|
|
while i < n {
|
|
let c = v[i]
|
|
if c >= '0' and c <= '9' { cur = cur * 10 + (c - 48) }
|
|
else if c == '.' { push(parts, cur); cur = 0 }
|
|
else { i = n } # a '-'/'+' ends the numeric core
|
|
i += 1
|
|
}
|
|
push(parts, cur)
|
|
while len(parts) < 3 { push(parts, 0) }
|
|
return parts
|
|
}
|
|
|
|
# -1 / 0 / 1 for a < b / a == b / a > b
|
|
function ver_cmp(a: pointer, b: pointer) -> int {
|
|
let pa = ver_nums(a)
|
|
let pb = ver_nums(b)
|
|
var i = 0
|
|
while i < 3 {
|
|
if pa[i] < pb[i] { return -1 }
|
|
if pa[i] > pb[i] { return 1 }
|
|
i += 1
|
|
}
|
|
return 0
|
|
}
|
|
function ver_gt(a: pointer, b: pointer) -> bool { return ver_cmp(a, b) > 0 }
|
|
|
|
# is `s` a version tag (optional leading v, then a digit)?
|
|
function is_ver_tag(s: pointer) -> bool {
|
|
let v = strip_v(s)
|
|
if slen(v) == 0 { return false }
|
|
return v[0] >= '0' and v[0] <= '9'
|
|
}
|
|
|
|
# ---- tiny line/token scanners for the manifest & lock ------------------------
|
|
|
|
# split a manifest/lock line into tokens: "quoted strings" (unquoted) or bare
|
|
# words. A '#' outside a quote starts a comment that ends the line.
|
|
function tok_line(line: pointer) -> []pointer {
|
|
var toks = new []pointer
|
|
let n = slen(line)
|
|
var i = 0
|
|
while i < n {
|
|
let c = line[i]
|
|
if c == '#' { i = n } # '#' comment
|
|
else if c == ' ' or c == '\t' { i += 1 } # whitespace
|
|
else if c == '"' { # "quoted"
|
|
i += 1
|
|
let start = i
|
|
while i < n and line[i] != '"' { i += 1 }
|
|
push(toks, sslice(line, start, i))
|
|
if i < n { i += 1 }
|
|
} else { # bare word
|
|
let start = i
|
|
while i < n and line[i] != ' ' and line[i] != '\t' and line[i] != '#' { i += 1 }
|
|
push(toks, sslice(line, start, i))
|
|
}
|
|
}
|
|
return toks
|
|
}
|
|
|
|
# split a captured multi-line string into trimmed non-empty lines
|
|
function split_lines(s: pointer) -> []pointer {
|
|
var out = new []pointer
|
|
let n = slen(s)
|
|
var i = 0
|
|
while i < n {
|
|
let line = line_at(s, i)
|
|
i = i + slen(line) + 1
|
|
let t = s_trim(line)
|
|
if slen(t) > 0 { push(out, t) }
|
|
}
|
|
return out
|
|
}
|
|
|
|
# ---- manifest / lock parsing -------------------------------------------------
|
|
|
|
function parse_manifest(text: pointer) -> Manifest {
|
|
let m = manifest_new()
|
|
let n = slen(text)
|
|
var i = 0
|
|
while i < n {
|
|
let line = line_at(text, i)
|
|
i = i + slen(line) + 1
|
|
let ts = tok_line(line)
|
|
if len(ts) > 0 {
|
|
let head = ts[0]
|
|
if head == "package" { if len(ts) > 1 { m.module = ts[1] } }
|
|
else if head == "version" { if len(ts) > 1 { m.ver = ts[1] } }
|
|
else if head == "kind" { if len(ts) > 1 { m.kind = ts[1] } }
|
|
else if head == "provides" { var k = 1; while k < len(ts) { push(m.provides, ts[k]); k += 1 } }
|
|
else if head == "targets" { var k = 1; while k < len(ts) { push(m.targets, ts[k]); k += 1 } }
|
|
else if head == "require" {
|
|
if len(ts) > 2 { let d = new Dep; d.module = ts[1]; d.ver = ts[2]; push(m.deps, d) }
|
|
}
|
|
# `pack "assets/kit"` — an asset root that `ludic pack` walks into the
|
|
# .lpak. Repeatable, and the order is the order they are walked.
|
|
else if head == "pack" { var k = 1; while k < len(ts) { push(m.packs, ts[k]); k += 1 } }
|
|
# `app name "Maroon Lake"` — the metadata a macOS bundle is built from.
|
|
# Held as a flat key/value list rather than a property per key, so a new
|
|
# Info.plist field is a line in the bundler and nothing here.
|
|
else if head == "app" {
|
|
if len(ts) > 2 { push(m.app, ts[1]); push(m.app, ts[2]) }
|
|
}
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
# the value of one `app <key>` line, or "" when the manifest does not set it
|
|
function manifest_app(m: Manifest, key: pointer) -> pointer {
|
|
var i = 0
|
|
while i + 1 < len(m.app) {
|
|
if m.app[i] == key { return m.app[i + 1] }
|
|
i += 2
|
|
}
|
|
return ""
|
|
}
|
|
|
|
# read a package.lock.ludic body into a list of pinned entries
|
|
function parse_lock(text: pointer) -> []Manifest {
|
|
var out = new []Manifest
|
|
let n = slen(text)
|
|
var i = 0
|
|
while i < n {
|
|
let line = line_at(text, i)
|
|
i = i + slen(line) + 1
|
|
let ts = tok_line(line)
|
|
if len(ts) > 1 and ts[0] == "module" {
|
|
let m = manifest_new()
|
|
m.module = ts[1]
|
|
var k = 2
|
|
while k < len(ts) {
|
|
let key = ts[k]
|
|
if key == "version" and k + 1 < len(ts) { m.ver = ts[k + 1]; k += 2 }
|
|
else if key == "hash" and k + 1 < len(ts) { m.hash = ts[k + 1]; k += 2 }
|
|
else if key == "kind" and k + 1 < len(ts) { m.kind = ts[k + 1]; k += 2 }
|
|
else if key == "provides" and k + 1 < len(ts) { push(m.provides, ts[k + 1]); k += 2 }
|
|
else { k += 1 }
|
|
}
|
|
push(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
# ---- the content-addressed store & git fetch layer --------------------------
|
|
|
|
# the global store root: $LUDIC_STORE, else ~/.ludic/store. Trailing slash.
|
|
function store_root() -> pointer {
|
|
let s = getenv("LUDIC_STORE")
|
|
if s != null { return ensure_dir_slash(s) }
|
|
return ensure_dir_slash(`{getenv_or("HOME", "/tmp")}/.ludic/store`)
|
|
}
|
|
function ensure_dir_slash(d: pointer) -> pointer {
|
|
let n = slen(d)
|
|
if n == 0 { return d }
|
|
if d[n - 1] == '/' { return d }
|
|
return d + "/"
|
|
}
|
|
|
|
# the git URL for a module: a local $LUDIC_PKG_PROXY tree (used by tests and for
|
|
# mirrors/offline) keyed by the import path, else https://<import-path>.
|
|
function repo_url(module: pointer) -> pointer {
|
|
let proxy = getenv("LUDIC_PKG_PROXY")
|
|
if proxy != null { return `{proxy}/{module}` }
|
|
return `https://{module}`
|
|
}
|
|
|
|
# ensure a clone cache for `module` under the store; refresh tags if present.
|
|
# Returns the cache dir, or "" if the clone failed.
|
|
function ensure_clone(module: pointer) -> pointer {
|
|
let cache = `{store_root()}cache/{flat(module)}`
|
|
if file_exists(cache) {
|
|
shell(`git -C {cache} fetch -q --tags 2>/dev/null`)
|
|
return cache
|
|
}
|
|
shell(`mkdir -p {store_root()}cache`)
|
|
if not shq(`git clone -q {repo_url(module)} {cache} 2>/dev/null`) {
|
|
err(`ludic: cannot fetch {module} (git clone {repo_url(module)} failed)\n`)
|
|
return ""
|
|
}
|
|
return cache
|
|
}
|
|
|
|
# check out version `ver` in the cache — a v-prefixed tag first, then bare.
|
|
function checkout_ver(cache: pointer, ver: pointer) -> bool {
|
|
if shq(`git -C {cache} checkout -q v{ver} 2>/dev/null`) { return true }
|
|
return shq(`git -C {cache} checkout -q {ver} 2>/dev/null`)
|
|
}
|
|
|
|
# the greatest published version tag of `module` (normalised, no leading v)
|
|
function latest_version(module: pointer) -> pointer {
|
|
let cache = ensure_clone(module)
|
|
if cache == "" { return "" }
|
|
let lines = split_lines(capture(`git -C {cache} tag`))
|
|
var best = ""
|
|
var k = 0
|
|
while k < len(lines) {
|
|
let tg = lines[k]
|
|
if is_ver_tag(tg) {
|
|
let cand = strip_v(tg)
|
|
if best == "" or ver_gt(cand, best) { best = cand }
|
|
}
|
|
k += 1
|
|
}
|
|
return best
|
|
}
|
|
|
|
# read a package's manifest at `ver`; a package may omit package.ludic, in which
|
|
# case it is a leaf source package with no declared deps.
|
|
function fetch_manifest(module: pointer, ver: pointer) -> Manifest {
|
|
let cache = ensure_clone(module)
|
|
var m = manifest_new()
|
|
m.module = module
|
|
m.ver = ver
|
|
if cache == "" { return m }
|
|
if not checkout_ver(cache, ver) {
|
|
err(`ludic: {module} has no version {ver}\n`)
|
|
return m
|
|
}
|
|
let txt = read_file(`{cache}/package.ludic`)
|
|
if txt != null { m = parse_manifest(txt) }
|
|
m.module = module
|
|
m.ver = ver
|
|
return m
|
|
}
|
|
|
|
# the sha256 pick — shasum on macOS, sha256sum elsewhere. Both print "<hash> f".
|
|
function sha_cmd() -> pointer {
|
|
if shq("command -v shasum >/dev/null 2>&1") { return "shasum -a 256" }
|
|
return "sha256sum"
|
|
}
|
|
|
|
# a deterministic content hash of a directory tree (its files' names + bytes),
|
|
# excluding .git. Independent of file metadata, so it is stable across machines.
|
|
function dir_hash(dir: pointer) -> pointer {
|
|
let sc = sha_cmd()
|
|
return capture_line(`( cd {dir} && find . -type f -not -path './.git/*' | LC_ALL=C sort | while IFS= read -r f; do {sc} "$f"; done | {sc} | cut -d' ' -f1 )`)
|
|
}
|
|
|
|
# snapshot `module`@`ver` into the content-addressed store; returns its hash.
|
|
# The store entry is immutable and shared: if the hash is already present the
|
|
# copy is skipped. The .git directory is never stored.
|
|
function snapshot(module: pointer, ver: pointer) -> pointer {
|
|
let cache = ensure_clone(module)
|
|
if cache == "" { return "" }
|
|
if not checkout_ver(cache, ver) { return "" }
|
|
let h = dir_hash(cache)
|
|
if slen(h) == 0 { return "" }
|
|
let dest = `{store_root()}{h}`
|
|
if not file_exists(dest) {
|
|
shell(`rm -rf {dest}.tmp`)
|
|
shell(`cp -R {cache} {dest}.tmp`)
|
|
shell(`rm -rf {dest}.tmp/.git`)
|
|
shell(`mv {dest}.tmp {dest}`)
|
|
}
|
|
return h
|
|
}
|
|
|
|
# link a project's ludic_modules/<module> view at the store entry `hash`
|
|
function link_module(module: pointer, hash: pointer) -> void {
|
|
let dest = `{store_root()}{hash}`
|
|
let link = `ludic_modules/{module}`
|
|
shell(`mkdir -p "$(dirname {link})"`)
|
|
shell(`rm -rf {link}`)
|
|
shell(`ln -s {dest} {link}`)
|
|
}
|
|
|
|
# ---- MVS resolution ----------------------------------------------------------
|
|
|
|
function find_mod(mods: []pointer, m: pointer) -> int {
|
|
var i = 0
|
|
while i < len(mods) { if mods[i] == m { return i }; i += 1 }
|
|
return -1
|
|
}
|
|
|
|
# resolve a root manifest to the selected build list (one Manifest per module,
|
|
# each at its chosen version). MVS: a module's version is the greatest of every
|
|
# required minimum; the returned list is the reachable closure at those versions.
|
|
function resolve(root: Manifest) -> []Manifest {
|
|
var mods = new []pointer # module -> selected version (parallel arrays)
|
|
var vers = new []pointer
|
|
var fm = new []pointer # the current requirement frontier
|
|
var fv = new []pointer
|
|
var di = 0
|
|
while di < len(root.deps) { push(fm, root.deps[di].module); push(fv, root.deps[di].ver); di += 1 }
|
|
|
|
while len(fm) > 0 {
|
|
var nm = new []pointer
|
|
var nv = new []pointer
|
|
var i = 0
|
|
while i < len(fm) {
|
|
let m = fm[i]
|
|
let v = fv[i]
|
|
let idx = find_mod(mods, m)
|
|
var take = false
|
|
if idx < 0 { push(mods, m); push(vers, v); take = true }
|
|
else if ver_gt(v, vers[idx]) { vers[idx] = v; take = true }
|
|
if take {
|
|
let man = fetch_manifest(m, v) # expand the chosen version's requires
|
|
var k = 0
|
|
while k < len(man.deps) { push(nm, man.deps[k].module); push(nv, man.deps[k].ver); k += 1 }
|
|
}
|
|
i += 1
|
|
}
|
|
fm = nm
|
|
fv = nv
|
|
}
|
|
|
|
# reachability closure: keep only modules reachable from the root, each pinned
|
|
# to its selected version (a module required only by a version later bumped
|
|
# away drops out — the minimal MVS build list).
|
|
var out = new []Manifest
|
|
var seen = new []pointer
|
|
var q = new []pointer
|
|
var d2 = 0
|
|
while d2 < len(root.deps) { push(q, root.deps[d2].module); d2 += 1 }
|
|
var qi = 0
|
|
while qi < len(q) {
|
|
let m = q[qi]
|
|
qi += 1
|
|
if find_mod(seen, m) < 0 {
|
|
push(seen, m)
|
|
let idx = find_mod(mods, m)
|
|
if idx >= 0 {
|
|
let ver = vers[idx]
|
|
let man = fetch_manifest(m, ver)
|
|
push(out, man)
|
|
var k = 0
|
|
while k < len(man.deps) { push(q, man.deps[k].module); k += 1 }
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
# sort a build list by module path (stable enough — insertion sort)
|
|
function sort_mans(a: []Manifest) -> []Manifest {
|
|
var i = 1
|
|
while i < len(a) {
|
|
let x = a[i]
|
|
var j = i - 1
|
|
while j >= 0 and (a[j].module > x.module) { a[j + 1] = a[j]; j -= 1 }
|
|
a[j + 1] = x
|
|
i += 1
|
|
}
|
|
return a
|
|
}
|
|
|
|
# ---- namespace-collision policy (v1: hard error) ----------------------------
|
|
# Two packages may not both register the same Foo.* namespace. Returns "" when
|
|
# clean, else a human message naming the conflicting modules.
|
|
function collision(sels: []Manifest) -> pointer {
|
|
var ns = new []pointer
|
|
var owner = new []pointer
|
|
var i = 0
|
|
while i < len(sels) {
|
|
var p = 0
|
|
while p < len(sels[i].provides) {
|
|
let name = sels[i].provides[p]
|
|
let at = find_mod(ns, name)
|
|
if at >= 0 { return `namespace {name} provided by both {owner[at]} and {sels[i].module}` }
|
|
push(ns, name)
|
|
push(owner, sels[i].module)
|
|
p += 1
|
|
}
|
|
i += 1
|
|
}
|
|
return ""
|
|
}
|
|
|
|
# ---- lockfile writing --------------------------------------------------------
|
|
|
|
function write_lock(sels: []Manifest) -> bool {
|
|
var body = "# package.lock.ludic — generated by `ludic get`. Do not edit by hand." + nl()
|
|
body = body + "lock 1" + nl()
|
|
let sorted = sort_mans(sels)
|
|
var i = 0
|
|
while i < len(sorted) {
|
|
let m = sorted[i]
|
|
var line = `module "{m.module}" version "{m.ver}" hash "{m.hash}" kind "{m.kind}"`
|
|
var p = 0
|
|
while p < len(m.provides) { line += ` provides "{m.provides[p]}"`; p += 1 }
|
|
body = body + line + nl()
|
|
i += 1
|
|
}
|
|
return write_file("package.lock.ludic", body)
|
|
}
|
|
|
|
# ---- the shared get/install pipeline ----------------------------------------
|
|
# resolve -> collision check -> snapshot each into the store -> link the project
|
|
# view -> write the lockfile. Returns 0 on success.
|
|
function do_install(root: Manifest) -> int {
|
|
let sels = resolve(root)
|
|
if len(sels) == 0 {
|
|
print("no dependencies to resolve")
|
|
write_lock(sels)
|
|
return 0
|
|
}
|
|
let clash = collision(sels)
|
|
if slen(clash) > 0 { err(`ludic: namespace collision — {clash}\n`); return 1 }
|
|
|
|
var i = 0
|
|
while i < len(sels) {
|
|
let m = sels[i]
|
|
if m.kind == "prebuilt" {
|
|
let t = target_id()
|
|
if not has_target(m, t) {
|
|
err(`ludic: {m.module}@{m.ver} is a prebuilt lib and ships no artifact for target {t}\n`)
|
|
return 1
|
|
}
|
|
}
|
|
let h = snapshot(m.module, m.ver)
|
|
if slen(h) == 0 { err(`ludic: failed to snapshot {m.module}@{m.ver}\n`); return 1 }
|
|
m.hash = `sha256:{h}`
|
|
link_module(m.module, h)
|
|
print(` {m.module} {m.ver} ({m.kind}) sha256:{sslice(h, 0, 12)}…`)
|
|
i += 1
|
|
}
|
|
if not write_lock(sels) { err("ludic: cannot write package.lock.ludic\n"); return 1 }
|
|
print(`resolved {string(len(sels))} package(s) — see package.lock.ludic; linked under ludic_modules/`)
|
|
return 0
|
|
}
|
|
|
|
# the build target id a prebuilt lib is matched against
|
|
function target_id() -> pointer {
|
|
let t = getenv("LUDIC_TARGET")
|
|
if t != null { return t }
|
|
let os = capture_line("uname -s")
|
|
let arch = capture_line("uname -m")
|
|
if os == "Darwin" { return `native-{arch}` }
|
|
return `native-{arch}`
|
|
}
|
|
function has_target(m: Manifest, t: pointer) -> bool {
|
|
var i = 0
|
|
while i < len(m.targets) { if m.targets[i] == t { return true }; i += 1 }
|
|
return false
|
|
}
|
|
|
|
# ---- reading / editing the project manifest ---------------------------------
|
|
|
|
# the project manifest in the current directory, or a fresh default one
|
|
function read_root_manifest() -> Manifest {
|
|
let txt = read_file("package.ludic")
|
|
if txt == null { return manifest_new() }
|
|
return parse_manifest(txt)
|
|
}
|
|
|
|
# split "module@version" into a 2-element list [module, version]; version is ""
|
|
# when absent
|
|
function split_spec(spec: pointer) -> []pointer {
|
|
var out = new []pointer
|
|
let n = slen(spec)
|
|
var at = -1
|
|
var i = 0
|
|
while i < n { if spec[i] == '@' { at = i }; i += 1 } # '@' = 64
|
|
if at < 0 { push(out, spec); push(out, "") }
|
|
else { push(out, sslice(spec, 0, at)); push(out, sslice(spec, at + 1, n)) }
|
|
return out
|
|
}
|
|
|
|
# rewrite package.ludic so `module` requires exactly `ver`, creating the file
|
|
# with sane defaults when it does not exist yet.
|
|
function set_require(module: pointer, ver: pointer) -> void {
|
|
var txt = read_file("package.ludic")
|
|
if txt == null {
|
|
txt = "# package.ludic — Ludic package manifest" + nl()
|
|
txt = txt + `package "app"` + nl()
|
|
txt = txt + `version "0.0.0"` + nl()
|
|
txt = txt + "kind source" + nl()
|
|
}
|
|
var out = ""
|
|
let n = slen(txt)
|
|
var i = 0
|
|
var replaced = false
|
|
while i < n {
|
|
let line = line_at(txt, i)
|
|
i = i + slen(line) + 1
|
|
let ts = tok_line(line)
|
|
if len(ts) >= 2 and ts[0] == "require" and ts[1] == module {
|
|
out = out + `require "{module}" "{ver}"` + nl()
|
|
replaced = true
|
|
} else {
|
|
out = out + line + nl()
|
|
}
|
|
}
|
|
if not replaced { out = out + `require "{module}" "{ver}"` + nl() }
|
|
write_file("package.ludic", out)
|
|
}
|
|
|
|
# ---- the commands ------------------------------------------------------------
|
|
|
|
# ludic add <module>[@version] — add/update a dependency then install
|
|
function cmd_pkg_add() -> int {
|
|
if arg_count() < 3 { err("usage: ludic add <module>[@version]\n"); return 1 }
|
|
let spec = split_spec(arg(2))
|
|
let module = spec[0]
|
|
var ver = spec[1]
|
|
if slen(ver) == 0 {
|
|
ver = latest_version(module)
|
|
if slen(ver) == 0 { err(`ludic: {module} has no published version tags (git tag vX.Y.Z to publish)\n`); return 1 }
|
|
print(`ludic add: {module} -> latest v{ver}`)
|
|
}
|
|
set_require(module, ver)
|
|
return do_install(read_root_manifest())
|
|
}
|
|
|
|
# ludic get — resolve + fetch + link every dependency in package.ludic, write lock
|
|
function cmd_pkg_get() -> int {
|
|
let txt = read_file("package.ludic")
|
|
if txt == null { err("ludic: no package.ludic in the current directory (ludic add <module> to start one)\n"); return 1 }
|
|
print("resolving dependencies (MVS)…")
|
|
return do_install(parse_manifest(txt))
|
|
}
|
|
|
|
# ludic update [module] — bump a dep (or all) to its latest published version, relock
|
|
function cmd_pkg_update() -> int {
|
|
let root = read_root_manifest()
|
|
if len(root.deps) == 0 { err("ludic: package.ludic declares no dependencies\n"); return 1 }
|
|
let only = argn(2, "")
|
|
var i = 0
|
|
while i < len(root.deps) {
|
|
let d = root.deps[i]
|
|
if only == "" or only == d.module {
|
|
let latest = latest_version(d.module)
|
|
if slen(latest) > 0 and ver_gt(latest, d.ver) {
|
|
print(`ludic update: {d.module} {d.ver} -> {latest}`)
|
|
set_require(d.module, latest)
|
|
}
|
|
}
|
|
i += 1
|
|
}
|
|
return do_install(read_root_manifest())
|
|
}
|
|
|
|
# ludic verify — check every locked package against the store by content hash and
|
|
# confirm the project view links to it
|
|
function cmd_pkg_verify() -> int {
|
|
let txt = read_file("package.lock.ludic")
|
|
if txt == null { err("ludic: no package.lock.ludic (run ludic get first)\n"); return 1 }
|
|
let locked = parse_lock(txt)
|
|
if len(locked) == 0 { print("lockfile lists no packages"); return 0 }
|
|
var bad_count = 0
|
|
var i = 0
|
|
while i < len(locked) {
|
|
let m = locked[i]
|
|
let want = m.hash
|
|
let raw = strip_prefix(want, "sha256:")
|
|
let dest = `{store_root()}{raw}`
|
|
if not file_exists(dest) {
|
|
print(` MISSING {m.module}@{m.ver} (store entry {want} absent)`)
|
|
bad_count += 1
|
|
} else {
|
|
let got = dir_hash(dest)
|
|
if got == raw {
|
|
let link = `ludic_modules/{m.module}`
|
|
if file_exists(link) { print(` ok {m.module}@{m.ver}`) }
|
|
else { print(` UNLINKED {m.module}@{m.ver} (ludic_modules view missing — run ludic get)`); bad_count += 1 }
|
|
} else {
|
|
print(` TAMPERED {m.module}@{m.ver} (want sha256:{sslice(raw, 0, 12)}… got sha256:{sslice(got, 0, 12)}…)`)
|
|
bad_count += 1
|
|
}
|
|
}
|
|
i += 1
|
|
}
|
|
if bad_count == 0 { print(`verified {string(len(locked))} package(s) against the store`); return 0 }
|
|
err(`ludic: {string(bad_count)} package(s) failed verification\n`)
|
|
return 1
|
|
}
|
|
|
|
# ludic vendor — copy the resolved packages into ./vendor for hermetic/offline
|
|
# builds. Build against them with LUDIC_MODULES=vendor.
|
|
function cmd_pkg_vendor() -> int {
|
|
let txt = read_file("package.lock.ludic")
|
|
if txt == null { err("ludic: no package.lock.ludic (run ludic get first)\n"); return 1 }
|
|
let locked = parse_lock(txt)
|
|
shell("rm -rf vendor")
|
|
var i = 0
|
|
while i < len(locked) {
|
|
let m = locked[i]
|
|
let raw = strip_prefix(m.hash, "sha256:")
|
|
let dest = `{store_root()}{raw}`
|
|
if not file_exists(dest) { err(`ludic: {m.module}@{m.ver} not in the store — run ludic get\n`); return 1 }
|
|
let vdir = `vendor/{m.module}`
|
|
shell(`mkdir -p "$(dirname {vdir})"`)
|
|
shell(`cp -R {dest} {vdir}`)
|
|
print(` vendored {m.module}@{m.ver}`)
|
|
i += 1
|
|
}
|
|
print(`vendored {string(len(locked))} package(s) into ./vendor — build offline with LUDIC_MODULES=vendor`)
|
|
return 0
|
|
}
|
|
|
|
# ---- prebuilt binary packages (issue #64) -----------------------------------
|
|
|
|
# ludic build-lib [module.ludic] — compile a package's module to a prebuilt dylib for
|
|
# the current target, under lib/<target>/. The developer runs this in the package
|
|
# repo, commits the artifact, and `git tag`s — consumers fetch and link it.
|
|
# The compiler is $LUDICC (default bin/ludicc) so this works both in-repo and,
|
|
# with the flag set, from an installed toolchain.
|
|
function cmd_pkg_build_lib() -> int {
|
|
var src = argn(2, "")
|
|
# the manifest and the lockfile are not modules: match their names exactly,
|
|
# rather than as a regex where `.` also matched package.lock.ludic
|
|
if src == "" { src = capture_line("ls *.ludic 2>/dev/null | grep -vxF -e package.ludic -e package.lock.ludic | head -1") }
|
|
if src == "" or not file_exists(src) { err("usage: ludic build-lib <module.ludic> (run in the package directory)\n"); return 1 }
|
|
let man = read_root_manifest()
|
|
var name = ""
|
|
if man.module != "" { name = capture_line(`basename {man.module}`) }
|
|
if name == "" { name = capture_line(`basename {src} .ludic`) }
|
|
let t = target_id()
|
|
shell(`mkdir -p lib/{t}`)
|
|
let ludicc = getenv_or("LUDICC", "bin/ludicc")
|
|
let ll = `{tmp_dir()}/buildlib.ll`
|
|
let blerr = tmp_path("bl.err")
|
|
if not shq(`{ludicc} --emit-module {src} -o {ll} 2>{blerr}`) {
|
|
let why = capture_line(`tail -1 {blerr}`)
|
|
err(`ludic build-lib: compile failed — {why}\n`); return 1
|
|
}
|
|
let out = `lib/{t}/lib{name}.dylib`
|
|
# @rpath install name so a consumer resolves it via -rpath to the store dir
|
|
if not shq(`{cc()} -O2 -Wno-override-module -dynamiclib -undefined dynamic_lookup -Wl,-install_name,@rpath/lib{name}.dylib {ll} -o {out} 2>{blerr}`) {
|
|
let why2 = capture_line(`tail -1 {blerr}`)
|
|
err(`ludic build-lib: link failed — {why2}\n`); return 1
|
|
}
|
|
shell(`rm -f {ll}`)
|
|
print(`built {out} (target {t})`)
|
|
print(`add to package.ludic: kind prebuilt / targets "{t}" — then commit lib/ and git tag`)
|
|
return 0
|
|
}
|
|
|
|
# ludic link-flags — print the clang link flags a consumer build needs to pull in the
|
|
# project's prebuilt module dylibs (for the current target). Portable escape from
|
|
# `ludic build` (which is in-repo only): any build system can splice `$(ludic link-flags)`
|
|
# into its link step.
|
|
function cmd_pkg_link_flags() -> int {
|
|
let f = prebuilt_link_flags()
|
|
print(s_trim(f))
|
|
return 0
|
|
}
|
|
|
|
# clang link flags that pull every locked prebuilt package's dylib (for the
|
|
# current target) into a consumer build: the dylib itself, an rpath to its store
|
|
# directory, and -export_dynamic so the module resolves the host's ludic_* ABI at
|
|
# load. Empty when the project has no prebuilt dependencies.
|
|
function prebuilt_link_flags() -> pointer {
|
|
let txt = read_file("package.lock.ludic")
|
|
if txt == null { return "" }
|
|
let locked = parse_lock(txt)
|
|
let t = target_id()
|
|
var flags = ""
|
|
var any = false
|
|
var i = 0
|
|
while i < len(locked) {
|
|
let m = locked[i]
|
|
if m.kind == "prebuilt" {
|
|
let raw = strip_prefix(m.hash, "sha256:")
|
|
let dir = `{store_root()}{raw}/lib/{t}`
|
|
let libs = split_lines(capture(`ls {dir}/*.dylib 2>/dev/null`))
|
|
var k = 0
|
|
while k < len(libs) { flags = flags + " " + libs[k]; any = true; k += 1 }
|
|
if len(libs) > 0 { flags += ` -Wl,-rpath,{dir}` }
|
|
}
|
|
i += 1
|
|
}
|
|
if any { flags += " -Wl,-export_dynamic" }
|
|
return flags
|
|
}
|
|
|
|
# drop a leading `prefix` from `s` if present
|
|
function strip_prefix(s: pointer, prefix: pointer) -> pointer {
|
|
let pn = slen(prefix)
|
|
if pn <= slen(s) and s_starts(s, prefix) { return sslice(s, pn, slen(s)) }
|
|
return s
|
|
}
|