superpowers-manager 0.4.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.
@@ -0,0 +1,99 @@
1
+ import { lstat, readFile, realpath, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { codexInstalledPluginsFromJson, } from "./codex-json.js";
4
+ export const CODEX_MANAGER_PLUGIN_ID = "superpowers@superpowers-manager";
5
+ export const CODEX_LEGACY_PLUGIN_ID = "superpowers@superpowers-wrapper";
6
+ const SUPERPOWERS_PLUGIN_PREFIX = "superpowers@";
7
+ const DISPLAYABLE_PLUGIN_ID = /^superpowers@[A-Za-z0-9][A-Za-z0-9._-]*/;
8
+ const NATIVE_ROUTE = ".agents/skills/superpowers";
9
+ const NATIVE_ROUTE_CONFLICT = `native Codex skills route ~/${NATIVE_ROUTE} has indeterminate activity`;
10
+ function isMissingPath(cause) {
11
+ if (cause === null || typeof cause !== "object" || !("code" in cause)) {
12
+ return false;
13
+ }
14
+ return cause.code === "ENOENT" || cause.code === "ENOTDIR";
15
+ }
16
+ function isUnmanagedPluginId(pluginId) {
17
+ if (pluginId === CODEX_MANAGER_PLUGIN_ID ||
18
+ pluginId === CODEX_LEGACY_PLUGIN_ID) {
19
+ return false;
20
+ }
21
+ return (pluginId.startsWith(SUPERPOWERS_PLUGIN_PREFIX) &&
22
+ pluginId.length > SUPERPOWERS_PLUGIN_PREFIX.length);
23
+ }
24
+ function displayablePluginId(pluginId) {
25
+ return pluginId.length <= 256 &&
26
+ DISPLAYABLE_PLUGIN_ID.exec(pluginId)?.[0] === pluginId
27
+ ? pluginId
28
+ : "";
29
+ }
30
+ function unmanagedPluginConflict(plugin) {
31
+ if (!isUnmanagedPluginId(plugin.pluginId))
32
+ return "";
33
+ const displayId = displayablePluginId(plugin.pluginId);
34
+ const subject = displayId.length > 0
35
+ ? `Codex plugin ${displayId}`
36
+ : "Codex plugin with a non-displayable Superpowers identity";
37
+ if (plugin.installed === true && plugin.enabled === true) {
38
+ return `active ${subject}`;
39
+ }
40
+ if ((plugin.installed === true && plugin.enabled === false) ||
41
+ (plugin.installed === false && plugin.enabled === false)) {
42
+ return "";
43
+ }
44
+ return `${subject} has indeterminate activity`;
45
+ }
46
+ async function nativeRouteConflict(ctx) {
47
+ const home = ctx.env?.HOME;
48
+ if (home === undefined || home.length === 0)
49
+ return NATIVE_ROUTE_CONFLICT;
50
+ const route = join(home, ".agents", "skills", "superpowers");
51
+ const asset = join(route, "using-superpowers", "SKILL.md");
52
+ let routeIsSymlink;
53
+ try {
54
+ routeIsSymlink = (await lstat(route)).isSymbolicLink();
55
+ }
56
+ catch (cause) {
57
+ return isMissingPath(cause) ? "" : NATIVE_ROUTE_CONFLICT;
58
+ }
59
+ if (!routeIsSymlink) {
60
+ try {
61
+ await lstat(asset);
62
+ }
63
+ catch (cause) {
64
+ return isMissingPath(cause) ? "" : NATIVE_ROUTE_CONFLICT;
65
+ }
66
+ }
67
+ try {
68
+ const resolvedAsset = await realpath(asset);
69
+ if (!(await stat(resolvedAsset)).isFile())
70
+ return NATIVE_ROUTE_CONFLICT;
71
+ const contents = await readFile(resolvedAsset, "utf8");
72
+ const lines = contents.split(/\r?\n/u);
73
+ if (lines[0] !== "---")
74
+ return "";
75
+ const end = lines.indexOf("---", 1);
76
+ if (end < 0 ||
77
+ !lines
78
+ .slice(1, end)
79
+ .some((line) => /^name:[ \t]+using-superpowers[ \t]*$/u.test(line))) {
80
+ return "";
81
+ }
82
+ }
83
+ catch {
84
+ return NATIVE_ROUTE_CONFLICT;
85
+ }
86
+ return NATIVE_ROUTE_CONFLICT;
87
+ }
88
+ export async function inspectCodexConflicts(ctx, installedListing) {
89
+ const conflicts = new Set();
90
+ for (const plugin of codexInstalledPluginsFromJson(installedListing)) {
91
+ const conflict = unmanagedPluginConflict(plugin);
92
+ if (conflict.length > 0)
93
+ conflicts.add(conflict);
94
+ }
95
+ const nativeConflict = await nativeRouteConflict(ctx);
96
+ if (nativeConflict.length > 0)
97
+ conflicts.add(nativeConflict);
98
+ return [...conflicts];
99
+ }
@@ -1,8 +1,10 @@
1
+ import { join, resolve } from "node:path";
1
2
  import { failureResult, hasTerminalControl, successResult, } from "./adapter-result.js";
2
3
  import { codexInspect, codexInstall, codexRemove, } from "./adapter.js";
3
4
  import { codexPreparationLocation, inspectCodexPrepared, prepareCodexCandidate, readCodexPrepared, validateCodexPreparationBeforeFetch, } from "./codex-prepare.js";
4
5
  import { codexInstallReceipt, codexPresentation, } from "./codex-presentation.js";
5
6
  import { reportLegacyState, requireManagedUpdateControl, requireNoLegacyState, verifyUninstalledResources, } from "./lifecycle.js";
7
+ import { SafetyError } from "./safety-error.js";
6
8
  import { commitMatches } from "./status.js";
7
9
  function preserveFailure(result) {
8
10
  if (result.outcome.ok) {
@@ -40,6 +42,23 @@ function resourceFlag(result, key) {
40
42
  const value = bag[key];
41
43
  return typeof value === "boolean" ? value : null;
42
44
  }
45
+ function conflictDescriptions(result) {
46
+ const raw = result.conflicts;
47
+ if (raw === null || raw === undefined)
48
+ return [];
49
+ if (!Array.isArray(raw))
50
+ return null;
51
+ const descriptions = [];
52
+ for (const item of raw) {
53
+ if (typeof item !== "string" ||
54
+ item.length === 0 ||
55
+ hasTerminalControl(item)) {
56
+ return null;
57
+ }
58
+ descriptions.push(item);
59
+ }
60
+ return descriptions;
61
+ }
43
62
  function installDecision(legacy) {
44
63
  switch (legacy.kind) {
45
64
  case "ok":
@@ -87,7 +106,11 @@ export function normalizeCodexOwnership(result) {
87
106
  return malformed(result, "adapter returned a non-string identity_state for inspect --view ownership");
88
107
  }
89
108
  const identityState = identity.value;
90
- const installEligibility = identityState.length === 0
109
+ const conflicts = conflictDescriptions(record);
110
+ if (conflicts === null) {
111
+ return malformed(result, "expected an array of strings at conflicts");
112
+ }
113
+ const legacyEligibility = identityState.length === 0
91
114
  ? {
92
115
  kind: "blocked",
93
116
  output: {
@@ -96,6 +119,19 @@ export function normalizeCodexOwnership(result) {
96
119
  },
97
120
  }
98
121
  : installDecision(requireNoLegacyState(identityState));
122
+ const installEligibility = legacyEligibility.kind === "blocked" || conflicts.length === 0
123
+ ? legacyEligibility
124
+ : {
125
+ kind: "blocked",
126
+ output: {
127
+ stdout: [],
128
+ stderr: [
129
+ "Conflicting unmanaged Superpowers Codex resources require manual resolution:",
130
+ ...conflicts.map((conflict) => `- ${conflict}`),
131
+ "Remove or disable each resource manually, then retry.",
132
+ ],
133
+ },
134
+ };
99
135
  const verification = verifyUninstalledResources(result);
100
136
  const legacyReport = reportLegacyState(identityState);
101
137
  let removalVerification;
@@ -132,6 +168,7 @@ export function normalizeCodexOwnership(result) {
132
168
  removalVerification,
133
169
  postRemovalOutput,
134
170
  presentationValue: identityState,
171
+ presentationConflicts: conflicts,
135
172
  }, result.outcome.messages);
136
173
  }
137
174
  export function normalizeCodexControl(result) {
@@ -243,8 +280,21 @@ function requirements(command, env) {
243
280
  },
244
281
  ];
245
282
  }
283
+ async function mutationRoots(ctx) {
284
+ const env = ctx.env ?? {};
285
+ const configured = env.CODEX_HOME;
286
+ if (configured !== undefined && configured.length > 0) {
287
+ return [resolve(configured)];
288
+ }
289
+ const home = env.HOME;
290
+ if (home === undefined || home.length === 0) {
291
+ throw new SafetyError("codex-harness", "cannot determine Codex state root without HOME");
292
+ }
293
+ return [join(resolve(home), ".codex")];
294
+ }
246
295
  export const codexHarness = {
247
296
  preparationLocation: codexPreparationLocation,
297
+ mutationRoots,
248
298
  validatePreparationBeforeFetch: validateCodexPreparationBeforeFetch,
249
299
  prepareCandidate: prepareCodexCandidate,
250
300
  inspectPrepared: inspectCodexPrepared,
@@ -59,6 +59,13 @@ function hasTerminalControl(value) {
59
59
  export function installedListingHas(raw, arrayKey, field, value) {
60
60
  return checkedItems(raw, ACCEPT_CONSTANTS, arrayKey, field).some((item) => item[field] === value);
61
61
  }
62
+ export function codexInstalledPluginsFromJson(raw) {
63
+ return checkedItems(raw, ACCEPT_CONSTANTS, "installed", "pluginId").map((item) => ({
64
+ pluginId: item.pluginId,
65
+ installed: typeof item.installed === "boolean" ? item.installed : null,
66
+ enabled: typeof item.enabled === "boolean" ? item.enabled : null,
67
+ }));
68
+ }
62
69
  export function marketplaceRootFromJson(raw, marketplaceName) {
63
70
  const items = checkedItems(raw, ACCEPT_CONSTANTS, "marketplaces", "name");
64
71
  const match = items.find((item) => item.name === marketplaceName);
@@ -2,9 +2,11 @@ import { cp, mkdir, rm, stat } from "node:fs/promises";
2
2
  import { isAbsolute, join, resolve } from "node:path";
3
3
  import { failureResult, successResult, } from "./adapter-result.js";
4
4
  import { codexBuild } from "./adapter.js";
5
- import { commitMatches } from "./domain/fingerprint.js";
5
+ import { ARTIFACT_RECEIPT } from "./artifact-tree.js";
6
+ import { assessCodexCompatibility, readCodexAssessment, writeCodexAssessment, } from "./codex-compatibility.js";
6
7
  import { readManifest } from "./hooks.js";
7
- import { generatedCommitOrEmpty, generatedMetadataPath, readStrictProvenanceField, writeProvenance, } from "./provenance.js";
8
+ import { readGeneratedCommitLenient, readStrictProvenanceField, writeProvenance, } from "./provenance.js";
9
+ import { classifyPathNoFollow } from "./safe-path.js";
8
10
  import { SafetyError } from "./safety-error.js";
9
11
  import { manifestVersionForRef } from "./upstream-version.js";
10
12
  // Order is inherited from the original prepare command; the first miss wins.
@@ -27,6 +29,12 @@ const RESOLUTION_KINDS = [
27
29
  "ref",
28
30
  "raw-commit",
29
31
  ];
32
+ function unassessedCompatibility() {
33
+ return {
34
+ kind: "unknown",
35
+ reason: "Codex compatibility assessment is not available",
36
+ };
37
+ }
30
38
  function prepareError(message, cause) {
31
39
  return new SafetyError("prepare", message, { cause });
32
40
  }
@@ -166,26 +174,66 @@ export async function prepareCodexCandidate(input, ctx) {
166
174
  if (built.status !== 0) {
167
175
  return failureResult("build", "invalid-status", "adapter reported failure without an error outcome", [], built.outcome.messages);
168
176
  }
169
- return successResult(built.outcome.operation, { root: input.candidateRoot, commit: input.selection.desiredCommit }, built.outcome.messages);
177
+ const compatibility = await assessCodexCompatibility(input.upstreamRoot, input.selection);
178
+ if (compatibility.kind === "supported") {
179
+ try {
180
+ await writeCodexAssessment(input.candidateRoot, input.selection, (await regularFileExists(join(input.upstreamRoot, ".codex-plugin/plugin.json")))
181
+ ? "upstream"
182
+ : "fallback");
183
+ }
184
+ catch {
185
+ return failureResult("prepare", "assessment-failed", `cannot record Codex compatibility assessment: ${input.candidateRoot}`, [], built.outcome.messages);
186
+ }
187
+ }
188
+ return successResult(built.outcome.operation, {
189
+ root: input.candidateRoot,
190
+ commit: input.selection.desiredCommit,
191
+ compatibility,
192
+ identity: input.selection.desiredCommit,
193
+ }, built.outcome.messages);
170
194
  }
171
195
  export async function inspectCodexPrepared(selection, ctx) {
172
- const observedIdentity = await generatedCommitOrEmpty(ctx.root);
173
- if (!commitMatches(selection.desiredCommit, observedIdentity)) {
174
- return successResult("inspect-prepared", { kind: "needs-prepare", observedIdentity }, []);
196
+ const root = codexPreparationLocation(ctx).destinationRoot;
197
+ const observedIdentity = await readGeneratedCommitLenient(join(root, ".superpowers-upstream.json"));
198
+ const compatibility = unassessedCompatibility();
199
+ try {
200
+ if ((await classifyPathNoFollow(join(root, ARTIFACT_RECEIPT))) === "missing")
201
+ return successResult("inspect-prepared", {
202
+ kind: "needs-prepare",
203
+ observedIdentity,
204
+ compatibility,
205
+ }, []);
206
+ const artifact = await readCodexAssessment(root);
207
+ const source = await readStrictProvenanceField(join(artifact.root, ".superpowers-upstream.json"), "source");
208
+ if (artifact.commit === selection.desiredCommit &&
209
+ source === selection.effectiveSource) {
210
+ if (artifact.compatibility.kind === "supported")
211
+ return successResult("inspect-prepared", {
212
+ kind: "current",
213
+ artifact,
214
+ observedIdentity,
215
+ compatibility: artifact.compatibility,
216
+ }, []);
217
+ return successResult("inspect-prepared", {
218
+ kind: "needs-prepare",
219
+ observedIdentity,
220
+ compatibility: artifact.compatibility,
221
+ }, []);
222
+ }
223
+ }
224
+ catch {
225
+ return failureResult("inspect-prepared", "invalid-assessment", `cannot inspect Codex prepared artifact: ${root}`, [], []);
175
226
  }
176
227
  return successResult("inspect-prepared", {
177
- kind: "current",
178
- artifact: {
179
- root: join(ctx.root, "plugins", "superpowers"),
180
- commit: observedIdentity,
181
- },
228
+ kind: "needs-prepare",
182
229
  observedIdentity,
230
+ compatibility,
183
231
  }, []);
184
232
  }
185
233
  export async function readCodexPrepared(ctx) {
186
234
  let commit = "";
187
235
  try {
188
- const value = await readStrictProvenanceField(generatedMetadataPath(ctx.root), "commit");
236
+ const value = await readStrictProvenanceField(join(codexPreparationLocation(ctx).destinationRoot, ".superpowers-upstream.json"), "commit");
189
237
  if (typeof value === "string")
190
238
  commit = value;
191
239
  }
@@ -195,5 +243,13 @@ export async function readCodexPrepared(ctx) {
195
243
  if (commit.length === 0) {
196
244
  return failureResult("read-prepared", "missing-commit", "generated metadata missing desired commit after prepare", [], []);
197
245
  }
198
- return successResult("read-prepared", { root: join(ctx.root, "plugins", "superpowers"), commit }, []);
246
+ try {
247
+ const artifact = await readCodexAssessment(codexPreparationLocation(ctx).destinationRoot);
248
+ if (artifact.compatibility.kind !== "supported")
249
+ throw new Error("unsupported profile");
250
+ return successResult("read-prepared", artifact, []);
251
+ }
252
+ catch {
253
+ return failureResult("read-prepared", "invalid-assessment", "generated Codex compatibility assessment is missing or invalid", [], []);
254
+ }
199
255
  }
@@ -4,6 +4,7 @@ import { displaySource } from "./selection.js";
4
4
  // porcelain field order exposed through the harness presentation.
5
5
  export function fields(f) {
6
6
  return [
7
+ { key: "harness", label: "harness", value: f.harness },
7
8
  { key: "requested_ref", label: "requested ref", value: f.requestedRef },
8
9
  { key: "resolved_ref", label: "resolved ref", value: f.resolvedRef },
9
10
  { key: "desired_commit", label: "desired commit", value: f.desiredCommit },
@@ -58,6 +59,7 @@ export function fields(f) {
58
59
  ];
59
60
  }
60
61
  const NO_FACTS = {
62
+ harness: "codex",
61
63
  requestedRef: "",
62
64
  resolvedRef: "",
63
65
  desiredCommit: "",
@@ -129,6 +131,7 @@ function probeFacts(facts) {
129
131
  const selection = facts.selection;
130
132
  const saved = selection.saved;
131
133
  return {
134
+ harness: "codex",
132
135
  requestedRef: selection.requestedRef,
133
136
  resolvedRef: selection.resolvedRef,
134
137
  desiredCommit: selection.desiredCommit,
@@ -205,7 +208,27 @@ export const codexPresentation = {
205
208
  currentNotice: "manager is current",
206
209
  renderProbe(snapshot) {
207
210
  const facts = probeFacts(snapshot);
208
- return { human: formatHuman(facts), porcelain: formatPorcelain(facts) };
211
+ const conflicts = snapshot.ownership.presentationConflicts ?? [];
212
+ const additional = [
213
+ ["installation_state", snapshot.installed.kind],
214
+ ["resource_state", snapshot.resourceState ?? "idle"],
215
+ ["compatibility", snapshot.compatibility.kind],
216
+ ["compatibility_reason", snapshot.compatibility.reason],
217
+ ];
218
+ return {
219
+ human: formatHuman(facts) +
220
+ additional
221
+ .map(([key, value]) => `${key.replaceAll("_", " ")}: ${value}\n`)
222
+ .join("") +
223
+ conflicts
224
+ .map((conflict) => `ownership conflict: ${conflict}\n`)
225
+ .join(""),
226
+ porcelain: formatPorcelain(facts) +
227
+ additional.map(([key, value]) => `${key}=${value}\n`).join("") +
228
+ conflicts
229
+ .map((conflict) => `ownership_conflict=${conflict}\n`)
230
+ .join(""),
231
+ };
209
232
  },
210
233
  renderInstallVerification(desiredCommit, receipt, inspection) {
211
234
  if (inspection.status !== 0 || !inspection.outcome.ok) {
@@ -5,18 +5,38 @@
5
5
  import { tmpdir } from "node:os";
6
6
  import { oneLine } from "../cli-arguments.js";
7
7
  import { withWorkspace, workspaceRemovalFailure } from "../workspace.js";
8
- import { gatherProbe, replayOutcome } from "./probe.js";
8
+ import { gatherProbe, replayOutcome, validInstalled, validOutput, } from "./probe.js";
9
9
  import { runPrepare } from "./prepare.js";
10
+ import { runWithMutation } from "./mutation.js";
11
+ import { activationBlock } from "../harness-compatibility.js";
10
12
  function writeOutput(output, ctx) {
11
13
  for (const line of output.stdout)
12
14
  ctx.stdout.write(`${line}\n`);
13
15
  for (const line of output.stderr)
14
16
  ctx.stderr.write(`${line}\n`);
15
17
  }
16
- async function invoke(call, failure, outcomes) {
18
+ async function invoke(call, failure, outcomes, valid = () => true) {
17
19
  let result;
18
20
  try {
19
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
+ }
20
40
  }
21
41
  catch {
22
42
  return { ok: false, message: failure.unexpected, result: null };
@@ -35,6 +55,12 @@ async function invoke(call, failure, outcomes) {
35
55
  result: { status: result.status, outcome },
36
56
  };
37
57
  }
58
+ function validReceipt(value) {
59
+ return (value !== null &&
60
+ typeof value === "object" &&
61
+ validOutput(value.missingVerificationOutput) &&
62
+ validOutput(value.mismatchVerificationOutput));
63
+ }
38
64
  // withWorkspace can throw AFTER its callback has already returned a fully
39
65
  // computed StageOutcome: a post-success cleanup failure discards that return
40
66
  // value entirely and rejects instead, UNLESS an `onCleanupFailure` reporter is
@@ -103,10 +129,30 @@ async function gatherInstallStages(ctx, selection, artifact) {
103
129
  }
104
130
  // Stage 3: the mutation itself. Nothing above may have issued this --
105
131
  // that is the whole point of stages 1 and 2 running first.
132
+ const activationFailure = activationBlock(artifact.compatibility, ctx.options.allowExperimental);
133
+ if (activationFailure !== null)
134
+ return failed(activationFailure);
106
135
  const installFailure = ctx.adapter.presentation.callFailure("install", adapterContext);
107
- const install = await invoke(() => ctx.adapter.install(artifact, adapterContext), installFailure, outcomes);
136
+ const install = await invoke(() => ctx.adapter.install(artifact, adapterContext), installFailure, outcomes, validReceipt);
108
137
  if (!install.ok)
109
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
+ }
110
156
  // Stage 4: inspect fingerprint, to verify the mutation actually took.
111
157
  //
112
158
  // Deliberately NOT short-circuited on `!inspected.ok`, unlike stages
@@ -117,27 +163,71 @@ async function gatherInstallStages(ctx, selection, artifact) {
117
163
  // is what turns a failed inspection into "error: installed manager
118
164
  // fingerprint inspection failed after install."
119
165
  // renderInstallVerification's failed-inspection arm
120
- // (`src/codex-presentation.ts:306::if (inspection.status !== 0 || !inspection.outcome.ok) {`)
166
+ // (`src/codex-presentation.ts:332::if (inspection.status !== 0 || !inspection.outcome.ok) {`)
121
167
  // exists for this result-bearing path; the lifecycle compatibility
122
168
  // export delegates through the same arm. Returning
123
169
  // failed() instead reported the adapter's own generic diagnostic and
124
170
  // dropped the post-install verification claim -- a mutation had
125
171
  // already been issued at stage 3,
126
172
  // so "the install could not be verified" is the contract, not "an
127
- // adapter call failed". A ctx.adapter THROW is the one case with no
128
- // result to verify against, and keeps the short-circuit.
173
+ // adapter call failed". A ctx.adapter THROW has no result to render.
174
+ // A pending transaction still rolls back before reporting that failure.
129
175
  const inspectionFailure = ctx.adapter.presentation.callFailure("post-install", adapterContext);
130
- const inspected = await invoke(() => ctx.adapter.inspectInstalled(selection, adapterContext), inspectionFailure, outcomes);
131
- if (!inspected.ok && inspected.result === null) {
132
- return failed(inspected.message);
133
- }
176
+ const inspected = await invoke(() => ctx.adapter.inspectInstalled(selection, adapterContext), inspectionFailure, outcomes, validInstalled);
134
177
  const inspection = inspected.result;
135
- if (inspection === null)
136
- return failed(null);
137
- const output = ctx.adapter.presentation.renderInstallVerification(selection.desiredCommit, install.result, inspection);
138
- const verified = inspection.status === 0 &&
178
+ const verified = inspection !== null &&
179
+ inspection.status === 0 &&
139
180
  inspection.outcome.ok &&
140
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
+ }
227
+ }
228
+ if (inspection === null)
229
+ return failed(inspected.ok ? null : inspected.message);
230
+ const output = ctx.adapter.presentation.renderInstallVerification(selection.desiredCommit, install.result, inspection);
141
231
  return {
142
232
  kind: "verified",
143
233
  outcomes,
@@ -164,10 +254,14 @@ async function gatherInstallStages(ctx, selection, artifact) {
164
254
  }
165
255
  }
166
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) {
167
262
  // scripts/install never reads "$@", so extra arguments are silently
168
263
  // ignored -- the same asymmetry runPrepare and runUninstall document.
169
264
  void argv;
170
- ctx.stdout.write(`${ctx.adapter.presentation.installNotice}\n`);
171
265
  let probe;
172
266
  try {
173
267
  probe = await gatherProbe(ctx);
@@ -179,7 +273,7 @@ export async function runInstall(argv, ctx) {
179
273
  // runs only after this try/catch has resolved.
180
274
  //
181
275
  // This is a SECOND consumer of gatherProbe's throw channel --
182
- // `src/commands/probe.ts:251-309::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
183
277
  // runProbe catch is the first. Because both consumers wrap the identical
184
278
  // function, its long comment there enumerates exactly what can reach THIS
185
279
  // stream too, including the three foreign-text exceptions at :251-296:
@@ -215,6 +309,11 @@ export async function runInstall(argv, ctx) {
215
309
  writeOutput(facts.ownership.installEligibility.output, ctx);
216
310
  return 1;
217
311
  }
312
+ if (facts.resourceState === "recovery-required" &&
313
+ facts.control.probeEligibility.kind === "blocked") {
314
+ writeOutput(facts.control.probeEligibility.output, ctx);
315
+ return 1;
316
+ }
218
317
  switch (facts.status) {
219
318
  case "needs prepare": {
220
319
  const prepareStatus = await runPrepare([], ctx);
@@ -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
+ }