@openparachute/hub 0.3.0-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.
Files changed (76) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +284 -0
  3. package/package.json +31 -0
  4. package/src/__tests__/auth.test.ts +101 -0
  5. package/src/__tests__/auto-wire.test.ts +283 -0
  6. package/src/__tests__/cli.test.ts +192 -0
  7. package/src/__tests__/cloudflare-config.test.ts +54 -0
  8. package/src/__tests__/cloudflare-detect.test.ts +68 -0
  9. package/src/__tests__/cloudflare-state.test.ts +92 -0
  10. package/src/__tests__/cloudflare-tunnel.test.ts +207 -0
  11. package/src/__tests__/config.test.ts +18 -0
  12. package/src/__tests__/env-file.test.ts +125 -0
  13. package/src/__tests__/expose-auth-preflight.test.ts +201 -0
  14. package/src/__tests__/expose-cloudflare.test.ts +484 -0
  15. package/src/__tests__/expose-interactive.test.ts +703 -0
  16. package/src/__tests__/expose-last-provider.test.ts +113 -0
  17. package/src/__tests__/expose-off-auto.test.ts +269 -0
  18. package/src/__tests__/expose-state.test.ts +101 -0
  19. package/src/__tests__/expose.test.ts +1581 -0
  20. package/src/__tests__/hub-control.test.ts +346 -0
  21. package/src/__tests__/hub-server.test.ts +157 -0
  22. package/src/__tests__/hub.test.ts +116 -0
  23. package/src/__tests__/install.test.ts +1145 -0
  24. package/src/__tests__/lifecycle.test.ts +608 -0
  25. package/src/__tests__/migrate.test.ts +422 -0
  26. package/src/__tests__/notes-serve.test.ts +135 -0
  27. package/src/__tests__/port-assign.test.ts +178 -0
  28. package/src/__tests__/process-state.test.ts +140 -0
  29. package/src/__tests__/scribe-config.test.ts +193 -0
  30. package/src/__tests__/scribe-provider-interactive.test.ts +361 -0
  31. package/src/__tests__/services-manifest.test.ts +177 -0
  32. package/src/__tests__/status.test.ts +347 -0
  33. package/src/__tests__/tailscale-commands.test.ts +111 -0
  34. package/src/__tests__/tailscale-detect.test.ts +64 -0
  35. package/src/__tests__/vault-auth-status.test.ts +164 -0
  36. package/src/__tests__/vault-tokens-create-interactive.test.ts +183 -0
  37. package/src/__tests__/well-known.test.ts +214 -0
  38. package/src/auto-wire.ts +184 -0
  39. package/src/cli.ts +482 -0
  40. package/src/cloudflare/config.ts +58 -0
  41. package/src/cloudflare/detect.ts +58 -0
  42. package/src/cloudflare/state.ts +96 -0
  43. package/src/cloudflare/tunnel.ts +135 -0
  44. package/src/commands/auth.ts +69 -0
  45. package/src/commands/expose-auth-preflight.ts +217 -0
  46. package/src/commands/expose-cloudflare.ts +329 -0
  47. package/src/commands/expose-interactive.ts +428 -0
  48. package/src/commands/expose-off-auto.ts +199 -0
  49. package/src/commands/expose.ts +522 -0
  50. package/src/commands/install.ts +422 -0
  51. package/src/commands/lifecycle.ts +324 -0
  52. package/src/commands/migrate.ts +253 -0
  53. package/src/commands/scribe-provider-interactive.ts +269 -0
  54. package/src/commands/status.ts +238 -0
  55. package/src/commands/vault-tokens-create-interactive.ts +137 -0
  56. package/src/commands/vault.ts +17 -0
  57. package/src/config.ts +16 -0
  58. package/src/env-file.ts +76 -0
  59. package/src/expose-last-provider.ts +71 -0
  60. package/src/expose-state.ts +125 -0
  61. package/src/help.ts +279 -0
  62. package/src/hub-control.ts +254 -0
  63. package/src/hub-origin.ts +44 -0
  64. package/src/hub-server.ts +113 -0
  65. package/src/hub.ts +674 -0
  66. package/src/notes-serve.ts +135 -0
  67. package/src/port-assign.ts +125 -0
  68. package/src/process-state.ts +111 -0
  69. package/src/scribe-config.ts +149 -0
  70. package/src/service-spec.ts +296 -0
  71. package/src/services-manifest.ts +171 -0
  72. package/src/tailscale/commands.ts +41 -0
  73. package/src/tailscale/detect.ts +107 -0
  74. package/src/tailscale/run.ts +28 -0
  75. package/src/vault/auth-status.ts +179 -0
  76. package/src/well-known.ts +127 -0
