superpowers-manager 0.3.0 → 0.5.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 (41) hide show
  1. package/README.md +63 -325
  2. package/dist/adapter.js +91 -46
  3. package/dist/artifact-tree.js +110 -0
  4. package/dist/atomic.js +103 -7
  5. package/dist/cli-arguments.js +37 -0
  6. package/dist/cli.js +97 -40
  7. package/dist/codex-compatibility.js +126 -0
  8. package/dist/codex-conflicts.js +99 -0
  9. package/dist/codex-harness.js +321 -0
  10. package/dist/codex-json.js +7 -0
  11. package/dist/codex-prepare.js +255 -0
  12. package/dist/codex-presentation.js +282 -0
  13. package/dist/commands/install.js +187 -173
  14. package/dist/commands/mutation.js +71 -0
  15. package/dist/commands/prepare.js +87 -188
  16. package/dist/commands/probe.js +191 -176
  17. package/dist/commands/uninstall.js +52 -139
  18. package/dist/commands/update.js +33 -88
  19. package/dist/generated-plugin.js +3 -3
  20. package/dist/harness-compatibility.js +13 -0
  21. package/dist/harness.js +1 -0
  22. package/dist/lifecycle.js +1 -86
  23. package/dist/manifest-overlay.js +2 -2
  24. package/dist/pi-compatibility.js +73 -0
  25. package/dist/pi-harness.js +63 -0
  26. package/dist/pi-install.js +495 -0
  27. package/dist/pi-native.js +81 -0
  28. package/dist/pi-package.js +153 -0
  29. package/dist/pi-paths.js +30 -0
  30. package/dist/pi-prepare.js +111 -0
  31. package/dist/pi-presentation.js +67 -0
  32. package/dist/pi-settings.js +171 -0
  33. package/dist/pi-shared-skills.js +413 -0
  34. package/dist/pi-state.js +311 -0
  35. package/dist/resource-lock.js +265 -0
  36. package/dist/safe-path.js +21 -5
  37. package/dist/strict-json.js +1 -1
  38. package/dist/upstream-workspace.js +8 -0
  39. package/dist/validator.js +3 -2
  40. package/package.json +19 -18
  41. package/bin/superpowers-manager.js +0 -16
@@ -4,62 +4,62 @@
4
4
  // and the adapter arrives through ctx.adapter.
5
5
  import { tmpdir } from "node:os";
6
6
  import { oneLine } from "../cli-arguments.js";
7
- import { requireManagedUpdateControl, requireNoLegacyState, verifyInstalledFingerprint, } from "../lifecycle.js";
8
- import { generatedMetadataPath, readStrictProvenanceField, } from "../provenance.js";
9
7
  import { withWorkspace, workspaceRemovalFailure } from "../workspace.js";
10
- import { formatPorcelain, gatherProbe, replayOutcome, } from "./probe.js";
8
+ import { gatherProbe, replayOutcome, validInstalled, validOutput, } from "./probe.js";
11
9
  import { runPrepare } from "./prepare.js";
