xb-init 2.1.0__tar.gz → 2.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. {xb_init-2.1.0 → xb_init-2.2.2}/PKG-INFO +46 -7
  2. {xb_init-2.1.0 → xb_init-2.2.2}/README.md +45 -6
  3. {xb_init-2.1.0 → xb_init-2.2.2}/pyproject.toml +1 -1
  4. xb_init-2.2.2/tests/test_cli.py +88 -0
  5. xb_init-2.2.2/tests/test_update_templates.py +77 -0
  6. {xb_init-2.1.0 → xb_init-2.2.2}/uv.lock +1 -1
  7. {xb_init-2.1.0 → xb_init-2.2.2}/xb/commands/init.py +108 -14
  8. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/backend/api/config.py.j2 +22 -0
  9. xb_init-2.2.2/xb/templates/backend/api/update/__init__.py.j2 +188 -0
  10. xb_init-2.2.2/xb/templates/backend/app_version.py.j2 +11 -0
  11. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/backend/backend_build.py.j2 +7 -0
  12. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/backend/main.py.j2 +25 -2
  13. xb_init-2.2.2/xb/templates/backend/managers/__init__.py.j2 +7 -0
  14. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/backend/managers/logger_manager.py.j2 +14 -9
  15. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/backend/managers/path_manager.py.j2 +114 -1
  16. xb_init-2.2.2/xb/templates/backend/managers/secret_obfuscator.py.j2 +123 -0
  17. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/backend/managers/sudoers_manager.py.j2 +14 -0
  18. xb_init-2.2.2/xb/templates/backend/services/__init__.py.j2 +1 -0
  19. xb_init-2.2.2/xb/templates/backend/services/feishu_client.py.j2 +264 -0
  20. xb_init-2.2.2/xb/templates/backend/services/updater.py.j2 +712 -0
  21. xb_init-2.2.2/xb/templates/configs/config_changes.json.j2 +1 -0
  22. xb_init-2.2.2/xb/templates/configs/secrets.yaml.example.j2 +9 -0
  23. xb_init-2.2.2/xb/templates/configs/secrets.yaml.j2 +9 -0
  24. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/electron/resources/postinst.py.j2 +39 -7
  25. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/electron/resources/postrm.py.j2 +2 -2
  26. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/frontend/src/components/ConfigSetup.vue.j2 +5 -38
  27. xb_init-2.2.2/xb/templates/frontend/src/components/GitVersionBadge.vue.j2 +297 -0
  28. xb_init-2.2.2/xb/templates/frontend/src/components/version/CommitHistoryPanel.vue.j2 +286 -0
  29. xb_init-2.2.2/xb/templates/frontend/src/components/version/UpdatePanel.vue.j2 +766 -0
  30. xb_init-2.2.2/xb/templates/frontend/src/composables/useUpdate.js.j2 +295 -0
  31. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/root/.gitignore.j2 +6 -0
  32. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/root/AGENTS.md.j2 +63 -0
  33. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/root/README.md.j2 +85 -4
  34. xb_init-2.2.2/xb/templates/root/build.py.j2 +448 -0
  35. xb_init-2.2.2/xb/templates/scripts/feishu_upload.py.j2 +656 -0
  36. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/version/scripts/gui_version_manager.py.j2 +19 -1
  37. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/version/scripts/version_manager.py.j2 +60 -0
  38. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/version/scripts/web_version_manager.py.j2 +19 -1
  39. {xb_init-2.1.0 → xb_init-2.2.2}/xb/utils/template_engine.py +71 -4
  40. xb_init-2.1.0/tests/test_cli.py +0 -14
  41. xb_init-2.1.0/xb/templates/backend/managers/__init__.py.j2 +0 -7
  42. xb_init-2.1.0/xb/templates/configs/secrets.yaml.example.j2 +0 -3
  43. xb_init-2.1.0/xb/templates/configs/secrets.yaml.j2 +0 -3
  44. xb_init-2.1.0/xb/templates/electron/.npmrc.j2 +0 -10
  45. xb_init-2.1.0/xb/templates/frontend/.npmrc.j2 +0 -7
  46. xb_init-2.1.0/xb/templates/frontend/src/components/GitVersionBadge.vue.j2 +0 -359
  47. xb_init-2.1.0/xb/templates/root/build.py.j2 +0 -68
  48. {xb_init-2.1.0 → xb_init-2.2.2}/.gitignore +0 -0
  49. {xb_init-2.1.0 → xb_init-2.2.2}/docs/screenshot.png +0 -0
  50. {xb_init-2.1.0 → xb_init-2.2.2}/tests/test_upgrade.py +0 -0
  51. {xb_init-2.1.0 → xb_init-2.2.2}/xb/__init__.py +0 -0
  52. {xb_init-2.1.0 → xb_init-2.2.2}/xb/cli.py +0 -0
  53. {xb_init-2.1.0 → xb_init-2.2.2}/xb/commands/__init__.py +0 -0
  54. {xb_init-2.1.0 → xb_init-2.2.2}/xb/commands/build.py +0 -0
  55. {xb_init-2.1.0 → xb_init-2.2.2}/xb/commands/dev.py +0 -0
  56. {xb_init-2.1.0 → xb_init-2.2.2}/xb/commands/doctor.py +0 -0
  57. {xb_init-2.1.0 → xb_init-2.2.2}/xb/commands/upgrade.py +0 -0
  58. {xb_init-2.1.0 → xb_init-2.2.2}/xb/commands/version.py +0 -0
  59. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/backend/api/__init__.py.j2 +0 -0
  60. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/backend/api/ports.py.j2 +0 -0
  61. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/backend/api/terminal/__init__.py.j2 +0 -0
  62. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/backend/api/terminal/routes.py.j2 +0 -0
  63. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/backend/requirements.txt.j2 +0 -0
  64. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/configs/global_config.yaml.j2 +0 -0
  65. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/electron/launcher.js.j2 +0 -0
  66. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/electron/main.js.j2 +0 -0
  67. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/electron/package.json.j2 +0 -0
  68. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/electron/port_diagnostics.js.j2 +0 -0
  69. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/electron/preload.js.j2 +0 -0
  70. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/electron/prepare.js.j2 +0 -0
  71. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/electron/resources/uninstaller.nsh.j2 +0 -0
  72. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/frontend/index.html.j2 +0 -0
  73. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/frontend/package.json.j2 +0 -0
  74. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/frontend/src/App.vue.j2 +0 -0
  75. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/frontend/src/components/DashboardHome.vue.j2 +0 -0
  76. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/frontend/src/components/FileManager.vue.j2 +0 -0
  77. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/frontend/src/components/RefreshButton.vue.j2 +0 -0
  78. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/frontend/src/components/TerminalPanel.vue.j2 +0 -0
  79. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/frontend/src/components/TerminalView.vue.j2 +0 -0
  80. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/frontend/src/components/ThemeToggle.vue.j2 +0 -0
  81. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/frontend/src/composables/useTerminalSessions.js.j2 +0 -0
  82. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/frontend/src/main.js.j2 +0 -0
  83. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/frontend/src/style.css.j2 +0 -0
  84. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/frontend/vite.config.js.j2 +0 -0
  85. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/root/dev.py.j2 +0 -0
  86. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/root/pyproject.toml.j2 +0 -0
  87. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/scripts/example.py.j2 +0 -0
  88. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/version/hooks/pre-commit.py.j2 +0 -0
  89. {xb_init-2.1.0 → xb_init-2.2.2}/xb/templates/version/scripts/install_hooks.py.j2 +0 -0
  90. {xb_init-2.1.0 → xb_init-2.2.2}/xb/utils/__init__.py +0 -0
  91. {xb_init-2.1.0 → xb_init-2.2.2}/xb/utils/click_helpers.py +0 -0
  92. {xb_init-2.1.0 → xb_init-2.2.2}/xb/utils/validators.py +0 -0
  93. {xb_init-2.1.0 → xb_init-2.2.2}/xb/utils/version_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: xb-init
