@danypops/pi-packed 0.19.7 → 0.19.10

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 (94) hide show
  1. package/dist/client.d.ts +109 -0
  2. package/dist/client.d.ts.map +1 -0
  3. package/dist/client.js +1 -0
  4. package/dist/protocol.d.ts +221 -0
  5. package/dist/protocol.d.ts.map +1 -0
  6. package/dist/protocol.js +1 -0
  7. package/extension/src/{permission.ts → approval/permission.ts} +1 -1
  8. package/extension/src/index.ts +1 -1
  9. package/extension/src/packed.ts +2 -2
  10. package/extension/src/{discover.ts → tabs/discover.ts} +4 -4
  11. package/extension/src/{resource-config.ts → tabs/resource-config.ts} +4 -4
  12. package/extension/src/{security-tui.ts → tabs/security-tui.ts} +3 -3
  13. package/extension/src/tool-output.ts +1 -1
  14. package/extension/src/tools.ts +2 -2
  15. package/extension/src/tui.ts +4 -4
  16. package/package.json +31 -8
  17. package/service/schema/pi-setup-v1.schema.json +70 -0
  18. package/service/setup/danypops-ecosystem.pi-setup.json +15 -0
  19. package/service/src/adoption/advisories.ts +268 -0
  20. package/service/src/adoption/check.ts +872 -0
  21. package/service/src/adoption/commit-freshness.ts +167 -0
  22. package/service/src/adoption/doctor.ts +135 -0
  23. package/service/src/adoption/install-validation.ts +187 -0
  24. package/service/src/adoption/pack.ts +291 -0
  25. package/service/src/adoption/score.ts +466 -0
  26. package/service/src/adoption/smoke-child.ts +113 -0
  27. package/service/src/adoption/smoke.ts +282 -0
  28. package/service/src/cli/cli.ts +926 -0
  29. package/service/src/daemon/cleanup.ts +76 -0
  30. package/service/src/daemon/client.ts +412 -0
  31. package/service/src/daemon/daemon-service.ts +249 -0
  32. package/service/src/daemon/daemon.ts +110 -0
  33. package/service/src/daemon/service.ts +664 -0
  34. package/service/src/daemon/watcher.ts +92 -0
  35. package/service/src/index/build-index.ts +256 -0
  36. package/service/src/packages/catalog.ts +61 -0
  37. package/service/src/packages/db.ts +224 -0
  38. package/service/src/packages/install.ts +60 -0
  39. package/service/src/packages/installed.ts +123 -0
  40. package/service/src/packages/package.ts +141 -0
  41. package/service/src/packages/resources.ts +203 -0
  42. package/service/src/pi/pi-version.ts +171 -0
  43. package/service/src/public/atomic-json.ts +32 -0
  44. package/service/src/public/client.ts +277 -0
  45. package/service/src/public/protocol.ts +169 -0
  46. package/service/src/publish/publish.ts +855 -0
  47. package/service/src/registry/registry.ts +246 -0
  48. package/service/src/security/security.ts +128 -0
  49. package/service/src/self-update/self-update.ts +148 -0
  50. package/service/src/setup/setup.ts +761 -0
  51. package/service/src/shared/atomic-json.ts +33 -0
  52. package/service/src/shared/cache.ts +21 -0
  53. package/service/src/shared/constants.ts +73 -0
  54. package/service/src/shared/log.ts +21 -0
  55. package/service/src/shared/paths.ts +88 -0
  56. package/service/src/shared/state.ts +15 -0
  57. package/service/src/shared/version.ts +46 -0
  58. package/service/test/advisories.test.ts +287 -0
  59. package/service/test/check.test.ts +368 -0
  60. package/service/test/cleanup.test.ts +220 -0
  61. package/service/test/cli.test.ts +1303 -0
  62. package/service/test/core.test.ts +181 -0
  63. package/service/test/daemon-kit-migration.test.ts +181 -0
  64. package/service/test/daemon-service.test.ts +238 -0
  65. package/service/test/db.test.ts +178 -0
  66. package/service/test/doctor.test.ts +234 -0
  67. package/service/test/domain.test.ts +291 -0
  68. package/service/test/fixtures/install-validation/broken-package/extension/index.ts +3 -0
  69. package/service/test/fixtures/install-validation/broken-package/package.json +8 -0
  70. package/service/test/fixtures/install-validation/healthy-package/extension/index.ts +3 -0
  71. package/service/test/fixtures/install-validation/healthy-package/package.json +8 -0
  72. package/service/test/fixtures/install-validation/no-manifest-package/package.json +5 -0
  73. package/service/test/index.test.ts +353 -0
  74. package/service/test/install-validation.test.ts +114 -0
  75. package/service/test/install.test.ts +113 -0
  76. package/service/test/log.test.ts +42 -0
  77. package/service/test/pack-score.test.ts +513 -0
  78. package/service/test/pi-version.test.ts +318 -0
  79. package/service/test/public-boundary.test.ts +54 -0
  80. package/service/test/public-client.test.ts +127 -0
  81. package/service/test/public-consumer.ts +8 -0
  82. package/service/test/publish.test.ts +333 -0
  83. package/service/test/registry-contract.test.ts +148 -0
  84. package/service/test/resources.test.ts +255 -0
  85. package/service/test/security.test.ts +89 -0
  86. package/service/test/self-update.test.ts +257 -0
  87. package/service/test/service.test.ts +555 -0
  88. package/service/test/setup.test.ts +375 -0
  89. package/service/test/smoke.test.ts +118 -0
  90. package/service/test/version.test.ts +37 -0
  91. package/service/tsconfig.consumer.json +13 -0
  92. package/service/tsconfig.public.json +12 -0
  93. /package/extension/src/{reload.ts → approval/reload.ts} +0 -0
  94. /package/extension/src/{discover-model.ts → tabs/discover-model.ts} +0 -0
