diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index e7a6c7dee..94579d30f 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -167,6 +167,11 @@ jobs: fi cp release/bfmpay-dweb-beta.zip "release/bfmpay-dweb-${VERSION}-beta.zip" fi + + # Copy metadata.json for dweb://install link + if [ -f "dists/metadata.json" ]; then + cp dists/metadata.json release/metadata.json + fi # 上传 DWEB 到 SFTP 服务器 - name: Upload DWEB to SFTP @@ -234,7 +239,7 @@ jobs: ### DWEB 安装 在 DWEB 浏览器中打开以下链接安装: ``` - dweb://install?url=https://github.com/${{ github.repository }}/releases/download/${{ steps.channel.outputs.channel == 'stable' && format('v{0}', steps.version.outputs.version) || 'beta' }}/bfmpay-dweb${{ steps.channel.outputs.channel == 'beta' && '-beta' || '' }}.zip + dweb://install?url=https://github.com/${{ github.repository }}/releases/download/${{ steps.channel.outputs.channel == 'stable' && format('v{0}', steps.version.outputs.version) || 'beta' }}/metadata.json ``` # ==================== GitHub-hosted 标准构建 ==================== @@ -442,6 +447,11 @@ jobs: cp release/bfmpay-dweb-beta.zip "release/bfmpay-dweb-${VERSION}-beta.zip" fi + # Copy metadata.json for dweb://install link + if [ -f "dists/metadata.json" ]; then + cp dists/metadata.json release/metadata.json + fi + echo "=== Release artifacts ===" ls -la release/ @@ -530,5 +540,5 @@ jobs: ### DWEB 安装 在 DWEB 浏览器中打开以下链接安装: ``` - dweb://install?url=https://github.com/${{ github.repository }}/releases/download/${{ needs.build-standard.outputs.channel == 'stable' && format('v{0}', needs.build-standard.outputs.version) || 'beta' }}/bfmpay-dweb${{ needs.build-standard.outputs.channel == 'beta' && '-beta' || '' }}.zip + dweb://install?url=https://github.com/${{ github.repository }}/releases/download/${{ needs.build-standard.outputs.channel == 'stable' && format('v{0}', needs.build-standard.outputs.version) || 'beta' }}/metadata.json ``` diff --git a/docs/download.md b/docs/download.md index 1ca50ce80..1511b9ef1 100644 --- a/docs/download.md +++ b/docs/download.md @@ -41,7 +41,7 @@

DWEB 稳定版

经过充分测试的稳定版本,推荐日常使用。

- 安装到 DWEB + 安装到 DWEB 下载 ZIP 文件 @@ -51,7 +51,7 @@

DWEB 测试版

包含最新功能,每次代码更新自动发布。

