@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
|
@@ -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,
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deploy-verify.ts — answers "did a publish/install actually land
|
|
3
|
+
* everywhere it should have" as one deterministic check, instead of the
|
|
4
|
+
* hand-diffing ceremony repeated after nearly every @danypops/* publish:
|
|
5
|
+
* comparing a package's on-disk version at every known install location
|
|
6
|
+
* (Pi's own npm project, Bun's global install cache) against an expected
|
|
7
|
+
* version, plus scanning for a stale nested node_modules "shadow" copy one
|
|
8
|
+
* level below another installed package's own node_modules -- exactly the
|
|
9
|
+
* layout ExecInstaller.reresolveDependencyTree() already fixes for the
|
|
10
|
+
* install path (see install.ts), surfaced here read-only for diagnosis.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
export interface InstallLocation {
|
|
17
|
+
readonly label: string;
|
|
18
|
+
readonly packageJsonPath: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface LocationStatus extends InstallLocation {
|
|
22
|
+
/** undefined when the location has no readable package.json at all. */
|
|
23
|
+
readonly version?: string;
|
|
24
|
+
readonly mtimeMs?: number;
|
|
25
|
+
readonly present: boolean;
|
|
26
|
+
/** Only meaningful when present -- false whenever present is false too. */
|
|
27
|
+
readonly upToDate: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DeployVerification {
|
|
31
|
+
readonly packageName: string;
|
|
32
|
+
readonly expectedVersion?: string;
|
|
33
|
+
readonly locations: readonly LocationStatus[];
|
|
34
|
+
readonly shadowCopies: readonly LocationStatus[];
|
|
35
|
+
/**
|
|
36
|
+
* false when expectedVersion is unknown, a *present* known location is
|
|
37
|
+
* behind it, or a shadow copy exists at all (a shadow copy is always a
|
|
38
|
+
* problem regardless of its own version -- it can shadow the correct
|
|
39
|
+
* top-level install for whichever importer's own node_modules walk
|
|
40
|
+
* reaches it first). A location simply being absent (e.g. this package
|
|
41
|
+
* was never installed via `bun install -g`) never fails this by
|
|
42
|
+
* itself -- only a present-but-stale copy does.
|
|
43
|
+
*/
|
|
44
|
+
readonly ok: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readVersionAndMtime(packageJsonPath: string): { version?: string; mtimeMs?: number } {
|
|
48
|
+
try {
|
|
49
|
+
const mtimeMs = statSync(packageJsonPath).mtimeMs;
|
|
50
|
+
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
|
|
51
|
+
return { version: typeof pkg.version === "string" ? pkg.version : undefined, mtimeMs };
|
|
52
|
+
} catch {
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function statusOf(location: InstallLocation, expectedVersion: string | undefined): LocationStatus {
|
|
58
|
+
const { version, mtimeMs } = readVersionAndMtime(location.packageJsonPath);
|
|
59
|
+
return { ...location, version, mtimeMs, present: version !== undefined, upToDate: version !== undefined && version === expectedVersion };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Every location Packed itself installs an npm-sourced package into --
|
|
63
|
+
* Pi's own npm project (piHome/npm) and Bun's own global install cache
|
|
64
|
+
* (used by `bun install -g`/bunx-adjacent flows, not something Packed
|
|
65
|
+
* writes to itself, but a real place a stale copy can quietly linger).
|
|
66
|
+
* `home` defaults to the real home directory; injectable so a test never
|
|
67
|
+
* has to read or write outside its own tmpdir fixture. */
|
|
68
|
+
export function standardInstallLocations(piHome: string, packageName: string, home: string = homedir()): InstallLocation[] {
|
|
69
|
+
return [
|
|
70
|
+
{ label: "Pi npm project", packageJsonPath: join(piHome, "npm", "node_modules", packageName, "package.json") },
|
|
71
|
+
{
|
|
72
|
+
label: "Bun global install cache",
|
|
73
|
+
packageJsonPath: join(home, ".cache", ".bun", "install", "global", "node_modules", packageName, "package.json"),
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Bounded to Pi npm project's own direct children (and one level into a
|
|
79
|
+
* scope directory) -- the exact shape ExecInstaller.reresolveDependencyTree()
|
|
80
|
+
* fixes for the install path: a sibling package's own node_modules holding
|
|
81
|
+
* a nested copy of a shared dependency that a targeted `pi update`/`pi
|
|
82
|
+
* install` never reaches, only a full-tree `npm install` does. Never
|
|
83
|
+
* descends into a shadow copy's own node_modules -- one level is the real,
|
|
84
|
+
* confirmed-live shape; deeper nesting is npm's own problem to dedupe. */
|
|
85
|
+
export function findShadowCopies(piHome: string, packageName: string): InstallLocation[] {
|
|
86
|
+
const npmNodeModulesDir = join(piHome, "npm", "node_modules");
|
|
87
|
+
const found: InstallLocation[] = [];
|
|
88
|
+
let entries: string[];
|
|
89
|
+
try {
|
|
90
|
+
entries = readdirSync(npmNodeModulesDir);
|
|
91
|
+
} catch {
|
|
92
|
+
return found;
|
|
93
|
+
}
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
if (entry === packageName || entry.startsWith(".")) continue;
|
|
96
|
+
const entryPath = join(npmNodeModulesDir, entry);
|
|
97
|
+
let isDirectory: boolean;
|
|
98
|
+
try {
|
|
99
|
+
isDirectory = statSync(entryPath).isDirectory();
|
|
100
|
+
} catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (!isDirectory) continue;
|
|
104
|
+
if (entry.startsWith("@")) {
|
|
105
|
+
let scoped: string[];
|
|
106
|
+
try {
|
|
107
|
+
scoped = readdirSync(entryPath);
|
|
108
|
+
} catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
for (const sub of scoped) {
|
|
112
|
+
const nested = join(entryPath, sub, "node_modules", packageName, "package.json");
|
|
113
|
+
if (existsSync(nested)) found.push({ label: `shadow copy under ${entry}/${sub}`, packageJsonPath: nested });
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const nested = join(entryPath, "node_modules", packageName, "package.json");
|
|
118
|
+
if (existsSync(nested)) found.push({ label: `shadow copy under ${entry}`, packageJsonPath: nested });
|
|
119
|
+
}
|
|
120
|
+
return found;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* `expectedVersion` defaults to whatever's resolved at Pi's own npm
|
|
125
|
+
* project -- the natural "it was just installed/updated there, does every
|
|
126
|
+
* OTHER known location and shadow copy agree" question this exists to
|
|
127
|
+
* answer without a network round-trip. Pass one explicitly (e.g. the
|
|
128
|
+
* version a publish just produced) to check against that instead.
|
|
129
|
+
*/
|
|
130
|
+
export function verifyDeploy(piHome: string, packageName: string, expectedVersion?: string, home: string = homedir()): DeployVerification {
|
|
131
|
+
const locations0 = standardInstallLocations(piHome, packageName, home);
|
|
132
|
+
const resolvedExpected = expectedVersion ?? readVersionAndMtime(locations0[0]!.packageJsonPath).version;
|
|
133
|
+
const locations = locations0.map((location) => statusOf(location, resolvedExpected));
|
|
134
|
+
const shadowCopies = findShadowCopies(piHome, packageName).map((location) => statusOf(location, resolvedExpected));
|
|
135
|
+
const ok = resolvedExpected !== undefined && locations.every((l) => !l.present || l.upToDate) && shadowCopies.length === 0;
|
|
136
|
+
return { packageName, expectedVersion: resolvedExpected, locations, shadowCopies, ok };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function formatDeployVerification(report: DeployVerification, json: boolean): string {
|
|
140
|
+
if (json) return `${JSON.stringify(report)}\n`;
|
|
141
|
+
let out = `${report.ok ? "PASS" : "FAIL"} — ${report.packageName}${report.expectedVersion ? `@${report.expectedVersion}` : " (version unknown -- not found at the primary location either)"}\n`;
|
|
142
|
+
for (const location of report.locations) {
|
|
143
|
+
const state = !location.present ? "MISSING" : location.upToDate ? "ok" : `STALE (${location.version})`;
|
|
144
|
+
out += ` ${state.padEnd(16)} ${location.label} (${location.packageJsonPath})\n`;
|
|
145
|
+
}
|
|
146
|
+
for (const shadow of report.shadowCopies) {
|
|
147
|
+
out += ` SHADOW COPY ${shadow.label} at ${shadow.version ?? "unknown version"} (${shadow.packageJsonPath})\n`;
|
|
148
|
+
}
|
|
149
|
+
if (report.shadowCopies.length === 0) out += " no shadow copies found\n";
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
@@ -8,8 +8,10 @@ import {
|
|
|
8
8
|
type InstallValidationResult,
|
|
9
9
|
type InstallValidator,
|
|
10
10
|
} from "../adoption/install-validation.ts";
|
|
11
|
+
import { applyEntryFilters, captureEntryFilters, resolveToggleSettingsPath } from "./resources.ts";
|
|
11
12
|
import { createLogger } from "../shared/log.ts";
|
|
12
|
-
import { defaultPiHome, isPinnedNpmSource, readResolvedVersion } from "./installed.ts";
|
|
13
|
+
import { defaultPiHome, isPinnedNpmSource, npmPackageName, readPackageDeclarations, readResolvedVersion } from "./installed.ts";
|
|
14
|
+
import { isNewer } from "./package.ts";
|
|
13
15
|
import type { Installer, UpdateOutcome } from "./package.ts";
|
|
14
16
|
|
|
15
17
|
function round1(ms: number): number {
|
|
@@ -44,6 +46,14 @@ export class ExecInstaller implements Installer {
|
|
|
44
46
|
* against a real daemon instead of guessing from wall-clock totals alone.
|
|
45
47
|
*/
|
|
46
48
|
private readonly logger: Logger = createLogger("install"),
|
|
49
|
+
/**
|
|
50
|
+
* Optional real registry-mirror lookup (see daemon/watcher.ts's own
|
|
51
|
+
* checkUpdates(), which this shares its cross-check logic with via
|
|
52
|
+
* package.ts's isNewer) -- undefined by default so every existing
|
|
53
|
+
* caller/test keeps its old "already up to date means nothing to
|
|
54
|
+
* report" behavior unless a daemon explicitly wires its own mirror in.
|
|
55
|
+
*/
|
|
56
|
+
private readonly latestOf?: (name: string) => string | undefined,
|
|
47
57
|
) {}
|
|
48
58
|
|
|
49
59
|
private async run(args: string[]): Promise<string> {
|
|
@@ -141,7 +151,8 @@ export class ExecInstaller implements Installer {
|
|
|
141
151
|
return this.run(["remove", ...(options?.local ? ["-l"] : []), source]);
|
|
142
152
|
}
|
|
143
153
|
|
|
144
|
-
async update(source: string,
|
|
154
|
+
async update(source: string, options?: { approved?: boolean; local?: boolean; target?: string }): Promise<UpdateOutcome> {
|
|
155
|
+
if (options?.target) return this.replace(source, options.target, options);
|
|
145
156
|
const t0 = performance.now();
|
|
146
157
|
const pinned = isPinnedNpmSource(source);
|
|
147
158
|
const previousVersion = readResolvedVersion(this.piHome, source);
|
|
@@ -165,6 +176,112 @@ export class ExecInstaller implements Installer {
|
|
|
165
176
|
// have changed" signal instead of falsely claiming it didn't.
|
|
166
177
|
const knowsBoth = previousVersion !== undefined && currentVersion !== undefined;
|
|
167
178
|
const changed = !knowsBoth || previousVersion !== currentVersion;
|
|
168
|
-
|
|
179
|
+
const alreadyUpToDate = !changed;
|
|
180
|
+
// Only worth cross-checking when there's actually a "nothing changed"
|
|
181
|
+
// conclusion to double-check, a real mirror lookup was wired in, and
|
|
182
|
+
// there's a real bare npm name + resolved version to compare against.
|
|
183
|
+
const name = alreadyUpToDate && this.latestOf ? npmPackageName(source) : undefined;
|
|
184
|
+
const latest = name && currentVersion ? this.latestOf?.(name) : undefined;
|
|
185
|
+
const outOfRangeUpdateAvailable = latest && currentVersion && isNewer(latest, currentVersion) ? latest : undefined;
|
|
186
|
+
return {
|
|
187
|
+
output,
|
|
188
|
+
reloadRequired: changed,
|
|
189
|
+
alreadyUpToDate,
|
|
190
|
+
pinned,
|
|
191
|
+
previousVersion,
|
|
192
|
+
currentVersion,
|
|
193
|
+
...(outOfRangeUpdateAvailable ? { outOfRangeUpdateAvailable } : {}),
|
|
194
|
+
// A plain update() with no target can never move an exact pin --
|
|
195
|
+
// `pi update --extension` itself intentionally skips it (npm's own
|
|
196
|
+
// documented behavior for a versioned spec). alreadyUpToDate/pinned
|
|
197
|
+
// above are already an honest, non-"false success" result; this adds
|
|
198
|
+
// an unambiguous machine-readable nudge toward options.target instead
|
|
199
|
+
// of a caller having to infer "pinned AND alreadyUpToDate means stuck"
|
|
200
|
+
// itself. See replace() for the actual supervised pin-move workflow.
|
|
201
|
+
...(pinned && alreadyUpToDate ? { pinnedSourceRequiresTarget: true } : {}),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The supervised "move a pin" workflow `pi update --extension` can never
|
|
207
|
+
* perform by itself (it intentionally skips an exact pin) -- see the
|
|
208
|
+
* linked research doc (pinned-package-update-behavior-and-safe-
|
|
209
|
+
* replacement-research): a bare `pi install <newSource>` does not
|
|
210
|
+
* rewrite an already-configured entry for the same package, it requires
|
|
211
|
+
* an explicit remove-then-install sequence. Reuses install()/remove()
|
|
212
|
+
* (the same supported, already-tested primitives) rather than
|
|
213
|
+
* hand-rolling new subprocess calls, so validation/reresolve/timing
|
|
214
|
+
* behave identically to every other mutation.
|
|
215
|
+
*
|
|
216
|
+
* Order of operations: identity check (never touches disk on mismatch)
|
|
217
|
+
* -> capture `fromSource`'s own filter overrides (extensions/skills/
|
|
218
|
+
* prompts/themes) so remove() doesn't silently discard them -> remove
|
|
219
|
+
* `fromSource` -> install `toSource`, rolling back to `fromSource` on
|
|
220
|
+
* failure -> re-apply the captured filters onto the new entry -> verify
|
|
221
|
+
* BOTH postconditions (installed: readResolvedVersion; configured:
|
|
222
|
+
* present in settings.json) before reporting success.
|
|
223
|
+
*/
|
|
224
|
+
private async replace(fromSource: string, toSource: string, options?: { approved?: boolean; local?: boolean }): Promise<UpdateOutcome> {
|
|
225
|
+
const fromName = npmPackageName(fromSource);
|
|
226
|
+
const toName = npmPackageName(toSource);
|
|
227
|
+
if (!fromName || !toName || fromName !== toName) {
|
|
228
|
+
throw new Error(`replace target must be the same package as the configured source: ${fromSource} -> ${toSource}`);
|
|
229
|
+
}
|
|
230
|
+
const settingsPath = resolveToggleSettingsPath(this.piHome);
|
|
231
|
+
const before = { source: fromSource, version: readResolvedVersion(this.piHome, fromSource) };
|
|
232
|
+
const filters = captureEntryFilters(settingsPath, fromSource);
|
|
233
|
+
|
|
234
|
+
let removeOutput: string;
|
|
235
|
+
try {
|
|
236
|
+
removeOutput = await this.remove(fromSource, options);
|
|
237
|
+
} catch (e) {
|
|
238
|
+
throw new Error(`replace failed removing ${fromSource}: ${e instanceof Error ? e.message : String(e)}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let installOutput: string;
|
|
242
|
+
try {
|
|
243
|
+
installOutput = await this.install(toSource, options);
|
|
244
|
+
} catch (installError) {
|
|
245
|
+
const installMessage = installError instanceof Error ? installError.message : String(installError);
|
|
246
|
+
let rollback: NonNullable<UpdateOutcome["rollback"]>;
|
|
247
|
+
try {
|
|
248
|
+
await this.install(fromSource, options);
|
|
249
|
+
rollback = { attempted: true, ok: true };
|
|
250
|
+
} catch (rollbackError) {
|
|
251
|
+
rollback = {
|
|
252
|
+
attempted: true,
|
|
253
|
+
ok: false,
|
|
254
|
+
message: rollbackError instanceof Error ? rollbackError.message : String(rollbackError),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
const rollbackNote = rollback.ok
|
|
258
|
+
? `rolled back to ${fromSource}`
|
|
259
|
+
: `ROLLBACK TO ${fromSource} ALSO FAILED: ${rollback.message} -- ${fromName} may now be uninstalled entirely, reinstall manually`;
|
|
260
|
+
throw new Error(`replace failed installing ${toSource}: ${installMessage} (${rollbackNote})`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
await applyEntryFilters(settingsPath, toSource, filters);
|
|
264
|
+
|
|
265
|
+
const afterVersion = readResolvedVersion(this.piHome, toSource);
|
|
266
|
+
const configuredAfter = readPackageDeclarations(this.piHome).includes(toSource);
|
|
267
|
+
if (!configuredAfter) {
|
|
268
|
+
// install() itself reported success, but settings.json doesn't show
|
|
269
|
+
// toSource configured -- a real postcondition failure, not merely a
|
|
270
|
+
// version mismatch. Never claim success on an unverified replace.
|
|
271
|
+
throw new Error(`replace installed ${toSource} but settings.json does not show it configured -- verify manually before retrying`);
|
|
272
|
+
}
|
|
273
|
+
const after = { source: toSource, version: afterVersion };
|
|
274
|
+
const changed = before.version !== after.version || before.source !== after.source;
|
|
275
|
+
return {
|
|
276
|
+
output: `${removeOutput}\n${installOutput}`.trim(),
|
|
277
|
+
reloadRequired: changed,
|
|
278
|
+
alreadyUpToDate: !changed,
|
|
279
|
+
pinned: isPinnedNpmSource(toSource),
|
|
280
|
+
previousVersion: before.version,
|
|
281
|
+
currentVersion: after.version,
|
|
282
|
+
replaced: true,
|
|
283
|
+
before,
|
|
284
|
+
after,
|
|
285
|
+
};
|
|
169
286
|
}
|
|
170
287
|
}
|
|
@@ -11,6 +11,27 @@
|
|
|
11
11
|
// itself stays a pure port: nothing here creates a runtime dependency on install-validation.ts's
|
|
12
12
|
// own npm-pack/tar/subprocess machinery.
|
|
13
13
|
import type { InstallValidationResult } from "../adoption/install-validation.ts";
|
|
14
|
+
import { gt, valid } from "semver";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* True drift only: `latest` is a real semver step *ahead* of `have`, never
|
|
18
|
+
* merely different from it. A plain !== check cannot distinguish "have is
|
|
19
|
+
* behind latest" from "have is already ahead of a stale/wrong latest" --
|
|
20
|
+
* confirmed live via the watcher's own version of this bug: an installed
|
|
21
|
+
* package whose version had already passed the daemon's mirrored
|
|
22
|
+
* dist-tags.latest kept showing a permanent, un-clearable "update
|
|
23
|
+
* available" badge pointing at an *older* version. Falls back to the old
|
|
24
|
+
* inequality only when either side isn't parseable semver (a git ref, a
|
|
25
|
+
* literal "latest" tag, etc.) -- those aren't comparable at all, so
|
|
26
|
+
* "different" is the only signal left. Lives here (not watcher.ts, its
|
|
27
|
+
* original home) so both the daemon's watcher and ExecInstaller.update()'s
|
|
28
|
+
* own out-of-range check share one implementation without packages/
|
|
29
|
+
* reaching into daemon/ (the dependency already runs the other way).
|
|
30
|
+
*/
|
|
31
|
+
export function isNewer(latest: string, have: string): boolean {
|
|
32
|
+
if (valid(latest, { loose: true }) && valid(have, { loose: true })) return gt(latest, have, { loose: true });
|
|
33
|
+
return latest !== have;
|
|
34
|
+
}
|
|
14
35
|
|
|
15
36
|
export type PackageVerification = "keyword-only" | "manifest" | "conventional";
|
|
16
37
|
|
|
@@ -108,13 +129,58 @@ export interface UpdateOutcome {
|
|
|
108
129
|
pinned: boolean;
|
|
109
130
|
previousVersion?: string;
|
|
110
131
|
currentVersion?: string;
|
|
132
|
+
/**
|
|
133
|
+
* Set only when alreadyUpToDate is true AND a real registry version
|
|
134
|
+
* newer than currentVersion exists outside the source's own declared
|
|
135
|
+
* caret/range -- confirmed live: a 0.x package (e.g. ^0.9.8) that
|
|
136
|
+
* crossed its own minor boundary (0.10.0 published) reported a bare
|
|
137
|
+
* "already up to date" with no clue a newer version existed at all.
|
|
138
|
+
* `pi update --extension` is itself bound by the declared range, so
|
|
139
|
+
* "nothing changed" alone can't distinguish "genuinely current" from
|
|
140
|
+
* "current within a range that's now stale" -- this cross-checks
|
|
141
|
+
* against the same registry-mirror source of truth checkUpdates()
|
|
142
|
+
* already uses, independent of the declared range.
|
|
143
|
+
*/
|
|
144
|
+
outOfRangeUpdateAvailable?: string;
|
|
145
|
+
/**
|
|
146
|
+
* True only when update() was called on an exact npm pin with no
|
|
147
|
+
* options.target -- `pi update --extension` intentionally leaves an
|
|
148
|
+
* exact pin unchanged (npm's own documented behavior for a versioned
|
|
149
|
+
* spec), so alreadyUpToDate/pinned above are already an honest,
|
|
150
|
+
* non-"false success" result. This is an additional stable,
|
|
151
|
+
* unambiguous machine-readable signal (kept alongside those existing
|
|
152
|
+
* fields for back-compat) that a caller wanting to actually MOVE the
|
|
153
|
+
* pin should retry with options.target set -- see replaced/before/
|
|
154
|
+
* after/rollback below.
|
|
155
|
+
*/
|
|
156
|
+
pinnedSourceRequiresTarget?: boolean;
|
|
157
|
+
/**
|
|
158
|
+
* True when options.target was given and update() routed to the
|
|
159
|
+
* supervised replace workflow (remove the old exact source, install
|
|
160
|
+
* the new one, verify both configured and installed postconditions,
|
|
161
|
+
* attempt rollback on failure) instead of `pi update --extension`.
|
|
162
|
+
*/
|
|
163
|
+
replaced?: boolean;
|
|
164
|
+
before?: { source: string; version?: string };
|
|
165
|
+
after?: { source: string; version?: string };
|
|
166
|
+
/** Present only on a replace failure -- whether restoring the original
|
|
167
|
+
* source was attempted and whether that restoration itself succeeded. */
|
|
168
|
+
rollback?: { attempted: boolean; ok: boolean; message?: string };
|
|
111
169
|
}
|
|
112
170
|
|
|
113
171
|
/** Driven port: pi CLI mutations. */
|
|
114
172
|
export interface Installer {
|
|
115
173
|
install(source: string, options?: { approved?: boolean; local?: boolean }): Promise<string>;
|
|
116
174
|
remove(source: string, options?: { approved?: boolean; local?: boolean }): Promise<string>;
|
|
117
|
-
|
|
175
|
+
/**
|
|
176
|
+
* `options.target`, when given, replaces `source` (an exact pin or not)
|
|
177
|
+
* with `target` through a supervised remove+install workflow instead of
|
|
178
|
+
* `pi update --extension` -- see ExecInstaller.update()'s own doc
|
|
179
|
+
* comment and the linked research doc
|
|
180
|
+
* (pinned-package-update-behavior-and-safe-replacement-research) for
|
|
181
|
+
* why a plain `pi update` can never move an exact pin by itself.
|
|
182
|
+
*/
|
|
183
|
+
update(source: string, options?: { approved?: boolean; local?: boolean; target?: string }): Promise<UpdateOutcome>;
|
|
118
184
|
/**
|
|
119
185
|
* Optional batch-oriented split of install(), for a caller installing several packages
|
|
120
186
|
* together (see SetupManager.apply()) -- a single ad hoc install() still does all three
|