@danypops/pi-packed 0.21.14 → 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 (41) 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/approval/reload.ts +51 -2
  7. package/extension/src/menu-theme.ts +13 -3
  8. package/extension/src/packed.ts +2 -2
  9. package/extension/src/tabs/discover.ts +13 -2
  10. package/extension/src/tool-output.ts +14 -1
  11. package/extension/src/tools.ts +46 -14
  12. package/extension/src/tui.ts +7 -3
  13. package/package.json +4 -4
  14. package/service/src/adoption/doctor.ts +15 -0
  15. package/service/src/adoption/install-validation.ts +97 -5
  16. package/service/src/adoption/module-freshness.ts +100 -0
  17. package/service/src/adoption/smoke.ts +26 -2
  18. package/service/src/cli/cli.ts +30 -5
  19. package/service/src/daemon/client.ts +13 -7
  20. package/service/src/daemon/daemon.ts +35 -3
  21. package/service/src/daemon/service.ts +42 -7
  22. package/service/src/daemon/vehicle-registration.ts +118 -35
  23. package/service/src/daemon/watcher.ts +1 -18
  24. package/service/src/packages/deploy-verify.ts +151 -0
  25. package/service/src/packages/install.ts +120 -3
  26. package/service/src/packages/package.ts +67 -1
  27. package/service/src/packages/resources.ts +63 -0
  28. package/service/src/public/client.ts +9 -3
  29. package/service/src/public/protocol.ts +13 -1
  30. package/service/test/cli.test.ts +75 -1
  31. package/service/test/deploy-verify.test.ts +168 -0
  32. package/service/test/doctor.test.ts +104 -1
  33. package/service/test/fixtures/install-validation/convention-only-package/extensions/index.ts +3 -0
  34. package/service/test/fixtures/install-validation/convention-only-package/package.json +5 -0
  35. package/service/test/install-validation.test.ts +14 -1
  36. package/service/test/install.test.ts +229 -0
  37. package/service/test/module-freshness.test.ts +146 -0
  38. package/service/test/resources.test.ts +66 -1
  39. package/service/test/service.test.ts +68 -1
  40. package/service/test/smoke.test.ts +49 -2
  41. package/service/test/vehicle-registration.test.ts +97 -5
@@ -12,7 +12,7 @@
12
12
  * subprocess as non-gating, observational evidence alongside that gating
13
13
  * check -- see ExtensionLoadResult.additionalLoadPaths.
14
14
  */
15
- import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
15
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
16
16
  import { createRequire } from "node:module";
17
17
  import { tmpdir } from "node:os";
18
18
  import { join, resolve } from "node:path";
@@ -74,6 +74,75 @@ const MAX_LOAD_TIMEOUT_MS = 30_000;
74
74
  const PACK_TIMEOUT_MS = 30_000;
75
75
  const INSTALL_TIMEOUT_MS = 60_000;
76
76
  const MAX_EXTENSIONS = 20;
77
+ /** Bounds directoryHasMatchingFile's own recursive walk -- a real Pi package's
78
+ * resource directories are small; this only exists so an adversarial or
79
+ * accidentally enormous tarball can't make the convention-directory scan
80
+ * itself expensive. */
81
+ const MAX_CONVENTION_SCAN_ENTRIES = 500;
82
+
83
+ /** The four resource kinds a package.json's own `pi` manifest key, or (absent
84
+ * a manifest) pi's own convention directories, can declare -- see
85
+ * @earendil-works/pi-coding-agent's docs/packages.md "Package Structure". */
86
+ const MANIFEST_RESOURCE_FIELDS = ["extensions", "skills", "prompts", "themes"] as const;
87
+
88
+ /** Loosely mirrors (not a byte-for-byte port -- pi's own walker is
89
+ * .gitignore-aware via the `ignore` package, this isn't) pi's own
90
+ * convention-directory file matching from @earendil-works/pi-coding-agent's
91
+ * package-manager.js: extensions/ take .ts/.js recursively, skills/ take
92
+ * SKILL.md or any .md recursively, prompts/ and themes/ take top-level
93
+ * .md/.json respectively. Close enough to answer "would pi's own loader ever
94
+ * find anything to load here", which is all a pre-install admission check
95
+ * needs. */
96
+ const CONVENTION_RESOURCE_DIRS: Record<string, { match: (name: string) => boolean; recursive: boolean }> = {
97
+ extensions: { match: (name) => name.endsWith(".ts") || name.endsWith(".js"), recursive: true },
98
+ skills: { match: (name) => name === "SKILL.md" || name.endsWith(".md"), recursive: true },
99
+ prompts: { match: (name) => name.endsWith(".md"), recursive: false },
100
+ themes: { match: (name) => name.endsWith(".json"), recursive: false },
101
+ };
102
+
103
+ function hasNonEmptyStringArray(value: unknown): boolean {
104
+ return Array.isArray(value) && value.some((entry) => typeof entry === "string" && entry.trim().length > 0);
105
+ }
106
+
107
+ /** True if the package's own `pi` manifest key declares at least one
108
+ * non-empty resource array of any of the four kinds -- not just
109
+ * `extensions`, which the caller already checked separately. */
110
+ function hasAnyManifestResource(pi: Record<string, unknown> | undefined): boolean {
111
+ if (!pi) return false;
112
+ return MANIFEST_RESOURCE_FIELDS.some((field) => hasNonEmptyStringArray(pi[field]));
113
+ }
114
+
115
+ /** Bounded recursive scan for at least one file matching `match` under `dir`.
116
+ * `remaining` is a shared mutable budget across the whole call tree so a
117
+ * pathological directory structure can't make this unbounded work. */
118
+ function directoryHasMatchingFile(dir: string, match: (name: string) => boolean, recursive: boolean, remaining: { count: number }): boolean {
119
+ if (!existsSync(dir) || remaining.count <= 0) return false;
120
+ let entries: import("node:fs").Dirent<string>[];
121
+ try {
122
+ entries = readdirSync(dir, { withFileTypes: true, encoding: "utf8" });
123
+ } catch {
124
+ return false;
125
+ }
126
+ for (const entry of entries) {
127
+ if (remaining.count-- <= 0) return false;
128
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
129
+ const full = join(dir, entry.name);
130
+ if (entry.isFile() && match(entry.name)) return true;
131
+ if (recursive && entry.isDirectory() && directoryHasMatchingFile(full, match, recursive, remaining)) return true;
132
+ }
133
+ return false;
134
+ }
135
+
136
+ /** True if the staged package root has any of pi's own convention resource
137
+ * directories (extensions/, skills/, prompts/, themes/) containing at least
138
+ * one file pi's own loader would actually pick up -- the fallback pi itself
139
+ * uses when a package declares no `pi` manifest at all. */
140
+ function hasConventionResources(root: string): boolean {
141
+ const remaining = { count: MAX_CONVENTION_SCAN_ENTRIES };
142
+ return Object.entries(CONVENTION_RESOURCE_DIRS).some(([dirName, { match, recursive }]) =>
143
+ directoryHasMatchingFile(join(root, dirName), match, recursive, remaining),
144
+ );
145
+ }
77
146
 
