diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts index 09441c9..f409cbd 100644 --- a/cli/src/commands/update.ts +++ b/cli/src/commands/update.ts @@ -20,7 +20,7 @@ import { type DeployContext, } from '../lib/deploy.js'; -export async function cmdUpdate(): Promise { +export async function cmdUpdate(opts: { force?: boolean } = {}): Promise { const config = loadConfig(); if (config.mode !== 'self-hosted') { @@ -81,7 +81,7 @@ export async function cmdUpdate(): Promise { d1DatabaseId: d1DatabaseId || undefined, }; - const result = await downloadAndDeploy(ctx); + const result = await downloadAndDeploy(ctx, 'main', { force: opts.force }); if (result.implemented) { // message 含部分失敗清單(「部署 X/Y 成功,N 失敗:✗ ...」)——必須印出來, diff --git a/cli/src/index.ts b/cli/src/index.ts index 5d5bc6f..9177a9f 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -176,7 +176,8 @@ program program .command('update') .description('self-hosted:拉新 release 並重新部署到你的 Cloudflare') - .action(() => cmdUpdate()); + .option('--force', '強制重部所有 worker(忽略未變動跳過快取)') + .action((opts: { force?: boolean }) => cmdUpdate({ force: opts.force })); // acr install-harness(把 arcrun 的 CC harness 裝進當前專案) program diff --git a/cli/src/lib/deploy.ts b/cli/src/lib/deploy.ts index 909760c..64aab4d 100644 --- a/cli/src/lib/deploy.ts +++ b/cli/src/lib/deploy.ts @@ -10,10 +10,59 @@ import { execFileSync } from 'node:child_process'; import { mkdtempSync, readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs'; -import { tmpdir } from 'node:os'; +import { tmpdir, homedir } from 'node:os'; import { join } from 'node:path'; +import { createHash } from 'node:crypto'; import chalk from 'chalk'; +/** 部署狀態 manifest:記錄上次成功部署每個 worker 的內容指紋(content hash), + * 讓 acr update 跳過未變動的 worker(壓測 2026-06-12:22/23 成功後重跑仍全部 + * pnpm install + wrangler deploy,22 個沒變的白跑)。存 ~/.arcrun/。 + * 指紋含 wrangler.toml 注入後的內容 → 換帳號/KV 會變更指紋 → 自動重部,不會誤跳。*/ +const MANIFEST_PATH = join(homedir(), '.arcrun', 'deploy-manifest.json'); + +function loadManifest(): Record { + try { + return JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as Record; + } catch { + return {}; + } +} + +function saveManifest(m: Record): void { + try { + writeFileSync(MANIFEST_PATH, JSON.stringify(m, null, 2)); + } catch { + /* manifest 寫失敗不致命:下次全部重部(退化成舊行為,不會錯,只是慢) */ + } +} + +/** 算一個 worker 目錄的內容指紋:遞迴 hash 所有檔案(排除 node_modules), + * 加上 accountId(換帳號要重部)。檔案路徑相對化後排序 → 跨機器/temp 目錄穩定。*/ +function dirContentHash(dir: string, accountId: string): string { + const h = createHash('sha256'); + h.update(accountId); + const walk = (d: string, rel: string): void => { + let entries: string[]; + try { entries = readdirSync(d).sort(); } catch { return; } + for (const name of entries) { + if (name === 'node_modules' || name === '.git') continue; + const full = join(d, name); + const relPath = rel ? `${rel}/${name}` : name; + let st; + try { st = statSync(full); } catch { continue; } + if (st.isDirectory()) { + walk(full, relPath); + } else { + h.update(relPath); + try { h.update(readFileSync(full)); } catch { /* skip unreadable */ } + } + } + }; + walk(dir, ''); + return h.digest('hex'); +} + /** GitHub repo(codeload tarball 來源)。fork 者改這裡或用 ARCRUN_REPO env。 * 注意:repo 名大小寫敏感(codeload 路徑需完全一致)。*/ const ARCRUN_REPO = process.env.ARCRUN_REPO ?? 'uncle6me-web/Arcrun'; @@ -82,7 +131,11 @@ export function wranglerAvailable(): boolean { * @param ctx 部署上下文 * @param ref git ref(branch / tag),預設 main;acr update 可帶 tag */ -export async function downloadAndDeploy(ctx: DeployContext, ref = 'main'): Promise { +export async function downloadAndDeploy( + ctx: DeployContext, + ref = 'main', + opts: { force?: boolean } = {}, +): Promise { // 1. 下載 + 解壓 codeload tarball let root: string; try { @@ -106,7 +159,11 @@ export async function downloadAndDeploy(ctx: DeployContext, ref = 'main'): Promi const allDirs = [...tier1, ...tier2]; const failures: string[] = []; let deployed = 0; - console.log(chalk.gray(` → 部署 ${allDirs.length} 個 worker(每個含 install + deploy,依序進行)...`)); + let skipped = 0; + // 內容指紋 manifest:未變動且上次成功的 worker 跳過(key 用 worker 名,不用 temp 絕對路徑)。 + // --force 清空 manifest → 全部重部。 + const manifest = opts.force ? {} : loadManifest(); + console.log(chalk.gray(` → 部署 ${allDirs.length} 個 worker(未變動者跳過,依序進行)...`)); for (let i = 0; i < allDirs.length; i++) { const dir = allDirs[i]; const tomlPath = join(dir, 'wrangler.toml'); @@ -114,14 +171,28 @@ export async function downloadAndDeploy(ctx: DeployContext, ref = 'main'): Promi process.stdout.write(chalk.gray(` [${i + 1}/${allDirs.length}] ${label} ...`)); try { injectWranglerConfig(tomlPath, ctx); + // 注入後算指紋:與 manifest 比,相同 = 上次成功部過且內容沒變 → 跳過。 + const hash = dirContentHash(dir, ctx.accountId); + if (manifest[label] === hash) { + skipped++; + console.log(chalk.gray(' ⊘ 未變動,跳過')); + continue; + } runWranglerDeploy(dir, ctx); + manifest[label] = hash; // 只在成功後記錄 → 失敗者下次必重試 + saveManifest(manifest); deployed++; console.log(chalk.green(' ✓')); } catch (e) { + delete manifest[label]; // 失敗 → 清掉舊指紋,確保下次重部 + saveManifest(manifest); failures.push(`${dir}: ${e instanceof Error ? e.message : String(e)}`); console.log(chalk.yellow(' ⚠')); } } + if (skipped > 0) { + console.log(chalk.gray(` (${skipped} 個未變動已跳過;要強制全部重部跑 acr update --force)`)); + } // 3.5 KBDB Base: D1 建好後套 migration(建三表 + recipe_stat seed)。 // 建 D1(cf-api ensureD1Database)只產生空資料庫,schema 要靠這步套。