superpowers-manager 0.1.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +184 -64
  2. package/bin/superpowers-manager.js +9 -237
  3. package/dist/adapter-result.js +204 -0
  4. package/dist/adapter.js +649 -0
  5. package/dist/atomic.js +142 -0
  6. package/dist/cli-arguments.js +32 -0
  7. package/dist/cli.js +293 -0
  8. package/dist/codex-json.js +89 -0
  9. package/dist/codex-state.js +78 -0
  10. package/dist/commands/context.js +1 -0
  11. package/dist/commands/install.js +376 -0
  12. package/dist/commands/pin.js +79 -0
  13. package/dist/commands/prepare.js +527 -0
  14. package/dist/commands/probe.js +392 -0
  15. package/dist/commands/track-latest.js +44 -0
  16. package/dist/commands/uninstall.js +308 -0
  17. package/dist/commands/unpin.js +95 -0
  18. package/dist/commands/update.js +138 -0
  19. package/dist/domain/fingerprint.js +4 -0
  20. package/dist/domain/refs.js +37 -0
  21. package/dist/effective-selection.js +123 -0
  22. package/dist/generated-plugin.js +748 -0
  23. package/dist/git.js +38 -0
  24. package/dist/hooks.js +290 -0
  25. package/dist/lifecycle.js +220 -0
  26. package/dist/manifest-overlay.js +125 -0
  27. package/dist/provenance.js +111 -0
  28. package/dist/python-json-format.js +83 -0
  29. package/dist/python-json.js +29 -0
  30. package/dist/python-text.js +74 -0
  31. package/dist/safe-path.js +137 -0
  32. package/dist/safety-error.js +10 -0
  33. package/dist/selection-state-cli.js +85 -0
  34. package/dist/selection-store.js +172 -0
  35. package/dist/selection.js +200 -0
  36. package/dist/status.js +24 -0
  37. package/dist/strict-json.js +248 -0
  38. package/dist/upstream-cli.js +110 -0
  39. package/dist/upstream-version.js +40 -0
  40. package/dist/upstream.js +253 -0
  41. package/dist/validate-generated-plugin-cli.js +118 -0
  42. package/dist/validator.js +317 -0
  43. package/dist/workspace.js +139 -0
  44. package/package.json +25 -2
  45. package/scripts/adapters/codex/adapter +0 -598
  46. package/scripts/adapters/codex/lib.sh +0 -391
  47. package/scripts/adapters/codex/validate-generated-plugin.py +0 -314
  48. package/scripts/core/adapter.sh +0 -92
  49. package/scripts/core/common.sh +0 -35
  50. package/scripts/core/lifecycle.sh +0 -127
  51. package/scripts/core/provenance.sh +0 -135
  52. package/scripts/core/status.sh +0 -27
  53. package/scripts/core/upstream.sh +0 -146
  54. package/scripts/core/validate-adapter-response.py +0 -245
  55. package/scripts/install +0 -60
  56. package/scripts/prepare +0 -126
  57. package/scripts/probe +0 -61
  58. package/scripts/uninstall +0 -44
  59. package/scripts/update +0 -31
