@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.
- 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 +712 -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 +519 -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 +652 -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 +242 -37
- 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-sign.ts +275 -0
- package/src/module-manifest.ts +435 -0
- package/src/oauth-handlers.ts +1206 -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,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
|
-
*
|
|
126
|
-
*
|
|
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:
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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 {
|
|
279
|
+
return {
|
|
280
|
+
error: `unknown service "${svc}". known: ${knownServices().join(", ")}`,
|
|
281
|
+
};
|
|
151
282
|
}
|
|
152
283
|
|
|
153
|
-
const targets:
|
|
284
|
+
const targets: ResolvedTarget[] = [];
|
|
154
285
|
for (const entry of manifest.services) {
|
|
155
286
|
const short = shortNameForManifest(entry.name);
|
|
156
|
-
if (
|
|
157
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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);
|