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 (
+
+
+
+ {/* 角标 */}
+
+ DEV
+
+
+ )
+}
diff --git a/vite.config.ts b/vite.config.ts
index a53c90a66..410f5228f 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -113,6 +113,8 @@ export default defineConfig({
'global': 'globalThis',
// Mock 模式标识(用于条件加载 MockDevTools)
'__MOCK_MODE__': JSON.stringify(SERVICE_IMPL === 'mock'),
+ // Dev 模式标识(用于显示开发版水印)
+ '__DEV_MODE__': JSON.stringify(process.env.VITE_DEV_MODE === 'true'),
},
optimizeDeps: {
include: ['buffer'],