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 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) 构建。
@@ -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'
@@ -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
+ }