@danypops/pi-packed 0.25.2 → 0.27.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/package.json +2 -2
- package/service/src/adoption/doctor.ts +8 -0
- package/service/src/adoption/duplicate-dependency-doctor.ts +103 -0
- package/service/src/daemon/daemon-service.ts +59 -63
- package/service/test/cli.test.ts +2 -1
- package/service/test/daemon-maintenance.test.ts +1 -1
- package/service/test/daemon-service.test.ts +29 -0
- package/service/test/doctor.test.ts +35 -1
- package/service/test/duplicate-dependency-doctor.test.ts +124 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/pi-packed",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Pi package lifecycle, validation, daemon, tools, profiles, and TUI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"typecheck": "bunx tsc --noEmit"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@danypops/armada": "^0.4.
|
|
30
|
+
"@danypops/armada": "^0.4.9",
|
|
31
31
|
"@danypops/packed": "^0.7.0",
|
|
32
32
|
"@danypops/pi-extension-harness": "^0.7.0",
|
|
33
33
|
"@danypops/vehicle-client": "^0.8.1",
|
|
@@ -10,6 +10,7 @@ import { existsSync } from "node:fs";
|
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { createNodeServiceInstallDeps } from "@danypops/vehicle-server/service";
|
|
12
12
|
import { listPackageResources, type PackageResources, resolveInstalledDir } from "../packages/resources.ts";
|
|
13
|
+
import { type DuplicateDependencyDiagnostic, findDuplicateDependencyVersions } from "./duplicate-dependency-doctor.ts";
|
|
13
14
|
import type { ModuleFreshnessDiagnostic } from "./module-freshness.ts";
|
|
14
15
|
import { checkServiceUnitPaths, type ServiceUnitDiagnostic } from "./service-doctor.ts";
|
|
15
16
|
import { runExtensionSmoke, type SmokeOptions, type SmokeRegistrations } from "./smoke.ts";
|
|
@@ -56,6 +57,8 @@ export interface DoctorReport {
|
|
|
56
57
|
truncated: boolean;
|
|
57
58
|
/** A daemon-backed package's systemd --user unit referencing a path that no longer exists on disk -- see service-doctor.ts. Empty (not omitted) on a platform without systemd --user coverage. */
|
|
58
59
|
serviceUnits: ServiceUnitDiagnostic[];
|
|
60
|
+
/** Any @danypops/* package resolved to more than one distinct version anywhere in piHome's own npm tree -- see duplicate-dependency-doctor.ts's own doc comment for why this stays out of `ok`. */
|
|
61
|
+
duplicateDependencies: DuplicateDependencyDiagnostic[];
|
|
59
62
|
/**
|
|
60
63
|
* Whether THIS running process still holds a stale in-memory copy of one
|
|
61
64
|
* of its own runtime dependencies, loaded once at startup -- see
|
|
@@ -140,6 +143,7 @@ export async function runDoctor(piHome: string, projectRoot?: string, options: S
|
|
|
140
143
|
scanned: bounded.length,
|
|
141
144
|
truncated,
|
|
142
145
|
serviceUnits: serviceReport.diagnostics,
|
|
146
|
+
duplicateDependencies: findDuplicateDependencyVersions(piHome),
|
|
143
147
|
};
|
|
144
148
|
}
|
|
145
149
|
|
|
@@ -161,6 +165,10 @@ export function formatDoctorReport(report: DoctorReport, json: boolean): string
|
|
|
161
165
|
for (const diagnostic of report.serviceUnits) {
|
|
162
166
|
out += `\n${diagnostic.severity.toUpperCase()} ${diagnostic.code} ${diagnostic.package} (${diagnostic.unitName}.service): ${diagnostic.message}\n`;
|
|
163
167
|
}
|
|
168
|
+
for (const duplicate of report.duplicateDependencies) {
|
|
169
|
+
const versions = duplicate.locations.map((location) => `${location.version} (${location.path})`).join(", ");
|
|
170
|
+
out += `\nDUPLICATE_DEPENDENCY_VERSION ${duplicate.name}: ${versions}\n`;
|
|
171
|
+
}
|
|
164
172
|
for (const module of report.moduleFreshness ?? []) {
|
|
165
173
|
if (!module.stale) continue;
|
|
166
174
|
out += `\nSTALE_MODULE_CACHE ${module.name}: the daemon loaded ${module.loadedVersion ?? "an unknown version"} at startup, but ${module.currentVersion ?? "a different version"} is now on disk -- restart the daemon (systemctl --user restart pi-packed.service) to pick it up.\n`;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* duplicate-dependency-doctor.ts — walks piHome's npm/node_modules tree
|
|
3
|
+
* (every top-level @danypops/<name>, plus one level of nesting -- the same
|
|
4
|
+
* shadow-copy shape findShadowCopies walks for one named package) and
|
|
5
|
+
* reports any @danypops/<name> resolved to more than one distinct version
|
|
6
|
+
* at once. Generalizes a bug class that recurred four times in one
|
|
7
|
+
* session (pi-web-spider, pi-tickets, pi-papyrus/jittor, pi-packed itself):
|
|
8
|
+
* a stale dependency floor forces a private nested copy instead of
|
|
9
|
+
* deduping to the shared hoisted one.
|
|
10
|
+
*
|
|
11
|
+
* Informational only, not part of DoctorReport's own `ok` -- a duplicate
|
|
12
|
+
* version isn't always a bug (an incompatible major range from a third
|
|
13
|
+
* party can coexist safely), so failing the whole report on every one
|
|
14
|
+
* would make a real, actionable case easy to tune out.
|
|
15
|
+
*/
|
|
16
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
export interface DuplicateDependencyLocation {
|
|
20
|
+
readonly path: string;
|
|
21
|
+
readonly version: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DuplicateDependencyDiagnostic {
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly locations: readonly DuplicateDependencyLocation[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Matches this file's own scan bound -- a tree-wide sweep never processes an unbounded package list. */
|
|
30
|
+
const MAX_PACKAGES_SCANNED = 2_000;
|
|
31
|
+
|
|
32
|
+
interface FoundPackage {
|
|
33
|
+
readonly name: string;
|
|
34
|
+
readonly version: string;
|
|
35
|
+
readonly path: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readNameAndVersion(packageJsonPath: string): { name?: string; version?: string } {
|
|
39
|
+
try {
|
|
40
|
+
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: unknown; version?: unknown };
|
|
41
|
+
return {
|
|
42
|
+
name: typeof pkg.name === "string" ? pkg.name : undefined,
|
|
43
|
+
version: typeof pkg.version === "string" ? pkg.version : undefined,
|
|
44
|
+
};
|
|
45
|
+
} catch {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Every @danypops/<name> directory directly under nodeModulesDir -- never throws; a missing or
|
|
51
|
+
* unreadable directory is just an empty result. */
|
|
52
|
+
function listDanypopsPackageDirs(nodeModulesDir: string): string[] {
|
|
53
|
+
let scoped: string[];
|
|
54
|
+
try {
|
|
55
|
+
scoped = readdirSync(join(nodeModulesDir, "@danypops"));
|
|
56
|
+
} catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
return scoped.map((name) => join(nodeModulesDir, "@danypops", name));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function scanInto(nodeModulesDir: string, budget: { remaining: number }, found: FoundPackage[]): void {
|
|
63
|
+
for (const dir of listDanypopsPackageDirs(nodeModulesDir)) {
|
|
64
|
+
if (budget.remaining <= 0) return;
|
|
65
|
+
budget.remaining--;
|
|
66
|
+
const { name, version } = readNameAndVersion(join(dir, "package.json"));
|
|
67
|
+
if (name && version) found.push({ name, version, path: dir });
|
|
68
|
+
// One level of nesting under THIS package's own node_modules -- the real, confirmed-live
|
|
69
|
+
// shadow-copy shape a stale floor produces; never descends deeper (matches findShadowCopies'
|
|
70
|
+
// own bound -- npm's own dedup is responsible for anything nested further than that).
|
|
71
|
+
for (const nestedDir of listDanypopsPackageDirs(join(dir, "node_modules"))) {
|
|
72
|
+
if (budget.remaining <= 0) return;
|
|
73
|
+
budget.remaining--;
|
|
74
|
+
const nested = readNameAndVersion(join(nestedDir, "package.json"));
|
|
75
|
+
if (nested.name && nested.version) found.push({ name: nested.name, version: nested.version, path: nestedDir });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Every @danypops/<name> resolved to more than one distinct version
|
|
82
|
+
* anywhere under piHome/npm/node_modules -- sorted by name for stable
|
|
83
|
+
* output. Two locations at the SAME version are never reported (npm simply
|
|
84
|
+
* didn't need to hoist further, not a real split); only an actual version
|
|
85
|
+
* disagreement is.
|
|
86
|
+
*/
|
|
87
|
+
export function findDuplicateDependencyVersions(piHome: string): DuplicateDependencyDiagnostic[] {
|
|
88
|
+
const found: FoundPackage[] = [];
|
|
89
|
+
scanInto(join(piHome, "npm", "node_modules"), { remaining: MAX_PACKAGES_SCANNED }, found);
|
|
90
|
+
|
|
91
|
+
const byName = new Map<string, DuplicateDependencyLocation[]>();
|
|
92
|
+
for (const pkg of found) {
|
|
93
|
+
byName.set(pkg.name, [...(byName.get(pkg.name) ?? []), { path: pkg.path, version: pkg.version }]);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const diagnostics: DuplicateDependencyDiagnostic[] = [];
|
|
97
|
+
for (const [name, locations] of byName) {
|
|
98
|
+
const distinctVersions = new Set(locations.map((location) => location.version));
|
|
99
|
+
if (distinctVersions.size > 1) diagnostics.push({ name, locations });
|
|
100
|
+
}
|
|
101
|
+
diagnostics.sort((a, b) => a.name.localeCompare(b.name));
|
|
102
|
+
return diagnostics;
|
|
103
|
+
}
|
|
@@ -25,8 +25,10 @@
|
|
|
25
25
|
* step isn't a real convention, so detection reads what's already true
|
|
26
26
|
* on disk instead of asking for one more declaration.
|
|
27
27
|
*/
|
|
28
|
+
import { spawn } from "node:child_process";
|
|
28
29
|
import { readdirSync, readFileSync } from "node:fs";
|
|
29
30
|
import { join } from "node:path";
|
|
31
|
+
import { fileURLToPath } from "node:url";
|
|
30
32
|
import { createVehicleRegistrar, type VehicleRegistrar, type VehicleRegistrationOutcome, type VehicleSpec } from "@danypops/armada";
|
|
31
33
|
import { resolveDaemonPaths } from "@danypops/vehicle-server/paths";
|
|
32
34
|
import {
|
|
@@ -127,25 +129,17 @@ function dependsOnVehicle(pkg: InstalledPackageJson): boolean {
|
|
|
127
129
|
* bin their `service install` command already points at).
|
|
128
130
|
*/
|
|
129
131
|
/**
|
|
130
|
-
* Resolves ONE candidate directory to a daemon entrypoint
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
* unconditionally winning (see detectVehicleDaemonService's own comment on
|
|
136
|
-
* why iteration order alone is never a safe tie-break).
|
|
132
|
+
* Resolves ONE candidate directory to a daemon entrypoint -- factored out
|
|
133
|
+
* so the nested and hoisted candidates for the same dependency name can
|
|
134
|
+
* each be resolved independently and then compared (see
|
|
135
|
+
* detectVehicleDaemonService), instead of the first one found
|
|
136
|
+
* unconditionally winning.
|
|
137
137
|
*/
|
|
138
138
|
function resolveDependencyCandidate(depDir: string, depName: string): ResolvedDaemonEntrypoint | undefined {
|
|
139
139
|
const dep = readPackageJson(depDir);
|
|
140
140
|
if (!dep) return undefined;
|
|
141
|
-
// An explicit manifest
|
|
142
|
-
//
|
|
143
|
-
// installed package. Without this, a dependency's own correct, explicit
|
|
144
|
-
// handleFilename/name/etc. was silently discarded in favor of a guess whenever the daemon
|
|
145
|
-
// was discovered one level down instead of installed directly by name -- exactly the
|
|
146
|
-
// common case (a Pi extension like pi-papyrus has no daemon of its own). Confirmed live:
|
|
147
|
-
// papyrus's real handle file is "vehicle-handle.json", not the "daemon.json" convention
|
|
148
|
-
// guess, and papyrus is only ever discovered this way, never installed directly by name.
|
|
141
|
+
// An explicit manifest wins over convention detection -- confirmed live: papyrus's real
|
|
142
|
+
// handle file is "vehicle-handle.json", not the "daemon.json" convention guess.
|
|
149
143
|
const depManifest = dep.packed?.daemonService;
|
|
150
144
|
if (depManifest && typeof depManifest.binPath === "string" && depManifest.binPath.length > 0 && dep.version) {
|
|
151
145
|
return {
|
|
@@ -190,14 +184,9 @@ export function detectVehicleDaemonService(
|
|
|
190
184
|
.filter((entry): entry is ResolvedDaemonEntrypoint => entry !== undefined);
|
|
191
185
|
if (resolved.length === 0) continue;
|
|
192
186
|
if (resolved.length === 1) return resolved[0];
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
// the nested copy always won here, unconditionally, purely because candidateDirs checks it
|
|
197
|
-
// first -- regardless of which was actually newer. Prefer the real higher semver version
|
|
198
|
-
// instead; a genuine tie keeps the first-found (nested) result, preserving papyrus's own
|
|
199
|
-
// intentional-nesting case (confirmed live, see resolveDependencyCandidate's own comment)
|
|
200
|
-
// exactly as before whenever there's nothing to disambiguate.
|
|
187
|
+
// Nested and hoisted both resolved for the same dependency (the real jittor incident:
|
|
188
|
+
// a stale nested copy always won just because it's checked first). Prefer the newer
|
|
189
|
+
// version; a genuine tie keeps the nested result, preserving papyrus's intentional case.
|
|
201
190
|
let best = resolved[0]!;
|
|
202
191
|
for (const candidate of resolved.slice(1)) {
|
|
203
192
|
if (versionAtLeast(candidate.version, best.version) && candidate.version !== best.version) best = candidate;
|
|
@@ -438,13 +427,7 @@ export interface ReconcileAllResult {
|
|
|
438
427
|
reconciled: Array<{ packageName: string; vehicleName: string; installed: boolean; reason?: string }>;
|
|
439
428
|
skipped: number;
|
|
440
429
|
failed: Array<{ packageName: string; reason: string }>;
|
|
441
|
-
/**
|
|
442
|
-
* Every Vehicle unregistered because this same sweep could no longer
|
|
443
|
-
* discover it by any path (neither a configured extension's own
|
|
444
|
-
* dependency walk nor listUnconfiguredDaemonDependencies' root-pinned
|
|
445
|
-
* scan) -- see pruneStaleVehicles' own doc comment for why this is safe
|
|
446
|
-
* to do unconditionally rather than requiring a separate opt-in call.
|
|
447
|
-
*/
|
|
430
|
+
/** Every Vehicle unregistered because this sweep no longer discovers it at all -- see pruneStaleVehicles. */
|
|
448
431
|
pruned: Array<{ vehicleName: string; executable: string }>;
|
|
449
432
|
pruneFailed: Array<{ vehicleName: string; reason: string }>;
|
|
450
433
|
}
|
|
@@ -458,6 +441,38 @@ const PACKED_PACKAGE_NAME = "@danypops/pi-packed";
|
|
|
458
441
|
* daemonOptions() -- vehicleName: PACKED_VEHICLE_NAME). One canonical name, not two. */
|
|
459
442
|
export const PACKED_VEHICLE_NAME = "pi-packed";
|
|
460
443
|
const SELF_REGISTRATION_REASON = "Packed cannot replace its own Armada service from inside the running daemon";
|
|
444
|
+
const SELF_RESTART_SCHEDULED_REASON = "self-restart scheduled via a detached armada CLI process";
|
|
445
|
+
/** Long enough for the HTTP response carrying this reason back to the caller to flush before the
|
|
446
|
+
* detached child's `armada restart` stops this very process. */
|
|
447
|
+
const SELF_RESTART_DELAY_MS = 500;
|
|
448
|
+
|
|
449
|
+
/** Fire-and-forget: schedule Packed's own Vehicle to restart from OUTSIDE this process. Packed
|
|
450
|
+
* can't safely stop+start itself in-process (the `stop` step would kill the very process handling
|
|
451
|
+
* this request before it could `start` again) -- so this spawns a detached `armada restart
|
|
452
|
+
* pi-packed` a beat later, after the response reporting "scheduled" has had time to flush, and lets
|
|
453
|
+
* it outlive this process via detached+unref. Swallows a resolution failure silently: worst case is
|
|
454
|
+
* the pre-existing behavior (a human restarts the service by hand), never a crash. */
|
|
455
|
+
export type SelfRestartScheduler = (spec: ServiceSpec) => void;
|
|
456
|
+
|
|
457
|
+
export const scheduleSelfRestart: SelfRestartScheduler = (_spec) => {
|
|
458
|
+
let cliPath: string;
|
|
459
|
+
try {
|
|
460
|
+
cliPath = fileURLToPath(import.meta.resolve("@danypops/armada/cli"));
|
|
461
|
+
} catch {
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
setTimeout(() => {
|
|
465
|
+
try {
|
|
466
|
+
const child = spawn(process.execPath, [cliPath, "restart", PACKED_VEHICLE_NAME, "--json"], {
|
|
467
|
+
detached: true,
|
|
468
|
+
stdio: "ignore",
|
|
469
|
+
});
|
|
470
|
+
child.unref();
|
|
471
|
+
} catch {
|
|
472
|
+
/* best-effort -- a human can still restart the service by hand */
|
|
473
|
+
}
|
|
474
|
+
}, SELF_RESTART_DELAY_MS).unref();
|
|
475
|
+
};
|
|
461
476
|
|
|
462
477
|
/**
|
|
463
478
|
* Sweeps every installed Packed package (global scope, plus a project's own
|
|
@@ -509,43 +524,20 @@ export async function reconcileAllDaemonServices(
|
|
|
509
524
|
...(resolved.result.installed ? {} : { reason: resolved.result.reason }),
|
|
510
525
|
});
|
|
511
526
|
}
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
// pi:-configured list above -- a root-pinned dependency (e.g. lector,
|
|
515
|
-
// pinned directly in piHome/npm/package.json ahead of whatever version its
|
|
516
|
-
// own pi-lector wrapper's tree would resolve) is a real, currently-running
|
|
517
|
-
// Vehicle that the configured-packages loop above never reaches at all.
|
|
518
|
-
// Folding it in here is what makes the discovered set below actually
|
|
519
|
-
// complete -- pruning against anything narrower would delete a Vehicle
|
|
520
|
-
// that's still genuinely there, just reachable by a different path.
|
|
527
|
+
// Also fold in root-pinned dependencies (e.g. lector) the configured-packages loop above
|
|
528
|
+
// never reaches -- pruning against a narrower set would delete a Vehicle still genuinely there.
|
|
521
529
|
for (const dep of listUnconfiguredDaemonDependencies(piHome)) seen.add(dep.vehicleName);
|
|
522
530
|
const { pruned, pruneFailed } = await pruneStaleVehicles(piHome, seen, installer);
|
|
523
531
|
return { reconciled, skipped, failed, pruned, pruneFailed };
|
|
524
532
|
}
|
|
525
533
|
|
|
526
534
|
/**
|
|
527
|
-
* Unregisters every Vehicle Armada
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
* see this session's own live incident, a duplicate "web-spider-daemon"
|
|
534
|
-
* losing every restart's single-instance-lock race against the correctly-
|
|
535
|
-
* named "web-spider" Vehicle) just sits in the manifest forever: nothing
|
|
536
|
-
* ever re-discovers it to overwrite it, and nothing ever notices it's gone
|
|
537
|
-
* stale either.
|
|
538
|
-
*
|
|
539
|
-
* Scoped to only ever touch a Vehicle whose own `executable` lives under
|
|
540
|
-
* piHome's own npm/node_modules -- i.e. one only Packed itself could
|
|
541
|
-
* plausibly have registered in the first place. A Vehicle registered by an
|
|
542
|
-
* entirely different mechanism (a hand-rolled `armada upsert` pointing
|
|
543
|
-
* somewhere else on disk) is never a candidate, no matter how the
|
|
544
|
-
* discovered set above turns out -- this function has no way to know that
|
|
545
|
-
* caller's own intent, so it stays out of its way entirely. Packed's own
|
|
546
|
-
* Vehicle (PACKED_VEHICLE_NAME) is excluded for the same reason
|
|
547
|
-
* install()/remove()/restart() already refuse to touch it: reconcile runs
|
|
548
|
-
* from inside Packed's own process.
|
|
535
|
+
* Unregisters every Vehicle Armada declares that `discovered` no longer
|
|
536
|
+
* produces at all -- otherwise a renamed/collided Vehicle (see the real
|
|
537
|
+
* web-spider-daemon incident) sits in the manifest forever. Scoped to only
|
|
538
|
+
* ever touch a Vehicle whose `executable` lives under piHome's own
|
|
539
|
+
* npm/node_modules -- one Packed itself could plausibly have registered.
|
|
540
|
+
* Packed's own Vehicle is excluded, matching install/remove/restart.
|
|
549
541
|
*/
|
|
550
542
|
async function pruneStaleVehicles(
|
|
551
543
|
piHome: string,
|
|
@@ -599,7 +591,10 @@ export interface DaemonServiceInstaller {
|
|
|
599
591
|
* path/native controller are resolved once, not on every call.
|
|
600
592
|
*/
|
|
601
593
|
export class RealDaemonServiceInstaller implements DaemonServiceInstaller {
|
|
602
|
-
constructor(
|
|
594
|
+
constructor(
|
|
595
|
+
private readonly registrar: VehicleRegistrar = createVehicleRegistrar(),
|
|
596
|
+
private readonly selfRestart: SelfRestartScheduler = scheduleSelfRestart,
|
|
597
|
+
) {}
|
|
603
598
|
|
|
604
599
|
async listRegisteredVehicles(): Promise<readonly VehicleSpec[]> {
|
|
605
600
|
return this.registrar.listRegistered();
|
|
@@ -642,7 +637,8 @@ export class RealDaemonServiceInstaller implements DaemonServiceInstaller {
|
|
|
642
637
|
const resolved = resolveDaemonServiceSpec(piHome, source);
|
|
643
638
|
if (!resolved.ok) return resolved;
|
|
644
639
|
if (resolved.spec.name === PACKED_VEHICLE_NAME) {
|
|
645
|
-
|
|
640
|
+
this.selfRestart(resolved.spec);
|
|
641
|
+
return { ok: true, restarted: true, reason: SELF_RESTART_SCHEDULED_REASON, spec: resolved.spec };
|
|
646
642
|
}
|
|
647
643
|
if (!(await isVehicleServiceRegistered(resolved.spec.name, this.registrar))) {
|
|
648
644
|
return { ok: true, restarted: false, reason: `no persistent service is registered for ${resolved.spec.name}`, spec: resolved.spec };
|
package/service/test/cli.test.ts
CHANGED
|
@@ -1059,7 +1059,7 @@ describe("CLI", () => {
|
|
|
1059
1059
|
},
|
|
1060
1060
|
async doctor(projectRoot) {
|
|
1061
1061
|
calls.push(`doctor:${projectRoot}`);
|
|
1062
|
-
return { ok: true, conflicts: [], extensions: [], scanned: 0, truncated: false, serviceUnits: [] };
|
|
1062
|
+
return { ok: true, conflicts: [], extensions: [], scanned: 0, truncated: false, serviceUnits: [], duplicateDependencies: [] };
|
|
1063
1063
|
},
|
|
1064
1064
|
async updatesForProject(projectRoot) {
|
|
1065
1065
|
calls.push(`updatesForProject:${projectRoot}`);
|
|
@@ -1101,6 +1101,7 @@ describe("CLI", () => {
|
|
|
1101
1101
|
scanned: 0,
|
|
1102
1102
|
truncated: false,
|
|
1103
1103
|
serviceUnits: [],
|
|
1104
|
+
duplicateDependencies: [],
|
|
1104
1105
|
});
|
|
1105
1106
|
expect(JSON.parse((await cliRun(["updates", "--project", "/tmp/project", "--json"], d)).out).updates).toEqual([]);
|
|
1106
1107
|
expect(calls).toEqual([
|
|
@@ -316,7 +316,7 @@ describe("startPackedDaemon's own maintenance-task wiring (self-heals Vehicle dr
|
|
|
316
316
|
});
|
|
317
317
|
});
|
|
318
318
|
|
|
319
|
-
describe("reconcileAllDaemonServices pruning
|
|
319
|
+
describe("reconcileAllDaemonServices pruning (the-armada-registrar-jittor-web-spider-daemon-collision)", () => {
|
|
320
320
|
function stalePiHomeVehicle(piHome: string, name: string): VehicleSpec {
|
|
321
321
|
return {
|
|
322
322
|
name: name as VehicleSpec["name"],
|
|
@@ -9,8 +9,11 @@ import {
|
|
|
9
9
|
detectVehicleDaemonService,
|
|
10
10
|
listManagedPackages,
|
|
11
11
|
listUnconfiguredDaemonDependencies,
|
|
12
|
+
PACKED_VEHICLE_NAME,
|
|
12
13
|
RealDaemonServiceInstaller,
|
|
13
14
|
resolveDaemonServiceSpec,
|
|
15
|
+
scheduleSelfRestart,
|
|
16
|
+
type SelfRestartScheduler,
|
|
14
17
|
} from "../src/daemon/daemon-service.ts";
|
|
15
18
|
|
|
16
19
|
const roots: string[] = [];
|
|
@@ -470,6 +473,32 @@ describe("RealDaemonServiceInstaller -- Armada registration through the in-proce
|
|
|
470
473
|
}
|
|
471
474
|
});
|
|
472
475
|
|
|
476
|
+
it("restart() schedules an out-of-process self-restart for Packed's own Vehicle, never touching Armada in-process", async () => {
|
|
477
|
+
const piHome = fakePiHome();
|
|
478
|
+
writePackage(piHome, "@danypops/pi-packed", { binPath: "service/src/cli/cli.ts", args: ["serve"] });
|
|
479
|
+
const harness = await createArmadaTestHarness();
|
|
480
|
+
const scheduled: Array<{ name: string }> = [];
|
|
481
|
+
const selfRestart: SelfRestartScheduler = (spec) => {
|
|
482
|
+
scheduled.push({ name: spec.name });
|
|
483
|
+
};
|
|
484
|
+
try {
|
|
485
|
+
const installer = new RealDaemonServiceInstaller(harness.registrar, selfRestart);
|
|
486
|
+
const outcome = await installer.restart(piHome, "npm:@danypops/pi-packed");
|
|
487
|
+
|
|
488
|
+
expect(outcome).toMatchObject({ ok: true, restarted: true, spec: { name: "pi-packed" } });
|
|
489
|
+
expect(scheduled).toEqual([{ name: PACKED_VEHICLE_NAME }]);
|
|
490
|
+
// never asked Armada to stop/replace/start the very process running this test
|
|
491
|
+
expect(harness.events()).toEqual([]);
|
|
492
|
+
} finally {
|
|
493
|
+
await harness.dispose();
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("scheduleSelfRestart resolves armada's real CLI module -- proves the wiring is live, not just typechecked", () => {
|
|
498
|
+
expect(() => import.meta.resolve("@danypops/armada/cli")).not.toThrow();
|
|
499
|
+
expect(typeof scheduleSelfRestart).toBe("function");
|
|
500
|
+
});
|
|
501
|
+
|
|
473
502
|
it("install() reports a non-daemon package without touching Armada", async () => {
|
|
474
503
|
const piHome = fakePiHome();
|
|
475
504
|
writePackage(piHome, "some-pkg", undefined);
|
|
@@ -169,6 +169,40 @@ describeIfSandboxed("runDoctor", () => {
|
|
|
169
169
|
expect(report.ok).toBe(true);
|
|
170
170
|
expect(report.scanned).toBe(1);
|
|
171
171
|
});
|
|
172
|
+
|
|
173
|
+
it("surfaces a real duplicate @danypops/* dependency version without failing the overall report -- the same jittor-incident shape, caught proactively this time", async () => {
|
|
174
|
+
const home = piHome(["npm:pi-a"]);
|
|
175
|
+
installNpmPackage(
|
|
176
|
+
home,
|
|
177
|
+
"pi-a",
|
|
178
|
+
{ extensions: ["extension/index.ts"] },
|
|
179
|
+
{
|
|
180
|
+
"extension/index.ts": 'export default function (pi: any) { pi.registerTool({ name: "alpha" }); }',
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
const nodeModules = join(home, "npm", "node_modules");
|
|
184
|
+
mkdirSync(join(nodeModules, "@danypops", "jittor"), { recursive: true });
|
|
185
|
+
writeFileSync(
|
|
186
|
+
join(nodeModules, "@danypops", "jittor", "package.json"),
|
|
187
|
+
JSON.stringify({ name: "@danypops/jittor", version: "0.18.1" }),
|
|
188
|
+
);
|
|
189
|
+
mkdirSync(join(nodeModules, "@danypops", "pi-a", "node_modules", "@danypops", "jittor"), { recursive: true });
|
|
190
|
+
writeFileSync(
|
|
191
|
+
join(nodeModules, "@danypops", "pi-a", "node_modules", "@danypops", "jittor", "package.json"),
|
|
192
|
+
JSON.stringify({ name: "@danypops/jittor", version: "0.14.0" }),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const report = await runDoctor(home);
|
|
196
|
+
|
|
197
|
+
expect(report.duplicateDependencies).toHaveLength(1);
|
|
198
|
+
expect(report.duplicateDependencies[0]?.name).toBe("@danypops/jittor");
|
|
199
|
+
expect(report.ok).toBe(true); // informational only -- never fails the run by itself
|
|
200
|
+
|
|
201
|
+
const text = formatDoctorReport(report, false);
|
|
202
|
+
expect(text).toContain("DUPLICATE_DEPENDENCY_VERSION @danypops/jittor");
|
|
203
|
+
expect(text).toContain("0.18.1");
|
|
204
|
+
expect(text).toContain("0.14.0");
|
|
205
|
+
});
|
|
172
206
|
});
|
|
173
207
|
|
|
174
208
|
class NoopRegistry implements Registry {
|
|
@@ -313,7 +347,7 @@ describe("doctor.run — module freshness (a long-running daemon process's own s
|
|
|
313
347
|
});
|
|
314
348
|
|
|
315
349
|
describe("formatDoctorReport — module freshness rendering", () => {
|
|
316
|
-
const base = { ok: true, conflicts: [], extensions: [], scanned: 0, truncated: false, serviceUnits: [] };
|
|
350
|
+
const base = { ok: true, conflicts: [], extensions: [], scanned: 0, truncated: false, serviceUnits: [], duplicateDependencies: [] };
|
|
317
351
|
|
|
318
352
|
it("prints a STALE_MODULE_CACHE line with an actionable restart hint for each stale entry, and nothing for a fresh one", () => {
|
|
319
353
|
const text = formatDoctorReport(
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* duplicate-dependency-doctor.test.ts — the general form of the bug class
|
|
3
|
+
* this session hit four separate times: a stale dependency floor forcing
|
|
4
|
+
* a private nested copy instead of deduping to the shared hoisted one.
|
|
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 { findDuplicateDependencyVersions } from "../src/adoption/duplicate-dependency-doctor.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 piHome(): string {
|
|
23
|
+
return track(mkdtempSync(join(tmpdir(), "packed-dup-deps-")));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writePackage(dir: string, name: string, version: string): void {
|
|
27
|
+
mkdirSync(dir, { recursive: true });
|
|
28
|
+
writeFileSync(join(dir, "package.json"), JSON.stringify({ name, version }));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("findDuplicateDependencyVersions", () => {
|
|
32
|
+
it("returns nothing for a clean tree with no @danypops packages at all", () => {
|
|
33
|
+
const home = piHome();
|
|
34
|
+
expect(findDuplicateDependencyVersions(home)).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns nothing when every @danypops package resolves to exactly one version, even across several packages", () => {
|
|
38
|
+
const home = piHome();
|
|
39
|
+
const nodeModules = join(home, "npm", "node_modules");
|
|
40
|
+
writePackage(join(nodeModules, "@danypops", "armada"), "@danypops/armada", "0.4.7");
|
|
41
|
+
writePackage(join(nodeModules, "@danypops", "jittor"), "@danypops/jittor", "0.18.1");
|
|
42
|
+
|
|
43
|
+
expect(findDuplicateDependencyVersions(home)).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("reports the real jittor incident shape: a stale nested copy alongside the correctly-versioned hoisted one", () => {
|
|
47
|
+
const home = piHome();
|
|
48
|
+
const nodeModules = join(home, "npm", "node_modules");
|
|
49
|
+
const hoistedDir = join(nodeModules, "@danypops", "jittor");
|
|
50
|
+
writePackage(hoistedDir, "@danypops/jittor", "0.18.1");
|
|
51
|
+
const nestedDir = join(nodeModules, "@danypops", "pi-papyrus", "node_modules", "@danypops", "jittor");
|
|
52
|
+
writePackage(nestedDir, "@danypops/jittor", "0.14.0");
|
|
53
|
+
writePackage(join(nodeModules, "@danypops", "pi-papyrus"), "@danypops/pi-papyrus", "1.0.0");
|
|
54
|
+
|
|
55
|
+
const diagnostics = findDuplicateDependencyVersions(home);
|
|
56
|
+
|
|
57
|
+
expect(diagnostics).toHaveLength(1);
|
|
58
|
+
expect(diagnostics[0]!.name).toBe("@danypops/jittor");
|
|
59
|
+
const versions = diagnostics[0]!.locations.map((location) => location.version).sort();
|
|
60
|
+
expect(versions).toEqual(["0.14.0", "0.18.1"]);
|
|
61
|
+
const paths = diagnostics[0]!.locations.map((location) => location.path).sort();
|
|
62
|
+
expect(paths).toEqual([hoistedDir, nestedDir].sort());
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("does NOT report two locations at the exact same version -- npm simply didn't need to hoist further, not a real split", () => {
|
|
66
|
+
const home = piHome();
|
|
67
|
+
const nodeModules = join(home, "npm", "node_modules");
|
|
68
|
+
writePackage(join(nodeModules, "@danypops", "vehicle-core"), "@danypops/vehicle-core", "0.17.0");
|
|
69
|
+
writePackage(
|
|
70
|
+
join(nodeModules, "@danypops", "pi-tickets", "node_modules", "@danypops", "vehicle-core"),
|
|
71
|
+
"@danypops/vehicle-core",
|
|
72
|
+
"0.17.0",
|
|
73
|
+
);
|
|
74
|
+
writePackage(join(nodeModules, "@danypops", "pi-tickets"), "@danypops/pi-tickets", "1.0.0");
|
|
75
|
+
|
|
76
|
+
expect(findDuplicateDependencyVersions(home)).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("never descends into a nested copy's OWN node_modules -- one level of nesting is the real, confirmed shape; deeper is npm's own dedup problem", () => {
|
|
80
|
+
const home = piHome();
|
|
81
|
+
const nodeModules = join(home, "npm", "node_modules");
|
|
82
|
+
writePackage(join(nodeModules, "@danypops", "vehicle-server"), "@danypops/vehicle-server", "0.24.1");
|
|
83
|
+
const oneLevelDir = join(nodeModules, "@danypops", "pi-packed", "node_modules", "@danypops", "vehicle-server");
|
|
84
|
+
writePackage(oneLevelDir, "@danypops/vehicle-server", "0.24.1");
|
|
85
|
+
writePackage(join(nodeModules, "@danypops", "pi-packed"), "@danypops/pi-packed", "1.0.0");
|
|
86
|
+
// Two levels deep -- must never be reached.
|
|
87
|
+
writePackage(
|
|
88
|
+
join(oneLevelDir, "node_modules", "@danypops", "vehicle-core"),
|
|
89
|
+
"@danypops/vehicle-core",
|
|
90
|
+
"9.9.9",
|
|
91
|
+
);
|
|
92
|
+
writePackage(join(nodeModules, "@danypops", "vehicle-core"), "@danypops/vehicle-core", "0.17.0");
|
|
93
|
+
|
|
94
|
+
expect(findDuplicateDependencyVersions(home)).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("sorts multiple diagnostics by package name for stable output", () => {
|
|
98
|
+
const home = piHome();
|
|
99
|
+
const nodeModules = join(home, "npm", "node_modules");
|
|
100
|
+
writePackage(join(nodeModules, "@danypops", "web-spider-daemon"), "@danypops/web-spider-daemon", "0.24.2");
|
|
101
|
+
writePackage(
|
|
102
|
+
join(nodeModules, "@danypops", "pi-web-spider", "node_modules", "@danypops", "web-spider-daemon"),
|
|
103
|
+
"@danypops/web-spider-daemon",
|
|
104
|
+
"0.24.0",
|
|
105
|
+
);
|
|
106
|
+
writePackage(join(nodeModules, "@danypops", "pi-web-spider"), "@danypops/pi-web-spider", "1.0.0");
|
|
107
|
+
writePackage(join(nodeModules, "@danypops", "armada"), "@danypops/armada", "0.4.7");
|
|
108
|
+
writePackage(
|
|
109
|
+
join(nodeModules, "@danypops", "pi-packed", "node_modules", "@danypops", "armada"),
|
|
110
|
+
"@danypops/armada",
|
|
111
|
+
"0.4.3",
|
|
112
|
+
);
|
|
113
|
+
writePackage(join(nodeModules, "@danypops", "pi-packed"), "@danypops/pi-packed", "1.0.0");
|
|
114
|
+
|
|
115
|
+
const diagnostics = findDuplicateDependencyVersions(home);
|
|
116
|
+
|
|
117
|
+
expect(diagnostics.map((diagnostic) => diagnostic.name)).toEqual(["@danypops/armada", "@danypops/web-spider-daemon"]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("returns [] rather than throwing when piHome/npm/node_modules doesn't exist yet", () => {
|
|
121
|
+
const home = piHome();
|
|
122
|
+
expect(findDuplicateDependencyVersions(home)).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
});
|