@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
|
@@ -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 {
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packed's full 29-operation daemon surface projected onto the real Vehicle
|
|
3
|
+
* protocol. Every operation delegates to the exact same executeOperation()
|
|
4
|
+
* function /api/v1/ops already calls (one implementation, two projections --
|
|
5
|
+
* the same shape every other Vehicle-migrated daemon in this ecosystem
|
|
6
|
+
* uses) -- no behavior change, only a second real transport served
|
|
7
|
+
* alongside (not replacing) the existing /api/v1/ops route.
|
|
8
|
+
*
|
|
9
|
+
* Every operation's input is already a well-typed OperationInputs[Name] that
|
|
10
|
+
* executeOperation() validates and dispatches internally (throwing
|
|
11
|
+
* PackageOperationError on a bad shape/denied approval) -- there is no
|
|
12
|
+
* separate Vehicle-side schema to duplicate that logic, so both input and
|
|
13
|
+
* output use passthroughVehicleSchema and let executeOperation's own
|
|
14
|
+
* validation (already covered by service.test.ts) be the single source of
|
|
15
|
+
* truth for what's accepted.
|
|
16
|
+
*
|
|
17
|
+
* Effect classification is grounded in security.ts's own PACKAGE_OPERATIONS
|
|
18
|
+
* classification (read/maintenance/code-execution/settings-mutation/
|
|
19
|
+
* security-mutation) -- the codebase's own, already-deployed risk model --
|
|
20
|
+
* translated to Vehicle's effect vocabulary rather than independently
|
|
21
|
+
* re-derived, with two deliberate refinements documented at each entry
|
|
22
|
+
* where Vehicle's own taxonomy draws a finer distinction Packed's doesn't
|
|
23
|
+
* (package.remove -> destructive, since Vehicle has a dedicated category
|
|
24
|
+
* for irreversible deletion; restart_service/reconcile_services ->
|
|
25
|
+
* external-write rather than open-world, since restarting/reconciling an
|
|
26
|
+
* ALREADY-installed, already-vetted service introduces no new code, unlike
|
|
27
|
+
* install/install_service/update/setup.apply which can fetch and run
|
|
28
|
+
* arbitrary newly-published code).
|
|
29
|
+
*
|
|
30
|
+
* Approval/authorization is NOT reimplemented here: executeOperation()
|
|
31
|
+
* (and the route()-based operations it delegates to internally) already
|
|
32
|
+
* calls authorize()/assertPackagePermission() and throws
|
|
33
|
+
* PackageOperationError(status: 403) on a denied mutation -- reusing
|
|
34
|
+
* executeOperation() verbatim means this Vehicle surface automatically
|
|
35
|
+
* inherits the exact same approval gate, with zero duplicated policy logic
|
|
36
|
+
* to drift out of sync.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import type { VehicleEffect, VehicleIdempotency } from "@danypops/vehicle-core";
|
|
40
|
+
import { bindVehicleOperation, defineVehicleOperation, passthroughVehicleSchema, VehicleError } from "@danypops/vehicle-core";
|
|
41
|
+
import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
42
|
+
import { OPERATION_NAMES, type OperationInputs, type OperationName, type OperationOutputs } from "./service.ts";
|
|
43
|
+
|
|
44
|
+
/** Thrown by executeOperation() with an HTTP-shaped .status; preserves /api/v1/ops's own status->meaning exactly instead of letting VehicleRegistry.invoke()'s catch-all flatten every failure into a generic internal/500 with the message discarded. */
|
|
45
|
+
interface StatusCarryingError extends Error {
|
|
46
|
+
readonly status: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function hasStatus(error: unknown): error is StatusCarryingError {
|
|
50
|
+
return error instanceof Error && typeof (error as { status?: unknown }).status === "number";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function withPackedErrorParity<T>(run: () => T | Promise<T>): Promise<T> {
|
|
54
|
+
try {
|
|
55
|
+
return await run();
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error instanceof VehicleError) throw error;
|
|
58
|
+
if (hasStatus(error)) {
|
|
59
|
+
const category =
|
|
60
|
+
error.status === 403
|
|
61
|
+
? "authorization"
|
|
62
|
+
: error.status === 404
|
|
63
|
+
? "not_found"
|
|
64
|
+
: error.status === 400
|
|
65
|
+
? "validation"
|
|
66
|
+
: error.status >= 500
|
|
67
|
+
? "unavailable"
|
|
68
|
+
: "validation";
|
|
69
|
+
throw new VehicleError("operation-rejected", error.message, { category, cause: error });
|
|
70
|
+
}
|
|
71
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
72
|
+
throw new VehicleError("operation-rejected", message, { category: "validation", cause: error });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const OWNER = "packed";
|
|
77
|
+
const LIMITS = { defaultTimeoutMs: 30_000, maxTimeoutMs: 120_000, maxRequestBytes: 65_536, maxResponseBytes: 4_194_304 };
|
|
78
|
+
|
|
79
|
+
const READ: VehicleIdempotency = { mode: "safe" };
|
|
80
|
+
const WRITE: VehicleIdempotency = { mode: "unsafe" };
|
|
81
|
+
|
|
82
|
+
interface OperationMeta {
|
|
83
|
+
readonly description: string;
|
|
84
|
+
readonly effect: VehicleEffect;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* One entry per OPERATION_NAMES member. See this file's own doc comment for
|
|
89
|
+
* the general translation rule (security.ts's PACKAGE_OPERATIONS
|
|
90
|
+
* classification) and its two deliberate refinements.
|
|
91
|
+
*/
|
|
92
|
+
const OPERATION_META: Record<OperationName, OperationMeta> = {
|
|
93
|
+
"package.search": { description: "Searches Pi packages on npm.", effect: "read" },
|
|
94
|
+
"package.info": { description: "Reads bounded metadata for one package.", effect: "read" },
|
|
95
|
+
"package.installed": { description: "Lists locally installed Pi packages.", effect: "read" },
|
|
96
|
+
"package.catalog": { description: "Reads the local SQLite catalog mirror.", effect: "read" },
|
|
97
|
+
"package.catalog.sync": {
|
|
98
|
+
description: "Refreshes the local catalog mirror from the Pi-package-tagged npm registry subset.",
|
|
99
|
+
effect: "external-write",
|
|
100
|
+
},
|
|
101
|
+
"package.index": { description: "Reads the locally built adoption-score index, if one exists.", effect: "read" },
|
|
102
|
+
"package.index.build": {
|
|
103
|
+
description: "Builds the adoption-score index by scoring catalog entries against the npm registry.",
|
|
104
|
+
effect: "external-write",
|
|
105
|
+
},
|
|
106
|
+
"package.updates": { description: "Reads the last background update-check snapshot.", effect: "read" },
|
|
107
|
+
"package.check": { description: "Runs static (and optionally smoke-test) quality checks against a local package path.", effect: "read" },
|
|
108
|
+
"package.pack": { description: "Verifies a local package path via npm pack.", effect: "read" },
|
|
109
|
+
"package.score": { description: "Computes an adoption-readiness score for a local path or registry package.", effect: "read" },
|
|
110
|
+
"setup.export": { description: "Exports the current Pi setup as a portable manifest.", effect: "local-write" },
|
|
111
|
+
"setup.update": { description: "Updates an existing setup manifest in place.", effect: "local-write" },
|
|
112
|
+
"setup.plan": { description: "Computes a setup manifest's install/update/remove plan without applying it.", effect: "read" },
|
|
113
|
+
"setup.apply": {
|
|
114
|
+
description: "Applies a setup manifest's plan -- can install, update, or remove packages.",
|
|
115
|
+
effect: "open-world",
|
|
116
|
+
},
|
|
117
|
+
"package.security.get": { description: "Reads this daemon's mutation-approval security settings.", effect: "read" },
|
|
118
|
+
"package.security.set": { description: "Writes this daemon's mutation-approval security settings.", effect: "local-write" },
|
|
119
|
+
"package.install": { description: "Installs a Pi package from an npm, git, or https source.", effect: "open-world" },
|
|
120
|
+
"package.install_service": {
|
|
121
|
+
description: "Installs a persistent supervised service for an already-installed daemon package.",
|
|
122
|
+
effect: "open-world",
|
|
123
|
+
},
|
|
124
|
+
"package.restart_service": {
|
|
125
|
+
description: "Restarts an already-installed package's persistent service -- no new code introduced.",
|
|
126
|
+
effect: "external-write",
|
|
127
|
+
},
|
|
128
|
+
"package.reconcile_services": {
|
|
129
|
+
description: "Reconciles every installed daemon package's persistent service against desired state.",
|
|
130
|
+
effect: "external-write",
|
|
131
|
+
},
|
|
132
|
+
"package.remove": { description: "Removes an installed Pi package. Irreversible.", effect: "destructive" },
|
|
133
|
+
"package.update": { description: "Updates a configured Pi package to its latest available version.", effect: "open-world" },
|
|
134
|
+
"resources.list": { description: "Lists global and project-scoped Pi resources (extensions, skills, prompts, themes).", effect: "read" },
|
|
135
|
+
"resources.toggle": { description: "Enables or disables one Pi resource in a settings file.", effect: "local-write" },
|
|
136
|
+
"pi.status": { description: "Reports the locally running Pi version against the latest published release.", effect: "read" },
|
|
137
|
+
"advisories.scan": { description: "Scans installed package versions against known advisories.", effect: "read" },
|
|
138
|
+
"doctor.run": { description: "Runs diagnostic health checks (service install drift, resource config, ...).", effect: "read" },
|
|
139
|
+
"package.updates.project": { description: "Checks for updates across every scope visible to one project.", effect: "read" },
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** Read effects need only packed:read; every other effect needs both (writes commonly also read first). */
|
|
143
|
+
function permissionsFor(effect: VehicleEffect): readonly string[] {
|
|
144
|
+
return effect === "read" ? ["packed:read"] : ["packed:read", "packed:write"];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function registerPackedVehicleOperations(
|
|
148
|
+
registry: VehicleRegistry,
|
|
149
|
+
executeOperation: <Name extends OperationName>(op: Name, input: OperationInputs[Name]) => Promise<OperationOutputs[Name]>,
|
|
150
|
+
): void {
|
|
151
|
+
for (const name of OPERATION_NAMES) {
|
|
152
|
+
const meta = OPERATION_META[name];
|
|
153
|
+
const operation = defineVehicleOperation({
|
|
154
|
+
name,
|
|
155
|
+
version: 1,
|
|
156
|
+
description: meta.description,
|
|
157
|
+
input: passthroughVehicleSchema,
|
|
158
|
+
output: passthroughVehicleSchema,
|
|
159
|
+
permissions: permissionsFor(meta.effect),
|
|
160
|
+
effect: meta.effect,
|
|
161
|
+
idempotency: meta.effect === "read" ? READ : WRITE,
|
|
162
|
+
limits: LIMITS,
|
|
163
|
+
});
|
|
164
|
+
registry.register(
|
|
165
|
+
OWNER,
|
|
166
|
+
bindVehicleOperation(
|
|
167
|
+
operation,
|
|
168
|
+
() => async (context) =>
|
|
169
|
+
withPackedErrorParity(() => executeOperation(name, context.input as OperationInputs[typeof name])),
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { AuthenticatedRpcClient } from "@danypops/vehicle-client/rpc-client";
|
|
6
6
|
import { readDaemonHandle, resolveDaemonPaths } from "@danypops/vehicle-server/paths";
|
|
7
|
-
import { isServiceInstalled as vehicleIsServiceInstalled } from "@danypops/vehicle-server/service";
|
|
7
|
+
import { createNodeServiceInstallDeps, isServiceInstalled as vehicleIsServiceInstalled } from "@danypops/vehicle-server/service";
|
|
8
8
|
import type {
|
|
9
9
|
ExtensionOperationInputs,
|
|
10
10
|
ExtensionOperationName,
|
|
@@ -89,21 +89,9 @@ export function resolvePackedClientPaths(options: PackedPathOptions = {}): Packe
|
|
|
89
89
|
return { token: paths.token, handle: paths.handle, serviceDescriptor: paths.serviceDescriptor };
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return vehicleIsServiceInstalled(
|
|
96
|
-
{ name: SERVICE_NAME, binPath: "", descriptorPath: serviceDescriptor },
|
|
97
|
-
{
|
|
98
|
-
fileExists: existsSync,
|
|
99
|
-
writeFile: () => {},
|
|
100
|
-
readFile: () => null,
|
|
101
|
-
removeFile: () => {},
|
|
102
|
-
mkdirp: () => {},
|
|
103
|
-
runCommand: () => ({ ok: false, output: "" }),
|
|
104
|
-
which: () => false,
|
|
105
|
-
},
|
|
106
|
-
);
|
|
92
|
+
/** Checks Armada's authoritative desired fleet rather than native descriptor files. */
|
|
93
|
+
function isPackedServiceInstalled(): boolean {
|
|
94
|
+
return vehicleIsServiceInstalled(SERVICE_NAME, createNodeServiceInstallDeps());
|
|
107
95
|
}
|
|
108
96
|
|
|
109
97
|
export type FetchTransport = (request: Request) => Promise<Response>;
|
|
@@ -251,7 +239,7 @@ export async function ensureClient(deps: EnsureClientDeps): Promise<PackedClient
|
|
|
251
239
|
const waitedSeconds = (attempts * delayMs) / 1000;
|
|
252
240
|
throw new Error(
|
|
253
241
|
serviceInstalled
|
|
254
|
-
? `Packed daemon did not become ready within ${waitedSeconds} seconds, but a
|
|
242
|
+
? `Packed daemon did not become ready within ${waitedSeconds} seconds, but a managed service is installed -- run packed doctor rather than auto-spawning a second one`
|
|
255
243
|
: `Packed daemon did not become ready within ${waitedSeconds} seconds`,
|
|
256
244
|
);
|
|
257
245
|
}
|
|
@@ -259,7 +247,7 @@ export async function ensureClient(deps: EnsureClientDeps): Promise<PackedClient
|
|
|
259
247
|
export async function ensurePackedClient(paths = resolvePackedClientPaths(), transport: FetchTransport = fetch): Promise<PackedClient> {
|
|
260
248
|
return ensureClient({
|
|
261
249
|
connect: () => connectPackedClient(paths, transport),
|
|
262
|
-
isServiceInstalled: () => isPackedServiceInstalled(
|
|
250
|
+
isServiceInstalled: () => isPackedServiceInstalled(),
|
|
263
251
|
spawn: () => {
|
|
264
252
|
const override = process.env.PI_PACKED_BIN;
|
|
265
253
|
const command = override ?? process.env.PI_PACKED_BUN ?? "bun";
|
|
@@ -92,7 +92,6 @@ export type PackageResources = { source: string; name: string; scope: "global" |
|
|
|
92
92
|
export interface ServiceSpecSummary {
|
|
93
93
|
name: string;
|
|
94
94
|
binPath: string;
|
|
95
|
-
descriptorPath: string;
|
|
96
95
|
}
|
|
97
96
|
|
|
98
97
|
/** Mirrors service/src/pi/pi-version.ts's CURRENT_PI_PACKAGE_NAME
|
|
@@ -23,6 +23,7 @@ export const PACKAGE_OPERATIONS = [
|
|
|
23
23
|
"install",
|
|
24
24
|
"install_service",
|
|
25
25
|
"restart_service",
|
|
26
|
+
"reconcile_services",
|
|
26
27
|
"setup.apply",
|
|
27
28
|
"update",
|
|
28
29
|
"update.self",
|
|
@@ -70,6 +71,7 @@ const CLASSIFICATIONS: Record<PackageOperation, PackageOperationClassification>
|
|
|
70
71
|
install: "code-execution",
|
|
71
72
|
install_service: "code-execution",
|
|
72
73
|
restart_service: "code-execution",
|
|
74
|
+
reconcile_services: "code-execution",
|
|
73
75
|
"setup.apply": "code-execution",
|
|
74
76
|
update: "code-execution",
|
|
75
77
|
"update.self": "code-execution",
|
|
@@ -52,6 +52,7 @@ export const INDEX_OPERATION_TIMEOUT_MS = 10 * 60_000;
|
|
|
52
52
|
export const WATCH_INTERVAL_DEFAULT_MS = 30 * 60_000; // updates diff cadence
|
|
53
53
|
export const CATALOG_INTERVAL_DEFAULT_MS = 6 * 3_600_000; // full mirror TTL
|
|
54
54
|
export const INDEX_INTERVAL_DEFAULT_MS = 6 * 3_600_000; // static index regeneration TTL, same cadence as the catalog mirror it reads from
|
|
55
|
+
export const RECONCILE_INTERVAL_DEFAULT_MS = 30 * 60_000; // Vehicle-service drift sweep cadence -- catches an out-of-band npm install/update a running daemon never picked up
|
|
55
56
|
export const IDLE_BUDGET_DEFAULT_MS = 10 * 60_000; // on-demand self-exit
|
|
56
57
|
export const WATCHDOG_TICK_MS = 15_000;
|
|
57
58
|
|
|
@@ -69,5 +70,6 @@ export const ENV = {
|
|
|
69
70
|
WATCH_SECS: "PI_PACKED_WATCH_SECS",
|
|
70
71
|
CATALOG_SECS: "PI_PACKED_CATALOG_SECS",
|
|
71
72
|
INDEX_SECS: "PI_PACKED_INDEX_SECS",
|
|
73
|
+
RECONCILE_SECS: "PI_PACKED_RECONCILE_SECS",
|
|
72
74
|
IDLE_SECS: "PI_PACKED_IDLE_SECS",
|
|
73
75
|
} as const;
|