`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]>
128 lines
4.3 KiB
TypeScript
128 lines
4.3 KiB
TypeScript
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<unknown> {
|
|
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<number> {
|
|
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;
|
|
});
|
|
}
|