superpowers-manager 0.1.6 → 0.3.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.
Files changed (59) hide show
  1. package/README.md +184 -64
  2. package/bin/superpowers-manager.js +9 -237
  3. package/dist/adapter-result.js +204 -0
  4. package/dist/adapter.js +649 -0
  5. package/dist/atomic.js +142 -0
  6. package/dist/cli-arguments.js +32 -0
  7. package/dist/cli.js +293 -0
  8. package/dist/codex-json.js +89 -0
  9. package/dist/codex-state.js +78 -0
  10. package/dist/commands/context.js +1 -0
  11. package/dist/commands/install.js +376 -0
  12. package/dist/commands/pin.js +79 -0
  13. package/dist/commands/prepare.js +527 -0
  14. package/dist/commands/probe.js +392 -0
  15. package/dist/commands/track-latest.js +44 -0
  16. package/dist/commands/uninstall.js +308 -0
  17. package/dist/commands/unpin.js +95 -0
  18. package/dist/commands/update.js +138 -0
  19. package/dist/domain/fingerprint.js +4 -0
  20. package/dist/domain/refs.js +37 -0
  21. package/dist/effective-selection.js +123 -0
  22. package/dist/generated-plugin.js +748 -0
  23. package/dist/git.js +38 -0
  24. package/dist/hooks.js +290 -0
  25. package/dist/lifecycle.js +220 -0
  26. package/dist/manifest-overlay.js +125 -0
  27. package/dist/provenance.js +111 -0
  28. package/dist/python-json-format.js +83 -0
  29. package/dist/python-json.js +29 -0
  30. package/dist/python-text.js +74 -0
  31. package/dist/safe-path.js +137 -0
  32. package/dist/safety-error.js +10 -0
  33. package/dist/selection-state-cli.js +85 -0
  34. package/dist/selection-store.js +172 -0
  35. package/dist/selection.js +200 -0
  36. package/dist/status.js +24 -0
  37. package/dist/strict-json.js +248 -0
  38. package/dist/upstream-cli.js +110 -0
  39. package/dist/upstream-version.js +40 -0
  40. package/dist/upstream.js +253 -0
  41. package/dist/validate-generated-plugin-cli.js +118 -0
  42. package/dist/validator.js +317 -0
  43. package/dist/workspace.js +139 -0
  44. package/package.json +25 -2
  45. package/scripts/adapters/codex/adapter +0 -598
  46. package/scripts/adapters/codex/lib.sh +0 -391
  47. package/scripts/adapters/codex/validate-generated-plugin.py +0 -314
  48. package/scripts/core/adapter.sh +0 -92
  49. package/scripts/core/common.sh +0 -35
  50. package/scripts/core/lifecycle.sh +0 -127
  51. package/scripts/core/provenance.sh +0 -135
  52. package/scripts/core/status.sh +0 -27
  53. package/scripts/core/upstream.sh +0 -146
  54. package/scripts/core/validate-adapter-response.py +0 -245
  55. package/scripts/install +0 -60
  56. package/scripts/prepare +0 -126
  57. package/scripts/probe +0 -61
  58. package/scripts/uninstall +0 -44
  59. package/scripts/update +0 -31
