1091 lines
40 KiB
TypeScript
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;
|