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"; import { type ModulesDirectorySelection, resolveModulesDirectory } from "../modules/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 platformVersion: string; /** Where the platform is installed: Core's migrations live beneath it, and so does the * development default modules directory. Installed modules may live anywhere else. */ readonly repositoryRoot: 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; } /** * The modules directory exactly as `startApi` would choose it. An unusable configured value is not * thrown here, because Doctor must still produce a report: it is handed on as a required directory * for discovery to refuse, which reports it as a sanitized module-discovery failure. */ function doctorModulesDirectory( configured: string | undefined, repositoryRoot: string, ): ModulesDirectorySelection { try { return resolveModulesDirectory({ configured, repositoryRoot }); } catch { return { modulesDirectory: configured ?? "", modulesDirectoryRequired: true }; } } /** * 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, coreMigrationsDirectory: path.join(metadata.repositoryRoot, "core", "drizzle"), databaseEnvironment: host.environment, databaseTransport: host.databaseTransport, databaseUrl: host.databaseUrl, identityConfiguration: { ...host.identity, environment: host.environment }, mailConfiguration: host.mail, moduleDiscovery: { moduleSdkVersion: metadata.moduleSdkVersion, ...doctorModulesDirectory(host.modulesDirectory, metadata.repositoryRoot), 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), platformVersion: requiredString(rootPackage.version), repositoryRoot, 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; }); }