superpowers-manager 0.7.3 → 0.9.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 (67) hide show
  1. package/README.md +36 -16
  2. package/dist/atomic.js +1 -3
  3. package/dist/cli-arguments.js +4 -1
  4. package/dist/cli.js +14 -43
  5. package/dist/commands/adapter-call.js +24 -0
  6. package/dist/commands/install.js +16 -63
  7. package/dist/commands/mutation.js +5 -5
  8. package/dist/commands/pin.js +10 -5
  9. package/dist/commands/prepare.js +42 -108
  10. package/dist/commands/probe.js +33 -19
  11. package/dist/commands/uninstall.js +18 -46
  12. package/dist/commands/unpin.js +2 -1
  13. package/dist/commands/update.js +8 -29
  14. package/dist/domain/refs.js +0 -3
  15. package/dist/effective-selection.js +11 -15
  16. package/dist/git-tree.js +4 -6
  17. package/dist/harness-command-result.js +36 -5
  18. package/dist/harnesses/claude-code/harness.js +39 -0
  19. package/dist/harnesses/claude-code/install.js +380 -0
  20. package/dist/harnesses/claude-code/native.js +79 -0
  21. package/dist/harnesses/claude-code/paths.js +35 -0
  22. package/dist/harnesses/claude-code/prepare.js +117 -0
  23. package/dist/harnesses/claude-code/presentation.js +25 -0
  24. package/dist/harnesses/claude-code/state.js +253 -0
  25. package/dist/harnesses/codex/adapter.js +27 -68
  26. package/dist/harnesses/codex/conflicts.js +3 -8
  27. package/dist/harnesses/codex/generated-plugin.js +4 -11
  28. package/dist/harnesses/codex/harness.js +4 -26
  29. package/dist/harnesses/codex/hooks.js +11 -27
  30. package/dist/harnesses/codex/lifecycle.js +22 -107
  31. package/dist/harnesses/codex/manifest-overlay.js +9 -27
  32. package/dist/harnesses/codex/paths.js +29 -0
  33. package/dist/harnesses/codex/prepare.js +9 -43
  34. package/dist/harnesses/codex/presentation.js +1 -1
  35. package/dist/harnesses/codex/publication.js +5 -40
  36. package/dist/harnesses/codex/state.js +1 -6
  37. package/dist/harnesses/codex/stored-state.js +1 -6
  38. package/dist/harnesses/opencode/config.js +105 -50
  39. package/dist/harnesses/opencode/discovery.js +11 -15
  40. package/dist/harnesses/opencode/install.js +57 -102
  41. package/dist/harnesses/opencode/native.js +28 -49
  42. package/dist/harnesses/opencode/package.js +45 -67
  43. package/dist/harnesses/opencode/prepare.js +2 -7
  44. package/dist/harnesses/opencode/presentation.js +3 -7
  45. package/dist/harnesses/opencode/state.js +1 -0
  46. package/dist/harnesses/pi/install.js +31 -120
  47. package/dist/harnesses/pi/native.js +9 -34
  48. package/dist/harnesses/pi/package.js +9 -50
  49. package/dist/harnesses/pi/prepare.js +3 -8
  50. package/dist/harnesses/pi/presentation.js +2 -7
  51. package/dist/harnesses/pi/settings.js +5 -9
  52. package/dist/harnesses/pi/shared-skills.js +23 -53
  53. package/dist/harnesses/pi/state.js +3 -10
  54. package/dist/provenance.js +3 -19
  55. package/dist/resource-lock.js +11 -16
  56. package/dist/safe-path.js +28 -1
  57. package/dist/selection.js +15 -19
  58. package/dist/snapshot-journal.js +101 -0
  59. package/dist/snapshot-package.js +62 -1
  60. package/dist/snapshot-prepare.js +9 -2
  61. package/dist/snapshot-presentation.js +9 -0
  62. package/dist/upstream.js +8 -12
  63. package/dist/validator.js +26 -23
  64. package/dist/workspace.js +29 -35
  65. package/package.json +10 -9
  66. package/dist/python-json.js +0 -4
  67. package/dist/upstream-workspace.js +0 -8
@@ -1,36 +1,24 @@
1
- import { mkdir, stat } from "node:fs/promises";
2
- import { dirname, isAbsolute, join, sep } from "node:path";
1
+ import { mkdir } from "node:fs/promises";
2
+ import { dirname, join, sep } from "node:path";
3
3
  import { atomicReplaceDir } from "../atomic.js";
