dsh-win-pwsh7 0.2.4 → 0.2.6

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 CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.6 — loads on DSH 0.2.2-rc.1 again
4
+
5
+ - **The plugin had gone silently inert on the 0.2.2 desktop build.** 0.2.5 declared
6
+ `engines.dsh: ">=0.2.1-rc.3 <0.2.2-0"`. The loader skips a bundle whose range the running build
7
+ does not satisfy, prints nothing, and reports it only as an entry in `skippedBundles`, so on
8
+ DSH 0.2.2-rc.1 both tools, the guard, the page and the UTF-8 console bridge were absent. The
9
+ only visible trace was `patch: entry "win-pwsh7" not found` on every recomposition, because the
10
+ bundle patch that inserts that row never applied.
11
+ - `engines.dsh` is now `>=0.2.1-rc.3 <0.3.0-0` and `dsh.verifiedAgainst` is `0.2.2-rc.1`. The
12
+ upper bound follows the **minor** line: DSH ships fixes as prereleases of the next patch, so a
13
+ bound on the next patch's prerelease is a bound on "the next DSH update".
14
+ - `tools/check-host.mjs` now fails when the range ends at a patch prerelease
15
+ (`<X.Y.Z-0`) instead of `<X.<minor+1>.0-0`; the check was mutation-tested by restoring the old
16
+ range (red) and putting the new one back (green).
17
+ - `tools/check-native-contracts.mjs` follows the 0.2.2 split of `dsh-tools`: it accepts
18
+ `checkGuards` (0.2.2) or `guardReason` (0.2.1), reads the `Error: <reason>` materialization from
19
+ `lib/pipeline.js` when that file exists, asserts the pre-execute waterfall still runs **before**
20
+ the guards, and drops `ToolRuntime.prepareExecution` (removed upstream; this plugin never used it).
21
+ Green on 0.2.2-rc.1 in 37 checks.
22
+ - `docs/native-contracts.md` records the new baseline, the per-package delta from 0.2.1-rc.4 and
23
+ the new guard symbols. `dsh-tool-pwsh` is byte-identical; `dsh-pwsh-local` only adds
24
+ `PYTHONIOENCODING: utf-8`, so `ENCODING_PREAMBLE` still carries no `chcp` and the UTF-8 bridge
25
+ stays.
26
+ - No tool, prompt, install, gate or page behavior changed.
27
+
28
+ ## 0.2.5 — transitional Windows UTF-8 console bridge
29
+
30
+ - Added `lib/utf8-console-bridge.js`, a removable layer that wraps the same `ctx.shell.execute`
31
+ the `pwsh` tool uses and prefixes Windows commands with `chcp.com 65001 > $null;`. A nested
32
+ `pwsh -File` / `powershell -File` otherwise inherits the console code page (936 on a Simplified
33
+ Chinese machine) and emits GBK, which the Host collector decodes as UTF-8 and renders as U+FFFD.
34
+ - `utf8Console` (default `true`) gates the layer. It fails open when the shell service, the
35
+ executor shape or the config reader is unavailable, and restores the original executor when the
36
+ plugin unloads. No tool, prompt, install or gate behavior changed.
37
+ - `tools/check-utf8-bridge.mjs` covers the prefix, opt-out, idempotence, restore and fail-open paths
38
+ with a fake executor; it runs in the `check` chain.
39
+ - Removal contract: delete `lib/utf8-console-bridge.js`, its import and mount in `index.js`, the
40
+ `utf8Console` config field, the bridge gate and its `check` entry, plus the README section and
41
+ this note. The README records the exact steps and the `grep` acceptance check.
42
+
3
43
  ## 0.2.4 — plugin page aligned with the dsh-ops card language
4
44
 
5
45
  - Restyled both contributions after the installed dsh-ops 0.5.0 branch: a 0.5px hairline card on
package/README.en.md CHANGED
@@ -18,7 +18,10 @@ default executor then chooses the runtime itself, and the plugin still never wri
18
18
  ## Verified environment
19
19
 
20
20
  - Windows 10/11.
21
- - Verified on DSH **0.2.1-rc.3** (see `dsh.verifiedAgainst` in `package.json`).
21
+ - Verified on DSH **0.2.2-rc.1** (see `dsh.verifiedAgainst` in `package.json`).
22
+ - Supported range: `>=0.2.1-rc.3 <0.3.0-0`. Ending the range at the next prerelease makes the
23
+ loader skip this bundle silently after every DSH patch release (no tools, no gate, no UTF-8
24
+ bridge), so the upper bound follows the **minor** line.
22
25
  - The script path needs Microsoft Store; the manual path only needs the Store page.
23
26
  - No administrator rights are required.
24
27
 
@@ -44,7 +47,7 @@ multi-session validation passed, so the switch is default-on again.
44
47
  ## Install this plugin
45
48
 
46
49
  ```powershell
47
- dsh plugin --profile desktop add github:XinSpark/dsh-win-pwsh7#v0.2.4
50
+ dsh plugin --profile desktop add github:XinSpark/dsh-win-pwsh7#v0.2.6
48
51
  # or
49
52
  dsh plugin --profile desktop add dsh-win-pwsh7
50
53
  ```
