Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4047df286c | ||
|
|
5bf9a891f8 | ||
|
|
ed3e6aceb0 |
251
init.lua
251
init.lua
@@ -32,6 +32,130 @@
|
|||||||
|
|
||||||
local M = {}
|
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 /
|
-- Pure-Lua JSON decoder (strict subset: object / array / string /
|
||||||
-- number / true / false / null). Sufficient for manifest.lib /
|
-- number / true / false / null). Sufficient for manifest.lib /
|
||||||
@@ -269,9 +393,12 @@ local function id_to_path(lib_id)
|
|||||||
end
|
end
|
||||||
M._id_to_path = id_to_path
|
M._id_to_path = id_to_path
|
||||||
|
|
||||||
-- Locate a lib's manifest under <install_root>/libs/<id-path>/.
|
-- Find a lib's manifest across multiple candidate libs_dirs. Returns
|
||||||
local function lib_manifest_path(install_root, lib_id)
|
-- (full_path, owning_libs_dir) for the first candidate that exists, or
|
||||||
return install_root .. "/libs/" .. id_to_path(lib_id) .. "/manifest.lib"
|
-- (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
|
end
|
||||||
|
|
||||||
-- Gitea-slug derivation. Default: "sporel-" + lib-id verbatim.
|
-- Gitea-slug derivation. Default: "sporel-" + lib-id verbatim.
|
||||||
@@ -296,7 +423,7 @@ M._gitea_slug_for = gitea_slug_for
|
|||||||
-- Each closure entry records the (source, lib_id, version) pin that the
|
-- 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
|
-- closure walk observed; multi-pin entries for the same lib_id are kept
|
||||||
-- intact so detect_conflicts() can group them.
|
-- intact so detect_conflicts() can group them.
|
||||||
local function walk_closure(module_manifest_path, install_root)
|
local function walk_closure(module_manifest_path, libs_dirs)
|
||||||
local closure = {}
|
local closure = {}
|
||||||
local visited = {}
|
local visited = {}
|
||||||
|
|
||||||
@@ -318,7 +445,8 @@ local function walk_closure(module_manifest_path, install_root)
|
|||||||
})
|
})
|
||||||
if not visited[d.id] then
|
if not visited[d.id] then
|
||||||
visited[d.id] = true
|
visited[d.id] = true
|
||||||
local m = read_manifest(lib_manifest_path(install_root, d.id))
|
local mpath = find_lib_manifest(libs_dirs, d.id)
|
||||||
|
local m = mpath and read_manifest(mpath) or nil
|
||||||
if m and m.deps then
|
if m and m.deps then
|
||||||
for _, sub in ipairs(m.deps) do
|
for _, sub in ipairs(m.deps) do
|
||||||
table.insert(queue, { source = m.id, dep = sub })
|
table.insert(queue, { source = m.id, dep = sub })
|
||||||
@@ -377,17 +505,46 @@ M._detect_conflicts = detect_conflicts
|
|||||||
-- C — dirty : commits-past-tag or working-tree dirty → warn
|
-- C — dirty : commits-past-tag or working-tree dirty → warn
|
||||||
-- D — wrong-tag : describe != pinned-tag → silent checkout
|
-- D — wrong-tag : describe != pinned-tag → silent checkout
|
||||||
-- E — broken : describe fails entirely → error
|
-- E — broken : describe fails entirely → error
|
||||||
local function run_state_check(unique_closure, install_root,
|
-- 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)
|
gitea_base, warnings, errors)
|
||||||
local git = require("lib-core.git")
|
local git = require("lib-core.git")
|
||||||
|
|
||||||
for _, e in ipairs(unique_closure) do
|
for _, e in ipairs(unique_closure) do
|
||||||
local lib_dir = install_root .. "/libs/" .. id_to_path(e.lib_id)
|
-- 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 url = gitea_base .. "/" .. gitea_slug_for(e.lib_id) .. ".git"
|
||||||
local pinned = "v" .. e.version
|
local pinned = "v" .. e.version
|
||||||
|
|
||||||
if not git.is_repo(lib_dir) then
|
if not git.is_repo(lib_dir) then
|
||||||
-- Case A: clone @ pinned-tag.
|
-- 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)
|
local ok, gerr = git.clone(url, lib_dir, pinned)
|
||||||
if not ok then
|
if not ok then
|
||||||
table.insert(errors, {
|
table.insert(errors, {
|
||||||
@@ -396,15 +553,26 @@ local function run_state_check(unique_closure, install_root,
|
|||||||
message = tostring(gerr),
|
message = tostring(gerr),
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
end
|
||||||
else
|
else
|
||||||
local desc, derr = git.describe(lib_dir)
|
local desc, derr = git.describe(lib_dir)
|
||||||
if not desc then
|
if not desc then
|
||||||
-- Case E: broken.
|
-- 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, {
|
table.insert(errors, {
|
||||||
lib_id = e.lib_id,
|
lib_id = e.lib_id,
|
||||||
kind = "describe-fail",
|
kind = "describe-fail",
|
||||||
message = tostring(derr),
|
message = tostring(derr),
|
||||||
})
|
})
|
||||||
|
end
|
||||||
elseif desc == pinned then
|
elseif desc == pinned then
|
||||||
-- Case B: clean exact match.
|
-- Case B: clean exact match.
|
||||||
else
|
else
|
||||||
@@ -436,6 +604,7 @@ local function run_state_check(unique_closure, install_root,
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
::continue::
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -446,30 +615,36 @@ end
|
|||||||
-- ensure_for_module_at — full integrity check.
|
-- ensure_for_module_at — full integrity check.
|
||||||
-- module_manifest_path : absolute path to manifest.module
|
-- module_manifest_path : absolute path to manifest.module
|
||||||
-- opts:
|
-- opts:
|
||||||
-- install_root : root of <staged>/libs/ — defaults to env
|
-- install_root : root of <staged>/libs/. Resolution chain (Slice 6):
|
||||||
-- SPOREL_INSTALL_ROOT (slice 6: engine.install_root()).
|
-- 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
|
-- check_only : skip per-lib git operations entirely
|
||||||
-- (used by closure-shape unit tests).
|
-- (used by closure-shape unit tests).
|
||||||
-- gitea_base : override default base URL.
|
-- 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)
|
function M.ensure_for_module_at(module_manifest_path, opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
local install_root = opts.install_root
|
local install_root = resolve_install_root(opts)
|
||||||
or os.getenv("SPOREL_INSTALL_ROOT")
|
local libs_dirs = resolve_libs_dirs(opts, install_root)
|
||||||
if not install_root then
|
if #libs_dirs == 0 then
|
||||||
return {
|
return {
|
||||||
ok = false, conflicts = {}, warnings = {},
|
ok = false, conflicts = {}, warnings = {},
|
||||||
errors = {{
|
errors = {{
|
||||||
lib_id = "<module>",
|
lib_id = "<module>",
|
||||||
kind = "config",
|
kind = "config",
|
||||||
message = "no install_root: pass opts.install_root or " ..
|
message = "no libs_dir candidates: pass opts.libs_dir, set " ..
|
||||||
"set SPOREL_INSTALL_ROOT (slice 6 will add " ..
|
"SPOREL_LIBS_DIR / SPOREL_INSTALL_ROOT, or run " ..
|
||||||
"engine.install_root() binding)",
|
"under an engine that provides engine.libs_dirs() / " ..
|
||||||
|
"engine.install_root()",
|
||||||
}},
|
}},
|
||||||
closure = {},
|
closure = {},
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
local closure, werr = walk_closure(module_manifest_path, install_root)
|
local closure, werr = walk_closure(module_manifest_path, libs_dirs)
|
||||||
if not closure then
|
if not closure then
|
||||||
return {
|
return {
|
||||||
ok = false, conflicts = {}, warnings = {},
|
ok = false, conflicts = {}, warnings = {},
|
||||||
@@ -507,11 +682,10 @@ function M.ensure_for_module_at(module_manifest_path, opts)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local gitea_base = opts.gitea_base
|
local gitea_base = resolve_gitea_base(opts)
|
||||||
or "https://git.davoryn.de/sporel"
|
|
||||||
|
|
||||||
local warnings, errors = {}, {}
|
local warnings, errors = {}, {}
|
||||||
run_state_check(unique, install_root, gitea_base, warnings, errors)
|
run_state_check(unique, libs_dirs, gitea_base, warnings, errors)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok = (#errors == 0),
|
ok = (#errors == 0),
|
||||||
@@ -523,24 +697,41 @@ function M.ensure_for_module_at(module_manifest_path, opts)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Convenience wrapper resolving the module-id via install layout.
|
-- Convenience wrapper resolving the module-id via install layout.
|
||||||
-- Slice 6 will replace SPOREL_INSTALL_ROOT with engine.install_root().
|
-- 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)
|
function M.ensure_for_module(module_id, opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
local install_root = opts.install_root
|
local install_root = resolve_install_root(opts)
|
||||||
or os.getenv("SPOREL_INSTALL_ROOT")
|
local modules_dirs = resolve_modules_dirs(opts, install_root)
|
||||||
if not install_root then
|
if #modules_dirs == 0 then
|
||||||
return {
|
return {
|
||||||
ok = false, conflicts = {}, warnings = {},
|
ok = false, conflicts = {}, warnings = {},
|
||||||
errors = {{
|
errors = {{
|
||||||
lib_id = "<module>", kind = "config",
|
lib_id = "<module>", kind = "config",
|
||||||
message = "no install_root for ensure_for_module",
|
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 = {},
|
closure = {},
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
local path = install_root .. "/modules/" .. module_id
|
-- Find the first candidate that actually has this module.
|
||||||
.. "/manifest.module"
|
local path = first_existing(modules_dirs,
|
||||||
opts.install_root = install_root
|
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)
|
return M.ensure_for_module_at(path, opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"id": "lib-management.dep-fetcher",
|
"id": "lib-management.dep-fetcher",
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
"api_min": "0.1",
|
"api_min": "0.1",
|
||||||
"deps": [
|
"deps": [
|
||||||
{"id": "lib-core.git", "version": "0.1.0"}
|
{"id": "lib-core.git", "version": "0.2.0"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user