12
- // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:13::conflicting`, verbatim, and always first: the shell echoed it before
13
- // even invoking probe.
14
- const NOTE = "Note: remove or disable conflicting Superpowers providers yourself before" +
15
- " relying on manager skills.\n";
16
- function readStringField(result, key, label) {
17
- // Callers only reach this after invoke() has already proven
18
- // `result.status === 0 && result.outcome.ok`; the guard below is for
19
- // TypeScript's narrowing of `outcome.result`, not a live branch.
20
- const outcome = result.outcome;
21
- const parsed = outcome.ok
22
- ? outcome.result
23
- : null;
24
- const raw = parsed?.[key];
25
- if (raw === null || raw === undefined)
26
- return { kind: "ok", value: "" };
27
- if (typeof raw === "string")
28
- return { kind: "ok", value: raw };
29
- return {
30
- kind: "malformed",
31
- message: `adapter returned a non-string ${key} for ${label}`,
32
- };
10
+ import { runWithMutation } from "./mutation.js";
11
+ import { activationBlock } from "../harness-compatibility.js";
12
+ function writeOutput(output, ctx) {
13
+ for (const line of output.stdout)
14
+ ctx.stdout.write(`${line}\n`);
15
+ for (const line of output.stderr)
16
+ ctx.stderr.write(`${line}\n`);
33
17
  }
34
- async function invoke(ctx, env, argv, outcomes) {
18
+ async function invoke(call, failure, outcomes, valid = () => true) {
35
19
  let result;
36
20
  try {
37
- result = await ctx.adapter(argv, { root: ctx.root, env });
21
+ result = await call();
22
+ if (!result ||
23
+ !Number.isInteger(result.status) ||
24
+ !result.outcome ||
25
+ typeof result.outcome.ok !== "boolean" ||
26
+ typeof result.outcome.operation !== "string" ||
27
+ !Array.isArray(result.outcome.messages) ||
28
+ !result.outcome.messages.every((message) => message &&
29
+ (message.channel === "stdout" || message.channel === "stderr") &&
30
+ typeof message.text === "string") ||
31
+ (result.outcome.ok
32
+ ? result.outcome.error !== null || !valid(result.outcome.result)
33
+ : !result.outcome.error ||
34
+ typeof result.outcome.error.code !== "string" ||
35
+ typeof result.outcome.error.message !== "string" ||
36
+ !Array.isArray(result.outcome.error.hints) ||
37
+ !result.outcome.error.hints.every((hint) => typeof hint === "string"))) {
38
+ return { ok: false, message: failure.invalidStatus, result: null };
39
+ }
38
40
  }
39
41
  catch {
40
- return {
41
- ok: false,
42
- message: `cannot invoke Codex adapter for ${argv.join(" ")}`,
43
- result: null,
44
- };
42
+ return { ok: false, message: failure.unexpected, result: null };
45
43
  }
46
- // Clause 1: replay every outcome, on both the success and the failure
47
- // path, before any decision -- collected here and replayed by runInstall
48
- // once gatherInstallStages's try/catch has resolved, for the same EPIPE
49
- // reason gatherProbe carries its outcomes out rather than writing in place
50
- // (src/commands/probe.ts).
51
44
  outcomes.push(result.outcome);
52
45
  const outcome = result.outcome;
53
46
  if (result.status !== 0 || !outcome.ok) {
54
47
  return {
55
48
  ok: false,
56
- message: outcome.ok
57
- ? `adapter reported a failure status for ${argv.join(" ")}`
58
- : null,
49
+ message: outcome.ok ? failure.invalidStatus : null,
59
50
  result,
60
51
  };
61
52
  }
62
- return { ok: true, result };
53
+ return {
54
+ ok: true,
55
+ result: { status: result.status, outcome },
56
+ };
57
+ }
58
+ function validReceipt(value) {
59
+ return (value !== null &&
60
+ typeof value === "object" &&
61
+ validOutput(value.missingVerificationOutput) &&
62
+ validOutput(value.mismatchVerificationOutput));
63
63
  }
64
64
  // withWorkspace can throw AFTER its callback has already returned a fully
65
65
  // computed StageOutcome: a post-success cleanup failure discards that return
@@ -84,7 +84,7 @@ class GatherFailure extends Error {
84
84
  // created via spw_make_workspace + spw_install_workspace_trap. Performs no
85
85
  // writes of its own -- same EPIPE-avoidance shape as gatherProbe and
86
86
  // src/commands/uninstall.ts's gatherUninstall.
87
- async function gatherInstallStages(ctx, desiredCommit) {
87
+ async function gatherInstallStages(ctx, selection, artifact) {
88
88
  // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:38::tmp_parent=` -- ${TMPDIR:-/tmp}. Matches
89
89
  // src/commands/uninstall.ts's gatherUninstall.
90
90
  const parent = ctx.env.TMPDIR ?? tmpdir();
@@ -98,36 +98,61 @@ async function gatherInstallStages(ctx, desiredCommit) {
98
98
  outcomes,
99
99
  message,
100
100
  });
101
+ const adapterContext = { root: ctx.root, env };
101
102
  // Stage 1: inspect ownership, re-checked even though gatherProbe just
102
103
  // reported it. Mutation authority requires CURRENT, VALIDATED
103
104
  // evidence -- a probe's answer is neither by the time this runs.
104
- const ownership = await invoke(ctx, env, ["inspect", "--view", "ownership"], outcomes);
105
+ const ownershipFailure = ctx.adapter.presentation.callFailure("install-ownership", adapterContext);
106
+ const ownership = await invoke(() => ctx.adapter.inspectOwnership(adapterContext), ownershipFailure, outcomes);
105
107
  if (!ownership.ok)
106
108
  return failed(ownership.message);
107
- const identity = readStringField(ownership.result, "identity_state", "inspect --view ownership");
108
- if (identity.kind === "malformed")
109
- return failed(identity.message);
110
- const legacy = requireNoLegacyState(identity.value);
111
- if (legacy.kind === "blocked") {
112
- return { kind: "blocked", outcomes, lines: legacy.lines };
109
+ const ownershipDecision = ownership.result.outcome.result.installEligibility;
110
+ if (ownershipDecision.kind === "blocked") {
111
+ return {
112
+ kind: "blocked",
113
+ outcomes,
114
+ output: ownershipDecision.output,
115
+ };
113
116
  }
114
- if (legacy.kind === "unknown")
115
- return failed(legacy.message);
116
117
  // Stage 2: inspect update-control, re-checked for the same reason.
117
- const updateControl = await invoke(ctx, env, ["inspect", "--view", "update-control"], outcomes);
118
- if (!updateControl.ok)
119
- return failed(updateControl.message);
120
- const control = readStringField(updateControl.result, "update_control", "inspect --view update-control");
121
- if (control.kind === "malformed")
118
+ const controlFailure = ctx.adapter.presentation.callFailure("install-control", adapterContext);
119
+ const control = await invoke(() => ctx.adapter.inspectUpdateControl(adapterContext), controlFailure, outcomes);
120
+ if (!control.ok)
122
121
  return failed(control.message);
123
- const managed = requireManagedUpdateControl(control.value);
124
- if (!managed.ok)
125
- return failed(managed.message);
122
+ const mutationDecision = control.result.outcome.result.mutationEligibility;
123
+ if (mutationDecision.kind === "blocked") {
124
+ return {
125
+ kind: "blocked",
126
+ outcomes,
127
+ output: mutationDecision.output,
128
+ };
129
+ }
126
130
  // Stage 3: the mutation itself. Nothing above may have issued this --
127
131
  // that is the whole point of stages 1 and 2 running first.
128
- const install = await invoke(ctx, env, ["install", "--package-root", ctx.root], outcomes);
132
+ const activationFailure = activationBlock(artifact.compatibility, ctx.options.allowExperimental);
133
+ if (activationFailure !== null)
134
+ return failed(activationFailure);
135
+ const installFailure = ctx.adapter.presentation.callFailure("install", adapterContext);
136
+ const install = await invoke(() => ctx.adapter.install(artifact, adapterContext), installFailure, outcomes, validReceipt);
129
137
  if (!install.ok)
130
138
  return failed(install.message);
139
+ let transaction;
140
+ try {
141
+ const candidate = install.result.outcome.result.transaction;
142
+ if (candidate !== undefined) {
143
+ if (candidate === null || typeof candidate !== "object")
144
+ throw new Error("invalid transaction");
145
+ const finalize = candidate.finalize.bind(candidate);
146
+ const rollback = candidate.rollback.bind(candidate);
147
+ if (typeof finalize !== "function" ||
148
+ typeof rollback !== "function")
149
+ throw new Error("invalid transaction");
150
+ transaction = { finalize, rollback };
151
+ }
152
+ }
153
+ catch {
154
+ return failed("invalid installation transaction receipt; preserve recovery material for manual resolution");
155
+ }
131
156
  // Stage 4: inspect fingerprint, to verify the mutation actually took.
132
157
  //
133
158
  // Deliberately NOT short-circuited on `!inspected.ok`, unlike stages
@@ -137,32 +162,78 @@ async function gatherInstallStages(ctx, desiredCommit) {
137
162
  // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/lifecycle.sh:91-94::spw_inspect_fingerprint`,
138
163
  // is what turns a failed inspection into "error: installed manager
139
164
  // fingerprint inspection failed after install."
140
- // verifyInstalledFingerprint's "call-failed" arm
141
- // (`src/lifecycle.ts:158::if (inspected.kind === "call-failed") {`)
142
- // exists for exactly this and is reachable only from here. Returning
165
+ // renderInstallVerification's failed-inspection arm
166
+ // (`src/codex-presentation.ts:332::if (inspection.status !== 0 || !inspection.outcome.ok) {`)
167
+ // exists for this result-bearing path; the lifecycle compatibility
168
+ // export delegates through the same arm. Returning
143
169
  // failed() instead reported the adapter's own generic diagnostic and
144
170
  // dropped the post-install verification claim -- a mutation had
145
171
  // already been issued at stage 3,
146
172
  // so "the install could not be verified" is the contract, not "an
147
- // adapter call failed". A ctx.adapter THROW is the one case with no
148
- // result to verify against, and keeps the short-circuit.
149
- const inspected = await invoke(ctx, env, ["inspect", "--view", "fingerprint"], outcomes);
150
- let inspectResult;
151
- if (inspected.ok) {
152
- inspectResult = inspected.result;
173
+ // adapter call failed". A ctx.adapter THROW has no result to render.
174
+ // A pending transaction still rolls back before reporting that failure.
175
+ const inspectionFailure = ctx.adapter.presentation.callFailure("post-install", adapterContext);
176
+ const inspected = await invoke(() => ctx.adapter.inspectInstalled(selection, adapterContext), inspectionFailure, outcomes, validInstalled);
177
+ const inspection = inspected.result;
178
+ const verified = inspection !== null &&
179
+ inspection.status === 0 &&
180
+ inspection.outcome.ok &&
181
+ inspection.outcome.result.kind === "current";
182
+ if (transaction !== undefined) {
183
+ const operation = verified ? "finalize" : "rollback";
184
+ const settlement = await invoke(() => (verified ? transaction.finalize() : transaction.rollback()), {
185
+ unexpected: `installation ${operation} did not complete; preserve recovery material for manual resolution`,
186
+ invalidStatus: `installation ${operation} returned an invalid result; preserve recovery material for manual resolution`,
187
+ }, outcomes, (value) => value === null);
188
+ if (!verified) {
189
+ const restored = await invoke(() => ctx.adapter.inspectInstalled(selection, adapterContext), inspectionFailure, outcomes, validInstalled);
190
+ const restoration = restored.ok
191
+ ? `installed state after rollback: ${restored.result.outcome.result.kind}; identity=${restored.result.outcome.result.observedIdentity}`
192
+ : "installed state after rollback could not be inspected";
193
+ const message = !settlement.ok
194
+ ? settlement.message
195
+ : !inspected.ok
196
+ ? inspected.message
197
+ : "installation could not be verified";
198
+ return {
199
+ kind: "verified",
200
+ outcomes,
201
+ status: 1,
202
+ stdout: [],
203
+ stderr: [
204
+ ...(message === null ? [] : [`error: ${message}`]),
205
+ restoration,
206
+ ],
207
+ };
208
+ }
209
+ if (!settlement.ok) {
210
+ const actual = await invoke(() => ctx.adapter.inspectInstalled(selection, adapterContext), inspectionFailure, outcomes, validInstalled);
211
+ return {
212
+ kind: "verified",
213
+ outcomes,
214
+ status: 1,
215
+ stdout: [],
216
+ stderr: [
217
+ "error: installation was verified, but transaction cleanup did not complete; preserve recovery material for manual resolution",
218
+ actual.ok
219
+ ? `installed state after failed finalization: ${actual.result.outcome.result.kind}; identity=${actual.result.outcome.result.observedIdentity}`
220
+ : "installed state after failed finalization could not be inspected",
221
+ ...(settlement.message === null
222
+ ? []
223
+ : [`error: ${settlement.message}`]),
224
+ ],
225
+ };
226
+ }
153
227
  }
154
- else {
155
- if (inspected.result === null)
156
- return failed(inspected.message);
157
- inspectResult = inspected.result;
158
- }
159
- const verdict = verifyInstalledFingerprint(desiredCommit, install.result, inspectResult);
228
+ if (inspection === null)
229
+ return failed(inspected.ok ? null : inspected.message);
230
+ const output = ctx.adapter.presentation.renderInstallVerification(selection.desiredCommit, install.result, inspection);
160
231
  return {
161
232
  kind: "verified",
162
233
  outcomes,
163
- status: verdict.ok ? 0 : 1,
164
- stdout: verdict.stdout,
165
- stderr: verdict.stderr,
234
+ status: verified ? 0 : 1,
235
+ stdout: output.stdout,
236
+ stderr: output.stderr,
166
237
  };
167
238
  }, {
168
239
  // Suppresses withWorkspace's throw on a POST-SUCCESS cleanup failure,
@@ -182,44 +253,15 @@ async function gatherInstallStages(ctx, desiredCommit) {
182
253
  throw new GatherFailure(cause, outcomes);
183
254
  }
184
255
  }
185
- // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:29-32::printf` kept its shell `case ... *)` wildcard even though the
186
- // three inputs the shell's own probe could ever emit were bounded by the same
187
- // three-way status.sh logic this port's statusForCommits (src/status.ts) now
188
- // owns exactly. statusForCommits can only ever return "needs prepare",
189
- // "needs install" or "current" -- so this branch is NOT reachable through
190
- // runInstall's own call to gatherProbe today. It stays anyway, because
191
- // ProbeFacts.status is typed `string`, not that three-literal union, and a
192
- // future caller that builds facts by hand (as this module's own unit test
193
- // does, directly) must still see it fail closed rather than silently
194
- // proceed.
195
- //
196
- // Exported specifically so a direct test can reach it. That is ONE STEP
197
- // FURTHER than
198
- // `src/commands/probe.ts:367-372::This guard is NOT the production path.`'s own
199
- // precedent: that comment licenses RETAINING an unreachable branch inside an
200
- // already-public function (runProbe was public before that comment existed),
201
- // not EXPORTING a new one. This module does the latter, deliberately, because
202
- // runInstall
203
- // itself has no way to construct the unreachable input. Not part of the
204
- // interface Task 5 or Task 8 consume.
205
- //
206
- // A test against this export proves the two writes below are correct for a
207
- // given `facts`. It does NOT prove that runInstall's own `else if` branch
208
- // actually reaches and calls this function, or that runInstall returns 1
209
- // afterward -- those are properties of the call site, not of this function,
210
- // and need their own coverage there.
211
- export function renderUnknownProbeStatus(facts, ctx) {
212
- // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:30-31::printf` -- the porcelain reaches the terminal ONLY here.
213
- // Every other path swallows it, matching `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:18::porcelain`'s
214
- // probe_output=$(...) capture.
215
- ctx.stdout.write(formatPorcelain(facts));
216
- ctx.stderr.write(`error: unknown probe status: ${facts.status}\n`);
217
- }
218
256
  export async function runInstall(argv, ctx) {
257
+ if (ctx.adapter.presentation.installNotice !== "")
258
+ ctx.stdout.write(`${ctx.adapter.presentation.installNotice}\n`);
259
+ return await runWithMutation("install", ctx, async (scoped) => performInstall(argv, scoped));
260
+ }
261
+ async function performInstall(argv, ctx) {
219
262
  // scripts/install never reads "$@", so extra arguments are silently
220
263
  // ignored -- the same asymmetry runPrepare and runUninstall document.
221
264
  void argv;
222
- ctx.stdout.write(NOTE);
223
265
  let probe;
224
266
  try {
225
267
  probe = await gatherProbe(ctx);
@@ -231,20 +273,20 @@ export async function runInstall(argv, ctx) {
231
273
  // runs only after this try/catch has resolved.
232
274
  //
233
275
  // This is a SECOND consumer of gatherProbe's throw channel --
234
- // `src/commands/probe.ts:399-433::THREE exceptions, all inherited and none a regression:`'s
276
+ // `src/commands/probe.ts:469-527::THREE exceptions, all inherited and none a regression:`'s
235
277
  // runProbe catch is the first. Because both consumers wrap the identical
236
278
  // function, its long comment there enumerates exactly what can reach THIS
237
- // stream too, including the three foreign-text exceptions at :399-433:
238
- // 1. :399-410 -- resolveRef splices git's own combined stdout+stderr
279
+ // stream too, including the three foreign-text exceptions at :251-296:
280
+ // 1. :251-262 -- resolveRef splices git's own combined stdout+stderr
239
281
  // into its text. Reached on probe's DEFAULT path, which that comment
240
282
  // defines as every invocation NOT resolving a saved pin: a 40-hex
241
283
  // ref returns a "raw-commit" resolution at
242
284
  // `src/upstream.ts:162-164::return { kind: "raw-commit"`
243
285
  // before any git call, so it reaches no splice at all.
244
- // 2. :411-423 -- src/selection-store.ts's read path interpolates the
286
+ // 2. :263-280 -- src/selection-store.ts's read path interpolates the
245
287
  // caught error's own message, so Node errno prose can appear.
246
288
  // AGENTS.md grandfathers that module's wording.
247
- // 3. :424-433 -- a SPAWN-level git failure, a different channel from
289
+ // 3. :281-296 -- a SPAWN-level git failure, a different channel from
248
290
  // exception 1's exit-status one: on the non-ENOENT arm of
249
291
  // `src/git.ts:47-52::if (typeof failure.code === "string") {`, runGit
250
292
  // rejects with "cannot run git: " followed by the Node spawn error's
@@ -263,72 +305,45 @@ export async function runInstall(argv, ctx) {
263
305
  return 1;
264
306
  }
265
307
  const facts = probe.facts;
266
- // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:19-20::identity_state=$(spw_probe_field`. Guards the PROBE-derived value only -- the
267
- // re-inspection at :198 reads through readStringField, which already has
268
- // its own "malformed" arm for a non-string value, and an empty string
269
- // there is legitimately absent (the JSON-null convention), not an error.
270
- // This check exists because a probe that reports no identity state at all
271
- // is a different failure than one that reports an unrecognised one:
272
- // requireNoLegacyState("") would otherwise reach its "unknown" arm and
273
- // print "unknown adapter identity state: " (empty-suffixed), which names a
274
- // symptom rather than the actual cause. Do NOT add a second copy of this
275
- // check at :198 -- it guards a different value with a different failure
276
- // mode.
277
- if (facts.identityState.length === 0) {
278
- ctx.stderr.write("error: probe did not report adapter identity state\n");
279
- return 1;
280
- }
281
- // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:20-21::report`, run BEFORE the workspace is ever created and before
282
- // any further adapter call -- a legacy identity is fatal on sight, not
283
- // something worth spending a mutation attempt on.
284
- const legacy = requireNoLegacyState(facts.identityState);
285
- if (legacy.kind === "blocked") {
286
- // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/lifecycle.sh:50-53::'Legacy superpowers-wrapper Codex state is`
287
- // is a single printf writing three bare lines to stderr, no `error: `
288
- // prefix; :54 is the `return 1` that follows it, reached without spw_die.
289
- for (const line of legacy.lines)
290
- ctx.stderr.write(`${line}\n`);
308
+ if (facts.ownership.installEligibility.kind === "blocked") {
309
+ writeOutput(facts.ownership.installEligibility.output, ctx);
291
310
  return 1;
292
311
  }
293
- if (legacy.kind === "unknown") {
294
- ctx.stderr.write(`error: ${legacy.message}\n`);
312
+ if (facts.resourceState === "recovery-required" &&
313
+ facts.control.probeEligibility.kind === "blocked") {
314
+ writeOutput(facts.control.probeEligibility.output, ctx);
295
315
  return 1;
296
316
  }
297
- // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:22-33::status=`.
298
- if (facts.status === "needs prepare") {
299
- // Called as a FUNCTION: a failure propagates as a status, never through
300
- // `set -eu`. runPrepare has already replayed its own outcomes and
301
- // written its own diagnostics by the time it returns, so nothing further
302
- // is written here on that path.
303
- const prepareStatus = await runPrepare([], ctx);
304
- if (prepareStatus !== 0)
305
- return prepareStatus;
306
- }
307
- else if (facts.status !== "needs install" && facts.status !== "current") {
308
- renderUnknownProbeStatus(facts, ctx);
309
- return 1;
317
+ switch (facts.status) {
318
+ case "needs prepare": {
319
+ const prepareStatus = await runPrepare([], ctx);
320
+ if (prepareStatus !== 0)
321
+ return prepareStatus;
322
+ break;
323
+ }
324
+ case "needs install":
325
+ break;
326
+ case "current":
327
+ break;
310
328
  }
311
- // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:35-36::spw_metadata_commit_or_empty`. The STRICT reader, never the lenient
312
- // generatedCommitOrEmpty gatherProbe already used for facts.generatedCommit:
313
- // a throw, an absent key, or a non-string value are all treated as absent
314
- // here, matching spw_metadata_commit_or_empty's `|| true` plus the
315
- // following `[ -n ]` check.
316
- let desiredCommit = "";
329
+ let prepared;
317
330
  try {
318
- const value = await readStrictProvenanceField(generatedMetadataPath(ctx.root), "commit");
319
- if (typeof value === "string")
320
- desiredCommit = value;
331
+ prepared = await ctx.adapter.readPrepared({ root: ctx.root, env: ctx.env });
321
332
  }
322
333
  catch {
323
- // Absent, same as an undefined or non-string read.
334
+ ctx.stderr.write("error: cannot read prepared harness state\n");
335
+ return 1;
324
336
  }
325
- if (desiredCommit.length === 0) {
326
- ctx.stderr.write("error: generated metadata missing desired commit after prepare\n");
337
+ replayOutcome(prepared.outcome, ctx);
338
+ if (!prepared.outcome.ok)
339
+ return 1;
340
+ if (prepared.status !== 0) {
341
+ ctx.stderr.write("error: adapter reported a failure status for prepared harness inspection\n");
327
342
  return 1;
328
343
  }
329
344
  let stage;
330
345
  try {
331
- stage = await gatherInstallStages(ctx, desiredCommit);
346
+ stage = await gatherInstallStages(ctx, facts.selection, prepared.outcome.result);
332
347
  }
333
348
  catch (cause) {
334
349
  const outcomes = cause instanceof GatherFailure ? cause.outcomes : [];
@@ -343,8 +358,7 @@ export async function runInstall(argv, ctx) {
343
358
  replayOutcome(each, ctx);
344
359
  let status;
345
360
  if (outcome.kind === "blocked") {
346
- for (const line of outcome.lines)
347
- ctx.stderr.write(`${line}\n`);
361
+ writeOutput(outcome.output, ctx);
348
362
  status = 1;
349
363
  }
350
364
  else if (outcome.kind === "failed") {
@@ -355,8 +369,8 @@ export async function runInstall(argv, ctx) {
355
369
  }
356
370
  else {
357
371
  // :99-100 printed both lines BEFORE deciding, on every path that got this
358
- // far; verifyInstalledFingerprint already encodes that, so both arrays
359
- // are written in order regardless of verdict.ok.
372
+ // far; the verified outcome already carries both renderer arrays, so they
373
+ // are written in order regardless of its status.
360
374
  for (const line of outcome.stdout)
361
375
  ctx.stdout.write(`${line}\n`);
362
376
  for (const line of outcome.stderr)
@@ -0,0 +1,71 @@
1
+ import { computeEffectiveSelection } from "../effective-selection.js";
2
+ import { upstreamCacheRoot } from "../upstream-workspace.js";
3
+ import { SafetyError } from "../safety-error.js";
4
+ import { isAbsolute } from "node:path";
5
+ import { oneLine } from "../cli-arguments.js";
6
+ export async function withMutation(command, ctx, action) {
7
+ // Selection validation precedes every adapter call and lock creation. A
8
+ // nested mutation receives the already resolved value and cannot observe a
9
+ // saved-selection change midway through one invocation.
10
+ const selection = command === "uninstall"
11
+ ? ctx.selection
12
+ : (ctx.selection ?? (await computeEffectiveSelection(ctx.root, ctx.env)));
13
+ const scoped = selection === undefined ? ctx : { ...ctx, selection };
14
+ const adapterContext = { root: ctx.root, env: ctx.env };
15
+ let location;
16
+ try {
17
+ location = ctx.adapter.preparationLocation(adapterContext);
18
+ }
19
+ catch (cause) {
20
+ throw new SafetyError("mutation", "cannot determine preparation location", {
21
+ cause,
22
+ });
23
+ }
24
+ if (!isAbsolute(location.destinationRoot))
25
+ throw new SafetyError("mutation", "adapter returned a non-absolute preparation destination");
26
+ if (!location.stagingLeaf ||
27
+ location.stagingLeaf === "." ||
28
+ location.stagingLeaf === ".." ||
29
+ location.stagingLeaf.includes("/") ||
30
+ location.stagingLeaf.includes("\\"))
31
+ throw new SafetyError("mutation", "adapter returned an invalid preparation staging leaf");
32
+ let mutationRoots;
33
+ try {
34
+ mutationRoots = await ctx.adapter.mutationRoots(adapterContext);
35
+ if (!Array.isArray(mutationRoots) ||
36
+ mutationRoots.some((root) => typeof root !== "string" || !isAbsolute(root)))
37
+ throw new Error("invalid roots");
38
+ }
39
+ catch (cause) {
40
+ throw new SafetyError("mutation", "cannot determine harness mutation resources", { cause });
41
+ }
42
+ const resources = [location.destinationRoot, ...mutationRoots];
43
+ if (command === "prepare") {
44
+ resources.push(upstreamCacheRoot(ctx.root, ctx.env, process.cwd()));
45
+ }
46
+ // The coordinator sorts each acquisition and refuses conflicts immediately.
47
+ // A nested prepare may add a cache lock that sorts before an outer target;
48
+ // it cannot deadlock because no acquisition waits, while already-held roots
49
+ // are reentrant for this invocation.
50
+ return await ctx.coordination.withResources(resources, async () => action(scoped));
51
+ }
52
+ export async function runWithMutation(command, ctx, action) {
53
+ let actionThrew = false;
54
+ try {
55
+ return await withMutation(command, ctx, async (scoped) => {
56
+ try {
57
+ return await action(scoped);
58
+ }
59
+ catch (cause) {
60
+ actionThrew = true;
61
+ throw cause;
62
+ }
63
+ });
64
+ }
65
+ catch (cause) {
66
+ if (actionThrew)
67
+ throw cause;
68
+ ctx.stderr.write(`error: ${oneLine(cause)}\n`);
69
+ return 1;
70
+ }
71
+ }