@openparachute/hub 0.6.3 → 0.6.4-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.
Files changed (97) hide show
  1. package/package.json +1 -2
  2. package/src/__tests__/account-home-ui.test.ts +344 -110
  3. package/src/__tests__/account-mirror.test.ts +156 -0
  4. package/src/__tests__/account-setup.test.ts +880 -0
  5. package/src/__tests__/account-usage.test.ts +137 -0
  6. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  7. package/src/__tests__/account-vault-token.test.ts +53 -1
  8. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  9. package/src/__tests__/admin-vaults.test.ts +20 -0
  10. package/src/__tests__/api-account.test.ts +236 -4
  11. package/src/__tests__/api-invites.test.ts +217 -0
  12. package/src/__tests__/api-mint-token.test.ts +259 -10
  13. package/src/__tests__/api-modules-ops.test.ts +195 -3
  14. package/src/__tests__/api-modules.test.ts +40 -4
  15. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  16. package/src/__tests__/auto-wire.test.ts +101 -1
  17. package/src/__tests__/cli.test.ts +188 -2
  18. package/src/__tests__/cloudflare-state.test.ts +104 -0
  19. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  20. package/src/__tests__/expose-cloudflare.test.ts +135 -9
  21. package/src/__tests__/expose-interactive.test.ts +234 -7
  22. package/src/__tests__/expose-supervisor-version.test.ts +104 -0
  23. package/src/__tests__/expose.test.ts +10 -5
  24. package/src/__tests__/grants.test.ts +197 -8
  25. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  26. package/src/__tests__/hub-server.test.ts +761 -13
  27. package/src/__tests__/hub-unit.test.ts +185 -0
  28. package/src/__tests__/init.test.ts +579 -3
  29. package/src/__tests__/install.test.ts +448 -2
  30. package/src/__tests__/invites.test.ts +220 -0
  31. package/src/__tests__/launchctl-guard.test.ts +185 -0
  32. package/src/__tests__/migrate-cutover.test.ts +33 -0
  33. package/src/__tests__/module-ops-client.test.ts +68 -0
  34. package/src/__tests__/scope-explanations.test.ts +16 -0
  35. package/src/__tests__/serve-boot.test.ts +74 -1
  36. package/src/__tests__/serve.test.ts +121 -7
  37. package/src/__tests__/setup-wizard.test.ts +110 -0
  38. package/src/__tests__/spawn-path.test.ts +191 -0
  39. package/src/__tests__/status.test.ts +64 -0
  40. package/src/__tests__/supervisor.test.ts +374 -0
  41. package/src/__tests__/users.test.ts +66 -0
  42. package/src/__tests__/well-known.test.ts +25 -0
  43. package/src/__tests__/wizard.test.ts +72 -1
  44. package/src/account-home-ui.ts +481 -235
  45. package/src/account-mirror.ts +126 -0
  46. package/src/account-setup.ts +381 -0
  47. package/src/account-usage.ts +118 -0
  48. package/src/account-vault-admin-token.ts +242 -0
  49. package/src/account-vault-token.ts +36 -2
  50. package/src/admin-login-ui.ts +121 -0
  51. package/src/admin-vault-admin-token.ts +8 -2
  52. package/src/admin-vaults.ts +137 -29
  53. package/src/api-account.ts +118 -1
  54. package/src/api-invites.ts +345 -0
  55. package/src/api-mint-token.ts +81 -0
  56. package/src/api-modules-ops.ts +168 -53
  57. package/src/api-modules.ts +36 -0
  58. package/src/auto-wire.ts +87 -0
  59. package/src/cli.ts +128 -34
  60. package/src/cloudflare/detect.ts +1 -1
  61. package/src/cloudflare/state.ts +104 -8
  62. package/src/commands/expose-2fa-warning.ts +17 -13
  63. package/src/commands/expose-cloudflare.ts +103 -36
  64. package/src/commands/expose-interactive.ts +163 -17
  65. package/src/commands/expose-supervisor.ts +45 -0
  66. package/src/commands/init.ts +183 -4
  67. package/src/commands/install.ts +321 -3
  68. package/src/commands/migrate-cutover.ts +12 -5
  69. package/src/commands/serve-boot.ts +33 -3
  70. package/src/commands/serve.ts +158 -37
  71. package/src/commands/status.ts +9 -1
  72. package/src/commands/wizard.ts +36 -2
  73. package/src/grants.ts +113 -0
  74. package/src/help.ts +18 -5
  75. package/src/hub-db.ts +70 -2
  76. package/src/hub-server.ts +438 -41
  77. package/src/hub-settings.ts +3 -3
  78. package/src/hub-unit.ts +259 -9
  79. package/src/invites.ts +291 -0
  80. package/src/launchctl-guard.ts +131 -0
  81. package/src/managed-unit.ts +13 -3
  82. package/src/migrate-offer.ts +15 -6
  83. package/src/module-ops-client.ts +47 -22
  84. package/src/scope-attenuation.ts +19 -0
  85. package/src/scope-explanations.ts +9 -1
  86. package/src/service-spec.ts +17 -4
  87. package/src/setup-wizard.ts +34 -2
  88. package/src/spawn-path.ts +148 -0
  89. package/src/supervisor.ts +232 -7
  90. package/src/users.ts +54 -8
  91. package/src/vault-hub-origin-env.ts +28 -0
  92. package/src/vault-name.ts +13 -1
  93. package/src/well-known.ts +13 -0
  94. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  95. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  96. package/web/ui/dist/index.html +2 -2
  97. package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
