scripts/validate-config.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
import {
accessSync,
constants,
existsSync,
readFileSync,
statSync,
} from "node:fs";
import { createPrivateKey } from "node:crypto";
import { parseEnv } from "node:util";
import { isIP } from "node:net";
import { resolve } from "node:path";
import { validateMcpUrl } from "./setup-config.mjs";
const args = process.argv.slice(2);
const remote = args.includes("--remote");
const smoke = args.includes("--smoke");
const envIndex = args.indexOf("--env");
if (
envIndex >= 0 &&
(!args[envIndex + 1] || args[envIndex + 1].startsWith("--"))
) {
console.error("Error: --env requires a file path.");
process.exit(2);
}
const envPath = resolve(envIndex >= 0 ? args[envIndex + 1] : "app/.env");
const productionComposeEnv =
envPath.endsWith("/deploy/.env.production") ||
envPath.endsWith("/deploy/.env.production.example");
if (args.includes("-h") || args.includes("--help")) {
console.log(`Validate devpush configuration.
Usage:
pnpm validate:config
pnpm validate:config -- --smoke
pnpm validate:config -- --remote
pnpm validate:config -- --env deploy/.env.production --remote
Options:
--env <path> Environment file to read. Defaults to app/.env.
--smoke Require only config needed by local smoke checks.
--remote Require internet-reachable settings for real server validation.`);
process.exit(0);
}
if (smoke && remote) {
console.error("Error: --smoke and --remote cannot be used together.");
process.exit(1);
}
const fileEnv = existsSync(envPath)
? parseEnv(readFileSync(envPath, "utf8"))
: {};
const env = { ...fileEnv, ...process.env };
const errors = [];
const warnings = [];
const localHostnames = new Set(["localhost", "127.0.0.1", "::1"]);
const remoteDatabase = Boolean(
env.DEVPUSH_DATABASE_URL?.trim() || env.DEVPUSH_LOG_DATABASE_URL?.trim(),
);
if (remoteDatabase) {
checkRequired("DEVPUSH_DATABASE_URL");
checkRequired("DEVPUSH_LOG_DATABASE_URL");
const identities = [];
for (const key of ["DEVPUSH_DATABASE_URL", "DEVPUSH_LOG_DATABASE_URL"]) {
try {
const url = new URL(env[key]);
if (
!["libsql:", "https:", "http:"].includes(url.protocol) ||
!url.hostname ||
url.username ||
url.password ||
url.hash
)
throw new Error();
identities.push(
`${url.hostname}:${url.port || (url.protocol === "http:" ? "80" : "443")}${url.pathname.replace(/\/$/, "")}`,
);
} catch {
errors.push(
`${key} must be a libsql, HTTPS or HTTP URL without embedded credentials or fragments.`,
);
}
}
if (identities.length === 2 && identities[0] === identities[1])
errors.push("Main data and logs must use different remote databases.");
} else if (productionComposeEnv) {
checkRequired("DEVPUSH_DATABASE_PATH");
checkRequired("DEVPUSH_LOG_DATABASE_PATH");
}
if (
!remoteDatabase &&
resolve(env.DEVPUSH_DATABASE_PATH || "app/.data/devpush.sqlite") ===
resolve(env.DEVPUSH_LOG_DATABASE_PATH || "app/.data/logs.sqlite")
) {
errors.push("Main and log database paths must differ.");
}
if (
!remoteDatabase &&
(env.DEVPUSH_DATABASE_AUTH_TOKEN || env.DEVPUSH_LOG_DATABASE_AUTH_TOKEN)
) {
errors.push("Database authentication tokens require remote database URLs.");
}
checkRequired("BETTER_AUTH_SECRET");
checkRequired("DEVPUSH_BASE_URL");
const baseUrl = readUrl("DEVPUSH_BASE_URL");
const authUrl = env.BETTER_AUTH_URL?.trim()
? readUrl("BETTER_AUTH_URL")
: baseUrl;
validateOrigin("DEVPUSH_BASE_URL", baseUrl);
if (env.BETTER_AUTH_URL?.trim()) {
validateOrigin("BETTER_AUTH_URL", authUrl);
}
const mcpUrl = env.DEVPUSH_MCP_URL?.trim()
? readUrl("DEVPUSH_MCP_URL")
: baseUrl
? new URL("/mcp", baseUrl)
: null;
if (mcpUrl) {
const error = validateMcpUrl(mcpUrl.href);
if (error) errors.push(`DEVPUSH_MCP_URL: ${error}`);
if (remote && isPrivateOrLocalHost(mcpUrl.hostname))
errors.push("DEVPUSH_MCP_URL must be public for remote validation.");
}
const authSecret = env.BETTER_AUTH_SECRET?.trim();
if (authSecret) {
const weakSecret =
authSecret.length < 32 ||
authSecret === "dev-secret-change-me" ||
authSecret === "dev-secret" ||
authSecret.startsWith("replace-with-");
if (weakSecret) {
issue(
"BETTER_AUTH_SECRET should be a generated secret with at least 32 characters.",
);
}
}
if (smoke) {
finish();
}
checkRequired("DEVPUSH_SECRET_ENCRYPTION_KEY");
const encryptionSecret = env.DEVPUSH_SECRET_ENCRYPTION_KEY?.trim();
if (
encryptionSecret &&
(encryptionSecret.length < 32 ||
encryptionSecret === "dev-secret-encryption-key-change-me" ||
encryptionSecret.startsWith("replace-with-"))
) {
issue(
"DEVPUSH_SECRET_ENCRYPTION_KEY should be a separate generated secret with at least 32 characters.",
);
}
if (
env.DEVPUSH_ADMIN_EMAIL?.trim() &&
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(env.DEVPUSH_ADMIN_EMAIL.trim())
) {
errors.push("DEVPUSH_ADMIN_EMAIL must be a valid email address when set.");
} else if (remote && !env.DEVPUSH_ADMIN_EMAIL?.trim()) {
warnings.push(
"DEVPUSH_ADMIN_EMAIL is unset; the earliest-created user will be the installation administrator.",
);
}
if (productionComposeEnv) {
checkRequired("DEVPUSH_CRON_SECRET");
}
const cronSecret = env.DEVPUSH_CRON_SECRET?.trim();
if (
cronSecret &&
(cronSecret.length < 32 || cronSecret.startsWith("replace-with-"))
) {
issue(
"DEVPUSH_CRON_SECRET should be a generated secret with at least 32 characters.",
);
}
if (cronSecret && cronSecret === authSecret) {
issue("DEVPUSH_CRON_SECRET must differ from BETTER_AUTH_SECRET.");
}
checkPair("Google OAuth", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET");
checkPair("GitHub OAuth", "GITHUB_APP_CLIENT_ID", "GITHUB_APP_CLIENT_SECRET");
checkRequired("GITHUB_APP_ID");
checkRequired("GITHUB_APP_NAME");
checkRequired("GITHUB_APP_PRIVATE_KEY");
if (env.GITHUB_APP_ID?.trim() && !/^\d+$/.test(env.GITHUB_APP_ID.trim())) {
errors.push("GITHUB_APP_ID must be numeric.");
}
const privateKey = env.GITHUB_APP_PRIVATE_KEY;
if (privateKey) {
try {
createPrivateKey(privateKey.replaceAll("\\n", "\n"));
} catch (error) {
errors.push(
`GITHUB_APP_PRIVATE_KEY is not a valid private key: ${error.message}`,
);
}
}
if (!env.GITHUB_APP_WEBHOOK_SECRET) {
issue("GITHUB_APP_WEBHOOK_SECRET is required for GitHub App push webhooks.");
}
if (!env.EMAIL_FROM || !env.RESEND_API_KEY) {
issue(
"Email OTP requires EMAIL_FROM and RESEND_API_KEY. GitHub/Google OAuth can still work without OTP email.",
);
}
if (env.DEVPUSH_AGENT_BINARY_DIR) {
const binaryDir = resolve(env.DEVPUSH_AGENT_BINARY_DIR);
for (const filename of [
"devpush-agent-linux-amd64",
"devpush-agent-linux-arm64",
]) {
const binaryPath = resolve(binaryDir, filename);
try {
const stat = statSync(binaryPath);
if (!stat.isFile()) {
errors.push(
`DEVPUSH_AGENT_BINARY_DIR entry is not a file: ${binaryPath}`,
);
} else {
accessSync(binaryPath, constants.R_OK);
}
} catch (error) {
errors.push(
`DEVPUSH_AGENT_BINARY_DIR entry is not readable: ${binaryPath}`,
);
}
}
}
if (
env.DEVPUSH_TRAEFIK_EMAIL &&
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(env.DEVPUSH_TRAEFIK_EMAIL)
) {
errors.push("DEVPUSH_TRAEFIK_EMAIL must be a valid email address when set.");
}
if (authUrl) {
const base = authUrl.origin;
console.log("Expected OAuth callbacks:");
console.log(` Google: ${base}/api/auth/callback/google`);
console.log(` GitHub: ${base}/api/auth/callback/github`);
console.log(` GitHub App: ${base}/api/github/install/callback`);
console.log("");
}
finish();
function checkRequired(name) {
if (!env[name]?.trim()) {
errors.push(`${name} is required.`);
}
}
function checkPair(label, idName, secretName) {
const hasId = Boolean(env[idName]?.trim());
const hasSecret = Boolean(env[secretName]?.trim());
if (hasId !== hasSecret) {
errors.push(`${label} requires both ${idName} and ${secretName}.`);
}
}
function readUrl(name) {
const value = env[name]?.trim();
if (!value) {
return null;
}
try {
return new URL(value);
} catch {
errors.push(`${name} must be a valid URL.`);
return null;
}
}
function validateOrigin(name, url) {
if (!url) {
return;
}
if (
url.pathname !== "/" ||
url.href.includes("?") ||
url.href.includes("#") ||
url.username ||
url.password ||
!["http:", "https:"].includes(url.protocol)
) {
errors.push(
`${name} must be an HTTP(S) origin without credentials, path, query, or fragment.`,
);
}
const host = url.hostname
.toLowerCase()
.replace(/^\[|\]$/g, "")
.replace(/\.$/, "");
const localDevelopment =
localHostnames.has(host) ||
host.endsWith(".localhost") ||
host === "host.lima.internal" ||
/^127(?:\.\d{1,3}){3}$/.test(host);
if (url.protocol === "http:" && !localDevelopment) {
errors.push(`${name} must use HTTPS except for local development hosts.`);
}
if (remote && url.protocol !== "https:") {
errors.push(`${name} must use https:// for remote validation.`);
}
if (remote && isPrivateOrLocalHost(url.hostname)) {
errors.push(
`${name} must be public, not localhost, for remote validation.`,
);
}
}
function isPrivateOrLocalHost(hostname) {
const host = hostname
.toLowerCase()
.replace(/^\[|\]$/g, "")
.replace(/\.$/, "");
if (
localHostnames.has(host) ||
host.endsWith(".localhost") ||
host.endsWith(".local")
) {
return true;
}
const ipVersion = isIP(host);
if (ipVersion === 4) {
const [a, b] = host.split(".").map((part) => Number(part));
return (
a === 10 ||
a === 127 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254)
);
}
if (ipVersion === 6) {
return (
host === "::1" ||
host.startsWith("fc") ||
host.startsWith("fd") ||
host.startsWith("fe80:")
);
}
return false;
}
function issue(message) {
if (remote || productionComposeEnv) {
errors.push(message);
} else {
warnings.push(message);
}
}
function finish() {
for (const warning of warnings) {
console.warn(`Warning: ${warning}`);
}
if (errors.length > 0) {
for (const error of errors) {
console.error(`Error: ${error}`);
}
process.exit(1);
}
console.log(
smoke
? "Local smoke config validation passed."
: remote
? "Remote config validation passed."
: "Local config validation passed.",
);
process.exit(0);
}