import { existsSync, readFileSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { describeFreeInTreeSeedanceEngine } from './execution-runtime.js'; import { resolveActiveTransport } from './execution-adapter.js'; import type { FreeSeedanceEngineDescription } from './execution-adapter.js'; import { DEFAULT_PROVIDER_REGISTRY } from './provider-platform/registry.js'; import { ROUTE_PREREQUISITES } from './provider-platform/route-prerequisites.js'; import { resolveVeoRuntime, veoRuntimeIssues } from './veo-runtime.js'; import type { ProviderRouteId } from './provider-platform/types.js'; import type { VideoProviderRouteStatusReport, VideoProviderRuntimeDependencyStatus, VideoProviderStatusReport, } from './types.js'; type ExecutableName = VideoProviderRuntimeDependencyStatus['name']; interface BuildProviderStatusReportOptions { workspaceRoot?: string; env?: NodeJS.ProcessEnv; now?: Date; probeExecutable?: (name: ExecutableName) => string | undefined; ignoreRuntimeDependencyIssues?: boolean; probeVeoRuntime?: typeof veoRuntimeIssues; /** * Whether the vendored free Seedance engine is bootstrapped. Injectable * because the real probe stats a venv and a browser profile that exist on a * bootstrapped machine and not in CI — without a seam the free-engine * reporting could only be asserted on one of the two. * * A `FreeSeedanceEngineDescription` also carries the human reason, which is * what `setupHint` prints; a bare boolean stays accepted (older callers and * tests) and yields a generic reason. */ probeFreeSeedanceEngine?: (env: NodeJS.ProcessEnv) => boolean | FreeSeedanceEngineDescription; } type EngineVerdict = { ready: boolean; reason?: string }; function normalizeEngineVerdict(result: boolean | FreeSeedanceEngineDescription): EngineVerdict { if (typeof result === 'boolean') { return result ? { ready: true } : { ready: false, reason: 'the free Higgsfield engine that ships with videoclaw is not set up on this machine' }; } return { ready: result.ready, reason: result.reason }; } /** * What to TYPE next when a route cannot run. Ordered by which failing piece has * to be fixed first: the free Higgsfield engine, then the Google Flow sidecar, * then plain credentials, then local binaries. `null` whenever the route has no * issues — including a seedance-direct that is unavailable-free but usable-paid, * where there is nothing to set up. */ function buildSetupHint(input: { routeId: ProviderRouteId; issues: string[]; engine: EngineVerdict; veoSidecarRoot: string | null; missingEnvVars: string[]; missingDependencies: string[]; }): string | null { if (input.issues.length === 0) return null; if (input.routeId === 'seedance-direct' && !input.engine.ready) { return `Seedance 2.0 (free) is not set up yet: ${input.engine.reason}.` + ' Run engines/seedance-direct/bootstrap.sh, then bootstrap/import_cookies.py on a machine whose Chrome' + ' is logged in to higgsfield.ai — or set SUTUI_API_KEY to use the paid API instead.'; } if (input.routeId === 'veo-useapi' && input.veoSidecarRoot) { return `Google Veo (via your Google Flow account) is not set up yet: copy ${input.veoSidecarRoot} somewhere` + ' writable, run bun install --frozen-lockfile there, export VCLAW_VEO_CLI_ROOT to that copy,' + ' and set USEAPI_API_TOKEN and USEAPI_ACCOUNT_EMAIL.'; } if (input.missingEnvVars.length > 0) { return `Missing credentials: export ${input.missingEnvVars.join(' ')} in the shell that runs vclaw.`; } if (input.missingDependencies.length > 0) { return `Install the missing runtime dependencies and put them on PATH: ${input.missingDependencies.join(', ')}.`; } return null; } function findExecutable(command: string): string | undefined { try { const resolved = execFileSync('which', [command], { encoding: 'utf-8' }).trim(); return resolved || undefined; } catch { return undefined; } } 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('='); const value = rest.join('=').trim().replace(/^['"]|['"]$/g, ''); out[key.trim()] = value; } return out; } function hasRouteExecutionOverride(routeId: ProviderRouteId, env: Record): boolean { // Availability includes submission readiness. Poll/cancel overrides do not // replace native submission and cannot waive its credentials or runtime. const names = [ ROUTE_PREREQUISITES[routeId].adapterEnvVar, ...ROUTE_PREREQUISITES[routeId].commandEnvVars, ]; return names.some((name) => { const value = env[name]; return Boolean(value && value.trim() !== ''); }); } export function buildProviderStatusReport( options: BuildProviderStatusReportOptions = {}, ): VideoProviderStatusReport { // .env.local is run-environment config. Callers resolve the root the same way // every other command does (--workspace-root / --root -> VCLAW_WORKSPACE -> // ~/videoclaw); cwd remains the library default for a caller that names none. const workspaceRoot = options.workspaceRoot ?? process.cwd(); const envLocalPath = `${workspaceRoot}/.env.local`; const env = { ...readDotEnvLikeFile(envLocalPath), ...(options.env ?? process.env), }; const now = options.now ?? new Date(); const probe = options.probeExecutable ?? ((name: ExecutableName) => findExecutable(name === 'bun' ? env.VCLAW_VEO_BUN_BIN?.trim() || name : name)); const ignoreRuntimeDependencyIssues = options.ignoreRuntimeDependencyIssues ?? false; const probeFreeSeedanceEngine = options.probeFreeSeedanceEngine ?? describeFreeInTreeSeedanceEngine; const engineVerdictFor = (probeEnv: NodeJS.ProcessEnv) => normalizeEngineVerdict(probeFreeSeedanceEngine(probeEnv)); const engineReady = (probeEnv: NodeJS.ProcessEnv) => engineVerdictFor(probeEnv).ready; const dependencyNames: ExecutableName[] = ['python3', 'bun', 'ffmpeg']; const runtimeDependencies: VideoProviderRuntimeDependencyStatus[] = dependencyNames.map((name) => ({ name, available: Boolean(probe(name)), path: probe(name), })); const availableDependencies = runtimeDependencies.filter((item) => item.available).map((item) => item.name); const envSources: string[] = []; if (existsSync(envLocalPath)) { envSources.push(envLocalPath); } const workspaceIssues: string[] = []; const workspaceOk = existsSync(workspaceRoot); if (!workspaceOk) { workspaceIssues.push('Workspace root does not exist.'); } const routes: VideoProviderRouteStatusReport[] = DEFAULT_PROVIDER_REGISTRY.map((route) => { const prerequisites = ROUTE_PREREQUISITES[route.id]; const requiredEnvVars = prerequisites.requiredEnvVars; const availableEnvVars = requiredEnvVars.filter((name) => { const value = env[name]; return Boolean(value && value.trim() !== ''); }); const missingEnvVars = requiredEnvVars.filter((name) => !availableEnvVars.includes(name)); const requiredDependencies = prerequisites.requiredDependencies; // A full adapter, explicit submission command, or bootstrapped in-tree free // seedance engine (ADR 0006) owns submission prerequisites, so the native // submission transport's required env vars // (e.g. SUTUI_API_KEY) must NOT gate availability. Before this waiver, a // fully-configured free engine still read `unavailable` and every i2v plan // blocked with "No available provider route supports image-to-video" until // the operator exported a placeholder key (the Last Call workaround). const engine = route.id === 'seedance-direct' ? engineVerdictFor(env) : { ready: false }; const freeSeedanceEngine = route.id === 'seedance-direct' && engine.ready; const executionOverride = hasRouteExecutionOverride(route.id, env) || freeSeedanceEngine; // Which transport owns the NEXT submission, derived from the adapter layer's // own precedence. Without it the route just read `available` while its notes // still described the paid xskill.ai API and listed SUTUI_API_KEY as missing, // and an operator had no way to tell which transport would actually run — // or that the route had just become free. const activeTransport = resolveActiveTransport(route.id, env, engineReady); const dependencyOverride = executionOverride || ignoreRuntimeDependencyIssues; const presentDependencies = dependencyOverride ? [...requiredDependencies] : requiredDependencies.filter((name) => availableDependencies.includes(name)); const missingDependencies = dependencyOverride ? [] : requiredDependencies.filter((name) => !presentDependencies.includes(name)); const issues: string[] = []; const notes = [...(route.notes ?? [])]; // The sidecar directory the hint names is the one the ISSUE text already // resolved — recomputing it here is exactly the drift that would send an // operator to a directory the runtime never looks at. let veoSidecarRoot: string | null = null; if (route.id === 'veo-useapi' && !executionOverride && !ignoreRuntimeDependencyIssues) { const veoRuntime = resolveVeoRuntime({ env, workspaceRoot }); const veoIssues = (options.probeVeoRuntime ?? veoRuntimeIssues)(veoRuntime); issues.push(...veoIssues); if (veoIssues.length > 0) veoSidecarRoot = veoRuntime.cliRoot; notes.push('Availability checks local configuration only; provider authentication and live rendering are not verified.'); } // Missing built-in-transport env vars only matter when the built-in // transport would actually run; under an execution override the adapter // owns auth. They stay listed in `missingEnvVars` for visibility either way. if (missingEnvVars.length > 0 && !executionOverride) { issues.push(`Missing environment variables: ${missingEnvVars.join(', ')}`); } if (missingDependencies.length > 0) { issues.push(`Missing runtime dependencies: ${missingDependencies.join(', ')}`); } if (freeSeedanceEngine) { // FIRST, not appended: the route's own static notes describe the paid // xskill.ai API, and a correction that trails them is read second. notes.unshift('Renders through the free in-tree Higgsfield/cloak browser engine (engines/seedance-direct, $0 per clip); the paid xskill.ai API and its SUTUI_API_KEY are not used on this path. Set VCLAW_SEEDANCE_DIRECT_NATIVE=1 to pin the paid native transport instead.'); } else if (executionOverride) { notes.push('Execution override configured; adapter or submit command owns submission checks. Poll/cancel overrides remain action-specific.'); } else if (ignoreRuntimeDependencyIssues) { notes.push('Runtime dependency probes ignored for this explicit execution environment.'); } // A machine holding SUTUI_API_KEY has no ISSUES when the free engine goes // dark — the route is genuinely usable, just no longer free — so setupHint // stays null and nobody would learn the free path had been lost. Say it // here instead. Not fired under an explicit NATIVE pin: that is a deliberate // request for the paid API, not the free path breaking. if ( route.id === 'seedance-direct' && activeTransport === 'native-seedance' && !engine.ready && !env.VCLAW_SEEDANCE_DIRECT_NATIVE ) { notes.push(`Seedance 2.0 is NOT running free through your Higgsfield account: ${engine.reason}. Every render here bills through the paid API (SUTUI_API_KEY) until that is fixed.`); } let availability: VideoProviderRouteStatusReport['availability'] = 'available'; if (issues.length > 0) { availability = 'unavailable'; } else if (prerequisites.maturity === 'scaffold') { availability = 'degraded'; notes.push('Scaffold path only; keep out of default production routing.'); } return { routeId: route.id, provider: route.provider, activeTransport, displayName: route.displayName, path: route.path, availability, maturity: prerequisites.maturity, summary: route.summary, supportedOperations: Array.from( new Set(route.operationSupport.map((support) => support.operation)), ), requiredEnvVars, availableEnvVars, // The free engine never reads SUTUI_API_KEY, so reporting it missing sent // operators hunting a key the active transport does not want. missingEnvVars: freeSeedanceEngine ? [] : missingEnvVars, requiredDependencies, availableDependencies: presentDependencies, missingDependencies, issues, notes, setupHint: buildSetupHint({ routeId: route.id, issues, engine, veoSidecarRoot, missingEnvVars, missingDependencies, }), }; }); return { generatedAt: now.toISOString(), workspace: { root: workspaceRoot, ok: workspaceOk, issues: workspaceIssues, }, envSources, runtimeDependencies, routes, }; }