@danypops/pi-packed 0.21.15 → 0.22.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 +2 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1 -1
- package/dist/protocol.d.ts +33 -0
- package/dist/protocol.d.ts.map +1 -1
- package/extension/src/menu-theme.ts +13 -3
- package/extension/src/packed.ts +2 -2
- package/extension/src/tools.ts +24 -8
- package/extension/src/tui.ts +7 -3
- package/package.json +4 -4
- package/service/src/adoption/doctor.ts +15 -0
- package/service/src/adoption/install-validation.ts +97 -5
- package/service/src/adoption/module-freshness.ts +100 -0
- package/service/src/adoption/smoke.ts +26 -2
- package/service/src/cli/cli.ts +30 -5
- package/service/src/daemon/client.ts +13 -7
- package/service/src/daemon/daemon.ts +35 -3
- package/service/src/daemon/service.ts +42 -7
- package/service/src/daemon/vehicle-registration.ts +118 -35
- package/service/src/daemon/watcher.ts +1 -18
- package/service/src/packages/deploy-verify.ts +151 -0
- package/service/src/packages/install.ts +120 -3
- package/service/src/packages/package.ts +67 -1
- package/service/src/packages/resources.ts +63 -0
- package/service/src/public/client.ts +9 -3
- package/service/src/public/protocol.ts +13 -1
- package/service/test/cli.test.ts +75 -1
- package/service/test/deploy-verify.test.ts +168 -0
- package/service/test/doctor.test.ts +104 -1
- package/service/test/fixtures/install-validation/convention-only-package/extensions/index.ts +3 -0
- package/service/test/fixtures/install-validation/convention-only-package/package.json +5 -0
- package/service/test/install-validation.test.ts +14 -1
- package/service/test/install.test.ts +229 -0
- package/service/test/module-freshness.test.ts +146 -0
- package/service/test/resources.test.ts +66 -1
- package/service/test/service.test.ts +68 -1
- package/service/test/smoke.test.ts +49 -2
- package/service/test/vehicle-registration.test.ts +97 -5
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
2
3
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
4
5
|
|
|
@@ -53,13 +54,36 @@ function addReadOnlyBind(args: string[], path: string): void {
|
|
|
53
54
|
if (existsSync(path)) args.push("--ro-bind", path, path);
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Resolves an installed dependency's real on-disk package directory via
|
|
59
|
+
* Node's own module-resolution algorithm (require.resolve against its
|
|
60
|
+
* package.json), starting from `fromDir` -- correct regardless of whether
|
|
61
|
+
* the package manager nested it under fromDir's own node_modules or
|
|
62
|
+
* hoisted it to a shared ancestor node_modules (npm's own hoisting
|
|
63
|
+
* decision, driven by what else in the tree also depends on it).
|
|
64
|
+
* Confirmed live: `packed doctor --json` crashed hard reconstructing a
|
|
65
|
+
* fixed nested-only path (`<packageDirectory>/node_modules/jiti`) that
|
|
66
|
+
* doesn't exist once jiti is hoisted to Pi's own npm prefix instead of
|
|
67
|
+
* pi-packed's own node_modules. Never executes the resolved module --
|
|
68
|
+
* require.resolve only locates a path, it never runs anything. undefined
|
|
69
|
+
* (not a throw) when genuinely unresolvable from here.
|
|
70
|
+
*/
|
|
71
|
+
export function resolveDependencyModulesDir(fromDir: string, name: string): string | undefined {
|
|
72
|
+
try {
|
|
73
|
+
const req = createRequire(join(fromDir, "package.json"));
|
|
74
|
+
return realpathSync(dirname(req.resolve(`${name}/package.json`)));
|
|
75
|
+
} catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
56
80
|
function sandboxCommand(packageRoot: string, extensionPath: string, maxProcesses: number): string[] | undefined {
|
|
57
81
|
if (process.platform !== "linux" || !existsSync("/usr/bin/bwrap") || !existsSync("/usr/bin/prlimit")) return undefined;
|
|
58
82
|
const serviceRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
59
83
|
const packageDirectory = dirname(serviceRoot);
|
|
60
84
|
const childHarness = join(serviceRoot, "src/adoption/smoke-child.ts");
|
|
61
|
-
const
|
|
62
|
-
|
|
85
|
+
const jitiModules = resolveDependencyModulesDir(packageDirectory, "jiti");
|
|
86
|
+
if (!jitiModules) return undefined;
|
|
63
87
|
const bun = realpathSync(process.execPath);
|
|
64
88
|
const relativeExtension = relative(packageRoot, extensionPath).split(sep).join("/");
|
|
65
89
|
const args = [
|
package/service/src/cli/cli.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { checkUpdates } from "../daemon/watcher.ts";
|
|
|
13
13
|
import { generateIndex, indexPath, readIndex } from "../index/build-index.ts";
|
|
14
14
|
import { syncCatalog } from "../packages/catalog.ts";
|
|
15
15
|
import { catalogList, dbPath, getSyncMeta, latestVersion, openDb, searchLocal } from "../packages/db.ts";
|
|
16
|
+
import { formatDeployVerification, verifyDeploy } from "../packages/deploy-verify.ts";
|
|
16
17
|
import { defaultPiBin, NAME_RE } from "../packages/install.ts";
|
|
17
18
|
import { npmPackageName, readInstalledPackages, readInstalledPackagesAcrossScopes } from "../packages/installed.ts";
|
|
18
19
|
import type { Installer, Pkg, Registry, UpdateEntry } from "../packages/package.ts";
|
|
@@ -93,6 +94,7 @@ usage:
|
|
|
93
94
|
packed index status [--json] report the local static index's generatedAt and package count
|
|
94
95
|
packed check [path] [--smoke] [--json] diagnose a Pi package; smoke is isolated and opt-in
|
|
95
96
|
packed doctor [--project <path>] [--json] smoke-test every enabled extension (global + project scope) and report tool/command/shortcut/flag name collisions before pi has to
|
|
97
|
+
packed verify-deploy <name> [--version <v>] [--json] diff a package's on-disk version across every known install location plus stale node_modules shadow copies
|
|
96
98
|
packed pack [path] [--json] verify exact npm tarball contents without lifecycle scripts
|
|
97
99
|
packed score [path|name] [--json] report adoption-readiness evidence by dimension
|
|
98
100
|
packed publish setup [path] [--force] [--json] generate an OIDC staged-publish workflow
|
|
@@ -169,6 +171,8 @@ interface Flags {
|
|
|
169
171
|
ecosystem: boolean;
|
|
170
172
|
self: boolean;
|
|
171
173
|
project?: string;
|
|
174
|
+
version?: string;
|
|
175
|
+
to?: string;
|
|
172
176
|
}
|
|
173
177
|
|
|
174
178
|
function parseFlags(rest: string[]): { flags: Flags; pos: string[] } {
|
|
@@ -202,6 +206,10 @@ function parseFlags(rest: string[]): { flags: Flags; pos: string[] } {
|
|
|
202
206
|
else if (a.startsWith("--limit=")) flags.limit = Number(a.slice(8)) || SEARCH_DEFAULT_LIMIT;
|
|
203
207
|
else if (a === "--project" && i + 1 < rest.length) flags.project = rest[++i];
|
|
204
208
|
else if (a.startsWith("--project=")) flags.project = a.slice(10);
|
|
209
|
+
else if (a === "--version" && i + 1 < rest.length) flags.version = rest[++i];
|
|
210
|
+
else if (a.startsWith("--version=")) flags.version = a.slice(10);
|
|
211
|
+
else if (a === "--to" && i + 1 < rest.length) flags.to = rest[++i];
|
|
212
|
+
else if (a.startsWith("--to=")) flags.to = a.slice(5);
|
|
205
213
|
else pos.push(a);
|
|
206
214
|
}
|
|
207
215
|
return { flags, pos };
|
|
@@ -358,6 +366,17 @@ const commands: Record<string, { usage: string; run: Command }> = {
|
|
|
358
366
|
},
|
|
359
367
|
},
|
|
360
368
|
|
|
369
|
+
"verify-deploy": {
|
|
370
|
+
usage: "packed verify-deploy <name> [--version <v>] [--json] (diff a package's on-disk version across every known install location plus stale node_modules shadow copies)",
|
|
371
|
+
async run(_rest, d, flags, pos) {
|
|
372
|
+
const name = pos[0] ?? "";
|
|
373
|
+
if (!NAME_RE.test(name)) return usageErr(`usage: ${commands["verify-deploy"]!.usage}\n`);
|
|
374
|
+
const report = verifyDeploy(d.piHome, name, flags.version);
|
|
375
|
+
const output = formatDeployVerification(report, flags.json);
|
|
376
|
+
return report.ok ? ok(output) : fail(output);
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
|
|
361
380
|
search: {
|
|
362
381
|
usage: "packed search <query> [--offline] [--limit N] [--json]",
|
|
363
382
|
async run(_rest, d, flags, pos) {
|
|
@@ -646,7 +665,8 @@ const commands: Record<string, { usage: string; run: Command }> = {
|
|
|
646
665
|
},
|
|
647
666
|
|
|
648
667
|
update: {
|
|
649
|
-
usage:
|
|
668
|
+
usage:
|
|
669
|
+
"packed update <configured-source> [--to <new-source>] [--approve] [--json] | packed update --self [--approve] [--json] (--to replaces an exact pin end to end -- see verify-deploy/doctor for post-replace checks)",
|
|
650
670
|
async run(_rest, d, flags, pos) {
|
|
651
671
|
if (flags.self) {
|
|
652
672
|
try {
|
|
@@ -666,20 +686,25 @@ const commands: Record<string, { usage: string; run: Command }> = {
|
|
|
666
686
|
}
|
|
667
687
|
const source = pos[0] ?? "";
|
|
668
688
|
if (!SOURCE_RE.test(source)) return usageErr(`usage: ${commands.update!.usage}\n`);
|
|
689
|
+
if (flags.to !== undefined && !SOURCE_RE.test(flags.to)) return usageErr(`usage: ${commands.update!.usage}\n`);
|
|
669
690
|
try {
|
|
670
|
-
const outcome = await d.inst.update(source, { approved: flags.approved });
|
|
691
|
+
const outcome = await d.inst.update(source, { approved: flags.approved, target: flags.to });
|
|
671
692
|
if (outcome.alreadyUpToDate) {
|
|
672
693
|
if (flags.json) return ok(`${JSON.stringify({ ok: true, source, ...outcome })}\n`);
|
|
673
694
|
const version = outcome.currentVersion ?? outcome.previousVersion;
|
|
674
695
|
const reason = outcome.pinned
|
|
675
|
-
? `is pinned to ${version ?? "an exact version"} — pi update intentionally leaves pinned packages unchanged; run \`packed
|
|
696
|
+
? `is pinned to ${version ?? "an exact version"} — pi update intentionally leaves pinned packages unchanged; run \`packed update ${source} --to npm:${npmPackageName(source) ?? source}@<version>\` to move off the pin`
|
|
676
697
|
: `is already up to date${version ? ` at ${version}` : ""}`;
|
|
677
|
-
|
|
698
|
+
const outOfRangeNote = outcome.outOfRangeUpdateAvailable
|
|
699
|
+
? ` (a newer version, ${outcome.outOfRangeUpdateAvailable}, exists outside the declared range -- widen it to update)`
|
|
700
|
+
: "";
|
|
701
|
+
return ok(`${source} ${reason}${outOfRangeNote}\n`);
|
|
678
702
|
}
|
|
679
703
|
if (flags.json) return ok(`${JSON.stringify({ ok: true, source, ...outcome })}\n`);
|
|
680
704
|
const transition =
|
|
681
705
|
outcome.previousVersion && outcome.currentVersion ? ` (${outcome.previousVersion} → ${outcome.currentVersion})` : "";
|
|
682
|
-
|
|
706
|
+
const replacedNote = outcome.replaced ? ` (replaced ${outcome.before?.source ?? source} with ${outcome.after?.source ?? ""})` : "";
|
|
707
|
+
return ok(`${outcome.output}${transition}${replacedNote}\nReload Pi with /reload to activate the updated package.\n`);
|
|
683
708
|
} catch (error) {
|
|
684
709
|
const message = error instanceof Error ? error.message : String(error);
|
|
685
710
|
return flags.json
|
|
@@ -51,7 +51,7 @@ export interface PackageDaemonPort {
|
|
|
51
51
|
restartService(source: string, approved?: boolean): Promise<{ output: string; restarted?: boolean; spec?: ServiceSummary }>;
|
|
52
52
|
reconcileServices(approved?: boolean, projectRoot?: string): Promise<OperationOutputs["package.reconcile_services"]>;
|
|
53
53
|
remove(name: string, approved?: boolean): Promise<string>;
|
|
54
|
-
update(source: string, approved?: boolean): Promise<UpdateOutcome>;
|
|
54
|
+
update(source: string, approved?: boolean, target?: string): Promise<UpdateOutcome>;
|
|
55
55
|
piStatus(): Promise<PiVersionReport>;
|
|
56
56
|
resourcesList(projectRoot?: string): Promise<{ global: PackageResources[]; project: PackageResources[] }>;
|
|
57
57
|
resourcesToggle(
|
|
@@ -265,8 +265,8 @@ export class PackageDaemonClient implements PackageDaemonPort {
|
|
|
265
265
|
return result.output;
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
-
async update(source: string, approved = false): Promise<UpdateOutcome> {
|
|
269
|
-
const result = await this.call("package.update", { source, approved });
|
|
268
|
+
async update(source: string, approved = false, target?: string): Promise<UpdateOutcome> {
|
|
269
|
+
const result = await this.call("package.update", { source, approved, target });
|
|
270
270
|
if (!result.ok) throw new PackageDaemonError(result.output || `failed to update ${source}`, "package.update");
|
|
271
271
|
return {
|
|
272
272
|
output: result.output,
|
|
@@ -275,6 +275,12 @@ export class PackageDaemonClient implements PackageDaemonPort {
|
|
|
275
275
|
pinned: result.pinned ?? false,
|
|
276
276
|
previousVersion: result.previousVersion,
|
|
277
277
|
currentVersion: result.currentVersion,
|
|
278
|
+
outOfRangeUpdateAvailable: result.outOfRangeUpdateAvailable,
|
|
279
|
+
pinnedSourceRequiresTarget: result.pinnedSourceRequiresTarget,
|
|
280
|
+
replaced: result.replaced,
|
|
281
|
+
before: result.before,
|
|
282
|
+
after: result.after,
|
|
283
|
+
rollback: result.rollback,
|
|
278
284
|
};
|
|
279
285
|
}
|
|
280
286
|
}
|
|
@@ -289,8 +295,8 @@ export class PackageDaemonInstaller implements Installer {
|
|
|
289
295
|
throw new PackageDaemonError("daemon package removal requires an npm: source", "package.remove");
|
|
290
296
|
return this.client.remove(source.slice(4), options?.approved);
|
|
291
297
|
}
|
|
292
|
-
update(source: string, options?: { approved?: boolean; local?: boolean }): Promise<UpdateOutcome> {
|
|
293
|
-
return this.client.update(source, options?.approved);
|
|
298
|
+
update(source: string, options?: { approved?: boolean; local?: boolean; target?: string }): Promise<UpdateOutcome> {
|
|
299
|
+
return this.client.update(source, options?.approved, options?.target);
|
|
294
300
|
}
|
|
295
301
|
}
|
|
296
302
|
|
|
@@ -312,8 +318,8 @@ export class DaemonBackedInstaller implements Installer {
|
|
|
312
318
|
async remove(source: string, options?: { approved?: boolean }): Promise<string> {
|
|
313
319
|
return new PackageDaemonInstaller(await connectPackageDaemon(this.paths)).remove(source, options);
|
|
314
320
|
}
|
|
315
|
-
async update(source: string, options?: { approved?: boolean }): Promise<UpdateOutcome> {
|
|
316
|
-
return (await connectPackageDaemon(this.paths)).update(source, options?.approved);
|
|
321
|
+
async update(source: string, options?: { approved?: boolean; target?: string }): Promise<UpdateOutcome> {
|
|
322
|
+
return (await connectPackageDaemon(this.paths)).update(source, options?.approved, options?.target);
|
|
317
323
|
}
|
|
318
324
|
}
|
|
319
325
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { dirname } from "node:path";
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
2
3
|
import {
|
|
3
4
|
type MaintenanceTask,
|
|
4
5
|
type RunningDaemon,
|
|
@@ -7,6 +8,7 @@ import {
|
|
|
7
8
|
startDaemon,
|
|
8
9
|
} from "@danypops/vehicle-server/daemon";
|
|
9
10
|
import { ensureAuthToken } from "@danypops/vehicle-server/paths";
|
|
11
|
+
import { captureLoadedModules, checkModuleFreshnessAll, ownRuntimeDependencyNames } from "../adoption/module-freshness.ts";
|
|
10
12
|
import { type DaemonServiceInstaller, RealDaemonServiceInstaller, reconcileAllDaemonServices } from "./daemon-service.ts";
|
|
11
13
|
import { generateIndex, indexPath, indexStatus } from "../index/build-index.ts";
|
|
12
14
|
import { catalogStatus, syncCatalog } from "../packages/catalog.ts";
|
|
@@ -51,14 +53,34 @@ function configuredIdleBudgetMs(options: StartPackedDaemonOptions): number | und
|
|
|
51
53
|
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1_000 : undefined;
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Captured exactly once, here at daemon startup (daemonOptions() itself
|
|
58
|
+
* runs once per process -- see vehicle-server/daemon.js's own single
|
|
59
|
+
* `buildApp()` call site) -- see module-freshness.ts's own header comment
|
|
60
|
+
* for why this specific moment is what makes the later comparison
|
|
61
|
+
* meaningful: this is the instant this process's own static imports last
|
|
62
|
+
* actually loaded these files into memory.
|
|
63
|
+
*/
|
|
64
|
+
function captureOwnModuleSnapshot() {
|
|
65
|
+
const serviceRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
66
|
+
const packageDirectory = dirname(serviceRoot);
|
|
67
|
+
const ownPackageJsonPath = join(packageDirectory, "package.json");
|
|
68
|
+
return captureLoadedModules(packageDirectory, ownRuntimeDependencyNames(ownPackageJsonPath));
|
|
69
|
+
}
|
|
70
|
+
|
|
54
71
|
export function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOptions {
|
|
55
72
|
const paths = options.paths ?? resolvePackedPaths();
|
|
56
73
|
if (options.migrateLegacy ?? options.paths === undefined) migrateLegacyPackedState(paths, legacyPackedStateDirectory());
|
|
57
74
|
const token = ensureAuthToken(paths.token, "Packed");
|
|
58
75
|
const reg = options.reg ?? new HttpRegistry();
|
|
59
|
-
const inst = options.inst ?? new ExecInstaller();
|
|
60
76
|
const piHome = options.piHome ?? defaultPiHome();
|
|
61
77
|
const database = openDb(paths.database);
|
|
78
|
+
const moduleSnapshot = captureOwnModuleSnapshot();
|
|
79
|
+
// Wires ExecInstaller.update()'s own out-of-range cross-check to the exact
|
|
80
|
+
// same registry-mirror source of truth the package-update-check task below
|
|
81
|
+
// already uses via checkUpdates() -- one mirror, two consumers, never two
|
|
82
|
+
// notions of "the real latest version".
|
|
83
|
+
const inst = options.inst ?? new ExecInstaller(undefined, piHome, undefined, undefined, undefined, (name) => latestVersion(database, name));
|
|
62
84
|
const daemonServiceInstaller = options.daemonServiceInstaller ?? new RealDaemonServiceInstaller();
|
|
63
85
|
const configuredMaintenanceTasks = options.maintenanceTasks ?? [
|
|
64
86
|
{
|
|
@@ -116,7 +138,17 @@ export function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOpt
|
|
|
116
138
|
maintenanceTasks,
|
|
117
139
|
...(idleBudgetMs === undefined ? {} : { idleBudgetMs }),
|
|
118
140
|
idleTickMs: WATCHDOG_TICK_MS,
|
|
119
|
-
buildApp: () =>
|
|
141
|
+
buildApp: () =>
|
|
142
|
+
createApp({
|
|
143
|
+
reg,
|
|
144
|
+
inst,
|
|
145
|
+
token,
|
|
146
|
+
stateDir: paths.stateDirectory,
|
|
147
|
+
dataDir: dirname(paths.database),
|
|
148
|
+
piHome,
|
|
149
|
+
daemonServiceInstaller,
|
|
150
|
+
moduleFreshness: () => checkModuleFreshnessAll(moduleSnapshot),
|
|
151
|
+
}),
|
|
120
152
|
onShutdown: () => database.close(),
|
|
121
153
|
};
|
|
122
154
|
}
|
|
@@ -13,6 +13,7 @@ import { registerPackedVehicleOperations } from "./vehicle-registration.ts";
|
|
|
13
13
|
import { type AdvisoryReport, resolveInstalledVersions, scanInstalledPackages } from "../adoption/advisories.ts";
|
|
14
14
|
import { type CheckReport, type PackageChecker, StaticPackageChecker } from "../adoption/check.ts";
|
|
15
15
|
import { type DoctorReport, runDoctor } from "../adoption/doctor.ts";
|
|
16
|
+
import type { ModuleFreshnessDiagnostic } from "../adoption/module-freshness.ts";
|
|
16
17
|
import { NpmPackVerifier, type PackReport } from "../adoption/pack.ts";
|
|
17
18
|
import { type AdoptionReport, scoreTarget } from "../adoption/score.ts";
|
|
18
19
|
import { buildIndex, indexPath, type PackageIndex, readIndex, writeIndex } from "../index/build-index.ts";
|
|
@@ -68,6 +69,15 @@ export interface Deps {
|
|
|
68
69
|
apply(manifestPath: string, options?: { prune?: boolean }): Promise<SetupApplyResult>;
|
|
69
70
|
};
|
|
70
71
|
daemonServiceInstaller?: DaemonServiceInstaller;
|
|
72
|
+
/**
|
|
73
|
+
* Re-checks this exact running process's own captured startup snapshot
|
|
74
|
+
* of its runtime dependencies against their current on-disk state --
|
|
75
|
+
* see module-freshness.ts. Sync (a handful of small file stats/reads),
|
|
76
|
+
* injected by daemon.ts once at process start; undefined for any Deps
|
|
77
|
+
* built without a real running process behind it (a standalone `packed
|
|
78
|
+
* doctor` never has a snapshot to compare against at all).
|
|
79
|
+
*/
|
|
80
|
+
moduleFreshness?: () => ModuleFreshnessDiagnostic[];
|
|
71
81
|
piVersion?: { check(options?: { timeoutMs?: number }): Promise<PiVersionReport> };
|
|
72
82
|
advisories?: { scan(installed: Record<string, string>): Promise<AdvisoryReport> };
|
|
73
83
|
}
|
|
@@ -126,7 +136,7 @@ export interface OperationInputs {
|
|
|
126
136
|
"package.restart_service": { source: string; approved?: boolean };
|
|
127
137
|
"package.reconcile_services": { approved?: boolean; projectRoot?: string };
|
|
128
138
|
"package.remove": { name: string; approved?: boolean };
|
|
129
|
-
"package.update": { source: string; approved?: boolean };
|
|
139
|
+
"package.update": { source: string; approved?: boolean; target?: string };
|
|
130
140
|
"resources.list": { projectRoot?: string };
|
|
131
141
|
"resources.toggle": { source: string; field: ResourceField; path: string; enabled: boolean; projectRoot?: string; approved?: boolean };
|
|
132
142
|
"pi.status": Record<string, never>;
|
|
@@ -257,6 +267,10 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
257
267
|
description: "Pi package lifecycle daemon",
|
|
258
268
|
});
|
|
259
269
|
registerPackedVehicleOperations(vehicleRegistry, executeOperation);
|
|
270
|
+
// Live from day one: the registry's own gate mirrors whatever mutationApproval already
|
|
271
|
+
// is on disk at startup, then tracks every /security POST from here on (see below) --
|
|
272
|
+
// never a fixed per-deployment constant baked in once and forgotten.
|
|
273
|
+
vehicleRegistry.configureApprovals({ enabled: readSecuritySettings(deps.stateDir).mutationApproval === "always" });
|
|
260
274
|
const vehicleApp = createVehicleHttpApp({ registry: vehicleRegistry, token: deps.token });
|
|
261
275
|
|
|
262
276
|
function authorize(operation: PackageOperation, approved: boolean): Response | undefined {
|
|
@@ -294,7 +308,14 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
294
308
|
}
|
|
295
309
|
const denied = authorize("security.write", body.approved === true);
|
|
296
310
|
if (denied) return denied;
|
|
297
|
-
|
|
311
|
+
const mutationApproval = body.mutationApproval as MutationApproval;
|
|
312
|
+
const written = await writeSecuritySettings(deps.stateDir, { mutationApproval });
|
|
313
|
+
// Same live-toggle guarantee the legacy /api/v1/ops transport already had via
|
|
314
|
+
// readSecuritySettings() being re-read fresh on every request -- the Vehicle
|
|
315
|
+
// transport's own gate is a stateful in-memory policy, so a change here has to
|
|
316
|
+
// be pushed to it explicitly instead of being implicitly always-fresh.
|
|
317
|
+
vehicleRegistry.updateApprovalPolicy({ enabled: mutationApproval === "always" });
|
|
318
|
+
return json(written);
|
|
298
319
|
}
|
|
299
320
|
|
|
300
321
|
if (path === "/search" && req.method === "GET") {
|
|
@@ -486,22 +507,32 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
486
507
|
if (path === "/update" && req.method === "POST") {
|
|
487
508
|
let source = "";
|
|
488
509
|
let approved = false;
|
|
510
|
+
let target: string | undefined;
|
|
489
511
|
try {
|
|
490
|
-
const body = (await req.json()) as { source?: unknown; approved?: unknown };
|
|
512
|
+
const body = (await req.json()) as { source?: unknown; approved?: unknown; target?: unknown };
|
|
491
513
|
source = String(body.source ?? "");
|
|
492
514
|
approved = body.approved === true;
|
|
515
|
+
target = typeof body.target === "string" && body.target.length > 0 ? body.target : undefined;
|
|
493
516
|
} catch {
|
|
494
517
|
/* fall through to validation */
|
|
495
518
|
}
|
|
496
519
|
if (!SOURCE_RE.test(source)) {
|
|
497
520
|
return err(400, "invalid source; want a configured npm:, git:, or https package source");
|
|
498
521
|
}
|
|
522
|
+
if (target !== undefined && !SOURCE_RE.test(target)) {
|
|
523
|
+
return err(400, "invalid target; want a configured npm:, git:, or https package source");
|
|
524
|
+
}
|
|
499
525
|
const denied = authorize("update", approved);
|
|
500
526
|
if (denied) return denied;
|
|
527
|
+
// A successful replace re-identifies the package under `target`, not
|
|
528
|
+
// `source` -- a daemon-backed package's own service restart (below)
|
|
529
|
+
// must reconcile against the NEW installed source, never the one that
|
|
530
|
+
// was just removed.
|
|
531
|
+
const restartSource = target ?? source;
|
|
501
532
|
try {
|
|
502
|
-
const outcome = await deps.inst.update(source, { approved });
|
|
503
|
-
if (!
|
|
504
|
-
const service = await daemonServiceInstaller.restart(piHomeForServiceInstall,
|
|
533
|
+
const outcome = await deps.inst.update(source, { approved, target });
|
|
534
|
+
if (!restartSource.startsWith("npm:") || outcome.alreadyUpToDate) return json({ ok: true, source, ...outcome });
|
|
535
|
+
const service = await daemonServiceInstaller.restart(piHomeForServiceInstall, restartSource);
|
|
505
536
|
if (!service.ok) {
|
|
506
537
|
if (service.notADaemon) return json({ ok: true, source, ...outcome });
|
|
507
538
|
return json({ ok: false, source, ...outcome, output: `${outcome.output}\n${service.reason}` });
|
|
@@ -598,7 +629,11 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
598
629
|
const value = input as OperationInputs["doctor.run"];
|
|
599
630
|
if (value.projectRoot !== undefined && (typeof value.projectRoot !== "string" || value.projectRoot.length > 4_096))
|
|
600
631
|
throw new PackageOperationError("projectRoot must be a string up to 4096 characters", 400);
|
|
601
|
-
|
|
632
|
+
const report = await runDoctor(deps.piHome ?? defaultPiHome(), value.projectRoot);
|
|
633
|
+
const moduleFreshness = deps.moduleFreshness?.();
|
|
634
|
+
if (moduleFreshness === undefined) return report as OperationOutputs[Name];
|
|
635
|
+
const anyStale = moduleFreshness.some((diagnostic) => diagnostic.stale);
|
|
636
|
+
return { ...report, moduleFreshness, ok: report.ok && !anyStale } as OperationOutputs[Name];
|
|
602
637
|
}
|
|
603
638
|
if (op === "package.updates.project") {
|
|
604
639
|
const value = input as OperationInputs["package.updates.project"];
|
|
@@ -27,13 +27,29 @@
|
|
|
27
27
|
* install/install_service/update/setup.apply which can fetch and run
|
|
28
28
|
* arbitrary newly-published code).
|
|
29
29
|
*
|
|
30
|
-
* Approval
|
|
31
|
-
* (
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
30
|
+
* Approval is delegated to the registry's own Vehicle-native mechanism
|
|
31
|
+
* (VehicleRegistry.configureApprovals/updateApprovalPolicy, wired up in
|
|
32
|
+
* daemon.ts/service.ts against packed's own mutationApproval setting) --
|
|
33
|
+
* NOT reimplemented here. Each operation below carries an explicit
|
|
34
|
+
* requiresApproval, exactly matching security.ts's PACKAGE_OPERATIONS
|
|
35
|
+
* classification's own guarded set (code-execution/settings-mutation/
|
|
36
|
+
* security-mutation), set unconditionally rather than left to derive from
|
|
37
|
+
* effect: two of Vehicle's five effect buckets already mix a guarded
|
|
38
|
+
* operation with an unguarded one here (external-write covers both
|
|
39
|
+
* restart_service/reconcile_services, which ARE guarded, and
|
|
40
|
+
* catalog.sync/index.build, which never were; local-write covers both
|
|
41
|
+
* resources.toggle/security.set, guarded, and setup.export/setup.update,
|
|
42
|
+
* never guarded) -- no single effect-derived default could reproduce this
|
|
43
|
+
* split, which is exactly the gap VehicleOperationDescriptor.requiresApproval
|
|
44
|
+
* exists to close.
|
|
45
|
+
*
|
|
46
|
+
* Once the registry's own gate approves a call (because it wasn't gated,
|
|
47
|
+
* or because a real capability was presented), the bound handler forces
|
|
48
|
+
* approved: true onto the input before delegating to executeOperation() --
|
|
49
|
+
* its own legacy authorize()/assertPackagePermission() check (still the
|
|
50
|
+
* sole gate for the older /api/v1/ops transport, untouched here) would
|
|
51
|
+
* otherwise reject a call whose caller never knew that REST-only field
|
|
52
|
+
* existed. A Vehicle caller's only approval contract is Vehicle's own.
|
|
37
53
|
*/
|
|
38
54
|
|
|
39
55
|
import type { VehicleEffect, VehicleIdempotency } from "@danypops/vehicle-core";
|
|
@@ -66,6 +82,11 @@ const WRITE: VehicleIdempotency = { mode: "unsafe" };
|
|
|
66
82
|
interface OperationMeta {
|
|
67
83
|
readonly description: string;
|
|
68
84
|
readonly effect: VehicleEffect;
|
|
85
|
+
/** See vehicle-core's VehicleOperationDescriptor.requiresApproval -- set explicitly for
|
|
86
|
+
* every operation here (never left to derive from effect), matching security.ts's own
|
|
87
|
+
* per-operation guarded/unguarded classification exactly. See this file's own doc
|
|
88
|
+
* comment for why effect alone can't reproduce that split. */
|
|
89
|
+
readonly requiresApproval: boolean;
|
|
69
90
|
}
|
|
70
91
|
|
|
71
92
|
/**
|
|
@@ -74,53 +95,111 @@ interface OperationMeta {
|
|
|
74
95
|
* classification) and its two deliberate refinements.
|
|
75
96
|
*/
|
|
76
97
|
const OPERATION_META: Record<OperationName, OperationMeta> = {
|
|
77
|
-
"package.search": { description: "Searches Pi packages on npm.", effect: "read" },
|
|
78
|
-
"package.info": { description: "Reads bounded metadata for one package.", effect: "read" },
|
|
79
|
-
"package.installed": { description: "Lists locally installed Pi packages.", effect: "read" },
|
|
80
|
-
"package.catalog": { description: "Reads the local SQLite catalog mirror.", effect: "read" },
|
|
98
|
+
"package.search": { description: "Searches Pi packages on npm.", effect: "read", requiresApproval: false },
|
|
99
|
+
"package.info": { description: "Reads bounded metadata for one package.", effect: "read", requiresApproval: false },
|
|
100
|
+
"package.installed": { description: "Lists locally installed Pi packages.", effect: "read", requiresApproval: false },
|
|
101
|
+
"package.catalog": { description: "Reads the local SQLite catalog mirror.", effect: "read", requiresApproval: false },
|
|
81
102
|
"package.catalog.sync": {
|
|
82
103
|
description: "Refreshes the local catalog mirror from the Pi-package-tagged npm registry subset.",
|
|
83
104
|
effect: "external-write",
|
|
105
|
+
requiresApproval: false,
|
|
84
106
|
},
|
|
85
|
-
"package.index": { description: "Reads the locally built adoption-score index, if one exists.", effect: "read" },
|
|
107
|
+
"package.index": { description: "Reads the locally built adoption-score index, if one exists.", effect: "read", requiresApproval: false },
|
|
86
108
|
"package.index.build": {
|
|
87
109
|
description: "Builds the adoption-score index by scoring catalog entries against the npm registry.",
|
|
88
110
|
effect: "external-write",
|
|
111
|
+
requiresApproval: false,
|
|
112
|
+
},
|
|
113
|
+
"package.updates": { description: "Reads the last background update-check snapshot.", effect: "read", requiresApproval: false },
|
|
114
|
+
"package.check": {
|
|
115
|
+
description: "Runs static (and optionally smoke-test) quality checks against a local package path.",
|
|
116
|
+
effect: "read",
|
|
117
|
+
requiresApproval: false,
|
|
118
|
+
},
|
|
119
|
+
"package.pack": { description: "Verifies a local package path via npm pack.", effect: "read", requiresApproval: false },
|
|
120
|
+
"package.score": {
|
|
121
|
+
description: "Computes an adoption-readiness score for a local path or registry package.",
|
|
122
|
+
effect: "read",
|
|
123
|
+
requiresApproval: false,
|
|
124
|
+
},
|
|
125
|
+
"setup.export": { description: "Exports the current Pi setup as a portable manifest.", effect: "local-write", requiresApproval: false },
|
|
126
|
+
"setup.update": { description: "Updates an existing setup manifest in place.", effect: "local-write", requiresApproval: false },
|
|
127
|
+
"setup.plan": {
|
|
128
|
+
description: "Computes a setup manifest's install/update/remove plan without applying it.",
|
|
129
|
+
effect: "read",
|
|
130
|
+
requiresApproval: false,
|
|
89
131
|
},
|
|
90
|
-
"package.updates": { description: "Reads the last background update-check snapshot.", effect: "read" },
|
|
91
|
-
"package.check": { description: "Runs static (and optionally smoke-test) quality checks against a local package path.", effect: "read" },
|
|
92
|
-
"package.pack": { description: "Verifies a local package path via npm pack.", effect: "read" },
|
|
93
|
-
"package.score": { description: "Computes an adoption-readiness score for a local path or registry package.", effect: "read" },
|
|
94
|
-
"setup.export": { description: "Exports the current Pi setup as a portable manifest.", effect: "local-write" },
|
|
95
|
-
"setup.update": { description: "Updates an existing setup manifest in place.", effect: "local-write" },
|
|
96
|
-
"setup.plan": { description: "Computes a setup manifest's install/update/remove plan without applying it.", effect: "read" },
|
|
97
132
|
"setup.apply": {
|
|
98
133
|
description: "Applies a setup manifest's plan -- can install, update, or remove packages.",
|
|
99
134
|
effect: "open-world",
|
|
135
|
+
requiresApproval: true,
|
|
136
|
+
},
|
|
137
|
+
"package.security.get": {
|
|
138
|
+
description: "Reads this daemon's mutation-approval security settings.",
|
|
139
|
+
effect: "read",
|
|
140
|
+
requiresApproval: false,
|
|
141
|
+
},
|
|
142
|
+
"package.security.set": {
|
|
143
|
+
description: "Writes this daemon's mutation-approval security settings.",
|
|
144
|
+
effect: "local-write",
|
|
145
|
+
requiresApproval: true,
|
|
146
|
+
},
|
|
147
|
+
"package.install": {
|
|
148
|
+
description: "Installs a Pi package from an npm, git, or https source.",
|
|
149
|
+
effect: "open-world",
|
|
150
|
+
requiresApproval: true,
|
|
100
151
|
},
|
|
101
|
-
"package.security.get": { description: "Reads this daemon's mutation-approval security settings.", effect: "read" },
|
|
102
|
-
"package.security.set": { description: "Writes this daemon's mutation-approval security settings.", effect: "local-write" },
|
|
103
|
-
"package.install": { description: "Installs a Pi package from an npm, git, or https source.", effect: "open-world" },
|
|
104
152
|
"package.install_service": {
|
|
105
153
|
description: "Installs a persistent supervised service for an already-installed daemon package.",
|
|
106
154
|
effect: "open-world",
|
|
155
|
+
requiresApproval: true,
|
|
107
156
|
},
|
|
108
157
|
"package.restart_service": {
|
|
109
158
|
description: "Restarts an already-installed package's persistent service -- no new code introduced.",
|
|
110
159
|
effect: "external-write",
|
|
160
|
+
requiresApproval: true,
|
|
111
161
|
},
|
|
112
162
|
"package.reconcile_services": {
|
|
113
163
|
description: "Reconciles every installed daemon package's persistent service against desired state.",
|
|
114
164
|
effect: "external-write",
|
|
165
|
+
requiresApproval: true,
|
|
166
|
+
},
|
|
167
|
+
"package.remove": { description: "Removes an installed Pi package. Irreversible.", effect: "destructive", requiresApproval: true },
|
|
168
|
+
"package.update": {
|
|
169
|
+
description: "Updates a configured Pi package to its latest available version.",
|
|
170
|
+
effect: "open-world",
|
|
171
|
+
requiresApproval: true,
|
|
172
|
+
},
|
|
173
|
+
"resources.list": {
|
|
174
|
+
description: "Lists global and project-scoped Pi resources (extensions, skills, prompts, themes).",
|
|
175
|
+
effect: "read",
|
|
176
|
+
requiresApproval: false,
|
|
177
|
+
},
|
|
178
|
+
"resources.toggle": {
|
|
179
|
+
description: "Enables or disables one Pi resource in a settings file.",
|
|
180
|
+
effect: "local-write",
|
|
181
|
+
requiresApproval: true,
|
|
182
|
+
},
|
|
183
|
+
"pi.status": {
|
|
184
|
+
description: "Reports the locally running Pi version against the latest published release.",
|
|
185
|
+
effect: "read",
|
|
186
|
+
requiresApproval: false,
|
|
187
|
+
},
|
|
188
|
+
"advisories.scan": {
|
|
189
|
+
description: "Scans installed package versions against known advisories.",
|
|
190
|
+
effect: "read",
|
|
191
|
+
requiresApproval: false,
|
|
192
|
+
},
|
|
193
|
+
"doctor.run": {
|
|
194
|
+
description: "Runs diagnostic health checks (service install drift, resource config, ...).",
|
|
195
|
+
effect: "read",
|
|
196
|
+
requiresApproval: false,
|
|
197
|
+
},
|
|
198
|
+
"package.updates.project": {
|
|
199
|
+
description: "Checks for updates across every scope visible to one project.",
|
|
200
|
+
effect: "read",
|
|
201
|
+
requiresApproval: false,
|
|
115
202
|
},
|
|
116
|
-
"package.remove": { description: "Removes an installed Pi package. Irreversible.", effect: "destructive" },
|
|
117
|
-
"package.update": { description: "Updates a configured Pi package to its latest available version.", effect: "open-world" },
|
|
118
|
-
"resources.list": { description: "Lists global and project-scoped Pi resources (extensions, skills, prompts, themes).", effect: "read" },
|
|
119
|
-
"resources.toggle": { description: "Enables or disables one Pi resource in a settings file.", effect: "local-write" },
|
|
120
|
-
"pi.status": { description: "Reports the locally running Pi version against the latest published release.", effect: "read" },
|
|
121
|
-
"advisories.scan": { description: "Scans installed package versions against known advisories.", effect: "read" },
|
|
122
|
-
"doctor.run": { description: "Runs diagnostic health checks (service install drift, resource config, ...).", effect: "read" },
|
|
123
|
-
"package.updates.project": { description: "Checks for updates across every scope visible to one project.", effect: "read" },
|
|
124
203
|
};
|
|
125
204
|
|
|
126
205
|
/** Read effects need only packed:read; every other effect needs both (writes commonly also read first). */
|
|
@@ -142,16 +221,20 @@ export function registerPackedVehicleOperations(
|
|
|
142
221
|
output: passthroughVehicleSchema,
|
|
143
222
|
permissions: permissionsFor(meta.effect),
|
|
144
223
|
effect: meta.effect,
|
|
224
|
+
requiresApproval: meta.requiresApproval,
|
|
145
225
|
idempotency: meta.effect === "read" ? READ : WRITE,
|
|
146
226
|
limits: LIMITS,
|
|
147
227
|
});
|
|
148
228
|
registry.register(
|
|
149
229
|
OWNER,
|
|
150
|
-
bindVehicleOperation(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
230
|
+
bindVehicleOperation(operation, () => async (context) => {
|
|
231
|
+
// The registry's own gate (see this file's doc comment) already decided this
|
|
232
|
+
// call is approved by the time the handler runs -- forcing approved: true
|
|
233
|
+
// here satisfies executeOperation()'s own legacy authorize() check without
|
|
234
|
+
// asking a Vehicle caller to know that REST-only field even exists.
|
|
235
|
+
const input = { ...(context.input as Record<string, unknown>), approved: true } as OperationInputs[typeof name];
|
|
236
|
+
return withPackedErrorParity(() => executeOperation(name, input));
|
|
237
|
+
}),
|
|
155
238
|
);
|
|
156
239
|
}
|
|
157
240
|
}
|
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
-
import { gt, valid } from "semver";
|
|
9
8
|
import { UPDATES_FILE } from "../shared/constants.ts";
|
|
10
9
|
import { createLogger } from "../shared/log.ts";
|
|
11
10
|
|
|
12
11
|
const log = createLogger("watcher");
|
|
13
12
|
|
|
13
|
+
import { isNewer } from "../packages/package.ts";
|
|
14
14
|
import type { InstalledPkg, UpdateEntry, UpdatesSnapshot } from "../packages/package.ts";
|
|
15
15
|
|
|
16
16
|
function updatesPath(dir: string): string {
|
|
@@ -30,23 +30,6 @@ export async function loadUpdates(dir: string): Promise<UpdatesSnapshot | undefi
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/**
|
|
34
|
-
* True drift only: mirrored latest is a real semver step *ahead* of what's
|
|
35
|
-
* installed, never merely different from it. A plain !== check (this
|
|
36
|
-
* function's own bug until fixed) cannot distinguish "installed is behind
|
|
37
|
-
* latest" from "installed is already ahead of a stale/wrong mirrored
|
|
38
|
-
* latest" -- confirmed live: an installed package whose version had
|
|
39
|
-
* already passed the daemon's own mirrored dist-tags.latest kept showing
|
|
40
|
-
* a permanent, un-clearable "update available" badge pointing at an
|
|
41
|
-
* *older* version. Falls back to the old inequality only when either side
|
|
42
|
-
* isn't parseable semver (a git ref, a literal "latest" tag, etc.) --
|
|
43
|
-
* those aren't comparable at all, so "different" is the only signal left.
|
|
44
|
-
*/
|
|
45
|
-
function isNewer(latest: string, have: string): boolean {
|
|
46
|
-
if (valid(latest, { loose: true }) && valid(have, { loose: true })) return gt(latest, have, { loose: true });
|
|
47
|
-
return latest !== have;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
33
|
/** Pure diff against the local mirror (apt list --upgradable semantics):
|
|
51
34
|
* drift = mirrored latest is genuinely newer than what we have. The
|
|
52
35
|
* mirror is refreshed by catalogSync; updates are computed offline,
|