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.
Files changed (57) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/LICENSE +21 -0
  3. package/NPM_RELEASE.md +110 -0
  4. package/README.md +155 -0
  5. package/SECURITY.md +85 -0
  6. package/SECURITY_MODEL.md +107 -0
  7. package/THIRD_PARTY.md +61 -0
  8. package/browser-runtime/package-lock.json +86 -0
  9. package/browser-runtime/package.json +15 -0
  10. package/extensions/browser/core.mjs +306 -0
  11. package/extensions/browser/index.ts +723 -0
  12. package/extensions/browser/smoke.mjs +47 -0
  13. package/extensions/command-guard/bash.mjs +1426 -0
  14. package/extensions/command-guard/cmd.mjs +369 -0
  15. package/extensions/command-guard/core.mjs +506 -0
  16. package/extensions/command-guard/index.ts +634 -0
  17. package/extensions/command-guard/managed-files.mjs +22 -0
  18. package/extensions/command-guard/paths.mjs +398 -0
  19. package/extensions/command-guard/powershell-parser.ps1 +47 -0
  20. package/extensions/command-guard/powershell.mjs +655 -0
  21. package/extensions/command-guard/redact.mjs +65 -0
  22. package/extensions/command-guard/rules.mjs +2557 -0
  23. package/extensions/command-guard/smoke.mjs +422 -0
  24. package/extensions/files/core.mjs +422 -0
  25. package/extensions/files/index.ts +678 -0
  26. package/extensions/spec/core.mjs +47 -0
  27. package/extensions/spec.ts +457 -0
  28. package/extensions/tool-wishlist/capabilities.json +114 -0
  29. package/extensions/tool-wishlist/core.mjs +1525 -0
  30. package/extensions/tool-wishlist/index.ts +804 -0
  31. package/extensions/tool-wishlist/registry.mjs +99 -0
  32. package/extensions/tool-wishlist/validators.mjs +345 -0
  33. package/extensions/ui-refresh/index.ts +54 -0
  34. package/extensions/workflow-controls/challenge.mjs +196 -0
  35. package/extensions/workflow-controls/experiments.mjs +628 -0
  36. package/extensions/workflow-controls/index.ts +1144 -0
  37. package/extensions/workflow-controls/scope.mjs +272 -0
  38. package/extensions/workflow-controls/smoke.mjs +201 -0
  39. package/package.json +98 -0
  40. package/scripts/check-package.mjs +483 -0
  41. package/scripts/check-pi-package.mjs +223 -0
  42. package/scripts/check-release-order.mjs +97 -0
  43. package/scripts/lib.mjs +182 -0
  44. package/scripts/lock.mjs +122 -0
  45. package/scripts/specpi.mjs +2037 -0
  46. package/scripts/verify-artifact.mjs +21 -0
  47. package/shell/pi-profiles.sh +14 -0
  48. package/site/logo.svg +9 -0
  49. package/site/self-improvement-loop-v2.svg +108 -0
  50. package/skills/donsetch/SKILL.md +76 -0
  51. package/skills/specpi-improve/SKILL.md +54 -0
  52. package/specpi +4 -0
  53. package/specpi.cmd +4 -0
  54. package/templates/AGENTS.md +23 -0
  55. package/templates/settings.json +10 -0
  56. package/themes/specpi-spec.json +96 -0
  57. package/themes/tea-house.json +89 -0