@@ -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 { SCRIBE_AUTH_ENV_KEY, SCRIBE_URL_ENV_KEY, autoWireScribeAuth } from "../auto-wire.ts";
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-"));
@@ -7,12 +7,15 @@ import {
7
7
  CloudflaredStateError,
8
8
  type CloudflaredTunnelRecord,
9
9
  clearCloudflaredState,
10
+ clearPendingHostname,
10
11
  findTunnelRecord,
11
12
  listTunnelRecords,
12
13
  readCloudflaredState,
14
+ readPendingHostname,
13
15
  withTunnelRecord,
14
16
  withoutTunnelRecord,
15
17
  writeCloudflaredState,
18
+ writePendingHostname,
16
19
  } from "../cloudflare/state.ts";
17
20
 
18
21
  function makeTempPath(): { path: string; cleanup: () => void } {
@@ -250,3 +253,104 @@ describe("cloudflared state — record helpers", () => {
250
253
  expect(listTunnelRecords(undefined)).toEqual([]);
251
254
  });
252
255
  });
256
+
257
+ describe("hub#567 pending hostname", () => {
258
+ test("read returns undefined when no state file / no pending hostname", () => {
259
+ const { path, cleanup } = makeTempPath();
260
+ try {
261
+ expect(readPendingHostname(path)).toBeUndefined();
262
+ writeCloudflaredState(sample, path);
263
+ expect(readPendingHostname(path)).toBeUndefined();
264
+ } finally {
265
+ cleanup();
266
+ }
267
+ });
268
+
269
+ test("write then read round-trips the pending hostname (seeds empty state)", () => {
270
+ const { path, cleanup } = makeTempPath();
271
+ try {
272
+ writePendingHostname("techne.parachute.computer", path);
273
+ expect(readPendingHostname(path)).toBe("techne.parachute.computer");
274
+ const state = readCloudflaredState(path);
275
+ expect(state?.pendingHostname).toBe("techne.parachute.computer");
276
+ expect(state?.tunnels).toEqual({});
277
+ } finally {
278
+ cleanup();
279
+ }
280
+ });
281
+
282
+ test("write preserves existing tunnel records", () => {
283
+ const { path, cleanup } = makeTempPath();
284
+ try {
285
+ writeCloudflaredState(sample, path);
286
+ writePendingHostname("techne.parachute.computer", path);
287
+ const state = readCloudflaredState(path);
288
+ expect(state?.pendingHostname).toBe("techne.parachute.computer");
289
+ expect(state?.tunnels.parachute).toEqual(sampleRecord);
290
+ } finally {
291
+ cleanup();
292
+ }
293
+ });
294
+
295
+ test("clear drops the pending hostname but keeps tunnel records", () => {
296
+ const { path, cleanup } = makeTempPath();
297
+ try {
298
+ writeCloudflaredState({ ...sample, pendingHostname: "techne.parachute.computer" }, path);
299
+ clearPendingHostname(path);
300
+ const state = readCloudflaredState(path);
301
+ expect(state?.pendingHostname).toBeUndefined();
302
+ expect(state?.tunnels.parachute).toEqual(sampleRecord);
303
+ } finally {
304
+ cleanup();
305
+ }
306
+ });
307
+
308
+ test("clear removes the state file entirely when no tunnels remain", () => {
309
+ const { path, cleanup } = makeTempPath();
310
+ try {
311
+ writePendingHostname("techne.parachute.computer", path);
312
+ expect(existsSync(path)).toBe(true);
313
+ clearPendingHostname(path);
314
+ expect(existsSync(path)).toBe(false);
315
+ } finally {
316
+ cleanup();
317
+ }
318
+ });
319
+
320
+ test("validate preserves a pending hostname round-tripped through the bytes", () => {
321
+ const { path, cleanup } = makeTempPath();
322
+ try {
323
+ const withPending: CloudflaredState = { ...sample, pendingHostname: "a.example.com" };
324
+ writeCloudflaredState(withPending, path);
325
+ expect(readCloudflaredState(path)).toEqual(withPending);
326
+ } finally {
327
+ cleanup();
328
+ }
329
+ });
330
+
331
+ test("withTunnelRecord preserves an existing pending hostname", () => {
332
+ const seed: CloudflaredState = { version: 2, tunnels: {}, pendingHostname: "a.example.com" };
333
+ const next = withTunnelRecord(seed, sampleRecord);
334
+ expect(next.pendingHostname).toBe("a.example.com");
335
+ expect(next.tunnels.parachute).toEqual(sampleRecord);
336
+ });
337
+
338
+ test("withoutTunnelRecord carries the pending hostname when it's the only thing left", () => {
339
+ const seed: CloudflaredState = {
340
+ version: 2,
341
+ tunnels: { parachute: sampleRecord },
342
+ pendingHostname: "a.example.com",
343
+ };
344
+ // Removing the last tunnel must NOT discard a typed-but-not-routed hostname.
345
+ expect(withoutTunnelRecord(seed, "parachute")).toEqual({
346
+ version: 2,
347
+ tunnels: {},
348
+ pendingHostname: "a.example.com",
349
+ });
350
+ });
351
+
352
+ test("withoutTunnelRecord returns undefined when no tunnels AND no pending hostname remain", () => {
353
+ const seed: CloudflaredState = { version: 2, tunnels: { parachute: sampleRecord } };
354
+ expect(withoutTunnelRecord(seed, "parachute")).toBeUndefined();
355
+ });
356
+ });
@@ -87,13 +87,16 @@ describe("printPublic2FAWarning", () => {
87
87
  });
