dsh-console-utf8 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/README.zh.md +92 -0
- package/cordis.patch.yml +9 -0
- package/dsh-plugin.json +30 -0
- package/lib/console-cp.js +103 -0
- package/lib/index.js +13 -0
- package/lib/log.js +64 -0
- package/lib/plugin.js +274 -0
- package/lib/shell-hook.js +67 -0
- package/package.json +62 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|
5
|
+
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## 0.1.1 (2026-09-13)
|
|
8
|
+
|
|
9
|
+
- Documentation only, no code change. The "host switch" limitation now records what a restarted dsh-tui session measured: the Windows launcher starts the host without a console of its own, so every `chcp.com` child gets a fresh console, the host half logs its warning, and the `BASH_ENV` hook carries the whole fix. The warning is expected on every start, not a fault.
|
|
10
|
+
|
|
11
|
+
## 0.1.0 (2026-09-13)
|
|
12
|
+
|
|
13
|
+
- Initial release.
|
|
14
|
+
- Host console: switches the console the dsh host was started in to code page 65001 via `chcp.com`, reads the page back and logs what was observed.
|
|
15
|
+
- Shell hook: writes a `BASH_ENV` hook under the dsh state directory so every non-interactive `bash -c` re-applies the code page inside its own process group, and restores `BASH_ENV` on unload only while it is still the value this plugin set.
|
|
16
|
+
- Bounded lifecycle log at `~/.dsh-tui/dsh-console-utf8.log` (newest half kept past 128 KiB; nothing is written under `node --test`).
|
|
17
|
+
- Config: `enabled`, `codePage`, `setHostConsole`, `shellHook`, `shimPath` — every key defaulted, so an empty composition entry is a safe no-op.
|
|
18
|
+
- Windows only: every other platform takes the `not win32` path and changes nothing.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 VviLliAm-qwq
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# dsh-console-utf8
|
|
2
|
+
|
|
3
|
+
**English** · [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
Keeps the Windows console on code page **65001 (UTF-8)** for the dsh host and for the commands the bash tool runs, so output from Windows-native child processes stops arriving as mojibake.
|
|
6
|
+
|
|
7
|
+
## The problem
|
|
8
|
+
|
|
9
|
+
The dsh subprocess layer decodes every child's stdout as UTF-8. Windows-native tools that a bash command invokes — `powershell.exe`, `cmd.exe`, `git.exe`, and `chcp.com` itself — write their text in the console's OEM code page instead (936/GBK on a Chinese system, 932 on Japanese, 437 on US-English). The bytes are then read as UTF-8 and every non-ASCII character in that output is destroyed:
|
|
10
|
+
|
|
11
|
+
| Console state | `chcp` output |
|
|
12
|
+
|---|---|
|
|
13
|
+
| default (936) | the localised line, its Chinese replaced by U+FFFD runs |
|
|
14
|
+
| after `chcp 65001` | `Active code page: 65001` |
|
|
15
|
+
|
|
16
|
+
It is not a decoding bug that can be fixed by decoding harder: the console has to speak the same encoding the decoder assumes. That is all this plugin does.
|
|
17
|
+
|
|
18
|
+
## What it does
|
|
19
|
+
|
|
20
|
+
- **Host console** (`setHostConsole`, default on): switches the console the dsh host was started in to the configured code page using `chcp.com`, then reads the page back and logs what the console actually reports — a sandbox or a foreign locale that accepts the call and keeps the old page is reported instead of being claimed as a success.
|
|
21
|
+
- **Shell hook** (`shellHook`, default on): maintains `~/.dsh-tui/console-utf8.sh` and points `BASH_ENV` at it, so every non-interactive `bash -c` re-applies the code page inside its own process group. This covers the case where the shell executor spawns a command in a fresh console, which would otherwise start back at the system default.
|
|
22
|
+
- **Diagnostics**: a bounded lifecycle log at `~/.dsh-tui/dsh-console-utf8.log` records the resolved config, the observed code page before and after, and the hook decision. It is trimmed to its newest half once it passes 128 KiB, and nothing is written while `node --test` is running.
|
|
23
|
+
|
|
24
|
+
Exactly two files are written, both under the dsh state directory: the hook and the log. Commands are never rewritten, PATH is never touched, the shell stack is never patched, and no other file is read.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
dsh plugin --profile <profile> add dsh-console-utf8
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Restart the TUI afterwards (`/restart`) — the plugin acts at mount time.
|
|
33
|
+
|
|
34
|
+
Manual installation: copy the package into `~/.dsh/profiles/<profile>/node_modules/dsh-console-utf8/` and append `"dsh-console-utf8"` to `dsh.profile.bundles` in that profile's `package.json`. The package declares `dsh.bundle.patch`, so it mounts itself at boot.
|
|
35
|
+
|
|
36
|
+
## Compatibility
|
|
37
|
+
|
|
38
|
+
| Item | Value |
|
|
39
|
+
|---|---|
|
|
40
|
+
| Platform | Windows only (`win32`); any other platform takes the `not win32` path and changes nothing |
|
|
41
|
+
| Host | dsh-tui with manifest v0.15 / `v1alpha1` host facet |
|
|
42
|
+
| Node | `^22.19 || >=24`, pure ESM |
|
|
43
|
+
| Contributes | nothing — no command, no permission, no contract, no seam registration |
|
|
44
|
+
| Shell stack | benefits any stack whose commands go through a Windows console; the `BASH_ENV` hook only applies to **bash** (`sh`/`dash` are unaffected) |
|
|
45
|
+
|
|
46
|
+
## Configuration
|
|
47
|
+
|
|
48
|
+
| Key | Type | Default | Meaning |
|
|
49
|
+
|---|---|---|---|
|
|
50
|
+
| `enabled` | boolean | `true` | Master switch. `false` mounts the plugin and does nothing. |
|
|
51
|
+
| `codePage` | number | `65001` | Code page to enforce. Override only deliberately. |
|
|
52
|
+
| `setHostConsole` | boolean | `true` | Switch the host process's console. |
|
|
53
|
+
| `shellHook` | boolean | `true` | Maintain the `BASH_ENV` hook. |
|
|
54
|
+
| `shimPath` | string | `''` | Hook path. Empty means `~/.dsh-tui/console-utf8.sh`. |
|
|
55
|
+
|
|
56
|
+
## Known limitations
|
|
57
|
+
|
|
58
|
+
- **Root cause is upstream.** This plugin makes the console match the decoder's assumption; it does not change how the subprocess layer decodes output. A host that decodes with a fallback would not need it.
|
|
59
|
+
- **`BASH_ENV` is shared.** If another tool already set `BASH_ENV` to a different path, the plugin stands down and logs why rather than clobbering it; point `shimPath` at that path to adopt it, or disable `shellHook`.
|
|
60
|
+
- **`bash` only.** The hook is not read by `sh`, `dash`, `zsh` or PowerShell, and a command that resets the code page itself (`chcp 936`) wins until the next command.
|
|
61
|
+
- **The host switch needs a console the host owns.** When the host starts without one — a headless probe, and the Windows dsh-tui launcher, which hands the host no console handle — every `chcp.com` child gets a console of its own, so the switch cannot take effect. The plugin logs a warning instead of claiming success, and the shell hook then carries the whole fix. Measured twice: in the 0.1.0 integration probe and in a real restarted dsh-tui session. Expect the `host console code page … after asking for …` warning on every start; it is not a fault.
|
|
62
|
+
- **Per console, not per system.** A newly created console starts at the system default again; use the system-wide UTF-8 setting if that is what you want.
|
|
63
|
+
- **The hook costs one `chcp.com` per bash invocation** (a few milliseconds), silenced so it never reaches the tool output.
|
|
64
|
+
- **Nothing already copied is repaired.** Text that is mojibake in the clipboard or in a file stays that way.
|
|
65
|
+
- Verified on Windows 11 with a CP936 system locale; other code pages are expected to behave the same but were not measured.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
pnpm install
|
|
71
|
+
npm run verify # encoding sweep + unit tests + manifest + pack layout
|
|
72
|
+
node --test # unit tests only
|
|
73
|
+
npm run check:encoding # BOM / damaged-sequence sweep
|
|
74
|
+
npm run validate:manifest
|
|
75
|
+
npm run pack:verify # published file list, and that no shipped module is missing
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The unit tests never touch the real console or the user's files: the code-page calls, the hook writer and the environment are injected.
|
|
79
|
+
|
|
80
|
+
## Publishing
|
|
81
|
+
|
|
82
|
+
Version tags drive the release (`vX.Y.Z`, tag = `package.json` version). The repository ships a GitHub Actions workflow that runs the verification chain and publishes to npm with provenance.
|
|
83
|
+
|
|
84
|
+
## Publishing
|
|
85
|
+
|
|
86
|
+
- **Repository**: <https://github.com/VviLliAm-qwq/dsh-console-utf8> (public)
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT. See [LICENSE](LICENSE).
|
|
91
|
+
|
|
92
|
+
Built for [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI).
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# dsh-console-utf8
|
|
2
|
+
|
|
3
|
+
[English](README.md) · **中文**
|
|
4
|
+
|
|
5
|
+
把 **dsh 宿主**和 **bash 工具命令**所用的 Windows 控制台固定在代码页 **65001(UTF-8)**,让 Windows 原生子进程的输出不再变成乱码。
|
|
6
|
+
|
|
7
|
+
## 问题是什么
|
|
8
|
+
|
|
9
|
+
dsh 的子进程层统一按 UTF-8 解码每个子进程的 stdout。而 bash 命令里调用的 Windows 原生工具 —— `powershell.exe`、`cmd.exe`、`git.exe`,甚至 `chcp.com` 自己 —— 是用控制台的 OEM 代码页输出的(中文系统 936/GBK,日文 932,美式英文 437)。这些字节被当成 UTF-8 读取后,其中每一个非 ASCII 字符都会被打碎:
|
|
10
|
+
|
|
11
|
+
| 控制台状态 | `chcp` 的输出 |
|
|
12
|
+
|---|---|
|
|
13
|
+
| 默认(936) | 本地化文案里的中文被替换成一串 U+FFFD |
|
|
14
|
+
| 执行 `chcp 65001` 之后 | `Active code page: 65001` |
|
|
15
|
+
|
|
16
|
+
这不是「解码再努力一点」能解决的问题:控制台必须说出解码方所假定的那种编码。本插件做的只有这一件事。
|
|
17
|
+
|
|
18
|
+
## 它做什么
|
|
19
|
+
|
|
20
|
+
- **宿主控制台**(`setHostConsole`,默认开):用 `chcp.com` 把 dsh 宿主启动时所在的控制台切换到配置的代码页,然后**回读**实际生效的代码页并写进日志 —— 沙箱或异常区域设置可能出现「调用返回成功但代码页没变」,这种情况会被如实记录,而不是被当成成功。
|
|
21
|
+
- **shell 钩子**(`shellHook`,默认开):维护 `~/.dsh-tui/console-utf8.sh` 并把 `BASH_ENV` 指向它,使每一次非交互 `bash -c` 都在自己的进程组里重新应用一次代码页。这覆盖了「shell 执行器在新控制台里拉起命令」的情形 —— 那种情况下代码页会退回系统默认值。
|
|
22
|
+
- **可诊断性**:`~/.dsh-tui/dsh-console-utf8.log` 记录解析后的配置、切换前后的代码页以及钩子决策;超过 128 KiB 时裁掉旧的一半;`node --test` 下不写任何日志。
|
|
23
|
+
|
|
24
|
+
全程只写两个文件,都在 dsh 状态目录下:钩子与日志。不重写命令、不动 PATH、不打补丁到 shell 栈,除这两个文件外不读取任何用户文件。
|
|
25
|
+
|
|
26
|
+
## 安装
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
dsh plugin --profile <profile> add dsh-console-utf8
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
之后**重启 TUI**(`/restart`)—— 插件在挂载时动作。
|
|
33
|
+
|
|
34
|
+
手工安装:把包复制到 `~/.dsh/profiles/<profile>/node_modules/dsh-console-utf8/`,并把 `"dsh-console-utf8"` 追加进该 profile `package.json` 的 `dsh.profile.bundles`。包内声明了 `dsh.bundle.patch`,启动时会自行挂载。
|
|
35
|
+
|
|
36
|
+
## 兼容性
|
|
37
|
+
|
|
38
|
+
| 项 | 值 |
|
|
39
|
+
|---|---|
|
|
40
|
+
| 平台 | 仅 Windows(`win32`);其他平台走 `not win32` 分支,什么都不改 |
|
|
41
|
+
| 宿主 | dsh-tui,manifest v0.15 / `v1alpha1` host facet |
|
|
42
|
+
| Node | `^22.19 || >=24`,纯 ESM |
|
|
43
|
+
| 贡献面 | 无 —— 不注册命令、不申请权限、不声明契约、不使用接缝 |
|
|
44
|
+
| shell 栈 | 任何经由 Windows 控制台执行命令的栈都受益;`BASH_ENV` 钩子只对 **bash** 生效(`sh`/`dash` 不受影响) |
|
|
45
|
+
|
|
46
|
+
## 配置
|
|
47
|
+
|
|
48
|
+
| 键 | 类型 | 默认 | 含义 |
|
|
49
|
+
|---|---|---|---|
|
|
50
|
+
| `enabled` | boolean | `true` | 总开关。`false` 时插件照常挂载但什么都不做。 |
|
|
51
|
+
| `codePage` | number | `65001` | 要强制使用的代码页。非必要不建议改。 |
|
|
52
|
+
| `setHostConsole` | boolean | `true` | 切换宿主进程所在的控制台。 |
|
|
53
|
+
| `shellHook` | boolean | `true` | 维护 `BASH_ENV` 钩子。 |
|
|
54
|
+
| `shimPath` | string | `''` | 钩子路径。留空表示 `~/.dsh-tui/console-utf8.sh`。 |
|
|
55
|
+
|
|
56
|
+
## 已知限制
|
|
57
|
+
|
|
58
|
+
- **根因在上游**:本插件让控制台迁就解码方的假设,并没有改变子进程层「一律按 UTF-8 解码」这件事。若宿主改为带回退的解码,这个插件就不需要了。
|
|
59
|
+
- **`BASH_ENV` 是共享的**:若已有其他工具把它设成了别的路径,插件会**让位并记录原因**,而不是覆盖它;可以把 `shimPath` 指向那个路径来接管,或关闭 `shellHook`。
|
|
60
|
+
- **只对 bash 生效**:`sh`、`dash`、`zsh`、PowerShell 都不读这个钩子;命令自身重置代码页(如 `chcp 936`)会在下一条命令之前一直生效。
|
|
61
|
+
- **宿主那一半需要宿主真正拥有的控制台**:当宿主启动时就没有自己的控制台 —— 无头探测如此,Windows 上 dsh-tui 的启动器也如此(它不把控制台句柄交给宿主)—— 每个 `chcp.com` 子进程会各自拿到一个新控制台,切换无法生效。此时插件**记录警告**而不谎报成功,改由 shell 钩子独自承担修复。已实测两次:0.1.0 的集成探测,以及真实重启后的 dsh-tui 会话。每次启动都会看到 `host console code page … after asking for …` 这条 warn,**它不是故障**。
|
|
62
|
+
- **按控制台生效,不是全系统**:新建的控制台会回到系统默认值;需要全系统生效请改用系统的 UTF-8 区域设置。
|
|
63
|
+
- **每条 bash 命令多一次 `chcp.com`**(几毫秒),输出已静默,不会混进工具结果。
|
|
64
|
+
- **不修复已经损坏的内容**:剪贴板或文件里已经乱掉的字不会因此恢复。
|
|
65
|
+
- 已在 Windows 11 / CP936 系统区域设置下实测;其他代码页预期行为一致,但未实测。
|
|
66
|
+
|
|
67
|
+
## 开发
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
pnpm install
|
|
71
|
+
npm run verify # 编码扫描 + 单测 + manifest + 发布包布局
|
|
72
|
+
node --test # 只跑单测
|
|
73
|
+
npm run check:encoding # BOM / 损坏序列扫描
|
|
74
|
+
npm run validate:manifest
|
|
75
|
+
npm run pack:verify # 发布文件清单,以及「有没有模块漏进清单」
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
单元测试**不会**碰真实控制台或用户文件:代码页调用、钩子写入器与环境全部由外部注入。
|
|
79
|
+
|
|
80
|
+
## 发布
|
|
81
|
+
|
|
82
|
+
版本由 tag 驱动(`vX.Y.Z`,tag 必须等于 `package.json` 的 version)。仓库自带 GitHub Actions 工作流:先跑完整校验链,再带 provenance 发布到 npm。
|
|
83
|
+
|
|
84
|
+
## 发布
|
|
85
|
+
|
|
86
|
+
- **仓库**:<https://github.com/VviLliAm-qwq/dsh-console-utf8>(公开)
|
|
87
|
+
|
|
88
|
+
## 许可
|
|
89
|
+
|
|
90
|
+
MIT,见 [LICENSE](LICENSE)。
|
|
91
|
+
|
|
92
|
+
为 [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI) 构建。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# dsh-console-utf8 bundle patch: mounts the console-code-page plugin.
|
|
2
|
+
#
|
|
3
|
+
# Nothing else is overridden here on purpose. The plugin only touches the
|
|
4
|
+
# console code page and one environment variable; the shell stack itself is
|
|
5
|
+
# left to whoever provides it (the official bash rows, enabled for Windows by
|
|
6
|
+
# a separate PATH-side plugin). Revert by removing this bundle.
|
|
7
|
+
- insert:
|
|
8
|
+
- id: dsh-console-utf8
|
|
9
|
+
name: 'dsh-console-utf8'
|
package/dsh-plugin.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://dsh.community/schemas/dsh-plugin-0.15.json",
|
|
3
|
+
"manifestVersion": "0.15",
|
|
4
|
+
"id": "com.dsh-tui-ecosystem.dsh-console-utf8",
|
|
5
|
+
"name": "dsh-console-utf8",
|
|
6
|
+
"version": "0.1.1",
|
|
7
|
+
"facets": {
|
|
8
|
+
"host": {
|
|
9
|
+
"entry": "lib/index.js",
|
|
10
|
+
"apiVersion": "v1alpha1"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"requires": {
|
|
14
|
+
"contracts": []
|
|
15
|
+
},
|
|
16
|
+
"permissions": [],
|
|
17
|
+
"subscriptions": [],
|
|
18
|
+
"contributes": {
|
|
19
|
+
"commands": []
|
|
20
|
+
},
|
|
21
|
+
"source": {
|
|
22
|
+
"repository": "https://github.com/VviLliAm-qwq/dsh-console-utf8"
|
|
23
|
+
},
|
|
24
|
+
"compat": {
|
|
25
|
+
"hosts": [
|
|
26
|
+
"dsh-tui"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT"
|
|
30
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-console-utf8 — console code page primitives.
|
|
3
|
+
*
|
|
4
|
+
* A Windows console owns its code page, and every console process attached to
|
|
5
|
+
* it inherits that page. `chcp.com` reports it and rewrites it; because the
|
|
6
|
+
* value belongs to the CONSOLE rather than to the calling process, a
|
|
7
|
+
* short-lived child changes what its parent and its siblings observe
|
|
8
|
+
* (measured 2026-09-13: a piped `chcp.com 65001` child left a later sibling
|
|
9
|
+
* `powershell.exe` reporting 65001).
|
|
10
|
+
*
|
|
11
|
+
* Every function here takes its spawner as an argument so tests can drive
|
|
12
|
+
* fabricated output: no test may touch the real console.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-console-utf8/console-cp
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Turn whatever a spawner returned (string, Buffer or undefined) into text. */
|
|
18
|
+
function asText(value) {
|
|
19
|
+
if (typeof value === 'string') return value
|
|
20
|
+
if (value instanceof Uint8Array) return Buffer.from(value).toString('utf8')
|
|
21
|
+
return ''
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse the code page number out of `chcp` output.
|
|
26
|
+
*
|
|
27
|
+
* The text around the number is localised ("Active code page: 65001",
|
|
28
|
+
* "活动代码页: 65001") and on a non-UTF-8 console it frequently arrives already
|
|
29
|
+
* mangled, so only the digits are trusted: the first 2–5 digit run wins.
|
|
30
|
+
*
|
|
31
|
+
* @param {unknown} output - Raw `chcp.com` stdout.
|
|
32
|
+
* @returns {number | undefined} The code page, or undefined when unparseable.
|
|
33
|
+
*/
|
|
34
|
+
export function parseCodePage(output) {
|
|
35
|
+
const match = /(\d{2,5})/.exec(asText(output))
|
|
36
|
+
if (match === null) return undefined
|
|
37
|
+
const value = Number(match[1])
|
|
38
|
+
return Number.isInteger(value) && value >= 1 && value <= 65535 ? value : undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* How both calls are made. stdout is PIPED on purpose: the host's screen must
|
|
43
|
+
* never receive "Active code page: …". Piping stdio does not detach the
|
|
44
|
+
* console — the child still inherits it and still rewrites its code page.
|
|
45
|
+
*/
|
|
46
|
+
const CALL_OPTIONS = Object.freeze({
|
|
47
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
48
|
+
timeout: 5000,
|
|
49
|
+
windowsHide: true,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read the code page of the console this process is attached to.
|
|
54
|
+
*
|
|
55
|
+
* @param {Function} spawnSync - `node:child_process` spawnSync (or a stand-in).
|
|
56
|
+
* @returns {number | undefined} The code page, or undefined when there is no
|
|
57
|
+
* readable console.
|
|
58
|
+
*/
|
|
59
|
+
export function readConsoleCodePage(spawnSync) {
|
|
60
|
+
try {
|
|
61
|
+
const result = spawnSync('chcp.com', [], CALL_OPTIONS)
|
|
62
|
+
if (result === null || result === undefined || result.status !== 0) return undefined
|
|
63
|
+
return parseCodePage(result.stdout)
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Ask the shared console to switch to `codePage`.
|
|
71
|
+
*
|
|
72
|
+
* @param {number} codePage - Target code page (65001 = UTF-8).
|
|
73
|
+
* @param {Function} spawnSync - `node:child_process` spawnSync (or a stand-in).
|
|
74
|
+
* @returns {boolean} True when `chcp.com` exited 0.
|
|
75
|
+
*/
|
|
76
|
+
export function switchConsoleCodePage(codePage, spawnSync) {
|
|
77
|
+
try {
|
|
78
|
+
const result = spawnSync('chcp.com', [String(codePage)], CALL_OPTIONS)
|
|
79
|
+
return result !== null && result !== undefined && result.status === 0
|
|
80
|
+
} catch {
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Bring the console to `codePage` and report the page it is left on.
|
|
87
|
+
*
|
|
88
|
+
* The read-back matters: a sandbox, a foreign locale or a denied console can
|
|
89
|
+
* accept the call and still leave the old page in place, and the log should
|
|
90
|
+
* say so instead of claiming success.
|
|
91
|
+
*
|
|
92
|
+
* @param {number} codePage - Target code page.
|
|
93
|
+
* @param {Function} spawnSync - `node:child_process` spawnSync (or a stand-in).
|
|
94
|
+
* @returns {{ before: number | undefined, after: number | undefined,
|
|
95
|
+
* switched: boolean }} Observed pages and whether a call was made.
|
|
96
|
+
*/
|
|
97
|
+
export function ensureConsoleCodePage(codePage, spawnSync) {
|
|
98
|
+
const before = readConsoleCodePage(spawnSync)
|
|
99
|
+
if (before === codePage) return { before, after: before, switched: false }
|
|
100
|
+
const accepted = switchConsoleCodePage(codePage, spawnSync)
|
|
101
|
+
const after = readConsoleCodePage(spawnSync)
|
|
102
|
+
return { before, after, switched: accepted }
|
|
103
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-console-utf8 — Cordis entry.
|
|
3
|
+
*
|
|
4
|
+
* This module re-exports exactly the three symbols a Cordis plugin entry is
|
|
5
|
+
* read for (`name`, `Config`, `apply`) and nothing else: an entry module that
|
|
6
|
+
* carries extra symbols changes how the loader wraps the activation, which is
|
|
7
|
+
* the failure mode documented in `docs/DSH-PLUGIN-SOP.md` §2.1. The
|
|
8
|
+
* implementation lives in `./plugin.js`.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-console-utf8
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export { Config, apply, name } from './plugin.js'
|
package/lib/log.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-console-utf8 — bounded lifecycle log.
|
|
3
|
+
*
|
|
4
|
+
* The host keeps no diagnostics for a bundle-patch plugin, so the plugin
|
|
5
|
+
* speaks for itself: one file under the dsh state directory, trimmed to its
|
|
6
|
+
* newest half once it outgrows the cap. Nothing is written under
|
|
7
|
+
* `node --test`, so a test run never touches a user's log.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-console-utf8/log
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { appendFileSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
13
|
+
|
|
14
|
+
/** Above this size the log is trimmed to its newest half. */
|
|
15
|
+
export const MAX_LOG_BYTES = 128 * 1024
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Append one line, trimming first when the file has outgrown `maxBytes`.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} path - Log file path.
|
|
21
|
+
* @param {string} line - Complete line, newline included.
|
|
22
|
+
* @param {number} [maxBytes] - Size cap before trimming.
|
|
23
|
+
*/
|
|
24
|
+
export function appendLogLine(path, line, maxBytes = MAX_LOG_BYTES) {
|
|
25
|
+
try {
|
|
26
|
+
if (statSync(path).size > maxBytes) {
|
|
27
|
+
writeFileSync(path, readFileSync(path, 'utf8').slice(-Math.floor(maxBytes / 2)))
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// Missing or unreadable file: the append below recreates it.
|
|
31
|
+
}
|
|
32
|
+
appendFileSync(path, line)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build the plugin's quiet logger: the host logger always, plus the bounded
|
|
37
|
+
* file when file logging is allowed.
|
|
38
|
+
*
|
|
39
|
+
* @param {object} ctx - Cordis context (its `logger` is optional).
|
|
40
|
+
* @param {object} options - Logger wiring.
|
|
41
|
+
* @param {string} options.path - Diagnostic log path.
|
|
42
|
+
* @param {boolean} options.enabled - False under `node --test`.
|
|
43
|
+
* @param {string} [options.prefix] - Message prefix.
|
|
44
|
+
* @returns {{ info: (message: string) => void, warn: (message: string) => void }}
|
|
45
|
+
*/
|
|
46
|
+
export function createLogger(ctx, { path, enabled, prefix = 'dsh-console-utf8' }) {
|
|
47
|
+
const write = (level, message) => {
|
|
48
|
+
try {
|
|
49
|
+
ctx?.logger?.[level]?.(`${prefix}: ${message}`)
|
|
50
|
+
} catch {
|
|
51
|
+
// Observability only; never let logging break the plugin.
|
|
52
|
+
}
|
|
53
|
+
if (!enabled) return
|
|
54
|
+
try {
|
|
55
|
+
appendLogLine(path, `${new Date().toISOString()} ${level} ${message}\n`)
|
|
56
|
+
} catch {
|
|
57
|
+
// An unwritable log path is not worth surfacing.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
info: (message) => write('info', message),
|
|
62
|
+
warn: (message) => write('warn', message),
|
|
63
|
+
}
|
|
64
|
+
}
|
package/lib/plugin.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-console-utf8 — keep the Windows console on code page 65001 (UTF-8).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
*
|
|
6
|
+
* The dsh subprocess layer decodes every child's stdout as UTF-8
|
|
7
|
+
* (`buffer.toString('utf8')`). Windows-native tools that a bash command
|
|
8
|
+
* invokes — `powershell.exe`, `cmd.exe`, `git.exe`, `chcp.com` itself — write
|
|
9
|
+
* their text in the console's OEM code page instead (936/GBK on a Chinese
|
|
10
|
+
* system, 932 on Japanese, 437 on US-English). The bytes are then read as
|
|
11
|
+
* UTF-8, and every non-ASCII character in that output is destroyed:
|
|
12
|
+
*
|
|
13
|
+
* chcp → the localised line, its Chinese replaced by U+FFFD
|
|
14
|
+
* runs — GBK bytes read as UTF-8
|
|
15
|
+
* after 65001 → Active code page: 65001
|
|
16
|
+
* 中文测试OK → 中文测试OK (measured 2026-09-13)
|
|
17
|
+
*
|
|
18
|
+
* So the mismatch is not the tools' fault, and it is not fixable by decoding
|
|
19
|
+
* harder: the console has to speak the same encoding the decoder assumes.
|
|
20
|
+
* This plugin makes that true, in two places, because the shell executor may
|
|
21
|
+
* spawn a command either inside the host's console or in a fresh one:
|
|
22
|
+
*
|
|
23
|
+
* 1. the host's console is switched to `codePage` through `chcp.com`
|
|
24
|
+
* (`setHostConsole`), and
|
|
25
|
+
* 2. every non-interactive bash gets a `BASH_ENV` hook that repeats the
|
|
26
|
+
* switch inside its own process group (`shellHook`).
|
|
27
|
+
*
|
|
28
|
+
* WHAT IT DOES NOT DO
|
|
29
|
+
*
|
|
30
|
+
* It never rewrites a command, never patches the shell stack, never changes
|
|
31
|
+
* PATH, and never reads the user's files beyond the one hook it maintains. It
|
|
32
|
+
* writes exactly two files, both under the dsh state directory: the hook and
|
|
33
|
+
* this plugin's log.
|
|
34
|
+
*
|
|
35
|
+
* @module dsh-console-utf8/plugin
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { spawnSync } from 'node:child_process'
|
|
39
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
40
|
+
import { homedir } from 'node:os'
|
|
41
|
+
import { dirname, join } from 'node:path'
|
|
42
|
+
import { fileURLToPath } from 'node:url'
|
|
43
|
+
import z from '@deepseek-ai/schemastery'
|
|
44
|
+
import { ensureConsoleCodePage } from './console-cp.js'
|
|
45
|
+
import { createLogger } from './log.js'
|
|
46
|
+
import { SHIM_FILE_NAME, shimScript, toPosixPath } from './shell-hook.js'
|
|
47
|
+
|
|
48
|
+
export const name = 'dsh-console-utf8'
|
|
49
|
+
|
|
50
|
+
/** Every key carries a default: a missing composition entry changes nothing. */
|
|
51
|
+
export const Config = z.object({
|
|
52
|
+
/** Master switch. False mounts the plugin and does nothing. */
|
|
53
|
+
enabled: z.boolean().default(true),
|
|
54
|
+
/** Code page to enforce. 65001 is UTF-8; override only to deliberate. */
|
|
55
|
+
codePage: z.number().default(65001),
|
|
56
|
+
/** Switch the host process's console (the one the TUI was started in). */
|
|
57
|
+
setHostConsole: z.boolean().default(true),
|
|
58
|
+
/** Maintain the BASH_ENV hook so every bash command re-applies it. */
|
|
59
|
+
shellHook: z.boolean().default(true),
|
|
60
|
+
/** Hook path. Empty means `~/.dsh-tui/console-utf8.sh`. */
|
|
61
|
+
shimPath: z.string().default(''),
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
/** Boot defaults mirroring the schema. */
|
|
65
|
+
const DEFAULTS = Object.freeze({
|
|
66
|
+
enabled: true,
|
|
67
|
+
codePage: 65001,
|
|
68
|
+
setHostConsole: true,
|
|
69
|
+
shellHook: true,
|
|
70
|
+
shimPath: '',
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const DIAG_LOG = join(homedir(), '.dsh-tui', 'dsh-console-utf8.log')
|
|
74
|
+
/** Inside `node --test` nothing may touch a user's log or hook file. */
|
|
75
|
+
const FILE_LOG_ENABLED = typeof process.env?.NODE_TEST_CONTEXT !== 'string'
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Coerce an untrusted config object into the known keys with valid types.
|
|
79
|
+
*
|
|
80
|
+
* @param {unknown} config - Composition-entry config.
|
|
81
|
+
* @returns {{ enabled: boolean, codePage: number, setHostConsole: boolean,
|
|
82
|
+
* shellHook: boolean, shimPath: string }} A complete, safe config.
|
|
83
|
+
*/
|
|
84
|
+
export function sanitizeConfig(config) {
|
|
85
|
+
const out = { ...DEFAULTS }
|
|
86
|
+
if (config === null || typeof config !== 'object') return out
|
|
87
|
+
for (const key of ['enabled', 'setHostConsole', 'shellHook']) {
|
|
88
|
+
if (typeof config[key] === 'boolean') out[key] = config[key]
|
|
89
|
+
}
|
|
90
|
+
if (Number.isInteger(config.codePage) && config.codePage >= 1 && config.codePage <= 65535) {
|
|
91
|
+
out.codePage = config.codePage
|
|
92
|
+
}
|
|
93
|
+
if (typeof config.shimPath === 'string') out.shimPath = config.shimPath
|
|
94
|
+
return out
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Default hook location: the dsh state directory, which is this plugin's only
|
|
99
|
+
* writable home (`docs/DSH-PLUGIN-SOP.md` §2).
|
|
100
|
+
*
|
|
101
|
+
* @param {string} home - User home directory.
|
|
102
|
+
* @returns {string} Absolute hook path.
|
|
103
|
+
*/
|
|
104
|
+
export function defaultShimPath(home) {
|
|
105
|
+
return join(home, '.dsh-tui', SHIM_FILE_NAME)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Undo this plugin's `BASH_ENV`, but only while the value is still the one it
|
|
110
|
+
* set: the user or another plugin may have moved it afterwards, and their
|
|
111
|
+
* value must win.
|
|
112
|
+
*
|
|
113
|
+
* @param {Record<string, string | undefined>} env - Environment to edit.
|
|
114
|
+
* @param {string} owned - The value this plugin installed.
|
|
115
|
+
* @returns {'restored' | 'left alone'} What happened.
|
|
116
|
+
*/
|
|
117
|
+
export function restoreBashEnv(env, owned) {
|
|
118
|
+
if (env.BASH_ENV !== owned) return 'left alone'
|
|
119
|
+
delete env.BASH_ENV
|
|
120
|
+
return 'restored'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Write the hook when its content differs, creating the directory if needed.
|
|
125
|
+
*
|
|
126
|
+
* A stale hook (an older code page) is the one failure mode that would make
|
|
127
|
+
* the fix silently do the wrong thing, so the file is compared rather than
|
|
128
|
+
* assumed.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} path - Hook path.
|
|
131
|
+
* @param {string} content - Desired content.
|
|
132
|
+
* @returns {boolean} True when the file was (re)written.
|
|
133
|
+
*/
|
|
134
|
+
export function writeShimIfChanged(path, content) {
|
|
135
|
+
let current
|
|
136
|
+
try {
|
|
137
|
+
current = readFileSync(path, 'utf8')
|
|
138
|
+
} catch {
|
|
139
|
+
current = undefined
|
|
140
|
+
}
|
|
141
|
+
if (current === content) return false
|
|
142
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
143
|
+
// No BOM, LF endings: the file is sourced by `sh`, never parsed as JSON.
|
|
144
|
+
writeFileSync(path, content, 'utf8')
|
|
145
|
+
return true
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Do the work with every side effect injected, so tests can drive it.
|
|
150
|
+
*
|
|
151
|
+
* @param {object} deps - Injected environment.
|
|
152
|
+
* @param {string} deps.platform - `process.platform`.
|
|
153
|
+
* @param {Record<string, string | undefined>} deps.env - Environment to edit.
|
|
154
|
+
* @param {string} deps.home - User home directory.
|
|
155
|
+
* @param {Function} deps.spawnSync - Spawner for `chcp.com`.
|
|
156
|
+
* @param {(path: string, content: string) => boolean} deps.ensureShim - Hook writer.
|
|
157
|
+
* @param {{ info: Function, warn: Function }} deps.log - Logger.
|
|
158
|
+
* @param {object} config - Sanitized config.
|
|
159
|
+
* @returns {{ skipped: string, console: object | null, shim: object | null }}
|
|
160
|
+
*/
|
|
161
|
+
export function setup(deps, config) {
|
|
162
|
+
const result = { skipped: '', console: null, shim: null }
|
|
163
|
+
|
|
164
|
+
if (!config.enabled) {
|
|
165
|
+
result.skipped = 'disabled by config'
|
|
166
|
+
deps.log.info('skipped: disabled by config')
|
|
167
|
+
return result
|
|
168
|
+
}
|
|
169
|
+
if (deps.platform !== 'win32') {
|
|
170
|
+
// POSIX consoles are UTF-8 already; `chcp.com` does not exist there.
|
|
171
|
+
result.skipped = 'not win32'
|
|
172
|
+
deps.log.info('skipped: not win32')
|
|
173
|
+
return result
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (config.setHostConsole) {
|
|
177
|
+
const { before, after, switched } = ensureConsoleCodePage(config.codePage, deps.spawnSync)
|
|
178
|
+
result.console = { before, after, switched }
|
|
179
|
+
if (before === undefined && after === undefined) {
|
|
180
|
+
// A detached host has no console to read; the shell hook still covers
|
|
181
|
+
// the commands, so this is a note rather than a failure.
|
|
182
|
+
deps.log.warn('host console code page unreadable (no attached console?); shell hook still applies')
|
|
183
|
+
} else if (after === config.codePage) {
|
|
184
|
+
deps.log.info(`host console code page ${before ?? '?'} -> ${after}`)
|
|
185
|
+
} else {
|
|
186
|
+
deps.log.warn(`host console code page is ${after ?? 'unknown'} after asking for ${config.codePage}`)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (config.shellHook) {
|
|
191
|
+
const shimPath = config.shimPath === '' ? defaultShimPath(deps.home) : config.shimPath
|
|
192
|
+
const value = toPosixPath(shimPath)
|
|
193
|
+
let written = false
|
|
194
|
+
try {
|
|
195
|
+
written = deps.ensureShim(shimPath, shimScript(config.codePage))
|
|
196
|
+
} catch (error) {
|
|
197
|
+
deps.log.warn(`could not write the shell hook: ${error instanceof Error ? error.message : String(error)}`)
|
|
198
|
+
result.shim = { path: shimPath, applied: false, reason: 'hook not writable' }
|
|
199
|
+
return result
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const existing = typeof deps.env.BASH_ENV === 'string' && deps.env.BASH_ENV !== '' ? deps.env.BASH_ENV : undefined
|
|
203
|
+
if (existing !== undefined && existing !== value) {
|
|
204
|
+
// Someone else owns BASH_ENV. Clobbering it would break their hook, so
|
|
205
|
+
// this plugin stands down and says why; the config can point elsewhere.
|
|
206
|
+
deps.log.warn(`BASH_ENV is already ${existing}; left untouched (set this plugin's shimPath to adopt it)`)
|
|
207
|
+
result.shim = { path: shimPath, applied: false, reason: 'BASH_ENV owned by someone else' }
|
|
208
|
+
return result
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
deps.env.BASH_ENV = value
|
|
212
|
+
result.shim = {
|
|
213
|
+
path: shimPath,
|
|
214
|
+
applied: true,
|
|
215
|
+
written,
|
|
216
|
+
previous: undefined,
|
|
217
|
+
value,
|
|
218
|
+
}
|
|
219
|
+
deps.log.info(`BASH_ENV -> ${value} (${written ? 'hook written' : 'hook already current'})`)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return result
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Wire the plugin.
|
|
227
|
+
*
|
|
228
|
+
* @param {object} ctx - Cordis context of this activation.
|
|
229
|
+
* @param {unknown} config - Composition-entry config.
|
|
230
|
+
*/
|
|
231
|
+
export function apply(ctx, config) {
|
|
232
|
+
const log = createLogger(ctx, { path: DIAG_LOG, enabled: FILE_LOG_ENABLED })
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
log.info(`apply started pid=${process.pid} node=${process.version} file=${fileURLToPath(import.meta.url)}`)
|
|
236
|
+
} catch {
|
|
237
|
+
// Logging must never be the reason a plugin fails to load.
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const resolved = sanitizeConfig(config)
|
|
242
|
+
log.info(`config ${JSON.stringify(resolved)}`)
|
|
243
|
+
|
|
244
|
+
const result = setup(
|
|
245
|
+
{
|
|
246
|
+
platform: process.platform,
|
|
247
|
+
env: process.env,
|
|
248
|
+
home: homedir(),
|
|
249
|
+
spawnSync,
|
|
250
|
+
ensureShim: writeShimIfChanged,
|
|
251
|
+
log,
|
|
252
|
+
},
|
|
253
|
+
resolved,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
const shim = result.shim
|
|
257
|
+
if (shim !== null && shim.applied === true) {
|
|
258
|
+
const owned = shim.value
|
|
259
|
+
ctx.effect(function* bashEnvEffect() {
|
|
260
|
+
yield () => {
|
|
261
|
+
try {
|
|
262
|
+
log.info(`BASH_ENV ${restoreBashEnv(process.env, owned)}`)
|
|
263
|
+
} catch {
|
|
264
|
+
// Best-effort teardown.
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}, 'dsh-console-utf8 BASH_ENV')
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
log.info('apply finished')
|
|
271
|
+
} catch (error) {
|
|
272
|
+
log.warn(`apply failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-console-utf8 — the BASH_ENV hook.
|
|
3
|
+
*
|
|
4
|
+
* `chcp` fixes the console the shell itself is attached to, but the shell
|
|
5
|
+
* executor is free to spawn a command in a fresh console (a detached child
|
|
6
|
+
* gets the system default page back). `BASH_ENV` closes that hole at the top
|
|
7
|
+
* of every non-interactive `bash -c`: bash sources the named file before it
|
|
8
|
+
* runs the command, so the code page is corrected inside the very process
|
|
9
|
+
* group that is about to invoke `powershell.exe` / `cmd.exe` / `git.exe`.
|
|
10
|
+
*
|
|
11
|
+
* Measured 2026-09-13: a non-interactive `bash -c` really does source
|
|
12
|
+
* `$BASH_ENV`, and `chcp.com 65001` issued from it left the following
|
|
13
|
+
* `powershell.exe -Command chcp` reporting 65001 and printing its Chinese
|
|
14
|
+
* message intact.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-console-utf8/shell-hook
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** File name of the generated hook, written under the dsh state directory. */
|
|
20
|
+
export const SHIM_FILE_NAME = 'console-utf8.sh'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The hook itself.
|
|
24
|
+
*
|
|
25
|
+
* Constraints, each of them load-bearing:
|
|
26
|
+
* - silent: stdout/stderr of the code page call are discarded, because the
|
|
27
|
+
* hook runs in front of every command and any noise would land in the
|
|
28
|
+
* tool output;
|
|
29
|
+
* - harmless when `chcp.com` is absent (a POSIX machine): `command -v`
|
|
30
|
+
* gates it, and the trailing `:` keeps the hook's own exit status 0 so it
|
|
31
|
+
* can never masquerade as the command's failure;
|
|
32
|
+
* - POSIX `sh` only, no bashisms: `BASH_ENV` is sourced by bash, but
|
|
33
|
+
* quoting and `[ ]` conventions here stay portable.
|
|
34
|
+
*
|
|
35
|
+
* @param {number} codePage - Code page the hook switches to (65001 = UTF-8).
|
|
36
|
+
* @returns {string} The complete script text (LF endings, no BOM).
|
|
37
|
+
*/
|
|
38
|
+
export function shimScript(codePage) {
|
|
39
|
+
return [
|
|
40
|
+
'#!/bin/sh',
|
|
41
|
+
'# dsh-console-utf8 — sourced through BASH_ENV before every non-interactive',
|
|
42
|
+
'# bash command, so Windows-native children of the shell speak UTF-8: that is',
|
|
43
|
+
'# the encoding the dsh subprocess layer decodes their output with.',
|
|
44
|
+
`if command -v chcp.com >/dev/null 2>&1; then chcp.com ${codePage} >/dev/null 2>&1; fi`,
|
|
45
|
+
'# Keep the hook invisible to the command it runs in front of.',
|
|
46
|
+
':',
|
|
47
|
+
'',
|
|
48
|
+
].join('\n')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Convert a Windows path into the `/c/...` form Git Bash resolves.
|
|
53
|
+
*
|
|
54
|
+
* `BASH_ENV` is consumed by an MSYS bash, and `C:\Users\x\.dsh-tui\f.sh`
|
|
55
|
+
* survives that hand-off only by accident. The drive-letter form is
|
|
56
|
+
* deterministic.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} windowsPath - `C:\Users\x\.dsh-tui\console-utf8.sh`.
|
|
59
|
+
* @returns {string} `/c/Users/x/.dsh-tui/console-utf8.sh`; the input with
|
|
60
|
+
* forward slashes when it carries no drive letter.
|
|
61
|
+
*/
|
|
62
|
+
export function toPosixPath(windowsPath) {
|
|
63
|
+
const normalized = String(windowsPath).replaceAll('\\', '/')
|
|
64
|
+
const drive = /^([A-Za-z]):(\/.*)?$/.exec(normalized)
|
|
65
|
+
if (drive === null) return normalized
|
|
66
|
+
return `/${drive[1].toLowerCase()}${drive[2] ?? ''}`
|
|
67
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-console-utf8",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Keep the Windows console on code page 65001 (UTF-8) for the dsh host and its bash tool commands, so output from Windows-native child processes stops being decoded as mojibake",
|
|
5
|
+
"packageManager": "pnpm@11.22.0",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "lib/index.js",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./lib/index.js"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/VviLliAm-qwq/dsh-console-utf8.git"
|
|
15
|
+
},
|
|
16
|
+
"author": "VviLliAm-qwq",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"dsh",
|
|
19
|
+
"dsh-plugin",
|
|
20
|
+
"dsh-tui",
|
|
21
|
+
"deepseek-harness",
|
|
22
|
+
"encoding",
|
|
23
|
+
"utf-8",
|
|
24
|
+
"codepage",
|
|
25
|
+
"chcp",
|
|
26
|
+
"windows",
|
|
27
|
+
"bash",
|
|
28
|
+
"mojibake"
|
|
29
|
+
],
|
|
30
|
+
"files": [
|
|
31
|
+
"lib",
|
|
32
|
+
"dsh-plugin.json",
|
|
33
|
+
"cordis.patch.yml",
|
|
34
|
+
"README.md",
|
|
35
|
+
"README.zh.md",
|
|
36
|
+
"CHANGELOG.md",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
],
|
|
39
|
+
"dsh": {
|
|
40
|
+
"bundle": {
|
|
41
|
+
"patch": "./cordis.patch.yml"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": "^22.19 || >=24"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"test": "node --test",
|
|
49
|
+
"check:encoding": "node scripts/check-encoding.mjs",
|
|
50
|
+
"validate:manifest": "node scripts/validate-manifest.mjs",
|
|
51
|
+
"pack:verify": "node scripts/pack-verify.mjs",
|
|
52
|
+
"verify": "npm run check:encoding && npm test && npm run validate:manifest && npm run pack:verify",
|
|
53
|
+
"prepublishOnly": "npm run verify"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
57
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
61
|
+
}
|
|
62
|
+
}
|