@awebai/oats 0.24.13 → 0.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/oats.mjs +994 -2837
- package/docs/capabilities.md +136 -323
- package/docs/configuration.md +68 -533
- package/docs/conventions.md +51 -24
- package/docs/design/2026-09-16-fresh-operator-walkthrough.md +19 -14
- package/docs/design/2026-09-16-portable-migration-evidence.md +2 -0
- package/docs/design/2026-09-16-portable-onboarding.md +4 -2
- package/docs/design/2026-09-20-redesign-program-board.md +1 -1
- package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +2 -0
- package/docs/design/2026-09-23-simplified-workspace-model.md +711 -0
- package/docs/design/2026-09-23-workspace-module-contracts.md +460 -0
- package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +65 -0
- package/docs/design/README.md +20 -8
- package/docs/design/operations-contract.md +1 -0
- package/docs/design/package-engine-contract.md +1 -1
- package/docs/design/package-runtime-api.md +1 -1
- package/docs/desktop-cli-api.md +356 -5
- package/docs/desktop-succession.md +12 -6
- package/docs/desktop.md +9 -4
- package/docs/execution-targets.md +16 -4
- package/docs/first-team.md +107 -224
- package/docs/implementation.md +41 -11
- package/docs/integrations.md +50 -47
- package/docs/knowledge-capability-authoring.md +10 -4
- package/docs/knowledge-migration.md +21 -12
- package/docs/knowledge-reference/package-craft.md +11 -3
- package/docs/knowledge.md +60 -18
- package/docs/layers.md +3 -3
- package/docs/migration-from-oas.md +20 -9
- package/docs/oats-local.schema.json +50 -0
- package/docs/oats-membership.schema.json +23 -0
- package/docs/oats-workspace.schema.json +133 -48
- package/docs/official-marketplace.md +9 -6
- package/docs/packages.md +229 -440
- package/docs/rebuild-to-v2.md +347 -0
- package/docs/release-notes/v0.25.0.md +99 -0
- package/docs/release-notes/v0.25.1.md +94 -0
- package/docs/schedules.md +12 -6
- package/docs/soul.schema.json +41 -68
- package/docs/souls-and-instances.md +175 -108
- package/docs/workspace-adoption.md +70 -345
- package/docs/workspaces.md +436 -119
- package/lib/core.mjs +462 -61
- package/lib/instance-resolution.mjs +387 -0
- package/lib/materialize.mjs +580 -0
- package/lib/operator-dispatch.mjs +117 -0
- package/lib/packages.mjs +558 -1269
- package/lib/remote.mjs +718 -0
- package/lib/resolve.mjs +638 -0
- package/lib/schedule.mjs +90 -16
- package/lib/workspace.mjs +654 -0
- package/package.json +1 -1
- package/lib/portable-migration-artifacts.mjs +0 -135
- package/lib/portable-migration-evidence.mjs +0 -305
- package/lib/portable-migration-store.mjs +0 -199
- package/lib/portable-migration.mjs +0 -104
- package/lib/portable-onboarding-acceptance.mjs +0 -66
- package/lib/setup-expert-source.mjs +0 -100
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
/** lib/materialize.mjs — copy whole, compose, record (workspace model v2, module contract §5).
|
|
2
|
+
*
|
|
3
|
+
* Supersedes the module/skill assembly in core.mjs spawn (`prepared` copies, symlinked
|
|
4
|
+
* skill sets, ambient exclusion) and capability-artifacts.mjs.
|
|
5
|
+
*
|
|
6
|
+
* materialize(resolution, home, options)
|
|
7
|
+
* For every module of the resolution: fetch its capability directory whole into
|
|
8
|
+
* <home>/.oats/modules/<name>/, verify the copy's content digest, copy its skills as
|
|
9
|
+
* FULL copies into <home>/.agents/skills/<name>/<skill>/, compose <home>/AGENTS.md
|
|
10
|
+
* (soul body + each module's inject, same marker comments as the kernel composer),
|
|
11
|
+
* keep the CLAUDE.md / .claude/skills aliases, and record modules + providers in
|
|
12
|
+
* <home>/instance.json.
|
|
13
|
+
*
|
|
14
|
+
* TRANSACTION: everything is built under <home>/.oats/.staging-<pid>-<random>/ (unique per
|
|
15
|
+
* call, so two materializations of one home never share staging) and then renamed into
|
|
16
|
+
* place. Any failure before the commit removes the staging directory and leaves the home
|
|
17
|
+
* exactly as it was (a `.oats/` directory created only for staging is removed too). The
|
|
18
|
+
* commit re-checks the home's shape (no symlink planted at .oats/.agents/.claude or the
|
|
19
|
+
* module targets during the fetch), then runs a short sequence of renames — modules,
|
|
20
|
+
* skills, AGENTS.md, instance.json, and the CLAUDE.md / .claude/skills aliases — with a
|
|
21
|
+
* rollback journal: should any step fail, everything already placed is undone and the
|
|
22
|
+
* previous AGENTS.md / instance.json restored before a named error (E_MATERIALIZE_HOME)
|
|
23
|
+
* propagates. A second materialization racing on the same home surfaces as
|
|
24
|
+
* E_MATERIALIZE_HOME { why: "busy" }, never a raw ENOTEMPTY.
|
|
25
|
+
*
|
|
26
|
+
* SOURCES. A module is fetched from its repository at `from.commit` at the capability
|
|
27
|
+
* directory the RESOLVER recorded (`module.dir`: the manifest-listed directory — for a
|
|
28
|
+
* package, the `oats-package.json#capabilities[]` entry, which need not equal the
|
|
29
|
+
* capability name: oats-package/capabilities/oats-okf → oats.okf); without `module.dir`,
|
|
30
|
+
* `capabilities/<name>` (member) or `<lock.path>/capabilities/<name>` (package) is assumed.
|
|
31
|
+
* A package module's `from.commit` must equal the lock entry's `commit` when a lock is
|
|
32
|
+
* given (E_MATERIALIZE_INTEGRITY why:lock); the lock, when given, must be v3 (E_LOCK_SCHEMA).
|
|
33
|
+
* The repository of a package is `from.repoKey` when the resolver recorded it, else the
|
|
34
|
+
* lock entry's `url`, else derived from its `source` ("git:<key>@<ref>" directly;
|
|
35
|
+
* "catalog:<id>" through `options.catalog[id].url`). A repository that cannot be
|
|
36
|
+
* determined is E_MATERIALIZE_SOURCE — nothing is guessed.
|
|
37
|
+
*
|
|
38
|
+
* INTEGRITY. `fetch` returns the digest of what it wrote; `remote.contentDigest(dest)` is
|
|
39
|
+
* recomputed over the bytes on disk and must agree (E_MATERIALIZE_INTEGRITY), as must
|
|
40
|
+
* `module.digest` when the resolver pinned one. The verified digest is what gets recorded.
|
|
41
|
+
*
|
|
42
|
+
* SAFETY. Module names use the capabilityName grammar; skill directory names are single
|
|
43
|
+
* safe path components; every skill/inject path resolves under its module root; symlinks
|
|
44
|
+
* are never copied (the fetch already refuses them; the copy refuses them again).
|
|
45
|
+
*
|
|
46
|
+
* driftOf(instanceJson, discovery, { lock })
|
|
47
|
+
* Compares each recorded module with the workspace's current picture → current | moved |
|
|
48
|
+
* missing ("missing" = repo not confirmed, or the capability no longer present).
|
|
49
|
+
*/
|
|
50
|
+
import {
|
|
51
|
+
chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync,
|
|
52
|
+
readlinkSync, rmdirSync, statSync, symlinkSync, writeFileSync,
|
|
53
|
+
} from "node:fs";
|
|
54
|
+
import { isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
55
|
+
import { randomBytes } from "node:crypto";
|
|
56
|
+
import { oatsError } from "./errors.mjs";
|
|
57
|
+
import * as defaultRemote from "./remote.mjs";
|
|
58
|
+
import { renderInstructionText } from "./instruction-composition.mjs";
|
|
59
|
+
import { DEFAULT_PACKAGE_PATH, validateLock } from "./packages.mjs";
|
|
60
|
+
|
|
61
|
+
export const MODULES_DIR = join(".oats", "modules");
|
|
62
|
+
export const SKILLS_DIR = join(".agents", "skills");
|
|
63
|
+
const OID_RE = /^[0-9a-f]{40}$/;
|
|
64
|
+
const DIGEST_RE = /^sha256-[0-9a-f]{64}$/;
|
|
65
|
+
const CAPABILITY_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
66
|
+
/** A single directory-entry name we are willing to create: not empty, not "."/"..", no separators/NUL. */
|
|
67
|
+
const SAFE_COMPONENT_RE = /^(?!\.+$)[^/\\\x00]+$/;
|
|
68
|
+
|
|
69
|
+
/** oatsError with the details reachable as BOTH e.provenance (today's field) and e.details (Phase A convention). */
|
|
70
|
+
function fail(code, message, details) {
|
|
71
|
+
const e = oatsError(code, message, details);
|
|
72
|
+
if (details) e.details = details;
|
|
73
|
+
return e;
|
|
74
|
+
}
|
|
75
|
+
const plainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
76
|
+
const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
|
|
77
|
+
|
|
78
|
+
/* ───────────────────────────── validation ─────────────────────────────── */
|
|
79
|
+
|
|
80
|
+
function assertResolution(resolution) {
|
|
81
|
+
const bad = (path, why) => fail("E_MATERIALIZE_RESOLUTION", `resolution ${path}: ${why}`, { path });
|
|
82
|
+
if (!plainObject(resolution)) throw bad("/", "must be an object (lib/resolve.mjs#resolveSoul output)");
|
|
83
|
+
if (resolution.resolutionApi !== 1) throw bad("/resolutionApi", `must be 1 (found ${JSON.stringify(resolution.resolutionApi)})`);
|
|
84
|
+
if (!Array.isArray(resolution.modules)) throw bad("/modules", "must be an array");
|
|
85
|
+
const seen = new Set();
|
|
86
|
+
resolution.modules.forEach((m, i) => {
|
|
87
|
+
const at = `/modules/${i}`;
|
|
88
|
+
if (!plainObject(m)) throw bad(at, "must be an object");
|
|
89
|
+
if (typeof m.name !== "string" || !CAPABILITY_NAME_RE.test(m.name)) throw bad(`${at}/name`, `must match ${CAPABILITY_NAME_RE} (found ${JSON.stringify(m.name)})`);
|
|
90
|
+
if (seen.has(m.name)) throw bad(`${at}/name`, `duplicate module ${JSON.stringify(m.name)}`);
|
|
91
|
+
seen.add(m.name);
|
|
92
|
+
if (!plainObject(m.from)) throw bad(`${at}/from`, "must be an object");
|
|
93
|
+
if (m.from.kind !== "member" && m.from.kind !== "package") throw bad(`${at}/from/kind`, 'must be "member" or "package"');
|
|
94
|
+
if (m.from.kind === "member" && (typeof m.from.repoKey !== "string" || !m.from.repoKey)) throw bad(`${at}/from/repoKey`, "must be a repo key");
|
|
95
|
+
if (m.from.kind === "package" && (typeof m.from.package !== "string" || !m.from.package)) throw bad(`${at}/from/package`, "must be a package id");
|
|
96
|
+
if (typeof m.from.commit !== "string" || !OID_RE.test(m.from.commit)) throw bad(`${at}/from/commit`, "must be a full 40-hex OID");
|
|
97
|
+
if (m.digest !== undefined && m.digest !== null && (typeof m.digest !== "string" || !DIGEST_RE.test(m.digest))) throw bad(`${at}/digest`, "must be sha256-<hex> when present");
|
|
98
|
+
if (m.manifest !== undefined && !plainObject(m.manifest)) throw bad(`${at}/manifest`, "must be an object when present");
|
|
99
|
+
if (m.dir !== undefined && m.dir !== null && (typeof m.dir !== "string" || !m.dir)) throw bad(`${at}/dir`, "must be a non-empty repo-relative path when present");
|
|
100
|
+
});
|
|
101
|
+
if (resolution.skills !== undefined && !Array.isArray(resolution.skills)) throw bad("/skills", "must be an array when present");
|
|
102
|
+
if (resolution.injects !== undefined && !Array.isArray(resolution.injects)) throw bad("/injects", "must be an array when present");
|
|
103
|
+
if (resolution.payloads !== undefined && !plainObject(resolution.payloads)) throw bad("/payloads", "must be an object when present");
|
|
104
|
+
return resolution;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A path declared by a manifest/resolution, relative to the module root: normalized, contained. */
|
|
108
|
+
function modulePath(text, module, what) {
|
|
109
|
+
if (typeof text !== "string" || !text.trim()) throw fail("E_MATERIALIZE_RESOLUTION", `module ${module}: ${what} path must be a non-empty string`, { module, what, path: text });
|
|
110
|
+
const rel = posix.normalize(text.trim().replace(/\\/g, "/")).replace(/^\.\/+/, "").replace(/\/+$/, "");
|
|
111
|
+
if (!rel || rel === "." || rel.startsWith("../") || rel === ".." || posix.isAbsolute(rel) || rel.split("/").some((c) => !SAFE_COMPONENT_RE.test(c))) {
|
|
112
|
+
throw fail("E_MATERIALIZE_RESOLUTION", `module ${module}: ${what} path ${JSON.stringify(text)} does not stay inside the module`, { module, what, path: text });
|
|
113
|
+
}
|
|
114
|
+
return rel;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* ───────────────────────────── sources ─────────────────────────────── */
|
|
118
|
+
|
|
119
|
+
/** The ref string lib/remote.mjs#parseRepoRef understands for a canonical repo key. */
|
|
120
|
+
export function refForKey(key) {
|
|
121
|
+
if (typeof key !== "string" || !key) throw fail("E_REPO_REF", "repo key must be a non-empty string", { key });
|
|
122
|
+
if (key.startsWith("local/")) return key.slice("local/".length);
|
|
123
|
+
return `git:${key}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function packageRepoKey(module, entry, catalog, remote) {
|
|
127
|
+
if (typeof module.from.repoKey === "string" && module.from.repoKey) return module.from.repoKey;
|
|
128
|
+
if (!entry) return null;
|
|
129
|
+
if (typeof entry.url === "string" && entry.url) return remote.parseRepoRef(entry.url).key;
|
|
130
|
+
const source = String(entry.source || "");
|
|
131
|
+
if (source.startsWith("git:")) {
|
|
132
|
+
const at = source.lastIndexOf("@");
|
|
133
|
+
return at > 4 ? source.slice(4, at) : source.slice(4);
|
|
134
|
+
}
|
|
135
|
+
if (source.startsWith("catalog:")) {
|
|
136
|
+
const c = catalog?.[module.from.package];
|
|
137
|
+
if (c && typeof c.url === "string" && c.url) return remote.parseRepoRef(c.url).key;
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A repo-relative directory recorded by the resolver (module.dir): normalized, contained, no odd components. */
|
|
143
|
+
function repoRelativeDir(text, module) {
|
|
144
|
+
const rel = posix.normalize(String(text).replace(/\\/g, "/")).replace(/^\.\/+/, "").replace(/\/+$/, "");
|
|
145
|
+
if (!rel || rel === "." || rel === ".." || rel.startsWith("../") || posix.isAbsolute(rel) || rel.split("/").some((c) => !SAFE_COMPONENT_RE.test(c) || /^\.git$/i.test(c))) {
|
|
146
|
+
throw fail("E_MATERIALIZE_RESOLUTION", `module ${module}: dir ${JSON.stringify(text)} is not a relative path inside the repository`, { module, path: text });
|
|
147
|
+
}
|
|
148
|
+
return rel;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Where a module is fetched from → { key, ref, commit, dir }.
|
|
152
|
+
* The capability directory is the one the resolver recorded (`module.dir`, i.e. the manifest-listed
|
|
153
|
+
* directory — a package's `oats-package.json#capabilities[]` entry, a member's `capabilities/<dir>`);
|
|
154
|
+
* only without it is `capabilities/<name>` assumed. The two differ for real packages (oats-okf → oats.okf). */
|
|
155
|
+
function moduleSource(module, { lock, catalog, remote }) {
|
|
156
|
+
const name = module.name, from = module.from;
|
|
157
|
+
const recordedDir = typeof module.dir === "string" && module.dir ? repoRelativeDir(module.dir, name) : null;
|
|
158
|
+
if (from.kind === "member") {
|
|
159
|
+
return { key: from.repoKey, ref: refForKey(from.repoKey), commit: from.commit, dir: recordedDir ?? `capabilities/${name}` };
|
|
160
|
+
}
|
|
161
|
+
if (lock !== undefined && lock !== null) validateLock(lock); // E_LOCK_SCHEMA
|
|
162
|
+
const entry = plainObject(lock?.packages) ? lock.packages[from.package] ?? null : null;
|
|
163
|
+
if (entry && entry.commit !== from.commit) {
|
|
164
|
+
throw fail("E_MATERIALIZE_INTEGRITY", `module ${name}: resolution pins package ${from.package} at ${from.commit} but the lock records ${entry.commit}`,
|
|
165
|
+
{ module: name, package: from.package, resolved: from.commit, locked: entry.commit, why: "lock" });
|
|
166
|
+
}
|
|
167
|
+
const key = packageRepoKey(module, entry, catalog, remote);
|
|
168
|
+
if (!key) {
|
|
169
|
+
throw fail("E_MATERIALIZE_SOURCE", `module ${name}: cannot determine the repository of package ${from.package} (no from.repoKey, and the lock/catalog do not name one)`,
|
|
170
|
+
{ module: name, package: from.package, lockSource: entry?.source ?? null });
|
|
171
|
+
}
|
|
172
|
+
const path = typeof from.path === "string" && from.path ? from.path : (typeof entry?.path === "string" && entry.path ? entry.path : DEFAULT_PACKAGE_PATH);
|
|
173
|
+
const pkgPath = posix.normalize(path.replace(/\\/g, "/")).replace(/^\.\/+/, "").replace(/\/+$/, "");
|
|
174
|
+
if (!pkgPath || pkgPath === "." || pkgPath.startsWith("..") || posix.isAbsolute(pkgPath)) {
|
|
175
|
+
throw fail("E_MATERIALIZE_SOURCE", `module ${name}: package path ${JSON.stringify(path)} is not a relative path inside the repository`, { module: name, package: from.package, path });
|
|
176
|
+
}
|
|
177
|
+
if (recordedDir && recordedDir !== pkgPath && !recordedDir.startsWith(`${pkgPath}/`)) {
|
|
178
|
+
throw fail("E_MATERIALIZE_SOURCE", `module ${name}: recorded dir ${recordedDir} is outside the package path ${pkgPath}`, { module: name, package: from.package, dir: recordedDir, path: pkgPath });
|
|
179
|
+
}
|
|
180
|
+
return { key, ref: refForKey(key), commit: from.commit, dir: recordedDir ?? `${pkgPath}/capabilities/${name}` };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* ───────────────────────────── copying ─────────────────────────────── */
|
|
184
|
+
|
|
185
|
+
/** Full copy of a fetched tree: regular files and directories only, exec bit preserved as 0o755 / 0o644. */
|
|
186
|
+
function copyTree(src, dest, shownRoot) {
|
|
187
|
+
const st = lstatSync(src);
|
|
188
|
+
if (st.isSymbolicLink()) throw fail("E_REMOTE_TREE_UNSAFE", `${shownRoot} is a symlink`, { path: shownRoot, why: "symlink" });
|
|
189
|
+
if (!st.isDirectory()) throw fail("E_MATERIALIZE_RESOLUTION", `${shownRoot} is not a directory`, { path: shownRoot });
|
|
190
|
+
mkdirSync(dest, { recursive: true, mode: 0o755 });
|
|
191
|
+
for (const name of readdirSync(src).sort()) {
|
|
192
|
+
const from = join(src, name), to = join(dest, name), shown = `${shownRoot}/${name}`, s = lstatSync(from);
|
|
193
|
+
if (s.isSymbolicLink()) throw fail("E_REMOTE_TREE_UNSAFE", `${shown} is a symlink`, { path: shown, why: "symlink" });
|
|
194
|
+
if (s.isDirectory()) { copyTree(from, to, shown); continue; }
|
|
195
|
+
if (!s.isFile()) throw fail("E_REMOTE_TREE_UNSAFE", `${shown} is not a regular file`, { path: shown, why: "device" });
|
|
196
|
+
copyFileSync(from, to);
|
|
197
|
+
chmodSync(to, (s.mode & 0o111) ? 0o755 : 0o644);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function hasSkillDoc(dir) {
|
|
202
|
+
try { return statSync(join(dir, "SKILL.md")).isFile(); } catch { return false; }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The skills a module contributes → [{ name, path }] (path relative to the module root).
|
|
206
|
+
* Precedence: resolution.skills rows for the module; else manifest.skills; else every
|
|
207
|
+
* skills/<dir>/SKILL.md in the fetched tree. */
|
|
208
|
+
function moduleSkills(resolution, module, moduleDir) {
|
|
209
|
+
const declared = (resolution.skills || []).filter((s) => s && s.module === module.name);
|
|
210
|
+
const rows = [];
|
|
211
|
+
if (declared.length) {
|
|
212
|
+
for (const s of declared) {
|
|
213
|
+
const rel = modulePath(s.path, module.name, "skill");
|
|
214
|
+
const name = typeof s.name === "string" && s.name ? s.name : posix.basename(rel);
|
|
215
|
+
rows.push({ name, path: rel });
|
|
216
|
+
}
|
|
217
|
+
} else if (Array.isArray(module.manifest?.skills)) {
|
|
218
|
+
for (const entry of module.manifest.skills) {
|
|
219
|
+
const rel = modulePath(entry, module.name, "skill");
|
|
220
|
+
const abs = join(moduleDir, ...rel.split("/"));
|
|
221
|
+
if (hasSkillDoc(abs)) { rows.push({ name: posix.basename(rel), path: rel }); continue; }
|
|
222
|
+
// A directory of skills (manifest `skills: ["skills"]`): each child with a SKILL.md is one skill.
|
|
223
|
+
if (existsSync(abs) && lstatSync(abs).isDirectory()) {
|
|
224
|
+
for (const child of readdirSync(abs).sort()) if (hasSkillDoc(join(abs, child))) rows.push({ name: child, path: `${rel}/${child}` });
|
|
225
|
+
} else {
|
|
226
|
+
throw fail("E_MATERIALIZE_RESOLUTION", `module ${module.name}: declared skills path ${JSON.stringify(entry)} is missing from the fetched capability`, { module: module.name, path: entry });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
const skillsRoot = join(moduleDir, "skills");
|
|
231
|
+
if (existsSync(skillsRoot) && lstatSync(skillsRoot).isDirectory()) {
|
|
232
|
+
for (const child of readdirSync(skillsRoot).sort()) if (hasSkillDoc(join(skillsRoot, child))) rows.push({ name: child, path: `skills/${child}` });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
for (const r of rows) {
|
|
236
|
+
if (!SAFE_COMPONENT_RE.test(r.name)) throw fail("E_MATERIALIZE_RESOLUTION", `module ${module.name}: skill name ${JSON.stringify(r.name)} is not a safe directory name`, { module: module.name, skill: r.name });
|
|
237
|
+
const abs = join(moduleDir, ...r.path.split("/"));
|
|
238
|
+
if (!hasSkillDoc(abs)) throw fail("E_MATERIALIZE_RESOLUTION", `module ${module.name}: skill ${r.name} (${r.path}) has no readable SKILL.md in the fetched capability`, { module: module.name, skill: r.name, path: r.path });
|
|
239
|
+
}
|
|
240
|
+
return rows;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The inject files a module contributes → [relative path]. resolution.injects rows first, else manifest.inject. */
|
|
244
|
+
function moduleInjects(resolution, module, moduleDir) {
|
|
245
|
+
const declared = (resolution.injects || []).filter((i) => i && i.module === module.name);
|
|
246
|
+
const rels = declared.length
|
|
247
|
+
? declared.map((i) => modulePath(i.path, module.name, "inject"))
|
|
248
|
+
: (typeof module.manifest?.inject === "string" && module.manifest.inject ? [modulePath(module.manifest.inject, module.name, "inject")] : []);
|
|
249
|
+
for (const rel of rels) {
|
|
250
|
+
const abs = join(moduleDir, ...rel.split("/"));
|
|
251
|
+
let st = null;
|
|
252
|
+
try { st = lstatSync(abs); } catch {}
|
|
253
|
+
if (!st || !st.isFile()) throw fail("E_MATERIALIZE_RESOLUTION", `module ${module.name}: inject ${rel} is missing from the fetched capability`, { module: module.name, path: rel });
|
|
254
|
+
}
|
|
255
|
+
return rels;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* ───────────────────────────── home ─────────────────────────────── */
|
|
259
|
+
|
|
260
|
+
function assertHome(home) {
|
|
261
|
+
if (typeof home !== "string" || !isAbsolute(home)) throw fail("E_MATERIALIZE_HOME", "home must be an absolute path", { home });
|
|
262
|
+
const abs = resolve(home);
|
|
263
|
+
let st;
|
|
264
|
+
try { st = statSync(abs); } catch { throw fail("E_MATERIALIZE_HOME", `instance home ${abs} does not exist`, { home: abs }); }
|
|
265
|
+
if (!st.isDirectory()) throw fail("E_MATERIALIZE_HOME", `instance home ${abs} is not a directory`, { home: abs });
|
|
266
|
+
return abs;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function readInstanceJson(file) {
|
|
270
|
+
if (!existsSync(file)) return {};
|
|
271
|
+
let parsed;
|
|
272
|
+
try { parsed = JSON.parse(readFileSync(file, "utf8")); } catch (e) {
|
|
273
|
+
throw fail("E_MATERIALIZE_HOME", `${file} is not valid JSON: ${e.message}`, { file });
|
|
274
|
+
}
|
|
275
|
+
if (!plainObject(parsed)) throw fail("E_MATERIALIZE_HOME", `${file} must hold a JSON object`, { file });
|
|
276
|
+
return parsed;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function soulBody(home, options) {
|
|
280
|
+
if (typeof options.soulAgentsMd === "string") return { text: options.soulAgentsMd, file: null };
|
|
281
|
+
const candidates = [];
|
|
282
|
+
if (typeof options.soulDir === "string") candidates.push(join(resolve(options.soulDir), "AGENTS.md"));
|
|
283
|
+
candidates.push(join(home, "soul", "AGENTS.md"));
|
|
284
|
+
for (const file of candidates) {
|
|
285
|
+
try { if (statSync(file).isFile()) return { text: readFileSync(file, "utf8"), file }; } catch {}
|
|
286
|
+
}
|
|
287
|
+
throw fail("E_MATERIALIZE_RESOLUTION", `the soul's canonical AGENTS.md was not found (looked at ${candidates.join(", ")}); pass options.soulAgentsMd or options.soulDir`, { candidates });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** `.oats/` may pre-exist (v1 homes, a previous materialization); remember whether WE made it. */
|
|
291
|
+
function ensureOatsDir(home) {
|
|
292
|
+
const dir = join(home, ".oats");
|
|
293
|
+
if (existsSync(dir)) {
|
|
294
|
+
if (!lstatSync(dir).isDirectory()) throw fail("E_MATERIALIZE_HOME", `${dir} exists and is not a directory`, { path: dir });
|
|
295
|
+
return { dir, created: false };
|
|
296
|
+
}
|
|
297
|
+
mkdirSync(dir, { mode: 0o755 });
|
|
298
|
+
return { dir, created: true };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/* ───────────────────────────── materialize ─────────────────────────────── */
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* materialize(resolution, home, options) → { modules, skills, agentsMd, instanceJson }
|
|
305
|
+
*
|
|
306
|
+
* options:
|
|
307
|
+
* fetch(ref, commit, dir, destDir, remoteOptions) → { files, bytes, digest } default remote.fetchRemoteTree
|
|
308
|
+
* remote module with parseRepoRef + contentDigest (+ fetchRemoteTree when `fetch` is not given); default lib/remote.mjs
|
|
309
|
+
* remoteOptions threaded into fetch (cacheDir, exec, …)
|
|
310
|
+
* lock oats-lock.json v3 object: package repo/path/commit for from.kind === "package"
|
|
311
|
+
* catalog { <id>: { url, ref, path } } for lock entries whose source is "catalog:<id>"
|
|
312
|
+
* soulAgentsMd the soul's canonical AGENTS.md text (else read from options.soulDir or <home>/soul/AGENTS.md)
|
|
313
|
+
* blocks pre-rendered blocks [{ source, file, content }] placed BEFORE the capability injects
|
|
314
|
+
* (the kernel's own kernel:… and work-mode:… blocks, composed by core.mjs)
|
|
315
|
+
* now() ISO timestamp source (tests)
|
|
316
|
+
*/
|
|
317
|
+
export async function materialize(resolution, home, options = {}) {
|
|
318
|
+
assertResolution(resolution);
|
|
319
|
+
const homeAbs = assertHome(home);
|
|
320
|
+
const remote = options.remote ?? defaultRemote;
|
|
321
|
+
if (typeof remote?.contentDigest !== "function" || typeof remote?.parseRepoRef !== "function") {
|
|
322
|
+
throw new TypeError("materialize: remote must provide contentDigest() and parseRepoRef() (module contract §1)");
|
|
323
|
+
}
|
|
324
|
+
const fetch = options.fetch ?? remote.fetchRemoteTree;
|
|
325
|
+
if (typeof fetch !== "function") throw new TypeError("materialize: options.fetch (or remote.fetchRemoteTree) must be a function");
|
|
326
|
+
const now = typeof options.now === "function" ? options.now : () => new Date().toISOString();
|
|
327
|
+
const { lock, catalog } = options;
|
|
328
|
+
|
|
329
|
+
// Everything that can be decided without touching the network or the disk, first.
|
|
330
|
+
const sources = resolution.modules.map((m) => ({ module: m, source: moduleSource(m, { lock, catalog, remote }) }));
|
|
331
|
+
const body = soulBody(homeAbs, options);
|
|
332
|
+
const instanceFile = join(homeAbs, "instance.json");
|
|
333
|
+
const previous = readInstanceJson(instanceFile);
|
|
334
|
+
const modulesRoot = join(homeAbs, MODULES_DIR);
|
|
335
|
+
const skillsRoot = join(homeAbs, SKILLS_DIR);
|
|
336
|
+
const parents = [join(homeAbs, ".oats"), modulesRoot, join(homeAbs, ".agents"), skillsRoot, join(homeAbs, ".claude")];
|
|
337
|
+
/** The home must still be what we checked: module targets absent, every parent a real directory (or absent).
|
|
338
|
+
* Run before the fetch AND immediately before the commit — a symlink planted in between must not be written through. */
|
|
339
|
+
const assertHomeShape = () => {
|
|
340
|
+
for (const { module } of sources) {
|
|
341
|
+
for (const target of [join(modulesRoot, module.name), join(skillsRoot, module.name)]) {
|
|
342
|
+
let st = null;
|
|
343
|
+
try { st = lstatSync(target); } catch {}
|
|
344
|
+
if (st) throw fail("E_MATERIALIZE_HOME", `${target} already exists — the instance already carries module ${module.name}; materialize never overwrites a module in place`, { home: homeAbs, module: module.name, path: target });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
for (const p of parents) {
|
|
348
|
+
let st = null;
|
|
349
|
+
try { st = lstatSync(p); } catch {}
|
|
350
|
+
if (st && !st.isDirectory()) throw fail("E_MATERIALIZE_HOME", `${p} exists and is not a directory`, { home: homeAbs, path: p, why: "not-a-directory" });
|
|
351
|
+
}
|
|
352
|
+
for (const p of [join(homeAbs, "AGENTS.md"), instanceFile]) {
|
|
353
|
+
let st = null;
|
|
354
|
+
try { st = lstatSync(p); } catch {}
|
|
355
|
+
if (st && !st.isFile() && !st.isSymbolicLink()) throw fail("E_MATERIALIZE_HOME", `${p} exists and is not a file`, { home: homeAbs, path: p, why: "not-a-file" });
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
assertHomeShape();
|
|
359
|
+
|
|
360
|
+
const oats = ensureOatsDir(homeAbs);
|
|
361
|
+
// Unique per call: two materializations of one home in one process (or across processes) never share staging.
|
|
362
|
+
const staging = join(oats.dir, `.staging-${process.pid}-${randomBytes(6).toString("hex")}`);
|
|
363
|
+
const discard = () => {
|
|
364
|
+
try { rmSync(staging, { recursive: true, force: true }); } catch { /* an unremovable staging dir must not mask the original error */ }
|
|
365
|
+
if (oats.created) { try { rmdirSync(oats.dir); } catch { /* something else appeared; leave it */ } }
|
|
366
|
+
};
|
|
367
|
+
/** A real directory at `p` (mkdir without following a symlink; EEXIST on a non-directory → refusal). */
|
|
368
|
+
const ensureDir = (p) => {
|
|
369
|
+
try { mkdirSync(p, { mode: 0o755 }); }
|
|
370
|
+
catch (e) {
|
|
371
|
+
if (e?.code !== "EEXIST") throw e;
|
|
372
|
+
if (!lstatSync(p).isDirectory()) throw fail("E_MATERIALIZE_HOME", `${p} exists and is not a directory`, { home: homeAbs, path: p, why: "not-a-directory" });
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
let outcome;
|
|
377
|
+
try {
|
|
378
|
+
mkdirSync(join(staging, "modules"), { recursive: true, mode: 0o755 });
|
|
379
|
+
mkdirSync(join(staging, "skills"), { recursive: true, mode: 0o755 });
|
|
380
|
+
|
|
381
|
+
const moduleRows = [], skillRows = [], capabilityBlocks = [], recorded = {};
|
|
382
|
+
const skillOwners = new Map();
|
|
383
|
+
for (const { module, source } of sources) {
|
|
384
|
+
const stagedModule = join(staging, "modules", module.name);
|
|
385
|
+
const finalModule = join(modulesRoot, module.name);
|
|
386
|
+
let fetched;
|
|
387
|
+
try { fetched = await fetch(source.ref, source.commit, source.dir, stagedModule, { ...(options.remoteOptions || {}), allowSymlinks: defaultRemote.OATS_ALIAS_SYMLINK }); }
|
|
388
|
+
catch (e) {
|
|
389
|
+
if (e?.code === "E_REMOTE_PATH_MISSING") {
|
|
390
|
+
throw fail("E_CAPABILITY_MISSING", `module ${module.name}: ${source.dir} is not present in ${source.key}@${source.commit.slice(0, 12)}`,
|
|
391
|
+
{ module: module.name, repoKey: source.key, commit: source.commit, path: source.dir, cause: e.message });
|
|
392
|
+
}
|
|
393
|
+
throw e;
|
|
394
|
+
}
|
|
395
|
+
if (!plainObject(fetched) || typeof fetched.digest !== "string" || !DIGEST_RE.test(fetched.digest)) {
|
|
396
|
+
throw fail("E_MATERIALIZE_INTEGRITY", `module ${module.name}: fetch did not report a sha256 content digest`, { module: module.name, reported: fetched?.digest ?? null, why: "fetch" });
|
|
397
|
+
}
|
|
398
|
+
let stagedStat = null;
|
|
399
|
+
try { stagedStat = lstatSync(stagedModule); } catch {}
|
|
400
|
+
if (!stagedStat || !stagedStat.isDirectory()) {
|
|
401
|
+
throw fail("E_MATERIALIZE_INTEGRITY", `module ${module.name}: fetch reported success but wrote nothing at ${stagedModule}`, { module: module.name, path: stagedModule, why: "fetch" });
|
|
402
|
+
}
|
|
403
|
+
// Defense in depth: regular files and directories only, whatever `fetch`/`remote` claim (contract §1).
|
|
404
|
+
assertPlainTree(stagedModule, module.name, stagedModule);
|
|
405
|
+
const digest = remote.contentDigest(stagedModule, { allowSymlinks: defaultRemote.OATS_ALIAS_SYMLINK });
|
|
406
|
+
if (digest !== fetched.digest) {
|
|
407
|
+
throw fail("E_MATERIALIZE_INTEGRITY", `module ${module.name}: the copy digests ${digest} but the fetch reported ${fetched.digest}`,
|
|
408
|
+
{ module: module.name, repoKey: source.key, commit: source.commit, expected: fetched.digest, actual: digest, why: "copy" });
|
|
409
|
+
}
|
|
410
|
+
if (module.digest && module.digest !== digest) {
|
|
411
|
+
throw fail("E_MATERIALIZE_INTEGRITY", `module ${module.name}: content digest ${digest} does not match the resolution's ${module.digest}`,
|
|
412
|
+
{ module: module.name, repoKey: source.key, commit: source.commit, expected: module.digest, actual: digest, why: "resolution" });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
for (const skill of moduleSkills(resolution, module, stagedModule)) {
|
|
416
|
+
const owner = skillOwners.get(skill.name);
|
|
417
|
+
if (owner && owner !== module.name) {
|
|
418
|
+
throw fail("E_SKILL_DUPLICATE", `skill ${JSON.stringify(skill.name)} is contributed by both ${owner} and ${module.name}`, { name: skill.name, modules: [owner, module.name] });
|
|
419
|
+
}
|
|
420
|
+
skillOwners.set(skill.name, module.name);
|
|
421
|
+
const src = join(stagedModule, ...skill.path.split("/"));
|
|
422
|
+
const staged = join(staging, "skills", module.name, skill.name);
|
|
423
|
+
copyTree(src, staged, `${module.name}/${skill.path}`);
|
|
424
|
+
skillRows.push({ module: module.name, name: skill.name, from: `${MODULES_DIR}/${module.name}/${skill.path}`.split(sep).join("/"), path: join(skillsRoot, module.name, skill.name) });
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
for (const rel of moduleInjects(resolution, module, stagedModule)) {
|
|
428
|
+
const content = readFileSync(join(stagedModule, ...rel.split("/")), "utf8").trim();
|
|
429
|
+
capabilityBlocks.push({ source: `capability:${module.name}`, file: join(finalModule, ...rel.split("/")), content });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const at = now();
|
|
433
|
+
const from = clone(module.from);
|
|
434
|
+
recorded[module.name] = { from, commit: source.commit, digest, materializedAt: at };
|
|
435
|
+
moduleRows.push({ name: module.name, from, commit: source.commit, digest, files: fetched.files ?? null, bytes: fetched.bytes ?? null, path: finalModule, materializedAt: at });
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const blocks = [...(Array.isArray(options.blocks) ? options.blocks : []), ...capabilityBlocks];
|
|
439
|
+
for (const b of blocks) {
|
|
440
|
+
if (!plainObject(b) || typeof b.source !== "string" || typeof b.file !== "string" || typeof b.content !== "string") {
|
|
441
|
+
throw fail("E_MATERIALIZE_RESOLUTION", "options.blocks entries must be { source, file, content } strings", { block: b });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const agentsText = renderInstructionText(body.text, blocks);
|
|
445
|
+
writeFileSync(join(staging, "AGENTS.md"), agentsText, { mode: 0o644 });
|
|
446
|
+
|
|
447
|
+
const merged = { ...previous, modules: recorded, providers: clone(resolution.payloads ?? {}) };
|
|
448
|
+
if (typeof resolution.revision === "string" && resolution.revision) merged.resolutionRevision = resolution.revision;
|
|
449
|
+
writeFileSync(join(staging, "instance.json"), JSON.stringify(merged, null, 2) + "\n", { mode: 0o644 });
|
|
450
|
+
|
|
451
|
+
// ── commit: re-check the home (TOCTOU), then a short sequence of renames with a rollback journal ──
|
|
452
|
+
assertHomeShape();
|
|
453
|
+
const undo = []; // LIFO: () => void
|
|
454
|
+
const backupDir = join(staging, "previous");
|
|
455
|
+
mkdirSync(backupDir, { mode: 0o755 });
|
|
456
|
+
const swapIn = (src, dest, label) => {
|
|
457
|
+
// Preserve whatever was at dest (file or symlink) in staging so a later failure can restore it.
|
|
458
|
+
let had = false;
|
|
459
|
+
try { lstatSync(dest); had = true; } catch {}
|
|
460
|
+
if (had) { renameSync(dest, join(backupDir, label)); undo.push(() => renameSync(join(backupDir, label), dest)); }
|
|
461
|
+
renameSync(src, dest);
|
|
462
|
+
undo.push(() => rmSync(dest, { recursive: true, force: true }));
|
|
463
|
+
};
|
|
464
|
+
const placeDir = (src, dest) => {
|
|
465
|
+
try { renameSync(src, dest); }
|
|
466
|
+
catch (e) {
|
|
467
|
+
if (e?.code === "EEXIST" || e?.code === "ENOTEMPTY" || e?.code === "EISDIR") {
|
|
468
|
+
throw fail("E_MATERIALIZE_HOME", `${dest} appeared while materializing — another materialization of this home is in flight, or the home changed under us`, { home: homeAbs, path: dest, why: "busy" });
|
|
469
|
+
}
|
|
470
|
+
throw e;
|
|
471
|
+
}
|
|
472
|
+
undo.push(() => rmSync(dest, { recursive: true, force: true }));
|
|
473
|
+
};
|
|
474
|
+
const mkdirTracked = (p) => {
|
|
475
|
+
let existed = true;
|
|
476
|
+
try { lstatSync(p); } catch { existed = false; }
|
|
477
|
+
ensureDir(p);
|
|
478
|
+
if (!existed) undo.push(() => { try { rmdirSync(p); } catch { /* not empty: someone else's */ } });
|
|
479
|
+
};
|
|
480
|
+
try {
|
|
481
|
+
for (const p of [join(homeAbs, ".oats"), modulesRoot, join(homeAbs, ".agents"), skillsRoot, join(homeAbs, ".claude")]) mkdirTracked(p);
|
|
482
|
+
for (const { module } of sources) {
|
|
483
|
+
placeDir(join(staging, "modules", module.name), join(modulesRoot, module.name));
|
|
484
|
+
if (existsSync(join(staging, "skills", module.name))) placeDir(join(staging, "skills", module.name), join(skillsRoot, module.name));
|
|
485
|
+
}
|
|
486
|
+
swapIn(join(staging, "AGENTS.md"), join(homeAbs, "AGENTS.md"), "AGENTS.md");
|
|
487
|
+
swapIn(join(staging, "instance.json"), instanceFile, "instance.json");
|
|
488
|
+
// Aliases (relative symlinks), only when absent — the canonical-plus-alias construction stays; inside the transaction.
|
|
489
|
+
const claudeMd = join(homeAbs, "CLAUDE.md");
|
|
490
|
+
if (!linkPresent(claudeMd)) { symlinkSync("AGENTS.md", claudeMd); undo.push(() => rmSync(claudeMd, { force: true })); }
|
|
491
|
+
const claudeSkills = join(homeAbs, ".claude", "skills");
|
|
492
|
+
if (!linkPresent(claudeSkills)) { symlinkSync(join("..", ".agents", "skills"), claudeSkills); undo.push(() => rmSync(claudeSkills, { force: true })); }
|
|
493
|
+
} catch (e) {
|
|
494
|
+
for (const step of undo.reverse()) { try { step(); } catch { /* best effort: the original error is what matters */ } }
|
|
495
|
+
if (e?.code === "EACCES" || e?.code === "EPERM" || e?.code === "EROFS" || e?.code === "EISDIR" || e?.code === "ENOTDIR") {
|
|
496
|
+
throw fail("E_MATERIALIZE_HOME", `cannot write the instance home ${homeAbs}: ${e.message}`, { home: homeAbs, why: "unwritable", cause: e.code });
|
|
497
|
+
}
|
|
498
|
+
throw e;
|
|
499
|
+
}
|
|
500
|
+
discard();
|
|
501
|
+
|
|
502
|
+
outcome = { modules: moduleRows, skills: skillRows, agentsMd: join(homeAbs, "AGENTS.md"), instanceJson: merged, blocks };
|
|
503
|
+
} catch (error) {
|
|
504
|
+
discard();
|
|
505
|
+
throw error;
|
|
506
|
+
}
|
|
507
|
+
return outcome;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Every entry under `dir` is a regular file or a directory (no symlinks, devices, sockets) — E_REMOTE_TREE_UNSAFE. */
|
|
511
|
+
function assertPlainTree(dir, module, root = dir) {
|
|
512
|
+
for (const name of readdirSync(dir)) {
|
|
513
|
+
const p = join(dir, name), st = lstatSync(p);
|
|
514
|
+
if (st.isSymbolicLink()) {
|
|
515
|
+
// The one admitted alias (OATS_ALIAS_SYMLINK) must point at its sibling AGENTS.md.
|
|
516
|
+
const rel = relative(root, p).split(sep).join("/");
|
|
517
|
+
if (!defaultRemote.OATS_ALIAS_SYMLINK(rel) || readlinkSync(p) !== "AGENTS.md") throw fail("E_REMOTE_TREE_UNSAFE", `module ${module}: ${p} is a symlink`, { module, path: p, why: "symlink" });
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (st.isDirectory()) { assertPlainTree(p, module, root); continue; }
|
|
521
|
+
if (!st.isFile()) throw fail("E_REMOTE_TREE_UNSAFE", `module ${module}: ${p} is not a regular file`, { module, path: p, why: "device" });
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function linkPresent(path) {
|
|
526
|
+
try { lstatSync(path); return true; } catch { return false; }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/* ───────────────────────────── driftOf ─────────────────────────────── */
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* driftOf(instanceJson, discovery, { lock } = {})
|
|
533
|
+
* → [{ module, from, recorded: { repoKey, commit }, current: { commit } | null, status: "current"|"moved"|"missing", reason? }]
|
|
534
|
+
*
|
|
535
|
+
* Member modules: the confirmed member row for `repoKey` in `discovery.members` is the current
|
|
536
|
+
* state — unconfirmed/absent → "missing" (reason "unconfirmed"); the capability gone from the
|
|
537
|
+
* member → "missing" (reason "capability-absent"); a different commit → "moved"; else "current".
|
|
538
|
+
* Package modules are pinned by version: without a lock they are "current"; with one, the lock's
|
|
539
|
+
* entry decides — absent package or capability → "missing", another commit → "moved".
|
|
540
|
+
*/
|
|
541
|
+
export function driftOf(instanceJson, discovery, { lock } = {}) {
|
|
542
|
+
const modules = plainObject(instanceJson?.modules) ? instanceJson.modules : {};
|
|
543
|
+
const members = Array.isArray(discovery?.members) ? discovery.members : [];
|
|
544
|
+
const rows = [];
|
|
545
|
+
for (const name of Object.keys(modules).sort()) {
|
|
546
|
+
const rec = modules[name] || {};
|
|
547
|
+
const from = plainObject(rec.from) ? rec.from : {};
|
|
548
|
+
const commit = typeof rec.commit === "string" ? rec.commit : (typeof from.commit === "string" ? from.commit : null);
|
|
549
|
+
if (from.kind === "package") {
|
|
550
|
+
const recorded = { repoKey: from.repoKey ?? null, package: from.package ?? null, version: from.version ?? null, commit };
|
|
551
|
+
if (!plainObject(lock?.packages)) { rows.push({ module: name, from: clone(from), recorded, current: commit ? { commit } : null, status: "current" }); continue; }
|
|
552
|
+
const entry = lock.packages[from.package];
|
|
553
|
+
if (!entry) { rows.push({ module: name, from: clone(from), recorded, current: null, status: "missing", reason: "package-absent" }); continue; }
|
|
554
|
+
if (Array.isArray(entry.capabilities) && !entry.capabilities.includes(name)) {
|
|
555
|
+
rows.push({ module: name, from: clone(from), recorded, current: { commit: entry.commit, version: entry.version }, status: "missing", reason: "capability-absent" }); continue;
|
|
556
|
+
}
|
|
557
|
+
rows.push({ module: name, from: clone(from), recorded, current: { commit: entry.commit, version: entry.version }, status: entry.commit === commit ? "current" : "moved" });
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
const repoKey = typeof from.repoKey === "string" ? from.repoKey : null;
|
|
561
|
+
const recorded = { repoKey, commit };
|
|
562
|
+
const member = repoKey ? members.find((m) => m && m.key === repoKey) : null;
|
|
563
|
+
if (!member || !member.confirmed || typeof member.commit !== "string") {
|
|
564
|
+
rows.push({ module: name, from: clone(from), recorded, current: null, status: "missing", reason: member ? (member.reason || "unconfirmed") : "unconfirmed" });
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
const present = Array.isArray(member.capabilities) && member.capabilities.some((c) => c && c.name === name);
|
|
568
|
+
if (!present) { rows.push({ module: name, from: clone(from), recorded, current: { commit: member.commit }, status: "missing", reason: "capability-absent" }); continue; }
|
|
569
|
+
rows.push({ module: name, from: clone(from), recorded, current: { commit: member.commit }, status: member.commit === commit ? "current" : "moved" });
|
|
570
|
+
}
|
|
571
|
+
return rows;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Read the modules recorded in an instance home (→ {} when absent). */
|
|
575
|
+
export function recordedModules(home) {
|
|
576
|
+
const file = join(resolve(home), "instance.json");
|
|
577
|
+
if (!existsSync(file)) return {};
|
|
578
|
+
const parsed = readInstanceJson(file);
|
|
579
|
+
return plainObject(parsed.modules) ? parsed.modules : {};
|
|
580
|
+
}
|