@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.
- package/README.md +19 -17
- package/package.json +15 -4
- package/src/__tests__/admin-auth.test.ts +197 -0
- package/src/__tests__/admin-config.test.ts +281 -0
- package/src/__tests__/admin-grants.test.ts +271 -0
- package/src/__tests__/admin-handlers.test.ts +530 -0
- package/src/__tests__/admin-host-admin-token.test.ts +115 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
- package/src/__tests__/admin-vaults.test.ts +615 -0
- package/src/__tests__/auth-codes.test.ts +253 -0
- package/src/__tests__/auth.test.ts +1063 -17
- package/src/__tests__/cli.test.ts +50 -0
- package/src/__tests__/clients.test.ts +264 -0
- package/src/__tests__/cloudflare-state.test.ts +167 -7
- package/src/__tests__/csrf.test.ts +117 -0
- package/src/__tests__/expose-cloudflare.test.ts +232 -37
- package/src/__tests__/expose-off-auto.test.ts +15 -9
- package/src/__tests__/expose-public-auto.test.ts +153 -0
- package/src/__tests__/expose.test.ts +216 -24
- package/src/__tests__/grants.test.ts +164 -0
- package/src/__tests__/hub-db.test.ts +153 -0
- package/src/__tests__/hub-server.test.ts +984 -26
- package/src/__tests__/hub.test.ts +56 -49
- package/src/__tests__/install.test.ts +327 -3
- package/src/__tests__/jwks.test.ts +37 -0
- package/src/__tests__/jwt-sign.test.ts +361 -0
- package/src/__tests__/lifecycle.test.ts +616 -5
- package/src/__tests__/module-manifest.test.ts +183 -0
- package/src/__tests__/oauth-handlers.test.ts +3112 -0
- package/src/__tests__/oauth-ui.test.ts +253 -0
- package/src/__tests__/operator-token.test.ts +140 -0
- package/src/__tests__/providers-detect.test.ts +158 -0
- package/src/__tests__/scope-explanations.test.ts +108 -0
- package/src/__tests__/scope-registry.test.ts +220 -0
- package/src/__tests__/services-manifest.test.ts +137 -1
- package/src/__tests__/sessions.test.ts +116 -0
- package/src/__tests__/setup.test.ts +361 -0
- package/src/__tests__/signing-keys.test.ts +153 -0
- package/src/__tests__/upgrade.test.ts +541 -0
- package/src/__tests__/users.test.ts +154 -0
- package/src/__tests__/well-known.test.ts +127 -10
- package/src/admin-auth.ts +126 -0
- package/src/admin-config-ui.ts +534 -0
- package/src/admin-config.ts +226 -0
- package/src/admin-grants.ts +160 -0
- package/src/admin-handlers.ts +365 -0
- package/src/admin-host-admin-token.ts +83 -0
- package/src/admin-vault-admin-token.ts +98 -0
- package/src/admin-vaults.ts +359 -0
- package/src/auth-codes.ts +189 -0
- package/src/cli.ts +202 -25
- package/src/clients.ts +210 -0
- package/src/cloudflare/config.ts +25 -6
- package/src/cloudflare/state.ts +108 -28
- package/src/commands/auth.ts +851 -19
- package/src/commands/expose-cloudflare.ts +85 -45
- package/src/commands/expose-interactive.ts +20 -44
- package/src/commands/expose-off-auto.ts +27 -11
- package/src/commands/expose-public-auto.ts +179 -0
- package/src/commands/expose.ts +63 -32
- package/src/commands/install.ts +337 -48
- package/src/commands/lifecycle.ts +269 -38
- package/src/commands/setup.ts +366 -0
- package/src/commands/status.ts +4 -1
- package/src/commands/upgrade.ts +429 -0
- package/src/csrf.ts +101 -0
- package/src/grants.ts +142 -0
- package/src/help.ts +133 -19
- package/src/hub-control.ts +12 -0
- package/src/hub-db.ts +164 -0
- package/src/hub-server.ts +643 -22
- package/src/hub.ts +97 -390
- package/src/jwks.ts +41 -0
- package/src/jwt-audience.ts +40 -0
- package/src/jwt-sign.ts +275 -0
- package/src/module-manifest.ts +435 -0
- package/src/oauth-handlers.ts +1175 -0
- package/src/oauth-ui.ts +582 -0
- package/src/operator-token.ts +129 -0
- package/src/providers/detect.ts +97 -0
- package/src/scope-explanations.ts +137 -0
- package/src/scope-registry.ts +158 -0
- package/src/service-spec.ts +270 -97
- package/src/services-manifest.ts +57 -1
- package/src/sessions.ts +115 -0
- package/src/signing-keys.ts +120 -0
- package/src/users.ts +144 -0
- package/src/well-known.ts +62 -26
- package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
- package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
- 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 {
|
|
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 {
|
|
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,
|
|
55
|
+
spawn(cmd: readonly string[], logFile: string, opts?: SpawnerOptions): number;
|
|
32
56
|
}
|
|
33
57
|
|
|
34
58
|
export const defaultSpawner: Spawner = {
|
|
35
|
-
spawn(cmd, logFile,
|
|
59
|
+
spawn(cmd, logFile, opts) {
|
|
36
60
|
const fd = openSync(logFile, "a");
|
|
37
|
-
const
|
|
61
|
+
const spawnOpts: Parameters<typeof Bun.spawn>[1] = {
|
|
38
62
|
stdio: ["ignore", fd, fd],
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
126
|
-
*
|
|
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:
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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 {
|
|
294
|
+
return {
|
|
295
|
+
error: `unknown service "${svc}". known: ${knownServices().join(", ")}`,
|
|
296
|
+
};
|
|
151
297
|
}
|
|
152
298
|
|
|
153
|
-
const targets:
|
|
299
|
+
const targets: ResolvedTarget[] = [];
|
|
154
300
|
for (const entry of manifest.services) {
|
|
155
301
|
const short = shortNameForManifest(entry.name);
|
|
156
|
-
if (
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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);
|