@openparachute/hub 0.7.4-rc.5 → 0.7.4-rc.7

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.4-rc.5",
3
+ "version": "0.7.4-rc.7",
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": {
@@ -11,15 +11,8 @@
11
11
  "bin": {
12
12
  "parachute": "src/cli.ts"
13
13
  },
14
- "workspaces": [
15
- "packages/*"
16
- ],
17
- "files": [
18
- "src",
19
- "web/ui/dist",
20
- "README.md",
21
- "LICENSE"
22
- ],
14
+ "workspaces": ["packages/*"],
15
+ "files": ["src", "web/ui/dist", "README.md", "LICENSE"],
23
16
  "repository": {
24
17
  "type": "git",
25
18
  "url": "https://github.com/ParachuteComputer/parachute-hub.git"
@@ -2,7 +2,13 @@ import { describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { HOST_ADMIN_SCOPE, type RunResult, handleCreateVault } from "../admin-vaults.ts";
5
+ import {
6
+ HOST_ADMIN_SCOPE,
7
+ type RunResult,
8
+ handleCreateVault,
9
+ listVaultInstanceNames,
10
+ provisionVault,
11
+ } from "../admin-vaults.ts";
6
12
  import { hubDbPath, openHubDb } from "../hub-db.ts";
7
13
  import { signAccessToken } from "../jwt-sign.ts";
8
14
  import { upsertService, writeManifest } from "../services-manifest.ts";
@@ -1304,3 +1310,160 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
1304
1310
  }
1305
1311
  });
1306
1312
  });
1313
+
1314
+ // ===========================================================================
1315
+ // #478 — empty-paths vault rows must not resolve to phantom "default"
1316
+ // ===========================================================================
1317
+
1318
+ describe("#478 — empty-paths vault row tolerance", () => {
1319
+ test("findExistingVault: empty-paths vault row does NOT match 'default'", () => {
1320
+ // A vault module registered in services.json with paths:[] is "installed
1321
+ // but no servable vault instance". Hub must skip it — never synthesize a
1322
+ // phantom "default" — so provisionVault can proceed to a real create.
1323
+ const h = makeHarness();
1324
+ try {
1325
+ // Write a services.json with a parachute-vault entry carrying paths:[].
1326
+ writeManifest(
1327
+ {
1328
+ services: [
1329
+ {
1330
+ name: "parachute-vault",
1331
+ port: 1940,
1332
+ paths: [],
1333
+ health: "/health",
1334
+ version: "0.5.0",
1335
+ },
1336
+ ],
1337
+ },
1338
+ h.manifestPath,
1339
+ );
1340
+ // Calling provisionVault("default") internally calls findExistingVault.
1341
+ // We verify the behaviour indirectly via listVaultInstanceNames (exported
1342
+ // for this test) and via provisionVault's created:true path below.
1343
+ const names = listVaultInstanceNames(h.manifestPath);
1344
+ expect(names.has("default")).toBe(false);
1345
+ } finally {
1346
+ h.cleanup();
1347
+ }
1348
+ });
1349
+
1350
+ test("listVaultInstanceNames: empty-paths vault row is omitted from the Set", () => {
1351
+ const h = makeHarness();
1352
+ try {
1353
+ writeManifest(
1354
+ {
1355
+ services: [
1356
+ {
1357
+ name: "parachute-vault",
1358
+ port: 1940,
1359
+ paths: [],
1360
+ health: "/health",
1361
+ version: "0.5.0",
1362
+ },
1363
+ ],
1364
+ },
1365
+ h.manifestPath,
1366
+ );
1367
+ const names = listVaultInstanceNames(h.manifestPath);
1368
+ expect(names.size).toBe(0);
1369
+ } finally {
1370
+ h.cleanup();
1371
+ }
1372
+ });
1373
+
1374
+ test("provisionVault: empty-paths row → created:true (proceeds to orchestrate, not false 'already exists')", async () => {
1375
+ // Core regression test for #478: before the fix, an empty-paths row
1376
+ // resolved to phantom "default" → findExistingVault returned non-null →
1377
+ // provisionVault short-circuited to created:false with "already exists".
1378
+ // After the fix: findExistingVault returns null → orchestrate runs →
1379
+ // created:true.
1380
+ const h = makeHarness();
1381
+ try {
1382
+ const db = openHubDb(hubDbPath(h.dir));
1383
+ try {
1384
+ rotateSigningKey(db);
1385
+ // Seed an empty-paths vault row (what vault's self-register emits at
1386
+ // zero vaults, per the #478 contract).
1387
+ writeManifest(
1388
+ {
1389
+ services: [
1390
+ {
1391
+ name: "parachute-vault",
1392
+ port: 1940,
1393
+ paths: [],
1394
+ health: "/health",
1395
+ version: "0.5.0",
1396
+ },
1397
+ ],
1398
+ },
1399
+ h.manifestPath,
1400
+ );
1401
+
1402
+ const calls: Array<readonly string[]> = [];
1403
+ const runCommand = async (cmd: readonly string[]): Promise<RunResult> => {
1404
+ calls.push(cmd);
1405
+ // Simulate vault CLI writing the real path into services.json after
1406
+ // a successful create. Because vault IS already registered (paths:[]),
1407
+ // orchestrate picks the `parachute-vault create --json` branch and
1408
+ // expects JSON stdout.
1409
+ upsertService(
1410
+ {
1411
+ name: "parachute-vault",
1412
+ port: 1940,
1413
+ paths: ["/vault/default"],
1414
+ health: "/health",
1415
+ version: "0.5.0",
1416
+ },
1417
+ h.manifestPath,
1418
+ );
1419
+ return { exitCode: 0, stdout: vaultCreateJson("default"), stderr: "" };
1420
+ };
1421
+
1422
+ const result = await provisionVault("default", {
1423
+ issuer: ISSUER,
1424
+ manifestPath: h.manifestPath,
1425
+ runCommand,
1426
+ });
1427
+
1428
+ // Must have proceeded to orchestrate and returned created:true.
1429
+ expect(result.ok).toBe(true);
1430
+ if (!result.ok) return; // narrow for TS
1431
+ expect(result.created).toBe(true);
1432
+ // The orchestration command ran (not short-circuited).
1433
+ expect(calls.length).toBeGreaterThan(0);
1434
+ } finally {
1435
+ db.close();
1436
+ }
1437
+ } finally {
1438
+ h.cleanup();
1439
+ }
1440
+ });
1441
+
1442
+ test("listVaultInstanceNames: real paths still enumerate correctly (empty-paths does not break them)", () => {
1443
+ // Sanity: mixing an empty-paths row with a real-paths row — the real
1444
+ // paths are still found, the empty one is still skipped.
1445
+ const h = makeHarness();
1446
+ try {
1447
+ writeManifest(
1448
+ {
1449
+ services: [
1450
+ {
1451
+ name: "parachute-vault",
1452
+ port: 1940,
1453
+ paths: ["/vault/default", "/vault/work"],
1454
+ health: "/health",
1455
+ version: "0.5.0",
1456
+ },
1457
+ ],
1458
+ },
1459
+ h.manifestPath,
1460
+ );
1461
+ const names = listVaultInstanceNames(h.manifestPath);
1462
+ expect(names.has("default")).toBe(true);
1463
+ expect(names.has("work")).toBe(true);
1464
+ expect(names.size).toBe(2);
1465
+ } finally {
1466
+ h.cleanup();
1467
+ }
1468
+ });
1469
+ });
@@ -2218,5 +2218,158 @@ describe("resolveInitChannel (hub#694 bug 2)", () => {
2218
2218
  });