@@ -0,0 +1,483 @@
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 { createHash } from "node:crypto";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
12
+ const repoRoot = path.resolve(scriptDir, "..");
13
+ const npmCli = process.env.npm_execpath;
14
+ const artifactIndex = process.argv.indexOf("--artifact");
15
+ const manifestIndex = process.argv.indexOf("--manifest");
16
+ const artifactPath = artifactIndex >= 0 ? process.argv[artifactIndex + 1] : undefined;
17
+ const artifactManifestPath = manifestIndex >= 0 ? process.argv[manifestIndex + 1] : undefined;
18
+ const hasArtifact = artifactIndex >= 0;
19
+ const hasManifest = manifestIndex >= 0;
20
+
21
+ if (
22
+ hasArtifact !== hasManifest ||
23
+ (hasArtifact && (!artifactPath || !artifactManifestPath)) ||
24
+ (!hasArtifact && process.argv.length > 2)
25
+ ) {
26
+ throw new Error("Use no arguments, or supply --artifact <tarball> and --manifest <npm-pack.json> together");
27
+ }
28
+
29
+ if (!npmCli || !fs.existsSync(npmCli)) {
30
+ throw new Error("Run package validation through npm so npm_execpath identifies the active npm CLI");
31
+ }
32
+
33
+ const requiredFiles = [
34
+ "CHANGELOG.md",
35
+ "LICENSE",
36
+ "NPM_RELEASE.md",
37
+ "README.md",
38
+ "SECURITY.md",
39
+ "SECURITY_MODEL.md",
40
+ "THIRD_PARTY.md",
41
+ "browser-runtime/package-lock.json",
42
+ "browser-runtime/package.json",
43
+ "extensions/browser/core.mjs",
44
+ "extensions/browser/index.ts",
45
+ "extensions/browser/smoke.mjs",
46
+ "extensions/command-guard/bash.mjs",
47
+ "extensions/command-guard/cmd.mjs",
48
+ "extensions/command-guard/core.mjs",
49
+ "extensions/command-guard/index.ts",
50
+ "extensions/command-guard/managed-files.mjs",
51
+ "extensions/command-guard/paths.mjs",
52
+ "extensions/command-guard/powershell-parser.ps1",
53
+ "extensions/command-guard/powershell.mjs",
54
+ "extensions/command-guard/redact.mjs",
55
+ "extensions/command-guard/rules.mjs",
56
+ "extensions/command-guard/smoke.mjs",
57
+ "extensions/files/core.mjs",
58
+ "extensions/files/index.ts",
59
+ "extensions/spec.ts",
60
+ "extensions/spec/core.mjs",
61
+ "extensions/tool-wishlist/capabilities.json",
62
+ "extensions/tool-wishlist/core.mjs",
63
+ "extensions/tool-wishlist/index.ts",
64
+ "extensions/tool-wishlist/registry.mjs",
65
+ "extensions/tool-wishlist/validators.mjs",
66
+ "extensions/ui-refresh/index.ts",
67
+ "extensions/workflow-controls/challenge.mjs",
68
+ "extensions/workflow-controls/experiments.mjs",
69
+ "extensions/workflow-controls/index.ts",
70
+ "extensions/workflow-controls/scope.mjs",
71
+ "extensions/workflow-controls/smoke.mjs",
72
+ "package.json",
73
+ "scripts/check-package.mjs",
74
+ "scripts/check-pi-package.mjs",
75
+ "scripts/check-release-order.mjs",
76
+ "scripts/lib.mjs",
77
+ "scripts/lock.mjs",
78
+ "scripts/specpi.mjs",
79
+ "scripts/verify-artifact.mjs",
80
+ "shell/pi-profiles.sh",
81
+ "site/logo.svg",
82
+ "site/self-improvement-loop-v2.svg",
83
+ "skills/donsetch/SKILL.md",
84
+ "skills/specpi-improve/SKILL.md",
85
+ "specpi",
86
+ "specpi.cmd",
87
+ "templates/AGENTS.md",
88
+ "templates/settings.json",
89
+ "themes/specpi-spec.json",
90
+ "themes/tea-house.json",
91
+ ];
92
+
93
+ const hostPeerPackages = [
94
+ "@earendil-works/pi-ai",
95
+ "@earendil-works/pi-coding-agent",
96
+ "@earendil-works/pi-tui",
97
+ "typebox",
98
+ ];
99
+
100
+ const forbiddenPrefixes = [
101
+ ".git/",
102
+ ".github/",
103
+ ".pi/",
104
+ "design/",
105
+ "node_modules/",
106
+ "tests/",
107
+ "browser-runtime/node_modules/",
108
+ ];
109
+
110
+ function runNode(args, options = {}) {
111
+ const result = spawnSync(process.execPath, args, {
112
+ cwd: options.cwd || repoRoot,
113
+ env: options.env || process.env,
114
+ encoding: "utf8",
115
+ windowsHide: true,
116
+ timeout: options.timeout || 180_000,
117
+ maxBuffer: 20 * 1024 * 1024,
118
+ });
119
+ if (result.error) {
120
+ throw result.error;
121
+ }
122
+
123
+ if (result.status !== 0) {
124
+ throw new Error(
125
+ `${process.execPath} ${args.join(" ")} failed (${result.status})\n${result.stdout || ""}${result.stderr || ""}`,
126
+ );
127
+ }
128
+
129
+ return result;
130
+ }
131
+
132
+ function runNpm(args, options = {}) {
133
+ return runNode([npmCli, ...args], options);
134
+ }
135
+
136
+ function quoteWindowsCommandArg(value) {
137
+ const text = String(value);
138
+ if (text.includes("%")) {
139
+ throw new Error(`cmd.exe expands "%" on its command line and cannot receive this argument safely: ${text}`);
140
+ }
141
+
142
+ return `"${text.replaceAll('"', '""')}"`;
143
+ }
144
+
145
+ function runInstalledBin(binPath, args, options = {}) {
146
+ const common = {
147
+ cwd: options.cwd || repoRoot,
148
+ env: options.env || process.env,
149
+ encoding: "utf8",
150
+ windowsHide: true,
151
+ timeout: options.timeout || 180_000,
152
+ maxBuffer: 20 * 1024 * 1024,
153
+ };
154
+ const result =
155
+ process.platform === "win32"
156
+ ? spawnSync([binPath, ...args].map(quoteWindowsCommandArg).join(" "), {
157
+ ...common,
158
+ shell: process.env.ComSpec || "cmd.exe",
159
+ })
160
+ : spawnSync(binPath, args, common);
161
+ if (result.error) {
162
+ throw result.error;
163
+ }
164
+
165
+ if (options.expectFailure) {
166
+ assert.notEqual(result.status, 0, `installed SpecPi bin ${args.join(" ")} unexpectedly succeeded`);
167
+ } else if (result.status !== 0) {
168
+ throw new Error(
169
+ `installed SpecPi bin ${args.join(" ")} failed (${result.status})\n${result.stdout || ""}${result.stderr || ""}`,
170
+ );
171
+ }
172
+
173
+ return result;
174
+ }
175
+
176
+ function snapshotTree(root, ignoredPrefixes = []) {
177
+ const snapshot = {};
178
+ const visit = (directory, relativeDirectory = "") => {
179
+ for (const entry of fs
180
+ .readdirSync(directory, { withFileTypes: true })
181
+ .sort((left, right) => left.name.localeCompare(right.name))) {
182
+ const relativePath = path.posix.join(relativeDirectory.replaceAll("\\", "/"), entry.name);
183
+ if (ignoredPrefixes.some((prefix) => relativePath === prefix || relativePath.startsWith(`${prefix}/`))) {
184
+ continue;
185
+ }
186
+
187
+ const absolutePath = path.join(directory, entry.name);
188
+ const stats = fs.lstatSync(absolutePath);
189
+ if (stats.isDirectory()) {
190
+ snapshot[relativePath] = { type: "directory" };
191
+ visit(absolutePath, relativePath);
192
+ } else if (stats.isSymbolicLink()) {
193
+ snapshot[relativePath] = { type: "symlink", target: fs.readlinkSync(absolutePath) };
194
+ } else if (stats.isFile()) {
195
+ snapshot[relativePath] = { type: "file", data: fs.readFileSync(absolutePath).toString("base64") };
196
+ } else {
197
+ snapshot[relativePath] = { type: "other" };
198
+ }
199
+ }
200
+ };
201
+
202
+ visit(root);
203
+
204
+ return snapshot;
205
+ }
206
+
207
+ function packageRootForPrefix(prefix, env) {
208
+ const root = runNpm(["root", "--global", "--prefix", prefix], { env }).stdout.trim();
209
+ assert.ok(root, "npm did not report a global package root");
210
+
211
+ return path.join(root, "specpi");
212
+ }
213
+
214
+ function writeFakePi(directory) {
215
+ fs.mkdirSync(directory, { recursive: true });
216
+ if (process.platform === "win32") {
217
+ fs.writeFileSync(
218
+ path.join(directory, "pi.cmd"),
219
+ '@echo off\r\nif "%~1"=="--version" (echo 0.84.4& exit /b 0)\r\nexit /b 0\r\n',
220
+ );
221
+ } else {
222
+ const target = path.join(directory, "pi");
223
+ fs.writeFileSync(target, '#!/bin/sh\nif [ "$1" = "--version" ]; then echo 0.84.4; fi\nexit 0\n');
224
+ fs.chmodSync(target, 0o755);
225
+ }
226
+ }
227
+
228
+ function assertPackageMetadata(packageJson) {
229
+ assert.equal(packageJson.name, "specpi");
230
+ assert.match(packageJson.version, /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/);
231
+ assert.equal(packageJson.license, "MIT");
232
+ assert.equal(packageJson.author, "Tanner Middleton");
233
+ assert.equal(packageJson.repository?.url, "git+https://github.com/TannerMidd/SpecPi.git");
234
+ assert.equal(packageJson.bugs?.url, "https://github.com/TannerMidd/SpecPi/issues");
235
+ assert.equal(packageJson.homepage, "https://github.com/TannerMidd/SpecPi#readme");
236
+ assert.equal(packageJson.engines?.node, ">=22.19.0");
237
+ assert.equal(packageJson.bin?.specpi, "./scripts/specpi.mjs");
238
+ assert.ok(packageJson.keywords?.includes("pi-package"));
239
+ assert.deepEqual(packageJson.pi, {
240
+ extensions: ["./extensions"],
241
+ skills: ["./skills"],
242
+ themes: ["./themes"],
243
+ });
244
+ assert.equal(packageJson.publishConfig?.access, "public");
245
+ assert.equal(packageJson.publishConfig?.provenance, true);
246
+ assert.equal(packageJson.scripts?.preinstall, undefined);
247
+ assert.equal(packageJson.scripts?.install, undefined);
248
+ assert.equal(packageJson.scripts?.postinstall, undefined);
249
+ assert.deepEqual(Object.keys(packageJson.dependencies || {}), []);
250
+ assert.deepEqual(Object.keys(packageJson.optionalDependencies || {}), []);
251
+
252
+ assert.deepEqual(Object.keys(packageJson.peerDependencies || {}).sort(), hostPeerPackages);
253
+ for (const peer of hostPeerPackages) {
254
+ assert.equal(packageJson.peerDependencies[peer], "*");
255
+ assert.equal(packageJson.peerDependenciesMeta?.[peer]?.optional, true);
256
+ }
257
+ }
258
+
259
+ function assertArtifactMatchesManifest(tarball, packResult) {
260
+ const bytes = fs.readFileSync(tarball);
261
+ assert.equal(fs.statSync(tarball).size, packResult.size, "artifact size differs from its npm manifest");
262
+ assert.equal(
263
+ createHash("sha1").update(bytes).digest("hex"),
264
+ packResult.shasum,
265
+ "artifact SHA-1 differs from its npm manifest",
266
+ );
267
+ assert.equal(
268
+ `sha512-${createHash("sha512").update(bytes).digest("base64")}`,
269
+ packResult.integrity,
270
+ "artifact integrity differs from its npm manifest",
271
+ );
272
+ }
273
+
274
+ function assertPackageFiles(packResult) {
275
+ const entries = new Map(packResult.files.map((entry) => [entry.path.replaceAll("\\", "/"), entry]));
276
+ assert.deepEqual(
277
+ [...entries.keys()].sort(),
278
+ [...requiredFiles].sort(),
279
+ "packed artifact file manifest differs from the reviewed allow-list",
280
+ );
281
+
282
+ const forbidden = [...entries.keys()].filter(
283
+ (file) =>
284
+ forbiddenPrefixes.some((prefix) => file === prefix.slice(0, -1) || file.startsWith(prefix)) ||
285
+ file.endsWith(".tgz") ||
286
+ file.endsWith(".log") ||
287
+ file.includes("Screenshot"),
288
+ );
289
+ assert.deepEqual(forbidden, [], "packed artifact contains forbidden files");
290
+
291
+ const binEntry = entries.get("scripts/specpi.mjs");
292
+ assert.ok(binEntry, "packed artifact is missing its bin target");
293
+ if (process.platform !== "win32") {
294
+ assert.ok((binEntry.mode & 0o111) !== 0, "packed bin target is not executable");
295
+ }
296
+ }
297
+
298
+ function assertReadmeAssets(packageRoot) {
299
+ const readme = fs.readFileSync(path.join(packageRoot, "README.md"), "utf8");
300
+ const localSources = [...readme.matchAll(/<img\s+[^>]*src="([^"]+)"/g)]
301
+ .map((match) => match[1])
302
+ .filter((source) => !/^https?:\/\//.test(source));
303
+ assert.ok(localSources.length > 0, "README package check did not find any local image assets");
304
+ for (const source of localSources) {
305
+ assert.ok(fs.existsSync(path.join(packageRoot, source)), `README image is missing from package: ${source}`);
306
+ }
307
+ }
308
+
309
+ function assertInstalledLifecycle(packageRoot, binPath, temporaryRoot, baseEnv) {
310
+ const agentDir = path.join(temporaryRoot, "agent");
311
+ const fakeBin = path.join(temporaryRoot, "fake-bin");
312
+ writeFakePi(fakeBin);
313
+ const pathKey = Object.keys(baseEnv).find((key) => key.toLowerCase() === "path") || "PATH";
314
+ const env = {
315
+ ...baseEnv,
316
+ PI_CODING_AGENT_DIR: agentDir,
317
+ GIT_AUTHOR_NAME: "SpecPi Package Check",
318
+ GIT_AUTHOR_EMAIL: "specpi-package-check@example.invalid",
319
+ GIT_COMMITTER_NAME: "SpecPi Package Check",
320
+ GIT_COMMITTER_EMAIL: "specpi-package-check@example.invalid",
321
+ };
322
+ env[pathKey] = `${fakeBin}${path.delimiter}${baseEnv[pathKey] || ""}`;
323
+ const lifecycleCwd = path.join(temporaryRoot, "lifecycle-cwd");
324
+ fs.mkdirSync(lifecycleCwd, { recursive: true });
325
+ const runCli = (args, options = {}) => runInstalledBin(binPath, args, { cwd: lifecycleCwd, env, ...options });
326
+ const help = runCli(["help"]);
327
+ const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
328
+ assert.match(help.stdout, new RegExp(`SpecPi ${packageJson.version.replaceAll(".", "\\.")}`));
329
+
330
+ runCli(["plan", "--skip-package-install", "--skip-browser-install", "--skip-tool-install", "--skip-shell"]);
331
+ assert.equal(fs.existsSync(agentDir), false, "plan mutated the isolated agent directory");
332
+ const unknown = runCli(["unknown-package-smoke-command"], { expectFailure: true });
333
+ assert.match(`${unknown.stdout}\n${unknown.stderr}`, /Unknown command: unknown-package-smoke-command/);
334
+ assert.equal(fs.existsSync(agentDir), false, "unknown command mutated the isolated agent directory");
335
+
336
+ const lifecycleFlags = [
337
+ "--yes",
338
+ "--skip-package-install",
339
+ "--skip-browser-install",
340
+ "--skip-tool-install",
341
+ "--skip-shell",
342
+ ];
343
+ runCli(["install", ...lifecycleFlags]);
344
+ runCli(["doctor"], { timeout: 300_000 });
345
+
346
+ const guardDirectory = path.join(agentDir, "extensions", "command-guard");
347
+ const driftedGuardPath = path.join(guardDirectory, "index.ts");
348
+ fs.appendFileSync(driftedGuardPath, "\n// package-check rollback drift\n");
349
+ const treeBeforeFailure = snapshotTree(agentDir, ["specpi/backups"]);
350
+ const failedUpdate = runCli(["update", ...lifecycleFlags, "--force"], {
351
+ expectFailure: true,
352
+ env: {
353
+ ...env,
354
+ SPECPI_TESTING: "1",
355
+ SPECPI_TEST_FAIL_POINT: "after-first-command-guard-file",
356
+ },
357
+ });
358
+ assert.match(`${failedUpdate.stdout}\n${failedUpdate.stderr}`, /SpecPi-managed changes rolled back/);
359
+ assert.deepEqual(
360
+ snapshotTree(agentDir, ["specpi/backups"]),
361
+ treeBeforeFailure,
362
+ "failed update did not restore the complete managed tree",
363
+ );
364
+
365
+ const privateEvidence = new Map([
366
+ [path.join(agentDir, "specpi", "wishlist", "observations.jsonl"), '{"private":true}\n'],
367
+ [
368
+ path.join(agentDir, "specpi", "wishlist", "decisions.jsonl"),
369
+ '{"action":"retire","journal":{"schema":1,"evidence":["private proof"],"gates":["npm run check"],"version":"0.10.0"}}\n',
370
+ ],
371
+ [path.join(agentDir, "specpi", "experiments", "registry.json"), '{"private":true}\n'],
372
+ [path.join(agentDir, "specpi", "experiments", "patches", "package-smoke.patch"), "private patch\n"],
373
+ ]);
374
+ for (const [file, content] of privateEvidence) {
375
+ fs.mkdirSync(path.dirname(file), { recursive: true });
376
+ fs.writeFileSync(file, content, { mode: 0o600 });
377
+ }
378
+
379
+ runCli(["update", ...lifecycleFlags, "--force"]);
380
+ runCli(["doctor"], { timeout: 300_000 });
381
+ runCli(["uninstall", "--yes"]);
382
+
383
+ assert.equal(
384
+ fs.existsSync(path.join(agentDir, "extensions", "command-guard", "index.ts")),
385
+ false,
386
+ "uninstall left a managed extension behind",
387
+ );
388
+ for (const [file, expected] of privateEvidence) {
389
+ assert.equal(fs.existsSync(file), true, `uninstall removed private SpecPi evidence: ${file}`);
390
+ assert.equal(fs.readFileSync(file, "utf8"), expected, `uninstall changed private SpecPi evidence: ${file}`);
391
+ }
392
+ }
393
+
394
+ const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "specpi-package-check-"));
395
+ try {
396
+ const packDirectory = path.join(temporaryRoot, "pack");
397
+ const prefix = path.join(temporaryRoot, "prefix");
398
+ fs.mkdirSync(packDirectory, { recursive: true });
399
+ fs.mkdirSync(prefix, { recursive: true });
400
+
401
+ let packResult;
402
+ let tarball;
403
+ if (artifactPath) {
404
+ const results = JSON.parse(fs.readFileSync(path.resolve(artifactManifestPath), "utf8"));
405
+ assert.equal(results.length, 1, "artifact manifest does not describe exactly one package");
406
+ packResult = results[0];
407
+ tarball = path.resolve(artifactPath);
408
+ assert.equal(path.basename(tarball), packResult.filename, "artifact filename differs from its npm manifest");
409
+ } else {
410
+ const packed = runNpm(["pack", "--pack-destination", packDirectory, "--json", "--ignore-scripts"]);
411
+ assert.equal(packed.stderr.trim(), "", `npm pack emitted warnings:\n${packed.stderr}`);
412
+ const results = JSON.parse(packed.stdout);
413
+ assert.equal(results.length, 1, "npm pack did not produce exactly one artifact");
414
+ packResult = results[0];
415
+ tarball = path.join(packDirectory, packResult.filename);
416
+ }
417
+
418
+ assertPackageFiles(packResult);
419
+
420
+ const sourcePackage = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
421
+ assertPackageMetadata(sourcePackage);
422
+ assert.equal(packResult.name, sourcePackage.name);
423
+ assert.equal(packResult.version, sourcePackage.version);
424
+ assert.deepEqual(packResult.bundled || [], [], "packed artifact unexpectedly bundles dependencies");
425
+ assert.ok(packResult.size < 300_000, `packed artifact unexpectedly exceeds 300 KB: ${packResult.size}`);
426
+ assert.ok(
427
+ packResult.unpackedSize < 1_000_000,
428
+ `unpacked artifact unexpectedly exceeds 1 MB: ${packResult.unpackedSize}`,
429
+ );
430
+
431
+ assert.ok(fs.existsSync(tarball), "the reported npm tarball does not exist");
432
+ assertArtifactMatchesManifest(tarball, packResult);
433
+
434
+ const installEnv = { ...process.env, npm_config_audit: "false", npm_config_fund: "false" };
435
+ runNpm(
436
+ [
437
+ "install",
438
+ "--global",
439
+ "--prefix",
440
+ prefix,
441
+ "--ignore-scripts",
442
+ "--no-audit",
443
+ "--no-fund",
444
+ "--offline",
445
+ tarball,
446
+ ],
447
+ { env: installEnv },
448
+ );
449
+
450
+ const packageRoot = packageRootForPrefix(prefix, installEnv);
451
+ assert.ok(fs.existsSync(packageRoot), "npm did not install SpecPi under the temporary global prefix");
452
+ const binPath = process.platform === "win32" ? path.join(prefix, "specpi.cmd") : path.join(prefix, "bin", "specpi");
453
+ assert.ok(fs.existsSync(binPath), "npm did not create the platform CLI shim");
454
+ assert.match(
455
+ runInstalledBin(binPath, ["help"], { env: installEnv }).stdout,
456
+ new RegExp(`SpecPi ${sourcePackage.version.replaceAll(".", "\\.")}`),
457
+ );
458
+
459
+ const installedRoot = path.dirname(packageRoot);
460
+ for (const peer of hostPeerPackages) {
461
+ const peerSegments = peer.split("/");
462
+ assert.equal(
463
+ fs.existsSync(path.join(installedRoot, ...peerSegments)),
464
+ false,
465
+ `npm installed optional host peer beside SpecPi: ${peer}`,
466
+ );
467
+ assert.equal(
468
+ fs.existsSync(path.join(packageRoot, "node_modules", ...peerSegments)),
469
+ false,
470
+ `npm installed optional host peer inside SpecPi: ${peer}`,
471
+ );
472
+ }
473
+
474
+ assertPackageMetadata(JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")));
475
+ assertReadmeAssets(packageRoot);
476
+ assertInstalledLifecycle(packageRoot, binPath, temporaryRoot, installEnv);
477
+
478
+ console.log(
479
+ `Package check passed: ${packResult.filename} (${packResult.entryCount} files, ${packResult.size} bytes compressed)`,
480
+ );
481
+ } finally {
482
+ fs.rmSync(temporaryRoot, { recursive: true, force: true });
483
+ }