dsh-single-terminal 0.1.0

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.
Files changed (72) hide show
  1. package/README.md +144 -0
  2. package/README.zh.md +129 -0
  3. package/cordis.patch.yml +11 -0
  4. package/lib/client.js +11438 -0
  5. package/lib/client.js.map +1 -0
  6. package/lib/index.js +588 -0
  7. package/lib/types/client/controller.d.ts +86 -0
  8. package/lib/types/client/controller.d.ts.map +1 -0
  9. package/lib/types/client/controller.js +235 -0
  10. package/lib/types/client/drawer.d.ts +10 -0
  11. package/lib/types/client/drawer.d.ts.map +1 -0
  12. package/lib/types/client/drawer.js +84 -0
  13. package/lib/types/client/i18n.d.ts +8 -0
  14. package/lib/types/client/i18n.d.ts.map +1 -0
  15. package/lib/types/client/i18n.js +51 -0
  16. package/lib/types/client/index.d.ts +13 -0
  17. package/lib/types/client/index.d.ts.map +1 -0
  18. package/lib/types/client/index.js +13 -0
  19. package/lib/types/client/plugin.d.ts +23 -0
  20. package/lib/types/client/plugin.d.ts.map +1 -0
  21. package/lib/types/client/plugin.js +60 -0
  22. package/lib/types/client/protocol.d.ts +81 -0
  23. package/lib/types/client/protocol.d.ts.map +1 -0
  24. package/lib/types/client/protocol.js +5 -0
  25. package/lib/types/client/storage.d.ts +6 -0
  26. package/lib/types/client/storage.d.ts.map +1 -0
  27. package/lib/types/client/storage.js +21 -0
  28. package/lib/types/client/styles.d.ts +5 -0
  29. package/lib/types/client/styles.d.ts.map +1 -0
  30. package/lib/types/client/styles.js +103 -0
  31. package/lib/types/client/term.d.ts +15 -0
  32. package/lib/types/client/term.d.ts.map +1 -0
  33. package/lib/types/client/term.js +86 -0
  34. package/lib/types/client/toggle.d.ts +9 -0
  35. package/lib/types/client/toggle.d.ts.map +1 -0
  36. package/lib/types/client/toggle.js +19 -0
  37. package/lib/types/client/ws.d.ts +25 -0
  38. package/lib/types/client/ws.d.ts.map +1 -0
  39. package/lib/types/client/ws.js +79 -0
  40. package/lib/types/client/xterm-css.d.ts +7 -0
  41. package/lib/types/client/xterm-css.d.ts.map +1 -0
  42. package/lib/types/client/xterm-css.js +224 -0
  43. package/lib/types/host/hub.d.ts +39 -0
  44. package/lib/types/host/hub.d.ts.map +1 -0
  45. package/lib/types/host/hub.js +291 -0
  46. package/lib/types/host/index.d.ts +17 -0
  47. package/lib/types/host/index.d.ts.map +1 -0
  48. package/lib/types/host/index.js +66 -0
  49. package/lib/types/host/shells.d.ts +30 -0
  50. package/lib/types/host/shells.d.ts.map +1 -0
  51. package/lib/types/host/shells.js +202 -0
  52. package/lib/types/host/types.d.ts +109 -0
  53. package/lib/types/host/types.d.ts.map +1 -0
  54. package/lib/types/host/types.js +4 -0
  55. package/package.json +102 -0
  56. package/src/client/controller.ts +293 -0
  57. package/src/client/drawer.tsx +182 -0
  58. package/src/client/i18n.ts +58 -0
  59. package/src/client/index.ts +17 -0
  60. package/src/client/plugin.tsx +92 -0
  61. package/src/client/protocol.ts +40 -0
  62. package/src/client/storage.ts +21 -0
  63. package/src/client/styles.ts +106 -0
  64. package/src/client/term.tsx +95 -0
  65. package/src/client/toggle.tsx +43 -0
  66. package/src/client/ws.ts +81 -0
  67. package/src/client/xterm-css.ts +224 -0
  68. package/src/host/cordis-augment.d.ts +22 -0
  69. package/src/host/hub.ts +302 -0
  70. package/src/host/index.ts +78 -0
  71. package/src/host/shells.ts +216 -0
  72. package/src/host/types.ts +79 -0