2219
2219
  });
2220
2220
 
2221
+ // ---------------------------------------------------------------------------
2222
+ // #478 Part 2 — `parachute init --vault-name <name>` creates the first vault
2223
+ // ---------------------------------------------------------------------------
2224
+
2225
+ describe("init --vault-name (#478 Part 2)", () => {
2226
+ /** Minimal stub that satisfies every init seam except the ones under test. */
2227
+ function baseOpts(
2228
+ h: Harness,
2229
+ overrides: Parameters<typeof init>[0] = {},
2230
+ ): Parameters<typeof init>[0] {
2231
+ return {
2232
+ configDir: h.configDir,
2233
+ manifestPath: h.manifestPath,
2234
+ log: () => {},
2235
+ alive: () => false,
2236
+ ensureHubVersion: async () => ({
2237
+ outcome: "match" as const,
2238
+ installedVersion: "test",
2239
+ messages: [],
2240
+ }),
2241
+ ensureHub: async () => {
2242
+ writeHubPort(1939, h.configDir);
2243
+ return { pid: 0, port: 1939, started: true };
2244
+ },
2245
+ readExposeStateFn: () => undefined,
2246
+ isTty: false,
2247
+ platform: "linux" as const,
2248
+ installVaultModuleImpl: noopVaultInstall,
2249
+ noBrowser: true,
2250
+ noExposePrompt: true,
2251
+ noWizardPrompt: true,
2252
+ ...overrides,
2253
+ };
2254
+ }
2255
+
2256
+ test("(a) with vaultName set: invokes createFirstVaultImpl with the name", async () => {
2257
+ const h = makeHarness();
2258
+ try {
2259
+ const createCalls: string[] = [];
2260
+ const logs: string[] = [];
2261
+ const code = await init(
2262
+ baseOpts(h, {
2263
+ vaultName: "myvault",
2264
+ log: (l) => logs.push(l),
2265
+ createFirstVaultImpl: async (name) => {
2266
+ createCalls.push(name);
2267
+ return 0;
2268
+ },
2269
+ }),
2270
+ );
2271
+ expect(code).toBe(0);
2272
+ expect(createCalls).toEqual(["myvault"]);
2273
+ expect(logs.join("\n")).toContain('Creating vault "myvault"');
2274
+ expect(logs.join("\n")).toContain('Vault "myvault" created');
2275
+ } finally {
2276
+ h.cleanup();
2277
+ }
2278
+ });
2279
+
2280
+ test("(b) without vaultName: does NOT invoke createFirstVaultImpl", async () => {
2281
+ const h = makeHarness();
2282
+ try {
2283
+ let createCalled = false;
2284
+ const code = await init(
2285
+ baseOpts(h, {
2286
+ // no vaultName set
2287
+ createFirstVaultImpl: async () => {
2288
+ createCalled = true;
2289
+ return 0;
2290
+ },
2291
+ }),
2292
+ );
2293
+ expect(code).toBe(0);
2294
+ expect(createCalled).toBe(false);
2295
+ } finally {
2296
+ h.cleanup();
2297
+ }
2298
+ });
2299
+
2300
+ test("(c) invalid vault name via the CLI seam: validateVaultName rejects it", () => {
2301
+ // The CLI validates before calling init; test the validator directly
2302
+ // so the unit test doesn't need to drive argv parsing. The validator
2303
+ // is the same one the CLI uses (imported in cli.ts).
2304
+ const { validateVaultName } = require("../vault-name.ts");
2305
+ const result = validateVaultName("My Vault!");
2306
+ expect(result.ok).toBe(false);
2307
+ expect(result.error).toMatch(/lowercase alphanumeric/);
2308
+ });
2309
+
2310
+ test("(d) a seeded services.json vault MODULE row does NOT suppress the create", async () => {
2311
+ // REGRESSION (the rc.7 verification bug): Step 0.5's `install("vault",
2312
+ // { noCreate: true })` seeds a `parachute-vault` services.json row via
2313
+ // `spec.seedEntry` on EVERY fresh install. The OLD Step 1.6 keyed
2314
+ // idempotency off that row, so on the exact fresh-box path this feature
2315
+ // targets it saw the row + silently no-op'd the create — the headline
2316
+ // feature never fired. The row marks "module installed", not "instance
2317
+ // exists". Idempotency must live in `parachute-vault create`'s own exit
2318
+ // (which errors "already exists" on a real re-run), NOT a row precheck.
2319
+ // So: a seeded module row must NOT prevent the create from being attempted.
2320
+ const h = makeHarness();
2321
+ try {
2322
+ // Seed services.json with the module row (as Step 0.5 always does).
2323
+ seedVault(h.manifestPath);
2324
+ const createCalls: string[] = [];
2325
+ const logs: string[] = [];
2326
+ const code = await init(
2327
+ baseOpts(h, {
2328
+ vaultName: "myvault",
2329
+ log: (l) => logs.push(l),
2330
+ createFirstVaultImpl: async (name) => {
2331
+ createCalls.push(name);
2332
+ return 0;
2333
+ },
2334
+ }),
2335
+ );
2336
+ expect(code).toBe(0);
2337
+ // The create WAS attempted despite the seeded module row.
2338
+ expect(createCalls).toEqual(["myvault"]);
2339
+ expect(logs.join("\n")).toContain('Creating vault "myvault"');
2340
+ expect(logs.join("\n")).toContain('Vault "myvault" created');
2341
+ // No "already configured / ignored" no-op message.
2342
+ expect(logs.join("\n")).not.toContain("already configured");
2343
+ } finally {
2344
+ h.cleanup();
2345
+ }
2346
+ });
2347
+
2348
+ test("non-zero exit from create (e.g. vault already exists): warns but init still exits 0", async () => {
2349
+ // A non-zero exit covers both "vault already exists" (a benign re-run, where
2350
+ // `parachute-vault create` errors + exits 1) and a genuine creation failure.
2351
+ // Either way init is non-fatal — the operator can re-run / check status.
2352
+ const h = makeHarness();
2353
+ try {
2354
+ const logs: string[] = [];
2355
+ const code = await init(
2356
+ baseOpts(h, {
2357
+ vaultName: "myvault",
2358
+ log: (l) => logs.push(l),
2359
+ createFirstVaultImpl: async () => 1,
2360
+ }),
2361
+ );
2362
+ // Init is non-fatal on create failure — operator can retry.
2363
+ expect(code).toBe(0);
2364
+ const joined = logs.join("\n");
2365
+ expect(joined).toContain("exited 1");
2366
+ expect(joined).toContain("may already exist, or creation failed");
2367
+ expect(joined).toContain("parachute vault create myvault");
2368
+ } finally {
2369
+ h.cleanup();
2370
+ }
2371
+ });
2372
+ });
2373
+
2221
2374
  // Type alias used only inside this test file for the heuristic test.
