scripts/doctor.mjs
scripts/doctor.mjsBrowse 22 files
1,962 tokens
7,117 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env node2/**3 * Preflight. Run this BEFORE the interview, not after the first failure.4 *5 * node scripts/doctor.mjs check everything6 * node scripts/doctor.mjs --probe also spend one API call to read the balance7 *8 * Every check that can fail deep inside a build with a misleading message is9 * checked here with an honest one. The three that actually bite:10 *11 * - a STRIPPED ffmpeg on PATH. It carries ~50 filters and silently lacks12 * scale, fps, psnr and the webp muxer, then fails with "No option name13 * near ..." or "Unable to choose an output format", both of which read as14 * a mistake in your command rather than a missing feature.15 * - no KIE_AI_API_KEY, which only matters if you are generating assets.16 * - playwright-core resolving from the wrong directory. It is required from17 * the BUILD folder, not from the skill.18 */19 20import fs from "node:fs";21import path from "node:path";22import { execFileSync } from "node:child_process";23import { createRequire } from "node:module";24import { fileURLToPath } from "node:url";25import { paths } from "./workspace.mjs";26 27const HERE = path.dirname(fileURLToPath(import.meta.url));28const rows = [];29const add = (sev, name, ok, detail, fix) => rows.push({ sev, name, ok, detail, fix });30 31const run = (cmd, args) => {32 try {33 return execFileSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });34 } catch {35 return null;36 }37};38 39// ---------------------------------------------------------------- node ----40const major = Number(process.versions.node.split(".")[0]);41add("required", "node", major >= 18, `v${process.versions.node}`,42 "Install Node 18 or newer.");43 44// -------------------------------------------------------------- ffmpeg ----45function globWinGet() {46 const home = process.env.USERPROFILE || process.env.HOME || "";47 const base = path.join(home, "AppData/Local/Microsoft/WinGet/Packages");48 if (!fs.existsSync(base)) return [];49 const out = [];50 for (const d of fs.readdirSync(base)) {51 if (!/^Gyan\.FFmpeg/i.test(d)) continue;52 const inner = path.join(base, d);53 for (const e of fs.readdirSync(inner)) {54 const p = path.join(inner, e, "bin/ffmpeg.exe");55 if (fs.existsSync(p)) out.push(p);56 }57 }58 return out;59}60 61const candidates = [62 process.env.SCROLLCRAFT_FFMPEG,63 "ffmpeg",64 ...globWinGet(),65 "/usr/local/bin/ffmpeg",66 "/opt/homebrew/bin/ffmpeg",67 "/usr/bin/ffmpeg",68 "/snap/bin/ffmpeg",69].filter(Boolean);70 71let ffmpeg = null, filterCount = 0;72for (const c of candidates) {73 const out = run(c, ["-hide_banner", "-filters"]);74 if (!out) continue;75 const n = out.split("\n").length;76 if (n > filterCount) { filterCount = n; ffmpeg = c; }77 if (n > 200) break;78}79add("required", "ffmpeg (full build)", filterCount > 200,80 ffmpeg ? `${ffmpeg} (${filterCount} filters)` : "not found",81 "A stripped ffmpeg lacks scale/fps/psnr and the webp muxer. Install a full build (Windows: winget install Gyan.FFmpeg) or set SCROLLCRAFT_FFMPEG to one.");82 83if (ffmpeg && filterCount > 200) {84 const enc = run(ffmpeg, ["-hide_banner", "-encoders"]) || "";85 add("optional", " └ libwebp encoder", /libwebp/.test(enc),86 /libwebp/.test(enc) ? "present" : "missing",87 "Posters fall back to JPEG. Not fatal, just heavier.");88}89 90// ---------------------------------------------------------- playwright ----91let pw = false, pwWhere = "";92try {93 createRequire(path.join(process.cwd(), "package.json"))("playwright-core");94 pw = true; pwWhere = "resolves from cwd";95} catch {96 try {97 createRequire(path.join(HERE, "package.json"))("playwright-core");98 pw = true; pwWhere = "resolves from the skill, but NOT from cwd";99 } catch { pwWhere = "not installed"; }100}101add("verify", "playwright-core", pw, pwWhere,102 "Run `npm i playwright-core` inside the build folder. Only needed for the verification pass.");103 104const chrome = [105 process.env.SCROLLCRAFT_CHROME,106 // Windows107 "C:/Program Files/Google/Chrome/Application/chrome.exe",108 "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",109 "C:/Program Files/Microsoft/Edge/Application/msedge.exe",110 // macOS111 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",112 "/Applications/Chromium.app/Contents/MacOS/Chromium",113 "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",114 // Linux115 "/usr/bin/google-chrome",116 "/usr/bin/google-chrome-stable",117 "/usr/bin/chromium",118 "/usr/bin/chromium-browser",119 "/snap/bin/chromium",120].find((p) => p && fs.existsSync(p));121add("verify", "Chrome", Boolean(chrome), chrome || "not found",122 "Install Chrome, or set SCROLLCRAFT_CHROME to an executable.");123 124// ------------------------------------------------------------- kie key ----125function findKey() {126 if (process.env.KIE_AI_API_KEY) return "env";127 let dir = process.cwd();128 for (let i = 0; i < 8; i++) {129 const p = path.join(dir, ".env");130 if (fs.existsSync(p) && /^\s*KIE_AI_API_KEY\s*=\s*\S+/m.test(fs.readFileSync(p, "utf8"))) return p;131 const up = path.dirname(dir);132 if (up === dir) break;133 dir = up;134 }135 return null;136}137const keyWhere = findKey();138add("optional", "KIE_AI_API_KEY", Boolean(keyWhere), keyWhere || "not set",139 "Only needed to GENERATE imagery. Building from your own photos and footage needs no key and no spend. Copy .env.example to .env to set one.");140 141// ----------------------------------------------------------- workspace ----142let ws = null;143try {144 ws = paths();145 add("required", "workspace", true, `${ws.workspace}\n via ${ws.via}`, "");146 add("optional", " └ registry", fs.existsSync(ws.fingerprints),147 fs.existsSync(ws.fingerprints) ? "present" : "not created yet",148 "Run `node scripts/workspace.mjs --ensure` to create it.");149} catch (e) {150 add("required", "workspace", false, e.message, "Fix or delete the offending .scrollcraft.json.");151}152 153// -------------------------------------------------------------- report ----154const mark = (r) => (r.ok ? "\u001b[32m ok \u001b[0m" : r.sev === "required" ? "\u001b[31mFAIL\u001b[0m" : "\u001b[33mwarn\u001b[0m");155console.log("\nscrollcraft preflight\n");156for (const r of rows) {157 console.log(` [${mark(r)}] ${r.name.padEnd(22)} ${r.detail}`);158 if (!r.ok && r.fix) console.log(` ${"\u001b[2m"}${r.fix}${"\u001b[0m"}`);159}160 161const hardFails = rows.filter((r) => !r.ok && r.sev === "required");162const softFails = rows.filter((r) => !r.ok && r.sev !== "required");163console.log("");164if (hardFails.length) {165 console.log(`\u001b[31m${hardFails.length} required check(s) failed. Fix these before building.\u001b[0m\n`);166 process.exit(1);167}168console.log(softFails.length169 ? `\u001b[33mReady, with ${softFails.length} optional item(s) missing (see above).\u001b[0m\n`170 : "\u001b[32mReady.\u001b[0m\n");171 172if (process.argv.includes("--probe") && keyWhere) {173 const { execFileSync: x } = await import("node:child_process");174 try {175 console.log("credit: " + x(process.execPath, [path.join(HERE, "kie.mjs"), "probe"], { encoding: "utf8" }).trim().replace(/^credit:\s*/, ""));176 } catch (e) { console.log("balance probe failed: " + e.message); }177}178