4
4
  import { oneLine } from "../cli-arguments.js";
5
5
  import { computeEffectiveSelection } from "../effective-selection.js";
6
6
  import { runGit } from "../git.js";
7
+ import { isDirectory } from "../safe-path.js";
7
8
  import { SafetyError } from "../safety-error.js";
8
- import { fetchExactCommit, gitSafeSource } from "../upstream.js";
9
- import { upstreamCacheRoot } from "../upstream-workspace.js";
9
+ import { fetchExactCommit, gitSafeSource, upstreamCacheRoot, } from "../upstream.js";
10
10
  import { BOUNDED_EXECUTABLE, launchFailureMessage, resolveValidator, runValidator, displayPath, } from "../validator.js";
11
- import { withWorkspace, workspaceRemovalFailure } from "../workspace.js";
11
+ import { withWorkspaceReporting, } from "../workspace.js";
12
+ import { invoke } from "./adapter-call.js";
12
13
  import { replayOutcome } from "./probe.js";
13
14
  import { runWithMutation } from "./mutation.js";
14
15
  // Every message this module writes is hand-written here. The cause is attached
15
16
  // for debuggability and never reaches a stream: oneLine (src/cli-arguments.ts)
16
17
  // reads .message only. Same arrangement as hookError
17
- // (`src/harnesses/codex/hooks.ts:44::function hookError`).
18
+ // (`src/harnesses/codex/hooks.ts:45::function hookError`).
18
19
  function prepareError(message, cause) {
19
20
  return new SafetyError("prepare", message, { cause });
20
21
  }
21
- // `[ -d ]` — `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:50::if [ -d`. A regular file named `.git` is what a git
22
- // worktree or `clone --separate-git-dir` leaves behind; `-e` would take the
23
- // fetch branch and let git follow its `gitdir:` pointer, where the shell took
24
- // the clone branch. `src/upstream.ts:332::if (!(await isDirectory` makes the
25
- // same distinction.
26
- async function directoryExists(path) {
27
- try {
28
- return (await stat(path)).isDirectory();
29
- }
30
- catch {
31
- return false;
32
- }
33
- }
34
22
  // mkdir throws raw ErrnoExceptions. Every command-owned call goes through here
35
23
  // so the message on the stream is this module's, not Node's.