- 安装 Beta 版 + 安装 Beta 版 下载 ZIP 文件 diff --git a/scripts/build.ts b/scripts/build.ts index d1ed22586..1309f1f52 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -322,6 +322,15 @@ async function createReleaseArtifacts(webDir: string, dwebDir: string) { const dwebVersionZipPath = join(releaseDir, `bfmpay-dweb-${version}${suffix}.zip`) cpSync(dwebZipPath, dwebVersionZipPath) + // 复制 metadata.json 到 release 目录(plaoc bundle 自动生成) + const metadataSrc = join(DISTS_DIR, 'metadata.json') + if (existsSync(metadataSrc)) { + cpSync(metadataSrc, join(releaseDir, 'metadata.json')) + log.info(` - metadata.json`) + } else { + log.warn('metadata.json 未找到,dweb://install 链接可能无法正常工作') + } + log.success(`Release 产物创建完成: ${releaseDir}`) log.info(` - ${webZipName}`) log.info(` - ${dwebZipName}`) diff --git a/scripts/set-secret.ts b/scripts/set-secret.ts index 1b598e6f9..bd7eb7582 100644 --- a/scripts/set-secret.ts +++ b/scripts/set-secret.ts @@ -1,192 +1,81 @@ #!/usr/bin/env bun -/** - * 交互式配置管理工具 - * - * 用法: - * pnpm set-secret # 交互式选择要配置的项目 - * pnpm set-secret --list # 列出当前配置状态 - * - * 支持配置: - * - E2E 测试账号(助记词、地址、安全密码) - * - DWEB 发布账号(SFTP 正式版/开发版) - * - * 配置目标: - * - 本地: .env.local - * - CI/CD: GitHub Secrets - */ import { execSync } from 'node:child_process' import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { select, checkbox, input, password, confirm } from '@inquirer/prompts' - -// ==================== 配置 ==================== const ROOT = process.cwd() const ENV_LOCAL_PATH = join(ROOT, '.env.local') -// 颜色输出 -const colors = { +const ANSI = { + clear: '\x1b[2J', + home: '\x1b[H', + hideCursor: '\x1b[?25l', + showCursor: '\x1b[?25h', + clearLine: '\x1b[2K', + moveTo: (row: number, col: number) => `\x1b[${row};${col}H`, + moveUp: (n: number) => `\x1b[${n}A`, + bold: '\x1b[1m', + dim: '\x1b[2m', reset: '\x1b[0m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m', - dim: '\x1b[2m', -} - -const log = { - info: (msg: string) => console.log(`${colors.blue}ℹ${colors.reset} ${msg}`), - success: (msg: string) => console.log(`${colors.green}✓${colors.reset} ${msg}`), - warn: (msg: string) => console.log(`${colors.yellow}⚠${colors.reset} ${msg}`), - error: (msg: string) => console.log(`${colors.red}✗${colors.reset} ${msg}`), + bgBlue: '\x1b[44m', + white: '\x1b[37m', } -// ==================== 配置项定义 ==================== - -interface SecretDefinition { +interface SecretField { key: string - description: string - category: string + label: string + isPassword: boolean required: boolean - isPassword?: boolean - validate?: (value: string) => string | true + validate?: (value: string) => string | null } -const SECRET_DEFINITIONS: SecretDefinition[] = [ - // E2E 测试 - { - key: 'E2E_TEST_MNEMONIC', - description: '测试钱包助记词(24个词)', - category: 'e2e', - required: true, - validate: (v) => { - const words = v.split(/\s+/).filter(Boolean) - if (words.length !== 24 && words.length !== 12) { - return `助记词应为 12 或 24 个词,当前: ${words.length} 个` - } - return true - }, - }, - { - key: 'E2E_TEST_ADDRESS', - description: '测试钱包地址(从助记词派生)', - category: 'e2e', - required: false, - }, - { - key: 'E2E_TEST_SECOND_SECRET', - description: '安全密码/二次密钥(如果账号设置了 secondPublicKey)', - category: 'e2e', - required: false, - isPassword: true, - }, - - // DWEB 发布 - 正式版 - { - key: 'DWEB_SFTP_USER', - description: 'SFTP 正式版用户名', - category: 'dweb-stable', - required: true, - }, - { - key: 'DWEB_SFTP_PASS', - description: 'SFTP 正式版密码', - category: 'dweb-stable', - required: true, - isPassword: true, - }, - - // DWEB 发布 - 开发版 - { - key: 'DWEB_SFTP_USER_DEV', - description: 'SFTP 开发版用户名', - category: 'dweb-dev', - required: true, - }, - { - key: 'DWEB_SFTP_PASS_DEV', - description: 'SFTP 开发版密码', - category: 'dweb-dev', - required: true, - isPassword: true, - }, - - // API Keys - { - key: 'TRONGRID_API_KEY', - description: 'TronGrid API Key(支持逗号分隔多个,启动时随机选中一个)', - category: 'api-keys', - required: false, - isPassword: true, - validate: (v) => { - if (!v) return true - const keys = v.split(',').map(k => k.trim()).filter(Boolean) - for (const key of keys) { - if (!/^[a-f0-9-]{36}$/i.test(key)) { - return `API Key 格式无效: ${key}(应为 UUID)` - } - } - return true - }, - }, - { - key: 'ETHERSCAN_API_KEY', - description: 'Etherscan API Key(支持逗号分隔多个,启动时随机选中一个)', - category: 'api-keys', - required: false, - isPassword: true, - validate: (v) => { - if (!v) return true - const keys = v.split(',').map(k => k.trim()).filter(Boolean) - for (const key of keys) { - if (!/^[A-Z0-9]{34}$/i.test(key)) { - return `API Key 格式无效: ${key}(应为 34 位字母数字)` - } - } - return true - }, - }, +const FIELDS: SecretField[] = [ + { key: 'DWEB_SFTP_USER', label: 'DWEB SFTP 用户名 (正式)', isPassword: false, required: true }, + { key: 'DWEB_SFTP_PASS', label: 'DWEB SFTP 密码 (正式)', isPassword: true, required: true }, + { key: 'DWEB_SFTP_USER_DEV', label: 'DWEB SFTP 用户名 (开发)', isPassword: false, required: false }, + { key: 'DWEB_SFTP_PASS_DEV', label: 'DWEB SFTP 密码 (开发)', isPassword: true, required: false }, + { key: 'E2E_TEST_MNEMONIC', label: 'E2E 测试助记词', isPassword: false, required: false, validate: validateMnemonic }, + { key: 'E2E_TEST_SECOND_SECRET', label: 'E2E 安全密码', isPassword: true, required: false }, + { key: 'TRONGRID_API_KEY', label: 'TronGrid API Key', isPassword: true, required: false }, + { key: 'ETHERSCAN_API_KEY', label: 'Etherscan API Key', isPassword: true, required: false }, ] -interface CategoryDefinition { - id: string - name: string - description: string +function validateMnemonic(v: string): string | null { + if (!v) return null + const words = v.split(/\s+/).filter(Boolean) + if (words.length !== 12 && words.length !== 24) { + return `需要 12 或 24 个词,当前 ${words.length} 个` + } + return null } -const CATEGORIES: CategoryDefinition[] = [ - { - id: 'e2e', - name: 'E2E 测试', - description: '端到端测试所需的测试钱包配置', - }, - { - id: 'dweb-stable', - name: 'DWEB 正式版发布', - description: 'SFTP 正式服务器账号(用于 pnpm release)', - }, - { - id: 'dweb-dev', - name: 'DWEB 开发版发布', - description: 'SFTP 开发服务器账号(用于日常 CI/CD)', - }, - { - id: 'api-keys', - name: 'API Keys', - description: '第三方 API 密钥(TronGrid 等)', - }, -] - -// ==================== 工具函数 ==================== +interface State { + cursor: number + values: Map + editing: boolean + editBuffer: string + editCursor: number + message: string + messageType: 'info' | 'error' | 'success' + localEnv: Map + ghSecrets: Set + hasGhCli: boolean + target: 'local' | 'github' | 'both' + saved: boolean +} -// Note: exec utility function available if needed -// function _exec(cmd: string, silent = false): string { ... } +function getTermSize(): { cols: number; rows: number } { + return { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 } +} function checkGhCli(): boolean { try { - execSync('gh --version', { stdio: 'pipe' }) execSync('gh auth status', { stdio: 'pipe' }) return true } catch { @@ -194,272 +83,281 @@ function checkGhCli(): boolean { } } -function getGitHubSecrets(): Map { - const secrets = new Map() +function getGitHubSecrets(): Set { + const secrets = new Set() try { const output = execSync('gh secret list', { encoding: 'utf-8', stdio: 'pipe' }) for (const line of output.split('\n')) { - const [name, updatedAt] = line.split('\t') - if (name) { - secrets.set(name.trim(), updatedAt?.trim() || '') - } + const name = line.split('\t')[0]?.trim() + if (name) secrets.add(name) } - } catch { - // gh cli not available or not authenticated - } + } catch {} return secrets } function getLocalEnv(): Map { const env = new Map() if (!existsSync(ENV_LOCAL_PATH)) return env - const content = readFileSync(ENV_LOCAL_PATH, 'utf-8') for (const line of content.split('\n')) { - const match = line.match(/^([A-Z_]+)="(.*)"\s*$/) - if (match) { - env.set(match[1], match[2]) - } + const match = line.match(/^([A-Z_][A-Z0-9_]*)="(.*)"$/) + if (match) env.set(match[1], match[2]) } return env } -function updateLocalEnv(updates: Map): void { - let content = '' - if (existsSync(ENV_LOCAL_PATH)) { - content = readFileSync(ENV_LOCAL_PATH, 'utf-8') - } - - for (const [key, value] of updates) { - const regex = new RegExp(`^${key}=".*"\\s*$`, 'm') +function saveLocalEnv(values: Map): void { + let content = existsSync(ENV_LOCAL_PATH) ? readFileSync(ENV_LOCAL_PATH, 'utf-8') : '' + for (const [key, value] of values) { + if (!value) continue + const regex = new RegExp(`^${key}=".*"$`, 'm') const newLine = `${key}="${value}"` - if (regex.test(content)) { content = content.replace(regex, newLine) } else { content = content.trimEnd() + '\n' + newLine + '\n' } } - writeFileSync(ENV_LOCAL_PATH, content) } -async function setGitHubSecret(key: string, value: string): Promise { - try { - execSync(`gh secret set ${key} --body "${value.replace(/"/g, '\\"')}"`, { - cwd: ROOT, - stdio: 'pipe', - }) - return true - } catch { - return false +function saveGitHubSecrets(values: Map): number { + let count = 0 + for (const [key, value] of values) { + if (!value) continue + try { + execSync(`gh secret set ${key}`, { input: value, stdio: ['pipe', 'pipe', 'pipe'] }) + count++ + } catch {} } + return count } -// ==================== 地址派生 ==================== - -async function deriveAddress(mnemonic: string): Promise { - try { - const { getBioforestCore, setGenesisBaseUrl } = await import('../src/services/bioforest-sdk/index.js') - - const genesisPath = `file://${join(ROOT, 'public/configs/genesis')}` - setGenesisBaseUrl(genesisPath, { with: { type: 'json' } }) - - const core = await getBioforestCore('bfmeta') - const accountHelper = core.accountBaseHelper() - return await accountHelper.getAddressFromSecret(mnemonic) - } catch (error) { - log.warn(`无法派生地址: ${error instanceof Error ? error.message : error}`) - return '' - } +function maskValue(v: string, isPassword: boolean): string { + if (!v) return '' + if (isPassword) return '•'.repeat(Math.min(v.length, 16)) + if (v.length > 20) return v.slice(0, 17) + '...' + return v } -// ==================== 状态显示 ==================== +function render(state: State): void { + const { cols } = getTermSize() + const width = Math.min(cols - 4, 76) + let out = ANSI.home + ANSI.hideCursor + + const title = ' BFM Pay 配置管理 ' + const padTitle = Math.floor((width - Bun.stringWidth(title)) / 2) + out += `${ANSI.cyan}┌${'─'.repeat(width)}┐${ANSI.reset}\n` + out += `${ANSI.cyan}│${' '.repeat(padTitle)}${ANSI.bold}${title}${ANSI.reset}${ANSI.cyan}${' '.repeat(width - padTitle - Bun.stringWidth(title))}│${ANSI.reset}\n` + out += `${ANSI.cyan}├${'─'.repeat(width)}┤${ANSI.reset}\n` + + const targetLabel = state.target === 'both' ? 'Local + GitHub' : state.target === 'local' ? 'Local only' : 'GitHub only' + out += `${ANSI.cyan}│${ANSI.reset} Target: ${ANSI.yellow}${targetLabel}${ANSI.reset}${' '.repeat(width - 10 - targetLabel.length)}${ANSI.cyan}│${ANSI.reset}\n` + out += `${ANSI.cyan}├${'─'.repeat(width)}┤${ANSI.reset}\n` + + for (let i = 0; i < FIELDS.length; i++) { + const field = FIELDS[i] + const isSelected = i === state.cursor + const value = state.values.get(field.key) || '' + const hasLocal = state.localEnv.has(field.key) + const hasGh = state.ghSecrets.has(field.key) + + const prefix = isSelected ? `${ANSI.cyan}▸${ANSI.reset}` : ' ' + const labelWidth = 28 + const label = field.label.padEnd(labelWidth).slice(0, labelWidth) + + let displayValue: string + if (state.editing && isSelected) { + const buf = field.isPassword ? '•'.repeat(state.editBuffer.length) : state.editBuffer + displayValue = `[${buf}█]` + } else { + const masked = maskValue(value, field.isPassword) + displayValue = value ? `[${masked}]` : `[${ANSI.dim}空${ANSI.reset}]` + } -async function showStatus(): Promise { - console.log(` -${colors.cyan}╔════════════════════════════════════════╗ -║ 配置状态 ║ -╚════════════════════════════════════════╝${colors.reset} -`) + const localStatus = hasLocal || value ? `${ANSI.green}L${ANSI.reset}` : `${ANSI.dim}·${ANSI.reset}` + const ghStatus = !state.hasGhCli ? `${ANSI.dim}?${ANSI.reset}` : hasGh || value ? `${ANSI.green}G${ANSI.reset}` : `${ANSI.dim}·${ANSI.reset}` + const reqMark = field.required ? `${ANSI.red}*${ANSI.reset}` : ' ' - const localEnv = getLocalEnv() - const ghSecrets = getGitHubSecrets() - const hasGhCli = checkGhCli() - - for (const category of CATEGORIES) { - console.log(`\n${colors.blue}▸ ${category.name}${colors.reset} ${colors.dim}(${category.description})${colors.reset}`) + const lineContent = `${prefix} ${reqMark}${label} ${displayValue} ${localStatus}${ghStatus}` + const lineLen = 3 + 1 + labelWidth + 1 + Bun.stringWidth(displayValue.replace(/\x1b\[[0-9;]*m/g, '')) + 3 + const pad = Math.max(0, width - lineLen) + out += `${ANSI.cyan}│${ANSI.reset}${lineContent}${' '.repeat(pad)}${ANSI.cyan}│${ANSI.reset}\n` + } - const secrets = SECRET_DEFINITIONS.filter((s) => s.category === category.id) - for (const secret of secrets) { - const localValue = localEnv.get(secret.key) - const ghValue = ghSecrets.get(secret.key) + out += `${ANSI.cyan}├${'─'.repeat(width)}┤${ANSI.reset}\n` - const localStatus = localValue - ? `${colors.green}✓${colors.reset}` - : `${colors.dim}✗${colors.reset}` + const msgColor = state.messageType === 'error' ? ANSI.red : state.messageType === 'success' ? ANSI.green : ANSI.dim + const msgText = state.message || '↑↓ 移动 Enter 编辑 K 保留 D 清空 S 保存 T 切换目标 Q 退出' + const msgLen = Bun.stringWidth(msgText.replace(/\x1b\[[0-9;]*m/g, '')) + out += `${ANSI.cyan}│${ANSI.reset} ${msgColor}${msgText}${ANSI.reset}${' '.repeat(Math.max(0, width - msgLen - 2))}${ANSI.cyan}│${ANSI.reset}\n` + out += `${ANSI.cyan}└${'─'.repeat(width)}┘${ANSI.reset}\n` - const ghStatus = !hasGhCli - ? `${colors.dim}?${colors.reset}` - : ghValue - ? `${colors.green}✓${colors.reset}` - : `${colors.dim}✗${colors.reset}` + process.stdout.write(out) +} - console.log( - ` ${secret.key.padEnd(25)} Local: ${localStatus} GitHub: ${ghStatus} ${colors.dim}${secret.description}${colors.reset}`, - ) - } +async function run(): Promise { + const args = process.argv.slice(2) + if (args.includes('--list') || args.includes('-l')) { + showListMode() + return } - if (!hasGhCli) { - console.log(`\n${colors.yellow}⚠ GitHub CLI 未安装或未登录,无法显示 GitHub Secrets 状态${colors.reset}`) - console.log(` 安装: brew install gh && gh auth login`) + const state: State = { + cursor: 0, + values: new Map(), + editing: false, + editBuffer: '', + editCursor: 0, + message: '', + messageType: 'info', + localEnv: getLocalEnv(), + ghSecrets: getGitHubSecrets(), + hasGhCli: checkGhCli(), + target: 'both', + saved: false, } - console.log('') -} - -// ==================== 配置流程 ==================== - -async function configureCategory(categoryId: string, target: 'local' | 'github' | 'both'): Promise { - const category = CATEGORIES.find((c) => c.id === categoryId) - if (!category) return + for (const field of FIELDS) { + const existing = state.localEnv.get(field.key) + if (existing) state.values.set(field.key, existing) + } - console.log(`\n${colors.cyan}▸ 配置 ${category.name}${colors.reset}\n`) + process.stdout.write(ANSI.clear) + process.stdin.setRawMode(true) + process.stdin.resume() - const secrets = SECRET_DEFINITIONS.filter((s) => s.category === categoryId) - const values = new Map() + render(state) - for (const secret of secrets) { - let value: string + process.stdin.on('data', (data: Buffer) => { + const key = data.toString() - if (secret.isPassword) { - value = await password({ - message: `${secret.description}:`, - }) + if (state.editing) { + handleEditMode(state, key) } else { - value = await input({ - message: `${secret.description}:`, - validate: (v) => { - if (secret.required && !v.trim()) { - return '此项必填' - } - if (secret.validate) { - return secret.validate(v) - } - return true - }, - }) + handleNavigationMode(state, key) } - if (value) { - values.set(secret.key, value) - - // 特殊处理:从助记词派生地址 - if (secret.key === 'E2E_TEST_MNEMONIC') { - log.info('派生地址...') - const address = await deriveAddress(value) - if (address) { - values.set('E2E_TEST_ADDRESS', address) - log.success(`地址: ${address}`) - } - } - } - } - - // 保存到本地 - if (target === 'local' || target === 'both') { - updateLocalEnv(values) - log.success(`已更新 .env.local`) - } + render(state) + }) - // 保存到 GitHub - if (target === 'github' || target === 'both') { - if (!checkGhCli()) { - log.error('GitHub CLI 未安装或未登录') - log.info('安装: brew install gh && gh auth login') - return - } + process.on('exit', () => { + process.stdout.write(ANSI.showCursor) + process.stdin.setRawMode(false) + }) +} - for (const [key, value] of values) { - const ok = await setGitHubSecret(key, value) - if (ok) { - log.success(`GitHub Secret: ${key}`) - } else { - log.error(`GitHub Secret: ${key} 设置失败`) +function handleEditMode(state: State, key: string): void { + if (key === '\r' || key === '\n') { + const field = FIELDS[state.cursor] + if (field.validate) { + const err = field.validate(state.editBuffer) + if (err) { + state.message = err + state.messageType = 'error' + return } } + state.values.set(field.key, state.editBuffer) + state.editing = false + state.message = `${field.key} 已更新` + state.messageType = 'success' + } else if (key === '\x1b') { + state.editing = false + state.editBuffer = '' + state.message = '取消编辑' + state.messageType = 'info' + } else if (key === '\x7f') { + state.editBuffer = state.editBuffer.slice(0, -1) + } else if (key.length === 1 && key.charCodeAt(0) >= 32) { + state.editBuffer += key } } -// ==================== 主程序 ==================== +function handleNavigationMode(state: State, key: string): void { + state.message = '' + state.messageType = 'info' + + if (key === '\x1b[A' || key === 'k') { + state.cursor = Math.max(0, state.cursor - 1) + } else if (key === '\x1b[B' || key === 'j') { + state.cursor = Math.min(FIELDS.length - 1, state.cursor + 1) + } else if (key === '\r' || key === '\n' || key === 'e') { + state.editing = true + state.editBuffer = state.values.get(FIELDS[state.cursor].key) || '' + state.message = 'Enter 确认 | Esc 取消' + state.messageType = 'info' + } else if (key === 'd' || key === 'D') { + const field = FIELDS[state.cursor] + state.values.delete(field.key) + state.message = `${field.key} 已清空` + state.messageType = 'info' + } else if (key === 't' || key === 'T') { + if (state.target === 'both') state.target = 'local' + else if (state.target === 'local') state.target = 'github' + else state.target = 'both' + } else if (key === 's' || key === 'S') { + save(state) + } else if (key === 'q' || key === 'Q' || key === '\x03') { + process.stdout.write(ANSI.showCursor + '\n') + if (!state.saved && state.values.size > 0) { + console.log(`${ANSI.yellow}未保存的更改已丢弃${ANSI.reset}`) + } + process.exit(0) + } +} -async function main(): Promise { - const args = process.argv.slice(2) +function save(state: State): void { + let localCount = 0 + let ghCount = 0 - // 显示状态 - if (args.includes('--list') || args.includes('-l')) { - await showStatus() - return + if (state.target === 'local' || state.target === 'both') { + saveLocalEnv(state.values) + localCount = state.values.size } - console.log(` -${colors.cyan}╔════════════════════════════════════════╗ -║ 配置管理工具 ║ -╚════════════════════════════════════════╝${colors.reset} -`) - - // 选择要配置的类别 - const selectedCategories = await checkbox({ - message: '选择要配置的项目 (空格选中,回车确认):', - choices: CATEGORIES.map((c) => ({ - value: c.id, - name: `${c.name} - ${c.description}`, - })), - required: true, - }) + if ((state.target === 'github' || state.target === 'both') && state.hasGhCli) { + ghCount = saveGitHubSecrets(state.values) + } - // 选择配置目标 - const target = await select({ - message: '配置保存到:', - choices: [ - { value: 'both' as const, name: '本地 + GitHub(推荐)' }, - { value: 'local' as const, name: '仅本地 (.env.local)' }, - { value: 'github' as const, name: '仅 GitHub Secrets' }, - ], - }) + state.saved = true + state.localEnv = getLocalEnv() + state.ghSecrets = getGitHubSecrets() - // 检查 GitHub CLI - if ((target === 'github' || target === 'both') && !checkGhCli()) { - log.error('GitHub CLI 未安装或未登录') - log.info('安装: brew install gh && gh auth login') + if (state.target === 'both') { + state.message = `已保存 Local:${localCount} GitHub:${ghCount}` + } else if (state.target === 'local') { + state.message = `已保存到 .env.local (${localCount} 项)` + } else { + state.message = `已保存到 GitHub Secrets (${ghCount} 项)` + } + state.messageType = 'success' +} - if (target === 'github') { - return - } +function showListMode(): void { + const localEnv = getLocalEnv() + const ghSecrets = getGitHubSecrets() + const hasGhCli = checkGhCli() - const continueLocal = await confirm({ - message: '是否仅配置本地?', - default: true, - }) + console.log(`\n${ANSI.cyan}${ANSI.bold}配置状态${ANSI.reset}\n`) - if (!continueLocal) { - return - } + for (const field of FIELDS) { + const hasLocal = localEnv.has(field.key) + const hasGh = ghSecrets.has(field.key) + const localStatus = hasLocal ? `${ANSI.green}✓${ANSI.reset}` : `${ANSI.dim}·${ANSI.reset}` + const ghStatus = !hasGhCli ? `${ANSI.dim}?${ANSI.reset}` : hasGh ? `${ANSI.green}✓${ANSI.reset}` : `${ANSI.dim}·${ANSI.reset}` + const reqMark = field.required ? `${ANSI.red}*${ANSI.reset}` : ' ' + console.log(` ${reqMark}${field.key.padEnd(25)} L:${localStatus} G:${ghStatus} ${ANSI.dim}${field.label}${ANSI.reset}`) } - // 逐个配置 - for (const categoryId of selectedCategories) { - await configureCategory(categoryId, target as 'local' | 'github' | 'both') + if (!hasGhCli) { + console.log(`\n${ANSI.yellow}⚠ GitHub CLI 未登录${ANSI.reset}`) } - - console.log(`\n${colors.green}✓ 配置完成!${colors.reset}\n`) - - // 显示最终状态 - await showStatus() + console.log() } -main().catch((error) => { - log.error(`配置失败: ${error.message}`) +run().catch((err) => { + process.stdout.write(ANSI.showCursor) + console.error(`${ANSI.red}错误: ${err.message}${ANSI.reset}`) process.exit(1) })