xb-init 2.2.4__tar.gz → 2.5.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 (99) hide show
  1. xb_init-2.5.2/.github/workflows/ci.yml +42 -0
  2. {xb_init-2.2.4 → xb_init-2.5.2}/PKG-INFO +28 -5
  3. {xb_init-2.2.4 → xb_init-2.5.2}/README.md +27 -4
  4. {xb_init-2.2.4 → xb_init-2.5.2}/pyproject.toml +3 -3
  5. {xb_init-2.2.4 → xb_init-2.5.2}/tests/test_cli.py +59 -11
  6. xb_init-2.5.2/tests/test_config_form.py +137 -0
  7. xb_init-2.5.2/tests/test_template_render.py +150 -0
  8. {xb_init-2.2.4 → xb_init-2.5.2}/tests/test_update_templates.py +8 -8
  9. xb_init-2.5.2/uv.lock +445 -0
  10. {xb_init-2.2.4 → xb_init-2.5.2}/xb/__init__.py +2 -1
  11. {xb_init-2.2.4 → xb_init-2.5.2}/xb/commands/build.py +8 -16
  12. {xb_init-2.2.4 → xb_init-2.5.2}/xb/commands/dev.py +8 -16
  13. {xb_init-2.2.4 → xb_init-2.5.2}/xb/commands/doctor.py +25 -3
  14. {xb_init-2.2.4 → xb_init-2.5.2}/xb/commands/init.py +64 -12
  15. xb_init-2.5.2/xb/commands/version.py +63 -0
  16. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/main.py.j2 +2 -2
  17. xb_init-2.5.2/xb/templates/configs/global_config.yaml.j2 +3 -0
  18. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/electron/package.json.j2 +1 -1
  19. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/index.html.j2 +1 -1
  20. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/App.vue.j2 +4 -4
  21. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/components/DashboardHome.vue.j2 +1 -1
  22. xb_init-2.5.2/xb/templates/root/LICENSE.j2 +21 -0
  23. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/root/README.md.j2 +1 -1
  24. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/root/pyproject.toml.j2 +1 -1
  25. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/version/scripts/version_manager.py.j2 +1 -1
  26. xb_init-2.5.2/xb/utils/config_form.py +308 -0
  27. xb_init-2.5.2/xb/utils/project.py +18 -0
  28. {xb_init-2.2.4 → xb_init-2.5.2}/xb/utils/template_engine.py +35 -4
  29. xb_init-2.2.4/uv.lock +0 -445
  30. xb_init-2.2.4/xb/commands/version.py +0 -65
  31. xb_init-2.2.4/xb/templates/configs/global_config.yaml.j2 +0 -3
  32. {xb_init-2.2.4 → xb_init-2.5.2}/.gitignore +0 -0
  33. {xb_init-2.2.4 → xb_init-2.5.2}/docs/screenshot.png +0 -0
  34. {xb_init-2.2.4 → xb_init-2.5.2}/tests/test_upgrade.py +0 -0
  35. {xb_init-2.2.4 → xb_init-2.5.2}/xb/cli.py +0 -0
  36. {xb_init-2.2.4 → xb_init-2.5.2}/xb/commands/__init__.py +0 -0
  37. {xb_init-2.2.4 → xb_init-2.5.2}/xb/commands/upgrade.py +0 -0
  38. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/api/__init__.py.j2 +0 -0
  39. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/api/config.py.j2 +0 -0
  40. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/api/ports.py.j2 +0 -0
  41. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/api/terminal/__init__.py.j2 +0 -0
  42. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/api/terminal/routes.py.j2 +0 -0
  43. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/api/update/__init__.py.j2 +0 -0
  44. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/app_version.py.j2 +0 -0
  45. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/backend_build.py.j2 +0 -0
  46. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/managers/__init__.py.j2 +0 -0
  47. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/managers/logger_manager.py.j2 +0 -0
  48. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/managers/path_manager.py.j2 +0 -0
  49. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/managers/secret_obfuscator.py.j2 +0 -0
  50. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/managers/sudoers_manager.py.j2 +0 -0
  51. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/requirements.txt.j2 +0 -0
  52. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/services/__init__.py.j2 +0 -0
  53. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/services/feishu_client.py.j2 +0 -0
  54. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/services/updater.py.j2 +0 -0
  55. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/configs/config_changes.json.j2 +0 -0
  56. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/configs/secrets.yaml.example.j2 +0 -0
  57. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/configs/secrets.yaml.j2 +0 -0
  58. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/electron/launcher.js.j2 +0 -0
  59. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/electron/main.js.j2 +0 -0
  60. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/electron/port_diagnostics.js.j2 +0 -0
  61. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/electron/preload.js.j2 +0 -0
  62. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/electron/prepare.js.j2 +0 -0
  63. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/electron/resources/postinst.py.j2 +0 -0
  64. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/electron/resources/postrm.py.j2 +0 -0
  65. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/electron/resources/uninstaller.nsh.j2 +0 -0
  66. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/package.json.j2 +0 -0
  67. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/components/ConfigSetup.vue.j2 +0 -0
  68. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/components/FileManager.vue.j2 +0 -0
  69. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/components/GitVersionBadge.vue.j2 +0 -0
  70. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/components/RefreshButton.vue.j2 +0 -0
  71. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/components/TerminalPanel.vue.j2 +0 -0
  72. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/components/TerminalView.vue.j2 +0 -0
  73. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/components/ThemeToggle.vue.j2 +0 -0
  74. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/components/version/CommitHistoryPanel.vue.j2 +0 -0
  75. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/components/version/UpdatePanel.vue.j2 +0 -0
  76. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/composables/useTerminalSessions.js.j2 +0 -0
  77. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/composables/useUpdate.js.j2 +0 -0
  78. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/main.js.j2 +0 -0
  79. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/style.css.j2 +0 -0
  80. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/vite.config.js.j2 +0 -0
  81. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/root/.gitignore.j2 +0 -0
  82. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/root/AGENTS.md.j2 +0 -0
  83. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/root/build.py.j2 +0 -0
  84. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/root/dev.py.j2 +0 -0
  85. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/root/docs/images/feishu-folder.png +0 -0
  86. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/root/docs/images/feishu-permission.png +0 -0
  87. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/root/docs/images/release-upload.png +0 -0
  88. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/root/docs/images/update-dialog.png +0 -0
  89. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/root/docs//350/207/252/345/212/250/346/233/264/346/226/260/350/257/264/346/230/216.md" +0 -0
  90. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/scripts/example.py.j2 +0 -0
  91. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/scripts/feishu_upload.py.j2 +0 -0
  92. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/version/hooks/pre-commit.py.j2 +0 -0
  93. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/version/scripts/gui_version_manager.py.j2 +0 -0
  94. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/version/scripts/install_hooks.py.j2 +0 -0
  95. {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/version/scripts/web_version_manager.py.j2 +0 -0
  96. {xb_init-2.2.4 → xb_init-2.5.2}/xb/utils/__init__.py +0 -0
  97. {xb_init-2.2.4 → xb_init-2.5.2}/xb/utils/click_helpers.py +0 -0
  98. {xb_init-2.2.4 → xb_init-2.5.2}/xb/utils/validators.py +0 -0
  99. {xb_init-2.2.4 → xb_init-2.5.2}/xb/utils/version_check.py +0 -0
@@ -0,0 +1,42 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [release_v0, main, master]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ strategy:
11
+ fail-fast: false
12
+ matrix:
13
+ os: [ubuntu-latest, windows-latest]
14
+ runs-on: ${{ matrix.os }}
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - name: Install uv
19
+ uses: astral-sh/setup-uv@v5
20
+ with:
21
+ python-version: "3.12"
22
+
23
+ - name: Sync dependencies
24
+ run: uv sync --extra dev
25
+
26
+ - name: Lint (ruff)
27
+ run: uv run ruff check xb tests
28
+
29
+ - name: Test (pytest)
30
+ shell: bash
31
+ run: |
32
+ set -o pipefail
33
+ uv run pytest -v --tb=line -ra 2>&1 | tee pytest-output.txt
34
+
35
+ - name: Expose failures as annotations
36
+ if: failure()
37
+ shell: bash
38
+ run: |
39
+ grep -E "^(FAILED|ERROR)" pytest-output.txt | while IFS= read -r line; do
40
+ echo "::error::${line:0:250}"
41
+ done
42
+ grep -E "^(FAILED|ERROR)" pytest-output.txt || echo "::error::no FAILED lines captured"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: xb-init
3
- Version: 2.2.4
3
+ Version: 2.5.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
@@ -77,7 +77,6 @@ xb init demo
77
77
 
78
78
  # 带 sudo 免密配置
79
79
  xb init demo --sudoers
80
-
81
80
  # 带内置终端
82
81
  xb init demo --terminal
83
82
 
@@ -91,7 +90,6 @@ xb init demo --update
91
90
  xb init demo --icon ~/icons/app.png
92
91
 
93
92
  cd demo
94
-
95
93
  # 启动开发环境
96
94
  xb dev
97
95
 
@@ -102,6 +100,29 @@ xb dev status
102
100
  xb dev stop
103
101
  ```
104
102
 
103
+ ### 交互式配置表单
104
+
105
+ `xb init` 会在创建前显示一个单屏配置表单,回车即按当前值创建:
106
+
107
+ ```
108
+ xb init demo — 应用配置
109
+ ↑↓ 移动 · 空格 勾选/取消 · 选中后直接输入 · 回车 开始创建
110
+ ────────────────────────────────────────────────────────
111
+ ❯ [ ] 应用显示名 : Demo
112
+ [ ] 端口(后端/前端) : 8000 / 8001
113
+ [ ] sudo 免密 ✘ (Linux 应用内更新需要)
114
+ [ ] 内置终端 ✘ (Web 终端组件)
115
+ [ ] 应用内更新 ✘ (自动附带 sudo 免密)
116
+ [ ] 应用图标 : 使用默认
117
+ [ ] 跳过依赖安装 ✘ (离线环境可跳过)
118
+ ```
119
+
120
+ - `--name/--port/--sudoers/--terminal/--update/--icon/--skip-install` 传了的参数
121
+ 直接预填进表单并勾上,回车即确认,老用法不受影响
122
+ - 勾选"应用内更新"会自动勾上"sudo 免密",且后者此时不可单独取消
123
+ - 文本行(显示名/端口/图标)勾选时若仍是默认值则自动清空,直接输入即可
124
+ - stdin 非终端时(脚本/CI 管道调用)自动跳过表单,沿用 CLI 参数
125
+
105
126
  Windows PowerShell 也可以直接运行生成项目脚本:
106
127
 
107
128
  ```powershell
@@ -121,6 +142,7 @@ Electron 二进制镜像通过 `ELECTRON_MIRROR` 环境变量注入。
121
142
 
122
143
  > **`xb init` 自动行为**:
123
144
  > - 执行 `npm install`(frontend + electron),生成 `package-lock.json`
145
+ > 离线环境可加 `--skip-install` 跳过,稍后手动安装
124
146
  > - 生成的项目用 [nrm](https://github.com/Pana/nrm) 管理 npm 源:`xb init`
125
147
  > 安装依赖前与 `build.py` 构建前都会自动 `nrm test` 测速所有内置源并切换
126
148
  > 到最快的(未安装 nrm 时自动全局安装);项目不放 `.npmrc`,npm 源完全由
@@ -138,7 +160,7 @@ Electron 二进制镜像通过 `ELECTRON_MIRROR` 环境变量注入。
138
160
 
139
161
  | 命令 | 说明 |
140
162
  |------|------|
141
- | `xb init <name> [--sudoers] [--terminal] [--update] [--icon PATH]` | 初始化项目 |
163
+ | `xb init <name> [--name 显示名] [--port 9000] [--sudoers] [--terminal] [--update] [--icon PATH] [--skip-install]` | 初始化项目 |
142
164
  | `xb dev [start\|stop\|status]` | 启动/停止/查看开发环境 |
143
165
  | `xb build [all\|frontend\|backend\|electron]` | 构建项目 |
144
166
  | `xb build -f / -b / -e / -a` | 构建快捷 flag |
@@ -163,7 +185,7 @@ xb init demo --icon ./my-icon.png
163
185
  > **[xb/templates/root/docs/自动更新说明.md](xb/templates/root/docs/自动更新说明.md)**
164
186
  > (生成的项目会自带 `docs/自动更新说明.md` 与配图)。
165
187
 
166
- `xb init demo --update` 会让生成的项目内置与 [leo_stm32_board_test](../leo_stm32_board_test) 同源的自动更新能力,
188
+ `xb init demo --update` 会让生成的项目内置自动更新能力,
167
189
  以飞书云盘的一个文件夹作为发布"货架":
168
190
 
169
191
  **生成内容:**
@@ -201,6 +223,7 @@ demo/
201
223
  ├── pyproject.toml # Python 依赖(uv 管理)
202
224
  ├── AGENTS.md # AI 编码助手协作约定
203
225
  ├── README.md # 项目说明(含三种启动方式详解)
226
+ ├── LICENSE # MIT 许可证
204
227
  ├── .gitignore
205
228
  ├── backend/ # FastAPI 后端
206
229
  │ ├── main.py # FastAPI 入口(lifespan / CORS / SPA 兜底)
@@ -48,7 +48,6 @@ xb init demo
48
48
 
49
49
  # 带 sudo 免密配置
50
50
  xb init demo --sudoers
51
-
52
51
  # 带内置终端
53
52
  xb init demo --terminal
54
53
 
@@ -62,7 +61,6 @@ xb init demo --update
62
61
  xb init demo --icon ~/icons/app.png
63
62
 
64
63
  cd demo
65
-
66
64
  # 启动开发环境
67
65
  xb dev
68
66
 
@@ -73,6 +71,29 @@ xb dev status
73
71
  xb dev stop
74
72
  ```
75
73
 
74
+ ### 交互式配置表单
75
+
76
+ `xb init` 会在创建前显示一个单屏配置表单,回车即按当前值创建:
77
+
78
+ ```
79
+ xb init demo — 应用配置
80
+ ↑↓ 移动 · 空格 勾选/取消 · 选中后直接输入 · 回车 开始创建
81
+ ────────────────────────────────────────────────────────
82
+ ❯ [ ] 应用显示名 : Demo
83
+ [ ] 端口(后端/前端) : 8000 / 8001
84
+ [ ] sudo 免密 ✘ (Linux 应用内更新需要)
85
+ [ ] 内置终端 ✘ (Web 终端组件)
86
+ [ ] 应用内更新 ✘ (自动附带 sudo 免密)
87
+ [ ] 应用图标 : 使用默认
88
+ [ ] 跳过依赖安装 ✘ (离线环境可跳过)
89
+ ```
90
+
91
+ - `--name/--port/--sudoers/--terminal/--update/--icon/--skip-install` 传了的参数
92
+ 直接预填进表单并勾上,回车即确认,老用法不受影响
93
+ - 勾选"应用内更新"会自动勾上"sudo 免密",且后者此时不可单独取消
94
+ - 文本行(显示名/端口/图标)勾选时若仍是默认值则自动清空,直接输入即可
95
+ - stdin 非终端时(脚本/CI 管道调用)自动跳过表单,沿用 CLI 参数
96
+
76
97
  Windows PowerShell 也可以直接运行生成项目脚本:
77
98
 
78
99
  ```powershell
@@ -92,6 +113,7 @@ Electron 二进制镜像通过 `ELECTRON_MIRROR` 环境变量注入。
92
113
 
93
114
  > **`xb init` 自动行为**:
94
115
  > - 执行 `npm install`(frontend + electron),生成 `package-lock.json`
116
+ > 离线环境可加 `--skip-install` 跳过,稍后手动安装
95
117
  > - 生成的项目用 [nrm](https://github.com/Pana/nrm) 管理 npm 源:`xb init`
96
118
  > 安装依赖前与 `build.py` 构建前都会自动 `nrm test` 测速所有内置源并切换
97
119
  > 到最快的(未安装 nrm 时自动全局安装);项目不放 `.npmrc`,npm 源完全由
@@ -109,7 +131,7 @@ Electron 二进制镜像通过 `ELECTRON_MIRROR` 环境变量注入。
109
131
 
110
132
  | 命令 | 说明 |
111
133
  |------|------|
112
- | `xb init <name> [--sudoers] [--terminal] [--update] [--icon PATH]` | 初始化项目 |
134
+ | `xb init <name> [--name 显示名] [--port 9000] [--sudoers] [--terminal] [--update] [--icon PATH] [--skip-install]` | 初始化项目 |
113
135
  | `xb dev [start\|stop\|status]` | 启动/停止/查看开发环境 |
114
136
  | `xb build [all\|frontend\|backend\|electron]` | 构建项目 |
115
137
  | `xb build -f / -b / -e / -a` | 构建快捷 flag |
@@ -134,7 +156,7 @@ xb init demo --icon ./my-icon.png
134
156
  > **[xb/templates/root/docs/自动更新说明.md](xb/templates/root/docs/自动更新说明.md)**
135
157
  > (生成的项目会自带 `docs/自动更新说明.md` 与配图)。
136
158
 
137
- `xb init demo --update` 会让生成的项目内置与 [leo_stm32_board_test](../leo_stm32_board_test) 同源的自动更新能力,
159
+ `xb init demo --update` 会让生成的项目内置自动更新能力,
138
160
  以飞书云盘的一个文件夹作为发布"货架":
139
161
 
140
162
  **生成内容:**
@@ -172,6 +194,7 @@ demo/
172
194
  ├── pyproject.toml # Python 依赖(uv 管理)
173
195
  ├── AGENTS.md # AI 编码助手协作约定
174
196
  ├── README.md # 项目说明(含三种启动方式详解)
197
+ ├── LICENSE # MIT 许可证
175
198
  ├── .gitignore
176
199
  ├── backend/ # FastAPI 后端
177
200
  │ ├── main.py # FastAPI 入口(lifespan / CORS / SPA 兜底)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "xb-init"
7
- version = "2.2.4"
7
+ version = "2.5.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"
@@ -51,11 +51,11 @@ packages = ["xb"]
51
51
 
52
52
  [tool.black]
53
53
  line-length = 100
54
- target-version = ['py310']
54
+ target-version = ['py312']
55
55
 
56
56
  [tool.ruff]
57
57
  line-length = 100
58
- target-version = "py310"
58
+ target-version = "py312"
59
59
 
60
60
  [tool.ruff.lint]
61
61
  select = ["E", "F", "I", "N", "W", "B"]
@@ -15,36 +15,48 @@ def test_upgrade_option_runs_without_subcommand(monkeypatch):
15
15
  assert "Missing command" not in result.output
16
16
 
17
17
 
18
- def _invoke_init(monkeypatch, tmp_path, args, password):
19
- """跑 init 命令但替换掉耗时/有副作用的步骤,捕获传给模板引擎的参数。"""
18
+ def _invoke_init(monkeypatch, tmp_path, args, password, platform="linux"):
19
+ """跑 init 命令但替换掉耗时/有副作用的步骤,捕获传给模板引擎的参数。
20
+
21
+ - 密码通过 monkeypatch rich Prompt.ask 注入而非 stdin 管道
22
+ - platform 默认 patch 为 linux(询问密码的行为只在 Linux 分支),
23
+ 否则真 Windows runner 上 sys.platform=win32 会直接跳过密码询问
24
+ """
20
25
  captured = {}
26
+ asked = []
21
27
  monkeypatch.setattr(init_module, "get_latest_if_newer", lambda _v: None)
22
28
  monkeypatch.setattr(init_module.shutil, "which", lambda _name: None)
23
29
  monkeypatch.setattr(init_module, "init_git_repo", lambda *_a, **_k: True)
30
+ monkeypatch.setattr(init_module.sys, "platform", platform)
24
31
  monkeypatch.setattr(
25
32
  init_module.TemplateEngine,
26
33
  "render_project",
27
34
  lambda self, **kwargs: captured.update(kwargs),
28
35
  )
29
36
 
37
+ def fake_ask(*_args, **_kwargs):
38
+ asked.append(True)
39
+ return password
40
+
41
+ monkeypatch.setattr(init_module.Prompt, "ask", fake_ask)
42
+
30
43
  result = CliRunner().invoke(
31
- cli.main, ["init", str(tmp_path / "demo"), *args], input=f"{password}\n"
44
+ cli.main, ["init", str(tmp_path / "demo"), *args], input="\n"
32
45
  )
33
46
  assert result.exit_code == 0, result.output
34
- return captured, result.output
47
+ return captured, result.output, asked
35
48
 
36
49
 
37
50
  def test_update_implies_sudoers(monkeypatch, tmp_path):
38
- captured, output = _invoke_init(monkeypatch, tmp_path, ["--update"], "pw123")
51
+ captured, _output, _asked = _invoke_init(monkeypatch, tmp_path, ["--update"], "pw123")
39
52
 
40
53
  assert captured["enable_update"] is True
41
54
  assert captured["enable_sudo"] is True
42
55
  assert captured["sudo_password"] == "pw123"
43
- assert "自动附带 --sudoers" in output
44
56
 
45
57
 
46
58
  def test_sudoers_alone_still_prompts(monkeypatch, tmp_path):
47
- captured, output = _invoke_init(monkeypatch, tmp_path, ["--sudoers"], "pw123")
59
+ captured, output, asked = _invoke_init(monkeypatch, tmp_path, ["--sudoers"], "pw123")
48
60
 
49
61
  assert captured["enable_update"] is False
50
62
  assert captured["enable_sudo"] is True
@@ -53,13 +65,49 @@ def test_sudoers_alone_still_prompts(monkeypatch, tmp_path):
53
65
 
54
66
 
55
67
  def test_plain_init_has_no_sudo(monkeypatch, tmp_path):
56
- captured, output = _invoke_init(monkeypatch, tmp_path, [], "")
68
+ captured, output, asked = _invoke_init(monkeypatch, tmp_path, [], "")
57
69
 
58
70
  assert captured["enable_update"] is False
59
71
  assert captured["enable_sudo"] is False
60
72
  assert captured["sudo_password"] == ""
61
73
 
62
74
 
75
+ def test_display_name_passed_through(monkeypatch, tmp_path):
76
+ captured, _, _asked = _invoke_init(
77
+ monkeypatch, tmp_path, ["--name", "我的应用"], ""
78
+ )
79
+
80
+ assert captured["display_name"] == "我的应用"
81
+
82
+
83
+ def test_display_name_defaults_to_none(monkeypatch, tmp_path):
84
+ captured, _, _asked = _invoke_init(monkeypatch, tmp_path, [], "")
85
+
86
+ assert captured["display_name"] is None
87
+
88
+
89
+ def test_port_passed_through(monkeypatch, tmp_path):
90
+ captured, _output, _asked = _invoke_init(monkeypatch, tmp_path, ["--port", "9100"], "")
91
+
92
+ assert captured["backend_port"] == 9100
93
+
94
+
95
+ def test_port_defaults_to_none(monkeypatch, tmp_path):
96
+ captured, _, _asked = _invoke_init(monkeypatch, tmp_path, [], "")
97
+
98
+ assert captured["backend_port"] is None
99
+
100
+
101
+ def test_port_out_of_range_rejected(monkeypatch, tmp_path):
102
+ monkeypatch.setattr(init_module, "get_latest_if_newer", lambda _v: None)
103
+
104
+ result = CliRunner().invoke(
105
+ cli.main, ["init", str(tmp_path / "demo"), "--port", "70000"], input="\n"
106
+ )
107
+
108
+ assert result.exit_code != 0
109
+
110
+
63
111
  def test_pick_fastest_from_nrm_output():
64
112
  output = """ npm ---------- 807 ms
65
113
  yarn --------- 795 ms
@@ -78,11 +126,11 @@ def test_pick_fastest_from_nrm_output_all_failed():
78
126
 
79
127
 
80
128
  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"
129
+ captured, output, asked = _invoke_init(
130
+ monkeypatch, tmp_path, ["--sudoers"], "should-not-be-used", platform="win32"
84
131
  )
85
132
 
86
133
  assert captured["enable_sudo"] is True
87
134
  assert captured["sudo_password"] == ""
135
+ assert asked == [] # Windows 不询问密码
88
136
  assert "跳过密码输入" in output
@@ -0,0 +1,137 @@
1
+ """配置表单(ConfigForm)交互逻辑测试:不进键盘循环,直接驱动方法。
2
+
3
+ 表单在 Windows 上会隐藏 sudoers 行,逻辑测试统一以 Linux 形态为准
4
+ (mock os.name),Windows 隐藏行为由 test_sudoers_row_hidden_on_windows 覆盖。
5
+ """
6
+
7
+ import unittest.mock as mock
8
+
9
+ from xb.utils.config_form import ConfigForm, run_config_form
10
+
11
+
12
+ def make_form(cli=None):
13
+ with mock.patch("xb.utils.config_form.os.name", "posix"):
14
+ return ConfigForm("demo", cli)
15
+
16
+
17
+ def _cursor_key(form, key):
18
+ form.cursor = next(
19
+ i for i, r in enumerate(form.rows) if r["key"] == key)
20
+
21
+
22
+ def test_update_checks_sudoers_and_blocks_unchecking():
23
+ form = make_form()
24
+ _cursor_key(form, "update")
25
+ form.toggle()
26
+ assert form._row("update")["checked"] is True
27
+ assert form._row("sudoers")["checked"] is True
28
+
29
+ # update 勾着时 sudoers 无法单独取消
30
+ _cursor_key(form, "sudoers")
31
+ form.toggle()
32
+ assert form._row("sudoers")["checked"] is True
33
+ assert form.message # 有提示
34
+
35
+
36
+ def test_sudoers_toggle_without_update():
37
+ form = make_form()
38
+ _cursor_key(form, "sudoers")
39
+ form.toggle()
40
+ assert form._row("sudoers")["checked"] is True
41
+ form.toggle()
42
+ assert form._row("sudoers")["checked"] is False
43
+ assert not form.message
44
+
45
+
46
+ def test_text_row_clears_default_on_first_check():
47
+ form = make_form()
48
+ _cursor_key(form, "port")
49
+ assert form._row("port")["value"] == "8000"
50
+ form.toggle()
51
+ assert form._row("port")["checked"] is True
52
+ assert form._row("port")["value"] == "" # 默认值清空等待输入
53
+
54
+ for ch in "9100":
55
+ form.feed_char(ch)
56
+ assert form._row("port")["value"] == "9100"
57
+
58
+
59
+ def test_text_row_keeps_custom_value_when_rechecked():
60
+ form = make_form({"display_name": "机器人"})
61
+ _cursor_key(form, "display_name")
62
+ # CLI 预填的值直接勾上
63
+ assert form._row("display_name")["checked"] is True
64
+ assert form._row("display_name")["value"] == "机器人"
65
+
66
+ form.toggle() # 取消
67
+ form.toggle() # 再勾上,自定义值不被清掉
68
+ assert form._row("display_name")["value"] == "机器人"
69
+
70
+
71
+ def test_collect_unchecked_items_are_none():
72
+ form = make_form()
73
+ result = form.collect()
74
+ assert result["display_name"] is None
75
+ assert result["port"] is None
76
+ assert result["sudoers"] is False
77
+ assert result["terminal"] is False
78
+ assert result["update"] is False
79
+ assert result["icon"] is None
80
+ assert result["skip_install"] is False
81
+
82
+
83
+ def test_collect_valid_port():
84
+ form = make_form()
85
+ _cursor_key(form, "port")
86
+ form.toggle()
87
+ for ch in "9200":
88
+ form.feed_char(ch)
89
+ result = form.collect()
90
+ assert result["port"] == 9200
91
+ assert result["sudoers"] is False
92
+
93
+
94
+ def test_collect_invalid_port_falls_back_to_none():
95
+ form = make_form()
96
+ _cursor_key(form, "port")
97
+ form.toggle()
98
+ for ch in "99999":
99
+ form.feed_char(ch)
100
+ result = form.collect()
101
+ assert result["port"] is None
102
+ assert form._row("port")["checked"] is False
103
+
104
+
105
+ def test_sudoers_row_hidden_on_windows():
106
+ with mock.patch("xb.utils.config_form.os.name", "nt"):
107
+ form = ConfigForm("demo")
108
+ assert not form._has("sudoers")
109
+ result = form.collect()
110
+ assert result["sudoers"] is False
111
+
112
+
113
+ def test_backspace_only_on_checked_text_row():
114
+ form = make_form({"display_name": "abc"})
115
+ _cursor_key(form, "display_name")
116
+ form.backspace()
117
+ assert form._row("display_name")["value"] == "ab"
118
+
119
+ _cursor_key(form, "terminal")
120
+ form.backspace() # 开关行:无值可删,不报错即可
121
+
122
+
123
+ def test_run_config_form_skipped_when_not_tty():
124
+ result = run_config_form("demo", {
125
+ "display_name": None,
126
+ "port": 9100,
127
+ "sudoers": False,
128
+ "terminal": True,
129
+ "update": True,
130
+ "icon": None,
131
+ "skip_install": False,
132
+ })
133
+ # 非 tty 环境跳过表单,CLI 值原样透传且 update 联动 sudoers
134
+ assert result["port"] == 9100
135
+ assert result["sudoers"] is True
136
+ assert result["terminal"] is True
137
+ assert result["display_name"] is None
@@ -0,0 +1,150 @@
1
+ """模板渲染矩阵测试:sudoers x terminal x update 全组合。
2
+
3
+ 渲染到临时目录,校验:
4
+ - 条件文件按开关出现/缺席(防止模板改坏条件渲染)
5
+ - 所有生成的 .py 能通过 compile() 语法检查(防止模板产出非法 Python)
6
+ - secrets.yaml 权限收紧为 0600(POSIX)
7
+ """
8
+
9
+ import itertools
10
+ import json
11
+ import os
12
+
13
+ import pytest
14
+
15
+ from xb.utils.template_engine import TemplateEngine
16
+
17
+ COMBOS = list(itertools.product([False, True], repeat=3))
18
+
19
+
20
+ def _render(
21
+ tmp_path, enable_sudo, enable_terminal, enable_update, display_name=None,
22
+ backend_port=None,
23
+ ):
24
+ target = tmp_path / "demo"
25
+ TemplateEngine().render_project(
26
+ target_dir=target,
27
+ package_name="demo",
28
+ display_name=display_name,
29
+ backend_port=backend_port,
30
+ enable_sudo=enable_sudo,
31
+ enable_terminal=enable_terminal,
32
+ enable_update=enable_update,
33
+ sudo_password="pw123" if enable_sudo else "",
34
+ )
35
+ return target
36
+
37
+
38
+ @pytest.mark.parametrize(("enable_sudo", "enable_terminal", "enable_update"), COMBOS)
39
+ def test_render_project_matrix(tmp_path, enable_sudo, enable_terminal, enable_update):
40
+ target = _render(tmp_path, enable_sudo, enable_terminal, enable_update)
41
+
42
+ # 基础骨架始终存在
43
+ for rel in (
44
+ "pyproject.toml",
45
+ "dev.py",
46
+ "build.py",
47
+ "README.md",
48
+ "LICENSE",
49
+ "AGENTS.md",
50
+ "backend/main.py",
51
+ "backend/managers/path_manager.py",
52
+ "frontend/src/App.vue",
53
+ "electron/main.js",
54
+ "configs/global_config.yaml",
55
+ "version/scripts/version_manager.py",
56
+ ):
57
+ assert (target / rel).exists(), f"缺少基础文件 {rel}"
58
+
59
+ # 条件文件按开关出现/缺席
60
+ conditional = {
61
+ "backend/api/terminal/routes.py": enable_terminal,
62
+ "frontend/src/components/TerminalPanel.vue": enable_terminal,
63
+ "frontend/src/composables/useTerminalSessions.js": enable_terminal,
64
+ "backend/managers/sudoers_manager.py": enable_sudo or enable_update,
65
+ "backend/api/update/__init__.py": enable_update,
66
+ "backend/services/updater.py": enable_update,
67
+ "backend/managers/secret_obfuscator.py": enable_update,
68
+ "scripts/feishu_upload.py": enable_update,
69
+ "frontend/src/composables/useUpdate.js": enable_update,
70
+ }
71
+ for rel, should_exist in conditional.items():
72
+ assert (target / rel).exists() is should_exist, f"{rel} 存在性与开关不符"
73
+
74
+ # secrets.yaml 仅在 sudo + 提供密码时生成
75
+ secrets = target / "configs" / "secrets.yaml"
76
+ assert secrets.exists() is (enable_sudo and bool("pw123"))
77
+ if secrets.exists() and os.name != "nt":
78
+ assert secrets.stat().st_mode & 0o777 == 0o600, "secrets.yaml 应为 0600 权限"
79
+
80
+ # 生成的所有 Python 文件必须是合法语法(含带 jinja 条件的模板产物)
81
+ for py in target.rglob("*.py"):
82
+ source = py.read_text(encoding="utf-8")
83
+ compile(source, str(py), "exec")
84
+
85
+
86
+ def test_render_project_main_py_switches(tmp_path):
87
+ """main.py 的路由注册随开关变化。"""
88
+ plain = _render(tmp_path / "a", False, False, False)
89
+ main_plain = (plain / "backend" / "main.py").read_text(encoding="utf-8")
90
+ assert "terminal_router" not in main_plain
91
+ assert "update_router" not in main_plain
92
+ assert "sudoers_manager" not in main_plain
93
+
94
+ full = _render(tmp_path / "b", True, True, True)
95
+ main_full = (full / "backend" / "main.py").read_text(encoding="utf-8")
96
+ assert "terminal_router" in main_full
97
+ assert "update_router" in main_full
98
+ assert "sudoers_manager" in main_full
99
+
100
+
101
+ def test_display_name_defaults_to_capitalized_package(tmp_path):
102
+ target = _render(tmp_path, False, False, False)
103
+ assert "<title>Demo</title>" in (target / "frontend" / "index.html").read_text(
104
+ encoding="utf-8"
105
+ )
106
+
107
+
108
+ def test_ports_written_to_global_config(tmp_path):
109
+ """--port 指定后端端口时,前端端口自动 +1;不传时用默认值。"""
110
+ target = _render(tmp_path, False, False, False, backend_port=9100)
111
+ config = (target / "configs" / "global_config.yaml").read_text(encoding="utf-8")
112
+ assert "backend: 9100" in config
113
+ assert "frontend: 9101" in config
114
+
115
+ default = _render(tmp_path / "d", False, False, False)
116
+ default_config = (default / "configs" / "global_config.yaml").read_text(
117
+ encoding="utf-8"
118
+ )
119
+ assert "backend: 8000" in default_config
120
+ assert "frontend: 5173" in default_config
121
+
122
+
123
+ def test_display_name_with_quotes_and_chinese(tmp_path):
124
+ """显示名含引号/中文/空格时,各产物文件必须仍然合法。"""
125
+ display_name = '我的 "Cool" App'
126
+ target = _render(tmp_path, True, False, True, display_name=display_name)
127
+
128
+ # HTML 文案直接使用显示名
129
+ assert f"<title>{display_name}</title>" in (
130
+ target / "frontend" / "index.html"
131
+ ).read_text(encoding="utf-8")
132
+
133
+ # electron/package.json 必须是合法 JSON 且 description 正确
134
+ pkg = json.loads((target / "electron" / "package.json").read_text(encoding="utf-8"))
135
+ assert pkg["description"] == display_name
136
+ # 技术标识不被显示名污染
137
+ assert pkg["name"] == "demo"
138
+ assert pkg["build"]["productName"] == "Demo"
139
+
140
+ # Python 产物可编译且标题正确(tojson 转义后的字面量)
141
+ main_py = (target / "backend" / "main.py").read_text(encoding="utf-8")
142
+ compile(main_py, "main.py", "exec")
143
+ assert f"title={json.dumps(display_name, ensure_ascii=False)}" in main_py
144
+
145
+ # pyproject 的 TOML description 合法(TOML basic string 与 JSON 转义兼容)
146
+ pyproject = (target / "pyproject.toml").read_text(encoding="utf-8")
147
+ assert 'description = "我的 \\"Cool\\" App' in pyproject
148
+
149
+ # README 标题使用显示名
150
+ assert f"# {display_name}" in (target / "README.md").read_text(encoding="utf-8")