@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.
@@ -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
- // hub#275: operator-settable channel (`latest` | `rc`). Read on every
439
- // op so a toggle change applies to the next install without a hub
440
- // restart. The hub-settings layer seeds from PARACHUTE_MODULE_CHANNEL
441
- // on first read; after that the row is source of truth.
442
- const channel = getModuleInstallChannel(deps.db);
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
- const channel = getModuleInstallChannel(deps.db);
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]);
@@ -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
- { version: string; installDir?: string; uis?: Record<string, UiSubUnit> }
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 providerExtract = extractNamedFlag(tagExtract.rest, "--scribe-provider");
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("usage: parachute install <service|all> [--tag <name>] [--no-start]");
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 remaining = tagExtract.rest;
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("usage: parachute upgrade [<service>] [--tag <name>]");
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
 
@@ -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
- const addSpec =
502
- target.kind === "local-path"
503
- ? target.absPath
504
- : opts.tag
505
- ? `${target.packageName}@${opts.tag}`
506
- : target.packageName;
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
  }