@@ -0,0 +1,368 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { checkPackage, type Diagnostic, discoverPackageResources, formatCheckReport } from "../src/adoption/check.ts";
6
+
7
+ const roots: string[] = [];
8
+ afterEach(() => {
9
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
10
+ });
11
+
12
+ function fixture(pkg: Record<string, unknown>, files: Record<string, string> = {}): string {
13
+ const root = mkdtempSync(join(tmpdir(), "packed-check-"));
14
+ roots.push(root);
15
+ writeFileSync(join(root, "package.json"), JSON.stringify(pkg, null, 2));
16
+ for (const [path, content] of Object.entries(files)) {
17
+ mkdirSync(join(root, path, ".."), { recursive: true });
18
+ writeFileSync(join(root, path), content);
19
+ }
20
+ return root;
21
+ }
22
+
23
+ const base = {
24
+ name: "pi-example",
25
+ version: "1.0.0",
26
+ description: "Example Pi extension",
27
+ license: "MIT",
28
+ repository: "github:owner/pi-example",
29
+ homepage: "https://example.test/pi-example",
30
+ bugs: "https://example.test/pi-example/issues",
31
+ keywords: ["pi-package"],
32
+ files: ["extensions", "skills", "README.md", "LICENSE"],
33
+ pi: { extensions: ["extensions/*.ts"], skills: ["skills/*/SKILL.md"] },
34
+ peerDependencies: { "@earendil-works/pi-coding-agent": "*" },
35
+ };
36
+
37
+ function codes(diagnostics: Diagnostic[]): string[] {
38
+ return diagnostics.map((diagnostic) => diagnostic.code);
39
+ }
40
+
41
+ describe("discoverPackageResources (pure resource discovery for the config overlay)", () => {
42
+ it("matches declared manifest patterns per field, filtered by extension and containment", () => {
43
+ const pkg = { pi: { extensions: ["extensions/*.ts"], skills: ["skills/*/SKILL.md"], themes: [] } };
44
+ const root = fixture(pkg, { "extensions/index.ts": "", "extensions/README.md": "", "skills/review/SKILL.md": "", "prompts/x.md": "" });
45
+ const files = ["extensions/index.ts", "extensions/README.md", "skills/review/SKILL.md", "prompts/x.md"];
46
+ const resources = discoverPackageResources(root, pkg, files);
47
+ expect(resources.extensions).toEqual(["extensions/index.ts"]);
48
+ expect(resources.skills).toEqual(["skills/review/SKILL.md"]);
49
+ expect(resources.themes).toEqual([]);
50
+ expect(resources.prompts).toEqual([]); // omitted from a present pi manifest -> not discovered
51
+ });
52
+
53
+ it("falls back to conventional directories when no pi manifest is present", () => {
54
+ const root = fixture({}, { "extensions/foo.ts": "", "skills/bar/SKILL.md": "", "prompts/baz.md": "", "themes/dark.json": "{}" });
55
+ const files = ["extensions/foo.ts", "skills/bar/SKILL.md", "prompts/baz.md", "themes/dark.json"];
56
+ const resources = discoverPackageResources(root, {}, files);
57
+ expect(resources.extensions).toEqual(["extensions/foo.ts"]);
58
+ expect(resources.skills).toEqual(["skills/bar/SKILL.md"]);
59
+ expect(resources.prompts).toEqual(["prompts/baz.md"]);
60
+ expect(resources.themes).toEqual(["themes/dark.json"]);
61
+ });
62
+
63
+ it("never throws on an invalid pi manifest shape and reports zero resources instead", () => {
64
+ const root = fixture({ pi: "not-an-object" }, { "extensions/foo.ts": "" });
65
+ const resources = discoverPackageResources(root, { pi: "not-an-object" }, ["extensions/foo.ts"]);
66
+ expect(resources).toEqual({ extensions: [], skills: [], prompts: [], themes: [] });
67
+ });
68
+ });
69
+
70
+ describe("static capability and lifecycle-script signals (packed check)", () => {
71
+ function capabilityFixture(scripts: Record<string, string> | undefined, sourceFiles: Record<string, string>) {
72
+ return fixture(
73
+ { ...base, ...(scripts ? { scripts } : {}) },
74
+ {
75
+ "skills/example/SKILL.md": "---\nname: example\ndescription: Example. Use for examples.\n---\n",
76
+ "README.md": "# Example\n",
77
+ LICENSE: "MIT\n",
78
+ ...sourceFiles,
79
+ },
80
+ );
81
+ }
82
+
83
+ it("is silent for a package with no lifecycle scripts and no capability-suggestive imports", async () => {
84
+ const root = capabilityFixture(undefined, { "extensions/index.ts": "export default function () {}" });
85
+ const report = await checkPackage(root, { generic: false });
86
+ expect(codes(report.diagnostics)).not.toContain("PI_LIFECYCLE_SCRIPT_DECLARED");
87
+ expect(codes(report.diagnostics)).not.toContain("PI_CAPABILITY_IMPORT");
88
+ });
89
+
90
+ it("flags each declared lifecycle script with its exact command as evidence, at warning tier", async () => {
91
+ const root = capabilityFixture(
92
+ { postinstall: "node ./setup.js", prepare: "tsc -p ." },
93
+ { "extensions/index.ts": "export default function () {}" },
94
+ );
95
+ const report = await checkPackage(root, { generic: false });
96
+ const declared = report.diagnostics.filter((d) => d.code === "PI_LIFECYCLE_SCRIPT_DECLARED");
97
+ expect(declared).toHaveLength(2);
98
+ expect(declared.every((d) => d.severity === "warning")).toBe(true);
99
+ expect(declared.find((d) => d.path === "package.json#scripts.postinstall")?.message).toContain("node ./setup.js");
100
+ expect(declared.find((d) => d.path === "package.json#scripts.prepare")?.message).toContain("tsc -p .");
101
+ });
102
+
103
+ it("ignores non-lifecycle scripts (e.g. test, build) -- only preinstall/install/postinstall/prepare count", async () => {
104
+ const root = capabilityFixture({ test: "bun test", build: "tsc" }, { "extensions/index.ts": "export default function () {}" });
105
+ const report = await checkPackage(root, { generic: false });
106
+ expect(codes(report.diagnostics)).not.toContain("PI_LIFECYCLE_SCRIPT_DECLARED");
107
+ });
108
+
109
+ it("flags a capability-suggestive import with the exact file and module as evidence, at info tier", async () => {
110
+ const root = capabilityFixture(undefined, {
111
+ "extensions/index.ts": 'import { spawn } from "child_process";\nexport default function () {}',
112
+ });
113
+ const report = await checkPackage(root, { generic: false });
114
+ const found = report.diagnostics.filter((d) => d.code === "PI_CAPABILITY_IMPORT");
115
+ expect(found).toHaveLength(1);
116
+ expect(found[0]!.severity).toBe("info");
117
+ expect(found[0]!.path).toBe("extensions/index.ts");
118
+ expect(found[0]!.message).toContain("child_process");
119
+ });
120
+
121
+ it("normalizes a node:-prefixed specifier and a subpath to the same bare capability module", async () => {
122
+ const root = capabilityFixture(undefined, {
123
+ "extensions/index.ts": 'import fs from "node:fs/promises";\nexport default function () {}',
124
+ });
125
+ const report = await checkPackage(root, { generic: false });
126
+ const found = report.diagnostics.filter((d) => d.code === "PI_CAPABILITY_IMPORT");
127
+ expect(found).toHaveLength(1);
128
+ expect(found[0]!.message).toContain("fs");
129
+ });
130
+
131
+ it("flags bun:sqlite as a capability-suggestive import", async () => {
132
+ const root = capabilityFixture(undefined, {
133
+ "extensions/index.ts": 'import { Database } from "bun:sqlite";\nexport default function () {}',
134
+ });
135
+ const report = await checkPackage(root, { generic: false });
136
+ expect(report.diagnostics.some((d) => d.code === "PI_CAPABILITY_IMPORT" && d.message.includes("bun:sqlite"))).toBe(true);
137
+ });
138
+
139
+ it("reports each capability once per file, not once per occurrence", async () => {
140
+ const root = capabilityFixture(undefined, {
141
+ "extensions/index.ts": 'import { spawn } from "child_process";\nimport { exec } from "child_process";\nexport default function () {}',
142
+ });
143
+ const report = await checkPackage(root, { generic: false });
144
+ expect(report.diagnostics.filter((d) => d.code === "PI_CAPABILITY_IMPORT")).toHaveLength(1);
145
+ });
146
+
147
+ it("never flags an ordinary third-party or relative import as a capability signal", async () => {
148
+ const root = capabilityFixture(undefined, {
149
+ "extensions/index.ts": 'import lodash from "lodash";\nimport { helper } from "./helper.ts";\nexport default function () {}',
150
+ });
151
+ const report = await checkPackage(root, { generic: false });
152
+ expect(codes(report.diagnostics)).not.toContain("PI_CAPABILITY_IMPORT");
153
+ });
154
+
155
+ it("never executes the scanned source -- purely a text scan (structural guarantee on the check function itself)", async () => {
156
+ const source = await Bun.file(new URL("../src/adoption/check.ts", import.meta.url)).text();
157
+ const capabilityCheckBody = source.slice(source.indexOf("function capabilityCheck"), source.indexOf("function capabilityCheck") + 1200);
158
+ expect(capabilityCheckBody).not.toMatch(/\brequire\(|import\s*\(|eval\(|Bun\.spawn|child_process/);
159
+ });
160
+ });
161
+
162
+ describe("pi.cleanup static validation (packed check)", () => {
163
+ function cleanupFixture(cleanup: unknown) {
164
+ return fixture(
165
+ { ...base, pi: { ...base.pi, cleanup } },
166
+ {
167
+ "extensions/index.ts": "export default function () {}",
168
+ "skills/example/SKILL.md": "---\nname: example\ndescription: Example. Use for examples.\n---\n",
169
+ "README.md": "# Example\n",
170
+ LICENSE: "MIT\n",
171
+ },
172
+ );
173
+ }
174
+
175
+ it("is silent for a package that never declares pi.cleanup -- zero behavior change", async () => {
176
+ const root = fixture(base, {
177
+ "extensions/index.ts": "export default function () {}",
178
+ "skills/example/SKILL.md": "---\nname: example\ndescription: Example. Use for examples.\n---\n",
179
+ "README.md": "# Example\n",
180
+ LICENSE: "MIT\n",
181
+ });
182
+ const report = await checkPackage(root, { generic: false });
183
+ expect(codes(report.diagnostics)).not.toContain("PI_CLEANUP_INVALID");
184
+ expect(codes(report.diagnostics)).not.toContain("PI_CLEANUP_ESCAPES_PACKAGE");
185
+ });
186
+
187
+ it("accepts a well-formed pi.cleanup declaration", async () => {
188
+ const report = await checkPackage(cleanupFixture(["cache.json", "state/"]), { generic: false });
189
+ expect(codes(report.diagnostics)).not.toContain("PI_CLEANUP_INVALID");
190
+ expect(codes(report.diagnostics)).not.toContain("PI_CLEANUP_ESCAPES_PACKAGE");
191
+ });
192
+
193
+ it("flags a non-array pi.cleanup as invalid", async () => {
194
+ const report = await checkPackage(cleanupFixture("cache.json"), { generic: false });
195
+ expect(codes(report.diagnostics)).toContain("PI_CLEANUP_INVALID");
196
+ });
197
+
198
+ it("flags an absolute path as escaping the package", async () => {
199
+ const report = await checkPackage(cleanupFixture(["/etc/passwd"]), { generic: false });
200
+ expect(codes(report.diagnostics)).toContain("PI_CLEANUP_ESCAPES_PACKAGE");
201
+ expect(report.ok).toBe(false);
202
+ });
203
+
204
+ it('flags a ".."-escaping path', async () => {
205
+ const report = await checkPackage(cleanupFixture(["../../etc/passwd"]), { generic: false });
206
+ expect(codes(report.diagnostics)).toContain("PI_CLEANUP_ESCAPES_PACKAGE");
207
+ });
208
+
209
+ it("warns (not an error) when pi.cleanup exceeds the bounded entry count or per-entry length", async () => {
210
+ const report = await checkPackage(cleanupFixture([...Array(60).keys()].map((i) => `f${i}`)), { generic: false });
211
+ const tooLarge = report.diagnostics.find((d) => d.code === "PI_CLEANUP_TOO_LARGE");
212
+ expect(tooLarge?.severity).toBe("warning");
213
+ });
214
+ });
215
+
216
+ describe("packed check", () => {
217
+ it("accepts a complete static Pi package without executing it", async () => {
218
+ const root = fixture(base, {
219
+ "extensions/index.ts":
220
+ 'import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; export default function (pi: ExtensionAPI) { pi.registerCommand("example", { handler() {} }); }',
221
+ "skills/example/SKILL.md":
222
+ "---\nname: example\ndescription: Runs the example workflow. Use for examples.\n---\n\nSee [reference](reference/guide.md).\n",
223
+ "skills/example/reference/guide.md": "# Guide\n",
224
+ "README.md": "# Example\n",
225
+ LICENSE: "MIT\n",
226
+ });
227
+ const report = await checkPackage(root, { generic: false });
228
+ expect(report.ok).toBe(true);
229
+ expect(report.root).toBe(root);
230
+ expect(report.summary.errors).toBe(0);
231
+ expect(report.checkedFiles).toBeGreaterThan(0);
232
+ });
233
+
234
+ it("returns stable manifest, resource, containment, dependency, metadata, and skill codes", async () => {
235
+ const root = fixture(
236
+ {
237
+ name: "Bad Package",
238
+ version: "wat",
239
+ keywords: [],
240
+ pi: {
241
+ extensions: ["../escape.ts", "extensions/[", "node_modules/pi-child/extensions"],
242
+ skills: ["skills/*/SKILL.md"],
243
+ prompts: "prompts/*.md",
244
+ image: "https://example.test/image.svg",
245
+ video: "https://example.test/video.webm",
246
+ },
247
+ dependencies: { "@earendil-works/pi-coding-agent": "^1.0.0" },
248
+ devDependencies: { leftpad: "1.0.0" },
249
+ },
250
+ {
251
+ "skills/bad/SKILL.md": "---\nname: Bad_Name\n---\n\nSee [missing](reference/missing.md).\n",
252
+ "extensions/index.ts": 'import leftpad from "leftpad"; export default () => leftpad;',
253
+ },
254
+ );
255
+ const report = await checkPackage(root, { generic: false });
256
+ expect(codes(report.diagnostics)).toEqual(
257
+ expect.arrayContaining([
258
+ "PKG_NAME_INVALID",
259
+ "PKG_VERSION_INVALID",
260
+ "PKG_LICENSE_MISSING",
261
+ "PKG_REPOSITORY_MISSING",
262
+ "PKG_HOMEPAGE_MISSING",
263
+ "PKG_BUGS_MISSING",
264
+ "PI_KEYWORD_MISSING",
265
+ "PI_MANIFEST_FIELD_INVALID",
266
+ "PI_RESOURCE_OUTSIDE_PACKAGE",
267
+ "PI_RESOURCE_GLOB_INVALID",
268
+ "PI_RESOURCE_NO_MATCH",
269
+ "PI_IMAGE_FORMAT_INVALID",
270
+ "PI_VIDEO_FORMAT_INVALID",
271
+ "PI_CORE_DEPENDENCY_PLACEMENT",
272
+ "PI_PACKAGE_NOT_BUNDLED",
273
+ "RUNTIME_DEPENDENCY_MISSING",
274
+ "SKILL_NAME_INVALID",
275
+ "SKILL_DESCRIPTION_MISSING",
276
+ "SKILL_REFERENCE_MISSING",
277
+ "PACKAGE_README_MISSING",
278
+ "PACKAGE_LICENSE_FILE_MISSING",
279
+ ]),
280
+ );
281
+ expect(report.ok).toBe(false);
282
+ });
283
+
284
+ it("rejects declared resources excluded from the npm package", async () => {
285
+ const root = fixture(
286
+ { ...base, files: ["README.md"], pi: { extensions: ["extensions/index.ts"] } },
287
+ {
288
+ "extensions/index.ts": "export default function () {}",
289
+ "README.md": "# Example",
290
+ LICENSE: "MIT",
291
+ },
292
+ );
293
+ const report = await checkPackage(root, { generic: false });
294
+ expect(codes(report.diagnostics)).toContain("PI_RESOURCE_NOT_PUBLISHED");
295
+ });
296
+
297
+ it("rejects declared resources that escape through a symlink", async () => {
298
+ const outside = mkdtempSync(join(tmpdir(), "packed-check-outside-"));
299
+ roots.push(outside);
300
+ writeFileSync(join(outside, "extension.ts"), "export default function () {}");
301
+ const root = fixture({ ...base, pi: { extensions: ["extensions/outside.ts"] } }, { "README.md": "# Example", LICENSE: "MIT" });
302
+ mkdirSync(join(root, "extensions"), { recursive: true });
303
+ symlinkSync(join(outside, "extension.ts"), join(root, "extensions/outside.ts"));
304
+ const report = await checkPackage(root, { generic: false });
305
+ expect(codes(report.diagnostics)).toContain("PI_RESOURCE_OUTSIDE_PACKAGE");
306
+ });
307
+
308
+ it("discovers conventional directories and reports empty resource sets", async () => {
309
+ const root = fixture({ ...base, pi: undefined }, { "README.md": "# Example", LICENSE: "MIT" });
310
+ const report = await checkPackage(root, { generic: false });
311
+ expect(codes(report.diagnostics)).toContain("PI_NO_RESOURCES");
312
+ });
313
+
314
+ it("composes publint without running package lifecycle scripts", async () => {
315
+ const root = fixture(
316
+ { ...base, scripts: { prepack: "node -e \\\"require('fs').writeFileSync('executed','yes')\\\"" } },
317
+ {
318
+ "extensions/index.ts": "export default function () {}",
319
+ "skills/example/SKILL.md": "---\nname: example\ndescription: Example skill.\n---\n",
320
+ "README.md": "# Example",
321
+ LICENSE: "MIT",
322
+ },
323
+ );
324
+ await checkPackage(root);
325
+ expect(existsSync(join(root, "executed"))).toBe(false);
326
+ });
327
+
328
+ it("maps publint messages into the stable diagnostic envelope", async () => {
329
+ const root = fixture(
330
+ { ...base, pi: { extensions: ["extensions/index.ts"] }, main: "missing.js" },
331
+ {
332
+ "extensions/index.ts": "export default function () {}",
333
+ "README.md": "# Example",
334
+ LICENSE: "MIT",
335
+ },
336
+ );
337
+ const report = await checkPackage(root);
338
+ expect(codes(report.diagnostics)).toContain("NPM_FILE_DOES_NOT_EXIST");
339
+ });
340
+
341
+ it("bounds diagnostics, files, and both human and JSON output", async () => {
342
+ const imports = Array.from({ length: 40 }, (_, i) => `import value${i} from "missing-${i}";`).join("\n");
343
+ const root = fixture(
344
+ { name: "x", version: "1.0.0", pi: { extensions: ["extensions/index.ts"] } },
345
+ { "extensions/index.ts": `${imports}\nexport default function () {}` },
346
+ );
347
+ const report = await checkPackage(root, { generic: false, maxDiagnostics: 10, maxFiles: 20 });
348
+ expect(report.diagnostics).toHaveLength(10);
349
+ expect(report.truncated).toBe(true);
350
+ const human = formatCheckReport(report, false);
351
+ const json = formatCheckReport(report, true);
352
+ expect(human.length).toBeLessThanOrEqual(8_000);
353
+ expect(json.length).toBeLessThanOrEqual(32_000);
354
+ expect(JSON.parse(json).diagnostics).toHaveLength(10);
355
+ });
356
+
357
+ it("reports an invalid package path without throwing", async () => {
358
+ const report = await checkPackage(join(tmpdir(), "packed-does-not-exist"), { generic: false });
359
+ expect(codes(report.diagnostics)).toEqual(["PKG_PATH_INVALID"]);
360
+ });
361
+
362
+ it("reports invalid package JSON with a stable code", async () => {
363
+ const root = fixture(base);
364
+ writeFileSync(join(root, "package.json"), "{");
365
+ const report = await checkPackage(root, { generic: false });
366
+ expect(codes(report.diagnostics)).toEqual(["PKG_JSON_INVALID"]);
367
+ });
368
+ });
@@ -0,0 +1,220 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { AuthenticatedRpcClient } from "@danypops/vehicle-client/rpc-client";
6
+ import { formatCleanupSummary, readCleanupManifest, runCleanup } from "../src/daemon/cleanup.ts";
7
+ import { createApp, type OperationInputs, type OperationName, type OperationOutputs } from "../src/daemon/service.ts";
8
+ import type { Installer, PkgInfo, Registry, SearchPage, UpdateOutcome } from "../src/packages/package.ts";
9
+
10
+ const roots: string[] = [];
11
+ afterEach(() => {
12
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
13
+ });
14
+
15
+ function pkg(manifest: Record<string, unknown>): string {
16
+ const root = mkdtempSync(join(tmpdir(), "packed-cleanup-"));
17
+ roots.push(root);
18
+ writeFileSync(join(root, "package.json"), JSON.stringify(manifest));
19
+ return root;
20
+ }
21
+
22
+ describe("readCleanupManifest", () => {
23
+ it("returns [] for a package that never declares pi.cleanup -- zero behavior change is the default", () => {
24
+ expect(readCleanupManifest(pkg({ name: "pi-demo" }))).toEqual([]);
25
+ });
26
+
27
+ it("returns [] for a malformed pi.cleanup (not an array), never throws", () => {
28
+ expect(readCleanupManifest(pkg({ name: "pi-demo", pi: { cleanup: "not-an-array" } }))).toEqual([]);
29
+ });
30
+
31
+ it("returns [] when package.json itself is missing or unparseable", () => {
32
+ const root = mkdtempSync(join(tmpdir(), "packed-cleanup-"));
33
+ roots.push(root);
34
+ expect(readCleanupManifest(root)).toEqual([]);
35
+ });
36
+
37
+ it("filters non-string entries and bounds count and per-entry length", () => {
38
+ const declared = [...Array(60).keys()].map((i) => `dir-${i}`);
39
+ const root = pkg({ name: "pi-demo", pi: { cleanup: [...declared, 123, null, " ", "a".repeat(600)] } });
40
+ const result = readCleanupManifest(root);
41
+ expect(result.length).toBe(50); // MAX_CLEANUP_ENTRIES
42
+ expect(result.every((item) => typeof item === "string" && item.length <= 512)).toBe(true);
43
+ });
44
+ });
45
+
46
+ describe("runCleanup", () => {
47
+ it("is a complete no-op for a package that declares nothing -- readCleanupManifest already returns [] so nothing is even attempted", () => {
48
+ const root = pkg({ name: "pi-demo" });
49
+ expect(runCleanup(root)).toEqual([]);
50
+ });
51
+
52
+ it("removes a declared file within the package's own directory and reports it", () => {
53
+ const root = pkg({ name: "pi-demo", pi: { cleanup: ["cache.json"] } });
54
+ writeFileSync(join(root, "cache.json"), "{}");
55
+ const result = runCleanup(root);
56
+ expect(result).toEqual([{ declared: "cache.json", status: "removed" }]);
57
+ expect(existsSync(join(root, "cache.json"))).toBe(false);
58
+ });
59
+
60
+ it("removes a declared directory recursively", () => {
61
+ const root = pkg({ name: "pi-demo", pi: { cleanup: ["state"] } });
62
+ mkdirSync(join(root, "state"));
63
+ writeFileSync(join(root, "state", "data.db"), "x");
64
+ const result = runCleanup(root);
65
+ expect(result).toEqual([{ declared: "state", status: "removed" }]);
66
+ expect(existsSync(join(root, "state"))).toBe(false);
67
+ });
68
+
69
+ it("reports missing (not an error) for a declared path that never existed", () => {
70
+ const root = pkg({ name: "pi-demo", pi: { cleanup: ["never-created.log"] } });
71
+ expect(runCleanup(root)).toEqual([{ declared: "never-created.log", status: "missing" }]);
72
+ });
73
+
74
+ it("fails closed on an absolute path -- never deletes outside the package", () => {
75
+ const root = pkg({ name: "pi-demo", pi: { cleanup: ["/etc/passwd"] } });
76
+ const result = runCleanup(root);
77
+ expect(result).toEqual([{ declared: "/etc/passwd", status: "escaped" }]);
78
+ expect(existsSync("/etc/passwd")).toBe(true);
79
+ });
80
+
81
+ it('fails closed on a ".."-escaping path, never deletes outside the package', () => {
82
+ const root = pkg({ name: "pi-demo", pi: { cleanup: ["../../etc/passwd"] } });
83
+ const outside = join(root, "..", "..", "etc-passwd-marker");
84
+ const result = runCleanup(root);
85
+ expect(result).toEqual([{ declared: "../../etc/passwd", status: "escaped" }]);
86
+ expect(existsSync(outside)).toBe(false);
87
+ });
88
+
89
+ it("fails closed on a symlink inside the package that points outside it, matching check.ts's own escape discipline", () => {
90
+ const root = pkg({ name: "pi-demo", pi: { cleanup: ["escape-link"] } });
91
+ const outsideTarget = mkdtempSync(join(tmpdir(), "packed-cleanup-outside-"));
92
+ roots.push(outsideTarget);
93
+ const canary = join(outsideTarget, "canary.txt");
94
+ writeFileSync(canary, "do not delete");
95
+ symlinkSync(outsideTarget, join(root, "escape-link"));
96
+ const result = runCleanup(root);
97
+ expect(result).toEqual([{ declared: "escape-link", status: "escaped" }]);
98
+ expect(existsSync(canary)).toBe(true);
99
+ });
100
+
101
+ it("reports every declared entry independently -- one escape doesn't block a legitimate sibling removal", () => {
102
+ const root = pkg({ name: "pi-demo", pi: { cleanup: ["../escape", "cache.json", "missing.log"] } });
103
+ writeFileSync(join(root, "cache.json"), "{}");
104
+ const result = runCleanup(root);
105
+ expect(result).toEqual([
106
+ { declared: "../escape", status: "escaped" },
107
+ { declared: "cache.json", status: "removed" },
108
+ { declared: "missing.log", status: "missing" },
109
+ ]);
110
+ });
111
+ });
112
+
113
+ describe("formatCleanupSummary", () => {
114
+ it("is empty for no declared targets -- a package that never opts in produces zero visible output difference", () => {
115
+ expect(formatCleanupSummary([])).toBe("");
116
+ });
117
+
118
+ it("lists every target's outcome", () => {
119
+ const summary = formatCleanupSummary([
120
+ { declared: "cache.json", status: "removed" },
121
+ { declared: "../x", status: "escaped" },
122
+ ]);
123
+ expect(summary).toContain("removed: cache.json");
124
+ expect(summary).toContain("escaped: ../x");
125
+ });
126
+ });
127
+
128
+ class NoopRegistry implements Registry {
129
+ async search(): Promise<SearchPage> {
130
+ return { results: [], total: 0 };
131
+ }
132
+ async searchPage(): Promise<SearchPage> {
133
+ return { results: [], total: 0 };
134
+ }
135
+ async searchAll() {
136
+ return [];
137
+ }
138
+ async info(name: string): Promise<PkgInfo> {
139
+ return { name, version: "1.0.0" };
140
+ }
141
+ }
142
+
143
+ class RecordingInstaller implements Installer {
144
+ removed?: string;
145
+ async install() {
146
+ return "ok";
147
+ }
148
+ async remove(source: string) {
149
+ this.removed = source;
150
+ return `Removed ${source}`;
151
+ }
152
+ async update(): Promise<UpdateOutcome> {
153
+ return { output: "ok", reloadRequired: false, alreadyUpToDate: true, pinned: false };
154
+ }
155
+ }
156
+
157
+ describe("packed remove applies pi.cleanup before delegating to pi remove (real daemon route)", () => {
158
+ function installedNpmPackage(pi: Record<string, unknown>): { piHome: string; dir: string } {
159
+ const piHome = mkdtempSync(join(tmpdir(), "packed-cleanup-daemon-"));
160
+ roots.push(piHome);
161
+ const dir = join(piHome, "npm", "node_modules", "pi-demo");
162
+ mkdirSync(dir, { recursive: true });
163
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "pi-demo", version: "1.0.0", pi }));
164
+ return { piHome, dir };
165
+ }
166
+
167
+ function rpcClient(inst: Installer, piHome: string) {
168
+ const app = createApp({
169
+ reg: new NoopRegistry(),
170
+ inst,
171
+ token: "test-token",
172
+ stateDir: mkdtempSync(join(tmpdir(), "packed-cleanup-state-")),
173
+ dataDir: mkdtempSync(join(tmpdir(), "packed-cleanup-data-")),
174
+ piHome,
175
+ });
176
+ return new AuthenticatedRpcClient<OperationName, OperationInputs, OperationOutputs>("http://packed.test", "test-token", {
177
+ label: "Packed",
178
+ transport: (request) => app.fetch(request),
179
+ });
180
+ }
181
+
182
+ it("removes a declared cleanup path before pi remove runs, and reports it in the same output the CLI already prints", async () => {
183
+ const { piHome, dir } = installedNpmPackage({ cleanup: ["cache.json"] });
184
+ writeFileSync(join(dir, "cache.json"), "{}");
185
+ const inst = new RecordingInstaller();
186
+ const client = rpcClient(inst, piHome);
187
+ const result = await client.call("package.remove", { name: "pi-demo", approved: true });
188
+ expect(result.ok).toBe(true);
189
+ expect(result.output).toContain("Removed npm:pi-demo");
190
+ expect(result.output).toContain("removed: cache.json");
191
+ expect(existsSync(join(dir, "cache.json"))).toBe(false); // deleted before pi remove even ran
192
+ expect(inst.removed).toBe("npm:pi-demo");
193
+ });
194
+
195
+ it("produces byte-identical output to plain pi remove for a package that never declares pi.cleanup -- zero behavior change", async () => {
196
+ const { piHome } = installedNpmPackage({});
197
+ const inst = new RecordingInstaller();
198
+ const client = rpcClient(inst, piHome);
199
+ const result = await client.call("package.remove", { name: "pi-demo", approved: true });
200
+ expect(result.output).toBe("Removed npm:pi-demo");
201
+ });
202
+
203
+ it("still delegates to pi remove and reports the escape, but never deletes anything outside the package", async () => {
204
+ const { piHome } = installedNpmPackage({ cleanup: ["../../etc/passwd"] });
205
+ const inst = new RecordingInstaller();
206
+ const client = rpcClient(inst, piHome);
207
+ const result = await client.call("package.remove", { name: "pi-demo", approved: true });
208
+ expect(result.output).toContain("escaped: ../../etc/passwd");
209
+ expect(inst.removed).toBe("npm:pi-demo"); // pi remove still ran
210
+ expect(existsSync("/etc/passwd")).toBe(true);
211
+ });
212
+
213
+ it("requires the same approval as every other remove -- cleanup never bypasses it", async () => {
214
+ const { piHome } = installedNpmPackage({ cleanup: ["cache.json"] });
215
+ const inst = new RecordingInstaller();
216
+ const client = rpcClient(inst, piHome);
217
+ await expect(client.call("package.remove", { name: "pi-demo" })).rejects.toThrow("approval required");
218
+ expect(inst.removed).toBeUndefined();
219
+ });
220
+ });