@@ -83,6 +86,7 @@ The count lives in the current process and resets when DSH restarts.
83
86
  |---|---|---|
84
87
  | `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
88
  | `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. |
89
+ | `utf8Console` | `true` | Transitional: prefixes every `pwsh` tool command with `chcp.com 65001` on Windows, so a nested `pwsh -File` cannot emit GBK under code page 936 and be decoded as UTF-8. Removable once the Host ships the same fix. |
86
90
 
87
91
  `overridePath` is `volatile()`: the plugin-page configuration section saves it live; it can also be
88
92
  set in the profile patch:
@@ -129,6 +133,42 @@ The status section is a native card showing the state, runtime and executor path
129
133
  at the top right; the configuration section exposes `overridePath` and the default-on "Keep native
130
134
  pwsh alongside dsh-ops" native switch.
131
135
 
136
+ ## Windows UTF-8 console (transitional)
137
+
138
+ The Host collector decodes `pwsh` output as UTF-8, but a `pwsh -File` / `powershell -File` started
139
+ inside a tool command inherits the **console code page** (936 on a Simplified Chinese machine) and
140
+ emits GBK, which then renders as `�`. The Host preamble pins the outer process's .NET encoding, not
141
+ the console code page.
142
+
143
+ 0.2.5 adds a transitional layer: it wraps the same `ctx.shell.execute` the `pwsh` tool uses and
144
+ prefixes every Windows command with `chcp.com 65001 > $null;`. It affects only this plugin's users,
145
+ is on by default, can be disabled with `utf8Console: false`, and fails open when the Host shape
146
+ does not match.
147
+
148
+ ### Removal contract
149
+
150
+ After the Host ships the same fix, delete the layer whole:
151
+
152
+ 1. delete `lib/utf8-console-bridge.js`;
153
+ 2. delete the import and `mountUtf8ConsoleBridge(ctx, current)` call in `index.js`;
154
+ 3. delete `Config.utf8Console`;
155
+ 4. delete `tools/check-utf8-bridge.mjs` and its entry in the `package.json` `check` chain;
156
+ 5. delete this section and the 0.2.5 transitional note in CHANGELOG.
157
+
158
+ Acceptance: `grep -rn "utf8-console-bridge\|utf8Console" index.js lib tools` returns nothing.
159
+
160
+ ### Deciding that the Host has fixed it
161
+
162
+ Either signal is enough to start removing the layer:
163
+
164
+ - `@deepseek-ai/dsh-pwsh-local`'s own `ENCODING_PREAMBLE` includes the `chcp.com 65001` switch
165
+ (check `packages/shell/pwsh-local/src/index.ts` upstream, or `lib/index.js` in the installed
166
+ package);
167
+ - with this plugin absent or `utf8Console` off, a nested `pwsh -File` under a CP936 console already
168
+ prints UTF-8.
169
+
170
+ Re-run the nested script in a real CP936 environment before deleting.
171
+
132
172
  ## Disable or uninstall
133
173
 
134
174
  Disabling the plugin removes the `pwsh` gate and the plugin page; it does not uninstall PowerShell 7.
