@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
@@ -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
@@ -201,3 +201,66 @@ export async function toggleResource(input: ToggleResourceInput): Promise<{ ok:
201
201
  }
202
202
  return { ok: true };
203
203
  }
204
+
205
+ /**
206
+ * Captures a configured package entry's own filter overrides
207
+ * (extensions/skills/prompts/themes +/-path arrays -- see toggleResource's
208
+ * own doc comment) before a replace/update-to-target operation removes it
209
+ * outright: a bare `pi remove` + `pi install` sequence to bump a pinned
210
+ * version replaces the WHOLE settings.json entry, silently reverting any
211
+ * per-resource enable/disable customization the user had configured back
212
+ * to the freshly-installed package's own defaults. undefined when the
213
+ * entry has no filter overrides at all (nothing to preserve) or isn't
214
+ * found (already removed, or never existed as a real entry).
215
+ */
216
+ export function captureEntryFilters(settingsPath: string, source: string): Partial<Record<ResourceField, string[]>> | undefined {
217
+ const entry = readSettingsPackages(settingsPath).find((candidate) => candidate.source === source);
218
+ if (!entry) return undefined;
219
+ const filters: Partial<Record<ResourceField, string[]>> = {};
220
+ for (const field of RESOURCE_FIELDS) {
221
+ const value = entry[field];
222
+ if (value) filters[field] = value;
223
+ }
224
+ return Object.keys(filters).length > 0 ? filters : undefined;
225
+ }
226
+
227
+ /**
228
+ * Re-applies a previously captured set of filter overrides onto `source`'s
229
+ * own freshly-created package entry -- the other half of
230
+ * captureEntryFilters, restoring what a replace operation's remove step
231
+ * would otherwise have discarded. A no-op (never throws) when `filters` is
232
+ * undefined/empty, the settings file is unreadable, or `source`'s entry
233
+ * doesn't exist yet (e.g. the install step itself failed and never wrote
234
+ * one) -- there is nothing safe to merge a filter override onto in that
235
+ * case, and the caller's own install failure is already the real error to
236
+ * surface.
237
+ */
238
+ export async function applyEntryFilters(
239
+ settingsPath: string,
240
+ source: string,
241
+ filters: Partial<Record<ResourceField, string[]>> | undefined,
242
+ ): Promise<boolean> {
243
+ if (!filters || Object.keys(filters).length === 0) return true; // nothing to preserve is not a failure
244
+ let settings: Record<string, unknown>;
245
+ try {
246
+ settings = JSON.parse(readFileSync(settingsPath, "utf8"));
247
+ } catch {
248
+ return false;
249
+ }
250
+ const packages = Array.isArray(settings.packages) ? [...settings.packages] : [];
251
+ const index = packages.findIndex((raw) => parseEntry(raw)?.source === source);
252
+ if (index === -1) return false;
253
+ const merged: Record<string, unknown> =
254
+ typeof packages[index] === "string" ? { source: packages[index] } : { ...(packages[index] as Record<string, unknown>) };
255
+ for (const [field, value] of Object.entries(filters)) merged[field] = value;
256
+ packages[index] = merged;
257
+ try {
258
+ await atomicWriteJson(settingsPath, { ...settings, packages });
259
+ return true;
260
+ } catch {
261
+ // best-effort: the replace itself already succeeded, a filter-preservation
262
+ // write failure here is surfaced to the caller as a non-fatal detail, not
263
+ // a reason to report the whole replace as failed.
264
+ return false;
265
+ }
266
+ }
@@ -55,7 +55,7 @@ export interface PackedExtensionClient {
55
55
  install(source: string, approved?: boolean): Promise<string>;
56
56
  installService(source: string, approved?: boolean): Promise<{ output: string; spec?: ServiceSpecSummary }>;
57
57
  remove(name: string, approved?: boolean): Promise<string>;
58
- update(source: string, approved?: boolean): Promise<UpdateOutcome>;
58
+ update(source: string, approved?: boolean, target?: string): Promise<UpdateOutcome>;
59
59
  setupPlan(manifestPath: string, prune?: boolean): Promise<SetupPlan>;
60
60
  setupApply(manifestPath: string, approved?: boolean, prune?: boolean): Promise<SetupApplyResult>;
61
61
  listResources(projectRoot?: string): Promise<{ global: PackageResources[]; project: PackageResources[] }>;
@@ -176,8 +176,8 @@ export class PackedClient implements PackedExtensionClient {
176
176
  if (!result.ok) throw new Error(result.output);
177
177
  return result.output;
178
178
  }
179
- async update(source: string, approved = false): Promise<UpdateOutcome> {
180
- const result = await this.call("package.update", { source, approved });
179
+ async update(source: string, approved = false, target?: string): Promise<UpdateOutcome> {
180
+ const result = await this.call("package.update", { source, approved, target });
181
181
  if (!result.ok) throw new Error(result.output);
182
182
  return {
183
183
  output: result.output,
@@ -186,6 +186,12 @@ export class PackedClient implements PackedExtensionClient {
186
186
  pinned: result.pinned ?? false,
187
187
  previousVersion: result.previousVersion,
188
188
  currentVersion: result.currentVersion,
189
+ outOfRangeUpdateAvailable: result.outOfRangeUpdateAvailable,
190
+ pinnedSourceRequiresTarget: result.pinnedSourceRequiresTarget,
191
+ replaced: result.replaced,
192
+ before: result.before,
193
+ after: result.after,
194
+ rollback: result.rollback,
189
195
  };
190
196
  }
191
197
  setupPlan(manifestPath: string, prune = false) {
@@ -53,6 +53,12 @@ export interface UpdateOutcome {
53
53
  pinned: boolean;
54
54
  previousVersion?: string;
55
55
  currentVersion?: string;
56
+ outOfRangeUpdateAvailable?: string;
57
+ pinnedSourceRequiresTarget?: boolean;
58
+ replaced?: boolean;
59
+ before?: { source: string; version?: string };
60
+ after?: { source: string; version?: string };
61
+ rollback?: { attempted: boolean; ok: boolean; message?: string };
56
62
  }
57
63
  export interface Diagnostic {
58
64
  code: string;
@@ -141,7 +147,7 @@ export interface ExtensionOperationInputs {
141
147
  "package.install": { source: string; approved?: boolean };
142
148
  "package.install_service": { source: string; approved?: boolean };
143
149
  "package.remove": { name: string; approved?: boolean };
144
- "package.update": { source: string; approved?: boolean };
150
+ "package.update": { source: string; approved?: boolean; target?: string };
145
151
  "setup.plan": { manifestPath: string; prune?: boolean };
146
152
  "setup.apply": { manifestPath: string; approved?: boolean; prune?: boolean };
147
153
  "resources.list": { projectRoot?: string };
@@ -165,6 +171,12 @@ export interface ExtensionOperationOutputs {
165
171
  pinned?: boolean;
166
172
  previousVersion?: string;
167
173
  currentVersion?: string;
174
+ outOfRangeUpdateAvailable?: string;
175
+ pinnedSourceRequiresTarget?: boolean;
176
+ replaced?: boolean;
177
+ before?: { source: string; version?: string };
178
+ after?: { source: string; version?: string };
179
+ rollback?: { attempted: boolean; ok: boolean; message?: string };
168
180
  };
169
181
  "setup.plan": SetupPlan;
170
182
  "setup.apply": SetupApplyResult;
@@ -59,9 +59,11 @@ class FakeInstaller implements Installer {
59
59
  this.approved = options?.approved === true;
60
60
  return `Removed ${source}`;
61
61
  }
62
- async update(source: string, options?: { approved?: boolean }): Promise<UpdateOutcome> {
62
+ gotTarget: string | undefined;
63
+ async update(source: string, options?: { approved?: boolean; target?: string }): Promise<UpdateOutcome> {
63
64
  this.updated = source;
64
65
  this.approved = options?.approved === true;
66
+ this.gotTarget = options?.target;
65
67
  return {
66
68
  output: `Updated ${source}`,
67
69
  reloadRequired: true,
@@ -594,6 +596,52 @@ describe("CLI", () => {
594
596
  });
595
597
  });
596
598
 
599
+ it("update --to threads the replacement target through to the installer and reports it in both human and JSON output", async () => {
600
+ const d = deps({
601
+ inst: (() => {
602
+ const fake = new FakeInstaller();
603
+ fake.updateOutcome = {
604
+ replaced: true,
605
+ before: { source: "npm:@scope/pkg@1.0.0", version: "1.0.0" },
606
+ after: { source: "npm:@scope/pkg@2.0.0", version: "2.0.0" },
607
+ };
608
+ return fake;
609
+ })(),
610
+ });
611
+
612
+ const human = await cliRun(["update", "npm:@scope/pkg@1.0.0", "--to", "npm:@scope/pkg@2.0.0", "--approve"], d);
613
+ expect(human.code).toBe(0);
614
+ expect(human.out).toContain("replaced npm:@scope/pkg@1.0.0 with npm:@scope/pkg@2.0.0");
615
+ expect((d.inst as FakeInstaller).gotTarget).toBe("npm:@scope/pkg@2.0.0");
616
+
617
+ const json = await cliRun(["update", "npm:@scope/pkg@1.0.0", "--to", "npm:@scope/pkg@2.0.0", "--approve", "--json"], d);
618
+ const parsed = JSON.parse(json.out);
619
+ expect(parsed.replaced).toBe(true);
620
+ expect(parsed.before).toEqual({ source: "npm:@scope/pkg@1.0.0", version: "1.0.0" });
621
+ expect(parsed.after).toEqual({ source: "npm:@scope/pkg@2.0.0", version: "2.0.0" });
622
+ });
623
+
624
+ it("update --to rejects an invalid target source with a usage error, before ever calling the installer", async () => {
625
+ const d = deps();
626
+ const result = await cliRun(["update", "npm:@scope/pkg@1.0.0", "--to", "not a valid source!", "--approve"], d);
627
+ expect(result.code).toBe(2);
628
+ expect((d.inst as FakeInstaller).updated).toBe("");
629
+ });
630
+
631
+ it("reports pinnedSourceRequiresTarget and mentions --to in the human-readable pinned message", async () => {
632
+ const d = deps({
633
+ inst: (() => {
634
+ const fake = new FakeInstaller();
635
+ fake.updateOutcome = { alreadyUpToDate: true, reloadRequired: false, pinned: true, pinnedSourceRequiresTarget: true };
636
+ return fake;
637
+ })(),
638
+ });
639
+
640
+ const human = await cliRun(["update", "npm:@scope/pkg@1.0.0", "--approve"], d);
641
+ expect(human.out).toContain("--to");
642
+ const json = await cliRun(["update", "npm:@scope/pkg@1.0.0", "--approve", "--json"], d);
643
+ expect(JSON.parse(json.out).pinnedSourceRequiresTarget).toBe(true);
644
+ });
597
645
 
598
646
  it("update --self requires approval under the guarded default, same as every other mutation", async () => {
599
647
  const d = deps({
@@ -947,6 +995,32 @@ describe("CLI", () => {
947
995
  ]);
948
996
  });
949
997
 
998
+ it("verify-deploy runs standalone without a daemon, reporting stale/missing/shadow locations for a real on-disk layout", async () => {
999
+ const piHome = track(mkdtempSync(join(tmpdir(), "packed-verify-deploy-cli-")));
1000
+ const pkgDir = join(piHome, "npm", "node_modules", "@scope", "pkg");
1001
+ mkdirSync(pkgDir, { recursive: true });
1002
+ writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ name: "@scope/pkg", version: "1.2.3" }));
1003
+ const d = deps({ piHome });
1004
+
1005
+ const jsonResult = await cliRun(["verify-deploy", "@scope/pkg", "--json"], d);
1006
+ expect(jsonResult.code).toBe(0);
1007
+ const parsed = JSON.parse(jsonResult.out);
1008
+ expect(parsed.ok).toBe(true);
1009
+ expect(parsed.packageName).toBe("@scope/pkg");
1010
+ expect(parsed.expectedVersion).toBe("1.2.3");
1011
+
1012
+ const human = await cliRun(["verify-deploy", "@scope/pkg"], d);
1013
+ expect(human.out).toContain("PASS");
1014
+ expect(human.code).toBe(0);
1015
+
1016
+ const stale = await cliRun(["verify-deploy", "@scope/pkg", "--version", "9.9.9"], d);
1017
+ expect(stale.code).toBe(1);
1018
+ expect(stale.out).toContain("FAIL");
1019
+ expect(stale.out).toContain("STALE (1.2.3)");
1020
+
1021
+ expect((await cliRun(["verify-deploy", "not a valid name!"], d)).code).toBe(2);
1022
+ });
1023
+
950
1024
  it("advisories runs standalone without a daemon and degrades to zero findings, never a real network call, when nothing is installed", async () => {
951
1025
  const d = deps({ piHome: track(mkdtempSync(join(tmpdir(), "packed-advisories-cli-"))) });
952
1026
  const result = await cliRun(["advisories", "--json"], d);