2222
2375
  type ExposeChoice = "none" | "tailnet" | "cloudflare";
@@ -3267,7 +3267,7 @@ describe("typed vault name (hub#267)", () => {
3267
3267
  }
3268
3268
  });
3269
3269
 
3270
- test("vault POST with empty name falls back to 'default' + omits the env override", async () => {
3270
+ test("vault POST with empty name falls back to 'default' + ALWAYS passes PARACHUTE_VAULT_NAME (#478 Part 2)", async () => {
3271
3271
  const db = openHubDb(hubDbPath(h.dir));
3272
3272
  try {
3273
3273
  const user = await createUser(db, "owner", "pw");
@@ -3330,12 +3330,12 @@ describe("typed vault name (hub#267)", () => {
3330
3330
  await new Promise((r) => setTimeout(r, 50));
3331
3331
  const vaultSpawn = spawnRequests.find((s) => s.short === "vault");
3332
3332
  expect(vaultSpawn).toBeDefined();
3333
- // No PARACHUTE_VAULT_NAME override on the default-name path (vault's
3334
- // resolveFirstBootVaultName already defaults to "default" when the
3335
- // env var is absent). PORT is set by the supervisor (hub#356) for
3336
- // every supervised child regardless assert the empty-name path
3337
- // doesn't add PARACHUTE_VAULT_NAME.
3338
- expect(vaultSpawn?.env?.PARACHUTE_VAULT_NAME).toBeUndefined();
3333
+ // #478 Part 2: wizard ALWAYS passes PARACHUTE_VAULT_NAME so vault's
3334
+ // first-boot knows which vault to create once silent auto-create is
3335
+ // removed. Even for the default name, the env var must be set to
3336
+ // "default" not omitted. PORT is set by the supervisor (hub#356)
3337
+ // for every supervised child regardless.
3338
+ expect(vaultSpawn?.env?.PARACHUTE_VAULT_NAME).toBe("default");
3339
3339
  expect(vaultSpawn?.env?.PORT).toBe("1940");
3340
3340
  } finally {
3341
3341
  db.close();
@@ -68,11 +68,16 @@ describe("listVaultNames", () => {
68
68
  expect(listVaultNames(manifest)).toEqual(["personal", "work"]);
69
69
  });
70
70
 
71
- test("entry with no paths falls back to the manifest-suffix name (hub#143)", () => {
71
+ test("#478: bare empty-paths `parachute-vault` row yields NO name (no phantom 'default')", () => {
72
+ // What vault's self-register emits at zero vaults: the row stays present
73
+ // (installed-detection) but advertises no `/vault/<name>` path. It must not
74
+ // leak a selectable/assignable "default" before any vault exists. Mirrors
75
+ // the empty-paths skip in admin-vaults.ts (findExistingVault /
76
+ // listVaultInstanceNames).
72
77
  const manifest: ServicesManifest = {
73
78
  services: [
74
79
  {
75
- name: "parachute-vault-archived",
80
+ name: "parachute-vault",
76
81
  port: 1940,
77
82
  paths: [],
78
83
  health: "/h",
@@ -80,7 +85,31 @@ describe("listVaultNames", () => {
80
85
  },
81
86
  ],
82
87
  };
83
- expect(listVaultNames(manifest)).toEqual(["archived"]);
88
+ expect(listVaultNames(manifest)).toEqual([]);
89
+ });
90
+
91
+ test("#478: empty-paths row is skipped; real-paths vault rows are unchanged (positive control)", () => {
92
+ // An empty-paths row alongside a real-paths row: the real instance still
93
+ // resolves, the empty one contributes nothing.
94
+ const manifest: ServicesManifest = {
95
+ services: [
96
+ {
97
+ name: "parachute-vault",
98
+ port: 1940,
99
+ paths: [],
100
+ health: "/h",
101
+ version: "0.1.0",
102
+ },
103
+ {
104
+ name: "parachute-vault-work",
105
+ port: 1941,
106
+ paths: ["/vault/work"],
107
+ health: "/h",
108
+ version: "0.1.0",
109
+ },
110
+ ],
111
+ };
112
+ expect(listVaultNames(manifest)).toEqual(["work"]);
84
113
  });
85
114
 
86
115
  test("deduplicates collisions across single-entry + per-vault shapes", () => {
@@ -207,15 +207,11 @@ function findExistingVault(
207
207
  } catch {
208
208
  return null;
209
209
  }
210
- const target = `/vault/${name}`;
211
210
  for (const svc of manifest.services) {
212
211
  if (!isVaultEntry(svc)) continue;
213
- if (svc.paths.length === 0) {
214
- if (vaultInstanceNameFor(svc.name, undefined) === name) {
215
- return { url: target, version: svc.version, path: target };
216
- }
217
- continue;
218
- }
212
+ // #478: an empty-paths vault row means "installed but no servable vault
213
+ // instance" skip it entirely so it never resolves to a phantom "default".
214
+ if (svc.paths.length === 0) continue;
219
215
  for (const path of svc.paths) {
220
216
  if (vaultInstanceNameFor(svc.name, path) === name) {
221
217
  return { url: path, version: svc.version, path };
@@ -607,7 +603,7 @@ function emptyCascadeSummary(): CascadeSummary {
607
603
  }
608
604
 
609
605
  /** Every vault instance name currently registered in services.json. */
610
- function listVaultInstanceNames(manifestPath: string): Set<string> {
606
+ export function listVaultInstanceNames(manifestPath: string): Set<string> {
611
607
  const names = new Set<string>();
612
608
  let manifest: ReturnType<typeof readManifest>;
613
609
  try {
@@ -617,10 +613,9 @@ function listVaultInstanceNames(manifestPath: string): Set<string> {
617
613
  }
618
614
  for (const svc of manifest.services) {
619
615
  if (!isVaultEntry(svc)) continue;
620
- if (svc.paths.length === 0) {
621
- names.add(vaultInstanceNameFor(svc.name, undefined));
622
- continue;
623
- }
616
+ // #478: an empty-paths vault row means "installed but no servable vault
617
+ // instance" — skip it so no phantom "default" is synthesized.
618
+ if (svc.paths.length === 0) continue;
624
619
  for (const path of svc.paths) names.add(vaultInstanceNameFor(svc.name, path));
625
620
  }
626
621
  return names;
package/src/cli.ts CHANGED
@@ -420,17 +420,37 @@ async function main(argv: string[]): Promise<number> {
420
420
  );
421
421
  return 1;
422
422
  }
423
- const noBrowser = channelExtract.rest.includes("--no-browser");
424
- const noExposePrompt = channelExtract.rest.includes("--no-expose-prompt");
425
- const cliWizard = channelExtract.rest.includes("--cli-wizard");
426
- const browserWizard = channelExtract.rest.includes("--browser-wizard");
423
+ // #478 Part 2: --vault-name <name> creates the first vault in one shot.
424
+ const vaultNameExtract = extractNamedFlag(channelExtract.rest, "--vault-name");
425
+ if (vaultNameExtract.error) {
426
+ console.error(`parachute init: ${vaultNameExtract.error}`);
427
+ return 1;
428
+ }
429
+ let validatedVaultName: string | undefined;
430
+ if (vaultNameExtract.value !== undefined) {
431
+ if (vaultNameExtract.value.trim() === "") {
432
+ console.error("parachute init: --vault-name must not be empty.");
433
+ return 1;
434
+ }
435
+ const { validateVaultName: vvn } = await import("./vault-name.ts");
436
+ const vr = vvn(vaultNameExtract.value);
437
+ if (!vr.ok) {
438
+ console.error(`parachute init: invalid --vault-name: ${vr.error}`);
439
+ return 1;
440
+ }
441
+ validatedVaultName = vr.name;
442
+ }
443
+ const noBrowser = vaultNameExtract.rest.includes("--no-browser");
444
+ const noExposePrompt = vaultNameExtract.rest.includes("--no-expose-prompt");
445
+ const cliWizard = vaultNameExtract.rest.includes("--cli-wizard");
446
+ const browserWizard = vaultNameExtract.rest.includes("--browser-wizard");
427
447
  const known = new Set([
428
448
  "--no-browser",
429
449
  "--no-expose-prompt",
430
450
  "--cli-wizard",
431
451
  "--browser-wizard",
432
452
  ]);
433
- const unknown = channelExtract.rest.find((a) => !known.has(a));
453
+ const unknown = vaultNameExtract.rest.find((a) => !known.has(a));
434
454
  if (unknown !== undefined) {
435
455
  console.error(`parachute init: unknown argument "${unknown}"`);
436
456
  console.error(
@@ -438,6 +458,7 @@ async function main(argv: string[]): Promise<number> {
438
458
  " [--expose none|tailnet|cloudflare]\n" +
439
459
  " [--channel rc|latest]\n" +
440
460
  " [--hub-origin <url>]\n" +
461
+ " [--vault-name <name>]\n" +
441
462
  " [--cli-wizard | --browser-wizard]",
442
463
  );
443
464
  return 1;
@@ -456,6 +477,7 @@ async function main(argv: string[]): Promise<number> {
456
477
  if (channelExtract.value === "rc" || channelExtract.value === "latest") {
457
478
  initOpts.channel = channelExtract.value;
458
479
  }
480
+ if (validatedVaultName !== undefined) initOpts.vaultName = validatedVaultName;
459
481
  if (cliWizard) initOpts.wizardChoice = "cli";
460
482
  else if (browserWizard) initOpts.wizardChoice = "browser";
461
483
  const mod = await loadCommand("init", () => import("./commands/init.ts"));
@@ -52,6 +52,7 @@ import { issueOperatorToken, readOperatorTokenFile } from "../operator-token.ts"
52
52
  import { type AliveFn, defaultAlive, processState } from "../process-state.ts";
53
53
  import { findService, readManifestLenient } from "../services-manifest.ts";
54
54
  import { listUsers } from "../users.ts";
55
+ import { validateVaultName } from "../vault-name.ts";
55
56
  import { type InstallOpts, install as defaultInstall } from "./install.ts";
56
57
 
57
58
  /** The three options the exposure prompt offers — also the `--expose` flag's domain. */
@@ -202,6 +203,44 @@ export interface InitOpts {
202
203
  * already known so there's no question to ask).
203
204
  */
204
205
  noWizardPrompt?: boolean;
206
+ /**
207
+ * Vault name to create as part of `parachute init --vault-name <name>`
208
+ * (#478 Part 2). When set, init creates the first vault via
209
+ * `createFirstVaultImpl` immediately after Step 1.5 (operator-token
210
+ * guarantee), before the admin-URL resolution. Validated with
211
+ * `validateVaultName` in the CLI before reaching here.
212
+ *
213
+ * Idempotency lives in `parachute-vault create`, NOT in a services.json
214
+ * precheck: `create <name>` exits 0 + creates when `<name>` is new, and
215
+ * exits non-zero ("Vault \"<name>\" already exists.") on a re-run. We
216
+ * therefore ALWAYS attempt the create when this field is set and treat a
217
+ * non-zero exit as non-fatal (warn + continue). A services.json
218
+ * `parachute-vault` row is the MODULE-installed marker (Step 0.5 seeds it
219
+ * via `spec.seedEntry` on EVERY fresh install), not an instance marker —
220
+ * keying idempotency off it would silently no-op the create on the exact
221
+ * fresh-box path this feature targets.
222
+ *
223
+ * Without this field (the default), init makes NO vault — the wizard
224
+ * owns Create/Import/Skip as before. The --no-browser / scripted path
225
+ * remains vault-free unless --vault-name is explicitly passed.
226
+ */
227
+ vaultName?: string;
228
+ /**
229
+ * Test seam: injectable impl for the `--vault-name` create step (#478
230
+ * Part 2). This IS the whole create implementation — tests swap the
231
+ * entire function for a stub that records the call without touching a
232
+ * live vault binary. It takes the vault name plus a ctx carrying a
233
+ * runner shim, and returns an exit code (0 = success).
234
+ *
235
+ * The production default (`defaultCreateFirstVault`) uses that runner to
236
+ * shell out `["parachute-vault", "create", name]`, following the `Runner`
237
+ * type pattern established across every command that shells out:
238
+ * `readonly string[] => Promise<number>`.
239
+ */
240
+ createFirstVaultImpl?: (
241
+ name: string,
242
+ ctx: { runner: (cmd: readonly string[]) => Promise<number> },
243
+ ) => Promise<number>;
205
244
  /**
206
245
  * Canonical public hub origin (the OAuth issuer / `iss` claim). Persisted to
207
246
  * `hub_settings.hub_origin` BEFORE the hub unit starts + modules spawn, so
@@ -599,6 +638,38 @@ async function defaultFetchBootstrapToken(loopbackUrl: string): Promise<string |
599
638
  }
600
639
  }
601
640
 
641
+ /**
642
+ * Default runner shim for the `--vault-name` create step (#478 Part 2).
643
+ * Shells out `parachute-vault create <name>` via Bun.spawn, inheriting
644
+ * the operator's full env (so PARACHUTE_HOME, PATH, etc. pass through).
645
+ *
646
+ * This is the same pattern every other command that shells out uses — a
647
+ * `Runner` (`readonly string[] => Promise<number>`) so tests can inject a
648
+ * stub without touching the real vault binary.
649
+ */
650
+ async function defaultRunner(cmd: readonly string[]): Promise<number> {
651
+ const proc = Bun.spawn([...cmd], {
652
+ stdio: ["inherit", "inherit", "inherit"],
653
+ env: process.env,
654
+ });
655
+ return await proc.exited;
656
+ }
657
+
658
+ /**
659
+ * Default impl for the `--vault-name` first-vault create step (#478 Part 2).
660
+ * Invokes `parachute-vault create <name>` via the injectable runner. The
661
+ * `parachute-vault` binary must already be on PATH (guaranteed by Step 0.5's
662
+ * vault-module install). Exit code is forwarded directly — callers log the
663
+ * outcome and continue (a non-zero create doesn't abort init; the operator
664
+ * can re-run `parachute vault create <name>` or use the wizard to retry).
665
+ */
666
+ async function defaultCreateFirstVault(
667
+ name: string,
668
+ ctx: { runner: (cmd: readonly string[]) => Promise<number> },
669
+ ): Promise<number> {
670
+ return await ctx.runner(["parachute-vault", "create", name]);
671
+ }
672
+
602
673
  /**
603
674
  * Prompt for the wizard-choice question (hub#168 Cut 4). Returns the
604
675
  * picked option, or `undefined` if the operator quit. Default is
@@ -735,6 +806,7 @@ export async function init(opts: InitOpts = {}): Promise<number> {
735
806
  const runCliWizardImpl = opts.runCliWizardImpl ?? defaultRunCliWizard;
736
807
  const fetchBootstrapTokenImpl = opts.fetchBootstrapTokenImpl ?? defaultFetchBootstrapToken;
737
808
  const setHubOriginImpl = opts.setHubOriginImpl ?? defaultSetHubOrigin;
809
+ const createFirstVaultImpl = opts.createFirstVaultImpl ?? defaultCreateFirstVault;
738
810
 
739
811
  log("Parachute init — getting your hub set up.");
740
812
  log("");
@@ -885,6 +957,42 @@ export async function init(opts: InitOpts = {}): Promise<number> {
885
957
  // to the wizard regardless.
886
958
  await guaranteeOperatorToken({ configDir, hubPort, log });
887
959
 
960
+ // Step 1.6 (#478 Part 2): if `--vault-name <name>` was given, create the
961
+ // first vault now — after the hub is up (Step 1) and the operator token is
962
+ // guaranteed (Step 1.5). The vault module was installed at Step 0.5, so
963
+ // `parachute-vault` is on PATH.
964
+ //
965
+ // We ALWAYS attempt the create when `--vault-name` is set. Idempotency lives
966
+ // in `parachute-vault create` itself, NOT in a services.json precheck:
967
+ // - `create <name>` exits 0 + creates the vault when `<name>` is new.
968
+ // - `create <name>` exits non-zero ("Vault \"<name>\" already exists.") when
969
+ // that exact name already exists (a benign re-run).
970
+ //
971
+ // We DON'T precheck the services.json `parachute-vault` row: Step 0.5's
972
+ // `install("vault", { noCreate: true })` seeds that row via `spec.seedEntry`
973
+ // on EVERY fresh install (the module-installed marker — see install.ts's
974
+ // InstallOpts doc), so on the exact fresh-box path this feature targets the
975
+ // row is ALWAYS present and a row-keyed precheck would silently no-op the
976
+ // create. The row marks "module installed", not "instance exists" — only the
977
+ // create command's own exit reliably distinguishes the two.
978
+ //
979
+ // A non-zero exit is non-fatal: warn + continue. It could mean the vault
980
+ // already exists (a fine re-run) OR a genuine creation failure — init's
981
+ // contract is hub up → wizard regardless, so we never abort here. The
982
+ // operator can check `parachute status` / re-run `parachute vault create`.
983
+ if (opts.vaultName !== undefined) {
984
+ log(`Creating vault "${opts.vaultName}"…`);
985
+ const createCode = await createFirstVaultImpl(opts.vaultName, { runner: defaultRunner });
986
+ if (createCode === 0) {
987
+ log(`✓ Vault "${opts.vaultName}" created.`);
988
+ } else {
989
+ log(
990
+ `⚠ \`parachute-vault create ${opts.vaultName}\` exited ${createCode} — the vault may already exist, or creation failed. Check \`parachute status\` / re-run \`parachute vault create ${opts.vaultName}\`.`,
991
+ );
992
+ }
993
+ log("");
994
+ }
995
+
888
996
  // Step 2: exposure chain. Skipped when already exposed, in non-TTY,
889
997
  // or when --no-expose-prompt was passed. `--expose <choice>` jumps
890
998
  // straight to the corresponding chain without asking.
package/src/help.ts CHANGED
@@ -134,6 +134,7 @@ Usage:
134
134
  [--expose none|tailnet|cloudflare]
135
135
  [--channel rc|latest]
136
136
  [--hub-origin <url>]
137
+ [--vault-name <name>]
137
138
  [--cli-wizard | --browser-wizard]
138
139
 
139
140
  What it does:
@@ -178,6 +179,14 @@ Flags:
178
179
  accepting it in one pass. For reverse-proxy /
179
180
  Caddy-direct boxes that bind loopback but are reached
180
181
  over a public HTTPS URL (e.g. https://<ip>.sslip.io).
182
+ --vault-name <name> create the first vault in one shot (#478 Part 2).
183
+ Runs \`parachute-vault create <name>\` after the hub
184
+ is up. Non-fatal on re-run — \`create\` exits
185
+ non-zero if the vault already exists, and that's
186
+ tolerated. Must be a valid vault name: lowercase
187
+ alphanumeric + hyphens/underscores, 2–32 chars.
188
+ Without this flag, the wizard owns vault creation
189
+ (the default experience is unchanged).
181
190
  --cli-wizard skip the "browser or CLI?" prompt and walk the wizard
182
191
  in this terminal (hub#168 Cut 4)
183
192
  --browser-wizard skip the prompt and open the browser wizard directly
@@ -190,7 +199,9 @@ Examples:
190
199
  parachute init --expose tailnet # CI/scripted: chain straight into Tailscale
191
200
  parachute init --no-browser # don't shell out to open / xdg-open
192
201
  parachute init --cli-wizard # walk the wizard in this terminal (hub#168)
193
- parachute init --channel rc # rc box: install the vault module from @rc
202
+ parachute init --channel rc # rc box: install the vault module from @rc
203
+ parachute init --vault-name default --no-browser
204
+ # CI/scripted: hub + first vault in one pass
194
205
  `;
195
206
  }
196
207
 
@@ -2344,19 +2344,19 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
2344
2344
  if (registry) {
2345
2345
  // hub#267: thread the typed name through `PARACHUTE_VAULT_NAME` so
2346
2346
  // vault's first-boot path (vault#342) names the created vault
2347
- // accordingly. Skip the env override when the operator left the
2348
- // field blank — vault's `resolveFirstBootVaultName` defaults to
2349
- // `default` on absent env vars, so this preserves the prior
2350
- // behaviour for the empty-input case.
2347
+ // accordingly.
2351
2348
  //
2352
- // If the operator typed "default" explicitly, treat the same as
2353
- // blank vault's first-boot defaults to "default" anyway, so
2354
- // skipping the env override is correct (the comparison below
2355
- // catches both blank-trimmed-to-DEFAULT and typed-"default").
2356
- const spawnEnv: Record<string, string> = {};
2357
- if (vaultName !== DEFAULT_VAULT_NAME) {
2358
- spawnEnv.PARACHUTE_VAULT_NAME = vaultName;
2359
- }
2349
+ // #478 Part 2: ALWAYS set `PARACHUTE_VAULT_NAME`, even when the name
2350
+ // is "default". Once vault removes its silent auto-create-on-missing-env
2351
+ // behavior, vault's first-boot will require the env var to know which
2352
+ // vault to create — a missing `PARACHUTE_VAULT_NAME` will mean "no vault
2353
+ // to create" rather than "create one named default". Passing it
2354
+ // explicitly for every path (including the default) is correct and safe:
2355
+ // vault's `resolveFirstBootVaultName` accepts "default" as a valid name
2356
+ // and behaves identically to the prior implicit default.
2357
+ const spawnEnv: Record<string, string> = {
2358
+ PARACHUTE_VAULT_NAME: vaultName,
2359
+ };
2360
2360
  // Capture importParams + deps in the runInstall promise chain — when
2361
2361
  // mode === "import", run the vault-side `/.parachute/mirror/import`
2362
2362
  // POST as a follow-up step once the supervised vault has come up
@@ -2377,7 +2377,7 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
2377
2377
  registry,
2378
2378
  ...(deps.run ? { run: deps.run } : {}),
2379
2379
  ...(deps.isLinked ? { isLinked: deps.isLinked } : {}),
2380
- ...(Object.keys(spawnEnv).length > 0 ? { spawnEnv } : {}),
2380
+ spawnEnv,
2381
2381
  })
2382
2382
  .then(async () => {
2383
2383
  if (!importToRun) return;
@@ -23,8 +23,17 @@
23
23
  * Walks both manifest shapes: single-entry-multi-path (`parachute-vault`
24
24
  * with `paths: ["/vault/work", "/vault/personal"]`) and per-vault entries
25
25
  * (`parachute-vault-work`) by delegating each (name, path) pair to
26
- * `vaultInstanceNameFor`. Entries with no paths still resolve to a name via
27
- * the helper's manifest-suffix fallback (hub#143).
26
+ * `vaultInstanceNameFor`.
27
+ *
28
+ * #478: an empty-paths vault row (e.g. `parachute-vault` with `paths: []`,
29
+ * which vault's self-register emits at zero vaults) is "installed but no
30
+ * servable vault instance" and is SKIPPED entirely — it must not synthesize a
31
+ * name (the bare `parachute-vault` would otherwise resolve to a phantom
32
+ * "default"). This mirrors the empty-paths `continue` in `admin-vaults.ts`'s
33
+ * `findExistingVault`/`listVaultInstanceNames`, so every read path agrees: a
34
+ * vault instance is named only by a real `/vault/<name>` mount path. This
35
+ * supersedes the prior hub#143 manifest-suffix fallback for path-less entries
36
+ * — a registered vault carries its mount path once a vault exists.
28
37
  */
29
38
  import { type ServicesManifest, readManifestLenient } from "./services-manifest.ts";
30
39
  import { isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
@@ -39,8 +48,10 @@ export function listVaultNames(manifest: ServicesManifest): string[] {
39
48
  const names = new Set<string>();
40
49
  for (const svc of manifest.services) {
41
50
  if (!isVaultEntry(svc)) continue;
42
- const paths = svc.paths.length > 0 ? svc.paths : [undefined];
43
- for (const path of paths) {
51
+ // #478: an empty-paths vault row means "installed but no servable vault
52
+ // instance" skip it so it never synthesizes a phantom "default".
53
+ if (svc.paths.length === 0) continue;
54
+ for (const path of svc.paths) {
44
55
  names.add(vaultInstanceNameFor(svc.name, path));
45
56
  }
46
57
  }