@openparachute/hub 0.3.0-rc.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +19 -17
  2. package/package.json +15 -4
  3. package/src/__tests__/admin-auth.test.ts +197 -0
  4. package/src/__tests__/admin-config.test.ts +281 -0
  5. package/src/__tests__/admin-grants.test.ts +271 -0
  6. package/src/__tests__/admin-handlers.test.ts +530 -0
  7. package/src/__tests__/admin-host-admin-token.test.ts +115 -0
  8. package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
  9. package/src/__tests__/admin-vaults.test.ts +615 -0
  10. package/src/__tests__/auth-codes.test.ts +253 -0
  11. package/src/__tests__/auth.test.ts +712 -17
  12. package/src/__tests__/cli.test.ts +50 -0
  13. package/src/__tests__/clients.test.ts +264 -0
  14. package/src/__tests__/cloudflare-state.test.ts +167 -7
  15. package/src/__tests__/csrf.test.ts +117 -0
  16. package/src/__tests__/expose-cloudflare.test.ts +232 -37
  17. package/src/__tests__/expose-off-auto.test.ts +15 -9
  18. package/src/__tests__/expose-public-auto.test.ts +153 -0
  19. package/src/__tests__/expose.test.ts +216 -24
  20. package/src/__tests__/grants.test.ts +164 -0
  21. package/src/__tests__/hub-db.test.ts +153 -0
  22. package/src/__tests__/hub-server.test.ts +984 -26
  23. package/src/__tests__/hub.test.ts +56 -49
  24. package/src/__tests__/install.test.ts +327 -3
  25. package/src/__tests__/jwks.test.ts +37 -0
  26. package/src/__tests__/jwt-sign.test.ts +361 -0
  27. package/src/__tests__/lifecycle.test.ts +519 -5
  28. package/src/__tests__/module-manifest.test.ts +183 -0
  29. package/src/__tests__/oauth-handlers.test.ts +3112 -0
  30. package/src/__tests__/oauth-ui.test.ts +253 -0
  31. package/src/__tests__/operator-token.test.ts +140 -0
  32. package/src/__tests__/providers-detect.test.ts +158 -0
  33. package/src/__tests__/scope-explanations.test.ts +108 -0
  34. package/src/__tests__/scope-registry.test.ts +220 -0
  35. package/src/__tests__/services-manifest.test.ts +137 -1
  36. package/src/__tests__/sessions.test.ts +116 -0
  37. package/src/__tests__/setup.test.ts +361 -0
  38. package/src/__tests__/signing-keys.test.ts +153 -0
  39. package/src/__tests__/upgrade.test.ts +541 -0
  40. package/src/__tests__/users.test.ts +154 -0
  41. package/src/__tests__/well-known.test.ts +127 -10
  42. package/src/admin-auth.ts +126 -0
  43. package/src/admin-config-ui.ts +534 -0
  44. package/src/admin-config.ts +226 -0
  45. package/src/admin-grants.ts +160 -0
  46. package/src/admin-handlers.ts +365 -0
  47. package/src/admin-host-admin-token.ts +83 -0
  48. package/src/admin-vault-admin-token.ts +98 -0
  49. package/src/admin-vaults.ts +359 -0
  50. package/src/auth-codes.ts +189 -0
  51. package/src/cli.ts +202 -25
  52. package/src/clients.ts +210 -0
  53. package/src/cloudflare/config.ts +25 -6
  54. package/src/cloudflare/state.ts +108 -28
  55. package/src/commands/auth.ts +652 -19
  56. package/src/commands/expose-cloudflare.ts +85 -45
  57. package/src/commands/expose-interactive.ts +20 -44
  58. package/src/commands/expose-off-auto.ts +27 -11
  59. package/src/commands/expose-public-auto.ts +179 -0
  60. package/src/commands/expose.ts +63 -32
  61. package/src/commands/install.ts +337 -48
  62. package/src/commands/lifecycle.ts +242 -37
  63. package/src/commands/setup.ts +366 -0
  64. package/src/commands/status.ts +4 -1
  65. package/src/commands/upgrade.ts +429 -0
  66. package/src/csrf.ts +101 -0
  67. package/src/grants.ts +142 -0
  68. package/src/help.ts +133 -19
  69. package/src/hub-control.ts +12 -0
  70. package/src/hub-db.ts +164 -0
  71. package/src/hub-server.ts +643 -22
  72. package/src/hub.ts +97 -390
  73. package/src/jwks.ts +41 -0
  74. package/src/jwt-sign.ts +275 -0
  75. package/src/module-manifest.ts +435 -0
  76. package/src/oauth-handlers.ts +1206 -0
  77. package/src/oauth-ui.ts +582 -0
  78. package/src/operator-token.ts +129 -0
  79. package/src/providers/detect.ts +97 -0
  80. package/src/scope-explanations.ts +137 -0
  81. package/src/scope-registry.ts +158 -0
  82. package/src/service-spec.ts +270 -97
  83. package/src/services-manifest.ts +57 -1
  84. package/src/sessions.ts +115 -0
  85. package/src/signing-keys.ts +120 -0
  86. package/src/users.ts +144 -0
  87. package/src/well-known.ts +62 -26
  88. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  89. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  90. package/web/ui/dist/index.html +14 -0
