@openparachute/hub 0.7.3 → 0.7.4-rc.1

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.3",
3
+ "version": "0.7.4-rc.1",
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": {
@@ -103,6 +103,28 @@ describe("cli", () => {
103
103
  expect(code).toBe(1);
104
104
  expect(stderr).toMatch(/invalid --hub-origin/);
105
105
  });
106
+
107
+ // hub#694 bug 2: `init --channel` mirrors `install --channel` — rejected at
108
+ // the arg layer (exit 1) before any daemon work, so these are hermetic.
109
+ test("init --channel without a value exits 1 (hub#694)", async () => {
110
+ const { code, stderr } = await runCli(["init", "--channel"]);
111
+ expect(code).toBe(1);
112
+ expect(stderr).toMatch(/--channel requires a value/);
113
+ });
114
+
115
+ test("init --channel with an invalid value exits 1 (hub#694)", async () => {
116
+ const { code, stderr } = await runCli(["init", "--channel", "banana"]);
117
+ expect(code).toBe(1);
118
+ expect(stderr).toMatch(/--channel must be "rc" or "latest"/);
119
+ expect(stderr).toMatch(/banana/);
120
+ });
121
+
122
+ test("init --help documents --channel (hub#694)", async () => {
123
+ const { code, stdout } = await runCli(["init", "--help"]);
124
+ expect(code).toBe(0);
125
+ expect(stdout).toMatch(/--channel/);
126
+ expect(stdout).toMatch(/rc\|latest/);
127
+ });
106
128
  });
107
129
 
108
130
  describe("cli per-subcommand help", () => {
@@ -2,7 +2,13 @@ import { describe, expect, test } from "bun:test";
2
2
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { hasNoDisplay, init, looksLikeServer, resolveAdminUrl } from "../commands/init.ts";
5
+ import {
6
+ hasNoDisplay,
7
+ init,
8
+ looksLikeServer,
9
+ resolveAdminUrl,
10
+ resolveInitChannel,
11
+ } from "../commands/init.ts";
6
12
  import type { ExposeState } from "../expose-state.ts";
7
13
  import { writeHubPort } from "../hub-control.ts";
8
14
  import { writePid } from "../process-state.ts";
@@ -1865,5 +1871,352 @@ describe("init exposure chain", () => {
1865
1871
  });
1866
1872
  });
1867
1873
 
