@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.
- package/package.json +1 -2
- package/src/__tests__/account-home-ui.test.ts +344 -110
- package/src/__tests__/account-mirror.test.ts +156 -0
- 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 +236 -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 +195 -3
- 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__/cloudflare-state.test.ts +104 -0
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +135 -9
- package/src/__tests__/expose-interactive.test.ts +234 -7
- package/src/__tests__/expose-supervisor-version.test.ts +104 -0
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/grants.test.ts +197 -8
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +761 -13
- package/src/__tests__/hub-unit.test.ts +185 -0
- package/src/__tests__/init.test.ts +579 -3
- package/src/__tests__/install.test.ts +448 -2
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +33 -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__/setup-wizard.test.ts +110 -0
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +374 -0
- package/src/__tests__/users.test.ts +66 -0
- package/src/__tests__/well-known.test.ts +25 -0
- package/src/__tests__/wizard.test.ts +72 -1
- package/src/account-home-ui.ts +481 -235
- package/src/account-mirror.ts +126 -0
- 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 +36 -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 +118 -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 +128 -34
- package/src/cloudflare/detect.ts +1 -1
- package/src/cloudflare/state.ts +104 -8
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/expose-cloudflare.ts +103 -36
- package/src/commands/expose-interactive.ts +163 -17
- package/src/commands/expose-supervisor.ts +45 -0
- package/src/commands/init.ts +183 -4
- package/src/commands/install.ts +321 -3
- 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/commands/wizard.ts +36 -2
- package/src/grants.ts +113 -0
- package/src/help.ts +18 -5
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +438 -41
- package/src/hub-settings.ts +3 -3
- package/src/hub-unit.ts +259 -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 +17 -4
- package/src/setup-wizard.ts +34 -2
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +232 -7
- package/src/users.ts +54 -8
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/src/well-known.ts +13 -0
- 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
|
@@ -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-"));
|
|
@@ -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
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
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("
|
|
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(
|
|
124
|
-
|
|
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("
|
|
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
|
|
516
|
-
const
|
|
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
|
-
|
|
534
|
-
|
|
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
|
|
1234
|
-
//
|
|
1235
|
-
|
|
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("
|
|
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 {
|