superpowers-manager 0.8.0 → 0.9.1

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 (66) 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 +33 -3
  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 +36 -69
  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 +28 -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 +2 -4
  39. package/dist/harnesses/opencode/discovery.js +1 -5
  40. package/dist/harnesses/opencode/install.js +12 -93
  41. package/dist/harnesses/opencode/native.js +28 -49
  42. package/dist/harnesses/opencode/package.js +11 -55
  43. package/dist/harnesses/opencode/prepare.js +2 -7
  44. package/dist/harnesses/opencode/presentation.js +2 -7
  45. package/dist/harnesses/pi/install.js +31 -120
  46. package/dist/harnesses/pi/native.js +9 -34
  47. package/dist/harnesses/pi/package.js +9 -50
  48. package/dist/harnesses/pi/prepare.js +3 -8
  49. package/dist/harnesses/pi/presentation.js +2 -7
  50. package/dist/harnesses/pi/settings.js +5 -9
  51. package/dist/harnesses/pi/shared-skills.js +23 -53
  52. package/dist/harnesses/pi/state.js +3 -10
  53. package/dist/provenance.js +3 -19
  54. package/dist/resource-lock.js +11 -16
  55. package/dist/safe-path.js +28 -1
  56. package/dist/selection.js +15 -19
  57. package/dist/snapshot-journal.js +101 -0
  58. package/dist/snapshot-package.js +62 -1
  59. package/dist/snapshot-prepare.js +9 -2
  60. package/dist/snapshot-presentation.js +9 -0
  61. package/dist/upstream.js +8 -12
  62. package/dist/validator.js +26 -23
  63. package/dist/workspace.js +29 -35
  64. package/package.json +24 -13
  65. package/dist/python-json.js +0 -4
  66. package/dist/upstream-workspace.js +0 -8
@@ -6,7 +6,7 @@ import { observeSnapshot, sameSnapshotSource, } from "../../snapshot-package.js"
6
6
  import { readPiPackageAssessment, readPiReceipt, } from "./package.js";
7
7
  import { piPaths } from "./paths.js";
8
8
  import { readPiSettings, resolveCanonicalPiLocalSource, resolvePiLocalSource, } from "./settings.js";
9
- import { classifyPathNoFollow } from "../../safe-path.js";
9
+ import { canonicalizeProspectivePath, classifyPathNoFollow, } from "../../safe-path.js";
10
10
  // Verified remote spellings only: Pi 0.85.1 accepts http:// as Git. Unprefixed
11
11
  // github: and git+ forms stay local; git:-wrapped aliases and explicit transports
12
12
  // normalize only an exact GitHub authority and repository path (native source
@@ -102,19 +102,12 @@ function isKnownUpstreamPiSource(raw) {
102
102
  }
103
103
  async function observeSettings(paths) {
104
104
  const settings = await readPiSettings(paths.settingsFile, paths.homeDir);
105
- const installedRoot = await resolveCanonicalPiLocalSource(paths.installedRoot, paths.agentDir, paths.homeDir);
106
- let registration = null;
107
- for (const entry of settings.packages) {
108
- if ((await resolveCanonicalPiLocalSource(entry.source, paths.agentDir, paths.homeDir)) === installedRoot) {
109
- registration = entry;
110
- }
111
- }
112
- return { settings, registration };
105
+ return { settings, registration: settings.managerRegistration };
113
106
  }
114
107
  async function isNamedLocalSuperpowers(entry, paths, canonicalInstalledRoot) {
115
108
  const root = resolvePiLocalSource(entry.source, paths.agentDir, paths.homeDir);
116
109
  if (root === null ||
117
- (await resolveCanonicalPiLocalSource(entry.source, paths.agentDir, paths.homeDir)) === canonicalInstalledRoot)
110
+ (await canonicalizeProspectivePath(root)) === canonicalInstalledRoot)
118
111
  return false;
119
112
  const rootKind = await classifyPathNoFollow(root);
120
113
  if (rootKind !== "directory" && rootKind !== "symlink")
@@ -1,7 +1,6 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
- import { join } from "node:path";
3
2
  import { COMMIT_INPUT_RE } from "./domain/refs.js";
4
- import { escapePythonJsonString } from "./python-json.js";
3
+ import { escapeNonAscii } from "./python-json-format.js";
5
4
  import { SafetyError } from "./safety-error.js";
6
5
  import { parseStrictJson, } from "./strict-json.js";
