specpi 0.10.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/CHANGELOG.md +150 -0
- package/LICENSE +21 -0
- package/NPM_RELEASE.md +110 -0
- package/README.md +155 -0
- package/SECURITY.md +85 -0
- package/SECURITY_MODEL.md +107 -0
- package/THIRD_PARTY.md +61 -0
- package/browser-runtime/package-lock.json +86 -0
- package/browser-runtime/package.json +15 -0
- package/extensions/browser/core.mjs +306 -0
- package/extensions/browser/index.ts +723 -0
- package/extensions/browser/smoke.mjs +47 -0
- package/extensions/command-guard/bash.mjs +1426 -0
- package/extensions/command-guard/cmd.mjs +369 -0
- package/extensions/command-guard/core.mjs +506 -0
- package/extensions/command-guard/index.ts +634 -0
- package/extensions/command-guard/managed-files.mjs +22 -0
- package/extensions/command-guard/paths.mjs +398 -0
- package/extensions/command-guard/powershell-parser.ps1 +47 -0
- package/extensions/command-guard/powershell.mjs +655 -0
- package/extensions/command-guard/redact.mjs +65 -0
- package/extensions/command-guard/rules.mjs +2557 -0
- package/extensions/command-guard/smoke.mjs +422 -0
- package/extensions/files/core.mjs +422 -0
- package/extensions/files/index.ts +678 -0
- package/extensions/spec/core.mjs +47 -0
- package/extensions/spec.ts +457 -0
- package/extensions/tool-wishlist/capabilities.json +114 -0
- package/extensions/tool-wishlist/core.mjs +1525 -0
- package/extensions/tool-wishlist/index.ts +804 -0
- package/extensions/tool-wishlist/registry.mjs +99 -0
- package/extensions/tool-wishlist/validators.mjs +345 -0
- package/extensions/ui-refresh/index.ts +54 -0
- package/extensions/workflow-controls/challenge.mjs +196 -0
- package/extensions/workflow-controls/experiments.mjs +628 -0
- package/extensions/workflow-controls/index.ts +1144 -0
- package/extensions/workflow-controls/scope.mjs +272 -0
- package/extensions/workflow-controls/smoke.mjs +201 -0
- package/package.json +98 -0
- package/scripts/check-package.mjs +483 -0
- package/scripts/check-pi-package.mjs +223 -0
- package/scripts/check-release-order.mjs +97 -0
- package/scripts/lib.mjs +182 -0
- package/scripts/lock.mjs +122 -0
- package/scripts/specpi.mjs +2037 -0
- package/scripts/verify-artifact.mjs +21 -0
- package/shell/pi-profiles.sh +14 -0
- package/site/logo.svg +9 -0
- package/site/self-improvement-loop-v2.svg +108 -0
- package/skills/donsetch/SKILL.md +76 -0
- package/skills/specpi-improve/SKILL.md +54 -0
- package/specpi +4 -0
- package/specpi.cmd +4 -0
- package/templates/AGENTS.md +23 -0
- package/templates/settings.json +10 -0
- package/themes/specpi-spec.json +96 -0
- package/themes/tea-house.json +89 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import process from "node:process";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
|
+
|
|
10
|
+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const repoRoot = path.resolve(scriptDir, "..");
|
|
12
|
+
const npmCli = process.env.npm_execpath;
|
|
13
|
+
const artifactIndex = process.argv.indexOf("--artifact");
|
|
14
|
+
const artifactPath = artifactIndex >= 0 ? process.argv[artifactIndex + 1] : undefined;
|
|
15
|
+
const piPackage = "@earendil-works/pi-coding-agent";
|
|
16
|
+
const piVersion = "0.84.4";
|
|
17
|
+
const specpiVersion = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")).version;
|
|
18
|
+
const hostPeerPackages = [
|
|
19
|
+
"@earendil-works/pi-ai",
|
|
20
|
+
"@earendil-works/pi-coding-agent",
|
|
21
|
+
"@earendil-works/pi-tui",
|
|
22
|
+
"typebox",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
if ((artifactIndex >= 0 && !artifactPath) || (artifactIndex < 0 && process.argv.length > 2)) {
|
|
26
|
+
throw new Error("Use no arguments, or supply --artifact <tarball>");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!npmCli || !fs.existsSync(npmCli)) {
|
|
30
|
+
throw new Error("Run Pi package validation through npm so npm_execpath identifies the active npm CLI");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function runNode(args, options = {}) {
|
|
34
|
+
const result = spawnSync(process.execPath, args, {
|
|
35
|
+
cwd: options.cwd || repoRoot,
|
|
36
|
+
env: options.env || process.env,
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
windowsHide: true,
|
|
39
|
+
timeout: options.timeout || 300_000,
|
|
40
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
41
|
+
});
|
|
42
|
+
if (result.error) {
|
|
43
|
+
throw result.error;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (result.status !== 0) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`${process.execPath} ${args.join(" ")} failed (${result.status})\n${result.stdout || ""}${result.stderr || ""}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function runNpm(args, options = {}) {
|
|
56
|
+
return runNode([npmCli, ...args], options);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "specpi-pi-package-check-"));
|
|
60
|
+
try {
|
|
61
|
+
const prefix = path.join(temporaryRoot, "prefix");
|
|
62
|
+
const packDirectory = path.join(temporaryRoot, "pack");
|
|
63
|
+
const agentDir = path.join(temporaryRoot, "agent");
|
|
64
|
+
fs.mkdirSync(prefix, { recursive: true });
|
|
65
|
+
fs.mkdirSync(packDirectory, { recursive: true });
|
|
66
|
+
fs.mkdirSync(agentDir, { recursive: true });
|
|
67
|
+
const authPath = path.join(agentDir, "auth.json");
|
|
68
|
+
const authCanary = "{}\n";
|
|
69
|
+
fs.writeFileSync(authPath, authCanary, { mode: 0o600 });
|
|
70
|
+
|
|
71
|
+
const env = {
|
|
72
|
+
...process.env,
|
|
73
|
+
PI_CODING_AGENT_DIR: agentDir,
|
|
74
|
+
npm_config_audit: "false",
|
|
75
|
+
npm_config_fund: "false",
|
|
76
|
+
};
|
|
77
|
+
let tarball;
|
|
78
|
+
let artifactLabel;
|
|
79
|
+
if (artifactPath) {
|
|
80
|
+
tarball = path.resolve(artifactPath);
|
|
81
|
+
artifactLabel = path.basename(tarball);
|
|
82
|
+
} else {
|
|
83
|
+
const packed = runNpm(["pack", "--pack-destination", packDirectory, "--json", "--ignore-scripts"], {
|
|
84
|
+
env,
|
|
85
|
+
});
|
|
86
|
+
const packResult = JSON.parse(packed.stdout)[0];
|
|
87
|
+
assert.ok(packResult?.filename, "npm pack did not report the SpecPi tarball");
|
|
88
|
+
tarball = path.join(packDirectory, packResult.filename);
|
|
89
|
+
artifactLabel = packResult.filename;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
assert.ok(fs.existsSync(tarball), "the SpecPi tarball does not exist");
|
|
93
|
+
|
|
94
|
+
runNpm(
|
|
95
|
+
[
|
|
96
|
+
"install",
|
|
97
|
+
"--global",
|
|
98
|
+
"--prefix",
|
|
99
|
+
prefix,
|
|
100
|
+
"--ignore-scripts",
|
|
101
|
+
"--no-audit",
|
|
102
|
+
"--no-fund",
|
|
103
|
+
`${piPackage}@${piVersion}`,
|
|
104
|
+
],
|
|
105
|
+
{ env, timeout: 600_000 },
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const globalRoot = runNpm(["root", "--global", "--prefix", prefix], { env }).stdout.trim();
|
|
109
|
+
const piRoot = path.join(globalRoot, "@earendil-works", "pi-coding-agent");
|
|
110
|
+
const piManifest = JSON.parse(fs.readFileSync(path.join(piRoot, "package.json"), "utf8"));
|
|
111
|
+
assert.equal(piManifest.version, piVersion);
|
|
112
|
+
const piBin = typeof piManifest.bin === "string" ? piManifest.bin : piManifest.bin?.pi;
|
|
113
|
+
assert.equal(typeof piBin, "string");
|
|
114
|
+
const piCli = path.join(piRoot, piBin);
|
|
115
|
+
assert.ok(fs.existsSync(piCli), "the pinned Pi package did not provide its CLI entrypoint");
|
|
116
|
+
assert.equal(fs.existsSync(path.join(globalRoot, "specpi")), false, "SpecPi shared Pi's npm installation tree");
|
|
117
|
+
|
|
118
|
+
const npmWrapper = path.join(temporaryRoot, "candidate-npm.mjs");
|
|
119
|
+
fs.writeFileSync(
|
|
120
|
+
npmWrapper,
|
|
121
|
+
`import process from "node:process";\n` +
|
|
122
|
+
`import { spawnSync } from "node:child_process";\n` +
|
|
123
|
+
`const npmCli = ${JSON.stringify(npmCli)};\n` +
|
|
124
|
+
`const candidate = ${JSON.stringify(tarball)};\n` +
|
|
125
|
+
`const requested = ${JSON.stringify(`specpi@${specpiVersion}`)};\n` +
|
|
126
|
+
`const args = process.argv.slice(2).map((arg) => arg === requested ? candidate : arg);\n` +
|
|
127
|
+
`const result = spawnSync(process.execPath, [npmCli, ...args], { env: process.env, stdio: "inherit" });\n` +
|
|
128
|
+
`if (result.error) throw result.error;\n` +
|
|
129
|
+
`process.exitCode = result.status ?? 1;\n`,
|
|
130
|
+
);
|
|
131
|
+
fs.writeFileSync(
|
|
132
|
+
path.join(agentDir, "settings.json"),
|
|
133
|
+
`${JSON.stringify({ npmCommand: [process.execPath, npmWrapper] }, null, 2)}\n`,
|
|
134
|
+
{ mode: 0o600 },
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const npmSource = `npm:specpi@${specpiVersion}`;
|
|
138
|
+
runNode([piCli, "install", npmSource], { cwd: temporaryRoot, env });
|
|
139
|
+
const specpiRoot = path.join(agentDir, "npm", "node_modules", "specpi");
|
|
140
|
+
assert.ok(fs.existsSync(path.join(specpiRoot, "package.json")), "Pi did not install the npm package candidate");
|
|
141
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(specpiRoot, "package.json"), "utf8")).version, specpiVersion);
|
|
142
|
+
for (const peer of hostPeerPackages) {
|
|
143
|
+
const peerSegments = peer.split("/");
|
|
144
|
+
assert.equal(
|
|
145
|
+
fs.existsSync(path.join(agentDir, "npm", "node_modules", ...peerSegments)),
|
|
146
|
+
false,
|
|
147
|
+
`Pi installed host peer beside SpecPi: ${peer}`,
|
|
148
|
+
);
|
|
149
|
+
assert.equal(
|
|
150
|
+
fs.existsSync(path.join(specpiRoot, "node_modules", ...peerSegments)),
|
|
151
|
+
false,
|
|
152
|
+
`Pi installed host peer inside SpecPi: ${peer}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const listed = runNode([piCli, "list"], { cwd: temporaryRoot, env });
|
|
157
|
+
assert.match(listed.stdout, /User packages:/);
|
|
158
|
+
assert.match(listed.stdout, new RegExp(`npm:specpi@${specpiVersion.replaceAll(".", "\\.")}`));
|
|
159
|
+
|
|
160
|
+
const probePath = path.join(temporaryRoot, "resource-probe.mjs");
|
|
161
|
+
const piEntryUrl = pathToFileURL(path.join(piRoot, "dist", "index.js")).href;
|
|
162
|
+
fs.writeFileSync(
|
|
163
|
+
probePath,
|
|
164
|
+
`import { DefaultResourceLoader } from ${JSON.stringify(piEntryUrl)};\n` +
|
|
165
|
+
`const loader = new DefaultResourceLoader(${JSON.stringify({ cwd: temporaryRoot, agentDir })});\n` +
|
|
166
|
+
"await loader.reload();\n" +
|
|
167
|
+
"const extensionResult = loader.getExtensions();\n" +
|
|
168
|
+
"console.log('SPECPI_RESOURCE_PROBE=' + JSON.stringify({\n" +
|
|
169
|
+
" extensionPaths: extensionResult.extensions.map((extension) => extension.resolvedPath),\n" +
|
|
170
|
+
" extensionErrors: extensionResult.errors,\n" +
|
|
171
|
+
" skillNames: loader.getSkills().skills.map((skill) => skill.name),\n" +
|
|
172
|
+
" themeNames: loader.getThemes().themes.map((theme) => theme.name),\n" +
|
|
173
|
+
"}));\n",
|
|
174
|
+
);
|
|
175
|
+
const probeResult = runNode([probePath], { cwd: temporaryRoot, env });
|
|
176
|
+
const probeLine = probeResult.stdout.split(/\r?\n/).find((line) => line.startsWith("SPECPI_RESOURCE_PROBE="));
|
|
177
|
+
assert.ok(probeLine, `Pi resource probe did not return structured output:\n${probeResult.stdout}`);
|
|
178
|
+
const resources = JSON.parse(probeLine.slice("SPECPI_RESOURCE_PROBE=".length));
|
|
179
|
+
for (const expected of [
|
|
180
|
+
"/extensions/browser/index.ts",
|
|
181
|
+
"/extensions/command-guard/index.ts",
|
|
182
|
+
"/extensions/files/index.ts",
|
|
183
|
+
"/extensions/spec.ts",
|
|
184
|
+
"/extensions/tool-wishlist/index.ts",
|
|
185
|
+
"/extensions/ui-refresh/index.ts",
|
|
186
|
+
"/extensions/workflow-controls/index.ts",
|
|
187
|
+
]) {
|
|
188
|
+
assert.ok(
|
|
189
|
+
resources.extensionPaths.some((entry) => entry.replaceAll("\\", "/").endsWith(expected)),
|
|
190
|
+
`Pi did not discover ${expected}: ${JSON.stringify(resources)}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
assert.deepEqual(resources.extensionErrors, [], `Pi reported extension load errors: ${JSON.stringify(resources)}`);
|
|
195
|
+
assert.ok(resources.skillNames.includes("specpi-improve"), "Pi did not discover the SpecPi improvement skill");
|
|
196
|
+
assert.ok(resources.skillNames.includes("donsetch"), "Pi did not discover the DonSeTch skill");
|
|
197
|
+
assert.ok(resources.themeNames.includes("specpi-spec"), "Pi did not discover the SpecPi theme");
|
|
198
|
+
assert.ok(resources.themeNames.includes("tea-house"), "Pi did not discover the tea-house theme");
|
|
199
|
+
|
|
200
|
+
const loaded = runNode([piCli, "--offline", "--no-session", "--no-context-files", "--list-models", "gpt"], {
|
|
201
|
+
env,
|
|
202
|
+
timeout: 300_000,
|
|
203
|
+
});
|
|
204
|
+
assert.doesNotMatch(
|
|
205
|
+
`${loaded.stdout}\n${loaded.stderr}`,
|
|
206
|
+
/failed to load|cannot find package|ERR_MODULE_NOT_FOUND/i,
|
|
207
|
+
);
|
|
208
|
+
assert.equal(fs.readFileSync(authPath, "utf8"), authCanary, "Pi package smoke modified authentication state");
|
|
209
|
+
assert.equal(
|
|
210
|
+
fs.existsSync(path.join(specpiRoot, "browser-runtime", "node_modules")),
|
|
211
|
+
false,
|
|
212
|
+
"native package unexpectedly bundled the managed browser runtime",
|
|
213
|
+
);
|
|
214
|
+
const browserCore = await import(pathToFileURL(path.join(specpiRoot, "extensions", "browser", "core.mjs")).href);
|
|
215
|
+
await assert.rejects(
|
|
216
|
+
browserCore.loadBrowserRuntime(path.join(agentDir, "specpi", "browser-runtime")),
|
|
217
|
+
/SpecPi browser runtime is not installed.*Run specpi update/s,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
console.log(`Pi package check passed: ${artifactLabel} loaded through Pi ${piVersion}`);
|
|
221
|
+
} finally {
|
|
222
|
+
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
|
223
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
|
|
4
|
+
function parseVersion(value, label) {
|
|
5
|
+
const match =
|
|
6
|
+
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(
|
|
7
|
+
value || "",
|
|
8
|
+
);
|
|
9
|
+
if (!match) {
|
|
10
|
+
throw new Error(`${label} is not a semantic version: ${value || "<empty>"}`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const prerelease = match[4]?.split(".") || [];
|
|
14
|
+
if (
|
|
15
|
+
prerelease.some((identifier) => /^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith("0"))
|
|
16
|
+
) {
|
|
17
|
+
throw new Error(`${label} has a numeric prerelease identifier with a leading zero: ${value}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
core: match.slice(1, 4),
|
|
22
|
+
prerelease,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function compareNumericIdentifiers(left, right) {
|
|
27
|
+
if (left.length !== right.length) {
|
|
28
|
+
return left.length > right.length ? 1 : -1;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return left === right ? 0 : left > right ? 1 : -1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function compareIdentifiers(left, right) {
|
|
35
|
+
const leftNumeric = /^\d+$/.test(left);
|
|
36
|
+
const rightNumeric = /^\d+$/.test(right);
|
|
37
|
+
if (leftNumeric && rightNumeric) {
|
|
38
|
+
return compareNumericIdentifiers(left, right);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (leftNumeric !== rightNumeric) {
|
|
42
|
+
return leftNumeric ? -1 : 1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return left === right ? 0 : left > right ? 1 : -1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function compareVersions(left, right) {
|
|
49
|
+
for (let index = 0; index < left.core.length; index += 1) {
|
|
50
|
+
const compared = compareNumericIdentifiers(left.core[index], right.core[index]);
|
|
51
|
+
if (compared !== 0) {
|
|
52
|
+
return compared;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (left.prerelease.length === 0 || right.prerelease.length === 0) {
|
|
57
|
+
if (left.prerelease.length === right.prerelease.length) {
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return left.prerelease.length === 0 ? 1 : -1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const length = Math.max(left.prerelease.length, right.prerelease.length);
|
|
65
|
+
for (let index = 0; index < length; index += 1) {
|
|
66
|
+
if (left.prerelease[index] === undefined || right.prerelease[index] === undefined) {
|
|
67
|
+
return left.prerelease[index] === undefined ? -1 : 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const compared = compareIdentifiers(left.prerelease[index], right.prerelease[index]);
|
|
71
|
+
if (compared !== 0) {
|
|
72
|
+
return compared;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const [command, candidateValue, currentValue] = process.argv.slice(2);
|
|
80
|
+
try {
|
|
81
|
+
const candidate = parseVersion(candidateValue, "candidate version");
|
|
82
|
+
if (command === "tag") {
|
|
83
|
+
console.log(candidate.prerelease.length === 0 ? "latest" : "next");
|
|
84
|
+
} else if (command === "advance") {
|
|
85
|
+
const current = parseVersion(currentValue, "current dist-tag version");
|
|
86
|
+
if (compareVersions(candidate, current) <= 0) {
|
|
87
|
+
throw new Error(`candidate ${candidateValue} would not advance its dist-tag from ${currentValue}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
console.log(`Release order check passed: ${candidateValue} advances its dist-tag from ${currentValue}`);
|
|
91
|
+
} else {
|
|
92
|
+
throw new Error("Usage: check-release-order.mjs tag <version> | advance <candidate> <current>");
|
|
93
|
+
}
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
96
|
+
process.exitCode = 1;
|
|
97
|
+
}
|
package/scripts/lib.mjs
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
|
+
|
|
4
|
+
export const AGENTS_START = "<!-- specpi:start -->";
|
|
5
|
+
export const AGENTS_END = "<!-- specpi:end -->";
|
|
6
|
+
export const SHELL_START = "# >>> SpecPi >>>";
|
|
7
|
+
export const SHELL_END = "# <<< SpecPi <<<";
|
|
8
|
+
|
|
9
|
+
export function sha256(data) {
|
|
10
|
+
return createHash("sha256").update(data).digest("hex");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function deepEqual(a, b) {
|
|
14
|
+
return isDeepStrictEqual(a, b);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function packageSource(entry) {
|
|
18
|
+
return typeof entry === "string" ? entry : entry?.source;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function packageIdentity(entry) {
|
|
22
|
+
const source = packageSource(entry);
|
|
23
|
+
if (typeof source !== "string") {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!source.startsWith("npm:")) {
|
|
28
|
+
return source;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const spec = source.slice(4);
|
|
32
|
+
if (spec.startsWith("@")) {
|
|
33
|
+
const slash = spec.indexOf("/");
|
|
34
|
+
const versionAt = slash < 0 ? -1 : spec.indexOf("@", slash);
|
|
35
|
+
|
|
36
|
+
return `npm:${versionAt < 0 ? spec : spec.slice(0, versionAt)}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const versionAt = spec.lastIndexOf("@");
|
|
40
|
+
|
|
41
|
+
return `npm:${versionAt > 0 ? spec.slice(0, versionAt) : spec}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function mergePackages(existing, desired) {
|
|
45
|
+
const result = Array.isArray(existing) ? structuredClone(existing) : [];
|
|
46
|
+
for (const wanted of desired) {
|
|
47
|
+
const identity = packageIdentity(wanted);
|
|
48
|
+
const index = result.findIndex((entry) => packageIdentity(entry) === identity);
|
|
49
|
+
if (index >= 0) {
|
|
50
|
+
result[index] = structuredClone(wanted);
|
|
51
|
+
} else {
|
|
52
|
+
result.push(structuredClone(wanted));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function restorePackageChanges(current, changes, warnings = []) {
|
|
60
|
+
const result = Array.isArray(current) ? structuredClone(current) : [];
|
|
61
|
+
for (const change of changes) {
|
|
62
|
+
const currentIndex = result.findIndex((entry) => packageIdentity(entry) === change.identity);
|
|
63
|
+
if (currentIndex < 0 || !deepEqual(result[currentIndex], change.installed)) {
|
|
64
|
+
warnings.push(`Preserved modified package setting: ${change.identity}`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (change.beforeExists) {
|
|
69
|
+
result[currentIndex] = structuredClone(change.before);
|
|
70
|
+
} else {
|
|
71
|
+
result.splice(currentIndex, 1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function markerRange(text, start, end) {
|
|
79
|
+
const startIndex = text.indexOf(start);
|
|
80
|
+
const endIndex = text.indexOf(end);
|
|
81
|
+
if (startIndex < 0 !== endIndex < 0) {
|
|
82
|
+
throw new Error(`Malformed managed block: expected both ${start} and ${end}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (startIndex < 0) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (text.indexOf(start, startIndex + start.length) >= 0 || text.indexOf(end, endIndex + end.length) >= 0) {
|
|
90
|
+
throw new Error(`Malformed managed block: duplicate ${start} or ${end}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (endIndex < startIndex) {
|
|
94
|
+
throw new Error(`Malformed managed block: ${end} precedes ${start}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { startIndex, endIndex: endIndex + end.length };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function upsertManagedBlock(text, start, end, body) {
|
|
101
|
+
const normalized = String(text ?? "").replace(/\r\n/g, "\n");
|
|
102
|
+
const block = `${start}\n${String(body).trim()}\n${end}`;
|
|
103
|
+
const range = markerRange(normalized, start, end);
|
|
104
|
+
if (!range) {
|
|
105
|
+
return `${normalized.trimEnd()}${normalized.trim() ? "\n\n" : ""}${block}\n`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const before = normalized.slice(0, range.startIndex).trimEnd();
|
|
109
|
+
const after = normalized.slice(range.endIndex).trimStart();
|
|
110
|
+
|
|
111
|
+
return `${before}${before ? "\n\n" : ""}${block}${after ? `\n\n${after}` : "\n"}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function removeManagedBlock(text, start, end) {
|
|
115
|
+
const normalized = String(text ?? "").replace(/\r\n/g, "\n");
|
|
116
|
+
const range = markerRange(normalized, start, end);
|
|
117
|
+
if (!range) {
|
|
118
|
+
return normalized;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const before = normalized.slice(0, range.startIndex).trimEnd();
|
|
122
|
+
const after = normalized.slice(range.endIndex).trimStart();
|
|
123
|
+
if (!before && !after) {
|
|
124
|
+
return "";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return `${before}${before && after ? "\n\n" : ""}${after}\n`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function readPath(object, pathParts) {
|
|
131
|
+
let current = object;
|
|
132
|
+
for (const part of pathParts) {
|
|
133
|
+
if (current === null || typeof current !== "object" || !Object.hasOwn(current, part)) {
|
|
134
|
+
return { exists: false, value: undefined };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
current = current[part];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { exists: true, value: structuredClone(current) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function setPath(object, pathParts, value) {
|
|
144
|
+
let current = object;
|
|
145
|
+
for (const part of pathParts.slice(0, -1)) {
|
|
146
|
+
if (current[part] === null || typeof current[part] !== "object" || Array.isArray(current[part])) {
|
|
147
|
+
current[part] = {};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
current = current[part];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
current[pathParts.at(-1)] = structuredClone(value);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function deletePath(object, pathParts) {
|
|
157
|
+
let current = object;
|
|
158
|
+
const parents = [];
|
|
159
|
+
for (const part of pathParts.slice(0, -1)) {
|
|
160
|
+
if (current === null || typeof current !== "object" || !Object.hasOwn(current, part)) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
parents.push([current, part]);
|
|
165
|
+
current = current[part];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!current || typeof current !== "object") {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
delete current[pathParts.at(-1)];
|
|
173
|
+
|
|
174
|
+
for (const [parent, key] of parents.reverse()) {
|
|
175
|
+
const child = parent[key];
|
|
176
|
+
if (child && typeof child === "object" && !Array.isArray(child) && Object.keys(child).length === 0) {
|
|
177
|
+
delete parent[key];
|
|
178
|
+
} else {
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
package/scripts/lock.mjs
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
function assertSafeLockPath(agentDir, lockPath) {
|
|
7
|
+
const root = path.resolve(agentDir);
|
|
8
|
+
let current = path.dirname(path.resolve(lockPath));
|
|
9
|
+
while (current !== root) {
|
|
10
|
+
try {
|
|
11
|
+
if (fs.lstatSync(current).isSymbolicLink()) {
|
|
12
|
+
throw new Error(`SpecPi refuses a symlinked lock parent: ${current}`);
|
|
13
|
+
}
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (error.code !== "ENOENT") {
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const parent = path.dirname(current);
|
|
21
|
+
if (parent === current) {
|
|
22
|
+
throw new Error(`SpecPi lock path escapes the agent directory: ${lockPath}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
current = parent;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
if (fs.lstatSync(lockPath).isSymbolicLink()) {
|
|
30
|
+
throw new Error(`SpecPi refuses a symlinked lock target: ${lockPath}`);
|
|
31
|
+
}
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error.code !== "ENOENT") {
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function processState(pid) {
|
|
40
|
+
try {
|
|
41
|
+
process.kill(pid, 0);
|
|
42
|
+
|
|
43
|
+
return "active";
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error?.code === "ESRCH") {
|
|
46
|
+
return "absent";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function acquireSpecPiLock(agentDir) {
|
|
54
|
+
const root = path.resolve(agentDir);
|
|
55
|
+
const stateDir = path.join(root, "specpi");
|
|
56
|
+
const lockPath = path.join(stateDir, "install.lock");
|
|
57
|
+
assertSafeLockPath(root, lockPath);
|
|
58
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
59
|
+
const token = `${process.pid}:${randomBytes(12).toString("hex")}`;
|
|
60
|
+
const payload = `${JSON.stringify({ pid: process.pid, token })}\n`;
|
|
61
|
+
let fd;
|
|
62
|
+
try {
|
|
63
|
+
fd = fs.openSync(lockPath, "wx", 0o600);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error.code !== "EEXIST") {
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const raw = fs.readFileSync(lockPath, "utf8").trim();
|
|
70
|
+
let pid;
|
|
71
|
+
if (/^[1-9]\d*$/.test(raw)) {
|
|
72
|
+
pid = Number(raw);
|
|
73
|
+
} else {
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(raw);
|
|
77
|
+
} catch {
|
|
78
|
+
throw new Error(`SpecPi lock is malformed and was not reclaimed: ${lockPath}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (
|
|
82
|
+
!parsed ||
|
|
83
|
+
typeof parsed !== "object" ||
|
|
84
|
+
Array.isArray(parsed) ||
|
|
85
|
+
!Number.isInteger(parsed.pid) ||
|
|
86
|
+
parsed.pid <= 0 ||
|
|
87
|
+
typeof parsed.token !== "string" ||
|
|
88
|
+
!parsed.token
|
|
89
|
+
) {
|
|
90
|
+
throw new Error(`SpecPi lock is malformed and was not reclaimed: ${lockPath}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
pid = parsed.pid;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (processState(pid) === "active") {
|
|
97
|
+
throw new Error(`Another SpecPi operation appears active: ${lockPath}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (fs.readFileSync(lockPath, "utf8").trim() !== raw) {
|
|
101
|
+
throw new Error(`SpecPi lock changed during recovery and was not reclaimed: ${lockPath}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
fs.rmSync(lockPath);
|
|
105
|
+
fd = fs.openSync(lockPath, "wx", 0o600);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
fs.writeFileSync(fd, payload);
|
|
109
|
+
fs.closeSync(fd);
|
|
110
|
+
|
|
111
|
+
return () => {
|
|
112
|
+
try {
|
|
113
|
+
if (fs.readFileSync(lockPath, "utf8") === payload) {
|
|
114
|
+
fs.rmSync(lockPath);
|
|
115
|
+
}
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error.code !== "ENOENT") {
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|