@crazyhappyone/dsh-tui 0.1.0-alpha.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zhangyangrui
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.
@@ -0,0 +1,5 @@
1
+ # Bilingual-pair consistency record: both languages carry equal authority.
2
+ # This source-only mirror has no DSH pairing runner. Recompute with:
3
+ # git hash-object README.md README.zh.md
4
+ README.md: 87fc088f1ef0e8f09d582ca4075d7be650ed5312
5
+ README.zh.md: 0f23e7e5d1f1a5f9b2fdf41f8bf4e593114b1111
package/README.md ADDED
@@ -0,0 +1,169 @@
1
+ ---
2
+ description: "Installable source-runtime launcher and private source mirror for the DeepSeek Harness terminal interface."
3
+ kind: "package-group"
4
+ ---
5
+
6
+ # dsh-tui — DeepSeek Harness terminal interface
7
+
8
+ English | [中文](README.zh.md)
9
+
10
+ ## Summary
11
+
12
+ `@crazyhappyone/dsh-tui` provides the `dsh-tui` command for running and safely updating the DeepSeek Harness terminal interface. The launcher manages a dedicated DSH source checkout, so it never pulls, resets, stashes, or cleans a contributor's development checkout. This repository also mirrors the `packages/tui/` bundle and renderer sources for review. DSH continues to own agents, sessions, tools, persistence, providers, permissions, and profile assembly.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Install the launcher](#install-the-launcher)
17
+ - [Run and update](#run-and-update)
18
+ - [Configure the source runtime](#configure-the-source-runtime)
19
+ - [Relationship to DSH](#relationship-to-dsh)
20
+ - [Packages](#packages)
21
+ - [Development workflow](#development-workflow)
22
+ - [Publish the npm package](#publish-the-npm-package)
23
+ - [Known limitations](#known-limitations)
24
+ - [Dev Note](#dev-note)
25
+
26
+ -----
27
+
28
+ <a id="install-the-launcher"></a>
29
+ ## Install the launcher
30
+
31
+ The unscoped `dsh-tui` package belongs to another maintainer. This project publishes only under the authenticated `crazyhappyone` npm scope.
32
+
33
+ After a prerelease is published:
34
+
35
+ ```text
36
+ npm install --global @crazyhappyone/dsh-tui@next
37
+ dsh-tui version
38
+ ```
39
+
40
+ Installation creates only the launcher. It does not clone DSH, run `pnpm install`, prompt for Git credentials, or execute a postinstall script. Run `dsh-tui update` explicitly to create the source runtime.
41
+
42
+ -----
43
+
44
+ <a id="run-and-update"></a>
45
+ ## Run and update
46
+
47
+ Initialize the dedicated runtime, then start the TUI:
48
+
49
+ ```text
50
+ dsh-tui update
51
+ dsh-tui
52
+ ```
53
+
54
+ Ordinary arguments pass to the `deepseek-tui` profile unchanged:
55
+
56
+ ```text
57
+ dsh-tui "review this repository"
58
+ dsh-tui --resume <session-id>
59
+ dsh-tui --cwd <directory>
60
+ ```
61
+
62
+ `dsh-tui update` clones the configured source when the runtime is absent. For an existing runtime it requires a clean worktree, fetches the configured ref, proves the current HEAD is its ancestor, applies only `git merge --ff-only`, and runs `pnpm install --frozen-lockfile`. Dirty or divergent history stops before HEAD changes.
63
+
64
+ `dsh-tui version` reports the launcher version, runtime directory, runtime Git SHA, and DSH version without network access. The exact words `update` and `version` are launcher commands; use `dsh-tui -- update` or `dsh-tui -- version` to send either word as a TUI task.
65
+
66
+ Updating the launcher and updating the runtime are separate operations:
67
+
68
+ ```text
69
+ npm install --global @crazyhappyone/dsh-tui@next
70
+ dsh-tui update
71
+ ```
72
+
73
+ -----
74
+
75
+ <a id="configure-the-source-runtime"></a>
76
+ ## Configure the source runtime
77
+
78
+ The defaults track the owner's private `feat/deepseek-tui` branch. Git authorization for that repository is required. Other users can select a compatible accessible repository and ref.
79
+
80
+ | Environment variable | Default | Purpose |
81
+ |---|---|---|
82
+ | `DSH_TUI_RUNTIME_DIR` | `~/.local/share/dsh-tui/runtime` | Dedicated clone used only by the launcher |
83
+ | `DSH_TUI_SOURCE_URL` | `https://github.com/zhangyang-crazy-one/deepseek-harness.git` | Git remote cloned and fetched by `update` |
84
+ | `DSH_TUI_SOURCE_REF` | `feat/deepseek-tui` | Branch or ref fetched by `update` |
85
+ | `DSH_TUI_PNPM` | `pnpm` | pnpm executable used for install and launch |
86
+
87
+ Values must be non-empty, and the runtime directory must be absolute. The launcher refuses a symbolic-link runtime path and an existing directory that is not a Git checkout. Child commands receive argument arrays without shell interpolation.
88
+
89
+ If dependency refresh fails after a fast-forward, the runtime stays at the reported new SHA but is not ready. Run the exact recovery command printed by the launcher. For dirty or divergent state, inspect only the dedicated runtime directory or select a new empty `DSH_TUI_RUNTIME_DIR`; the launcher never discards it automatically.
90
+
91
+ -----
92
+
93
+ <a id="relationship-to-dsh"></a>
94
+ ## Relationship to DSH
95
+
96
+ The TUI is a DSH profile layer, not a separate agent runtime:
97
+
98
+ ```text
99
+ @deepseek-ai/dsh-base
100
+ └─ @deepseek-ai/dsh-tui profile patch, terminal lifecycle, controller
101
+ └─ @deepseek-ai/dsh-tui-render Ink projection and terminal I/O
102
+ ```
103
+
104
+ The integration source lives on the private [`feat/deepseek-tui` DSH branch](https://github.com/zhangyang-crazy-one/deepseek-harness/tree/feat/deepseek-tui/packages/tui). Changes that affect DSH services, profile composition, assembled CLI snapshots, or Agent Notes belong in that monorepo and follow its architecture, testing, and documentation rules.
105
+
106
+ -----
107
+
108
+ <a id="packages"></a>
109
+ ## Packages
110
+
111
+ | Package | DSH shape | Responsibility |
112
+ |---|---|---|
113
+ | [`tui/`](tui/README.md) | Profile bundle plus runtime plugin | Composes the terminal layer over `dsh-base`, owns the live terminal session, and maps user actions to DSH services |
114
+ | [`tui-render/`](tui-render/README.md) | Library | Projects controller state through Ink without owning agents, persistence, or model requests |
115
+
116
+ -----
117
+
118
+ <a id="development-workflow"></a>
119
+ ## Development workflow
120
+
121
+ Implement TUI behavior in the complete DSH checkout, run the checks selected by the changed behavior, and export the confirmed `packages/tui/` tree here. Develop the launcher in this repository with `npm test`; test installation through a packed tarball rather than a workspace link.
122
+
123
+ The launcher runtime must remain separate from the development checkout. To test against another source safely, point `DSH_TUI_RUNTIME_DIR` at a temporary absolute directory and configure `DSH_TUI_SOURCE_URL` and `DSH_TUI_SOURCE_REF`.
124
+
125
+ -----
126
+
127
+ <a id="publish-the-npm-package"></a>
128
+ ## Publish the npm package
129
+
130
+ The operator authenticates through npm's browser flow and verifies the selected account:
131
+
132
+ ```text
133
+ npm config delete //registry.npmjs.org/:_authToken
134
+ npm config set registry https://registry.npmjs.org/
135
+ npm login --registry=https://registry.npmjs.org/ --auth-type=web
136
+ npm whoami
137
+ ```
138
+
139
+ `npm whoami` must print `crazyhappyone`. Never place the password, OTP, or npm token in this repository, an issue, command output captured for review, or an AI conversation.
140
+
141
+ After tests and packed-install verification pass, publish a prerelease only with explicit operator approval:
142
+
143
+ ```text
144
+ npm publish --access public --tag next
145
+ ```
146
+
147
+ When npm requires two-factor authentication, the operator supplies the current code locally:
148
+
149
+ ```text
150
+ npm publish --access public --tag next --otp=<six-digit-code>
151
+ ```
152
+
153
+ Promotion to `latest` is a separate release decision. Local implementation and verification never create or change a registry package automatically.
154
+
155
+ -----
156
+
157
+ <a id="known-limitations"></a>
158
+ ## Known limitations
159
+
160
+ - **Private default source** — npm installation is public, but the default DSH runtime remote requires repository authorization; anonymous users must configure an accessible compatible source.
161
+ - **Source toolchain required** — runtime initialization requires Git, pnpm, and a DSH-supported Node version.
162
+ - **No automatic conflict resolution** — dirty or non-fast-forward runtime history requires explicit human action or a new runtime directory.
163
+ - **Runtime update is not launcher update** — `dsh-tui update` refreshes DSH source and dependencies; npm updates the launcher package.
164
+ - **Integration owns release readiness** — launcher success does not replace the DSH branch's behavior, snapshot, build, or hygiene checks.
165
+
166
+ <a id="dev-note"></a>
167
+ ## Dev Note
168
+
169
+ None.
package/README.zh.md ADDED
@@ -0,0 +1,169 @@
1
+ ---
2
+ description: "DeepSeek Harness 终端界面的可安装源码运行时 launcher 与 private 源码镜像。"
3
+ kind: "package-group"
4
+ ---
5
+
6
+ # dsh-tui — DeepSeek Harness 终端界面
7
+
8
+ [English](README.md) | 中文
9
+
10
+ ## 概述
11
+
12
+ `@crazyhappyone/dsh-tui` 提供 `dsh-tui` 命令,用于运行和安全更新 DeepSeek Harness 终端界面。Launcher 管理专用 DSH 源码 checkout,因此不会 pull、reset、stash 或 clean 贡献者的开发 checkout。本仓库还镜像 `packages/tui/` bundle 与 renderer 源码以供审查。Agent、会话、工具、持久化、provider、权限与 profile 组装仍由 DSH 拥有。
13
+
14
+ ## 目录
15
+
16
+ - [安装 launcher](#install-the-launcher)
17
+ - [运行与更新](#run-and-update)
18
+ - [配置源码运行时](#configure-the-source-runtime)
19
+ - [与 DSH 的关系](#relationship-to-dsh)
20
+ - [包](#packages)
21
+ - [开发工作流](#development-workflow)
22
+ - [发布 npm 包](#publish-the-npm-package)
23
+ - [已知限制](#known-limitations)
24
+ - [开发备注](#dev-note)
25
+
26
+ -----
27
+
28
+ <a id="install-the-launcher"></a>
29
+ ## 安装 launcher
30
+
31
+ 无 scope 的 `dsh-tui` 包属于另一位 maintainer。本项目只在已认证的 `crazyhappyone` npm scope 下发布。
32
+
33
+ 发布 prerelease 后运行:
34
+
35
+ ```text
36
+ npm install --global @crazyhappyone/dsh-tui@next
37
+ dsh-tui version
38
+ ```
39
+
40
+ 安装只创建 launcher。它不会 clone DSH、运行 `pnpm install`、请求 Git 凭据或执行 postinstall script。请显式运行 `dsh-tui update` 创建源码运行时。
41
+
42
+ -----
43
+
44
+ <a id="run-and-update"></a>
45
+ ## 运行与更新
46
+
47
+ 初始化专用运行时,然后启动 TUI:
48
+
49
+ ```text
50
+ dsh-tui update
51
+ dsh-tui
52
+ ```
53
+
54
+ 普通参数会原样传递给 `deepseek-tui` profile:
55
+
56
+ ```text
57
+ dsh-tui "review this repository"
58
+ dsh-tui --resume <session-id>
59
+ dsh-tui --cwd <directory>
60
+ ```
61
+
62
+ 运行时不存在时,`dsh-tui update` 会 clone 配置的源码。对于现有运行时,它要求工作树干净、fetch 配置的 ref、证明当前 HEAD 是该 ref 的祖先、只应用 `git merge --ff-only`,再运行 `pnpm install --frozen-lockfile`。Dirty 或 divergent history 会在 HEAD 改变前停止。
63
+
64
+ `dsh-tui version` 会报告 launcher 版本、运行时目录、运行时 Git SHA 与 DSH 版本,并且不访问网络。精确单词 `update` 与 `version` 是 launcher 命令;使用 `dsh-tui -- update` 或 `dsh-tui -- version` 将其作为 TUI task 发送。
65
+
66
+ 更新 launcher 与更新运行时是不同操作:
67
+
68
+ ```text
69
+ npm install --global @crazyhappyone/dsh-tui@next
70
+ dsh-tui update
71
+ ```
72
+
73
+ -----
74
+
75
+ <a id="configure-the-source-runtime"></a>
76
+ ## 配置源码运行时
77
+
78
+ 默认值跟踪所有者的 private `feat/deepseek-tui` 分支。该仓库需要 Git authorization。其他用户可以选择一个兼容且可访问的仓库与 ref。
79
+
80
+ | 环境变量 | 默认值 | 用途 |
81
+ |---|---|---|
82
+ | `DSH_TUI_RUNTIME_DIR` | `~/.local/share/dsh-tui/runtime` | 仅供 launcher 使用的专用 clone |
83
+ | `DSH_TUI_SOURCE_URL` | `https://github.com/zhangyang-crazy-one/deepseek-harness.git` | `update` clone 和 fetch 的 Git remote |
84
+ | `DSH_TUI_SOURCE_REF` | `feat/deepseek-tui` | `update` fetch 的 branch 或 ref |
85
+ | `DSH_TUI_PNPM` | `pnpm` | 安装和启动所用的 pnpm executable |
86
+
87
+ 所有值都不得为空,运行时目录必须是绝对路径。Launcher 会拒绝 symbolic-link runtime path,也会拒绝不是 Git checkout 的现有目录。Child command 接收参数数组,不使用 shell interpolation。
88
+
89
+ 如果 fast-forward 后的依赖刷新失败,运行时会停留在报告的新 SHA,但不会被声明为 ready。请运行 launcher 打印的精确恢复命令。对于 dirty 或 divergent 状态,只检查专用运行时目录,或选择新的空 `DSH_TUI_RUNTIME_DIR`;launcher 不会自动丢弃它。
90
+
91
+ -----
92
+
93
+ <a id="relationship-to-dsh"></a>
94
+ ## 与 DSH 的关系
95
+
96
+ TUI 是 DSH profile 层,而不是独立的 agent 运行时:
97
+
98
+ ```text
99
+ @deepseek-ai/dsh-base
100
+ └─ @deepseek-ai/dsh-tui profile patch, terminal lifecycle, controller
101
+ └─ @deepseek-ai/dsh-tui-render Ink projection and terminal I/O
102
+ ```
103
+
104
+ 集成源码位于 private [`feat/deepseek-tui` DSH 分支](https://github.com/zhangyang-crazy-one/deepseek-harness/tree/feat/deepseek-tui/packages/tui)。影响 DSH 服务、profile 组合、组装后 CLI snapshot 或 Agent Note 的变更归该 monorepo 所有,并遵循其中的架构、测试与文档规则。
105
+
106
+ -----
107
+
108
+ <a id="packages"></a>
109
+ ## 包
110
+
111
+ | 包 | DSH 形态 | 职责 |
112
+ |---|---|---|
113
+ | [`tui/`](tui/README.zh.md) | Profile bundle 与运行时插件 | 在 `dsh-base` 上组合终端层、拥有 live 终端会话,并将用户动作映射到 DSH 服务 |
114
+ | [`tui-render/`](tui-render/README.zh.md) | Library | 通过 Ink 投影控制器状态,但不拥有 agent、持久化或模型请求 |
115
+
116
+ -----
117
+
118
+ <a id="development-workflow"></a>
119
+ ## 开发工作流
120
+
121
+ 在完整 DSH checkout 中实现 TUI 行为,运行受变更行为影响的检查,并将确认后的 `packages/tui/` tree 导出到这里。在本仓库中使用 `npm test` 开发 launcher;通过 packed tarball 测试安装,不使用 workspace link。
122
+
123
+ Launcher runtime 必须与开发 checkout 分离。要安全测试其他源码,请将 `DSH_TUI_RUNTIME_DIR` 指向临时绝对目录,并配置 `DSH_TUI_SOURCE_URL` 与 `DSH_TUI_SOURCE_REF`。
124
+
125
+ -----
126
+
127
+ <a id="publish-the-npm-package"></a>
128
+ ## 发布 npm 包
129
+
130
+ Operator 通过 npm browser flow 认证,并验证所选账号:
131
+
132
+ ```text
133
+ npm config delete //registry.npmjs.org/:_authToken
134
+ npm config set registry https://registry.npmjs.org/
135
+ npm login --registry=https://registry.npmjs.org/ --auth-type=web
136
+ npm whoami
137
+ ```
138
+
139
+ `npm whoami` 必须打印 `crazyhappyone`。绝不要把密码、OTP 或 npm token 放入本仓库、issue、供审查捕获的命令输出或 AI conversation。
140
+
141
+ 测试与 packed-install 验证通过后,仅在 operator 显式批准时发布 prerelease:
142
+
143
+ ```text
144
+ npm publish --access public --tag next
145
+ ```
146
+
147
+ npm 要求双因素认证时,operator 在本地提供当前验证码:
148
+
149
+ ```text
150
+ npm publish --access public --tag next --otp=<six-digit-code>
151
+ ```
152
+
153
+ 提升到 `latest` 是单独的 release 决策。本地实现与验证绝不会自动创建或更改 registry package。
154
+
155
+ -----
156
+
157
+ <a id="known-limitations"></a>
158
+ ## 已知限制
159
+
160
+ - **Private 默认源码** — npm 安装是 public,但默认 DSH runtime remote 需要仓库授权;匿名用户必须配置一个可访问的兼容源码。
161
+ - **需要源码工具链** — 初始化运行时需要 Git、pnpm 与 DSH 支持的 Node 版本。
162
+ - **不自动解决冲突** — dirty 或 non-fast-forward runtime history 需要显式人工操作或新的运行时目录。
163
+ - **运行时更新不是 launcher 更新** — `dsh-tui update` 刷新 DSH 源码与依赖;npm 更新 launcher package。
164
+ - **集成分支拥有发布就绪状态** — launcher 成功不能替代 DSH 分支的行为、snapshot、build 或 hygiene 检查。
165
+
166
+ <a id="dev-note"></a>
167
+ ## 开发备注
168
+
169
+ 无。
package/bin/dsh-tui.js ADDED
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ /** Command-line entry for the dsh-tui source-runtime launcher. */
3
+
4
+ import { lstatSync, mkdirSync, readFileSync } from 'node:fs'
5
+ import { constants, homedir } from 'node:os'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { spawnSync } from 'node:child_process'
8
+ import { parseLauncherInvocation, resolveLauncherSettings, runLauncher } from '../src/launcher.js'
9
+
10
+ function inspectPath(path) {
11
+ try {
12
+ const stat = lstatSync(path)
13
+ if (stat.isSymbolicLink()) return 'symlink'
14
+ if (stat.isDirectory()) return 'directory'
15
+ if (stat.isFile()) return 'file'
16
+ return 'other'
17
+ } catch (error) {
18
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return 'missing'
19
+ throw error
20
+ }
21
+ }
22
+
23
+ function run(command, args, options = {}) {
24
+ const capture = options.capture === true
25
+ const result = spawnSync(command, args, {
26
+ cwd: options.cwd,
27
+ encoding: capture ? 'utf8' : undefined,
28
+ env: process.env,
29
+ maxBuffer: capture ? 16 * 1024 * 1024 : undefined,
30
+ shell: false,
31
+ stdio: options.stdio === 'inherit' ? 'inherit' : capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
32
+ })
33
+ if (result.error !== undefined) {
34
+ return { status: 1, stdout: '', stderr: result.error.message }
35
+ }
36
+ const status = result.status ?? signalOutcome(result.signal)
37
+ return {
38
+ status,
39
+ stdout: typeof result.stdout === 'string' ? result.stdout : '',
40
+ stderr: typeof result.stderr === 'string' ? result.stderr : '',
41
+ ...(result.signal === null ? {} : { signal: result.signal }),
42
+ }
43
+ }
44
+
45
+ function signalOutcome(signal) {
46
+ if (signal === null) return 1
47
+ const number = constants.signals[signal]
48
+ return number === undefined ? 1 : 128 + number
49
+ }
50
+
51
+ const manifest = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'))
52
+
53
+ try {
54
+ const invocation = parseLauncherInvocation(process.argv.slice(2))
55
+ const settings = resolveLauncherSettings({ env: process.env, homeDirectory: homedir() })
56
+ process.exitCode = runLauncher({
57
+ invocation,
58
+ settings,
59
+ packageVersion: manifest.version,
60
+ adapters: {
61
+ inspectPath,
62
+ makeDirectory: path => mkdirSync(path, { recursive: true, mode: 0o700 }),
63
+ readText: path => readFileSync(path, 'utf8'),
64
+ run,
65
+ writeOut: text => process.stdout.write(text),
66
+ writeError: text => process.stderr.write(text),
67
+ },
68
+ })
69
+ } catch (error) {
70
+ process.stderr.write(`dsh-tui: ${error instanceof Error ? error.message : String(error)}\n`)
71
+ process.exitCode = 1
72
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@crazyhappyone/dsh-tui",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "Source-runtime launcher for the DeepSeek Harness terminal interface",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "dsh-tui": "bin/dsh-tui.js"
9
+ },
10
+ "files": [
11
+ "bin/dsh-tui.js",
12
+ "src/**/*.js",
13
+ "README.md",
14
+ "README.zh.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --test",
19
+ "pack:check": "npm pack --dry-run --json"
20
+ },
21
+ "engines": {
22
+ "node": "^22.19.0 || >=24.0.0"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "tag": "next"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/zhangyang-crazy-one/dsh-tui.git"
31
+ },
32
+ "keywords": [
33
+ "deepseek",
34
+ "dsh",
35
+ "tui",
36
+ "terminal"
37
+ ]
38
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Source-runtime launcher for the DeepSeek Harness TUI.
3
+ * @module @crazyhappyone/dsh-tui/launcher
4
+ */
5
+
6
+ import { dirname, isAbsolute, join } from 'node:path'
7
+
8
+ const DEFAULT_SOURCE_URL = 'https://github.com/zhangyang-crazy-one/deepseek-harness.git'
9
+ const DEFAULT_SOURCE_REF = 'feat/deepseek-tui'
10
+
11
+ /** @typedef {'missing' | 'directory' | 'file' | 'symlink' | 'other'} PathKind */
12
+
13
+ /**
14
+ * @typedef {object} LauncherSettings
15
+ * @property {string} runtimeDirectory
16
+ * @property {string} sourceUrl
17
+ * @property {string} sourceRef
18
+ * @property {string} packageManager
19
+ */
20
+
21
+ /**
22
+ * @typedef {object} CommandResult
23
+ * @property {number} status
24
+ * @property {string} stdout
25
+ * @property {string} stderr
26
+ * @property {string | undefined} [signal]
27
+ */
28
+
29
+ /**
30
+ * @typedef {object} LauncherAdapters
31
+ * @property {(path: string) => PathKind} inspectPath
32
+ * @property {(path: string) => void} makeDirectory
33
+ * @property {(path: string) => string} readText
34
+ * @property {(command: string, args: string[], options?: {cwd?: string, stdio?: 'inherit', capture?: boolean}) => CommandResult} run
35
+ * @property {(text: string) => void} writeOut
36
+ * @property {(text: string) => void} writeError
37
+ */
38
+
39
+ /**
40
+ * Parse the launcher's reserved management commands.
41
+ * @param {readonly string[]} argv - arguments after the executable.
42
+ * @returns {{kind: 'version'} | {kind: 'update'} | {kind: 'launch', args: string[]}}
43
+ */
44
+ export function parseLauncherInvocation(argv) {
45
+ if (argv[0] === '--') return { kind: 'launch', args: [...argv.slice(1)] }
46
+ if (argv[0] === 'version') {
47
+ if (argv.length !== 1) throw new Error('version takes no arguments; use `dsh-tui -- version ...` to send it as a task')
48
+ return { kind: 'version' }
49
+ }
50
+ if (argv[0] === 'update') {
51
+ if (argv.length !== 1) throw new Error('update takes no arguments; use `dsh-tui -- update ...` to send it as a task')
52
+ return { kind: 'update' }
53
+ }
54
+ return { kind: 'launch', args: [...argv] }
55
+ }
56
+
57
+ /**
58
+ * Resolve deployment settings without touching the filesystem.
59
+ * @param {{env: Readonly<Record<string, string | undefined>>, homeDirectory: string}} input
60
+ * @returns {LauncherSettings}
61
+ */
62
+ export function resolveLauncherSettings({ env, homeDirectory }) {
63
+ const runtimeDirectory = environmentValue(
64
+ env,
65
+ 'DSH_TUI_RUNTIME_DIR',
66
+ join(homeDirectory, '.local', 'share', 'dsh-tui', 'runtime'),
67
+ )
68
+ if (!isAbsolute(runtimeDirectory)) throw new Error('DSH_TUI_RUNTIME_DIR must be an absolute path')
69
+ return {
70
+ runtimeDirectory,
71
+ sourceUrl: environmentValue(env, 'DSH_TUI_SOURCE_URL', DEFAULT_SOURCE_URL),
72
+ sourceRef: environmentValue(env, 'DSH_TUI_SOURCE_REF', DEFAULT_SOURCE_REF),
73
+ packageManager: environmentValue(env, 'DSH_TUI_PNPM', 'pnpm'),
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Run one parsed launcher invocation.
79
+ * @param {{invocation: ReturnType<typeof parseLauncherInvocation>, settings: LauncherSettings, packageVersion: string, adapters: LauncherAdapters}} input
80
+ * @returns {number} process exit status.
81
+ */
82
+ export function runLauncher({ invocation, settings, packageVersion, adapters }) {
83
+ switch (invocation.kind) {
84
+ case 'version':
85
+ return showVersion({ settings, packageVersion, adapters })
86
+ case 'update':
87
+ return updateRuntime({ settings, adapters })
88
+ case 'launch':
89
+ return launchTui({ args: invocation.args, settings, adapters })
90
+ default:
91
+ return assertNever(invocation)
92
+ }
93
+ }
94
+
95
+ function environmentValue(env, name, fallback) {
96
+ const value = env[name]
97
+ if (value === undefined) return fallback
98
+ if (value.trim() === '') throw new Error(`${name} must not be empty`)
99
+ return value
100
+ }
101
+
102
+ function runtimeState(settings, adapters) {
103
+ const kind = adapters.inspectPath(settings.runtimeDirectory)
104
+ if (kind === 'symlink') return { ok: false, message: `runtime path is a symbolic link: ${settings.runtimeDirectory}` }
105
+ if (kind === 'missing') return { ok: true, initialized: false }
106
+ if (kind !== 'directory') return { ok: false, message: `runtime path is not a directory: ${settings.runtimeDirectory}` }
107
+ const gitKind = adapters.inspectPath(join(settings.runtimeDirectory, '.git'))
108
+ if (gitKind !== 'directory' && gitKind !== 'file') {
109
+ return { ok: false, message: `runtime directory is not a Git checkout: ${settings.runtimeDirectory}` }
110
+ }
111
+ return { ok: true, initialized: true }
112
+ }
113
+
114
+ function launchTui({ args, settings, adapters }) {
115
+ const state = runtimeState(settings, adapters)
116
+ if (!state.ok) return reportError(adapters, state.message)
117
+ if (!state.initialized) {
118
+ return reportError(adapters, `runtime is not initialized at ${settings.runtimeDirectory}; run \`dsh-tui update\` first`)
119
+ }
120
+ const result = adapters.run(
121
+ settings.packageManager,
122
+ ['dsh', '--profile', 'deepseek-tui', ...args],
123
+ { cwd: settings.runtimeDirectory, stdio: 'inherit' },
124
+ )
125
+ return result.status
126
+ }
127
+
128
+ function showVersion({ settings, packageVersion, adapters }) {
129
+ adapters.writeOut(`dsh-tui ${packageVersion}\n`)
130
+ adapters.writeOut(`runtime directory: ${settings.runtimeDirectory}\n`)
131
+ const state = runtimeState(settings, adapters)
132
+ if (!state.ok) return reportError(adapters, state.message)
133
+ if (!state.initialized) {
134
+ adapters.writeOut('runtime: uninitialized\n')
135
+ return 0
136
+ }
137
+ const revision = captureGit(adapters, settings.runtimeDirectory, ['rev-parse', 'HEAD'], 'read runtime HEAD')
138
+ if (!revision.ok) return revision.status
139
+ adapters.writeOut(`runtime SHA: ${revision.stdout}\n`)
140
+ adapters.writeOut(`DSH version: ${readDshVersion(settings.runtimeDirectory, adapters)}\n`)
141
+ return 0
142
+ }
143
+
144
+ function readDshVersion(runtimeDirectory, adapters) {
145
+ try {
146
+ const manifest = JSON.parse(adapters.readText(join(runtimeDirectory, 'package.json')))
147
+ return typeof manifest.version === 'string' && manifest.version !== '' ? manifest.version : 'unknown'
148
+ } catch (error) {
149
+ return `unreadable (${errorMessage(error)})`
150
+ }
151
+ }
152
+
153
+ function updateRuntime({ settings, adapters }) {
154
+ const state = runtimeState(settings, adapters)
155
+ if (!state.ok) return reportError(adapters, state.message)
156
+ if (!state.initialized) return cloneRuntime({ settings, adapters })
157
+
158
+ const clean = captureGit(adapters, settings.runtimeDirectory, ['status', '--porcelain'], 'inspect runtime worktree')
159
+ if (!clean.ok) return clean.status
160
+ if (clean.stdout !== '') {
161
+ return reportError(adapters, `runtime worktree is not clean: ${settings.runtimeDirectory}`)
162
+ }
163
+
164
+ const before = captureGit(adapters, settings.runtimeDirectory, ['rev-parse', 'HEAD'], 'read current runtime HEAD')
165
+ if (!before.ok) return before.status
166
+ const fetched = adapters.run(
167
+ 'git',
168
+ ['fetch', '--no-tags', '--', settings.sourceUrl, settings.sourceRef],
169
+ { cwd: settings.runtimeDirectory, capture: true },
170
+ )
171
+ if (fetched.status !== 0) {
172
+ return reportCommandFailure(adapters, 'fetch configured runtime source', fetched, [settings.sourceUrl])
173
+ }
174
+ const after = captureGit(adapters, settings.runtimeDirectory, ['rev-parse', 'FETCH_HEAD'], 'read fetched runtime HEAD')
175
+ if (!after.ok) return after.status
176
+
177
+ const ancestry = adapters.run(
178
+ 'git',
179
+ ['merge-base', '--is-ancestor', before.stdout, after.stdout],
180
+ { cwd: settings.runtimeDirectory, capture: true },
181
+ )
182
+ if (ancestry.status === 1) {
183
+ return reportError(
184
+ adapters,
185
+ `runtime update is not a fast-forward (${before.stdout} -> ${after.stdout}); replace the dedicated runtime only after reviewing its history`,
186
+ )
187
+ }
188
+ if (ancestry.status !== 0) return reportCommandFailure(adapters, 'verify runtime ancestry', ancestry)
189
+
190
+ const merge = adapters.run('git', ['merge', '--ff-only', after.stdout], {
191
+ cwd: settings.runtimeDirectory,
192
+ capture: true,
193
+ })
194
+ if (merge.status !== 0) return reportCommandFailure(adapters, 'fast-forward runtime', merge)
195
+ const installStatus = installDependencies({ settings, adapters, currentSha: after.stdout })
196
+ if (installStatus !== 0) return installStatus
197
+ adapters.writeOut(`runtime updated: ${before.stdout} -> ${after.stdout}\n`)
198
+ return 0
199
+ }
200
+
201
+ function cloneRuntime({ settings, adapters }) {
202
+ adapters.makeDirectory(dirname(settings.runtimeDirectory))
203
+ const clone = adapters.run(
204
+ 'git',
205
+ ['clone', '--branch', settings.sourceRef, '--single-branch', '--', settings.sourceUrl, settings.runtimeDirectory],
206
+ { capture: true },
207
+ )
208
+ if (clone.status !== 0) return reportCommandFailure(adapters, 'clone configured runtime source', clone, [settings.sourceUrl])
209
+ const installStatus = installDependencies({ settings, adapters, currentSha: 'new checkout' })
210
+ if (installStatus !== 0) return installStatus
211
+ const revision = captureGit(adapters, settings.runtimeDirectory, ['rev-parse', 'HEAD'], 'read cloned runtime HEAD')
212
+ if (!revision.ok) return revision.status
213
+ adapters.writeOut(`runtime initialized: ${revision.stdout}\n`)
214
+ return 0
215
+ }
216
+
217
+ function installDependencies({ settings, adapters, currentSha }) {
218
+ const install = adapters.run(settings.packageManager, ['install', '--frozen-lockfile'], {
219
+ cwd: settings.runtimeDirectory,
220
+ stdio: 'inherit',
221
+ })
222
+ if (install.status === 0) return 0
223
+ adapters.writeError(`dsh-tui: dependency refresh failed at ${currentSha}; runtime is not ready\n`)
224
+ adapters.writeError(`dsh-tui: recover with: cd ${settings.runtimeDirectory} && ${settings.packageManager} install --frozen-lockfile\n`)
225
+ return install.status
226
+ }
227
+
228
+ function captureGit(adapters, cwd, args, label) {
229
+ const result = adapters.run('git', args, { cwd, capture: true })
230
+ if (result.status !== 0) return { ok: false, status: reportCommandFailure(adapters, label, result) }
231
+ return { ok: true, stdout: result.stdout.trim() }
232
+ }
233
+
234
+ function reportCommandFailure(adapters, label, result, redactions = []) {
235
+ let detail = result.stderr.trim()
236
+ for (const value of redactions) detail = detail.replaceAll(value, '<source>')
237
+ detail = detail.replaceAll(/https?:\/\/[^/@\s]+@/g, 'https://<credentials>@')
238
+ adapters.writeError(`dsh-tui: ${label} failed${detail === '' ? '' : `: ${detail}`}\n`)
239
+ return result.status === 0 ? 1 : result.status
240
+ }
241
+
242
+ function reportError(adapters, message) {
243
+ adapters.writeError(`dsh-tui: ${message}\n`)
244
+ return 1
245
+ }
246
+
247
+ function errorMessage(error) {
248
+ return error instanceof Error ? error.message : String(error)
249
+ }
250
+
251
+ function assertNever(value) {
252
+ throw new Error(`unhandled launcher invocation: ${JSON.stringify(value)}`)
253
+ }