@danypops/pi-packed 0.19.12 → 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/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
|
@@ -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;
|
package/service/test/cli.test.ts
CHANGED
|
@@ -79,29 +79,48 @@ class FakeDaemonServiceInstaller {
|
|
|
79
79
|
restartGotSource = "";
|
|
80
80
|
restartApproved = false;
|
|
81
81
|
restartFail = false;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
82
|
+
reconcileGotApproved: boolean | undefined;
|
|
83
|
+
reconcileGotProjectRoot: string | undefined;
|
|
84
|
+
reconcileFail = false;
|
|
85
|
+
async install(source: string, approved?: boolean) {
|
|
86
86
|
this.gotSource = source;
|
|
87
87
|
this.approved = approved === true;
|
|
88
88
|
if (this.fail) throw new Error("install-service failed");
|
|
89
89
|
return {
|
|
90
90
|
output: `installed a persistent service for ${source}`,
|
|
91
|
-
spec: {
|
|
91
|
+
spec: {
|
|
92
|
+
name: "probe",
|
|
93
|
+
version: "1.0.0",
|
|
94
|
+
binPath: "/opt/probe/cli.js",
|
|
95
|
+
handlePath: "/tmp/probe.handle.json",
|
|
96
|
+
},
|
|
92
97
|
};
|
|
93
98
|
}
|
|
94
|
-
async restart(
|
|
95
|
-
source: string,
|
|
96
|
-
approved?: boolean,
|
|
97
|
-
): Promise<{ output: string; restarted?: boolean; spec?: { name: string; binPath: string; descriptorPath: string } }> {
|
|
99
|
+
async restart(source: string, approved?: boolean) {
|
|
98
100
|
this.restartGotSource = source;
|
|
99
101
|
this.restartApproved = approved === true;
|
|
100
102
|
if (this.restartFail) throw new Error("restart-service failed");
|
|
101
103
|
return {
|
|
102
104
|
output: `restarted the persistent service for ${source}`,
|
|
103
105
|
restarted: true,
|
|
104
|
-
spec: {
|
|
106
|
+
spec: {
|
|
107
|
+
name: "probe",
|
|
108
|
+
version: "1.0.0",
|
|
109
|
+
binPath: "/opt/probe/cli.js",
|
|
110
|
+
handlePath: "/tmp/probe.handle.json",
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
async reconcileAll(approved?: boolean, projectRoot?: string) {
|
|
115
|
+
this.reconcileGotApproved = approved;
|
|
116
|
+
this.reconcileGotProjectRoot = projectRoot;
|
|
117
|
+
if (this.reconcileFail) throw new Error("reconcile-services failed");
|
|
118
|
+
return {
|
|
119
|
+
ok: true,
|
|
120
|
+
output: "reconciled 1 Vehicle(s), skipped 0 non-daemon package(s)",
|
|
121
|
+
reconciled: [{ packageName: "@danypops/probe", vehicleName: "probe", installed: true }],
|
|
122
|
+
skipped: 0,
|
|
123
|
+
failed: [],
|
|
105
124
|
};
|
|
106
125
|
}
|
|
107
126
|
}
|
|
@@ -454,67 +473,15 @@ describe("CLI", () => {
|
|
|
454
473
|
it("install runs with stable human and JSON output", async () => {
|
|
455
474
|
const d = deps();
|
|
456
475
|
expect((await cliRun(["install", "npm:foo"], d)).code).toBe(1);
|
|
457
|
-
|
|
458
|
-
// auto-registration composition has its own dedicated tests below.
|
|
459
|
-
const human = await cliRun(["install", "npm:foo", "--approve", "--no-service"], d);
|
|
476
|
+
const human = await cliRun(["install", "npm:foo", "--approve"], d);
|
|
460
477
|
expect(human.code).toBe(0);
|
|
461
478
|
expect(human.out).toContain("Installed npm:foo");
|
|
462
479
|
expect((d.inst as FakeInstaller).approved).toBe(true);
|
|
463
|
-
const json = await cliRun(["install", "npm:foo", "--approve", "--
|
|
480
|
+
const json = await cliRun(["install", "npm:foo", "--approve", "--json"], d);
|
|
464
481
|
expect(json.code).toBe(0);
|
|
465
482
|
expect(JSON.parse(json.out)).toEqual({ ok: true, source: "npm:foo", output: "Installed npm:foo" });
|
|
466
483
|
});
|
|
467
484
|
|
|
468
|
-
it("install auto-registers a detected daemon service under the same approval, silently for a non-daemon package", async () => {
|
|
469
|
-
const d = deps();
|
|
470
|
-
const human = await cliRun(["install", "npm:foo", "--approve"], d);
|
|
471
|
-
expect(human.code).toBe(0);
|
|
472
|
-
expect(human.out).toContain("Installed npm:foo");
|
|
473
|
-
expect(human.out).toContain("installed a persistent service for npm:foo");
|
|
474
|
-
expect((d.daemonService as FakeDaemonServiceInstaller).approved).toBe(true);
|
|
475
|
-
const json = await cliRun(["install", "npm:foo", "--approve", "--json"], d);
|
|
476
|
-
expect(json.code).toBe(0);
|
|
477
|
-
expect(JSON.parse(json.out)).toEqual({
|
|
478
|
-
ok: true,
|
|
479
|
-
source: "npm:foo",
|
|
480
|
-
output: "Installed npm:foo",
|
|
481
|
-
serviceInstall: { detected: true, ok: true, output: "installed a persistent service for npm:foo" },
|
|
482
|
-
});
|
|
483
|
-
|
|
484
|
-
// notADaemon: the overwhelmingly common case (an ordinary, non-daemon package) stays silent.
|
|
485
|
-
const notADaemon = deps();
|
|
486
|
-
(notADaemon.daemonService as FakeDaemonServiceInstaller).install = async () => {
|
|
487
|
-
throw Object.assign(
|
|
488
|
-
new Error("foo does not declare a packed.daemonService manifest and no Vehicle-shaped daemon dependency was detected"),
|
|
489
|
-
{ notADaemon: true },
|
|
490
|
-
);
|
|
491
|
-
};
|
|
492
|
-
const silent = await cliRun(["install", "npm:foo", "--approve", "--json"], notADaemon);
|
|
493
|
-
expect(JSON.parse(silent.out)).toEqual({ ok: true, source: "npm:foo", output: "Installed npm:foo" });
|
|
494
|
-
|
|
495
|
-
// A genuine failure (a daemon was detected but registration itself failed) is reported
|
|
496
|
-
// without failing the install, which already succeeded.
|
|
497
|
-
const realFailure = deps();
|
|
498
|
-
(realFailure.daemonService as FakeDaemonServiceInstaller).fail = true;
|
|
499
|
-
const failed = await cliRun(["install", "npm:foo", "--approve", "--json"], realFailure);
|
|
500
|
-
expect(failed.code).toBe(0);
|
|
501
|
-
expect(JSON.parse(failed.out)).toEqual({
|
|
502
|
-
ok: true,
|
|
503
|
-
source: "npm:foo",
|
|
504
|
-
output: "Installed npm:foo",
|
|
505
|
-
serviceInstall: { detected: true, ok: false, output: "install-service failed" },
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
// --no-service skips the attempt entirely -- the daemonService fake is never called.
|
|
509
|
-
const skipped = deps();
|
|
510
|
-
await cliRun(["install", "npm:foo", "--approve", "--no-service"], skipped);
|
|
511
|
-
expect((skipped.daemonService as FakeDaemonServiceInstaller).gotSource).toBe("");
|
|
512
|
-
|
|
513
|
-
// A non-npm source never attempts service detection -- daemon-service resolution only supports npm: today.
|
|
514
|
-
const gitSource = deps();
|
|
515
|
-
await cliRun(["install", "git:github.com/u/r@v1", "--approve"], gitSource);
|
|
516
|
-
expect((gitSource.daemonService as FakeDaemonServiceInstaller).gotSource).toBe("");
|
|
517
|
-
});
|
|
518
485
|
|
|
519
486
|
it("install-service validates source", async () => {
|
|
520
487
|
const d = deps();
|
|
@@ -536,7 +503,12 @@ describe("CLI", () => {
|
|
|
536
503
|
ok: true,
|
|
537
504
|
source: "npm:foo",
|
|
538
505
|
output: "installed a persistent service for npm:foo",
|
|
539
|
-
spec: {
|
|
506
|
+
spec: {
|
|
507
|
+
name: "probe",
|
|
508
|
+
version: "1.0.0",
|
|
509
|
+
binPath: "/opt/probe/cli.js",
|
|
510
|
+
handlePath: "/tmp/probe.handle.json",
|
|
511
|
+
},
|
|
540
512
|
});
|
|
541
513
|
});
|
|
542
514
|
|
|
@@ -555,33 +527,52 @@ describe("CLI", () => {
|
|
|
555
527
|
expect(out).toContain("requires a running packed daemon");
|
|
556
528
|
});
|
|
557
529
|
|
|
558
|
-
it("
|
|
530
|
+
it("reconcile-services requires approval, then sweeps every installed package with stable human and JSON output", async () => {
|
|
559
531
|
const d = deps();
|
|
560
|
-
expect((await cliRun(["
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
expect(
|
|
565
|
-
|
|
566
|
-
expect(
|
|
567
|
-
const json = await cliRun(["update", "npm:foo", "--approve", "--no-service", "--json"], d);
|
|
532
|
+
expect((await cliRun(["reconcile-services"], d)).code).toBe(1);
|
|
533
|
+
const human = await cliRun(["reconcile-services", "--approve"], d);
|
|
534
|
+
expect(human.code).toBe(0);
|
|
535
|
+
expect(human.out).toContain("reconciled 1 Vehicle(s)");
|
|
536
|
+
expect((d.daemonService as FakeDaemonServiceInstaller).reconcileGotApproved).toBe(true);
|
|
537
|
+
const json = await cliRun(["reconcile-services", "--approve", "--json"], d);
|
|
538
|
+
expect(json.code).toBe(0);
|
|
568
539
|
expect(JSON.parse(json.out)).toEqual({
|
|
569
540
|
ok: true,
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
pinned: false,
|
|
541
|
+
output: "reconciled 1 Vehicle(s), skipped 0 non-daemon package(s)",
|
|
542
|
+
reconciled: [{ packageName: "@danypops/probe", vehicleName: "probe", installed: true }],
|
|
543
|
+
skipped: 0,
|
|
544
|
+
failed: [],
|
|
575
545
|
});
|
|
576
546
|
});
|
|
577
547
|
|
|
578
|
-
it("
|
|
548
|
+
it("reconcile-services threads --project through to the sweep", async () => {
|
|
549
|
+
const d = deps();
|
|
550
|
+
await cliRun(["reconcile-services", "--approve", "--project", "/repo"], d);
|
|
551
|
+
expect((d.daemonService as FakeDaemonServiceInstaller).reconcileGotProjectRoot).toBe("/repo");
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("reconcile-services reports a sweep failure in-band with exit code 1", async () => {
|
|
555
|
+
const d = deps();
|
|
556
|
+
(d.daemonService as FakeDaemonServiceInstaller).reconcileFail = true;
|
|
557
|
+
const { code, out } = await cliRun(["reconcile-services", "--approve"], d);
|
|
558
|
+
expect(code).toBe(1);
|
|
559
|
+
expect(out).toContain("reconcile-services failed");
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("reconcile-services fails closed without a running daemon", async () => {
|
|
563
|
+
const d = deps({ daemonService: undefined });
|
|
564
|
+
const { code, out } = await cliRun(["reconcile-services", "--approve"], d);
|
|
565
|
+
expect(code).toBe(1);
|
|
566
|
+
expect(out).toContain("requires a running packed daemon");
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it("update delegates one configured source with stable output and approval", async () => {
|
|
579
570
|
const d = deps();
|
|
571
|
+
expect((await cliRun(["update", "npm:foo"], d)).code).toBe(1);
|
|
580
572
|
const human = await cliRun(["update", "npm:foo", "--approve"], d);
|
|
581
|
-
expect(human.code).toBe(0);
|
|
582
573
|
expect(human.out).toContain("Updated npm:foo");
|
|
583
|
-
expect(
|
|
584
|
-
expect((d.
|
|
574
|
+
expect((d.inst as FakeInstaller).updated).toBe("npm:foo");
|
|
575
|
+
expect((d.inst as FakeInstaller).approved).toBe(true);
|
|
585
576
|
const json = await cliRun(["update", "npm:foo", "--approve", "--json"], d);
|
|
586
577
|
expect(JSON.parse(json.out)).toEqual({
|
|
587
578
|
ok: true,
|
|
@@ -590,47 +581,10 @@ describe("CLI", () => {
|
|
|
590
581
|
reloadRequired: true,
|
|
591
582
|
alreadyUpToDate: false,
|
|
592
583
|
pinned: false,
|
|
593
|
-
serviceRestart: { detected: true, ok: true, output: "restarted the persistent service for npm:foo" },
|
|
594
584
|
});
|
|
595
|
-
|
|
596
|
-
// notADaemon: the overwhelmingly common case (an ordinary, non-daemon package) stays silent.
|
|
597
|
-
const notADaemon = deps();
|
|
598
|
-
(notADaemon.daemonService as FakeDaemonServiceInstaller).restart = async () => {
|
|
599
|
-
throw Object.assign(
|
|
600
|
-
new Error("foo does not declare a packed.daemonService manifest and no Vehicle-shaped daemon dependency was detected"),
|
|
601
|
-
{ notADaemon: true },
|
|
602
|
-
);
|
|
603
|
-
};
|
|
604
|
-
const silent = await cliRun(["update", "npm:foo", "--approve", "--json"], notADaemon);
|
|
605
|
-
expect(JSON.parse(silent.out)).toEqual({
|
|
606
|
-
ok: true,
|
|
607
|
-
source: "npm:foo",
|
|
608
|
-
output: "Updated npm:foo",
|
|
609
|
-
reloadRequired: true,
|
|
610
|
-
alreadyUpToDate: false,
|
|
611
|
-
pinned: false,
|
|
612
|
-
});
|
|
613
|
-
|
|
614
|
-
// A genuine failure (a daemon was detected but restarting it failed) is reported
|
|
615
|
-
// without failing the update, which already succeeded.
|
|
616
|
-
const realFailure = deps();
|
|
617
|
-
(realFailure.daemonService as FakeDaemonServiceInstaller).restartFail = true;
|
|
618
|
-
const failed = await cliRun(["update", "npm:foo", "--approve", "--json"], realFailure);
|
|
619
|
-
expect(failed.code).toBe(0);
|
|
620
|
-
const failedBody = JSON.parse(failed.out);
|
|
621
|
-
expect(failedBody.serviceRestart).toEqual({ detected: true, ok: false, output: "restart-service failed" });
|
|
622
|
-
|
|
623
|
-
// --no-service skips the attempt entirely -- the daemonService fake is never called.
|
|
624
|
-
const skipped = deps();
|
|
625
|
-
await cliRun(["update", "npm:foo", "--approve", "--no-service"], skipped);
|
|
626
|
-
expect((skipped.daemonService as FakeDaemonServiceInstaller).restartGotSource).toBe("");
|
|
627
|
-
|
|
628
|
-
// A non-npm source never attempts service detection -- daemon-service resolution only supports npm: today.
|
|
629
|
-
const gitSource = deps({ inst: new FakeInstaller() });
|
|
630
|
-
await cliRun(["update", "git:github.com/u/r@v1", "--approve"], gitSource);
|
|
631
|
-
expect((gitSource.daemonService as FakeDaemonServiceInstaller).restartGotSource).toBe("");
|
|
632
585
|
});
|
|
633
586
|
|
|
587
|
+
|
|
634
588
|
it("update --self requires approval under the guarded default, same as every other mutation", async () => {
|
|
635
589
|
const d = deps({
|
|
636
590
|
selfUpdater: {
|
|
@@ -851,19 +805,36 @@ describe("CLI", () => {
|
|
|
851
805
|
},
|
|
852
806
|
async installService(source) {
|
|
853
807
|
calls.push(`installService:${source}`);
|
|
854
|
-
return {
|
|
808
|
+
return {
|
|
809
|
+
output: source,
|
|
810
|
+
spec: {
|
|
811
|
+
name: "probe",
|
|
812
|
+
version: "1.0.0",
|
|
813
|
+
binPath: "/opt/probe/cli.js",
|
|
814
|
+
handlePath: "/tmp/probe.handle.json",
|
|
815
|
+
},
|
|
816
|
+
};
|
|
855
817
|
},
|
|
856
818
|
async restartService(source) {
|
|
857
819
|
calls.push(`restartService:${source}`);
|
|
858
820
|
return {
|
|
859
821
|
output: source,
|
|
860
822
|
restarted: true,
|
|
861
|
-
spec: {
|
|
823
|
+
spec: {
|
|
824
|
+
name: "probe",
|
|
825
|
+
version: "1.0.0",
|
|
826
|
+
binPath: "/opt/probe/cli.js",
|
|
827
|
+
handlePath: "/tmp/probe.handle.json",
|
|
828
|
+
},
|
|
862
829
|
};
|
|
863
830
|
},
|
|
864
831
|
async remove(name) {
|
|
865
832
|
return name;
|
|
866
833
|
},
|
|
834
|
+
async reconcileServices(approved, projectRoot) {
|
|
835
|
+
calls.push(`reconcileServices:${approved}:${projectRoot}`);
|
|
836
|
+
return { ok: true, output: "reconciled 0 Vehicle(s), skipped 0 non-daemon package(s)", reconciled: [], skipped: 0, failed: [] };
|
|
837
|
+
},
|
|
867
838
|
async update(source) {
|
|
868
839
|
return { output: source, reloadRequired: false, alreadyUpToDate: true, pinned: false };
|
|
869
840
|
},
|
|
@@ -1050,7 +1021,7 @@ describe("CLI", () => {
|
|
|
1050
1021
|
const d = deps({ piHome });
|
|
1051
1022
|
|
|
1052
1023
|
const clean = await cliRun(["doctor", "--json"], d);
|
|
1053
|
-
expect(clean.code).toBe(0);
|
|
1024
|
+
expect(clean.code, clean.out).toBe(0);
|
|
1054
1025
|
expect(JSON.parse(clean.out)).toMatchObject({ ok: true, conflicts: [] });
|
|
1055
1026
|
|
|
1056
1027
|
const withProject = await cliRun(["doctor", "--project", projectRoot, "--json"], d);
|
|
@@ -1105,16 +1076,27 @@ describe("daemon client", () => {
|
|
|
1105
1076
|
reg: new FakeRegistry([{ name: "pi-lsp", version: "0.3.0" }]),
|
|
1106
1077
|
inst: daemonInstaller,
|
|
1107
1078
|
daemonServiceInstaller: {
|
|
1108
|
-
install
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1079
|
+
async install() {
|
|
1080
|
+
return {
|
|
1081
|
+
ok: true,
|
|
1082
|
+
result: { installed: true },
|
|
1083
|
+
spec: { name: "pi-lsp", version: "1.0.0", binPath: "/opt/pi-lsp/cli.js", handlePath: "/tmp/pi-lsp.handle.json" },
|
|
1084
|
+
};
|
|
1085
|
+
},
|
|
1086
|
+
async remove() {
|
|
1087
|
+
return {
|
|
1088
|
+
ok: true,
|
|
1089
|
+
result: { installed: true },
|
|
1090
|
+
spec: { name: "pi-lsp", version: "1.0.0", binPath: "/opt/pi-lsp/cli.js", handlePath: "/tmp/pi-lsp.handle.json" },
|
|
1091
|
+
};
|
|
1092
|
+
},
|
|
1093
|
+
async restart() {
|
|
1094
|
+
return {
|
|
1095
|
+
ok: true,
|
|
1096
|
+
restarted: true,
|
|
1097
|
+
spec: { name: "pi-lsp", version: "1.0.0", binPath: "/opt/pi-lsp/cli.js", handlePath: "/tmp/pi-lsp.handle.json" },
|
|
1098
|
+
};
|
|
1099
|
+
},
|
|
1118
1100
|
},
|
|
1119
1101
|
token: daemonToken,
|
|
1120
1102
|
stateDir: daemonDir,
|
|
@@ -1244,11 +1226,11 @@ describe("daemon client", () => {
|
|
|
1244
1226
|
expect((await client.setupApply(`${checkRoot}/pi-setup.json`, true)).ok).toBe(true);
|
|
1245
1227
|
expect(await client.security()).toEqual({ mutationApproval: "always" });
|
|
1246
1228
|
await expect(client.install("npm:pi-lsp")).rejects.toThrow("approval required");
|
|
1247
|
-
expect(await client.install("npm:pi-lsp", true)).toBe("Installed npm:pi-lsp");
|
|
1229
|
+
expect(await client.install("npm:pi-lsp", true)).toBe("Installed npm:pi-lsp\ninstalled persistent Vehicle pi-lsp");
|
|
1248
1230
|
await expect(client.installService("npm:pi-lsp")).rejects.toThrow("approval required");
|
|
1249
1231
|
expect(await client.installService("npm:pi-lsp", true)).toEqual({
|
|
1250
1232
|
output: "installed a persistent service for pi-lsp",
|
|
1251
|
-
spec: { name: "pi-lsp", binPath: "/opt/pi-lsp/cli.js"
|
|
1233
|
+
spec: { name: "pi-lsp", binPath: "/opt/pi-lsp/cli.js" },
|
|
1252
1234
|
});
|
|
1253
1235
|
expect(await client.remove("pi-lsp", true)).toBe("Removed npm:pi-lsp");
|
|
1254
1236
|
expect(await client.piStatus()).toEqual({ current: "0.82.1", latest: "0.83.0", upToDate: false });
|
|
@@ -1261,7 +1243,9 @@ describe("daemon client", () => {
|
|
|
1261
1243
|
currentVersion: undefined,
|
|
1262
1244
|
});
|
|
1263
1245
|
const installer = new PackageDaemonInstaller(client);
|
|
1264
|
-
expect(await installer.install("npm:pi-lsp@1.0.0", { approved: true })).toBe(
|
|
1246
|
+
expect(await installer.install("npm:pi-lsp@1.0.0", { approved: true })).toBe(
|
|
1247
|
+
"Installed npm:pi-lsp@1.0.0\ninstalled persistent Vehicle pi-lsp",
|
|
1248
|
+
);
|
|
1265
1249
|
expect(await installer.remove("npm:pi-lsp", { approved: true })).toBe("Removed npm:pi-lsp");
|
|
1266
1250
|
expect((await installer.update("npm:pi-lsp", { approved: true })).output).toBe("Updated npm:pi-lsp");
|
|
1267
1251
|
expect(await client.setMutationApproval("never", true)).toEqual({ mutationApproval: "never" });
|