Files
pi-imgen/extensions/imgen/doctor.ts
T

1091 lines
40 KiB
TypeScript

/**
* 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<keyof ModelsConfig, string>,
},
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<string, string>,
},
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<import("node:fs").Stats | null> {
try {
return await stat(path);
} catch {
return null;
}
}
async function canAccess(path: string, mode: number): Promise<boolean> {
try {
await access(path, mode);
return true;
} catch {
return false;
}
}
async function listDir(path: string): Promise<string[]> {
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<string | null> {
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<string | null> {
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<typeof spawn> | 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<number | null> {
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/<Nome>`, 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/<Nome>` 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<VolumeInfo> {
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<CheckResult, "id" | "label">;
/** Rule 1, in one function: no check can ever escape with an exception. */
async function check(id: CheckId, label: string, body: () => Promise<CheckBody>): Promise<CheckResult> {
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<CheckBody> {
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<CheckBody> {
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<CheckBody & { usable: boolean }> {
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<CheckBody> {
const roles: (keyof ModelsConfig)[] = ["draft", "final", "alt", "edit", "upscale"];
const blocking = new Set<keyof ModelsConfig>(["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<CheckBody> {
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<CheckBody> {
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<CheckBody> {
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<CheckBody> {
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<string, string[]>();
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<CheckBody> {
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/<Nome>/…` ON THE BOOT DISK — the stub that
* makes macOS remount the real disk as "<Nome> 1" next time. We refuse to write into a
* volume that is not there.
*/
async function checkOutputDir(cfg: ImgenConfig): Promise<CheckBody> {
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<DoctorReport> {
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<CheckLevel, string> = { 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<void> {
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;