fix(cli): address Gemini test report — local mode, validate bug, offline flag

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>
This commit is contained in:
2026-04-16 14:53:30 +08:00
parent 8e2c32e466
commit 7bd4ab0a6e
6 changed files with 68 additions and 49 deletions
+35 -34
View File
@@ -7,7 +7,7 @@ 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): Promise<void> {
export async function cmdValidate(filePath: string, options: { offline?: boolean } = {}): Promise<void> {
const config = loadConfig();
let allPassed = true;
@@ -48,15 +48,13 @@ export async function cmdValidate(filePath: string): Promise<void> {
allPassed = false;
}
// 4. 所有節點在 config 中有對應Input/Output 節點除外)
// 4. 所有節點在 config 中有對應
// 規則:除了名稱為 "input" 的保留字節點,其餘所有節點都必須有 config
// 注意:不能用「只出現在 subject 或只出現在 object」來判斷是否需要 config
// 因為像 a >> b >> c 中 a、b、c 都可能需要 config
const nodeNames = getNodeNames(triplets);
const inputNodes = new Set(triplets.map(t => t.subject).filter(s =>
!triplets.some(t => t.object === s)
));
const outputNodes = new Set(triplets.map(t => t.object).filter(o =>
!triplets.some(t => t.subject === o)
));
const componentNodes = nodeNames.filter(n => !inputNodes.has(n) && !outputNodes.has(n));
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) {
@@ -66,35 +64,41 @@ export async function cmdValidate(filePath: string): Promise<void> {
check('config 完整性', true, `${componentNodes.length} 個節點均有 config`);
}
// 5. 零件存在於 WASM_BUCKET(透過 cypher/search 確認
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;
// 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 }),
});
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;
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('零件存在性', true, '所有零件均已在 WASM_BUCKET');
check('零件存在性', false, `無法連線 ${executorUrl}(加 --offline 跳過此檢查)`);
}
} else {
check('零件存在性', false, `無法連線 ${executorUrl},跳過驗證`);
} catch {
check('零件存在性', false, `無法連線 ${executorUrl}(加 --offline 跳過此檢查)`);
}
} catch {
check('零件存在性', false, `無法連線 ${executorUrl},跳過驗證`);
}
// 6. 所需 credentials 上傳至 CREDENTIALS_KV(僅在有 KV 設定時執行
if (config.cloudflare_account_id && config.cf_api_token) {
// 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;
@@ -106,12 +110,9 @@ export async function cmdValidate(filePath: string): Promise<void> {
apiToken: config.cf_api_token,
});
// 查詢已上傳的 credentials
try {
const kvKeys = await kv.list('cred:');
const uploadedCreds = new Set(kvKeys.map(k => k.name.replace('cred:', '')));
// 比對 workflow config 中引用的 credential key
const usedCreds = extractCredentialRefs(workflow.config ?? {});
const missingCreds = usedCreds.filter(k => !uploadedCreds.has(k));