@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.
- package/dist/client.d.ts +2 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1 -1
- package/dist/protocol.d.ts +33 -0
- package/dist/protocol.d.ts.map +1 -1
- package/extension/src/menu-theme.ts +13 -3
- package/extension/src/packed.ts +2 -2
- package/extension/src/tools.ts +24 -8
- package/extension/src/tui.ts +7 -3
- package/package.json +4 -4
- package/service/src/adoption/doctor.ts +15 -0
- package/service/src/adoption/install-validation.ts +97 -5
- package/service/src/adoption/module-freshness.ts +100 -0
- package/service/src/adoption/smoke.ts +26 -2
- package/service/src/cli/cli.ts +30 -5
- package/service/src/daemon/client.ts +13 -7
- package/service/src/daemon/daemon.ts +35 -3
- package/service/src/daemon/service.ts +42 -7
- package/service/src/daemon/vehicle-registration.ts +118 -35
- package/service/src/daemon/watcher.ts +1 -18
- package/service/src/packages/deploy-verify.ts +151 -0
- package/service/src/packages/install.ts +120 -3
- package/service/src/packages/package.ts +67 -1
- package/service/src/packages/resources.ts +63 -0
- package/service/src/public/client.ts +9 -3
- package/service/src/public/protocol.ts +13 -1
- package/service/test/cli.test.ts +75 -1
- package/service/test/deploy-verify.test.ts +168 -0
- package/service/test/doctor.test.ts +104 -1
- package/service/test/fixtures/install-validation/convention-only-package/extensions/index.ts +3 -0
- package/service/test/fixtures/install-validation/convention-only-package/package.json +5 -0
- package/service/test/install-validation.test.ts +14 -1
- package/service/test/install.test.ts +229 -0
- package/service/test/module-freshness.test.ts +146 -0
- package/service/test/resources.test.ts +66 -1
- package/service/test/service.test.ts +68 -1
- package/service/test/smoke.test.ts +49 -2
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/service/test/cli.test.ts
CHANGED
|
@@ -59,9 +59,11 @@ class FakeInstaller implements Installer {
|
|
|
59
59
|
this.approved = options?.approved === true;
|
|
60
60
|
return `Removed ${source}`;
|
|
61
61
|
}
|
|
62
|
-
|
|
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);
|