@@ -0,0 +1,291 @@
1
+ /**
2
+ * dsh-single-terminal —— 终端 Hub:PTY 会话管理 + WebSocket 帧协议。
3
+ *
4
+ * - PTY 经插件自身依赖 node-pty(原生模块,external)直连 ConPTY/POSIX pty,
5
+ * 获得完整 resize/kill/数据流控制(宿主 ctx.subprocess 的 TerminalHandle
6
+ * 接口不暴露 resize,无法满足抽屉尺寸跟随);
7
+ * - 会话与 WebSocket 连接解耦:页面刷新/抽屉关闭后会话保活,重连后 attach
8
+ * 回放环形缓冲(容量 scrollbackLimit);
9
+ * - 多浏览器标签页可同时 attach 同一会话:输出广播、输入合并。
10
+ */
11
+ import { spawn } from 'node:child_process';
12
+ import { randomUUID } from 'node:crypto';
13
+ import { statSync } from 'node:fs';
14
+ import { createRequire } from 'node:module';
15
+ import { homedir } from 'node:os';
16
+ import { isAbsolute } from 'node:path';
17
+ import { WebSocket, WebSocketServer } from 'ws';
18
+ const require = createRequire(import.meta.url);
19
+ const nodePty = require('node-pty');
20
+ const MAX_SESSIONS = 20;
21
+ const INPUT_LIMIT = 1 << 16;
22
+ const COLS_MIN = 2;
23
+ const COLS_MAX = 500;
24
+ const ROWS_MIN = 2;
25
+ const ROWS_MAX = 300;
26
+ const clamp = (value, min, max) => Number.isFinite(value) ? Math.min(max, Math.max(min, Math.round(value))) : min;
27
+ export class TerminalHub {
28
+ config;
29
+ registry;
30
+ wss = new WebSocketServer({ noServer: true });
31
+ sockets = new Set();
32
+ sessions = new Map();
33
+ nextSocketId = 0;
34
+ constructor(config, registry) {
35
+ this.config = config;
36
+ this.registry = registry;
37
+ this.wss.on('connection', (socket) => { this.handleConnection(socket); });
38
+ }
39
+ /** webServer.registerUpgrade 的 handler 入口(鉴权由插件入口完成后调用)。 */
40
+ handleUpgrade(req, socket, head) {
41
+ this.wss.handleUpgrade(req, socket, head, (ws) => {
42
+ this.wss.emit('connection', ws, req);
43
+ });
44
+ }
45
+ /** 插件卸载:终止全部会话并断开所有连接。 */
46
+ dispose() {
47
+ console.log(`[dsh-single-terminal] hub dispose (${this.sessions.size} sessions, ${this.sockets.size} sockets)`);
48
+ for (const session of this.sessions.values()) {
49
+ this.killSession(session);
50
+ }
51
+ this.sessions.clear();
52
+ for (const socket of this.sockets) {
53
+ try {
54
+ socket.terminate();
55
+ }
56
+ catch { /* already gone */ }
57
+ }
58
+ this.sockets.clear();
59
+ }
60
+ /* ── 连接生命周期 ─────────────────────────────────────────────── */
61
+ handleConnection(socket) {
62
+ const sid = ++this.nextSocketId;
63
+ this.sockets.add(socket);
64
+ console.log(`[dsh-single-terminal] ws#${sid} connected (total ${this.sockets.size})`);
65
+ socket.on('message', (raw) => { this.dispatch(socket, raw, sid); });
66
+ socket.on('close', () => { this.sockets.delete(socket); console.log(`[dsh-single-terminal] ws#${sid} closed (total ${this.sockets.size})`); });
67
+ socket.on('error', () => { this.sockets.delete(socket); });
68
+ this.send(socket, {
69
+ type: 'hello',
70
+ platform: process.platform,
71
+ defaultShell: this.registry.defaultShellId(this.config.defaultShell),
72
+ fontSize: this.config.fontSize,
73
+ fontFamily: this.config.fontFamily,
74
+ });
75
+ }
76
+ dispatch(socket, raw, sid) {
77
+ let frame;
78
+ try {
79
+ frame = JSON.parse(String(raw));
80
+ }
81
+ catch {
82
+ return;
83
+ }
84
+ if (frame.type !== 'input' && frame.type !== 'resize') {
85
+ const detail = 'id' in frame ? ` id=${String(frame.id).slice(0, 8)}` : '';
86
+ console.log(`[dsh-single-terminal] ws#${sid} <- ${frame.type}${detail}`);
87
+ }
88
+ switch (frame.type) {
89
+ case 'ping':
90
+ this.send(socket, { type: 'pong' });
91
+ break;
92
+ case 'list':
93
+ this.send(socket, this.snapshotFrame());
94
+ break;
95
+ case 'open':
96
+ this.open(frame.shellId, frame.cols, frame.rows, frame.cwd);
97
+ break;
98
+ case 'input': {
99
+ const session = this.sessions.get(frame.id);
100
+ if (session === undefined || !session.alive)
101
+ break;
102
+ try {
103
+ session.pty.write(frame.data.length > INPUT_LIMIT ? frame.data.slice(0, INPUT_LIMIT) : frame.data);
104
+ }
105
+ catch { /* dead pty: exit event follows */ }
106
+ break;
107
+ }
108
+ case 'resize': {
109
+ const session = this.sessions.get(frame.id);
110
+ if (session === undefined || !session.alive)
111
+ break;
112
+ try {
113
+ session.pty.resize(clamp(frame.cols, COLS_MIN, COLS_MAX), clamp(frame.rows, ROWS_MIN, ROWS_MAX));
114
+ }
115
+ catch { /* transient during teardown */ }
116
+ break;
117
+ }
118
+ case 'attach': {
119
+ const session = this.sessions.get(frame.id);
120
+ if (session === undefined)
121
+ break;
122
+ this.send(socket, { type: 'replay', id: session.id, data: session.buffer });
123
+ if (!session.alive) {
124
+ this.send(socket, { type: 'exit', id: session.id, exitCode: session.exitCode, signal: session.signal });
125
+ }
126
+ break;
127
+ }
128
+ case 'close':
129
+ this.close(frame.id);
130
+ break;
131
+ }
132
+ }
133
+ /* ── 会话管理 ────────────────────────────────────────────────── */
134
+ open(shellId, cols, rows, cwdOverride) {
135
+ if (this.sessions.size >= MAX_SESSIONS) {
136
+ this.broadcast({ type: 'error', message: '终端数量已达上限 / terminal session limit reached' });
137
+ return;
138
+ }
139
+ const shell = this.registry.resolve(shellId);
140
+ if (shell === null) {
141
+ this.broadcast({ type: 'error', message: `未知或不可用的 shell:「${shellId}」/ unknown or unavailable shell` });
142
+ return;
143
+ }
144
+ const cwd = this.resolveCwd(cwdOverride);
145
+ const env = { ...process.env };
146
+ if (process.platform !== 'win32')
147
+ env.TERM = 'xterm-256color';
148
+ let pty;
149
+ try {
150
+ pty = nodePty.spawn(shell.file, shell.args, {
151
+ name: 'xterm-256color',
152
+ cols: clamp(cols, COLS_MIN, COLS_MAX),
153
+ rows: clamp(rows, ROWS_MIN, ROWS_MAX),
154
+ cwd,
155
+ env,
156
+ });
157
+ }
158
+ catch (e) {
159
+ const message = e instanceof Error ? e.message : String(e);
160
+ this.broadcast({ type: 'error', message: `无法启动 ${shell.name} / failed to spawn ${shell.name}: ${message}` });
161
+ return;
162
+ }
163
+ const session = {
164
+ id: randomUUID(),
165
+ shell,
166
+ cwd,
167
+ pty,
168
+ buffer: '',
169
+ alive: true,
170
+ closing: false,
171
+ exitCode: null,
172
+ signal: null,
173
+ };
174
+ this.sessions.set(session.id, session);
175
+ console.log(`[dsh-single-terminal] session ${session.id.slice(0, 8)} opened shell=${shell.id} pid=${pty.pid} cwd=${cwd} (${this.sessions.size} total)`);
176
+ pty.onData((data) => {
177
+ this.appendBuffer(session, data);
178
+ this.broadcast({ type: 'data', id: session.id, data });
179
+ });
180
+ pty.onExit(({ exitCode, signal }) => {
181
+ session.alive = false;
182
+ session.exitCode = exitCode;
183
+ session.signal = typeof signal === 'number' ? signal : null;
184
+ if (this.sessions.has(session.id)) {
185
+ this.broadcast({ type: 'exit', id: session.id, exitCode: session.exitCode, signal: session.signal });
186
+ // 已广播 exit,客户端本地标记「已退出」;从快照移除,避免页面刷新后
187
+ // 被 adopt 成死标签。之后对该 id 的 close 帧静默忽略(客户端已删标签)。
188
+ this.sessions.delete(session.id);
189
+ console.log(`[dsh-single-terminal] session ${session.id.slice(0, 8)} (pid ${session.pty.pid}) exited code=${exitCode}`);
190
+ }
191
+ });
192
+ this.broadcast({ type: 'opened', session: this.sessionInfo(session) });
193
+ }
194
+ close(id) {
195
+ const session = this.sessions.get(id);
196
+ if (session === undefined)
197
+ return;
198
+ console.log(`[dsh-single-terminal] closing session ${id.slice(0, 8)} (pid ${session.pty.pid})`);
199
+ this.sessions.delete(id);
200
+ if (!session.alive) {
201
+ this.broadcast({ type: 'exit', id, exitCode: session.exitCode, signal: session.signal, closed: true });
202
+ return;
203
+ }
204
+ session.closing = true;
205
+ this.killSession(session);
206
+ this.broadcast({ type: 'exit', id, exitCode: null, signal: null, closed: true });
207
+ }
208
+ killSession(session) {
209
+ try {
210
+ session.pty.kill();
211
+ }
212
+ catch { /* already dead */ }
213
+ if (process.platform === 'win32') {
214
+ // node-pty 关闭 ConPTY 在部分 shell(powershell + PSReadLine)下不会结束
215
+ // 整棵进程树(实测 shell 存活、控制台子进程死亡),补 taskkill /T /F 兜底;
216
+ // 进程已死时 taskkill 静默失败。
217
+ try {
218
+ spawn('taskkill', ['/T', '/F', '/PID', String(session.pty.pid)], { stdio: 'ignore' });
219
+ }
220
+ catch { /* best effort */ }
221
+ }
222
+ else {
223
+ // forkpty 使 shell 成为会话首进程(pid == pgid),负 pid 杀整个进程组。
224
+ try {
225
+ process.kill(-session.pty.pid, 'SIGKILL');
226
+ }
227
+ catch { /* group gone */ }
228
+ }
229
+ }
230
+ resolveCwd(override) {
231
+ const validDirectory = (path) => {
232
+ try {
233
+ return statSync(path).isDirectory();
234
+ }
235
+ catch {
236
+ return false;
237
+ }
238
+ };
239
+ if (override !== undefined) {
240
+ const trimmed = override.trim();
241
+ if (trimmed.length > 0 && isAbsolute(trimmed) && validDirectory(trimmed))
242
+ return trimmed;
243
+ }
244
+ const configured = (this.config.defaultCwd ?? 'workspace').trim();
245
+ if (configured.length > 0 && configured !== 'workspace' && configured !== 'home' && isAbsolute(configured) && validDirectory(configured)) {
246
+ return configured;
247
+ }
248
+ return homedir();
249
+ }
250
+ appendBuffer(session, data) {
251
+ session.buffer += data;
252
+ const limit = this.config.scrollbackLimit;
253
+ if (session.buffer.length > limit) {
254
+ session.buffer = session.buffer.slice(session.buffer.length - limit);
255
+ }
256
+ }
257
+ /* ── 帧与快照 ────────────────────────────────────────────────── */
258
+ snapshotFrame() {
259
+ return {
260
+ type: 'shells',
261
+ defaultShell: this.registry.defaultShellId(this.config.defaultShell),
262
+ shells: this.registry.list(),
263
+ sessions: [...this.sessions.values()].map((session) => this.sessionInfo(session)),
264
+ };
265
+ }
266
+ sessionInfo(session) {
267
+ return {
268
+ id: session.id,
269
+ shellId: session.shell.id,
270
+ label: session.shell.name,
271
+ cwd: session.cwd,
272
+ alive: session.alive,
273
+ pid: session.pty.pid,
274
+ exitCode: session.exitCode,
275
+ signal: session.signal,
276
+ };
277
+ }
278
+ send(socket, frame) {
279
+ if (socket.readyState !== WebSocket.OPEN)
280
+ return;
281
+ try {
282
+ socket.send(JSON.stringify(frame));
283
+ }
284
+ catch { /* connection raced closed */ }
285
+ }
286
+ broadcast(frame) {
287
+ for (const socket of this.sockets) {
288
+ this.send(socket, frame);
289
+ }
290
+ }
291
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * dsh-single-terminal —— 插件宿主半边入口。
3
+ *
4
+ * - WebSocket 路由 /api/dsh-single-terminal.ws(webServer.registerUpgrade,exact path):
5
+ * 鉴权复用宿主 connection.requestRejection(Host/Origin 围栏 + dsh-auth cookie),
6
+ * 升级后交给 TerminalHub 处理 JSON 帧协议;
7
+ * - Config(schemastery)由宿主 Plugins 设置页渲染,热更新经 plugin 重载生效;
8
+ * - 卸载清理:注销路由 + 终止全部 PTY 会话(ctx.effect disposer)。
9
+ */
10
+ import type { Context } from '@deepseek-ai/cordis';
11
+ import type { CustomShellConfig, TerminalPluginConfig } from './types.ts';
12
+ export declare const name = "dsh-single-terminal";
13
+ export declare const inject: string[];
14
+ export declare const Config: import('@deepseek-ai/schemastery').default<TerminalPluginConfig>;
15
+ export declare function apply(ctx: Context, config: TerminalPluginConfig): void;
16
+ export type { CustomShellConfig, TerminalPluginConfig };
17
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/host/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAIlD,OAAO,KAAK,EAAqB,iBAAiB,EAAE,oBAAoB,EAAoB,MAAM,YAAY,CAAA;AAE9G,eAAO,MAAM,IAAI,wBAAwB,CAAA;AACzC,eAAO,MAAM,MAAM,UAA8B,CAAA;AAWjD,eAAO,MAAM,MAAM,EAAE,OAAO,0BAA0B,EAAE,OAAO,CAAC,oBAAoB,CAUH,CAAA;AAMjF,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,oBAAoB,GAAG,IAAI,CA6BtE;AAED,YAAY,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,CAAA"}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * dsh-single-terminal —— 插件宿主半边入口。
3
+ *
4
+ * - WebSocket 路由 /api/dsh-single-terminal.ws(webServer.registerUpgrade,exact path):
5
+ * 鉴权复用宿主 connection.requestRejection(Host/Origin 围栏 + dsh-auth cookie),
6
+ * 升级后交给 TerminalHub 处理 JSON 帧协议;
7
+ * - Config(schemastery)由宿主 Plugins 设置页渲染,热更新经 plugin 重载生效;
8
+ * - 卸载清理:注销路由 + 终止全部 PTY 会话(ctx.effect disposer)。
9
+ */
10
+ import Schema from '@deepseek-ai/schemastery';
11
+ import { TerminalHub } from "./hub.js";
12
+ import { ShellRegistry } from "./shells.js";
13
+ export const name = 'dsh-single-terminal';
14
+ export const inject = ['webServer', 'connection'];
15
+ /* ── 配置(docs/develop/basic/config)────────────────────────────── */
16
+ const CustomShellSchema = Schema.object({
17
+ id: Schema.string().required().description('唯一 id,如 my-shell'),
18
+ name: Schema.string().required().description('菜单显示名,如 My Shell'),
19
+ command: Schema.string().required().description('可执行文件路径或 PATH 上的命令名,如 nu'),
20
+ args: Schema.array(Schema.string()).default([]).description('启动参数'),
21
+ });
22
+ export const Config = Schema.object({
23
+ defaultShell: Schema.string().default('powershell')
24
+ .description('默认 shell id:powershell | pwsh | cmd | gitbash | wsl(或自定义 shell 的 id)。非 Windows 下自动回退到 $SHELL/bash'),
25
+ defaultCwd: Schema.string().default('workspace')
26
+ .description('终端初始目录:workspace(当前工作区根,未知时回退用户主目录)| home | 绝对路径'),
27
+ scrollbackLimit: Schema.number().default(200_000)
28
+ .description('重连回放缓冲的字符上限(每会话)'),
29
+ fontSize: Schema.number().default(13).description('终端字号'),
30
+ fontFamily: Schema.string().default('Consolas, "Cascadia Mono", "Courier New", monospace').description('终端字体'),
31
+ customShells: Schema.array(CustomShellSchema).default([]).description('自定义 shell 列表'),
32
+ });
33
+ /* ── apply ───────────────────────────────────────────────────────── */
34
+ const WS_PATH = '/api/dsh-single-terminal.ws';
35
+ export function apply(ctx, config) {
36
+ const webServer = ctx.get('webServer');
37
+ const connection = ctx.get('connection');
38
+ if (webServer === undefined || connection === undefined)
39
+ return;
40
+ const registry = new ShellRegistry(config.customShells ?? []);
41
+ const hub = new TerminalHub(config, registry);
42
+ let unregister;
43
+ try {
44
+ unregister = webServer.registerUpgrade({
45
+ path: WS_PATH,
46
+ handler: (req, socket, head) => {
47
+ const rejection = connection.requestRejection(req);
48
+ if (rejection !== undefined) {
49
+ socket.destroy();
50
+ return;
51
+ }
52
+ hub.handleUpgrade(req, socket, head);
53
+ },
54
+ });
55
+ }
56
+ catch (e) {
57
+ console.warn('[dsh-single-terminal] register upgrade route failed:', e instanceof Error ? e.message : String(e));
58
+ }
59
+ ctx.effect(() => () => {
60
+ try {
61
+ unregister?.();
62
+ }
63
+ catch { /* already unregistered */ }
64
+ hub.dispose();
65
+ });
66
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * dsh-single-terminal —— shell 注册表与探测。
3
+ *
4
+ * 探测范式对齐宿主 packages/shell/pwsh-local/src/resolve.ts:
5
+ * 已知路径 + PATH 逐项探测 + lstatSync(isFile||isSymbolicLink),
6
+ * 不用注册表 / where.exe。检测不到的 shell available=false(客户端隐藏)。
7
+ */
8
+ import type { CustomShellConfig } from './types.ts';
9
+ export interface ResolvedShell {
10
+ id: string;
11
+ name: string;
12
+ file: string;
13
+ args: string[];
14
+ }
15
+ export declare class ShellRegistry {
16
+ private readonly customShells;
17
+ constructor(customShells: readonly CustomShellConfig[]);
18
+ /** 枚举当前平台可配置的 shell(检测不到的 available=false,由客户端隐藏)。 */
19
+ list(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): Array<{
20
+ id: string;
21
+ name: string;
22
+ available: boolean;
23
+ }>;
24
+ /** 解析为可执行规格;内置 id 优先,其次自定义 shell。 */
25
+ resolve(id: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): ResolvedShell | null;
26
+ /** 默认 shell id:配置值可用则用之;否则 win32 用 powershell,POSIX 用 $SHELL 名或 bash。 */
27
+ defaultShellId(preferred: string | undefined, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string;
28
+ private resolveCustom;
29
+ }
30
+ //# sourceMappingURL=shells.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shells.d.ts","sourceRoot":"","sources":["../../../src/host/shells.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAA;AAEnD,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,EAAE,CAAA;CACf;AAmJD,qBAAa,aAAa;IACZ,OAAO,CAAC,QAAQ,CAAC,YAAY;gBAAZ,YAAY,EAAE,SAAS,iBAAiB,EAAE;IAEvE,sDAAsD;IACtD,IAAI,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,EAAE,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAAG,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,OAAO,CAAA;KAAE,CAAC;IAajJ,qCAAqC;IACrC,OAAO,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,GAAE,MAAM,CAAC,UAAwB,EAAE,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAAG,aAAa,GAAG,IAAI;IAc7H,yEAAyE;IACzE,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,GAAG,GAAE,MAAM,CAAC,UAAwB,EAAE,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAAG,MAAM;IAQzI,OAAO,CAAC,aAAa;CAUtB"}
@@ -0,0 +1,202 @@
1
+ /**
2
+ * dsh-single-terminal —— shell 注册表与探测。
3
+ *
4
+ * 探测范式对齐宿主 packages/shell/pwsh-local/src/resolve.ts:
5
+ * 已知路径 + PATH 逐项探测 + lstatSync(isFile||isSymbolicLink),
6
+ * 不用注册表 / where.exe。检测不到的 shell available=false(客户端隐藏)。
7
+ */
8
+ import { lstatSync } from 'node:fs';
9
+ import { delimiter, join } from 'node:path';
10
+ function isFileLike(path) {
11
+ try {
12
+ const stat = lstatSync(path);
13
+ return stat.isFile() || stat.isSymbolicLink();
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ function firstExisting(paths) {
20
+ for (const path of paths) {
21
+ if (path.length > 0 && isFileLike(path))
22
+ return path;
23
+ }
24
+ return null;
25
+ }
26
+ function pathEntries(env) {
27
+ return (env.PATH ?? '')
28
+ .split(delimiter)
29
+ .map((entry) => entry.trim().replace(/^"|"$/g, ''))
30
+ .filter((entry) => entry.length > 0);
31
+ }
32
+ /** PATH 逐项探测一个裸可执行名(Windows 附加 PATHEXT 扩展名)。 */
33
+ function probeOnPath(name, env, platform) {
34
+ const extensions = platform === 'win32'
35
+ ? (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter((ext) => ext.length > 0)
36
+ : [''];
37
+ const candidates = [];
38
+ for (const entry of pathEntries(env)) {
39
+ for (const ext of extensions)
40
+ candidates.push(join(entry, name + ext));
41
+ }
42
+ return firstExisting(candidates);
43
+ }
44
+ const WINDOWS_BUILTINS = [
45
+ {
46
+ id: 'powershell',
47
+ name: 'PowerShell',
48
+ platforms: ['win32'],
49
+ resolve: (env) => {
50
+ const systemRoot = env.SystemRoot ?? 'C:\\Windows';
51
+ const file = firstExisting([join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')]);
52
+ return file === null ? null : { file, args: [] };
53
+ },
54
+ },
55
+ {
56
+ id: 'pwsh',
57
+ name: 'PowerShell 7',
58
+ platforms: ['win32', 'darwin', 'linux'],
59
+ resolve: (env, platform) => {
60
+ const candidates = [];
61
+ if (platform === 'win32') {
62
+ const programFiles = env.ProgramFiles ?? 'C:\\Program Files';
63
+ candidates.push(join(programFiles, 'PowerShell', '7', 'pwsh.exe'));
64
+ for (const entry of pathEntries(env))
65
+ candidates.push(join(entry, 'pwsh.exe'));
66
+ }
67
+ else {
68
+ candidates.push(...pathEntries(env).map((entry) => join(entry, 'pwsh')));
69
+ }
70
+ const file = firstExisting(candidates);
71
+ return file === null ? null : { file, args: [] };
72
+ },
73
+ },
74
+ {
75
+ id: 'cmd',
76
+ name: 'CMD',
77
+ platforms: ['win32'],
78
+ resolve: (env) => {
79
+ const systemRoot = env.SystemRoot ?? 'C:\\Windows';
80
+ const file = firstExisting([join(systemRoot, 'System32', 'cmd.exe')]);
81
+ return file === null ? null : { file, args: [] };
82
+ },
83
+ },
84
+ {
85
+ id: 'gitbash',
86
+ name: 'Git Bash',
87
+ platforms: ['win32'],
88
+ resolve: (env) => {
89
+ const programFiles = env.ProgramFiles ?? 'C:\\Program Files';
90
+ const programFilesX86 = env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)';
91
+ const localAppData = env.LocalAppData ?? '';
92
+ const file = firstExisting([
93
+ join(programFiles, 'Git', 'bin', 'bash.exe'),
94
+ join(programFiles, 'Git', 'usr', 'bin', 'bash.exe'),
95
+ join(programFilesX86, 'Git', 'bin', 'bash.exe'),
96
+ localAppData.length > 0 ? join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe') : '',
97
+ ]);
98
+ return file === null ? null : { file, args: ['-i'] };
99
+ },
100
+ },
101
+ {
102
+ id: 'wsl',
103
+ name: 'WSL',
104
+ platforms: ['win32'],
105
+ resolve: (env) => {
106
+ const systemRoot = env.SystemRoot ?? 'C:\\Windows';
107
+ const file = firstExisting([join(systemRoot, 'System32', 'wsl.exe')]);
108
+ return file === null ? null : { file, args: [] };
109
+ },
110
+ },
111
+ ];
112
+ const POSIX_BUILTINS = [
113
+ {
114
+ id: 'bash',
115
+ name: 'Bash',
116
+ platforms: ['darwin', 'linux'],
117
+ resolve: (env) => {
118
+ const file = firstExisting([...pathEntries(env).map((entry) => join(entry, 'bash')), '/bin/bash', '/usr/bin/bash']);
119
+ return file === null ? null : { file, args: [] };
120
+ },
121
+ },
122
+ {
123
+ id: 'zsh',
124
+ name: 'Zsh',
125
+ platforms: ['darwin', 'linux'],
126
+ resolve: (env) => {
127
+ const file = firstExisting(['/bin/zsh', '/usr/bin/zsh', ...pathEntries(env).map((entry) => join(entry, 'zsh'))]);
128
+ return file === null ? null : { file, args: [] };
129
+ },
130
+ },
131
+ {
132
+ id: 'fish',
133
+ name: 'Fish',
134
+ platforms: ['darwin', 'linux'],
135
+ resolve: (env) => {
136
+ const file = firstExisting([
137
+ '/usr/local/bin/fish',
138
+ '/opt/homebrew/bin/fish',
139
+ ...pathEntries(env).map((entry) => join(entry, 'fish')),
140
+ ]);
141
+ return file === null ? null : { file, args: [] };
142
+ },
143
+ },
144
+ ];
145
+ const BUILTINS = [...WINDOWS_BUILTINS, ...POSIX_BUILTINS];
146
+ export class ShellRegistry {
147
+ customShells;
148
+ constructor(customShells) {
149
+ this.customShells = customShells;
150
+ }
151
+ /** 枚举当前平台可配置的 shell(检测不到的 available=false,由客户端隐藏)。 */
152
+ list(env = process.env, platform = process.platform) {
153
+ const result = [];
154
+ for (const def of BUILTINS) {
155
+ if (!def.platforms.includes(platform))
156
+ continue;
157
+ result.push({ id: def.id, name: def.name, available: def.resolve(env, platform) !== null });
158
+ }
159
+ for (const custom of this.customShells) {
160
+ if (platform !== 'win32' && !/[\\/]/.test(custom.command) && custom.command.toLowerCase().endsWith('.exe'))
161
+ continue;
162
+ result.push({ id: custom.id, name: custom.name, available: this.resolveCustom(custom, env, platform) !== null });
163
+ }
164
+ return result;
165
+ }
166
+ /** 解析为可执行规格;内置 id 优先,其次自定义 shell。 */
167
+ resolve(id, env = process.env, platform = process.platform) {
168
+ const builtin = BUILTINS.find((def) => def.id === id && def.platforms.includes(platform));
169
+ if (builtin !== undefined) {
170
+ const resolved = builtin.resolve(env, platform);
171
+ return resolved === null ? null : { id: builtin.id, name: builtin.name, ...resolved };
172
+ }
173
+ const custom = this.customShells.find((entry) => entry.id === id);
174
+ if (custom !== undefined) {
175
+ const resolved = this.resolveCustom(custom, env, platform);
176
+ return resolved === null ? null : { id: custom.id, name: custom.name, ...resolved };
177
+ }
178
+ return null;
179
+ }
180
+ /** 默认 shell id:配置值可用则用之;否则 win32 用 powershell,POSIX 用 $SHELL 名或 bash。 */
181
+ defaultShellId(preferred, env = process.env, platform = process.platform) {
182
+ if (preferred !== undefined && preferred.length > 0 && this.resolve(preferred, env, platform) !== null)
183
+ return preferred;
184
+ if (platform === 'win32')
185
+ return 'powershell';
186
+ const shellName = (env.SHELL ?? '').split('/').pop() ?? '';
187
+ if (shellName.length > 0 && this.resolve(shellName, env, platform) !== null)
188
+ return shellName;
189
+ return 'bash';
190
+ }
191
+ resolveCustom(custom, env, platform) {
192
+ const raw = custom.command.trim();
193
+ if (raw.length === 0)
194
+ return null;
195
+ const args = Array.isArray(custom.args) ? [...custom.args] : [];
196
+ if (/[\\/]/.test(raw) || isFileLike(raw)) {
197
+ return isFileLike(raw) ? { file: raw, args } : null;
198
+ }
199
+ const file = probeOnPath(raw, env, platform);
200
+ return file === null ? null : { file, args };
201
+ }
202
+ }