@danypops/pi-packed 0.21.15 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/client.d.ts +2 -2
  2. package/dist/client.d.ts.map +1 -1
  3. package/dist/client.js +1 -1
  4. package/dist/protocol.d.ts +33 -0
  5. package/dist/protocol.d.ts.map +1 -1
  6. package/extension/src/menu-theme.ts +13 -3
  7. package/extension/src/packed.ts +2 -2
  8. package/extension/src/tools.ts +24 -8
  9. package/extension/src/tui.ts +7 -3
  10. package/package.json +4 -4
  11. package/service/src/adoption/doctor.ts +15 -0
  12. package/service/src/adoption/install-validation.ts +97 -5
  13. package/service/src/adoption/module-freshness.ts +100 -0
  14. package/service/src/adoption/smoke.ts +26 -2
  15. package/service/src/cli/cli.ts +30 -5
  16. package/service/src/daemon/client.ts +13 -7
  17. package/service/src/daemon/daemon.ts +35 -3
  18. package/service/src/daemon/service.ts +42 -7
  19. package/service/src/daemon/vehicle-registration.ts +118 -35
  20. package/service/src/daemon/watcher.ts +1 -18
  21. package/service/src/packages/deploy-verify.ts +151 -0
  22. package/service/src/packages/install.ts +120 -3
  23. package/service/src/packages/package.ts +67 -1
  24. package/service/src/packages/resources.ts +63 -0
  25. package/service/src/public/client.ts +9 -3
  26. package/service/src/public/protocol.ts +13 -1
  27. package/service/test/cli.test.ts +75 -1
  28. package/service/test/deploy-verify.test.ts +168 -0
  29. package/service/test/doctor.test.ts +104 -1
  30. package/service/test/fixtures/install-validation/convention-only-package/extensions/index.ts +3 -0
  31. package/service/test/fixtures/install-validation/convention-only-package/package.json +5 -0
  32. package/service/test/install-validation.test.ts +14 -1
  33. package/service/test/install.test.ts +229 -0
  34. package/service/test/module-freshness.test.ts +146 -0
  35. package/service/test/resources.test.ts +66 -1
  36. package/service/test/service.test.ts +68 -1
  37. package/service/test/smoke.test.ts +49 -2
  38. package/service/test/vehicle-registration.test.ts +97 -5
@@ -1,4 +1,5 @@
1
1
  import { existsSync, realpathSync } from "node:fs";
2
+ import { createRequire } from "node:module";
2
3
  import { dirname, join, relative, resolve, sep } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
 
@@ -53,13 +54,36 @@ function addReadOnlyBind(args: string[], path: string): void {
53
54
  if (existsSync(path)) args.push("--ro-bind", path, path);
54
55
  }
55
56
 
