@openparachute/hub 0.7.3-rc.7 → 0.7.3-rc.9
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/package.json +1 -1
- package/src/__tests__/hub-origins-env-set.test.ts +199 -0
- package/src/__tests__/scribe-config.test.ts +117 -0
- package/src/__tests__/serve-boot.test.ts +45 -0
- package/src/__tests__/setup-wizard.test.ts +18 -0
- package/src/__tests__/vault-hub-origin-env.test.ts +38 -4
- package/src/__tests__/wizard-transcription.test.ts +334 -0
- package/src/__tests__/wizard.test.ts +146 -0
- package/src/api-modules-ops.ts +12 -0
- package/src/commands/init.ts +10 -2
- package/src/commands/serve-boot.ts +16 -2
- package/src/commands/wizard-transcription.ts +296 -0
- package/src/commands/wizard.ts +73 -3
- package/src/hub-origin.ts +64 -0
- package/src/jwt-sign.ts +14 -3
- package/src/scribe-config.ts +145 -0
- package/src/setup-wizard.ts +37 -4
- package/src/vault-hub-origin-env.ts +109 -16
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcription step for the CLI setup wizard (`parachute setup-wizard` /
|
|
3
|
+
* `parachute init`).
|
|
4
|
+
*
|
|
5
|
+
* Until now the CLI wizard NEVER asked about transcription — it walked
|
|
6
|
+
* Account → Vault → Expose and stopped, while the browser wizard's vault step
|
|
7
|
+
* folds a full scribe sub-form (none / cloud + key / local). That divergence
|
|
8
|
+
* is the "asks different questions in its CLI vs browser forms" root cause from
|
|
9
|
+
* the onboarding-streamline arc. This module brings the CLI to parity.
|
|
10
|
+
*
|
|
11
|
+
* Crucially it follows the arc's "never ask without doing" rule: when the
|
|
12
|
+
* operator picks a provider we ACTUALLY set it up, or honestly say we couldn't
|
|
13
|
+
* and point at the alternative — we never record a dead provider string.
|
|
14
|
+
*
|
|
15
|
+
* - **none** → write nothing; say transcription is off + how to turn it on.
|
|
16
|
+
* - **cloud** (groq / openai) → write provider + key (scribe-config.ts), then
|
|
17
|
+
* install + start scribe via the hub's own `parachute install scribe`
|
|
18
|
+
* one-shot. The very-first scribe boot reads the provider we just wrote.
|
|
19
|
+
* - **local** → RAM/platform-gate FIRST (decideLocalProvider). If the box
|
|
20
|
+
* can't run a local model (no backend for the platform, or < 2 GB RAM) we
|
|
21
|
+
* do NOT write `local` — we explain why + steer to the cloud one-shot. If
|
|
22
|
+
* it can, we install scribe, then run scribe's own runnable install routine
|
|
23
|
+
* (`parachute-scribe install-backend --provider <onnx-asr|parakeet-mlx>`,
|
|
24
|
+
* scribe PR #79) which apt/pip-installs the engine + warm-pulls the model
|
|
25
|
+
* and exits non-zero on hard failure. On success we record the resolved
|
|
26
|
+
* platform provider; on failure we say so + point at cloud, recording
|
|
27
|
+
* nothing.
|
|
28
|
+
*
|
|
29
|
+
* Everything that touches the host (subprocess spawn, RAM probe, the prompt)
|
|
30
|
+
* goes through an injected seam so tests exercise every branch WITHOUT
|
|
31
|
+
* installing anything or shelling out.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { createInterface } from "node:readline/promises";
|
|
35
|
+
import {
|
|
36
|
+
type ScribeProviderKey,
|
|
37
|
+
apiKeyEnvFor,
|
|
38
|
+
clearScribeProvider,
|
|
39
|
+
decideLocalProvider,
|
|
40
|
+
readAvailableRamMib,
|
|
41
|
+
} from "../scribe-config.ts";
|
|
42
|
+
|
|
43
|
+
/** Outcome of one subprocess. Exit code only — stdio is inherited / streamed. */
|
|
44
|
+
export type WizardCommandRunner = (cmd: readonly string[]) => Promise<number>;
|
|
45
|
+
|
|
46
|
+
export interface TranscriptionStepOpts {
|
|
47
|
+
/** `~/.parachute` (or the PARACHUTE_HOME override). Where scribe config lives. */
|
|
48
|
+
configDir: string;
|
|
49
|
+
/** Log shim — production prints to stdout; tests capture into an array. */
|
|
50
|
+
log: (line: string) => void;
|
|
51
|
+
/** Prompt seam — production uses readline; tests inject a scripted queue. */
|
|
52
|
+
prompt?: (question: string) => Promise<string>;
|
|
53
|
+
/**
|
|
54
|
+
* Pre-supply the choice non-interactively (mirrors the wizard's other
|
|
55
|
+
* run-from-flag escapes). `none` | `local` | a cloud provider name.
|
|
56
|
+
*/
|
|
57
|
+
transcribeMode?: "none" | "local" | "groq" | "openai";
|
|
58
|
+
/** Pre-supplied cloud API key (for `groq` / `openai`). */
|
|
59
|
+
transcribeApiKey?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Command runner seam. Production spawns the real binary inheriting stdio;
|
|
62
|
+
* tests inject a recorder so nothing installs. Receives a full argv —
|
|
63
|
+
* `["parachute", "install", "scribe", ...]` or
|
|
64
|
+
* `["parachute-scribe", "install-backend", "--provider", "onnx-asr"]`.
|
|
65
|
+
*/
|
|
66
|
+
runCommand?: WizardCommandRunner;
|
|
67
|
+
/** Platform override (test seam). Defaults to the real host platform. */
|
|
68
|
+
platform?: NodeJS.Platform;
|
|
69
|
+
/** Available-RAM override in MiB (test seam). Defaults to the real probe. */
|
|
70
|
+
availableRamMib?: number | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Default readline prompt (matches wizard.ts's defaultPrompt). */
|
|
74
|
+
async function defaultPrompt(question: string): Promise<string> {
|
|
75
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
76
|
+
try {
|
|
77
|
+
return await rl.question(question);
|
|
78
|
+
} finally {
|
|
79
|
+
rl.close();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Default command runner: spawn the binary, inherit stdio so the operator sees
|
|
85
|
+
* apt/pip progress in real time, resolve to the exit code. Never throws — a
|
|
86
|
+
* spawn failure (binary not on PATH) surfaces as a non-zero code, which the
|
|
87
|
+
* caller treats as "couldn't install."
|
|
88
|
+
*/
|
|
89
|
+
const defaultRunCommand: WizardCommandRunner = async (cmd) => {
|
|
90
|
+
try {
|
|
91
|
+
const proc = Bun.spawn([...cmd], {
|
|
92
|
+
stdout: "inherit",
|
|
93
|
+
stderr: "inherit",
|
|
94
|
+
stdin: "inherit",
|
|
95
|
+
});
|
|
96
|
+
return await proc.exited;
|
|
97
|
+
} catch {
|
|
98
|
+
return 127; // ENOENT / spawn failure → "command not found"
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Walk the transcription step. Returns 0 always — a transcription that
|
|
104
|
+
* couldn't be set up is reported honestly but does NOT fail the wizard (the
|
|
105
|
+
* operator can finish setup and add it later; it's not a blocking step).
|
|
106
|
+
*/
|
|
107
|
+
export async function walkTranscriptionStep(opts: TranscriptionStepOpts): Promise<number> {
|
|
108
|
+
const log = opts.log;
|
|
109
|
+
const prompt = opts.prompt ?? defaultPrompt;
|
|
110
|
+
const runCommand = opts.runCommand ?? defaultRunCommand;
|
|
111
|
+
const platform = opts.platform ?? process.platform;
|
|
112
|
+
|
|
113
|
+
log("");
|
|
114
|
+
log("Step — Transcription (scribe)");
|
|
115
|
+
log(" Parachute can transcribe voice notes + audio attachments. Pick a");
|
|
116
|
+
log(" transcription engine, or skip and add one later.");
|
|
117
|
+
|
|
118
|
+
// Resolve the choice (flag or prompt).
|
|
119
|
+
const choice = await resolveChoice(opts, prompt, log);
|
|
120
|
+
if (choice === "none") {
|
|
121
|
+
log("");
|
|
122
|
+
log(" Transcription off. Turn it on later with `parachute install scribe`.");
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (choice === "local") {
|
|
127
|
+
return await handleLocal(opts, prompt, runCommand, platform, log);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Cloud provider (groq / openai).
|
|
131
|
+
return await handleCloud(opts, choice, prompt, runCommand, log);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
type ResolvedChoice = "none" | "local" | "groq" | "openai";
|
|
135
|
+
|
|
136
|
+
async function resolveChoice(
|
|
137
|
+
opts: TranscriptionStepOpts,
|
|
138
|
+
prompt: (q: string) => Promise<string>,
|
|
139
|
+
log: (l: string) => void,
|
|
140
|
+
): Promise<ResolvedChoice> {
|
|
141
|
+
if (opts.transcribeMode !== undefined) return opts.transcribeMode;
|
|
142
|
+
log("");
|
|
143
|
+
log(" 1) None — skip transcription (default)");
|
|
144
|
+
log(" 2) Local — run the engine on this box (no API key, needs ~2 GB RAM)");
|
|
145
|
+
log(" 3) Cloud — Groq or OpenAI (fast, needs an API key, ~$0.04/hr of audio)");
|
|
146
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
147
|
+
const raw = (await prompt(" Pick [1]: ")).trim().toLowerCase();
|
|
148
|
+
if (raw === "" || raw === "1" || raw === "none" || raw === "n") return "none";
|
|
149
|
+
if (raw === "2" || raw === "local" || raw === "l") return "local";
|
|
150
|
+
if (raw === "3" || raw === "cloud" || raw === "c") {
|
|
151
|
+
// Sub-pick which cloud provider.
|
|
152
|
+
for (let inner = 0; inner < 5; inner++) {
|
|
153
|
+
const which = (await prompt(" Cloud provider — [g]roq (default) or [o]penai: "))
|
|
154
|
+
.trim()
|
|
155
|
+
.toLowerCase();
|
|
156
|
+
if (which === "" || which === "g" || which === "groq") return "groq";
|
|
157
|
+
if (which === "o" || which === "openai") return "openai";
|
|
158
|
+
log(` Sorry — expected groq or openai (got "${which}"). Try again.`);
|
|
159
|
+
}
|
|
160
|
+
log(" Too many invalid entries; skipping transcription.");
|
|
161
|
+
return "none";
|
|
162
|
+
}
|
|
163
|
+
if (raw === "groq" || raw === "g") return "groq";
|
|
164
|
+
if (raw === "openai" || raw === "o") return "openai";
|
|
165
|
+
log(` Sorry — expected 1, 2, or 3 (got "${raw}"). Try again.`);
|
|
166
|
+
}
|
|
167
|
+
log(" Too many invalid entries; skipping transcription.");
|
|
168
|
+
return "none";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function handleCloud(
|
|
172
|
+
opts: TranscriptionStepOpts,
|
|
173
|
+
provider: "groq" | "openai",
|
|
174
|
+
prompt: (q: string) => Promise<string>,
|
|
175
|
+
runCommand: WizardCommandRunner,
|
|
176
|
+
log: (l: string) => void,
|
|
177
|
+
): Promise<number> {
|
|
178
|
+
const envKey = apiKeyEnvFor(provider as ScribeProviderKey);
|
|
179
|
+
let apiKey = opts.transcribeApiKey;
|
|
180
|
+
if (apiKey === undefined && envKey) {
|
|
181
|
+
apiKey = (await prompt(` Paste your ${envKey} (or blank to set later): `)).trim();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Install + start scribe via the EXISTING one-shot path, handing it the
|
|
185
|
+
// chosen provider + key. `parachute install scribe --scribe-provider <p>
|
|
186
|
+
// [--scribe-key <k>]` writes the provider into scribe's config + the key into
|
|
187
|
+
// scribe/.env and starts the module — the same wiring the bare CLI install
|
|
188
|
+
// does. Passing the provider also suppresses install's own interactive
|
|
189
|
+
// provider prompt (it's already an explicit choice).
|
|
190
|
+
const cmd = ["parachute", "install", "scribe", "--scribe-provider", provider];
|
|
191
|
+
if (envKey && apiKey && apiKey.length > 0) {
|
|
192
|
+
cmd.push("--scribe-key", apiKey);
|
|
193
|
+
}
|
|
194
|
+
log("");
|
|
195
|
+
log(` Installing scribe with the ${provider} cloud provider…`);
|
|
196
|
+
const code = await runCommand(cmd);
|
|
197
|
+
if (code !== 0) {
|
|
198
|
+
log(` ✗ scribe install returned ${code}. Retry: \`${cmd.join(" ")}\`.`);
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
if (envKey && !(apiKey && apiKey.length > 0)) {
|
|
202
|
+
log(
|
|
203
|
+
` ✓ Recorded ${provider}. Add ${envKey} later: \`echo '${envKey}=<value>' >> ${opts.configDir}/scribe/.env\` then \`parachute restart scribe\`.`,
|
|
204
|
+
);
|
|
205
|
+
} else {
|
|
206
|
+
log(` ✓ Scribe installed and running with the ${provider} cloud provider.`);
|
|
207
|
+
}
|
|
208
|
+
return 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function handleLocal(
|
|
212
|
+
opts: TranscriptionStepOpts,
|
|
213
|
+
prompt: (q: string) => Promise<string>,
|
|
214
|
+
runCommand: WizardCommandRunner,
|
|
215
|
+
platform: NodeJS.Platform,
|
|
216
|
+
log: (l: string) => void,
|
|
217
|
+
): Promise<number> {
|
|
218
|
+
const ramMib =
|
|
219
|
+
opts.availableRamMib !== undefined ? opts.availableRamMib : readAvailableRamMib(platform);
|
|
220
|
+
const decision = decideLocalProvider(platform, ramMib);
|
|
221
|
+
|
|
222
|
+
if (!decision.ok) {
|
|
223
|
+
// Can't install local here — say EXACTLY why + point at the cloud one-shot.
|
|
224
|
+
// Do NOT record a dead `local` provider string.
|
|
225
|
+
log("");
|
|
226
|
+
log(` ✗ Local transcription isn't possible on this box: ${decision.reason}`);
|
|
227
|
+
log("");
|
|
228
|
+
log(" One-shot cloud alternative — get a free Groq key at https://console.groq.com,");
|
|
229
|
+
log(" then run:");
|
|
230
|
+
log(" parachute install scribe --scribe-provider groq --scribe-key gsk_…");
|
|
231
|
+
log(" (or re-run this wizard and choose Cloud).");
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const provider = decision.provider as "parakeet-mlx" | "onnx-asr";
|
|
236
|
+
log("");
|
|
237
|
+
log(` This box can run ${provider} locally.`);
|
|
238
|
+
|
|
239
|
+
// Confirm before the (slow, apt/pip) install unless pre-supplied.
|
|
240
|
+
if (opts.transcribeMode === undefined) {
|
|
241
|
+
const ok = (await prompt(` Install ${provider} now? [Y/n]: `)).trim().toLowerCase();
|
|
242
|
+
if (ok === "n" || ok === "no") {
|
|
243
|
+
log(
|
|
244
|
+
" Skipped. Install later with `parachute-scribe install-backend` or re-run this wizard.",
|
|
245
|
+
);
|
|
246
|
+
return 0;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Install the scribe module first, recording the resolved provider so install
|
|
251
|
+
// doesn't prompt for one. (We UNDO this record below if the engine install
|
|
252
|
+
// fails — so a failure never leaves a dead provider string.)
|
|
253
|
+
log("");
|
|
254
|
+
log(" Installing the scribe module…");
|
|
255
|
+
const moduleCode = await runCommand([
|
|
256
|
+
"parachute",
|
|
257
|
+
"install",
|
|
258
|
+
"scribe",
|
|
259
|
+
"--scribe-provider",
|
|
260
|
+
provider,
|
|
261
|
+
]);
|
|
262
|
+
if (moduleCode !== 0) {
|
|
263
|
+
clearScribeProvider(opts.configDir);
|
|
264
|
+
log(` ✗ scribe module install returned ${moduleCode} — not recording a local provider.`);
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Run scribe's OWN runnable install routine (scribe PR #79). It apt/pip-
|
|
269
|
+
// installs the engine, warm-pulls the model, and exits non-zero on hard
|
|
270
|
+
// failure (no engine on PATH, too little RAM on its own re-check, etc.).
|
|
271
|
+
log("");
|
|
272
|
+
log(` Installing the ${provider} engine via scribe (this can take a few minutes)…`);
|
|
273
|
+
// The `--provider <p>` FLAG form is intentional (not the positional
|
|
274
|
+
// `install-backend <p>`): scribe's cmdInstallBackend reads it via getFlag
|
|
275
|
+
// when args[1] starts with "-". If scribe ever moves off its module-level
|
|
276
|
+
// `args` global, re-confirm this flag is still honored.
|
|
277
|
+
const code = await runCommand(["parachute-scribe", "install-backend", "--provider", provider]);
|
|
278
|
+
if (code !== 0) {
|
|
279
|
+
// HONEST skip — undo the provisional provider record; do NOT leave a dead
|
|
280
|
+
// provider string scribe can't honor.
|
|
281
|
+
clearScribeProvider(opts.configDir);
|
|
282
|
+
log("");
|
|
283
|
+
log(` ✗ ${provider} install failed (exit ${code}); not recording it as the provider.`);
|
|
284
|
+
log(
|
|
285
|
+
" Cloud alternative: `parachute install scribe --scribe-provider groq --scribe-key gsk_…`",
|
|
286
|
+
);
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Engine installed + verified by scribe — keep the provider recorded (the
|
|
291
|
+
// install step already wrote it) and restart so the running scribe picks it up.
|
|
292
|
+
log("");
|
|
293
|
+
log(` ✓ ${provider} installed and recorded as the transcription provider.`);
|
|
294
|
+
await runCommand(["parachute", "restart", "scribe"]);
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
package/src/commands/wizard.ts
CHANGED
|
@@ -39,8 +39,10 @@
|
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
41
|
import { createInterface } from "node:readline/promises";
|
|
42
|
+
import { configDir } from "../config.ts";
|
|
42
43
|
import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
|
|
43
44
|
import { SESSION_COOKIE_NAME } from "../sessions.ts";
|
|
45
|
+
import { type WizardCommandRunner, walkTranscriptionStep } from "./wizard-transcription.ts";
|
|
44
46
|
|
|
45
47
|
const POLL_INTERVAL_MS = 2000;
|
|
46
48
|
const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes — generous enough for a slow `bun add` over a flaky connection.
|
|
@@ -111,6 +113,29 @@ export interface RunCliWizardOpts {
|
|
|
111
113
|
* the wizard's.
|
|
112
114
|
*/
|
|
113
115
|
exposeMode?: "localhost" | "tailnet" | "public";
|
|
116
|
+
/**
|
|
117
|
+
* `~/.parachute` (or the PARACHUTE_HOME override) — where scribe's config
|
|
118
|
+
* lives. Required for the transcription step to write the chosen provider +
|
|
119
|
+
* key. Init passes its resolved configDir; when absent the transcription
|
|
120
|
+
* step is skipped (the wizard can still walk account / vault / expose).
|
|
121
|
+
*/
|
|
122
|
+
configDir?: string;
|
|
123
|
+
/**
|
|
124
|
+
* Pre-supply the transcription answer (non-interactive escape, mirrors the
|
|
125
|
+
* browser wizard's `scribe_provider`). `none` | `local` | `groq` | `openai`.
|
|
126
|
+
*/
|
|
127
|
+
transcribeMode?: "none" | "local" | "groq" | "openai";
|
|
128
|
+
/** Pre-supplied cloud transcription API key (groq / openai). */
|
|
129
|
+
transcribeApiKey?: string;
|
|
130
|
+
/**
|
|
131
|
+
* Test seam: replace the transcription step's subprocess runner so tests
|
|
132
|
+
* never install scribe / shell out. Threaded into `walkTranscriptionStep`.
|
|
133
|
+
*/
|
|
134
|
+
transcribeRunCommand?: WizardCommandRunner;
|
|
135
|
+
/** Test seam: platform override for the transcription step. */
|
|
136
|
+
platform?: NodeJS.Platform;
|
|
137
|
+
/** Test seam: available-RAM override (MiB) for the transcription step's gate. */
|
|
138
|
+
availableRamMib?: number | null;
|
|
114
139
|
}
|
|
115
140
|
|
|
116
141
|
/** Cookie jar — tiny, in-memory, no persistence across runs. */
|
|
@@ -395,9 +420,14 @@ async function pollOperation(
|
|
|
395
420
|
}
|
|
396
421
|
}
|
|
397
422
|
|
|
398
|
-
/**
|
|
423
|
+
/**
|
|
424
|
+
* Validate password length up front so we don't bounce off a 400 the operator
|
|
425
|
+
* can't see. Floor is 12 to match the server (`PASSWORD_MIN_LEN`) AND this
|
|
426
|
+
* wizard's own "min 12 chars" copy at the password prompt — the validator was
|
|
427
|
+
* stuck at 8, contradicting both (onboarding-streamline hub PR1).
|
|
428
|
+
*/
|
|
399
429
|
function validatePassword(pw: string): string | undefined {
|
|
400
|
-
if (pw.length <
|
|
430
|
+
if (pw.length < 12) return "Password must be at least 12 characters.";
|
|
401
431
|
return undefined;
|
|
402
432
|
}
|
|
403
433
|
|
|
@@ -743,6 +773,24 @@ export async function runCliWizard(opts: RunCliWizardOpts): Promise<number> {
|
|
|
743
773
|
if (code !== 0) return code;
|
|
744
774
|
state = await fetchWizardState(hubUrl, jar, fetchImpl);
|
|
745
775
|
}
|
|
776
|
+
// Transcription step (onboarding-streamline hub PR1) — the CLI's parity with
|
|
777
|
+
// the browser wizard's folded scribe sub-form. The hub's setup-state machine
|
|
778
|
+
// has no "transcription" step (scribe is a module install, not a wizard gate),
|
|
779
|
+
// so this runs unconditionally between vault and expose rather than off
|
|
780
|
+
// `state.step`. Skipped without a configDir (nowhere to write scribe config).
|
|
781
|
+
// Non-fatal: a transcription that couldn't be set up never blocks setup.
|
|
782
|
+
if (opts.configDir !== undefined) {
|
|
783
|
+
await walkTranscriptionStep({
|
|
784
|
+
configDir: opts.configDir,
|
|
785
|
+
log,
|
|
786
|
+
prompt,
|
|
787
|
+
...(opts.transcribeMode !== undefined ? { transcribeMode: opts.transcribeMode } : {}),
|
|
788
|
+
...(opts.transcribeApiKey !== undefined ? { transcribeApiKey: opts.transcribeApiKey } : {}),
|
|
789
|
+
...(opts.transcribeRunCommand !== undefined ? { runCommand: opts.transcribeRunCommand } : {}),
|
|
790
|
+
...(opts.platform !== undefined ? { platform: opts.platform } : {}),
|
|
791
|
+
...(opts.availableRamMib !== undefined ? { availableRamMib: opts.availableRamMib } : {}),
|
|
792
|
+
});
|
|
793
|
+
}
|
|
746
794
|
if (state.step === "expose") {
|
|
747
795
|
const code = await walkExposeStep(hubUrl, jar, state, ctx);
|
|
748
796
|
if (code !== 0) return code;
|
|
@@ -852,6 +900,21 @@ export function parseWizardArgs(args: readonly string[]): ParsedWizardArgs | { e
|
|
|
852
900
|
}
|
|
853
901
|
out.opts.exposeMode = value;
|
|
854
902
|
break;
|
|
903
|
+
case "--transcribe-mode":
|
|
904
|
+
if (!consumeValue()) return { error: `${key} requires a value` };
|
|
905
|
+
if (value !== "none" && value !== "local" && value !== "groq" && value !== "openai") {
|
|
906
|
+
return { error: `${key} must be one of none, local, groq, openai` };
|
|
907
|
+
}
|
|
908
|
+
out.opts.transcribeMode = value;
|
|
909
|
+
break;
|
|
910
|
+
case "--transcribe-key":
|
|
911
|
+
if (!consumeValue()) return { error: `${key} requires a value` };
|
|
912
|
+
out.opts.transcribeApiKey = value;
|
|
913
|
+
break;
|
|
914
|
+
case "--config-dir":
|
|
915
|
+
if (!consumeValue()) return { error: `${key} requires a path` };
|
|
916
|
+
out.opts.configDir = value;
|
|
917
|
+
break;
|
|
855
918
|
default:
|
|
856
919
|
if (a.startsWith("--")) return { error: `unknown argument "${a}"` };
|
|
857
920
|
return { error: `unexpected positional argument "${a}"` };
|
|
@@ -877,12 +940,19 @@ export async function runSetupWizardCommand(args: readonly string[]): Promise<nu
|
|
|
877
940
|
" [--bootstrap-token <token>]\n" +
|
|
878
941
|
" [--vault-mode create|import|skip] [--vault-name <name>]\n" +
|
|
879
942
|
" [--vault-import-url <url>] [--vault-import-pat <pat>] [--vault-import-replace]\n" +
|
|
943
|
+
" [--transcribe-mode none|local|groq|openai] [--transcribe-key <key>]\n" +
|
|
944
|
+
" [--config-dir <path>]\n" +
|
|
880
945
|
" [--expose-mode localhost|tailnet|public]",
|
|
881
946
|
);
|
|
882
947
|
return 1;
|
|
883
948
|
}
|
|
949
|
+
// Default configDir from PARACHUTE_HOME (matching the rest of the CLI) so the
|
|
950
|
+
// standalone `parachute setup-wizard` invocation can run the transcription
|
|
951
|
+
// step. An explicit `--config-dir` wins.
|
|
952
|
+
const wizardOpts = { ...parsed.opts };
|
|
953
|
+
if (wizardOpts.configDir === undefined) wizardOpts.configDir = configDir();
|
|
884
954
|
return await runCliWizard({
|
|
885
|
-
...
|
|
955
|
+
...wizardOpts,
|
|
886
956
|
log: (line) => console.log(line),
|
|
887
957
|
});
|
|
888
958
|
}
|
package/src/hub-origin.ts
CHANGED
|
@@ -14,6 +14,70 @@
|
|
|
14
14
|
|
|
15
15
|
export const HUB_ORIGIN_ENV = "PARACHUTE_HUB_ORIGIN";
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* The env var carrying the SET of origins the hub legitimately answers on,
|
|
19
|
+
* comma-separated (multi-origin iss-set, onboarding-streamline 2026-06-25).
|
|
20
|
+
*
|
|
21
|
+
* Supervised resource servers (vault, scribe) read this in addition to the
|
|
22
|
+
* single canonical `PARACHUTE_HUB_ORIGIN` and widen their accepted-`iss` check
|
|
23
|
+
* from one string to this set — so a token minted under one URL of a
|
|
24
|
+
* multi-URL box (loopback ∪ `<ip>.sslip.io` ∪ a custom domain behind Caddy)
|
|
25
|
+
* validates when the resource is reached via another URL of the SAME box. The
|
|
26
|
+
* signing key is stable + origin-independent, so only the `iss` string varies.
|
|
27
|
+
*
|
|
28
|
+
* SECURITY INVARIANT: the value MUST be the hub's `buildHubBoundOrigins`
|
|
29
|
+
* output — configured issuer ∪ loopback aliases ∪ expose-state public origin ∪
|
|
30
|
+
* platform origin — published BY THE HUB. It must NEVER contain an unvalidated
|
|
31
|
+
* request `Host` / `X-Forwarded-Host`. Accepting `iss ∈ this-set` is safe
|
|
32
|
+
* ONLY because the resource server's JWKS signature verify runs first and
|
|
33
|
+
* proves the hub minted the token.
|
|
34
|
+
*
|
|
35
|
+
* `PARACHUTE_HUB_ORIGIN` (the single canonical origin) is ALWAYS written
|
|
36
|
+
* alongside this var for back-compat: a resource server on an older
|
|
37
|
+
* scope-guard that doesn't read `PARACHUTE_HUB_ORIGINS` keeps its existing
|
|
38
|
+
* single-origin behavior.
|
|
39
|
+
*/
|
|
40
|
+
export const HUB_ORIGINS_ENV = "PARACHUTE_HUB_ORIGINS";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Serialize an origin SET into the comma-separated `PARACHUTE_HUB_ORIGINS`
|
|
44
|
+
* wire form. Dedupes, drops empties, preserves first-seen order. Returns
|
|
45
|
+
* `undefined` when nothing survives (caller skips setting the env var so a
|
|
46
|
+
* resource server falls back to single-origin behavior). Pair with
|
|
47
|
+
* `buildHubBoundOrigins` to produce `origins`.
|
|
48
|
+
*/
|
|
49
|
+
export function serializeHubOrigins(origins: readonly string[]): string | undefined {
|
|
50
|
+
const seen = new Set<string>();
|
|
51
|
+
for (const raw of origins) {
|
|
52
|
+
if (typeof raw !== "string") continue;
|
|
53
|
+
const trimmed = raw.trim().replace(/\/+$/, "");
|
|
54
|
+
if (trimmed.length > 0) seen.add(trimmed);
|
|
55
|
+
}
|
|
56
|
+
if (seen.size === 0) return undefined;
|
|
57
|
+
return Array.from(seen).join(",");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse the comma-separated `PARACHUTE_HUB_ORIGINS` wire form back into an
|
|
62
|
+
* origin array. Tolerant of surrounding whitespace + trailing slashes + empty
|
|
63
|
+
* segments. The resource-server inverse of `serializeHubOrigins`; lives here
|
|
64
|
+
* (rather than only in the consumer adapters) so the hub's own injection tests
|
|
65
|
+
* can round-trip against the canonical parser. Returns `[]` for an
|
|
66
|
+
* absent/empty/garbage value.
|
|
67
|
+
*/
|
|
68
|
+
export function parseHubOrigins(raw: string | undefined): string[] {
|
|
69
|
+
if (!raw) return [];
|
|
70
|
+
const out: string[] = [];
|
|
71
|
+
const seen = new Set<string>();
|
|
72
|
+
for (const seg of raw.split(",")) {
|
|
73
|
+
const trimmed = seg.trim().replace(/\/+$/, "");
|
|
74
|
+
if (trimmed.length === 0 || seen.has(trimmed)) continue;
|
|
75
|
+
seen.add(trimmed);
|
|
76
|
+
out.push(trimmed);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
17
81
|
export interface DeriveHubOriginOpts {
|
|
18
82
|
/** Explicit user override (e.g., `--hub-origin`). Wins over everything else. */
|
|
19
83
|
override?: string;
|
package/src/jwt-sign.ts
CHANGED
|
@@ -55,9 +55,20 @@ export interface SignAccessTokenOpts {
|
|
|
55
55
|
clientId: string;
|
|
56
56
|
/**
|
|
57
57
|
* Hub origin — sets the `iss` claim. Required: every consumer (vault,
|
|
58
|
-
* scribe,
|
|
59
|
-
*
|
|
60
|
-
*
|
|
58
|
+
* scribe, agent) validates `iss`, and a missing claim is rejected. Callers
|
|
59
|
+
* derive this via `deriveHubOrigin()` or thread it from `OAuthDeps.issuer`
|
|
60
|
+
* (the per-request origin from `resolveIssuer`).
|
|
61
|
+
*
|
|
62
|
+
* Multi-origin iss-set (onboarding-streamline 2026-06-25): consumers no
|
|
63
|
+
* longer pin `iss` to a SINGLE `PARACHUTE_HUB_ORIGIN`. The hub publishes the
|
|
64
|
+
* SET of origins it legitimately answers on via `PARACHUTE_HUB_ORIGINS`
|
|
65
|
+
* (`buildHubBoundOrigins` — issuer ∪ loopback ∪ expose-state ∪ platform),
|
|
66
|
+
* and a scope-guard ≥0.5.0 consumer accepts `iss` ∈ that set. So a token
|
|
67
|
+
* minted here with one URL of a multi-URL box validates when the resource is
|
|
68
|
+
* reached via another URL of the SAME box. Mint stays per-request as-is: the
|
|
69
|
+
* canonical configured origin (the intended Caddy/zero-SSH deploy) is minted
|
|
70
|
+
* on every request AND is a member of the published set, so mint and validate
|
|
71
|
+
* already align — no clamp needed here.
|
|
61
72
|
*/
|
|
62
73
|
issuer: string;
|
|
63
74
|
/**
|
package/src/scribe-config.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { totalmem } from "node:os";
|
|
2
3
|
import { dirname, join } from "node:path";
|
|
3
4
|
import { parseEnvFile, upsertEnvLine, writeEnvFile } from "./env-file.ts";
|
|
4
5
|
|
|
@@ -60,6 +61,118 @@ export type ScribeProviderKey = (typeof SCRIBE_PROVIDERS)[number]["key"];
|
|
|
60
61
|
/** Default provider scribe falls back to when the config doesn't pick one. */
|
|
61
62
|
export const SCRIBE_DEFAULT_PROVIDER: ScribeProviderKey = "parakeet-mlx";
|
|
62
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the "local" choice to the CORRECT platform backend. The setup
|
|
66
|
+
* wizard (browser + CLI) lets the operator pick "local" without knowing the
|
|
67
|
+
* engine name; this picks the one that actually runs here.
|
|
68
|
+
*
|
|
69
|
+
* - macOS → `parakeet-mlx` (Apple Silicon MLX)
|
|
70
|
+
* - Linux → `onnx-asr` (cross-platform Sherpa-ONNX)
|
|
71
|
+
* - other → `null` (no local backend — steer to cloud)
|
|
72
|
+
*
|
|
73
|
+
* Mirrors scribe's own `platformLocalProvider` (parachute-scribe
|
|
74
|
+
* src/install-backend.ts) so hub and scribe can't drift on the mapping.
|
|
75
|
+
* Fixes the long-standing bug where the wizard mapped `local` UNCONDITIONALLY
|
|
76
|
+
* to `parakeet-mlx`, which silently fails on every Linux box (the common
|
|
77
|
+
* DigitalOcean / VPS deploy).
|
|
78
|
+
*/
|
|
79
|
+
export function platformLocalProvider(
|
|
80
|
+
platform: NodeJS.Platform,
|
|
81
|
+
): "parakeet-mlx" | "onnx-asr" | null {
|
|
82
|
+
if (platform === "darwin") return "parakeet-mlx";
|
|
83
|
+
if (platform === "linux") return "onnx-asr";
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Minimum available RAM (MiB) below which a local ASR model would be
|
|
89
|
+
* OOM-killed. Mirrors scribe's `MIN_RAM_MIB` (parachute-scribe
|
|
90
|
+
* src/install-backend.ts) — the 1 GB DigitalOcean droplet is the box this
|
|
91
|
+
* guards against; a local Parakeet/ONNX model needs ~2 GB to load.
|
|
92
|
+
*/
|
|
93
|
+
export const MIN_RAM_MIB = 2048;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Available RAM in MiB, or `null` when it can't be determined.
|
|
97
|
+
*
|
|
98
|
+
* Linux: reads `MemAvailable` from `/proc/meminfo` (the honest figure — free
|
|
99
|
+
* plus reclaimable cache), matching scribe's probe so the two layers agree on
|
|
100
|
+
* the same droplet. Falls back to `MemFree` on very old kernels that predate
|
|
101
|
+
* MemAvailable.
|
|
102
|
+
*
|
|
103
|
+
* Non-Linux: falls back to `os.totalmem()` (there's no MemAvailable analogue;
|
|
104
|
+
* total is a coarse upper bound but enough to keep a tiny VM from offering
|
|
105
|
+
* local). macOS dev boxes comfortably clear the floor, so the coarseness is
|
|
106
|
+
* harmless there.
|
|
107
|
+
*
|
|
108
|
+
* Sync by design: the wizard's provider-decision path is synchronous, and the
|
|
109
|
+
* `/proc/meminfo` read is a tiny file. Tests inject `availableRamMib` directly
|
|
110
|
+
* to exercise the gate without touching the real host.
|
|
111
|
+
*/
|
|
112
|
+
export function readAvailableRamMib(platform: NodeJS.Platform = process.platform): number | null {
|
|
113
|
+
if (platform === "linux") {
|
|
114
|
+
try {
|
|
115
|
+
const text = readFileSync("/proc/meminfo", "utf8");
|
|
116
|
+
const avail = /^MemAvailable:\s+(\d+)\s*kB/m.exec(text);
|
|
117
|
+
const free = /^MemFree:\s+(\d+)\s*kB/m.exec(text);
|
|
118
|
+
const kb = avail ? Number(avail[1]) : free ? Number(free[1]) : null;
|
|
119
|
+
if (kb === null || !Number.isFinite(kb)) return null;
|
|
120
|
+
return Math.floor(kb / 1024);
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Non-Linux: total physical memory as a coarse fallback.
|
|
126
|
+
try {
|
|
127
|
+
const bytes = totalmem();
|
|
128
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return null;
|
|
129
|
+
return Math.floor(bytes / (1024 * 1024));
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Decide whether a LOCAL transcription provider is offerable / acceptable on
|
|
137
|
+
* this host, and if not, why + what to steer to. Both wizard surfaces (browser
|
|
138
|
+
* POST handler + CLI) consult this BEFORE recording a `local` choice so we
|
|
139
|
+
* never write a dead provider string that scribe can't run.
|
|
140
|
+
*
|
|
141
|
+
* `ok: false` carries a `reason` (shown inline) and a `steerTo` cloud provider
|
|
142
|
+
* the caller should redirect to.
|
|
143
|
+
*/
|
|
144
|
+
export interface LocalProviderDecision {
|
|
145
|
+
ok: boolean;
|
|
146
|
+
/** The resolved platform backend when `ok` (parakeet-mlx / onnx-asr). */
|
|
147
|
+
provider?: "parakeet-mlx" | "onnx-asr";
|
|
148
|
+
/** Human-readable reason when `ok` is false (no local backend / too little RAM). */
|
|
149
|
+
reason?: string;
|
|
150
|
+
/** Cloud provider to redirect to when local is unavailable. */
|
|
151
|
+
steerTo?: "groq";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function decideLocalProvider(
|
|
155
|
+
platform: NodeJS.Platform,
|
|
156
|
+
availableRamMib: number | null,
|
|
157
|
+
): LocalProviderDecision {
|
|
158
|
+
const provider = platformLocalProvider(platform);
|
|
159
|
+
if (provider === null) {
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
reason: `No local transcription backend runs on "${platform}". Use a cloud provider instead.`,
|
|
163
|
+
steerTo: "groq",
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (availableRamMib !== null && availableRamMib < MIN_RAM_MIB) {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
reason: `This box has ${availableRamMib} MiB available RAM, below the ${MIN_RAM_MIB} MiB a local ASR model needs (it would be OOM-killed). Use a cloud provider instead — groq is fast (~$0.04/hr of audio).`,
|
|
170
|
+
steerTo: "groq",
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return { ok: true, provider };
|
|
174
|
+
}
|
|
175
|
+
|
|
63
176
|
export function isKnownScribeProvider(value: string): value is ScribeProviderKey {
|
|
64
177
|
return SCRIBE_PROVIDERS.some((p) => p.key === value);
|
|
65
178
|
}
|
|
@@ -136,6 +249,38 @@ export function writeScribeProvider(configDir: string, provider: ScribeProviderK
|
|
|
136
249
|
renameSync(tmp, path);
|
|
137
250
|
}
|
|
138
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Remove `transcribe.provider` from scribe's config.json (preserving every
|
|
254
|
+
* other key). Used by the wizard's local-install path to UNDO a provisional
|
|
255
|
+
* provider record when the engine install fails — so we never leave a dead
|
|
256
|
+
* provider string scribe can't honor. A no-op when the file or the
|
|
257
|
+
* `transcribe` block is absent.
|
|
258
|
+
*/
|
|
259
|
+
export function clearScribeProvider(configDir: string): void {
|
|
260
|
+
const path = scribeConfigPath(configDir);
|
|
261
|
+
if (!existsSync(path)) return;
|
|
262
|
+
let current: Record<string, unknown>;
|
|
263
|
+
try {
|
|
264
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
265
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
|
|
266
|
+
current = parsed as Record<string, unknown>;
|
|
267
|
+
} catch {
|
|
268
|
+
return; // malformed → leave it; auto-wire repairs on next run
|
|
269
|
+
}
|
|
270
|
+
const transcribe = current.transcribe;
|
|
271
|
+
if (typeof transcribe !== "object" || transcribe === null || Array.isArray(transcribe)) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// Drop `provider` from the transcribe block (destructure-omit, no `delete`).
|
|
275
|
+
const { provider: _dropped, ...block } = transcribe as Record<string, unknown>;
|
|
276
|
+
const { transcribe: _omit, ...rest } = current;
|
|
277
|
+
const next: Record<string, unknown> =
|
|
278
|
+
Object.keys(block).length === 0 ? rest : { ...rest, transcribe: block };
|
|
279
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
280
|
+
writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`);
|
|
281
|
+
renameSync(tmp, path);
|
|
282
|
+
}
|
|
283
|
+
|
|
139
284
|
/**
|
|
140
285
|
* Idempotent upsert of a single `KEY=value` into `<configDir>/scribe/.env`.
|
|
141
286
|
* Used for the API-key prompt result. Other lines (auto-wire keys, manual
|