superpowers-manager 0.2.0 → 0.4.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 (68) hide show
  1. package/README.md +340 -66
  2. package/dist/adapter-result.js +204 -0
  3. package/dist/adapter.js +690 -0
  4. package/dist/atomic.js +142 -0
  5. package/dist/cli-arguments.js +32 -0
  6. package/dist/cli.js +313 -0
  7. package/dist/codex-harness.js +271 -0
  8. package/dist/codex-json.js +89 -0
  9. package/dist/codex-prepare.js +199 -0
  10. package/dist/codex-presentation.js +259 -0
  11. package/dist/codex-state.js +78 -0
  12. package/dist/commands/context.js +1 -0
  13. package/dist/commands/install.js +291 -0
  14. package/dist/commands/pin.js +79 -0
  15. package/dist/commands/prepare.js +420 -0
  16. package/dist/commands/probe.js +257 -0
  17. package/dist/commands/track-latest.js +44 -0
  18. package/dist/commands/uninstall.js +217 -0
  19. package/dist/commands/unpin.js +95 -0
  20. package/dist/commands/update.js +79 -0
  21. package/dist/domain/fingerprint.js +4 -0
  22. package/dist/domain/refs.js +37 -0
  23. package/dist/effective-selection.js +123 -0
  24. package/dist/generated-plugin.js +748 -0
  25. package/dist/git.js +38 -0
  26. package/dist/harness.js +1 -0
  27. package/dist/hooks.js +290 -0
  28. package/dist/lifecycle.js +135 -0
  29. package/dist/manifest-overlay.js +125 -0
  30. package/dist/provenance.js +111 -0
  31. package/dist/python-json-format.js +83 -0
  32. package/dist/python-json.js +29 -0
  33. package/dist/python-text.js +74 -0
  34. package/dist/safe-path.js +137 -0
  35. package/dist/safety-error.js +10 -0
  36. package/dist/selection-state-cli.js +85 -0
  37. package/dist/selection-store.js +172 -0
  38. package/dist/selection.js +200 -0
  39. package/dist/status.js +24 -0
  40. package/dist/strict-json.js +248 -0
  41. package/dist/upstream-cli.js +110 -0
  42. package/dist/upstream-version.js +40 -0
  43. package/dist/upstream.js +253 -0
  44. package/dist/validate-generated-plugin-cli.js +118 -0
  45. package/dist/validator.js +317 -0
  46. package/dist/workspace.js +139 -0
  47. package/package.json +25 -4
  48. package/bin/superpowers-manager.js +0 -290
  49. package/scripts/adapters/codex/adapter +0 -603
  50. package/scripts/adapters/codex/lib.sh +0 -449
  51. package/scripts/adapters/codex/validate-generated-plugin.py +0 -314
  52. package/scripts/core/adapter.sh +0 -103
  53. package/scripts/core/common.sh +0 -59
  54. package/scripts/core/lifecycle.sh +0 -141
  55. package/scripts/core/provenance.sh +0 -135
  56. package/scripts/core/selection-state.py +0 -414
  57. package/scripts/core/selection.sh +0 -168
  58. package/scripts/core/status.sh +0 -27
  59. package/scripts/core/upstream.sh +0 -280
  60. package/scripts/core/validate-adapter-response.py +0 -284
  61. package/scripts/install +0 -59
  62. package/scripts/pin +0 -51
  63. package/scripts/prepare +0 -117
  64. package/scripts/probe +0 -82
  65. package/scripts/track-latest +0 -30
  66. package/scripts/uninstall +0 -35
  67. package/scripts/unpin +0 -41
  68. package/scripts/update +0 -34
