#!/usr/bin/env node const fs = require('node:fs'); const path = require('node:path'); const rootDir = path.resolve(__dirname, '..', '..'); const configPath = path.join(rootDir, 'server/src/config.js'); const envExamplePath = path.join(rootDir, 'deploy/trialrun/.env.example'); const composePath = path.join(rootDir, 'deploy/trialrun/docker-compose.yml'); const requiredServices = ['server', 'worker', 'init']; const allowedOverrides = { server: ['NODE_ENV'], worker: ['NODE_ENV', 'SERVE_CLIENT_DIST', 'JOBS_ENABLED'], init: ['NODE_ENV'], }; function readFile(filePath) { return fs.readFileSync(filePath, 'utf8'); } function extractConfigEnvKeys(content) { const keys = new Set(); for (const match of content.matchAll(/process\.env\.([A-Z0-9_]+)/g)) { keys.add(match[1]); } for (const match of content.matchAll(/requiredEnv\("([A-Z0-9_]+)"\)/g)) { keys.add(match[1]); } return [...keys].sort(); } function extractEnvExampleKeys(content) { const keys = new Set(); for (const line of content.split(/\r?\n/)) { const match = line.match(/^\s*#?\s*([A-Z0-9_]+)\s*=/); if (match) { keys.add(match[1]); } } return keys; } function extractServiceBlock(composeContent, serviceName) { const lines = composeContent.split(/\r?\n/); let capture = false; const block = []; for (const line of lines) { const serviceMatch = line.match(/^ ([A-Za-z0-9_]+):\s*$/); if (serviceMatch) { if (capture) { break; } capture = serviceMatch[1] === serviceName; continue; } if (capture) { block.push(line); } } return block.length ? block.join('\n') : null; } function extractSection(serviceBlock, sectionName) { const lines = serviceBlock.split(/\r?\n/); const sectionLines = []; let capture = false; for (const line of lines) { const sectionMatch = line.match(/^\s{4}([A-Za-z0-9_]+):\s*$/); if (sectionMatch) { if (capture) { break; } capture = sectionMatch[1] === sectionName; continue; } if (capture) { sectionLines.push(line); } } return sectionLines; } function extractEnvFileEntries(serviceBlock) { return extractSection(serviceBlock, 'env_file') .map((line) => line.match(/^\s{6}-\s*(.+?)\s*$/)) .filter(Boolean) .map((item) => item[1]); } function extractEnvironmentKeys(serviceBlock) { return extractSection(serviceBlock, 'environment') .map((line) => line.match(/^\s{6}([A-Z0-9_]+):/)) .filter(Boolean) .map((item) => item[1]); } function fail(message, errors) { console.error(`[R26][FAIL] ${message}`); for (const error of errors) { console.error(`- ${error}`); } process.exit(1); } const configContent = readFile(configPath); const envExampleContent = readFile(envExamplePath); const composeContent = readFile(composePath); const configKeys = extractConfigEnvKeys(configContent); const envExampleKeys = extractEnvExampleKeys(envExampleContent); const errors = []; for (const key of configKeys) { if (!envExampleKeys.has(key)) { errors.push(`.env.example 缺少 ${key}`); } } for (const serviceName of requiredServices) { const block = extractServiceBlock(composeContent, serviceName); if (!block) { errors.push(`docker-compose.yml 缺少服务 ${serviceName}`); continue; } const envFiles = extractEnvFileEntries(block); if (!envFiles.includes('./.env')) { errors.push(`服务 ${serviceName} 未通过 env_file 引用 ./.env`); } const environmentKeys = extractEnvironmentKeys(block); const illegalOverrides = environmentKeys.filter( (key) => !(allowedOverrides[serviceName] ?? []).includes(key), ); if (illegalOverrides.length) { errors.push(`服务 ${serviceName} 存在未授权 override: ${illegalOverrides.join(', ')}`); } } if (errors.length) { fail('部署环境契约校验失败', errors); } console.log('[R26][PASS] 部署环境契约校验通过'); console.log(`- config.js env 键数: ${configKeys.length}`); console.log(`- .env.example env 键数: ${envExampleKeys.size}`); console.log(`- 已检查服务: ${requiredServices.join(', ')}`);