package/dist/git.js ADDED
@@ -0,0 +1,38 @@
1
+ import { execFile } from "node:child_process";
2
+ import { SafetyError } from "./safety-error.js";
3
+ export function runGit(args, options = {}) {
4
+ return new Promise((resolve, reject) => {
5
+ execFile("git", [...args], {
6
+ cwd: options.cwd,
7
+ env: { ...process.env, LC_ALL: "C", GIT_TERMINAL_PROMPT: "0" },
8
+ encoding: "utf8",
9
+ maxBuffer: Infinity,
10
+ shell: false,
11
+ windowsHide: true,
12
+ }, (error, stdout, stderr) => {
13
+ if (error === null) {
14
+ resolve({ status: 0, signal: null, stdout, stderr });
15
+ return;
16
+ }
17
+ const failure = error;
18
+ if (typeof failure.code === "string") {
19
+ const message = failure.code === "ENOENT"
20
+ ? "required command not found: git"
21
+ : `cannot run git: ${failure.message}`;
22
+ reject(new SafetyError("git", message, { cause: error }));
23
+ return;
24
+ }
25
+ const signal = failure.signal ?? null;
26
+ if (signal !== null) {
27
+ resolve({ status: null, signal, stdout, stderr });
28
+ return;
29
+ }
30
+ resolve({
31
+ status: typeof failure.code === "number" ? failure.code : 1,
32
+ signal: null,
33
+ stdout,
34
+ stderr,
35
+ });
36
+ });
37
+ });
38
+ }
package/dist/hooks.js ADDED
@@ -0,0 +1,290 @@
1
+ import { copyFile, cp, mkdir, readdir, readFile, readlink, stat, symlink, } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { assertExistingContained, assertProspectiveContained, classifyPathNoFollow, } from "./safe-path.js";
4
+ import { SafetyError } from "./safety-error.js";
5
+ import { parseStrictJson } from "./strict-json.js";
6
+ // Annotated, not `as const`, matching
7
+ // `src/provenance.ts:20::export const PROVENANCE_STRICT_PROFILE` and
8
+ // `src/selection-store.ts:19::const SELECTION_JSON_PROFILE`. The annotation
9
+ // rejects a misspelled or unsupported profile field here rather than at the
10
+ // parseStrictJson call.
11
+ const MANIFEST_PROFILE = {
12
+ duplicateKeys: "last-wins",
13
+ nonStandardConstants: "reject",
14
+ maxDepth: 256,
15
+ };
16
+ // Private helper, matching
17
+ // `src/selection.ts:42::return new SafetyError("selection"` and
18
+ // `src/upstream.ts:35::function upstreamError`. No new exported error class:
19
+ // SafetyError already carries module and cause.
20
+ function hookError(message, cause) {
21
+ return new SafetyError("hooks", message, { cause });
22
+ }
23
+ // One frozen template for every owned safe-path failure. The three wrappers
24
+ // below stay separate because they perform different operations; only the
25
+ // message is shared.
26
+ function ownedPathError(label, path, cause) {
27
+ return hookError(`${label} escapes or could not be resolved: ${path}`, cause);
28
+ }
29
+ // Every safe-path call goes through here. Calling classifyPathNoFollow
30
+ // directly would let a SafetyError with module "safe-path" escape to the CLI,
31
+ // breaking the promise that this module owns its diagnostics.
32
+ async function classifyOwned(path, label) {
33
+ try {
34
+ return await classifyPathNoFollow(path);
35
+ }
36
+ catch (cause) {
37
+ throw ownedPathError(label, path, cause);
38
+ }
39
+ }
40
+ async function isRegularFileFollowing(path) {
41
+ try {
42
+ return (await stat(path)).isFile();
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ }
48
+ async function isDirectoryFollowing(path) {
49
+ try {
50
+ return (await stat(path)).isDirectory();
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ async function requireExistingContained(root, path, label) {
57
+ try {
58
+ await assertExistingContained(root, path);
59
+ }
60
+ catch (cause) {
61
+ throw ownedPathError(label, path, cause);
62
+ }
63
+ }
64
+ async function requireProspectiveContained(root, path, label) {
65
+ try {
66
+ await assertProspectiveContained(root, path);
67
+ }
68
+ catch (cause) {
69
+ throw ownedPathError(label, path, cause);
70
+ }
71
+ }
72
+ export async function readManifest(path) {
73
+ let bytes;
74
+ try {
75
+ bytes = await readFile(path);
76
+ }
77
+ catch {
78
+ // Deliberately drops the cause on both branches. `detail(cause)` on the
79
+ // read branch surfaced a raw errno (`ENOENT: … open '<path>'`), which the
80
+ // `hook classification failed:` site in src/adapter.ts re-emits onto the
81
+ // terminal these commands write to; the parse branch surfaced
82
+ // strict-json's own wording under a prefix src/manifest-overlay.ts also
83
+ // uses with CPython wording.
84
+ // Same text as the `cannot read manifest JSON in` site in src/adapter.ts.
85
+ throw hookError(`cannot read manifest JSON in ${path}`);
86
+ }
87
+ let parsed;
88
+ try {
89
+ parsed = parseStrictJson(bytes, MANIFEST_PROFILE);
90
+ }
91
+ catch {
92
+ throw hookError(`invalid manifest JSON in ${path}`);
93
+ }
94
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
95
+ throw hookError(`manifest must be a JSON object: ${path}`);
96
+ }
97
+ return parsed;
98
+ }
99
+ function isPlainObject(value) {
100
+ return value !== null && typeof value === "object" && !Array.isArray(value);
101
+ }
102
+ function isEmptyArray(value) {
103
+ return Array.isArray(value) && value.length === 0;
104
+ }
105
+ function isEmptyObject(value) {
106
+ return isPlainObject(value) && Object.keys(value).length === 0;
107
+ }
108
+ async function checkedFile(root, relativePath) {
109
+ const source = resolve(root, relativePath);
110
+ await requireExistingContained(root, source, "declared hook source");
111
+ if (!(await isRegularFileFollowing(source))) {
112
+ throw hookError(`declared hook source is not a regular file: ${source}`);
113
+ }
114
+ return source;
115
+ }
116
+ async function validateDeclaredFile(upstreamRoot, raw, index) {
117
+ if (!raw.startsWith("./")) {
118
+ throw hookError(`declared hook path must start with ./: hooks declaration index ${index}`);
119
+ }
120
+ await checkedFile(upstreamRoot, raw.slice(2));
121
+ }
122
+ export async function classifyHooks(manifest, source, upstreamRoot) {
123
+ if (source === "fallback") {
124
+ if (Object.hasOwn(manifest, "hooks")) {
125
+ throw hookError("fallback manifest must not declare hooks");
126
+ }
127
+ return { copyHooksSubtree: false, declaredPaths: [] };
128
+ }
129
+ const declared = Object.hasOwn(manifest, "hooks");
130
+ const hooksRoot = resolve(upstreamRoot, "hooks");
131
+ const defaultConfig = resolve(hooksRoot, "hooks.json");
132
+ const hooksRootPresent = (await classifyOwned(hooksRoot, "hook subtree")) !== "missing";
133
+ if (!declared) {
134
+ return {
135
+ copyHooksSubtree: await isRegularFileFollowing(defaultConfig),
136
+ declaredPaths: [],
137
+ };
138
+ }
139
+ const hooks = manifest.hooks;
140
+ if (isEmptyArray(hooks)) {
141
+ return {
142
+ copyHooksSubtree: await isRegularFileFollowing(defaultConfig),
143
+ declaredPaths: [],
144
+ };
145
+ }
146
+ if (isEmptyObject(hooks)) {
147
+ return { copyHooksSubtree: false, declaredPaths: [] };
148
+ }
149
+ let paths;
150
+ if (typeof hooks === "string") {
151
+ paths = [hooks];
152
+ }
153
+ else if (Array.isArray(hooks) &&
154
+ hooks.length > 0 &&
155
+ hooks.every((value) => typeof value === "string")) {
156
+ paths = hooks;
157
+ }
158
+ else if (isPlainObject(hooks) ||
159
+ (Array.isArray(hooks) && hooks.length > 0 && hooks.every(isPlainObject))) {
160
+ return { copyHooksSubtree: hooksRootPresent, declaredPaths: [] };
161
+ }
162
+ else {
163
+ throw hookError("unsupported or mixed hooks declaration");
164
+ }
165
+ for (const [index, raw] of paths.entries()) {
166
+ await validateDeclaredFile(upstreamRoot, raw, index);
167
+ }
168
+ return { copyHooksSubtree: hooksRootPresent, declaredPaths: paths };
169
+ }
170
+ async function checkedDestination(root, relativePath) {
171
+ const destination = resolve(root, relativePath);
172
+ await requireProspectiveContained(root, dirname(destination), "declared hook destination parent");
173
+ const kind = await classifyOwned(destination, "declared hook destination");
174
+ if (kind === "symlink") {
175
+ throw hookError(`declared hook destination must not be a symlink: ${destination}`);
176
+ }
177
+ if (kind === "missing") {
178
+ await requireProspectiveContained(root, destination, "declared hook destination");
179
+ return destination;
180
+ }
181
+ await requireExistingContained(root, destination, "declared hook destination");
182
+ if (!(await isRegularFileFollowing(destination))) {
183
+ throw hookError(`declared hook destination is not a regular file: ${destination}`);
184
+ }
185
+ return destination;
186
+ }
187
+ async function collectEntries(tree) {
188
+ const found = [];
189
+ const pending = [tree];
190
+ while (pending.length > 0) {
191
+ const current = pending.pop();
192
+ let entries;
193
+ try {
194
+ entries = await readdir(current, { withFileTypes: true });
195
+ }
196
+ catch (cause) {
197
+ throw hookError(`hook subtree escapes or is broken: ${current}`, cause);
198
+ }
199
+ for (const entry of entries) {
200
+ const path = join(current, entry.name);
201
+ found.push(path);
202
+ // Never descend through a symlink; isDirectory() is lstat-based here.
203
+ if (entry.isDirectory())
204
+ pending.push(path);
205
+ }
206
+ }
207
+ return found;
208
+ }
209
+ async function validateSubtreeSymlinks(tree, containmentRoot) {
210
+ if ((await classifyOwned(tree, "hook subtree")) === "symlink") {
211
+ if (isAbsolute(await readlink(tree))) {
212
+ throw hookError(`absolute subtree symlink is not allowed: ${tree}`);
213
+ }
214
+ }
215
+ try {
216
+ await assertExistingContained(containmentRoot, tree);
217
+ }
218
+ catch (cause) {
219
+ throw hookError(`hook subtree escapes or is broken: ${tree}`, cause);
220
+ }
221
+ if (!(await isDirectoryFollowing(tree))) {
222
+ throw hookError(`hook subtree is not a directory: ${tree}`);
223
+ }
224
+ for (const path of await collectEntries(tree)) {
225
+ if ((await classifyOwned(path, "hook subtree entry")) !== "symlink")
226
+ continue;
227
+ if (isAbsolute(await readlink(path))) {
228
+ throw hookError(`absolute symlink is not allowed: ${path}`);
229
+ }
230
+ try {
231
+ await assertExistingContained(containmentRoot, path);
232
+ }
233
+ catch (cause) {
234
+ throw hookError(`symlink escapes or is broken: ${path}`, cause);
235
+ }
236
+ }
237
+ }
238
+ async function copyPreservingSymlink(source, destination) {
239
+ if ((await classifyOwned(source, "declared hook source")) === "symlink") {
240
+ await symlink(await readlink(source), destination);
241
+ return;
242
+ }
243
+ await copyFile(source, destination);
244
+ }
245
+ async function validateMaterializedDestination(destination, candidateRoot) {
246
+ const message = `materialized hook destination escapes or is broken: ${destination}`;
247
+ try {
248
+ await assertExistingContained(candidateRoot, destination);
249
+ }
250
+ catch (cause) {
251
+ // Retain the safe-path error as `cause`; only the message and module are
252
+ // re-owned. Discarding it would erase why containment failed.
253
+ throw hookError(message, cause);
254
+ }
255
+ if (!(await isRegularFileFollowing(destination))) {
256
+ throw hookError(message);
257
+ }
258
+ }
259
+ export async function materializeHooks(plan, sourceRoot, candidateRoot) {
260
+ if (plan.copyHooksSubtree) {
261
+ const sourceHooks = resolve(sourceRoot, "hooks");
262
+ const candidateHooks = resolve(candidateRoot, "hooks");
263
+ await validateSubtreeSymlinks(sourceHooks, sourceRoot);
264
+ if ((await classifyOwned(sourceHooks, "hook subtree")) === "symlink") {
265
+ await symlink(await readlink(sourceHooks), candidateHooks);
266
+ }
267
+ else {
268
+ await cp(sourceHooks, candidateHooks, {
269
+ recursive: true,
270
+ verbatimSymlinks: true,
271
+ });
272
+ }
273
+ await validateSubtreeSymlinks(candidateHooks, candidateRoot);
274
+ }
275
+ const destinations = [];
276
+ for (const raw of plan.declaredPaths) {
277
+ const relativePath = raw.slice(2);
278
+ const source = await checkedFile(sourceRoot, relativePath);
279
+ const destination = await checkedDestination(candidateRoot, relativePath);
280
+ await mkdir(dirname(destination), { recursive: true });
281
+ if ((await classifyOwned(destination, "declared hook destination")) ===
282
+ "missing") {
283
+ await copyPreservingSymlink(source, destination);
284
+ }
285
+ destinations.push(destination);
286
+ }
287
+ for (const destination of destinations) {
288
+ await validateMaterializedDestination(destination, candidateRoot);
289
+ }
290
+ }
@@ -0,0 +1,220 @@
1
+ // Ported from scripts/core/lifecycle.sh. Every function here is PURE: it
2
+ // returns its verdict as data and writes nothing. The command modules do the
3
+ // writing.
4
+ //
5
+ // That shape is not stylistic. src/commands/probe.ts's gatherProbe established
6
+ // it because a write inside a try can raise EPIPE, be caught by that try, and
7
+ // be relabelled as a domain failure. Keeping the predicates write-free means
8
+ // the hazard cannot exist here at all.
9
+ // Frozen text, from
10
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/lifecycle.sh:50-53::'Legacy superpowers-wrapper Codex state is`.
11
+ // The version in the second line is a historical package coordinate an operator
12
+ // must type verbatim, not a reference to this package's current version — do not
13
+ // derive it.
14
+ const BLOCKED_LINES = [
15
+ "Legacy superpowers-wrapper Codex state is installed.",
16
+ "Run: npx superpowers-wrapper@0.1.1 uninstall",
17
+ "Then run: npx superpowers-manager install",
18
+ ];
19
+ // Frozen text, from
20
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/lifecycle.sh:75-77::remains`.
21
+ // Two lines, not three: the report path does not tell the operator to re-install.
22
+ const REPORT_LINES = [
23
+ "Legacy superpowers-wrapper Codex state remains installed.",
24
+ "Run: npx superpowers-wrapper@0.1.1 uninstall",
25
+ ];
26
+ function unknownState(identityState) {
27
+ return {
28
+ kind: "unknown",
29
+ message: `unknown adapter identity state: ${identityState}`,
30
+ };
31
+ }
32
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/lifecycle.sh:43-60::spw_require_no_legacy_state`
33
+ export function requireNoLegacyState(identityState) {
34
+ if (identityState === "neither" || identityState === "manager") {
35
+ return { kind: "ok" };
36
+ }
37
+ if (identityState === "legacy" || identityState === "both") {
38
+ return { kind: "blocked", lines: BLOCKED_LINES };
39
+ }
40
+ return unknownState(identityState);
41
+ }
42
+ // Ported from
43
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/lifecycle.sh:72-85::spw_report_legacy_state`.
44
+ // Same enumeration, different disposition: this one reports and continues
45
+ // rather than blocking, so its clean arm and its legacy arm are both non-fatal.
46
+ export function reportLegacyState(identityState) {
47
+ if (identityState === "neither" || identityState === "manager") {
48
+ return { kind: "ok" };
49
+ }
50
+ if (identityState === "legacy" || identityState === "both") {
51
+ return { kind: "report", lines: REPORT_LINES };
52
+ }
53
+ return unknownState(identityState);
54
+ }
55
+ import { hasTerminalControl } from "./adapter-result.js";
56
+ import { commitMatches } from "./status.js";
57
+ // Ported from
58
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/lifecycle.sh:62-70::spw_require_managed_update_control`.
59
+ // Both refusals reached spw_die in the shell, so both carry the `error: ` prefix
60
+ // at the call site and neither is special.
61
+ export function requireManagedUpdateControl(value) {
62
+ if (value === "managed")
63
+ return { ok: true };
64
+ if (value === "unsupported") {
65
+ return {
66
+ ok: false,
67
+ message: "adapter cannot guarantee manager-controlled updates",
68
+ };
69
+ }
70
+ return {
71
+ ok: false,
72
+ message: `unknown adapter update-control capability: ${value}`,
73
+ };
74
+ }
75
+ function readResult(adapterResult) {
76
+ if (adapterResult.status !== 0)
77
+ return { kind: "call-failed" };
78
+ const outcome = adapterResult.outcome;
79
+ if (!outcome.ok)
80
+ return { kind: "call-failed" };
81
+ const value = outcome.result;
82
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
83
+ return { kind: "unusable" };
84
+ }
85
+ return { kind: "object", value: value };
86
+ }
87
+ // Ported from
88
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/lifecycle.sh:87-124::spw_verify_installed_fingerprint`.
89
+ // The shell performed the inspection itself at :91; a pure function cannot, so
90
+ // the caller performs it and a failure arrives here as an AdapterResult with a
91
+ // non-zero status.
92
+ export function verifyInstalledFingerprint(desiredCommit, installResult, inspectResult) {
93
+ const inspected = readResult(inspectResult);
94
+ if (inspected.kind === "call-failed") {
95
+ return {
96
+ ok: false,
97
+ stdout: [],
98
+ stderr: [
99
+ "error: installed manager fingerprint inspection failed after install.",
100
+ ],
101
+ };
102
+ }
103
+ if (inspected.kind === "unusable") {
104
+ // A well-formed outcome whose `result` is not an object is the port's
105
+ // analogue of the shell's own split: the inspect call SUCCEEDED and only
106
+ // the content is unusable. This is the branch
107
+ // tests/unit/lifecycle.test.js's "an unparseable fingerprint result
108
+ // names parsing, not inspection" exercises. Spec §6.2.3 items 3 and 4.
109
+ return {
110
+ ok: false,
111
+ stdout: [],
112
+ stderr: [
113
+ "error: cannot parse installed manager fingerprint inspection result after install.",
114
+ ],
115
+ };
116
+ }
117
+ const raw = inspected.value.fingerprint;
118
+ // The Python reader printed the empty string for a JSON null, and
119
+ // `fingerprint` is null whenever no plugin version is active
120
+ // (`src/adapter.ts:820::fingerprint: null`). Anything non-string and non-null
121
+ // is unparseable.
122
+ //
123
+ // PORT-ONLY, and intentional. The shell cannot construct this trigger:
124
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/provenance.sh:62::print(value`
125
+ // stringifies any non-null scalar, so a non-string fingerprint never reaches
126
+ // spw_verify_installed_fingerprint.
127
+ // The port CAN encounter the shape and must fail closed rather than
128
+ // coerce. Pinned by a test below and recorded in
129
+ // tests/migration-inventory/codex-state-units.md. Spec §6.2.3 item 3.
130
+ if (raw !== null && raw !== undefined && typeof raw !== "string") {
131
+ return {
132
+ ok: false,
133
+ stdout: [],
134
+ stderr: [
135
+ "error: cannot parse installed manager fingerprint inspection result after install.",
136
+ ],
137
+ };
138
+ }
139
+ const installedCommit = typeof raw === "string" ? raw : "";
140
+ // :99-100 printed both lines BEFORE deciding, on every path that got this
141
+ // far. Moving them into the success branch would silently drop them from the
142
+ // failure output an operator reads to diagnose the mismatch.
143
+ const stdout = [
144
+ `desired_commit=${desiredCommit}`,
145
+ `installed_commit=${installedCommit}`,
146
+ ];
147
+ if (installedCommit.length > 0 &&
148
+ commitMatches(desiredCommit, installedCommit)) {
149
+ return { ok: true, stdout: [...stdout, "manager updated"], stderr: [] };
150
+ }
151
+ // :106-113 — which hint key is read depends on whether a commit was
152
+ // detected at all, and the hint is optional in both directions.
153
+ const installed = readResult(installResult);
154
+ const hints = installed.kind === "object" ? installed.value.verification_hints : null;
155
+ let hint = "";
156
+ if (typeof hints === "object" && hints !== null && !Array.isArray(hints)) {
157
+ const key = installedCommit.length > 0 ? "mismatch" : "missing";
158
+ const value = hints[key];
159
+ if (typeof value === "string" && !hasTerminalControl(value))
160
+ hint = value;
161
+ }
162
+ const stderr = [
163
+ installedCommit.length > 0
164
+ ? "error: installed manager fingerprint does not match the prepared plugin after install."
165
+ : "error: installed manager fingerprint is not detectable after install.",
166
+ ];
167
+ if (hint.length > 0)
168
+ stderr.push(`hint: ${hint}`);
169
+ return { ok: false, stdout, stderr };
170
+ }
171
+ // Ported from
172
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/lifecycle.sh:126-141::spw_verify_uninstalled_resources`,
173
+ // with the Boolean coercion of
174
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/adapter.sh:58-73::spw_adapter_result_boolean`
175
+ // folded in: a non-Boolean is a hard failure, never a falsy "absent".
176
+ export function verifyUninstalledResources(inspectResult) {
177
+ const read = readResult(inspectResult);
178
+ if (read.kind !== "object") {
179
+ return {
180
+ ok: false,
181
+ message: "cannot read the adapter ownership inspection after removal",
182
+ };
183
+ }
184
+ const inspected = read.value;
185
+ // A missing or non-object `resources` falls THROUGH to the Boolean check
186
+ // rather than getting its own message.
187
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/adapter.sh:70::expected`
188
+ // emits "expected Boolean adapter result at resources.plugin" for input {} —
189
+ // the input tests/unit/lifecycle.test.js's "a non-object resources falls
190
+ // through to the Boolean message" exercises — so a distinct "not an object"
191
+ // message here would be a port-only divergence. Parity, not divergence. Spec
192
+ // §6.2.3 item 3.
193
+ const resources = inspected.resources;
194
+ const bag = typeof resources === "object" &&
195
+ resources !== null &&
196
+ !Array.isArray(resources)
197
+ ? resources
198
+ : {};
199
+ for (const key of ["plugin", "marketplace"]) {
200
+ if (typeof bag[key] !== "boolean") {
201
+ return {
202
+ ok: false,
203
+ message: `expected a Boolean adapter result at resources.${key}`,
204
+ };
205
+ }
206
+ }
207
+ if (bag.plugin === true) {
208
+ return {
209
+ ok: false,
210
+ message: "owned plugin resource is still installed after removal",
211
+ };
212
+ }
213
+ if (bag.marketplace === true) {
214
+ return {
215
+ ok: false,
216
+ message: "owned marketplace resource is still registered after removal",
217
+ };
218
+ }
219
+ return { ok: true };
220
+ }
@@ -0,0 +1,125 @@
1
+ import { escapeNonAscii, formatPythonNumber } from "./python-json-format.js";
2
+ import { SafetyError } from "./safety-error.js";
3
+ import { isRawNumber, isRawObject, parseStrictJsonPreservingNumbers, } from "./strict-json.js";
4
+ const OVERLAY_PROFILE = {
5
+ duplicateKeys: "last-wins",
6
+ nonStandardConstants: "reject",
7
+ maxDepth: 256,
8
+ };
9
+ /**
10
+ * Apply the manager's manifest overlay to a Codex plugin manifest.
11
+ *
12
+ * Byte-for-byte equivalent to the CPython implementation this replaces:
13
+ * `json.dump(data, f, indent=2, allow_nan=False)` plus a trailing newline.
14
+ * The byte-parity evidence lives in
15
+ * `tests/baseline/manifest-overlay-parity.test.js` (tracked as
16
+ * `MANIFEST-READER-OVERLAY-01` in `docs/baseline/traceability.md`) against
17
+ * the fixtures under `tests/fixtures/baseline/overlay-parity/`. Known,
18
+ * deliberate divergences from strict byte parity are recorded in
19
+ * `tests/fixtures/baseline/overlay-parity/divergent/README.md`.
20
+ */
21
+ export function applyManifestOverlay(source, version, path) {
22
+ let parsed;
23
+ try {
24
+ parsed = parseStrictJsonPreservingNumbers(source, OVERLAY_PROFILE);
25
+ }
26
+ catch (cause) {
27
+ throw translate(cause, source, path);
28
+ }
29
+ if (!isRawObject(parsed)) {
30
+ throw new SafetyError("manifest-overlay", `manifest must be a JSON object: ${path}`);
31
+ }
32
+ // CPython assigns into a dict: an existing key keeps its position and a new
33
+ // key is appended in assignment order. Verified 2026-07-31 —
34
+ // {"version":"old","b":2} becomes {"version": ..., "b": 2, "skills": ...},
35
+ // while {"b":2} becomes {"b": 2, "version": ..., "skills": ...}.
36
+ const entries = parsed.entries.map(([key, value]) => [key, value]);
37
+ setMember(entries, "version", version);
38
+ setMember(entries, "skills", "./skills/");
39
+ try {
40
+ return `${emitObject(entries, 0)}\n`;
41
+ }
42
+ catch (cause) {
43
+ throw rewrapNumberOutOfRange(cause, path);
44
+ }
45
+ }
46
+ // formatPythonNumber (src/python-json-format.ts) has no notion of a manifest
47
+ // path — it is a general CPython-`json.dump`-equivalent number formatter,
48
+ // exercised on its own in tests/unit/python-json-format.test.js — so it
49
+ // throws a bare `JSON number out of range: ${raw}`. That left the operator
50
+ // with no indication of which manifest produced the diagnostic, unlike every
51
+ // other overlay failure. This rewrap adds the path without touching
52
+ // formatPythonNumber's own contract, and is scoped to exactly this message so
53
+ // it cannot swallow or reword any other overlay throw.
54
+ const NUMBER_OUT_OF_RANGE_PREFIX = "JSON number out of range: ";
55
+ function rewrapNumberOutOfRange(cause, path) {
56
+ if (cause instanceof SafetyError &&
57
+ cause.message.startsWith(NUMBER_OUT_OF_RANGE_PREFIX)) {
58
+ const raw = cause.message.slice(NUMBER_OUT_OF_RANGE_PREFIX.length);
59
+ return new SafetyError("manifest-overlay", `JSON number out of range in ${path}: ${raw}`, { cause });
60
+ }
61
+ return cause;
62
+ }
63
+ function setMember(entries, key, value) {
64
+ const index = entries.findIndex(([existing]) => existing === key);
65
+ if (index === -1)
66
+ entries.push([key, value]);
67
+ else
68
+ entries[index] = [key, value];
69
+ }
70
+ /**
71
+ * Map strict-json's diagnostics onto the CPython wording the callers assert.
72
+ * Repository policy at `AGENTS.md:90::most diagnostics are asserted as`
73
+ * freezes most of these diagnostics as complete-string contracts.
74
+ */
75
+ function translate(cause, source, path) {
76
+ const text = cause instanceof Error ? cause.message : String(cause);
77
+ const token = /^non-standard JSON constant (\S+)/.exec(text)?.[1];
78
+ if (token !== undefined) {
79
+ return new SafetyError("manifest-overlay", `invalid manifest JSON in ${path}: non-standard numeric constant: ${token}`, { cause });
80
+ }
81
+ if (/container depth exceeds/.test(text)) {
82
+ return new SafetyError("manifest-overlay", `JSON nesting exceeds limit in ${path}`, { cause });
83
+ }
84
+ // strict-json reports `… at character N`
85
+ // (`src/strict-json.ts:305::at character ${this.index}`), a zero-based offset. CPython reports
86
+ // one-based line and column. Convert so the message keeps CPython's shape.
87
+ // This does NOT claim the two parsers fail at the same place on the same
88
+ // malformed input — see the spec.
89
+ const match = /^(.*) at character (\d+)$/.exec(text);
90
+ const reason = match?.[1] ?? text;
91
+ const offset = match === null ? 0 : Number(match[2]);
92
+ const preceding = source.slice(0, offset);
93
+ const newlines = preceding.split("\n");
94
+ const line = newlines.length;
95
+ const column = (newlines[newlines.length - 1]?.length ?? 0) + 1;
96
+ return new SafetyError("manifest-overlay", `invalid manifest JSON in ${path}: line ${line} column ${column}: ${reason}`, { cause });
97
+ }
98
+ function emit(value, depth) {
99
+ if (value === null)
100
+ return "null";
101
+ if (typeof value === "boolean")
102
+ return value ? "true" : "false";
103
+ if (typeof value === "string")
104
+ return escapeNonAscii(JSON.stringify(value));
105
+ // Order matters: isRawNumber before isRawObject, and both before Array —
106
+ // the branded records are objects too.
107
+ if (isRawNumber(value))
108
+ return formatPythonNumber(value.source);
109
+ if (isRawObject(value))
110
+ return emitObject(value.entries, depth);
111
+ const inner = " ".repeat(depth + 1);
112
+ const outer = " ".repeat(depth);
113
+ if (value.length === 0)
114
+ return "[]";
115
+ const items = value.map((item) => `${inner}${emit(item, depth + 1)}`);
116
+ return `[\n${items.join(",\n")}\n${outer}]`;
117
+ }
118
+ function emitObject(entries, depth) {
119
+ if (entries.length === 0)
120
+ return "{}";
121
+ const inner = " ".repeat(depth + 1);
122
+ const outer = " ".repeat(depth);
123
+ const members = entries.map(([key, item]) => `${inner}${escapeNonAscii(JSON.stringify(key))}: ${emit(item, depth + 1)}`);
124
+ return `{\n${members.join(",\n")}\n${outer}}`;
125
+ }