7bd4ab0a6e
A. acr init --local: new local mode, no Cloudflare account required;
config defaults to mode:local when ~/.arcrun/config.yaml missing
B. validate node-count bug: removed faulty input/output node heuristic
that dropped start/end nodes from config check; now all nodes except
reserved 'input' keyword must have config entries
C. acr validate --offline: skip remote component-existence and credentials
checks; local mode also auto-skips these checks
D. parts.ts: replace require('node:fs') with static import (ES module fix)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
150 lines
5.4 KiB
TypeScript
150 lines
5.4 KiB
TypeScript
/**
|
||
* acr validate <workflow.yaml>
|
||
* 在執行前驗證 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<void> {
|
||
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<string, string> = { '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, Record<string, unknown>>): string[] {
|
||
const refs = new Set<string>();
|
||
const jsonStr = JSON.stringify(config);
|
||
const matches = jsonStr.matchAll(/\{\{creds\.([^}]+)\}\}/g);
|
||
for (const m of matches) {
|
||
refs.add(m[1]);
|
||
}
|
||
return [...refs];
|
||
}
|