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,2037 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import readline from "node:readline/promises";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
|
+
import {
|
|
10
|
+
AGENTS_END,
|
|
11
|
+
AGENTS_START,
|
|
12
|
+
SHELL_END,
|
|
13
|
+
SHELL_START,
|
|
14
|
+
deepEqual,
|
|
15
|
+
deletePath,
|
|
16
|
+
mergePackages,
|
|
17
|
+
packageIdentity,
|
|
18
|
+
readPath,
|
|
19
|
+
removeManagedBlock,
|
|
20
|
+
restorePackageChanges,
|
|
21
|
+
setPath,
|
|
22
|
+
sha256,
|
|
23
|
+
upsertManagedBlock,
|
|
24
|
+
} from "./lib.mjs";
|
|
25
|
+
import { validateCapabilityRegistry, isValidValidatorName } from "../extensions/tool-wishlist/registry.mjs";
|
|
26
|
+
import { runValidator } from "../extensions/tool-wishlist/validators.mjs";
|
|
27
|
+
import { acquireSpecPiLock } from "./lock.mjs";
|
|
28
|
+
import { COMMAND_GUARD_MANAGED_FILES } from "../extensions/command-guard/managed-files.mjs";
|
|
29
|
+
|
|
30
|
+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const repoRoot = path.resolve(scriptDir, "..");
|
|
32
|
+
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
|
|
33
|
+
const VERSION = packageJson.version;
|
|
34
|
+
const MIN_PI_VERSION = "0.84.4";
|
|
35
|
+
const PI_PACKAGE = "@earendil-works/pi-coding-agent";
|
|
36
|
+
const PI_PACKAGE_VERSION = "0.84.4";
|
|
37
|
+
const CLI = process.platform === "win32" ? ".\\specpi.cmd" : "./specpi";
|
|
38
|
+
const agentDir = path.resolve(process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent"));
|
|
39
|
+
const stateDir = path.join(agentDir, "specpi");
|
|
40
|
+
const manifestPath = path.join(stateDir, "manifest.json");
|
|
41
|
+
const settingsPath = path.join(agentDir, "settings.json");
|
|
42
|
+
const retiredSubagentConfigPath = path.join(agentDir, "extensions", "subagent", "config.json");
|
|
43
|
+
const agentsPath = path.join(agentDir, "AGENTS.md");
|
|
44
|
+
const browserRuntimeSourceDir = path.join(repoRoot, "browser-runtime");
|
|
45
|
+
const browserRuntimeDir = path.join(stateDir, "browser-runtime");
|
|
46
|
+
const browserRuntimeMarker = path.join(browserRuntimeDir, "specpi-runtime.json");
|
|
47
|
+
const browserSmokePath = path.join(agentDir, "extensions", "browser", "smoke.mjs");
|
|
48
|
+
const capabilityRegistryPath = path.join(agentDir, "extensions", "tool-wishlist", "capabilities.json");
|
|
49
|
+
const managedToolsDir = path.join(stateDir, "optional-tools");
|
|
50
|
+
const managedBinDir = path.join(stateDir, "bin");
|
|
51
|
+
function injectTestFailure(point) {
|
|
52
|
+
if (process.env.SPECPI_TESTING === "1" && process.env.SPECPI_TEST_FAIL_POINT === point) {
|
|
53
|
+
throw new Error(`Injected test failure at ${point}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
process.env.PATH = (process.env.PATH || "")
|
|
58
|
+
.split(path.delimiter)
|
|
59
|
+
.filter((entry) => path.resolve(entry || ".").toLowerCase() !== path.resolve(managedBinDir).toLowerCase())
|
|
60
|
+
.join(path.delimiter);
|
|
61
|
+
if (hasValidatedManagedTools()) {
|
|
62
|
+
process.env.PATH = `${managedBinDir}${path.delimiter}${process.env.PATH || ""}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const PACKAGES = [
|
|
66
|
+
"npm:pi-web-access@0.25.0",
|
|
67
|
+
"npm:@juicesharp/rpiv-ask-user-question@2.7.1",
|
|
68
|
+
"npm:@llblab/pi-codex-usage@0.9.3",
|
|
69
|
+
"npm:@tunnckocore/pi-gpt-fast-mode@0.4.0",
|
|
70
|
+
"npm:@narumitw/pi-goal@0.54.3",
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const OPTIONAL_TOOLS = [
|
|
74
|
+
{ id: "donsetch", label: "DonSeTch", commands: ["donsetch"], purpose: "advanced web crawling skill" },
|
|
75
|
+
];
|
|
76
|
+
const DONSETCH_VERSION = "3.4.0";
|
|
77
|
+
|
|
78
|
+
function usage() {
|
|
79
|
+
console.log(`SpecPi ${VERSION}
|
|
80
|
+
|
|
81
|
+
Usage:
|
|
82
|
+
${CLI} plan
|
|
83
|
+
${CLI} install [--yes] [--skip-package-install] [--skip-browser-install] [--skip-tool-install] [--skip-shell]
|
|
84
|
+
${CLI} update [--yes] [--force] [--skip-package-install] [--skip-browser-install] [--skip-tool-install] [--skip-shell]
|
|
85
|
+
${CLI} doctor
|
|
86
|
+
${CLI} uninstall [--yes]
|
|
87
|
+
|
|
88
|
+
Options:
|
|
89
|
+
--yes Do not ask for confirmation; attempt all missing optional tools.
|
|
90
|
+
--force Replace locally modified SpecPi-managed files during update.
|
|
91
|
+
--skip-package-install Do not bootstrap Pi or install external Pi packages (also skips the browser runtime).
|
|
92
|
+
--skip-browser-install Install browser tools but skip the managed Playwright/Chromium runtime.
|
|
93
|
+
--skip-tool-install Do not offer or install the optional DonSeTch CLI.
|
|
94
|
+
--skip-shell Do not install shell profile functions or edit a shell rc file.
|
|
95
|
+
|
|
96
|
+
Environment:
|
|
97
|
+
PI_CODING_AGENT_DIR Override the Pi agent directory (default: ~/.pi/agent).
|
|
98
|
+
`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseArgs(argv) {
|
|
102
|
+
const command = argv[0] || "help";
|
|
103
|
+
const known = new Set([
|
|
104
|
+
"--yes",
|
|
105
|
+
"--force",
|
|
106
|
+
"--skip-package-install",
|
|
107
|
+
"--skip-browser-install",
|
|
108
|
+
"--skip-tool-install",
|
|
109
|
+
"--skip-shell",
|
|
110
|
+
]);
|
|
111
|
+
for (const arg of argv.slice(1)) {
|
|
112
|
+
if (!known.has(arg)) {
|
|
113
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
command,
|
|
119
|
+
yes: argv.includes("--yes"),
|
|
120
|
+
force: argv.includes("--force"),
|
|
121
|
+
skipPackageInstall: argv.includes("--skip-package-install"),
|
|
122
|
+
skipBrowserInstall: argv.includes("--skip-browser-install") || argv.includes("--skip-package-install"),
|
|
123
|
+
skipToolInstall: argv.includes("--skip-tool-install"),
|
|
124
|
+
skipShell: argv.includes("--skip-shell"),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function timestamp() {
|
|
129
|
+
return new Date().toISOString().replaceAll(":", "").replaceAll(".", "-");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function envValue(env, name) {
|
|
133
|
+
if (env[name] !== undefined) {
|
|
134
|
+
return env[name];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const key = Object.keys(env).find((item) => item.toLowerCase() === name.toLowerCase());
|
|
138
|
+
|
|
139
|
+
return key ? env[key] : undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function resolveCommand(command, env = process.env) {
|
|
143
|
+
const hasPath = command.includes("/") || command.includes("\\");
|
|
144
|
+
const directories = hasPath ? [""] : (envValue(env, "PATH") || "").split(path.delimiter);
|
|
145
|
+
const extension = path.extname(command);
|
|
146
|
+
const candidates =
|
|
147
|
+
process.platform === "win32" && !extension
|
|
148
|
+
? (envValue(env, "PATHEXT") || ".COM;.EXE;.BAT;.CMD").split(";").map((item) => `${command}${item}`)
|
|
149
|
+
: [command];
|
|
150
|
+
|
|
151
|
+
for (const directory of directories) {
|
|
152
|
+
for (const candidate of candidates) {
|
|
153
|
+
const file = hasPath ? candidate : path.join(directory || ".", candidate);
|
|
154
|
+
try {
|
|
155
|
+
const stat = fs.statSync(file);
|
|
156
|
+
if (stat.isFile() && (process.platform === "win32" || (stat.mode & 0o111) !== 0)) {
|
|
157
|
+
return path.resolve(file);
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
const windowsAppsWinget = envValue(env, "LOCALAPPDATA")
|
|
161
|
+
? path.join(envValue(env, "LOCALAPPDATA"), "Microsoft", "WindowsApps", "winget.exe")
|
|
162
|
+
: undefined;
|
|
163
|
+
if (
|
|
164
|
+
process.platform === "win32" &&
|
|
165
|
+
(error.code === "EACCES" || error.code === "EPERM") &&
|
|
166
|
+
windowsAppsWinget &&
|
|
167
|
+
path.resolve(file).toLowerCase() === path.resolve(windowsAppsWinget).toLowerCase()
|
|
168
|
+
) {
|
|
169
|
+
// The winget App Execution Alias can execute even when Windows denies
|
|
170
|
+
// metadata access to its reparse point under WindowsApps.
|
|
171
|
+
return path.resolve(file);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (error.code !== "ENOENT" && error.code !== "ENOTDIR") {
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function commandExists(command) {
|
|
185
|
+
return resolveCommand(command) !== undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function powerShellVersion(executable) {
|
|
189
|
+
if (!executable) {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const result = run(
|
|
195
|
+
executable,
|
|
196
|
+
["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "$PSVersionTable.PSVersion.ToString()"],
|
|
197
|
+
{ capture: true },
|
|
198
|
+
);
|
|
199
|
+
const version = String(result.stdout || "").trim();
|
|
200
|
+
|
|
201
|
+
return /^\d+\.\d+(?:\.\d+){0,2}$/.test(version) ? version : "unknown";
|
|
202
|
+
} catch {
|
|
203
|
+
return "unknown";
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function hasValidatedManagedTools() {
|
|
208
|
+
if (!fs.existsSync(manifestPath) || !fs.existsSync(managedBinDir)) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
214
|
+
const records = manifest.managedOptionalTools || [];
|
|
215
|
+
if (records.length === 0) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const expectedNames = new Set();
|
|
220
|
+
for (const record of records) {
|
|
221
|
+
if (path.dirname(record.target) !== managedBinDir || path.dirname(record.marker) !== managedToolsDir) {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const targetStat = fs.lstatSync(record.target);
|
|
226
|
+
const markerStat = fs.lstatSync(record.marker);
|
|
227
|
+
if (
|
|
228
|
+
!targetStat.isFile() ||
|
|
229
|
+
targetStat.isSymbolicLink() ||
|
|
230
|
+
!markerStat.isFile() ||
|
|
231
|
+
markerStat.isSymbolicLink()
|
|
232
|
+
) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (sha256(fs.readFileSync(record.target)) !== record.installedHash) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
expectedNames.add(path.basename(record.target));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const actualNames = fs.readdirSync(managedBinDir).filter((name) => !name.startsWith("."));
|
|
244
|
+
|
|
245
|
+
return actualNames.length === expectedNames.size && actualNames.every((name) => expectedNames.has(name));
|
|
246
|
+
} catch {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function compareVersions(left, right) {
|
|
252
|
+
const leftParts = left.split(".").map(Number);
|
|
253
|
+
const rightParts = right.split(".").map(Number);
|
|
254
|
+
for (let index = 0; index < 3; index += 1) {
|
|
255
|
+
if (leftParts[index] !== rightParts[index]) {
|
|
256
|
+
return leftParts[index] - rightParts[index];
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return 0;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function npmGlobalBinDir() {
|
|
264
|
+
const result = run("npm", ["prefix", "--global"], { capture: true });
|
|
265
|
+
const prefix = (result.stdout || "").trim();
|
|
266
|
+
if (!prefix) {
|
|
267
|
+
throw new Error("Could not determine npm's global installation prefix.");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return process.platform === "win32" ? prefix : path.join(prefix, "bin");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function installPi() {
|
|
274
|
+
run("npm", [
|
|
275
|
+
"install",
|
|
276
|
+
"--global",
|
|
277
|
+
"--ignore-scripts",
|
|
278
|
+
`${PI_PACKAGE}@${PI_PACKAGE_VERSION}`,
|
|
279
|
+
"--no-audit",
|
|
280
|
+
"--no-fund",
|
|
281
|
+
]);
|
|
282
|
+
if (!commandExists("pi")) {
|
|
283
|
+
const binDir = npmGlobalBinDir();
|
|
284
|
+
throw new Error(
|
|
285
|
+
`Pi was installed by npm, but ${binDir} is not available on PATH. Add that directory to PATH, open a new terminal, and rerun SpecPi install.`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
assertPiVersion();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function assertPiVersion() {
|
|
293
|
+
const result = run("pi", ["--version"], { capture: true });
|
|
294
|
+
const match = (result.stdout || "").match(/\b(\d+\.\d+\.\d+)\b/);
|
|
295
|
+
if (!match) {
|
|
296
|
+
throw new Error("Could not determine the installed Pi version from `pi --version`.");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (compareVersions(match[1], MIN_PI_VERSION) < 0) {
|
|
300
|
+
throw new Error(`Pi ${MIN_PI_VERSION} or newer is required; found ${match[1]}.`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return match[1];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function quoteWindowsCommandArg(value) {
|
|
307
|
+
return `"${String(value).replaceAll("%", "%%").replaceAll('"', '""')}"`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function run(command, args, options = {}) {
|
|
311
|
+
const env = { ...process.env, ...(options.env || {}) };
|
|
312
|
+
const executable = resolveCommand(command, env) || command;
|
|
313
|
+
const needsWindowsCommandProcessor = process.platform === "win32" && /\.(?:cmd|bat)$/i.test(executable);
|
|
314
|
+
const spawnOptions = {
|
|
315
|
+
cwd: options.cwd || os.homedir(),
|
|
316
|
+
env,
|
|
317
|
+
encoding: "utf8",
|
|
318
|
+
stdio: options.capture ? "pipe" : "inherit",
|
|
319
|
+
};
|
|
320
|
+
const result = needsWindowsCommandProcessor
|
|
321
|
+
? spawnSync([executable, ...args].map(quoteWindowsCommandArg).join(" "), {
|
|
322
|
+
...spawnOptions,
|
|
323
|
+
shell: envValue(env, "ComSpec") || "cmd.exe",
|
|
324
|
+
})
|
|
325
|
+
: spawnSync(executable, args, spawnOptions);
|
|
326
|
+
if (result.error) {
|
|
327
|
+
throw result.error;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (result.status !== 0) {
|
|
331
|
+
const detail = options.capture ? `\n${result.stderr || result.stdout || ""}` : "";
|
|
332
|
+
throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status}${detail}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return result;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function optionalToolInstalled(tool) {
|
|
339
|
+
return tool.commands.some(commandExists);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function optionalToolInstallSpec(_tool) {
|
|
343
|
+
if (!commandExists("npm")) {
|
|
344
|
+
return { unavailable: "npm is not available" };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
command: "npm",
|
|
349
|
+
args: ["install", "--global", `donsetch@${DONSETCH_VERSION}`, "--no-audit", "--no-fund"],
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function shellDisplay(value) {
|
|
354
|
+
const text = String(value);
|
|
355
|
+
|
|
356
|
+
return /^[A-Za-z0-9_@%+=:,./\\-]+$/.test(text) ? text : JSON.stringify(text);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function formatInstallSpec(spec) {
|
|
360
|
+
if (spec.unavailable) {
|
|
361
|
+
return `unavailable: ${spec.unavailable}`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return [spec.command, ...spec.args].map(shellDisplay).join(" ");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function chooseOptionalTools(options) {
|
|
368
|
+
if (options.skipToolInstall) {
|
|
369
|
+
return [];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const missing = OPTIONAL_TOOLS.filter((tool) => !optionalToolInstalled(tool));
|
|
373
|
+
if (missing.length === 0) {
|
|
374
|
+
return [];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (options.yes) {
|
|
378
|
+
return missing;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (!process.stdin.isTTY) {
|
|
382
|
+
throw new Error(
|
|
383
|
+
`Optional tool selection requires a TTY; pass --yes to install all missing tools or --skip-tool-install.`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const selected = [];
|
|
388
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
389
|
+
try {
|
|
390
|
+
console.log("\nOptional tools (global npm installs are external and are not removed on uninstall):");
|
|
391
|
+
for (const tool of missing) {
|
|
392
|
+
const spec = optionalToolInstallSpec(tool);
|
|
393
|
+
if (spec.unavailable) {
|
|
394
|
+
console.warn(` ${tool.label}: ${spec.unavailable}`);
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const answer = (
|
|
399
|
+
await rl.question(`Install ${tool.label} for ${tool.purpose}?\n ${formatInstallSpec(spec)}\n[y/N] `)
|
|
400
|
+
)
|
|
401
|
+
.trim()
|
|
402
|
+
.toLowerCase();
|
|
403
|
+
if (answer === "y" || answer === "yes") {
|
|
404
|
+
selected.push(tool);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
} finally {
|
|
408
|
+
rl.close();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return selected;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function installOptionalTools(tools) {
|
|
415
|
+
const warnings = [];
|
|
416
|
+
const externalAttempts = [];
|
|
417
|
+
for (const tool of tools) {
|
|
418
|
+
const spec = optionalToolInstallSpec(tool);
|
|
419
|
+
if (spec.unavailable) {
|
|
420
|
+
warnings.push(`Could not install optional tool ${tool.label}: ${spec.unavailable}.`);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
console.log(`\nInstalling optional tool ${tool.label}:`);
|
|
425
|
+
console.log(` ${formatInstallSpec(spec)}`);
|
|
426
|
+
try {
|
|
427
|
+
externalAttempts.push(tool.label);
|
|
428
|
+
run(spec.command, spec.args);
|
|
429
|
+
if (!optionalToolInstalled(tool)) {
|
|
430
|
+
warnings.push(
|
|
431
|
+
`${tool.label} installed successfully but is not visible on the current PATH; restart the terminal before using it.`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
} catch (error) {
|
|
435
|
+
warnings.push(`Optional tool ${tool.label} failed to install: ${error.message}`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
warnings,
|
|
441
|
+
managedRecords: [],
|
|
442
|
+
externalAttempts,
|
|
443
|
+
rollback() {
|
|
444
|
+
return [];
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function browserRuntimeLockHash() {
|
|
450
|
+
return sha256(fs.readFileSync(path.join(browserRuntimeSourceDir, "package-lock.json")));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function browserRuntimeStatus() {
|
|
454
|
+
if (!fs.existsSync(browserRuntimeMarker)) {
|
|
455
|
+
return { installed: false, reason: "runtime marker is missing" };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
const marker = readJson(browserRuntimeMarker, {});
|
|
460
|
+
const playwright = readJson(path.join(browserRuntimeDir, "node_modules", "playwright", "package.json"), {});
|
|
461
|
+
const browsersDir = path.join(browserRuntimeDir, "browsers");
|
|
462
|
+
const hasBrowser =
|
|
463
|
+
fs.existsSync(browsersDir) && fs.readdirSync(browsersDir).some((entry) => !entry.startsWith("."));
|
|
464
|
+
if (marker.schema !== 1 || marker.lockHash !== browserRuntimeLockHash()) {
|
|
465
|
+
return { installed: false, reason: "runtime lock does not match this SpecPi release" };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (playwright.version !== "1.62.1") {
|
|
469
|
+
return { installed: false, reason: "Playwright 1.62.1 is not installed" };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (!hasBrowser) {
|
|
473
|
+
return { installed: false, reason: "managed Chromium is missing" };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return { installed: true, version: playwright.version, lockHash: marker.lockHash };
|
|
477
|
+
} catch (error) {
|
|
478
|
+
return { installed: false, reason: error.message };
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function smokeBrowserRuntime(directory) {
|
|
483
|
+
try {
|
|
484
|
+
return run(process.execPath, [path.join(repoRoot, "extensions", "browser", "smoke.mjs"), directory], {
|
|
485
|
+
capture: true,
|
|
486
|
+
});
|
|
487
|
+
} catch (error) {
|
|
488
|
+
throw new Error(
|
|
489
|
+
`${error.message}\nChromium could not launch. Verify the host satisfies Playwright Chromium system dependencies; SpecPi does not install Playwright system dependencies.`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function installBrowserRuntime(warnings) {
|
|
495
|
+
const current = browserRuntimeStatus();
|
|
496
|
+
if (current.installed) {
|
|
497
|
+
try {
|
|
498
|
+
smokeBrowserRuntime(browserRuntimeDir);
|
|
499
|
+
console.log(
|
|
500
|
+
"Managed browser runtime is current and passed its launch smoke; reusing Playwright 1.62.1 and Chromium.",
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
return {
|
|
504
|
+
commit() {},
|
|
505
|
+
rollback() {
|
|
506
|
+
return [];
|
|
507
|
+
},
|
|
508
|
+
changed: false,
|
|
509
|
+
};
|
|
510
|
+
} catch (error) {
|
|
511
|
+
warnings.push(`Existing managed browser runtime failed validation and will be replaced: ${error.message}`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const operationStamp = `${process.pid}-${Date.now()}`;
|
|
516
|
+
const stage = path.join(stateDir, `.browser-runtime-stage-${operationStamp}`);
|
|
517
|
+
const previous = path.join(stateDir, `.browser-runtime-previous-${operationStamp}`);
|
|
518
|
+
const failed = path.join(stateDir, `.browser-runtime-failed-${operationStamp}`);
|
|
519
|
+
let promoted = false;
|
|
520
|
+
try {
|
|
521
|
+
fs.rmSync(stage, { recursive: true, force: true });
|
|
522
|
+
fs.rmSync(previous, { recursive: true, force: true });
|
|
523
|
+
fs.mkdirSync(stage, { recursive: true, mode: 0o700 });
|
|
524
|
+
fs.copyFileSync(path.join(browserRuntimeSourceDir, "package.json"), path.join(stage, "package.json"));
|
|
525
|
+
fs.copyFileSync(path.join(browserRuntimeSourceDir, "package-lock.json"), path.join(stage, "package-lock.json"));
|
|
526
|
+
run("npm", ["ci", "--ignore-scripts", "--no-audit", "--no-fund"], { cwd: stage });
|
|
527
|
+
const browsersPath = path.join(stage, "browsers");
|
|
528
|
+
run(process.execPath, [path.join(stage, "node_modules", "playwright", "cli.js"), "install", "chromium"], {
|
|
529
|
+
cwd: stage,
|
|
530
|
+
env: { PLAYWRIGHT_BROWSERS_PATH: browsersPath },
|
|
531
|
+
});
|
|
532
|
+
writeJson(
|
|
533
|
+
path.join(stage, "specpi-runtime.json"),
|
|
534
|
+
{ schema: 1, playwrightVersion: "1.62.1", lockHash: browserRuntimeLockHash() },
|
|
535
|
+
0o600,
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
if (fs.existsSync(browserRuntimeDir)) {
|
|
539
|
+
fs.renameSync(browserRuntimeDir, previous);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
fs.renameSync(stage, browserRuntimeDir);
|
|
543
|
+
promoted = true;
|
|
544
|
+
smokeBrowserRuntime(browserRuntimeDir);
|
|
545
|
+
} catch (error) {
|
|
546
|
+
const cleanupErrors = [];
|
|
547
|
+
try {
|
|
548
|
+
fs.rmSync(stage, { recursive: true, force: true });
|
|
549
|
+
} catch (cleanupError) {
|
|
550
|
+
cleanupErrors.push(cleanupError.message);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (promoted && fs.existsSync(browserRuntimeDir)) {
|
|
554
|
+
try {
|
|
555
|
+
fs.renameSync(browserRuntimeDir, failed);
|
|
556
|
+
} catch (cleanupError) {
|
|
557
|
+
cleanupErrors.push(`quarantine failed runtime: ${cleanupError.message}`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (fs.existsSync(previous) && !fs.existsSync(browserRuntimeDir)) {
|
|
562
|
+
try {
|
|
563
|
+
fs.renameSync(previous, browserRuntimeDir);
|
|
564
|
+
} catch (cleanupError) {
|
|
565
|
+
cleanupErrors.push(`restore previous runtime: ${cleanupError.message}`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (fs.existsSync(failed)) {
|
|
570
|
+
try {
|
|
571
|
+
fs.rmSync(failed, { recursive: true, force: true });
|
|
572
|
+
} catch (cleanupError) {
|
|
573
|
+
cleanupErrors.push(`remove failed runtime: ${cleanupError.message}`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
throw new Error(
|
|
578
|
+
`${error.message}${cleanupErrors.length ? `\nRuntime cleanup also failed: ${cleanupErrors.join("; ")}` : ""}`,
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
let settled = false;
|
|
583
|
+
|
|
584
|
+
return {
|
|
585
|
+
changed: true,
|
|
586
|
+
commit() {
|
|
587
|
+
if (settled) {
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
settled = true;
|
|
592
|
+
try {
|
|
593
|
+
fs.rmSync(previous, { recursive: true, force: true });
|
|
594
|
+
} catch (error) {
|
|
595
|
+
warnings.push(`Could not remove retired browser runtime ${previous}: ${error.message}`);
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
rollback() {
|
|
599
|
+
if (settled) {
|
|
600
|
+
return [];
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
settled = true;
|
|
604
|
+
const errors = [];
|
|
605
|
+
if (fs.existsSync(browserRuntimeDir)) {
|
|
606
|
+
try {
|
|
607
|
+
fs.renameSync(browserRuntimeDir, failed);
|
|
608
|
+
} catch (error) {
|
|
609
|
+
errors.push(`quarantine new runtime: ${error.message}`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (fs.existsSync(previous) && !fs.existsSync(browserRuntimeDir)) {
|
|
614
|
+
try {
|
|
615
|
+
fs.renameSync(previous, browserRuntimeDir);
|
|
616
|
+
} catch (error) {
|
|
617
|
+
errors.push(`restore previous runtime: ${error.message}`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (fs.existsSync(failed)) {
|
|
622
|
+
try {
|
|
623
|
+
fs.rmSync(failed, { recursive: true, force: true });
|
|
624
|
+
} catch (error) {
|
|
625
|
+
errors.push(`remove rolled-back runtime: ${error.message}`);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
return errors;
|
|
630
|
+
},
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function readJson(file, fallback = {}) {
|
|
635
|
+
if (!fs.existsSync(file)) {
|
|
636
|
+
return structuredClone(fallback);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
try {
|
|
640
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
641
|
+
} catch (error) {
|
|
642
|
+
throw new Error(`Cannot parse ${file}: ${error.message}`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function lstatMaybe(file) {
|
|
647
|
+
try {
|
|
648
|
+
return fs.lstatSync(file);
|
|
649
|
+
} catch (error) {
|
|
650
|
+
if (error.code === "ENOENT") {
|
|
651
|
+
return undefined;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
throw error;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function pathExists(file) {
|
|
659
|
+
return lstatMaybe(file) !== undefined;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function assertNoBrokenSymlinks(files) {
|
|
663
|
+
for (const file of files) {
|
|
664
|
+
const stat = lstatMaybe(file);
|
|
665
|
+
if (stat?.isSymbolicLink() && !fs.existsSync(file)) {
|
|
666
|
+
throw new Error(`Broken symlink is unsupported: ${file}`);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function atomicWrite(file, data, mode = 0o600) {
|
|
672
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
673
|
+
let destination = file;
|
|
674
|
+
if (lstatMaybe(file)?.isSymbolicLink()) {
|
|
675
|
+
if (!fs.existsSync(file)) {
|
|
676
|
+
throw new Error(`Broken symlink is unsupported: ${file}`);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
destination = fs.realpathSync(file);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const temp = path.join(path.dirname(destination), `.${path.basename(destination)}.${process.pid}.tmp`);
|
|
683
|
+
fs.writeFileSync(temp, data, { mode });
|
|
684
|
+
fs.chmodSync(temp, mode);
|
|
685
|
+
fs.renameSync(temp, destination);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function writeJson(file, value, mode = 0o600) {
|
|
689
|
+
atomicWrite(file, `${JSON.stringify(value, null, 2)}\n`, mode);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function existingMode(file, fallback) {
|
|
693
|
+
try {
|
|
694
|
+
return fs.statSync(file).mode & 0o777;
|
|
695
|
+
} catch {
|
|
696
|
+
return fallback;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function detectShellRc() {
|
|
701
|
+
if (process.env.SPECPI_SHELL_RC) {
|
|
702
|
+
return path.resolve(process.env.SPECPI_SHELL_RC);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const shell = path.basename(process.env.SHELL || "");
|
|
706
|
+
if (shell === "bash") {
|
|
707
|
+
return path.join(os.homedir(), ".bashrc");
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (shell === "zsh") {
|
|
711
|
+
return path.join(os.homedir(), ".zshrc");
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
return undefined;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function shellQuote(value) {
|
|
718
|
+
return `'${String(value).replaceAll("'", `'"'"'`)}'`;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function validManagedModelScope(value) {
|
|
722
|
+
return (
|
|
723
|
+
value?.enforce === true &&
|
|
724
|
+
value?.strict === true &&
|
|
725
|
+
Array.isArray(value.allow) &&
|
|
726
|
+
value.allow.length === 1 &&
|
|
727
|
+
(value.allow[0] === "inherit" || /^[^/*]+\/\*$/.test(value.allow[0]))
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function desiredSettingsOperations() {
|
|
732
|
+
return [{ path: ["theme"], value: "specpi-spec" }];
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function managedFiles(includeShell) {
|
|
736
|
+
const files = [
|
|
737
|
+
[path.join(repoRoot, "extensions", "spec.ts"), path.join(agentDir, "extensions", "spec.ts"), 0o644],
|
|
738
|
+
[
|
|
739
|
+
path.join(repoRoot, "extensions", "spec", "core.mjs"),
|
|
740
|
+
path.join(agentDir, "extensions", "spec", "core.mjs"),
|
|
741
|
+
0o644,
|
|
742
|
+
],
|
|
743
|
+
[
|
|
744
|
+
path.join(repoRoot, "extensions", "ui-refresh", "index.ts"),
|
|
745
|
+
path.join(agentDir, "extensions", "specpi-ui-refresh", "index.ts"),
|
|
746
|
+
0o644,
|
|
747
|
+
],
|
|
748
|
+
[
|
|
749
|
+
path.join(repoRoot, "extensions", "workflow-controls", "index.ts"),
|
|
750
|
+
path.join(agentDir, "extensions", "workflow-controls", "index.ts"),
|
|
751
|
+
0o644,
|
|
752
|
+
],
|
|
753
|
+
[
|
|
754
|
+
path.join(repoRoot, "extensions", "workflow-controls", "scope.mjs"),
|
|
755
|
+
path.join(agentDir, "extensions", "workflow-controls", "scope.mjs"),
|
|
756
|
+
0o644,
|
|
757
|
+
],
|
|
758
|
+
[
|
|
759
|
+
path.join(repoRoot, "extensions", "workflow-controls", "experiments.mjs"),
|
|
760
|
+
path.join(agentDir, "extensions", "workflow-controls", "experiments.mjs"),
|
|
761
|
+
0o644,
|
|
762
|
+
],
|
|
763
|
+
[
|
|
764
|
+
path.join(repoRoot, "extensions", "workflow-controls", "challenge.mjs"),
|
|
765
|
+
path.join(agentDir, "extensions", "workflow-controls", "challenge.mjs"),
|
|
766
|
+
0o644,
|
|
767
|
+
],
|
|
768
|
+
[
|
|
769
|
+
path.join(repoRoot, "extensions", "workflow-controls", "smoke.mjs"),
|
|
770
|
+
path.join(agentDir, "extensions", "workflow-controls", "smoke.mjs"),
|
|
771
|
+
0o755,
|
|
772
|
+
],
|
|
773
|
+
[
|
|
774
|
+
path.join(repoRoot, "extensions", "files", "index.ts"),
|
|
775
|
+
path.join(agentDir, "extensions", "files", "index.ts"),
|
|
776
|
+
0o644,
|
|
777
|
+
],
|
|
778
|
+
[
|
|
779
|
+
path.join(repoRoot, "extensions", "files", "core.mjs"),
|
|
780
|
+
path.join(agentDir, "extensions", "files", "core.mjs"),
|
|
781
|
+
0o644,
|
|
782
|
+
],
|
|
783
|
+
[
|
|
784
|
+
path.join(repoRoot, "extensions", "browser", "index.ts"),
|
|
785
|
+
path.join(agentDir, "extensions", "browser", "index.ts"),
|
|
786
|
+
0o644,
|
|
787
|
+
],
|
|
788
|
+
[
|
|
789
|
+
path.join(repoRoot, "extensions", "browser", "core.mjs"),
|
|
790
|
+
path.join(agentDir, "extensions", "browser", "core.mjs"),
|
|
791
|
+
0o644,
|
|
792
|
+
],
|
|
793
|
+
[
|
|
794
|
+
path.join(repoRoot, "extensions", "browser", "smoke.mjs"),
|
|
795
|
+
path.join(agentDir, "extensions", "browser", "smoke.mjs"),
|
|
796
|
+
0o755,
|
|
797
|
+
],
|
|
798
|
+
[
|
|
799
|
+
path.join(repoRoot, "extensions", "tool-wishlist", "index.ts"),
|
|
800
|
+
path.join(agentDir, "extensions", "tool-wishlist", "index.ts"),
|
|
801
|
+
0o644,
|
|
802
|
+
],
|
|
803
|
+
[
|
|
804
|
+
path.join(repoRoot, "extensions", "tool-wishlist", "core.mjs"),
|
|
805
|
+
path.join(agentDir, "extensions", "tool-wishlist", "core.mjs"),
|
|
806
|
+
0o644,
|
|
807
|
+
],
|
|
808
|
+
[
|
|
809
|
+
path.join(repoRoot, "extensions", "tool-wishlist", "registry.mjs"),
|
|
810
|
+
path.join(agentDir, "extensions", "tool-wishlist", "registry.mjs"),
|
|
811
|
+
0o644,
|
|
812
|
+
],
|
|
813
|
+
[
|
|
814
|
+
path.join(repoRoot, "extensions", "tool-wishlist", "validators.mjs"),
|
|
815
|
+
path.join(agentDir, "extensions", "tool-wishlist", "validators.mjs"),
|
|
816
|
+
0o644,
|
|
817
|
+
],
|
|
818
|
+
[
|
|
819
|
+
path.join(repoRoot, "extensions", "tool-wishlist", "capabilities.json"),
|
|
820
|
+
path.join(agentDir, "extensions", "tool-wishlist", "capabilities.json"),
|
|
821
|
+
0o644,
|
|
822
|
+
],
|
|
823
|
+
...COMMAND_GUARD_MANAGED_FILES.map((name) => [
|
|
824
|
+
path.join(repoRoot, "extensions", "command-guard", name),
|
|
825
|
+
path.join(agentDir, "extensions", "command-guard", name),
|
|
826
|
+
0o644,
|
|
827
|
+
]),
|
|
828
|
+
[
|
|
829
|
+
path.join(repoRoot, "skills", "specpi-improve", "SKILL.md"),
|
|
830
|
+
path.join(agentDir, "skills", "specpi-improve", "SKILL.md"),
|
|
831
|
+
0o644,
|
|
832
|
+
],
|
|
833
|
+
[
|
|
834
|
+
path.join(repoRoot, "skills", "donsetch", "SKILL.md"),
|
|
835
|
+
path.join(agentDir, "skills", "donsetch", "SKILL.md"),
|
|
836
|
+
0o644,
|
|
837
|
+
],
|
|
838
|
+
[path.join(repoRoot, "themes", "tea-house.json"), path.join(agentDir, "themes", "tea-house.json"), 0o644],
|
|
839
|
+
[path.join(repoRoot, "themes", "specpi-spec.json"), path.join(agentDir, "themes", "specpi-spec.json"), 0o644],
|
|
840
|
+
];
|
|
841
|
+
if (includeShell) {
|
|
842
|
+
files.push([path.join(repoRoot, "shell", "pi-profiles.sh"), path.join(stateDir, "pi-profiles.sh"), 0o644]);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
return files;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function snapshot(files) {
|
|
849
|
+
const result = new Map();
|
|
850
|
+
for (const file of files) {
|
|
851
|
+
if (result.has(file)) {
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
if (fs.existsSync(file)) {
|
|
856
|
+
result.set(file, {
|
|
857
|
+
exists: true,
|
|
858
|
+
data: fs.readFileSync(file),
|
|
859
|
+
mode: fs.statSync(file).mode & 0o777,
|
|
860
|
+
});
|
|
861
|
+
} else {
|
|
862
|
+
result.set(file, { exists: false });
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
return result;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function restoreSnapshot(items) {
|
|
870
|
+
for (const [file, prior] of items) {
|
|
871
|
+
if (prior.exists) {
|
|
872
|
+
atomicWrite(file, prior.data, prior.mode);
|
|
873
|
+
} else {
|
|
874
|
+
fs.rmSync(file, { force: true });
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function createOriginalFileRecord(target, backupDir, existingRecord, index) {
|
|
880
|
+
if (existingRecord) {
|
|
881
|
+
return structuredClone(existingRecord);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
if (!fs.existsSync(target)) {
|
|
885
|
+
return { existed: false };
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const backup = path.join(backupDir, "original", `${String(index).padStart(3, "0")}-${path.basename(target)}`);
|
|
889
|
+
fs.mkdirSync(path.dirname(backup), { recursive: true, mode: 0o700 });
|
|
890
|
+
fs.copyFileSync(target, backup);
|
|
891
|
+
fs.chmodSync(backup, 0o600);
|
|
892
|
+
|
|
893
|
+
return {
|
|
894
|
+
existed: true,
|
|
895
|
+
backup: path.relative(stateDir, backup),
|
|
896
|
+
mode: fs.statSync(target).mode & 0o777,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function applySettings(settings, operations, previousChanges = []) {
|
|
901
|
+
const changesByPath = new Map(previousChanges.map((change) => [change.path.join("."), change]));
|
|
902
|
+
const changes = [];
|
|
903
|
+
for (const operation of operations) {
|
|
904
|
+
const key = operation.path.join(".");
|
|
905
|
+
const existingChange = changesByPath.get(key);
|
|
906
|
+
const current = readPath(settings, operation.path);
|
|
907
|
+
const before = existingChange || {
|
|
908
|
+
path: operation.path,
|
|
909
|
+
beforeExists: current.exists,
|
|
910
|
+
...(current.exists ? { before: structuredClone(current.value) } : {}),
|
|
911
|
+
};
|
|
912
|
+
const currentMatchesPrevious = existingChange
|
|
913
|
+
? existingChange.installedExists
|
|
914
|
+
? current.exists && deepEqual(current.value, existingChange.installed)
|
|
915
|
+
: !current.exists
|
|
916
|
+
: false;
|
|
917
|
+
const currentIsValid = current.exists && (!operation.validate || operation.validate(current.value));
|
|
918
|
+
const preserveTunable = operation.userTunable && currentIsValid && (!existingChange || !currentMatchesPrevious);
|
|
919
|
+
const preserveDynamicPolicy = operation.dynamicPolicy && currentIsValid;
|
|
920
|
+
|
|
921
|
+
if (operation.arrayIncludes) {
|
|
922
|
+
const original = existingChange
|
|
923
|
+
? {
|
|
924
|
+
path: structuredClone(existingChange.path),
|
|
925
|
+
beforeExists: existingChange.beforeExists,
|
|
926
|
+
...(existingChange.beforeExists ? { before: structuredClone(existingChange.before) } : {}),
|
|
927
|
+
}
|
|
928
|
+
: structuredClone(before);
|
|
929
|
+
const existingValues = current.exists && Array.isArray(current.value) ? current.value : [];
|
|
930
|
+
const installed = [...existingValues];
|
|
931
|
+
for (const entry of operation.arrayIncludes) {
|
|
932
|
+
if (!installed.some((value) => deepEqual(value, entry))) {
|
|
933
|
+
installed.push(structuredClone(entry));
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
setPath(settings, operation.path, installed);
|
|
938
|
+
changes.push({
|
|
939
|
+
...original,
|
|
940
|
+
managedArrayEntries: structuredClone(operation.arrayIncludes),
|
|
941
|
+
installedExists: true,
|
|
942
|
+
installed: structuredClone(installed),
|
|
943
|
+
});
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
if (preserveTunable) {
|
|
948
|
+
changes.push({ ...structuredClone(before), userTunable: true });
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if (preserveDynamicPolicy) {
|
|
953
|
+
changes.push({
|
|
954
|
+
...structuredClone(before),
|
|
955
|
+
dynamicPolicy: true,
|
|
956
|
+
installedExists: true,
|
|
957
|
+
installed: structuredClone(current.value),
|
|
958
|
+
});
|
|
959
|
+
continue;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
if (operation.delete) {
|
|
963
|
+
deletePath(settings, operation.path);
|
|
964
|
+
} else {
|
|
965
|
+
setPath(settings, operation.path, operation.value);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
changes.push({
|
|
969
|
+
...structuredClone(before),
|
|
970
|
+
...(operation.userTunable ? { userTunable: true } : {}),
|
|
971
|
+
...(operation.dynamicPolicy ? { dynamicPolicy: true } : {}),
|
|
972
|
+
installedExists: !operation.delete,
|
|
973
|
+
...(!operation.delete ? { installed: structuredClone(operation.value) } : {}),
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
return changes;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function retireSettings(settings, previousChanges, operations, warnings) {
|
|
981
|
+
const desired = new Set(operations.map((operation) => operation.path.join(".")));
|
|
982
|
+
const retired = previousChanges.filter((change) => !desired.has(change.path.join(".")));
|
|
983
|
+
restoreSettingChanges(settings, retired, warnings);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function buildPackageChanges(packagesBeforeOperation, previousChanges = []) {
|
|
987
|
+
return PACKAGES.map((installed) => {
|
|
988
|
+
const identity = packageIdentity(installed);
|
|
989
|
+
const previous = previousChanges.find((change) => change.identity === identity);
|
|
990
|
+
if (previous) {
|
|
991
|
+
return { ...structuredClone(previous), installed };
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
const before = (packagesBeforeOperation || []).find((entry) => packageIdentity(entry) === identity);
|
|
995
|
+
|
|
996
|
+
return {
|
|
997
|
+
identity,
|
|
998
|
+
beforeExists: before !== undefined,
|
|
999
|
+
...(before !== undefined ? { before: structuredClone(before) } : {}),
|
|
1000
|
+
installed,
|
|
1001
|
+
};
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function restoreFileRecord(target, record, warnings, reason) {
|
|
1006
|
+
if (!fs.existsSync(target)) {
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
if (sha256(fs.readFileSync(target)) !== record.installedHash) {
|
|
1011
|
+
warnings.push(`Preserved modified file during ${reason}: ${target}`);
|
|
1012
|
+
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (record.existed) {
|
|
1017
|
+
const backup = path.join(stateDir, record.backup);
|
|
1018
|
+
if (!fs.existsSync(backup)) {
|
|
1019
|
+
throw new Error(`Missing original backup: ${backup}`);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
atomicWrite(target, fs.readFileSync(backup), record.mode || 0o644);
|
|
1023
|
+
} else {
|
|
1024
|
+
fs.rmSync(target, { force: true });
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function makeAgentsBlock() {
|
|
1029
|
+
const template = fs.readFileSync(path.join(repoRoot, "templates", "AGENTS.md"), "utf8").trim();
|
|
1030
|
+
|
|
1031
|
+
return `_SpecPi-managed guidance, version ${VERSION}._\n\n${template}`;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function makeShellBlock() {
|
|
1035
|
+
const profile = path.join(stateDir, "pi-profiles.sh");
|
|
1036
|
+
|
|
1037
|
+
return `[ -f ${shellQuote(profile)} ] && . ${shellQuote(profile)}`;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function finishManagedBlockRemoval(file, result, existedBefore) {
|
|
1041
|
+
const isSymlink = lstatMaybe(file)?.isSymbolicLink() || false;
|
|
1042
|
+
if (result.trim() || existedBefore || isSymlink) {
|
|
1043
|
+
atomicWrite(file, result, existingMode(file, 0o644));
|
|
1044
|
+
} else {
|
|
1045
|
+
fs.rmSync(file, { force: true });
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function readManifest(required = false) {
|
|
1050
|
+
if (!fs.existsSync(manifestPath)) {
|
|
1051
|
+
if (required) {
|
|
1052
|
+
throw new Error(`SpecPi is not installed. Run ${CLI} install first.`);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
return undefined;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const manifest = readJson(manifestPath);
|
|
1059
|
+
if (manifest.schema !== 1) {
|
|
1060
|
+
throw new Error(`Unsupported manifest schema in ${manifestPath}`);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
return manifest;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function acquireLock() {
|
|
1067
|
+
return acquireSpecPiLock(agentDir);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
async function confirm(message, yes) {
|
|
1071
|
+
if (yes) {
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
if (!process.stdin.isTTY) {
|
|
1076
|
+
throw new Error(`Confirmation requires a TTY; inspect ${CLI} plan, then pass --yes.`);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1080
|
+
const answer = (await rl.question(`${message} [y/N] `)).trim().toLowerCase();
|
|
1081
|
+
rl.close();
|
|
1082
|
+
if (answer !== "y" && answer !== "yes") {
|
|
1083
|
+
throw new Error("Cancelled.");
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function assertSources() {
|
|
1088
|
+
const required = [
|
|
1089
|
+
"extensions/spec.ts",
|
|
1090
|
+
"extensions/spec/core.mjs",
|
|
1091
|
+
"extensions/ui-refresh/index.ts",
|
|
1092
|
+
"extensions/workflow-controls/index.ts",
|
|
1093
|
+
"extensions/workflow-controls/scope.mjs",
|
|
1094
|
+
"extensions/workflow-controls/experiments.mjs",
|
|
1095
|
+
"extensions/workflow-controls/challenge.mjs",
|
|
1096
|
+
"extensions/workflow-controls/smoke.mjs",
|
|
1097
|
+
"extensions/files/index.ts",
|
|
1098
|
+
"extensions/files/core.mjs",
|
|
1099
|
+
"extensions/browser/index.ts",
|
|
1100
|
+
"extensions/browser/core.mjs",
|
|
1101
|
+
"extensions/browser/smoke.mjs",
|
|
1102
|
+
"browser-runtime/package.json",
|
|
1103
|
+
"browser-runtime/package-lock.json",
|
|
1104
|
+
"extensions/tool-wishlist/index.ts",
|
|
1105
|
+
"extensions/tool-wishlist/core.mjs",
|
|
1106
|
+
"extensions/tool-wishlist/registry.mjs",
|
|
1107
|
+
"extensions/tool-wishlist/validators.mjs",
|
|
1108
|
+
"extensions/tool-wishlist/capabilities.json",
|
|
1109
|
+
...COMMAND_GUARD_MANAGED_FILES.map((name) => `extensions/command-guard/${name}`),
|
|
1110
|
+
"skills/specpi-improve/SKILL.md",
|
|
1111
|
+
"skills/donsetch/SKILL.md",
|
|
1112
|
+
"themes/tea-house.json",
|
|
1113
|
+
"themes/specpi-spec.json",
|
|
1114
|
+
"templates/AGENTS.md",
|
|
1115
|
+
"shell/pi-profiles.sh",
|
|
1116
|
+
];
|
|
1117
|
+
for (const relative of required) {
|
|
1118
|
+
if (!fs.existsSync(path.join(repoRoot, relative))) {
|
|
1119
|
+
throw new Error(`Missing repository source: ${relative}`);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
const [nodeMajor, nodeMinor] = process.versions.node.split(".").map(Number);
|
|
1124
|
+
if (nodeMajor < 22 || (nodeMajor === 22 && nodeMinor < 19)) {
|
|
1125
|
+
throw new Error(`Node 22.19 or newer is required; found ${process.versions.node}`);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
readJson(settingsPath, {});
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function printPlan(options) {
|
|
1132
|
+
const shellRc = detectShellRc();
|
|
1133
|
+
const manageShell = !options.skipShell && Boolean(shellRc);
|
|
1134
|
+
console.log(`SpecPi ${VERSION} installation plan
|
|
1135
|
+
|
|
1136
|
+
Pi agent directory:
|
|
1137
|
+
${agentDir}
|
|
1138
|
+
|
|
1139
|
+
Managed files:`);
|
|
1140
|
+
for (const [, target] of managedFiles(manageShell)) {
|
|
1141
|
+
console.log(` ${target}`);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
console.log(` ${agentsPath} (managed block only)`);
|
|
1145
|
+
if (!options.skipShell && shellRc) {
|
|
1146
|
+
console.log(` ${shellRc} (managed source block only)`);
|
|
1147
|
+
} else if (!options.skipShell) {
|
|
1148
|
+
console.log(" shell integration skipped (only bash and zsh are supported)");
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
console.log(` ${manifestPath}`);
|
|
1152
|
+
|
|
1153
|
+
console.log("\nSettings ownership:");
|
|
1154
|
+
console.log(" theme = specpi-spec");
|
|
1155
|
+
console.log(" pinned package entries (merged by npm package identity)");
|
|
1156
|
+
console.log(" command-guard mode and approvals are session-only and no raw commands are persisted");
|
|
1157
|
+
console.log(" scope and completion state stays in the active Pi session branch without raw tool content");
|
|
1158
|
+
console.log(" experiment metadata and exported patches stay in private local SpecPi state");
|
|
1159
|
+
console.log(" provider, default model, authentication, trust, sessions, and history are untouched");
|
|
1160
|
+
console.log(" capability-gap events use sanitized summaries and salted task/session/project hashes");
|
|
1161
|
+
console.log(" collection requires one explicit local on/off decision and never uploads data");
|
|
1162
|
+
console.log(" wishlist lifecycle decisions are explicit, append-only, and reversible");
|
|
1163
|
+
console.log(" isolated browser contexts never reuse the user's Chrome profile or cookies");
|
|
1164
|
+
|
|
1165
|
+
console.log("\nManaged browser runtime:");
|
|
1166
|
+
if (options.skipBrowserInstall) {
|
|
1167
|
+
console.log(
|
|
1168
|
+
" skipped by explicit flag (browser tools remain installed but unavailable without an existing runtime)",
|
|
1169
|
+
);
|
|
1170
|
+
} else {
|
|
1171
|
+
console.log(` ${browserRuntimeDir}`);
|
|
1172
|
+
console.log(" Playwright 1.62.1 + matching managed Chromium");
|
|
1173
|
+
console.log(" staged before atomic promotion; no global executable installation");
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
console.log("\nPi runtime:");
|
|
1177
|
+
if (options.skipPackageInstall) {
|
|
1178
|
+
console.log(" bootstrap skipped by explicit flag");
|
|
1179
|
+
} else if (commandExists("pi")) {
|
|
1180
|
+
console.log(" existing Pi installation will be used");
|
|
1181
|
+
} else {
|
|
1182
|
+
console.log(` missing; npm will globally install ${PI_PACKAGE}@${PI_PACKAGE_VERSION} after confirmation`);
|
|
1183
|
+
console.log(" this external installation is preserved by SpecPi uninstall");
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
console.log("\nPinned packages:");
|
|
1187
|
+
for (const spec of PACKAGES) {
|
|
1188
|
+
console.log(` ${spec}`);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
console.log("\nOptional tools:");
|
|
1192
|
+
if (options.skipToolInstall) {
|
|
1193
|
+
console.log(" installation skipped by explicit flag");
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
for (const tool of OPTIONAL_TOOLS) {
|
|
1197
|
+
if (optionalToolInstalled(tool)) {
|
|
1198
|
+
console.log(` ${tool.label}: already available`);
|
|
1199
|
+
} else if (options.skipToolInstall) {
|
|
1200
|
+
console.log(` ${tool.label}: missing`);
|
|
1201
|
+
} else {
|
|
1202
|
+
console.log(` ${tool.label}: ${formatInstallSpec(optionalToolInstallSpec(tool))}`);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
if (!options.skipToolInstall) {
|
|
1207
|
+
console.log(
|
|
1208
|
+
options.yes
|
|
1209
|
+
? " --yes attempts every missing optional tool"
|
|
1210
|
+
: " interactive install asks about each available missing tool individually",
|
|
1211
|
+
);
|
|
1212
|
+
console.log(" global npm changes are external and remain after uninstall");
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
console.log(" Chromium is installed by default inside the managed browser runtime");
|
|
1216
|
+
console.log("\nEvery install/update creates timestamped backups under:");
|
|
1217
|
+
console.log(` ${path.join(stateDir, "backups")}`);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function validateManagedUpdate(manifest, files, force) {
|
|
1221
|
+
if (!manifest || force) {
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
for (const [, target] of files) {
|
|
1226
|
+
const record = manifest.files?.[target];
|
|
1227
|
+
if (!record || !fs.existsSync(target)) {
|
|
1228
|
+
continue;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const currentHash = sha256(fs.readFileSync(target));
|
|
1232
|
+
if (currentHash !== record.installedHash) {
|
|
1233
|
+
throw new Error(`Managed file was modified after installation: ${target}\nUse --force to replace it.`);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function retireManagedOptionalToolRecords(records, warnings, preserved) {
|
|
1239
|
+
for (const record of records || []) {
|
|
1240
|
+
if (path.dirname(record.target) !== managedBinDir || path.dirname(record.marker) !== managedToolsDir) {
|
|
1241
|
+
throw new Error(`Legacy managed optional tool path is outside SpecPi state: ${record.target}`);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
if (lstatMaybe(record.target)?.isSymbolicLink() || lstatMaybe(record.marker)?.isSymbolicLink()) {
|
|
1245
|
+
throw new Error(`Legacy managed optional tool paths must not be symlinks: ${record.target}`);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
if (fs.existsSync(record.target) && sha256(fs.readFileSync(record.target)) !== record.installedHash) {
|
|
1249
|
+
const preserveDir = path.join(stateDir, "preserved-modified-tools");
|
|
1250
|
+
fs.mkdirSync(preserveDir, { recursive: true, mode: 0o700 });
|
|
1251
|
+
const destination = path.join(preserveDir, `${path.basename(record.target)}-${timestamp()}`);
|
|
1252
|
+
fs.renameSync(record.target, destination);
|
|
1253
|
+
preserved.push(destination);
|
|
1254
|
+
warnings.push(`Moved retired modified optional tool outside trusted PATH: ${destination}`);
|
|
1255
|
+
} else {
|
|
1256
|
+
fs.rmSync(record.target, { force: true });
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
fs.rmSync(record.marker, { force: true });
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
try {
|
|
1263
|
+
fs.rmdirSync(managedBinDir);
|
|
1264
|
+
} catch (error) {
|
|
1265
|
+
if (error.code !== "ENOENT" && error.code !== "ENOTEMPTY") {
|
|
1266
|
+
throw error;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
try {
|
|
1271
|
+
fs.rmdirSync(managedToolsDir);
|
|
1272
|
+
} catch (error) {
|
|
1273
|
+
if (error.code !== "ENOENT" && error.code !== "ENOTEMPTY") {
|
|
1274
|
+
throw error;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
async function installOrUpdate(options, update) {
|
|
1280
|
+
assertSources();
|
|
1281
|
+
const piAvailable = commandExists("pi");
|
|
1282
|
+
const shouldBootstrapPi = !piAvailable && !options.skipPackageInstall;
|
|
1283
|
+
if (shouldBootstrapPi && !commandExists("npm")) {
|
|
1284
|
+
throw new Error(`npm is required to install missing Pi ${PI_PACKAGE}@${PI_PACKAGE_VERSION}.`);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
if (piAvailable) {
|
|
1288
|
+
assertPiVersion();
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
if (!options.skipBrowserInstall && !commandExists("npm")) {
|
|
1292
|
+
throw new Error("npm is required to install the managed browser runtime.");
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
const releaseLock = acquireLock();
|
|
1296
|
+
let transaction;
|
|
1297
|
+
let piBootstrapAttempted = false;
|
|
1298
|
+
let piInstalledByOperation = false;
|
|
1299
|
+
let browserRuntimeTransaction;
|
|
1300
|
+
let optionalToolTransaction;
|
|
1301
|
+
let backupDir;
|
|
1302
|
+
const preservedRetiredTools = [];
|
|
1303
|
+
try {
|
|
1304
|
+
const previousManifest = readManifest(update);
|
|
1305
|
+
if (!update && previousManifest) {
|
|
1306
|
+
throw new Error(`SpecPi is already installed. Run ${CLI} update.`);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
const shellRc = previousManifest?.shellRc || (options.skipShell ? undefined : detectShellRc());
|
|
1310
|
+
const manageShellNow = Boolean(shellRc) && !options.skipShell;
|
|
1311
|
+
if (!options.skipShell && !shellRc) {
|
|
1312
|
+
console.warn("Shell integration skipped: SpecPi currently supports bash and zsh only.");
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
const files = managedFiles(manageShellNow);
|
|
1316
|
+
validateManagedUpdate(previousManifest, files, options.force);
|
|
1317
|
+
printPlan({ ...options, skipShell: !manageShellNow });
|
|
1318
|
+
await confirm(`${update ? "Update" : "Install"} SpecPi ${VERSION}?`, options.yes);
|
|
1319
|
+
if (shouldBootstrapPi) {
|
|
1320
|
+
piBootstrapAttempted = true;
|
|
1321
|
+
installPi();
|
|
1322
|
+
piInstalledByOperation = true;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
const selectedOptionalTools = await chooseOptionalTools(options);
|
|
1326
|
+
optionalToolTransaction = await installOptionalTools(selectedOptionalTools);
|
|
1327
|
+
|
|
1328
|
+
const watched = [
|
|
1329
|
+
settingsPath,
|
|
1330
|
+
...(previousManifest?.subagentConfigChanges ? [retiredSubagentConfigPath] : []),
|
|
1331
|
+
agentsPath,
|
|
1332
|
+
manifestPath,
|
|
1333
|
+
...(shellRc ? [shellRc] : []),
|
|
1334
|
+
...files.map(([, target]) => target),
|
|
1335
|
+
...Object.keys(previousManifest?.files || {}),
|
|
1336
|
+
...(previousManifest?.managedOptionalTools || []).flatMap((record) => [record.target, record.marker]),
|
|
1337
|
+
];
|
|
1338
|
+
assertNoBrokenSymlinks(watched);
|
|
1339
|
+
transaction = snapshot(watched);
|
|
1340
|
+
backupDir = path.join(stateDir, "backups", timestamp());
|
|
1341
|
+
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
|
|
1342
|
+
|
|
1343
|
+
const settingsBeforeOperation = readJson(settingsPath, {});
|
|
1344
|
+
const warnings = [...optionalToolTransaction.warnings];
|
|
1345
|
+
const blockFiles = structuredClone(previousManifest?.blockFiles || {});
|
|
1346
|
+
blockFiles.agents ||= { existed: pathExists(agentsPath) };
|
|
1347
|
+
if (shellRc) {
|
|
1348
|
+
blockFiles.shell ||= { existed: pathExists(shellRc) };
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
const packageChanges = buildPackageChanges(
|
|
1352
|
+
settingsBeforeOperation.packages || [],
|
|
1353
|
+
previousManifest?.packageChanges || [],
|
|
1354
|
+
);
|
|
1355
|
+
|
|
1356
|
+
if (!options.skipBrowserInstall) {
|
|
1357
|
+
browserRuntimeTransaction = installBrowserRuntime(warnings);
|
|
1358
|
+
} else if (!browserRuntimeStatus().installed) {
|
|
1359
|
+
warnings.push(
|
|
1360
|
+
"Managed browser runtime installation was explicitly skipped; browser tools will be unavailable.",
|
|
1361
|
+
);
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
if (!options.skipPackageInstall) {
|
|
1365
|
+
for (const spec of PACKAGES) {
|
|
1366
|
+
run("pi", ["install", spec]);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
const settings = readJson(settingsPath, {});
|
|
1371
|
+
const desiredPackageIds = new Set(PACKAGES.map(packageIdentity));
|
|
1372
|
+
const retiredPackageChanges = (previousManifest?.packageChanges || []).filter(
|
|
1373
|
+
(change) => !desiredPackageIds.has(change.identity),
|
|
1374
|
+
);
|
|
1375
|
+
settings.packages = restorePackageChanges(settings.packages, retiredPackageChanges, warnings);
|
|
1376
|
+
settings.packages = mergePackages(settings.packages, PACKAGES);
|
|
1377
|
+
|
|
1378
|
+
const operations = desiredSettingsOperations();
|
|
1379
|
+
retireSettings(settings, previousManifest?.settingsChanges || [], operations, warnings);
|
|
1380
|
+
const settingsChanges = applySettings(settings, operations, previousManifest?.settingsChanges || []);
|
|
1381
|
+
writeJson(settingsPath, settings, existingMode(settingsPath, 0o600));
|
|
1382
|
+
injectTestFailure("after-settings");
|
|
1383
|
+
|
|
1384
|
+
if (previousManifest?.subagentConfigChanges) {
|
|
1385
|
+
const retiredConfig = readJson(retiredSubagentConfigPath, {});
|
|
1386
|
+
restoreSettingChanges(retiredConfig, previousManifest.subagentConfigChanges, warnings);
|
|
1387
|
+
if (previousManifest.subagentConfigExisted === false && Object.keys(retiredConfig).length === 0) {
|
|
1388
|
+
fs.rmSync(retiredSubagentConfigPath, { force: true });
|
|
1389
|
+
} else {
|
|
1390
|
+
writeJson(retiredSubagentConfigPath, retiredConfig, existingMode(retiredSubagentConfigPath, 0o600));
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
const fileRecords = structuredClone(previousManifest?.files || {});
|
|
1395
|
+
const desiredTargets = new Set(files.map(([, target]) => target));
|
|
1396
|
+
if (update && options.skipShell && previousManifest?.shellRc) {
|
|
1397
|
+
desiredTargets.add(path.join(stateDir, "pi-profiles.sh"));
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
for (const [target, record] of Object.entries(fileRecords)) {
|
|
1401
|
+
if (desiredTargets.has(target)) {
|
|
1402
|
+
continue;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
restoreFileRecord(target, record, warnings, "update retirement");
|
|
1406
|
+
delete fileRecords[target];
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
let fileIndex = Object.keys(fileRecords).length;
|
|
1410
|
+
for (const [source, target, mode] of files) {
|
|
1411
|
+
const record = createOriginalFileRecord(target, backupDir, fileRecords[target], fileIndex++);
|
|
1412
|
+
const data = fs.readFileSync(source);
|
|
1413
|
+
atomicWrite(target, data, mode);
|
|
1414
|
+
if (target.startsWith(`${path.join(agentDir, "extensions", "command-guard")}${path.sep}`)) {
|
|
1415
|
+
injectTestFailure("after-first-command-guard-file");
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
record.installedHash = sha256(data);
|
|
1419
|
+
fileRecords[target] = record;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
const existingAgents = fs.existsSync(agentsPath) ? fs.readFileSync(agentsPath, "utf8") : "";
|
|
1423
|
+
atomicWrite(
|
|
1424
|
+
agentsPath,
|
|
1425
|
+
upsertManagedBlock(existingAgents, AGENTS_START, AGENTS_END, makeAgentsBlock()),
|
|
1426
|
+
existingMode(agentsPath, 0o644),
|
|
1427
|
+
);
|
|
1428
|
+
|
|
1429
|
+
if (manageShellNow) {
|
|
1430
|
+
const existingShell = fs.existsSync(shellRc) ? fs.readFileSync(shellRc, "utf8") : "";
|
|
1431
|
+
atomicWrite(
|
|
1432
|
+
shellRc,
|
|
1433
|
+
upsertManagedBlock(existingShell, SHELL_START, SHELL_END, makeShellBlock()),
|
|
1434
|
+
existingMode(shellRc, 0o644),
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
retireManagedOptionalToolRecords(previousManifest?.managedOptionalTools, warnings, preservedRetiredTools);
|
|
1439
|
+
|
|
1440
|
+
const manifest = {
|
|
1441
|
+
schema: 1,
|
|
1442
|
+
version: VERSION,
|
|
1443
|
+
installedAt: previousManifest?.installedAt || new Date().toISOString(),
|
|
1444
|
+
updatedAt: new Date().toISOString(),
|
|
1445
|
+
agentDir,
|
|
1446
|
+
shellRc,
|
|
1447
|
+
blockFiles,
|
|
1448
|
+
packagesKeyBeforeExists:
|
|
1449
|
+
previousManifest?.packagesKeyBeforeExists ?? Object.hasOwn(settingsBeforeOperation, "packages"),
|
|
1450
|
+
packageChanges,
|
|
1451
|
+
settingsChanges,
|
|
1452
|
+
browserRuntime: browserRuntimeStatus(),
|
|
1453
|
+
piBootstrap: piInstalledByOperation
|
|
1454
|
+
? {
|
|
1455
|
+
package: PI_PACKAGE,
|
|
1456
|
+
version: PI_PACKAGE_VERSION,
|
|
1457
|
+
installedAt: new Date().toISOString(),
|
|
1458
|
+
external: true,
|
|
1459
|
+
}
|
|
1460
|
+
: previousManifest?.piBootstrap,
|
|
1461
|
+
managedOptionalTools: optionalToolTransaction.managedRecords,
|
|
1462
|
+
files: fileRecords,
|
|
1463
|
+
backups: [...(previousManifest?.backups || []), path.relative(stateDir, backupDir)],
|
|
1464
|
+
};
|
|
1465
|
+
writeJson(manifestPath, manifest, 0o600);
|
|
1466
|
+
|
|
1467
|
+
if (!options.skipPackageInstall) {
|
|
1468
|
+
run("pi", ["--offline", "--list-models", "gpt"], { capture: true });
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
browserRuntimeTransaction?.commit();
|
|
1472
|
+
console.log(`\nSpecPi ${update ? "updated" : "installed"} successfully.`);
|
|
1473
|
+
console.log(`Manifest: ${manifestPath}`);
|
|
1474
|
+
for (const warning of warnings) {
|
|
1475
|
+
console.warn(`Warning: ${warning}`);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
console.log(
|
|
1479
|
+
update
|
|
1480
|
+
? `Run ${CLI} doctor, then restart active Pi sessions.`
|
|
1481
|
+
: `Run ${CLI} doctor, then /reload in active Pi sessions.`,
|
|
1482
|
+
);
|
|
1483
|
+
} catch (error) {
|
|
1484
|
+
const rollbackErrors = [];
|
|
1485
|
+
try {
|
|
1486
|
+
rollbackErrors.push(...(browserRuntimeTransaction?.rollback() || []));
|
|
1487
|
+
} catch (rollbackError) {
|
|
1488
|
+
rollbackErrors.push(`browser runtime rollback: ${rollbackError.message}`);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
try {
|
|
1492
|
+
rollbackErrors.push(...(optionalToolTransaction?.rollback() || []));
|
|
1493
|
+
} catch (rollbackError) {
|
|
1494
|
+
rollbackErrors.push(`managed optional tool rollback: ${rollbackError.message}`);
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
if (transaction) {
|
|
1498
|
+
try {
|
|
1499
|
+
restoreSnapshot(transaction);
|
|
1500
|
+
} catch (rollbackError) {
|
|
1501
|
+
rollbackErrors.push(`configuration rollback: ${rollbackError.message}`);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
for (const preserved of preservedRetiredTools) {
|
|
1506
|
+
try {
|
|
1507
|
+
fs.rmSync(preserved, { force: true });
|
|
1508
|
+
} catch (rollbackError) {
|
|
1509
|
+
rollbackErrors.push(`retired tool cleanup: ${rollbackError.message}`);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
if (backupDir) {
|
|
1514
|
+
try {
|
|
1515
|
+
fs.rmSync(backupDir, { recursive: true, force: true });
|
|
1516
|
+
} catch (rollbackError) {
|
|
1517
|
+
rollbackErrors.push(`backup cleanup: ${rollbackError.message}`);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
const optionalWarnings = optionalToolTransaction?.warnings.length
|
|
1522
|
+
? `\nOptional tool warnings: ${optionalToolTransaction.warnings.join("; ")}`
|
|
1523
|
+
: "";
|
|
1524
|
+
const externalNotes = [];
|
|
1525
|
+
if (piBootstrapAttempted) {
|
|
1526
|
+
externalNotes.push(
|
|
1527
|
+
`Pi bootstrap ${PI_PACKAGE}@${PI_PACKAGE_VERSION} was attempted and is not rolled back automatically`,
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
if (optionalToolTransaction?.externalAttempts.length) {
|
|
1532
|
+
externalNotes.push(
|
|
1533
|
+
`External optional tool changes were not rolled back: ${optionalToolTransaction.externalAttempts.join(", ")}`,
|
|
1534
|
+
);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
const externalNote = externalNotes.length ? `\n${externalNotes.join("\n")}` : "";
|
|
1538
|
+
throw new Error(
|
|
1539
|
+
`${transaction ? "SpecPi-managed changes rolled back: " : ""}${error.message}${optionalWarnings}${externalNote}${rollbackErrors.length ? `\nSecondary rollback errors: ${rollbackErrors.join("; ")}` : ""}`,
|
|
1540
|
+
);
|
|
1541
|
+
} finally {
|
|
1542
|
+
releaseLock();
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
function restoreSettingChanges(settings, changes, warnings) {
|
|
1547
|
+
for (const change of changes) {
|
|
1548
|
+
const current = readPath(settings, change.path);
|
|
1549
|
+
if (Array.isArray(change.managedArrayEntries)) {
|
|
1550
|
+
if (!current.exists || !Array.isArray(current.value)) {
|
|
1551
|
+
warnings.push(`Preserved modified setting: ${change.path.join(".")}`);
|
|
1552
|
+
continue;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
if (change.beforeExists && !Array.isArray(change.before)) {
|
|
1556
|
+
if (change.installedExists && deepEqual(current.value, change.installed)) {
|
|
1557
|
+
setPath(settings, change.path, structuredClone(change.before));
|
|
1558
|
+
} else {
|
|
1559
|
+
warnings.push(
|
|
1560
|
+
`Preserved modified setting instead of restoring non-array value: ${change.path.join(".")}`,
|
|
1561
|
+
);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
continue;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
const originallyPresent = new Set(
|
|
1568
|
+
change.beforeExists && Array.isArray(change.before)
|
|
1569
|
+
? change.managedArrayEntries
|
|
1570
|
+
.filter((entry) => change.before.some((value) => deepEqual(value, entry)))
|
|
1571
|
+
.map((entry) => JSON.stringify(entry))
|
|
1572
|
+
: [],
|
|
1573
|
+
);
|
|
1574
|
+
const restored = current.value.filter((value) => {
|
|
1575
|
+
const managed = change.managedArrayEntries.some((entry) => deepEqual(value, entry));
|
|
1576
|
+
|
|
1577
|
+
return !managed || originallyPresent.has(JSON.stringify(value));
|
|
1578
|
+
});
|
|
1579
|
+
if (!change.beforeExists && restored.length === 0) {
|
|
1580
|
+
deletePath(settings, change.path);
|
|
1581
|
+
} else {
|
|
1582
|
+
setPath(settings, change.path, restored);
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
continue;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
const matchesInstalled = change.dynamicPolicy
|
|
1589
|
+
? current.exists && validManagedModelScope(current.value)
|
|
1590
|
+
: change.installedExists
|
|
1591
|
+
? current.exists && deepEqual(current.value, change.installed)
|
|
1592
|
+
: !current.exists;
|
|
1593
|
+
if (!matchesInstalled) {
|
|
1594
|
+
warnings.push(`Preserved modified setting: ${change.path.join(".")}`);
|
|
1595
|
+
continue;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
if (change.beforeExists) {
|
|
1599
|
+
setPath(settings, change.path, change.before);
|
|
1600
|
+
} else {
|
|
1601
|
+
deletePath(settings, change.path);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
async function uninstall(options) {
|
|
1607
|
+
const releaseLock = acquireLock();
|
|
1608
|
+
let transaction;
|
|
1609
|
+
let retiredBrowserRuntime;
|
|
1610
|
+
const preservedManagedTools = [];
|
|
1611
|
+
try {
|
|
1612
|
+
const manifest = readManifest(true);
|
|
1613
|
+
await confirm(`Uninstall SpecPi ${manifest.version}?`, options.yes);
|
|
1614
|
+
const watched = [
|
|
1615
|
+
settingsPath,
|
|
1616
|
+
...(manifest.subagentConfigChanges ? [retiredSubagentConfigPath] : []),
|
|
1617
|
+
agentsPath,
|
|
1618
|
+
manifestPath,
|
|
1619
|
+
...(manifest.shellRc ? [manifest.shellRc] : []),
|
|
1620
|
+
...Object.keys(manifest.files || {}),
|
|
1621
|
+
...(manifest.managedOptionalTools || []).flatMap((record) => [record.target, record.marker]),
|
|
1622
|
+
];
|
|
1623
|
+
assertNoBrokenSymlinks(watched);
|
|
1624
|
+
for (const record of manifest.managedOptionalTools || []) {
|
|
1625
|
+
if (lstatMaybe(record.target)?.isSymbolicLink() || lstatMaybe(record.marker)?.isSymbolicLink()) {
|
|
1626
|
+
throw new Error(`Managed optional tool paths must not be symlinks during uninstall: ${record.target}`);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
transaction = snapshot(watched);
|
|
1631
|
+
const warnings = [];
|
|
1632
|
+
|
|
1633
|
+
const settings = readJson(settingsPath, {});
|
|
1634
|
+
restoreSettingChanges(settings, manifest.settingsChanges || [], warnings);
|
|
1635
|
+
settings.packages = restorePackageChanges(settings.packages, manifest.packageChanges || [], warnings);
|
|
1636
|
+
if (!manifest.packagesKeyBeforeExists && settings.packages.length === 0) {
|
|
1637
|
+
delete settings.packages;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
writeJson(settingsPath, settings, existingMode(settingsPath, 0o600));
|
|
1641
|
+
|
|
1642
|
+
if (manifest.subagentConfigChanges) {
|
|
1643
|
+
const retiredConfig = readJson(retiredSubagentConfigPath, {});
|
|
1644
|
+
restoreSettingChanges(retiredConfig, manifest.subagentConfigChanges, warnings);
|
|
1645
|
+
if (manifest.subagentConfigExisted === false && Object.keys(retiredConfig).length === 0) {
|
|
1646
|
+
fs.rmSync(retiredSubagentConfigPath, { force: true });
|
|
1647
|
+
} else {
|
|
1648
|
+
writeJson(retiredSubagentConfigPath, retiredConfig, existingMode(retiredSubagentConfigPath, 0o600));
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
for (const [target, record] of Object.entries(manifest.files || {})) {
|
|
1653
|
+
restoreFileRecord(target, record, warnings, "uninstall");
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
if (fs.existsSync(agentsPath)) {
|
|
1657
|
+
const result = removeManagedBlock(fs.readFileSync(agentsPath, "utf8"), AGENTS_START, AGENTS_END);
|
|
1658
|
+
finishManagedBlockRemoval(agentsPath, result, manifest.blockFiles?.agents?.existed ?? true);
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
if (manifest.shellRc && fs.existsSync(manifest.shellRc)) {
|
|
1662
|
+
const result = removeManagedBlock(fs.readFileSync(manifest.shellRc, "utf8"), SHELL_START, SHELL_END);
|
|
1663
|
+
finishManagedBlockRemoval(manifest.shellRc, result, manifest.blockFiles?.shell?.existed ?? true);
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
for (const record of manifest.managedOptionalTools || []) {
|
|
1667
|
+
if (fs.existsSync(record.target) && sha256(fs.readFileSync(record.target)) !== record.installedHash) {
|
|
1668
|
+
const preserveDir = path.join(stateDir, "preserved-modified-tools");
|
|
1669
|
+
fs.mkdirSync(preserveDir, { recursive: true, mode: 0o700 });
|
|
1670
|
+
const preserved = path.join(preserveDir, `${path.basename(record.target)}-${timestamp()}`);
|
|
1671
|
+
fs.renameSync(record.target, preserved);
|
|
1672
|
+
fs.rmSync(record.marker, { force: true });
|
|
1673
|
+
preservedManagedTools.push(preserved);
|
|
1674
|
+
warnings.push(`Moved modified managed optional tool outside trusted PATH: ${preserved}`);
|
|
1675
|
+
continue;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
fs.rmSync(record.target, { force: true });
|
|
1679
|
+
fs.rmSync(record.marker, { force: true });
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
try {
|
|
1683
|
+
fs.rmdirSync(managedBinDir);
|
|
1684
|
+
} catch (error) {
|
|
1685
|
+
if (error.code !== "ENOENT" && error.code !== "ENOTEMPTY") {
|
|
1686
|
+
throw error;
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
try {
|
|
1691
|
+
fs.rmdirSync(managedToolsDir);
|
|
1692
|
+
} catch (error) {
|
|
1693
|
+
if (error.code !== "ENOENT" && error.code !== "ENOTEMPTY") {
|
|
1694
|
+
throw error;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
if (fs.existsSync(browserRuntimeDir)) {
|
|
1699
|
+
retiredBrowserRuntime = path.join(stateDir, `.browser-runtime-uninstall-${process.pid}-${Date.now()}`);
|
|
1700
|
+
fs.renameSync(browserRuntimeDir, retiredBrowserRuntime);
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
fs.rmSync(manifestPath, { force: true });
|
|
1704
|
+
|
|
1705
|
+
if (retiredBrowserRuntime) {
|
|
1706
|
+
try {
|
|
1707
|
+
fs.rmSync(retiredBrowserRuntime, { recursive: true, force: true });
|
|
1708
|
+
} catch (error) {
|
|
1709
|
+
warnings.push(`Could not remove retired browser runtime ${retiredBrowserRuntime}: ${error.message}`);
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
retiredBrowserRuntime = undefined;
|
|
1714
|
+
console.log("SpecPi configuration, managed optional tools, and managed browser runtime uninstalled.");
|
|
1715
|
+
console.log(
|
|
1716
|
+
"Externally installed Pi, optional tools, browser artifacts, downloaded Pi package caches, and local wishlist state/archives were preserved. Experiment metadata and exported patches were also preserved.",
|
|
1717
|
+
);
|
|
1718
|
+
console.log(`Wishlist state: ${stateDir}`);
|
|
1719
|
+
for (const warning of warnings) {
|
|
1720
|
+
console.warn(`Warning: ${warning}`);
|
|
1721
|
+
}
|
|
1722
|
+
} catch (error) {
|
|
1723
|
+
const rollbackErrors = [];
|
|
1724
|
+
if (retiredBrowserRuntime && fs.existsSync(retiredBrowserRuntime) && !fs.existsSync(browserRuntimeDir)) {
|
|
1725
|
+
try {
|
|
1726
|
+
fs.renameSync(retiredBrowserRuntime, browserRuntimeDir);
|
|
1727
|
+
} catch (rollbackError) {
|
|
1728
|
+
rollbackErrors.push(`browser runtime restore: ${rollbackError.message}`);
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
if (transaction) {
|
|
1733
|
+
try {
|
|
1734
|
+
restoreSnapshot(transaction);
|
|
1735
|
+
} catch (rollbackError) {
|
|
1736
|
+
rollbackErrors.push(`configuration rollback: ${rollbackError.message}`);
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
for (const preserved of preservedManagedTools) {
|
|
1741
|
+
try {
|
|
1742
|
+
fs.rmSync(preserved, { force: true });
|
|
1743
|
+
} catch (rollbackError) {
|
|
1744
|
+
rollbackErrors.push(`preserved tool cleanup: ${rollbackError.message}`);
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
throw new Error(
|
|
1749
|
+
`${transaction ? "Uninstall rolled back: " : ""}${error.message}${rollbackErrors.length ? `\nSecondary rollback errors: ${rollbackErrors.join("; ")}` : ""}`,
|
|
1750
|
+
);
|
|
1751
|
+
} finally {
|
|
1752
|
+
releaseLock();
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
async function doctor() {
|
|
1757
|
+
assertSources();
|
|
1758
|
+
const manifest = readManifest(true);
|
|
1759
|
+
const errors = [];
|
|
1760
|
+
const warnings = [];
|
|
1761
|
+
const commandGuardRoot = path.resolve(agentDir, "extensions", "command-guard");
|
|
1762
|
+
let commandGuardIntegrity = true;
|
|
1763
|
+
const settings = readJson(settingsPath, {});
|
|
1764
|
+
const commandGuardHelper = path.join(commandGuardRoot, "powershell-parser.ps1");
|
|
1765
|
+
try {
|
|
1766
|
+
fs.accessSync(commandGuardHelper, fs.constants.R_OK);
|
|
1767
|
+
} catch {
|
|
1768
|
+
errors.push(`PowerShell parser helper is missing or unreadable: ${commandGuardHelper}`);
|
|
1769
|
+
commandGuardIntegrity = false;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
const windowsPowerShellPath =
|
|
1773
|
+
process.platform === "win32"
|
|
1774
|
+
? resolveCommand("powershell.exe") ||
|
|
1775
|
+
(process.env.SystemRoot
|
|
1776
|
+
? path.join(process.env.SystemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
|
1777
|
+
: undefined)
|
|
1778
|
+
: undefined;
|
|
1779
|
+
const powerShell7Path =
|
|
1780
|
+
process.platform === "win32"
|
|
1781
|
+
? resolveCommand("pwsh.exe") ||
|
|
1782
|
+
(process.env.ProgramFiles
|
|
1783
|
+
? path.join(process.env.ProgramFiles, "PowerShell", "7", "pwsh.exe")
|
|
1784
|
+
: undefined)
|
|
1785
|
+
: undefined;
|
|
1786
|
+
const powershellStatus =
|
|
1787
|
+
process.platform === "win32"
|
|
1788
|
+
? {
|
|
1789
|
+
windowsPowerShell:
|
|
1790
|
+
windowsPowerShellPath && fs.existsSync(windowsPowerShellPath)
|
|
1791
|
+
? powerShellVersion(windowsPowerShellPath)
|
|
1792
|
+
: undefined,
|
|
1793
|
+
powerShell7:
|
|
1794
|
+
powerShell7Path && fs.existsSync(powerShell7Path)
|
|
1795
|
+
? powerShellVersion(powerShell7Path)
|
|
1796
|
+
: undefined,
|
|
1797
|
+
}
|
|
1798
|
+
: undefined;
|
|
1799
|
+
// The guard parses with whichever installed host accepts the command text, so either one is sufficient.
|
|
1800
|
+
if (powershellStatus && !powershellStatus.windowsPowerShell && !powershellStatus.powerShell7) {
|
|
1801
|
+
errors.push("No PowerShell parser host is available; PowerShell tool calls will be denied.");
|
|
1802
|
+
} else if (powershellStatus && !powershellStatus.windowsPowerShell) {
|
|
1803
|
+
warnings.push("Windows PowerShell 5.1 is unavailable; the command guard parses with PowerShell 7 only.");
|
|
1804
|
+
} else if (powershellStatus && !powershellStatus.powerShell7) {
|
|
1805
|
+
warnings.push(
|
|
1806
|
+
"PowerShell 7 is unavailable; the command guard parses with Windows PowerShell 5.1 only, which rejects PowerShell 7 syntax.",
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
let capabilityRegistry;
|
|
1811
|
+
try {
|
|
1812
|
+
capabilityRegistry = validateCapabilityRegistry(JSON.parse(fs.readFileSync(capabilityRegistryPath, "utf8")));
|
|
1813
|
+
} catch (error) {
|
|
1814
|
+
errors.push(`Capability registry invalid: ${error.message}`);
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
for (const operation of desiredSettingsOperations()) {
|
|
1818
|
+
const current = readPath(settings, operation.path);
|
|
1819
|
+
const valid = operation.delete
|
|
1820
|
+
? !current.exists
|
|
1821
|
+
: operation.arrayIncludes
|
|
1822
|
+
? current.exists &&
|
|
1823
|
+
Array.isArray(current.value) &&
|
|
1824
|
+
operation.arrayIncludes.every((entry) => current.value.some((value) => deepEqual(value, entry)))
|
|
1825
|
+
: operation.userTunable || operation.dynamicPolicy
|
|
1826
|
+
? current.exists && (!operation.validate || operation.validate(current.value))
|
|
1827
|
+
: current.exists && deepEqual(current.value, operation.value);
|
|
1828
|
+
if (!valid) {
|
|
1829
|
+
errors.push(
|
|
1830
|
+
`${operation.userTunable ? "Invalid user-tunable setting" : "Setting drift"}: ${operation.path.join(".")}`,
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
for (const spec of PACKAGES) {
|
|
1836
|
+
const found = (settings.packages || []).find((entry) => packageIdentity(entry) === packageIdentity(spec));
|
|
1837
|
+
if (!deepEqual(found, spec)) {
|
|
1838
|
+
errors.push(`Missing or unpinned package setting: ${spec}`);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
for (const [target, record] of Object.entries(manifest.files || {})) {
|
|
1843
|
+
const resolvedTarget = path.resolve(target);
|
|
1844
|
+
const commandGuardFile =
|
|
1845
|
+
resolvedTarget === commandGuardRoot || resolvedTarget.startsWith(`${commandGuardRoot}${path.sep}`);
|
|
1846
|
+
if (!fs.existsSync(target)) {
|
|
1847
|
+
errors.push(`Missing managed file: ${target}`);
|
|
1848
|
+
if (commandGuardFile) {
|
|
1849
|
+
commandGuardIntegrity = false;
|
|
1850
|
+
}
|
|
1851
|
+
} else if (sha256(fs.readFileSync(target)) !== record.installedHash) {
|
|
1852
|
+
if (commandGuardFile) {
|
|
1853
|
+
commandGuardIntegrity = false;
|
|
1854
|
+
errors.push(`Modified command-guard file: ${target}`);
|
|
1855
|
+
} else {
|
|
1856
|
+
warnings.push(`Modified managed file: ${target}`);
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
const agents = fs.existsSync(agentsPath) ? fs.readFileSync(agentsPath, "utf8") : "";
|
|
1862
|
+
if (!agents.includes(AGENTS_START) || !agents.includes(AGENTS_END)) {
|
|
1863
|
+
errors.push("Missing SpecPi AGENTS.md block");
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
if (manifest.shellRc) {
|
|
1867
|
+
const shell = fs.existsSync(manifest.shellRc) ? fs.readFileSync(manifest.shellRc, "utf8") : "";
|
|
1868
|
+
if (!shell.includes(SHELL_START) || !shell.includes(SHELL_END)) {
|
|
1869
|
+
errors.push("Missing SpecPi shell block");
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
for (const record of manifest.managedOptionalTools || []) {
|
|
1874
|
+
if (!fs.existsSync(record.target)) {
|
|
1875
|
+
errors.push(`Missing managed optional tool: ${record.target}`);
|
|
1876
|
+
} else if (sha256(fs.readFileSync(record.target)) !== record.installedHash) {
|
|
1877
|
+
warnings.push(`Modified managed optional tool: ${record.target}`);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
const runtimeStatus = browserRuntimeStatus();
|
|
1882
|
+
let browserSmoke;
|
|
1883
|
+
if (!runtimeStatus.installed) {
|
|
1884
|
+
const message = `Managed browser runtime unavailable: ${runtimeStatus.reason}`;
|
|
1885
|
+
if (manifest.browserRuntime?.installed === false) {
|
|
1886
|
+
warnings.push(`${message} (installation was skipped)`);
|
|
1887
|
+
} else {
|
|
1888
|
+
errors.push(message);
|
|
1889
|
+
}
|
|
1890
|
+
} else if (!fs.existsSync(browserSmokePath)) {
|
|
1891
|
+
errors.push(`Browser smoke probe is missing: ${browserSmokePath}`);
|
|
1892
|
+
} else {
|
|
1893
|
+
try {
|
|
1894
|
+
const result = smokeBrowserRuntime(browserRuntimeDir);
|
|
1895
|
+
browserSmoke = (result.stdout || "").trim();
|
|
1896
|
+
} catch (error) {
|
|
1897
|
+
errors.push(`Browser smoke failed: ${error.message}`);
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
if (!commandExists("pi")) {
|
|
1902
|
+
errors.push("pi is not available on PATH");
|
|
1903
|
+
} else {
|
|
1904
|
+
try {
|
|
1905
|
+
assertPiVersion();
|
|
1906
|
+
} catch (error) {
|
|
1907
|
+
errors.push(error.message);
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
if (!commandExists("donsetch")) {
|
|
1912
|
+
warnings.push("donsetch is missing (optional skill prerequisite)");
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
console.log(`SpecPi ${manifest.version} doctor`);
|
|
1916
|
+
console.log(`Agent directory: ${agentDir}`);
|
|
1917
|
+
if (powershellStatus) {
|
|
1918
|
+
console.log(
|
|
1919
|
+
`POWERSHELL WindowsPowerShell=${powershellStatus.windowsPowerShell || "unavailable"} PowerShell7=${powershellStatus.powerShell7 || "unavailable"}`,
|
|
1920
|
+
);
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
if (browserSmoke) {
|
|
1924
|
+
console.log(`BROWSER ${browserSmoke}`);
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
const validatorOutcomes = new Map();
|
|
1928
|
+
for (const capability of capabilityRegistry?.capabilities || []) {
|
|
1929
|
+
for (const validator of capability.validations || []) {
|
|
1930
|
+
if (!isValidValidatorName(validator)) {
|
|
1931
|
+
errors.push(`Capability ${capability.id} links unknown validator ${validator}`);
|
|
1932
|
+
continue;
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
if (validatorOutcomes.has(validator)) {
|
|
1936
|
+
continue;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
if (validator === "command-guard-smoke" && !commandGuardIntegrity) {
|
|
1940
|
+
validatorOutcomes.set(validator, {
|
|
1941
|
+
status: "fail",
|
|
1942
|
+
detail: "installed command-guard checksum integrity failed",
|
|
1943
|
+
});
|
|
1944
|
+
continue;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
if (validator === "browser-runtime-smoke") {
|
|
1948
|
+
// The browser smoke already ran above; reuse its outcome instead of launching Chromium twice.
|
|
1949
|
+
validatorOutcomes.set(
|
|
1950
|
+
validator,
|
|
1951
|
+
browserSmoke
|
|
1952
|
+
? { status: "pass" }
|
|
1953
|
+
: { status: manifest.browserRuntime?.installed === false ? "skipped" : "fail" },
|
|
1954
|
+
);
|
|
1955
|
+
continue;
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
const run = runValidator(validator, { cwd: repoRoot });
|
|
1959
|
+
validatorOutcomes.set(
|
|
1960
|
+
validator,
|
|
1961
|
+
run.code === 0
|
|
1962
|
+
? { status: "pass" }
|
|
1963
|
+
: { status: "fail", detail: `${(run.stderr || run.stdout || "").trim().slice(0, 300)}` },
|
|
1964
|
+
);
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
for (const capability of capabilityRegistry?.capabilities || []) {
|
|
1969
|
+
for (const validator of capability.validations || []) {
|
|
1970
|
+
const outcome = validatorOutcomes.get(validator);
|
|
1971
|
+
if (!outcome) {
|
|
1972
|
+
continue;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
if (outcome.status === "pass") {
|
|
1976
|
+
console.log(`CAPABILITY ${capability.id} verified by ${validator}`);
|
|
1977
|
+
} else if (validator === "browser-runtime-smoke" && outcome.status === "skipped") {
|
|
1978
|
+
warnings.push(
|
|
1979
|
+
`Capability ${capability.id} could not be verified because browser runtime installation was skipped`,
|
|
1980
|
+
);
|
|
1981
|
+
} else {
|
|
1982
|
+
errors.push(
|
|
1983
|
+
`Capability ${capability.id} validator ${validator} failed${outcome.detail ? `: ${outcome.detail}` : ""}`,
|
|
1984
|
+
);
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
for (const warning of warnings) {
|
|
1990
|
+
console.warn(`WARN ${warning}`);
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
for (const error of errors) {
|
|
1994
|
+
console.error(`ERROR ${error}`);
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
if (errors.length) {
|
|
1998
|
+
console.error(`\nDoctor failed with ${errors.length} error(s).`);
|
|
1999
|
+
process.exitCode = 1;
|
|
2000
|
+
} else {
|
|
2001
|
+
console.log(`\nOK (${warnings.length} warning${warnings.length === 1 ? "" : "s"})`);
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
async function main() {
|
|
2006
|
+
const options = parseArgs(process.argv.slice(2));
|
|
2007
|
+
switch (options.command) {
|
|
2008
|
+
case "help":
|
|
2009
|
+
case "--help":
|
|
2010
|
+
case "-h":
|
|
2011
|
+
usage();
|
|
2012
|
+
break;
|
|
2013
|
+
case "plan":
|
|
2014
|
+
assertSources();
|
|
2015
|
+
printPlan(options);
|
|
2016
|
+
break;
|
|
2017
|
+
case "install":
|
|
2018
|
+
await installOrUpdate(options, false);
|
|
2019
|
+
break;
|
|
2020
|
+
case "update":
|
|
2021
|
+
await installOrUpdate(options, true);
|
|
2022
|
+
break;
|
|
2023
|
+
case "doctor":
|
|
2024
|
+
await doctor();
|
|
2025
|
+
break;
|
|
2026
|
+
case "uninstall":
|
|
2027
|
+
await uninstall(options);
|
|
2028
|
+
break;
|
|
2029
|
+
default:
|
|
2030
|
+
throw new Error(`Unknown command: ${options.command}`);
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
main().catch((error) => {
|
|
2035
|
+
console.error(`SpecPi: ${error.message}`);
|
|
2036
|
+
process.exitCode = 1;
|
|
2037
|
+
});
|