import { readFile } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { type DoctorOptions, diagnoseEkklesia, doctorExitCode, formatDoctorReport, } from "../diagnostics/index.js"; import { type HostEnvironment, loadHostConfiguration } from "../host/index.js"; interface ModuleSdkPackageDocument { readonly version?: unknown; } interface RootPackageDocument extends ModuleSdkPackageDocument { readonly engines?: { readonly node?: unknown; }; } /** * Everything Doctor needs that comes from the repository itself rather than the deployment * environment, resolved by the caller so the environment mapping below stays pure and testable. */ export interface DoctorCommandMetadata { readonly moduleSdkVersion: string; readonly modulesDirectory: string; readonly platformVersion: string; readonly runtimeRange: string; readonly runtimeVersion: string; } async function readJson(pathname: string): Promise { return JSON.parse(await readFile(pathname, "utf8")) as unknown; } function rootPackageDocument(value: unknown): RootPackageDocument { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new TypeError("Invalid workspace package metadata."); } return value as RootPackageDocument; } function requiredString(value: unknown): string { if (typeof value !== "string" || value.length === 0) { throw new TypeError("Missing workspace package metadata."); } return value; } /** * Turns one deployment environment into Doctor's inputs. * * Every infrastructure value comes from `loadHostConfiguration` — the same function `apps/api` calls * with the same environment — so `pnpm doctor` and `startApi` cannot disagree about what this * deployment is configured to use. Doctor previously read its own subset here and silently omitted * object storage and module secrets entirely, which made it report a correctly configured * installation as having neither. * * Everything is handed over **raw**. Doctor validates it and reports a sanitized code, so a * malformed value appears in the report — which is the whole reason an operator runs this command — * instead of throwing before a report exists. */ export function createDoctorOptions( environment: HostEnvironment, metadata: DoctorCommandMetadata, ): DoctorOptions { const host = loadHostConfiguration(environment); return { clientAddress: host.clientAddress, databaseEnvironment: host.environment, databaseTransport: host.databaseTransport, databaseUrl: host.databaseUrl, identityConfiguration: { ...host.identity, environment: host.environment }, mailConfiguration: host.mail, moduleDiscovery: { moduleSdkVersion: metadata.moduleSdkVersion, modulesDirectory: metadata.modulesDirectory, platformVersion: metadata.platformVersion, }, moduleSecrets: host.moduleSecrets, runtimeRange: metadata.runtimeRange, runtimeVersion: metadata.runtimeVersion, storageConfiguration: host.storage, }; } export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise { const [rootValue, moduleSdkValue] = await Promise.all([ readJson(path.join(repositoryRoot, "package.json")), readJson(path.join(repositoryRoot, "packages", "module-sdk", "package.json")), ]); const rootPackage = rootPackageDocument(rootValue); const moduleSdkPackage = rootPackageDocument(moduleSdkValue); const report = await diagnoseEkklesia( createDoctorOptions(process.env, { moduleSdkVersion: requiredString(moduleSdkPackage.version), modulesDirectory: path.join(repositoryRoot, "modules"), platformVersion: requiredString(rootPackage.version), runtimeRange: requiredString(rootPackage.engines?.node), runtimeVersion: process.versions.node, }), ); console.log(formatDoctorReport(report).join("\n")); return doctorExitCode(report); } function isDirectExecution(): boolean { const entrypoint = process.argv[1]; return ( entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href ); } if (isDirectExecution()) { runDoctorCommand() .then((exitCode) => { process.exitCode = exitCode; }) .catch(() => { console.error("Ekklesia Doctor failed before diagnostics could be composed."); process.exitCode = 1; }); }