config/env.ts
5.6 KB · sha256:786b440e9ca4d29eb78caefe587ea55fccd49c5072c07cf14d38c2ad4e773151
import { z } from "zod";
import path from "path";
import fs from "fs";
/**
* Load environment variables from disk + process.env.
*
* The cwd at runtime is usually the parent `scripts/` directory (rune
* launches each script with the scripts dir as cwd), so the rune's own
* `.env` lives at `./ward/.env`. We also accept a top-level `./.env` as
* a fallback so operators can keep one shared file if they prefer.
*/
function loadEnv(): Record<string, string | undefined> {
const candidates = [
path.join(process.cwd(), "ward", ".env"),
path.join(process.cwd(), ".env"),
];
for (const p of candidates) {
try {
const envFile = fs.readFileSync(p, "utf-8");
const envVars: Record<string, string> = {};
for (const line of envFile.split("\n")) {
const cleanLine = line.trim().replace(/\r$/, "");
if (cleanLine === "" || cleanLine.startsWith("#")) continue;
const match = cleanLine.match(/^([^=]+)=(.*)$/);
if (match?.[1] && match[2] !== undefined) {
const key = match[1].trim();
let value = match[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
envVars[key] = value;
}
}
return { ...process.env, ...envVars };
} catch {
// try next candidate
}
}
console.warn("ward: no .env file found, using process.env only");
return { ...process.env };
}
const envVars = loadEnv();
// Hoist a known set of keys onto process.env so anything that reads
// directly from process.env (subprocesses, libraries that bypass `env`,
// etc.) sees the file-sourced values too.
const HOIST_TO_PROCESS_ENV = [
"DATABASE_URI",
"WEB_HOST",
"WEB_PORT",
] as const;
for (const key of HOIST_TO_PROCESS_ENV) {
if (envVars[key] !== undefined) process.env[key] = envVars[key];
}
/**
* Parse a boolish input (`"true"` / `"1"` / `"yes"` / `"on"`, or the
* inverse) into a real boolean, falling back to `dflt` on missing /
* unrecognised input. Accepts a literal boolean too so process.env values
* that arrive pre-coerced (rare but possible) still work.
*/
const boolish = (dflt: boolean) =>
z
.union([z.string(), z.boolean(), z.undefined()])
.transform((v): boolean => {
if (typeof v === "boolean") return v;
if (v == null) return dflt;
const s = String(v).trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(s)) return true;
if (["0", "false", "no", "off"].includes(s)) return false;
return dflt;
});
/**
* Parse a numeric env var with a default. Strings come in from .env;
* we coerce + validate against a finite-number predicate so a stray
* "abc" trips schema validation instead of silently becoming NaN.
*/
const numericDefault = (dflt: number, opts: { int?: boolean } = {}) =>
z
.union([z.string(), z.number(), z.undefined()])
.transform((v): number => {
if (typeof v === "number") return v;
if (v == null || v === "") return dflt;
const n = Number(v);
if (!Number.isFinite(n)) return dflt;
return opts.int ? Math.floor(n) : n;
});
const envSchema = z.object({
NODE_ENV: z.enum(["development", "production"]).default("development"),
// ----- Database -----
DATABASE_URI: z
.string()
.default("mongodb://localhost:27017/ward")
.describe("MongoDB connection string"),
// ----- Host plugin -----
RUNE_PLUGIN_NAME: z
.string()
.default("Rune")
.describe(
"Bukkit plugin name that hosts this script (used for scheduler ownership)",
),
// ----- Web dashboard -----
WEB_HOST: z
.string()
.default("0.0.0.0")
.describe("Bind address for the dashboard HTTP server"),
WEB_PORT: numericDefault(3000, { int: true }).describe(
"TCP port the dashboard HTTP server binds on",
),
WEB_DEV: boolish(false).describe(
"Run the SPA via its dev server (HMR) instead of the prebuilt bundle",
),
WEB_DEV_PORT: numericDefault(3001, { int: true }).describe(
"Port the SPA dev server (vite) listens on when WEB_DEV=true",
),
WEB_TOKEN_TTL_HOURS: numericDefault(24).describe(
"How long bearer tokens minted by /ward web token stay valid (hours)",
),
WEB_TOKEN_SWEEP_MINUTES: numericDefault(15).describe(
"Background interval for purging expired bearer tokens (minutes)",
),
// ----- Chat -----
CHAT_DISPLAY: boolish(true).describe(
"Cancel chat events and re-broadcast with Ward prefix/suffix formatting",
),
CHAT_FORMAT: z
.string()
.default("%ward_prefix%%player_name%%ward_suffix%<gray>:</gray> <message>")
.describe(
"MiniMessage + PAPI template for chat lines; `<message>` is the literal player message",
),
CHAT_HOVER: z
.string()
.default(
"<gray>Name: <white>%player_name%</white><newline>" +
"<gray>Group: <white>%ward_group%</white><newline>" +
"<gray>Prefix: %ward_prefix%<reset><newline>" +
"<gray>UUID: <dark_gray>%player_uuid%",
)
.describe(
"MiniMessage + PAPI hover-text template for chat lines (empty = no hover)",
),
// ----- PAPI -----
PAPI_EXPANSION: boolish(true).describe(
"Register a PlaceholderAPI expansion exposing Ward state",
),
});
try {
envSchema.parse(envVars);
} catch (error) {
if (error instanceof z.ZodError) {
console.error(
"ward: invalid environment variables:",
`Validation failed: ${error.issues
.map((e) => `${e.path.join(".")}: ${e.message}`)
.join(", ")}`,
);
}
}
export const env = envSchema.parse(envVars);
export type Env = z.infer<typeof envSchema>;