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
*/
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<typeof createInterface>, question: string):
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 });
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<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> {
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');
+1 -2
View File
@@ -3,7 +3,7 @@
* acr parts scaffold <component> — 輸出 config 範本
* acr parts publish <component> — 提交零件至公眾 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)) {
+18 -17
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,7 +64,10 @@ export async function cmdValidate(filePath: string): Promise<void> {
check('config 完整性', true, `${componentNodes.length} 個節點均有 config`);
}
// 5. 零件存在於 WASM_BUCKET(透過 cypher/search 確認
// 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' };
@@ -87,14 +88,17 @@ export async function cmdValidate(filePath: string): Promise<void> {
check('零件存在性', true, '所有零件均已在 WASM_BUCKET');
}
} else {
check('零件存在性', false, `無法連線 ${executorUrl},跳過驗證`);
check('零件存在性', false, `無法連線 ${executorUrl}(加 --offline 跳過此檢查)`);
}
} catch {
check('零件存在性', false, `無法連線 ${executorUrl},跳過驗證`);
check('零件存在性', false, `無法連線 ${executorUrl}(加 --offline 跳過此檢查)`);
}
}
// 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));
+5 -3
View File
@@ -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 <file>')
.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 <component>
+1 -1
View File
@@ -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'],
+3 -5
View File
@@ -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;