88
88
  expect(fired).toBe(true);
89
89
  const joined = logs.join("\n");
90
- // hub#473: real hub-login 2FA. The warning now recommends the real
91
- // `parachute auth 2fa enroll` path (+ the /account/2fa browser path) and
92
- // still nudges a strong owner password.
93
- expect(joined).toContain("/login is now reachable on the public internet");
90
+ // hub#473: real hub-login 2FA. The recommendation now leads with the
91
+ // friendly "strongly recommended" framing, points at both the /account/2fa
92
+ // browser path and the `parachute auth 2fa enroll` CLI path, makes clear
93
+ // it's not a requirement, and still nudges a strong owner password.
94
+ expect(joined).toContain("Strongly recommended: turn on two-factor authentication");
95
+ expect(joined).toContain("reachable from the public internet");
94
96
  expect(joined).toContain("https://vault.example.com/login");
97
+ expect(joined).toContain("https://vault.example.com/account/2fa");
95
98
  expect(joined).toContain("parachute auth 2fa enroll");
96
- expect(joined).toContain("/account/2fa");
99
+ expect(joined).toContain("It's a recommendation, not a requirement");
97
100
  expect(joined).toContain("parachute auth set-password");
98
101
  });
99
102
 
@@ -120,9 +123,9 @@ describe("printPublic2FAWarning", () => {
120
123
  publicUrl: "https://vault.example.com",
121
124
  });
122
125
  expect(fired).toBe(true);
123
- expect(logs.some((l) => l.includes("/login is now reachable on the public internet"))).toBe(
124
- true,
125
- );
126
+ expect(
127
+ logs.some((l) => l.includes("Strongly recommended: turn on two-factor authentication")),
128
+ ).toBe(true);
126
129
  });
127
130
 
