/** * acr validate * 在執行前驗證 YAML 格式、關係詞合法性、零件是否存在、credentials 是否已上傳 */ import chalk from 'chalk'; import { loadConfig, getCypherExecutorUrl } from '../lib/config.js'; import { loadWorkflowYaml, parseTriplets, validateRelations, getNodeNames } from '../lib/yaml-parser.js'; import { CfKvClient } from '../lib/cf-api.js'; export async function cmdValidate(filePath: string, options: { offline?: boolean } = {}): Promise { const config = loadConfig(); let allPassed = true; const check = (label: string, ok: boolean, detail?: string) => { const icon = ok ? chalk.green('✓') : chalk.red('✗'); console.log(` ${icon} ${label}${detail ? ` ${chalk.gray(detail)}` : ''}`); if (!ok) allPassed = false; }; console.log(chalk.bold(`\n 驗證 ${filePath}\n`)); // 1. YAML 格式 let workflow; try { workflow = loadWorkflowYaml(filePath); check('YAML 格式正確', true, `name=${workflow.name}`); } catch (e) { check('YAML 格式', false, e instanceof Error ? e.message : String(e)); process.exit(1); } // 2. 三元組解析 let triplets; try { triplets = parseTriplets(workflow.flow); check('三元組解析', true, `${triplets.length} 條`); } catch (e) { check('三元組解析', false, e instanceof Error ? e.message : String(e)); process.exit(1); } // 3. 關係詞驗證(不允許 PIPE) try { validateRelations(triplets); check('關係詞合法性', true); } catch (e) { check('關係詞合法性', false, e instanceof Error ? e.message : String(e)); allPassed = false; } // 4. 所有節點在 config 中有對應 // 規則:除了名稱為 "input" 的保留字節點,其餘所有節點都必須有 config // 注意:不能用「只出現在 subject 或只出現在 object」來判斷是否需要 config, // 因為像 a >> b >> c 中 a、b、c 都可能需要 config const nodeNames = getNodeNames(triplets); const RESERVED_NODES = new Set(['input']); // 保留字節點不需要 config const componentNodes = nodeNames.filter(n => !RESERVED_NODES.has(n)); const missingConfigs = componentNodes.filter(n => !(workflow.config ?? {})[n]); if (missingConfigs.length > 0) { check('config 完整性', false, `缺少 config:${missingConfigs.join(', ')}`); allPassed = false; } else { check('config 完整性', true, `${componentNodes.length} 個節點均有 config`); } // 5. 零件存在性(--offline 時跳過) if (options.offline || config.mode === 'local') { check('零件存在性', true, '離線模式,跳過遠端檢查'); } else { const executorUrl = getCypherExecutorUrl(config); try { const headers: Record = { 'Content-Type': 'application/json' }; if (config.api_key) headers['X-Arcrun-API-Key'] = config.api_key; const res = await fetch(`${executorUrl}/cypher/search`, { method: 'POST', headers, body: JSON.stringify({ triplets: workflow.flow }), }); if (res.ok) { const data = await res.json() as { missing: string[] }; if (data.missing.length > 0) { check('零件存在性', false, `WASM_BUCKET 中找不到:${data.missing.join(', ')}`); allPassed = false; } else { check('零件存在性', true, '所有零件均已在 WASM_BUCKET'); } } else { check('零件存在性', false, `無法連線 ${executorUrl}(加 --offline 跳過此檢查)`); } } catch { check('零件存在性', false, `無法連線 ${executorUrl}(加 --offline 跳過此檢查)`); } } // 6. Credentials 上傳檢查(--offline 或 local 模式時跳過) if (options.offline || config.mode === 'local') { // 離線模式略過 } else if (config.cloudflare_account_id && config.cf_api_token) { const namespaceId = config.mode === 'standard' ? config.user_kv_namespace_id : config.credentials_kv_namespace_id; if (namespaceId) { const kv = new CfKvClient({ accountId: config.cloudflare_account_id, namespaceId, apiToken: config.cf_api_token, }); try { const kvKeys = await kv.list('cred:'); const uploadedCreds = new Set(kvKeys.map(k => k.name.replace('cred:', ''))); const usedCreds = extractCredentialRefs(workflow.config ?? {}); const missingCreds = usedCreds.filter(k => !uploadedCreds.has(k)); if (missingCreds.length > 0) { check('Credentials 上傳', false, `未上傳:${missingCreds.join(', ')}(執行 acr creds push)`); allPassed = false; } else { check('Credentials 上傳', true, `已上傳 ${uploadedCreds.size} 個 credential`); } } catch { check('Credentials 上傳', false, 'KV 查詢失敗,跳過驗證'); } } } console.log(''); if (allPassed) { console.log(chalk.green.bold(' ✓ 驗證通過\n')); } else { console.log(chalk.red.bold(' ✗ 驗證未通過,請修正上方錯誤\n')); process.exit(1); } } /** 從 workflow config 中提取可能的 credential key 引用(模板 {{creds.xxx}})*/ function extractCredentialRefs(config: Record>): string[] { const refs = new Set(); const jsonStr = JSON.stringify(config); const matches = jsonStr.matchAll(/\{\{creds\.([^}]+)\}\}/g); for (const m of matches) { refs.add(m[1]); } return [...refs]; }