@openparachute/hub 0.6.3 → 0.6.4-rc.2
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__/account-setup.test.ts +880 -0
- package/src/__tests__/account-usage.test.ts +137 -0
- package/src/__tests__/account-vault-admin-token.test.ts +301 -0
- package/src/__tests__/account-vault-token.test.ts +53 -1
- package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
- package/src/__tests__/admin-vaults.test.ts +20 -0
- package/src/__tests__/api-account.test.ts +125 -4
- package/src/__tests__/api-invites.test.ts +217 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +187 -1
- package/src/__tests__/api-modules.test.ts +40 -4
- package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
- package/src/__tests__/auto-wire.test.ts +101 -1
- package/src/__tests__/cli.test.ts +188 -2
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +5 -4
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +628 -13
- package/src/__tests__/hub-unit.test.ts +4 -0
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +32 -0
- package/src/__tests__/module-ops-client.test.ts +68 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/__tests__/serve-boot.test.ts +74 -1
- package/src/__tests__/serve.test.ts +121 -7
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +177 -0
- package/src/__tests__/users.test.ts +27 -0
- package/src/account-home-ui.ts +82 -9
- package/src/account-setup.ts +381 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +27 -2
- package/src/admin-login-ui.ts +121 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +54 -1
- package/src/api-invites.ts +345 -0
- package/src/api-mint-token.ts +81 -0
- package/src/api-modules-ops.ts +168 -53
- package/src/api-modules.ts +36 -0
- package/src/auto-wire.ts +87 -0
- package/src/cli.ts +122 -32
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/migrate-cutover.ts +12 -5
- package/src/commands/serve-boot.ts +33 -3
- package/src/commands/serve.ts +158 -37
- package/src/commands/status.ts +9 -1
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +399 -41
- package/src/hub-unit.ts +4 -9
- package/src/invites.ts +291 -0
- package/src/launchctl-guard.ts +131 -0
- package/src/managed-unit.ts +13 -3
- package/src/migrate-offer.ts +15 -6
- package/src/module-ops-client.ts +47 -22
- package/src/scope-attenuation.ts +19 -0
- package/src/scope-explanations.ts +9 -1
- package/src/service-spec.ts +8 -3
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +84 -7
- package/src/users.ts +42 -4
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
|
@@ -323,6 +323,13 @@ describe("POST /api/modules/:short/install", () => {
|
|
|
323
323
|
expect(manifest.services.some((s) => s.name === "parachute-vault")).toBe(true);
|
|
324
324
|
// Supervisor was handed the spawn.
|
|
325
325
|
expect(spawns.find((s) => s.short === "vault")?.cmd).toEqual(["parachute-vault", "serve"]);
|
|
326
|
+
// The install-time spawn path (spawnSupervised) injects the enriched PATH
|
|
327
|
+
// so a module installed via /admin/modules can find operator tools (scribe's
|
|
328
|
+
// parakeet-mlx / ffmpeg) — same fix as the serve-boot path, second spawn
|
|
329
|
+
// site (hub launchd-PATH regression). It must carry the inherited PATH.
|
|
330
|
+
const vaultEnv = spawns.find((s) => s.short === "vault")?.env;
|
|
331
|
+
expect(vaultEnv?.PATH).toBeDefined();
|
|
332
|
+
expect(vaultEnv?.PATH?.length).toBeGreaterThan(0);
|
|
326
333
|
});
|
|
327
334
|
|
|
328
335
|
test("idempotent: already-installed + running returns succeeded immediately", async () => {
|
|
@@ -842,6 +849,31 @@ describe("POST /api/modules/:short/start", () => {
|
|
|
842
849
|
expect(res.status).toBe(403);
|
|
843
850
|
});
|
|
844
851
|
|
|
852
|
+
test("supervisor.start throw → structured 500 module_op_failed, not a naked 500 (hub#536)", async () => {
|
|
853
|
+
seedVault();
|
|
854
|
+
// Models the hub#536 wedge: Bun.spawn throwing because the module's
|
|
855
|
+
// installDir/cwd no longer exists (NOT a missing binary — the preflight
|
|
856
|
+
// + rethrowIfMissing nets don't catch it, so it propagates out of
|
|
857
|
+
// supervisor.start). Pre-fix this escaped the handler as a bodyless 500
|
|
858
|
+
// and the CLI showed an opaque "request failed".
|
|
859
|
+
const supervisor = new Supervisor({
|
|
860
|
+
spawnFn: () => {
|
|
861
|
+
throw new Error("simulated spawn failure: cwd /gone/parachute-app does not exist");
|
|
862
|
+
},
|
|
863
|
+
});
|
|
864
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
865
|
+
const res = await handleStart(
|
|
866
|
+
postReq("/api/modules/vault/start", { authorization: `Bearer ${bearer}` }),
|
|
867
|
+
"vault",
|
|
868
|
+
{ db: h.db, issuer: ISSUER, manifestPath: h.manifestPath, configDir: h.dir, supervisor },
|
|
869
|
+
);
|
|
870
|
+
expect(res.status).toBe(500);
|
|
871
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
872
|
+
expect(body.error).toBe("module_op_failed");
|
|
873
|
+
expect(body.error_description).toContain("vault start failed");
|
|
874
|
+
expect(body.error_description).toContain("cwd /gone/parachute-app does not exist");
|
|
875
|
+
});
|
|
876
|
+
|
|
845
877
|
test("pure supervisor.start of an installed module — NOT install (no bun add)", async () => {
|
|
846
878
|
seedVault();
|
|
847
879
|
const { supervisor, spawns } = makeIdleSupervisor();
|
|
@@ -1096,7 +1128,44 @@ describe("POST /api/modules/:short/restart", () => {
|
|
|
1096
1128
|
});
|
|
1097
1129
|
afterEach(() => h.cleanup());
|
|
1098
1130
|
|
|
1099
|
-
|
|
1131
|
+
/** Seed a minimal installed vault row (in services.json). */
|
|
1132
|
+
function seedVault(port = 1940): void {
|
|
1133
|
+
writeManifest(h.manifestPath, [
|
|
1134
|
+
{
|
|
1135
|
+
name: "parachute-vault",
|
|
1136
|
+
port,
|
|
1137
|
+
paths: ["/vault/default"],
|
|
1138
|
+
health: "/vault/default/health",
|
|
1139
|
+
version: "0.0.0-linked",
|
|
1140
|
+
},
|
|
1141
|
+
]);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
test("400 not_installed when the module isn't in services.json", async () => {
|
|
1145
|
+
// restart rebuilds the spawn req from services.json (hub#532), so a
|
|
1146
|
+
// missing row is a `not_installed` 400 (mirrors `start`) — before the
|
|
1147
|
+
// supervisor is even consulted.
|
|
1148
|
+
const { supervisor } = makeIdleSupervisor();
|
|
1149
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
1150
|
+
const res = await handleRestart(
|
|
1151
|
+
postReq("/api/modules/vault/restart", { authorization: `Bearer ${bearer}` }),
|
|
1152
|
+
"vault",
|
|
1153
|
+
{
|
|
1154
|
+
db: h.db,
|
|
1155
|
+
issuer: ISSUER,
|
|
1156
|
+
manifestPath: h.manifestPath,
|
|
1157
|
+
configDir: h.dir,
|
|
1158
|
+
supervisor,
|
|
1159
|
+
run: async () => 0,
|
|
1160
|
+
},
|
|
1161
|
+
);
|
|
1162
|
+
expect(res.status).toBe(400);
|
|
1163
|
+
const body = (await res.json()) as { error: string };
|
|
1164
|
+
expect(body.error).toBe("not_installed");
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
test("404 not_supervised when installed but not currently running", async () => {
|
|
1168
|
+
seedVault();
|
|
1100
1169
|
const { supervisor } = makeIdleSupervisor();
|
|
1101
1170
|
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
1102
1171
|
const res = await handleRestart(
|
|
@@ -1117,6 +1186,7 @@ describe("POST /api/modules/:short/restart", () => {
|
|
|
1117
1186
|
});
|
|
1118
1187
|
|
|
1119
1188
|
test("returns new state on success", async () => {
|
|
1189
|
+
seedVault();
|
|
1120
1190
|
const { supervisor } = makeIdleSupervisor();
|
|
1121
1191
|
await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
|
|
1122
1192
|
|
|
@@ -1140,6 +1210,122 @@ describe("POST /api/modules/:short/restart", () => {
|
|
|
1140
1210
|
// on timing — either is acceptable here as long as it's not crashed/stopped.
|
|
1141
1211
|
expect(["restarting", "running", "starting"]).toContain(body.state.status);
|
|
1142
1212
|
});
|
|
1213
|
+
|
|
1214
|
+
test("re-injects the CURRENT hub origin on restart (hub#532)", async () => {
|
|
1215
|
+
// The core hub#532 fix: a module first started under one issuer, then
|
|
1216
|
+
// restarted after the canonical origin changed (e.g. post-expose), must
|
|
1217
|
+
// re-spawn with the NEW PARACHUTE_HUB_ORIGIN — not replay the stale
|
|
1218
|
+
// first-start snapshot. We drive the supervisor directly to control the
|
|
1219
|
+
// first-start env, then restart through the handler with a different
|
|
1220
|
+
// `deps.issuer` and assert the recorded spawn carries the new origin.
|
|
1221
|
+
seedVault(1940);
|
|
1222
|
+
const { supervisor, spawns } = makeIdleSupervisor();
|
|
1223
|
+
// First start with the OLD origin baked in (as boot would have).
|
|
1224
|
+
await supervisor.start({
|
|
1225
|
+
short: "vault",
|
|
1226
|
+
cmd: ["parachute-vault", "serve"],
|
|
1227
|
+
env: { PORT: "1940", PARACHUTE_HUB_ORIGIN: "https://old.example" },
|
|
1228
|
+
});
|
|
1229
|
+
const spawnsBefore = spawns.length;
|
|
1230
|
+
|
|
1231
|
+
const NEW_ORIGIN = "https://new.example";
|
|
1232
|
+
// The bearer was minted at the prior (loopback) ISSUER; the canonical
|
|
1233
|
+
// origin has since moved to NEW_ORIGIN. `knownIssuers` accepts the older
|
|
1234
|
+
// bearer iss while `deps.issuer` is the current canonical origin — exactly
|
|
1235
|
+
// the post-expose scenario hub#532 describes.
|
|
1236
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
1237
|
+
const res = await handleRestart(
|
|
1238
|
+
postReq("/api/modules/vault/restart", { authorization: `Bearer ${bearer}` }),
|
|
1239
|
+
"vault",
|
|
1240
|
+
{
|
|
1241
|
+
db: h.db,
|
|
1242
|
+
issuer: NEW_ORIGIN,
|
|
1243
|
+
knownIssuers: [ISSUER, NEW_ORIGIN],
|
|
1244
|
+
manifestPath: h.manifestPath,
|
|
1245
|
+
configDir: h.dir,
|
|
1246
|
+
supervisor,
|
|
1247
|
+
run: async () => 0,
|
|
1248
|
+
},
|
|
1249
|
+
);
|
|
1250
|
+
expect(res.status).toBe(200);
|
|
1251
|
+
// A fresh spawn happened, and it carries the NEW origin (rebuilt from the
|
|
1252
|
+
// live deps.issuer), not the stale one from first start.
|
|
1253
|
+
const reSpawn = spawns[spawnsBefore];
|
|
1254
|
+
expect(reSpawn?.env?.PARACHUTE_HUB_ORIGIN).toBe(NEW_ORIGIN);
|
|
1255
|
+
expect(reSpawn?.env?.PORT).toBe("1940");
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
test("crash-restart after a restart reuses the REFRESHED req (hub#532)", async () => {
|
|
1259
|
+
// The refreshed SpawnRequest must become the supervisor entry's `req` so
|
|
1260
|
+
// a SUBSEQUENT crash-restart (handleExit → spawnAndWatch, which replays
|
|
1261
|
+
// `entry.req`) also carries the current env — not the original snapshot.
|
|
1262
|
+
seedVault(1940);
|
|
1263
|
+
// Controllable supervisor: spawns record env; each fake exposes a `crash()`
|
|
1264
|
+
// resolving `exited` with code 1 (a crash, not an operator stop). `kill()`
|
|
1265
|
+
// (driven by the injected group-aware `killFn`) resolves with 0 so the
|
|
1266
|
+
// handler's restart-stop completes promptly. Distinct codes let us crash a
|
|
1267
|
+
// specific child without tripping the stop path.
|
|
1268
|
+
const spawns: SpawnRequest[] = [];
|
|
1269
|
+
const procs: Array<{ pid: number; crash: () => void; kill: () => void }> = [];
|
|
1270
|
+
let nextPid = 8000;
|
|
1271
|
+
const spawnFn = (req: SpawnRequest): SupervisedProc => {
|
|
1272
|
+
spawns.push(req);
|
|
1273
|
+
const pid = nextPid++;
|
|
1274
|
+
let resolveExit!: (c: number | null) => void;
|
|
1275
|
+
const exited = new Promise<number | null>((r) => {
|
|
1276
|
+
resolveExit = r;
|
|
1277
|
+
});
|
|
1278
|
+
const proc = {
|
|
1279
|
+
pid,
|
|
1280
|
+
exited,
|
|
1281
|
+
stdout: null,
|
|
1282
|
+
stderr: null,
|
|
1283
|
+
kill: () => resolveExit(0),
|
|
1284
|
+
};
|
|
1285
|
+
procs.push({ pid, crash: () => resolveExit(1), kill: proc.kill });
|
|
1286
|
+
return proc;
|
|
1287
|
+
};
|
|
1288
|
+
const killFn = (pid: number): void => {
|
|
1289
|
+
procs.find((p) => p.pid === Math.abs(pid))?.kill();
|
|
1290
|
+
};
|
|
1291
|
+
const supervisor = new Supervisor({ spawnFn, killFn, restartDelayMs: 1, startReadyMs: 0 });
|
|
1292
|
+
|
|
1293
|
+
// First start with the OLD origin.
|
|
1294
|
+
await supervisor.start({
|
|
1295
|
+
short: "vault",
|
|
1296
|
+
cmd: ["parachute-vault", "serve"],
|
|
1297
|
+
env: { PORT: "1940", PARACHUTE_HUB_ORIGIN: "https://old.example" },
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
const NEW_ORIGIN = "https://new.example";
|
|
1301
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
1302
|
+
await handleRestart(
|
|
1303
|
+
postReq("/api/modules/vault/restart", { authorization: `Bearer ${bearer}` }),
|
|
1304
|
+
"vault",
|
|
1305
|
+
{
|
|
1306
|
+
db: h.db,
|
|
1307
|
+
issuer: NEW_ORIGIN,
|
|
1308
|
+
knownIssuers: [ISSUER, NEW_ORIGIN],
|
|
1309
|
+
manifestPath: h.manifestPath,
|
|
1310
|
+
configDir: h.dir,
|
|
1311
|
+
supervisor,
|
|
1312
|
+
run: async () => 0,
|
|
1313
|
+
},
|
|
1314
|
+
);
|
|
1315
|
+
// The restart re-spawn is the most recent — confirm the NEW origin landed.
|
|
1316
|
+
const restartSpawnIdx = spawns.length - 1;
|
|
1317
|
+
expect(spawns[restartSpawnIdx]?.env?.PARACHUTE_HUB_ORIGIN).toBe(NEW_ORIGIN);
|
|
1318
|
+
|
|
1319
|
+
// Crash the restart-spawned child → the supervisor's crash-restart replays
|
|
1320
|
+
// entry.req (which `start` stored from the refreshed req).
|
|
1321
|
+
procs[restartSpawnIdx]?.crash();
|
|
1322
|
+
// Wait for the crash watcher's restartDelay + respawn.
|
|
1323
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
1324
|
+
expect(spawns.length).toBeGreaterThan(restartSpawnIdx + 1);
|
|
1325
|
+
const crashRespawn = spawns[spawns.length - 1];
|
|
1326
|
+
// The crash-restart inherited the REFRESHED req → still the new origin.
|
|
1327
|
+
expect(crashRespawn?.env?.PARACHUTE_HUB_ORIGIN).toBe(NEW_ORIGIN);
|
|
1328
|
+
});
|
|
1143
1329
|
});
|
|
1144
1330
|
|
|
1145
1331
|
describe("POST /api/modules/:short/upgrade", () => {
|
|
@@ -317,6 +317,45 @@ describe("GET /api/modules", () => {
|
|
|
317
317
|
expect(shorts).not.toContain("surface");
|
|
318
318
|
});
|
|
319
319
|
|
|
320
|
+
test("non-curated supervised modules appear in `supervised` (not `modules`) — hub#539", async () => {
|
|
321
|
+
// surface (the UI host) is supervised but not curated. Its run-state must
|
|
322
|
+
// surface in `supervised` so `parachute status` reads it `active`, while it
|
|
323
|
+
// stays OUT of the curated `modules` catalog (which drives the install UI).
|
|
324
|
+
writeManifest(h.manifestPath, [
|
|
325
|
+
{
|
|
326
|
+
name: "parachute-vault",
|
|
327
|
+
port: 1940,
|
|
328
|
+
paths: ["/vault/default"],
|
|
329
|
+
health: "/vault/default/health",
|
|
330
|
+
version: "0.4.5",
|
|
331
|
+
},
|
|
332
|
+
]);
|
|
333
|
+
const { supervisor } = makeIdleSupervisor();
|
|
334
|
+
await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
|
|
335
|
+
await supervisor.start({ short: "surface", cmd: ["parachute-surface", "serve"] });
|
|
336
|
+
|
|
337
|
+
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
338
|
+
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
339
|
+
db: h.db,
|
|
340
|
+
issuer: ISSUER,
|
|
341
|
+
manifestPath: h.manifestPath,
|
|
342
|
+
supervisor,
|
|
343
|
+
fetchLatestVersion: async () => null,
|
|
344
|
+
});
|
|
345
|
+
const body = (await res.json()) as {
|
|
346
|
+
modules: Array<{ short: string }>;
|
|
347
|
+
supervised: Array<{ short: string; supervisor_status: string | null; pid: number | null }>;
|
|
348
|
+
};
|
|
349
|
+
// surface stays out of the curated catalog…
|
|
350
|
+
expect(body.modules.map((m) => m.short)).not.toContain("surface");
|
|
351
|
+
// …but its run-state is in `supervised`, marked running with a pid.
|
|
352
|
+
const surf = body.supervised.find((m) => m.short === "surface");
|
|
353
|
+
expect(surf?.supervisor_status).toBe("running");
|
|
354
|
+
expect(typeof surf?.pid).toBe("number");
|
|
355
|
+
// Curated modules appear in `supervised` too (consumers dedupe by short).
|
|
356
|
+
expect(body.supervised.find((m) => m.short === "vault")?.supervisor_status).toBe("running");
|
|
357
|
+
});
|
|
358
|
+
|
|
320
359
|
test("surfaces installed_version from services.json", async () => {
|
|
321
360
|
writeManifest(h.manifestPath, [
|
|
322
361
|
{
|
|
@@ -716,10 +755,7 @@ describe("GET /api/modules", () => {
|
|
|
716
755
|
// Second back-to-back request must not re-hit the registry. The
|
|
717
756
|
// UI may poll this endpoint; we don't want it to slam npm.
|
|
718
757
|
let calls = 0;
|
|
719
|
-
const probe = async (
|
|
720
|
-
_pkg: string,
|
|
721
|
-
_channel: "latest" | "rc",
|
|
722
|
-
): Promise<string | null> => {
|
|
758
|
+
const probe = async (_pkg: string, _channel: "latest" | "rc"): Promise<string | null> => {
|
|
723
759
|
calls++;
|
|
724
760
|
return "0.5.0";
|
|
725
761
|
};
|
|
@@ -83,6 +83,11 @@ function putReq(body: unknown, headers: Record<string, string> = {}): Request {
|
|
|
83
83
|
});
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// Stub "no exposure recorded" so resolveIssuer / resolveIssuerSource don't
|
|
87
|
+
// pick up the host's real ~/.parachute/expose-state.json (the #531 expose
|
|
88
|
+
// tier would otherwise shadow the request-origin source these tests assert).
|
|
89
|
+
const noExpose = (): string | undefined => undefined;
|
|
90
|
+
|
|
86
91
|
function deps(
|
|
87
92
|
h: Harness,
|
|
88
93
|
overrides: Partial<Parameters<typeof handleApiSettingsHubOrigin>[1]> = {},
|
|
@@ -90,8 +95,8 @@ function deps(
|
|
|
90
95
|
return {
|
|
91
96
|
db: h.db,
|
|
92
97
|
issuer: ISSUER,
|
|
93
|
-
resolvedIssuer: resolveIssuer(getReq(), h.db, undefined),
|
|
94
|
-
resolvedSource: resolveIssuerSource(h.db, undefined),
|
|
98
|
+
resolvedIssuer: resolveIssuer(getReq(), h.db, undefined, noExpose),
|
|
99
|
+
resolvedSource: resolveIssuerSource(h.db, undefined, noExpose),
|
|
95
100
|
...overrides,
|
|
96
101
|
};
|
|
97
102
|
}
|
|
@@ -414,8 +419,8 @@ describe("change takes effect on the next request (no restart needed)", () => {
|
|
|
414
419
|
const g1 = await handleApiSettingsHubOrigin(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
415
420
|
db: h.db,
|
|
416
421
|
issuer: ISSUER,
|
|
417
|
-
resolvedIssuer: resolveIssuer(getReq(), h.db, undefined),
|
|
418
|
-
resolvedSource: resolveIssuerSource(h.db, undefined),
|
|
422
|
+
resolvedIssuer: resolveIssuer(getReq(), h.db, undefined, noExpose),
|
|
423
|
+
resolvedSource: resolveIssuerSource(h.db, undefined, noExpose),
|
|
419
424
|
});
|
|
420
425
|
const b1 = (await g1.json()) as { source: string; resolved_issuer: string };
|
|
421
426
|
expect(b1.source).toBe("request");
|
|
@@ -426,8 +431,8 @@ describe("change takes effect on the next request (no restart needed)", () => {
|
|
|
426
431
|
{
|
|
427
432
|
db: h.db,
|
|
428
433
|
issuer: ISSUER,
|
|
429
|
-
resolvedIssuer: resolveIssuer(putReq({}), h.db, undefined),
|
|
430
|
-
resolvedSource: resolveIssuerSource(h.db, undefined),
|
|
434
|
+
resolvedIssuer: resolveIssuer(putReq({}), h.db, undefined, noExpose),
|
|
435
|
+
resolvedSource: resolveIssuerSource(h.db, undefined, noExpose),
|
|
431
436
|
},
|
|
432
437
|
);
|
|
433
438
|
expect(p.status).toBe(200);
|
|
@@ -437,8 +442,8 @@ describe("change takes effect on the next request (no restart needed)", () => {
|
|
|
437
442
|
const g2 = await handleApiSettingsHubOrigin(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
438
443
|
db: h.db,
|
|
439
444
|
issuer: ISSUER,
|
|
440
|
-
resolvedIssuer: resolveIssuer(getReq(), h.db, undefined),
|
|
441
|
-
resolvedSource: resolveIssuerSource(h.db, undefined),
|
|
445
|
+
resolvedIssuer: resolveIssuer(getReq(), h.db, undefined, noExpose),
|
|
446
|
+
resolvedSource: resolveIssuerSource(h.db, undefined, noExpose),
|
|
442
447
|
});
|
|
443
448
|
const b2 = (await g2.json()) as {
|
|
444
449
|
hub_origin: string | null;
|
|
@@ -2,7 +2,12 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
SCRIBE_AUTH_ENV_KEY,
|
|
7
|
+
SCRIBE_URL_ENV_KEY,
|
|
8
|
+
autoWireScribeAuth,
|
|
9
|
+
selfHealScribeAuth,
|
|
10
|
+
} from "../auto-wire.ts";
|
|
6
11
|
import { writePid } from "../process-state.ts";
|
|
7
12
|
|
|
8
13
|
function makeHarness(): { dir: string; cleanup: () => void } {
|
|
@@ -281,3 +286,98 @@ describe("autoWireScribeAuth", () => {
|
|
|
281
286
|
}
|
|
282
287
|
});
|
|
283
288
|
});
|
|
289
|
+
|
|
290
|
+
// Item H — serve-boot self-heal of scribe's auth token from vault's .env.
|
|
291
|
+
describe("selfHealScribeAuth", () => {
|
|
292
|
+
function seedVaultToken(dir: string, token: string): void {
|
|
293
|
+
mkdirSync(join(dir, "vault"), { recursive: true });
|
|
294
|
+
writeFileSync(join(dir, "vault", ".env"), `${SCRIBE_AUTH_ENV_KEY}=${token}\n`);
|
|
295
|
+
}
|
|
296
|
+
function seedScribeConfig(dir: string, config: Record<string, unknown>): void {
|
|
297
|
+
mkdirSync(join(dir, "scribe"), { recursive: true });
|
|
298
|
+
writeFileSync(join(dir, "scribe", "config.json"), `${JSON.stringify(config, null, 2)}\n`);
|
|
299
|
+
}
|
|
300
|
+
function readScribeConfig(dir: string): Record<string, unknown> {
|
|
301
|
+
return JSON.parse(readFileSync(join(dir, "scribe", "config.json"), "utf8"));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
test("vault .env has token + scribe config missing auth → self-heal writes it", () => {
|
|
305
|
+
const h = makeHarness();
|
|
306
|
+
try {
|
|
307
|
+
seedVaultToken(h.dir, "shared-secret-xyz");
|
|
308
|
+
seedScribeConfig(h.dir, { provider: "openai", model: "whisper-1" });
|
|
309
|
+
const logs: string[] = [];
|
|
310
|
+
const result = selfHealScribeAuth({ configDir: h.dir, log: (l) => logs.push(l) });
|
|
311
|
+
expect(result.healed).toBe(true);
|
|
312
|
+
const cfg = readScribeConfig(h.dir);
|
|
313
|
+
expect((cfg.auth as Record<string, unknown>).required_token).toBe("shared-secret-xyz");
|
|
314
|
+
// Other config keys preserved (merge-don't-clobber).
|
|
315
|
+
expect(cfg.provider).toBe("openai");
|
|
316
|
+
expect(cfg.model).toBe("whisper-1");
|
|
317
|
+
expect(logs.join("\n")).toMatch(/Self-healed scribe auth/);
|
|
318
|
+
} finally {
|
|
319
|
+
h.cleanup();
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("scribe config wholly absent → self-heal creates it with the token", () => {
|
|
324
|
+
const h = makeHarness();
|
|
325
|
+
try {
|
|
326
|
+
seedVaultToken(h.dir, "shared-secret-xyz");
|
|
327
|
+
// No scribe/config.json at all.
|
|
328
|
+
const result = selfHealScribeAuth({ configDir: h.dir });
|
|
329
|
+
expect(result.healed).toBe(true);
|
|
330
|
+
expect((readScribeConfig(h.dir).auth as Record<string, unknown>).required_token).toBe(
|
|
331
|
+
"shared-secret-xyz",
|
|
332
|
+
);
|
|
333
|
+
} finally {
|
|
334
|
+
h.cleanup();
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("scribe token mismatches vault → re-synced to vault's value", () => {
|
|
339
|
+
const h = makeHarness();
|
|
340
|
+
try {
|
|
341
|
+
seedVaultToken(h.dir, "vault-token-NEW");
|
|
342
|
+
seedScribeConfig(h.dir, { auth: { required_token: "stale-token-OLD" }, model: "x" });
|
|
343
|
+
const result = selfHealScribeAuth({ configDir: h.dir });
|
|
344
|
+
expect(result.healed).toBe(true);
|
|
345
|
+
const cfg = readScribeConfig(h.dir);
|
|
346
|
+
expect((cfg.auth as Record<string, unknown>).required_token).toBe("vault-token-NEW");
|
|
347
|
+
expect(cfg.model).toBe("x");
|
|
348
|
+
} finally {
|
|
349
|
+
h.cleanup();
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("already in sync → no-op (idempotent), config untouched", () => {
|
|
354
|
+
const h = makeHarness();
|
|
355
|
+
try {
|
|
356
|
+
seedVaultToken(h.dir, "same-token");
|
|
357
|
+
seedScribeConfig(h.dir, { auth: { required_token: "same-token" }, extra: "keep" });
|
|
358
|
+
const before = readFileSync(join(h.dir, "scribe", "config.json"), "utf8");
|
|
359
|
+
const result = selfHealScribeAuth({ configDir: h.dir });
|
|
360
|
+
expect(result.healed).toBe(false);
|
|
361
|
+
expect(result.reason).toBe("already-synced");
|
|
362
|
+
// Byte-identical — no rewrite.
|
|
363
|
+
expect(readFileSync(join(h.dir, "scribe", "config.json"), "utf8")).toBe(before);
|
|
364
|
+
} finally {
|
|
365
|
+
h.cleanup();
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("vault has no SCRIBE_AUTH_TOKEN → no-op (nothing to sync)", () => {
|
|
370
|
+
const h = makeHarness();
|
|
371
|
+
try {
|
|
372
|
+
mkdirSync(join(h.dir, "vault"), { recursive: true });
|
|
373
|
+
writeFileSync(join(h.dir, "vault", ".env"), "FOO=bar\n");
|
|
374
|
+
const result = selfHealScribeAuth({ configDir: h.dir });
|
|
375
|
+
expect(result.healed).toBe(false);
|
|
376
|
+
expect(result.reason).toBe("no-token");
|
|
377
|
+
// Scribe config not created.
|
|
378
|
+
expect(existsSync(join(h.dir, "scribe", "config.json"))).toBe(false);
|
|
379
|
+
} finally {
|
|
380
|
+
h.cleanup();
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
});
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { appendFileSync, cpSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
6
6
|
const CLI = join(import.meta.dir, "..", "cli.ts");
|
|
7
|
+
const REPO_ROOT = join(import.meta.dir, "..", "..");
|
|
7
8
|
|
|
8
9
|
async function runCli(
|
|
9
10
|
args: string[],
|
|
@@ -277,6 +278,191 @@ describe("cli per-subcommand help", () => {
|
|
|
277
278
|
});
|
|
278
279
|
});
|
|
279
280
|
|
|
281
|
+
describe("cli lazy-import isolation (feedback #9)", () => {
|
|
282
|
+
// Regression for the eager-import fragility: `cli.ts` used to import every
|
|
283
|
+
// command module at top-level, so a module that THREW at eval-time (the 0.6.2
|
|
284
|
+
// `migrate-cutover.ts` ReferenceError) aborted the entire CLI load — even
|
|
285
|
+
// `parachute --help` — because top-level import evaluation runs before
|
|
286
|
+
// `run()`'s try/catch is reached. Per-arm lazy `await import()` isolates a
|
|
287
|
+
// broken module to its own command.
|
|
288
|
+
//
|
|
289
|
+
// We exercise the REAL dispatcher: copy the live `src/` tree (plus the repo
|
|
290
|
+
// `package.json`, which `cli.ts` imports as `../package.json`) into a sandbox
|
|
291
|
+
// *inside the repo* so workspace `node_modules` resolution still works, then
|
|
292
|
+
// corrupt one command module so it throws at module-eval. `node_modules` is
|
|
293
|
+
// NOT copied — Bun walks up to the repo's. The corruption never touches the
|
|
294
|
+
// real source tree, so concurrent suites are unaffected.
|
|
295
|
+
let sandbox: string;
|
|
296
|
+
let sandboxCli: string;
|
|
297
|
+
|
|
298
|
+
beforeAll(() => {
|
|
299
|
+
// The sandbox lives INSIDE the repo (`<repo>/.tmp-cli-iso-*`) on purpose: it
|
|
300
|
+
// copies `src/` + `package.json` but NOT `node_modules`. The sandboxed CLI
|
|
301
|
+
// still resolves workspace packages (`@openparachute/depcheck`, etc.) by Bun
|
|
302
|
+
// walking up the directory tree to the **repo-root** `node_modules` — the same
|
|
303
|
+
// walk a nested file uses. So this suite REQUIRES `node_modules` installed at
|
|
304
|
+
// the repo root. CI must `bun install` before running it; a fresh worktree
|
|
305
|
+
// without an install will see `Cannot find module '@openparachute/...'`
|
|
306
|
+
// failures that are worktree-resolution artifacts, NOT a regression in the
|
|
307
|
+
// code under test. (A temp dir under `os.tmpdir()` would break this walk and
|
|
308
|
+
// also break `cli.ts`'s `../package.json` import, hence the in-repo sandbox.)
|
|
309
|
+
sandbox = mkdtempSync(join(REPO_ROOT, ".tmp-cli-iso-"));
|
|
310
|
+
cpSync(join(REPO_ROOT, "src"), join(sandbox, "src"), { recursive: true });
|
|
311
|
+
cpSync(join(REPO_ROOT, "package.json"), join(sandbox, "package.json"));
|
|
312
|
+
sandboxCli = join(sandbox, "src", "cli.ts");
|
|
313
|
+
// Append an unconditional throw so the module fails at eval. `migrate-cutover`
|
|
314
|
+
// is the canonical real-world case (the 0.6.2 bug) AND it's reachable by both
|
|
315
|
+
// eager paths the fix addresses: the direct `cli.ts` import and the transitive
|
|
316
|
+
// `cli.ts → lifecycle.ts → migrate-offer.ts → migrate-cutover.ts` chain.
|
|
317
|
+
appendFileSync(
|
|
318
|
+
join(sandbox, "src", "commands", "migrate-cutover.ts"),
|
|
319
|
+
'\nthrow new ReferenceError("boom: migrate-cutover failed at module eval");\n',
|
|
320
|
+
);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
afterAll(() => {
|
|
324
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
async function runSandbox(
|
|
328
|
+
args: string[],
|
|
329
|
+
): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
330
|
+
const proc = Bun.spawn([process.execPath, sandboxCli, ...args], {
|
|
331
|
+
stdout: "pipe",
|
|
332
|
+
stderr: "pipe",
|
|
333
|
+
env: {
|
|
334
|
+
...process.env,
|
|
335
|
+
HOME: "/tmp/parachute-hub-nonexistent-home",
|
|
336
|
+
PARACHUTE_HOME: "/tmp/parachute-hub-nonexistent-home",
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
340
|
+
new Response(proc.stdout).text(),
|
|
341
|
+
new Response(proc.stderr).text(),
|
|
342
|
+
proc.exited,
|
|
343
|
+
]);
|
|
344
|
+
return { code, stdout, stderr };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
test("a command module that throws at eval does NOT abort --help", async () => {
|
|
348
|
+
const { code, stdout } = await runSandbox(["--help"]);
|
|
349
|
+
expect(code).toBe(0);
|
|
350
|
+
expect(stdout).toMatch(/parachute install/);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("an unrelated command still dispatches when one module is broken", async () => {
|
|
354
|
+
// `status` doesn't touch migrate-cutover at all — it must still run to
|
|
355
|
+
// completion (exit 0) rather than dying at top-level import.
|
|
356
|
+
const { code } = await runSandbox(["status"]);
|
|
357
|
+
expect(code).toBe(0);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("lifecycle commands survive the broken transitive path", async () => {
|
|
361
|
+
// `stop` pulls in `migrate-offer.ts` (for the §7.5 detect-and-offer), which
|
|
362
|
+
// used to EAGERLY import the broken `migrate-cutover.ts`. With the import now
|
|
363
|
+
// `import type` + lazy, `stop --help` must not crash.
|
|
364
|
+
const { code, stdout } = await runSandbox(["stop", "--help"]);
|
|
365
|
+
expect(code).toBe(0);
|
|
366
|
+
expect(stdout).toMatch(/parachute stop/);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("the broken command itself exits 1 with a 'failed to load' message", async () => {
|
|
370
|
+
const { code, stderr } = await runSandbox(["migrate", "--to-supervised"]);
|
|
371
|
+
expect(code).toBe(1);
|
|
372
|
+
expect(stderr).toMatch(/parachute migrate: failed to load/);
|
|
373
|
+
expect(stderr).toMatch(/boom: migrate-cutover failed at module eval/);
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// hub#534: `migrate --teardown` must surface teardownHubUnit's outcome — pre-fix
|
|
378
|
+
// it ignored `removed` + `messages` and always exited 0, so a non-removal looked
|
|
379
|
+
// like success. We exercise the REAL CLI arm via the in-repo sandbox (same shape
|
|
380
|
+
// as the lazy-import suite above) so the arm's exit-code mapping runs end-to-end
|
|
381
|
+
// without shelling out to real launchctl/systemctl: the sandboxed
|
|
382
|
+
// `teardownHubUnit` is replaced with a stub keyed off an env var.
|
|
383
|
+
describe("cli migrate --teardown exit-code policy (hub#534)", () => {
|
|
384
|
+
let sandbox: string;
|
|
385
|
+
let sandboxCli: string;
|
|
386
|
+
|
|
387
|
+
beforeAll(() => {
|
|
388
|
+
sandbox = mkdtempSync(join(REPO_ROOT, ".tmp-cli-teardown-"));
|
|
389
|
+
cpSync(join(REPO_ROOT, "src"), join(sandbox, "src"), { recursive: true });
|
|
390
|
+
cpSync(join(REPO_ROOT, "package.json"), join(sandbox, "package.json"));
|
|
391
|
+
sandboxCli = join(sandbox, "src", "cli.ts");
|
|
392
|
+
// Replace migrate-cutover.ts entirely with a minimal stub exporting only the
|
|
393
|
+
// `teardownHubUnit` the CLI arm calls. Its result is driven by
|
|
394
|
+
// `TEARDOWN_FAKE` so one rewrite covers all three outcomes. It logs the same
|
|
395
|
+
// human-facing lines the real function would (so the stdout assertions match
|
|
396
|
+
// real behavior), and the CLI owns the exit code.
|
|
397
|
+
writeFileSync(
|
|
398
|
+
join(sandbox, "src", "commands", "migrate-cutover.ts"),
|
|
399
|
+
[
|
|
400
|
+
"export function teardownHubUnit() {",
|
|
401
|
+
' const mode = process.env.TEARDOWN_FAKE ?? "removed";',
|
|
402
|
+
' if (mode === "removed") {',
|
|
403
|
+
' console.log("Removed systemd unit parachute-hub.service — the hub no longer starts on boot.");',
|
|
404
|
+
" return { removed: true, messages: [] };",
|
|
405
|
+
" }",
|
|
406
|
+
' if (mode === "failure") {',
|
|
407
|
+
' console.log("Hub-unit teardown did not complete:");',
|
|
408
|
+
' console.log(" systemctl disable failed: permission denied");',
|
|
409
|
+
' return { removed: false, messages: ["systemctl disable failed: permission denied"] };',
|
|
410
|
+
" }",
|
|
411
|
+
' console.log("No hub unit was installed — nothing to tear down.");',
|
|
412
|
+
" return { removed: false, messages: [] };",
|
|
413
|
+
"}",
|
|
414
|
+
"",
|
|
415
|
+
].join("\n"),
|
|
416
|
+
);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
afterAll(() => {
|
|
420
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
async function runTeardown(
|
|
424
|
+
fake: string,
|
|
425
|
+
): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
426
|
+
const proc = Bun.spawn([process.execPath, sandboxCli, "migrate", "--teardown"], {
|
|
427
|
+
stdout: "pipe",
|
|
428
|
+
stderr: "pipe",
|
|
429
|
+
env: {
|
|
430
|
+
...process.env,
|
|
431
|
+
HOME: "/tmp/parachute-hub-nonexistent-home",
|
|
432
|
+
PARACHUTE_HOME: "/tmp/parachute-hub-nonexistent-home",
|
|
433
|
+
TEARDOWN_FAKE: fake,
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
437
|
+
new Response(proc.stdout).text(),
|
|
438
|
+
new Response(proc.stderr).text(),
|
|
439
|
+
proc.exited,
|
|
440
|
+
]);
|
|
441
|
+
return { code, stdout, stderr };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
test("removed → exit 0 with the removal message", async () => {
|
|
445
|
+
const { code, stdout } = await runTeardown("removed");
|
|
446
|
+
expect(code).toBe(0);
|
|
447
|
+
expect(stdout).toMatch(/Removed systemd unit/);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("nothing installed → informational exit 0", async () => {
|
|
451
|
+
const { code, stdout } = await runTeardown("nothing");
|
|
452
|
+
expect(code).toBe(0);
|
|
453
|
+
expect(stdout).toMatch(/nothing to tear down/);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("removal failure (messages present) → exit 1, reason on stderr", async () => {
|
|
457
|
+
const { code, stdout, stderr } = await runTeardown("failure");
|
|
458
|
+
expect(code).toBe(1);
|
|
459
|
+
// The function logged the failure header to stdout; the CLI re-surfaces the
|
|
460
|
+
// detail on stderr so a script's `2>` capture sees the reason.
|
|
461
|
+
expect(stdout).toMatch(/did not complete/);
|
|
462
|
+
expect(stderr).toMatch(/permission denied/);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
280
466
|
describe("cli friendly errors", () => {
|
|
281
467
|
test("malformed services.json prints friendly error not stack trace", async () => {
|
|
282
468
|
const dir = mkdtempSync(join(tmpdir(), "pcli-bad-"));
|