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:
750
scripts/doctor.mts
Normal file
750
scripts/doctor.mts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user