@danypops/pi-packed 0.19.10 → 0.19.12
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/check.ts +39 -4
- package/service/src/adoption/doctor.ts +17 -1
- package/service/src/adoption/service-doctor.ts +128 -0
- package/service/test/check.test.ts +76 -0
- package/service/test/cli.test.ts +2 -1
- package/service/test/service-doctor.test.ts +142 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/pi-packed",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.12",
|
|
4
4
|
"description": "Pi package lifecycle, validation, daemon, tools, profiles, and TUI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"typecheck": "bunx tsc --noEmit"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@danypops/packed": "
|
|
19
|
+
"@danypops/packed": "^0.6.0",
|
|
20
20
|
"@danypops/pi-extension-harness": "^0.2.0",
|
|
21
21
|
"@danypops/vehicle-client": "^0.2.0",
|
|
22
22
|
"@danypops/vehicle-core": "^0.6.1",
|
|
@@ -487,11 +487,40 @@ function cleanupManifestCheck(context: Context): void {
|
|
|
487
487
|
}
|
|
488
488
|
}
|
|
489
489
|
|
|
490
|
+
/** A conventional test file (path under a test/tests/__tests__ directory, or
|
|
491
|
+
* a .test./.spec. basename) never runs on a consumer's machine at
|
|
492
|
+
* install/import time -- it has no bearing on what a package's *shipped
|
|
493
|
+
* runtime* code actually imports, even though npm's own `files` field
|
|
494
|
+
* (a plain directory-prefix allowlist, e.g. "service") has no way to exclude
|
|
495
|
+
* it short of a package author remembering to. Real bug this fixes: a test
|
|
496
|
+
* file's own fixture data can contain string literals shaped exactly like
|
|
497
|
+
* real import statements (deliberately, to test this very checker's
|
|
498
|
+
* detection logic) without being real imports of the file that contains them. */
|
|
499
|
+
function isTestFile(path: string): boolean {
|
|
500
|
+
if (/(?:^|\/)(?:test|tests|__tests__)\//.test(path)) return true;
|
|
501
|
+
return /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(path);
|
|
502
|
+
}
|
|
503
|
+
|
|
490
504
|
/** Shipped .js/.ts/.cjs/.mjs files that are actually contained within the
|
|
491
|
-
* package root (never a symlink escape) -- the same
|
|
492
|
-
* source-scanning check works over. */
|
|
505
|
+
* package root (never a symlink escape), excluding test files -- the same
|
|
506
|
+
* file universe every source-scanning check works over. */
|
|
493
507
|
function shippedSourceFiles(context: Context): string[] {
|
|
494
|
-
return shippedFiles(context).filter(
|
|
508
|
+
return shippedFiles(context).filter(
|
|
509
|
+
(path) => /\.[cm]?[jt]s$/.test(path) && !isTestFile(path) && isContainedFile(context.root, path),
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Strips line and block comments before the import-specifier regex below runs
|
|
515
|
+
* -- a comment can contain an arbitrary quoted "from ..." substring the regex
|
|
516
|
+
* would otherwise match as if it were real code (confirmed live against a
|
|
517
|
+
* JSDoc comment describing a bug fix in prose that happened to contain
|
|
518
|
+
* exactly that shape). Not a full lexer: a string literal that itself
|
|
519
|
+
* contains a comment delimiter could still confuse this, a pre-existing
|
|
520
|
+
* limit of a regex-based scan, not a regression this introduces.
|
|
521
|
+
*/
|
|
522
|
+
function stripComments(source: string): string {
|
|
523
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, "");
|
|
495
524
|
}
|
|
496
525
|
|
|
497
526
|
/** Every import/require specifier literal in one file, bounded by
|
|
@@ -500,7 +529,7 @@ function shippedSourceFiles(context: Context): string[] {
|
|
|
500
529
|
function importSpecifiersIn(context: Context, file: string): string[] | undefined {
|
|
501
530
|
const absolute = join(context.root, file);
|
|
502
531
|
if (lstatSync(absolute).size > MAX_SOURCE_BYTES) return undefined;
|
|
503
|
-
const source = readFileSync(absolute, "utf8");
|
|
532
|
+
const source = stripComments(readFileSync(absolute, "utf8"));
|
|
504
533
|
return [...source.matchAll(/(?:from\s*|import\s*\(|require\s*\()\s*["']([^"']+)["']/g)].map((match) => match[1]!);
|
|
505
534
|
}
|
|
506
535
|
|
|
@@ -523,6 +552,12 @@ function dependencyCheck(context: Context): void {
|
|
|
523
552
|
for (const specifier of importSpecifiersIn(context, file) ?? []) {
|
|
524
553
|
if (specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("node:") || specifier.startsWith("bun:")) continue;
|
|
525
554
|
const name = specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0]!;
|
|
555
|
+
// A package importing its own subpath exports (e.g. "@scope/pkg/client")
|
|
556
|
+
// is not a runtime dependency on itself -- there is no real range to
|
|
557
|
+
// declare, and node/bun resolve a self-reference via the package's own
|
|
558
|
+
// name field, not a node_modules entry that a `dependencies` line would
|
|
559
|
+
// describe.
|
|
560
|
+
if (name === context.pkg.name) continue;
|
|
526
561
|
if (CORE_PACKAGES.has(name)) {
|
|
527
562
|
if (peers[name] !== "*")
|
|
528
563
|
context.add({
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* answers the same question proactively, before pi ever runs.
|
|
8
8
|
*/
|
|
9
9
|
import { join } from "node:path";
|
|
10
|
+
import { createNodeServiceInstallDeps } from "@danypops/vehicle-server/service";
|
|
10
11
|
import { listPackageResources, type PackageResources, resolveInstalledDir } from "../packages/resources.ts";
|
|
12
|
+
import { checkServiceUnitPaths, type ServiceUnitDiagnostic } from "./service-doctor.ts";
|
|
11
13
|
import { runExtensionSmoke, type SmokeOptions, type SmokeRegistrations } from "./smoke.ts";
|
|
12
14
|
|
|
13
15
|
const REGISTRATION_KINDS = ["tools", "commands", "shortcuts", "flags"] as const;
|
|
@@ -50,6 +52,8 @@ export interface DoctorReport {
|
|
|
50
52
|
extensions: DoctorExtensionResult[];
|
|
51
53
|
scanned: number;
|
|
52
54
|
truncated: boolean;
|
|
55
|
+
/** 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. */
|
|
56
|
+
serviceUnits: ServiceUnitDiagnostic[];
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
interface ScanJob {
|
|
@@ -112,7 +116,16 @@ export async function runDoctor(piHome: string, projectRoot?: string, options: S
|
|
|
112
116
|
}
|
|
113
117
|
conflicts.sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name));
|
|
114
118
|
|
|
115
|
-
|
|
119
|
+
const serviceReport = checkServiceUnitPaths(piHome, projectRoot, createNodeServiceInstallDeps());
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
ok: conflicts.length === 0 && !anyNotOk && serviceReport.ok,
|
|
123
|
+
conflicts,
|
|
124
|
+
extensions,
|
|
125
|
+
scanned: bounded.length,
|
|
126
|
+
truncated,
|
|
127
|
+
serviceUnits: serviceReport.diagnostics,
|
|
128
|
+
};
|
|
116
129
|
}
|
|
117
130
|
|
|
118
131
|
function formatClaimant(claim: DoctorClaim): string {
|
|
@@ -130,6 +143,9 @@ export function formatDoctorReport(report: DoctorReport, json: boolean): string
|
|
|
130
143
|
if (extension.status === "ok") continue;
|
|
131
144
|
out += `\n${extension.status.toUpperCase()} ${formatClaimant({ name: extension.name, source: extension.source, scope: extension.scope, extension: extension.extension })}${extension.message ? `: ${extension.message}` : ""}\n`;
|
|
132
145
|
}
|
|
146
|
+
for (const diagnostic of report.serviceUnits) {
|
|
147
|
+
out += `\n${diagnostic.severity.toUpperCase()} ${diagnostic.code} ${diagnostic.package} (${diagnostic.unitName}.service): ${diagnostic.message}\n`;
|
|
148
|
+
}
|
|
133
149
|
if (report.truncated) out += "\nOutput truncated: more enabled extensions exist than this run's bound.\n";
|
|
134
150
|
return out;
|
|
135
151
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* service-doctor.ts — checks every installed package's own daemon systemd
|
|
3
|
+
* --user unit (if one is registered) for an ExecStart path that no longer
|
|
4
|
+
* exists on disk. A unit is written once, at install/service-install time,
|
|
5
|
+
* and never re-validated against the package's current on-disk layout --
|
|
6
|
+
* confirmed live: a refactor that moved a package's own CLI entrypoint left
|
|
7
|
+
* a real, already-registered unit pointing at a deleted file. The process
|
|
8
|
+
* kept running only because it had been loaded into memory before the file
|
|
9
|
+
* vanished; the next crash or reboot would have failed to restart at all,
|
|
10
|
+
* silently, until someone happened to notice the daemon was down.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately separate from doctor.ts's own DoctorReport (extension
|
|
13
|
+
* registration-conflict scanning) -- a different subject (installed
|
|
14
|
+
* services vs. loaded extensions), composed alongside it in runDoctor()
|
|
15
|
+
* rather than folded into its shape.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync } from "node:fs";
|
|
18
|
+
import type { ServiceInstallDeps } from "@danypops/vehicle-server/service";
|
|
19
|
+
import { isServiceInstalled } from "@danypops/vehicle-server/service";
|
|
20
|
+
import { readInstalledPackagesAcrossScopes } from "../packages/installed.ts";
|
|
21
|
+
import { resolveDaemonServiceSpec } from "../daemon/daemon-service.ts";
|
|
22
|
+
|
|
23
|
+
export interface ServiceUnitDiagnostic {
|
|
24
|
+
code: "SERVICE_EXEC_PATH_MISSING" | "SERVICE_EXEC_UNPARSEABLE";
|
|
25
|
+
severity: "error" | "warning";
|
|
26
|
+
package: string;
|
|
27
|
+
unitName: string;
|
|
28
|
+
message: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ServiceDoctorReport {
|
|
32
|
+
ok: boolean;
|
|
33
|
+
diagnostics: ServiceUnitDiagnostic[];
|
|
34
|
+
checked: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Tokenizes ExecStart's own shell-quoted argument list -- matching
|
|
39
|
+
* generateSystemdUnit's shellQuote, which wraps every argument in double
|
|
40
|
+
* quotes and backslash-escapes only ", \, $, and ` inside them. Returns
|
|
41
|
+
* undefined for a missing or unrecognizable ExecStart line rather than
|
|
42
|
+
* guessing; the caller degrades that to a warning, never a false error.
|
|
43
|
+
*/
|
|
44
|
+
export function parseExecStartTokens(unitText: string): string[] | undefined {
|
|
45
|
+
const line = unitText.split("\n").find((entry) => entry.trimStart().startsWith("ExecStart="));
|
|
46
|
+
if (!line) return undefined;
|
|
47
|
+
const raw = line.slice(line.indexOf("=") + 1);
|
|
48
|
+
const tokens: string[] = [];
|
|
49
|
+
const pattern = /"((?:[^"\\]|\\.)*)"/g;
|
|
50
|
+
let match: RegExpExecArray | null = pattern.exec(raw);
|
|
51
|
+
while (match !== null) {
|
|
52
|
+
tokens.push(match[1]!.replace(/\\(["\\$`])/g, "$1"));
|
|
53
|
+
match = pattern.exec(raw);
|
|
54
|
+
}
|
|
55
|
+
return tokens.length > 0 ? tokens : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A token worth existence-checking is one that's actually path-shaped -- a bare word like "serve" (a real subcommand argument, not a path) would otherwise resolve relative to this check's own cwd and produce a false SERVICE_EXEC_PATH_MISSING. */
|
|
59
|
+
function looksLikePath(token: string): boolean {
|
|
60
|
+
return token.includes("/");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ServiceDoctorDeps extends Pick<ServiceInstallDeps, "fileExists" | "readFile"> {
|
|
64
|
+
platform?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* For every installed package that resolves to a real Vehicle daemon spec
|
|
69
|
+
* (same resolution daemon-service.ts's own install/restart commands use)
|
|
70
|
+
* and currently has a systemd --user unit registered, reads that unit's
|
|
71
|
+
* actual on-disk ExecStart (not a freshly recomputed "correct" one -- the
|
|
72
|
+
* whole point is catching a real unit that has drifted from what's true
|
|
73
|
+
* today) and flags any referenced path that no longer exists.
|
|
74
|
+
*
|
|
75
|
+
* Linux/systemd only, matching installUserService's own platform coverage
|
|
76
|
+
* -- macOS/Windows unit shapes differ and aren't checked here yet.
|
|
77
|
+
*/
|
|
78
|
+
export function checkServiceUnitPaths(piHome: string, projectRoot: string | undefined, deps: ServiceDoctorDeps): ServiceDoctorReport {
|
|
79
|
+
const diagnostics: ServiceUnitDiagnostic[] = [];
|
|
80
|
+
const platform = deps.platform ?? process.platform;
|
|
81
|
+
let checked = 0;
|
|
82
|
+
if (platform !== "linux") return { ok: true, diagnostics, checked };
|
|
83
|
+
|
|
84
|
+
for (const pkg of readInstalledPackagesAcrossScopes(piHome, projectRoot)) {
|
|
85
|
+
const resolved = resolveDaemonServiceSpec(piHome, `npm:${pkg.name}`);
|
|
86
|
+
if (!resolved.ok) continue;
|
|
87
|
+
const spec = resolved.spec;
|
|
88
|
+
if (!isServiceInstalled(spec, deps as ServiceInstallDeps)) continue;
|
|
89
|
+
checked++;
|
|
90
|
+
|
|
91
|
+
const unitText = deps.readFile(spec.descriptorPath);
|
|
92
|
+
if (unitText === null) continue; // isServiceInstalled said yes but the read raced/failed -- transient, not a real finding.
|
|
93
|
+
|
|
94
|
+
const tokens = parseExecStartTokens(unitText);
|
|
95
|
+
if (!tokens) {
|
|
96
|
+
diagnostics.push({
|
|
97
|
+
code: "SERVICE_EXEC_UNPARSEABLE",
|
|
98
|
+
severity: "warning",
|
|
99
|
+
package: pkg.name,
|
|
100
|
+
unitName: spec.name,
|
|
101
|
+
message: `could not parse ExecStart in ${spec.descriptorPath}`,
|
|
102
|
+
});
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const token of tokens) {
|
|
107
|
+
if (!looksLikePath(token) || existsSync(token)) continue;
|
|
108
|
+
diagnostics.push({
|
|
109
|
+
code: "SERVICE_EXEC_PATH_MISSING",
|
|
110
|
+
severity: "error",
|
|
111
|
+
package: pkg.name,
|
|
112
|
+
unitName: spec.name,
|
|
113
|
+
message: `${spec.name}.service's ExecStart references a path that no longer exists: ${token} -- the service will fail to (re)start the next time it stops. Reinstall it (packed install-service npm:${pkg.name}) to regenerate.`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { ok: diagnostics.every((diagnostic) => diagnostic.severity !== "error"), diagnostics, checked };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function formatServiceDoctorReport(report: ServiceDoctorReport, json: boolean): string {
|
|
122
|
+
if (json) return `${JSON.stringify(report)}\n`;
|
|
123
|
+
let out = `${report.ok ? "PASS" : "FAIL"} — ${report.checked} service unit(s) checked, ${report.diagnostics.length} issue(s)\n`;
|
|
124
|
+
for (const diagnostic of report.diagnostics) {
|
|
125
|
+
out += `${diagnostic.severity.toUpperCase()} ${diagnostic.code} ${diagnostic.package} (${diagnostic.unitName}.service): ${diagnostic.message}\n`;
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
@@ -366,3 +366,79 @@ describe("packed check", () => {
|
|
|
366
366
|
expect(codes(report.diagnostics)).toEqual(["PKG_JSON_INVALID"]);
|
|
367
367
|
});
|
|
368
368
|
});
|
|
369
|
+
|
|
370
|
+
describe("dependencyCheck false-positive regressions (packed check)", () => {
|
|
371
|
+
it("never scans a test file's own fixture strings as if they were real imports of that file", async () => {
|
|
372
|
+
// A checker's own test suite legitimately contains string literals shaped exactly
|
|
373
|
+
// like real import statements (fixture data for testing detection itself) -- these
|
|
374
|
+
// must never be attributed to the test file that merely contains them as text.
|
|
375
|
+
const root = fixture(
|
|
376
|
+
{ ...base, files: ["extensions", "test", "README.md", "LICENSE"] },
|
|
377
|
+
{
|
|
378
|
+
"extensions/index.ts": "export default function () {}",
|
|
379
|
+
"test/check.test.ts": 'const fixtureSource = \'import lodash from "lodash";\';\n',
|
|
380
|
+
"README.md": "# Example",
|
|
381
|
+
LICENSE: "MIT",
|
|
382
|
+
},
|
|
383
|
+
);
|
|
384
|
+
const report = await checkPackage(root, { generic: false });
|
|
385
|
+
expect(codes(report.diagnostics)).not.toContain("RUNTIME_DEPENDENCY_MISSING");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("never scans a test-directory file outside a *.test.ts basename either (e.g. a shared test helper)", async () => {
|
|
389
|
+
const root = fixture(
|
|
390
|
+
{ ...base, files: ["extensions", "test", "README.md", "LICENSE"] },
|
|
391
|
+
{
|
|
392
|
+
"extensions/index.ts": "export default function () {}",
|
|
393
|
+
"test/helper.ts": 'import leftpad from "leftpad";\n',
|
|
394
|
+
"README.md": "# Example",
|
|
395
|
+
LICENSE: "MIT",
|
|
396
|
+
},
|
|
397
|
+
);
|
|
398
|
+
const report = await checkPackage(root, { generic: false });
|
|
399
|
+
expect(codes(report.diagnostics)).not.toContain("RUNTIME_DEPENDENCY_MISSING");
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("never treats a quoted 'from ...' phrase inside a comment as a real import specifier", async () => {
|
|
403
|
+
const root = fixture(
|
|
404
|
+
{ ...base, files: ["extensions", "README.md", "LICENSE"] },
|
|
405
|
+
{
|
|
406
|
+
"extensions/index.ts":
|
|
407
|
+
'/**\n * migrated "latest" from "some-package that looks like an import"\n */\nexport default function () {}\n',
|
|
408
|
+
"README.md": "# Example",
|
|
409
|
+
LICENSE: "MIT",
|
|
410
|
+
},
|
|
411
|
+
);
|
|
412
|
+
const report = await checkPackage(root, { generic: false });
|
|
413
|
+
expect(codes(report.diagnostics)).not.toContain("RUNTIME_DEPENDENCY_MISSING");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("still catches a real RUNTIME_DEPENDENCY_MISSING import sitting right next to a misleading comment", async () => {
|
|
417
|
+
const root = fixture(
|
|
418
|
+
{ ...base, files: ["extensions", "README.md", "LICENSE"] },
|
|
419
|
+
{
|
|
420
|
+
"extensions/index.ts":
|
|
421
|
+
'// migrated "latest" from "totally not a real package"\nimport real from "real-missing-dep";\nexport default function () { return real; }\n',
|
|
422
|
+
"README.md": "# Example",
|
|
423
|
+
LICENSE: "MIT",
|
|
424
|
+
},
|
|
425
|
+
);
|
|
426
|
+
const report = await checkPackage(root, { generic: false });
|
|
427
|
+
expect(codes(report.diagnostics)).toContain("RUNTIME_DEPENDENCY_MISSING");
|
|
428
|
+
const diagnostic = report.diagnostics.find((d) => d.code === "RUNTIME_DEPENDENCY_MISSING")!;
|
|
429
|
+
expect(diagnostic.message).toContain("real-missing-dep");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("never flags a package's own self-import of its own subpath export as a missing runtime dependency", async () => {
|
|
433
|
+
const root = fixture(
|
|
434
|
+
{ ...base, name: "@scope/self-ref", files: ["extensions", "README.md", "LICENSE"] },
|
|
435
|
+
{
|
|
436
|
+
"extensions/index.ts": 'import { thing } from "@scope/self-ref/client";\nexport default function () { return thing; }\n',
|
|
437
|
+
"README.md": "# Example",
|
|
438
|
+
LICENSE: "MIT",
|
|
439
|
+
},
|
|
440
|
+
);
|
|
441
|
+
const report = await checkPackage(root, { generic: false });
|
|
442
|
+
expect(codes(report.diagnostics)).not.toContain("RUNTIME_DEPENDENCY_MISSING");
|
|
443
|
+
});
|
|
444
|
+
});
|
package/service/test/cli.test.ts
CHANGED
|
@@ -898,7 +898,7 @@ describe("CLI", () => {
|
|
|
898
898
|
},
|
|
899
899
|
async doctor(projectRoot) {
|
|
900
900
|
calls.push(`doctor:${projectRoot}`);
|
|
901
|
-
return { ok: true, conflicts: [], extensions: [], scanned: 0, truncated: false };
|
|
901
|
+
return { ok: true, conflicts: [], extensions: [], scanned: 0, truncated: false, serviceUnits: [] };
|
|
902
902
|
},
|
|
903
903
|
async updatesForProject(projectRoot) {
|
|
904
904
|
calls.push(`updatesForProject:${projectRoot}`);
|
|
@@ -939,6 +939,7 @@ describe("CLI", () => {
|
|
|
939
939
|
extensions: [],
|
|
940
940
|
scanned: 0,
|
|
941
941
|
truncated: false,
|
|
942
|
+
serviceUnits: [],
|
|
942
943
|
});
|
|
943
944
|
expect(JSON.parse((await cliRun(["updates", "--project", "/tmp/project", "--json"], d)).out).updates).toEqual([]);
|
|
944
945
|
expect(calls).toEqual([
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { checkServiceUnitPaths, parseExecStartTokens, type ServiceDoctorDeps } from "../src/adoption/service-doctor.ts";
|
|
6
|
+
|
|
7
|
+
const roots: string[] = [];
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
function piHome(settingsPackages: unknown[]): string {
|
|
13
|
+
const root = mkdtempSync(join(tmpdir(), "packed-service-doctor-"));
|
|
14
|
+
roots.push(root);
|
|
15
|
+
writeFileSync(join(root, "settings.json"), JSON.stringify({ packages: settingsPackages }, null, 2));
|
|
16
|
+
return root;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A real installed package on disk, shaped so detectVehicleDaemonService recognizes it as a daemon (own bin + a real dependency on @danypops/vehicle-server). */
|
|
20
|
+
function installDaemonPackage(home: string, name: string): string {
|
|
21
|
+
const dir = join(home, "npm", "node_modules", name);
|
|
22
|
+
mkdirSync(dir, { recursive: true });
|
|
23
|
+
writeFileSync(
|
|
24
|
+
join(dir, "package.json"),
|
|
25
|
+
JSON.stringify({ name, version: "1.0.0", bin: { [name]: "cli.ts" }, dependencies: { "@danypops/vehicle-server": "^0.7.0" } }, null, 2),
|
|
26
|
+
);
|
|
27
|
+
writeFileSync(join(dir, "cli.ts"), "// real entrypoint, present on disk\n");
|
|
28
|
+
return dir;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fakeDeps(unitText: string | null): ServiceDoctorDeps {
|
|
32
|
+
return {
|
|
33
|
+
fileExists: () => unitText !== null,
|
|
34
|
+
readFile: () => unitText,
|
|
35
|
+
platform: "linux",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("parseExecStartTokens", () => {
|
|
40
|
+
it("parses shell-quoted tokens, unescaping the same characters shellQuote escapes", () => {
|
|
41
|
+
const unit = ['[Service]', 'ExecStart="/home/x/.bun/bin/bun" "/home/x/pkg/cli.ts" "serve"', ''].join("\n");
|
|
42
|
+
expect(parseExecStartTokens(unit)).toEqual(["/home/x/.bun/bin/bun", "/home/x/pkg/cli.ts", "serve"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("unescapes a backslash-escaped quote/backslash/dollar/backtick inside a token", () => {
|
|
46
|
+
const unit = 'ExecStart="/bin/bun" "/weird \\"path\\" with \\\\ and \\$ and \\`"';
|
|
47
|
+
expect(parseExecStartTokens(unit)).toEqual(["/bin/bun", '/weird "path" with \\ and $ and `']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns undefined when there is no ExecStart line at all", () => {
|
|
51
|
+
expect(parseExecStartTokens("[Service]\nType=simple\n")).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns undefined for an ExecStart line with no quoted tokens", () => {
|
|
55
|
+
expect(parseExecStartTokens("ExecStart=\n")).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("checkServiceUnitPaths", () => {
|
|
60
|
+
it("is silent on a non-linux platform without even reading installed packages", () => {
|
|
61
|
+
const home = piHome(["npm:whatever"]);
|
|
62
|
+
const report = checkServiceUnitPaths(home, undefined, { fileExists: () => true, readFile: () => "", platform: "darwin" });
|
|
63
|
+
expect(report).toEqual({ ok: true, diagnostics: [], checked: 0 });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("skips a package that never resolves to a Vehicle-shaped daemon", () => {
|
|
67
|
+
const home = piHome(["npm:not-a-daemon"]);
|
|
68
|
+
const dir = join(home, "npm", "node_modules", "not-a-daemon");
|
|
69
|
+
mkdirSync(dir, { recursive: true });
|
|
70
|
+
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "not-a-daemon", version: "1.0.0" }, null, 2));
|
|
71
|
+
const report = checkServiceUnitPaths(home, undefined, fakeDeps("ExecStart=\"/bin/true\"\n"));
|
|
72
|
+
expect(report).toEqual({ ok: true, diagnostics: [], checked: 0 });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("skips a daemon-shaped package with no systemd unit currently installed", () => {
|
|
76
|
+
const home = piHome(["npm:fakedaemon"]);
|
|
77
|
+
installDaemonPackage(home, "fakedaemon");
|
|
78
|
+
const report = checkServiceUnitPaths(home, undefined, { fileExists: () => false, readFile: () => null, platform: "linux" });
|
|
79
|
+
expect(report).toEqual({ ok: true, diagnostics: [], checked: 0 });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("reports clean when the installed unit's ExecStart paths all still exist", () => {
|
|
83
|
+
const home = piHome(["npm:fakedaemon"]);
|
|
84
|
+
const dir = installDaemonPackage(home, "fakedaemon");
|
|
85
|
+
const unit = `ExecStart="/bin/sh" "${join(dir, "cli.ts")}" "serve"\n`;
|
|
86
|
+
const report = checkServiceUnitPaths(home, undefined, fakeDeps(unit));
|
|
87
|
+
expect(report.checked).toBe(1);
|
|
88
|
+
expect(report.diagnostics).toEqual([]);
|
|
89
|
+
expect(report.ok).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("flags SERVICE_EXEC_PATH_MISSING with the exact missing path when ExecStart references a deleted file", () => {
|
|
93
|
+
const home = piHome(["npm:fakedaemon"]);
|
|
94
|
+
installDaemonPackage(home, "fakedaemon");
|
|
95
|
+
const missing = join(tmpdir(), "packed-service-doctor-definitely-missing", "cli.ts");
|
|
96
|
+
const unit = `ExecStart="/bin/sh" "${missing}" "serve"\n`;
|
|
97
|
+
const report = checkServiceUnitPaths(home, undefined, fakeDeps(unit));
|
|
98
|
+
expect(report.ok).toBe(false);
|
|
99
|
+
expect(report.diagnostics).toEqual([
|
|
100
|
+
{
|
|
101
|
+
code: "SERVICE_EXEC_PATH_MISSING",
|
|
102
|
+
severity: "error",
|
|
103
|
+
package: "fakedaemon",
|
|
104
|
+
unitName: "fakedaemon",
|
|
105
|
+
message: expect.stringContaining(missing),
|
|
106
|
+
},
|
|
107
|
+
]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("never flags a bare subcommand argument (no path separator) as a missing path", () => {
|
|
111
|
+
const home = piHome(["npm:fakedaemon"]);
|
|
112
|
+
const dir = installDaemonPackage(home, "fakedaemon");
|
|
113
|
+
// "serve" alone would resolve relative to this process's own cwd and could
|
|
114
|
+
// spuriously not exist there -- must never be treated as a path to check.
|
|
115
|
+
const unit = `ExecStart="/bin/sh" "${join(dir, "cli.ts")}" "serve"\n`;
|
|
116
|
+
const report = checkServiceUnitPaths(home, undefined, fakeDeps(unit));
|
|
117
|
+
expect(report.diagnostics.some((d) => d.message.includes('"serve"'))).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("degrades an unparseable ExecStart to a warning, never a false error", () => {
|
|
121
|
+
const home = piHome(["npm:fakedaemon"]);
|
|
122
|
+
installDaemonPackage(home, "fakedaemon");
|
|
123
|
+
const report = checkServiceUnitPaths(home, undefined, fakeDeps("[Service]\nType=simple\n"));
|
|
124
|
+
expect(report.ok).toBe(true);
|
|
125
|
+
expect(report.diagnostics).toEqual([
|
|
126
|
+
{
|
|
127
|
+
code: "SERVICE_EXEC_UNPARSEABLE",
|
|
128
|
+
severity: "warning",
|
|
129
|
+
package: "fakedaemon",
|
|
130
|
+
unitName: "fakedaemon",
|
|
131
|
+
message: expect.any(String),
|
|
132
|
+
},
|
|
133
|
+
]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("treats a raced fileExists=true/readFile=null as transient, not a finding", () => {
|
|
137
|
+
const home = piHome(["npm:fakedaemon"]);
|
|
138
|
+
installDaemonPackage(home, "fakedaemon");
|
|
139
|
+
const report = checkServiceUnitPaths(home, undefined, { fileExists: () => true, readFile: () => null, platform: "linux" });
|
|
140
|
+
expect(report).toEqual({ ok: true, diagnostics: [], checked: 1 });
|
|
141
|
+
});
|
|
142
|
+
});
|