36
24
  async function owned(message, fn) {
@@ -70,34 +58,13 @@ function disclosureLine(resolution) {
70
58
  return `[superpowers-manager: running external validator ${configured}${via} -> ${displayPath(resolution.resolved)}]\n`;
71
59
  }
72
60
  const NO_VALIDATOR_OUTPUT = { stdout: "", stderr: "" };
73
- function validStagingLeaf(value) {
74
- return (value.length > 0 &&
75
- value !== "." &&
76
- value !== ".." &&
77
- !value.includes("/") &&
78
- !value.includes("\\"));
79
- }
80
- async function gatherPrepare(ctx) {
61
+ async function gatherPrepare(ctx, location) {
81
62
  // Collect outcomes without writing so output failures cannot be classified as
82
63
  // inspection failures. Replay collected outcomes after gathering completes.
83
64
  const env = ctx.env;
84
- const cwd = process.cwd();
85
- const cache = upstreamCacheRoot(ctx.root, env, cwd);
65
+ const cache = upstreamCacheRoot(ctx.root, env);
86
66
  const cacheParent = dirname(cache);
87
67
  const adapterContext = { root: ctx.root, env };
88
- let location;
89
- try {
90
- location = ctx.adapter.preparationLocation(adapterContext);
91
- }
92
- catch (cause) {
93
- throw prepareError("cannot determine preparation location", cause);
94
- }
95
- if (!isAbsolute(location.destinationRoot)) {
96
- throw prepareError("adapter returned a non-absolute preparation destination");
97
- }
98
- if (!validStagingLeaf(location.stagingLeaf)) {
99
- throw prepareError("adapter returned an invalid preparation staging leaf");
100
- }
101
68
  const pluginRoot = location.destinationRoot;
102
69
  const executableValidator = env.SUPERPOWERS_VALIDATOR_EXECUTABLE || "";
103
70
  const tmpParent = dirname(pluginRoot);
@@ -109,30 +76,14 @@ async function gatherPrepare(ctx) {
109
76
  message,
110
77
  });
111
78
  const selection = ctx.selection ?? (await computeEffectiveSelection(ctx.root, env));
112
- let prefetch;
113
- try {
114
- prefetch = await ctx.adapter.validatePreparationBeforeFetch(adapterContext);
115
- }
116
- catch {
117
- return {
118
- outcome: failed(ctx.adapter.presentation.callFailure("prepare", adapterContext)
119
- .unexpected),
120
- cleanupWarning: null,
121
- };
122
- }
123
- outcomes.push(prefetch.outcome);
124
- if (!prefetch.outcome.ok)
125
- return { outcome: failed(null), cleanupWarning: null };
126
- if (prefetch.status !== 0) {
127
- return {
128
- outcome: failed(ctx.adapter.presentation.callFailure("prepare", adapterContext)
129
- .invalidStatus),
130
- cleanupWarning: null,
131
- };
79
+ // callFailure only formats strings; computing it once up front is safe.
80
+ const failure = ctx.adapter.presentation.callFailure("prepare", adapterContext);
81
+ const prefetch = await invoke(() => ctx.adapter.validatePreparationBeforeFetch(adapterContext), failure, outcomes);
82
+ if (!prefetch.ok) {
83
+ return { value: failed(prefetch.message), cleanupWarning: null };
132
84
  }
133
85
  await owned(`cannot create directory: ${tmpParent}`, () => mkdir(tmpParent, { recursive: true }));
134
- let cleanupWarning = null;
135
- const outcome = await withWorkspace(tmpParent, ".superpowers.prepare.", async (workspace) => {
86
+ return await withWorkspaceReporting(tmpParent, ".superpowers.prepare.", async (workspace) => {
136
87
  const candidate = join(workspace, location.stagingLeaf);
137
88
  await owned(`cannot create directory: ${cacheParent}`, () => mkdir(cacheParent, { recursive: true }));
138
89
  if (selection.selectionMode === "pinned") {
@@ -140,7 +91,12 @@ async function gatherPrepare(ctx) {
140
91
  }
141
92
  else {
142
93
  const source = gitSafeSource(selection.effectiveSource);
143
- if (await directoryExists(join(cache, ".git"))) {
94
+ // `[ -d ]` — `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/prepare:50::if [ -d`. A regular file named `.git` is what a git
95
+ // worktree or `clone --separate-git-dir` leaves behind; `-e` would take the
96
+ // fetch branch and let git follow its `gitdir:` pointer, where the shell took
97
+ // the clone branch. `src/upstream.ts:323::if (!(await isDirectory` makes the
98
+ // same distinction.
99
+ if (await isDirectory(join(cache, ".git"))) {
144
100
  const fetched = await runGit([
145
101
  "-C",
146
102
  cache,
@@ -175,34 +131,23 @@ async function gatherPrepare(ctx) {
175
131
  if (checkedOut.status !== 0) {
176
132
  return failed(`cannot check out upstream commit: ${selection.desiredCommit}`);
177
133
  }
178
- let prepared;
179
- try {
180
- prepared = await ctx.adapter.prepareCandidate({
181
- upstreamRoot: cache,
182
- workspaceRoot: workspace,
183
- candidateRoot: candidate,
184
- selection,
185
- }, adapterContext);
186
- }
187
- catch {
188
- return failed(ctx.adapter.presentation.callFailure("prepare", adapterContext)
189
- .unexpected);
190
- }
191
- outcomes.push(prepared.outcome);
192
- if (prepared.status !== 0 || !prepared.outcome.ok) {
193
- return failed(prepared.outcome.ok
194
- ? ctx.adapter.presentation.callFailure("prepare", adapterContext)
195
- .invalidStatus
196
- : null);
197
- }
198
- if (prepared.outcome.result.root !== candidate) {
134
+ const prepared = await invoke(() => ctx.adapter.prepareCandidate({
135
+ upstreamRoot: cache,
136
+ workspaceRoot: workspace,
137
+ candidateRoot: candidate,
138
+ selection,
139
+ }, adapterContext), failure, outcomes);
140
+ if (!prepared.ok)
141
+ return failed(prepared.message);
142
+ const candidateResult = prepared.result.outcome.result;
143
+ if (candidateResult.root !== candidate) {
199
144
  return failed("adapter returned an unexpected preparation root");
200
145
  }
201
- if (prepared.outcome.result.commit !== selection.desiredCommit) {
146
+ if (candidateResult.commit !== selection.desiredCommit) {
202
147
  return failed("adapter returned an unexpected preparation commit");
203
148
  }
204
- if (prepared.outcome.result.compatibility.kind === "unsupported") {
205
- return failed(prepared.outcome.result.compatibility.reason);
149
+ if (candidateResult.compatibility.kind === "unsupported") {
150
+ return failed(candidateResult.compatibility.reason);
206
151
  }
207
152
  let validator = NO_VALIDATOR_OUTPUT;
208
153
  if (executableValidator.length > 0) {
@@ -248,7 +193,7 @@ async function gatherPrepare(ctx) {
248
193
  // the workspace on return, and the candidate lives in it.
249
194
  //
250
195
  // atomicReplaceDir delegates to beginDirectoryPublication, whose outer
251
- // catch (`src/atomic.ts:319::if (cause`) wraps every non-SafetyError
196
+ // catch (`src/atomic.ts:315::if (cause`) wraps every non-SafetyError
252
197
  // into a SafetyError, so the callee owns every failure on this path and
253
198
  // re-emitting its own diagnostic is the sanctioned form of interpolation.
254
199
  // The hand-written prefix carries the live root, which the callee's message
@@ -271,28 +216,17 @@ async function gatherPrepare(ctx) {
271
216
  resolvedRef: selection.resolvedRef,
272
217
  commit: selection.desiredCommit,
273
218
  };
274
- }, {
275
- // Suppresses withWorkspace's throw on a POST-SUCCESS cleanup failure, so
276
- // the PrepareOutcome the callback already computed still reaches
277
- // runPrepare instead of being discarded. The reporter runs synchronously,
278
- // as the option requires (`src/workspace.ts:103-106::Must`).
279
- onCleanupFailure: (path) => {
280
- cleanupWarning = workspaceRemovalFailure(path);
281
- },
282
219
  });
283
- return { outcome, cleanupWarning };
284
220
  }
285
221
  export async function runPrepare(argv, ctx) {
286
- return await runWithMutation("prepare", ctx, async (scoped) => performPrepare(argv, scoped));
222
+ // argv is ignored: scripts/prepare never read "$@". Probe, by contrast,
223
+ // rejects extra arguments in the CLI parser.
224
+ return await runWithMutation("prepare", ctx, async (scoped, location) => performPrepare(scoped, location));
287
225
  }
288
- async function performPrepare(argv, ctx) {
289
- // scripts/prepare never reads "$@", so extra arguments are ignored. This is a
290
- // deliberate asymmetry with probe, whose shell original rejected unknown
291
- // arguments and whose arity therefore moved into parseArgs in slice 2.
292
- void argv;
226
+ async function performPrepare(ctx, location) {
293
227
  let run;
294
228
  try {
295
- run = await gatherPrepare(ctx);
229
+ run = await gatherPrepare(ctx, location);
296
230
  }
297
231
  catch (cause) {
298
232
  // Reader wrappers supply their own diagnostics. The saved-selection read
@@ -302,7 +236,7 @@ async function performPrepare(argv, ctx) {
302
236
  ctx.stderr.write(`error: ${oneLine(cause)}\n`);
303
237
  return 1;
304
238
  }
305
- const { outcome, cleanupWarning } = run;
239
+ const { value: outcome, cleanupWarning } = run;
306
240
  for (const each of outcome.outcomes)
307
241
  replayOutcome(each, ctx);
308
242
  if (outcome.validator.stdout.length > 0) {
@@ -328,7 +262,7 @@ async function performPrepare(argv, ctx) {
328
262
  // completed before cleanup ran, so it is not being reported as unverified
329
263
  // -- but something did still go wrong, and AGENTS.md's fail-closed rule
330
264
  // extends to it. Mirrors
331
- // `src/commands/install.ts:446::if (cleanupWarning`.
265
+ // `src/commands/install.ts:372::if (cleanupWarning`.
332
266
  ctx.stderr.write(`error: ${cleanupWarning}\n`);
333
267
  return 1;
334
268
  }
@@ -1,3 +1,4 @@
1
+ import { isDeepStrictEqual } from "node:util";
1
2
  import { assertFailureWritable, writeAdapterFailure, } from "../adapter-result.js";
2
3
  import { oneLine } from "../cli-arguments.js";
3
4
  import { computeEffectiveSelection } from "../effective-selection.js";
@@ -96,13 +97,17 @@ export async function gatherProbe(ctx) {
96
97
  if (!controlAfter.ok)
97
98
  return { status: 1, outcomes, message: controlAfter.message };
98
99
  const after = await ctx.coordination.observeResources(resources);
99
- if (JSON.stringify(before) !== JSON.stringify(after) ||
100
- JSON.stringify(prepared.result.outcome.result) !==
101
- JSON.stringify(preparedAfter.result.outcome.result) ||
102
- JSON.stringify(installed.result.outcome.result) !==
103
- JSON.stringify(installedAfter.result.outcome.result) ||
104
- JSON.stringify(control.result.outcome.result) !==
105
- JSON.stringify(controlAfter.result.outcome.result)) {
100
+ if (!isDeepStrictEqual([
101
+ before,
102
+ prepared.result.outcome.result,
103
+ installed.result.outcome.result,
104
+ control.result.outcome.result,
105
+ ], [
106
+ after,
107
+ preparedAfter.result.outcome.result,
108
+ installedAfter.result.outcome.result,
109
+ controlAfter.result.outcome.result,
110
+ ])) {
106
111
  return {
107
112
  status: 1,
108
113
  outcomes,
@@ -141,14 +146,10 @@ export async function gatherProbe(ctx) {
141
146
  },
142
147
  };
143
148
  }
144
- export async function runProbe(argv, ctx) {
145
- // The CLI parser rejects these inputs first. Keep this guard for direct
146
- // in-process callers, which do not necessarily pass through that parser.
147
- const porcelain = argv.length === 1 && argv[0] === "--porcelain";
148
- if (!porcelain && argv.length !== 0) {
149
- ctx.stderr.write(PROBE_USAGE);
150
- return 2;
151
- }
149
+ // Runs the probe and reports a failed gather the way every probing command
150
+ // does: `error:` for a throw, replay of every collected outcome, then the
151
+ // command-level message. null means the caller must return 1.
152
+ export async function probeFacts(ctx) {
152
153
  let outcome;
153
154
  try {
154
155
  outcome = await gatherProbe(ctx);
@@ -159,7 +160,7 @@ export async function runProbe(argv, ctx) {
159
160
  // `src/selection-store.ts:116-121::if (cause instanceof SafetyError && cause.module === "selection") {`.
160
161
  // oneLine() bounds any inherited git or filesystem diagnostic to one line.
161
162
  ctx.stderr.write(`error: ${oneLine(cause)}\n`);
162
- return 1;
163
+ return null;
163
164
  }
164
165
  // Replay before reporting a command-level result on either path.
165
166
  for (const each of outcome.outcomes)
@@ -170,9 +171,22 @@ export async function runProbe(argv, ctx) {
170
171
  if (outcome.message !== null) {
171
172
  ctx.stderr.write(`error: ${outcome.message}\n`);
172
173
  }
173
- return 1;
174
+ return null;
174
175
  }
175
- const rendered = ctx.adapter.presentation.renderProbe(outcome.facts);
176
+ return outcome.facts;
177
+ }
178
+ export async function runProbe(argv, ctx) {
179
+ // The CLI parser rejects these inputs first. Keep this guard for direct
180
+ // in-process callers, which do not necessarily pass through that parser.
181
+ const porcelain = argv.length === 1 && argv[0] === "--porcelain";
182
+ if (!porcelain && argv.length !== 0) {
183
+ ctx.stderr.write(PROBE_USAGE);
184
+ return 2;
185
+ }
186
+ const facts = await probeFacts(ctx);
187
+ if (facts === null)
188
+ return 1;
189
+ const rendered = ctx.adapter.presentation.renderProbe(facts);
176
190
  ctx.stdout.write(porcelain ? rendered.porcelain : rendered.human);
177
- return outcome.facts.resourceState === "recovery-required" ? 1 : 0;
191
+ return facts.resourceState === "recovery-required" ? 1 : 0;
178
192
  }
@@ -1,15 +1,9 @@
1
1
  import { tmpdir } from "node:os";
2
2
  import { oneLine } from "../cli-arguments.js";
3
- import { withWorkspace, workspaceRemovalFailure } from "../workspace.js";
4
- import { GatherFailure, callAdapter, } from "./adapter-call.js";
3
+ import { withWorkspaceReporting, } from "../workspace.js";
4
+ import { GatherFailure, invoke, writeOutput } from "./adapter-call.js";
5
5
  import { runWithMutation } from "./mutation.js";
6
6
  import { replayOutcome } from "./probe.js";
7
- async function invoke(call, failure, outcomes) {
8
- const result = await callAdapter(call, failure);
9
- if (result.result !== null)
10
- outcomes.push(result.result.outcome);
11
- return result;
12
- }
13
7
  // Collect outcomes without writing so output failures cannot be classified as
14
8
  // inspection failures. Replay collected outcomes after gathering completes.
15
9
  async function gatherUninstall(ctx) {
@@ -17,9 +11,8 @@ async function gatherUninstall(ctx) {
17
11
  // Kept outside the workspace callback so a workspace failure preserves every
18
12
  // outcome collected before it.
19
13
  const outcomes = [];
20
- let cleanupWarning = null;
21
14
  try {
22
- const outcome = await withWorkspace(parent, "superpowers-manager.uninstall.", async (workspace) => {
15
+ return await withWorkspaceReporting(parent, "superpowers-manager.uninstall.", async (workspace) => {
23
16
  const env = { ...ctx.env, TMPDIR: workspace };
24
17
  const failed = (message) => ({
25
18
  status: 1,
@@ -59,44 +52,30 @@ async function gatherUninstall(ctx) {
59
52
  outcomes,
60
53
  output: ctx.adapter.presentation.renderRemovalCompletion(ownership, removalInput),
61
54
  };
62
- }, {
63
- // Suppresses withWorkspace's throw on a POST-SUCCESS cleanup failure,
64
- // so the UninstallOutcome the callback already computed still comes
65
- // back as `outcome` instead of being discarded. Runs synchronously,
66
- // as the option requires (src/workspace.ts).
67
- onCleanupFailure: (path) => {
68
- cleanupWarning = workspaceRemovalFailure(path);
69
- },
70
55
  });
71
- return { outcome, cleanupWarning };
72
56
  }
73
57
  catch (cause) {
74
- // Reachable only for mkdtemp failure, with nothing collected yet: this
75
- // callback never throws -- every ctx.adapter throw is already caught
76
- // inside invoke(), and presenceFlag/
77
- // reportLegacyState are pure (src/harnesses/codex/lifecycle.ts's header comment) -- so a
78
- // post-success cleanup failure is handled by onCleanupFailure above and
79
- // cannot reach here. Wrapping with `outcomes` anyway keeps the class
80
- // total over its declared contract rather than assuming the callback's
81
- // purity at the throw site.
58
+ // Reachable only for mkdtemp failure, with nothing collected yet: the
59
+ // callback never throws (every ctx.adapter throw is caught inside
60
+ // invoke()), and a post-success cleanup failure comes back as
61
+ // cleanupWarning. Wrapping with `outcomes` anyway keeps the class total
62
+ // over its declared contract rather than assuming the callback's purity at
63
+ // the throw site.
82
64
  throw new GatherFailure("uninstall gather failed", cause, outcomes);
83
65
  }
84
66
  }
85
67
  export async function runUninstall(argv, ctx) {
86
- return await runWithMutation("uninstall", ctx, async (scoped) => performUninstall(argv, scoped));
68
+ // argv is ignored: scripts/uninstall never reads "$@".
69
+ return await runWithMutation("uninstall", ctx, async (scoped) => performUninstall(scoped));
87
70
  }
88
- async function performUninstall(argv, ctx) {
89
- // scripts/uninstall never reads "$@", so extra arguments are silently
90
- // ignored -- the same asymmetry runPrepare documents at its own
91
- // `void argv;`.
92
- void argv;
71
+ async function performUninstall(ctx) {
93
72
  let run;
94
73
  try {
95
74
  run = await gatherUninstall(ctx);
96
75
  }
97
76
  catch (cause) {
98
- // Replay collected outcomes before reporting a gather failure. The
99
- // cleanup reporter retains post-success cleanup failures as data.
77
+ // Replay collected outcomes before reporting a gather failure. A
78
+ // post-success cleanup failure arrives as cleanupWarning, not here.
100
79
  const outcomes = cause instanceof GatherFailure ? cause.outcomes : [];
101
80
  for (const outcome of outcomes)
102
81
  replayOutcome(outcome, ctx);
@@ -104,7 +83,7 @@ async function performUninstall(argv, ctx) {
104
83
  ctx.stderr.write(`error: ${oneLine(inner)}\n`);
105
84
  return 1;
106
85
  }
107
- const { outcome, cleanupWarning } = run;
86
+ const { value: outcome, cleanupWarning } = run;
108
87
  // Replay before reporting a command-level result on either path.
109
88
  for (const each of outcome.outcomes)
110
89
  replayOutcome(each, ctx);
@@ -115,19 +94,12 @@ async function performUninstall(argv, ctx) {
115
94
  if (outcome.message !== null) {
116
95
  ctx.stderr.write(`error: ${outcome.message}\n`);
117
96
  }
118
- if (outcome.output !== null) {
119
- for (const line of outcome.output.stdout)
120
- ctx.stdout.write(`${line}\n`);
121
- for (const line of outcome.output.stderr)
122
- ctx.stderr.write(`${line}\n`);
123
- }
97
+ if (outcome.output !== null)
98
+ writeOutput(outcome.output, ctx);
124
99
  status = 1;
125
100
  }
126
101
  else {
127
- for (const line of outcome.output.stdout)
128
- ctx.stdout.write(`${line}\n`);
129
- for (const line of outcome.output.stderr)
130
- ctx.stderr.write(`${line}\n`);
102
+ writeOutput(outcome.output, ctx);
131
103
  status = 0;
132
104
  }
133
105
  if (cleanupWarning !== null) {
@@ -1,6 +1,7 @@
1
1
  import { lstat, unlink } from "node:fs/promises";
2
2
  import { oneLine } from "../cli-arguments.js";
3
3
  import { selectionStatePath } from "../effective-selection.js";
4
+ import { isErrno } from "../safe-path.js";
4
5
  import { selectionError } from "../selection.js";
5
6
  import { readConfigRef } from "../upstream.js";
6
7
  const NOT_REGULAR = "selection state path is not a regular file; remove it manually after inspecting";
@@ -24,7 +25,7 @@ async function attemptUnpin(ctx) {
24
25
  return await lstat(path);
25
26
  }
26
27
  catch (cause) {
27
- if (cause.code === "ENOENT")
28
+ if (isErrno(cause, "ENOENT"))
28
29
  return null;
29
30
  throw selectionError(`cannot inspect selection state: ${path}`);
30
31
  }
@@ -1,37 +1,16 @@
1
- // Update delegates adapter calls to probe, prepare, and install. Those
2
- // mutating commands own their workspaces, so update opens none itself.
3
- import { oneLine } from "../cli-arguments.js";
4
- import { runInstall, writeOutput } from "./install.js";
5
- import { gatherProbe, replayOutcome } from "./probe.js";
1
+ import { writeOutput } from "./adapter-call.js";
2
+ import { runInstall } from "./install.js";
3
+ import { probeFacts } from "./probe.js";
6
4
  import { runPrepare } from "./prepare.js";
7
5
  import { runWithMutation } from "./mutation.js";
8
6
  export async function runUpdate(argv, ctx) {
9
- return await runWithMutation("update", ctx, async (scoped) => performUpdate(argv, scoped));
7
+ // argv is ignored: scripts/update never reads "$@".
8
+ return await runWithMutation("update", ctx, async (scoped) => performUpdate(scoped));
10
9
  }
11
- async function performUpdate(argv, ctx) {
12
- // scripts/update never reads "$@", so extra arguments are silently
13
- // ignored -- the same asymmetry runPrepare and runInstall document.
14
- void argv;
15
- let probe;
16
- try {
17
- probe = await gatherProbe(ctx);
18
- }
19
- catch (cause) {
20
- // Gathering does not write, so this catch cannot misclassify an output
21
- // failure. oneLine() bounds any inherited diagnostic to one line.
22
- ctx.stderr.write(`error: ${oneLine(cause)}\n`);
10
+ async function performUpdate(ctx) {
11
+ const facts = await probeFacts(ctx);
12
+ if (facts === null)
23
13
  return 1;
24
- }
25
- // Replay first, on both paths, before any decision -- clause 1.
26
- for (const outcome of probe.outcomes)
27
- replayOutcome(outcome, ctx);
28
- if (probe.status === 1) {
29
- if (probe.message !== null) {
30
- ctx.stderr.write(`error: ${probe.message}\n`);
31
- }
32
- return 1;
33
- }
34
- const facts = probe.facts;
35
14
  if (facts.ownership.installEligibility.kind === "blocked") {
36
15
  writeOutput(facts.ownership.installEligibility.output, ctx);
37
16
  return 1;
@@ -12,9 +12,6 @@ export const COMMIT_INPUT_RE = /^[0-9A-Fa-f]{40}$/;
12
12
  export function isTagRef(value) {
13
13
  return TAG_RE.test(value);
14
14
  }
15
- export function isCommit(value) {
16
- return COMMIT_RE.test(value);
17
- }
18
15
  export function normalizeCommitInput(value) {
19
16
  return COMMIT_INPUT_RE.test(value) ? value.toLowerCase() : null;
20
17
  }
@@ -1,5 +1,5 @@
1
1
  // `selectionError` already exists at
2
- // `src/selection.ts:41::export function selectionError` and tags errors with
2
+ // `src/selection.ts:34::export function selectionError` and tags errors with
3
3
  // module "selection". Import it rather than defining a second one.
4
4
  import { normalizeSaved, selectionError, validateSource, } from "./selection.js";
5
5
  import { readSelectionState } from "./selection-store.js";
@@ -67,7 +67,6 @@ export async function computeEffectiveSelection(root, env) {
67
67
  let selectionOrigin;
68
68
  let selectionMode;
69
69
  let requestedRef;
70
- let usesSavedPin = false;
71
70
  if (env.SUPERPOWERS_REF) {
72
71
  selectionOrigin = "environment";
73
72
  selectionMode = "override";
@@ -77,19 +76,6 @@ export async function computeEffectiveSelection(root, env) {
77
76
  selectionOrigin = "user-config";
78
77
  selectionMode = "pinned";
79
78
  requestedRef = saved.saved_requested_ref;
80
- usesSavedPin = true;
81
- }
82
- else if (saved.saved_mode === "track-latest") {
83
- selectionOrigin = "user-config";
84
- selectionMode = "track-latest";
85
- requestedRef = "latest-release";
86
- }
87
- else {
88
- selectionOrigin = "package-default";
89
- selectionMode = "default";
90
- requestedRef = await readConfigRef(root, env);
91
- }
92
- if (usesSavedPin) {
93
79
  return {
94
80
  selectionOrigin,
95
81
  selectionMode,
@@ -104,6 +90,16 @@ export async function computeEffectiveSelection(root, env) {
104
90
  saved,
105
91
  };
106
92
  }
93
+ else if (saved.saved_mode === "track-latest") {
94
+ selectionOrigin = "user-config";
95
+ selectionMode = "track-latest";
96
+ requestedRef = "latest-release";
97
+ }
98
+ else {
99
+ selectionOrigin = "package-default";
100
+ selectionMode = "default";
101
+ requestedRef = await readConfigRef(root, env);
102
+ }
107
103
  const resolution = await resolveRef(effectiveSource, requestedRef);
108
104
  // `Resolution` (src/upstream.ts) is { kind, ref, commit } — the
109
105
  // resolved ref field is named `ref`, not `resolvedRef`.
package/dist/git-tree.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { chmod, mkdir, symlink, writeFile } from "node:fs/promises";
3
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { ARTIFACT_RECEIPT } from "./artifact-tree.js";
6
6
  import { COMMIT_RE } from "./domain/refs.js";
7
- import { assertNoFollowType, assertSymlinkTargetContained, } from "./safe-path.js";
7
+ import { assertNoFollowType, assertSymlinkTargetContained, isContained, } from "./safe-path.js";
8
8
  import { SafetyError } from "./safety-error.js";
9
9
  const exec = promisify(execFile);
10
10
  const DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
@@ -66,13 +66,11 @@ export async function materializeGitTree(repository, commit, destination) {
66
66
  }
67
67
  for (const entry of entries.filter((entry) => entry.mode === "120000")) {
68
68
  const target = DECODER.decode(await gitBytes(repository, ["cat-file", "blob", entry.oid]));
69
- const path = join(destination, entry.path), suffix = relative(resolve(destination), resolve(dirname(path), target));
69
+ const path = join(destination, entry.path);
70
70
  if (!target ||
71
71
  target.includes("\0") ||
72
72
  isAbsolute(target) ||
73
- suffix === ".." ||
74
- suffix.startsWith("../") ||
75
- isAbsolute(suffix))
73
+ !isContained(resolve(destination), resolve(dirname(path), target)))
76
74
  throw new Error("link escape");
77
75
  await symlink(target, path);
78
76
  }