Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

538 lines
14 KiB
TypeScript

#!/usr/bin/env bun
/**
* Environment validation entrypoint for Feed deployments.
* It checks required service, database, cache, and game variables before local or deployed startup.
*/
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
export type ValidationProfile = "local" | "staging" | "production";
export type EnvValues = Record<string, string | undefined>;
type RuleKind = "allOf" | "oneOf";
type RuleContext = {
env: EnvValues;
profile: ValidationProfile;
};
type ValidationRule = {
id: string;
description: string;
kind: RuleKind;
keys: string[];
profiles?: ValidationProfile[];
when?: (context: RuleContext) => boolean;
};
export type MissingRequirement = {
id: string;
description: string;
requirement: string;
};
type DeprecatedEnvVar = {
key: string;
message: string;
};
export type ValidationResult = {
profile: ValidationProfile;
activeRuleCount: number;
missing: MissingRequirement[];
deprecated: DeprecatedEnvVar[];
undocumentedKeys: string[];
valid: boolean;
};
type ParsedArgs = {
profile?: ValidationProfile;
envFiles: string[];
showHelp: boolean;
};
const TRUTHY_VALUES = new Set(["1", "true", "yes", "on", "start", "running"]);
const BASE_REQUIRED_RULES: ValidationRule[] = [
{
id: "database-url",
description: "Database connection",
kind: "allOf",
keys: ["DATABASE_URL"],
},
{
id: "steward-jwt-secret",
description: "Steward JWT signing secret",
kind: "allOf",
keys: ["STEWARD_JWT_SECRET"],
},
{
id: "cron-secret",
description: "Cron authentication",
kind: "allOf",
keys: ["CRON_SECRET"],
},
{
id: "llm-provider-key",
description: "At least one LLM provider API key",
kind: "oneOf",
keys: [
"ELIZACLOUD_API_KEY",
"GROQ_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
],
},
];
const CONDITIONAL_RULES: ValidationRule[] = [
{
id: "sendgrid-from-address",
description: "Notification sender address when SendGrid is enabled",
kind: "oneOf",
keys: ["NOTIFICATION_EMAIL_FROM", "EMAIL_FROM"],
when: ({ env }) => isSet(env, "SENDGRID_API_KEY"),
},
{
id: "agent0-core",
description: "Agent0 runtime settings when Agent0 integration is enabled",
kind: "allOf",
keys: [
"AGENT0_RPC_URL",
"AGENT0_PRIVATE_KEY",
"FEED_GAME_WALLET_ADDRESS",
"AGENT0_SUBGRAPH_URL",
],
when: ({ env }) => isEnabled(env.AGENT0_ENABLED),
},
{
id: "agent0-ipfs-provider",
description: "Agent0 IPFS provider credentials when Agent0 is enabled",
kind: "oneOf",
keys: ["PINATA_JWT", "FILECOIN_PRIVATE_KEY", "AGENT0_IPFS_API"],
when: ({ env }) => isEnabled(env.AGENT0_ENABLED),
},
{
id: "nft-chat-gating",
description: "NFT chat gating dependencies when NFT chat gating is enabled",
kind: "allOf",
keys: ["NFT_CONTRACT_ADDRESS", "NFT_INDEXER_GRAPHQL_URL"],
when: ({ env }) => isEnabled(env.NFT_CHAT_GATING_ENABLED),
},
{
id: "public-app-url",
description: "Public app URL for non-local environments",
kind: "allOf",
keys: ["NEXT_PUBLIC_APP_URL"],
profiles: ["staging", "production"],
},
];
const DEPRECATED_ENV_VARS: DeprecatedEnvVar[] = [
{
key: "WAITLIST_MODE",
message:
"Deprecated. Waitlist/app routing is host-based now (WAITLIST_HOSTNAMES).",
},
{
key: "NEXT_PUBLIC_POSTHOG_KEY",
message: "Deprecated alias. Use NEXT_PUBLIC_POSTHOG_PROJECT_ID.",
},
{
key: "FEED_GAME_WALLET",
message: "Legacy alias. Use FEED_GAME_WALLET_ADDRESS.",
},
];
const UNDOCUMENTED_ALLOWLIST = new Set([
"CI",
"HOSTNAME",
"HOME",
"PWD",
"PATH",
"SHLVL",
]);
function normalizeValue(value: string | undefined): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
export function isEnabled(value: string | undefined): boolean {
const normalized = normalizeValue(value)?.toLowerCase();
if (!normalized) return false;
return TRUTHY_VALUES.has(normalized);
}
function isSet(env: EnvValues, key: string): boolean {
return Boolean(normalizeValue(env[key]));
}
function buildRequirement(rule: ValidationRule): string {
if (rule.kind === "allOf") {
return `all of: ${rule.keys.join(", ")}`;
}
return `one of: ${rule.keys.join(", ")}`;
}
function isRuleActive(rule: ValidationRule, context: RuleContext): boolean {
if (rule.profiles && !rule.profiles.includes(context.profile)) {
return false;
}
if (!rule.when) {
return true;
}
return rule.when(context);
}
export function deriveProfile(
env: EnvValues,
explicitProfile?: ValidationProfile,
): ValidationProfile {
if (explicitProfile) return explicitProfile;
const deploymentEnv = env.DEPLOYMENT_ENV?.toLowerCase().trim();
if (deploymentEnv === "mainnet") return "production";
if (deploymentEnv === "testnet") return "staging";
if (deploymentEnv === "localnet") return "local";
const vercelEnv = env.VERCEL_ENV?.toLowerCase().trim();
if (vercelEnv === "production") return "production";
if (vercelEnv === "preview") return "staging";
const nodeEnv = env.NODE_ENV?.toLowerCase().trim();
if (nodeEnv === "production") return "production";
return "local";
}
export function evaluateEnv(params: {
env: EnvValues;
profile: ValidationProfile;
documentedKeys?: Set<string>;
declaredFileKeys?: Set<string>;
}): ValidationResult {
const {
env,
profile,
documentedKeys = new Set(),
declaredFileKeys = new Set(),
} = params;
const context: RuleContext = { env, profile };
const rules = [...BASE_REQUIRED_RULES, ...CONDITIONAL_RULES].filter((rule) =>
isRuleActive(rule, context),
);
const missing: MissingRequirement[] = [];
for (const rule of rules) {
if (rule.kind === "allOf") {
const missingKeys = rule.keys.filter((key) => !isSet(env, key));
if (missingKeys.length > 0) {
missing.push({
id: rule.id,
description: rule.description,
requirement: buildRequirement(rule),
});
}
continue;
}
const hasAny = rule.keys.some((key) => isSet(env, key));
if (!hasAny) {
missing.push({
id: rule.id,
description: rule.description,
requirement: buildRequirement(rule),
});
}
}
const deprecated = DEPRECATED_ENV_VARS.filter(({ key }) => isSet(env, key));
const deprecatedKeys = new Set(deprecated.map((item) => item.key));
const undocumentedKeys = Array.from(declaredFileKeys).filter((key) => {
if (deprecatedKeys.has(key)) return false;
if (documentedKeys.has(key)) return false;
if (UNDOCUMENTED_ALLOWLIST.has(key)) return false;
if (key.startsWith("TURBO_")) return false;
if (key.startsWith("NX_")) return false;
return true;
});
undocumentedKeys.sort((a, b) => a.localeCompare(b));
return {
profile,
activeRuleCount: rules.length,
missing,
deprecated,
undocumentedKeys,
valid: missing.length === 0,
};
}
function loadFileEnv(filePath: string): Record<string, string> {
if (!existsSync(filePath)) {
return {};
}
const raw = readFileSync(filePath, "utf8");
return parseEnvContent(raw);
}
function extractEnvKeys(filePath: string): string[] {
if (!existsSync(filePath)) return [];
const content = readFileSync(filePath, "utf8");
const keys: string[] = [];
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const delimiterIndex = trimmed.indexOf("=");
if (delimiterIndex <= 0) continue;
const key = trimmed.slice(0, delimiterIndex).trim();
if (!/^[A-Z0-9_]+$/.test(key)) continue;
keys.push(key);
}
return keys;
}
function normalizeEnvMap(input: Record<string, string | undefined>): EnvValues {
const normalized: EnvValues = {};
for (const [key, value] of Object.entries(input)) {
normalized[key] = normalizeValue(value);
}
return normalized;
}
function parseEnvContent(content: string): Record<string, string> {
const parsed: Record<string, string> = {};
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const delimiterIndex = trimmed.indexOf("=");
if (delimiterIndex <= 0) {
continue;
}
const key = trimmed.slice(0, delimiterIndex).trim();
if (!/^[A-Z0-9_]+$/.test(key)) {
continue;
}
let rawValue = trimmed.slice(delimiterIndex + 1).trim();
if (
(rawValue.startsWith('"') && rawValue.endsWith('"')) ||
(rawValue.startsWith("'") && rawValue.endsWith("'"))
) {
rawValue = rawValue.slice(1, -1);
}
parsed[key] = rawValue;
}
return parsed;
}
function parseArgs(args: string[]): ParsedArgs {
const parsed: ParsedArgs = {
envFiles: [],
showHelp: false,
};
for (let index = 0; index < args.length; index++) {
const arg = args[index];
if (!arg) continue;
if (arg === "--help" || arg === "-h") {
parsed.showHelp = true;
continue;
}
if (arg.startsWith("--profile=")) {
const profile = arg.slice("--profile=".length) as ValidationProfile;
if (!["local", "staging", "production"].includes(profile)) {
throw new Error(
`Invalid --profile value "${profile}". Expected local|staging|production.`,
);
}
parsed.profile = profile;
continue;
}
if (arg === "--profile") {
const value = args[index + 1] as ValidationProfile | undefined;
if (!value) {
throw new Error("--profile requires a value.");
}
if (!["local", "staging", "production"].includes(value)) {
throw new Error(
`Invalid --profile value "${value}". Expected local|staging|production.`,
);
}
parsed.profile = value;
index += 1;
continue;
}
if (arg.startsWith("--env-file=")) {
const filePath = arg.slice("--env-file=".length).trim();
if (!filePath) {
throw new Error("--env-file cannot be empty.");
}
parsed.envFiles.push(filePath);
continue;
}
if (arg === "--env-file") {
const value = args[index + 1];
if (!value) {
throw new Error("--env-file requires a value.");
}
parsed.envFiles.push(value);
index += 1;
continue;
}
throw new Error(`Unknown argument "${arg}". Use --help for usage.`);
}
return parsed;
}
function getDefaultEnvFiles(profile: ValidationProfile): string[] {
if (profile === "production") {
return [".env", ".env.production.local"];
}
if (profile === "staging") {
return [".env", ".env.staging.local"];
}
return [".env", ".env.local"];
}
function printHelp(): void {
console.info("Validate Feed environment variables");
console.info("");
console.info("Usage:");
console.info(
" bun run scripts/validate-env.ts [--profile local|staging|production] [--env-file <path>]",
);
console.info("");
console.info("Examples:");
console.info(" bun run scripts/validate-env.ts");
console.info(
" bun run scripts/validate-env.ts --profile=staging --env-file=.env.staging.local",
);
}
function printMissingRequirements(missing: MissingRequirement[]): void {
console.error("Missing required environment settings:");
for (const item of missing) {
console.error(`- ${item.description}`);
console.error(` requirement: ${item.requirement}`);
}
}
function printWarnings(result: ValidationResult): void {
if (result.deprecated.length > 0) {
console.warn("");
console.warn("Deprecated environment variables detected:");
for (const warning of result.deprecated) {
console.warn(`- ${warning.key}: ${warning.message}`);
}
}
if (result.undocumentedKeys.length > 0) {
console.warn("");
console.warn(
`Undocumented keys detected in loaded env files (${result.undocumentedKeys.length}):`,
);
const preview = result.undocumentedKeys.slice(0, 40);
for (const key of preview) {
console.warn(`- ${key}`);
}
if (result.undocumentedKeys.length > preview.length) {
const extraCount = result.undocumentedKeys.length - preview.length;
console.warn(`- ...and ${extraCount} more`);
}
}
}
export function runCli(args: string[] = process.argv.slice(2)): number {
const parsedArgs = parseArgs(args);
if (parsedArgs.showHelp) {
printHelp();
return 0;
}
const processEnv = normalizeEnvMap(process.env);
const profile = deriveProfile(processEnv, parsedArgs.profile);
const filesToLoad = getDefaultEnvFiles(profile);
for (const file of parsedArgs.envFiles) {
filesToLoad.push(file);
}
const loadedFiles: string[] = [];
const mergedFileEnv: EnvValues = {};
const declaredFileKeys = new Set<string>();
for (const file of filesToLoad) {
const absolutePath = resolve(process.cwd(), file);
if (!existsSync(absolutePath)) {
continue;
}
const parsedFile = loadFileEnv(absolutePath);
for (const [key, value] of Object.entries(parsedFile)) {
mergedFileEnv[key] = normalizeValue(value);
}
for (const key of extractEnvKeys(absolutePath)) {
declaredFileKeys.add(key);
}
loadedFiles.push(file);
}
const mergedEnv: EnvValues = {
...mergedFileEnv,
...processEnv,
};
const examplePath = resolve(process.cwd(), ".env.example");
const documentedKeys = new Set(extractEnvKeys(examplePath));
const result = evaluateEnv({
env: mergedEnv,
profile,
documentedKeys,
declaredFileKeys,
});
console.info(`Profile: ${result.profile}`);
if (loadedFiles.length > 0) {
console.info(`Loaded env files: ${loadedFiles.join(", ")}`);
} else {
console.info("Loaded env files: none (using process environment only)");
}
console.info(`Active rules: ${result.activeRuleCount}`);
if (!result.valid) {
console.error("");
printMissingRequirements(result.missing);
printWarnings(result);
return 1;
}
console.info("");
console.info("Environment validation passed.");
printWarnings(result);
return 0;
}
if (import.meta.main) {
process.exit(runCli());
}