@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,269 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { type AliveFn, defaultAlive, processState } from "../process-state.ts";
3
+ import {
4
+ SCRIBE_DEFAULT_PROVIDER,
5
+ SCRIBE_PROVIDERS,
6
+ type ScribeProviderKey,
7
+ apiKeyEnvFor,
8
+ isKnownScribeProvider,
9
+ readScribeProviderState,
10
+ writeScribeApiKey,
11
+ writeScribeProvider,
12
+ } from "../scribe-config.ts";
13
+ import { restart as lifecycleRestart } from "./lifecycle.ts";
14
+
15
+ /**
16
+ * Owns the post-install scribe setup: pick a transcription provider, capture
17
+ * an API key when needed, persist both, and restart scribe if it's already
18
+ * running so the new wiring takes effect immediately.
19
+ *
20
+ * Routing (in order):
21
+ * 1. `preselectProvider` (the `--scribe-provider <name>` flag) — validate,
22
+ * use directly, no prompt.
23
+ * 2. Existing config has a non-default provider → assume the user already
24
+ * chose; skip silently.
25
+ * 3. Interactive TTY → numbered-list prompt, then API-key prompt for the
26
+ * cloud providers that need one.
27
+ * 4. Anything else (non-TTY, no flag) → leave the file untouched. The CLI
28
+ * footer points at `scribe.config.json` so scripts that need a non-
29
+ * default provider can write it themselves.
30
+ *
31
+ * Errors don't fail the install: a flaky restart or a config write that loses
32
+ * a race shouldn't undo a successful `bun add`. The user gets a clear log
33
+ * line and can re-run by hand.
34
+ */
35
+
36
+ export type InteractiveAvailability =
37
+ | { kind: "available"; prompt: (q: string) => Promise<string> }
38
+ | { kind: "not-tty" };
39
+
40
+ function defaultAvailability(): InteractiveAvailability {
41
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return { kind: "not-tty" };
42
+ return {
43
+ kind: "available",
44
+ prompt: async (question: string) => {
45
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
46
+ try {
47
+ return await rl.question(question);
48
+ } finally {
49
+ rl.close();
50
+ }
51
+ },
52
+ };
53
+ }
54
+
55
+ export interface SetupScribeProviderOpts {
56
+ configDir: string;
57
+ log?: (line: string) => void;
58
+ /**
59
+ * Pre-chosen provider from `--scribe-provider <name>` (or programmatic
60
+ * caller). Bypasses the picker entirely and the existing-config check —
61
+ * passing the flag is itself an explicit choice.
62
+ */
63
+ preselectProvider?: string;
64
+ /**
65
+ * Pre-supplied API key from `--scribe-key <key>`. Only consulted for
66
+ * providers that need one (groq / openai). Ignored for local providers.
67
+ */
68
+ preselectKey?: string;
69
+ /**
70
+ * Interactive availability + prompt seam. Tests inject `{ kind: "available",
71
+ * prompt: ... }` to drive the picker without a real TTY; production lets the
72
+ * default sense `process.stdin.isTTY`.
73
+ */
74
+ availability?: InteractiveAvailability;
75
+ /** Restart-vault test seam, mirroring auto-wire's. */
76
+ alive?: AliveFn;
77
+ restartService?: (short: string) => Promise<number>;
78
+ }
79
+
80
+ export interface SetupScribeProviderResult {
81
+ /** True when this call wrote a new provider into config.json. */
82
+ configured: boolean;
83
+ /** Provider value present in scribe's config.json after this call. */
84
+ provider: string | undefined;
85
+ /** True when this call wrote a new API key into scribe/.env. */
86
+ wroteApiKey: boolean;
87
+ /** True when scribe was running and this call issued a restart. */
88
+ restartedScribe: boolean;
89
+ /** When non-empty, why the prompt was skipped (for telemetry / tests). */
90
+ skippedReason?: "preselected" | "already-configured" | "non-interactive";
91
+ }
92
+
93
+ export async function setupScribeProvider(
94
+ opts: SetupScribeProviderOpts,
95
+ ): Promise<SetupScribeProviderResult> {
96
+ const log = opts.log ?? (() => {});
97
+ const availability = opts.availability ?? defaultAvailability();
98
+ const alive = opts.alive ?? defaultAlive;
99
+ const restartService =
100
+ opts.restartService ??
101
+ ((short: string) => lifecycleRestart(short, { configDir: opts.configDir, log }));
102
+
103
+ const initial = readScribeProviderState(opts.configDir);
104
+
105
+ // 1. Flag-driven path: --scribe-provider wins outright.
106
+ if (opts.preselectProvider) {
107
+ if (!isKnownScribeProvider(opts.preselectProvider)) {
108
+ log(
109
+ `⚠ unknown --scribe-provider "${opts.preselectProvider}". Known: ${SCRIBE_PROVIDERS.map((p) => p.key).join(", ")}. Leaving config unchanged.`,
110
+ );
111
+ return {
112
+ configured: false,
113
+ provider: initial.provider,
114
+ wroteApiKey: false,
115
+ restartedScribe: false,
116
+ skippedReason: "preselected",
117
+ };
118
+ }
119
+ return await applyProviderChoice(opts.preselectProvider, opts.preselectKey, "preselected", {
120
+ configDir: opts.configDir,
121
+ log,
122
+ alive,
123
+ restartService,
124
+ });
125
+ }
126
+
127
+ // 2. Detect-and-skip: a previous run (or the operator) has set a non-default
128
+ // provider. Leave it alone.
129
+ if (initial.provider !== undefined && initial.provider !== SCRIBE_DEFAULT_PROVIDER) {
130
+ log(
131
+ `Scribe transcription provider already set to "${initial.provider}" — leaving as-is. Edit ${opts.configDir}/scribe/config.json to change.`,
132
+ );
133
+ return {
134
+ configured: false,
135
+ provider: initial.provider,
136
+ wroteApiKey: false,
137
+ restartedScribe: false,
138
+ skippedReason: "already-configured",
139
+ };
140
+ }
141
+
142
+ // 3. Non-interactive (no TTY, no flag): don't prompt, don't write. The
143
+ // install footer tells the user where to look later.
144
+ if (availability.kind !== "available") {
145
+ return {
146
+ configured: false,
147
+ provider: initial.provider,
148
+ wroteApiKey: false,
149
+ restartedScribe: false,
150
+ skippedReason: "non-interactive",
151
+ };
152
+ }
153
+
154
+ // 4. Prompt loop.
155
+ const picked = await pickProvider(availability.prompt, log);
156
+ if (!picked) {
157
+ log(
158
+ "No transcription provider chosen — leaving scribe at its built-in default (parakeet-mlx).",
159
+ );
160
+ return {
161
+ configured: false,
162
+ provider: initial.provider,
163
+ wroteApiKey: false,
164
+ restartedScribe: false,
165
+ };
166
+ }
167
+
168
+ let apiKey: string | undefined;
169
+ const envKey = apiKeyEnvFor(picked);
170
+ if (envKey) {
171
+ apiKey = (await availability.prompt(`Paste your ${envKey} (or blank to skip): `)).trim();
172
+ if (apiKey === "") {
173
+ log(
174
+ `Skipped ${envKey} entry. Set it later via \`echo '${envKey}=<value>' >> ${opts.configDir}/scribe/.env\` then \`parachute restart scribe\`.`,
175
+ );
176
+ apiKey = undefined;
177
+ }
178
+ }
179
+
180
+ return await applyProviderChoice(picked, apiKey, undefined, {
181
+ configDir: opts.configDir,
182
+ log,
183
+ alive,
184
+ restartService,
185
+ });
186
+ }
187
+
188
+ interface ApplyDeps {
189
+ configDir: string;
190
+ log: (line: string) => void;
191
+ alive: AliveFn;
192
+ restartService: (short: string) => Promise<number>;
193
+ }
194
+
195
+ async function applyProviderChoice(
196
+ provider: ScribeProviderKey,
197
+ apiKey: string | undefined,
198
+ skippedReason: SetupScribeProviderResult["skippedReason"],
199
+ deps: ApplyDeps,
200
+ ): Promise<SetupScribeProviderResult> {
201
+ writeScribeProvider(deps.configDir, provider);
202
+ let wroteApiKey = false;
203
+ const envKey = apiKeyEnvFor(provider);
204
+ if (envKey && apiKey && apiKey.length > 0) {
205
+ writeScribeApiKey(deps.configDir, envKey, apiKey);
206
+ wroteApiKey = true;
207
+ }
208
+
209
+ if (envKey && apiKey) {
210
+ deps.log(
211
+ `Set scribe transcription provider to "${provider}" and saved ${envKey} to ${deps.configDir}/scribe/.env.`,
212
+ );
213
+ } else if (envKey) {
214
+ deps.log(
215
+ `Set scribe transcription provider to "${provider}". Add ${envKey} to ${deps.configDir}/scribe/.env before transcribing.`,
216
+ );
217
+ } else {
218
+ deps.log(`Set scribe transcription provider to "${provider}".`);
219
+ }
220
+
221
+ let restartedScribe = false;
222
+ if (processState("scribe", deps.configDir, deps.alive).status === "running") {
223
+ deps.log("Restarting scribe to pick up the new transcription provider…");
224
+ const code = await deps.restartService("scribe");
225
+ if (code === 0) {
226
+ restartedScribe = true;
227
+ } else {
228
+ deps.log(
229
+ "⚠ scribe restart failed. Run manually once the issue is resolved: parachute restart scribe",
230
+ );
231
+ }
232
+ }
233
+
234
+ const result: SetupScribeProviderResult = {
235
+ configured: true,
236
+ provider,
237
+ wroteApiKey,
238
+ restartedScribe,
239
+ };
240
+ if (skippedReason) result.skippedReason = skippedReason;
241
+ return result;
242
+ }
243
+
244
+ async function pickProvider(
245
+ prompt: (q: string) => Promise<string>,
246
+ log: (line: string) => void,
247
+ ): Promise<ScribeProviderKey | undefined> {
248
+ log("");
249
+ log("Which transcription provider would you like to use?");
250
+ for (let i = 0; i < SCRIBE_PROVIDERS.length; i++) {
251
+ const p = SCRIBE_PROVIDERS[i];
252
+ if (!p) continue;
253
+ log(` [${i + 1}] ${p.label.padEnd(13)} ${p.blurb}`);
254
+ }
255
+ log(` [s] skip — leave at default (${SCRIBE_DEFAULT_PROVIDER})`);
256
+
257
+ for (let attempt = 0; attempt < 5; attempt++) {
258
+ const raw = (await prompt("> ")).trim().toLowerCase();
259
+ if (raw === "" || raw === "s" || raw === "skip") return undefined;
260
+ const asNumber = Number.parseInt(raw, 10);
261
+ if (Number.isInteger(asNumber) && asNumber >= 1 && asNumber <= SCRIBE_PROVIDERS.length) {
262
+ return SCRIBE_PROVIDERS[asNumber - 1]?.key;
263
+ }
264
+ if (isKnownScribeProvider(raw)) return raw;
265
+ log(`Sorry — expected 1..${SCRIBE_PROVIDERS.length}, a name, or s (got "${raw}"). Try again.`);
266
+ }
267
+ log("Too many invalid entries; skipping.");
268
+ return undefined;
269
+ }
@@ -0,0 +1,238 @@
1
+ import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
2
+ import { HUB_SVC, readHubPort } from "../hub-control.ts";
3
+ import { type AliveFn, defaultAlive, formatUptime, processState } from "../process-state.ts";
4
+ import { getSpec, shortNameForManifest } from "../service-spec.ts";
5
+ import { type ServiceEntry, readManifest } from "../services-manifest.ts";
6
+
7
+ export type FetchFn = (url: string, init?: RequestInit) => Promise<Response>;
8
+
9
+ export interface StatusOpts {
10
+ manifestPath?: string;
11
+ fetchImpl?: FetchFn;
12
+ print?: (line: string) => void;
13
+ timeoutMs?: number;
14
+ configDir?: string;
15
+ alive?: AliveFn;
16
+ now?: () => Date;
17
+ }
18
+
19
+ export interface ProbeResult {
20
+ entry: ServiceEntry;
21
+ healthy: boolean;
22
+ statusCode?: number;
23
+ error?: string;
24
+ latencyMs: number;
25
+ }
26
+
27
+ export async function probe(
28
+ entry: ServiceEntry,
29
+ fetchImpl: FetchFn,
30
+ timeoutMs: number,
31
+ ): Promise<ProbeResult> {
32
+ const url = `http://localhost:${entry.port}${entry.health}`;
33
+ const controller = new AbortController();
34
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
35
+ const start = performance.now();
36
+ try {
37
+ const res = await fetchImpl(url, { signal: controller.signal });
38
+ const latencyMs = Math.round(performance.now() - start);
39
+ return {
40
+ entry,
41
+ healthy: res.ok,
42
+ statusCode: res.status,
43
+ latencyMs,
44
+ };
45
+ } catch (err) {
46
+ const latencyMs = Math.round(performance.now() - start);
47
+ return {
48
+ entry,
49
+ healthy: false,
50
+ error: err instanceof Error ? err.message : String(err),
51
+ latencyMs,
52
+ };
53
+ } finally {
54
+ clearTimeout(timer);
55
+ }
56
+ }
57
+
58
+ function formatRow(cells: string[], widths: number[]): string {
59
+ return cells
60
+ .map((c, i) => c.padEnd(widths[i] ?? 0, " "))
61
+ .join(" ")
62
+ .trimEnd();
63
+ }
64
+
65
+ interface StatusRow {
66
+ service: string;
67
+ port: string;
68
+ version: string;
69
+ processLabel: string;
70
+ pidLabel: string;
71
+ uptimeLabel: string;
72
+ healthLabel: string;
73
+ latencyLabel: string;
74
+ url: string | undefined;
75
+ healthy: boolean;
76
+ skipped: boolean;
77
+ }
78
+
79
+ /**
80
+ * Canonical reachable URL for a row. Spec-driven where possible (vault appends
81
+ * `/mcp`, scribe is at the root, …). Unknown services fall back to bare
82
+ * `http://127.0.0.1:<port>` plus the first declared path so third-party
83
+ * services still get a useful pointer rather than an empty cell.
84
+ */
85
+ function urlForEntry(entry: ServiceEntry, short: string | undefined): string | undefined {
86
+ const spec = short ? getSpec(short) : undefined;
87
+ const fromSpec = spec?.urlForEntry?.(entry);
88
+ if (fromSpec) return fromSpec;
89
+ const first = entry.paths[0]?.replace(/\/+$/, "") ?? "";
90
+ return `http://127.0.0.1:${entry.port}${first}`;
91
+ }
92
+
93
+ function hubRow(configDir: string, alive: AliveFn, nowDate: Date): StatusRow | undefined {
94
+ const proc = processState(HUB_SVC, configDir, alive);
95
+ if (proc.status === "unknown") return undefined;
96
+ const port = readHubPort(configDir);
97
+ const portLabel = port !== undefined ? String(port) : "-";
98
+ const processLabel = proc.status === "running" ? "running" : "stopped";
99
+ const pidLabel = proc.status === "running" && proc.pid !== undefined ? String(proc.pid) : "-";
100
+ const uptimeLabel =
101
+ proc.status === "running" && proc.startedAt ? formatUptime(proc.startedAt, nowDate) : "-";
102
+ return {
103
+ service: "parachute-hub (internal)",
104
+ port: portLabel,
105
+ version: "-",
106
+ processLabel,
107
+ pidLabel,
108
+ uptimeLabel,
109
+ healthLabel: "-",
110
+ latencyLabel: "-",
111
+ url: port !== undefined ? `http://127.0.0.1:${port}` : undefined,
112
+ healthy: true,
113
+ skipped: true,
114
+ };
115
+ }
116
+
117
+ export async function status(opts: StatusOpts = {}): Promise<number> {
118
+ const manifestPath = opts.manifestPath ?? SERVICES_MANIFEST_PATH;
119
+ const fetchImpl = opts.fetchImpl ?? fetch;
120
+ const print = opts.print ?? ((line) => console.log(line));
121
+ const timeoutMs = opts.timeoutMs ?? 1500;
122
+ const configDir = opts.configDir ?? CONFIG_DIR;
123
+ const alive = opts.alive ?? defaultAlive;
124
+ const now = opts.now ?? (() => new Date());
125
+
126
+ const manifest = readManifest(manifestPath);
127
+ if (manifest.services.length === 0) {
128
+ print("No services installed yet.");
129
+ print("Try: parachute install vault");
130
+ return 0;
131
+ }
132
+
133
+ const nowDate = now();
134
+
135
+ /**
136
+ * Per-row resolution: look up the short name so we can read PID state,
137
+ * skip the health probe when the process is known-stopped (ECONNREFUSED
138
+ * noise isn't informative), and report it as running/stopped + uptime.
139
+ *
140
+ * Third-party services we don't know about fall back to probing and show
141
+ * "-" for process columns.
142
+ */
143
+ const rows: StatusRow[] = await Promise.all(
144
+ manifest.services.map(async (entry) => {
145
+ const short = shortNameForManifest(entry.name);
146
+ const proc = short ? processState(short, configDir, alive) : undefined;
147
+
148
+ const processLabel =
149
+ proc?.status === "running" ? "running" : proc?.status === "stopped" ? "stopped" : "-";
150
+ const pidLabel =
151
+ proc?.status === "running" && proc.pid !== undefined ? String(proc.pid) : "-";
152
+ const uptimeLabel =
153
+ proc?.status === "running" && proc.startedAt ? formatUptime(proc.startedAt, nowDate) : "-";
154
+
155
+ const url = urlForEntry(entry, short);
156
+
157
+ // Only skip probe when we know the process is dead (PID file was
158
+ // present but kill(pid, 0) failed). "unknown" status (no PID file)
159
+ // still probes — externally-managed services should report health.
160
+ if (proc?.status === "stopped") {
161
+ return {
162
+ service: entry.name,
163
+ port: String(entry.port),
164
+ version: entry.version,
165
+ processLabel,
166
+ pidLabel,
167
+ uptimeLabel,
168
+ healthLabel: "-",
169
+ latencyLabel: "-",
170
+ url,
171
+ healthy: false,
172
+ skipped: true,
173
+ };
174
+ }
175
+
176
+ const p = await probe(entry, fetchImpl, timeoutMs);
177
+ const healthLabel = p.healthy
178
+ ? "ok"
179
+ : p.statusCode !== undefined
180
+ ? `http ${p.statusCode}`
181
+ : (p.error ?? "down");
182
+ return {
183
+ service: entry.name,
184
+ port: String(entry.port),
185
+ version: entry.version,
186
+ processLabel,
187
+ pidLabel,
188
+ uptimeLabel,
189
+ healthLabel,
190
+ latencyLabel: `${p.latencyMs}ms`,
191
+ url,
192
+ healthy: p.healthy,
193
+ skipped: false,
194
+ };
195
+ }),
196
+ );
197
+
198
+ // Hub is an internal service — not in services.json, but users notice
199
+ // when it's dead. Only show it if we've seen it run.
200
+ const hub = hubRow(configDir, alive, nowDate);
201
+ if (hub) rows.push(hub);
202
+
203
+ const header = ["SERVICE", "PORT", "VERSION", "PROCESS", "PID", "UPTIME", "HEALTH", "LATENCY"];
204
+ const textRows = rows.map((r) => [
205
+ r.service,
206
+ r.port,
207
+ r.version,
208
+ r.processLabel,
209
+ r.pidLabel,
210
+ r.uptimeLabel,
211
+ r.healthLabel,
212
+ r.latencyLabel,
213
+ ]);
214
+ const widths = header.map((_, i) =>
215
+ Math.max(header[i]?.length ?? 0, ...textRows.map((r) => r[i]?.length ?? 0)),
216
+ );
217
+ print(formatRow(header, widths));
218
+ // URL stays on a continuation line rather than a column. URLs are long
219
+ // (vault's MCP path runs ~40 chars), and a ninth column would push the
220
+ // table past 80 cols on every install. The " → " prefix groups visually
221
+ // with the row above without misleading the table widths.
222
+ for (let i = 0; i < textRows.length; i++) {
223
+ const cells = textRows[i];
224
+ const row = rows[i];
225
+ if (!cells || !row) continue;
226
+ print(formatRow(cells, widths));
227
+ if (row.url) print(` → ${row.url}`);
228
+ }
229
+
230
+ /**
231
+ * Overall exit: non-zero if any *probed* service is unhealthy. A stopped
232
+ * service is expected ("I haven't started it yet"), not a failure — users
233
+ * want `parachute status` to return 0 after a fresh install before they
234
+ * `parachute start`. Health regressions among running services still 1.
235
+ */
236
+ const anyUnhealthy = rows.some((r) => !r.skipped && !r.healthy);
237
+ return anyUnhealthy ? 1 : 0;
238
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * `parachute vault tokens create` (no narrowing flags, in a TTY) — guided
3
+ * token creation. Same command with any of `--scope` / `--read` /
4
+ * `--permission`, or running under a non-TTY, bypasses this module and passes
5
+ * through to `parachute-vault tokens create` unchanged.
6
+ *
7
+ * Two prompts:
8
+ *
9
+ * 1. Scope — read / write / admin / cancel. Default is `read` on Enter:
10
+ * the two-factor reasoning is (a) a read-only token is the least
11
+ * dangerous thing to mint by mistake, and (b) most callers of this
12
+ * command interactively are plumbing in a new read-only consumer
13
+ * (hooks, dashboards, n8n triggers). Users who actually want admin can
14
+ * type "3" in ~1 second.
15
+ *
16
+ * 2. Label — free-form string, blank skips the prompt entirely (vault's
17
+ * own `--label` default of "default" then applies). Skipped outright
18
+ * if the user already supplied `--label …` on the command line.
19
+ *
20
+ * The resolved flags are appended to the original argv and forwarded to
21
+ * `parachute-vault tokens create` via an inherit-stdio subprocess so the
22
+ * generated token and its usage block print directly to the user's terminal.
23
+ *
24
+ * Shape mirrors `expose-interactive.ts`: every side-effectful edge is an
25
+ * injectable seam so the full prompt tree is testable without spawning.
26
+ */
27
+
28
+ import { createInterface } from "node:readline/promises";
29
+
30
+ export type InteractiveRunner = (cmd: readonly string[]) => Promise<number>;
31
+
32
+ const defaultInteractiveRunner: InteractiveRunner = async (cmd) => {
33
+ const proc = Bun.spawn([...cmd], { stdio: ["inherit", "inherit", "inherit"] });
34
+ return await proc.exited;
35
+ };
36
+
37
+ async function defaultPrompt(question: string): Promise<string> {
38
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
39
+ try {
40
+ return await rl.question(question);
41
+ } finally {
42
+ rl.close();
43
+ }
44
+ }
45
+
46
+ export interface VaultTokensCreateInteractiveOpts {
47
+ /**
48
+ * Original argv after `vault tokens create`. Flags the user already
49
+ * supplied (`--vault <name>`, `--expires <dur>`, `--label <x>`) are
50
+ * forwarded verbatim to `parachute-vault`; only the scope dimension is
51
+ * resolved interactively.
52
+ */
53
+ args: readonly string[];
54
+ prompt?: (question: string) => Promise<string>;
55
+ interactiveRunner?: InteractiveRunner;
56
+ log?: (line: string) => void;
57
+ }
58
+
59
+ interface Resolved {
60
+ args: readonly string[];
61
+ prompt: (question: string) => Promise<string>;
62
+ interactiveRunner: InteractiveRunner;
63
+ log: (line: string) => void;
64
+ }
65
+
66
+ function resolve(opts: VaultTokensCreateInteractiveOpts): Resolved {
67
+ return {
68
+ args: opts.args,
69
+ prompt: opts.prompt ?? defaultPrompt,
70
+ interactiveRunner: opts.interactiveRunner ?? defaultInteractiveRunner,
71
+ log: opts.log ?? ((line) => console.log(line)),
72
+ };
73
+ }
74
+
75
+ type ScopeChoice = "read" | "write" | "admin" | "cancel";
76
+
77
+ async function promptScope(r: Resolved): Promise<ScopeChoice> {
78
+ r.log("Scope for this token?");
79
+ r.log(" [1] read — query-only (safer default)");
80
+ r.log(" [2] write — read + create/update");
81
+ r.log(" [3] admin — full access (token + config management)");
82
+ r.log(" [4] cancel");
83
+ while (true) {
84
+ const raw = (await r.prompt("Choice [1]: ")).trim().toLowerCase();
85
+ if (raw === "" || raw === "1" || raw === "read") return "read";
86
+ if (raw === "2" || raw === "write") return "write";
87
+ if (raw === "3" || raw === "admin") return "admin";
88
+ if (raw === "4" || raw === "cancel" || raw === "q") return "cancel";
89
+ r.log(`(didn't understand "${raw}" — please pick 1, 2, 3, or 4)`);
90
+ }
91
+ }
92
+
93
+ async function promptLabel(r: Resolved): Promise<string | undefined> {
94
+ r.log("");
95
+ const raw = (await r.prompt('Label for this token (e.g. "n8n-sync", blank to skip): ')).trim();
96
+ return raw === "" ? undefined : raw;
97
+ }
98
+
99
+ /**
100
+ * Map the scope choice to the CLI flag sequence vault expects. We pass the
101
+ * canonical form for each level so anyone inspecting the spawned argv can
102
+ * see exactly what got minted — `--read` reads clearer than `--scope
103
+ * vault:read` for the common case, and `vault:write`/`vault:admin` are the
104
+ * canonical OAuth-style scope names for the other two.
105
+ */
106
+ function scopeFlagsFor(choice: "read" | "write" | "admin"): string[] {
107
+ if (choice === "read") return ["--read"];
108
+ if (choice === "write") return ["--scope", "vault:write"];
109
+ return ["--scope", "vault:admin"];
110
+ }
111
+
112
+ export async function runVaultTokensCreateInteractive(
113
+ opts: VaultTokensCreateInteractiveOpts,
114
+ ): Promise<number> {
115
+ const r = resolve(opts);
116
+
117
+ const scope = await promptScope(r);
118
+ if (scope === "cancel") {
119
+ r.log("Cancelled — no token created.");
120
+ return 0;
121
+ }
122
+
123
+ const hasLabelFlag = r.args.includes("--label");
124
+ const label = hasLabelFlag ? undefined : await promptLabel(r);
125
+
126
+ const forwarded: string[] = [
127
+ "parachute-vault",
128
+ "tokens",
129
+ "create",
130
+ ...r.args,
131
+ ...scopeFlagsFor(scope),
132
+ ];
133
+ if (label !== undefined) forwarded.push("--label", label);
134
+
135
+ r.log("");
136
+ return await r.interactiveRunner(forwarded);
137
+ }
@@ -0,0 +1,17 @@
1
+ export async function dispatchVault(args: readonly string[]): Promise<number> {
2
+ try {
3
+ const proc = Bun.spawn(["parachute-vault", ...args], {
4
+ stdio: ["inherit", "inherit", "inherit"],
5
+ });
6
+ return await proc.exited;
7
+ } catch (err) {
8
+ const msg = err instanceof Error ? err.message : String(err);
9
+ if (msg.toLowerCase().includes("enoent") || msg.toLowerCase().includes("not found")) {
10
+ console.error("parachute-vault not found on PATH.");
11
+ console.error("Install it with: parachute install vault");
12
+ return 127;
13
+ }
14
+ console.error(`failed to run parachute-vault: ${msg}`);
15
+ return 1;
16
+ }
17
+ }
package/src/config.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Root config directory. Honors `$PARACHUTE_HOME` to match the convention
6
+ * used by `parachute-vault` — both sides must resolve the same path for the
7
+ * shared `services.json` to round-trip.
8
+ */
9
+ export function configDir(env: NodeJS.ProcessEnv = process.env): string {
10
+ const override = env.PARACHUTE_HOME;
11
+ if (override && override.length > 0) return override;
12
+ return join(homedir(), ".parachute");
13
+ }
14
+
15
+ export const CONFIG_DIR = configDir();
16
+ export const SERVICES_MANIFEST_PATH = join(CONFIG_DIR, "services.json");