@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
@@ -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,
@@ -0,0 +1,151 @@
1
+ /**
2
+ * deploy-verify.ts — answers "did a publish/install actually land
3
+ * everywhere it should have" as one deterministic check, instead of the
4
+ * hand-diffing ceremony repeated after nearly every @danypops/* publish:
5
+ * comparing a package's on-disk version at every known install location
6
+ * (Pi's own npm project, Bun's global install cache) against an expected
7
+ * version, plus scanning for a stale nested node_modules "shadow" copy one
8
+ * level below another installed package's own node_modules -- exactly the
9
+ * layout ExecInstaller.reresolveDependencyTree() already fixes for the
10
+ * install path (see install.ts), surfaced here read-only for diagnosis.
11
+ */
12
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ export interface InstallLocation {
17
+ readonly label: string;
18
+ readonly packageJsonPath: string;
19
+ }
20
+
21
+ export interface LocationStatus extends InstallLocation {
22
+ /** undefined when the location has no readable package.json at all. */
23
+ readonly version?: string;
24
+ readonly mtimeMs?: number;
25
+ readonly present: boolean;
26
+ /** Only meaningful when present -- false whenever present is false too. */
27
+ readonly upToDate: boolean;
28
+ }
29
+
30
+ export interface DeployVerification {
31
+ readonly packageName: string;
32
+ readonly expectedVersion?: string;
33
+ readonly locations: readonly LocationStatus[];
34
+ readonly shadowCopies: readonly LocationStatus[];
35
+ /**
36
+ * false when expectedVersion is unknown, a *present* known location is
37
+ * behind it, or a shadow copy exists at all (a shadow copy is always a
38
+ * problem regardless of its own version -- it can shadow the correct
39
+ * top-level install for whichever importer's own node_modules walk
40
+ * reaches it first). A location simply being absent (e.g. this package
41
+ * was never installed via `bun install -g`) never fails this by
42
+ * itself -- only a present-but-stale copy does.
43
+ */
44
+ readonly ok: boolean;
45
+ }
46
+
47
+ function readVersionAndMtime(packageJsonPath: string): { version?: string; mtimeMs?: number } {
48
+ try {
49
+ const mtimeMs = statSync(packageJsonPath).mtimeMs;
50
+ const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
51
+ return { version: typeof pkg.version === "string" ? pkg.version : undefined, mtimeMs };
52
+ } catch {
53
+ return {};
54
+ }
55
+ }
56
+
57
+ function statusOf(location: InstallLocation, expectedVersion: string | undefined): LocationStatus {
58
+ const { version, mtimeMs } = readVersionAndMtime(location.packageJsonPath);
59
+ return { ...location, version, mtimeMs, present: version !== undefined, upToDate: version !== undefined && version === expectedVersion };
60
+ }
61
+
62
+ /** Every location Packed itself installs an npm-sourced package into --
63
+ * Pi's own npm project (piHome/npm) and Bun's own global install cache
64
+ * (used by `bun install -g`/bunx-adjacent flows, not something Packed
65
+ * writes to itself, but a real place a stale copy can quietly linger).
66
+ * `home` defaults to the real home directory; injectable so a test never
67
+ * has to read or write outside its own tmpdir fixture. */
68
+ export function standardInstallLocations(piHome: string, packageName: string, home: string = homedir()): InstallLocation[] {
69
+ return [
70
+ { label: "Pi npm project", packageJsonPath: join(piHome, "npm", "node_modules", packageName, "package.json") },
71
+ {
72
+ label: "Bun global install cache",
73
+ packageJsonPath: join(home, ".cache", ".bun", "install", "global", "node_modules", packageName, "package.json"),
74
+ },
75
+ ];
76
+ }
77
+
78
+ /** Bounded to Pi npm project's own direct children (and one level into a
79
+ * scope directory) -- the exact shape ExecInstaller.reresolveDependencyTree()
80
+ * fixes for the install path: a sibling package's own node_modules holding
81
+ * a nested copy of a shared dependency that a targeted `pi update`/`pi
82
+ * install` never reaches, only a full-tree `npm install` does. Never
83
+ * descends into a shadow copy's own node_modules -- one level is the real,
84
+ * confirmed-live shape; deeper nesting is npm's own problem to dedupe. */
85
+ export function findShadowCopies(piHome: string, packageName: string): InstallLocation[] {
86
+ const npmNodeModulesDir = join(piHome, "npm", "node_modules");
87
+ const found: InstallLocation[] = [];
88
+ let entries: string[];
89
+ try {
90
+ entries = readdirSync(npmNodeModulesDir);
91
+ } catch {
92
+ return found;
93
+ }
94
+ for (const entry of entries) {
95
+ if (entry === packageName || entry.startsWith(".")) continue;
96
+ const entryPath = join(npmNodeModulesDir, entry);
97
+ let isDirectory: boolean;
98
+ try {
99
+ isDirectory = statSync(entryPath).isDirectory();
100
+ } catch {
101
+ continue;
102
+ }
103
+ if (!isDirectory) continue;
104
+ if (entry.startsWith("@")) {
105
+ let scoped: string[];
106
+ try {
107
+ scoped = readdirSync(entryPath);
108
+ } catch {
109
+ continue;
110
+ }
111
+ for (const sub of scoped) {
112
+ const nested = join(entryPath, sub, "node_modules", packageName, "package.json");
113
+ if (existsSync(nested)) found.push({ label: `shadow copy under ${entry}/${sub}`, packageJsonPath: nested });
114
+ }
115
+ continue;
116
+ }
117
+ const nested = join(entryPath, "node_modules", packageName, "package.json");
118
+ if (existsSync(nested)) found.push({ label: `shadow copy under ${entry}`, packageJsonPath: nested });
119
+ }
120
+ return found;
121
+ }
122
+
123
+ /**
124
+ * `expectedVersion` defaults to whatever's resolved at Pi's own npm
125
+ * project -- the natural "it was just installed/updated there, does every
126
+ * OTHER known location and shadow copy agree" question this exists to
127
+ * answer without a network round-trip. Pass one explicitly (e.g. the
128
+ * version a publish just produced) to check against that instead.
129
+ */
130
+ export function verifyDeploy(piHome: string, packageName: string, expectedVersion?: string, home: string = homedir()): DeployVerification {
131
+ const locations0 = standardInstallLocations(piHome, packageName, home);
132
+ const resolvedExpected = expectedVersion ?? readVersionAndMtime(locations0[0]!.packageJsonPath).version;
133
+ const locations = locations0.map((location) => statusOf(location, resolvedExpected));
134
+ const shadowCopies = findShadowCopies(piHome, packageName).map((location) => statusOf(location, resolvedExpected));
135
+ const ok = resolvedExpected !== undefined && locations.every((l) => !l.present || l.upToDate) && shadowCopies.length === 0;
136
+ return { packageName, expectedVersion: resolvedExpected, locations, shadowCopies, ok };
137
+ }
138
+
139
+ export function formatDeployVerification(report: DeployVerification, json: boolean): string {
140
+ if (json) return `${JSON.stringify(report)}\n`;
141
+ let out = `${report.ok ? "PASS" : "FAIL"} — ${report.packageName}${report.expectedVersion ? `@${report.expectedVersion}` : " (version unknown -- not found at the primary location either)"}\n`;
142
+ for (const location of report.locations) {
143
+ const state = !location.present ? "MISSING" : location.upToDate ? "ok" : `STALE (${location.version})`;
144
+ out += ` ${state.padEnd(16)} ${location.label} (${location.packageJsonPath})\n`;
145
+ }
146
+ for (const shadow of report.shadowCopies) {
147
+ out += ` SHADOW COPY ${shadow.label} at ${shadow.version ?? "unknown version"} (${shadow.packageJsonPath})\n`;
148
+ }
149
+ if (report.shadowCopies.length === 0) out += " no shadow copies found\n";
150
+ return out;
151
+ }
@@ -8,8 +8,10 @@ import {
8
8
  type InstallValidationResult,
9
9
  type InstallValidator,
10
10
  } from "../adoption/install-validation.ts";