@@ -3,19 +3,33 @@ import { join } from "node:path";
3
3
  import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
4
4
  import { readEnvFileValues } from "../env-file.ts";
5
5
  import { readExposeState } from "../expose-state.ts";
6
- import { readHubPort } from "../hub-control.ts";
6
+ import {
7
+ type EnsureHubOpts,
8
+ type EnsureHubResult,
9
+ HUB_SVC,
10
+ type StopHubOpts,
11
+ ensureHubRunning,
12
+ readHubPort,
13
+ stopHub,
14
+ } from "../hub-control.ts";
7
15
  import { HUB_ORIGIN_ENV, deriveHubOrigin } from "../hub-origin.ts";
16
+ import { ModuleManifestError } from "../module-manifest.ts";
8
17
  import {
9
18
  type AliveFn,
10
19
  clearPid,
11
- defaultAlive,
12
20
  ensureLogPath,
13
21
  logPath as logPathFor,
14
22
  processState,
15
23
  readPid,
16
24
  writePid,
17
25
  } from "../process-state.ts";
18
- import { getSpec, knownServices, shortNameForManifest } from "../service-spec.ts";
26
+ import {
27
+ type ServiceSpec,
28
+ getSpec,
29
+ getSpecFromInstallDir,
30
+ knownServices,
31
+ shortNameForManifest,
32
+ } from "../service-spec.ts";
19
33
  import { type ServiceEntry, readManifest } from "../services-manifest.ts";
20
34
 
21
35
  /**
@@ -26,18 +40,35 @@ import { type ServiceEntry, readManifest } from "../services-manifest.ts";
26
40
  * `env`, when provided, is merged into the child's environment on top of the
27
41
  * parent's — today's only caller is `start`, which injects
28
42
  * PARACHUTE_HUB_ORIGIN so vault's OAuth issuer matches the hub URL.
43
+ *
44
+ * `cwd`, when provided, is the child's working directory. Set to the
45
+ * service's installDir for third-party modules so manifest-declared
46
+ * relative startCmds (e.g. `["bun", "web/server/src/server.ts"]`) resolve
47
+ * against the package root.
29
48
  */
49
+ export interface SpawnerOptions {
50
+ env?: Record<string, string>;
51
+ cwd?: string;
52
+ }
53
+
30
54
  export interface Spawner {
31
- spawn(cmd: readonly string[], logFile: string, env?: Record<string, string>): number;
55
+ spawn(cmd: readonly string[], logFile: string, opts?: SpawnerOptions): number;
32
56
  }
33
57
 
