139 lines
4.5 KiB
TypeScript
139 lines
4.5 KiB
TypeScript
|
|
import type { StartupValidationCheckDto } from "@mrp/shared";
|
||
|
|
import fs from "node:fs/promises";
|
||
|
|
import path from "node:path";
|
||
|
|
|
||
|
|
import { env } from "../config/env.js";
|
||
|
|
import { paths } from "../config/paths.js";
|
||
|
|
import { prisma } from "./prisma.js";
|
||
|
|
|
||
|
|
interface StartupValidationReport {
|
||
|
|
status: "PASS" | "WARN" | "FAIL";
|
||
|
|
checks: StartupValidationCheckDto[];
|
||
|
|
}
|
||
|
|
|
||
|
|
async function pathExists(targetPath: string) {
|
||
|
|
try {
|
||
|
|
await fs.access(targetPath);
|
||
|
|
return true;
|
||
|
|
} catch {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function collectStartupValidationReport(): Promise<StartupValidationReport> {
|
||
|
|
const checks: StartupValidationCheckDto[] = [];
|
||
|
|
|
||
|
|
checks.push({
|
||
|
|
id: "data-dir",
|
||
|
|
label: "Data directory",
|
||
|
|
status: (await pathExists(paths.dataDir)) ? "PASS" : "FAIL",
|
||
|
|
message: (await pathExists(paths.dataDir)) ? `Data directory available at ${paths.dataDir}.` : `Data directory is missing: ${paths.dataDir}.`,
|
||
|
|
});
|
||
|
|
|
||
|
|
checks.push({
|
||
|
|
id: "uploads-dir",
|
||
|
|
label: "Uploads directory",
|
||
|
|
status: (await pathExists(paths.uploadsDir)) ? "PASS" : "FAIL",
|
||
|
|
message: (await pathExists(paths.uploadsDir))
|
||
|
|
? `Uploads directory available at ${paths.uploadsDir}.`
|
||
|
|
: `Uploads directory is missing: ${paths.uploadsDir}.`,
|
||
|
|
});
|
||
|
|
|
||
|
|
checks.push({
|
||
|
|
id: "prisma-dir",
|
||
|
|
label: "Prisma directory",
|
||
|
|
status: (await pathExists(paths.prismaDir)) ? "PASS" : "FAIL",
|
||
|
|
message: (await pathExists(paths.prismaDir))
|
||
|
|
? `Prisma data directory available at ${paths.prismaDir}.`
|
||
|
|
: `Prisma data directory is missing: ${paths.prismaDir}.`,
|
||
|
|
});
|
||
|
|
|
||
|
|
try {
|
||
|
|
await prisma.$queryRawUnsafe("SELECT 1");
|
||
|
|
checks.push({
|
||
|
|
id: "database-connection",
|
||
|
|
label: "Database connection",
|
||
|
|
status: "PASS",
|
||
|
|
message: "SQLite connection check succeeded.",
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
checks.push({
|
||
|
|
id: "database-connection",
|
||
|
|
label: "Database connection",
|
||
|
|
status: "FAIL",
|
||
|
|
message: error instanceof Error ? error.message : "SQLite connection check failed.",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (env.NODE_ENV === "production") {
|
||
|
|
checks.push({
|
||
|
|
id: "client-dist",
|
||
|
|
label: "Client bundle",
|
||
|
|
status: (await pathExists(path.join(paths.clientDistDir, "index.html"))) ? "PASS" : "FAIL",
|
||
|
|
message: (await pathExists(path.join(paths.clientDistDir, "index.html")))
|
||
|
|
? `Client bundle found at ${paths.clientDistDir}.`
|
||
|
|
: `Production client bundle is missing from ${paths.clientDistDir}.`,
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
checks.push({
|
||
|
|
id: "client-dist",
|
||
|
|
label: "Client bundle",
|
||
|
|
status: "PASS",
|
||
|
|
message: "Client bundle check skipped outside production mode.",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const puppeteerPath = env.PUPPETEER_EXECUTABLE_PATH || "/usr/bin/chromium";
|
||
|
|
const puppeteerExists = await pathExists(puppeteerPath);
|
||
|
|
checks.push({
|
||
|
|
id: "puppeteer-runtime",
|
||
|
|
label: "PDF runtime",
|
||
|
|
status: puppeteerExists ? "PASS" : env.NODE_ENV === "production" ? "FAIL" : "WARN",
|
||
|
|
message: puppeteerExists
|
||
|
|
? `Chromium runtime available at ${puppeteerPath}.`
|
||
|
|
: `Chromium runtime was not found at ${puppeteerPath}.`,
|
||
|
|
});
|
||
|
|
|
||
|
|
checks.push({
|
||
|
|
id: "client-origin",
|
||
|
|
label: "Client origin",
|
||
|
|
status: env.NODE_ENV === "production" && env.CLIENT_ORIGIN.includes("localhost") ? "WARN" : "PASS",
|
||
|
|
message:
|
||
|
|
env.NODE_ENV === "production" && env.CLIENT_ORIGIN.includes("localhost")
|
||
|
|
? `Production CLIENT_ORIGIN still points to localhost: ${env.CLIENT_ORIGIN}.`
|
||
|
|
: `Client origin is configured as ${env.CLIENT_ORIGIN}.`,
|
||
|
|
});
|
||
|
|
|
||
|
|
checks.push({
|
||
|
|
id: "admin-password",
|
||
|
|
label: "Bootstrap admin password",
|
||
|
|
status: env.NODE_ENV === "production" && env.ADMIN_PASSWORD === "ChangeMe123!" ? "WARN" : "PASS",
|
||
|
|
message:
|
||
|
|
env.NODE_ENV === "production" && env.ADMIN_PASSWORD === "ChangeMe123!"
|
||
|
|
? "Production is still using the default bootstrap admin password."
|
||
|
|
: "Bootstrap admin credentials are not using the default production password.",
|
||
|
|
});
|
||
|
|
|
||
|
|
const status = checks.some((check) => check.status === "FAIL")
|
||
|
|
? "FAIL"
|
||
|
|
: checks.some((check) => check.status === "WARN")
|
||
|
|
? "WARN"
|
||
|
|
: "PASS";
|
||
|
|
|
||
|
|
return {
|
||
|
|
status,
|
||
|
|
checks,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function assertStartupReadiness() {
|
||
|
|
const report = await collectStartupValidationReport();
|
||
|
|
|
||
|
|
if (report.status === "FAIL") {
|
||
|
|
const failedChecks = report.checks.filter((check) => check.status === "FAIL").map((check) => `${check.label}: ${check.message}`);
|
||
|
|
throw new Error(`Startup validation failed. ${failedChecks.join(" | ")}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
return report;
|
||
|
|
}
|