1874
+ // ---------------------------------------------------------------------------
1875
+ // hub#694 — first-run robustness.
1876
+ //
1877
+ // Bug 1 (the spawn race): the supervisor scans services.json EXACTLY ONCE at
1878
+ // hub-unit boot. So the vault module MUST be seeded into services.json BEFORE
1879
+ // the hub unit starts (ensureHub) — otherwise the boot scan finds no vault row
1880
+ // and vault never spawns until a manual `parachute restart`. These tests pin the
1881
+ // ordering: vault-install precedes ensureHub on both the laptop (no --hub-origin)
1882
+ // and the DO (--hub-origin) path, and Step-0 origin-persist stays first.
1883
+ //
1884
+ // Bug 2 (channel): init must install the vault module from the chosen channel
1885
+ // (`--channel rc` / PARACHUTE_CHANNEL=rc), not always @latest — otherwise an rc
1886
+ // box downgrades vault below the rc-tracking hub.
1887
+ // ---------------------------------------------------------------------------
1888
+
1889
+ describe("init hub#694 — vault seeded before the supervisor boot scan (bug 1)", () => {
1890
+ test("laptop (no --hub-origin): installs+seeds vault BEFORE ensureHub", async () => {
1891
+ const h = makeHarness();
1892
+ try {
1893
+ const order: string[] = [];
1894
+ const code = await init({
1895
+ configDir: h.configDir,
1896
+ ensureHubVersion: async () => ({
1897
+ outcome: "match" as const,
1898
+ installedVersion: "test",
1899
+ messages: [],
1900
+ }),
1901
+ manifestPath: h.manifestPath,
1902
+ log: () => {},
1903
+ alive: () => false,
1904
+ // The load-bearing assertion: the vault module install (which seeds the
1905
+ // services.json row) MUST happen before the hub unit boots, so the
1906
+ // supervisor's one-time boot scan finds + spawns vault on the first pass.
1907
+ installVaultModuleImpl: async () => {
1908
+ order.push("install-vault");
1909
+ return 0;
1910
+ },
1911
+ ensureHub: async () => {
1912
+ order.push("ensureHub");
1913
+ writeHubPort(1939, h.configDir);
1914
+ return { pid: 0, port: 1939, started: true };
1915
+ },
1916
+ readExposeStateFn: () => undefined,
1917
+ isTty: false,
1918
+ platform: "linux",
1919
+ });
1920
+ expect(code).toBe(0);
1921
+ expect(order).toEqual(["install-vault", "ensureHub"]);
1922
+ } finally {
1923
+ h.cleanup();
1924
+ }
1925
+ });
1926
+
1927
+ test("--hub-origin (DO path): order is set-origin → install vault → ensureHub", async () => {
1928
+ const h = makeHarness();
1929
+ try {
1930
+ const order: string[] = [];
1931
+ const code = await init({
1932
+ configDir: h.configDir,
1933
+ ensureHubVersion: async () => ({
1934
+ outcome: "match" as const,
1935
+ installedVersion: "test",
1936
+ messages: [],
1937
+ }),
1938
+ manifestPath: h.manifestPath,
1939
+ log: () => {},
1940
+ alive: () => false,
1941
+ hubOrigin: "https://box.sslip.io",
1942
+ // hub#693 Step-0 origin persist must remain FIRST (boot issuer + child
1943
+ // env read it); the hub#694 vault seed slots in AFTER it but BEFORE the
1944
+ // hub unit boots, so the freshly-spawned vault comes up with the public
1945
+ // origin in its accepted-iss set AND is present in the boot scan — in one
1946
+ // pass, no restart.
1947
+ setHubOriginImpl: (_dir, origin) => {
1948
+ order.push(`set-origin:${origin}`);
1949
+ },
1950
+ installVaultModuleImpl: async () => {
1951
+ order.push("install-vault");
1952
+ return 0;
1953
+ },
1954
+ ensureHub: async () => {
1955
+ order.push("ensureHub");
1956
+ writeHubPort(1939, h.configDir);
1957
+ return { pid: 0, port: 1939, started: true };
1958
+ },
1959
+ readExposeStateFn: () => undefined,
1960
+ isTty: false,
1961
+ platform: "linux",
1962
+ });
1963
+ expect(code).toBe(0);
1964
+ expect(order).toEqual(["set-origin:https://box.sslip.io", "install-vault", "ensureHub"]);
1965
+ } finally {
1966
+ h.cleanup();
1967
+ }
1968
+ });
1969
+
1970
+ test("real seed: vault row is in services.json by the time ensureHub runs", async () => {
1971
+ // End-to-end-ish: drive the REAL defaultInstallVaultModule (bun-linked vault
1972
+ // short-circuits the bun-add) and assert the services.json row exists when
1973
+ // ensureHub is invoked — i.e. the boot scan would find it.
1974
+ const h = makeHarness();
1975
+ try {
1976
+ let vaultRowAtEnsure: boolean | undefined;
1977
+ const code = await init({
1978
+ configDir: h.configDir,
1979
+ ensureHubVersion: async () => ({
1980
+ outcome: "match" as const,
1981
+ installedVersion: "test",
1982
+ messages: [],
1983
+ }),
1984
+ manifestPath: h.manifestPath,
1985
+ log: () => {},
1986
+ alive: () => false,
1987
+ ensureHub: async () => {
1988
+ // At hub-unit-boot time the services.json must already carry vault.
1989
+ const { findService } = await import("../services-manifest.ts");
1990
+ vaultRowAtEnsure = findService("parachute-vault", h.manifestPath) !== undefined;
1991
+ writeHubPort(1939, h.configDir);
1992
+ return { pid: 0, port: 1939, started: true };
1993
+ },
1994
+ readExposeStateFn: () => undefined,
1995
+ isTty: false,
1996
+ platform: "linux",
1997
+ // NOTE: no installVaultModuleImpl override — exercise the real install
1998
+ // so we prove the seed write lands before ensureHub, not just the stub.
1999
+ });
2000
+ expect(code).toBe(0);
2001
+ expect(vaultRowAtEnsure).toBe(true);
2002
+ } finally {
2003
+ h.cleanup();
2004
+ }
2005
+ });
2006
+
2007
+ test("already-seeded vault: install is skipped, ensureHub still runs", async () => {
2008
+ const h = makeHarness();
2009
+ try {
2010
+ seedVault(h.manifestPath);
2011
+ const order: string[] = [];
2012
+ const code = await init({
2013
+ configDir: h.configDir,
2014
+ ensureHubVersion: async () => ({
2015
+ outcome: "match" as const,
2016
+ installedVersion: "test",
2017
+ messages: [],
2018
+ }),
2019
+ manifestPath: h.manifestPath,
2020
+ log: () => {},
2021
+ alive: () => false,
2022
+ installVaultModuleImpl: async () => {
2023
+ order.push("install-vault");
2024
+ return 0;
2025
+ },
2026
+ ensureHub: async () => {
2027
+ order.push("ensureHub");
2028
+ writeHubPort(1939, h.configDir);
2029
+ return { pid: 0, port: 1939, started: true };
2030
+ },
2031
+ readExposeStateFn: () => undefined,
2032
+ isTty: false,
2033
+ platform: "linux",
2034
+ });
2035
+ expect(code).toBe(0);
2036
+ // Vault already present → install short-circuits; only ensureHub fires.
2037
+ expect(order).toEqual(["ensureHub"]);
2038
+ } finally {
2039
+ h.cleanup();
2040
+ }
2041
+ });
2042
+ });
2043
+
2044
+ describe("init hub#694 — install channel (bug 2)", () => {
2045
+ test("--channel rc propagates to the vault module install", async () => {
2046
+ const h = makeHarness();
2047
+ try {
2048
+ const channels: (string | undefined)[] = [];
2049
+ const code = await init({
2050
+ configDir: h.configDir,
2051
+ ensureHubVersion: async () => ({
2052
+ outcome: "match" as const,
2053
+ installedVersion: "test",
2054
+ messages: [],
2055
+ }),
2056
+ manifestPath: h.manifestPath,
2057
+ log: () => {},
2058
+ alive: () => false,
2059
+ channel: "rc",
2060
+ installVaultModuleImpl: async (_d, _m, channel) => {
2061
+ channels.push(channel);
2062
+ return 0;
2063
+ },
2064
+ ensureHub: async () => {
2065
+ writeHubPort(1939, h.configDir);
2066
+ return { pid: 0, port: 1939, started: true };
2067
+ },
2068
+ readExposeStateFn: () => undefined,
2069
+ isTty: false,
2070
+ platform: "linux",
2071
+ });
2072
+ expect(code).toBe(0);
2073
+ expect(channels).toEqual(["rc"]);
2074
+ } finally {
2075
+ h.cleanup();
2076
+ }
2077
+ });
2078
+
2079
+ test("PARACHUTE_CHANNEL=rc env propagates when no --channel flag", async () => {
2080
+ const h = makeHarness();
2081
+ try {
2082
+ const channels: (string | undefined)[] = [];
2083
+ const code = await init({
2084
+ configDir: h.configDir,
2085
+ ensureHubVersion: async () => ({
2086
+ outcome: "match" as const,
2087
+ installedVersion: "test",
2088
+ messages: [],
2089
+ }),
2090
+ manifestPath: h.manifestPath,
2091
+ log: () => {},
2092
+ alive: () => false,
2093
+ // No `channel` opt — exercise the env fallback the DO cloud-init script
2094
+ // relies on (it exports PARACHUTE_CHANNEL but passes no --channel flag).
2095
+ env: { PARACHUTE_CHANNEL: "rc" } as NodeJS.ProcessEnv,
2096
+ installVaultModuleImpl: async (_d, _m, channel) => {
2097
+ channels.push(channel);
2098
+ return 0;
2099
+ },
2100
+ ensureHub: async () => {
2101
+ writeHubPort(1939, h.configDir);
2102
+ return { pid: 0, port: 1939, started: true };
2103
+ },
2104
+ readExposeStateFn: () => undefined,
2105
+ isTty: false,
2106
+ platform: "linux",
2107
+ });
2108
+ expect(code).toBe(0);
2109
+ expect(channels).toEqual(["rc"]);
2110
+ } finally {
2111
+ h.cleanup();
2112
+ }
2113
+ });
2114
+
2115
+ test("default (no flag, no env): vault install gets undefined → install resolves latest", async () => {
2116
+ const h = makeHarness();
2117
+ try {
2118
+ const channels: (string | undefined)[] = [];
2119
+ const code = await init({
2120
+ configDir: h.configDir,
2121
+ ensureHubVersion: async () => ({
2122
+ outcome: "match" as const,
2123
+ installedVersion: "test",
2124
+ messages: [],
2125
+ }),
2126
+ manifestPath: h.manifestPath,
2127
+ log: () => {},
2128
+ alive: () => false,
2129
+ env: {} as NodeJS.ProcessEnv,
2130
+ installVaultModuleImpl: async (_d, _m, channel) => {
2131
+ channels.push(channel);
2132
+ return 0;
2133
+ },
2134
+ ensureHub: async () => {
2135
+ writeHubPort(1939, h.configDir);
2136
+ return { pid: 0, port: 1939, started: true };
2137
+ },
2138
+ readExposeStateFn: () => undefined,
2139
+ isTty: false,
2140
+ platform: "linux",
2141
+ });
2142
+ expect(code).toBe(0);
2143
+ expect(channels).toEqual([undefined]);
2144
+ } finally {
2145
+ h.cleanup();
2146
+ }
2147
+ });
2148
+
2149
+ test("--channel flag overrides PARACHUTE_CHANNEL env", async () => {
2150
+ const h = makeHarness();
2151
+ try {
2152
+ const channels: (string | undefined)[] = [];
2153
+ const code = await init({
2154
+ configDir: h.configDir,
2155
+ ensureHubVersion: async () => ({
2156
+ outcome: "match" as const,
2157
+ installedVersion: "test",
2158
+ messages: [],
2159
+ }),
2160
+ manifestPath: h.manifestPath,
2161
+ log: () => {},
2162
+ alive: () => false,
2163
+ channel: "latest",
2164
+ env: { PARACHUTE_CHANNEL: "rc" } as NodeJS.ProcessEnv,
2165
+ installVaultModuleImpl: async (_d, _m, channel) => {
2166
+ channels.push(channel);
2167
+ return 0;
2168
+ },
2169
+ ensureHub: async () => {
2170
+ writeHubPort(1939, h.configDir);
2171
+ return { pid: 0, port: 1939, started: true };
2172
+ },
2173
+ readExposeStateFn: () => undefined,
2174
+ isTty: false,
2175
+ platform: "linux",
2176
+ });
2177
+ expect(code).toBe(0);
2178
+ expect(channels).toEqual(["latest"]);
2179
+ } finally {
2180
+ h.cleanup();
2181
+ }
2182
+ });
2183
+ });
2184
+
2185
+ describe("resolveInitChannel (hub#694 bug 2)", () => {
2186
+ const empty = {} as NodeJS.ProcessEnv;
2187
+ test("explicit rc/latest wins", () => {
2188
+ expect(resolveInitChannel("rc", empty)).toBe("rc");
2189
+ expect(resolveInitChannel("latest", { PARACHUTE_CHANNEL: "rc" } as NodeJS.ProcessEnv)).toBe(
2190
+ "latest",
2191
+ );
2192
+ });
2193
+ test("PARACHUTE_CHANNEL env when no explicit", () => {
2194
+ expect(resolveInitChannel(undefined, { PARACHUTE_CHANNEL: "rc" } as NodeJS.ProcessEnv)).toBe(
2195
+ "rc",
2196
+ );
2197
+ });
2198
+ test("PARACHUTE_INSTALL_CHANNEL env when no explicit + no PARACHUTE_CHANNEL", () => {
2199
+ expect(
2200
+ resolveInitChannel(undefined, { PARACHUTE_INSTALL_CHANNEL: "rc" } as NodeJS.ProcessEnv),
2201
+ ).toBe("rc");
2202
+ });
2203
+ test("PARACHUTE_CHANNEL wins over PARACHUTE_INSTALL_CHANNEL", () => {
2204
+ expect(
2205
+ resolveInitChannel(undefined, {
2206
+ PARACHUTE_CHANNEL: "latest",
2207
+ PARACHUTE_INSTALL_CHANNEL: "rc",
2208
+ } as NodeJS.ProcessEnv),
2209
+ ).toBe("latest");
2210
+ });
2211
+ test("garbage env → undefined (install falls back to latest)", () => {
2212
+ expect(
2213
+ resolveInitChannel(undefined, { PARACHUTE_CHANNEL: "banana" } as NodeJS.ProcessEnv),
2214
+ ).toBe(undefined);
2215
+ });
2216
+ test("nothing set → undefined", () => {
2217
+ expect(resolveInitChannel(undefined, empty)).toBe(undefined);
2218
+ });
2219
+ });
2220
+
1868
2221
  // Type alias used only inside this test file for the heuristic test.
