superpowers-manager 0.2.0 → 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 (64) hide show
  1. package/README.md +112 -47
  2. package/bin/superpowers-manager.js +9 -283
  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 -603
  46. package/scripts/adapters/codex/lib.sh +0 -449
  47. package/scripts/adapters/codex/validate-generated-plugin.py +0 -314
  48. package/scripts/core/adapter.sh +0 -103
  49. package/scripts/core/common.sh +0 -59
  50. package/scripts/core/lifecycle.sh +0 -141
  51. package/scripts/core/provenance.sh +0 -135
  52. package/scripts/core/selection-state.py +0 -414
  53. package/scripts/core/selection.sh +0 -168
  54. package/scripts/core/status.sh +0 -27
  55. package/scripts/core/upstream.sh +0 -280
  56. package/scripts/core/validate-adapter-response.py +0 -284
  57. package/scripts/install +0 -59
  58. package/scripts/pin +0 -51
  59. package/scripts/prepare +0 -117
  60. package/scripts/probe +0 -82
  61. package/scripts/track-latest +0 -30
  62. package/scripts/uninstall +0 -35
  63. package/scripts/unpin +0 -41
  64. package/scripts/update +0 -34
@@ -0,0 +1,527 @@
1
+ import { cp, mkdir, rm, stat } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
3
+ import { atomicReplaceDir } from "../atomic.js";
4
+ import { oneLine } from "../cli-arguments.js";
5
+ import { computeEffectiveSelection } from "../effective-selection.js";
6
+ import { runGit } from "../git.js";
7
+ import { readManifest } from "../hooks.js";
8
+ import { writeProvenance } from "../provenance.js";
9
+ import { SafetyError } from "../safety-error.js";
10
+ import { manifestVersionForRef } from "../upstream-version.js";
11
+ import { fetchExactCommit, gitSafeSource } from "../upstream.js";
12
+ import { BOUNDED_EXECUTABLE, UNBOUNDED_LEGACY, launchFailureMessage, resolveValidator, runValidator, displayPath, } from "../validator.js";
13
+ import { withWorkspace, workspaceRemovalFailure } from "../workspace.js";
14
+ import { replayOutcome } from "./probe.js";
15
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:64-67::spw_require_upstream_path "$cache/skills`, via spw_require_upstream_path. Order is the shell's;
16
+ // the first miss wins.
17
+ const REQUIRED_UPSTREAM = [
18
+ { path: "skills", label: "skills/" },
19
+ { path: "LICENSE", label: "LICENSE" },
20
+ { path: "README.md", label: "README.md" },
21
+ { path: "CODE_OF_CONDUCT.md", label: "CODE_OF_CONDUCT.md" },
22
+ ];
23
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:73-77::spw_copy_path_if_present "$cache/skills`. Same names on both sides.
24
+ const COPY_PATHS = [
25
+ "skills",
26
+ "assets",
27
+ "LICENSE",
28
+ "README.md",
29
+ "CODE_OF_CONDUCT.md",
30
+ ];
31
+ const RESOLUTION_KINDS = [
32
+ "latest-release",
33
+ "tag",
34
+ "ref",
35
+ "raw-commit",
36
+ ];
37
+ // Every message this module writes is hand-written here. The cause is attached
38
+ // for debuggability and never reaches a stream: oneLine (src/cli-arguments.ts)
39
+ // reads .message only. Same arrangement as hookError
40
+ // (`src/hooks.ts:44::function hookError`).
41
+ function prepareError(message, cause) {
42
+ return new SafetyError("prepare", message, { cause });
43
+ }
44
+ // `[ -e ]` — follows symlinks, so a dangling link is absent to the shell too.
45
+ // Two call sites, each mirroring a distinct `-e` in the shell: the
46
+ // REQUIRED_UPSTREAM loop
47
+ // (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/common.sh:53-59::spw_require_upstream_path`
48
+ // — e.g. skills/ is a directory, not a regular file) and copyPathIfPresent's
49
+ // guard (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/common.sh:44-51::spw_copy_path_if_present`). The `.git`
50
+ // check below is NOT a third site: it needs `-d` (F1), not `-e`, and uses
51
+ // directoryExists instead.
52
+ async function pathExists(path) {
53
+ try {
54
+ await stat(path);
55
+ return true;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ // `[ -f ]` — regular file. `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:42::missing`, :80, and :108 all use -f, and
62
+ // tests/baseline/cli-parity.test.js's "CLI-ENV-MANIFEST-TEMPLATE-01 fallback
63
+ // template bytes and non-file rejection" asserts a DIRECTORY passed as
64
+ // SUPERPOWERS_MANIFEST_TEMPLATE is rejected before any adapter build. A
65
+ // stat-only predicate would accept it.
66
+ async function regularFileExists(path) {
67
+ try {
68
+ return (await stat(path)).isFile();
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }
74
+ // `[ -d ]` — `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:50::if [ -d`. A regular file named `.git` is what a git
75
+ // worktree or `clone --separate-git-dir` leaves behind; `-e` would take the
76
+ // fetch branch and let git follow its `gitdir:` pointer, where the shell took
77
+ // the clone branch. `src/upstream.ts:332::if (!(await isDirectory` makes the
78
+ // same distinction.
79
+ async function directoryExists(path) {
80
+ try {
81
+ return (await stat(path)).isDirectory();
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
87
+ // mkdir, cp, and rm throw raw ErrnoExceptions. Every prepare-owned call goes
88
+ // through here so the message on the stream is this module's, not Node's.
89
+ async function owned(message, fn) {
90
+ try {
91
+ return await fn();
92
+ }
93
+ catch (cause) {
94
+ throw prepareError(message, cause);
95
+ }
96
+ }
97
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/common.sh:44-51::spw_copy_path_if_present`.
98
+ // `cp -R` copies symlinks AS symlinks;
99
+ // fs.cp's default rewrites relative link targets against the destination,
100
+ // which would change what the adapter's containment checks see.
101
+ async function copyPathIfPresent(source, destination) {
102
+ if (!(await pathExists(source)))
103
+ return;
104
+ await owned(`cannot clear candidate path: ${destination}`, () => rm(destination, { recursive: true, force: true }));
105
+ await owned(`cannot copy upstream path into candidate: ${source}`, () => cp(source, destination, { recursive: true, verbatimSymlinks: true }));
106
+ }
107
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:17-24::case "$cache_parent`. Only cache_parent and plugin_root are resolved against
108
+ // the invocation cwd; manifest_template (:14) is not.
109
+ function resolveFromCwd(value, cwd) {
110
+ return isAbsolute(value) ? value : resolve(cwd, value);
111
+ }
112
+ // readManifest (`src/hooks.ts:113::readManifest`) owns the read and the parse completely: byte
113
+ // read so invalid UTF-8 is rejected rather than replaced, cause dropped, three
114
+ // hand-written messages naming the path, object check included. Its
115
+ // diagnostics are pinned by
116
+ // `tests/unit/hooks.test.js:108::void test("readManifest diagnostics`. This wrapper adds only
117
+ // the `version` type check.
118
+ export async function readUpstreamManifestVersion(path) {
119
+ const manifest = await readManifest(path);
120
+ const value = manifest.version;
121
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/provenance.sh:56-62::for part in dotted_key.split`
122
+ // — absent key and JSON null both yield "".
123
+ if (value === undefined || value === null)
124
+ return "";
125
+ if (typeof value !== "string") {
126
+ // The shell stringified any other type through Python's print()
127
+ // (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/provenance.sh:62::print(value`), so `"version": 6` became "6" and flowed into both the
128
+ // provenance record and --upstream-manifest-version. Fail closed instead.
129
+ // Spec divergence 7.
130
+ throw prepareError(`upstream manifest version is not a string: ${path}`);
131
+ }
132
+ return value;
133
+ }
134
+ function asResolutionKind(value) {
135
+ for (const kind of RESOLUTION_KINDS) {
136
+ if (kind === value)
137
+ return kind;
138
+ }
139
+ throw prepareError(`unknown upstream resolution kind: ${value}`);
140
+ }
141
+ // Each stream's marker goes to that stream's own destination, so a reader of either
142
+ // sees its own truncation and neither is silently short.
143
+ function withTruncationMarker(captured, stream) {
144
+ if (captured.droppedBytes === 0)
145
+ return captured.text;
146
+ return `${captured.text}\n[superpowers-manager: validator ${stream} truncated, ${captured.droppedBytes} bytes dropped]\n`;
147
+ }
148
+ // D8's disclosure. The variable is ambient process env and nothing filters it, so a
149
+ // consumer's CI can set it; naming what actually ran is what makes a supply-chain
150
+ // surprise visible. Paths go through displayPath, so a control character in either
151
+ // one cannot reach the terminal raw.
152
+ function disclosureLine(resolution) {
153
+ const configured = displayPath(resolution.configured);
154
+ if (resolution.resolved === null) {
155
+ // The manager does not know the final target and does not pretend to. But a
156
+ // dangling symlink is a KNOWN fact even when resolution fails, and saying "the
157
+ // OS selects it" about a path-like value would be wrong.
158
+ if (resolution.isSymlink) {
159
+ return `[superpowers-manager: running external validator ${configured} (a symlink whose target could not be resolved)]\n`;
160
+ }
161
+ if (configured.includes(sep)) {
162
+ return `[superpowers-manager: running external validator ${configured} (unresolved)]\n`;
163
+ }
164
+ return `[superpowers-manager: running external validator ${configured} (a bare name; PATH selects the file, and the manager does not guess which)]\n`;
165
+ }
166
+ const via = resolution.isSymlink ? " via symlink" : "";
167
+ return `[superpowers-manager: running external validator ${configured}${via} -> ${displayPath(resolution.resolved)}]\n`;
168
+ }
169
+ const NO_VALIDATOR_OUTPUT = { stdout: "", stderr: "" };
170
+ async function gatherPrepare(ctx) {
171
+ const env = ctx.env;
172
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:16::invocation_root=` — captured before the two case statements below.
173
+ // getcwd(3) returns the physical path, so this matches `pwd -P` without a
174
+ // realpath call.
175
+ const cwd = process.cwd();
176
+ const cacheParent = resolveFromCwd(env.SUPERPOWERS_CACHE_DIR || join(ctx.root, ".cache", "upstream"), cwd);
177
+ const pluginRoot = resolveFromCwd(env.SUPERPOWERS_PLUGIN_ROOT || join(ctx.root, "plugins", "superpowers"), cwd);
178
+ const manifestTemplate = env.SUPERPOWERS_MANIFEST_TEMPLATE ||
179
+ join(ctx.root, "plugins", "superpowers", ".codex-plugin", "plugin.template.json");
180
+ const additionalValidator = env.SUPERPOWERS_VALIDATOR || "";
181
+ const executableValidator = env.SUPERPOWERS_VALIDATOR_EXECUTABLE || "";
182
+ const cache = join(cacheParent, "superpowers");
183
+ const tmpParent = dirname(pluginRoot);
184
+ await owned(`cannot create directory: ${tmpParent}`, () => mkdir(tmpParent, { recursive: true }));
185
+ let cleanupWarning = null;
186
+ const outcome = await withWorkspace(tmpParent, ".superpowers.prepare.", async (workspace) => {
187
+ const failed = (message) => ({
188
+ kind: "failed",
189
+ outcomes: [],
190
+ validator: NO_VALIDATOR_OUTPUT,
191
+ message,
192
+ });
193
+ const candidate = join(workspace, "superpowers");
194
+ const selection = await computeEffectiveSelection(ctx.root, env);
195
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:42::missing` — `[ -f ]`.
196
+ if (!(await regularFileExists(manifestTemplate))) {
197
+ return failed(`missing fallback manifest template: ${manifestTemplate}`);
198
+ }
199
+ await owned(`cannot create directory: ${cacheParent}`, () => mkdir(cacheParent, { recursive: true }));
200
+ if (selection.selectionMode === "pinned") {
201
+ await fetchExactCommit(selection.effectiveSource, selection.desiredCommit, cache, workspace);
202
+ }
203
+ else {
204
+ const source = gitSafeSource(selection.effectiveSource);
205
+ if (await directoryExists(join(cache, ".git"))) {
206
+ const fetched = await runGit([
207
+ "-C",
208
+ cache,
209
+ "fetch",
210
+ "--tags",
211
+ "--prune",
212
+ "--",
213
+ source,
214
+ ]);
215
+ // runGit returns its status rather than throwing, so no git output is
216
+ // in scope. `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:52::spw_die "cannot fetch` names the source and nothing else, and
217
+ // computeEffectiveSelection ran validateSource first, so a
218
+ // credential-bearing source never reaches here.
219
+ if (fetched.status !== 0) {
220
+ return failed(`cannot fetch upstream repo: ${selection.effectiveSource}`);
221
+ }
222
+ }
223
+ else {
224
+ const cloned = await runGit(["clone", "--", source, cache]);
225
+ if (cloned.status !== 0) {
226
+ return failed(`cannot clone upstream repo: ${selection.effectiveSource}`);
227
+ }
228
+ }
229
+ }
230
+ const checkedOut = await runGit([
231
+ "-C",
232
+ cache,
233
+ "checkout",
234
+ "--detach",
235
+ selection.desiredCommit,
236
+ ]);
237
+ if (checkedOut.status !== 0) {
238
+ return failed(`cannot check out upstream commit: ${selection.desiredCommit}`);
239
+ }
240
+ for (const required of REQUIRED_UPSTREAM) {
241
+ // spw_require_upstream_path is `[ -e ]`, not `[ -f ]`: skills/ is a
242
+ // directory.
243
+ if (!(await pathExists(join(cache, required.path)))) {
244
+ return failed(`required upstream path missing: ${required.label}`);
245
+ }
246
+ }
247
+ await owned(`cannot clear candidate root: ${candidate}`, () => rm(candidate, { recursive: true, force: true }));
248
+ await owned(`cannot create candidate root: ${candidate}`, () => mkdir(join(candidate, ".codex-plugin"), { recursive: true }));
249
+ for (const name of COPY_PATHS) {
250
+ await copyPathIfPresent(join(cache, name), join(candidate, name));
251
+ }
252
+ const upstreamManifest = join(cache, ".codex-plugin", "plugin.json");
253
+ let upstreamManifestVersion = "";
254
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:80::if [ -f` — `[ -f ]`.
255
+ if (await regularFileExists(upstreamManifest)) {
256
+ upstreamManifestVersion =
257
+ await readUpstreamManifestVersion(upstreamManifest);
258
+ }
259
+ await writeProvenance(join(candidate, ".superpowers-upstream.json"), {
260
+ source: selection.effectiveSource,
261
+ requested_ref: selection.requestedRef,
262
+ resolved_ref: selection.resolvedRef,
263
+ commit: selection.desiredCommit,
264
+ upstream_manifest_version: upstreamManifestVersion,
265
+ });
266
+ const managerVersion = manifestVersionForRef({
267
+ requestedRef: selection.requestedRef,
268
+ resolutionKind: asResolutionKind(selection.resolutionKind),
269
+ resolvedRef: selection.resolvedRef,
270
+ commit: selection.desiredCommit,
271
+ });
272
+ let built;
273
+ try {
274
+ built = await ctx.adapter([
275
+ "build",
276
+ "--upstream-root",
277
+ cache,
278
+ "--candidate-root",
279
+ candidate,
280
+ "--requested-ref",
281
+ selection.requestedRef,
282
+ "--resolved-ref",
283
+ selection.resolvedRef,
284
+ "--commit",
285
+ selection.desiredCommit,
286
+ "--manager-version",
287
+ managerVersion,
288
+ "--upstream-manifest-version",
289
+ upstreamManifestVersion,
290
+ "--fallback-manifest",
291
+ manifestTemplate,
292
+ ], { root: ctx.root, env });
293
+ }
294
+ catch {
295
+ // ctx.adapter reports CONTROLLED failures by return value
296
+ // (`src/adapter-result.ts:32-35::export interface AdapterResult`) but still THROWS for a
297
+ // non-AdapterFailure cause (runAdapter's closing `throw cause`,
298
+ // src/adapter.ts). That cause is by construction the one failure
299
+ // src/adapter.ts declined to own, so its text must never reach
300
+ // ctx.stderr. Caught here rather than in runPrepare's outer catch,
301
+ // the same treatment
302
+ // `src/commands/probe.ts:205-228::It does still THROW for a non-AdapterFailure cause`
303
+ // gives it.
304
+ return failed("cannot build the generated plugin candidate");
305
+ }
306
+ const outcomes = [built.outcome];
307
+ if (built.status !== 0 || !built.outcome.ok) {
308
+ return {
309
+ kind: "failed",
310
+ outcomes,
311
+ validator: NO_VALIDATOR_OUTPUT,
312
+ // replayOutcome emits the adapter's own error and hints; a second
313
+ // line here would duplicate them. The `ok && status !== 0`
314
+ // combination cannot arise from successResult/failureResult, so it
315
+ // gets its own hand-written message rather than a silent replay.
316
+ message: built.outcome.ok
317
+ ? "adapter reported failure without an error outcome"
318
+ : null,
319
+ };
320
+ }
321
+ let validator = NO_VALIDATOR_OUTPUT;
322
+ if (additionalValidator.length > 0) {
323
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:108::[ -f "$additional_validator` — `[ -f ]`.
324
+ if (!(await regularFileExists(additionalValidator))) {
325
+ return {
326
+ kind: "failed",
327
+ outcomes,
328
+ validator,
329
+ message: `additional plugin validator not found: ${additionalValidator}`,
330
+ };
331
+ }
332
+ const ran = await runValidator(["python3", additionalValidator, candidate], UNBOUNDED_LEGACY, env, workspace);
333
+ if (ran.kind === "launchFailed") {
334
+ // PARITY, and it must stay a THROW. The legacy path rejects the workspace
335
+ // callback on a spawn failure; withWorkspace returns the callback error
336
+ // and the `outcomes` collected above are LOST. That loss is a recorded,
337
+ // deliberately unassigned defect. Returning a failed outcome here would
338
+ // replay those outcomes instead -- observably different control flow for
339
+ // CLI-ENV-VALIDATOR-01, which this PR is not authorized to change.
340
+ throw prepareError(`cannot execute additional plugin validator: ${additionalValidator}`, ran.cause);
341
+ }
342
+ validator = { stdout: ran.stdout.text, stderr: ran.stderr.text };
343
+ if (ran.kind !== "exited" || ran.code !== 0) {
344
+ return {
345
+ kind: "failed",
346
+ outcomes,
347
+ validator,
348
+ message: "additional plugin validation failed",
349
+ };
350
+ }
351
+ }
352
+ else if (executableValidator.length > 0) {
353
+ const resolution = await resolveValidator(executableValidator);
354
+ const ran = await runValidator([executableValidator, candidate], BOUNDED_EXECUTABLE, env, workspace);
355
+ // The disclosure lands BEFORE any early return, so a launch failure still
356
+ // reports what the manager tried to run and what it resolved to — exactly
357
+ // the path where an operator most needs it. It is assigned twice rather
358
+ // than once because `launchFailed` carries no captured streams: there was
359
+ // no process to capture from.
360
+ validator = { stdout: disclosureLine(resolution), stderr: "" };
361
+ if (ran.kind === "launchFailed") {
362
+ return {
363
+ kind: "failed",
364
+ outcomes,
365
+ validator,
366
+ message: launchFailureMessage(ran.errno, resolution),
367
+ };
368
+ }
369
+ validator = {
370
+ stdout: disclosureLine(resolution) +
371
+ withTruncationMarker(ran.stdout, "stdout"),
372
+ stderr: withTruncationMarker(ran.stderr, "stderr"),
373
+ };
374
+ if (ran.kind === "timedOut") {
375
+ return {
376
+ kind: "failed",
377
+ outcomes,
378
+ validator,
379
+ message: `external plugin validation timed out after ${Math.round(ran.afterMs / 1000)}s`,
380
+ };
381
+ }
382
+ if (ran.code !== 0) {
383
+ return {
384
+ kind: "failed",
385
+ outcomes,
386
+ validator,
387
+ message: "external plugin validation failed",
388
+ };
389
+ }
390
+ }
391
+ // The swap must run inside the workspace callback: withWorkspace removes
392
+ // the workspace on return, and the candidate lives in it.
393
+ //
394
+ // atomicReplaceDir's outer catch (`src/atomic.ts:208-215::if (cause`) wraps every
395
+ // non-SafetyError into a SafetyError, so the callee owns every failure on
396
+ // this path and re-emitting its own diagnostic is the sanctioned form of
397
+ // interpolation. The hand-written prefix carries the live root, which the
398
+ // callee's message does not.
399
+ try {
400
+ await atomicReplaceDir(candidate, pluginRoot);
401
+ }
402
+ catch (cause) {
403
+ return {
404
+ kind: "failed",
405
+ outcomes,
406
+ validator,
407
+ message: `cannot install generated tree into ${pluginRoot}: ${oneLine(cause)}`,
408
+ };
409
+ }
410
+ return {
411
+ kind: "ok",
412
+ outcomes,
413
+ validator,
414
+ resolvedRef: selection.resolvedRef,
415
+ commit: selection.desiredCommit,
416
+ };
417
+ }, {
418
+ // Suppresses withWorkspace's throw on a POST-SUCCESS cleanup failure, so
419
+ // the PrepareOutcome the callback already computed still reaches
420
+ // runPrepare instead of being discarded. The reporter runs synchronously,
421
+ // as the option requires (`src/workspace.ts:103-106::Must`).
422
+ onCleanupFailure: (path) => {
423
+ cleanupWarning = workspaceRemovalFailure(path);
424
+ },
425
+ });
426
+ return { outcome, cleanupWarning };
427
+ }
428
+ export async function runPrepare(argv, ctx) {
429
+ // scripts/prepare never reads "$@", so extra arguments are ignored. This is a
430
+ // deliberate asymmetry with probe, whose shell original rejected unknown
431
+ // arguments and whose arity therefore moved into parseArgs in slice 2.
432
+ void argv;
433
+ let run;
434
+ try {
435
+ run = await gatherPrepare(ctx);
436
+ }
437
+ catch (cause) {
438
+ // Hand-written messages, per AGENTS.md's reader-diagnostics rule.
439
+ // Reachable here: prepareError(), from this module's owned() wrappers,
440
+ // its two manifest-version checks, asResolutionKind, and the
441
+ // additional-validator branch's own throw on a legacy-validator launch
442
+ // failure (its cause is the shared runner's (src/validator.ts) captured
443
+ // spawn error, which oneLine never reads -- it takes .message only);
444
+ // readManifest's three hookError messages
445
+ // (`src/hooks.ts:113-138::readManifest`), pinned by
446
+ // `tests/unit/hooks.test.js:108::void test("readManifest diagnostics` as carrying no reader vocabulary or
447
+ // errno; and SafetyErrors from gitSafeSource, writeProvenance, and
448
+ // withWorkspace.
449
+ //
450
+ // FOUR exceptions, all inherited and none a regression:
451
+ // 1. resolveRef splices git's combined stdout+stderr into its own text
452
+ // on the NON-PINNED path (src/upstream.ts), reached via
453
+ // computeEffectiveSelection (src/effective-selection.ts).
454
+ // This is the DEFAULT invocation -- plain `prepare`, `track-latest`,
455
+ // and any non-40-hex SUPERPOWERS_REF -- not an exotic corner. Pinned
456
+ // by
457
+ // `tests/unit/upstream.test.js:460-469::void test("resolveRef reports a query failure for latest`,
458
+ // :471-481, and :483-501.
459
+ // 2. fetchExactCommit splices the same combined stdout+stderr into its
460
+ // own text on the PINNED path (both of its own splice sites in
461
+ // src/upstream.ts, and proveCommit's, which it calls). This is the
462
+ // rarer of the two raw-git-output paths, not the only one.
463
+ // 3. `src/selection-store.ts:120-124::cause.module === "selection") {`
464
+ // (same shape at :49, :86, :98) is the
465
+ // module AGENTS.md's `src/selection-store.ts` bullet grandfathers:
466
+ // it interpolates the caught error's own message, so Node errno
467
+ // prose can reach this stream -- sanctioned, nothing here needs
468
+ // fixing.
469
+ // 4. Every runGit call site in this module (fetch, clone, checkout) can
470
+ // reject instead of resolving: `src/git.ts:47-52::reject(new SafetyError` wraps every string
471
+ // errno other than ENOENT in
472
+ // `new SafetyError("git", \`cannot run git: ${failure.message}\`)`,
473
+ // and that Node spawn-level message reaches ctx.stderr through this
474
+ // catch. So the claim that the non-pinned clone/fetch/checkout
475
+ // branch names only the source is true for a non-zero *exit status*
476
+ // (handled explicitly below, in gatherPrepare) but false for a
477
+ // *spawn-level* failure -- runGit throws rather than returning a
478
+ // status in that case, and this outer catch is what stands between
479
+ // it and the stream.
480
+ //
481
+ // oneLine() at this catch collapses each of exceptions 1, 2, and 4 -- the
482
+ // three carrying git-derived text -- to a single line. It collapses CR/LF
483
+ // only, so it bounds how much of that text lands, not what it may
484
+ // contain.
485
+ //
486
+ // runAdapter's closing `throw cause` (src/adapter.ts) does NOT arrive
487
+ // here -- the call site catches it and converts it to a hand-written
488
+ // message per AGENTS.md's reader-diagnostics rule.
489
+ //
490
+ // gatherPrepare performs no writes of its own, so this catch cannot also be
491
+ // reached by an EPIPE from prepare's own output: every write below runs
492
+ // only after this try/catch has resolved.
493
+ ctx.stderr.write(`error: ${oneLine(cause)}\n`);
494
+ return 1;
495
+ }
496
+ const { outcome, cleanupWarning } = run;
497
+ for (const each of outcome.outcomes)
498
+ replayOutcome(each, ctx);
499
+ if (outcome.validator.stdout.length > 0) {
500
+ ctx.stdout.write(outcome.validator.stdout);
501
+ }
502
+ if (outcome.validator.stderr.length > 0) {
503
+ ctx.stderr.write(outcome.validator.stderr);
504
+ }
505
+ let status;
506
+ if (outcome.kind === "failed") {
507
+ if (outcome.message !== null) {
508
+ ctx.stderr.write(`error: ${outcome.message}\n`);
509
+ }
510
+ status = 1;
511
+ }
512
+ else {
513
+ ctx.stdout.write(`prepared ${outcome.resolvedRef} at ${outcome.commit}\n`);
514
+ status = 0;
515
+ }
516
+ if (cleanupWarning !== null) {
517
+ // A leaked workspace is reported even when the outcome above was itself a
518
+ // success: the generated-tree replacement that produced it already
519
+ // completed before cleanup ran, so it is not being reported as unverified
520
+ // -- but something did still go wrong, and AGENTS.md's fail-closed rule
521
+ // extends to it. Mirrors
522
+ // `src/commands/install.ts:503-510::if (cleanupWarning`.
523
+ ctx.stderr.write(`error: ${cleanupWarning}\n`);
524
+ return 1;
525
+ }
526
+ return status;
527
+ }