@@ -0,0 +1,308 @@
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 { reportLegacyState, verifyUninstalledResources } from "../lifecycle.js";
7
+ import { withWorkspace, workspaceRemovalFailure } from "../workspace.js";
8
+ import { replayOutcome } from "./probe.js";
9
+ function presenceFlag(result, key) {
10
+ const outcome = result.outcome;
11
+ if (result.status !== 0 || !outcome.ok) {
12
+ return { kind: "call-failed" };
13
+ }
14
+ const value = outcome.result;
15
+ // Mirrors src/lifecycle.ts's verifyUninstalledResources: a missing or
16
+ // non-object `resources` falls THROUGH to the Boolean check rather than
17
+ // getting its own message, for parity with
18
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/adapter.sh:70::expected`'s
19
+ // single "expected Boolean..." text on {}.
20
+ const resources = typeof value === "object" && value !== null && !Array.isArray(value)
21
+ ? value.resources
22
+ : undefined;
23
+ const bag = typeof resources === "object" &&
24
+ resources !== null &&
25
+ !Array.isArray(resources)
26
+ ? resources
27
+ : {};
28
+ const flag = bag[key];
29
+ if (typeof flag !== "boolean") {
30
+ return { kind: "malformed", key };
31
+ }
32
+ return { kind: "ok", value: flag };
33
+ }
34
+ async function invoke(ctx, env, argv, outcomes) {
35
+ let result;
36
+ try {
37
+ result = await ctx.adapter(argv, { root: ctx.root, env });
38
+ }
39
+ catch {
40
+ return {
41
+ ok: false,
42
+ message: `cannot invoke Codex adapter for ${argv.join(" ")}`,
43
+ };
44
+ }
45
+ // Clause 1: replay every outcome, on both the success and the failure
46
+ // path, before any decision -- collected here and replayed by the caller
47
+ // once gatherUninstall's try/catch has resolved, for the same EPIPE reason
48
+ // gatherProbe carries its outcomes out rather than writing in place
49
+ // (`src/commands/probe.ts:289::The outcomes are CARRIED OUT rather than replayed in place`).
50
+ outcomes.push(result.outcome);
51
+ const outcome = result.outcome;
52
+ if (result.status !== 0 || !outcome.ok) {
53
+ return {
54
+ ok: false,
55
+ message: outcome.ok
56
+ ? `adapter reported a failure status for ${argv.join(" ")}`
57
+ : null,
58
+ };
59
+ }
60
+ return { ok: true, result };
61
+ }
62
+ // withWorkspace throws for mkdtemp failure before the callback ever runs
63
+ // ("cannot create workspace",
64
+ // `src/workspace.ts:120::throw new SafetyError("workspace", "cannot create workspace"`).
65
+ // A bare re-throw would silently drop every outcome collected before that
66
+ // point -- a narrow
67
+ // DIAG-ADAPTER-01 regression the shell never had, since it replayed each
68
+ // adapter response as it went rather than batching replay to the end. This
69
+ // carries the outcomes collected so far alongside the original cause, so
70
+ // runUninstall's catch can still replay them before reporting the cause.
71
+ //
72
+ // The post-success cleanup failure no longer reaches here: onCleanupFailure
73
+ // below suppresses withWorkspace's throw for that case and records the
74
+ // warning as data, so the computed UninstallOutcome survives it. The
75
+ // outcome-carrying is still load-bearing for mkdtemp, and the shape stays
76
+ // identical to src/commands/install.ts's GatherFailure.
77
+ class GatherFailure extends Error {
78
+ inner;
79
+ outcomes;
80
+ constructor(inner, outcomes) {
81
+ super("uninstall gather failed");
82
+ this.inner = inner;
83
+ this.outcomes = outcomes;
84
+ }
85
+ }
86
+ // Every step that can throw or fail closed, returning the outcome as data and
87
+ // performing NO writes. Same shape as gatherProbe
88
+ // (`src/commands/probe.ts:280-281::readonly status: 0;`) and for the same
89
+ // reason: a write inside this try could raise EPIPE, be caught here, and be
90
+ // relabelled as a domain failure.
91
+ async function gatherUninstall(ctx) {
92
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/uninstall:20-21::TMPDIR=` exported TMPDIR="$uninstall_workspace" so every
93
+ // child confined its temporary files to the tree the workspace trap
94
+ // removed. The AdapterContext passed to ctx.adapter below does the same.
95
+ const parent = ctx.env.TMPDIR ?? tmpdir();
96
+ // Declared OUTSIDE the withWorkspace callback (rather than inside, as an
97
+ // earlier draft had it) so the catch below -- which wraps the ENTIRE
98
+ // withWorkspace call, not just the callback -- can still see whatever was
99
+ // collected before a workspace throw. Both `mkdtemp` failure (nothing
100
+ // collected yet) and a post-success cleanup failure (everything the
101
+ // callback collected) reach this same array.
102
+ const outcomes = [];
103
+ let cleanupWarning = null;
104
+ try {
105
+ const outcome = await withWorkspace(parent, "superpowers-manager.uninstall.", async (workspace) => {
106
+ const env = { ...ctx.env, TMPDIR: workspace };
107
+ const failed = (message) => ({
108
+ status: 1,
109
+ outcomes,
110
+ message,
111
+ });
112
+ // Stage 1: inspect ownership, before removal.
113
+ const first = await invoke(ctx, env, ["inspect", "--view", "ownership"], outcomes);
114
+ if (!first.ok)
115
+ return failed(first.message);
116
+ const pluginFlag = presenceFlag(first.result, "plugin");
117
+ if (pluginFlag.kind === "malformed") {
118
+ return failed(`expected a Boolean adapter result at resources.${pluginFlag.key}`);
119
+ }
120
+ if (pluginFlag.kind === "call-failed") {
121
+ // Unreachable via this call path: `first.ok` above already proved
122
+ // status === 0 && outcome.ok, the only inputs presenceFlag reads
123
+ // to decide this arm. Handled anyway so presenceFlag stays total
124
+ // and this switch stays exhaustive rather than assuming its caller
125
+ // always gates first.
126
+ return failed(null);
127
+ }
128
+ const marketplaceFlag = presenceFlag(first.result, "marketplace");
129
+ if (marketplaceFlag.kind === "malformed") {
130
+ return failed(`expected a Boolean adapter result at resources.${marketplaceFlag.key}`);
131
+ }
132
+ if (marketplaceFlag.kind === "call-failed") {
133
+ return failed(null);
134
+ }
135
+ // Stage 2: uninstall, with the two presence Booleans read above.
136
+ const uninstallStage = await invoke(ctx, env, [
137
+ "uninstall",
138
+ "--plugin-present",
139
+ String(pluginFlag.value),
140
+ "--marketplace-present",
141
+ String(marketplaceFlag.value),
142
+ ], outcomes);
143
+ if (!uninstallStage.ok)
144
+ return failed(uninstallStage.message);
145
+ // Stage 3: inspect ownership AGAIN. This overwrites the first
146
+ // inspection (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/uninstall:29-30::spw_verify_uninstalled_resources`), so everything below reads the
147
+ // POST-uninstall state, not the pre-uninstall one read above.
148
+ const second = await invoke(ctx, env, ["inspect", "--view", "ownership"], outcomes);
149
+ if (!second.ok)
150
+ return failed(second.message);
151
+ const verify = verifyUninstalledResources(second.result);
152
+ if (!verify.ok)
153
+ return failed(verify.message);
154
+ // identity_state comes from this SAME second inspection, matching
155
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/uninstall:31::spw_adapter_result_get`'s spw_adapter_result_get read of the
156
+ // overwritten inspect_result. `second.ok` already proved this call
157
+ // succeeded, so outcome.ok is true here; the explicit check below is
158
+ // for TypeScript's narrowing (a local variable, not a re-derivation
159
+ // of that fact) rather than a live branch.
160
+ const secondOutcome = second.result.outcome;
161
+ if (!secondOutcome.ok)
162
+ return failed(null);
163
+ // Mirrors the inspect null/non-string branches
164
+ // (`src/commands/probe.ts:256-263::if (value === null || value === undefined) {`)
165
+ // and the fingerprint read
166
+ // (`src/lifecycle.ts:181-194::const raw = inspected.value.fingerprint;`):
167
+ // a JSON null or a missing key defaults to "" (the Python reader's own
168
+ // convention for a JSON null, scripts/core/provenance.sh's
169
+ // spw_json_get), but a present, non-null, NON-STRING value is a
170
+ // distinct, fail-closed "malformed" case with its own text -- never
171
+ // silently stringified.
172
+ // (A previous draft of this comment claimed parity with the shell's
173
+ // stringify-and-compare behaviour at
174
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/provenance.sh:62::print(value`;
175
+ // that was wrong on inspection, and it cited these same two call sites
176
+ // as support even though both of them fail closed on a non-string value
177
+ // rather than stringifying it. AGENTS.md's
178
+ // fail-closed rule wins over shell parity here.)
179
+ const parsed = secondOutcome.result;
180
+ const identityRaw = parsed?.identity_state;
181
+ let identityState;
182
+ if (identityRaw === null || identityRaw === undefined) {
183
+ identityState = "";
184
+ }
185
+ else if (typeof identityRaw === "string") {
186
+ identityState = identityRaw;
187
+ }
188
+ else {
189
+ return failed("adapter returned a non-string identity_state for inspect --view ownership");
190
+ }
191
+ const verdict = reportLegacyState(identityState);
192
+ if (verdict.kind === "unknown")
193
+ return failed(verdict.message);
194
+ const lines = [];
195
+ // verdict.kind is "ok" or "report" here (reportLegacyState never
196
+ // returns "blocked" -- that arm belongs to requireNoLegacyState --
197
+ // but LegacyVerdict is one shared union, so this narrows rather than
198
+ // assumes).
199
+ if (verdict.kind !== "ok") {
200
+ lines.push(...verdict.lines);
201
+ }
202
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/uninstall:34-35::complete`. The first line ports verbatim; the second
203
+ // changes scripts/prepare to npx superpowers-manager prepare (spec
204
+ // §3.6) -- a deliberate, observable divergence, recorded as a
205
+ // port-only entry in tests/migration-inventory/uninstall-commands.md.
206
+ lines.push("uninstall complete");
207
+ lines.push("note: local generated artifacts under plugins/superpowers/ and " +
208
+ ".cache/upstream/ were left in place; remove them manually or " +
209
+ "regenerate with npx superpowers-manager prepare.");
210
+ return { status: 0, outcomes, lines };
211
+ }, {
212
+ // Suppresses withWorkspace's throw on a POST-SUCCESS cleanup failure,
213
+ // so the UninstallOutcome the callback already computed still comes
214
+ // back as `outcome` instead of being discarded. Runs synchronously,
215
+ // as the option requires (src/workspace.ts).
216
+ onCleanupFailure: (path) => {
217
+ cleanupWarning = workspaceRemovalFailure(path);
218
+ },
219
+ });
220
+ return { outcome, cleanupWarning };
221
+ }
222
+ catch (cause) {
223
+ // Reachable only for mkdtemp failure, with nothing collected yet: this
224
+ // callback never throws -- every ctx.adapter throw is already caught
225
+ // inside invoke(), and presenceFlag/verifyUninstalledResources/
226
+ // reportLegacyState are pure (src/lifecycle.ts's header comment) -- so a
227
+ // post-success cleanup failure is handled by onCleanupFailure above and
228
+ // cannot reach here. Wrapping with `outcomes` anyway keeps the class
229
+ // total over its declared contract rather than assuming the callback's
230
+ // purity at the throw site.
231
+ throw new GatherFailure(cause, outcomes);
232
+ }
233
+ }
234
+ export async function runUninstall(argv, ctx) {
235
+ // scripts/uninstall never reads "$@", so extra arguments are silently
236
+ // ignored -- the same asymmetry runPrepare documents at its own
237
+ // `void argv;`.
238
+ void argv;
239
+ let run;
240
+ try {
241
+ run = await gatherUninstall(ctx);
242
+ }
243
+ catch (cause) {
244
+ // gatherUninstall throws exactly one shape: GatherFailure, wrapping
245
+ // withWorkspace's "cannot create workspace" SafetyError
246
+ // (`src/workspace.ts:120::throw new SafetyError("workspace", "cannot create workspace"`),
247
+ // alongside whatever outcomes were collected before that throw -- none,
248
+ // for that cause. Replaying first, before
249
+ // reporting the cause, keeps the arm honest for any outcome-bearing
250
+ // throw the class is declared to carry (DIAG-ADAPTER-01). The
251
+ // post-success cleanup failure no longer arrives here: gatherUninstall's
252
+ // onCleanupFailure records it as `cleanupWarning` and the computed
253
+ // outcome survives, which is what `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/uninstall:34-35::complete` did. The
254
+ // `instanceof` guard is defensive rather than load-bearing --
255
+ // gatherUninstall's own catch is the only thing that can throw here, and
256
+ // it always wraps -- but this catch does not assume that invariant
257
+ // blindly.
258
+ //
259
+ // A cause outside ctx.adapter's AdapterFailure guard
260
+ // (`src/adapter.ts:1001::if (cause instanceof AdapterFailure) {`) does NOT
261
+ // reach here: invoke() catches it inside gatherUninstall and converts it
262
+ // to a hand-written message carried as UninstallOutcome data, exactly as
263
+ // src/commands/probe.ts's inspect() does for the same cause.
264
+ //
265
+ // gatherUninstall performs no writes of its own, so this catch cannot
266
+ // also be reached by an EPIPE from uninstall's own output -- every write
267
+ // below runs only after this try/catch has resolved.
268
+ const outcomes = cause instanceof GatherFailure ? cause.outcomes : [];
269
+ for (const outcome of outcomes)
270
+ replayOutcome(outcome, ctx);
271
+ const inner = cause instanceof GatherFailure ? cause.inner : cause;
272
+ ctx.stderr.write(`error: ${oneLine(inner)}\n`);
273
+ return 1;
274
+ }
275
+ const { outcome, cleanupWarning } = run;
276
+ // Replay first, on both paths: the shell validator replayed every
277
+ // response's messages whether or not that response was a failure
278
+ // (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/validate-adapter-response.py:268::replay(messages)`).
279
+ for (const each of outcome.outcomes)
280
+ replayOutcome(each, ctx);
281
+ let status;
282
+ if (outcome.status === 1) {
283
+ // null means replayOutcome already emitted the adapter's own error:
284
+ // and hint: lines for the failing outcome.
285
+ if (outcome.message !== null) {
286
+ ctx.stderr.write(`error: ${outcome.message}\n`);
287
+ }
288
+ status = 1;
289
+ }
290
+ else {
291
+ for (const line of outcome.lines) {
292
+ ctx.stdout.write(`${line}\n`);
293
+ }
294
+ status = 0;
295
+ }
296
+ if (cleanupWarning !== null) {
297
+ // Mirrors src/commands/install.ts's closing arm. A leaked workspace is
298
+ // reported even when the domain outcome above was a success: the
299
+ // uninstall and its verification already completed against the adapter
300
+ // before cleanup ran, so it is not being reported as unverified -- but
301
+ // something did still go wrong, and AGENTS.md's fail-closed rule extends
302
+ // to it. The operator keeps "uninstall complete", which is the one line
303
+ // telling them whether the removal they asked for happened.
304
+ ctx.stderr.write(`error: ${cleanupWarning}\n`);
305
+ return 1;
306
+ }
307
+ return status;
308
+ }
@@ -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
+ }
@@ -0,0 +1,138 @@
1
+ // Ports scripts/update. The shell sourced common.sh and lifecycle.sh; the
2
+ // predicates now live in src/lifecycle.ts. Unlike install and uninstall,
3
+ // update issues no ctx.adapter call of its own: every adapter interaction
4
+ // this module needs already happens inside gatherProbe, runPrepare or
5
+ // runInstall, each of which owns its own §4.2a-conformant invoke() gate. That
6
+ // is also why update opens no temporary workspace of its own -- there is no
7
+ // mutation here for one to protect.
8
+ import { oneLine } from "../cli-arguments.js";
9
+ import { requireManagedUpdateControl, requireNoLegacyState, } from "../lifecycle.js";
10
+ import { formatPorcelain, gatherProbe, replayOutcome, } from "./probe.js";
11
+ import { runInstall } from "./install.js";
12
+ import { runPrepare } from "./prepare.js";
13
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/update:29-32::echo "$probe_output`'s `case ... *)` wildcard. Same unreachability shape as
14
+ // src/commands/install.ts's renderUnknownProbeStatus: statusForCommits
15
+ // (src/status.ts) can only ever return "needs prepare", "needs
16
+ // install" or "current", so this branch is NOT reachable through runUpdate's
17
+ // own call to gatherProbe today. ProbeFacts.status is typed `string`, not
18
+ // that three-literal union, so a future caller that builds facts by hand (as
19
+ // this module's own unit test does, directly) must still see it fail closed
20
+ // rather than silently proceed.
21
+ //
22
+ // Exported specifically so a direct test can reach it -- not part of the
23
+ // interface Task 5 or Task 8 consume. A test against this export proves the
24
+ // two writes below are correct for a given `facts`; it does NOT prove that
25
+ // runUpdate's own final `else` branch actually reaches and calls this
26
+ // function, or that runUpdate returns 1 afterward -- those are properties of
27
+ // the call site, not of this function, and need their own coverage there.
28
+ export function renderUnknownProbeStatus(facts, ctx) {
29
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/update:30-31::echo "$probe_output` -- `echo "$probe_output"` (stdout), then the error
30
+ // to stderr via an explicit `>&2`. This is a DIFFERENT mechanism from
31
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:30-31::printf`'s `spw_die`, even though both end up writing the
32
+ // porcelain to stdout and an `error: `-prefixed line to stderr: install's
33
+ // error text flows through spw_die's own `error: $*` formatting, while
34
+ // update's is spelled out inline in the shell literal already. The two
35
+ // command modules therefore each need their own case pinning this arm --
36
+ // one must not be inferred from the other.
37
+ ctx.stdout.write(formatPorcelain(facts));
38
+ ctx.stderr.write(`error: unknown probe status: ${facts.status}\n`);
39
+ }
40
+ export async function runUpdate(argv, ctx) {
41
+ // scripts/update never reads "$@", so extra arguments are silently
42
+ // ignored -- the same asymmetry runPrepare and runInstall document.
43
+ void argv;
44
+ let probe;
45
+ try {
46
+ probe = await gatherProbe(ctx);
47
+ }
48
+ catch (cause) {
49
+ // gatherProbe performs no writes of its own (src/commands/probe.ts), so
50
+ // this catch cannot also be reached by an EPIPE from update's own
51
+ // output -- update writes nothing before this call resolves, unlike
52
+ // install's NOTE line.
53
+ //
54
+ // This is a THIRD consumer of gatherProbe's throw channel --
55
+ // `src/commands/probe.ts:381-433::THREE exceptions, all inherited and none a regression:`'s
56
+ // runProbe catch is the first and src/commands/install.ts's runInstall catch
57
+ // is the second. Because all three wrap the identical function, runProbe's
58
+ // long comment there enumerates exactly what can reach this stream too; not
59
+ // repeated here.
60
+ ctx.stderr.write(`error: ${oneLine(cause)}\n`);
61
+ return 1;
62
+ }
63
+ // Replay first, on both paths, before any decision -- clause 1.
64
+ for (const outcome of probe.outcomes)
65
+ replayOutcome(outcome, ctx);
66
+ if (probe.status === 1) {
67
+ if (probe.message !== null) {
68
+ ctx.stderr.write(`error: ${probe.message}\n`);
69
+ }
70
+ return 1;
71
+ }
72
+ const facts = probe.facts;
73
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/update:9-10::identity_state=`. Guards the PROBE-derived value only, matching
74
+ // src/commands/install.ts's identical guard at `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:19-20::identity_state=$(spw_probe_field`: a
75
+ // probe that reports no identity state at all is a different failure from
76
+ // one that reports an unrecognised one, and requireNoLegacyState("") would
77
+ // otherwise reach its "unknown" arm and print a symptom rather than the
78
+ // actual cause.
79
+ if (facts.identityState.length === 0) {
80
+ ctx.stderr.write("error: probe did not report adapter identity state\n");
81
+ return 1;
82
+ }
83
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/update:11::spw_require_no_legacy_state`.
84
+ const legacy = requireNoLegacyState(facts.identityState);
85
+ if (legacy.kind === "blocked") {
86
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/lifecycle.sh:50-53::'Legacy superpowers-wrapper Codex state is`
87
+ // is a single printf writing three bare lines to stderr, no `error: `
88
+ // prefix; :54 is the `return 1` that follows it, reached without spw_die.
89
+ for (const line of legacy.lines)
90
+ ctx.stderr.write(`${line}\n`);
91
+ return 1;
92
+ }
93
+ if (legacy.kind === "unknown") {
94
+ ctx.stderr.write(`error: ${legacy.message}\n`);
95
+ return 1;
96
+ }
97
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/update:13-14::update_control=`. install checks only identity_state
98
+ // (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:20::report`); update checks update_control too -- the first of
99
+ // §4.4's two corrections. Both emptiness checks run before the switch, not
100
+ // just one.
101
+ if (facts.updateControl.length === 0) {
102
+ ctx.stderr.write("error: probe did not report adapter update-control capability\n");
103
+ return 1;
104
+ }
105
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/update:16-34::case`, the four-way switch.
106
+ if (facts.status === "current") {
107
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/update:18::spw_require_managed_update_control` -- gated BEFORE printing anything. §4.4's second
108
+ // correction: an update that printed "manager is current" under an
109
+ // unsupported adapter would be asserting managed control it had not
110
+ // verified.
111
+ const managed = requireManagedUpdateControl(facts.updateControl);
112
+ if (!managed.ok) {
113
+ ctx.stderr.write(`error: ${managed.message}\n`);
114
+ return 1;
115
+ }
116
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/update:19-20::printf`. Unlike install, which never lets the porcelain
117
+ // reach the terminal on a successful run (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/install:18::porcelain`'s
118
+ // probe_output=$(...) capture), update prints it here.
119
+ ctx.stdout.write(formatPorcelain(facts));
120
+ ctx.stdout.write("manager is current\n");
121
+ return 0;
122
+ }
123
+ if (facts.status === "needs prepare") {
124
+ // Called as a FUNCTION: a failure propagates as a status, never through
125
+ // `set -eu`. runPrepare has already replayed its own outcomes and
126
+ // written its own diagnostics by the time it returns, so nothing further
127
+ // is written here on that path.
128
+ const prepareStatus = await runPrepare([], ctx);
129
+ if (prepareStatus !== 0)
130
+ return prepareStatus;
131
+ return await runInstall([], ctx);
132
+ }
133
+ if (facts.status === "needs install") {
134
+ return await runInstall([], ctx);
135
+ }
136
+ renderUnknownProbeStatus(facts, ctx);
137
+ return 1;
138
+ }
@@ -0,0 +1,4 @@
1
+ export function commitMatches(desired, observed) {
2
+ return (observed.length > 0 &&
3
+ (observed === desired || observed === desired.slice(0, 7)));
4
+ }
@@ -0,0 +1,37 @@
1
+ const SEMVER_BASE_SOURCE = "(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)" +
2
+ "(?:-(?:(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" +
3
+ "(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?";
4
+ export const SEMVER_BASE_RE = new RegExp(`^${SEMVER_BASE_SOURCE}$`);
5
+ export const TAG_RE = new RegExp(`^v${SEMVER_BASE_SOURCE}$`);
6
+ export const COMMIT_RE = /^[0-9a-f]{40}$/;
7
+ export const COMMIT_INPUT_RE = /^[0-9A-Fa-f]{40}$/;
8
+ export function isTagRef(value) {
9
+ return TAG_RE.test(value);
10
+ }
11
+ export function isCommit(value) {
12
+ return COMMIT_RE.test(value);
13
+ }
14
+ export function normalizeCommitInput(value) {
15
+ return COMMIT_INPUT_RE.test(value) ? value.toLowerCase() : null;
16
+ }
17
+ export function parseStableTag(tag) {
18
+ if (!TAG_RE.test(tag))
19
+ return null;
20
+ const core = tag.slice(1);
21
+ // Prerelease filter. Sound only because TAG_RE already matched: the numeric
22
+ // components cannot contain "-", so the sole way one reaches `core` is a
23
+ // prerelease suffix. Stable-tag selection must exclude those.
24
+ if (core.includes("-"))
25
+ return null;
26
+ const [major, minor, patch] = core.split(".");
27
+ return { major: BigInt(major), minor: BigInt(minor), patch: BigInt(patch) };
28
+ }
29
+ export function compareStable(a, b) {
30
+ if (a.major !== b.major)
31
+ return a.major < b.major ? -1 : 1;
32
+ if (a.minor !== b.minor)
33
+ return a.minor < b.minor ? -1 : 1;
34
+ if (a.patch !== b.patch)
35
+ return a.patch < b.patch ? -1 : 1;
36
+ return 0;
37
+ }