@gavinc-cn/touchstone-dsh 0.2.0 → 0.3.0
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.
- package/LICENSE +0 -0
- package/README.md +23 -15
- package/README.zh.md +17 -12
- package/auth.py +0 -0
- package/backend/lib/access.js +44 -0
- package/backend/lib/agents.js +341 -0
- package/backend/lib/args.js +94 -0
- package/backend/lib/auth.js +195 -0
- package/backend/lib/board.js +6154 -0
- package/backend/lib/builtin-assets.js +1177 -0
- package/backend/lib/casescan.js +456 -0
- package/backend/lib/chat.js +2053 -0
- package/backend/lib/cookie.js +76 -0
- package/backend/lib/db.js +2260 -0
- package/backend/lib/dshdriver.js +1148 -0
- package/backend/lib/dshevents.js +851 -0
- package/backend/lib/export-cases.js +261 -0
- package/backend/lib/feishu.js +1738 -0
- package/backend/lib/feishu_cfg.js +228 -0
- package/backend/lib/feishu_conv.js +1484 -0
- package/backend/lib/feishu_device.js +428 -0
- package/backend/lib/feishu_inbound.js +1674 -0
- package/backend/lib/feishu_provision.js +855 -0
- package/backend/lib/feishu_rest.js +398 -0
- package/backend/lib/feishu_slash.js +568 -0
- package/backend/lib/feishu_ws.js +950 -0
- package/backend/lib/http.js +312 -0
- package/backend/lib/lib.js +973 -0
- package/backend/lib/loadcase.js +749 -0
- package/backend/lib/loadgen.js +1407 -0
- package/backend/lib/localbus.js +164 -0
- package/backend/lib/lock.js +405 -0
- package/backend/lib/proc.js +278 -0
- package/backend/lib/prompts.js +1021 -0
- package/backend/lib/pybase64.js +114 -0
- package/backend/lib/pyjson.js +214 -0
- package/backend/lib/query.js +125 -0
- package/backend/lib/rag.js +1310 -0
- package/backend/lib/runner.js +4520 -0
- package/backend/lib/servercaps.js +182 -0
- package/backend/lib/serverstate.js +237 -0
- package/backend/lib/sessparse.js +1176 -0
- package/backend/lib/sse.js +207 -0
- package/backend/lib/static.js +84 -0
- package/backend/lib/taskjson.js +302 -0
- package/backend/lib/waitq.js +2114 -0
- package/backend/lib/worktree.js +588 -0
- package/backend/lib/zstd-frames.js +218 -0
- package/backend/routes/admin.js +553 -0
- package/backend/routes/agents.js +149 -0
- package/backend/routes/assets.js +145 -0
- package/backend/routes/board.js +1220 -0
- package/backend/routes/feishu.js +269 -0
- package/backend/routes/feishu_app.js +215 -0
- package/backend/routes/feishu_hook.js +358 -0
- package/backend/routes/index.js +297 -0
- package/backend/routes/projects.js +1059 -0
- package/backend/routes/sessions.js +946 -0
- package/backend/routes/tasks.js +1530 -0
- package/backend/schema/fixtures/ancient.sql +189 -0
- package/backend/schema/fixtures/pre_alter.sql +177 -0
- package/backend/schema/migrations.json +154 -0
- package/backend/schema/schema.json +1451 -0
- package/backend/schema/schema.sql +216 -0
- package/backend/server.js +498 -0
- package/board.py +30 -1
- package/builtin_assets.py +0 -0
- package/builtin_prompts/free_style/flow.md +0 -0
- package/builtin_prompts/free_style/load_test.md +0 -0
- package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
- package/builtin_prompts/free_style/templates/case_template.md +0 -0
- package/builtin_prompts/free_style/templates/index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
- package/builtin_prompts/free_style/templates/status_template.md +0 -0
- package/db.py +294 -129
- package/dsh-plugin/cordis.patch.yml +0 -0
- package/dsh-plugin/install.sh +22 -8
- package/dsh-plugin/lib/client.bundle.js +0 -0
- package/dsh-plugin/lib/index.js +299 -29
- package/dsh-plugin/lib/pack-closure.mjs +291 -0
- package/dsh-plugin/scripts/build.mjs +0 -0
- package/dsh-plugin/scripts/prepack-check.mjs +30 -2
- package/dsh-plugin/scripts/select-bundle.mjs +0 -0
- package/dsh-plugin/src/client.js +0 -0
- package/dshevents.py +0 -0
- package/export_cases.py +0 -0
- package/extensions/dsh-autocommit/asset.json +0 -0
- package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
- package/extensions/dsh-autocommit/pkg/package.json +0 -0
- package/feishu_conv.py +0 -0
- package/lib.py +0 -0
- package/lifecycle.py +116 -13
- package/loadcase.py +0 -0
- package/loadgen.py +0 -0
- package/localbus.py +0 -0
- package/migrate_p7b.py +0 -0
- package/package.json +6 -1
- package/platcompat.py +0 -0
- package/prompts.py +0 -0
- package/rag.py +0 -0
- package/requirements.txt +0 -0
- package/touchstone.py +0 -0
- package/waitq.py +0 -0
- package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
- package/webui/dist-plugin/assets/{index-JLwyZZxC.js → index-UrMyeAXF.js} +110 -110
- package/webui/dist-plugin/index.html +2 -2
- package/worktree.py +0 -0
- package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
package/LICENSE
CHANGED
|
File without changes
|
package/README.md
CHANGED
|
@@ -31,8 +31,10 @@ remembers what it already tried. A unified queue keeps exactly one writer per pr
|
|
|
31
31
|
editing the same checkout at once), and the whole thing is observable — live round logs, a rendered
|
|
32
32
|
session window, and an SSE-backed board.
|
|
33
33
|
|
|
34
|
-
Touchstone runs **locally**.
|
|
35
|
-
|
|
34
|
+
Touchstone runs **locally**. Standalone mode is a Python web site; plugin mode defaults to the bundled Node
|
|
35
|
+
backend (since 2026-10-15, with `backend: python` as the one-line fallback). Either way it is a SQLite
|
|
36
|
+
database plus a file-system workspace, and no data leaves your machine except for the model calls your
|
|
37
|
+
`dsh` host makes.
|
|
36
38
|
|
|
37
39
|
## Highlights
|
|
38
40
|
|
|
@@ -56,8 +58,9 @@ no data leaves your machine except for the model calls your `dsh` host makes.
|
|
|
56
58
|
drives the load itself, N runs per task, with per-run metrics, charts, and Markdown/JSON reports.
|
|
57
59
|
- **Optional integrations.** Feishu (Lark) push notifications with two-way answering from chat cards, and
|
|
58
60
|
RAG-based semantic search over the case library.
|
|
59
|
-
- **Self-contained stack.** Backend:
|
|
60
|
-
|
|
61
|
+
- **Self-contained stack.** Backend: the bundled Node backend (plugin-mode default, needs Node 22.19+) or
|
|
62
|
+
the Python standard library plus four small dependencies (standalone mode / `backend: python`).
|
|
63
|
+
Frontend: React + Vite. Storage: SQLite + plain files. Runs on Linux and Windows.
|
|
61
64
|
|
|
62
65
|
## Screenshots
|
|
63
66
|
|
|
@@ -133,8 +136,8 @@ flowchart LR
|
|
|
133
136
|
|
|
134
137
|
| Requirement | Version | Needed for |
|
|
135
138
|
|-------------|---------|------------|
|
|
136
|
-
|
|
|
137
|
-
|
|
|
139
|
+
| Node.js | 22.19+ or 24+ | The `dsh` host **and the plugin-mode backend** (default; the front-end build alone needs Node 18+) |
|
|
140
|
+
| Python | 3.12+ | Standalone mode (`./touchstone.sh start`) and plugin mode with `backend: python` (verified on 3.12) |
|
|
138
141
|
| DeepSeek Harness (`dsh`) | current release | Agent execution — required for plugin mode |
|
|
139
142
|
|
|
140
143
|
### 1. Clone and install
|
|
@@ -142,7 +145,7 @@ flowchart LR
|
|
|
142
145
|
```bash
|
|
143
146
|
git clone https://github.com/gavinc-cn/touchstone-dsh.git
|
|
144
147
|
cd touchstone-dsh
|
|
145
|
-
python3 -m pip install -r requirements.txt
|
|
148
|
+
python3 -m pip install -r requirements.txt # needed for standalone mode / plugin mode with backend: python
|
|
146
149
|
```
|
|
147
150
|
|
|
148
151
|
### 2. Start the site (standalone mode)
|
|
@@ -192,25 +195,30 @@ node ~/.dsh/profiles/web/node_modules/@gavinc-cn/touchstone-dsh/dsh-plugin/scrip
|
|
|
192
195
|
# 4) restart the dsh web UI, open http://127.0.0.1:3080, use the Touchstone entry (Alt+T toggles the panel)
|
|
193
196
|
```
|
|
194
197
|
|
|
195
|
-
|
|
198
|
+
**Plugin mode needs no Python by default.** Since 2026-10-15 the plugin shell starts the bundled Node
|
|
199
|
+
backend (it probes the host Node for `node:sqlite` first; if the host is too old it shows a hint page with
|
|
200
|
+
the two ways out). Python is used only for the Python backend — standalone mode, or `backend: python` in the
|
|
201
|
+
plugin config, which is the one-line fallback during the observation window (deleting the key no longer
|
|
202
|
+
reverts anything: the default *is* `node` now). For that Python backend, the platform's own dependencies are
|
|
203
|
+
installed with pip, not npm:
|
|
196
204
|
|
|
197
205
|
```bash
|
|
198
206
|
pip install -r ~/.dsh/profiles/web/node_modules/@gavinc-cn/touchstone-dsh/requirements.txt
|
|
199
207
|
# Windows: python -m pip install ... (the official installer ships python.exe, not python3)
|
|
200
208
|
```
|
|
201
209
|
|
|
202
|
-
|
|
203
|
-
`python3` (the same order as `touchstone.cmd`), and uses the first one that actually
|
|
204
|
-
interpreter where you installed the dependencies, the missing-dependency page prints
|
|
205
|
-
interpreter it picked; to pin another one, set `pythonPath` in the profile patch:
|
|
210
|
+
With the Python backend, the plugin resolves the interpreter itself when you do not configure one: it probes
|
|
211
|
+
`python` first, then `python3` (the same order as `touchstone.cmd`), and uses the first one that actually
|
|
212
|
+
runs. If that is not the interpreter where you installed the dependencies, the missing-dependency page prints
|
|
213
|
+
the exact command for the interpreter it picked; to pin another one, set `pythonPath` in the profile patch:
|
|
206
214
|
|
|
207
215
|
```yaml
|
|
208
216
|
- id: touchstone-dsh
|
|
209
|
-
config: { pythonPath: /path/to/python }
|
|
217
|
+
config: { backend: python, pythonPath: /path/to/python }
|
|
210
218
|
```
|
|
211
219
|
|
|
212
|
-
No `repoDir` is needed: the package carries `server.py
|
|
213
|
-
its own directory.
|
|
220
|
+
No `repoDir` is needed: the package carries `server.py`, `backend/` (the Node backend) and
|
|
221
|
+
`webui/dist-plugin`, so the plugin falls back to its own directory.
|
|
214
222
|
|
|
215
223
|
**Option B — from a checkout (development):**
|
|
216
224
|
|
package/README.zh.md
CHANGED
|
@@ -30,8 +30,9 @@ Touchstone 把「我们真该多测一点」变成一条持续转动的流水线
|
|
|
30
30
|
一个写者(不会有两个 agent 同时改同一份检出),并且全流程可观测——逐轮实时日志、可渲染的会话窗口、
|
|
31
31
|
以及基于 SSE 的看板。
|
|
32
32
|
|
|
33
|
-
Touchstone
|
|
34
|
-
|
|
33
|
+
Touchstone 是**本地运行**的:独立形态是 Python 站点;插件形态自 2026-10-15 起缺省走包内 Node 后端
|
|
34
|
+
(要回 Python 就在插件 config 里显式写 `backend: python`)。两种形态都是 SQLite + 文件系统工作区,
|
|
35
|
+
除了你的 `dsh` 宿主发起的模型调用之外,数据不出本机。
|
|
35
36
|
|
|
36
37
|
## 主要特性
|
|
37
38
|
|
|
@@ -50,8 +51,8 @@ Touchstone 是**本地运行**的:Python 站点 + SQLite + 文件系统工作
|
|
|
50
51
|
- **内置压测**。压测任务先让 agent 产出场景与脚本,之后由平台自己发压,一个任务可跑 N 次,每次都有
|
|
51
52
|
指标、图表与 Markdown/JSON 报告。
|
|
52
53
|
- **可选集成**。飞书(Lark)推送通知与卡片双向作答;案例库的可选语义检索(RAG)。
|
|
53
|
-
-
|
|
54
|
-
Linux 与 Windows 均可运行。
|
|
54
|
+
- **自包含技术栈**。后端:包内 Node 后端(插件形态缺省,需 Node 22.19+)或 Python 标准库 + 4 个小依赖
|
|
55
|
+
(独立形态 / `backend: python`);前端:React + Vite;存储:SQLite + 普通文件。Linux 与 Windows 均可运行。
|
|
55
56
|
|
|
56
57
|
## 界面截图
|
|
57
58
|
|
|
@@ -123,8 +124,8 @@ flowchart LR
|
|
|
123
124
|
|
|
124
125
|
| 依赖 | 版本 | 用途 |
|
|
125
126
|
|------|------|------|
|
|
126
|
-
|
|
|
127
|
-
|
|
|
127
|
+
| Node.js | 22.19+ 或 24+ | 运行 `dsh` 宿主**与插件形态的后端**(缺省;仅构建前端时 Node 18+ 即可) |
|
|
128
|
+
| Python | 3.12+ | 独立形态(`./touchstone.sh start`)与插件形态的 `backend: python`(实测 3.12) |
|
|
128
129
|
| DeepSeek Harness(`dsh`) | 当前版本 | agent 执行——插件形态必需 |
|
|
129
130
|
|
|
130
131
|
### 1. 克隆并安装
|
|
@@ -132,7 +133,7 @@ flowchart LR
|
|
|
132
133
|
```bash
|
|
133
134
|
git clone https://github.com/gavinc-cn/touchstone-dsh.git
|
|
134
135
|
cd touchstone-dsh
|
|
135
|
-
python3 -m pip install -r requirements.txt
|
|
136
|
+
python3 -m pip install -r requirements.txt # 独立形态 / 插件形态配了 backend: python 才需要
|
|
136
137
|
```
|
|
137
138
|
|
|
138
139
|
### 2. 启动站点(独立形态)
|
|
@@ -174,23 +175,27 @@ node ~/.dsh/profiles/web/node_modules/@gavinc-cn/touchstone-dsh/dsh-plugin/scrip
|
|
|
174
175
|
# 4) 重启 dsh web,打开 http://127.0.0.1:3080,用侧栏里的 Touchstone 入口(Alt+T 开关面板)
|
|
175
176
|
```
|
|
176
177
|
|
|
177
|
-
|
|
178
|
+
**插件形态缺省不需要 Python。** 自 2026-10-15 起薄壳拉起的是包内 Node 后端(先探宿主 Node 的
|
|
179
|
+
`node:sqlite`,过旧则给提示页并列出两条出路)。只有用 Python 后端时才需要 Python —— 即独立形态,
|
|
180
|
+
或在插件 config 里显式写 `backend: python`(观测期的一行回滚;**删掉该键不再有任何回退效果**,
|
|
181
|
+
缺省就是 `node`)。走 Python 后端时,平台自身的依赖走 pip,npm 不管:
|
|
178
182
|
|
|
179
183
|
```bash
|
|
180
184
|
pip install -r ~/.dsh/profiles/web/node_modules/@gavinc-cn/touchstone-dsh/requirements.txt
|
|
181
185
|
# Windows 用 python -m pip install ...(官方安装器只装 python.exe,没有 python3)
|
|
182
186
|
```
|
|
183
187
|
|
|
184
|
-
|
|
185
|
-
|
|
188
|
+
配了 Python 后端且未指定解释器时,插件自己探——先试 `python`、再试 `python3`(与 `touchstone.cmd`
|
|
189
|
+
同序),用第一个真能跑起来的那个。如果探到的不是你装依赖的那支,缺依赖提示页里给的就是**它探到的那支**
|
|
186
190
|
的解释器命令;想指定别支,在 profile patch 里写 `pythonPath`:
|
|
187
191
|
|
|
188
192
|
```yaml
|
|
189
193
|
- id: touchstone-dsh
|
|
190
|
-
config: { pythonPath: /path/to/python }
|
|
194
|
+
config: { backend: python, pythonPath: /path/to/python }
|
|
191
195
|
```
|
|
192
196
|
|
|
193
|
-
**不需要配 `repoDir`**:包里带着 `server.py
|
|
197
|
+
**不需要配 `repoDir`**:包里带着 `server.py`、`backend/`(Node 后端)与 `webui/dist-plugin`,
|
|
198
|
+
插件缺省就用包自身目录。
|
|
194
199
|
|
|
195
200
|
**方式 B —— 从检出安装(开发用):**
|
|
196
201
|
|
package/auth.py
CHANGED
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 请求访问门禁(`server.py` 里挂在 Handler 上的那几道**纯判据**,M4.5)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个文件:`server.js` 是入口脚本——模块顶层就 `await main()`(进程契约要求
|
|
5
|
+
* 「被 import 即启动」不成立,而 Node 22.19 没有 `import.meta.main`,用它做守卫会在旧版
|
|
6
|
+
* 上静默不启动)。因此门禁判据留在 `server.js` 里就**不可被单测 import**,只能靠实例级
|
|
7
|
+
* 对拍覆盖。抽成纯函数(零 IO、零 import)后,`backend/test/write-base.test.mjs` 能逐条
|
|
8
|
+
* 钉死边界,而 `server.js` 只负责「在正确的时机调它」。
|
|
9
|
+
*
|
|
10
|
+
* 与 Python 的对齐点(`server.py`):
|
|
11
|
+
* - `MUST_CHANGE_PW_ALLOW`(487):白名单三端点;
|
|
12
|
+
* - `_must_change_pw_blocked`(5203):`must_change_pw` 为假 ⇒ 放行;信任头 ⇒ 放行;
|
|
13
|
+
* 在白名单里 ⇒ 放行;否则 403 `{"error":"请先修改初始口令"}`;
|
|
14
|
+
* - **`do_GET`(1023) 与 `do_POST`(1040) 都调它**,且都在各自的 `if path.startswith("/api/")`
|
|
15
|
+
* 之内;`do_PATCH`(1227) / `do_DELETE`(1266) 没有这道闸。
|
|
16
|
+
* ⚠ 2026-10-14 订正:本文件此前写的是「只有 `do_POST`(1040) 调它」,**与源码不符**——
|
|
17
|
+
* `server.js` 曾照这句错注释实现,于是持一次性口令的账号在 Node 侧能 GET 全部 `/api/*`
|
|
18
|
+
* 读端点(oracle 一律 403)。M7-R3 车道用隔离实例真跑 `server.py` 量出来的。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** 强制改密闸白名单(`server.py:MUST_CHANGE_PW_ALLOW`):持一次性口令的账号只能走这三个端点 */
|
|
22
|
+
export const MUST_CHANGE_PW_ALLOW = Object.freeze([
|
|
23
|
+
'/api/auth/me', '/api/auth/change_password', '/api/auth/logout',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `server.py:_must_change_pw_blocked` 的纯判据。
|
|
28
|
+
*
|
|
29
|
+
* 返回 `true` = 调用方应回 **403 紧凑体** `{"error":"请先修改初始口令"}`。
|
|
30
|
+
*
|
|
31
|
+
* `user` 为空(未登录)不算「被闸挡住」——那种情况该走 401,且 Python 里
|
|
32
|
+
* `if not user or not user["must_change_pw"]: return False` 也是放行(401 由更早的检查发)。
|
|
33
|
+
*
|
|
34
|
+
* @param {object|null|undefined} user 当前用户行(`must_change_pw` 为 0/1 或布尔)
|
|
35
|
+
* @param {string} path 不含查询串的请求路径
|
|
36
|
+
* @param {{trusted?: boolean}} [options] `trusted` = 反代注入免登信任头(`_trusted_admin_request`)
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
export function mustChangePwBlocked(user, path, { trusted = false } = {}) {
|
|
40
|
+
if (!user || !user.must_change_pw) return false;
|
|
41
|
+
if (trusted) return false;
|
|
42
|
+
if (MUST_CHANGE_PW_ALLOW.includes(path)) return false;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents.py` 的 Node 对应物(M3 交付归族读口;M6-2 补齐扫描口)。
|
|
3
|
+
*
|
|
4
|
+
* M3(读路径)里几乎所有端点都要按项目的 `agent_path` 归族(`_api_get_board` 下发
|
|
5
|
+
* family/capabilities、`_api_board_session_messages` 归一 sessparse 解析族、
|
|
6
|
+
* `board.web_busy_map` 判「是不是 dsh 族」…),而归族判据是纯字符串规则、零 IO,故先落。
|
|
7
|
+
* M6-2 把模块的另一大半(`scan_agents` / `scan_skills` / `_probe_version` /
|
|
8
|
+
* `_search_extra` / `_parse_skill_md` / `_scan_skill_dir` 与四张常量表)按 `agents.py` 补齐。
|
|
9
|
+
*
|
|
10
|
+
* 归族返回值与 Python 逐字一致(`"dsh_plugin"` / `"retired"`),判据见 `agents.py:134-162`:
|
|
11
|
+
* ① `dsh-plugin:` 前缀(必须先于 basename 判——它不是有效可执行路径);
|
|
12
|
+
* ② `kimi-web:` / `opencode-web:` 两个旧虚拟前缀;
|
|
13
|
+
* ③ basename 含 kimi/opencode/claude/hermes/deepseek 的旧 CLI 族;
|
|
14
|
+
* ④ basename == `dsh`(裸 dsh CLI,`--profile headless` 形态已退场);
|
|
15
|
+
* ⑤ **其余一律 `dsh_plugin`**(空 `agent_path` 与未知可执行名的默认族)。
|
|
16
|
+
*
|
|
17
|
+
* 第 ⑤ 条是最容易写反的一条:默认族不是「未知就报错」,而是「收敛到唯一产品入口」。
|
|
18
|
+
*/
|
|
19
|
+
import { spawnSync } from 'node:child_process';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
/** dsh 插件驱动前缀(`agents.py:DSH_PLUGIN_PREFIX`):后跟任意提示串,平台不使用 */
|
|
25
|
+
export const DSH_PLUGIN_PREFIX = 'dsh-plugin:';
|
|
26
|
+
|
|
27
|
+
/** 退场族标记(`agents.py:RETIRED_FAMILY`):起跑处据此返回 `RETIRED_MSG` */
|
|
28
|
+
export const RETIRED_FAMILY = 'retired';
|
|
29
|
+
|
|
30
|
+
/** 退场族提示文案(`agents.py:RETIRED_MSG`,逐字照抄——它直接展示给用户) */
|
|
31
|
+
export const RETIRED_MSG = '该智能体族已下线,请在项目设置里改绑 dsh 插件';
|
|
32
|
+
|
|
33
|
+
/** 「插件·进程内」条目展示名(`agents.py:PLUGIN_ENTRY_NAME`,M6 的 scan 会用) */
|
|
34
|
+
export const PLUGIN_ENTRY_NAME = 'dsh(插件·进程内)';
|
|
35
|
+
|
|
36
|
+
/** 退场族的旧虚拟条目前缀(`agents.py:LEGACY_WEB_PREFIXES`) */
|
|
37
|
+
export const LEGACY_WEB_PREFIXES = ['kimi-web:', 'opencode-web:'];
|
|
38
|
+
|
|
39
|
+
/** 旧 CLI 族可执行名关键词(`agents.py:152` 的判定同源) */
|
|
40
|
+
const RETIRED_NAME_HINTS = ['kimi', 'opencode', 'claude', 'hermes', 'deepseek'];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 归族:只可能返回 `"dsh_plugin"`(可跑)或 `RETIRED_FAMILY`(退场族)。
|
|
44
|
+
*
|
|
45
|
+
* Python 用 `ap.strip()` 后判前缀、再用 `os.path.basename(ap).lower()` 判名字;
|
|
46
|
+
* Node 侧 `path.basename` 在 POSIX 上等价(Windows 反斜杠路径在 Linux 版上本来就
|
|
47
|
+
* 不是有效路径,与 Python 同——两版都跑在同一个平台上,不比跨平台行为)。
|
|
48
|
+
*/
|
|
49
|
+
export function agentFamily(agentPath) {
|
|
50
|
+
const ap = String(agentPath ?? '').trim();
|
|
51
|
+
if (ap.startsWith(DSH_PLUGIN_PREFIX)) return 'dsh_plugin';
|
|
52
|
+
if (LEGACY_WEB_PREFIXES.some((prefix) => ap.startsWith(prefix))) return RETIRED_FAMILY;
|
|
53
|
+
const name = path.basename(ap).toLowerCase();
|
|
54
|
+
if (RETIRED_NAME_HINTS.some((hint) => name.includes(hint))) return RETIRED_FAMILY;
|
|
55
|
+
if (name === 'dsh') return RETIRED_FAMILY; // 裸 dsh CLI(headless 形态)已退场
|
|
56
|
+
return 'dsh_plugin'; // 空串/未知名的默认族(见文件头 ⑤)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
60
|
+
// M6-2:扫描口(`agents.py:45-247`)
|
|
61
|
+
//
|
|
62
|
+
// 与 Python 的形态差异(两条,都是「Node 没有 stdlib 对应物」):
|
|
63
|
+
// ① `shutil.which` → 本地 `whichSync`:POSIX 语义(名字含 `/` 直接判,否则遍历 `PATH`
|
|
64
|
+
// 各段取第一个「文件且可执行」者)。Windows 的 PATHEXT 分支不在本轮范围(本机 POSIX)。
|
|
65
|
+
// ② `glob.glob` → 本地 `globDirs`:只支持本文件用到的那一种模式(`<base>/*/<suffix>`,
|
|
66
|
+
// 单个 `*` 整段通配),按 `sorted()` 返回——与 `sorted(glob.glob(pat))` 同序。
|
|
67
|
+
// 另注:`_probe_version` 的 `text[0][:120]` 在 Python 按**码点**切,这里用 `[...str]` 同口径
|
|
68
|
+
// (`String.prototype.slice` 会切在 UTF-16 码元上)。
|
|
69
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
70
|
+
|
|
71
|
+
/** 支持的 agent 清单(`agents.py:AGENT_DEFS`;展示名仅作文档,实际用于定位 dsh 可执行) */
|
|
72
|
+
export const AGENT_DEFS = [['dsh(DeepSeek Harness)', ['dsh']]];
|
|
73
|
+
|
|
74
|
+
/** PATH 之外常见的安装目录(`agents.py:EXTRA_DIRS`,顺序即查找顺序) */
|
|
75
|
+
export const EXTRA_DIRS = [
|
|
76
|
+
'/usr/local/bin',
|
|
77
|
+
path.join(os.homedir(), '.local/bin'),
|
|
78
|
+
'/bin/versions/node',
|
|
79
|
+
path.join(os.homedir(), '.npm-global/bin'),
|
|
80
|
+
];
|
|
81
|
+
if (process.platform === 'win32' && process.env.APPDATA) {
|
|
82
|
+
EXTRA_DIRS.push(path.join(process.env.APPDATA, 'npm'));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 递归进 versions 下各 node 版本的 bin/(`agents.py:VERSIONS_BIN_GLOB`) */
|
|
86
|
+
export const VERSIONS_BIN_GLOB = '/bin/versions/node';
|
|
87
|
+
|
|
88
|
+
/** conda/mamba 各虚拟环境的 bin 目录(`agents.py:CONDA_ENV_BIN_GLOBS`,glob 模式) */
|
|
89
|
+
export const CONDA_ENV_BIN_GLOBS = [
|
|
90
|
+
'/opt/miniconda3/envs/*/bin',
|
|
91
|
+
'/opt/anaconda3/envs/*/bin',
|
|
92
|
+
path.join(os.homedir(), 'miniconda3/envs/*/bin'),
|
|
93
|
+
path.join(os.homedir(), 'anaconda3/envs/*/bin'),
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
/** skill 目录约定(`agents.py:SKILL_DIRS_BY_FAMILY`):族 → [用户级元组, 项目级元组] */
|
|
97
|
+
export const SKILL_DIRS_BY_FAMILY = {
|
|
98
|
+
dsh_plugin: [['~/.dsh/skills', '~/.agents/skills'], ['.dsh/skills', '.agents/skills']],
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** SKILL.md 相对扫描根的最大目录深度(`agents.py:SKILL_WALK_MAX_DEPTH`) */
|
|
102
|
+
export const SKILL_WALK_MAX_DEPTH = 2;
|
|
103
|
+
|
|
104
|
+
/** `_SKILL_FRONT_RE`:`\A---\s*\n(.*?)\n---`(DOTALL) */
|
|
105
|
+
const SKILL_FRONT_RE = /^---\s*\n([\s\S]*?)\n---/;
|
|
106
|
+
/** `_SKILL_NAME_RE` / `_SKILL_DESC_RE`:多行模式下 `^name:` / `^description:`(行首锚定) */
|
|
107
|
+
const SKILL_NAME_RE = /^name:[ \t]*(.+)$/m;
|
|
108
|
+
const SKILL_DESC_RE = /^description:[ \t]*(.+)$/m;
|
|
109
|
+
|
|
110
|
+
/** description 截断长度(`agents.py:_SKILL_DESC_LIMIT`) */
|
|
111
|
+
export const SKILL_DESC_LIMIT = 200;
|
|
112
|
+
|
|
113
|
+
/** 文本拆行(照 Python `str.splitlines()` 的边界集合) */
|
|
114
|
+
function splitLines(text) {
|
|
115
|
+
return String(text ?? '').split(/\r\n|\r|\n|\u2028|\u2029/);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 文件是否「存在且可执行」(Python `os.path.isfile(p) and os.access(p, os.X_OK)`) */
|
|
119
|
+
function isExecutableFile(p) {
|
|
120
|
+
try {
|
|
121
|
+
const st = fs.statSync(p);
|
|
122
|
+
if (!st.isFile()) return false;
|
|
123
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
124
|
+
return true;
|
|
125
|
+
} catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** `os.path.isdir`(不存在/权限不足一律 false) */
|
|
131
|
+
function isDir(p) {
|
|
132
|
+
try {
|
|
133
|
+
return fs.statSync(p).isDirectory();
|
|
134
|
+
} catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** `os.path.expanduser('~/x')`:只处理开头的 `~`(POSIX 下用 HOME) */
|
|
140
|
+
function expandUser(p) {
|
|
141
|
+
const s = String(p ?? '');
|
|
142
|
+
if (s === '~') return os.homedir();
|
|
143
|
+
if (s.startsWith('~/')) return path.join(os.homedir(), s.slice(2));
|
|
144
|
+
return s;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `shutil.which` 的 POSIX 子集(见文件头差异①) */
|
|
148
|
+
export function whichSync(name) {
|
|
149
|
+
const n = String(name ?? '');
|
|
150
|
+
if (!n) return null;
|
|
151
|
+
if (n.includes('/')) return isExecutableFile(n) ? n : null;
|
|
152
|
+
for (const dir of String(process.env.PATH ?? '').split(path.delimiter)) {
|
|
153
|
+
if (!dir) continue;
|
|
154
|
+
const candidate = path.join(dir, n);
|
|
155
|
+
if (isExecutableFile(candidate)) return candidate;
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** `glob.glob` 的单 `*` 整段通配子集(见文件头差异②),返回 `sorted()` 序 */
|
|
161
|
+
function globDirs(pattern) {
|
|
162
|
+
const star = pattern.indexOf('*');
|
|
163
|
+
if (star < 0) return [pattern];
|
|
164
|
+
const base = pattern.slice(0, pattern.lastIndexOf('/', star));
|
|
165
|
+
const rest = pattern.slice(pattern.indexOf('/', star));
|
|
166
|
+
let names;
|
|
167
|
+
try {
|
|
168
|
+
names = fs.readdirSync(base);
|
|
169
|
+
} catch {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
return names.slice().sort().map((n) => `${base}/${n}${rest}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* `agents._search_extra`:在扩展目录中查找 name 可执行文件,返回路径或 null。
|
|
177
|
+
*
|
|
178
|
+
* 命中判据是「文件 + 可执行位」(`agents.py:82/88`);`VERSIONS_BIN_GLOB` 那一项要**再下钻
|
|
179
|
+
* 一层**到 `<d>/<版本>/bin/<name>`;conda 那组是 glob 展开后逐个匹配。
|
|
180
|
+
*/
|
|
181
|
+
export function searchExtra(name) {
|
|
182
|
+
const candidates = [];
|
|
183
|
+
for (const d of EXTRA_DIRS) {
|
|
184
|
+
if (!isDir(d)) continue;
|
|
185
|
+
if (d === VERSIONS_BIN_GLOB) {
|
|
186
|
+
let versions;
|
|
187
|
+
try {
|
|
188
|
+
versions = fs.readdirSync(d);
|
|
189
|
+
} catch {
|
|
190
|
+
versions = [];
|
|
191
|
+
}
|
|
192
|
+
for (const ver of versions) candidates.push(path.join(d, ver, 'bin', name));
|
|
193
|
+
} else {
|
|
194
|
+
candidates.push(path.join(d, name));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const p of candidates) {
|
|
198
|
+
if (isExecutableFile(p)) return p;
|
|
199
|
+
}
|
|
200
|
+
for (const pat of CONDA_ENV_BIN_GLOBS) {
|
|
201
|
+
for (const bindir of globDirs(pat)) {
|
|
202
|
+
const p = path.join(bindir, name);
|
|
203
|
+
if (isExecutableFile(p)) return p;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* `agents._probe_version`:跑 `--version`(2s 超时),失败返回空串。
|
|
211
|
+
*
|
|
212
|
+
* Python 的 `except (OSError, TimeoutExpired)` 对应 Node 的 `error`(ENOENT/EACCES)与
|
|
213
|
+
* 「被信号杀死」(spawnSync 超时 ⇒ `status === null`);**非 0 退出不算失败**
|
|
214
|
+
* (Python `subprocess.run` 同样不抛),照旧读它写出来的 stdout/stderr,
|
|
215
|
+
* 取首行(`splitlines()` 口径)并按码点截 120。
|
|
216
|
+
*/
|
|
217
|
+
export function probeVersion(exePath) {
|
|
218
|
+
const p = spawnSync(exePath, ['--version'], {
|
|
219
|
+
encoding: 'utf8',
|
|
220
|
+
timeout: 2000,
|
|
221
|
+
cwd: os.tmpdir(),
|
|
222
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
223
|
+
});
|
|
224
|
+
if (p.error || p.status === null) return '';
|
|
225
|
+
const text = splitLines(String(p.stdout || p.stderr || '').trim());
|
|
226
|
+
return text.length ? [...text[0]].slice(0, 120).join('') : '';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* `agents.scan_agents`:扫描本机 dsh 可执行,返回**产品入口单条目**(恒 1 条)。
|
|
231
|
+
*
|
|
232
|
+
* 未找到时同样返回一条 `found=false`(前端显示「未找到」),保持「下拉里有且只有这一条」
|
|
233
|
+
* 的读侧不变量。**裸 dsh CLI 行不返回**(它属退场族,列出来是误选陷阱)。
|
|
234
|
+
*/
|
|
235
|
+
export function scanAgents() {
|
|
236
|
+
let found = '';
|
|
237
|
+
for (const [, bins] of AGENT_DEFS) {
|
|
238
|
+
for (const b of bins) {
|
|
239
|
+
found = whichSync(b) || searchExtra(b) || '';
|
|
240
|
+
if (found) break;
|
|
241
|
+
}
|
|
242
|
+
if (found) break;
|
|
243
|
+
}
|
|
244
|
+
return [{
|
|
245
|
+
name: PLUGIN_ENTRY_NAME,
|
|
246
|
+
path: found ? DSH_PLUGIN_PREFIX + found : '',
|
|
247
|
+
version: found ? probeVersion(found) : '',
|
|
248
|
+
found: Boolean(found),
|
|
249
|
+
}];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* `agents._parse_skill_md`:解析 SKILL.md 的 frontmatter,返回 `[name, description]`。
|
|
254
|
+
*
|
|
255
|
+
* 只读前 **4096 字节**(照 `f.read(4096)`,按 UTF-8 解码后的字符数);读失败返回 null。
|
|
256
|
+
* name 缺省用所在目录名;description 缺省空串并截断到 200(Python `[:200]` 按码点)。
|
|
257
|
+
*/
|
|
258
|
+
export function parseSkillMd(filePath, fallbackName) {
|
|
259
|
+
let head;
|
|
260
|
+
try {
|
|
261
|
+
const fd = fs.openSync(filePath, 'r');
|
|
262
|
+
try {
|
|
263
|
+
const buf = Buffer.alloc(4096);
|
|
264
|
+
const read = fs.readSync(fd, buf, 0, 4096, 0);
|
|
265
|
+
head = new TextDecoder('utf-8').decode(buf.subarray(0, read));
|
|
266
|
+
} finally {
|
|
267
|
+
fs.closeSync(fd);
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
const m = SKILL_FRONT_RE.exec(head);
|
|
273
|
+
const body = m ? m[1] : head; // 无 frontmatter 时退化为头部全文(容错)
|
|
274
|
+
const nm = SKILL_NAME_RE.exec(body);
|
|
275
|
+
const dm = SKILL_DESC_RE.exec(body);
|
|
276
|
+
const name = nm ? nm[1].trim() : fallbackName;
|
|
277
|
+
const desc = dm ? [...dm[1].trim()].slice(0, SKILL_DESC_LIMIT).join('') : '';
|
|
278
|
+
return [name, desc];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* `agents._scan_skill_dir`:扫一个 skill 根(深度受限),返回 `[{name,description,source}]`。
|
|
283
|
+
*
|
|
284
|
+
* 深度按「相对根的目录层数」算:只在 `rel < SKILL_WALK_MAX_DEPTH` 时继续下钻,
|
|
285
|
+
* 故 `SKILL.md` 最深允许出现在第 2 层(`<类>/<名>/SKILL.md`)。命中目录先收自身再下钻,
|
|
286
|
+
* 与 `os.walk` 的自顶向下序一致。
|
|
287
|
+
*/
|
|
288
|
+
export function scanSkillDir(root, source) {
|
|
289
|
+
const skills = [];
|
|
290
|
+
const base = String(root ?? '').replace(/[/\\]+$/, '');
|
|
291
|
+
if (!base || !isDir(base)) return skills;
|
|
292
|
+
const baseDepth = base.split(path.sep).length - 1;
|
|
293
|
+
const walk = (dir) => {
|
|
294
|
+
let entries;
|
|
295
|
+
try {
|
|
296
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
297
|
+
} catch {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (entries.some((e) => e.name === 'SKILL.md')) {
|
|
301
|
+
const parsed = parseSkillMd(path.join(dir, 'SKILL.md'),
|
|
302
|
+
path.basename(dir.replace(/[/\\]+$/, '')));
|
|
303
|
+
if (parsed) skills.push({ name: parsed[0], description: parsed[1], source });
|
|
304
|
+
}
|
|
305
|
+
const rel = dir.split(path.sep).length - 1 - baseDepth;
|
|
306
|
+
if (rel >= SKILL_WALK_MAX_DEPTH) return;
|
|
307
|
+
for (const ent of entries) {
|
|
308
|
+
if (ent.isDirectory()) walk(path.join(dir, ent.name));
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
walk(base);
|
|
312
|
+
return skills;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* `agents.scan_skills`:列出 agent 可用的 skill(用户级 + 项目级),按 name 排序。
|
|
317
|
+
*
|
|
318
|
+
* 同名时项目级**覆盖**用户级;用户级内部先命中者保留(`setdefault` 语义);`agent_path`
|
|
319
|
+
* 为空返回空列表;无目录约定的族返回空。
|
|
320
|
+
*/
|
|
321
|
+
export function scanSkills(agentPath, projectDir = '') {
|
|
322
|
+
const ap = String(agentPath ?? '').trim();
|
|
323
|
+
if (!ap) return [];
|
|
324
|
+
const family = agentFamily(ap);
|
|
325
|
+
const [userDirs, projDirs] = SKILL_DIRS_BY_FAMILY[family] || [[], []];
|
|
326
|
+
const found = new Map();
|
|
327
|
+
for (const d of userDirs) {
|
|
328
|
+
for (const s of scanSkillDir(expandUser(d), 'user')) {
|
|
329
|
+
if (!found.has(s.name)) found.set(s.name, s);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const pd = String(projectDir ?? '').trim();
|
|
333
|
+
if (pd) {
|
|
334
|
+
for (const d of projDirs) {
|
|
335
|
+
for (const s of scanSkillDir(path.join(pd, d), 'project')) {
|
|
336
|
+
found.set(s.name, s); // 项目级覆盖用户级
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return [...found.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
341
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node 后端命令行契约解析(backend/server.js 的入口参数)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个模块:薄壳(dsh-plugin/lib/index.js)用**同一份 argv** 拉起 Python 与 Node
|
|
5
|
+
* 两个后端(方案 §3.2),这份 argv 就是两版之间的接口;把它抽出来单独测,才能在不起进程的
|
|
6
|
+
* 情况下钉死「默认值与 Python 侧 argparse 逐项一致」。
|
|
7
|
+
*
|
|
8
|
+
* 与 Python 侧的对应关系(server.py:_build_arg_parser,2026-10-11 复核):
|
|
9
|
+
* --host 缺省 127.0.0.1(安全默认:仅本机)
|
|
10
|
+
* --port 缺省 4601(薄壳恒传 0 = 随机端口)
|
|
11
|
+
* --web-dir 缺省 `<仓库根>/webui/dist`,可被 TS_WEB_DIR 覆盖
|
|
12
|
+
* --trust-internal-user / --parent-watch 布尔开关
|
|
13
|
+
* --allow-shared-db 同库单实例闸的逃生口(M1 起解析:语义已由 backend/lib/lock.js 落地,
|
|
14
|
+
* 见 server.js 的取锁段;M0 曾**故意不解析**它——解析一个语义未实现的
|
|
15
|
+
* 开关等于静默说谎,现在实现到位,这个理由不再成立)
|
|
16
|
+
*/
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { parseArgs } from 'node:util';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 仓库根 = 本文件上溯三级(<根>/backend/lib/args.js)。
|
|
23
|
+
* 静态页缺省目录由它派生,与 Python 侧 `BASE_DIR/webui/dist` 同口径。
|
|
24
|
+
*/
|
|
25
|
+
export const REPO_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
26
|
+
|
|
27
|
+
/** 与 Python argparse 对齐的缺省监听地址与端口 */
|
|
28
|
+
export const DEFAULT_HOST = '127.0.0.1';
|
|
29
|
+
export const DEFAULT_PORT = 4601;
|
|
30
|
+
|
|
31
|
+
/** 把 node:util parseArgs 的英文错误码翻成可操作的中文提示(终端里直接能看懂) */
|
|
32
|
+
function describeParseError(error) {
|
|
33
|
+
const code = error && error.code;
|
|
34
|
+
if (code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION') {
|
|
35
|
+
return new Error(`未知参数: ${error.message.replace(/^Unknown option\s*/, '')}`);
|
|
36
|
+
}
|
|
37
|
+
if (code === 'ERR_PARSE_ARGS_INVALID_OPTION_VALUE') {
|
|
38
|
+
return new Error(`参数缺少取值: ${error.message}`);
|
|
39
|
+
}
|
|
40
|
+
return new Error(`参数错误: ${(error && error.message) || error}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 解析入口 argv(不含 node 可执行文件与脚本路径)。
|
|
45
|
+
* 非法输入一律抛错:这个进程由薄壳拉起,静默兜底只会把「参数写错」变成「后端行为诡异」。
|
|
46
|
+
*/
|
|
47
|
+
export function parseBackendArgs(argv = process.argv.slice(2)) {
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = parseArgs({
|
|
51
|
+
args: argv,
|
|
52
|
+
strict: true,
|
|
53
|
+
allowPositionals: false,
|
|
54
|
+
options: {
|
|
55
|
+
host: { type: 'string' },
|
|
56
|
+
port: { type: 'string' },
|
|
57
|
+
'web-dir': { type: 'string' },
|
|
58
|
+
'trust-internal-user': { type: 'boolean' },
|
|
59
|
+
'parent-watch': { type: 'boolean' },
|
|
60
|
+
'allow-shared-db': { type: 'boolean' },
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
} catch (error) {
|
|
64
|
+
throw describeParseError(error);
|
|
65
|
+
}
|
|
66
|
+
const values = parsed.values;
|
|
67
|
+
let port = DEFAULT_PORT;
|
|
68
|
+
if (values.port !== undefined) {
|
|
69
|
+
// parseArgs 没有数字类型,端口在这里显式校验(`abc` / `1.5` / 负数都要挡在启动之前)
|
|
70
|
+
if (!/^\d+$/.test(values.port)) throw new Error(`端口必须是整数: ${values.port}`);
|
|
71
|
+
port = Number(values.port);
|
|
72
|
+
if (port > 65535) throw new Error(`端口越界(0-65535): ${values.port}`);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
host: values.host === undefined ? DEFAULT_HOST : values.host,
|
|
76
|
+
port,
|
|
77
|
+
webDir: values['web-dir'] === undefined ? null : values['web-dir'],
|
|
78
|
+
trustInternalUser: values['trust-internal-user'] === true,
|
|
79
|
+
parentWatch: values['parent-watch'] === true,
|
|
80
|
+
// 与 Python 的 `--allow-shared-db`(action="store_true")同语义:缺省 false
|
|
81
|
+
allowSharedDb: values['allow-shared-db'] === true,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 静态页目录解析:显式 --web-dir > 环境变量 TS_WEB_DIR > `<仓库根>/webui/dist`。
|
|
87
|
+
* 优先级与 Python 侧一致(`WEB_DIR = os.environ.get("TS_WEB_DIR") or BASE_DIR/webui/dist`,
|
|
88
|
+
* 显式参数在 main() 里覆盖它)。
|
|
89
|
+
*/
|
|
90
|
+
export function resolveWebDir(args, { env = process.env, repoRoot = REPO_ROOT } = {}) {
|
|
91
|
+
if (args && args.webDir) return args.webDir;
|
|
92
|
+
if (env && env.TS_WEB_DIR) return env.TS_WEB_DIR;
|
|
93
|
+
return join(repoRoot, 'webui', 'dist');
|
|
94
|
+
}
|