@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21
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/README.md +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* tailscale/cloudflared.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
import { DEFAULT_CLOUDFLARED_HOME } from "../cloudflare/detect.ts";
|
|
31
|
+
import { DEFAULT_CLOUDFLARED_HOME, cloudflaredInstallHint } from "../cloudflare/detect.ts";
|
|
32
32
|
import {
|
|
33
33
|
type DetectProvidersOpts,
|
|
34
34
|
type ProviderAvailability,
|
|
@@ -109,9 +109,11 @@ function reportNeitherReady(r: Resolved, p: ProviderAvailability): number {
|
|
|
109
109
|
r.log("");
|
|
110
110
|
r.log(" Option B — Cloudflare Tunnel (your own domain, Cloudflare DNS):");
|
|
111
111
|
if (!p.cloudflare.available) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
// 2026-05-27 refresh: defer to the shared install-hint helper so the
|
|
113
|
+
// canonical install path stays in one place. Pre-refresh this surface
|
|
114
|
+
// hard-coded a developers.cloudflare.com URL that now serves HTML/404.
|
|
115
|
+
r.log(" 1. Install cloudflared:");
|
|
116
|
+
for (const line of cloudflaredInstallHint().split("\n")) r.log(` ${line}`);
|
|
115
117
|
r.log(" 2. Log in: cloudflared tunnel login");
|
|
116
118
|
r.log(" 3. Re-run with --domain: parachute expose public --cloudflare --domain <hostname>");
|
|
117
119
|
} else {
|
package/src/commands/expose.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { type ServiceEntry, readManifest } from "../services-manifest.ts";
|
|
|
24
24
|
import { type ServeEntry, bringupCommand, teardownCommand } from "../tailscale/commands.ts";
|
|
25
25
|
import { getFqdn, isTailscaleInstalled } from "../tailscale/detect.ts";
|
|
26
26
|
import { type Runner, defaultRunner } from "../tailscale/run.ts";
|
|
27
|
+
import { clearVaultHubOrigin } from "../vault-hub-origin-env.ts";
|
|
27
28
|
import type { VaultAuthStatus } from "../vault/auth-status.ts";
|
|
28
29
|
import {
|
|
29
30
|
WELL_KNOWN_DIR,
|
|
@@ -438,6 +439,13 @@ export async function exposeOff(layer: ExposeLayer, opts: ExposeOpts = {}): Prom
|
|
|
438
439
|
}
|
|
439
440
|
|
|
440
441
|
clearExposeState(statePath);
|
|
442
|
+
// Drop the persisted PARACHUTE_HUB_ORIGIN from vault's `.env`. `expose up`
|
|
443
|
+
// (via the vault restart) persisted the public origin so the launchd /
|
|
444
|
+
// systemd daemon validates `iss` against it. With exposure gone, a
|
|
445
|
+
// local-only hub mints loopback-`iss` tokens, so a stale public origin left
|
|
446
|
+
// in `.env` would itself cause the mismatch on the next daemon restart.
|
|
447
|
+
// Reverting to vault's loopback default (`getHubOrigin`) keeps them aligned.
|
|
448
|
+
clearVaultHubOrigin(configDir, log);
|
|
441
449
|
// Pair to the debug-only write at expose-up — clean up the inspection artifact
|
|
442
450
|
// on teardown so it doesn't outlive the layer it described.
|
|
443
451
|
if (existsSync(wellKnownFilePath)) {
|
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `parachute init` — fresh-install front door, single entry point for both
|
|
3
|
+
* laptops and remote servers (EC2, DigitalOcean, Hetzner, any VPS).
|
|
4
|
+
*
|
|
5
|
+
* Aaron's framing (2026-05-28): "orient local install more to leveraging the
|
|
6
|
+
* wizard if possible. Local install in this case should be extremely similar
|
|
7
|
+
* to ec2, except that perhaps we get parachute expose set up first."
|
|
8
|
+
*
|
|
9
|
+
* The job: get the user from a fresh install to the admin SPA setup
|
|
10
|
+
* wizard with one command, regardless of where the box lives. The wizard
|
|
11
|
+
* already handles vault install + scribe install + first-boot bootstrap
|
|
12
|
+
* (`/admin/setup`). `init`'s responsibility is narrower:
|
|
13
|
+
*
|
|
14
|
+
* 1. The hub binary is already on PATH (you can't `parachute init`
|
|
15
|
+
* without it). So "is hub installed" is always yes here.
|
|
16
|
+
* 2. Is the hub *running* on this box? If not, start it.
|
|
17
|
+
* 3. Is the hub already exposed (`expose-state.json` present)? If so,
|
|
18
|
+
* skip straight to printing the FQDN. Otherwise, in a TTY, ask
|
|
19
|
+
* whether the operator wants to expose it now — defaulting to
|
|
20
|
+
* "no, loopback" on a laptop and pre-selecting Cloudflare on a
|
|
21
|
+
* server (SSH session detected). Same command both paths.
|
|
22
|
+
* 4. After any exposure chain, re-resolve and print the canonical
|
|
23
|
+
* admin URL — local loopback if we're not exposed, the tailnet /
|
|
24
|
+
* cloudflare FQDN if we are.
|
|
25
|
+
* 5. Offer to open the URL in a browser (macOS: `open`, Linux:
|
|
26
|
+
* `xdg-open`). Skip in non-TTY shells.
|
|
27
|
+
* 6. If a vault is already configured, confirm "looks good" and
|
|
28
|
+
* point at the URL. The wizard surfaces install-state internally —
|
|
29
|
+
* no need to duplicate that logic here.
|
|
30
|
+
*
|
|
31
|
+
* Idempotent: every re-run is safe. If hub is up and exposed (or the user
|
|
32
|
+
* picked "no expose" once), the chain short-circuits.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { spawnSync } from "node:child_process";
|
|
36
|
+
import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
|
|
37
|
+
import { type ExposeState, readExposeState } from "../expose-state.ts";
|
|
38
|
+
import {
|
|
39
|
+
type EnsureHubOpts,
|
|
40
|
+
HUB_DEFAULT_PORT,
|
|
41
|
+
HUB_SVC,
|
|
42
|
+
ensureHubRunning,
|
|
43
|
+
readHubPort,
|
|
44
|
+
} from "../hub-control.ts";
|
|
45
|
+
import { type AliveFn, defaultAlive, processState } from "../process-state.ts";
|
|
46
|
+
import { findService, readManifestLenient } from "../services-manifest.ts";
|
|
47
|
+
import { type InstallOpts, install as defaultInstall } from "./install.ts";
|
|
48
|
+
|
|
49
|
+
/** The three options the exposure prompt offers — also the `--expose` flag's domain. */
|
|
50
|
+
export type ExposeChoice = "none" | "tailnet" | "cloudflare";
|
|
51
|
+
|
|
52
|
+
/** Where to continue setup after init finishes. CLI walks prompts in the terminal; browser opens /admin/setup. */
|
|
53
|
+
export type WizardChoice = "browser" | "cli";
|
|
54
|
+
|
|
55
|
+
export interface InitOpts {
|
|
56
|
+
configDir?: string;
|
|
57
|
+
manifestPath?: string;
|
|
58
|
+
log?: (line: string) => void;
|
|
59
|
+
/** Test seam: `processState` liveness check. */
|
|
60
|
+
alive?: AliveFn;
|
|
61
|
+
/**
|
|
62
|
+
* Test seam: `ensureHubRunning` shim. Production uses the real one;
|
|
63
|
+
* tests pass a stub that records calls without spawning.
|
|
64
|
+
*/
|
|
65
|
+
ensureHub?: (opts: EnsureHubOpts) => Promise<{ pid: number; port: number; started: boolean }>;
|
|
66
|
+
/** Test seam: expose-state reader. */
|
|
67
|
+
readExposeStateFn?: () => ExposeState | undefined;
|
|
68
|
+
/** Test seam: TTY check (production reads `process.stdin.isTTY`). */
|
|
69
|
+
isTty?: boolean;
|
|
70
|
+
/** Test seam: prompt for "open in browser?" and exposure choice. */
|
|
71
|
+
prompt?: (question: string) => Promise<string>;
|
|
72
|
+
/**
|
|
73
|
+
* Test seam: browser-open shim. Receives `url`; production shells out
|
|
74
|
+
* to `open` (darwin) / `xdg-open` (linux). Returns true on success.
|
|
75
|
+
*/
|
|
76
|
+
openBrowser?: (url: string) => boolean;
|
|
77
|
+
/** Test seam: `process.platform`. */
|
|
78
|
+
platform?: NodeJS.Platform;
|
|
79
|
+
/** Test seam: process.env for SSH / DISPLAY detection. */
|
|
80
|
+
env?: NodeJS.ProcessEnv;
|
|
81
|
+
/**
|
|
82
|
+
* If true, don't even ask about opening the browser. Convenient flag for
|
|
83
|
+
* CI / scripts that just want the URL printed and exit 0.
|
|
84
|
+
*/
|
|
85
|
+
noBrowser?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Non-interactive exposure choice. Skips the prompt entirely:
|
|
88
|
+
* - "none" — no-op (laptop default)
|
|
89
|
+
* - "tailnet" — chain into `exposeTailnet("up", {})`
|
|
90
|
+
* - "cloudflare" — chain into the cloudflare interactive flow
|
|
91
|
+
* For CI / scripted deploys.
|
|
92
|
+
*/
|
|
93
|
+
exposeChoice?: ExposeChoice;
|
|
94
|
+
/** Skip the exposure prompt; fall through to "here's localhost URL". */
|
|
95
|
+
noExposePrompt?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Test seam: shim for the tailnet exposure chain. Production imports
|
|
98
|
+
* `exposeTailnet` lazily. Tests pass a stub to record the call without
|
|
99
|
+
* shelling out to `tailscale serve`.
|
|
100
|
+
*/
|
|
101
|
+
exposeTailnetImpl?: () => Promise<number>;
|
|
102
|
+
/**
|
|
103
|
+
* Test seam: shim for the cloudflare exposure chain. Production imports
|
|
104
|
+
* `exposePublicInteractive` with `preselect: "cloudflare"`. Tests pass a
|
|
105
|
+
* stub to record the call without shelling out to `cloudflared`.
|
|
106
|
+
*/
|
|
107
|
+
exposeCloudflareImpl?: () => Promise<number>;
|
|
108
|
+
/**
|
|
109
|
+
* Test seam: shim for the vault-module install step (hub#168 Cut 1).
|
|
110
|
+
* Production calls `install("vault", { noCreate: true, noStart: true, …})`
|
|
111
|
+
* to put `@openparachute/vault` on PATH without creating a first-vault
|
|
112
|
+
* instance — the wizard's vault step decides Create/Import/Skip. Tests
|
|
113
|
+
* pass a stub to record the call without shelling out.
|
|
114
|
+
*/
|
|
115
|
+
installVaultModuleImpl?: (configDir: string, manifestPath: string) => Promise<number>;
|
|
116
|
+
/**
|
|
117
|
+
* Override the wizard-choice prompt (hub#168 Cut 4). When set, the
|
|
118
|
+
* "Continue setup in the browser or CLI?" question is answered without
|
|
119
|
+
* a prompt; otherwise default is `browser`. Non-interactive shells
|
|
120
|
+
* (`!isTty`) skip the prompt entirely and print the admin URL.
|
|
121
|
+
*/
|
|
122
|
+
wizardChoice?: WizardChoice;
|
|
123
|
+
/**
|
|
124
|
+
* Test seam: shim for the CLI wizard chain (hub#168 Cut 3). Production
|
|
125
|
+
* lazy-imports `runCliWizard` from `./wizard.ts`. Tests pass a stub.
|
|
126
|
+
*/
|
|
127
|
+
runCliWizardImpl?: (opts: { hubUrl: string; log: (l: string) => void }) => Promise<number>;
|
|
128
|
+
/**
|
|
129
|
+
* Skip the "browser or CLI?" wizard-choice prompt (hub#168 Cut 4). Used
|
|
130
|
+
* by pre-Cut-4 tests that don't expect the new prompt + by the
|
|
131
|
+
* `--no-browser` / explicit-`--cli-wizard` paths (where the answer is
|
|
132
|
+
* already known so there's no question to ask).
|
|
133
|
+
*/
|
|
134
|
+
noWizardPrompt?: boolean;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Compute the canonical admin URL. Prefers the live expose state (so a
|
|
139
|
+
* user with an exposed hub sees the public URL, not the loopback) and
|
|
140
|
+
* falls back to localhost when nothing is exposed.
|
|
141
|
+
*
|
|
142
|
+
* Returns `undefined` only when the hub port can't be determined and no
|
|
143
|
+
* exposure is active — caller treats that as "hub didn't start" and
|
|
144
|
+
* surfaces an actionable error.
|
|
145
|
+
*/
|
|
146
|
+
export function resolveAdminUrl(
|
|
147
|
+
exposeState: ExposeState | undefined,
|
|
148
|
+
hubPort: number | undefined,
|
|
149
|
+
): string | undefined {
|
|
150
|
+
if (exposeState?.canonicalFqdn) {
|
|
151
|
+
return `https://${exposeState.canonicalFqdn}/admin/`;
|
|
152
|
+
}
|
|
153
|
+
if (hubPort !== undefined) {
|
|
154
|
+
return `http://127.0.0.1:${hubPort}/admin/`;
|
|
155
|
+
}
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Heuristic: is this likely a server (vs. a laptop)?
|
|
161
|
+
*
|
|
162
|
+
* Servers default-highlight Cloudflare in the prompt; laptops default to
|
|
163
|
+
* "no expose". We don't auto-pick — always prompt — but pre-select the
|
|
164
|
+
* sensible default so an operator can confirm with Enter.
|
|
165
|
+
*
|
|
166
|
+
* Signals:
|
|
167
|
+
* - Linux platform AND ($SSH_CONNECTION set OR no $DISPLAY)
|
|
168
|
+
*
|
|
169
|
+
* macOS / Windows / Linux desktop → laptop.
|
|
170
|
+
*/
|
|
171
|
+
export function looksLikeServer(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean {
|
|
172
|
+
if (platform !== "linux") return false;
|
|
173
|
+
// WSL2 is Linux + headless from $DISPLAY's perspective but is in fact a
|
|
174
|
+
// developer's laptop. Detect via WSL-specific env vars (set in every WSL
|
|
175
|
+
// distro) so we don't pre-select Cloudflare for someone running Parachute
|
|
176
|
+
// inside WSL on Windows. Reviewer-flagged on #445.
|
|
177
|
+
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return false;
|
|
178
|
+
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return true;
|
|
179
|
+
if (!env.DISPLAY && !env.WAYLAND_DISPLAY) return true;
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Heuristic: would a browser-spawn fail because there's no display?
|
|
185
|
+
*
|
|
186
|
+
* A TTY guard alone is insufficient — an SSH session is a TTY with no display,
|
|
187
|
+
* so `xdg-open` fails (or blocks). We treat a box as display-less when:
|
|
188
|
+
* - it's a server per {@link looksLikeServer} (linux + SSH or no X/Wayland,
|
|
189
|
+
* excluding WSL which is a dev laptop), OR
|
|
190
|
+
* - it's linux with neither $DISPLAY nor $WAYLAND_DISPLAY (covers a local
|
|
191
|
+
* headless linux console that isn't over SSH).
|
|
192
|
+
*
|
|
193
|
+
* macOS / Windows always have a window server, so they're never display-less
|
|
194
|
+
* here (someone SSH'd into a Mac is a rare enough edge that we keep the happy
|
|
195
|
+
* path — `open` no-ops gracefully there anyway).
|
|
196
|
+
*/
|
|
197
|
+
export function hasNoDisplay(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean {
|
|
198
|
+
if (platform !== "linux") return false;
|
|
199
|
+
if (looksLikeServer(platform, env)) return true;
|
|
200
|
+
return !env.DISPLAY && !env.WAYLAND_DISPLAY;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Default browser-opener. Tries `open` on macOS, `xdg-open` on Linux, and
|
|
205
|
+
* returns false when neither is available (Windows / WSL fallthrough +
|
|
206
|
+
* misc Unixes ship `xdg-open` so coverage is decent without bringing in
|
|
207
|
+
* a dependency).
|
|
208
|
+
*/
|
|
209
|
+
function defaultOpenBrowser(url: string, platform: NodeJS.Platform): boolean {
|
|
210
|
+
const cmd = platform === "darwin" ? "open" : platform === "linux" ? "xdg-open" : undefined;
|
|
211
|
+
if (!cmd) return false;
|
|
212
|
+
// spawnSync's `stdio: "ignore"` keeps the launcher quiet; we'll log the
|
|
213
|
+
// outcome ourselves.
|
|
214
|
+
const result = spawnSync(cmd, [url], { stdio: "ignore" });
|
|
215
|
+
return result.status === 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function defaultPrompt(question: string): Promise<string> {
|
|
219
|
+
const { createInterface } = await import("node:readline/promises");
|
|
220
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
221
|
+
try {
|
|
222
|
+
return await rl.question(question);
|
|
223
|
+
} finally {
|
|
224
|
+
rl.close();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Default chain into Tailscale exposure. Lazy-imports so tests don't pull
|
|
230
|
+
* tailscale wiring into the init module's surface.
|
|
231
|
+
*/
|
|
232
|
+
async function defaultExposeTailnet(): Promise<number> {
|
|
233
|
+
const { exposeTailnet } = await import("./expose.ts");
|
|
234
|
+
return await exposeTailnet("up", {});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Default chain into Cloudflare exposure. Goes through the interactive
|
|
239
|
+
* flow with `preselect: "cloudflare"` so the operator gets walked
|
|
240
|
+
* through install / login / hostname-prompt as needed.
|
|
241
|
+
*/
|
|
242
|
+
async function defaultExposeCloudflare(): Promise<number> {
|
|
243
|
+
const { exposePublicInteractive } = await import("./expose-interactive.ts");
|
|
244
|
+
return await exposePublicInteractive({ preselect: "cloudflare" });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Default impl for the vault-module install step (hub#168 Cut 1). Calls
|
|
249
|
+
* install("vault", { noCreate: true, noStart: true, …}) with a quiet log
|
|
250
|
+
* shim that re-emits each line under an `[install vault] ` prefix so the
|
|
251
|
+
* init log stays grep-able. Idempotent — `install` short-circuits the
|
|
252
|
+
* bun-add when vault is already linked / installed.
|
|
253
|
+
*/
|
|
254
|
+
async function defaultInstallVaultModule(configDir: string, manifestPath: string): Promise<number> {
|
|
255
|
+
const installOpts: InstallOpts = {
|
|
256
|
+
configDir,
|
|
257
|
+
manifestPath,
|
|
258
|
+
noCreate: true,
|
|
259
|
+
noStart: true,
|
|
260
|
+
log: (line) => console.log(`[install vault] ${line}`),
|
|
261
|
+
};
|
|
262
|
+
return await defaultInstall("vault", installOpts);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Default impl for the CLI wizard chain (hub#168 Cut 3). Lazy-imports
|
|
267
|
+
* `runCliWizard` from `./wizard.ts`. Tests pass a stub via
|
|
268
|
+
* `runCliWizardImpl` rather than triggering the real HTTP-to-localhost
|
|
269
|
+
* flow.
|
|
270
|
+
*/
|
|
271
|
+
async function defaultRunCliWizard(opts: {
|
|
272
|
+
hubUrl: string;
|
|
273
|
+
log: (l: string) => void;
|
|
274
|
+
}): Promise<number> {
|
|
275
|
+
const { runCliWizard } = await import("./wizard.ts");
|
|
276
|
+
return await runCliWizard(opts);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Prompt for the wizard-choice question (hub#168 Cut 4). Returns the
|
|
281
|
+
* picked option, or `undefined` if the operator quit. Default is
|
|
282
|
+
* `browser` because (a) the browser flow is the canonical post-launch
|
|
283
|
+
* experience, and (b) it works without re-asking the operator about
|
|
284
|
+
* their password aloud on the terminal.
|
|
285
|
+
*/
|
|
286
|
+
async function promptWizardChoice(
|
|
287
|
+
prompt: (q: string) => Promise<string>,
|
|
288
|
+
log: (line: string) => void,
|
|
289
|
+
): Promise<WizardChoice | undefined> {
|
|
290
|
+
log("Continue setup here in the CLI, or in your browser?");
|
|
291
|
+
log(" 1) Browser (opens /admin/setup) (default)");
|
|
292
|
+
log(" 2) CLI (walks you through it in this terminal)");
|
|
293
|
+
log("");
|
|
294
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
295
|
+
const raw = (await prompt("Pick [1]: ")).trim().toLowerCase();
|
|
296
|
+
if (raw === "") return "browser";
|
|
297
|
+
if (raw === "1" || raw === "browser" || raw === "b") return "browser";
|
|
298
|
+
if (raw === "2" || raw === "cli" || raw === "c") return "cli";
|
|
299
|
+
if (raw === "q" || raw === "quit" || raw === "exit") return undefined;
|
|
300
|
+
log(`Sorry — expected 1, 2, or q (got "${raw}"). Try again.`);
|
|
301
|
+
}
|
|
302
|
+
log("Too many invalid entries; defaulting to browser.");
|
|
303
|
+
return "browser";
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Prompt for the exposure choice. Returns the picked option, or
|
|
308
|
+
* `undefined` if the operator quit / bailed.
|
|
309
|
+
*
|
|
310
|
+
* Default is whichever option matches the platform heuristic — laptops
|
|
311
|
+
* default to "none", servers to "cloudflare". Empty input picks the
|
|
312
|
+
* default (so Enter == confirm).
|
|
313
|
+
*/
|
|
314
|
+
async function promptExposeChoice(
|
|
315
|
+
prompt: (q: string) => Promise<string>,
|
|
316
|
+
log: (line: string) => void,
|
|
317
|
+
defaultChoice: ExposeChoice,
|
|
318
|
+
): Promise<ExposeChoice | undefined> {
|
|
319
|
+
log("Do you want to expose it publicly so you can reach it from other devices?");
|
|
320
|
+
const mark = (c: ExposeChoice) => (c === defaultChoice ? " (default)" : "");
|
|
321
|
+
log(` 1) No — keep it loopback-only${mark("none")}`);
|
|
322
|
+
log(` 2) Yes, private to your tailnet (Tailscale \`serve\`)${mark("tailnet")}`);
|
|
323
|
+
log(` 3) Yes via Cloudflare Tunnel (public HTTPS, your own domain)${mark("cloudflare")}`);
|
|
324
|
+
log("");
|
|
325
|
+
|
|
326
|
+
const defaultDigit = defaultChoice === "none" ? "1" : defaultChoice === "tailnet" ? "2" : "3";
|
|
327
|
+
|
|
328
|
+
// Bounded retries — a stuck prompt (non-TTY stdin that slipped through,
|
|
329
|
+
// piped /dev/null, etc.) shouldn't spin forever.
|
|
330
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
331
|
+
const raw = (await prompt(`Pick [${defaultDigit}]: `)).trim().toLowerCase();
|
|
332
|
+
if (raw === "") {
|
|
333
|
+
return defaultChoice;
|
|
334
|
+
}
|
|
335
|
+
if (raw === "1" || raw === "no" || raw === "none") return "none";
|
|
336
|
+
if (raw === "2" || raw === "tailnet" || raw === "tailscale") return "tailnet";
|
|
337
|
+
if (raw === "3" || raw === "cloudflare") return "cloudflare";
|
|
338
|
+
if (raw === "q" || raw === "quit" || raw === "exit") return undefined;
|
|
339
|
+
log(`Sorry — expected 1, 2, 3, or q (got "${raw}"). Try again.`);
|
|
340
|
+
}
|
|
341
|
+
log("Too many invalid entries; falling back to default.");
|
|
342
|
+
return defaultChoice;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export async function init(opts: InitOpts = {}): Promise<number> {
|
|
346
|
+
const configDir = opts.configDir ?? CONFIG_DIR;
|
|
347
|
+
const manifestPath = opts.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
348
|
+
const log = opts.log ?? ((line) => console.log(line));
|
|
349
|
+
const alive = opts.alive ?? defaultAlive;
|
|
350
|
+
const ensureHub = opts.ensureHub ?? ensureHubRunning;
|
|
351
|
+
const readExposeStateFn = opts.readExposeStateFn ?? (() => readExposeState());
|
|
352
|
+
const isTty = opts.isTty ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
353
|
+
const prompt = opts.prompt ?? defaultPrompt;
|
|
354
|
+
const platform = opts.platform ?? process.platform;
|
|
355
|
+
const env = opts.env ?? process.env;
|
|
356
|
+
const openBrowser = opts.openBrowser ?? ((url: string) => defaultOpenBrowser(url, platform));
|
|
357
|
+
const exposeTailnetImpl = opts.exposeTailnetImpl ?? defaultExposeTailnet;
|
|
358
|
+
const exposeCloudflareImpl = opts.exposeCloudflareImpl ?? defaultExposeCloudflare;
|
|
359
|
+
const installVaultModuleImpl = opts.installVaultModuleImpl ?? defaultInstallVaultModule;
|
|
360
|
+
const runCliWizardImpl = opts.runCliWizardImpl ?? defaultRunCliWizard;
|
|
361
|
+
|
|
362
|
+
log("Parachute init — getting your hub set up.");
|
|
363
|
+
log("");
|
|
364
|
+
|
|
365
|
+
// Step 1: hub running?
|
|
366
|
+
const hubState = processState(HUB_SVC, configDir, alive);
|
|
367
|
+
let hubPort: number | undefined;
|
|
368
|
+
if (hubState.status === "running") {
|
|
369
|
+
hubPort = readHubPort(configDir);
|
|
370
|
+
log(`✓ Hub already running (pid ${hubState.pid}${hubPort ? `, port ${hubPort}` : ""}).`);
|
|
371
|
+
} else {
|
|
372
|
+
log("Hub not running — starting it now…");
|
|
373
|
+
try {
|
|
374
|
+
const result = await ensureHub({ configDir, log: () => {} });
|
|
375
|
+
hubPort = result.port;
|
|
376
|
+
log(`✓ Hub started (pid ${result.pid}, port ${result.port}).`);
|
|
377
|
+
} catch (err) {
|
|
378
|
+
log(`✗ Hub failed to start: ${err instanceof Error ? err.message : String(err)}`);
|
|
379
|
+
log("");
|
|
380
|
+
log("Try checking the logs:");
|
|
381
|
+
log(" parachute logs hub");
|
|
382
|
+
return 1;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Fall back to the default canonical port if `readHubPort` returned
|
|
387
|
+
// undefined (which can happen if the hub was started by some prior tool
|
|
388
|
+
// that didn't write a port file). The hub binds 1939 unless explicitly
|
|
389
|
+
// overridden, so the fallback is almost always correct.
|
|
390
|
+
if (hubPort === undefined) hubPort = HUB_DEFAULT_PORT;
|
|
391
|
+
|
|
392
|
+
// Step 2: exposure chain. Skipped when already exposed, in non-TTY,
|
|
393
|
+
// or when --no-expose-prompt was passed. `--expose <choice>` jumps
|
|
394
|
+
// straight to the corresponding chain without asking.
|
|
395
|
+
let exposeState = readExposeStateFn();
|
|
396
|
+
const alreadyExposed = Boolean(exposeState?.canonicalFqdn);
|
|
397
|
+
|
|
398
|
+
if (alreadyExposed) {
|
|
399
|
+
// Already-exposed short-circuit: don't prompt. The admin URL printed
|
|
400
|
+
// later will be the FQDN.
|
|
401
|
+
log(`✓ Hub is already exposed at ${exposeState?.canonicalFqdn}.`);
|
|
402
|
+
} else if (opts.exposeChoice !== undefined) {
|
|
403
|
+
// Non-interactive override.
|
|
404
|
+
const code = await runExposureChoice(opts.exposeChoice, {
|
|
405
|
+
log,
|
|
406
|
+
exposeTailnetImpl,
|
|
407
|
+
exposeCloudflareImpl,
|
|
408
|
+
});
|
|
409
|
+
if (code !== 0) return code;
|
|
410
|
+
// Refresh state — the chain may have brought up an FQDN.
|
|
411
|
+
exposeState = readExposeStateFn();
|
|
412
|
+
} else if (opts.noExposePrompt) {
|
|
413
|
+
// Skip the question; fall through to localhost URL.
|
|
414
|
+
} else if (!isTty) {
|
|
415
|
+
// Non-TTY: don't prompt. Operator can re-run with --expose if needed.
|
|
416
|
+
} else {
|
|
417
|
+
log(`Hub is running locally at http://127.0.0.1:${hubPort}.`);
|
|
418
|
+
log("");
|
|
419
|
+
const isServer = looksLikeServer(platform, env);
|
|
420
|
+
const defaultChoice: ExposeChoice = isServer ? "cloudflare" : "none";
|
|
421
|
+
const picked = await promptExposeChoice(prompt, log, defaultChoice);
|
|
422
|
+
if (picked === undefined) {
|
|
423
|
+
log("");
|
|
424
|
+
log("Skipped exposure. Re-run `parachute expose public` later if you want to.");
|
|
425
|
+
} else if (picked !== "none") {
|
|
426
|
+
log("");
|
|
427
|
+
const code = await runExposureChoice(picked, {
|
|
428
|
+
log,
|
|
429
|
+
exposeTailnetImpl,
|
|
430
|
+
exposeCloudflareImpl,
|
|
431
|
+
});
|
|
432
|
+
if (code !== 0) return code;
|
|
433
|
+
exposeState = readExposeStateFn();
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Step 2.5: always install the vault module (hub#168 Cut 1). Aaron's
|
|
438
|
+
// 2026-05-28 directive: "it should always install the vault module"
|
|
439
|
+
// even though "creating a vault should be optional." We split the
|
|
440
|
+
// module install (always) from the first-vault create (deferred to
|
|
441
|
+
// the wizard) by passing `noCreate: true` to install — bun add -g
|
|
442
|
+
// runs, services.json gets seeded, but `parachute-vault init` (which
|
|
443
|
+
// would auto-create a `default` vault) is skipped. The wizard's
|
|
444
|
+
// vault step then either Creates / Imports / Skips.
|
|
445
|
+
//
|
|
446
|
+
// Idempotent: install short-circuits the bun-add when vault is
|
|
447
|
+
// already linked (`bun link`) or already globally installed. If the
|
|
448
|
+
// operator already has a vault row, this is a no-op past the
|
|
449
|
+
// already-installed log line. We don't block init on this step;
|
|
450
|
+
// a non-zero exit code is logged but treated as a warning, since the
|
|
451
|
+
// wizard can re-attempt the install itself from /admin/setup.
|
|
452
|
+
const findVaultEntry = (): boolean => {
|
|
453
|
+
try {
|
|
454
|
+
return findService("parachute-vault", manifestPath) !== undefined;
|
|
455
|
+
} catch {
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
const vaultAlreadyInstalled = findVaultEntry();
|
|
460
|
+
if (!vaultAlreadyInstalled) {
|
|
461
|
+
log("");
|
|
462
|
+
log("Installing the vault module so the wizard can offer create / import / skip…");
|
|
463
|
+
const installCode = await installVaultModuleImpl(configDir, manifestPath);
|
|
464
|
+
if (installCode !== 0) {
|
|
465
|
+
log(
|
|
466
|
+
`⚠ vault module install returned ${installCode}; the wizard can retry from /admin/setup.`,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Step 3: vault configured? (After the module install above, this may
|
|
472
|
+
// have flipped from false to true on a fresh box. The wizard reads
|
|
473
|
+
// services.json on every request, so the "configured" answer here is
|
|
474
|
+
// best-effort — it only shapes the next-step log message below.)
|
|
475
|
+
let hasVault = false;
|
|
476
|
+
try {
|
|
477
|
+
const manifest = readManifestLenient(manifestPath);
|
|
478
|
+
hasVault = manifest.services.some((s) => s.name.startsWith("parachute-vault"));
|
|
479
|
+
} catch {
|
|
480
|
+
// Lenient reader doesn't throw on most shapes, but be defensive — a
|
|
481
|
+
// malformed services.json shouldn't crash init. The wizard handles it.
|
|
482
|
+
hasVault = false;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Step 4: resolve the admin URL.
|
|
486
|
+
const adminUrl = resolveAdminUrl(exposeState, hubPort);
|
|
487
|
+
if (!adminUrl) {
|
|
488
|
+
log("");
|
|
489
|
+
log("✗ Couldn't resolve an admin URL (no hub port, no exposure state).");
|
|
490
|
+
log(" This shouldn't happen if hub started successfully — file an issue.");
|
|
491
|
+
return 1;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
log("");
|
|
495
|
+
if (hasVault) {
|
|
496
|
+
log("Looks good — your hub is up and a vault is configured.");
|
|
497
|
+
} else {
|
|
498
|
+
log("Next: finish setup in the admin wizard (installs vault, configures admin user).");
|
|
499
|
+
}
|
|
500
|
+
log("");
|
|
501
|
+
log(` ${adminUrl}`);
|
|
502
|
+
log("");
|
|
503
|
+
|
|
504
|
+
// Step 4.5: offer the operator the CLI wizard vs. the browser wizard
|
|
505
|
+
// (hub#168 Cut 4). Aaron's 2026-05-28 directive: "we should be able to
|
|
506
|
+
// move through a setup wizard on the command line or (and this is the
|
|
507
|
+
// default experience) run it through on the web." Browser remains the
|
|
508
|
+
// default — the in-terminal CLI walk is opt-in (`2)` at the prompt,
|
|
509
|
+
// `--cli-wizard` flag non-interactively).
|
|
510
|
+
//
|
|
511
|
+
// The CLI wizard chain only fires when:
|
|
512
|
+
// - explicit `wizardChoice === "cli"` (flag-driven), or
|
|
513
|
+
// - interactive TTY + the operator picked CLI at the prompt.
|
|
514
|
+
//
|
|
515
|
+
// In every other case (non-TTY, --no-browser, explicit
|
|
516
|
+
// `wizardChoice === "browser"`, or interactive default), we fall
|
|
517
|
+
// through to the existing browser-open flow below.
|
|
518
|
+
let choice: WizardChoice | undefined = opts.wizardChoice;
|
|
519
|
+
// `noWizardPrompt` (or pre-existing `noExposePrompt` for back-compat
|
|
520
|
+
// with the smaller pre-hub#168 test surface) suppresses the
|
|
521
|
+
// browser-or-CLI question. Tests written before Cut 4 don't expect a
|
|
522
|
+
// new prompt; without this flag they would see the wizard-choice
|
|
523
|
+
// prompt fire and timeout on a `'n'` answer (which means "no" to the
|
|
524
|
+
// historical Y/n browser-open confirm, not "exit" to the new prompt).
|
|
525
|
+
if (choice === undefined && isTty && !opts.noBrowser && !opts.noWizardPrompt) {
|
|
526
|
+
log("");
|
|
527
|
+
choice = await promptWizardChoice(prompt, log);
|
|
528
|
+
}
|
|
529
|
+
if (choice === "cli") {
|
|
530
|
+
log("");
|
|
531
|
+
log("Launching the CLI wizard. (You can also visit the URL above in a browser any time.)");
|
|
532
|
+
return await runCliWizardImpl({ hubUrl: adminUrl.replace(/\/admin\/?$/, ""), log });
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Step 5: offer to open the browser. Skip in non-TTY shells (CI),
|
|
536
|
+
// honor `--no-browser`.
|
|
537
|
+
if (opts.noBrowser) return 0;
|
|
538
|
+
if (!isTty) {
|
|
539
|
+
log("(Open the URL above in a browser to continue.)");
|
|
540
|
+
return 0;
|
|
541
|
+
}
|
|
542
|
+
if (platform !== "darwin" && platform !== "linux") {
|
|
543
|
+
log("(Open the URL above in your browser to continue.)");
|
|
544
|
+
return 0;
|
|
545
|
+
}
|
|
546
|
+
// Headless guard: a TTY isn't enough — an SSH session is a TTY but has no
|
|
547
|
+
// display, so `xdg-open` either fails noisily or (worse) blocks. Skip the
|
|
548
|
+
// spawn entirely on a server-shaped box (linux + no $DISPLAY/$WAYLAND_DISPLAY,
|
|
549
|
+
// or SSH) and just print the link. Aaron hit this on EC2: init tried to open
|
|
550
|
+
// a browser, failed with "Couldn't launch a browser," and (pre-Fix-1) showed
|
|
551
|
+
// the loopback URL. With Fix 1 the printed link is now the public Cloudflare
|
|
552
|
+
// URL. Keep spawning on a real desktop (macOS, Linux-with-display).
|
|
553
|
+
if (hasNoDisplay(platform, env)) {
|
|
554
|
+
log("(No display detected — open the URL above in a browser to continue.)");
|
|
555
|
+
return 0;
|
|
556
|
+
}
|
|
557
|
+
// `choice === "browser"` (either flag-driven or the operator picked
|
|
558
|
+
// browser at the prompt) goes straight to openBrowser — skip the
|
|
559
|
+
// back-compat "Open in your browser now?" Y/n confirm. If choice is
|
|
560
|
+
// undefined (no prompt, no flag), keep the historical Y/n confirm
|
|
561
|
+
// for back-compat with existing tests + scripted callers.
|
|
562
|
+
if (choice !== "browser") {
|
|
563
|
+
const answer = (await prompt("Open in your browser now? [Y/n] ")).trim().toLowerCase();
|
|
564
|
+
if (answer === "n" || answer === "no") return 0;
|
|
565
|
+
}
|
|
566
|
+
const ok = openBrowser(adminUrl);
|
|
567
|
+
if (!ok) {
|
|
568
|
+
log("");
|
|
569
|
+
log("(Couldn't launch a browser — open the URL above manually.)");
|
|
570
|
+
}
|
|
571
|
+
return 0;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Dispatch the chosen exposure path. Returns the exit code of the
|
|
576
|
+
* downstream chain. `none` is a no-op (success).
|
|
577
|
+
*/
|
|
578
|
+
async function runExposureChoice(
|
|
579
|
+
choice: ExposeChoice,
|
|
580
|
+
ctx: {
|
|
581
|
+
log: (line: string) => void;
|
|
582
|
+
exposeTailnetImpl: () => Promise<number>;
|
|
583
|
+
exposeCloudflareImpl: () => Promise<number>;
|
|
584
|
+
},
|
|
585
|
+
): Promise<number> {
|
|
586
|
+
if (choice === "none") return 0;
|
|
587
|
+
if (choice === "tailnet") {
|
|
588
|
+
ctx.log("Setting up private tailnet access (Tailscale `serve`)…");
|
|
589
|
+
return await ctx.exposeTailnetImpl();
|
|
590
|
+
}
|
|
591
|
+
// cloudflare
|
|
592
|
+
ctx.log("Setting up Cloudflare Tunnel…");
|
|
593
|
+
return await ctx.exposeCloudflareImpl();
|
|
594
|
+
}
|