release: v0.14.0

Fourteen commits since v0.13.0. See CHANGELOG.md for the full list.

Two additions: `npm run doctor`, which runs the config doctor from a terminal — including inside the shipped image — and exits with the worst finding, so it can gate a deployment script; and a Playwright spec that loads all fourteen admin routes against a production build.

One change, invisible on screen: the admin panel got a form-field layer (#533).

Five fixes: grid.gap on both axes (#513), the dashboard status badge (#539), the journal editor's unsaved-changes guard (#538), essay paragraph markup (#537), and aria-pressed on the panel's switches and pickers.
This commit is contained in:
Ralksta
2026-08-30 15:11:25 +02:00
committed by GitHub
parent a813433891
commit e99d3f2f1b
59 changed files with 8652 additions and 6676 deletions

750
scripts/doctor.mts Normal file
View File

@@ -0,0 +1,750 @@
/**
* Config doctor for the terminal — `npm run doctor` (#521).
*
* The panel version of this (`/admin` → status badge → Diagnostics) needs a
* running app and a working admin password. This one answers when neither is
* true, which is the situation people actually write support threads about.
*
* ── Why this file is a `.mts` run by plain `node` ────────────────────────
*
* The project has no TypeScript runner among its dependencies, and the issue
* offered two ways out: add `tsx` as a dev dependency, or compile a `.mjs`
* entry point during the build. Both fail the case this CLI exists for.
*
* The shipped image (`node:22-alpine`, `output: 'standalone'`) carries only
* production dependencies — `tsx` is not among them, and `npx tsx` in a
* container whose app is already broken means a network round trip to the
* registry before the diagnosis can start. A compiled `.mjs` would work, but
* only by adding a build step whose output has to be kept in the image and in
* sync with the source.
*
* Node 22.18 unflagged native type stripping, so `node scripts/doctor.mts`
* runs the TypeScript directly with no runner at all. It has one constraint:
* no path aliases, and every relative import needs its file extension. That
* rules out importing `lib/config` — but `lib/admin/doctor.ts` has no imports
* whatsoever (it is pure functions over gathered inputs, written that way in
* #491 precisely so a CLI could reuse it), so the entire TypeScript module
* graph of this script is two files. Nothing is duplicated that judges: the
* checks all come from `lib/admin/doctor.ts`.
*
* What *is* repeated here is the gathering — reading the filesystem and the
* environment instead of a request, exactly as the issue described. It is
* deliberately tolerant rather than a second `getConfig()`: it walks the parsed
* YAML for anything that looks like an album ID or a password rather than
* knowing the schema, so a config shape it has never seen still gets checked,
* and a config `getConfig()` would throw on still gets a report.
*
* ── Exit codes ───────────────────────────────────────────────────────────
*
* 0 every check passed
* 1 at least one warning, no errors
* 2 at least one error
* 3 the doctor itself could not run
*
* Nothing here ever prints a secret: `lib/admin/doctor.ts` reports only whether
* something is set and where to look, and the gathering below never puts a
* password, an API key or `AUTH_SECRET` into a finding.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
checkAlbumIds,
checkAlbumsShared,
checkAuthSecret,
checkImmichCalls,
checkPasswords,
worstLevel,
type AlbumRef,
type DoctorFinding,
type DoctorLevel,
type PasswordRef,
} from '../lib/admin/doctor.ts';
/**
* `process.env`, minus the `NODE_ENV`-is-required augmentation Next adds to
* `NodeJS.ProcessEnv` — which would force every test to hand over a NODE_ENV
* it does not care about.
*/
export type EnvLike = Record<string, string | undefined>;
// ── Exit codes ────────────────────────────────────────────────────────────
/** Worst finding level → process exit code. */
export const EXIT_CODES: Record<DoctorLevel, number> = { ok: 0, warn: 1, error: 2 };
/** The doctor itself failed — distinct from any finding it could have made. */
export const EXIT_INTERNAL = 3;
export function exitCodeFor(level: DoctorLevel): number {
return EXIT_CODES[level] ?? EXIT_INTERNAL;
}
// ── Environment ───────────────────────────────────────────────────────────
/**
* `.env.local` then `.env`, without overwriting anything already set.
*
* Next loads these for `npm run dev`; a bare `node` process does not, and a
* checkout that keeps its credentials there would otherwise be told it has no
* Immich configured. Real environment variables still win, which matches both
* Next's precedence and `getInstallCredentials()`.
*/
export function loadDotEnv(cwd: string, env: EnvLike = process.env): void {
for (const file of ['.env.local', '.env']) {
let raw: string;
try {
raw = fs.readFileSync(path.join(cwd, file), 'utf8');
} catch {
continue;
}
for (const line of raw.split('\n')) {
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
if (!match) continue;
const [, key, rest] = match;
if (key in env) continue;
let value = rest.trim();
if (
(value.startsWith('"') && value.endsWith('"') && value.length > 1) ||
(value.startsWith("'") && value.endsWith("'") && value.length > 1)
) {
value = value.slice(1, -1);
} else {
value = value.replace(/\s+#.*$/, '').trim();
}
env[key] = value;
}
}
}
export interface Credentials {
apiUrl: string;
apiKey: string;
authSecret: string;
}
/**
* Mirrors `getInstallCredentials()`: environment first, `content/install.json`
* underneath. A malformed or unreadable install file is treated as absent —
* the checks below then report what is missing.
*/
export function resolveCredentials(contentDir: string, env: EnvLike): Credentials {
let file: Record<string, unknown> = {};
try {
const parsed: unknown = JSON.parse(
fs.readFileSync(path.join(contentDir, 'install.json'), 'utf8'),
);
if (parsed && typeof parsed === 'object') file = parsed as Record<string, unknown>;
} catch {
// No install.json, or one that cannot be parsed. Env may still carry everything.
}
const str = (value: unknown) => (typeof value === 'string' ? value : '');
// env.ts normalises IMMICH_API_URL through `new URL()` and strips trailing
// slashes; an invalid value there becomes empty rather than throwing.
let apiUrl = env.IMMICH_API_URL || str(file.apiUrl);
if (apiUrl) {
try {
apiUrl = new URL(apiUrl).toString().replace(/\/+$/, '');
} catch {
apiUrl = '';
}
}
return {
apiUrl,
apiKey: env.IMMICH_API_KEY || str(file.apiKey),
authSecret: env.AUTH_SECRET || str(file.authSecret),
};
}
// ── Gathering from the YAML ───────────────────────────────────────────────
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
type YamlNode = unknown;
function isRecord(node: YamlNode): node is Record<string, unknown> {
return !!node && typeof node === 'object' && !Array.isArray(node);
}
/**
* Every album ID reachable in the tree, deduplicated and in document order.
*
* Anything under an `albums:` list counts, whatever nests it — standalone
* albums, subpages in either the list or the map form, sections. An entry is
* either the ID itself or a single-key object whose key is the ID (the form
* that carries a title, a password or a sort mode). Only well-formed UUIDs are
* kept, matching `validateUuid()`, which drops the rest rather than passing a
* typo on to Immich.
*/
export function collectAlbumIds(node: YamlNode): string[] {
const found: string[] = [];
const takeEntry = (entry: unknown) => {
if (typeof entry === 'string') {
if (UUID_REGEX.test(entry.trim())) found.push(entry.trim());
return;
}
if (isRecord(entry)) {
const key = Object.keys(entry)[0];
if (key && UUID_REGEX.test(key.trim())) found.push(key.trim());
// The value may itself hold nested config, but never further album IDs.
walk(entry);
}
};
const walk = (current: YamlNode) => {
if (Array.isArray(current)) {
current.forEach(walk);
return;
}
if (!isRecord(current)) return;
for (const [key, value] of Object.entries(current)) {
if (key === 'albums' && Array.isArray(value)) {
value.forEach(takeEntry);
continue;
}
// assetOrder and hero hold asset IDs, not albums — skipped by name.
if (key === 'assetOrder' || key === 'hero') continue;
walk(value);
}
};
walk(node);
return [...new Set(found)];
}
/**
* Every `password:` in the tree, labelled by where it sits.
*
* The label is a YAML path rather than a slug: it is what the reader has to
* open the file and find the value, and unlike a slug it cannot go stale when
* the schema grows a new place to put a password. The value travels only as far
* as `checkPasswords()`, which reports the storage format and never the secret.
*/
export function collectPasswords(node: YamlNode, source: string): PasswordRef[] {
const found: PasswordRef[] = [];
const walk = (current: YamlNode, trail: string) => {
if (Array.isArray(current)) {
current.forEach((item, i) => walk(item, `${trail}[${i}]`));
return;
}
if (!isRecord(current)) return;
for (const [key, value] of Object.entries(current)) {
const here = trail ? `${trail}.${key}` : key;
if ((key === 'password' || key === 'sitePassword') && typeof value === 'string' && value) {
// Name the subpage or album rather than the index where one is at hand.
const name = typeof current.name === 'string' ? ` (${current.name})` : '';
found.push({ label: `${source} ${here}${name}`, value });
continue;
}
walk(value, here);
}
};
walk(node, '');
return found;
}
/** The `password:` of a journal entry's frontmatter, if it has one. */
export function frontmatterPassword(markdown: string): string | null {
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(markdown);
if (!match) return null;
for (const line of match[1].split('\n')) {
const found = /^\s*password\s*:\s*(.+?)\s*$/.exec(line);
if (!found) continue;
const value = found[1].replace(/^["']|["']$/g, '');
return value || null;
}
return null;
}
export interface UnwritablePath {
/** As the report names it, e.g. `content/.backups`. */
label: string;
/** Owner of the path, so the report can say whose permission problem it is. */
ownerUid: number;
}
/**
* Content paths that exist but refuse writes. Absent is fine — see the route.
*
* The owner comes back with each path because "not writable" from a terminal
* is only half a finding: the panel asks as the app's own user, a CLI asks as
* whoever typed the command, and on a Docker deployment those differ (#551).
*/
export function findUnwritable(contentDir: string): UnwritablePath[] {
const unwritable: UnwritablePath[] = [];
for (const dir of ['', '.backups', 'journal']) {
const target = path.join(contentDir, dir);
try {
fs.accessSync(target, fs.constants.W_OK);
} catch {
try {
const stat = fs.statSync(target);
unwritable.push({ label: `content/${dir}`.replace(/\/$/, ''), ownerUid: stat.uid });
} catch {
// Not created yet: the app makes it on first write.
}
}
}
return unwritable;
}
/** The uid this process runs as, or null where the platform has none. */
export function currentUid(): number | null {
return typeof process.getuid === 'function' ? process.getuid() : null;
}
/**
* The writability finding, in the CLI's own words.
*
* `checkWritable()` in lib/admin/doctor.ts says "saving from the admin panel
* will fail", which is true where it runs — inside the app, as the app's user.
* A CLI tests whoever typed the command. Run from the host against a container
* that writes as uid 1001, that produced an error on a deployment with nothing
* wrong with it, and the obvious reaction (chown the volume from the host) is
* the one change that would actually break the container's access (#551).
*
* So the level follows what the answer is worth:
*
* - running as the owner — the app's own user, or a bare-metal install — the
* result is about the app, and stays an error.
* - anyone else: reported, attributed, and left in NOTES, because the CLI
* cannot tell whether the user it tested is the one that matters.
*
* The id stays `content-writable`, so the panel and the CLI still name the
* same check.
*/
export function reportWritable(unwritable: UnwritablePath[], uid: number | null): CliFinding {
if (!unwritable.length) {
return {
id: 'content-writable',
level: 'ok',
title: 'content/ is writable',
detail: 'Config, journal and backups can be saved.',
};
}
const labels = unwritable.map((u) => u.label).join(', ');
const count = unwritable.length;
const noun = `${count} content ${count === 1 ? 'path is' : 'paths are'} not writable`;
// No getuid(): Windows. Nothing can be attributed, so nothing is claimed.
if (uid === null) {
return {
id: 'content-writable',
level: 'ok',
note: true,
title: `${noun} for this user`,
detail:
'Checked as the account running this command, which may not be the account the app ' +
`runs as. If it is, saving from the admin panel will fail, and so will backups: ${labels}`,
};
}
if (unwritable.every((u) => u.ownerUid === uid)) {
return {
id: 'content-writable',
level: 'error',
title: noun,
detail:
'Saving from the admin panel will fail, and so will backups. Check the ownership of the ' +
`mounted volume: ${labels}`,
};
}
const owners = [...new Set(unwritable.map((u) => u.ownerUid))].join(', ');
return {
id: 'content-writable',
level: 'ok',
note: true,
title: `${noun} by uid ${uid} — but ${count === 1 ? 'it is' : 'they are'} owned by uid ${owners}`,
detail:
'Checked as the user running this command, not as the user the app runs as. The paths ' +
`belong to uid ${owners}, which can write them; the Docker image runs the app as uid 1001. ` +
`This only matters if the app itself runs as uid ${uid}. To check as the app: ` +
`docker compose exec folio npm run doctor. Paths: ${labels}`,
};
}
// ── Report formatting ─────────────────────────────────────────────────────
const LEVEL_ORDER: DoctorLevel[] = ['error', 'warn', 'ok'];
const LEVEL_LABEL: Record<DoctorLevel, string> = {
error: 'ERRORS',
warn: 'WARNINGS',
ok: 'PASSED',
};
/**
* A finding, plus whether the CLI actually established it.
*
* Notes carry level `ok` so they cannot skew `worstLevel()` or the exit code,
* but listing them under PASSED would claim a check that never ran. They get
* their own group instead. The flag lives here rather than on `DoctorFinding`
* because the type is shared with the panel, where the same checks are real —
* being unverifiable is a fact about the terminal, not about the finding.
*
* It is per finding rather than per id: `content-writable` is a note when the
* CLI ran as someone other than the owner, and a genuine error when it did not
* (#551).
*/
export type CliFinding = DoctorFinding & { note?: boolean };
const NOTE_LABEL = 'NOTES';
const NOTE_MARK = 'i';
const NOTE_COLOR = '\x1b[36m';
const LEVEL_MARK: Record<DoctorLevel, string> = { error: 'x', warn: '!', ok: 'v' };
/** Hand-written, so the CLI needs no colour dependency. */
const LEVEL_COLOR: Record<DoctorLevel, string> = {
error: '\x1b[31m',
warn: '\x1b[33m',
ok: '\x1b[32m',
};
const DIM = '\x1b[2m';
const BOLD = '\x1b[1m';
const RESET = '\x1b[0m';
/** Soft-wraps at a word boundary; a single long token is left intact. */
export function wrap(text: string, width: number, indent: string): string[] {
const lines: string[] = [];
let line = '';
for (const word of text.split(/\s+/).filter(Boolean)) {
if (line && (line + ' ' + word).length > width) {
lines.push(indent + line);
line = word;
} else {
line = line ? `${line} ${word}` : word;
}
}
if (line) lines.push(indent + line);
return lines;
}
export interface FormatOptions {
color?: boolean;
width?: number;
}
/** The whole report as one string, so it can be asserted on in a test. */
export function formatReport(findings: CliFinding[], options: FormatOptions = {}): string {
const color = options.color ?? false;
const width = Math.max(40, Math.min(options.width ?? 80, 120));
const paint = (code: string, text: string) => (color ? `${code}${text}${RESET}` : text);
const out: string[] = ['', paint(BOLD, 'Immich Folio — config doctor'), ''];
const notes = findings.filter((f) => f.note === true);
const checks = findings.filter((f) => f.note !== true);
const group = (label: string, color: string, mark: string, group: CliFinding[]) => {
if (!group.length) return;
out.push(paint(color + BOLD, `${label} (${group.length})`));
for (const finding of group) {
out.push(` ${paint(color, mark)} ${finding.title}`);
for (const line of wrap(finding.detail, width - 6, ' ')) {
out.push(paint(DIM, line));
}
out.push('');
}
};
for (const level of LEVEL_ORDER) {
// Notes sit between the warnings and the passes: they are not a problem,
// but they are the part of the report a reader still has to act on.
if (level === 'ok') group(NOTE_LABEL, NOTE_COLOR, NOTE_MARK, notes);
group(
LEVEL_LABEL[level],
LEVEL_COLOR[level],
LEVEL_MARK[level],
checks.filter((f) => f.level === level),
);
}
const counts = LEVEL_ORDER.map((level) => checks.filter((f) => f.level === level).length);
const [errors, warns, oks] = counts;
out.push(
paint(
BOLD,
`${errors} error${errors === 1 ? '' : 's'}, ` +
`${warns} warning${warns === 1 ? '' : 's'}, ` +
`${oks} check${oks === 1 ? '' : 's'} passed` +
(notes.length ? `, ${notes.length} note${notes.length === 1 ? '' : 's'}` : '') +
`.`,
),
);
out.push('');
return out.join('\n');
}
/** Colour unless the output is piped, or NO_COLOR / FORCE_COLOR says otherwise. */
export function shouldUseColor(
env: EnvLike = process.env,
isTty: boolean = process.stdout.isTTY === true,
): boolean {
if (env.NO_COLOR) return false;
if (env.FORCE_COLOR && env.FORCE_COLOR !== '0') return true;
return isTty;
}
// ── The run ───────────────────────────────────────────────────────────────
/**
* `TRUSTED_PROXY_HOPS` cannot be checked from a terminal.
*
* `checkProxyHops()` compares the configured value against the
* `X-Forwarded-For` chain of a live request, and there is none here. Rather
* than guess — a wrong guess in either direction points at a rate-limiting bug
* that is not there — the CLI reports the configured value and says where the
* real check lives.
*/
function reportProxyHops(env: EnvLike): CliFinding {
const raw = env.TRUSTED_PROXY_HOPS;
const hops = raw && !isNaN(parseInt(raw, 10)) ? Math.max(0, parseInt(raw, 10)) : 0;
return {
id: 'proxy-hops',
level: 'ok',
note: true,
title: `TRUSTED_PROXY_HOPS is ${hops}${raw ? '' : ' (unset)'} — not verifiable from the terminal`,
detail:
'Whether this is right can only be judged against a real request: the value says how far ' +
'from the right of X-Forwarded-For the client IP sits, and no request reaches a CLI. Open ' +
'the Diagnostics panel in /admin over your public URL to have it measured. One reverse ' +
'proxy (nginx, Traefik or Caddy) alone is 1; no proxy at all is 0.',
};
}
async function gatherFindings(cwd: string, env: EnvLike): Promise<CliFinding[]> {
const contentDir = env.INSTALL_CONTENT_DIR || path.join(cwd, 'content');
const findings: CliFinding[] = [];
const credentials = resolveCredentials(contentDir, env);
findings.push(checkAuthSecret(credentials.authSecret || undefined));
findings.push(reportProxyHops(env));
// ── The YAML, read tolerantly ───────────────────────────────────────
let yamlLoad: ((input: string) => unknown) | null = null;
try {
yamlLoad = (await import('js-yaml')).load as (input: string) => unknown;
} catch {
// Reported once below, against whichever file needed it.
}
const readYaml = (filename: string): { node: YamlNode; error: string | null } => {
let raw: string;
try {
raw = fs.readFileSync(path.join(contentDir, filename), 'utf8');
} catch {
return { node: null, error: null }; // Absent, which each caller judges itself.
}
if (!yamlLoad) return { node: null, error: 'js-yaml is not installed' };
try {
return { node: yamlLoad(raw) ?? {}, error: null };
} catch (error) {
// js-yaml appends a snippet of the offending lines; the first line
// already names the fault and the position, and the rest turns into
// noise once it is wrapped.
const message = error instanceof Error ? error.message : String(error);
return { node: null, error: message.split('\n')[0] };
}
};
const gallery = readYaml('gallery.yaml');
const settings = readYaml('settings.yaml');
const galleryExists = fs.existsSync(path.join(contentDir, 'gallery.yaml'));
if (!galleryExists) {
findings.push({
id: 'gallery-yaml',
level: 'error',
title: 'content/gallery.yaml does not exist',
detail:
`Nothing is published yet. Run the setup wizard at /install, or copy ` +
`content/gallery.yaml.example to content/gallery.yaml and put your album IDs in it.`,
});
} else if (gallery.error) {
findings.push({
id: 'gallery-yaml',
level: 'error',
title: 'content/gallery.yaml could not be parsed',
detail: `The site falls back to the setup screen until this is fixed. ${gallery.error}. A working copy may be waiting in content/.backups/.`,
});
} else {
findings.push({
id: 'gallery-yaml',
level: 'ok',
title: 'content/gallery.yaml parses',
detail: 'The gallery structure could be read.',
});
}
if (settings.error) {
findings.push({
id: 'settings-yaml',
level: 'error',
title: 'content/settings.yaml could not be parsed',
detail: `Site title, theme and grid defaults fall back to their built-in values. ${settings.error}`,
});
}
const configuredAlbums = collectAlbumIds(gallery.node);
// ── Immich: the three calls Folio depends on ────────────────────────
const calls: Array<{ endpoint: string; ok: boolean }> = [];
let albums: AlbumRef[] = [];
if (!credentials.apiUrl || !credentials.apiKey) {
findings.push({
id: 'immich-api',
level: 'error',
title: 'No Immich URL or API key configured',
detail:
'Set IMMICH_API_URL and IMMICH_API_KEY, or run the setup wizard at /install. ' +
(credentials.apiUrl ? 'The API key is missing.' : 'The URL is missing or not a valid URL.'),
});
} else {
// getConfig() appends /api unless it is already there.
const base = credentials.apiUrl.endsWith('/api')
? credentials.apiUrl
: `${credentials.apiUrl}/api`;
const timeout = Math.max(1000, parseInt(env.IMMICH_TIMEOUT_MS || '', 10) || 15000);
const headers = { 'x-api-key': credentials.apiKey, Accept: 'application/json' };
const call = async (endpoint: string, init?: RequestInit) => {
try {
const res = await fetch(`${base}${endpoint}`, {
headers,
signal: AbortSignal.timeout(timeout),
...init,
});
calls.push({ endpoint, ok: res.ok });
return res.ok ? res : null;
} catch {
calls.push({ endpoint, ok: false });
return null;
}
};
await call('/server/ping');
const albumRes = await call('/albums');
await call('/search/metadata', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ size: 1, page: 1 }),
});
if (albumRes) {
try {
const parsed: unknown = await albumRes.json();
if (Array.isArray(parsed)) albums = parsed as AlbumRef[];
} catch {
// A malformed body is already reflected by the call above.
}
}
findings.push(checkImmichCalls(calls));
}
if (albums.length) {
findings.push(checkAlbumIds(configuredAlbums, albums));
findings.push(checkAlbumsShared(configuredAlbums, albums));
} else if (galleryExists && !gallery.error && !configuredAlbums.length) {
findings.push({
id: 'album-ids',
level: 'warn',
title: 'No albums configured',
detail: 'gallery.yaml lists no album IDs, so the gallery has nothing to show.',
});
}
// ── Passwords, from every file one can live in ──────────────────────
const passwords: PasswordRef[] = [
...(env.SITE_PASSWORD ? [{ label: 'SITE_PASSWORD', value: env.SITE_PASSWORD }] : []),
...collectPasswords(settings.node, 'settings.yaml'),
...collectPasswords(gallery.node, 'gallery.yaml'),
];
for (const dir of ['journal', 'essays']) {
let entries: string[];
try {
entries = fs.readdirSync(path.join(contentDir, dir));
} catch {
continue; // Optional directories.
}
for (const name of entries.filter((n) => n.endsWith('.md'))) {
try {
const password = frontmatterPassword(
fs.readFileSync(path.join(contentDir, dir, name), 'utf8'),
);
if (password) passwords.push({ label: `${dir}/${name}`, value: password });
} catch {
// An unreadable entry is covered by the writability check below.
}
}
}
findings.push(checkPasswords(passwords));
// ── Writability of the content volume ───────────────────────────────
if (!fs.existsSync(contentDir)) {
findings.push({
id: 'content-writable',
level: 'error',
title: 'content/ does not exist',
detail:
`Nothing can be saved and nothing can be published. Expected it at ${contentDir} — ` +
'run the doctor from the project root, or check that the volume is mounted.',
});
} else {
findings.push(reportWritable(findUnwritable(contentDir), currentUid()));
}
return findings;
}
export async function main(
cwd: string = process.cwd(),
env: EnvLike = process.env,
): Promise<number> {
loadDotEnv(cwd, env);
let findings: CliFinding[];
try {
findings = await gatherFindings(cwd, env);
} catch (error) {
// A raw stack trace is never the right answer here: the person running this
// is already looking at something broken.
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(
`\nThe config doctor could not finish: ${message}\n` +
`Run it from the project root, and please report this at ` +
`https://github.com/ralksta/immich-folio/issues\n\n`,
);
return EXIT_INTERNAL;
}
process.stdout.write(
formatReport(findings, {
color: shouldUseColor(env),
width: process.stdout.columns || 80,
}),
);
return exitCodeFor(worstLevel(findings));
}
// Only when run directly. `npm run doctor` goes through scripts/doctor.mjs,
// which checks the Node version first; the tests import the helpers above.
const invoked = process.argv[1] ? path.resolve(process.argv[1]) : '';
if (invoked && invoked === fileURLToPath(import.meta.url)) {
process.exitCode = await main();
}