/** * acr update — 從 Gitea 拉最新 archive,重新部署零件/引擎到用戶自己的 Cloudflare。 * * 與 acr init --self-hosted 走同一條「下載 archive → 注入 KV id → wrangler deploy」的路 *(同一支 downloadAndDeploy),差別只在:init 是首次(建 KV/D1 + 寫 config), * update 是沿用既有 config 重部署變動的 Worker。因共用 downloadAndDeploy 的內容指紋 manifest, * 「新裝零件補上、內容未變者略過」對 update 天然成立 → install ≈ update(Arcrun#4)。 * * 下載源(Arcrun#4,2026-07-07):由 GitHub codeload 改指 Gitea archive(git.uncle6.me), * 讓 D20 防 flag 下不能碰 GitHub 的 self-hosted 用戶也能 acr update 補裝新零件(如 code 零件)。 * * 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3「acr update」 */ import chalk from 'chalk'; import { loadConfig } from '../lib/config.js'; import { wranglerAvailable, downloadAndDeploy, type DeployContext, } from '../lib/deploy.js'; export async function cmdUpdate(opts: { force?: boolean } = {}): 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')); // 🔴 Arcrun#97:這裡**曾經**先「照名字 ensure」一輪 KV + D1 再往下傳。 // binding 名(WEBHOOKS)被當成 CF 上的資源標題去找,安裝器建的資源不叫那個名字 // ⇒ 每次都對不上 ⇒ 每次都新建一顆空的綁上去 ⇒ 使用者的工作流/登入/子庫從畫面上消失。 // 現在資源解析整段搬進 downloadAndDeploy:先讀「你已部署的 worker 現在綁著什麼」再決定, // 而且是**下載完、看得到這版要哪些 binding 之後**才決定,不再由這裡預先造一批。 const ctx: DeployContext = { accountId: config.cloudflare_account_id, apiToken: config.cf_api_token, workerSubdomain: extractSubdomain(config.cypher_executor_url), // self-hosted → 注入 MULTI_TENANT="false"(mcp-account-source §5.5,修 acr update 部署的 MCP 401)。 // config 源頭:init 寫 multi_tenant:false + mode:'self-hosted'。acr update 只在 self-hosted 跑。 selfHosted: config.mode === 'self-hosted' || config.multi_tenant === false, // 語義查詢(issue #7):預設**開**,只有 config 顯式寫 kbdb_embed:false 才關。 // 🔴 2026-08-09 翻轉預設(leo:「語義搜尋已經確定是一安裝就提供的功能」)。 // 舊判斷 `=== true` 的實害:config 沒這個欄位(舊 config / 一鍵安裝實例本機補跑 update) // 時 redeploy 會把 kbdb 的 [[vectorize]]+[ai] binding 靜默剝掉——一台**原本正常**的 // 實例就這樣失去語意搜尋,畫面上還被說成「還沒開通」。wrangler deploy 是整份覆蓋, // binding 不在 toml 裡=直接消失,這正是「裝好的實例壞掉」的機制之一。 kbdbEmbed: config.kbdb_embed !== false, }; // mode:'update' → 資源解析在「一顆該更新的 worker 都找不到」時會停手而不是重建一整套 //(Arcrun#97 的另一道門:名字對不上時別假裝這是全新安裝)。 const result = await downloadAndDeploy(ctx, 'main', { force: opts.force, mode: 'update' }); // 資源解析階段喊停:什麼都沒建、什麼都沒部。原文照印,然後非零離開—— // 不能混進「部分失敗」的黃字裡帶過(那正是使用者不會發現的那種失敗)。 if (result.blocked) { console.log(chalk.yellow('\n ⚠ 更新沒有進行,你的實例維持原樣。\n')); console.log(' ' + result.message.split('\n').join('\n ')); console.log(''); process.exit(1); } if (result.implemented) { // message 含部分失敗清單(「部署 X/Y 成功,N 失敗:✗ ...」)——必須印出來, // 否則 worker 失敗被綠勾蓋掉(假綠):cypher 沒部上 → 後面 migrate 打舊 worker 404, // 用戶重跑 N 次都不知道根因(壓測 2026-06-11 實證)。 if (result.message?.includes('失敗')) { console.log(chalk.yellow(`\n ⚠ 部署部分失敗:`)); console.log(chalk.yellow(' ' + result.message.split('\n').join('\n '))); } else { 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 rawText = await res.text(); let body: { success?: boolean; migrated?: number; skipped?: number; errors?: string[] } | null = null; try { body = JSON.parse(rawText); } catch { /* 非 JSON(如 CF 錯誤頁)→ 用原文 */ } if (res.ok && body?.success) { console.log(chalk.green(` ✓ migrated ${body.migrated ?? 0}, skipped ${body.skipped ?? 0}`)); } else { // 印 server 回的錯誤內容(截前 200 字)——只回 HTTP status 沒人能診斷 // (壓測 2026-06-11:404→500 重跑 3 次都看不到根因)。 const detail = (body?.errors?.join('; ') ?? rawText).slice(0, 200); console.log(chalk.yellow(` ⚠ HTTP ${res.status}${detail ? `:${detail}` : ''}`)); console.log(chalk.yellow(' 404 = cypher worker 還是舊版(看上方部署是否有失敗);500 = server 端錯誤(看上行錯誤內容)')); } } 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] ?? ''; }