128
131
  test("embeds the supplied publicUrl into the /login pointer", () => {
@@ -509,16 +509,31 @@ describe("exposeCloudflareUp", () => {
509
509
  }
510
510
  });
511
511
 
512
- test("errors out when vault isn't installed", async () => {
512
+ test("hub#564: continues (no vault gate) when vault isn't installed — routes the hub anyway", async () => {
513
513
  const env = makeEnv({ includeVault: false });
514
514
  try {
515
- const { runner } = queueRunner([{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }]);
516
- const { spawner } = fakeSpawner(0);
515
+ const uuid = "3d2b8d8f-2345-6789-abcd-ef0123456789";
516
+ const derived = "parachute-vault-example-com";
517
+ // The full chain must run now that the vault gate is gone: version,
518
+ // tunnel list, tunnel create, route dns.
519
+ const { runner } = queueRunner([
520
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }, // --version
521
+ { code: 0, stdout: "[]", stderr: "" }, // tunnel list
522
+ {
523
+ code: 0,
524
+ stdout: `Created tunnel ${derived} with id ${uuid}\n`,
525
+ stderr: "",
526
+ }, // create
527
+ { code: 0, stdout: "", stderr: "" }, // route dns
528
+ ]);
529
+ const { spawner } = fakeSpawner(42000);
517
530
  const logs: string[] = [];
518
531
 
519
532
  const code = await exposeCloudflareUp("vault.example.com", {
520
533
  runner,
521
534
  spawner,
535
+ alive: () => false,
536
+ kill: () => {},
522
537
  log: (l) => logs.push(l),
523
538
  manifestPath: env.manifestPath,
524
539
  statePath: env.statePath,
@@ -528,10 +543,17 @@ describe("exposeCloudflareUp", () => {
528
543
  cloudflaredHome: env.cloudflaredHome,
529
544
  configDir: env.configDir,
530
545
  skipHub: true,
546
+ now: () => new Date("2026-04-22T12:00:00Z"),
531
547
  });
532
548
 
533
- expect(code).toBe(1);
534
- expect(logs.join("\n")).toContain("parachute install vault");
549
+ // The expose succeeds (the hub is what gets routed), with a courtesy
550
+ // note instead of a dead-end. No "install vault" gate, no Vault-URL
551
+ // footer (vault isn't there to point at).
552
+ expect(code).toBe(0);
553
+ const joined = logs.join("\n");
554
+ expect(joined).toContain("vault not installed yet");
555
+ expect(joined).not.toContain("nothing to route");
556
+ expect(joined).not.toMatch(/^\s*Vault:/m);
535
557
  } finally {
536
558
  env.cleanup();
537
559
  }
@@ -1230,9 +1252,10 @@ describe("exposeCloudflareUp", () => {
1230
1252
 
1231
1253
  expect(code).toBe(0);
1232
1254
  const joined = logs.join("\n");
1233
- // hub#473: real hub-login 2FA — the warning now recommends the real
1234
- // `parachute auth 2fa enroll` path.
1235
- expect(joined).toContain("/login is now reachable on the public internet");
1255
+ // hub#473: real hub-login 2FA — the recommendation now leads with the
1256
+ // friendly "strongly recommended" framing and the real `parachute auth
1257
+ // 2fa enroll` / `/account/2fa` paths.
1258
+ expect(joined).toContain("Strongly recommended: turn on two-factor authentication");
1236
1259
  expect(joined).toContain("https://vault.example.com/login");
1237
1260
  expect(joined).toContain("parachute auth 2fa enroll");
1238
1261
  } finally {
@@ -1281,7 +1304,7 @@ describe("exposeCloudflareUp", () => {
1281
1304
 
1282
1305
  expect(code).toBe(0);
1283
1306
  const joined = logs.join("\n");
1284
- expect(joined).not.toContain("/login is now reachable on the public internet");
1307
+ expect(joined).not.toContain("Strongly recommended: turn on two-factor authentication");
1285
1308
  // The contextual 2FA warning is suppressed (2FA already enrolled); the
1286
1309
  // always-shown owner-password guidance from `printAuthGuidance` still
1287
1310
  // appears, and it now (hub#473) also surfaces the real `2fa enroll`
@@ -1441,6 +1464,109 @@ describe("exposeCloudflareOff", () => {
1441
1464
  }
1442
1465
  });
1443
1466
 
1467
+ test("clears stale PARACHUTE_HUB_ORIGIN from vault/.env on last-tunnel down (#503)", async () => {
1468
+ const env = makeEnv();
1469
+ try {
1470
+ // Seed the stale public origin the up-path persisted into vault/.env.
1471
+ // After teardown the hub is loopback-only, so leaving this would pin a
1472
+ // public expected issuer and 401 every request on the next vault restart.
1473
+ const vaultEnvPath = join(env.configDir, "vault", ".env");
1474
+ require("node:fs").mkdirSync(join(env.configDir, "vault"), { recursive: true });
1475
+ writeFileSync(vaultEnvPath, "PARACHUTE_HUB_ORIGIN=https://vault.example.com\n");
1476
+
1477
+ writeCloudflaredState(
1478
+ {
1479
+ version: 2,
1480
+ tunnels: {
1481
+ parachute: {
1482
+ pid: 55557,
1483
+ tunnelUuid: "ffffffff-0000-0000-0000-000000000006",
1484
+ tunnelName: "parachute",
1485
+ hostname: "vault.example.com",
1486
+ startedAt: "2026-04-22T12:00:00.000Z",
1487
+ configPath: env.configPath,
1488
+ },
1489
+ },
1490
+ },
1491
+ env.statePath,
1492
+ );
1493
+
1494
+ const logs: string[] = [];
1495
+ const code = await exposeCloudflareOff({
1496
+ configDir: env.configDir,
1497
+ statePath: env.statePath,
1498
+ exposeStatePath: env.exposeStatePath,
1499
+ alive: () => false,
1500
+ kill: () => {},
1501
+ log: (l) => logs.push(l),
1502
+ });
1503
+
1504
+ expect(code).toBe(0);
1505
+ // The stale public origin is gone — vault reverts to its loopback default.
1506
+ expect(readEnvFileValues(vaultEnvPath).PARACHUTE_HUB_ORIGIN).toBeUndefined();
1507
+ // Operator is told what to restart so a running vault picks up the change.
1508
+ expect(logs.join("\n")).toContain("cleared PARACHUTE_HUB_ORIGIN");
1509
+ expect(logs.join("\n")).toContain("parachute restart vault");
1510
+ } finally {
1511
+ env.cleanup();
1512
+ }
1513
+ });
1514
+
1515
+ test("leaves vault/.env untouched while other tunnels survive (#503)", async () => {
1516
+ const env = makeEnv();
1517
+ try {
1518
+ const vaultEnvPath = join(env.configDir, "vault", ".env");
1519
+ require("node:fs").mkdirSync(join(env.configDir, "vault"), { recursive: true });
1520
+ writeFileSync(vaultEnvPath, "PARACHUTE_HUB_ORIGIN=https://vault.example.com\n");
1521
+
1522
+ // Two tunnels; tear down only one by name → the box stays exposed, so the
1523
+ // persisted public origin must remain (clearing it would break the live
1524
+ // tunnel's iss check). Symmetric with the expose-state.json retention.
1525
+ writeCloudflaredState(
1526
+ {
1527
+ version: 2,
1528
+ tunnels: {
1529
+ alpha: {
1530
+ pid: 55558,
1531
+ tunnelUuid: "11111111-0000-0000-0000-000000000007",
1532
+ tunnelName: "alpha",
1533
+ hostname: "alpha.example.com",
1534
+ startedAt: "2026-04-22T12:00:00.000Z",
1535
+ configPath: env.configPath,
1536
+ },
1537
+ beta: {
1538
+ pid: 55559,
1539
+ tunnelUuid: "22222222-0000-0000-0000-000000000008",
1540
+ tunnelName: "beta",
1541
+ hostname: "beta.example.com",
1542
+ startedAt: "2026-04-22T12:00:00.000Z",
1543
+ configPath: env.configPath,
1544
+ },
1545
+ },
1546
+ },
1547
+ env.statePath,
1548
+ );
1549
+
1550
+ const code = await exposeCloudflareOff({
1551
+ configDir: env.configDir,
1552
+ tunnelName: "alpha",
1553
+ statePath: env.statePath,
1554
+ exposeStatePath: env.exposeStatePath,
1555
+ alive: () => false,
1556
+ kill: () => {},
1557
+ log: () => {},
1558
+ });
1559
+
1560
+ expect(code).toBe(0);
1561
+ // Beta tunnel survives → public origin stays.
1562
+ expect(readEnvFileValues(vaultEnvPath).PARACHUTE_HUB_ORIGIN).toBe(
1563
+ "https://vault.example.com",
1564
+ );
1565
+ } finally {
1566
+ env.cleanup();
1567
+ }
1568
+ });
1569
+
1444
1570
  test("clears stale state when the process is already gone", async () => {
1445
1571
  const env = makeEnv();
1446
1572
  try {