diff --git a/.env.example b/.env.example index 17122d1f8..a911ad3ce 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,24 @@ -# E2E 测试环境变量示例 +# 环境变量示例 # 复制此文件为 .env.local 并填入真实值 # .env.local 文件不会被提交到 git +# ==================== E2E 测试 ==================== + # 资金账号助记词 - 用于提供测试资金(24个中文词,空格分隔) E2E_TEST_MNEMONIC="词1 词2 词3 词4 词5 词6 词7 词8 词9 词10 词11 词12 词13 词14 词15 词16 词17 词18 词19 词20 词21 词22 词23 词24" # 钱包锁 E2E_TEST_PASSWORD="your-test-password" + +# ==================== DWEB 发布 ==================== + +# SFTP 服务器地址(默认: sftp://iweb.xin:22022) +# DWEB_SFTP_URL="sftp://iweb.xin:22022" + +# 正式版账号(用于 pnpm release 和 --stable 构建) +DWEB_SFTP_USER="keyapp" +DWEB_SFTP_PASS="your-password" + +# 开发版账号(用于日常 CI/CD 和 beta 构建) +DWEB_SFTP_USER_DEV="keyapp-dev" +DWEB_SFTP_PASS_DEV="your-dev-password" diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index fb47ab37f..e10db3ff9 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -111,16 +111,16 @@ jobs: fi # 准备 webapp 目录 - mkdir -p docs/public/webapp docs/public/webapp-beta + mkdir -p docs/public/webapp docs/public/webapp-dev if [[ "$CHANNEL" == "stable" ]]; then cp -r dist-web/* docs/public/webapp/ if gh release download --pattern 'bfmpay-web-beta.zip' --dir /tmp -R ${{ github.repository }} 2>/dev/null; then - unzip -q /tmp/bfmpay-web-beta.zip -d docs/public/webapp-beta/ + unzip -q /tmp/bfmpay-web-beta.zip -d docs/public/webapp-dev/ else - cp -r dist-web/* docs/public/webapp-beta/ + cp -r dist-web/* docs/public/webapp-dev/ fi else - cp -r dist-web/* docs/public/webapp-beta/ + cp -r dist-web/* docs/public/webapp-dev/ if gh release download --pattern 'bfmpay-web.zip' --dir /tmp -R ${{ github.repository }} 2>/dev/null; then unzip -q /tmp/bfmpay-web.zip -d docs/public/webapp/ else @@ -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: @@ -197,7 +212,7 @@ jobs: ### 在线访问 - Web 应用 (stable): https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/webapp/ - - Web 应用 (beta): https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/webapp-beta/ + - Web 应用 (beta): https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/webapp-dev/ - 文档首页: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/ ### DWEB 安装 @@ -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 @@ -314,20 +334,20 @@ jobs: echo "Copied current build to docs/public/webapp/ (stable)" # 尝试下载最新的 beta 版本(从 latest prerelease) - mkdir -p docs/public/webapp-beta + mkdir -p docs/public/webapp-dev if gh release download --pattern 'bfmpay-web-beta.zip' --dir /tmp -R ${{ github.repository }} 2>/dev/null; then - unzip -q /tmp/bfmpay-web-beta.zip -d docs/public/webapp-beta/ + unzip -q /tmp/bfmpay-web-beta.zip -d docs/public/webapp-dev/ echo "Downloaded beta version from release" else # 如果没有 beta release,使用当前构建 - cp -r dist-web/* docs/public/webapp-beta/ - echo "No beta release found, using current build for webapp-beta" + cp -r dist-web/* docs/public/webapp-dev/ + echo "No beta release found, using current build for webapp-dev" fi else - # beta 构建:当前版本放 webapp-beta/,尝试下载 stable - mkdir -p docs/public/webapp-beta - cp -r dist-web/* docs/public/webapp-beta/ - echo "Copied current build to docs/public/webapp-beta/ (beta)" + # beta 构建:当前版本放 webapp-dev/,尝试下载 stable + mkdir -p docs/public/webapp-dev + cp -r dist-web/* docs/public/webapp-dev/ + echo "Copied current build to docs/public/webapp-dev/ (beta)" # 尝试下载最新的 stable 版本 mkdir -p docs/public/webapp @@ -344,8 +364,8 @@ jobs: # 显示准备好的目录 echo "=== webapp directory ===" ls -la docs/public/webapp/ | head -5 - echo "=== webapp-beta directory ===" - ls -la docs/public/webapp-beta/ | head -5 + echo "=== webapp-dev directory ===" + ls -la docs/public/webapp-dev/ | head -5 # ===== 构建 Storybook ===== - name: Build Storybook @@ -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 @@ -470,7 +505,7 @@ jobs: ### 在线访问 - Web 应用 (stable): https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/webapp/ - - Web 应用 (beta): https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/webapp-beta/ + - Web 应用 (beta): https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/webapp-dev/ - 文档首页: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/ ### DWEB 安装 diff --git a/.gitignore b/.gitignore index 923d15b25..c9afb0a12 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,7 @@ storybook-static/ docs/.vitepress/dist docs/.vitepress/cache docs/public/webapp -docs/public/webapp-beta +docs/public/webapp-dev # dependencies node_modules diff --git a/CHAT.md b/CHAT.md index b2d846e17..1e9418cc6 100644 --- a/CHAT.md +++ b/CHAT.md @@ -28,7 +28,7 @@ PDR文件不该包含过多的软件工程技术细节吧,反而应该专注 我们的build.ts脚本所实现的持续集成功能,其实默认生成的是“beta”渠道版本: 1. 这是每次git-push到main分支就会自动触发的。 -2. beta版本的网页版链接应该是 `https://???.github.io/???/webapp-beta/` +2. beta版本的网页版链接应该是 `https://???.github.io/???/webapp-dev/` 1. 也就是说“stable”版本仍然在`https://???.github.io/???/webapp/`,这个我们可能需要从github-release找最近的一个stable版本 2. 如果要生成“stable”,那么需要本地执行`pnpm gen:stable`,这个脚本默认是在本地执行: 1. 它的目的是更新版本号,生成changelog,这是一个交互式的命令。 @@ -49,7 +49,7 @@ PDR文件不该包含过多的软件工程技术细节吧,反而应该专注 1. 我们需要vitepress作为我们页面的骨架 2. 不论是stable还是beta,默认都应该从github-release下载,如果下载不到,那么就使用本地构建的 -3. download页面可以下载各种渠道的版本,目前有4个:webapp-stable/webapp-beta/dwebapp-stable/dwebapp-beta +3. download页面可以下载各种渠道的版本,目前有4个:webapp-stable/webapp-dev/dwebapp-stable/dwebapp-dev --- diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index a494fff87..ef9823831 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -24,7 +24,7 @@ export default defineConfig({ base, // webapp/storybook 目录由 CI 动态生成,忽略死链接检查 - ignoreDeadLinks: [/\.\/webapp/, /\.\/webapp-beta/, /\.\/storybook/], + ignoreDeadLinks: [/\.\/webapp/, /\.\/webapp-dev/, /\.\/storybook/], head: [ ['link', { rel: 'icon', type: 'image/webp', href: '/logos/logo-64.webp' }], diff --git a/docs/.vitepress/plugins/webapp-loader.ts b/docs/.vitepress/plugins/webapp-loader.ts index dd1cfb21e..9dd0f6b43 100644 --- a/docs/.vitepress/plugins/webapp-loader.ts +++ b/docs/.vitepress/plugins/webapp-loader.ts @@ -2,7 +2,7 @@ * VitePress 插件:在 dev/build 前准备 webapp 目录 * * 流程: - * 1. 尝试从 GitHub Release 下载 webapp/webapp-beta + * 1. 尝试从 GitHub Release 下载 webapp/webapp-dev * 2. 下载失败则检查本地 dist-web 是否存在 * 3. 都没有则运行本地构建 */ @@ -161,7 +161,7 @@ async function prepareAllWebapps() { // 并行准备两个版本 await Promise.all([ prepareWebapp('stable', join(DOCS_PUBLIC, 'webapp')), - prepareWebapp('beta', join(DOCS_PUBLIC, 'webapp-beta')), + prepareWebapp('beta', join(DOCS_PUBLIC, 'webapp-dev')), ]) log.success('webapp 目录准备完成') @@ -186,8 +186,8 @@ export function webappLoaderPlugin(): Plugin { server.middlewares.use((req, res, next) => { const url = req.url || '' - // 处理 /webapp/ 和 /webapp-beta/ 路径 - const match = url.match(/^\/(webapp|webapp-beta)(\/.*)?$/) + // 处理 /webapp/ 和 /webapp-dev/ 路径 + const match = url.match(/^\/(webapp|webapp-dev)(\/.*)?$/) if (match) { const webappDir = match[1] const subPath = match[2] || '/' diff --git a/docs/contributing.md b/docs/contributing.md index 0173276ff..4f3d32efa 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -102,7 +102,7 @@ CI/CD 会自动构建以下产物: |------|------| | `/` | VitePress 文档站点 | | `/webapp/` | Web 应用(稳定版) | -| `/webapp-beta/` | Web 应用(测试版) | +| `/webapp-dev/` | Web 应用(测试版) | | `/storybook/` | Storybook 组件文档 | ## 开发规范 diff --git a/docs/download.md b/docs/download.md index 8f52924cd..1ca50ce80 100644 --- a/docs/download.md +++ b/docs/download.md @@ -22,7 +22,7 @@

