ossclip 0.1.4 → 0.1.6

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/src/program.ts ADDED
@@ -0,0 +1,401 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { Command, InvalidArgumentError } from "commander";
5
+ import { z } from "zod/v4";
6
+ import { CleanupLevelSchema, SceneComponentIdSchema } from "@ossclip/core";
7
+ import { STUDIO_ENTRY } from "@ossclip/renderer";
8
+ import { loadEnvFiles } from "./env";
9
+ import { produce } from "./produce";
10
+
11
+ // Before anything reads a provider key (R16 §77) — including the auto-detect
12
+ // order in `defaultProviderName`, which decides which model runs.
13
+ const envFiles = loadEnvFiles();
14
+
15
+ /**
16
+ * Every command this CLI has, built onto a fresh instance.
17
+ *
18
+ * Exported so the wizard's drift guard (test/produce-argv-roundtrip.test.ts)
19
+ * parses wizard argv against the REAL program. It used to parse against a
20
+ * hand-declared replica of thirteen options, which is exactly how
21
+ * `--whisper-model` gets renamed here, keeps being accepted there, and ships
22
+ * a wizard that teaches a flag the CLI no longer has — the drift the argv
23
+ * architecture was chosen to prevent.
24
+ *
25
+ * Every action closes over the LOCAL instance: the wizard hands its argv back
26
+ * through `program.parseAsync`, and that re-entry has to land on the program
27
+ * that is actually running, not on a module-level one.
28
+ */
29
+ export function buildProgram(): Command {
30
+ const program = new Command();
31
+
32
+ program
33
+ .name("ossclip")
34
+ .description(
35
+ "local-first video producer: cuts silence and fillers, word-timed captions, " +
36
+ "face-aware framing, LLM-planned code-rendered graphics",
37
+ )
38
+ // Read from the manifest, never hardcoded (R22 §113): a literal here said
39
+ // "0.1.0" for every release after it, so `--version` reported the number a
40
+ // developer typed rather than the one npm installed — the exact field a
41
+ // bug report is judged by. npm always packs package.json regardless of
42
+ // `files`, so this resolves in a published install too.
43
+ .version(
44
+ (
45
+ JSON.parse(
46
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
47
+ ) as { version: string }
48
+ ).version,
49
+ );
50
+
51
+ // Bare `ossclip` at a TTY opens the menu. Piped or in CI it prints help,
52
+ // byte for byte what it printed before — a front door must not become a
53
+ // hang for a script.
54
+ program.action(async () => {
55
+ const { isInteractive } = await import("./interactive/tty");
56
+ if (!isInteractive()) {
57
+ program.outputHelp();
58
+ return;
59
+ }
60
+ const { chooseFromMenu, menuArgv } = await import("./interactive/menu");
61
+ const choice = await chooseFromMenu();
62
+ const direct = menuArgv(choice);
63
+ const { renderCommand } = await import("./interactive/render");
64
+ if (direct !== null) {
65
+ // Echoed for the same reason the wizard echoes: the README promises
66
+ // "every choice prints the equivalent command before it runs, so the
67
+ // menu is also how you learn the flags", and three of the four entries
68
+ // printed nothing. The menu's whole pedagogical point is this line.
69
+ console.log(`\n▸ running:\n ${renderCommand(direct)}\n`);
70
+ await program.parseAsync(["node", "ossclip", ...direct]);
71
+ return;
72
+ }
73
+ const { produceWizard } = await import("./interactive/produce-wizard");
74
+ const { loadConfig } = await import("@ossclip/core");
75
+ const argv = await produceWizard({ speaker: loadConfig().speaker });
76
+ console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
77
+ await program.parseAsync(["node", "ossclip", ...argv]);
78
+ });
79
+
80
+ program
81
+ .command("produce")
82
+ .description("transcribe → analyze → cut → captions → render")
83
+ // OPTIONAL so a bare `ossclip produce` at a TTY opens the wizard instead of
84
+ // printing a usage error at somebody who does not yet know the flags. A
85
+ // non-interactive run still gets commander's "missing required argument".
86
+ .argument("[input]", "input video file")
87
+ .option("-o, --out <path>", "output video path (default: <input>.ossclip.mp4)")
88
+ .option("--cleanup <level>", "exact | light | standard | aggressive", "standard")
89
+ .option("--transcript <path>", "inject a transcript JSON instead of running whisper")
90
+ .option("--no-render", "stop after writing production.json / render props")
91
+ .option(
92
+ "--no-mezzanine",
93
+ "render straight from the source instead of a dense-keyframe mezzanine " +
94
+ "(also makes the source's folder the render server's public dir)",
95
+ )
96
+ .option("--noise-db <db>", "override the measured silence threshold, e.g. -30", parseFloat)
97
+ .option("--workdir <dir>", "cache/work directory (default: <input dir>/.ossclip)")
98
+ .option(
99
+ "--aspect <ratio>",
100
+ "output shape: 9:16 (vertical, default) or 16:9 (landscape, 1920x1080)",
101
+ "9:16",
102
+ )
103
+ .option("--produce", "run the LLM producer brain to plan title cards & graphics", false)
104
+ .option(
105
+ "--clip <seconds>",
106
+ "produce only the strongest ~N-second window of a long take (requires --produce; " +
107
+ "a source already at or under the target is produced whole)",
108
+ (v: string) => {
109
+ // §93a: reject rather than coerce — `--clip 0`, negatives and typos must
110
+ // not silently become "no clip" or NaN-length windows.
111
+ const n = Number.parseFloat(v);
112
+ if (!Number.isFinite(n) || n <= 0) {
113
+ throw new InvalidArgumentError(`--clip wants a positive number of seconds, got "${v}"`);
114
+ }
115
+ return n;
116
+ },
117
+ )
118
+ .option(
119
+ "--clip-window <start:end>",
120
+ "internal: the resolved highlight's word range, recorded into command.json by --clip " +
121
+ "runs so the editor's Render replays the same window without an LLM call",
122
+ )
123
+ .option("--intent <text>", "what the video should be ('educational video about agents…')")
124
+ .option(
125
+ "--llm <provider>",
126
+ "claude | claude-cli | gemini | mock. Default: claude if ANTHROPIC_API_KEY is set, " +
127
+ "else claude-cli (your logged-in Claude Code — Pro/Max subscription, no API charges)",
128
+ )
129
+ .option("--llm-model <id>", "override the provider's default model")
130
+ .option(
131
+ "--llm-fast-model <id>",
132
+ "model for mechanical calls (repair, scene props); 'same' disables tiering",
133
+ )
134
+ .option(
135
+ "--speaker <who>",
136
+ 'who is on camera, e.g. "Ahsan, host of Code with Ahsan" — helps repair recognise mangled names',
137
+ )
138
+ .option("--scenes <path>", "hand-authored scenes JSON (Scene[]) — no LLM in the loop")
139
+ .option(
140
+ "--no-repair",
141
+ "skip the ASR mishearing repair pass (captions then show the raw transcription)",
142
+ )
143
+ .option("--whisper-model <name>", "transcription model for this run, e.g. base.en | small.en | medium.en")
144
+ .option(
145
+ "--force-component <id>",
146
+ "debug: render every graphic with this component (e.g. FlowDiagram) to exercise it on real copy",
147
+ )
148
+ .option(
149
+ "--source-fit <mode>",
150
+ "cover | contain. cover (default) crops the source to fill the vertical " +
151
+ "frame; contain shows the WHOLE frame inset against the backdrop — the " +
152
+ "answer for a landscape take whose content matters beyond the speaker",
153
+ "cover",
154
+ )
155
+ .option(
156
+ "--source-is-edited",
157
+ "the source is already an edited reel with burned-in text — keep ossclip's graphics off it without waiting on detection",
158
+ )
159
+ .option(
160
+ "--blooper-marker <word>",
161
+ "cut the flubbed take whenever you say this word out loud (e.g. blooper): " +
162
+ "removal runs back to the start of the sentence it spoiled. Off unless given",
163
+ )
164
+ .option("--no-cover", "skip the cover image written beside the video")
165
+ .option("--cover <path>", "cover image output path (default: <out>.cover.jpg)")
166
+ .option("--open-editor", "open the editor when the run finishes")
167
+ .option(
168
+ "--no-open-editor",
169
+ "don't open the editor, and don't ask (overrides openEditorAfterProduce)",
170
+ )
171
+ .option("--editor-port <n>", "port for the editor started by --open-editor",
172
+ (v) => Number.parseInt(v, 10), 5174)
173
+ .action(async (input: string | undefined, opts, command: Command) => {
174
+ if (input === undefined) {
175
+ // commander 12's parseAsync does not reset option state between calls,
176
+ // so a flag typed alongside a bare `produce` would survive into the
177
+ // wizard's own parse and silently override the answer the user just
178
+ // gave — while `▸ running:` printed a command without it. Refusing is
179
+ // the only shape where the printed command is always the run.
180
+ const typedFlags = command.options.some(
181
+ (o) => command.getOptionValueSource(o.attributeName()) === "cli",
182
+ );
183
+ if (typedFlags) {
184
+ throw new Error(
185
+ "pass the input file when you pass flags — bare `ossclip produce` opens the wizard instead",
186
+ );
187
+ }
188
+ const { isInteractive } = await import("./interactive/tty");
189
+ if (!isInteractive()) {
190
+ throw new Error("missing required argument 'input' — the video file to produce");
191
+ }
192
+ const { produceWizard } = await import("./interactive/produce-wizard");
193
+ const { renderCommand } = await import("./interactive/render");
194
+ const { loadConfig } = await import("@ossclip/core");
195
+ const argv = await produceWizard({ speaker: loadConfig().speaker });
196
+ console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
197
+ // Re-entering the SAME parse the flags take: the zod checks below run
198
+ // on wizard output exactly as they do on a typed command line.
199
+ await program.parseAsync(["node", "ossclip", ...argv]);
200
+ return;
201
+ }
202
+ // Say which keys came from a file — never the keys themselves. A run that
203
+ // picks a provider from a `.env` should say where that came from.
204
+ if (envFiles.length > 0) console.log(`▸ env: ${envFiles.join(", ")}`);
205
+ const cleanup = CleanupLevelSchema.parse(opts.cleanup);
206
+ const provider = opts.llm
207
+ ? z.enum(["claude", "claude-cli", "gemini", "mock"]).parse(opts.llm)
208
+ : undefined;
209
+ const forceComponent = opts.forceComponent
210
+ ? SceneComponentIdSchema.parse(opts.forceComponent)
211
+ : undefined;
212
+ // Parsed, not coerced: a typo'd `--source-fit containn` silently falling
213
+ // back to cover is exactly the crop the flag exists to prevent.
214
+ const sourceFit = z.enum(["cover", "contain"]).parse(opts.sourceFit);
215
+ const result = await produce(input, {
216
+ out: opts.out,
217
+ cleanup,
218
+ transcript: opts.transcript,
219
+ render: opts.render,
220
+ mezzanine: opts.mezzanine,
221
+ workdir: opts.workdir,
222
+ aspect: opts.aspect === "16:9" ? "16:9" : "9:16",
223
+ noiseDb: opts.noiseDb,
224
+ produce: opts.produce,
225
+ intent: opts.intent,
226
+ provider,
227
+ llmModel: opts.llmModel,
228
+ llmFastModel: opts.llmFastModel,
229
+ speaker: opts.speaker,
230
+ scenes: opts.scenes,
231
+ repair: opts.repair,
232
+ whisperModel: opts.whisperModel,
233
+ forceComponent,
234
+ // commander gives `--no-cover` as cover:false and `--cover <path>` as a
235
+ // string on the same key.
236
+ sourceIsEdited: opts.sourceIsEdited === true,
237
+ blooperMarker: opts.blooperMarker,
238
+ sourceFit,
239
+ cover: opts.cover !== false,
240
+ coverPath: typeof opts.cover === "string" ? opts.cover : undefined,
241
+ clip: opts.clip,
242
+ clipWindow: opts.clipWindow,
243
+ });
244
+ const { offerEditor } = await import("./interactive/offer-editor");
245
+ await offerEditor(result, { flag: opts.openEditor, port: opts.editorPort });
246
+ });
247
+
248
+ program
249
+ .command("transcribe")
250
+ .description("run the pipeline up to the transcript and cut report, no render")
251
+ .argument("<input>", "input video file")
252
+ .option("--cleanup <level>", "exact | light | standard | aggressive", "standard")
253
+ .option("--transcript <path>", "inject a transcript JSON instead of running whisper")
254
+ .option("--noise-db <db>", "override the measured silence threshold, e.g. -30", parseFloat)
255
+ .option("--workdir <dir>", "cache/work directory")
256
+ .option("--whisper-model <name>", "transcription model for this run, e.g. base.en | small.en | medium.en")
257
+ .action(async (input: string, opts) => {
258
+ const cleanup = CleanupLevelSchema.parse(opts.cleanup);
259
+ await produce(input, {
260
+ cleanup,
261
+ transcript: opts.transcript,
262
+ render: false,
263
+ mezzanine: false,
264
+ workdir: opts.workdir,
265
+ noiseDb: opts.noiseDb,
266
+ whisperModel: opts.whisperModel,
267
+ });
268
+ });
269
+
270
+ program
271
+ .command("studio")
272
+ .description("open Remotion Studio on a produced composition (visual debugging)")
273
+ .argument("<renderProps>", "path to a work dir's render-props.json")
274
+ .option("--video-dir <dir>", "directory containing the source video (public dir)")
275
+ .action(async (renderProps: string, opts) => {
276
+ const propsPath = resolve(renderProps);
277
+ const publicDir = opts.videoDir ? resolve(opts.videoDir) : dirname(propsPath);
278
+ // Resolve Remotion's CLI through module resolution instead of spawning
279
+ // `pnpm` — a global `npm i -g ossclip` has no pnpm and no workspace, and
280
+ // Windows would need the .cmd shim. `@remotion/cli` is a dependency of
281
+ // @ossclip/renderer, so resolving from THERE works in both a clone and a
282
+ // published install, on every OS, run via the node that's running us.
283
+ const { createRequire } = await import("node:module");
284
+ let remotionCliJs: string;
285
+ try {
286
+ const require = createRequire(import.meta.url);
287
+ const rendererDir = dirname(require.resolve("@ossclip/renderer/package.json"));
288
+ const fromRenderer = createRequire(join(rendererDir, "package.json"));
289
+ const cliPkgPath = fromRenderer.resolve("@remotion/cli/package.json");
290
+ const cliPkg = JSON.parse(readFileSync(cliPkgPath, "utf8")) as {
291
+ bin: string | Record<string, string>;
292
+ };
293
+ const binRel = typeof cliPkg.bin === "string" ? cliPkg.bin : cliPkg.bin.remotion;
294
+ if (!binRel) throw new Error("no remotion bin entry");
295
+ remotionCliJs = join(dirname(cliPkgPath), binRel);
296
+ } catch {
297
+ throw new Error(
298
+ "couldn't resolve @remotion/cli — in a clone, run `pnpm install` first",
299
+ );
300
+ }
301
+ const child = spawn(
302
+ process.execPath,
303
+ [remotionCliJs, "studio", STUDIO_ENTRY, `--props=${propsPath}`, `--public-dir=${publicDir}`],
304
+ { stdio: "inherit" },
305
+ );
306
+ child.on("error", (e) => {
307
+ console.error(`✗ failed to start Remotion Studio: ${e.message}`);
308
+ process.exit(1);
309
+ });
310
+ child.on("exit", (code) => process.exit(code ?? 0));
311
+ });
312
+
313
+ program
314
+ .command("edit")
315
+ .description("open the editing page on a produced workdir")
316
+ // OPTIONAL since R17 §83: with no argument the editor opens on a project
317
+ // picker — recent produce runs plus a folder browser — and the top bar's
318
+ // Open button switches projects without restarting the server.
319
+ .argument("[workdir]", "a work directory containing render-props.json")
320
+ .option("--port <n>", "port to listen on", (v) => Number.parseInt(v, 10), 5174)
321
+ .option("--no-open", "do not open a browser")
322
+ .action(async (workdir: string | undefined, opts) => {
323
+ const { startEditServer, resolveEditorPageDir } = await import("./edit");
324
+ // An npm install ships the page prebuilt (editor-dist/); a clone builds
325
+ // it once with `pnpm build`. A server that starts fine but 404s every
326
+ // page request is the worst version of missing — fail loudly with the
327
+ // fix instead.
328
+ const pageDir = resolveEditorPageDir();
329
+ if (pageDir === null) {
330
+ throw new Error(
331
+ "editor UI isn't built yet — run `pnpm build` " +
332
+ "(or `pnpm --filter @ossclip/editor build`) once, then re-run `ossclip edit`.",
333
+ );
334
+ }
335
+
336
+ // With no argument the editor opens on its own project picker (R17 §83).
337
+ // With one, resolve what the user MEANT: `ossclip edit <video folder>`
338
+ // was the reported failure, and produce's output lives one level down.
339
+ let target: string | undefined = workdir;
340
+ if (workdir !== undefined) {
341
+ const { probeWorkdir } = await import("./interactive/workdir-probe");
342
+ const { resolveWorkdir, candidateListMessage } = await import("./interactive/resolve-workdir");
343
+ const { isInteractive } = await import("./interactive/tty");
344
+ const { dir, probe } = await probeWorkdir(workdir);
345
+ const resolution = resolveWorkdir(dir, probe);
346
+ if (resolution.kind === "none") throw new Error(resolution.message);
347
+ if (resolution.kind === "choose") {
348
+ if (!isInteractive()) {
349
+ throw new Error(candidateListMessage(dir, resolution.candidates));
350
+ }
351
+ const { pickWorkdir } = await import("./interactive/pick-workdir");
352
+ target = await pickWorkdir(resolution.candidates);
353
+ } else {
354
+ target = resolution.workdir;
355
+ // Say so when the path was not the one typed — a silent redirect
356
+ // leaves the user with the wrong mental model of where things live.
357
+ if (resolution.via === "nested") console.log(`▸ resolved ${workdir} → ${target}`);
358
+ }
359
+ }
360
+
361
+ const server = await startEditServer(target, { port: opts.port, pageDir });
362
+ console.log(`▸ editor at ${server.url}`);
363
+ if (opts.open) {
364
+ const { openInBrowser } = await import("./open");
365
+ openInBrowser(server.url);
366
+ }
367
+ });
368
+
369
+ program
370
+ .command("setup")
371
+ .description(
372
+ "install everything ossclip needs (ffmpeg, whisper.cpp, the transcription model) " +
373
+ "into ~/.ossclip — the one-command onboarding on macOS, Linux, and Windows",
374
+ )
375
+ .option("--model <name>", "transcription model to download (default: config, i.e. small.en)")
376
+ .option("--skip-llm", "don't ask about an LLM provider (only --produce needs one)", false)
377
+ .option("--force", "re-download the pieces setup manages, even if present", false)
378
+ .option("-y, --yes", "no questions — accept the plan and skip the provider prompt", false)
379
+ .action(async (opts) => {
380
+ if (envFiles.length > 0) console.log(`▸ env: ${envFiles.join(", ")}`);
381
+ const { setup } = await import("./setup/setup");
382
+ await setup({ model: opts.model, skipLlm: opts.skipLlm, force: opts.force, yes: opts.yes });
383
+ });
384
+
385
+ program
386
+ .command("doctor")
387
+ .description("check every prerequisite and print the exact fix for anything missing")
388
+ .action(async () => {
389
+ // Env files are loaded at module top (R16 §77) — BEFORE this runs — so a
390
+ // provider key living in a `.env` is visible here, not a false negative.
391
+ if (envFiles.length > 0) console.log(`▸ env: ${envFiles.join(", ")}`);
392
+ const { runDoctor, formatDoctor, realProbes } = await import("./doctor");
393
+ const { resolveEditorPageDir } = await import("./edit");
394
+ const { loadConfig } = await import("@ossclip/core");
395
+ const checks = await runDoctor(loadConfig(), realProbes(resolveEditorPageDir()));
396
+ console.log(formatDoctor(checks));
397
+ if (checks.some((c) => !c.ok)) process.exit(1);
398
+ });
399
+
400
+ return program;
401
+ }
@@ -0,0 +1,122 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream, createWriteStream, mkdirSync, renameSync, statSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+
5
+ /**
6
+ * Download with resume + integrity check — the model is a 466 MB file and
7
+ * "my wifi dropped at 91%" must not mean starting over.
8
+ *
9
+ * Writes `<dest>.part`, resumes with a Range header when a partial exists
10
+ * (GitHub and Hugging Face both honor ranges; a server that answers 200
11
+ * instead of 206 restarts cleanly), verifies the hash of the COMPLETE file
12
+ * before renaming into place — a `.part` never becomes a `dest` unverified.
13
+ */
14
+
15
+ export interface DownloadOptions {
16
+ /** hex digest; algorithm chosen by which field is set */
17
+ sha256?: string;
18
+ sha1?: string;
19
+ onProgress?: (doneBytes: number, totalBytes: number | null) => void;
20
+ /** injected in tests */
21
+ fetchImpl?: typeof fetch;
22
+ }
23
+
24
+ export async function download(url: string, dest: string, opts: DownloadOptions = {}): Promise<void> {
25
+ const fetchImpl = opts.fetchImpl ?? fetch;
26
+ const part = `${dest}.part`;
27
+ mkdirSync(dirname(dest), { recursive: true });
28
+
29
+ let offset = 0;
30
+ try {
31
+ offset = statSync(part).size;
32
+ } catch {
33
+ // no partial — fresh download
34
+ }
35
+
36
+ const headers: Record<string, string> = {};
37
+ if (offset > 0) headers.Range = `bytes=${offset}-`;
38
+
39
+ const res = await fetchImpl(url, { headers, redirect: "follow" });
40
+ if (res.status === 200) {
41
+ offset = 0; // server ignored the range — start over
42
+ } else if (res.status !== 206) {
43
+ throw new Error(`download failed: HTTP ${res.status} for ${url}`);
44
+ }
45
+ if (!res.body) throw new Error(`download failed: empty body for ${url}`);
46
+
47
+ const lengthHeader = res.headers.get("content-length");
48
+ const total = lengthHeader ? offset + Number.parseInt(lengthHeader, 10) : null;
49
+
50
+ const out = createWriteStream(part, offset > 0 ? { flags: "a" } : {});
51
+ let done = offset;
52
+ const reader = res.body.getReader();
53
+ try {
54
+ for (;;) {
55
+ const { value, done: finished } = await reader.read();
56
+ if (finished) break;
57
+ if (value) {
58
+ done += value.length;
59
+ if (!out.write(value)) {
60
+ await new Promise<void>((r) => out.once("drain", () => r()));
61
+ }
62
+ opts.onProgress?.(done, total);
63
+ }
64
+ }
65
+ } finally {
66
+ await new Promise<void>((r) => out.end(() => r()));
67
+ }
68
+
69
+ const algo = opts.sha256 ? "sha256" : opts.sha1 ? "sha1" : null;
70
+ const expected = opts.sha256 ?? opts.sha1;
71
+ if (algo && expected) {
72
+ const actual = await hashFile(part, algo);
73
+ if (actual !== expected.toLowerCase()) {
74
+ // A corrupt partial would resume corrupt forever — a mismatch removes it.
75
+ const { rmSync } = await import("node:fs");
76
+ rmSync(part, { force: true });
77
+ throw new Error(
78
+ `checksum mismatch for ${url}\n expected ${algo} ${expected}\n got ${actual}\n` +
79
+ "The partial download was removed — re-run to try again.",
80
+ );
81
+ }
82
+ }
83
+ renameSync(part, dest);
84
+ }
85
+
86
+ export function hashFile(path: string, algo: "sha256" | "sha1"): Promise<string> {
87
+ return new Promise((resolve, reject) => {
88
+ const hash = createHash(algo);
89
+ createReadStream(path)
90
+ .on("data", (chunk) => hash.update(chunk))
91
+ .on("error", reject)
92
+ .on("end", () => resolve(hash.digest("hex")));
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Single rewritten stderr line: `▸ ggml-small.en.bin 312/466 MB 67%`.
98
+ * Piped output (CI logs) gets a plain line every 10% instead — `\r` doesn't
99
+ * rewrite in a captured log, it accumulates.
100
+ */
101
+ export function progressLine(label: string): (done: number, total: number | null) => void {
102
+ const tty = process.stderr.isTTY === true;
103
+ const stepPct = tty ? 1 : 10;
104
+ let lastPct = -1;
105
+ let lastMB = -1;
106
+ return (done, total) => {
107
+ const doneMB = Math.round(done / 1e6);
108
+ if (total === null) {
109
+ if (doneMB !== lastMB && doneMB % 25 === 0) {
110
+ lastMB = doneMB;
111
+ process.stderr.write(`${tty ? "\r" : ""}▸ ${label} ${doneMB} MB${tty ? "" : "\n"}`);
112
+ }
113
+ return;
114
+ }
115
+ const pct = Math.floor((done / total) * 100);
116
+ if (pct === lastPct || pct % stepPct !== 0) return;
117
+ lastPct = pct;
118
+ const totalMB = Math.round(total / 1e6);
119
+ const line = `▸ ${label} ${doneMB}/${totalMB} MB ${pct}%`;
120
+ process.stderr.write(tty ? `\r${line}${pct === 100 ? "\n" : ""}` : `${line}\n`);
121
+ };
122
+ }
@@ -0,0 +1,91 @@
1
+ import { spawn } from "node:child_process";
2
+ import { chmodSync, readdirSync, type Dirent } from "node:fs";
3
+ import { join, win32 } from "node:path";
4
+
5
+ /**
6
+ * Archive extraction without a single npm dependency: every platform we
7
+ * download for ships a `tar` that reads everything we download. GNU tar
8
+ * covers .tar.gz/.tar.xz on Linux; bsdtar (macOS, and Windows 10+ as
9
+ * %SystemRoot%\System32\tar.exe) additionally reads .zip.
10
+ *
11
+ * The Windows subtlety that cost a CI run (§117): a bare `tar` there is
12
+ * whichever one PATH finds first, and any machine with Git for Windows —
13
+ * every GitHub runner, and most developer boxes — puts MSYS **GNU** tar
14
+ * ahead of the system bsdtar. GNU tar cannot read a zip and exits 128. So
15
+ * on win32 the system bsdtar is tried by absolute path FIRST, and every
16
+ * candidate that fails for ANY reason falls through to the next: the
17
+ * original code only fell back when `spawn` itself errored, which a
18
+ * nonzero exit is not.
19
+ */
20
+
21
+ /** Extractors to try, in order. Pure, so the ordering is unit-testable. */
22
+ export function tarCandidates(
23
+ platform: NodeJS.Platform = process.platform,
24
+ env: NodeJS.ProcessEnv = process.env,
25
+ ): string[] {
26
+ if (platform !== "win32") return ["tar"];
27
+ const systemRoot = env.SystemRoot ?? env.SYSTEMROOT ?? "C:\\Windows";
28
+ // `win32.join`, not `join`: this builds a Windows path and the planner is
29
+ // pure over an injected platform, so it must not pick separators from
30
+ // whatever host the tests happen to run on.
31
+ // Absolute bsdtar first, then whatever PATH has (an unusual box may only
32
+ // have one of them).
33
+ return [win32.join(systemRoot, "System32", "tar.exe"), "tar"];
34
+ }
35
+
36
+ const spawnOk = (bin: string, args: string[]): Promise<boolean> =>
37
+ new Promise((resolve) => {
38
+ const child = spawn(bin, args, { stdio: "ignore" });
39
+ child.on("error", () => resolve(false));
40
+ child.on("exit", (code) => resolve(code === 0));
41
+ });
42
+
43
+ export async function extractArchive(
44
+ archivePath: string,
45
+ destDir: string,
46
+ platform: NodeJS.Platform = process.platform,
47
+ env: NodeJS.ProcessEnv = process.env,
48
+ ): Promise<void> {
49
+ for (const bin of tarCandidates(platform, env)) {
50
+ if (await spawnOk(bin, ["-xf", archivePath, "-C", destDir])) return;
51
+ }
52
+ if (platform === "win32" && archivePath.endsWith(".zip")) {
53
+ const ok = await spawnOk("powershell", [
54
+ "-NoProfile",
55
+ "-Command",
56
+ `Expand-Archive -LiteralPath '${archivePath}' -DestinationPath '${destDir}' -Force`,
57
+ ]);
58
+ if (ok) return;
59
+ throw new Error(`neither tar nor Expand-Archive could extract ${archivePath}`);
60
+ }
61
+ throw new Error(`tar could not extract ${archivePath}`);
62
+ }
63
+
64
+ /**
65
+ * Find `basename` under `dir`, recursively. Release archives put binaries at
66
+ * different depths (`Release/whisper-cli.exe`, `<verdir>/bin/ffmpeg`) and
67
+ * those layouts are upstream's to change — searching beats hardcoding them.
68
+ */
69
+ export function findFile(dir: string, basename: string): string | null {
70
+ let entries: Dirent[];
71
+ try {
72
+ entries = readdirSync(dir, { withFileTypes: true });
73
+ } catch {
74
+ return null;
75
+ }
76
+ for (const e of entries) {
77
+ if (e.isFile() && e.name === basename) return join(dir, e.name);
78
+ }
79
+ for (const e of entries) {
80
+ if (e.isDirectory()) {
81
+ const hit = findFile(join(dir, e.name), basename);
82
+ if (hit) return hit;
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+
88
+ /** POSIX archives usually preserve the execute bit; make sure of it. */
89
+ export function markExecutable(path: string, platform: NodeJS.Platform = process.platform): void {
90
+ if (platform !== "win32") chmodSync(path, 0o755);
91
+ }