@awebai/oats 0.22.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/LICENSE +21 -0
- package/README.md +380 -0
- package/bin/oats.mjs +3294 -0
- package/capabilities/oats-authoring/oats.json +12 -0
- package/capabilities/oats-aweb/bin/oats-aweb.mjs +297 -0
- package/capabilities/oats-aweb/injects/aweb.md +55 -0
- package/capabilities/oats-aweb/oats.json +47 -0
- package/capabilities/oats-aweb/package.json +20 -0
- package/capabilities/oats-jira/bin/oats-jira.mjs +40 -0
- package/capabilities/oats-jira/injects/jira.md +10 -0
- package/capabilities/oats-jira/oats.json +22 -0
- package/capabilities/oats-jira/package.json +25 -0
- package/capabilities/oats-jira/skills/jira-tasks/SKILL.md +176 -0
- package/capabilities/oats-linear/README.md +234 -0
- package/capabilities/oats-linear/bin/oats-linear-hook.mjs +32 -0
- package/capabilities/oats-linear/bin/oats-linear.mjs +344 -0
- package/capabilities/oats-linear/injects/linear.md +8 -0
- package/capabilities/oats-linear/oats.json +24 -0
- package/capabilities/oats-linear/package.json +29 -0
- package/capabilities/oats-linear/skills/linear-tasks/SKILL.md +217 -0
- package/capabilities/oats-linear/test/oats-linear.test.mjs +168 -0
- package/capabilities/oats-okf/agents/memory-harvest.md +25 -0
- package/capabilities/oats-okf/bin/oats-okf.mjs +283 -0
- package/capabilities/oats-okf/injects/okf.md +62 -0
- package/capabilities/oats-okf/oats.json +20 -0
- package/capabilities/oats-okf/package.json +22 -0
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +100 -0
- package/capabilities/oats-okf/skills/okf/SKILL.md +107 -0
- package/capabilities/oats-okf/skills/okf/scripts/okf-validate.mjs +123 -0
- package/capabilities/oats-review/agents/reviewer/AGENTS.md +53 -0
- package/capabilities/oats-review/agents/reviewer/soul.yaml +6 -0
- package/capabilities/oats-review/injects/review.md +68 -0
- package/capabilities/oats-review/oats.json +10 -0
- package/capabilities/oats-review/skills/code-review/SKILL.md +44 -0
- package/capabilities/oats-review/skills/security-review/SKILL.md +59 -0
- package/docs/capabilities.md +505 -0
- package/docs/capability-manifest.schema.json +223 -0
- package/docs/configuration.md +482 -0
- package/docs/conventions.md +73 -0
- package/docs/design/desktop-ux-plan.md +362 -0
- package/docs/design/package-engine-contract.md +813 -0
- package/docs/design/package-runtime-api.md +414 -0
- package/docs/desktop-cli-api.md +89 -0
- package/docs/desktop-succession.md +51 -0
- package/docs/desktop.md +187 -0
- package/docs/implementation.md +282 -0
- package/docs/integrations.md +123 -0
- package/docs/knowledge-theory.md +97 -0
- package/docs/knowledge.md +139 -0
- package/docs/layers.md +110 -0
- package/docs/migration-from-oas.md +122 -0
- package/docs/oats-config.schema.json +140 -0
- package/docs/oats-lock.schema.json +175 -0
- package/docs/oats-package.schema.json +129 -0
- package/docs/packages.md +442 -0
- package/docs/release-lane.md +132 -0
- package/docs/release-notes/v0.18.2.md +79 -0
- package/docs/release-notes/v0.18.3.md +63 -0
- package/docs/release-notes/v0.18.4.md +65 -0
- package/docs/release-notes/v0.18.5.md +66 -0
- package/docs/release-notes/v0.18.6.md +87 -0
- package/docs/release-notes/v0.19.0.md +186 -0
- package/docs/release-notes/v0.19.1.md +24 -0
- package/docs/release-notes/v0.19.2.md +32 -0
- package/docs/release-notes/v0.19.3.md +16 -0
- package/docs/release-notes/v0.19.4.md +12 -0
- package/docs/release-notes/v0.20.0.md +89 -0
- package/docs/release-notes/v0.22.0.md +180 -0
- package/docs/souls-and-instances.md +344 -0
- package/injects/framework-workspace.md +7 -0
- package/injects/instance-boundary.md +43 -0
- package/injects/local-soul.md +19 -0
- package/injects/oats.md +11 -0
- package/injects/work-attached.md +16 -0
- package/injects/work-checkout.md +12 -0
- package/injects/work-workspace.md +22 -0
- package/injects/work-worktree.md +13 -0
- package/lib/core.mjs +6792 -0
- package/lib/packages.mjs +1375 -0
- package/lib/tmux-config.mjs +35 -0
- package/package-catalog.json +44 -0
- package/package.json +55 -0
- package/packages/record/README.md +151 -0
- package/packages/record/bin/capture.mjs +256 -0
- package/packages/record/bin/recall.mjs +102 -0
- package/packages/record/bin/setup.mjs +281 -0
- package/packages/record/bin/turn-record.mjs +47 -0
- package/packages/record/docs/turn-record-sot.md +350 -0
- package/packages/record/lib/canonical.mjs +151 -0
- package/packages/record/lib/capture-aw.mjs +179 -0
- package/packages/record/lib/capture-cc.mjs +322 -0
- package/packages/record/lib/formats.mjs +296 -0
- package/packages/record/lib/ignore.mjs +155 -0
- package/packages/record/lib/index-db.mjs +524 -0
- package/packages/record/lib/project-aweb.mjs +251 -0
- package/packages/record/lib/segments.mjs +186 -0
- package/packages/record/lib/store.mjs +447 -0
- package/packages/record/lib/tags.mjs +114 -0
- package/packages/record/package.json +45 -0
- package/packages/record/test/vectors/README.md +34 -0
- package/packages/record/test/vectors/aweb-projection-v1.json +231 -0
- package/packages/record/test/vectors/journal-merge-v1.json +261 -0
- package/packages/record/test/vectors/turn-id-v1.json +152 -0
- package/packages/record/test/vectors/validate.mjs +391 -0
- package/skills/integration-authoring/SKILL.md +76 -0
- package/skills/oats/SKILL.md +151 -0
- package/skills/oats-config/SKILL.md +162 -0
- package/skills/oats-getting-started/SKILL.md +159 -0
- package/skills/oats-packages/SKILL.md +180 -0
- package/skills/oats-support/SKILL.md +79 -0
- package/skills/skill-craft/SKILL.md +109 -0
- package/skills/soul-craft/SKILL.md +109 -0
package/lib/packages.mjs
ADDED
|
@@ -0,0 +1,1375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OATS distribution packages — WS2 policy layer (config bootstrap and workspace
|
|
3
|
+
* reconciliation) over the package ENGINE in lib/core.mjs.
|
|
4
|
+
*
|
|
5
|
+
* The engine owns source parsing, manifests, the store, lock v2, acquisition,
|
|
6
|
+
* exact restore, capability indexing, and trust (docs/design/
|
|
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).
|
|
12
|
+
*
|
|
13
|
+
* Runtime-neutral and dependency-free, like lib/core.mjs.
|
|
14
|
+
*/
|
|
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";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
21
|
+
import { execFileSync } from "node:child_process";
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import {
|
|
24
|
+
DEFAULT_PACKAGE_PATH, OATS_LOCK_FILE, OAS_SCOPE_FILES, detectOasScopes, findRoot, installedCapabilitiesDir, listAgents, listInstalledPackages, loadPackageManifestAt, officialCapabilityAliases, packageSpecIdentity,
|
|
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
|
+
}
|
|
42
|
+
|
|
43
|
+
const AGENT_TYPE_RE = /^[a-z][a-z0-9-]*$/;
|
|
44
|
+
|
|
45
|
+
// ---------- config templates (selection, validation, adoption) ----------
|
|
46
|
+
//
|
|
47
|
+
// Both engine readers — acquisition's staged `configTemplates` and
|
|
48
|
+
// `readLockedConfigTemplates` — produce the SAME descriptor, so everything here
|
|
49
|
+
// takes descriptors and never cares which reader produced them.
|
|
50
|
+
|
|
51
|
+
/** Scope-relative home of adopted template bases. Visible and commit-safe by
|
|
52
|
+
* design: sync's three-way comparison needs the original base under review. */
|
|
53
|
+
export const ADOPTED_TEMPLATES_DIRNAME = join(".agents", "config-templates", "adopted");
|
|
54
|
+
export const ADOPTION_METADATA_FILE = "adoption.json";
|
|
55
|
+
|
|
56
|
+
/** Package and template names become PATH SEGMENTS under the adopted root, so
|
|
57
|
+
* they get the same directory-name grammar a capability id gets. */
|
|
58
|
+
const ADOPTED_TOKEN_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
59
|
+
|
|
60
|
+
export const adoptedTemplateDir = (levelDir, packageId, templateName) => {
|
|
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
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Positively validate every EXISTING parent from the scope down to `leafDir`,
|
|
70
|
+
* immediately before a write.
|
|
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. */
|
|
82
|
+
export function assertNoSymlinkedParents(scopeDir, leafDir, what) {
|
|
83
|
+
const base = resolve(scopeDir);
|
|
84
|
+
const leaf = resolve(leafDir);
|
|
85
|
+
if (!isInside(base, leaf)) fail("E_ADOPTED_PATH_UNSAFE", `${what} resolves outside the scope: ${leaf}`);
|
|
86
|
+
const rel = relative(base, leaf);
|
|
87
|
+
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
|
+
for (const part of rel ? rel.split(sep) : []) {
|
|
91
|
+
cur = join(cur, part);
|
|
92
|
+
let st;
|
|
93
|
+
try { st = lstatSync(cur); } catch { return; } // absent from here down: this run creates it
|
|
94
|
+
if (st.isSymbolicLink()) {
|
|
95
|
+
fail("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`);
|
|
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}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
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
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Atomically replace one file, never writing THROUGH it: a protected path may
|
|
203
|
+
* be a symlink, and opening it for write would follow the link and clobber
|
|
204
|
+
* whatever it points at. Sibling temp + rename replaces the entry itself. */
|
|
205
|
+
export function writeFileAtomic(file, contents) {
|
|
206
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
207
|
+
const tmp = join(dirname(file), `.${basename(file)}.oats-tmp-${process.pid}`);
|
|
208
|
+
try {
|
|
209
|
+
writeFileSync(tmp, contents);
|
|
210
|
+
renameSync(tmp, file);
|
|
211
|
+
} finally { rmSync(tmp, { force: true }); }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Write the active config plus the exact adopted base and its metadata.
|
|
215
|
+
*
|
|
216
|
+
* The base is the template's EXACT bytes — not the local config — because the
|
|
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 };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Read this scope's adopted base + metadata, or null when nothing is adopted.
|
|
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
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------- config template three-way merge (byte-preserving) ----------
|
|
286
|
+
//
|
|
287
|
+
// `oats config sync` compares three texts: the recorded ADOPTED BASE, the
|
|
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.
|
|
300
|
+
|
|
301
|
+
/** Guard for the O(n*m) LCS tables below. Configs are tens to hundreds of lines;
|
|
302
|
+
* anything past this is not a config being synchronized and must fail closed
|
|
303
|
+
* rather than allocate gigabytes. */
|
|
304
|
+
const MERGE_MAX_LINE_PRODUCT = 4_000_000;
|
|
305
|
+
|
|
306
|
+
/** Split text into lines that RETAIN their exact terminators, so that
|
|
307
|
+
* splitConfigLines(t).join("") === t for any t — including CRLF files and a
|
|
308
|
+
* final line with no newline. Line identity is therefore byte identity: a line
|
|
309
|
+
* that differs only in its terminator is a real difference, not a match. */
|
|
310
|
+
export function splitConfigLines(text) {
|
|
311
|
+
const s = String(text);
|
|
312
|
+
const lines = [];
|
|
313
|
+
let start = 0;
|
|
314
|
+
for (let i = 0; i < s.length; i++) {
|
|
315
|
+
if (s[i] === "\n") { lines.push(s.slice(start, i + 1)); start = i + 1; }
|
|
316
|
+
}
|
|
317
|
+
if (start < s.length) lines.push(s.slice(start));
|
|
318
|
+
return lines;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Regions where `b` differs from `a`, as {aStart,aEnd,bStart,bEnd} half-open
|
|
322
|
+
* line ranges. A pure insertion is a zero-width `a` range; a pure deletion a
|
|
323
|
+
* zero-width `b` range. */
|
|
324
|
+
function changeRegions(a, b) {
|
|
325
|
+
const n = a.length, m = b.length;
|
|
326
|
+
if (n * m > MERGE_MAX_LINE_PRODUCT) {
|
|
327
|
+
fail("E_SYNC_TOO_LARGE", `config texts are too large to merge line-by-line (${n} x ${m} lines)`);
|
|
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]);
|
|
332
|
+
}
|
|
333
|
+
const regions = [];
|
|
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;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Line-count delta a region introduces on the non-base side. */
|
|
352
|
+
const regionDelta = (r) => (r.bEnd - r.bStart) - (r.aEnd - r.aStart);
|
|
353
|
+
|
|
354
|
+
function digestOf(...parts) {
|
|
355
|
+
const hash = createHash("sha256");
|
|
356
|
+
for (const part of parts) { hash.update(String(part)); hash.update("\0"); }
|
|
357
|
+
return `sha256-${hash.digest("hex")}`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Three-way merge PLAN for one config: what changed where, and what a caller
|
|
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);
|
|
381
|
+
|
|
382
|
+
const localRegions = changeRegions(base, local);
|
|
383
|
+
const templateRegions = changeRegions(base, template);
|
|
384
|
+
|
|
385
|
+
// Group by overlapping-or-touching BASE range. Touching counts: an insertion
|
|
386
|
+
// at line p (zero-width base range) and a replacement starting at p are the
|
|
387
|
+
// same disputed spot, and treating them as independent would interleave two
|
|
388
|
+
// sides' edits at one point without either side ever agreeing to it.
|
|
389
|
+
const marked = [
|
|
390
|
+
...localRegions.map((r) => ({ side: "local", r })),
|
|
391
|
+
...templateRegions.map((r) => ({ side: "template", r })),
|
|
392
|
+
].sort((x, y) => x.r.aStart - y.r.aStart || x.r.aEnd - y.r.aEnd);
|
|
393
|
+
|
|
394
|
+
const groups = [];
|
|
395
|
+
for (const item of marked) {
|
|
396
|
+
const last = groups[groups.length - 1];
|
|
397
|
+
if (last && item.r.aStart <= last.aEnd) {
|
|
398
|
+
last.aEnd = Math.max(last.aEnd, item.r.aEnd);
|
|
399
|
+
last.items.push(item);
|
|
400
|
+
} else {
|
|
401
|
+
groups.push({ aStart: item.r.aStart, aEnd: item.r.aEnd, items: [item] });
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Offsets of each side relative to the base, accumulated across groups: every
|
|
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
|
+
});
|
|
441
|
+
}
|
|
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
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Apply a plan's decisions to the LOCAL text, byte-preservingly.
|
|
457
|
+
*
|
|
458
|
+
* decisions: { [regionId]: "local" | "package" | { edit: "<replacement text>" } }
|
|
459
|
+
* Regions with no decision fall back to `recommended`; a conflict has none, so
|
|
460
|
+
* an unresolved conflict is a typed failure (E_SYNC_AMBIGUOUS) rather than a
|
|
461
|
+
* silent pick — that is the noninteractive fail-closed rule.
|
|
462
|
+
*
|
|
463
|
+
* Returns { text, applied }. `applied` lists the regions that actually changed
|
|
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"})`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const chosen = [];
|
|
482
|
+
for (const region of plan.regions) {
|
|
483
|
+
const raw = Object.hasOwn(decisions, region.id) ? decisions[region.id] : region.recommended;
|
|
484
|
+
if (raw === null || raw === undefined) {
|
|
485
|
+
fail("E_SYNC_AMBIGUOUS", `change region ${region.id} is a conflict — the local file and the package template both changed it, so it needs an explicit local/package/edit choice`);
|
|
486
|
+
}
|
|
487
|
+
if (typeof raw === "object") {
|
|
488
|
+
if (typeof raw.edit !== "string") fail("E_SYNC_BAD_DECISION", `change region ${region.id}: an edit decision needs { edit: "<text>" }`);
|
|
489
|
+
chosen.push({ region, choice: "edit", text: raw.edit });
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (raw !== "local" && raw !== "package") fail("E_SYNC_BAD_DECISION", `change region ${region.id}: decision must be "local", "package", or { edit }, not ${JSON.stringify(raw)}`);
|
|
493
|
+
chosen.push({ region, choice: raw, text: raw === "local" ? region.local.text : region.template.text });
|
|
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;
|
|
510
|
+
}
|
|
511
|
+
out.push(local.slice(cursor).join(""));
|
|
512
|
+
return { text: out.join(""), applied };
|
|
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 };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Reject ANY symlink in an INTERMEDIATE component of a protected path.
|
|
557
|
+
*
|
|
558
|
+
* One escaping out of the scope is the obvious danger: restoring through it
|
|
559
|
+
* would delete or rewrite outer-scope state this run never owned. But a
|
|
560
|
+
* CONTAINED alias is refused too, because it makes two protected paths address
|
|
561
|
+
* the same bytes — restoring one entry then silently deletes or overwrites
|
|
562
|
+
* another entry's artifact, and the outcome depends on entry order. A journal
|
|
563
|
+
* whose entries can overlap cannot promise byte-exact restoration, so the only
|
|
564
|
+
* safe posture is to refuse the layout rather than guess an ordering.
|
|
565
|
+
*
|
|
566
|
+
* A symlink AT the protected path itself is fine: it is captured and restored
|
|
567
|
+
* verbatim and never written through. */
|
|
568
|
+
function assertJournalContainment(scopeReal, rel) {
|
|
569
|
+
const parts = rel.split("/").filter(Boolean);
|
|
570
|
+
let cur = scopeReal;
|
|
571
|
+
for (const part of parts.slice(0, -1)) {
|
|
572
|
+
cur = join(cur, part);
|
|
573
|
+
let st;
|
|
574
|
+
try { st = lstatSync(cur); } catch { return; } // absent from here down: nothing to alias or escape through
|
|
575
|
+
if (!st.isSymbolicLink()) continue;
|
|
576
|
+
if (!isInside(scopeReal, realpathSync(cur))) {
|
|
577
|
+
fail("E_JOURNAL_PATH_ESCAPE", `refusing to journal ${rel}: "${part}" is a symlink leaving ${scopeReal}`);
|
|
578
|
+
}
|
|
579
|
+
fail("E_JOURNAL_SYMLINK_COMPONENT", `refusing to journal ${rel}: "${part}" is a symlink aliasing another directory inside ${scopeReal} — two journal entries could then address the same bytes`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Normalize one journalled relative path and reject anything ambiguous.
|
|
584
|
+
* Fail-closed on purpose: this is a private API whose callers are in this
|
|
585
|
+
* repository, so a malformed path is a bug to surface, never to interpret. */
|
|
586
|
+
function canonicalJournalRel(rel, seen) {
|
|
587
|
+
if (typeof rel !== "string") fail("E_JOURNAL_BAD_PATH", `journalled path must be a string, got ${typeof rel}`);
|
|
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;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Copy preserving type, symlink targets, modes, and timestamps.
|
|
602
|
+
*
|
|
603
|
+
* Hand-walked rather than fs.cpSync: cpSync's native recursion ABORTS THE
|
|
604
|
+
* PROCESS with an uncatchable C++ filesystem_error when it meets an unreadable
|
|
605
|
+
* directory (measured on node 22). A capability store with one bad-permission
|
|
606
|
+
* directory would then kill the whole command with a libc++ message and no
|
|
607
|
+
* cleanup, instead of a typed failure this journal can compensate. readdirSync
|
|
608
|
+
* raises an ordinary catchable EACCES, which is what the constructor's cleanup
|
|
609
|
+
* needs to work at all. */
|
|
610
|
+
function copyExact(from, to) {
|
|
611
|
+
const st = lstatSync(from);
|
|
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;
|
|
622
|
+
}
|
|
623
|
+
if (!st.isFile()) fail("E_JOURNAL_UNSUPPORTED_ENTRY", `cannot journal ${from}: not a regular file, directory, or symlink`);
|
|
624
|
+
copyFileSync(from, to);
|
|
625
|
+
chmodSync(to, st.mode & 0o7777);
|
|
626
|
+
utimesSync(to, st.atime, st.mtime);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** Open a run-level rollback journal over one scope.
|
|
630
|
+
*
|
|
631
|
+
* Snapshots every artifact in RUN_JOURNAL_PATHS (plus any `extraPaths`) exactly
|
|
632
|
+
* as it is right now — bytes, file type, symlink targets, and mode bits — into a
|
|
633
|
+
* backup directory OUTSIDE the protected tree, and records which ancestor
|
|
634
|
+
* directories (including the `.agents` anchor) this run would be creating.
|
|
635
|
+
*
|
|
636
|
+
* Then exactly one of:
|
|
637
|
+
* rollback() restore every snapshot, remove everything the run created, and
|
|
638
|
+
* report truthfully — including partial failure.
|
|
639
|
+
* finalize() the run succeeded: discard the backup. Call it only after every
|
|
640
|
+
* command-owned write has finished, because it is the point of no
|
|
641
|
+
* return.
|
|
642
|
+
* Both are idempotent; rollback after finalize is a caller bug and throws. */
|
|
643
|
+
export function beginRunJournal(scopeDir, { backupRoot = tmpdir(), extraPaths = [] } = {}) {
|
|
644
|
+
const scope = resolve(scopeDir);
|
|
645
|
+
if (!existsSync(scope)) fail("E_JOURNAL_NO_SCOPE", `cannot journal a run at ${scope}: the scope directory does not exist`);
|
|
646
|
+
const scopeReal = realpathSync(scope);
|
|
647
|
+
|
|
648
|
+
// Validate and canonicalize BEFORE staging anything, so a malformed call
|
|
649
|
+
// never creates a backup directory it then has to clean up.
|
|
650
|
+
const seen = new Set();
|
|
651
|
+
const rels = [...RUN_JOURNAL_PATHS, ...extraPaths].map((rel) => canonicalJournalRel(rel, seen));
|
|
652
|
+
|
|
653
|
+
// Directories that already existed. Anything NOT here that exists at rollback
|
|
654
|
+
// time was created by this run and must be pruned once it is empty — which is
|
|
655
|
+
// also what makes "remove a run-created .agents anchor only when empty" fall
|
|
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";
|
|
702
|
+
|
|
703
|
+
/** Restore one entry to exactly what it was; absence is itself a state to restore. */
|
|
704
|
+
const restoreEntry = (entry, report) => {
|
|
705
|
+
// Absent then, absent now: nothing to undo. Every other case is restored
|
|
706
|
+
// unconditionally — comparing first would only save work, and a comparison
|
|
707
|
+
// that is subtly wrong silently skips a restore the run depended on.
|
|
708
|
+
if (entry.kind === "absent" && classifyPath(entry.path).kind === "absent") return;
|
|
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 });
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
/** Remove directories this run created, deepest first, and only while empty. */
|
|
723
|
+
const pruneCreatedDirs = (report) => {
|
|
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); }
|
|
729
|
+
}
|
|
730
|
+
candidates.add(anchorPath);
|
|
731
|
+
for (const dir of [...candidates].sort((a, b) => b.length - a.length)) {
|
|
732
|
+
if (preexistingDirs.has(dir) || !existsSync(dir)) continue;
|
|
733
|
+
try {
|
|
734
|
+
if (readdirSync(dir).length) continue; // not ours to empty — owned/ or a stranger's file lives here
|
|
735
|
+
rmdirSync(dir);
|
|
736
|
+
report.removed.push(dir.slice(scope.length + 1) || AGENTS_ANCHOR);
|
|
737
|
+
} catch (e) {
|
|
738
|
+
report.failures.push({ path: dir.slice(scope.length + 1) || AGENTS_ANCHOR, error: e.message });
|
|
739
|
+
}
|
|
740
|
+
}
|
|
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
|
+
|
|
794
|
+
/** Resolve a package id/path to its ENGINE-loaded manifest for profile
|
|
795
|
+
* adoption/diff. Installed ids resolve through listInstalledPackages (the
|
|
796
|
+
* engine's indexed store); local paths load directly. Git URLs are cloned by
|
|
797
|
+
* the caller (adoption acquires; diff uses a temp clone). */
|
|
798
|
+
export function resolveProfilePackage(src, dir, { clone } = {}) {
|
|
799
|
+
// Classify through the ENGINE's parser, never a local regex: a diff that
|
|
800
|
+
// disagreed with acquisition about what a source spells (which git
|
|
801
|
+
// spellings count, which contained root is selected) would compare the
|
|
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 };
|
|
832
|
+
}
|
|
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
|
+
|
|
899
|
+
/** Deterministic path-order discovery of descendant scopes inside a team boundary.
|
|
900
|
+
* Prunes .git, generated stores (.agents), dependency/vendor dirs, agent
|
|
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;
|
|
925
|
+
}
|
|
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
|
+
|
|
934
|
+
/** Un-migrated OAS scopes a guided migration must never read as "nothing to
|
|
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 }));
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// ---------- oats migrate --from-oas (aweb-abfy.3) ----------
|
|
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
|
+
}
|
|
963
|
+
|
|
964
|
+
/** Pure line transform of an oas-config.yaml: the top-level `oas:` defaults
|
|
965
|
+
* block becomes `oats:`, and capability ids with catalog renames become their
|
|
966
|
+
* successors — in `capability:` values and in additive map keys. Line-based
|
|
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 };
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** Everything `oats migrate --from-oas` would change at ONE scope, as explicit
|
|
988
|
+
* steps, plus the validation of every transformed artifact BEFORE anything is
|
|
989
|
+
* touched. Pure read. Steps cover breaks 1-3 of docs/migration-from-oas.md:
|
|
990
|
+
* - oas-config.yaml → oats-config.yaml (line transform, validated);
|
|
991
|
+
* - oas-lock.json → oats-lock.json (verbatim — entry ids stay oas.*; the
|
|
992
|
+
* chained guided migration converts them via the renaming aliases);
|
|
993
|
+
* - installed capability dirs: oas.json → oats.json (verbatim);
|
|
994
|
+
* - agents/`*`/soul and local-agents/`*`/soul: .oas-scaffold-owners.json →
|
|
995
|
+
* .oats-scaffold-owners.json with owner ids mapped to their successors.
|
|
996
|
+
* Break 4 (stale v1 integrity) is deliberately NOT recomputed: the chained
|
|
997
|
+
* migration re-acquires every official capability, which supersedes the
|
|
998
|
+
* artifacts the integrity covered. */
|
|
999
|
+
export function planFromOasScope(scope) {
|
|
1000
|
+
const renames = oasRenameMap();
|
|
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
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
return { scope: resolve(scope), steps, errors };
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/** Apply one scope's --from-oas rename plan under the run journal and return
|
|
1054
|
+
* the OPEN journal: the caller chains the guided v1→v2 migration and only
|
|
1055
|
+
* then finalizes, so the whole scope conversion is one transaction — any
|
|
1056
|
+
* failure in either phase restores the original OAS-named bytes. */
|
|
1057
|
+
export function applyFromOasScope(scope, plan) {
|
|
1058
|
+
const scopeAbs = resolve(scope);
|
|
1059
|
+
// Journal every touched path not already covered by the standard set — an
|
|
1060
|
+
// exact repeat or a path nested under a journalled directory would be
|
|
1061
|
+
// E_JOURNAL_DUPLICATE_PATH / a redundant double snapshot.
|
|
1062
|
+
const covered = (rel) => RUN_JOURNAL_PATHS.some((p) => rel === p || rel.startsWith(`${p}/`));
|
|
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
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
const journal = beginRunJournal(scopeAbs, { extraPaths: [...extraPaths] });
|
|
1071
|
+
try {
|
|
1072
|
+
for (const s of plan.steps) {
|
|
1073
|
+
if (s.kind === "rewrite") { writeFileSync(s.to, s.content); rmSync(s.from); }
|
|
1074
|
+
else renameSync(s.from, s.to);
|
|
1075
|
+
}
|
|
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
|
+
}
|
|
1146
|
+
return { runtimes, souls, requesters };
|
|
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 } };
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/** Is a command on PATH? (dependency-free `which`). */
|
|
1193
|
+
export function commandOnPath(cmd, env = process.env) {
|
|
1194
|
+
if (!cmd || /[\\/]/.test(cmd)) return false;
|
|
1195
|
+
for (const dir of String(env.PATH || "").split(delimiter)) {
|
|
1196
|
+
if (!dir) continue;
|
|
1197
|
+
try { const st = statSync(join(dir, cmd)); if (st.isFile() && (st.mode & 0o111)) return true; } catch { /* keep looking */ }
|
|
1198
|
+
}
|
|
1199
|
+
return false;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/** Build the informed-consent install plan for one requirement on this host, or an explanation why none applies.
|
|
1203
|
+
* Never uses sudo, shell strings, or authentication. */
|
|
1204
|
+
export function requirementInstallPlan(req, { platform = process.platform, context } = {}) {
|
|
1205
|
+
const r = normalizeRequirement(req);
|
|
1206
|
+
if (!r) return undefined;
|
|
1207
|
+
if (r.kind === "runtime-package") {
|
|
1208
|
+
// Never build an executable plan for an invalid entry — the fail-closed
|
|
1209
|
+
// policy must see it as unconsentable, not as an install recipe.
|
|
1210
|
+
if (r._invalid) return { command: r.command, why: r.why, docs: r.install.docs, unavailable: r._invalid };
|
|
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
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
return { command: r.command, why: r.why, docs: r.install.docs, unavailable: applicable.length ? "no allowlisted install method for this host" : "no install method matches this platform" };
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/** Gate: a requirement's command must be a safe executable basename/CLI token —
|
|
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
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/** Aggregate missing host requirements across reconciled scopes, only for
|
|
1256
|
+
* capabilities activated somewhere in those scopes, deduplicated by command.
|
|
1257
|
+
* Returns [{ command, why, docs, plan, requestedBy: [{ capability, scope }],
|
|
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
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/** Execute one consented install plan (argv, no shell) and verify the command lands on PATH. */
|
|
1363
|
+
export function runRequirementInstall(plan, { env = process.env, stdio = "inherit" } = {}) {
|
|
1364
|
+
if (!plan || !plan.argv) throw new Error(`no executable install plan for "${plan?.command}"`);
|
|
1365
|
+
for (const step of plan.steps?.length ? plan.steps : [plan.argv]) {
|
|
1366
|
+
if (step?.length) execFileSync(step[0], step.slice(1), { stdio, env });
|
|
1367
|
+
}
|
|
1368
|
+
// Verify what the requirement actually promised. A runtime package never
|
|
1369
|
+
// lands on PATH, so verifying it there would report every successful install
|
|
1370
|
+
// as a failure.
|
|
1371
|
+
const onPath = plan.runtime
|
|
1372
|
+
? runtimePackageInstalled(plan.runtime, plan.package, env, plan.probe || {})
|
|
1373
|
+
: commandOnPath(plan.command, env);
|
|
1374
|
+
return { command: plan.command, installed: true, onPath };
|
|
1375
|
+
}
|