34
58
  export const defaultSpawner: Spawner = {
35
- spawn(cmd, logFile, env) {
59
+ spawn(cmd, logFile, opts) {
36
60
  const fd = openSync(logFile, "a");
37
- const proc = Bun.spawn([...cmd], {
61
+ const spawnOpts: Parameters<typeof Bun.spawn>[1] = {
38
62
  stdio: ["ignore", fd, fd],
39
- env: env ? { ...process.env, ...env } : undefined,
40
- });
63
+ // Spawn in a fresh process group (pid == pgid) so kill(-pid, sig)
64
+ // reaches every descendant, not just the wrapper. Without this,
65
+ // wrapped startCmds like `pnpm exec tsx server.ts` leave the tsx
66
+ // grandchild bound to the port after stop → restart hits EADDRINUSE.
67
+ detached: true,
68
+ };
69
+ if (opts?.env) spawnOpts.env = { ...process.env, ...opts.env };
70
+ if (opts?.cwd) spawnOpts.cwd = opts.cwd;
71
+ const proc = Bun.spawn([...cmd], spawnOpts);
41
72
  proc.unref();
42
73
  return proc.pid;
43
74
  },
@@ -46,8 +77,48 @@ export const defaultSpawner: Spawner = {
46
77
  export type KillFn = (pid: number, signal: NodeJS.Signals | number) => void;
47
78
  export type SleepFn = (ms: number) => Promise<void>;
48
79
 
80
+ /**
81
+ * Group-aware liveness: returns true if the process group (pgid == pid)
82
+ * still has any member. Pairs with `defaultSpawner`'s `detached: true` —
83
+ * the recorded pid is the pgid we created, so the group's existence is
84
+ * the right "is the service still up?" signal (catches the wrapper-dead-
85
+ * but-grandchild-listening case that causes EADDRINUSE on restart).
86
+ *
87
+ * Falls back to a single-pid check for legacy pidfiles written before
88
+ * detached-spawn landed: `kill(-pid, 0)` returns ESRCH because no group
89
+ * with that pgid exists, and we still want to honor the bare-pid alive
90
+ * signal so a follow-up `stop` runs.
91
+ */
92
+ export const defaultAlive: AliveFn = (pid) => {
93
+ try {
94
+ process.kill(-pid, 0);
95
+ return true;
96
+ } catch (err) {
97
+ if ((err as NodeJS.ErrnoException).code !== "ESRCH") return true;
98
+ }
99
+ try {
100
+ process.kill(pid, 0);
101
+ return true;
102
+ } catch {
103
+ return false;
104
+ }
105
+ };
106
+
107
+ /**
108
+ * Sends `signal` to the entire process group rooted at `pid`. With
109
+ * `defaultSpawner` putting the child in its own group, this reaches the
110
+ * wrapper and any grandchildren in one syscall. ESRCH on the group send
111
+ * means the pgid is gone (legacy pidfile, or the leader exited and the
112
+ * group emptied) — fall back to a bare-pid signal so the caller's intent
113
+ * still lands when there's a positive-pid process to receive it.
114
+ */
49
115
  export const defaultKill: KillFn = (pid, signal) => {
50
- process.kill(pid, signal);
116
+ try {
117
+ process.kill(-pid, signal);
118
+ } catch (err) {
119
+ if ((err as NodeJS.ErrnoException).code !== "ESRCH") throw err;
120
+ process.kill(pid, signal);
121
+ }
51
122
  };
52
123
 
53
124
  export const defaultSleep: SleepFn = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -72,6 +143,18 @@ export interface LifecycleOpts {
72
143
  * and the service advertises its own default issuer.
73
144
  */
74
145
  hubOrigin?: string;
146
+ /**
147
+ * Hub-lifecycle seams for `parachute start|stop|restart hub`. The hub
148
+ * doesn't go through the generic services-manifest path because its
149
+ * start has special semantics (port-fallback probe, port-file write,
150
+ * --issuer flag) — `lifecycle.start("hub")` dispatches to
151
+ * `ensureHubRunning` and `lifecycle.stop("hub")` dispatches to
152
+ * `stopHub`. Tests inject stubs to avoid spawning real bun processes.
153
+ */
154
+ hub?: {
155
+ ensureRunning?: (opts: EnsureHubOpts) => Promise<EnsureHubResult>;
156
+ stop?: (opts: StopHubOpts) => Promise<boolean>;
157
+ };
75
158
  }
76
159
 
77
160
  interface Resolved {
@@ -86,6 +169,8 @@ interface Resolved {
86
169
  killWaitMs: number;
87
170
  pollIntervalMs: number;
88
171
  hubOrigin: string | undefined;
172
+ ensureHub: (opts: EnsureHubOpts) => Promise<EnsureHubResult>;
173
+ stopHubFn: (opts: StopHubOpts) => Promise<boolean>;
89
174
  }
90
175
 
91
176
  function resolve(opts: LifecycleOpts): Resolved {
@@ -102,6 +187,8 @@ function resolve(opts: LifecycleOpts): Resolved {
102
187
  killWaitMs: opts.killWaitMs ?? 10_000,
103
188
  pollIntervalMs: opts.pollIntervalMs ?? 200,
104
189
  hubOrigin: resolveHubOrigin(opts.hubOrigin, configDir),
190
+ ensureHub: opts.hub?.ensureRunning ?? ensureHubRunning,
191
+ stopHubFn: opts.hub?.stop ?? stopHub,
105
192
  };
106
193
  }
107
194
 
@@ -120,41 +207,92 @@ function resolveHubOrigin(override: string | undefined, configDir: string): stri
120
207
  return deriveHubOrigin({ exposeFqdn, hubPort: readHubPort(configDir) });
121
208
  }
122
209
 
210
+ interface ResolvedTarget {
211
+ short: string;
212
+ entry: ServiceEntry;
213
+ /**
214
+ * Lifecycle spec resolved at request time. First-party comes from
215
+ * `getSpec(short)`; third-party comes from
216
+ * `getSpecFromInstallDir(entry.installDir, ...)`. May be undefined when
217
+ * a row has neither — lifecycle prints "lifecycle not yet supported"
218
+ * for that service rather than crashing the whole sweep.
219
+ */
220
+ spec: ServiceSpec | undefined;
221
+ }
222
+
223
+ async function specForEntry(
224
+ short: string,
225
+ entry: ServiceEntry,
226
+ ): Promise<{ spec: ServiceSpec | undefined; error?: string }> {
227
+ const firstParty = getSpec(short);
228
+ if (firstParty) return { spec: firstParty };
229
+ if (!entry.installDir) return { spec: undefined };
230
+ try {
231
+ const spec = await getSpecFromInstallDir(entry.installDir, entry.name);
232
+ return { spec: spec ?? undefined };
233
+ } catch (err) {
234
+ if (err instanceof ModuleManifestError) {
235
+ return { spec: undefined, error: err.message };
236
+ }
237
+ throw err;
238
+ }
239
+ }
240
+
123
241
  /**
124
242
  * Services selected by the `[svc]` positional. `undefined` targets every
125
- * installed service (looked up via the manifest). Unknown names get a
126
- * friendly error up front rather than a confusing spawn failure downstream.
243
+ * manageable service (first-party shortnames OR third-party rows that
244
+ * carry `installDir`). Unknown names get a friendly error up front rather
245
+ * than a confusing spawn failure downstream.
246
+ *
247
+ * Third-party modules are addressed by the `name` field from their
248
+ * `module.json` (which is what install copied to `entry.name` for
249
+ * third-party). First-party are addressed by their short name (vault,
250
+ * notes, …) and matched via `shortNameForManifest`.
127
251
  */
128
- function resolveTargets(
252
+ async function resolveTargets(
129
253
  svc: string | undefined,
130
254
  manifestPath: string,
131
- ): { targets: Array<{ short: string; entry: ServiceEntry }> } | { error: string } {
255
+ ): Promise<{ targets: ResolvedTarget[] } | { error: string }> {
132
256
  const manifest = readManifest(manifestPath);
133
257
  if (manifest.services.length === 0) {
134
258
  return { error: "No services installed yet. Try: parachute install vault" };
135
259
  }
136
260
 
137
261
  if (svc !== undefined) {
138
- const spec = getSpec(svc);
139
- if (!spec) {
140
- return {
141
- error: `unknown service "${svc}". known: ${knownServices().join(", ")}`,
142
- };
262
+ // Try first-party (svc is a short name → known fallback).
263
+ const firstPartySpec = getSpec(svc);
264
+ if (firstPartySpec) {
265
+ const entry = manifest.services.find((s) => s.name === firstPartySpec.manifestName);
266
+ if (!entry) {
267
+ return { error: `${svc} isn't installed. Run \`parachute install ${svc}\` first.` };
268
+ }
269
+ return { targets: [{ short: svc, entry, spec: firstPartySpec }] };
143
270
  }
144
- const entry = manifest.services.find((s) => s.name === spec.manifestName);
145
- if (!entry) {
146
- return {
147
- error: `${svc} isn't installed. Run \`parachute install ${svc}\` first.`,
148
- };
271
+ // Third-party: match a services.json row by name. Third-party rows
272
+ // carry `installDir`; without it we have no way to resolve a spec.
273
+ const entry = manifest.services.find((s) => s.name === svc);
274
+ if (entry?.installDir) {
275
+ const { spec, error } = await specForEntry(svc, entry);
276
+ if (error) return { error: `${svc}: invalid module.json — ${error}` };
277
+ return { targets: [{ short: svc, entry, spec }] };
149
278
  }
150
- return { targets: [{ short: svc, entry }] };
279
+ return {
280
+ error: `unknown service "${svc}". known: ${knownServices().join(", ")}`,
281
+ };
151
282
  }
152
283
 
153
- const targets: Array<{ short: string; entry: ServiceEntry }> = [];
284
+ const targets: ResolvedTarget[] = [];
154
285
  for (const entry of manifest.services) {
155
286
  const short = shortNameForManifest(entry.name);
156
- if (!short) continue;
157
- targets.push({ short, entry });
287
+ if (short) {
288
+ const spec = getSpec(short);
289
+ targets.push({ short, entry, spec });
290
+ continue;
291
+ }
292
+ if (entry.installDir) {
293
+ const { spec } = await specForEntry(entry.name, entry);
294
+ targets.push({ short: entry.name, entry, spec });
295
+ }
158
296
  }
159
297
  if (targets.length === 0) {
160
298
  return { error: "No manageable services in services.json." };
@@ -164,14 +302,15 @@ function resolveTargets(
164
302
 
165
303
  export async function start(svc: string | undefined, opts: LifecycleOpts = {}): Promise<number> {
166
304
  const r = resolve(opts);
167
- const picked = resolveTargets(svc, r.manifestPath);
305
+ if (svc === HUB_SVC) return startHubSvc(r);
306
+ const picked = await resolveTargets(svc, r.manifestPath);
168
307
  if ("error" in picked) {
169
308
  r.log(picked.error);
170
309
  return 1;
171
310
  }
172
311
 
173
312
  let failures = 0;
174
- for (const { short, entry } of picked.targets) {
313
+ for (const { short, entry, spec } of picked.targets) {
175
314
  const state = processState(short, r.configDir, r.alive);
176
315
  if (state.status === "running") {
177
316
  r.log(`${short} already running (pid ${state.pid}).`);
@@ -182,7 +321,6 @@ export async function start(svc: string | undefined, opts: LifecycleOpts = {}):
182
321
  clearPid(short, r.configDir);
183
322
  }
184
323
 
185
- const spec = getSpec(short);
186
324
  const cmd = spec?.startCmd?.(entry);
187
325
  if (!cmd || cmd.length === 0) {
188
326
  r.log(`${short}: lifecycle not yet supported for this service.`);
@@ -200,9 +338,16 @@ export async function start(svc: string | undefined, opts: LifecycleOpts = {}):
200
338
  const fileEnv = readEnvFileValues(join(r.configDir, short, ".env"));
201
339
  const env: Record<string, string> = { ...fileEnv };
202
340
  if (r.hubOrigin) env[HUB_ORIGIN_ENV] = r.hubOrigin;
203
- const envForSpawn = Object.keys(env).length > 0 ? env : undefined;
341
+ const spawnerOpts: { env?: Record<string, string>; cwd?: string } = {};
342
+ if (Object.keys(env).length > 0) spawnerOpts.env = env;
343
+ // Third-party modules ship clean relative startCmds — `cwd: installDir`
344
+ // makes those resolve. First-party fallbacks use absolute / PATH binaries
345
+ // so their cwd is irrelevant; passing it doesn't hurt.
346
+ if (entry.installDir) spawnerOpts.cwd = entry.installDir;
347
+ const passOpts =
348
+ spawnerOpts.env !== undefined || spawnerOpts.cwd !== undefined ? spawnerOpts : undefined;
204
349
  try {
205
- const pid = r.spawner.spawn(cmd, logFile, envForSpawn);
350
+ const pid = r.spawner.spawn(cmd, logFile, passOpts);
206
351
  writePid(short, pid, r.configDir);
207
352
  r.log(`✓ ${short} started (pid ${pid}); logs: ${logFile}`);
208
353
  if (r.hubOrigin) r.log(` ${HUB_ORIGIN_ENV}=${r.hubOrigin}`);
@@ -217,7 +362,8 @@ export async function start(svc: string | undefined, opts: LifecycleOpts = {}):
217
362
 
218
363
  export async function stop(svc: string | undefined, opts: LifecycleOpts = {}): Promise<number> {
219
364
  const r = resolve(opts);
220
- const picked = resolveTargets(svc, r.manifestPath);
365
+ if (svc === HUB_SVC) return stopHubSvc(r);
366
+ const picked = await resolveTargets(svc, r.manifestPath);
221
367
  if ("error" in picked) {
222
368
  r.log(picked.error);
223
369
  return 1;
@@ -272,8 +418,58 @@ export async function restart(svc: string | undefined, opts: LifecycleOpts = {})
272
418
  return await start(svc, opts);
273
419
  }
274
420
 
421
+ /**
422
+ * Start the internal hub. Delegates to `ensureHubRunning`, which owns the
423
+ * port-fallback probe, the port-file write, and the issuer flag — none of
424
+ * which fit a generic `SERVICE_SPECS` entry. The hub origin (when known)
425
+ * doubles as the OAuth `iss` claim, so we forward it as `issuer`.
426
+ *
427
+ * Silences `ensureHubRunning`'s own log and emits our own `✓ hub started …`
428
+ * line so the output matches the service-start shape (`✓ vault started
429
+ * (pid X); logs: …`) and `stopHubSvc`'s `✓ hub stopped.` symmetry.
430
+ */
431
+ async function startHubSvc(r: Resolved): Promise<number> {
432
+ const ensureOpts: EnsureHubOpts = { configDir: r.configDir, log: () => {} };
433
+ if (r.hubOrigin) ensureOpts.issuer = r.hubOrigin;
434
+ try {
435
+ const result = await r.ensureHub(ensureOpts);
436
+ if (result.started) {
437
+ const logFile = logPathFor(HUB_SVC, r.configDir);
438
+ r.log(`✓ hub started (pid ${result.pid}) on port ${result.port}; logs: ${logFile}`);
439
+ } else {
440
+ r.log(`hub already running (pid ${result.pid}) on port ${result.port}.`);
441
+ }
442
+ return 0;
443
+ } catch (err) {
444
+ r.log(`✗ hub failed to start: ${err instanceof Error ? err.message : String(err)}`);
445
+ return 1;
446
+ }
447
+ }
448
+
449
+ /**
450
+ * Stop the internal hub. `stopHub` returns false when nothing was running
451
+ * (no pidfile, or stale pidfile cleared) — that's a clean no-op for the
452
+ * operator, so we still exit 0.
453
+ */
454
+ async function stopHubSvc(r: Resolved): Promise<number> {
455
+ try {
456
+ const stopped = await r.stopHubFn({
457
+ configDir: r.configDir,
458
+ log: r.log,
459
+ killWaitMs: r.killWaitMs,
460
+ pollIntervalMs: r.pollIntervalMs,
461
+ });
462
+ r.log(stopped ? "✓ hub stopped." : "hub wasn't running.");
463
+ return 0;
464
+ } catch (err) {
465
+ r.log(`✗ hub failed to stop: ${err instanceof Error ? err.message : String(err)}`);
466
+ return 1;
467
+ }
468
+ }
469
+
275
470
  export interface LogsOpts {
276
471
  configDir?: string;
472
+ manifestPath?: string;
277
473
  log?: (line: string) => void;
278
474
  /** Tail stream — if omitted, uses `tail -n <lines> -f <file>` via spawn. */
279
475
  tailSpawner?: Spawner;
@@ -284,14 +480,23 @@ export interface LogsOpts {
284
480
 
285
481
  export async function logs(svc: string, opts: LogsOpts = {}): Promise<number> {
286
482
  const configDir = opts.configDir ?? CONFIG_DIR;
483
+ const manifestPath = opts.manifestPath ?? SERVICES_MANIFEST_PATH;
287
484
  const log = opts.log ?? ((line) => console.log(line));
288
485
  const lines = opts.lines ?? 200;
289
486
  const follow = opts.follow ?? false;
290
487
 
291
- const spec = getSpec(svc);
292
- if (!spec) {
293
- log(`unknown service "${svc}". known: ${knownServices().join(", ")}`);
294
- return 1;
488
+ // logs only needs a valid short name to find the log file. First-party
489
+ // wins via the spec lookup; third-party rows match by `entry.name`; the
490
+ // internal hub is a known short outside of services.json. We don't need
491
+ // the full spec here — we just need to confirm the name maps to
492
+ // something the CLI manages.
493
+ const isFirstParty = getSpec(svc) !== undefined;
494
+ if (!isFirstParty && svc !== HUB_SVC) {
495
+ const entry = readManifest(manifestPath).services.find((s) => s.name === svc);
496
+ if (!entry?.installDir) {
497
+ log(`unknown service "${svc}". known: ${[HUB_SVC, ...knownServices()].join(", ")}`);
498
+ return 1;
499
+ }
295
500
  }
296
501
 
297
502
  const path = logPathFor(svc, configDir);