superpowers-manager 0.5.2 → 0.6.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.
@@ -3,9 +3,8 @@ import { failureResult, successResult, } from "../../adapter-result.js";
3
3
  import { beginDirectoryPublication, } from "../../atomic.js";
4
4
  import { digestArtifactTree } from "../../artifact-tree.js";
5
5
  import { assertNoFollowType, classifyPathNoFollow } from "../../safe-path.js";
6
- import { codexInspect, codexInstallRefreshMode, codexRemove, codexReadNativeState, } from "./adapter.js";
6
+ import { codexInspectControl, codexInspectOwnership, codexInstallRefreshMode, codexRemove, codexReadNativeState, } from "./adapter.js";
7
7
  import { readCodexAssessment } from "./compatibility.js";
8
- import { requireManagedUpdateControl, requireNoLegacyState, } from "./lifecycle.js";
9
8
  import { readCodexMarketplace, stageCodexMarketplace, } from "./marketplace.js";
10
9
  import { assertCodexPreparationSeparate, codexPathFailureDetails, codexPaths, } from "./paths.js";
11
10
  import { codexPreparationLocation } from "./prepare.js";
@@ -13,7 +12,8 @@ import { beginCodexRecovery, finishCodexRecovery, readCodexRecovery, verifyCodex
13
12
  import { hasFilesystemAccessFailure, pathsEqual } from "./state.js";
14
13
  const DEFAULTS = {
15
14
  readNative: codexReadNativeState,
16
- inspectNative: codexInspect,
15
+ inspectOwnership: codexInspectOwnership,
16
+ inspectControl: codexInspectControl,
17
17
  beginPublication: beginDirectoryPublication,
18
18
  };
19
19
  const REMOVAL_DEFAULTS = {
@@ -26,16 +26,6 @@ function fail(code, message, messages) {
26
26
  function removalFailure(code, message, messages) {
27
27
  return failureResult("uninstall-codex", code, message, [], messages);
28
28
  }
29
- function objectResult(result) {
30
- if (result.status !== 0 ||
31
- !result.outcome.ok ||
32
- result.outcome.result === null ||
33
- typeof result.outcome.result !== "object" ||
34
- Array.isArray(result.outcome.result)) {
35
- throw new Error("Codex inspection failed");
36
- }
37
- return result.outcome.result;
38
- }
39
29
  function acceptedNative(result) {
40
30
  if (result.status !== 0 || !result.outcome.ok) {
41
31
  throw new Error("Codex native observation failed");
@@ -54,26 +44,18 @@ function acceptedNative(result) {
54
44
  return value;
55
45
  }
56
46
  function requireOwnership(result) {
57
- const value = objectResult(result);
58
- if (typeof value.identity_state !== "string" ||
59
- requireNoLegacyState(value.identity_state).kind !== "ok" ||
60
- !Array.isArray(value.conflicts) ||
61
- value.conflicts.length !== 0) {
62
- throw new Error("Codex ownership blocks publication");
47
+ if (result.status !== 0 || !result.outcome.ok) {
48
+ throw new Error("Codex inspection failed");
63
49
  }
64
- const resources = value.resources;
65
- if (typeof resources !== "object" ||
66
- resources === null ||
67
- Array.isArray(resources) ||
68
- typeof resources.plugin !== "boolean" ||
69
- typeof resources.marketplace !== "boolean") {
70
- throw new Error("Codex ownership inspection is malformed");
50
+ if (result.outcome.result?.installEligibility?.kind !== "allowed") {
51
+ throw new Error("Codex ownership blocks publication");
71
52
  }
72
53
  }
73
54
  function requireControl(result) {
74
- const value = objectResult(result);
75
- if (typeof value.update_control !== "string" ||
76
- !requireManagedUpdateControl(value.update_control).ok) {
55
+ if (result.status !== 0 || !result.outcome.ok) {
56
+ throw new Error("Codex inspection failed");
57
+ }
58
+ if (result.outcome.result?.mutationEligibility?.kind !== "allowed") {
77
59
  throw new Error("Codex update control blocks publication");
78
60
  }
79
61
  }
@@ -262,10 +244,10 @@ async function observeNative(ctx, dependencies, messages) {
262
244
  return acceptedNative(result);
263
245
  }
264
246
  async function requireEligibility(ctx, dependencies, messages) {
265
- const ownership = await dependencies.inspectNative("ownership", ctx);
247
+ const ownership = await dependencies.inspectOwnership(ctx);
266
248
  messages.push(...ownership.outcome.messages);
267
249
  requireOwnership(ownership);
268
- const control = await dependencies.inspectNative("update-control", ctx);
250
+ const control = await dependencies.inspectControl(ctx);
269
251
  messages.push(...control.outcome.messages);
270
252
  requireControl(control);
271
253
  }
@@ -1,72 +1,3 @@
1
- import { SafetyError } from "./safety-error.js";
2
- /**
3
- * Render a JSON number token the way CPython's `json.dump` does.
4
- *
5
- * Classification is by source text, matching CPython's own: a token containing
6
- * `.` or `e`/`E` parses as a float, anything else as an arbitrary-width int.
7
- * Integers are emitted verbatim so widths above 2^53 survive; `-0` is the only
8
- * normalization JSON's grammar leaves room for, since leading zeros and a
9
- * leading `+` are already forbidden.
10
- */
11
- export function formatPythonNumber(raw) {
12
- if (!/[.eE]/.test(raw)) {
13
- // NaN/Infinity/-Infinity contain no `.` or `e`/`E`, so they would
14
- // otherwise fall into the "emit verbatim" integer branch and echo back
15
- // as invalid JSON. A reader running a non-standard-constants accept
16
- // profile (see src/strict-json.ts) can hand this function exactly these
17
- // three tokens as a raw number source, so this function must reject them
18
- // itself rather than assume its caller already did. CPython's
19
- // `json.dump(..., allow_nan=False)` raises ValueError for all three.
20
- if (raw === "NaN" || raw === "Infinity" || raw === "-Infinity") {
21
- throw new SafetyError("manifest-overlay", `JSON number out of range: ${raw}`);
22
- }
23
- return raw === "-0" ? "0" : raw;
24
- }
25
- const value = Number(raw);
26
- if (!Number.isFinite(value)) {
27
- throw new SafetyError("manifest-overlay", `JSON number out of range: ${raw}`);
28
- }
29
- return formatPythonFloat(value);
30
- }
31
- function formatPythonFloat(value) {
32
- // toExponential() drops the sign of negative zero; CPython's repr keeps it.
33
- // Note this differs from the integer -0, which normalizes to 0 — consistent,
34
- // because -0 has no `.` or `e` and so classifies as an integer.
35
- if (Object.is(value, -0))
36
- return "-0.0";
37
- // Called with no argument, toExponential() yields the shortest digit string
38
- // that uniquely identifies the double — the same guarantee CPython's repr
39
- // relies on. Taking digits and exponent separately is what lets us apply
40
- // CPython's notation threshold instead of JavaScript's.
41
- const [mantissa, exponentText] = value.toExponential().split("e");
42
- const exponent = Number(exponentText);
43
- const negative = mantissa.startsWith("-");
44
- const digits = mantissa.replace("-", "").replace(".", "");
45
- let body;
46
- if (exponent >= -4 && exponent < 16) {
47
- // Fixed notation, built by MOVING the decimal point through the digit
48
- // string above. Do NOT route this through value.toFixed(): toExponential()
49
- // has already produced the shortest digits that identify this double, and
50
- // toFixed performs a second, independent rounding of the full decimal
51
- // expansion. The two disagree — 1888570120608320.2 formats as
52
- // ...320.3 via toFixed and ...320.2 in CPython. Measured 2026-07-31.
53
- if (exponent >= digits.length - 1) {
54
- body = `${digits}${"0".repeat(exponent - (digits.length - 1))}.0`;
55
- }
56
- else if (exponent >= 0) {
57
- body = `${digits.slice(0, exponent + 1)}.${digits.slice(exponent + 1)}`;
58
- }
59
- else {
60
- body = `0.${"0".repeat(-exponent - 1)}${digits}`;
61
- }
62
- }
63
- else {
64
- const sign = exponent < 0 ? "-" : "+";
65
- const magnitude = String(Math.abs(exponent)).padStart(2, "0");
66
- body = `${mantissa.replace("-", "")}e${sign}${magnitude}`;
67
- }
68
- return negative ? `-${body}` : body;
69
- }
70
1
  /**
71
2
  * Apply CPython's `ensure_ascii=True` to an already-JSON-encoded string. Input
72
3
  * must be the output of JSON.stringify, which has already escaped quotes,
@@ -1,29 +1,4 @@
1
+ import { escapeNonAscii } from "./python-json-format.js";
1
2
  export function escapePythonJsonString(value) {
2
- let escaped = "";
3
- const shortEscapes = {
4
- 0x08: "\\b",
5
- 0x09: "\\t",
6
- 0x0a: "\\n",
7
- 0x0c: "\\f",
8
- 0x0d: "\\r",
9
- };
10
- for (let index = 0; index < value.length; index += 1) {
11
- const code = value.charCodeAt(index);
12
- if (code === 0x22) {
13
- escaped += '\\"';
14
- }
15
- else if (code === 0x5c) {
16
- escaped += "\\\\";
17
- }
18
- else if (Object.hasOwn(shortEscapes, code)) {
19
- escaped += shortEscapes[code];
20
- }
21
- else if (code < 0x20 || code >= 0x7f) {
22
- escaped += `\\u${code.toString(16).padStart(4, "0")}`;
23
- }
24
- else {
25
- escaped += value[index];
26
- }
27
- }
28
- return escaped;
3
+ return escapeNonAscii(JSON.stringify(value).slice(1, -1));
29
4
  }
@@ -1,75 +1,15 @@
1
- // CPython `str.strip()` whitespace, from Unicode's Bidi/White_Space tables as
2
- // CPython applies them. Deliberately excludes U+FEFF, which JavaScript's
3
- // `trim()` removes and Python's `strip()` does not.
4
- const PYTHON_WHITESPACE = new Set([
5
- "\t",
6
- "\n",
7
- "\v",
8
- "\f",
9
- "\r",
10
- " ",
11
- "\x1c",
12
- "\x1d",
13
- "\x1e",
14
- "\x1f",
15
- "\x85",
16
- " ",
17
- " ",
18
- " ",
19
- " ",
20
- " ",
21
- " ",
22
- " ",
23
- " ",
24
- " ",
25
- " ",
26
- " ",
27
- " ",
28
- " ",
29
- "
",
30
- "
",
31
- " ",
32
- " ",
33
- " ",
34
- ]);
35
- // CPython `str.splitlines()` boundaries. `\r\n` is handled as a pair below.
36
- const PYTHON_LINE_BOUNDARIES = new Set([
37
- "\n",
38
- "\r",
39
- "\v",
40
- "\f",
41
- "\x1c",
42
- "\x1d",
43
- "\x1e",
44
- "\x85",
45
- "
",
46
- "
",
47
- ]);
1
+ // Match first through last non-whitespace; avoid suffix retries on internal runs.
48
2
  export function pythonStrip(value) {
49
- let start = 0;
50
- let end = value.length;
51
- while (start < end && PYTHON_WHITESPACE.has(value[start]))
52
- start += 1;
53
- while (end > start && PYTHON_WHITESPACE.has(value[end - 1]))
54
- end -= 1;
55
- return value.slice(start, end);
3
+ return (value.match(
4
+ // oxlint-disable-next-line no-control-regex -- exact CPython whitespace includes C0 separators.
5
+ /[^\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028-\u2029\u202f\u205f\u3000](?:[\s\S]*[^\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028-\u2029\u202f\u205f\u3000])?/)?.[0] ?? "");
56
6
  }
7
+ // CRLF is one boundary; a terminating boundary adds no final empty line.
57
8
  export function pythonSplitlines(value) {
58
- const lines = [];
59
- let start = 0;
60
- let index = 0;
61
- while (index < value.length) {
62
- const character = value[index];
63
- if (!PYTHON_LINE_BOUNDARIES.has(character)) {
64
- index += 1;
65
- continue;
66
- }
67
- lines.push(value.slice(start, index));
68
- index += character === "\r" && value[index + 1] === "\n" ? 2 : 1;
69
- start = index;
70
- }
71
- if (start < value.length)
72
- lines.push(value.slice(start));
9
+ // oxlint-disable-next-line no-control-regex -- CPython boundaries include C0 controls.
10
+ const lines = value.split(/\r\n|[\n\r\v\f\u001c-\u001e\u0085\u2028\u2029]/);
11
+ if (lines.at(-1) === "")
12
+ lines.pop();
73
13
  return lines;
74
14
  }
75
15
  /** Python `sorted()` orders by code point; JavaScript's default sort does not. */
package/dist/validator.js CHANGED
@@ -2,21 +2,16 @@ import { spawn } from "node:child_process";
2
2
  import { lstat, realpath, stat } from "node:fs/promises";
3
3
  import { sep } from "node:path";
4
4
  import { hasTerminalControl, pythonUnicodeEscapeBytes, } from "./adapter-result.js";
5
- // The legacy SUPERPOWERS_VALIDATOR path. Its behaviour is frozen by parity, so it
6
- // carries no limits at all; see the spec's D2. Bounding it is PR 12's row.
7
- export const UNBOUNDED_LEGACY = { kind: "unbounded" };
8
5
  // SUPERPOWERS_VALIDATOR_EXECUTABLE. 30s is ~270x a measured realistic validator run
9
6
  // (0.11s, dominated by interpreter startup); 64 KiB per stream clears a
10
7
  // one-diagnostic-line-per-file report (30 KB on a real tree) with headroom.
11
8
  export const BOUNDED_EXECUTABLE = {
12
- kind: "bounded",
13
9
  timeoutMs: 30_000,
14
10
  graceMs: 2_000,
15
11
  drainMs: 200,
16
12
  maxBytesPerStream: 64 * 1024,
17
13
  };
18
- // Retains the first `limit` bytes and counts the rest. A null limit retains
19
- // everything, which is the unbounded policy's contract. push() always consumes the
14
+ // Retains the first `limit` bytes and counts the rest. push() always consumes the
20
15
  // whole chunk: ceasing to consume would block the child on a full pipe.
21
16
  class Sink {
22
17
  chunks = [];
@@ -27,10 +22,6 @@ class Sink {
27
22
  this.limit = limit;
28
23
  }
29
24
  push(chunk) {
30
- if (this.limit === null) {
31
- this.chunks.push(chunk);
32
- return;
33
- }
34
25
  const room = this.limit - this.kept;
35
26
  if (room <= 0) {
36
27
  this.dropped += chunk.length;
@@ -42,21 +33,15 @@ class Sink {
42
33
  this.dropped += chunk.length - take.length;
43
34
  }
44
35
  done() {
45
- // Buffer.toString, NOT TextDecoder: TextDecoder strips a leading BOM and the
46
- // legacy path's current string accumulator preserves it. Measured:
47
- // decode(<BOM>hi) is "hi" but toString("utf8") is "\ufeffhi". The legacy path
48
- // must be byte-identical, so BOTH paths use toString.
36
+ // Buffer.toString preserves a leading BOM; TextDecoder would strip it.
49
37
  return {
50
38
  text: Buffer.concat(this.chunks).toString("utf8"),
51
39
  droppedBytes: this.dropped,
52
40
  };
53
41
  }
54
42
  }
55
- // Signal the child's whole process group. Only meaningful for a child spawned
56
- // under the BOUNDED policy: that spawn passes `detached: true`, so the child
57
- // leads a group of its own and -pid reaches its descendants. The LEGACY path
58
- // spawns without `detached`, so its child does not lead a group and this
59
- // function is not for it. Wrapped because the group is already gone in the
43
+ // Signal the child's whole process group. The child leads a group of its own,
44
+ // so -pid reaches descendants. Wrapped because the group is already gone in
60
45
  // ordinary race between SIGTERM and SIGKILL.
61
46
  function signalGroup(child, signal) {
62
47
  if (child.pid === undefined)
@@ -71,9 +56,8 @@ function signalGroup(child, signal) {
71
56
  export function runValidator(argv, policy, env, workspace, cwd) {
72
57
  return new Promise((resolveRun) => {
73
58
  const [command, ...args] = argv;
74
- const limit = policy.kind === "bounded" ? policy.maxBytesPerStream : null;
75
- const out = new Sink(limit);
76
- const err = new Sink(limit);
59
+ const out = new Sink(policy.maxBytesPerStream);
60
+ const err = new Sink(policy.maxBytesPerStream);
77
61
  let settled = false;
78
62
  // Settlement and termination are independent BY CONSTRUCTION, which is why
79
63
  // there is no grace-timer handle to clear here: the timedOut settle is nested
@@ -113,20 +97,15 @@ export function runValidator(argv, policy, env, workspace, cwd) {
113
97
  env: { ...process.env, ...env, TMPDIR: workspace },
114
98
  cwd,
115
99
  stdio: ["ignore", "pipe", "pipe"],
116
- // D4: under the BOUNDED policy only, the child leads its own process group
117
- // so a timeout can signal the GROUP -- signalling the process alone leaves
118
- // descendants holding the inherited pipes, and `close` then waits for them
119
- // (measured at 5279 ms against a 300 ms timeout). The LEGACY path must not
120
- // acquire group leadership: that changes process and session semantics for
121
- // a path whose whole contract is that it is unchanged.
122
- detached: policy.kind === "bounded",
100
+ // The child leads its own process group so a timeout can signal the GROUP --
101
+ // signalling the process alone leaves descendants holding inherited pipes.
102
+ detached: true,
123
103
  });
124
104
  }
125
105
  catch (cause) {
126
106
  settle({
127
107
  kind: "launchFailed",
128
108
  errno: cause.code ?? "UNKNOWN",
129
- cause,
130
109
  });
131
110
  return;
132
111
  }
@@ -136,7 +115,7 @@ export function runValidator(argv, policy, env, workspace, cwd) {
136
115
  child.stderr.on("data", (chunk) => err.push(chunk));
137
116
  // Both error and close fire on a launch failure, so settle() is idempotent.
138
117
  child.on("error", (cause) => {
139
- settle({ kind: "launchFailed", errno: cause.code ?? "UNKNOWN", cause });
118
+ settle({ kind: "launchFailed", errno: cause.code ?? "UNKNOWN" });
140
119
  });
141
120
  child.on("close", (code) => {
142
121
  // On the timeout path, settling here would resolve BEFORE SIGKILL and the
@@ -154,12 +133,6 @@ export function runValidator(argv, policy, env, workspace, cwd) {
154
133
  // may lag it indefinitely. Capture the code here or a successful validator
155
134
  // whose descendant lingers would settle with a null code and be rejected.
156
135
  lastCode = code;
157
- // BOUNDED ONLY. The legacy path settles on `close`, exactly as it does today:
158
- // a validator that backgrounds `(sleep 0.6; echo late) &` has "late" captured
159
- // under `close` and lost under a 200 ms drain, and that output is captured
160
- // today.
161
- if (policy.kind !== "bounded")
162
- return;
163
136
  // The TIMEOUT path owns its own settlement, after the escalation has run.
164
137
  if (timedOut)
165
138
  return;
@@ -186,31 +159,29 @@ export function runValidator(argv, policy, env, workspace, cwd) {
186
159
  });
187
160
  }, policy.drainMs);
188
161
  });
189
- if (policy.kind === "bounded") {
190
- timeoutTimer = setTimeout(() => {
191
- timedOut = true;
192
- // Signal the GROUP, not the process: descendants hold the pipes. SIGTERM
193
- // first, because the contract promises stderr carries the reason, and
194
- // reading continues through the grace window so the child can print it.
195
- signalGroup(child, "SIGTERM");
162
+ timeoutTimer = setTimeout(() => {
163
+ timedOut = true;
164
+ // Signal the GROUP, not the process: descendants hold the pipes. SIGTERM
165
+ // first, because the contract promises stderr carries the reason, and
166
+ // reading continues through the grace window so the child can print it.
167
+ signalGroup(child, "SIGTERM");
168
+ setTimeout(() => {
169
+ signalGroup(child, "SIGKILL");
170
+ // Settle ONLY after SIGKILL has been sent. An earlier revision settled
171
+ // first and left the escalation on an unref'ed timer, which never fires
172
+ // because the CLI exits as soon as its handler returns -- measured, the
173
+ // manager exited at 146 ms and the descendant survived. The wall-clock
174
+ // bound is therefore timeout + grace + drain, which is still a bound.
196
175
  setTimeout(() => {
197
- signalGroup(child, "SIGKILL");
198
- // Settle ONLY after SIGKILL has been sent. An earlier revision settled
199
- // first and left the escalation on an unref'ed timer, which never fires
200
- // because the CLI exits as soon as its handler returns -- measured, the
201
- // manager exited at 146 ms and the descendant survived. The wall-clock
202
- // bound is therefore timeout + grace + drain, which is still a bound.
203
- setTimeout(() => {
204
- settle({
205
- kind: "timedOut",
206
- afterMs: policy.timeoutMs,
207
- stdout: out.done(),
208
- stderr: err.done(),
209
- });
210
- }, policy.drainMs);
211
- }, policy.graceMs);
212
- }, policy.timeoutMs);
213
- }
176
+ settle({
177
+ kind: "timedOut",
178
+ afterMs: policy.timeoutMs,
179
+ stdout: out.done(),
180
+ stderr: err.done(),
181
+ });
182
+ }, policy.drainMs);
183
+ }, policy.graceMs);
184
+ }, policy.timeoutMs);
214
185
  });
215
186
  }
216
187
  // Every probe below is best-effort BY DESIGN: spawn is the authority on whether the
@@ -297,22 +268,14 @@ const VALIDATOR_COMMANDS = new Set([
297
268
  "install",
298
269
  "update",
299
270
  ]);
300
- // Legacy emptiness semantics exactly: empty string means unset, whitespace-only
301
- // counts as set. No trimming — a trim would silently change legacy behaviour.
302
- export function bothConfigured(env) {
303
- return ((env.SUPERPOWERS_VALIDATOR || "").length > 0 &&
304
- (env.SUPERPOWERS_VALIDATOR_EXECUTABLE || "").length > 0);
305
- }
306
271
  // The second exported accessor preflight derives its enforcement from. It is an
307
272
  // accessor rather than an inline check because an accessor that under-reports what
308
273
  // preflight enforces is the blind spot slice 2 closed.
309
274
  export function configurationErrors(cmd, env) {
310
- if (!VALIDATOR_COMMANDS.has(cmd))
311
- return [];
312
- if (!bothConfigured(env))
275
+ if (!VALIDATOR_COMMANDS.has(cmd) || !env.SUPERPOWERS_VALIDATOR)
313
276
  return [];
314
277
  return [
315
- "SUPERPOWERS_VALIDATOR and SUPERPOWERS_VALIDATOR_EXECUTABLE are both set — " +
316
- "unset one; the manager never chooses between them silently",
278
+ "SUPERPOWERS_VALIDATOR has been removed; unset it and configure " +
279
+ "SUPERPOWERS_VALIDATOR_EXECUTABLE with an executable validator.",
317
280
  ];
318
281
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superpowers-manager",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Install obra/superpowers directly from upstream for Codex and Pi. Stay on a known-good release or commit, try a branch, and upgrade when you choose.",
5
5
  "keywords": [
6
6
  "superpowers",
@@ -1,85 +0,0 @@
1
- import { realpathSync } from "node:fs";
2
- import { writeFile } from "node:fs/promises";
3
- import { displaySource, normalizePinnedArguments, normalizeSaved, validateSource, } from "./selection.js";
4
- import { readSelectionState, writeSelectionState } from "./selection-store.js";
5
- import { oneLine, parseFlags, UsageError } from "./cli-arguments.js";
6
- const COMMAND_FLAGS = {
7
- read: ["path", "output"],
8
- "write-pinned": ["path", "source", "requested-ref", "resolved-ref", "commit"],
9
- "write-track-latest": ["path", "source"],
10
- "validate-source": ["source"],
11
- "display-source": ["source"],
12
- };
13
- function parseCommand(argv) {
14
- const [name, ...rest] = argv;
15
- if (name === undefined)
16
- throw new UsageError("a subcommand is required");
17
- if (!Object.hasOwn(COMMAND_FLAGS, name)) {
18
- throw new UsageError(`unknown subcommand: ${name}`);
19
- }
20
- const command = name;
21
- return { name: command, flags: parseFlags(rest, COMMAND_FLAGS[command]) };
22
- }
23
- async function dispatch({ name, flags }) {
24
- if (name === "read") {
25
- const normalized = normalizeSaved(await readSelectionState(flags.path));
26
- try {
27
- await writeFile(flags.output, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
28
- }
29
- catch (cause) {
30
- const message = cause instanceof Error ? cause.message : String(cause);
31
- throw new Error(`cannot write normalized selection output ${flags.output}: ${message}`, { cause });
32
- }
33
- return;
34
- }
35
- if (name === "write-pinned") {
36
- await writeSelectionState(flags.path, normalizePinnedArguments({
37
- source: flags.source,
38
- requestedRef: flags["requested-ref"],
39
- resolvedRef: flags["resolved-ref"],
40
- commit: flags.commit,
41
- }));
42
- return;
43
- }
44
- if (name === "write-track-latest") {
45
- await writeSelectionState(flags.path, {
46
- schema_version: 1,
47
- mode: "track-latest",
48
- source: flags.source,
49
- });
50
- return;
51
- }
52
- if (name === "validate-source") {
53
- validateSource(flags.source);
54
- return;
55
- }
56
- if (name === "display-source") {
57
- process.stdout.write(`${displaySource(flags.source)}\n`);
58
- return;
59
- }
60
- const unreachable = name;
61
- throw new Error(`unhandled subcommand: ${String(unreachable)}`);
62
- }
63
- export async function runSelectionStateCli(argv) {
64
- let command;
65
- try {
66
- command = parseCommand(argv);
67
- }
68
- catch (cause) {
69
- process.stderr.write(`usage: selection-state-cli <subcommand> [options]\n`);
70
- process.stderr.write(`error: ${oneLine(cause)}\n`);
71
- return 2;
72
- }
73
- try {
74
- await dispatch(command);
75
- return 0;
76
- }
77
- catch (cause) {
78
- process.stderr.write(`error: ${oneLine(cause)}\n`);
79
- return 1;
80
- }
81
- }
82
- const entry = process.argv[1];
83
- if (entry !== undefined && import.meta.filename === realpathSync(entry)) {
84
- process.exitCode = await runSelectionStateCli(process.argv.slice(2));
85
- }