@@ -143,6 +183,7 @@ to 5.1, uninstall the Store build or pin the executor to `powershell.exe` (not r
143
183
  | The Store page opens without an Install button | Confirm the VPN is off, fully quit and restart Microsoft Store, then retry; or use the manual path. |
144
184
  | `pwsh` still runs 5.1 | Run `pwsh7_status`, paste the reported `pwshPath` patch into the profile, and restart; or install the Store build. |
145
185
  | The plugin page is missing | Confirm `dsh-win-pwsh7` is enabled in the current profile, then restart DSH. |
186
+ | Chinese turns into `�` when a command nests `pwsh` | 0.2.5 handles it through `utf8Console`; if you disabled it, turn it back on or prefix the command with `chcp.com 65001 > $null;`. |
146
187
 
147
188
  ## Verification
148
189
 
@@ -150,10 +191,11 @@ to 5.1, uninstall the Store build or pin the executor to `powershell.exe` (not r
150
191
  $env:DSH_APP_DIR = 'E:\FXS\APP\DSH NEXT\resources\app.asar'
151
192
  node tools/check-host.mjs
152
193
  node tools/check-client.mjs
194
+ node tools/check-utf8-bridge.mjs
153
195
  node tools/check-native-contracts.mjs
154
196
  ```
155
197
 
156
- All three gates run offline against a packed installation (`resources/app.asar`); `DSH_APP_DIR`
198
+ All four gates run offline against a packed installation (`resources/app.asar`); `DSH_APP_DIR`
157
199
  accepts the install root, `resources/app`, or `resources/app.asar`.
158
200
 
159
201
  ## Design constraints
@@ -163,7 +205,7 @@ accepts the install root, `resources/app`, or `resources/app.asar`.
163
205
  - No VPN auto-detection and no metered-connection preflight: the user confirms the VPN before an
164
206
  install, and a page-load failure asks again.
165
207
  - No automatic retries; the three-failure breaker ends the loop.
166
- - Run all three gates before publishing; end-to-end installation is release evidence, not part of
208
+ - Run all four gates before publishing; end-to-end installation is release evidence, not part of
167
209
  the automated gate.
168
210
 
169
211
  ## License
package/README.md CHANGED
@@ -13,7 +13,9 @@
13
13
  ## 验证环境
14
14
 
15
15
  - Windows 10/11。
16
- - 已在 DSH **0.2.1-rc.3** 上验证(见 `package.json` 的 `dsh.verifiedAgainst`)。
16
+ - 已在 DSH **0.2.2-rc.1** 上验证(见 `package.json` 的 `dsh.verifiedAgainst`)。
17
+ - 支持范围 `>=0.2.1-rc.3 <0.3.0-0`:收紧到"下一个预发布"会让 loader 在 DSH 每次小版本更新后**静默跳过**本插件
18
+ (不装、不报错、`pwsh` 失去 gate 和 UTF-8 过渡层),所以上界按**次版本**划。
17
19
  - 脚本安装需要 Microsoft Store 可用;手动安装只需要能打开 Store 页面。
18
20
  - 不需要管理员权限。
19
21
 
@@ -36,7 +38,7 @@
36
38
 
37
39
  ```powershell
38
40
  # 从 GitHub 安装(推荐锁定 tag)
39
- dsh plugin --profile desktop add github:XinSpark/dsh-win-pwsh7#v0.2.4
41
+ dsh plugin --profile desktop add github:XinSpark/dsh-win-pwsh7#v0.2.6
40
42
  # 或 npm
41
43
  dsh plugin --profile desktop add dsh-win-pwsh7
42
44
  ```
@@ -70,6 +72,7 @@ dsh plugin --profile desktop add dsh-win-pwsh7
70
72
  |---|---|---|
71
73
  | `overridePath` | 未设 | 优先级①的显式 `pwsh.exe`。必须能启动并报告 PowerShell 7+;未通过校验就回落到自动探测。**只影响探测与推荐,不接线**。 |
72
74
  | `dshOpsCompat` | `true` | 与 dsh-ops 共存(默认开启):自动写入并启用兼容模式,隐藏重复的官方工具,保留 `pwsh` + `ops_bash`。需要重启。 |
75
+ | `utf8Console` | `true` | 过渡层:Windows 下给 `pwsh` 工具的每条命令前加 `chcp.com 65001`,避免嵌套 `pwsh -File` 按 936 输出 GBK 后被按 UTF-8 解码成乱码。宿主修复后可删除。 |
73
76
 
74
77
  `overridePath` 是 `volatile()`:插件页配置区保存即生效;也可以写进 profile 补丁:
75
78
 
@@ -107,6 +110,34 @@ The executor resolves to the version-stable Store family alias: … Nothing to c
107
110
 
108
111
  插件页的状态区是原生卡片,显示状态、运行时和执行器路径,右上角有「重新检测」;配置区有 `overridePath` 和默认开启的「与 dsh-ops 共存」原生开关。
109
112
 
113
+ ## Windows UTF-8 控制台(过渡层)
114
+
115
+ DSH 的 `pwsh` 工具按 UTF-8 解码输出,但命令里再启动的 `pwsh -File` / `powershell -File` 会继承**控制台代码页**(简体中文机器通常是 936),输出 GBK,最终显示为 `�`。宿主的前缀只固定了外层进程的 .NET 编码,没有改控制台代码页。
116
+
117
+ 0.2.5 增加一个过渡层:包装 `pwsh` 工具使用的 `ctx.shell.execute`,在 Windows 上给每条命令前加 `chcp.com 65001 > $null;`。它只影响装了本插件的用户;默认开启,可用 `utf8Console: false` 关闭;宿主接口形状不符时 fail-open,不影响 `pwsh` 工具本身。
118
+
119
+ ### 删除契约
120
+
121
+ 宿主发布同样的修复后,按这五步整块删除:
122
+
123
+ 1. 删 `lib/utf8-console-bridge.js`;
124
+ 2. 删 `index.js` 的 import 和 `mountUtf8ConsoleBridge(ctx, current)` 调用;
125
+ 3. 删 `Config.utf8Console`;
126
+ 4. 删 `tools/check-utf8-bridge.mjs`,并从 `package.json` 的 `check` 链里移除;
127
+ 5. 删本节和 CHANGELOG 里的 0.2.5 过渡说明。
128
+
129
+ 验收:`grep -rn "utf8-console-bridge\|utf8Console" index.js lib tools` 无结果。
130
+
131
+ ### 判定上游已修复
132
+
133
+ 满足任一条件即可开始删除:
134
+
135
+ - `@deepseek-ai/dsh-pwsh-local` 的 `ENCODING_PREAMBLE` 自己包含 `chcp.com 65001`(可查上游
136
+ `packages/shell/pwsh-local/src/index.ts`,或安装包里的 `lib/index.js`);
137
+ - 在 CP936 控制台下,不装/关闭本插件时,命令里嵌套的 `pwsh -File` 已经输出 UTF-8。
138
+
139
+ 删除前先在真实的 CP936 环境复跑一遍嵌套脚本,确认不再需要这层。
140
+
110
141
  ## 禁用或卸载
111
142
 
112
143
  禁用插件只移除 `pwsh` 的 gate 和插件页,不会卸载 PowerShell 7。只要机器上仍有 `pwsh.exe`,默认执行器仍会优先运行它(通常是 Store 版 PowerShell 7);只有找不到 `pwsh.exe` 时才回退到 Windows PowerShell 5.1。要真正回到 5.1,需要卸载 Store 版或把执行器 pin 到 `powershell.exe`,不推荐这样做。
@@ -118,6 +149,7 @@ The executor resolves to the version-stable Store family alias: … Nothing to c
118
149
  | Store 页面打开但没有「安装」按钮 | 确认 VPN 已关闭,完全退出并重启 Microsoft Store 后重试;或改用手动安装。 |
119
150
  | `pwsh` 仍运行 5.1 | 运行 `pwsh7_status`,按报告把 `pwshPath` 补丁粘进 profile 并重启;或安装 Store 版。 |
120
151
  | 插件页看不到 | 确认当前 profile 已启用 `dsh-win-pwsh7`,然后重启 DSH。 |
152
+ | 命令里嵌套 `pwsh` 时中文变 `�` | 0.2.5 默认已由 `utf8Console` 过渡层处理;若手动关掉,重新开启,或在命令前加 `chcp.com 65001 > $null;`。 |
121
153
 
122
154
  ## 验证
123
155
 
@@ -125,17 +157,18 @@ The executor resolves to the version-stable Store family alias: … Nothing to c
125
157
  $env:DSH_APP_DIR = 'E:\FXS\APP\DSH NEXT\resources\app.asar'
126
158
  node tools/check-host.mjs
127
159
  node tools/check-client.mjs
160
+ node tools/check-utf8-bridge.mjs
128
161
  node tools/check-native-contracts.mjs
129
162
  ```
130
163
 
131
- 三套门都可以在打包安装(`resources/app.asar`)上离线复跑;`DSH_APP_DIR` 接安装根目录、`resources/app` 或 `resources/app.asar`。
164
+ 四套门都可以在打包安装(`resources/app.asar`)上离线复跑;`DSH_APP_DIR` 接安装根目录、`resources/app` 或 `resources/app.asar`。
132
165
 
133
166
  ## 设计约束
134
167
 
135
168
  - 判定读执行器自己的 `ctx.shell.pwshPath`,不是扫配置行;`overridePath` 只影响探测与推荐。
136
169
  - 不做 VPN 自动检测、不做计量连接预检;VPN 由用户在安装前确认,页面加载失败时再提示。
137
170
  - 不自动重试;连续失败由熔断终止。
138
- - 提交前跑三套门;端到端安装是发布证据,不是自动化门的一部分。
171
+ - 提交前跑四套门;端到端安装是发布证据,不是自动化门的一部分。
139
172
 
140
173
  ## License
141
174
 
package/index.js CHANGED
@@ -51,6 +51,7 @@ import {
51
51
  mountDshOpsBridge,
52
52
  removeDshOpsCompatPatch,
53
53
  } from './lib/dsh-ops-bridge.js'
54
+ import { mountUtf8ConsoleBridge } from './lib/utf8-console-bridge.js'
54
55
 
55
56
  export const name = 'dsh-win-pwsh7'
56
57
  export const inject = ['subprocess', 'tools', 'configEditor', 'systemPrompt']
@@ -70,6 +71,9 @@ export const Config = z.object({
70
71
  // Default-on after the multi-session validation of the pre-restriction bridge. Turning it
71
72
  // off removes the managed compatibility override; turning it on restores it.
72
73
  dshOpsCompat: z.boolean().default(true).volatile(),
74
+ // Transitional Windows console code page fix, owned by lib/utf8-console-bridge.js.
75
+ // Delete this field together with the bridge and its mount (see README removal contract).
76
+ utf8Console: z.boolean().default(true).volatile(),
73
77
  })
74
78
 
75
79
  /**
@@ -374,6 +378,10 @@ export function apply(ctx, config) {
374
378
 
375
379
  ctx.effect(() => mountDshOpsBridge(ctx, current), 'dsh-win-pwsh7: hide duplicate official tools while dsh-ops is compatible')
376
380
 
381
+ // Transitional, removable: delete this mount and lib/utf8-console-bridge.js once the Host
382
+ // pins the console code page itself. The README records the removal contract.
383
+ ctx.effect(() => mountUtf8ConsoleBridge(ctx, current), 'dsh-win-pwsh7: pin the Windows console code page for pwsh output')
384
+
377
385
  ctx.effect(() => {
378
386
  void activationSweep(ctx, current()).then((report) => {
379
387
  ctx.logger?.info?.('dsh-win-pwsh7: %s', flattenReport(report))
@@ -533,12 +541,14 @@ function readRowConfig(ctx, fallback) {
533
541
  const merged = {
534
542
  overridePath: typeof fallback?.overridePath === 'string' ? fallback.overridePath : undefined,
535
543
  dshOpsCompat: fallback?.dshOpsCompat !== false,
544
+ utf8Console: fallback?.utf8Console !== false,
536
545
  }
537
546
  try {
538
547
  const entry = ctx.configEditor.entries().find((candidate) => candidate?.options?.name === 'dsh-win-pwsh7')
539
548
  const values = entry?.options?.config
540
549
  if (isSet(values?.overridePath)) merged.overridePath = String(values.overridePath)
541
550
  if (values?.dshOpsCompat !== undefined) merged.dshOpsCompat = values.dshOpsCompat !== false
551
+ if (values?.utf8Console !== undefined) merged.utf8Console = values.utf8Console !== false
542
552
  } catch { /* fall back to the captured config */ }
543
553
  return merged
544
554
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Transitional Windows UTF-8 console bridge.
3
+ *
4
+ * The Host's `dsh-pwsh-local` collector decodes stdout as UTF-8, but a nested
5
+ * `pwsh -File` / `powershell -File` started by a tool command inherits the console
6
+ * code page (936 on a Simplified Chinese machine) and emits GBK. The Host preamble
7
+ * only pins the .NET output encoding of the outer process; it does not change the
8
+ * console code page. This module wraps the same `ctx.shell` executor the `pwsh` tool
9
+ * uses and prepends `chcp.com 65001` so the code page is right before the command
10
+ * runs.
11
+ *
12
+ * It is deliberately a leaf: no imports from the rest of the plugin, one mount in
13
+ * `index.js`, one config field, and a documented removal contract. Delete this file,
14
+ * the import/mount and the config field when the Host ships the same fix.
15
+ *
16
+ * REMOVE WHEN: the Host's `@deepseek-ai/dsh-pwsh-local` pins the console code page
17
+ * itself (its `ENCODING_PREAMBLE` carries the `chcp.com 65001` switch), or a nested
18
+ * `pwsh -File` under code page 936 already emits UTF-8 without this plugin. Follow
19
+ * the README removal contract in the same change.
20
+ */
21
+
22
+ /** One line of the wrapper's own diagnostics. */
23
+ const describe = (error) => (error instanceof Error ? error.message : String(error))
24
+
25
+ /** Marks an executor this module already wrapped, so HMR or a second mount cannot stack. */
26
+ export const UTF8_CONSOLE_PATCH = Symbol.for('dsh-win-pwsh7.utf8ConsoleBridge')
27
+
28
+ /** A command that already selects UTF-8 must not be prefixed twice. */
29
+ const ALREADY_UTF8 = /^\s*chcp(?:\.com)?\s+65001(?:\s|>|$)/iu
30
+
31
+ /**
32
+ * Prefix a Windows command with the console code page switch.
33
+ *
34
+ * @param {unknown} command - the caller's command text.
35
+ * @returns the command, prefixed once when it is a string and not already pinned.
36
+ */
37
+ export function utf8Command(command) {
38
+ if (typeof command !== 'string' || command.length === 0) return command
39
+ if (ALREADY_UTF8.test(command)) return command
40
+ return `chcp.com 65001 > $null; ${command}`
41
+ }
42
+
43
+ /**
44
+ * Wrap the live shell executor the `pwsh` tool resolves.
45
+ *
46
+ * Fails open: a missing service, a non-writable executor or a shape change leaves
47
+ * the plugin working exactly as before and logs one warning. The wrapper is removed
48
+ * with the plugin's fiber; it never writes files, config or profile state.
49
+ *
50
+ * @param ctx - the row's Host context.
51
+ * @param current - reads the live row configuration.
52
+ * @param platform - injectable for tests; defaults to `process.platform`.
53
+ * @returns void; the wrap is owned by the injected fiber.
54
+ */
55
+ export function mountUtf8ConsoleBridge(ctx, current, platform = process.platform) {
56
+ if (platform !== 'win32') return
57
+ const warn = (message) => ctx.logger?.warn?.(`dsh-win-pwsh7: ${message}`)
58
+ if (typeof ctx.inject !== 'function') return
59
+ try {
60
+ ctx.inject(['shell'], (scoped) => {
61
+ try {
62
+ const viaGet = typeof scoped.get === 'function' ? scoped.get('shell') : undefined
63
+ const shell = viaGet ?? scoped.shell
64
+ if (shell === undefined || shell === null) return
65
+ if (shell[UTF8_CONSOLE_PATCH] === true) return
66
+ const original = shell.execute
67
+ if (typeof original !== 'function') {
68
+ warn('the shell service exposes no execute(); the UTF-8 console bridge stays off')
69
+ return
70
+ }
71
+ shell.execute = function executeWithUtf8Console(spec) {
72
+ try {
73
+ if (current?.()?.utf8Console === false) return original.call(this, spec)
74
+ if (spec === null || typeof spec !== 'object' || typeof spec.command !== 'string') {
75
+ return original.call(this, spec)
76
+ }
77
+ const command = utf8Command(spec.command)
78
+ if (command === spec.command) return original.call(this, spec)
79
+ return original.call(this, { ...spec, command })
80
+ } catch (error) {
81
+ warn(`the UTF-8 console bridge fell open: ${describe(error)}`)
82
+ return original.call(this, spec)
83
+ }
84
+ }
85
+ Object.defineProperty(shell, UTF8_CONSOLE_PATCH, { value: true, configurable: true })
86
+ const restore = () => {
87
+ try {
88
+ shell.execute = original
89
+ } catch { /* the executor was replaced by another owner */ }
90
+ try {
91
+ delete shell[UTF8_CONSOLE_PATCH]
92
+ } catch { /* non-configurable; the marker is advisory */ }
93
+ }
94
+ if (typeof scoped.effect === 'function') {
95
+ scoped.effect(() => restore, 'dsh-win-pwsh7: utf8 console bridge')
96
+ }
97
+ } catch (error) {
98
+ warn(`the UTF-8 console bridge could not attach: ${describe(error)}`)
99
+ }
100
+ })
101
+ } catch (error) {
102
+ warn(`the UTF-8 console bridge could not inject the shell service: ${describe(error)}`)
103
+ }
104
+ }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "dsh-win-pwsh7",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
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": {
7
- "dsh": ">=0.2.1-rc.3 <0.2.2-0"
7
+ "dsh": ">=0.2.1-rc.3 <0.3.0-0"
8
8
  },