7
6
  const PROVENANCE_STRICT_PROFILE = {
@@ -57,21 +56,6 @@ export async function readGeneratedCommitLenient(path) {
57
56
  return "";
58
57
  }
59
58
  }
60
- // Ported from
61
- // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/lifecycle.sh:28-31::spw_generated_metadata_path(`.
62
- // The path the generated tree's provenance lives at, relative to a package
63
- // root.
64
- export function generatedMetadataPath(root) {
65
- return join(root, "plugins", "superpowers", ".superpowers-upstream.json");
66
- }
67
- // Ported from
68
- // `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/lifecycle.sh:33-37::spw_generated_commit_or_empty`.
69
- // Lenient by design: a missing or malformed generated provenance file yields
70
- // "", which the typed prepared-state flow reports as "needs prepare".
71
- // Aborting here would deny the operator the remediation path.
72
- export async function generatedCommitOrEmpty(root) {
73
- return readGeneratedCommitLenient(generatedMetadataPath(root));
74
- }
75
59
  export async function readCodexBuildSource(path) {
76
60
  try {
77
61
  const parsed = parseStrictJson(await readFile(path), PROVENANCE_CODEX_SOURCE_PROFILE);
@@ -96,8 +80,8 @@ const PROVENANCE_KEYS = [
96
80
  "upstream_manifest_version",
97
81
  ];
98
82
  export function serializeProvenance(record) {
99
- const lines = PROVENANCE_KEYS.map((key) => ` "${key}": "${escapePythonJsonString(record[key])}"`);
100
- return `{\n${lines.join(",\n")}\n}\n`;
83
+ const ordered = Object.fromEntries(PROVENANCE_KEYS.map((key) => [key, record[key]]));
84
+ return `${escapeNonAscii(JSON.stringify(ordered, null, 2))}\n`;
101
85
  }
102
86
  export async function writeProvenance(path, record) {
103
87
  try {
@@ -2,13 +2,8 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
3
  import { lstat, mkdir, open, readFile, realpath, rmdir, unlink, writeFile, } from "node:fs/promises";
4
4
  import { dirname, join } from "node:path";
5
- import { canonicalizeProspectivePath } from "./safe-path.js";
5
+ import { canonicalizeProspectivePath, isErrno } from "./safe-path.js";
6
6
  import { SafetyError } from "./safety-error.js";
7
- function errnoIs(cause, code) {
8
- return (cause instanceof Error &&
9
- "code" in cause &&
10
- cause.code === code);
11
- }
12
7
  async function nearestExistingDirectory(path) {
13
8
  let cursor = path;
14
9
  for (;;) {
@@ -16,7 +11,7 @@ async function nearestExistingDirectory(path) {
16
11
  return await realpath(cursor);
17
12
  }
18
13
  catch (cause) {
19
- if (!errnoIs(cause, "ENOENT"))
14
+ if (!isErrno(cause, "ENOENT"))
20
15
  throw cause;
21
16
  const parent = dirname(cursor);
22
17
  if (parent === cursor)
@@ -55,7 +50,7 @@ async function resourceLock(resource) {
55
50
  return { resource: canonical, lockPath: candidate };
56
51
  }
57
52
  catch (cause) {
58
- if (!errnoIs(cause, "ENOENT")) {
53
+ if (!isErrno(cause, "ENOENT")) {
59
54
  throw new SafetyError("resource-lock", `cannot inspect resource lock: ${canonical}`, { cause });
60
55
  }
61
56
  }
@@ -71,7 +66,7 @@ async function assertNoCompetingResourceLock(lock, held) {
71
66
  await lstat(candidate);
72
67
  }
73
68
  catch (cause) {
74
- if (errnoIs(cause, "ENOENT"))
69
+ if (isErrno(cause, "ENOENT"))
75
70
  continue;
76
71
  throw new SafetyError("resource-lock", `cannot inspect resource lock: ${lock.resource}`, { cause });
77
72
  }
@@ -177,12 +172,12 @@ class FilesystemResourceCoordinator {
177
172
  }
178
173
  }
179
174
  catch (cause) {
180
- if (errnoIs(cause, "ENOENT")) {
175
+ if (isErrno(cause, "ENOENT")) {
181
176
  try {
182
177
  await lstat(lock.lockPath);
183
178
  }
184
179
  catch (again) {
185
- if (errnoIs(again, "ENOENT"))
180
+ if (isErrno(again, "ENOENT"))
186
181
  return observation(this.#held.has(lock.lockPath) ? "uninspectable" : "idle");
187
182
  }
188
183
  }
@@ -206,7 +201,7 @@ class FilesystemResourceCoordinator {
206
201
  const existing = this.#held.get(lock.lockPath);
207
202
  if (existing !== undefined) {
208
203
  existing.count += 1;
209
- acquired.push({ held: existing });
204
+ acquired.push(existing);
210
205
  continue;
211
206
  }
212
207
  const token = randomUUID();
@@ -214,7 +209,7 @@ class FilesystemResourceCoordinator {
214
209
  await mkdir(lock.lockPath);
215
210
  }
216
211
  catch (cause) {
217
- if (errnoIs(cause, "EEXIST")) {
212
+ if (isErrno(cause, "EEXIST")) {
218
213
  throw new SafetyError("resource-lock", `resource is busy: ${lock.resource}`, { cause });
219
214
  }
220
215
  throw new SafetyError("resource-lock", `cannot acquire resource lock: ${lock.resource}`, { cause });
@@ -231,7 +226,7 @@ class FilesystemResourceCoordinator {
231
226
  throw new SafetyError("resource-lock", `cannot record resource lock ownership: ${lock.resource}`, { cause });
232
227
  }
233
228
  this.#held.set(lock.lockPath, held);
234
- acquired.push({ held });
229
+ acquired.push(held);
235
230
  }
236
231
  // A missing ancestor can appear between placement discovery and mkdir,
237
232
  // leaving concurrently acquired markers at different depths. Checking
@@ -247,9 +242,9 @@ class FilesystemResourceCoordinator {
247
242
  }
248
243
  let firstReleaseFailure = null;
249
244
  for (const entry of acquired.reverse()) {
250
- const released = await releaseOwned(entry.held);
245
+ const released = await releaseOwned(entry);
251
246
  if (!released.keepHeld) {
252
- this.#held.delete(entry.held.lockPath);
247
+ this.#held.delete(entry.lockPath);
253
248
  }
254
249
  firstReleaseFailure ??= released.failure;
255
250
  }
package/dist/safe-path.js CHANGED
@@ -1,4 +1,4 @@
1
- import { lstat, readlink, realpath } from "node:fs/promises";
1
+ import { lstat, readlink, realpath, stat } from "node:fs/promises";
2
2
  import { basename, dirname, isAbsolute, relative, resolve, sep, } from "node:path";
3
3
  import { SafetyError } from "./safety-error.js";
4
4
  export function isErrno(value, code) {
@@ -6,6 +6,33 @@ export function isErrno(value, code) {
6
6
  "code" in value &&
7
7
  value.code === code);
8
8
  }
9
+ // Follow symlinks, like `[ -d ]` / `[ -f ]`: a regular file, a broken
10
+ // symlink, or an unreadable path is not a directory (and vice versa), so any
11
+ // stat failure answers false.
12
+ export async function isDirectory(path) {
13
+ try {
14
+ return (await stat(path)).isDirectory();
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ export async function isFile(path) {
21
+ try {
22
+ return (await stat(path)).isFile();
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ /**
29
+ * `ENOENT`/`ENOTDIR` mean *missing*; every other error rejects. This is the
30
+ * only place the absence set is defined.
31
+ */
32
+ export function isAbsenceError(cause) {
33
+ const code = cause?.code;
34
+ return code === "ENOENT" || code === "ENOTDIR";
35
+ }
9
36
  export function isContained(root, candidate) {
10
37
  const suffix = relative(root, candidate);
11
38
  return (suffix === "" ||
package/dist/selection.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { isIP } from "node:net";
2
- import { COMMIT_RE, TAG_RE, normalizeCommitInput } from "./domain/refs.js";
3
- import { escapePythonJsonString } from "./python-json.js";
2
+ import { COMMIT_RE, TAG_RE } from "./domain/refs.js";
3
+ import { escapeNonAscii } from "./python-json-format.js";
4
4
  import { SafetyError } from "./safety-error.js";
5
5
  export function selectionError(message, cause) {
6
6
  return new SafetyError("selection", message, { cause });
@@ -179,22 +179,18 @@ export function normalizeSaved(record) {
179
179
  saved_commit: record.mode === "pinned" ? record.commit : "",
180
180
  };
181
181
  }
182
- export function normalizePinnedArguments(arguments_) {
183
- const requestedRef = normalizeCommitInput(arguments_.requestedRef) ?? arguments_.requestedRef;
184
- const resolvedRef = normalizeCommitInput(arguments_.resolvedRef) ?? arguments_.resolvedRef;
185
- const commit = arguments_.commit.toLowerCase();
186
- return {
187
- schema_version: 1,
188
- mode: "pinned",
189
- source: arguments_.source,
190
- requested_ref: requestedRef,
191
- resolved_ref: resolvedRef,
192
- commit,
193
- };
194
- }
195
182
  export function serializeRecord(record) {
196
- if (record.mode === "track-latest") {
197
- return `{\n "schema_version": 1,\n "mode": "track-latest",\n "source": "${escapePythonJsonString(record.source)}"\n}\n`;
198
- }
199
- return `{\n "schema_version": 1,\n "mode": "pinned",\n "source": "${escapePythonJsonString(record.source)}",\n "requested_ref": "${escapePythonJsonString(record.requested_ref)}",\n "resolved_ref": "${escapePythonJsonString(record.resolved_ref)}",\n "commit": "${escapePythonJsonString(record.commit)}"\n}\n`;
183
+ // Keys are listed here, not taken from the caller's object, so the bytes do
184
+ // not depend on how the record was built.
185
+ const ordered = record.mode === "track-latest"
186
+ ? { schema_version: 1, mode: record.mode, source: record.source }
187
+ : {
188
+ schema_version: 1,
189
+ mode: record.mode,
190
+ source: record.source,
191
+ requested_ref: record.requested_ref,
192
+ resolved_ref: record.resolved_ref,
193
+ commit: record.commit,
194
+ };
195
+ return `${escapeNonAscii(JSON.stringify(ordered, null, 2))}\n`;
200
196
  }
@@ -0,0 +1,101 @@
1
+ import { lstat, open, readdir, readFile, rmdir, unlink, } from "node:fs/promises";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { atomicWriteFile } from "./atomic.js";
4
+ import { assertNoFollowType, classifyPathNoFollow } from "./safe-path.js";
5
+ function sameIdentity(left, right) {
6
+ return left.dev === right.dev && left.ino === right.ino;
7
+ }
8
+ export async function identity(path) {
9
+ await assertNoFollowType(path, ["directory"]);
10
+ return await lstat(path);
11
+ }
12
+ export function journalPath(pending) {
13
+ return join(pending.paths.recoveryRoot, "transaction.json");
14
+ }
15
+ export async function requireNoRecovery(paths) {
16
+ await assertNoFollowType(paths.recoveryRoot, ["missing"]);
17
+ }
18
+ export async function hasRecovery(paths) {
19
+ try {
20
+ return (await classifyPathNoFollow(paths.recoveryRoot)) !== "missing";
21
+ }
22
+ catch {
23
+ return true;
24
+ }
25
+ }
26
+ async function syncDirectoryStrict(path) {
27
+ const directory = await open(path, "r");
28
+ try {
29
+ await directory.sync();
30
+ }
31
+ finally {
32
+ await directory.close();
33
+ }
34
+ }
35
+ export function publicationPaths(canonicalRoot, token) {
36
+ return {
37
+ backup: join(dirname(canonicalRoot), `.${basename(canonicalRoot)}.bak.${token}`),
38
+ stage: join(dirname(canonicalRoot), `.${basename(canonicalRoot)}.stage.${token}`),
39
+ };
40
+ }
41
+ // The first journal is durable before either a staging tree or backup exists.
42
+ export async function createJournal(recoveryRoot, bytes) {
43
+ const path = join(recoveryRoot, "transaction.json");
44
+ const handle = await open(path, "wx", 0o600);
45
+ try {
46
+ await handle.writeFile(bytes);
47
+ await handle.sync();
48
+ }
49
+ finally {
50
+ await handle.close();
51
+ }
52
+ await syncDirectoryStrict(recoveryRoot);
53
+ return await lstat(path);
54
+ }
55
+ export function createSnapshotJournal(rules) {
56
+ const { label } = rules;
57
+ async function requireIdentity(path, expected) {
58
+ if (!sameIdentity(await identity(path), expected))
59
+ throw new Error(`${label} directory changed`);
60
+ }
61
+ async function requireJournal(pending) {
62
+ if ((await rules.validatePaths(pending.paths)) !== pending.canonicalRoot ||
63
+ pending.journal.installedRoot !== pending.canonicalRoot ||
64
+ !/^[a-f0-9]{32}$/u.test(pending.journal.token))
65
+ throw new Error(`${label} recovery root changed`);
66
+ await requireIdentity(pending.paths.recoveryRoot, pending.recoveryIdentity);
67
+ const path = journalPath(pending);
68
+ await assertNoFollowType(path, ["regular-file"]);
69
+ const observed = await lstat(path);
70
+ if (!sameIdentity(observed, pending.journalIdentity) ||
71
+ observed.size > rules.readCap ||
72
+ !(await readFile(path)).equals(rules.serialize(pending.journal)))
73
+ throw new Error(`${label} recovery journal changed`);
74
+ const expected = publicationPaths(pending.canonicalRoot, pending.journal.token);
75
+ if (pending.backup !== expected.backup || pending.stage !== expected.stage)
76
+ throw new Error(`${label} recovery paths changed`);
77
+ }
78
+ async function phase(pending, next, changes = {}) {
79
+ await requireJournal(pending);
80
+ const journal = { ...pending.journal, ...changes, phase: next };
81
+ const bytes = rules.serialize(journal);
82
+ await atomicWriteFile(journalPath(pending), bytes, {
83
+ validate: async (temporary) => {
84
+ if (!(await readFile(temporary)).equals(bytes))
85
+ throw new Error(`${label} journal write changed`);
86
+ },
87
+ });
88
+ await syncDirectoryStrict(pending.paths.recoveryRoot);
89
+ pending.journal = journal;
90
+ pending.journalIdentity = await lstat(journalPath(pending));
91
+ }
92
+ async function retireJournal(pending) {
93
+ await requireJournal(pending);
94
+ if ((await readdir(pending.paths.recoveryRoot)).some((name) => name !== "transaction.json"))
95
+ throw new Error(`unknown ${label} recovery material`);
96
+ await unlink(journalPath(pending));
97
+ await requireIdentity(pending.paths.recoveryRoot, pending.recoveryIdentity);
98
+ await rmdir(pending.paths.recoveryRoot);
99
+ }
100
+ return { requireIdentity, requireJournal, phase, retireJournal };
101
+ }
@@ -1,8 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { lstat } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
- import { addArtifactHashField, digestArtifactTree, readArtifactFile, } from "./artifact-tree.js";
4
+ import { ARTIFACT_DIGEST_RE, ARTIFACT_RECEIPT, addArtifactHashField, digestArtifactTree, readArtifactFile, readArtifactObject, } from "./artifact-tree.js";
5
+ import { COMMIT_RE } from "./domain/refs.js";
5
6
  import { classifyPathNoFollow } from "./safe-path.js";
7
+ import { SafetyError } from "./safety-error.js";
6
8
  import { validateSource } from "./selection.js";
7
9
  const OFFICIAL_SOURCES = new Set([
8
10
  "https://github.com/obra/superpowers",
@@ -76,3 +78,62 @@ export async function requireSnapshotBootstrap(root, relativePath, expectedSize,
76
78
  createHash("sha256").update(bytes).digest("hex") !== expectedDigest)
77
79
  throw new Error("bootstrap implementation");
78
80
  }
81
+ // strictGeneration=false admits a generation on unknown/unsupported
82
+ // compatibility. Only Pi passes false: its readers keep that recorded policy
83
+ // (tests/unit/harnesses/pi/package.test.ts, "receipt readers retain their
84
+ // distinct unsupported-generation policy").
85
+ function isReceiptCompatibility(value, strictGeneration) {
86
+ if (!value || typeof value !== "object" || Array.isArray(value))
87
+ return false;
88
+ const compatibility = value;
89
+ if (typeof compatibility.reason !== "string")
90
+ return false;
91
+ if (compatibility.kind === "supported" ||
92
+ compatibility.kind === "experimental")
93
+ return typeof compatibility.generation === "string";
94
+ return ((compatibility.kind === "unknown" ||
95
+ compatibility.kind === "unsupported") &&
96
+ (!strictGeneration || compatibility.generation === undefined));
97
+ }
98
+ export function createSnapshotReceipts(rules) {
99
+ const module = `${rules.harness}-package`;
100
+ async function readReceipt(root) {
101
+ const path = join(root, ARTIFACT_RECEIPT);
102
+ try {
103
+ const value = await readArtifactObject(root, path);
104
+ if (value.schema !== 1 ||
105
+ value.manager !== "superpowers-manager" ||
106
+ value.harness !== rules.harness ||
107
+ typeof value.source !== "string" ||
108
+ typeof value.commit !== "string" ||
109
+ !COMMIT_RE.test(value.commit) ||
110
+ typeof value.digest !== "string" ||
111
+ !ARTIFACT_DIGEST_RE.test(value.digest) ||
112
+ typeof value.binding !== "string" ||
113
+ !ARTIFACT_DIGEST_RE.test(value.binding))
114
+ throw new Error("receipt fields");
115
+ validateSource(value.source);
116
+ if (snapshotReceiptBinding(value) !==
117
+ value.binding)
118
+ throw new Error("receipt binding");
119
+ if (!isReceiptCompatibility(value.compatibility, rules.strictGeneration))
120
+ throw new Error("receipt compatibility");
121
+ return value;
122
+ }
123
+ catch (cause) {
124
+ throw new SafetyError(module, `invalid ${rules.label} artifact receipt: ${path}`, { cause });
125
+ }
126
+ }
127
+ async function readAssessment(root) {
128
+ const receipt = await readReceipt(root);
129
+ if ((await digestArtifactTree(root)) !== receipt.digest)
130
+ throw new SafetyError(module, `${rules.label} artifact digest mismatch: ${root}`);
131
+ return {
132
+ receipt,
133
+ compatibility: await rules.assessCompatibility(root, {
134
+ effectiveSource: receipt.source,
135
+ }),
136
+ };
137
+ }
138
+ return { readReceipt, readAssessment };
139
+ }
@@ -6,6 +6,10 @@ import { materializeGitTree } from "./git-tree.js";
6
6
  import { classifyPathNoFollow } from "./safe-path.js";
7
7
  import { sameSnapshotSource, snapshotReceiptBinding, } from "./snapshot-package.js";
8
8
  export function createSnapshotPreparation(b) {
9
+ const preparationLocation = (ctx) => ({
10
+ destinationRoot: b.paths(ctx.env ?? {}, process.cwd()).preparedRoot,
11
+ stagingLeaf: "superpowers",
12
+ });
9
13
  const artifactFrom = (root, a) => ({
10
14
  root,
11
15
  commit: a.receipt.commit,
@@ -13,6 +17,7 @@ export function createSnapshotPreparation(b) {
13
17
  compatibility: a.compatibility,
14
18
  });
15
19
  return {
20
+ preparationLocation,
16
21
  async prepareCandidate(input) {
17
22
  try {
18
23
  await materializeGitTree(input.upstreamRoot, input.selection.desiredCommit, input.candidateRoot);
@@ -20,6 +25,8 @@ export function createSnapshotPreparation(b) {
20
25
  if (compatibility.kind === "unsupported" ||
21
26
  compatibility.kind === "unknown")
22
27
  return failureResult("prepare", "unsupported", compatibility.reason, [], []);
28
+ if (b.rewrite !== undefined)
29
+ await b.rewrite(input.candidateRoot, input.selection);
23
30
  const digest = await digestArtifactTree(input.candidateRoot);
24
31
  const identity = {
25
32
  schema: 1,
@@ -42,7 +49,7 @@ export function createSnapshotPreparation(b) {
42
49
  }
43
50
  },
44
51
  async inspectPrepared(selection, ctx) {
45
- const root = b.preparationLocation(ctx).destinationRoot;
52
+ const root = preparationLocation(ctx).destinationRoot;
46
53
  const unknown = {
47
54
  kind: "unknown",
48
55
  reason: `${b.label} prepared compatibility evidence is missing`,
@@ -83,7 +90,7 @@ export function createSnapshotPreparation(b) {
83
90
  }
84
91
  },
85
92
  async readPrepared(ctx) {
86
- const root = b.preparationLocation(ctx).destinationRoot;
93
+ const root = preparationLocation(ctx).destinationRoot;
87
94
  try {
88
95
  const assessment = await b.readAssessment(root);
89
96
  if (assessment.compatibility.kind !== "supported" &&
@@ -42,3 +42,12 @@ export function createSnapshotPresentation(label, renderProbe, wasAbsent) {
42
42
  },
43
43
  };
44
44
  }
45
+ // Each harness chooses its fields and applies its own escaping before calling.
46
+ export function probeOutput(fields) {
47
+ return {
48
+ human: fields
49
+ .map(([key, value]) => `${key.replaceAll("_", " ")}: ${value || "not present"}\n`)
50
+ .join(""),
51
+ porcelain: fields.map(([key, value]) => `${key}=${value}\n`).join(""),
52
+ };
53
+ }
package/dist/upstream.js CHANGED
@@ -1,8 +1,9 @@
1
- import { readFile, stat } from "node:fs/promises";
2
- import { join } from "node:path";
1
+ import { readFile } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
3
  import { COMMIT_INPUT_RE, compareStable, parseStableTag, } from "./domain/refs.js";
4
4
  import { runGit } from "./git.js";
5
5
  import { SafetyError } from "./safety-error.js";
6
+ import { isDirectory } from "./safe-path.js";
6
7
  import { displaySource } from "./selection.js";
7
8
  import { withWorkspace } from "./workspace.js";
8
9
  const UNAVAILABLE_OBJECT_RE = /not our ref|unadvertised object|couldn't find remote ref/;
@@ -35,16 +36,6 @@ function failed(result) {
35
36
  function combined(result) {
36
37
  return `${result.stdout}${result.stderr}`.replace(/\n+$/, "");
37
38
  }
38
- // Mirrors `[ -d "$repository/.git" ]`: a regular file, a broken symlink, or
39
- // an unreadable path is *not* a directory, so the cache is re-initialized.
40
- async function isDirectory(path) {
41
- try {
42
- return (await stat(path)).isDirectory();
43
- }
44
- catch {
45
- return false;
46
- }
47
- }
48
39
  export function parseLsRemote(output) {
49
40
  const entries = [];
50
41
  for (const line of output.split("\n")) {
@@ -251,3 +242,8 @@ export async function fetchExactCommit(source, commit, repository, workspacePare
251
242
  }
252
243
  });
253
244
  }
245
+ // An explicit relative SUPERPOWERS_CACHE_DIR resolves against the
246
+ // invocation's working directory.
247
+ export function upstreamCacheRoot(root, env) {
248
+ return resolve(env.SUPERPOWERS_CACHE_DIR || join(root, ".cache", "upstream"), "superpowers");
249
+ }
package/dist/validator.js CHANGED
@@ -198,14 +198,21 @@ export async function resolveValidator(configured) {
198
198
  // runs whatever PATH selects -- the same divergence that makes realpath unsafe
199
199
  // here.
200
200
  const pathLike = configured.includes(sep);
201
+ if (!pathLike) {
202
+ return {
203
+ configured,
204
+ resolved: null,
205
+ isSymlink: false,
206
+ isDirectory: false,
207
+ exists: false,
208
+ };
209
+ }
201
210
  let isSymlink = false;
202
- if (pathLike) {
203
- try {
204
- isSymlink = (await lstat(configured)).isSymbolicLink();
205
- }
206
- catch {
207
- /* spawn reports the real reason */
208
- }
211
+ try {
212
+ isSymlink = (await lstat(configured)).isSymbolicLink();
213
+ }
214
+ catch {
215
+ /* spawn reports the real reason */
209
216
  }
210
217
  // Resolve ONLY when the configured value contains a path separator -- which is
211
218
  // exactly when spawn bypasses PATH and the two agree about the target. For a
@@ -214,25 +221,21 @@ export async function resolveValidator(configured) {
214
221
  // interpreter and spawn("node") ran another. Resolving a bare name would let the
215
222
  // manager confidently disclose a file it is not about to run.
216
223
  let resolved = null;
217
- if (configured.includes(sep)) {
218
- try {
219
- resolved = await realpath(configured);
220
- }
221
- catch {
222
- /* unresolvable; stays null and is never claimed */
223
- }
224
+ try {
225
+ resolved = await realpath(configured);
226
+ }
227
+ catch {
228
+ /* unresolvable; stays null and is never claimed */
224
229
  }
225
230
  let isDirectory = false;
226
231
  let exists = false;
227
- if (pathLike) {
228
- try {
229
- const s = await stat(configured);
230
- isDirectory = s.isDirectory();
231
- exists = true;
232
- }
233
- catch {
234
- /* spawn reports the real reason */
235
- }
232
+ try {
233
+ const s = await stat(configured);
234
+ isDirectory = s.isDirectory();
235
+ exists = true;
236
+ }
237
+ catch {
238
+ /* spawn reports the real reason */
236
239
  }
237
240
  return { configured, resolved, isSymlink, isDirectory, exists };
238
241
  }