From 8db9ef5fc9978d48312144d04e3ef577061e7c85 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 14:53:12 +0800 Subject: [PATCH 1/7] feat(build): implement SFTP upload for dweb builds - Add ssh2-sftp-client dependency - Create scripts/utils/sftp.ts with upload functionality - Implement uploadDweb() in build.ts with retry mechanism - Default SFTP URL: sftp://iweb.xin:22022 - Support DWEB_SFTP_USER, DWEB_SFTP_PASS env vars Tested: Successfully uploaded to iweb.xin:22022 --- package.json | 1 + pnpm-lock.yaml | 68 +++++++++++++++ scripts/build.ts | 33 +++++-- scripts/utils/sftp.ts | 198 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 294 insertions(+), 6 deletions(-) create mode 100644 scripts/utils/sftp.ts diff --git a/package.json b/package.json index a88be5338..f85cfff0e 100644 --- a/package.json +++ b/package.json @@ -161,6 +161,7 @@ "semver": "^7.7.3", "shadcn": "^3.6.1", "sharp": "^0.34.5", + "ssh2-sftp-client": "^12.0.1", "storybook": "^10.1.4", "tailwindcss": "^4.0.0", "turbo": "^2.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69921f728..0e598ea18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -303,6 +303,9 @@ importers: sharp: specifier: ^0.34.5 version: 0.34.5 + ssh2-sftp-client: + specifier: ^12.0.1 + version: 12.0.1 storybook: specifier: ^10.1.4 version: 10.1.10(@testing-library/dom@10.4.0)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -3989,6 +3992,9 @@ packages: bs58check@2.1.2: resolution: {integrity: sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA==} + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-more-ints@1.0.0: resolution: {integrity: sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg==} @@ -3998,6 +4004,10 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + buildcheck@0.0.7: + resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} + engines: {node: '>=10.0.0'} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -4182,6 +4192,10 @@ packages: component-inherit@0.0.3: resolution: {integrity: sha512-w+LhYREhatpVqTESyGFg3NlP6Iu0kEKUHETY9GoZP/pQyW4mHFZuFWRUCIqVPZ36ueVLtoOEZaAqbCF2RDndaA==} + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + conf@9.0.2: resolution: {integrity: sha512-rLSiilO85qHgaTBIIHQpsv8z+NnVfZq3cKuYNCXN1AOqPzced0GWZEe/A517VldRLyQYXUMyV+vszavE2jSAqw==} engines: {node: '>=10'} @@ -4252,6 +4266,10 @@ packages: typescript: optional: true + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + crc-32@1.2.2: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} @@ -5670,6 +5688,9 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nan@2.24.0: + resolution: {integrity: sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -6554,6 +6575,14 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + ssh2-sftp-client@12.0.1: + resolution: {integrity: sha512-ICJ1L2PmBel2Q2ctbyxzTFZCPKSHYYD6s2TFZv7NXmZDrDNGk8lHBb/SK2WgXLMXNANH78qoumeJzxlWZqSqWg==} + engines: {node: '>=18.20.4'} + + ssh2@1.17.0: + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} + engines: {node: '>=10.16.0'} + sshpk@1.18.0: resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} engines: {node: '>=0.10.0'} @@ -6925,6 +6954,9 @@ packages: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typeorm@0.3.20: resolution: {integrity: sha512-sJ0T08dV5eoZroaq9uPKBoNcGslHBR4E4y+EBHs//SiGbblGe7IeduP/IH4ddCcj0qp3PHwDwGnuvqEAnKlq/Q==} engines: {node: '>=16.13.0'} @@ -11329,6 +11361,8 @@ snapshots: create-hash: 1.2.0 safe-buffer: 5.2.1 + buffer-from@1.1.2: {} + buffer-more-ints@1.0.0: {} buffer-xor@1.0.3: {} @@ -11338,6 +11372,9 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buildcheck@0.0.7: + optional: true + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -11519,6 +11556,13 @@ snapshots: component-inherit@0.0.3: {} + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + conf@9.0.2: dependencies: ajv: 7.2.4 @@ -11584,6 +11628,12 @@ snapshots: optionalDependencies: typescript: 5.9.3 + cpu-features@0.0.10: + dependencies: + buildcheck: 0.0.7 + nan: 2.24.0 + optional: true + crc-32@1.2.2: {} create-hash@1.2.0: @@ -13074,6 +13124,9 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nan@2.24.0: + optional: true + nanoid@3.3.11: {} negotiator@1.0.0: {} @@ -14004,6 +14057,19 @@ snapshots: sprintf-js@1.0.3: {} + ssh2-sftp-client@12.0.1: + dependencies: + concat-stream: 2.0.0 + ssh2: 1.17.0 + + ssh2@1.17.0: + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.24.0 + sshpk@1.18.0: dependencies: asn1: 0.2.6 @@ -14373,6 +14439,8 @@ snapshots: es-errors: 1.3.0 is-typed-array: 1.1.15 + typedarray@0.0.6: {} + typeorm@0.3.20(redis@4.6.7): dependencies: '@sqltools/formatter': 1.2.5 diff --git a/scripts/build.ts b/scripts/build.ts index fd55fdddc..9251477f6 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -28,6 +28,7 @@ import { execSync } from 'node:child_process' import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, cpSync } from 'node:fs' import { join, resolve } from 'node:path' import { createWriteStream } from 'node:fs' +import { uploadToSftp } from './utils/sftp' // ==================== 配置 ==================== @@ -284,19 +285,39 @@ async function uploadDweb() { log.step('上传 DWEB 版本') - // 检查环境变量 - const sftpUrl = process.env.DWEB_SFTP_URL + // 检查环境变量(URL 有默认值) + const sftpUrl = process.env.DWEB_SFTP_URL || 'sftp://iweb.xin:22022' const sftpUser = process.env.DWEB_SFTP_USER const sftpPass = process.env.DWEB_SFTP_PASS - if (!sftpUrl || !sftpUser || !sftpPass) { + if (!sftpUser || !sftpPass) { log.warn('未配置 SFTP 环境变量,跳过上传') - log.info('请设置: DWEB_SFTP_URL, DWEB_SFTP_USER, DWEB_SFTP_PASS') + log.info('请设置: DWEB_SFTP_USER, DWEB_SFTP_PASS') return } - // TODO: 实现 SFTP 上传 - log.warn('SFTP 上传功能待实现') + // 确定上传目录:优先使用 plaoc 打包输出 (dists/),否则使用 dist-dweb + const uploadDir = existsSync(DISTS_DIR) && readdirSync(DISTS_DIR).length > 0 ? DISTS_DIR : DIST_DWEB_DIR + + if (!existsSync(uploadDir)) { + log.error(`上传目录不存在: ${uploadDir}`) + log.info('请先运行 dweb 构建') + return + } + + try { + await uploadToSftp({ + url: sftpUrl, + username: sftpUser, + password: sftpPass, + sourceDir: uploadDir, + projectName: 'bfmpay-dweb', + }) + log.success('DWEB 上传完成') + } catch (error) { + log.error(`DWEB 上传失败: ${error}`) + throw error + } } // ==================== 主程序 ==================== diff --git a/scripts/utils/sftp.ts b/scripts/utils/sftp.ts new file mode 100644 index 000000000..eca2c10e3 --- /dev/null +++ b/scripts/utils/sftp.ts @@ -0,0 +1,198 @@ +/** + * SFTP 上传工具 + * + * 用于将 dweb (plaoc) 打包产物上传到 SFTP 服务器 + */ + +import SftpClient from 'ssh2-sftp-client' +import { existsSync, readdirSync, statSync } from 'node:fs' +import { join, posix } from 'node:path' + +// 颜色输出 +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', +} + +const log = { + info: (msg: string) => console.log(`${colors.blue}i${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}`), +} + +/** + * SFTP 上传配置 + */ +export type SftpUploadConfig = { + /** SFTP URL,格式: sftp://host:port */ + url: string + /** 用户名 */ + username: string + /** 密码 */ + password: string + /** 本地源目录 */ + sourceDir: string + /** 项目名称(用于日志) */ + projectName: string +} + +/** + * 解析 SFTP URL + */ +function parseSftpUrl(url: string): { host: string; port: number } { + try { + const urlObj = new URL(url) + return { + host: urlObj.hostname, + port: urlObj.port ? parseInt(urlObj.port, 10) : 22, + } + } catch { + // 如果 URL 解析失败,尝试直接作为主机名处理 + return { host: url, port: 22 } + } +} + +/** + * 格式化文件大小 + */ +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB` + return `${(bytes / (1024 * 1024)).toFixed(2)} MB` +} + +/** + * 上传文件到 SFTP 服务器 + * + * @param config 上传配置 + * @param maxRetries 最大重试次数 + */ +export async function uploadToSftp(config: SftpUploadConfig, maxRetries = 3): Promise { + const { url, username, password, sourceDir, projectName } = config + + // 解析 SFTP URL + const { host, port } = parseSftpUrl(url) + + // 检查源目录是否存在 + if (!existsSync(sourceDir)) { + throw new Error(`源目录不存在: ${sourceDir}`) + } + + log.info(`准备上传 ${projectName} 到 SFTP 服务器: ${host}:${port}`) + log.info(`源目录: ${sourceDir}`) + log.info(`用户名: ${username}`) + + let lastError: Error | null = null + + // 重试机制 + for (let attempt = 1; attempt <= maxRetries; attempt++) { + const client = new SftpClient() + + try { + log.info(`尝试连接 SFTP 服务器 (${attempt}/${maxRetries})...`) + + await client.connect({ + host, + port, + username, + password, + readyTimeout: 30000, + retries: 3, + retry_factor: 2, + retry_minTimeout: 2000, + }) + + log.success(`SFTP 连接成功: ${host}`) + + // 获取当前工作目录 + const remoteDir = await client.cwd() + log.info(`远程工作目录: ${remoteDir}`) + + // 获取要上传的文件列表 + const entries = readdirSync(sourceDir, { withFileTypes: true }) + const files = entries.filter((f) => f.isFile()) + + if (files.length === 0) { + log.warn(`源目录中没有文件可上传: ${sourceDir}`) + return + } + + log.info(`开始上传 ${files.length} 个文件...`) + + let uploadedCount = 0 + + for (const file of files) { + const localPath = join(sourceDir, file.name) + const remotePath = posix.join(remoteDir, file.name) + const fileSize = statSync(localPath).size + + log.info(`上传: ${file.name} (${formatSize(fileSize)})`) + + // 单个文件上传重试 + let fileUploaded = false + for (let fileAttempt = 1; fileAttempt <= 3; fileAttempt++) { + try { + await client.fastPut(localPath, remotePath, { + step: (transferred: number, _chunk: number, total: number) => { + const percent = Math.round((transferred / total) * 100) + process.stdout.write(`\r 进度: ${percent}%`) + }, + }) + process.stdout.write('\n') + log.success(` ${file.name} 上传成功`) + uploadedCount++ + fileUploaded = true + break + } catch (fileError) { + log.warn(` ${file.name} 上传失败 (${fileAttempt}/3): ${fileError}`) + if (fileAttempt < 3) { + await new Promise((r) => setTimeout(r, 2000)) + } + } + } + + if (!fileUploaded) { + throw new Error(`文件 ${file.name} 上传失败,已重试 3 次`) + } + + // 短暂暂停,避免服务器过载 + await new Promise((r) => setTimeout(r, 500)) + } + + log.success(`SFTP 上传完成! 项目: ${projectName}, 成功: ${uploadedCount}/${files.length}`) + return + } catch (error) { + lastError = error as Error + log.error(`SFTP 上传失败 (${attempt}/${maxRetries}): ${error}`) + + if (attempt < maxRetries) { + const delaySeconds = attempt * 2 + log.info(`等待 ${delaySeconds} 秒后重试...`) + await new Promise((r) => setTimeout(r, delaySeconds * 1000)) + } + } finally { + try { + await client.end() + } catch { + // 忽略关闭连接时的错误 + } + } + } + + // 所有重试都失败了 + log.error(`SFTP 上传最终失败: ${projectName},已重试 ${maxRetries} 次`) + + // 提供故障排除建议 + console.log(`\n故障排除建议:`) + console.log(`1. 使用第三方工具验证 SFTP 连接`) + console.log(`2. 检查 SFTP 服务器状态和网络连接`) + console.log(`3. 确认 SFTP 用户有写入权限`) + console.log(`4. 检查防火墙和端口设置`) + console.log(`5. 尝试使用 FileZilla 等 SFTP 客户端手动测试`) + + throw lastError || new Error(`SFTP 上传失败`) +} From 39c8f23eb53498b51cab5766d1870cee361dc9e8 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 15:03:17 +0800 Subject: [PATCH 2/7] feat(build): add beta/stable channel support for SFTP upload - beta channel uses DWEB_SFTP_USER_DEV / DWEB_SFTP_PASS_DEV - stable channel uses DWEB_SFTP_USER / DWEB_SFTP_PASS - Add SFTP upload step to both self-hosted and github-hosted CD workflows - Add bun setup step for github-hosted runner --- .github/workflows/cd.yml | 35 +++++++++++++++++++++++++++++++++++ scripts/build.ts | 15 +++++++++++---- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index fb47ab37f..442df1c50 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -168,6 +168,21 @@ jobs: cp release/bfmpay-dweb-beta.zip "release/bfmpay-dweb-${VERSION}-beta.zip" fi + # 上传 DWEB 到 SFTP 服务器 + - name: Upload DWEB to SFTP + env: + CHANNEL: ${{ steps.channel.outputs.channel }} + DWEB_SFTP_USER: ${{ secrets.DWEB_SFTP_USER }} + DWEB_SFTP_PASS: ${{ secrets.DWEB_SFTP_PASS }} + DWEB_SFTP_USER_DEV: ${{ secrets.DWEB_SFTP_USER_DEV }} + DWEB_SFTP_PASS_DEV: ${{ secrets.DWEB_SFTP_PASS_DEV }} + run: | + if [[ "$CHANNEL" == "stable" ]]; then + bun scripts/build.ts dweb --upload --stable --skip-typecheck --skip-test + else + bun scripts/build.ts dweb --upload --skip-typecheck --skip-test + fi + # 直接推送到 gh-pages 分支,避免使用 upload-pages-artifact(self-hosted 上容易卡住) - name: Deploy to GitHub Pages env: @@ -250,6 +265,11 @@ jobs: node-version: ${{ env.NODE_VERSION }} cache: 'pnpm' + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Install dependencies run: pnpm install --frozen-lockfile @@ -414,6 +434,21 @@ jobs: echo "=== Release artifacts ===" ls -la release/ + # ===== 上传 DWEB 到 SFTP 服务器 ===== + - name: Upload DWEB to SFTP + env: + CHANNEL: ${{ steps.channel.outputs.channel }} + DWEB_SFTP_USER: ${{ secrets.DWEB_SFTP_USER }} + DWEB_SFTP_PASS: ${{ secrets.DWEB_SFTP_PASS }} + DWEB_SFTP_USER_DEV: ${{ secrets.DWEB_SFTP_USER_DEV }} + DWEB_SFTP_PASS_DEV: ${{ secrets.DWEB_SFTP_PASS_DEV }} + run: | + if [[ "$CHANNEL" == "stable" ]]; then + bun scripts/build.ts dweb --upload --stable --skip-typecheck --skip-test + else + bun scripts/build.ts dweb --upload --skip-typecheck --skip-test + fi + # ===== 上传构建产物 ===== - name: Upload GitHub Pages artifact uses: actions/upload-pages-artifact@v3 diff --git a/scripts/build.ts b/scripts/build.ts index 9251477f6..54ed6230b 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -283,19 +283,26 @@ async function uploadDweb() { return } - log.step('上传 DWEB 版本') + const channel = getChannel() + log.step(`上传 DWEB 版本 (${channel})`) // 检查环境变量(URL 有默认值) const sftpUrl = process.env.DWEB_SFTP_URL || 'sftp://iweb.xin:22022' - const sftpUser = process.env.DWEB_SFTP_USER - const sftpPass = process.env.DWEB_SFTP_PASS + + // 根据渠道选择账号: + // - stable: DWEB_SFTP_USER / DWEB_SFTP_PASS (正式版账号) + // - beta: DWEB_SFTP_USER_DEV / DWEB_SFTP_PASS_DEV (开发版账号) + const sftpUser = channel === 'stable' ? process.env.DWEB_SFTP_USER : (process.env.DWEB_SFTP_USER_DEV || process.env.DWEB_SFTP_USER) + const sftpPass = channel === 'stable' ? process.env.DWEB_SFTP_PASS : (process.env.DWEB_SFTP_PASS_DEV || process.env.DWEB_SFTP_PASS) if (!sftpUser || !sftpPass) { log.warn('未配置 SFTP 环境变量,跳过上传') - log.info('请设置: DWEB_SFTP_USER, DWEB_SFTP_PASS') + log.info(channel === 'stable' ? '请设置: DWEB_SFTP_USER, DWEB_SFTP_PASS' : '请设置: DWEB_SFTP_USER_DEV, DWEB_SFTP_PASS_DEV') return } + log.info(`SFTP 用户: ${sftpUser}`) + // 确定上传目录:优先使用 plaoc 打包输出 (dists/),否则使用 dist-dweb const uploadDir = existsSync(DISTS_DIR) && readdirSync(DISTS_DIR).length > 0 ? DISTS_DIR : DIST_DWEB_DIR From 2a3114a313a63e99f2f49ac4cd240dc7b949fc7f Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 15:10:04 +0800 Subject: [PATCH 3/7] feat(build): add dev version support with auto-versioning and watermark - Add auto dev version numbering: VERSION-dev.YYMMDDNO (e.g., 0.1.0-dev.26010401) - Add dev prefix to dweb package id (dev.bfmpay.bfmeta.com.dweb) - Upload dev builds to date-based directories (/YYMMDD/) - Add DevWatermark component for dev builds - Add VITE_DEV_MODE environment variable and __DEV_MODE__ global Tested: Successfully built and uploaded dev.bfmpay.bfmeta.com.dweb-0.1.0-dev.26010401 --- scripts/build.ts | 102 ++++++++++++++++----- scripts/utils/sftp.ts | 117 ++++++++++++++++++++++++- src/StackflowApp.tsx | 3 + src/components/common/DevWatermark.tsx | 52 +++++++++++ vite.config.ts | 2 + 5 files changed, 252 insertions(+), 24 deletions(-) create mode 100644 src/components/common/DevWatermark.tsx diff --git a/scripts/build.ts b/scripts/build.ts index 54ed6230b..cc0339bb2 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -28,7 +28,10 @@ import { execSync } from 'node:child_process' import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, cpSync } from 'node:fs' import { join, resolve } from 'node:path' import { createWriteStream } from 'node:fs' -import { uploadToSftp } from './utils/sftp' +import { uploadToSftp, getNextDevVersion, getTodayDateString } from './utils/sftp' + +// Dev 版本信息(在 buildDweb 时设置) +let devVersionInfo: { version: string; dateDir: string } | null = null // ==================== 配置 ==================== @@ -161,32 +164,87 @@ async function buildWeb() { } async function buildDweb() { - log.step('构建 DWEB 版本') + const channel = getChannel() + const isDev = channel === 'beta' + + log.step(`构建 DWEB 版本 (${channel})`) cleanDir(DIST_DWEB_DIR) cleanDir(DISTS_DIR) - // 使用 SERVICE_IMPL=dweb 构建 - exec('pnpm build:dweb', { - env: { - SERVICE_IMPL: 'dweb', - }, - }) + // 如果是 dev 版本,先获取版本号 + if (isDev && process.argv.includes('--upload')) { + const sftpUrl = process.env.DWEB_SFTP_URL || 'sftp://iweb.xin:22022' + const sftpUser = process.env.DWEB_SFTP_USER_DEV || process.env.DWEB_SFTP_USER + const sftpPass = process.env.DWEB_SFTP_PASS_DEV || process.env.DWEB_SFTP_PASS + + if (sftpUser && sftpPass) { + try { + const baseVersion = getVersion() + const info = await getNextDevVersion({ url: sftpUrl, username: sftpUser, password: sftpPass }, baseVersion) + devVersionInfo = { version: info.version, dateDir: info.dateDir } + log.info(`Dev 版本号: ${devVersionInfo.version}`) + } catch (error) { + log.warn(`获取 dev 版本号失败: ${error},使用默认版本号`) + } + } + } - // 移动到 dist-dweb - if (existsSync(DIST_DIR)) { - copyDir(DIST_DIR, DIST_DWEB_DIR) - rmSync(DIST_DIR, { recursive: true, force: true }) + // 如果是 dev 版本,修改 manifest.json + const manifestPath = join(ROOT, 'manifest.json') + const manifestBackupPath = join(ROOT, 'manifest.json.bak') + let manifestModified = false + + if (isDev && existsSync(manifestPath)) { + const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) + // 备份原始 manifest + writeFileSync(manifestBackupPath, JSON.stringify(manifest, null, 2)) + + // 修改 id 添加 dev 前缀 + const originalId = manifest.id + manifest.id = `dev.${originalId}` + + // 修改版本号 + if (devVersionInfo) { + manifest.version = devVersionInfo.version + } + + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)) + manifestModified = true + log.info(`manifest.json 已修改: id=${manifest.id}, version=${manifest.version}`) } - // 运行 plaoc bundle 打包 - log.step('运行 Plaoc 打包') try { - exec(`plaoc bundle "${DIST_DWEB_DIR}" -c ./ -o "${DISTS_DIR}"`) - log.success('Plaoc 打包完成') - } catch (error) { - log.warn('Plaoc 打包失败,可能未安装 plaoc CLI') - log.info('请安装: npm install -g @aspect/plaoc-cli') + // 使用 SERVICE_IMPL=dweb 构建,dev 模式添加水印标识 + exec('pnpm build:dweb', { + env: { + SERVICE_IMPL: 'dweb', + VITE_DEV_MODE: isDev ? 'true' : 'false', + }, + }) + + // 移动到 dist-dweb + if (existsSync(DIST_DIR)) { + copyDir(DIST_DIR, DIST_DWEB_DIR) + rmSync(DIST_DIR, { recursive: true, force: true }) + } + + // 运行 plaoc bundle 打包 + log.step('运行 Plaoc 打包') + try { + exec(`plaoc bundle "${DIST_DWEB_DIR}" -c ./ -o "${DISTS_DIR}"`) + log.success('Plaoc 打包完成') + } catch (error) { + log.warn('Plaoc 打包失败,可能未安装 plaoc CLI') + log.info('请安装: npm install -g @aspect/plaoc-cli') + } + } finally { + // 恢复原始 manifest.json + if (manifestModified && existsSync(manifestBackupPath)) { + cpSync(manifestBackupPath, manifestPath) + rmSync(manifestBackupPath) + log.info('manifest.json 已恢复') + } } log.success('DWEB 版本构建完成') @@ -313,12 +371,16 @@ async function uploadDweb() { } try { + // beta 渠道按日期分组上传 + const remoteSubDir = channel === 'beta' && devVersionInfo ? devVersionInfo.dateDir : undefined + await uploadToSftp({ url: sftpUrl, username: sftpUser, password: sftpPass, sourceDir: uploadDir, - projectName: 'bfmpay-dweb', + projectName: channel === 'beta' ? 'bfmpay-dweb-dev' : 'bfmpay-dweb', + remoteSubDir, }) log.success('DWEB 上传完成') } catch (error) { diff --git a/scripts/utils/sftp.ts b/scripts/utils/sftp.ts index eca2c10e3..b612e043e 100644 --- a/scripts/utils/sftp.ts +++ b/scripts/utils/sftp.ts @@ -25,19 +25,27 @@ const log = { } /** - * SFTP 上传配置 + * SFTP 连接配置 */ -export type SftpUploadConfig = { +export type SftpConnectionConfig = { /** SFTP URL,格式: sftp://host:port */ url: string /** 用户名 */ username: string /** 密码 */ password: string +} + +/** + * SFTP 上传配置 + */ +export type SftpUploadConfig = SftpConnectionConfig & { /** 本地源目录 */ sourceDir: string /** 项目名称(用于日志) */ projectName: string + /** 远程子目录(可选,用于按日期分组) */ + remoteSubDir?: string } /** @@ -65,6 +73,89 @@ function formatSize(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(2)} MB` } +/** + * 获取今天的日期字符串 YYMMDD + */ +export function getTodayDateString(): string { + const now = new Date() + const yy = String(now.getFullYear() % 100).padStart(2, '0') + const mm = String(now.getMonth() + 1).padStart(2, '0') + const dd = String(now.getDate()).padStart(2, '0') + return `${yy}${mm}${dd}` +} + +/** + * 获取下一个 dev 版本号 + * 格式: baseVersion-dev.YYMMDDNO + * 例如: 0.1.0-dev.26010401 + */ +export async function getNextDevVersion( + config: SftpConnectionConfig, + baseVersion: string, +): Promise<{ version: string; dateDir: string; buildNo: number }> { + const { url, username, password } = config + const { host, port } = parseSftpUrl(url) + + const dateStr = getTodayDateString() + const dateDir = dateStr // 日期目录 + + const client = new SftpClient() + + try { + await client.connect({ + host, + port, + username, + password, + readyTimeout: 30000, + }) + + const remoteDir = await client.cwd() + const dateDirPath = posix.join(remoteDir, dateDir) + + // 检查日期目录是否存在 + let existingFiles: string[] = [] + try { + const exists = await client.exists(dateDirPath) + if (exists) { + const list = await client.list(dateDirPath) + existingFiles = list.map((f) => f.name) + } + } catch { + // 目录不存在,使用空列表 + } + + // 查找今天已有的版本号 + // 文件名格式: dev.bfmpay.bfmeta.com.dweb-0.1.0-dev.26010401.zip + const pattern = new RegExp(`-dev\\.${dateStr}(\\d{2})\\.zip$`) + let maxNo = 0 + + for (const fileName of existingFiles) { + const match = fileName.match(pattern) + if (match) { + const no = parseInt(match[1], 10) + if (no > maxNo) { + maxNo = no + } + } + } + + const buildNo = maxNo + 1 + const buildNoStr = String(buildNo).padStart(2, '0') + const version = `${baseVersion}-dev.${dateStr}${buildNoStr}` + + log.info(`检测到今天已有 ${maxNo} 个版本,下一个版本号: ${version}`) + + return { version, dateDir, buildNo } + } finally { + try { + await client.end() + } catch { + // 忽略 + } + } +} + /** * 上传文件到 SFTP 服务器 * @@ -72,7 +163,7 @@ function formatSize(bytes: number): string { * @param maxRetries 最大重试次数 */ export async function uploadToSftp(config: SftpUploadConfig, maxRetries = 3): Promise { - const { url, username, password, sourceDir, projectName } = config + const { url, username, password, sourceDir, projectName, remoteSubDir } = config // 解析 SFTP URL const { host, port } = parseSftpUrl(url) @@ -85,6 +176,9 @@ export async function uploadToSftp(config: SftpUploadConfig, maxRetries = 3): Pr log.info(`准备上传 ${projectName} 到 SFTP 服务器: ${host}:${port}`) log.info(`源目录: ${sourceDir}`) log.info(`用户名: ${username}`) + if (remoteSubDir) { + log.info(`远程子目录: ${remoteSubDir}`) + } let lastError: Error | null = null @@ -109,9 +203,24 @@ export async function uploadToSftp(config: SftpUploadConfig, maxRetries = 3): Pr log.success(`SFTP 连接成功: ${host}`) // 获取当前工作目录 - const remoteDir = await client.cwd() + let remoteDir = await client.cwd() log.info(`远程工作目录: ${remoteDir}`) + // 如果指定了子目录,创建并切换到子目录 + if (remoteSubDir) { + const targetDir = posix.join(remoteDir, remoteSubDir) + try { + const exists = await client.exists(targetDir) + if (!exists) { + await client.mkdir(targetDir, true) + log.info(`创建目录: ${targetDir}`) + } + remoteDir = targetDir + } catch (mkdirError) { + log.warn(`创建目录失败: ${mkdirError},尝试继续上传到根目录`) + } + } + // 获取要上传的文件列表 const entries = readdirSync(sourceDir, { withFileTypes: true }) const files = entries.filter((f) => f.isFile()) diff --git a/src/StackflowApp.tsx b/src/StackflowApp.tsx index 6cc936914..eba0827c6 100644 --- a/src/StackflowApp.tsx +++ b/src/StackflowApp.tsx @@ -4,6 +4,7 @@ import { Stack } from './stackflow'; import { MiniappWindow, MiniappStackView } from './components/ecosystem'; import { miniappRuntimeStore, miniappRuntimeSelectors, closeStackView } from './services/miniapp-runtime'; import { MiniappVisualProvider } from './services/miniapp-runtime/MiniappVisualProvider'; +import { DevWatermark } from './components/common/DevWatermark'; export function StackflowApp() { const isStackViewOpen = useStore(miniappRuntimeStore, miniappRuntimeSelectors.isStackViewOpen); @@ -19,6 +20,8 @@ export function StackflowApp() { {/* Fallback 容器 - 当 slot lost 时保持 MiniappWindow 挂载 */}
+ {/* 开发版水印 */} + diff --git a/src/components/common/DevWatermark.tsx b/src/components/common/DevWatermark.tsx new file mode 100644 index 000000000..c785e0d09 --- /dev/null +++ b/src/components/common/DevWatermark.tsx @@ -0,0 +1,52 @@ +/** + * 开发版水印组件 + * + * 在开发版本中显示 "DEV" 水印,不影响页面交互 + */ + +declare const __DEV_MODE__: boolean + +export function DevWatermark() { + // 只在 dev 模式下显示 + if (!__DEV_MODE__) { + return null + } + + return ( +

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

- 打开 Beta 版 + 打开 Beta 版
diff --git a/docs/index.md b/docs/index.md index 5e2e7e757..e8ac0115e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -49,7 +49,7 @@ features: | 平台 | 稳定版 | 测试版 | |------|--------|--------| -| **Web** | webapp/ | webapp-beta/ | +| **Web** | webapp/ | webapp-dev/ | | **DWEB** | [下载页面](./download) | [下载页面](./download) | 查看 [下载页面](./download) 了解更多版本信息。 diff --git a/scripts/build.ts b/scripts/build.ts index cc0339bb2..8918708e6 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -20,7 +20,7 @@ * --upload 上传 dweb 版本到服务器 * * 渠道说明: - * beta: 部署到 /webapp-beta/,每次 push main 自动触发 + * beta: 部署到 /webapp-dev/,每次 push main 自动触发 * stable: 部署到 /webapp/,通过 pnpm gen:stable 手动触发 */ @@ -263,7 +263,7 @@ async function prepareGhPages(webDir: string) { log.step('准备 GitHub Pages 部署') const channel = getChannel() - const webappDirName = channel === 'stable' ? 'webapp' : 'webapp-beta' + const webappDirName = channel === 'stable' ? 'webapp' : 'webapp-dev' // 将当前构建复制到 docs/public 目录,供 VitePress 使用 const docsPublicWebapp = join(ROOT, 'docs', 'public', webappDirName) From 47546e5e8ebf166dc5e6a41dd27e81ddeeb751ef Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 15:26:15 +0800 Subject: [PATCH 7/7] feat(scripts): upgrade set-secret to interactive CLI - Support multiple configuration categories: - E2E testing (mnemonic, address, second secret) - DWEB stable release (SFTP credentials) - DWEB dev release (SFTP credentials) - Interactive category selection with checkbox - Choose target: local only, GitHub only, or both - Show configuration status with --list flag - Auto-derive address from mnemonic --- package.json | 1 + scripts/set-secret.ts | 546 ++++++++++++++++++++++++++++++------------ 2 files changed, 399 insertions(+), 148 deletions(-) diff --git a/package.json b/package.json index 93429b9b1..3b8b06432 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "docs:preview": "vitepress preview docs", "gen:stable": "bun scripts/gen-stable.ts", "release": "bun scripts/release.ts", + "set-secret": "bun scripts/set-secret.ts", "gen:logo": "bun scripts/gen-logo.ts", "i18n:extract": "bun scripts/i18n-extract.ts", "i18n:check": "turbo run i18n:run --", diff --git a/scripts/set-secret.ts b/scripts/set-secret.ts index 891b498c4..16259265e 100644 --- a/scripts/set-secret.ts +++ b/scripts/set-secret.ts @@ -1,187 +1,437 @@ #!/usr/bin/env bun /** - * 配置 E2E 测试密钥 - * + * 交互式配置管理工具 + * * 用法: - * bun scripts/set-secret.ts --local # 更新本地 .env.local - * bun scripts/set-secret.ts --ci # 配置 GitHub 仓库 secrets - * bun scripts/set-secret.ts --all # 同时配置两者 - * - * 需要输入: - * - 助记词(必需)- 自动派生地址 - * - 安全密码/二次密钥(可选)- 如果账号设置了 secondPublicKey - * - * 钱包锁在测试代码中固定,不需要配置 + * pnpm set-secret # 交互式选择要配置的项目 + * pnpm set-secret --list # 列出当前配置状态 + * + * 支持配置: + * - E2E 测试账号(助记词、地址、安全密码) + * - DWEB 发布账号(SFTP 正式版/开发版) + * + * 配置目标: + * - 本地: .env.local + * - CI/CD: GitHub Secrets */ -import { $ } from 'bun' -import * as fs from 'fs' -import * as path from 'path' -import * as readline from 'readline' +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' -async function prompt(question: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) +// ==================== 配置 ==================== + +const ROOT = process.cwd() +const ENV_LOCAL_PATH = join(ROOT, '.env.local') + +// 颜色输出 +const colors = { + 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}`), +} + +// ==================== 配置项定义 ==================== + +interface SecretDefinition { + key: string + description: string + category: string + required: boolean + isPassword?: boolean + validate?: (value: string) => string | true +} + +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, + }, +] + +interface CategoryDefinition { + id: string + name: string + description: string +} + +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)', + }, +] + +// ==================== 工具函数 ==================== + +function exec(cmd: string, silent = false): string { + try { + return execSync(cmd, { + cwd: ROOT, + encoding: 'utf-8', + stdio: silent ? 'pipe' : 'inherit', + }).trim() + } catch { + return '' + } +} + +function checkGhCli(): boolean { + try { + execSync('gh --version', { stdio: 'pipe' }) + execSync('gh auth status', { stdio: 'pipe' }) + return true + } catch { + return false + } +} - return new Promise((resolve) => { - rl.question(question, (answer) => { - rl.close() - resolve(answer.trim()) +function getGitHubSecrets(): Map { + const secrets = new Map() + 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() || '') + } + } + } catch { + // gh cli not available or not authenticated + } + 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]) + } + } + 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') + 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 + } } -/** - * 从助记词派生 BioForest 地址 - */ +// ==================== 地址派生 ==================== + async function deriveAddress(mnemonic: string): Promise { try { const { getBioforestCore, setGenesisBaseUrl } = await import('../src/services/bioforest-sdk/index.js') - - // 设置 genesis 文件的路径(Node.js 环境使用 file:// 协议) - const genesisPath = `file://${path.join(process.cwd(), 'public/configs/genesis')}` + + const genesisPath = `file://${join(ROOT, 'public/configs/genesis')}` setGenesisBaseUrl(genesisPath, { with: { type: 'json' } }) - - // 使用默认的 BioForest 主网配置 + const core = await getBioforestCore('bfmeta') const accountHelper = core.accountBaseHelper() - // 使用正确的 API: getAddressFromSecret return await accountHelper.getAddressFromSecret(mnemonic) } catch (error) { - console.error('⚠️ 无法派生地址:', error instanceof Error ? error.message : error) + log.warn(`无法派生地址: ${error instanceof Error ? error.message : error}`) return '' } } -interface SecretConfig { - mnemonic: string - address: string - secondSecret: string -} +// ==================== 状态显示 ==================== -async function promptSecrets(): Promise { - console.log('\n📝 配置 E2E 测试账号\n') - - // 1. 助记词(必需) - console.log('请输入测试钱包助记词:') - const mnemonic = await prompt('> ') - - if (!mnemonic) { - console.error('❌ 助记词不能为空') - process.exit(1) - } - - const words = mnemonic.split(/\s+/) - if (words.length !== 24 && words.length !== 12) { - console.error(`❌ 助记词应为 12 或 24 个词,当前: ${words.length} 个`) - process.exit(1) - } - - // 派生地址 - console.log('\n🔄 派生地址...') - const address = await deriveAddress(mnemonic) - if (address) { - console.log(`✅ 地址: ${address}`) - } - - // 2. 安全密码/二次密钥(可选) - console.log('\n请输入安全密码/二次密钥(如果账号已设置 secondPublicKey,否则直接回车跳过):') - const secondSecret = await prompt('> ') - - if (secondSecret) { - console.log('✅ 已配置安全密码') - } else { - console.log('ℹ️ 未配置安全密码(账号未设置或不需要)') - } - - return { mnemonic, address, secondSecret } -} +async function showStatus(): Promise { + console.log(` +${colors.cyan}╔════════════════════════════════════════╗ +║ 配置状态 ║ +╚════════════════════════════════════════╝${colors.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}`) -async function setLocal(config: SecretConfig): Promise { - const envPath = path.join(process.cwd(), '.env.local') - - const content = `# E2E 测试密钥 - 不要提交到 git -# 由 scripts/set-secret.ts 生成 + 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) -# 测试钱包助记词 -E2E_TEST_MNEMONIC="${config.mnemonic}" + const localStatus = localValue + ? `${colors.green}✓${colors.reset}` + : `${colors.dim}✗${colors.reset}` -# 派生地址 -${config.address ? `E2E_TEST_ADDRESS="${config.address}"` : '# E2E_TEST_ADDRESS=(派生失败)'} + const ghStatus = !hasGhCli + ? `${colors.dim}?${colors.reset}` + : ghValue + ? `${colors.green}✓${colors.reset}` + : `${colors.dim}✗${colors.reset}` -# 安全密码/二次密钥(如果账号设置了 secondPublicKey) -${config.secondSecret ? `E2E_TEST_SECOND_SECRET="${config.secondSecret}"` : '# E2E_TEST_SECOND_SECRET=(未设置)'} -` - - fs.writeFileSync(envPath, content) - console.log(`\n✅ 已更新 ${envPath}`) + console.log( + ` ${secret.key.padEnd(25)} Local: ${localStatus} GitHub: ${ghStatus} ${colors.dim}${secret.description}${colors.reset}`, + ) + } + } + + if (!hasGhCli) { + console.log(`\n${colors.yellow}⚠ GitHub CLI 未安装或未登录,无法显示 GitHub Secrets 状态${colors.reset}`) + console.log(` 安装: brew install gh && gh auth login`) + } + + console.log('') } -async function setCI(config: SecretConfig): Promise { - console.log('\n🔐 配置 GitHub secrets...\n') - - try { - await $`gh --version`.quiet() - } catch { - console.error('❌ 需要 GitHub CLI: brew install gh && gh auth login') - process.exit(1) +// ==================== 配置流程 ==================== + +async function configureCategory(categoryId: string, target: 'local' | 'github' | 'both'): Promise { + const category = CATEGORIES.find((c) => c.id === categoryId) + if (!category) return + + console.log(`\n${colors.cyan}▸ 配置 ${category.name}${colors.reset}\n`) + + const secrets = SECRET_DEFINITIONS.filter((s) => s.category === categoryId) + const values = new Map() + + for (const secret of secrets) { + let value: string + + if (secret.isPassword) { + value = await password({ + message: `${secret.description}:`, + }) + } else { + value = await input({ + message: `${secret.description}:`, + validate: (v) => { + if (secret.required && !v.trim()) { + return '此项必填' + } + if (secret.validate) { + return secret.validate(v) + } + return true + }, + }) + } + + 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}`) + } + } + } } - - try { - await $`gh auth status`.quiet() - } catch { - console.error('❌ 请先登录: gh auth login') - process.exit(1) - } - - const secrets: Record = { E2E_TEST_MNEMONIC: config.mnemonic } - if (config.address) secrets.E2E_TEST_ADDRESS = config.address - if (config.secondSecret) secrets.E2E_TEST_SECOND_SECRET = config.secondSecret - - for (const [key, value] of Object.entries(secrets)) { - try { - await $`echo ${value} | gh secret set ${key}`.quiet() - console.log(` ✅ ${key}`) - } catch (error) { - console.error(` ❌ ${key}: ${error}`) + + // 保存到本地 + if (target === 'local' || target === 'both') { + updateLocalEnv(values) + log.success(`已更新 .env.local`) + } + + // 保存到 GitHub + if (target === 'github' || target === 'both') { + if (!checkGhCli()) { + log.error('GitHub CLI 未安装或未登录') + log.info('安装: brew install gh && gh auth login') + return + } + + 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} 设置失败`) + } } } - - console.log('\n📋 当前 secrets:') - await $`gh secret list` } +// ==================== 主程序 ==================== + async function main(): Promise { const args = process.argv.slice(2) - const setLocalFlag = args.includes('--local') || args.includes('--all') - const setCIFlag = args.includes('--ci') || args.includes('--all') - - if (!setLocalFlag && !setCIFlag) { - console.log(` -配置 E2E 测试密钥 - -用法: - bun scripts/set-secret.ts --local 更新 .env.local - bun scripts/set-secret.ts --ci 配置 GitHub secrets - bun scripts/set-secret.ts --all 两者都配置 - -需要输入: - - 助记词(必需)- 自动派生地址 - - 安全密码/二次密钥(可选)- 如果账号设置了 secondPublicKey - -钱包锁在测试代码中固定,不需要配置。 + + // 显示状态 + if (args.includes('--list') || args.includes('-l')) { + await showStatus() + return + } + + console.log(` +${colors.cyan}╔════════════════════════════════════════╗ +║ 配置管理工具 ║ +╚════════════════════════════════════════╝${colors.reset} `) - process.exit(0) - } - - const config = await promptSecrets() - - if (setLocalFlag) await setLocal(config) - if (setCIFlag) await setCI(config) - - console.log('\n🎉 完成!') + + // 选择要配置的类别 + const selectedCategories = await checkbox({ + message: '选择要配置的项目:', + choices: CATEGORIES.map((c) => ({ + value: c.id, + name: `${c.name} - ${c.description}`, + })), + }) + + if (selectedCategories.length === 0) { + log.info('未选择任何配置项') + return + } + + // 选择配置目标 + 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' }, + ], + }) + + // 检查 GitHub CLI + if ((target === 'github' || target === 'both') && !checkGhCli()) { + log.error('GitHub CLI 未安装或未登录') + log.info('安装: brew install gh && gh auth login') + + if (target === 'github') { + return + } + + const continueLocal = await confirm({ + message: '是否仅配置本地?', + default: true, + }) + + if (!continueLocal) { + return + } + } + + // 逐个配置 + for (const categoryId of selectedCategories) { + await configureCategory(categoryId, target as 'local' | 'github' | 'both') + } + + console.log(`\n${colors.green}✓ 配置完成!${colors.reset}\n`) + + // 显示最终状态 + await showStatus() } -main().catch(console.error) +main().catch((error) => { + log.error(`配置失败: ${error.message}`) + process.exit(1) +})