@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,664 @@
1
+ /**
2
+ * service.ts — the HTTP entry point into the daemon, as a pure Web Standard
3
+ * handler: (Request) → Response. Bun.serve wraps it for the network;
4
+ * tests call it in-process. Same port, two adapters — Cockburn's symmetry.
5
+ */
6
+
7
+ import { existsSync as fileExistsSync } from "node:fs";
8
+ import { errorResponse, healthResponse, jsonResponse, readyResponse, requireBearerToken } from "@danypops/vehicle-server/rpc-http";
9
+ import type { ServiceSpec } from "@danypops/vehicle-server/service";
10
+ import { type AdvisoryReport, resolveInstalledVersions, scanInstalledPackages } from "../adoption/advisories.ts";
11
+ import { type CheckReport, type PackageChecker, StaticPackageChecker } from "../adoption/check.ts";
12
+ import { type DoctorReport, runDoctor } from "../adoption/doctor.ts";
13
+ import { NpmPackVerifier, type PackReport } from "../adoption/pack.ts";
14
+ import { type AdoptionReport, scoreTarget } from "../adoption/score.ts";
15
+ import { buildIndex, indexPath, type PackageIndex, readIndex, writeIndex } from "../index/build-index.ts";
16
+ import { syncCatalog } from "../packages/catalog.ts";
17
+ import { catalogList, dbPath, getSyncMeta, latestVersion, openDb, searchLocal } from "../packages/db.ts";
18
+ import { defaultPiHome, readInstalledPackages, readInstalledPackagesAcrossScopes } from "../packages/installed.ts";
19
+ import type { InstalledPkg, Installer, Pkg, PkgInfo, Registry, SearchPage, UpdateOutcome, UpdatesSnapshot } from "../packages/package.ts";
20
+ import { buildSearchQuery, clampLimit } from "../packages/package.ts";
21
+ import {
22
+ listPackageResources,
23
+ type PackageResources,
24
+ RESOURCE_FIELDS,
25
+ type ResourceField,
26
+ resolveInstalledDir,
27
+ resolveToggleSettingsPath,
28
+ toggleResource,
29
+ } from "../packages/resources.ts";
30
+ import { checkPiVersion, type PiVersionReport } from "../pi/pi-version.ts";
31
+ import {
32
+ assertPackagePermission,
33
+ type MutationApproval,
34
+ PackageApprovalRequiredError,
35
+ type PackageOperation,
36
+ readSecuritySettings,
37
+ writeSecuritySettings,
38
+ } from "../security/security.ts";
39
+ import { type SetupApplyResult, type SetupExportReport, SetupManager, type SetupPlan, type SetupUpdateReport } from "../setup/setup.ts";
40
+ import { TTLCache } from "../shared/cache.ts";
41
+ import { SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT } from "../shared/constants.ts";
42
+ import { createLogger } from "../shared/log.ts";
43
+ import { VERSION } from "../shared/version.ts";
44
+ import { formatCleanupSummary, runCleanup } from "./cleanup.ts";
45
+ import { type DaemonServiceInstaller, RealDaemonServiceInstaller } from "./daemon-service.ts";
46
+ import { checkUpdates, loadUpdates } from "./watcher.ts";
47
+
48
+ const log = createLogger("service");
49
+
50
+ export interface Deps {
51
+ reg: Registry;
52
+ inst: Installer;
53
+ token: string;
54
+ stateDir: string;
55
+ dataDir?: string;
56
+ piHome?: string;
57
+ cache?: TTLCache;
58
+ checker?: PackageChecker;
59
+ packer?: { verify(path: string): Promise<PackReport> };
60
+ scorer?: { score(target: string): Promise<AdoptionReport> };
61
+ setup?: {
62
+ export(projectRoot: string, options?: { force?: boolean; machineLocal?: boolean }): Promise<SetupExportReport>;
63
+ update(manifestPath: string): Promise<SetupUpdateReport>;
64
+ plan(manifestPath: string, options?: { prune?: boolean }): Promise<SetupPlan>;
65
+ apply(manifestPath: string, options?: { prune?: boolean }): Promise<SetupApplyResult>;
66
+ };
67
+ daemonServiceInstaller?: DaemonServiceInstaller;
68
+ piVersion?: { check(options?: { timeoutMs?: number }): Promise<PiVersionReport> };
69
+ advisories?: { scan(installed: Record<string, string>): Promise<AdvisoryReport> };
70
+ }
71
+
72
+ export type OperationName =
73
+ | "package.search"
74
+ | "package.info"
75
+ | "package.installed"
76
+ | "package.catalog"
77
+ | "package.catalog.sync"
78
+ | "package.index"
79
+ | "package.index.build"
80
+ | "package.updates"
81
+ | "package.check"
82
+ | "package.pack"
83
+ | "package.score"
84
+ | "setup.export"
85
+ | "setup.update"
86
+ | "setup.plan"
87
+ | "setup.apply"
88
+ | "package.security.get"
89
+ | "package.security.set"
90
+ | "package.install"
91
+ | "package.install_service"
92
+ | "package.restart_service"
93
+ | "package.remove"
94
+ | "package.update"
95
+ | "resources.list"
96
+ | "resources.toggle"
97
+ | "pi.status"
98
+ | "advisories.scan"
99
+ | "doctor.run"
100
+ | "package.updates.project";
101
+
102
+ export interface OperationInputs {
103
+ "package.search": { query: string; limit: number; offline?: boolean };
104
+ "package.info": { name: string };
105
+ "package.installed": Record<string, never>;
106
+ "package.catalog": Record<string, never>;
107
+ "package.catalog.sync": Record<string, never>;
108
+ "package.index": Record<string, never>;
109
+ "package.index.build": Record<string, never>;
110
+ "package.updates": Record<string, never>;
111
+ "package.check": { path: string; smoke?: boolean };
112
+ "package.pack": { path: string };
113
+ "package.score": { target: string };
114
+ "setup.export": { projectRoot: string; force?: boolean; machineLocal?: boolean };
115
+ "setup.update": { manifestPath: string };
116
+ "setup.plan": { manifestPath: string; prune?: boolean };
117
+ "setup.apply": { manifestPath: string; approved?: boolean; prune?: boolean };
118
+ "package.security.get": Record<string, never>;
119
+ "package.security.set": { mutationApproval: MutationApproval; approved?: boolean };
120
+ "package.install": { source: string; approved?: boolean };
121
+ "package.install_service": { source: string; approved?: boolean };
122
+ "package.restart_service": { source: string; approved?: boolean };
123
+ "package.remove": { name: string; approved?: boolean };
124
+ "package.update": { source: string; approved?: boolean };
125
+ "resources.list": { projectRoot?: string };
126
+ "resources.toggle": { source: string; field: ResourceField; path: string; enabled: boolean; projectRoot?: string; approved?: boolean };
127
+ "pi.status": Record<string, never>;
128
+ "advisories.scan": { name?: string };
129
+ "doctor.run": { projectRoot?: string };
130
+ "package.updates.project": { projectRoot: string };
131
+ }
132
+
133
+ interface MutationResponse {
134
+ ok: boolean;
135
+ output: string;
136
+ }
137
+ interface UpdateMutationResponse extends MutationResponse, Partial<Omit<UpdateOutcome, "output">> {}
138
+ interface InstallServiceResponse {
139
+ ok: boolean;
140
+ output: string;
141
+ spec?: Pick<ServiceSpec, "name" | "binPath" | "descriptorPath">;
142
+ notADaemon?: boolean;
143
+ }
144
+ interface RestartServiceResponse extends InstallServiceResponse {
145
+ restarted?: boolean;
146
+ }
147
+
148
+ export interface OperationOutputs {
149
+ "package.search": { query: string; total: number; results: SearchPage["results"]; offline?: boolean };
150
+ "package.info": PkgInfo;
151
+ "package.installed": InstalledPkg[];
152
+ "package.catalog": { fetchedAt?: string; sha256?: string; packages: Pkg[] };
153
+ "package.catalog.sync": { synced: number };
154
+ "package.index": PackageIndex | undefined;
155
+ "package.index.build": PackageIndex;
156
+ "package.updates": UpdatesSnapshot;
157
+ "package.check": CheckReport;
158
+ "package.pack": PackReport;
159
+ "package.score": AdoptionReport;
160
+ "setup.export": SetupExportReport;
161
+ "setup.update": SetupUpdateReport;
162
+ "setup.plan": SetupPlan;
163
+ "setup.apply": SetupApplyResult;
164
+ "package.security.get": { mutationApproval: MutationApproval };
165
+ "package.security.set": { mutationApproval: MutationApproval };
166
+ "package.install": MutationResponse;
167
+ "package.install_service": InstallServiceResponse;
168
+ "package.restart_service": RestartServiceResponse;
169
+ "package.remove": MutationResponse;
170
+ "package.update": UpdateMutationResponse;
171
+ "resources.list": { global: PackageResources[]; project: PackageResources[] };
172
+ "resources.toggle": MutationResponse;
173
+ "pi.status": PiVersionReport;
174
+ "advisories.scan": AdvisoryReport;
175
+ "doctor.run": DoctorReport;
176
+ "package.updates.project": UpdatesSnapshot;
177
+ }
178
+
179
+ export const OPERATION_NAMES: readonly OperationName[] = [
180
+ "package.search",
181
+ "package.info",
182
+ "package.installed",
183
+ "package.catalog",
184
+ "package.catalog.sync",
185
+ "package.index",
186
+ "package.index.build",
187
+ "package.updates",
188
+ "package.check",
189
+ "package.pack",
190
+ "package.score",
191
+ "setup.export",
192
+ "setup.update",
193
+ "setup.plan",
194
+ "setup.apply",
195
+ "package.security.get",
196
+ "package.security.set",
197
+ "package.install",
198
+ "package.install_service",
199
+ "package.restart_service",
200
+ "package.remove",
201
+ "package.update",
202
+ "resources.list",
203
+ "resources.toggle",
204
+ "pi.status",
205
+ "advisories.scan",
206
+ "doctor.run",
207
+ "package.updates.project",
208
+ ];
209
+
210
+ class PackageOperationError extends Error {
211
+ constructor(
212
+ message: string,
213
+ readonly status: number,
214
+ ) {
215
+ super(message);
216
+ }
217
+ }
218
+
219
+ const SOURCE_RE = /^(npm:[A-Za-z0-9@._/-]+|git:[A-Za-z0-9@:._/-]+|https:\/\/[A-Za-z0-9@:._/?=&%~-]+)$/;
220
+ const NAME_RE = /^(@[A-Za-z0-9._-]+\/)?[A-Za-z0-9._-]+$/;
221
+
222
+ function json(v: unknown, init?: ResponseInit): Response {
223
+ return jsonResponse(v, init);
224
+ }
225
+
226
+ function err(status: number, msg: string, details: Record<string, unknown> = {}): Response {
227
+ return jsonResponse({ error: msg, ...details }, { status });
228
+ }
229
+
230
+ function pickSpec(spec: ServiceSpec): Pick<ServiceSpec, "name" | "binPath" | "descriptorPath"> {
231
+ return { name: spec.name, binPath: spec.binPath, descriptorPath: spec.descriptorPath };
232
+ }
233
+
234
+ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Response> } {
235
+ const cache = deps.cache ?? new TTLCache();
236
+ const dataDir = deps.dataDir ?? deps.stateDir;
237
+ const checker = deps.checker ?? new StaticPackageChecker();
238
+ const packer = deps.packer ?? new NpmPackVerifier();
239
+ const setup = deps.setup ?? new SetupManager(deps.reg, deps.inst, deps.piHome ?? defaultPiHome());
240
+ const daemonServiceInstaller = deps.daemonServiceInstaller ?? new RealDaemonServiceInstaller();
241
+ const piHomeForServiceInstall = deps.piHome ?? defaultPiHome();
242
+
243
+ function authorize(operation: PackageOperation, approved: boolean): Response | undefined {
244
+ try {
245
+ assertPackagePermission(readSecuritySettings(deps.stateDir), operation, approved);
246
+ return undefined;
247
+ } catch (error) {
248
+ if (error instanceof PackageApprovalRequiredError) {
249
+ return err(403, error.message, { code: error.code, operation: error.operation });
250
+ }
251
+ throw error;
252
+ }
253
+ }
254
+
255
+ async function route(req: Request): Promise<Response> {
256
+ const url = new URL(req.url);
257
+ const path = url.pathname;
258
+
259
+ if (path === "/health" && req.method === "GET") return healthResponse(VERSION);
260
+ if (path === "/ready" && req.method === "GET") return readyResponse(true);
261
+
262
+ if (path === "/security" && req.method === "GET") {
263
+ return json(readSecuritySettings(deps.stateDir));
264
+ }
265
+
266
+ if (path === "/security" && req.method === "POST") {
267
+ let body: { mutationApproval?: unknown; approved?: unknown };
268
+ try {
269
+ body = (await req.json()) as typeof body;
270
+ } catch {
271
+ return err(400, "invalid security settings JSON");
272
+ }
273
+ if (body.mutationApproval !== "always" && body.mutationApproval !== "never") {
274
+ return err(400, "mutationApproval must be always or never");
275
+ }
276
+ const denied = authorize("security.write", body.approved === true);
277
+ if (denied) return denied;
278
+ return json(await writeSecuritySettings(deps.stateDir, { mutationApproval: body.mutationApproval as MutationApproval }));
279
+ }
280
+
281
+ if (path === "/search" && req.method === "GET") {
282
+ const q = url.searchParams.get("q") ?? "";
283
+ const limit = clampLimit(Number(url.searchParams.get("limit")), SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT);
284
+ // offline=1: serve from the SQLite mirror (apt-cache search analog)
285
+ if (url.searchParams.get("offline") === "1") {
286
+ const db = openDb(dbPath(dataDir));
287
+ try {
288
+ const results = searchLocal(db, q, limit);
289
+ return json({ query: q, total: results.length, results, offline: true });
290
+ } finally {
291
+ db.close();
292
+ }
293
+ }
294
+ try {
295
+ const { results, total } = await deps.reg.search(buildSearchQuery(q), limit);
296
+ return json({ query: q, total, results });
297
+ } catch (e) {
298
+ return err(502, e instanceof Error ? e.message : String(e));
299
+ }
300
+ }
301
+
302
+ if (path === "/info" && req.method === "GET") {
303
+ const name = url.searchParams.get("name") ?? "";
304
+ if (!name) return err(400, "missing name");
305
+ try {
306
+ return json(await deps.reg.info(name));
307
+ } catch (e) {
308
+ return err(502, e instanceof Error ? e.message : String(e));
309
+ }
310
+ }
311
+
312
+ if (path === "/installed" && req.method === "GET") {
313
+ return json(readInstalledPackages(deps.piHome ?? defaultPiHome()));
314
+ }
315
+
316
+ if (path === "/remove" && req.method === "POST") {
317
+ let name = "";
318
+ let approved = false;
319
+ try {
320
+ const body = (await req.json()) as { name?: unknown; approved?: unknown };
321
+ name = String(body.name ?? "");
322
+ approved = body.approved === true;
323
+ } catch {
324
+ /* fall through to validation */
325
+ }
326
+ if (!NAME_RE.test(name)) {
327
+ return err(400, "invalid name; want a bare npm package name");
328
+ }
329
+ const denied = authorize("remove", approved);
330
+ if (denied) return denied;
331
+ // pi.cleanup is read and applied before delegating to pi remove --
332
+ // once pi remove finishes, an npm-sourced package's own directory
333
+ // (and its manifest) may already be gone.
334
+ const installedDir = resolveInstalledDir(deps.piHome ?? defaultPiHome(), `npm:${name}`);
335
+ const cleanup = installedDir ? runCleanup(installedDir) : [];
336
+ try {
337
+ const output = await deps.inst.remove(`npm:${name}`, { approved });
338
+ return json({ ok: true, name, output: output + formatCleanupSummary(cleanup) });
339
+ } catch (e) {
340
+ const message = e instanceof Error ? e.message : String(e);
341
+ return json({ ok: false, name, output: message + formatCleanupSummary(cleanup) });
342
+ }
343
+ }
344
+
345
+ if (path === "/install" && req.method === "POST") {
346
+ let source = "";
347
+ let approved = false;
348
+ try {
349
+ const body = (await req.json()) as { source?: unknown; approved?: unknown };
350
+ source = String(body.source ?? "");
351
+ approved = body.approved === true;
352
+ } catch {
353
+ /* fall through to validation */
354
+ }
355
+ if (!SOURCE_RE.test(source)) {
356
+ return err(400, "invalid source; want npm:<pkg>[@ver], git:<host>/<owner>/<repo>[@ref], or https://…");
357
+ }
358
+ const denied = authorize("install", approved);
359
+ if (denied) return denied;
360
+ try {
361
+ const output = await deps.inst.install(source, { approved });
362
+ return json({ ok: true, source, output });
363
+ } catch (e) {
364
+ return json({ ok: false, source, output: e instanceof Error ? e.message : String(e) });
365
+ }
366
+ }
367
+
368
+ if (path === "/install-service" && req.method === "POST") {
369
+ let source = "";
370
+ let approved = false;
371
+ try {
372
+ const body = (await req.json()) as { source?: unknown; approved?: unknown };
373
+ source = String(body.source ?? "");
374
+ approved = body.approved === true;
375
+ } catch {
376
+ /* fall through to validation */
377
+ }
378
+ if (!SOURCE_RE.test(source)) {
379
+ return err(400, "invalid source; want npm:<pkg>[@ver] -- daemon-service installation only supports npm sources today");
380
+ }
381
+ const denied = authorize("install_service", approved);
382
+ if (denied) return denied;
383
+ const resolved = daemonServiceInstaller.install(piHomeForServiceInstall, source);
384
+ if (!resolved.ok) return json({ ok: false, output: resolved.reason, notADaemon: resolved.notADaemon });
385
+ if (!resolved.result.installed) return json({ ok: false, output: resolved.result.reason, spec: pickSpec(resolved.spec) });
386
+ return json({ ok: true, output: `installed a persistent service for ${resolved.spec.name}`, spec: pickSpec(resolved.spec) });
387
+ }
388
+
389
+ if (path === "/restart-service" && req.method === "POST") {
390
+ let source = "";
391
+ let approved = false;
392
+ try {
393
+ const body = (await req.json()) as { source?: unknown; approved?: unknown };
394
+ source = String(body.source ?? "");
395
+ approved = body.approved === true;
396
+ } catch {
397
+ /* fall through to validation */
398
+ }
399
+ if (!SOURCE_RE.test(source)) {
400
+ return err(400, "invalid source; want npm:<pkg>[@ver] -- daemon-service restart only supports npm sources today");
401
+ }
402
+ const denied = authorize("restart_service", approved);
403
+ if (denied) return denied;
404
+ const resolved = daemonServiceInstaller.restart(piHomeForServiceInstall, source);
405
+ if (!resolved.ok) return json({ ok: false, output: resolved.reason, notADaemon: resolved.notADaemon });
406
+ const output = resolved.restarted
407
+ ? `restarted the persistent service for ${resolved.spec.name}`
408
+ : (resolved.reason ?? `no restart needed for ${resolved.spec.name}`);
409
+ return json({ ok: true, output, restarted: resolved.restarted, spec: pickSpec(resolved.spec) });
410
+ }
411
+
412
+ if (path === "/update" && req.method === "POST") {
413
+ let source = "";
414
+ let approved = false;
415
+ try {
416
+ const body = (await req.json()) as { source?: unknown; approved?: unknown };
417
+ source = String(body.source ?? "");
418
+ approved = body.approved === true;
419
+ } catch {
420
+ /* fall through to validation */
421
+ }
422
+ if (!SOURCE_RE.test(source)) {
423
+ return err(400, "invalid source; want a configured npm:, git:, or https package source");
424
+ }
425
+ const denied = authorize("update", approved);
426
+ if (denied) return denied;
427
+ try {
428
+ const outcome = await deps.inst.update(source, { approved });
429
+ return json({ ok: true, source, ...outcome });
430
+ } catch (error) {
431
+ return json({ ok: false, source, output: error instanceof Error ? error.message : String(error), reloadRequired: false });
432
+ }
433
+ }
434
+
435
+ if (path === "/updates" && req.method === "GET") {
436
+ const snap = await loadUpdates(deps.stateDir);
437
+ return json(snap ?? { updates: [] });
438
+ }
439
+
440
+ if (path === "/catalog" && req.method === "GET") {
441
+ const db = openDb(dbPath(dataDir));
442
+ try {
443
+ const meta = getSyncMeta(db);
444
+ return json({ fetchedAt: meta?.fetchedAt, sha256: meta?.sha256, packages: catalogList(db) });
445
+ } finally {
446
+ db.close();
447
+ }
448
+ }
449
+
450
+ return err(404, "not found");
451
+ }
452
+
453
+ async function executeOperation<Name extends OperationName>(op: Name, input: OperationInputs[Name]): Promise<OperationOutputs[Name]> {
454
+ if (op === "package.catalog.sync") return { synced: await syncCatalog(deps.reg, dataDir) } as OperationOutputs[Name];
455
+ if (op === "package.index") return readIndex(indexPath(dataDir)) as OperationOutputs[Name];
456
+ if (op === "package.index.build") {
457
+ const index = await buildIndex(deps.reg, dataDir);
458
+ await writeIndex(indexPath(dataDir), index);
459
+ return index as OperationOutputs[Name];
460
+ }
461
+ if (op === "package.check" || op === "package.pack") {
462
+ const packagePath = (input as OperationInputs["package.check"] | OperationInputs["package.pack"]).path;
463
+ if (typeof packagePath !== "string" || packagePath.length === 0 || packagePath.length > 4_096)
464
+ throw new PackageOperationError("path must be a non-empty string up to 4096 characters", 400);
465
+ if (op === "package.pack") return (await packer.verify(packagePath)) as OperationOutputs[Name];
466
+ return (await checker.check(packagePath, {
467
+ smoke: (input as OperationInputs["package.check"]).smoke === true,
468
+ })) as OperationOutputs[Name];
469
+ }
470
+ if (op === "package.score") {
471
+ const target = (input as OperationInputs["package.score"]).target;
472
+ if (typeof target !== "string" || target.length === 0 || target.length > 4_096)
473
+ throw new PackageOperationError("target must be a non-empty string up to 4096 characters", 400);
474
+ return (deps.scorer ? await deps.scorer.score(target) : await scoreTarget(target, deps.reg, packer)) as OperationOutputs[Name];
475
+ }
476
+ if (op === "setup.export") {
477
+ const value = input as OperationInputs["setup.export"];
478
+ if (typeof value.projectRoot !== "string" || value.projectRoot.length === 0 || value.projectRoot.length > 4_096)
479
+ throw new PackageOperationError("projectRoot must be a non-empty string up to 4096 characters", 400);
480
+ return (await setup.export(value.projectRoot, {
481
+ force: value.force === true,
482
+ machineLocal: value.machineLocal === true,
483
+ })) as OperationOutputs[Name];
484
+ }
485
+ if (op === "setup.update" || op === "setup.plan" || op === "setup.apply") {
486
+ const value = input as OperationInputs["setup.update"] | OperationInputs["setup.plan"] | OperationInputs["setup.apply"];
487
+ if (typeof value.manifestPath !== "string" || value.manifestPath.length === 0 || value.manifestPath.length > 4_096)
488
+ throw new PackageOperationError("manifestPath must be a non-empty string up to 4096 characters", 400);
489
+ if (op === "setup.update") return (await setup.update(value.manifestPath)) as OperationOutputs[Name];
490
+ if (op === "setup.apply") {
491
+ const applyInput = input as OperationInputs["setup.apply"];
492
+ const denied = authorize("setup.apply", applyInput.approved === true);
493
+ if (denied) throw new PackageOperationError(((await denied.json()) as { error: string }).error, denied.status);
494
+ return (await setup.apply(value.manifestPath, { prune: applyInput.prune === true })) as OperationOutputs[Name];
495
+ }
496
+ return (await setup.plan(value.manifestPath, {
497
+ prune: (input as OperationInputs["setup.plan"]).prune === true,
498
+ })) as OperationOutputs[Name];
499
+ }
500
+ if (op === "pi.status") {
501
+ return (deps.piVersion ? await deps.piVersion.check() : await checkPiVersion()) as OperationOutputs[Name];
502
+ }
503
+ if (op === "advisories.scan") {
504
+ const value = input as OperationInputs["advisories.scan"];
505
+ if (value.name !== undefined && (typeof value.name !== "string" || value.name.length === 0 || value.name.length > 214))
506
+ throw new PackageOperationError("name must be a non-empty string up to 214 characters", 400);
507
+ const installed = resolveInstalledVersions(deps.piHome ?? defaultPiHome(), value.name);
508
+ const scan = deps.advisories?.scan ?? scanInstalledPackages;
509
+ return (await scan(installed)) as OperationOutputs[Name];
510
+ }
511
+ if (op === "resources.list") {
512
+ const value = input as OperationInputs["resources.list"];
513
+ if (value.projectRoot !== undefined && (typeof value.projectRoot !== "string" || value.projectRoot.length > 4_096))
514
+ throw new PackageOperationError("projectRoot must be a string up to 4096 characters", 400);
515
+ return listPackageResources(deps.piHome ?? defaultPiHome(), value.projectRoot) as OperationOutputs[Name];
516
+ }
517
+ if (op === "doctor.run") {
518
+ const value = input as OperationInputs["doctor.run"];
519
+ if (value.projectRoot !== undefined && (typeof value.projectRoot !== "string" || value.projectRoot.length > 4_096))
520
+ throw new PackageOperationError("projectRoot must be a string up to 4096 characters", 400);
521
+ return (await runDoctor(deps.piHome ?? defaultPiHome(), value.projectRoot)) as OperationOutputs[Name];
522
+ }
523
+ if (op === "package.updates.project") {
524
+ const value = input as OperationInputs["package.updates.project"];
525
+ if (typeof value.projectRoot !== "string" || value.projectRoot.length === 0 || value.projectRoot.length > 4_096)
526
+ throw new PackageOperationError("projectRoot must be a non-empty string up to 4096 characters", 400);
527
+ // Live, on-demand, cross-scope -- distinct from package.updates' own persisted,
528
+ // global-only background snapshot (startWatcher), which has no project context.
529
+ const db = openDb(dbPath(dataDir));
530
+ try {
531
+ const installed = readInstalledPackagesAcrossScopes(deps.piHome ?? defaultPiHome(), value.projectRoot);
532
+ const updates = checkUpdates((name) => latestVersion(db, name), installed);
533
+ return { checkedAt: new Date().toISOString(), updates } as OperationOutputs[Name];
534
+ } finally {
535
+ db.close();
536
+ }
537
+ }
538
+ if (op === "resources.toggle") {
539
+ const value = input as OperationInputs["resources.toggle"];
540
+ if (typeof value.source !== "string" || value.source.length === 0 || value.source.length > 4_096)
541
+ throw new PackageOperationError("source must be a non-empty string up to 4096 characters", 400);
542
+ if (!RESOURCE_FIELDS.includes(value.field))
543
+ throw new PackageOperationError("field must be one of extensions, skills, prompts, themes", 400);
544
+ if (typeof value.path !== "string" || value.path.length === 0 || value.path.length > 4_096 || value.path.includes(".."))
545
+ throw new PackageOperationError("path must be a non-empty, non-escaping relative path up to 4096 characters", 400);
546
+ const denied = authorize("resources.toggle", value.approved === true);
547
+ if (denied) throw new PackageOperationError(((await denied.json()) as { error: string }).error, denied.status);
548
+ const piHome = deps.piHome ?? defaultPiHome();
549
+ const settingsPath = resolveToggleSettingsPath(piHome, value.projectRoot);
550
+ if (value.projectRoot && !fileExistsSync(settingsPath)) throw new PackageOperationError("no project settings file to toggle", 404);
551
+ const result = await toggleResource({
552
+ settingsPath,
553
+ source: value.source,
554
+ field: value.field,
555
+ path: value.path,
556
+ enabled: value.enabled,
557
+ });
558
+ return {
559
+ ok: result.ok,
560
+ output: result.ok ? `${value.enabled ? "enabled" : "disabled"} ${value.path}` : (result.error ?? "toggle failed"),
561
+ } as OperationOutputs[Name];
562
+ }
563
+ let path: string;
564
+ let init: RequestInit = {};
565
+ switch (op) {
566
+ case "package.search": {
567
+ const value = input as OperationInputs["package.search"];
568
+ const params = new URLSearchParams({ q: value.query, limit: String(value.limit) });
569
+ if (value.offline) params.set("offline", "1");
570
+ path = `/search?${params}`;
571
+ break;
572
+ }
573
+ case "package.info":
574
+ path = `/info?name=${encodeURIComponent((input as OperationInputs["package.info"]).name)}`;
575
+ break;
576
+ case "package.installed":
577
+ path = "/installed";
578
+ break;
579
+ case "package.catalog":
580
+ path = "/catalog";
581
+ break;
582
+ case "package.updates":
583
+ path = "/updates";
584
+ break;
585
+ case "package.security.get":
586
+ path = "/security";
587
+ break;
588
+ case "package.security.set":
589
+ path = "/security";
590
+ init = { method: "POST", body: JSON.stringify(input) };
591
+ break;
592
+ case "package.install":
593
+ path = "/install";
594
+ init = { method: "POST", body: JSON.stringify(input) };
595
+ break;
596
+ case "package.install_service":
597
+ path = "/install-service";
598
+ init = { method: "POST", body: JSON.stringify(input) };
599
+ break;
600
+ case "package.restart_service":
601
+ path = "/restart-service";
602
+ init = { method: "POST", body: JSON.stringify(input) };
603
+ break;
604
+ case "package.remove":
605
+ path = "/remove";
606
+ init = { method: "POST", body: JSON.stringify(input) };
607
+ break;
608
+ case "package.update":
609
+ path = "/update";
610
+ init = { method: "POST", body: JSON.stringify(input) };
611
+ break;
612
+ default:
613
+ throw new PackageOperationError(`unknown operation: ${String(op)}`, 404);
614
+ }
615
+ const response = await route(new Request(`http://packed.internal${path}`, init));
616
+ const body = (await response.json()) as { error?: unknown };
617
+ if (!response.ok) {
618
+ throw new PackageOperationError(
619
+ typeof body.error === "string" ? body.error : `operation failed with HTTP ${response.status}`,
620
+ response.status,
621
+ );
622
+ }
623
+ return body as OperationOutputs[Name];
624
+ }
625
+
626
+ return {
627
+ async fetch(req: Request): Promise<Response> {
628
+ const t0 = Date.now();
629
+ if (!requireBearerToken(req, deps.token)) return errorResponse("missing or invalid bearer token", 401);
630
+ const requestUrl = new URL(req.url);
631
+ if (req.method === "GET" && requestUrl.pathname === "/api/v1/ops") return jsonResponse({ operations: OPERATION_NAMES });
632
+ if (req.method === "POST" && requestUrl.pathname === "/api/v1/ops") {
633
+ try {
634
+ const body = (await req.json()) as { op?: unknown; input?: unknown };
635
+ if (typeof body.op !== "string") return errorResponse("op is required", 400);
636
+ const input = body.input ?? {};
637
+ if (typeof input !== "object" || input === null || Array.isArray(input)) return errorResponse("input must be an object", 400);
638
+ const result = await executeOperation(body.op as OperationName, input as OperationInputs[OperationName]);
639
+ return jsonResponse({ result });
640
+ } catch (error) {
641
+ return errorResponse(
642
+ error instanceof Error ? error.message : String(error),
643
+ error instanceof PackageOperationError ? error.status : 400,
644
+ );
645
+ }
646
+ }
647
+ // Cache successful GETs by URI (smart-proxy concern).
648
+ if (req.method === "GET" && !["/health", "/updates", "/catalog", "/security"].includes(new URL(req.url).pathname)) {
649
+ const hit = cache.get(req.url);
650
+ if (hit) {
651
+ log.debug("request", { path: new URL(req.url).pathname, cache: "hit", ms: Date.now() - t0 });
652
+ return new Response(hit, { headers: { "content-type": "application/json", "x-cache": "hit" } });
653
+ }
654
+ const res = await route(req);
655
+ if (res.status === 200) cache.set(req.url, await res.clone().text());
656
+ log.debug("request", { path: new URL(req.url).pathname, status: res.status, cache: "miss", ms: Date.now() - t0 });
657
+ return res;
658
+ }
659
+ const res = await route(req);
660
+ log.debug("request", { path: new URL(req.url).pathname, status: res.status, ms: Date.now() - t0 });
661
+ return res;
662
+ },
663
+ };
664
+ }