#!/usr/bin/env node import { existsSync, readFileSync } from 'node:fs'; import { isMainModule, printJson, runCommand } from './command-center-utils.mjs'; const DEFAULT_ENV_FILES = ['.env.local', '.env.real-smoke.local', '.env.agent.local']; const SECRET_NAME_PATTERN = /(API_?KEY|API_?KEYS|TOKEN|PASSWORD|SECRET|CREDENTIAL|PRIVATE)/i; const PRIVATE_ENDPOINT_NAME_PATTERN = /PROXY/i; const URL_NAME_PATTERN = /(BASE_URL|URL|ENDPOINT|HOST)$/i; const DOCKER_INSPECT_TIMEOUT_MS = 10_000; export function parseEnvContent(content) { const entries = []; for (const line of content.split(/\r?\n/)) { const parsed = parseEnvLine(line); if (parsed) entries.push(parsed); } return entries; } function parseEnvLine(line) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) return undefined; const match = trimmed.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); if (!match) return undefined; return { name: match[1], value: parseEnvValue(match[2].trim()) }; } function parseEnvValue(value) { if (value.length < 2) return value; if (value.startsWith('"') || value.startsWith("'")) { return parseQuotedEnvValue(value); } return stripUnquotedInlineComment(value).trim(); } function parseQuotedEnvValue(value) { const quote = value[0]; const closeIndex = value.indexOf(quote, 1); if (closeIndex < 0) return value.slice(1); return value.slice(1, closeIndex); } function stripUnquotedInlineComment(value) { const index = value.search(/\s#/); if (index < 0) return value; return value.slice(0, index); } export function summarizeEnvEntries(entries) { return entries.map(({ name, value }) => summarizeEnvEntry(name, value)).sort((a, b) => a.name.localeCompare(b.name)); } function summarizeEnvEntry(name, value) { const set = value.length > 0; const sensitive = SECRET_NAME_PATTERN.test(name) || PRIVATE_ENDPOINT_NAME_PATTERN.test(name); const summary = { name, set, sensitive, value_kind: classifyValue(value) }; if (sensitive && set) { summary.item_count = value.split(',').map((item) => item.trim()).filter(Boolean).length; } if (!sensitive && set && URL_NAME_PATTERN.test(name)) { const endpoint = summarizeEndpoint(value); summary.value_kind = endpoint.kind; summary.url = endpoint; } return summary; } function classifyValue(value) { if (!value) return 'empty'; if (/^(true|false)$/i.test(value)) return 'boolean'; if (/^\d+$/.test(value)) return 'integer'; if (isHttpUrl(value)) return 'url'; if (value.includes(',')) return 'list'; return 'non_empty'; } function isHttpUrl(value) { try { const url = new URL(value); return url.protocol === 'http:' || url.protocol === 'https:'; } catch { return false; } } function summarizeUrl(value) { try { const url = new URL(value); return { valid: true, protocol: url.protocol.replace(':', ''), host: url.host, has_path: url.pathname !== '/', has_query: Boolean(url.search), has_fragment: Boolean(url.hash), has_credentials: Boolean(url.username || url.password) }; } catch { return { valid: false }; } } function summarizeEndpoint(value) { if (isHttpUrl(value)) return { ...summarizeUrl(value), kind: 'url' }; const host = summarizeHost(value); if (host.valid) return { ...host, kind: 'host' }; return { valid: false, kind: classifyValue(value) }; } function summarizeHost(value) { if (!/^[A-Za-z0-9._-]+(?::\d+)?$/.test(value)) return { valid: false }; try { const url = new URL(`http://${value}`); return { valid: true, protocol: null, host: url.host, has_path: false, has_query: false, has_fragment: false, has_credentials: false }; } catch { return { valid: false }; } } export function summarizeEnvFile(filePath) { if (!existsSync(filePath)) return { type: 'file', path: filePath, exists: false, variables: [] }; const entries = parseEnvContent(readFileSync(filePath, 'utf8')); return { type: 'file', path: filePath, exists: true, variable_count: entries.length, variables: summarizeEnvEntries(entries) }; } export function summarizeDockerEnv(containerName) { const result = runCommand('docker', ['inspect', containerName, '--format', '{{json .Config.Env}}'], { timeoutMs: DOCKER_INSPECT_TIMEOUT_MS }); if (!result.ok) { return { type: 'docker_container', container: containerName, ok: false, error: summarizeDockerInspectFailure(result) }; } const entries = parseDockerEnvOutput(result.stdout); return { type: 'docker_container', container: containerName, ok: true, variable_count: entries.length, variables: summarizeEnvEntries(entries) }; } function summarizeDockerInspectFailure(result) { return { message: result.error || 'docker inspect failed', status: result.status, signal: result.signal || undefined }; } export function parseDockerEnvOutput(output) { const values = JSON.parse(output); if (!Array.isArray(values)) throw new Error('docker inspect did not return an env array'); return values.map((line) => { const index = String(line).indexOf('='); if (index < 0) return { name: String(line), value: '' }; return { name: String(line).slice(0, index), value: String(line).slice(index + 1) }; }); } function parseArgs(argv) { const options = { files: [], containers: [], help: false }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--help' || arg === '-h') options.help = true; else if (arg === '--file') options.files.push({ path: readOptionValue(argv, (index += 1), arg), optional: false }); else if (arg === '--file-if-exists') options.files.push({ path: readOptionValue(argv, (index += 1), arg), optional: true }); else if (arg === '--container') options.containers.push(readOptionValue(argv, (index += 1), arg)); else throw new Error(`Unknown option: ${arg}`); } if (options.files.length === 0 && options.containers.length === 0) { options.files.push(...DEFAULT_ENV_FILES.map((path) => ({ path, optional: false }))); } return options; } function readOptionValue(argv, index, name) { const value = argv[index]; if (!value || value.startsWith('--')) throw new Error(`${name} requires a value`); return value; } function printHelp() { console.log(`Usage: node scripts/env-summary.mjs node scripts/env-summary.mjs --file .env.local --container gpt-image-playground-customer node scripts/env-summary.mjs --file-if-exists .env.agent.local Output is always redacted. It reports variable names, whether values are set, secret item counts, and URL hosts only.`); } function main() { const options = parseArgs(process.argv.slice(2)); if (options.help) { printHelp(); return; } const sources = [ ...options.files .filter((file) => !file.optional || existsSync(file.path)) .map((file) => summarizeEnvFile(file.path)), ...options.containers.map((containerName) => summarizeDockerEnv(containerName)) ]; printJson({ ok: sources.every((source) => source.ok !== false), redacted: true, sources }); if (sources.some((source) => source.ok === false)) process.exit(1); } try { if (isMainModule(import.meta.url, process.argv[1])) main(); } catch (error) { printJson({ ok: false, redacted: true, error: error instanceof Error ? error.message : String(error) }); process.exit(1); }