`pnpm doctor` and `startApi` each read their own subset of the same environment variables, and the subsets had drifted. Doctor never read `S3_*` or module secrets at all, so a correctly configured installation was told its object storage was "not configured" and every module requiring a bucket or a secret was reported blocked -- while the runtime, from the identical environment, bound those buckets and ran the modules. Both executables now map the environment through one `loadHostConfiguration(env)` in Core, which takes the environment as an argument and never reads `process.env` itself. Validation stays with the existing per-domain validators, so each caller keeps its own failure behaviour: startup refuses a rejected value, Doctor reports it as a sanitized code. Doctor's storage option becomes raw input like its mail option, so a half-configured provider is reported as a blocking configuration failure instead of throwing before a report exists. The outbound-email check -- computed, counted toward the result, but absent from every rendered section -- is now printed, and the rendered set is asserted against a real report so a future check cannot go missing the same way. Co-Authored-By: Claude Opus 5 <[email protected]>
78 lines
2.7 KiB
TypeScript
78 lines
2.7 KiB
TypeScript
import type { DoctorCheck, DoctorCheckId, DoctorReport, DoctorResult } from "./doctor.js";
|
|
|
|
interface DoctorSection {
|
|
readonly checkIds: readonly DoctorCheckId[];
|
|
readonly title: string;
|
|
}
|
|
|
|
function section(title: string, checkIds: readonly DoctorCheckId[]): DoctorSection {
|
|
return Object.freeze({ checkIds: Object.freeze([...checkIds]), title });
|
|
}
|
|
|
|
const sections: readonly DoctorSection[] = Object.freeze([
|
|
section("Platform", ["platform.runtime"]),
|
|
section("Modules", [
|
|
"modules.discovery",
|
|
"modules.dependencies",
|
|
"modules.permissions",
|
|
"modules.lifecycle",
|
|
"modules.configuration",
|
|
]),
|
|
section("Data definitions", ["schemas.discovery", "migrations.discovery"]),
|
|
section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]),
|
|
section("Identity", ["identity.configuration", "identity.client-address"]),
|
|
section("Object storage", ["object-storage.integration", "object-storage.bindings"]),
|
|
section("Outbound email", ["outbound-email.configuration"]),
|
|
]);
|
|
|
|
/**
|
|
* Every check id, exactly once.
|
|
*
|
|
* A check absent from `sections` is still computed and still counted toward the overall result, but
|
|
* is never shown — so a configuration failure would flip an operator's report to NOT READY with
|
|
* nothing on screen explaining why. `outbound-email.configuration` was in precisely that state, and
|
|
* `doctor-report.test.ts` now compares this list against the ids of a real report, so a new check
|
|
* cannot be added without being rendered.
|
|
*/
|
|
export const RENDERED_DOCTOR_CHECK_IDS: readonly DoctorCheckId[] = Object.freeze(
|
|
sections.flatMap((entry) => [...entry.checkIds]),
|
|
);
|
|
|
|
const statusSymbols: Readonly<Record<DoctorCheck["status"], string>> = Object.freeze({
|
|
failure: "✗",
|
|
pass: "✓",
|
|
skipped: "○",
|
|
warning: "!",
|
|
});
|
|
|
|
const resultLabels: Readonly<Record<DoctorResult, string>> = Object.freeze({
|
|
"not-ready": "NOT READY",
|
|
ready: "READY",
|
|
"ready-with-warnings": "READY WITH WARNINGS",
|
|
});
|
|
|
|
export function formatDoctorReport(report: DoctorReport): readonly string[] {
|
|
const checks = new Map(report.checks.map((check) => [check.id, check]));
|
|
const lines = ["Ekklesia Doctor", ""];
|
|
|
|
for (const [sectionIndex, section] of sections.entries()) {
|
|
lines.push(section.title);
|
|
for (const checkId of section.checkIds) {
|
|
const check = checks.get(checkId);
|
|
if (!check) {
|
|
continue;
|
|
}
|
|
lines.push(` ${statusSymbols[check.status]} ${check.summary}`);
|
|
for (const detail of check.details) {
|
|
lines.push(` ${detail}`);
|
|
}
|
|
}
|
|
if (sectionIndex < sections.length - 1) {
|
|
lines.push("");
|
|
}
|
|
}
|
|
|
|
lines.push("", `Result: ${resultLabels[report.result]}`);
|
|
return Object.freeze(lines);
|
|
}
|