@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,872 @@
1
+ import { existsSync, lstatSync, opendirSync, readFileSync, realpathSync } from "node:fs";
2
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { publint } from "publint";
4
+ import { formatMessage } from "publint/utils";
5
+ import { type ExtensionSmokeResult, runExtensionSmoke } from "./smoke.ts";
6
+
7
+ export type DiagnosticSeverity = "error" | "warning" | "info";
8
+
9
+ export interface Diagnostic {
10
+ code: string;
11
+ severity: DiagnosticSeverity;
12
+ path: string;
13
+ message: string;
14
+ fix?: string;
15
+ }
16
+
17
+ export interface CheckReport {
18
+ root: string;
19
+ ok: boolean;
20
+ diagnostics: Diagnostic[];
21
+ summary: { errors: number; warnings: number; info: number };
22
+ checkedFiles: number;
23
+ truncated: boolean;
24
+ smoke?: { extensions: ExtensionSmokeResult[] };
25
+ }
26
+
27
+ export interface CheckOptions {
28
+ generic?: boolean;
29
+ smoke?: boolean;
30
+ maxDiagnostics?: number;
31
+ maxFiles?: number;
32
+ }
33
+
34
+ export interface PackageChecker {
35
+ check(packagePath: string, options?: CheckOptions): Promise<CheckReport>;
36
+ }
37
+
38
+ export class StaticPackageChecker implements PackageChecker {
39
+ check(packagePath: string, options?: CheckOptions): Promise<CheckReport> {
40
+ return checkPackage(packagePath, options);
41
+ }
42
+ }
43
+
44
+ interface Context {
45
+ root: string;
46
+ pkg: Record<string, unknown>;
47
+ files: string[];
48
+ add(diagnostic: Diagnostic): void;
49
+ markTruncated(): void;
50
+ }
51
+
52
+ type Check = (context: Context) => void | Promise<void>;
53
+
54
+ const DEFAULT_MAX_DIAGNOSTICS = 200;
55
+ const DEFAULT_MAX_FILES = 2_000;
56
+ const MAX_SOURCE_BYTES = 256 * 1024;
57
+ const MAX_GENERIC_BYTES = 5 * 1024 * 1024;
58
+ const MAX_HUMAN_OUTPUT = 8_000;
59
+ const MAX_JSON_OUTPUT = 32_000;
60
+ const CORE_PACKAGES = new Set([
61
+ "@earendil-works/pi-ai",
62
+ "@earendil-works/pi-agent-core",
63
+ "@earendil-works/pi-coding-agent",
64
+ "@earendil-works/pi-tui",
65
+ "typebox",
66
+ ]);
67
+ const RESOURCE_FIELDS = ["extensions", "skills", "prompts", "themes"] as const;
68
+ const RESOURCE_EXTENSIONS: Record<(typeof RESOURCE_FIELDS)[number], RegExp> = {
69
+ extensions: /\.[cm]?[jt]s$/,
70
+ skills: /(?:SKILL\.md|\.md)$/i,
71
+ prompts: /\.md$/i,
72
+ themes: /\.json$/i,
73
+ };
74
+
75
+ function isRecord(value: unknown): value is Record<string, unknown> {
76
+ return typeof value === "object" && value !== null && !Array.isArray(value);
77
+ }
78
+
79
+ function declaredBundledRoots(pkg: Record<string, unknown>): string[] {
80
+ if (!isRecord(pkg.pi)) return [];
81
+ const roots = new Set<string>();
82
+ for (const field of RESOURCE_FIELDS) {
83
+ for (const raw of patternsFor(pkg.pi[field]) ?? []) {
84
+ const normalized = normalizePattern(raw.replace(/^!/, ""));
85
+ if (!normalized.startsWith("node_modules/")) continue;
86
+ const parts = normalized.split("/");
87
+ roots.add(parts[1]!.startsWith("@") ? parts.slice(0, 3).join("/") : parts.slice(0, 2).join("/"));
88
+ }
89
+ }
90
+ return [...roots];
91
+ }
92
+
93
+ export function walk(root: string, maxFiles: number, bundledRoots: string[]): { files: string[]; truncated: boolean } {
94
+ const files: string[] = [];
95
+ const queue = [root];
96
+ const maxEntries = maxFiles * 4;
97
+ let visitedEntries = 0;
98
+ while (queue.length > 0) {
99
+ const directory = queue.shift()!;
100
+ const handle = opendirSync(directory);
101
+ try {
102
+ for (;;) {
103
+ const entry = handle.readSync();
104
+ if (entry === null) break;
105
+ visitedEntries++;
106
+ if (visitedEntries > maxEntries) return { files, truncated: true };
107
+ if (entry.name === ".git") continue;
108
+ if (entry.name === "node_modules" && directory === root) {
109
+ for (const bundledRoot of bundledRoots) {
110
+ const absolute = join(root, bundledRoot);
111
+ if (existsSync(absolute)) queue.push(absolute);
112
+ }
113
+ continue;
114
+ }
115
+ if (entry.name === "node_modules") continue;
116
+ const absolute = join(directory, entry.name);
117
+ const path = relative(root, absolute).split(sep).join("/");
118
+ if (entry.isDirectory()) queue.push(absolute);
119
+ else {
120
+ files.push(path);
121
+ if (files.length >= maxFiles) return { files, truncated: true };
122
+ }
123
+ }
124
+ } finally {
125
+ handle.closeSync();
126
+ }
127
+ }
128
+ return { files, truncated: false };
129
+ }
130
+
131
+ function patternsFor(value: unknown): string[] | undefined {
132
+ return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : undefined;
133
+ }
134
+
135
+ function normalizePattern(pattern: string): string {
136
+ return pattern.replace(/^\.\//, "").replaceAll("\\", "/");
137
+ }
138
+
139
+ function hasMagic(pattern: string): boolean {
140
+ return /[*?{[\]]/.test(pattern);
141
+ }
142
+
143
+ function isValidGlob(pattern: string): boolean {
144
+ for (const [open, close] of [
145
+ ["[", "]"],
146
+ ["{", "}"],
147
+ ] as const) {
148
+ let depth = 0;
149
+ for (let index = 0; index < pattern.length; index++) {
150
+ if (pattern[index] === "\\") {
151
+ index++;
152
+ continue;
153
+ }
154
+ if (pattern[index] === open) depth++;
155
+ if (pattern[index] === close && --depth < 0) return false;
156
+ }
157
+ if (depth !== 0) return false;
158
+ }
159
+ try {
160
+ new Bun.Glob(pattern);
161
+ return true;
162
+ } catch {
163
+ return false;
164
+ }
165
+ }
166
+
167
+ export function matchesPattern(file: string, pattern: string): boolean {
168
+ const normalized = normalizePattern(pattern);
169
+ if (!hasMagic(normalized)) return file === normalized || file.startsWith(`${normalized.replace(/\/$/, "")}/`);
170
+ return new Bun.Glob(normalized).match(file);
171
+ }
172
+
173
+ export function isContainedFile(root: string, file: string): boolean {
174
+ try {
175
+ const target = realpathSync(join(root, file));
176
+ return target.startsWith(`${root}${sep}`);
177
+ } catch {
178
+ return false;
179
+ }
180
+ }
181
+
182
+ function resolvePatterns(files: string[], patterns: string[]): string[] {
183
+ const selected = new Set<string>();
184
+ for (const raw of patterns) {
185
+ const exclude = raw.startsWith("!");
186
+ const pattern = exclude ? raw.slice(1) : raw;
187
+ for (const file of files) {
188
+ if (!matchesPattern(file, pattern)) continue;
189
+ if (exclude) selected.delete(file);
190
+ else selected.add(file);
191
+ }
192
+ }
193
+ return [...selected];
194
+ }
195
+
196
+ function shippedFiles(context: Context): string[] {
197
+ const configured = patternsFor(context.pkg.files);
198
+ if (!configured) return context.files;
199
+ const selected = new Set(resolvePatterns(context.files, configured));
200
+ for (const file of context.files) {
201
+ if (file === "package.json" || /^readme(?:\.|$)/i.test(basename(file)) || /^(?:licen[cs]e|copying)(?:\.|$)/i.test(basename(file)))
202
+ selected.add(file);
203
+ }
204
+ return [...selected];
205
+ }
206
+
207
+ function manifestCheck(context: Context): void {
208
+ const { pkg, add } = context;
209
+ if (typeof pkg.name !== "string" || !/^(@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(pkg.name)) {
210
+ add({
211
+ code: "PKG_NAME_INVALID",
212
+ severity: "error",
213
+ path: "package.json#name",
214
+ message: "name must be a valid lowercase npm package name",
215
+ });
216
+ }
217
+ if (typeof pkg.version !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(pkg.version)) {
218
+ add({ code: "PKG_VERSION_INVALID", severity: "error", path: "package.json#version", message: "version must be valid SemVer" });
219
+ }
220
+ for (const [field, code] of [
221
+ ["license", "PKG_LICENSE_MISSING"],
222
+ ["repository", "PKG_REPOSITORY_MISSING"],
223
+ ["homepage", "PKG_HOMEPAGE_MISSING"],
224
+ ["bugs", "PKG_BUGS_MISSING"],
225
+ ] as const) {
226
+ if (pkg[field] === undefined)
227
+ add({ code, severity: "warning", path: `package.json#${field}`, message: `${field} metadata is missing` });
228
+ }
229
+ const keywords = Array.isArray(pkg.keywords) ? pkg.keywords : [];
230
+ if (!keywords.includes("pi-package"))
231
+ add({
232
+ code: "PI_KEYWORD_MISSING",
233
+ severity: "warning",
234
+ path: "package.json#keywords",
235
+ message: "add pi-package for Pi gallery discovery",
236
+ fix: "Add pi-package to keywords.",
237
+ });
238
+ if (!context.files.some((file) => /^readme(?:\.|$)/i.test(basename(file))))
239
+ add({ code: "PACKAGE_README_MISSING", severity: "warning", path: "README.md", message: "the package has no README" });
240
+ if (!context.files.some((file) => /^(?:licen[cs]e|copying)(?:\.|$)/i.test(basename(file))))
241
+ add({ code: "PACKAGE_LICENSE_FILE_MISSING", severity: "warning", path: "LICENSE", message: "the package has no license file" });
242
+ }
243
+
244
+ /** Resolves a resource field's configured or default patterns without
245
+ * validating or diagnosing them -- undefined means "this field does not
246
+ * apply" (an explicit pi manifest exists but omits the field). */
247
+ function resourceFieldPatterns(pi: unknown, field: (typeof RESOURCE_FIELDS)[number]): string[] | undefined {
248
+ const configured = isRecord(pi) ? pi[field] : undefined;
249
+ if (configured !== undefined) return patternsFor(configured);
250
+ if (pi === undefined) return [`${field}/`];
251
+ return undefined;
252
+ }
253
+
254
+ /** Matches a resource field's patterns against the file list and applies
255
+ * the extension filter -- containment is the caller's call (diagnosed as
256
+ * an error in resourcesCheck, silently dropped in discoverPackageResources). */
257
+ function matchResourceFiles(files: string[], field: (typeof RESOURCE_FIELDS)[number], patterns: string[]): string[] {
258
+ let matched: string[] = [];
259
+ try {
260
+ matched = resolvePatterns(
261
+ files,
262
+ patterns.filter(
263
+ (pattern) => !pattern.includes("..") && !isAbsolute(pattern) && isValidGlob(normalizePattern(pattern.replace(/^!/, ""))),
264
+ ),
265
+ );
266
+ } catch {
267
+ /* invalid glob already reported */
268
+ }
269
+ return matched.filter((file) => RESOURCE_EXTENSIONS[field].test(file));
270
+ }
271
+
272
+ export type ResourceField = (typeof RESOURCE_FIELDS)[number];
273
+
274
+ /** Pure discovery: which shipped files a package's own manifest (or
275
+ * conventional-directory fallback) declares as extensions/skills/prompts/
276
+ * themes, after the same extension-type and containment filtering
277
+ * resourcesCheck diagnoses -- without the diagnostics. Used by the
278
+ * package-resource overlay to know what there is to enable or disable. */
279
+ export function discoverPackageResources(root: string, pkg: Record<string, unknown>, files: string[]): Record<ResourceField, string[]> {
280
+ const pi = pkg.pi;
281
+ const result = {} as Record<ResourceField, string[]>;
282
+ if (pi !== undefined && !isRecord(pi)) {
283
+ for (const field of RESOURCE_FIELDS) result[field] = [];
284
+ return result;
285
+ }
286
+ for (const field of RESOURCE_FIELDS) {
287
+ const patterns = resourceFieldPatterns(pi, field);
288
+ result[field] = patterns ? matchResourceFiles(files, field, patterns).filter((file) => isContainedFile(root, file)) : [];
289
+ }
290
+ return result;
291
+ }
292
+
293
+ function resourcesCheck(context: Context): void {
294
+ const pi = context.pkg.pi;
295
+ let resourceCount = 0;
296
+ if (pi !== undefined && !isRecord(pi)) {
297
+ context.add({ code: "PI_MANIFEST_INVALID", severity: "error", path: "package.json#pi", message: "pi must be an object" });
298
+ return;
299
+ }
300
+ for (const field of RESOURCE_FIELDS) {
301
+ const configured = isRecord(pi) ? pi[field] : undefined;
302
+ let patterns: string[];
303
+ if (configured !== undefined) {
304
+ const parsed = patternsFor(configured);
305
+ if (!parsed) {
306
+ context.add({
307
+ code: "PI_MANIFEST_FIELD_INVALID",
308
+ severity: "error",
309
+ path: `package.json#pi.${field}`,
310
+ message: `${field} must be an array of glob strings`,
311
+ });
312
+ continue;
313
+ }
314
+ patterns = parsed;
315
+ } else if (pi === undefined) {
316
+ patterns = [`${field}/`];
317
+ } else {
318
+ continue;
319
+ }
320
+ for (const raw of patterns) {
321
+ const pattern = raw.startsWith("!") ? raw.slice(1) : raw;
322
+ const normalized = normalizePattern(pattern);
323
+ if (isAbsolute(normalized) || normalized.split("/").includes("..")) {
324
+ context.add({
325
+ code: "PI_RESOURCE_OUTSIDE_PACKAGE",
326
+ severity: "error",
327
+ path: `package.json#pi.${field}`,
328
+ message: `resource pattern escapes the package: ${raw}`,
329
+ });
330
+ continue;
331
+ }
332
+ if (!isValidGlob(normalized))
333
+ context.add({
334
+ code: "PI_RESOURCE_GLOB_INVALID",
335
+ severity: "error",
336
+ path: `package.json#pi.${field}`,
337
+ message: `invalid resource glob: ${raw}`,
338
+ });
339
+ }
340
+ const matched = matchResourceFiles(context.files, field, patterns).filter((file) => {
341
+ if (isContainedFile(context.root, file)) return true;
342
+ context.add({
343
+ code: "PI_RESOURCE_OUTSIDE_PACKAGE",
344
+ severity: "error",
345
+ path: file,
346
+ message: `resource resolves outside the package: ${file}`,
347
+ });
348
+ return false;
349
+ });
350
+ resourceCount += matched.length;
351
+ const packageFiles = patternsFor(context.pkg.files);
352
+ if (packageFiles) {
353
+ for (const file of matched) {
354
+ if (!resolvePatterns([file], packageFiles).includes(file))
355
+ context.add({
356
+ code: "PI_RESOURCE_NOT_PUBLISHED",
357
+ severity: "error",
358
+ path: file,
359
+ message: "declared Pi resource is excluded by package.json files",
360
+ });
361
+ }
362
+ }
363
+ const dependencies = isRecord(context.pkg.dependencies) ? context.pkg.dependencies : {};
364
+ const bundled = Array.isArray(context.pkg.bundledDependencies)
365
+ ? context.pkg.bundledDependencies
366
+ : Array.isArray(context.pkg.bundleDependencies)
367
+ ? context.pkg.bundleDependencies
368
+ : [];
369
+ for (const raw of patterns) {
370
+ const normalized = normalizePattern(raw.replace(/^!/, ""));
371
+ if (!normalized.startsWith("node_modules/")) continue;
372
+ const parts = normalized.slice("node_modules/".length).split("/");
373
+ const dependency = parts[0]!.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]!;
374
+ if (dependencies[dependency] === undefined || !bundled.includes(dependency))
375
+ context.add({
376
+ code: "PI_PACKAGE_NOT_BUNDLED",
377
+ severity: "error",
378
+ path: `package.json#pi.${field}`,
379
+ message: `${dependency} resources require dependencies plus bundledDependencies`,
380
+ });
381
+ }
382
+ if (patterns.some((pattern) => !pattern.startsWith("!")) && matched.length === 0) {
383
+ context.add({
384
+ code: "PI_RESOURCE_NO_MATCH",
385
+ severity: "error",
386
+ path: `package.json#pi.${field}`,
387
+ message: `${field} patterns match no supported resources`,
388
+ });
389
+ }
390
+ }
391
+ if (resourceCount === 0)
392
+ context.add({
393
+ code: "PI_NO_RESOURCES",
394
+ severity: "error",
395
+ path: "package.json#pi",
396
+ message: "package declares or contains no Pi resources",
397
+ });
398
+ if (isRecord(pi)) {
399
+ if (pi.image !== undefined && typeof pi.image !== "string")
400
+ context.add({
401
+ code: "PI_IMAGE_INVALID",
402
+ severity: "error",
403
+ path: "package.json#pi.image",
404
+ message: "gallery image must be a URL string",
405
+ });
406
+ else if (typeof pi.image === "string" && !/\.(?:png|jpe?g|gif|webp)(?:[?#].*)?$/i.test(pi.image))
407
+ context.add({
408
+ code: "PI_IMAGE_FORMAT_INVALID",
409
+ severity: "error",
410
+ path: "package.json#pi.image",
411
+ message: "gallery image must be PNG, JPEG, GIF, or WebP",
412
+ });
413
+ if (pi.video !== undefined && typeof pi.video !== "string")
414
+ context.add({
415
+ code: "PI_VIDEO_INVALID",
416
+ severity: "error",
417
+ path: "package.json#pi.video",
418
+ message: "gallery video must be a URL string",
419
+ });
420
+ else if (typeof pi.video === "string" && !/\.mp4(?:[?#].*)?$/i.test(pi.video))
421
+ context.add({
422
+ code: "PI_VIDEO_FORMAT_INVALID",
423
+ severity: "error",
424
+ path: "package.json#pi.video",
425
+ message: "gallery video must be MP4",
426
+ });
427
+ }
428
+ }
429
+
430
+ // Mirrors cleanup.ts's own MAX_CLEANUP_ENTRIES/MAX_CLEANUP_PATH_BYTES --
431
+ // duplicated as plain literals rather than imported to avoid a circular
432
+ // module dependency (cleanup.ts already imports isContainedFile from here).
433
+ const MAX_CLEANUP_ENTRIES = 50;
434
+ const MAX_CLEANUP_PATH_BYTES = 512;
435
+
436
+ /** Statically validates an optional pi.cleanup declaration at authoring
437
+ * time, before a package is ever installed or removed -- the same
438
+ * escape discipline packed remove itself enforces at removal time
439
+ * (cleanup.ts's runCleanup), just catchable earlier via packed check. */
440
+ function cleanupManifestCheck(context: Context): void {
441
+ const pi = context.pkg.pi;
442
+ if (!isRecord(pi) || pi.cleanup === undefined) return;
443
+ const declared = pi.cleanup;
444
+ if (!Array.isArray(declared) || !declared.every((item) => typeof item === "string")) {
445
+ context.add({
446
+ code: "PI_CLEANUP_INVALID",
447
+ severity: "error",
448
+ path: "package.json#pi.cleanup",
449
+ message: "pi.cleanup must be an array of relative path strings",
450
+ });
451
+ return;
452
+ }
453
+ if (declared.length > MAX_CLEANUP_ENTRIES) {
454
+ context.add({
455
+ code: "PI_CLEANUP_TOO_LARGE",
456
+ severity: "warning",
457
+ path: "package.json#pi.cleanup",
458
+ message: `pi.cleanup declares ${declared.length} entries; only the first ${MAX_CLEANUP_ENTRIES} are honored at removal time`,
459
+ });
460
+ }
461
+ for (const raw of declared) {
462
+ if (raw.length > MAX_CLEANUP_PATH_BYTES) {
463
+ context.add({
464
+ code: "PI_CLEANUP_TOO_LARGE",
465
+ severity: "warning",
466
+ path: "package.json#pi.cleanup",
467
+ message: `pi.cleanup entry exceeds ${MAX_CLEANUP_PATH_BYTES} characters and will be ignored at removal time: ${raw.slice(0, 80)}...`,
468
+ });
469
+ continue;
470
+ }
471
+ const trimmed = raw.trim();
472
+ if (trimmed.length === 0) {
473
+ context.add({
474
+ code: "PI_CLEANUP_ESCAPES_PACKAGE",
475
+ severity: "error",
476
+ path: "package.json#pi.cleanup",
477
+ message: "pi.cleanup entries must not be empty",
478
+ });
479
+ } else if (isAbsolute(trimmed) || trimmed.split("/").includes("..")) {
480
+ context.add({
481
+ code: "PI_CLEANUP_ESCAPES_PACKAGE",
482
+ severity: "error",
483
+ path: "package.json#pi.cleanup",
484
+ message: `pi.cleanup entry escapes the package and will never be removed: ${raw}`,
485
+ });
486
+ }
487
+ }
488
+ }
489
+
490
+ /** Shipped .js/.ts/.cjs/.mjs files that are actually contained within the
491
+ * package root (never a symlink escape) -- the same file universe every
492
+ * source-scanning check works over. */
493
+ function shippedSourceFiles(context: Context): string[] {
494
+ return shippedFiles(context).filter((path) => /\.[cm]?[jt]s$/.test(path) && isContainedFile(context.root, path));
495
+ }
496
+
497
+ /** Every import/require specifier literal in one file, bounded by
498
+ * MAX_SOURCE_BYTES -- undefined when the file is too large to read (the
499
+ * caller's own bound, never silently truncated mid-scan). */
500
+ function importSpecifiersIn(context: Context, file: string): string[] | undefined {
501
+ const absolute = join(context.root, file);
502
+ if (lstatSync(absolute).size > MAX_SOURCE_BYTES) return undefined;
503
+ const source = readFileSync(absolute, "utf8");
504
+ return [...source.matchAll(/(?:from\s*|import\s*\(|require\s*\()\s*["']([^"']+)["']/g)].map((match) => match[1]!);
505
+ }
506
+
507
+ function dependencyCheck(context: Context): void {
508
+ const dependencies = isRecord(context.pkg.dependencies) ? context.pkg.dependencies : {};
509
+ const optional = isRecord(context.pkg.optionalDependencies) ? context.pkg.optionalDependencies : {};
510
+ const peers = isRecord(context.pkg.peerDependencies) ? context.pkg.peerDependencies : {};
511
+ const dev = isRecord(context.pkg.devDependencies) ? context.pkg.devDependencies : {};
512
+ for (const core of CORE_PACKAGES) {
513
+ if (dependencies[core] !== undefined || optional[core] !== undefined || (peers[core] !== undefined && peers[core] !== "*")) {
514
+ context.add({
515
+ code: "PI_CORE_DEPENDENCY_PLACEMENT",
516
+ severity: "error",
517
+ path: `package.json#peerDependencies.${core}`,
518
+ message: `${core} must be a peerDependency with range *`,
519
+ });
520
+ }
521
+ }
522
+ for (const file of shippedSourceFiles(context)) {
523
+ for (const specifier of importSpecifiersIn(context, file) ?? []) {
524
+ if (specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("node:") || specifier.startsWith("bun:")) continue;
525
+ const name = specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0]!;
526
+ if (CORE_PACKAGES.has(name)) {
527
+ if (peers[name] !== "*")
528
+ context.add({
529
+ code: "PI_CORE_DEPENDENCY_PLACEMENT",
530
+ severity: "error",
531
+ path: file,
532
+ message: `${name} must be declared as peerDependencies[${name}] = *`,
533
+ });
534
+ } else if (dependencies[name] === undefined && optional[name] === undefined) {
535
+ const misplaced = dev[name] !== undefined ? "devDependency" : peers[name] !== undefined ? "peerDependency" : undefined;
536
+ context.add({
537
+ code: "RUNTIME_DEPENDENCY_MISSING",
538
+ severity: "error",
539
+ path: file,
540
+ message: `${name} is imported by shipped code but is not in dependencies${misplaced ? `; it is only a ${misplaced}` : ""}`,
541
+ });
542
+ }
543
+ }
544
+ }
545
+ }
546
+
547
+ const LIFECYCLE_SCRIPT_NAMES = ["preinstall", "install", "postinstall", "prepare"] as const;
548
+ const MAX_SCRIPT_TEXT = 500;
549
+
550
+ /** Bare module name a capability-suggestive specifier is checked against,
551
+ * after stripping an optional node:/bun: prefix and any subpath
552
+ * ("node:fs/promises" and "fs/promises" both normalize to "fs"). */
553
+ const CAPABILITY_MODULES = new Set(
554
+ (["child_process", "net", "fs", "dgram", "worker_threads"] as const).flatMap((name) => [name, `node:${name}`]).concat("bun:sqlite"),
555
+ );
556
+
557
+ function capabilitySpecifier(specifier: string): string | undefined {
558
+ const bare = specifier.split("/")[0]!;
559
+ return CAPABILITY_MODULES.has(bare) ? bare : undefined;
560
+ }
561
+
562
+ /**
563
+ * Purely static, non-executing capability/lifecycle-script signals over the
564
+ * exact shipped tarball contents -- the shoulder.dev-style "what does this
565
+ * package's code actually do" question, without ever installing or running
566
+ * anything. Info/warning tier only; neither diagnostic blocks by itself.
567
+ */
568
+ function capabilityCheck(context: Context): void {
569
+ const scripts = isRecord(context.pkg.scripts) ? context.pkg.scripts : {};
570
+ for (const name of LIFECYCLE_SCRIPT_NAMES) {
571
+ const command = scripts[name];
572
+ if (typeof command === "string" && command.trim().length > 0) {
573
+ context.add({
574
+ code: "PI_LIFECYCLE_SCRIPT_DECLARED",
575
+ severity: "warning",
576
+ path: `package.json#scripts.${name}`,
577
+ message: `declares a ${name} lifecycle script that runs automatically on install: ${command.slice(0, MAX_SCRIPT_TEXT)}`,
578
+ });
579
+ }
580
+ }
581
+ for (const file of shippedSourceFiles(context)) {
582
+ const seen = new Set<string>();
583
+ for (const specifier of importSpecifiersIn(context, file) ?? []) {
584
+ const capability = capabilitySpecifier(specifier);
585
+ if (capability && !seen.has(capability)) {
586
+ seen.add(capability);
587
+ context.add({
588
+ code: "PI_CAPABILITY_IMPORT",
589
+ severity: "info",
590
+ path: file,
591
+ message: `imports ${capability}, a capability-suggestive module`,
592
+ });
593
+ }
594
+ }
595
+ }
596
+ }
597
+
598
+ function parseFrontmatter(source: string): Record<string, string> {
599
+ if (!source.startsWith("---\n")) return {};
600
+ const end = source.indexOf("\n---", 4);
601
+ if (end < 0) return {};
602
+ const fields: Record<string, string> = {};
603
+ for (const line of source.slice(4, end).split("\n")) {
604
+ const match = /^([a-zA-Z0-9-]+):\s*(.*)$/.exec(line);
605
+ if (match) fields[match[1]!] = match[2]!.trim().replace(/^['"]|['"]$/g, "");
606
+ }
607
+ return fields;
608
+ }
609
+
610
+ function skillsCheck(context: Context): void {
611
+ for (const file of context.files.filter(
612
+ (path) => (basename(path) === "SKILL.md" || /^skills\/[^/]+\.md$/.test(path)) && isContainedFile(context.root, path),
613
+ )) {
614
+ const source = readFileSync(join(context.root, file), "utf8");
615
+ const fields = parseFrontmatter(source);
616
+ if (typeof fields.name !== "string" || !/^(?!-)(?!.*--)[a-z0-9-]{1,64}(?<!-)$/.test(fields.name))
617
+ context.add({
618
+ code: "SKILL_NAME_INVALID",
619
+ severity: "error",
620
+ path: file,
621
+ message: "skill name must use 1-64 lowercase letters, numbers, and single hyphens",
622
+ });
623
+ if (!fields.description)
624
+ context.add({
625
+ code: "SKILL_DESCRIPTION_MISSING",
626
+ severity: "error",
627
+ path: file,
628
+ message: "skill frontmatter requires a description",
629
+ });
630
+ else if (fields.description.length > 1024)
631
+ context.add({
632
+ code: "SKILL_DESCRIPTION_TOO_LONG",
633
+ severity: "warning",
634
+ path: file,
635
+ message: "skill description exceeds 1024 characters",
636
+ });
637
+ const references = new Set<string>();
638
+ for (const match of source.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)) references.add(match[1]!.split("#")[0]!);
639
+ for (const match of source.matchAll(/`((?:scripts|references?|assets)\/[^`\s]+)`/g)) references.add(match[1]!);
640
+ for (const target of references) {
641
+ if (!target || /^(?:[a-z]+:|#|\/)/i.test(target)) continue;
642
+ let decoded: string;
643
+ try {
644
+ decoded = decodeURIComponent(target);
645
+ } catch {
646
+ context.add({
647
+ code: "SKILL_REFERENCE_INVALID",
648
+ severity: "error",
649
+ path: file,
650
+ message: `skill reference is not a valid path: ${target}`,
651
+ });
652
+ continue;
653
+ }
654
+ const absolute = resolve(context.root, dirname(file), decoded);
655
+ if (!absolute.startsWith(`${context.root}${sep}`) || !existsSync(absolute))
656
+ context.add({
657
+ code: "SKILL_REFERENCE_MISSING",
658
+ severity: "error",
659
+ path: file,
660
+ message: `skill reference does not exist: ${target}`,
661
+ });
662
+ }
663
+ }
664
+ }
665
+
666
+ function extensionFiles(context: Context): string[] {
667
+ const pi = isRecord(context.pkg.pi) ? context.pkg.pi : undefined;
668
+ const configured = patternsFor(pi?.extensions);
669
+ return (
670
+ configured ? resolvePatterns(context.files, configured) : context.files.filter((path) => /^extensions\/.*\.[cm]?[jt]s$/.test(path))
671
+ ).filter((path) => /\.[cm]?[jt]s$/.test(path) && isContainedFile(context.root, path));
672
+ }
673
+
674
+ function extensionsCheck(context: Context): void {
675
+ const extensions = extensionFiles(context);
676
+ for (const file of extensions) {
677
+ const absolute = join(context.root, file);
678
+ if (lstatSync(absolute).size > MAX_SOURCE_BYTES) continue;
679
+ const source = readFileSync(absolute, "utf8");
680
+ if (!/export\s+default\s+(?:async\s+)?(?:function|\(?[A-Za-z_$][\w$]*\)?\s*=>)/.test(source))
681
+ context.add({
682
+ code: "PI_EXTENSION_DEFAULT_EXPORT_MISSING",
683
+ severity: "error",
684
+ path: file,
685
+ message: "extension must statically expose a default factory export",
686
+ });
687
+ if (!/\.register(?:Tool|Command|Shortcut|Flag|MessageRenderer|Provider)\s*\(|\.on\s*\(/.test(source))
688
+ context.add({
689
+ code: "PI_EXTENSION_REGISTRATION_NOT_DETECTED",
690
+ severity: "info",
691
+ path: file,
692
+ message: "no statically recognizable Pi registration was found",
693
+ });
694
+ }
695
+ }
696
+
697
+ async function genericCheck(context: Context): Promise<void> {
698
+ try {
699
+ const files = [];
700
+ let totalBytes = 0;
701
+ for (const name of shippedFiles(context)) {
702
+ if (!isContainedFile(context.root, name)) continue;
703
+ const size = lstatSync(join(context.root, name)).size;
704
+ if (size > MAX_SOURCE_BYTES || totalBytes + size > MAX_GENERIC_BYTES) {
705
+ context.markTruncated();
706
+ context.add({
707
+ code: "NPM_INPUT_TRUNCATED",
708
+ severity: "warning",
709
+ path: name,
710
+ message: "file was omitted from generic npm checks by the static input bound",
711
+ });
712
+ continue;
713
+ }
714
+ const data = readFileSync(join(context.root, name));
715
+ totalBytes += data.byteLength;
716
+ files.push({ name: `package/${name}`, data });
717
+ }
718
+ const result = await publint({ pkgDir: "package", pack: { files }, level: "suggestion" });
719
+ for (const message of result.messages) {
720
+ context.add({
721
+ code: `NPM_${message.code}`,
722
+ severity: message.type === "error" ? "error" : message.type === "warning" ? "warning" : "info",
723
+ path: message.path.length > 0 ? `package.json#${message.path.join(".")}` : "package.json",
724
+ message: formatMessage(message, result.pkg, { color: false }) ?? message.code,
725
+ });
726
+ }
727
+ } catch (error) {
728
+ context.add({
729
+ code: "NPM_PUBLINT_FAILED",
730
+ severity: "warning",
731
+ path: "package.json",
732
+ message: `publint failed: ${error instanceof Error ? error.message : String(error)}`,
733
+ });
734
+ }
735
+ }
736
+
737
+ export async function checkPackage(packagePath: string, options: CheckOptions = {}): Promise<CheckReport> {
738
+ const requestedRoot = resolve(packagePath);
739
+ let root: string;
740
+ try {
741
+ root = realpathSync(requestedRoot);
742
+ if (!lstatSync(root).isDirectory()) throw new Error("path is not a directory");
743
+ } catch (error) {
744
+ const diagnostic: Diagnostic = {
745
+ code: "PKG_PATH_INVALID",
746
+ severity: "error",
747
+ path: requestedRoot,
748
+ message: `cannot inspect package path: ${error instanceof Error ? error.message : String(error)}`,
749
+ };
750
+ return {
751
+ root: requestedRoot,
752
+ ok: false,
753
+ diagnostics: [diagnostic],
754
+ summary: { errors: 1, warnings: 0, info: 0 },
755
+ checkedFiles: 0,
756
+ truncated: false,
757
+ };
758
+ }
759
+ const maxDiagnostics = Math.max(1, Math.min(options.maxDiagnostics ?? DEFAULT_MAX_DIAGNOSTICS, 1_000));
760
+ const maxFiles = Math.max(1, Math.min(options.maxFiles ?? DEFAULT_MAX_FILES, 10_000));
761
+ const manifestPath = join(root, "package.json");
762
+ let pkg: Record<string, unknown>;
763
+ try {
764
+ const parsed: unknown = JSON.parse(readFileSync(manifestPath, "utf8"));
765
+ if (!isRecord(parsed)) throw new Error("root must be an object");
766
+ pkg = parsed;
767
+ } catch (error) {
768
+ const diagnostic: Diagnostic = {
769
+ code: "PKG_JSON_INVALID",
770
+ severity: "error",
771
+ path: "package.json",
772
+ message: `cannot parse package.json: ${error instanceof Error ? error.message : String(error)}`,
773
+ };
774
+ return { root, ok: false, diagnostics: [diagnostic], summary: { errors: 1, warnings: 0, info: 0 }, checkedFiles: 0, truncated: false };
775
+ }
776
+ const walked = walk(root, maxFiles, declaredBundledRoots(pkg));
777
+ const diagnostics: Diagnostic[] = [];
778
+ const totals = { errors: 0, warnings: 0, info: 0 };
779
+ let diagnosticOverflow = false;
780
+ const context: Context = {
781
+ root,
782
+ pkg,
783
+ files: walked.files,
784
+ markTruncated() {
785
+ diagnosticOverflow = true;
786
+ },
787
+ add(diagnostic) {
788
+ if (diagnostic.severity === "error") totals.errors++;
789
+ else if (diagnostic.severity === "warning") totals.warnings++;
790
+ else totals.info++;
791
+ const bounded = {
792
+ ...diagnostic,
793
+ path: diagnostic.path.slice(0, 512),
794
+ message: diagnostic.message.slice(0, 1_000),
795
+ fix: diagnostic.fix?.slice(0, 1_000),
796
+ };
797
+ if (diagnostics.length < maxDiagnostics) diagnostics.push(bounded);
798
+ else diagnosticOverflow = true;
799
+ },
800
+ };
801
+ const checks: Check[] = [
802
+ manifestCheck,
803
+ resourcesCheck,
804
+ cleanupManifestCheck,
805
+ dependencyCheck,
806
+ capabilityCheck,
807
+ skillsCheck,
808
+ extensionsCheck,
809
+ ];
810
+ if (options.generic !== false) checks.push(genericCheck);
811
+ for (const check of checks) await check(context);
812
+ let smoke: CheckReport["smoke"];
813
+ if (options.smoke) {
814
+ const extensions = extensionFiles(context).slice(0, 10);
815
+ if (extensionFiles(context).length > extensions.length) context.markTruncated();
816
+ const results: ExtensionSmokeResult[] = [];
817
+ for (const file of extensions) {
818
+ const result = await runExtensionSmoke(root, join(root, file));
819
+ results.push(result);
820
+ const suffix = result.status.replaceAll("-", "_").toUpperCase();
821
+ context.add({
822
+ code: `PI_EXTENSION_SMOKE_${suffix}`,
823
+ severity: result.status === "ok" ? "info" : "error",
824
+ path: file,
825
+ message:
826
+ result.status === "ok"
827
+ ? `extension loaded; registrations: ${JSON.stringify(result.registrations)}`
828
+ : (result.message ?? `extension smoke failed: ${result.status}`),
829
+ });
830
+ }
831
+ smoke = { extensions: results };
832
+ }
833
+ const severityRank: Record<DiagnosticSeverity, number> = { error: 0, warning: 1, info: 2 };
834
+ diagnostics.sort(
835
+ (a, b) => severityRank[a.severity] - severityRank[b.severity] || a.code.localeCompare(b.code) || a.path.localeCompare(b.path),
836
+ );
837
+ return {
838
+ root,
839
+ ok: totals.errors === 0,
840
+ diagnostics,
841
+ summary: totals,
842
+ checkedFiles: walked.files.length,
843
+ truncated: walked.truncated || diagnosticOverflow,
844
+ ...(smoke ? { smoke } : {}),
845
+ };
846
+ }
847
+
848
+ export function formatCheckReport(report: CheckReport, json: boolean): string {
849
+ if (json) {
850
+ let diagnostics = report.diagnostics;
851
+ let output = JSON.stringify(report);
852
+ while (output.length + 1 > MAX_JSON_OUTPUT && diagnostics.length > 0) {
853
+ diagnostics = diagnostics.slice(0, Math.floor(diagnostics.length / 2));
854
+ output = JSON.stringify({ ...report, diagnostics, truncated: true });
855
+ }
856
+ if (output.length + 1 > MAX_JSON_OUTPUT)
857
+ output = JSON.stringify({
858
+ root: report.root.slice(0, 4_096),
859
+ ok: report.ok,
860
+ diagnostics: [],
861
+ summary: report.summary,
862
+ checkedFiles: report.checkedFiles,
863
+ truncated: true,
864
+ });
865
+ return `${output}\n`;
866
+ }
867
+ let output = `${report.ok ? "PASS" : "FAIL"} ${report.root} — ${report.summary.errors} error(s), ${report.summary.warnings} warning(s), ${report.summary.info} info\n`;
868
+ for (const diagnostic of report.diagnostics)
869
+ output += `${diagnostic.severity.toUpperCase()} ${diagnostic.code} ${diagnostic.path}: ${diagnostic.message}${diagnostic.fix ? ` Fix: ${diagnostic.fix}` : ""}\n`;
870
+ if (report.truncated) output += "Output truncated by configured bounds.\n";
871
+ return output.slice(0, MAX_HUMAN_OUTPUT);
872
+ }