c543abaa8a
leo 審核意見:「這條正好違反 D88——閘要長在機器上,不是長在誰的記性上。而且失敗模式 是最壞的那種:靜默打錯帳號的中心 KV,沒有任何東西會叫。帳號側有白名單擋、中心側完全 沒有——這個不對稱本身就是設計缺口。」 修法:assertWranglerIsCentralAccount() 在 planCentralCleanup()(central-plan/ central-apply 共用的入口)最前面跑 `wrangler whoami --json`,核對登入態的帳號清單裡 有 uncle6(58309bb90fd93ad6d0fe0aae99170e9d)才放行,沒有就丟錯拒絕整個操作。 已用模擬錯誤帳號 id 驗證比對邏輯正確拒絕;正常路徑(真的登入 uncle6)central-plan 照常運作,貼在同一次 commit 的測試輸出裡。 同時把 leo 裁示②(apply 的依賴序失敗不擋併、但要記實測輪數)補進「已知限制」段: 兩次實測收斂輪數分別是 2 輪(有外部依賴殘留時)與 3 輪(純內部 service binding 鏈時)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
497 lines
24 KiB
JavaScript
497 lines
24 KiB
JavaScript
#!/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_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 <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 檢查會擋下重裝**。
|
||
*
|
||
* 用法:
|
||
* 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', '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<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 名字。 */
|
||
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。
|
||
//
|
||
// 🔴 2026-08-14 leo 審核裁示(D88「閘要長在機器上,不是長在誰的記性上」):這裡原本
|
||
// 靠操作者自己每次手動 `wrangler whoami` 核對——那個核對動作是對的,只是長錯地方
|
||
// (長在人身上)。失敗模式是最壞的一種:wrangler 的 OAuth 登入態如果哪天換成別的帳號,
|
||
// 會**靜默**對錯的帳號動中心 KV,沒有任何東西會叫。帳號側(youlin)有 ALLOWED_ACCOUNT_IDS
|
||
// 白名單擋,中心側原本完全沒有——這個不對稱本身就是設計缺口,現在補齊。
|
||
// assertWranglerIsCentralAccount() 在任何中心 KV 讀寫前跑,核對 wrangler 現在的 OAuth
|
||
// 登入態的帳號清單裡有沒有 CENTRAL_ACCOUNT_ID,沒有就直接丟錯、不放行。
|
||
|
||
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
|
||
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_KV/PEER_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) {
|
||
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);
|
||
});
|