package/README.md ADDED
@@ -0,0 +1,284 @@
1
+ # Parachute Hub
2
+
3
+ `@openparachute/hub` — the local hub for the [Parachute](https://parachute.computer) ecosystem. The `parachute` binary is one of its surfaces.
4
+
5
+ The hub coordinates the modules running on your machine: it installs them, runs them as background processes, exposes them over Tailscale, serves the discovery document at `/.well-known/parachute.json`, and (soon) issues OAuth tokens. Each module (vault, notes, scribe, channel, …) stays a standalone package; the hub stitches them together.
6
+
7
+ > Previously published as `@openparachute/cli`. Renamed 2026-04-26 to better reflect the role — see [parachute-patterns/hub-as-issuer](https://github.com/ParachuteComputer/parachute-patterns/blob/main/patterns/hub-as-issuer.md). The `parachute` binary name is unchanged.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ bun add -g @openparachute/hub
13
+ ```
14
+
15
+ Prereqs: [Bun](https://bun.sh) 1.3.0 or later. `parachute expose` also requires [Tailscale](https://tailscale.com/download) **1.82 or newer** (installed + `tailscale up` run once); the `expose` path is under active polish for launch, so expect rough edges.
16
+
17
+ ## First 5 minutes
18
+
19
+ ```sh
20
+ # 1. Install the hub (one line — installs the `parachute` binary)
21
+ bun add -g @openparachute/hub
22
+
23
+ # 2. Install a service (runs `bun add -g @openparachute/vault` + `parachute-vault init`)
24
+ parachute install vault
25
+
26
+ # 3. Start the service in the background (PID + logs tracked under ~/.parachute/vault/)
27
+ parachute start vault
28
+
29
+ # 4. Check it landed — reads ~/.parachute/services.json, shows process state + probes health
30
+ parachute status
31
+ # SERVICE PORT VERSION PROCESS PID UPTIME HEALTH LATENCY
32
+ # parachute-vault 1940 0.2.4 running 12345 12s ok 2ms
33
+
34
+ # 5. Use it. Vault is up on 127.0.0.1:1940; Claude Code picked up the MCP
35
+ # on your next session. Point any other local MCP client (Codex, Goose,
36
+ # OpenCode, Cursor, Zed, Cline, your own agent) at:
37
+ # http://127.0.0.1:1940/vault/default/mcp
38
+
39
+ # 6. Expose beyond localhost — Tailscale Funnel or Cloudflare Tunnel.
40
+ # Polishing for broad launch, but live today for early testers:
41
+ parachute expose --help
42
+ ```
43
+
44
+ Tear down with `parachute expose tailnet off` or `parachute expose public off`. Layers are independent — `off` only affects the layer you name.
45
+
46
+ ## Service lifecycle
47
+
48
+ `parachute start`, `stop`, `restart`, and `logs` manage services as background processes — no launchd, no manual `bun serve`, no hunting for PIDs.
49
+
50
+ ```sh
51
+ parachute start # start every installed service
52
+ parachute start vault # just one
53
+ parachute stop # SIGTERM, then SIGKILL after 10s if stuck
54
+ parachute restart vault # stop + start
55
+ parachute logs vault # last 200 lines
56
+ parachute logs vault -f # tail (like `tail -f`)
57
+ ```
58
+
59
+ State lives under `~/.parachute/<service>/`:
60
+
61
+ - `run/<service>.pid` — child PID; `parachute status` uses this to report running/stopped + uptime
62
+ - `logs/<service>.log` — stdout + stderr (appended)
63
+
64
+ `parachute start` is idempotent: if the service is already running, it's a no-op. Stale PID files (process died without cleanup) are cleared on the next start. Services whose PID file is absent are treated as *unknown* — status still probes their port, so externally-managed services (e.g. you ran `parachute-vault serve` directly) aren't misreported as stopped.
65
+
66
+ ### Migrating from launchd (pre-launch beta)
67
+
68
+ If you previously ran vault under launchd, switch to `parachute start`:
69
+
70
+ ```sh
71
+ launchctl unload ~/Library/LaunchAgents/computer.parachute.vault.plist
72
+ rm ~/Library/LaunchAgents/computer.parachute.vault.plist
73
+ parachute start vault
74
+ ```
75
+
76
+ An at-login auto-start mode (`parachute start --boot`) is on the post-launch roadmap.
77
+
78
+ ### Migrating from pre-CLI installs
79
+
80
+ If you've been running Parachute services by hand for a while, `~/.parachute/` may contain files from before the per-service restructure — top-level `daily.db`, `server.yaml`, a stray `logs/` directory, and so on. `parachute install` will print a one-line notice when it sees anything like that; run `parachute migrate` to sweep them:
81
+
82
+ ```sh
83
+ parachute migrate --dry-run # see the plan
84
+ parachute migrate # interactive (prompts before moving)
85
+ parachute migrate --yes # unattended
86
+ ```
87
+
88
+ Anything swept goes to `~/.parachute/.archive-<YYYY-MM-DD>/` with its original name — nothing is deleted. Recognized entries (per-service dirs, `services.json`, `expose-state.json`, `well-known/`) are left in place, and so is anything starting with a dot (so `.env` and prior `.archive-*` dirs are safe).
89
+
90
+ ## Three layers of addressability
91
+
92
+ Each additive; each can be turned off without affecting the layer below.
93
+
94
+ - **Local** — services on loopback. Zero config. Browsers treat `localhost` as a secure context, so OAuth, PKCE, and Web Crypto all just work out of the box.
95
+ - **Tailnet** — `parachute expose tailnet` wraps `tailscale serve` for every registered service. HTTPS via Tailscale's MagicDNS cert. Only machines on your tailnet can reach the URL.
96
+ - **Public** — `parachute expose public` routes each handler through `tailscale funnel` so the same URLs become reachable from the public internet. At launch, Funnel is the only supported backend; Caddy + your-own-domain and cloudflared tunnels are planned post-launch.
97
+
98
+ Under the hood, tailnet mode uses `tailscale serve` and public mode uses `tailscale funnel`; both write into the same node-level serve config. The CLI records which layer is live so that `expose <other-layer> off` is a no-op rather than a surprise teardown of the active layer.
99
+
100
+ ## Path-routing (and why)
101
+
102
+ Every service mounts under a path on a single canonical hostname. The root `/` is a hub page that auto-discovers everything installed on this node:
103
+
104
+ ```
105
+ https://parachute.<tailnet>.ts.net/ → hub (service directory)
106
+ https://parachute.<tailnet>.ts.net/vault/default → parachute-vault API
107
+ https://parachute.<tailnet>.ts.net/lens → parachute-lens
108
+ https://parachute.<tailnet>.ts.net/scribe → parachute-scribe
109
+ https://parachute.<tailnet>.ts.net/.well-known/parachute.json ← discovery
110
+ ```
111
+
112
+ The hub page fetches the discovery doc at load, then each service's `/.parachute/info` endpoint for display name, tagline, and icon. Adding a new service is zero CLI code — drop in its manifest entry and the hub picks it up.
113
+
114
+ Under the hood, `/` and `/.well-known/parachute.json` are proxied by a tiny internal HTTP server (`parachute-hub`) that `parachute expose` spawns on the loopback interface. Tailscale's file-serve mode is sandbox-restricted on macOS, so a localhost proxy is the portable shape. The hub process is stopped automatically when the last exposure layer is torn down; `parachute status` lists it under `(internal)`.
115
+
116
+ The `/.well-known/parachute.json` document is an always-present descriptor — flat `services[]` array that the hub iterates, plus top-level keys for legacy clients:
117
+
118
+ ```json
119
+ {
120
+ "vaults": [
121
+ { "name": "default", "url": "https://parachute.taildf9ce2.ts.net/vault/default", "version": "0.2.4" }
122
+ ],
123
+ "services": [
124
+ {
125
+ "name": "parachute-vault",
126
+ "url": "https://parachute.taildf9ce2.ts.net/vault/default",
127
+ "path": "/vault/default",
128
+ "version": "0.2.4",
129
+ "infoUrl": "https://parachute.taildf9ce2.ts.net/vault/default/.parachute/info"
130
+ },
131
+ {
132
+ "name": "parachute-lens",
133
+ "url": "https://parachute.taildf9ce2.ts.net/lens",
134
+ "path": "/lens",
135
+ "version": "0.0.1",
136
+ "infoUrl": "https://parachute.taildf9ce2.ts.net/lens/.parachute/info"
137
+ }
138
+ ],
139
+ "lens": { "url": "https://parachute.taildf9ce2.ts.net/lens", "version": "0.0.1" }
140
+ }
141
+ ```
142
+
143
+ Why path-routing and not subdomain-per-service? Two reasons:
144
+
145
+ 1. **Tailscale Funnel HTTPS is capped at three ports per node** (443, 8443, 10000). Pinning every service to 443 behind a path means you can install any number of services without ever hitting that cap.
146
+ 2. **Subdomain-per-service requires the Tailscale Services feature** (virtual-IP advertisement per service), which is more than a MagicDNS wildcard — it needs admin-side setup that's out of scope for a one-command install. When it's a launch-grade path, we'll add `parachute expose tailnet --mode subdomain`.
147
+
148
+ Funnel has bandwidth quotas on Tailscale's free tier. See [tailscale.com/kb/1223/funnel](https://tailscale.com/kb/1223/funnel) for current limits; for heavy traffic, the post-launch Caddy / cloudflared modes will be the answer.
149
+
150
+ ## Ports
151
+
152
+ Parachute services reserve a block of loopback ports in the canonical range **1939–1949**. One range, one firewall rule, no surprises.
153
+
154
+ | Port | Service |
155
+ | ---- | ------------------ |
156
+ | 1939 | parachute-hub (internal proxy + static) |
157
+ | 1940 | parachute-vault |
158
+ | 1941 | parachute-channel |
159
+ | 1942 | parachute-notes |
160
+ | 1943 | parachute-scribe |
161
+ | 1944–1949 | *unassigned (CLI fallback range)* |
162
+
163
+ The hub pins 1939 — no fallback. If something else is on 1939 when you run `parachute expose`, the command fails with a pointer to `lsof -iTCP:1939` rather than walking up into another service's slot.
164
+
165
+ **The CLI is the port authority.** `parachute install <svc>` picks the port at install time and writes `PORT=<port>` into `~/.parachute/<svc>/.env`; lifecycle.start merges that .env into the spawn env so the next daemon boot binds the port the CLI assigned. The algorithm:
166
+
167
+ 1. Prefer the canonical slot (e.g. vault → 1940).
168
+ 2. On collision, walk the unassigned range (1944–1949).
169
+ 3. Range exhausted: assign past 1949 with a warning.
170
+
171
+ Idempotent: an existing `PORT=` in `~/.parachute/<svc>/.env` wins, so re-installs and operator-edited ports survive across upgrades. Services keep their compiled-in fallbacks (vault → 1940 etc.) so a stand-alone `bun run` still works without a CLI-managed .env.
172
+
173
+ `parachute expose` probes every service's port at bringup. A service that isn't responding still gets exposed, but you get a `⚠ parachute-<svc> (port …) is not responding` line so proxied requests never silently 502 without explanation.
174
+
175
+ ## How services register
176
+
177
+ Each Parachute service writes a manifest entry to `~/.parachute/services.json` on install. The CLI reads that manifest to drive `parachute status`, `parachute expose tailnet`, and `parachute expose public`.
178
+
179
+ ```json
180
+ {
181
+ "services": [
182
+ {
183
+ "name": "parachute-vault",
184
+ "port": 1940,
185
+ "paths": ["/vault/default"],
186
+ "health": "/vault/default/health",
187
+ "version": "0.2.4"
188
+ }
189
+ ]
190
+ }
191
+ ```
192
+
193
+ Optional `displayName` and `tagline` may be added to personalize the hub-page card; if absent, the hub falls back to the short name and the service's own `/.parachute/info` response.
194
+
195
+ The schema is a bit-for-bit contract shared between the CLI and every service. Services own their write side; the CLI owns the read + exposure side.
196
+
197
+ ### Claiming `/` — legacy manifests
198
+
199
+ Pre-hub services wrote `paths: ["/"]` (when there was only one service at `/`). On `parachute expose`, any such entry is remapped in-memory to `/<shortname>` with a one-line warning; re-running `parachute install <svc>` updates the on-disk manifest permanently. The hub always owns `/`.
200
+
201
+ If you want the CLI (and every service you install) to use a config directory other than `~/.parachute`, set `PARACHUTE_HOME`:
202
+
203
+ ```sh
204
+ export PARACHUTE_HOME=/some/other/path
205
+ ```
206
+
207
+ ## Already have parachute-vault installed?
208
+
209
+ Install the hub and `parachute vault ...` forwards to your existing `parachute-vault` binary:
210
+
211
+ ```sh
212
+ bun add -g @openparachute/hub
213
+ parachute vault init # dispatches to parachute-vault init
214
+ parachute vault --help # dispatches to parachute-vault --help
215
+ ```
216
+
217
+ Nothing about your existing vault moves or needs reconfiguring.
218
+
219
+ ## Smoke walkthrough (post-install)
220
+
221
+ Copy-paste to verify the whole chain. Everything here is idempotent.
222
+
223
+ ```sh
224
+ # Install
225
+ bun add -g @openparachute/hub
226
+
227
+ # Verify CLI
228
+ parachute --version
229
+ parachute --help
230
+
231
+ # Install a service
232
+ parachute install vault
233
+
234
+ # Manifest should now exist
235
+ cat ~/.parachute/services.json
236
+
237
+ # Start it in the background
238
+ parachute start vault
239
+
240
+ # Status should show vault as running + healthy
241
+ parachute status
242
+
243
+ # Peek at the service's logs
244
+ parachute logs vault
245
+
246
+ # Expose across your tailnet (requires tailscale + `tailscale up`)
247
+ parachute expose tailnet
248
+
249
+ # Open the URL printed above in a browser on any tailnet peer.
250
+ # Also confirm the discovery document:
251
+ curl -s https://parachute.<tailnet>.ts.net/.well-known/parachute.json | jq .
252
+
253
+ # Flip to public (Funnel)
254
+ parachute expose public
255
+ # Open the same URL in a browser NOT on your tailnet — phone on cell, say.
256
+
257
+ # Tear down
258
+ parachute expose public off
259
+ ```
260
+
261
+ ## Subcommand reference
262
+
263
+ Run `parachute --help` for the top-level list, and `parachute <subcommand> --help` for details on any individual command.
264
+
265
+ ```
266
+ parachute install <service> install and register a service
267
+ parachute status show installed services, process state, health
268
+ parachute start [service] start services in the background
269
+ parachute stop [service] stop services (SIGTERM → 10s → SIGKILL)
270
+ parachute restart [service] stop + start
271
+ parachute logs <service> [-f] print/tail service logs
272
+ parachute expose tailnet [off] HTTPS across your tailnet
273
+ parachute expose public [off] HTTPS on the public internet (Funnel)
274
+ parachute migrate [--dry-run] archive legacy files at ecosystem root
275
+ parachute vault <args...> dispatch to parachute-vault
276
+ ```
277
+
278
+ ## Status
279
+
280
+ Pre-alpha. API surface is stabilizing but not frozen.
281
+
282
+ ## License
283
+
284
+ AGPL-3.0 — same as the rest of the Parachute ecosystem.
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@openparachute/hub",
3
+ "version": "0.3.0-rc.1",
4
+ "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
+ "license": "AGPL-3.0",
6
+ "type": "module",
7
+ "module": "src/cli.ts",
8
+ "bin": {
9
+ "parachute": "src/cli.ts"
10
+ },
11
+ "files": ["src", "README.md", "LICENSE"],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/ParachuteComputer/parachute-hub.git"
15
+ },
16
+ "scripts": {
17
+ "start": "bun src/cli.ts",
18
+ "test": "bun test",
19
+ "lint": "biome check .",
20
+ "lint:fix": "biome check --write .",
21
+ "format": "biome format --write .",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "devDependencies": {
25
+ "@biomejs/biome": "^1.9.4",
26
+ "@types/bun": "latest"
27
+ },
28
+ "peerDependencies": {
29
+ "typescript": "^5"
30
+ }
31
+ }
@@ -0,0 +1,101 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { type Runner, auth, authHelp } from "../commands/auth.ts";
3
+
4
+ function makeRunner(result: number | (() => Promise<number>) = 0): {
5
+ runner: Runner;
6
+ calls: Array<readonly string[]>;
7
+ } {
8
+ const calls: Array<readonly string[]> = [];
9
+ const runner: Runner = {
10
+ async run(cmd) {
11
+ calls.push(cmd);
12
+ return typeof result === "function" ? await result() : result;
13
+ },
14
+ };
15
+ return { runner, calls };
16
+ }
17
+
18
+ describe("parachute auth", () => {
19
+ test("set-password forwards to parachute-vault set-password", async () => {
20
+ const { runner, calls } = makeRunner(0);
21
+ const code = await auth(["set-password"], runner);
22
+ expect(code).toBe(0);
23
+ expect(calls).toEqual([["parachute-vault", "set-password"]]);
24
+ });
25
+
26
+ test("set-password --clear forwards the flag", async () => {
27
+ const { runner, calls } = makeRunner(0);
28
+ const code = await auth(["set-password", "--clear"], runner);
29
+ expect(code).toBe(0);
30
+ expect(calls).toEqual([["parachute-vault", "set-password", "--clear"]]);
31
+ });
32
+
33
+ test("2fa enroll forwards to parachute-vault 2fa enroll", async () => {
34
+ const { runner, calls } = makeRunner(0);
35
+ const code = await auth(["2fa", "enroll"], runner);
36
+ expect(code).toBe(0);
37
+ expect(calls).toEqual([["parachute-vault", "2fa", "enroll"]]);
38
+ });
39
+
40
+ test("2fa enroll --some-flag forwards every arg after the subcommand", async () => {
41
+ const { runner, calls } = makeRunner(0);
42
+ const code = await auth(["2fa", "enroll", "--some-flag", "value"], runner);
43
+ expect(code).toBe(0);
44
+ expect(calls).toEqual([["parachute-vault", "2fa", "enroll", "--some-flag", "value"]]);
45
+ });
46
+
47
+ test("exit code from parachute-vault is propagated", async () => {
48
+ const { runner } = makeRunner(3);
49
+ const code = await auth(["2fa", "status"], runner);
50
+ expect(code).toBe(3);
51
+ });
52
+
53
+ test("ENOENT surfaces install hint and exit 127", async () => {
54
+ const runner: Runner = {
55
+ async run() {
56
+ throw new Error("ENOENT: spawn parachute-vault");
57
+ },
58
+ };
59
+ const code = await auth(["set-password"], runner);
60
+ expect(code).toBe(127);
61
+ });
62
+
63
+ test("bogus subcommand exits 1 without spawning vault", async () => {
64
+ const { runner, calls } = makeRunner(0);
65
+ const code = await auth(["whoami"], runner);
66
+ expect(code).toBe(1);
67
+ expect(calls).toEqual([]);
68
+ });
69
+
70
+ test("no args prints help and exits 0 without spawning vault", async () => {
71
+ const { runner, calls } = makeRunner(0);
72
+ const code = await auth([], runner);
73
+ expect(code).toBe(0);
74
+ expect(calls).toEqual([]);
75
+ });
76
+
77
+ test("--help and help both route to the same help surface", async () => {
78
+ const { runner, calls } = makeRunner(0);
79
+ expect(await auth(["--help"], runner)).toBe(0);
80
+ expect(await auth(["-h"], runner)).toBe(0);
81
+ expect(await auth(["help"], runner)).toBe(0);
82
+ expect(calls).toEqual([]);
83
+ });
84
+ });
85
+
86
+ describe("authHelp", () => {
87
+ const h = authHelp();
88
+
89
+ test("lists every blessed subcommand", () => {
90
+ expect(h).toContain("parachute auth set-password");
91
+ expect(h).toContain("--clear");
92
+ expect(h).toContain("parachute auth 2fa status");
93
+ expect(h).toContain("parachute auth 2fa enroll");
94
+ expect(h).toContain("parachute auth 2fa disable");
95
+ expect(h).toContain("parachute auth 2fa backup-codes");
96
+ });
97
+
98
+ test("mentions the vault-install hint", () => {
99
+ expect(h).toContain("parachute install vault");
100
+ });
101
+ });