@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.
@@ -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 { existsSync, readFileSync } from "node:fs";
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
- /** Real, file-existence-only check on Linux/macOS (Windows checks a
93
- * registry Run key instead) -- cheap, synchronous, no subprocess. */
94
- function isPackedServiceInstalled(serviceDescriptor: string): boolean {
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 supervised service is installed -- check it directly (e.g. systemctl --user status pi-packed.service) rather than auto-spawning a second one`
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(paths.serviceDescriptor),
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;
@@ -366,3 +366,79 @@ describe("packed check", () => {
366
366
  expect(codes(report.diagnostics)).toEqual(["PKG_JSON_INVALID"]);
367
367
  });
368
368
  });
369
+
370
+ describe("dependencyCheck false-positive regressions (packed check)", () => {
371
+ it("never scans a test file's own fixture strings as if they were real imports of that file", async () => {
372
+ // A checker's own test suite legitimately contains string literals shaped exactly
373
+ // like real import statements (fixture data for testing detection itself) -- these
374
+ // must never be attributed to the test file that merely contains them as text.
375
+ const root = fixture(
376
+ { ...base, files: ["extensions", "test", "README.md", "LICENSE"] },
377
+ {
378
+ "extensions/index.ts": "export default function () {}",
379
+ "test/check.test.ts": 'const fixtureSource = \'import lodash from "lodash";\';\n',
380
+ "README.md": "# Example",
381
+ LICENSE: "MIT",
382
+ },
383
+ );
384
+ const report = await checkPackage(root, { generic: false });
385
+ expect(codes(report.diagnostics)).not.toContain("RUNTIME_DEPENDENCY_MISSING");
386
+ });
387
+
388
+ it("never scans a test-directory file outside a *.test.ts basename either (e.g. a shared test helper)", async () => {
389
+ const root = fixture(
390
+ { ...base, files: ["extensions", "test", "README.md", "LICENSE"] },
391
+ {
392
+ "extensions/index.ts": "export default function () {}",
393
+ "test/helper.ts": 'import leftpad from "leftpad";\n',
394
+ "README.md": "# Example",
395
+ LICENSE: "MIT",
396
+ },
397
+ );
398
+ const report = await checkPackage(root, { generic: false });
399
+ expect(codes(report.diagnostics)).not.toContain("RUNTIME_DEPENDENCY_MISSING");
400
+ });
401
+
402
+ it("never treats a quoted 'from ...' phrase inside a comment as a real import specifier", async () => {
403
+ const root = fixture(
404
+ { ...base, files: ["extensions", "README.md", "LICENSE"] },
405
+ {
406
+ "extensions/index.ts":
407
+ '/**\n * migrated "latest" from "some-package that looks like an import"\n */\nexport default function () {}\n',
408
+ "README.md": "# Example",
409
+ LICENSE: "MIT",
410
+ },
411
+ );
412
+ const report = await checkPackage(root, { generic: false });
413
+ expect(codes(report.diagnostics)).not.toContain("RUNTIME_DEPENDENCY_MISSING");
414
+ });
415
+
416
+ it("still catches a real RUNTIME_DEPENDENCY_MISSING import sitting right next to a misleading comment", async () => {
417
+ const root = fixture(
418
+ { ...base, files: ["extensions", "README.md", "LICENSE"] },
419
+ {
420
+ "extensions/index.ts":
421
+ '// migrated "latest" from "totally not a real package"\nimport real from "real-missing-dep";\nexport default function () { return real; }\n',
422
+ "README.md": "# Example",
423
+ LICENSE: "MIT",
424
+ },
425
+ );
426
+ const report = await checkPackage(root, { generic: false });
427
+ expect(codes(report.diagnostics)).toContain("RUNTIME_DEPENDENCY_MISSING");
428
+ const diagnostic = report.diagnostics.find((d) => d.code === "RUNTIME_DEPENDENCY_MISSING")!;
429
+ expect(diagnostic.message).toContain("real-missing-dep");
430
+ });
431
+
432
+ it("never flags a package's own self-import of its own subpath export as a missing runtime dependency", async () => {
433
+ const root = fixture(
434
+ { ...base, name: "@scope/self-ref", files: ["extensions", "README.md", "LICENSE"] },
435
+ {
436
+ "extensions/index.ts": 'import { thing } from "@scope/self-ref/client";\nexport default function () { return thing; }\n',
437
+ "README.md": "# Example",
438
+ LICENSE: "MIT",
439
+ },
440
+ );
441
+ const report = await checkPackage(root, { generic: false });
442
+ expect(codes(report.diagnostics)).not.toContain("RUNTIME_DEPENDENCY_MISSING");
443
+ });
444
+ });