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.
- 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 +36 -5
- 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 +105 -50
- package/dist/harnesses/opencode/discovery.js +11 -15
- package/dist/harnesses/opencode/install.js +57 -102
- package/dist/harnesses/opencode/native.js +28 -49
- package/dist/harnesses/opencode/package.js +45 -67
- package/dist/harnesses/opencode/prepare.js +2 -7
- package/dist/harnesses/opencode/presentation.js +3 -7
- package/dist/harnesses/opencode/state.js +1 -0
- 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
|
@@ -1,51 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { ARTIFACT_DIGEST_RE, ARTIFACT_RECEIPT, digestArtifactTree, readArtifactObject, } from "../../artifact-tree.js";
|
|
3
|
-
import { COMMIT_RE } from "../../domain/refs.js";
|
|
4
|
-
import { SafetyError } from "../../safety-error.js";
|
|
5
|
-
import { validateSource } from "../../selection.js";
|
|
1
|
+
import { createSnapshotReceipts, } from "../../snapshot-package.js";
|
|
6
2
|
import { assessPiCompatibility } from "./compatibility.js";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
typeof value.source !== "string" ||
|
|
16
|
-
typeof value.commit !== "string" ||
|
|
17
|
-
!COMMIT_RE.test(value.commit) ||
|
|
18
|
-
typeof value.digest !== "string" ||
|
|
19
|
-
!ARTIFACT_DIGEST_RE.test(value.digest) ||
|
|
20
|
-
typeof value.binding !== "string" ||
|
|
21
|
-
!ARTIFACT_DIGEST_RE.test(value.binding))
|
|
22
|
-
throw new Error("receipt fields");
|
|
23
|
-
validateSource(value.source);
|
|
24
|
-
if (snapshotReceiptBinding(value) !== value.binding)
|
|
25
|
-
throw new Error("receipt binding");
|
|
26
|
-
const compatibility = value.compatibility;
|
|
27
|
-
if (!compatibility ||
|
|
28
|
-
typeof compatibility !== "object" ||
|
|
29
|
-
Array.isArray(compatibility) ||
|
|
30
|
-
typeof compatibility.reason !== "string" ||
|
|
31
|
-
typeof compatibility.kind !== "string" ||
|
|
32
|
-
!["unknown", "unsupported", "supported", "experimental"].includes(compatibility.kind) ||
|
|
33
|
-
((compatibility.kind === "supported" ||
|
|
34
|
-
compatibility.kind === "experimental") &&
|
|
35
|
-
typeof compatibility.generation !== "string"))
|
|
36
|
-
throw new Error("receipt compatibility");
|
|
37
|
-
return value;
|
|
38
|
-
}
|
|
39
|
-
catch (cause) {
|
|
40
|
-
throw new SafetyError("pi-package", `invalid Pi artifact receipt: ${path}`, { cause });
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
export async function readPiPackageAssessment(root) {
|
|
44
|
-
const receipt = await readPiReceipt(root);
|
|
45
|
-
if ((await digestArtifactTree(root)) !== receipt.digest)
|
|
46
|
-
throw new SafetyError("pi-package", `Pi artifact digest mismatch: ${root}`);
|
|
47
|
-
const compatibility = await assessPiCompatibility(root, {
|
|
48
|
-
effectiveSource: receipt.source,
|
|
49
|
-
});
|
|
50
|
-
return { receipt, compatibility };
|
|
51
|
-
}
|
|
3
|
+
const receipts = createSnapshotReceipts({
|
|
4
|
+
harness: "pi",
|
|
5
|
+
label: "Pi",
|
|
6
|
+
strictGeneration: false,
|
|
7
|
+
assessCompatibility: assessPiCompatibility,
|
|
8
|
+
});
|
|
9
|
+
export const readPiReceipt = receipts.readReceipt;
|
|
10
|
+
export const readPiPackageAssessment = receipts.readAssessment;
|
|
@@ -1,20 +1,15 @@
|
|
|
1
1
|
import { createSnapshotPreparation } from "../../snapshot-prepare.js";
|
|
2
|
-
import { piPaths } from "./paths.js";
|
|
3
2
|
import { assessPiCompatibility } from "./compatibility.js";
|
|
4
3
|
import { readPiPackageAssessment } from "./package.js";
|
|
5
|
-
|
|
6
|
-
return {
|
|
7
|
-
destinationRoot: piPaths(ctx.env ?? {}, process.cwd()).preparedRoot,
|
|
8
|
-
stagingLeaf: "superpowers",
|
|
9
|
-
};
|
|
10
|
-
}
|
|
4
|
+
import { piPaths } from "./paths.js";
|
|
11
5
|
const preparation = createSnapshotPreparation({
|
|
12
6
|
harness: "pi",
|
|
13
7
|
label: "Pi",
|
|
14
|
-
|
|
8
|
+
paths: piPaths,
|
|
15
9
|
assessCompatibility: assessPiCompatibility,
|
|
16
10
|
readAssessment: readPiPackageAssessment,
|
|
17
11
|
});
|
|
12
|
+
export const piPreparationLocation = preparation.preparationLocation;
|
|
18
13
|
export const preparePiCandidate = preparation.prepareCandidate;
|
|
19
14
|
export const inspectPiPrepared = preparation.inspectPrepared;
|
|
20
15
|
export const readPiPrepared = preparation.readPrepared;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createSnapshotPresentation } from "../../snapshot-presentation.js";
|
|
1
|
+
import { createSnapshotPresentation, probeOutput, } from "../../snapshot-presentation.js";
|
|
2
2
|
const renderProbe = (facts) => {
|
|
3
3
|
const fields = [
|
|
4
4
|
["harness", "pi"],
|
|
@@ -14,11 +14,6 @@ const renderProbe = (facts) => {
|
|
|
14
14
|
["compatibility_reason", facts.compatibility.reason],
|
|
15
15
|
["status", facts.status],
|
|
16
16
|
];
|
|
17
|
-
return
|
|
18
|
-
human: fields
|
|
19
|
-
.map(([key, value]) => `${key.replaceAll("_", " ")}: ${value || "not present"}\n`)
|
|
20
|
-
.join(""),
|
|
21
|
-
porcelain: fields.map(([key, value]) => `${key}=${value}\n`).join(""),
|
|
22
|
-
};
|
|
17
|
+
return probeOutput(fields);
|
|
23
18
|
};
|
|
24
19
|
export const piPresentation = createSnapshotPresentation("Pi", renderProbe, (input) => input.receiptDigest === null && input.registrationIdentity === null);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { canonicalizeProspectivePath } from "../../safe-path.js";
|
|
4
|
+
import { canonicalizeProspectivePath, isErrno } from "../../safe-path.js";
|
|
5
5
|
import { SafetyError } from "../../safety-error.js";
|
|
6
6
|
import { parseStrictJson, } from "../../strict-json.js";
|
|
7
7
|
const SETTINGS_PROFILE = {
|
|
@@ -88,12 +88,6 @@ function parsePackage(value, index, path) {
|
|
|
88
88
|
}),
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
|
-
function isMissing(cause) {
|
|
92
|
-
return (typeof cause === "object" &&
|
|
93
|
-
cause !== null &&
|
|
94
|
-
"code" in cause &&
|
|
95
|
-
cause.code === "ENOENT");
|
|
96
|
-
}
|
|
97
91
|
export function resolvePiLocalSource(source, agentDir, homeDir) {
|
|
98
92
|
let normalized = source.trim();
|
|
99
93
|
if (normalized.length === 0 ||
|
|
@@ -127,8 +121,9 @@ export async function readPiSettings(path, homeDir) {
|
|
|
127
121
|
bytes = await readFile(path);
|
|
128
122
|
}
|
|
129
123
|
catch (cause) {
|
|
130
|
-
if (
|
|
131
|
-
return { packages: [] };
|
|
124
|
+
if (isErrno(cause, "ENOENT")) {
|
|
125
|
+
return { packages: [], managerRegistration: null };
|
|
126
|
+
}
|
|
132
127
|
throw new SafetyError("pi-settings", `cannot read Pi settings ${path}`, {
|
|
133
128
|
cause,
|
|
134
129
|
});
|
|
@@ -166,6 +161,7 @@ export async function readPiSettings(path, homeDir) {
|
|
|
166
161
|
}
|
|
167
162
|
return {
|
|
168
163
|
packages,
|
|
164
|
+
managerRegistration: managerRegistrations[0] ?? null,
|
|
169
165
|
...(settings.skills === undefined ? {} : { skills: settings.skills }),
|
|
170
166
|
};
|
|
171
167
|
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { readdir, readFile, readlink, realpath } from "node:fs/promises";
|
|
2
2
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep, } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { classifyPathNoFollow, isContained } from "../../safe-path.js";
|
|
4
|
+
import { classifyPathNoFollow, isContained, } from "../../safe-path.js";
|
|
5
|
+
// Every caller in this module treats a classification failure as its own
|
|
6
|
+
// indeterminate/false outcome; null carries that failure without a try block.
|
|
7
|
+
function classifyOrNull(path) {
|
|
8
|
+
return classifyPathNoFollow(path).catch(() => null);
|
|
9
|
+
}
|
|
5
10
|
const PI_IGNORE_FILES = [".gitignore", ".ignore", ".fdignore"];
|
|
6
11
|
function toPosixPath(path) {
|
|
7
12
|
return path.split(sep).join("/");
|
|
@@ -9,13 +14,9 @@ function toPosixPath(path) {
|
|
|
9
14
|
async function ignoreControlState(directory) {
|
|
10
15
|
for (const filename of PI_IGNORE_FILES) {
|
|
11
16
|
const path = join(directory, filename);
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
kind = await classifyPathNoFollow(path);
|
|
15
|
-
}
|
|
16
|
-
catch {
|
|
17
|
+
const kind = await classifyOrNull(path);
|
|
18
|
+
if (kind === null)
|
|
17
19
|
return "indeterminate";
|
|
18
|
-
}
|
|
19
20
|
if (kind === "missing")
|
|
20
21
|
continue;
|
|
21
22
|
if (kind !== "regular-file")
|
|
@@ -42,13 +43,9 @@ async function collectSharedSkillDirectory(directory) {
|
|
|
42
43
|
return { kind: "indeterminate" };
|
|
43
44
|
}
|
|
44
45
|
const declaredSkill = join(directory, "SKILL.md");
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
declaredKind = await classifyPathNoFollow(declaredSkill);
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
46
|
+
const declaredKind = await classifyOrNull(declaredSkill);
|
|
47
|
+
if (declaredKind === null)
|
|
50
48
|
return { kind: "indeterminate" };
|
|
51
|
-
}
|
|
52
49
|
if (declaredKind === "regular-file") {
|
|
53
50
|
return { kind: "candidates", paths: [declaredSkill] };
|
|
54
51
|
}
|
|
@@ -68,13 +65,9 @@ async function collectSharedSkillDirectory(directory) {
|
|
|
68
65
|
if (entry.name === "SKILL.md")
|
|
69
66
|
return { kind: "indeterminate" };
|
|
70
67
|
const path = join(directory, entry.name);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
kind = await classifyPathNoFollow(path);
|
|
74
|
-
}
|
|
75
|
-
catch {
|
|
68
|
+
const kind = await classifyOrNull(path);
|
|
69
|
+
if (kind === null)
|
|
76
70
|
return { kind: "indeterminate" };
|
|
77
|
-
}
|
|
78
71
|
if (kind === "symlink" || kind === "missing") {
|
|
79
72
|
return { kind: "indeterminate" };
|
|
80
73
|
}
|
|
@@ -96,38 +89,26 @@ async function collectSharedPiSkills(paths) {
|
|
|
96
89
|
const skillsRoot = join(agentsRoot, "skills");
|
|
97
90
|
const sharedRoot = join(skillsRoot, "superpowers");
|
|
98
91
|
for (const directory of [agentsRoot, skillsRoot]) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
kind = await classifyPathNoFollow(directory);
|
|
102
|
-
}
|
|
103
|
-
catch {
|
|
92
|
+
const kind = await classifyOrNull(directory);
|
|
93
|
+
if (kind === null)
|
|
104
94
|
return { kind: "indeterminate" };
|
|
105
|
-
}
|
|
106
95
|
if (kind === "missing")
|
|
107
96
|
return { kind: "none" };
|
|
108
97
|
if (kind !== "directory")
|
|
109
98
|
return { kind: "indeterminate" };
|
|
110
99
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
sharedRootKind = await classifyPathNoFollow(sharedRoot);
|
|
114
|
-
}
|
|
115
|
-
catch {
|
|
100
|
+
const sharedRootKind = await classifyOrNull(sharedRoot);
|
|
101
|
+
if (sharedRootKind === null)
|
|
116
102
|
return { kind: "indeterminate" };
|
|
117
|
-
}
|
|
118
103
|
if (sharedRootKind === "missing")
|
|
119
104
|
return { kind: "none" };
|
|
120
105
|
if ((await ignoreControlState(skillsRoot)) === "indeterminate") {
|
|
121
106
|
return { kind: "indeterminate" };
|
|
122
107
|
}
|
|
123
108
|
const discoveryRootSkill = join(skillsRoot, "SKILL.md");
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
discoveryRootSkillKind = await classifyPathNoFollow(discoveryRootSkill);
|
|
127
|
-
}
|
|
128
|
-
catch {
|
|
109
|
+
const discoveryRootSkillKind = await classifyOrNull(discoveryRootSkill);
|
|
110
|
+
if (discoveryRootSkillKind === null)
|
|
129
111
|
return { kind: "indeterminate" };
|
|
130
|
-
}
|
|
131
112
|
if (discoveryRootSkillKind === "regular-file")
|
|
132
113
|
return { kind: "none" };
|
|
133
114
|
if (discoveryRootSkillKind !== "missing") {
|
|
@@ -248,13 +229,8 @@ async function hasSafeSharedSkillAncestors(path, sharedRoot) {
|
|
|
248
229
|
directories.push(cursor);
|
|
249
230
|
}
|
|
250
231
|
for (const directory of directories) {
|
|
251
|
-
|
|
252
|
-
if ((await classifyPathNoFollow(directory)) !== "directory")
|
|
253
|
-
return false;
|
|
254
|
-
}
|
|
255
|
-
catch {
|
|
232
|
+
if ((await classifyOrNull(directory)) !== "directory")
|
|
256
233
|
return false;
|
|
257
|
-
}
|
|
258
234
|
}
|
|
259
235
|
return true;
|
|
260
236
|
}
|
|
@@ -305,11 +281,8 @@ async function explicitSharedSkillActivities(settings, paths) {
|
|
|
305
281
|
}
|
|
306
282
|
continue;
|
|
307
283
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
lexicalKind = await classifyPathNoFollow(path);
|
|
311
|
-
}
|
|
312
|
-
catch {
|
|
284
|
+
const lexicalKind = await classifyOrNull(path);
|
|
285
|
+
if (lexicalKind === null) {
|
|
313
286
|
if (isContained(sharedRoot, path) || isContained(path, sharedRoot)) {
|
|
314
287
|
indeterminate = true;
|
|
315
288
|
}
|
|
@@ -343,11 +316,8 @@ async function explicitSharedSkillActivities(settings, paths) {
|
|
|
343
316
|
isContained(canonicalPath, canonicalSharedRoot);
|
|
344
317
|
if (!related)
|
|
345
318
|
continue;
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
canonicalKind = await classifyPathNoFollow(canonicalPath);
|
|
349
|
-
}
|
|
350
|
-
catch {
|
|
319
|
+
const canonicalKind = await classifyOrNull(canonicalPath);
|
|
320
|
+
if (canonicalKind === null) {
|
|
351
321
|
indeterminate = true;
|
|
352
322
|
continue;
|
|
353
323
|
}
|
|
@@ -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
|
+
}
|