forked from bai/curriculum-project-hub
574 lines
20 KiB
JavaScript
574 lines
20 KiB
JavaScript
#!/usr/bin/env node
|
|
import { execFile } from "node:child_process";
|
|
import { constants } from "node:fs";
|
|
import { access, lstat, readFile, realpath, stat } from "node:fs/promises";
|
|
import { basename, dirname, join, resolve } from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { PrismaClient } from "@prisma/client";
|
|
import { parse } from "dotenv";
|
|
import { LocalSecretEnvelope, loadLocalSecretKeyring } from "../security/secretEnvelope.js";
|
|
import { verifyStoredProviderEnvelopes } from "../connections/providerConnections.js";
|
|
import { verifyStoredFeishuApplicationEnvelopes } from "../connections/feishuApplicationConnections.js";
|
|
import {
|
|
DeploymentPreflightError,
|
|
validateDeploymentPreflight,
|
|
type DeploymentEnv,
|
|
type DeploymentPreflightInput,
|
|
type DeploymentPreflightResult,
|
|
} from "./preflight.js";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
interface Options {
|
|
readonly envFile: string;
|
|
readonly keyringFile: string;
|
|
readonly nodeBin: string;
|
|
readonly runuserBin: string;
|
|
readonly setprivBin: string;
|
|
readonly serviceUser: string;
|
|
readonly baseDir: string;
|
|
readonly hubDir: string;
|
|
readonly serviceHome: string;
|
|
readonly stateDir: string;
|
|
readonly cacheDir: string;
|
|
readonly workspaceRoot: string;
|
|
readonly bwrapBin: string;
|
|
readonly socatBin: string;
|
|
readonly pgIsReadyBin: string;
|
|
readonly serviceUid?: number;
|
|
readonly serviceGid?: number;
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
const envFileContents = await readFile(options.envFile);
|
|
const env = parse(envFileContents) satisfies DeploymentEnv;
|
|
const rawInput = deploymentInput(options, env);
|
|
|
|
// Validate lexical paths and values before touching any external dependency.
|
|
validateDeploymentPreflight(rawInput);
|
|
|
|
const canonicalInput = await canonicalizeInput(rawInput);
|
|
const result = validateDeploymentPreflight(canonicalInput);
|
|
await validateSecretFile(options.envFile);
|
|
const secretEnvelope = await validateKeyringFile(options.keyringFile);
|
|
await validateNodeVersion(options.nodeBin);
|
|
await validateExecutable(options.runuserBin, "runuser");
|
|
await validateExecutable(options.setprivBin, "setpriv");
|
|
await validateExecutable(options.bwrapBin, "bubblewrap");
|
|
await validateExecutable(options.socatBin, "socat");
|
|
await validateExecutable(result.cphBin, "cph");
|
|
await validateExecutable(options.pgIsReadyBin, "pg_isready");
|
|
await validateCph(result.cphBin);
|
|
await validatePostgres(options.pgIsReadyBin, result);
|
|
await validatePostgresQuery(result.databaseUrl, secretEnvelope);
|
|
|
|
if (options.serviceUid !== undefined && options.serviceGid !== undefined) {
|
|
await validateProvisionedDirectories(canonicalInput, options.serviceUid, options.serviceGid);
|
|
await validateServiceRuntime(options, rawInput, canonicalInput, result);
|
|
}
|
|
|
|
console.log("[preflight] configuration and prerequisites valid");
|
|
}
|
|
|
|
function deploymentInput(options: Options, env: DeploymentEnv): DeploymentPreflightInput {
|
|
return {
|
|
baseDir: options.baseDir,
|
|
hubDir: options.hubDir,
|
|
serviceHome: options.serviceHome,
|
|
stateDir: options.stateDir,
|
|
cacheDir: options.cacheDir,
|
|
workspaceRoot: options.workspaceRoot,
|
|
env,
|
|
};
|
|
}
|
|
|
|
async function canonicalizeInput(input: DeploymentPreflightInput): Promise<DeploymentPreflightInput> {
|
|
const baseDir = await canonicalizeProspectivePath(input.baseDir);
|
|
const hubDir = await canonicalizeProspectivePath(input.hubDir);
|
|
const serviceHome = await canonicalizeProspectivePath(input.serviceHome);
|
|
const stateDir = await canonicalizeProspectivePath(input.stateDir);
|
|
const cacheDir = await canonicalizeProspectivePath(input.cacheDir);
|
|
const workspaceRoot = await canonicalizeProspectivePath(input.workspaceRoot);
|
|
return {
|
|
baseDir,
|
|
hubDir,
|
|
serviceHome,
|
|
stateDir,
|
|
cacheDir,
|
|
workspaceRoot,
|
|
env: { ...input.env, HUB_PROJECT_WORKSPACE_ROOT: workspaceRoot },
|
|
};
|
|
}
|
|
|
|
async function canonicalizeProspectivePath(value: string): Promise<string> {
|
|
const absolute = resolve(value);
|
|
const missingComponents: string[] = [];
|
|
let candidate = absolute;
|
|
|
|
while (true) {
|
|
try {
|
|
const existing = await realpath(candidate);
|
|
return resolve(existing, ...missingComponents.reverse());
|
|
} catch (error) {
|
|
if (!isNodeError(error) || error.code !== "ENOENT") throw error;
|
|
const parent = dirname(candidate);
|
|
if (parent === candidate) throw error;
|
|
missingComponents.push(basename(candidate));
|
|
candidate = parent;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function validateSecretFile(path: string): Promise<void> {
|
|
const metadata = await stat(path);
|
|
if (!metadata.isFile()) {
|
|
throw new Error(`environment file is not a regular file: ${path}`);
|
|
}
|
|
if ((metadata.mode & 0o077) !== 0) {
|
|
throw new Error(`environment file must not be accessible by group or others: ${path}`);
|
|
}
|
|
}
|
|
|
|
async function validateKeyringFile(path: string): Promise<LocalSecretEnvelope> {
|
|
const metadata = await lstat(path);
|
|
if (metadata.isSymbolicLink() || !metadata.isFile()) {
|
|
throw new Error(`local secret keyring is not a regular non-symlink file: ${path}`);
|
|
}
|
|
if ((metadata.mode & 0o077) !== 0) {
|
|
throw new Error(`local secret keyring must not be accessible by group or others: ${path}`);
|
|
}
|
|
const keyring = await loadLocalSecretKeyring({
|
|
NODE_ENV: "preflight",
|
|
HUB_SECRET_KEYRING_FILE: path,
|
|
});
|
|
console.log("[preflight] local secret keyring is valid");
|
|
return new LocalSecretEnvelope(keyring);
|
|
}
|
|
|
|
async function validateNodeVersion(nodeBin: string): Promise<void> {
|
|
let stdout: string;
|
|
try {
|
|
({ stdout } = await execFileAsync(nodeBin, ["--version"], { timeout: 10_000 }));
|
|
} catch (error) {
|
|
throw new Error(`Node.js version check failed: ${nodeBin} (${formatFailure(error)})`, { cause: error });
|
|
}
|
|
const version = stdout.trim().replace(/^v/, "");
|
|
const major = Number(version.split(".")[0]);
|
|
if (!Number.isInteger(major) || major < 20) {
|
|
throw new Error(`Node.js 20 or newer is required; found ${version || "unknown"}`);
|
|
}
|
|
console.log(`[preflight] Node.js: ${version}`);
|
|
}
|
|
|
|
async function validateExecutable(path: string, label: string): Promise<void> {
|
|
try {
|
|
await access(path, constants.X_OK);
|
|
} catch (error) {
|
|
throw new Error(
|
|
`${label} executable is missing or not executable: ${path} (${formatFailure(error)})`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
}
|
|
|
|
async function validateCph(cphBin: string): Promise<void> {
|
|
let stdout: string;
|
|
try {
|
|
({ stdout } = await execFileAsync(cphBin, ["--version"], { timeout: 10_000 }));
|
|
} catch (error) {
|
|
throw new Error(`cph --version failed: ${cphBin} (${formatFailure(error)})`, { cause: error });
|
|
}
|
|
if (!/^cph \d+\.\d+\.\d+(?:\s|$)/m.test(stdout)) {
|
|
throw new Error(`cph --version returned an unexpected value: ${cphBin}`);
|
|
}
|
|
console.log(`[preflight] cph: ${stdout.trim()}`);
|
|
}
|
|
|
|
async function validatePostgres(pgIsReadyBin: string, result: DeploymentPreflightResult): Promise<void> {
|
|
const url = new URL(result.databaseUrl);
|
|
const postgresEnv: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
PGCONNECT_TIMEOUT: "5",
|
|
PGHOST: url.hostname,
|
|
PGPORT: url.port || "5432",
|
|
PGUSER: decodeURIComponent(url.username),
|
|
PGPASSWORD: decodeURIComponent(url.password),
|
|
PGDATABASE: decodeURIComponent(url.pathname.replace(/^\//, "")),
|
|
};
|
|
const sslMode = url.searchParams.get("sslmode");
|
|
if (sslMode !== null) postgresEnv["PGSSLMODE"] = sslMode;
|
|
|
|
try {
|
|
await execFileAsync(pgIsReadyBin, ["--quiet"], { env: postgresEnv, timeout: 10_000 });
|
|
} catch (error) {
|
|
throw new Error(
|
|
`PostgreSQL is not accepting connections for DATABASE_URL (${formatFailure(error)})`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
console.log("[preflight] PostgreSQL is accepting connections");
|
|
}
|
|
|
|
async function validatePostgresQuery(
|
|
databaseUrl: string,
|
|
secretEnvelope: LocalSecretEnvelope,
|
|
): Promise<void> {
|
|
const client = new PrismaClient({
|
|
datasources: { db: { url: databaseUrl } },
|
|
log: [],
|
|
});
|
|
let queryFailure: unknown;
|
|
const envelopeFailures: Array<{ readonly kind: "provider" | "Feishu"; readonly error: unknown }> = [];
|
|
try {
|
|
await client.$queryRawUnsafe("SELECT 1");
|
|
const migrationTable = await client.$queryRaw<Array<{ relation: string | null }>>`
|
|
SELECT to_regclass('public."_prisma_migrations"')::text AS "relation"
|
|
`;
|
|
if (migrationTable[0]?.relation === null || migrationTable[0]?.relation === undefined) {
|
|
console.log("[preflight] empty database has no migration table or stored connection envelopes");
|
|
} else {
|
|
const providerApplied = await migrationApplied(
|
|
client,
|
|
"20260710220000_org_provider_secret_envelopes",
|
|
);
|
|
if (!providerApplied) {
|
|
console.log("[preflight] provider envelope migration is pending; no stored envelopes to authenticate");
|
|
} else {
|
|
try {
|
|
const verified = await verifyStoredProviderEnvelopes(client, secretEnvelope);
|
|
console.log(`[preflight] authenticated ${verified} stored provider envelope(s)`);
|
|
} catch (error) {
|
|
envelopeFailures.push({ kind: "provider", error });
|
|
}
|
|
}
|
|
const feishuApplied = await migrationApplied(
|
|
client,
|
|
"20260710230000_org_feishu_application_secrets",
|
|
);
|
|
if (!feishuApplied) {
|
|
console.log("[preflight] Feishu envelope migration is pending; no stored envelopes to authenticate");
|
|
} else {
|
|
try {
|
|
const verified = await verifyStoredFeishuApplicationEnvelopes(client, secretEnvelope);
|
|
console.log(`[preflight] authenticated ${verified} stored Feishu envelope(s)`);
|
|
} catch (error) {
|
|
envelopeFailures.push({ kind: "Feishu", error });
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
queryFailure = error;
|
|
}
|
|
|
|
let disconnectFailure: unknown;
|
|
try {
|
|
await client.$disconnect();
|
|
} catch (error) {
|
|
disconnectFailure = error;
|
|
}
|
|
|
|
if (queryFailure !== undefined) {
|
|
const disconnectDetail =
|
|
disconnectFailure === undefined ? "" : `; disconnect also failed (${formatFailure(disconnectFailure)})`;
|
|
throw new Error(
|
|
`PostgreSQL authenticated query failed for DATABASE_URL (${formatFailure(queryFailure)})${disconnectDetail}`,
|
|
{ cause: queryFailure },
|
|
);
|
|
}
|
|
if (disconnectFailure !== undefined) {
|
|
throw new Error(
|
|
`PostgreSQL preflight disconnect failed (${formatFailure(disconnectFailure)})`,
|
|
{ cause: disconnectFailure },
|
|
);
|
|
}
|
|
if (envelopeFailures.length > 0) {
|
|
const failures = envelopeFailures.map(({ error }) => error);
|
|
const cause = failures.length === 1
|
|
? failures[0]
|
|
: new AggregateError(failures, "multiple connection envelope verifications failed");
|
|
const details = envelopeFailures
|
|
.map(({ kind, error }) => `${kind}: ${formatFailure(error)}`)
|
|
.join("; ");
|
|
throw new Error(
|
|
`stored connection envelope verification failed (${details})`,
|
|
{ cause },
|
|
);
|
|
}
|
|
console.log("[preflight] PostgreSQL authenticated query succeeded");
|
|
}
|
|
|
|
async function migrationApplied(client: PrismaClient, migrationName: string): Promise<boolean> {
|
|
const rows = await client.$queryRaw<Array<{ finished_at: Date | null; rolled_back_at: Date | null }>>`
|
|
SELECT "finished_at", "rolled_back_at"
|
|
FROM "_prisma_migrations"
|
|
WHERE "migration_name" = ${migrationName}
|
|
ORDER BY "started_at" DESC
|
|
LIMIT 1
|
|
`;
|
|
return rows[0]?.finished_at !== null && rows[0]?.finished_at !== undefined &&
|
|
rows[0]?.rolled_back_at === null;
|
|
}
|
|
|
|
async function validateProvisionedDirectories(
|
|
input: DeploymentPreflightInput,
|
|
serviceUid: number,
|
|
serviceGid: number,
|
|
): Promise<void> {
|
|
const directories = [input.serviceHome, input.stateDir, input.cacheDir, input.workspaceRoot];
|
|
for (const directory of directories) {
|
|
const metadata = await stat(directory);
|
|
if (!metadata.isDirectory()) {
|
|
throw new Error(`provisioned path is not a directory: ${directory}`);
|
|
}
|
|
if (metadata.uid !== serviceUid || metadata.gid !== serviceGid) {
|
|
throw new Error(`provisioned directory has unexpected ownership: ${directory}`);
|
|
}
|
|
if ((metadata.mode & 0o777) !== 0o750) {
|
|
throw new Error(`provisioned directory must have mode 0750: ${directory}`);
|
|
}
|
|
}
|
|
console.log(`[preflight] service directories owned by ${serviceUid}:${serviceGid} with mode 0750`);
|
|
}
|
|
|
|
async function validateServiceRuntime(
|
|
options: Options,
|
|
configuredInput: DeploymentPreflightInput,
|
|
canonicalInput: DeploymentPreflightInput,
|
|
result: DeploymentPreflightResult,
|
|
): Promise<void> {
|
|
const serviceEnv: NodeJS.ProcessEnv = {
|
|
...configuredInput.env,
|
|
NODE_ENV: "production",
|
|
DATABASE_URL: result.databaseUrl,
|
|
HOME: configuredInput.serviceHome,
|
|
XDG_STATE_HOME: configuredInput.stateDir,
|
|
XDG_CACHE_HOME: configuredInput.cacheDir,
|
|
PATH: [dirname(options.nodeBin), dirname(options.bwrapBin), dirname(options.socatBin), dirname(result.cphBin), "/usr/bin", "/bin"].join(":"),
|
|
};
|
|
const pathAccessRequirements = (input: DeploymentPreflightInput) => [
|
|
[input.hubDir, constants.R_OK | constants.X_OK],
|
|
[join(input.hubDir, "dist"), constants.R_OK | constants.X_OK],
|
|
[join(input.hubDir, "node_modules"), constants.R_OK | constants.X_OK],
|
|
[join(input.hubDir, "prisma"), constants.R_OK | constants.X_OK],
|
|
[input.serviceHome, constants.R_OK | constants.W_OK | constants.X_OK],
|
|
[input.stateDir, constants.R_OK | constants.W_OK | constants.X_OK],
|
|
[input.cacheDir, constants.R_OK | constants.W_OK | constants.X_OK],
|
|
[input.workspaceRoot, constants.R_OK | constants.W_OK | constants.X_OK],
|
|
[join(input.hubDir, "dist", "server.js"), constants.R_OK],
|
|
[join(input.hubDir, "dist", "deployment", "service-runtime-probe.js"), constants.R_OK],
|
|
[join(input.hubDir, "node_modules", "prisma", "build", "index.js"), constants.R_OK],
|
|
[join(input.hubDir, "prisma", "schema.prisma"), constants.R_OK],
|
|
] as const;
|
|
const accessRequirements = [
|
|
...pathAccessRequirements(configuredInput),
|
|
...pathAccessRequirements(canonicalInput),
|
|
[options.nodeBin, constants.X_OK],
|
|
[options.bwrapBin, constants.X_OK],
|
|
[options.socatBin, constants.X_OK],
|
|
[options.setprivBin, constants.X_OK],
|
|
[result.cphBin, constants.X_OK],
|
|
] as const;
|
|
const accessProbe = [
|
|
'const { accessSync } = require("node:fs");',
|
|
"for (const [path, mode] of JSON.parse(process.argv[1])) accessSync(path, mode);",
|
|
].join("\n");
|
|
|
|
await runAsService(
|
|
options,
|
|
serviceEnv,
|
|
options.nodeBin,
|
|
["-e", accessProbe, JSON.stringify(accessRequirements)],
|
|
"release and persistent path access probe",
|
|
);
|
|
await runAsService(
|
|
options,
|
|
serviceEnv,
|
|
options.nodeBin,
|
|
["--check", join(configuredInput.hubDir, "dist", "server.js")],
|
|
"built Hub syntax probe",
|
|
);
|
|
await runAsService(
|
|
options,
|
|
serviceEnv,
|
|
options.nodeBin,
|
|
[join(configuredInput.hubDir, "node_modules", "prisma", "build", "index.js"), "--version"],
|
|
"Prisma runtime probe",
|
|
);
|
|
await runAsService(
|
|
options,
|
|
serviceEnv,
|
|
options.nodeBin,
|
|
[join(configuredInput.hubDir, "dist", "deployment", "service-runtime-probe.js")],
|
|
"Hub dependency and authenticated database probe",
|
|
);
|
|
await runAsService(options, serviceEnv, result.cphBin, ["--version"], "cph runtime probe");
|
|
await runAsService(options, serviceEnv, options.socatBin, ["-V"], "socat runtime probe");
|
|
await runAsService(
|
|
options,
|
|
serviceEnv,
|
|
options.bwrapBin,
|
|
[
|
|
"--unshare-all",
|
|
"--die-with-parent",
|
|
"--new-session",
|
|
"--ro-bind",
|
|
"/",
|
|
"/",
|
|
"--proc",
|
|
"/proc",
|
|
"--dev",
|
|
"/dev",
|
|
"/bin/true",
|
|
],
|
|
"bubblewrap user-namespace probe",
|
|
);
|
|
console.log(`[preflight] runtime prerequisites execute as service user ${options.serviceUser}`);
|
|
}
|
|
|
|
async function runAsService(
|
|
options: Options,
|
|
env: NodeJS.ProcessEnv,
|
|
command: string,
|
|
args: readonly string[],
|
|
label: string,
|
|
): Promise<void> {
|
|
try {
|
|
await execFileAsync(
|
|
options.runuserBin,
|
|
[
|
|
"-u",
|
|
options.serviceUser,
|
|
"--",
|
|
options.setprivBin,
|
|
"--no-new-privs",
|
|
command,
|
|
...args,
|
|
],
|
|
{ cwd: options.hubDir, env, timeout: 15_000, maxBuffer: 2 * 1024 * 1024 },
|
|
);
|
|
} catch (error) {
|
|
throw new Error(`${label} failed for service user ${options.serviceUser} (${formatFailure(error)})`, {
|
|
cause: error,
|
|
});
|
|
}
|
|
}
|
|
|
|
function parseArgs(argv: readonly string[]): Options {
|
|
const values = new Map<string, string>();
|
|
for (let index = 0; index < argv.length; index += 2) {
|
|
const key = argv[index];
|
|
const value = argv[index + 1];
|
|
if (key === undefined || value === undefined || !key.startsWith("--")) {
|
|
throw new Error("preflight arguments must be --name value pairs");
|
|
}
|
|
if (values.has(key)) throw new Error(`duplicate preflight argument: ${key}`);
|
|
values.set(key, value);
|
|
}
|
|
|
|
const allowed = new Set([
|
|
"--env-file",
|
|
"--keyring-file",
|
|
"--node-bin",
|
|
"--runuser-bin",
|
|
"--setpriv-bin",
|
|
"--service-user",
|
|
"--base-dir",
|
|
"--hub-dir",
|
|
"--service-home",
|
|
"--state-dir",
|
|
"--cache-dir",
|
|
"--workspace-root",
|
|
"--bwrap-bin",
|
|
"--socat-bin",
|
|
"--pg-isready-bin",
|
|
"--service-uid",
|
|
"--service-gid",
|
|
]);
|
|
for (const key of values.keys()) {
|
|
if (!allowed.has(key)) throw new Error(`unknown preflight argument: ${key}`);
|
|
}
|
|
|
|
const serviceUid = optionalId(values, "--service-uid");
|
|
const serviceGid = optionalId(values, "--service-gid");
|
|
if ((serviceUid === undefined) !== (serviceGid === undefined)) {
|
|
throw new Error("--service-uid and --service-gid must be supplied together");
|
|
}
|
|
|
|
return {
|
|
envFile: required(values, "--env-file"),
|
|
keyringFile: required(values, "--keyring-file"),
|
|
nodeBin: required(values, "--node-bin"),
|
|
runuserBin: required(values, "--runuser-bin"),
|
|
setprivBin: required(values, "--setpriv-bin"),
|
|
serviceUser: required(values, "--service-user"),
|
|
baseDir: required(values, "--base-dir"),
|
|
hubDir: required(values, "--hub-dir"),
|
|
serviceHome: required(values, "--service-home"),
|
|
stateDir: required(values, "--state-dir"),
|
|
cacheDir: required(values, "--cache-dir"),
|
|
workspaceRoot: required(values, "--workspace-root"),
|
|
bwrapBin: required(values, "--bwrap-bin"),
|
|
socatBin: required(values, "--socat-bin"),
|
|
pgIsReadyBin: required(values, "--pg-isready-bin"),
|
|
...(serviceUid === undefined ? {} : { serviceUid, serviceGid: serviceGid! }),
|
|
};
|
|
}
|
|
|
|
function required(values: ReadonlyMap<string, string>, key: string): string {
|
|
const value = values.get(key);
|
|
if (value === undefined || value === "") throw new Error(`missing required preflight argument: ${key}`);
|
|
return value;
|
|
}
|
|
|
|
function optionalId(values: ReadonlyMap<string, string>, key: string): number | undefined {
|
|
const raw = values.get(key);
|
|
if (raw === undefined) return undefined;
|
|
const value = Number(raw);
|
|
if (!Number.isInteger(value) || value < 0) throw new Error(`${key} must be a non-negative integer`);
|
|
return value;
|
|
}
|
|
|
|
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
return error instanceof Error && "code" in error;
|
|
}
|
|
|
|
function formatFailure(error: unknown): string {
|
|
if (!(error instanceof Error)) return String(error);
|
|
|
|
const details: string[] = [];
|
|
const processError = error as NodeJS.ErrnoException & {
|
|
readonly signal?: NodeJS.Signals | null;
|
|
readonly stderr?: string | Buffer;
|
|
};
|
|
if (processError.code !== undefined) details.push(`exit=${String(processError.code)}`);
|
|
if (processError.signal !== undefined && processError.signal !== null) {
|
|
details.push(`signal=${processError.signal}`);
|
|
}
|
|
const stderr = processError.stderr?.toString().trim();
|
|
if (stderr !== undefined && stderr !== "") details.push(`stderr=${boundedDiagnostic(redactDiagnostic(stderr))}`);
|
|
if (details.length === 0) details.push(boundedDiagnostic(redactDiagnostic(error.message)));
|
|
return details.join(", ");
|
|
}
|
|
|
|
function boundedDiagnostic(value: string): string {
|
|
const limit = 2_000;
|
|
if (value.length <= limit) return value;
|
|
return `${value.slice(0, limit)}…[truncated ${value.length - limit} chars]`;
|
|
}
|
|
|
|
function redactDiagnostic(value: string): string {
|
|
return value
|
|
.replace(/\b(postgres(?:ql)?):\/\/([^:\s/@]+):([^@\s]+)@/gi, "$1://$2:[REDACTED]@")
|
|
.replace(/\b(password\s*[:=]\s*)\S+/gi, "$1[REDACTED]");
|
|
}
|
|
|
|
main().catch((error: unknown) => {
|
|
if (error instanceof DeploymentPreflightError) {
|
|
console.error(`[preflight] ${error.message}`);
|
|
} else {
|
|
console.error(`[preflight] failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
process.exitCode = 1;
|
|
});
|