@openparachute/hub 0.5.7 → 0.5.10-rc.10
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/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +70 -323
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-modules-ops.test.ts +658 -0
- package/src/__tests__/api-modules.test.ts +426 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/csrf.test.ts +40 -1
- package/src/__tests__/expose-2fa-warning.test.ts +3 -5
- package/src/__tests__/expose-cloudflare.test.ts +1 -1
- package/src/__tests__/expose.test.ts +2 -2
- package/src/__tests__/hub-server.test.ts +584 -67
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +123 -53
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/oauth-handlers.test.ts +522 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/request-protocol.test.ts +54 -0
- package/src/__tests__/serve-boot.test.ts +193 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/sessions.test.ts +25 -2
- package/src/__tests__/setup-gate.test.ts +222 -0
- package/src/__tests__/setup-wizard.test.ts +2089 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +482 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/vault-name.test.ts +79 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +37 -254
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-modules-ops.ts +585 -0
- package/src/api-modules.ts +367 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/cli.ts +28 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +6 -6
- package/src/commands/serve-boot.ts +133 -0
- package/src/commands/serve.ts +214 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +34 -13
- package/src/help.ts +55 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +87 -0
- package/src/hub-server.ts +767 -136
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +298 -150
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/oauth-handlers.ts +262 -56
- package/src/oauth-ui.ts +23 -2
- package/src/operator-token.ts +349 -18
- package/src/origin-check.ts +127 -0
- package/src/rate-limit.ts +5 -2
- package/src/request-protocol.ts +48 -0
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +30 -18
- package/src/setup-wizard.ts +2009 -0
- package/src/supervisor.ts +411 -0
- package/src/vault-name.ts +71 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
- package/web/ui/dist/assets/index-CP07NbdF.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Container-mode module boot helpers, separated from `serve.ts` so
|
|
3
|
+
* tests can drive the supervisor wiring without standing up
|
|
4
|
+
* `Bun.serve`.
|
|
5
|
+
*
|
|
6
|
+
* `bootSupervisedModules` reads services.json, resolves each
|
|
7
|
+
* first-party module's `startCmd` from `SERVICE_SPECS`, and calls
|
|
8
|
+
* `Supervisor.start` for each. Third-party modules with `installDir`
|
|
9
|
+
* but no first-party fallback are also picked up via the same
|
|
10
|
+
* `getSpecFromInstallDir` path that `commands/lifecycle.ts` uses, so
|
|
11
|
+
* a hub-installed `@third-party/foo` boots the same way `vault` does.
|
|
12
|
+
*
|
|
13
|
+
* Idempotent: re-calling boot when modules are already running is a
|
|
14
|
+
* no-op (supervisor's own idempotent `start`). Missing-startCmd rows
|
|
15
|
+
* are logged + skipped, not fatal — the operator may have installed a
|
|
16
|
+
* module that doesn't expose a daemon (e.g. CLI-only).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { readEnvFileValues } from "../env-file.ts";
|
|
21
|
+
import { HUB_ORIGIN_ENV } from "../hub-origin.ts";
|
|
22
|
+
import { ModuleManifestError } from "../module-manifest.ts";
|
|
23
|
+
import {
|
|
24
|
+
type ServiceSpec,
|
|
25
|
+
getSpec,
|
|
26
|
+
getSpecFromInstallDir,
|
|
27
|
+
shortNameForManifest,
|
|
28
|
+
} from "../service-spec.ts";
|
|
29
|
+
import { type ServiceEntry, readManifest } from "../services-manifest.ts";
|
|
30
|
+
import type { Supervisor } from "../supervisor.ts";
|
|
31
|
+
|
|
32
|
+
export interface BootOpts {
|
|
33
|
+
/** Path to services.json. */
|
|
34
|
+
readonly manifestPath: string;
|
|
35
|
+
/** Config dir ($PARACHUTE_HOME). Used to read per-module .env. */
|
|
36
|
+
readonly configDir: string;
|
|
37
|
+
/** Canonical OAuth issuer / hub origin. Forwarded to child env as PARACHUTE_HUB_ORIGIN. */
|
|
38
|
+
readonly hubOrigin?: string;
|
|
39
|
+
/** Logger seam. */
|
|
40
|
+
readonly log?: (line: string) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface BootedModule {
|
|
44
|
+
readonly short: string;
|
|
45
|
+
readonly entryName: string;
|
|
46
|
+
readonly status: "started" | "skipped";
|
|
47
|
+
readonly reason?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Walk services.json, spawn every manageable module via the
|
|
52
|
+
* supervisor. Returns a per-module decision log so the caller can
|
|
53
|
+
* surface a startup summary.
|
|
54
|
+
*/
|
|
55
|
+
export async function bootSupervisedModules(
|
|
56
|
+
supervisor: Supervisor,
|
|
57
|
+
opts: BootOpts,
|
|
58
|
+
): Promise<BootedModule[]> {
|
|
59
|
+
const log = opts.log ?? (() => {});
|
|
60
|
+
const manifest = readManifest(opts.manifestPath);
|
|
61
|
+
const results: BootedModule[] = [];
|
|
62
|
+
|
|
63
|
+
for (const entry of manifest.services) {
|
|
64
|
+
const short = shortNameForManifest(entry.name) ?? entry.name;
|
|
65
|
+
const spec = await resolveSpec(short, entry);
|
|
66
|
+
if (!spec) {
|
|
67
|
+
// Row exists but no first-party fallback and no installDir-derived
|
|
68
|
+
// manifest. `parachute start` would print the same hint; the
|
|
69
|
+
// container path just logs + carries on.
|
|
70
|
+
log(`[supervisor] ${short}: no startCmd resolvable (services.json entry exists, no spec).`);
|
|
71
|
+
results.push({
|
|
72
|
+
short,
|
|
73
|
+
entryName: entry.name,
|
|
74
|
+
status: "skipped",
|
|
75
|
+
reason: "no-spec",
|
|
76
|
+
});
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const cmd = spec.startCmd?.(entry);
|
|
81
|
+
if (!cmd || cmd.length === 0) {
|
|
82
|
+
log(`[supervisor] ${short}: spec resolved but no startCmd — skipping (CLI-only module).`);
|
|
83
|
+
results.push({
|
|
84
|
+
short,
|
|
85
|
+
entryName: entry.name,
|
|
86
|
+
status: "skipped",
|
|
87
|
+
reason: "no-start-cmd",
|
|
88
|
+
});
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Per-module .env layers under HUB_ORIGIN (hub-origin wins on
|
|
93
|
+
// collision — it's the canonical issuer source, and a stale .env
|
|
94
|
+
// shouldn't override the live `parachute serve` env).
|
|
95
|
+
const fileEnv = readEnvFileValues(join(opts.configDir, short, ".env"));
|
|
96
|
+
const env: Record<string, string> = { ...fileEnv };
|
|
97
|
+
if (opts.hubOrigin) env[HUB_ORIGIN_ENV] = opts.hubOrigin;
|
|
98
|
+
|
|
99
|
+
const req: {
|
|
100
|
+
short: string;
|
|
101
|
+
cmd: readonly string[];
|
|
102
|
+
cwd?: string;
|
|
103
|
+
env?: Record<string, string>;
|
|
104
|
+
} = {
|
|
105
|
+
short,
|
|
106
|
+
cmd,
|
|
107
|
+
};
|
|
108
|
+
// Third-party modules ship clean relative startCmds — cwd:
|
|
109
|
+
// installDir makes them resolve. First-party fallbacks use
|
|
110
|
+
// absolute / PATH binaries so cwd is a no-op there.
|
|
111
|
+
if (entry.installDir) req.cwd = entry.installDir;
|
|
112
|
+
if (Object.keys(env).length > 0) req.env = env;
|
|
113
|
+
|
|
114
|
+
await supervisor.start(req);
|
|
115
|
+
log(`[supervisor] ${short}: started (cmd=${cmd.join(" ")}).`);
|
|
116
|
+
results.push({ short, entryName: entry.name, status: "started" });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return results;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function resolveSpec(short: string, entry: ServiceEntry): Promise<ServiceSpec | undefined> {
|
|
123
|
+
const firstParty = getSpec(short);
|
|
124
|
+
if (firstParty) return firstParty;
|
|
125
|
+
if (!entry.installDir) return undefined;
|
|
126
|
+
try {
|
|
127
|
+
const spec = await getSpecFromInstallDir(entry.installDir, entry.name);
|
|
128
|
+
return spec ?? undefined;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
if (err instanceof ModuleManifestError) return undefined;
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `parachute serve` — long-running hub HTTP server, foregrounded.
|
|
3
|
+
*
|
|
4
|
+
* The on-box CLI flow (`parachute expose`) spawns hub-server detached and
|
|
5
|
+
* tracks it via pidfile. Container hosts (Docker, Render) need the inverse
|
|
6
|
+
* shape: the hub process IS PID 1 of the container, lives in the
|
|
7
|
+
* foreground, and exits with the container.
|
|
8
|
+
*
|
|
9
|
+
* This subcommand wires that path:
|
|
10
|
+
*
|
|
11
|
+
* - Reads `PORT` (default 1939) and `PARACHUTE_HUB_ORIGIN` (the canonical
|
|
12
|
+
* public origin Render exposes via custom domain) from env.
|
|
13
|
+
* - Auto-writes `hub.html` into `~/.parachute/well-known/` so `/` serves a
|
|
14
|
+
* real discovery page on a fresh disk without the operator having to
|
|
15
|
+
* run `parachute expose` first.
|
|
16
|
+
* - Seeds an initial admin from `PARACHUTE_INITIAL_ADMIN_USERNAME` +
|
|
17
|
+
* `PARACHUTE_INITIAL_ADMIN_PASSWORD` on first boot when no admin
|
|
18
|
+
* exists, so the wizard isn't a hard precondition.
|
|
19
|
+
* - Starts the hub-server fetch loop bound to `0.0.0.0` (container hosts
|
|
20
|
+
* need to accept the platform's HTTP forwarder, not just localhost).
|
|
21
|
+
*
|
|
22
|
+
* Stays out of pidfile/log-rotation logic — those are for the detached
|
|
23
|
+
* `parachute start hub` flow. A container supervisor (Docker, systemd,
|
|
24
|
+
* Render) owns process lifecycle; this command only owns the fetch loop.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
// NOTE: CONFIG_DIR/WELL_KNOWN_DIR/SERVICES_MANIFEST_PATH are evaluated at
|
|
30
|
+
// import time from process.env.PARACHUTE_HOME. The `env` parameter on
|
|
31
|
+
// `serve()` cannot reroute them — set PARACHUTE_HOME before importing for
|
|
32
|
+
// path isolation.
|
|
33
|
+
import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
|
|
34
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
35
|
+
import { hubFetch } from "../hub-server.ts";
|
|
36
|
+
import { writeHubFile } from "../hub.ts";
|
|
37
|
+
import { Supervisor } from "../supervisor.ts";
|
|
38
|
+
import { createUser, userCount } from "../users.ts";
|
|
39
|
+
import { WELL_KNOWN_DIR } from "../well-known.ts";
|
|
40
|
+
import { bootSupervisedModules } from "./serve-boot.ts";
|
|
41
|
+
|
|
42
|
+
export interface ServeOpts {
|
|
43
|
+
/** Override PORT (test-only). Real callers thread env via process.env. */
|
|
44
|
+
port?: number;
|
|
45
|
+
/** Override PARACHUTE_HUB_ORIGIN (test-only). */
|
|
46
|
+
issuer?: string;
|
|
47
|
+
/** Override the env source (test-only). */
|
|
48
|
+
env?: NodeJS.ProcessEnv;
|
|
49
|
+
/** Logger seam (test-only). */
|
|
50
|
+
log?: (line: string) => void;
|
|
51
|
+
/**
|
|
52
|
+
* Inject a pre-built Supervisor (test-only). Production constructs
|
|
53
|
+
* one internally with default options; tests pass in a Supervisor
|
|
54
|
+
* with stubbed spawn/sleep/now so the boot path doesn't try to
|
|
55
|
+
* `Bun.spawn` real children.
|
|
56
|
+
*/
|
|
57
|
+
supervisor?: Supervisor;
|
|
58
|
+
/** Skip the services.json boot pass (test-only). */
|
|
59
|
+
skipModuleBoot?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ServeResult {
|
|
63
|
+
port: number;
|
|
64
|
+
issuer?: string;
|
|
65
|
+
/**
|
|
66
|
+
* "seeded" — initial admin created from env vars.
|
|
67
|
+
* "exists" — admin row already present, env vars ignored.
|
|
68
|
+
* "needs-setup" — no admin and no env-var seed; wizard mode (the
|
|
69
|
+
* setup-placeholder redirect in hub-server.ts takes over).
|
|
70
|
+
*/
|
|
71
|
+
adminBootstrap: "seeded" | "exists" | "needs-setup";
|
|
72
|
+
/** The supervisor instance — exposed so callers (tests) can introspect / drive it. */
|
|
73
|
+
supervisor: Supervisor;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const DEFAULT_PORT = 1939;
|
|
77
|
+
|
|
78
|
+
function parsePort(raw: string | undefined): number | undefined {
|
|
79
|
+
if (raw === undefined || raw === "") return undefined;
|
|
80
|
+
const n = Number.parseInt(raw, 10);
|
|
81
|
+
if (!Number.isInteger(n) || n <= 0 || n > 65535) {
|
|
82
|
+
throw new Error(`PORT must be 1..65535, got "${raw}"`);
|
|
83
|
+
}
|
|
84
|
+
return n;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Seed the initial admin from env vars when no admin exists. Returns the
|
|
89
|
+
* bootstrap state so the caller can log it for operator visibility.
|
|
90
|
+
*
|
|
91
|
+
* Boot-time idempotent: if an admin already exists, we leave it alone —
|
|
92
|
+
* `PARACHUTE_INITIAL_ADMIN_*` is a first-boot seed, not a reset switch.
|
|
93
|
+
* That keeps a container restart with the env vars still set from
|
|
94
|
+
* clobbering an admin who has since rotated their password.
|
|
95
|
+
*/
|
|
96
|
+
export async function seedInitialAdminIfNeeded(
|
|
97
|
+
db: ReturnType<typeof openHubDb>,
|
|
98
|
+
env: NodeJS.ProcessEnv,
|
|
99
|
+
log: (line: string) => void = () => {},
|
|
100
|
+
): Promise<"seeded" | "exists" | "needs-setup"> {
|
|
101
|
+
if (userCount(db) > 0) return "exists";
|
|
102
|
+
const username = env.PARACHUTE_INITIAL_ADMIN_USERNAME?.trim();
|
|
103
|
+
const password = env.PARACHUTE_INITIAL_ADMIN_PASSWORD;
|
|
104
|
+
if (!username || !password) return "needs-setup";
|
|
105
|
+
await createUser(db, username, password);
|
|
106
|
+
log(`parachute serve: seeded initial admin "${username}" from PARACHUTE_INITIAL_ADMIN_*`);
|
|
107
|
+
return "seeded";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Run the hub fetch loop in the foreground. Resolves when `Bun.serve` is
|
|
112
|
+
* bound; the returned `stop()` shuts the server down for tests.
|
|
113
|
+
*
|
|
114
|
+
* The CLI dispatcher calls this without awaiting completion — `Bun.serve`
|
|
115
|
+
* runs the listener and the runtime keeps the process alive until a
|
|
116
|
+
* signal. Tests pass `port: 0` so the kernel picks an ephemeral port.
|
|
117
|
+
*/
|
|
118
|
+
export async function serve(opts: ServeOpts = {}): Promise<{
|
|
119
|
+
result: ServeResult;
|
|
120
|
+
stop: () => Promise<void>;
|
|
121
|
+
}> {
|
|
122
|
+
const env = opts.env ?? process.env;
|
|
123
|
+
const log = opts.log ?? ((line) => console.log(line));
|
|
124
|
+
|
|
125
|
+
const envPort = parsePort(env.PORT);
|
|
126
|
+
const port = opts.port ?? envPort ?? DEFAULT_PORT;
|
|
127
|
+
const issuer = (opts.issuer ?? env.PARACHUTE_HUB_ORIGIN)?.replace(/\/+$/, "") || undefined;
|
|
128
|
+
// Containers default to 0.0.0.0 so the platform's HTTP forwarder can
|
|
129
|
+
// reach us; the `--hostname` flag / PARACHUTE_BIND_HOST is the escape
|
|
130
|
+
// hatch for setups that want loopback-only inside a sidecar.
|
|
131
|
+
const hostname = env.PARACHUTE_BIND_HOST || "0.0.0.0";
|
|
132
|
+
|
|
133
|
+
// Ensure the well-known dir exists, and seed a static hub.html so `/`
|
|
134
|
+
// serves something coherent on a fresh disk (the dynamic path through
|
|
135
|
+
// `hubFetch` takes over once a DB row exists; the disk file is the
|
|
136
|
+
// signed-out fallback).
|
|
137
|
+
if (!existsSync(WELL_KNOWN_DIR)) mkdirSync(WELL_KNOWN_DIR, { recursive: true });
|
|
138
|
+
const hubHtmlPath = join(WELL_KNOWN_DIR, "hub.html");
|
|
139
|
+
if (!existsSync(hubHtmlPath)) writeHubFile(hubHtmlPath);
|
|
140
|
+
|
|
141
|
+
const dbPath = hubDbPath();
|
|
142
|
+
const db = openHubDb(dbPath);
|
|
143
|
+
const adminBootstrap = await seedInitialAdminIfNeeded(db, env, log);
|
|
144
|
+
|
|
145
|
+
if (adminBootstrap === "needs-setup") {
|
|
146
|
+
log(
|
|
147
|
+
"parachute serve: no admin account configured. Set PARACHUTE_INITIAL_ADMIN_USERNAME + PARACHUTE_INITIAL_ADMIN_PASSWORD, or visit /admin/setup once the hub is reachable.",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const supervisor = opts.supervisor ?? new Supervisor();
|
|
152
|
+
|
|
153
|
+
// Boot already-installed modules from services.json. In a container,
|
|
154
|
+
// this is the path that re-spawns vault / notes / scribe after a
|
|
155
|
+
// restart — the persistent disk preserved both the install (in
|
|
156
|
+
// `$BUN_INSTALL/install/global/node_modules`) and the row that says
|
|
157
|
+
// "this module is registered + active." Idempotent: the supervisor
|
|
158
|
+
// skips modules that are already running.
|
|
159
|
+
if (!opts.skipModuleBoot) {
|
|
160
|
+
try {
|
|
161
|
+
const booted = await bootSupervisedModules(supervisor, {
|
|
162
|
+
manifestPath: SERVICES_MANIFEST_PATH,
|
|
163
|
+
configDir: CONFIG_DIR,
|
|
164
|
+
...(issuer !== undefined ? { hubOrigin: issuer } : {}),
|
|
165
|
+
log,
|
|
166
|
+
});
|
|
167
|
+
const startedCount = booted.filter((b) => b.status === "started").length;
|
|
168
|
+
if (startedCount > 0) {
|
|
169
|
+
log(`parachute serve: supervisor booted ${startedCount} module(s) from services.json.`);
|
|
170
|
+
}
|
|
171
|
+
} catch (err) {
|
|
172
|
+
// A malformed services.json or a single module-spec read failure
|
|
173
|
+
// shouldn't keep the hub HTTP server from coming up — the
|
|
174
|
+
// operator can still hit /admin/modules to remediate.
|
|
175
|
+
log(
|
|
176
|
+
`parachute serve: module boot failed (${err instanceof Error ? err.message : String(err)}). The hub HTTP server is still starting; visit /admin/modules to remediate.`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const server = Bun.serve({
|
|
182
|
+
port,
|
|
183
|
+
hostname,
|
|
184
|
+
fetch: hubFetch(WELL_KNOWN_DIR, {
|
|
185
|
+
getDb: () => db,
|
|
186
|
+
issuer,
|
|
187
|
+
loopbackPort: port,
|
|
188
|
+
supervisor,
|
|
189
|
+
}),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
log(
|
|
193
|
+
`parachute serve: listening on http://${hostname}:${port} (PARACHUTE_HOME=${CONFIG_DIR}, db=${dbPath}, issuer=${issuer ?? "<request-origin>"}, admin=${adminBootstrap})`,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
result: {
|
|
198
|
+
port,
|
|
199
|
+
...(issuer !== undefined ? { issuer } : {}),
|
|
200
|
+
adminBootstrap,
|
|
201
|
+
supervisor,
|
|
202
|
+
},
|
|
203
|
+
stop: async () => {
|
|
204
|
+
// Stop supervised children first so they get a clean SIGTERM
|
|
205
|
+
// before the HTTP server (which they may depend on for hub-issued
|
|
206
|
+
// tokens) goes away.
|
|
207
|
+
for (const state of supervisor.list()) {
|
|
208
|
+
await supervisor.stop(state.short);
|
|
209
|
+
}
|
|
210
|
+
await server.stop();
|
|
211
|
+
db.close();
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
package/src/commands/status.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
|
|
2
2
|
import { HUB_SVC, readHubPort } from "../hub-control.ts";
|
|
3
|
+
import {
|
|
4
|
+
type DetectInstallSourceDeps,
|
|
5
|
+
detectHubInstallSource,
|
|
6
|
+
detectInstallSource,
|
|
7
|
+
formatInstallSourceLabel,
|
|
8
|
+
isStale,
|
|
9
|
+
} from "../install-source.ts";
|
|
3
10
|
import { type AliveFn, defaultAlive, formatUptime, processState } from "../process-state.ts";
|
|
4
11
|
import { canonicalPortForManifest, getSpec, shortNameForManifest } from "../service-spec.ts";
|
|
5
12
|
import { type ServiceEntry, readManifest } from "../services-manifest.ts";
|
|
@@ -14,6 +21,19 @@ export interface StatusOpts {
|
|
|
14
21
|
configDir?: string;
|
|
15
22
|
alive?: AliveFn;
|
|
16
23
|
now?: () => Date;
|
|
24
|
+
/**
|
|
25
|
+
* Test seam for install-source detection. Production reads the filesystem
|
|
26
|
+
* + shells out to git; tests inject stubs so each case (npm / bun-linked /
|
|
27
|
+
* unknown / stale) is exercised deterministically without depending on
|
|
28
|
+
* the operator's actual bun globals.
|
|
29
|
+
*/
|
|
30
|
+
installSourceDeps?: DetectInstallSourceDeps;
|
|
31
|
+
/**
|
|
32
|
+
* Directory containing the running hub source. Defaults to `import.meta.dir`
|
|
33
|
+
* (the directory of this file). Tests override so the hub row's install
|
|
34
|
+
* source classification doesn't depend on the test runner's location.
|
|
35
|
+
*/
|
|
36
|
+
hubSrcDir?: string;
|
|
17
37
|
}
|
|
18
38
|
|
|
19
39
|
export interface ProbeResult {
|
|
@@ -71,6 +91,7 @@ interface StatusRow {
|
|
|
71
91
|
uptimeLabel: string;
|
|
72
92
|
healthLabel: string;
|
|
73
93
|
latencyLabel: string;
|
|
94
|
+
sourceLabel: string;
|
|
74
95
|
url: string | undefined;
|
|
75
96
|
healthy: boolean;
|
|
76
97
|
skipped: boolean;
|
|
@@ -82,6 +103,13 @@ interface StatusRow {
|
|
|
82
103
|
* hard-erroring on a deliberate operator port change.
|
|
83
104
|
*/
|
|
84
105
|
driftWarning?: string;
|
|
106
|
+
/**
|
|
107
|
+
* Version-drift indicator (hub#243). Set when a bun-linked service's
|
|
108
|
+
* `services.json.version` lags the live `package.json` version at its
|
|
109
|
+
* checkout. Surfaced as a continuation line so operators can spot a
|
|
110
|
+
* stale-after-rebuild row without comparing columns by eye.
|
|
111
|
+
*/
|
|
112
|
+
staleNote?: string;
|
|
85
113
|
}
|
|
86
114
|
|
|
87
115
|
/**
|
|
@@ -98,7 +126,13 @@ function urlForEntry(entry: ServiceEntry, short: string | undefined): string | u
|
|
|
98
126
|
return `http://127.0.0.1:${entry.port}${first}`;
|
|
99
127
|
}
|
|
100
128
|
|
|
101
|
-
function hubRow(
|
|
129
|
+
function hubRow(
|
|
130
|
+
configDir: string,
|
|
131
|
+
alive: AliveFn,
|
|
132
|
+
nowDate: Date,
|
|
133
|
+
hubSrcDir: string,
|
|
134
|
+
installSourceDeps: DetectInstallSourceDeps,
|
|
135
|
+
): StatusRow | undefined {
|
|
102
136
|
const proc = processState(HUB_SVC, configDir, alive);
|
|
103
137
|
if (proc.status === "unknown") return undefined;
|
|
104
138
|
const port = readHubPort(configDir);
|
|
@@ -107,15 +141,17 @@ function hubRow(configDir: string, alive: AliveFn, nowDate: Date): StatusRow | u
|
|
|
107
141
|
const pidLabel = proc.status === "running" && proc.pid !== undefined ? String(proc.pid) : "-";
|
|
108
142
|
const uptimeLabel =
|
|
109
143
|
proc.status === "running" && proc.startedAt ? formatUptime(proc.startedAt, nowDate) : "-";
|
|
144
|
+
const source = detectHubInstallSource(hubSrcDir, installSourceDeps);
|
|
110
145
|
return {
|
|
111
146
|
service: "parachute-hub (internal)",
|
|
112
147
|
port: portLabel,
|
|
113
|
-
version: "-",
|
|
148
|
+
version: source.livePackageVersion ?? "-",
|
|
114
149
|
processLabel,
|
|
115
150
|
pidLabel,
|
|
116
151
|
uptimeLabel,
|
|
117
152
|
healthLabel: "-",
|
|
118
153
|
latencyLabel: "-",
|
|
154
|
+
sourceLabel: formatInstallSourceLabel(source),
|
|
119
155
|
url: port !== undefined ? `http://127.0.0.1:${port}` : undefined,
|
|
120
156
|
healthy: true,
|
|
121
157
|
skipped: true,
|
|
@@ -130,6 +166,8 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
|
|
|
130
166
|
const configDir = opts.configDir ?? CONFIG_DIR;
|
|
131
167
|
const alive = opts.alive ?? defaultAlive;
|
|
132
168
|
const now = opts.now ?? (() => new Date());
|
|
169
|
+
const installSourceDeps = opts.installSourceDeps ?? {};
|
|
170
|
+
const hubSrcDir = opts.hubSrcDir ?? import.meta.dir;
|
|
133
171
|
|
|
134
172
|
const manifest = readManifest(manifestPath);
|
|
135
173
|
if (manifest.services.length === 0) {
|
|
@@ -178,6 +216,17 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
|
|
|
178
216
|
? `canonical port is ${canonical}`
|
|
179
217
|
: undefined;
|
|
180
218
|
|
|
219
|
+
// Install-source detection (hub#243). One filesystem walk + maybe one
|
|
220
|
+
// `git rev-parse` per row. Failures degrade silently to `unknown` —
|
|
221
|
+
// status output should never error out on a missing checkout dir.
|
|
222
|
+
const detectArgs: { entryName: string; installDir?: string } = { entryName: entry.name };
|
|
223
|
+
if (entry.installDir !== undefined) detectArgs.installDir = entry.installDir;
|
|
224
|
+
const source = detectInstallSource(detectArgs, installSourceDeps);
|
|
225
|
+
const sourceLabel = formatInstallSourceLabel(source);
|
|
226
|
+
const staleNote = isStale(entry.version, source)
|
|
227
|
+
? `STALE: services.json cached ${entry.version}; live package.json ${source.livePackageVersion}`
|
|
228
|
+
: undefined;
|
|
229
|
+
|
|
181
230
|
// Only skip probe when we know the process is dead (PID file was
|
|
182
231
|
// present but kill(pid, 0) failed). "unknown" status (no PID file)
|
|
183
232
|
// still probes — externally-managed services should report health.
|
|
@@ -191,10 +240,12 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
|
|
|
191
240
|
uptimeLabel,
|
|
192
241
|
healthLabel: "-",
|
|
193
242
|
latencyLabel: "-",
|
|
243
|
+
sourceLabel,
|
|
194
244
|
url,
|
|
195
245
|
healthy: false,
|
|
196
246
|
skipped: true,
|
|
197
247
|
driftWarning,
|
|
248
|
+
staleNote,
|
|
198
249
|
};
|
|
199
250
|
}
|
|
200
251
|
|
|
@@ -213,20 +264,32 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
|
|
|
213
264
|
uptimeLabel,
|
|
214
265
|
healthLabel,
|
|
215
266
|
latencyLabel: `${p.latencyMs}ms`,
|
|
267
|
+
sourceLabel,
|
|
216
268
|
url,
|
|
217
269
|
healthy: p.healthy,
|
|
218
270
|
skipped: false,
|
|
219
271
|
driftWarning,
|
|
272
|
+
staleNote,
|
|
220
273
|
};
|
|
221
274
|
}),
|
|
222
275
|
);
|
|
223
276
|
|
|
224
277
|
// Hub is an internal service — not in services.json, but users notice
|
|
225
278
|
// when it's dead. Only show it if we've seen it run.
|
|
226
|
-
const hub = hubRow(configDir, alive, nowDate);
|
|
279
|
+
const hub = hubRow(configDir, alive, nowDate, hubSrcDir, installSourceDeps);
|
|
227
280
|
if (hub) rows.push(hub);
|
|
228
281
|
|
|
229
|
-
const header = [
|
|
282
|
+
const header = [
|
|
283
|
+
"SERVICE",
|
|
284
|
+
"PORT",
|
|
285
|
+
"VERSION",
|
|
286
|
+
"PROCESS",
|
|
287
|
+
"PID",
|
|
288
|
+
"UPTIME",
|
|
289
|
+
"HEALTH",
|
|
290
|
+
"LATENCY",
|
|
291
|
+
"SOURCE",
|
|
292
|
+
];
|
|
230
293
|
const textRows = rows.map((r) => [
|
|
231
294
|
r.service,
|
|
232
295
|
r.port,
|
|
@@ -236,14 +299,17 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
|
|
|
236
299
|
r.uptimeLabel,
|
|
237
300
|
r.healthLabel,
|
|
238
301
|
r.latencyLabel,
|
|
302
|
+
r.sourceLabel,
|
|
239
303
|
]);
|
|
240
304
|
const widths = header.map((_, i) =>
|
|
241
305
|
Math.max(header[i]?.length ?? 0, ...textRows.map((r) => r[i]?.length ?? 0)),
|
|
242
306
|
);
|
|
243
307
|
print(formatRow(header, widths));
|
|
244
|
-
// URL
|
|
245
|
-
// (vault's MCP path runs ~40 chars)
|
|
246
|
-
//
|
|
308
|
+
// URL, drift, and stale notes stay on continuation lines rather than
|
|
309
|
+
// columns. URLs are long (vault's MCP path runs ~40 chars); SOURCE labels
|
|
310
|
+
// can be long for bun-linked rows. Spreading them across columns would
|
|
311
|
+
// push the table well past 80 cols on every install — continuation lines
|
|
312
|
+
// keep the table scannable. The " → " / " ! " prefixes group visually
|
|
247
313
|
// with the row above without misleading the table widths.
|
|
248
314
|
for (let i = 0; i < textRows.length; i++) {
|
|
249
315
|
const cells = textRows[i];
|
|
@@ -251,10 +317,8 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
|
|
|
251
317
|
if (!cells || !row) continue;
|
|
252
318
|
print(formatRow(cells, widths));
|
|
253
319
|
if (row.url) print(` → ${row.url}`);
|
|
254
|
-
// Drift warning rides as its own continuation line. Plain ASCII (no
|
|
255
|
-
// emoji / unicode glyphs) for terminal compatibility — the same
|
|
256
|
-
// surface that prints to scripts piping `parachute status`.
|
|
257
320
|
if (row.driftWarning) print(` ! ${row.driftWarning}`);
|
|
321
|
+
if (row.staleNote) print(` ! ${row.staleNote}`);
|
|
258
322
|
}
|
|
259
323
|
|
|
260
324
|
/**
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -38,6 +38,7 @@ import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
|
38
38
|
import { homedir } from "node:os";
|
|
39
39
|
import { dirname, join } from "node:path";
|
|
40
40
|
import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
|
|
41
|
+
import { HUB_PACKAGE, HUB_SVC } from "../hub-control.ts";
|
|
41
42
|
import { ModuleManifestError } from "../module-manifest.ts";
|
|
42
43
|
import {
|
|
43
44
|
type ServiceSpec,
|
|
@@ -149,16 +150,38 @@ function resolve(opts: UpgradeOpts): Resolved {
|
|
|
149
150
|
};
|
|
150
151
|
}
|
|
151
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Synthetic services.json row for the hub. The hub isn't in services.json
|
|
155
|
+
* (it's an implementation detail of `parachute expose`, not a user-facing
|
|
156
|
+
* service), so callers passing `hub` as the upgrade target need a fabricated
|
|
157
|
+
* `ResolvedTarget`. Only `installDir` is read downstream — left undefined so
|
|
158
|
+
* `findGlobalInstall("@openparachute/hub")` is the sole locate path, which
|
|
159
|
+
* works the same for npm installs and `bun link` checkouts.
|
|
160
|
+
*/
|
|
161
|
+
function hubTarget(): ResolvedTarget {
|
|
162
|
+
const entry: ServiceEntry = {
|
|
163
|
+
name: HUB_PACKAGE,
|
|
164
|
+
port: 0,
|
|
165
|
+
paths: [],
|
|
166
|
+
health: "",
|
|
167
|
+
version: "",
|
|
168
|
+
};
|
|
169
|
+
return { short: HUB_SVC, entry, spec: undefined, packageName: HUB_PACKAGE };
|
|
170
|
+
}
|
|
171
|
+
|
|
152
172
|
async function resolveTargets(
|
|
153
173
|
svc: string | undefined,
|
|
154
174
|
manifestPath: string,
|
|
155
175
|
): Promise<{ targets: ResolvedTarget[] } | { error: string }> {
|
|
156
176
|
const manifest = readManifest(manifestPath);
|
|
157
|
-
if (manifest.services.length === 0) {
|
|
158
|
-
return { error: "No services installed yet. Try: parachute install <service>" };
|
|
159
|
-
}
|
|
160
177
|
|
|
161
178
|
if (svc !== undefined) {
|
|
179
|
+
if (svc === HUB_SVC) return { targets: [hubTarget()] };
|
|
180
|
+
|
|
181
|
+
if (manifest.services.length === 0) {
|
|
182
|
+
return { error: "No services installed yet. Try: parachute install <service>" };
|
|
183
|
+
}
|
|
184
|
+
|
|
162
185
|
const firstPartySpec = getSpec(svc);
|
|
163
186
|
if (firstPartySpec) {
|
|
164
187
|
const entry = manifest.services.find((s) => s.name === firstPartySpec.manifestName);
|
|
@@ -183,10 +206,15 @@ async function resolveTargets(
|
|
|
183
206
|
throw err;
|
|
184
207
|
}
|
|
185
208
|
}
|
|
186
|
-
return {
|
|
209
|
+
return {
|
|
210
|
+
error: `unknown service "${svc}". known: ${[HUB_SVC, ...knownServices()].join(", ")}`,
|
|
211
|
+
};
|
|
187
212
|
}
|
|
188
213
|
|
|
189
|
-
|
|
214
|
+
// Sweep mode: hub first, then everything in services.json. Hub-first means a
|
|
215
|
+
// dispatcher upgrade can't be undermined mid-sweep by a service upgrade that
|
|
216
|
+
// restarts hub for reasons unrelated to its own code change.
|
|
217
|
+
const targets: ResolvedTarget[] = [hubTarget()];
|
|
190
218
|
for (const entry of manifest.services) {
|
|
191
219
|
const short = shortNameForManifest(entry.name);
|
|
192
220
|
if (short) {
|
|
@@ -209,7 +237,6 @@ async function resolveTargets(
|
|
|
209
237
|
}
|
|
210
238
|
}
|
|
211
239
|
}
|
|
212
|
-
if (targets.length === 0) return { error: "No upgradeable services in services.json." };
|
|
213
240
|
return { targets };
|
|
214
241
|
}
|
|
215
242
|
|