Web 测试版

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

- 打开 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/package.json b/package.json index a88be5338..3b8b06432 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,8 @@ "docs:build": "vitepress build docs", "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 --", @@ -161,6 +163,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..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 手动触发 */ @@ -28,6 +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, getNextDevVersion, getTodayDateString } from './utils/sftp' + +// Dev 版本信息(在 buildDweb 时设置) +let devVersionInfo: { version: string; dateDir: string } | null = null // ==================== 配置 ==================== @@ -160,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 版本构建完成') @@ -204,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) @@ -282,21 +341,52 @@ 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 sftpUrl = process.env.DWEB_SFTP_URL - 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 (!sftpUrl || !sftpUser || !sftpPass) { + if (!sftpUser || !sftpPass) { log.warn('未配置 SFTP 环境变量,跳过上传') - log.info('请设置: DWEB_SFTP_URL, 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 } - // TODO: 实现 SFTP 上传 - log.warn('SFTP 上传功能待实现') + log.info(`SFTP 用户: ${sftpUser}`) + + // 确定上传目录:优先使用 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 { + // beta 渠道按日期分组上传 + const remoteSubDir = channel === 'beta' && devVersionInfo ? devVersionInfo.dateDir : undefined + + await uploadToSftp({ + url: sftpUrl, + username: sftpUser, + password: sftpPass, + sourceDir: uploadDir, + projectName: channel === 'beta' ? 'bfmpay-dweb-dev' : 'bfmpay-dweb', + remoteSubDir, + }) + log.success('DWEB 上传完成') + } catch (error) { + log.error(`DWEB 上传失败: ${error}`) + throw error + } } // ==================== 主程序 ==================== diff --git a/scripts/release.ts b/scripts/release.ts new file mode 100644 index 000000000..a3128eca8 --- /dev/null +++ b/scripts/release.ts @@ -0,0 +1,483 @@ +#!/usr/bin/env bun +/** + * BFM Pay 正式版发布脚本 + * + * 交互式脚本,完整发布流程: + * 1. 检查工作区状态 + * 2. 选择版本号(小版本/中版本/大版本/当前/自定义) + * 3. 运行类型检查和测试 + * 4. 构建 Web 和 DWEB 版本 + * 5. 上传 DWEB 到正式服务器 + * 6. 更新 package.json 和 manifest.json + * 7. 更新 CHANGELOG.md + * 8. 提交变更并打 tag + * 9. 推送触发 GitHub Pages 更新 + * + * Usage: + * pnpm release + */ + +import { execSync } from 'node:child_process' +import { existsSync, readFileSync, writeFileSync, cpSync, rmSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { confirm, select, input } from '@inquirer/prompts' +import semver from 'semver' + +// ==================== 配置 ==================== + +const ROOT = resolve(import.meta.dirname, '..') +const PACKAGE_JSON_PATH = join(ROOT, 'package.json') +const MANIFEST_JSON_PATH = join(ROOT, 'manifest.json') +const CHANGELOG_PATH = join(ROOT, 'CHANGELOG.md') + +// 颜色输出 +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', + bold: '\x1b[1m', +} + +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}`), + step: (msg: string) => console.log(`\n${colors.cyan}▸${colors.reset} ${colors.cyan}${msg}${colors.reset}`), +} + +// ==================== 工具函数 ==================== + +function exec(cmd: string, options?: { silent?: boolean; env?: Record }): string { + try { + const result = execSync(cmd, { + cwd: ROOT, + encoding: 'utf-8', + stdio: options?.silent ? 'pipe' : 'inherit', + env: { ...process.env, ...options?.env }, + }) + return typeof result === 'string' ? result.trim() : '' + } catch (error) { + if (options?.silent) { + return '' + } + throw error + } +} + +function execOutput(cmd: string): string { + return execSync(cmd, { cwd: ROOT, encoding: 'utf-8' }).trim() +} + +function readJson(path: string): T { + return JSON.parse(readFileSync(path, 'utf-8')) +} + +function writeJson(path: string, data: unknown) { + writeFileSync(path, JSON.stringify(data, null, 2) + '\n') +} + +// ==================== 检查函数 ==================== + +async function checkWorkspace(): Promise { + log.step('检查工作区状态') + + // 检查是否在 worktree 中 + const cwd = process.cwd() + if (cwd.includes('.git-worktree')) { + log.error('请在主目录中运行此脚本,不要在 worktree 中运行') + return false + } + + // 检查未提交的变更 + const status = execOutput('git status --porcelain') + if (status) { + log.warn('检测到未提交的变更:') + console.log(status) + + const shouldContinue = await confirm({ + message: '是否继续?(未提交的变更将被包含在发布中)', + default: false, + }) + + if (!shouldContinue) { + return false + } + } else { + log.success('工作区干净') + } + + // 检查当前分支 + const branch = execOutput('git branch --show-current') + if (branch !== 'main') { + log.warn(`当前分支: ${branch}(建议在 main 分支发布)`) + const shouldContinue = await confirm({ + message: '是否继续?', + default: false, + }) + if (!shouldContinue) { + return false + } + } else { + log.success(`当前分支: ${branch}`) + } + + return true +} + +// ==================== 版本选择 ==================== + +interface PackageJson { + version: string + lastChangelogCommit?: string + [key: string]: unknown +} + +interface ManifestJson { + version: string + change_log: string + [key: string]: unknown +} + +async function selectVersion(): Promise { + log.step('选择版本号') + + const pkg = readJson(PACKAGE_JSON_PATH) + const currentVersion = pkg.version + + console.log(`\n当前版本: ${colors.bold}${currentVersion}${colors.reset}\n`) + + const choice = await select({ + message: '请选择版本升级类型:', + choices: [ + { + value: 'patch', + name: `🔧 Patch (${currentVersion} → ${semver.inc(currentVersion, 'patch')}) - Bug 修复`, + }, + { + value: 'minor', + name: `✨ Minor (${currentVersion} → ${semver.inc(currentVersion, 'minor')}) - 新功能`, + }, + { + value: 'major', + name: `🚀 Major (${currentVersion} → ${semver.inc(currentVersion, 'major')}) - 重大变更`, + }, + { + value: 'current', + name: `📌 当前版本 (${currentVersion}) - 强制使用当前版本号`, + }, + { + value: 'custom', + name: '✏️ 自定义版本号', + }, + ], + }) + + let newVersion: string + + if (choice === 'current') { + newVersion = currentVersion + } else if (choice === 'custom') { + const customVersion = await input({ + message: '请输入版本号 (例如: 1.2.3):', + validate: (value) => { + if (!semver.valid(value)) { + return '请输入有效的语义化版本号 (例如: 1.2.3)' + } + return true + }, + }) + newVersion = customVersion + } else { + newVersion = semver.inc(currentVersion, choice as 'patch' | 'minor' | 'major')! + } + + // 确认版本 + const confirmed = await confirm({ + message: `确认发布版本 ${colors.bold}v${newVersion}${colors.reset}?`, + default: true, + }) + + if (!confirmed) { + throw new Error('用户取消') + } + + return newVersion +} + +// ==================== 构建和上传 ==================== + +async function runBuild(): Promise { + log.step('运行类型检查') + exec('pnpm typecheck') + + log.step('运行单元测试') + exec('pnpm test') + + log.step('构建 Web 版本') + exec('pnpm build:web', { + env: { SERVICE_IMPL: 'web' }, + }) + + // 移动到 dist-web + const distDir = join(ROOT, 'dist') + const distWebDir = join(ROOT, 'dist-web') + if (existsSync(distWebDir)) { + rmSync(distWebDir, { recursive: true }) + } + if (existsSync(distDir)) { + cpSync(distDir, distWebDir, { recursive: true }) + rmSync(distDir, { recursive: true }) + } + + log.step('构建 DWEB 版本') + exec('pnpm build:dweb', { + env: { SERVICE_IMPL: 'dweb', VITE_DEV_MODE: 'false' }, + }) + + // 移动到 dist-dweb + const distDwebDir = join(ROOT, 'dist-dweb') + if (existsSync(distDwebDir)) { + rmSync(distDwebDir, { recursive: true }) + } + if (existsSync(distDir)) { + cpSync(distDir, distDwebDir, { recursive: true }) + rmSync(distDir, { recursive: true }) + } + + log.step('运行 Plaoc 打包') + const distsDir = join(ROOT, 'dists') + if (existsSync(distsDir)) { + rmSync(distsDir, { recursive: true }) + } + exec(`plaoc bundle "${distDwebDir}" -c ./ -o "${distsDir}"`) + + log.success('构建完成') +} + +async function uploadDweb(): Promise { + log.step('上传 DWEB 到正式服务器') + + 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 (!sftpUser || !sftpPass) { + log.warn('未配置 SFTP 环境变量 (DWEB_SFTP_USER, DWEB_SFTP_PASS)') + const shouldSkip = await confirm({ + message: '是否跳过上传?', + default: true, + }) + if (shouldSkip) { + log.info('跳过上传') + return + } + throw new Error('请配置 SFTP 环境变量') + } + + // 使用 build.ts 的上传功能 + exec('bun scripts/build.ts dweb --upload --stable --skip-typecheck --skip-test', { + env: { + DWEB_SFTP_URL: sftpUrl, + DWEB_SFTP_USER: sftpUser, + DWEB_SFTP_PASS: sftpPass, + }, + }) + + log.success('上传完成') +} + +// ==================== 更新文件 ==================== + +function updateVersionFiles(version: string, changelog: string): void { + log.step('更新版本文件') + + // 更新 package.json + const pkg = readJson(PACKAGE_JSON_PATH) + pkg.version = version + pkg.lastChangelogCommit = execOutput('git rev-parse HEAD') + writeJson(PACKAGE_JSON_PATH, pkg) + log.success('更新 package.json') + + // 更新 manifest.json + if (existsSync(MANIFEST_JSON_PATH)) { + const manifest = readJson(MANIFEST_JSON_PATH) + manifest.version = version + manifest.change_log = changelog + writeJson(MANIFEST_JSON_PATH, manifest) + log.success('更新 manifest.json') + } +} + +async function updateChangelog(version: string): Promise { + log.step('更新 CHANGELOG.md') + + const summary = await input({ + message: '请输入本次更新的简要描述:', + default: '功能更新和优化', + }) + + const date = new Date().toISOString().split('T')[0] + const commitHash = execOutput('git rev-parse HEAD') + + let content = `## [${version}] - ${date}\n\n` + content += `${summary}\n\n` + content += `\n\n` + + // 读取现有 CHANGELOG 或创建新的 + let existingContent = '' + if (existsSync(CHANGELOG_PATH)) { + existingContent = readFileSync(CHANGELOG_PATH, 'utf-8') + existingContent = existingContent.replace(/^# 更新日志\n+/, '') + existingContent = existingContent.replace(/^# Changelog\n+/, '') + } + + const newContent = `# 更新日志\n\n${content}${existingContent}` + writeFileSync(CHANGELOG_PATH, newContent) + + log.success('更新 CHANGELOG.md') + return summary +} + +// ==================== Git 操作 ==================== + +async function commitAndTag(version: string): Promise { + log.step('提交变更并创建 Tag') + + // 添加所有变更 + exec('git add -A') + + // 提交 + exec(`git commit -m "release: v${version}"`) + log.success(`提交: release: v${version}`) + + // 创建 tag + exec(`git tag -a v${version} -m "Release v${version}"`) + log.success(`创建 Tag: v${version}`) +} + +async function pushAndTriggerCD(version: string): Promise { + log.step('推送到 GitHub') + + console.log(` +${colors.yellow}推送后将触发:${colors.reset} + - GitHub Actions CD 流程 + - GitHub Pages 更新 + - GitHub Release 创建 +`) + + const shouldPush = await confirm({ + message: '是否推送到 GitHub?', + default: true, + }) + + if (!shouldPush) { + log.info('跳过推送。你可以稍后手动执行:') + console.log(` git push origin main`) + console.log(` git push origin v${version}`) + return + } + + // 推送代码 + exec('git push origin main') + log.success('推送代码') + + // 推送 tag(这会触发 CD) + exec(`git push origin v${version}`) + log.success(`推送 Tag v${version}`) + + console.log(` +${colors.green}GitHub Actions 将自动:${colors.reset} + - 构建 Web 和 DWEB 版本 + - 部署到 GitHub Pages + - 创建 GitHub Release + - 上传 DWEB 到正式服务器 + +查看进度: https://github.com/BioforestChain/KeyApp/actions +`) +} + +// ==================== 主程序 ==================== + +async function main() { + console.log(` +${colors.magenta}╔════════════════════════════════════════╗ +║ BFM Pay Release Script ║ +╚════════════════════════════════════════╝${colors.reset} +`) + + // 1. 检查工作区 + const canContinue = await checkWorkspace() + if (!canContinue) { + log.info('发布已取消') + process.exit(0) + } + + // 2. 选择版本号 + let newVersion: string + try { + newVersion = await selectVersion() + } catch (error) { + log.info('发布已取消') + process.exit(0) + } + + // 3. 确认发布流程 + console.log(` +${colors.cyan}发布流程:${colors.reset} + 1. 运行类型检查和测试 + 2. 构建 Web 和 DWEB 版本 + 3. 上传 DWEB 到正式服务器 + 4. 更新版本号和 CHANGELOG + 5. 提交变更并创建 Tag + 6. 推送触发 GitHub Pages 更新 +`) + + const confirmRelease = await confirm({ + message: '确认开始发布流程?', + default: true, + }) + + if (!confirmRelease) { + log.info('发布已取消') + process.exit(0) + } + + // 4. 运行构建 + await runBuild() + + // 5. 上传 DWEB + await uploadDweb() + + // 6. 更新 CHANGELOG + const changelog = await updateChangelog(newVersion) + + // 7. 更新版本文件 + updateVersionFiles(newVersion, changelog) + + // 8. 提交并打 tag + await commitAndTag(newVersion) + + // 9. 推送 + await pushAndTriggerCD(newVersion) + + console.log(` +${colors.green}╔════════════════════════════════════════╗ +║ 发布完成! v${newVersion.padEnd(20)}║ +╚════════════════════════════════════════╝${colors.reset} + +${colors.blue}下一步:${colors.reset} + - 检查 GitHub Actions: https://github.com/BioforestChain/KeyApp/actions + - 查看 Release: https://github.com/BioforestChain/KeyApp/releases + - 访问 GitHub Pages: https://bioforestchain.github.io/KeyApp/ +`) +} + +main().catch((error) => { + log.error(`发布失败: ${error.message}`) + process.exit(1) +}) 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) +}) diff --git a/scripts/utils/sftp.ts b/scripts/utils/sftp.ts new file mode 100644 index 000000000..b612e043e --- /dev/null +++ b/scripts/utils/sftp.ts @@ -0,0 +1,307 @@ +/** + * 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 SftpConnectionConfig = { + /** SFTP URL,格式: sftp://host:port */ + url: string + /** 用户名 */ + username: string + /** 密码 */ + password: string +} + +/** + * SFTP 上传配置 + */ +export type SftpUploadConfig = SftpConnectionConfig & { + /** 本地源目录 */ + sourceDir: string + /** 项目名称(用于日志) */ + projectName: string + /** 远程子目录(可选,用于按日期分组) */ + remoteSubDir?: 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` +} + +/** + * 获取今天的日期字符串 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 服务器 + * + * @param config 上传配置 + * @param maxRetries 最大重试次数 + */ +export async function uploadToSftp(config: SftpUploadConfig, maxRetries = 3): Promise { + const { url, username, password, sourceDir, projectName, remoteSubDir } = 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}`) + if (remoteSubDir) { + log.info(`远程子目录: ${remoteSubDir}`) + } + + 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}`) + + // 获取当前工作目录 + 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()) + + 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 上传失败`) +} 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 ( +