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.
- xb_init-2.5.2/.github/workflows/ci.yml +42 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/PKG-INFO +28 -5
- {xb_init-2.2.4 → xb_init-2.5.2}/README.md +27 -4
- {xb_init-2.2.4 → xb_init-2.5.2}/pyproject.toml +3 -3
- {xb_init-2.2.4 → xb_init-2.5.2}/tests/test_cli.py +59 -11
- xb_init-2.5.2/tests/test_config_form.py +137 -0
- xb_init-2.5.2/tests/test_template_render.py +150 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/tests/test_update_templates.py +8 -8
- xb_init-2.5.2/uv.lock +445 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/__init__.py +2 -1
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/commands/build.py +8 -16
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/commands/dev.py +8 -16
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/commands/doctor.py +25 -3
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/commands/init.py +64 -12
- xb_init-2.5.2/xb/commands/version.py +63 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/main.py.j2 +2 -2
- xb_init-2.5.2/xb/templates/configs/global_config.yaml.j2 +3 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/electron/package.json.j2 +1 -1
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/index.html.j2 +1 -1
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/App.vue.j2 +4 -4
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/components/DashboardHome.vue.j2 +1 -1
- xb_init-2.5.2/xb/templates/root/LICENSE.j2 +21 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/root/README.md.j2 +1 -1
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/root/pyproject.toml.j2 +1 -1
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/version/scripts/version_manager.py.j2 +1 -1
- xb_init-2.5.2/xb/utils/config_form.py +308 -0
- xb_init-2.5.2/xb/utils/project.py +18 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/utils/template_engine.py +35 -4
- xb_init-2.2.4/uv.lock +0 -445
- xb_init-2.2.4/xb/commands/version.py +0 -65
- xb_init-2.2.4/xb/templates/configs/global_config.yaml.j2 +0 -3
- {xb_init-2.2.4 → xb_init-2.5.2}/.gitignore +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/docs/screenshot.png +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/tests/test_upgrade.py +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/cli.py +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/commands/__init__.py +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/commands/upgrade.py +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/api/__init__.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/api/config.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/api/ports.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/api/terminal/__init__.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/api/terminal/routes.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/api/update/__init__.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/app_version.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/backend_build.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/managers/__init__.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/managers/logger_manager.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/managers/path_manager.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/managers/secret_obfuscator.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/managers/sudoers_manager.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/requirements.txt.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/services/__init__.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/services/feishu_client.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/backend/services/updater.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/configs/config_changes.json.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/configs/secrets.yaml.example.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/configs/secrets.yaml.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/electron/launcher.js.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/electron/main.js.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/electron/port_diagnostics.js.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/electron/preload.js.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/electron/prepare.js.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/electron/resources/postinst.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/electron/resources/postrm.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/electron/resources/uninstaller.nsh.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/package.json.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/components/ConfigSetup.vue.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/components/FileManager.vue.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/components/GitVersionBadge.vue.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/components/RefreshButton.vue.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/components/TerminalPanel.vue.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/components/TerminalView.vue.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/components/ThemeToggle.vue.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/components/version/CommitHistoryPanel.vue.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/components/version/UpdatePanel.vue.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/composables/useTerminalSessions.js.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/composables/useUpdate.js.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/main.js.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/src/style.css.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/frontend/vite.config.js.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/root/.gitignore.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/root/AGENTS.md.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/root/build.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/root/dev.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/root/docs/images/feishu-folder.png +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/root/docs/images/feishu-permission.png +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/root/docs/images/release-upload.png +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/root/docs/images/update-dialog.png +0 -0
- {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
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/scripts/example.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/scripts/feishu_upload.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/version/hooks/pre-commit.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/version/scripts/gui_version_manager.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/version/scripts/install_hooks.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/templates/version/scripts/web_version_manager.py.j2 +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/utils/__init__.py +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/utils/click_helpers.py +0 -0
- {xb_init-2.2.4 → xb_init-2.5.2}/xb/utils/validators.py +0 -0
- {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
|
|
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`
|
|
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`
|
|
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
|
|
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 = ['
|
|
54
|
+
target-version = ['py312']
|
|
55
55
|
|
|
56
56
|
[tool.ruff]
|
|
57
57
|
line-length = 100
|
|
58
|
-
target-version = "
|
|
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=
|
|
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,
|
|
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
|
-
|
|
82
|
-
|
|
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")
|