11
+ import { applyEntryFilters, captureEntryFilters, resolveToggleSettingsPath } from "./resources.ts";
11
12
  import { createLogger } from "../shared/log.ts";
12
- import { defaultPiHome, isPinnedNpmSource, readResolvedVersion } from "./installed.ts";
13
+ import { defaultPiHome, isPinnedNpmSource, npmPackageName, readPackageDeclarations, readResolvedVersion } from "./installed.ts";
14
+ import { isNewer } from "./package.ts";
13
15
  import type { Installer, UpdateOutcome } from "./package.ts";
14
16
 
15
17
  function round1(ms: number): number {
@@ -44,6 +46,14 @@ export class ExecInstaller implements Installer {
44
46
  * against a real daemon instead of guessing from wall-clock totals alone.
45
47
  */
46
48
  private readonly logger: Logger = createLogger("install"),
49
+ /**
50
+ * Optional real registry-mirror lookup (see daemon/watcher.ts's own
51
+ * checkUpdates(), which this shares its cross-check logic with via
52
+ * package.ts's isNewer) -- undefined by default so every existing
53
+ * caller/test keeps its old "already up to date means nothing to
54
+ * report" behavior unless a daemon explicitly wires its own mirror in.
55
+ */
56
+ private readonly latestOf?: (name: string) => string | undefined,
47
57
  ) {}
48
58
 
49
59
  private async run(args: string[]): Promise<string> {
@@ -141,7 +151,8 @@ export class ExecInstaller implements Installer {
141
151
  return this.run(["remove", ...(options?.local ? ["-l"] : []), source]);
142
152
  }
143
153
 
144
- async update(source: string, _options?: { approved?: boolean; local?: boolean }): Promise<UpdateOutcome> {
154
+ async update(source: string, options?: { approved?: boolean; local?: boolean; target?: string }): Promise<UpdateOutcome> {
155
+ if (options?.target) return this.replace(source, options.target, options);
145
156
  const t0 = performance.now();
146
157
  const pinned = isPinnedNpmSource(source);
147
158
  const previousVersion = readResolvedVersion(this.piHome, source);
@@ -165,6 +176,112 @@ export class ExecInstaller implements Installer {
165
176
  // have changed" signal instead of falsely claiming it didn't.
166
177
  const knowsBoth = previousVersion !== undefined && currentVersion !== undefined;
167
178
  const changed = !knowsBoth || previousVersion !== currentVersion;
168
- return { output, reloadRequired: changed, alreadyUpToDate: !changed, pinned, previousVersion, currentVersion };
179
+ const alreadyUpToDate = !changed;
180
+ // Only worth cross-checking when there's actually a "nothing changed"
181
+ // conclusion to double-check, a real mirror lookup was wired in, and
182
+ // there's a real bare npm name + resolved version to compare against.
183
+ const name = alreadyUpToDate && this.latestOf ? npmPackageName(source) : undefined;
184
+ const latest = name && currentVersion ? this.latestOf?.(name) : undefined;
185
+ const outOfRangeUpdateAvailable = latest && currentVersion && isNewer(latest, currentVersion) ? latest : undefined;
186
+ return {
187
+ output,
188
+ reloadRequired: changed,
189
+ alreadyUpToDate,
190
+ pinned,
191
+ previousVersion,
192
+ currentVersion,
193
+ ...(outOfRangeUpdateAvailable ? { outOfRangeUpdateAvailable } : {}),
194
+ // A plain update() with no target can never move an exact pin --
195
+ // `pi update --extension` itself intentionally skips it (npm's own
196
+ // documented behavior for a versioned spec). alreadyUpToDate/pinned
197
+ // above are already an honest, non-"false success" result; this adds
198
+ // an unambiguous machine-readable nudge toward options.target instead
199
+ // of a caller having to infer "pinned AND alreadyUpToDate means stuck"
200
+ // itself. See replace() for the actual supervised pin-move workflow.
201
+ ...(pinned && alreadyUpToDate ? { pinnedSourceRequiresTarget: true } : {}),
202
+ };
203
+ }
204
+
205
+ /**
206
+ * The supervised "move a pin" workflow `pi update --extension` can never
207
+ * perform by itself (it intentionally skips an exact pin) -- see the
208
+ * linked research doc (pinned-package-update-behavior-and-safe-
209
+ * replacement-research): a bare `pi install <newSource>` does not
210
+ * rewrite an already-configured entry for the same package, it requires
211
+ * an explicit remove-then-install sequence. Reuses install()/remove()
212
+ * (the same supported, already-tested primitives) rather than
213
+ * hand-rolling new subprocess calls, so validation/reresolve/timing
214
+ * behave identically to every other mutation.
215
+ *
216
+ * Order of operations: identity check (never touches disk on mismatch)
217
+ * -> capture `fromSource`'s own filter overrides (extensions/skills/
218
+ * prompts/themes) so remove() doesn't silently discard them -> remove
219
+ * `fromSource` -> install `toSource`, rolling back to `fromSource` on
220
+ * failure -> re-apply the captured filters onto the new entry -> verify
221
+ * BOTH postconditions (installed: readResolvedVersion; configured:
222
+ * present in settings.json) before reporting success.
223
+ */
224
+ private async replace(fromSource: string, toSource: string, options?: { approved?: boolean; local?: boolean }): Promise<UpdateOutcome> {
225
+ const fromName = npmPackageName(fromSource);
226
+ const toName = npmPackageName(toSource);
227
+ if (!fromName || !toName || fromName !== toName) {
228
+ throw new Error(`replace target must be the same package as the configured source: ${fromSource} -> ${toSource}`);
229
+ }
230
+ const settingsPath = resolveToggleSettingsPath(this.piHome);
231
+ const before = { source: fromSource, version: readResolvedVersion(this.piHome, fromSource) };
232
+ const filters = captureEntryFilters(settingsPath, fromSource);
233
+
234
+ let removeOutput: string;
235
+ try {
236
+ removeOutput = await this.remove(fromSource, options);
237
+ } catch (e) {
238
+ throw new Error(`replace failed removing ${fromSource}: ${e instanceof Error ? e.message : String(e)}`);
239
+ }
240
+
241
+ let installOutput: string;
242
+ try {
243
+ installOutput = await this.install(toSource, options);
244
+ } catch (installError) {
245
+ const installMessage = installError instanceof Error ? installError.message : String(installError);
246
+ let rollback: NonNullable<UpdateOutcome["rollback"]>;
247
+ try {
248
+ await this.install(fromSource, options);
249
+ rollback = { attempted: true, ok: true };
250
+ } catch (rollbackError) {
251
+ rollback = {
252
+ attempted: true,
253
+ ok: false,
254
+ message: rollbackError instanceof Error ? rollbackError.message : String(rollbackError),
255
+ };
256
+ }
257
+ const rollbackNote = rollback.ok
258
+ ? `rolled back to ${fromSource}`
259
+ : `ROLLBACK TO ${fromSource} ALSO FAILED: ${rollback.message} -- ${fromName} may now be uninstalled entirely, reinstall manually`;
260
+ throw new Error(`replace failed installing ${toSource}: ${installMessage} (${rollbackNote})`);
261
+ }
262
+
263
+ await applyEntryFilters(settingsPath, toSource, filters);
264
+
265
+ const afterVersion = readResolvedVersion(this.piHome, toSource);
266
+ const configuredAfter = readPackageDeclarations(this.piHome).includes(toSource);
267
+ if (!configuredAfter) {
268
+ // install() itself reported success, but settings.json doesn't show
269
+ // toSource configured -- a real postcondition failure, not merely a
270
+ // version mismatch. Never claim success on an unverified replace.
271
+ throw new Error(`replace installed ${toSource} but settings.json does not show it configured -- verify manually before retrying`);
272
+ }
273
+ const after = { source: toSource, version: afterVersion };
274
+ const changed = before.version !== after.version || before.source !== after.source;
275
+ return {
276
+ output: `${removeOutput}\n${installOutput}`.trim(),
277
+ reloadRequired: changed,
278
+ alreadyUpToDate: !changed,
279
+ pinned: isPinnedNpmSource(toSource),
280
+ previousVersion: before.version,
281
+ currentVersion: after.version,
282
+ replaced: true,
283
+ before,
284
+ after,
285
+ };
169
286
  }
170
287
  }
@@ -11,6 +11,27 @@
11
11
  // itself stays a pure port: nothing here creates a runtime dependency on install-validation.ts's
12
12
  // own npm-pack/tar/subprocess machinery.
13
13
  import type { InstallValidationResult } from "../adoption/install-validation.ts";
14
+ import { gt, valid } from "semver";
15
+
16
+ /**
17
+ * True drift only: `latest` is a real semver step *ahead* of `have`, never
18
+ * merely different from it. A plain !== check cannot distinguish "have is
19
+ * behind latest" from "have is already ahead of a stale/wrong latest" --
20
+ * confirmed live via the watcher's own version of this bug: an installed
21
+ * package whose version had already passed the daemon's mirrored
22
+ * dist-tags.latest kept showing a permanent, un-clearable "update
23
+ * available" badge pointing at an *older* version. Falls back to the old
24
+ * inequality only when either side isn't parseable semver (a git ref, a
25
+ * literal "latest" tag, etc.) -- those aren't comparable at all, so
26
+ * "different" is the only signal left. Lives here (not watcher.ts, its
27
+ * original home) so both the daemon's watcher and ExecInstaller.update()'s
28
+ * own out-of-range check share one implementation without packages/
29
+ * reaching into daemon/ (the dependency already runs the other way).
30
+ */
31
+ export function isNewer(latest: string, have: string): boolean {
32
+ if (valid(latest, { loose: true }) && valid(have, { loose: true })) return gt(latest, have, { loose: true });
33
+ return latest !== have;
34
+ }
14
35
 
15
36
  export type PackageVerification = "keyword-only" | "manifest" | "conventional";
16
37
 
@@ -108,13 +129,58 @@ export interface UpdateOutcome {
108
129
  pinned: boolean;
109
130
  previousVersion?: string;
110
131
  currentVersion?: string;
132
+ /**
133
+ * Set only when alreadyUpToDate is true AND a real registry version
134
+ * newer than currentVersion exists outside the source's own declared
135
+ * caret/range -- confirmed live: a 0.x package (e.g. ^0.9.8) that
136
+ * crossed its own minor boundary (0.10.0 published) reported a bare
137
+ * "already up to date" with no clue a newer version existed at all.
138
+ * `pi update --extension` is itself bound by the declared range, so
139
+ * "nothing changed" alone can't distinguish "genuinely current" from
140
+ * "current within a range that's now stale" -- this cross-checks
141
+ * against the same registry-mirror source of truth checkUpdates()
142
+ * already uses, independent of the declared range.
143
+ */
144
+ outOfRangeUpdateAvailable?: string;
145
+ /**
146
+ * True only when update() was called on an exact npm pin with no
147
+ * options.target -- `pi update --extension` intentionally leaves an
148
+ * exact pin unchanged (npm's own documented behavior for a versioned
149
+ * spec), so alreadyUpToDate/pinned above are already an honest,
150
+ * non-"false success" result. This is an additional stable,
151
+ * unambiguous machine-readable signal (kept alongside those existing
152
+ * fields for back-compat) that a caller wanting to actually MOVE the
153
+ * pin should retry with options.target set -- see replaced/before/
154
+ * after/rollback below.
155
+ */
156
+ pinnedSourceRequiresTarget?: boolean;
157
+ /**
158
+ * True when options.target was given and update() routed to the
159
+ * supervised replace workflow (remove the old exact source, install
160
+ * the new one, verify both configured and installed postconditions,
161
+ * attempt rollback on failure) instead of `pi update --extension`.
162
+ */
163
+ replaced?: boolean;
164
+ before?: { source: string; version?: string };
165
+ after?: { source: string; version?: string };
166
+ /** Present only on a replace failure -- whether restoring the original
167
+ * source was attempted and whether that restoration itself succeeded. */
168
+ rollback?: { attempted: boolean; ok: boolean; message?: string };
111
169
  }
112
170
 
113
171
  /** Driven port: pi CLI mutations. */
114
172
  export interface Installer {
115
173
  install(source: string, options?: { approved?: boolean; local?: boolean }): Promise<string>;
116
174
  remove(source: string, options?: { approved?: boolean; local?: boolean }): Promise<string>;
117
- update(source: string, options?: { approved?: boolean; local?: boolean }): Promise<UpdateOutcome>;
175
+ /**
176
+ * `options.target`, when given, replaces `source` (an exact pin or not)
177
+ * with `target` through a supervised remove+install workflow instead of
178
+ * `pi update --extension` -- see ExecInstaller.update()'s own doc
179
+ * comment and the linked research doc
180
+ * (pinned-package-update-behavior-and-safe-replacement-research) for
181
+ * why a plain `pi update` can never move an exact pin by itself.
182
+ */
183
+ update(source: string, options?: { approved?: boolean; local?: boolean; target?: string }): Promise<UpdateOutcome>;
118
184
  /**
119
185
  * Optional batch-oriented split of install(), for a caller installing several packages
120
186
  * together (see SetupManager.apply()) -- a single ad hoc install() still does all three