dsh-wsl-tool 1.10.10 → 1.10.11
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/README.md +23 -1
- package/README.zh-CN.md +16 -1
- package/SUPPORT.md +36 -0
- package/index.js +412 -41
- package/lib/client.js +350 -67
- package/lib/config.js +34 -15
- package/lib/terminal-cwd.js +408 -0
- package/lib/tools/wsl-path.js +5 -5
- package/lib/tools/wsl.js +5 -4
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -119,6 +119,13 @@ specifier you install under.
|
|
|
119
119
|
|
|
120
120
|
3. Restart DSH.
|
|
121
121
|
|
|
122
|
+
The settings panel is the one optional piece: its form needs
|
|
123
|
+
`@deepseek-ai/schemastery`, which most profiles already have (any plugin that
|
|
124
|
+
depends on it brings it in — add it to the profile's dependencies if yours does
|
|
125
|
+
not). Without it, the three tools and the panel's 「WSL 终端启动路径」 field work
|
|
126
|
+
as usual and only the switches are absent — the panel says so instead of waiting
|
|
127
|
+
forever.
|
|
128
|
+
|
|
122
129
|
## Compatibility
|
|
123
130
|
|
|
124
131
|
Verified against DSH **0.1.7-rc.2** (and 0.1.5-rc.2 before it): the tool schemas
|
|
@@ -149,7 +156,9 @@ explanation.
|
|
|
149
156
|
| `wsl-env` 能力体检 | registers the `wsl-env` tool |
|
|
150
157
|
| 后台任务 | whether `wsl` accepts `runInBackground` |
|
|
151
158
|
| 自动转换路径 | the default for the per-call `translatePaths` |
|
|
152
|
-
| 默认跟随会话工作区 | start in the session's directory instead of `~` when `workdir` is omitted |
|
|
159
|
+
| 默认跟随会话工作区 | start in the session's directory instead of `~` when `workdir` is omitted. **On by default**: with it off, every relative path an agent writes lands in the Linux home, which is invisible from Explorer and grows the WSL disk image |
|
|
160
|
+
| Linux 默认工作目录 | a fixed Linux directory (for example `/mnt/d/project`) used when `workdir` is omitted. Filling it in wins over the switch above; clearing it goes back to following the session |
|
|
161
|
+
| WSL 终端启动路径 | the optional sidebar terminal's startup directory — `--cd <dir>` on the `terminal-controller` row of your **profile patch** (see [Optional: a WSL terminal in the sidebar](#optional-a-wsl-terminal-in-the-sidebar)). Empty clears it, and the panel reads the current value from the same file |
|
|
153
162
|
| 危险命令守卫 | whether a destructive command needs an explicit `allowDangerous` |
|
|
154
163
|
|
|
155
164
|
The panel edits the plugin's own configuration, so the same values can be written
|
|
@@ -159,6 +168,11 @@ environment, then the built-in defaults** — and a switch left at its default l
|
|
|
159
168
|
the layer below decide, which is why `DSH_WSL_WORKDIR=session` keeps working for
|
|
160
169
|
someone who never opened the panel.
|
|
161
170
|
|
|
171
|
+
「WSL 终端启动路径」 is the one exception: the sidebar terminal belongs to another
|
|
172
|
+
plugin, so that field edits your profile's patch layer instead — the file is backed
|
|
173
|
+
up before every write, only the terminal row's `args` line is rewritten, and the
|
|
174
|
+
result is read back and undone if it is not exactly that one line.
|
|
175
|
+
|
|
162
176
|
**A change takes effect at the next DSH start**: the host reads this configuration
|
|
163
177
|
once per mount, and the panel says so. Distro and timeout are values rather than
|
|
164
178
|
features — set them in the patch or with `DSH_WSL_DISTRO` / `DSH_WSL_TIMEOUT_MS`,
|
|
@@ -188,6 +202,14 @@ To turn it on, copy that entry into your profile's own patch layer
|
|
|
188
202
|
(`$DSH_HOME/profiles/<profile>/cordis.patch.yml`); a CLI launch can instead pass
|
|
189
203
|
`--patch <path to the installed file>`. It applies at the next app start.
|
|
190
204
|
|
|
205
|
+
The panel can set the startup directory for you: 「WSL 终端启动路径」 (beside 「默认
|
|
206
|
+
Linux 工作目录」) reads the current value out of that file and writes `--cd <dir>` onto
|
|
207
|
+
the row's `args` line — backing the patch up first, changing only that one line, and
|
|
208
|
+
checking what landed before it is kept. Clearing the field removes the flag, which puts
|
|
209
|
+
the terminal back on the session workspace (the Windows directory it is started from,
|
|
210
|
+
translated to `/mnt/…`); `~` pins it to the Linux home. It takes effect at the next app
|
|
211
|
+
start too.
|
|
212
|
+
|
|
191
213
|
```yaml
|
|
192
214
|
- id: terminal-controller
|
|
193
215
|
config:
|
package/README.zh-CN.md
CHANGED
|
@@ -103,6 +103,10 @@ launcher: WSL 版本: 2.6.3.0 · 内核版本: 6.6.87.2-1 · WSLg 版本: 1.0.71
|
|
|
103
103
|
|
|
104
104
|
3. 重启 DSH。
|
|
105
105
|
|
|
106
|
+
设置面板是唯一可选的一块:它的表单需要 `@deepseek-ai/schemastery`,多数 profile 已经有了
|
|
107
|
+
(只要装过任何依赖它的插件;没有的话把它加进 profile 的依赖即可)。没有它时,三个工具与
|
|
108
|
+
面板里的「WSL 终端启动路径」照常可用,只是开关不显示 —— 面板会直接说明这一点,不会一直转圈等待。
|
|
109
|
+
|
|
106
110
|
## 兼容性
|
|
107
111
|
|
|
108
112
|
已在 DSH **0.1.7-rc.2** 上验证(此前为 0.1.5-rc.2):工具 schema 通过 DSH 自己的
|
|
@@ -130,13 +134,19 @@ DSH_SUBPROCESS_LOCAL=/path/to/dsh/node_modules npm run test:real
|
|
|
130
134
|
| `wsl-env` 能力体检 | 是否注册 `wsl-env` 工具 |
|
|
131
135
|
| 后台任务 | `wsl` 是否接受 `runInBackground` |
|
|
132
136
|
| 自动转换路径 | 每次调用的 `translatePaths` 默认值 |
|
|
133
|
-
| 默认跟随会话工作区 | 未传 `workdir` 时从会话目录开始,而不是
|
|
137
|
+
| 默认跟随会话工作区 | 未传 `workdir` 时从会话目录开始,而不是 `~`。**默认开**:关掉的话,agent 写的相对路径都会落进 Linux 家目录 —— 那儿在资源管理器里看不见,还会撑大 WSL 磁盘镜像 |
|
|
138
|
+
| Linux 默认工作目录 | 未传 `workdir` 时使用的固定 Linux 目录(如 `/mnt/d/project`)。填了就优先于上面的开关;清空则回到跟随会话 |
|
|
139
|
+
| WSL 终端启动路径 | 可选的侧边栏终端从哪个目录启动 —— 写进你 **profile patch** 里 `terminal-controller` 那一行的 `--cd <目录>`(见[可选:在侧边栏开一个 WSL 终端](#可选在侧边栏开一个-wsl-终端))。留空则删掉该参数;当前值也是从这个文件读的 |
|
|
134
140
|
| 危险命令守卫 | 危险命令是否必须显式 `allowDangerous` |
|
|
135
141
|
|
|
136
142
|
面板改的是插件自己的配置,所以同样的值也可以手写进 profile patch(`- id: tool-wsl` 加 `config:`)
|
|
137
143
|
或用环境变量设。优先级由 `lib/config.js` 定:**插件配置 > 环境变量 > 内置默认值**;开关停在默认值时
|
|
138
144
|
下层说了算 —— 这正是"从没打开过面板的人,`DSH_WSL_WORKDIR=session` 依然生效"的原因。
|
|
139
145
|
|
|
146
|
+
「WSL 终端启动路径」是唯一的例外:侧边栏终端属于另一个插件,所以那个输入框改的是你 profile 的
|
|
147
|
+
patch 层 —— 每次写入前先备份该文件,只重写终端那一行的 `args`,写后还会读回来核对,只要不是"只改了
|
|
148
|
+
这一行"就当场还原。
|
|
149
|
+
|
|
140
150
|
**改动在下次启动 DSH 后生效**:宿主每次挂载只读一次该配置,面板里也写着这句。发行版与超时属于"值"
|
|
141
151
|
而不是"功能":在 patch 里或用 `DSH_WSL_DISTRO` / `DSH_WSL_TIMEOUT_MS` 设置,面板只显示当前生效值。
|
|
142
152
|
|
|
@@ -158,6 +168,11 @@ DSH_SUBPROCESS_LOCAL=/path/to/dsh/node_modules npm run test:real
|
|
|
158
168
|
(`$DSH_HOME/profiles/<profile>/cordis.patch.yml`);命令行启动也可以改成
|
|
159
169
|
`--patch <已安装文件路径>`。**下次启动应用时生效。**
|
|
160
170
|
|
|
171
|
+
这个启动目录也能在面板里设:顶部的「WSL 终端启动路径」(挨着「默认 Linux 工作目录」)会从那个
|
|
172
|
+
文件读出当前值,再把 `--cd <目录>` 写到该行 `args` 上 —— 写前先备份 patch 文件,只改这一行,
|
|
173
|
+
写后读回来核对。**清空**则删掉该参数,终端回到跟随会话工作区(启动时的 Windows 目录会被翻译成
|
|
174
|
+
`/mnt/…`);填 `~` 固定到 Linux 家目录。改完同样**重启 DSH 生效**。
|
|
175
|
+
|
|
161
176
|
```yaml
|
|
162
177
|
- id: terminal-controller
|
|
163
178
|
config:
|
package/SUPPORT.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# 支持与反馈 / Support and feedback
|
|
2
|
+
|
|
3
|
+
这个插件只有一个维护者,所以"发对地方"能省掉一轮来回。
|
|
4
|
+
This plugin has one maintainer, so getting the report to the right place saves a round trip.
|
|
5
|
+
|
|
6
|
+
## 去哪儿反馈什么 / Where to report what
|
|
7
|
+
|
|
8
|
+
| 你遇到的问题 / What you hit | 发到哪儿 / Where |
|
|
9
|
+
|---|---|
|
|
10
|
+
| **插件的 bug**:命令失败、路径转换不对、面板开关无效、后台任务异常… / A **plugin** bug | **[本仓库的 Issue](https://github.com/XINY11451/dsh-wsl/issues/new/choose)**(有模板,会问你环境)/ **Issues here** (templates ask for the environment) |
|
|
11
|
+
| **功能建议**:想要一个新能力 / A **feature request** | **[本仓库的 Issue](https://github.com/XINY11451/dsh-wsl/issues/new/choose)**,选「功能建议」模板 / Issues here, the feature template |
|
|
12
|
+
| **用法问题、想法、经验分享** / Usage questions, ideas, war stories | **[Discussions](https://github.com/XINY11451/dsh-wsl/discussions)** |
|
|
13
|
+
| **DSH 本体的问题**(与 WSL 无关的界面、会话、模型…)/ **DSH itself** | [deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness/issues) |
|
|
14
|
+
| **插件市场的界面问题**(浏览、安装按钮、列表渲染…)/ The **market UI** | [dsh-market](https://github.com/dsh-market/dsh-market/issues) |
|
|
15
|
+
| **收录/目录问题**(列表文案、分类、截图没更新…)/ The **catalog listing** | [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/issues) —— 注意:那里的 Issue 只处理列表与站点本身,插件 bug 发过去会被关掉 / Note: Issues there cover the list and its site only; plugin bugs are closed |
|
|
16
|
+
|
|
17
|
+
## 提交 bug 前,先把这几件事准备好 / Before filing a bug
|
|
18
|
+
|
|
19
|
+
1. **插件版本** —— 面板底部的「复制插件信息」会替你读出来(来自 `package.json`),或 `npm ls dsh-wsl-tool` / The plugin version, read out by 「复制插件信息」 in the panel (from `package.json`), or `npm ls dsh-wsl-tool`
|
|
20
|
+
2. **DSH 版本** —— 同一个按钮也会读出来(从应用自己的 manifest),设置里也能看到 / The same button reads it out (from the application's own manifest); Settings shows it too
|
|
21
|
+
3. **WSL 发行版与内核** —— 同一个按钮会替你探测(默认发行版、内核、systemd/docker/GPU 等能力标记);也可用 `wsl -l -v` 或 `wsl-env` 工具 / The same button probes them (default distribution, kernel, capability flags such as systemd, docker and GPU); `wsl -l -v` or one `wsl-env` call work too
|
|
22
|
+
4. **原文**:完整命令、完整报错、界面上出现的话 —— 不要转述 / The exact command, the exact error, the exact UI text — not a paraphrase
|
|
23
|
+
|
|
24
|
+
侧边栏面板底部是**一个**反馈入口:旁边写着提交指南(标题怎么起、正文写哪三段、粘到哪个字段),以及一个「复制插件信息」按钮。点它会由宿主半**自动读取**:包名、版本、仓库(来自本包的 `package.json`)、Node 与平台、它正运行在哪个 DSH 构建里、以及 WSL 的默认发行版/内核/能力标记 —— 再配上当前生效的配置和各开关状态,一次复制完。粘进 Issue 的「补充」栏即可。读取要跑几次 WSL 探测,所以按钮会先显示「正在读取…」;读不到的项会写明"未能读取",不会编造。它**不会**自己发送任何东西。
|
|
25
|
+
The panel ends with **one** feedback entry: a submission guide beside it (how to title it, which three paragraphs the body needs, which field to paste into) and a 「复制插件信息」 button. The host half **reads it all in**: package name, version and repository (from this package's `package.json`), Node and the platform, the DSH build it is running inside, and WSL's default distribution, kernel and capability flags — plus the effective configuration and the switch states. Paste that into the issue's 「补充」 field. A few WSL probes run first, so the button reads 「正在读取…」; anything unreadable says so instead of guessing. It sends nothing by itself.
|
|
26
|
+
|
|
27
|
+
## 关于隐私 / Privacy
|
|
28
|
+
|
|
29
|
+
- 插件**不做任何静默上报**:不点反馈入口,就不会有网络请求。/ The plugin reports nothing by itself — no request happens unless you open the feedback entry.
|
|
30
|
+
- 面板复制出来的文本只含**版本号、包名、仓库、Node 与平台、发行版名、超时,以及你面板里各开关的状态**;不含完整路径、主机名、用户名或任何凭据。/ The copied block contains versions, the package name, the repository, Node and the platform, the distribution name, the timeout and your switch states — no full paths, host names, user names or credentials.
|
|
31
|
+
- Issue 是**公开**的。提交前请自己过一眼,把不想公开的删掉。/ Issues are **public**. Read it once before submitting and delete what you would rather not publish.
|
|
32
|
+
|
|
33
|
+
## 修复的节奏 / How fixes happen
|
|
34
|
+
|
|
35
|
+
报告 → 复现(必要时我会请你补 `wsl-env` 输出)→ 改代码 → 测试(这个插件有 347 项宿主检查 + 76 项客户端检查)→ 发版(npm + 市场资产同一次发布,逐字节一致)→ 你升级后回帖确认 → 关闭。
|
|
36
|
+
Report → reproduce (I may ask for a full `wsl-env`) → fix → tests (the plugin ships with 347 host checks and 76 client checks) → release (npm and the market asset go out byte-identical in one run) → you confirm after upgrading → close.
|
package/index.js
CHANGED
|
@@ -21,14 +21,15 @@
|
|
|
21
21
|
// A configuration change is a mount, not something a running session re-reads:
|
|
22
22
|
// every switch below is consulted once, in `apply`.
|
|
23
23
|
|
|
24
|
-
import { readFileSync } from 'node:fs'
|
|
25
|
-
import { dirname, join } from 'node:path'
|
|
24
|
+
import { copyFileSync, existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
25
|
+
import { basename, dirname, join } from 'node:path'
|
|
26
26
|
|
|
27
27
|
import { CAPABILITY_PROBE, capabilityLines, parseFacts } from './lib/diagnostics.js'
|
|
28
28
|
import { parseDefaultDistro } from './lib/tools/wsl-env.js'
|
|
29
29
|
import { resolveConfig } from './lib/config.js'
|
|
30
30
|
import { pickSchemaBuilder } from './lib/schema.js'
|
|
31
31
|
import { createRunner } from './lib/runner.js'
|
|
32
|
+
import { readTerminalCwd, reviewTerminalWrite, writeTerminalCwd } from './lib/terminal-cwd.js'
|
|
32
33
|
import { createWslTool } from './lib/tools/wsl.js'
|
|
33
34
|
import { createWslPathTool } from './lib/tools/wsl-path.js'
|
|
34
35
|
import { createWslEnvTool } from './lib/tools/wsl-env.js'
|
|
@@ -85,16 +86,17 @@ if (Schema === null) {
|
|
|
85
86
|
*/
|
|
86
87
|
export const Config = Schema?.object({
|
|
87
88
|
tools: Schema.object({
|
|
88
|
-
wsl: Schema.boolean().default(true).description('注册 `wsl` 工具:在 WSL
|
|
89
|
-
path: Schema.boolean().default(true).description('注册 `wsl-path`
|
|
90
|
-
env: Schema.boolean().default(true).description('注册 `wsl-env`
|
|
91
|
-
}).description('
|
|
92
|
-
backgroundJobs: Schema.boolean().default(true).description('
|
|
93
|
-
translatePaths: Schema.boolean().default(true).description('
|
|
94
|
-
startInSessionWorkspace: Schema.boolean().default(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
89
|
+
wsl: Schema.boolean().default(true).description('注册 `wsl` 工具:在 WSL 发行版中执行 Linux 命令。').volatile(),
|
|
90
|
+
path: Schema.boolean().default(true).description('注册 `wsl-path` 工具:在 Windows 路径与 `/mnt/...` 之间互转。').volatile(),
|
|
91
|
+
env: Schema.boolean().default(true).description('注册 `wsl-env` 工具:汇总发行版、内核、systemd、cgroup、GPU 直通、docker 与挂载盘。').volatile(),
|
|
92
|
+
}).description('要注册哪些工具;这三项在重启 DSH 后生效。'),
|
|
93
|
+
backgroundJobs: Schema.boolean().default(true).description('允许长任务以 `runInBackground` 后台执行,结果由内置 job 工具读取。').volatile(),
|
|
94
|
+
translatePaths: Schema.boolean().default(true).description('命令中的 Windows 路径默认转为 `/mnt/...`。').volatile(),
|
|
95
|
+
startInSessionWorkspace: Schema.boolean().default(true).description('未传 `workdir` 时从会话目录启动;默认开。关闭后使用下方固定目录,该目录留空时为家目录 `~`。').volatile(),
|
|
96
|
+
workdir: Schema.string().default('').description('未传 `workdir` 时使用的固定 Linux 目录(如 `/mnt/d/project`),优先于「跟随会话工作区」;留空表示不指定。').volatile(),
|
|
97
|
+
dangerGuard: Schema.boolean().default(true).description('危险命令(删除、分区、关机等)需显式 `allowDangerous` 才放行;关闭后模型可直接执行。').volatile(),
|
|
98
|
+
distro: Schema.string().default('').description('固定使用的发行版;留空表示使用系统默认(也可用 `DSH_WSL_DISTRO`)。').volatile(),
|
|
99
|
+
timeoutMs: Schema.number().default(0).description('默认命令超时(毫秒);0 表示使用内置默认(也可用 `DSH_WSL_TIMEOUT_MS`)。').volatile(),
|
|
98
100
|
}).description('dsh-wsl 的功能开关与默认值。')
|
|
99
101
|
|
|
100
102
|
/**
|
|
@@ -103,6 +105,373 @@ export const Config = Schema?.object({
|
|
|
103
105
|
*/
|
|
104
106
|
const INFO_ROUTE = '/dsh-wsl-tool/info'
|
|
105
107
|
|
|
108
|
+
/**
|
|
109
|
+
* The sidebar terminal's startup directory, as one GET/POST pair.
|
|
110
|
+
*
|
|
111
|
+
* That value does NOT live in this plugin's configuration: the 新建终端 shell belongs
|
|
112
|
+
* to another plugin, and only the profile's own patch layer can override its `args`.
|
|
113
|
+
* So the panel edits that file through these routes, and `lib/terminal-cwd.js` owns the
|
|
114
|
+
* rules for doing it safely (one line, backed up first, read back afterwards).
|
|
115
|
+
*
|
|
116
|
+
* `GET` -> `{ path, available, error }`: what the patch says now, and whether the
|
|
117
|
+
* terminal row is there at all (`available: false` means the opt-in has not
|
|
118
|
+
* been applied, which is a different instruction than an empty value).
|
|
119
|
+
* `POST` -> `{ ok, path, error }`, body `{ path }`; `path: ''` removes the flag.
|
|
120
|
+
*/
|
|
121
|
+
const TERMINAL_ROUTE = '/dsh-wsl-tool/terminal-cwd'
|
|
122
|
+
|
|
123
|
+
/** The profile patch layer this plugin reads and edits, under `DSH_PROFILE_DIR`. */
|
|
124
|
+
const PATCH_FILE_NAME = 'cordis.patch.yml'
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The profile patch this process was started with, or `null` when it cannot be found.
|
|
128
|
+
*
|
|
129
|
+
* The host's own answer comes first: `profileContext.patchPath` is the file the platform's
|
|
130
|
+
* settings UI edits — `dsh-app-boot` and `dsh-config-editor` both read it, and it is the
|
|
131
|
+
* only reliable source. `DSH_PROFILE_DIR` is injected into MODEL TOOL subprocesses, not
|
|
132
|
+
* into the desktop host process itself: measured, the host answered "no DSH_PROFILE_DIR"
|
|
133
|
+
* while the profile patch sat exactly where it belonged. The environment therefore stays
|
|
134
|
+
* as a fallback for a test or a host without that service, never as the primary.
|
|
135
|
+
*
|
|
136
|
+
* Read at REQUEST time rather than captured at mount: the value belongs to the host, and a
|
|
137
|
+
* request that cannot name a file must say so instead of writing to a path from boot.
|
|
138
|
+
*/
|
|
139
|
+
function resolvePatchFile(ctx, env) {
|
|
140
|
+
let service
|
|
141
|
+
try {
|
|
142
|
+
service = ctx === undefined || ctx === null || typeof ctx.get !== 'function'
|
|
143
|
+
? undefined
|
|
144
|
+
: ctx.get('profileContext')
|
|
145
|
+
} catch {
|
|
146
|
+
service = undefined
|
|
147
|
+
}
|
|
148
|
+
const patchPath = service === undefined || service === null ? undefined : service.patchPath
|
|
149
|
+
if (typeof patchPath === 'string' && patchPath.trim() !== '') return patchPath.trim()
|
|
150
|
+
const dir = env === undefined || env === null ? undefined : env.DSH_PROFILE_DIR
|
|
151
|
+
if (typeof dir === 'string' && dir.trim() !== '') return join(dir.trim(), PATCH_FILE_NAME)
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Why the file could not be named, worded so the next reader knows where to look. */
|
|
156
|
+
const NO_PATCH_ERROR = '宿主既没有 profileContext.patchPath 也没有 DSH_PROFILE_DIR,读不到 profile 的 patch 文件'
|
|
157
|
+
|
|
158
|
+
/** A body past this is not a path; refuse it instead of buffering whatever arrives. */
|
|
159
|
+
const MAX_TERMINAL_BODY_BYTES = 4096
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* How many of THIS plugin's own backups to keep beside the patch.
|
|
163
|
+
*
|
|
164
|
+
* One backup per save is the point — the file is the user's composition — but leaving
|
|
165
|
+
* every one of them forever would litter the profile directory. Old ones are pruned by
|
|
166
|
+
* age, and only files matching the exact name this plugin writes are ever considered.
|
|
167
|
+
*/
|
|
168
|
+
const MAX_PATCH_BACKUPS = 10
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* `cordis.patch.yml.bak-YYYYMMDD-HHMMSSmmm` (with a `-N` counter as a last resort).
|
|
172
|
+
*
|
|
173
|
+
* The millisecond field is not cosmetic: pruning FREES names, and without it the next
|
|
174
|
+
* save reuses the un-suffixed name of the second — so a freed (old) name would sort as
|
|
175
|
+
* the newest and the wrong backup would be deleted. The older name without milliseconds is
|
|
176
|
+
* still recognized, so backups written by a previous build are pruned by the same rule.
|
|
177
|
+
*/
|
|
178
|
+
const BACKUP_NAME_RE = /\.bak-(\d{8})-(\d{6})(\d{3})?(?:-(\d+))?$/
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The chronological key a backup name carries: fixed-width stamp text, then the counter.
|
|
182
|
+
*
|
|
183
|
+
* A string, not a number: `YYYYMMDDHHMMSSmmm` is 17 digits, past what a double holds
|
|
184
|
+
* exactly, and fixed width means plain string comparison IS chronological.
|
|
185
|
+
*/
|
|
186
|
+
function backupOrder(name) {
|
|
187
|
+
const match = BACKUP_NAME_RE.exec(name)
|
|
188
|
+
if (match === null) return { stamp: '', counter: 0 }
|
|
189
|
+
return {
|
|
190
|
+
stamp: `${match[1]}${match[2]}${match[3] ?? '000'}`,
|
|
191
|
+
counter: match[4] === undefined ? 1 : Number(match[4]),
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function answerJson(response, code, payload) {
|
|
196
|
+
response.writeHead(code, {
|
|
197
|
+
'content-type': 'application/json; charset=utf-8',
|
|
198
|
+
// A value that was just read or written must describe this moment, not a cached one.
|
|
199
|
+
'cache-control': 'no-store',
|
|
200
|
+
})
|
|
201
|
+
response.end(JSON.stringify(payload))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** One request header, from a node:http request or a fetch-style request object. */
|
|
205
|
+
function requestHeader(request, name) {
|
|
206
|
+
const headers = request === undefined || request === null ? undefined : request.headers
|
|
207
|
+
if (headers === undefined || headers === null) return ''
|
|
208
|
+
const value = typeof headers.get === 'function' ? headers.get(name) : headers[name.toLowerCase()]
|
|
209
|
+
return typeof value === 'string' ? value : ''
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Read one bounded JSON object body. @returns `{ value }` or `{ error }`. */
|
|
213
|
+
function readJsonBody(request, limit = MAX_TERMINAL_BODY_BYTES) {
|
|
214
|
+
return new Promise((resolve) => {
|
|
215
|
+
if (request === null || typeof request.on !== 'function') {
|
|
216
|
+
resolve({ error: '读不到请求体' })
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
const chunks = []
|
|
220
|
+
let size = 0
|
|
221
|
+
let settled = false
|
|
222
|
+
const finish = (result) => {
|
|
223
|
+
if (!settled) {
|
|
224
|
+
settled = true
|
|
225
|
+
resolve(result)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
request.on('data', (chunk) => {
|
|
229
|
+
size += chunk.length
|
|
230
|
+
if (size > limit) {
|
|
231
|
+
finish({ error: `请求体超过 ${limit} 字节` })
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
chunks.push(chunk)
|
|
235
|
+
})
|
|
236
|
+
request.on('end', () => {
|
|
237
|
+
const text = Buffer.concat(chunks).toString('utf8').trim()
|
|
238
|
+
if (text === '') {
|
|
239
|
+
finish({ error: '请求体是空的' })
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
let parsed
|
|
243
|
+
try {
|
|
244
|
+
parsed = JSON.parse(text)
|
|
245
|
+
} catch {
|
|
246
|
+
finish({ error: '请求体不是合法 JSON' })
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
250
|
+
finish({ error: '请求体必须是一个 JSON 对象' })
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
finish({ value: parsed })
|
|
254
|
+
})
|
|
255
|
+
request.on('error', () => finish({ error: '读取请求体失败' }))
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Copy the patch next to itself before touching it.
|
|
261
|
+
*
|
|
262
|
+
* The file is the user's own composition: an edit that turns out badly has to be
|
|
263
|
+
* recoverable by hand even if this process dies mid-write. The stamp carries milliseconds
|
|
264
|
+
* (with a counter as a last resort), and no colons, because Windows refuses those in a
|
|
265
|
+
* file name.
|
|
266
|
+
*/
|
|
267
|
+
function backupPatchFile(file) {
|
|
268
|
+
const iso = new Date().toISOString()
|
|
269
|
+
// 20261005-144428123, no colons: Windows refuses them in a file name.
|
|
270
|
+
const stamp = `${iso.slice(0, 10).replace(/-/g, '')}-${iso.slice(11, 19).replace(/:/g, '')}${iso.slice(20, 23)}`
|
|
271
|
+
let target = `${file}.bak-${stamp}`
|
|
272
|
+
let suffix = 2
|
|
273
|
+
while (existsSync(target)) {
|
|
274
|
+
target = `${file}.bak-${stamp}-${suffix}`
|
|
275
|
+
suffix += 1
|
|
276
|
+
}
|
|
277
|
+
copyFileSync(file, target)
|
|
278
|
+
return target
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Keep the newest {@link MAX_PATCH_BACKUPS} backups of one patch, delete the rest.
|
|
283
|
+
*
|
|
284
|
+
* Deliberately conservative: a file is a candidate only when its name starts with this
|
|
285
|
+
* patch's name and matches the exact stamp format above, so a `.bak` the user made by
|
|
286
|
+
* hand (or another patch's backup) is never touched. `keep` is the backup that was just
|
|
287
|
+
* written and is never a candidate, whatever the mtimes say. Never throws — a backup that
|
|
288
|
+
* could not be pruned is a tidiness problem, not a reason to fail a save.
|
|
289
|
+
*/
|
|
290
|
+
function prunePatchBackups(file, keep) {
|
|
291
|
+
const dir = dirname(file)
|
|
292
|
+
const prefix = `${basename(file)}.bak-`
|
|
293
|
+
const protectedName = typeof keep === 'string' ? basename(keep) : ''
|
|
294
|
+
let names
|
|
295
|
+
try {
|
|
296
|
+
names = readdirSync(dir)
|
|
297
|
+
} catch {
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
const mine = []
|
|
301
|
+
for (const name of names) {
|
|
302
|
+
if (name === protectedName) continue
|
|
303
|
+
if (!name.startsWith(prefix) || !BACKUP_NAME_RE.test(name)) continue
|
|
304
|
+
let time = 0
|
|
305
|
+
try {
|
|
306
|
+
time = statSync(join(dir, name)).mtimeMs
|
|
307
|
+
} catch {
|
|
308
|
+
// Raced away (or unreadable): it cannot be ordered, so treat it as the oldest.
|
|
309
|
+
}
|
|
310
|
+
mine.push({ name, time, order: backupOrder(name) })
|
|
311
|
+
}
|
|
312
|
+
// The backup just written counts against the budget too, so the total on disk never
|
|
313
|
+
// exceeds the cap.
|
|
314
|
+
const reserved = protectedName !== '' && names.includes(protectedName) ? 1 : 0
|
|
315
|
+
const budget = MAX_PATCH_BACKUPS - reserved
|
|
316
|
+
if (mine.length <= budget) return
|
|
317
|
+
// Newest first by mtime, with the name's own timestamp as the tie-break so a burst of
|
|
318
|
+
// saves in one clock tick still has a defined order.
|
|
319
|
+
mine.sort((left, right) => {
|
|
320
|
+
if (right.time !== left.time) return right.time - left.time
|
|
321
|
+
if (left.order.stamp !== right.order.stamp) return left.order.stamp < right.order.stamp ? 1 : -1
|
|
322
|
+
return right.order.counter - left.order.counter
|
|
323
|
+
})
|
|
324
|
+
for (const entry of mine.slice(budget)) {
|
|
325
|
+
try {
|
|
326
|
+
rmSync(join(dir, entry.name), { force: true })
|
|
327
|
+
} catch (error) {
|
|
328
|
+
console.warn('dsh-wsl-tool: could not prune an old profile-patch backup', error)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Serve the terminal startup directory.
|
|
335
|
+
*
|
|
336
|
+
* Takes the host context because the file's location comes from it
|
|
337
|
+
* (`profileContext.patchPath`). Never throws: every failure becomes a structured answer the
|
|
338
|
+
* panel can show, because a 500 here would take the panel's field down without saying why.
|
|
339
|
+
*/
|
|
340
|
+
async function serveTerminalCwd(ctx, request, response) {
|
|
341
|
+
const file = resolvePatchFile(ctx, process.env)
|
|
342
|
+
if (file === null) {
|
|
343
|
+
answerJson(response, 200, { ok: false, path: '', available: false, error: NO_PATCH_ERROR })
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (request.method === 'GET') {
|
|
348
|
+
let text
|
|
349
|
+
try {
|
|
350
|
+
text = readFileSync(file, 'utf8')
|
|
351
|
+
} catch {
|
|
352
|
+
answerJson(response, 200, { path: '', available: false, error: `读不到 ${PATCH_FILE_NAME}` })
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
const read = readTerminalCwd(text)
|
|
356
|
+
answerJson(response, 200, { path: read.path, available: read.row, error: read.error })
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (request.method !== 'POST') {
|
|
361
|
+
response.writeHead(405, { allow: 'GET, POST' })
|
|
362
|
+
response.end()
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// This route WRITES a file in the user's profile, and the host's web server carries no
|
|
367
|
+
// origin policy of its own (its README: "route owners … enforce their own request
|
|
368
|
+
// policy"). Requiring the JSON content type is the same gate the platform's own upload
|
|
369
|
+
// route uses: `application/json` is not a CORS-safelisted content type, so a cross-site
|
|
370
|
+
// page can only send it after a preflight — and this server answers no preflight — while
|
|
371
|
+
// a form or a `no-cors` fetch cannot set it at all. The panel is same-origin and already
|
|
372
|
+
// sends it, so this costs an honest caller nothing and keeps a hostile page from
|
|
373
|
+
// rewriting the patch.
|
|
374
|
+
const contentType = requestHeader(request, 'content-type')
|
|
375
|
+
if (!/^application\/json\s*(?:;|$)/i.test(contentType)) {
|
|
376
|
+
answerJson(response, 415, { ok: false, path: '', error: '请求必须是 application/json' })
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const body = await readJsonBody(request)
|
|
381
|
+
if (body.error !== undefined) {
|
|
382
|
+
answerJson(response, 400, { ok: false, path: '', error: body.error })
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
if (typeof body.value.path !== 'string') {
|
|
386
|
+
answerJson(response, 400, { ok: false, path: '', error: '请求体里需要一个字符串字段 path' })
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
let original
|
|
391
|
+
try {
|
|
392
|
+
original = readFileSync(file, 'utf8')
|
|
393
|
+
} catch {
|
|
394
|
+
answerJson(response, 200, { ok: false, path: '', error: `读不到 ${PATCH_FILE_NAME}` })
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Bad input (a line break, a quote) and an unrecognized file both stop HERE, with the
|
|
399
|
+
// file still byte-for-byte what it was.
|
|
400
|
+
const next = writeTerminalCwd(original, body.value.path)
|
|
401
|
+
if (!next.ok) {
|
|
402
|
+
answerJson(response, 200, {
|
|
403
|
+
ok: false,
|
|
404
|
+
path: readTerminalCwd(original).path,
|
|
405
|
+
error: next.error,
|
|
406
|
+
})
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
if (next.text === original) {
|
|
410
|
+
// Already what was asked for: no write, and no backup for a no-op.
|
|
411
|
+
answerJson(response, 200, { ok: true, path: next.path, error: null })
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
let backup
|
|
416
|
+
try {
|
|
417
|
+
backup = backupPatchFile(file)
|
|
418
|
+
} catch (error) {
|
|
419
|
+
console.warn('dsh-wsl-tool: could not back up the profile patch', error)
|
|
420
|
+
answerJson(response, 200, {
|
|
421
|
+
ok: false,
|
|
422
|
+
path: readTerminalCwd(original).path,
|
|
423
|
+
error: '备份 profile patch 失败,未改动文件',
|
|
424
|
+
})
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
prunePatchBackups(file, backup)
|
|
428
|
+
|
|
429
|
+
try {
|
|
430
|
+
writeFileSync(file, next.text, 'utf8')
|
|
431
|
+
} catch (error) {
|
|
432
|
+
console.warn('dsh-wsl-tool: writing the profile patch failed', error)
|
|
433
|
+
answerJson(response, 200, {
|
|
434
|
+
ok: false,
|
|
435
|
+
path: readTerminalCwd(original).path,
|
|
436
|
+
error: '写入 profile patch 失败',
|
|
437
|
+
})
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Read back what actually landed. Anything other than exactly one changed line is
|
|
442
|
+
// undone from the copy held in memory, so a surprise never stays in the user's file —
|
|
443
|
+
// unless the file is no longer our text at all, which means somebody else wrote to it
|
|
444
|
+
// after us and their version must not be overwritten.
|
|
445
|
+
let written = null
|
|
446
|
+
try {
|
|
447
|
+
written = readFileSync(file, 'utf8')
|
|
448
|
+
} catch {
|
|
449
|
+
written = null
|
|
450
|
+
}
|
|
451
|
+
const review = reviewTerminalWrite(original, written, next.text, next.path)
|
|
452
|
+
if (review.verdict === 'conflict') {
|
|
453
|
+
answerJson(response, 200, { ok: false, path: '', error: review.error })
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
if (review.verdict === 'undo') {
|
|
457
|
+
let restored = true
|
|
458
|
+
try {
|
|
459
|
+
writeFileSync(file, original, 'utf8')
|
|
460
|
+
} catch (error) {
|
|
461
|
+
console.error('dsh-wsl-tool: could not restore the profile patch', error)
|
|
462
|
+
restored = false
|
|
463
|
+
}
|
|
464
|
+
answerJson(response, 200, {
|
|
465
|
+
ok: false,
|
|
466
|
+
path: readTerminalCwd(original).path,
|
|
467
|
+
error: `写入校验失败(${review.error})${restored ? ',已还原' : ',且还原失败,请从备份恢复'}`,
|
|
468
|
+
})
|
|
469
|
+
return
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
answerJson(response, 200, { ok: true, path: next.path, error: null })
|
|
473
|
+
}
|
|
474
|
+
|
|
106
475
|
/** Read this package's own manifest once; a broken manifest must not break the route. */
|
|
107
476
|
let manifestCache
|
|
108
477
|
function readManifest() {
|
|
@@ -247,39 +616,41 @@ export function apply(ctx, settings = {}) {
|
|
|
247
616
|
ctx.inject(['webServer'], (inner) => {
|
|
248
617
|
const webServer = inner.webServer
|
|
249
618
|
if (webServer === undefined || webServer === null || typeof webServer.register !== 'function') return
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
response.end()
|
|
257
|
-
return
|
|
258
|
-
}
|
|
259
|
-
let body
|
|
260
|
-
try {
|
|
261
|
-
// The WSL probes cost a few WSL round trips; they run concurrently and
|
|
262
|
-
// every one of them may fail without failing the request.
|
|
263
|
-
body = JSON.stringify(await selfInfo(config, runner))
|
|
264
|
-
} catch (error) {
|
|
265
|
-
console.warn('dsh-wsl-tool: reading self info failed', error)
|
|
266
|
-
body = JSON.stringify({ name: 'dsh-wsl-tool', version: null, dsh: null, wsl: null })
|
|
267
|
-
}
|
|
268
|
-
response.writeHead(200, {
|
|
269
|
-
'content-type': 'application/json; charset=utf-8',
|
|
270
|
-
// A copied block must describe this moment, not a cached one.
|
|
271
|
-
'cache-control': 'no-store',
|
|
272
|
-
})
|
|
273
|
-
response.end(body)
|
|
274
|
-
},
|
|
275
|
-
})
|
|
276
|
-
if (typeof inner.effect === 'function') {
|
|
277
|
-
inner.effect(() => disposer, 'dsh-wsl-tool: self-info route')
|
|
619
|
+
// Two routes, published together: the read-only self info, and the terminal
|
|
620
|
+
// startup directory the panel reads and writes. Both are optional — an older
|
|
621
|
+
// host without `webServer` simply gets no panel features that need one.
|
|
622
|
+
const publish = (path, handler, label) => {
|
|
623
|
+
const disposer = webServer.register({ kind: 'exact', path, handler })
|
|
624
|
+
if (typeof inner.effect === 'function') inner.effect(() => disposer, label)
|
|
278
625
|
}
|
|
626
|
+
publish(INFO_ROUTE, async (request, response) => {
|
|
627
|
+
if (request.method !== 'GET') {
|
|
628
|
+
response.writeHead(405, { allow: 'GET' })
|
|
629
|
+
response.end()
|
|
630
|
+
return
|
|
631
|
+
}
|
|
632
|
+
let body
|
|
633
|
+
try {
|
|
634
|
+
// The WSL probes cost a few WSL round trips; they run concurrently and
|
|
635
|
+
// every one of them may fail without failing the request.
|
|
636
|
+
body = JSON.stringify(await selfInfo(config, runner))
|
|
637
|
+
} catch (error) {
|
|
638
|
+
console.warn('dsh-wsl-tool: reading self info failed', error)
|
|
639
|
+
body = JSON.stringify({ name: 'dsh-wsl-tool', version: null, dsh: null, wsl: null })
|
|
640
|
+
}
|
|
641
|
+
response.writeHead(200, {
|
|
642
|
+
'content-type': 'application/json; charset=utf-8',
|
|
643
|
+
// A copied block must describe this moment, not a cached one.
|
|
644
|
+
'cache-control': 'no-store',
|
|
645
|
+
})
|
|
646
|
+
response.end(body)
|
|
647
|
+
}, 'dsh-wsl-tool: self-info route')
|
|
648
|
+
publish(TERMINAL_ROUTE, (request, response) => serveTerminalCwd(ctx, request, response),
|
|
649
|
+
'dsh-wsl-tool: terminal-cwd route')
|
|
279
650
|
})
|
|
280
651
|
}
|
|
281
652
|
} catch (error) {
|
|
282
|
-
console.warn('dsh-wsl-tool: could not publish the
|
|
653
|
+
console.warn('dsh-wsl-tool: could not publish the plugin routes', error)
|
|
283
654
|
}
|
|
284
655
|
|
|
285
656
|
// A volatile edit is written into the same object the loader handed us and then
|