9
9
  "license": "MIT",
10
10
  "icon": "./icon.svg",
@@ -33,6 +33,7 @@
33
33
  "client.js",
34
34
  "lib/typert.host.js",
35
35
  "lib/dsh-ops-bridge.js",
36
+ "lib/utf8-console-bridge.js",
36
37
  "cordis.patch.yml",
37
38
  "locale/*.json",
38
39
  "icon.svg",
@@ -51,12 +52,12 @@
51
52
  "windows"
52
53
  ],
53
54
  "scripts": {
54
- "check": "node tools/check-host.mjs && node tools/check-client.mjs && node tools/check-native-contracts.mjs",
55
+ "check": "node tools/check-host.mjs && node tools/check-client.mjs && node tools/check-utf8-bridge.mjs && node tools/check-native-contracts.mjs",
55
56
  "check:contracts": "node tools/check-native-contracts.mjs",
56
57
  "prepublishOnly": "npm run check"
57
58
  },
58
59
  "dsh": {
59
- "verifiedAgainst": "0.2.1-rc.3",
60
+ "verifiedAgainst": "0.2.2-rc.1",
60
61
  "bundle": {
61
62
  "patch": "./cordis.patch.yml"
62
63
  },
@@ -830,6 +830,27 @@ check('client copy: every t() key is defined in both dictionaries', () => {
830
830
  }
831
831
  })
