@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
@@ -0,0 +1,76 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+
4
+ /**
5
+ * Minimal `.env` round-tripping for files we own.
6
+ *
7
+ * Used by:
8
+ * - auto-wire (writing SCRIBE_AUTH_TOKEN / SCRIBE_URL into vault .env)
9
+ * - scribe provider setup (writing GROQ_API_KEY etc. into scribe .env)
10
+ * - lifecycle.start (reading scribe .env values into the spawn env so the
11
+ * process actually sees the keys it was configured with)
12
+ *
13
+ * Scope intentionally narrow:
14
+ * - `KEY=value` lines (`=` is the first character, value is everything after)
15
+ * - one-level surrounding quotes are stripped on read (single or double)
16
+ * - everything else (comments, multiline, exports, escape sequences) is
17
+ * preserved as-is on round-trip but not parsed as values
18
+ */
19
+
20
+ export interface ParsedEnv {
21
+ /** Raw file lines preserved for round-trip writes (no trailing blank). */
22
+ lines: string[];
23
+ /** Parsed `KEY → value` pairs (quoted values returned unquoted). */
24
+ values: Record<string, string>;
25
+ }
26
+
27
+ export function parseEnvFileText(content: string): ParsedEnv {
28
+ const raw = content.length === 0 ? [] : content.split("\n");
29
+ // Drop a trailing empty string from a file that ends in "\n" so we don't
30
+ // double up newlines when we round-trip.
31
+ if (raw.length > 0 && raw[raw.length - 1] === "") raw.pop();
32
+ const values: Record<string, string> = {};
33
+ for (const line of raw) {
34
+ const eq = line.indexOf("=");
35
+ if (eq <= 0) continue;
36
+ const key = line.slice(0, eq);
37
+ let value = line.slice(eq + 1);
38
+ if (
39
+ value.length >= 2 &&
40
+ ((value.startsWith('"') && value.endsWith('"')) ||
41
+ (value.startsWith("'") && value.endsWith("'")))
42
+ ) {
43
+ value = value.slice(1, -1);
44
+ }
45
+ values[key] = value;
46
+ }
47
+ return { lines: raw, values };
48
+ }
49
+
50
+ export function parseEnvFile(path: string): ParsedEnv {
51
+ if (!existsSync(path)) return { lines: [], values: {} };
52
+ return parseEnvFileText(readFileSync(path, "utf8"));
53
+ }
54
+
55
+ export function readEnvFileValues(path: string): Record<string, string> {
56
+ return parseEnvFile(path).values;
57
+ }
58
+
59
+ export function upsertEnvLine(lines: string[], key: string, value: string): string[] {
60
+ const next = [...lines];
61
+ const prefix = `${key}=`;
62
+ const idx = next.findIndex((line) => line.startsWith(prefix));
63
+ if (idx >= 0) {
64
+ next[idx] = `${key}=${value}`;
65
+ } else {
66
+ next.push(`${key}=${value}`);
67
+ }
68
+ return next;
69
+ }
70
+
71
+ export function writeEnvFile(path: string, lines: readonly string[]): void {
72
+ mkdirSync(dirname(path), { recursive: true });
73
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
74
+ writeFileSync(tmp, `${lines.join("\n")}\n`);
75
+ renameSync(tmp, path);
76
+ }
@@ -0,0 +1,71 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ unlinkSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { CONFIG_DIR } from "./config.ts";
11
+
12
+ export const EXPOSE_LAST_PROVIDER_PATH = join(CONFIG_DIR, "expose-last-provider.json");
13
+
14
+ export type ExposeProvider = "tailscale" | "cloudflare";
15
+
16
+ export interface ExposeLastProvider {
17
+ version: 1;
18
+ provider: ExposeProvider;
19
+ /** ISO-8601 timestamp of the last selection — debugging only. */
20
+ writtenAt: string;
21
+ }
22
+
23
+ /**
24
+ * Persisted cross-invocation preference — remembers which provider the user
25
+ * picked last in the interactive flow so we can default to it next time.
26
+ *
27
+ * Unlike the live state files (`expose-state.json`, `cloudflared-state.json`)
28
+ * this is just a preference hint, so missing or corrupt content is not fatal:
29
+ * we return `undefined` and the caller falls back to its default. A stale file
30
+ * is never load-bearing — the worst case is a one-keystroke re-pick.
31
+ */
32
+ export function readLastProvider(
33
+ path: string = EXPOSE_LAST_PROVIDER_PATH,
34
+ ): ExposeLastProvider | undefined {
35
+ if (!existsSync(path)) return undefined;
36
+ let raw: unknown;
37
+ try {
38
+ raw = JSON.parse(readFileSync(path, "utf8"));
39
+ } catch {
40
+ return undefined;
41
+ }
42
+ if (!raw || typeof raw !== "object") return undefined;
43
+ const r = raw as Record<string, unknown>;
44
+ if (r.version !== 1) return undefined;
45
+ if (r.provider !== "tailscale" && r.provider !== "cloudflare") return undefined;
46
+ if (typeof r.writtenAt !== "string" || r.writtenAt.length === 0) return undefined;
47
+ return { version: 1, provider: r.provider, writtenAt: r.writtenAt };
48
+ }
49
+
50
+ export function writeLastProvider(
51
+ provider: ExposeProvider,
52
+ opts: { path?: string; now?: () => Date } = {},
53
+ ): void {
54
+ const path = opts.path ?? EXPOSE_LAST_PROVIDER_PATH;
55
+ const now = opts.now ?? (() => new Date());
56
+ if (!existsSync(dirname(path))) {
57
+ mkdirSync(dirname(path), { recursive: true });
58
+ }
59
+ const record: ExposeLastProvider = {
60
+ version: 1,
61
+ provider,
62
+ writtenAt: now().toISOString(),
63
+ };
64
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
65
+ writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`);
66
+ renameSync(tmp, path);
67
+ }
68
+
69
+ export function clearLastProvider(path: string = EXPOSE_LAST_PROVIDER_PATH): void {
70
+ if (existsSync(path)) unlinkSync(path);
71
+ }
@@ -0,0 +1,125 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ unlinkSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { CONFIG_DIR } from "./config.ts";
11
+ import type { ServeEntry } from "./tailscale/commands.ts";
12
+
13
+ export const EXPOSE_STATE_PATH = join(CONFIG_DIR, "expose-state.json");
14
+
15
+ export type ExposeMode = "path" | "subdomain";
16
+ export type ExposeLayer = "tailnet" | "public";
17
+
18
+ export interface ExposeState {
19
+ version: 1;
20
+ layer: ExposeLayer;
21
+ mode: ExposeMode;
22
+ canonicalFqdn: string;
23
+ port: number;
24
+ funnel: boolean;
25
+ entries: ServeEntry[];
26
+ /**
27
+ * Hub origin emitted when this exposure was brought up — the URL OAuth
28
+ * clients will see as the issuer. `parachute start vault` reads it to set
29
+ * PARACHUTE_HUB_ORIGIN so vault's OAuth metadata matches reality. Optional
30
+ * for pre-Phase-0 state files; writers always populate it.
31
+ */
32
+ hubOrigin?: string;
33
+ }
34
+
35
+ export class ExposeStateError extends Error {
36
+ override name = "ExposeStateError";
37
+ }
38
+
39
+ function validate(raw: unknown, path: string): ExposeState {
40
+ if (!raw || typeof raw !== "object") {
41
+ throw new ExposeStateError(`${path}: root must be an object`);
42
+ }
43
+ const r = raw as Record<string, unknown>;
44
+ if (r.version !== 1) {
45
+ throw new ExposeStateError(`${path}: unsupported version ${String(r.version)}`);
46
+ }
47
+ if (r.layer !== "tailnet" && r.layer !== "public") {
48
+ throw new ExposeStateError(`${path}: layer must be "tailnet" or "public"`);
49
+ }
50
+ if (r.mode !== "path" && r.mode !== "subdomain") {
51
+ throw new ExposeStateError(`${path}: mode must be "path" or "subdomain"`);
52
+ }
53
+ if (typeof r.canonicalFqdn !== "string" || r.canonicalFqdn.length === 0) {
54
+ throw new ExposeStateError(`${path}: canonicalFqdn must be a non-empty string`);
55
+ }
56
+ if (typeof r.port !== "number" || !Number.isInteger(r.port)) {
57
+ throw new ExposeStateError(`${path}: port must be an integer`);
58
+ }
59
+ if (typeof r.funnel !== "boolean") {
60
+ throw new ExposeStateError(`${path}: funnel must be a boolean`);
61
+ }
62
+ if (!Array.isArray(r.entries)) {
63
+ throw new ExposeStateError(`${path}: entries must be an array`);
64
+ }
65
+ if (r.hubOrigin !== undefined && typeof r.hubOrigin !== "string") {
66
+ throw new ExposeStateError(`${path}: hubOrigin must be a string if present`);
67
+ }
68
+ const entries: ServeEntry[] = r.entries.map((e, i) => {
69
+ if (!e || typeof e !== "object") {
70
+ throw new ExposeStateError(`${path} entries[${i}]: expected object`);
71
+ }
72
+ const entry = e as Record<string, unknown>;
73
+ const kind = entry.kind;
74
+ if (kind !== "proxy" && kind !== "file") {
75
+ throw new ExposeStateError(`${path} entries[${i}]: kind must be "proxy" or "file"`);
76
+ }
77
+ if (typeof entry.mount !== "string" || !entry.mount.startsWith("/")) {
78
+ throw new ExposeStateError(`${path} entries[${i}]: mount must start with "/"`);
79
+ }
80
+ if (typeof entry.target !== "string" || entry.target.length === 0) {
81
+ throw new ExposeStateError(`${path} entries[${i}]: target must be non-empty string`);
82
+ }
83
+ if (typeof entry.service !== "string" || entry.service.length === 0) {
84
+ throw new ExposeStateError(`${path} entries[${i}]: service must be non-empty string`);
85
+ }
86
+ return { kind, mount: entry.mount, target: entry.target, service: entry.service };
87
+ });
88
+ const state: ExposeState = {
89
+ version: 1,
90
+ layer: r.layer,
91
+ mode: r.mode,
92
+ canonicalFqdn: r.canonicalFqdn,
93
+ port: r.port,
94
+ funnel: r.funnel,
95
+ entries,
96
+ };
97
+ if (typeof r.hubOrigin === "string") state.hubOrigin = r.hubOrigin;
98
+ return state;
99
+ }
100
+
101
+ export function readExposeState(path: string = EXPOSE_STATE_PATH): ExposeState | undefined {
102
+ if (!existsSync(path)) return undefined;
103
+ let raw: unknown;
104
+ try {
105
+ raw = JSON.parse(readFileSync(path, "utf8"));
106
+ } catch (err) {
107
+ throw new ExposeStateError(
108
+ `failed to parse ${path}: ${err instanceof Error ? err.message : String(err)}`,
109
+ );
110
+ }
111
+ return validate(raw, path);
112
+ }
113
+
114
+ export function writeExposeState(state: ExposeState, path: string = EXPOSE_STATE_PATH): void {
115
+ if (!existsSync(dirname(path))) {
116
+ mkdirSync(dirname(path), { recursive: true });
117
+ }
118
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
119
+ writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
120
+ renameSync(tmp, path);
121
+ }
122
+
123
+ export function clearExposeState(path: string = EXPOSE_STATE_PATH): void {
124
+ if (existsSync(path)) unlinkSync(path);
125
+ }
package/src/help.ts ADDED
@@ -0,0 +1,279 @@
1
+ import pkg from "../package.json" with { type: "json" };
2
+ import { knownServices } from "./service-spec.ts";
3
+
4
+ export function topLevelHelp(): string {
5
+ const services = knownServices().join(" | ");
6
+ return `parachute ${pkg.version} — top-level CLI for the Parachute ecosystem
7
+
8
+ Usage:
9
+ parachute install <service> install and register a service
10
+ services: ${services}
11
+ parachute status show installed services, process state, health
12
+ parachute start [service] start all services (or one) in the background
13
+ parachute stop [service] stop all services (or one) — SIGTERM then SIGKILL
14
+ parachute restart [service] stop + start
15
+ parachute logs <service> [-f] print service logs; -f to tail
16
+ parachute expose tailnet [off] HTTPS across your tailnet
17
+ parachute expose public [off] HTTPS on the public internet (Funnel)
18
+ parachute migrate [--dry-run] archive legacy files at ecosystem root
19
+ parachute auth <cmd> identity (set password, manage 2FA)
20
+ parachute vault <args...> vault-specific ops (tokens, 2fa, config, init,
21
+ etc.) — forwards to parachute-vault.
22
+ For lifecycle, use \`parachute start|stop|restart|logs vault\`.
23
+
24
+ Flags:
25
+ --help, -h show this help (also per-subcommand: \`parachute <cmd> --help\`)
26
+ --version, -v print version
27
+ `;
28
+ }
29
+
30
+ export function installHelp(): string {
31
+ return `parachute install — install and register a Parachute service
32
+
33
+ Usage:
34
+ parachute install <service> [--tag <name>] [--no-start]
35
+ parachute install all [--tag <name>] [--no-start]
36
+ parachute install scribe [--scribe-provider <name>] [--scribe-key <key>]
37
+
38
+ Services:
39
+ ${knownServices().join(", ")}
40
+ all install every known service in turn
41
+
42
+ What it does:
43
+ 1. bun add -g @openparachute/<service>[@<tag>]
44
+ 2. run any service-specific init (e.g. \`parachute-vault init\`)
45
+ 3. assign a canonical port (1939–1949) and write \`PORT=<port>\` into
46
+ \`~/.parachute/<service>/.env\`. Idempotent — an existing PORT wins, so
47
+ re-installs and operator-edited ports survive across upgrades.
48
+ 4. verify the service registered itself in ~/.parachute/services.json
49
+ 5. for scribe in a TTY: prompt for transcription provider + API key
50
+ (or take \`--scribe-provider\` / \`--scribe-key\`)
51
+ 6. start the service in the background (idempotent — no-op if already up)
52
+
53
+ Flags:
54
+ --tag <name> npm dist-tag or exact version to install
55
+ (e.g. \`--tag rc\` → \`bun add -g @openparachute/vault@rc\`)
56
+ Skipped if the package is already \`bun link\`-ed locally.
57
+ --no-start skip the post-install daemon start. For piped / CI
58
+ installs that own their own process model.
59
+ --scribe-provider <name> set scribe's transcription provider non-interactively.
60
+ Known: parakeet-mlx (default), onnx-asr, whisper, groq, openai.
61
+ Skips the interactive picker.
62
+ --scribe-key <key> set the API key for the chosen provider non-interactively.
63
+ Stored in ~/.parachute/scribe/.env. Only meaningful for
64
+ cloud providers (groq → GROQ_API_KEY, openai → OPENAI_API_KEY).
65
+
66
+ Examples:
67
+ parachute install vault # installs, runs init, starts vault
68
+ parachute install notes # installs and starts notes
69
+ parachute install scribe # installs, prompts for provider, starts scribe
70
+ parachute install scribe --scribe-provider groq --scribe-key gsk_…
71
+ # non-interactive scribe setup
72
+ parachute install vault --tag rc # pin to rc dist-tag
73
+ parachute install all --tag rc # bootstrap whole ecosystem to rc
74
+ parachute install vault --no-start # install without auto-starting (CI)
75
+
76
+ Aliases:
77
+ lens → notes # accepted for one release cycle after
78
+ # the brief Lens rebrand was reverted on
79
+ # 2026-04-22; prints a rename notice.
80
+ `;
81
+ }
82
+
83
+ export function statusHelp(): string {
84
+ return `parachute status — show installed services, process state, and health
85
+
86
+ Usage:
87
+ parachute status
88
+
89
+ What it does:
90
+ Reads ~/.parachute/services.json. For each registered service:
91
+ - checks PID file at ~/.parachute/<svc>/run/<svc>.pid → running/stopped
92
+ - probes http://localhost:<port><health> (skipped for known-stopped processes)
93
+
94
+ Stopped services show "-" for health and don't count toward the exit
95
+ code — they're an expected state after fresh install before \`parachute
96
+ start\`. Running or externally-managed services that fail health checks
97
+ do exit 1.
98
+
99
+ Exit codes:
100
+ 0 all probed services healthy (or none running)
101
+ 1 one or more probed services unhealthy
102
+
103
+ Example:
104
+ $ parachute status
105
+ SERVICE PORT VERSION PROCESS PID UPTIME HEALTH LATENCY
106
+ parachute-vault 1940 0.2.4 running 12345 2h 13m ok 2ms
107
+ → http://127.0.0.1:1940/vault/default/mcp
108
+ parachute-notes 1942 0.0.1 stopped - - - -
109
+ → http://127.0.0.1:1942/notes
110
+ `;
111
+ }
112
+
113
+ export function exposeHelp(): string {
114
+ return `parachute expose — route your services behind HTTPS on a network layer
115
+
116
+ Usage:
117
+ parachute expose tailnet [off]
118
+ parachute expose public [off]
119
+ parachute expose public --cloudflare --domain <hostname>
120
+ parachute expose public off --cloudflare
121
+
122
+ Interactive:
123
+ Run in a terminal with no flags, \`parachute expose public\` walks you
124
+ through provider selection (Tailscale Funnel vs. Cloudflare Tunnel),
125
+ offers to install missing dependencies on macOS, and prompts for the
126
+ Cloudflare hostname when needed. Piped / non-TTY invocations keep the
127
+ scripted behavior: default to Tailscale, flags override.
128
+
129
+ Layers:
130
+ tailnet HTTPS across your tailnet (tailscale serve)
131
+ public HTTPS on the public internet
132
+ - default: Tailscale Funnel (no domain needed, *.ts.net URL)
133
+ - --cloudflare + --domain: named Cloudflare tunnel on your own
134
+ domain (stable URL, free, no bandwidth caps)
135
+
136
+ Tailscale and Cloudflare modes share no state. Either can be up without the
137
+ other. Inside each mode, switching on/off is idempotent.
138
+
139
+ Flags:
140
+ --hub-origin <url> override the OAuth issuer URL advertised to clients
141
+ (default: https://<fqdn> when exposed, else http://127.0.0.1:<hub-port>)
142
+ --cloudflare use a named Cloudflare tunnel for the public layer
143
+ (requires --domain)
144
+ --domain <hostname> fully-qualified hostname to route through the tunnel
145
+ (e.g. vault.example.com). The apex must be a zone on
146
+ your Cloudflare account.
147
+
148
+ Examples:
149
+ parachute expose tailnet # tailnet HTTPS
150
+ parachute expose public # Funnel: *.ts.net URL
151
+ parachute expose public off # stop the Funnel
152
+ parachute expose public --cloudflare --domain vault.example.com
153
+ # stable URL via cloudflared
154
+ parachute expose public off --cloudflare # stop the cloudflared tunnel
155
+
156
+ Tailscale Funnel constraints:
157
+ - HTTPS only on ports 443 / 8443 / 10000 per node. We pin to 443 and
158
+ path-route (vault at /vault/…, notes at /notes, …) so this cap never
159
+ becomes a constraint no matter how many services you install.
160
+ - Bandwidth caps on Tailscale's free tier — see https://tailscale.com/kb/1223/funnel.
161
+ - Subdomain-per-service needs the Tailscale Services feature (not yet).
162
+
163
+ Cloudflare tunnel requirements (--cloudflare):
164
+ - \`cloudflared\` installed (macOS: \`brew install cloudflared\`).
165
+ - \`cloudflared tunnel login\` run once (browser flow) — drops a cert at
166
+ ~/.cloudflared/cert.pem.
167
+ - Apex of --domain is a Cloudflare zone on that account. Add at
168
+ https://dash.cloudflare.com → Add site (any registrar works).
169
+ - Only vault is currently routed. Multi-service ingress via Cloudflare is
170
+ deferred; use Funnel if you need hub + vault + notes on one exposure.
171
+ `;
172
+ }
173
+
174
+ export function startHelp(): string {
175
+ return `parachute start — spawn services in the background
176
+
177
+ Usage:
178
+ parachute start start every installed service
179
+ parachute start <service> start just that one
180
+
181
+ What it does:
182
+ For each target service, spawns its start command detached, redirects
183
+ stdout+stderr to ~/.parachute/<service>/logs/<service>.log, and records
184
+ the child PID at ~/.parachute/<service>/run/<service>.pid.
185
+
186
+ Idempotent: if the service is already running, no-op.
187
+ If a stale PID file exists (process died without cleanup), it's cleared
188
+ and the service starts fresh.
189
+
190
+ Flags:
191
+ --hub-origin <url> override PARACHUTE_HUB_ORIGIN passed to services
192
+ (default: current expose-state hub origin, else loopback)
193
+
194
+ Examples:
195
+ parachute start bring everything up
196
+ parachute start vault just vault
197
+ parachute logs vault watch what just started
198
+
199
+ Start commands by service:
200
+ vault parachute-vault serve
201
+ scribe parachute-scribe serve
202
+ channel parachute-channel daemon
203
+ notes bun <cli>/notes-serve.ts --port <configured> --mount <paths[0]>
204
+ `;
205
+ }
206
+
207
+ export function stopHelp(): string {
208
+ return `parachute stop — stop running services cleanly
209
+
210
+ Usage:
211
+ parachute stop stop every installed service
212
+ parachute stop <service> stop just that one
213
+
214
+ What it does:
215
+ Sends SIGTERM, waits up to 10s for a clean exit, then escalates to
216
+ SIGKILL if the process is still alive. Removes the PID file on success.
217
+
218
+ No-op if the service wasn't running.
219
+
220
+ Examples:
221
+ parachute stop stop everything before sleep
222
+ parachute stop vault just vault
223
+ `;
224
+ }
225
+
226
+ export function restartHelp(): string {
227
+ return `parachute restart — stop then start
228
+
229
+ Usage:
230
+ parachute restart restart every installed service
231
+ parachute restart <service> restart just that one
232
+
233
+ What it does:
234
+ Equivalent to \`parachute stop <svc> && parachute start <svc>\`.
235
+ `;
236
+ }
237
+
238
+ export function logsHelp(): string {
239
+ return `parachute logs — print service logs
240
+
241
+ Usage:
242
+ parachute logs <service> print the last 200 lines
243
+ parachute logs <service> -f tail the log (like \`tail -f\`)
244
+
245
+ Log file:
246
+ ~/.parachute/<service>/logs/<service>.log
247
+
248
+ If no log file exists yet, prints a hint to \`parachute start <service>\`.
249
+ `;
250
+ }
251
+
252
+ export function migrateHelp(): string {
253
+ return `parachute migrate — archive legacy files at the ecosystem root
254
+
255
+ Usage:
256
+ parachute migrate [--dry-run] [--yes]
257
+
258
+ What it does:
259
+ Scans ~/.parachute/ for files and directories that don't belong to the
260
+ post-restructure layout. Recognized entries — per-service dirs
261
+ (vault/, notes/, scribe/, channel/, hub/; legacy lens/ also kept),
262
+ services.json,
263
+ expose-state.json, well-known/ — stay in place. Anything else (plus
264
+ known legacy cruft like daily.db, server.yaml) is moved under
265
+ ~/.parachute/.archive-<YYYY-MM-DD>/, never deleted.
266
+
267
+ Dotfiles at the root (.env, .DS_Store, prior .archive-* dirs) are left
268
+ alone.
269
+
270
+ Flags:
271
+ --dry-run print the plan; make no changes
272
+ --yes, -y skip the confirmation prompt
273
+
274
+ Examples:
275
+ parachute migrate --dry-run see what would move, without touching anything
276
+ parachute migrate interactive sweep (prompts before acting)
277
+ parachute migrate --yes sweep without prompting
278
+ `;
279
+ }