1869
2222
  type ExposeChoice = "none" | "tailnet" | "cloudflare";
package/src/cli.ts CHANGED
@@ -402,22 +402,41 @@ async function main(argv: string[]): Promise<number> {
402
402
  );
403
403
  return 1;
404
404
  }
405
- const noBrowser = exposeExtract.rest.includes("--no-browser");
406
- const noExposePrompt = exposeExtract.rest.includes("--no-expose-prompt");
407
- const cliWizard = exposeExtract.rest.includes("--cli-wizard");
408
- const browserWizard = exposeExtract.rest.includes("--browser-wizard");
405
+ // hub#694 bug 2: `--channel rc|latest` picks the dist-tag init installs the
406
+ // vault module from. Without it, init always resolved @latest — DOWNGRADING
407
+ // vault below an rc-tracking hub. Mirrors `parachute install --channel`.
408
+ const channelExtract = extractNamedFlag(exposeExtract.rest, "--channel");
409
+ if (channelExtract.error) {
410
+ console.error(`parachute init: ${channelExtract.error}`);
411
+ return 1;
412
+ }
413
+ if (
414
+ channelExtract.value !== undefined &&
415
+ channelExtract.value !== "rc" &&
416
+ channelExtract.value !== "latest"
417
+ ) {
418
+ console.error(
419
+ `parachute init: --channel must be "rc" or "latest" (got "${channelExtract.value}")`,
420
+ );
421
+ return 1;
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");
409
427
  const known = new Set([
410
428
  "--no-browser",
411
429
  "--no-expose-prompt",
412
430
  "--cli-wizard",
413
431
  "--browser-wizard",
414
432
  ]);
415
- const unknown = exposeExtract.rest.find((a) => !known.has(a));
433
+ const unknown = channelExtract.rest.find((a) => !known.has(a));
416
434
  if (unknown !== undefined) {
417
435
  console.error(`parachute init: unknown argument "${unknown}"`);
418
436
  console.error(
419
437
  "usage: parachute init [--no-browser] [--no-expose-prompt]\n" +
420
438
  " [--expose none|tailnet|cloudflare]\n" +
439
+ " [--channel rc|latest]\n" +
421
440
  " [--hub-origin <url>]\n" +
422
441
  " [--cli-wizard | --browser-wizard]",
423
442
  );
@@ -434,6 +453,9 @@ async function main(argv: string[]): Promise<number> {
434
453
  if (exposeExtract.value) {
435
454
  initOpts.exposeChoice = exposeExtract.value as "none" | "tailnet" | "cloudflare";
436
455
  }
456
+ if (channelExtract.value === "rc" || channelExtract.value === "latest") {
457
+ initOpts.channel = channelExtract.value;
458
+ }
437
459
  if (cliWizard) initOpts.wizardChoice = "cli";
438
460
  else if (browserWizard) initOpts.wizardChoice = "browser";
439
461
  const mod = await loadCommand("init", () => import("./commands/init.ts"));
@@ -154,14 +154,31 @@ export interface InitOpts {
154
154
  * stub to record the call without shelling out to `cloudflared`.
155
155
  */
156
156
  exposeCloudflareImpl?: () => Promise<number>;
157
+ /**
158
+ * Install channel for the vault module (hub#694 bug 2). `"rc"` makes init's
159
+ * vault install resolve `@openparachute/vault@rc` instead of `@latest`, so an
160
+ * rc-channel box (hub installed from `@rc`) doesn't DOWNGRADE vault below the
161
+ * hub on `parachute init`. Default `"latest"` (npm default; back-compat for
162
+ * every existing operator). Precedence in the CLI: `--channel <v>` flag >
163
+ * `PARACHUTE_CHANNEL` / `PARACHUTE_INSTALL_CHANNEL` env > `"latest"`. Threaded
164
+ * verbatim into `install()`'s existing `channel` plumbing
165
+ * (`resolveInstallChannel`).
166
+ */
167
+ channel?: "latest" | "rc";
157
168
  /**
158
169
  * Test seam: shim for the vault-module install step (hub#168 Cut 1).
159
170
  * Production calls `install("vault", { noCreate: true, noStart: true, …})`
160
171
  * to put `@openparachute/vault` on PATH without creating a first-vault
161
172
  * instance — the wizard's vault step decides Create/Import/Skip. Tests
162
- * pass a stub to record the call without shelling out.
173
+ * pass a stub to record the call without shelling out. The `channel` arg
174
+ * (hub#694) forwards into `install()`'s `channel` option so an rc box installs
175
+ * vault from `@rc`.
163
176
  */
164
- installVaultModuleImpl?: (configDir: string, manifestPath: string) => Promise<number>;
177
+ installVaultModuleImpl?: (
178
+ configDir: string,
179
+ manifestPath: string,
180
+ channel?: "latest" | "rc",
181
+ ) => Promise<number>;
165
182
  /**
166
183
  * Override the wizard-choice prompt (hub#168 Cut 4). When set, the
167
184
  * "Continue setup in the browser or CLI?" question is answered without
@@ -509,8 +526,18 @@ async function defaultGuaranteeOperatorToken(ctx: {
509
526
  * shim that re-emits each line under an `[install vault] ` prefix so the
510
527
  * init log stays grep-able. Idempotent — `install` short-circuits the
511
528
  * bun-add when vault is already linked / installed.
529
+ *
530
+ * The `channel` arg (hub#694) forwards into `install()`'s `channel` option so
531
+ * an rc-channel box installs `@openparachute/vault@rc` instead of `@latest` —
532
+ * otherwise init silently DOWNGRADES vault below the rc-tracking hub. Undefined
533
+ * → install's own resolution (`--tag` > `channel` > `PARACHUTE_INSTALL_CHANNEL`
534
+ * env > `"latest"`) applies, preserving today's behavior.
512
535
  */
513
- async function defaultInstallVaultModule(configDir: string, manifestPath: string): Promise<number> {
536
+ async function defaultInstallVaultModule(
537
+ configDir: string,
538
+ manifestPath: string,
539
+ channel?: "latest" | "rc",
540
+ ): Promise<number> {
514
541
  const installOpts: InstallOpts = {
515
542
  configDir,
516
543
  manifestPath,
@@ -518,6 +545,7 @@ async function defaultInstallVaultModule(configDir: string, manifestPath: string
518
545
  noStart: true,
519
546
  log: (line) => console.log(`[install vault] ${line}`),
520
547
  };
548
+ if (channel) installOpts.channel = channel;
521
549
  return await defaultInstall("vault", installOpts);
522
550
  }
523
551
 
@@ -637,6 +665,32 @@ async function promptExposeChoice(
637
665
  return defaultChoice;
638
666
  }
639
667
 
668
+ /**
669
+ * Resolve the install channel for init's vault module step (hub#694 bug 2).
670
+ *
671
+ * Precedence (highest → lowest):
672
+ * 1. explicit `--channel rc|latest` (parsed in cli.ts → `opts.channel`)
673
+ * 2. `PARACHUTE_CHANNEL` env (the DigitalOcean cloud-init script's var)
674
+ * 3. `PARACHUTE_INSTALL_CHANNEL` env (the install layer's own platform cascade)
675
+ * 4. `undefined` → `install()` falls back to its own "latest" default
676
+ *
677
+ * Returns `"latest"` / `"rc"` when one is resolved, or `undefined` to defer to
678
+ * `install()`'s resolution. A non-`rc`/`latest` env value is ignored (returns
679
+ * undefined) so a typo degrades to "latest" rather than crashing init — the
680
+ * same forgiving posture `resolveInstallChannel` takes for the install command.
681
+ */
682
+ export function resolveInitChannel(
683
+ explicit: "latest" | "rc" | undefined,
684
+ env: NodeJS.ProcessEnv,
685
+ ): "latest" | "rc" | undefined {
686
+ if (explicit === "rc" || explicit === "latest") return explicit;
687
+ for (const key of ["PARACHUTE_CHANNEL", "PARACHUTE_INSTALL_CHANNEL"]) {
688
+ const v = env[key];
689
+ if (v === "rc" || v === "latest") return v;
690
+ }
691
+ return undefined;
692
+ }
693
+
640
694
  export async function init(opts: InitOpts = {}): Promise<number> {
641
695
  const configDir = opts.configDir ?? CONFIG_DIR;
642
696
  const manifestPath = opts.manifestPath ?? SERVICES_MANIFEST_PATH;
@@ -668,6 +722,16 @@ export async function init(opts: InitOpts = {}): Promise<number> {
668
722
  const exposeTailnetImpl = opts.exposeTailnetImpl ?? defaultExposeTailnet;
669
723
  const exposeCloudflareImpl = opts.exposeCloudflareImpl ?? defaultExposeCloudflare;
670
724
  const installVaultModuleImpl = opts.installVaultModuleImpl ?? defaultInstallVaultModule;
725
+ // hub#694 bug 2: resolve the channel for the vault module install. Precedence:
726
+ // explicit `--channel <v>` (opts.channel) > `PARACHUTE_CHANNEL` /
727
+ // `PARACHUTE_INSTALL_CHANNEL` env > undefined (install's own "latest"
728
+ // fallback). The env fallback is what makes the DigitalOcean cloud-init
729
+ // script's `PARACHUTE_CHANNEL=rc` cascade into init's vault install with zero
730
+ // extra flags — init never received a `--channel` from that script, but it
731
+ // reads the env the script already exports. A garbage env value falls through
732
+ // to undefined → install resolves "latest" (matching resolveInstallChannel's
733
+ // own garbage-handling), so an operator typo can't break init.
734
+ const installChannel: "latest" | "rc" | undefined = resolveInitChannel(opts.channel, env);
671
735
  const runCliWizardImpl = opts.runCliWizardImpl ?? defaultRunCliWizard;
672
736
  const fetchBootstrapTokenImpl = opts.fetchBootstrapTokenImpl ?? defaultFetchBootstrapToken;
673
737
  const setHubOriginImpl = opts.setHubOriginImpl ?? defaultSetHubOrigin;
@@ -696,6 +760,48 @@ export async function init(opts: InitOpts = {}): Promise<number> {
696
760
  }
697
761
  }
698
762
 
763
+ // Step 0.5 (hub#694 bug 1 — the spawn race): install + seed the vault module
764
+ // into services.json BEFORE the hub unit starts (Step 1 below). The hub unit's
765
+ // `serve` runs the in-process Supervisor, which scans services.json EXACTLY
766
+ // ONCE at boot (`bootSupervisedModules`) and never rescans. If we seed vault
767
+ // AFTER the unit boots (the old Step 2.5 ordering), that single scan reads a
768
+ // services.json with no vault row → vault is registered-but-never-spawned, so
769
+ // `/vault/*` 502s until a manual `parachute restart` re-triggers a per-module
770
+ // start. On a slow box (1GB droplet) the scan reliably wins that race. Seeding
771
+ // first means the boot scan finds vault and spawns it on the first pass — no
772
+ // restart needed.
773
+ //
774
+ // `install("vault", { noCreate: true, noStart: true, … })` only does the
775
+ // on-disk work — `bun add -g` (idempotent; short-circuits when vault is
776
+ // bun-linked / already installed) + an `upsertService` seed write. It does NOT
777
+ // need a running hub: the start path, the stale-unit sweep, and the
778
+ // supervised-hub guidance probe are all gated off under `noCreate`/`noStart`,
779
+ // so running it before the unit exists is safe. The wizard's vault step still
780
+ // owns Create / Import / Skip (noCreate defers first-vault creation); the
781
+ // supervisor (not install.ts) owns spawning (noStart).
782
+ //
783
+ // Idempotent: if a vault row already exists (re-run, or a prior install), this
784
+ // short-circuits past the bun-add and the row is left intact. We don't block
785
+ // init on a non-zero exit — the wizard can retry from /admin/setup.
786
+ const findVaultEntry = (): boolean => {
787
+ try {
788
+ return findService("parachute-vault", manifestPath) !== undefined;
789
+ } catch {
790
+ return false;
791
+ }
792
+ };
793
+ const vaultAlreadyInstalled = findVaultEntry();
794
+ if (!vaultAlreadyInstalled) {
795
+ log("Installing the vault module so the wizard can offer create / import / skip…");
796
+ const installCode = await installVaultModuleImpl(configDir, manifestPath, installChannel);
797
+ if (installCode !== 0) {
798
+ log(
799
+ `⚠ vault module install returned ${installCode}; the wizard can retry from /admin/setup.`,
800
+ );
801
+ }
802
+ log("");
803
+ }
804
+
699
805
  // Step 1: hub running?
700
806
  // NB: under the Phase 3a unit-managed hub there is no pidfile, so
701
807
  // `processState(HUB_SVC)` reports not-running on EVERY init re-run even when
@@ -831,41 +937,14 @@ export async function init(opts: InitOpts = {}): Promise<number> {
831
937
  }
832
938
  }
833
939
 
834
- // Step 2.5: always install the vault module (hub#168 Cut 1). Aaron's
835
- // 2026-05-28 directive: "it should always install the vault module"
836
- // even though "creating a vault should be optional." We split the
837
- // module install (always) from the first-vault create (deferred to
838
- // the wizard) by passing `noCreate: true` to installbun add -g
839
- // runs, services.json gets seeded, but `parachute-vault init` (which
840
- // would auto-create a `default` vault) is skipped. The wizard's
841
- // vault step then either Creates / Imports / Skips.
842
- //
843
- // Idempotent: install short-circuits the bun-add when vault is
844
- // already linked (`bun link`) or already globally installed. If the
845
- // operator already has a vault row, this is a no-op past the
846
- // already-installed log line. We don't block init on this step;
847
- // a non-zero exit code is logged but treated as a warning, since the
848
- // wizard can re-attempt the install itself from /admin/setup.
849
- const findVaultEntry = (): boolean => {
850
- try {
851
- return findService("parachute-vault", manifestPath) !== undefined;
852
- } catch {
853
- return false;
854
- }
855
- };
856
- const vaultAlreadyInstalled = findVaultEntry();
857
- if (!vaultAlreadyInstalled) {
858
- log("");
859
- log("Installing the vault module so the wizard can offer create / import / skip…");
860
- const installCode = await installVaultModuleImpl(configDir, manifestPath);
861
- if (installCode !== 0) {
862
- log(
863
- `⚠ vault module install returned ${installCode}; the wizard can retry from /admin/setup.`,
864
- );
865
- }
866
- }
940
+ // (The vault module install + seed now runs at Step 0.5, BEFORE the hub unit
941
+ // starts see hub#694 bug 1. It used to live here, after exposure; moving it
942
+ // ahead of Step 1's hub bringup is what lets the supervisor's one-time boot
943
+ // scan find + spawn vault instead of registering it after the scan already
944
+ // ran. The "always install the vault module" directiveAaron 2026-05-28,
945
+ // hub#168 Cut 1 and the noCreate/noStart split are unchanged.)
867
946
 
868
- // Step 3: vault configured? (After the module install above, this may
947
+ // Step 3: vault configured? (After the Step 0.5 module install above, this may
869
948
  // have flipped from false to true on a fresh box. The wizard reads
870
949
  // services.json on every request, so the "configured" answer here is
871
950
  // best-effort — it only shapes the next-step log message below.)
package/src/help.ts CHANGED
@@ -132,6 +132,7 @@ export function initHelp(): string {
132
132
  Usage:
133
133
  parachute init [--no-browser] [--no-expose-prompt]
134
134
  [--expose none|tailnet|cloudflare]
135
+ [--channel rc|latest]
135
136
  [--hub-origin <url>]
136
137
  [--cli-wizard | --browser-wizard]
137
138
 
@@ -168,6 +169,10 @@ Flags:
168
169
  none — stay loopback-only
169
170
  tailnet — set up Tailscale serve (private to your tailnet)
170
171
  cloudflare — set up Cloudflare Tunnel (your own domain)
172
+ --channel <rc|latest> npm dist-tag for the vault module install (default: latest).
173
+ Use \`rc\` on an rc-channel box so init doesn't downgrade
174
+ vault below the hub. Also honors PARACHUTE_CHANNEL /
175
+ PARACHUTE_INSTALL_CHANNEL env when the flag is absent.
171
176
  --hub-origin <url> set the canonical public origin (OAuth issuer) BEFORE
172
177
  the hub + modules start, so vault/scribe come up
173
178
  accepting it in one pass. For reverse-proxy /
@@ -185,6 +190,7 @@ Examples:
185
190
  parachute init --expose tailnet # CI/scripted: chain straight into Tailscale
186
191
  parachute init --no-browser # don't shell out to open / xdg-open
187
192
  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
188
194
  `;
189
195
  }
190
196