832
832
 
833
+ // --- Manifest: the DSH range must not end at the next prerelease ---------------------------
834
+ //
835
+ // 0.2.5 declared `>=0.2.1-rc.3 <0.2.2-0` and the desktop moved to 0.2.2-rc.1: the loader skips a
836
+ // bundle whose `engines.dsh` the running build does not satisfy, prints nothing, and the whole
837
+ // plugin disappears (no tools, no gate, no UTF-8 bridge) until a new release ships. DSH patches
838
+ // arrive as prereleases, so a bound on the next patch's prerelease is a bound on "the next DSH
839
+ // update". The range therefore ends at the next minor: `<X.<minor+1>.0-0`.
840
+
841
+ check('engines.dsh ends at the next minor, not at the next prerelease', () => {
842
+ const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
843
+ const [major, minor] = manifest.dsh.verifiedAgainst.split('.').map((part) => Number.parseInt(part, 10))
844
+ const terms = manifest.engines.dsh.trim().split(/\s+/u)
845
+ const upper = terms.find((term) => term.startsWith('<'))
846
+ assert.equal(
847
+ upper,
848
+ `<${major}.${minor + 1}.0-0`,
849
+ 'a `<X.Y.Z-0` bound on the next patch prerelease makes the loader skip this bundle after the next DSH release',
850
+ )
851
+ assert.ok(terms.some((term) => term.startsWith('>=')), 'the range needs a lower bound')
852
+ })
853
+
833
854
  // --- Verdict -----------------------------------------------------------------------------
