@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
|
@@ -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");
|