dsh-wsl-tool 1.9.1 → 1.10.1
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/PUBLISHING.md +16 -0
- package/README.md +32 -0
- package/README.zh-CN.md +25 -0
- package/index.js +71 -15
- package/lib/client.js +691 -0
- package/lib/config.js +69 -16
- package/lib/schema.js +35 -0
- package/lib/tools/wsl.js +15 -3
- package/package.json +24 -3
package/PUBLISHING.md
CHANGED
|
@@ -67,6 +67,22 @@ both that the entry stays relative and that it resolves.
|
|
|
67
67
|
once at load. A patch change (the tool row, or the sidebar terminal override)
|
|
68
68
|
is read at composition time, so it needs that restart too.
|
|
69
69
|
|
|
70
|
+
**Then prove the schema really builds from the installed copy**, because this
|
|
71
|
+
is the one failure the tools survive:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
cd ~/.dsh/profiles/desktop/node_modules/dsh-wsl
|
|
75
|
+
node -e "import('./index.js').then(m => console.log(typeof m.Config, JSON.stringify(m.Config({}))))"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
It must print `function` and the resolved defaults. If it prints `undefined`,
|
|
79
|
+
the plugin declares no `Config`, the platform has no schema to project, the
|
|
80
|
+
entry's config status stays `absent` and the sidebar panel renders without a
|
|
81
|
+
single switch while all three tools keep working. The fragile step is the
|
|
82
|
+
interop hop: `@deepseek-ai/schemastery` exports its builder as the **default**
|
|
83
|
+
export, so `const { Schema } = await import(...)` silently yields `undefined`
|
|
84
|
+
(see `lib/schema.js`, and the regression test that pins the picking).
|
|
85
|
+
|
|
70
86
|
3. The tag runs the pipeline. The order is deliberate — the release asset goes
|
|
71
87
|
**first** because it is the market's critical path, then npm, so a failing npm
|
|
72
88
|
publish fails the run loudly without withholding the release.
|
package/README.md
CHANGED
|
@@ -134,6 +134,38 @@ A background job is owned by the calling session (`owner: exec.agent.id`), which
|
|
|
134
134
|
is what lets the model read it back with `job_output`/`job_kill` and what keeps
|
|
135
135
|
other sessions out; an execution with no agent starts the job unowned.
|
|
136
136
|
|
|
137
|
+
## The WSL panel (left sidebar)
|
|
138
|
+
|
|
139
|
+
The plugin ships a small client half: a **WSL** entry in the desktop app's left
|
|
140
|
+
sidebar, whose panel carries one switch per feature, each with a one-line
|
|
141
|
+
explanation.
|
|
142
|
+
|
|
143
|
+
| Switch | What it controls |
|
|
144
|
+
|---|---|
|
|
145
|
+
| `wsl` 命令执行 | registers the `wsl` tool |
|
|
146
|
+
| `wsl-path` 路径转换 | registers the `wsl-path` tool |
|
|
147
|
+
| `wsl-env` 能力体检 | registers the `wsl-env` tool |
|
|
148
|
+
| 后台任务 | whether `wsl` accepts `runInBackground` |
|
|
149
|
+
| 自动转换路径 | the default for the per-call `translatePaths` |
|
|
150
|
+
| 默认跟随会话工作区 | start in the session's directory instead of `~` when `workdir` is omitted |
|
|
151
|
+
| 危险命令守卫 | whether a destructive command needs an explicit `allowDangerous` |
|
|
152
|
+
|
|
153
|
+
The panel edits the plugin's own configuration, so the same values can be written
|
|
154
|
+
by hand (`- id: tool-wsl` with `config:` in a profile patch) or by environment
|
|
155
|
+
variables. `lib/config.js` owns the precedence — **plugin configuration, then the
|
|
156
|
+
environment, then the built-in defaults** — and a switch left at its default lets
|
|
157
|
+
the layer below decide, which is why `DSH_WSL_WORKDIR=session` keeps working for
|
|
158
|
+
someone who never opened the panel.
|
|
159
|
+
|
|
160
|
+
**A change takes effect at the next DSH start**: the host reads this configuration
|
|
161
|
+
once per mount, and the panel says so. Distro and timeout are values rather than
|
|
162
|
+
features — set them in the patch or with `DSH_WSL_DISTRO` / `DSH_WSL_TIMEOUT_MS`,
|
|
163
|
+
and the panel shows what is currently in effect.
|
|
164
|
+
|
|
165
|
+
The settings surface needs `@deepseek-ai/schemastery`, which the plugin declares as
|
|
166
|
+
an optional peer dependency: without it the three tools still run on their defaults
|
|
167
|
+
and only the panel is missing.
|
|
168
|
+
|
|
137
169
|
## Optional: a WSL terminal in the sidebar
|
|
138
170
|
|
|
139
171
|
The desktop app's sidebar terminal can open WSL instead of a Windows shell. It is
|
package/README.zh-CN.md
CHANGED
|
@@ -116,6 +116,31 @@ DSH_SUBPROCESS_LOCAL=/path/to/dsh/node_modules npm run test:real
|
|
|
116
116
|
`job_output`/`job_kill` 读回它的依据,也是其他会话读不到它的围栏;exec 里没有 agent 时
|
|
117
117
|
任务则是无主的。
|
|
118
118
|
|
|
119
|
+
## 左侧栏的 WSL 面板
|
|
120
|
+
|
|
121
|
+
插件带了一个小的客户端半:桌面版左侧栏里多一个 **WSL** 入口,面板里每个功能一个开关,每个开关
|
|
122
|
+
配一行说明。
|
|
123
|
+
|
|
124
|
+
| 开关 | 管什么 |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `wsl` 命令执行 | 是否注册 `wsl` 工具 |
|
|
127
|
+
| `wsl-path` 路径转换 | 是否注册 `wsl-path` 工具 |
|
|
128
|
+
| `wsl-env` 能力体检 | 是否注册 `wsl-env` 工具 |
|
|
129
|
+
| 后台任务 | `wsl` 是否接受 `runInBackground` |
|
|
130
|
+
| 自动转换路径 | 每次调用的 `translatePaths` 默认值 |
|
|
131
|
+
| 默认跟随会话工作区 | 未传 `workdir` 时从会话目录开始,而不是 `~` |
|
|
132
|
+
| 危险命令守卫 | 危险命令是否必须显式 `allowDangerous` |
|
|
133
|
+
|
|
134
|
+
面板改的是插件自己的配置,所以同样的值也可以手写进 profile patch(`- id: tool-wsl` 加 `config:`)
|
|
135
|
+
或用环境变量设。优先级由 `lib/config.js` 定:**插件配置 > 环境变量 > 内置默认值**;开关停在默认值时
|
|
136
|
+
下层说了算 —— 这正是"从没打开过面板的人,`DSH_WSL_WORKDIR=session` 依然生效"的原因。
|
|
137
|
+
|
|
138
|
+
**改动在下次启动 DSH 后生效**:宿主每次挂载只读一次该配置,面板里也写着这句。发行版与超时属于"值"
|
|
139
|
+
而不是"功能":在 patch 里或用 `DSH_WSL_DISTRO` / `DSH_WSL_TIMEOUT_MS` 设置,面板只显示当前生效值。
|
|
140
|
+
|
|
141
|
+
这个设置界面需要 `@deepseek-ai/schemastery`(插件把它声明为可选 peer 依赖):没有它三个工具照常按
|
|
142
|
+
默认值工作,只是没有面板。
|
|
143
|
+
|
|
119
144
|
## 可选:在侧边栏开一个 WSL 终端
|
|
120
145
|
|
|
121
146
|
桌面版侧边栏终端可以开 WSL 而不是 Windows shell。这是**可选**的 —— 装插件**不会**改你终端默认
|
package/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// dsh-wsl: model-facing WSL tools for DeepSeek Harness (DSH).
|
|
2
2
|
//
|
|
3
|
-
// Registers three tools
|
|
3
|
+
// Registers up to three tools, each one switchable from the plugin's
|
|
4
|
+
// left-sidebar panel:
|
|
4
5
|
// - `wsl` : run a Linux command through wsl.exe, returning stdout/stderr
|
|
5
6
|
// with exit-code / signal / timeout / truncation markers.
|
|
6
7
|
// - `wsl-path` : convert between Windows and WSL paths via `wslpath`.
|
|
@@ -11,12 +12,17 @@
|
|
|
11
12
|
// plugin publishes nothing and only consumes the host-plane `subprocess` and
|
|
12
13
|
// `tools` registries, so it sits loose in an agent preset without a realm.
|
|
13
14
|
//
|
|
14
|
-
// The implementation lives in `lib/`: `config` (defaults
|
|
15
|
-
//
|
|
16
|
-
// destructive-command rules), `result` (markers and truncation),
|
|
17
|
-
// one spawn path)
|
|
15
|
+
// The implementation lives in `lib/`: `config` (defaults, environment overrides
|
|
16
|
+
// and the plugin's own switches), `paths` (path translation and shell quoting),
|
|
17
|
+
// `guard` (the destructive-command rules), `result` (markers and truncation),
|
|
18
|
+
// `runner` (the one spawn path), `tools/` (the three tool definitions) and
|
|
19
|
+
// `client.js` (the sidebar panel, a separate web-platform bundle).
|
|
20
|
+
//
|
|
21
|
+
// A configuration change is a mount, not something a running session re-reads:
|
|
22
|
+
// every switch below is consulted once, in `apply`.
|
|
18
23
|
|
|
19
24
|
import { resolveConfig } from './lib/config.js'
|
|
25
|
+
import { pickSchemaBuilder } from './lib/schema.js'
|
|
20
26
|
import { createRunner } from './lib/runner.js'
|
|
21
27
|
import { createWslTool } from './lib/tools/wsl.js'
|
|
22
28
|
import { createWslPathTool } from './lib/tools/wsl-path.js'
|
|
@@ -25,15 +31,65 @@ import { createWslEnvTool } from './lib/tools/wsl-env.js'
|
|
|
25
31
|
export const name = 'tool-wsl'
|
|
26
32
|
export const inject = ['tools', 'subprocess']
|
|
27
33
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
// The schema is what gives this plugin a settings surface at all: the platform
|
|
35
|
+
// projects the config of every entry that declares one (`ctx.settings.describe()`
|
|
36
|
+
// is keyed by entry id), and the sidebar panel reads and writes that projection.
|
|
37
|
+
// It needs `@deepseek-ai/schemastery`, which a real DSH profile supplies (the
|
|
38
|
+
// plugin declares it as an optional peer dependency) but which is absent when this
|
|
39
|
+
// module is imported standalone, as `test/smoke.mjs` does. A soft import keeps the
|
|
40
|
+
// tools runnable there: without a schema every switch keeps its default and only
|
|
41
|
+
// the panel is missing — and `lib/schema.js` explains why the PICKING, not just
|
|
42
|
+
// the import, is the fragile part.
|
|
43
|
+
let Schema = null
|
|
44
|
+
try {
|
|
45
|
+
Schema = pickSchemaBuilder(await import('@deepseek-ai/schemastery'))
|
|
46
|
+
} catch {
|
|
47
|
+
Schema = null
|
|
48
|
+
}
|
|
49
|
+
if (Schema === null) {
|
|
50
|
+
// Not fatal — the tools run on their defaults — but it is exactly the difference
|
|
51
|
+
// between a panel with switches and one without, so say so instead of failing
|
|
52
|
+
// quietly.
|
|
53
|
+
console.warn(
|
|
54
|
+
'dsh-wsl-tool: no schema builder (@deepseek-ai/schemastery) — the tools run on ' +
|
|
55
|
+
'their defaults and the sidebar panel will not offer the switches',
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The plugin's own configuration.
|
|
61
|
+
*
|
|
62
|
+
* Every field is ALSO settable by hand in a profile patch
|
|
63
|
+
* (`- id: tool-wsl` / `config:`) and by the environment, and `lib/config.js`
|
|
64
|
+
* documents the precedence: this configuration wins, the environment is the
|
|
65
|
+
* deployment default, the built-in defaults are last.
|
|
66
|
+
*
|
|
67
|
+
* `distro` and `timeoutMs` are deliberately absent from the sidebar panel (they
|
|
68
|
+
* are values, not features) but stay here so a patch or a panel could grow a
|
|
69
|
+
* field for them without a schema change.
|
|
70
|
+
*/
|
|
71
|
+
export const Config = Schema?.object({
|
|
72
|
+
tools: Schema.object({
|
|
73
|
+
wsl: Schema.boolean().default(true).description('注册 `wsl` 工具:在 WSL 里执行 Linux 命令。'),
|
|
74
|
+
path: Schema.boolean().default(true).description('注册 `wsl-path` 工具:Windows 路径与 /mnt/... 互转。'),
|
|
75
|
+
env: Schema.boolean().default(true).description('注册 `wsl-env` 工具:汇总 WSL 环境能力。'),
|
|
76
|
+
}).description('要注册哪几个工具。'),
|
|
77
|
+
backgroundJobs: Schema.boolean().default(true).description('允许 `runInBackground`,由内置 job 工具读回结果。'),
|
|
78
|
+
translatePaths: Schema.boolean().default(true).description('默认把命令里的 Windows 路径转成 /mnt/...。'),
|
|
79
|
+
startInSessionWorkspace: Schema.boolean().default(false).description('未传 `workdir` 时从会话工作区开始,而不是 Linux 家目录。'),
|
|
80
|
+
dangerGuard: Schema.boolean().default(true).description('危险命令必须显式 `allowDangerous` 才放行。关掉后模型可直接删除/分区。'),
|
|
81
|
+
distro: Schema.string().default('').description('要固定使用的发行版;留空则用系统默认(也可用 DSH_WSL_DISTRO)。'),
|
|
82
|
+
timeoutMs: Schema.number().default(0).description('默认命令超时毫秒数;0 表示用内置默认(也可用 DSH_WSL_TIMEOUT_MS)。'),
|
|
83
|
+
}).description('dsh-wsl 的功能开关与默认值。')
|
|
84
|
+
|
|
85
|
+
export function apply(ctx, settings = {}) {
|
|
86
|
+
// Resolved once per mount: a settings change is a restart, not a live edit.
|
|
87
|
+
const config = resolveConfig(process.env, settings)
|
|
32
88
|
const runner = createRunner(ctx, config)
|
|
33
|
-
// `ctx` is passed for the optional `jobs` service only
|
|
34
|
-
// it is read with ctx.get at call time, never injected,
|
|
35
|
-
// tool-jobs still mounts this plugin.
|
|
36
|
-
ctx.tools.register(createWslTool({ ctx, config, runner }))
|
|
37
|
-
ctx.tools.register(createWslPathTool({ config, runner }))
|
|
38
|
-
ctx.tools.register(createWslEnvTool({ config, runner }))
|
|
89
|
+
// `ctx` is passed to the `wsl` tool for the optional `jobs` service only
|
|
90
|
+
// (background commands); it is read with ctx.get at call time, never injected,
|
|
91
|
+
// so a preset without tool-jobs still mounts this plugin.
|
|
92
|
+
if (config.tools.wsl) ctx.tools.register(createWslTool({ ctx, config, runner }))
|
|
93
|
+
if (config.tools.path) ctx.tools.register(createWslPathTool({ config, runner }))
|
|
94
|
+
if (config.tools.env) ctx.tools.register(createWslEnvTool({ config, runner }))
|
|
39
95
|
}
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
// dsh-wsl-tool, browser half.
|
|
2
|
+
//
|
|
3
|
+
// This file is a PLAIN hand-written bundle, exactly like the ones DSH ships: no
|
|
4
|
+
// build step, no `import`/`export` keywords, no JSX, no TypeScript. The page
|
|
5
|
+
// loads it as a script, the module loader below hands it a `require`, and the
|
|
6
|
+
// only two modules it may ask for are React and the client UI primitives.
|
|
7
|
+
//
|
|
8
|
+
// What it contributes:
|
|
9
|
+
// - one rail entry in the left sidebar (`sidebar.panellist`, a list seat), and
|
|
10
|
+
// - the panel behind that entry (`main`, a keyed seat under the same id),
|
|
11
|
+
// whose switches edit this plugin's own Host configuration row.
|
|
12
|
+
//
|
|
13
|
+
// The Host half reads its settings ONCE per mount (see `lib/config.js`), so a
|
|
14
|
+
// switch here changes the documented file/profile configuration; it does not
|
|
15
|
+
// reconfigure a running session. The panel says so in one line instead of
|
|
16
|
+
// pretending otherwise.
|
|
17
|
+
//
|
|
18
|
+
// Layout facts this file depends on (all verified against the shipped client):
|
|
19
|
+
// - `sidebar.panellist` entries carry `id`, `order` and `label`, and render
|
|
20
|
+
// their own icon (there is no icon field).
|
|
21
|
+
// - `main` is keyed by that same id, and its `inject` callback supplies extra
|
|
22
|
+
// props to the panel component.
|
|
23
|
+
// - `ctx.configForms.get(rowId)` returns a scope with
|
|
24
|
+
// `getSnapshot()` / `subscribe(listener)` / `mutate(ops, revision)`, where a
|
|
25
|
+
// snapshot is `{ status, value, base, user, writable, revision }` and one op
|
|
26
|
+
// is `{ op: 'set', path, value }` or `{ op: 'unset', path }`.
|
|
27
|
+
|
|
28
|
+
window.__ModuleLoader__.load({
|
|
29
|
+
id: 'dsh-wsl-tool',
|
|
30
|
+
factory: (require) => {
|
|
31
|
+
var module = { exports: {} }
|
|
32
|
+
var exports = module.exports
|
|
33
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
34
|
+
|
|
35
|
+
const React = require('react')
|
|
36
|
+
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Panel identity. The layout pairs one `sidebar.panellist` entry with the
|
|
40
|
+
* `main` entry registered under the same key, so these two must agree; the
|
|
41
|
+
* rail entry's `label` is the visible text, the accessible name and the
|
|
42
|
+
* collapsed tooltip.
|
|
43
|
+
*/
|
|
44
|
+
const PANEL_ID = 'dsh-wsl'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Loader row id of this bundle's Host half, read from our own
|
|
48
|
+
* `cordis.patch.yml`. `configForms.get` addresses a Host configuration entry
|
|
49
|
+
* by that row id — not by the package name, because one package may be
|
|
50
|
+
* composed as several rows under different ids.
|
|
51
|
+
*/
|
|
52
|
+
const CONFIG_ROW_ID = 'tool-wsl'
|
|
53
|
+
|
|
54
|
+
// Theme tokens, read as CSS custom properties so the panel follows the
|
|
55
|
+
// active light/dark theme without importing any stylesheet. Each carries a
|
|
56
|
+
// neutral fallback: an unknown token must not make text invisible.
|
|
57
|
+
const LABEL_PRIMARY = 'var(--dsw-alias-label-primary, inherit)'
|
|
58
|
+
const LABEL_SECONDARY = 'var(--dsw-alias-label-secondary, inherit)'
|
|
59
|
+
const LABEL_TERTIARY = 'var(--dsw-alias-label-tertiary, inherit)'
|
|
60
|
+
const BORDER_SOFT = 'var(--dsw-alias-border-l2, rgba(127, 127, 127, 0.25))'
|
|
61
|
+
const BORDER_BADGE = 'var(--dsw-alias-border-l3, rgba(127, 127, 127, 0.35))'
|
|
62
|
+
const WARN_LABEL = 'var(--dsw-alias-state-warn-label, #b45309)'
|
|
63
|
+
const LINK_LABEL = 'var(--dsw-alias-link, inherit)'
|
|
64
|
+
const CODE_FILL = 'var(--dsw-alias-markdown-code-block, rgba(127, 127, 127, 0.12))'
|
|
65
|
+
const CODE_LABEL = 'var(--dsw-alias-markdown-inline-code, inherit)'
|
|
66
|
+
const RADIUS = 'var(--dsw-radius-xs, 4px)'
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The switches, grouped into the two sections the panel renders.
|
|
70
|
+
*
|
|
71
|
+
* `path` - path inside this plugin's configuration object (settings ops are
|
|
72
|
+
* path-addressed, so a nested section is one array, not a string).
|
|
73
|
+
* `label` - visible row text; also the switch's accessible name.
|
|
74
|
+
* `hint` - the one-line explanation under the label.
|
|
75
|
+
* `when` - the composed default for the row, used ONLY when the resolved
|
|
76
|
+
* value does not carry the field at all (an older Host half, or a
|
|
77
|
+
* field the Host schema does not declare). Reading a missing field
|
|
78
|
+
* as `false` would silently mislabel a default-on feature.
|
|
79
|
+
* `danger` - marks a switch whose off state widens what the model may run.
|
|
80
|
+
*/
|
|
81
|
+
const SECTIONS = [
|
|
82
|
+
{
|
|
83
|
+
id: 'tools',
|
|
84
|
+
title: '工具',
|
|
85
|
+
rows: [
|
|
86
|
+
{
|
|
87
|
+
path: ['tools', 'wsl'],
|
|
88
|
+
label: 'wsl 命令执行',
|
|
89
|
+
hint: '注册 `wsl` 工具:让模型在 WSL 发行版里执行 Linux 命令(一次一个全新 shell)',
|
|
90
|
+
when: true,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
path: ['tools', 'path'],
|
|
94
|
+
label: 'wsl-path 路径转换',
|
|
95
|
+
hint: '注册 `wsl-path` 工具:在 Windows 路径与 `/mnt/...` 之间互转',
|
|
96
|
+
when: true,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
path: ['tools', 'env'],
|
|
100
|
+
label: 'wsl-env 能力体检',
|
|
101
|
+
hint: '注册 `wsl-env` 工具:汇总发行版、内核、systemd、cgroup、GPU 直通、docker、挂载盘与两侧配置',
|
|
102
|
+
when: true,
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: 'behavior',
|
|
108
|
+
title: '行为',
|
|
109
|
+
rows: [
|
|
110
|
+
{
|
|
111
|
+
path: ['backgroundJobs'],
|
|
112
|
+
label: '后台任务',
|
|
113
|
+
hint: '允许长任务用 `runInBackground` 后台执行,再由内置 job 工具读回结果',
|
|
114
|
+
when: true,
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
path: ['translatePaths'],
|
|
118
|
+
label: '自动转换路径',
|
|
119
|
+
hint: '命令里的 Windows 路径自动转成 `/mnt/...`(每次调用仍可用 `translatePaths` 覆盖)',
|
|
120
|
+
when: true,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
path: ['startInSessionWorkspace'],
|
|
124
|
+
label: '默认跟随会话工作区',
|
|
125
|
+
hint: '未传 `workdir` 时从会话所在目录开始(关掉则从 Linux 家目录 `~` 开始)',
|
|
126
|
+
when: false,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
path: ['dangerGuard'],
|
|
130
|
+
label: '危险命令守卫',
|
|
131
|
+
hint: '删除、分区、关机等命令必须显式 `allowDangerous` 才放行。关掉后模型可直接执行这类命令,请谨慎',
|
|
132
|
+
when: true,
|
|
133
|
+
danger: true,
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The sidebar-terminal snippet, verbatim.
|
|
141
|
+
*
|
|
142
|
+
* It is a String.raw template AND its lines start at column 0 on purpose: it
|
|
143
|
+
* must come out byte-for-byte as the YAML a user pastes into a profile patch,
|
|
144
|
+
* backslashes in the Windows path included. Re-indenting it or writing it with
|
|
145
|
+
* ordinary escapes would quietly corrupt the path.
|
|
146
|
+
*/
|
|
147
|
+
const TERMINAL_PATCH_YAML = String.raw`- id: terminal-controller
|
|
148
|
+
config:
|
|
149
|
+
shell:
|
|
150
|
+
path: 'C:\Windows\System32\wsl.exe'
|
|
151
|
+
name: WSL
|
|
152
|
+
args: ['-e', 'bash', '-l']`
|
|
153
|
+
|
|
154
|
+
/** A setting nobody can read yet; shaped like a scope snapshot so the panel
|
|
155
|
+
* can treat it uniformly. `status: 'unavailable'` is the platform's own word
|
|
156
|
+
* for "this Host does not serve that configuration entry". */
|
|
157
|
+
const UNAVAILABLE_SNAPSHOT = {
|
|
158
|
+
status: 'unavailable',
|
|
159
|
+
value: undefined,
|
|
160
|
+
base: undefined,
|
|
161
|
+
user: undefined,
|
|
162
|
+
writable: false,
|
|
163
|
+
revision: undefined,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------- helpers
|
|
167
|
+
|
|
168
|
+
/** Read one path out of a possibly absent object, never throwing. */
|
|
169
|
+
function readPath(target, path) {
|
|
170
|
+
let cursor = target
|
|
171
|
+
for (const step of path) {
|
|
172
|
+
if (cursor === null || typeof cursor !== 'object') return undefined
|
|
173
|
+
cursor = cursor[step]
|
|
174
|
+
}
|
|
175
|
+
return cursor
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Whether the USER layer itself carries this path — i.e. whether the value is
|
|
180
|
+
* an override rather than inherited from the composition or the schema
|
|
181
|
+
* default. Presence is what marks an override, not a value comparison: an
|
|
182
|
+
* override that happens to equal the default is still an override.
|
|
183
|
+
*/
|
|
184
|
+
function hasPath(target, path) {
|
|
185
|
+
let cursor = target
|
|
186
|
+
for (const step of path) {
|
|
187
|
+
if (cursor === null || typeof cursor !== 'object') return false
|
|
188
|
+
if (!Object.prototype.hasOwnProperty.call(cursor, step)) return false
|
|
189
|
+
cursor = cursor[step]
|
|
190
|
+
}
|
|
191
|
+
return true
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Everything this panel knows about the Host configuration.
|
|
196
|
+
*
|
|
197
|
+
* Both the service and the row are optional, so every read is tolerant:
|
|
198
|
+
* a missing `configForms`, a row this Host does not serve, and a scope whose
|
|
199
|
+
* `getSnapshot` throws all end at `unavailable` instead of an exception in
|
|
200
|
+
* the middle of a render.
|
|
201
|
+
*/
|
|
202
|
+
function createConfigModel() {
|
|
203
|
+
let served = false
|
|
204
|
+
let scope
|
|
205
|
+
let unsubscribe
|
|
206
|
+
const listeners = new Set()
|
|
207
|
+
|
|
208
|
+
const publish = () => {
|
|
209
|
+
// Copy first: a listener may unsubscribe itself while being notified.
|
|
210
|
+
for (const listener of Array.from(listeners)) {
|
|
211
|
+
try {
|
|
212
|
+
listener()
|
|
213
|
+
} catch (error) {
|
|
214
|
+
console.error('dsh-wsl-tool: a settings panel listener failed', error)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const read = () => {
|
|
220
|
+
if (scope === undefined || scope === null || typeof scope.getSnapshot !== 'function') {
|
|
221
|
+
return UNAVAILABLE_SNAPSHOT
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
const snapshot = scope.getSnapshot()
|
|
225
|
+
return snapshot !== null && typeof snapshot === 'object' ? snapshot : UNAVAILABLE_SNAPSHOT
|
|
226
|
+
} catch (error) {
|
|
227
|
+
console.error('dsh-wsl-tool: reading the configuration scope failed', error)
|
|
228
|
+
return UNAVAILABLE_SNAPSHOT
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
/** Whether the `configForms` service was there at all. A service this DSH
|
|
234
|
+
* does not ship reads differently from a row it does not serve. */
|
|
235
|
+
hasService: () => served,
|
|
236
|
+
/** Bind the scope once the service appears. */
|
|
237
|
+
attach: (next) => {
|
|
238
|
+
if (next === undefined || next === null) return
|
|
239
|
+
served = true
|
|
240
|
+
scope = next
|
|
241
|
+
if (typeof scope.subscribe === 'function') {
|
|
242
|
+
try {
|
|
243
|
+
unsubscribe = scope.subscribe(publish)
|
|
244
|
+
} catch (error) {
|
|
245
|
+
console.error('dsh-wsl-tool: subscribing to the configuration scope failed', error)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
publish()
|
|
249
|
+
},
|
|
250
|
+
/** Release the scope subscription; the caller's effect owns the call. */
|
|
251
|
+
detach: () => {
|
|
252
|
+
if (typeof unsubscribe === 'function') {
|
|
253
|
+
try {
|
|
254
|
+
unsubscribe()
|
|
255
|
+
} catch (error) {
|
|
256
|
+
console.error('dsh-wsl-tool: releasing the configuration scope failed', error)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
unsubscribe = undefined
|
|
260
|
+
},
|
|
261
|
+
read,
|
|
262
|
+
subscribe: (listener) => {
|
|
263
|
+
listeners.add(listener)
|
|
264
|
+
return () => {
|
|
265
|
+
listeners.delete(listener)
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
/**
|
|
269
|
+
* Submit exactly one path-addressed write against the revision read just
|
|
270
|
+
* now, and answer whether the Host accepted it.
|
|
271
|
+
*
|
|
272
|
+
* The revision matters: the settings service refuses a write that was
|
|
273
|
+
* composed against a value somebody else has already replaced, rather than
|
|
274
|
+
* silently overwriting it. Its own settings form treats any falsy answer as
|
|
275
|
+
* a refusal, so this panel does the same — `false` means "not saved", and
|
|
276
|
+
* the panel words it instead of pretending the switch moved.
|
|
277
|
+
*/
|
|
278
|
+
write: async (op) => {
|
|
279
|
+
const snapshot = read()
|
|
280
|
+
if (scope === undefined || typeof scope.mutate !== 'function') return false
|
|
281
|
+
if (snapshot.writable !== true) return false
|
|
282
|
+
try {
|
|
283
|
+
const landed = await scope.mutate([op], snapshot.revision)
|
|
284
|
+
return Boolean(landed)
|
|
285
|
+
} catch (error) {
|
|
286
|
+
console.error('dsh-wsl-tool: writing the configuration failed', error)
|
|
287
|
+
return false
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Stand-in model for a seat that somehow renders without injected props, so
|
|
294
|
+
* a missing prop shows the "not served" note instead of throwing. */
|
|
295
|
+
const DETACHED_MODEL = createConfigModel()
|
|
296
|
+
|
|
297
|
+
// ----------------------------------------------------------------- styles
|
|
298
|
+
|
|
299
|
+
const PANEL_STYLE = {
|
|
300
|
+
boxSizing: 'border-box',
|
|
301
|
+
display: 'flex',
|
|
302
|
+
flexDirection: 'column',
|
|
303
|
+
gap: '24px',
|
|
304
|
+
height: '100%',
|
|
305
|
+
maxWidth: '760px',
|
|
306
|
+
overflowY: 'auto',
|
|
307
|
+
padding: '24px',
|
|
308
|
+
color: LABEL_PRIMARY,
|
|
309
|
+
fontFamily: 'inherit',
|
|
310
|
+
fontSize: '13px',
|
|
311
|
+
lineHeight: '20px',
|
|
312
|
+
}
|
|
313
|
+
const HEADER_STYLE = { display: 'flex', flexDirection: 'column', gap: '4px' }
|
|
314
|
+
const TITLE_STYLE = { margin: 0, fontSize: '18px', fontWeight: 600, lineHeight: '26px' }
|
|
315
|
+
const DESCRIPTION_STYLE = { margin: 0, color: LABEL_SECONDARY }
|
|
316
|
+
const NOTE_STYLE = { margin: 0, color: LABEL_TERTIARY, fontSize: '12px', lineHeight: '18px' }
|
|
317
|
+
const STACK_STYLE = { display: 'flex', flexDirection: 'column', gap: '24px' }
|
|
318
|
+
const SECTION_STYLE = { display: 'flex', flexDirection: 'column', gap: '4px' }
|
|
319
|
+
const SECTION_TITLE_STYLE = { margin: 0, fontSize: '14px', fontWeight: 500, lineHeight: '20px' }
|
|
320
|
+
const ROWS_STYLE = { display: 'flex', flexDirection: 'column' }
|
|
321
|
+
const ROW_STYLE = {
|
|
322
|
+
display: 'flex',
|
|
323
|
+
alignItems: 'flex-start',
|
|
324
|
+
justifyContent: 'space-between',
|
|
325
|
+
gap: '16px',
|
|
326
|
+
padding: '14px 0',
|
|
327
|
+
borderBottom: '0.5px solid ' + BORDER_SOFT,
|
|
328
|
+
}
|
|
329
|
+
const ROW_COPY_STYLE = { display: 'flex', flexDirection: 'column', gap: '2px', minWidth: 0 }
|
|
330
|
+
const ROW_LABEL_STYLE = { display: 'flex', alignItems: 'center', gap: '6px', fontWeight: 500 }
|
|
331
|
+
const ROW_ACTIONS_STYLE = { display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0 }
|
|
332
|
+
const HINT_STYLE = { margin: 0, color: LABEL_TERTIARY, fontSize: '12px', lineHeight: '18px', maxWidth: '66ch' }
|
|
333
|
+
const BADGE_STYLE = {
|
|
334
|
+
border: '0.5px solid ' + BORDER_BADGE,
|
|
335
|
+
borderRadius: RADIUS,
|
|
336
|
+
color: LABEL_SECONDARY,
|
|
337
|
+
fontSize: '11px',
|
|
338
|
+
lineHeight: '16px',
|
|
339
|
+
padding: '1px 6px',
|
|
340
|
+
whiteSpace: 'nowrap',
|
|
341
|
+
}
|
|
342
|
+
const DANGER_STYLE = {
|
|
343
|
+
border: '0.5px solid currentColor',
|
|
344
|
+
borderRadius: RADIUS,
|
|
345
|
+
color: WARN_LABEL,
|
|
346
|
+
fontSize: '11px',
|
|
347
|
+
lineHeight: '16px',
|
|
348
|
+
padding: '0 4px',
|
|
349
|
+
whiteSpace: 'nowrap',
|
|
350
|
+
}
|
|
351
|
+
const RESET_STYLE = {
|
|
352
|
+
background: 'none',
|
|
353
|
+
border: 'none',
|
|
354
|
+
color: LINK_LABEL,
|
|
355
|
+
cursor: 'pointer',
|
|
356
|
+
fontFamily: 'inherit',
|
|
357
|
+
fontSize: '12px',
|
|
358
|
+
fontWeight: 'inherit',
|
|
359
|
+
padding: 0,
|
|
360
|
+
textDecoration: 'underline',
|
|
361
|
+
}
|
|
362
|
+
const DEFINITION_ROW_STYLE = { display: 'flex', alignItems: 'baseline', gap: '12px' }
|
|
363
|
+
const DEFINITION_LABEL_STYLE = { color: LABEL_SECONDARY, minWidth: '13em' }
|
|
364
|
+
const DEFINITION_VALUE_STYLE = { color: LABEL_PRIMARY }
|
|
365
|
+
const PRE_STYLE = {
|
|
366
|
+
margin: 0,
|
|
367
|
+
padding: '12px',
|
|
368
|
+
background: CODE_FILL,
|
|
369
|
+
borderRadius: RADIUS,
|
|
370
|
+
color: CODE_LABEL,
|
|
371
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
|
372
|
+
fontSize: '12px',
|
|
373
|
+
lineHeight: '18px',
|
|
374
|
+
overflowX: 'auto',
|
|
375
|
+
whiteSpace: 'pre',
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ------------------------------------------------------------ components
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The rail icon. A `sidebar.panellist` entry draws its own glyph: an 18×18
|
|
382
|
+
* terminal prompt in `currentColor`, so the layout's selected/hover colours
|
|
383
|
+
* come through untouched.
|
|
384
|
+
*/
|
|
385
|
+
function WslIcon() {
|
|
386
|
+
return React.createElement(
|
|
387
|
+
'svg',
|
|
388
|
+
{
|
|
389
|
+
width: 18,
|
|
390
|
+
height: 18,
|
|
391
|
+
viewBox: '0 0 20 20',
|
|
392
|
+
fill: 'none',
|
|
393
|
+
stroke: 'currentColor',
|
|
394
|
+
strokeWidth: 1.5,
|
|
395
|
+
strokeLinecap: 'round',
|
|
396
|
+
strokeLinejoin: 'round',
|
|
397
|
+
'aria-hidden': 'true',
|
|
398
|
+
focusable: 'false',
|
|
399
|
+
},
|
|
400
|
+
// The terminal frame, the prompt chevron, and the command line.
|
|
401
|
+
React.createElement('path', {
|
|
402
|
+
d: 'M3.5 4.5h13a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1Z',
|
|
403
|
+
}),
|
|
404
|
+
React.createElement('path', { d: 'M6.5 8.5 8.5 10.5 6.5 12.5' }),
|
|
405
|
+
React.createElement('path', { d: 'M10.5 13h3' }),
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** One read-only label/value line, used by the effective-defaults section. */
|
|
410
|
+
function definitionRow(label, value) {
|
|
411
|
+
return React.createElement(
|
|
412
|
+
'div',
|
|
413
|
+
{ style: DEFINITION_ROW_STYLE },
|
|
414
|
+
React.createElement('span', { style: DEFINITION_LABEL_STYLE }, label),
|
|
415
|
+
React.createElement('span', { style: DEFINITION_VALUE_STYLE }, value),
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** A section frame: a heading plus its body, which may be several nodes. */
|
|
420
|
+
function section(key, title, ...children) {
|
|
421
|
+
return React.createElement(
|
|
422
|
+
'section',
|
|
423
|
+
{ key, style: SECTION_STYLE },
|
|
424
|
+
React.createElement('h3', { style: SECTION_TITLE_STYLE }, title),
|
|
425
|
+
...children,
|
|
426
|
+
)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The panel behind the rail entry.
|
|
431
|
+
*
|
|
432
|
+
* It renders whatever the scope currently says — `ready`, still `loading`, or
|
|
433
|
+
* `unavailable` — because a settings panel that throws or renders nothing on a
|
|
434
|
+
* stripped composition is worse than one that explains itself. Reading goes
|
|
435
|
+
* through the injected model so a service that appears later needs no remount.
|
|
436
|
+
*/
|
|
437
|
+
function WslPanel(props) {
|
|
438
|
+
const model = props !== null && props !== undefined && props.model !== undefined ? props.model : DETACHED_MODEL
|
|
439
|
+
const [snapshot, setSnapshot] = React.useState(model.read())
|
|
440
|
+
// `busy` is the path key of the write in flight; `refused` remembers that the
|
|
441
|
+
// Host did not accept the last one, so the panel can say so.
|
|
442
|
+
const [busy, setBusy] = React.useState(undefined)
|
|
443
|
+
const [refused, setRefused] = React.useState(false)
|
|
444
|
+
|
|
445
|
+
React.useEffect(
|
|
446
|
+
() =>
|
|
447
|
+
model.subscribe(() => {
|
|
448
|
+
setSnapshot(model.read())
|
|
449
|
+
}),
|
|
450
|
+
[model],
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Send one write and reflect its outcome. The promise is returned so a
|
|
455
|
+
* caller (or a test) can await the settled state; React ignores the return
|
|
456
|
+
* value of an event handler, and `model.write` never rejects.
|
|
457
|
+
*/
|
|
458
|
+
const submit = (op, key) => {
|
|
459
|
+
setBusy(key)
|
|
460
|
+
setRefused(false)
|
|
461
|
+
return model.write(op).then((landed) => {
|
|
462
|
+
setBusy(undefined)
|
|
463
|
+
setRefused(!landed)
|
|
464
|
+
setSnapshot(model.read())
|
|
465
|
+
return landed
|
|
466
|
+
})
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** One switch row: switch, label and hint on the left, override controls and
|
|
470
|
+
* the switch itself on the right. */
|
|
471
|
+
const rowElement = (row) => {
|
|
472
|
+
const key = row.path.join('.')
|
|
473
|
+
const overridden = hasPath(snapshot.user, row.path)
|
|
474
|
+
const disabled = snapshot.writable !== true || busy !== undefined
|
|
475
|
+
const effective = readPath(snapshot.value, row.path)
|
|
476
|
+
const checked = effective === undefined ? row.when : effective === true
|
|
477
|
+
return React.createElement(
|
|
478
|
+
'div',
|
|
479
|
+
{ key, style: ROW_STYLE },
|
|
480
|
+
React.createElement(
|
|
481
|
+
'div',
|
|
482
|
+
{ style: ROW_COPY_STYLE },
|
|
483
|
+
React.createElement(
|
|
484
|
+
'div',
|
|
485
|
+
{ style: ROW_LABEL_STYLE },
|
|
486
|
+
row.label,
|
|
487
|
+
row.danger === true ? React.createElement('span', { style: DANGER_STYLE }, '有风险') : null,
|
|
488
|
+
),
|
|
489
|
+
React.createElement('p', { style: HINT_STYLE }, row.hint),
|
|
490
|
+
),
|
|
491
|
+
React.createElement(
|
|
492
|
+
'div',
|
|
493
|
+
{ style: ROW_ACTIONS_STYLE },
|
|
494
|
+
overridden ? React.createElement('span', { style: BADGE_STYLE }, '已覆盖') : null,
|
|
495
|
+
overridden
|
|
496
|
+
? React.createElement(
|
|
497
|
+
'button',
|
|
498
|
+
{
|
|
499
|
+
type: 'button',
|
|
500
|
+
style: RESET_STYLE,
|
|
501
|
+
disabled,
|
|
502
|
+
// Returned as well as performed: React ignores a handler's
|
|
503
|
+
// return value, but awaiting it is how a test sees the write
|
|
504
|
+
// settle.
|
|
505
|
+
onClick: () => submit({ op: 'unset', path: row.path }, key),
|
|
506
|
+
},
|
|
507
|
+
'恢复默认',
|
|
508
|
+
)
|
|
509
|
+
: null,
|
|
510
|
+
React.createElement(primitives.Switch, {
|
|
511
|
+
checked,
|
|
512
|
+
label: row.label,
|
|
513
|
+
disabled,
|
|
514
|
+
...(row.danger === true
|
|
515
|
+
? { title: '有风险:关掉后模型可以直接执行删除、分区、关机等命令' }
|
|
516
|
+
: {}),
|
|
517
|
+
onChange: (next) => submit({ op: 'set', path: row.path, value: next === true }, key),
|
|
518
|
+
}),
|
|
519
|
+
),
|
|
520
|
+
)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const children = []
|
|
524
|
+
|
|
525
|
+
children.push(
|
|
526
|
+
React.createElement(
|
|
527
|
+
'header',
|
|
528
|
+
{ key: 'header', style: HEADER_STYLE },
|
|
529
|
+
React.createElement('h2', { style: TITLE_STYLE }, 'WSL'),
|
|
530
|
+
React.createElement('p', { style: DESCRIPTION_STYLE }, '通过 WSL 在 Windows 上执行 Linux 命令的开关与说明'),
|
|
531
|
+
React.createElement('p', { style: NOTE_STYLE }, '改动会在下次启动 DSH 后生效'),
|
|
532
|
+
),
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
// The switches — or the one reason they cannot be shown right now.
|
|
536
|
+
if (model.hasService() !== true) {
|
|
537
|
+
children.push(
|
|
538
|
+
React.createElement(
|
|
539
|
+
'p',
|
|
540
|
+
{ key: 'note-service', style: NOTE_STYLE },
|
|
541
|
+
'此 DSH 未提供配置表单服务(configForms),无法在这里调整设置。',
|
|
542
|
+
),
|
|
543
|
+
)
|
|
544
|
+
} else if (snapshot.status === 'loading') {
|
|
545
|
+
children.push(React.createElement('p', { key: 'note-loading', style: NOTE_STYLE }, '正在读取配置…'))
|
|
546
|
+
} else if (snapshot.status !== 'ready') {
|
|
547
|
+
children.push(
|
|
548
|
+
React.createElement('p', { key: 'note-unavailable', style: NOTE_STYLE }, '此 DSH 未提供该插件的配置作用域'),
|
|
549
|
+
)
|
|
550
|
+
} else {
|
|
551
|
+
const blocks = SECTIONS.map((group) =>
|
|
552
|
+
section(group.id, group.title, React.createElement('div', { style: ROWS_STYLE }, group.rows.map(rowElement))),
|
|
553
|
+
)
|
|
554
|
+
if (refused) {
|
|
555
|
+
blocks.unshift(
|
|
556
|
+
React.createElement('p', { key: 'note-refused', style: NOTE_STYLE }, '保存被拒绝:配置没有被写入,请重试'),
|
|
557
|
+
)
|
|
558
|
+
}
|
|
559
|
+
children.push(React.createElement('div', { key: 'switches', style: STACK_STYLE }, blocks))
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// The effective values of the two knobs that have no switch, because they
|
|
563
|
+
// are a distro name and a duration rather than a boolean.
|
|
564
|
+
if (snapshot.status === 'ready') {
|
|
565
|
+
const distro = readPath(snapshot.value, ['distro'])
|
|
566
|
+
const timeoutMs = readPath(snapshot.value, ['timeoutMs'])
|
|
567
|
+
children.push(
|
|
568
|
+
section(
|
|
569
|
+
'defaults',
|
|
570
|
+
'当前默认值',
|
|
571
|
+
React.createElement(
|
|
572
|
+
'div',
|
|
573
|
+
null,
|
|
574
|
+
definitionRow(
|
|
575
|
+
'发行版 distro',
|
|
576
|
+
typeof distro === 'string' && distro.trim() !== '' ? distro : '系统默认发行版',
|
|
577
|
+
),
|
|
578
|
+
definitionRow(
|
|
579
|
+
'命令超时 timeoutMs',
|
|
580
|
+
typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) ? timeoutMs + ' 毫秒' : '—',
|
|
581
|
+
),
|
|
582
|
+
),
|
|
583
|
+
React.createElement(
|
|
584
|
+
'p',
|
|
585
|
+
{ style: HINT_STYLE },
|
|
586
|
+
'这两个值来自插件配置,可以在 profile 的 cordis.patch.yml 里写,也可以用环境变量 DSH_WSL_DISTRO / DSH_WSL_TIMEOUT_MS 覆盖。',
|
|
587
|
+
),
|
|
588
|
+
),
|
|
589
|
+
)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Opt-in, read-only: the snippet goes ABOVE its explanation, which refers to
|
|
593
|
+
// it as 「上面这段」.
|
|
594
|
+
children.push(
|
|
595
|
+
section(
|
|
596
|
+
'terminal',
|
|
597
|
+
'侧边栏 WSL 终端(可选)',
|
|
598
|
+
React.createElement(
|
|
599
|
+
'p',
|
|
600
|
+
{ style: HINT_STYLE },
|
|
601
|
+
'插件还可以把桌面端的侧边栏终端指向 WSL;这是可选项,默认不开。',
|
|
602
|
+
),
|
|
603
|
+
React.createElement('pre', { style: PRE_STYLE }, TERMINAL_PATCH_YAML),
|
|
604
|
+
React.createElement(
|
|
605
|
+
'p',
|
|
606
|
+
{ style: HINT_STYLE },
|
|
607
|
+
'把上面这段加进 `$DSH_HOME/profiles/<profile>/cordis.patch.yml` 后重启 DSH,新建终端里就会出现 WSL(详见插件 README)',
|
|
608
|
+
),
|
|
609
|
+
),
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
return React.createElement('div', { style: PANEL_STYLE }, children)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ------------------------------------------------------------------ apply
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Mount the rail entry and the panel.
|
|
619
|
+
*
|
|
620
|
+
* This function must never throw: a client half that fails to apply takes its
|
|
621
|
+
* whole bundle's surface down with it, and the WSL tools work fine without any
|
|
622
|
+
* of this. So a missing `slots` service, a missing `configForms`, an unserved
|
|
623
|
+
* configuration row and a scope that refuses to be read all degrade into a note
|
|
624
|
+
* in the panel instead of an exception.
|
|
625
|
+
*/
|
|
626
|
+
function apply(ctx) {
|
|
627
|
+
const model = createConfigModel()
|
|
628
|
+
|
|
629
|
+
// `configForms` is reached through `ctx.inject`, never through this module's
|
|
630
|
+
// own `inject` array: the panel is a convenience, not a reason to refuse to
|
|
631
|
+
// mount on a composition that does not ship the settings service.
|
|
632
|
+
try {
|
|
633
|
+
if (typeof ctx.inject === 'function') {
|
|
634
|
+
ctx.inject(['configForms'], (inner) => {
|
|
635
|
+
let scope
|
|
636
|
+
try {
|
|
637
|
+
scope = inner.configForms.get(CONFIG_ROW_ID)
|
|
638
|
+
} catch (error) {
|
|
639
|
+
console.error('dsh-wsl-tool: this DSH serves no "' + CONFIG_ROW_ID + '" configuration row', error)
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
model.attach(scope)
|
|
643
|
+
// The subscription lives exactly as long as this fiber: `effect` runs
|
|
644
|
+
// the returned disposer on unload or recomposition.
|
|
645
|
+
if (typeof inner.effect === 'function') {
|
|
646
|
+
inner.effect(() => () => model.detach(), 'dsh-wsl-tool: configuration scope')
|
|
647
|
+
}
|
|
648
|
+
})
|
|
649
|
+
}
|
|
650
|
+
} catch (error) {
|
|
651
|
+
console.error('dsh-wsl-tool: asking for the configuration service failed', error)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
try {
|
|
655
|
+
if (ctx.slots === undefined || ctx.slots === null) {
|
|
656
|
+
console.error('dsh-wsl-tool: this DSH has no slots service; the WSL panel stays unmounted')
|
|
657
|
+
return
|
|
658
|
+
}
|
|
659
|
+
// Both seats are registered from the innermost callback, exactly as the
|
|
660
|
+
// shipped panels do: the rail entry and the panel it opens appear together
|
|
661
|
+
// or not at all. The callback's return value is the disposer chain.
|
|
662
|
+
ctx.slots.inject('main', () =>
|
|
663
|
+
ctx.slots.inject('sidebar.panellist', () => {
|
|
664
|
+
const stopMain = ctx.slots.register(
|
|
665
|
+
{ name: 'main', key: PANEL_ID, inject: () => ({ model }) },
|
|
666
|
+
WslPanel,
|
|
667
|
+
)
|
|
668
|
+
const stopIcon = ctx.slots.register(
|
|
669
|
+
{ name: 'sidebar.panellist', id: PANEL_ID, order: 50, label: () => 'WSL' },
|
|
670
|
+
WslIcon,
|
|
671
|
+
)
|
|
672
|
+
return () => {
|
|
673
|
+
if (typeof stopIcon === 'function') stopIcon()
|
|
674
|
+
if (typeof stopMain === 'function') stopMain()
|
|
675
|
+
}
|
|
676
|
+
}),
|
|
677
|
+
)
|
|
678
|
+
} catch (error) {
|
|
679
|
+
console.error('dsh-wsl-tool: registering the WSL panel failed', error)
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// `slots` is the one service this module needs before it can do anything.
|
|
684
|
+
// `configForms` is optional and is asked for above, at runtime.
|
|
685
|
+
const inject = ['slots']
|
|
686
|
+
|
|
687
|
+
exports.apply = apply
|
|
688
|
+
exports.inject = inject
|
|
689
|
+
return module.exports
|
|
690
|
+
},
|
|
691
|
+
})
|
package/lib/config.js
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
// Resolved settings for one mounted plugin instance.
|
|
2
2
|
//
|
|
3
|
-
// Everything tunable lives here
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Everything tunable lives here, and it is resolved from three layers, most
|
|
4
|
+
// specific first:
|
|
5
|
+
//
|
|
6
|
+
// 1. the plugin's own configuration — the switches in the left-sidebar panel
|
|
7
|
+
// (a host `Config` schema; see index.js), stored in the profile patch;
|
|
8
|
+
// 2. the environment, for a deployment that tunes a headless install with no
|
|
9
|
+
// UI at all;
|
|
10
|
+
// 3. the built-in defaults below.
|
|
11
|
+
//
|
|
12
|
+
// A layer only speaks when it actually says something: an unset switch, an empty
|
|
13
|
+
// distro or a zero timeout falls THROUGH to the layer beneath instead of pinning
|
|
14
|
+
// the default over it. That is what keeps `DSH_WSL_WORKDIR=session` working for a
|
|
15
|
+
// user who never opened the panel. An unparsable or out-of-range value falls back
|
|
16
|
+
// rather than failing the mount, because one bad environment variable must not
|
|
17
|
+
// take all three tools down.
|
|
7
18
|
|
|
8
19
|
import { windowsPathToWsl } from './paths.js'
|
|
9
20
|
|
|
@@ -28,6 +39,16 @@ export const DEFAULTS = {
|
|
|
28
39
|
maxCommandChars: 30_000,
|
|
29
40
|
/** `setTimeout` stores its delay in a signed 32-bit int; larger fires at once. */
|
|
30
41
|
maxTimerDelayMs: 2 ** 31 - 1,
|
|
42
|
+
/** Which tools this plugin registers at all. */
|
|
43
|
+
tools: { wsl: true, path: true, env: true },
|
|
44
|
+
/** Whether `wsl` accepts `runInBackground` (the built-in job tools read it back). */
|
|
45
|
+
backgroundJobs: true,
|
|
46
|
+
/** Whether a Windows path in `command` is rewritten to /mnt/... by default. */
|
|
47
|
+
translatePaths: true,
|
|
48
|
+
/** Whether the destructive-command guard may be bypassed only by the caller's
|
|
49
|
+
* explicit `allowDangerous`. Never set this from the environment: it is the
|
|
50
|
+
* one switch whose off position is a footgun, and the panel marks it as such. */
|
|
51
|
+
dangerGuard: true,
|
|
31
52
|
}
|
|
32
53
|
|
|
33
54
|
/**
|
|
@@ -69,8 +90,18 @@ function envInt(env, name, fallback, min, max) {
|
|
|
69
90
|
return value >= min && value <= max ? value : fallback
|
|
70
91
|
}
|
|
71
92
|
|
|
72
|
-
/**
|
|
73
|
-
function
|
|
93
|
+
/** A configured boolean, or the fallback when the layer did not set one. */
|
|
94
|
+
function setting(value, fallback) {
|
|
95
|
+
return typeof value === 'boolean' ? value : fallback
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param env - the environment layer.
|
|
100
|
+
* @param configured - the plugin's own configuration layer (may be undefined).
|
|
101
|
+
* @returns a distribution name to pin, or null to use the system default.
|
|
102
|
+
*/
|
|
103
|
+
function resolveDistro(env, configured) {
|
|
104
|
+
if (typeof configured === 'string' && configured.trim() !== '') return configured.trim()
|
|
74
105
|
const raw = env.DSH_WSL_DISTRO
|
|
75
106
|
return typeof raw === 'string' && raw.trim() !== '' ? raw.trim() : null
|
|
76
107
|
}
|
|
@@ -78,15 +109,19 @@ function envDistro(env) {
|
|
|
78
109
|
/**
|
|
79
110
|
* Where a call starts when the caller passes no `workdir`.
|
|
80
111
|
*
|
|
81
|
-
* `home`
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
112
|
+
* `home` keeps the documented `~`. `session` starts in the session's working
|
|
113
|
+
* directory — the plugin's own process cwd, the same source `dsh-pwsh-local` uses
|
|
114
|
+
* by default — which is what an agent working on a Windows checkout usually
|
|
115
|
+
* wants, since its files live at `/mnt/<drive>/...` rather than in the Linux home.
|
|
116
|
+
* Anything else is an explicit default path.
|
|
86
117
|
*
|
|
87
118
|
* @returns a path, or null meaning "the process working directory".
|
|
88
119
|
*/
|
|
89
|
-
function
|
|
120
|
+
function resolveWorkdir(env, settings) {
|
|
121
|
+
// Only the ON position is authoritative: off means "whatever the layers below
|
|
122
|
+
// say", so `DSH_WSL_WORKDIR=session` keeps working for a user who never opened
|
|
123
|
+
// the panel, and a fresh install still starts in `~`.
|
|
124
|
+
if (settings.startInSessionWorkspace === true) return null
|
|
90
125
|
const raw = env.DSH_WSL_WORKDIR
|
|
91
126
|
if (typeof raw !== 'string' || raw.trim() === '') return DEFAULTS.workdir
|
|
92
127
|
const value = raw.trim()
|
|
@@ -103,27 +138,45 @@ function envWorkdir(env) {
|
|
|
103
138
|
* stream and would be discarded exactly when it is most useful.
|
|
104
139
|
*
|
|
105
140
|
* @param env - environment to read (injectable for tests).
|
|
141
|
+
* @param settings - the plugin's own configuration, as the composition resolved
|
|
142
|
+
* it against the Config schema (injectable for tests; may be undefined).
|
|
106
143
|
*/
|
|
107
|
-
export function resolveConfig(env = process.env) {
|
|
144
|
+
export function resolveConfig(env = process.env, settings = {}) {
|
|
108
145
|
const maxOutputBytes = envInt(
|
|
109
146
|
env, 'DSH_WSL_MAX_OUTPUT_BYTES', DEFAULTS.maxOutputBytes, OUTPUT_BYTES_MIN, OUTPUT_BYTES_MAX,
|
|
110
147
|
)
|
|
111
148
|
const maxCommandTimeoutMs = envInt(
|
|
112
149
|
env, 'DSH_WSL_MAX_TIMEOUT_MS', DEFAULTS.maxCommandTimeoutMs, TIMEOUT_MS_MIN, DEFAULTS.maxTimerDelayMs,
|
|
113
150
|
)
|
|
151
|
+
const tools = settings.tools ?? {}
|
|
152
|
+
// 0 means "not configured", so the environment and the built-in default still
|
|
153
|
+
// decide; a positive value is the user's own deadline.
|
|
154
|
+
const configuredTimeoutMs = Number.isInteger(settings.timeoutMs) && settings.timeoutMs > 0
|
|
155
|
+
? settings.timeoutMs
|
|
156
|
+
: undefined
|
|
114
157
|
return {
|
|
115
158
|
...DEFAULTS,
|
|
159
|
+
tools: {
|
|
160
|
+
wsl: setting(tools.wsl, DEFAULTS.tools.wsl),
|
|
161
|
+
path: setting(tools.path, DEFAULTS.tools.path),
|
|
162
|
+
env: setting(tools.env, DEFAULTS.tools.env),
|
|
163
|
+
},
|
|
164
|
+
backgroundJobs: setting(settings.backgroundJobs, DEFAULTS.backgroundJobs),
|
|
165
|
+
translatePaths: setting(settings.translatePaths, DEFAULTS.translatePaths),
|
|
166
|
+
dangerGuard: setting(settings.dangerGuard, DEFAULTS.dangerGuard),
|
|
167
|
+
startInSessionWorkspace: settings.startInSessionWorkspace === true,
|
|
116
168
|
// null means "whatever wsl.exe uses by default", which is what makes the
|
|
117
169
|
// package portable: `Ubuntu-22.04` exists on the author's machine, not
|
|
118
170
|
// necessarily on a storefront user's.
|
|
119
|
-
distro:
|
|
120
|
-
defaultWorkdir:
|
|
171
|
+
distro: resolveDistro(env, settings.distro),
|
|
172
|
+
defaultWorkdir: resolveWorkdir(env, settings),
|
|
121
173
|
maxOutputBytes,
|
|
122
174
|
maxSpillBytes: Math.max(DEFAULTS.maxSpillBytes, maxOutputBytes),
|
|
123
175
|
maxCommandTimeoutMs,
|
|
124
176
|
// The default deadline obeys the cap too, so the two knobs cannot disagree.
|
|
125
177
|
commandTimeoutMs: Math.min(
|
|
126
|
-
|
|
178
|
+
configuredTimeoutMs
|
|
179
|
+
?? envInt(env, 'DSH_WSL_TIMEOUT_MS', DEFAULTS.commandTimeoutMs, TIMEOUT_MS_MIN, DEFAULTS.maxTimerDelayMs),
|
|
127
180
|
maxCommandTimeoutMs,
|
|
128
181
|
),
|
|
129
182
|
}
|
package/lib/schema.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The schema builder behind `Config`, resolved through one interop hop.
|
|
2
|
+
//
|
|
3
|
+
// `@deepseek-ai/schemastery` exports the builder as its DEFAULT export: the module
|
|
4
|
+
// namespace of `import('@deepseek-ai/schemastery')` is `{ default: Schema }`, with
|
|
5
|
+
// no named `Schema`. Code that destructures `{ Schema }` from it gets `undefined`
|
|
6
|
+
// without any error, and `Schema?.object({...})` then yields `undefined` — so the
|
|
7
|
+
// plugin declares no `Config`, the platform has no schema to project, its settings
|
|
8
|
+
// namespace never appears, and the sidebar panel renders without switches. That is
|
|
9
|
+
// a quiet failure two layers away from its cause (measured: the Loader entry's
|
|
10
|
+
// config status stayed `absent` while the tools themselves worked), so the shape is
|
|
11
|
+
// pinned here, in one place, with a unit test.
|
|
12
|
+
//
|
|
13
|
+
// A named `Schema` export and a `Schema` property on the default are accepted too:
|
|
14
|
+
// other DSH packages re-export the builder in those shapes, and the check below is
|
|
15
|
+
// about what can actually build a schema, not about this one package.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Pick the schema builder out of a module namespace.
|
|
19
|
+
*
|
|
20
|
+
* @param namespace - a module namespace (`await import(...)`), or anything else.
|
|
21
|
+
* @returns the builder, or null when the namespace carries none.
|
|
22
|
+
*/
|
|
23
|
+
export function pickSchemaBuilder(namespace) {
|
|
24
|
+
if (namespace === null || typeof namespace !== 'object') return null
|
|
25
|
+
const candidates = [namespace.Schema, namespace.default?.Schema, namespace.default]
|
|
26
|
+
for (const candidate of candidates) {
|
|
27
|
+
// `object` is the entry point every Config needs; requiring it here is what
|
|
28
|
+
// makes a wrong export shape look like a missing builder instead of a schema
|
|
29
|
+
// that throws later, while the composition is already being built.
|
|
30
|
+
if (candidate !== null && candidate !== undefined && typeof candidate.object === 'function') {
|
|
31
|
+
return candidate
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null
|
|
35
|
+
}
|
package/lib/tools/wsl.js
CHANGED
|
@@ -195,7 +195,7 @@ export function createWslTool({ ctx, config, runner }) {
|
|
|
195
195
|
},
|
|
196
196
|
translatePaths: {
|
|
197
197
|
type: 'boolean',
|
|
198
|
-
description:
|
|
198
|
+
description: `Default ${config.translatePaths}: rewrite Windows paths in \`command\` to /mnt/... . Set false to pass \`command\` verbatim, e.g. a native path for a Windows program launched through interop. \`workdir\` is always translated.`,
|
|
199
199
|
},
|
|
200
200
|
},
|
|
201
201
|
required: ['command', 'description'],
|
|
@@ -263,9 +263,11 @@ export function createWslTool({ ctx, config, runner }) {
|
|
|
263
263
|
}
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
-
|
|
266
|
+
// The switch is the default, the parameter is the per-call override.
|
|
267
|
+
const translatePaths = args.translatePaths ?? config.translatePaths
|
|
268
|
+
const command = translatePaths === false ? args.command : windowsPathToWsl(args.command)
|
|
267
269
|
const reason = destructiveReason(command)
|
|
268
|
-
if (reason !== null && args.allowDangerous !== true) {
|
|
270
|
+
if (reason !== null && args.allowDangerous !== true && config.dangerGuard) {
|
|
269
271
|
throw new Error(
|
|
270
272
|
`wsl: refused a destructive command (${reason}). ` +
|
|
271
273
|
'If this is intended, re-issue it with `allowDangerous: true`.',
|
|
@@ -283,6 +285,16 @@ export function createWslTool({ ctx, config, runner }) {
|
|
|
283
285
|
}
|
|
284
286
|
|
|
285
287
|
if (args.runInBackground === true) {
|
|
288
|
+
// The switch is checked here rather than hidden from the schema: a model
|
|
289
|
+
// that was told about `runInBackground` (by a cached prompt, or a
|
|
290
|
+
// transcript resumed from before the switch moved) must be told why it is
|
|
291
|
+
// refused instead of silently running in the foreground.
|
|
292
|
+
if (!config.backgroundJobs) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
'wsl: background jobs are switched off in this plugin\'s settings ' +
|
|
295
|
+
'(left sidebar, WSL panel -> 后台任务). Run it in the foreground, or turn the switch back on.',
|
|
296
|
+
)
|
|
297
|
+
}
|
|
286
298
|
// A background job is meant to outlive the foreground deadline, so the
|
|
287
299
|
// configured default does NOT apply; an explicit timeoutMs still does.
|
|
288
300
|
return startInBackground(command, { ...opts, timeoutMs: args.timeoutMs }, exec)
|
package/package.json
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-wsl-tool",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Run Linux commands from Windows through WSL, essentially matching a native Linux DSH for command execution: background jobs, stdin, path translation, a destructive-command guard and a WSL capability report.
|
|
3
|
+
"version": "1.10.1",
|
|
4
|
+
"description": "Run Linux commands from Windows through WSL, essentially matching a native Linux DSH for command execution: background jobs, stdin, path translation, a destructive-command guard and a WSL capability report. A left-sidebar panel switches each capability on or off, each with a one-line explanation, and an opt-in patch gives the desktop app's sidebar terminal a WSL shell. WSL calls run below the DSH sandbox, and a project on a Windows drive keeps Windows filesystem semantics.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
15
|
+
},
|
|
16
|
+
"peerDependenciesMeta": {
|
|
17
|
+
"@deepseek-ai/schemastery": {
|
|
18
|
+
"optional": true
|
|
19
|
+
}
|
|
20
|
+
},
|
|
7
21
|
"license": "MIT",
|
|
8
22
|
"repository": {
|
|
9
23
|
"type": "git",
|
|
@@ -36,7 +50,7 @@
|
|
|
36
50
|
"LICENSE"
|
|
37
51
|
],
|
|
38
52
|
"scripts": {
|
|
39
|
-
"test": "node test/smoke.mjs",
|
|
53
|
+
"test": "node test/smoke.mjs && node test/client.mjs",
|
|
40
54
|
"test:real": "node test/smoke.mjs --real && node test/real-seam.mjs",
|
|
41
55
|
"sync": "node scripts/sync-profile.mjs"
|
|
42
56
|
},
|
|
@@ -47,6 +61,13 @@
|
|
|
47
61
|
"dsh": {
|
|
48
62
|
"bundle": {
|
|
49
63
|
"patch": "./cordis.patch.yml"
|
|
64
|
+
},
|
|
65
|
+
"client": {
|
|
66
|
+
"platform": "web",
|
|
67
|
+
"immediately": true,
|
|
68
|
+
"inject": [
|
|
69
|
+
"@deepseek-ai/dsh-client-ui-primitives"
|
|
70
|
+
]
|
|
50
71
|
}
|
|
51
72
|
}
|
|
52
73
|
}
|