From 2ca7e7747d263ab0600d3f8b62808e9e4ee84b8f Mon Sep 17 00:00:00 2001 From: wuzf Date: Sat, 21 Mar 2026 19:52:02 +0800 Subject: [PATCH] feat(deploy): auto-detect and reuse existing KV namespace in deploy scripts --- scripts/deploy-config.js | 77 ++++++++++++++++ scripts/deploy.js | 137 +++++++++++----------------- tests/scripts/deploy-config.test.js | 84 +++++++++++++++++ 3 files changed, 215 insertions(+), 83 deletions(-) create mode 100644 scripts/deploy-config.js create mode 100644 tests/scripts/deploy-config.test.js diff --git a/scripts/deploy-config.js b/scripts/deploy-config.js new file mode 100644 index 0000000..05de3a0 --- /dev/null +++ b/scripts/deploy-config.js @@ -0,0 +1,77 @@ +export function injectWorkerVersion(configText, version) { + const updated = configText.replace( + /^(\s*SW_VERSION\s*=\s*)"[^"]*"(\s*)$/m, + `$1"${version}"$2` + ); + + if (updated === configText) { + throw new Error('在 wrangler.toml 中未找到 SW_VERSION 配置'); + } + + return updated; +} + +export function extractWorkerName(configText) { + const match = configText.match(/^name\s*=\s*"([^"]+)"/m); + return match ? match[1] : null; +} + +export function injectKvNamespaceId(configText, id) { + // Match the first [[kv_namespaces]] block that contains binding = "SECRETS_KV" + // and is NOT inside an [env.*] section. + const lines = configText.split('\n'); + let inEnvSection = false; + let kvBlockStart = -1; + let bindingLine = -1; + let existingIdLine = -1; + + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + + // Track whether we're inside an [env.*] section + if (/^\[env\./.test(trimmed)) { + inEnvSection = true; + continue; + } + if (/^\[(?!env\.)/.test(trimmed) || /^\[\[(?!env\.)/.test(trimmed)) { + inEnvSection = false; + } + + if (inEnvSection) continue; + + if (trimmed === '[[kv_namespaces]]') { + kvBlockStart = i; + bindingLine = -1; + existingIdLine = -1; + continue; + } + + if (kvBlockStart >= 0) { + // End of block: next section header or empty line after content + if (/^\[/.test(trimmed) || /^\[\[/.test(trimmed)) { + if (bindingLine >= 0) break; + kvBlockStart = -1; + continue; + } + + if (/^binding\s*=\s*"SECRETS_KV"/.test(trimmed)) { + bindingLine = i; + } + if (/^id\s*=\s*"/.test(trimmed)) { + existingIdLine = i; + } + } + } + + if (bindingLine < 0) { + return configText; + } + + if (existingIdLine >= 0) { + lines[existingIdLine] = `id = "${id}"`; + } else { + lines.splice(bindingLine + 1, 0, `id = "${id}"`); + } + + return lines.join('\n'); +} diff --git a/scripts/deploy.js b/scripts/deploy.js index db8b29a..d4261a5 100644 --- a/scripts/deploy.js +++ b/scripts/deploy.js @@ -16,19 +16,20 @@ */ import { execSync } from 'child_process'; -import { fileURLToPath } from 'url'; +import { readFileSync, writeFileSync } from 'fs'; import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +import { extractWorkerName, injectKvNamespaceId, injectWorkerVersion } from './deploy-config.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -// 解析命令行参数 const args = process.argv.slice(2); const versionStrategy = args.includes('--git') ? '--git' : - args.includes('--package') ? '--package' : - ''; + args.includes('--package') ? '--package' : + ''; -// 提取环境参数 const envIndex = args.indexOf('--env'); const envArg = envIndex !== -1 && args[envIndex + 1] ? `--env ${args[envIndex + 1]}` : ''; @@ -38,89 +39,34 @@ console.log(' 2FA Manager 自动化部署'); console.log('========================================'); console.log(''); -// Step 1: 生成版本号 -console.log('📦 Step 1: 生成 Service Worker 版本号...'); try { - const versionCmd = `node ${join(__dirname, 'generate-version.js')} ${versionStrategy} --verbose`; - const version = execSync(versionCmd, { encoding: 'utf-8' }).trim().split('\n')[0]; + const version = generateVersion(versionStrategy); + const wranglerPath = join(__dirname, '..', 'wrangler.toml'); + const originalConfig = readFileSync(wranglerPath, 'utf-8'); + console.log(` ✅ 版本号: ${version}`); console.log(''); - // Step 2: 临时修改 wrangler.toml console.log('📝 Step 2: 注入版本到配置...'); - const wranglerPath = join(__dirname, '..', 'wrangler.toml'); - // 读取原始配置 - const fs = await import('fs'); - const originalConfig = fs.readFileSync(wranglerPath, 'utf-8'); + let modifiedConfig = injectWorkerVersion(originalConfig, version); - let modifiedConfig = originalConfig; - - // 替换版本号 - modifiedConfig = modifiedConfig.replace( - /SW_VERSION = "v1"/, - `SW_VERSION = "${version}"` - ); - - // 检测 KV namespace 配置,自动查找或创建 - const hasKvBinding = /\[\[kv_namespaces\]\]\r?\nbinding = "SECRETS_KV"\r?\nid = "/.test(modifiedConfig); - if (!hasKvBinding) { - console.log(' 🔍 检测到 KV namespace 未配置,查找已有的...'); - - let kvId = null; - - // Step A: 先从已有的 KV namespace 中查找 - try { - const listOutput = execSync('npx wrangler kv namespace list', { encoding: 'utf-8' }); - const namespaces = JSON.parse(listOutput); - // 精确匹配 "SECRETS_KV"(deploy.js 创建的,用户数据在此) - const existing = namespaces.find((ns) => ns.title === 'SECRETS_KV'); - if (existing) { - kvId = existing.id; - console.log(` ✅ 找到已有 KV namespace "${existing.title}": ${kvId}`); - } - } catch { - console.log(' ⚠️ 查询 KV namespace 列表失败,尝试创建新的...'); - } - - // Step B: 没找到才创建 - if (!kvId) { - try { - console.log(' 📦 未找到已有 KV namespace,创建新的...'); - const kvOutput = execSync('npx wrangler kv namespace create SECRETS_KV', { - encoding: 'utf-8', - }); - const idMatch = kvOutput.match(/id = "([a-f0-9]+)"/); - if (idMatch) { - kvId = idMatch[1]; - console.log(` ✅ KV namespace 已创建: ${kvId}`); - } else { - console.warn(' ⚠️ 无法从输出中提取 KV ID,尝试继续部署...'); - } - } catch (kvError) { - console.error(' ❌ 创建 KV namespace 失败'); - throw kvError; - } - } - - // 注入 KV 配置到 wrangler.toml - if (kvId) { - const kvBlock = `[[kv_namespaces]]\nbinding = "SECRETS_KV"\nid = "${kvId}"`; - // 替换已有的注释块,或在 [vars] 前插入 - const commentedKvPattern = /# \[\[kv_namespaces\]\]\r?\n# binding = "SECRETS_KV"\r?\n(?:#[^\n]*\r?\n)*/; - if (commentedKvPattern.test(modifiedConfig)) { - modifiedConfig = modifiedConfig.replace(commentedKvPattern, kvBlock + '\n'); - } else { - modifiedConfig = modifiedConfig.replace(/(\[vars\])/, kvBlock + '\n\n$1'); - } - } - } - - fs.writeFileSync(wranglerPath, modifiedConfig, 'utf-8'); console.log(` ✅ 已注入版本: ${version}`); console.log(''); - // Step 3: 执行部署 + // Step 2.5: 自动检测并绑定已有 KV namespace,防止重复创建 + console.log('🔍 Step 2.5: 检测已有 KV namespace...'); + const existingKv = findExistingKvId(extractWorkerName(modifiedConfig)); + if (existingKv) { + modifiedConfig = injectKvNamespaceId(modifiedConfig, existingKv.id); + console.log(` ✅ 复用已有 KV: ${existingKv.title} (${existingKv.id})`); + } else { + console.log(' ℹ️ 未检测到已有 KV,将由 Wrangler 自动创建'); + } + console.log(''); + + writeFileSync(wranglerPath, modifiedConfig, 'utf-8'); + console.log('🚀 Step 3: 部署到 Cloudflare Workers...'); console.log(` 命令: npx wrangler deploy ${envArg}`.trim()); console.log(''); @@ -128,7 +74,7 @@ try { try { execSync(`npx wrangler deploy ${envArg}`.trim(), { stdio: 'inherit', - encoding: 'utf-8' + encoding: 'utf-8', }); console.log(''); @@ -139,7 +85,6 @@ try { console.log(`📦 版本: ${version}`); console.log(`🌐 环境: ${envArg || '生产环境 (production)'}`); console.log(''); - } catch (deployError) { console.error(''); console.error('❌ ========================================'); @@ -148,13 +93,11 @@ try { console.error(''); throw deployError; } finally { - // Step 4: 恢复原始配置 console.log('🔄 Step 4: 恢复配置文件...'); - fs.writeFileSync(wranglerPath, originalConfig, 'utf-8'); + writeFileSync(wranglerPath, originalConfig, 'utf-8'); console.log(' ✅ 配置已恢复'); console.log(''); } - } catch (error) { console.error(''); console.error('❌ 部署流程失败:'); @@ -162,3 +105,31 @@ try { console.error(''); process.exit(1); } + +function generateVersion(versionStrategyArg) { + console.log('📦 Step 1: 生成 Service Worker 版本号...'); + const versionCmd = `node ${join(__dirname, 'generate-version.js')} ${versionStrategyArg} --verbose`; + return execSync(versionCmd, { encoding: 'utf-8' }).trim().split('\n')[0]; +} + +function findExistingKvId(workerName) { + try { + const output = execSync('npx wrangler kv namespace list', { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + const namespaces = JSON.parse(output); + if (!namespaces.length) return null; + + // 按优先级匹配,覆盖 wrangler auto-provision、Dashboard 创建、老版本等命名格式 + const match = + namespaces.find(ns => ns.title === `${workerName}-SECRETS_KV`) || + namespaces.find(ns => ns.title.includes('SECRETS_KV')) || + namespaces.find(ns => ns.title === workerName) || + (namespaces.length === 1 ? namespaces[0] : null); + + return match ? { id: match.id, title: match.title } : null; + } catch { + return null; + } +} diff --git a/tests/scripts/deploy-config.test.js b/tests/scripts/deploy-config.test.js new file mode 100644 index 0000000..077ee84 --- /dev/null +++ b/tests/scripts/deploy-config.test.js @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; + +import { extractWorkerName, injectKvNamespaceId, injectWorkerVersion } from '../../scripts/deploy-config.js'; + +describe('injectWorkerVersion', () => { + it('replaces SW_VERSION without touching KV bindings', () => { + const config = `name = "2fa" +main = "src/worker.js" + +[[kv_namespaces]] +binding = "SECRETS_KV" + +[vars] +SW_VERSION = "v1" +`; + + const updated = injectWorkerVersion(config, 'v20260325-123456'); + + expect(updated).toContain('SW_VERSION = "v20260325-123456"'); + expect(updated).toContain('[[kv_namespaces]]\nbinding = "SECRETS_KV"'); + expect(updated.match(/\[\[kv_namespaces\]\]/g)).toHaveLength(1); + }); + + it('throws when SW_VERSION is missing', () => { + expect(() => injectWorkerVersion('[vars]\n', 'v20260325-123456')).toThrow( + '在 wrangler.toml 中未找到 SW_VERSION 配置' + ); + }); +}); + +describe('extractWorkerName', () => { + it('extracts name from config', () => { + expect(extractWorkerName('name = "2fa"\nmain = "src/worker.js"')).toBe('2fa'); + }); + + it('returns null when name is missing', () => { + expect(extractWorkerName('main = "src/worker.js"')).toBeNull(); + }); +}); + +describe('injectKvNamespaceId', () => { + const baseConfig = `name = "2fa" +main = "src/worker.js" + +[[kv_namespaces]] +binding = "SECRETS_KV" + +[vars] +SW_VERSION = "v1" + +[env.development] +name = "2fa-dev" + +[[env.development.kv_namespaces]] +binding = "SECRETS_KV" +`; + + it('inserts id when none exists', () => { + const result = injectKvNamespaceId(baseConfig, 'abc123'); + expect(result).toContain('binding = "SECRETS_KV"\nid = "abc123"'); + }); + + it('replaces existing id', () => { + const configWithId = baseConfig.replace( + 'binding = "SECRETS_KV"\n\n[vars]', + 'binding = "SECRETS_KV"\nid = "old-id"\n\n[vars]' + ); + const result = injectKvNamespaceId(configWithId, 'new-id'); + expect(result).toContain('id = "new-id"'); + expect(result).not.toContain('old-id'); + }); + + it('does not modify env.development kv_namespaces', () => { + const result = injectKvNamespaceId(baseConfig, 'abc123'); + // The env.development block should not have id injected + const devBlock = result.split('[[env.development.kv_namespaces]]')[1]; + expect(devBlock).not.toContain('id = "abc123"'); + }); + + it('returns config unchanged when no SECRETS_KV binding found', () => { + const noKvConfig = 'name = "2fa"\n[vars]\nSW_VERSION = "v1"\n'; + expect(injectKvNamespaceId(noKvConfig, 'abc123')).toBe(noKvConfig); + }); +});