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
+23 -4
View File
@@ -4,7 +4,7 @@
* 呼叫 arcrun.dev 取得 API Key,寫入 ~/.arcrun/config.yaml * 呼叫 arcrun.dev 取得 API Key,寫入 ~/.arcrun/config.yaml
*/ */
import { createInterface } from 'node:readline/promises'; import { createInterface } from 'node:readline/promises';
import { writeFileSync, existsSync } from 'node:fs'; import { writeFileSync, existsSync, readFileSync, appendFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import chalk from 'chalk'; import chalk from 'chalk';
import { saveConfig, type ArcrunConfig } from '../lib/config.js'; import { saveConfig, type ArcrunConfig } from '../lib/config.js';
@@ -16,13 +16,15 @@ async function prompt(rl: ReturnType<typeof createInterface>, question: string):
return answer.trim(); return answer.trim();
} }
export async function cmdInit(options: { selfHosted?: boolean }): Promise<void> { export async function cmdInit(options: { local?: boolean; selfHosted?: boolean }): Promise<void> {
const rl = createInterface({ input: process.stdin, output: process.stdout }); const rl = createInterface({ input: process.stdin, output: process.stdout });
console.log(chalk.bold('\n arcrun 初始化設定\n')); console.log(chalk.bold('\n arcrun 初始化設定\n'));
try { try {
if (options.selfHosted) { if (options.local) {
await initLocal();
} else if (options.selfHosted) {
await initSelfHosted(rl); await initSelfHosted(rl);
} else { } else {
await initStandard(rl); await initStandard(rl);
@@ -32,6 +34,24 @@ export async function cmdInit(options: { selfHosted?: boolean }): Promise<void>
} }
} }
async function initLocal(): Promise<void> {
console.log(chalk.gray(' Local 模式:不需要 Cloudflare 帳號,直接在本機跑 workflow\n'));
const config: ArcrunConfig = {
mode: 'local',
};
saveConfig(config);
createCredentialsYamlIfMissing();
console.log(chalk.green('\n ✓ 設定完成 → ~/.arcrun/config.yamllocal 模式)\n'));
console.log(' 你可以立刻開始:');
console.log(chalk.cyan(' acr validate workflow.yaml --offline') + ' # 驗證 workflow 格式');
console.log(chalk.cyan(' acr run <workflow_name>') + ' # 本機執行\n');
console.log(chalk.gray(' Local 模式不連線 arcrun.dev,所有 workflow 在本機執行。'));
console.log(chalk.gray(' 需要 Cloudflare 部署?執行 acr initStandard 模式)。\n'));
}
async function initStandard(rl: ReturnType<typeof createInterface>): Promise<void> { async function initStandard(rl: ReturnType<typeof createInterface>): Promise<void> {
console.log(chalk.gray(' Standard 模式:使用 arcrun.dev 的執行引擎,credential 存在你自己的 CF KV\n')); console.log(chalk.gray(' Standard 模式:使用 arcrun.dev 的執行引擎,credential 存在你自己的 CF KV\n'));
@@ -137,7 +157,6 @@ function createCredentialsYamlIfMissing(): void {
// 確保 .gitignore 排除 credentials.yaml // 確保 .gitignore 排除 credentials.yaml
const gitignorePath = join(process.cwd(), '.gitignore'); const gitignorePath = join(process.cwd(), '.gitignore');
if (existsSync(gitignorePath)) { if (existsSync(gitignorePath)) {
const { readFileSync, appendFileSync } = await import('node:fs');
const content = readFileSync(gitignorePath, 'utf8'); const content = readFileSync(gitignorePath, 'utf8');
if (!content.includes('credentials.yaml')) { if (!content.includes('credentials.yaml')) {
appendFileSync(gitignorePath, '\ncredentials.yaml\n'); appendFileSync(gitignorePath, '\ncredentials.yaml\n');
+1 -2
View File
@@ -3,7 +3,7 @@
* acr parts scaffold <component> — 輸出 config 範本 * acr parts scaffold <component> — 輸出 config 範本
* acr parts publish <component> — 提交零件至公眾 registry * acr parts publish <component> — 提交零件至公眾 registry
*/ */
import { readFileSync, existsSync } from 'node:fs'; import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import chalk from 'chalk'; import chalk from 'chalk';
import ora from 'ora'; import ora from 'ora';
@@ -226,7 +226,6 @@ function loadLocalComponents(): ComponentInfo[] {
for (const dir of dirs) { for (const dir of dirs) {
if (existsSync(dir)) { if (existsSync(dir)) {
const components: ComponentInfo[] = []; const components: ComponentInfo[] = [];
const { readdirSync } = require('node:fs');
for (const name of readdirSync(dir)) { for (const name of readdirSync(dir)) {
const contractPath = join(dir, name, 'component.contract.yaml'); const contractPath = join(dir, name, 'component.contract.yaml');
if (existsSync(contractPath)) { if (existsSync(contractPath)) {
+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 { loadWorkflowYaml, parseTriplets, validateRelations, getNodeNames } from '../lib/yaml-parser.js';
import { CfKvClient } from '../lib/cf-api.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(); const config = loadConfig();
let allPassed = true; let allPassed = true;
@@ -48,15 +48,13 @@ export async function cmdValidate(filePath: string): Promise<void> {
allPassed = false; 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 nodeNames = getNodeNames(triplets);
const inputNodes = new Set(triplets.map(t => t.subject).filter(s => const RESERVED_NODES = new Set(['input']); // 保留字節點不需要 config
!triplets.some(t => t.object === s) const componentNodes = nodeNames.filter(n => !RESERVED_NODES.has(n));
));
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 missingConfigs = componentNodes.filter(n => !(workflow.config ?? {})[n]); const missingConfigs = componentNodes.filter(n => !(workflow.config ?? {})[n]);
if (missingConfigs.length > 0) { if (missingConfigs.length > 0) {
@@ -66,35 +64,41 @@ export async function cmdValidate(filePath: string): Promise<void> {
check('config 完整性', true, `${componentNodes.length} 個節點均有 config`); check('config 完整性', true, `${componentNodes.length} 個節點均有 config`);
} }
// 5. 零件存在於 WASM_BUCKET(透過 cypher/search 確認 // 5. 零件存在性(--offline 時跳過
const executorUrl = getCypherExecutorUrl(config); if (options.offline || config.mode === 'local') {
try { check('零件存在性', true, '離線模式,跳過遠端檢查');
const headers: Record<string, string> = { 'Content-Type': 'application/json' }; } else {
if (config.api_key) headers['X-Arcrun-API-Key'] = config.api_key; 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`, { const res = await fetch(`${executorUrl}/cypher/search`, {
method: 'POST', method: 'POST',
headers, headers,
body: JSON.stringify({ triplets: workflow.flow }), body: JSON.stringify({ triplets: workflow.flow }),
}); });
if (res.ok) { if (res.ok) {
const data = await res.json() as { missing: string[] }; const data = await res.json() as { missing: string[] };
if (data.missing.length > 0) { if (data.missing.length > 0) {
check('零件存在性', false, `WASM_BUCKET 中找不到:${data.missing.join(', ')}`); check('零件存在性', false, `WASM_BUCKET 中找不到:${data.missing.join(', ')}`);
allPassed = false; allPassed = false;
} else {
check('零件存在性', true, '所有零件均已在 WASM_BUCKET');
}
} else { } else {
check('零件存在性', true, '所有零件均已在 WASM_BUCKET'); check('零件存在性', false, `無法連線 ${executorUrl}(加 --offline 跳過此檢查)`);
} }
} else { } catch {
check('零件存在性', false, `無法連線 ${executorUrl},跳過驗證`); check('零件存在性', false, `無法連線 ${executorUrl}(加 --offline 跳過此檢查)`);
} }
} catch {
check('零件存在性', false, `無法連線 ${executorUrl},跳過驗證`);
} }
// 6. 所需 credentials 上傳至 CREDENTIALS_KV(僅在有 KV 設定時執行 // 6. Credentials 上傳檢查(--offline 或 local 模式時跳過
if (config.cloudflare_account_id && config.cf_api_token) { if (options.offline || config.mode === 'local') {
// 離線模式略過
} else if (config.cloudflare_account_id && config.cf_api_token) {
const namespaceId = config.mode === 'standard' const namespaceId = config.mode === 'standard'
? config.user_kv_namespace_id ? config.user_kv_namespace_id
: config.credentials_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, apiToken: config.cf_api_token,
}); });
// 查詢已上傳的 credentials
try { try {
const kvKeys = await kv.list('cred:'); const kvKeys = await kv.list('cred:');
const uploadedCreds = new Set(kvKeys.map(k => k.name.replace('cred:', ''))); const uploadedCreds = new Set(kvKeys.map(k => k.name.replace('cred:', '')));
// 比對 workflow config 中引用的 credential key
const usedCreds = extractCredentialRefs(workflow.config ?? {}); const usedCreds = extractCredentialRefs(workflow.config ?? {});
const missingCreds = usedCreds.filter(k => !uploadedCreds.has(k)); const missingCreds = usedCreds.filter(k => !uploadedCreds.has(k));
+5 -3
View File
@@ -27,8 +27,9 @@ program
program program
.command('init') .command('init')
.description('互動式初始化設定(建立 ~/.arcrun/config.yaml') .description('互動式初始化設定(建立 ~/.arcrun/config.yaml')
.option('--self-hosted', '使用 Self-hosted 模式(自行部署所有 Worker)') .option('--local', '本機模式:不需要 Cloudflare 帳號,直接在本機測試 workflow')
.action((options: { selfHosted?: boolean }) => cmdInit(options)); .option('--self-hosted', '完全 Self-hosted 模式:自行部署所有 Cloudflare Worker')
.action((options: { local?: boolean; selfHosted?: boolean }) => cmdInit(options));
// acr creds push [credentials.yaml] // acr creds push [credentials.yaml]
const credsCmd = program.command('creds').description('Credential 管理'); const credsCmd = program.command('creds').description('Credential 管理');
@@ -54,7 +55,8 @@ program
program program
.command('validate <file>') .command('validate <file>')
.description('執行前驗證 workflow.yaml(格式、關係詞、零件存在性、credentials') .description('執行前驗證 workflow.yaml(格式、關係詞、零件存在性、credentials')
.action((file: string) => cmdValidate(file)); .option('--offline', '離線模式:跳過零件存在性與 credentials 的遠端檢查')
.action((file: string, options: { offline?: boolean }) => cmdValidate(file, options));
// acr parts // acr parts
// acr parts scaffold <component> // acr parts scaffold <component>
+1 -1
View File
@@ -87,7 +87,7 @@ export async function encryptCredential(value: string, encryptionKey: string): P
const keyBytes = hexToUint8Array(encryptionKey); const keyBytes = hexToUint8Array(encryptionKey);
const cryptoKey = await crypto.subtle.importKey( const cryptoKey = await crypto.subtle.importKey(
'raw', 'raw',
keyBytes, keyBytes.buffer as ArrayBuffer,
{ name: 'AES-GCM' }, { name: 'AES-GCM' },
false, false,
['encrypt'], ['encrypt'],
+3 -5
View File
@@ -7,7 +7,7 @@ import { join } from 'node:path';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
export interface ArcrunConfig { export interface ArcrunConfig {
mode: 'standard' | 'self-hosted'; mode: 'local' | 'standard' | 'self-hosted';
// Standard 模式 // Standard 模式
cloudflare_account_id?: string; cloudflare_account_id?: string;
user_kv_namespace_id?: string; user_kv_namespace_id?: string;
@@ -31,10 +31,8 @@ export function configExists(): boolean {
export function loadConfig(): ArcrunConfig { export function loadConfig(): ArcrunConfig {
if (!existsSync(CONFIG_PATH)) { if (!existsSync(CONFIG_PATH)) {
throw new Error( // 未初始化時回傳 local 模式預設值,讓 validate --offline 等指令能在無設定下運作
'找不到 ~/.arcrun/config.yaml\n' + return { mode: 'local' };
'請先執行:acr init'
);
} }
const raw = readFileSync(CONFIG_PATH, 'utf8'); const raw = readFileSync(CONFIG_PATH, 'utf8');
return yaml.load(raw) as ArcrunConfig; return yaml.load(raw) as ArcrunConfig;