@openparachute/hub 0.3.0-rc.1 → 0.5.1

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 (91) 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 +1063 -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 +616 -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 +851 -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 +269 -38
  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-audience.ts +40 -0
  75. package/src/jwt-sign.ts +275 -0
  76. package/src/module-manifest.ts +435 -0
  77. package/src/oauth-handlers.ts +1175 -0
  78. package/src/oauth-ui.ts +582 -0
  79. package/src/operator-token.ts +129 -0
  80. package/src/providers/detect.ts +97 -0
  81. package/src/scope-explanations.ts +137 -0
  82. package/src/scope-registry.ts +158 -0
  83. package/src/service-spec.ts +270 -97
  84. package/src/services-manifest.ts +57 -1
  85. package/src/sessions.ts +115 -0
  86. package/src/signing-keys.ts +120 -0
  87. package/src/users.ts +144 -0
  88. package/src/well-known.ts +62 -26
  89. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  90. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  91. 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,107 @@ 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 — `start` prints the actionable "no installDir"
218
+ * re-install message for an installDir-less third-party row, or
219
+ * "lifecycle not yet supported" otherwise; `stop`/`logs` keep working
220
+ * via pidfile/logfile semantics keyed by `short`.
221
+ */
222
+ spec: ServiceSpec | undefined;
223
+ }
224
+
225
+ async function specForEntry(
226
+ short: string,
227
+ entry: ServiceEntry,
228
+ ): Promise<{ spec: ServiceSpec | undefined; error?: string }> {
229
+ const firstParty = getSpec(short);
230
+ if (firstParty) return { spec: firstParty };
231
+ if (!entry.installDir) return { spec: undefined };
232
+ try {
233
+ const spec = await getSpecFromInstallDir(entry.installDir, entry.name);
234
+ return { spec: spec ?? undefined };
235
+ } catch (err) {
236
+ if (err instanceof ModuleManifestError) {
237
+ return { spec: undefined, error: err.message };
238
+ }
239
+ throw err;
240
+ }
241
+ }
242
+
123
243
  /**
124
244
  * 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.
245
+ * manageable service (first-party shortnames OR third-party rows that
246
+ * carry `installDir`). Unknown names get a friendly error up front rather
247
+ * than a confusing spawn failure downstream.
248
+ *
249
+ * Third-party modules are addressed by the `name` field from their
250
+ * `module.json` (which is what install copied to `entry.name` for
251
+ * third-party). First-party are addressed by their short name (vault,
252
+ * notes, …) and matched via `shortNameForManifest`.
253
+ *
254
+ * Named-path detail: a third-party row whose name matches but lacks
255
+ * `installDir` resolves to the entry with `spec: undefined` (rather than
256
+ * an "unknown service" error). `stop`/`logs` handle the spec-less case
257
+ * via pidfile/logfile semantics; `start` surfaces an actionable
258
+ * re-install hint downstream. The genuinely-unknown path (no first-party
259
+ * fallback AND no row in services.json) still errors as `unknown service`.
127
260
  */
