dsh-win-pwsh7 0.2.0 → 0.2.4
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 +47 -0
- package/README.en.md +17 -3
- package/README.md +12 -3
- package/client.js +144 -86
- 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 +47 -15
- package/tools/check-host.mjs +156 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.4 — plugin page aligned with the dsh-ops card language
|
|
4
|
+
|
|
5
|
+
- Restyled both contributions after the installed dsh-ops 0.5.0 branch: a 0.5px hairline card on
|
|
6
|
+
`radius-lg` with a shaded card body, 13px/600 group titles, max-content fact labels, and the
|
|
7
|
+
same input, button and focus-token metrics.
|
|
8
|
+
- Split the configure surface into two cards: the executor path sits in a shaded body, the
|
|
9
|
+
dsh-ops coexistence switch sits in the card head, and save/reset follow the cards.
|
|
10
|
+
- The status section is one card now: title, refresh metric and action in the head, the facts in
|
|
11
|
+
a shaded body, and the profile patch as a sunken preview block.
|
|
12
|
+
- Updated the client gate to assert the card language instead of the retired flat section.
|
|
13
|
+
|
|
14
|
+
## 0.2.3 — removable dsh-ops coexistence bridge
|
|
15
|
+
|
|
16
|
+
- Moved the bridge, profile-patch ownership, and pure name planning into `lib/dsh-ops-bridge.js`.
|
|
17
|
+
`index.js` now mounts it in one place; `package.json` ships the module.
|
|
18
|
+
- Enabled coexistence declaratively from `cordis.patch.yml`; the plugin no longer writes an
|
|
19
|
+
enable override into the profile. Turning the switch off writes only a marked disable override.
|
|
20
|
+
- Removal contract: delete the module, the mount line, the config field, the client switch, and its
|
|
21
|
+
gate; the bundle patch disappears with the package. A switch-off override is the only possible
|
|
22
|
+
profile residue and is removed by the plugin's cleanup when it runs.
|
|
23
|
+
- The bundle patch must come after `dsh-ops` in `dsh.profile.bundles`; otherwise the loader warns
|
|
24
|
+
and skips it. The profile keeps `dsh-ops` before `dsh-win-pwsh7`.
|
|
25
|
+
|
|
26
|
+
## 0.2.2 — fix the dsh-ops bridge freeze
|
|
27
|
+
|
|
28
|
+
- Fixed the 0.2.1 hide/unhide oscillation that could pin the Host main thread when many sessions
|
|
29
|
+
were restored. The bridge now plans from the host's **pre-restriction** `knownNames` /
|
|
30
|
+
`restrictableNames` instead of the post-restriction `visible` set, and filters the deny list to
|
|
31
|
+
names `tools.restrict()` actually accepts. The fallback path latches names it already hid.
|
|
32
|
+
- Added regression checks for the stable plan, the older-host fallback, and the scope-local
|
|
33
|
+
`ops_bash` case that used to throw an unknown-global-tool warning.
|
|
34
|
+
- Restored `dshOpsCompat` to default-on after multi-session validation of the fixed bridge.
|
|
35
|
+
- The configuration page now uses the native `Switch` primitive and native setting-row layout for
|
|
36
|
+
the coexistence toggle; labels and hints no longer expose raw config keys.
|
|
37
|
+
|
|
38
|
+
## 0.2.1 — dsh-ops coexistence bridge
|
|
39
|
+
|
|
40
|
+
- Added a default-on `dshOpsCompat` configuration row. When dsh-ops is installed, pwsh7 writes
|
|
41
|
+
a marked `dsh-ops-compat` override into the active profile patch and enables it, so the native
|
|
42
|
+
`pwsh` tool is visible again alongside `ops_bash`.
|
|
43
|
+
- The agent bridge re-hides the official `read` / `glob` / `grep` tools when their OPS
|
|
44
|
+
counterparts are visible, plus the duplicate official `bash` / `pwsh_persistent` shell. It
|
|
45
|
+
keeps `pwsh` and `ops_bash` and does nothing when dsh-ops is absent.
|
|
46
|
+
- The compatibility patch needs a DSH restart; turning the switch off removes only the block this
|
|
47
|
+
plugin wrote. Uninstall cleanup is best-effort and leaves at most a harmless compatibility row.
|
|
48
|
+
- Added the plugin-page toggle with its own localized row and hint.
|
|
49
|
+
|
|
3
50
|
## 0.2.0 — 优化 Store 版 pwsh7 安装方式
|
|
4
51
|
|
|
5
52
|
优化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.4
|
|
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.4
|
|
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
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// client.js — the browser half of dsh-win-pwsh7: the configure
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// client.js — the browser half of dsh-win-pwsh7: the configure cards and the status card
|
|
2
|
+
// on the plugin pages. Both use the same chrome as the installed dsh-ops branch — a 0.5px
|
|
3
|
+
// hairline card on radius-lg, a shaded card body, 13px/600 titles and max-content fact
|
|
4
|
+
// labels — while the Host page keeps its own 32px rhythm between detail sections.
|
|
4
5
|
//
|
|
5
6
|
// One operation, two callers: the diagnostics section and the re-detect button call the
|
|
6
7
|
// same Host method (`status`) that the `pwsh7_status` agent tool runs — over the Remote
|
|
@@ -12,6 +13,7 @@ window.__ModuleLoader__.load({
|
|
|
12
13
|
id: 'dsh-win-pwsh7',
|
|
13
14
|
factory(require) {
|
|
14
15
|
const React = require('react')
|
|
16
|
+
const { Switch } = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
15
17
|
const h = React.createElement
|
|
16
18
|
const { useEffect, useState, useSyncExternalStore } = React
|
|
17
19
|
|
|
@@ -96,8 +98,12 @@ window.__ModuleLoader__.load({
|
|
|
96
98
|
redetect: 'Re-detect',
|
|
97
99
|
redetecting: 'Detecting…',
|
|
98
100
|
card_title: 'Configuration',
|
|
99
|
-
|
|
100
|
-
|
|
101
|
+
executor_title: 'PowerShell 7 executor',
|
|
102
|
+
executor_hint: 'The runtime the pwsh tool runs. This page only probes and recommends; the profile patch in the status section does the wiring.',
|
|
103
|
+
field_overridePath: 'PowerShell 7 path',
|
|
104
|
+
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.',
|
|
105
|
+
field_dshOpsCompat: 'Coexist with dsh-ops',
|
|
106
|
+
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
107
|
save: 'Save',
|
|
102
108
|
reset: 'Reset',
|
|
103
109
|
saved: 'Saved {at}',
|
|
@@ -138,8 +144,12 @@ window.__ModuleLoader__.load({
|
|
|
138
144
|
redetect: '重新检测',
|
|
139
145
|
redetecting: '检测中…',
|
|
140
146
|
card_title: '配置',
|
|
141
|
-
|
|
142
|
-
|
|
147
|
+
executor_title: 'PowerShell 7 执行器',
|
|
148
|
+
executor_hint: 'pwsh 工具实际运行的 PowerShell。本页只做探测与推荐;接线用状态区里的 profile 补丁。',
|
|
149
|
+
field_overridePath: 'PowerShell 7 路径',
|
|
150
|
+
overridePath_hint: '可选的显式 pwsh.exe 路径(`overridePath`)。留空 = 自动探测。必须能启动并报告 PowerShell 7 及以上;Store 家族别名可用,版本号目录永不被 pin。这里只影响插件的探测与推荐,不会给 pwsh 工具接线;接线请用状态区里的补丁。',
|
|
151
|
+
field_dshOpsCompat: '与 dsh-ops 共存',
|
|
152
|
+
dshOpsCompat_hint: '默认开启。通过启用 dsh-ops 的 Compatibility 模式并隐藏重复的官方工具,让原生 pwsh 与 ops_bash 并存。需要重启 DSH。',
|
|
143
153
|
save: '保存',
|
|
144
154
|
reset: '重置',
|
|
145
155
|
saved: '已保存 {at}',
|
|
@@ -159,46 +169,61 @@ window.__ModuleLoader__.load({
|
|
|
159
169
|
// while its section is mounted, so both sections carry it: a page can render the status
|
|
160
170
|
// section without ever rendering the configure section.
|
|
161
171
|
const CSS = `
|
|
162
|
-
.dw7-
|
|
163
|
-
.dw7-
|
|
164
|
-
.dw7-
|
|
165
|
-
.dw7-
|
|
166
|
-
.dw7-
|
|
167
|
-
.dw7-
|
|
168
|
-
.dw7-
|
|
169
|
-
.dw7-
|
|
170
|
-
.dw7-
|
|
171
|
-
.dw7-
|
|
172
|
-
.dw7-
|
|
173
|
-
.dw7-
|
|
174
|
-
.dw7-
|
|
175
|
-
.dw7-
|
|
176
|
-
.dw7-
|
|
172
|
+
.dw7-settings{display:flex;flex-direction:column;gap:12px;min-width:0;color:var(--dsw-alias-label-primary);font:inherit;}
|
|
173
|
+
.dw7-settings p{margin:0;line-height:1.55;overflow-wrap:anywhere;}
|
|
174
|
+
.dw7-group{display:flex;flex-direction:column;min-width:0;}
|
|
175
|
+
.dw7-card{border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);padding:14px;gap:10px;}
|
|
176
|
+
.dw7-group-head{display:flex;align-items:flex-start;gap:16px;min-width:0;}
|
|
177
|
+
.dw7-card .dw7-group-head{align-items:flex-start;}
|
|
178
|
+
.dw7-group-head .dw7-field-text{display:flex;flex-direction:column;flex:1 1 auto;min-width:0;gap:2px;}
|
|
179
|
+
.dw7-group-head > button[role=switch]{flex:none;margin-top:1px;}
|
|
180
|
+
.dw7-group-title{margin:0 0 4px;font-size:13px;font-weight:600;line-height:20px;color:var(--dsw-alias-label-secondary);}
|
|
181
|
+
.dw7-card .dw7-group-title{margin:0;}
|
|
182
|
+
.dw7-card-body{display:flex;flex-direction:column;min-width:0;padding:12px;border-radius:var(--dsw-radius-md);background:var(--dsw-alias-interactive-bg-hover);}
|
|
183
|
+
.dw7-card-body > .dw7-field{padding:10px 0;}
|
|
184
|
+
.dw7-card-body > .dw7-field:first-child{padding-top:0;}
|
|
185
|
+
.dw7-card-body > .dw7-field:last-child{padding-bottom:0;}
|
|
186
|
+
.dw7-card-body .dw7-field + .dw7-field{border-top:.5px solid var(--dsw-alias-border-l2);}
|
|
177
187
|
.dw7-field{display:flex;flex-direction:column;gap:6px;padding:12px 0;min-width:0;}
|
|
178
188
|
.dw7-field + .dw7-field{border-top:.5px solid var(--dsw-alias-border-l2);}
|
|
179
|
-
.dw7-
|
|
180
|
-
.dw7-
|
|
189
|
+
.dw7-field-text{display:flex;flex-direction:column;flex:1 1 auto;gap:4px;min-width:0;}
|
|
190
|
+
.dw7-field-label{font-size:13px;font-weight:500;line-height:1.5;color:var(--dsw-alias-label-primary);}
|
|
191
|
+
.dw7-field-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary);}
|
|
192
|
+
.dw7-input{box-sizing:border-box;width:100%;height:34px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:1.5;}
|
|
181
193
|
.dw7-input:focus-visible{outline:none;border-color:var(--dsw-alias-state-business-primary);}
|
|
182
194
|
.dw7-input:disabled{color:var(--dsw-alias-label-tertiary);cursor:default;}
|
|
183
|
-
.dw7-
|
|
195
|
+
.dw7-input[aria-invalid=true]{border-color:var(--dsw-alias-state-error-primary);}
|
|
196
|
+
.dw7-field-error{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-state-error-primary);}
|
|
184
197
|
.dw7-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding-top:4px;}
|
|
185
198
|
.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
199
|
.dw7-save:disabled{opacity:.4;cursor:default;}
|
|
187
|
-
.dw7-save:focus-visible,.dw7-reset:focus-visible,.dw7-small:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:1px;}
|
|
188
200
|
.dw7-reset{border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer;}
|
|
189
|
-
.dw7-reset:
|
|
190
|
-
.dw7-reset:disabled{cursor:default;}
|
|
201
|
+
.dw7-reset:disabled{opacity:.4;cursor:default;}
|
|
191
202
|
.dw7-small{box-sizing:border-box;height:28px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-sm);background:transparent;font:inherit;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary);cursor:pointer;}
|
|
192
|
-
.dw7-small:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);}
|
|
193
203
|
.dw7-small:disabled{opacity:.4;cursor:not-allowed;}
|
|
204
|
+
.dw7-detail{display:flex;flex-direction:column;gap:12px;min-width:0;}
|
|
205
|
+
.dw7-detail-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;min-width:0;}
|
|
206
|
+
.dw7-detail-title{margin:0;flex:none;font-size:13px;font-weight:600;line-height:20px;color:var(--dsw-alias-label-primary);}
|
|
207
|
+
.dw7-detail-metric{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);}
|
|
208
|
+
.dw7-detail-action{margin-left:auto;flex:none;}
|
|
209
|
+
.dw7-status-card{display:flex;flex-direction:column;gap:10px;padding:14px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);min-width:0;}
|
|
210
|
+
.dw7-status-body{padding:12px;border-radius:var(--dsw-radius-md);background:var(--dsw-alias-interactive-bg-hover);min-width:0;}
|
|
211
|
+
.dw7-status-lead{margin:0 0 12px;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1.6;}
|
|
212
|
+
.dw7-facts{display:grid;gap:6px;margin:0;}
|
|
213
|
+
.dw7-facts > div{display:grid;grid-template-columns:max-content minmax(0,1fr);align-items:baseline;column-gap:14px;}
|
|
214
|
+
.dw7-facts dt{color:var(--dsw-alias-label-tertiary);font-size:13px;}
|
|
215
|
+
.dw7-facts dd{margin:0;overflow-wrap:anywhere;font-size:13px;}
|
|
216
|
+
.dw7-status-note{margin:12px 0 0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.6;}
|
|
217
|
+
.dw7-note{margin:0;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-tertiary);}
|
|
194
218
|
.dw7-report{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.6;white-space:pre-wrap;overflow-wrap:anywhere;}
|
|
195
|
-
.dw7-empty{margin:0;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px;}
|
|
196
219
|
.dw7-error{margin:0;color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;overflow-wrap:anywhere;}
|
|
197
220
|
.dw7-ok{margin:0;color:var(--dsw-alias-state-success-primary);font-size:12px;line-height:1.5;}
|
|
198
|
-
.dw7-
|
|
199
|
-
.dw7-
|
|
200
|
-
.dw7-
|
|
221
|
+
.dw7-preview{display:flex;flex-direction:column;gap:10px;padding:12px;border-radius:var(--dsw-radius-md);background:var(--dsw-alias-interactive-bg-hover);font-size:13px;}
|
|
222
|
+
.dw7-preview-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0;}
|
|
223
|
+
.dw7-preview-note{margin:0;flex:1;min-width:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5;}
|
|
201
224
|
.dw7-code{box-sizing:border-box;margin:0;padding:10px 12px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);font-size:12px;line-height:1.6;white-space:pre-wrap;overflow-wrap:anywhere;}
|
|
225
|
+
.dw7-save:focus-visible,.dw7-reset:focus-visible,.dw7-small:focus-visible,.dw7-input:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:1px;}
|
|
226
|
+
@media(max-width:440px){.dw7-facts > div{grid-template-columns:1fr;gap:2px;}}
|
|
202
227
|
`
|
|
203
228
|
|
|
204
229
|
return {
|
|
@@ -362,40 +387,43 @@ window.__ModuleLoader__.load({
|
|
|
362
387
|
}
|
|
363
388
|
}
|
|
364
389
|
const patchBlock = patch !== null
|
|
365
|
-
? h('div', { className: 'dw7-
|
|
366
|
-
h('div', { className: 'dw7-
|
|
367
|
-
h('p', { className: 'dw7-
|
|
368
|
-
h('button', { className: 'dw7-small', onClick: () => void copyPatch() }, copied ? t('copied') : t('copy_patch')),
|
|
390
|
+
? h('div', { className: 'dw7-preview' },
|
|
391
|
+
h('div', { className: 'dw7-preview-head' },
|
|
392
|
+
h('p', { className: 'dw7-preview-note' }, t('patch_note')),
|
|
393
|
+
h('button', { type: 'button', className: 'dw7-small', onClick: () => void copyPatch() }, copied ? t('copied') : t('copy_patch')),
|
|
369
394
|
copyNote ? h('span', { className: 'dw7-error' }, copyNote) : null),
|
|
370
395
|
h('pre', { className: 'dw7-code' }, patch))
|
|
371
396
|
: null
|
|
372
|
-
|
|
397
|
+
const body = fallback
|
|
398
|
+
? h('p', { className: 'dw7-report', key: 'report' }, report)
|
|
399
|
+
: h('div', { className: 'dw7-status-body', key: 'body' },
|
|
400
|
+
h('p', { className: 'dw7-status-lead' }, view.lead),
|
|
401
|
+
h('dl', { className: 'dw7-facts' },
|
|
402
|
+
h('div', { key: 'status' },
|
|
403
|
+
h('dt', null, t('status_row_status')),
|
|
404
|
+
h('dd', null, view.status)),
|
|
405
|
+
h('div', { key: 'runtime' },
|
|
406
|
+
h('dt', null, t('status_row_runtime')),
|
|
407
|
+
h('dd', null, view.runtime)),
|
|
408
|
+
h('div', { key: 'path' },
|
|
409
|
+
h('dt', null, t('status_row_path')),
|
|
410
|
+
h('dd', null, path ?? t('status_no_path')))),
|
|
411
|
+
h('p', { className: 'dw7-status-note' }, view.note))
|
|
412
|
+
return h('section', { className: 'dw7-detail' },
|
|
373
413
|
h('style', null, CSS),
|
|
374
|
-
h('div', { className: 'dw7-status-
|
|
375
|
-
h('
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
h('div', { key: 'status' },
|
|
388
|
-
h('dt', null, t('status_row_status')),
|
|
389
|
-
h('dd', null, view.status)),
|
|
390
|
-
h('div', { key: 'runtime' },
|
|
391
|
-
h('dt', null, t('status_row_runtime')),
|
|
392
|
-
h('dd', null, view.runtime)),
|
|
393
|
-
h('div', { key: 'path' },
|
|
394
|
-
h('dt', null, t('status_row_path')),
|
|
395
|
-
h('dd', null, path ?? t('status_no_path')))),
|
|
396
|
-
h('small', { className: 'dw7-note' }, view.note)),
|
|
397
|
-
patchBlock,
|
|
398
|
-
snap.error !== null ? h('p', { className: 'dw7-error' }, t('status_error', { error: snap.error })) : null,
|
|
414
|
+
h('div', { className: 'dw7-status-card' },
|
|
415
|
+
h('div', { className: 'dw7-detail-head' },
|
|
416
|
+
h('h3', { className: 'dw7-detail-title' }, t('diagnostics_title')),
|
|
417
|
+
snap.at ? h('span', { className: 'dw7-detail-metric' }, t('refreshed_at', { at: snap.at })) : null,
|
|
418
|
+
h('button', {
|
|
419
|
+
type: 'button',
|
|
420
|
+
className: 'dw7-small dw7-detail-action',
|
|
421
|
+
onClick: () => void refresh(),
|
|
422
|
+
disabled: snap.state === 'loading',
|
|
423
|
+
}, snap.state === 'loading' ? t('redetecting') : t('redetect'))),
|
|
424
|
+
body,
|
|
425
|
+
patchBlock,
|
|
426
|
+
snap.error !== null ? h('p', { className: 'dw7-error' }, t('status_error', { error: snap.error })) : null),
|
|
399
427
|
)
|
|
400
428
|
}
|
|
401
429
|
|
|
@@ -415,9 +443,10 @@ window.__ModuleLoader__.load({
|
|
|
415
443
|
const unavailable = snap.status === 'unavailable'
|
|
416
444
|
const served = {
|
|
417
445
|
overridePath: typeof snap.value?.overridePath === 'string' ? snap.value.overridePath : '',
|
|
446
|
+
dshOpsCompat: snap.value?.dshOpsCompat !== false,
|
|
418
447
|
}
|
|
419
448
|
const values = draft ?? served
|
|
420
|
-
const dirty = draft !== null && values.overridePath !== served.overridePath
|
|
449
|
+
const dirty = draft !== null && (values.overridePath !== served.overridePath || values.dshOpsCompat !== served.dshOpsCompat)
|
|
421
450
|
const editable = !loading && !unavailable && snap.writable !== false && !busy
|
|
422
451
|
|
|
423
452
|
async function save() {
|
|
@@ -431,6 +460,9 @@ window.__ModuleLoader__.load({
|
|
|
431
460
|
} else if (nextPath !== served.overridePath) {
|
|
432
461
|
ok = await form.set('overridePath', nextPath) && ok
|
|
433
462
|
}
|
|
463
|
+
if (values.dshOpsCompat !== served.dshOpsCompat) {
|
|
464
|
+
ok = await form.set('dshOpsCompat', values.dshOpsCompat) && ok
|
|
465
|
+
}
|
|
434
466
|
if (ok) {
|
|
435
467
|
setDraft(null)
|
|
436
468
|
setSavedAt(new Date().toLocaleTimeString())
|
|
@@ -444,37 +476,63 @@ window.__ModuleLoader__.load({
|
|
|
444
476
|
}
|
|
445
477
|
}
|
|
446
478
|
|
|
447
|
-
return h('
|
|
479
|
+
return h('form', {
|
|
480
|
+
className: 'dw7-settings',
|
|
481
|
+
onSubmit: (event) => {
|
|
482
|
+
event.preventDefault()
|
|
483
|
+
void save()
|
|
484
|
+
},
|
|
485
|
+
},
|
|
448
486
|
h('style', null, CSS),
|
|
449
|
-
h('div', { className: 'dw7-head' },
|
|
450
|
-
h('h4', { className: 'dw7-title' }, t('card_title'))),
|
|
451
487
|
loading || unavailable
|
|
452
|
-
? h('p', { className: 'dw7-
|
|
488
|
+
? h('p', { className: 'dw7-note' }, loading ? t('form_loading') : t('form_unavailable'))
|
|
453
489
|
: [
|
|
454
|
-
h('
|
|
455
|
-
h('
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
490
|
+
h('section', { className: 'dw7-group dw7-card', key: 'executor' },
|
|
491
|
+
h('div', { className: 'dw7-group-head' },
|
|
492
|
+
h('div', { className: 'dw7-field-text' },
|
|
493
|
+
h('h4', { className: 'dw7-group-title' }, t('executor_title')),
|
|
494
|
+
h('p', { className: 'dw7-field-hint' }, t('executor_hint')))),
|
|
495
|
+
h('div', { className: 'dw7-card-body' },
|
|
496
|
+
h('div', { className: 'dw7-field' },
|
|
497
|
+
h('label', { className: 'dw7-field-label', htmlFor: 'dw7-override-path' }, t('field_overridePath')),
|
|
498
|
+
h('input', {
|
|
499
|
+
id: 'dw7-override-path',
|
|
500
|
+
className: 'dw7-input',
|
|
501
|
+
type: 'text',
|
|
502
|
+
spellCheck: false,
|
|
503
|
+
value: values.overridePath,
|
|
504
|
+
disabled: !editable,
|
|
505
|
+
placeholder: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
|
|
506
|
+
onChange: (event) => {
|
|
507
|
+
setDraft({ ...values, overridePath: event.target.value })
|
|
508
|
+
setNote(null)
|
|
509
|
+
setSavedAt(null)
|
|
510
|
+
},
|
|
511
|
+
}),
|
|
512
|
+
h('p', { className: 'dw7-field-hint' }, t('overridePath_hint'))))),
|
|
513
|
+
h('section', { className: 'dw7-group dw7-card', key: 'compat' },
|
|
514
|
+
h('div', { className: 'dw7-group-head' },
|
|
515
|
+
h('div', { className: 'dw7-field-text' },
|
|
516
|
+
h('h4', { className: 'dw7-group-title' }, t('field_dshOpsCompat')),
|
|
517
|
+
h('p', { className: 'dw7-field-hint' }, t('dshOpsCompat_hint'))),
|
|
518
|
+
h(Switch, {
|
|
519
|
+
checked: values.dshOpsCompat,
|
|
520
|
+
disabled: !editable,
|
|
521
|
+
label: t('field_dshOpsCompat'),
|
|
522
|
+
onChange: (checked) => {
|
|
523
|
+
setDraft({ ...values, dshOpsCompat: checked })
|
|
524
|
+
setNote(null)
|
|
525
|
+
setSavedAt(null)
|
|
526
|
+
},
|
|
527
|
+
}))),
|
|
471
528
|
h('div', { className: 'dw7-actions', key: 'actions' },
|
|
472
529
|
h('button', {
|
|
530
|
+
type: 'submit',
|
|
473
531
|
className: 'dw7-save',
|
|
474
|
-
onClick: () => void save(),
|
|
475
532
|
disabled: !editable || (!dirty && note === null),
|
|
476
533
|
}, t('save')),
|
|
477
534
|
h('button', {
|
|
535
|
+
type: 'button',
|
|
478
536
|
className: 'dw7-reset',
|
|
479
537
|
onClick: () => {
|
|
480
538
|
setDraft(null)
|
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.4",
|
|
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,34 +154,41 @@ 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
|
})
|
|
165
176
|
check('the summary view renders nothing, as the row list expects', () => {
|
|
166
177
|
assert.equal(card({ view: 'summary' }), null)
|
|
167
178
|
})
|
|
168
|
-
check('the configure surface
|
|
179
|
+
check('the configure surface uses the dsh-ops card language', () => {
|
|
180
|
+
// Aligned with the installed dsh-ops branch: one hairline card per group, a shaded
|
|
181
|
+
// card body for editable fields, and the toggle in the card head.
|
|
169
182
|
const sections = elementsOf(cardTree, 'section')
|
|
170
|
-
assert.equal(sections.length,
|
|
171
|
-
|
|
183
|
+
assert.equal(sections.length, 2, `expected two cards, found ${sections.length}`)
|
|
184
|
+
for (const section of sections) {
|
|
185
|
+
assert.match(String(section.props.className), /\bdw7-card\b/u)
|
|
186
|
+
assert.match(String(section.props.className), /\bdw7-group\b/u)
|
|
187
|
+
}
|
|
188
|
+
const bodies = elementsOf(cardTree, 'div').filter(node => node.props.className === 'dw7-card-body')
|
|
189
|
+
assert.equal(bodies.length, 1, `expected one shaded body for the editable card, found ${bodies.length}`)
|
|
190
|
+
assert.ok(!elementsOf(cardTree, 'section').some(node => /\bdw7-section\b/u.test(String(node.props.className))),
|
|
191
|
+
'the retired flat section class survived')
|
|
172
192
|
})
|
|
173
193
|
|
|
174
194
|
// A second application with no controller for this row — the state that used to throw inside the
|
|
@@ -245,16 +265,28 @@ check('the en dictionary carries no Chinese', () => {
|
|
|
245
265
|
const cjk = en.match(/[\u4e00-\u9fff]/gu)
|
|
246
266
|
assert.equal(cjk, null, `Chinese in the en dictionary: ${cjk === null ? '' : cjk.join('')}`)
|
|
247
267
|
})
|
|
248
|
-
check('one
|
|
268
|
+
check('one card-language stylesheet covers both surfaces', () => {
|
|
249
269
|
assert.ok(/const CSS = `/u.test(source), 'the shared stylesheet is missing')
|
|
250
270
|
assert.ok(!/CSS_CARD|CSS_DIAG/u.test(source), 'the old per-card stylesheets survived')
|
|
251
|
-
for (const selector of ['.dw7-
|
|
271
|
+
for (const selector of ['.dw7-card{', '.dw7-card-body{', '.dw7-group-title{', '.dw7-detail{', '.dw7-status-card{',
|
|
272
|
+
'.dw7-status-body{', '.dw7-facts{', '.dw7-input{', '.dw7-save{', '.dw7-small{', '.dw7-code{']) {
|
|
252
273
|
assert.ok(source.includes(selector), `the stylesheet does not define ${selector}`)
|
|
253
274
|
}
|
|
275
|
+
// The dsh-ops branch's card chrome: a 0.5px hairline on radius-lg with a shaded body,
|
|
276
|
+
// 13px/600 group titles, and description-list labels sized to their own text.
|
|
277
|
+
assert.ok(source.includes('border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);padding:14px;gap:10px;'),
|
|
278
|
+
'the card chrome no longer matches the dsh-ops card')
|
|
279
|
+
assert.ok(source.includes('background:var(--dsw-alias-interactive-bg-hover);'),
|
|
280
|
+
'the card body no longer uses the sunken surface')
|
|
281
|
+
assert.ok(source.includes('font-size:13px;font-weight:600;line-height:20px;'),
|
|
282
|
+
'the group title metrics no longer match the dsh-ops card')
|
|
283
|
+
assert.ok(source.includes('grid-template-columns:max-content minmax(0,1fr);align-items:baseline;column-gap:14px;'),
|
|
284
|
+
'the fact list no longer uses the native max-content label column')
|
|
254
285
|
const start = source.indexOf('const CSS = `')
|
|
255
286
|
const css = source.slice(start, source.indexOf('`', start + 20))
|
|
256
287
|
assert.ok(!/#[0-9a-f]{3,8}\b/iu.test(css), 'the stylesheet hard-codes a color instead of using a theme token')
|
|
257
288
|
})
|
|
289
|
+
|
|
258
290
|
check('no metered-network copy or branch remains', () => {
|
|
259
291
|
assert.ok(!/metered/iu.test(source), 'the removed metered-network feature still has copy or a branch')
|
|
260
292
|
})
|
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')
|