superpowers-manager 0.8.0 → 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.
- package/README.md +36 -16
- package/dist/atomic.js +1 -3
- package/dist/cli-arguments.js +4 -1
- package/dist/cli.js +14 -43
- package/dist/commands/adapter-call.js +24 -0
- package/dist/commands/install.js +16 -63
- package/dist/commands/mutation.js +5 -5
- package/dist/commands/pin.js +10 -5
- package/dist/commands/prepare.js +42 -108
- package/dist/commands/probe.js +33 -19
- package/dist/commands/uninstall.js +18 -46
- package/dist/commands/unpin.js +2 -1
- package/dist/commands/update.js +8 -29
- package/dist/domain/refs.js +0 -3
- package/dist/effective-selection.js +11 -15
- package/dist/git-tree.js +4 -6
- package/dist/harness-command-result.js +33 -3
- package/dist/harnesses/claude-code/harness.js +39 -0
- package/dist/harnesses/claude-code/install.js +380 -0
- package/dist/harnesses/claude-code/native.js +79 -0
- package/dist/harnesses/claude-code/paths.js +35 -0
- package/dist/harnesses/claude-code/prepare.js +117 -0
- package/dist/harnesses/claude-code/presentation.js +25 -0
- package/dist/harnesses/claude-code/state.js +253 -0
- package/dist/harnesses/codex/adapter.js +27 -68
- package/dist/harnesses/codex/conflicts.js +3 -8
- package/dist/harnesses/codex/generated-plugin.js +4 -11
- package/dist/harnesses/codex/harness.js +4 -26
- package/dist/harnesses/codex/hooks.js +11 -27
- package/dist/harnesses/codex/lifecycle.js +22 -107
- package/dist/harnesses/codex/manifest-overlay.js +9 -27
- package/dist/harnesses/codex/paths.js +29 -0
- package/dist/harnesses/codex/prepare.js +9 -43
- package/dist/harnesses/codex/presentation.js +1 -1
- package/dist/harnesses/codex/publication.js +5 -40
- package/dist/harnesses/codex/state.js +1 -6
- package/dist/harnesses/codex/stored-state.js +1 -6
- package/dist/harnesses/opencode/config.js +2 -4
- package/dist/harnesses/opencode/discovery.js +1 -5
- package/dist/harnesses/opencode/install.js +12 -93
- package/dist/harnesses/opencode/native.js +28 -49
- package/dist/harnesses/opencode/package.js +11 -55
- package/dist/harnesses/opencode/prepare.js +2 -7
- package/dist/harnesses/opencode/presentation.js +2 -7
- package/dist/harnesses/pi/install.js +31 -120
- package/dist/harnesses/pi/native.js +9 -34
- package/dist/harnesses/pi/package.js +9 -50
- package/dist/harnesses/pi/prepare.js +3 -8
- package/dist/harnesses/pi/presentation.js +2 -7
- package/dist/harnesses/pi/settings.js +5 -9
- package/dist/harnesses/pi/shared-skills.js +23 -53
- package/dist/harnesses/pi/state.js +3 -10
- package/dist/provenance.js +3 -19
- package/dist/resource-lock.js +11 -16
- package/dist/safe-path.js +28 -1
- package/dist/selection.js +15 -19
- package/dist/snapshot-journal.js +101 -0
- package/dist/snapshot-package.js +62 -1
- package/dist/snapshot-prepare.js +9 -2
- package/dist/snapshot-presentation.js +9 -0
- package/dist/upstream.js +8 -12
- package/dist/validator.js +26 -23
- package/dist/workspace.js +29 -35
- package/package.json +10 -9
- package/dist/python-json.js +0 -4
- 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
|
-
|
|
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
|
|
110
|
+
(await canonicalizeProspectivePath(root)) === canonicalInstalledRoot)
|
|
118
111
|
return false;
|
|
119
112
|
const rootKind = await classifyPathNoFollow(root);
|
|
120
113
|
if (rootKind !== "directory" && rootKind !== "symlink")
|
package/dist/provenance.js
CHANGED
|
@@ -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 {
|
|
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
|
|
100
|
-
return
|
|
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 {
|
package/dist/resource-lock.js
CHANGED
|
@@ -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 (!
|
|
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 (!
|
|
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 (
|
|
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 (
|
|
175
|
+
if (isErrno(cause, "ENOENT")) {
|
|
181
176
|
try {
|
|
182
177
|
await lstat(lock.lockPath);
|
|
183
178
|
}
|
|
184
179
|
catch (again) {
|
|
185
|
-
if (
|
|
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(
|
|
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 (
|
|
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(
|
|
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
|
|
245
|
+
const released = await releaseOwned(entry);
|
|
251
246
|
if (!released.keepHeld) {
|
|
252
|
-
this.#held.delete(entry.
|
|
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
|
|
3
|
-
import {
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
+
}
|
package/dist/snapshot-package.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/snapshot-prepare.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
}
|