834
855
 
835
856
  if (failures.length > 0) {
@@ -72,6 +72,32 @@ function source(name) {
72
72
  throw new Error(`${name}: neither lib/types/index.js nor lib/index.js exists`)
73
73
  }
74
74
 
75
+ /**
76
+ * Read one named file of an installed package, or '' when the build does not have it.
77
+ *
78
+ * A contract can move between files without being lost: 0.2.2 moved the tool pipeline out of
79
+ * `lib/index.js` into `lib/pipeline.js`. Reading a fixed list and joining what exists keeps the
80
+ * assertion on the behavior when the file split changes, instead of on a remembered path.
81
+ */
82
+ function trySource(name, relative) {
83
+ try {
84
+ return readFileSync(join(packageDir(name), relative), 'utf8')
85
+ } catch {
86
+ return ''
87
+ }
88
+ }
89
+
90
+ /** The tool pipeline: one file up to 0.2.1, split into `lib/pipeline.js` from 0.2.2. */
91
+ function pipeline() {
92
+ return ['lib/index.js', 'lib/pipeline.js'].map((relative) => trySource('dsh-tools', relative)).join('\n')
93
+ }
94
+
95
+ /** The single file that hosts the pre-execute waterfall in this build. */
96
+ function pipelineFile() {
97
+ const split = trySource('dsh-tools', 'lib/pipeline.js')
98
+ return split === '' ? trySource('dsh-tools', 'lib/index.js') : split
99
+ }
100
+
75
101
  let checks = 0
76
102
  /** One assertion with a stable label for the summary. */
77
103
  function check(label, assertion) {
@@ -109,11 +135,30 @@ async function run() {
109
135
 
110
136
  // ---- tool registry: guard is global, monotonic, and lands as an error --
111
137
  check('defineTool is exported', () => assert.equal(typeof tools.defineTool, 'function'))
112
- for (const method of ['guard', 'guardReason', 'restrict', 'prepareExecution', 'execute']) {
138
+ for (const method of ['guard', 'restrict', 'execute']) {
113
139
  check(`ToolRuntime.${method} exists`, () => assert.equal(typeof tools.ToolRuntime.prototype[method], 'function'))
114
140
  }
115
- check('guard denial is materialized as `Error: <reason>`', () => {
116
- assert.match(source('dsh-tools'), /Error: \$\{denialReason\}/u)
141
+ // 0.2.1 read guards in `guardReason`; 0.2.2 renamed it `checkGuards` and moved the one who
142
+ // materializes the denial into `lib/pipeline.js`. The plugin only needs "guards are still read
143
+ // once, after pre-execute, and the reason reaches the model" — not a particular private name.
144
+ const GUARD_READERS = ['checkGuards', 'guardReason']
145
+ const guardReader = GUARD_READERS.find((name) => typeof tools.ToolRuntime.prototype[name] === 'function')
146
+ check('ToolRuntime reads its guards through one method', () => {
147
+ assert.ok(
148
+ guardReader !== undefined,
149
+ `neither ${GUARD_READERS.join(' nor ')} exists — the gate cannot tell whether guards still deny`,
150
+ )
151
+ })
152
+ check('a guard denial is materialized as `Error: <reason>`', () => {
153
+ assert.match(pipeline(), /Error: \$\{denialReason\}/u)
154
+ assert.ok(pipeline().includes(`.${guardReader}(`), `the denial pipeline never calls ${guardReader}`)
155
+ })
156
+ check('guards are read after the pre-execute waterfall', () => {
157
+ const text = pipelineFile()
158
+ const waterfall = text.search(/tools\/pre-execute/u)
159
+ const read = text.search(new RegExp(`${guardReader}\\(`, 'u'))
160
+ assert.ok(waterfall >= 0, 'the pre-execute waterfall is gone; the gate cannot be refreshed before a pwsh call')
161
+ assert.ok(read > waterfall, 'guards run before the pre-execute waterfall, so a stale reading would decide the call')
117
162
  })
118
163
  check('restrict refuses a plain-context caller', () => {
119
164
  assert.match(source('dsh-tools'), /tools\.restrict\(\) requires a scoped context/u)
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Gate for the transitional Windows UTF-8 console bridge.
3
+ *
4
+ * The bridge wraps the Host's `ctx.shell.execute`; this suite drives it with a fake
5
+ * executor so the prefix, the opt-out, the fail-open paths and the restore are
6
+ * verifiable without a DSH installation.
7
+ */
8
+ import assert from 'node:assert/strict'
9
+ import { mountUtf8ConsoleBridge, utf8Command } from '../lib/utf8-console-bridge.js'
10
+
11
+ let failures = 0
12
+ function check(label, assertion) {
13
+ try {
14
+ assertion()
15
+ } catch (error) {
16
+ failures += 1
17
+ console.error(`FAIL — ${label}: ${error.message}`)
18
+ }
19
+ }
20
+
21
+ function fakeContext(shell, options = {}) {
22
+ const effects = []
23
+ const warnings = []
24
+ const injected = []
25
+ const scoped = {
26
+ get: () => shell,
27
+ effect(fn) { effects.push(fn()) },
28
+ }
29
+ const ctx = {
30
+ logger: { warn: (message) => warnings.push(message) },
31
+ inject(deps, callback) {
32
+ injected.push(deps)
33
+ callback(scoped)
34
+ },
35
+ }
36
+ return { ctx, effects, warnings, injected }
37
+ }
38
+
39
+ function fakeShell() {
40
+ const calls = []
41
+ const shell = {
42
+ execute(spec) {
43
+ calls.push({ spec, self: this })
44
+ return spec
45
+ },
46
+ }
47
+ return { shell, calls }
48
+ }
49
+
50
+ // --- The pure command transform -----------------------------------------------------------
51
+
52
+ check('prefixes a bare command once', () => {
53
+ assert.equal(utf8Command('Get-Location'), 'chcp.com 65001 > $null; Get-Location')
54
+ })
55
+ check('leaves an already-pinned command alone', () => {
56
+ for (const command of [
57
+ 'chcp 65001 > $null; Get-Location',
58
+ 'chcp.com 65001 > $null; Get-Location',
59
+ ' chcp.com 65001 >$null; Get-Location',
60
+ ]) {
61
+ assert.equal(utf8Command(command), command)
62
+ }
63
+ })
64
+ check('leaves non-strings and empty strings alone', () => {
65
+ for (const value of [undefined, null, 42, '']) assert.equal(utf8Command(value), value)
66
+ })
67
+
68
+ // --- The executor wrapper ----------------------------------------------------------------
69
+
70
+ check('wraps the shell executor and prefixes the command', () => {
71
+ const { shell, calls } = fakeShell()
72
+ const { ctx } = fakeContext(shell)
73
+ mountUtf8ConsoleBridge(ctx, () => ({ utf8Console: true }), 'win32')
74
+ const spec = { command: 'pwsh -NoProfile -File enc_test.ps1', cwd: 'C:\\work', timeoutMs: 1000 }
75
+ shell.execute(spec)
76
+ assert.equal(calls.length, 1)
77
+ assert.equal(calls[0].spec.command, 'chcp.com 65001 > $null; pwsh -NoProfile -File enc_test.ps1')
78
+ assert.equal(calls[0].spec.cwd, 'C:\\work')
79
+ assert.equal(calls[0].spec.timeoutMs, 1000)
80
+ assert.equal(calls[0].self, shell)
81
+ })
82
+ check('passes through when the config field is off', () => {
83
+ const { shell, calls } = fakeShell()
84
+ const { ctx } = fakeContext(shell)
85
+ mountUtf8ConsoleBridge(ctx, () => ({ utf8Console: false }), 'win32')
86
+ shell.execute({ command: 'Get-Location' })
87
+ assert.equal(calls[0].spec.command, 'Get-Location')
88
+ })
89
+ check('does not double-prefix a command that already selects UTF-8', () => {
90
+ const { shell, calls } = fakeShell()
91
+ const { ctx } = fakeContext(shell)
92
+ mountUtf8ConsoleBridge(ctx, () => ({ utf8Console: true }), 'win32')
93
+ shell.execute({ command: 'chcp.com 65001 > $null; Get-Location' })
94
+ assert.equal(calls[0].spec.command, 'chcp.com 65001 > $null; Get-Location')
95
+ })
96
+ check('is idempotent when mounted twice on the same executor', () => {
97
+ const { shell } = fakeShell()
98
+ const { ctx } = fakeContext(shell)
99
+ mountUtf8ConsoleBridge(ctx, () => ({ utf8Console: true }), 'win32')
100
+ const once = shell.execute
101
+ mountUtf8ConsoleBridge(ctx, () => ({ utf8Console: true }), 'win32')
102
+ assert.equal(shell.execute, once)
103
+ })
104
+ check('restores the original executor when the fiber disposes', () => {
105
+ const { shell, calls } = fakeShell()
106
+ const original = shell.execute
107
+ const { ctx, effects } = fakeContext(shell)
108
+ mountUtf8ConsoleBridge(ctx, () => ({ utf8Console: true }), 'win32')
109
+ assert.notEqual(shell.execute, original)
110
+ effects.forEach((dispose) => dispose())
111
+ assert.equal(shell.execute, original)
112
+ shell.execute({ command: 'Get-Location' })
113
+ assert.equal(calls.at(-1).spec.command, 'Get-Location')
114
+ })
115
+
116
+ // --- Fail-open paths ---------------------------------------------------------------------
117
+
118
+ check('does nothing on non-Windows', () => {
119
+ const { shell } = fakeShell()
120
+ const { ctx, injected } = fakeContext(shell)
121
+ mountUtf8ConsoleBridge(ctx, () => ({ utf8Console: true }), 'linux')
122
+ assert.deepEqual(injected, [])
123
+ })
124
+ check('warns and leaves a service without execute untouched', () => {
125
+ const { ctx, warnings } = fakeContext({})
126
+ mountUtf8ConsoleBridge(ctx, () => ({ utf8Console: true }), 'win32')
127
+ assert.equal(warnings.length, 1)
128
+ assert.match(warnings[0], /no execute/u)
129
+ })
130
+ check('warns instead of throwing on a frozen executor', () => {
131
+ const shell = Object.freeze({ execute(spec) { return spec } })
132
+ const { ctx, warnings } = fakeContext(shell)
133
+ assert.doesNotThrow(() => mountUtf8ConsoleBridge(ctx, () => ({ utf8Console: true }), 'win32'))
134
+ assert.equal(warnings.length, 1)
135
+ })
136
+ check('falls open when the config reader throws', () => {
137
+ const { shell, calls } = fakeShell()
138
+ const { ctx } = fakeContext(shell)
139
+ mountUtf8ConsoleBridge(ctx, () => { throw new Error('config unavailable') }, 'win32')
140
+ shell.execute({ command: 'Get-Location' })
141
+ assert.equal(calls[0].spec.command, 'Get-Location')
142
+ })
143
+
144
+ if (failures > 0) {
145
+ console.error(`utf8 bridge: ${failures} check(s) failed`)
146
+ process.exit(1)
147
+ }
148
+ console.log('ok — utf8 console bridge: prefix, opt-out, idempotence, restore and fail-open paths')