-- lib-management.dep-fetcher v0.1.0 -- -- Walks a module's transitive lib-dep closure, detects pin conflicts -- (hard-fail), and ensures each lib in the closure is at the pinned -- v tag via lib-core.git. Slice 5 of the dep-fetcher plan. -- -- Public API: -- ensure_for_module_at(manifest_path, opts) -> result -- ensure_for_module(module_id) -> result (slice 6 once -- engine.install_root -- is bound) -- -- result = { -- ok : bool, -- conflicts : [{lib_id, pins=[{source, version}, ...]}], -- warnings : [{lib_id, kind, ...}], -- errors : [{lib_id, kind, message}], -- closure : [{source, lib_id, version}], -- } -- -- Stop-gap notes (resolved in slice 6): -- - opts.install_root replaces a not-yet-existing engine.install_root() -- binding; falls back to SPOREL_INSTALL_ROOT env var. -- - gitea_base is hardcoded; future slice 6 will move it to engine.json -- + engine.config(key) binding. -- -- Per the dep-fetcher architecture (sporel-distribution-model.md), only -- vagrant + its transitive closure are public. Private repos (paid -- content via Steam, Gitea-SSO provisioning) fail with a clear network -- error during the slice-5 implementation — that is acceptable; auth -- plumbing lands in a later slice. local M = {} -- ===================================================================== -- Engine binding probes (Slice 6) -- -- Slice 6 of the dep-fetcher plan wired engine.install_root() and -- engine.config(key) as Lua-level bindings. We probe for them at -- call-time (not load-time) so this lib continues to load under -- engines that pre-date Slice 6 — the fallbacks (env var, hardcoded -- URL) keep slice-5-compat with unmodified hosts. -- ===================================================================== local function resolve_install_root(opts) -- 1. explicit override if opts and opts.install_root then return opts.install_root end -- 2. Slice 6 engine binding if type(engine) == "table" and type(engine.install_root) == "function" then local ok, root = pcall(engine.install_root) if ok and type(root) == "string" and root ~= "" then return root end end -- 3. Slice 5 stop-gap: env var return os.getenv("SPOREL_INSTALL_ROOT") end local function resolve_gitea_base(opts) -- 1. explicit override if opts and opts.gitea_base then return opts.gitea_base end -- 2. Slice 6 engine.config("gitea_base") (sourced from engine.json) if type(engine) == "table" and type(engine.config) == "function" then local ok, val = pcall(engine.config, "gitea_base") if ok and type(val) == "string" and val ~= "" then return val end end -- 3. Hardcoded slice-5 fallback return "https://git.davoryn.de/sporel" end M._resolve_install_root = resolve_install_root -- exposed for tests M._resolve_gitea_base = resolve_gitea_base -- exposed for tests -- ===================================================================== -- Pure-Lua JSON decoder (strict subset: object / array / string / -- number / true / false / null). Sufficient for manifest.lib / -- manifest.module files which are simple JSON. -- -- The engine does NOT expose a generic engine.json.decode binding yet -- (engine.asset.load_json is sandboxed to the asset-tree, which fixture -- paths sit outside of). Slice 6 may add engine.json.decode; until then -- we ship our own parser inline. ~80 lines, deterministic, no -- dependencies. -- ===================================================================== local function json_decode(text) local pos = 1 local len = #text local function err(msg) return nil, string.format("json: %s at offset %d", msg, pos) end local function skip_ws() while pos <= len do local c = text:byte(pos) -- ' ', '\t', '\n', '\r' if c == 32 or c == 9 or c == 10 or c == 13 then pos = pos + 1 else return end end end local parse_value -- forward decl local function parse_string() if text:byte(pos) ~= 34 then return err("expected '\"'") end pos = pos + 1 local start = pos local parts = nil -- accumulate only if escapes present while pos <= len do local c = text:byte(pos) if c == 34 then -- closing quote local out if parts then table.insert(parts, text:sub(start, pos - 1)) out = table.concat(parts) else out = text:sub(start, pos - 1) end pos = pos + 1 return out elseif c == 92 then -- backslash parts = parts or {} table.insert(parts, text:sub(start, pos - 1)) pos = pos + 1 local esc = text:byte(pos) if esc == 34 then table.insert(parts, '"') elseif esc == 92 then table.insert(parts, '\\') elseif esc == 47 then table.insert(parts, '/') elseif esc == 98 then table.insert(parts, '\b') elseif esc == 102 then table.insert(parts, '\f') elseif esc == 110 then table.insert(parts, '\n') elseif esc == 114 then table.insert(parts, '\r') elseif esc == 116 then table.insert(parts, '\t') elseif esc == 117 then -- \uXXXX — emit as raw UTF-8 for BMP codepoints. -- Surrogate pairs are not handled (manifests do -- not need them). local hex = text:sub(pos + 1, pos + 4) local code = tonumber(hex, 16) if not code then return err("invalid \\u escape") end if code < 0x80 then table.insert(parts, string.char(code)) elseif code < 0x800 then table.insert(parts, string.char( 0xC0 + math.floor(code / 0x40), 0x80 + (code % 0x40))) else table.insert(parts, string.char( 0xE0 + math.floor(code / 0x1000), 0x80 + math.floor(code / 0x40) % 0x40, 0x80 + (code % 0x40))) end pos = pos + 4 else return err("invalid escape") end pos = pos + 1 start = pos else pos = pos + 1 end end return err("unterminated string") end local function parse_number() local start = pos local c = text:byte(pos) if c == 45 then pos = pos + 1 end -- leading minus while pos <= len do c = text:byte(pos) -- digit / '.' / 'e' / 'E' / '+' / '-' if (c >= 48 and c <= 57) or c == 46 or c == 43 or c == 45 or c == 101 or c == 69 then pos = pos + 1 else break end end local n = tonumber(text:sub(start, pos - 1)) if not n then return err("invalid number") end return n end local function parse_literal() if text:sub(pos, pos + 3) == "true" then pos = pos + 4; return true elseif text:sub(pos, pos + 4) == "false" then pos = pos + 5; return false elseif text:sub(pos, pos + 3) == "null" then pos = pos + 4; return nil end return err("invalid literal") end local function parse_array() pos = pos + 1 -- consume '[' local arr = {} skip_ws() if pos <= len and text:byte(pos) == 93 then -- ']' pos = pos + 1; return arr end while pos <= len do skip_ws() local v, e = parse_value() if e then return nil, e end table.insert(arr, v) skip_ws() local c = text:byte(pos) if c == 44 then -- ',' pos = pos + 1 elseif c == 93 then -- ']' pos = pos + 1 return arr else return err("expected ',' or ']' in array") end end return err("unterminated array") end local function parse_object() pos = pos + 1 -- consume '{' local obj = {} skip_ws() if pos <= len and text:byte(pos) == 125 then -- '}' pos = pos + 1; return obj end while pos <= len do skip_ws() local k, e = parse_string() if not k then return nil, e end skip_ws() if text:byte(pos) ~= 58 then -- ':' return err("expected ':' in object") end pos = pos + 1 skip_ws() local v, e2 = parse_value() if e2 then return nil, e2 end obj[k] = v skip_ws() local c = text:byte(pos) if c == 44 then -- ',' pos = pos + 1 elseif c == 125 then -- '}' pos = pos + 1 return obj else return err("expected ',' or '}' in object") end end return err("unterminated object") end parse_value = function() skip_ws() if pos > len then return err("unexpected end of input") end local c = text:byte(pos) if c == 34 then return parse_string() elseif c == 123 then return parse_object() elseif c == 91 then return parse_array() elseif c == 116 or c == 102 or c == 110 then return parse_literal() elseif c == 45 or (c >= 48 and c <= 57) then return parse_number() else return err("unexpected character") end end skip_ws() local result, e = parse_value() if e then return nil, e end skip_ws() if pos <= len then return nil, string.format( "json: trailing data at offset %d", pos) end return result end M._json_decode = json_decode -- exposed for tests -- ===================================================================== -- Manifest reading -- ===================================================================== -- Reads a manifest.lib or manifest.module file from an absolute path. local function read_manifest(path) local f, err = io.open(path, "rb") if not f then return nil, err end local content = f:read("*a") f:close() local m, jerr = json_decode(content) if not m then return nil, jerr end return m end -- ===================================================================== -- Path / slug helpers -- ===================================================================== -- Convert "lib-core.maps" into "lib-core/maps" (filesystem-relative -- under /libs/). local function id_to_path(lib_id) return (lib_id:gsub("%.", "/")) end M._id_to_path = id_to_path -- Locate a lib's manifest under /libs//. local function lib_manifest_path(install_root, lib_id) return install_root .. "/libs/" .. id_to_path(lib_id) .. "/manifest.lib" end -- Gitea-slug derivation. Default: "sporel-" + lib-id verbatim. -- -- Slice-4 finding: Gitea rejects repo names ending in ".git" (the suffix -- is reserved by the git protocol). For libs whose ID ends in ".git" -- (currently: `lib-core.git`), the actual slug appends "-lib". Generic -- rule so future ".git"-suffixed libs (none planned) Just Work. local function gitea_slug_for(lib_id) if lib_id:sub(-4) == ".git" then return "sporel-" .. lib_id .. "-lib" end return "sporel-" .. lib_id end M._gitea_slug_for = gitea_slug_for -- ===================================================================== -- Closure walk -- ===================================================================== -- Walk the transitive dep closure starting from a module-manifest path. -- Each closure entry records the (source, lib_id, version) pin that the -- closure walk observed; multi-pin entries for the same lib_id are kept -- intact so detect_conflicts() can group them. local function walk_closure(module_manifest_path, install_root) local closure = {} local visited = {} local root, err = read_manifest(module_manifest_path) if not root then return nil, err end local queue = {} for _, d in ipairs(root.deps or {}) do table.insert(queue, { source = root.id, dep = d }) end while #queue > 0 do local entry = table.remove(queue, 1) local d = entry.dep table.insert(closure, { source = entry.source, lib_id = d.id, version = d.version, }) if not visited[d.id] then visited[d.id] = true local m = read_manifest(lib_manifest_path(install_root, d.id)) if m and m.deps then for _, sub in ipairs(m.deps) do table.insert(queue, { source = m.id, dep = sub }) end end -- If m is nil, the lib isn't installed yet — closure walk -- ignores that (we still record the pin from the parent -- so the state check downstream can clone the lib). end end return closure end M._walk_closure = walk_closure -- ===================================================================== -- Conflict detection -- ===================================================================== -- Returns a list of conflict-entries: {lib_id, pins=[{source, version}]}. -- A conflict exists when two parents pin the same lib to different -- versions. local function detect_conflicts(closure) local by_lib = {} for _, e in ipairs(closure) do by_lib[e.lib_id] = by_lib[e.lib_id] or {} table.insert(by_lib[e.lib_id], { source = e.source, version = e.version, }) end local conflicts = {} for lib_id, pins in pairs(by_lib) do local first = pins[1].version local mismatched = false for i = 2, #pins do if pins[i].version ~= first then mismatched = true break end end if mismatched then table.insert(conflicts, { lib_id = lib_id, pins = pins }) end end return conflicts end M._detect_conflicts = detect_conflicts -- ===================================================================== -- Per-lib state check (cases A-E from spec §5) -- ===================================================================== -- Cases (from sporel-meta/docs/superpowers/specs/2026-05-30-dep-fetcher- -- architecture.md §5): -- A — missing : lib dir does not exist → clone @ pinned-tag -- B — clean : repo present, describe == v → OK -- C — dirty : commits-past-tag or working-tree dirty → warn -- D — wrong-tag : describe != pinned-tag → silent checkout -- E — broken : describe fails entirely → error -- F — packaged : lib dir exists, no .git/, manifest.lib version -- matches pin → accept (cmake-staged install) local function manifest_version_matches(lib_dir, expected_version) local f = io.open(lib_dir .. "/manifest.lib", "rb") if not f then return false end local content = f:read("*a") f:close() local m, _ = json_decode(content) return m ~= nil and m.version == expected_version end local function run_state_check(unique_closure, install_root, gitea_base, warnings, errors) local git = require("lib-core.git") for _, e in ipairs(unique_closure) do local lib_dir = install_root .. "/libs/" .. id_to_path(e.lib_id) local url = gitea_base .. "/" .. gitea_slug_for(e.lib_id) .. ".git" local pinned = "v" .. e.version if not git.is_repo(lib_dir) then -- No .git dir present. Two sub-cases: -- F — packaged install: dir exists with a manifest.lib at -- the right version (cmake --install layout). Accept. -- A — missing: dir absent or manifest mismatch → clone. if manifest_version_matches(lib_dir, e.version) then -- Case F: packaged install, no action needed. else local ok, gerr = git.clone(url, lib_dir, pinned) if not ok then table.insert(errors, { lib_id = e.lib_id, kind = "net-fail", message = tostring(gerr), }) end end else local desc, derr = git.describe(lib_dir) if not desc then -- Case E: broken. table.insert(errors, { lib_id = e.lib_id, kind = "describe-fail", message = tostring(derr), }) elseif desc == pinned then -- Case B: clean exact match. else -- Distinguish C (dirty) from D (wrong-tag). "dirty" -- means describe is "--g[-dirty]" where -- matches the pinned version (commits past -- pinned-tag and/or working-tree dirty). local pin_escaped = pinned:gsub("([%.%-%+])", "%%%1") local is_dirty_past_tag = desc:match( "^" .. pin_escaped .. "%-%d+%-g") ~= nil local is_workdir_dirty = desc:sub(-6) == "-dirty" if is_dirty_past_tag or is_workdir_dirty then -- Case C: dirty — warn, do nothing. table.insert(warnings, { lib_id = e.lib_id, kind = "dirty", describe = desc, }) else -- Case D: at a different tag → silent checkout. local ok, gerr = git.checkout(lib_dir, pinned) if not ok then table.insert(errors, { lib_id = e.lib_id, kind = "checkout-fail", message = tostring(gerr), }) end end end end end end -- ===================================================================== -- Public API -- ===================================================================== -- ensure_for_module_at — full integrity check. -- module_manifest_path : absolute path to manifest.module -- opts: -- install_root : root of /libs/. Resolution chain (Slice 6): -- 1. opts.install_root (explicit override) -- 2. engine.install_root() if the binding is present -- 3. SPOREL_INSTALL_ROOT env var (slice-5 stop-gap) -- check_only : skip per-lib git operations entirely -- (used by closure-shape unit tests). -- gitea_base : override default base URL. Resolution chain: -- 1. opts.gitea_base -- 2. engine.config("gitea_base") if available -- 3. hardcoded https://git.davoryn.de/sporel fallback function M.ensure_for_module_at(module_manifest_path, opts) opts = opts or {} local install_root = resolve_install_root(opts) if not install_root then return { ok = false, conflicts = {}, warnings = {}, errors = {{ lib_id = "", kind = "config", message = "no install_root: pass opts.install_root, " .. "set SPOREL_INSTALL_ROOT, or run under an engine " .. "that provides engine.install_root()", }}, closure = {}, } end local closure, werr = walk_closure(module_manifest_path, install_root) if not closure then return { ok = false, conflicts = {}, warnings = {}, errors = {{ lib_id = "", kind = "manifest-read", message = tostring(werr), }}, closure = {}, } end local conflicts = detect_conflicts(closure) if #conflicts > 0 then return { ok = false, conflicts = conflicts, warnings = {}, errors = {}, closure = closure, } end if opts.check_only then return { ok = true, conflicts = {}, warnings = {}, errors = {}, closure = closure, } end -- Deduplicate by lib_id — conflict-free closure means same pin -- everywhere, so each lib needs exactly one state-check pass. local seen, unique = {}, {} for _, e in ipairs(closure) do if not seen[e.lib_id] then seen[e.lib_id] = true table.insert(unique, e) end end local gitea_base = resolve_gitea_base(opts) local warnings, errors = {}, {} run_state_check(unique, install_root, gitea_base, warnings, errors) return { ok = (#errors == 0), conflicts = {}, warnings = warnings, errors = errors, closure = closure, } end -- Convenience wrapper resolving the module-id via install layout. -- Resolves install_root via the same Slice 6 chain as ensure_for_module_at: -- explicit opts → engine.install_root() → SPOREL_INSTALL_ROOT env var. function M.ensure_for_module(module_id, opts) opts = opts or {} local install_root = resolve_install_root(opts) if not install_root then return { ok = false, conflicts = {}, warnings = {}, errors = {{ lib_id = "", kind = "config", message = "no install_root for ensure_for_module: " .. "pass opts.install_root, set SPOREL_INSTALL_ROOT, " .. "or run under an engine providing " .. "engine.install_root()", }}, closure = {}, } end local path = install_root .. "/modules/" .. module_id .. "/manifest.module" opts.install_root = install_root return M.ensure_for_module_at(path, opts) end return M