- resolve_libs_dirs / resolve_modules_dirs return arrays of candidate paths (priority: opts override > opts.install_root-derived > engine.libs_dirs() / modules_dirs() > SPOREL_*_DIR env > install_root-derived fallback). first_existing walks the array to find the candidate that actually contains the requested manifest, so dev-tree layouts with libs and modules in different roots work without forcing the caller to pre-flatten. - New case G: when describe fails because the repo has no tags at all (common during dev between tagged releases), accept the lib if its manifest.version matches the pin. Previously errored as "describe-fail" which made the pre-launch dep-check unusable in dev-tree runs. - Dep pin: lib-core.git 0.1.0 -> 0.2.0 (token-auth + set_token).
739 lines
28 KiB
Lua
739 lines
28 KiB
Lua
-- 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<X.Y.Z> 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
|
|
|
|
-- Slice 6.1: libs and modules can live in many roots simultaneously
|
|
-- (multi-path engine discovery, dev-tree SPOREL_*_DIR overrides). Each
|
|
-- resolver returns an ARRAY of candidate paths (priority-ordered);
|
|
-- callers iterate to find the first that contains the requested file.
|
|
--
|
|
-- Priority order:
|
|
-- 1. opts.libs_dir / opts.modules_dir explicit override (always wins)
|
|
-- 2. opts.install_root explicit override -> derive `<root>/{libs,modules}`
|
|
-- (tests pass install_root pointing at a fixture; the engine
|
|
-- bindings would otherwise return the real engine paths, masking
|
|
-- the fixture)
|
|
-- 3. engine.{libs,modules}_dirs() — runtime discovery candidates
|
|
-- 4. SPOREL_{LIBS,MODULES}_DIR env var
|
|
-- 5. Auto-resolved install_root -> `<root>/{libs,modules}` (last resort)
|
|
local function resolve_libs_dirs(opts, install_root)
|
|
local list = {}
|
|
if opts and opts.libs_dir then table.insert(list, opts.libs_dir) end
|
|
if opts and opts.install_root then
|
|
table.insert(list, opts.install_root .. "/libs")
|
|
end
|
|
if type(engine) == "table"
|
|
and type(engine.libs_dirs) == "function" then
|
|
local ok, dirs = pcall(engine.libs_dirs)
|
|
if ok and type(dirs) == "table" then
|
|
for _, d in ipairs(dirs) do
|
|
if type(d) == "string" and d ~= "" then
|
|
table.insert(list, d)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
local env = os.getenv("SPOREL_LIBS_DIR")
|
|
if env and env ~= "" then table.insert(list, env) end
|
|
if install_root then table.insert(list, install_root .. "/libs") end
|
|
return list
|
|
end
|
|
|
|
local function resolve_modules_dirs(opts, install_root)
|
|
local list = {}
|
|
if opts and opts.modules_dir then table.insert(list, opts.modules_dir) end
|
|
if opts and opts.install_root then
|
|
table.insert(list, opts.install_root .. "/modules")
|
|
end
|
|
if type(engine) == "table"
|
|
and type(engine.modules_dirs) == "function" then
|
|
local ok, dirs = pcall(engine.modules_dirs)
|
|
if ok and type(dirs) == "table" then
|
|
for _, d in ipairs(dirs) do
|
|
if type(d) == "string" and d ~= "" then
|
|
table.insert(list, d)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
local env = os.getenv("SPOREL_MODULES_DIR")
|
|
if env and env ~= "" then table.insert(list, env) end
|
|
if install_root then table.insert(list, install_root .. "/modules") end
|
|
return list
|
|
end
|
|
|
|
-- Helper: filesystem existence check via io.open. Used to pick the
|
|
-- first multi-path candidate that actually has the requested file.
|
|
local function file_exists(path)
|
|
local f = io.open(path, "rb")
|
|
if not f then return false end
|
|
f:close()
|
|
return true
|
|
end
|
|
|
|
-- Given a list of candidate dirs and a relative file-path, return the
|
|
-- first <dir>/<rel> that exists, or nil if none.
|
|
local function first_existing(dirs, rel_path)
|
|
for _, d in ipairs(dirs) do
|
|
local p = d .. "/" .. rel_path
|
|
if file_exists(p) then return p, d end
|
|
end
|
|
return nil, nil
|
|
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_libs_dirs = resolve_libs_dirs -- exposed for tests
|
|
M._resolve_modules_dirs = resolve_modules_dirs -- 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 <install>/libs/).
|
|
local function id_to_path(lib_id)
|
|
return (lib_id:gsub("%.", "/"))
|
|
end
|
|
M._id_to_path = id_to_path
|
|
|
|
-- Find a lib's manifest across multiple candidate libs_dirs. Returns
|
|
-- (full_path, owning_libs_dir) for the first candidate that exists, or
|
|
-- (nil, nil) if absent everywhere.
|
|
local function find_lib_manifest(libs_dirs, lib_id)
|
|
return first_existing(libs_dirs,
|
|
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, libs_dirs)
|
|
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 mpath = find_lib_manifest(libs_dirs, d.id)
|
|
local m = mpath and read_manifest(mpath) or nil
|
|
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<X.Y.Z> → 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, libs_dirs,
|
|
gitea_base, warnings, errors)
|
|
local git = require("lib-core.git")
|
|
|
|
for _, e in ipairs(unique_closure) do
|
|
-- Resolve the lib's home: first libs_dir that contains its
|
|
-- manifest. Falls back to the first candidate so clone targets
|
|
-- a deterministic location when the lib is missing entirely.
|
|
local _, owning_dir = find_lib_manifest(libs_dirs, e.lib_id)
|
|
local libs_dir = owning_dir or libs_dirs[1]
|
|
if not libs_dir then
|
|
table.insert(errors, {
|
|
lib_id = e.lib_id, kind = "config",
|
|
message = "no libs_dir candidate for state-check",
|
|
})
|
|
goto continue
|
|
end
|
|
local lib_dir = libs_dir .. "/" .. 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
|
|
-- describe failed. Two sub-cases:
|
|
-- G — untagged dev-state: repo has no tags at all (common
|
|
-- when working on master between tagged releases).
|
|
-- If manifest.version matches pin, the install IS at
|
|
-- the right code regardless of git refs → accept.
|
|
-- E — genuinely broken: manifest mismatch + no tags →
|
|
-- caller has no way to recover, surface error.
|
|
if manifest_version_matches(lib_dir, e.version) then
|
|
-- Case G: untagged but manifest authoritative — accept.
|
|
else
|
|
table.insert(errors, {
|
|
lib_id = e.lib_id,
|
|
kind = "describe-fail",
|
|
message = tostring(derr),
|
|
})
|
|
end
|
|
elseif desc == pinned then
|
|
-- Case B: clean exact match.
|
|
else
|
|
-- Distinguish C (dirty) from D (wrong-tag). "dirty"
|
|
-- means describe is "<tag>-<n>-g<sha>[-dirty]" where
|
|
-- <tag> 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
|
|
::continue::
|
|
end
|
|
end
|
|
|
|
-- =====================================================================
|
|
-- Public API
|
|
-- =====================================================================
|
|
|
|
-- ensure_for_module_at — full integrity check.
|
|
-- module_manifest_path : absolute path to manifest.module
|
|
-- opts:
|
|
-- install_root : root of <staged>/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)
|
|
local libs_dirs = resolve_libs_dirs(opts, install_root)
|
|
if #libs_dirs == 0 then
|
|
return {
|
|
ok = false, conflicts = {}, warnings = {},
|
|
errors = {{
|
|
lib_id = "<module>",
|
|
kind = "config",
|
|
message = "no libs_dir candidates: pass opts.libs_dir, set " ..
|
|
"SPOREL_LIBS_DIR / SPOREL_INSTALL_ROOT, or run " ..
|
|
"under an engine that provides engine.libs_dirs() / " ..
|
|
"engine.install_root()",
|
|
}},
|
|
closure = {},
|
|
}
|
|
end
|
|
|
|
local closure, werr = walk_closure(module_manifest_path, libs_dirs)
|
|
if not closure then
|
|
return {
|
|
ok = false, conflicts = {}, warnings = {},
|
|
errors = {{
|
|
lib_id = "<module>",
|
|
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, libs_dirs, 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)
|
|
local modules_dirs = resolve_modules_dirs(opts, install_root)
|
|
if #modules_dirs == 0 then
|
|
return {
|
|
ok = false, conflicts = {}, warnings = {},
|
|
errors = {{
|
|
lib_id = "<module>", kind = "config",
|
|
message = "no modules_dir candidates for ensure_for_module: " ..
|
|
"pass opts.modules_dir, set SPOREL_MODULES_DIR / " ..
|
|
"SPOREL_INSTALL_ROOT, or run under an engine " ..
|
|
"providing engine.modules_dirs() / engine.install_root()",
|
|
}},
|
|
closure = {},
|
|
}
|
|
end
|
|
-- Find the first candidate that actually has this module.
|
|
local path = first_existing(modules_dirs,
|
|
module_id .. "/manifest.module")
|
|
if not path then
|
|
return {
|
|
ok = false, conflicts = {}, warnings = {},
|
|
errors = {{
|
|
lib_id = "<module>", kind = "manifest-read",
|
|
message = "module '" .. module_id ..
|
|
"' manifest not found in any of " ..
|
|
tostring(#modules_dirs) .. " candidate dirs",
|
|
}},
|
|
closure = {},
|
|
}
|
|
end
|
|
if install_root then opts.install_root = install_root end
|
|
return M.ensure_for_module_at(path, opts)
|
|
end
|
|
|
|
return M
|