3
- Version: 2.1.0
3
+ Version: 2.2.2
4
4
  Summary: Project management tool for UV + FastAPI + Vue3 + Electron desktop applications
5
5
  Project-URL: Homepage, https://github.com/xuebli/xb
6
6
  Project-URL: Documentation, https://github.com/xuebli/xb#readme
@@ -43,6 +43,7 @@ Description-Content-Type: text/markdown
43
43
  - 内置全局亮色/暗色主题切换
44
44
  - 可选 sudo 免密配置(`--sudoers`)
45
45
  - 可选内置终端(`--terminal`)
46
+ - 可选应用内自动更新(`--update`,飞书云盘发布 + 版本检查 + 下载校验 + 自动安装)
46
47
  - 可选自定义应用图标(`--icon`)
47
48
  - 内置开发、构建、版本管理命令
48
49
  - 自动版本检查与一键升级(`xb --upgrade`)
@@ -83,6 +84,9 @@ xb init demo --terminal
83
84
  # 同时启用终端和 sudo 免密配置
84
85
  xb init demo --terminal --sudoers
85
86
 
87
+ # 启用应用内自动更新(自动附带 --sudoers 免密,输入 sudo 密码即可)
88
+ xb init demo --update
89
+
86
90
  # 带自定义图标
87
91
  xb init demo --icon ~/icons/app.png
88
92
 
@@ -110,14 +114,18 @@ python .\dev.py stop
110
114
  使用 `--terminal` 时,Windows 终端使用 PowerShell + ConPTY,支持上下箭头
111
115
  命令历史、复制粘贴、Tab 补全和 Ctrl+C;Ubuntu/Debian 使用 bash + Unix PTY。
112
116
 
113
- Electron 二进制镜像通过 `ELECTRON_MIRROR` 环境变量注入,不向 `.npmrc`
114
- 写 npm 不识别的自定义键。若安装过程被网络中断,生成项目的
117
+ Electron 二进制镜像通过 `ELECTRON_MIRROR` 环境变量注入。
118
+ 若安装过程被网络中断,生成项目的
115
119
  `electron/prepare.js` 会在 `npm start` 或 `npm run build` 前自动检查并补装
116
120
  缺失的 Electron 二进制。
117
121
 
118
122
  > **`xb init` 自动行为**:
119
123
  > - 执行 `npm install`(frontend + electron),生成 `package-lock.json`
