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.
- package/editor-dist/assets/{index-B5H_dKAW.js → index-DZhkQB1i.js} +24 -24
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/doctor.ts +31 -9
- package/src/index.ts +56 -4
- package/src/open.ts +30 -0
- package/src/produce.ts +121 -14
- package/src/setup/download.ts +122 -0
- package/src/setup/extract.ts +91 -0
- package/src/setup/manifest.ts +159 -0
- package/src/setup/plan.ts +211 -0
- package/src/setup/provider.ts +51 -0
- package/src/setup/setup.ts +233 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { CONFIG_DIR, loadConfig, saveConfigPatch, type OssclipConfig } from "@ossclip/core";
|
|
6
|
+
import { MODELS, WHISPER_BUILD_HINT, modelUrl, type BinaryAsset } from "./manifest";
|
|
7
|
+
import { formatPlan, managedBinDir, planSetup, type SetupProbes, type SetupStep } from "./plan";
|
|
8
|
+
import { download, progressLine } from "./download";
|
|
9
|
+
import { extractArchive, findFile, markExecutable } from "./extract";
|
|
10
|
+
import { promptForProvider } from "./provider";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `ossclip setup` — the whole install, one command (§90: the install cliff
|
|
14
|
+
* is the adoption ceiling; this is the ramp). Downloads pinned static
|
|
15
|
+
* ffmpeg and prebuilt whisper.cpp where upstream publishes them, brews on
|
|
16
|
+
* macOS, fetches the transcription model with resume + checksum, offers to
|
|
17
|
+
* save an LLM key, and records absolute paths in ~/.ossclip/config.json so
|
|
18
|
+
* nothing touches PATH — that last part is the entire Windows story.
|
|
19
|
+
*
|
|
20
|
+
* Setup fills gaps and never takes over a working install: whatever
|
|
21
|
+
* already runs is left exactly where it is. It ends by running the real
|
|
22
|
+
* doctor, because setup's definition of done is doctor's definition of
|
|
23
|
+
* healthy.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface SetupCliOptions {
|
|
27
|
+
model?: string;
|
|
28
|
+
skipLlm: boolean;
|
|
29
|
+
force: boolean;
|
|
30
|
+
yes: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const probeBin = (bin: string, arg: string): Promise<boolean> =>
|
|
34
|
+
new Promise((resolve) => {
|
|
35
|
+
// Existence is the question, not exit code (same contract as doctor).
|
|
36
|
+
const child = spawn(bin, [arg], { stdio: "ignore" });
|
|
37
|
+
child.on("error", () => resolve(false));
|
|
38
|
+
child.on("exit", () => resolve(true));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export async function setup(opts: SetupCliOptions): Promise<void> {
|
|
42
|
+
const cfg = loadConfig();
|
|
43
|
+
const model = opts.model ?? cfg.model;
|
|
44
|
+
const probes: SetupProbes = {
|
|
45
|
+
binRuns: probeBin,
|
|
46
|
+
exists: existsSync,
|
|
47
|
+
platform: process.platform,
|
|
48
|
+
arch: process.arch,
|
|
49
|
+
env: process.env,
|
|
50
|
+
};
|
|
51
|
+
const steps = await planSetup(cfg, probes, {
|
|
52
|
+
configDir: CONFIG_DIR,
|
|
53
|
+
model,
|
|
54
|
+
force: opts.force,
|
|
55
|
+
skipLlm: opts.skipLlm,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
console.log("ossclip setup — plan:\n");
|
|
59
|
+
console.log(formatPlan(steps));
|
|
60
|
+
console.log("");
|
|
61
|
+
|
|
62
|
+
const interactive = process.stdin.isTTY === true && !opts.yes;
|
|
63
|
+
const needsWork = steps.some((s) => s.status !== "satisfied");
|
|
64
|
+
const failures: string[] = [];
|
|
65
|
+
const rl = interactive
|
|
66
|
+
? createInterface({ input: process.stdin, output: process.stdout })
|
|
67
|
+
: null;
|
|
68
|
+
try {
|
|
69
|
+
if (needsWork && rl) {
|
|
70
|
+
const answer = (await rl.question("Proceed? [Y/n] ")).trim().toLowerCase();
|
|
71
|
+
if (answer === "n" || answer === "no") {
|
|
72
|
+
console.log("▸ nothing changed.");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const patch: Partial<OssclipConfig> = {};
|
|
78
|
+
for (const step of steps) {
|
|
79
|
+
// One step failing (a dropped download, a brew hiccup) must not throw
|
|
80
|
+
// away the others' work — record it, keep going, save what succeeded,
|
|
81
|
+
// and let the closing doctor run print the remaining fixes.
|
|
82
|
+
try {
|
|
83
|
+
await runStep(step);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
86
|
+
failures.push(`${step.kind}: ${msg}`);
|
|
87
|
+
console.error(`✗ ${step.kind} failed: ${msg}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function runStep(step: SetupStep): Promise<void> {
|
|
92
|
+
switch (step.kind) {
|
|
93
|
+
case "ffmpeg":
|
|
94
|
+
if (step.status === "download" && step.asset) {
|
|
95
|
+
const [ffmpegBin, ffprobeBin] = step.asset.bins;
|
|
96
|
+
if (!ffmpegBin || !ffprobeBin) throw new Error("manifest bug: ffmpeg asset needs two bins");
|
|
97
|
+
const dir = await provision("ffmpeg", step.asset);
|
|
98
|
+
patch.ffmpegPath = mustFind(dir, ffmpegBin);
|
|
99
|
+
patch.ffprobePath = mustFind(dir, ffprobeBin);
|
|
100
|
+
await verifyRuns(patch.ffmpegPath, "-version", "ffmpeg");
|
|
101
|
+
await verifyRuns(patch.ffprobePath, "-version", "ffprobe");
|
|
102
|
+
console.log(`▸ ffmpeg ready: ${patch.ffmpegPath}`);
|
|
103
|
+
} else if (step.status === "brew") {
|
|
104
|
+
await brewInstall(step.hint ?? "ffmpeg");
|
|
105
|
+
await verifyRuns("ffmpeg", "-version", "ffmpeg");
|
|
106
|
+
} else if (step.status === "manual") {
|
|
107
|
+
console.log(`✗ ffmpeg needs a manual step: ${step.hint}`);
|
|
108
|
+
}
|
|
109
|
+
break;
|
|
110
|
+
case "whisper":
|
|
111
|
+
if (step.status === "download" && step.asset) {
|
|
112
|
+
const [whisperBin] = step.asset.bins;
|
|
113
|
+
if (!whisperBin) throw new Error("manifest bug: whisper asset needs a bin");
|
|
114
|
+
const dir = await provision("whisper", step.asset);
|
|
115
|
+
const bin = mustFind(dir, whisperBin);
|
|
116
|
+
// The probe matters here more than anywhere: a prebuilt that
|
|
117
|
+
// doesn't run on this libc must fail NOW with the build recipe,
|
|
118
|
+
// not half-configured at first transcription.
|
|
119
|
+
await verifyRuns(bin, "--help", "whisper-cli", WHISPER_BUILD_HINT);
|
|
120
|
+
patch.whisperPath = bin;
|
|
121
|
+
console.log(`▸ whisper-cli ready: ${bin}`);
|
|
122
|
+
} else if (step.status === "brew") {
|
|
123
|
+
await brewInstall(step.hint ?? "whisper-cpp");
|
|
124
|
+
await verifyRuns("whisper-cli", "--help", "whisper-cli", WHISPER_BUILD_HINT);
|
|
125
|
+
} else if (step.status === "manual") {
|
|
126
|
+
console.log(`✗ whisper-cli needs a manual step: ${step.hint}`);
|
|
127
|
+
}
|
|
128
|
+
break;
|
|
129
|
+
case "model":
|
|
130
|
+
if (step.status === "download") {
|
|
131
|
+
const modelPath = isAbsolute(model)
|
|
132
|
+
? model
|
|
133
|
+
: join(cfg.modelDir, `ggml-${model}.bin`);
|
|
134
|
+
const info = MODELS[model];
|
|
135
|
+
if (!info) {
|
|
136
|
+
console.log(
|
|
137
|
+
`▸ ${model} isn't in the pinned table — downloading without a checksum.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
console.log(`▸ downloading ggml-${model}.bin${info ? ` (~${info.sizeMB} MB)` : ""}…`);
|
|
141
|
+
await download(modelUrl(model), modelPath, {
|
|
142
|
+
sha1: info?.sha1,
|
|
143
|
+
onProgress: progressLine(`ggml-${model}.bin`),
|
|
144
|
+
});
|
|
145
|
+
console.log(`▸ model ready: ${modelPath}`);
|
|
146
|
+
if (opts.model && opts.model !== cfg.model) patch.model = opts.model;
|
|
147
|
+
} else if (step.status === "manual") {
|
|
148
|
+
console.log(`✗ model needs a manual step: ${step.hint}`);
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
151
|
+
case "provider":
|
|
152
|
+
if (step.status === "prompt" && rl) {
|
|
153
|
+
await promptForProvider(
|
|
154
|
+
{ ask: (q) => rl.question(q), say: (l) => console.log(l) },
|
|
155
|
+
CONFIG_DIR,
|
|
156
|
+
);
|
|
157
|
+
} else if (step.status === "prompt") {
|
|
158
|
+
console.log(
|
|
159
|
+
"▸ no LLM provider configured (non-interactive run) — needed for --produce only; " +
|
|
160
|
+
"set ANTHROPIC_API_KEY or GEMINI_API_KEY when you want it.",
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (Object.keys(patch).length > 0) {
|
|
168
|
+
const path = saveConfigPatch(patch);
|
|
169
|
+
console.log(`▸ recorded in ${path}`);
|
|
170
|
+
}
|
|
171
|
+
} finally {
|
|
172
|
+
rl?.close();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Setup's exit criterion is doctor's: all green, or the exact fix per line.
|
|
176
|
+
console.log("");
|
|
177
|
+
const { runDoctor, formatDoctor, realProbes } = await import("../doctor");
|
|
178
|
+
const { resolveEditorPageDir } = await import("../edit");
|
|
179
|
+
const checks = await runDoctor(loadConfig(), realProbes(resolveEditorPageDir()));
|
|
180
|
+
console.log(formatDoctor(checks));
|
|
181
|
+
if (failures.length > 0) process.exitCode = 1;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Download an asset (with resume) and extract it under the managed bin dir. */
|
|
185
|
+
async function provision(kind: string, asset: BinaryAsset): Promise<string> {
|
|
186
|
+
const archive = join(CONFIG_DIR, "downloads", basename(new URL(asset.url).pathname));
|
|
187
|
+
const destDir = join(managedBinDir(CONFIG_DIR), `${kind}-${asset.version}`);
|
|
188
|
+
console.log(`▸ downloading ${basename(archive)} (~${asset.sizeMB} MB)…`);
|
|
189
|
+
await download(asset.url, archive, {
|
|
190
|
+
sha256: asset.sha256,
|
|
191
|
+
onProgress: progressLine(basename(archive)),
|
|
192
|
+
});
|
|
193
|
+
rmSync(destDir, { recursive: true, force: true });
|
|
194
|
+
mkdirSync(destDir, { recursive: true });
|
|
195
|
+
await extractArchive(archive, destDir);
|
|
196
|
+
for (const bin of asset.bins) {
|
|
197
|
+
const found = findFile(destDir, bin);
|
|
198
|
+
if (found) markExecutable(found);
|
|
199
|
+
}
|
|
200
|
+
// The archive did its job; ~160 MB of zip has no second life.
|
|
201
|
+
rmSync(archive, { force: true });
|
|
202
|
+
return destDir;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function mustFind(dir: string, bin: string): string {
|
|
206
|
+
const found = findFile(dir, bin);
|
|
207
|
+
if (!found) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`extracted archive doesn't contain ${bin} — upstream layout changed; ` +
|
|
210
|
+
"please open an issue with this output",
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return found;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function verifyRuns(bin: string, arg: string, name: string, hint?: string): Promise<void> {
|
|
217
|
+
if (await probeBin(bin, arg)) return;
|
|
218
|
+
throw new Error(
|
|
219
|
+
`${name} was installed but doesn't run on this machine (${bin}).` +
|
|
220
|
+
(hint ? `\nFallback: ${hint}` : ""),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function brewInstall(formula: string): Promise<void> {
|
|
225
|
+
console.log(`▸ brew install ${formula}…`);
|
|
226
|
+
return new Promise((resolve, reject) => {
|
|
227
|
+
const child = spawn("brew", ["install", formula], { stdio: "inherit" });
|
|
228
|
+
child.on("error", (e) => reject(new Error(`brew failed to start: ${e.message}`)));
|
|
229
|
+
child.on("exit", (code) =>
|
|
230
|
+
code === 0 ? resolve() : reject(new Error(`brew install ${formula} exited ${code}`)),
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
}
|