@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.
- 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/approval/reload.ts +51 -2
- package/extension/src/menu-theme.ts +13 -3
- package/extension/src/packed.ts +2 -2
- package/extension/src/tabs/discover.ts +13 -2
- package/extension/src/tool-output.ts +14 -1
- package/extension/src/tools.ts +46 -14
- 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
|
@@ -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);
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deploy-verify.test.ts — the exact hand-diffing ceremony this module
|
|
3
|
+
* replaces: comparing a package's on-disk version at every known install
|
|
4
|
+
* location plus stale shadow copies, against an expected version.
|
|
5
|
+
*/
|
|
6
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
7
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { findShadowCopies, formatDeployVerification, verifyDeploy } from "../src/packages/deploy-verify.ts";
|
|
11
|
+
|
|
12
|
+
const roots: string[] = [];
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function track(dir: string): string {
|
|
18
|
+
roots.push(dir);
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writePackageAt(packageJsonPath: string, version: string): void {
|
|
23
|
+
mkdirSync(join(packageJsonPath, ".."), { recursive: true });
|
|
24
|
+
writeFileSync(packageJsonPath, JSON.stringify({ version }));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function piHomeWith(name: string, version: string | undefined): string {
|
|
28
|
+
const dir = track(mkdtempSync(join(tmpdir(), "packed-deploy-verify-")));
|
|
29
|
+
if (version !== undefined) writePackageAt(join(dir, "npm", "node_modules", name, "package.json"), version);
|
|
30
|
+
else mkdirSync(join(dir, "npm", "node_modules"), { recursive: true });
|
|
31
|
+
return dir;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("verifyDeploy", () => {
|
|
35
|
+
it("reports ok when the only known location matches the expected version and no shadow copy exists", () => {
|
|
36
|
+
const piHome = piHomeWith("@scope/pkg", "1.2.3");
|
|
37
|
+
|
|
38
|
+
const report = verifyDeploy(piHome, "@scope/pkg", "1.2.3");
|
|
39
|
+
|
|
40
|
+
expect(report.ok).toBe(true);
|
|
41
|
+
expect(report.expectedVersion).toBe("1.2.3");
|
|
42
|
+
expect(report.locations[0]!.upToDate).toBe(true);
|
|
43
|
+
expect(report.shadowCopies).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("defaults expectedVersion to whatever's resolved at the primary (Pi npm project) location when none is given", () => {
|
|
47
|
+
const piHome = piHomeWith("@scope/pkg", "2.0.0");
|
|
48
|
+
|
|
49
|
+
const report = verifyDeploy(piHome, "@scope/pkg");
|
|
50
|
+
|
|
51
|
+
expect(report.expectedVersion).toBe("2.0.0");
|
|
52
|
+
expect(report.ok).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("reports not-ok and version-unknown when the package isn't found anywhere and no expected version was given", () => {
|
|
56
|
+
const piHome = piHomeWith("@scope/pkg", undefined);
|
|
57
|
+
|
|
58
|
+
const report = verifyDeploy(piHome, "@scope/other-pkg");
|
|
59
|
+
|
|
60
|
+
expect(report.expectedVersion).toBeUndefined();
|
|
61
|
+
expect(report.ok).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("flags a location still on an older version as stale, not just missing", () => {
|
|
65
|
+
const piHome = piHomeWith("@scope/pkg", "1.0.0");
|
|
66
|
+
const fakeHome = track(mkdtempSync(join(tmpdir(), "packed-deploy-verify-home-")));
|
|
67
|
+
writePackageAt(join(fakeHome, ".cache", ".bun", "install", "global", "node_modules", "@scope/pkg", "package.json"), "0.9.0");
|
|
68
|
+
|
|
69
|
+
const report = verifyDeploy(piHome, "@scope/pkg", "1.0.0", fakeHome);
|
|
70
|
+
|
|
71
|
+
const bunLocation = report.locations.find((l) => l.label === "Bun global install cache")!;
|
|
72
|
+
expect(bunLocation.present).toBe(true);
|
|
73
|
+
expect(bunLocation.upToDate).toBe(false);
|
|
74
|
+
expect(bunLocation.version).toBe("0.9.0");
|
|
75
|
+
expect(report.ok).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("findShadowCopies — the exact layout ExecInstaller.reresolveDependencyTree() already fixes for the install path", () => {
|
|
80
|
+
it("finds a nested copy under an unscoped sibling package's own node_modules", () => {
|
|
81
|
+
const piHome = piHomeWith("shared", "2.0.0");
|
|
82
|
+
writePackageAt(join(piHome, "npm", "node_modules", "leaf", "node_modules", "shared", "package.json"), "1.0.0");
|
|
83
|
+
|
|
84
|
+
const shadows = findShadowCopies(piHome, "shared");
|
|
85
|
+
|
|
86
|
+
expect(shadows).toHaveLength(1);
|
|
87
|
+
expect(shadows[0]!.label).toBe("shadow copy under leaf");
|
|
88
|
+
expect(shadows[0]!.packageJsonPath).toBe(join(piHome, "npm", "node_modules", "leaf", "node_modules", "shared", "package.json"));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("finds a nested copy one level under a scoped sibling package", () => {
|
|
92
|
+
const piHome = piHomeWith("shared", "2.0.0");
|
|
93
|
+
writePackageAt(join(piHome, "npm", "node_modules", "@scope", "leaf", "node_modules", "shared", "package.json"), "1.0.0");
|
|
94
|
+
|
|
95
|
+
const shadows = findShadowCopies(piHome, "shared");
|
|
96
|
+
|
|
97
|
+
expect(shadows).toHaveLength(1);
|
|
98
|
+
expect(shadows[0]!.label).toBe("shadow copy under @scope/leaf");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("finds nothing when the tree is clean", () => {
|
|
102
|
+
const piHome = piHomeWith("shared", "2.0.0");
|
|
103
|
+
mkdirSync(join(piHome, "npm", "node_modules", "unrelated"), { recursive: true });
|
|
104
|
+
|
|
105
|
+
expect(findShadowCopies(piHome, "shared")).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("never descends into a shadow copy's own node_modules", () => {
|
|
109
|
+
const piHome = piHomeWith("shared", "2.0.0");
|
|
110
|
+
writePackageAt(join(piHome, "npm", "node_modules", "leaf", "node_modules", "shared", "package.json"), "1.0.0");
|
|
111
|
+
// A second-level nested copy inside the shadow copy itself -- out of scope.
|
|
112
|
+
writePackageAt(
|
|
113
|
+
join(piHome, "npm", "node_modules", "leaf", "node_modules", "shared", "node_modules", "shared", "package.json"),
|
|
114
|
+
"0.5.0",
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const shadows = findShadowCopies(piHome, "shared");
|
|
118
|
+
|
|
119
|
+
expect(shadows).toHaveLength(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("reports every shadow copy in verifyDeploy's own report, making the overall result not-ok regardless of the shadow's own version", () => {
|
|
123
|
+
const piHome = piHomeWith("shared", "2.0.0");
|
|
124
|
+
// The shadow copy itself happens to already match the expected version --
|
|
125
|
+
// still a real problem: two copies existing at all is what matters, not
|
|
126
|
+
// whether this particular one happens to agree right now.
|
|
127
|
+
writePackageAt(join(piHome, "npm", "node_modules", "leaf", "node_modules", "shared", "package.json"), "2.0.0");
|
|
128
|
+
|
|
129
|
+
const report = verifyDeploy(piHome, "shared", "2.0.0");
|
|
130
|
+
|
|
131
|
+
expect(report.shadowCopies).toHaveLength(1);
|
|
132
|
+
expect(report.shadowCopies[0]!.upToDate).toBe(true);
|
|
133
|
+
expect(report.ok).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("formatDeployVerification", () => {
|
|
138
|
+
it("renders a human-readable PASS report with no shadow copies", () => {
|
|
139
|
+
const piHome = piHomeWith("@scope/pkg", "1.2.3");
|
|
140
|
+
const report = verifyDeploy(piHome, "@scope/pkg", "1.2.3");
|
|
141
|
+
|
|
142
|
+
const text = formatDeployVerification(report, false);
|
|
143
|
+
|
|
144
|
+
expect(text).toContain("PASS");
|
|
145
|
+
expect(text).toContain("@scope/pkg@1.2.3");
|
|
146
|
+
expect(text).toContain("no shadow copies found");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("renders valid JSON when json is true", () => {
|
|
150
|
+
const piHome = piHomeWith("@scope/pkg", "1.2.3");
|
|
151
|
+
const report = verifyDeploy(piHome, "@scope/pkg", "1.2.3");
|
|
152
|
+
|
|
153
|
+
const parsed = JSON.parse(formatDeployVerification(report, true));
|
|
154
|
+
|
|
155
|
+
expect(parsed.ok).toBe(true);
|
|
156
|
+
expect(parsed.packageName).toBe("@scope/pkg");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("renders a human-readable FAIL report listing a stale location", () => {
|
|
160
|
+
const piHome = piHomeWith("@scope/pkg", "1.0.0");
|
|
161
|
+
const report = verifyDeploy(piHome, "@scope/pkg", "9.9.9");
|
|
162
|
+
|
|
163
|
+
const text = formatDeployVerification(report, false);
|
|
164
|
+
|
|
165
|
+
expect(text).toContain("FAIL");
|
|
166
|
+
expect(text).toContain("STALE (1.0.0)");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { AuthenticatedRpcClient } from "@danypops/vehicle-client/rpc-client";
|
|
7
|
-
import { runDoctor } from "../src/adoption/doctor.ts";
|
|
7
|
+
import { formatDoctorReport, runDoctor } from "../src/adoption/doctor.ts";
|
|
8
8
|
import { createApp, type OperationInputs, type OperationName, type OperationOutputs } from "../src/daemon/service.ts";
|
|
9
9
|
import type { Installer, PkgInfo, Registry, SearchPage } from "../src/packages/package.ts";
|
|
10
10
|
|
|
@@ -237,3 +237,106 @@ describeIfSandboxed("doctor.run (daemon RPC wiring)", () => {
|
|
|
237
237
|
expect(result.conflicts).toHaveLength(1);
|
|
238
238
|
});
|
|
239
239
|
});
|
|
240
|
+
|
|
241
|
+
describe("doctor.run — module freshness (a long-running daemon process's own stale in-memory dependency, see module-freshness.ts)", () => {
|
|
242
|
+
it("merges in moduleFreshness and flips ok:false when the injected checker reports a stale dependency", async () => {
|
|
243
|
+
const home = piHome([]);
|
|
244
|
+
const app = createApp({
|
|
245
|
+
reg: new NoopRegistry(),
|
|
246
|
+
inst: new NoopInstaller(),
|
|
247
|
+
token: "test-token",
|
|
248
|
+
stateDir: track(mkdtempSync(join(tmpdir(), "packed-doctor-state-"))),
|
|
249
|
+
dataDir: track(mkdtempSync(join(tmpdir(), "packed-doctor-data-"))),
|
|
250
|
+
piHome: home,
|
|
251
|
+
moduleFreshness: () => [
|
|
252
|
+
{ name: "stale-dep", loadedVersion: "1.0.0", currentVersion: "2.0.0", stale: true },
|
|
253
|
+
{ name: "fresh-dep", loadedVersion: "1.0.0", currentVersion: "1.0.0", stale: false },
|
|
254
|
+
],
|
|
255
|
+
});
|
|
256
|
+
const client = new AuthenticatedRpcClient<OperationName, OperationInputs, OperationOutputs>("http://packed.test", "test-token", {
|
|
257
|
+
label: "Packed",
|
|
258
|
+
transport: (request) => app.fetch(request),
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const result = await client.call("doctor.run", {});
|
|
262
|
+
|
|
263
|
+
expect(result.moduleFreshness).toEqual([
|
|
264
|
+
{ name: "stale-dep", loadedVersion: "1.0.0", currentVersion: "2.0.0", stale: true },
|
|
265
|
+
{ name: "fresh-dep", loadedVersion: "1.0.0", currentVersion: "1.0.0", stale: false },
|
|
266
|
+
]);
|
|
267
|
+
// The base doctor report (no extensions installed) would otherwise be ok:true.
|
|
268
|
+
expect(result.ok).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("stays ok:true and reports no stale entries when the injected checker finds nothing stale", async () => {
|
|
272
|
+
const home = piHome([]);
|
|
273
|
+
const app = createApp({
|
|
274
|
+
reg: new NoopRegistry(),
|
|
275
|
+
inst: new NoopInstaller(),
|
|
276
|
+
token: "test-token",
|
|
277
|
+
stateDir: track(mkdtempSync(join(tmpdir(), "packed-doctor-state-"))),
|
|
278
|
+
dataDir: track(mkdtempSync(join(tmpdir(), "packed-doctor-data-"))),
|
|
279
|
+
piHome: home,
|
|
280
|
+
moduleFreshness: () => [{ name: "fresh-dep", loadedVersion: "1.0.0", currentVersion: "1.0.0", stale: false }],
|
|
281
|
+
});
|
|
282
|
+
const client = new AuthenticatedRpcClient<OperationName, OperationInputs, OperationOutputs>("http://packed.test", "test-token", {
|
|
283
|
+
label: "Packed",
|
|
284
|
+
transport: (request) => app.fetch(request),
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const result = await client.call("doctor.run", {});
|
|
288
|
+
|
|
289
|
+
expect(result.moduleFreshness).toEqual([{ name: "fresh-dep", loadedVersion: "1.0.0", currentVersion: "1.0.0", stale: false }]);
|
|
290
|
+
expect(result.ok).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("omits moduleFreshness entirely when nothing injects it -- a standalone runDoctor() call has no snapshot to compare against", async () => {
|
|
294
|
+
const home = piHome([]);
|
|
295
|
+
const app = createApp({
|
|
296
|
+
reg: new NoopRegistry(),
|
|
297
|
+
inst: new NoopInstaller(),
|
|
298
|
+
token: "test-token",
|
|
299
|
+
stateDir: track(mkdtempSync(join(tmpdir(), "packed-doctor-state-"))),
|
|
300
|
+
dataDir: track(mkdtempSync(join(tmpdir(), "packed-doctor-data-"))),
|
|
301
|
+
piHome: home,
|
|
302
|
+
});
|
|
303
|
+
const client = new AuthenticatedRpcClient<OperationName, OperationInputs, OperationOutputs>("http://packed.test", "test-token", {
|
|
304
|
+
label: "Packed",
|
|
305
|
+
transport: (request) => app.fetch(request),
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const result = await client.call("doctor.run", {});
|
|
309
|
+
|
|
310
|
+
expect(result.moduleFreshness).toBeUndefined();
|
|
311
|
+
expect(result.ok).toBe(true);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe("formatDoctorReport — module freshness rendering", () => {
|
|
316
|
+
const base = { ok: true, conflicts: [], extensions: [], scanned: 0, truncated: false, serviceUnits: [] };
|
|
317
|
+
|
|
318
|
+
it("prints a STALE_MODULE_CACHE line with an actionable restart hint for each stale entry, and nothing for a fresh one", () => {
|
|
319
|
+
const text = formatDoctorReport(
|
|
320
|
+
{
|
|
321
|
+
...base,
|
|
322
|
+
ok: false,
|
|
323
|
+
moduleFreshness: [
|
|
324
|
+
{ name: "stale-dep", loadedVersion: "1.0.0", currentVersion: "2.0.0", stale: true },
|
|
325
|
+
{ name: "fresh-dep", loadedVersion: "1.0.0", currentVersion: "1.0.0", stale: false },
|
|
326
|
+
],
|
|
327
|
+
},
|
|
328
|
+
false,
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
expect(text).toContain("STALE_MODULE_CACHE stale-dep");
|
|
332
|
+
expect(text).toContain("loaded 1.0.0 at startup");
|
|
333
|
+
expect(text).toContain("2.0.0 is now on disk");
|
|
334
|
+
expect(text).toContain("systemctl --user restart pi-packed.service");
|
|
335
|
+
expect(text).not.toContain("fresh-dep");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("prints nothing extra when moduleFreshness is absent or empty", () => {
|
|
339
|
+
expect(formatDoctorReport(base, false)).not.toContain("STALE_MODULE_CACHE");
|
|
340
|
+
expect(formatDoctorReport({ ...base, moduleFreshness: [] }, false)).not.toContain("STALE_MODULE_CACHE");
|
|
341
|
+
});
|
|
342
|
+
});
|
|
@@ -21,6 +21,7 @@ const FIXTURES = join(__dirname, "fixtures/install-validation");
|
|
|
21
21
|
const HEALTHY = join(FIXTURES, "healthy-package");
|
|
22
22
|
const BROKEN = join(FIXTURES, "broken-package");
|
|
23
23
|
const NO_MANIFEST = join(FIXTURES, "no-manifest-package");
|
|
24
|
+
const CONVENTION_ONLY = join(FIXTURES, "convention-only-package");
|
|
24
25
|
const DEP_PACKAGE = join(FIXTURES, "dep-package");
|
|
25
26
|
|
|
26
27
|
const roots: string[] = [];
|
|
@@ -96,10 +97,22 @@ describe("HeadlessInstallValidator (real npm pack + tar extraction, no mocking)"
|
|
|
96
97
|
expect(result).toEqual({ ok: true, source: "git:github.com/u/r@main", extensions: [] });
|
|
97
98
|
});
|
|
98
99
|
|
|
99
|
-
it("
|
|
100
|
+
it("refuses a package with no pi manifest and no convention resource directory -- not a Pi package (the is-number/is-buffer shape)", async () => {
|
|
100
101
|
const validator = new HeadlessInstallValidator();
|
|
101
102
|
const result = await validator.validate(`npm:${NO_MANIFEST}`);
|
|
103
|
+
expect(result.ok).toBe(false);
|
|
104
|
+
expect(result.extensions).toEqual([]);
|
|
105
|
+
expect(result.message).toContain("not a Pi package");
|
|
106
|
+
}, 20_000);
|
|
107
|
+
|
|
108
|
+
it("passes a package with no pi manifest but a real convention extensions/ directory", async () => {
|
|
109
|
+
const validator = new HeadlessInstallValidator();
|
|
110
|
+
const result = await validator.validate(`npm:${CONVENTION_ONLY}`);
|
|
102
111
|
expect(result.ok).toBe(true);
|
|
112
|
+
// Convention-discovered extensions (no pi.extensions manifest entry) are
|
|
113
|
+
// not headless load-checked here -- only presence gates admission; the
|
|
114
|
+
// per-extension mock-pi-cli check only ever runs against manifest-
|
|
115
|
+
// declared pi.extensions entries.
|
|
103
116
|
expect(result.extensions).toEqual([]);
|
|
104
117
|
}, 20_000);
|
|
105
118
|
|