superpowers-manager 0.5.2 → 0.7.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 +44 -26
- package/dist/adapter-result.js +5 -9
- package/dist/atomic.js +4 -14
- package/dist/cli-arguments.js +1 -27
- package/dist/cli.js +32 -52
- package/dist/commands/adapter-call.js +28 -0
- package/dist/commands/install.js +26 -107
- package/dist/commands/mutation.js +7 -0
- package/dist/commands/pin.js +1 -1
- package/dist/commands/prepare.js +39 -129
- package/dist/commands/probe.js +35 -264
- package/dist/commands/track-latest.js +1 -1
- package/dist/commands/uninstall.js +16 -92
- package/dist/commands/update.js +5 -25
- package/dist/effective-selection.js +7 -9
- package/dist/git-tree.js +85 -0
- package/dist/harness-command-result.js +47 -0
- package/dist/harnesses/codex/adapter.js +231 -510
- package/dist/harnesses/codex/compatibility.js +2 -17
- package/dist/harnesses/codex/conflicts.js +2 -3
- package/dist/harnesses/codex/harness.js +18 -263
- package/dist/harnesses/codex/hooks.js +1 -1
- package/dist/harnesses/codex/json.js +1 -11
- package/dist/harnesses/codex/lifecycle.js +80 -72
- package/dist/harnesses/codex/manifest-overlay.js +23 -18
- package/dist/harnesses/codex/paths.js +14 -29
- package/dist/harnesses/codex/presentation.js +8 -37
- package/dist/harnesses/codex/publication.js +13 -37
- package/dist/harnesses/codex/recovery.js +5 -5
- package/dist/harnesses/codex/stored-state.js +1 -1
- package/dist/harnesses/opencode/config.js +256 -0
- package/dist/harnesses/opencode/discovery.js +587 -0
- package/dist/harnesses/opencode/harness.js +37 -0
- package/dist/harnesses/opencode/install.js +601 -0
- package/dist/harnesses/opencode/native.js +57 -0
- package/dist/harnesses/opencode/package.js +89 -0
- package/dist/harnesses/opencode/paths.js +38 -0
- package/dist/harnesses/opencode/prepare.js +30 -0
- package/dist/harnesses/opencode/presentation.js +28 -0
- package/dist/harnesses/opencode/state.js +174 -0
- package/dist/harnesses/pi/compatibility.js +5 -37
- package/dist/harnesses/pi/install.js +7 -5
- package/dist/harnesses/pi/native.js +3 -47
- package/dist/harnesses/pi/package.js +5 -107
- package/dist/harnesses/pi/prepare.js +13 -104
- package/dist/harnesses/pi/presentation.js +23 -66
- package/dist/harnesses/pi/shared-skills.js +28 -39
- package/dist/harnesses/pi/state.js +7 -27
- package/dist/provenance.js +2 -2
- package/dist/python-json-format.js +0 -69
- package/dist/python-json.js +2 -27
- package/dist/python-text.js +9 -69
- package/dist/safe-path.js +2 -2
- package/dist/selection-store.js +2 -22
- package/dist/snapshot-package.js +78 -0
- package/dist/snapshot-prepare.js +99 -0
- package/dist/snapshot-presentation.js +44 -0
- package/dist/validator.js +39 -73
- package/node_modules/jsonc-parser/CHANGELOG.md +76 -0
- package/node_modules/jsonc-parser/LICENSE.md +21 -0
- package/node_modules/jsonc-parser/README.md +364 -0
- package/node_modules/jsonc-parser/SECURITY.md +41 -0
- package/node_modules/jsonc-parser/lib/esm/impl/edit.js +185 -0
- package/node_modules/jsonc-parser/lib/esm/impl/format.js +261 -0
- package/node_modules/jsonc-parser/lib/esm/impl/parser.js +659 -0
- package/node_modules/jsonc-parser/lib/esm/impl/scanner.js +443 -0
- package/node_modules/jsonc-parser/lib/esm/impl/string-intern.js +29 -0
- package/node_modules/jsonc-parser/lib/esm/main.d.ts +351 -0
- package/node_modules/jsonc-parser/lib/esm/main.js +178 -0
- package/node_modules/jsonc-parser/lib/umd/impl/edit.js +201 -0
- package/node_modules/jsonc-parser/lib/umd/impl/format.js +275 -0
- package/node_modules/jsonc-parser/lib/umd/impl/parser.js +682 -0
- package/node_modules/jsonc-parser/lib/umd/impl/scanner.js +456 -0
- package/node_modules/jsonc-parser/lib/umd/impl/string-intern.js +42 -0
- package/node_modules/jsonc-parser/lib/umd/main.d.ts +351 -0
- package/node_modules/jsonc-parser/lib/umd/main.js +194 -0
- package/node_modules/jsonc-parser/package.json +37 -0
- package/package.json +8 -4
- package/dist/domain/fingerprint.js +0 -4
- package/dist/selection-state-cli.js +0 -85
- package/dist/status.js +0 -24
- package/dist/upstream-cli.js +0 -110
- package/dist/validate-generated-plugin-cli.js +0 -118
package/dist/python-json.js
CHANGED
|
@@ -1,29 +1,4 @@
|
|
|
1
|
+
import { escapeNonAscii } from "./python-json-format.js";
|
|
1
2
|
export function escapePythonJsonString(value) {
|
|
2
|
-
|
|
3
|
-
const shortEscapes = {
|
|
4
|
-
0x08: "\\b",
|
|
5
|
-
0x09: "\\t",
|
|
6
|
-
0x0a: "\\n",
|
|
7
|
-
0x0c: "\\f",
|
|
8
|
-
0x0d: "\\r",
|
|
9
|
-
};
|
|
10
|
-
for (let index = 0; index < value.length; index += 1) {
|
|
11
|
-
const code = value.charCodeAt(index);
|
|
12
|
-
if (code === 0x22) {
|
|
13
|
-
escaped += '\\"';
|
|
14
|
-
}
|
|
15
|
-
else if (code === 0x5c) {
|
|
16
|
-
escaped += "\\\\";
|
|
17
|
-
}
|
|
18
|
-
else if (Object.hasOwn(shortEscapes, code)) {
|
|
19
|
-
escaped += shortEscapes[code];
|
|
20
|
-
}
|
|
21
|
-
else if (code < 0x20 || code >= 0x7f) {
|
|
22
|
-
escaped += `\\u${code.toString(16).padStart(4, "0")}`;
|
|
23
|
-
}
|
|
24
|
-
else {
|
|
25
|
-
escaped += value[index];
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
return escaped;
|
|
3
|
+
return escapeNonAscii(JSON.stringify(value).slice(1, -1));
|
|
29
4
|
}
|
package/dist/python-text.js
CHANGED
|
@@ -1,75 +1,15 @@
|
|
|
1
|
-
//
|
|
2
|
-
// CPython applies them. Deliberately excludes U+FEFF, which JavaScript's
|
|
3
|
-
// `trim()` removes and Python's `strip()` does not.
|
|
4
|
-
const PYTHON_WHITESPACE = new Set([
|
|
5
|
-
"\t",
|
|
6
|
-
"\n",
|
|
7
|
-
"\v",
|
|
8
|
-
"\f",
|
|
9
|
-
"\r",
|
|
10
|
-
" ",
|
|
11
|
-
"\x1c",
|
|
12
|
-
"\x1d",
|
|
13
|
-
"\x1e",
|
|
14
|
-
"\x1f",
|
|
15
|
-
"\x85",
|
|
16
|
-
" ",
|
|
17
|
-
" ",
|
|
18
|
-
" ",
|
|
19
|
-
" ",
|
|
20
|
-
" ",
|
|
21
|
-
" ",
|
|
22
|
-
" ",
|
|
23
|
-
" ",
|
|
24
|
-
" ",
|
|
25
|
-
" ",
|
|
26
|
-
" ",
|
|
27
|
-
" ",
|
|
28
|
-
" ",
|
|
29
|
-
"
",
|
|
30
|
-
"
",
|
|
31
|
-
" ",
|
|
32
|
-
" ",
|
|
33
|
-
" ",
|
|
34
|
-
]);
|
|
35
|
-
// CPython `str.splitlines()` boundaries. `\r\n` is handled as a pair below.
|
|
36
|
-
const PYTHON_LINE_BOUNDARIES = new Set([
|
|
37
|
-
"\n",
|
|
38
|
-
"\r",
|
|
39
|
-
"\v",
|
|
40
|
-
"\f",
|
|
41
|
-
"\x1c",
|
|
42
|
-
"\x1d",
|
|
43
|
-
"\x1e",
|
|
44
|
-
"\x85",
|
|
45
|
-
"
",
|
|
46
|
-
"
",
|
|
47
|
-
]);
|
|
1
|
+
// Match first through last non-whitespace; avoid suffix retries on internal runs.
|
|
48
2
|
export function pythonStrip(value) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
start += 1;
|
|
53
|
-
while (end > start && PYTHON_WHITESPACE.has(value[end - 1]))
|
|
54
|
-
end -= 1;
|
|
55
|
-
return value.slice(start, end);
|
|
3
|
+
return (value.match(
|
|
4
|
+
// oxlint-disable-next-line no-control-regex -- exact CPython whitespace includes C0 separators.
|
|
5
|
+
/[^\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028-\u2029\u202f\u205f\u3000](?:[\s\S]*[^\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028-\u2029\u202f\u205f\u3000])?/)?.[0] ?? "");
|
|
56
6
|
}
|
|
7
|
+
// CRLF is one boundary; a terminating boundary adds no final empty line.
|
|
57
8
|
export function pythonSplitlines(value) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const character = value[index];
|
|
63
|
-
if (!PYTHON_LINE_BOUNDARIES.has(character)) {
|
|
64
|
-
index += 1;
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
lines.push(value.slice(start, index));
|
|
68
|
-
index += character === "\r" && value[index + 1] === "\n" ? 2 : 1;
|
|
69
|
-
start = index;
|
|
70
|
-
}
|
|
71
|
-
if (start < value.length)
|
|
72
|
-
lines.push(value.slice(start));
|
|
9
|
+
// oxlint-disable-next-line no-control-regex -- CPython boundaries include C0 controls.
|
|
10
|
+
const lines = value.split(/\r\n|[\n\r\v\f\u001c-\u001e\u0085\u2028\u2029]/);
|
|
11
|
+
if (lines.at(-1) === "")
|
|
12
|
+
lines.pop();
|
|
73
13
|
return lines;
|
|
74
14
|
}
|
|
75
15
|
/** Python `sorted()` orders by code point; JavaScript's default sort does not. */
|
package/dist/safe-path.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { lstat, readlink, realpath } 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
|
-
function isErrno(value, code) {
|
|
4
|
+
export function isErrno(value, code) {
|
|
5
5
|
return (value instanceof Error &&
|
|
6
6
|
"code" in value &&
|
|
7
7
|
value.code === code);
|
|
8
8
|
}
|
|
9
|
-
function isContained(root, candidate) {
|
|
9
|
+
export function isContained(root, candidate) {
|
|
10
10
|
const suffix = relative(root, candidate);
|
|
11
11
|
return (suffix === "" ||
|
|
12
12
|
(!isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`)));
|
package/dist/selection-store.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
2
|
import { mkdir, open } from "node:fs/promises";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
|
-
import { atomicWriteFile
|
|
4
|
+
import { atomicWriteFile } from "./atomic.js";
|
|
5
5
|
import { classifyPathNoFollow } from "./safe-path.js";
|
|
6
6
|
import { SafetyError } from "./safety-error.js";
|
|
7
7
|
import { selectionError, serializeRecord, validateRecord, } from "./selection.js";
|
|
@@ -132,19 +132,6 @@ async function ensureStateDirectory(path) {
|
|
|
132
132
|
throw selectionError(`selection state directory is not usable: ${path}`);
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
|
-
async function finalStateDiagnostic(path, finalBytes) {
|
|
136
|
-
try {
|
|
137
|
-
const record = finalBytes === undefined
|
|
138
|
-
? await readSelectionState(path)
|
|
139
|
-
: parseRecordBytes(path, finalBytes);
|
|
140
|
-
return record === null
|
|
141
|
-
? "selection state is now absent"
|
|
142
|
-
: `selection state is now ${record.mode}`;
|
|
143
|
-
}
|
|
144
|
-
catch (cause) {
|
|
145
|
-
return `final selection state cannot be validated: ${errorText(cause)}`;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
135
|
export async function writeSelectionState(path, proposed, options = {}) {
|
|
149
136
|
await ensureStateDirectory(dirname(path));
|
|
150
137
|
// Invalid existing state must block overwrite.
|
|
@@ -160,13 +147,6 @@ export async function writeSelectionState(path, proposed, options = {}) {
|
|
|
160
147
|
});
|
|
161
148
|
}
|
|
162
149
|
catch (cause) {
|
|
163
|
-
|
|
164
|
-
? cause.details
|
|
165
|
-
: undefined;
|
|
166
|
-
if (details?.phase !== "post-replacement") {
|
|
167
|
-
throw selectionError(`cannot write selection state: ${causedErrorText(cause)}`, cause);
|
|
168
|
-
}
|
|
169
|
-
const diagnostic = await finalStateDiagnostic(path, details.finalBytes);
|
|
170
|
-
throw selectionError(`cannot complete selection state write: ${causedErrorText(cause)}; ${diagnostic}`, cause);
|
|
150
|
+
throw selectionError(`cannot write selection state: ${causedErrorText(cause)}`, cause);
|
|
171
151
|
}
|
|
172
152
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { addArtifactHashField, digestArtifactTree, readArtifactFile, } from "./artifact-tree.js";
|
|
5
|
+
import { classifyPathNoFollow } from "./safe-path.js";
|
|
6
|
+
import { validateSource } from "./selection.js";
|
|
7
|
+
const OFFICIAL_SOURCES = new Set([
|
|
8
|
+
"https://github.com/obra/superpowers",
|
|
9
|
+
"https://github.com/obra/superpowers.git",
|
|
10
|
+
"ssh://git@github.com/obra/superpowers.git",
|
|
11
|
+
"git@github.com:obra/superpowers.git",
|
|
12
|
+
]);
|
|
13
|
+
export async function observeSnapshot(root, readReceipt) {
|
|
14
|
+
const kind = await classifyPathNoFollow(root);
|
|
15
|
+
if (kind === "missing")
|
|
16
|
+
return { kind: "absent" };
|
|
17
|
+
if (kind !== "directory")
|
|
18
|
+
return { kind: "unverified" };
|
|
19
|
+
try {
|
|
20
|
+
const receipt = await readReceipt(root);
|
|
21
|
+
const digest = await digestArtifactTree(root);
|
|
22
|
+
return receipt.digest === digest
|
|
23
|
+
? { kind: "owned", receipt, digest }
|
|
24
|
+
: { kind: "unverified" };
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return { kind: "unverified" };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function isOfficialSnapshotSource(source) {
|
|
31
|
+
return OFFICIAL_SOURCES.has(source);
|
|
32
|
+
}
|
|
33
|
+
export function sameSnapshotSource(left, right) {
|
|
34
|
+
validateSource(left);
|
|
35
|
+
validateSource(right);
|
|
36
|
+
return (left === right ||
|
|
37
|
+
(isOfficialSnapshotSource(left) && isOfficialSnapshotSource(right)));
|
|
38
|
+
}
|
|
39
|
+
// A consistency checksum, not authentication. A coherent manual rewrite of
|
|
40
|
+
// both receipt and bytes remains possible; installed comparison must still use
|
|
41
|
+
// an independently validated candidate.
|
|
42
|
+
export function snapshotReceiptBinding(receipt) {
|
|
43
|
+
const hash = createHash("sha256");
|
|
44
|
+
for (const field of [
|
|
45
|
+
String(receipt.schema),
|
|
46
|
+
receipt.manager,
|
|
47
|
+
receipt.harness,
|
|
48
|
+
receipt.source,
|
|
49
|
+
receipt.commit,
|
|
50
|
+
receipt.digest,
|
|
51
|
+
])
|
|
52
|
+
addArtifactHashField(hash, Buffer.from(field));
|
|
53
|
+
return hash.digest("hex");
|
|
54
|
+
}
|
|
55
|
+
export function requireNoSnapshotDependencies(pkg) {
|
|
56
|
+
for (const key of [
|
|
57
|
+
"dependencies",
|
|
58
|
+
"optionalDependencies",
|
|
59
|
+
"peerDependencies",
|
|
60
|
+
"bundleDependencies",
|
|
61
|
+
"bundledDependencies",
|
|
62
|
+
]) {
|
|
63
|
+
const value = pkg[key];
|
|
64
|
+
if (value !== undefined &&
|
|
65
|
+
(value === null ||
|
|
66
|
+
typeof value !== "object" ||
|
|
67
|
+
Object.keys(value).length !== 0))
|
|
68
|
+
throw new Error("runtime dependencies");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export async function requireSnapshotBootstrap(root, relativePath, expectedSize, expectedDigest) {
|
|
72
|
+
const bootstrap = join(root, relativePath);
|
|
73
|
+
const bytes = await readArtifactFile(root, bootstrap, expectedSize);
|
|
74
|
+
if ((await lstat(bootstrap)).mode & 0o111 ||
|
|
75
|
+
bytes.length !== expectedSize ||
|
|
76
|
+
createHash("sha256").update(bytes).digest("hex") !== expectedDigest)
|
|
77
|
+
throw new Error("bootstrap implementation");
|
|
78
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { failureResult, successResult, } from "./adapter-result.js";
|
|
4
|
+
import { ARTIFACT_RECEIPT, digestArtifactTree } from "./artifact-tree.js";
|
|
5
|
+
import { materializeGitTree } from "./git-tree.js";
|
|
6
|
+
import { classifyPathNoFollow } from "./safe-path.js";
|
|
7
|
+
import { sameSnapshotSource, snapshotReceiptBinding, } from "./snapshot-package.js";
|
|
8
|
+
export function createSnapshotPreparation(b) {
|
|
9
|
+
const artifactFrom = (root, a) => ({
|
|
10
|
+
root,
|
|
11
|
+
commit: a.receipt.commit,
|
|
12
|
+
identity: a.receipt.digest,
|
|
13
|
+
compatibility: a.compatibility,
|
|
14
|
+
});
|
|
15
|
+
return {
|
|
16
|
+
async prepareCandidate(input) {
|
|
17
|
+
try {
|
|
18
|
+
await materializeGitTree(input.upstreamRoot, input.selection.desiredCommit, input.candidateRoot);
|
|
19
|
+
const compatibility = await b.assessCompatibility(input.candidateRoot, input.selection);
|
|
20
|
+
if (compatibility.kind === "unsupported" ||
|
|
21
|
+
compatibility.kind === "unknown")
|
|
22
|
+
return failureResult("prepare", "unsupported", compatibility.reason, [], []);
|
|
23
|
+
const digest = await digestArtifactTree(input.candidateRoot);
|
|
24
|
+
const identity = {
|
|
25
|
+
schema: 1,
|
|
26
|
+
manager: "superpowers-manager",
|
|
27
|
+
harness: b.harness,
|
|
28
|
+
source: input.selection.effectiveSource,
|
|
29
|
+
commit: input.selection.desiredCommit,
|
|
30
|
+
digest,
|
|
31
|
+
};
|
|
32
|
+
const receipt = {
|
|
33
|
+
...identity,
|
|
34
|
+
binding: snapshotReceiptBinding(identity),
|
|
35
|
+
compatibility,
|
|
36
|
+
};
|
|
37
|
+
await writeFile(join(input.candidateRoot, ARTIFACT_RECEIPT), JSON.stringify(receipt) + "\n", { flag: "wx" });
|
|
38
|
+
return successResult("prepare", artifactFrom(input.candidateRoot, { receipt, compatibility }), []);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return failureResult("prepare", "invalid-package", `cannot prepare ${b.label} artifact: ${input.candidateRoot}`, [], []);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
async inspectPrepared(selection, ctx) {
|
|
45
|
+
const root = b.preparationLocation(ctx).destinationRoot;
|
|
46
|
+
const unknown = {
|
|
47
|
+
kind: "unknown",
|
|
48
|
+
reason: `${b.label} prepared compatibility evidence is missing`,
|
|
49
|
+
};
|
|
50
|
+
try {
|
|
51
|
+
if ((await classifyPathNoFollow(join(root, ARTIFACT_RECEIPT))) ===
|
|
52
|
+
"missing")
|
|
53
|
+
return successResult("inspect-prepared", {
|
|
54
|
+
kind: "needs-prepare",
|
|
55
|
+
observedIdentity: "",
|
|
56
|
+
compatibility: unknown,
|
|
57
|
+
}, []);
|
|
58
|
+
const assessment = await b.readAssessment(root);
|
|
59
|
+
const { receipt, compatibility } = assessment;
|
|
60
|
+
if (receipt.commit !== selection.desiredCommit ||
|
|
61
|
+
!sameSnapshotSource(receipt.source, selection.effectiveSource))
|
|
62
|
+
return successResult("inspect-prepared", {
|
|
63
|
+
kind: "needs-prepare",
|
|
64
|
+
observedIdentity: receipt.digest,
|
|
65
|
+
compatibility: unknown,
|
|
66
|
+
}, []);
|
|
67
|
+
if (compatibility.kind !== "supported" &&
|
|
68
|
+
compatibility.kind !== "experimental")
|
|
69
|
+
return successResult("inspect-prepared", {
|
|
70
|
+
kind: "needs-prepare",
|
|
71
|
+
observedIdentity: receipt.digest,
|
|
72
|
+
compatibility,
|
|
73
|
+
}, []);
|
|
74
|
+
return successResult("inspect-prepared", {
|
|
75
|
+
kind: "current",
|
|
76
|
+
artifact: artifactFrom(root, assessment),
|
|
77
|
+
observedIdentity: receipt.digest,
|
|
78
|
+
compatibility,
|
|
79
|
+
}, []);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return failureResult("inspect-prepared", "invalid-package", `cannot inspect ${b.label} prepared artifact: ${root}`, [], []);
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
async readPrepared(ctx) {
|
|
86
|
+
const root = b.preparationLocation(ctx).destinationRoot;
|
|
87
|
+
try {
|
|
88
|
+
const assessment = await b.readAssessment(root);
|
|
89
|
+
if (assessment.compatibility.kind !== "supported" &&
|
|
90
|
+
assessment.compatibility.kind !== "experimental")
|
|
91
|
+
throw new Error("unsupported profile");
|
|
92
|
+
return successResult("read-prepared", artifactFrom(root, assessment), []);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return failureResult("read-prepared", "invalid-package", `cannot read ${b.label} prepared artifact: ${root}`, [], []);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export function createSnapshotPresentation(label, renderProbe, wasAbsent) {
|
|
2
|
+
return {
|
|
3
|
+
installNotice: "",
|
|
4
|
+
currentNotice: `The Superpowers ${label} snapshot is current.`,
|
|
5
|
+
renderProbe,
|
|
6
|
+
renderInstallVerification(_desired, receipt, inspection) {
|
|
7
|
+
if (receipt.status !== 0 || !receipt.outcome.ok)
|
|
8
|
+
return {
|
|
9
|
+
stdout: [],
|
|
10
|
+
stderr: [
|
|
11
|
+
`error: ${label} activation did not return a verified installation receipt`,
|
|
12
|
+
],
|
|
13
|
+
};
|
|
14
|
+
if (inspection.status !== 0 || !inspection.outcome.ok)
|
|
15
|
+
return receipt.outcome.result.missingVerificationOutput;
|
|
16
|
+
if (inspection.outcome.result.kind === "current")
|
|
17
|
+
return {
|
|
18
|
+
stdout: [
|
|
19
|
+
`Installed the frozen Superpowers ${label} snapshot. Restart ${label} to load it.`,
|
|
20
|
+
],
|
|
21
|
+
stderr: [],
|
|
22
|
+
};
|
|
23
|
+
return inspection.outcome.result.kind === "absent"
|
|
24
|
+
? receipt.outcome.result.missingVerificationOutput
|
|
25
|
+
: receipt.outcome.result.mismatchVerificationOutput;
|
|
26
|
+
},
|
|
27
|
+
renderRemovalCompletion(_ownership, input) {
|
|
28
|
+
return {
|
|
29
|
+
stdout: [
|
|
30
|
+
wasAbsent(input)
|
|
31
|
+
? `No managed Superpowers ${label} installation is present.`
|
|
32
|
+
: `Removed the managed Superpowers ${label} installation. Restart ${label} to load the resulting state.`,
|
|
33
|
+
],
|
|
34
|
+
stderr: [],
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
callFailure(site) {
|
|
38
|
+
return {
|
|
39
|
+
unexpected: `${label} ${site} did not complete`,
|
|
40
|
+
invalidStatus: `${label} ${site} returned an invalid status`,
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
package/dist/validator.js
CHANGED
|
@@ -2,21 +2,16 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { lstat, realpath, stat } from "node:fs/promises";
|
|
3
3
|
import { sep } from "node:path";
|
|
4
4
|
import { hasTerminalControl, pythonUnicodeEscapeBytes, } from "./adapter-result.js";
|
|
5
|
-
// The legacy SUPERPOWERS_VALIDATOR path. Its behaviour is frozen by parity, so it
|
|
6
|
-
// carries no limits at all; see the spec's D2. Bounding it is PR 12's row.
|
|
7
|
-
export const UNBOUNDED_LEGACY = { kind: "unbounded" };
|
|
8
5
|
// SUPERPOWERS_VALIDATOR_EXECUTABLE. 30s is ~270x a measured realistic validator run
|
|
9
6
|
// (0.11s, dominated by interpreter startup); 64 KiB per stream clears a
|
|
10
7
|
// one-diagnostic-line-per-file report (30 KB on a real tree) with headroom.
|
|
11
8
|
export const BOUNDED_EXECUTABLE = {
|
|
12
|
-
kind: "bounded",
|
|
13
9
|
timeoutMs: 30_000,
|
|
14
10
|
graceMs: 2_000,
|
|
15
11
|
drainMs: 200,
|
|
16
12
|
maxBytesPerStream: 64 * 1024,
|
|
17
13
|
};
|
|
18
|
-
// Retains the first `limit` bytes and counts the rest.
|
|
19
|
-
// everything, which is the unbounded policy's contract. push() always consumes the
|
|
14
|
+
// Retains the first `limit` bytes and counts the rest. push() always consumes the
|
|
20
15
|
// whole chunk: ceasing to consume would block the child on a full pipe.
|
|
21
16
|
class Sink {
|
|
22
17
|
chunks = [];
|
|
@@ -27,10 +22,6 @@ class Sink {
|
|
|
27
22
|
this.limit = limit;
|
|
28
23
|
}
|
|
29
24
|
push(chunk) {
|
|
30
|
-
if (this.limit === null) {
|
|
31
|
-
this.chunks.push(chunk);
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
25
|
const room = this.limit - this.kept;
|
|
35
26
|
if (room <= 0) {
|
|
36
27
|
this.dropped += chunk.length;
|
|
@@ -42,21 +33,15 @@ class Sink {
|
|
|
42
33
|
this.dropped += chunk.length - take.length;
|
|
43
34
|
}
|
|
44
35
|
done() {
|
|
45
|
-
// Buffer.toString
|
|
46
|
-
// legacy path's current string accumulator preserves it. Measured:
|
|
47
|
-
// decode(<BOM>hi) is "hi" but toString("utf8") is "\ufeffhi". The legacy path
|
|
48
|
-
// must be byte-identical, so BOTH paths use toString.
|
|
36
|
+
// Buffer.toString preserves a leading BOM; TextDecoder would strip it.
|
|
49
37
|
return {
|
|
50
38
|
text: Buffer.concat(this.chunks).toString("utf8"),
|
|
51
39
|
droppedBytes: this.dropped,
|
|
52
40
|
};
|
|
53
41
|
}
|
|
54
42
|
}
|
|
55
|
-
// Signal the child's whole process group.
|
|
56
|
-
//
|
|
57
|
-
// leads a group of its own and -pid reaches its descendants. The LEGACY path
|
|
58
|
-
// spawns without `detached`, so its child does not lead a group and this
|
|
59
|
-
// function is not for it. Wrapped because the group is already gone in the
|
|
43
|
+
// Signal the child's whole process group. The child leads a group of its own,
|
|
44
|
+
// so -pid reaches descendants. Wrapped because the group is already gone in
|
|
60
45
|
// ordinary race between SIGTERM and SIGKILL.
|
|
61
46
|
function signalGroup(child, signal) {
|
|
62
47
|
if (child.pid === undefined)
|
|
@@ -71,9 +56,8 @@ function signalGroup(child, signal) {
|
|
|
71
56
|
export function runValidator(argv, policy, env, workspace, cwd) {
|
|
72
57
|
return new Promise((resolveRun) => {
|
|
73
58
|
const [command, ...args] = argv;
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
const err = new Sink(limit);
|
|
59
|
+
const out = new Sink(policy.maxBytesPerStream);
|
|
60
|
+
const err = new Sink(policy.maxBytesPerStream);
|
|
77
61
|
let settled = false;
|
|
78
62
|
// Settlement and termination are independent BY CONSTRUCTION, which is why
|
|
79
63
|
// there is no grace-timer handle to clear here: the timedOut settle is nested
|
|
@@ -109,24 +93,22 @@ export function runValidator(argv, policy, env, workspace, cwd) {
|
|
|
109
93
|
// not an unhandled rejection -- which is what this try/catch prevents.
|
|
110
94
|
let child;
|
|
111
95
|
try {
|
|
96
|
+
const childEnvironment = policy.inheritEnvironment === false
|
|
97
|
+
? { ...env, TMPDIR: workspace }
|
|
98
|
+
: { ...process.env, ...env, TMPDIR: workspace };
|
|
112
99
|
child = spawn(command, args, {
|
|
113
|
-
env:
|
|
100
|
+
env: childEnvironment,
|
|
114
101
|
cwd,
|
|
115
102
|
stdio: ["ignore", "pipe", "pipe"],
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
// (measured at 5279 ms against a 300 ms timeout). The LEGACY path must not
|
|
120
|
-
// acquire group leadership: that changes process and session semantics for
|
|
121
|
-
// a path whose whole contract is that it is unchanged.
|
|
122
|
-
detached: policy.kind === "bounded",
|
|
103
|
+
// The child leads its own process group so a timeout can signal the GROUP --
|
|
104
|
+
// signalling the process alone leaves descendants holding inherited pipes.
|
|
105
|
+
detached: true,
|
|
123
106
|
});
|
|
124
107
|
}
|
|
125
108
|
catch (cause) {
|
|
126
109
|
settle({
|
|
127
110
|
kind: "launchFailed",
|
|
128
111
|
errno: cause.code ?? "UNKNOWN",
|
|
129
|
-
cause,
|
|
130
112
|
});
|
|
131
113
|
return;
|
|
132
114
|
}
|
|
@@ -136,7 +118,7 @@ export function runValidator(argv, policy, env, workspace, cwd) {
|
|
|
136
118
|
child.stderr.on("data", (chunk) => err.push(chunk));
|
|
137
119
|
// Both error and close fire on a launch failure, so settle() is idempotent.
|
|
138
120
|
child.on("error", (cause) => {
|
|
139
|
-
settle({ kind: "launchFailed", errno: cause.code ?? "UNKNOWN"
|
|
121
|
+
settle({ kind: "launchFailed", errno: cause.code ?? "UNKNOWN" });
|
|
140
122
|
});
|
|
141
123
|
child.on("close", (code) => {
|
|
142
124
|
// On the timeout path, settling here would resolve BEFORE SIGKILL and the
|
|
@@ -154,12 +136,6 @@ export function runValidator(argv, policy, env, workspace, cwd) {
|
|
|
154
136
|
// may lag it indefinitely. Capture the code here or a successful validator
|
|
155
137
|
// whose descendant lingers would settle with a null code and be rejected.
|
|
156
138
|
lastCode = code;
|
|
157
|
-
// BOUNDED ONLY. The legacy path settles on `close`, exactly as it does today:
|
|
158
|
-
// a validator that backgrounds `(sleep 0.6; echo late) &` has "late" captured
|
|
159
|
-
// under `close` and lost under a 200 ms drain, and that output is captured
|
|
160
|
-
// today.
|
|
161
|
-
if (policy.kind !== "bounded")
|
|
162
|
-
return;
|
|
163
139
|
// The TIMEOUT path owns its own settlement, after the escalation has run.
|
|
164
140
|
if (timedOut)
|
|
165
141
|
return;
|
|
@@ -186,31 +162,29 @@ export function runValidator(argv, policy, env, workspace, cwd) {
|
|
|
186
162
|
});
|
|
187
163
|
}, policy.drainMs);
|
|
188
164
|
});
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
165
|
+
timeoutTimer = setTimeout(() => {
|
|
166
|
+
timedOut = true;
|
|
167
|
+
// Signal the GROUP, not the process: descendants hold the pipes. SIGTERM
|
|
168
|
+
// first, because the contract promises stderr carries the reason, and
|
|
169
|
+
// reading continues through the grace window so the child can print it.
|
|
170
|
+
signalGroup(child, "SIGTERM");
|
|
171
|
+
setTimeout(() => {
|
|
172
|
+
signalGroup(child, "SIGKILL");
|
|
173
|
+
// Settle ONLY after SIGKILL has been sent. An earlier revision settled
|
|
174
|
+
// first and left the escalation on an unref'ed timer, which never fires
|
|
175
|
+
// because the CLI exits as soon as its handler returns -- measured, the
|
|
176
|
+
// manager exited at 146 ms and the descendant survived. The wall-clock
|
|
177
|
+
// bound is therefore timeout + grace + drain, which is still a bound.
|
|
196
178
|
setTimeout(() => {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
afterMs: policy.timeoutMs,
|
|
207
|
-
stdout: out.done(),
|
|
208
|
-
stderr: err.done(),
|
|
209
|
-
});
|
|
210
|
-
}, policy.drainMs);
|
|
211
|
-
}, policy.graceMs);
|
|
212
|
-
}, policy.timeoutMs);
|
|
213
|
-
}
|
|
179
|
+
settle({
|
|
180
|
+
kind: "timedOut",
|
|
181
|
+
afterMs: policy.timeoutMs,
|
|
182
|
+
stdout: out.done(),
|
|
183
|
+
stderr: err.done(),
|
|
184
|
+
});
|
|
185
|
+
}, policy.drainMs);
|
|
186
|
+
}, policy.graceMs);
|
|
187
|
+
}, policy.timeoutMs);
|
|
214
188
|
});
|
|
215
189
|
}
|
|
216
190
|
// Every probe below is best-effort BY DESIGN: spawn is the authority on whether the
|
|
@@ -297,22 +271,14 @@ const VALIDATOR_COMMANDS = new Set([
|
|
|
297
271
|
"install",
|
|
298
272
|
"update",
|
|
299
273
|
]);
|
|
300
|
-
// Legacy emptiness semantics exactly: empty string means unset, whitespace-only
|
|
301
|
-
// counts as set. No trimming — a trim would silently change legacy behaviour.
|
|
302
|
-
export function bothConfigured(env) {
|
|
303
|
-
return ((env.SUPERPOWERS_VALIDATOR || "").length > 0 &&
|
|
304
|
-
(env.SUPERPOWERS_VALIDATOR_EXECUTABLE || "").length > 0);
|
|
305
|
-
}
|
|
306
274
|
// The second exported accessor preflight derives its enforcement from. It is an
|
|
307
275
|
// accessor rather than an inline check because an accessor that under-reports what
|
|
308
276
|
// preflight enforces is the blind spot slice 2 closed.
|
|
309
277
|
export function configurationErrors(cmd, env) {
|
|
310
|
-
if (!VALIDATOR_COMMANDS.has(cmd))
|
|
311
|
-
return [];
|
|
312
|
-
if (!bothConfigured(env))
|
|
278
|
+
if (!VALIDATOR_COMMANDS.has(cmd) || !env.SUPERPOWERS_VALIDATOR)
|
|
313
279
|
return [];
|
|
314
280
|
return [
|
|
315
|
-
"SUPERPOWERS_VALIDATOR
|
|
316
|
-
"
|
|
281
|
+
"SUPERPOWERS_VALIDATOR has been removed; unset it and configure " +
|
|
282
|
+
"SUPERPOWERS_VALIDATOR_EXECUTABLE with an executable validator.",
|
|
317
283
|
];
|
|
318
284
|
}
|