ossclip 0.1.3 → 0.1.5

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.
@@ -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
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The acquisition table `ossclip setup` provisions from — pure data.
3
+ *
4
+ * Every entry is pinned to an exact upstream release and checksum, verified
5
+ * at pin time; setup refuses a download whose hash doesn't match. Nothing
6
+ * here ships inside the npm package — the GPL ffmpeg builds and the
7
+ * whisper.cpp binaries are downloaded onto the user's machine at the user's
8
+ * request, which keeps the MIT package's dependency graph clean.
9
+ *
10
+ * Bumping a pin: update url/version/sha256 together (BtbN publishes
11
+ * `checksums.sha256` per release; ggml-org assets are hashed by hand), and
12
+ * re-run the setup-e2e workflow. The manifest test asserts every supported
13
+ * platform×arch resolves to either an asset or an explicit manual hint.
14
+ */
15
+
16
+ export interface BinaryAsset {
17
+ url: string;
18
+ sha256: string;
19
+ /** Extracted with `tar -xf` everywhere; win32 falls back to Expand-Archive for zips. */
20
+ archive: "zip" | "tar.gz" | "tar.xz";
21
+ /** Binary basenames to locate (recursively) after extraction. */
22
+ bins: string[];
23
+ /** Download size, for up-front disclosure. */
24
+ sizeMB: number;
25
+ license: "GPL" | "MIT";
26
+ version: string;
27
+ }
28
+
29
+ const BTBN =
30
+ "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-29-13-36";
31
+ const FFMPEG_VER = "n8.1.2-31-g8c9502e9b0";
32
+
33
+ const WHISPER =
34
+ "https://github.com/ggml-org/whisper.cpp/releases/download/v1.9.1";
35
+ const WHISPER_VER = "v1.9.1";
36
+
37
+ /**
38
+ * Static ffmpeg+ffprobe per platform. macOS returns null: BtbN publishes no
39
+ * darwin assets, and brew is near-universal there — the planner falls back
40
+ * to it.
41
+ */
42
+ export function ffmpegAsset(platform: NodeJS.Platform, arch: string): BinaryAsset | null {
43
+ const bins =
44
+ platform === "win32" ? ["ffmpeg.exe", "ffprobe.exe"] : ["ffmpeg", "ffprobe"];
45
+ const common = { bins, license: "GPL" as const, version: FFMPEG_VER };
46
+ if (platform === "win32" && arch === "x64") {
47
+ return {
48
+ ...common,
49
+ url: `${BTBN}/ffmpeg-${FFMPEG_VER}-win64-gpl-8.1.zip`,
50
+ sha256: "106d3f8e72b70e29f83983dbaa65efdfc5355716a5df675dc846e441929f7890",
51
+ archive: "zip",
52
+ sizeMB: 160,
53
+ };
54
+ }
55
+ if (platform === "win32" && arch === "arm64") {
56
+ return {
57
+ ...common,
58
+ url: `${BTBN}/ffmpeg-${FFMPEG_VER}-winarm64-gpl-8.1.zip`,
59
+ sha256: "ac46bdb0c9c619b107c7281a0cc6932a9419c4d6c3c8c36a259550f7fcee1a1a",
60
+ archive: "zip",
61
+ sizeMB: 107,
62
+ };
63
+ }
64
+ if (platform === "linux" && arch === "x64") {
65
+ return {
66
+ ...common,
67
+ url: `${BTBN}/ffmpeg-${FFMPEG_VER}-linux64-gpl-8.1.tar.xz`,
68
+ sha256: "9fb60ff01e6574258dc76efdf94f901a651582da67b8edcfd10e8860233b7ef4",
69
+ archive: "tar.xz",
70
+ sizeMB: 120,
71
+ };
72
+ }
73
+ if (platform === "linux" && arch === "arm64") {
74
+ return {
75
+ ...common,
76
+ url: `${BTBN}/ffmpeg-${FFMPEG_VER}-linuxarm64-gpl-8.1.tar.xz`,
77
+ sha256: "d8f9598a885db3deabd06af7f0f70c8565af27d29fadbcf746598c9306a0c3fa",
78
+ archive: "tar.xz",
79
+ sizeMB: 102,
80
+ };
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * Prebuilt whisper.cpp `whisper-cli` per platform. The Windows zip carries
87
+ * its DLLs beside the exe and the Ubuntu tarballs link their .so files via
88
+ * an `$ORIGIN` runpath, so both run straight out of the extracted directory
89
+ * (verified at pin time). macOS returns null — upstream ships no darwin CLI
90
+ * binary; brew's `whisper-cpp` covers it.
91
+ *
92
+ * Windows-on-ARM gets the x64 build: upstream publishes no arm64 zip, and
93
+ * Windows 11 runs x64 binaries under emulation.
94
+ */
95
+ export function whisperAsset(platform: NodeJS.Platform, arch: string): BinaryAsset | null {
96
+ if (platform === "win32") {
97
+ // The BLAS build — meaningfully faster on small.en, worth the extra DLL.
98
+ return {
99
+ url: `${WHISPER}/whisper-blas-bin-x64.zip`,
100
+ sha256: "3c319eab3e87f85883e1ff3d14426c0a1986c661c5eb5985e8af431ed9c4f71f",
101
+ archive: "zip",
102
+ bins: ["whisper-cli.exe"],
103
+ sizeMB: 20,
104
+ license: "MIT",
105
+ version: WHISPER_VER,
106
+ };
107
+ }
108
+ if (platform === "linux" && arch === "x64") {
109
+ return {
110
+ url: `${WHISPER}/whisper-bin-ubuntu-x64.tar.gz`,
111
+ sha256: "f3bf3b4369a99b54665b0f19b88483b30de27f25963b0414235dea03198515c5",
112
+ archive: "tar.gz",
113
+ bins: ["whisper-cli"],
114
+ sizeMB: 9,
115
+ license: "MIT",
116
+ version: WHISPER_VER,
117
+ };
118
+ }
119
+ if (platform === "linux" && arch === "arm64") {
120
+ return {
121
+ url: `${WHISPER}/whisper-bin-ubuntu-arm64.tar.gz`,
122
+ sha256: "e0b66cd551ff6f2a28fabe3c6e89691eea037bb76833493abb9a71ca788994b3",
123
+ archive: "tar.gz",
124
+ bins: ["whisper-cli"],
125
+ sizeMB: 5,
126
+ license: "MIT",
127
+ version: WHISPER_VER,
128
+ };
129
+ }
130
+ return null;
131
+ }
132
+
133
+ /**
134
+ * ggml transcription models. Sizes and SHA-1 hashes come from upstream's
135
+ * models/README.md — upstream publishes SHA-1, so that's what we verify;
136
+ * it's an integrity check against truncated downloads, not a security
137
+ * boundary (the download is already pinned to a host and path over HTTPS).
138
+ */
139
+ export interface ModelInfo {
140
+ sizeMB: number;
141
+ sha1: string;
142
+ }
143
+
144
+ export const MODELS: Record<string, ModelInfo> = {
145
+ "tiny.en": { sizeMB: 75, sha1: "c78c86eb1a8faa21b369bcd33207cc90d64ae9df" },
146
+ "base.en": { sizeMB: 142, sha1: "137c40403d78fd54d454da0f9bd998f78703390c" },
147
+ "small.en": { sizeMB: 466, sha1: "db8a495a91d927739e50b3fc1cc4c6b8f6c2d022" },
148
+ "medium.en": { sizeMB: 1536, sha1: "8c30f0e44ce9560643ebd10bbe50cd20eafd3723" },
149
+ };
150
+
151
+ export function modelUrl(name: string): string {
152
+ return `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${name}.bin`;
153
+ }
154
+
155
+ /** The exact build recipe printed when no prebuilt fits — one copy, not four. */
156
+ export const WHISPER_BUILD_HINT =
157
+ "build whisper.cpp from source: git clone https://github.com/ggml-org/whisper.cpp && " +
158
+ "cd whisper.cpp && cmake -B build && cmake --build build -j --config Release " +
159
+ "(then point OSSCLIP_WHISPER at build/bin/whisper-cli)";
@@ -0,0 +1,211 @@
1
+ import { isAbsolute, join } from "node:path";
2
+ import type { OssclipConfig } from "@ossclip/core";
3
+ import { type BinaryAsset, MODELS, ffmpegAsset, modelUrl, whisperAsset } from "./manifest";
4
+
5
+ /**
6
+ * The planning half of `ossclip setup` — pure over injected probes, like
7
+ * doctor (R18 §90a), so every branch is unit-testable without a network or
8
+ * a second OS. The IO half (download/extract/brew/prompt) lives in setup.ts.
9
+ *
10
+ * Ground rules:
11
+ * - Anything that already works is `satisfied` and never touched. A user's
12
+ * own ffmpeg on PATH, a hand-set OSSCLIP_WHISPER, a model already on
13
+ * disk — setup's job is to fill gaps, not to take over working installs.
14
+ * - `--force` re-provisions only what setup itself manages (paths under
15
+ * `<configDir>/bin`, or bare names that were never resolved) — it must
16
+ * never clobber a path the user pointed elsewhere on purpose.
17
+ * - No platform gets silence: where no strategy applies, the step is
18
+ * `manual` with the exact commands to run.
19
+ */
20
+
21
+ export type StepKind = "ffmpeg" | "whisper" | "model" | "provider";
22
+ export type StepStatus = "satisfied" | "download" | "brew" | "manual" | "prompt";
23
+
24
+ export interface SetupStep {
25
+ kind: StepKind;
26
+ status: StepStatus;
27
+ /** What was found, or what will happen — one line for the plan table. */
28
+ detail: string;
29
+ /** Set when status === "download". */
30
+ asset?: BinaryAsset;
31
+ /** Download size when known (binary asset or known model). */
32
+ sizeMB?: number;
33
+ /** brew formula when status === "brew"; the manual fix when "manual". */
34
+ hint?: string;
35
+ }
36
+
37
+ export interface SetupProbes {
38
+ binRuns(bin: string, arg: string): Promise<boolean>;
39
+ exists(path: string): boolean;
40
+ platform: NodeJS.Platform;
41
+ arch: string;
42
+ env: NodeJS.ProcessEnv;
43
+ }
44
+
45
+ export interface SetupOptions {
46
+ /** Resolved `~/.ossclip` (injected so tests don't touch the real home). */
47
+ configDir: string;
48
+ model: string;
49
+ force: boolean;
50
+ skipLlm: boolean;
51
+ }
52
+
53
+ export const managedBinDir = (configDir: string): string => join(configDir, "bin");
54
+
55
+ /** A path setup owns and may re-provision under --force. */
56
+ const isManaged = (path: string, configDir: string): boolean =>
57
+ !isAbsolute(path) || path.startsWith(managedBinDir(configDir));
58
+
59
+ export async function planSetup(
60
+ cfg: OssclipConfig,
61
+ p: SetupProbes,
62
+ opts: SetupOptions,
63
+ ): Promise<SetupStep[]> {
64
+ const steps: SetupStep[] = [];
65
+ const brewAvailable =
66
+ p.platform === "darwin" ? await p.binRuns("brew", "--version") : false;
67
+
68
+ // ffmpeg + ffprobe travel together: one archive provides both, and a
69
+ // machine with one but not the other is a broken install either way.
70
+ const ffmpegOk =
71
+ (await p.binRuns(cfg.ffmpegPath, "-version")) &&
72
+ (await p.binRuns(cfg.ffprobePath, "-version"));
73
+ const ffmpegForceable = isManaged(cfg.ffmpegPath, opts.configDir);
74
+ if (ffmpegOk && !(opts.force && ffmpegForceable)) {
75
+ steps.push({ kind: "ffmpeg", status: "satisfied", detail: cfg.ffmpegPath });
76
+ } else {
77
+ const asset = ffmpegAsset(p.platform, p.arch);
78
+ if (asset) {
79
+ steps.push({
80
+ kind: "ffmpeg",
81
+ status: "download",
82
+ detail: `static ffmpeg + ffprobe ${asset.version} (${asset.license} build)`,
83
+ asset,
84
+ sizeMB: asset.sizeMB,
85
+ });
86
+ } else if (brewAvailable) {
87
+ steps.push({
88
+ kind: "ffmpeg",
89
+ status: "brew",
90
+ detail: "ffmpeg + ffprobe via Homebrew",
91
+ hint: "ffmpeg",
92
+ });
93
+ } else {
94
+ steps.push({
95
+ kind: "ffmpeg",
96
+ status: "manual",
97
+ detail: "no automated path for this platform",
98
+ hint:
99
+ p.platform === "darwin"
100
+ ? "install Homebrew (https://brew.sh) then `brew install ffmpeg`, or set OSSCLIP_FFMPEG"
101
+ : "install ffmpeg from https://ffmpeg.org and set OSSCLIP_FFMPEG",
102
+ });
103
+ }
104
+ }
105
+
106
+ const whisperOk = await p.binRuns(cfg.whisperPath, "--help");
107
+ const whisperForceable = isManaged(cfg.whisperPath, opts.configDir);
108
+ if (whisperOk && !(opts.force && whisperForceable)) {
109
+ steps.push({ kind: "whisper", status: "satisfied", detail: cfg.whisperPath });
110
+ } else {
111
+ const asset = whisperAsset(p.platform, p.arch);
112
+ if (asset) {
113
+ steps.push({
114
+ kind: "whisper",
115
+ status: "download",
116
+ detail: `prebuilt whisper.cpp ${asset.version} (whisper-cli)`,
117
+ asset,
118
+ sizeMB: asset.sizeMB,
119
+ });
120
+ } else if (brewAvailable) {
121
+ steps.push({
122
+ kind: "whisper",
123
+ status: "brew",
124
+ detail: "whisper.cpp via Homebrew",
125
+ hint: "whisper-cpp",
126
+ });
127
+ } else {
128
+ steps.push({
129
+ kind: "whisper",
130
+ status: "manual",
131
+ detail: "no automated path for this platform",
132
+ hint:
133
+ p.platform === "darwin"
134
+ ? "install Homebrew (https://brew.sh) then `brew install whisper-cpp`, or set OSSCLIP_WHISPER"
135
+ : "see https://github.com/ggml-org/whisper.cpp — build from source, then set OSSCLIP_WHISPER",
136
+ });
137
+ }
138
+ }
139
+
140
+ // The model: same resolution produce and doctor use — absolute is a file
141
+ // path, a bare name lives in modelDir as ggml-<name>.bin. `--force` never
142
+ // re-downloads a present model; a corrupt one is deleted by hand.
143
+ const model = opts.model;
144
+ const modelPath = isAbsolute(model) ? model : join(cfg.modelDir, `ggml-${model}.bin`);
145
+ const known = MODELS[model];
146
+ if (p.exists(modelPath)) {
147
+ steps.push({ kind: "model", status: "satisfied", detail: modelPath });
148
+ } else if (isAbsolute(model)) {
149
+ steps.push({
150
+ kind: "model",
151
+ status: "manual",
152
+ detail: `configured model is an absolute path that doesn't exist: ${model}`,
153
+ hint: "put the file there, or set model to a name like small.en for setup to download",
154
+ });
155
+ } else {
156
+ steps.push({
157
+ kind: "model",
158
+ status: "download",
159
+ detail: `${modelUrl(model)} → ${modelPath}`,
160
+ sizeMB: known?.sizeMB,
161
+ });
162
+ }
163
+
164
+ // Provider, in doctor's detection order. Setup can save a key, but only
165
+ // ever interactively — never invented, never required (--skip-llm).
166
+ const provider = p.env.GEMINI_API_KEY
167
+ ? "gemini (GEMINI_API_KEY is set)"
168
+ : p.env.ANTHROPIC_API_KEY
169
+ ? "claude (ANTHROPIC_API_KEY is set)"
170
+ : (await p.binRuns("claude", "--version"))
171
+ ? "claude-cli (logged-in Claude Code)"
172
+ : null;
173
+ if (opts.skipLlm) {
174
+ steps.push({
175
+ kind: "provider",
176
+ status: "satisfied",
177
+ detail: provider ?? "skipped (--skip-llm) — needed for --produce only",
178
+ });
179
+ } else if (provider) {
180
+ steps.push({ kind: "provider", status: "satisfied", detail: provider });
181
+ } else {
182
+ steps.push({
183
+ kind: "provider",
184
+ status: "prompt",
185
+ detail: "no LLM provider found — setup will ask (Enter skips; only --produce needs one)",
186
+ });
187
+ }
188
+
189
+ return steps;
190
+ }
191
+
192
+ export function formatPlan(steps: SetupStep[]): string {
193
+ const lines = steps.map((s) => {
194
+ const mark = s.status === "satisfied" ? "✓" : "▸";
195
+ const size = s.sizeMB ? ` (~${s.sizeMB} MB)` : "";
196
+ const action =
197
+ s.status === "satisfied"
198
+ ? s.detail
199
+ : s.status === "download"
200
+ ? `download${size}: ${s.detail}`
201
+ : s.status === "brew"
202
+ ? `brew install ${s.hint}`
203
+ : s.status === "prompt"
204
+ ? s.detail
205
+ : `manual: ${s.hint}`;
206
+ return `${mark} ${s.kind.padEnd(10)} ${action}`;
207
+ });
208
+ const totalMB = steps.reduce((n, s) => n + (s.status === "download" ? (s.sizeMB ?? 0) : 0), 0);
209
+ if (totalMB > 0) lines.push(`\n total download ~${totalMB} MB → everything lands under ~/.ossclip`);
210
+ return lines.join("\n");
211
+ }
@@ -0,0 +1,51 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+
4
+ /**
5
+ * The LLM-provider step of `ossclip setup`.
6
+ *
7
+ * A key is only ever taken interactively and saved to `~/.ossclip/.env` —
8
+ * the file `loadEnvFiles` (R16 §77) already reads last, so a shell export
9
+ * or a project `.env` still wins. Secrets stay out of config.json, which
10
+ * people paste into issues.
11
+ */
12
+
13
+ export interface ProviderIO {
14
+ /** Ask one question, return the trimmed answer ("" for just-Enter). */
15
+ ask(question: string): Promise<string>;
16
+ say(line: string): void;
17
+ }
18
+
19
+ export async function promptForProvider(io: ProviderIO, configDir: string): Promise<void> {
20
+ io.say("");
21
+ io.say("An LLM provider is only needed for `--produce` (the graphics planner).");
22
+ io.say("Cutting + captions run fully local without one.");
23
+ io.say(" 1) I have an Anthropic API key");
24
+ io.say(" 2) I have a Google Gemini API key");
25
+ io.say(" 3) I use Claude Code (already logged in — no key needed)");
26
+ io.say(" Enter) skip for now");
27
+ const choice = (await io.ask("Choice: ")).trim();
28
+ if (choice === "3") {
29
+ io.say("▸ nothing to save — ossclip finds the claude CLI on PATH by itself.");
30
+ return;
31
+ }
32
+ const envKey = choice === "1" ? "ANTHROPIC_API_KEY" : choice === "2" ? "GEMINI_API_KEY" : null;
33
+ if (!envKey) {
34
+ io.say("▸ skipped — `ossclip doctor` will remind you what --produce needs.");
35
+ return;
36
+ }
37
+ const value = (await io.ask(`${envKey}=`)).trim();
38
+ if (!value) {
39
+ io.say("▸ empty — skipped.");
40
+ return;
41
+ }
42
+ const envPath = saveProviderKey(configDir, envKey, value);
43
+ io.say(`▸ saved to ${envPath} (delete that line to revoke)`);
44
+ }
45
+
46
+ export function saveProviderKey(configDir: string, key: string, value: string): string {
47
+ const envPath = join(configDir, ".env");
48
+ mkdirSync(dirname(envPath), { recursive: true });
49
+ appendFileSync(envPath, `${key}=${value}\n`, { mode: 0o600 });
50
+ return envPath;
51
+ }