Files
Arcrun/scripts/teardown-instance.mjs
uncle6me-web 5d441aa7d3 fix(teardown): 中心側補上帳號白名單——原本 central-* 碰得到 leo21c
實測破口(2026-08-14,修補前):
  central-plan --account-id 51a01bfa…(leo21c)
  → 一路暢通,列出 deployed:51a01bfa…:leo21c
  加 --yes 就會刪掉 leo 的安裝紀錄 ⇒ 他下次重裝撞 Arcrun#120 死結。

根因是兩個問題被當成同一個:
  ① 動的是「誰的」紀錄 → assertAccountAllowed(帳號側有,中心側沒有)
  ② 以「誰的身分」動手   → assertWranglerIsCentralAccount(D88 補的那道)
c543aba 的註解寫「這個不對稱現在補齊」,但只補了②。註解說補齊、程式補一半,
後來讀的人會相信註解——所以順手把那段註解改成實測講法。

另修 listAllScripts 沒翻頁(ops-facts 記過的同款坑)。在這支工具裡漏看一顆
worker 有兩層傷害:拆不乾淨,以及「共用資源保護」看不到那個 owner,
把還在用的資源判成沒人用而刪掉。

並補一條已知限制:資源清單是從 worker binding 反推、不掃帳號,
所以沒人綁的孤兒殘骸不會被列也不會被刪(這正是 drill-a/drill-b 不受影響的原因,
但代價是拆完重裝可能撞 Arcrun#123,要自己再列一次帳號)。

驗證(youlin,全唯讀,未執行 apply):
- plan:6 顆 worker + 10 個資源(8 顆 yuga3bse KV/yuga3bse-db/embed-m3),
  drill-a、drill-b 不在清單
- leo21c 帳號側、中心側 兩條路徑皆 exit 1 拒絕
- youlin 中心側仍正常:staging 通道有 deployed:1129efd7…:youlin-hsieh-dev 一筆

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:51:31 +08:00

536 lines
26 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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:<accountId>:`
* 不是「帳號上現在有什麼」。只清帳號上的 worker/KV/D1/Vectorize、漏清中心那筆 ⇒ 重裝被判定
* 「已經裝過」卡死。本工具因此分兩段:① 帳號側(Cloudflare 資源)② 中心側(安裝器的
* INSTALLER_KVPEER_INSTALLER_KV `deployed:` 紀錄)——兩段都做才算「不再殘留」。
*
* 安全設計(這是刪除工具,寫錯會毀掉別人的東西):
* 1. 帳號白名單——只認 ALLOWED_ACCOUNT_IDS 裡的帳號,其餘一律拒絕執行。
* 2. 兩階段——預設 `plan`(唯讀,只印清單);要真的刪必須 `apply --yes`。
* 3. 歸屬判準——「這個帳號上的 worker 是不是這台 Arcrun 實例的」不是猜名字前綴,是
* 「這個 worker 名字有沒有出現在本 repomatrix/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 <name,...>` 需要呼叫者自己知道帳號上有哪些 worker 該排除(本工具不會自動偵測
* 「這個 worker 是不是正在被別人使用」——那是 main/coordinator 層級的協調,不是機器能判斷的)。
* - 中心 KV 清理走 `wrangler kv key`,用的是本機既有的 wrangler OAuth 登入態(不是 CF API
* token),因為 INSTALLER_KV 住在另一個帳號(uncle6,官方安裝器所在),本工具沒有那把帳號
* 的 API token。**2026-08-14 起機械強制**`assertWranglerIsCentralAccount()` 在任何
* `central-plan``central-apply` 動作前跑 `wrangler whoami --json`,核對登入態看得到
* uncle6 帳號才放行,看不到就丟錯拒絕(D88「閘要長在機器上,不是長在誰的記性上」——
* 原本這是操作者每次手動核對的紀律,不是結構保證,現在補成機械閘)。
* - `apply` 對「worker 被別的 worker service binding 卡住刪不掉」**不會自動重試/拓樸排序**。
* 這是 fail-safe(失敗就是刪不掉,不會刪錯東西),但需要操作者自己重跑 `apply` 到收斂。
* 2026-08-14 兩次實測的收斂輪數:第一次(帳號上有 kbdb-graph-plugin 這類外部依賴殘留)
* 跑了 2 輪(16 個失敗 → mcp 已刪,cypher-executor 解鎖 → 其餘 13 個跟著解鎖,僅
* arcrun-kbdb 卡在外部依賴,需另外處理);第二次(帳號上沒有外部依賴殘留,純內部
* service binding 鏈)跑了 3 輪(16 個失敗 → cypher-executor 解鎖、13 個一次過 →
* 只剩 array-ops 卡 cypher-executor 剛好晚一步刪掉 → 第 3 輪清空)。**下一個人看到
* 第一輪一半失敗不是工具壞掉,是預期行為,重跑 `apply` 即可,通常 2–3 輪收斂。**
* - 🔴 **偵測不到「不屬於這台實例的 worker 反過來依賴這台實例的 worker」這種形狀**
* 2026-08-14 實撞,兩層咬合):`kbdb-graph-plugin`(不認得,正確被排除、不動)身上有
* service binding 指著 `arcrun-kbdb`(要刪的)。CF 因此拒絕刪 `arcrun-kbdb`——
* 本工具當下**只會回報那次 DELETE 呼叫失敗**,不會主動告訴你「有一個外部依賴卡住了」,
* 需要人自己讀錯誤訊息裡的 worker 名字去追。
* ⇒ **第二層**kbdb-graph-plugin 被移除後 `arcrun-kbdb` 才刪得掉,但如果選擇「先清掉
* `arcrun-kbdb` 綁著的資源、留著這顆殼」這條路(半殘留),下次呼叫 `acr init``acr update`
* 時,`shared/resource-rule` 的 fail-closed 保護(Arcrun#97 那道)會正確地發現
* 「這顆 worker 的 binding 指向的資源已經找不到」而**拒絕重裝**(這是它該有的行為,不是
* bug;但代表**本工具留下的半殘留會讓下一步——重裝——卡住**,不是「拆一半也沒關係」)。
* ⇒ **結論**:本工具目前不會自動偵測或處理跨 worker 的依賴鏈;遇到「刪不掉、被誰誰誰的
* service binding 卡住」時,操作者要自己判斷那個「誰誰誰」是不是這台實例的一部分
* (是 → 一併排入拆除;不是 → 照紅線問人再決定要不要動它),**拆完後如果選了半殘留,
* 要有心理準備下一步的 fail-closed 檢查會擋下重裝**。
*
* - 🔴 **只拆「有人綁著的」,看不見孤兒資源**:資源清單是從「要刪的 worker 的 binding」反推的,
* 不是掃帳號。所以**沒有任何 worker 綁著的殘骸**(例:上一次安裝到一半死掉留下的 KV/D1)
* 本工具**不會列、也不會刪**。這個取捨是刻意的——它同時是「不誤刪」的來源
* 2026-08-14 youlin 上的 `arcrun-rag-drill-a``drill-b` 兩顆演練用 D1 因此原封不動)——
* 但代價是:拆完之後帳號上可能仍留著同名殘骸,重裝時撞 Arcrun#123(同名資源已存在)。
* ⇒ 拆完要重裝之前,**自己再列一次帳號**確認沒有同租戶前綴的孤兒。
*
* 用法:
* node scripts/teardown-instance.mjs plan --account-id <id> --api-token <token> [--keep w1,w2]
* node scripts/teardown-instance.mjs apply --account-id <id> --api-token <token> [--keep w1,w2] --yes
* node scripts/teardown-instance.mjs central-plan --account-id <id>
* node scripts/teardown-instance.mjs central-apply --account-id <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', 'youlinstage,可自由拆裝)'],
]);
// 明確擋掉的帳號(即使有人想放進 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.jsonmanifest.core[].name
// 併入已知清單。manifest.json 是公開端點、本工具的目的正是「重裝後長出一樣的東西」,
// 用它當第二個真相源與目的一致。抓不到(離線/端點掛了)就退回只用 repo 掃描,印警告,
// 不讓網路失敗擋住整個工具。
const DEFAULT_MANIFEST_URL =
'https://cdn.jsdelivr.net/gh/youlinhsieh/arcrun-rag-bundles@20d4ad6900f51e02242815402a70890fc5b12712/manifest.json';
/** @returns {Promise<string[]>} */
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<string>} */
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 名字。
*
* 🔴 一定要翻頁。漏看一顆 worker 在這支工具裡是**兩層**傷害:
* ① 那顆是實例的一部分卻沒被刪 ⇒ 拆不乾淨;
* ② 更糟——那顆若綁著某顆 KV/D1,「共用資源保護」就看不到這個 owner,
* 於是把還在用的資源判成「只有要刪的 worker 綁著」而**刪掉別人在用的東西**。
* (同款坑 ops-facts 記過:`cf-resource-api.mjs` 寫死 per_page=100 又不翻頁。)
*/
async function listAllScripts(api) {
const names = [];
for (let page = 1; ; page++) {
const { ok, result, error } = await api.cfRaw(`/workers/scripts?per_page=100&page=${page}`);
if (!ok) throw new Error(`列 worker scripts 失敗(page ${page}):${error}`);
const batch = /** @type {Array<{id:string}>} */ (result) ?? [];
for (const s of batch) names.push(s.id);
if (batch.length < 100) break;
}
return names;
}
/**
* 對帳號上每一顆 worker 查它的 binding。
* 回傳 workerBindingsworker 名字對應到 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。
//
// 🔴 2026-08-14 leo 審核裁示(D88「閘要長在機器上,不是長在誰的記性上」):這裡原本
// 靠操作者自己每次手動 `wrangler whoami` 核對——那個核對動作是對的,只是長錯地方
// (長在人身上)。失敗模式是最壞的一種:wrangler 的 OAuth 登入態如果哪天換成別的帳號,
// 會**靜默**對錯的帳號動中心 KV,沒有任何東西會叫。帳號側(youlin)有 ALLOWED_ACCOUNT_IDS
// 白名單擋,中心側原本完全沒有——這個不對稱本身就是設計缺口。
//
// ⚠️ 2026-08-14 稍晚訂正:上面那句原本寫「現在補齊」,**但當時只補了一半**——
// 補的是「以誰的身分動手」(wrangler 身分),沒補「動的是誰的紀錄」(accountId 白名單)。
// 實測證明破口是真的:`central-plan --account-id 51a01bfa…`leo21c)一路暢通,
// 直接列出 `deployed:51a01bfa…:leo21c`,再加 `--yes` 就會刪掉 leo 的安裝紀錄。
// ⇒ 現在 planCentralCleanup() 兩道都跑:assertAccountAllowed assertWranglerIsCentralAccount。
// 📌 教訓:**「補齊了」這句話要用實測講,不要用註解講**——註解說補齊,程式只補一半,
// 而後來讀的人(包括我)會相信註解。
// assertWranglerIsCentralAccount() 在任何中心 KV 讀寫前跑,核對 wrangler 現在的 OAuth
// 登入態的帳號清單裡有沒有 CENTRAL_ACCOUNT_ID,沒有就直接丟錯、不放行。
const CENTRAL_KV = {
prod: { id: '2ff9a845e7f646dab7b1e3dd6707b77f', label: 'INSTALLER_KVprod 通道)' },
staging: { id: '750c23faf0794d3db317c32f596df9a9', label: 'PEER_INSTALLER_KVstaging 通道,t157 雙通道問題)' },
};
const CENTRAL_ACCOUNT_ID = '58309bb90fd93ad6d0fe0aae99170e9d'; // uncle6
const CENTRAL_ACCOUNT_LABEL = "uncle6.me@gmail.com's Account";
/** 核對本機 wrangler 的 OAuth 登入態確實看得到 uncle6 帳號,不對就丟錯、不放行任何中心 KV 操作。 */
function assertWranglerIsCentralAccount() {
let out;
try {
out = execFileSync('npx', ['wrangler', 'whoami', '--json'], {
cwd: REPO_ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
} catch (e) {
throw new Error(
`拒絕執行中心 KV 操作:無法確認 wrangler 登入身分(\`wrangler whoami --json\` 失敗:` +
`${e instanceof Error ? e.message : e})。這是刪除工具,身分核不對就不准動中心 KV。`
);
}
const jsonStart = out.indexOf('{');
let who;
try {
who = JSON.parse(jsonStart >= 0 ? out.slice(jsonStart) : out);
} catch {
throw new Error(`拒絕執行中心 KV 操作:解析 \`wrangler whoami --json\` 輸出失敗:${out.slice(0, 300)}`);
}
if (!who.loggedIn) {
throw new Error('拒絕執行中心 KV 操作:wrangler 目前未登入(loggedIn:false)。');
}
const ids = (who.accounts ?? []).map((a) => a.id);
if (!ids.includes(CENTRAL_ACCOUNT_ID)) {
throw new Error(
`拒絕執行中心 KV 操作:wrangler 目前的 OAuth 登入態(${who.email ?? '未知 email'})看得到的帳號是` +
` [${ids.join(', ') || '無'}],裡面沒有中心帳號 ${CENTRAL_ACCOUNT_ID}${CENTRAL_ACCOUNT_LABEL})。` +
` 這代表你現在的 wrangler 登入態不是 uncle6——INSTALLER_KVPEER_INSTALLER_KV 只住在那個帳號,` +
` 繼續執行極可能是對錯的帳號動手(或單純連不到)。請先 \`npx wrangler login\` 切回 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) {
// 🔴 兩道,缺一不可,而且問的是**不同的問題**:
// ① assertAccountAllowed(accountId):「我要清掉的是**誰的**紀錄」——
// 中心 KV 裡每個帳號一筆,`deployed:<accountId>:`。少了這道,
// `central-apply --account-id <leo21c 的 id>` 會清掉 leo21c 的安裝紀錄,
// leo 下次重裝就會撞上 Arcrun#120 的死結。**帳號側擋得住 leo21c,中心側原本擋不住。**
// 2026-08-14 實測:`central-plan --account-id 51a01bfa…` 直接列出了
// `deployed:51a01bfa…:leo21c`,一路暢通。)
// ② assertWranglerIsCentralAccount():「我現在**以誰的身分**在動中心 KV」(D88 那道)。
// ①問對象、②問身分。原本只有②,於是「白名單」只保護了帳號側的一半路徑。
assertAccountAllowed(accountId);
assertWranglerIsCentralAccount();
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 <id> --api-token <token> [--keep w1,w2]
node scripts/teardown-instance.mjs apply --account-id <id> --api-token <token> [--keep w1,w2] --yes
node scripts/teardown-instance.mjs central-plan --account-id <id>
node scripts/teardown-instance.mjs central-apply --account-id <id> --yes`);
process.exit(1);
}
main().catch((e) => {
console.error('錯誤:', e instanceof Error ? e.message : e);
process.exit(1);
});