@awebai/oats 0.24.12 → 0.25.0
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 +936 -2822
- package/docs/capabilities.md +136 -323
- package/docs/configuration.md +68 -533
- 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 +309 -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 +386 -6
- package/docs/desktop-succession.md +3 -4
- package/docs/first-team.md +107 -224
- package/docs/implementation.md +6 -4
- package/docs/integrations.md +45 -44
- package/docs/knowledge-capability-authoring.md +10 -4
- package/docs/knowledge-migration.md +5 -4
- package/docs/knowledge-reference/package-craft.md +11 -3
- package/docs/knowledge.md +24 -8
- package/docs/layers.md +3 -3
- 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 +233 -0
- package/docs/release-notes/v0.24.13.md +51 -0
- package/docs/release-notes/v0.25.0.md +99 -0
- 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 +429 -119
- package/lib/core.mjs +419 -55
- package/lib/instance-resolution.mjs +312 -0
- package/lib/materialize.mjs +580 -0
- package/lib/packages.mjs +501 -1273
- package/lib/remote.mjs +639 -0
- package/lib/resolve.mjs +576 -0
- package/lib/schedule.mjs +194 -34
- package/lib/workspace.mjs +635 -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
package/lib/packages.mjs
CHANGED
|
@@ -1,207 +1,105 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OATS
|
|
3
|
-
* reconciliation) over the package ENGINE in lib/core.mjs.
|
|
2
|
+
* OATS packages — versions, lock v3, approval (workspace model v2).
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* package-engine-contract.md + package-runtime-api.md). This module carries
|
|
8
|
-
* ONLY what the Decision assigns to workstream 2:
|
|
9
|
-
* - config profile selection/validation/provenance and the report-only diff;
|
|
10
|
-
* - team-boundary workspace scope discovery (pruned, deterministic);
|
|
11
|
-
* - host-requirement consent policy (identity/conflict fail-closed, plans).
|
|
4
|
+
* Contract: docs/design/2026-09-23-workspace-module-contracts.md §4.
|
|
5
|
+
* Decision: agents/oats-expert/soul/knowledge/decisions/workspace-model-v2.md.
|
|
12
6
|
*
|
|
13
|
-
*
|
|
7
|
+
* A package is a place to fetch from WITH a version attached. Nothing is
|
|
8
|
+
* installed: `resolvePackages` turns each `workspace.packages` entry into an
|
|
9
|
+
* exact (commit, integrity) pair recorded in `oats-lock.json`
|
|
10
|
+
* (lockfileVersion 3), and the one-time executable approval per version lives
|
|
11
|
+
* next to the commit it approved. A moved tag (same version string, different
|
|
12
|
+
* commit) fails integrity and asks again.
|
|
13
|
+
*
|
|
14
|
+
* This module is synchronous except `resolvePackages`, shells out to nothing,
|
|
15
|
+
* and depends only on `node:*`. Remote access is INJECTED (`remote` option) so
|
|
16
|
+
* callers pass `lib/remote.mjs` and tests pass an in-memory fake.
|
|
17
|
+
*
|
|
18
|
+
* Lock v3 shape (exactly as the contract):
|
|
19
|
+
*
|
|
20
|
+
* {
|
|
21
|
+
* lockfileVersion: 3,
|
|
22
|
+
* packages: {
|
|
23
|
+
* <id>: {
|
|
24
|
+
* source: "catalog:<id>" | "git:<key>@<ref>",
|
|
25
|
+
* url: "<repo url the package was read from>", // repo identity: resolve/materialize need no catalog
|
|
26
|
+
* path: "<dir of oats-package.json inside the repo>",
|
|
27
|
+
* version: "<version string, no leading v>",
|
|
28
|
+
* commit: "<full 40-hex OID>",
|
|
29
|
+
* integrity: "sha256-<hex>", // contentDigest of the package tree at <path>
|
|
30
|
+
* capabilities: ["<cap name>", …], // sorted
|
|
31
|
+
* approved: { executables: "sha256-<hex>", at: "<ISO-8601 UTC>" } | null
|
|
32
|
+
* }
|
|
33
|
+
* }
|
|
34
|
+
* }
|
|
35
|
+
*
|
|
36
|
+
* `packageTree` (input of `executablesDigest`):
|
|
37
|
+
*
|
|
38
|
+
* { manifests: [ { name: "<cap name>", manifest: <parsed oats.json>, files: Map<relpath, Buffer> } ] }
|
|
39
|
+
*
|
|
40
|
+
* `files` holds the bytes of the capability directory, keyed by POSIX
|
|
41
|
+
* relative path from that directory (e.g. "bin/tool.mjs"). Each
|
|
42
|
+
* `manifest.commands[<cmd>]` value is "<relpath> [args…]"; the FIRST token is
|
|
43
|
+
* the executable target whose bytes are digested. Only command targets are
|
|
44
|
+
* digested — skills, injects and other files are covered by `integrity`.
|
|
45
|
+
*
|
|
46
|
+
* Deprecated shims: every name the 0.24 kernel still imports from this module
|
|
47
|
+
* is exported below as a thin function throwing E_REMOVED so nothing breaks at
|
|
48
|
+
* import time; callers are deleted in the next phase.
|
|
14
49
|
*/
|
|
15
|
-
import {
|
|
16
|
-
chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync,
|
|
17
|
-
readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync,
|
|
18
|
-
} from "node:fs";
|
|
50
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync, lstatSync } from "node:fs";
|
|
19
51
|
import { tmpdir } from "node:os";
|
|
20
|
-
import { basename,
|
|
21
|
-
import { execFileSync } from "node:child_process";
|
|
52
|
+
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
22
53
|
import { createHash } from "node:crypto";
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
gitCheckoutExactRef, parsePackageSource, resolveClaudeBinary, resolvePackageRoot,
|
|
26
|
-
OATS_VERSION, parseYamlNested, readPackageLocks, resolveOatsConfig, RUNTIME_PACKAGE_MANAGERS,
|
|
27
|
-
runtimePackageInstalled, runtimePackageIdentity, runtimePackageStatus, safeRuntimePackageSpec,
|
|
28
|
-
safeRuntimeSourceRef, validateConfigShape, withConfigFile,
|
|
29
|
-
} from "./core.mjs";
|
|
30
|
-
|
|
31
|
-
// Runtime-package primitives live in the ENGINE (core.mjs) so spawn can use them
|
|
32
|
-
// without this policy module being imported from there. Re-exported here because
|
|
33
|
-
// the consent policy is this module's public surface.
|
|
34
|
-
export { packageSpecIdentity, RUNTIME_PACKAGE_MANAGERS, runtimePackageIdentity, runtimePackageInstalled, runtimePackageStatus, safeRuntimePackageSpec };
|
|
35
|
-
|
|
36
|
-
/** Throw an Error carrying a stable contract error code (engine taxonomy §4 + WS2 codes). */
|
|
37
|
-
function fail(code, message) {
|
|
38
|
-
const e = new Error(message);
|
|
39
|
-
e.code = code;
|
|
40
|
-
throw e;
|
|
41
|
-
}
|
|
54
|
+
import { oatsError as baseOatsError } from "./errors.mjs";
|
|
55
|
+
import * as defaultRemote from "./remote.mjs";
|
|
42
56
|
|
|
43
|
-
const
|
|
57
|
+
export const LOCK_FILE = "oats-lock.json";
|
|
58
|
+
export const LOCK_VERSION = 3;
|
|
59
|
+
export const PACKAGE_MANIFEST = "oats-package.json";
|
|
60
|
+
export const CAPABILITY_MANIFEST = "oats.json";
|
|
61
|
+
/** Default location of `oats-package.json` inside a package repo when the
|
|
62
|
+
* workspace value carries no path (the catalog supplies one for catalog ids). */
|
|
63
|
+
export const DEFAULT_PACKAGE_PATH = "oats-package";
|
|
44
64
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
// `readLockedConfigTemplates` — produce the SAME descriptor, so everything here
|
|
49
|
-
// takes descriptors and never cares which reader produced them.
|
|
65
|
+
const OID_RE = /^[0-9a-f]{40}$/;
|
|
66
|
+
const DIGEST_RE = /^sha256-[0-9a-f]{64}$/;
|
|
67
|
+
const CONTRACT_DOC = "docs/design/2026-09-23-workspace-module-contracts.md";
|
|
50
68
|
|
|
51
|
-
/**
|
|
52
|
-
*
|
|
53
|
-
|
|
54
|
-
|
|
69
|
+
/** `oatsError` with details attached as BOTH `e.provenance` (today's field) and
|
|
70
|
+
* `e.details` (the contract's name) so callers can read either. */
|
|
71
|
+
function oatsError(code, message, details) {
|
|
72
|
+
const e = baseOatsError(code, message, details);
|
|
73
|
+
if (details !== undefined) e.details = details;
|
|
74
|
+
return e;
|
|
75
|
+
}
|
|
55
76
|
|
|
56
|
-
|
|
57
|
-
* they get the same directory-name grammar a capability id gets. */
|
|
58
|
-
const ADOPTED_TOKEN_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
77
|
+
// ---------- generic file-safety helpers (kept: used by bin/oats.mjs outside package code) ----------
|
|
59
78
|
|
|
60
|
-
|
|
61
|
-
for (const [what, token] of [["package", packageId], ["template", templateName]]) {
|
|
62
|
-
if (typeof token !== "string" || !ADOPTED_TOKEN_RE.test(token)) {
|
|
63
|
-
fail("E_ADOPTED_PATH_UNSAFE", `adopted template path refused: ${what} token ${JSON.stringify(token)} is not a safe directory name (expected ${ADOPTED_TOKEN_RE.source})`);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return join(levelDir, ADOPTED_TEMPLATES_DIRNAME, packageId, templateName);
|
|
67
|
-
};
|
|
79
|
+
const isInside = (base, target) => target === base || target.startsWith(base + sep);
|
|
68
80
|
|
|
69
|
-
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* An adopted base is written under a path the operator controls, and any
|
|
73
|
-
* intermediate component could be a symlink — placed deliberately, or left over
|
|
74
|
-
* from an unrelated tool. Following one writes the run's bytes somewhere nobody
|
|
75
|
-
* asked for. The check refuses ANY intermediate symlink, contained or escaping:
|
|
76
|
-
* a link that happens to stay inside the scope today is still a redirection
|
|
77
|
-
* this code never sanctioned, and distinguishing the two only adds a way to be
|
|
78
|
-
* wrong. Components that do not exist yet are fine — this run creates them.
|
|
79
|
-
*
|
|
80
|
-
* Deliberately re-run immediately before each write rather than cached: the
|
|
81
|
-
* gap between checking and writing is exactly where a swap would land. */
|
|
81
|
+
/** Refuse to write below `scopeDir` through any symlinked path component of
|
|
82
|
+
* `leafDir`. Components that do not exist yet are fine — this run creates them.
|
|
83
|
+
* Re-run immediately before each write rather than cached. */
|
|
82
84
|
export function assertNoSymlinkedParents(scopeDir, leafDir, what) {
|
|
83
85
|
const base = resolve(scopeDir);
|
|
84
86
|
const leaf = resolve(leafDir);
|
|
85
|
-
if (!isInside(base, leaf))
|
|
87
|
+
if (!isInside(base, leaf)) throw oatsError("E_ADOPTED_PATH_UNSAFE", `${what} resolves outside the scope: ${leaf}`, { scope: base, path: leaf });
|
|
86
88
|
const rel = relative(base, leaf);
|
|
87
89
|
let cur = base;
|
|
88
|
-
// The scope root itself is the caller's own anchor, so the walk starts BELOW
|
|
89
|
-
// it and covers every component this code is responsible for.
|
|
90
90
|
for (const part of rel ? rel.split(sep) : []) {
|
|
91
91
|
cur = join(cur, part);
|
|
92
92
|
let st;
|
|
93
|
-
try { st = lstatSync(cur); } catch { return; }
|
|
93
|
+
try { st = lstatSync(cur); } catch { return; }
|
|
94
94
|
if (st.isSymbolicLink()) {
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
if (!st.isDirectory()) fail("E_ADOPTED_PATH_UNSAFE", `${what} passes through ${cur}, which is not a directory`);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Replace a file with an exact copy of another, never writing THROUGH the
|
|
102
|
-
* destination. `copyFileSync` opens the destination for write and therefore
|
|
103
|
-
* FOLLOWS it: a pre-planted `oats-config.yaml.bak` symlink would redirect the
|
|
104
|
-
* copy onto whatever it points at. Sibling temp + rename replaces the entry. */
|
|
105
|
-
export function copyFileAtomic(from, to) {
|
|
106
|
-
writeFileAtomic(to, readFileSync(from));
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Choose a template: explicit name, else the single marked default, else the
|
|
110
|
-
* only one. Several unmarked templates need an explicit choice — guessing would
|
|
111
|
-
* silently adopt a policy nobody picked. */
|
|
112
|
-
export function selectConfigTemplate(templates, name, packageId, bail) {
|
|
113
|
-
const refuse = (code, msg) => (bail ? bail(code, msg) : fail(code, msg));
|
|
114
|
-
const list = templates || [];
|
|
115
|
-
if (!list.length) return refuse("E_NO_TEMPLATES", `package ${packageId} exports no config templates`);
|
|
116
|
-
if (name) {
|
|
117
|
-
const hit = list.find((t) => t.template === name);
|
|
118
|
-
if (!hit) return refuse("E_TEMPLATE_NOT_FOUND", `package ${packageId} has no config template "${name}" (templates: ${list.map((t) => t.template).join(", ")})`);
|
|
119
|
-
return hit;
|
|
120
|
-
}
|
|
121
|
-
const marked = list.filter((t) => t.default);
|
|
122
|
-
if (marked.length === 1) return marked[0];
|
|
123
|
-
if (list.length === 1) return list[0];
|
|
124
|
-
return refuse("E_TEMPLATE_AMBIGUOUS", `package ${packageId} exports several config templates and none is marked default (${list.map((t) => t.template).join(", ")}) — pass --config <name>`);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** Validate one config template's CONTENT before adoption. Same policy checks
|
|
128
|
-
* the profile validator carried, now reading a descriptor's bytes rather than a
|
|
129
|
-
* package directory: config schema; every referenced installed capability
|
|
130
|
-
* supplied by the closure; layer agreement against the real provider manifest;
|
|
131
|
-
* agent-type syntax; no scope-escaping paths.
|
|
132
|
-
* `deferUnknownLayers` exists because acquisition's PREVIEW capability rows
|
|
133
|
-
* carry no capability manifest, so a template binding a layer to one of the
|
|
134
|
-
* package's OWN not-yet-materialized capabilities cannot have that binding
|
|
135
|
-
* checked inside the pre-commit gate. Refusing there would make every package
|
|
136
|
-
* whose template binds a layer unadoptable, and accepting silently would be
|
|
137
|
-
* fail-open — so the gate defers exactly those bindings and the caller
|
|
138
|
-
* re-validates against the real materialized manifests before finalizing,
|
|
139
|
-
* rolling the whole run back if they disagree. Same user-visible outcome as a
|
|
140
|
-
* gate refusal (nothing persists), just paid for after the copy.
|
|
141
|
-
* Returns a list of error strings (empty = valid). */
|
|
142
|
-
export function validateConfigTemplate(descriptor, packageId, { dependencyProviders = new Map(), deferUnknownLayers = false } = {}) {
|
|
143
|
-
const where = `config template "${descriptor.template}" of package ${packageId}`;
|
|
144
|
-
let cfg;
|
|
145
|
-
try {
|
|
146
|
-
// A template is config SOURCE MATERIAL read from package bytes, so it hits
|
|
147
|
-
// the same typed key refusal as a config on disk — and it must name the
|
|
148
|
-
// template, not just "a mapping key". Same rendering as validateConfigShape
|
|
149
|
-
// below, which already carries the template path.
|
|
150
|
-
cfg = withConfigFile(`${where} (${descriptor.path})`, () => parseYamlNested(String(descriptor.content)));
|
|
151
|
-
validateConfigShape(cfg, `${where} (${descriptor.path})`);
|
|
152
|
-
} catch (e) { return [e.message]; }
|
|
153
|
-
return validateAdoptableConfig(cfg, where, dependencyProviders, deferUnknownLayers);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** The shared config-policy checks, over an already-parsed config. */
|
|
157
|
-
function validateAdoptableConfig(cfg, where, dependencyProviders, deferUnknownLayers = false) {
|
|
158
|
-
const errors = [];
|
|
159
|
-
const supplied = new Set(dependencyProviders.keys());
|
|
160
|
-
const entries = [];
|
|
161
|
-
const caps = cfg.capabilities || {};
|
|
162
|
-
for (const [layer, entry] of Object.entries(caps.layers || {})) {
|
|
163
|
-
if (entry && typeof entry === "object") entries.push({ id: entry.capability, entry, slot: layer });
|
|
164
|
-
}
|
|
165
|
-
for (const [id, entry] of Object.entries(caps.additive || {})) entries.push({ id, entry: entry && typeof entry === "object" ? entry : {}, slot: undefined });
|
|
166
|
-
|
|
167
|
-
for (const { id, entry, slot } of entries) {
|
|
168
|
-
const from = String(entry.from || "installed");
|
|
169
|
-
if (from.startsWith("path:")) { errors.push(`${where}: capability ${id} uses "from: ${from}" — templates must reference installed capabilities, not host paths`); continue; }
|
|
170
|
-
if (from === "installed" && !supplied.has(id)) {
|
|
171
|
-
errors.push(`${where}: capability ${id} is not supplied by the package or its dependency closure (supplied: ${[...supplied].join(", ") || "none"})`);
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
174
|
-
if (slot) {
|
|
175
|
-
const capMan = dependencyProviders.get(id) ?? null;
|
|
176
|
-
if (capMan && capMan.layer !== slot) errors.push(`${where}: layer ${slot} binds ${id}, but its manifest declares layer "${capMan.layer || "none"}"`);
|
|
177
|
-
else if (capMan === null && supplied.has(id) && !deferUnknownLayers) {
|
|
178
|
-
errors.push(`${where}: layer ${slot} binds ${id}, but its provider manifest is not available to verify the layer — install the provider first`);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
const override = entry["injection-override"];
|
|
182
|
-
if (typeof override === "string" && (isAbsolute(override) || override.split(/[\\/]/).includes(".."))) {
|
|
183
|
-
errors.push(`${where}: capability ${id} injection-override escapes the target scope: ${override}`);
|
|
95
|
+
throw oatsError("E_ADOPTED_PATH_UNSAFE", `${what} passes through a symlink at ${cur} — OATS never writes through a link it did not create; remove or replace that entry`, { path: cur });
|
|
184
96
|
}
|
|
97
|
+
if (!st.isDirectory()) throw oatsError("E_ADOPTED_PATH_UNSAFE", `${what} passes through ${cur}, which is not a directory`, { path: cur });
|
|
185
98
|
}
|
|
186
|
-
for (const [name] of Object.entries(cfg["agent-types"] || {})) {
|
|
187
|
-
if (!AGENT_TYPE_RE.test(name)) errors.push(`${where}: agent type "${name}" must be lowercase alphanumeric/hyphens`);
|
|
188
|
-
}
|
|
189
|
-
for (const [mode, wm] of Object.entries(cfg["work-modes"] || {})) {
|
|
190
|
-
const setup = wm && typeof wm === "object" ? wm.setup : undefined;
|
|
191
|
-
if (typeof setup === "string" && (isAbsolute(setup) || setup.split(/[\\/]/).includes(".."))) {
|
|
192
|
-
errors.push(`${where}: work-modes.${mode}.setup escapes the target scope: ${setup}`);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
const oatsOverride = cfg.oats && typeof cfg.oats === "object" ? cfg.oats["injection-override"] : undefined;
|
|
196
|
-
if (typeof oatsOverride === "string" && (isAbsolute(oatsOverride) || oatsOverride.split(/[\\/]/).includes(".."))) {
|
|
197
|
-
errors.push(`${where}: oats.injection-override escapes the target scope: ${oatsOverride}`);
|
|
198
|
-
}
|
|
199
|
-
return errors;
|
|
200
99
|
}
|
|
201
100
|
|
|
202
|
-
/**
|
|
203
|
-
*
|
|
204
|
-
* whatever it points at. Sibling temp + rename replaces the entry itself. */
|
|
101
|
+
/** Sibling temp + rename: replaces the directory entry itself, never writing
|
|
102
|
+
* THROUGH a pre-planted symlink at `file`. */
|
|
205
103
|
export function writeFileAtomic(file, contents) {
|
|
206
104
|
mkdirSync(dirname(file), { recursive: true });
|
|
207
105
|
const tmp = join(dirname(file), `.${basename(file)}.oats-tmp-${process.pid}`);
|
|
@@ -211,1165 +109,495 @@ export function writeFileAtomic(file, contents) {
|
|
|
211
109
|
} finally { rmSync(tmp, { force: true }); }
|
|
212
110
|
}
|
|
213
111
|
|
|
214
|
-
/**
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
* three-way sync compares against it; a base that drifted toward local would
|
|
218
|
-
* silently turn upstream changes into conflicts. The metadata copies the
|
|
219
|
-
* engine's `contentIntegrity` verbatim rather than inventing a second digest,
|
|
220
|
-
* and carries nothing machine-local: no absolute path, no host, no secret. */
|
|
221
|
-
export function writeAdoptedTemplate(levelDir, configFile, { package: packageId, template, root }, { writeConfig = true } = {}) {
|
|
222
|
-
const baseDir = adoptedTemplateDir(levelDir, packageId, template.template);
|
|
223
|
-
const baseFile = join(baseDir, "oats-config.yaml");
|
|
224
|
-
const metadataFile = join(baseDir, ADOPTION_METADATA_FILE);
|
|
225
|
-
// A `path:` source is one developer's filesystem layout. This file is meant to
|
|
226
|
-
// be COMMITTED, so recording it would leak a machine path into the repository
|
|
227
|
-
// and mean nothing on any other checkout — record that the adoption was local
|
|
228
|
-
// instead of where it happened. Git and catalog sources are portable and are
|
|
229
|
-
// recorded verbatim.
|
|
230
|
-
const rawSource = root?.source ?? null;
|
|
231
|
-
const localSource = typeof rawSource === "string" && rawSource.startsWith("path:");
|
|
232
|
-
const metadata = {
|
|
233
|
-
package: packageId,
|
|
234
|
-
template: template.template,
|
|
235
|
-
templatePath: template.path,
|
|
236
|
-
source: localSource ? null : rawSource,
|
|
237
|
-
...(localSource ? { localSource: true } : {}),
|
|
238
|
-
version: root?.version ?? null,
|
|
239
|
-
commit: root?.commit ?? null,
|
|
240
|
-
packagePath: root?.path ?? null,
|
|
241
|
-
hash: template.contentIntegrity,
|
|
242
|
-
...(template.legacySpelling ? { legacySpelling: true } : {}),
|
|
243
|
-
adoptedWith: `oats ${OATS_VERSION}`,
|
|
244
|
-
};
|
|
245
|
-
// IMMEDIATELY before the writes, not once at command entry: every existing
|
|
246
|
-
// component from the scope down to the template directory must be a real
|
|
247
|
-
// directory this code can own.
|
|
248
|
-
assertNoSymlinkedParents(levelDir, baseDir, `adopted base for ${packageId}:${template.template}`);
|
|
249
|
-
// `sync` writes the MERGED config itself; only first adoption and reset copy
|
|
250
|
-
// the template verbatim into place.
|
|
251
|
-
if (writeConfig) writeFileAtomic(configFile, template.content);
|
|
252
|
-
writeFileAtomic(baseFile, template.content);
|
|
253
|
-
writeFileAtomic(metadataFile, `${JSON.stringify(metadata, null, 2)}\n`);
|
|
254
|
-
return { baseDir, baseFile, metadataFile, metadata };
|
|
112
|
+
/** Exact copy via `writeFileAtomic` (copyFileSync would follow a destination symlink). */
|
|
113
|
+
export function copyFileAtomic(from, to) {
|
|
114
|
+
writeFileAtomic(to, readFileSync(from));
|
|
255
115
|
}
|
|
256
116
|
|
|
257
|
-
|
|
258
|
-
* At most one adopted base may exist; more than one is a typed failure rather
|
|
259
|
-
* than a guess about which is current. */
|
|
260
|
-
export function readAdoptedTemplate(levelDir) {
|
|
261
|
-
const root = join(levelDir, ADOPTED_TEMPLATES_DIRNAME);
|
|
262
|
-
if (!existsSync(root)) return null;
|
|
263
|
-
const found = [];
|
|
264
|
-
for (const pkg of readdirSync(root, { withFileTypes: true })) {
|
|
265
|
-
if (!pkg.isDirectory()) continue;
|
|
266
|
-
for (const tpl of readdirSync(join(root, pkg.name), { withFileTypes: true })) {
|
|
267
|
-
if (!tpl.isDirectory()) continue;
|
|
268
|
-
const dir = join(root, pkg.name, tpl.name);
|
|
269
|
-
if (existsSync(join(dir, ADOPTION_METADATA_FILE))) found.push({ package: pkg.name, template: tpl.name, dir });
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
if (!found.length) return null;
|
|
273
|
-
if (found.length > 1) {
|
|
274
|
-
fail("E_MULTIPLE_ADOPTED_BASES", `scope ${levelDir} records ${found.length} adopted config templates (${found.map((f) => `${f.package}:${f.template}`).join(", ")}) — exactly one may be current; remove the stale ones under ${ADOPTED_TEMPLATES_DIRNAME}`);
|
|
275
|
-
}
|
|
276
|
-
const hit = found[0];
|
|
277
|
-
let metadata;
|
|
278
|
-
try { metadata = JSON.parse(readFileSync(join(hit.dir, ADOPTION_METADATA_FILE), "utf8")); }
|
|
279
|
-
catch (e) { fail("E_ADOPTION_METADATA_INVALID", `adopted template metadata at ${join(hit.dir, ADOPTION_METADATA_FILE)} is unreadable: ${e.message}`); }
|
|
280
|
-
const baseFile = join(hit.dir, "oats-config.yaml");
|
|
281
|
-
if (!existsSync(baseFile)) fail("E_ADOPTION_BASE_MISSING", `adopted template ${hit.package}:${hit.template} has metadata but no recorded base at ${baseFile}`);
|
|
282
|
-
return { ...hit, metadata, baseFile, baseText: readFileSync(baseFile, "utf8") };
|
|
283
|
-
}
|
|
117
|
+
// ---------- lock v3 ----------
|
|
284
118
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
// current LOCAL oats-config.yaml, and the TEMPLATE from the currently locked
|
|
289
|
-
// package. The Decision's binding rule is that untouched local bytes stay
|
|
290
|
-
// byte-identical — comments, key ordering, blank lines, indentation style and
|
|
291
|
-
// the presence or absence of a trailing newline all survive. That forbids the
|
|
292
|
-
// obvious implementation (parse YAML, merge objects, reserialize): a round trip
|
|
293
|
-
// through the kernel's YAML subset would rewrite the whole file even when one
|
|
294
|
-
// key changed. So the merge is a line-level three-way diff whose output is
|
|
295
|
-
// built by splicing ONLY the selected regions into the original local line
|
|
296
|
-
// array; every other byte is copied verbatim from the local file.
|
|
297
|
-
//
|
|
298
|
-
// This half is deliberately engine-independent: pure text in, text out, no lock
|
|
299
|
-
// reads, no filesystem, no package identity. The CLI supplies the three texts.
|
|
119
|
+
const emptyLock = () => ({ lockfileVersion: LOCK_VERSION, packages: {} });
|
|
120
|
+
const plainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
121
|
+
const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
|
|
300
122
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
123
|
+
function sortedObject(obj) {
|
|
124
|
+
const out = {};
|
|
125
|
+
for (const k of Object.keys(obj).sort()) out[k] = obj[k];
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
305
128
|
|
|
306
|
-
/**
|
|
307
|
-
*
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
129
|
+
/** Structural validation of a lock v3 object. Throws E_LOCK_SCHEMA naming the
|
|
130
|
+
* offending path; a v1/v2 lock names its version and points at the rebuild. */
|
|
131
|
+
export function validateLock(lock, { file } = {}) {
|
|
132
|
+
const where = file ? ` (${file})` : "";
|
|
133
|
+
if (!plainObject(lock)) throw oatsError("E_LOCK_SCHEMA", `oats-lock.json must be a JSON object${where}`, { file, path: "/" });
|
|
134
|
+
const v = lock.lockfileVersion;
|
|
135
|
+
if (v !== LOCK_VERSION) {
|
|
136
|
+
const shown = v === undefined ? "missing" : JSON.stringify(v);
|
|
137
|
+
throw oatsError("E_LOCK_SCHEMA",
|
|
138
|
+
`oats-lock.json lockfileVersion ${shown} is not supported: this kernel reads lockfileVersion ${LOCK_VERSION} only (workspace model v2; no migration — delete the lock and run \`oats sync\`)${where}`,
|
|
139
|
+
{ file, path: "/lockfileVersion", found: v, expected: LOCK_VERSION });
|
|
140
|
+
}
|
|
141
|
+
if (!plainObject(lock.packages)) throw oatsError("E_LOCK_SCHEMA", `oats-lock.json "packages" must be an object${where}`, { file, path: "/packages" });
|
|
142
|
+
for (const [id, entry] of Object.entries(lock.packages)) {
|
|
143
|
+
const at = `/packages/${id}`;
|
|
144
|
+
const bad = (field, why) => oatsError("E_LOCK_SCHEMA", `oats-lock.json ${at}/${field}: ${why}${where}`, { file, path: `${at}/${field}`, id });
|
|
145
|
+
if (!plainObject(entry)) throw oatsError("E_LOCK_SCHEMA", `oats-lock.json ${at} must be an object${where}`, { file, path: at, id });
|
|
146
|
+
if (typeof entry.source !== "string" || !/^(catalog:|git:)/.test(entry.source)) throw bad("source", 'must be "catalog:<id>" or "git:<key>@<ref>"');
|
|
147
|
+
if (entry.url !== undefined && (typeof entry.url !== "string" || !entry.url)) throw bad("url", "must be a non-empty repo url when present");
|
|
148
|
+
if (typeof entry.path !== "string" || !entry.path) throw bad("path", "must be a non-empty string");
|
|
149
|
+
if (typeof entry.version !== "string" || !entry.version) throw bad("version", "must be a non-empty string");
|
|
150
|
+
if (typeof entry.commit !== "string" || !OID_RE.test(entry.commit)) throw bad("commit", "must be a full 40-hex OID");
|
|
151
|
+
if (typeof entry.integrity !== "string" || !DIGEST_RE.test(entry.integrity)) throw bad("integrity", "must be sha256-<hex>");
|
|
152
|
+
if (!Array.isArray(entry.capabilities) || entry.capabilities.some((c) => typeof c !== "string" || !c)) throw bad("capabilities", "must be an array of capability names");
|
|
153
|
+
if (entry.approved !== null) {
|
|
154
|
+
if (!plainObject(entry.approved)) throw bad("approved", "must be null or { executables, at }");
|
|
155
|
+
if (typeof entry.approved.executables !== "string" || !DIGEST_RE.test(entry.approved.executables)) throw bad("approved/executables", "must be sha256-<hex>");
|
|
156
|
+
if (typeof entry.approved.at !== "string" || Number.isNaN(Date.parse(entry.approved.at))) throw bad("approved/at", "must be an ISO-8601 timestamp");
|
|
157
|
+
}
|
|
316
158
|
}
|
|
317
|
-
|
|
318
|
-
return lines;
|
|
159
|
+
return lock;
|
|
319
160
|
}
|
|
320
161
|
|
|
321
|
-
/**
|
|
322
|
-
*
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
330
|
-
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) {
|
|
331
|
-
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
162
|
+
/** Read `<dir>/oats-lock.json`. A missing file is an empty v3 lock; anything
|
|
163
|
+
* else is validated (E_LOCK_SCHEMA). Returns a fresh object every call. */
|
|
164
|
+
export function readLock(dir) {
|
|
165
|
+
const file = join(resolve(dir), LOCK_FILE);
|
|
166
|
+
if (!existsSync(file)) return emptyLock();
|
|
167
|
+
let parsed;
|
|
168
|
+
try { parsed = JSON.parse(readFileSync(file, "utf8")); } catch (e) {
|
|
169
|
+
throw oatsError("E_LOCK_SCHEMA", `oats-lock.json is not valid JSON (${file}): ${e.message}`, { file, path: "/" });
|
|
332
170
|
}
|
|
333
|
-
|
|
334
|
-
let cur = null;
|
|
335
|
-
const flush = () => { if (cur) { regions.push(cur); cur = null; } };
|
|
336
|
-
let i = 0, j = 0;
|
|
337
|
-
while (i < n && j < m) {
|
|
338
|
-
if (a[i] === b[j]) { flush(); i++; j++; continue; }
|
|
339
|
-
if (!cur) cur = { aStart: i, aEnd: i, bStart: j, bEnd: j };
|
|
340
|
-
if (lcs[i + 1][j] >= lcs[i][j + 1]) cur.aEnd = ++i;
|
|
341
|
-
else cur.bEnd = ++j;
|
|
342
|
-
}
|
|
343
|
-
if (i < n || j < m) {
|
|
344
|
-
if (!cur) cur = { aStart: i, aEnd: i, bStart: j, bEnd: j };
|
|
345
|
-
cur.aEnd = n; cur.bEnd = m;
|
|
346
|
-
}
|
|
347
|
-
flush();
|
|
348
|
-
return regions;
|
|
171
|
+
return validateLock(parsed, { file });
|
|
349
172
|
}
|
|
350
173
|
|
|
351
|
-
/**
|
|
352
|
-
|
|
174
|
+
/** Canonical lock text: sorted package ids, sorted capability names, 2-space indent, trailing newline. */
|
|
175
|
+
export function canonicalLock(lock) {
|
|
176
|
+
validateLock(lock);
|
|
177
|
+
const packages = {};
|
|
178
|
+
for (const id of Object.keys(lock.packages).sort()) {
|
|
179
|
+
const e = lock.packages[id];
|
|
180
|
+
packages[id] = {
|
|
181
|
+
source: e.source, ...(typeof e.url === "string" && e.url ? { url: e.url } : {}), path: e.path, version: e.version, commit: e.commit, integrity: e.integrity,
|
|
182
|
+
capabilities: [...e.capabilities].sort(),
|
|
183
|
+
approved: e.approved ? { executables: e.approved.executables, at: e.approved.at } : null,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
return { lockfileVersion: LOCK_VERSION, packages };
|
|
187
|
+
}
|
|
353
188
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
189
|
+
/** Write `<dir>/oats-lock.json` atomically in canonical form. Returns the file path. */
|
|
190
|
+
export function writeLock(dir, lock) {
|
|
191
|
+
const file = join(resolve(dir), LOCK_FILE);
|
|
192
|
+
writeFileAtomic(file, JSON.stringify(canonicalLock(lock), null, 2) + "\n");
|
|
193
|
+
return file;
|
|
358
194
|
}
|
|
359
195
|
|
|
360
|
-
|
|
361
|
-
* may decide about it. Nothing is written and nothing is chosen here.
|
|
362
|
-
*
|
|
363
|
-
* Each returned region carries the exact text of all three sides plus:
|
|
364
|
-
* kind "upstream" — only the template moved away from the base; may be offered
|
|
365
|
-
* "local" — only the local file moved; it always stays (never offered away)
|
|
366
|
-
* "conflict" — both moved, differently; needs an explicit local/package/edit choice
|
|
367
|
-
* "agreed" — both moved to the same text; already in sync, nothing to do
|
|
368
|
-
* recommended the decision applyConfigMerge uses when the caller passes none —
|
|
369
|
-
* null for conflicts, which therefore cannot be resolved silently
|
|
370
|
-
* digest binds the decision to the exact three texts the plan was built
|
|
371
|
-
* from, so a stored automation answer cannot be replayed onto
|
|
372
|
-
* shifted content
|
|
373
|
-
*
|
|
374
|
-
* Region `local` ranges are indices into splitConfigLines(localText); they are
|
|
375
|
-
* what applyConfigMerge splices, which is why round-tripping a plan with no
|
|
376
|
-
* upstream application returns the local bytes unchanged. */
|
|
377
|
-
export function planConfigMerge(baseText, localText, templateText) {
|
|
378
|
-
const base = splitConfigLines(baseText);
|
|
379
|
-
const local = splitConfigLines(localText);
|
|
380
|
-
const template = splitConfigLines(templateText);
|
|
196
|
+
// ---------- workspace `packages:` values ----------
|
|
381
197
|
|
|
382
|
-
|
|
383
|
-
|
|
198
|
+
/** Strip a single leading "v" from a semver-ish tag ("v2.1.3" → "2.1.3"). */
|
|
199
|
+
const versionOf = (text) => text.replace(/^v(?=\d)/, "");
|
|
384
200
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
].sort((x, y) => x.r.aStart - y.r.aStart || x.r.aEnd - y.r.aEnd);
|
|
201
|
+
/** The two — and only two — `packages:` value forms (contract §2, non-collapse rule):
|
|
202
|
+
* catalog version: v2.1.3 | 2.1.3 | v1.0.0-rc.1 (resolves through the official catalog)
|
|
203
|
+
* direct git ref: git:<repo>@<ref> (<repo> = any ref lib/remote.mjs understands;
|
|
204
|
+
* <ref> = tag/branch name or full OID)
|
|
205
|
+
* Shared by lib/workspace.mjs (schema validation) and resolvePackages. */
|
|
206
|
+
export const CATALOG_VERSION_RE = /^v?\d+(?:\.\d+)*(?:[-+][0-9A-Za-z.-]+)?$/;
|
|
207
|
+
const REF_NAME_RE = /^(?![-/.])(?!.*\.\.)(?!.*[\s~^:?*[\\\x00-\x1f\x7f])(?!.*@\{)(?!.*\/\.)(?!.*\.lock(?:\/|$))(?!.*\/\/)[^/]+(?:\/[^/]+)*(?<!\/|\.)$/;
|
|
393
208
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Classify one `packages:` value WITHOUT touching the catalog or the network.
|
|
211
|
+
* → { kind: "catalog", version } | { kind: "git", repo, at } | { problem: "<why>" }
|
|
212
|
+
* `repo` is the remainder after `git:` when that remainder is itself a full ref
|
|
213
|
+
* (`/abs/bare.git`, `file:///…`, `https://…`, `git@host:…`), else `git:<host>/<path>` as written.
|
|
214
|
+
*/
|
|
215
|
+
export function classifyPackageValue(value) {
|
|
216
|
+
if (typeof value !== "string" || !value.trim()) return { problem: "must be a non-empty string" };
|
|
217
|
+
const text = value.trim();
|
|
218
|
+
if (text !== value) return { problem: "must not carry surrounding whitespace" };
|
|
219
|
+
if (text.startsWith("git:")) {
|
|
220
|
+
const atIdx = text.lastIndexOf("@");
|
|
221
|
+
if (atIdx <= "git:".length) return { problem: "a git package must be written git:<repo>@<ref> (no @<ref> found)" };
|
|
222
|
+
const rest = text.slice("git:".length, atIdx);
|
|
223
|
+
const at = text.slice(atIdx + 1);
|
|
224
|
+
if (!at) return { problem: "a git package must be written git:<repo>@<ref> (empty <ref>)" };
|
|
225
|
+
if (rest.includes("@") && !/^git@[^@/:]+:/.test(rest)) return { problem: "a git package carries exactly one @<ref> (a repo ref has no @ except the git@host: ssh form)" };
|
|
226
|
+
if (!OID_RE.test(at) && !REF_NAME_RE.test(at)) return { problem: `${JSON.stringify(at)} is not a tag/branch name or full OID` };
|
|
227
|
+
if (!rest) return { problem: "a git package must be written git:<repo>@<ref> (empty <repo>)" };
|
|
228
|
+
// `git:` + an already-complete ref form: hand the remainder to parseRepoRef unchanged.
|
|
229
|
+
const repo = /^(?:\/|file:\/\/|https?:\/\/|git@)/.test(rest) ? rest : `git:${rest}`;
|
|
230
|
+
return { kind: "git", repo, at };
|
|
231
|
+
}
|
|
232
|
+
if (/^(?:https?:\/\/|file:\/\/|git@|\/)/.test(text) || text.includes("@") || text.includes("/")) {
|
|
233
|
+
return { problem: "a package outside the catalog must be written git:<repo>@<ref>" };
|
|
234
|
+
}
|
|
235
|
+
if (!CATALOG_VERSION_RE.test(text)) return { problem: `${JSON.stringify(text)} is neither a version (v2.1.3) nor git:<repo>@<ref>` };
|
|
236
|
+
return { kind: "catalog", version: versionOf(text) };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Split a catalog ref into a prefix and its version tail:
|
|
240
|
+
* "v2.1.3" → { prefix: "", vee: true } ; "oats-framework/v1.1.3" → { prefix: "oats-framework/", vee: true }. */
|
|
241
|
+
function refPattern(catalogRef) {
|
|
242
|
+
const m = /^(.*?)(v?)(\d[^/]*)$/.exec(catalogRef);
|
|
243
|
+
if (!m) return null;
|
|
244
|
+
return { prefix: m[1], vee: m[2] === "v" };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Recompose a catalog ref for a requested version, keeping the catalog's tag convention. */
|
|
248
|
+
function catalogRefFor(entry, requested) {
|
|
249
|
+
const pat = refPattern(entry.ref || "");
|
|
250
|
+
const bare = versionOf(requested);
|
|
251
|
+
if (!pat) return requested; // catalog ref carries no version tail — the request is the ref
|
|
252
|
+
return `${pat.prefix}${pat.vee ? "v" : ""}${bare}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Accept either the file shape ({ policy, packages: {…} }) or a bare id → entry map. */
|
|
256
|
+
function catalogPackages(catalog) {
|
|
257
|
+
if (!plainObject(catalog)) return {};
|
|
258
|
+
if (plainObject(catalog.packages) && !("url" in catalog.packages)) return catalog.packages;
|
|
259
|
+
return catalog;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Interpret one `workspace.packages` value for package `id`.
|
|
263
|
+
* - "<version>" → catalog lookup; ref recomposed from the catalog's tag convention
|
|
264
|
+
* - "git:<repo>@<ref>" → direct ref; `<repo>` is any repo ref `lib/remote.mjs` understands
|
|
265
|
+
* (git:host/path, https://…, git@host:…, /abs/bare.git, file:///…)
|
|
266
|
+
* Nothing else is accepted (E_WORKSPACE_SCHEMA for a malformed value, E_REPO_REF for a bad git form).
|
|
267
|
+
* → { kind: "catalog"|"git", remoteRef, at, path } */
|
|
268
|
+
export function parsePackageRequest(id, value, catalog) {
|
|
269
|
+
const c = classifyPackageValue(value);
|
|
270
|
+
if (c.problem) {
|
|
271
|
+
const code = typeof value === "string" && value.trim().startsWith("git:") ? "E_REPO_REF" : "E_WORKSPACE_SCHEMA";
|
|
272
|
+
throw oatsError(code, `packages.${id}: ${c.problem}, got ${JSON.stringify(value)}`, { id, value, path: `/packages/${id}`, problems: [c.problem] });
|
|
273
|
+
}
|
|
274
|
+
if (c.kind === "git") return { kind: "git", remoteRef: c.repo, at: c.at, path: DEFAULT_PACKAGE_PATH };
|
|
275
|
+
const text = value.trim();
|
|
276
|
+
const entry = catalogPackages(catalog)[id];
|
|
277
|
+
if (!plainObject(entry) || typeof entry.url !== "string") {
|
|
278
|
+
throw oatsError("E_PACKAGE_MISSING", `packages.${id}: ${JSON.stringify(text)} is a catalog version but the catalog has no package ${JSON.stringify(id)} — use git:<repo>@<ref> for a package outside the catalog`, { id, value: text });
|
|
279
|
+
}
|
|
280
|
+
return { kind: "catalog", remoteRef: entry.url, at: catalogRefFor(entry, text), path: typeof entry.path === "string" && entry.path ? entry.path : DEFAULT_PACKAGE_PATH };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---------- reading a package over the injected remote ----------
|
|
284
|
+
|
|
285
|
+
const pjoin = (...parts) => posix.normalize(posix.join(...parts));
|
|
286
|
+
|
|
287
|
+
const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
288
|
+
|
|
289
|
+
function assertRelPath(what, rel, details) {
|
|
290
|
+
if (typeof rel !== "string" || !rel || isAbsolute(rel) || /^[\\/]/.test(rel) || /[\x00]/.test(rel) || rel.split(/[\\/]/).some((p) => p === ".." || /^[A-Za-z]:$/.test(p))) {
|
|
291
|
+
throw oatsError("E_PACKAGE_MANIFEST", `${what} must be a relative path inside the package, got ${JSON.stringify(rel)}`, details);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Read + parse a JSON file of the package. A missing file is E_PACKAGE_MANIFEST (the
|
|
296
|
+
* package is malformed), never a leaked E_REMOTE_PATH_MISSING. */
|
|
297
|
+
async function readJson(remote, remoteRef, commit, path, what, details) {
|
|
298
|
+
let bytes;
|
|
299
|
+
try { ({ bytes } = await remote.readRemoteFile(remoteRef, commit, path)); } catch (e) {
|
|
300
|
+
if (e?.code === "E_REMOTE_PATH_MISSING") throw oatsError("E_PACKAGE_MANIFEST", `${what} is missing: no ${path} in ${typeof remoteRef === "string" ? remoteRef : remoteRef?.key}@${String(commit).slice(0, 12)}`, { ...details, path, cause: e.code });
|
|
301
|
+
throw e;
|
|
403
302
|
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
// line outside a group is common to base and that side, so the mapping is
|
|
407
|
-
// exact rather than approximate.
|
|
408
|
-
let localOffset = 0, templateOffset = 0;
|
|
409
|
-
const regions = [];
|
|
410
|
-
for (const [index, group] of groups.entries()) {
|
|
411
|
-
const span = group.aEnd - group.aStart;
|
|
412
|
-
const localItems = group.items.filter((it) => it.side === "local").map((it) => it.r);
|
|
413
|
-
const templateItems = group.items.filter((it) => it.side === "template").map((it) => it.r);
|
|
414
|
-
const localStart = group.aStart + localOffset;
|
|
415
|
-
const templateStart = group.aStart + templateOffset;
|
|
416
|
-
const localLen = span + localItems.reduce((sum, r) => sum + regionDelta(r), 0);
|
|
417
|
-
const templateLen = span + templateItems.reduce((sum, r) => sum + regionDelta(r), 0);
|
|
418
|
-
localOffset += localLen - span;
|
|
419
|
-
templateOffset += templateLen - span;
|
|
420
|
-
|
|
421
|
-
const baseSlice = base.slice(group.aStart, group.aEnd).join("");
|
|
422
|
-
const localSlice = local.slice(localStart, localStart + localLen).join("");
|
|
423
|
-
const templateSlice = template.slice(templateStart, templateStart + templateLen).join("");
|
|
424
|
-
|
|
425
|
-
const localChanged = localItems.length > 0;
|
|
426
|
-
const templateChanged = templateItems.length > 0;
|
|
427
|
-
let kind;
|
|
428
|
-
if (localChanged && templateChanged) kind = localSlice === templateSlice ? "agreed" : "conflict";
|
|
429
|
-
else if (templateChanged) kind = "upstream";
|
|
430
|
-
else kind = "local";
|
|
431
|
-
|
|
432
|
-
regions.push({
|
|
433
|
-
id: `h${index + 1}`,
|
|
434
|
-
kind,
|
|
435
|
-
recommended: kind === "conflict" ? null : kind === "upstream" ? "package" : "local",
|
|
436
|
-
digest: digestOf(baseSlice, localSlice, templateSlice),
|
|
437
|
-
base: { start: group.aStart, end: group.aEnd, text: baseSlice },
|
|
438
|
-
local: { start: localStart, end: localStart + localLen, text: localSlice },
|
|
439
|
-
template: { start: templateStart, end: templateStart + templateLen, text: templateSlice },
|
|
440
|
-
});
|
|
303
|
+
try { return JSON.parse(Buffer.from(bytes).toString("utf8")); } catch (e) {
|
|
304
|
+
throw oatsError("E_PACKAGE_MANIFEST", `${what} at ${path} is not valid JSON: ${e.message}`, { ...details, path });
|
|
441
305
|
}
|
|
442
|
-
|
|
443
|
-
const counts = { upstream: 0, local: 0, conflict: 0, agreed: 0 };
|
|
444
|
-
for (const r of regions) counts[r.kind]++;
|
|
445
|
-
return {
|
|
446
|
-
regions,
|
|
447
|
-
counts,
|
|
448
|
-
conflicts: regions.filter((r) => r.kind === "conflict").map((r) => r.id),
|
|
449
|
-
// "clean" means a caller can apply the recommendations without asking anyone.
|
|
450
|
-
clean: counts.conflict === 0,
|
|
451
|
-
localDigest: digestOf(localText),
|
|
452
|
-
planDigest: digestOf(baseText, localText, templateText),
|
|
453
|
-
};
|
|
454
306
|
}
|
|
455
307
|
|
|
456
|
-
/**
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
* bytes. With no decisions and no upstream regions, text === localText exactly,
|
|
465
|
-
* byte for byte. */
|
|
466
|
-
export function applyConfigMerge(localText, plan, decisions = {}) {
|
|
467
|
-
const local = splitConfigLines(localText);
|
|
468
|
-
// A plan carries the digest of the local text it was computed against. Line
|
|
469
|
-
// indices are meaningless against any other text, so applying a plan the user
|
|
470
|
-
// reviewed before the file changed under them must fail, not splice at the
|
|
471
|
-
// old offsets.
|
|
472
|
-
if (plan.localDigest && plan.localDigest !== digestOf(localText)) {
|
|
473
|
-
fail("E_SYNC_STALE_PLAN", "the local oats-config.yaml changed after this merge plan was computed — re-run the diff and review the plan again");
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
const byId = new Map(plan.regions.map((r) => [r.id, r]));
|
|
477
|
-
for (const id of Object.keys(decisions)) {
|
|
478
|
-
if (!byId.has(id)) fail("E_SYNC_UNKNOWN_REGION", `no such change region "${id}" in this plan (regions: ${[...byId.keys()].join(", ") || "none"})`);
|
|
308
|
+
/** Read `oats-package.json` and every capability manifest it lists.
|
|
309
|
+
* → { manifest, capabilities: [{ name, dir, manifest }] } (sorted by name, codepoint order).
|
|
310
|
+
* Two capability dirs declaring the same name → E_PACKAGE_MANIFEST { duplicate }. */
|
|
311
|
+
export async function readPackageManifests(remote, remoteRef, commit, path, details = {}) {
|
|
312
|
+
const manifestPath = pjoin(path, PACKAGE_MANIFEST);
|
|
313
|
+
const manifest = await readJson(remote, remoteRef, commit, manifestPath, "package manifest", details);
|
|
314
|
+
if (!plainObject(manifest) || typeof manifest.package !== "string" || !Array.isArray(manifest.capabilities)) {
|
|
315
|
+
throw oatsError("E_PACKAGE_MANIFEST", `package manifest at ${manifestPath} must declare "package" and "capabilities"`, { ...details, path: manifestPath });
|
|
479
316
|
}
|
|
480
|
-
|
|
481
|
-
const
|
|
482
|
-
for (const
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
317
|
+
const capabilities = [];
|
|
318
|
+
const seen = new Map();
|
|
319
|
+
for (const rel of manifest.capabilities) {
|
|
320
|
+
assertRelPath(`${manifestPath} capabilities[]`, rel, { ...details, path: manifestPath });
|
|
321
|
+
const dir = pjoin(path, rel);
|
|
322
|
+
const capManifest = await readJson(remote, remoteRef, commit, pjoin(dir, CAPABILITY_MANIFEST), "capability manifest", details);
|
|
323
|
+
if (!plainObject(capManifest) || typeof capManifest.capability !== "string" || !capManifest.capability) {
|
|
324
|
+
throw oatsError("E_PACKAGE_MANIFEST", `capability manifest at ${pjoin(dir, CAPABILITY_MANIFEST)} must declare "capability"`, { ...details, path: dir });
|
|
486
325
|
}
|
|
487
|
-
if (
|
|
488
|
-
|
|
489
|
-
chosen.push({ region, choice: "edit", text: raw.edit });
|
|
490
|
-
continue;
|
|
326
|
+
if (seen.has(capManifest.capability)) {
|
|
327
|
+
throw oatsError("E_PACKAGE_MANIFEST", `package ${manifest.package} declares capability ${JSON.stringify(capManifest.capability)} twice (${seen.get(capManifest.capability)} and ${dir})`, { ...details, path: dir, duplicate: capManifest.capability, other: seen.get(capManifest.capability) });
|
|
491
328
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
const out = [];
|
|
497
|
-
const applied = [];
|
|
498
|
-
let cursor = 0;
|
|
499
|
-
for (const { region, choice, text } of chosen) {
|
|
500
|
-
out.push(local.slice(cursor, region.local.start).join(""));
|
|
501
|
-
let replacement = text;
|
|
502
|
-
const isTail = region.local.end >= local.length;
|
|
503
|
-
// A replacement that does not end in a newline while local content follows
|
|
504
|
-
// would glue two YAML lines together. Terminate it instead of emitting a
|
|
505
|
-
// corrupt config; the tail region legitimately may end without a newline.
|
|
506
|
-
if (replacement && !replacement.endsWith("\n") && !isTail) replacement += "\n";
|
|
507
|
-
out.push(replacement);
|
|
508
|
-
if (replacement !== region.local.text) applied.push({ id: region.id, kind: region.kind, choice });
|
|
509
|
-
cursor = region.local.end;
|
|
329
|
+
seen.set(capManifest.capability, dir);
|
|
330
|
+
capabilities.push({ name: capManifest.capability, dir, manifest: capManifest });
|
|
510
331
|
}
|
|
511
|
-
|
|
512
|
-
return {
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// ---------- run-level rollback journal (CLI-private) ----------
|
|
516
|
-
//
|
|
517
|
-
// A multi-step init or template adoption touches several artifacts that no
|
|
518
|
-
// single engine call spans: the active config, the lock, the flat installed
|
|
519
|
-
// capability store, the capability .gitignore, and the adopted template base
|
|
520
|
-
// plus its metadata. Engine operations are individually atomic and expose no
|
|
521
|
-
// transaction handle, so the RUN-level guarantee — "a later failure rolls back
|
|
522
|
-
// only this run's changes and preserves pre-existing bytes/artifacts" — is the
|
|
523
|
-
// CLI's to keep. This journal is that mechanism, deliberately private to this
|
|
524
|
-
// lane rather than a public kernel API.
|
|
525
|
-
//
|
|
526
|
-
// It is engine-independent by construction: paths in, bytes out. It knows the
|
|
527
|
-
// scope layout and nothing about locks, packages, or capabilities.
|
|
528
|
-
|
|
529
|
-
/** Scope-relative artifacts a run-level transaction must be able to undo. */
|
|
530
|
-
export const RUN_JOURNAL_PATHS = Object.freeze([
|
|
531
|
-
"oats-config.yaml",
|
|
532
|
-
"oats-lock.json",
|
|
533
|
-
".agents/capabilities/installed",
|
|
534
|
-
".agents/capabilities/.gitignore",
|
|
535
|
-
".agents/config-templates/adopted",
|
|
536
|
-
// The recoverable backup is run state too. Without it a failed sync could
|
|
537
|
-
// leave a .bak from THIS run behind (or destroy one an earlier run left),
|
|
538
|
-
// and rollback would report a clean failure that was not clean.
|
|
539
|
-
"oats-config.yaml.bak",
|
|
540
|
-
]);
|
|
541
|
-
|
|
542
|
-
/** The anchor whose creation is itself part of the run's changes. */
|
|
543
|
-
const AGENTS_ANCHOR = ".agents";
|
|
544
|
-
|
|
545
|
-
const isInside = (base, p) => p === base || p.startsWith(base.endsWith(sep) ? base : base + sep);
|
|
546
|
-
|
|
547
|
-
/** Record what a path IS without following it: absent, symlink (+target), dir, or file (+mode). */
|
|
548
|
-
function classifyPath(p) {
|
|
549
|
-
let st;
|
|
550
|
-
try { st = lstatSync(p); } catch { return { kind: "absent" }; }
|
|
551
|
-
if (st.isSymbolicLink()) return { kind: "symlink", target: readlinkSync(p), mode: st.mode & 0o7777 };
|
|
552
|
-
if (st.isDirectory()) return { kind: "dir", mode: st.mode & 0o7777 };
|
|
553
|
-
return { kind: "file", mode: st.mode & 0o7777 };
|
|
332
|
+
capabilities.sort((a, b) => byCodepoint(a.name, b.name));
|
|
333
|
+
return { manifest, capabilities };
|
|
554
334
|
}
|
|
555
335
|
|
|
556
|
-
/**
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
336
|
+
/** Content digest of the package tree at `path`: the digest `fetchRemoteTree` reports for a
|
|
337
|
+
* copy into a scratch directory (contract §1: `contentDigest(dir)` is the local-directory form;
|
|
338
|
+
* the remote has no digest-without-copy primitive yet — see the deferred note in the review). */
|
|
339
|
+
async function packageIntegrity(remote, remoteRef, commit, path) {
|
|
340
|
+
if (typeof remote.fetchRemoteTree !== "function") {
|
|
341
|
+
throw oatsError("E_PACKAGE_INTEGRITY", "remote must provide fetchRemoteTree() to compute a package's integrity", { path: "/remote" });
|
|
342
|
+
}
|
|
343
|
+
const scratch = mkdtempSync(join(tmpdir(), "oats-pkg-"));
|
|
344
|
+
const dest = join(scratch, "tree");
|
|
345
|
+
try {
|
|
346
|
+
const { digest } = await remote.fetchRemoteTree(remoteRef, commit, path, dest, { allowSymlinks: defaultRemote.OATS_ALIAS_SYMLINK });
|
|
347
|
+
return digest;
|
|
348
|
+
} finally { rmSync(scratch, { recursive: true, force: true }); }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Depth bound for reading a capability directory: deep enough for any real layout. */
|
|
352
|
+
export const PACKAGE_TREE_DEPTH = 64;
|
|
353
|
+
|
|
354
|
+
/** Build a `packageTree` (see module header) for `executablesDigest` from the remote.
|
|
355
|
+
* Reads every blob under each capability directory. */
|
|
356
|
+
export async function readPackageTree(remote, remoteRef, commit, path, { depth = PACKAGE_TREE_DEPTH, remoteOptions } = {}) {
|
|
357
|
+
remote = bindRemote(remote, remoteOptions);
|
|
358
|
+
const { capabilities } = await readPackageManifests(remote, remoteRef, commit, path);
|
|
359
|
+
const manifests = [];
|
|
360
|
+
for (const cap of capabilities) {
|
|
361
|
+
const listing = await remote.listRemoteTree(remoteRef, commit, cap.dir, { depth });
|
|
362
|
+
const files = new Map();
|
|
363
|
+
for (const item of listing) {
|
|
364
|
+
if (item.type !== "blob") continue;
|
|
365
|
+
const { bytes } = await remote.readRemoteFile(remoteRef, commit, pjoin(cap.dir, item.path));
|
|
366
|
+
files.set(item.path, Buffer.from(bytes));
|
|
578
367
|
}
|
|
579
|
-
|
|
368
|
+
manifests.push({ name: cap.name, manifest: cap.manifest, files });
|
|
580
369
|
}
|
|
370
|
+
return { manifests };
|
|
581
371
|
}
|
|
582
372
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
const trimmed = rel.trim();
|
|
589
|
-
if (!trimmed) fail("E_JOURNAL_BAD_PATH", "journalled path must be a nonempty relative path");
|
|
590
|
-
if (trimmed.includes("\\")) fail("E_JOURNAL_BAD_PATH", `journalled path must use "/" separators: ${rel}`);
|
|
591
|
-
if (isAbsolute(trimmed)) fail("E_JOURNAL_PATH_ESCAPE", `journalled path must stay inside the scope: ${rel}`);
|
|
592
|
-
const parts = trimmed.split("/").filter((p) => p !== "" && p !== ".");
|
|
593
|
-
if (parts.includes("..")) fail("E_JOURNAL_PATH_ESCAPE", `journalled path must stay inside the scope: ${rel}`);
|
|
594
|
-
if (!parts.length) fail("E_JOURNAL_BAD_PATH", `journalled path resolves to the scope itself: ${rel}`);
|
|
595
|
-
const canonical = parts.join("/");
|
|
596
|
-
if (seen.has(canonical)) fail("E_JOURNAL_DUPLICATE_PATH", `journalled path listed more than once: ${canonical}`);
|
|
597
|
-
seen.add(canonical);
|
|
598
|
-
return canonical;
|
|
373
|
+
// ---------- resolvePackages ----------
|
|
374
|
+
|
|
375
|
+
function assertDigest(what, value, details) {
|
|
376
|
+
if (typeof value !== "string" || !DIGEST_RE.test(value)) throw oatsError("E_PACKAGE_INTEGRITY", `${what} is not a sha256-<hex> digest: ${JSON.stringify(value)}`, details);
|
|
377
|
+
return value;
|
|
599
378
|
}
|
|
600
379
|
|
|
601
|
-
/**
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
mkdirSync(dirname(to), { recursive: true });
|
|
613
|
-
if (st.isSymbolicLink()) { symlinkSync(readlinkSync(from), to); return; }
|
|
614
|
-
if (st.isDirectory()) {
|
|
615
|
-
mkdirSync(to, { recursive: true });
|
|
616
|
-
for (const name of readdirSync(from)) copyExact(join(from, name), join(to, name));
|
|
617
|
-
// Mode and times go on AFTER the children: a read-only directory set first
|
|
618
|
-
// would reject its own contents.
|
|
619
|
-
chmodSync(to, st.mode & 0o7777);
|
|
620
|
-
utimesSync(to, st.atime, st.mtime);
|
|
621
|
-
return;
|
|
380
|
+
/** Bind `remoteOptions` (cacheDir, exec, …) into every call of a contract-§1 remote. */
|
|
381
|
+
export function bindRemote(remote, remoteOptions) {
|
|
382
|
+
if (!remoteOptions || Object.keys(remoteOptions).length === 0) return remote;
|
|
383
|
+
const bound = { ...remote };
|
|
384
|
+
for (const name of ["observeRemote", "readRemoteFile", "listRemoteTree", "fetchRemoteTree"]) {
|
|
385
|
+
if (typeof remote[name] !== "function") continue;
|
|
386
|
+
bound[name] = (...args) => {
|
|
387
|
+
const arity = { observeRemote: 1, readRemoteFile: 3, listRemoteTree: 3, fetchRemoteTree: 4 }[name];
|
|
388
|
+
const opts = { ...(args[arity] || {}), ...remoteOptions };
|
|
389
|
+
return remote[name](...args.slice(0, arity), opts);
|
|
390
|
+
};
|
|
622
391
|
}
|
|
623
|
-
|
|
624
|
-
copyFileSync(from, to);
|
|
625
|
-
chmodSync(to, st.mode & 0o7777);
|
|
626
|
-
utimesSync(to, st.atime, st.mtime);
|
|
392
|
+
return bound;
|
|
627
393
|
}
|
|
628
394
|
|
|
629
|
-
/**
|
|
395
|
+
/**
|
|
396
|
+
* Resolve every `workspace.packages` entry to an exact commit + integrity and
|
|
397
|
+
* merge into the lock (immutably — the input lock is never mutated).
|
|
630
398
|
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
399
|
+
* options.catalog: package-catalog.json (file shape or its `packages` map)
|
|
400
|
+
* options.lock: current lock (v3) — defaults to an empty lock
|
|
401
|
+
* options.remote: { observeRemote, readRemoteFile, listRemoteTree, fetchRemoteTree } — defaults to
|
|
402
|
+
* lib/remote.mjs; tests pass an in-memory fake. options.remoteOptions (cacheDir, exec, …)
|
|
403
|
+
* is threaded into every remote call.
|
|
635
404
|
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
const
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
// out rather than being a special case.
|
|
657
|
-
const preexistingDirs = new Set();
|
|
658
|
-
const noteAncestors = (rel) => {
|
|
659
|
-
const parts = rel.split("/").filter(Boolean);
|
|
660
|
-
let cur = scope;
|
|
661
|
-
for (const part of parts.slice(0, -1)) {
|
|
662
|
-
cur = join(cur, part);
|
|
663
|
-
if (existsSync(cur)) preexistingDirs.add(cur);
|
|
664
|
-
}
|
|
665
|
-
};
|
|
666
|
-
|
|
667
|
-
const backupDir = mkdtempSync(join(backupRoot, "oats-run-journal-"));
|
|
668
|
-
const entries = [];
|
|
669
|
-
let anchorExisted;
|
|
670
|
-
try {
|
|
671
|
-
// The backup must not live inside the tree it protects: restoring a
|
|
672
|
-
// directory means deleting it first, which would delete the backup with it.
|
|
673
|
-
if (isInside(scopeReal, realpathSync(backupDir))) {
|
|
674
|
-
fail("E_JOURNAL_BACKUP_INSIDE_SCOPE", `run-journal backup ${backupDir} would live inside the protected scope ${scopeReal}`);
|
|
675
|
-
}
|
|
676
|
-
for (const [index, rel] of rels.entries()) {
|
|
677
|
-
assertJournalContainment(scopeReal, rel);
|
|
678
|
-
noteAncestors(rel);
|
|
679
|
-
const path = join(scope, rel);
|
|
680
|
-
const state = classifyPath(path);
|
|
681
|
-
// Keyed by INDEX, never by a flattened path: "a/b" and "a__b" are
|
|
682
|
-
// different artifacts and any separator-substitution scheme collides them,
|
|
683
|
-
// which would restore one entry's bytes over the other's.
|
|
684
|
-
const backup = state.kind === "absent" ? null : join(backupDir, String(index));
|
|
685
|
-
if (backup) copyExact(path, backup);
|
|
686
|
-
entries.push({ rel, path, backup, ...state });
|
|
687
|
-
}
|
|
688
|
-
const anchorPath = join(scope, AGENTS_ANCHOR);
|
|
689
|
-
anchorExisted = preexistingDirs.has(anchorPath) || existsSync(anchorPath);
|
|
690
|
-
if (anchorExisted) preexistingDirs.add(anchorPath);
|
|
691
|
-
} catch (e) {
|
|
692
|
-
// A half-built journal protects nothing, so it must not outlive the
|
|
693
|
-
// failure: leaving the partial backup behind would strand a copy of the
|
|
694
|
-
// scope's bytes in the temp tree with no owner to clean it up.
|
|
695
|
-
rmSync(backupDir, { recursive: true, force: true });
|
|
696
|
-
throw e;
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
const anchorPath = join(scope, AGENTS_ANCHOR);
|
|
700
|
-
|
|
701
|
-
let state = "open";
|
|
405
|
+
* → { lock, changes: [{ id, from, to, commit, approvalNeeded }] }
|
|
406
|
+
* - `from` is the previously locked version or null; `to` is the resolved version
|
|
407
|
+
* (null when the package was removed from the workspace and dropped from the lock).
|
|
408
|
+
* - `approvalNeeded` = the entry's `approved` is null.
|
|
409
|
+
* - A locked entry whose version string is unchanged but whose commit or
|
|
410
|
+
* integrity moved → E_PACKAGE_INTEGRITY { id, version, locked, observed }.
|
|
411
|
+
* - Unchanged entries keep their approval ONLY when the recorded executables digest still
|
|
412
|
+
* matches the tree (else E_PACKAGE_UNAPPROVED); a new version starts unapproved.
|
|
413
|
+
* - A value resolving to a BRANCH → E_PACKAGE_INTEGRITY { why: "branch" }: versions are immutable.
|
|
414
|
+
*/
|
|
415
|
+
export async function resolvePackages(workspace, { catalog = {}, lock = emptyLock(), remote = defaultRemote, remoteOptions } = {}) {
|
|
416
|
+
if (!remote || typeof remote.observeRemote !== "function" || typeof remote.readRemoteFile !== "function") {
|
|
417
|
+
throw new TypeError("resolvePackages: remote must provide observeRemote()/readRemoteFile() (module contract §1)");
|
|
418
|
+
}
|
|
419
|
+
remote = bindRemote(remote, remoteOptions);
|
|
420
|
+
const previous = validateLock(lock);
|
|
421
|
+
const requests = plainObject(workspace?.packages) ? workspace.packages : {};
|
|
422
|
+
const ids = Object.keys(requests).sort();
|
|
423
|
+
const nextPackages = {};
|
|
424
|
+
const changes = [];
|
|
702
425
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
try {
|
|
710
|
-
// Remove whatever is there now. On a symlink this unlinks the link
|
|
711
|
-
// itself, so a hostile link target is never written through.
|
|
712
|
-
rmSync(entry.path, { recursive: true, force: true });
|
|
713
|
-
if (entry.kind === "absent") { report.removed.push(entry.rel); return; }
|
|
714
|
-
copyExact(entry.backup, entry.path);
|
|
715
|
-
if (entry.kind !== "symlink") chmodSync(entry.path, entry.mode);
|
|
716
|
-
report.restored.push(entry.rel);
|
|
717
|
-
} catch (e) {
|
|
718
|
-
report.failures.push({ path: entry.rel, error: e.message });
|
|
426
|
+
for (const id of ids) {
|
|
427
|
+
const req = parsePackageRequest(id, requests[id], catalog);
|
|
428
|
+
const details = { id, value: requests[id] };
|
|
429
|
+
const obs = await remote.observeRemote(req.remoteRef, { at: req.at });
|
|
430
|
+
if (!obs || typeof obs.commit !== "string" || !OID_RE.test(obs.commit)) {
|
|
431
|
+
throw oatsError("E_PACKAGE_INTEGRITY", `packages.${id}: remote did not yield a full commit OID for ${req.remoteRef}@${req.at}`, { ...details, observed: obs?.commit });
|
|
719
432
|
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
const candidates = new Set();
|
|
725
|
-
for (const { rel } of entries) {
|
|
726
|
-
const parts = rel.split("/").filter(Boolean);
|
|
727
|
-
let cur = scope;
|
|
728
|
-
for (const part of parts.slice(0, -1)) { cur = join(cur, part); candidates.add(cur); }
|
|
433
|
+
// A package version must be immutable: a branch is a moving target and is refused up front
|
|
434
|
+
// (a tag that later moves is caught below as E_PACKAGE_INTEGRITY).
|
|
435
|
+
if (typeof obs.ref === "string" && /^refs\/heads\//.test(obs.ref)) {
|
|
436
|
+
throw oatsError("E_PACKAGE_INTEGRITY", `packages.${id}: ${req.at} is a branch (${obs.ref}), not a version — pin a tag or a full commit OID`, { ...details, why: "branch", ref: obs.ref, commit: obs.commit });
|
|
729
437
|
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
438
|
+
const source = req.kind === "catalog" ? `catalog:${id}` : `git:${obs.key}@${req.at}`;
|
|
439
|
+
const version = req.kind === "catalog" ? versionOf(requests[id].trim()) : versionOf(req.at);
|
|
440
|
+
const old = previous.packages[id] || null;
|
|
441
|
+
|
|
442
|
+
// Same version + same source + same path as locked: the lock must still describe reality.
|
|
443
|
+
if (old && old.version === version && old.source === source && old.path === req.path) {
|
|
444
|
+
if (old.commit !== obs.commit) {
|
|
445
|
+
throw oatsError("E_PACKAGE_INTEGRITY",
|
|
446
|
+
`packages.${id} ${version} is locked at ${old.commit} but ${source} now resolves to ${obs.commit} — the tag moved; a version string must change when its content does`,
|
|
447
|
+
{ ...details, version, locked: { commit: old.commit, integrity: old.integrity }, observed: { commit: obs.commit } });
|
|
739
448
|
}
|
|
449
|
+
const integrity = assertDigest(`packages.${id} integrity`, await packageIntegrity(remote, req.remoteRef, obs.commit, old.path), details);
|
|
450
|
+
if (integrity !== old.integrity) {
|
|
451
|
+
throw oatsError("E_PACKAGE_INTEGRITY",
|
|
452
|
+
`packages.${id} ${version} @ ${obs.commit}: content digest ${integrity} does not match the locked ${old.integrity}`,
|
|
453
|
+
{ ...details, version, locked: { commit: old.commit, integrity: old.integrity }, observed: { commit: obs.commit, integrity } });
|
|
454
|
+
}
|
|
455
|
+
// A recorded approval must describe THESE executables, not merely any well-formed digest.
|
|
456
|
+
if (old.approved) {
|
|
457
|
+
const executables = executablesDigest(await readPackageTree(remote, req.remoteRef, obs.commit, old.path));
|
|
458
|
+
if (executables !== old.approved.executables) {
|
|
459
|
+
throw oatsError("E_PACKAGE_UNAPPROVED",
|
|
460
|
+
`packages.${id} ${version}: the recorded approval ${old.approved.executables} does not match the package's executables ${executables} — approve again`,
|
|
461
|
+
{ ...details, version, commit: obs.commit, approved: old.approved, executables });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
nextPackages[id] = clone(old);
|
|
465
|
+
continue;
|
|
740
466
|
}
|
|
741
|
-
};
|
|
742
|
-
|
|
743
|
-
return {
|
|
744
|
-
scope,
|
|
745
|
-
backupDir,
|
|
746
|
-
anchorCreatedByRun: !anchorExisted,
|
|
747
|
-
/** What the journal is protecting, for previews and diagnostics. */
|
|
748
|
-
protected: entries.map(({ rel, kind }) => ({ path: rel, was: kind })),
|
|
749
|
-
get state() { return state; },
|
|
750
|
-
|
|
751
|
-
/** Undo this run. Attempts EVERY step even after one fails, so a partial
|
|
752
|
-
* failure is reported in full rather than hidden behind the first error. */
|
|
753
|
-
rollback() {
|
|
754
|
-
if (state === "finalized") fail("E_JOURNAL_FINALIZED", "this run was already finalized — its backup is gone and it cannot be rolled back");
|
|
755
|
-
const report = { restored: [], removed: [], failures: [], complete: true, summary: "" };
|
|
756
|
-
if (state === "rolled-back") { report.summary = "nothing to roll back (already rolled back)"; return report; }
|
|
757
|
-
for (const entry of entries) restoreEntry(entry, report);
|
|
758
|
-
pruneCreatedDirs(report);
|
|
759
|
-
report.complete = report.failures.length === 0;
|
|
760
|
-
report.summary = report.complete
|
|
761
|
-
? `rolled back ${report.restored.length} restored, ${report.removed.length} removed`
|
|
762
|
-
: `ROLLBACK INCOMPLETE — ${report.failures.length} of ${entries.length} artifact(s) could not be restored: ${report.failures.map((f) => `${f.path} (${f.error})`).join("; ")}`;
|
|
763
|
-
// The backup survives an incomplete rollback: it is the only remaining
|
|
764
|
-
// copy of the pre-run bytes, so destroying it would turn a recoverable
|
|
765
|
-
// failure into permanent loss.
|
|
766
|
-
if (report.complete) { rmSync(backupDir, { recursive: true, force: true }); state = "rolled-back"; }
|
|
767
|
-
return report;
|
|
768
|
-
},
|
|
769
|
-
|
|
770
|
-
/** The run succeeded — drop the backup. Point of no return. */
|
|
771
|
-
finalize() {
|
|
772
|
-
if (state === "rolled-back") fail("E_JOURNAL_ROLLED_BACK", "this run was already rolled back and cannot be finalized");
|
|
773
|
-
rmSync(backupDir, { recursive: true, force: true });
|
|
774
|
-
state = "finalized";
|
|
775
|
-
},
|
|
776
|
-
};
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
/** Capability ids supplied by the visible locked packages of a scope.
|
|
780
|
-
*
|
|
781
|
-
* Reads the CAPABILITY rows, not the package rows: in the revised lock a
|
|
782
|
-
* package row carries no capability list at all, and the capability row's
|
|
783
|
-
* `package` back-reference is the single provider truth — which is exactly why
|
|
784
|
-
* the two levels can no longer disagree. */
|
|
785
|
-
export function lockedPackageCapabilities(startDir) {
|
|
786
|
-
const out = new Map(); // capability id → [package ids]
|
|
787
|
-
for (const [capId, row] of Object.entries(readPackageLocks(startDir).capabilities)) {
|
|
788
|
-
if (!out.has(capId)) out.set(capId, []);
|
|
789
|
-
out.get(capId).push(row.package);
|
|
790
|
-
}
|
|
791
|
-
return out;
|
|
792
|
-
}
|
|
793
467
|
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
// adopted snapshot against a profile the install never used.
|
|
803
|
-
// A malformed source is a typed failure, never a fall-through to the
|
|
804
|
-
// installed-id lookup: "#../escape" must report path-escape, not "not an
|
|
805
|
-
// installed package id".
|
|
806
|
-
const parsedSrc = parsePackageSource(src);
|
|
807
|
-
if (parsedSrc.kind === "path") {
|
|
808
|
-
const abs = parsedSrc.path; // absolute, tilde already expanded
|
|
809
|
-
const manifest = loadPackageManifestAt(abs); // throws invalid-package-manifest with the engine code
|
|
810
|
-
return { manifest, commit: "local", source: `path:${abs}` };
|
|
811
|
-
}
|
|
812
|
-
const isUrl = parsedSrc.kind === "git";
|
|
813
|
-
if (isUrl) {
|
|
814
|
-
if (!clone) fail("invalid-source", "git package sources need a clone directory (internal)");
|
|
815
|
-
// Select the CONTAINED package root exactly the way acquisition does — the
|
|
816
|
-
// profile a diff compares against must come from the same directory the
|
|
817
|
-
// install would lock, not from whatever sits at the repository root.
|
|
818
|
-
// The REF matters for the same reason: reading HEAD's profile while the
|
|
819
|
-
// returned provenance claims "@v1" compares the snapshot against a version
|
|
820
|
-
// the install never used. A shallow clone cannot check out an arbitrary
|
|
821
|
-
// ref, so only the unpinned case stays shallow.
|
|
822
|
-
// The ref is resolved and verified by the ENGINE before checkout: a
|
|
823
|
-
// caller-supplied ref must never reach git as an option-capable argument
|
|
824
|
-
// (see gitCheckoutExactRef).
|
|
825
|
-
const ref = parsedSrc.ref;
|
|
826
|
-
execFileSync("git", ["clone", "-q", ...(ref ? [] : ["--depth", "1"]), parsedSrc.url, clone], { stdio: ["ignore", "pipe", "pipe"] });
|
|
827
|
-
const commit = ref
|
|
828
|
-
? gitCheckoutExactRef(clone, ref, src)
|
|
829
|
-
: execFileSync("git", ["-C", clone, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
|
830
|
-
const manifest = loadPackageManifestAt(resolvePackageRoot(clone, parsedSrc.packagePath ?? DEFAULT_PACKAGE_PATH, src));
|
|
831
|
-
return { manifest, commit, source: parsedSrc.normalized };
|
|
468
|
+
const { capabilities } = await readPackageManifests(remote, req.remoteRef, obs.commit, req.path, details);
|
|
469
|
+
const integrity = assertDigest(`packages.${id} integrity`, await packageIntegrity(remote, req.remoteRef, obs.commit, req.path), details);
|
|
470
|
+
nextPackages[id] = {
|
|
471
|
+
source, url: obs.url, path: req.path, version, commit: obs.commit, integrity,
|
|
472
|
+
capabilities: capabilities.map((c) => c.name).sort(),
|
|
473
|
+
approved: null,
|
|
474
|
+
};
|
|
475
|
+
changes.push({ id, from: old ? old.version : null, to: version, commit: obs.commit, approvalNeeded: true });
|
|
832
476
|
}
|
|
833
|
-
// Installed/locked package id visible from dir (engine indexing).
|
|
834
|
-
// findLast: closest scope wins for a package identity (the listing is
|
|
835
|
-
// outermost → innermost and may hold the same id at two levels).
|
|
836
|
-
const pkg = listInstalledPackages(dir).findLast((p) => p.package === src);
|
|
837
|
-
if (!pkg) fail("invalid-source", `"${src}" is not an installed package id, local path, or git URL at ${dir} — acquire it first with \`oats install <source>\``);
|
|
838
|
-
return { manifest: pkg.manifest, commit: pkg.commit || "local", source: pkg.source };
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
// ---------- team-boundary workspace scope discovery ----------
|
|
842
|
-
|
|
843
|
-
/** Directory names pruned during descendant scope discovery (dependency/vendor trees). */
|
|
844
|
-
export const PRUNED_DIR_NAMES = new Set([".git", "node_modules", "vendor", ".venv", "venv", "bower_components", ".direnv"]);
|
|
845
|
-
|
|
846
|
-
const isScopeDir = (dir) => existsSync(join(dir, "oats-config.yaml")) || existsSync(join(dir, OATS_LOCK_FILE));
|
|
847
|
-
const declaresTeam = (dir) => {
|
|
848
|
-
const file = join(dir, "oats-config.yaml");
|
|
849
|
-
if (!existsSync(file)) return false;
|
|
850
|
-
try { return !!parseYamlNested(readFileSync(file, "utf8")).team; } catch { return false; }
|
|
851
|
-
};
|
|
852
|
-
|
|
853
|
-
/** A distribution package ROOT — the directory carrying the oats-package.json
|
|
854
|
-
* manifest. Everything BELOW it is package PAYLOAD (see its use in
|
|
855
|
-
* walkBoundaryDirs). The literal filename matches the engine's readers in
|
|
856
|
-
* lib/core.mjs; there is no custom manifest name in the contract. */
|
|
857
|
-
const isPackageRoot = (dir) => existsSync(join(dir, "oats-package.json"));
|
|
858
|
-
|
|
859
|
-
/** The one pruned walk under a team boundary: .git, generated stores
|
|
860
|
-
* (.agents), dependency/vendor dirs, agent instance homes/worktrees,
|
|
861
|
-
* local-agents, nested team boundaries, and package PAYLOAD (anything under an
|
|
862
|
-
* oats-package.json manifest) are skipped. `visit` is called on every surviving
|
|
863
|
-
* directory in deterministic path order; the boundary itself is not visited. */
|
|
864
|
-
function walkBoundaryDirs(boundary, visit) {
|
|
865
|
-
const walk = (dir) => {
|
|
866
|
-
let entries;
|
|
867
|
-
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
868
|
-
const names = entries.filter((e) => e.isDirectory() && !e.isSymbolicLink()).map((e) => e.name).sort((x, y) => x.localeCompare(y));
|
|
869
|
-
for (const name of names) {
|
|
870
|
-
if (PRUNED_DIR_NAMES.has(name)) continue;
|
|
871
|
-
const child = join(dir, name);
|
|
872
|
-
// Generated stores (.agents: capability/package stores, injections) never
|
|
873
|
-
// contain deployment scopes; agent instance homes/worktrees and local
|
|
874
|
-
// souls are runtime state, not workspace repositories.
|
|
875
|
-
if (name === ".agents") continue;
|
|
876
|
-
if (name === "local-agents") continue;
|
|
877
|
-
if (name === "instances" && existsSync(join(dir, "soul"))) continue;
|
|
878
|
-
// Nested team boundary: a descendant scope declaring its own team: is its own reconciliation unit.
|
|
879
|
-
if (declaresTeam(child)) continue;
|
|
880
|
-
visit(child);
|
|
881
|
-
// Package PAYLOAD is content the package exports, not deployment scopes.
|
|
882
|
-
// A member repo shipping a package carries config TEMPLATES (declared via
|
|
883
|
-
// configTemplates, canonically under config-templates/) whose layers name
|
|
884
|
-
// capabilities the adopting deployment has not installed. Reconciling one
|
|
885
|
-
// as a live scope reported phantom "supplied by no visible locked package"
|
|
886
|
-
// failures for the whole team. The rule is the MANIFEST, not the literal
|
|
887
|
-
// template path: custom payload roots exist and one repo may ship several
|
|
888
|
-
// packages at different paths. A config whose containing ANCESTOR holds an
|
|
889
|
-
// oats-package.json is payload — so a repo root that is itself a package
|
|
890
|
-
// root (no manifest ancestor) is still visited above.
|
|
891
|
-
if (isPackageRoot(child)) continue;
|
|
892
|
-
walk(child);
|
|
893
|
-
}
|
|
894
|
-
};
|
|
895
|
-
// A boundary that is itself a package root makes every descendant payload.
|
|
896
|
-
if (!isPackageRoot(boundary)) walk(boundary);
|
|
897
|
-
}
|
|
898
477
|
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
* instance homes/worktrees, local-agents, nested team boundaries, and package
|
|
902
|
-
* PAYLOAD (anything under an oats-package.json manifest). The boundary itself
|
|
903
|
-
* is NOT included. Returns sorted absolute paths. */
|
|
904
|
-
export function discoverWorkspaceScopes(boundary) {
|
|
905
|
-
const out = [];
|
|
906
|
-
walkBoundaryDirs(boundary, (child) => { if (isScopeDir(child)) out.push(child); });
|
|
907
|
-
return out;
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
/** Every VISIBLE lock-owning scope a guided migration must consider, in
|
|
911
|
-
* deterministic path order (ancestors sort before their descendants):
|
|
912
|
-
* - the explicit scope's ancestor chain, so an outer repo/laptop lock that
|
|
913
|
-
* the deployment actually reads is migrated too, not silently left on v1;
|
|
914
|
-
* - the team boundary when the scope declares one;
|
|
915
|
-
* - descendant config/lock scopes under that boundary, using the same pruned
|
|
916
|
-
* discovery reconciliation uses (nested team boundaries stay self-owned).
|
|
917
|
-
* Only directories that actually own an oats-lock.json are returned — a scope
|
|
918
|
-
* with no lock has nothing to migrate. */
|
|
919
|
-
export function discoverMigrationScopes(startDir, { teamScope } = {}) {
|
|
920
|
-
const ownsLock = (dir) => existsSync(join(dir, OATS_LOCK_FILE));
|
|
921
|
-
const out = new Set();
|
|
922
|
-
for (let d = resolve(startDir); ; d = dirname(d)) {
|
|
923
|
-
if (ownsLock(d)) out.add(d);
|
|
924
|
-
if (dirname(d) === d) break;
|
|
478
|
+
for (const id of Object.keys(previous.packages).sort()) {
|
|
479
|
+
if (!(id in requests)) changes.push({ id, from: previous.packages[id].version, to: null, commit: null, approvalNeeded: false });
|
|
925
480
|
}
|
|
926
|
-
const boundary = resolve(teamScope || startDir);
|
|
927
|
-
if (ownsLock(boundary)) out.add(boundary);
|
|
928
|
-
for (const s of discoverWorkspaceScopes(boundary)) if (ownsLock(s)) out.add(resolve(s));
|
|
929
|
-
// Plain code-unit sort: deterministic, and a parent path is a prefix of its
|
|
930
|
-
// children so ancestors are always planned and applied first.
|
|
931
|
-
return [...out].sort();
|
|
932
|
-
}
|
|
933
481
|
|
|
934
|
-
|
|
935
|
-
* do": the ancestor chain plus the same pruned boundary walk migration scope
|
|
936
|
-
* discovery uses. Returns [{dir, files}] in deterministic path order. */
|
|
937
|
-
export function discoverOasScopes(startDir, { teamScope } = {}) {
|
|
938
|
-
const oasFiles = (dir) => OAS_SCOPE_FILES.filter((f) => existsSync(join(dir, f)));
|
|
939
|
-
const found = new Map();
|
|
940
|
-
for (const f of detectOasScopes(startDir)) found.set(f.dir, f.files);
|
|
941
|
-
const boundary = resolve(teamScope || startDir);
|
|
942
|
-
const boundaryFiles = oasFiles(boundary);
|
|
943
|
-
if (boundaryFiles.length) found.set(boundary, boundaryFiles);
|
|
944
|
-
walkBoundaryDirs(boundary, (child) => {
|
|
945
|
-
const files = oasFiles(child);
|
|
946
|
-
if (files.length) found.set(resolve(child), files);
|
|
947
|
-
});
|
|
948
|
-
return [...found.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([dir, files]) => ({ dir, files }));
|
|
482
|
+
return { lock: { lockfileVersion: LOCK_VERSION, packages: sortedObject(nextPackages) }, changes };
|
|
949
483
|
}
|
|
950
484
|
|
|
951
|
-
// ----------
|
|
952
|
-
|
|
953
|
-
/** Legacy id → successor id, straight from the catalog's renaming aliases.
|
|
954
|
-
* The catalog is the ONLY place that knows about renames; nothing here may
|
|
955
|
-
* hard-code an oas.* → oats.* pair. */
|
|
956
|
-
export function oasRenameMap() {
|
|
957
|
-
const map = Object.create(null);
|
|
958
|
-
for (const [legacy, target] of Object.entries(officialCapabilityAliases())) {
|
|
959
|
-
if (target && typeof target === "object" && target.capability && target.capability !== legacy) map[legacy] = target.capability;
|
|
960
|
-
}
|
|
961
|
-
return map;
|
|
962
|
-
}
|
|
485
|
+
// ---------- executables digest + approval ----------
|
|
963
486
|
|
|
964
|
-
/**
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
* on purpose: comments, ordering and every unrelated byte survive. */
|
|
968
|
-
export function transformOasConfigText(text, renames) {
|
|
969
|
-
const changes = [];
|
|
970
|
-
const lines = text.split("\n").map((line) => {
|
|
971
|
-
let out = line;
|
|
972
|
-
if (/^oas:\s*(#.*)?$/.test(line)) out = line.replace(/^oas:/, "oats:");
|
|
973
|
-
else {
|
|
974
|
-
const cap = line.match(/^(\s*capability:\s*)([A-Za-z0-9_.-]+)(\s*(?:#.*)?)$/);
|
|
975
|
-
if (cap && renames[cap[2]]) out = cap[1] + renames[cap[2]] + cap[3];
|
|
976
|
-
else {
|
|
977
|
-
const key = line.match(/^(\s+)([A-Za-z0-9_.-]+):(\s*(?:#.*)?)$/);
|
|
978
|
-
if (key && renames[key[2]]) out = key[1] + renames[key[2]] + ":" + key[3];
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
|
-
if (out !== line) changes.push({ from: line.trim(), to: out.trim() });
|
|
982
|
-
return out;
|
|
983
|
-
});
|
|
984
|
-
return { text: lines.join("\n"), changes };
|
|
487
|
+
/** First token of a `commands` value is the executable target (e.g. "bin/x.mjs cut" → "bin/x.mjs"). */
|
|
488
|
+
export function commandTarget(specification) {
|
|
489
|
+
return String(specification).trim().split(/\s+/)[0] || "";
|
|
985
490
|
}
|
|
986
491
|
|
|
987
|
-
/**
|
|
988
|
-
*
|
|
989
|
-
*
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
const steps = [];
|
|
1002
|
-
const errors = [];
|
|
1003
|
-
const addStep = (from, to, note, content) => {
|
|
1004
|
-
if (existsSync(to)) { errors.push(`${to} already exists — refusing to overwrite; resolve by hand`); return; }
|
|
1005
|
-
steps.push({ kind: content !== undefined ? "rewrite" : "rename", from, to, note, content });
|
|
1006
|
-
};
|
|
1007
|
-
|
|
1008
|
-
const cfg = join(scope, "oas-config.yaml");
|
|
1009
|
-
if (existsSync(cfg)) {
|
|
1010
|
-
const t = transformOasConfigText(readFileSync(cfg, "utf8"), renames);
|
|
1011
|
-
try {
|
|
1012
|
-
const parsed = parseYamlNested(t.text);
|
|
1013
|
-
validateConfigShape(parsed, join(scope, "oats-config.yaml"));
|
|
1014
|
-
} catch (e) { errors.push(`transformed oats-config.yaml does not validate: ${e.message}`); }
|
|
1015
|
-
addStep(cfg, join(scope, "oats-config.yaml"), t.changes.length ? `${t.changes.length} line change${t.changes.length === 1 ? "" : "s"} (oas: block, capability ids)` : "renamed, content unchanged", t.text);
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
const lock = join(scope, "oas-lock.json");
|
|
1019
|
-
if (existsSync(lock)) {
|
|
1020
|
-
try { JSON.parse(readFileSync(lock, "utf8")); }
|
|
1021
|
-
catch (e) { errors.push(`oas-lock.json is not valid JSON: ${e.message}`); }
|
|
1022
|
-
addStep(lock, join(scope, OATS_LOCK_FILE), "verbatim — entry ids stay oas.* for the chained guided migration to convert");
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
const store = installedCapabilitiesDir(scope);
|
|
1026
|
-
if (existsSync(store)) {
|
|
1027
|
-
for (const e of readdirSync(store, { withFileTypes: true })) {
|
|
1028
|
-
if (!e.isDirectory() || e.name.startsWith(".")) continue;
|
|
1029
|
-
const manifest = join(store, e.name, "oas.json");
|
|
1030
|
-
if (existsSync(manifest)) addStep(manifest, join(store, e.name, "oats.json"), "manifest filename");
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
for (const root of ["agents", "local-agents"]) {
|
|
1035
|
-
const rootDir = join(scope, root);
|
|
1036
|
-
if (!existsSync(rootDir)) continue;
|
|
1037
|
-
for (const e of readdirSync(rootDir, { withFileTypes: true })) {
|
|
1038
|
-
if (!e.isDirectory()) continue;
|
|
1039
|
-
const owners = join(rootDir, e.name, "soul", ".oas-scaffold-owners.json");
|
|
1040
|
-
if (!existsSync(owners)) continue;
|
|
1041
|
-
let mapped;
|
|
1042
|
-
try {
|
|
1043
|
-
const doc = JSON.parse(readFileSync(owners, "utf8"));
|
|
1044
|
-
mapped = Object.fromEntries(Object.entries(doc).map(([rel, owner]) => [rel, renames[owner] || owner]));
|
|
1045
|
-
} catch (err) { errors.push(`${owners} is not valid JSON: ${err.message}`); continue; }
|
|
1046
|
-
addStep(owners, join(rootDir, e.name, "soul", ".oats-scaffold-owners.json"), "owner ids mapped to successors", JSON.stringify(mapped, null, 2) + "\n");
|
|
1047
|
-
}
|
|
492
|
+
/** Every executable a manifest can make the kernel run: `commands.*` targets AND `hooks.*.command`
|
|
493
|
+
* targets (hooks run automatically at spawn/retire — the executables an approver most needs to see).
|
|
494
|
+
* → [{ kind: "command"|"hook", name, target }] in canonical order. */
|
|
495
|
+
export function manifestExecutables(manifest) {
|
|
496
|
+
const out = [];
|
|
497
|
+
const commands = plainObject(manifest?.commands) ? manifest.commands : {};
|
|
498
|
+
for (const cmd of Object.keys(commands).sort(byCodepoint)) out.push({ kind: "command", name: cmd, target: commandTarget(commands[cmd]), spec: commands[cmd] });
|
|
499
|
+
const hooks = plainObject(manifest?.hooks) ? manifest.hooks : {};
|
|
500
|
+
for (const hook of Object.keys(hooks).sort(byCodepoint)) {
|
|
501
|
+
// A hook is { command, ... } (schema: required command) or a bare string. A hook object WITHOUT
|
|
502
|
+
// `command` is malformed — it enters the list with spec undefined so executablesDigest refuses it
|
|
503
|
+
// (E_PACKAGE_MANIFEST), never an invisible no-op an approver does not see.
|
|
504
|
+
const spec = plainObject(hooks[hook]) ? hooks[hook].command : hooks[hook];
|
|
505
|
+
out.push({ kind: "hook", name: hook, target: spec === undefined || spec === null ? "" : commandTarget(spec), spec });
|
|
1048
506
|
}
|
|
1049
|
-
|
|
1050
|
-
return { scope: resolve(scope), steps, errors };
|
|
507
|
+
return out;
|
|
1051
508
|
}
|
|
1052
509
|
|
|
1053
|
-
/**
|
|
1054
|
-
*
|
|
1055
|
-
*
|
|
1056
|
-
*
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
const extraPaths = new Set();
|
|
1064
|
-
for (const s of plan.steps) {
|
|
1065
|
-
for (const p of [s.from, s.to]) {
|
|
1066
|
-
const rel = relative(scopeAbs, p);
|
|
1067
|
-
if (!rel.startsWith("..") && !covered(rel)) extraPaths.add(rel);
|
|
1068
|
-
}
|
|
510
|
+
/**
|
|
511
|
+
* sha256 over every capability manifest's executables' bytes — `commands` targets and
|
|
512
|
+
* `hooks.*.command` targets — in canonical codepoint order (manifest name, kind, entry name).
|
|
513
|
+
* Locale-independent: the same tree digests identically on every machine.
|
|
514
|
+
* Input is a `packageTree` (module header). A target missing from `files` →
|
|
515
|
+
* E_PACKAGE_MANIFEST { capability, command, target }. Empty set → the digest of nothing.
|
|
516
|
+
*/
|
|
517
|
+
export function executablesDigest(packageTree) {
|
|
518
|
+
if (!plainObject(packageTree) || !Array.isArray(packageTree.manifests)) {
|
|
519
|
+
throw oatsError("E_PACKAGE_MANIFEST", "executablesDigest expects { manifests: [{ name, manifest, files }] }", { path: "/manifests" });
|
|
1069
520
|
}
|
|
1070
|
-
const
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
521
|
+
const hash = createHash("sha256");
|
|
522
|
+
const manifests = [...packageTree.manifests].sort((a, b) => byCodepoint(String(a.name), String(b.name)));
|
|
523
|
+
for (const { name, manifest, files } of manifests) {
|
|
524
|
+
for (const { kind, name: entry, target, spec } of manifestExecutables(manifest)) {
|
|
525
|
+
const label = kind === "hook" ? `hooks.${entry}.command` : `commands.${entry}`;
|
|
526
|
+
const details = { capability: name, command: entry, kind, target };
|
|
527
|
+
if (typeof spec !== "string") throw oatsError("E_PACKAGE_MANIFEST", `${name}: ${label} must be a string, got ${typeof spec}`, details);
|
|
528
|
+
assertRelPath(`${name} ${label}`, target, details);
|
|
529
|
+
const bytes = files instanceof Map ? files.get(target) : (plainObject(files) && Object.hasOwn(files, target) ? files[target] : undefined);
|
|
530
|
+
if (bytes === undefined) throw oatsError("E_PACKAGE_MANIFEST", `${name}: ${label} targets ${target}, which is not in the package`, details);
|
|
531
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
|
|
532
|
+
// Hooks enter the framing under "hooks.<name>" so a hook and a command of the same name never collide.
|
|
533
|
+
hash.update(`${name}\0${kind === "hook" ? `hooks.${entry}` : entry}\0${target}\0${buf.length}\0`);
|
|
534
|
+
hash.update(buf);
|
|
535
|
+
hash.update("\0");
|
|
1075
536
|
}
|
|
1076
|
-
} catch (e) {
|
|
1077
|
-
journal.rollback();
|
|
1078
|
-
fail(e.code || "E_FROM_OAS_APPLY", `--from-oas rename phase failed at ${scopeAbs} and was rolled back: ${e.message}`);
|
|
1079
|
-
}
|
|
1080
|
-
return journal;
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
// ---------- host requirements (structured, consented) ----------
|
|
1084
|
-
|
|
1085
|
-
/** Allowlisted install methods. Recipes are data; commands are argv arrays (no shell, no sudo, no auth).
|
|
1086
|
-
* Null-prototype: `method.manager` is manifest input, and an ALLOWLIST that
|
|
1087
|
-
* answers for inherited `constructor`/`toString` is not an allowlist. */
|
|
1088
|
-
export const REQUIREMENT_MANAGERS = {
|
|
1089
|
-
__proto__: null,
|
|
1090
|
-
"npm-global": {
|
|
1091
|
-
scope: "user-level (npm global prefix)",
|
|
1092
|
-
plan: (method) => {
|
|
1093
|
-
const pkg = String(method.package || "");
|
|
1094
|
-
if (!/^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*(@[\w.^~><=-]+)?$/i.test(pkg)) throw new Error(`npm-global package spec is not a plain package name: "${pkg}"`);
|
|
1095
|
-
return { argv: ["npm", "install", "-g", pkg], source: `npm registry (${pkg})` };
|
|
1096
|
-
},
|
|
1097
|
-
},
|
|
1098
|
-
brew: {
|
|
1099
|
-
scope: "user-level (Homebrew prefix)",
|
|
1100
|
-
plan: (method) => {
|
|
1101
|
-
const formula = String(method.formula || method.package || "");
|
|
1102
|
-
if (!/^[a-z0-9][\w.@/-]*$/i.test(formula)) throw new Error(`brew formula is not a plain formula name: "${formula}"`);
|
|
1103
|
-
return { argv: ["brew", "install", formula], source: `Homebrew (${formula})` };
|
|
1104
|
-
},
|
|
1105
|
-
},
|
|
1106
|
-
"download-checksum": {
|
|
1107
|
-
scope: "user-level",
|
|
1108
|
-
plan: () => { throw new Error("download-with-checksum installs are not implemented yet — use the documented install URL"); },
|
|
1109
|
-
},
|
|
1110
|
-
};
|
|
1111
|
-
|
|
1112
|
-
/** Which runtimes would actually RUN a capability in this scope?
|
|
1113
|
-
*
|
|
1114
|
-
* A runtime-package requirement must never mutate a host that does not use that
|
|
1115
|
-
* runtime: a Claude-only deployment with oats.aweb active is not asked to install
|
|
1116
|
-
* a pi package. Capability targeting is per-soul (global/type/soul), so the
|
|
1117
|
-
* answer comes from resolving the capability against each known soul and
|
|
1118
|
-
* collecting the runtimes of the souls it is actually active for.
|
|
1119
|
-
*
|
|
1120
|
-
* POLICY when a scope has no souls, or none that the capability targets: the
|
|
1121
|
-
* requirement is NOT raised. A fresh deployment cannot know which runtimes its
|
|
1122
|
-
* future souls will use, and prompting every host for every runtime is exactly
|
|
1123
|
-
* the mutation this exists to avoid. Spawn performs the final, authoritative
|
|
1124
|
-
* check against the instance's ACTUAL runtime — which `--runtime` can override
|
|
1125
|
-
* after any reconciliation — so a genuinely needed package is still caught
|
|
1126
|
-
* there, with a pointed, separately consentable remedy. */
|
|
1127
|
-
export function capabilityRuntimeTargets(scope, capId) {
|
|
1128
|
-
const requesters = [];
|
|
1129
|
-
const runtimes = new Set();
|
|
1130
|
-
let souls = 0;
|
|
1131
|
-
let root;
|
|
1132
|
-
try { root = findRoot(scope); } catch { root = undefined; }
|
|
1133
|
-
if (!root) return { runtimes, souls, requesters };
|
|
1134
|
-
let list = [];
|
|
1135
|
-
try { list = listAgents(root); } catch { list = []; }
|
|
1136
|
-
for (const soul of list) {
|
|
1137
|
-
souls++;
|
|
1138
|
-
try {
|
|
1139
|
-
const resolved = resolveOatsConfig(scope, soul.name);
|
|
1140
|
-
if (!(resolved.capabilities || []).some((c) => c.id === capId)) continue;
|
|
1141
|
-
const runtime = soul.runtime || "pi"; // soul default; spawn may override
|
|
1142
|
-
runtimes.add(runtime);
|
|
1143
|
-
requesters.push({ soul: soul.name, runtime });
|
|
1144
|
-
} catch { /* unresolvable souls are reported by the reconciler */ }
|
|
1145
537
|
}
|
|
1146
|
-
return {
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
/** Normalize a manifest `requires` entry to the structured form.
|
|
1150
|
-
* Legacy shape: { command, why, install: "https://…" }.
|
|
1151
|
-
* A present-but-invalid command (empty, null, non-string) returns a typed
|
|
1152
|
-
* invalid record so the fail-closed policy sees it — only a fully ABSENT
|
|
1153
|
-
* command key is dropped as "not a requirement". */
|
|
1154
|
-
export function normalizeRequirement(req) {
|
|
1155
|
-
if (!req || typeof req !== "object") return undefined;
|
|
1156
|
-
// Runtime-package requirement: satisfied by the RUNTIME's package manager, not
|
|
1157
|
-
// by a command on PATH. `runtime` scopes it — a Claude-only deployment is never
|
|
1158
|
-
// asked to install a pi package. The identity used for dedup, consent and
|
|
1159
|
-
// conflict detection is "<runtime>:<package identity>", version selector
|
|
1160
|
-
// stripped, so @latest and a pinned version are one requirement.
|
|
1161
|
-
if ("runtime" in req && req.runtime !== undefined) {
|
|
1162
|
-
const runtime = typeof req.runtime === "string" ? req.runtime : JSON.stringify(req.runtime);
|
|
1163
|
-
const pkg = typeof req.package === "string" ? req.package : req.package === undefined ? "" : JSON.stringify(req.package);
|
|
1164
|
-
return {
|
|
1165
|
-
kind: "runtime-package", runtime, package: pkg, why: req.why,
|
|
1166
|
-
marketplace: req.marketplace,
|
|
1167
|
-
command: `${runtime}:${runtimePackageIdentity(runtime, pkg)}`, // identity/consent key
|
|
1168
|
-
install: { docs: typeof req.install === "string" ? req.install : req.install?.docs, methods: [] },
|
|
1169
|
-
_invalid: !RUNTIME_PACKAGE_MANAGERS[runtime]
|
|
1170
|
-
? `unknown runtime "${runtime}" (known: ${Object.keys(RUNTIME_PACKAGE_MANAGERS).join(", ")})`
|
|
1171
|
-
: !safeRuntimePackageSpec(pkg, runtime)
|
|
1172
|
-
? `runtime package spec is not a plain source token for ${runtime}: ${JSON.stringify(pkg)}`
|
|
1173
|
-
: req.marketplace !== undefined && !safeRuntimeSourceRef(req.marketplace)
|
|
1174
|
-
? `marketplace is not a plain source reference: ${JSON.stringify(req.marketplace)}`
|
|
1175
|
-
: undefined,
|
|
1176
|
-
};
|
|
1177
|
-
}
|
|
1178
|
-
// JSON manifests cannot carry undefined; a programmatic undefined counts as absent.
|
|
1179
|
-
if (!("command" in req) || req.command === undefined) return undefined;
|
|
1180
|
-
const nonString = typeof req.command !== "string";
|
|
1181
|
-
const command = nonString ? JSON.stringify(req.command) : req.command;
|
|
1182
|
-
const install = req.install;
|
|
1183
|
-
const base = { command, why: req.why, ...(nonString ? { _nonStringCommand: true } : {}) };
|
|
1184
|
-
if (typeof install === "string" || install === undefined) {
|
|
1185
|
-
return { ...base, install: { docs: typeof install === "string" ? install : undefined, methods: [] } };
|
|
1186
|
-
}
|
|
1187
|
-
if (typeof install !== "object") return { ...base, install: { methods: [] } };
|
|
1188
|
-
const methods = Array.isArray(install.methods) ? install.methods.filter((m) => m && typeof m === "object") : [];
|
|
1189
|
-
return { ...base, install: { docs: install.docs, methods } };
|
|
538
|
+
return `sha256-${hash.digest("hex")}`;
|
|
1190
539
|
}
|
|
1191
540
|
|
|
1192
|
-
/**
|
|
1193
|
-
export function
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
541
|
+
/** Record the executable approval for package `id`. Returns a NEW lock; the input is untouched. */
|
|
542
|
+
export function approve(lock, id, digest, at = new Date().toISOString()) {
|
|
543
|
+
validateLock(lock);
|
|
544
|
+
const entry = lock.packages[id];
|
|
545
|
+
if (!entry) throw oatsError("E_PACKAGE_MISSING", `cannot approve ${JSON.stringify(id)}: not in the lock — run \`oats sync\` first`, { id });
|
|
546
|
+
if (typeof digest !== "string" || !DIGEST_RE.test(digest)) throw oatsError("E_PACKAGE_INTEGRITY", `approval digest for ${id} must be sha256-<hex>`, { id, digest });
|
|
547
|
+
const ms = Date.parse(at);
|
|
548
|
+
if (Number.isNaN(ms)) throw oatsError("E_LOCK_SCHEMA", `approval timestamp for ${id} is not ISO-8601: ${JSON.stringify(at)}`, { id, at });
|
|
549
|
+
const next = clone(lock);
|
|
550
|
+
next.packages[id] = { ...next.packages[id], approved: { executables: digest, at: new Date(ms).toISOString() } };
|
|
551
|
+
return next;
|
|
1200
552
|
}
|
|
1201
553
|
|
|
1202
|
-
/**
|
|
1203
|
-
*
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
if (!
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
const mgr = RUNTIME_PACKAGE_MANAGERS[r.runtime];
|
|
1212
|
-
// Some runtimes need more than one command (Claude registers a marketplace
|
|
1213
|
-
// before installing). `steps` is the truth; `argv` stays the final command
|
|
1214
|
-
// so existing consumers keep working.
|
|
1215
|
-
// Same executable the session will launch with — see verifyRuntimePackages.
|
|
1216
|
-
const opts = { context, ...(r.runtime === "claude" && context ? { bin: resolveClaudeBinary(context) } : {}) };
|
|
1217
|
-
const steps = mgr.steps ? mgr.steps(r.package, req, opts) : [mgr.argv(r.package, req, opts)];
|
|
1218
|
-
return {
|
|
1219
|
-
command: r.command, why: r.why, docs: r.install.docs,
|
|
1220
|
-
manager: r.runtime, steps, argv: steps[steps.length - 1], source: `${r.runtime} package (${r.package})`,
|
|
1221
|
-
// Carried so POST-INSTALL verification probes the same executable and
|
|
1222
|
-
// context the install ran through. Without it, an install performed via
|
|
1223
|
-
// `claude-personal` is verified against the literal `claude` and reported
|
|
1224
|
-
// failed (or falsely successful) purely by which account holds the plugin
|
|
1225
|
-
// (reviewer-165d668).
|
|
1226
|
-
probe: opts,
|
|
1227
|
-
scope: mgr.scope, runtime: r.runtime, package: r.package, marketplace: r.marketplace,
|
|
1228
|
-
version: r.runtime === "pi" ? (String(r.package).slice(packageSpecIdentity(r.package).length).match(/^@(.+)$/) || [])[1] : undefined,
|
|
1229
|
-
};
|
|
1230
|
-
}
|
|
1231
|
-
const applicable = (r.install.methods || []).filter((m) => !m.platform || m.platform === platform);
|
|
1232
|
-
for (const method of applicable) {
|
|
1233
|
-
const manager = REQUIREMENT_MANAGERS[method.manager];
|
|
1234
|
-
if (!manager) continue; // non-allowlisted methods are ignored, never executed
|
|
1235
|
-
try {
|
|
1236
|
-
const { argv, source } = manager.plan(method);
|
|
1237
|
-
return {
|
|
1238
|
-
command: r.command, why: r.why, docs: r.install.docs,
|
|
1239
|
-
manager: method.manager, argv, source, scope: manager.scope,
|
|
1240
|
-
version: (String(method.package || method.formula || "").match(/.@([^@]+)$/) || [])[1],
|
|
1241
|
-
};
|
|
1242
|
-
} catch (e) {
|
|
1243
|
-
return { command: r.command, why: r.why, docs: r.install.docs, unavailable: e.message };
|
|
1244
|
-
}
|
|
554
|
+
/** Which locked package provides capability `capName`? → { id, entry } | null.
|
|
555
|
+
* Two packages providing the same name is ambiguous and fails closed:
|
|
556
|
+
* E_PACKAGE_MISSING { capability, ambiguous: [ids] } — never a silent first-by-id pick. */
|
|
557
|
+
export function packageProviding(lock, capName) {
|
|
558
|
+
if (!plainObject(lock?.packages)) return null;
|
|
559
|
+
const providers = Object.keys(lock.packages).sort().filter((id) => Array.isArray(lock.packages[id]?.capabilities) && lock.packages[id].capabilities.includes(capName));
|
|
560
|
+
if (providers.length === 0) return null;
|
|
561
|
+
if (providers.length > 1) {
|
|
562
|
+
throw oatsError("E_PACKAGE_MISSING", `capability ${JSON.stringify(capName)} is provided by ${providers.length} locked packages (${providers.join(", ")}) — a soul cannot say which; keep one of them in packages:`, { capability: capName, ambiguous: providers });
|
|
1245
563
|
}
|
|
1246
|
-
return {
|
|
564
|
+
return { id: providers[0], entry: lock.packages[providers[0]] };
|
|
1247
565
|
}
|
|
1248
566
|
|
|
1249
|
-
|
|
1250
|
-
* no path separators, whitespace, leading dash, or shell syntax. Fail closed. */
|
|
1251
|
-
export function safeRequirementCommand(cmd) {
|
|
1252
|
-
return typeof cmd === "string" && /^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(cmd);
|
|
1253
|
-
}
|
|
567
|
+
// ---------- deprecated shims (phase A only; callers are deleted next phase) ----------
|
|
1254
568
|
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
* invalid?, conflict? }].
|
|
1259
|
-
* Fail-closed identity rules:
|
|
1260
|
-
* - a command that is not a safe executable token is flagged { invalid } with
|
|
1261
|
-
* NO install plan — it can never be consented or executed;
|
|
1262
|
-
* - two active capabilities requesting the SAME command with NON-identical
|
|
1263
|
-
* plans produce one deterministic conflict entry ({ conflict: { plans } },
|
|
1264
|
-
* provenance-rich, no plan, no consent) — identical plans merge requestedBy. */
|
|
1265
|
-
export function aggregateMissingRequirements(scopes, { platform = process.platform, env = process.env, accepted = new Set() } = {}) {
|
|
1266
|
-
const byCommand = new Map();
|
|
1267
|
-
for (const scope of scopes) {
|
|
1268
|
-
// Capabilities targeted by agent-type or soul are INVISIBLE to a
|
|
1269
|
-
// soul-less scope resolution, so resolving the scope alone silently skipped
|
|
1270
|
-
// their requirements entirely — for host commands as much as for runtime
|
|
1271
|
-
// packages. Union the scope-level set with every soul's set, keeping one
|
|
1272
|
-
// entry per capability id.
|
|
1273
|
-
const active = new Map();
|
|
1274
|
-
try { for (const cap of resolveOatsConfig(scope).capabilities || []) active.set(cap.id, cap); }
|
|
1275
|
-
catch { continue; /* scope failures are reported by the reconciler */ }
|
|
1276
|
-
let root;
|
|
1277
|
-
try { root = findRoot(scope); } catch { root = undefined; }
|
|
1278
|
-
for (const soul of root ? listAgents(root) : []) {
|
|
1279
|
-
try { for (const cap of resolveOatsConfig(scope, soul.name).capabilities || []) if (!active.has(cap.id)) active.set(cap.id, cap); }
|
|
1280
|
-
catch { /* unresolvable souls are reported by the reconciler */ }
|
|
1281
|
-
}
|
|
1282
|
-
for (const cap of active.values()) {
|
|
1283
|
-
for (const raw of cap.manifest?.requires || []) {
|
|
1284
|
-
const r = normalizeRequirement(raw);
|
|
1285
|
-
if (!r) continue;
|
|
1286
|
-
if (r.kind === "runtime-package") {
|
|
1287
|
-
if (r._invalid) {
|
|
1288
|
-
const key = `\u0000invalid:${r.command}`;
|
|
1289
|
-
if (!byCommand.has(key)) byCommand.set(key, { kind: "runtime-package", command: r.command, why: r.why, docs: r.install.docs, plan: null, invalid: r._invalid, requestedBy: [] });
|
|
1290
|
-
const bad = byCommand.get(key);
|
|
1291
|
-
if (!bad.requestedBy.some((x) => x.capability === cap.id && x.scope === scope)) bad.requestedBy.push({ capability: cap.id, scope });
|
|
1292
|
-
continue;
|
|
1293
|
-
}
|
|
1294
|
-
// RUNTIME SCOPING: raise this only for a deployment that actually runs
|
|
1295
|
-
// the named runtime, or a Claude-only host with oats.aweb active gets
|
|
1296
|
-
// prompted for a pi package — the provider-agnostic contract must not
|
|
1297
|
-
// mutate a host for an adapter it never uses.
|
|
1298
|
-
const targets = capabilityRuntimeTargets(scope, cap.id);
|
|
1299
|
-
// EXPLICIT CONSENT OVERRIDES SCOPING. Spawn can be given `--runtime pi`
|
|
1300
|
-
// for a soul whose default is claude, and it then emits
|
|
1301
|
-
// `oats install --accept-requirement pi:<pkg>`. If scoping also filtered
|
|
1302
|
-
// that named requirement out, the remedy we printed would install
|
|
1303
|
-
// nothing and the retry would fail identically (reviewer-ad1b9f0).
|
|
1304
|
-
// Naming a requirement IS the statement that this host needs it.
|
|
1305
|
-
if (!targets.runtimes.has(r.runtime) && !accepted.has(r.command)) continue;
|
|
1306
|
-
// Satisfied by the runtime's own package list, not by PATH.
|
|
1307
|
-
const probeOpts = { context: scope, ...(r.runtime === "claude" ? { bin: resolveClaudeBinary(scope) } : {}) };
|
|
1308
|
-
if (runtimePackageInstalled(r.runtime, r.package, env, probeOpts)) continue;
|
|
1309
|
-
const plan = requirementInstallPlan(raw, { platform, context: scope });
|
|
1310
|
-
if (!byCommand.has(r.command)) byCommand.set(r.command, { kind: "runtime-package", command: r.command, runtime: r.runtime, package: r.package, why: r.why, docs: r.install.docs, plan, requestedBy: [], _plans: [] });
|
|
1311
|
-
const agg = byCommand.get(r.command);
|
|
1312
|
-
agg._plans.push({ plan, capability: cap.id, scope });
|
|
1313
|
-
if (!agg.requestedBy.some((x) => x.capability === cap.id && x.scope === scope)) {
|
|
1314
|
-
// Provenance names the souls that pulled it in, so a mixed pi+claude
|
|
1315
|
-
// deployment shows ONE deduped requirement explaining why it applies.
|
|
1316
|
-
agg.requestedBy.push({ capability: cap.id, scope, souls: targets.requesters.filter((t) => t.runtime === r.runtime).map((t) => t.soul) });
|
|
1317
|
-
}
|
|
1318
|
-
continue;
|
|
1319
|
-
}
|
|
1320
|
-
if (r._nonStringCommand || !safeRequirementCommand(r.command)) {
|
|
1321
|
-
const key = `\u0000invalid:${r.command}`;
|
|
1322
|
-
if (!byCommand.has(key)) byCommand.set(key, { command: r.command, why: r.why, docs: r.install.docs, plan: null, invalid: "requirement command is not a safe executable name (no paths, whitespace, dashes-first, or shell syntax)", requestedBy: [] });
|
|
1323
|
-
const bad = byCommand.get(key);
|
|
1324
|
-
if (!bad.requestedBy.some((x) => x.capability === cap.id && x.scope === scope)) bad.requestedBy.push({ capability: cap.id, scope });
|
|
1325
|
-
continue;
|
|
1326
|
-
}
|
|
1327
|
-
if (commandOnPath(r.command, env)) continue;
|
|
1328
|
-
const plan = requirementInstallPlan(raw, { platform });
|
|
1329
|
-
if (!byCommand.has(r.command)) {
|
|
1330
|
-
byCommand.set(r.command, { command: r.command, why: r.why, docs: r.install.docs, plan, requestedBy: [], _plans: [{ plan, capability: cap.id, scope }] });
|
|
1331
|
-
} else {
|
|
1332
|
-
const agg = byCommand.get(r.command);
|
|
1333
|
-
// Retain EVERY requester's plan so conflict provenance is complete for 3+
|
|
1334
|
-
// requesters; the conflict itself derives from the collected set below.
|
|
1335
|
-
agg._plans.push({ plan, capability: cap.id, scope });
|
|
1336
|
-
}
|
|
1337
|
-
const agg = byCommand.get(r.command);
|
|
1338
|
-
if (!agg.requestedBy.some((x) => x.capability === cap.id && x.scope === scope)) agg.requestedBy.push({ capability: cap.id, scope });
|
|
1339
|
-
}
|
|
1340
|
-
}
|
|
1341
|
-
}
|
|
1342
|
-
// Derive conflicts AFTER collection so provenance covers every requester
|
|
1343
|
-
// (3+ capabilities included). Plan identity = the executable argv (or
|
|
1344
|
-
// unavailability); non-identical plans for one command are never
|
|
1345
|
-
// installable or consentable.
|
|
1346
|
-
// steps is authoritative: two capabilities can require the same plugin while
|
|
1347
|
-
// registering its marketplace from DIFFERENT sources, which collapses to one
|
|
1348
|
-
// requirement if only the final argv is compared (reviewer-6f1bb9c).
|
|
1349
|
-
const planKey = (p) => JSON.stringify(p?.steps || (p?.argv ? [p.argv] : null) || p?.unavailable || null);
|
|
1350
|
-
for (const agg of byCommand.values()) {
|
|
1351
|
-
if (!agg._plans) continue;
|
|
1352
|
-
const keys = new Set(agg._plans.map((x) => planKey(x.plan)));
|
|
1353
|
-
if (keys.size > 1) {
|
|
1354
|
-
agg.conflict = { plans: agg._plans.map((x) => ({ capability: x.capability, scope: x.scope, argv: x.plan?.argv || null, steps: x.plan?.steps || null, unavailable: x.plan?.unavailable || null })) };
|
|
1355
|
-
agg.plan = null;
|
|
1356
|
-
}
|
|
1357
|
-
}
|
|
1358
|
-
// Canonical string sort key: commands may be JSON.stringify'd non-strings.
|
|
1359
|
-
return [...byCommand.values()].map(({ _plans, ...rest }) => rest).sort((a, b) => String(a.command).localeCompare(String(b.command)));
|
|
1360
|
-
}
|
|
569
|
+
const removed = (name) => function removedByWorkspaceModel() {
|
|
570
|
+
throw oatsError("E_REMOVED", `${name} was removed by the workspace model; see ${CONTRACT_DOC}`, { name, contract: CONTRACT_DOC });
|
|
571
|
+
};
|
|
1361
572
|
|
|
1362
|
-
|
|
1363
|
-
export
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
573
|
+
export const aggregateMissingRequirements = removed("aggregateMissingRequirements");
|
|
574
|
+
export const applyConfigMerge = removed("applyConfigMerge");
|
|
575
|
+
export const applyFromOasScope = removed("applyFromOasScope");
|
|
576
|
+
export const beginRunJournal = removed("beginRunJournal");
|
|
577
|
+
export const capabilityRuntimeTargets = removed("capabilityRuntimeTargets");
|
|
578
|
+
export const commandOnPath = removed("commandOnPath");
|
|
579
|
+
export const discoverMigrationScopes = removed("discoverMigrationScopes");
|
|
580
|
+
export const discoverOasScopes = removed("discoverOasScopes");
|
|
581
|
+
export const discoverWorkspaceScopes = removed("discoverWorkspaceScopes");
|
|
582
|
+
export const lockedPackageCapabilities = removed("lockedPackageCapabilities");
|
|
583
|
+
export const normalizeRequirement = removed("normalizeRequirement");
|
|
584
|
+
export const oasRenameMap = removed("oasRenameMap");
|
|
585
|
+
export const packageSpecIdentity = removed("packageSpecIdentity");
|
|
586
|
+
export const planConfigMerge = removed("planConfigMerge");
|
|
587
|
+
export const planFromOasScope = removed("planFromOasScope");
|
|
588
|
+
export const readAdoptedTemplate = removed("readAdoptedTemplate");
|
|
589
|
+
export const requirementInstallPlan = removed("requirementInstallPlan");
|
|
590
|
+
export const runRequirementInstall = removed("runRequirementInstall");
|
|
591
|
+
export const runtimePackageInstalled = removed("runtimePackageInstalled");
|
|
592
|
+
export const runtimePackageStatus = removed("runtimePackageStatus");
|
|
593
|
+
export const selectConfigTemplate = removed("selectConfigTemplate");
|
|
594
|
+
export const splitConfigLines = removed("splitConfigLines");
|
|
595
|
+
export const transformOasConfigText = removed("transformOasConfigText");
|
|
596
|
+
export const validateConfigTemplate = removed("validateConfigTemplate");
|
|
597
|
+
export const writeAdoptedTemplate = removed("writeAdoptedTemplate");
|
|
598
|
+
export const adoptedTemplateDir = removed("adoptedTemplateDir");
|
|
599
|
+
/** Was a constant table; any property access throws E_REMOVED. */
|
|
600
|
+
export const REQUIREMENT_MANAGERS = new Proxy(Object.freeze({}), {
|
|
601
|
+
get(_t, prop) { if (typeof prop === "symbol" || prop === "then") return undefined; removed("REQUIREMENT_MANAGERS")(); },
|
|
602
|
+
has() { removed("REQUIREMENT_MANAGERS")(); },
|
|
603
|
+
});
|