@danypops/pi-packed 0.19.12 → 0.20.1
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/README.md +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1 -1
- package/dist/protocol.d.ts +0 -1
- package/dist/protocol.d.ts.map +1 -1
- package/extension/src/menu-theme.ts +17 -12
- package/extension/src/model.ts +11 -4
- package/extension/src/tui.ts +2 -2
- package/package.json +19 -6
- package/service/src/adoption/doctor.ts +5 -1
- package/service/src/adoption/service-doctor.ts +67 -94
- package/service/src/cli/cli.ts +32 -60
- package/service/src/daemon/client.ts +20 -26
- package/service/src/daemon/daemon-service.ts +127 -37
- package/service/src/daemon/daemon.ts +24 -2
- package/service/src/daemon/service.ts +76 -11
- package/service/src/daemon/vehicle-registration.ts +173 -0
- package/service/src/public/client.ts +7 -19
- package/service/src/public/protocol.ts +0 -1
- package/service/src/security/security.ts +2 -0
- package/service/src/shared/constants.ts +2 -0
- package/service/test/cli.test.ts +121 -137
- package/service/test/daemon-kit-migration.test.ts +1 -0
- package/service/test/daemon-maintenance.test.ts +127 -0
- package/service/test/daemon-service.test.ts +93 -40
- package/service/test/public-client.test.ts +1 -1
- package/service/test/security.test.ts +1 -0
- package/service/test/service-doctor.test.ts +53 -95
- package/service/test/service.test.ts +89 -13
- package/service/test/vehicle-registration.test.ts +121 -0
package/service/src/cli/cli.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { formatDoctorReport, runDoctor } from "../adoption/doctor.ts";
|
|
|
8
8
|
import { formatPackReport, NpmPackVerifier, type PackReport } from "../adoption/pack.ts";
|
|
9
9
|
import { type AdoptionReport, formatAdoptionReport, scoreTarget } from "../adoption/score.ts";
|
|
10
10
|
import type { PackageDaemonPort } from "../daemon/client.ts";
|
|
11
|
+
import type { OperationOutputs } from "../daemon/service.ts";
|
|
11
12
|
import { checkUpdates } from "../daemon/watcher.ts";
|
|
12
13
|
import { generateIndex, indexPath, readIndex } from "../index/build-index.ts";
|
|
13
14
|
import { syncCatalog } from "../packages/catalog.ts";
|
|
@@ -83,7 +84,7 @@ usage:
|
|
|
83
84
|
packed search <query> [--offline] [--json] search npm (or the local mirror with --offline)
|
|
84
85
|
packed info <name> [--json] package details
|
|
85
86
|
packed updates [--project <path>] [--json] updates per the local mirror; --project also checks that project's own .pi/settings.json pins
|
|
86
|
-
packed update <source> [--approve] [--
|
|
87
|
+
packed update <source> [--approve] [--json] update one configured package through Pi and reconcile its registered Vehicle
|
|
87
88
|
packed update --self [--approve] [--json] update Packed itself (npm-global installs only) and restart its supervised service
|
|
88
89
|
packed mirror [--json] sync upstream into the local SQLite index
|
|
89
90
|
packed installed [--json] installed pi packages
|
|
@@ -100,9 +101,10 @@ usage:
|
|
|
100
101
|
packed setup update [manifest] [--json] deliberately refresh immutable package resolutions
|
|
101
102
|
packed setup plan [manifest] [--prune] [--json] show an additive or exact setup diff without mutation
|
|
102
103
|
packed setup apply [manifest] [--prune] [--approve] [--json] apply an approved setup plan
|
|
103
|
-
packed install <source> [--approve] [--
|
|
104
|
-
packed install-service <source> --approve [--json]
|
|
104
|
+
packed install <source> [--approve] [--json] pi install npm:|git:|https://… via daemon and reconcile any declared Vehicle
|
|
105
|
+
packed install-service <source> --approve [--json] reconcile a package's declared Vehicle
|
|
105
106
|
packed restart-service <source> --approve [--json] restart a package's already-registered persistent service standalone (packed update already does this automatically)
|
|
107
|
+
packed reconcile-services --approve [--project <path>] [--json] sweep every installed package and self-heal its Vehicle registration (the daemon also runs this on its own interval and at startup)
|
|
106
108
|
packed remove <name> [--approve] [--json] remove by bare npm name via daemon
|
|
107
109
|
packed security [always|never] [--approve] [--json] read or set mutation approval policy
|
|
108
110
|
packed serve run the long-running daemon
|
|
@@ -138,14 +140,12 @@ export interface CliDeps {
|
|
|
138
140
|
apply(manifestPath: string, options?: { prune?: boolean }): Promise<SetupApplyResult>;
|
|
139
141
|
};
|
|
140
142
|
daemonService?: {
|
|
141
|
-
install(
|
|
142
|
-
source: string,
|
|
143
|
-
approved?: boolean,
|
|
144
|
-
): Promise<{ output: string; spec?: { name: string; binPath: string; descriptorPath: string } }>;
|
|
143
|
+
install(source: string, approved?: boolean): Promise<{ output: string; spec?: { name: string; binPath: string } }>;
|
|
145
144
|
restart(
|
|
146
145
|
source: string,
|
|
147
146
|
approved?: boolean,
|
|
148
|
-
): Promise<{ output: string; restarted?: boolean; spec?: { name: string; binPath: string
|
|
147
|
+
): Promise<{ output: string; restarted?: boolean; spec?: { name: string; binPath: string } }>;
|
|
148
|
+
reconcileAll(approved?: boolean, projectRoot?: string): Promise<OperationOutputs["package.reconcile_services"]>;
|
|
149
149
|
};
|
|
150
150
|
piVersion?: { check(): Promise<PiVersionReport> };
|
|
151
151
|
selfUpdater?: { run(): Promise<SelfUpdateReport> };
|
|
@@ -166,7 +166,6 @@ interface Flags {
|
|
|
166
166
|
force: boolean;
|
|
167
167
|
prune: boolean;
|
|
168
168
|
machineLocal: boolean;
|
|
169
|
-
noService: boolean;
|
|
170
169
|
ecosystem: boolean;
|
|
171
170
|
self: boolean;
|
|
172
171
|
project?: string;
|
|
@@ -183,7 +182,6 @@ function parseFlags(rest: string[]): { flags: Flags; pos: string[] } {
|
|
|
183
182
|
force: false,
|
|
184
183
|
prune: false,
|
|
185
184
|
machineLocal: false,
|
|
186
|
-
noService: false,
|
|
187
185
|
ecosystem: false,
|
|
188
186
|
self: false,
|
|
189
187
|
};
|
|
@@ -198,7 +196,6 @@ function parseFlags(rest: string[]): { flags: Flags; pos: string[] } {
|
|
|
198
196
|
else if (a === "--force") flags.force = true;
|
|
199
197
|
else if (a === "--prune") flags.prune = true;
|
|
200
198
|
else if (a === "--machine-local") flags.machineLocal = true;
|
|
201
|
-
else if (a === "--no-service") flags.noService = true;
|
|
202
199
|
else if (a === "--ecosystem") flags.ecosystem = true;
|
|
203
200
|
else if (a === "--self") flags.self = true;
|
|
204
201
|
else if (a === "--limit" && i + 1 < rest.length) flags.limit = Number(rest[++i]) || SEARCH_DEFAULT_LIMIT;
|
|
@@ -236,6 +233,7 @@ const PACKAGE_COMMAND_OPERATIONS: Record<string, PackageOperation | undefined> =
|
|
|
236
233
|
install: "install",
|
|
237
234
|
"install-service": "install_service",
|
|
238
235
|
"restart-service": "restart_service",
|
|
236
|
+
"reconcile-services": "reconcile_services",
|
|
239
237
|
remove: "remove",
|
|
240
238
|
pi: "pi.status",
|
|
241
239
|
advisories: "advisories.scan",
|
|
@@ -570,36 +568,14 @@ const commands: Record<string, { usage: string; run: Command }> = {
|
|
|
570
568
|
},
|
|
571
569
|
|
|
572
570
|
install: {
|
|
573
|
-
usage: "packed install npm:<pkg>[@ver] | git:<host>/<owner>/<repo>[@ref] | https://… [--
|
|
571
|
+
usage: "packed install npm:<pkg>[@ver] | git:<host>/<owner>/<repo>[@ref] | https://… [--json]",
|
|
574
572
|
async run(_rest, d, flags, pos) {
|
|
575
573
|
const source = pos[0] ?? "";
|
|
576
574
|
if (!SOURCE_RE.test(source)) return usageErr(`usage: ${commands.install!.usage}\n`);
|
|
577
575
|
try {
|
|
578
576
|
const output = await d.inst.install(source, { approved: flags.approved });
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
// mutation tier, so this isn't a new consent surface. Silent for the
|
|
582
|
-
// overwhelmingly common case (most Pi packages aren't daemons at all);
|
|
583
|
-
// --no-service skips the attempt entirely, and a genuine failure (detected
|
|
584
|
-
// a daemon but couldn't register it) is reported without failing the
|
|
585
|
-
// install itself, which already succeeded.
|
|
586
|
-
let serviceInstall: { detected: true; ok: boolean; output: string } | undefined;
|
|
587
|
-
if (!flags.noService && source.startsWith("npm:") && d.daemonService) {
|
|
588
|
-
try {
|
|
589
|
-
const svc = await d.daemonService.install(source, flags.approved);
|
|
590
|
-
serviceInstall = { detected: true, ok: true, output: svc.output };
|
|
591
|
-
} catch (e) {
|
|
592
|
-
if (!(e instanceof Error) || !(e as { notADaemon?: boolean }).notADaemon) {
|
|
593
|
-
serviceInstall = { detected: true, ok: false, output: e instanceof Error ? e.message : String(e) };
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
if (flags.json) return ok(`${JSON.stringify({ ok: true, source, output, ...(serviceInstall ? { serviceInstall } : {}) })}\n`);
|
|
598
|
-
let human = `${output}\n`;
|
|
599
|
-
if (serviceInstall?.ok) human += `${serviceInstall.output}\n`;
|
|
600
|
-
else if (serviceInstall && !serviceInstall.ok)
|
|
601
|
-
human += `note: detected a persistent-service daemon but could not register it: ${serviceInstall.output}\n`;
|
|
602
|
-
return ok(human);
|
|
577
|
+
if (flags.json) return ok(`${JSON.stringify({ ok: true, source, output })}\n`);
|
|
578
|
+
return ok(`${output}\n`);
|
|
603
579
|
} catch (e) {
|
|
604
580
|
const error = e instanceof Error ? e.message : String(e);
|
|
605
581
|
return flags.json ? fail(`${JSON.stringify({ ok: false, source, error })}\n`) : fail(`${error}\n`);
|
|
@@ -640,6 +616,21 @@ const commands: Record<string, { usage: string; run: Command }> = {
|
|
|
640
616
|
},
|
|
641
617
|
},
|
|
642
618
|
|
|
619
|
+
"reconcile-services": {
|
|
620
|
+
usage:
|
|
621
|
+
"packed reconcile-services --approve [--project <path>] [--json] (sweeps every installed package and self-heals its Vehicle registration through Armada)",
|
|
622
|
+
async run(_rest, d, flags) {
|
|
623
|
+
if (!d.daemonService) return fail("reconcile-services requires a running packed daemon\n");
|
|
624
|
+
try {
|
|
625
|
+
const result = await d.daemonService.reconcileAll(flags.approved, flags.project);
|
|
626
|
+
return flags.json ? ok(`${JSON.stringify(result)}\n`) : ok(`${result.output}\n`);
|
|
627
|
+
} catch (e) {
|
|
628
|
+
const error = e instanceof Error ? e.message : String(e);
|
|
629
|
+
return flags.json ? fail(`${JSON.stringify({ ok: false, error })}\n`) : fail(`${error}\n`);
|
|
630
|
+
}
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
|
|
643
634
|
security: {
|
|
644
635
|
usage: "packed security [always|never] [--json]",
|
|
645
636
|
async run(_rest, d, flags, pos) {
|
|
@@ -685,27 +676,10 @@ const commands: Record<string, { usage: string; run: Command }> = {
|
|
|
685
676
|
: `is already up to date${version ? ` at ${version}` : ""}`;
|
|
686
677
|
return ok(`${source} ${reason}\n`);
|
|
687
678
|
}
|
|
688
|
-
|
|
689
|
-
// the common non-daemon case, a failure reported without failing the update itself.
|
|
690
|
-
let serviceRestart: { detected: true; ok: boolean; output: string } | undefined;
|
|
691
|
-
if (!flags.noService && source.startsWith("npm:") && d.daemonService) {
|
|
692
|
-
try {
|
|
693
|
-
const svc = await d.daemonService.restart(source, flags.approved);
|
|
694
|
-
serviceRestart = { detected: true, ok: true, output: svc.output };
|
|
695
|
-
} catch (e) {
|
|
696
|
-
if (!(e instanceof Error) || !(e as { notADaemon?: boolean }).notADaemon) {
|
|
697
|
-
serviceRestart = { detected: true, ok: false, output: e instanceof Error ? e.message : String(e) };
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
if (flags.json) return ok(`${JSON.stringify({ ok: true, source, ...outcome, ...(serviceRestart ? { serviceRestart } : {}) })}\n`);
|
|
679
|
+
if (flags.json) return ok(`${JSON.stringify({ ok: true, source, ...outcome })}\n`);
|
|
702
680
|
const transition =
|
|
703
681
|
outcome.previousVersion && outcome.currentVersion ? ` (${outcome.previousVersion} → ${outcome.currentVersion})` : "";
|
|
704
|
-
|
|
705
|
-
if (serviceRestart?.ok) human += `${serviceRestart.output}\n`;
|
|
706
|
-
else if (serviceRestart && !serviceRestart.ok)
|
|
707
|
-
human += `note: could not restart its persistent service: ${serviceRestart.output}\n`;
|
|
708
|
-
return ok(`${human}Reload Pi with /reload to activate the updated package.\n`);
|
|
682
|
+
return ok(`${outcome.output}${transition}\nReload Pi with /reload to activate the updated package.\n`);
|
|
709
683
|
} catch (error) {
|
|
710
684
|
const message = error instanceof Error ? error.message : String(error);
|
|
711
685
|
return flags.json
|
|
@@ -755,10 +729,7 @@ const commands: Record<string, { usage: string; run: Command }> = {
|
|
|
755
729
|
},
|
|
756
730
|
};
|
|
757
731
|
|
|
758
|
-
/**
|
|
759
|
-
* is disabled (PI_PACKED_IDLE_SECS=0) since systemd's own Restart=always takes over lifecycle. This
|
|
760
|
-
* only ever prints (see the `service` command above); nothing here calls installUserService, so
|
|
761
|
-
* descriptorPath is never actually read/written -- required by ServiceSpec's shape regardless. */
|
|
732
|
+
/** Renders the legacy diagnostic unit without installing it; Armada owns service-manager mutation. */
|
|
762
733
|
export function renderUnit(execPath: string, cliPath: string, piBin?: string): string {
|
|
763
734
|
// systemd does not read shell rc files: PI_BIN must be explicit so the
|
|
764
735
|
// daemon's install/remove execs can find the pi binary.
|
|
@@ -767,10 +738,11 @@ export function renderUnit(execPath: string, cliPath: string, piBin?: string): s
|
|
|
767
738
|
return generateSystemdUnit({
|
|
768
739
|
name: "pi-packed",
|
|
769
740
|
displayName: "pi-packed package service (Pi agent)",
|
|
741
|
+
version: VERSION,
|
|
770
742
|
binPath: execPath,
|
|
771
743
|
args: [cliPath, "serve"],
|
|
772
744
|
env,
|
|
773
|
-
|
|
745
|
+
handlePath: resolvePackedPaths().handle,
|
|
774
746
|
// Packed's own client (connectPackageDaemon) never auto-spawns -- "start packed.service"
|
|
775
747
|
// or this unit's own systemd supervision is its only recovery path.
|
|
776
748
|
restartOnFailure: true,
|
|
@@ -25,6 +25,7 @@ import type { OperationInputs, OperationName, OperationOutputs } from "./service
|
|
|
25
25
|
|
|
26
26
|
export type FetchTransport = (request: Request) => Promise<Response>;
|
|
27
27
|
type RpcClient = AuthenticatedRpcClient<OperationName, OperationInputs, OperationOutputs>;
|
|
28
|
+
type ServiceSummary = { name: string; binPath: string };
|
|
28
29
|
|
|
29
30
|
export interface PackageDaemonPort {
|
|
30
31
|
search(query: string, limit: number, offline?: boolean): Promise<{ query: string; total: number; results: SearchPage["results"] }>;
|
|
@@ -46,14 +47,9 @@ export interface PackageDaemonPort {
|
|
|
46
47
|
security(): Promise<SecuritySettings>;
|
|
47
48
|
setMutationApproval(value: MutationApproval, approved?: boolean): Promise<SecuritySettings>;
|
|
48
49
|
install(source: string, approved?: boolean): Promise<string>;
|
|
49
|
-
installService(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
): Promise<{ output: string; spec?: { name: string; binPath: string; descriptorPath: string } }>;
|
|
53
|
-
restartService(
|
|
54
|
-
source: string,
|
|
55
|
-
approved?: boolean,
|
|
56
|
-
): Promise<{ output: string; restarted?: boolean; spec?: { name: string; binPath: string; descriptorPath: string } }>;
|
|
50
|
+
installService(source: string, approved?: boolean): Promise<{ output: string; spec?: ServiceSummary }>;
|
|
51
|
+
restartService(source: string, approved?: boolean): Promise<{ output: string; restarted?: boolean; spec?: ServiceSummary }>;
|
|
52
|
+
reconcileServices(approved?: boolean, projectRoot?: string): Promise<OperationOutputs["package.reconcile_services"]>;
|
|
57
53
|
remove(name: string, approved?: boolean): Promise<string>;
|
|
58
54
|
update(source: string, approved?: boolean): Promise<UpdateOutcome>;
|
|
59
55
|
piStatus(): Promise<PiVersionReport>;
|
|
@@ -230,10 +226,7 @@ export class PackageDaemonClient implements PackageDaemonPort {
|
|
|
230
226
|
return result.output;
|
|
231
227
|
}
|
|
232
228
|
|
|
233
|
-
async installService(
|
|
234
|
-
source: string,
|
|
235
|
-
approved = false,
|
|
236
|
-
): Promise<{ output: string; spec?: { name: string; binPath: string; descriptorPath: string } }> {
|
|
229
|
+
async installService(source: string, approved = false): Promise<{ output: string; spec?: ServiceSummary }> {
|
|
237
230
|
const result = await this.call("package.install_service", { source, approved });
|
|
238
231
|
if (!result.ok)
|
|
239
232
|
throw new PackageDaemonError(
|
|
@@ -248,7 +241,7 @@ export class PackageDaemonClient implements PackageDaemonPort {
|
|
|
248
241
|
async restartService(
|
|
249
242
|
source: string,
|
|
250
243
|
approved = false,
|
|
251
|
-
): Promise<{ output: string; restarted?: boolean; spec?:
|
|
244
|
+
): Promise<{ output: string; restarted?: boolean; spec?: ServiceSummary }> {
|
|
252
245
|
const result = await this.call("package.restart_service", { source, approved });
|
|
253
246
|
if (!result.ok)
|
|
254
247
|
throw new PackageDaemonError(
|
|
@@ -260,6 +253,12 @@ export class PackageDaemonClient implements PackageDaemonPort {
|
|
|
260
253
|
return { output: result.output, restarted: result.restarted, spec: result.spec };
|
|
261
254
|
}
|
|
262
255
|
|
|
256
|
+
async reconcileServices(approved = false, projectRoot?: string): Promise<OperationOutputs["package.reconcile_services"]> {
|
|
257
|
+
const result = await this.call("package.reconcile_services", { approved, projectRoot });
|
|
258
|
+
if (!result.ok) throw new PackageDaemonError(result.output || "failed to reconcile Vehicle services", "package.reconcile_services");
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
261
|
+
|
|
263
262
|
async remove(name: string, approved = false): Promise<string> {
|
|
264
263
|
const result = await this.call("package.remove", { name, approved });
|
|
265
264
|
if (!result.ok) throw new PackageDaemonError(result.output || `failed to remove ${name}`, "package.remove");
|
|
@@ -319,14 +318,9 @@ export class DaemonBackedInstaller implements Installer {
|
|
|
319
318
|
}
|
|
320
319
|
|
|
321
320
|
export interface DaemonServiceInstallerPort {
|
|
322
|
-
install(
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
): Promise<{ output: string; spec?: { name: string; binPath: string; descriptorPath: string } }>;
|
|
326
|
-
restart(
|
|
327
|
-
source: string,
|
|
328
|
-
approved?: boolean,
|
|
329
|
-
): Promise<{ output: string; restarted?: boolean; spec?: { name: string; binPath: string; descriptorPath: string } }>;
|
|
321
|
+
install(source: string, approved?: boolean): Promise<{ output: string; spec?: ServiceSummary }>;
|
|
322
|
+
restart(source: string, approved?: boolean): Promise<{ output: string; restarted?: boolean; spec?: ServiceSummary }>;
|
|
323
|
+
reconcileAll(approved?: boolean, projectRoot?: string): Promise<OperationOutputs["package.reconcile_services"]>;
|
|
330
324
|
}
|
|
331
325
|
|
|
332
326
|
export class DaemonBackedDaemonServiceInstaller implements DaemonServiceInstallerPort {
|
|
@@ -334,15 +328,15 @@ export class DaemonBackedDaemonServiceInstaller implements DaemonServiceInstalle
|
|
|
334
328
|
async restart(
|
|
335
329
|
source: string,
|
|
336
330
|
approved?: boolean,
|
|
337
|
-
): Promise<{ output: string; restarted?: boolean; spec?:
|
|
331
|
+
): Promise<{ output: string; restarted?: boolean; spec?: ServiceSummary }> {
|
|
338
332
|
return (await connectPackageDaemon(this.paths)).restartService(source, approved);
|
|
339
333
|
}
|
|
340
|
-
async install(
|
|
341
|
-
source: string,
|
|
342
|
-
approved?: boolean,
|
|
343
|
-
): Promise<{ output: string; spec?: { name: string; binPath: string; descriptorPath: string } }> {
|
|
334
|
+
async install(source: string, approved?: boolean): Promise<{ output: string; spec?: ServiceSummary }> {
|
|
344
335
|
return (await connectPackageDaemon(this.paths)).installService(source, approved);
|
|
345
336
|
}
|
|
337
|
+
async reconcileAll(approved?: boolean, projectRoot?: string): Promise<OperationOutputs["package.reconcile_services"]> {
|
|
338
|
+
return (await connectPackageDaemon(this.paths)).reconcileServices(approved, projectRoot);
|
|
339
|
+
}
|
|
346
340
|
}
|
|
347
341
|
|
|
348
342
|
export class DaemonRegistry implements Registry {
|
|
@@ -26,25 +26,30 @@
|
|
|
26
26
|
* on disk instead of asking for one more declaration.
|
|
27
27
|
*/
|
|
28
28
|
import { readFileSync } from "node:fs";
|
|
29
|
-
import {
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import { createVehicleRegistrar, type VehicleRegistrar } from "@danypops/armada";
|
|
30
31
|
import { resolveDaemonPaths } from "@danypops/vehicle-server/paths";
|
|
31
32
|
import {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
isServiceInstalled,
|
|
35
|
-
type ServiceInstallDeps,
|
|
33
|
+
isVehicleServiceRegistered,
|
|
34
|
+
registerVehicleService,
|
|
36
35
|
type ServiceInstallResult,
|
|
37
36
|
type ServiceSpec,
|
|
37
|
+
unregisterVehicleService,
|
|
38
38
|
} from "@danypops/vehicle-server/service";
|
|
39
|
-
import { npmPackageName } from "../packages/installed.ts";
|
|
39
|
+
import { npmPackageName, readInstalledPackagesAcrossScopes } from "../packages/installed.ts";
|
|
40
40
|
|
|
41
41
|
export interface DaemonServiceManifest {
|
|
42
42
|
/** Relative to the installed package's own root directory. */
|
|
43
43
|
binPath: string;
|
|
44
44
|
args?: string[];
|
|
45
|
-
/** Defaults to the bare npm package name (scope stripped).
|
|
45
|
+
/** Defaults to the bare npm package name (scope stripped). */
|
|
46
46
|
name?: string;
|
|
47
47
|
displayName?: string;
|
|
48
|
+
/** Runtime handle filename inside the Vehicle's XDG runtime directory. */
|
|
49
|
+
handleFilename?: string;
|
|
50
|
+
workingDirectory?: string;
|
|
51
|
+
restartOnFailure?: boolean;
|
|
52
|
+
restartSec?: number;
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
export type ResolveDaemonServiceResult =
|
|
@@ -63,10 +68,16 @@ interface ResolvedDaemonEntrypoint {
|
|
|
63
68
|
args?: string[];
|
|
64
69
|
name: string;
|
|
65
70
|
displayName?: string;
|
|
71
|
+
handleFilename?: string;
|
|
72
|
+
workingDirectory?: string;
|
|
73
|
+
restartOnFailure?: boolean;
|
|
74
|
+
restartSec?: number;
|
|
75
|
+
version: string;
|
|
66
76
|
}
|
|
67
77
|
|
|
68
78
|
interface InstalledPackageJson {
|
|
69
79
|
name?: string;
|
|
80
|
+
version?: string;
|
|
70
81
|
bin?: string | Record<string, string>;
|
|
71
82
|
dependencies?: Record<string, string>;
|
|
72
83
|
packed?: { daemonService?: DaemonServiceManifest };
|
|
@@ -122,8 +133,8 @@ export function detectVehicleDaemonService(
|
|
|
122
133
|
if (!own) return undefined;
|
|
123
134
|
|
|
124
135
|
const ownBin = firstBinPath(own);
|
|
125
|
-
if (ownBin && dependsOnVehicle(own)) {
|
|
126
|
-
return { binPath: join(packageDir, ownBin), args: ["serve"], name: unscopedName(own.name ?? fallbackName) };
|
|
136
|
+
if (ownBin && dependsOnVehicle(own) && own.version) {
|
|
137
|
+
return { binPath: join(packageDir, ownBin), args: ["serve"], name: unscopedName(own.name ?? fallbackName), version: own.version };
|
|
127
138
|
}
|
|
128
139
|
|
|
129
140
|
for (const depName of Object.keys(own.dependencies ?? {})) {
|
|
@@ -135,8 +146,8 @@ export function detectVehicleDaemonService(
|
|
|
135
146
|
const dep = readPackageJson(depDir);
|
|
136
147
|
if (!dep) continue;
|
|
137
148
|
const depBin = firstBinPath(dep);
|
|
138
|
-
if (depBin && dependsOnVehicle(dep)) {
|
|
139
|
-
return { binPath: join(depDir, depBin), args: ["serve"], name: unscopedName(dep.name ?? depName) };
|
|
149
|
+
if (depBin && dependsOnVehicle(dep) && dep.version) {
|
|
150
|
+
return { binPath: join(depDir, depBin), args: ["serve"], name: unscopedName(dep.name ?? depName), version: dep.version };
|
|
140
151
|
}
|
|
141
152
|
}
|
|
142
153
|
}
|
|
@@ -146,20 +157,21 @@ export function detectVehicleDaemonService(
|
|
|
146
157
|
function buildSpec(entry: ResolvedDaemonEntrypoint): ServiceSpec {
|
|
147
158
|
const paths = resolveDaemonPaths({
|
|
148
159
|
stateDirectoryName: entry.name,
|
|
149
|
-
// Only serviceDescriptor is used below; these three exist purely to
|
|
150
|
-
// satisfy resolveDaemonPaths()'s shared shape for a package this
|
|
151
|
-
// module never opens the db/token/handle of.
|
|
152
160
|
databaseFilename: "unused.db",
|
|
153
161
|
tokenFilename: "unused-token",
|
|
154
|
-
handleFilename: "
|
|
162
|
+
handleFilename: entry.handleFilename ?? "handle.json",
|
|
155
163
|
systemdUnitName: `${entry.name}.service`,
|
|
156
164
|
});
|
|
157
165
|
return {
|
|
158
166
|
name: entry.name,
|
|
159
167
|
displayName: entry.displayName,
|
|
168
|
+
version: entry.version,
|
|
160
169
|
binPath: entry.binPath,
|
|
161
170
|
args: entry.args,
|
|
162
|
-
|
|
171
|
+
handlePath: paths.handle,
|
|
172
|
+
workingDirectory: entry.workingDirectory,
|
|
173
|
+
...(entry.restartOnFailure === undefined ? {} : { restartOnFailure: entry.restartOnFailure }),
|
|
174
|
+
...(entry.restartSec === undefined ? {} : { restartSec: entry.restartSec }),
|
|
163
175
|
};
|
|
164
176
|
}
|
|
165
177
|
|
|
@@ -187,6 +199,11 @@ export function resolveDaemonServiceSpec(piHome: string, source: string): Resolv
|
|
|
187
199
|
args: manifest.args,
|
|
188
200
|
name: manifest.name ?? unscopedName(packageName),
|
|
189
201
|
displayName: manifest.displayName,
|
|
202
|
+
handleFilename: manifest.handleFilename,
|
|
203
|
+
workingDirectory: manifest.workingDirectory,
|
|
204
|
+
restartOnFailure: manifest.restartOnFailure,
|
|
205
|
+
restartSec: manifest.restartSec,
|
|
206
|
+
version: pkg.version ?? "0.0.0",
|
|
190
207
|
}),
|
|
191
208
|
};
|
|
192
209
|
}
|
|
@@ -201,49 +218,122 @@ export function resolveDaemonServiceSpec(piHome: string, source: string): Resolv
|
|
|
201
218
|
};
|
|
202
219
|
}
|
|
203
220
|
|
|
221
|
+
export interface ReconcileAllResult {
|
|
222
|
+
reconciled: Array<{ packageName: string; vehicleName: string; installed: boolean; reason?: string }>;
|
|
223
|
+
skipped: number;
|
|
224
|
+
failed: Array<{ packageName: string; reason: string }>;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Matches readPackageDeclarations' own bound -- a reconcile-all sweep never processes an unbounded package list. */
|
|
228
|
+
const MAX_RECONCILE_PACKAGES = 500;
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Sweeps every installed Packed package (global scope, plus a project's own
|
|
232
|
+
* pins when projectRoot is given), resolves each to a Vehicle-shaped daemon
|
|
233
|
+
* exactly as install()/restart() already do per-package, and upserts +
|
|
234
|
+
* reconciles it through Armada. This is what makes Armada authoritative for
|
|
235
|
+
* every Vehicle Packed knows about, not just the one source a single
|
|
236
|
+
* install/update call happened to touch -- it also self-heals a Vehicle
|
|
237
|
+
* whose daemon package version bumped as someone else's transitive
|
|
238
|
+
* dependency, and a Vehicle a prior Packed version never registered at all.
|
|
239
|
+
* Idempotent and safe to call unconditionally: a non-daemon package costs
|
|
240
|
+
* one or two file reads (see resolveDaemonServiceSpec) and never reaches
|
|
241
|
+
* Armada. Two packages that resolve to the same Vehicle (a Pi extension and
|
|
242
|
+
* its own daemon dependency, both separately Packed-tracked) reconcile it
|
|
243
|
+
* once, not twice.
|
|
244
|
+
*/
|
|
245
|
+
export async function reconcileAllDaemonServices(
|
|
246
|
+
piHome: string,
|
|
247
|
+
projectRoot: string | undefined,
|
|
248
|
+
installer: Pick<DaemonServiceInstaller, "install">,
|
|
249
|
+
): Promise<ReconcileAllResult> {
|
|
250
|
+
const packages = readInstalledPackagesAcrossScopes(piHome, projectRoot).slice(0, MAX_RECONCILE_PACKAGES);
|
|
251
|
+
const reconciled: ReconcileAllResult["reconciled"] = [];
|
|
252
|
+
const failed: ReconcileAllResult["failed"] = [];
|
|
253
|
+
const seen = new Set<string>();
|
|
254
|
+
let skipped = 0;
|
|
255
|
+
for (const pkg of packages) {
|
|
256
|
+
const resolved = await installer.install(piHome, `npm:${pkg.name}`);
|
|
257
|
+
if (!resolved.ok) {
|
|
258
|
+
if (resolved.notADaemon) {
|
|
259
|
+
skipped++;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
failed.push({ packageName: pkg.name, reason: resolved.reason });
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (seen.has(resolved.spec.name)) continue;
|
|
266
|
+
seen.add(resolved.spec.name);
|
|
267
|
+
reconciled.push({
|
|
268
|
+
packageName: pkg.name,
|
|
269
|
+
vehicleName: resolved.spec.name,
|
|
270
|
+
installed: resolved.result.installed,
|
|
271
|
+
...(resolved.result.installed ? {} : { reason: resolved.result.reason }),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return { reconciled, skipped, failed };
|
|
275
|
+
}
|
|
276
|
+
|
|
204
277
|
export interface DaemonServiceInstaller {
|
|
205
278
|
install(
|
|
206
279
|
piHome: string,
|
|
207
280
|
source: string,
|
|
208
|
-
): { ok: true; result: ServiceInstallResult; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean }
|
|
281
|
+
): Promise<{ ok: true; result: ServiceInstallResult; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean }>;
|
|
282
|
+
remove(
|
|
283
|
+
piHome: string,
|
|
284
|
+
source: string,
|
|
285
|
+
): Promise<{ ok: true; result: ServiceInstallResult; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean }>;
|
|
209
286
|
restart(
|
|
210
287
|
piHome: string,
|
|
211
288
|
source: string,
|
|
212
|
-
): { ok: true; restarted: boolean; reason?: string; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean }
|
|
289
|
+
): Promise<{ ok: true; restarted: boolean; reason?: string; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean }>;
|
|
213
290
|
}
|
|
214
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Calls Armada's own VehicleRegistrar directly (in-process), rather than
|
|
294
|
+
* shelling out to its CLI as a subprocess -- the same registration logic
|
|
295
|
+
* `@danypops/armada` exposes to any other library consumer, not a Packed-
|
|
296
|
+
* specific reimplementation. One registrar per instance so its manifest
|
|
297
|
+
* path/native controller are resolved once, not on every call.
|
|
298
|
+
*/
|
|
215
299
|
export class RealDaemonServiceInstaller implements DaemonServiceInstaller {
|
|
216
|
-
|
|
300
|
+
constructor(private readonly registrar: VehicleRegistrar = createVehicleRegistrar()) {}
|
|
301
|
+
|
|
302
|
+
async install(
|
|
217
303
|
piHome: string,
|
|
218
304
|
source: string,
|
|
219
|
-
): { ok: true; result: ServiceInstallResult; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean } {
|
|
305
|
+
): Promise<{ ok: true; result: ServiceInstallResult; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean }> {
|
|
220
306
|
const resolved = resolveDaemonServiceSpec(piHome, source);
|
|
221
307
|
if (!resolved.ok) return resolved;
|
|
222
|
-
const result =
|
|
308
|
+
const result = await registerVehicleService(resolved.spec, this.registrar);
|
|
223
309
|
return { ok: true, result, spec: resolved.spec };
|
|
224
310
|
}
|
|
225
311
|
|
|
226
|
-
|
|
312
|
+
async remove(
|
|
313
|
+
piHome: string,
|
|
314
|
+
source: string,
|
|
315
|
+
): Promise<{ ok: true; result: ServiceInstallResult; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean }> {
|
|
316
|
+
const resolved = resolveDaemonServiceSpec(piHome, source);
|
|
317
|
+
if (!resolved.ok) return resolved;
|
|
318
|
+
const result = await unregisterVehicleService(resolved.spec.name, this.registrar);
|
|
319
|
+
return { ok: true, result, spec: resolved.spec };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async restart(
|
|
227
323
|
piHome: string,
|
|
228
324
|
source: string,
|
|
229
|
-
): { ok: true; restarted: boolean; reason?: string; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean } {
|
|
325
|
+
): Promise<{ ok: true; restarted: boolean; reason?: string; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean }> {
|
|
230
326
|
const resolved = resolveDaemonServiceSpec(piHome, source);
|
|
231
327
|
if (!resolved.ok) return resolved;
|
|
232
|
-
|
|
233
|
-
if (!isServiceInstalled(resolved.spec, deps)) {
|
|
328
|
+
if (!(await isVehicleServiceRegistered(resolved.spec.name, this.registrar))) {
|
|
234
329
|
return { ok: true, restarted: false, reason: `no persistent service is registered for ${resolved.spec.name}`, spec: resolved.spec };
|
|
235
330
|
}
|
|
236
|
-
const result =
|
|
237
|
-
return {
|
|
331
|
+
const result = await registerVehicleService(resolved.spec, this.registrar);
|
|
332
|
+
return {
|
|
333
|
+
ok: true,
|
|
334
|
+
restarted: result.installed,
|
|
335
|
+
...(result.installed ? {} : { reason: result.reason }),
|
|
336
|
+
spec: resolved.spec,
|
|
337
|
+
};
|
|
238
338
|
}
|
|
239
339
|
}
|
|
240
|
-
|
|
241
|
-
/** Linux/systemd only, matching installUserService's platform coverage order -- macOS/Windows report unsupported rather than guessing at launchctl/reg.exe restart equivalents this module doesn't implement. */
|
|
242
|
-
export function restartUserService(spec: ServiceSpec, deps: ServiceInstallDeps): { restarted: boolean; reason?: string } {
|
|
243
|
-
const platform = deps.platform ?? process.platform;
|
|
244
|
-
if (platform !== "linux")
|
|
245
|
-
return { restarted: false, reason: `restart is not supported on platform "${platform}" yet -- restart ${spec.name} manually` };
|
|
246
|
-
const result = deps.runCommand("systemctl", ["--user", "restart", basename(spec.descriptorPath)]);
|
|
247
|
-
if (!result.ok) return { restarted: false, reason: `systemctl --user restart failed: ${result.output}` };
|
|
248
|
-
return { restarted: true };
|
|
249
|
-
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
startDaemon,
|
|
8
8
|
} from "@danypops/vehicle-server/daemon";
|
|
9
9
|
import { ensureAuthToken } from "@danypops/vehicle-server/paths";
|
|
10
|
+
import { type DaemonServiceInstaller, RealDaemonServiceInstaller, reconcileAllDaemonServices } from "./daemon-service.ts";
|
|
10
11
|
import { generateIndex, indexPath, indexStatus } from "../index/build-index.ts";
|
|
11
12
|
import { catalogStatus, syncCatalog } from "../packages/catalog.ts";
|
|
12
13
|
import { latestVersion, openDb } from "../packages/db.ts";
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
ENV,
|
|
20
21
|
IDLE_BUDGET_DEFAULT_MS,
|
|
21
22
|
INDEX_INTERVAL_DEFAULT_MS,
|
|
23
|
+
RECONCILE_INTERVAL_DEFAULT_MS,
|
|
22
24
|
WATCH_INTERVAL_DEFAULT_MS,
|
|
23
25
|
WATCHDOG_TICK_MS,
|
|
24
26
|
} from "../shared/constants.ts";
|
|
@@ -35,12 +37,13 @@ export interface StartPackedDaemonOptions {
|
|
|
35
37
|
reg?: Registry;
|
|
36
38
|
inst?: Installer;
|
|
37
39
|
piHome?: string;
|
|
40
|
+
daemonServiceInstaller?: DaemonServiceInstaller;
|
|
38
41
|
maintenanceTasks?: MaintenanceTask[];
|
|
39
42
|
idleBudgetMs?: number;
|
|
40
43
|
migrateLegacy?: boolean;
|
|
41
44
|
}
|
|
42
45
|
|
|
43
|
-
function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOptions {
|
|
46
|
+
export function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOptions {
|
|
44
47
|
const paths = options.paths ?? resolvePackedPaths();
|
|
45
48
|
if (options.migrateLegacy ?? options.paths === undefined) migrateLegacyPackedState(paths, legacyPackedStateDirectory());
|
|
46
49
|
const token = ensureAuthToken(paths.token, "Packed");
|
|
@@ -48,6 +51,7 @@ function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOptions {
|
|
|
48
51
|
const inst = options.inst ?? new ExecInstaller();
|
|
49
52
|
const piHome = options.piHome ?? defaultPiHome();
|
|
50
53
|
const database = openDb(paths.database);
|
|
54
|
+
const daemonServiceInstaller = options.daemonServiceInstaller ?? new RealDaemonServiceInstaller();
|
|
51
55
|
const configuredMaintenanceTasks = options.maintenanceTasks ?? [
|
|
52
56
|
{
|
|
53
57
|
name: "package-update-check",
|
|
@@ -75,6 +79,24 @@ function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOptions {
|
|
|
75
79
|
if (indexStatus(indexPath(dataDirectory), ttlMs).stale) await generateIndex(reg, dataDirectory, indexPath(dataDirectory));
|
|
76
80
|
},
|
|
77
81
|
},
|
|
82
|
+
{
|
|
83
|
+
// Self-heals a Vehicle a running daemon never picked up -- an out-of-band
|
|
84
|
+
// npm install/update, or a transitive dependency bump that resolveDaemonServiceSpec
|
|
85
|
+
// would only otherwise re-check the next time /install or /restart-service ran
|
|
86
|
+
// for that exact package.
|
|
87
|
+
name: "vehicle-reconcile",
|
|
88
|
+
intervalMs: envMs(ENV.RECONCILE_SECS, RECONCILE_INTERVAL_DEFAULT_MS),
|
|
89
|
+
run: async () => {
|
|
90
|
+
const result = await reconcileAllDaemonServices(piHome, undefined, daemonServiceInstaller);
|
|
91
|
+
if (result.failed.length > 0) {
|
|
92
|
+
logger.warn("vehicle-reconcile completed with failures", {
|
|
93
|
+
reconciled: result.reconciled.length,
|
|
94
|
+
skipped: result.skipped,
|
|
95
|
+
failed: result.failed.length,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
},
|
|
78
100
|
];
|
|
79
101
|
const maintenanceTasks = configuredMaintenanceTasks.filter((task) => task.intervalMs > 0);
|
|
80
102
|
|
|
@@ -93,7 +115,7 @@ function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOptions {
|
|
|
93
115
|
maintenanceTasks,
|
|
94
116
|
idleBudgetMs: options.idleBudgetMs ?? envMs(ENV.IDLE_SECS, IDLE_BUDGET_DEFAULT_MS),
|
|
95
117
|
idleTickMs: WATCHDOG_TICK_MS,
|
|
96
|
-
buildApp: () => createApp({ reg, inst, token, stateDir: paths.stateDirectory, dataDir: dirname(paths.database), piHome }),
|
|
118
|
+
buildApp: () => createApp({ reg, inst, token, stateDir: paths.stateDirectory, dataDir: dirname(paths.database), piHome, daemonServiceInstaller }),
|
|
97
119
|
onShutdown: () => database.close(),
|
|
98
120
|
};
|
|
99
121
|
}
|