@openparachute/hub 0.7.7-rc.15 → 0.7.7-rc.17

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.7-rc.15",
3
+ "version": "0.7.7-rc.17",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -34,7 +34,6 @@
34
34
  },
35
35
  "devDependencies": {
36
36
  "@biomejs/biome": "^1.9.4",
37
- "@openparachute/door-contract": "workspace:*",
38
37
  "@types/bun": "latest",
39
38
  "@types/qrcode": "^1.5.6"
40
39
  },
@@ -44,6 +43,7 @@
44
43
  "dependencies": {
45
44
  "@node-rs/argon2": "^2.0.2",
46
45
  "@openparachute/depcheck": "0.1.1",
46
+ "@openparachute/door-contract": "^0.6.0",
47
47
  "jose": "^6.2.2",
48
48
  "otpauth": "^9.5.0",
49
49
  "qrcode": "^1.5.4"
@@ -219,6 +219,80 @@ describe("deriveWizardState", () => {
219
219
  }
220
220
  });
221
221
 
222
+ test("real-version vault row with paths:[] is NOT a real vault (e2e boot-spawned-child fix)", async () => {
223
+ // The Tier-1 e2e wizard-skip root cause. When the hub boot-spawns the vault
224
+ // MODULE child, vault's selfRegister writes its services.json row at the
225
+ // REAL package version (e.g. 0.7.4-rc.1, NOT SEED_VERSION) but with
226
+ // `paths: []` because no vault instance exists yet. The SEED_VERSION
227
+ // sentinel alone did NOT catch this — a bare `version !== SEED_VERSION`
228
+ // check read the empty-paths row as a real vault, so the wizard skipped its
229
+ // vault step and `--vault-name` was ignored. hasRealVault must be
230
+ // INSTANCE-aware: no mount path → not a real vault (the #478 empty-paths
231
+ // discrimination findExistingVault already uses).
232
+ const db = openHubDb(hubDbPath(h.dir));
233
+ try {
234
+ await createUser(db, "owner", "pw");
235
+ writeManifest(
236
+ {
237
+ services: [
238
+ {
239
+ name: "parachute-vault",
240
+ version: "0.7.4-rc.1",
241
+ port: 1940,
242
+ paths: [],
243
+ health: "/health",
244
+ },
245
+ ],
246
+ },
247
+ h.manifestPath,
248
+ );
249
+ const s = deriveWizardState({
250
+ db,
251
+ manifestPath: h.manifestPath,
252
+ readExposeStateFn: h.readExposeStateFn,
253
+ });
254
+ expect(s.hasRealVault).toBe(false);
255
+ expect(s.hasVault).toBe(false);
256
+ expect(s.step).toBe("vault");
257
+ } finally {
258
+ db.close();
259
+ }
260
+ });
261
+
262
+ test("real-version vault row WITH a mount path IS a real vault (positive control)", async () => {
263
+ // The counterpart to the empty-paths case: a genuinely-created vault carries
264
+ // a `/vault/<name>` path at a real version, so hasRealVault must be true and
265
+ // the wizard advances past the vault step.
266
+ const db = openHubDb(hubDbPath(h.dir));
267
+ try {
268
+ await createUser(db, "owner", "pw");
269
+ writeManifest(
270
+ {
271
+ services: [
272
+ {
273
+ name: "parachute-vault",
274
+ version: "0.7.4-rc.1",
275
+ port: 1940,
276
+ paths: ["/vault/e2e"],
277
+ health: "/health",
278
+ },
279
+ ],
280
+ },
281
+ h.manifestPath,
282
+ );
283
+ const s = deriveWizardState({
284
+ db,
285
+ manifestPath: h.manifestPath,
286
+ readExposeStateFn: h.readExposeStateFn,
287
+ });
288
+ expect(s.hasRealVault).toBe(true);
289
+ expect(s.hasVault).toBe(true);
290
+ expect(s.step).toBe("expose");
291
+ } finally {
292
+ db.close();
293
+ }
294
+ });
295
+
222
296
  test("expose step when admin + vault exist but expose mode not set yet (hub#268 Item 2)", async () => {
223
297
  const db = openHubDb(hubDbPath(h.dir));
224
298
  try {
@@ -1712,13 +1786,15 @@ describe("handleSetupVaultPost", () => {
1712
1786
  }
1713
1787
  });
1714
1788
 
1715
- test("idempotent — second POST while supervisor is running doesn't fire a second `bun add` (N2)", async () => {
1789
+ test("second POST while supervisor is running but vault UNREGISTERED synthesize success, no 2nd bun add, no double provision (N2 race)", async () => {
1716
1790
  // Reviewer-flagged race: two concurrent POSTs before either seeds
1717
- // services.json both pass `state.hasVault === false` and each fire
1718
- // `runInstall` each fires `bun add -g`. The wizard mirrors the
1719
- // `handleInstall` guard pattern: if the supervisor already has a
1720
- // live (starting/running/restarting) state for vault, mark the new
1721
- // op succeeded synchronously and skip the second install.
1791
+ // services.json both pass `state.hasVault === false`. The supervisor is
1792
+ // already running vault (a prior POST's runInstall spawned it) but the
1793
+ // services.json row hasn't been written yet so `vaultAlreadyRegistered`
1794
+ // is false. The wizard must NOT fire a second `bun add -g` AND must NOT
1795
+ // fire `provisionVault` (which would `parachute install vault` a second
1796
+ // time in this pre-seed window — reviewer N2). Instead it synthesizes a
1797
+ // succeeded op and lets the in-flight install create the vault.
1722
1798
  const db = openHubDb(hubDbPath(h.dir));
1723
1799
  try {
1724
1800
  const user = await createUser(db, "owner", "pw");
@@ -1734,8 +1810,9 @@ describe("handleSetupVaultPost", () => {
1734
1810
  });
1735
1811
  const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
1736
1812
  // Real supervisor with the never-exits spawn stub from makeSupervisor.
1737
- // Pre-spawn vault so `supervisor.get("vault").status === "starting"`
1738
- // by the time the wizard's POST runs.
1813
+ // Pre-spawn vault so `supervisor.get("vault").status === "starting"`.
1814
+ // The harness manifest is EMPTY (vault not registered) — the pre-seed
1815
+ // window.
1739
1816
  const supervisor = makeSupervisor();
1740
1817
  await supervisor.start({ short: "vault", cmd: ["bun", "noop"] });
1741
1818
  const runCalls: string[][] = [];
@@ -1743,6 +1820,17 @@ describe("handleSetupVaultPost", () => {
1743
1820
  runCalls.push([...cmd]);
1744
1821
  return 0;
1745
1822
  };
1823
+ // provisionVault must NOT be called in the unregistered race window.
1824
+ const provisionCalls: string[] = [];
1825
+ const provisionVaultImpl = async (name: string) => {
1826
+ provisionCalls.push(name);
1827
+ return {
1828
+ ok: true as const,
1829
+ created: true,
1830
+ entry: { name, url: `https://hub.example/vault/${name}`, version: "1.0.0" },
1831
+ createJson: null,
1832
+ };
1833
+ };
1746
1834
  const post = await handleSetupVaultPost(
1747
1835
  req("/admin/setup/vault", {
1748
1836
  method: "POST",
@@ -1763,17 +1851,17 @@ describe("handleSetupVaultPost", () => {
1763
1851
  supervisor,
1764
1852
  registry: getDefaultOperationsRegistry(),
1765
1853
  run: stubbedRun,
1854
+ provisionVaultImpl,
1766
1855
  },
1767
1856
  );
1768
1857
  expect(post.status).toBe(303);
1769
1858
  const location = post.headers.get("location") ?? "";
1770
1859
  expect(location).toMatch(/^\/admin\/setup\?op=/);
1771
- // Yield enough for any background runInstall promise to fire if
1772
- // the guard failed. Then assert: no `bun add` was invoked, and
1773
- // the op went straight to `succeeded` with the canonical
1774
- // "already supervised" log line.
1860
+ // Yield for any background promise. Then assert: no `bun add`, NO
1861
+ // provisionVault call (no double-install), op synthesized succeeded.
1775
1862
  await new Promise((r) => setTimeout(r, 50));
1776
1863
  expect(runCalls.length).toBe(0);
1864
+ expect(provisionCalls).toEqual([]);
1777
1865
  const opId = new URL(location, "http://x").searchParams.get("op") ?? "";
1778
1866
  const op = getDefaultOperationsRegistry().get(opId);
1779
1867
  expect(op?.status).toBe("succeeded");
@@ -1783,6 +1871,200 @@ describe("handleSetupVaultPost", () => {
1783
1871
  }
1784
1872
  });
1785
1873
 
1874
+ test("boot-spawned vault child (real version, paths:[]) — wizard create actually provisions the named vault (e2e wizard-skip fix)", async () => {
1875
+ // The exact Tier-1 e2e bug. The hub boot-spawns the vault MODULE child so it
1876
+ // can selfRegister; that child writes a services.json row at the REAL
1877
+ // package version (NOT SEED_VERSION) with `paths: []` because no vault
1878
+ // instance exists yet, AND the supervisor already reports vault "running".
1879
+ // Pre-fix: hasRealVault read the real-version row as a real vault, the
1880
+ // wizard skipped its vault step, and even if it POSTed, the already-
1881
+ // supervised short-circuit synthesized a succeeded op that created NOTHING
1882
+ // — `--vault-name e2e` was silently dropped. Post-fix: the empty-paths row
1883
+ // reads as no-real-vault (hasRealVault=false), the POST is honored, and the
1884
+ // short-circuit drives provisionVault to actually create the named vault.
1885
+ const db = openHubDb(hubDbPath(h.dir));
1886
+ try {
1887
+ const user = await createUser(db, "owner", "pw");
1888
+ const { createSession, SESSION_COOKIE_NAME: SC } = await import("../sessions.ts");
1889
+ const session = createSession(db, { userId: user.id });
1890
+ // Boot-spawned child's self-registered row: real version, paths: [].
1891
+ writeManifest(
1892
+ {
1893
+ services: [
1894
+ {
1895
+ name: "parachute-vault",
1896
+ version: "0.7.4-rc.1",
1897
+ port: 1940,
1898
+ paths: [],
1899
+ health: "/health",
1900
+ },
1901
+ ],
1902
+ },
1903
+ h.manifestPath,
1904
+ );
1905
+ // deriveWizardState must NOT treat the empty-paths real-version row as a
1906
+ // real vault (this is what un-skipped the wizard's vault step).
1907
+ const derived = deriveWizardState({
1908
+ db,
1909
+ manifestPath: h.manifestPath,
1910
+ readExposeStateFn: h.readExposeStateFn,
1911
+ });
1912
+ expect(derived.hasRealVault).toBe(false);
1913
+ expect(derived.step).toBe("vault");
1914
+
1915
+ const get = handleSetupGet(req("/admin/setup"), {
1916
+ db,
1917
+ manifestPath: h.manifestPath,
1918
+ configDir: h.dir,
1919
+ readExposeStateFn: h.readExposeStateFn,
1920
+ issuer: "https://hub.example",
1921
+ registry: getDefaultOperationsRegistry(),
1922
+ });
1923
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
1924
+ // Supervisor already running the boot-spawned vault module child.
1925
+ const supervisor = makeSupervisor();
1926
+ await supervisor.start({ short: "vault", cmd: ["bun", "noop"] });
1927
+ const runCalls: string[][] = [];
1928
+ const provisionCalls: string[] = [];
1929
+ const provisionVaultImpl = async (name: string) => {
1930
+ provisionCalls.push(name);
1931
+ return {
1932
+ ok: true as const,
1933
+ created: true,
1934
+ entry: { name, url: `https://hub.example/vault/${name}`, version: "0.7.4-rc.1" },
1935
+ createJson: null,
1936
+ };
1937
+ };
1938
+ const post = await handleSetupVaultPost(
1939
+ req("/admin/setup/vault", {
1940
+ method: "POST",
1941
+ body: new URLSearchParams({
1942
+ [CSRF_FIELD_NAME]: csrf,
1943
+ mode: "create",
1944
+ vault_name: "e2e",
1945
+ }).toString(),
1946
+ headers: {
1947
+ "content-type": "application/x-www-form-urlencoded",
1948
+ cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SC}=${session.id}`,
1949
+ },
1950
+ }),
1951
+ {
1952
+ db,
1953
+ manifestPath: h.manifestPath,
1954
+ configDir: h.dir,
1955
+ readExposeStateFn: h.readExposeStateFn,
1956
+ issuer: "https://hub.example",
1957
+ supervisor,
1958
+ registry: getDefaultOperationsRegistry(),
1959
+ run: async (cmd) => {
1960
+ runCalls.push([...cmd]);
1961
+ return 0;
1962
+ },
1963
+ provisionVaultImpl,
1964
+ },
1965
+ );
1966
+ expect(post.status).toBe(303);
1967
+ const location = post.headers.get("location") ?? "";
1968
+ expect(location).toMatch(/^\/admin\/setup\?op=/);
1969
+ await new Promise((r) => setTimeout(r, 50));
1970
+ // The named vault WAS provisioned (not silently dropped) …
1971
+ expect(provisionCalls).toEqual(["e2e"]);
1972
+ // … and no `bun add -g` fired (module already supervised).
1973
+ expect(runCalls.some((c) => c.join(" ").includes("bun add -g"))).toBe(false);
1974
+ const opId = new URL(location, "http://x").searchParams.get("op") ?? "";
1975
+ const op = getDefaultOperationsRegistry().get(opId);
1976
+ expect(op?.status).toBe("succeeded");
1977
+ expect(op?.log.join("\n")).toContain('vault "e2e" created');
1978
+ } finally {
1979
+ db.close();
1980
+ }
1981
+ });
1982
+
1983
+ test("import mode on an already-supervised box is REJECTED (no empty vault + fake success) — review must-fix", async () => {
1984
+ // On a boot-spawned box (vault module already supervised), the short-circuit
1985
+ // create path provisions an EMPTY vault. An IMPORT request must NOT be run
1986
+ // through it: that would leave an empty vault named after the import target
1987
+ // + a false "vault created" banner (a data-integrity footgun). The wizard
1988
+ // rejects import with an actionable message + a FAILED op instead.
1989
+ const db = openHubDb(hubDbPath(h.dir));
1990
+ try {
1991
+ const user = await createUser(db, "owner", "pw");
1992
+ const { createSession, SESSION_COOKIE_NAME: SC } = await import("../sessions.ts");
1993
+ const session = createSession(db, { userId: user.id });
1994
+ writeManifest(
1995
+ {
1996
+ services: [
1997
+ {
1998
+ name: "parachute-vault",
1999
+ version: "0.7.4-rc.1",
2000
+ port: 1940,
2001
+ paths: [],
2002
+ health: "/health",
2003
+ },
2004
+ ],
2005
+ },
2006
+ h.manifestPath,
2007
+ );
2008
+ const get = handleSetupGet(req("/admin/setup"), {
2009
+ db,
2010
+ manifestPath: h.manifestPath,
2011
+ configDir: h.dir,
2012
+ readExposeStateFn: h.readExposeStateFn,
2013
+ issuer: "https://hub.example",
2014
+ registry: getDefaultOperationsRegistry(),
2015
+ });
2016
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
2017
+ const supervisor = makeSupervisor();
2018
+ await supervisor.start({ short: "vault", cmd: ["bun", "noop"] });
2019
+ const provisionCalls: string[] = [];
2020
+ const post = await handleSetupVaultPost(
2021
+ req("/admin/setup/vault", {
2022
+ method: "POST",
2023
+ headers: {
2024
+ accept: "application/json",
2025
+ "content-type": "application/json",
2026
+ cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SC}=${session.id}`,
2027
+ },
2028
+ body: JSON.stringify({
2029
+ [CSRF_FIELD_NAME]: csrf,
2030
+ mode: "import",
2031
+ vault_name: "fromgit",
2032
+ remote_url: "https://example.com/repo.git",
2033
+ import_mode: "merge",
2034
+ }),
2035
+ }),
2036
+ {
2037
+ db,
2038
+ manifestPath: h.manifestPath,
2039
+ configDir: h.dir,
2040
+ readExposeStateFn: h.readExposeStateFn,
2041
+ issuer: "https://hub.example",
2042
+ supervisor,
2043
+ registry: getDefaultOperationsRegistry(),
2044
+ provisionVaultImpl: async (name: string) => {
2045
+ provisionCalls.push(name);
2046
+ return {
2047
+ ok: true as const,
2048
+ created: true,
2049
+ entry: { name, url: `https://hub.example/vault/${name}`, version: "0.7.4-rc.1" },
2050
+ createJson: null,
2051
+ };
2052
+ },
2053
+ },
2054
+ );
2055
+ expect(post.status).toBe(200);
2056
+ const body = (await post.json()) as { op_id?: string; message?: string };
2057
+ expect(body.message ?? "").toContain("isn't supported");
2058
+ // Import was NOT provisioned (no empty vault created).
2059
+ expect(provisionCalls).toEqual([]);
2060
+ // The op is FAILED, not a fake success.
2061
+ const op = body.op_id ? getDefaultOperationsRegistry().get(body.op_id) : undefined;
2062
+ expect(op?.status).toBe("failed");
2063
+ } finally {
2064
+ db.close();
2065
+ }
2066
+ });
2067
+
1786
2068
  test("create on a SEED-placeholder box: does NOT short-circuit + drives the supervisor to start vault (hub#607 + hub#608)", async () => {
1787
2069
  // hub#607 + hub#608 coupled fresh-operator flow. On an init'd box,
1788
2070
  // services.json already carries a `parachute-vault` placeholder at
@@ -82,6 +82,41 @@ describe("walkTranscriptionStep — mode resolution (the CLI question)", () => {
82
82
  }
83
83
  });
84
84
 
85
+ test("non-interactive stdin (no flag) DEFAULTS to none — never hangs on the prompt", async () => {
86
+ // Headless-hardening: with no `--transcribe-mode` flag AND a closed / non-
87
+ // interactive stdin, an interactive `prompt("Pick [1]:")` would busy-hang
88
+ // Bun's readline question() forever (the exact e2e wedge). Transcription is
89
+ // optional and documented as never-blocking, so resolveChoice DEFAULTS to
90
+ // "none" (with an honest log line) rather than throwing. We drive the real
91
+ // defaultPrompt (no `prompt` seam) and force isTTY=false for determinism;
92
+ // if the guard regressed, this test would HANG instead of passing.
93
+ const h = makeHarness();
94
+ const origIsTTY = process.stdin.isTTY;
95
+ Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true });
96
+ try {
97
+ const r = recordingRunner();
98
+ const logs: string[] = [];
99
+ const code = await walkTranscriptionStep({
100
+ configDir: h.dir,
101
+ log: (l) => logs.push(l),
102
+ // no transcribeMode, no prompt seam → real defaultPrompt would be hit
103
+ runCommand: r.run,
104
+ platform: "linux",
105
+ });
106
+ expect(code).toBe(0);
107
+ expect(r.cmds).toEqual([]); // nothing installed
108
+ expect(readCfg(h.dir)).toBeUndefined(); // no provider recorded
109
+ expect(logs.join("\n")).toContain("not interactive");
110
+ expect(logs.join("\n")).toContain("Transcription off");
111
+ } finally {
112
+ Object.defineProperty(process.stdin, "isTTY", {
113
+ value: origIsTTY,
114
+ configurable: true,
115
+ });
116
+ h.cleanup();
117
+ }
118
+ });
119
+
85
120
  test("interactive prompt: '3' then 'g' chooses groq cloud", async () => {
86
121
  const h = makeHarness();
87
122
  try {
@@ -655,4 +655,83 @@ describe("runCliWizard", () => {
655
655
  "/admin/setup/expose",
656
656
  ]);
657
657
  });
658
+
659
+ test("non-interactive stdin fails fast (no hang) when a required answer is missing", async () => {
660
+ // Headless-hardening: with stdin closed (cloud-init, `ssh host 'parachute
661
+ // setup-wizard …'`, run.sh-exec'd stages.sh) an unanswered prompt would
662
+ // busy-hang Bun's readline question() forever. defaultPrompt now throws a
663
+ // clear, flag-naming error instead. We drive the real defaultPrompt (no
664
+ // `prompt` seam) with the account username unsupplied, forcing the account
665
+ // step to prompt — and force isTTY=false so the assertion is deterministic
666
+ // regardless of how the test runner's stdin is wired.
667
+ const { fetchImpl } = makeFakeHub();
668
+ const origIsTTY = process.stdin.isTTY;
669
+ Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true });
670
+ try {
671
+ await expect(
672
+ runCliWizard({
673
+ hubUrl: "http://127.0.0.1:1939",
674
+ log: () => {},
675
+ fetchImpl,
676
+ sleep: async () => {},
677
+ // no accountUsername → account step calls the real defaultPrompt.
678
+ accountPassword: "longpassword",
679
+ vaultMode: "skip",
680
+ exposeMode: "localhost",
681
+ }),
682
+ ).rejects.toThrow(/stdin is not interactive/);
683
+ } finally {
684
+ Object.defineProperty(process.stdin, "isTTY", {
685
+ value: origIsTTY,
686
+ configurable: true,
687
+ });
688
+ }
689
+ });
690
+
691
+ test("headless runCliWizard with NO --transcribe-mode completes (transcription defaults to none, not a throw)", async () => {
692
+ // Regression guard for the review must-fix: `runCliWizard` used to forward
693
+ // its (throwing) `defaultPrompt` into the transcription step, so a headless
694
+ // run with account/vault/expose all supplied via flags but NO
695
+ // `--transcribe-mode` would THROW at the transcription step ("cannot prompt
696
+ // for: Pick [1]:") instead of defaulting to none. The wizard now forwards a
697
+ // prompt to the transcription step ONLY when a real seam is injected, so
698
+ // headless hits `resolveChoice`'s undefined-prompt non-TTY guard → none.
699
+ const { state, fetchImpl } = makeFakeHub();
700
+ const logs: string[] = [];
701
+ let ran = false;
702
+ const origIsTTY = process.stdin.isTTY;
703
+ Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true });
704
+ try {
705
+ const code = await runCliWizard({
706
+ hubUrl: "http://127.0.0.1:1939",
707
+ log: (l) => logs.push(l),
708
+ fetchImpl,
709
+ sleep: async () => {},
710
+ accountUsername: "admin",
711
+ accountPassword: "longpassword",
712
+ vaultMode: "skip",
713
+ exposeMode: "localhost",
714
+ configDir: "/tmp/pcli-wizard-headless-none", // triggers the step; none writes nothing
715
+ // NO transcribeMode, NO prompt seam → headless must default to none.
716
+ transcribeRunCommand: async () => {
717
+ ran = true;
718
+ return 0;
719
+ },
720
+ });
721
+ expect(code).toBe(0); // completed, did NOT throw
722
+ expect(ran).toBe(false); // none → no scribe install
723
+ expect(logs.join("\n")).toContain("Transcription off");
724
+ // Full flow still walked account → vault → expose.
725
+ expect(state.posted.map((p) => p.path)).toEqual([
726
+ "/admin/setup/account",
727
+ "/admin/setup/vault",
728
+ "/admin/setup/expose",
729
+ ]);
730
+ } finally {
731
+ Object.defineProperty(process.stdin, "isTTY", {
732
+ value: origIsTTY,
733
+ configurable: true,
734
+ });
735
+ }
736
+ });
658
737
  });
@@ -33,6 +33,12 @@
33
33
  * ownership gate the cloud twin runs per-vault is trivially satisfied here.
34
34
  */
35
35
  import type { Database } from "bun:sqlite";
36
+ // NOTE: this is a VALUE import — hub can't run without
37
+ // `@openparachute/door-contract` resolving at runtime, so it's a real
38
+ // `^0.6.0` runtime `dependency` (package.json, mirroring `@openparachute/depcheck`)
39
+ // and NOT a `workspace:*` devDependency. The published hub tarball doesn't ship
40
+ // `packages/`, so the npm-installed hub resolves door-contract from the registry.
41
+ // See RELEASING.md → "Releasing door-contract".
36
42
  import {
37
43
  type AccountBootstrap,
38
44
  type ParachuteAccountDescriptor,
@@ -72,6 +72,14 @@ export interface TranscriptionStepOpts {
72
72
 
73
73
  /** Default readline prompt (matches wizard.ts's defaultPrompt). */
74
74
  async function defaultPrompt(question: string): Promise<string> {
75
+ // Non-TTY guard (headless-hardening): unlike wizard.ts's required-step
76
+ // prompts (which throw), the transcription step is optional and must NEVER
77
+ // block setup. On a closed / non-interactive stdin, return the empty answer
78
+ // — which the callers already treat as a benign default (a blank cloud API
79
+ // key means "set it later"; a blank local-install confirm takes the [Y]
80
+ // default). `resolveChoice` short-circuits to "none" before ever reaching
81
+ // here without a flag; this covers the flag-supplied-mode downstream prompts.
82
+ if (!process.stdin.isTTY) return "";
75
83
  const rl = createInterface({ input: process.stdin, output: process.stdout });
76
84
  try {
77
85
  return await rl.question(question);
@@ -139,6 +147,22 @@ async function resolveChoice(
139
147
  log: (l: string) => void,
140
148
  ): Promise<ResolvedChoice> {
141
149
  if (opts.transcribeMode !== undefined) return opts.transcribeMode;
150
+ // Non-TTY guard (headless-hardening): with no `--transcribe-mode` flag AND a
151
+ // closed / non-interactive stdin (cloud-init, ssh heredoc, the e2e
152
+ // container), the real `defaultPrompt`'s `prompt("Pick [1]:")` would busy-hang
153
+ // forever on Bun's `readline/promises` question() — the exact wedge that
154
+ // timed out the Tier-1 e2e. Transcription is optional / opt-in and documented
155
+ // as NEVER blocking setup, so we DEFAULT to "none" (not throw — unlike the
156
+ // required account / vault / expose prompts in wizard.ts) with an honest log
157
+ // line. Guarded on `opts.prompt === undefined` so an injected prompt seam (a
158
+ // scripted test queue, or a caller supplying its own reader) is still honored
159
+ // — the wedge only exists for the real readline-backed default prompt.
160
+ if (opts.prompt === undefined && !process.stdin.isTTY) {
161
+ log("");
162
+ log(" stdin is not interactive — defaulting transcription to none.");
163
+ log(" Turn it on later with `parachute install scribe` (or re-run with --transcribe-mode).");
164
+ return "none";
165
+ }
142
166
  log("");
143
167
  log(" 1) None — skip transcription (default)");
144
168
  log(" 2) Local — run the engine on this box (no API key, needs ~2 GB RAM)");
@@ -156,8 +156,24 @@ interface FetchSetupResult {
156
156
  /**
157
157
  * Default readline prompt. Lives at module scope so tests can inject a
158
158
  * deterministic alternative through `opts.prompt`.
159
+ *
160
+ * Non-TTY guard (headless-hardening): Bun's `readline/promises` `question()`
161
+ * NEVER settles when stdin is closed / not a terminal (cloud-init, `ssh
162
+ * <host> 'parachute setup-wizard …'`, the e2e container's `run.sh`-exec'd
163
+ * stages.sh) — it busy-waits forever, wedging the wizard until the outer job
164
+ * timeout kills it. So when stdin is not interactive we FAIL FAST with an
165
+ * actionable message naming the flag that would have answered the prompt,
166
+ * rather than blocking on a prompt no one can ever answer. Account / vault /
167
+ * expose are required steps, so a missing answer here is fatal (the
168
+ * transcription step, being optional, defaults to "none" instead — see
169
+ * wizard-transcription.ts).
159
170
  */
160
171
  async function defaultPrompt(question: string): Promise<string> {
172
+ if (!process.stdin.isTTY) {
173
+ throw new Error(
174
+ `stdin is not interactive, so the wizard cannot prompt for: ${question.trim()}\nRe-run non-interactively by passing the corresponding flag (e.g. --account-username / --account-password / --vault-mode / --vault-name / --expose-mode / --transcribe-mode), or run in a real terminal.`,
175
+ );
176
+ }
161
177
  const rl = createInterface({ input: process.stdin, output: process.stdout });
162
178
  try {
163
179
  return await rl.question(question);
@@ -224,6 +240,14 @@ async function setupFetch(
224
240
  // Don't auto-follow — the wizard returns 303 to /admin/setup, and
225
241
  // we want to inspect the Location header rather than chase it.
226
242
  redirect: "manual",
243
+ // Per-request 30s ceiling so a wedged hub call TRIPS instead of
244
+ // suspending forever. `pollOperation` (below) bounds the whole op with
245
+ // POLL_TIMEOUT_MS, but that deadline is only checked BETWEEN fetches — a
246
+ // single fetch that never resolves would defeat it. AbortSignal.timeout
247
+ // makes each individual call fail-fast; a real hub responds in well under
248
+ // 30s. Test-injected fetch stubs ignore the signal, so this is inert in
249
+ // unit tests.
250
+ signal: AbortSignal.timeout(30_000),
227
251
  });
228
252
  const bodyText = await res.text();
229
253
  // Collect Set-Cookie. Try `getAll` first (Bun + node18+ supported it
@@ -783,7 +807,16 @@ export async function runCliWizard(opts: RunCliWizardOpts): Promise<number> {
783
807
  await walkTranscriptionStep({
784
808
  configDir: opts.configDir,
785
809
  log,
786
- prompt,
810
+ // Only forward a REAL injected prompt seam — NOT the resolved
811
+ // `prompt` (which is the throwing `defaultPrompt` when the caller
812
+ // supplied none). Passing the default here would defeat
813
+ // `resolveChoice`'s `opts.prompt === undefined` non-TTY guard: on a
814
+ // headless box with no `--transcribe-mode`, the transcription step
815
+ // would call the throwing default mid-wizard instead of defaulting to
816
+ // `none`. With the seam forwarded only when present, headless runs hit
817
+ // the guard (default none) and interactive/test runs still get their
818
+ // reader.
819
+ ...(opts.prompt !== undefined ? { prompt: opts.prompt } : {}),
787
820
  ...(opts.transcribeMode !== undefined ? { transcribeMode: opts.transcribeMode } : {}),
788
821
  ...(opts.transcribeApiKey !== undefined ? { transcribeApiKey: opts.transcribeApiKey } : {}),
789
822
  ...(opts.transcribeRunCommand !== undefined ? { runCommand: opts.transcribeRunCommand } : {}),
@@ -41,6 +41,7 @@ import type { Database } from "bun:sqlite";
41
41
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
42
42
  import { join } from "node:path";
43
43
  import { recordLoginUnlock } from "./admin-lock.ts";
44
+ import { provisionVault } from "./admin-vaults.ts";
44
45
  import { type OperationsRegistry, runInstall, specFor } from "./api-modules-ops.ts";
45
46
  import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
46
47
  import {
@@ -307,15 +308,34 @@ export function deriveWizardState(deps: {
307
308
  // phantom `vaults[]` row at SEED_VERSION); both surfaces must agree that a
308
309
  // placeholder is not a real vault.
309
310
  const vaultIsPlaceholder = vaultEntry !== undefined && vaultEntry.version === SEED_VERSION;
311
+ // hub#607-followup (the e2e wizard-skip bug): the SEED_VERSION check alone is
312
+ // NOT sufficient to distinguish "real vault" from "module installed, no
313
+ // instance". When the hub boot-spawns the vault module child, vault's
314
+ // `selfRegister` writes its services.json row at the REAL package version
315
+ // (e.g. 0.7.4-rc.1) but with `paths: []` (no vault instance created yet) —
316
+ // defeating the SEED_VERSION sentinel. A bare `version !== SEED_VERSION`
317
+ // check then read that empty-paths row as a real vault, so the wizard
318
+ // SILENTLY SKIPPED its vault step and `--vault-mode create --vault-name` was
319
+ // ignored (the operator finished setup with no vault). Make the check
320
+ // INSTANCE-aware: a real vault also requires at least one mount path. This is
321
+ // the SAME empty-paths discrimination `findExistingVault` /
322
+ // `listVaultInstanceNames` already use (#478) — a servable instance carries a
323
+ // canonical `/vault/<name>` path; a self-registered-but-uncreated row has
324
+ // `paths: []` and must read as "no real vault yet." A SEED_VERSION seed row
325
+ // carries `/vault/default`, so it's caught by `vaultIsPlaceholder` above and
326
+ // unaffected by this clause.
327
+ const vaultHasInstancePath = vaultEntry !== undefined && vaultEntry.paths.length > 0;
310
328
  // INVARIANT (B5 re-enterable vault step): hasRealVault means "a real
311
- // instance row exists" — placeholder excluded here, skip-marker excluded
312
- // below (skip flips hasVault, never hasRealVault). THREE sites key on this
313
- // same placeholder logic and must move together: this derivation,
314
- // handleSetupGet's `?step=vault` re-entry gate, and handleSetupVaultPost's
315
- // already-provisioned short-circuit. Changing one without the others
316
- // either re-opens a provisioning form over a real vault or dead-ends the
317
- // post-skip re-entry path.
318
- const hasRealVault = vaultEntry !== undefined && !vaultIsPlaceholder;
329
+ // instance row exists" — placeholder excluded here, empty-paths excluded via
330
+ // vaultHasInstancePath, skip-marker excluded below (skip flips hasVault,
331
+ // never hasRealVault). THREE sites key on this same `hasRealVault` value and
332
+ // must move together: this derivation, handleSetupGet's `?step=vault`
333
+ // re-entry gate, and handleSetupVaultPost's already-provisioned short-circuit
334
+ // all three read `deriveWizardState(...).hasRealVault`, so tightening it
335
+ // here moves all three at once. Changing the meaning here without checking
336
+ // those consumers either re-opens a provisioning form over a real vault or
337
+ // dead-ends the post-skip re-entry path.
338
+ const hasRealVault = vaultEntry !== undefined && !vaultIsPlaceholder && vaultHasInstancePath;
319
339
  // hub#168 Cut 2: `setup_vault_skipped === "true"` advances the wizard
320
340
  // past the vault step even when no vault row exists. The operator
321
341
  // explicitly chose Skip; the module is installed (Cut 1) but no
@@ -416,6 +436,14 @@ export interface SetupWizardDeps {
416
436
  registry?: OperationsRegistry;
417
437
  /** Test seam: stub `bun add` / `bun remove` runner. */
418
438
  run?: (cmd: readonly string[]) => Promise<number>;
439
+ /**
440
+ * Test seam: provision a vault for the already-supervised short-circuit in
441
+ * `handleSetupVaultPost`. Production omits this and uses the real
442
+ * {@link provisionVault} (which shells to `parachute-vault create <name>
443
+ * --json`). Tests inject a stub so the short-circuit can be asserted without
444
+ * spawning the vault CLI.
445
+ */
446
+ provisionVaultImpl?: typeof provisionVault;
419
447
  /**
420
448
  * Test seam: stub the bun-link detection used by `runInstall` to
421
449
  * short-circuit `bun add -g` when a package is already linked
@@ -2305,45 +2333,130 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
2305
2333
  const registry = deps.registry;
2306
2334
  const vaultSpec = specFor(FIRST_VAULT_SHORT);
2307
2335
 
2308
- // Idempotent short-circuit: if the supervisor is already running (or
2309
- // mid-spawn) for vault i.e. a previous POST already kicked off
2310
- // `runInstall` and beat us to spawningreturn a synthesized
2311
- // succeeded op instead of firing a second `bun add -g`. Mirrors the
2312
- // pattern in `handleInstall` (api-modules-ops.ts). Without this,
2313
- // two concurrent POSTs both pass `state.hasVault === false` (the
2314
- // services.json seed is the only signal that step exits, and it's
2315
- // written by `runInstall` *after* `bun add` returns), and each
2316
- // fires its own install — wasted work and a possible race on the
2317
- // seed/spawn writes. Low risk on first-boot in practice, but the
2318
- // fix is cheap and matches the API surface's posture.
2336
+ // Already-supervised handling: is the vault MODULE child already under the
2337
+ // supervisor (running / starting / restarting)? Two ways that happens:
2338
+ // (a) the concurrent-POST race this guard was written for — a prior POST
2339
+ // already fired `runInstall` and beat us to spawning the module; and
2340
+ // (b) the boot-spawned-child case the Tier-1 e2e exposed — the hub started
2341
+ // the vault module on boot so it could `selfRegister`, but NO vault
2342
+ // INSTANCE has been created yet (its services.json row carries
2343
+ // `paths: []`, which is exactly why `hasRealVault` was false above and
2344
+ // we reached this create path).
2345
+ // When the module is already supervised we must NOT fire a second
2346
+ // `bun add -g` / `runInstall`. The three create/import sub-cases are handled
2347
+ // below (import → reject; create+registered → provision the instance;
2348
+ // create+unregistered → let the in-flight install create it).
2319
2349
  const supervisorState = deps.supervisor.get(FIRST_VAULT_SHORT);
2320
- if (
2350
+ const alreadySupervised =
2321
2351
  supervisorState?.status === "running" ||
2322
2352
  supervisorState?.status === "starting" ||
2323
- supervisorState?.status === "restarting"
2324
- ) {
2353
+ supervisorState?.status === "restarting";
2354
+
2355
+ // MUST-FIX (review of #768): import-during-setup on an already-supervised box
2356
+ // isn't supported yet. On such a box (now the normal init'd-box path that
2357
+ // reaches this vault form) `provisionVault` only creates an EMPTY vault —
2358
+ // running it for an IMPORT request and reporting success would leave the
2359
+ // operator with an empty vault named after their import target plus a false
2360
+ // "vault created" banner (a data-integrity footgun). Reject with an
2361
+ // actionable message instead. Known limitation — see CHANGELOG; the fuller
2362
+ // fix (run the mirror-import follow-up after provision) is deferred because
2363
+ // it isn't exercised by the e2e and can't be verified end-to-end here.
2364
+ if (alreadySupervised && rawMode === "import") {
2365
+ const detail =
2366
+ "importing a vault during setup isn't supported yet on a box whose vault module is already running. Create the vault here instead, then import into it with `parachute vault …` or the admin UI.";
2325
2367
  if (registry) {
2326
- const op = registry.create("install", FIRST_VAULT_SHORT);
2368
+ const rejectOp = registry.create("install", FIRST_VAULT_SHORT);
2327
2369
  registry.update(
2328
- op.id,
2329
- { status: "succeeded" },
2330
- `${FIRST_VAULT_SHORT} already supervised (status=${supervisorState.status})`,
2370
+ rejectOp.id,
2371
+ { status: "failed", error: detail },
2372
+ `vault import unsupported on an already-supervised box: ${detail}`,
2331
2373
  );
2332
2374
  if (form.isJson) {
2333
- return jsonOkResponse({ op_id: op.id, step: "vault", message: "vault already supervised" });
2375
+ return jsonOkResponse({ op_id: rejectOp.id, step: "vault", message: detail });
2334
2376
  }
2335
- return redirect(`/admin/setup?op=${encodeURIComponent(op.id)}`);
2377
+ return redirect(`/admin/setup?op=${encodeURIComponent(rejectOp.id)}`);
2336
2378
  }
2337
2379
  if (form.isJson) {
2338
- return jsonOkResponse({ step: "vault", message: "vault already supervised" });
2380
+ return jsonErrorResponse(409, "Import not supported here", detail);
2339
2381
  }
2340
- return redirect("/admin/setup");
2382
+ return badRequestPage("Import not supported here", detail);
2341
2383
  }
2342
2384
 
2385
+ // NIT (reviewer N2): only provision if vault is ALREADY registered in
2386
+ // services.json. If it isn't, we're in the narrow pre-seed window of a
2387
+ // CONCURRENT create POST whose `runInstall` spawned the module but hasn't
2388
+ // written its row yet — firing `provisionVault` here would run
2389
+ // `parachute install vault` a SECOND time. In that case we synthesize
2390
+ // success below and let the in-flight install create the vault.
2391
+ const vaultAlreadyRegistered =
2392
+ findService(vaultSpec.manifestName, deps.manifestPath) !== undefined;
2393
+
2343
2394
  const op = registry
2344
2395
  ? registry.create("install", FIRST_VAULT_SHORT)
2345
2396
  : { id: cryptoRandomId(), status: "pending" as const, log: [] as string[] };
2346
- if (registry) {
2397
+
2398
+ if (alreadySupervised && vaultAlreadyRegistered) {
2399
+ // Case (b): supervised module, real-version row with `paths: []` (no
2400
+ // instance). Provision the named vault via the shipped admin-SPA path
2401
+ // (`parachute-vault create <name> --json`, idempotent + name-validated).
2402
+ // FIRE-AND-FORGET, matching the normal `void runInstall` path: return the
2403
+ // op_id immediately and mark the op from the BACKGROUND result. This keeps
2404
+ // the POST fast so the CLI's 30s per-request ceiling only bounds the quick
2405
+ // POST/poll round-trip — a slow cold `parachute-vault create` on a sluggish
2406
+ // VPS is polled to completion within POLL_TIMEOUT_MS (5 min), never aborted
2407
+ // mid-create. No `bun add -g` fires (module already supervised).
2408
+ const provision = deps.provisionVaultImpl ?? provisionVault;
2409
+ if (registry) {
2410
+ registry.update(
2411
+ op.id,
2412
+ { status: "running" },
2413
+ `${FIRST_VAULT_SHORT} already supervised (status=${supervisorState?.status}) — creating vault "${vaultName}"`,
2414
+ );
2415
+ void provision(vaultName, { issuer: deps.issuer, manifestPath: deps.manifestPath })
2416
+ .then((provisioned) => {
2417
+ if (provisioned.ok) {
2418
+ registry.update(
2419
+ op.id,
2420
+ { status: "succeeded" },
2421
+ provisioned.created
2422
+ ? `vault "${vaultName}" created`
2423
+ : `vault "${vaultName}" already exists`,
2424
+ );
2425
+ } else {
2426
+ registry.update(
2427
+ op.id,
2428
+ { status: "failed", error: provisioned.message },
2429
+ `vault create failed: ${provisioned.message}`,
2430
+ );
2431
+ }
2432
+ })
2433
+ .catch((err) => {
2434
+ const msg = err instanceof Error ? err.message : String(err);
2435
+ registry.update(op.id, { status: "failed", error: msg }, `vault create failed: ${msg}`);
2436
+ });
2437
+ } else {
2438
+ // No registry (test-only path): provision synchronously so the vault is
2439
+ // actually created; there's no op to poll.
2440
+ const provisioned = await provision(vaultName, {
2441
+ issuer: deps.issuer,
2442
+ manifestPath: deps.manifestPath,
2443
+ });
2444
+ if (!provisioned.ok) {
2445
+ console.warn(`[setup-wizard] vault create failed: ${provisioned.message}`);
2446
+ }
2447
+ }
2448
+ } else if (alreadySupervised) {
2449
+ // Case (a): supervised but NOT yet registered — the pre-seed window of a
2450
+ // concurrent create POST. Synthesize success; the in-flight `runInstall`
2451
+ // creates the vault (reviewer N2 — the original guard's job).
2452
+ if (registry) {
2453
+ registry.update(
2454
+ op.id,
2455
+ { status: "succeeded" },
2456
+ `${FIRST_VAULT_SHORT} already supervised (status=${supervisorState?.status}) — a concurrent install is provisioning the vault`,
2457
+ );
2458
+ }
2459
+ } else if (registry) {
2347
2460
  // hub#267: thread the typed name through `PARACHUTE_VAULT_NAME` so
2348
2461
  // vault's first-boot path (vault#342) names the created vault
2349
2462
  // accordingly.