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,392 @@
1
+ import { assertFailureWritable, writeAdapterFailure, } from "../adapter-result.js";
2
+ import { oneLine } from "../cli-arguments.js";
3
+ import { computeEffectiveSelection } from "../effective-selection.js";
4
+ import { generatedCommitOrEmpty } from "../provenance.js";
5
+ import { displaySource } from "../selection.js";
6
+ import { statusForCommits } from "../status.js";
7
+ export const PROBE_USAGE = "error: usage: superpowers-manager probe [--porcelain]\n";
8
+ // One ordered table drives both formats, so the porcelain key order
9
+ // (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/probe:43-59::printf 'requested_ref`) and the human label order (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/probe:61-77::printf 'requested ref`) cannot
10
+ // drift apart. Two parallel lists could each stay self-consistent while
11
+ // disagreeing with one another.
12
+ function fields(f) {
13
+ return [
14
+ { key: "requested_ref", label: "requested ref", value: f.requestedRef },
15
+ { key: "resolved_ref", label: "resolved ref", value: f.resolvedRef },
16
+ { key: "desired_commit", label: "desired commit", value: f.desiredCommit },
17
+ {
18
+ key: "generated_commit",
19
+ label: "generated plugin commit",
20
+ value: f.generatedCommit,
21
+ absent: "not present",
22
+ },
23
+ {
24
+ key: "installed_commit",
25
+ label: "installed manager commit or fingerprint",
26
+ value: f.installedCommit,
27
+ absent: "not detected",
28
+ },
29
+ {
30
+ key: "identity_state",
31
+ label: "Codex identity state",
32
+ value: f.identityState,
33
+ },
34
+ { key: "status", label: "status", value: f.status },
35
+ {
36
+ key: "selection_origin",
37
+ label: "selection origin",
38
+ value: f.selectionOrigin,
39
+ },
40
+ { key: "selection_mode", label: "selection mode", value: f.selectionMode },
41
+ {
42
+ key: "upstream_source_origin",
43
+ label: "upstream source origin",
44
+ value: f.upstreamSourceOrigin,
45
+ },
46
+ {
47
+ key: "effective_source",
48
+ label: "effective source",
49
+ value: f.effectiveSource,
50
+ },
51
+ { key: "saved_mode", label: "saved mode", value: f.savedMode },
52
+ { key: "saved_source", label: "saved source", value: f.savedSource },
53
+ {
54
+ key: "saved_requested_ref",
55
+ label: "saved requested ref",
56
+ value: f.savedRequestedRef,
57
+ },
58
+ {
59
+ key: "saved_resolved_ref",
60
+ label: "saved resolved ref",
61
+ value: f.savedResolvedRef,
62
+ },
63
+ { key: "saved_commit", label: "saved commit", value: f.savedCommit },
64
+ { key: "update_control", label: "update control", value: f.updateControl },
65
+ ];
66
+ }
67
+ // Derived from the same fields() table, so the key list a test asserts and
68
+ // the key list formatPorcelain emits cannot be different lists. The facts
69
+ // argument is irrelevant here — fields() is total over ProbeFacts and the
70
+ // keys do not depend on the values.
71
+ const NO_FACTS = {
72
+ requestedRef: "",
73
+ resolvedRef: "",
74
+ desiredCommit: "",
75
+ generatedCommit: "",
76
+ installedCommit: "",
77
+ identityState: "",
78
+ status: "",
79
+ selectionOrigin: "",
80
+ selectionMode: "",
81
+ upstreamSourceOrigin: "",
82
+ effectiveSource: "",
83
+ savedMode: "",
84
+ savedSource: "",
85
+ savedRequestedRef: "",
86
+ savedResolvedRef: "",
87
+ savedCommit: "",
88
+ updateControl: "",
89
+ };
90
+ export const PROBE_PORCELAIN_KEYS = fields(NO_FACTS).map((field) => field.key);
91
+ export function formatPorcelain(f) {
92
+ return fields(f)
93
+ .map((field) => `${field.key}=${field.value}\n`)
94
+ .join("");
95
+ }
96
+ export function formatHuman(f) {
97
+ let text = fields(f)
98
+ .map((field) => {
99
+ const shown = field.value.length === 0 && field.absent !== undefined
100
+ ? field.absent
101
+ : field.value;
102
+ return `${field.label}: ${shown}\n`;
103
+ })
104
+ .join("");
105
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/probe:78-81::if [ "$SPW_SELECTION_ORIGIN`.
106
+ if (f.selectionOrigin !== f.upstreamSourceOrigin) {
107
+ text +=
108
+ "warning: effective ref and source have mixed origins " +
109
+ `(ref: ${f.selectionOrigin}, source: ${f.upstreamSourceOrigin})\n`;
110
+ }
111
+ return text;
112
+ }
113
+ // Ports scripts/core/validate-adapter-response.py's replay (:235-238) and its
114
+ // error/hint block (:269-272). The shell ran that validator on EVERY adapter
115
+ // response, so adapter messages reached the operator on their declared streams
116
+ // in array order, and a controlled failure printed `error:` plus one `hint:`
117
+ // per hint. DIAG-ADAPTER-01 retains that contract
118
+ // (docs/baseline/protocol-disposition.md); dropping it here would be a
119
+ // silent diagnostics regression, not a simplification.
120
+ //
121
+ // Interpolating error.message and each hint is the sanctioned form (AGENTS.md):
122
+ // src/adapter.ts has 52 `fail()` sites and none interpolates a caught error's
123
+ // message, so the callee owns every failure reachable on this path. Those two
124
+ // writes now live in writeAdapterFailure (src/adapter-result.ts), which
125
+ // validates all three strings before the first of them reaches the stream.
126
+ //
127
+ // This writes to ctx, so it MUST NOT be called from inside gatherProbe's try
128
+ // -- see the ProbeOutcome note below.
129
+ export function replayOutcome(outcome, ctx) {
130
+ // Hoisted ABOVE the message loop, and that ordering is the point: a failure
131
+ // whose code, message, or a hint carries a terminal control character must
132
+ // leave both streams untouched, not the context lines followed by nothing.
133
+ // Returns immediately on a succeeding outcome.
134
+ assertFailureWritable(outcome);
135
+ for (const message of outcome.messages) {
136
+ const stream = message.channel === "stdout" ? ctx.stdout : ctx.stderr;
137
+ stream.write(`${message.text}\n`);
138
+ }
139
+ // The message loop stays unguarded on purpose: D8b scopes the check to code,
140
+ // message, and hints, and AdapterMessageLog escapes message text before it is
141
+ // stored, so scanning it here would re-check a population that cannot fail.
142
+ writeAdapterFailure(ctx, outcome);
143
+ }
144
+ // `runAdapter` reports a CONTROLLED failure by RETURN VALUE, not by throwing
145
+ // (`src/adapter-result.ts:32-35::export interface AdapterResult`). The shell got
146
+ // fail-closed behaviour for free: spw_invoke_adapter returned 1 and
147
+ // scripts/probe ran under `set -eu`.
148
+ // Omitting the status check here would read a failed inspection as absent
149
+ // evidence and report it as success.
150
+ //
151
+ // It does still THROW for a non-AdapterFailure cause (runAdapter's closing
152
+ // `throw cause`, src/adapter.ts). That is caught here rather than in runProbe's
153
+ // outer catch, because the two need different diagnostics -- see spec §3.3a.
154
+ //
155
+ // Reached through ctx.adapter, not a direct module-level dependency on the
156
+ // adapter module: src/commands/prepare.ts and this module are the two the
157
+ // injected double must observe, because install reaches the adapter through
158
+ // gatherProbe and runPrepare. Spec §4.5.
159
+ async function inspect(view, key, ctx) {
160
+ let result;
161
+ try {
162
+ result = await ctx.adapter(["inspect", "--view", view], {
163
+ root: ctx.root,
164
+ env: ctx.env,
165
+ });
166
+ }
167
+ catch {
168
+ // Deliberately does NOT interpolate the cause. A rethrown non-
169
+ // AdapterFailure is by construction the one failure src/adapter.ts chose
170
+ // not to own: free-form text of unknown provenance, which AGENTS.md bars
171
+ // from this stream. `view` is a bounded token -- one of three literals
172
+ // this function is ever called with -- so naming the input is safe.
173
+ return {
174
+ ok: false,
175
+ outcome: null,
176
+ message: `cannot inspect Codex adapter state for view ${view}`,
177
+ };
178
+ }
179
+ const outcome = result.outcome;
180
+ if (result.status !== 0 || !outcome.ok) {
181
+ // The `outcome.ok && status !== 0` combination cannot arise from
182
+ // successResult/failureResult, so it gets its own hand-written message
183
+ // rather than falling through to a replay that would print nothing.
184
+ return {
185
+ ok: false,
186
+ outcome,
187
+ message: outcome.ok
188
+ ? `adapter reported a failure status for inspect --view ${view}`
189
+ : null,
190
+ };
191
+ }
192
+ const value = outcome.result?.[key];
193
+ // The Python reader printed the empty string for a JSON null
194
+ // (scripts/core/provenance.sh's spw_json_get), and `fingerprint` is null
195
+ // whenever no plugin version is active (`src/adapter.ts:820::fingerprint: null`).
196
+ if (value === null || value === undefined) {
197
+ return { ok: true, value: "", outcome };
198
+ }
199
+ if (typeof value !== "string") {
200
+ return {
201
+ ok: false,
202
+ outcome,
203
+ message: `adapter returned a non-string ${key} for inspect --view ${view}`,
204
+ };
205
+ }
206
+ return { ok: true, value, outcome };
207
+ }
208
+ // Runs every step that can throw or fail closed, returning the outcome as
209
+ // data. It performs no writes of its own, so nothing inside runProbe's try can
210
+ // raise EPIPE — the same shape as src/commands/unpin.ts's attemptUnpin.
211
+ //
212
+ // The outcomes are CARRIED OUT rather than replayed in place, for that same
213
+ // reason: replayOutcome writes to ctx, and a write inside this try could
214
+ // raise EPIPE and be caught and relabelled as a selection failure. runProbe
215
+ // replays them, in collection order, after the try/catch has resolved. The
216
+ // operator-visible result is identical -- probe emits nothing else until the
217
+ // very end -- and the EPIPE hazard never exists.
218
+ // Exported for src/commands/install.ts and src/commands/update.ts, which need
219
+ // probe's FACTS rather than its rendering. The historical
220
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/lifecycle.sh:39-41::spw_probe_field`
221
+ // implementation awk-parsed the porcelain back into fields; that round trip is
222
+ // gone.
223
+ export async function gatherProbe(ctx) {
224
+ // Order mirrors `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/probe:24-40::spw_compute_effective_selection` exactly.
225
+ const selection = await computeEffectiveSelection(ctx.root, ctx.env);
226
+ const generatedCommit = await generatedCommitOrEmpty(ctx.root);
227
+ const outcomes = [];
228
+ const collect = async (view, key) => {
229
+ const result = await inspect(view, key, ctx);
230
+ if (result.outcome !== null)
231
+ outcomes.push(result.outcome);
232
+ return result;
233
+ };
234
+ const fingerprint = await collect("fingerprint", "fingerprint");
235
+ if (!fingerprint.ok) {
236
+ return { status: 1, outcomes, message: fingerprint.message };
237
+ }
238
+ const ownership = await collect("ownership", "identity_state");
239
+ if (!ownership.ok) {
240
+ return { status: 1, outcomes, message: ownership.message };
241
+ }
242
+ const updateControl = await collect("update-control", "update_control");
243
+ if (!updateControl.ok) {
244
+ return { status: 1, outcomes, message: updateControl.message };
245
+ }
246
+ const saved = selection.saved;
247
+ return {
248
+ status: 0,
249
+ outcomes,
250
+ facts: {
251
+ requestedRef: selection.requestedRef,
252
+ resolvedRef: selection.resolvedRef,
253
+ desiredCommit: selection.desiredCommit,
254
+ generatedCommit,
255
+ installedCommit: fingerprint.value,
256
+ identityState: ownership.value,
257
+ status: statusForCommits(selection.desiredCommit, generatedCommit, fingerprint.value),
258
+ selectionOrigin: selection.selectionOrigin,
259
+ selectionMode: selection.selectionMode,
260
+ upstreamSourceOrigin: selection.upstreamSourceOrigin,
261
+ effectiveSource: displaySource(selection.effectiveSource),
262
+ savedMode: saved.saved_mode,
263
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/probe:26-30::if [ -`: an absent saved source stays empty rather than
264
+ // being run through displaySource, which would render <redacted-source>.
265
+ savedSource: saved.saved_source.length > 0 ? displaySource(saved.saved_source) : "",
266
+ savedRequestedRef: saved.saved_requested_ref,
267
+ savedResolvedRef: saved.saved_resolved_ref,
268
+ savedCommit: saved.saved_commit,
269
+ updateControl: updateControl.value,
270
+ },
271
+ };
272
+ }
273
+ export async function runProbe(argv, ctx) {
274
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/probe:42::porcelain` tested only `[ "${1:-}" = "--porcelain" ]`, so a typo'd
275
+ // flag silently produced human output. Rejecting it is a deliberate
276
+ // narrowing, recorded as a port-only entry in
277
+ // tests/migration-inventory/probe.md.
278
+ //
279
+ // This guard is NOT the production path. src/cli.ts's parseArgs rejects the
280
+ // same inputs first, before preflight, with the usage block the CLI's other
281
+ // usage errors carry — exactly the arrangement track-latest and unpin have,
282
+ // where the in-module check is an unreachable-from-CLI duplicate. It stays
283
+ // because runProbe is also called directly by tests and by any future
284
+ // in-process caller that has not been through parseArgs.
285
+ const porcelain = argv.length === 1 && argv[0] === "--porcelain";
286
+ if (!porcelain && argv.length !== 0) {
287
+ ctx.stderr.write(PROBE_USAGE);
288
+ return 2;
289
+ }
290
+ let outcome;
291
+ try {
292
+ outcome = await gatherProbe(ctx);
293
+ }
294
+ catch (cause) {
295
+ // Hand-written messages, per AGENTS.md's reader-diagnostics rule. Reachable
296
+ // here: the selectionErrors validateSource raises
297
+ // (`src/selection.ts:137::malformed`, :140, requireSingleLineString at :77),
298
+ // reached from
299
+ // computeEffectiveSelection's validateSource call
300
+ // (src/effective-selection.ts); the ones validateRecord raises
301
+ // (`src/selection.ts:198::integer`, :219, requireObject at :47, requireExactKeys at
302
+ // :64, validatePinnedRecord at :170-183), reached from
303
+ // `src/selection-store.ts:103::return validateRecord` on the read path; the
304
+ // selectionErrors requireAbsolute and selectionConfigDir
305
+ // (src/effective-selection.ts) raise
306
+ // for a non-absolute or missing config directory; readConfigRef's `cannot
307
+ // read packaged upstream ref <path>` (src/upstream.ts); and resolveRef's own
308
+ // two no-match diagnostics, `no stable semver tag found for latest-release`
309
+ // and `cannot resolve upstream ref: <ref>` (src/upstream.ts). normalizeSaved
310
+ // (`src/selection.ts:222-241::normalizeSaved`) throws nothing at all.
311
+ //
312
+ // THREE exceptions, all inherited and none a regression:
313
+ // 1. resolveRef splices git's combined stdout+stderr into its own text
314
+ // (its three `cannot query upstream ...` throws, src/upstream.ts),
315
+ // reached via computeEffectiveSelection's resolveRef call
316
+ // (src/effective-selection.ts). The packaged ref is `latest-release`,
317
+ // which resolveRef answers with an ls-remote, so an ordinary unpinned
318
+ // run reaches this rather than only an exotic corner -- though not
319
+ // every unpinned run does: resolveRef's COMMIT_INPUT_RE branch returns
320
+ // before any runGit call, so a 40-hex requested ref splices nothing.
321
+ // Pinned by
322
+ // `tests/unit/upstream.test.js:460::void test("resolveRef reports a query failure for latest`,
323
+ // :471, and :483.
324
+ // 2. The
325
+ // `src/selection-store.ts:120-124::cause.module === "selection") {`
326
+ // site (same shape at :49, :86, :98) is the module AGENTS.md's
327
+ // `src/selection-store.ts` bullet grandfathers:
328
+ // it interpolates the caught error's own message, so Node errno
329
+ // prose can reach this stream. Reached on the READ path only, via
330
+ // loadSavedSelection (src/effective-selection.ts) ->
331
+ // readSelectionState
332
+ // (`src/selection-store.ts:149::export async function readSelectionState`).
333
+ // This module's four write-only interpolating sites -- ensureStateDirectory
334
+ // (:172), finalStateDiagnostic (:197), and the two in
335
+ // writeSelectionState's own catch (:225, :231) -- are all
336
+ // unreachable from probe, which never writes.
337
+ // 3. Every runGit call site inside resolveRef (src/upstream.ts) can
338
+ // reject instead of resolving. On the non-ENOENT arm of
339
+ // `src/git.ts:47-52::if (typeof`, runGit builds the message
340
+ // "cannot run git: " followed by the Node spawn error's own message
341
+ // (:51) and rejects with a SafetyError carrying it (:52), so that
342
+ // Node spawn-level text reaches ctx.stderr through this catch. The
343
+ // ENOENT arm (:50) is hand-written and carries nothing. A non-zero
344
+ // *exit status* is handled by exception 1 above; this is the
345
+ // *spawn-level* case, where runGit throws rather than returning a
346
+ // status.
347
+ //
348
+ // oneLine() at this catch collapses each of exceptions 1 and 3 -- the two
349
+ // carrying git-derived text -- to a single line. It collapses CR/LF only,
350
+ // so it bounds how much of that text lands, not what it may contain.
351
+ //
352
+ // fetchExactCommit is deliberately NOT in this list, unlike prepare's
353
+ // fetchExactCommit exception, which runPrepare's catch block documents.
354
+ // Its only callers are `src/upstream-cli.ts:83::await fetchExactCommit` and
355
+ // gatherPrepare's own call in src/commands/prepare.ts (git grep -n
356
+ // fetchExactCommit -- src/ is the check that keeps "only" true), so probe
357
+ // never reaches it and
358
+ // its splice sites cannot appear on this stream. Do not add it back by
359
+ // symmetry with prepare.
360
+ //
361
+ // generatedCommitOrEmpty is not in this list either: it cannot throw at
362
+ // all. It (src/provenance.ts) delegates to readGeneratedCommitLenient,
363
+ // which catches every failure and returns "".
364
+ //
365
+ // A non-AdapterFailure re-thrown by runAdapter's closing `throw cause`
366
+ // (src/adapter.ts) does NOT reach here: inspect() catches it and converts
367
+ // it to a hand-written message per AGENTS.md's reader-diagnostics rule -- a
368
+ // rethrown cause is exactly the failure src/adapter.ts declined to own, so
369
+ // its text must never reach this stream. See §3.3a.
370
+ //
371
+ // gatherProbe performs no writes of its own, so this catch cannot also be
372
+ // reached by an EPIPE from probe's own output — every write below runs
373
+ // only after this try/catch has resolved.
374
+ ctx.stderr.write(`error: ${oneLine(cause)}\n`);
375
+ return 1;
376
+ }
377
+ // Replay first, on both paths: the shell validator replayed every response's
378
+ // messages whether or not that response was a failure at
379
+ // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/validate-adapter-response.py:268::replay(messages)`.
380
+ for (const each of outcome.outcomes)
381
+ replayOutcome(each, ctx);
382
+ if (outcome.status === 1) {
383
+ // null means replayOutcome already emitted the adapter's own `error:`
384
+ // and `hint:` lines for the failing outcome.
385
+ if (outcome.message !== null) {
386
+ ctx.stderr.write(`error: ${outcome.message}\n`);
387
+ }
388
+ return 1;
389
+ }
390
+ ctx.stdout.write(porcelain ? formatPorcelain(outcome.facts) : formatHuman(outcome.facts));
391
+ return 0;
392
+ }
@@ -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
+ }