From 7bd4ab0a6eded6ea4d2bbbbfaca9783a998367a0 Mon Sep 17 00:00:00 2001 From: richblack Date: Thu, 16 Apr 2026 14:53:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(cli):=20address=20Gemini=20test=20report=20?= =?UTF-8?q?=E2=80=94=20local=20mode,=20validate=20bug,=20offline=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cli/src/commands/init.ts | 27 +++++++++++--- cli/src/commands/parts.ts | 3 +- cli/src/commands/validate.ts | 69 ++++++++++++++++++------------------ cli/src/index.ts | 8 +++-- cli/src/lib/cf-api.ts | 2 +- cli/src/lib/config.ts | 8 ++--- 6 files changed, 68 insertions(+), 49 deletions(-) diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index d5fa6c7..c15b1d0 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -4,7 +4,7 @@ * 呼叫 arcrun.dev 取得 API Key,寫入 ~/.arcrun/config.yaml */ 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 chalk from 'chalk'; import { saveConfig, type ArcrunConfig } from '../lib/config.js'; @@ -16,13 +16,15 @@ async function prompt(rl: ReturnType, question: string): return answer.trim(); } -export async function cmdInit(options: { selfHosted?: boolean }): Promise { +export async function cmdInit(options: { local?: boolean; selfHosted?: boolean }): Promise { const rl = createInterface({ input: process.stdin, output: process.stdout }); console.log(chalk.bold('\n arcrun 初始化設定\n')); try { - if (options.selfHosted) { + if (options.local) { + await initLocal(); + } else if (options.selfHosted) { await initSelfHosted(rl); } else { await initStandard(rl); @@ -32,6 +34,24 @@ export async function cmdInit(options: { selfHosted?: boolean }): Promise } } +async function initLocal(): Promise { + console.log(chalk.gray(' Local 模式:不需要 Cloudflare 帳號,直接在本機跑 workflow\n')); + + const config: ArcrunConfig = { + mode: 'local', + }; + + saveConfig(config); + createCredentialsYamlIfMissing(); + + console.log(chalk.green('\n ✓ 設定完成 → ~/.arcrun/config.yaml(local 模式)\n')); + console.log(' 你可以立刻開始:'); + console.log(chalk.cyan(' acr validate workflow.yaml --offline') + ' # 驗證 workflow 格式'); + console.log(chalk.cyan(' acr run ') + ' # 本機執行\n'); + console.log(chalk.gray(' Local 模式不連線 arcrun.dev,所有 workflow 在本機執行。')); + console.log(chalk.gray(' 需要 Cloudflare 部署?執行 acr init(Standard 模式)。\n')); +} + async function initStandard(rl: ReturnType): Promise { console.log(chalk.gray(' Standard 模式:使用 arcrun.dev 的執行引擎,credential 存在你自己的 CF KV\n')); @@ -137,7 +157,6 @@ function createCredentialsYamlIfMissing(): void { // 確保 .gitignore 排除 credentials.yaml const gitignorePath = join(process.cwd(), '.gitignore'); if (existsSync(gitignorePath)) { - const { readFileSync, appendFileSync } = await import('node:fs'); const content = readFileSync(gitignorePath, 'utf8'); if (!content.includes('credentials.yaml')) { appendFileSync(gitignorePath, '\ncredentials.yaml\n'); diff --git a/cli/src/commands/parts.ts b/cli/src/commands/parts.ts index cb78a66..31ef90d 100644 --- a/cli/src/commands/parts.ts +++ b/cli/src/commands/parts.ts @@ -3,7 +3,7 @@ * acr parts scaffold — 輸出 config 範本 * acr parts publish — 提交零件至公眾 registry */ -import { readFileSync, existsSync } from 'node:fs'; +import { readFileSync, existsSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import chalk from 'chalk'; import ora from 'ora'; @@ -226,7 +226,6 @@ function loadLocalComponents(): ComponentInfo[] { for (const dir of dirs) { if (existsSync(dir)) { const components: ComponentInfo[] = []; - const { readdirSync } = require('node:fs'); for (const name of readdirSync(dir)) { const contractPath = join(dir, name, 'component.contract.yaml'); if (existsSync(contractPath)) { diff --git a/cli/src/commands/validate.ts b/cli/src/commands/validate.ts index c90f6a1..0fcdffe 100644 --- a/cli/src/commands/validate.ts +++ b/cli/src/commands/validate.ts @@ -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 { +export async function cmdValidate(filePath: string, options: { offline?: boolean } = {}): Promise { const config = loadConfig(); let allPassed = true; @@ -48,15 +48,13 @@ export async function cmdValidate(filePath: string): Promise { 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 { check('config 完整性', true, `${componentNodes.length} 個節點均有 config`); } - // 5. 零件存在於 WASM_BUCKET(透過 cypher/search 確認) - const executorUrl = getCypherExecutorUrl(config); - try { - const headers: Record = { '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 = { '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 { 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)); diff --git a/cli/src/index.ts b/cli/src/index.ts index a2439e7..df2d578 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -27,8 +27,9 @@ program program .command('init') .description('互動式初始化設定(建立 ~/.arcrun/config.yaml)') - .option('--self-hosted', '使用 Self-hosted 模式(自行部署所有 Worker)') - .action((options: { selfHosted?: boolean }) => cmdInit(options)); + .option('--local', '本機模式:不需要 Cloudflare 帳號,直接在本機測試 workflow') + .option('--self-hosted', '完全 Self-hosted 模式:自行部署所有 Cloudflare Worker') + .action((options: { local?: boolean; selfHosted?: boolean }) => cmdInit(options)); // acr creds push [credentials.yaml] const credsCmd = program.command('creds').description('Credential 管理'); @@ -54,7 +55,8 @@ program program .command('validate ') .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 scaffold diff --git a/cli/src/lib/cf-api.ts b/cli/src/lib/cf-api.ts index 03d91bc..9ed0ed7 100644 --- a/cli/src/lib/cf-api.ts +++ b/cli/src/lib/cf-api.ts @@ -87,7 +87,7 @@ export async function encryptCredential(value: string, encryptionKey: string): P const keyBytes = hexToUint8Array(encryptionKey); const cryptoKey = await crypto.subtle.importKey( 'raw', - keyBytes, + keyBytes.buffer as ArrayBuffer, { name: 'AES-GCM' }, false, ['encrypt'], diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts index 22c78b9..1095ef1 100644 --- a/cli/src/lib/config.ts +++ b/cli/src/lib/config.ts @@ -7,7 +7,7 @@ import { join } from 'node:path'; import yaml from 'js-yaml'; export interface ArcrunConfig { - mode: 'standard' | 'self-hosted'; + mode: 'local' | 'standard' | 'self-hosted'; // Standard 模式 cloudflare_account_id?: string; user_kv_namespace_id?: string; @@ -31,10 +31,8 @@ export function configExists(): boolean { export function loadConfig(): ArcrunConfig { if (!existsSync(CONFIG_PATH)) { - throw new Error( - '找不到 ~/.arcrun/config.yaml\n' + - '請先執行:acr init' - ); + // 未初始化時回傳 local 模式預設值,讓 validate --offline 等指令能在無設定下運作 + return { mode: 'local' }; } const raw = readFileSync(CONFIG_PATH, 'utf8'); return yaml.load(raw) as ArcrunConfig;