128
- function resolveTargets(
261
+ async function resolveTargets(
129
262
  svc: string | undefined,
130
263
  manifestPath: string,
131
- ): { targets: Array<{ short: string; entry: ServiceEntry }> } | { error: string } {
264
+ ): Promise<{ targets: ResolvedTarget[] } | { error: string }> {
132
265
  const manifest = readManifest(manifestPath);
133
266
  if (manifest.services.length === 0) {
134
267
  return { error: "No services installed yet. Try: parachute install vault" };
135
268
  }
136
269
 
137
270
  if (svc !== undefined) {
138
- const spec = getSpec(svc);
139
- if (!spec) {
140
- return {
141
- error: `unknown service "${svc}". known: ${knownServices().join(", ")}`,
142
- };
271
+ // Try first-party (svc is a short name → known fallback).
272
+ const firstPartySpec = getSpec(svc);
273
+ if (firstPartySpec) {
274
+ const entry = manifest.services.find((s) => s.name === firstPartySpec.manifestName);
275
+ if (!entry) {
276
+ return { error: `${svc} isn't installed. Run \`parachute install ${svc}\` first.` };
277
+ }
278
+ return { targets: [{ short: svc, entry, spec: firstPartySpec }] };
143
279
  }
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
- };
280
+ // Third-party: match a services.json row by name. Rows with `installDir`
281
+ // resolve a full spec from the on-disk module.json. Rows without it are
282
+ // still managed (stop/logs use pidfile/logfile semantics keyed by short
283
+ // name), but with `spec: undefined` `start` will surface an
284
+ // installDir-specific error downstream rather than reject up front.
285
+ const entry = manifest.services.find((s) => s.name === svc);
286
+ if (entry) {
287
+ if (entry.installDir) {
288
+ const { spec, error } = await specForEntry(svc, entry);
289
+ if (error) return { error: `${svc}: invalid module.json — ${error}` };
290
+ return { targets: [{ short: svc, entry, spec }] };
291
+ }
292
+ return { targets: [{ short: svc, entry, spec: undefined }] };
149
293
  }
150
- return { targets: [{ short: svc, entry }] };
294
+ return {
295
+ error: `unknown service "${svc}". known: ${knownServices().join(", ")}`,
296
+ };
151
297
  }
152
298
 
153
- const targets: Array<{ short: string; entry: ServiceEntry }> = [];
299
+ const targets: ResolvedTarget[] = [];
154
300
  for (const entry of manifest.services) {
155
301
  const short = shortNameForManifest(entry.name);
156
- if (!short) continue;
157
- targets.push({ short, entry });
302
+ if (short) {
303
+ const spec = getSpec(short);
304
+ targets.push({ short, entry, spec });
305
+ continue;
306
+ }
307
+ if (entry.installDir) {
308
+ const { spec } = await specForEntry(entry.name, entry);
309
+ targets.push({ short: entry.name, entry, spec });
310
+ }
158
311
  }
159
312
  if (targets.length === 0) {
160
313
  return { error: "No manageable services in services.json." };
@@ -164,14 +317,15 @@ function resolveTargets(
164
317
 
165
318
  export async function start(svc: string | undefined, opts: LifecycleOpts = {}): Promise<number> {
166
319
  const r = resolve(opts);
167
- const picked = resolveTargets(svc, r.manifestPath);
320
+ if (svc === HUB_SVC) return startHubSvc(r);
321
+ const picked = await resolveTargets(svc, r.manifestPath);
168
322
  if ("error" in picked) {
169
323
  r.log(picked.error);
170
324
  return 1;
171
325
  }
172
326
 
173
327
  let failures = 0;
174
- for (const { short, entry } of picked.targets) {
328
+ for (const { short, entry, spec } of picked.targets) {
175
329
  const state = processState(short, r.configDir, r.alive);
176
330
  if (state.status === "running") {
177
331
  r.log(`${short} already running (pid ${state.pid}).`);
@@ -182,10 +336,19 @@ export async function start(svc: string | undefined, opts: LifecycleOpts = {}):
182
336
  clearPid(short, r.configDir);
183
337
  }
184
338
 
185
- const spec = getSpec(short);
186
339
  const cmd = spec?.startCmd?.(entry);
187
340
  if (!cmd || cmd.length === 0) {
188
- r.log(`${short}: lifecycle not yet supported for this service.`);
341
+ // Distinguish the missing-installDir case from "spec resolved but has
342
+ // no startCmd" — the former is fixable by re-registering the module,
343
+ // the latter is a hub-level limitation. Third-party rows hit the first
344
+ // branch when their self-registration predates the installDir contract.
345
+ if (!getSpec(short) && !entry.installDir) {
346
+ r.log(
347
+ `${short}: services.json entry has no installDir, so the start command can't be resolved. Re-run \`parachute install <path-to-${short}>\` to refresh its registration, or upgrade the module to a version that self-registers with installDir.`,
348
+ );
349
+ } else {
350
+ r.log(`${short}: lifecycle not yet supported for this service.`);
351
+ }
189
352
  failures++;
190
353
  continue;
191
354
  }
@@ -200,9 +363,16 @@ export async function start(svc: string | undefined, opts: LifecycleOpts = {}):
200
363
  const fileEnv = readEnvFileValues(join(r.configDir, short, ".env"));
201
364
  const env: Record<string, string> = { ...fileEnv };
202
365
  if (r.hubOrigin) env[HUB_ORIGIN_ENV] = r.hubOrigin;
203
- const envForSpawn = Object.keys(env).length > 0 ? env : undefined;
366
+ const spawnerOpts: { env?: Record<string, string>; cwd?: string } = {};
367
+ if (Object.keys(env).length > 0) spawnerOpts.env = env;
368
+ // Third-party modules ship clean relative startCmds — `cwd: installDir`
369
+ // makes those resolve. First-party fallbacks use absolute / PATH binaries
370
+ // so their cwd is irrelevant; passing it doesn't hurt.
371
+ if (entry.installDir) spawnerOpts.cwd = entry.installDir;
372
+ const passOpts =
373
+ spawnerOpts.env !== undefined || spawnerOpts.cwd !== undefined ? spawnerOpts : undefined;
204
374
  try {
205
- const pid = r.spawner.spawn(cmd, logFile, envForSpawn);
375
+ const pid = r.spawner.spawn(cmd, logFile, passOpts);
206
376
  writePid(short, pid, r.configDir);
207
377
  r.log(`✓ ${short} started (pid ${pid}); logs: ${logFile}`);
208
378
  if (r.hubOrigin) r.log(` ${HUB_ORIGIN_ENV}=${r.hubOrigin}`);
@@ -217,7 +387,8 @@ export async function start(svc: string | undefined, opts: LifecycleOpts = {}):
217
387
 
218
388
  export async function stop(svc: string | undefined, opts: LifecycleOpts = {}): Promise<number> {
219
389
  const r = resolve(opts);
220
- const picked = resolveTargets(svc, r.manifestPath);
390
+ if (svc === HUB_SVC) return stopHubSvc(r);
391
+ const picked = await resolveTargets(svc, r.manifestPath);
221
392
  if ("error" in picked) {
222
393
  r.log(picked.error);
223
394
  return 1;
@@ -272,8 +443,58 @@ export async function restart(svc: string | undefined, opts: LifecycleOpts = {})
272
443
  return await start(svc, opts);
273
444
  }
274
445
 
446
+ /**
447
+ * Start the internal hub. Delegates to `ensureHubRunning`, which owns the
448
+ * port-fallback probe, the port-file write, and the issuer flag — none of
449
+ * which fit a generic `SERVICE_SPECS` entry. The hub origin (when known)
450
+ * doubles as the OAuth `iss` claim, so we forward it as `issuer`.
451
+ *
452
+ * Silences `ensureHubRunning`'s own log and emits our own `✓ hub started …`
453
+ * line so the output matches the service-start shape (`✓ vault started
454
+ * (pid X); logs: …`) and `stopHubSvc`'s `✓ hub stopped.` symmetry.
455
+ */
456
+ async function startHubSvc(r: Resolved): Promise<number> {
457
+ const ensureOpts: EnsureHubOpts = { configDir: r.configDir, log: () => {} };
458
+ if (r.hubOrigin) ensureOpts.issuer = r.hubOrigin;
459
+ try {
460
+ const result = await r.ensureHub(ensureOpts);
461
+ if (result.started) {
462
+ const logFile = logPathFor(HUB_SVC, r.configDir);
463
+ r.log(`✓ hub started (pid ${result.pid}) on port ${result.port}; logs: ${logFile}`);
464
+ } else {
465
+ r.log(`hub already running (pid ${result.pid}) on port ${result.port}.`);
466
+ }
467
+ return 0;
468
+ } catch (err) {
469
+ r.log(`✗ hub failed to start: ${err instanceof Error ? err.message : String(err)}`);
470
+ return 1;
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Stop the internal hub. `stopHub` returns false when nothing was running
476
+ * (no pidfile, or stale pidfile cleared) — that's a clean no-op for the
477
+ * operator, so we still exit 0.
478
+ */
479
+ async function stopHubSvc(r: Resolved): Promise<number> {
480
+ try {
481
+ const stopped = await r.stopHubFn({
482
+ configDir: r.configDir,
483
+ log: r.log,
484
+ killWaitMs: r.killWaitMs,
485
+ pollIntervalMs: r.pollIntervalMs,
486
+ });
487
+ r.log(stopped ? "✓ hub stopped." : "hub wasn't running.");
488
+ return 0;
489
+ } catch (err) {
490
+ r.log(`✗ hub failed to stop: ${err instanceof Error ? err.message : String(err)}`);
491
+ return 1;
492
+ }
493
+ }
494
+
275
495
  export interface LogsOpts {
276
496
  configDir?: string;
497
+ manifestPath?: string;
277
498
  log?: (line: string) => void;
278
499
  /** Tail stream — if omitted, uses `tail -n <lines> -f <file>` via spawn. */
279
500
  tailSpawner?: Spawner;
@@ -284,14 +505,24 @@ export interface LogsOpts {
284
505
 
285
506
  export async function logs(svc: string, opts: LogsOpts = {}): Promise<number> {
286
507
  const configDir = opts.configDir ?? CONFIG_DIR;
508
+ const manifestPath = opts.manifestPath ?? SERVICES_MANIFEST_PATH;
287
509
  const log = opts.log ?? ((line) => console.log(line));
288
510
  const lines = opts.lines ?? 200;
289
511
  const follow = opts.follow ?? false;
290
512
 
291
- const spec = getSpec(svc);
292
- if (!spec) {
293
- log(`unknown service "${svc}". known: ${knownServices().join(", ")}`);
294
- return 1;
513
+ // logs only needs a valid short name to find the log file. First-party
514
+ // wins via the spec lookup; third-party rows match by `entry.name`; the
515
+ // internal hub is a known short outside of services.json. installDir is
516
+ // irrelevant here — the log file is keyed by short name and exists once
517
+ // the service has run, regardless of how it was registered. We just need
518
+ // to confirm the name maps to something the CLI manages.
519
+ const isFirstParty = getSpec(svc) !== undefined;
520
+ if (!isFirstParty && svc !== HUB_SVC) {
521
+ const entry = readManifest(manifestPath).services.find((s) => s.name === svc);
522
+ if (!entry) {
523
+ log(`unknown service "${svc}". known: ${[HUB_SVC, ...knownServices()].join(", ")}`);
524
+ return 1;
525
+ }
295
526
  }
296
527
 
297
528
  const path = logPathFor(svc, configDir);