#!/usr/bin/env node import { spawn, spawnSync } from 'node:child_process'; import http from 'node:http'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { pathToFileURL as pathToFileUrl } from 'node:url'; const ROOT = process.cwd(); const PORT = Number(process.env.TRUEGROWTH_LOCAL_API_PORT || 48177); const BASE_URL = `http://127.0.0.1:${PORT}`; const MOCK_COMFYUI_PORT = Number( process.env.TRUEGROWTH_MOCK_COMFYUI_PORT || 58188 ); const MOCK_COMFYUI_BASE_URL = `http://127.0.0.1:${MOCK_COMFYUI_PORT}`; const TIMEOUT_MS = Number(process.env.TRUEGROWTH_VERIFY_TIMEOUT_MS || 90_000); const FLUX_EXPECTED_BYTES = 17_236_328_572; const QWEN_EXPECTED_BYTES = 57_954_375_058; const COMFYUI_DIR = path.resolve( process.env.TRUEGROWTH_COMFYUI_DIR || path.join(ROOT, 'vendor', 'ComfyUI') ); const FLUX_FILE = path.join( COMFYUI_DIR, 'models', 'checkpoints', 'flux1-schnell-fp8.safetensors' ); const QWEN_DIR = path.join(COMFYUI_DIR, 'models', 'diffusers', 'Qwen-Image'); const STATIC_FILES = [ 'scripts/truegrowth-local-api.mjs', 'apps/web/src/workbench/WorkbenchShell.tsx', 'apps/web/src/workbench/local-runtime-client.ts', 'packages/drawnix/src/utils/runtime-model-discovery.ts', 'packages/drawnix/src/services/media-executor/fallback-executor.ts', ]; const TINY_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', 'base64' ); function createMockComfyUiDir() { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'truegrowth-mock-comfyui-')); fs.mkdirSync(path.join(root, 'models', 'checkpoints'), { recursive: true }); fs.mkdirSync(path.join(root, 'models', 'diffusers', 'Qwen-Image'), { recursive: true, }); fs.mkdirSync(path.join(root, 'models', 'diffusers', 'Qwen-Image', 'transformer'), { recursive: true, }); fs.mkdirSync(path.join(root, 'models', 'diffusers', 'Qwen-Image', 'text_encoder'), { recursive: true, }); fs.mkdirSync(path.join(root, 'models', 'diffusers', 'Qwen-Image', 'vae'), { recursive: true, }); fs.writeFileSync(path.join(root, 'main.py'), '# mock comfyui\n'); fs.writeFileSync(path.join(root, 'requirements.txt'), '# mock requirements\n'); fs.writeFileSync( path.join(root, 'models', 'checkpoints', 'flux1-schnell-fp8.safetensors'), 'mock flux' ); fs.writeFileSync( path.join(root, 'models', 'diffusers', 'Qwen-Image', 'model_index.json'), '{}\n' ); fs.writeFileSync( path.join( root, 'models', 'diffusers', 'Qwen-Image', 'transformer', 'diffusion_pytorch_model.safetensors.index.json' ), '{}\n' ); fs.writeFileSync( path.join( root, 'models', 'diffusers', 'Qwen-Image', 'text_encoder', 'model.safetensors.index.json' ), '{}\n' ); fs.writeFileSync( path.join( root, 'models', 'diffusers', 'Qwen-Image', 'vae', 'diffusion_pytorch_model.safetensors' ), 'mock vae' ); return root; } function assert(condition, message) { if (!condition) { throw new Error(message); } } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function safeJson(text) { try { return JSON.parse(text); } catch { return null; } } async function requestJson(url, options = {}) { const response = await fetch(url, options); const text = await response.text(); const json = safeJson(text); if (!response.ok) { throw new Error( `${options.method || 'GET'} ${url} failed ${response.status}: ${text}` ); } return json ?? text; } async function apiIsReady() { try { await requestJson(`${BASE_URL}/local-api/health`); return true; } catch { return false; } } async function waitForApi() { const startedAt = Date.now(); while (Date.now() - startedAt < TIMEOUT_MS) { if (await apiIsReady()) { return; } await delay(300); } throw new Error('Local API did not become ready in time.'); } function startMockComfyUiServer() { const requests = { prompts: [], views: [], }; const server = http.createServer(async (request, response) => { const url = new URL(request.url || '/', MOCK_COMFYUI_BASE_URL); const sendJson = (status, payload) => { response.writeHead(status, { 'content-type': 'application/json' }); response.end(JSON.stringify(payload)); }; if (request.method === 'GET' && url.pathname === '/system_stats') { sendJson(200, { system: { os: 'mock', python_version: '3.11' }, devices: [{ name: 'Mock GPU', type: 'cuda', vram_total: 24_000 }], }); return; } if (request.method === 'GET' && url.pathname === '/object_info') { sendJson(200, { CheckpointLoaderSimple: { input: { required: { ckpt_name: [ ['flux1-schnell-fp8.safetensors', 'sd_xl_base_1.0.safetensors'], ], }, }, }, CLIPTextEncode: { input: { required: {} } }, EmptySD3LatentImage: { input: { required: {} } }, KSampler: { input: { required: {} } }, VAEDecode: { input: { required: {} } }, SaveImage: { input: { required: {} } }, TrueGrowthQwenImageGenerate: { input: { required: { model_name: [['Qwen-Image']] } }, }, }); return; } if (request.method === 'POST' && url.pathname === '/prompt') { const chunks = []; for await (const chunk of request) chunks.push(chunk); const bodyText = Buffer.concat(chunks).toString('utf8'); const body = safeJson(bodyText) || {}; requests.prompts.push(body.prompt || body); sendJson(200, { prompt_id: `mock-prompt-${requests.prompts.length}` }); return; } if (request.method === 'GET' && url.pathname.startsWith('/history/')) { const promptId = decodeURIComponent(url.pathname.split('/').pop() || ''); sendJson(200, { [promptId]: { outputs: { 9: { images: [ { filename: 'truegrowth_mock_00001_.png', subfolder: '', type: 'output', }, ], }, }, }, }); return; } if (request.method === 'GET' && url.pathname === '/view') { requests.views.push(Object.fromEntries(url.searchParams.entries())); response.writeHead(200, { 'content-type': 'image/png' }); response.end(TINY_PNG); return; } sendJson(404, { error: 'not_found', path: url.pathname }); }); return new Promise((resolve, reject) => { server.once('error', reject); server.listen(MOCK_COMFYUI_PORT, '127.0.0.1', () => { server.off('error', reject); resolve({ server, requests }); }); }); } async function waitForTaskCompleted(taskId) { const startedAt = Date.now(); let lastTask = null; while (Date.now() - startedAt < TIMEOUT_MS) { const payload = await requestJson(`${BASE_URL}/local-api/tasks/${taskId}`); const task = payload.task || payload.item || payload; lastTask = task; if (task.status === 'completed') return task; if (task.status === 'failed') { throw new Error(`Task failed: ${task.error || 'unknown error'}`); } await delay(300); } throw new Error( `Task ${taskId} did not complete in time. Last state: ${JSON.stringify( lastTask )}` ); } function fileSizeLabel(filePath) { try { const bytes = fs.statSync(filePath).size; if (bytes > 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`; if (bytes > 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(2)} MB`; return `${bytes} B`; } catch { return null; } } function qwenSizeLabel() { if (!fs.existsSync(QWEN_DIR)) return null; let bytes = 0; const stack = [QWEN_DIR]; while (stack.length) { const current = stack.pop(); for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const fullPath = path.join(current, entry.name); if (entry.isDirectory()) { stack.push(fullPath); } else if (entry.isFile()) { bytes += fs.statSync(fullPath).size; } } } return bytes > 1024 ** 3 ? `${(bytes / 1024 ** 3).toFixed(2)} GB` : `${(bytes / 1024 ** 2).toFixed(2)} MB`; } function bytesLabel(bytes) { if (!Number.isFinite(Number(bytes))) return null; return `${(Number(bytes) / 1024 ** 3).toFixed(2)} GB`; } function diskAvailableBytes(targetPath) { try { const existingPath = fs.existsSync(targetPath) ? targetPath : path.dirname(targetPath); const result = spawnSync('df', ['-k', existingPath], { encoding: 'utf8' }); if (result.error) throw result.error; const output = result.stdout || ''; const line = output.trim().split(/\r?\n/).pop() || ''; const parts = line.trim().split(/\s+/); const availableKb = Number(parts[3] || 0); return Number.isFinite(availableKb) ? availableKb * 1024 : null; } catch { return null; } } function runStaticCheck() { const contents = Object.fromEntries( STATIC_FILES.map((file) => [ file, fs.readFileSync(path.join(ROOT, file), 'utf8'), ]) ); const api = contents['scripts/truegrowth-local-api.mjs']; const discovery = contents['packages/drawnix/src/utils/runtime-model-discovery.ts']; const executor = contents[ 'packages/drawnix/src/services/media-executor/fallback-executor.ts' ]; const workbench = contents['apps/web/src/workbench/WorkbenchShell.tsx']; const client = contents['apps/web/src/workbench/local-runtime-client.ts']; assert( api.includes( 'https://huggingface.co/Comfy-Org/flux1-schnell/resolve/main/flux1-schnell-fp8.safetensors' ), 'FLUX official download URL is missing from local API.' ); assert( api.includes( 'git clone --depth 1 https://github.com/comfyanonymous/ComfyUI.git vendor/ComfyUI' ) && api.includes("'--depth'") && api.includes("'1'"), 'ComfyUI automatic install should use a shallow official GitHub clone.' ); assert( api.includes('function pythonVersionInfo') && api.includes('function resolveComfyUiPythonCommand') && api.includes("path.join(os.homedir(), 'miniconda3', 'bin', 'python3.12')") && api.includes('Python 3.10+') && api.includes('pythonVersion.ok'), 'ComfyUI setup should auto-select and require Python 3.10+ before automatic install.' ); assert( api.includes("repo: 'Qwen/Qwen-Image'"), 'Qwen-Image official repo is missing from local API.' ); assert( api.includes('function getTrueGrowthComfyUiCustomNodeDir') && api.includes('class TrueGrowthQwenImageGenerate') && api.includes('"TrueGrowthQwenImageGenerate": TrueGrowthQwenImageGenerate') && api.includes('huggingface_hub>=0.24.0'), 'TrueGrowth Qwen-Image ComfyUI custom node is missing.' ); assert( api.includes("templateId: 'flux-fp8-checkpoint-text-to-image'"), 'FLUX FP8 template is missing from local API.' ); assert( api.includes("id: 'qwen-image-text-to-image'") && api.includes('function buildQwenImageComfyUiWorkflow'), 'Qwen-Image ComfyUI template or workflow builder is missing.' ); assert( api.includes('function buildFluxFp8CheckpointComfyUiWorkflow'), 'FLUX FP8 ComfyUI workflow builder is missing.' ); assert( api.includes('minFreeBytes: 18 * 1024 * 1024 * 1024') && api.includes('minFreeBytes: 70 * 1024 * 1024 * 1024'), 'Model download disk preflight thresholds are missing.' ); assert( api.includes('expectedBytes: 17_236_328_572') && api.includes('expectedBytes: 57_954_375_058') && api.includes('python3 -m huggingface_hub download Qwen/Qwen-Image'), 'Official FLUX/Qwen download sizes or manual commands are missing.' ); assert( api.includes("'-m',") && api.includes("'huggingface_hub'") && api.includes("'download'") && api.includes("downloadSpec.repo"), 'Qwen-Image snapshot download does not fall back to python -m huggingface_hub.' ); assert( api.includes('function comfyUiModelDownloadPreflight') && api.includes('downloadPreflight:'), 'Model Center assets do not expose ComfyUI download preflight state.' ); assert( api.includes('function getComfyUiVendorDir') && api.includes("pathname === '/local-api/comfyui/setup'") && api.includes('missing_vendor_path') && api.includes('invalid_comfyui_dir'), 'Local API does not let Model Center register an existing ComfyUI directory.' ); assert( api.includes('function installComfyUiImageAssetFromPath') && api.includes('function linkOrCopyComfyUiModelSource') && api.includes('function validateAigcPanelModelAssetLocalPath') && api.includes('function isCompleteQwenImageDiffusersModel') && api.includes("'model_index.json'") && api.includes("path.join('transformer', 'diffusion_pytorch_model.safetensors.index.json')") && api.includes('validate-local') && api.includes("asset.runtimeKind === 'comfyui-image'"), 'Local API does not install existing FLUX/Qwen files into ComfyUI model directories.' ); assert( api.includes('function huggingFaceCacheStatus') && api.includes('cacheStatus,'), 'ComfyUI model preflight does not report Hugging Face cache status.' ); assert( discovery.includes('flux-fp8-checkpoint-text-to-image'), 'FLUX FP8 model is missing from runtime model discovery.' ); assert( discovery.includes('qwen-image-text-to-image') && discovery.includes('Qwen-Image 本地生图'), 'Qwen-Image model is missing from runtime model discovery.' ); assert( !discovery.includes('...LOCAL_COMFYUI_MODEL_IDS'), 'Local ComfyUI templates should not be selected by default before model files are applied.' ); assert( executor.includes("templateId: 'flux-fp8-checkpoint-text-to-image'"), 'Media executor does not route FLUX FP8 selections to the FLUX FP8 template.' ); assert( executor.includes("templateId: 'qwen-image-text-to-image'"), 'Media executor does not route Qwen-Image selections to the Qwen template.' ); assert( workbench.includes('图片模型本地供应商 · ComfyUI') && workbench.includes('文件约 17.2GB'), 'Model Center ComfyUI guide or FLUX size copy is missing.' ); assert( workbench.includes('在模型中心一键安装 ComfyUI') && workbench.includes('没有安装 ComfyUI 时,从这里开始') && workbench.includes('一键安装 ComfyUI'), 'Model Center does not clearly guide first-time ComfyUI installation.' ); assert( workbench.includes('Python 3.10+'), 'Model Center does not explain the Python 3.10+ ComfyUI requirement.' ); assert( workbench.includes('downloadPreflight') && workbench.includes('空间不足'), 'Model Center does not surface ComfyUI download blockers.' ); assert( workbench.includes('检测到未完成缓存') && workbench.includes('downloadPreflight.cacheStatus'), 'Model Center does not surface partial Hugging Face cache state.' ); assert( workbench.includes('官方大小') && workbench.includes('tg-comfy-download-guide') && workbench.includes('python3 -m huggingface_hub download Qwen/Qwen-Image'), 'Model Center does not show official model sizes and manual ComfyUI download commands.' ); assert( workbench.includes('已有 ComfyUI 目录') && workbench.includes('configureComfyUiRuntime') && workbench.includes('登记目录'), 'Model Center does not expose existing ComfyUI directory registration.' ); assert( workbench.includes('FLUX safetensors 文件或 Qwen-Image 目录') && workbench.includes('.safetensors') && workbench.includes('检查路径') && workbench.includes('ComfyUI 接入预览'), 'Model Center import guide does not explain existing local image model import.' ); assert( client.includes('minFreeBytes?: number') && client.includes('cacheStatus?:'), 'Frontend client type does not expose model disk/cache requirement.' ); const availableBytes = diskAvailableBytes(COMFYUI_DIR); console.log( JSON.stringify( { ok: true, mode: 'static', officialDownloads: { flux: { repo: 'https://huggingface.co/Comfy-Org/flux1-schnell', file: 'https://huggingface.co/Comfy-Org/flux1-schnell/resolve/main/flux1-schnell-fp8.safetensors', expectedSize: bytesLabel(FLUX_EXPECTED_BYTES), templateId: 'flux-fp8-checkpoint-text-to-image', }, qwenImage: { repo: 'https://huggingface.co/Qwen/Qwen-Image', expectedRepoSize: bytesLabel(QWEN_EXPECTED_BYTES), target: 'models/diffusers/Qwen-Image', templateId: 'qwen-image-text-to-image', customNode: 'TrueGrowthQwenImageGenerate', runnable: true, }, }, disk: { comfyUiDir: COMFYUI_DIR, available: bytesLabel(availableBytes), canDownloadFlux: Boolean( availableBytes && availableBytes >= 18 * 1024 ** 3 ), canDownloadQwenImage: Boolean( availableBytes && availableBytes >= 70 * 1024 ** 3 ), }, }, null, 2 ) ); } async function runWorkflowCheck() { const { assertComfyUiWorkflowDimensionsForVerification, createComfyUiWorkflowForVerification, getComfyUiImageSizeForVerification, } = await import( pathToFileUrl(path.join(ROOT, 'scripts', 'truegrowth-local-api.mjs')).href ); const dimensionCases = [ { size: '16:9', expected: { width: 1824, height: 1024 } }, { size: '16x9', expected: { width: 1824, height: 1024 } }, { size: '9:16', expected: { width: 1024, height: 1824 } }, { size: '1x1', expected: { width: 1024, height: 1024 } }, { size: '1536x1024', expected: { width: 1536, height: 1024 } }, ]; for (const item of dimensionCases) { const actual = getComfyUiImageSizeForVerification(item.size, '1024x1024'); assert( actual.width === item.expected.width && actual.height === item.expected.height, `ComfyUI size ${item.size} resolved to ${actual.width}x${actual.height}, expected ${item.expected.width}x${item.expected.height}.` ); } const fluxWorkflow = createComfyUiWorkflowForVerification({ templateId: 'flux-fp8-checkpoint-text-to-image', modelId: 'flux1-schnell-fp8.safetensors', prompt: 'orange product poster', size: '1024x1024', steps: 4, cfg: 1, seed: 42, }); const qwenWorkflow = createComfyUiWorkflowForVerification({ templateId: 'qwen-image-text-to-image', modelId: 'Qwen-Image', prompt: '中文海报,标题为增长引擎', negativePrompt: '低清晰度', size: '1024x1024', steps: 20, cfg: 4, seed: 42, }); assertComfyUiWorkflowDimensionsForVerification( { templateId: 'flux-fp8-checkpoint-text-to-image', modelId: 'flux1-schnell-fp8.safetensors', prompt: 'orange product poster', size: '16x9', steps: 4, cfg: 1, seed: 42, }, { width: 1824, height: 1024 } ); assertComfyUiWorkflowDimensionsForVerification( { templateId: 'qwen-image-text-to-image', modelId: 'Qwen-Image', prompt: '中文竖版海报', aspectRatio: '9:16', steps: 20, cfg: 4, seed: 42, }, { width: 1024, height: 1824 } ); assertComfyUiWorkflowDimensionsForVerification( { templateId: 'qwen-image-text-to-image', modelId: 'Qwen-Image', prompt: '固定尺寸海报', width: 1280, height: 720, steps: 20, cfg: 4, seed: 42, }, { width: 1280, height: 720 } ); assert( Object.values(fluxWorkflow).some( (node) => node.class_type === 'CheckpointLoaderSimple' && node.inputs?.ckpt_name === 'flux1-schnell-fp8.safetensors' ), 'FLUX workflow does not load the FP8 checkpoint.' ); assert( Object.values(fluxWorkflow).some((node) => node.class_type === 'KSampler'), 'FLUX workflow is missing KSampler.' ); assert( Object.values(qwenWorkflow).some( (node) => node.class_type === 'TrueGrowthQwenImageGenerate' && node.inputs?.model_name === 'Qwen-Image' && node.inputs?.negative_prompt === '低清晰度' ), 'Qwen workflow does not use the TrueGrowth Qwen-Image node.' ); assert( Object.values(qwenWorkflow).some( (node) => node.class_type === 'SaveImage' && String(node.inputs?.filename_prefix || '').includes('qwen_image') ), 'Qwen workflow does not save generated image output.' ); console.log( JSON.stringify( { ok: true, mode: 'workflow', dimensionCases, fluxNodes: Object.values(fluxWorkflow).map((node) => node.class_type), qwenNodes: Object.values(qwenWorkflow).map((node) => node.class_type), }, null, 2 ) ); } async function main() { if (process.argv.includes('--static')) { runStaticCheck(); return; } if (process.argv.includes('--workflow')) { await runWorkflowCheck(); return; } if (process.argv.includes('--mock-comfyui')) { let mock = null; let child = null; let stderr = ''; let stdout = ''; let childExit = null; let mockComfyUiDir = null; try { mockComfyUiDir = createMockComfyUiDir(); mock = await startMockComfyUiServer(); child = spawn(process.execPath, ['scripts/truegrowth-local-api.mjs'], { cwd: ROOT, env: { ...process.env, TRUEGROWTH_LOCAL_API_PORT: String(PORT), TRUEGROWTH_COMFYUI_BASE_URL: MOCK_COMFYUI_BASE_URL, TRUEGROWTH_COMFYUI_DIR: mockComfyUiDir, TRUEGROWTH_COMFYUI_HISTORY_ATTEMPTS: '10', }, stdio: ['ignore', 'pipe', 'pipe'], }); child.stdout.on('data', (chunk) => { stdout += String(chunk); }); child.stderr.on('data', (chunk) => { stderr += String(chunk); }); child.on('exit', (code, signal) => { childExit = { code, signal }; }); try { await waitForApi(); } catch (error) { if (/listen EPERM|EACCES|operation not permitted/i.test(stderr)) { throw new Error( `Local API could not listen on ${BASE_URL}. Current environment blocked localhost binding. Raw error: ${stderr.trim()}` ); } throw new Error( `Local API did not become ready in time. exit: ${ childExit ? JSON.stringify(childExit) : 'still running' } stdout: ${stdout.trim()} stderr: ${stderr.trim()}` ); } const status = await requestJson(`${BASE_URL}/local-api/comfyui/status`); const statusConnection = status.connection || status; const statusTemplates = statusConnection.templates || status.templates || []; assert( statusConnection.connected, `Mock ComfyUI status should be connected. Payload: ${JSON.stringify( status )}` ); assert( statusTemplates.some( (item) => item.id === 'flux-fp8-checkpoint-text-to-image' && item.available ), 'FLUX FP8 template should be available against mock ComfyUI.' ); assert( statusTemplates.some( (item) => item.id === 'qwen-image-text-to-image' && item.available ), 'Qwen-Image template should be available against mock ComfyUI.' ); const generated = []; for (const input of [ { label: 'flux', templateId: 'flux-fp8-checkpoint-text-to-image', modelId: 'flux1-schnell-fp8.safetensors', prompt: 'a clean orange product poster', }, { label: 'qwen', templateId: 'qwen-image-text-to-image', modelId: 'Qwen-Image', prompt: '中文海报,标题为增长引擎', }, ]) { const submit = await requestJson( `${BASE_URL}/local-api/comfyui/generate-image`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title: `Mock ${input.label}`, payload: { prompt: input.prompt, templateId: input.templateId, modelId: input.modelId, size: '1024x1024', steps: input.label === 'flux' ? 4 : 20, cfg: input.label === 'flux' ? 1 : 4, seed: 42, }, }), } ); assert(submit.task?.id, `${input.label} task was not created.`); const task = await waitForTaskCompleted(submit.task.id); assert( Array.isArray(task.assetIds) && task.assetIds.length > 0, `${input.label} task did not create an asset.` ); generated.push({ label: input.label, taskId: task.id, assetIds: task.assetIds, }); } const assets = await requestJson(`${BASE_URL}/local-api/assets`); for (const item of generated) { const assetId = item.assetIds[0]; const asset = assets.items?.find((candidate) => candidate.id === assetId); assert(asset, `${item.label} generated asset is missing from library.`); assert( asset.type === 'image', `${item.label} generated asset should be an image.` ); } assert( mock.requests.prompts.some((workflow) => Object.values(workflow || {}).some( (node) => node?.class_type === 'TrueGrowthQwenImageGenerate' ) ), 'Mock ComfyUI did not receive the Qwen custom node workflow.' ); assert( mock.requests.prompts.some((workflow) => Object.values(workflow || {}).some( (node) => node?.class_type === 'CheckpointLoaderSimple' ) ), 'Mock ComfyUI did not receive the FLUX checkpoint workflow.' ); console.log( JSON.stringify( { ok: true, mode: 'mock-comfyui', baseUrl: BASE_URL, comfyUiBaseUrl: MOCK_COMFYUI_BASE_URL, generated, promptCount: mock.requests.prompts.length, viewCount: mock.requests.views.length, }, null, 2 ) ); } finally { if (child) child.kill('SIGTERM'); if (mock) await new Promise((resolve) => mock.server.close(resolve)); if (mockComfyUiDir) { fs.rmSync(mockComfyUiDir, { recursive: true, force: true }); } } return; } let child = null; let stderr = ''; try { if (!(await apiIsReady())) { child = spawn(process.execPath, ['scripts/truegrowth-local-api.mjs'], { cwd: ROOT, env: { ...process.env, TRUEGROWTH_LOCAL_API_PORT: String(PORT) }, stdio: ['ignore', 'pipe', 'pipe'], }); child.stderr.on('data', (chunk) => { stderr += String(chunk); }); } try { await waitForApi(); } catch (error) { if (/listen EPERM|EACCES|operation not permitted/i.test(stderr)) { throw new Error( `Local API could not listen on ${BASE_URL}. Current environment blocked localhost binding. Raw error: ${stderr.trim()}` ); } throw error; } const setup = await requestJson(`${BASE_URL}/local-api/comfyui/setup`); const assets = await requestJson( `${BASE_URL}/local-api/aigcpanel/model-assets` ); const templates = await requestJson( `${BASE_URL}/local-api/comfyui/templates` ); const comfyModels = await requestJson( `${BASE_URL}/local-api/comfyui/models` ); const imageAssets = Array.isArray(assets.items) ? assets.items.filter((item) => item.family === 'image') : []; const fluxAsset = imageAssets.find( (item) => item.id === 'flux-1-schnell-local' ); const qwenAsset = imageAssets.find( (item) => item.id === 'qwen-image-local' ); const installedImageModels = Array.isArray(assets.installedModels) ? assets.installedModels.filter((item) => item.family === 'image') : []; const fluxExists = fs.existsSync(FLUX_FILE); const qwenExists = fs.existsSync(QWEN_DIR); const fluxLoaded = Object.values(comfyModels.models || {}) .flat() .includes('flux1-schnell-fp8.safetensors'); const qwenLoaded = Object.values(comfyModels.models || {}) .flat() .includes('Qwen-Image'); assert( setup.setup?.vendorPath || setup.connection?.setup?.vendorPath, 'ComfyUI setup payload is missing.' ); assert(fluxAsset, 'FLUX model asset is missing from model center data.'); assert( qwenAsset, 'Qwen-Image model asset is missing from model center data.' ); assert( fluxAsset.comfyUiDownload?.url?.includes('Comfy-Org/flux1-schnell'), 'FLUX download URL is not wired to the ComfyUI official checkpoint.' ); assert( fluxAsset.comfyUiDownload?.templateId === 'flux-fp8-checkpoint-text-to-image', 'FLUX FP8 checkpoint is not wired to the dedicated ComfyUI workflow template.' ); assert( fluxAsset.comfyUiDownload?.minFreeBytes >= 18 * 1024 ** 3, 'FLUX download preflight does not require enough free disk space.' ); assert( qwenAsset.comfyUiDownload?.repo === 'Qwen/Qwen-Image', 'Qwen-Image download spec is not wired to the official Hugging Face repo.' ); assert( qwenAsset.comfyUiDownload?.runnable === true, 'Qwen-Image should be marked runnable because TrueGrowth installs a ComfyUI Qwen-Image node and workflow template.' ); assert( qwenAsset.comfyUiDownload?.minFreeBytes >= 70 * 1024 ** 3, 'Qwen-Image download preflight does not require enough free disk space.' ); const availableBytes = diskAvailableBytes(COMFYUI_DIR); const result = { ok: true, localApi: BASE_URL, comfyUi: { installed: Boolean(setup.setup?.installed), running: Boolean(setup.connected), baseUrl: setup.setup?.baseUrl || setup.connection?.baseUrl, vendorPath: setup.setup?.vendorPath || setup.connection?.setup?.vendorPath, nodeCount: setup.connection?.nodeCount || 0, }, models: { flux: { expectedPath: FLUX_FILE, officialRepo: 'https://huggingface.co/Comfy-Org/flux1-schnell', officialDownload: 'https://huggingface.co/Comfy-Org/flux1-schnell/resolve/main/flux1-schnell-fp8.safetensors', expectedSize: bytesLabel(FLUX_EXPECTED_BYTES), requiredFreeSpace: bytesLabel( fluxAsset.comfyUiDownload?.minFreeBytes ), exists: fluxExists, size: fluxExists ? fileSizeLabel(FLUX_FILE) : null, loadedByComfyUi: fluxLoaded, assetInstalled: Boolean(fluxAsset.installed), serviceStatus: fluxAsset.installedModel?.service?.status || null, }, qwenImage: { expectedPath: QWEN_DIR, officialRepo: 'https://huggingface.co/Qwen/Qwen-Image', expectedRepoSize: bytesLabel(QWEN_EXPECTED_BYTES), requiredFreeSpace: bytesLabel( qwenAsset.comfyUiDownload?.minFreeBytes ), exists: qwenExists, size: qwenExists ? qwenSizeLabel() : null, loadedByComfyUi: qwenLoaded, assetInstalled: Boolean(qwenAsset.installed), serviceStatus: qwenAsset.installedModel?.service?.status || null, runnable: qwenAsset.comfyUiDownload?.runnable !== false, note: qwenAsset.comfyUiDownload?.note || null, }, }, disk: { comfyUiDir: COMFYUI_DIR, available: bytesLabel(availableBytes), canDownloadFlux: Boolean( availableBytes && availableBytes >= Number(fluxAsset.comfyUiDownload?.minFreeBytes || 0) ), canDownloadQwenImage: Boolean( availableBytes && availableBytes >= Number(qwenAsset.comfyUiDownload?.minFreeBytes || 0) ), }, modelCenter: { installedImageModelCount: installedImageModels.length, templates: Array.isArray(templates.items) ? templates.items.map((item) => ({ id: item.id, available: item.available, modelChoices: item.modelChoices || [], missingNodes: item.missingNodes || [], })) : [], }, nextSteps: [ fluxExists ? 'FLUX 文件已在 ComfyUI 目录,启动 ComfyUI 后刷新模型中心并应用到生图。' : availableBytes && availableBytes < Number(fluxAsset.comfyUiDownload?.minFreeBytes || 0) ? `当前磁盘可用约 ${bytesLabel( availableBytes )},不足以下载 FLUX FP8。请先清理到至少 ${bytesLabel( fluxAsset.comfyUiDownload?.minFreeBytes )}。` : '在模型中心下载 FLUX,或把 flux1-schnell-fp8.safetensors 放入 models/checkpoints。', qwenExists ? 'Qwen-Image 已在 diffusers 目录;生成前确认已安装 Qwen-Image 专用 ComfyUI 节点或工作流。' : availableBytes && availableBytes < Number(qwenAsset.comfyUiDownload?.minFreeBytes || 0) ? `当前磁盘可用约 ${bytesLabel( availableBytes )},不足以下载 Qwen-Image。请先清理到至少 ${bytesLabel( qwenAsset.comfyUiDownload?.minFreeBytes )}。` : '如需中文海报能力,在模型中心下载 Qwen-Image 到 models/diffusers/Qwen-Image。', ], }; console.log(JSON.stringify(result, null, 2)); if (stderr.trim()) { console.error(stderr.trim()); } } finally { if (child && !child.killed) { child.kill('SIGTERM'); } } } main().catch((error) => { console.error( error instanceof Error ? error.stack || error.message : String(error) ); process.exit(1); });