#!/usr/bin/env node // @ts-check /** * teardown-instance.mjs — 把一個 Cloudflare 帳號上的 Arcrun self-hosted 安裝拆乾淨。 * * 🔴 這是內部工具,不是產品(leo 2026-08-14:「簡單先做出 uninstaller⋯先產出你自己可用的 * 版本,在 youlin 使用,對外的後面提供」)。目的:讓「拆掉重裝」變成隨時做得到的事, * 配套 D82 三步驗收(Arcrun#119/#120)——沒有拆的能力,「重裝從乾淨狀態開始」就永遠測不到。 * * 背景(Arcrun#120):安裝器判斷 init/update 看的是**中心 KV** 的 `deployed::`, * 不是「帳號上現在有什麼」。只清帳號上的 worker/KV/D1/Vectorize、漏清中心那筆 ⇒ 重裝被判定 * 「已經裝過」卡死。本工具因此分兩段:① 帳號側(Cloudflare 資源)② 中心側(安裝器的 * INSTALLER_KV/PEER_INSTALLER_KV `deployed:` 紀錄)——兩段都做才算「不再殘留」。 * * 安全設計(這是刪除工具,寫錯會毀掉別人的東西): * 1. 帳號白名單——只認 ALLOWED_ACCOUNT_IDS 裡的帳號,其餘一律拒絕執行。 * 2. 兩階段——預設 `plan`(唯讀,只印清單);要真的刪必須 `apply --yes`。 * 3. 歸屬判準——「這個帳號上的 worker 是不是這台 Arcrun 實例的」不是猜名字前綴,是 * 「這個 worker 名字有沒有出現在本 repo(matrix/arcrun)任何 wrangler*.toml 的 * `name = "..."`」。不認得的 worker 一律「不動、列出來讓人看」,不會被當成「安全,可刪」。 * 4. 共用資源保護——同一顆 KV/D1/Vectorize 若同時被「要刪的 worker」與「沒被列入要刪的 * worker」(不論是不認得的、還是被 --keep 排除的)綁著,一律排除、不刪、印警告。 * 這是為了 youlin 帳號上可能同時存在多台實例(例:一次性測試用的 * arcrun-cypher-executor-freshtest)而設——見下「已知限制」。 * * 已知限制(誠實列,不要假裝這個判準萬能): * - 判準是「repo 認不認得這個 worker 名字」,不是「這個 worker 屬於哪一台實例」。同帳號上 * 兩台同名慣例的實例(理論上不該發生,因為 worker 名字是全帳號唯一)用這個判準分不出來; * 能分的是「已知元件 vs 陌生 worker」以及「資源有沒有被別的 worker 共用」。 * - `--keep ` 需要呼叫者自己知道帳號上有哪些 worker 該排除(本工具不會自動偵測 * 「這個 worker 是不是正在被別人使用」——那是 main/coordinator 層級的協調,不是機器能判斷的)。 * - 中心 KV 清理走 `wrangler kv key`,用的是本機既有的 wrangler OAuth 登入態(不是 CF API * token),因為 INSTALLER_KV 住在另一個帳號(uncle6,官方安裝器所在),本工具沒有那把帳號 * 的 API token。呼叫前務必 `npx wrangler whoami` 確認登入態是 uncle6,不是別的帳號。 * * 用法: * node scripts/teardown-instance.mjs plan --account-id --api-token [--keep w1,w2] * node scripts/teardown-instance.mjs apply --account-id --api-token [--keep w1,w2] --yes * node scripts/teardown-instance.mjs central-plan --account-id * node scripts/teardown-instance.mjs central-apply --account-id --yes */ import { createCloudflareResourceApi } from '../shared/resource-rule/cf-resource-api.mjs'; import { execFileSync } from 'node:child_process'; import { readFileSync, readdirSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, '..'); // ── 紅線:帳號白名單(Cloudflare 帳號側)──────────────────────────────────────── // 只准對這裡列出的帳號動手。要加新帳號=人類決定,不是機器自己判斷。 const ALLOWED_ACCOUNT_IDS = new Map([ ['1129efd7df2e8899d537e9c8fbabb6cb', 'youlin(stage,可自由拆裝)'], ]); // 明確擋掉的帳號(即使有人想放進 ALLOWED 也留這份對照,錯誤訊息才講得清楚是撞了哪條紅線)。 const FORBIDDEN_ACCOUNT_IDS = new Map([ ['51a01bfa2665bd7bc3fd080dc40cf3e1', 'leo21c —— leo 正在上面工作,任何情況都不准動'], ]); function assertAccountAllowed(accountId) { if (FORBIDDEN_ACCOUNT_IDS.has(accountId)) { throw new Error( `拒絕執行:帳號 ${accountId} 是明確禁止清單(${FORBIDDEN_ACCOUNT_IDS.get(accountId)})。` ); } if (!ALLOWED_ACCOUNT_IDS.has(accountId)) { throw new Error( `拒絕執行:帳號 ${accountId} 不在白名單(${[...ALLOWED_ACCOUNT_IDS.keys()].join(', ')})。` + ` 這是刪除工具,新帳號要人類明確加進 ALLOWED_ACCOUNT_IDS 才能用,不接受呼叫端臨時指定。` ); } } // ── 一、掃描本 repo,建立「已知 Arcrun worker 名字」清單 ──────────────────────── // // 只掃 matrix/arcrun(本 repo)看不到 `arcrun-rag-ui`——那顆是 arcrun-rag 產品的前端, // 由 products/arcrun-rag 的安裝器一起裝進同一個帳號(manifest.core 裡有它),但它的 // wrangler 設定不住在本 repo。單靠本 repo 掃描會把它誤判成「不認得,不動」,導致 // 「拆乾淨」漏掉一顆——因此額外把安裝器實際使用的 bundle manifest.json(manifest.core[].name) // 併入已知清單。manifest.json 是公開端點、本工具的目的正是「重裝後長出一樣的東西」, // 用它當第二個真相源與目的一致。抓不到(離線/端點掛了)就退回只用 repo 掃描,印警告, // 不讓網路失敗擋住整個工具。 const DEFAULT_MANIFEST_URL = 'https://cdn.jsdelivr.net/gh/youlinhsieh/arcrun-rag-bundles@20d4ad6900f51e02242815402a70890fc5b12712/manifest.json'; /** @returns {Promise} */ async function fetchManifestCoreNames(manifestUrl = DEFAULT_MANIFEST_URL) { try { const res = await fetch(manifestUrl); if (!res.ok) throw new Error(`HTTP ${res.status}`); const manifest = await res.json(); return (manifest.core ?? []).map((c) => c.name).filter(Boolean); } catch (e) { console.warn(`⚠️ 抓 bundle manifest 失敗(${e instanceof Error ? e.message : e}),只用本 repo 掃描的清單。`); return []; } } /** @returns {Set} */ function scanKnownWorkerNames() { const names = new Set(); const nameRe = /^\s*name\s*=\s*"([^"]+)"/gm; /** @param {string} dir */ function walk(dir) { let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === '.wrangler') continue; walk(full); } else if (entry.isFile() && /^wrangler(\..+)?\.toml$/.test(entry.name)) { let content; try { content = readFileSync(full, 'utf8'); } catch { continue; } for (const m of content.matchAll(nameRe)) { names.add(m[1]); } } } } walk(REPO_ROOT); return names; } // ── 二、帳號側 discovery ───────────────────────────────────────────────────── /** 列出帳號上所有 worker script 名字。 */ async function listAllScripts(api) { const { ok, result, error } = await api.cfRaw('/workers/scripts?per_page=100'); if (!ok) throw new Error(`列 worker scripts 失敗:${error}`); return /** @type {Array<{id:string}>} */ (result).map((s) => s.id); } /** * 對帳號上每一顆 worker 查它的 binding。 * 回傳 workerBindings(worker 名字對應到 binding 陣列) * 與 resourceOwners(資源 key 對應到綁著它的 worker 名字集合)。 */ async function discoverAccount(api, allScripts) { const workerBindings = new Map(); const resourceOwners = new Map(); for (const name of allScripts) { const sb = await api.getScriptBindings(name); const bindings = sb.deployed ? sb.bindings : []; workerBindings.set(name, bindings); for (const b of bindings) { const key = `${b.kind}:${b.value}`; if (!resourceOwners.has(key)) resourceOwners.set(key, new Set()); resourceOwners.get(key).add(name); } } return { workerBindings, resourceOwners }; } /** * 算出拆除計畫。 * @param {{accountId:string, apiToken:string, keep:string[]}} opts */ async function planTeardown({ accountId, apiToken, keep }) { assertAccountAllowed(accountId); const api = createCloudflareResourceApi({ accountId, apiToken }); const knownNames = scanKnownWorkerNames(); for (const n of await fetchManifestCoreNames()) knownNames.add(n); const allScripts = await listAllScripts(api); const { workerBindings, resourceOwners } = await discoverAccount(api, allScripts); const keepSet = new Set(keep); const knownOnAccount = allScripts.filter((n) => knownNames.has(n)); const unknownOnAccount = allScripts.filter((n) => !knownNames.has(n)); const workersToDelete = knownOnAccount.filter((n) => !keepSet.has(n)); const workersKeptExplicit = knownOnAccount.filter((n) => keepSet.has(n)); const toDeleteSet = new Set(workersToDelete); const resourcesToDelete = []; const resourcesExcludedShared = []; const seenResourceKeys = new Set(); for (const name of workersToDelete) { for (const b of workerBindings.get(name) ?? []) { const key = `${b.kind}:${b.value}`; if (seenResourceKeys.has(key)) continue; seenResourceKeys.add(key); const owners = [...resourceOwners.get(key)]; const sharedWithSurvivor = owners.some((o) => !toDeleteSet.has(o)); if (sharedWithSurvivor) { resourcesExcludedShared.push({ ...b, key, owners }); } else { resourcesToDelete.push({ ...b, key, owners }); } } } return { accountId, accountLabel: ALLOWED_ACCOUNT_IDS.get(accountId), knownNames, allScripts, unknownOnAccount, workersToDelete, workersKeptExplicit, resourcesToDelete, resourcesExcludedShared, }; } function printPlan(plan) { console.log(`\n=== 拆除計畫:${plan.accountId}(${plan.accountLabel})===\n`); console.log(`帳號上共 ${plan.allScripts.length} 顆 worker。`); console.log(`\n【會被刪除的 worker】(${plan.workersToDelete.length} 顆——名字出現在本 repo wrangler*.toml,且沒被 --keep 排除)`); for (const n of plan.workersToDelete) console.log(` - ${n}`); if (plan.workersKeptExplicit.length) { console.log(`\n【明確排除、保留的已知 worker】(${plan.workersKeptExplicit.length} 顆——用 --keep 指定)`); for (const n of plan.workersKeptExplicit) console.log(` - ${n}`); } if (plan.unknownOnAccount.length) { console.log(`\n【不認得的 worker,不動】(${plan.unknownOnAccount.length} 顆——名字沒出現在本 repo 任何 wrangler*.toml)`); for (const n of plan.unknownOnAccount) console.log(` - ${n}`); } console.log(`\n【會被刪除的資源】(${plan.resourcesToDelete.length} 個——只被「要刪的 worker」綁著)`); for (const r of plan.resourcesToDelete) { console.log(` - [${r.kind}] ${r.value}(binding ${r.binding},owner: ${r.owners.join(', ')})`); } if (plan.resourcesExcludedShared.length) { console.log(`\n⚠️ 【排除、不刪的共用資源】(${plan.resourcesExcludedShared.length} 個——同時被保留中的 worker 綁著)`); for (const r of plan.resourcesExcludedShared) { console.log(` - [${r.kind}] ${r.value}(binding ${r.binding},owner: ${r.owners.join(', ')})`); } } console.log(''); } async function applyTeardown(plan, { accountId, apiToken }) { assertAccountAllowed(accountId); const api = createCloudflareResourceApi({ accountId, apiToken }); const results = { workers: [], resources: [] }; for (const name of plan.workersToDelete) { const { ok, status, error } = await api.cfRaw(`/workers/scripts/${encodeURIComponent(name)}`, { method: 'DELETE', }); results.workers.push({ name, ok, status, error }); console.log(ok ? ` ok 刪除 worker ${name}` : ` FAIL 刪除 worker ${name}(${status}):${error}`); } for (const r of plan.resourcesToDelete) { let res; if (r.kind === 'kv_namespace') { res = await api.cfRaw(`/storage/kv/namespaces/${r.value}`, { method: 'DELETE' }); } else if (r.kind === 'd1') { res = await api.cfRaw(`/d1/database/${r.value}`, { method: 'DELETE' }); } else if (r.kind === 'vectorize') { res = await api.cfRaw(`/vectorize/v2/indexes/${encodeURIComponent(r.value)}`, { method: 'DELETE' }); } else { res = { ok: false, status: 0, error: `未知資源類型 ${r.kind}` }; } results.resources.push({ ...r, ok: res.ok, status: res.status, error: res.error }); console.log( res.ok ? ` ok 刪除 ${r.kind} ${r.value}` : ` FAIL 刪除 ${r.kind} ${r.value}(${res.status}):${res.error}` ); } return results; } // ── 三、中心 KV(安裝器 deployed 紀錄)清理 ──────────────────────────────────── // 這兩顆 KV 住在 uncle6 帳號(官方安裝器 arcrun-installer 所在),不是 youlin 的帳號。 // 沒有這顆帳號的 CF API token,走本機已登入的 wrangler OAuth(`npx wrangler whoami` 需顯示 // uncle6.me@gmail.com's Account,58309bb90fd93ad6d0fe0aae99170e9d)。 const CENTRAL_KV = { prod: { id: '2ff9a845e7f646dab7b1e3dd6707b77f', label: 'INSTALLER_KV(prod 通道)' }, staging: { id: '750c23faf0794d3db317c32f596df9a9', label: 'PEER_INSTALLER_KV(staging 通道,t157 雙通道問題)' }, }; const CENTRAL_ACCOUNT_ID = '58309bb90fd93ad6d0fe0aae99170e9d'; // uncle6 function wranglerKvList(namespaceId, prefix) { const out = execFileSync( 'npx', ['wrangler', 'kv', 'key', 'list', '--namespace-id', namespaceId, '--prefix', prefix, '--remote'], { cwd: REPO_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } ); const jsonStart = out.indexOf('['); const jsonText = jsonStart >= 0 ? out.slice(jsonStart) : out; try { return JSON.parse(jsonText); } catch { throw new Error(`解析 wrangler kv key list 輸出失敗:${out.slice(0, 500)}`); } } function wranglerKvDelete(namespaceId, key) { execFileSync( 'npx', ['wrangler', 'kv', 'key', 'delete', '--namespace-id', namespaceId, key, '--remote'], { cwd: REPO_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } ); } function planCentralCleanup(accountId) { const prefix = `deployed:${accountId}:`; const plan = {}; for (const [chan, kv] of Object.entries(CENTRAL_KV)) { const keys = wranglerKvList(kv.id, prefix).map((k) => k.name); plan[chan] = { ...kv, prefix, keys }; } return plan; } function printCentralPlan(plan) { console.log(`\n=== 中心 KV 拆除計畫(帳號 ${CENTRAL_ACCOUNT_ID}, uncle6)===\n`); for (const info of Object.values(plan)) { console.log(`【${info.label}】前綴 ${info.prefix}`); if (!info.keys.length) { console.log(' (沒有殘留紀錄)'); } else { for (const k of info.keys) console.log(` - ${k}`); } } console.log(''); } function applyCentralCleanup(plan) { const results = {}; for (const [chan, info] of Object.entries(plan)) { results[chan] = []; for (const k of info.keys) { try { wranglerKvDelete(info.id, k); console.log(` ok 刪除 ${info.label} 的 ${k}`); results[chan].push({ key: k, ok: true }); } catch (e) { console.log(` FAIL 刪除 ${info.label} 的 ${k}:${e instanceof Error ? e.message : e}`); results[chan].push({ key: k, ok: false, error: String(e) }); } } } return results; } // ── 四、CLI ──────────────────────────────────────────────────────────────── function parseArgs(argv) { const out = { _: [] }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '--yes') out.yes = true; else if (a === '--account-id') out.accountId = argv[++i]; else if (a === '--api-token') out.apiToken = argv[++i]; else if (a === '--keep') out.keep = argv[++i].split(',').map((s) => s.trim()).filter(Boolean); else out._.push(a); } return out; } async function main() { const args = parseArgs(process.argv.slice(2)); const cmd = args._[0]; if (cmd === 'plan' || cmd === 'apply') { if (!args.accountId || !args.apiToken) { console.error('需要 --account-id 與 --api-token'); process.exit(1); } const plan = await planTeardown({ accountId: args.accountId, apiToken: args.apiToken, keep: args.keep ?? [] }); printPlan(plan); if (cmd === 'plan') return; if (!args.yes) { console.error('apply 需要加 --yes(先看過上面的計畫再加這個 flag)。'); process.exit(1); } console.log('=== 開始刪除 ===\n'); const results = await applyTeardown(plan, { accountId: args.accountId, apiToken: args.apiToken }); console.log('\n=== 完成 ==='); console.log(JSON.stringify(results, null, 2)); return; } if (cmd === 'central-plan' || cmd === 'central-apply') { if (!args.accountId) { console.error('需要 --account-id(要清哪個帳號的 deployed 紀錄)'); process.exit(1); } const plan = planCentralCleanup(args.accountId); printCentralPlan(plan); if (cmd === 'central-plan') return; if (!args.yes) { console.error('central-apply 需要加 --yes。'); process.exit(1); } console.log('=== 開始刪除中心紀錄 ===\n'); const results = applyCentralCleanup(plan); console.log('\n=== 完成 ==='); console.log(JSON.stringify(results, null, 2)); return; } console.log(`用法: node scripts/teardown-instance.mjs plan --account-id --api-token [--keep w1,w2] node scripts/teardown-instance.mjs apply --account-id --api-token [--keep w1,w2] --yes node scripts/teardown-instance.mjs central-plan --account-id node scripts/teardown-instance.mjs central-apply --account-id --yes`); process.exit(1); } main().catch((e) => { console.error('錯誤:', e instanceof Error ? e.message : e); process.exit(1); });