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:
@@ -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.yaml(local 模式)\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 init(Standard 模式)。\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');
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
+5
-3
@@ -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>
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user