@danypops/pi-packed 0.22.2 → 0.22.3
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/extension/src/vehicle-tools.ts +5 -1
- package/package.json +1 -1
- package/service/src/cli/cli.ts +7 -2
- package/service/src/daemon/daemon-service.ts +172 -4
- package/service/src/daemon/daemon.ts +10 -1
- package/service/src/daemon/service.ts +38 -4
- package/service/src/packages/install.ts +40 -0
- package/service/src/packages/package.ts +26 -0
- package/service/test/cli.test.ts +51 -0
- package/service/test/daemon-maintenance.test.ts +11 -0
- package/service/test/daemon-service.test.ts +149 -1
- package/service/test/install.test.ts +168 -0
- package/service/test/service.test.ts +105 -0
|
@@ -74,7 +74,11 @@ export async function registerPackedVehicle(pi: ExtensionAPI): Promise<void> {
|
|
|
74
74
|
await registerVehicleTools(pi, client, {
|
|
75
75
|
permissions: ["packed:read"],
|
|
76
76
|
principal: { id: "pi-packed" },
|
|
77
|
-
|
|
77
|
+
// ownVehicleName must match daemon.ts's own PACKED_VEHICLE_NAME (the shared Vehicle Handle
|
|
78
|
+
// Directory entry Packed's own daemon registers under via daemonOptions()'s vehicleName --
|
|
79
|
+
// see service/src/daemon/daemon.ts). activateForeignOperation is auto-supplied by
|
|
80
|
+
// registerVehicleTools. See enable-vehicle-shell-broker-mode-in-pi-packed.
|
|
81
|
+
shell: { coreOperations: CORE_OPERATIONS, broker: { ownVehicleName: "pi-packed" } },
|
|
78
82
|
});
|
|
79
83
|
} catch {
|
|
80
84
|
// Daemon state is stale/unreachable -- degrade silently, matching
|
package/package.json
CHANGED
package/service/src/cli/cli.ts
CHANGED
|
@@ -14,6 +14,7 @@ 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
16
|
import { formatDeployVerification, verifyDeploy } from "../packages/deploy-verify.ts";
|
|
17
|
+
import { listManagedPackages } from "../daemon/daemon-service.ts";
|
|
17
18
|
import { defaultPiBin, NAME_RE } from "../packages/install.ts";
|
|
18
19
|
import { npmPackageName, readInstalledPackages, readInstalledPackagesAcrossScopes } from "../packages/installed.ts";
|
|
19
20
|
import type { Installer, Pkg, Registry, UpdateEntry } from "../packages/package.ts";
|
|
@@ -556,9 +557,13 @@ const commands: Record<string, { usage: string; run: Command }> = {
|
|
|
556
557
|
installed: {
|
|
557
558
|
usage: "packed installed [--json]",
|
|
558
559
|
async run(_rest, d, flags) {
|
|
559
|
-
const installed = d.daemon ? await d.daemon.installed() :
|
|
560
|
+
const installed = d.daemon ? await d.daemon.installed() : listManagedPackages(d.piHome);
|
|
560
561
|
if (flags.json) return ok(`${JSON.stringify(installed)}\n`);
|
|
561
|
-
return ok(
|
|
562
|
+
return ok(
|
|
563
|
+
installed
|
|
564
|
+
.map((p) => ` ${p.name}@${p.pinned ?? p.installed ?? "?"}${p.kind === "daemon-dependency" ? " [daemon-dependency]" : ""}\n`)
|
|
565
|
+
.join(""),
|
|
566
|
+
);
|
|
562
567
|
},
|
|
563
568
|
},
|
|
564
569
|
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* step isn't a real convention, so detection reads what's already true
|
|
26
26
|
* on disk instead of asking for one more declaration.
|
|
27
27
|
*/
|
|
28
|
-
import { readFileSync } from "node:fs";
|
|
28
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
29
29
|
import { join } from "node:path";
|
|
30
30
|
import { createVehicleRegistrar, type VehicleRegistrar } from "@danypops/armada";
|
|
31
31
|
import { resolveDaemonPaths } from "@danypops/vehicle-server/paths";
|
|
@@ -36,7 +36,8 @@ import {
|
|
|
36
36
|
type ServiceSpec,
|
|
37
37
|
unregisterVehicleService,
|
|
38
38
|
} from "@danypops/vehicle-server/service";
|
|
39
|
-
import { npmPackageName, readInstalledPackagesAcrossScopes } from "../packages/installed.ts";
|
|
39
|
+
import { npmPackageName, readInstalledPackagesAcrossScopes, readPackageDeclarations } from "../packages/installed.ts";
|
|
40
|
+
import type { InstalledPkg } from "../packages/package.ts";
|
|
40
41
|
|
|
41
42
|
export interface DaemonServiceManifest {
|
|
42
43
|
/** Relative to the installed package's own root directory. */
|
|
@@ -154,12 +155,25 @@ export function detectVehicleDaemonService(
|
|
|
154
155
|
return undefined;
|
|
155
156
|
}
|
|
156
157
|
|
|
158
|
+
/**
|
|
159
|
+
* "daemon.json" -- not "handle.json" -- when a detected daemon carries no explicit
|
|
160
|
+
* packed.daemonService.handleFilename override. Confirmed live across every real Vehicle daemon
|
|
161
|
+
* checked in this ecosystem: jittor, pipes, and web-spider-daemon's own HANDLE_FILENAME constants
|
|
162
|
+
* are all "daemon.json"; only enigma uses "handle.json". A real, reported bug traced back to this
|
|
163
|
+
* default: pipes' Armada vehicle registration kept reverting to the wrong handlePath every time
|
|
164
|
+
* this auto-detect path re-ran, because this guess didn't match pipes' own real convention. Still
|
|
165
|
+
* a guess, not a read of the target package's own actual value -- a package with a real,
|
|
166
|
+
* non-default handleFilename must declare it explicitly via packed.daemonService for a fully
|
|
167
|
+
* correct resolution; this only narrows how often the guess is wrong.
|
|
168
|
+
*/
|
|
169
|
+
const DEFAULT_DETECTED_HANDLE_FILENAME = "daemon.json";
|
|
170
|
+
|
|
157
171
|
function buildSpec(entry: ResolvedDaemonEntrypoint): ServiceSpec {
|
|
158
172
|
const paths = resolveDaemonPaths({
|
|
159
173
|
stateDirectoryName: entry.name,
|
|
160
174
|
databaseFilename: "unused.db",
|
|
161
175
|
tokenFilename: "unused-token",
|
|
162
|
-
handleFilename: entry.handleFilename ??
|
|
176
|
+
handleFilename: entry.handleFilename ?? DEFAULT_DETECTED_HANDLE_FILENAME,
|
|
163
177
|
systemdUnitName: `${entry.name}.service`,
|
|
164
178
|
});
|
|
165
179
|
return {
|
|
@@ -218,6 +232,156 @@ export function resolveDaemonServiceSpec(piHome: string, source: string): Resolv
|
|
|
218
232
|
};
|
|
219
233
|
}
|
|
220
234
|
|
|
235
|
+
export interface DaemonDependencyPkg {
|
|
236
|
+
name: string;
|
|
237
|
+
version: string;
|
|
238
|
+
/** The Armada vehicle name resolveDaemonServiceSpec built for this package -- may differ
|
|
239
|
+
* from `name`'s own bare unscoped form when a packed.daemonService manifest overrides it. */
|
|
240
|
+
vehicleName: string;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Bare npm package names directly under a node_modules directory -- one level for an
|
|
244
|
+
* unscoped name, two for a @scope/name pair. Never throws; a missing directory (no
|
|
245
|
+
* npm project yet) is just an empty result. */
|
|
246
|
+
function listTopLevelPackageNames(nodeModulesDir: string): string[] {
|
|
247
|
+
let entries: string[];
|
|
248
|
+
try {
|
|
249
|
+
entries = readdirSync(nodeModulesDir);
|
|
250
|
+
} catch {
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
const names: string[] = [];
|
|
254
|
+
for (const entry of entries) {
|
|
255
|
+
if (!entry.startsWith("@")) {
|
|
256
|
+
names.push(entry);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
let scoped: string[];
|
|
260
|
+
try {
|
|
261
|
+
scoped = readdirSync(join(nodeModulesDir, entry));
|
|
262
|
+
} catch {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
for (const name of scoped) names.push(`${entry}/${name}`);
|
|
266
|
+
}
|
|
267
|
+
return names;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Matches reconcileAllDaemonServices' own bound -- a full node_modules sweep never
|
|
271
|
+
* processes an unbounded package list either. */
|
|
272
|
+
const MAX_DAEMON_DEPENDENCY_SCAN = 500;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Every top-level npm package physically installed under piHome/npm/node_modules
|
|
276
|
+
* that resolveDaemonServiceSpec() itself already recognizes as a real Vehicle-
|
|
277
|
+
* shaped daemon (an explicit packed.daemonService manifest, or
|
|
278
|
+
* detectVehicleDaemonService()'s own-bin-plus-real-vehicle-dependency
|
|
279
|
+
* convention), but that is NOT itself a pi:-configured extension
|
|
280
|
+
* (readPackageDeclarations() has no entry for it) -- e.g. @danypops/lector,
|
|
281
|
+
* added directly to piHome/npm/package.json as an independent pin so it can
|
|
282
|
+
* run ahead of whatever version its own pi-lector wrapper's dependency tree
|
|
283
|
+
* would otherwise resolve. These are real, running Armada vehicles that a
|
|
284
|
+
* pi:-manifest-only "installed packages" notion makes permanently invisible
|
|
285
|
+
* to `packed installed`/`packed update` -- see
|
|
286
|
+
* packed-package-update-restart-service-cant-manage.
|
|
287
|
+
*
|
|
288
|
+
* Deliberately does NOT walk one level into each configured extension's own
|
|
289
|
+
* dependencies the way resolveDaemonServiceSpec's own detection does for
|
|
290
|
+
* install-service/restart-service/doctor (service-doctor.ts's
|
|
291
|
+
* checkServiceUnitPaths already reaches that same dependency through its
|
|
292
|
+
* wrapper extension's own row) -- duplicating that walk here would
|
|
293
|
+
* double-report the same Vehicle under two different package names. This
|
|
294
|
+
* only reports a dependency independently resolvable BY ITS OWN NAME at
|
|
295
|
+
* piHome/npm's own top level -- exactly the shape a caller can actually run
|
|
296
|
+
* `packed update npm:<name>` against directly.
|
|
297
|
+
*/
|
|
298
|
+
export function listUnconfiguredDaemonDependencies(piHome: string): DaemonDependencyPkg[] {
|
|
299
|
+
const configured = new Set(
|
|
300
|
+
readPackageDeclarations(piHome).flatMap((source) => {
|
|
301
|
+
const name = npmPackageName(source);
|
|
302
|
+
return name ? [name] : [];
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
305
|
+
const names = listTopLevelPackageNames(join(piHome, "npm", "node_modules")).slice(0, MAX_DAEMON_DEPENDENCY_SCAN);
|
|
306
|
+
const out: DaemonDependencyPkg[] = [];
|
|
307
|
+
for (const name of names) {
|
|
308
|
+
if (configured.has(name)) continue;
|
|
309
|
+
const resolved = resolveDaemonServiceSpec(piHome, `npm:${name}`);
|
|
310
|
+
if (resolved.ok) out.push({ name, version: resolved.spec.version, vehicleName: resolved.spec.name });
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* True when `packageName` (a bare npm name) is a pi:-configured extension --
|
|
317
|
+
* present in readPackageDeclarations()'s own packages[] list, regardless of
|
|
318
|
+
* whether it's pinned. The other half of the classification
|
|
319
|
+
* classifyUpdateSource() needs: a name that resolves ok via
|
|
320
|
+
* resolveDaemonServiceSpec() but returns false here is a "daemon-dependency"
|
|
321
|
+
* class source, not an "extension" one.
|
|
322
|
+
*/
|
|
323
|
+
export function isConfiguredExtension(piHome: string, packageName: string): boolean {
|
|
324
|
+
return readPackageDeclarations(piHome).some((source) => npmPackageName(source) === packageName);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export type UpdateSourceKind = "extension" | "daemon-dependency";
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Classifies an npm: source for package.update's own two-path mutation:
|
|
331
|
+
* "extension" (pi:-configured, settings.json's packages[] -- `pi update
|
|
332
|
+
* --extension` already handles this correctly, unchanged) vs.
|
|
333
|
+
* "daemon-dependency" (NOT pi:-configured, but resolveDaemonServiceSpec()
|
|
334
|
+
* resolves it directly BY ITS OWN NAME -- pi-core can never see this one;
|
|
335
|
+
* confirmed via this house's own failing-test repro, service/test/
|
|
336
|
+
* install.test.ts, of pi's real "No matching package found"). Needs the
|
|
337
|
+
* alternate npm-level mutation path (Installer.updateDaemonDependency())
|
|
338
|
+
* instead of shelling to `pi update --extension` at all.
|
|
339
|
+
*
|
|
340
|
+
* undefined for a non-npm: source, or an npm: source that is neither
|
|
341
|
+
* (never installed, or installed but not Vehicle-shaped) -- package.update's
|
|
342
|
+
* existing catch-all path (calling `pi update --extension` and surfacing
|
|
343
|
+
* whatever it reports) still applies unchanged to either of those, exactly
|
|
344
|
+
* as it did before this classification existed.
|
|
345
|
+
*/
|
|
346
|
+
export function classifyUpdateSource(piHome: string, source: string): UpdateSourceKind | undefined {
|
|
347
|
+
if (!source.startsWith("npm:")) return undefined;
|
|
348
|
+
const packageName = npmPackageName(source);
|
|
349
|
+
if (!packageName) return undefined;
|
|
350
|
+
if (isConfiguredExtension(piHome, packageName)) return "extension";
|
|
351
|
+
return resolveDaemonServiceSpec(piHome, source).ok ? "daemon-dependency" : undefined;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* `packed installed`'s real, whole-fleet listing: every pi:-configured
|
|
356
|
+
* extension (`kind: "extension"`, exactly today's
|
|
357
|
+
* readInstalledPackagesAcrossScopes() output, unchanged) PLUS every
|
|
358
|
+
* top-level daemon-only dependency listUnconfiguredDaemonDependencies() can
|
|
359
|
+
* independently resolve (`kind: "daemon-dependency"`) -- so a real, running
|
|
360
|
+
* Armada vehicle like @danypops/lector shows up under its own name instead
|
|
361
|
+
* of staying invisibly nested inside its pi-lector wrapper's own row. See
|
|
362
|
+
* packed-package-update-restart-service-cant-manage.
|
|
363
|
+
*
|
|
364
|
+
* projectRoot only ever widens the extension half (matching
|
|
365
|
+
* readInstalledPackagesAcrossScopes' own scope) -- daemon-dependency
|
|
366
|
+
* detection is global-only for now, since the reported gap itself (an
|
|
367
|
+
* independently-pinned piHome/npm/package.json dependency) is a global-scope
|
|
368
|
+
* shape; a project-scoped .pi/npm equivalent is real future work, not
|
|
369
|
+
* something this fixes today.
|
|
370
|
+
*/
|
|
371
|
+
export function listManagedPackages(piHome: string, projectRoot?: string): InstalledPkg[] {
|
|
372
|
+
const extensions: InstalledPkg[] = readInstalledPackagesAcrossScopes(piHome, projectRoot).map((pkg) => ({
|
|
373
|
+
...pkg,
|
|
374
|
+
kind: "extension",
|
|
375
|
+
}));
|
|
376
|
+
const daemonDependencies: InstalledPkg[] = listUnconfiguredDaemonDependencies(piHome).map((dep) => ({
|
|
377
|
+
name: dep.name,
|
|
378
|
+
installed: dep.version,
|
|
379
|
+
scope: "global",
|
|
380
|
+
kind: "daemon-dependency",
|
|
381
|
+
}));
|
|
382
|
+
return [...extensions, ...daemonDependencies];
|
|
383
|
+
}
|
|
384
|
+
|
|
221
385
|
export interface ReconcileAllResult {
|
|
222
386
|
reconciled: Array<{ packageName: string; vehicleName: string; installed: boolean; reason?: string }>;
|
|
223
387
|
skipped: number;
|
|
@@ -227,7 +391,11 @@ export interface ReconcileAllResult {
|
|
|
227
391
|
/** Matches readPackageDeclarations' own bound -- a reconcile-all sweep never processes an unbounded package list. */
|
|
228
392
|
const MAX_RECONCILE_PACKAGES = 500;
|
|
229
393
|
const PACKED_PACKAGE_NAME = "@danypops/pi-packed";
|
|
230
|
-
|
|
394
|
+
/** Packed's own stable cross-daemon identity -- both its real Armada vehicle name (this file's own
|
|
395
|
+
* self-registration guards below) and, since it's the same daemon, the name it registers under in
|
|
396
|
+
* the shared Vehicle Handle Directory for Vehicle Shell broker mode (see daemon.ts's own
|
|
397
|
+
* daemonOptions() -- vehicleName: PACKED_VEHICLE_NAME). One canonical name, not two. */
|
|
398
|
+
export const PACKED_VEHICLE_NAME = "pi-packed";
|
|
231
399
|
const SELF_REGISTRATION_REASON = "Packed cannot replace its own Armada service from inside the running daemon";
|
|
232
400
|
|
|
233
401
|
/**
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from "@danypops/vehicle-server/daemon";
|
|
10
10
|
import { ensureAuthToken } from "@danypops/vehicle-server/paths";
|
|
11
11
|
import { captureLoadedModules, checkModuleFreshnessAll, ownRuntimeDependencyNames } from "../adoption/module-freshness.ts";
|
|
12
|
-
import { type DaemonServiceInstaller, RealDaemonServiceInstaller, reconcileAllDaemonServices } from "./daemon-service.ts";
|
|
12
|
+
import { type DaemonServiceInstaller, PACKED_VEHICLE_NAME, RealDaemonServiceInstaller, reconcileAllDaemonServices } from "./daemon-service.ts";
|
|
13
13
|
import { generateIndex, indexPath, indexStatus } from "../index/build-index.ts";
|
|
14
14
|
import { catalogStatus, syncCatalog } from "../packages/catalog.ts";
|
|
15
15
|
import { latestVersion, openDb } from "../packages/db.ts";
|
|
@@ -134,6 +134,15 @@ export function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOpt
|
|
|
134
134
|
return {
|
|
135
135
|
daemonLabel: "Packed",
|
|
136
136
|
handlePath: paths.handle,
|
|
137
|
+
// Vehicle Shell broker mode (enable-vehicle-shell-broker-mode-in-pi-packed): registers this
|
|
138
|
+
// daemon's own identity in the shared, cross-daemon Vehicle Handle Directory, alongside its
|
|
139
|
+
// existing private handlePath above -- unaffected either way. Must match
|
|
140
|
+
// extension/src/vehicle-tools.ts's own registerVehicleTools({ shell: { broker: { ownVehicleName }
|
|
141
|
+
// } }) literal (extension/ and service/ deliberately never cross-import in this package, so both
|
|
142
|
+
// sides carry the same comment-anchored literal instead of a shared TS import -- see that
|
|
143
|
+
// file's own comment).
|
|
144
|
+
vehicleName: PACKED_VEHICLE_NAME,
|
|
145
|
+
tokenPath: paths.token,
|
|
137
146
|
logger,
|
|
138
147
|
maintenanceTasks,
|
|
139
148
|
...(idleBudgetMs === undefined ? {} : { idleBudgetMs }),
|
|
@@ -19,7 +19,7 @@ import { type AdoptionReport, scoreTarget } from "../adoption/score.ts";
|
|
|
19
19
|
import { buildIndex, indexPath, type PackageIndex, readIndex, writeIndex } from "../index/build-index.ts";
|
|
20
20
|
import { syncCatalog } from "../packages/catalog.ts";
|
|
21
21
|
import { catalogList, dbPath, getSyncMeta, latestVersion, openDb, searchLocal } from "../packages/db.ts";
|
|
22
|
-
import { defaultPiHome,
|
|
22
|
+
import { defaultPiHome, npmPackageName, readInstalledPackagesAcrossScopes, splitNpmSource } from "../packages/installed.ts";
|
|
23
23
|
import type { InstalledPkg, Installer, Pkg, PkgInfo, Registry, SearchPage, UpdateOutcome, UpdatesSnapshot } from "../packages/package.ts";
|
|
24
24
|
import { buildSearchQuery, clampLimit } from "../packages/package.ts";
|
|
25
25
|
import {
|
|
@@ -46,7 +46,14 @@ import { SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT } from "../shared/constants.ts";
|
|
|
46
46
|
import { createLogger } from "../shared/log.ts";
|
|
47
47
|
import { VERSION } from "../shared/version.ts";
|
|
48
48
|
import { formatCleanupSummary, runCleanup } from "./cleanup.ts";
|
|
49
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
classifyUpdateSource,
|
|
51
|
+
type DaemonServiceInstaller,
|
|
52
|
+
listManagedPackages,
|
|
53
|
+
type ReconcileAllResult,
|
|
54
|
+
reconcileAllDaemonServices,
|
|
55
|
+
RealDaemonServiceInstaller,
|
|
56
|
+
} from "./daemon-service.ts";
|
|
50
57
|
import { checkUpdates, loadUpdates } from "./watcher.ts";
|
|
51
58
|
|
|
52
59
|
const log = createLogger("service");
|
|
@@ -366,7 +373,7 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
366
373
|
}
|
|
367
374
|
|
|
368
375
|
if (path === "/installed" && req.method === "GET") {
|
|
369
|
-
return json(
|
|
376
|
+
return json(listManagedPackages(deps.piHome ?? defaultPiHome()));
|
|
370
377
|
}
|
|
371
378
|
|
|
372
379
|
if (path === "/remove" && req.method === "POST") {
|
|
@@ -529,8 +536,35 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
529
536
|
// must reconcile against the NEW installed source, never the one that
|
|
530
537
|
// was just removed.
|
|
531
538
|
const restartSource = target ?? source;
|
|
539
|
+
// A real Vehicle-shaped daemon dependency with no pi: manifest of its
|
|
540
|
+
// own (e.g. @danypops/lector, pinned independently of its pi-lector
|
|
541
|
+
// wrapper) can never be found by `pi update --extension` -- pi-core's
|
|
542
|
+
// own "configured packages" notion IS settings.json's packages[].
|
|
543
|
+
// Route it through updateDaemonDependency() instead of ever shelling
|
|
544
|
+
// to pi at all, rather than surfacing its raw, unhelpful "No matching
|
|
545
|
+
// package found". See packed-package-update-restart-service-cant-manage.
|
|
546
|
+
const kind = classifyUpdateSource(piHomeForServiceInstall, source);
|
|
532
547
|
try {
|
|
533
|
-
|
|
548
|
+
let outcome: UpdateOutcome;
|
|
549
|
+
if (kind === "daemon-dependency") {
|
|
550
|
+
if (!deps.inst.updateDaemonDependency) {
|
|
551
|
+
return json({
|
|
552
|
+
ok: false,
|
|
553
|
+
source,
|
|
554
|
+
output: `${source} is a real Vehicle-shaped daemon dependency with no pi: manifest of its own -- this installer cannot update it directly`,
|
|
555
|
+
reloadRequired: false,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
const packageName = npmPackageName(source);
|
|
559
|
+
if (!packageName) return err(400, "invalid source; want npm:<pkg>[@ver]");
|
|
560
|
+
if (target !== undefined && npmPackageName(target) !== packageName) {
|
|
561
|
+
return err(400, "target must be the same package as source for a daemon-dependency update");
|
|
562
|
+
}
|
|
563
|
+
const [, targetVersion] = target ? splitNpmSource(target.slice(4)) : [undefined, undefined];
|
|
564
|
+
outcome = await deps.inst.updateDaemonDependency(packageName, { approved, version: targetVersion || undefined });
|
|
565
|
+
} else {
|
|
566
|
+
outcome = await deps.inst.update(source, { approved, target });
|
|
567
|
+
}
|
|
534
568
|
if (!restartSource.startsWith("npm:") || outcome.alreadyUpToDate) return json({ ok: true, source, ...outcome });
|
|
535
569
|
const service = await daemonServiceInstaller.restart(piHomeForServiceInstall, restartSource);
|
|
536
570
|
if (!service.ok) {
|
|
@@ -202,6 +202,46 @@ export class ExecInstaller implements Installer {
|
|
|
202
202
|
};
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
/**
|
|
206
|
+
* The alternate mutation path for a source classifyUpdateSource() flags as
|
|
207
|
+
* "daemon-dependency" -- genuinely installed and npm-resolvable, but NOT
|
|
208
|
+
* itself a pi:-configured extension, so `pi update --extension` can never
|
|
209
|
+
* find it (confirmed via a real failing-test repro of pi's own "No
|
|
210
|
+
* matching package found"). Runs a real, scoped `npm install
|
|
211
|
+
* <name>[@version]` at piHome/npm directly -- npm's own single-invocation
|
|
212
|
+
* dependency + lockfile update, no separate reresolveDependencyTree() call
|
|
213
|
+
* needed afterward. No `pi:` manifest exists for this package to validate
|
|
214
|
+
* against, so -- unlike install()/update() -- there is no validate() step
|
|
215
|
+
* here at all.
|
|
216
|
+
*/
|
|
217
|
+
async updateDaemonDependency(name: string, options?: { approved?: boolean; version?: string }): Promise<UpdateOutcome> {
|
|
218
|
+
const t0 = performance.now();
|
|
219
|
+
const previousVersion = readResolvedVersion(this.piHome, `npm:${name}`);
|
|
220
|
+
const spec = `${name}@${options?.version ?? "latest"}`;
|
|
221
|
+
const cwd = join(this.piHome, "npm");
|
|
222
|
+
// See run()'s own comment: env is explicit for the identical reason.
|
|
223
|
+
const proc = Bun.spawn([this.npmBin, "install", spec], { cwd, stdout: "pipe", stderr: "pipe", env: process.env });
|
|
224
|
+
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
225
|
+
const out = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
|
|
226
|
+
const code = await proc.exited;
|
|
227
|
+
if (code !== 0) throw new Error(out || `exit ${code}`);
|
|
228
|
+
const currentVersion = readResolvedVersion(this.piHome, `npm:${name}`);
|
|
229
|
+
this.logger.debug("updateDaemonDependency timing", { name, totalMs: round1(performance.now() - t0) });
|
|
230
|
+
// Same honesty discipline as update() above: only trust a "nothing
|
|
231
|
+
// changed" conclusion when a real version was actually read both before
|
|
232
|
+
// and after.
|
|
233
|
+
const knowsBoth = previousVersion !== undefined && currentVersion !== undefined;
|
|
234
|
+
const changed = !knowsBoth || previousVersion !== currentVersion;
|
|
235
|
+
return {
|
|
236
|
+
output: out,
|
|
237
|
+
reloadRequired: changed,
|
|
238
|
+
alreadyUpToDate: !changed,
|
|
239
|
+
pinned: options?.version !== undefined,
|
|
240
|
+
previousVersion,
|
|
241
|
+
currentVersion,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
205
245
|
/**
|
|
206
246
|
* The supervised "move a pin" workflow `pi update --extension` can never
|
|
207
247
|
* perform by itself (it intentionally skips an exact pin) -- see the
|
|
@@ -181,6 +181,21 @@ export interface Installer {
|
|
|
181
181
|
* why a plain `pi update` can never move an exact pin by itself.
|
|
182
182
|
*/
|
|
183
183
|
update(source: string, options?: { approved?: boolean; local?: boolean; target?: string }): Promise<UpdateOutcome>;
|
|
184
|
+
/**
|
|
185
|
+
* Updates a package that is genuinely installed and npm-resolvable but is
|
|
186
|
+
* NOT itself a pi:-configured extension -- `pi update --extension` can
|
|
187
|
+
* never find it (pi-core's own "configured packages" notion IS exactly
|
|
188
|
+
* settings.json's packages[]), confirmed via a real failing-test repro
|
|
189
|
+
* (service/test/install.test.ts) of pi's own "No matching package found".
|
|
190
|
+
* `daemon-service.ts`'s `classifyUpdateSource()` decides when this path
|
|
191
|
+
* applies instead of `update()` -- a real Vehicle-shaped daemon dependency
|
|
192
|
+
* with no `pi:` manifest of its own (e.g. @danypops/lector, pinned
|
|
193
|
+
* independently of its pi-lector wrapper). Optional because only a real
|
|
194
|
+
* npm-backed Installer (ExecInstaller) can perform it; a caller checks for
|
|
195
|
+
* its presence before using this path, exactly like validate()/
|
|
196
|
+
* installOnly()/reresolveDependencyTree() below.
|
|
197
|
+
*/
|
|
198
|
+
updateDaemonDependency?(name: string, options?: { approved?: boolean; version?: string }): Promise<UpdateOutcome>;
|
|
184
199
|
/**
|
|
185
200
|
* Optional batch-oriented split of install(), for a caller installing several packages
|
|
186
201
|
* together (see SetupManager.apply()) -- a single ad hoc install() still does all three
|
|
@@ -212,6 +227,17 @@ export interface InstalledPkg {
|
|
|
212
227
|
pinned?: string;
|
|
213
228
|
installed?: string;
|
|
214
229
|
scope?: "global" | "project";
|
|
230
|
+
/**
|
|
231
|
+
* "extension" (a real pi:-manifested package, settings.json's own
|
|
232
|
+
* packages[]) vs. "daemon-dependency" (a Vehicle-shaped daemon Packed
|
|
233
|
+
* independently detects at piHome/npm's own top level -- e.g.
|
|
234
|
+
* @danypops/lector -- that is NOT itself pi:-configured). Undefined from
|
|
235
|
+
* every pre-existing reader (readInstalledPackages/
|
|
236
|
+
* readInstalledPackagesAcrossScopes); only daemon-service.ts's
|
|
237
|
+
* listManagedPackages() sets this on every row it returns. See
|
|
238
|
+
* packed-package-update-restart-service-cant-manage.
|
|
239
|
+
*/
|
|
240
|
+
kind?: "extension" | "daemon-dependency";
|
|
215
241
|
}
|
|
216
242
|
|
|
217
243
|
export interface UpdateEntry {
|
package/service/test/cli.test.ts
CHANGED
|
@@ -387,6 +387,57 @@ describe("CLI", () => {
|
|
|
387
387
|
expect(out).toContain('"pinned":"0.8.2"');
|
|
388
388
|
});
|
|
389
389
|
|
|
390
|
+
it("installed --json also surfaces a real Vehicle-shaped daemon dependency that has no pi: manifest of its own, standalone without a daemon -- packed-package-update-restart-service-cant-manage", async () => {
|
|
391
|
+
const d = deps();
|
|
392
|
+
writeFileSync(join(d.piHome, "settings.json"), JSON.stringify({ packages: ["npm:@danypops/pi-lector@0.12.7"] }));
|
|
393
|
+
const piLectorDir = join(d.piHome, "npm", "node_modules", "@danypops", "pi-lector");
|
|
394
|
+
mkdirSync(piLectorDir, { recursive: true });
|
|
395
|
+
writeFileSync(
|
|
396
|
+
join(piLectorDir, "package.json"),
|
|
397
|
+
JSON.stringify({ name: "@danypops/pi-lector", version: "0.12.7", dependencies: { "@danypops/lector": "^0.18.0" } }),
|
|
398
|
+
);
|
|
399
|
+
const lectorDir = join(d.piHome, "npm", "node_modules", "@danypops", "lector");
|
|
400
|
+
mkdirSync(lectorDir, { recursive: true });
|
|
401
|
+
writeFileSync(
|
|
402
|
+
join(lectorDir, "package.json"),
|
|
403
|
+
JSON.stringify({
|
|
404
|
+
name: "@danypops/lector",
|
|
405
|
+
version: "0.18.9",
|
|
406
|
+
bin: { lector: "src/cli.ts" },
|
|
407
|
+
dependencies: { "@danypops/vehicle-server": "^0.18.2" },
|
|
408
|
+
}),
|
|
409
|
+
);
|
|
410
|
+
const { code, out } = await cliRun(["installed", "--json"], d);
|
|
411
|
+
expect(code).toBe(0);
|
|
412
|
+
expect(out).toContain('"name":"@danypops/lector"');
|
|
413
|
+
expect(out).toContain('"kind":"daemon-dependency"');
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("installed (human-readable) tags a daemon-dependency row distinctly from a plain pi: extension row", async () => {
|
|
417
|
+
const d = deps();
|
|
418
|
+
writeFileSync(join(d.piHome, "settings.json"), JSON.stringify({ packages: ["npm:@danypops/pi-lector@0.12.7"] }));
|
|
419
|
+
const piLectorDir = join(d.piHome, "npm", "node_modules", "@danypops", "pi-lector");
|
|
420
|
+
mkdirSync(piLectorDir, { recursive: true });
|
|
421
|
+
writeFileSync(
|
|
422
|
+
join(piLectorDir, "package.json"),
|
|
423
|
+
JSON.stringify({ name: "@danypops/pi-lector", version: "0.12.7", dependencies: { "@danypops/lector": "^0.18.0" } }),
|
|
424
|
+
);
|
|
425
|
+
const lectorDir = join(d.piHome, "npm", "node_modules", "@danypops", "lector");
|
|
426
|
+
mkdirSync(lectorDir, { recursive: true });
|
|
427
|
+
writeFileSync(
|
|
428
|
+
join(lectorDir, "package.json"),
|
|
429
|
+
JSON.stringify({
|
|
430
|
+
name: "@danypops/lector",
|
|
431
|
+
version: "0.18.9",
|
|
432
|
+
bin: { lector: "src/cli.ts" },
|
|
433
|
+
dependencies: { "@danypops/vehicle-server": "^0.18.2" },
|
|
434
|
+
}),
|
|
435
|
+
);
|
|
436
|
+
const { code, out } = await cliRun(["installed"], d);
|
|
437
|
+
expect(code).toBe(0);
|
|
438
|
+
expect(out).toBe(" @danypops/pi-lector@0.12.7\n @danypops/lector@0.18.9 [daemon-dependency]\n");
|
|
439
|
+
});
|
|
440
|
+
|
|
390
441
|
it("updates computes drift from the local mirror", async () => {
|
|
391
442
|
const d = deps();
|
|
392
443
|
writeFileSync(join(d.piHome, "settings.json"), JSON.stringify({ packages: ["npm:pi-extension-manager@0.8.2"] }));
|
|
@@ -128,6 +128,17 @@ describe("startPackedDaemon's own maintenance-task wiring (self-heals Vehicle dr
|
|
|
128
128
|
expect(options.idleBudgetMs).toBe(17_000);
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
+
it("registers pi-packed's own stable identity in the shared Vehicle Handle Directory (Vehicle Shell broker mode) -- enable-vehicle-shell-broker-mode-in-pi-packed", () => {
|
|
132
|
+
const paths = fakePaths();
|
|
133
|
+
const options = daemonOptions({ paths, reg: registry, inst: installer, piHome: fakePiHome([]), maintenanceTasks: [] });
|
|
134
|
+
// Must match extension/src/vehicle-tools.ts's own registerVehicleTools({ shell: { broker:
|
|
135
|
+
// { ownVehicleName } } }) literal -- the two sides can't share a real TS import (extension/
|
|
136
|
+
// and service/ deliberately never cross-import, matching this package's own established
|
|
137
|
+
// convention), so both carry the same comment-anchored literal instead.
|
|
138
|
+
expect(options.vehicleName).toBe("pi-packed");
|
|
139
|
+
expect(options.tokenPath).toBe(paths.token);
|
|
140
|
+
});
|
|
141
|
+
|
|
131
142
|
it("includes a vehicle-reconcile task, defaulting to RECONCILE_INTERVAL_DEFAULT_MS", () => {
|
|
132
143
|
const piHome = fakePiHome([]);
|
|
133
144
|
const paths = fakePaths();
|
|
@@ -4,7 +4,14 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import type { VehicleRegistrar } from "@danypops/armada";
|
|
6
6
|
import { createArmadaTestHarness } from "@danypops/armada/testing";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
classifyUpdateSource,
|
|
9
|
+
detectVehicleDaemonService,
|
|
10
|
+
listManagedPackages,
|
|
11
|
+
listUnconfiguredDaemonDependencies,
|
|
12
|
+
RealDaemonServiceInstaller,
|
|
13
|
+
resolveDaemonServiceSpec,
|
|
14
|
+
} from "../src/daemon/daemon-service.ts";
|
|
8
15
|
|
|
9
16
|
const roots: string[] = [];
|
|
10
17
|
afterEach(() => {
|
|
@@ -118,6 +125,22 @@ describe("resolveDaemonServiceSpec", () => {
|
|
|
118
125
|
});
|
|
119
126
|
});
|
|
120
127
|
|
|
128
|
+
it("defaults a detected (manifest-less) daemon's handle filename to 'daemon.json', not 'handle.json' -- confirmed live: jittor, pipes, and web-spider-daemon's own real HANDLE_FILENAME constants are all 'daemon.json'; only enigma uses 'handle.json'", () => {
|
|
129
|
+
const piHome = fakePiHome();
|
|
130
|
+
const dir = join(piHome, "npm", "node_modules", "@danypops/papyrus");
|
|
131
|
+
writeRawPackage(dir, {
|
|
132
|
+
name: "@danypops/papyrus",
|
|
133
|
+
version: "1.0.0",
|
|
134
|
+
bin: { papyrus: "src/cli.ts" },
|
|
135
|
+
dependencies: { "@danypops/vehicle-server": "^0.1.0" },
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const result = resolveDaemonServiceSpec(piHome, "npm:@danypops/papyrus");
|
|
139
|
+
expect(result.ok).toBe(true);
|
|
140
|
+
if (!result.ok) throw new Error("expected ok");
|
|
141
|
+
expect(result.spec.handlePath.endsWith("daemon.json")).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
121
144
|
it("falls back to detection one level into a real dependency -- a Pi extension with no daemon of its own", () => {
|
|
122
145
|
const piHome = fakePiHome();
|
|
123
146
|
const extDir = join(piHome, "npm", "node_modules", "@danypops/pi-papyrus");
|
|
@@ -208,6 +231,131 @@ describe("resolveDaemonServiceSpec", () => {
|
|
|
208
231
|
});
|
|
209
232
|
});
|
|
210
233
|
|
|
234
|
+
describe("listUnconfiguredDaemonDependencies -- the packed-package-update-restart-service-cant-manage visibility gap", () => {
|
|
235
|
+
it("reports a real Vehicle-shaped daemon installed at piHome/npm's own top level that is NOT a pi:-configured extension", () => {
|
|
236
|
+
const piHome = fakePiHome();
|
|
237
|
+
// @danypops/pi-lector IS configured (a real Pi extension, settings.json packages[]).
|
|
238
|
+
// @danypops/lector is a plain top-level dependency -- installed, own bin, real
|
|
239
|
+
// vehicle-server dependency -- but never itself `pi install`ed. Exactly the live
|
|
240
|
+
// shape confirmed in ~/.pi/agent/npm/package.json.
|
|
241
|
+
writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: ["npm:@danypops/pi-lector"] }));
|
|
242
|
+
writeRawPackage(join(piHome, "npm", "node_modules", "@danypops", "pi-lector"), {
|
|
243
|
+
name: "@danypops/pi-lector",
|
|
244
|
+
version: "0.12.7",
|
|
245
|
+
dependencies: { "@danypops/lector": "^0.18.0" },
|
|
246
|
+
});
|
|
247
|
+
writeRawPackage(join(piHome, "npm", "node_modules", "@danypops", "lector"), {
|
|
248
|
+
name: "@danypops/lector",
|
|
249
|
+
version: "0.18.9",
|
|
250
|
+
bin: { lector: "src/cli.ts" },
|
|
251
|
+
dependencies: { "@danypops/vehicle-server": "^0.18.2" },
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const result = listUnconfiguredDaemonDependencies(piHome);
|
|
255
|
+
|
|
256
|
+
expect(result).toEqual([{ name: "@danypops/lector", version: "0.18.9", vehicleName: "lector" }]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("never reports a package that is already pi:-configured, even though it independently resolves as a daemon too", () => {
|
|
260
|
+
const piHome = fakePiHome();
|
|
261
|
+
writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: ["npm:@danypops/papyrus"] }));
|
|
262
|
+
writeRawPackage(join(piHome, "npm", "node_modules", "@danypops", "papyrus"), {
|
|
263
|
+
name: "@danypops/papyrus",
|
|
264
|
+
version: "1.0.0",
|
|
265
|
+
bin: { papyrus: "src/cli.ts" },
|
|
266
|
+
dependencies: { "@danypops/vehicle-server": "^0.1.0" },
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(listUnconfiguredDaemonDependencies(piHome)).toEqual([]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("never reports a package with no real Vehicle-shaped daemon of its own -- an ordinary library dependency", () => {
|
|
273
|
+
const piHome = fakePiHome();
|
|
274
|
+
writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: [] }));
|
|
275
|
+
writeRawPackage(join(piHome, "npm", "node_modules", "picomatch"), { name: "picomatch", version: "4.0.5" });
|
|
276
|
+
|
|
277
|
+
expect(listUnconfiguredDaemonDependencies(piHome)).toEqual([]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("returns [] rather than throwing when piHome/npm/node_modules doesn't exist yet", () => {
|
|
281
|
+
const piHome = fakePiHome();
|
|
282
|
+
expect(listUnconfiguredDaemonDependencies(piHome)).toEqual([]);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("listManagedPackages -- packed installed's real whole-fleet listing (packed-package-update-restart-service-cant-manage)", () => {
|
|
287
|
+
it("tags a pi:-configured extension row 'extension' and a detected top-level daemon dependency 'daemon-dependency', side by side", () => {
|
|
288
|
+
const piHome = fakePiHome();
|
|
289
|
+
writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: ["npm:@danypops/pi-lector@0.12.7"] }));
|
|
290
|
+
writeRawPackage(join(piHome, "npm", "node_modules", "@danypops", "pi-lector"), {
|
|
291
|
+
name: "@danypops/pi-lector",
|
|
292
|
+
version: "0.12.7",
|
|
293
|
+
dependencies: { "@danypops/lector": "^0.18.0" },
|
|
294
|
+
});
|
|
295
|
+
writeRawPackage(join(piHome, "npm", "node_modules", "@danypops", "lector"), {
|
|
296
|
+
name: "@danypops/lector",
|
|
297
|
+
version: "0.18.9",
|
|
298
|
+
bin: { lector: "src/cli.ts" },
|
|
299
|
+
dependencies: { "@danypops/vehicle-server": "^0.18.2" },
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const result = listManagedPackages(piHome);
|
|
303
|
+
|
|
304
|
+
expect(result).toEqual([
|
|
305
|
+
{ name: "@danypops/pi-lector", pinned: "0.12.7", installed: undefined, scope: "global", kind: "extension" },
|
|
306
|
+
{ name: "@danypops/lector", installed: "0.18.9", scope: "global", kind: "daemon-dependency" },
|
|
307
|
+
]);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("is exactly today's extension listing, with kind added, when there are no daemon-only dependencies at all", () => {
|
|
311
|
+
const piHome = fakePiHome();
|
|
312
|
+
writeFileSync(piHome + "/settings.json", JSON.stringify({ packages: ["npm:plain@1.0.0"] }));
|
|
313
|
+
writeRawPackage(join(piHome, "npm", "node_modules", "plain"), { name: "plain", version: "1.0.0" });
|
|
314
|
+
|
|
315
|
+
expect(listManagedPackages(piHome)).toEqual([{ name: "plain", pinned: "1.0.0", installed: undefined, scope: "global", kind: "extension" }]);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe("classifyUpdateSource -- package.update's own two-path split (packed-package-update-restart-service-cant-manage)", () => {
|
|
320
|
+
it("classifies a pi:-configured extension as 'extension', even when it also happens to resolve as a Vehicle daemon itself", () => {
|
|
321
|
+
const piHome = fakePiHome();
|
|
322
|
+
writeFileSync(piHome + "/settings.json", JSON.stringify({ packages: ["npm:@danypops/papyrus"] }));
|
|
323
|
+
writeRawPackage(join(piHome, "npm", "node_modules", "@danypops", "papyrus"), {
|
|
324
|
+
name: "@danypops/papyrus",
|
|
325
|
+
version: "1.0.0",
|
|
326
|
+
bin: { papyrus: "src/cli.ts" },
|
|
327
|
+
dependencies: { "@danypops/vehicle-server": "^0.1.0" },
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(classifyUpdateSource(piHome, "npm:@danypops/papyrus")).toBe("extension");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("classifies a real Vehicle-shaped daemon with no pi:-configured entry of its own as 'daemon-dependency'", () => {
|
|
334
|
+
const piHome = fakePiHome();
|
|
335
|
+
writeFileSync(piHome + "/settings.json", JSON.stringify({ packages: ["npm:@danypops/pi-lector"] }));
|
|
336
|
+
writeRawPackage(join(piHome, "npm", "node_modules", "@danypops", "lector"), {
|
|
337
|
+
name: "@danypops/lector",
|
|
338
|
+
version: "0.18.9",
|
|
339
|
+
bin: { lector: "src/cli.ts" },
|
|
340
|
+
dependencies: { "@danypops/vehicle-server": "^0.18.2" },
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
expect(classifyUpdateSource(piHome, "npm:@danypops/lector")).toBe("daemon-dependency");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("classifies neither for an npm: source that resolves as nothing at all", () => {
|
|
347
|
+
const piHome = fakePiHome();
|
|
348
|
+
writeFileSync(piHome + "/settings.json", JSON.stringify({ packages: [] }));
|
|
349
|
+
|
|
350
|
+
expect(classifyUpdateSource(piHome, "npm:never-installed")).toBeUndefined();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("classifies neither for a non-npm source -- git:/https: replace/update behavior is untouched", () => {
|
|
354
|
+
const piHome = fakePiHome();
|
|
355
|
+
expect(classifyUpdateSource(piHome, "git:github.com/u/r@v1")).toBeUndefined();
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
211
359
|
describe("RealDaemonServiceInstaller -- Armada registration through the in-process registrar", () => {
|
|
212
360
|
it("install() resolves the package and drives Armada's real registrar/reconciler through the isolated harness", async () => {
|
|
213
361
|
const piHome = fakePiHome();
|
|
@@ -298,6 +298,174 @@ process.exit(1);
|
|
|
298
298
|
return script;
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
/**
|
|
302
|
+
* A fake `pi` binary reproducing real `pi update --extension <source>`'s own
|
|
303
|
+
* "configured packages" check (settings.json's `packages[]` -- the exact
|
|
304
|
+
* same list installed.ts's readPackageDeclarations()/readInstalledPackages()
|
|
305
|
+
* reads) -- distinct from writeFakePi above, which never looks at
|
|
306
|
+
* settings.json at all and so can't reproduce this failure mode. Real `pi`
|
|
307
|
+
* fails this way for ANY source not itself present in `packages[]`,
|
|
308
|
+
* regardless of whether it is genuinely resolvable on disk in node_modules --
|
|
309
|
+
* exactly the shape of a plain library dependency (e.g. @danypops/lector,
|
|
310
|
+
* added directly to piHome/npm/package.json so it can run as its own
|
|
311
|
+
* standalone Armada vehicle) that was never itself `pi install`ed as a Pi
|
|
312
|
+
* extension. See packed-package-update-restart-service-cant-manage.
|
|
313
|
+
*/
|
|
314
|
+
function writeFakePiConfiguredOnly(dir: string, piHome: string): string {
|
|
315
|
+
const script = join(dir, "fake-pi-configured-only");
|
|
316
|
+
const body = `
|
|
317
|
+
const piHome = ${JSON.stringify(piHome)};
|
|
318
|
+
const fs = require("node:fs");
|
|
319
|
+
const path = require("node:path");
|
|
320
|
+
|
|
321
|
+
function bareName(source) {
|
|
322
|
+
const spec = source.startsWith("npm:") ? source.slice(4) : source;
|
|
323
|
+
const at = spec.lastIndexOf("@");
|
|
324
|
+
return at > 0 ? spec.slice(0, at) : spec;
|
|
325
|
+
}
|
|
326
|
+
function entrySource(entry) {
|
|
327
|
+
return typeof entry === "string" ? entry : entry && typeof entry === "object" ? entry.source : undefined;
|
|
328
|
+
}
|
|
329
|
+
function readSettings() {
|
|
330
|
+
try {
|
|
331
|
+
return JSON.parse(fs.readFileSync(path.join(piHome, "settings.json"), "utf8"));
|
|
332
|
+
} catch {
|
|
333
|
+
return { packages: [] };
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const [cmd, flag, source] = process.argv.slice(2);
|
|
338
|
+
if (cmd === "update" && flag === "--extension") {
|
|
339
|
+
const settings = readSettings();
|
|
340
|
+
const configured = (settings.packages ?? []).some((e) => bareName(entrySource(e) ?? "") === bareName(source));
|
|
341
|
+
if (!configured) {
|
|
342
|
+
process.stderr.write("Error: No matching package found for " + source + "\\n");
|
|
343
|
+
process.exit(1);
|
|
344
|
+
}
|
|
345
|
+
process.stdout.write("Updated " + source + "\\n");
|
|
346
|
+
process.exit(0);
|
|
347
|
+
}
|
|
348
|
+
process.stderr.write("unsupported invocation: " + process.argv.slice(2).join(" ") + "\\n");
|
|
349
|
+
process.exit(1);
|
|
350
|
+
`;
|
|
351
|
+
writeFileSync(script, `#!/usr/bin/env bun\n${body}`);
|
|
352
|
+
chmodSync(script, 0o755);
|
|
353
|
+
return script;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
describe("ExecInstaller.update() — a real Vehicle daemon dependency with no pi: extension manifest of its own (packed-package-update-restart-service-cant-manage)", () => {
|
|
357
|
+
it("fails with pi's own 'No matching package found' for a package that is genuinely installed on disk but was never configured as a Pi extension -- e.g. a standalone Armada vehicle dependency like @danypops/lector", async () => {
|
|
358
|
+
const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-bin-")));
|
|
359
|
+
const piHome = writePiHome({ "@danypops/lector": "1.0.0", "@danypops/pi-lector": "0.12.7" });
|
|
360
|
+
// Only pi-lector (the real Pi extension, with its own `pi:` manifest) is a
|
|
361
|
+
// configured `packages[]` entry. @danypops/lector is a plain library
|
|
362
|
+
// dependency of piHome/npm/package.json -- installed and resolvable in
|
|
363
|
+
// node_modules, registered as its own independent Armada vehicle, but
|
|
364
|
+
// never itself `pi install`ed.
|
|
365
|
+
writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: ["npm:@danypops/pi-lector"] }));
|
|
366
|
+
const bin = writeFakePiConfiguredOnly(scriptDir, piHome);
|
|
367
|
+
const npmBin = writeFakeNpm(scriptDir, join(scriptDir, "npm.log"));
|
|
368
|
+
const installer = new ExecInstaller(bin, piHome, undefined, npmBin);
|
|
369
|
+
|
|
370
|
+
await expect(installer.update("npm:@danypops/lector")).rejects.toThrow(/No matching package found for npm:@danypops\/lector/);
|
|
371
|
+
|
|
372
|
+
// Its sibling, a real configured Pi extension, updates fine through the
|
|
373
|
+
// exact same code path -- matching the live-confirmed asymmetry this task
|
|
374
|
+
// recorded (`packed update npm:@danypops/pi-lector` reports
|
|
375
|
+
// alreadyUpToDate, `packed update npm:@danypops/lector` fails outright).
|
|
376
|
+
const sibling = await installer.update("npm:@danypops/pi-lector");
|
|
377
|
+
expect(sibling.alreadyUpToDate).toBe(true);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* A fake `npm` binary genuinely implementing `install <name>[@version]` against
|
|
383
|
+
* a real piHome -- distinct from writeFakeNpm above (a no-args
|
|
384
|
+
* reresolveDependencyTree() stand-in) and writeFakeReplacePi (implements `pi
|
|
385
|
+
* install`/`pi remove`, not raw npm). No version given simulates "whatever
|
|
386
|
+
* the registry's own latest happens to be right now" via `latestVersion`.
|
|
387
|
+
*/
|
|
388
|
+
function writeFakeNpmInstall(dir: string, piHome: string, latestVersion = "9.9.9"): string {
|
|
389
|
+
const script = join(dir, "fake-npm-install");
|
|
390
|
+
const body = `
|
|
391
|
+
const piHome = ${JSON.stringify(piHome)};
|
|
392
|
+
const latestVersion = ${JSON.stringify(latestVersion)};
|
|
393
|
+
const fs = require("node:fs");
|
|
394
|
+
const path = require("node:path");
|
|
395
|
+
|
|
396
|
+
const [cmd, spec] = process.argv.slice(2);
|
|
397
|
+
if (cmd !== "install" || !spec) {
|
|
398
|
+
process.stderr.write("unsupported invocation: " + process.argv.slice(2).join(" ") + "\\n");
|
|
399
|
+
process.exit(1);
|
|
400
|
+
}
|
|
401
|
+
const at = spec.lastIndexOf("@");
|
|
402
|
+
const name = at > 0 ? spec.slice(0, at) : spec;
|
|
403
|
+
const version = at > 0 ? spec.slice(at + 1) : latestVersion;
|
|
404
|
+
const resolved = version === "latest" ? latestVersion : version;
|
|
405
|
+
const pkgDir = path.join(piHome, "npm", "node_modules", name);
|
|
406
|
+
fs.mkdirSync(pkgDir, { recursive: true });
|
|
407
|
+
fs.writeFileSync(path.join(pkgDir, "package.json"), JSON.stringify({ name, version: resolved }));
|
|
408
|
+
process.stdout.write("added 1 package, changed 1 package\\n");
|
|
409
|
+
process.exit(0);
|
|
410
|
+
`;
|
|
411
|
+
writeFileSync(script, `#!/usr/bin/env bun\n${body}`);
|
|
412
|
+
chmodSync(script, 0o755);
|
|
413
|
+
return script;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
describe("ExecInstaller.updateDaemonDependency() -- the alternate npm-level mutation path for a package with no pi: manifest of its own (packed-package-update-restart-service-cant-manage)", () => {
|
|
417
|
+
it("bumps to the registry's real latest when no version is given, reporting an honest reloadRequired", async () => {
|
|
418
|
+
const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-daemon-dep-")));
|
|
419
|
+
const piHome = writePiHome({ "@danypops/lector": "0.18.9" });
|
|
420
|
+
const npmBin = writeFakeNpmInstall(scriptDir, piHome, "0.19.0");
|
|
421
|
+
const installer = new ExecInstaller("unused-pi-bin", piHome, undefined, npmBin);
|
|
422
|
+
|
|
423
|
+
const outcome = await installer.updateDaemonDependency("@danypops/lector");
|
|
424
|
+
|
|
425
|
+
expect(outcome.previousVersion).toBe("0.18.9");
|
|
426
|
+
expect(outcome.currentVersion).toBe("0.19.0");
|
|
427
|
+
expect(outcome.reloadRequired).toBe(true);
|
|
428
|
+
expect(outcome.alreadyUpToDate).toBe(false);
|
|
429
|
+
expect(outcome.pinned).toBe(false);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("pins to an explicit version when one is given, and reports pinned:true", async () => {
|
|
433
|
+
const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-daemon-dep-")));
|
|
434
|
+
const piHome = writePiHome({ "@danypops/lector": "0.18.9" });
|
|
435
|
+
const npmBin = writeFakeNpmInstall(scriptDir, piHome);
|
|
436
|
+
const installer = new ExecInstaller("unused-pi-bin", piHome, undefined, npmBin);
|
|
437
|
+
|
|
438
|
+
const outcome = await installer.updateDaemonDependency("@danypops/lector", { version: "0.20.0" });
|
|
439
|
+
|
|
440
|
+
expect(outcome.currentVersion).toBe("0.20.0");
|
|
441
|
+
expect(outcome.pinned).toBe(true);
|
|
442
|
+
expect(outcome.reloadRequired).toBe(true);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("reports an honest alreadyUpToDate when the version genuinely doesn't change", async () => {
|
|
446
|
+
const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-daemon-dep-")));
|
|
447
|
+
const piHome = writePiHome({ "@danypops/lector": "0.18.9" });
|
|
448
|
+
const npmBin = writeFakeNpmInstall(scriptDir, piHome, "0.18.9");
|
|
449
|
+
const installer = new ExecInstaller("unused-pi-bin", piHome, undefined, npmBin);
|
|
450
|
+
|
|
451
|
+
const outcome = await installer.updateDaemonDependency("@danypops/lector");
|
|
452
|
+
|
|
453
|
+
expect(outcome.alreadyUpToDate).toBe(true);
|
|
454
|
+
expect(outcome.reloadRequired).toBe(false);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("surfaces a real npm failure instead of silently reporting success", async () => {
|
|
458
|
+
const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-daemon-dep-")));
|
|
459
|
+
const piHome = writePiHome({ "@danypops/lector": "0.18.9" });
|
|
460
|
+
const failingNpm = join(scriptDir, "fake-npm-fail");
|
|
461
|
+
writeFileSync(failingNpm, ["#!/usr/bin/env bash", "echo 'ERESOLVE unable to resolve dependency tree' >&2", "exit 1"].join("\n"));
|
|
462
|
+
chmodSync(failingNpm, 0o755);
|
|
463
|
+
const installer = new ExecInstaller("unused-pi-bin", piHome, undefined, failingNpm);
|
|
464
|
+
|
|
465
|
+
await expect(installer.updateDaemonDependency("@danypops/lector")).rejects.toThrow(/ERESOLVE/);
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
301
469
|
describe("ExecInstaller.update({ target }) — the supervised replace() workflow for moving an exact pin", () => {
|
|
302
470
|
function setup(options: { failInstallFor?: string } = {}) {
|
|
303
471
|
const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-replace-bin-")));
|
|
@@ -58,6 +58,14 @@ class FakeInstaller implements Installer {
|
|
|
58
58
|
this.gotTarget = options?.target;
|
|
59
59
|
return { output: this.output, reloadRequired: true, alreadyUpToDate: false, pinned: false, ...this.updateOutcome };
|
|
60
60
|
}
|
|
61
|
+
updateDaemonDependencyGotName = "";
|
|
62
|
+
updateDaemonDependencyGotVersion: string | undefined;
|
|
63
|
+
updateDaemonDependencyOutcome: Partial<UpdateOutcome> = {};
|
|
64
|
+
async updateDaemonDependency(name: string, options?: { approved?: boolean; version?: string }): Promise<UpdateOutcome> {
|
|
65
|
+
this.updateDaemonDependencyGotName = name;
|
|
66
|
+
this.updateDaemonDependencyGotVersion = options?.version;
|
|
67
|
+
return { output: this.output, reloadRequired: true, alreadyUpToDate: false, pinned: false, ...this.updateDaemonDependencyOutcome };
|
|
68
|
+
}
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
class FakeDaemonServiceInstaller implements DaemonServiceInstaller {
|
|
@@ -606,6 +614,72 @@ describe("service app", () => {
|
|
|
606
614
|
});
|
|
607
615
|
});
|
|
608
616
|
|
|
617
|
+
it("POST /update routes a real Vehicle-shaped daemon dependency with no pi: manifest of its own through updateDaemonDependency, never pi update --extension -- packed-package-update-restart-service-cant-manage", async () => {
|
|
618
|
+
const inst = new FakeInstaller();
|
|
619
|
+
const svc = new FakeDaemonServiceInstaller();
|
|
620
|
+
const piHome = track(mkdtempSync(join(tmpdir(), "packed-update-daemon-dep-")));
|
|
621
|
+
writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: ["npm:@danypops/pi-lector@0.12.7"] }));
|
|
622
|
+
const lectorDir = join(piHome, "npm", "node_modules", "@danypops", "lector");
|
|
623
|
+
mkdirSync(lectorDir, { recursive: true });
|
|
624
|
+
writeFileSync(
|
|
625
|
+
join(lectorDir, "package.json"),
|
|
626
|
+
JSON.stringify({
|
|
627
|
+
name: "@danypops/lector",
|
|
628
|
+
version: "0.18.9",
|
|
629
|
+
bin: { lector: "src/cli.ts" },
|
|
630
|
+
dependencies: { "@danypops/vehicle-server": "^0.18.2" },
|
|
631
|
+
}),
|
|
632
|
+
);
|
|
633
|
+
const app = createApp(deps({ inst, daemonServiceInstaller: svc, piHome }));
|
|
634
|
+
|
|
635
|
+
const response = await app.fetch(
|
|
636
|
+
new Request("http://x/update", {
|
|
637
|
+
method: "POST",
|
|
638
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
639
|
+
body: JSON.stringify({ source: "npm:@danypops/lector", approved: true }),
|
|
640
|
+
}),
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
expect(response.status).toBe(200);
|
|
644
|
+
expect(await response.json()).toMatchObject({ ok: true, source: "npm:@danypops/lector", serviceReconciled: true });
|
|
645
|
+
expect(inst.updateDaemonDependencyGotName).toBe("@danypops/lector");
|
|
646
|
+
// Never fell through to pi update --extension -- the exact call that fails
|
|
647
|
+
// live with pi-core's own "No matching package found".
|
|
648
|
+
expect(inst.updated).toBe("");
|
|
649
|
+
expect(svc.restartGotSource).toBe("npm:@danypops/lector");
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("POST /update still routes a pi:-configured extension through the ordinary pi update --extension path, unchanged, even when it also happens to resolve as a Vehicle daemon itself", async () => {
|
|
653
|
+
const inst = new FakeInstaller();
|
|
654
|
+
const svc = new FakeDaemonServiceInstaller();
|
|
655
|
+
const piHome = track(mkdtempSync(join(tmpdir(), "packed-update-extension-")));
|
|
656
|
+
writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: ["npm:@danypops/papyrus"] }));
|
|
657
|
+
const papyrusDir = join(piHome, "npm", "node_modules", "@danypops", "papyrus");
|
|
658
|
+
mkdirSync(papyrusDir, { recursive: true });
|
|
659
|
+
writeFileSync(
|
|
660
|
+
join(papyrusDir, "package.json"),
|
|
661
|
+
JSON.stringify({
|
|
662
|
+
name: "@danypops/papyrus",
|
|
663
|
+
version: "1.0.0",
|
|
664
|
+
bin: { papyrus: "src/cli.ts" },
|
|
665
|
+
dependencies: { "@danypops/vehicle-server": "^0.18.2" },
|
|
666
|
+
}),
|
|
667
|
+
);
|
|
668
|
+
const app = createApp(deps({ inst, daemonServiceInstaller: svc, piHome }));
|
|
669
|
+
|
|
670
|
+
const response = await app.fetch(
|
|
671
|
+
new Request("http://x/update", {
|
|
672
|
+
method: "POST",
|
|
673
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
674
|
+
body: JSON.stringify({ source: "npm:@danypops/papyrus", approved: true }),
|
|
675
|
+
}),
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
expect(response.status).toBe(200);
|
|
679
|
+
expect(inst.updated).toBe("npm:@danypops/papyrus");
|
|
680
|
+
expect(inst.updateDaemonDependencyGotName).toBe("");
|
|
681
|
+
});
|
|
682
|
+
|
|
609
683
|
it("GET /updates serves the watcher snapshot", async () => {
|
|
610
684
|
const d = deps();
|
|
611
685
|
await saveUpdates(d.stateDir, {
|
|
@@ -632,6 +706,37 @@ describe("service app", () => {
|
|
|
632
706
|
expect(body.map((p: { name: string }) => p.name)).toEqual(["pi-extension-manager", "obj"]);
|
|
633
707
|
});
|
|
634
708
|
|
|
709
|
+
it("GET /installed also surfaces a real Vehicle-shaped daemon dependency that has no pi: manifest of its own -- packed-package-update-restart-service-cant-manage", async () => {
|
|
710
|
+
const piHome = track(mkdtempSync(join(tmpdir(), "packed-pi-")));
|
|
711
|
+
writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: ["npm:@danypops/pi-lector@0.12.7"] }));
|
|
712
|
+
const piLectorDir = join(piHome, "npm", "node_modules", "@danypops", "pi-lector");
|
|
713
|
+
mkdirSync(piLectorDir, { recursive: true });
|
|
714
|
+
writeFileSync(
|
|
715
|
+
join(piLectorDir, "package.json"),
|
|
716
|
+
JSON.stringify({ name: "@danypops/pi-lector", version: "0.12.7", dependencies: { "@danypops/lector": "^0.18.0" } }),
|
|
717
|
+
);
|
|
718
|
+
const lectorDir = join(piHome, "npm", "node_modules", "@danypops", "lector");
|
|
719
|
+
mkdirSync(lectorDir, { recursive: true });
|
|
720
|
+
writeFileSync(
|
|
721
|
+
join(lectorDir, "package.json"),
|
|
722
|
+
JSON.stringify({
|
|
723
|
+
name: "@danypops/lector",
|
|
724
|
+
version: "0.18.9",
|
|
725
|
+
bin: { lector: "src/cli.ts" },
|
|
726
|
+
dependencies: { "@danypops/vehicle-server": "^0.18.2" },
|
|
727
|
+
}),
|
|
728
|
+
);
|
|
729
|
+
const d = deps({ piHome });
|
|
730
|
+
const app = createApp(d);
|
|
731
|
+
const res = await app.fetch(new Request("http://x/installed", { headers: auth }));
|
|
732
|
+
expect(res.status).toBe(200);
|
|
733
|
+
const body = (await res.json()) as any;
|
|
734
|
+
expect(body).toEqual([
|
|
735
|
+
{ name: "@danypops/pi-lector", pinned: "0.12.7", installed: undefined, scope: "global", kind: "extension" },
|
|
736
|
+
{ name: "@danypops/lector", installed: "0.18.9", scope: "global", kind: "daemon-dependency" },
|
|
737
|
+
]);
|
|
738
|
+
});
|
|
739
|
+
|
|
635
740
|
it("POST /remove validates bare names and reports in-band", async () => {
|
|
636
741
|
const inst = new FakeInstaller();
|
|
637
742
|
const app = createApp(deps({ inst }));
|