import { existsSync, readFileSync, statSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { join, resolve } from 'node:path'; import { buildProviderStatusReport } from './provider-status.js'; import { ROUTE_PREREQUISITES } from './provider-platform/route-prerequisites.js'; import { APPLICATION_ROOT } from './veo-runtime.js'; import type { ProviderRouteId } from './provider-platform/types.js'; interface EnvVarStatus { name: string; /** * True when at least one provider route DECLARES this key. It is not a * blocking requirement: you only need the keys for the route you intend to * render on, so a missing one is a warning naming that route. */ required: boolean; /** The route ids this key gates, empty for advisory keys no route declares. */ gatesRoutes: ProviderRouteId[]; present: boolean; source: 'process' | '.env.local' | '.env' | 'missing'; } interface DependencyStatus { name: string; available: boolean; path?: string; } export interface VideoEnvironmentReport { generatedAt: string; workspaceRoot: string; /** The application tree the build row was checked against. */ applicationRoot: string; envSources: string[]; envVars: EnvVarStatus[]; geminiKeyPool: { count: number; recommended: number; ok: boolean; }; localDependencies: DependencyStatus[]; /** * The interpreter actually running, against the floor `package.json` * declares. Unlike a provider key, a Node below the floor cannot run the tool * at all, so this one does block. */ nodeVersion: { current: string; declaredMinimum: string | null; satisfied: boolean; }; build: { path: string; exists: boolean; ageHours?: number; fresh?: boolean; }; providers: ReturnType; blockingIssues: string[]; warnings: string[]; ok: boolean; } interface BuildVideoEnvironmentReportOptions { workspaceRoot?: string; /** * Where the `vclaw` application itself lives. The build-output row checks * THIS root, not the workspace: on an installed package the compiled CLI sits * in the package directory, so checking the workspace (or cwd) reported * "Build output missing: dist/cli/vclaw.js" and a blanket ok:false on a * perfectly healthy install. Same resolver the Veo sidecar uses. */ applicationRoot?: string; env?: NodeJS.ProcessEnv; now?: Date; probeExecutable?: (name: string) => string | undefined; /** The running interpreter version (e.g. `v20.10.0`); default process.version. */ nodeVersion?: string; } function readDotEnvLikeFile(path: string): Record { if (!existsSync(path)) return {}; const out: Record = {}; const raw = readFileSync(path, 'utf-8'); for (const line of raw.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) continue; const [key, ...rest] = trimmed.split('='); out[key.trim()] = rest.join('=').trim().replace(/^['"]|['"]$/g, ''); } return out; } function findExecutable(name: string): string | undefined { try { const resolved = execFileSync('which', [name], { encoding: 'utf-8' }).trim(); return resolved || undefined; } catch { return undefined; } } function countGeminiKeys(env: Record): number { const sources = [ env.GEMINI_API_KEYS, env.GOOGLE_API_KEYS, env.GOOGLE_API_KEY, ].filter((value): value is string => typeof value === 'string' && value.trim().length > 0); const seen = new Set(); for (const source of sources) { for (const raw of source.split(/[,;\n\s]+/)) { const key = raw.trim(); if (!key) continue; seen.add(key); } } return seen.size; } /** * Every env var some provider route declares, mapped to the routes that * declare it, in route-declaration order. Derived from `ROUTE_PREREQUISITES` * (the one declaration per route, #463) rather than a hand-kept list — the old * hardcoded trio drifted from reality in both directions: it demanded * GO_BANANAS_API_KEY, which no route declares, and never mentioned * USEAPI_API_TOKEN, which two of them do. */ function routeEnvVarIndex(): Map { const index = new Map(); for (const routeId of Object.keys(ROUTE_PREREQUISITES) as ProviderRouteId[]) { for (const name of ROUTE_PREREQUISITES[routeId].requiredEnvVars) { index.set(name, [...(index.get(name) ?? []), routeId]); } } return index; } /** * True when a route issue is nothing but "Missing environment variables: …" * naming keys the per-key warnings already reported. Any other issue text — a * dependency, a sidecar failure — is false, so it keeps its route warning. */ const MISSING_ENV_ISSUE_PREFIX = 'Missing environment variables: '; function issueIsOnlyReportedKeys(issue: string, reportedMissingKeys: Set): boolean { if (!issue.startsWith(MISSING_ENV_ISSUE_PREFIX)) return false; return issue.slice(MISSING_ENV_ISSUE_PREFIX.length) .split(',') .map((name) => name.trim()) .filter(Boolean) .every((name) => reportedMissingKeys.has(name)); } /** The `engines.node` floor the application declares, or null when unreadable. */ function declaredNodeMinimum(applicationRoot: string): string | null { try { const raw = readFileSync(join(applicationRoot, 'package.json'), 'utf-8'); const declared = (JSON.parse(raw) as { engines?: { node?: string } }).engines?.node; return typeof declared === 'string' && declared.trim() ? declared.trim() : null; } catch { return null; } } /** Compare a running `vX.Y.Z` against a `>=X.Y` floor. Unparseable → satisfied. */ function nodeSatisfies(current: string, declaredMinimum: string | null): boolean { if (!declaredMinimum) return true; const floor = /(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(declaredMinimum); const running = /(\d+)\.(\d+)\.(\d+)/.exec(current); if (!floor || !running) return true; const want = [Number(floor[1]), Number(floor[2] ?? 0), Number(floor[3] ?? 0)]; const have = [Number(running[1]), Number(running[2]), Number(running[3])]; for (let i = 0; i < 3; i += 1) { if (have[i] > want[i]) return true; if (have[i] < want[i]) return false; } return true; } export function buildVideoEnvironmentReport( options: BuildVideoEnvironmentReportOptions = {}, ): VideoEnvironmentReport { // .env/.env.local are run-environment config. The CLI hands in the workspace // resolved the same way every other command resolves it (--root -> // VCLAW_WORKSPACE -> ~/videoclaw); cwd is only the library default when a // caller names no root. The BUILD row is separate — it checks applicationRoot. const workspaceRoot = resolve(options.workspaceRoot ?? process.cwd()); const now = options.now ?? new Date(); const probe = options.probeExecutable ?? findExecutable; const envPath = join(workspaceRoot, '.env'); const envLocalPath = join(workspaceRoot, '.env.local'); const envFile = readDotEnvLikeFile(envPath); const envLocalFile = readDotEnvLikeFile(envLocalPath); const mergedEnv = { ...envFile, ...envLocalFile, ...(options.env ?? process.env), }; const envSources = [envPath, envLocalPath].filter((path) => existsSync(path)); // Provider keys come from the route declarations, and NONE of them blocks: // you only need the keys for the route you intend to render on. A machine // with Flow credentials and nothing else is a working machine. const routeEnvVars = routeEnvVarIndex(); const requiredEnvVars = [...routeEnvVars.keys()]; // Advisory keys no provider route declares: they widen what the tool can do // (Gemini analysis, Go Bananas stills, ElevenLabs narration) but gate no route. const optionalEnvVars = ['GOOGLE_API_KEY', 'GO_BANANAS_API_KEY', 'GEMINI_API_KEYS', 'GOOGLE_API_KEYS', 'ELEVENLABS_API_KEY'] .filter((name) => !routeEnvVars.has(name)); const envVars: EnvVarStatus[] = [...requiredEnvVars, ...optionalEnvVars].map((name) => { const present = Boolean(mergedEnv[name]?.trim()); let source: EnvVarStatus['source'] = 'missing'; if (present) { if ((options.env ?? process.env)[name]) { source = 'process'; } else if (Object.prototype.hasOwnProperty.call(envLocalFile, name)) { source = '.env.local'; } else if (Object.prototype.hasOwnProperty.call(envFile, name)) { source = '.env'; } } return { name, required: routeEnvVars.has(name), gatesRoutes: routeEnvVars.get(name) ?? [], present, source, }; }); const geminiKeyPoolCount = countGeminiKeys(mergedEnv); const localDependencyNames = ['node', 'npm', 'python3', 'ffmpeg', 'ffprobe', 'curl', 'bun']; const localDependencies: DependencyStatus[] = localDependencyNames.map((name) => ({ name, available: Boolean(probe(name)), path: probe(name), })); const applicationRoot = resolve(options.applicationRoot ?? APPLICATION_ROOT); const nodeVersionCurrent = options.nodeVersion ?? process.version; const nodeDeclaredMinimum = declaredNodeMinimum(applicationRoot); const nodeVersionSatisfied = nodeSatisfies(nodeVersionCurrent, nodeDeclaredMinimum); const buildPath = join(applicationRoot, 'dist', 'cli', 'vclaw.js'); const buildExists = existsSync(buildPath); const buildAgeHours = buildExists ? Math.max(0, Math.floor((now.getTime() - statSync(buildPath).mtime.getTime()) / 3_600_000)) : undefined; const buildFresh = buildAgeHours !== undefined ? buildAgeHours < 24 : undefined; const providers = buildProviderStatusReport({ workspaceRoot, env: mergedEnv, now, probeExecutable: (name) => probe(name), }); const blockingIssues: string[] = []; const warnings: string[] = []; // A provider key is a WARNING naming the route it gates. Making all three of // an old hardcoded list blocking meant a correctly-installed, Flow-only // machine read `ok: false` and had no way to tell which red row was real. const reportedMissingKeys = new Set(); for (const envVar of envVars) { if (envVar.present) continue; if (envVar.gatesRoutes.length > 0) { warnings.push(`Missing ${envVar.name}: route(s) ${envVar.gatesRoutes.join(', ')} cannot run without it. Only the route you intend to render on needs its keys.`); reportedMissingKeys.add(envVar.name); } } // Only what makes the tool itself unusable blocks. python3/bun/npm/curl are // per-route or per-lane, so they warn with the route named, like the keys. const BLOCKING_DEPENDENCIES = ['node', 'ffmpeg', 'ffprobe']; for (const dependency of localDependencies) { if (dependency.available) continue; if (BLOCKING_DEPENDENCIES.includes(dependency.name)) { blockingIssues.push(`Missing required local dependency: ${dependency.name}`); continue; } const gated = (Object.keys(ROUTE_PREREQUISITES) as ProviderRouteId[]) .filter((routeId) => (ROUTE_PREREQUISITES[routeId].requiredDependencies as string[]).includes(dependency.name)); warnings.push(gated.length > 0 ? `Missing ${dependency.name}: route(s) ${gated.join(', ')} cannot run without it.` : `Missing ${dependency.name}: some lanes need it, but no provider route declares it.`); } if (!nodeVersionSatisfied) { blockingIssues.push(`Node ${nodeVersionCurrent} is below the declared minimum ${nodeDeclaredMinimum ?? ''}`.trim()); } if (!buildExists) { blockingIssues.push('Build output missing: dist/cli/vclaw.js'); } else if (buildFresh === false) { warnings.push(`Build output is ${buildAgeHours}h old; run npm run build if the code changed.`); } if (geminiKeyPoolCount === 0) { warnings.push('No Gemini key pool detected; director decomposition will rely on GOOGLE_API_KEY only.'); } else if (geminiKeyPoolCount < 3) { warnings.push(`Gemini key pool size is ${geminiKeyPoolCount}; recommend 3+ keys for longer director runs.`); } // An unconfigured route is a route you have not set up, not a broken install. // seedance-direct used to block here, which is the other half of why a // Flow-only machine could never reach ok: true. for (const route of providers.routes) { if (route.availability === 'available') continue; // A route whose ONLY problem is keys the per-key warnings already named // would say the same thing a second time from the other direction, which on // a bare machine doubled the warning list. Anything else about the route — // a missing dependency, a sidecar issue, a scaffold note — still reports. if (route.issues.length > 0 && route.issues.every((issue) => issueIsOnlyReportedKeys(issue, reportedMissingKeys))) { continue; } warnings.push(`Route ${route.routeId} is ${route.availability}: ${route.issues.join('; ') || route.notes.join('; ')}`); } return { generatedAt: now.toISOString(), workspaceRoot, applicationRoot, envSources, envVars, geminiKeyPool: { count: geminiKeyPoolCount, recommended: 3, ok: geminiKeyPoolCount >= 3, }, localDependencies, nodeVersion: { current: nodeVersionCurrent, declaredMinimum: nodeDeclaredMinimum, satisfied: nodeVersionSatisfied, }, build: { path: buildPath, exists: buildExists, ...(buildAgeHours !== undefined ? { ageHours: buildAgeHours } : {}), ...(buildFresh !== undefined ? { fresh: buildFresh } : {}), }, providers, blockingIssues, warnings, ok: blockingIssues.length === 0, }; }