@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,761 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { Type } from "typebox";
6
+ import { Compile } from "typebox/compile";
7
+ import type { Diagnostic } from "../adoption/check.ts";
8
+ import { npmPackageName, readResolvedIntegrity, readResolvedVersion } from "../packages/installed.ts";
9
+ import type { Installer, Registry } from "../packages/package.ts";
10
+ import { writeJsonAtomic } from "../shared/atomic-json.ts";
11
+
12
+ export const SETUP_MANIFEST_FILE = "pi-setup.json";
13
+ export const SETUP_SCHEMA_PATH = "./schema/pi-setup-v1.schema.json";
14
+ const MAX_MANIFEST_BYTES = 1024 * 1024;
15
+ const MAX_PROFILE_FILE_BYTES = 256 * 1024;
16
+ const MAX_PACKAGES = 20;
17
+ const MAX_PROFILES = 100;
18
+ const MAX_OPERATION_OUTPUT = 1_000;
19
+ const SECRET_PATTERN =
20
+ /(?:-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|password|secret)\s*[:=]\s*\S+)/i;
21
+ const PROFILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
22
+ const COMMIT = /^[a-f0-9]{40}$/i;
23
+
24
+ const String128 = Type.String({ minLength: 1, maxLength: 128 });
25
+ const String256 = Type.String({ minLength: 1, maxLength: 256 });
26
+ const ScopeSchema = Type.Union([Type.Literal("global"), Type.Literal("project")]);
27
+ const ProfileSchema = Type.Object(
28
+ {
29
+ scope: ScopeSchema,
30
+ provider: Type.Optional(String128),
31
+ model: Type.Optional(String256),
32
+ thinkingLevel: Type.Optional(
33
+ Type.Union(["off", "minimal", "low", "medium", "high", "xhigh", "max"].map((value) => Type.Literal(value))),
34
+ ),
35
+ tools: Type.Optional(Type.Array(String128, { maxItems: 100, uniqueItems: true })),
36
+ instructions: Type.Optional(Type.String({ minLength: 1, maxLength: 16_384 })),
37
+ theme: Type.Optional(String128),
38
+ allowedModels: Type.Optional(Type.Array(String256, { maxItems: 100, uniqueItems: true })),
39
+ },
40
+ { additionalProperties: false },
41
+ );
42
+ const NpmPackageSchema = Type.Object(
43
+ {
44
+ kind: Type.Literal("npm"),
45
+ scope: ScopeSchema,
46
+ source: Type.String({ minLength: 5, maxLength: 512, pattern: "^npm:" }),
47
+ resolved: String128,
48
+ integrity: Type.String({ minLength: 8, maxLength: 512, pattern: "^sha(?:256|384|512)-[A-Za-z0-9+/=_-]+$" }),
49
+ },
50
+ { additionalProperties: false },
51
+ );
52
+ const GitPackageSchema = Type.Object(
53
+ {
54
+ kind: Type.Union([Type.Literal("git"), Type.Literal("https")]),
55
+ scope: ScopeSchema,
56
+ requested: Type.String({ minLength: 8, maxLength: 1_024 }),
57
+ source: Type.String({ minLength: 8, maxLength: 1_024 }),
58
+ resolved: Type.String({ pattern: "^[a-fA-F0-9]{40}$" }),
59
+ },
60
+ { additionalProperties: false },
61
+ );
62
+ const LocalPackageSchema = Type.Object(
63
+ {
64
+ kind: Type.Literal("local"),
65
+ scope: ScopeSchema,
66
+ source: Type.String({ minLength: 1, maxLength: 1_024 }),
67
+ resolved: Type.String({ minLength: 1, maxLength: 1_024 }),
68
+ machineLocal: Type.Literal(true),
69
+ },
70
+ { additionalProperties: false },
71
+ );
72
+ export const SetupManifestSchema = Type.Object(
73
+ {
74
+ $schema: Type.Literal(SETUP_SCHEMA_PATH),
75
+ schemaVersion: Type.Literal(1),
76
+ packages: Type.Array(Type.Union([NpmPackageSchema, GitPackageSchema, LocalPackageSchema]), { maxItems: MAX_PACKAGES }),
77
+ profiles: Type.Record(Type.String({ pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" }), ProfileSchema, {
78
+ maxProperties: MAX_PROFILES,
79
+ additionalProperties: false,
80
+ }),
81
+ defaultProfile: Type.Optional(Type.String({ pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" })),
82
+ },
83
+ { additionalProperties: false },
84
+ );
85
+ const ManifestValidator = Compile(SetupManifestSchema);
86
+
87
+ export type SetupScope = "global" | "project";
88
+ export type SetupPackage =
89
+ | { kind: "npm"; scope: SetupScope; source: string; resolved: string; integrity: string }
90
+ | { kind: "git" | "https"; scope: SetupScope; requested: string; source: string; resolved: string }
91
+ | { kind: "local"; scope: SetupScope; source: string; resolved: string; machineLocal: true };
92
+ export interface SetupProfile {
93
+ scope: SetupScope;
94
+ provider?: string;
95
+ model?: string;
96
+ thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
97
+ tools?: string[];
98
+ instructions?: string;
99
+ theme?: string;
100
+ allowedModels?: string[];
101
+ }
102
+ export interface SetupManifest {
103
+ $schema: typeof SETUP_SCHEMA_PATH;
104
+ schemaVersion: 1;
105
+ packages: SetupPackage[];
106
+ profiles: Record<string, SetupProfile>;
107
+ defaultProfile?: string;
108
+ }
109
+ export type SetupOperation =
110
+ | { kind: "install-package" | "update-package"; packageName: string; scope: SetupScope; source: string; resolved: string }
111
+ | { kind: "remove-package"; packageName: string; scope: SetupScope; source: string }
112
+ | { kind: "write-profile" | "remove-profile"; name: string; scope: SetupScope; fields: string[] };
113
+ export interface SetupExportReport {
114
+ ok: boolean;
115
+ path: string;
116
+ manifest: SetupManifest;
117
+ diagnostics: Diagnostic[];
118
+ wrote: boolean;
119
+ }
120
+ export interface SetupPlan {
121
+ ok: boolean;
122
+ manifestPath: string;
123
+ operations: SetupOperation[];
124
+ manifestSha256?: string;
125
+ prune?: boolean;
126
+ diagnostics: Diagnostic[];
127
+ }
128
+ export interface SetupApplyResult {
129
+ ok: boolean;
130
+ manifestPath: string;
131
+ operations: Array<{ kind: SetupOperation["kind"]; target: string; status: "succeeded" | "failed"; output?: string }>;
132
+ reloadRequired: boolean;
133
+ diagnostics: Diagnostic[];
134
+ }
135
+ export interface SetupUpdateReport extends SetupExportReport {
136
+ updated: number;
137
+ }
138
+ export interface GitResolutionPort {
139
+ resolve(source: string): Promise<{ commit: string; source: string }>;
140
+ }
141
+
142
+ async function readBoundedStream(stream: ReadableStream<Uint8Array>, maximum: number): Promise<string> {
143
+ const reader = stream.getReader();
144
+ const chunks: Uint8Array[] = [];
145
+ let size = 0;
146
+ try {
147
+ while (true) {
148
+ const { done, value } = await reader.read();
149
+ if (done) break;
150
+ size += value.byteLength;
151
+ if (size > maximum) {
152
+ await reader.cancel();
153
+ throw new Error(`process output exceeded ${maximum} bytes`);
154
+ }
155
+ chunks.push(value);
156
+ }
157
+ } finally {
158
+ reader.releaseLock();
159
+ }
160
+ return new TextDecoder().decode(Buffer.concat(chunks));
161
+ }
162
+
163
+ class GitLsRemoteResolver implements GitResolutionPort {
164
+ async resolve(source: string): Promise<{ commit: string; source: string }> {
165
+ const { clone, ref } = splitGitSource(source);
166
+ const proc = Bun.spawn(["git", "ls-remote", clone, ref ?? "HEAD"], {
167
+ stdout: "pipe",
168
+ stderr: "pipe",
169
+ signal: AbortSignal.timeout(30_000),
170
+ });
171
+ const [stdout, stderr, code] = await Promise.all([
172
+ readBoundedStream(proc.stdout, 64 * 1024),
173
+ readBoundedStream(proc.stderr, 64 * 1024),
174
+ proc.exited,
175
+ ]);
176
+ if (code !== 0) throw new Error((stderr || stdout || `git exited ${code}`).slice(0, 2_000));
177
+ const commit = stdout.trim().split(/\s+/)[0] ?? "";
178
+ if (!COMMIT.test(commit)) throw new Error("git ref did not resolve to one commit");
179
+ return { commit, source: immutableGitSource(source, commit) };
180
+ }
181
+ }
182
+
183
+ function isRecord(value: unknown): value is Record<string, unknown> {
184
+ return typeof value === "object" && value !== null && !Array.isArray(value);
185
+ }
186
+ function stableValue(value: unknown): unknown {
187
+ if (Array.isArray(value)) return value.map(stableValue);
188
+ if (!isRecord(value)) return value;
189
+ return Object.fromEntries(
190
+ Object.keys(value)
191
+ .sort()
192
+ .map((key) => [key, stableValue(value[key])]),
193
+ );
194
+ }
195
+ export function canonicalSetupManifest(manifest: SetupManifest): string {
196
+ return `${JSON.stringify(stableValue(manifest), null, 2)}\n`;
197
+ }
198
+
199
+ function sourceHasCredentials(source: string): boolean {
200
+ try {
201
+ const url = new URL(source.replace(/^git:/, ""));
202
+ return Boolean(url.username || url.password || url.search);
203
+ } catch {
204
+ return /(?:https?:\/\/)[^/@\s]+@/i.test(source);
205
+ }
206
+ }
207
+ function sourceKind(source: string): SetupPackage["kind"] {
208
+ if (source.startsWith("npm:")) return "npm";
209
+ if (source.startsWith("git:")) return "git";
210
+ if (/^https:\/\//.test(source)) return "https";
211
+ return "local";
212
+ }
213
+ function splitGitSource(source: string): { clone: string; ref?: string } {
214
+ const raw = source.replace(/^git:/, "");
215
+ const at = raw.lastIndexOf("@");
216
+ const pathStart = Math.max(raw.lastIndexOf("/"), raw.lastIndexOf(":"));
217
+ const hasRef = at > pathStart;
218
+ return { clone: hasRef ? raw.slice(0, at) : raw, ref: hasRef ? raw.slice(at + 1) : undefined };
219
+ }
220
+ function immutableGitSource(source: string, commit: string): string {
221
+ const { clone } = splitGitSource(source);
222
+ return `${source.startsWith("git:") ? "git:" : ""}${clone}@${commit}`;
223
+ }
224
+ function packageIdentity(source: string, scope: SetupScope, _root: string): string {
225
+ const kind = sourceKind(source);
226
+ if (kind === "npm") return `${scope}:npm:${npmPackageName(source) ?? source}`;
227
+ if (kind === "local") return `${scope}:local:${source}`;
228
+ return `${scope}:git:${splitGitSource(source).clone.replace(/\.git$/, "")}`;
229
+ }
230
+
231
+ export function decodeSetupManifest(text: string): SetupManifest {
232
+ if (Buffer.byteLength(text) > MAX_MANIFEST_BYTES) throw new Error("SETUP_MANIFEST_TOO_LARGE: manifest exceeds 1 MiB");
233
+ let value: unknown;
234
+ try {
235
+ value = JSON.parse(text);
236
+ } catch (error) {
237
+ throw new Error(`SETUP_MANIFEST_JSON_INVALID: ${error instanceof Error ? error.message : String(error)}`);
238
+ }
239
+ if (!ManifestValidator.Check(value)) {
240
+ const first = ManifestValidator.Errors(value)[0];
241
+ throw new Error(`SETUP_MANIFEST_SCHEMA_INVALID: ${first?.instancePath || "/"} ${first?.message ?? "invalid manifest"}`);
242
+ }
243
+ const manifest = value as SetupManifest;
244
+ if (manifest.defaultProfile && !manifest.profiles[manifest.defaultProfile])
245
+ throw new Error("SETUP_DEFAULT_PROFILE_UNKNOWN: defaultProfile must name a declared profile");
246
+ const identities = new Set<string>();
247
+ for (const pkg of manifest.packages) {
248
+ if (sourceHasCredentials(pkg.source) || (pkg.kind !== "npm" && pkg.kind !== "local" && sourceHasCredentials(pkg.requested)))
249
+ throw new Error("SETUP_CREDENTIAL_SOURCE: credential-bearing package sources are forbidden");
250
+ if (pkg.kind !== sourceKind(pkg.source)) throw new Error(`SETUP_SOURCE_KIND_MISMATCH: ${pkg.source}`);
251
+ if ((pkg.kind === "git" || pkg.kind === "https") && (!COMMIT.test(pkg.resolved) || splitGitSource(pkg.source).ref !== pkg.resolved))
252
+ throw new Error(`SETUP_GIT_NOT_IMMUTABLE: ${pkg.source}`);
253
+ if (pkg.kind === "local" && (!pkg.machineLocal || !isAbsolute(pkg.resolved)))
254
+ throw new Error(`SETUP_LOCAL_NOT_MACHINE_LOCAL: ${pkg.source}`);
255
+ const identity = packageIdentity(pkg.source, pkg.scope, pkg.resolved);
256
+ if (identities.has(identity)) throw new Error(`SETUP_PACKAGE_DUPLICATE: ${pkg.source}`);
257
+ identities.add(identity);
258
+ }
259
+ for (const [name, profile] of Object.entries(manifest.profiles)) {
260
+ if (!PROFILE_NAME.test(name)) throw new Error(`SETUP_PROFILE_NAME_INVALID: ${name}`);
261
+ if (profile.instructions && SECRET_PATTERN.test(profile.instructions))
262
+ throw new Error(`SETUP_PROFILE_SECRET: profile ${name} instructions contain secret-like material`);
263
+ }
264
+ return manifest;
265
+ }
266
+
267
+ function readBoundedJson(path: string, maxBytes: number): Record<string, unknown> {
268
+ if (!existsSync(path)) return {};
269
+ const stat = lstatSync(path);
270
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`SETUP_PATH_UNSAFE: ${path} must be a regular file`);
271
+ if (stat.size > maxBytes) throw new Error(`SETUP_FILE_TOO_LARGE: ${path}`);
272
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
273
+ if (!isRecord(parsed)) throw new Error(`SETUP_FILE_INVALID: ${path} must contain an object`);
274
+ return parsed;
275
+ }
276
+ function extractSources(path: string): string[] {
277
+ const raw = readBoundedJson(path, 256 * 1024).packages;
278
+ if (!Array.isArray(raw)) return [];
279
+ return raw
280
+ .map((entry) => (typeof entry === "string" ? entry : isRecord(entry) && typeof entry.source === "string" ? entry.source : ""))
281
+ .filter(Boolean)
282
+ .slice(0, 500);
283
+ }
284
+ function declarations(piHome: string, root: string): Array<{ source: string; scope: SetupScope }> {
285
+ return [
286
+ ...extractSources(join(piHome, "settings.json")).map((source) => ({ source, scope: "global" as const })),
287
+ ...extractSources(join(root, ".pi", "settings.json")).map((source) => ({ source, scope: "project" as const })),
288
+ ];
289
+ }
290
+ function profileValue(value: unknown): Omit<SetupProfile, "scope"> {
291
+ if (!isRecord(value)) throw new Error("SETUP_PROFILE_INVALID: profile must be an object");
292
+ const allowed = new Set(["provider", "model", "thinkingLevel", "tools", "instructions", "theme", "allowedModels"]);
293
+ if (Object.keys(value).some((key) => !allowed.has(key))) throw new Error("SETUP_PROFILE_INVALID: profile contains an unknown field");
294
+ const result: Omit<SetupProfile, "scope"> = {};
295
+ for (const field of ["provider", "model", "instructions", "theme"] as const) {
296
+ if (value[field] !== undefined && typeof value[field] !== "string") throw new Error(`SETUP_PROFILE_INVALID: ${field} must be a string`);
297
+ if (typeof value[field] === "string") result[field] = value[field];
298
+ }
299
+ if (
300
+ value.thinkingLevel !== undefined &&
301
+ !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(String(value.thinkingLevel))
302
+ )
303
+ throw new Error("SETUP_PROFILE_INVALID: invalid thinkingLevel");
304
+ if (value.thinkingLevel !== undefined) result.thinkingLevel = value.thinkingLevel as SetupProfile["thinkingLevel"];
305
+ for (const field of ["tools", "allowedModels"] as const) {
306
+ if (value[field] !== undefined && (!Array.isArray(value[field]) || !value[field].every((item) => typeof item === "string")))
307
+ throw new Error(`SETUP_PROFILE_INVALID: ${field} must contain strings`);
308
+ if (Array.isArray(value[field])) result[field] = value[field] as string[];
309
+ }
310
+ return result;
311
+ }
312
+ function readProfiles(path: string): Record<string, Omit<SetupProfile, "scope">> {
313
+ const raw = readBoundedJson(path, MAX_PROFILE_FILE_BYTES);
314
+ const profiles: Record<string, Omit<SetupProfile, "scope">> = {};
315
+ for (const [name, value] of Object.entries(raw).slice(0, MAX_PROFILES)) {
316
+ if (!PROFILE_NAME.test(name)) throw new Error(`SETUP_PROFILE_NAME_INVALID: ${name}`);
317
+ profiles[name] = profileValue(value);
318
+ }
319
+ return profiles;
320
+ }
321
+ async function atomicJson(path: string, value: unknown, mode: number): Promise<void> {
322
+ await writeJsonAtomic(path, stableValue(value), { mode, pretty: true });
323
+ }
324
+ function bundledSchemaText(): string {
325
+ return readFileSync(join(dirname(dirname(dirname(fileURLToPath(import.meta.url)))), "schema", "pi-setup-v1.schema.json"), "utf8");
326
+ }
327
+ /** The curated @danypops ecosystem starter manifest, shipped in this same package (setup/danypops-ecosystem.pi-setup.json) -- resolves relative to this module's own on-disk location the same way bundledSchemaText() does, so `packed setup plan/apply --ecosystem` works immediately after `pi install npm:@danypops/pi-packed`, no separate download. */
328
+ export function bundledEcosystemManifestPath(): string {
329
+ return join(dirname(dirname(dirname(fileURLToPath(import.meta.url)))), "setup", "danypops-ecosystem.pi-setup.json");
330
+ }
331
+ /** Deliberately NOT on writeJsonAtomic: this writes schemaText's exact bytes verbatim -- a
332
+ * later drift check (readFileSync(schemaPath) !== schemaText) needs byte-for-byte fidelity,
333
+ * which a JSON.parse+stringify round-trip through the JSON-specific atomic writer would break. */
334
+ function atomicText(path: string, text: string, mode: number): void {
335
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
336
+ if (existsSync(path) && lstatSync(path).isSymbolicLink()) throw new Error(`SETUP_PATH_UNSAFE: refusing to replace symlink ${path}`);
337
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
338
+ try {
339
+ writeFileSync(temporary, text, { flag: "wx", mode });
340
+ renameSync(temporary, path);
341
+ } finally {
342
+ rmSync(temporary, { force: true });
343
+ }
344
+ }
345
+ function manifestPath(input: string): string {
346
+ const absolute = resolve(input);
347
+ return absolute.endsWith(".json") ? absolute : join(absolute, SETUP_MANIFEST_FILE);
348
+ }
349
+ function readManifest(path: string): SetupManifest {
350
+ const stat = lstatSync(path);
351
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("SETUP_PATH_UNSAFE: manifest must be a regular file");
352
+ if (stat.size > MAX_MANIFEST_BYTES) throw new Error("SETUP_MANIFEST_TOO_LARGE: manifest exceeds 1 MiB");
353
+ return decodeSetupManifest(readFileSync(path, "utf8"));
354
+ }
355
+ function diagnostic(code: string, severity: Diagnostic["severity"], path: string, message: string): Diagnostic {
356
+ return { code, severity, path, message: message.slice(0, 2_000) };
357
+ }
358
+ function packageTarget(pkg: SetupPackage): string {
359
+ if (pkg.kind === "npm") return `npm:${npmPackageName(pkg.source)}@${pkg.resolved}`;
360
+ if (pkg.kind === "local") return pkg.resolved;
361
+ return pkg.source;
362
+ }
363
+
364
+ export class SetupManager {
365
+ constructor(
366
+ private readonly registry: Registry,
367
+ private readonly installer: Installer,
368
+ private readonly piHome: string,
369
+ private readonly git: GitResolutionPort = new GitLsRemoteResolver(),
370
+ ) {}
371
+
372
+ private async resolvePackage(source: string, scope: SetupScope, root: string, machineLocal: boolean): Promise<SetupPackage> {
373
+ const kind = sourceKind(source);
374
+ if (sourceHasCredentials(source)) throw new Error("credential-bearing sources are forbidden");
375
+ if (kind === "npm") {
376
+ const name = npmPackageName(source);
377
+ if (!name) throw new Error("invalid npm source");
378
+ const installHome = scope === "global" ? this.piHome : join(root, ".pi");
379
+ const installed = readResolvedVersion(installHome, source);
380
+ const lockIntegrity = readResolvedIntegrity(installHome, source);
381
+ const info = await this.registry.info(name);
382
+ const resolved = installed ?? info.version;
383
+ const integrity = lockIntegrity ?? (info.version === resolved ? info.publication?.integrity : undefined);
384
+ if (!integrity) throw new Error(`exact registry integrity unavailable for ${name}@${resolved}`);
385
+ return { kind, scope, source: `npm:${name}@${resolved}`, resolved, integrity };
386
+ }
387
+ if (kind === "local") {
388
+ if (!machineLocal) throw new Error(`local source requires --machine-local: ${source}`);
389
+ const base = scope === "global" ? this.piHome : root;
390
+ const resolved = resolve(base, source);
391
+ return { kind, scope, source, resolved, machineLocal: true };
392
+ }
393
+ const resolution = await this.git.resolve(source);
394
+ return { kind, scope, requested: source, source: resolution.source, resolved: resolution.commit };
395
+ }
396
+
397
+ async export(projectRoot: string, options: { force?: boolean; machineLocal?: boolean } = {}): Promise<SetupExportReport> {
398
+ const root = resolve(projectRoot),
399
+ path = join(root, SETUP_MANIFEST_FILE),
400
+ diagnostics: Diagnostic[] = [],
401
+ packages: SetupPackage[] = [];
402
+ for (const item of declarations(this.piHome, root).slice(0, MAX_PACKAGES)) {
403
+ try {
404
+ packages.push(await this.resolvePackage(item.source, item.scope, root, options.machineLocal === true));
405
+ } catch (error) {
406
+ diagnostics.push(
407
+ diagnostic("SETUP_RESOLUTION_FAILED", "error", "settings.json", error instanceof Error ? error.message : String(error)),
408
+ );
409
+ }
410
+ }
411
+ const profiles: Record<string, SetupProfile> = {};
412
+ try {
413
+ for (const [name, profile] of Object.entries(readProfiles(join(this.piHome, "profiles.json"))))
414
+ profiles[name] = { scope: "global", ...profile };
415
+ for (const [name, profile] of Object.entries(readProfiles(join(root, ".pi", "profiles.json"))))
416
+ profiles[name] = { scope: "project", ...profile };
417
+ } catch (error) {
418
+ diagnostics.push(
419
+ diagnostic("SETUP_PROFILE_READ_FAILED", "error", "profiles.json", error instanceof Error ? error.message : String(error)),
420
+ );
421
+ }
422
+ const manifest: SetupManifest = {
423
+ $schema: SETUP_SCHEMA_PATH,
424
+ schemaVersion: 1,
425
+ packages: packages.sort((a, b) => `${a.scope}:${a.source}`.localeCompare(`${b.scope}:${b.source}`)),
426
+ profiles: Object.fromEntries(Object.entries(profiles).sort(([a], [b]) => a.localeCompare(b))),
427
+ };
428
+ try {
429
+ decodeSetupManifest(canonicalSetupManifest(manifest));
430
+ } catch (error) {
431
+ diagnostics.push(diagnostic("SETUP_EXPORT_INVALID", "error", path, error instanceof Error ? error.message : String(error)));
432
+ }
433
+ const schemaPath = join(root, "schema", "pi-setup-v1.schema.json"),
434
+ schemaText = bundledSchemaText();
435
+ if (existsSync(path) && !options.force)
436
+ diagnostics.push(
437
+ diagnostic("SETUP_MANIFEST_EXISTS", "error", SETUP_MANIFEST_FILE, "manifest exists; use --force after reviewing it"),
438
+ );
439
+ if (existsSync(path) && lstatSync(path).isSymbolicLink())
440
+ diagnostics.push(diagnostic("SETUP_PATH_UNSAFE", "error", SETUP_MANIFEST_FILE, "refusing to overwrite a manifest symlink"));
441
+ if (existsSync(schemaPath) && lstatSync(schemaPath).isSymbolicLink())
442
+ diagnostics.push(
443
+ diagnostic("SETUP_PATH_UNSAFE", "error", "schema/pi-setup-v1.schema.json", "refusing to overwrite a schema symlink"),
444
+ );
445
+ else if (existsSync(schemaPath) && readFileSync(schemaPath, "utf8") !== schemaText && !options.force)
446
+ diagnostics.push(
447
+ diagnostic(
448
+ "SETUP_SCHEMA_CONFLICT",
449
+ "error",
450
+ "schema/pi-setup-v1.schema.json",
451
+ "project schema differs; use --force after reviewing it",
452
+ ),
453
+ );
454
+ if (diagnostics.some((item) => item.severity === "error")) return { ok: false, path, manifest, diagnostics, wrote: false };
455
+ atomicText(schemaPath, schemaText, 0o644);
456
+ await atomicJson(path, manifest, 0o644);
457
+ return { ok: true, path, manifest, diagnostics, wrote: true };
458
+ }
459
+
460
+ async update(input: string): Promise<SetupUpdateReport> {
461
+ const path = manifestPath(input);
462
+ let manifest: SetupManifest;
463
+ try {
464
+ manifest = readManifest(path);
465
+ } catch (error) {
466
+ const empty: SetupManifest = { $schema: SETUP_SCHEMA_PATH, schemaVersion: 1, packages: [], profiles: {} };
467
+ return {
468
+ ok: false,
469
+ path,
470
+ manifest: empty,
471
+ diagnostics: [diagnostic("SETUP_MANIFEST_INVALID", "error", path, error instanceof Error ? error.message : String(error))],
472
+ wrote: false,
473
+ updated: 0,
474
+ };
475
+ }
476
+ let updated = 0;
477
+ const packages: SetupPackage[] = [],
478
+ diagnostics: Diagnostic[] = [];
479
+ for (const pkg of manifest.packages) {
480
+ try {
481
+ let next: SetupPackage;
482
+ if (pkg.kind === "local") next = pkg;
483
+ else if (pkg.kind === "npm") {
484
+ const name = npmPackageName(pkg.source)!;
485
+ const info = await this.registry.info(name);
486
+ if (!info.publication?.integrity) throw new Error(`integrity unavailable for ${name}@${info.version}`);
487
+ next = { ...pkg, source: `npm:${name}@${info.version}`, resolved: info.version, integrity: info.publication.integrity };
488
+ } else {
489
+ const resolution = await this.git.resolve(pkg.requested);
490
+ next = { ...pkg, source: resolution.source, resolved: resolution.commit };
491
+ }
492
+ if (canonicalSetupManifest({ ...manifest, packages: [pkg] }) !== canonicalSetupManifest({ ...manifest, packages: [next] }))
493
+ updated++;
494
+ packages.push(next);
495
+ } catch (error) {
496
+ packages.push(pkg);
497
+ diagnostics.push(
498
+ diagnostic("SETUP_UPDATE_RESOLUTION_FAILED", "error", pkg.source, error instanceof Error ? error.message : String(error)),
499
+ );
500
+ }
501
+ }
502
+ const next = { ...manifest, packages };
503
+ if (diagnostics.length) return { ok: false, path, manifest: next, diagnostics, wrote: false, updated };
504
+ await atomicJson(path, next, 0o644);
505
+ return { ok: true, path, manifest: next, diagnostics: [], wrote: true, updated };
506
+ }
507
+
508
+ async plan(input: string, options: { prune?: boolean } = {}): Promise<SetupPlan> {
509
+ const path = manifestPath(input),
510
+ prune = options.prune === true;
511
+ let manifest: SetupManifest;
512
+ try {
513
+ manifest = readManifest(path);
514
+ } catch (error) {
515
+ return {
516
+ ok: false,
517
+ manifestPath: path,
518
+ operations: [],
519
+ prune,
520
+ diagnostics: [diagnostic("SETUP_MANIFEST_INVALID", "error", path, error instanceof Error ? error.message : String(error))],
521
+ };
522
+ }
523
+ const root = dirname(path),
524
+ current = declarations(this.piHome, root),
525
+ operations: SetupOperation[] = [];
526
+ for (const pkg of manifest.packages) {
527
+ const identity = packageIdentity(pkg.source, pkg.scope, pkg.kind === "local" ? pkg.resolved : root);
528
+ const found = current.find((item) => packageIdentity(item.source, item.scope, root) === identity);
529
+ if (!found)
530
+ operations.push({
531
+ kind: "install-package",
532
+ packageName: npmPackageName(pkg.source) ?? splitGitSource(pkg.source).clone,
533
+ scope: pkg.scope,
534
+ source: packageTarget(pkg),
535
+ resolved: pkg.resolved,
536
+ });
537
+ else {
538
+ const installHome = pkg.scope === "global" ? this.piHome : join(root, ".pi");
539
+ const resolved =
540
+ pkg.kind === "npm"
541
+ ? readResolvedVersion(installHome, found.source)
542
+ : pkg.kind === "local"
543
+ ? resolve(pkg.scope === "global" ? this.piHome : root, found.source)
544
+ : splitGitSource(found.source).ref;
545
+ const integrityMismatch = pkg.kind === "npm" && readResolvedIntegrity(installHome, found.source) !== pkg.integrity;
546
+ if (resolved !== pkg.resolved || integrityMismatch)
547
+ operations.push({
548
+ kind: "update-package",
549
+ packageName: npmPackageName(pkg.source) ?? splitGitSource(pkg.source).clone,
550
+ scope: pkg.scope,
551
+ source: packageTarget(pkg),
552
+ resolved: pkg.resolved,
553
+ });
554
+ }
555
+ }
556
+ if (prune) {
557
+ const desired = new Set(
558
+ manifest.packages.map((pkg) => packageIdentity(pkg.source, pkg.scope, pkg.kind === "local" ? pkg.resolved : root)),
559
+ );
560
+ for (const item of current)
561
+ if (!desired.has(packageIdentity(item.source, item.scope, root)))
562
+ operations.push({
563
+ kind: "remove-package",
564
+ packageName: npmPackageName(item.source) ?? item.source,
565
+ scope: item.scope,
566
+ source: item.source,
567
+ });
568
+ }
569
+ let globals: Record<string, Omit<SetupProfile, "scope">>, projects: Record<string, Omit<SetupProfile, "scope">>;
570
+ try {
571
+ globals = readProfiles(join(this.piHome, "profiles.json"));
572
+ projects = readProfiles(join(root, ".pi", "profiles.json"));
573
+ } catch (error) {
574
+ return {
575
+ ok: false,
576
+ manifestPath: path,
577
+ operations: [],
578
+ prune,
579
+ diagnostics: [
580
+ diagnostic("SETUP_PROFILE_READ_FAILED", "error", "profiles.json", error instanceof Error ? error.message : String(error)),
581
+ ],
582
+ };
583
+ }
584
+ for (const [name, scoped] of Object.entries(manifest.profiles).sort(([a], [b]) => a.localeCompare(b))) {
585
+ const { scope, ...profile } = scoped,
586
+ existing = scope === "global" ? globals[name] : projects[name];
587
+ if (JSON.stringify(stableValue(existing)) !== JSON.stringify(stableValue(profile)))
588
+ operations.push({ kind: "write-profile", name, scope, fields: Object.keys(profile).sort() });
589
+ }
590
+ if (prune)
591
+ for (const [scope, profiles] of [
592
+ ["global", globals],
593
+ ["project", projects],
594
+ ] as const)
595
+ for (const name of Object.keys(profiles).sort())
596
+ if (!manifest.profiles[name] || manifest.profiles[name].scope !== scope)
597
+ operations.push({ kind: "remove-profile", name, scope, fields: [] });
598
+ const order: Record<SetupOperation["kind"], number> = {
599
+ "install-package": 0,
600
+ "update-package": 0,
601
+ "write-profile": 1,
602
+ "remove-profile": 2,
603
+ "remove-package": 3,
604
+ };
605
+ operations.sort((a, b) => order[a.kind] - order[b.kind] || JSON.stringify(a).localeCompare(JSON.stringify(b)));
606
+ return {
607
+ ok: true,
608
+ manifestPath: path,
609
+ operations,
610
+ prune,
611
+ manifestSha256: createHash("sha256").update(canonicalSetupManifest(manifest)).digest("hex"),
612
+ diagnostics: [],
613
+ };
614
+ }
615
+
616
+ async apply(input: string, options: { prune?: boolean } = {}): Promise<SetupApplyResult> {
617
+ const plan = await this.plan(input, options);
618
+ if (!plan.ok)
619
+ return { ok: false, manifestPath: plan.manifestPath, operations: [], reloadRequired: false, diagnostics: plan.diagnostics };
620
+ let manifest: SetupManifest;
621
+ try {
622
+ manifest = readManifest(plan.manifestPath);
623
+ } catch (error) {
624
+ return {
625
+ ok: false,
626
+ manifestPath: plan.manifestPath,
627
+ operations: [],
628
+ reloadRequired: false,
629
+ diagnostics: [
630
+ diagnostic("SETUP_MANIFEST_INVALID", "error", plan.manifestPath, error instanceof Error ? error.message : String(error)),
631
+ ],
632
+ };
633
+ }
634
+ if (createHash("sha256").update(canonicalSetupManifest(manifest)).digest("hex") !== plan.manifestSha256)
635
+ return {
636
+ ok: false,
637
+ manifestPath: plan.manifestPath,
638
+ operations: [],
639
+ reloadRequired: false,
640
+ diagnostics: [diagnostic("SETUP_MANIFEST_CHANGED", "error", plan.manifestPath, "manifest changed after planning")],
641
+ };
642
+ const outcomes: SetupApplyResult["operations"] = [],
643
+ packageChanges = plan.operations.filter(
644
+ (item): item is Extract<SetupOperation, { kind: "install-package" | "update-package" }> =>
645
+ item.kind === "install-package" || item.kind === "update-package",
646
+ );
647
+ for (const operation of packageChanges) {
648
+ try {
649
+ const output = await this.installer.install(operation.source, { local: operation.scope === "project" });
650
+ outcomes.push({
651
+ kind: operation.kind,
652
+ target: operation.source,
653
+ status: "succeeded",
654
+ output: output.slice(0, MAX_OPERATION_OUTPUT),
655
+ });
656
+ } catch (error) {
657
+ outcomes.push({
658
+ kind: operation.kind,
659
+ target: operation.source,
660
+ status: "failed",
661
+ output: (error instanceof Error ? error.message : String(error)).slice(0, MAX_OPERATION_OUTPUT),
662
+ });
663
+ return {
664
+ ok: false,
665
+ manifestPath: plan.manifestPath,
666
+ operations: outcomes,
667
+ reloadRequired: outcomes.some((item) => item.status === "succeeded"),
668
+ diagnostics: [
669
+ diagnostic(
670
+ "SETUP_APPLY_FAILED",
671
+ "error",
672
+ operation.packageName,
673
+ "package operation failed; profile writes and removals were not started",
674
+ ),
675
+ ],
676
+ };
677
+ }
678
+ }
679
+ const root = dirname(plan.manifestPath);
680
+ for (const scope of ["global", "project"] as const) {
681
+ const changes = plan.operations.filter(
682
+ (item): item is Extract<SetupOperation, { kind: "write-profile" | "remove-profile" }> =>
683
+ (item.kind === "write-profile" || item.kind === "remove-profile") && item.scope === scope,
684
+ );
685
+ if (!changes.length) continue;
686
+ const path = scope === "global" ? join(this.piHome, "profiles.json") : join(root, ".pi", "profiles.json");
687
+ try {
688
+ const profiles = readProfiles(path);
689
+ for (const operation of changes) {
690
+ if (operation.kind === "remove-profile") delete profiles[operation.name];
691
+ else {
692
+ const scoped = manifest.profiles[operation.name];
693
+ if (!scoped || scoped.scope !== scope) throw new Error(`profile ${operation.name} changed after planning`);
694
+ const { scope: _scope, ...profile } = scoped;
695
+ profiles[operation.name] = profile;
696
+ }
697
+ }
698
+ await atomicJson(path, profiles, 0o600);
699
+ for (const operation of changes) outcomes.push({ kind: operation.kind, target: `${scope}:${operation.name}`, status: "succeeded" });
700
+ } catch (error) {
701
+ return {
702
+ ok: false,
703
+ manifestPath: plan.manifestPath,
704
+ operations: outcomes,
705
+ reloadRequired: packageChanges.length > 0,
706
+ diagnostics: [diagnostic("SETUP_PROFILE_WRITE_FAILED", "error", path, error instanceof Error ? error.message : String(error))],
707
+ };
708
+ }
709
+ }
710
+ for (const operation of plan.operations.filter(
711
+ (item): item is Extract<SetupOperation, { kind: "remove-package" }> => item.kind === "remove-package",
712
+ )) {
713
+ try {
714
+ const outcome = await this.installer.remove(operation.source, { local: operation.scope === "project" });
715
+ outcomes.push({
716
+ kind: operation.kind,
717
+ target: operation.source,
718
+ status: "succeeded",
719
+ output: outcome.slice(0, MAX_OPERATION_OUTPUT),
720
+ });
721
+ } catch (error) {
722
+ outcomes.push({
723
+ kind: operation.kind,
724
+ target: operation.source,
725
+ status: "failed",
726
+ output: (error instanceof Error ? error.message : String(error)).slice(0, MAX_OPERATION_OUTPUT),
727
+ });
728
+ return {
729
+ ok: false,
730
+ manifestPath: plan.manifestPath,
731
+ operations: outcomes,
732
+ reloadRequired: true,
733
+ diagnostics: [
734
+ diagnostic(
735
+ "SETUP_PRUNE_FAILED",
736
+ "error",
737
+ operation.source,
738
+ "package removal failed after desired packages and profiles were applied",
739
+ ),
740
+ ],
741
+ };
742
+ }
743
+ }
744
+ return {
745
+ ok: true,
746
+ manifestPath: plan.manifestPath,
747
+ operations: outcomes,
748
+ reloadRequired: outcomes.some((item) => item.kind.includes("package")),
749
+ diagnostics: [],
750
+ };
751
+ }
752
+ }
753
+
754
+ export function formatSetupReport(report: SetupExportReport | SetupUpdateReport | SetupPlan | SetupApplyResult, json = false): string {
755
+ if (json) return `${JSON.stringify(report)}\n`;
756
+ if ("manifest" in report)
757
+ return `${"updated" in report ? "updated" : report.ok ? "exported" : "not exported"}: ${report.path}\n${report.diagnostics.map((item) => `${item.severity} ${item.code}: ${item.message}`).join("\n")}${report.diagnostics.length ? "\n" : ""}`;
758
+ if ("reloadRequired" in report)
759
+ return `${report.ok ? "applied" : "failed"}: ${report.operations.length} operation(s)${report.reloadRequired ? "; reload required" : ""}\n${report.diagnostics.map((item) => `${item.severity} ${item.code}: ${item.message}`).join("\n")}${report.diagnostics.length ? "\n" : ""}`;
760
+ return `${report.ok ? "plan" : "invalid"}: ${report.operations.length} operation(s)${report.prune ? "; prune" : ""}\n${report.operations.map((item) => ` ${item.kind} ${"packageName" in item ? item.packageName : `${item.scope}:${item.name}`}`).join("\n")}${report.operations.length ? "\n" : ""}`;
761
+ }