dsh-win-pwsh7 0.2.0 → 0.2.3
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/CHANGELOG.md +36 -0
- package/README.en.md +17 -3
- package/README.md +12 -3
- package/client.js +32 -6
- package/cordis.patch.yml +6 -0
- package/index.js +34 -2
- package/lib/dsh-ops-bridge.js +329 -0
- package/package.json +4 -2
- package/tools/check-client.mjs +21 -10
- package/tools/check-host.mjs +156 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.3 — removable dsh-ops coexistence bridge
|
|
4
|
+
|
|
5
|
+
- Moved the bridge, profile-patch ownership, and pure name planning into `lib/dsh-ops-bridge.js`.
|
|
6
|
+
`index.js` now mounts it in one place; `package.json` ships the module.
|
|
7
|
+
- Enabled coexistence declaratively from `cordis.patch.yml`; the plugin no longer writes an
|
|
8
|
+
enable override into the profile. Turning the switch off writes only a marked disable override.
|
|
9
|
+
- Removal contract: delete the module, the mount line, the config field, the client switch, and its
|
|
10
|
+
gate; the bundle patch disappears with the package. A switch-off override is the only possible
|
|
11
|
+
profile residue and is removed by the plugin's cleanup when it runs.
|
|
12
|
+
- The bundle patch must come after `dsh-ops` in `dsh.profile.bundles`; otherwise the loader warns
|
|
13
|
+
and skips it. The profile keeps `dsh-ops` before `dsh-win-pwsh7`.
|
|
14
|
+
|
|
15
|
+
## 0.2.2 — fix the dsh-ops bridge freeze
|
|
16
|
+
|
|
17
|
+
- Fixed the 0.2.1 hide/unhide oscillation that could pin the Host main thread when many sessions
|
|
18
|
+
were restored. The bridge now plans from the host's **pre-restriction** `knownNames` /
|
|
19
|
+
`restrictableNames` instead of the post-restriction `visible` set, and filters the deny list to
|
|
20
|
+
names `tools.restrict()` actually accepts. The fallback path latches names it already hid.
|
|
21
|
+
- Added regression checks for the stable plan, the older-host fallback, and the scope-local
|
|
22
|
+
`ops_bash` case that used to throw an unknown-global-tool warning.
|
|
23
|
+
- Restored `dshOpsCompat` to default-on after multi-session validation of the fixed bridge.
|
|
24
|
+
- The configuration page now uses the native `Switch` primitive and native setting-row layout for
|
|
25
|
+
the coexistence toggle; labels and hints no longer expose raw config keys.
|
|
26
|
+
|
|
27
|
+
## 0.2.1 — dsh-ops coexistence bridge
|
|
28
|
+
|
|
29
|
+
- Added a default-on `dshOpsCompat` configuration row. When dsh-ops is installed, pwsh7 writes
|
|
30
|
+
a marked `dsh-ops-compat` override into the active profile patch and enables it, so the native
|
|
31
|
+
`pwsh` tool is visible again alongside `ops_bash`.
|
|
32
|
+
- The agent bridge re-hides the official `read` / `glob` / `grep` tools when their OPS
|
|
33
|
+
counterparts are visible, plus the duplicate official `bash` / `pwsh_persistent` shell. It
|
|
34
|
+
keeps `pwsh` and `ops_bash` and does nothing when dsh-ops is absent.
|
|
35
|
+
- The compatibility patch needs a DSH restart; turning the switch off removes only the block this
|
|
36
|
+
plugin wrote. Uninstall cleanup is best-effort and leaves at most a harmless compatibility row.
|
|
37
|
+
- Added the plugin-page toggle with its own localized row and hint.
|
|
38
|
+
|
|
3
39
|
## 0.2.0 — 优化 Store 版 pwsh7 安装方式
|
|
4
40
|
|
|
5
41
|
优化store版pwsh7安装方式,重构项目前端设计和工程设计,使其符合原生DSH工程习惯和界面风格。
|
package/README.en.md
CHANGED
|
@@ -25,14 +25,26 @@ default executor then chooses the runtime itself, and the plugin still never wri
|
|
|
25
25
|
## What it does not do
|
|
26
26
|
|
|
27
27
|
- It does not install anything at startup.
|
|
28
|
-
- It does not write the executor's `pwshPath
|
|
28
|
+
- It does not write the executor's `pwshPath`; it only writes a marked dsh-ops compatibility override when coexistence is on.
|
|
29
29
|
- It does not uninstall a Store-installed PowerShell 7.
|
|
30
30
|
- It does not auto-detect VPNs or preflight metered connections.
|
|
31
31
|
|
|
32
|
+
## Coexistence with dsh-ops
|
|
33
|
+
|
|
34
|
+
On by default. The `dsh-ops-compat` override in `cordis.patch.yml` owns the enabled state: while
|
|
35
|
+
the dsh-win-pwsh7 bundle is installed and comes after dsh-ops, a DSH restart restores the native
|
|
36
|
+
`pwsh` tool and keeps `ops_bash`. Turning the switch off writes only a marked disable override;
|
|
37
|
+
removing the bundle removes the enable override, and cleanup best-effort removes the disable one.
|
|
38
|
+
|
|
39
|
+
The 0.2.1 bridge planned from the post-restriction visible set, which made it hide and restore the
|
|
40
|
+
same tools in a loop and starved the Host main thread. 0.2.2 plans from the pre-restriction
|
|
41
|
+
`knownNames` / `restrictableNames` and filters names `tools.restrict()` does not accept. The
|
|
42
|
+
multi-session validation passed, so the switch is default-on again.
|
|
43
|
+
|
|
32
44
|
## Install this plugin
|
|
33
45
|
|
|
34
46
|
```powershell
|
|
35
|
-
dsh plugin --profile desktop add github:XinSpark/dsh-win-pwsh7#v0.2.
|
|
47
|
+
dsh plugin --profile desktop add github:XinSpark/dsh-win-pwsh7#v0.2.3
|
|
36
48
|
# or
|
|
37
49
|
dsh plugin --profile desktop add dsh-win-pwsh7
|
|
38
50
|
```
|
|
@@ -70,6 +82,7 @@ The count lives in the current process and resets when DSH restarts.
|
|
|
70
82
|
| Key | Default | Meaning |
|
|
71
83
|
|---|---|---|
|
|
72
84
|
| `overridePath` | unset | Priority ① explicit `pwsh.exe`. It must spawn and report PowerShell 7+; a failed validation falls back to auto-detection. **It only changes probing and recommendations, not executor wiring.** |
|
|
85
|
+
| `dshOpsCompat` | `true` | Coexist with dsh-ops (on by default): writes and enables its Compatibility mode, hides duplicate official tools, and keeps `pwsh` + `ops_bash`. Requires a restart. |
|
|
73
86
|
|
|
74
87
|
`overridePath` is `volatile()`: the plugin-page configuration section saves it live; it can also be
|
|
75
88
|
set in the profile patch:
|
|
@@ -113,7 +126,8 @@ executor / disable the plugin.
|
|
|
113
126
|
## Plugin page
|
|
114
127
|
|
|
115
128
|
The status section is a native card showing the state, runtime and executor path, with Re-detect
|
|
116
|
-
at the top right; the configuration section exposes
|
|
129
|
+
at the top right; the configuration section exposes `overridePath` and the default-on "Keep native
|
|
130
|
+
pwsh alongside dsh-ops" native switch.
|
|
117
131
|
|
|
118
132
|
## Disable or uninstall
|
|
119
133
|
|
package/README.md
CHANGED
|
@@ -20,15 +20,23 @@
|
|
|
20
20
|
## 它不做什么
|
|
21
21
|
|
|
22
22
|
- 不在启动时自动安装。
|
|
23
|
-
- 不写执行器的 `pwshPath
|
|
23
|
+
- 不写执行器的 `pwshPath`;只会在开启共存时写一条带标记的 dsh-ops 兼容覆盖。
|
|
24
24
|
- 不卸载由 Store 安装的 PowerShell 7。
|
|
25
25
|
- 不做 VPN 自动检测,也不做计量连接预检。
|
|
26
26
|
|
|
27
|
+
## 与 dsh-ops 共存
|
|
28
|
+
|
|
29
|
+
默认开启。启用由 `cordis.patch.yml` 的声明式 `dsh-ops-compat` 覆盖负责:只要 dsh-win-pwsh7 的 bundle 安装且位于 dsh-ops 之后,重启 DSH 后官方 `pwsh` 恢复,`ops_bash` 保留。关闭开关时只写入一条带标记的 disable override;卸载 bundle 后启用覆盖消失,cleanup 会尽力删除 disable override。
|
|
30
|
+
|
|
31
|
+
0.2.1 的桥接层在计算隐藏集合时读取了限制后的可见工具,会在隐藏/恢复之间振荡并卡死宿主;0.2.2 改为读取限制前的 `knownNames` / `restrictableNames`,并过滤 `tools.restrict()` 不接受的名字。多会话验证通过后已恢复默认开启。
|
|
32
|
+
|
|
33
|
+
桥接层还会在对应 OPS 工具可见时隐藏官方 `read` / `glob` / `grep`,以及重复的官方 `bash` / `pwsh_persistent`,让工具面保持 `pwsh` + `ops_bash`。桥接实现独立在 `lib/dsh-ops-bridge.js`,删除该模块和入口挂载即可移除。
|
|
34
|
+
|
|
27
35
|
## 安装本插件
|
|
28
36
|
|
|
29
37
|
```powershell
|
|
30
38
|
# 从 GitHub 安装(推荐锁定 tag)
|
|
31
|
-
dsh plugin --profile desktop add github:XinSpark/dsh-win-pwsh7#v0.2.
|
|
39
|
+
dsh plugin --profile desktop add github:XinSpark/dsh-win-pwsh7#v0.2.3
|
|
32
40
|
# 或 npm
|
|
33
41
|
dsh plugin --profile desktop add dsh-win-pwsh7
|
|
34
42
|
```
|
|
@@ -61,6 +69,7 @@ dsh plugin --profile desktop add dsh-win-pwsh7
|
|
|
61
69
|
| 键 | 默认 | 说明 |
|
|
62
70
|
|---|---|---|
|
|
63
71
|
| `overridePath` | 未设 | 优先级①的显式 `pwsh.exe`。必须能启动并报告 PowerShell 7+;未通过校验就回落到自动探测。**只影响探测与推荐,不接线**。 |
|
|
72
|
+
| `dshOpsCompat` | `true` | 与 dsh-ops 共存(默认开启):自动写入并启用兼容模式,隐藏重复的官方工具,保留 `pwsh` + `ops_bash`。需要重启。 |
|
|
64
73
|
|
|
65
74
|
`overridePath` 是 `volatile()`:插件页配置区保存即生效;也可以写进 profile 补丁:
|
|
66
75
|
|
|
@@ -96,7 +105,7 @@ The executor resolves to the version-stable Store family alias: … Nothing to c
|
|
|
96
105
|
|
|
97
106
|
## 插件页
|
|
98
107
|
|
|
99
|
-
|
|
108
|
+
插件页的状态区是原生卡片,显示状态、运行时和执行器路径,右上角有「重新检测」;配置区有 `overridePath` 和默认开启的「与 dsh-ops 共存」原生开关。
|
|
100
109
|
|
|
101
110
|
## 禁用或卸载
|
|
102
111
|
|
package/client.js
CHANGED
|
@@ -12,6 +12,7 @@ window.__ModuleLoader__.load({
|
|
|
12
12
|
id: 'dsh-win-pwsh7',
|
|
13
13
|
factory(require) {
|
|
14
14
|
const React = require('react')
|
|
15
|
+
const { Switch } = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
15
16
|
const h = React.createElement
|
|
16
17
|
const { useEffect, useState, useSyncExternalStore } = React
|
|
17
18
|
|
|
@@ -96,8 +97,10 @@ window.__ModuleLoader__.load({
|
|
|
96
97
|
redetect: 'Re-detect',
|
|
97
98
|
redetecting: 'Detecting…',
|
|
98
99
|
card_title: 'Configuration',
|
|
99
|
-
field_overridePath: '
|
|
100
|
-
overridePath_hint: '
|
|
100
|
+
field_overridePath: 'PowerShell 7 path',
|
|
101
|
+
overridePath_hint: 'Optional explicit pwsh.exe path (`overridePath`). Empty = auto-detect. It must spawn and report PowerShell 7 or newer; the Store family alias is accepted, while a version-numbered Store path is never pinned. This only changes what the plugin probes and recommends — it does not wire the pwsh tool. Use the patch in the status section for that.',
|
|
102
|
+
field_dshOpsCompat: 'Coexist with dsh-ops',
|
|
103
|
+
dshOpsCompat_hint: 'On by default. Keeps the native pwsh tool beside ops_bash by enabling dsh-ops Compatibility and hiding its duplicate official tools. Requires a DSH restart.',
|
|
101
104
|
save: 'Save',
|
|
102
105
|
reset: 'Reset',
|
|
103
106
|
saved: 'Saved {at}',
|
|
@@ -138,8 +141,10 @@ window.__ModuleLoader__.load({
|
|
|
138
141
|
redetect: '重新检测',
|
|
139
142
|
redetecting: '检测中…',
|
|
140
143
|
card_title: '配置',
|
|
141
|
-
field_overridePath: '
|
|
142
|
-
overridePath_hint: '
|
|
144
|
+
field_overridePath: 'PowerShell 7 路径',
|
|
145
|
+
overridePath_hint: '可选的显式 pwsh.exe 路径(`overridePath`)。留空 = 自动探测。必须能启动并报告 PowerShell 7 及以上;Store 家族别名可用,版本号目录永不被 pin。这里只影响插件的探测与推荐,不会给 pwsh 工具接线;接线请用状态区里的补丁。',
|
|
146
|
+
field_dshOpsCompat: '与 dsh-ops 共存',
|
|
147
|
+
dshOpsCompat_hint: '默认开启。通过启用 dsh-ops 的 Compatibility 模式并隐藏重复的官方工具,让原生 pwsh 与 ops_bash 并存。需要重启 DSH。',
|
|
143
148
|
save: '保存',
|
|
144
149
|
reset: '重置',
|
|
145
150
|
saved: '已保存 {at}',
|
|
@@ -181,6 +186,8 @@ window.__ModuleLoader__.load({
|
|
|
181
186
|
.dw7-input:focus-visible{outline:none;border-color:var(--dsw-alias-state-business-primary);}
|
|
182
187
|
.dw7-input:disabled{color:var(--dsw-alias-label-tertiary);cursor:default;}
|
|
183
188
|
.dw7-hint{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5;}
|
|
189
|
+
.dw7-switch-row{display:flex;align-items:flex-start;gap:16px;min-width:0;}
|
|
190
|
+
.dw7-switch-text{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:4px;}
|
|
184
191
|
.dw7-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding-top:4px;}
|
|
185
192
|
.dw7-save{appearance:none;border:1px solid transparent;border-radius:var(--dsw-radius-md);padding:5px 14px;font:inherit;font-size:13px;line-height:1.5;cursor:pointer;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);}
|
|
186
193
|
.dw7-save:disabled{opacity:.4;cursor:default;}
|
|
@@ -415,9 +422,10 @@ window.__ModuleLoader__.load({
|
|
|
415
422
|
const unavailable = snap.status === 'unavailable'
|
|
416
423
|
const served = {
|
|
417
424
|
overridePath: typeof snap.value?.overridePath === 'string' ? snap.value.overridePath : '',
|
|
425
|
+
dshOpsCompat: snap.value?.dshOpsCompat !== false,
|
|
418
426
|
}
|
|
419
427
|
const values = draft ?? served
|
|
420
|
-
const dirty = draft !== null && values.overridePath !== served.overridePath
|
|
428
|
+
const dirty = draft !== null && (values.overridePath !== served.overridePath || values.dshOpsCompat !== served.dshOpsCompat)
|
|
421
429
|
const editable = !loading && !unavailable && snap.writable !== false && !busy
|
|
422
430
|
|
|
423
431
|
async function save() {
|
|
@@ -431,6 +439,9 @@ window.__ModuleLoader__.load({
|
|
|
431
439
|
} else if (nextPath !== served.overridePath) {
|
|
432
440
|
ok = await form.set('overridePath', nextPath) && ok
|
|
433
441
|
}
|
|
442
|
+
if (values.dshOpsCompat !== served.dshOpsCompat) {
|
|
443
|
+
ok = await form.set('dshOpsCompat', values.dshOpsCompat) && ok
|
|
444
|
+
}
|
|
434
445
|
if (ok) {
|
|
435
446
|
setDraft(null)
|
|
436
447
|
setSavedAt(new Date().toLocaleTimeString())
|
|
@@ -462,12 +473,27 @@ window.__ModuleLoader__.load({
|
|
|
462
473
|
disabled: !editable,
|
|
463
474
|
placeholder: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
|
|
464
475
|
onChange: (event) => {
|
|
465
|
-
setDraft({ overridePath: event.target.value })
|
|
476
|
+
setDraft({ ...values, overridePath: event.target.value })
|
|
466
477
|
setNote(null)
|
|
467
478
|
setSavedAt(null)
|
|
468
479
|
},
|
|
469
480
|
}),
|
|
470
481
|
h('p', { className: 'dw7-hint' }, t('overridePath_hint'))),
|
|
482
|
+
h('div', { className: 'dw7-field', key: 'dshOpsCompat' },
|
|
483
|
+
h('div', { className: 'dw7-switch-row' },
|
|
484
|
+
h('div', { className: 'dw7-switch-text' },
|
|
485
|
+
h('div', { className: 'dw7-label' }, t('field_dshOpsCompat')),
|
|
486
|
+
h('p', { className: 'dw7-hint' }, t('dshOpsCompat_hint'))),
|
|
487
|
+
h(Switch, {
|
|
488
|
+
checked: values.dshOpsCompat,
|
|
489
|
+
disabled: !editable,
|
|
490
|
+
label: t('field_dshOpsCompat'),
|
|
491
|
+
onChange: (checked) => {
|
|
492
|
+
setDraft({ ...values, dshOpsCompat: checked })
|
|
493
|
+
setNote(null)
|
|
494
|
+
setSavedAt(null)
|
|
495
|
+
},
|
|
496
|
+
}))),
|
|
471
497
|
h('div', { className: 'dw7-actions', key: 'actions' },
|
|
472
498
|
h('button', {
|
|
473
499
|
className: 'dw7-save',
|
package/cordis.patch.yml
CHANGED
|
@@ -4,3 +4,9 @@
|
|
|
4
4
|
- insert:
|
|
5
5
|
- id: win-pwsh7
|
|
6
6
|
name: dsh-win-pwsh7
|
|
7
|
+
# Coexistence override: keep dsh-ops Compatibility on while this bundle is installed. The
|
|
8
|
+
# loader warns and skips the override when dsh-ops is absent; the profile switch writes the
|
|
9
|
+
# matching disable row only when the user turns coexistence off.
|
|
10
|
+
- id: dsh-ops-compat
|
|
11
|
+
name: dsh-ops/compat
|
|
12
|
+
disabled: false
|
package/index.js
CHANGED
|
@@ -46,6 +46,11 @@ import { Service } from '@deepseek-ai/cordis'
|
|
|
46
46
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
47
47
|
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
|
|
48
48
|
import z from '@deepseek-ai/schemastery'
|
|
49
|
+
import {
|
|
50
|
+
ensureDshOpsCompat,
|
|
51
|
+
mountDshOpsBridge,
|
|
52
|
+
removeDshOpsCompatPatch,
|
|
53
|
+
} from './lib/dsh-ops-bridge.js'
|
|
49
54
|
|
|
50
55
|
export const name = 'dsh-win-pwsh7'
|
|
51
56
|
export const inject = ['subprocess', 'tools', 'configEditor', 'systemPrompt']
|
|
@@ -58,9 +63,13 @@ export const inject = ['subprocess', 'tools', 'configEditor', 'systemPrompt']
|
|
|
58
63
|
* - id: win-pwsh7
|
|
59
64
|
* config:
|
|
60
65
|
* overridePath: 'D:\tools\pwsh\pwsh.exe' # priority ① — validated, must be an ordinary PowerShell 7+
|
|
66
|
+
* dshOpsCompat: true # default; keeps pwsh beside dsh-ops ops_bash
|
|
61
67
|
*/
|
|
62
68
|
export const Config = z.object({
|
|
63
69
|
overridePath: z.string().volatile(),
|
|
70
|
+
// Default-on after the multi-session validation of the pre-restriction bridge. Turning it
|
|
71
|
+
// off removes the managed compatibility override; turning it on restores it.
|
|
72
|
+
dshOpsCompat: z.boolean().default(true).volatile(),
|
|
64
73
|
})
|
|
65
74
|
|
|
66
75
|
/**
|
|
@@ -343,6 +352,27 @@ export function apply(ctx, config) {
|
|
|
343
352
|
const current = () => readRowConfig(ctx, config)
|
|
344
353
|
hostContext = ctx
|
|
345
354
|
const state = runtimeState(ctx)
|
|
355
|
+
const syncCompat = () => {
|
|
356
|
+
void ensureDshOpsCompat(ctx, current()).then((result) => {
|
|
357
|
+
if (result.changed) {
|
|
358
|
+
const action = result.state === 'bundle'
|
|
359
|
+
? 'delegated to the bundle patch'
|
|
360
|
+
: result.state === 'legacy-order'
|
|
361
|
+
? 'kept as a profile fallback because the bundle order is reversed'
|
|
362
|
+
: result.state === 'disabled' ? 'disabled in the profile' : 'removed'
|
|
363
|
+
ctx.logger?.info?.('dsh-win-pwsh7: dsh-ops compatibility %s; restart DSH to apply', action)
|
|
364
|
+
}
|
|
365
|
+
}, (error) => {
|
|
366
|
+
ctx.logger?.warn?.('dsh-win-pwsh7: dsh-ops compatibility patch failed: %s', describe(error))
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
ctx.effect(() => {
|
|
371
|
+
syncCompat()
|
|
372
|
+
return () => { removeDshOpsCompatPatch(ctx) }
|
|
373
|
+
}, 'dsh-win-pwsh7: manage the dsh-ops compatibility patch')
|
|
374
|
+
|
|
375
|
+
ctx.effect(() => mountDshOpsBridge(ctx, current), 'dsh-win-pwsh7: hide duplicate official tools while dsh-ops is compatible')
|
|
346
376
|
|
|
347
377
|
ctx.effect(() => {
|
|
348
378
|
void activationSweep(ctx, current()).then((report) => {
|
|
@@ -473,8 +503,8 @@ export function apply(ctx, config) {
|
|
|
473
503
|
ctx.effect(() => {
|
|
474
504
|
if (typeof ctx.on !== 'function') return
|
|
475
505
|
const disposers = [
|
|
476
|
-
ctx.on('app-boot/config-reload', () => { void refreshGate(ctx, current()) }),
|
|
477
|
-
ctx.on('plugin-manager/changed', () => { void refreshGate(ctx, current()) }),
|
|
506
|
+
ctx.on('app-boot/config-reload', () => { void refreshGate(ctx, current()); syncCompat() }),
|
|
507
|
+
ctx.on('plugin-manager/changed', () => { void refreshGate(ctx, current()); syncCompat() }),
|
|
478
508
|
]
|
|
479
509
|
return () => {
|
|
480
510
|
for (const dispose of disposers) dispose?.()
|
|
@@ -502,11 +532,13 @@ let hostContext = null
|
|
|
502
532
|
function readRowConfig(ctx, fallback) {
|
|
503
533
|
const merged = {
|
|
504
534
|
overridePath: typeof fallback?.overridePath === 'string' ? fallback.overridePath : undefined,
|
|
535
|
+
dshOpsCompat: fallback?.dshOpsCompat !== false,
|
|
505
536
|
}
|
|
506
537
|
try {
|
|
507
538
|
const entry = ctx.configEditor.entries().find((candidate) => candidate?.options?.name === 'dsh-win-pwsh7')
|
|
508
539
|
const values = entry?.options?.config
|
|
509
540
|
if (isSet(values?.overridePath)) merged.overridePath = String(values.overridePath)
|
|
541
|
+
if (values?.dshOpsCompat !== undefined) merged.dshOpsCompat = values.dshOpsCompat !== false
|
|
510
542
|
} catch { /* fall back to the captured config */ }
|
|
511
543
|
return merged
|
|
512
544
|
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional dsh-ops coexistence bridge.
|
|
3
|
+
*
|
|
4
|
+
* The bundle patch declares the compatibility override; this module only owns the switch-off
|
|
5
|
+
* override and the agent-scoped hiding of duplicate official tools. Keeping the enable path
|
|
6
|
+
* declarative is what makes the feature removable with the bundle.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
const isSet = (value) => typeof value === 'string' && value.length > 0
|
|
12
|
+
const describe = (error) => error instanceof Error ? error.message : String(error)
|
|
13
|
+
|
|
14
|
+
/** Markers for the only profile blocks this plugin may own. */
|
|
15
|
+
const DSH_OPS_PATCH_MARKER = '# dsh-win-pwsh7: dsh-ops compatibility (managed)'
|
|
16
|
+
const DSH_OPS_PATCH_BLOCK = `${DSH_OPS_PATCH_MARKER}\n- id: dsh-ops-compat\n disabled: false\n`
|
|
17
|
+
const DSH_OPS_DISABLE_MARKER = '# dsh-win-pwsh7: dsh-ops compatibility disabled (managed)'
|
|
18
|
+
const DSH_OPS_DISABLE_BLOCK = `${DSH_OPS_DISABLE_MARKER}\n- id: dsh-ops-compat\n disabled: true\n`
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The active profile directory. DSH exposes it to profile-launched processes; the
|
|
22
|
+
* plugin-manager instance carries the same directory as a fallback for hosts that do not.
|
|
23
|
+
* @param ctx - the row's Host context.
|
|
24
|
+
* @returns the profile directory, or `undefined` when it cannot be established safely.
|
|
25
|
+
*/
|
|
26
|
+
function profileDirectory(ctx) {
|
|
27
|
+
if (isSet(process.env.DSH_PROFILE_DIR)) return process.env.DSH_PROFILE_DIR
|
|
28
|
+
const manager = ctx.get('pluginManager')
|
|
29
|
+
const directory = manager?.profile?.dir
|
|
30
|
+
return isSet(directory) ? directory : undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whether dsh-ops is installed in this profile, read through the plugin manager's public
|
|
35
|
+
* bundle view. A missing manager or an unreadable view is "not detected", never an error.
|
|
36
|
+
* @param ctx - the row's Host context.
|
|
37
|
+
* @returns true when the dsh-ops bundle is present.
|
|
38
|
+
*/
|
|
39
|
+
async function dshOpsInstalled(ctx) {
|
|
40
|
+
const manager = ctx.get('pluginManager')
|
|
41
|
+
if (manager === undefined || typeof manager.listBundles !== 'function') return false
|
|
42
|
+
try {
|
|
43
|
+
const bundles = await manager.listBundles()
|
|
44
|
+
const ops = Array.isArray(bundles)
|
|
45
|
+
? bundles.find((bundle) => bundle?.name === 'dsh-ops' && bundle.installed !== false)
|
|
46
|
+
: undefined
|
|
47
|
+
return ops !== undefined && Array.isArray(ops.rows) && ops.rows.some((row) => row?.moduleName === 'dsh-ops/compat')
|
|
48
|
+
} catch {
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether dsh-ops is layered before dsh-win-pwsh7. The bundle override can only target a row
|
|
55
|
+
* inserted by an earlier layer; a reversed order would be warned and skipped by the loader.
|
|
56
|
+
* @param ctx - the row's Host context.
|
|
57
|
+
* @returns true when the order is usable or cannot be established.
|
|
58
|
+
*/
|
|
59
|
+
function bundleOrderOk(ctx) {
|
|
60
|
+
const directory = profileDirectory(ctx)
|
|
61
|
+
if (directory === undefined) return true
|
|
62
|
+
try {
|
|
63
|
+
const manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8'))
|
|
64
|
+
const bundles = manifest?.dsh?.profile?.bundles
|
|
65
|
+
if (!Array.isArray(bundles)) return true
|
|
66
|
+
const ops = bundles.indexOf('dsh-ops')
|
|
67
|
+
const pwsh7 = bundles.indexOf('dsh-win-pwsh7')
|
|
68
|
+
return ops < 0 || pwsh7 < 0 || ops < pwsh7
|
|
69
|
+
} catch {
|
|
70
|
+
return true
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read and rewrite the profile patch atomically; a failed write leaves the previous file.
|
|
76
|
+
* @param path - the profile patch file.
|
|
77
|
+
* @param transform - receives the current text and returns the next text.
|
|
78
|
+
* @returns true when the file changed.
|
|
79
|
+
*/
|
|
80
|
+
function rewriteProfilePatch(path, transform) {
|
|
81
|
+
let current = ''
|
|
82
|
+
try {
|
|
83
|
+
current = readFileSync(path, 'utf8')
|
|
84
|
+
} catch {
|
|
85
|
+
current = ''
|
|
86
|
+
}
|
|
87
|
+
const next = transform(current)
|
|
88
|
+
if (next === current) return false
|
|
89
|
+
const temporary = `${path}.${process.pid}.tmp`
|
|
90
|
+
writeFileSync(temporary, next, 'utf8')
|
|
91
|
+
renameSync(temporary, path)
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Remove the marked block only; a user's own `dsh-ops-compat` override is not touched.
|
|
97
|
+
* @param text - the profile patch text.
|
|
98
|
+
* @returns the text without this plugin's block.
|
|
99
|
+
*/
|
|
100
|
+
function withoutCompatBlock(text) {
|
|
101
|
+
return text
|
|
102
|
+
.replace(/# dsh-win-pwsh7: dsh-ops compatibility \(managed\)\r?\n- id: dsh-ops-compat\r?\n disabled: false\r?\n?/u, '')
|
|
103
|
+
.replace(/# dsh-win-pwsh7: dsh-ops compatibility disabled \(managed\)\r?\n- id: dsh-ops-compat\r?\n disabled: true\r?\n?/u, '')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Whether the user already owns a dsh-ops-compat override outside our markers. */
|
|
107
|
+
function hasForeignCompatEntry(text) {
|
|
108
|
+
return /^\s*-\s*(?:\{\s*)?id:\s*['"]?dsh-ops-compat\b/mu.test(withoutCompatBlock(text))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Ensure the bundle owns the enabled state, and the profile owns only an explicit switch-off.
|
|
113
|
+
* @param ctx - the row's Host context.
|
|
114
|
+
* @param config - the live row configuration.
|
|
115
|
+
* @returns `{ changed, state }` for logging; never throws.
|
|
116
|
+
*/
|
|
117
|
+
export async function ensureDshOpsCompat(ctx, config) {
|
|
118
|
+
const directory = profileDirectory(ctx)
|
|
119
|
+
if (directory === undefined) return { changed: false, state: 'no-profile' }
|
|
120
|
+
const path = join(directory, 'cordis.patch.yml')
|
|
121
|
+
const installed = await dshOpsInstalled(ctx)
|
|
122
|
+
const enabled = config?.dshOpsCompat !== false
|
|
123
|
+
if (!installed) {
|
|
124
|
+
return { changed: rewriteProfilePatch(path, (text) => withoutCompatBlock(text)), state: 'not-installed' }
|
|
125
|
+
}
|
|
126
|
+
if (enabled) {
|
|
127
|
+
if (bundleOrderOk(ctx)) {
|
|
128
|
+
// The bundle patch enables compatibility. Remove only legacy managed blocks so an old
|
|
129
|
+
// profile does not keep a duplicate override; a user's own entry is untouched.
|
|
130
|
+
const changed = rewriteProfilePatch(path, (text) => {
|
|
131
|
+
if (!text.includes(DSH_OPS_PATCH_MARKER) && !text.includes(DSH_OPS_DISABLE_MARKER)) return text
|
|
132
|
+
return withoutCompatBlock(text)
|
|
133
|
+
})
|
|
134
|
+
return { changed, state: 'bundle' }
|
|
135
|
+
}
|
|
136
|
+
// Older/foreign profiles can list pwsh7 before dsh-ops; the bundle override would be
|
|
137
|
+
// skipped. Keep the legacy profile enable block as a working fallback.
|
|
138
|
+
const changed = rewriteProfilePatch(path, (text) => {
|
|
139
|
+
if (hasForeignCompatEntry(text)) return text
|
|
140
|
+
if (text.includes(DSH_OPS_PATCH_MARKER)) return text
|
|
141
|
+
const base = withoutCompatBlock(text)
|
|
142
|
+
const normalized = base.trim() === '[]' ? '' : base
|
|
143
|
+
const separator = normalized === '' || normalized.endsWith('\n') ? '' : '\n'
|
|
144
|
+
return `${normalized}${separator}${DSH_OPS_PATCH_BLOCK}`
|
|
145
|
+
})
|
|
146
|
+
return { changed, state: 'legacy-order' }
|
|
147
|
+
}
|
|
148
|
+
const changed = rewriteProfilePatch(path, (text) => {
|
|
149
|
+
// Respect a user-owned override instead of fighting it with a second id.
|
|
150
|
+
if (hasForeignCompatEntry(text)) return withoutCompatBlock(text)
|
|
151
|
+
if (text.includes(DSH_OPS_DISABLE_MARKER)) return text
|
|
152
|
+
const base = withoutCompatBlock(text)
|
|
153
|
+
const normalized = base.trim() === '[]' ? '' : base
|
|
154
|
+
const separator = normalized === '' || normalized.endsWith('\n') ? '' : '\n'
|
|
155
|
+
return `${normalized}${separator}${DSH_OPS_DISABLE_BLOCK}`
|
|
156
|
+
})
|
|
157
|
+
return { changed, state: 'disabled' }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Synchronous best-effort removal for plugin disposal and config changes. */
|
|
161
|
+
export function removeDshOpsCompatPatch(ctx) {
|
|
162
|
+
const directory = profileDirectory(ctx)
|
|
163
|
+
if (directory === undefined) return false
|
|
164
|
+
try {
|
|
165
|
+
return rewriteProfilePatch(join(directory, 'cordis.patch.yml'), (text) => withoutCompatBlock(text))
|
|
166
|
+
} catch (error) {
|
|
167
|
+
ctx.logger?.warn?.('dsh-win-pwsh7: could not remove the dsh-ops compatibility patch: %s', describe(error))
|
|
168
|
+
return false
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The tool-name facts of one agent, read from the host's pre-restriction view when it is
|
|
174
|
+
* available.
|
|
175
|
+
*
|
|
176
|
+
* `visible` is POST-restriction: hiding read/glob/grep removes them from it, so the next
|
|
177
|
+
* reconcile pass no longer saw a counterpart pair, lifted the mask, saw the pair again, and
|
|
178
|
+
* restored it. Each flip emitted `tools/change`, which called this reconcile again — an
|
|
179
|
+
* endless hide/unhide loop that starved the Host main thread once many agents were created.
|
|
180
|
+
* `knownNames` / `restrictableNames` keep the pre-restriction facts and are also the only
|
|
181
|
+
* names `tools.restrict()` accepts, so the deny list is stable and valid.
|
|
182
|
+
* @param tools - the tool registry.
|
|
183
|
+
* @param agent - the agent scope.
|
|
184
|
+
* @param previousDeny - names this bridge already hid, used to keep older hosts stable.
|
|
185
|
+
* @returns `{ names, restrictable }`, both empty when the surface cannot be read.
|
|
186
|
+
*/
|
|
187
|
+
export function toolNameFacts(tools, agent, previousDeny = []) {
|
|
188
|
+
try {
|
|
189
|
+
const view = tools.view?.(agent)
|
|
190
|
+
const known = view?.knownNames
|
|
191
|
+
if (known !== undefined && typeof known.has === 'function') {
|
|
192
|
+
const restrictable = view?.restrictableNames
|
|
193
|
+
const facts = {
|
|
194
|
+
names: new Set(known),
|
|
195
|
+
restrictable: new Set(restrictable !== undefined && typeof restrictable.has === 'function' ? restrictable : known),
|
|
196
|
+
}
|
|
197
|
+
for (const name of previousDeny) {
|
|
198
|
+
facts.names.add(name)
|
|
199
|
+
facts.restrictable.add(name)
|
|
200
|
+
}
|
|
201
|
+
return facts
|
|
202
|
+
}
|
|
203
|
+
// Older hosts expose only the visible map or the schema projection. It is
|
|
204
|
+
// post-restriction, but unioning the names this bridge already hid keeps a
|
|
205
|
+
// hide/unhide loop from forming in the fallback path too.
|
|
206
|
+
const visible = view?.visible
|
|
207
|
+
let names
|
|
208
|
+
if (visible !== undefined && typeof visible.keys === 'function') {
|
|
209
|
+
names = new Set(visible.keys())
|
|
210
|
+
} else {
|
|
211
|
+
const schemas = tools.schemas?.(agent)
|
|
212
|
+
names = new Set(Array.isArray(schemas) ? schemas.map((schema) => schema.name) : [])
|
|
213
|
+
}
|
|
214
|
+
for (const name of previousDeny) names.add(name)
|
|
215
|
+
return { names, restrictable: new Set(names) }
|
|
216
|
+
} catch {
|
|
217
|
+
return { names: new Set(), restrictable: new Set() }
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The official tools this bridge re-hides when their OPS counterparts are visible. The
|
|
223
|
+
* counterpart test is deliberate: without it the bridge would hide official tools on a
|
|
224
|
+
* machine where dsh-ops is absent or Compatibility is off. Candidates are filtered to the
|
|
225
|
+
* names the host will actually accept in `tools.restrict()`, so a scope-local name such as
|
|
226
|
+
* a session-owned `ops_bash` can never make the whole mask throw.
|
|
227
|
+
* @param facts - the agent's stable tool-name facts.
|
|
228
|
+
* @returns the official names to deny.
|
|
229
|
+
*/
|
|
230
|
+
export function bridgeDenyNames(facts) {
|
|
231
|
+
const { names } = facts
|
|
232
|
+
const deny = []
|
|
233
|
+
if (names.has('ops_inspect_local_file') && names.has('read')) deny.push('read')
|
|
234
|
+
if (names.has('ops_glob') && names.has('glob')) deny.push('glob')
|
|
235
|
+
if (names.has('ops_grep') && names.has('grep')) deny.push('grep')
|
|
236
|
+
if (names.has('ops_bash')) {
|
|
237
|
+
if (names.has('bash')) deny.push('bash')
|
|
238
|
+
// Compatibility would otherwise expose both PowerShell tools beside ops_bash; keep
|
|
239
|
+
// the ordinary `pwsh` and remove the persistent duplicate.
|
|
240
|
+
if (names.has('pwsh') && names.has('pwsh_persistent')) deny.push('pwsh_persistent')
|
|
241
|
+
}
|
|
242
|
+
return deny.filter((name) => facts.restrictable.has(name))
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Keep the official file tools and duplicate shell out of the model's view when dsh-ops
|
|
247
|
+
* Compatibility is on. Agent-scoped `tools.restrict` is the only seam that hides rather
|
|
248
|
+
* than denies; every mask is lifted with its agent or with this effect.
|
|
249
|
+
* @param ctx - the row's Host context.
|
|
250
|
+
* @param current - reads the live row configuration.
|
|
251
|
+
* @returns a disposer for the bridge.
|
|
252
|
+
*/
|
|
253
|
+
export function mountDshOpsBridge(ctx, current) {
|
|
254
|
+
if (typeof ctx.on !== 'function') return
|
|
255
|
+
const tools = ctx.tools ?? ctx.get('tools')
|
|
256
|
+
const agents = ctx.get('agents')
|
|
257
|
+
if (tools === undefined || agents === undefined || typeof tools.restrict !== 'function') return
|
|
258
|
+
const entries = new Map()
|
|
259
|
+
let stopped = false
|
|
260
|
+
let mutating = false
|
|
261
|
+
const lift = (entry) => {
|
|
262
|
+
entry.deny = []
|
|
263
|
+
for (const dispose of entry.lifts.splice(0)) {
|
|
264
|
+
try {
|
|
265
|
+
dispose?.()
|
|
266
|
+
} catch (error) {
|
|
267
|
+
ctx.logger?.warn?.('dsh-win-pwsh7: bridge restriction cleanup failed: %s', describe(error))
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const reconcile = () => {
|
|
272
|
+
if (stopped || mutating) return
|
|
273
|
+
mutating = true
|
|
274
|
+
try {
|
|
275
|
+
const enabled = current()?.dshOpsCompat !== false
|
|
276
|
+
if (!enabled) {
|
|
277
|
+
for (const entry of entries.values()) lift(entry)
|
|
278
|
+
entries.clear()
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
for (const agent of agents.list?.() ?? []) {
|
|
282
|
+
if (!entries.has(agent)) entries.set(agent, { lifts: [], deny: [] })
|
|
283
|
+
}
|
|
284
|
+
for (const [agent, entry] of entries) {
|
|
285
|
+
const deny = bridgeDenyNames(toolNameFacts(tools, agent, entry.deny))
|
|
286
|
+
if (deny.length === entry.deny.length && deny.every((name) => entry.deny.includes(name))) continue
|
|
287
|
+
lift(entry)
|
|
288
|
+
if (deny.length === 0) continue
|
|
289
|
+
try {
|
|
290
|
+
if (agent?.ctx?.tools === undefined) continue
|
|
291
|
+
entry.lifts.push(agent.ctx.tools.restrict({ deny }))
|
|
292
|
+
entry.deny = deny
|
|
293
|
+
} catch (error) {
|
|
294
|
+
ctx.logger?.warn?.('dsh-win-pwsh7: bridge could not hide %s: %s', deny.join(', '), describe(error))
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
} finally {
|
|
298
|
+
mutating = false
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const disposers = []
|
|
302
|
+
if (typeof ctx.on === 'function') {
|
|
303
|
+
disposers.push(ctx.on('agent/created', ({ agent } = {}) => {
|
|
304
|
+
if (agent !== undefined) entries.set(agent, { lifts: [], deny: [] })
|
|
305
|
+
reconcile()
|
|
306
|
+
}))
|
|
307
|
+
disposers.push(ctx.on('agent/disposed', ({ agent } = {}) => {
|
|
308
|
+
const entry = entries.get(agent)
|
|
309
|
+
if (entry !== undefined) {
|
|
310
|
+
lift(entry)
|
|
311
|
+
entries.delete(agent)
|
|
312
|
+
}
|
|
313
|
+
}))
|
|
314
|
+
disposers.push(ctx.on('tools/change', () => { reconcile() }))
|
|
315
|
+
disposers.push(ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
|
316
|
+
reconcile()
|
|
317
|
+
return next()
|
|
318
|
+
}))
|
|
319
|
+
disposers.push(ctx.on('app-boot/config-reload', () => { reconcile() }))
|
|
320
|
+
disposers.push(ctx.on('plugin-manager/changed', () => { reconcile() }))
|
|
321
|
+
}
|
|
322
|
+
reconcile()
|
|
323
|
+
return () => {
|
|
324
|
+
stopped = true
|
|
325
|
+
for (const dispose of disposers) dispose?.()
|
|
326
|
+
for (const entry of entries.values()) lift(entry)
|
|
327
|
+
entries.clear()
|
|
328
|
+
}
|
|
329
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-win-pwsh7",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Detect and validate PowerShell 7 for the Harness pwsh tool on Windows, and install it on request through the Microsoft Store — scripted or manual — while the pwsh tool stays blocked until a PowerShell 7 runtime actually resolves.",
|
|
6
6
|
"engines": {
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"index.js",
|
|
33
33
|
"client.js",
|
|
34
34
|
"lib/typert.host.js",
|
|
35
|
+
"lib/dsh-ops-bridge.js",
|
|
35
36
|
"cordis.patch.yml",
|
|
36
37
|
"locale/*.json",
|
|
37
38
|
"icon.svg",
|
|
@@ -64,7 +65,8 @@
|
|
|
64
65
|
"immediately": true,
|
|
65
66
|
"inject": [
|
|
66
67
|
"@deepseek-ai/dsh-client-ui-plugin-manager",
|
|
67
|
-
"@deepseek-ai/dsh-api-remotes"
|
|
68
|
+
"@deepseek-ai/dsh-api-remotes",
|
|
69
|
+
"@deepseek-ai/dsh-client-ui-primitives"
|
|
68
70
|
]
|
|
69
71
|
}
|
|
70
72
|
}
|
package/tools/check-client.mjs
CHANGED
|
@@ -38,6 +38,18 @@ const React = {
|
|
|
38
38
|
},
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** The native Switch primitive, rendered as the button role the real component uses. */
|
|
42
|
+
function Switch({ checked, onChange, label, disabled }) {
|
|
43
|
+
return React.createElement('button', {
|
|
44
|
+
type: 'button',
|
|
45
|
+
role: 'switch',
|
|
46
|
+
'aria-checked': checked,
|
|
47
|
+
'aria-label': label,
|
|
48
|
+
disabled,
|
|
49
|
+
onClick: () => onChange(!checked),
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
41
53
|
/** Every string rendered anywhere in the tree, in document order. */
|
|
42
54
|
function textsOf(node, found = []) {
|
|
43
55
|
if (typeof node === 'string' || typeof node === 'number') {
|
|
@@ -119,6 +131,7 @@ await import('../client.js')
|
|
|
119
131
|
assert.ok(entry !== undefined, 'client.js did not call window.__ModuleLoader__.load()')
|
|
120
132
|
const client = entry.factory((name) => {
|
|
121
133
|
if (name === 'react') return React
|
|
134
|
+
if (name === '@deepseek-ai/dsh-client-ui-primitives') return { Switch }
|
|
122
135
|
throw new Error(`the client half required an unexpected module: ${name}`)
|
|
123
136
|
})
|
|
124
137
|
client.apply(ctx)
|
|
@@ -141,24 +154,22 @@ const cardTree = card({ view: 'page' })
|
|
|
141
154
|
const cardTexts = textsOf(cardTree)
|
|
142
155
|
|
|
143
156
|
check('the card renders its labels from the dictionaries, never as bare config keys', () => {
|
|
144
|
-
for (const key of ['overridePath']) {
|
|
157
|
+
for (const key of ['overridePath', 'dshOpsCompat']) {
|
|
145
158
|
assert.ok(!cardTexts.includes(key),
|
|
146
159
|
`the card rendered the bare config key "${key}" — a label was written as a literal instead of t()`)
|
|
147
160
|
}
|
|
148
|
-
for (const expected of [dictionaries.zh.field_overridePath]) {
|
|
161
|
+
for (const expected of [dictionaries.zh.field_overridePath, dictionaries.zh.field_dshOpsCompat]) {
|
|
149
162
|
assert.ok(cardTexts.includes(expected), `the card does not render the label "${expected}"`)
|
|
150
163
|
}
|
|
151
164
|
})
|
|
152
|
-
check('
|
|
153
|
-
const
|
|
154
|
-
assert.equal(
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
assert.match(labels[index], /[\u4e00-\u9fff]/u, `label ${index} is not localized: ${labels[index]}`)
|
|
158
|
-
}
|
|
165
|
+
check('the coexist row uses the native Switch primitive, not a custom checkbox', () => {
|
|
166
|
+
const switches = elementsOf(cardTree, Switch)
|
|
167
|
+
assert.equal(switches.length, 1, `expected one native switch, found ${switches.length}`)
|
|
168
|
+
assert.equal(switches[0].props.checked, true)
|
|
169
|
+
assert.equal(switches[0].props.label, dictionaries.zh.field_dshOpsCompat)
|
|
159
170
|
})
|
|
160
171
|
check('the card renders every hint in Chinese', () => {
|
|
161
|
-
for (const hint of [dictionaries.zh.overridePath_hint]) {
|
|
172
|
+
for (const hint of [dictionaries.zh.overridePath_hint, dictionaries.zh.dshOpsCompat_hint]) {
|
|
162
173
|
assert.ok(cardTexts.includes(hint), `the card does not render the hint "${hint.slice(0, 24)}…"`)
|
|
163
174
|
}
|
|
164
175
|
})
|
package/tools/check-host.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* from a table; the install checks run the job body against that stub, never Microsoft Store.
|
|
13
13
|
*/
|
|
14
14
|
import { register } from 'node:module'
|
|
15
|
-
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
15
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
16
16
|
import { dirname, join } from 'node:path'
|
|
17
17
|
import { tmpdir } from 'node:os'
|
|
18
18
|
import assert from 'node:assert/strict'
|
|
@@ -52,6 +52,7 @@ process.env.LOCALAPPDATA = gateHome
|
|
|
52
52
|
process.env.PATH = join(gateHome, 'bin')
|
|
53
53
|
|
|
54
54
|
const { apply } = await import('../index.js')
|
|
55
|
+
const { bridgeDenyNames, toolNameFacts } = await import('../lib/dsh-ops-bridge.js')
|
|
55
56
|
|
|
56
57
|
/** An ordinary executable that exists on every Windows machine; its version comes from the stub. */
|
|
57
58
|
const ORDINARY = 'C:\\Windows\\System32\\cmd.exe'
|
|
@@ -78,7 +79,7 @@ function makeStoreFixtures() {
|
|
|
78
79
|
* A stub Host context: records registrations, prompt sections and job specs, answers probes,
|
|
79
80
|
* spawns nothing. `jobs: false` omits the job registry.
|
|
80
81
|
*/
|
|
81
|
-
function makeContext({ rows = [], versions = {}, jobs = false, shellPath, shellObject } = {}) {
|
|
82
|
+
function makeContext({ rows = [], versions = {}, jobs = false, shellPath, shellObject, pluginManager, agents, visible } = {}) {
|
|
82
83
|
const registered = new Map()
|
|
83
84
|
const sections = []
|
|
84
85
|
const orderNames = []
|
|
@@ -105,6 +106,8 @@ function makeContext({ rows = [], versions = {}, jobs = false, shellPath, shellO
|
|
|
105
106
|
plugin() {},
|
|
106
107
|
get: (key) => {
|
|
107
108
|
if (key === 'configForms') return { get: () => ({}) }
|
|
109
|
+
if (key === 'pluginManager') return pluginManager
|
|
110
|
+
if (key === 'agents') return agents
|
|
108
111
|
if (key === 'jobs' && jobs) return jobRegistry
|
|
109
112
|
// The pwsh tool executes through `ctx.shell`; the executor's own `pwshPath` is the
|
|
110
113
|
// authoritative "what will run" fact. A context without it keeps the row-based reading.
|
|
@@ -129,6 +132,13 @@ function makeContext({ rows = [], versions = {}, jobs = false, shellPath, shellO
|
|
|
129
132
|
if (index >= 0) guards.splice(index, 1)
|
|
130
133
|
}
|
|
131
134
|
},
|
|
135
|
+
restrict() { return () => {} },
|
|
136
|
+
view() {
|
|
137
|
+
return { visible: new Map((visible ?? []).map((name) => [name, {}])) }
|
|
138
|
+
},
|
|
139
|
+
schemas() {
|
|
140
|
+
return (visible ?? []).map((name) => ({ name }))
|
|
141
|
+
},
|
|
132
142
|
},
|
|
133
143
|
on(event, listener) {
|
|
134
144
|
const set = listeners.get(event) ?? new Set()
|
|
@@ -641,6 +651,150 @@ if (existsSync(MSI)) {
|
|
|
641
651
|
else process.env.ProgramFiles = savedProgramFiles
|
|
642
652
|
}
|
|
643
653
|
|
|
654
|
+
// --- dsh-ops compatibility patch ---------------------------------------------------------
|
|
655
|
+
const compatDir = mkdtempSync(join(tmpdir(), 'dsh-win-pwsh7-compat-'))
|
|
656
|
+
const compatPatchPath = join(compatDir, 'cordis.patch.yml')
|
|
657
|
+
writeFileSync(compatPatchPath, '# dsh-win-pwsh7: dsh-ops compatibility (managed)\n- id: dsh-ops-compat\n disabled: false\n- id: keep-me\n name: keep-me\n', 'utf8')
|
|
658
|
+
const savedProfileDir = process.env.DSH_PROFILE_DIR
|
|
659
|
+
process.env.DSH_PROFILE_DIR = compatDir
|
|
660
|
+
const compatMachine = makeContext({
|
|
661
|
+
pluginManager: {
|
|
662
|
+
async listBundles() {
|
|
663
|
+
return [{ name: 'dsh-ops', installed: true, rows: [{ moduleName: 'dsh-ops/compat' }] }]
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
})
|
|
667
|
+
apply(compatMachine.ctx, {})
|
|
668
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
669
|
+
const compatWritten = readFileSync(compatPatchPath, 'utf8')
|
|
670
|
+
check('the enabled path delegates to the bundle and removes a legacy managed override', () => {
|
|
671
|
+
assert.ok(!compatWritten.includes('dsh-win-pwsh7: dsh-ops compatibility'), 'a managed block survived')
|
|
672
|
+
assert.ok(!compatWritten.includes('- id: dsh-ops-compat'), 'the bundle-owned override was written into the profile')
|
|
673
|
+
assert.match(compatWritten, /- id: keep-me/u, 'the user patch entry was lost')
|
|
674
|
+
})
|
|
675
|
+
apply(compatMachine.ctx, { dshOpsCompat: false })
|
|
676
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
677
|
+
const compatDisabled = readFileSync(compatPatchPath, 'utf8')
|
|
678
|
+
check('turning coexistence off writes only the managed disable override', () => {
|
|
679
|
+
assert.match(compatDisabled, /dsh-win-pwsh7: dsh-ops compatibility disabled \(managed\)/u)
|
|
680
|
+
assert.match(compatDisabled, /- id: dsh-ops-compat\n disabled: true/u)
|
|
681
|
+
assert.ok(!compatDisabled.includes('dsh-win-pwsh7: dsh-ops compatibility (managed)'), 'the enable marker survived')
|
|
682
|
+
assert.ok(compatDisabled.includes('- id: keep-me'), 'the user patch entry was removed')
|
|
683
|
+
})
|
|
684
|
+
apply(compatMachine.ctx, { dshOpsCompat: true })
|
|
685
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
686
|
+
const compatReenabled = readFileSync(compatPatchPath, 'utf8')
|
|
687
|
+
check('turning coexistence back on removes the disable override for the bundle', () => {
|
|
688
|
+
assert.ok(!compatReenabled.includes('dsh-win-pwsh7: dsh-ops compatibility'), 'a managed block survived')
|
|
689
|
+
assert.ok(!compatReenabled.includes('- id: dsh-ops-compat'), 'the bundle-owned override was written into the profile')
|
|
690
|
+
assert.ok(compatReenabled.includes('- id: keep-me'), 'the user patch entry was removed')
|
|
691
|
+
})
|
|
692
|
+
if (savedProfileDir === undefined) delete process.env.DSH_PROFILE_DIR
|
|
693
|
+
else process.env.DSH_PROFILE_DIR = savedProfileDir
|
|
694
|
+
rmSync(compatDir, { recursive: true, force: true })
|
|
695
|
+
|
|
696
|
+
check('the bundle patch owns the enabled compatibility override', () => {
|
|
697
|
+
const patch = readFileSync(new URL('../cordis.patch.yml', import.meta.url), 'utf8')
|
|
698
|
+
assert.match(patch, /^- id: dsh-ops-compat\n name: dsh-ops\/compat\n disabled: false$/mu)
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
const orderDir = mkdtempSync(join(tmpdir(), 'dsh-win-pwsh7-order-'))
|
|
702
|
+
writeFileSync(join(orderDir, 'package.json'), JSON.stringify({
|
|
703
|
+
dsh: { profile: { bundles: ['dsh-win-pwsh7', 'dsh-ops'] } },
|
|
704
|
+
}), 'utf8')
|
|
705
|
+
writeFileSync(join(orderDir, 'cordis.patch.yml'), '- id: keep-me\n name: keep-me\n', 'utf8')
|
|
706
|
+
const savedOrderDir = process.env.DSH_PROFILE_DIR
|
|
707
|
+
process.env.DSH_PROFILE_DIR = orderDir
|
|
708
|
+
const orderMachine = makeContext({
|
|
709
|
+
pluginManager: {
|
|
710
|
+
async listBundles() {
|
|
711
|
+
return [{ name: 'dsh-ops', installed: true, rows: [{ moduleName: 'dsh-ops/compat' }] }]
|
|
712
|
+
},
|
|
713
|
+
},
|
|
714
|
+
})
|
|
715
|
+
apply(orderMachine.ctx, {})
|
|
716
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
717
|
+
const orderPatch = readFileSync(join(orderDir, 'cordis.patch.yml'), 'utf8')
|
|
718
|
+
check('a reversed bundle order keeps the profile fallback instead of silently disabling compatibility', () => {
|
|
719
|
+
assert.match(orderPatch, /dsh-win-pwsh7: dsh-ops compatibility \(managed\)/u)
|
|
720
|
+
assert.match(orderPatch, /- id: dsh-ops-compat\n disabled: false/u)
|
|
721
|
+
assert.ok(orderPatch.includes('- id: keep-me'))
|
|
722
|
+
})
|
|
723
|
+
if (savedOrderDir === undefined) delete process.env.DSH_PROFILE_DIR
|
|
724
|
+
else process.env.DSH_PROFILE_DIR = savedOrderDir
|
|
725
|
+
rmSync(orderDir, { recursive: true, force: true })
|
|
726
|
+
|
|
727
|
+
// --- agent bridge hides only the duplicate tools ------------------------------------------
|
|
728
|
+
const bridgeDenies = []
|
|
729
|
+
const bridgeAgent = {
|
|
730
|
+
ctx: {
|
|
731
|
+
tools: {
|
|
732
|
+
restrict({ deny }) {
|
|
733
|
+
bridgeDenies.push(deny)
|
|
734
|
+
return () => {}
|
|
735
|
+
},
|
|
736
|
+
},
|
|
737
|
+
},
|
|
738
|
+
}
|
|
739
|
+
const bridgeMachine = makeContext({
|
|
740
|
+
agents: { list: () => [bridgeAgent] },
|
|
741
|
+
visible: [
|
|
742
|
+
'read', 'ops_inspect_local_file',
|
|
743
|
+
'glob', 'ops_glob',
|
|
744
|
+
'grep', 'ops_grep',
|
|
745
|
+
'bash', 'pwsh', 'pwsh_persistent', 'ops_bash',
|
|
746
|
+
],
|
|
747
|
+
})
|
|
748
|
+
apply(bridgeMachine.ctx, {})
|
|
749
|
+
check('dsh-ops bridge hides duplicate official tools but keeps pwsh', () => {
|
|
750
|
+
const deny = bridgeDenies.at(-1) ?? []
|
|
751
|
+
for (const name of ['read', 'glob', 'grep', 'bash', 'pwsh_persistent']) {
|
|
752
|
+
assert.ok(deny.includes(name), `bridge did not hide ${name}: ${deny.join(', ')}`)
|
|
753
|
+
}
|
|
754
|
+
assert.ok(!deny.includes('pwsh'), 'bridge hid the native pwsh tool')
|
|
755
|
+
assert.ok(!deny.includes('ops_bash'), 'bridge hid ops_bash')
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
const bridgeNames = new Set([
|
|
759
|
+
'read', 'ops_inspect_local_file',
|
|
760
|
+
'glob', 'ops_glob',
|
|
761
|
+
'grep', 'ops_grep',
|
|
762
|
+
'bash', 'pwsh', 'pwsh_persistent', 'ops_bash',
|
|
763
|
+
])
|
|
764
|
+
const bridgeRestrictable = new Set([...bridgeNames].filter((name) => name !== 'ops_bash'))
|
|
765
|
+
const bridgeTools = {
|
|
766
|
+
view() {
|
|
767
|
+
return { knownNames: bridgeNames, restrictableNames: bridgeRestrictable }
|
|
768
|
+
},
|
|
769
|
+
}
|
|
770
|
+
check('the bridge plans from pre-restriction names, so hiding cannot flip it', () => {
|
|
771
|
+
const first = bridgeDenyNames(toolNameFacts(bridgeTools, {}, []))
|
|
772
|
+
assert.deepEqual(first, ['read', 'glob', 'grep', 'bash', 'pwsh_persistent'])
|
|
773
|
+
// `visible` is now post-restriction; knownNames is unchanged, as the host's view guarantees.
|
|
774
|
+
const second = bridgeDenyNames(toolNameFacts(bridgeTools, {}, first))
|
|
775
|
+
assert.deepEqual(second, first)
|
|
776
|
+
})
|
|
777
|
+
|
|
778
|
+
check('the fallback view latches the names already hidden instead of oscillating', () => {
|
|
779
|
+
const visible = new Set(bridgeNames)
|
|
780
|
+
const fallbackTools = {
|
|
781
|
+
view() {
|
|
782
|
+
return { visible: new Map([...visible].map((name) => [name, {}])) }
|
|
783
|
+
},
|
|
784
|
+
}
|
|
785
|
+
const first = bridgeDenyNames(toolNameFacts(fallbackTools, {}, []))
|
|
786
|
+
for (const name of first) visible.delete(name)
|
|
787
|
+
const second = bridgeDenyNames(toolNameFacts(fallbackTools, {}, first))
|
|
788
|
+
assert.deepEqual(second, first)
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
check('a scope-local name cannot make the whole restriction plan throw', () => {
|
|
792
|
+
const facts = toolNameFacts(bridgeTools, {}, [])
|
|
793
|
+
const deny = bridgeDenyNames({ ...facts, restrictable: new Set([...bridgeRestrictable].filter((name) => name !== 'glob')) })
|
|
794
|
+
assert.ok(!deny.includes('glob'), 'a non-restrictable name survived the filter')
|
|
795
|
+
assert.ok(!deny.includes('ops_bash'), 'a scope-local counterpart was put in the deny list')
|
|
796
|
+
})
|
|
797
|
+
|
|
644
798
|
// --- The client half's policy copy must not drift (static; no browser needed) -------------
|
|
645
799
|
|
|
646
800
|
const client = readFileSync(new URL('../client.js', import.meta.url), 'utf8')
|