@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 +2 -2
- package/src/__tests__/setup-wizard.test.ts +294 -12
- package/src/__tests__/wizard-transcription.test.ts +35 -0
- package/src/__tests__/wizard.test.ts +79 -0
- package/src/account-api.ts +6 -0
- package/src/commands/wizard-transcription.ts +24 -0
- package/src/commands/wizard.ts +34 -1
- package/src/setup-wizard.ts +144 -31
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/hub",
|
|
3
|
-
"version": "0.7.7-rc.
|
|
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("
|
|
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
|
|
1718
|
-
//
|
|
1719
|
-
//
|
|
1720
|
-
//
|
|
1721
|
-
//
|
|
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
|
-
//
|
|
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
|
|
1772
|
-
//
|
|
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
|
});
|
package/src/account-api.ts
CHANGED
|
@@ -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)");
|
package/src/commands/wizard.ts
CHANGED
|
@@ -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 } : {}),
|
package/src/setup-wizard.ts
CHANGED
|
@@ -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,
|
|
312
|
-
// below (skip flips hasVault,
|
|
313
|
-
//
|
|
314
|
-
// handleSetupGet's `?step=vault`
|
|
315
|
-
// already-provisioned short-circuit
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
|
|
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
|
-
//
|
|
2309
|
-
//
|
|
2310
|
-
//
|
|
2311
|
-
//
|
|
2312
|
-
//
|
|
2313
|
-
//
|
|
2314
|
-
//
|
|
2315
|
-
//
|
|
2316
|
-
//
|
|
2317
|
-
//
|
|
2318
|
-
//
|
|
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
|
-
|
|
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
|
|
2368
|
+
const rejectOp = registry.create("install", FIRST_VAULT_SHORT);
|
|
2327
2369
|
registry.update(
|
|
2328
|
-
|
|
2329
|
-
{ status: "
|
|
2330
|
-
|
|
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:
|
|
2375
|
+
return jsonOkResponse({ op_id: rejectOp.id, step: "vault", message: detail });
|
|
2334
2376
|
}
|
|
2335
|
-
return redirect(`/admin/setup?op=${encodeURIComponent(
|
|
2377
|
+
return redirect(`/admin/setup?op=${encodeURIComponent(rejectOp.id)}`);
|
|
2336
2378
|
}
|
|
2337
2379
|
if (form.isJson) {
|
|
2338
|
-
return
|
|
2380
|
+
return jsonErrorResponse(409, "Import not supported here", detail);
|
|
2339
2381
|
}
|
|
2340
|
-
return
|
|
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
|
-
|
|
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.
|