@openparachute/hub 0.7.4-rc.6 → 0.7.4-rc.8

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.6",
3
+ "version": "0.7.4-rc.8",
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": {
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "dependencies": {
42
42
  "@node-rs/argon2": "^2.0.2",
43
- "@openparachute/depcheck": "0.1.0",
43
+ "@openparachute/depcheck": "0.1.1",
44
44
  "jose": "^6.2.2",
45
45
  "otpauth": "^9.5.0",
46
46
  "qrcode": "^1.5.4"
@@ -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();
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;