@openparachute/hub 0.5.13-rc.13 → 0.5.13-rc.21
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/package.json +1 -1
- package/src/__tests__/api-modules-ops.test.ts +257 -4
- package/src/__tests__/api-modules.test.ts +90 -0
- package/src/__tests__/cli.test.ts +13 -0
- package/src/__tests__/hub-server.test.ts +10 -13
- package/src/__tests__/install.test.ts +259 -24
- package/src/__tests__/lifecycle.test.ts +90 -13
- package/src/__tests__/module-manifest.test.ts +19 -3
- package/src/__tests__/post-install.test.ts +0 -2
- package/src/__tests__/scope-registry.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +456 -43
- package/src/__tests__/setup-wizard.test.ts +228 -0
- package/src/__tests__/status.test.ts +4 -4
- package/src/__tests__/upgrade.test.ts +362 -3
- package/src/api-modules-ops.ts +79 -7
- package/src/api-modules.ts +97 -1
- package/src/cli.ts +50 -4
- package/src/commands/install.ts +108 -6
- package/src/commands/lifecycle.ts +20 -0
- package/src/commands/upgrade.ts +213 -27
- package/src/help.ts +54 -17
- package/src/hub-server.ts +5 -0
- package/src/hub.ts +71 -0
- package/src/module-manifest.ts +22 -17
- package/src/service-spec.ts +44 -60
- package/src/services-manifest.ts +163 -3
- package/src/setup-wizard.ts +205 -12
- package/web/ui/dist/assets/index-5Mj6FqPg.css +1 -0
- package/web/ui/dist/assets/{index-D63mUkVX.js → index-BqjySZ_7.js} +12 -12
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DliViliP.css +0 -1
package/src/api-modules-ops.ts
CHANGED
|
@@ -36,6 +36,7 @@ import type { Database } from "bun:sqlite";
|
|
|
36
36
|
import { randomUUID } from "node:crypto";
|
|
37
37
|
import { dirname } from "node:path";
|
|
38
38
|
import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
|
|
39
|
+
import { PARACHUTE_INSTALL_CHANNEL_ENV } from "./commands/install.ts";
|
|
39
40
|
import { getModuleInstallChannel } from "./hub-settings.ts";
|
|
40
41
|
import { validateAccessToken } from "./jwt-sign.ts";
|
|
41
42
|
import { readModuleManifest } from "./module-manifest.ts";
|
|
@@ -325,6 +326,37 @@ function defaultRun(cmd: readonly string[]): Promise<number> {
|
|
|
325
326
|
return proc.exited;
|
|
326
327
|
}
|
|
327
328
|
|
|
329
|
+
/**
|
|
330
|
+
* Resolve which `<pkg>@<channel>` the API install path should ship,
|
|
331
|
+
* given the per-request override (POST body `channel`) and the
|
|
332
|
+
* cascading defaults. See `runInstall` for the precedence chain.
|
|
333
|
+
*
|
|
334
|
+
* Exported (test-only) so the api-modules-ops tests can assert the
|
|
335
|
+
* resolution without re-driving a full install through the registry.
|
|
336
|
+
*/
|
|
337
|
+
function resolveApiInstallChannel(
|
|
338
|
+
channelOverride: string | undefined,
|
|
339
|
+
deps: ApiModulesOpsDeps,
|
|
340
|
+
): string {
|
|
341
|
+
// 1. Per-request override.
|
|
342
|
+
if (channelOverride === "rc" || channelOverride === "latest") return channelOverride;
|
|
343
|
+
// 2. `PARACHUTE_INSTALL_CHANNEL` env var — cluster-wide cascade.
|
|
344
|
+
const fromEnv = process.env[PARACHUTE_INSTALL_CHANNEL_ENV];
|
|
345
|
+
if (typeof fromEnv === "string") {
|
|
346
|
+
if (fromEnv === "rc" || fromEnv === "latest") return fromEnv;
|
|
347
|
+
if (fromEnv.length > 0) {
|
|
348
|
+
// Garbage env value — log once per op so the operator notices, then
|
|
349
|
+
// fall through to the DB-stored channel. Don't crash the install.
|
|
350
|
+
console.warn(
|
|
351
|
+
`[api-modules-ops] ${PARACHUTE_INSTALL_CHANNEL_ENV}="${fromEnv}" is not a valid channel — falling back to admin-toggle setting.`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
// 3. Admin-toggle setting (hub#275). Seeds from `PARACHUTE_MODULE_CHANNEL`
|
|
356
|
+
// on first read; after that the row is source of truth.
|
|
357
|
+
return getModuleInstallChannel(deps.db);
|
|
358
|
+
}
|
|
359
|
+
|
|
328
360
|
/**
|
|
329
361
|
* Resolve the `installDir` for `spec` from `findGlobalInstall`. Null when
|
|
330
362
|
* the dep isn't wired (tests without a stub) or the package can't be
|
|
@@ -392,6 +424,32 @@ export async function handleInstall(
|
|
|
392
424
|
const authFail = await authorize(req, deps);
|
|
393
425
|
if (authFail) return authFail;
|
|
394
426
|
|
|
427
|
+
// Optional `{ channel: "rc" | "latest" }` in the body — per-call override
|
|
428
|
+
// for the SPA's "install X at rc" affordance (hub#337). Missing body /
|
|
429
|
+
// empty body / non-JSON body all fall through silently to the env →
|
|
430
|
+
// DB-stored channel resolution chain. A malformed `channel` value (not
|
|
431
|
+
// in the union) is rejected — operators shouldn't get a silent fallback
|
|
432
|
+
// on a typo they explicitly typed.
|
|
433
|
+
let bodyChannel: string | undefined;
|
|
434
|
+
if (req.headers.get("content-type")?.includes("application/json")) {
|
|
435
|
+
try {
|
|
436
|
+
const body = (await req.json()) as { channel?: unknown };
|
|
437
|
+
if (body && typeof body.channel === "string") {
|
|
438
|
+
if (body.channel !== "rc" && body.channel !== "latest") {
|
|
439
|
+
return jsonError(
|
|
440
|
+
400,
|
|
441
|
+
"invalid_channel",
|
|
442
|
+
`channel must be "rc" or "latest" (got "${body.channel}")`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
bodyChannel = body.channel;
|
|
446
|
+
}
|
|
447
|
+
} catch {
|
|
448
|
+
// Empty body / unparseable JSON — silently ignore; the env/DB
|
|
449
|
+
// resolution chain still applies.
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
395
453
|
const registry = deps.registry ?? defaultRegistry;
|
|
396
454
|
const op = registry.create("install", short);
|
|
397
455
|
|
|
@@ -409,7 +467,7 @@ export async function handleInstall(
|
|
|
409
467
|
// Kick off the async work. We DON'T await — the response goes back
|
|
410
468
|
// immediately + the work runs in the background. Errors get logged
|
|
411
469
|
// to the operation; nothing throws back to the request handler.
|
|
412
|
-
void runInstall(op.id, short, spec, deps).catch((err) => {
|
|
470
|
+
void runInstall(op.id, short, spec, deps, bodyChannel).catch((err) => {
|
|
413
471
|
const msg = err instanceof Error ? err.message : String(err);
|
|
414
472
|
registry.update(op.id, { status: "failed", error: msg }, `install failed: ${msg}`);
|
|
415
473
|
});
|
|
@@ -432,14 +490,22 @@ export async function runInstall(
|
|
|
432
490
|
short: CuratedModuleShort,
|
|
433
491
|
spec: ServiceSpec,
|
|
434
492
|
deps: ApiModulesOpsDeps,
|
|
493
|
+
channelOverride?: string,
|
|
435
494
|
): Promise<void> {
|
|
436
495
|
const registry = deps.registry ?? defaultRegistry;
|
|
437
496
|
const run = deps.run ?? defaultRun;
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
|
|
497
|
+
// Channel resolution (hub#337) — precedence:
|
|
498
|
+
// 1. per-request `channelOverride` (POST body `{channel}`)
|
|
499
|
+
// 2. `PARACHUTE_INSTALL_CHANNEL` env var (platform-default cascade for
|
|
500
|
+
// Render-style deploys that ship hub on rc and want rc for every
|
|
501
|
+
// module installed via /admin/modules too)
|
|
502
|
+
// 3. `hub_settings.module_install_channel` (admin SPA toggle, hub#275 —
|
|
503
|
+
// seeded from `PARACHUTE_MODULE_CHANNEL` on first read)
|
|
504
|
+
// 4. "latest" fallback
|
|
505
|
+
//
|
|
506
|
+
// Read on every op so a toggle change applies to the next install
|
|
507
|
+
// without a hub restart.
|
|
508
|
+
const channel = resolveApiInstallChannel(channelOverride, deps);
|
|
443
509
|
const spec_str = `${spec.package}@${channel}`;
|
|
444
510
|
registry.update(opId, { status: "running" }, `running bun add -g ${spec_str}`);
|
|
445
511
|
const code = await run(["bun", "add", "-g", spec_str]);
|
|
@@ -598,7 +664,13 @@ async function runUpgrade(
|
|
|
598
664
|
): Promise<void> {
|
|
599
665
|
const registry = deps.registry ?? defaultRegistry;
|
|
600
666
|
const run = deps.run ?? defaultRun;
|
|
601
|
-
|
|
667
|
+
// Mirror runInstall's precedence so PARACHUTE_INSTALL_CHANNEL=rc cascades
|
|
668
|
+
// to admin-SPA-driven upgrades too. Without this, a Render deploy with
|
|
669
|
+
// env=rc would install at @rc but upgrade through the SPA at whatever
|
|
670
|
+
// the DB toggle says — asymmetric + surprising to the operator.
|
|
671
|
+
// (Operators who want different install vs upgrade channels can still
|
|
672
|
+
// do so via the DB toggle when no env is set.)
|
|
673
|
+
const channel = resolveApiInstallChannel(undefined, deps);
|
|
602
674
|
const spec_str = `${spec.package}@${channel}`;
|
|
603
675
|
registry.update(opId, { status: "running" }, `running bun add -g ${spec_str}`);
|
|
604
676
|
const code = await run(["bun", "add", "-g", spec_str]);
|
package/src/api-modules.ts
CHANGED
|
@@ -30,6 +30,10 @@ import {
|
|
|
30
30
|
setModuleInstallChannel,
|
|
31
31
|
} from "./hub-settings.ts";
|
|
32
32
|
import { validateAccessToken } from "./jwt-sign.ts";
|
|
33
|
+
import {
|
|
34
|
+
type ModuleManifest,
|
|
35
|
+
readModuleManifest as defaultReadModuleManifest,
|
|
36
|
+
} from "./module-manifest.ts";
|
|
33
37
|
import { FIRST_PARTY_FALLBACKS, KNOWN_MODULES } from "./service-spec.ts";
|
|
34
38
|
// `FIRST_PARTY_FALLBACKS` and `KNOWN_MODULES` are both consulted by
|
|
35
39
|
// `lookupModule` below — the former for notes/channel (vendored manifests
|
|
@@ -107,6 +111,13 @@ export interface ApiModulesDeps {
|
|
|
107
111
|
cacheTtlMs?: number;
|
|
108
112
|
/** Test seam over wall-clock. */
|
|
109
113
|
now?: () => number;
|
|
114
|
+
/**
|
|
115
|
+
* Override the per-module `.parachute/module.json` reader. Production
|
|
116
|
+
* reads from disk via `module-manifest.readModuleManifest`; tests
|
|
117
|
+
* inject a fake. Used to surface `managementUrl` on the wire shape
|
|
118
|
+
* (hub#342 — drives the admin SPA Modules page's "Open" button).
|
|
119
|
+
*/
|
|
120
|
+
readModuleManifest?: (installDir: string) => Promise<ModuleManifest | null>;
|
|
110
121
|
}
|
|
111
122
|
|
|
112
123
|
/**
|
|
@@ -158,6 +169,30 @@ interface ModuleWireShape {
|
|
|
158
169
|
* design doc §12.
|
|
159
170
|
*/
|
|
160
171
|
uis: UiSubUnitWireShape[];
|
|
172
|
+
/**
|
|
173
|
+
* Canonical user-facing URL for this module's own UI (hub#342). Drives
|
|
174
|
+
* the admin SPA Modules page's "Open" button — clicking lands the
|
|
175
|
+
* operator on the module's own surface (combining view + configure
|
|
176
|
+
* per Aaron's framing: each module ships its own UI handling both).
|
|
177
|
+
*
|
|
178
|
+
* Resolution order:
|
|
179
|
+
* 1. Module's `managementUrl` from `<installDir>/.parachute/module.json`,
|
|
180
|
+
* resolved against the module's mounted URL — matches the
|
|
181
|
+
* well-known doc's resolution for vault rows.
|
|
182
|
+
* 2. Module's `uiUrl` from the same manifest, when it's the only
|
|
183
|
+
* declared surface — for modules where the user-facing UI IS
|
|
184
|
+
* the operator UI (App today).
|
|
185
|
+
* 3. Null when the module hasn't declared either field — the SPA
|
|
186
|
+
* renders a disabled "Open" tooltip ("module hasn't shipped an
|
|
187
|
+
* admin UI yet"). Tracked as follow-up issues per module
|
|
188
|
+
* (scribe#53, runner#8 today).
|
|
189
|
+
*
|
|
190
|
+
* Always an absolute path on the hub origin (leading `/`) — the SPA
|
|
191
|
+
* navigates same-origin, no need to worry about cross-origin
|
|
192
|
+
* managementUrls (those are an escape hatch for off-origin admin
|
|
193
|
+
* surfaces, unused by first-party modules today).
|
|
194
|
+
*/
|
|
195
|
+
management_url: string | null;
|
|
161
196
|
}
|
|
162
197
|
|
|
163
198
|
interface ModulesResponse {
|
|
@@ -251,7 +286,12 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
251
286
|
const manifest = readManifest(deps.manifestPath);
|
|
252
287
|
const installedByShort = new Map<
|
|
253
288
|
string,
|
|
254
|
-
{
|
|
289
|
+
{
|
|
290
|
+
version: string;
|
|
291
|
+
installDir?: string;
|
|
292
|
+
uis?: Record<string, UiSubUnit>;
|
|
293
|
+
mountPath?: string;
|
|
294
|
+
}
|
|
255
295
|
>();
|
|
256
296
|
for (const entry of manifest.services) {
|
|
257
297
|
// Join services.json rows to CURATED_MODULES by manifestName. The
|
|
@@ -266,14 +306,69 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
266
306
|
version: string;
|
|
267
307
|
installDir?: string;
|
|
268
308
|
uis?: Record<string, UiSubUnit>;
|
|
309
|
+
mountPath?: string;
|
|
269
310
|
} = { version: entry.version };
|
|
270
311
|
if (entry.installDir !== undefined) value.installDir = entry.installDir;
|
|
271
312
|
if (entry.uis !== undefined) value.uis = entry.uis;
|
|
313
|
+
// First non-`.parachute` path is the module's user-facing mount
|
|
314
|
+
// (`/app`, `/scribe`, `/vault/<name>`). Used below to resolve
|
|
315
|
+
// a relative `managementUrl` to a full hub-origin path. Skips
|
|
316
|
+
// `.parachute` entries because those are protocol mounts, not
|
|
317
|
+
// user surfaces — every module declares one.
|
|
318
|
+
const userPath = (entry.paths ?? []).find(
|
|
319
|
+
(p) => p !== "/.parachute" && !p.startsWith("/.parachute/"),
|
|
320
|
+
);
|
|
321
|
+
if (userPath !== undefined) value.mountPath = userPath;
|
|
272
322
|
installedByShort.set(short, value);
|
|
273
323
|
}
|
|
274
324
|
}
|
|
275
325
|
}
|
|
276
326
|
|
|
327
|
+
// Read each installed module's `.parachute/module.json` so we can
|
|
328
|
+
// surface `managementUrl` on the wire shape (hub#342). Quiet on
|
|
329
|
+
// per-entry errors: a malformed manifest on one module shouldn't 500
|
|
330
|
+
// the whole catalog response — its row just renders with a null
|
|
331
|
+
// management_url and the SPA shows the disabled "Open" tooltip.
|
|
332
|
+
const readModuleManifestFn = deps.readModuleManifest ?? defaultReadModuleManifest;
|
|
333
|
+
const managementUrlByShort = new Map<string, string>();
|
|
334
|
+
await Promise.all(
|
|
335
|
+
Array.from(installedByShort.entries()).map(async ([short, value]) => {
|
|
336
|
+
if (!value.installDir) return;
|
|
337
|
+
try {
|
|
338
|
+
const m = await readModuleManifestFn(value.installDir);
|
|
339
|
+
if (!m) return;
|
|
340
|
+
// Resolution per the module-ui-declaration.md hierarchy:
|
|
341
|
+
// managementUrl > uiUrl. Both are EITHER an absolute
|
|
342
|
+
// http(s) URL OR a relative path. Relative paths are joined
|
|
343
|
+
// against the module's mount path (entry.paths[0]) since both
|
|
344
|
+
// surfaces conventionally live under it (vault's `/admin`,
|
|
345
|
+
// app's `/admin`). Absolute URLs pass through verbatim.
|
|
346
|
+
const candidate = m.managementUrl ?? m.uiUrl;
|
|
347
|
+
if (candidate === undefined) return;
|
|
348
|
+
if (/^https?:\/\//i.test(candidate)) {
|
|
349
|
+
managementUrlByShort.set(short, candidate);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const mount = value.mountPath;
|
|
353
|
+
if (mount === undefined) {
|
|
354
|
+
// No user-facing mount declared — we can't resolve a relative
|
|
355
|
+
// path. Skip rather than guess. Vault rows hit this when
|
|
356
|
+
// services.json was hand-edited to remove the mount; the
|
|
357
|
+
// disabled-tooltip state in the SPA is the right surface.
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
// Join mount + candidate path. Both pieces have leading slashes
|
|
361
|
+
// already (mount per services.json convention; candidate per
|
|
362
|
+
// managementUrl validation). Drop one to avoid `//`.
|
|
363
|
+
const tail = candidate.startsWith("/") ? candidate : `/${candidate}`;
|
|
364
|
+
managementUrlByShort.set(short, `${mount}${tail}`);
|
|
365
|
+
} catch (err) {
|
|
366
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
367
|
+
console.warn(`api-modules: skipping managementUrl for ${short}: ${msg}`);
|
|
368
|
+
}
|
|
369
|
+
}),
|
|
370
|
+
);
|
|
371
|
+
|
|
277
372
|
// Supervisor state — per-module run status snapshot.
|
|
278
373
|
const supervisor = deps.supervisor;
|
|
279
374
|
const stateByShort = new Map<string, ModuleState>();
|
|
@@ -331,6 +426,7 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
331
426
|
pid: state?.pid ?? null,
|
|
332
427
|
install_dir: installed?.installDir ?? null,
|
|
333
428
|
uis: toUisWireShape(installed?.uis),
|
|
429
|
+
management_url: managementUrlByShort.get(short) ?? null,
|
|
334
430
|
});
|
|
335
431
|
}
|
|
336
432
|
|
package/src/cli.ts
CHANGED
|
@@ -315,7 +315,22 @@ async function main(argv: string[]): Promise<number> {
|
|
|
315
315
|
console.error(`parachute install: ${tagExtract.error}`);
|
|
316
316
|
return 1;
|
|
317
317
|
}
|
|
318
|
-
const
|
|
318
|
+
const channelExtract = extractNamedFlag(tagExtract.rest, "--channel");
|
|
319
|
+
if (channelExtract.error) {
|
|
320
|
+
console.error(`parachute install: ${channelExtract.error}`);
|
|
321
|
+
return 1;
|
|
322
|
+
}
|
|
323
|
+
if (
|
|
324
|
+
channelExtract.value !== undefined &&
|
|
325
|
+
channelExtract.value !== "rc" &&
|
|
326
|
+
channelExtract.value !== "latest"
|
|
327
|
+
) {
|
|
328
|
+
console.error(
|
|
329
|
+
`parachute install: --channel must be "rc" or "latest" (got "${channelExtract.value}")`,
|
|
330
|
+
);
|
|
331
|
+
return 1;
|
|
332
|
+
}
|
|
333
|
+
const providerExtract = extractNamedFlag(channelExtract.rest, "--scribe-provider");
|
|
319
334
|
if (providerExtract.error) {
|
|
320
335
|
console.error(`parachute install: ${providerExtract.error}`);
|
|
321
336
|
return 1;
|
|
@@ -329,7 +344,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
329
344
|
const installArgs = keyExtract.rest.filter((a) => a !== "--no-start");
|
|
330
345
|
const service = installArgs[0];
|
|
331
346
|
if (!service) {
|
|
332
|
-
console.error(
|
|
347
|
+
console.error(
|
|
348
|
+
"usage: parachute install <service|all> [--channel rc|latest] [--tag <name>] [--no-start]",
|
|
349
|
+
);
|
|
333
350
|
console.error(
|
|
334
351
|
" parachute install scribe [--scribe-provider <name>] [--scribe-key <key>]",
|
|
335
352
|
);
|
|
@@ -338,6 +355,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
338
355
|
}
|
|
339
356
|
const installOpts: Parameters<typeof install>[1] = {};
|
|
340
357
|
if (tagExtract.tag) installOpts.tag = tagExtract.tag;
|
|
358
|
+
if (channelExtract.value === "rc" || channelExtract.value === "latest") {
|
|
359
|
+
installOpts.channel = channelExtract.value;
|
|
360
|
+
}
|
|
341
361
|
if (noStart) installOpts.noStart = true;
|
|
342
362
|
if (providerExtract.value) installOpts.scribeProvider = providerExtract.value;
|
|
343
363
|
if (keyExtract.value) installOpts.scribeKey = keyExtract.value;
|
|
@@ -549,14 +569,40 @@ async function main(argv: string[]): Promise<number> {
|
|
|
549
569
|
console.error(`parachute upgrade: ${tagExtract.error}`);
|
|
550
570
|
return 1;
|
|
551
571
|
}
|
|
552
|
-
const
|
|
572
|
+
const channelExtract = extractNamedFlag(tagExtract.rest, "--channel");
|
|
573
|
+
if (channelExtract.error) {
|
|
574
|
+
console.error(`parachute upgrade: ${channelExtract.error}`);
|
|
575
|
+
return 1;
|
|
576
|
+
}
|
|
577
|
+
if (
|
|
578
|
+
channelExtract.value !== undefined &&
|
|
579
|
+
channelExtract.value !== "rc" &&
|
|
580
|
+
channelExtract.value !== "latest"
|
|
581
|
+
) {
|
|
582
|
+
console.error(
|
|
583
|
+
`parachute upgrade: --channel must be "rc" or "latest" (got "${channelExtract.value}")`,
|
|
584
|
+
);
|
|
585
|
+
return 1;
|
|
586
|
+
}
|
|
587
|
+
let remaining = channelExtract.rest;
|
|
588
|
+
const allowDowngradeIdx = remaining.indexOf("--allow-downgrade");
|
|
589
|
+
const allowDowngrade = allowDowngradeIdx !== -1;
|
|
590
|
+
if (allowDowngrade) {
|
|
591
|
+
remaining = remaining.filter((a) => a !== "--allow-downgrade");
|
|
592
|
+
}
|
|
553
593
|
if (remaining.length > 1) {
|
|
554
594
|
console.error(`parachute upgrade: unexpected argument "${remaining[1]}"`);
|
|
555
|
-
console.error(
|
|
595
|
+
console.error(
|
|
596
|
+
"usage: parachute upgrade [<service>] [--channel rc|latest] [--allow-downgrade] [--tag <name>]",
|
|
597
|
+
);
|
|
556
598
|
return 1;
|
|
557
599
|
}
|
|
558
600
|
const upgradeOpts: Parameters<typeof upgrade>[1] = {};
|
|
559
601
|
if (tagExtract.tag) upgradeOpts.tag = tagExtract.tag;
|
|
602
|
+
if (channelExtract.value === "rc" || channelExtract.value === "latest") {
|
|
603
|
+
upgradeOpts.channel = channelExtract.value;
|
|
604
|
+
}
|
|
605
|
+
if (allowDowngrade) upgradeOpts.allowDowngrade = true;
|
|
560
606
|
return await upgrade(remaining[0], upgradeOpts);
|
|
561
607
|
}
|
|
562
608
|
|
package/src/commands/install.ts
CHANGED
|
@@ -37,6 +37,67 @@ import {
|
|
|
37
37
|
|
|
38
38
|
export type Runner = (cmd: readonly string[]) => Promise<number>;
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Env var that defaults the install channel for `parachute install <svc>`
|
|
42
|
+
* (hub#337). When set to `rc` or `latest`, becomes the default channel for
|
|
43
|
+
* every `bun add -g <pkg>@<channel>` the install command composes. The
|
|
44
|
+
* explicit `--channel` flag (and `--tag`) override the env var per call.
|
|
45
|
+
*
|
|
46
|
+
* Rationale: the canonical Render deploy ships the hub container from
|
|
47
|
+
* `main` (which tracks the rc chain per governance rule 2). Without this
|
|
48
|
+
* env var the supervisor's `/admin/modules` install API would still
|
|
49
|
+
* resolve `@latest` for vault / app / scribe / runner — leaving a hub-on-rc
|
|
50
|
+
* cluster bootstrapping its other modules on stable, which silently
|
|
51
|
+
* fragments the cluster's version axis. Setting `PARACHUTE_INSTALL_CHANNEL=rc`
|
|
52
|
+
* at the platform level cascades the rc-ness across every module install,
|
|
53
|
+
* matching what an `npm i -g @openparachute/hub@rc` operator does on the
|
|
54
|
+
* CLI side.
|
|
55
|
+
*
|
|
56
|
+
* Garbage values (`PARACHUTE_INSTALL_CHANNEL=banana`) fall back to `latest`
|
|
57
|
+
* with a warning so an operator typo can't crash the install path.
|
|
58
|
+
*/
|
|
59
|
+
export const PARACHUTE_INSTALL_CHANNEL_ENV = "PARACHUTE_INSTALL_CHANNEL";
|
|
60
|
+
|
|
61
|
+
const VALID_INSTALL_CHANNELS = ["latest", "rc"] as const;
|
|
62
|
+
export type InstallChannel = (typeof VALID_INSTALL_CHANNELS)[number];
|
|
63
|
+
|
|
64
|
+
function isInstallChannel(v: string): v is InstallChannel {
|
|
65
|
+
return (VALID_INSTALL_CHANNELS as readonly string[]).includes(v);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve the dist-tag to use for `bun add -g <pkg>@<tag>` in `parachute
|
|
70
|
+
* install`. Precedence (highest → lowest):
|
|
71
|
+
*
|
|
72
|
+
* 1. explicit `--tag <name>` (programmatic — exact pin, may be a version)
|
|
73
|
+
* 2. explicit `--channel rc|latest` (operator-facing dist-tag override)
|
|
74
|
+
* 3. `PARACHUTE_INSTALL_CHANNEL` env var (platform-default cascade)
|
|
75
|
+
* 4. `"latest"` fallback (the npm default; back-compat for existing operators)
|
|
76
|
+
*
|
|
77
|
+
* Garbage env-var values fall back to `"latest"` with a warning. The
|
|
78
|
+
* `env` + `warn` knobs are test seams; production uses `process.env` +
|
|
79
|
+
* `console.warn`.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveInstallChannel(opts: {
|
|
82
|
+
tag?: string;
|
|
83
|
+
channel?: string;
|
|
84
|
+
env?: NodeJS.ProcessEnv;
|
|
85
|
+
warn?: (msg: string) => void;
|
|
86
|
+
}): string {
|
|
87
|
+
if (opts.tag) return opts.tag;
|
|
88
|
+
if (opts.channel) return opts.channel;
|
|
89
|
+
const env = opts.env ?? process.env;
|
|
90
|
+
const fromEnv = env[PARACHUTE_INSTALL_CHANNEL_ENV];
|
|
91
|
+
if (typeof fromEnv === "string" && fromEnv.length > 0) {
|
|
92
|
+
if (isInstallChannel(fromEnv)) return fromEnv;
|
|
93
|
+
const warn = opts.warn ?? ((msg: string) => console.warn(msg));
|
|
94
|
+
warn(
|
|
95
|
+
`[parachute install] ${PARACHUTE_INSTALL_CHANNEL_ENV}="${fromEnv}" is not a valid channel — expected one of ${VALID_INSTALL_CHANNELS.join(", ")}. Falling back to "latest".`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return "latest";
|
|
99
|
+
}
|
|
100
|
+
|
|
40
101
|
/**
|
|
41
102
|
* Transition aliases for services that were renamed. Accepted for one
|
|
42
103
|
* release cycle with a rename notice, then removed. `lens → notes`
|
|
@@ -77,8 +138,26 @@ export interface InstallOpts {
|
|
|
77
138
|
* `bun add -g` call is composed as `<package>@<tag>` so RC testers can
|
|
78
139
|
* pin a pre-release channel. `isLinked` still short-circuits — if the
|
|
79
140
|
* package is bun-linked locally, the tag is moot.
|
|
141
|
+
*
|
|
142
|
+
* Precedence: `tag` > `channel` > `PARACHUTE_INSTALL_CHANNEL` env > `"latest"`.
|
|
80
143
|
*/
|
|
81
144
|
tag?: string;
|
|
145
|
+
/**
|
|
146
|
+
* Operator-facing channel (`--channel rc|latest`, hub#337). Picks a npm
|
|
147
|
+
* dist-tag for the `bun add -g <pkg>@<channel>` call. Wins over the
|
|
148
|
+
* `PARACHUTE_INSTALL_CHANNEL` env var but loses to `tag` (which is the
|
|
149
|
+
* programmatic-pin escape hatch — e.g. an exact version string). The
|
|
150
|
+
* CLI argv parser rejects values outside `rc`/`latest` before this
|
|
151
|
+
* point; the install command itself trusts the caller's input.
|
|
152
|
+
*/
|
|
153
|
+
channel?: string;
|
|
154
|
+
/**
|
|
155
|
+
* Override `process.env` for channel resolution (test seam). Production
|
|
156
|
+
* reads from `process.env`. Tests inject a deterministic object to
|
|
157
|
+
* exercise the `PARACHUTE_INSTALL_CHANNEL` precedence + invalid-value
|
|
158
|
+
* fallback without polluting the real environment.
|
|
159
|
+
*/
|
|
160
|
+
envOverride?: NodeJS.ProcessEnv;
|
|
82
161
|
/**
|
|
83
162
|
* Override the random-token source for the vault↔scribe auto-wire.
|
|
84
163
|
* Tests pass a deterministic string; production uses crypto.randomBytes.
|
|
@@ -498,12 +577,35 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
|
|
|
498
577
|
} else if (target.kind === "local-path" && localAlreadyLinkedTo === targetReal) {
|
|
499
578
|
log(`${target.packageName} is already linked at ${target.absPath} — skipping bun add.`);
|
|
500
579
|
} else {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
580
|
+
// Channel resolution (hub#337): `--tag` > `--channel` > env > "latest".
|
|
581
|
+
// Local-path installs always pass the absolute path through verbatim
|
|
582
|
+
// (no channel applies — we're installing from the filesystem, not npm).
|
|
583
|
+
let addSpec: string;
|
|
584
|
+
if (target.kind === "local-path") {
|
|
585
|
+
addSpec = target.absPath;
|
|
586
|
+
} else {
|
|
587
|
+
const resolveOpts: Parameters<typeof resolveInstallChannel>[0] = {
|
|
588
|
+
warn: (msg) => log(`⚠ ${msg}`),
|
|
589
|
+
};
|
|
590
|
+
if (opts.tag !== undefined) resolveOpts.tag = opts.tag;
|
|
591
|
+
if (opts.channel !== undefined) resolveOpts.channel = opts.channel;
|
|
592
|
+
if (opts.envOverride !== undefined) resolveOpts.env = opts.envOverride;
|
|
593
|
+
const channel = resolveInstallChannel(resolveOpts);
|
|
594
|
+
// Suppress `@latest` from the displayed/composed spec when nothing
|
|
595
|
+
// was explicitly requested — bun resolves bare names to @latest
|
|
596
|
+
// anyway, and keeping the spec bare preserves byte-identical
|
|
597
|
+
// back-compat with pre-hub#337 logs ("Installing @openparachute/vault…"
|
|
598
|
+
// not "Installing @openparachute/vault@latest…"). Any explicit
|
|
599
|
+
// tag/channel/env value still flows through.
|
|
600
|
+
const explicit = opts.tag !== undefined || opts.channel !== undefined;
|
|
601
|
+
const envSet =
|
|
602
|
+
opts.envOverride !== undefined
|
|
603
|
+
? typeof opts.envOverride[PARACHUTE_INSTALL_CHANNEL_ENV] === "string" &&
|
|
604
|
+
opts.envOverride[PARACHUTE_INSTALL_CHANNEL_ENV] !== ""
|
|
605
|
+
: typeof process.env[PARACHUTE_INSTALL_CHANNEL_ENV] === "string" &&
|
|
606
|
+
process.env[PARACHUTE_INSTALL_CHANNEL_ENV] !== "";
|
|
607
|
+
addSpec = explicit || envSet ? `${target.packageName}@${channel}` : target.packageName;
|
|
608
|
+
}
|
|
507
609
|
log(`Installing ${addSpec}…`);
|
|
508
610
|
const addCode = await runner(["bun", "add", "-g", addSpec]);
|
|
509
611
|
if (addCode !== 0) {
|
|
@@ -594,6 +594,11 @@ export interface LogsOpts {
|
|
|
594
594
|
/** Number of trailing lines to print (default 200). */
|
|
595
595
|
lines?: number;
|
|
596
596
|
follow?: boolean;
|
|
597
|
+
/**
|
|
598
|
+
* Liveness probe seam — tests inject deterministic pid-alive answers.
|
|
599
|
+
* Defaults to the group-aware `defaultAlive` (hub#88).
|
|
600
|
+
*/
|
|
601
|
+
alive?: AliveFn;
|
|
597
602
|
}
|
|
598
603
|
|
|
599
604
|
export async function logs(svc: string, opts: LogsOpts = {}): Promise<number> {
|
|
@@ -602,6 +607,7 @@ export async function logs(svc: string, opts: LogsOpts = {}): Promise<number> {
|
|
|
602
607
|
const log = opts.log ?? ((line) => console.log(line));
|
|
603
608
|
const lines = opts.lines ?? 200;
|
|
604
609
|
const follow = opts.follow ?? false;
|
|
610
|
+
const alive = opts.alive ?? defaultAlive;
|
|
605
611
|
|
|
606
612
|
// logs only needs a valid short name to find the log file. First-party
|
|
607
613
|
// wins via the spec lookup; third-party rows match by `entry.name`; the
|
|
@@ -620,6 +626,20 @@ export async function logs(svc: string, opts: LogsOpts = {}): Promise<number> {
|
|
|
620
626
|
|
|
621
627
|
const path = logPathFor(svc, configDir);
|
|
622
628
|
if (!existsSync(path)) {
|
|
629
|
+
// Distinguish "daemon never started" from "daemon is running but the
|
|
630
|
+
// log file is missing" (hub#335). The latter shape surfaces when a
|
|
631
|
+
// module self-registers + spawns its own logger without going through
|
|
632
|
+
// `parachute start <svc>` (no hub-managed log file), or when an
|
|
633
|
+
// operator deletes the log mid-run. Previously both shapes printed the
|
|
634
|
+
// same `parachute start ${svc}` hint, leading operators to think their
|
|
635
|
+
// running daemon hadn't started.
|
|
636
|
+
const state = processState(svc, configDir, alive);
|
|
637
|
+
if (state.status === "running") {
|
|
638
|
+
log(
|
|
639
|
+
`${svc} is running (pid ${state.pid}) but no log file at ${path}. The daemon may be writing logs elsewhere — check its stdout/stderr or its own log destination.`,
|
|
640
|
+
);
|
|
641
|
+
return 0;
|
|
642
|
+
}
|
|
623
643
|
log(`no logs yet for ${svc}. \`parachute start ${svc}\` to begin.`);
|
|
624
644
|
return 0;
|
|
625
645
|
}
|