/** * doctor.ts — il controllo generale. * * Two callers, one implementation: * • session_start → `runDoctor()` then `sessionBanner()`: one short line, only if * something is actually wrong. Silence when all is well. * • `/doctor` → `runDoctor()` then `formatReport()`: the full table. * * THREE RULES THIS FILE EXISTS TO KEEP: * * 1. IT NEVER THROWS. Every single check runs inside `check()`, which converts any * exception into an ordinary "error" row. A health check that crashes is worse than * no health check: it takes the whole session down at start-up. * * 2. A DISCONNECTED MODEL DISK IS A NORMAL CONDITION, NOT A FAULT. The models live on * an external volume; it *will* be unplugged. We name the volume, say it is not * connected, and tell him to plug it in — no stack trace, no ENOENT, no drama. * * 3. EVERY LINE HE READS IS ITALIAN, and every problem carries a concrete remedy. * A check that says only "manca X" has failed at its job. * * Nothing here mutates anything except one temp file in the output directory (written * and deleted immediately) — that is the only honest way to answer "can I write there?". */ import { access, mkdir, readdir, rm, stat, statfs, writeFile } from "node:fs/promises"; import { constants as FS } from "node:fs"; import { spawn } from "node:child_process"; import { homedir } from "node:os"; import { arch, platform } from "node:process"; import { dirname, isAbsolute, join, parse as parsePath, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { configPath, loadConfig, type ImgenConfig, type ModelsConfig } from "./config.ts"; import { FONTS } from "./design/fonts.ts"; import { GRPC_SERVER_BINARY, GRPC_SERVER_URL, isPortOpen, serverPaths } from "./backends/server.ts"; import S, { bullets, errorText, fill, list, type ErrorMessage } from "./ui/strings.ts"; // --------------------------------------------------------------------------- // Paths // --------------------------------------------------------------------------- /** extensions/imgen/doctor.ts -> repo root. */ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); /** Where install.sh unpacks the 22 bundled families (one directory per family). */ export const FONTS_DIR = join(REPO_ROOT, "vendor", "fonts"); /** Default pi config dir, used only when the caller does not pass one. */ export const DEFAULT_PI_CONFIG_DIR = join(homedir(), ".pi", "agent"); /** * pi may be launched from a GUI context, where PATH is the bare * `/usr/bin:/bin:/usr/sbin:/sbin` and Homebrew is invisible. Same list as cpu.ts — * duplicated on purpose, because cpu.ts keeps its resolver private and doctor must not * drag the whole CPU-tools module (and sharp) into session start-up. */ const EXTRA_BIN_DIRS = [ join(REPO_ROOT, "vendor", "bin"), "/opt/homebrew/bin", "/usr/local/bin", join(homedir(), ".cargo", "bin"), join(homedir(), ".local", "bin"), ]; // --------------------------------------------------------------------------- // Thresholds // --------------------------------------------------------------------------- /** A "model" smaller than this is a Git LFS pointer or an interrupted download. */ const MIN_MODEL_BYTES = 64 * 1024 * 1024; /** Below this much free space a print job (300 dpi A3 PNG + PDF) starts to be at risk. */ const LOW_DISK_BYTES = 2 * 1024 * 1024 * 1024; /** Version probes must never hang a session start. */ const VERSION_TIMEOUT_MS = 4_000; /** The templates are written against Typst 0.15 (see docs/typst-verified.md). */ const TYPST_MIN = [0, 15] as const; // --------------------------------------------------------------------------- // Local Italian strings // --------------------------------------------------------------------------- /** * Only the lines `ui/strings.ts` does not (yet) carry. `S.doctor.checks` already owns * every check LABEL, and `S.errors.*` owns the big remedies, so this table is * deliberately small: statuses and detail lines. * * TODO: fold into `S.doctor` once that table settles — kept local for now so two files * are not being edited for one feature. */ const T = { /** Row status when a check itself blew up. Rule 1 made visible. */ checkFailed: "Non sono riuscito a fare questo controllo.", checkFailedFix: "Non è colpa tua: riprova, e se si ripete mandami quello che scrive qui sotto.", config: { ok: "Impostazioni lette.", okDefaults: "Nessun file di impostazioni: uso quelle standard.", fixDefaults: "Va benissimo così. Se vuoi cambiare cartelle o modelli, lancia ./install.sh.", }, platform: { notMac: "Questo pacchetto disegna solo su un Mac con chip Apple.", notArm: "Serve un Mac con chip Apple (M1 o successivi): su Intel il motore non esiste.", fix: "Il resto (impaginazione, PDF, ritagli) funziona lo stesso.", }, disk: { /** The headline case: the external volume is simply unplugged. */ volumeAbsent: "Il disco «{disco}» non è collegato.", volumeAbsentFix: "Collega il disco e riprova. I modelli li cerco in {dove}.", volumeThereFolderMissing: "Il disco «{disco}» c'è, ma dentro manca la cartella dei modelli.", folderMissing: "Non trovo la cartella dei modelli.", folderMissingFix: "Dovrebbe essere {dove}. Creala e rimettici dentro i modelli, oppure lancia ./install.sh.", notADir: "Il percorso dei modelli non è una cartella: {dove}", notReadable: "La cartella dei modelli c'è ma non riesco a leggerla: {dove}", notReadableFix: "Controlla i permessi della cartella (in Finder: Informazioni ▸ Autorizzazioni).", notAbsolute: "Il percorso dei modelli deve partire dalla radice del disco: {dove}", notAbsoluteFix: "Correggilo in {file} oppure rilancia ./install.sh.", okExternal: "Il disco «{disco}» è collegato.", okInternal: "I modelli stanno sul disco del computer.", space: "Spazio libero: {spazio}.", }, models: { allOk: "Ci sono tutti i modelli che servono.", someMissing: "Manca qualche modello.", noneConfigured: "Non è configurato nessun modello per disegnare.", skippedDiskOff: "Non posso controllare i modelli finché non è sistemato il disco qui sopra.", remote: "si scarica da internet al primo uso", present: "c'è", missing: "manca", truncated: "il file è troppo piccolo: scaricato a metà", unusedRole: "non configurato", fixMissing: "Rimetti i file mancanti in {dove}, oppure rilancia ./install.sh per riscaricarli.", /** Roles, as he would name them. */ roles: { draft: "prova veloce", final: "immagine definitiva", alt: "stile alternativo", edit: "modifica di un'immagine", upscale: "ingrandimento per la stampa", } as Record, }, drawThings: { ok: "Trovato: {dove}", missing: "Non trovo il comando che disegna le immagini.", }, typst: { ok: "Trovato: {dove}", okVersion: "Versione {versione}, trovato in {dove}", missing: "Non trovo il programma che compone le scritte.", tooOld: "La versione di Typst è vecchia ({versione}): le impaginazioni sono fatte per la {minima} o successive.", tooOldFix: "Aggiornalo con «brew upgrade typst».", }, server: { disabled: "Il motore residente è spento nelle impostazioni: ogni immagine partirà da fredda (più lenta).", disabledFix: "Se vuoi riaccenderlo, metti «server.enabled: true» in {file}.", running: "Acceso e in ascolto sulla porta {porta}.", binaryMissing: "Manca il programma del motore ({nome}).", binaryMissingFix: "Scaricalo da {url} e mettilo in {dove}, oppure lancia ./install.sh.", notExecutable: "Il programma del motore c'è ma non ha il permesso di partire: {dove}", notExecutableFix: "Dagli il permesso con «chmod +x \"{dove}\"».", stopped: "Il motore c'è ma non è acceso: le immagini partiranno da fredde (più lente).", stoppedFix: "Lo accendo da solo quando serve. Se vuoi accenderlo a mano: {comando}", foreign: "Sulla porta {porta} risponde già qualcosa che non ho avviato io: lo uso com'è e non lo tocco.", }, fonts: { ok: "Ci sono tutti i {n} caratteri, con le loro licenze.", empty: "La cartella dei caratteri è vuota.", emptyFix: "Lancia ./install.sh per scaricarli: senza caratteri non posso comporre le scritte.", missing: "Mancano {n} caratteri su {tot}.", missingFix: "Lancia ./install.sh per riscaricare quelli che mancano.", noLicense: "{n} caratteri sono senza file di licenza.", noLicenseItem: "manca la licenza", noLicenseFix: "Le licenze vanno distribuite insieme ai caratteri: rilancia ./install.sh.", dir: "Cartella: {dove}", }, print: { label: "Gli strumenti per la stampa", allOk: "Ci sono tutti: posso controllare il PDF prima di darlo alla tipografia.", someMissing: "Ne mancano alcuni: il PDF lo preparo lo stesso, ma non posso controllarlo bene.", noneNeeded: "Sono facoltativi: senza di loro il PDF si fa comunque.", fix: "Se li vuoi: brew install poppler qpdf ghostscript", tools: { pdfinfo: "controlla le misure e il riquadro di taglio del PDF", pdffonts: "controlla che i caratteri siano incorporati", qpdf: "controlla che il PDF non sia rovinato", gs: "converte e comprime il PDF quando la tipografia lo chiede", } as Record, }, output: { ok: "Posso scrivere in {dove}", created: "Ho creato la cartella dei lavori: {dove}", notAbsolute: "Il percorso della cartella dei lavori deve partire dalla radice del disco: {dove}", volumeAbsent: "La cartella dei lavori sta sul disco «{disco}», che non è collegato: {dove}", volumeAbsentFix: "Collega il disco «{disco}» e riprova. Non creo niente finché non c'è, altrimenti il Mac poi rimonta il disco con un altro nome.", lowSpace: "Resta poco spazio sul disco ({spazio}): una locandina da stampare può occuparne parecchio.", lowSpaceFix: "Libera un po' di posto, oppure sposta la cartella dei lavori.", }, /** Report chrome. */ report: { heading: "Controllo generale", detailPrefix: "dettaglio: ", }, } as const; // --------------------------------------------------------------------------- // Public shape // --------------------------------------------------------------------------- export type CheckId = | "config" | "platform" | "modelsDisk" | "models" | "drawThings" | "server" | "typst" | "fonts" | "printTools" | "outputDir"; /** * - `ok` tutto a posto * - `info` a posto, ma c'è qualcosa da sapere (roba facoltativa che manca) * - `warn` si lavora lo stesso, ma peggio / più lentamente * - `error` non si lavora finché non lo sistemi */ export type CheckLevel = "ok" | "info" | "warn" | "error"; /** One line inside a check: a model, a font family, an optional tool. */ export interface CheckItem { name: string; ok: boolean; /** Italian, short: "c'è", "manca", "si scarica al primo uso"… */ status: string; detail?: string; } export interface CheckResult { id: CheckId; /** Italian label — from `S.doctor.checks` wherever that table has one. */ label: string; level: CheckLevel; /** One Italian sentence: what the situation is. */ message: string; /** One Italian sentence: what to do about it. Absent when there is nothing to do. */ fix?: string; /** Technical detail (path, port, version). Never the whole message. */ detail?: string; items?: CheckItem[]; } export interface DoctorReport { /** True when nothing is blocking: no `error` rows. Warnings do not clear this flag. */ ok: boolean; /** True when at least one `warn` row is present. */ warnings: boolean; checks: CheckResult[]; /** Every warn/error message, ready for `bullets()` or a `Backend.probe()` shape. */ problems: string[]; /** The config the checks were run against (defaults when the file was unreadable). */ config: ImgenConfig; configFile: string; elapsedMs: number; } export interface DoctorOptions { /** Where `pi-imgen.json` lives. Defaults to `~/.pi/agent`. */ piConfigDir?: string; /** Pre-loaded config, when the caller already has one (session_start does). */ config?: ImgenConfig; /** Problems already found while loading that config. */ configProblems?: string[]; /** Skip the external `--version` calls. session_start passes true to stay instant. */ fast?: boolean; } // --------------------------------------------------------------------------- // Never-throwing primitives // --------------------------------------------------------------------------- async function statOf(path: string): Promise { try { return await stat(path); } catch { return null; } } async function canAccess(path: string, mode: number): Promise { try { await access(path, mode); return true; } catch { return false; } } async function listDir(path: string): Promise { try { return await readdir(path); } catch { return []; } } /** Resolves an executable by name, searching the GUI-invisible dirs first. Never throws. */ async function findExecutable(name: string): Promise { if (name.includes("/")) return (await canAccess(name, FS.X_OK)) ? name : null; const pathDirs = (process.env["PATH"] ?? "").split(":").filter(Boolean); for (const dir of [...EXTRA_BIN_DIRS, ...pathDirs]) { const candidate = join(dir, name); const st = await statOf(candidate); if (st?.isFile() && (await canAccess(candidate, FS.X_OK))) return candidate; } return null; } /** Runs a short command for its stdout. Returns null on any failure or timeout. */ function runBriefly(bin: string, args: string[], timeoutMs = VERSION_TIMEOUT_MS): Promise { return new Promise((resolveOut) => { let done = false; const finish = (value: string | null): void => { if (done) return; done = true; clearTimeout(timer); resolveOut(value); }; let child: ReturnType | null = null; const timer = setTimeout(() => { try { child?.kill("SIGKILL"); } catch { /* already gone */ } finish(null); }, timeoutMs); try { child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] }); } catch { finish(null); return; } let out = ""; child.stdout?.on("data", (b: Buffer) => { out += b.toString("utf8"); }); child.stderr?.on("data", (b: Buffer) => { out += b.toString("utf8"); }); child.on("error", () => finish(null)); child.on("close", (code) => finish(code === 0 ? out : out.trim() === "" ? null : out)); }); } /** "1,2 GB" — sizes as he would read them, with the Italian decimal comma. */ export function humanBytes(bytes: number): string { const units = ["B", "kB", "MB", "GB", "TB"]; let value = bytes; let unit = 0; while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; } const text = unit === 0 ? String(Math.round(value)) : value.toFixed(1).replace(".", ","); return `${text} ${units[unit]}`; } /** Free bytes on the filesystem holding `path`, or null when it cannot be measured. */ async function freeBytes(path: string): Promise { try { const fsStat = await statfs(path); return Number(fsStat.bavail) * Number(fsStat.bsize); } catch { return null; } } // --------------------------------------------------------------------------- // Volumes — the check that makes an unplugged disk a sentence, not a crash // --------------------------------------------------------------------------- export interface VolumeInfo { /** The path we were asked about. */ path: string; /** Mount point of the volume that (should) hold it, e.g. `/Volumes/Foto` or `/`. */ root: string; /** Volume name as it appears in Finder, when the path is on an external disk. */ name: string | null; /** True when the path is NOT on the boot disk. */ external: boolean; /** True when the volume is actually mounted right now. */ mounted: boolean; /** Deepest ancestor of `path` that exists. Useful to explain what is missing. */ deepestExisting: string | null; } /** * Works out whether the model volume is mounted, without ever asking the OS a question * it can answer with an exception. * * macOS mounts external disks under `/Volumes/`, so the volume NAME is simply the * second path segment — that is what lets us say «Il disco "Foto" non è collegato» * instead of printing an ENOENT. Two traps handled: * • an unmounted disk usually leaves NO `/Volumes/` entry at all; * • sometimes it leaves an empty stub directory on the boot volume. We catch that by * comparing st_dev with `/`: same device means the mount point is a plain folder, * i.e. the real disk is not there. * Off macOS the same st_dev walk still finds the mount point, so this is portable. */ export async function volumeOf(path: string): Promise { const info: VolumeInfo = { path, root: "/", name: null, external: false, mounted: true, deepestExisting: null, }; // Deepest existing ancestor: also tells us where a missing tree stops existing. let cursor = path; for (;;) { const st = await statOf(cursor); if (st) { info.deepestExisting = cursor; break; } const parent = dirname(cursor); if (parent === cursor) break; cursor = parent; } const segments = path.split("/").filter(Boolean); const underVolumes = path.startsWith("/Volumes/") && segments.length >= 2; if (underVolumes) { info.name = segments[1] ?? null; info.root = `/Volumes/${info.name}`; info.external = true; } const rootStat = await statOf(info.root); const bootStat = await statOf("/"); if (info.external) { if (!rootStat) { info.mounted = false; } else if (bootStat && rootStat.dev === bootStat.dev) { // Empty stub left behind by an ejected disk: the folder exists, the disk does not. const entries = await listDir(info.root); info.mounted = entries.length > 0; } return info; } // Not under /Volumes: walk up from the deepest existing ancestor to its mount point, // so a path on any other mounted filesystem is still described correctly. if (info.deepestExisting) { let current = info.deepestExisting; let currentStat = await statOf(current); for (;;) { const parent = dirname(current); if (parent === current) break; const parentStat = await statOf(parent); if (!parentStat || !currentStat || parentStat.dev !== currentStat.dev) break; current = parent; currentStat = parentStat; } info.root = current; if (bootStat && currentStat && currentStat.dev !== bootStat.dev) { info.external = true; info.name = parsePath(current).base || current; } } return info; } // --------------------------------------------------------------------------- // The checks // --------------------------------------------------------------------------- type CheckBody = Omit; /** Rule 1, in one function: no check can ever escape with an exception. */ async function check(id: CheckId, label: string, body: () => Promise): Promise { try { return { id, label, ...(await body()) }; } catch (e) { return { id, label, level: "error", message: T.checkFailed, fix: T.checkFailedFix, detail: (e as Error)?.message ?? String(e), }; } } /** Turns an `S.errors.*` entry into a check body, so remedies stay in one place. */ function fromError(level: CheckLevel, e: ErrorMessage): CheckBody { return { level, message: e.message, ...(e.fix ? { fix: e.fix } : {}), ...(e.detail ? { detail: e.detail } : {}), }; } function isRemoteModelRef(ref: string): boolean { return /^hf:\/\//i.test(ref) || /^https?:\/\//i.test(ref); } async function checkConfig(configFile: string, problems: string[]): Promise { const present = (await statOf(configFile)) !== null; if (problems.length > 0) { return { ...fromError("warn", S.errors.configBroken(configFile, problems.join("; "))), detail: configFile }; } if (!present) { return { level: "info", message: T.config.okDefaults, fix: T.config.fixDefaults, detail: configFile }; } return { level: "ok", message: T.config.ok, detail: configFile }; } async function checkPlatform(): Promise { if (platform !== "darwin") { return { level: "error", message: T.platform.notMac, fix: T.platform.fix, detail: `${platform}/${arch}` }; } if (arch !== "arm64") { return { level: "error", message: T.platform.notArm, fix: T.platform.fix, detail: `${platform}/${arch}` }; } return { level: "ok", message: "macOS · Apple Silicon", detail: `${platform}/${arch}` }; } async function checkModelsDisk(cfg: ImgenConfig, configFile: string): Promise { const path = cfg.modelsPath; if (!isAbsolute(path)) { return { usable: false, level: "error", message: fill(T.disk.notAbsolute, { dove: path }), fix: fill(T.disk.notAbsoluteFix, { file: configFile }), detail: path, }; } const vol = await volumeOf(path); // THE expected condition: the external disk is simply not plugged in. if (vol.external && !vol.mounted) { const err = S.errors.modelsDiskMissing(path); return { usable: false, level: "error", message: fill(T.disk.volumeAbsent, { disco: vol.name ?? vol.root }), fix: fill(T.disk.volumeAbsentFix, { dove: path }), detail: `${vol.root} — ${err.detail ?? path}`, }; } const st = await statOf(path); if (!st) { return { usable: false, level: "error", message: vol.external ? fill(T.disk.volumeThereFolderMissing, { disco: vol.name ?? vol.root }) : T.disk.folderMissing, fix: fill(T.disk.folderMissingFix, { dove: path }), detail: vol.deepestExisting ? `esiste fino a ${vol.deepestExisting}` : path, }; } if (!st.isDirectory()) { return { usable: false, level: "error", message: fill(T.disk.notADir, { dove: path }), detail: path }; } if (!(await canAccess(path, FS.R_OK | FS.X_OK))) { return { usable: false, level: "error", message: fill(T.disk.notReadable, { dove: path }), fix: T.disk.notReadableFix, detail: path, }; } const free = await freeBytes(path); const space = free === null ? "" : ` ${fill(T.disk.space, { spazio: humanBytes(free) })}`; return { usable: true, level: "ok", message: (vol.external ? fill(T.disk.okExternal, { disco: vol.name ?? vol.root }) : T.disk.okInternal) + space, detail: path, }; } async function checkModels(cfg: ImgenConfig, diskUsable: boolean): Promise { const roles: (keyof ModelsConfig)[] = ["draft", "final", "alt", "edit", "upscale"]; const blocking = new Set(["draft", "final"]); if (!diskUsable) { return { level: "warn", message: T.models.skippedDiskOff, detail: cfg.modelsPath }; } const items: CheckItem[] = []; const missing: string[] = []; let blockingMissing = false; for (const role of roles) { const file = cfg.models[role]; const roleLabel = T.models.roles[role]; if (!file) { if (blocking.has(role)) { blockingMissing = true; items.push({ name: roleLabel, ok: false, status: T.models.unusedRole }); } continue; } if (isRemoteModelRef(file)) { items.push({ name: `${roleLabel} — ${file}`, ok: true, status: T.models.remote }); continue; } const full = join(cfg.modelsPath, file); const st = await statOf(full); if (!st?.isFile()) { items.push({ name: `${roleLabel} — ${file}`, ok: false, status: T.models.missing, detail: full }); missing.push(file); if (blocking.has(role)) blockingMissing = true; continue; } if (st.size < MIN_MODEL_BYTES) { // A 4-6GB checkpoint that weighs a few kB is an LFS pointer or a killed download. items.push({ name: `${roleLabel} — ${file}`, ok: false, status: T.models.truncated, detail: `${full} (${humanBytes(st.size)})`, }); missing.push(file); if (blocking.has(role)) blockingMissing = true; continue; } items.push({ name: `${roleLabel} — ${file}`, ok: true, status: `${T.models.present} (${humanBytes(st.size)})` }); } if (items.length === 0) { return { level: "error", message: T.models.noneConfigured, fix: fill(T.models.fixMissing, { dove: cfg.modelsPath }) }; } if (missing.length === 0 && !blockingMissing) { return { level: "ok", message: T.models.allOk, items }; } // One missing model gets the fully specific remedy from strings.ts; several get the // generic one, because five identical paragraphs are not a better message. const single = missing.length === 1 ? S.errors.modelMissing(missing[0]!, cfg.modelsPath) : null; return { level: blockingMissing ? "error" : "warn", message: single ? single.message : T.models.someMissing, fix: single?.fix ?? fill(T.models.fixMissing, { dove: cfg.modelsPath }), items, }; } async function checkDrawThings(): Promise { const bin = await findExecutable("draw-things-cli"); if (!bin) return fromError("error", S.errors.drawThingsMissing); return { level: "ok", message: fill(T.drawThings.ok, { dove: bin }), detail: bin }; } async function checkTypst(fast: boolean): Promise { const bin = await findExecutable("typst"); if (!bin) return fromError("error", S.errors.typstMissing); if (fast) return { level: "ok", message: fill(T.typst.ok, { dove: bin }), detail: bin }; const out = (await runBriefly(bin, ["--version"])) ?? ""; const m = /(\d+)\.(\d+)\.(\d+)/.exec(out); if (!m) return { level: "ok", message: fill(T.typst.ok, { dove: bin }), detail: bin }; const version = `${m[1]}.${m[2]}.${m[3]}`; const major = Number(m[1]); const minor = Number(m[2]); const old = major < TYPST_MIN[0] || (major === TYPST_MIN[0] && minor < TYPST_MIN[1]); if (old) { return { level: "warn", message: fill(T.typst.tooOld, { versione: version, minima: `${TYPST_MIN[0]}.${TYPST_MIN[1]}` }), fix: T.typst.tooOldFix, detail: bin, }; } return { level: "ok", message: fill(T.typst.okVersion, { versione: version, dove: bin }), detail: bin }; } /** * The daemon. NOTE: nothing here is an `error`. `draw-things-cli` works perfectly well * cold — it just reloads 6GB of weights on every single call, which turns a draft loop * into a coffee break. So: warn, never block. */ async function checkServer(cfg: ImgenConfig, piConfigDir: string): Promise { const paths = serverPaths(cfg, piConfigDir); const configFile = configPath(piConfigDir); const listening = await isPortOpen(cfg.server.port); if (!cfg.server.enabled) { return { level: listening ? "info" : "warn", message: T.server.disabled, fix: fill(T.server.disabledFix, { file: configFile }), detail: paths.binary, }; } // A binary on PATH is just as good as our own copy under the pi config dir. const own = await statOf(paths.binary); const bin = own?.isFile() ? paths.binary : await findExecutable(GRPC_SERVER_BINARY); if (!bin) { return { level: listening ? "info" : "warn", message: listening ? fill(T.server.foreign, { porta: cfg.server.port }) : fill(T.server.binaryMissing, { nome: GRPC_SERVER_BINARY }), ...(listening ? {} : { fix: fill(T.server.binaryMissingFix, { url: GRPC_SERVER_URL, dove: paths.stateDir }) }), detail: paths.binary, }; } if (!(await canAccess(bin, FS.X_OK))) { return { level: "warn", message: fill(T.server.notExecutable, { dove: bin }), fix: fill(T.server.notExecutableFix, { dove: bin }), detail: bin, }; } if (listening) { return { level: "ok", message: fill(T.server.running, { porta: cfg.server.port }), detail: `${bin} :${cfg.server.port}` }; } const command = `"${bin}" "${cfg.modelsPath}" --no-tls --port ${cfg.server.port}` + (cfg.server.cpuOffload ? " --cpu-offload" : ""); return { level: "warn", message: T.server.stopped, fix: fill(T.server.stoppedFix, { comando: command }), detail: bin, }; } // -- fonts ------------------------------------------------------------------- const FONT_EXT = /\.(ttf|otf|ttc|woff2?)$/i; const LICENSE_FILE = /^(license|licence|ofl|ufl|copying|apache)/i; const normalise = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]/g, ""); /** * vendor/fonts is written by install.sh from Fontsource * (`api.fontsource.org/v1/download/{id}` ships the TTFs *and* the LICENSE), so the * expected layout is one directory per family named after its Fontsource id. We accept * the google/fonts slug and the family name too, plus a flat dump of files, because a * doctor that only recognises one spelling reports false alarms. */ async function checkFonts(): Promise { const entries = await listDir(FONTS_DIR); const real = entries.filter((e) => !e.startsWith(".")); if (real.length === 0) { return { level: "error", message: T.fonts.empty, fix: T.fonts.emptyFix, detail: FONTS_DIR }; } // Index the tree once: directories with their files, plus any files at the top level. const dirs = new Map(); const flat: string[] = []; for (const entry of real) { const full = join(FONTS_DIR, entry); const st = await statOf(full); if (st?.isDirectory()) dirs.set(normalise(entry), await listDir(full)); else if (st?.isFile()) flat.push(entry); } const items: CheckItem[] = []; const missing: string[] = []; const unlicensed: string[] = []; for (const font of FONTS) { const keys = [font.id, font.slug, font.family].map(normalise); const dirKey = keys.find((k) => dirs.has(k)); let files: string[] = []; if (dirKey) { files = dirs.get(dirKey) ?? []; } else { // Flat layout: match "Anton-Regular.ttf", "anton-latin-400-normal.woff2", … files = flat.filter((f) => keys.some((k) => normalise(f).startsWith(k))); } const faces = files.filter((f) => FONT_EXT.test(f)); const hasLicense = files.some((f) => LICENSE_FILE.test(f)); if (faces.length === 0) { missing.push(font.family); items.push({ name: font.family, ok: false, status: T.models.missing }); continue; } if (!hasLicense) { unlicensed.push(font.family); items.push({ name: font.family, ok: false, status: `${T.fonts.noLicenseItem} (${font.license})` }); continue; } items.push({ name: font.family, ok: true, status: `${faces.length} · ${font.license}` }); } if (missing.length === 0 && unlicensed.length === 0) { return { level: "ok", message: fill(T.fonts.ok, { n: FONTS.length }), detail: fill(T.fonts.dir, { dove: FONTS_DIR }), items, }; } if (missing.length > 0) { // Every family missing = install.sh never ran; a couple missing = a partial download. return { level: missing.length === FONTS.length ? "error" : "warn", message: fill(T.fonts.missing, { n: missing.length, tot: FONTS.length }), fix: T.fonts.missingFix, detail: list(missing), items, }; } return { level: "warn", message: fill(T.fonts.noLicense, { n: unlicensed.length }), fix: T.fonts.noLicenseFix, detail: list(unlicensed), items, }; } /** * Print helpers. All optional by design: the PDF is produced by Typst alone. These only * let us VERIFY it (TrimBox, embedded fonts, integrity) before he sends it to a printer, * so their absence is never worse than `info`/`warn`. */ async function checkPrintTools(): Promise { const names = ["pdfinfo", "pdffonts", "qpdf", "gs"]; const items: CheckItem[] = []; const found: string[] = []; for (const name of names) { const bin = await findExecutable(name); const what = T.print.tools[name] ?? ""; if (bin) found.push(name); items.push({ name: `${name} — ${what}`, ok: Boolean(bin), status: bin ? T.models.present : T.models.missing, ...(bin ? { detail: bin } : {}), }); } if (found.length === names.length) return { level: "ok", message: T.print.allOk, items }; return { level: found.length === 0 ? "info" : "warn", message: found.length === 0 ? T.print.noneNeeded : T.print.someMissing, fix: T.print.fix, items, }; } /** * The only check that writes: creating the directory and putting one byte in it is the * only way to distinguish "writable" from "looks writable" (network shares, read-only * volumes, and full disks all pass a permission test and then fail on write). * * The volume test in front of the `mkdir` is not decoration. If the output directory * lives on the same external disk as the models and that disk is unplugged, a * `mkdir -p` would happily create `/Volumes//…` ON THE BOOT DISK — the stub that * makes macOS remount the real disk as " 1" next time. We refuse to write into a * volume that is not there. */ async function checkOutputDir(cfg: ImgenConfig): Promise { const dir = cfg.outputDir; if (!isAbsolute(dir)) { return { level: "error", message: fill(T.output.notAbsolute, { dove: dir }), detail: dir }; } const vol = await volumeOf(dir); if (vol.external && !vol.mounted) { return { level: "error", message: fill(T.output.volumeAbsent, { disco: vol.name ?? vol.root, dove: dir }), fix: fill(T.output.volumeAbsentFix, { disco: vol.name ?? vol.root }), detail: vol.root, }; } const existed = (await statOf(dir))?.isDirectory() ?? false; const probe = join(dir, `.imgen-doctor-${process.pid}`); try { await mkdir(dir, { recursive: true }); await writeFile(probe, "ok", "utf8"); } catch (e) { return fromError("error", S.errors.writeFailed(dir, (e as Error).message)); } finally { await rm(probe, { force: true }).catch(() => { /* nothing to clean up */ }); } const free = await freeBytes(dir); if (free !== null && free < LOW_DISK_BYTES) { return { level: "warn", message: fill(T.output.lowSpace, { spazio: humanBytes(free) }), fix: T.output.lowSpaceFix, detail: dir, }; } const space = free === null ? "" : ` ${fill(T.disk.space, { spazio: humanBytes(free) })}`; return { level: "ok", message: (existed ? fill(T.output.ok, { dove: dir }) : fill(T.output.created, { dove: dir })) + space, detail: dir, }; } // --------------------------------------------------------------------------- // Runner // --------------------------------------------------------------------------- const LABELS = S.doctor.checks; /** * Runs every check. NEVER throws, NEVER rejects: the worst it can return is a report * made entirely of `error` rows. * * Checks are ordered the way he would fix them: settings, machine, disk, models, tools. * The models check is deliberately sequenced AFTER the disk check and is fed its result, * so an unplugged disk produces one clear sentence instead of six ENOENTs. */ export async function runDoctor(opts: DoctorOptions = {}): Promise { const started = Date.now(); const piConfigDir = opts.piConfigDir ?? DEFAULT_PI_CONFIG_DIR; const configFile = configPath(piConfigDir); const fast = opts.fast ?? false; let config: ImgenConfig; let configProblems: string[]; if (opts.config) { config = opts.config; configProblems = opts.configProblems ?? []; } else { // loadConfig() is itself never-throwing and degrades to DEFAULTS. const loaded = loadConfig(piConfigDir); config = loaded.config; configProblems = loaded.problems; } const checks: CheckResult[] = []; checks.push(await check("config", LABELS.config, () => checkConfig(configFile, configProblems))); checks.push(await check("platform", "Il computer", () => checkPlatform())); // Disk first, then models, which need to know whether the disk answered at all. let diskUsable = false; const disk = await check("modelsDisk", LABELS.modelsDisk, async () => { const { usable, ...body } = await checkModelsDisk(config, configFile); diskUsable = usable; return body; }); checks.push(disk); checks.push(await check("models", LABELS.models, () => checkModels(config, diskUsable))); checks.push(await check("drawThings", LABELS.drawThings, () => checkDrawThings())); checks.push(await check("server", LABELS.server, () => checkServer(config, piConfigDir))); checks.push(await check("typst", LABELS.typst, () => checkTypst(fast))); checks.push(await check("fonts", LABELS.fonts, () => checkFonts())); checks.push(await check("printTools", T.print.label, () => checkPrintTools())); checks.push(await check("outputDir", LABELS.outputDir, () => checkOutputDir(config))); const problems = checks .filter((c) => c.level === "warn" || c.level === "error") .map((c) => c.message); return { ok: !checks.some((c) => c.level === "error"), warnings: checks.some((c) => c.level === "warn"), checks, problems, config, configFile, elapsedMs: Date.now() - started, }; } /** `Backend.probe()`-shaped view, for callers that only want the two fields. */ export async function probe(opts: DoctorOptions = {}): Promise<{ ok: boolean; problems: string[] }> { const report = await runDoctor(opts); return { ok: report.ok, problems: report.problems }; } // --------------------------------------------------------------------------- // Presentation // --------------------------------------------------------------------------- const MARK: Record = { ok: "✓", info: "·", warn: "!", error: "✗" }; export function isBlocking(c: CheckResult): boolean { return c.level === "error"; } /** The full Italian report, for `/doctor`. Pure string building; cannot throw. */ export function formatReport(report: DoctorReport, opts: { verbose?: boolean } = {}): string { const verbose = opts.verbose ?? false; const lines: string[] = [S.doctor.title, ""]; for (const c of report.checks) { lines.push(`${MARK[c.level]} ${c.label} — ${c.message}`); if (c.fix && c.level !== "ok") lines.push(` → ${c.fix}`); if (c.items && (verbose || c.level !== "ok")) { // On a healthy row the item list is noise; on a broken one it is the diagnosis. const items = verbose ? c.items : c.items.filter((i) => !i.ok); for (const i of items) lines.push(` ${i.ok ? "·" : "✗"} ${i.name}: ${i.status}`); } if (verbose && c.detail) lines.push(` ${T.report.detailPrefix}${c.detail}`); } lines.push(""); if (report.ok && !report.warnings) { lines.push(S.doctor.allGood); } else { lines.push(S.doctor.someProblems); lines.push(bullets(report.problems)); lines.push(S.doctor.hint); } return lines.join("\n"); } /** * One line for session_start. Returns null when everything is fine and there is nothing * worth saying — a health check that greets him every morning is a health check he stops * reading. */ export function sessionBanner(report: DoctorReport): string | null { if (report.ok && !report.warnings) return null; // One blocking fault gets the full two-line treatment: what happened, what to do. // Several get a list, because four remedies in a row at start-up is a wall of text. const blocking = report.checks.filter(isBlocking); if (blocking.length === 1) { const only = blocking[0]!; return errorText({ message: only.message, ...(only.fix ? { fix: only.fix } : {}) }); } const shown = blocking.length > 0 ? blocking.map((c) => c.message) : report.problems; return `${S.doctor.someProblems}\n${bullets(shown)}\n${S.doctor.hint}`; } // --------------------------------------------------------------------------- // Standalone entry point: `node --import jiti/register doctor.ts [--verbose]` // --------------------------------------------------------------------------- async function main(): Promise { const argv = process.argv.slice(2); const verbose = argv.includes("--verbose") || argv.includes("-v"); const dirFlag = argv.findIndex((a) => a === "--config-dir"); const piConfigDir = dirFlag >= 0 ? argv[dirFlag + 1] : undefined; const report = await runDoctor({ ...(piConfigDir ? { piConfigDir } : {}), }); process.stdout.write(`${formatReport(report, { verbose })}\n`); process.exitCode = report.ok ? 0 : 1; } const invokedDirectly = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href; if (invokedDirectly) { // Even the CLI path obeys rule 1: a failure here prints, it does not stack-trace. main().catch((e: unknown) => { process.stdout.write(`${errorText(S.errors.unknown((e as Error)?.message))}\n`); process.exitCode = 1; }); } export default runDoctor;