dsh-win-multi-bash 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +7 -8
- package/README.md +41 -4
- package/README.zh.md +40 -3
- package/cordis.patch.yml +52 -10
- package/install.ps1 +179 -0
- package/lib/bash-git/index.js +28 -12
- package/lib/bash-wsl/index.js +23 -13
- package/lib/client.js +558 -0
- package/lib/index.js +46 -1
- package/lib/tool-bash/types/factory.js +64 -69
- package/lib/tool-bash/types/shell-family.js +193 -0
- package/package.json +13 -1
- package/uninstall.ps1 +50 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-win-multi-bash — browser half.
|
|
3
|
+
*
|
|
4
|
+
* The two tool rows carry their own configuration (`enableRunInBackground` plus
|
|
5
|
+
* the backend partition: paths, sandbox stance, probe timeout, sandbox
|
|
6
|
+
* hardening, and the executor's command limits). This module gives each row a
|
|
7
|
+
* **Configure** page on the Web sidebar's Plugins page, through the
|
|
8
|
+
* `plugins.row.config` slot keyed by `<package name>#<row id>`, so the options
|
|
9
|
+
* are edited in the panel instead of by hand in `cordis.patch.yml`.
|
|
10
|
+
*
|
|
11
|
+
* It also labels the bundle itself: the plugin has a **core row** (the package
|
|
12
|
+
* row, `win-mb-plugin`) that carries this browser half and owns the shared shell
|
|
13
|
+
* prompt section, and the panel cannot mark a third-party row read-only — dsh's
|
|
14
|
+
* plugin manager locks only its own protected modules, its own row, and rows the
|
|
15
|
+
* profile patch cannot address. So the core row is named where a user reads it
|
|
16
|
+
* instead: a badge beside the bundle's title and a note under the row list, both
|
|
17
|
+
* through `plugins.detail.badge` / `plugins.detail.section`.
|
|
18
|
+
*
|
|
19
|
+
* Persistence is the platform's own: every knob is declared `.volatile()` in the
|
|
20
|
+
* host half, which is exactly what makes it addressable by the settings service
|
|
21
|
+
* (`ctx.configForms` here, `settings` on the host) and writable through the
|
|
22
|
+
* profile's Cordis patch. This half owns no storage and no HTTP route.
|
|
23
|
+
*
|
|
24
|
+
* The form is staged, not live: a control writes nothing until **Save**, a save
|
|
25
|
+
* is fenced by the revision its drafts were read at, and a save the Host refuses
|
|
26
|
+
* keeps its drafts. See `dsh-settings` for the projection and the mutation
|
|
27
|
+
* contract, and `dsh-client-ui-plugin-manager` for the slots.
|
|
28
|
+
*
|
|
29
|
+
* @module dsh-win-multi-bash/client
|
|
30
|
+
*/
|
|
31
|
+
window.__ModuleLoader__.load({
|
|
32
|
+
id: "dsh-win-multi-bash",
|
|
33
|
+
factory: (require) => {
|
|
34
|
+
var module = { exports: {} };
|
|
35
|
+
var exports = module.exports;
|
|
36
|
+
|
|
37
|
+
const React = require("react");
|
|
38
|
+
const primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
39
|
+
const h = React.createElement;
|
|
40
|
+
|
|
41
|
+
//#region locales
|
|
42
|
+
/** Dictionary namespace owned by this plugin. */
|
|
43
|
+
const NS = "settings.win-mb-bash";
|
|
44
|
+
/** The package this browser half belongs to; every detail contribution filters on it. */
|
|
45
|
+
const PACKAGE_NAME = "dsh-win-multi-bash";
|
|
46
|
+
/** The core row's id, as `cordis.patch.yml` declares it. */
|
|
47
|
+
const CORE_ROW_ID = "win-mb-plugin";
|
|
48
|
+
/** English copy. */
|
|
49
|
+
const en = {
|
|
50
|
+
summary: "Configure the Git Bash and WSL tool rows: paths, sandbox stance, and command limits.",
|
|
51
|
+
coreBadge: "core row",
|
|
52
|
+
coreNote: "This plugin is three rows: its core row and one row per tool (git_bash, wsl_bash). Configure each tool on its own row. Keep the core row enabled — it serves this page and owns the shared shell prompt section; with it off the tools still run and carry that guidance in their own descriptions, but the Configure controls on this page disappear.",
|
|
53
|
+
unavailable: "This row is not loaded, so it cannot be configured right now.",
|
|
54
|
+
readOnly: "This deployment stores settings read-only.",
|
|
55
|
+
saveFailed: "The deployment did not accept these values; they were left for you to correct.",
|
|
56
|
+
save: "Save",
|
|
57
|
+
saving: "Saving…",
|
|
58
|
+
overridden: "Overridden",
|
|
59
|
+
reset: "Reset to default",
|
|
60
|
+
invalidNumber: "Enter a number, or leave blank to use the default.",
|
|
61
|
+
"option.auto": "auto",
|
|
62
|
+
"option.none": "none",
|
|
63
|
+
"option.bwrap": "bwrap",
|
|
64
|
+
"enableRunInBackground.label": "Background jobs",
|
|
65
|
+
"enableRunInBackground.hint": "Whether this tool accepts run_in_background. The tool schema carries this argument, so it follows the row's next load.",
|
|
66
|
+
"cwd.label": "Default working directory",
|
|
67
|
+
"cwd.hint": "Used when a call passes no workdir; empty means the session workspace.",
|
|
68
|
+
"timeoutMs.label": "Default command timeout (ms)",
|
|
69
|
+
"timeoutMs.hint": "How long one command may run before it is terminated. A background job ignores it: it runs until it finishes or is cancelled.",
|
|
70
|
+
"maxTimeoutMs.label": "Maximum timeout a call may request (ms)",
|
|
71
|
+
"maxTimeoutMs.hint": "A call asking for more is clamped to this. It never applies to a background job.",
|
|
72
|
+
"maxOutputBytes.label": "Output cap per stream (bytes)",
|
|
73
|
+
"maxOutputBytes.hint": "Output beyond this spills to a temporary file instead of being lost.",
|
|
74
|
+
"maxSpillBytes.label": "Spill file cap (bytes)",
|
|
75
|
+
"maxSpillBytes.hint": "How much of that spilled output is retained.",
|
|
76
|
+
"graceMs.label": "Grace before a forced kill (ms)",
|
|
77
|
+
"graceMs.hint": "How long a terminated command may still flush before it is killed outright.",
|
|
78
|
+
"bashPath.label": "Git Bash executable",
|
|
79
|
+
"bashPath.hint": "Pin an explicit bash.exe. Empty probes Git for Windows instead (PATH, well-known installs, then the registry).",
|
|
80
|
+
"wslPath.label": "WSL launcher",
|
|
81
|
+
"wslPath.hint": "Pin an explicit wsl.exe. Empty uses the standard Windows location.",
|
|
82
|
+
"wslDistro.label": "Distribution",
|
|
83
|
+
"wslDistro.hint": "Pin a distribution by name. Empty uses the first one WSL lists.",
|
|
84
|
+
"sandbox.label": "Sandbox stance",
|
|
85
|
+
"sandbox.hint": "auto probes and degrades honestly; none never confines; bwrap (WSL only) refuses to run unconfined.",
|
|
86
|
+
"probeTimeoutMs.label": "Sandbox probe timeout (ms)",
|
|
87
|
+
"probeTimeoutMs.hint": "How long the confinement probe may take before it is treated as a failure.",
|
|
88
|
+
"requireSandbox.label": "Refuse unconfined runs when the probe fails",
|
|
89
|
+
"requireSandbox.hint": "With it on, read-only and workspace-write calls are refused instead of degrading; danger-full-access still runs.",
|
|
90
|
+
};
|
|
91
|
+
/** Simplified Chinese copy. */
|
|
92
|
+
const zh = {
|
|
93
|
+
summary: "配置 Git Bash 与 WSL 两个工具行:路径、沙箱立场与命令限制。",
|
|
94
|
+
coreBadge: "核心行",
|
|
95
|
+
coreNote: "本插件共三行:核心行 + 每个工具一行(git_bash、wsl_bash)。工具各自在自己的行上配置。请保持核心行开启——本页由它提供,共有的那段 shell 提示词也归它所有;关掉后工具照旧可用(共有指导回落到各自的描述里),但本页的配置入口会消失。",
|
|
96
|
+
unavailable: "该行当前未加载,暂时无法配置。",
|
|
97
|
+
readOnly: "本部署的设置为只读。",
|
|
98
|
+
saveFailed: "本部署没有接受这些值,已保留供你修改。",
|
|
99
|
+
save: "保存",
|
|
100
|
+
saving: "保存中…",
|
|
101
|
+
overridden: "已覆盖",
|
|
102
|
+
reset: "恢复默认",
|
|
103
|
+
invalidNumber: "请填数字;留空表示使用默认值。",
|
|
104
|
+
"option.auto": "自动",
|
|
105
|
+
"option.none": "不限制",
|
|
106
|
+
"option.bwrap": "bwrap",
|
|
107
|
+
"enableRunInBackground.label": "后台任务",
|
|
108
|
+
"enableRunInBackground.hint": "该工具是否接受 run_in_background;该参数属于工具 schema,需该行下次加载后生效。",
|
|
109
|
+
"cwd.label": "默认工作目录",
|
|
110
|
+
"cwd.hint": "调用未传 workdir 时使用;留空表示会话工作区。",
|
|
111
|
+
"timeoutMs.label": "默认命令超时(毫秒)",
|
|
112
|
+
"timeoutMs.hint": "单条命令允许运行多久,超时即终止。后台任务不受此限制:它一直跑到结束或被取消。",
|
|
113
|
+
"maxTimeoutMs.label": "单次调用可请求的最大超时(毫秒)",
|
|
114
|
+
"maxTimeoutMs.hint": "调用请求更大值时会被压到该上限;该上限对后台任务不生效。",
|
|
115
|
+
"maxOutputBytes.label": "单流输出上限(字节)",
|
|
116
|
+
"maxOutputBytes.hint": "超出部分转存到临时文件,而不是被丢弃。",
|
|
117
|
+
"maxSpillBytes.label": "转存文件上限(字节)",
|
|
118
|
+
"maxSpillBytes.hint": "转存输出保留多少。",
|
|
119
|
+
"graceMs.label": "强制终止前的宽限(毫秒)",
|
|
120
|
+
"graceMs.hint": "命令被终止后仍可冲刷输出的时间。",
|
|
121
|
+
"bashPath.label": "Git Bash 可执行文件",
|
|
122
|
+
"bashPath.hint": "钉定显式 bash.exe;留空则自动探测 Git for Windows(PATH、常见安装位置、注册表)。",
|
|
123
|
+
"wslPath.label": "WSL 启动器",
|
|
124
|
+
"wslPath.hint": "钉定显式 wsl.exe;留空使用 Windows 标准位置。",
|
|
125
|
+
"wslDistro.label": "发行版",
|
|
126
|
+
"wslDistro.hint": "按名钉定发行版;留空使用 WSL 列出的第一个。",
|
|
127
|
+
"sandbox.label": "沙箱立场",
|
|
128
|
+
"sandbox.hint": "auto 探测并如实降级;none 从不限制;bwrap(仅 WSL)拒绝无沙箱运行。",
|
|
129
|
+
"probeTimeoutMs.label": "沙箱探测超时(毫秒)",
|
|
130
|
+
"probeTimeoutMs.hint": "探测限制能力最多耗时多久,超时按失败处理。",
|
|
131
|
+
"requireSandbox.label": "探测失败时拒绝无沙箱运行",
|
|
132
|
+
"requireSandbox.hint": "开启后 read-only / workspace-write 直接拒绝而非降级;danger-full-access 仍放行。",
|
|
133
|
+
};
|
|
134
|
+
//#endregion
|
|
135
|
+
|
|
136
|
+
//#region fields
|
|
137
|
+
/**
|
|
138
|
+
* One editable knob. `path` addresses it inside the row's config, `kind`
|
|
139
|
+
* picks the control, and `key` names its copy in the dictionary
|
|
140
|
+
* (`<key>.label` / `<key>.hint`). Every path here is `.volatile()` in the
|
|
141
|
+
* host half — the settings service refuses anything else, and the host
|
|
142
|
+
* audit pins that list.
|
|
143
|
+
*/
|
|
144
|
+
const field = (path, kind, options) => ({ key: path.join("."), path, kind, options });
|
|
145
|
+
/** The knobs every backend shares: the executor's own command limits. */
|
|
146
|
+
const limitFields = (partition) => [
|
|
147
|
+
field([partition, "cwd"], "text"),
|
|
148
|
+
field([partition, "timeoutMs"], "number"),
|
|
149
|
+
field([partition, "maxTimeoutMs"], "number"),
|
|
150
|
+
field([partition, "maxOutputBytes"], "number"),
|
|
151
|
+
field([partition, "maxSpillBytes"], "number"),
|
|
152
|
+
field([partition, "graceMs"], "number"),
|
|
153
|
+
];
|
|
154
|
+
/** What the two tool rows expose, in form order. */
|
|
155
|
+
const ROWS = [
|
|
156
|
+
{
|
|
157
|
+
rowId: "win-mb-tool-git",
|
|
158
|
+
fields: [
|
|
159
|
+
field(["enableRunInBackground"], "boolean"),
|
|
160
|
+
...limitFields("gitBash"),
|
|
161
|
+
field(["gitBash", "bashPath"], "text"),
|
|
162
|
+
field(["gitBash", "sandbox"], "enum", ["auto", "none"]),
|
|
163
|
+
field(["gitBash", "probeTimeoutMs"], "number"),
|
|
164
|
+
field(["gitBash", "requireSandbox"], "boolean"),
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
rowId: "win-mb-tool-wsl",
|
|
169
|
+
fields: [
|
|
170
|
+
field(["enableRunInBackground"], "boolean"),
|
|
171
|
+
...limitFields("wslBash"),
|
|
172
|
+
field(["wslBash", "wslPath"], "text"),
|
|
173
|
+
field(["wslBash", "wslDistro"], "text"),
|
|
174
|
+
field(["wslBash", "sandbox"], "enum", ["auto", "none", "bwrap"]),
|
|
175
|
+
field(["wslBash", "probeTimeoutMs"], "number"),
|
|
176
|
+
field(["wslBash", "requireSandbox"], "boolean"),
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
/** The slot key of one row's page: `<package name>#<row id>`. */
|
|
181
|
+
const rowKey = (rowId) => `dsh-win-multi-bash#${rowId}`;
|
|
182
|
+
//#endregion
|
|
183
|
+
|
|
184
|
+
//#region form model
|
|
185
|
+
/** Read a nested path out of a settings layer, `undefined` when absent. */
|
|
186
|
+
function at(layer, path) {
|
|
187
|
+
let node = layer;
|
|
188
|
+
for (const key of path) {
|
|
189
|
+
if (node === null || typeof node !== "object") return undefined;
|
|
190
|
+
node = node[key];
|
|
191
|
+
}
|
|
192
|
+
return node;
|
|
193
|
+
}
|
|
194
|
+
/** Whether a settings layer carries this path at all (what marks a field overridden). */
|
|
195
|
+
function has(layer, path) {
|
|
196
|
+
let node = layer;
|
|
197
|
+
for (const key of path) {
|
|
198
|
+
if (node === null || typeof node !== "object" || !Object.hasOwn(node, key)) return false;
|
|
199
|
+
node = node[key];
|
|
200
|
+
}
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Turn one staged draft into the value a write carries.
|
|
205
|
+
* @returns the value, or `undefined` for a draft no control accepts.
|
|
206
|
+
*/
|
|
207
|
+
function coerce(kind, draft) {
|
|
208
|
+
if (kind === "number") {
|
|
209
|
+
if (typeof draft === "number") return Number.isFinite(draft) ? draft : undefined;
|
|
210
|
+
const text = String(draft).trim();
|
|
211
|
+
if (text.length === 0) return undefined;
|
|
212
|
+
const value = Number(text);
|
|
213
|
+
return Number.isFinite(value) ? value : undefined;
|
|
214
|
+
}
|
|
215
|
+
if (kind === "boolean") return draft === true;
|
|
216
|
+
return String(draft);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The ordered writes one save sends: a clear for every reset and a set for
|
|
220
|
+
* every staged value. A draft no control accepts contributes no write —
|
|
221
|
+
* the form still blocks the save, rather than dropping the edit.
|
|
222
|
+
* @param fields - the row's knobs.
|
|
223
|
+
* @param staged - field key → `{ op: 'set', draft }` or `{ op: 'unset' }`.
|
|
224
|
+
* @returns the path ops `mutate` takes.
|
|
225
|
+
*/
|
|
226
|
+
function planOps(fields, staged) {
|
|
227
|
+
const ops = [];
|
|
228
|
+
for (const knob of fields) {
|
|
229
|
+
const entry = staged.get(knob.key);
|
|
230
|
+
if (entry === undefined) continue;
|
|
231
|
+
if (entry.op === "unset") {
|
|
232
|
+
ops.push({ op: "unset", path: knob.path });
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const value = coerce(knob.kind, entry.draft);
|
|
236
|
+
if (value === undefined) continue;
|
|
237
|
+
ops.push({ op: "set", path: knob.path, value });
|
|
238
|
+
}
|
|
239
|
+
return ops;
|
|
240
|
+
}
|
|
241
|
+
/** A minimal snapshot store: one cached snapshot, replaced on publish. */
|
|
242
|
+
function storeOf(compute) {
|
|
243
|
+
let snapshot = compute();
|
|
244
|
+
const listeners = new Set();
|
|
245
|
+
return {
|
|
246
|
+
getSnapshot: () => snapshot,
|
|
247
|
+
subscribe: (listener) => {
|
|
248
|
+
listeners.add(listener);
|
|
249
|
+
return () => listeners.delete(listener);
|
|
250
|
+
},
|
|
251
|
+
publish: () => {
|
|
252
|
+
snapshot = compute();
|
|
253
|
+
for (const listener of [...listeners]) listener();
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* One row's staged form over its settings namespace. Drafts live here, not
|
|
259
|
+
* in React state, so the card's component stays a pure function of the
|
|
260
|
+
* injected store (the shape every shipped settings page uses).
|
|
261
|
+
*/
|
|
262
|
+
class RowConfigForm {
|
|
263
|
+
/** @param scope - the settings form the host serves for this row id. */
|
|
264
|
+
constructor(scope, row) {
|
|
265
|
+
this.scope = scope;
|
|
266
|
+
this.row = row;
|
|
267
|
+
this.staged = new Map();
|
|
268
|
+
this.saving = false;
|
|
269
|
+
this.failed = false;
|
|
270
|
+
this.store = storeOf(() => this.project());
|
|
271
|
+
this.unsubscribe = scope.subscribe(() => this.store.publish());
|
|
272
|
+
}
|
|
273
|
+
/** Everything the card renders, recomputed as a whole on every change. */
|
|
274
|
+
project() {
|
|
275
|
+
const snapshot = this.scope.getSnapshot();
|
|
276
|
+
const fields = this.row.fields.map((knob) => {
|
|
277
|
+
const staged = this.staged.get(knob.key);
|
|
278
|
+
const stored = at(snapshot.value, knob.path);
|
|
279
|
+
const inherited = at(snapshot.base, knob.path);
|
|
280
|
+
const shown = staged === undefined ? stored : staged.op === "unset" ? inherited : staged.draft;
|
|
281
|
+
const text = shown === undefined || shown === null ? "" : String(shown);
|
|
282
|
+
return {
|
|
283
|
+
key: knob.key,
|
|
284
|
+
kind: knob.kind,
|
|
285
|
+
path: knob.path,
|
|
286
|
+
options: knob.options,
|
|
287
|
+
text,
|
|
288
|
+
value: shown,
|
|
289
|
+
checked: shown === true,
|
|
290
|
+
// A staged draft answers for itself, so the badge previews the save.
|
|
291
|
+
overridden: staged === undefined ? has(snapshot.user, knob.path) : staged.op === "set",
|
|
292
|
+
invalid: knob.kind === "number" && text.trim().length > 0 && !Number.isFinite(Number(text.trim())),
|
|
293
|
+
};
|
|
294
|
+
});
|
|
295
|
+
return {
|
|
296
|
+
available: snapshot.status === "ready",
|
|
297
|
+
writable: snapshot.writable === true,
|
|
298
|
+
dirty: this.staged.size > 0,
|
|
299
|
+
invalid: fields.some((entry) => entry.invalid),
|
|
300
|
+
saving: this.saving,
|
|
301
|
+
failed: this.failed,
|
|
302
|
+
fields,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
/** Stage one control's draft; an emptied text control stages a clear. */
|
|
306
|
+
edit(key, draft) {
|
|
307
|
+
const knob = this.row.fields.find((entry) => entry.key === key);
|
|
308
|
+
if (knob === undefined) return;
|
|
309
|
+
const empty = (knob.kind === "text" || knob.kind === "number") && String(draft).length === 0;
|
|
310
|
+
this.staged.set(key, empty ? { op: "unset" } : { op: "set", draft });
|
|
311
|
+
this.failed = false;
|
|
312
|
+
this.store.publish();
|
|
313
|
+
}
|
|
314
|
+
/** Stage a clear, so saving lets the field re-inherit the composition layer. */
|
|
315
|
+
resetField(key) {
|
|
316
|
+
this.staged.set(key, { op: "unset" });
|
|
317
|
+
this.failed = false;
|
|
318
|
+
this.store.publish();
|
|
319
|
+
}
|
|
320
|
+
/** Drop every staged draft. */
|
|
321
|
+
discard() {
|
|
322
|
+
this.staged.clear();
|
|
323
|
+
this.failed = false;
|
|
324
|
+
this.store.publish();
|
|
325
|
+
}
|
|
326
|
+
/** Write every staged draft in one revision-fenced mutation. */
|
|
327
|
+
async save() {
|
|
328
|
+
const snapshot = this.scope.getSnapshot();
|
|
329
|
+
const ops = planOps(this.row.fields, this.staged);
|
|
330
|
+
if (snapshot.writable !== true || ops.length === 0) return;
|
|
331
|
+
this.saving = true;
|
|
332
|
+
this.failed = false;
|
|
333
|
+
this.store.publish();
|
|
334
|
+
let accepted = false;
|
|
335
|
+
try {
|
|
336
|
+
accepted = await this.scope.mutate(ops, snapshot.revision);
|
|
337
|
+
} catch {
|
|
338
|
+
accepted = false;
|
|
339
|
+
}
|
|
340
|
+
this.saving = false;
|
|
341
|
+
if (accepted) this.staged.clear();
|
|
342
|
+
this.failed = !accepted;
|
|
343
|
+
this.store.publish();
|
|
344
|
+
}
|
|
345
|
+
/** The face the slot registration injects: the store and the edit actions. */
|
|
346
|
+
inject() {
|
|
347
|
+
return {
|
|
348
|
+
hooks: { rowForm: this.store },
|
|
349
|
+
edit: (key, draft) => this.edit(key, draft),
|
|
350
|
+
resetField: (key) => this.resetField(key),
|
|
351
|
+
discard: () => this.discard(),
|
|
352
|
+
save: () => this.save(),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
dispose() {
|
|
356
|
+
this.unsubscribe();
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
//#endregion
|
|
360
|
+
|
|
361
|
+
//#region card
|
|
362
|
+
/** One knob's control: a staged value field, a checkbox, or a segmented choice. */
|
|
363
|
+
function fieldControl(knob, disabled, props, t) {
|
|
364
|
+
const shared = {
|
|
365
|
+
id: `win-mb-${knob.key.replace(/\./g, "-")}`,
|
|
366
|
+
label: t(`${knob.key.split(".").pop()}.label`),
|
|
367
|
+
hint: t(`${knob.key.split(".").pop()}.hint`),
|
|
368
|
+
overridden: knob.overridden,
|
|
369
|
+
overriddenLabel: t("overridden"),
|
|
370
|
+
resetLabel: t("reset"),
|
|
371
|
+
invalidLabel: t("invalidNumber"),
|
|
372
|
+
disabled,
|
|
373
|
+
};
|
|
374
|
+
if (knob.kind === "text" || knob.kind === "number")
|
|
375
|
+
return h(primitives.SettingsValueField, {
|
|
376
|
+
...shared,
|
|
377
|
+
key: knob.key,
|
|
378
|
+
text: knob.text,
|
|
379
|
+
invalid: knob.invalid,
|
|
380
|
+
numeric: knob.kind === "number",
|
|
381
|
+
onEdit: (text) => props.edit(knob.key, text),
|
|
382
|
+
onReset: () => props.resetField(knob.key),
|
|
383
|
+
});
|
|
384
|
+
if (knob.kind === "boolean")
|
|
385
|
+
return h(
|
|
386
|
+
"div",
|
|
387
|
+
{ key: knob.key, className: "dshwmb-row" },
|
|
388
|
+
h(primitives.Checkbox, {
|
|
389
|
+
label: shared.label,
|
|
390
|
+
checked: knob.checked,
|
|
391
|
+
disabled,
|
|
392
|
+
onChange: (next) => props.edit(knob.key, next),
|
|
393
|
+
}),
|
|
394
|
+
h("p", { className: "dshwmb-hint" }, shared.hint),
|
|
395
|
+
knob.overridden
|
|
396
|
+
? h(primitives.Button, { variant: "ghost", size: "sm", type: "button", disabled, onClick: () => props.resetField(knob.key) }, shared.resetLabel)
|
|
397
|
+
: null,
|
|
398
|
+
);
|
|
399
|
+
return h(
|
|
400
|
+
"div",
|
|
401
|
+
{ key: knob.key, className: "dshwmb-row" },
|
|
402
|
+
h("p", { className: "dshwmb-label" }, shared.label),
|
|
403
|
+
h(primitives.SegmentedControl, {
|
|
404
|
+
id: shared.id,
|
|
405
|
+
value: String(knob.value ?? ""),
|
|
406
|
+
label: shared.label,
|
|
407
|
+
disabled,
|
|
408
|
+
options: knob.options.map((option) => ({ value: option, label: t(`option.${option}`) })),
|
|
409
|
+
onChange: (next) => props.edit(knob.key, next),
|
|
410
|
+
}),
|
|
411
|
+
h("p", { className: "dshwmb-hint" }, shared.hint),
|
|
412
|
+
knob.overridden
|
|
413
|
+
? h(primitives.Button, { variant: "ghost", size: "sm", type: "button", disabled, onClick: () => props.resetField(knob.key) }, shared.resetLabel)
|
|
414
|
+
: null,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* One row's configuration page as the Plugins page renders it. No React
|
|
419
|
+
* state: the store and the actions arrive through the slot's inject, and
|
|
420
|
+
* the page drops every draft when it unmounts.
|
|
421
|
+
*/
|
|
422
|
+
function RowConfigCard(props) {
|
|
423
|
+
const t = props.t;
|
|
424
|
+
if (props.view === "summary") return t("summary");
|
|
425
|
+
const state = props.useRowForm((snapshot) => snapshot);
|
|
426
|
+
const disabled = state.writable !== true;
|
|
427
|
+
return h(
|
|
428
|
+
primitives.SettingsForm,
|
|
429
|
+
{
|
|
430
|
+
labels: {
|
|
431
|
+
unavailable: t("unavailable"),
|
|
432
|
+
readOnly: t("readOnly"),
|
|
433
|
+
saveFailed: t("saveFailed"),
|
|
434
|
+
save: t("save"),
|
|
435
|
+
saving: t("saving"),
|
|
436
|
+
},
|
|
437
|
+
state,
|
|
438
|
+
onSave: props.save,
|
|
439
|
+
onDiscard: props.discard,
|
|
440
|
+
},
|
|
441
|
+
...state.fields.map((knob) => fieldControl(knob, disabled, props, t)),
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
//#endregion
|
|
445
|
+
|
|
446
|
+
//#region detail page
|
|
447
|
+
/** The page's localizer, or the English copy when the slot hands none over. */
|
|
448
|
+
function textOf(props) {
|
|
449
|
+
return typeof props?.t === "function" ? props.t : (key) => en[key] ?? key;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The detail pages this bundle owns: the bundle's own page, and the core
|
|
454
|
+
* row's page. Every other subject (another package, or one of the tool
|
|
455
|
+
* rows) renders nothing, which is what the slots expect of a contribution
|
|
456
|
+
* that has nothing to say about the subject.
|
|
457
|
+
* @param subject - the page subject the slot renders with.
|
|
458
|
+
* @returns the owned page's kind, or `undefined` for a foreign subject.
|
|
459
|
+
*/
|
|
460
|
+
function ownedPage(subject) {
|
|
461
|
+
if (subject === null || typeof subject !== "object") return undefined;
|
|
462
|
+
if (subject.kind === "bundle" && subject.pkg?.name === PACKAGE_NAME) return "bundle";
|
|
463
|
+
if (subject.kind === "row" && subject.pkg?.name === PACKAGE_NAME && subject.row?.rowId === CORE_ROW_ID) return "core-row";
|
|
464
|
+
return undefined;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Tags the bundle's title with its core row. dsh cannot make a third-party
|
|
469
|
+
* row read-only, so the row that must stay enabled is named where a user
|
|
470
|
+
* actually reads it.
|
|
471
|
+
*/
|
|
472
|
+
function CoreBadge(props) {
|
|
473
|
+
const t = textOf(props);
|
|
474
|
+
if (ownedPage(props?.subject) === undefined) return null;
|
|
475
|
+
return h("span", { className: "dshwmb-badge" }, t("coreBadge"));
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Says which row is which, and what switching the core one off costs. */
|
|
479
|
+
function CoreNote(props) {
|
|
480
|
+
const t = textOf(props);
|
|
481
|
+
if (ownedPage(props?.subject) === undefined) return null;
|
|
482
|
+
return h("section", { className: "dshwmb-note" }, h("p", { className: "dshwmb-hint" }, t("coreNote")));
|
|
483
|
+
}
|
|
484
|
+
//#endregion
|
|
485
|
+
|
|
486
|
+
//#region plugin
|
|
487
|
+
/** Required services (cordis fiber inject). */
|
|
488
|
+
const inject = ["slots", "locale", "configForms"];
|
|
489
|
+
/** This plugin's own layout, injected once per document. */
|
|
490
|
+
const CSS = [
|
|
491
|
+
".dshwmb-row{margin:0 0 14px}",
|
|
492
|
+
".dshwmb-label{margin:0 0 6px;font-size:13px;font-weight:500}",
|
|
493
|
+
".dshwmb-hint{margin:6px 0 0;font-size:12px;opacity:.65;line-height:1.5}",
|
|
494
|
+
".dshwmb-badge{display:inline-block;margin-left:8px;padding:1px 7px;border-radius:9px;border:1px solid currentColor;font-size:11px;font-weight:500;opacity:.75;vertical-align:middle}",
|
|
495
|
+
".dshwmb-note{margin:14px 0 0}",
|
|
496
|
+
].join("");
|
|
497
|
+
/** Install the stylesheet once, guarded for a host that serves this twice. */
|
|
498
|
+
function installStyles() {
|
|
499
|
+
if (typeof document === "undefined" || document.querySelector("style[data-plugin='dsh-win-multi-bash']") !== null) return;
|
|
500
|
+
const tag = document.createElement("style");
|
|
501
|
+
tag.dataset.plugin = "dsh-win-multi-bash";
|
|
502
|
+
tag.textContent = CSS;
|
|
503
|
+
document.head.appendChild(tag);
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Give each tool row its Configure page on the Plugins page, while the host
|
|
507
|
+
* serves that row's settings namespace.
|
|
508
|
+
* @param ctx - the browser plugin context.
|
|
509
|
+
*/
|
|
510
|
+
function apply(ctx) {
|
|
511
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-win-multi-bash: dictionaries");
|
|
512
|
+
installStyles();
|
|
513
|
+
for (const row of ROWS) {
|
|
514
|
+
const form = new RowConfigForm(ctx.configForms.get(row.rowId), row);
|
|
515
|
+
ctx.effect(() => () => form.dispose(), `dsh-win-multi-bash: ${row.rowId} form`);
|
|
516
|
+
ctx.effect(
|
|
517
|
+
() =>
|
|
518
|
+
ctx.configForms.whileServed([row.rowId], () =>
|
|
519
|
+
ctx.slots.inject("plugins.row.config", () =>
|
|
520
|
+
ctx.slots.register(
|
|
521
|
+
{
|
|
522
|
+
name: "plugins.row.config",
|
|
523
|
+
key: rowKey(row.rowId),
|
|
524
|
+
locale: NS,
|
|
525
|
+
inject: () => form.inject(),
|
|
526
|
+
},
|
|
527
|
+
RowConfigCard,
|
|
528
|
+
),
|
|
529
|
+
),
|
|
530
|
+
),
|
|
531
|
+
`dsh-win-multi-bash: ${row.rowId} page`,
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
// The bundle's own page and the core row's page are the same subject to a
|
|
535
|
+
// reader: label the row that has to stay on, and say what its switch costs.
|
|
536
|
+
for (const [slotName, component] of [
|
|
537
|
+
["plugins.detail.badge", CoreBadge],
|
|
538
|
+
["plugins.detail.section", CoreNote],
|
|
539
|
+
]) {
|
|
540
|
+
ctx.effect(
|
|
541
|
+
() =>
|
|
542
|
+
ctx.slots.inject(slotName, () =>
|
|
543
|
+
ctx.slots.register({ name: slotName, id: `${PACKAGE_NAME}:core`, order: 100, locale: NS }, component),
|
|
544
|
+
),
|
|
545
|
+
`dsh-win-multi-bash: ${slotName}`,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
//#endregion
|
|
550
|
+
|
|
551
|
+
exports.NS = NS;
|
|
552
|
+
exports.apply = apply;
|
|
553
|
+
exports.inject = inject;
|
|
554
|
+
/** Pure helpers the audit exercises without a renderer. */
|
|
555
|
+
exports.__internals = { ROWS, rowKey, at, has, coerce, planOps, RowConfigForm, RowConfigCard, CoreBadge, CoreNote, ownedPage, CORE_ROW_ID, PACKAGE_NAME, en, zh };
|
|
556
|
+
return module.exports;
|
|
557
|
+
},
|
|
558
|
+
});
|
package/lib/index.js
CHANGED
|
@@ -11,13 +11,58 @@
|
|
|
11
11
|
// (types/backend.js) instead of sharing the ctx.shell
|
|
12
12
|
// seat: dsh 0.1.7 removed ShellExecRequest.shell, the
|
|
13
13
|
// routing field the old shell-select design used.
|
|
14
|
+
// lib/tool-bash/shell-prompt.js
|
|
15
|
+
// — the shared prompt section's single owner row.
|
|
14
16
|
// lib/vendor/ — helpers.js (bash-sandbox classification, including the
|
|
15
17
|
// anchored runner-failure rule the base runtime does
|
|
16
18
|
// not export) and bwrap-profiles.js (bwrap rules/args).
|
|
19
|
+
// lib/client.js — the browser half: the Plugins page's configuration
|
|
20
|
+
// form for the two tool rows.
|
|
17
21
|
//
|
|
18
22
|
// Only published @deepseek-ai base packages are imported (dsh-shell,
|
|
19
23
|
// dsh-sandbox, dsh-bash-local, dsh-tools, dsh-llm, ...), so the plugin runs
|
|
20
24
|
// on any standard deployment — no additional runtime packages required.
|
|
21
25
|
//
|
|
22
26
|
// The composition wiring lives in cordis.patch.yml.
|
|
23
|
-
|
|
27
|
+
import {
|
|
28
|
+
SHELL_FAMILY_SECTION_NAME,
|
|
29
|
+
shellFamilySectionOrder,
|
|
30
|
+
shellFamilySectionTextFor,
|
|
31
|
+
} from './tool-bash/types/shell-family.js';
|
|
32
|
+
|
|
33
|
+
/** Row name; the Loader also uses it for logging and for the prompt-section owner. */
|
|
34
|
+
export const name = 'dsh-win-multi-bash';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `systemPrompt` is the registry the family section belongs to; `tools` is read
|
|
38
|
+
* at every assembly so the section can describe exactly the shell tools this
|
|
39
|
+
* composition mounts. Without a tools registry there are no such tools, and the
|
|
40
|
+
* row correctly never applies.
|
|
41
|
+
*/
|
|
42
|
+
export const inject = ['systemPrompt', 'tools'];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The package row — the plugin's core row.
|
|
46
|
+
*
|
|
47
|
+
* It exists because the Web client's module system discovers a browser half by
|
|
48
|
+
* resolving a Loader row's **bare package specifier** to its `package.json` and
|
|
49
|
+
* reading `dsh.client` there (client-modules' `locatePkgJson` →
|
|
50
|
+
* `exactPackageSpecifier`, which answers `undefined` for a subpath). The tool
|
|
51
|
+
* rows this bundle inserts are subpaths, so none of them can carry that
|
|
52
|
+
* declaration; without this row `lib/client.js` is never served and the tool
|
|
53
|
+
* rows show no Configure page.
|
|
54
|
+
*
|
|
55
|
+
* It is also the owner of the one prompt section the bash family shares
|
|
56
|
+
* (`tool:win-mb-bash`), which is why it is not an empty `apply` any more: the
|
|
57
|
+
* section used to live in a row of its own, and merging the two removes a
|
|
58
|
+
* switch that could only ever make the composition worse. The section's text is
|
|
59
|
+
* resolved per assembly, so a composition that switched every tool row off
|
|
60
|
+
* mounts no family tool and this section then contributes no text at all.
|
|
61
|
+
*/
|
|
62
|
+
export function apply(ctx) {
|
|
63
|
+
ctx.systemPrompt.section({
|
|
64
|
+
name: SHELL_FAMILY_SECTION_NAME,
|
|
65
|
+
order: shellFamilySectionOrder(ctx.systemPrompt),
|
|
66
|
+
text: ({ scope }) => shellFamilySectionTextFor(ctx, scope),
|
|
67
|
+
});
|
|
68
|
+
}
|