/** * acr update — 拉新 GitHub release,重新部署零件/引擎到用戶自己的 Cloudflare。 * * 與 acr init --self-hosted 走同一條「下載 release → 注入 KV id → wrangler deploy」的路, * 差別只在:init 是首次(建 KV/R2 + 寫 config),update 是沿用既有 config 重部署變動的 Worker。 * * 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3「acr update」 * * 誠實限制(mindset §7 / SDD §6):部署依賴 GitHub release(含預編譯 wasm), * release 產製管道補上前,誠實回報未實作,不假裝更新成功。 */ import chalk from 'chalk'; import { loadConfig } from '../lib/config.js'; import { CfAccountClient } from '../lib/cf-api.js'; import { wranglerAvailable, downloadAndDeploy, REQUIRED_KV_NAMESPACES, type DeployContext, } from '../lib/deploy.js'; export async function cmdUpdate(): Promise { const config = loadConfig(); if (config.mode !== 'self-hosted') { console.log(chalk.yellow('\n acr update 只用於 self-hosted 模式(部署在你自己的 Cloudflare)。')); console.log(chalk.gray(' 目前模式:' + config.mode + '。如要 self-host,先跑 acr init --self-hosted。\n')); process.exit(1); } if (!config.cloudflare_account_id || !config.cf_api_token) { console.log(chalk.yellow('\n config 缺 cloudflare_account_id / cf_api_token,無法部署。')); console.log(chalk.gray(' 請重新跑 acr init --self-hosted。\n')); process.exit(1); } if (!wranglerAvailable()) { console.log(chalk.yellow('\n ✗ 找不到 wrangler(Cloudflare CLI)。請先 npm i -g wrangler。\n')); process.exit(1); } console.log(chalk.bold('\n acr update — 拉新 release 並重新部署\n')); // 重新解析「全部」KV namespace id(冪等:已存在則重用),不只 config 存的兩個。 // 壓測 §4.1.3:舊版 update 只注入 WEBHOOKS+CREDENTIALS_KV,其餘 6 個注入成空字串 → // 重部署反而可能弄壞需要 RECIPES/EXEC_CONTEXT/... 的 worker。改為與 init 同樣全建妥。 const cf = new CfAccountClient(config.cloudflare_account_id, config.cf_api_token); const kvNamespaceIds: Record = {}; try { const existing = await cf.listKvNamespaces(); for (const title of REQUIRED_KV_NAMESPACES) { kvNamespaceIds[title] = await cf.ensureKvNamespace(title, existing); } } catch (e) { console.log(chalk.yellow(`\n ✗ 解析 KV namespace 失敗:${e instanceof Error ? e.message : e}\n`)); process.exit(1); } // D1(KBDB Base)冪等補建——之前只在 init 建,update 漏了,導致「init 時 D1 失敗(如 token 缺權限) // → 補好權限後沒有任何指令會補建 D1」(壓測 2026-06-09:D1 一直建不起來的真根因)。 // update 既是「冪等重部署」就該與 init 一致把 D1 也 ensure 上。 let d1DatabaseId = ''; try { process.stdout.write(chalk.gray(' → D1 arcrun-kbdb(冪等)...')); d1DatabaseId = await cf.ensureD1Database('arcrun-kbdb'); console.log(chalk.green(' ✓')); } catch (e) { const em = e instanceof Error ? e.message : String(e); console.log(chalk.yellow(` ⚠ ${em}`)); if (/auth/i.test(em)) { console.log(chalk.yellow(' CF token 缺 D1 權限 → 補勾「Account / D1 / Edit」重產 token 填回 .env 再 acr update')); } } const ctx: DeployContext = { accountId: config.cloudflare_account_id, apiToken: config.cf_api_token, workerSubdomain: extractSubdomain(config.cypher_executor_url), kvNamespaceIds, d1DatabaseId: d1DatabaseId || undefined, }; const result = await downloadAndDeploy(ctx); if (result.implemented) { console.log(chalk.green('\n ✓ 部署完成')); // 重跑 seed(薄殼:呼叫 API /init/seed;冪等,覆寫既有)。 // 修壓測 §4.1.3「update 不做 seed,但 init 提示說 update 會重試 seed」的矛盾。 const cypherUrl = config.cypher_executor_url ?? result.cypherExecutorUrl ?? (ctx.workerSubdomain ? `https://arcrun-cypher-executor.${ctx.workerSubdomain}.workers.dev` : ''); if (cypherUrl) { process.stdout.write(chalk.gray(' → 重新 seed recipe(API + auth,由 API 灌入)...')); try { const res = await fetch(`${cypherUrl}/init/seed`, { method: 'POST' }); const body = await res.json().catch(() => null) as { success?: boolean; message?: string } | null; console.log(res.ok && body?.success ? chalk.green(` ✓ ${body.message ?? ''}`) : chalk.yellow(` ⚠ ${body?.message ?? `HTTP ${res.status}`}`)); } catch (e) { console.log(chalk.yellow(` ⚠ seed 失敗(${e instanceof Error ? e.message : e})`)); } // kbdb-base 8.P0:一次性把舊的 per-key cron-idx:{apiKey}:{name} 折進單一 cron-idx:_all。 // 部署 8.P0 後既有 cron workflow 若不重 push 會停擺(scheduled 只讀新集中 key)→ 這裡冪等補上。 // 冪等、不刪舊 key、失敗不致命(重跑 acr update 會再試)。 process.stdout.write(chalk.gray(' → 遷移 cron index(舊 per-key → 集中 key,冪等)...')); try { const res = await fetch(`${cypherUrl}/webhooks/named/migrate-cron-index`, { method: 'POST' }); const body = await res.json().catch(() => null) as { success?: boolean; migrated?: number; skipped?: number } | null; console.log(res.ok && body?.success ? chalk.green(` ✓ migrated ${body.migrated ?? 0}, skipped ${body.skipped ?? 0}`) : chalk.yellow(` ⚠ HTTP ${res.status}(可重跑 acr update)`)); } catch (e) { console.log(chalk.yellow(` ⚠ cron index 遷移失敗(${e instanceof Error ? e.message : e})`)); } } console.log(''); } else { console.log(chalk.yellow(' ⚠ 更新尚未自動化:')); console.log(chalk.gray(' ' + result.message.split('\n').join('\n ')) + '\n'); } } /** 從 cypher_executor_url(https://arcrun-cypher-executor..workers.dev)抽 subdomain。*/ function extractSubdomain(url?: string): string { if (!url) return ''; const m = url.match(/arcrun-cypher-executor\.([^.]+)\.workers\.dev/); return m?.[1] ?? ''; }