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

View File

@@ -0,0 +1,427 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import yaml from 'js-yaml';
import type { CliFinding } from '../doctor.mts';
import {
EXIT_CODES,
EXIT_INTERNAL,
collectAlbumIds,
collectPasswords,
exitCodeFor,
currentUid,
findUnwritable,
formatReport,
frontmatterPassword,
reportWritable,
loadDotEnv,
resolveCredentials,
shouldUseColor,
wrap,
type EnvLike,
} from '../doctor.mts';
import type { DoctorFinding } from '../../lib/admin/doctor';
/**
* The CLI's own logic — the gathering, the formatting and the exit-code
* mapping. The checks themselves are covered by lib/__tests__/doctor.test.ts
* and are imported, not reimplemented (#521).
*/
function tmpdir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'folio-doctor-'));
}
describe('exitCodeFor', () => {
it('maps clean to 0 and the rest upwards', () => {
expect(exitCodeFor('ok')).toBe(0);
expect(exitCodeFor('warn')).toBe(1);
expect(exitCodeFor('error')).toBe(2);
});
it('keeps the internal failure code out of the finding range', () => {
expect(EXIT_INTERNAL).toBeGreaterThan(Math.max(...Object.values(EXIT_CODES)));
});
});
describe('collectAlbumIds', () => {
it('finds standalone, subpage and section albums in one pass', () => {
const gallery = yaml.load(`
albums:
- "11111111-1111-4111-8111-111111111111"
subpages:
- name: Travel
albums:
- "22222222-2222-4222-8222-222222222222"
- name: Japan
sections:
- title: Kyoto
albums:
- "33333333-3333-4333-8333-333333333333"
`);
expect(collectAlbumIds(gallery)).toEqual([
'11111111-1111-4111-8111-111111111111',
'22222222-2222-4222-8222-222222222222',
'33333333-3333-4333-8333-333333333333',
]);
});
it('reads the ID off the object form that carries a title or a password', () => {
const gallery = yaml.load(`
albums:
- "44444444-4444-4444-8444-444444444444": Hokkaido
- "55555555-5555-4555-8555-555555555555":
title: Kyoto
sort: filename
`);
expect(collectAlbumIds(gallery)).toEqual([
'44444444-4444-4444-8444-444444444444',
'55555555-5555-4555-8555-555555555555',
]);
});
it('handles the map form of subpages', () => {
const gallery = yaml.load(`
subpages:
Travel:
albums:
- "66666666-6666-4666-8666-666666666666"
`);
expect(collectAlbumIds(gallery)).toEqual(['66666666-6666-4666-8666-666666666666']);
});
/** hero and assetOrder hold asset UUIDs — reporting them as albums would
* turn every configured hero image into a "does not exist" error. */
it('does not mistake asset IDs for album IDs', () => {
const gallery = yaml.load(`
hero:
- "77777777-7777-4777-8777-777777777777"
albums:
- "88888888-8888-4888-8888-888888888888":
sort: manual
assetOrder:
- "99999999-9999-4999-8999-999999999999"
`);
expect(collectAlbumIds(gallery)).toEqual(['88888888-8888-4888-8888-888888888888']);
});
/** validateUuid() drops these rather than passing a typo on to Immich. */
it('drops entries that are not UUIDs, and deduplicates', () => {
const gallery = yaml.load(`
albums:
- "your-album-uuid-1"
- "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
subpages:
- name: Travel
albums:
- "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
`);
expect(collectAlbumIds(gallery)).toEqual(['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa']);
});
it('survives an empty or non-object document', () => {
expect(collectAlbumIds(null)).toEqual([]);
expect(collectAlbumIds('just a string')).toEqual([]);
});
});
describe('collectPasswords', () => {
it('finds a password wherever it sits, and labels it by path', () => {
const gallery = yaml.load(`
subpages:
- name: Projects
password: "hunter2"
albums:
- "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb":
password: "scrypt:a:b"
`);
const found = collectPasswords(gallery, 'gallery.yaml');
expect(found).toHaveLength(2);
expect(found[0].label).toContain('Projects');
expect(found[0].value).toBe('hunter2');
expect(found[1].label).toContain('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb');
});
it('picks up the site password from settings.yaml', () => {
const settings = yaml.load('sitePassword: "scrypt:a:b"');
const found = collectPasswords(settings, 'settings.yaml');
expect(found).toHaveLength(1);
expect(found[0].label).toBe('settings.yaml sitePassword');
});
it('ignores an empty password key', () => {
expect(collectPasswords(yaml.load('password: ""'), 'gallery.yaml')).toEqual([]);
});
});
describe('frontmatterPassword', () => {
it('reads a quoted or bare value', () => {
expect(frontmatterPassword('---\ntitle: A\npassword: "scrypt:a:b"\n---\n\nbody')).toBe(
'scrypt:a:b',
);
expect(frontmatterPassword('---\npassword: hunter2\n---\n')).toBe('hunter2');
});
it('returns null without frontmatter, or without a password in it', () => {
expect(frontmatterPassword('# Just markdown')).toBeNull();
expect(frontmatterPassword('---\ntitle: A\n---\n')).toBeNull();
});
/** The body of an entry may well talk about passwords. */
it('does not look past the frontmatter', () => {
expect(frontmatterPassword('---\ntitle: A\n---\n\npassword: not-one\n')).toBeNull();
});
});
describe('resolveCredentials', () => {
it('prefers the environment over install.json', () => {
const dir = tmpdir();
fs.writeFileSync(
path.join(dir, 'install.json'),
JSON.stringify({ apiUrl: 'http://file:2283', apiKey: 'file-key', authSecret: 'file-secret' }),
);
const creds = resolveCredentials(dir, { IMMICH_API_URL: 'http://env:2283' });
expect(creds.apiUrl).toBe('http://env:2283');
expect(creds.apiKey).toBe('file-key');
expect(creds.authSecret).toBe('file-secret');
});
it('treats an unparseable install.json as absent', () => {
const dir = tmpdir();
fs.writeFileSync(path.join(dir, 'install.json'), '{ not json');
expect(resolveCredentials(dir, {})).toEqual({ apiUrl: '', apiKey: '', authSecret: '' });
});
it('strips the trailing slash and empties an invalid URL, as env.ts does', () => {
const dir = tmpdir();
expect(resolveCredentials(dir, { IMMICH_API_URL: 'http://immich:2283/' }).apiUrl).toBe(
'http://immich:2283',
);
expect(resolveCredentials(dir, { IMMICH_API_URL: 'not a url' }).apiUrl).toBe('');
});
});
describe('loadDotEnv', () => {
it('reads .env.local ahead of .env, and never overwrites a real variable', () => {
const dir = tmpdir();
fs.writeFileSync(path.join(dir, '.env'), 'IMMICH_API_KEY=from-env\nAUTH_SECRET=from-env\n');
fs.writeFileSync(path.join(dir, '.env.local'), 'AUTH_SECRET="from-local"\n');
const env: EnvLike = { IMMICH_API_KEY: 'from-process' };
loadDotEnv(dir, env);
expect(env.IMMICH_API_KEY).toBe('from-process');
expect(env.AUTH_SECRET).toBe('from-local');
});
it('drops trailing comments on unquoted values and keeps quoted ones whole', () => {
const dir = tmpdir();
fs.writeFileSync(
path.join(dir, '.env'),
'A=plain # a comment\nB="quoted # hash"\nexport C=exported\n',
);
const env: EnvLike = {};
loadDotEnv(dir, env);
expect(env.A).toBe('plain');
expect(env.B).toBe('quoted # hash');
expect(env.C).toBe('exported');
});
it('is silent when neither file exists', () => {
const env: EnvLike = {};
expect(() => loadDotEnv(tmpdir(), env)).not.toThrow();
expect(env).toEqual({});
});
});
describe('findUnwritable', () => {
it('reports nothing for a writable content directory', () => {
const dir = tmpdir();
fs.mkdirSync(path.join(dir, 'journal'));
expect(findUnwritable(dir)).toEqual([]);
});
/** The app creates these on first write; only one that exists and refuses
* writes is a fault. */
it('does not report a directory that has not been created yet', () => {
expect(findUnwritable(tmpdir())).toEqual([]);
});
it('reports the owner alongside a path it cannot write', () => {
const dir = tmpdir();
const backups = path.join(dir, '.backups');
fs.mkdirSync(backups);
fs.chmodSync(backups, 0o555);
try {
const found = findUnwritable(dir);
// Running as root, 0555 is still writable — the case cannot be staged.
if (currentUid() === 0) return;
expect(found).toHaveLength(1);
expect(found[0].label).toBe('content/.backups');
expect(found[0].ownerUid).toBe(fs.statSync(backups).uid);
} finally {
fs.chmodSync(backups, 0o755);
}
});
});
/**
* The CLI tests whoever typed the command; the panel tests the app's own user.
* On a Docker deployment those differ, and calling that an error accused a
* healthy install (#551).
*/
describe('reportWritable', () => {
const mine = { label: 'content/.backups', ownerUid: 1000 };
const theirs = { label: 'content/.backups', ownerUid: 1001 };
it('passes when everything is writable', () => {
const finding = reportWritable([], 1000);
expect(finding.level).toBe('ok');
expect(finding.note).toBeUndefined();
expect(finding.title).toBe('content/ is writable');
});
it('is a real error when the CLI runs as the owner', () => {
const finding = reportWritable([mine], 1000);
expect(finding.level).toBe('error');
expect(finding.note).toBeUndefined();
expect(finding.detail).toContain('Saving from the admin panel will fail');
});
it('is a note, not an error, when the owner is someone else', () => {
const finding = reportWritable([theirs], 1000);
expect(finding.level).toBe('ok');
expect(finding.note).toBe(true);
expect(finding.title).toContain('uid 1000');
expect(finding.title).toContain('uid 1001');
expect(finding.detail).toContain('docker compose exec');
});
it('does not downgrade a mixed result to a note', () => {
const finding = reportWritable([mine, theirs], 1000);
expect(finding.level).toBe('ok');
expect(finding.note).toBe(true);
});
it('claims nothing on a platform without uids', () => {
const finding = reportWritable([theirs], null);
expect(finding.level).toBe('ok');
expect(finding.note).toBe(true);
expect(finding.title).not.toContain('undefined');
expect(finding.detail).toContain('may not be the account the app');
});
it('keeps the id the panel uses, whatever the outcome', () => {
for (const finding of [
reportWritable([], 1000),
reportWritable([mine], 1000),
reportWritable([theirs], 1000),
reportWritable([theirs], null),
]) {
expect(finding.id).toBe('content-writable');
}
});
});
describe('wrap', () => {
it('breaks at word boundaries and indents every line', () => {
expect(wrap('one two three four', 9, ' ')).toEqual([' one two', ' three', ' four']);
});
it('leaves a token longer than the width intact', () => {
expect(wrap('aaaaaaaaaaaa', 4, '')).toEqual(['aaaaaaaaaaaa']);
});
});
describe('formatReport', () => {
const findings: DoctorFinding[] = [
{ id: 'a', level: 'ok', title: 'Fine', detail: 'Nothing to do.' },
{ id: 'b', level: 'error', title: 'Broken', detail: 'Fix it.' },
{ id: 'c', level: 'warn', title: 'Odd', detail: 'Have a look.' },
];
it('groups by level, worst first', () => {
const report = formatReport(findings);
expect(report.indexOf('ERRORS (1)')).toBeLessThan(report.indexOf('WARNINGS (1)'));
expect(report.indexOf('WARNINGS (1)')).toBeLessThan(report.indexOf('PASSED (1)'));
});
it('ends with a count of each level', () => {
expect(formatReport(findings)).toContain('1 error, 1 warning, 1 check passed.');
expect(formatReport([])).toContain('0 errors, 0 warnings, 0 checks passed.');
});
it('omits a level nobody hit', () => {
const report = formatReport([findings[0]]);
expect(report).not.toContain('ERRORS');
expect(report).not.toContain('WARNINGS');
});
it('emits no escape sequences unless colour is asked for', () => {
const ansi = /\u001b\[/;
expect(ansi.test(formatReport(findings))).toBe(false);
expect(ansi.test(formatReport(findings, { color: true }))).toBe(true);
});
// The proxy-hop finding carries level `ok` so it cannot skew the exit code,
// but the CLI never checked it — listing it under PASSED would claim a check
// that did not run.
describe('notes', () => {
const proxy: CliFinding = {
id: 'proxy-hops',
level: 'ok',
note: true,
title: 'TRUSTED_PROXY_HOPS is 0 (unset) — not verifiable from the terminal',
detail: 'Open the Diagnostics panel to have it measured.',
};
it('needs the flag, not the id: the same check can be a real finding', () => {
const notANote = { ...proxy, note: undefined };
const report = formatReport([notANote]);
expect(report).not.toContain('NOTES');
expect(report).toContain('PASSED (1)');
});
it('lists a note in its own group rather than under PASSED', () => {
const report = formatReport([...findings, proxy]);
expect(report).toContain('NOTES (1)');
expect(report).toContain('PASSED (1)');
expect(report).toContain(proxy.title);
expect(report.indexOf(proxy.title)).toBeGreaterThan(report.indexOf('NOTES (1)'));
expect(report.indexOf(proxy.title)).toBeLessThan(report.indexOf('PASSED (1)'));
});
it('sits between the warnings and the passes', () => {
const report = formatReport([...findings, proxy]);
expect(report.indexOf('WARNINGS (1)')).toBeLessThan(report.indexOf('NOTES (1)'));
expect(report.indexOf('NOTES (1)')).toBeLessThan(report.indexOf('PASSED (1)'));
});
it('is counted separately, not as a passed check', () => {
expect(formatReport([...findings, proxy])).toContain(
'1 error, 1 warning, 1 check passed, 1 note.',
);
});
it('says nothing about notes when there are none', () => {
const report = formatReport(findings);
expect(report).not.toContain('NOTES');
expect(report).toContain('1 error, 1 warning, 1 check passed.');
});
});
});
describe('shouldUseColor', () => {
it('follows the terminal by default', () => {
expect(shouldUseColor({}, true)).toBe(true);
expect(shouldUseColor({}, false)).toBe(false);
});
it('honours NO_COLOR above everything', () => {
expect(shouldUseColor({ NO_COLOR: '1', FORCE_COLOR: '1' }, true)).toBe(false);
});
it('honours FORCE_COLOR when the output is piped', () => {
expect(shouldUseColor({ FORCE_COLOR: '1' }, false)).toBe(true);
expect(shouldUseColor({ FORCE_COLOR: '0' }, false)).toBe(false);
});
});

26
scripts/doctor.mjs Normal file
View File

@@ -0,0 +1,26 @@
/**
* Entry point for `npm run doctor` — see scripts/doctor.mts for the doctor
* itself, and for why it is TypeScript run by node's own type stripping (#521).
*
* This launcher is plain JavaScript so that it parses on every Node version.
* Type stripping arrived in Node 22.18; older runtimes fail on the `.mts` file
* with an ERR_UNKNOWN_FILE_EXTENSION stack trace, which is not an answer to
* give someone whose site is already down. The version is checked here, before
* the import that would throw.
*/
const [major, minor] = process.versions.node.split('.').map(Number);
if (major < 22 || (major === 22 && minor < 18)) {
process.stderr.write(
`\nThe config doctor needs Node 22.18 or newer (this is ${process.versions.node}).\n` +
`It runs TypeScript directly, which older versions cannot do.\n\n` +
`The same checks are in the admin panel: /admin → the status badge → Diagnostics.\n` +
`The shipped Docker image already has a new enough Node:\n` +
` docker compose exec folio npm run doctor\n\n`,
);
process.exit(3);
}
const { main } = await import('./doctor.mts');
process.exitCode = await main();

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();
}