120
- > - 生成的 `.npmrc` 已配好华为云镜像;electron 约 200MB 二进制也走镜像下载
124
+ > - 生成的项目用 [nrm](https://github.com/Pana/nrm) 管理 npm 源:`xb init`
125
+ > 安装依赖前与 `build.py` 构建前都会自动 `nrm test` 测速所有内置源并切换
126
+ > 到最快的(未安装 nrm 时自动全局安装);项目不放 `.npmrc`,npm 源完全由
127
+ > nrm 全局管理
128
+ > - electron 约 200MB 二进制走华为云镜像下载
121
129
  > - 若安装失败或超时,会打印提示但不中断项目创建,可稍后手动重试
122
130
  > - 执行 `git init` 并提交首个 commit(包含所有文件和 lock 文件)
123
131
  > - 若检测到 PyPI 有新版 xb,会询问是否先升级再创建项目
@@ -127,7 +135,7 @@ Electron 二进制镜像通过 `ELECTRON_MIRROR` 环境变量注入,不向 `.n
127
135
 
128
136
  | 命令 | 说明 |
129
137
  |------|------|
130
- | `xb init <name> [--sudoers] [--terminal] [--icon PATH]` | 初始化项目 |
138
+ | `xb init <name> [--sudoers] [--terminal] [--update] [--icon PATH]` | 初始化项目 |
131
139
  | `xb dev [start\|stop\|status]` | 启动/停止/查看开发环境 |
132
140
  | `xb build [all\|frontend\|backend\|electron]` | 构建项目 |
133
141
  | `xb build -f / -b / -e / -a` | 构建快捷 flag |
@@ -146,6 +154,39 @@ xb init demo --icon ./my-icon.png
146
154
  # ./assets/app-icon.png, ./assets/icon.png, ./resources/icon.png
147
155
  ```
148
156
 
157
+ ## 应用内自动更新(`--update`)
158
+
159
+ `xb init demo --update` 会让生成的项目内置与 [leo_stm32_board_test](../leo_stm32_board_test) 同源的自动更新能力,
160
+ 以飞书云盘的一个文件夹作为发布"货架":
161
+
162
+ **生成内容:**
163
+
164
+ | 位置 | 职责 |
165
+ |------|------|
166
+ | `backend/api/update/` | 更新接口(检查 / 信息 / 下载 / 进度 / 安装) |
167
+ | `backend/services/updater.py` | 版本比较、信息拉取、下载校验、配置备份迁移、安装 |
168
+ | `backend/services/feishu_client.py` | 飞书接口封装(文件夹列举、并发下载) |
169
+ | `backend/managers/secret_obfuscator.py` | 敏感值混淆(`obf1:`,防围观级别) |
170
+ | `backend/app_version.py` | 后端版本号(version_manager 提交时自动同步) |
171
+ | `frontend/src/components/UpdateChecker.vue` | 版本号徽章 + 更新弹窗 + 进度条 |
172
+ | `frontend/src/composables/useUpdate.js` | 定时检查 / 下载进度轮询 |
173
+ | `scripts/feishu_upload.py` | 发版端:安装包与版本信息上传飞书 |
174
+ | `configs/config_changes.json` | 配置迁移清单(顶层 key 是版本号) |
175
+ | `build.py` | 增加 `--upload-only` 与构建后发布编排 |
176
+
177
+ **发版流程:** 在 `configs/secrets.yaml` 填 `feishu.app_id` / `app_secret` /
178
+ `update_folder_url` → `./build.py -a` → 确认上传。构建时敏感键自动混淆进
179
+ `secrets.yaml.example` 随包分发,构建结束 example 恢复为留空模板。
180
+
181
+ **客户端:** 启动时 / 每小时 / 点版本号徽章时检查更新;发现新版本后下载
182
+ (4 线程分块 + SHA256 校验 + `.part` 防半截包)→ 备份配置 → 按
183
+ `config_changes.json` 迁移 → Ubuntu 趁应用存活时免密 `sudo dpkg -i`
184
+ (`--update` 默认附带 `--sudoers` 免密),Windows 等退出后静默安装。
185
+
186
+ **飞书侧准备:** 开放平台建自建应用 → 开通 `drive:drive` 权限并发布 →
187
+ 建云盘文件夹,完整链接填入 `update_folder_url`。开发模式(源码运行)不支持
188
+ 应用内更新,检查接口返回 `packaged: false`。
189
+
149
190
  ## 生成的项目结构
150
191
 
151
192
  ```
@@ -162,13 +203,11 @@ demo/
162
203
  ├── frontend/ # Vue 3 前端
163
204
  │ ├── package.json
164
205
  │ ├── package-lock.json
165
- │ ├── .npmrc # npm 镜像源(华为云)
166
206
  │ ├── vite.config.js
167
207
  │ └── src/
168
208
  ├── electron/ # Electron 主进程
169
209
  │ ├── package.json
170
210
  │ ├── package-lock.json
171
- │ ├── .npmrc # npm + electron 二进制镜像源
172
211
  │ ├── main.js
173
212
  │ └── resources/ # icon + Linux DEB hooks
174
213
  ├── version/ # 版本管理(pre-commit hook)
@@ -14,6 +14,7 @@
14
14
  - 内置全局亮色/暗色主题切换
15
15
  - 可选 sudo 免密配置(`--sudoers`)
16
16
  - 可选内置终端(`--terminal`)
17
+ - 可选应用内自动更新(`--update`,飞书云盘发布 + 版本检查 + 下载校验 + 自动安装)
17
18
  - 可选自定义应用图标(`--icon`)
18
19
  - 内置开发、构建、版本管理命令
19
20
  - 自动版本检查与一键升级(`xb --upgrade`)
@@ -54,6 +55,9 @@ xb init demo --terminal
54
55
  # 同时启用终端和 sudo 免密配置
55
56
  xb init demo --terminal --sudoers
56
57
 
58
+ # 启用应用内自动更新(自动附带 --sudoers 免密,输入 sudo 密码即可)
59
+ xb init demo --update
60
+
57
61
  # 带自定义图标
58
62
  xb init demo --icon ~/icons/app.png
59
63
 
@@ -81,14 +85,18 @@ python .\dev.py stop
81
85
  使用 `--terminal` 时,Windows 终端使用 PowerShell + ConPTY,支持上下箭头
82
86
  命令历史、复制粘贴、Tab 补全和 Ctrl+C;Ubuntu/Debian 使用 bash + Unix PTY。
83
87
 
84
- Electron 二进制镜像通过 `ELECTRON_MIRROR` 环境变量注入,不向 `.npmrc`
85
- 写 npm 不识别的自定义键。若安装过程被网络中断,生成项目的
88
+ Electron 二进制镜像通过 `ELECTRON_MIRROR` 环境变量注入。
89
+ 若安装过程被网络中断,生成项目的
86
90
  `electron/prepare.js` 会在 `npm start` 或 `npm run build` 前自动检查并补装
87
91
  缺失的 Electron 二进制。
88
92
 
89
93
  > **`xb init` 自动行为**:
90
94
  > - 执行 `npm install`(frontend + electron),生成 `package-lock.json`
91
- > - 生成的 `.npmrc` 已配好华为云镜像;electron 约 200MB 二进制也走镜像下载
95
+ > - 生成的项目用 [nrm](https://github.com/Pana/nrm) 管理 npm 源:`xb init`
96
+ > 安装依赖前与 `build.py` 构建前都会自动 `nrm test` 测速所有内置源并切换
97
+ > 到最快的(未安装 nrm 时自动全局安装);项目不放 `.npmrc`,npm 源完全由
98
+ > nrm 全局管理
99
+ > - electron 约 200MB 二进制走华为云镜像下载
92
100
  > - 若安装失败或超时,会打印提示但不中断项目创建,可稍后手动重试
93
101
  > - 执行 `git init` 并提交首个 commit(包含所有文件和 lock 文件)
94
102
  > - 若检测到 PyPI 有新版 xb,会询问是否先升级再创建项目
@@ -98,7 +106,7 @@ Electron 二进制镜像通过 `ELECTRON_MIRROR` 环境变量注入,不向 `.n
98
106
 
99
107
  | 命令 | 说明 |
100
108
  |------|------|
101
- | `xb init <name> [--sudoers] [--terminal] [--icon PATH]` | 初始化项目 |
109
+ | `xb init <name> [--sudoers] [--terminal] [--update] [--icon PATH]` | 初始化项目 |
102
110
  | `xb dev [start\|stop\|status]` | 启动/停止/查看开发环境 |
103
111
  | `xb build [all\|frontend\|backend\|electron]` | 构建项目 |
104
112
  | `xb build -f / -b / -e / -a` | 构建快捷 flag |
@@ -117,6 +125,39 @@ xb init demo --icon ./my-icon.png
117
125
  # ./assets/app-icon.png, ./assets/icon.png, ./resources/icon.png
118
126
  ```
119
127
 
128
+ ## 应用内自动更新(`--update`)
129
+
130
+ `xb init demo --update` 会让生成的项目内置与 [leo_stm32_board_test](../leo_stm32_board_test) 同源的自动更新能力,
131
+ 以飞书云盘的一个文件夹作为发布"货架":
132
+
133
+ **生成内容:**
134
+
135
+ | 位置 | 职责 |
136
+ |------|------|
137
+ | `backend/api/update/` | 更新接口(检查 / 信息 / 下载 / 进度 / 安装) |
138
+ | `backend/services/updater.py` | 版本比较、信息拉取、下载校验、配置备份迁移、安装 |
139
+ | `backend/services/feishu_client.py` | 飞书接口封装(文件夹列举、并发下载) |
140
+ | `backend/managers/secret_obfuscator.py` | 敏感值混淆(`obf1:`,防围观级别) |
141
+ | `backend/app_version.py` | 后端版本号(version_manager 提交时自动同步) |
142
+ | `frontend/src/components/UpdateChecker.vue` | 版本号徽章 + 更新弹窗 + 进度条 |
143
+ | `frontend/src/composables/useUpdate.js` | 定时检查 / 下载进度轮询 |
144
+ | `scripts/feishu_upload.py` | 发版端:安装包与版本信息上传飞书 |
145
+ | `configs/config_changes.json` | 配置迁移清单(顶层 key 是版本号) |
146
+ | `build.py` | 增加 `--upload-only` 与构建后发布编排 |
147
+
148
+ **发版流程:** 在 `configs/secrets.yaml` 填 `feishu.app_id` / `app_secret` /
149
+ `update_folder_url` → `./build.py -a` → 确认上传。构建时敏感键自动混淆进
150
+ `secrets.yaml.example` 随包分发,构建结束 example 恢复为留空模板。
151
+
152
+ **客户端:** 启动时 / 每小时 / 点版本号徽章时检查更新;发现新版本后下载
153
+ (4 线程分块 + SHA256 校验 + `.part` 防半截包)→ 备份配置 → 按
154
+ `config_changes.json` 迁移 → Ubuntu 趁应用存活时免密 `sudo dpkg -i`
155
+ (`--update` 默认附带 `--sudoers` 免密),Windows 等退出后静默安装。
156
+
157
+ **飞书侧准备:** 开放平台建自建应用 → 开通 `drive:drive` 权限并发布 →
158
+ 建云盘文件夹,完整链接填入 `update_folder_url`。开发模式(源码运行)不支持
159
+ 应用内更新,检查接口返回 `packaged: false`。
160
+
120
161
  ## 生成的项目结构
121
162
 
122
163
  ```
@@ -133,13 +174,11 @@ demo/
133
174
  ├── frontend/ # Vue 3 前端
134
175
  │ ├── package.json
135
176
  │ ├── package-lock.json
136
- │ ├── .npmrc # npm 镜像源(华为云)
137
177
  │ ├── vite.config.js
138
178
  │ └── src/
139
179
  ├── electron/ # Electron 主进程
140
180
  │ ├── package.json
141
181
  │ ├── package-lock.json
142
- │ ├── .npmrc # npm + electron 二进制镜像源
143
182
  │ ├── main.js
144
183
  │ └── resources/ # icon + Linux DEB hooks
145
184
  ├── version/ # 版本管理(pre-commit hook)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "xb-init"
7
- version = "2.1.0"
7
+ version = "2.2.2"
8
8
  description = "Project management tool for UV + FastAPI + Vue3 + Electron desktop applications"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -0,0 +1,88 @@
1
+ from click.testing import CliRunner
2
+
3
+ import xb.commands.init as init_module
4
+ from xb import cli
5
+
6
+
7
+ def test_upgrade_option_runs_without_subcommand(monkeypatch):
8
+ called = []
9
+ monkeypatch.setattr(cli, "run_upgrade", lambda: called.append(True))
10
+
11
+ result = CliRunner().invoke(cli.main, ["--upgrade"])
12
+
13
+ assert result.exit_code == 0
14
+ assert called == [True]
15
+ assert "Missing command" not in result.output
16
+
17
+
18
+ def _invoke_init(monkeypatch, tmp_path, args, password):
19
+ """跑 init 命令但替换掉耗时/有副作用的步骤,捕获传给模板引擎的参数。"""
20
+ captured = {}
21
+ monkeypatch.setattr(init_module, "get_latest_if_newer", lambda _v: None)
22
+ monkeypatch.setattr(init_module.shutil, "which", lambda _name: None)
23
+ monkeypatch.setattr(init_module, "init_git_repo", lambda *_a, **_k: True)
24
+ monkeypatch.setattr(
25
+ init_module.TemplateEngine,
26
+ "render_project",
27
+ lambda self, **kwargs: captured.update(kwargs),
28
+ )
29
+
30
+ result = CliRunner().invoke(
31
+ cli.main, ["init", str(tmp_path / "demo"), *args], input=f"{password}\n"
32
+ )
33
+ assert result.exit_code == 0, result.output
34
+ return captured, result.output
35
+
36
+
37
+ def test_update_implies_sudoers(monkeypatch, tmp_path):
38
+ captured, output = _invoke_init(monkeypatch, tmp_path, ["--update"], "pw123")
39
+
40
+ assert captured["enable_update"] is True
41
+ assert captured["enable_sudo"] is True
42
+ assert captured["sudo_password"] == "pw123"
43
+ assert "自动附带 --sudoers" in output
44
+
45
+
46
+ def test_sudoers_alone_still_prompts(monkeypatch, tmp_path):
47
+ captured, output = _invoke_init(monkeypatch, tmp_path, ["--sudoers"], "pw123")
48
+
49
+ assert captured["enable_update"] is False
50
+ assert captured["enable_sudo"] is True
51
+ assert captured["sudo_password"] == "pw123"
52
+ assert "自动附带" not in output
53
+
54
+
55
+ def test_plain_init_has_no_sudo(monkeypatch, tmp_path):
56
+ captured, output = _invoke_init(monkeypatch, tmp_path, [], "")
57
+
58
+ assert captured["enable_update"] is False
59
+ assert captured["enable_sudo"] is False
60
+ assert captured["sudo_password"] == ""
61
+
62
+
63
+ def test_pick_fastest_from_nrm_output():
64
+ output = """ npm ---------- 807 ms
65
+ yarn --------- 795 ms
66
+ tencent ------ 284 ms
67
+ cnpm --------- 803 ms
68
+ * taobao ------- 163 ms
69
+ npmMirror ---- 1265 ms (Fetch error, if this is your private registry, please ignore)
70
+ huawei ------- 96 ms
71
+ """
72
+ assert init_module._pick_fastest_from_nrm_output(output) == ("huawei", 96)
73
+
74
+
75
+ def test_pick_fastest_from_nrm_output_all_failed():
76
+ output = " npmMirror ---- 1265 ms (Fetch error, if this is your private registry, please ignore)\n"
77
+ assert init_module._pick_fastest_from_nrm_output(output) == ("", None)
78
+
79
+
80
+ def test_windows_skips_sudo_password(monkeypatch, tmp_path):
81
+ monkeypatch.setattr(init_module.sys, "platform", "win32")
82
+ captured, output = _invoke_init(
83
+ monkeypatch, tmp_path, ["--sudoers"], "should-not-be-used"
84
+ )
85
+
86
+ assert captured["enable_sudo"] is True
87
+ assert captured["sudo_password"] == ""
88
+ assert "跳过密码输入" in output
@@ -0,0 +1,77 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from xb.utils.template_engine import TemplateEngine
7
+
8
+ UPDATE_FILES = [
9
+ "backend/services/updater.py",
10
+ "backend/services/feishu_client.py",
11
+ "backend/api/update/__init__.py",
12
+ "backend/managers/secret_obfuscator.py",
13
+ "backend/managers/sudoers_manager.py",
14
+ "backend/app_version.py",
15
+ "scripts/feishu_upload.py",
16
+ "configs/config_changes.json",
17
+ "frontend/src/components/version/UpdatePanel.vue",
18
+ "frontend/src/components/version/CommitHistoryPanel.vue",
19
+ "frontend/src/composables/useUpdate.js",
20
+ ]
21
+
22
+
23
+ @pytest.fixture()
24
+ def render_project(tmp_path):
25
+ def _render(name: str, **kwargs) -> Path:
26
+ target = tmp_path / name
27
+ TemplateEngine().render_project(
28
+ target_dir=target,
29
+ package_name=name,
30
+ **kwargs,
31
+ )
32
+ return target
33
+
34
+ return _render
35
+
36
+
37
+ def test_init_without_update_skips_update_files(render_project):
38
+ target = render_project("plain_app")
39
+
40
+ for rel in UPDATE_FILES:
41
+ assert not (target / rel).exists(), f"不应生成 {rel}"
42
+ assert "update_router" not in (target / "backend" / "main.py").read_text()
43
+
44
+
45
+ def test_init_with_update_renders_update_files(render_project):
46
+ target = render_project("updatable_app", enable_update=True)
47
+
48
+ for rel in UPDATE_FILES:
49
+ assert (target / rel).exists(), f"缺少 {rel}"
50
+
51
+ # 配置迁移清单是合法 JSON
52
+ json.loads((target / "configs" / "config_changes.json").read_text())
53
+
54
+ # main.py 注册了更新路由
55
+ assert "app.include_router(update_router" in (target / "backend" / "main.py").read_text()
56
+
57
+ # secret_obfuscator 与 feishu_upload 的混淆密钥保持一致
58
+ obf = (target / "backend" / "managers" / "secret_obfuscator.py").read_text()
59
+ upload = (target / "scripts" / "feishu_upload.py").read_text()
60
+ key_line = next(line for line in obf.splitlines() if line.startswith("_KEY ="))
61
+ key = key_line.split("=", 1)[1].strip()
62
+ assert key in upload
63
+
64
+ # version_manager 会同步 app_version.py 与 package-lock.json
65
+ vm = (target / "version" / "scripts" / "version_manager.py").read_text()
66
+ assert "update_backend_version" in vm
67
+ assert "update_package_lock_version" in vm
68
+
69
+ # build.py 具备发布编排
70
+ build = (target / "build.py").read_text()
71
+ assert "--upload-only" in build
72
+ assert "_upload_stage" in build
73
+
74
+ # App.vue 挂载的 GitVersionBadge 集成了更新面板
75
+ badge = (target / "frontend" / "src" / "components" / "GitVersionBadge.vue").read_text()
76
+ assert "UpdatePanel" in badge
77
+ assert "CommitHistoryPanel" in badge
@@ -414,7 +414,7 @@ wheels = [
414
414
 
415
415
  [[package]]
416
416
  name = "xb-init"
417
- version = "2.1.0"
417
+ version = "2.2.0"
418
418
  source = { editable = "." }
419
419
  dependencies = [
420
420
  { name = "click" },
@@ -4,6 +4,7 @@ xb init 命令实现
4
4
  """
5
5
 
6
6
  import os
7
+ import re
7
8
  import shutil
8
9
  import subprocess
9
10
  import sys
@@ -140,6 +141,67 @@ def remove_existing_project(path: Path) -> None:
140
141
  shutil.rmtree(path, onexc=clear_readonly)
141
142
 
142
143
 
144
+ def _pick_fastest_from_nrm_output(output: str) -> tuple[str, int | None]:
145
+ """解析 `nrm test` 输出,返回 (最快源名, 毫秒);无可用源返回 ("", None)。
146
+
147
+ 输出形如 " huawei ------- 96 ms",不可用源带 "(Fetch error ...)" 标记。
148
+ """
149
+ best_name, best_ms = "", None
150
+ for line in output.splitlines():
151
+ match = re.match(r"^\s*\*?\s*(\S+)\s+-+\s+(\d+)\s+ms\b(.*)$", line)
152
+ if not match or "Fetch error" in match.group(3):
153
+ continue
154
+ elapsed = int(match.group(2))
155
+ if best_ms is None or elapsed < best_ms:
156
+ best_name, best_ms = match.group(1), elapsed
157
+ return best_name, best_ms
158
+
159
+
160
+ def nrm_speedtest(console: Console, npm_command: str) -> None:
161
+ """npm install 前用 nrm 测速所有内置源并自动切换到最快的源。
162
+
163
+ - 未安装 nrm 时自动全局安装(npm install -g nrm)
164
+ - nrm use 修改全局 npm 配置(~/.npmrc),对本机所有项目生效
165
+ - 任何失败只告警,不阻塞项目创建(沿用当前源继续安装)
166
+ """
167
+ nrm_command = "nrm.cmd" if os.name == "nt" and shutil.which("nrm.cmd") else "nrm"
168
+ try:
169
+ if not shutil.which(nrm_command):
170
+ console.print("[dim]未检测到 nrm,自动安装(npm install -g nrm)...[/dim]")
171
+ subprocess.run(
172
+ [npm_command, "install", "-g", "nrm"],
173
+ capture_output=True,
174
+ text=True,
175
+ timeout=120,
176
+ check=True,
177
+ )
178
+
179
+ console.print("[dim]正在测速 npm 镜像源(nrm test)...[/dim]")
180
+ result = subprocess.run(
181
+ [nrm_command, "test"],
182
+ capture_output=True,
183
+ text=True,
184
+ timeout=180,
185
+ check=True,
186
+ )
187
+ except Exception as e:
188
+ console.print(f"[yellow]⚠[/yellow] nrm 测速不可用(沿用当前 npm 源): {e}")
189
+ return
190
+
191
+ best_name, best_ms = _pick_fastest_from_nrm_output(result.stdout)
192
+ if not best_name:
193
+ console.print("[yellow]⚠[/yellow] nrm 测速无可用结果(沿用当前 npm 源)")
194
+ return
195
+
196
+ console.print(f"[green]✓[/green] 最快 npm 源: [cyan]{best_name}[/cyan]({best_ms} ms),切换中...")
197
+ try:
198
+ subprocess.run(
199
+ [nrm_command, "use", best_name], capture_output=True, text=True, check=True
200
+ )
201
+ except Exception as e:
202
+ console.print(f"[yellow]⚠[/yellow] 切换 npm 源失败(沿用当前源): {e}")
203
+
204
+
143
205
  class ParamSummaryCommand(ChineseHelpCommand):
144
206
  pass
145
207
 
@@ -177,6 +239,13 @@ class XbGroup(click.Group):
177
239
  default=False,
178
240
  help="启用内置 Web 终端(Linux/macOS 使用 bash,Windows 使用 PowerShell)",
179
241
  )
242
+ @click.option(
243
+ "--update",
244
+ "update",
245
+ is_flag=True,
246
+ default=False,
247
+ help="启用应用内自动更新(飞书云盘发布 + 版本检查 + 下载安装);默认附带 --sudoers 免密",
248
+ )
180
249
  @click.option(
181
250
  "--icon",
182
251
  "icon",
@@ -184,7 +253,13 @@ class XbGroup(click.Group):
184
253
  default=None,
185
254
  help="应用图标 PNG 路径;不传时自动查找 ./app-icon.png、./icon.png、./assets/icon.png 等约定路径",
186
255
  )
187
- def init_command(package: str, sudoers: bool, terminal: bool, icon: str | None):
256
+ def init_command(
257
+ package: str,
258
+ sudoers: bool,
259
+ terminal: bool,
260
+ icon: str | None,
261
+ update: bool,
262
+ ):
188
263
  """
189
264
  初始化项目结构
190
265
 
@@ -196,6 +271,7 @@ def init_command(package: str, sudoers: bool, terminal: bool, icon: str | None):
196
271
  xb init demo
197
272
  xb init myapp --sudoers
198
273
  xb init myapp --terminal
274
+ xb init myapp --update
199
275
  xb init /home/user/projects/myapp
200
276
  xb init ../other_dir/demo
201
277
  """
@@ -240,23 +316,37 @@ def init_command(package: str, sudoers: bool, terminal: bool, icon: str | None):
240
316
  console.print(f"[green]→[/green] 使用应用图标: [cyan]{icon_path}[/cyan]")
241
317
 
242
318
  # sudo 免密配置
319
+ # --update 默认附带 --sudoers:Ubuntu 下应用内安装更新依赖免密 dpkg
243
320
  enable_sudo = False
244
321
  sudo_password = ""
245
322
 
246
- if sudoers:
247
- console.print()
323
+ if update and not sudoers:
248
324
  console.print(
249
- Panel.fit(
250
- "[bold cyan]Sudo 免密配置[/bold cyan]\n\n"
251
- "启用 sudo 免密执行特定命令。\n"
252
- "密码将以明文存储在 configs/secrets.yaml 中,\n"
253
- "请确保该文件权限设置为 600 (仅所有者可读写)。",
254
- border_style="cyan",
255
- )
325
+ "[cyan]ℹ[/cyan] 已启用 --update,自动附带 --sudoers "
326
+ "(Ubuntu 应用内安装更新需要 sudo 免密)"
256
327
  )
328
+ sudoers = True
329
+
330
+ if sudoers:
331
+ if sys.platform.startswith("win"):
332
+ # Windows 没有 sudo 概念:仍生成 sudoers_manager(运行时自动 no-op),
333
+ # 但不询问、不写入密码
334
+ console.print("[dim]Windows 无需 sudo 免密,跳过密码输入[/dim]")
335
+ enable_sudo = True
336
+ else:
337
+ console.print()
338
+ console.print(
339
+ Panel.fit(
340
+ "[bold cyan]Sudo 免密配置[/bold cyan]\n\n"
341
+ "启用 sudo 免密执行特定命令。\n"
342
+ "密码将以明文存储在 configs/secrets.yaml 中,\n"
343
+ "请确保该文件权限设置为 600 (仅所有者可读写)。",
344
+ border_style="cyan",
345
+ )
346
+ )
257
347
 
258
- sudo_password = Prompt.ask("[cyan]请输入 sudo 密码[/cyan]", password=True)
259
- enable_sudo = True
348
+ sudo_password = Prompt.ask("[cyan]请输入 sudo 密码[/cyan]", password=True)
349
+ enable_sudo = True
260
350
 
261
351
  # 创建项目
262
352
  console.print()
@@ -269,15 +359,19 @@ def init_command(package: str, sudoers: bool, terminal: bool, icon: str | None):
269
359
  package_name=package_name,
270
360
  enable_sudo=enable_sudo,
271
361
  enable_terminal=terminal,
362
+ enable_update=update,
272
363
  sudo_password=sudo_password,
273
364
  icon_path=icon_path,
274
365
  )
275
366
 
276
367
  # 安装前端和 Electron 依赖(生成 package-lock.json 纳入首次 commit)
277
- # 镜像源由各子目录的 .npmrc 提供;electron 需下载约 200MB 二进制,超时给足 10 分钟
368
+ # npm 源由 nrm 全局管理;--no-audit/--no-fund 减少安装期网络请求
369
+ # electron 需下载约 200MB 二进制,超时给足 10 分钟
278
370
  if shutil.which("npm"):
279
371
  # Windows 上 npm 通常是 npm.cmd,CreateProcess 不能可靠地直接解析裸 npm。
280
372
  npm_command = "npm.cmd" if os.name == "nt" and shutil.which("npm.cmd") else "npm"
373
+ # 安装前测速切换最快 npm 源(nrm 缺失时自动安装;失败不阻塞创建)
374
+ nrm_speedtest(console, npm_command)
281
375
  for sub in ("frontend", "electron"):
282
376
  sub_dir = target_dir / sub
283
377
  if (sub_dir / "package.json").exists():
@@ -294,7 +388,7 @@ def init_command(package: str, sudoers: bool, terminal: bool, icon: str | None):
294
388
  ),
295
389
  })
296
390
  result = subprocess.run(
297
- [npm_command, "install"],
391
+ [npm_command, "install", "--no-audit", "--no-fund"],
298
392
  cwd=sub_dir,
299
393
  env=environment,
300
394
  capture_output=True,
@@ -1,5 +1,6 @@
1
1
  import pathlib
2
2
  import shutil
3
+ import sys
3
4
  import yaml
4
5
  from fastapi import APIRouter, HTTPException
5
6
  from pydantic import BaseModel, Field
@@ -12,6 +13,13 @@ from managers import sudoers_manager
12
13
  router = APIRouter()
13
14
  logger = get_logger(__name__)
14
15
 
16
+ # 运行平台标识(windows / macos / linux),前端据此隐藏平台无关的配置项
17
+ _PLATFORM = (
18
+ "windows" if sys.platform == "win32"
19
+ else "macos" if sys.platform == "darwin"
20
+ else "linux"
21
+ )
22
+
15
23
 
16
24
  [% if enable_sudo %]
17
25
  class ConfigRequest(BaseModel):
@@ -111,9 +119,12 @@ async def get_settings():
111
119
  "config_path": str(config_file),
112
120
  "secrets_path": str(secrets_file),
113
121
  "is_packaged": path_manager.is_packaged,
122
+ "platform": _PLATFORM,
114
123
  [% if enable_sudo %]
115
124
  "sudo_password_configured": bool(path_manager.get_config_value("sudo_password", config_file="secrets.yaml")),
116
125
  "sudo_password_locked": _sudo_password_locked(),
126
+ # Windows/macOS 上 sudoers_manager.required 为 False,前端隐藏 Sudo 密码卡片
127
+ "requires_sudo": sudoers_manager.required,
117
128
  [% endif %]
118
129
  "ports": {
119
130
  "backend": int(ports.get("backend") or 8000),
@@ -174,6 +185,9 @@ async def save_settings(req: AppSettingsRequest):
174
185
  @router.get("/config/check")
175
186
  async def check_config():
176
187
  secrets_file = path_manager.get_config_path("secrets.yaml")
188
+ # Windows/macOS 无 sudo 概念,前端据此隐藏配置入口、跳过首启弹窗
189
+ requires_sudo = sudoers_manager.required
190
+ platform = _PLATFORM
177
191
 
178
192
  if not secrets_file.exists():
179
193
  return {
@@ -182,6 +196,8 @@ async def check_config():
182
196
  "message": "密钥配置文件不存在",
183
197
  "config_path": str(secrets_file),
184
198
  "is_packaged": path_manager.is_packaged,
199
+ "requires_sudo": requires_sudo,
200
+ "platform": platform,
185
201
  }
186
202
 
187
203
  try:
@@ -194,6 +210,8 @@ async def check_config():
194
210
  "message": "密码未设置",
195
211
  "config_path": str(secrets_file),
196
212
  "is_packaged": path_manager.is_packaged,
213
+ "requires_sudo": requires_sudo,
214
+ "platform": platform,
197
215
  }
198
216
 
199
217
  if not sudoers_manager.verify_password(password):
@@ -203,6 +221,8 @@ async def check_config():
203
221
  "message": "密码不正确",
204
222
  "config_path": str(secrets_file),
205
223
  "is_packaged": path_manager.is_packaged,
224
+ "requires_sudo": requires_sudo,
225
+ "platform": platform,
206
226
  }
207
227
 
208
228
  sudoers_manager.setup()
@@ -212,6 +232,8 @@ async def check_config():
212
232
  "message": "配置正常",
213
233
  "config_path": str(secrets_file),
214
234
  "is_packaged": path_manager.is_packaged,
235
+ "requires_sudo": requires_sudo,
236
+ "platform": platform,
215
237
  }
216
238
  except HTTPException:
217
239
  raise