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 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 里执行 Linux 命令。').volatile(),
89
- path: Schema.boolean().default(true).description('注册 `wsl-path` 工具:Windows 路径与 /mnt/... 互转。').volatile(),
90
- env: Schema.boolean().default(true).description('注册 `wsl-env` 工具:汇总 WSL 环境能力。').volatile(),
91
- }).description('要注册哪几个工具;这三个开关在下次启动 DSH 后生效。'),
92
- backgroundJobs: Schema.boolean().default(true).description('允许 `runInBackground`,由内置 job 工具读回结果。').volatile(),
93
- translatePaths: Schema.boolean().default(true).description('默认把命令里的 Windows 路径转成 /mnt/...。').volatile(),
94
- startInSessionWorkspace: Schema.boolean().default(false).description('未传 `workdir` 时从会话工作区开始,而不是 Linux 家目录。').volatile(),
95
- dangerGuard: Schema.boolean().default(true).description('危险命令必须显式 `allowDangerous` 才放行。关掉后模型可直接删除/分区。').volatile(),
96
- distro: Schema.string().default('').description('要固定使用的发行版;留空则用系统默认(也可用 DSH_WSL_DISTRO)。').volatile(),
97
- timeoutMs: Schema.number().default(0).description('默认命令超时毫秒数;0 表示用内置默认(也可用 DSH_WSL_TIMEOUT_MS)。').volatile(),
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
- const disposer = webServer.register({
251
- kind: 'exact',
252
- path: INFO_ROUTE,
253
- handler: async (request, response) => {
254
- if (request.method !== 'GET') {
255
- response.writeHead(405, { allow: 'GET' })
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 self-info route', error)
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