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)
})