57
+ /**
58
+ * Resolves an installed dependency's real on-disk package directory via
59
+ * Node's own module-resolution algorithm (require.resolve against its
60
+ * package.json), starting from `fromDir` -- correct regardless of whether
61
+ * the package manager nested it under fromDir's own node_modules or
62
+ * hoisted it to a shared ancestor node_modules (npm's own hoisting
63
+ * decision, driven by what else in the tree also depends on it).
64
+ * Confirmed live: `packed doctor --json` crashed hard reconstructing a
65
+ * fixed nested-only path (`<packageDirectory>/node_modules/jiti`) that
66
+ * doesn't exist once jiti is hoisted to Pi's own npm prefix instead of
67
+ * pi-packed's own node_modules. Never executes the resolved module --
68
+ * require.resolve only locates a path, it never runs anything. undefined
69
+ * (not a throw) when genuinely unresolvable from here.
70
+ */
71
+ export function resolveDependencyModulesDir(fromDir: string, name: string): string | undefined {
72
+ try {
73
+ const req = createRequire(join(fromDir, "package.json"));
74
+ return realpathSync(dirname(req.resolve(`${name}/package.json`)));
75
+ } catch {
76
+ return undefined;
77
+ }
78
+ }
79
+
56
80
  function sandboxCommand(packageRoot: string, extensionPath: string, maxProcesses: number): string[] | undefined {
57
81
  if (process.platform !== "linux" || !existsSync("/usr/bin/bwrap") || !existsSync("/usr/bin/prlimit")) return undefined;
58
82
  const serviceRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
59
83
  const packageDirectory = dirname(serviceRoot);
60
84
  const childHarness = join(serviceRoot, "src/adoption/smoke-child.ts");
61
- const runnerModules = join(packageDirectory, "node_modules");
62
- const jitiModules = realpathSync(join(runnerModules, "jiti"));
85
+ const jitiModules = resolveDependencyModulesDir(packageDirectory, "jiti");
86
+ if (!jitiModules) return undefined;
63
87
  const bun = realpathSync(process.execPath);
64
88
  const relativeExtension = relative(packageRoot, extensionPath).split(sep).join("/");
65
89
  const args = [
@@ -13,6 +13,7 @@ import { checkUpdates } from "../daemon/watcher.ts";
13
13
  import { generateIndex, indexPath, readIndex } from "../index/build-index.ts";
14
14
  import { syncCatalog } from "../packages/catalog.ts";
15
15
  import { catalogList, dbPath, getSyncMeta, latestVersion, openDb, searchLocal } from "../packages/db.ts";
16
+ import { formatDeployVerification, verifyDeploy } from "../packages/deploy-verify.ts";
16
17
  import { defaultPiBin, NAME_RE } from "../packages/install.ts";
17
18
  import { npmPackageName, readInstalledPackages, readInstalledPackagesAcrossScopes } from "../packages/installed.ts";
18
19
  import type { Installer, Pkg, Registry, UpdateEntry } from "../packages/package.ts";
@@ -93,6 +94,7 @@ usage:
93
94
  packed index status [--json] report the local static index's generatedAt and package count
94
95
  packed check [path] [--smoke] [--json] diagnose a Pi package; smoke is isolated and opt-in
95
96
  packed doctor [--project <path>] [--json] smoke-test every enabled extension (global + project scope) and report tool/command/shortcut/flag name collisions before pi has to
97
+ packed verify-deploy <name> [--version <v>] [--json] diff a package's on-disk version across every known install location plus stale node_modules shadow copies
96
98
  packed pack [path] [--json] verify exact npm tarball contents without lifecycle scripts
97
99
  packed score [path|name] [--json] report adoption-readiness evidence by dimension
98
100
  packed publish setup [path] [--force] [--json] generate an OIDC staged-publish workflow
@@ -169,6 +171,8 @@ interface Flags {
169
171
  ecosystem: boolean;
170
172
  self: boolean;
171
173
  project?: string;
174
+ version?: string;
175
+ to?: string;
172
176
  }
173
177
 
174
178
  function parseFlags(rest: string[]): { flags: Flags; pos: string[] } {
@@ -202,6 +206,10 @@ function parseFlags(rest: string[]): { flags: Flags; pos: string[] } {
202
206
  else if (a.startsWith("--limit=")) flags.limit = Number(a.slice(8)) || SEARCH_DEFAULT_LIMIT;
203
207
  else if (a === "--project" && i + 1 < rest.length) flags.project = rest[++i];
204
208
  else if (a.startsWith("--project=")) flags.project = a.slice(10);
209
+ else if (a === "--version" && i + 1 < rest.length) flags.version = rest[++i];
210
+ else if (a.startsWith("--version=")) flags.version = a.slice(10);
211
+ else if (a === "--to" && i + 1 < rest.length) flags.to = rest[++i];
212
+ else if (a.startsWith("--to=")) flags.to = a.slice(5);
205
213
  else pos.push(a);
206
214
  }
207
215
  return { flags, pos };
@@ -358,6 +366,17 @@ const commands: Record<string, { usage: string; run: Command }> = {
358
366
  },
359
367
  },
360
368
 
369
+ "verify-deploy": {
370
+ usage: "packed verify-deploy <name> [--version <v>] [--json] (diff a package's on-disk version across every known install location plus stale node_modules shadow copies)",
371
+ async run(_rest, d, flags, pos) {
372
+ const name = pos[0] ?? "";
373
+ if (!NAME_RE.test(name)) return usageErr(`usage: ${commands["verify-deploy"]!.usage}\n`);
374
+ const report = verifyDeploy(d.piHome, name, flags.version);
375
+ const output = formatDeployVerification(report, flags.json);
376
+ return report.ok ? ok(output) : fail(output);
377
+ },
378
+ },
379
+
361
380
  search: {
362
381
  usage: "packed search <query> [--offline] [--limit N] [--json]",
363
382
  async run(_rest, d, flags, pos) {
@@ -646,7 +665,8 @@ const commands: Record<string, { usage: string; run: Command }> = {
646
665
  },
647
666
 
648
667
  update: {
649
- usage: "packed update <configured-source> [--approve] [--json] | packed update --self [--approve] [--json]",
668
+ usage:
669
+ "packed update <configured-source> [--to <new-source>] [--approve] [--json] | packed update --self [--approve] [--json] (--to replaces an exact pin end to end -- see verify-deploy/doctor for post-replace checks)",
650
670
  async run(_rest, d, flags, pos) {
651
671
  if (flags.self) {
652
672
  try {
@@ -666,20 +686,25 @@ const commands: Record<string, { usage: string; run: Command }> = {
666
686
  }
667
687
  const source = pos[0] ?? "";
668
688
  if (!SOURCE_RE.test(source)) return usageErr(`usage: ${commands.update!.usage}\n`);
689
+ if (flags.to !== undefined && !SOURCE_RE.test(flags.to)) return usageErr(`usage: ${commands.update!.usage}\n`);
669
690
  try {
670
- const outcome = await d.inst.update(source, { approved: flags.approved });
691
+ const outcome = await d.inst.update(source, { approved: flags.approved, target: flags.to });
671
692
  if (outcome.alreadyUpToDate) {
672
693
  if (flags.json) return ok(`${JSON.stringify({ ok: true, source, ...outcome })}\n`);
673
694
  const version = outcome.currentVersion ?? outcome.previousVersion;
674
695
  const reason = outcome.pinned
675
- ? `is pinned to ${version ?? "an exact version"} — pi update intentionally leaves pinned packages unchanged; run \`packed install npm:${npmPackageName(source) ?? source}\` to move off the pin`
696
+ ? `is pinned to ${version ?? "an exact version"} — pi update intentionally leaves pinned packages unchanged; run \`packed update ${source} --to npm:${npmPackageName(source) ?? source}@<version>\` to move off the pin`
676
697
  : `is already up to date${version ? ` at ${version}` : ""}`;
677
- return ok(`${source} ${reason}\n`);
698
+ const outOfRangeNote = outcome.outOfRangeUpdateAvailable
699
+ ? ` (a newer version, ${outcome.outOfRangeUpdateAvailable}, exists outside the declared range -- widen it to update)`
700
+ : "";
701
+ return ok(`${source} ${reason}${outOfRangeNote}\n`);
678
702
  }
679
703
  if (flags.json) return ok(`${JSON.stringify({ ok: true, source, ...outcome })}\n`);
680
704
  const transition =
681
705
  outcome.previousVersion && outcome.currentVersion ? ` (${outcome.previousVersion} → ${outcome.currentVersion})` : "";
682
- return ok(`${outcome.output}${transition}\nReload Pi with /reload to activate the updated package.\n`);
706
+ const replacedNote = outcome.replaced ? ` (replaced ${outcome.before?.source ?? source} with ${outcome.after?.source ?? ""})` : "";
707
+ return ok(`${outcome.output}${transition}${replacedNote}\nReload Pi with /reload to activate the updated package.\n`);
683
708
  } catch (error) {
684
709
  const message = error instanceof Error ? error.message : String(error);
685
710
  return flags.json
@@ -51,7 +51,7 @@ export interface PackageDaemonPort {
51
51
  restartService(source: string, approved?: boolean): Promise<{ output: string; restarted?: boolean; spec?: ServiceSummary }>;
52
52
  reconcileServices(approved?: boolean, projectRoot?: string): Promise<OperationOutputs["package.reconcile_services"]>;
53
53
  remove(name: string, approved?: boolean): Promise<string>;
54
- update(source: string, approved?: boolean): Promise<UpdateOutcome>;
54
+ update(source: string, approved?: boolean, target?: string): Promise<UpdateOutcome>;
55
55
  piStatus(): Promise<PiVersionReport>;
56
56
  resourcesList(projectRoot?: string): Promise<{ global: PackageResources[]; project: PackageResources[] }>;
57
57
  resourcesToggle(
@@ -265,8 +265,8 @@ export class PackageDaemonClient implements PackageDaemonPort {
265
265
  return result.output;
266
266
  }
267
267
 
268
- async update(source: string, approved = false): Promise<UpdateOutcome> {
269
- const result = await this.call("package.update", { source, approved });
268
+ async update(source: string, approved = false, target?: string): Promise<UpdateOutcome> {
269
+ const result = await this.call("package.update", { source, approved, target });
270
270
  if (!result.ok) throw new PackageDaemonError(result.output || `failed to update ${source}`, "package.update");
271
271
  return {
272
272
  output: result.output,
@@ -275,6 +275,12 @@ export class PackageDaemonClient implements PackageDaemonPort {
275
275
  pinned: result.pinned ?? false,
276
276
  previousVersion: result.previousVersion,
277
277
  currentVersion: result.currentVersion,
278
+ outOfRangeUpdateAvailable: result.outOfRangeUpdateAvailable,
279
+ pinnedSourceRequiresTarget: result.pinnedSourceRequiresTarget,
280
+ replaced: result.replaced,
281
+ before: result.before,
282
+ after: result.after,
283
+ rollback: result.rollback,
278
284
  };
279
285
  }
280
286
  }
@@ -289,8 +295,8 @@ export class PackageDaemonInstaller implements Installer {
289
295
  throw new PackageDaemonError("daemon package removal requires an npm: source", "package.remove");
290
296
  return this.client.remove(source.slice(4), options?.approved);
291
297
  }
292
- update(source: string, options?: { approved?: boolean; local?: boolean }): Promise<UpdateOutcome> {
293
- return this.client.update(source, options?.approved);
298
+ update(source: string, options?: { approved?: boolean; local?: boolean; target?: string }): Promise<UpdateOutcome> {
299
+ return this.client.update(source, options?.approved, options?.target);
294
300
  }
295
301
  }
296
302
 
@@ -312,8 +318,8 @@ export class DaemonBackedInstaller implements Installer {
312
318
  async remove(source: string, options?: { approved?: boolean }): Promise<string> {
313
319
  return new PackageDaemonInstaller(await connectPackageDaemon(this.paths)).remove(source, options);
314
320
  }
315
- async update(source: string, options?: { approved?: boolean }): Promise<UpdateOutcome> {
316
- return (await connectPackageDaemon(this.paths)).update(source, options?.approved);
321
+ async update(source: string, options?: { approved?: boolean; target?: string }): Promise<UpdateOutcome> {
322
+ return (await connectPackageDaemon(this.paths)).update(source, options?.approved, options?.target);
317
323
  }
318
324
  }
319
325
 
@@ -1,4 +1,5 @@
1
- import { dirname } from "node:path";
1
+ import { dirname, join } from "node:path";
2
+ import { fileURLToPath } from "node:url";
2
3
  import {
3
4
  type MaintenanceTask,
4
5
  type RunningDaemon,
@@ -7,6 +8,7 @@ import {
7
8
  startDaemon,
8
9
  } from "@danypops/vehicle-server/daemon";
9
10
  import { ensureAuthToken } from "@danypops/vehicle-server/paths";
11
+ import { captureLoadedModules, checkModuleFreshnessAll, ownRuntimeDependencyNames } from "../adoption/module-freshness.ts";
10
12
  import { type DaemonServiceInstaller, RealDaemonServiceInstaller, reconcileAllDaemonServices } from "./daemon-service.ts";
11
13
  import { generateIndex, indexPath, indexStatus } from "../index/build-index.ts";
12
14
  import { catalogStatus, syncCatalog } from "../packages/catalog.ts";
@@ -51,14 +53,34 @@ function configuredIdleBudgetMs(options: StartPackedDaemonOptions): number | und
51
53
  return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1_000 : undefined;
52
54
  }
53
55
 
56
+ /**
57
+ * Captured exactly once, here at daemon startup (daemonOptions() itself
58
+ * runs once per process -- see vehicle-server/daemon.js's own single
59
+ * `buildApp()` call site) -- see module-freshness.ts's own header comment
60
+ * for why this specific moment is what makes the later comparison
61
+ * meaningful: this is the instant this process's own static imports last
62
+ * actually loaded these files into memory.
63
+ */
64
+ function captureOwnModuleSnapshot() {
65
+ const serviceRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
66
+ const packageDirectory = dirname(serviceRoot);
67
+ const ownPackageJsonPath = join(packageDirectory, "package.json");
68
+ return captureLoadedModules(packageDirectory, ownRuntimeDependencyNames(ownPackageJsonPath));
69
+ }
70
+
54
71
  export function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOptions {
55
72
  const paths = options.paths ?? resolvePackedPaths();
56
73
  if (options.migrateLegacy ?? options.paths === undefined) migrateLegacyPackedState(paths, legacyPackedStateDirectory());
57
74
  const token = ensureAuthToken(paths.token, "Packed");
58
75
  const reg = options.reg ?? new HttpRegistry();
59
- const inst = options.inst ?? new ExecInstaller();
60
76
  const piHome = options.piHome ?? defaultPiHome();
61
77
  const database = openDb(paths.database);
78
+ const moduleSnapshot = captureOwnModuleSnapshot();
79
+ // Wires ExecInstaller.update()'s own out-of-range cross-check to the exact
80
+ // same registry-mirror source of truth the package-update-check task below
81
+ // already uses via checkUpdates() -- one mirror, two consumers, never two
82
+ // notions of "the real latest version".
83
+ const inst = options.inst ?? new ExecInstaller(undefined, piHome, undefined, undefined, undefined, (name) => latestVersion(database, name));
62
84
  const daemonServiceInstaller = options.daemonServiceInstaller ?? new RealDaemonServiceInstaller();
63
85
  const configuredMaintenanceTasks = options.maintenanceTasks ?? [
64
86
  {
@@ -116,7 +138,17 @@ export function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOpt
116
138
  maintenanceTasks,
117
139
  ...(idleBudgetMs === undefined ? {} : { idleBudgetMs }),
118
140
  idleTickMs: WATCHDOG_TICK_MS,
119
- buildApp: () => createApp({ reg, inst, token, stateDir: paths.stateDirectory, dataDir: dirname(paths.database), piHome, daemonServiceInstaller }),
141
+ buildApp: () =>
142
+ createApp({
143
+ reg,
144
+ inst,
145
+ token,
146
+ stateDir: paths.stateDirectory,
147
+ dataDir: dirname(paths.database),
148
+ piHome,
149
+ daemonServiceInstaller,
150
+ moduleFreshness: () => checkModuleFreshnessAll(moduleSnapshot),
151
+ }),
120
152
  onShutdown: () => database.close(),
121
153
  };
122
154
  }
@@ -13,6 +13,7 @@ import { registerPackedVehicleOperations } from "./vehicle-registration.ts";
13
13
  import { type AdvisoryReport, resolveInstalledVersions, scanInstalledPackages } from "../adoption/advisories.ts";
14
14
  import { type CheckReport, type PackageChecker, StaticPackageChecker } from "../adoption/check.ts";
15
15
  import { type DoctorReport, runDoctor } from "../adoption/doctor.ts";
16
+ import type { ModuleFreshnessDiagnostic } from "../adoption/module-freshness.ts";
16
17
  import { NpmPackVerifier, type PackReport } from "../adoption/pack.ts";
17
18
  import { type AdoptionReport, scoreTarget } from "../adoption/score.ts";
18
19
  import { buildIndex, indexPath, type PackageIndex, readIndex, writeIndex } from "../index/build-index.ts";
@@ -68,6 +69,15 @@ export interface Deps {
68
69
  apply(manifestPath: string, options?: { prune?: boolean }): Promise<SetupApplyResult>;
69
70
  };
70
71
  daemonServiceInstaller?: DaemonServiceInstaller;
72
+ /**
73
+ * Re-checks this exact running process's own captured startup snapshot
74
+ * of its runtime dependencies against their current on-disk state --
75
+ * see module-freshness.ts. Sync (a handful of small file stats/reads),
76
+ * injected by daemon.ts once at process start; undefined for any Deps
77
+ * built without a real running process behind it (a standalone `packed
78
+ * doctor` never has a snapshot to compare against at all).
79
+ */
80
+ moduleFreshness?: () => ModuleFreshnessDiagnostic[];
71
81
  piVersion?: { check(options?: { timeoutMs?: number }): Promise<PiVersionReport> };
72
82
  advisories?: { scan(installed: Record<string, string>): Promise<AdvisoryReport> };
73
83
  }
@@ -126,7 +136,7 @@ export interface OperationInputs {
126
136
  "package.restart_service": { source: string; approved?: boolean };
127
137
  "package.reconcile_services": { approved?: boolean; projectRoot?: string };
128
138
  "package.remove": { name: string; approved?: boolean };
129
- "package.update": { source: string; approved?: boolean };
139
+ "package.update": { source: string; approved?: boolean; target?: string };
130
140
  "resources.list": { projectRoot?: string };
131
141
  "resources.toggle": { source: string; field: ResourceField; path: string; enabled: boolean; projectRoot?: string; approved?: boolean };
132
142
  "pi.status": Record<string, never>;
@@ -257,6 +267,10 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
257
267
  description: "Pi package lifecycle daemon",
258
268
  });
259
269
  registerPackedVehicleOperations(vehicleRegistry, executeOperation);
270
+ // Live from day one: the registry's own gate mirrors whatever mutationApproval already
271
+ // is on disk at startup, then tracks every /security POST from here on (see below) --
272
+ // never a fixed per-deployment constant baked in once and forgotten.
273
+ vehicleRegistry.configureApprovals({ enabled: readSecuritySettings(deps.stateDir).mutationApproval === "always" });
260
274
  const vehicleApp = createVehicleHttpApp({ registry: vehicleRegistry, token: deps.token });
261
275
 
262
276
  function authorize(operation: PackageOperation, approved: boolean): Response | undefined {
@@ -294,7 +308,14 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
294
308
  }
295
309
  const denied = authorize("security.write", body.approved === true);
296
310
  if (denied) return denied;
297
- return json(await writeSecuritySettings(deps.stateDir, { mutationApproval: body.mutationApproval as MutationApproval }));
311
+ const mutationApproval = body.mutationApproval as MutationApproval;
312
+ const written = await writeSecuritySettings(deps.stateDir, { mutationApproval });
313
+ // Same live-toggle guarantee the legacy /api/v1/ops transport already had via
314
+ // readSecuritySettings() being re-read fresh on every request -- the Vehicle
315
+ // transport's own gate is a stateful in-memory policy, so a change here has to
316
+ // be pushed to it explicitly instead of being implicitly always-fresh.
317
+ vehicleRegistry.updateApprovalPolicy({ enabled: mutationApproval === "always" });
318
+ return json(written);
298
319
  }
299
320
 
300
321
  if (path === "/search" && req.method === "GET") {
@@ -486,22 +507,32 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
486
507
  if (path === "/update" && req.method === "POST") {
487
508
  let source = "";
488
509
  let approved = false;
510
+ let target: string | undefined;
489
511
  try {
490
- const body = (await req.json()) as { source?: unknown; approved?: unknown };
512
+ const body = (await req.json()) as { source?: unknown; approved?: unknown; target?: unknown };
491
513
  source = String(body.source ?? "");
492
514
  approved = body.approved === true;
515
+ target = typeof body.target === "string" && body.target.length > 0 ? body.target : undefined;
493
516
  } catch {
494
517
  /* fall through to validation */
495
518
  }
496
519
  if (!SOURCE_RE.test(source)) {
497
520
  return err(400, "invalid source; want a configured npm:, git:, or https package source");
498
521
  }
522
+ if (target !== undefined && !SOURCE_RE.test(target)) {
523
+ return err(400, "invalid target; want a configured npm:, git:, or https package source");
524
+ }
499
525
  const denied = authorize("update", approved);
500
526
  if (denied) return denied;
527
+ // A successful replace re-identifies the package under `target`, not
528
+ // `source` -- a daemon-backed package's own service restart (below)
529
+ // must reconcile against the NEW installed source, never the one that
530
+ // was just removed.
531
+ const restartSource = target ?? source;
501
532
  try {
502
- const outcome = await deps.inst.update(source, { approved });
503
- if (!source.startsWith("npm:") || outcome.alreadyUpToDate) return json({ ok: true, source, ...outcome });
504
- const service = await daemonServiceInstaller.restart(piHomeForServiceInstall, source);
533
+ const outcome = await deps.inst.update(source, { approved, target });
534
+ if (!restartSource.startsWith("npm:") || outcome.alreadyUpToDate) return json({ ok: true, source, ...outcome });
535
+ const service = await daemonServiceInstaller.restart(piHomeForServiceInstall, restartSource);
505
536
  if (!service.ok) {
506
537
  if (service.notADaemon) return json({ ok: true, source, ...outcome });
507
538
  return json({ ok: false, source, ...outcome, output: `${outcome.output}\n${service.reason}` });
@@ -598,7 +629,11 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
598
629
  const value = input as OperationInputs["doctor.run"];
599
630
  if (value.projectRoot !== undefined && (typeof value.projectRoot !== "string" || value.projectRoot.length > 4_096))
600
631
  throw new PackageOperationError("projectRoot must be a string up to 4096 characters", 400);
601
- return (await runDoctor(deps.piHome ?? defaultPiHome(), value.projectRoot)) as OperationOutputs[Name];
632
+ const report = await runDoctor(deps.piHome ?? defaultPiHome(), value.projectRoot);
633
+ const moduleFreshness = deps.moduleFreshness?.();
634
+ if (moduleFreshness === undefined) return report as OperationOutputs[Name];
635
+ const anyStale = moduleFreshness.some((diagnostic) => diagnostic.stale);
636
+ return { ...report, moduleFreshness, ok: report.ok && !anyStale } as OperationOutputs[Name];
602
637
  }
603
638
  if (op === "package.updates.project") {
604
639
  const value = input as OperationInputs["package.updates.project"];
@@ -27,13 +27,29 @@
27
27
  * install/install_service/update/setup.apply which can fetch and run
28
28
  * arbitrary newly-published code).
29
29
  *
30
- * Approval/authorization is NOT reimplemented here: executeOperation()
31
- * (and the route()-based operations it delegates to internally) already
32
- * calls authorize()/assertPackagePermission() and throws
33
- * PackageOperationError(status: 403) on a denied mutation -- reusing
34
- * executeOperation() verbatim means this Vehicle surface automatically
35
- * inherits the exact same approval gate, with zero duplicated policy logic
36
- * to drift out of sync.
30
+ * Approval is delegated to the registry's own Vehicle-native mechanism
31
+ * (VehicleRegistry.configureApprovals/updateApprovalPolicy, wired up in
32
+ * daemon.ts/service.ts against packed's own mutationApproval setting) --
33
+ * NOT reimplemented here. Each operation below carries an explicit
34
+ * requiresApproval, exactly matching security.ts's PACKAGE_OPERATIONS
35
+ * classification's own guarded set (code-execution/settings-mutation/
36
+ * security-mutation), set unconditionally rather than left to derive from
37
+ * effect: two of Vehicle's five effect buckets already mix a guarded
38
+ * operation with an unguarded one here (external-write covers both
39
+ * restart_service/reconcile_services, which ARE guarded, and
40
+ * catalog.sync/index.build, which never were; local-write covers both
41
+ * resources.toggle/security.set, guarded, and setup.export/setup.update,
42
+ * never guarded) -- no single effect-derived default could reproduce this
43
+ * split, which is exactly the gap VehicleOperationDescriptor.requiresApproval
44
+ * exists to close.
45
+ *
46
+ * Once the registry's own gate approves a call (because it wasn't gated,
47
+ * or because a real capability was presented), the bound handler forces
48
+ * approved: true onto the input before delegating to executeOperation() --
49
+ * its own legacy authorize()/assertPackagePermission() check (still the
50
+ * sole gate for the older /api/v1/ops transport, untouched here) would
51
+ * otherwise reject a call whose caller never knew that REST-only field
52
+ * existed. A Vehicle caller's only approval contract is Vehicle's own.
37
53
  */
38
54
 
39
55
  import type { VehicleEffect, VehicleIdempotency } from "@danypops/vehicle-core";
@@ -66,6 +82,11 @@ const WRITE: VehicleIdempotency = { mode: "unsafe" };
66
82
  interface OperationMeta {
67
83
  readonly description: string;
68
84
  readonly effect: VehicleEffect;
85
+ /** See vehicle-core's VehicleOperationDescriptor.requiresApproval -- set explicitly for
86
+ * every operation here (never left to derive from effect), matching security.ts's own
87
+ * per-operation guarded/unguarded classification exactly. See this file's own doc
88
+ * comment for why effect alone can't reproduce that split. */
89
+ readonly requiresApproval: boolean;
69
90
  }
70
91
 
71
92
  /**
@@ -74,53 +95,111 @@ interface OperationMeta {
74
95
  * classification) and its two deliberate refinements.
75
96
  */
76
97
  const OPERATION_META: Record<OperationName, OperationMeta> = {
77
- "package.search": { description: "Searches Pi packages on npm.", effect: "read" },
78
- "package.info": { description: "Reads bounded metadata for one package.", effect: "read" },
79
- "package.installed": { description: "Lists locally installed Pi packages.", effect: "read" },
80
- "package.catalog": { description: "Reads the local SQLite catalog mirror.", effect: "read" },
98
+ "package.search": { description: "Searches Pi packages on npm.", effect: "read", requiresApproval: false },
99
+ "package.info": { description: "Reads bounded metadata for one package.", effect: "read", requiresApproval: false },
100
+ "package.installed": { description: "Lists locally installed Pi packages.", effect: "read", requiresApproval: false },
101
+ "package.catalog": { description: "Reads the local SQLite catalog mirror.", effect: "read", requiresApproval: false },
81
102
  "package.catalog.sync": {
82
103
  description: "Refreshes the local catalog mirror from the Pi-package-tagged npm registry subset.",
83
104
  effect: "external-write",
105
+ requiresApproval: false,
84
106
  },
85
- "package.index": { description: "Reads the locally built adoption-score index, if one exists.", effect: "read" },
107
+ "package.index": { description: "Reads the locally built adoption-score index, if one exists.", effect: "read", requiresApproval: false },
86
108
  "package.index.build": {
87
109
  description: "Builds the adoption-score index by scoring catalog entries against the npm registry.",
88
110
  effect: "external-write",
111
+ requiresApproval: false,
112
+ },
113
+ "package.updates": { description: "Reads the last background update-check snapshot.", effect: "read", requiresApproval: false },
114
+ "package.check": {
115
+ description: "Runs static (and optionally smoke-test) quality checks against a local package path.",
116
+ effect: "read",
117
+ requiresApproval: false,
118
+ },
119
+ "package.pack": { description: "Verifies a local package path via npm pack.", effect: "read", requiresApproval: false },
120
+ "package.score": {
121
+ description: "Computes an adoption-readiness score for a local path or registry package.",
122
+ effect: "read",
123
+ requiresApproval: false,
124
+ },
125
+ "setup.export": { description: "Exports the current Pi setup as a portable manifest.", effect: "local-write", requiresApproval: false },
126
+ "setup.update": { description: "Updates an existing setup manifest in place.", effect: "local-write", requiresApproval: false },
127
+ "setup.plan": {
128
+ description: "Computes a setup manifest's install/update/remove plan without applying it.",
129
+ effect: "read",
130
+ requiresApproval: false,
89
131
  },
90
- "package.updates": { description: "Reads the last background update-check snapshot.", effect: "read" },
91
- "package.check": { description: "Runs static (and optionally smoke-test) quality checks against a local package path.", effect: "read" },
92
- "package.pack": { description: "Verifies a local package path via npm pack.", effect: "read" },
93
- "package.score": { description: "Computes an adoption-readiness score for a local path or registry package.", effect: "read" },
94
- "setup.export": { description: "Exports the current Pi setup as a portable manifest.", effect: "local-write" },
95
- "setup.update": { description: "Updates an existing setup manifest in place.", effect: "local-write" },
96
- "setup.plan": { description: "Computes a setup manifest's install/update/remove plan without applying it.", effect: "read" },
97
132
  "setup.apply": {
98
133
  description: "Applies a setup manifest's plan -- can install, update, or remove packages.",
99
134
  effect: "open-world",
135
+ requiresApproval: true,
136
+ },
137
+ "package.security.get": {
138
+ description: "Reads this daemon's mutation-approval security settings.",
139
+ effect: "read",
140
+ requiresApproval: false,
141
+ },
142
+ "package.security.set": {
143
+ description: "Writes this daemon's mutation-approval security settings.",
144
+ effect: "local-write",
145
+ requiresApproval: true,
146
+ },
147
+ "package.install": {
148
+ description: "Installs a Pi package from an npm, git, or https source.",
149
+ effect: "open-world",
150
+ requiresApproval: true,
100
151
  },
101
- "package.security.get": { description: "Reads this daemon's mutation-approval security settings.", effect: "read" },
102
- "package.security.set": { description: "Writes this daemon's mutation-approval security settings.", effect: "local-write" },
103
- "package.install": { description: "Installs a Pi package from an npm, git, or https source.", effect: "open-world" },
104
152
  "package.install_service": {
105
153
  description: "Installs a persistent supervised service for an already-installed daemon package.",
106
154
  effect: "open-world",
155
+ requiresApproval: true,
107
156
  },
108
157
  "package.restart_service": {
109
158
  description: "Restarts an already-installed package's persistent service -- no new code introduced.",
110
159
  effect: "external-write",
160
+ requiresApproval: true,
111
161
  },
112
162
  "package.reconcile_services": {
113
163
  description: "Reconciles every installed daemon package's persistent service against desired state.",
114
164
  effect: "external-write",
165
+ requiresApproval: true,
166
+ },
167
+ "package.remove": { description: "Removes an installed Pi package. Irreversible.", effect: "destructive", requiresApproval: true },
168
+ "package.update": {
169
+ description: "Updates a configured Pi package to its latest available version.",
170
+ effect: "open-world",
171
+ requiresApproval: true,
172
+ },
173
+ "resources.list": {
174
+ description: "Lists global and project-scoped Pi resources (extensions, skills, prompts, themes).",
175
+ effect: "read",
176
+ requiresApproval: false,
177
+ },
178
+ "resources.toggle": {
179
+ description: "Enables or disables one Pi resource in a settings file.",
180
+ effect: "local-write",
181
+ requiresApproval: true,
182
+ },
183
+ "pi.status": {
184
+ description: "Reports the locally running Pi version against the latest published release.",
185
+ effect: "read",
186
+ requiresApproval: false,
187
+ },
188
+ "advisories.scan": {
189
+ description: "Scans installed package versions against known advisories.",
190
+ effect: "read",
191
+ requiresApproval: false,
192
+ },
193
+ "doctor.run": {
194
+ description: "Runs diagnostic health checks (service install drift, resource config, ...).",
195
+ effect: "read",
196
+ requiresApproval: false,
197
+ },
198
+ "package.updates.project": {
199
+ description: "Checks for updates across every scope visible to one project.",
200
+ effect: "read",
201
+ requiresApproval: false,
115
202
  },
116
- "package.remove": { description: "Removes an installed Pi package. Irreversible.", effect: "destructive" },
117
- "package.update": { description: "Updates a configured Pi package to its latest available version.", effect: "open-world" },
118
- "resources.list": { description: "Lists global and project-scoped Pi resources (extensions, skills, prompts, themes).", effect: "read" },
119
- "resources.toggle": { description: "Enables or disables one Pi resource in a settings file.", effect: "local-write" },
120
- "pi.status": { description: "Reports the locally running Pi version against the latest published release.", effect: "read" },
121
- "advisories.scan": { description: "Scans installed package versions against known advisories.", effect: "read" },
122
- "doctor.run": { description: "Runs diagnostic health checks (service install drift, resource config, ...).", effect: "read" },
123
- "package.updates.project": { description: "Checks for updates across every scope visible to one project.", effect: "read" },
124
203
  };
125
204
 
126
205
  /** Read effects need only packed:read; every other effect needs both (writes commonly also read first). */
@@ -142,16 +221,20 @@ export function registerPackedVehicleOperations(
142
221
  output: passthroughVehicleSchema,
143
222
  permissions: permissionsFor(meta.effect),
144
223
  effect: meta.effect,
224
+ requiresApproval: meta.requiresApproval,
145
225
  idempotency: meta.effect === "read" ? READ : WRITE,
146
226
  limits: LIMITS,
147
227
  });
148
228
  registry.register(
149
229
  OWNER,
150
- bindVehicleOperation(
151
- operation,
152
- () => async (context) =>
153
- withPackedErrorParity(() => executeOperation(name, context.input as OperationInputs[typeof name])),
154
- ),
230
+ bindVehicleOperation(operation, () => async (context) => {
231
+ // The registry's own gate (see this file's doc comment) already decided this
232
+ // call is approved by the time the handler runs -- forcing approved: true
233
+ // here satisfies executeOperation()'s own legacy authorize() check without
234
+ // asking a Vehicle caller to know that REST-only field even exists.
235
+ const input = { ...(context.input as Record<string, unknown>), approved: true } as OperationInputs[typeof name];
236
+ return withPackedErrorParity(() => executeOperation(name, input));
237
+ }),
155
238
  );
156
239
  }
157
240
  }
@@ -5,12 +5,12 @@
5
5
  */
6
6
  import { mkdir, readFile, writeFile } from "node:fs/promises";
7
7
  import { join } from "node:path";
8
- import { gt, valid } from "semver";
9
8
  import { UPDATES_FILE } from "../shared/constants.ts";
10
9
  import { createLogger } from "../shared/log.ts";
11
10
 
12
11
  const log = createLogger("watcher");
13
12
 
13
+ import { isNewer } from "../packages/package.ts";
14
14
  import type { InstalledPkg, UpdateEntry, UpdatesSnapshot } from "../packages/package.ts";
15
15
 
16
16
  function updatesPath(dir: string): string {
@@ -30,23 +30,6 @@ export async function loadUpdates(dir: string): Promise<UpdatesSnapshot | undefi
30
30
  }
31
31
  }
32
32
 
33
- /**
34
- * True drift only: mirrored latest is a real semver step *ahead* of what's
35
- * installed, never merely different from it. A plain !== check (this
36
- * function's own bug until fixed) cannot distinguish "installed is behind
37
- * latest" from "installed is already ahead of a stale/wrong mirrored
38
- * latest" -- confirmed live: an installed package whose version had
39
- * already passed the daemon's own mirrored dist-tags.latest kept showing
40
- * a permanent, un-clearable "update available" badge pointing at an
41
- * *older* version. Falls back to the old inequality only when either side
42
- * isn't parseable semver (a git ref, a literal "latest" tag, etc.) --
43
- * those aren't comparable at all, so "different" is the only signal left.
44
- */
45
- function isNewer(latest: string, have: string): boolean {
46
- if (valid(latest, { loose: true }) && valid(have, { loose: true })) return gt(latest, have, { loose: true });
47
- return latest !== have;
48
- }
49
-
50
33
  /** Pure diff against the local mirror (apt list --upgradable semantics):
51
34
  * drift = mirrored latest is genuinely newer than what we have. The
52
35
  * mirror is refreshed by catalogSync; updates are computed offline,