78
147
  function bounded(value: number | undefined, fallback: number, maximum: number): number {
79
148
  if (!Number.isFinite(value) || value === undefined || value <= 0) return fallback;
@@ -215,9 +284,13 @@ async function verifyAllLoadPathsHeadless(entryPath: string, timeoutMs?: number)
215
284
  }
216
285
 
217
286
  /** Stages the real npm tarball in isolation and headlessly load-checks
218
- * every pi.extensions entry it declares. A package with no pi.extensions
219
- * (most npm packages) or a non-npm source has nothing to validate and
220
- * passes through as ok. */
287
+ * every pi.extensions entry it declares. A non-npm source has nothing to
288
+ * stage and passes through as ok. A package with no pi.extensions passes
289
+ * only if it declares some other pi manifest resource (skills/prompts/
290
+ * themes) or has a matching pi convention directory on disk -- otherwise
291
+ * it isn't a Pi package at all (a plain npm dependency like is-number or
292
+ * is-buffer) and is refused rather than silently admitted as a dead
293
+ * `packages` entry pi itself would never load anything from. */
221
294
  export class HeadlessInstallValidator implements InstallValidator {
222
295
  constructor(private readonly timeoutMs?: number) {}
223
296
 
@@ -241,7 +314,26 @@ export class HeadlessInstallValidator implements InstallValidator {
241
314
  const declared = Array.isArray(pi?.extensions)
242
315
  ? pi.extensions.filter((entry): entry is string => typeof entry === "string").slice(0, MAX_EXTENSIONS)
243
316
  : [];
244
- if (declared.length === 0) return { ok: true, source, extensions: [] };
317
+ if (declared.length === 0) {
318
+ // No pi.extensions to headlessly load-check -- but that alone isn't
319
+ // license to wave the package through. A real Pi package with only
320
+ // skills/prompts/themes (declared in the manifest or found via pi's
321
+ // own convention directories) still passes here unexamined; a
322
+ // package that is neither -- e.g. `is-number`, `is-buffer`, any
323
+ // plain leaf npm dependency someone points `pkg_install` at
324
+ // directly -- gets refused instead of silently becoming a dead
325
+ // `packages` entry that pi itself will never load anything from.
326
+ if (hasAnyManifestResource(pi) || hasConventionResources(staged.root)) {
327
+ return { ok: true, source, extensions: [] };
328
+ }
329
+ return {
330
+ ok: false,
331
+ source,
332
+ extensions: [],
333
+ message:
334
+ "package declares no pi.extensions/skills/prompts/themes and has no extensions/skills/prompts/themes directory -- not a Pi package",
335
+ };
336
+ }
245
337
 
246
338
  const installed = await installStagedDependencies(staged.root);
247
339
  if (!installed.ok) return { ok: false, source, extensions: [], message: installed.message };
@@ -0,0 +1,100 @@
1
+ /**
2
+ * module-freshness.ts — turns a recurring diagnosis-by-intuition ("this
3
+ * looks like Bun/Node holding a cached copy of an old build across a live
4
+ * process") into a real, checkable signal.
5
+ *
6
+ * A long-running process (the packed daemon) loads its own dependencies
7
+ * once via static imports at startup; Node/Bun's module cache then holds
8
+ * that exact in-memory copy for the rest of the process's life, regardless
9
+ * of whatever a later `pkg_update`/`npm install` writes to the same files
10
+ * on disk -- only a restart picks up the new code. Detecting that gap
11
+ * needs one snapshot taken at process start (captureLoadedModule, reusing
12
+ * smoke.ts's own real module-resolution helper, never executing anything)
13
+ * and a later re-read of the exact same on-disk path (checkModuleFreshness)
14
+ * -- if the file's mtime moved since the snapshot, the running process is
15
+ * provably still holding stale code in memory.
16
+ */
17
+ import { readFileSync, statSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { resolveDependencyModulesDir } from "./smoke.ts";
20
+
21
+ export interface LoadedModuleSnapshot {
22
+ readonly name: string;
23
+ readonly packageJsonPath: string;
24
+ readonly version?: string;
25
+ readonly mtimeMs?: number;
26
+ }
27
+
28
+ export interface ModuleFreshnessDiagnostic {
29
+ readonly name: string;
30
+ readonly loadedVersion?: string;
31
+ readonly currentVersion?: string;
32
+ /** True only when both mtimes are known and genuinely differ -- never a
33
+ * guess from version alone (a version string can stay identical across
34
+ * a same-version reinstall that still replaced the file, and not every
35
+ * package bumps a version for a local/dev iteration). */
36
+ readonly stale: boolean;
37
+ }
38
+
39
+ function readVersionAndMtime(packageJsonPath: string): { version?: string; mtimeMs?: number } {
40
+ try {
41
+ const mtimeMs = statSync(packageJsonPath).mtimeMs;
42
+ const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
43
+ return { version: typeof pkg.version === "string" ? pkg.version : undefined, mtimeMs };
44
+ } catch {
45
+ return {};
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Resolves `name`'s real installed package.json via Node's own module
51
+ * resolution (see smoke.ts's resolveDependencyModulesDir -- correct for
52
+ * both a nested and a hoisted layout, never executes anything) starting
53
+ * from `fromDir`, then records its version/mtime. undefined when `name`
54
+ * isn't resolvable from here at all -- never thrown, so one unresolvable
55
+ * watched name never prevents capturing the rest.
56
+ */
57
+ export function captureLoadedModule(fromDir: string, name: string): LoadedModuleSnapshot | undefined {
58
+ const dir = resolveDependencyModulesDir(fromDir, name);
59
+ if (!dir) return undefined;
60
+ const packageJsonPath = join(dir, "package.json");
61
+ return { name, packageJsonPath, ...readVersionAndMtime(packageJsonPath) };
62
+ }
63
+
64
+ /** Bulk capture, skipping (not failing on) any name that isn't resolvable. */
65
+ export function captureLoadedModules(fromDir: string, names: readonly string[]): LoadedModuleSnapshot[] {
66
+ const snapshots: LoadedModuleSnapshot[] = [];
67
+ for (const name of names) {
68
+ const snapshot = captureLoadedModule(fromDir, name);
69
+ if (snapshot) snapshots.push(snapshot);
70
+ }
71
+ return snapshots;
72
+ }
73
+
74
+ export function checkModuleFreshness(snapshot: LoadedModuleSnapshot): ModuleFreshnessDiagnostic {
75
+ const current = readVersionAndMtime(snapshot.packageJsonPath);
76
+ const stale = snapshot.mtimeMs !== undefined && current.mtimeMs !== undefined && current.mtimeMs !== snapshot.mtimeMs;
77
+ return { name: snapshot.name, loadedVersion: snapshot.version, currentVersion: current.version, stale };
78
+ }
79
+
80
+ export function checkModuleFreshnessAll(snapshots: readonly LoadedModuleSnapshot[]): ModuleFreshnessDiagnostic[] {
81
+ return snapshots.map(checkModuleFreshness);
82
+ }
83
+
84
+ /** The exact set of names worth watching for a long-running process: its
85
+ * own direct runtime dependencies (package.json's "dependencies" field) --
86
+ * the ones a static top-level import actually loads once at process start.
87
+ * devDependencies/peerDependencies are never loaded by the running process
88
+ * itself, so watching them would only ever report false staleness. Bounded
89
+ * the same way readPackageDeclarations elsewhere in this codebase bounds
90
+ * an untrusted-shape read -- never throws on a malformed package.json,
91
+ * just reports nothing to watch. */
92
+ export function ownRuntimeDependencyNames(packageJsonPath: string, maxNames = 200): string[] {
93
+ try {
94
+ const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { dependencies?: unknown };
95
+ if (typeof pkg.dependencies !== "object" || pkg.dependencies === null) return [];
96
+ return Object.keys(pkg.dependencies).slice(0, maxNames);
97
+ } catch {
98
+ return [];
99
+ }
100
+ }
@@ -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"];