@@ -0,0 +1,257 @@
1
+ import { assertFailureWritable, writeAdapterFailure, } from "../adapter-result.js";
2
+ import { oneLine } from "../cli-arguments.js";
3
+ import { computeEffectiveSelection } from "../effective-selection.js";
4
+ export const PROBE_USAGE = "error: usage: superpowers-manager probe [--porcelain]\n";
5
+ // Ports scripts/core/validate-adapter-response.py's replay (:235-238) and its
6
+ // error/hint block (:269-272). The shell ran that validator on EVERY adapter
7
+ // response, so adapter messages reached the operator on their declared streams
8
+ // in array order, and a controlled failure printed `error:` plus one `hint:`
9
+ // per hint. DIAG-ADAPTER-01 retains that contract
10
+ // (docs/baseline/protocol-disposition.md); dropping it here would be a
11
+ // silent diagnostics regression, not a simplification.
12
+ //
13
+ // Interpolating error.message and each hint is the sanctioned form (AGENTS.md):
14
+ // src/adapter.ts has 52 `fail()` sites and none interpolates a caught error's
15
+ // message, so the callee owns every failure reachable on this path. Those two
16
+ // writes now live in writeAdapterFailure (src/adapter-result.ts), which
17
+ // validates all three strings before the first of them reaches the stream.
18
+ //
19
+ // This writes to ctx, so it MUST NOT be called from inside gatherProbe's try
20
+ // -- see the ProbeOutcome note below.
21
+ export function replayOutcome(outcome, ctx) {
22
+ // Hoisted ABOVE the message loop, and that ordering is the point: a failure
23
+ // whose code, message, or a hint carries a terminal control character must
24
+ // leave both streams untouched, not the context lines followed by nothing.
25
+ // Returns immediately on a succeeding outcome.
26
+ assertFailureWritable(outcome);
27
+ for (const message of outcome.messages) {
28
+ const stream = message.channel === "stdout" ? ctx.stdout : ctx.stderr;
29
+ stream.write(`${message.text}\n`);
30
+ }
31
+ // The message loop stays unguarded on purpose: D8b scopes the check to code,
32
+ // message, and hints, and AdapterMessageLog escapes message text before it is
33
+ // stored, so scanning it here would re-check a population that cannot fail.
34
+ writeAdapterFailure(ctx, outcome);
35
+ }
36
+ // `runAdapter` reports a CONTROLLED failure by RETURN VALUE, not by throwing
37
+ // (`src/adapter-result.ts:32-35::export interface AdapterResult`). The shell got
38
+ // fail-closed behaviour for free: spw_invoke_adapter returned 1 and
39
+ // scripts/probe ran under `set -eu`.
40
+ // Omitting the status check here would read a failed inspection as absent
41
+ // evidence and report it as success.
42
+ //
43
+ // It does still THROW for a non-AdapterFailure cause (runAdapter's closing
44
+ // `throw cause`, src/adapter.ts). That is caught here rather than in runProbe's
45
+ // outer catch, because the two need different diagnostics -- see spec §3.3a.
46
+ //
47
+ // Reached through ctx.adapter, not a direct module-level dependency on the
48
+ // adapter module: src/commands/prepare.ts and this module are the two the
49
+ // injected double must observe, because install reaches the adapter through
50
+ // gatherProbe and runPrepare. Spec §4.5.
51
+ async function inspect(call, unexpected, invalidStatus) {
52
+ let result;
53
+ try {
54
+ result = await call();
55
+ }
56
+ catch {
57
+ return { ok: false, result: null, message: unexpected };
58
+ }
59
+ const outcome = result.outcome;
60
+ if (result.status !== 0 || !outcome.ok) {
61
+ // The `outcome.ok && status !== 0` combination cannot arise from
62
+ // successResult/failureResult, so it gets its own hand-written message
63
+ // rather than falling through to a replay that would print nothing.
64
+ return {
65
+ ok: false,
66
+ result,
67
+ message: outcome.ok ? invalidStatus : null,
68
+ };
69
+ }
70
+ return { ok: true, result: { status: result.status, outcome } };
71
+ }
72
+ // Runs every step that can throw or fail closed, returning the outcome as
73
+ // data. It performs no writes of its own, so nothing inside runProbe's try can
74
+ // raise EPIPE — the same shape as src/commands/unpin.ts's attemptUnpin.
75
+ //
76
+ // The outcomes are CARRIED OUT rather than replayed in place, for that same
77
+ // reason: replayOutcome writes to ctx, and a write inside this try could
78
+ // raise EPIPE and be caught and relabelled as a selection failure. runProbe
79
+ // replays them, in collection order, after the try/catch has resolved. The
80
+ // operator-visible result is identical -- probe emits nothing else until the
81
+ // very end -- and the EPIPE hazard never exists.
82
+ // Exported for src/commands/install.ts and src/commands/update.ts, which need
83
+ // probe's FACTS rather than its rendering. The historical
84
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/lifecycle.sh:39-41::spw_probe_field`
85
+ // implementation awk-parsed the porcelain back into fields; that round trip is
86
+ // gone.
87
+ export async function gatherProbe(ctx) {
88
+ // Order mirrors `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/probe:24-40::spw_compute_effective_selection` exactly.
89
+ const selection = await computeEffectiveSelection(ctx.root, ctx.env);
90
+ const outcomes = [];
91
+ const adapterContext = { root: ctx.root, env: ctx.env };
92
+ const collect = async (call, unexpected, invalidStatus) => {
93
+ const result = await inspect(call, unexpected, invalidStatus);
94
+ if (result.result !== null)
95
+ outcomes.push(result.result.outcome);
96
+ return result;
97
+ };
98
+ const prepared = await collect(() => ctx.adapter.inspectPrepared(selection, adapterContext), "cannot inspect prepared harness state", "adapter reported a failure status for prepared harness inspection");
99
+ if (!prepared.ok) {
100
+ return { status: 1, outcomes, message: prepared.message };
101
+ }
102
+ const failure = (site) => ctx.adapter.presentation.callFailure(site, adapterContext);
103
+ const installedFailure = failure("probe-installed");
104
+ const installed = await collect(() => ctx.adapter.inspectInstalled(selection, adapterContext), installedFailure.unexpected, installedFailure.invalidStatus);
105
+ if (!installed.ok) {
106
+ return { status: 1, outcomes, message: installed.message };
107
+ }
108
+ const ownershipFailure = failure("probe-ownership");
109
+ const ownership = await collect(() => ctx.adapter.inspectOwnership(adapterContext), ownershipFailure.unexpected, ownershipFailure.invalidStatus);
110
+ if (!ownership.ok) {
111
+ return { status: 1, outcomes, message: ownership.message };
112
+ }
113
+ const controlFailure = failure("probe-control");
114
+ const control = await collect(() => ctx.adapter.inspectUpdateControl(adapterContext), controlFailure.unexpected, controlFailure.invalidStatus);
115
+ if (!control.ok) {
116
+ return { status: 1, outcomes, message: control.message };
117
+ }
118
+ const preparedState = prepared.result.outcome.result;
119
+ const installedState = installed.result.outcome.result;
120
+ return {
121
+ status: 0,
122
+ outcomes,
123
+ facts: {
124
+ selection,
125
+ prepared: preparedState,
126
+ installed: installedState,
127
+ ownership: ownership.result.outcome.result,
128
+ control: control.result.outcome.result,
129
+ status: preparedState.kind !== "current"
130
+ ? "needs prepare"
131
+ : installedState.kind !== "current"
132
+ ? "needs install"
133
+ : "current",
134
+ },
135
+ };
136
+ }
137
+ export async function runProbe(argv, ctx) {
138
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/probe:42::porcelain` tested only `[ "${1:-}" = "--porcelain" ]`, so a typo'd
139
+ // flag silently produced human output. Rejecting it is a deliberate
140
+ // narrowing, recorded as a port-only entry in
141
+ // tests/migration-inventory/probe.md.
142
+ //
143
+ // This guard is NOT the production path. src/cli.ts's parseArgs rejects the
144
+ // same inputs first, before preflight, with the usage block the CLI's other
145
+ // usage errors carry — exactly the arrangement track-latest and unpin have,
146
+ // where the in-module check is an unreachable-from-CLI duplicate. It stays
147
+ // because runProbe is also called directly by tests and by any future
148
+ // in-process caller that has not been through parseArgs.
149
+ const porcelain = argv.length === 1 && argv[0] === "--porcelain";
150
+ if (!porcelain && argv.length !== 0) {
151
+ ctx.stderr.write(PROBE_USAGE);
152
+ return 2;
153
+ }
154
+ let outcome;
155
+ try {
156
+ outcome = await gatherProbe(ctx);
157
+ }
158
+ catch (cause) {
159
+ // Hand-written messages, per AGENTS.md's reader-diagnostics rule. Reachable
160
+ // here: the selectionErrors validateSource raises
161
+ // (`src/selection.ts:137::malformed`, :140, requireSingleLineString at :77),
162
+ // reached from
163
+ // computeEffectiveSelection's validateSource call
164
+ // (src/effective-selection.ts); the ones validateRecord raises
165
+ // (`src/selection.ts:198::integer`, :219, requireObject at :47, requireExactKeys at
166
+ // :64, validatePinnedRecord at :170-183), reached from
167
+ // `src/selection-store.ts:103::return validateRecord` on the read path; the
168
+ // selectionErrors requireAbsolute and selectionConfigDir
169
+ // (src/effective-selection.ts) raise
170
+ // for a non-absolute or missing config directory; readConfigRef's `cannot
171
+ // read packaged upstream ref <path>` (src/upstream.ts); and resolveRef's own
172
+ // two no-match diagnostics, `no stable semver tag found for latest-release`
173
+ // and `cannot resolve upstream ref: <ref>` (src/upstream.ts). normalizeSaved
174
+ // (`src/selection.ts:222-241::normalizeSaved`) throws nothing at all.
175
+ //
176
+ // THREE exceptions, all inherited and none a regression:
177
+ // 1. resolveRef splices git's combined stdout+stderr into its own text
178
+ // (its three `cannot query upstream ...` throws, src/upstream.ts),
179
+ // reached via computeEffectiveSelection's resolveRef call
180
+ // (src/effective-selection.ts). The packaged ref is `latest-release`,
181
+ // which resolveRef answers with an ls-remote, so an ordinary unpinned
182
+ // run reaches this rather than only an exotic corner -- though not
183
+ // every unpinned run does: resolveRef's COMMIT_INPUT_RE branch returns
184
+ // before any runGit call, so a 40-hex requested ref splices nothing.
185
+ // Pinned by
186
+ // `tests/unit/upstream.test.ts:453::void test("resolveRef reports a query failure for latest`,
187
+ // :471, and :483.
188
+ // 2. The
189
+ // `src/selection-store.ts:120-124::cause.module === "selection") {`
190
+ // site (same shape at :49, :86, :98) is the module AGENTS.md's
191
+ // `src/selection-store.ts` bullet grandfathers:
192
+ // it interpolates the caught error's own message, so Node errno
193
+ // prose can reach this stream. Reached on the READ path only, via
194
+ // loadSavedSelection (src/effective-selection.ts) ->
195
+ // readSelectionState
196
+ // (`src/selection-store.ts:149::export async function readSelectionState`).
197
+ // This module's four write-only interpolating sites -- ensureStateDirectory
198
+ // (:172), finalStateDiagnostic (:197), and the two in
199
+ // writeSelectionState's own catch (:225, :231) -- are all
200
+ // unreachable from probe, which never writes.
201
+ // 3. Every runGit call site inside resolveRef (src/upstream.ts) can
202
+ // reject instead of resolving. On the non-ENOENT arm of
203
+ // `src/git.ts:47-52::if (typeof`, runGit builds the message
204
+ // "cannot run git: " followed by the Node spawn error's own message
205
+ // (:51) and rejects with a SafetyError carrying it (:52), so that
206
+ // Node spawn-level text reaches ctx.stderr through this catch. The
207
+ // ENOENT arm (:50) is hand-written and carries nothing. A non-zero
208
+ // *exit status* is handled by exception 1 above; this is the
209
+ // *spawn-level* case, where runGit throws rather than returning a
210
+ // status.
211
+ //
212
+ // oneLine() at this catch collapses each of exceptions 1 and 3 -- the two
213
+ // carrying git-derived text -- to a single line. It collapses CR/LF only,
214
+ // so it bounds how much of that text lands, not what it may contain.
215
+ //
216
+ // fetchExactCommit is deliberately NOT in this list, unlike prepare's
217
+ // fetchExactCommit exception, which runPrepare's catch block documents.
218
+ // Its only callers are `src/upstream-cli.ts:83::await fetchExactCommit` and
219
+ // gatherPrepare's own call in src/commands/prepare.ts (git grep -n
220
+ // fetchExactCommit -- src/ is the check that keeps "only" true), so probe
221
+ // never reaches it and
222
+ // its splice sites cannot appear on this stream. Do not add it back by
223
+ // symmetry with prepare.
224
+ //
225
+ // generatedCommitOrEmpty is not in this list either: it cannot throw at
226
+ // all. It (src/provenance.ts) delegates to readGeneratedCommitLenient,
227
+ // which catches every failure and returns "".
228
+ //
229
+ // A non-AdapterFailure re-thrown by runAdapter's closing `throw cause`
230
+ // (src/adapter.ts) does NOT reach here: inspect() catches it and converts
231
+ // it to a hand-written message per AGENTS.md's reader-diagnostics rule -- a
232
+ // rethrown cause is exactly the failure src/adapter.ts declined to own, so
233
+ // its text must never reach this stream. See §3.3a.
234
+ //
235
+ // gatherProbe performs no writes of its own, so this catch cannot also be
236
+ // reached by an EPIPE from probe's own output — every write below runs
237
+ // only after this try/catch has resolved.
238
+ ctx.stderr.write(`error: ${oneLine(cause)}\n`);
239
+ return 1;
240
+ }
241
+ // Replay first, on both paths: the shell validator replayed every response's
242
+ // messages whether or not that response was a failure at
243
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/validate-adapter-response.py:268::replay(messages)`.
244
+ for (const each of outcome.outcomes)
245
+ replayOutcome(each, ctx);
246
+ if (outcome.status === 1) {
247
+ // null means replayOutcome already emitted the adapter's own `error:`
248
+ // and `hint:` lines for the failing outcome.
249
+ if (outcome.message !== null) {
250
+ ctx.stderr.write(`error: ${outcome.message}\n`);
251
+ }
252
+ return 1;
253
+ }
254
+ const rendered = ctx.adapter.presentation.renderProbe(outcome.facts);
255
+ ctx.stdout.write(porcelain ? rendered.porcelain : rendered.human);
256
+ return 0;
257
+ }
@@ -0,0 +1,44 @@
1
+ import { oneLine } from "../cli-arguments.js";
2
+ import { selectionStatePath, loadSavedSelection, UPSTREAM_URL_DEFAULT, } from "../effective-selection.js";
3
+ import { validateSource } from "../selection.js";
4
+ import { writeSelectionState } from "../selection-store.js";
5
+ export async function runTrackLatest(argv, ctx) {
6
+ if (argv.length !== 0) {
7
+ ctx.stderr.write("error: usage: superpowers-manager track-latest\n");
8
+ return 2;
9
+ }
10
+ try {
11
+ // Read first. This is a deliberate redundant boundary check, not the
12
+ // enforcing guard: writeSelectionState below already refuses to overwrite
13
+ // a corrupt existing record on its own (src/selection-store.ts's
14
+ // writeSelectionState calls readSelectionState on the same path before
15
+ // writing, under "Invalid existing state must block overwrite" —
16
+ // readSelectionState's own readOpenedRecord/parseRecordBytes is what
17
+ // validates an existing record, via validateRecord at
18
+ // `src/selection-store.ts:103::return validateRecord`). Calling loadSavedSelection here preserves
19
+ // `git show 349fe2ed405b371ec2de1347bb3fc50c6bc15dc4:scripts/track-latest:20-21::read`'s read-then-write shape, so this command
20
+ // stays fail-closed on its own terms rather than solely by depending on
21
+ // the store's internals.
22
+ await loadSavedSelection(ctx.env);
23
+ const source = ctx.env.SUPERPOWERS_UPSTREAM_URL || UPSTREAM_URL_DEFAULT;
24
+ validateSource(source);
25
+ await writeSelectionState(selectionStatePath(ctx.env), {
26
+ schema_version: 1,
27
+ mode: "track-latest",
28
+ source,
29
+ });
30
+ }
31
+ catch (cause) {
32
+ // Every throw reachable here is a hand-written SafetyError from
33
+ // loadSavedSelection, validateSource, selectionStatePath, or
34
+ // writeSelectionState — re-emitting a subordinate module's own
35
+ // diagnostic is the sanctioned form of interpolation (see AGENTS.md's
36
+ // diagnostics convention). The try ends here, before the stdout write
37
+ // below: an EPIPE from that write must never be caught and relabelled
38
+ // as one of those modules' own diagnostics.
39
+ ctx.stderr.write(`error: ${oneLine(cause)}\n`);
40
+ return 1;
41
+ }
42
+ ctx.stdout.write("saved upstream selection: latest stable release\n");
43
+ return 0;
44
+ }
@@ -0,0 +1,217 @@
1
+ // Ports scripts/uninstall. The shell sourced common.sh, provenance.sh,
2
+ // lifecycle.sh and adapter.sh; the predicates now live in src/lifecycle.ts
3
+ // and the adapter arrives through ctx.adapter.
4
+ import { tmpdir } from "node:os";
5
+ import { oneLine } from "../cli-arguments.js";
6
+ import { withWorkspace, workspaceRemovalFailure } from "../workspace.js";
7
+ import { replayOutcome } from "./probe.js";
8
+ async function invoke(call, failure, outcomes) {
9
+ let result;
10
+ try {
11
+ result = await call();
12
+ }
13
+ catch {
14
+ return { ok: false, message: failure.unexpected };
15
+ }
16
+ outcomes.push(result.outcome);
17
+ const outcome = result.outcome;
18
+ if (result.status !== 0 || !outcome.ok) {
19
+ return {
20
+ ok: false,
21
+ message: outcome.ok ? failure.invalidStatus : null,
22
+ };
23
+ }
24
+ return {
25
+ ok: true,
26
+ result: { status: result.status, outcome },
27
+ };
28
+ }
29
+ // withWorkspace throws for mkdtemp failure before the callback ever runs
30
+ // ("cannot create workspace",
31
+ // `src/workspace.ts:120::throw new SafetyError("workspace", "cannot create workspace"`).
32
+ // A bare re-throw would silently drop every outcome collected before that
33
+ // point -- a narrow
34
+ // DIAG-ADAPTER-01 regression the shell never had, since it replayed each
35
+ // adapter response as it went rather than batching replay to the end. This
36
+ // carries the outcomes collected so far alongside the original cause, so
37
+ // runUninstall's catch can still replay them before reporting the cause.
38
+ //
39
+ // The post-success cleanup failure no longer reaches here: onCleanupFailure
40
+ // below suppresses withWorkspace's throw for that case and records the
41
+ // warning as data, so the computed UninstallOutcome survives it. The
42
+ // outcome-carrying is still load-bearing for mkdtemp, and the shape stays
43
+ // identical to src/commands/install.ts's GatherFailure.
44
+ class GatherFailure extends Error {
45
+ inner;
46
+ outcomes;
47
+ constructor(inner, outcomes) {
48
+ super("uninstall gather failed");
49
+ this.inner = inner;
50
+ this.outcomes = outcomes;
51
+ }
52
+ }
53
+ // Every step that can throw or fail closed, returning the outcome as data and
54
+ // performing NO writes. Same shape as gatherProbe
55
+ // (`src/commands/probe.ts:114-117::readonly facts: ProbeSnapshot<R>;`) and for the same
56
+ // reason: a write inside this try could raise EPIPE, be caught here, and be
57
+ // relabelled as a domain failure.
58
+ async function gatherUninstall(ctx) {
59
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/uninstall:20-21::TMPDIR=` exported TMPDIR="$uninstall_workspace" so every
60
+ // child confined its temporary files to the tree the workspace trap
61
+ // removed. The AdapterContext passed to ctx.adapter below does the same.
62
+ const parent = ctx.env.TMPDIR ?? tmpdir();
63
+ // Declared OUTSIDE the withWorkspace callback (rather than inside, as an
64
+ // earlier draft had it) so the catch below -- which wraps the ENTIRE
65
+ // withWorkspace call, not just the callback -- can still see whatever was
66
+ // collected before a workspace throw. Both `mkdtemp` failure (nothing
67
+ // collected yet) and a post-success cleanup failure (everything the
68
+ // callback collected) reach this same array.
69
+ const outcomes = [];
70
+ let cleanupWarning = null;
71
+ try {
72
+ const outcome = await withWorkspace(parent, "superpowers-manager.uninstall.", async (workspace) => {
73
+ const env = { ...ctx.env, TMPDIR: workspace };
74
+ const failed = (message) => ({
75
+ status: 1,
76
+ outcomes,
77
+ message,
78
+ output: null,
79
+ });
80
+ const adapterContext = { root: ctx.root, env };
81
+ // Stage 1: inspect ownership, before removal.
82
+ const beforeFailure = ctx.adapter.presentation.callFailure("remove-ownership", adapterContext);
83
+ const before = await invoke(() => ctx.adapter.inspectOwnership(adapterContext), beforeFailure, outcomes);
84
+ if (!before.ok)
85
+ return failed(before.message);
86
+ // Stage 2: remove, passing the private input through untouched.
87
+ const removalInput = before.result.outcome.result.removalInput;
88
+ const removeFailure = ctx.adapter.presentation.callFailure("remove", adapterContext, removalInput);
89
+ const removed = await invoke(() => ctx.adapter.remove(removalInput, adapterContext), removeFailure, outcomes);
90
+ if (!removed.ok)
91
+ return failed(removed.message);
92
+ // Stage 3: inspect ownership AGAIN. This overwrites the first
93
+ // inspection (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/uninstall:29-30::spw_verify_uninstalled_resources`), so everything below reads the
94
+ // POST-uninstall state, not the pre-uninstall one read above.
95
+ const afterFailure = ctx.adapter.presentation.callFailure("post-remove", adapterContext);
96
+ const after = await invoke(() => ctx.adapter.inspectOwnership(adapterContext), afterFailure, outcomes);
97
+ if (!after.ok)
98
+ return failed(after.message);
99
+ const ownership = after.result.outcome.result;
100
+ if (ownership.removalVerification.kind === "blocked") {
101
+ return {
102
+ status: 1,
103
+ outcomes,
104
+ message: null,
105
+ output: ownership.removalVerification.output,
106
+ };
107
+ }
108
+ return {
109
+ status: 0,
110
+ outcomes,
111
+ output: ctx.adapter.presentation.renderRemovalCompletion(ownership),
112
+ };
113
+ }, {
114
+ // Suppresses withWorkspace's throw on a POST-SUCCESS cleanup failure,
115
+ // so the UninstallOutcome the callback already computed still comes
116
+ // back as `outcome` instead of being discarded. Runs synchronously,
117
+ // as the option requires (src/workspace.ts).
118
+ onCleanupFailure: (path) => {
119
+ cleanupWarning = workspaceRemovalFailure(path);
120
+ },
121
+ });
122
+ return { outcome, cleanupWarning };
123
+ }
124
+ catch (cause) {
125
+ // Reachable only for mkdtemp failure, with nothing collected yet: this
126
+ // callback never throws -- every ctx.adapter throw is already caught
127
+ // inside invoke(), and presenceFlag/verifyUninstalledResources/
128
+ // reportLegacyState are pure (src/lifecycle.ts's header comment) -- so a
129
+ // post-success cleanup failure is handled by onCleanupFailure above and
130
+ // cannot reach here. Wrapping with `outcomes` anyway keeps the class
131
+ // total over its declared contract rather than assuming the callback's
132
+ // purity at the throw site.
133
+ throw new GatherFailure(cause, outcomes);
134
+ }
135
+ }
136
+ export async function runUninstall(argv, ctx) {
137
+ // scripts/uninstall never reads "$@", so extra arguments are silently
138
+ // ignored -- the same asymmetry runPrepare documents at its own
139
+ // `void argv;`.
140
+ void argv;
141
+ let run;
142
+ try {
143
+ run = await gatherUninstall(ctx);
144
+ }
145
+ catch (cause) {
146
+ // gatherUninstall throws exactly one shape: GatherFailure, wrapping
147
+ // withWorkspace's "cannot create workspace" SafetyError
148
+ // (`src/workspace.ts:120::throw new SafetyError("workspace", "cannot create workspace"`),
149
+ // alongside whatever outcomes were collected before that throw -- none,
150
+ // for that cause. Replaying first, before
151
+ // reporting the cause, keeps the arm honest for any outcome-bearing
152
+ // throw the class is declared to carry (DIAG-ADAPTER-01). The
153
+ // post-success cleanup failure no longer arrives here: gatherUninstall's
154
+ // onCleanupFailure records it as `cleanupWarning` and the computed
155
+ // outcome survives, which is what `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/uninstall:34-35::complete` did. The
156
+ // `instanceof` guard is defensive rather than load-bearing --
157
+ // gatherUninstall's own catch is the only thing that can throw here, and
158
+ // it always wraps -- but this catch does not assume that invariant
159
+ // blindly.
160
+ //
161
+ // A cause outside ctx.adapter's AdapterFailure guard
162
+ // (`src/adapter.ts:973-999::async function runCodexOperation(`) does NOT
163
+ // reach here: invoke() catches it inside gatherUninstall and converts it
164
+ // to a hand-written message carried as UninstallOutcome data, exactly as
165
+ // src/commands/probe.ts's inspect() does for the same cause.
166
+ //
167
+ // gatherUninstall performs no writes of its own, so this catch cannot
168
+ // also be reached by an EPIPE from uninstall's own output -- every write
169
+ // below runs only after this try/catch has resolved.
170
+ const outcomes = cause instanceof GatherFailure ? cause.outcomes : [];
171
+ for (const outcome of outcomes)
172
+ replayOutcome(outcome, ctx);
173
+ const inner = cause instanceof GatherFailure ? cause.inner : cause;
174
+ ctx.stderr.write(`error: ${oneLine(inner)}\n`);
175
+ return 1;
176
+ }
177
+ const { outcome, cleanupWarning } = run;
178
+ // Replay first, on both paths: the shell validator replayed every
179
+ // response's messages whether or not that response was a failure
180
+ // (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/validate-adapter-response.py:268::replay(messages)`).
181
+ for (const each of outcome.outcomes)
182
+ replayOutcome(each, ctx);
183
+ let status;
184
+ if (outcome.status === 1) {
185
+ // null means replayOutcome already emitted the adapter's own error:
186
+ // and hint: lines for the failing outcome.
187
+ if (outcome.message !== null) {
188
+ ctx.stderr.write(`error: ${outcome.message}\n`);
189
+ }
190
+ if (outcome.output !== null) {
191
+ for (const line of outcome.output.stdout)
192
+ ctx.stdout.write(`${line}\n`);
193
+ for (const line of outcome.output.stderr)
194
+ ctx.stderr.write(`${line}\n`);
195
+ }
196
+ status = 1;
197
+ }
198
+ else {
199
+ for (const line of outcome.output.stdout)
200
+ ctx.stdout.write(`${line}\n`);
201
+ for (const line of outcome.output.stderr)
202
+ ctx.stderr.write(`${line}\n`);
203
+ status = 0;
204
+ }
205
+ if (cleanupWarning !== null) {
206
+ // Mirrors src/commands/install.ts's closing arm. A leaked workspace is
207
+ // reported even when the domain outcome above was a success: the
208
+ // uninstall and its verification already completed against the adapter
209
+ // before cleanup ran, so it is not being reported as unverified -- but
210
+ // something did still go wrong, and AGENTS.md's fail-closed rule extends
211
+ // to it. The operator keeps "uninstall complete", which is the one line
212
+ // telling them whether the removal they asked for happened.
213
+ ctx.stderr.write(`error: ${cleanupWarning}\n`);
214
+ return 1;
215
+ }
216
+ return status;
217
+ }
@@ -0,0 +1,95 @@
1
+ import { lstat, unlink } from "node:fs/promises";
2
+ import { oneLine } from "../cli-arguments.js";
3
+ import { selectionStatePath } from "../effective-selection.js";
4
+ import { selectionError } from "../selection.js";
5
+ import { readConfigRef } from "../upstream.js";
6
+ const NOT_REGULAR = "selection state path is not a regular file; remove it manually after inspecting";
7
+ // Runs every step that can throw or fail closed, returning the outcome as
8
+ // data instead of writing it — see UnpinOutcome above for why.
9
+ async function attemptUnpin(ctx) {
10
+ const statePath = selectionStatePath(ctx.env);
11
+ // The packaged fallback ignores an active SUPERPOWERS_REF: `git show 349fe2ed405b371ec2de1347bb3fc50c6bc15dc4:scripts/unpin:12::spw_config_ref`
12
+ // cleared it deliberately so the reported value is the packaged one.
13
+ const fallback = await readConfigRef(ctx.root, {
14
+ ...ctx.env,
15
+ SUPERPOWERS_REF: "",
16
+ });
17
+ // Only a validated ENOENT means "absent". Treating every errno as absence
18
+ // turns EACCES or ENOTDIR into `no saved upstream selection` and exit 0 —
19
+ // reporting unverifiable state as success, which the fail-closed rule forbids.
20
+ // The shell could not draw this distinction ([ -e ] cannot), so this is a
21
+ // deliberate narrowing beyond parity, recorded as a port-only inventory entry.
22
+ const inspect = async (path) => {
23
+ try {
24
+ return await lstat(path);
25
+ }
26
+ catch (cause) {
27
+ if (cause.code === "ENOENT")
28
+ return null;
29
+ throw selectionError(`cannot inspect selection state: ${path}`);
30
+ }
31
+ };
32
+ let removed = false;
33
+ const info = await inspect(statePath);
34
+ if (info !== null) {
35
+ if (!info.isFile()) {
36
+ return { status: 1, message: `${NOT_REGULAR}: ${statePath}` };
37
+ }
38
+ try {
39
+ await unlink(statePath);
40
+ }
41
+ catch {
42
+ return {
43
+ status: 1,
44
+ message: `cannot remove selection state: ${statePath}`,
45
+ };
46
+ }
47
+ removed = true;
48
+ }
49
+ // Same classification on the verification read: an EACCES here must not be
50
+ // read as "successfully absent".
51
+ if ((await inspect(statePath)) !== null) {
52
+ return {
53
+ status: 1,
54
+ message: `selection state remains after removal attempt: ${statePath}`,
55
+ };
56
+ }
57
+ return { status: 0, removed, fallback };
58
+ }
59
+ export async function runUnpin(argv, ctx) {
60
+ if (argv.length !== 0) {
61
+ ctx.stderr.write("error: usage: superpowers-manager unpin\n");
62
+ return 2;
63
+ }
64
+ let outcome;
65
+ try {
66
+ outcome = await attemptUnpin(ctx);
67
+ }
68
+ catch (cause) {
69
+ // Every throw reachable here is a hand-written SafetyError from
70
+ // selectionStatePath, readConfigRef, or inspect() inside attemptUnpin —
71
+ // re-emitting a subordinate module's own diagnostic is the sanctioned
72
+ // form of interpolation (see AGENTS.md's diagnostics convention).
73
+ // attemptUnpin performs no writes of its own (see UnpinOutcome above),
74
+ // so this catch cannot also be reached by an EPIPE from a write this
75
+ // function made itself — the write below runs only after this try/catch
76
+ // has already resolved.
77
+ ctx.stderr.write(`error: ${oneLine(cause)}\n`);
78
+ return 1;
79
+ }
80
+ if (outcome.status === 1) {
81
+ ctx.stderr.write(`error: ${outcome.message}\n`);
82
+ return 1;
83
+ }
84
+ const { removed, fallback } = outcome;
85
+ ctx.stdout.write(removed
86
+ ? `removed saved upstream selection; packaged fallback is ${fallback}\n`
87
+ : `no saved upstream selection; packaged fallback is ${fallback}\n`);
88
+ if (ctx.env.SUPERPOWERS_REF) {
89
+ ctx.stdout.write("note: active SUPERPOWERS_REF override remains effective\n");
90
+ }
91
+ if (ctx.env.SUPERPOWERS_UPSTREAM_URL) {
92
+ ctx.stdout.write("note: active SUPERPOWERS_UPSTREAM_URL override remains effective\n");
93
+ }
94
+ return 0;
95
+ }