922a57fe34
Self-hosted 開源:WASM 零件 + recipe + cypher-executor,跑在你自己的 Cloudflare。 此為重建的乾淨歷史起點(移除曾誤 commit 的 GCP SA 金鑰,舊歷史保留在 richblack/arcrun 與本地 backup 分支)。含: - acr init --self-hosted installer(建 KV/R2 + codeload 拉預編譯 wasm + wrangler deploy + seed recipe) - recipe push 把關(資料外流提醒 + 打通檢查) - 19 個正當零件預編譯 wasm(claude_api/km_writer/kbdb_upsert_block 排除:違反 DECISIONS §1) - CLI / cypher-executor / registry / 完整 SDD Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
240 lines
8.9 KiB
TypeScript
240 lines
8.9 KiB
TypeScript
/**
|
||
* acr recipe push <file> — 上傳 recipe YAML 到 arcrun.dev
|
||
* acr recipe list — 列出已上傳的 recipe
|
||
* acr recipe delete <id> — 刪除 recipe(canonical_id 或 rec_hash)
|
||
*/
|
||
import chalk from 'chalk';
|
||
import ora from 'ora';
|
||
import { readFileSync, existsSync } from 'node:fs';
|
||
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
|
||
import { obtainExposureConsent } from '../lib/exposure-warning.js';
|
||
import yaml from 'js-yaml';
|
||
|
||
interface RecipeYaml {
|
||
canonical_id?: string;
|
||
display_name?: string;
|
||
description?: string;
|
||
endpoint?: string;
|
||
method?: string;
|
||
headers?: Record<string, string>;
|
||
body?: Record<string, unknown>;
|
||
credentials_required?: Array<{ key: string; inject_as: string }>;
|
||
}
|
||
|
||
interface RecipeDefinition {
|
||
canonical_id: string;
|
||
hash_id: string;
|
||
display_name?: string;
|
||
description?: string;
|
||
endpoint: string;
|
||
method?: string;
|
||
credentials_required?: Array<{ key: string; inject_as: string }>;
|
||
created_at: number;
|
||
updated_at: number;
|
||
}
|
||
|
||
export async function cmdRecipePush(filePath: string): Promise<void> {
|
||
const config = loadConfig();
|
||
|
||
if (!config.api_key) {
|
||
console.error(chalk.red('缺少 API Key,請先執行 acr init 取得 API Key'));
|
||
process.exit(1);
|
||
}
|
||
|
||
if (!existsSync(filePath)) {
|
||
console.error(chalk.red(`找不到檔案:${filePath}`));
|
||
process.exit(1);
|
||
}
|
||
|
||
// 讀取並解析 YAML
|
||
let recipe: RecipeYaml;
|
||
try {
|
||
const raw = readFileSync(filePath, 'utf8');
|
||
recipe = yaml.load(raw) as RecipeYaml;
|
||
} catch (e) {
|
||
console.error(chalk.red(`YAML 解析失敗:${e instanceof Error ? e.message : e}`));
|
||
process.exit(1);
|
||
}
|
||
|
||
if (!recipe.canonical_id) {
|
||
console.error(chalk.red('recipe YAML 缺少 canonical_id 欄位'));
|
||
process.exit(1);
|
||
}
|
||
if (!recipe.endpoint) {
|
||
console.error(chalk.red('recipe YAML 缺少 endpoint 欄位'));
|
||
process.exit(1);
|
||
}
|
||
|
||
const executorUrl = getCypherExecutorUrl(config);
|
||
|
||
// 資料外流警示:recipe 定義一個資料去向(endpoint)。首次 push 需人類明示同意(公私一視同仁)。
|
||
// 已同意過(本機記住)→ 回非 null 自動放行;未同意/取消/非互動 → null → 中止。
|
||
const consent = await obtainExposureConsent({
|
||
kind: 'recipe',
|
||
resourceName: recipe.canonical_id,
|
||
destination: recipe.endpoint,
|
||
});
|
||
if (!consent) {
|
||
process.exit(1);
|
||
}
|
||
|
||
const spinner = ora(`上傳 recipe "${recipe.canonical_id}"`).start();
|
||
|
||
try {
|
||
const res = await fetch(`${executorUrl}/recipes`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Arcrun-API-Key': config.api_key,
|
||
},
|
||
body: JSON.stringify({ ...recipe, exposure_consent: consent ?? undefined }),
|
||
});
|
||
|
||
const data = await res.json() as { success: boolean; recipe?: RecipeDefinition; error?: string };
|
||
|
||
if (!data.success || !data.recipe) {
|
||
spinner.fail(chalk.red(`上傳失敗:${data.error ?? '未知錯誤'}`));
|
||
process.exit(1);
|
||
}
|
||
|
||
spinner.succeed(chalk.green(`✓ recipe "${data.recipe.canonical_id}" 上傳成功`));
|
||
console.log(`\n Hash ID:${chalk.cyan(data.recipe.hash_id)} (穩定引用,不受改名影響)`);
|
||
console.log(` Endpoint:${chalk.gray(data.recipe.endpoint)}`);
|
||
|
||
// 打通檢查(SDD recipe-push-gatekeeping §1.2):recipe 是「指向外部 API 的指針」,
|
||
// 正確性一半在「打不打得通」(DECISIONS §1 recipe 驗收 = 2xx)。
|
||
// self-hosted 是提醒級:不硬擋、誠實標原因(缺 credential 打不到 2xx 就誠實說,不假綠 — mindset §7)。
|
||
await probeRecipeEndpoint(recipe);
|
||
|
||
console.log(chalk.bold('\n 在 workflow config 中使用:\n'));
|
||
console.log(chalk.cyan(` config:`));
|
||
console.log(chalk.cyan(` my_node:`));
|
||
console.log(chalk.cyan(` component: ${data.recipe.canonical_id} # 或用 hash: ${data.recipe.hash_id}`));
|
||
if (data.recipe.credentials_required?.length) {
|
||
console.log(chalk.yellow(`\n 此 recipe 需要 credentials:${data.recipe.credentials_required.map(c => c.key).join(', ')}`));
|
||
console.log(chalk.gray(' 執行 acr creds push 上傳 token'));
|
||
}
|
||
console.log('');
|
||
} catch (e) {
|
||
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 打通檢查:push 時對 recipe endpoint 實打一次,回報是否 2xx。
|
||
*
|
||
* 提醒級(self-hosted):只回報、不硬擋(用戶可能就是要先 push 再設 credential)。
|
||
* 誠實(mindset §7):
|
||
* - endpoint 含未填模板({{_path}} / {{auth.x}} 等)→ 執行期才有值,push 時無法驗,誠實說明。
|
||
* - 打不到 2xx → 誠實標 HTTP status(如 401 多半是缺 credential),不假裝成功。
|
||
* - arcrun 不做授權判斷:401/403 是對方服務裁決,不是 recipe 的 bug(DECISIONS / mindset §3)。
|
||
*/
|
||
async function probeRecipeEndpoint(recipe: RecipeYaml): Promise<void> {
|
||
const endpoint = recipe.endpoint ?? '';
|
||
if (/\{\{.*?\}\}/.test(endpoint)) {
|
||
console.log(chalk.gray('\n 打通檢查:endpoint 含執行期變數({{...}}),push 時無法預打。'));
|
||
console.log(chalk.gray(' 實際是否打通待 acr run 時才知(recipe 驗收標準 = 執行回 2xx)。'));
|
||
return;
|
||
}
|
||
|
||
process.stdout.write(chalk.gray('\n 打通檢查(實打 endpoint)...'));
|
||
try {
|
||
const method = (recipe.method ?? 'POST').toUpperCase();
|
||
const res = await fetch(endpoint, {
|
||
method,
|
||
headers: recipe.headers,
|
||
// 不帶 credential(push 端沒有明文)→ 打不通多半是缺 auth,下面誠實標
|
||
...(method !== 'GET' && method !== 'HEAD'
|
||
? { body: JSON.stringify(recipe.body ?? {}) }
|
||
: {}),
|
||
signal: AbortSignal.timeout(10_000),
|
||
});
|
||
if (res.ok) {
|
||
console.log(chalk.green(` ✓ HTTP ${res.status}(打通)`));
|
||
} else if (res.status === 401 || res.status === 403) {
|
||
console.log(chalk.yellow(` ⚠ HTTP ${res.status}`));
|
||
console.log(chalk.gray(' 未驗收:多半是缺 credential(過認證後才會 2xx)。先 acr creds push 對應 token。'));
|
||
console.log(chalk.gray(' 註:401/403 是對方服務在行使授權,不是 recipe 的 bug。'));
|
||
} else {
|
||
console.log(chalk.yellow(` ⚠ HTTP ${res.status}(未打通)`));
|
||
console.log(chalk.gray(' recipe 已上傳,但 endpoint 目前未回 2xx。請確認 endpoint / method 正確。'));
|
||
}
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
console.log(chalk.yellow(` ⚠ 無法連線`));
|
||
console.log(chalk.gray(` ${msg.slice(0, 120)}(recipe 已上傳;連線問題不擋 push)`));
|
||
}
|
||
}
|
||
|
||
export async function cmdRecipeList(): Promise<void> {
|
||
const config = loadConfig();
|
||
const executorUrl = getCypherExecutorUrl(config);
|
||
const spinner = ora('取得 recipe 清單').start();
|
||
|
||
try {
|
||
const headers: Record<string, string> = {};
|
||
if (config.api_key) headers['X-Arcrun-API-Key'] = config.api_key;
|
||
|
||
const res = await fetch(`${executorUrl}/recipes`, { headers });
|
||
const data = await res.json() as { success: boolean; recipes?: RecipeDefinition[]; error?: string };
|
||
|
||
spinner.stop();
|
||
|
||
if (!data.success) {
|
||
console.error(chalk.red(`錯誤:${data.error}`));
|
||
process.exit(1);
|
||
}
|
||
|
||
const recipes = data.recipes ?? [];
|
||
if (recipes.length === 0) {
|
||
console.log(chalk.gray('\n 尚無 recipe。執行 acr recipe push <file> 上傳。\n'));
|
||
return;
|
||
}
|
||
|
||
console.log(chalk.bold(`\n arcrun recipes(${recipes.length} 個)\n`));
|
||
for (const r of recipes) {
|
||
console.log(` • ${chalk.cyan(r.canonical_id.padEnd(20))} ${chalk.gray(r.hash_id)} ${r.display_name ?? ''}`);
|
||
console.log(` ${chalk.gray(r.endpoint)}`);
|
||
if (r.credentials_required?.length) {
|
||
console.log(` ${chalk.yellow('🔑 需要:' + r.credentials_required.map(c => c.key).join(', '))}`);
|
||
}
|
||
}
|
||
console.log('');
|
||
} catch (e) {
|
||
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
export async function cmdRecipeDelete(id: string): Promise<void> {
|
||
const config = loadConfig();
|
||
|
||
if (!config.api_key) {
|
||
console.error(chalk.red('缺少 API Key,請先執行 acr init'));
|
||
process.exit(1);
|
||
}
|
||
|
||
const executorUrl = getCypherExecutorUrl(config);
|
||
const spinner = ora(`刪除 recipe "${id}"`).start();
|
||
|
||
try {
|
||
const res = await fetch(`${executorUrl}/recipes/${id}`, {
|
||
method: 'DELETE',
|
||
headers: { 'X-Arcrun-API-Key': config.api_key },
|
||
});
|
||
|
||
const data = await res.json() as { success: boolean; deleted?: string; error?: string };
|
||
|
||
if (!data.success) {
|
||
spinner.fail(chalk.red(`刪除失敗:${data.error ?? '未知錯誤'}`));
|
||
process.exit(1);
|
||
}
|
||
|
||
spinner.succeed(chalk.green(`✓ recipe "${data.deleted}" 已刪除`));
|
||
} catch (e) {
|
||
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
|
||
process.exit(1);
|
||
}
|
||
}
|