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:
427
scripts/__tests__/doctor-cli.test.ts
Normal file
427
scripts/__tests__/doctor-cli.test.ts
Normal 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
26
scripts/doctor.mjs
Normal 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
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