@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.
- package/LICENSE +661 -0
- package/README.md +284 -0
- package/package.json +31 -0
- package/src/__tests__/auth.test.ts +101 -0
- package/src/__tests__/auto-wire.test.ts +283 -0
- package/src/__tests__/cli.test.ts +192 -0
- package/src/__tests__/cloudflare-config.test.ts +54 -0
- package/src/__tests__/cloudflare-detect.test.ts +68 -0
- package/src/__tests__/cloudflare-state.test.ts +92 -0
- package/src/__tests__/cloudflare-tunnel.test.ts +207 -0
- package/src/__tests__/config.test.ts +18 -0
- package/src/__tests__/env-file.test.ts +125 -0
- package/src/__tests__/expose-auth-preflight.test.ts +201 -0
- package/src/__tests__/expose-cloudflare.test.ts +484 -0
- package/src/__tests__/expose-interactive.test.ts +703 -0
- package/src/__tests__/expose-last-provider.test.ts +113 -0
- package/src/__tests__/expose-off-auto.test.ts +269 -0
- package/src/__tests__/expose-state.test.ts +101 -0
- package/src/__tests__/expose.test.ts +1581 -0
- package/src/__tests__/hub-control.test.ts +346 -0
- package/src/__tests__/hub-server.test.ts +157 -0
- package/src/__tests__/hub.test.ts +116 -0
- package/src/__tests__/install.test.ts +1145 -0
- package/src/__tests__/lifecycle.test.ts +608 -0
- package/src/__tests__/migrate.test.ts +422 -0
- package/src/__tests__/notes-serve.test.ts +135 -0
- package/src/__tests__/port-assign.test.ts +178 -0
- package/src/__tests__/process-state.test.ts +140 -0
- package/src/__tests__/scribe-config.test.ts +193 -0
- package/src/__tests__/scribe-provider-interactive.test.ts +361 -0
- package/src/__tests__/services-manifest.test.ts +177 -0
- package/src/__tests__/status.test.ts +347 -0
- package/src/__tests__/tailscale-commands.test.ts +111 -0
- package/src/__tests__/tailscale-detect.test.ts +64 -0
- package/src/__tests__/vault-auth-status.test.ts +164 -0
- package/src/__tests__/vault-tokens-create-interactive.test.ts +183 -0
- package/src/__tests__/well-known.test.ts +214 -0
- package/src/auto-wire.ts +184 -0
- package/src/cli.ts +482 -0
- package/src/cloudflare/config.ts +58 -0
- package/src/cloudflare/detect.ts +58 -0
- package/src/cloudflare/state.ts +96 -0
- package/src/cloudflare/tunnel.ts +135 -0
- package/src/commands/auth.ts +69 -0
- package/src/commands/expose-auth-preflight.ts +217 -0
- package/src/commands/expose-cloudflare.ts +329 -0
- package/src/commands/expose-interactive.ts +428 -0
- package/src/commands/expose-off-auto.ts +199 -0
- package/src/commands/expose.ts +522 -0
- package/src/commands/install.ts +422 -0
- package/src/commands/lifecycle.ts +324 -0
- package/src/commands/migrate.ts +253 -0
- package/src/commands/scribe-provider-interactive.ts +269 -0
- package/src/commands/status.ts +238 -0
- package/src/commands/vault-tokens-create-interactive.ts +137 -0
- package/src/commands/vault.ts +17 -0
- package/src/config.ts +16 -0
- package/src/env-file.ts +76 -0
- package/src/expose-last-provider.ts +71 -0
- package/src/expose-state.ts +125 -0
- package/src/help.ts +279 -0
- package/src/hub-control.ts +254 -0
- package/src/hub-origin.ts +44 -0
- package/src/hub-server.ts +113 -0
- package/src/hub.ts +674 -0
- package/src/notes-serve.ts +135 -0
- package/src/port-assign.ts +125 -0
- package/src/process-state.ts +111 -0
- package/src/scribe-config.ts +149 -0
- package/src/service-spec.ts +296 -0
- package/src/services-manifest.ts +171 -0
- package/src/tailscale/commands.ts +41 -0
- package/src/tailscale/detect.ts +107 -0
- package/src/tailscale/run.ts +28 -0
- package/src/vault/auth-status.ts +179 -0
- package/src/well-known.ts +127 -0
package/src/env-file.ts
ADDED
|
@@ -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
|
+
}
|