@danypops/pi-packed 0.21.14 → 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/approval/reload.ts +51 -2
- package/extension/src/menu-theme.ts +13 -3
- package/extension/src/packed.ts +2 -2
- package/extension/src/tabs/discover.ts +13 -2
- package/extension/src/tool-output.ts +14 -1
- package/extension/src/tools.ts +46 -14
- 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
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* subprocess as non-gating, observational evidence alongside that gating
|
|
13
13
|
* check -- see ExtensionLoadResult.additionalLoadPaths.
|
|
14
14
|
*/
|
|
15
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
15
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
16
16
|
import { createRequire } from "node:module";
|
|
17
17
|
import { tmpdir } from "node:os";
|
|
18
18
|
import { join, resolve } from "node:path";
|
|
@@ -74,6 +74,75 @@ const MAX_LOAD_TIMEOUT_MS = 30_000;
|
|
|
74
74
|
const PACK_TIMEOUT_MS = 30_000;
|
|
75
75
|
const INSTALL_TIMEOUT_MS = 60_000;
|
|
76
76
|
const MAX_EXTENSIONS = 20;
|
|
77
|
+
/** Bounds directoryHasMatchingFile's own recursive walk -- a real Pi package's
|
|
78
|
+
* resource directories are small; this only exists so an adversarial or
|
|
79
|
+
* accidentally enormous tarball can't make the convention-directory scan
|
|
80
|
+
* itself expensive. */
|
|
81
|
+
const MAX_CONVENTION_SCAN_ENTRIES = 500;
|
|
82
|
+
|
|
83
|
+
/** The four resource kinds a package.json's own `pi` manifest key, or (absent
|
|
84
|
+
* a manifest) pi's own convention directories, can declare -- see
|
|
85
|
+
* @earendil-works/pi-coding-agent's docs/packages.md "Package Structure". */
|
|
86
|
+
const MANIFEST_RESOURCE_FIELDS = ["extensions", "skills", "prompts", "themes"] as const;
|
|
87
|
+
|
|
88
|
+
/** Loosely mirrors (not a byte-for-byte port -- pi's own walker is
|
|
89
|
+
* .gitignore-aware via the `ignore` package, this isn't) pi's own
|
|
90
|
+
* convention-directory file matching from @earendil-works/pi-coding-agent's
|
|
91
|
+
* package-manager.js: extensions/ take .ts/.js recursively, skills/ take
|
|
92
|
+
* SKILL.md or any .md recursively, prompts/ and themes/ take top-level
|
|
93
|
+
* .md/.json respectively. Close enough to answer "would pi's own loader ever
|
|
94
|
+
* find anything to load here", which is all a pre-install admission check
|
|
95
|
+
* needs. */
|
|
96
|
+
const CONVENTION_RESOURCE_DIRS: Record<string, { match: (name: string) => boolean; recursive: boolean }> = {
|
|
97
|
+
extensions: { match: (name) => name.endsWith(".ts") || name.endsWith(".js"), recursive: true },
|
|
98
|
+
skills: { match: (name) => name === "SKILL.md" || name.endsWith(".md"), recursive: true },
|
|
99
|
+
prompts: { match: (name) => name.endsWith(".md"), recursive: false },
|
|
100
|
+
themes: { match: (name) => name.endsWith(".json"), recursive: false },
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
function hasNonEmptyStringArray(value: unknown): boolean {
|
|
104
|
+
return Array.isArray(value) && value.some((entry) => typeof entry === "string" && entry.trim().length > 0);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** True if the package's own `pi` manifest key declares at least one
|
|
108
|
+
* non-empty resource array of any of the four kinds -- not just
|
|
109
|
+
* `extensions`, which the caller already checked separately. */
|
|
110
|
+
function hasAnyManifestResource(pi: Record<string, unknown> | undefined): boolean {
|
|
111
|
+
if (!pi) return false;
|
|
112
|
+
return MANIFEST_RESOURCE_FIELDS.some((field) => hasNonEmptyStringArray(pi[field]));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Bounded recursive scan for at least one file matching `match` under `dir`.
|
|
116
|
+
* `remaining` is a shared mutable budget across the whole call tree so a
|
|
117
|
+
* pathological directory structure can't make this unbounded work. */
|
|
118
|
+
function directoryHasMatchingFile(dir: string, match: (name: string) => boolean, recursive: boolean, remaining: { count: number }): boolean {
|
|
119
|
+
if (!existsSync(dir) || remaining.count <= 0) return false;
|
|
120
|
+
let entries: import("node:fs").Dirent<string>[];
|
|
121
|
+
try {
|
|
122
|
+
entries = readdirSync(dir, { withFileTypes: true, encoding: "utf8" });
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
if (remaining.count-- <= 0) return false;
|
|
128
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
129
|
+
const full = join(dir, entry.name);
|
|
130
|
+
if (entry.isFile() && match(entry.name)) return true;
|
|
131
|
+
if (recursive && entry.isDirectory() && directoryHasMatchingFile(full, match, recursive, remaining)) return true;
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** True if the staged package root has any of pi's own convention resource
|
|
137
|
+
* directories (extensions/, skills/, prompts/, themes/) containing at least
|
|
138
|
+
* one file pi's own loader would actually pick up -- the fallback pi itself
|
|
139
|
+
* uses when a package declares no `pi` manifest at all. */
|
|
140
|
+
function hasConventionResources(root: string): boolean {
|
|
141
|
+
const remaining = { count: MAX_CONVENTION_SCAN_ENTRIES };
|
|
142
|
+
return Object.entries(CONVENTION_RESOURCE_DIRS).some(([dirName, { match, recursive }]) =>
|
|
143
|
+
directoryHasMatchingFile(join(root, dirName), match, recursive, remaining),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
77
146
|
|
|
78
147
|
function bounded(value: number | undefined, fallback: number, maximum: number): number {
|
|
79
148
|
if (!Number.isFinite(value) || value === undefined || value <= 0) return fallback;
|
|
@@ -215,9 +284,13 @@ async function verifyAllLoadPathsHeadless(entryPath: string, timeoutMs?: number)
|
|
|
215
284
|
}
|
|
216
285
|
|
|
217
286
|
/** Stages the real npm tarball in isolation and headlessly load-checks
|
|
218
|
-
* every pi.extensions entry it declares. A
|
|
219
|
-
*
|
|
220
|
-
*
|
|
287
|
+
* every pi.extensions entry it declares. A non-npm source has nothing to
|
|
288
|
+
* stage and passes through as ok. A package with no pi.extensions passes
|
|
289
|
+
* only if it declares some other pi manifest resource (skills/prompts/
|
|
290
|
+
* themes) or has a matching pi convention directory on disk -- otherwise
|
|
291
|
+
* it isn't a Pi package at all (a plain npm dependency like is-number or
|
|
292
|
+
* is-buffer) and is refused rather than silently admitted as a dead
|
|
293
|
+
* `packages` entry pi itself would never load anything from. */
|
|
221
294
|
export class HeadlessInstallValidator implements InstallValidator {
|
|
222
295
|
constructor(private readonly timeoutMs?: number) {}
|
|
223
296
|
|
|
@@ -241,7 +314,26 @@ export class HeadlessInstallValidator implements InstallValidator {
|
|
|
241
314
|
const declared = Array.isArray(pi?.extensions)
|
|
242
315
|
? pi.extensions.filter((entry): entry is string => typeof entry === "string").slice(0, MAX_EXTENSIONS)
|
|
243
316
|
: [];
|
|
244
|
-
if (declared.length === 0)
|
|
317
|
+
if (declared.length === 0) {
|
|
318
|
+
// No pi.extensions to headlessly load-check -- but that alone isn't
|
|
319
|
+
// license to wave the package through. A real Pi package with only
|
|
320
|
+
// skills/prompts/themes (declared in the manifest or found via pi's
|
|
321
|
+
// own convention directories) still passes here unexamined; a
|
|
322
|
+
// package that is neither -- e.g. `is-number`, `is-buffer`, any
|
|
323
|
+
// plain leaf npm dependency someone points `pkg_install` at
|
|
324
|
+
// directly -- gets refused instead of silently becoming a dead
|
|
325
|
+
// `packages` entry that pi itself will never load anything from.
|
|
326
|
+
if (hasAnyManifestResource(pi) || hasConventionResources(staged.root)) {
|
|
327
|
+
return { ok: true, source, extensions: [] };
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
ok: false,
|
|
331
|
+
source,
|
|
332
|
+
extensions: [],
|
|
333
|
+
message:
|
|
334
|
+
"package declares no pi.extensions/skills/prompts/themes and has no extensions/skills/prompts/themes directory -- not a Pi package",
|
|
335
|
+
};
|
|
336
|
+
}
|
|
245
337
|
|
|
246
338
|
const installed = await installStagedDependencies(staged.root);
|
|
247
339
|
if (!installed.ok) return { ok: false, source, extensions: [], message: installed.message };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* module-freshness.ts — turns a recurring diagnosis-by-intuition ("this
|
|
3
|
+
* looks like Bun/Node holding a cached copy of an old build across a live
|
|
4
|
+
* process") into a real, checkable signal.
|
|
5
|
+
*
|
|
6
|
+
* A long-running process (the packed daemon) loads its own dependencies
|
|
7
|
+
* once via static imports at startup; Node/Bun's module cache then holds
|
|
8
|
+
* that exact in-memory copy for the rest of the process's life, regardless
|
|
9
|
+
* of whatever a later `pkg_update`/`npm install` writes to the same files
|
|
10
|
+
* on disk -- only a restart picks up the new code. Detecting that gap
|
|
11
|
+
* needs one snapshot taken at process start (captureLoadedModule, reusing
|
|
12
|
+
* smoke.ts's own real module-resolution helper, never executing anything)
|
|
13
|
+
* and a later re-read of the exact same on-disk path (checkModuleFreshness)
|
|
14
|
+
* -- if the file's mtime moved since the snapshot, the running process is
|
|
15
|
+
* provably still holding stale code in memory.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync, statSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { resolveDependencyModulesDir } from "./smoke.ts";
|
|
20
|
+
|
|
21
|
+
export interface LoadedModuleSnapshot {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly packageJsonPath: string;
|
|
24
|
+
readonly version?: string;
|
|
25
|
+
readonly mtimeMs?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ModuleFreshnessDiagnostic {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly loadedVersion?: string;
|
|
31
|
+
readonly currentVersion?: string;
|
|
32
|
+
/** True only when both mtimes are known and genuinely differ -- never a
|
|
33
|
+
* guess from version alone (a version string can stay identical across
|
|
34
|
+
* a same-version reinstall that still replaced the file, and not every
|
|
35
|
+
* package bumps a version for a local/dev iteration). */
|
|
36
|
+
readonly stale: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readVersionAndMtime(packageJsonPath: string): { version?: string; mtimeMs?: number } {
|
|
40
|
+
try {
|
|
41
|
+
const mtimeMs = statSync(packageJsonPath).mtimeMs;
|
|
42
|
+
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
|
|
43
|
+
return { version: typeof pkg.version === "string" ? pkg.version : undefined, mtimeMs };
|
|
44
|
+
} catch {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolves `name`'s real installed package.json via Node's own module
|
|
51
|
+
* resolution (see smoke.ts's resolveDependencyModulesDir -- correct for
|
|
52
|
+
* both a nested and a hoisted layout, never executes anything) starting
|
|
53
|
+
* from `fromDir`, then records its version/mtime. undefined when `name`
|
|
54
|
+
* isn't resolvable from here at all -- never thrown, so one unresolvable
|
|
55
|
+
* watched name never prevents capturing the rest.
|
|
56
|
+
*/
|
|
57
|
+
export function captureLoadedModule(fromDir: string, name: string): LoadedModuleSnapshot | undefined {
|
|
58
|
+
const dir = resolveDependencyModulesDir(fromDir, name);
|
|
59
|
+
if (!dir) return undefined;
|
|
60
|
+
const packageJsonPath = join(dir, "package.json");
|
|
61
|
+
return { name, packageJsonPath, ...readVersionAndMtime(packageJsonPath) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Bulk capture, skipping (not failing on) any name that isn't resolvable. */
|
|
65
|
+
export function captureLoadedModules(fromDir: string, names: readonly string[]): LoadedModuleSnapshot[] {
|
|
66
|
+
const snapshots: LoadedModuleSnapshot[] = [];
|
|
67
|
+
for (const name of names) {
|
|
68
|
+
const snapshot = captureLoadedModule(fromDir, name);
|
|
69
|
+
if (snapshot) snapshots.push(snapshot);
|
|
70
|
+
}
|
|
71
|
+
return snapshots;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function checkModuleFreshness(snapshot: LoadedModuleSnapshot): ModuleFreshnessDiagnostic {
|
|
75
|
+
const current = readVersionAndMtime(snapshot.packageJsonPath);
|
|
76
|
+
const stale = snapshot.mtimeMs !== undefined && current.mtimeMs !== undefined && current.mtimeMs !== snapshot.mtimeMs;
|
|
77
|
+
return { name: snapshot.name, loadedVersion: snapshot.version, currentVersion: current.version, stale };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function checkModuleFreshnessAll(snapshots: readonly LoadedModuleSnapshot[]): ModuleFreshnessDiagnostic[] {
|
|
81
|
+
return snapshots.map(checkModuleFreshness);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The exact set of names worth watching for a long-running process: its
|
|
85
|
+
* own direct runtime dependencies (package.json's "dependencies" field) --
|
|
86
|
+
* the ones a static top-level import actually loads once at process start.
|
|
87
|
+
* devDependencies/peerDependencies are never loaded by the running process
|
|
88
|
+
* itself, so watching them would only ever report false staleness. Bounded
|
|
89
|
+
* the same way readPackageDeclarations elsewhere in this codebase bounds
|
|
90
|
+
* an untrusted-shape read -- never throws on a malformed package.json,
|
|
91
|
+
* just reports nothing to watch. */
|
|
92
|
+
export function ownRuntimeDependencyNames(packageJsonPath: string, maxNames = 200): string[] {
|
|
93
|
+
try {
|
|
94
|
+
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { dependencies?: unknown };
|
|
95
|
+
if (typeof pkg.dependencies !== "object" || pkg.dependencies === null) return [];
|
|
96
|
+
return Object.keys(pkg.dependencies).slice(0, maxNames);
|
|
97
|
+
} catch {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -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"];
|