@danypops/pi-packed 0.19.11 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts.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/model.ts +11 -4
- package/package.json +17 -5
- package/service/src/adoption/check.ts +39 -4
- package/service/src/adoption/doctor.ts +21 -1
- package/service/src/adoption/service-doctor.ts +101 -0
- 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/check.test.ts +76 -0
- package/service/test/cli.test.ts +123 -138
- 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 +100 -0
- package/service/test/service.test.ts +89 -13
- package/service/test/vehicle-registration.test.ts +121 -0
|
@@ -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
|
}
|
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { existsSync as fileExistsSync } from "node:fs";
|
|
8
|
+
import { VehicleRegistry } from "@danypops/vehicle-server";
|
|
9
|
+
import { createVehicleHttpApp } from "@danypops/vehicle-server/http";
|
|
8
10
|
import { errorResponse, healthResponse, jsonResponse, readyResponse, requireBearerToken } from "@danypops/vehicle-server/rpc-http";
|
|
9
11
|
import type { ServiceSpec } from "@danypops/vehicle-server/service";
|
|
12
|
+
import { registerPackedVehicleOperations } from "./vehicle-registration.ts";
|
|
10
13
|
import { type AdvisoryReport, resolveInstalledVersions, scanInstalledPackages } from "../adoption/advisories.ts";
|
|
11
14
|
import { type CheckReport, type PackageChecker, StaticPackageChecker } from "../adoption/check.ts";
|
|
12
15
|
import { type DoctorReport, runDoctor } from "../adoption/doctor.ts";
|
|
@@ -42,7 +45,7 @@ import { SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT } from "../shared/constants.ts";
|
|
|
42
45
|
import { createLogger } from "../shared/log.ts";
|
|
43
46
|
import { VERSION } from "../shared/version.ts";
|
|
44
47
|
import { formatCleanupSummary, runCleanup } from "./cleanup.ts";
|
|
45
|
-
import { type DaemonServiceInstaller, RealDaemonServiceInstaller } from "./daemon-service.ts";
|
|
48
|
+
import { type DaemonServiceInstaller, type ReconcileAllResult, reconcileAllDaemonServices, RealDaemonServiceInstaller } from "./daemon-service.ts";
|
|
46
49
|
import { checkUpdates, loadUpdates } from "./watcher.ts";
|
|
47
50
|
|
|
48
51
|
const log = createLogger("service");
|
|
@@ -90,6 +93,7 @@ export type OperationName =
|
|
|
90
93
|
| "package.install"
|
|
91
94
|
| "package.install_service"
|
|
92
95
|
| "package.restart_service"
|
|
96
|
+
| "package.reconcile_services"
|
|
93
97
|
| "package.remove"
|
|
94
98
|
| "package.update"
|
|
95
99
|
| "resources.list"
|
|
@@ -120,6 +124,7 @@ export interface OperationInputs {
|
|
|
120
124
|
"package.install": { source: string; approved?: boolean };
|
|
121
125
|
"package.install_service": { source: string; approved?: boolean };
|
|
122
126
|
"package.restart_service": { source: string; approved?: boolean };
|
|
127
|
+
"package.reconcile_services": { approved?: boolean; projectRoot?: string };
|
|
123
128
|
"package.remove": { name: string; approved?: boolean };
|
|
124
129
|
"package.update": { source: string; approved?: boolean };
|
|
125
130
|
"resources.list": { projectRoot?: string };
|
|
@@ -138,12 +143,13 @@ interface UpdateMutationResponse extends MutationResponse, Partial<Omit<UpdateOu
|
|
|
138
143
|
interface InstallServiceResponse {
|
|
139
144
|
ok: boolean;
|
|
140
145
|
output: string;
|
|
141
|
-
spec?: Pick<ServiceSpec, "name" | "binPath"
|
|
146
|
+
spec?: Pick<ServiceSpec, "name" | "binPath">;
|
|
142
147
|
notADaemon?: boolean;
|
|
143
148
|
}
|
|
144
149
|
interface RestartServiceResponse extends InstallServiceResponse {
|
|
145
150
|
restarted?: boolean;
|
|
146
151
|
}
|
|
152
|
+
interface ReconcileServicesResponse extends MutationResponse, ReconcileAllResult {}
|
|
147
153
|
|
|
148
154
|
export interface OperationOutputs {
|
|
149
155
|
"package.search": { query: string; total: number; results: SearchPage["results"]; offline?: boolean };
|
|
@@ -166,6 +172,7 @@ export interface OperationOutputs {
|
|
|
166
172
|
"package.install": MutationResponse;
|
|
167
173
|
"package.install_service": InstallServiceResponse;
|
|
168
174
|
"package.restart_service": RestartServiceResponse;
|
|
175
|
+
"package.reconcile_services": ReconcileServicesResponse;
|
|
169
176
|
"package.remove": MutationResponse;
|
|
170
177
|
"package.update": UpdateMutationResponse;
|
|
171
178
|
"resources.list": { global: PackageResources[]; project: PackageResources[] };
|
|
@@ -197,6 +204,7 @@ export const OPERATION_NAMES: readonly OperationName[] = [
|
|
|
197
204
|
"package.install",
|
|
198
205
|
"package.install_service",
|
|
199
206
|
"package.restart_service",
|
|
207
|
+
"package.reconcile_services",
|
|
200
208
|
"package.remove",
|
|
201
209
|
"package.update",
|
|
202
210
|
"resources.list",
|
|
@@ -227,8 +235,8 @@ function err(status: number, msg: string, details: Record<string, unknown> = {})
|
|
|
227
235
|
return jsonResponse({ error: msg, ...details }, { status });
|
|
228
236
|
}
|
|
229
237
|
|
|
230
|
-
function pickSpec(spec: ServiceSpec): Pick<ServiceSpec, "name" | "binPath"
|
|
231
|
-
return { name: spec.name, binPath: spec.binPath
|
|
238
|
+
function pickSpec(spec: ServiceSpec): Pick<ServiceSpec, "name" | "binPath"> {
|
|
239
|
+
return { name: spec.name, binPath: spec.binPath };
|
|
232
240
|
}
|
|
233
241
|
|
|
234
242
|
export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Response> } {
|
|
@@ -240,6 +248,13 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
240
248
|
const daemonServiceInstaller = deps.daemonServiceInstaller ?? new RealDaemonServiceInstaller();
|
|
241
249
|
const piHomeForServiceInstall = deps.piHome ?? defaultPiHome();
|
|
242
250
|
|
|
251
|
+
// Additive, real Vehicle protocol surface for this daemon's full operation set -- served
|
|
252
|
+
// at /vehicle/* alongside (not replacing) /api/v1/ops below. Every operation delegates to
|
|
253
|
+
// the exact same executeOperation() this route's own /api/v1/ops handler calls.
|
|
254
|
+
const vehicleRegistry = new VehicleRegistry({ name: "packed", version: VERSION, description: "Pi package lifecycle daemon" });
|
|
255
|
+
registerPackedVehicleOperations(vehicleRegistry, executeOperation);
|
|
256
|
+
const vehicleApp = createVehicleHttpApp({ registry: vehicleRegistry, token: deps.token });
|
|
257
|
+
|
|
243
258
|
function authorize(operation: PackageOperation, approved: boolean): Response | undefined {
|
|
244
259
|
try {
|
|
245
260
|
assertPackagePermission(readSecuritySettings(deps.stateDir), operation, approved);
|
|
@@ -331,11 +346,21 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
331
346
|
// pi.cleanup is read and applied before delegating to pi remove --
|
|
332
347
|
// once pi remove finishes, an npm-sourced package's own directory
|
|
333
348
|
// (and its manifest) may already be gone.
|
|
334
|
-
const
|
|
349
|
+
const piHome = deps.piHome ?? defaultPiHome();
|
|
350
|
+
const installedDir = resolveInstalledDir(piHome, `npm:${name}`);
|
|
351
|
+
let serviceRemoved = false;
|
|
352
|
+
if (installedDir) {
|
|
353
|
+
const service = await daemonServiceInstaller.remove(piHome, `npm:${name}`);
|
|
354
|
+
if (!service.ok && !service.notADaemon) return json({ ok: false, name, output: service.reason });
|
|
355
|
+
if (service.ok) {
|
|
356
|
+
if (!service.result.installed) return json({ ok: false, name, output: service.result.reason });
|
|
357
|
+
serviceRemoved = true;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
335
360
|
const cleanup = installedDir ? runCleanup(installedDir) : [];
|
|
336
361
|
try {
|
|
337
362
|
const output = await deps.inst.remove(`npm:${name}`, { approved });
|
|
338
|
-
return json({ ok: true, name, output: output + formatCleanupSummary(cleanup) });
|
|
363
|
+
return json({ ok: true, name, output: output + formatCleanupSummary(cleanup), serviceRemoved });
|
|
339
364
|
} catch (e) {
|
|
340
365
|
const message = e instanceof Error ? e.message : String(e);
|
|
341
366
|
return json({ ok: false, name, output: message + formatCleanupSummary(cleanup) });
|
|
@@ -359,7 +384,19 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
359
384
|
if (denied) return denied;
|
|
360
385
|
try {
|
|
361
386
|
const output = await deps.inst.install(source, { approved });
|
|
362
|
-
return json({ ok: true, source, output });
|
|
387
|
+
if (!source.startsWith("npm:")) return json({ ok: true, source, output });
|
|
388
|
+
const service = await daemonServiceInstaller.install(piHomeForServiceInstall, source);
|
|
389
|
+
if (!service.ok) {
|
|
390
|
+
if (service.notADaemon) return json({ ok: true, source, output });
|
|
391
|
+
return json({ ok: false, source, output: `${output}\n${service.reason}` });
|
|
392
|
+
}
|
|
393
|
+
if (!service.result.installed) return json({ ok: false, source, output: `${output}\n${service.result.reason}` });
|
|
394
|
+
return json({
|
|
395
|
+
ok: true,
|
|
396
|
+
source,
|
|
397
|
+
output: `${output}\ninstalled persistent Vehicle ${service.spec.name}`,
|
|
398
|
+
service: pickSpec(service.spec),
|
|
399
|
+
});
|
|
363
400
|
} catch (e) {
|
|
364
401
|
return json({ ok: false, source, output: e instanceof Error ? e.message : String(e) });
|
|
365
402
|
}
|
|
@@ -380,7 +417,7 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
380
417
|
}
|
|
381
418
|
const denied = authorize("install_service", approved);
|
|
382
419
|
if (denied) return denied;
|
|
383
|
-
const resolved = daemonServiceInstaller.install(piHomeForServiceInstall, source);
|
|
420
|
+
const resolved = await daemonServiceInstaller.install(piHomeForServiceInstall, source);
|
|
384
421
|
if (!resolved.ok) return json({ ok: false, output: resolved.reason, notADaemon: resolved.notADaemon });
|
|
385
422
|
if (!resolved.result.installed) return json({ ok: false, output: resolved.result.reason, spec: pickSpec(resolved.spec) });
|
|
386
423
|
return json({ ok: true, output: `installed a persistent service for ${resolved.spec.name}`, spec: pickSpec(resolved.spec) });
|
|
@@ -401,7 +438,7 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
401
438
|
}
|
|
402
439
|
const denied = authorize("restart_service", approved);
|
|
403
440
|
if (denied) return denied;
|
|
404
|
-
const resolved = daemonServiceInstaller.restart(piHomeForServiceInstall, source);
|
|
441
|
+
const resolved = await daemonServiceInstaller.restart(piHomeForServiceInstall, source);
|
|
405
442
|
if (!resolved.ok) return json({ ok: false, output: resolved.reason, notADaemon: resolved.notADaemon });
|
|
406
443
|
const output = resolved.restarted
|
|
407
444
|
? `restarted the persistent service for ${resolved.spec.name}`
|
|
@@ -409,6 +446,23 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
409
446
|
return json({ ok: true, output, restarted: resolved.restarted, spec: pickSpec(resolved.spec) });
|
|
410
447
|
}
|
|
411
448
|
|
|
449
|
+
if (path === "/reconcile-services" && req.method === "POST") {
|
|
450
|
+
let approved = false;
|
|
451
|
+
let projectRoot: string | undefined;
|
|
452
|
+
try {
|
|
453
|
+
const body = (await req.json()) as { approved?: unknown; projectRoot?: unknown };
|
|
454
|
+
approved = body.approved === true;
|
|
455
|
+
projectRoot = typeof body.projectRoot === "string" ? body.projectRoot : undefined;
|
|
456
|
+
} catch {
|
|
457
|
+
/* fall through -- approved stays false, projectRoot stays undefined */
|
|
458
|
+
}
|
|
459
|
+
const denied = authorize("reconcile_services", approved);
|
|
460
|
+
if (denied) return denied;
|
|
461
|
+
const result = await reconcileAllDaemonServices(piHomeForServiceInstall, projectRoot, daemonServiceInstaller);
|
|
462
|
+
const output = `reconciled ${result.reconciled.length} Vehicle(s), skipped ${result.skipped} non-daemon package(s)${result.failed.length > 0 ? `, ${result.failed.length} failure(s)` : ""}`;
|
|
463
|
+
return json({ ok: result.failed.length === 0, output, ...result });
|
|
464
|
+
}
|
|
465
|
+
|
|
412
466
|
if (path === "/update" && req.method === "POST") {
|
|
413
467
|
let source = "";
|
|
414
468
|
let approved = false;
|
|
@@ -426,7 +480,13 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
426
480
|
if (denied) return denied;
|
|
427
481
|
try {
|
|
428
482
|
const outcome = await deps.inst.update(source, { approved });
|
|
429
|
-
return json({ ok: true, source, ...outcome });
|
|
483
|
+
if (!source.startsWith("npm:") || outcome.alreadyUpToDate) return json({ ok: true, source, ...outcome });
|
|
484
|
+
const service = await daemonServiceInstaller.restart(piHomeForServiceInstall, source);
|
|
485
|
+
if (!service.ok) {
|
|
486
|
+
if (service.notADaemon) return json({ ok: true, source, ...outcome });
|
|
487
|
+
return json({ ok: false, source, ...outcome, output: `${outcome.output}\n${service.reason}` });
|
|
488
|
+
}
|
|
489
|
+
return json({ ok: true, source, ...outcome, serviceReconciled: service.restarted });
|
|
430
490
|
} catch (error) {
|
|
431
491
|
return json({ ok: false, source, output: error instanceof Error ? error.message : String(error), reloadRequired: false });
|
|
432
492
|
}
|
|
@@ -601,6 +661,10 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
601
661
|
path = "/restart-service";
|
|
602
662
|
init = { method: "POST", body: JSON.stringify(input) };
|
|
603
663
|
break;
|
|
664
|
+
case "package.reconcile_services":
|
|
665
|
+
path = "/reconcile-services";
|
|
666
|
+
init = { method: "POST", body: JSON.stringify(input) };
|
|
667
|
+
break;
|
|
604
668
|
case "package.remove":
|
|
605
669
|
path = "/remove";
|
|
606
670
|
init = { method: "POST", body: JSON.stringify(input) };
|
|
@@ -626,8 +690,9 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
626
690
|
return {
|
|
627
691
|
async fetch(req: Request): Promise<Response> {
|
|
628
692
|
const t0 = Date.now();
|
|
629
|
-
if (!requireBearerToken(req, deps.token)) return errorResponse("missing or invalid bearer token", 401);
|
|
630
693
|
const requestUrl = new URL(req.url);
|
|
694
|
+
if (requestUrl.pathname.startsWith("/vehicle/")) return vehicleApp.fetch(req);
|
|
695
|
+
if (!requireBearerToken(req, deps.token)) return errorResponse("missing or invalid bearer token", 401);
|
|
631
696
|
if (req.method === "GET" && requestUrl.pathname === "/api/v1/ops") return jsonResponse({ operations: OPERATION_NAMES });
|
|
632
697
|
if (req.method === "POST" && requestUrl.pathname === "/api/v1/ops") {
|
|
633
698
|
try {
|