@keith9681/dsh-sidebar 0.15.2
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 +21 -0
- package/README.md +0 -0
- package/README_EN.md +0 -0
- package/cordis.patch.yml +49 -0
- package/lib/client-editor.js +34651 -0
- package/lib/client-mermaid.js +200629 -0
- package/lib/client-registry.js +13388 -0
- package/lib/client-terminal.js +8904 -0
- package/lib/client.js +13388 -0
- package/lib/index.js +3959 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-pty.d.ts +226 -0
- package/lib/types/browser-probe.d.ts +15 -0
- package/lib/types/bundle-route.d.ts +12 -0
- package/lib/types/client/BrowserView.d.ts +22 -0
- package/lib/types/client/DiffTab.d.ts +6 -0
- package/lib/types/client/DiffView.d.ts +51 -0
- package/lib/types/client/EditorHost.d.ts +12 -0
- package/lib/types/client/FileTree.d.ts +36 -0
- package/lib/types/client/GitView.d.ts +8 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/RenderBoundary.d.ts +32 -0
- package/lib/types/client/SandboxStatusBar.d.ts +10 -0
- package/lib/types/client/SideCardSection.d.ts +74 -0
- package/lib/types/client/SideChatView.d.ts +16 -0
- package/lib/types/client/Sidebar.d.ts +6 -0
- package/lib/types/client/SubagentView.d.ts +16 -0
- package/lib/types/client/TabBar.d.ts +43 -0
- package/lib/types/client/TerminalView.d.ts +24 -0
- package/lib/types/client/TextEditor.d.ts +10 -0
- package/lib/types/client/TreePanel.d.ts +23 -0
- package/lib/types/client/UploadOverlay.d.ts +20 -0
- package/lib/types/client/add-plugin-modal.d.ts +15 -0
- package/lib/types/client/api.d.ts +268 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +12 -0
- package/lib/types/client/browser.d.ts +50 -0
- package/lib/types/client/builtins/index.d.ts +18 -0
- package/lib/types/client/builtins/tabs.d.ts +11 -0
- package/lib/types/client/builtins/viewers.d.ts +3 -0
- package/lib/types/client/chunk-loader.d.ts +124 -0
- package/lib/types/client/chunks/editor.d.ts +9 -0
- package/lib/types/client/chunks/mermaid.d.ts +10 -0
- package/lib/types/client/chunks/terminal.d.ts +9 -0
- package/lib/types/client/cm-themes.d.ts +24 -0
- package/lib/types/client/conversation-draft.d.ts +14 -0
- package/lib/types/client/desktop-env.d.ts +35 -0
- package/lib/types/client/editable-state.d.ts +27 -0
- package/lib/types/client/editor-load.d.ts +66 -0
- package/lib/types/client/frame-batcher.d.ts +24 -0
- package/lib/types/client/icons.d.ts +57 -0
- package/lib/types/client/image-types.d.ts +3 -0
- package/lib/types/client/ime-guard.d.ts +36 -0
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/intercept.d.ts +33 -0
- package/lib/types/client/lang.d.ts +12 -0
- package/lib/types/client/lazy-chunk.d.ts +25 -0
- package/lib/types/client/link-intercept.d.ts +42 -0
- package/lib/types/client/locales.d.ts +366 -0
- package/lib/types/client/mermaid-blocks.d.ts +41 -0
- package/lib/types/client/mermaid-sanitize.d.ts +2 -0
- package/lib/types/client/mermaid.d.ts +12 -0
- package/lib/types/client/open-when-sized.d.ts +20 -0
- package/lib/types/client/open-with-settings.d.ts +4 -0
- package/lib/types/client/open-with.d.ts +87 -0
- package/lib/types/client/openpath-intercept.d.ts +41 -0
- package/lib/types/client/paths.d.ts +35 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/plugin-settings.d.ts +8 -0
- package/lib/types/client/plugins-shared.d.ts +27 -0
- package/lib/types/client/plugins-tabs.d.ts +3 -0
- package/lib/types/client/plugins-viewers.d.ts +3 -0
- package/lib/types/client/prefs.d.ts +41 -0
- package/lib/types/client/produced-files.d.ts +23 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +440 -0
- package/lib/types/client/settings-nav-icon.d.ts +19 -0
- package/lib/types/client/shell-presets.d.ts +48 -0
- package/lib/types/client/sidechat-transcript.d.ts +103 -0
- package/lib/types/client/split-pane.d.ts +30 -0
- package/lib/types/client/state.d.ts +359 -0
- package/lib/types/client/subagent-detect.d.ts +54 -0
- package/lib/types/client/subagent-jobs.d.ts +63 -0
- package/lib/types/client/tab-content-memo.d.ts +33 -0
- package/lib/types/client/terminal-font.d.ts +20 -0
- package/lib/types/client/theme.d.ts +45 -0
- package/lib/types/client/titlebar-strip.d.ts +23 -0
- package/lib/types/client/upload.d.ts +69 -0
- package/lib/types/client/wco.d.ts +47 -0
- package/lib/types/config.d.ts +64 -0
- package/lib/types/context-types.d.ts +603 -0
- package/lib/types/fs-operations.d.ts +28 -0
- package/lib/types/fs-search.d.ts +24 -0
- package/lib/types/fs-tree.d.ts +51 -0
- package/lib/types/git.d.ts +82 -0
- package/lib/types/html-route.d.ts +59 -0
- package/lib/types/index.d.ts +47 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/jobs-routes.d.ts +45 -0
- package/lib/types/open-external.d.ts +24 -0
- package/lib/types/prefs-shared.d.ts +225 -0
- package/lib/types/pty-deps.d.ts +78 -0
- package/lib/types/pty-manager.d.ts +168 -0
- package/lib/types/sidechat-core.d.ts +172 -0
- package/lib/types/sidechat-routes.d.ts +30 -0
- package/lib/types/subagent-activity.d.ts +44 -0
- package/lib/types/subagent-live-route.d.ts +44 -0
- package/lib/types/tools.d.ts +31 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +173 -0
- package/scripts/install.ps1 +289 -0
- package/scripts/install.sh +287 -0
- package/src/agent-pty.ts +527 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +129 -0
- package/src/client/BrowserView.tsx +253 -0
- package/src/client/DiffTab.tsx +110 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +525 -0
- package/src/client/FileTree.tsx +653 -0
- package/src/client/GitView.tsx +558 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/RenderBoundary.tsx +49 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +960 -0
- package/src/client/SideCardSection.tsx +1152 -0
- package/src/client/SideChatView.module.css +437 -0
- package/src/client/SideChatView.tsx +644 -0
- package/src/client/Sidebar.tsx +1148 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +883 -0
- package/src/client/TabBar.tsx +305 -0
- package/src/client/TerminalView.tsx +383 -0
- package/src/client/TextEditor.tsx +500 -0
- package/src/client/TreePanel.tsx +273 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +335 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +51 -0
- package/src/client/browser.ts +119 -0
- package/src/client/builtins/index.ts +37 -0
- package/src/client/builtins/tabs.tsx +331 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chunk-loader.ts +351 -0
- package/src/client/chunks/editor.tsx +9 -0
- package/src/client/chunks/mermaid.tsx +10 -0
- package/src/client/chunks/terminal.tsx +9 -0
- package/src/client/cm-themes.ts +129 -0
- package/src/client/conversation-draft.ts +29 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/desktop-env.ts +72 -0
- package/src/client/editable-state.ts +70 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/frame-batcher.ts +56 -0
- package/src/client/icons.tsx +161 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +369 -0
- package/src/client/intercept.tsx +110 -0
- package/src/client/lang.ts +103 -0
- package/src/client/layout.css +94 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales.ts +734 -0
- package/src/client/mermaid-blocks.ts +110 -0
- package/src/client/mermaid-sanitize.ts +93 -0
- package/src/client/mermaid.tsx +401 -0
- package/src/client/open-when-sized.ts +43 -0
- package/src/client/open-with-settings.tsx +135 -0
- package/src/client/open-with.ts +221 -0
- package/src/client/openpath-intercept.ts +62 -0
- package/src/client/paths.ts +47 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +87 -0
- package/src/client/plugins-viewers.ts +30 -0
- package/src/client/prefs.ts +207 -0
- package/src/client/produced-files.ts +91 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +838 -0
- package/src/client/settings-nav-icon.ts +45 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +2577 -0
- package/src/client/sidechat-transcript.ts +321 -0
- package/src/client/split-pane.tsx +322 -0
- package/src/client/state.ts +1175 -0
- package/src/client/subagent-detect.ts +137 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/tab-content-memo.ts +46 -0
- package/src/client/terminal-font.ts +28 -0
- package/src/client/theme.ts +110 -0
- package/src/client/titlebar-strip.ts +37 -0
- package/src/client/upload.ts +187 -0
- package/src/client/wco.ts +120 -0
- package/src/config.ts +155 -0
- package/src/context-types.ts +610 -0
- package/src/fs-operations.ts +99 -0
- package/src/fs-search.ts +86 -0
- package/src/fs-tree.ts +158 -0
- package/src/git.ts +244 -0
- package/src/html-route.ts +106 -0
- package/src/index.ts +1132 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/open-external.ts +90 -0
- package/src/prefs-shared.ts +268 -0
- package/src/pty-deps.ts +240 -0
- package/src/pty-manager.ts +376 -0
- package/src/sidechat-core.ts +468 -0
- package/src/sidechat-routes.ts +344 -0
- package/src/subagent-activity.ts +91 -0
- package/src/subagent-live-route.ts +93 -0
- package/src/tools.ts +479 -0
- package/src/trust-fence.ts +77 -0
- package/src/wire.ts +100 -0
package/src/pty-deps.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* node-pty dependency loading for the host half (issue #140, plugin side).
|
|
3
|
+
*
|
|
4
|
+
* The terminal surfaces (UI tabs + model-facing terminal_* tools) need
|
|
5
|
+
* node-pty, but the package must NEVER be imported statically at module
|
|
6
|
+
* top level: a missing or broken install (pnpm 11's strict-dep-builds
|
|
7
|
+
* skipping node-pty's install script, a pruned store entry, a failed
|
|
8
|
+
* prebuilt-binary download…) would then fail the plugin module load and —
|
|
9
|
+
* because a loader entry apply failure aborts the boot — take the whole
|
|
10
|
+
* `dsh web` server down with it.
|
|
11
|
+
*
|
|
12
|
+
* Instead the host half loads node-pty lazily (synchronously, via
|
|
13
|
+
* createRequire — the same resolution `ensureSpawnHelper` already uses in
|
|
14
|
+
* production). When the load fails the plugin stays mounted in a degraded
|
|
15
|
+
* state: the terminal tab shows a friendly error carrying a pasteable
|
|
16
|
+
* repair command (see scripts/install.sh / install.ps1 `--repair`), and the
|
|
17
|
+
* agent terminal tools are simply not registered.
|
|
18
|
+
*
|
|
19
|
+
* Version contract: the plugin must stay in sync with DSH core —
|
|
20
|
+
* `@deepseek-ai/dsh-subprocess-local` declares `"node-pty": "^1.1.0"` in
|
|
21
|
+
* its `dependencies`. Both sides then resolve the SAME pnpm store entry
|
|
22
|
+
* (same range, same integrity → one native binding, no drift). Do NOT
|
|
23
|
+
* switch to a fork (e.g. @lydell/node-pty) or a different range without
|
|
24
|
+
* re-checking the core declaration.
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
|
27
|
+
import { createRequire } from 'node:module'
|
|
28
|
+
import { homedir } from 'node:os'
|
|
29
|
+
import { basename, dirname, join } from 'node:path'
|
|
30
|
+
import { fileURLToPath } from 'node:url'
|
|
31
|
+
import type * as nodePtyNs from 'node-pty'
|
|
32
|
+
import { SidebarError } from './wire.ts'
|
|
33
|
+
|
|
34
|
+
/** The node-pty module surface the registries consume (spawn/kill/resize/…). */
|
|
35
|
+
export type NodePtyModule = typeof nodePtyNs
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The node-pty version range this plugin ships. MUST stay identical to the
|
|
39
|
+
* range DSH core declares (`@deepseek-ai/dsh-subprocess-local`): the same
|
|
40
|
+
* range keeps pnpm resolving both to one physical package.
|
|
41
|
+
*/
|
|
42
|
+
export const DSH_NODE_PTY_RANGE = '^1.1.0'
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The WebSocket close-code-1011 reason the host sends when node-pty is
|
|
46
|
+
* unavailable. The client recognizes this exact marker and fetches the full
|
|
47
|
+
* repair details from `/sidebar/api/terminal.deps` (a WS close reason is
|
|
48
|
+
* capped at 123 bytes, so the command itself cannot ride the close frame).
|
|
49
|
+
*/
|
|
50
|
+
export const PTY_DEPS_MISSING = 'pty-deps-missing'
|
|
51
|
+
|
|
52
|
+
/** A require-compatible loader, injectable for tests. */
|
|
53
|
+
export type NodePtyRequire = (id: string) => unknown
|
|
54
|
+
|
|
55
|
+
const defaultRequire: NodePtyRequire = createRequire(import.meta.url)
|
|
56
|
+
|
|
57
|
+
type LoadResult = { ok: true; module: NodePtyModule } | { ok: false; cause: unknown }
|
|
58
|
+
|
|
59
|
+
let cached: LoadResult | undefined
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Load node-pty once (synchronously) and cache the outcome. Returns null
|
|
63
|
+
* when the package or its native binding cannot be loaded; the cause stays
|
|
64
|
+
* queryable through {@link nodePtyLoadCause}. Never throws.
|
|
65
|
+
*/
|
|
66
|
+
export function loadNodePty(requireImpl: NodePtyRequire = defaultRequire): NodePtyModule | null {
|
|
67
|
+
if (cached === undefined) {
|
|
68
|
+
try {
|
|
69
|
+
cached = { ok: true, module: requireImpl('node-pty') as NodePtyModule }
|
|
70
|
+
} catch (cause) {
|
|
71
|
+
cached = { ok: false, cause }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return cached.ok ? cached.module : null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The recorded load failure (undefined when the load succeeded or never ran). */
|
|
78
|
+
export function nodePtyLoadCause(): unknown {
|
|
79
|
+
return cached !== undefined && !cached.ok ? cached.cause : undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Forget the cached outcome (tests only — a real reload is otherwise one-shot). */
|
|
83
|
+
export function resetNodePtyCache(): void {
|
|
84
|
+
cached = undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Load node-pty or throw the canonical degraded-mode error (class-constructor default). */
|
|
88
|
+
export function loadRequiredNodePty(): NodePtyModule {
|
|
89
|
+
const module = loadNodePty()
|
|
90
|
+
if (module === null) {
|
|
91
|
+
const cause = describeCause(nodePtyLoadCause())
|
|
92
|
+
throw new SidebarError(
|
|
93
|
+
'pty-deps-missing',
|
|
94
|
+
`node-pty (${DSH_NODE_PTY_RANGE}) failed to load: ${cause} — run the repair command shown in the terminal tab`,
|
|
95
|
+
503,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
return module
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Resolve a directory to its physical location (symlinked/link: installs). */
|
|
102
|
+
function realDir(file: string): string {
|
|
103
|
+
try {
|
|
104
|
+
return dirname(realpathSync(file))
|
|
105
|
+
} catch {
|
|
106
|
+
return dirname(file)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Walk up from `dir` looking for a DSH profile root (package.json + pnpm-workspace.yaml). */
|
|
111
|
+
function walkUp(dir: string, isRoot: (dir: string) => boolean): string | null {
|
|
112
|
+
let current = dir
|
|
113
|
+
for (let depth = 0; depth < 16; depth += 1) {
|
|
114
|
+
if (isRoot(current)) return current
|
|
115
|
+
const parent = dirname(current)
|
|
116
|
+
if (parent === current) break
|
|
117
|
+
current = parent
|
|
118
|
+
}
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Whether `dir` looks like a DSH profile root (the plugin lives under its node_modules). */
|
|
123
|
+
function isProfileRoot(dir: string): boolean {
|
|
124
|
+
return existsSync(join(dir, 'package.json')) && existsSync(join(dir, 'pnpm-workspace.yaml'))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Detect the DSH profile directory this plugin is installed into: the
|
|
129
|
+
* nearest ancestor of the plugin module that carries both `package.json`
|
|
130
|
+
* and `pnpm-workspace.yaml` (the profile root; the plugin resolves from the
|
|
131
|
+
* profile's node_modules). Falls back to `$DSH_HOME/profiles/web` (the
|
|
132
|
+
* standard web profile), then null.
|
|
133
|
+
*/
|
|
134
|
+
export function findProfileDir(fromFile: string = fileURLToPath(import.meta.url)): string | null {
|
|
135
|
+
const detected = walkUp(realDir(fromFile), isProfileRoot)
|
|
136
|
+
if (detected !== null) return detected
|
|
137
|
+
const home = process.env.DSH_HOME !== undefined && process.env.DSH_HOME.trim() !== ''
|
|
138
|
+
? process.env.DSH_HOME
|
|
139
|
+
: join(homedir(), '.dsh')
|
|
140
|
+
const web = join(home, 'profiles', 'web')
|
|
141
|
+
return isProfileRoot(web) ? realpathSync(web) : null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Whether `dir`'s package.json declares this plugin's name. */
|
|
145
|
+
function isPluginRoot(dir: string): boolean {
|
|
146
|
+
const file = join(dir, 'package.json')
|
|
147
|
+
if (!existsSync(file)) return false
|
|
148
|
+
try {
|
|
149
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8')) as { name?: unknown }
|
|
150
|
+
return parsed.name === 'dsh-better-sidebar'
|
|
151
|
+
} catch {
|
|
152
|
+
return false
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The plugin package root (walk-up from the module; works for lib/ and src/ layouts). */
|
|
157
|
+
export function findPluginRoot(fromFile: string = fileURLToPath(import.meta.url)): string | null {
|
|
158
|
+
return walkUp(realDir(fromFile), isPluginRoot)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Options for {@link buildRepairCommand}. */
|
|
162
|
+
export interface RepairCommandOptions {
|
|
163
|
+
/** The plugin package root (where scripts/install.sh / install.ps1 live). */
|
|
164
|
+
pluginRoot: string | null
|
|
165
|
+
/** The detected profile directory (null → the standard `web` profile). */
|
|
166
|
+
profileDir: string | null
|
|
167
|
+
/** Platform override for tests; defaults to the live process. */
|
|
168
|
+
platform?: NodeJS.Platform
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The pasteable repair command for a broken node-pty install: rerun the
|
|
173
|
+
* plugin's own installer in `--repair` mode (idempotent: it re-writes the
|
|
174
|
+
* profile's `allowBuilds: node-pty: true` and re-installs/rebuilds the
|
|
175
|
+
* dependency). Falls back to DSH's plugin command when the scripts are not
|
|
176
|
+
* shipped (exotic layouts).
|
|
177
|
+
*/
|
|
178
|
+
export function buildRepairCommand(options: RepairCommandOptions): { command: string; note?: string } {
|
|
179
|
+
const { pluginRoot, profileDir } = options
|
|
180
|
+
const platform = options.platform ?? process.platform
|
|
181
|
+
const profileName = profileDir !== null ? basename(profileDir) : null
|
|
182
|
+
const profileArg = profileName !== null
|
|
183
|
+
? (platform === 'win32' ? ` -Profile "${profileName}"` : ` --profile "${profileName}"`)
|
|
184
|
+
: ''
|
|
185
|
+
if (pluginRoot !== null) {
|
|
186
|
+
if (platform === 'win32') {
|
|
187
|
+
const script = join(pluginRoot, 'scripts', 'install.ps1')
|
|
188
|
+
if (existsSync(script)) {
|
|
189
|
+
return { command: `powershell -ExecutionPolicy Bypass -File "${script}" -Repair${profileArg}` }
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
const script = join(pluginRoot, 'scripts', 'install.sh')
|
|
193
|
+
if (existsSync(script)) {
|
|
194
|
+
return { command: `bash "${script}" --repair${profileArg}` }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const name = profileName ?? 'web'
|
|
199
|
+
return {
|
|
200
|
+
command: `dsh plugin --profile "${name}" install`,
|
|
201
|
+
note: 'If pnpm 11 blocked node-pty\'s build script, ensure `allowBuilds: node-pty: true` in the profile\'s pnpm-workspace.yaml (the plugin\'s scripts/install.sh / install.ps1 --repair does this automatically).',
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** One-line human description of the recorded load cause. */
|
|
206
|
+
function describeCause(cause: unknown): string {
|
|
207
|
+
if (cause instanceof Error) return cause.message
|
|
208
|
+
return String(cause)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Structured status served by the `/sidebar/api/terminal.deps` endpoint. */
|
|
212
|
+
export type NodePtyDepsStatus =
|
|
213
|
+
| { ok: true }
|
|
214
|
+
| {
|
|
215
|
+
ok: false
|
|
216
|
+
/** The require-time error message (module missing, native binding broken…). */
|
|
217
|
+
cause: string
|
|
218
|
+
/** The pasteable repair command (terminal/cmd). */
|
|
219
|
+
command: string
|
|
220
|
+
/** The detected profile name (null when undetected → the command defaults to web). */
|
|
221
|
+
profile: string | null
|
|
222
|
+
/** Optional supplementary hint (fallback command only). */
|
|
223
|
+
note?: string
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Current node-pty dependency status (loaded vs degraded + repair info). */
|
|
227
|
+
export function depsStatus(options: { fromFile?: string } = {}): NodePtyDepsStatus {
|
|
228
|
+
const module = loadNodePty()
|
|
229
|
+
if (module !== null) return { ok: true }
|
|
230
|
+
const pluginRoot = findPluginRoot(options.fromFile)
|
|
231
|
+
const profileDir = findProfileDir(options.fromFile)
|
|
232
|
+
const { command, note } = buildRepairCommand({ pluginRoot, profileDir })
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
cause: describeCause(nodePtyLoadCause()),
|
|
236
|
+
command,
|
|
237
|
+
profile: profileDir !== null ? basename(profileDir) : null,
|
|
238
|
+
...(note !== undefined ? { note } : {}),
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PTY session table for the sidebar terminals. One node-pty process per
|
|
3
|
+
* `${sessionId}:${tabId}` key; processes survive WebSocket disconnects
|
|
4
|
+
* (page refresh, tab switch) and reconnect to the same process by key.
|
|
5
|
+
* Output is mirrored into a bounded transcript ring (capped bytes) so a new
|
|
6
|
+
* connection replays history before live data. Sessions die only when the
|
|
7
|
+
* tab is closed or the plugin tears down.
|
|
8
|
+
*/
|
|
9
|
+
import { chmodSync, existsSync } from 'node:fs'
|
|
10
|
+
import { dirname, join } from 'node:path'
|
|
11
|
+
import { createRequire } from 'node:module'
|
|
12
|
+
import { userInfo } from 'node:os'
|
|
13
|
+
import type { IPty } from 'node-pty'
|
|
14
|
+
import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.ts'
|
|
15
|
+
import { SidebarError } from './wire.ts'
|
|
16
|
+
|
|
17
|
+
/** Per-terminal transcript bound (bytes kept for replay). */
|
|
18
|
+
const TRANSCRIPT_LIMIT = 1 << 20
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Restore the executable bit pnpm strips from node-pty's prebuilt
|
|
22
|
+
* spawn-helper (the macOS helper that forks and sets up the pty). Without it
|
|
23
|
+
* every spawn fails with `posix_spawnp failed`. Idempotent; mirrors
|
|
24
|
+
* @deepseek-ai/dsh-terminal-bash's ensure-spawn-helper postinstall, run at
|
|
25
|
+
* plugin activation so link-installed deployments get the fix too.
|
|
26
|
+
*/
|
|
27
|
+
export function ensureSpawnHelper(): void {
|
|
28
|
+
if (process.platform === 'win32') return
|
|
29
|
+
try {
|
|
30
|
+
const require = createRequire(import.meta.url)
|
|
31
|
+
const entry = require.resolve('node-pty')
|
|
32
|
+
const packageRoot = dirname(dirname(entry))
|
|
33
|
+
const candidates = [
|
|
34
|
+
join(packageRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'),
|
|
35
|
+
join(packageRoot, 'build', 'Release', 'spawn-helper'),
|
|
36
|
+
]
|
|
37
|
+
for (const helper of candidates) {
|
|
38
|
+
if (existsSync(helper)) chmodSync(helper, 0o755)
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// Resolution or chmod failure: the terminal surfaces its own spawn error.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One live terminal. */
|
|
46
|
+
export interface SidebarPty {
|
|
47
|
+
/** `${sessionId}:${tabId}` registry key. */
|
|
48
|
+
key: string
|
|
49
|
+
sessionId: string
|
|
50
|
+
tabId: string
|
|
51
|
+
/** The working directory the process was SPAWNED with (a reconnect that
|
|
52
|
+
* resolves a different authoritative cwd respawns instead of reusing —
|
|
53
|
+
* the page-load hydrate race can attach the real cwd after the first
|
|
54
|
+
* connect, and a shell in the wrong directory must not linger). */
|
|
55
|
+
cwd: string
|
|
56
|
+
pty: IPty
|
|
57
|
+
/** Output accumulated since spawn (bounded; head dropped when over the limit). */
|
|
58
|
+
transcript: string
|
|
59
|
+
/** Whether the top-level process exited (transcript stays replayable). */
|
|
60
|
+
exited: boolean
|
|
61
|
+
exitCode?: number | null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The terminal registry. `maxPerSession` bounds concurrent processes per
|
|
66
|
+
* conversation (the client caps tabs at the same number).
|
|
67
|
+
*
|
|
68
|
+
* Lifecycle of a UI-tab pty when its WebSocket drops:
|
|
69
|
+
* - **Close frame** (`{type:'close'}`): the user closed the tab → schedule a
|
|
70
|
+
* 0-ms close (quota released immediately).
|
|
71
|
+
* - **Park frame** (`{type:'park'}`): the user switched to another
|
|
72
|
+
* conversation; the tab is still open in its session's persisted state but
|
|
73
|
+
* its view unmounted → mark the pty as parked (no auto-close countdown).
|
|
74
|
+
* The pty stays alive until the user switches back (a reconnecting view
|
|
75
|
+
* calls `open()` which clears the parked state) or the tab is later closed
|
|
76
|
+
* (a `{type:'close'}` frame from a fresh connection). Without `park`, a
|
|
77
|
+
* bare socket drop would start the reconnect-grace countdown and kill the
|
|
78
|
+
* shell after `reconnectGraceMs` — wrong for a session switch, where the
|
|
79
|
+
* user is still actively using the app, just in another conversation.
|
|
80
|
+
* - **Bare socket drop** (no frame): page refresh, crash, plugin teardown →
|
|
81
|
+
* schedule a close after `reconnectGraceMs` so a quick reconnect reattaches
|
|
82
|
+
* the same shell.
|
|
83
|
+
*/
|
|
84
|
+
export class PtyManager {
|
|
85
|
+
private readonly sessions = new Map<string, SidebarPty>()
|
|
86
|
+
private readonly pendingCloses = new Map<string, ReturnType<typeof setTimeout>>()
|
|
87
|
+
/** Tabs whose view unmounted because the user switched conversations — the
|
|
88
|
+
* tab is still open in its session's state, so the pty must NOT enter the
|
|
89
|
+
* reconnect-grace countdown. Cleared by `cancelClose` (a reconnecting
|
|
90
|
+
* view's `open()` cancels it) or by `scheduleClose` (an explicit close
|
|
91
|
+
* frame still kills a parked pty). */
|
|
92
|
+
private readonly parked = new Set<string>()
|
|
93
|
+
|
|
94
|
+
constructor(
|
|
95
|
+
private readonly shell: string,
|
|
96
|
+
private readonly maxPerSession: number,
|
|
97
|
+
private readonly shellArgs: string[] = [],
|
|
98
|
+
/** The loaded node-pty module (injected so a broken install degrades instead of crashing the plugin). */
|
|
99
|
+
private readonly nodePty: NodePtyModule = loadRequiredNodePty(),
|
|
100
|
+
) {}
|
|
101
|
+
|
|
102
|
+
/** All live terminal keys of one session. */
|
|
103
|
+
keysOf(sessionId: string): string[] {
|
|
104
|
+
const keys: string[] = []
|
|
105
|
+
for (const handle of this.sessions.values()) {
|
|
106
|
+
if (handle.sessionId === sessionId) keys.push(handle.key)
|
|
107
|
+
}
|
|
108
|
+
return keys
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Open (or reuse) the terminal for a session/tab key. A handle whose
|
|
113
|
+
* process already exited is replaced with a fresh spawn (reconnecting a
|
|
114
|
+
* dead terminal must yield a live shell, not an input sink), and so is a
|
|
115
|
+
* live handle whose spawn cwd differs from the now-authoritative one (the
|
|
116
|
+
* first connect of a page load can arrive before the session hydrates, so
|
|
117
|
+
* it fell back to the process cwd — reconnecting with the real cwd must
|
|
118
|
+
* restart the shell in the right directory). Reopening also cancels any
|
|
119
|
+
* pending scheduled close (a reconnect within the grace window keeps the
|
|
120
|
+
* process alive).
|
|
121
|
+
* @param sessionId - conversation id.
|
|
122
|
+
* @param tabId - client tab id.
|
|
123
|
+
* @param cwd - initial working directory (the session's cwd).
|
|
124
|
+
* @param cols - initial terminal width.
|
|
125
|
+
* @param rows - initial terminal height.
|
|
126
|
+
* @returns the live handle.
|
|
127
|
+
* @throws {SidebarError} pty-error when the per-session cap is reached.
|
|
128
|
+
*/
|
|
129
|
+
open(
|
|
130
|
+
sessionId: string,
|
|
131
|
+
tabId: string,
|
|
132
|
+
cwd: string,
|
|
133
|
+
cols: number,
|
|
134
|
+
rows: number,
|
|
135
|
+
shell?: string,
|
|
136
|
+
shellArgs?: string[],
|
|
137
|
+
): SidebarPty {
|
|
138
|
+
const key = `${sessionId}:${tabId}`
|
|
139
|
+
this.cancelClose(key)
|
|
140
|
+
const existing = this.sessions.get(key)
|
|
141
|
+
if (existing !== undefined && !existing.exited && existing.cwd === cwd) return existing
|
|
142
|
+
if (existing !== undefined) this.close(key)
|
|
143
|
+
// Zombie cleanup: a session's exited handles (shell closed, tab dropped
|
|
144
|
+
// on an old host without the close frame) must not eat the quota.
|
|
145
|
+
for (const [candidate, handle] of [...this.sessions]) {
|
|
146
|
+
if (handle.sessionId === sessionId && handle.exited) this.close(candidate)
|
|
147
|
+
}
|
|
148
|
+
if (this.keysOf(sessionId).length >= this.maxPerSession) {
|
|
149
|
+
throw new SidebarError('pty-error', `terminal limit reached (${this.maxPerSession}) for this session`, 400)
|
|
150
|
+
}
|
|
151
|
+
const handle: SidebarPty = {
|
|
152
|
+
key,
|
|
153
|
+
sessionId,
|
|
154
|
+
tabId,
|
|
155
|
+
cwd,
|
|
156
|
+
pty: this.nodePty.spawn(shell ?? this.shell, shellSpawnArgs(shellArgs ?? this.shellArgs), {
|
|
157
|
+
name: 'xterm-256color',
|
|
158
|
+
cols: Math.max(2, Math.floor(cols)),
|
|
159
|
+
rows: Math.max(2, Math.floor(rows)),
|
|
160
|
+
cwd,
|
|
161
|
+
env: { ...process.env },
|
|
162
|
+
}),
|
|
163
|
+
transcript: '',
|
|
164
|
+
exited: false,
|
|
165
|
+
}
|
|
166
|
+
handle.pty.onData((data) => {
|
|
167
|
+
handle.transcript += data
|
|
168
|
+
if (handle.transcript.length > TRANSCRIPT_LIMIT) {
|
|
169
|
+
handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT)
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
handle.pty.onExit(({ exitCode }) => {
|
|
173
|
+
handle.exited = true
|
|
174
|
+
handle.exitCode = exitCode
|
|
175
|
+
})
|
|
176
|
+
this.sessions.set(key, handle)
|
|
177
|
+
return handle
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Schedule the terminal's destruction after `delayMs`. A tab close sends
|
|
182
|
+
* delay 0 (release the quota immediately); a bare socket drop (refresh,
|
|
183
|
+
* crash) uses the grace period so a quick reconnect keeps the process.
|
|
184
|
+
* `open()` cancels any pending close. Clears the parked state — an explicit
|
|
185
|
+
* close frame on a parked pty (the user switched back and closed the tab)
|
|
186
|
+
* still kills it.
|
|
187
|
+
*/
|
|
188
|
+
scheduleClose(key: string, delayMs: number): void {
|
|
189
|
+
const handle = this.sessions.get(key)
|
|
190
|
+
if (handle === undefined) return
|
|
191
|
+
this.cancelClose(key)
|
|
192
|
+
const timer = setTimeout(() => { this.close(key) }, delayMs)
|
|
193
|
+
this.pendingCloses.set(key, timer)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Park a terminal: the owning tab's view unmounted because the user
|
|
198
|
+
* switched to another conversation, but the tab is still open in its
|
|
199
|
+
* session's persisted state. Cancels any pending grace close and marks
|
|
200
|
+
* the pty so the host's `ws.on('close')` handler does NOT start the
|
|
201
|
+
* reconnect-grace countdown — the pty stays alive until the user switches
|
|
202
|
+
* back (a reconnecting view's `open()` clears this) or explicitly closes
|
|
203
|
+
* the tab (a `{type:'close'}` frame's `scheduleClose` clears this).
|
|
204
|
+
*/
|
|
205
|
+
park(key: string): void {
|
|
206
|
+
if (this.sessions.get(key) === undefined) return
|
|
207
|
+
this.cancelClose(key)
|
|
208
|
+
this.parked.add(key)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Whether this pty was parked (its view unmounted for a session switch). */
|
|
212
|
+
isParked(key: string): boolean {
|
|
213
|
+
return this.parked.has(key)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Cancel a pending scheduled close (the terminal is being reopened).
|
|
217
|
+
* Also clears the parked state — a reconnecting view reattaches a parked
|
|
218
|
+
* pty and resumes normal lifecycle. */
|
|
219
|
+
cancelClose(key: string): void {
|
|
220
|
+
const timer = this.pendingCloses.get(key)
|
|
221
|
+
if (timer !== undefined) {
|
|
222
|
+
clearTimeout(timer)
|
|
223
|
+
this.pendingCloses.delete(key)
|
|
224
|
+
}
|
|
225
|
+
this.parked.delete(key)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Resolve a live handle by key, or undefined. */
|
|
229
|
+
get(key: string): SidebarPty | undefined {
|
|
230
|
+
return this.sessions.get(key)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Close a terminal and drop its state (the owning tab was closed). */
|
|
234
|
+
close(key: string): void {
|
|
235
|
+
this.cancelClose(key)
|
|
236
|
+
const handle = this.sessions.get(key)
|
|
237
|
+
if (handle === undefined) return
|
|
238
|
+
this.sessions.delete(key)
|
|
239
|
+
try {
|
|
240
|
+
handle.pty.kill()
|
|
241
|
+
} catch {
|
|
242
|
+
// Already exited or gone; nothing left to kill.
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Close every terminal (plugin teardown). */
|
|
247
|
+
disposeAll(): void {
|
|
248
|
+
for (const timer of this.pendingCloses.values()) clearTimeout(timer)
|
|
249
|
+
this.pendingCloses.clear()
|
|
250
|
+
for (const key of [...this.sessions.keys()]) this.close(key)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Inputs for {@link defaultShell} resolution. Every field is optional and
|
|
256
|
+
* defaults to the live process, which keeps the no-argument call sites
|
|
257
|
+
* working while tests (and exotic embedders) can pin the platform, the
|
|
258
|
+
* environment, and the existence probe independently — the Windows chain
|
|
259
|
+
* never executes on the ubuntu CI runners, so it is only testable through
|
|
260
|
+
* these injection points.
|
|
261
|
+
*/
|
|
262
|
+
export interface ShellResolutionOptions {
|
|
263
|
+
/** Platform override (defaults to `process.platform`). */
|
|
264
|
+
platform?: NodeJS.Platform
|
|
265
|
+
/** Environment override; the resolver only reads SHELL, DSH_SIDEBAR_SHELL, PATH, ProgramW6432, ProgramFiles, LOCALAPPDATA. */
|
|
266
|
+
env?: NodeJS.ProcessEnv
|
|
267
|
+
/** Explicitly configured shell (the `shell` config field); wins over every automatic source. Empty means unset. */
|
|
268
|
+
explicit?: string
|
|
269
|
+
/** File-existence probe override (defaults to `existsSync`). */
|
|
270
|
+
exists?: (path: string) => boolean
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Candidate directories that may contain a `pwsh.exe` on Windows: PATH
|
|
275
|
+
* entries first, then the well-known machine/user install locations
|
|
276
|
+
* (including preview channels and per-user MSI/portable layouts). The
|
|
277
|
+
* machine-scope search reads both `ProgramW6432` and `ProgramFiles` so a
|
|
278
|
+
* 32-bit Node process — whose `ProgramFiles` points at `(x86)` — still
|
|
279
|
+
* finds a 64-bit PowerShell 7 install. De-duped while preserving priority
|
|
280
|
+
* order.
|
|
281
|
+
*/
|
|
282
|
+
function windowsPwshCandidateDirs(env: NodeJS.ProcessEnv): string[] {
|
|
283
|
+
const dirs: string[] = []
|
|
284
|
+
const pathEntries = env.PATH
|
|
285
|
+
if (pathEntries !== undefined) {
|
|
286
|
+
// The win32 branch always uses the Windows PATH separator; hardcoding it
|
|
287
|
+
// keeps the function testable from POSIX runners without a delimiter
|
|
288
|
+
// injection point.
|
|
289
|
+
for (const entry of pathEntries.split(';')) {
|
|
290
|
+
const trimmed = entry.trim()
|
|
291
|
+
if (trimmed !== '') dirs.push(trimmed)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
for (const programFiles of [env.ProgramW6432, env.ProgramFiles]) {
|
|
295
|
+
if (programFiles === undefined || programFiles.trim() === '') continue
|
|
296
|
+
dirs.push(join(programFiles, 'PowerShell', '7'))
|
|
297
|
+
dirs.push(join(programFiles, 'PowerShell', '7-preview'))
|
|
298
|
+
}
|
|
299
|
+
const localAppData = env.LOCALAPPDATA
|
|
300
|
+
if (localAppData !== undefined && localAppData.trim() !== '') {
|
|
301
|
+
dirs.push(join(localAppData, 'Microsoft', 'PowerShell', '7'))
|
|
302
|
+
dirs.push(join(localAppData, 'Microsoft', 'PowerShell', '7-preview'))
|
|
303
|
+
dirs.push(join(localAppData, 'Programs', 'PowerShell', '7'))
|
|
304
|
+
dirs.push(join(localAppData, 'Programs', 'PowerShell', '7-preview'))
|
|
305
|
+
}
|
|
306
|
+
return [...new Set(dirs)]
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The interactive shell for this platform, resolved like a terminal
|
|
311
|
+
* emulator: an explicitly configured shell (the `shell` config field) wins,
|
|
312
|
+
* then `$SHELL` on POSIX (deployment override), then the account's login
|
|
313
|
+
* shell from passwd, then `/bin/bash`. The passwd step matters because
|
|
314
|
+
* service managers and container inits often start dsh without `SHELL`, and
|
|
315
|
+
* the tab should still open the user's login shell (e.g. zsh) instead of
|
|
316
|
+
* silently degrading to bash.
|
|
317
|
+
*
|
|
318
|
+
* Windows previously short-circuited to `powershell.exe` (the inbox 5.1)
|
|
319
|
+
* before any resolution, so PowerShell 7 users always got a legacy shell
|
|
320
|
+
* without `??`/`?.`/ternary and with poor ANSI/UTF-8 defaults. The Windows
|
|
321
|
+
* chain is now: explicit shell → `DSH_SIDEBAR_SHELL` env override → first
|
|
322
|
+
* `pwsh.exe` found on PATH or in a known install directory → the 5.1
|
|
323
|
+
* fallback (machines without PowerShell 7 keep working).
|
|
324
|
+
*/
|
|
325
|
+
export function defaultShell(options: ShellResolutionOptions = {}): string {
|
|
326
|
+
const platform = options.platform ?? process.platform
|
|
327
|
+
const env = options.env ?? process.env
|
|
328
|
+
const exists = options.exists ?? existsSync
|
|
329
|
+
const explicit = options.explicit
|
|
330
|
+
if (explicit !== undefined && explicit.trim() !== '') return explicit.trim()
|
|
331
|
+
if (platform === 'win32') {
|
|
332
|
+
const envShell = env.DSH_SIDEBAR_SHELL
|
|
333
|
+
if (envShell !== undefined && envShell.trim() !== '') return envShell.trim()
|
|
334
|
+
for (const dir of windowsPwshCandidateDirs(env)) {
|
|
335
|
+
const candidate = join(dir, 'pwsh.exe')
|
|
336
|
+
if (exists(candidate)) return candidate
|
|
337
|
+
}
|
|
338
|
+
return 'powershell.exe'
|
|
339
|
+
}
|
|
340
|
+
const envShell = env.SHELL
|
|
341
|
+
if (envShell !== undefined && envShell.trim() !== '') return envShell.trim()
|
|
342
|
+
// userInfo() throws when the uid has no passwd entry (rare chroots);
|
|
343
|
+
// without a login shell there is nothing better than the bash default.
|
|
344
|
+
try {
|
|
345
|
+
const loginShell = userInfo().shell
|
|
346
|
+
if (typeof loginShell === 'string' && loginShell.trim() !== '') return loginShell
|
|
347
|
+
} catch {
|
|
348
|
+
// no passwd entry: fall through to /bin/bash
|
|
349
|
+
}
|
|
350
|
+
return '/bin/bash'
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* A short display name for a shell executable, used as the terminal tab
|
|
355
|
+
* title. `/bin/zsh` → `zsh`, `C:\...\powershell.exe` → `powershell`.
|
|
356
|
+
* Falls back to the raw value when no basename can be derived.
|
|
357
|
+
*/
|
|
358
|
+
export function shellDisplayName(shell: string): string {
|
|
359
|
+
const normalized = shell.replace(/\\/g, '/')
|
|
360
|
+
const base = normalized.slice(normalized.lastIndexOf('/') + 1)
|
|
361
|
+
if (base === '') return shell
|
|
362
|
+
return base.replace(/\.(exe|cmd|bat)$/i, '')
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Spawn arguments that make the shell behave like a terminal-emulator tab:
|
|
367
|
+
* POSIX shells start as login shells (`-l`) so they read the profile files
|
|
368
|
+
* (`~/.profile`, `~/.zprofile`); Windows PowerShell takes no login flag.
|
|
369
|
+
*
|
|
370
|
+
* When explicit `configured` args are supplied they REPLACE the platform
|
|
371
|
+
* defaults entirely, giving deployments full control over shell startup.
|
|
372
|
+
*/
|
|
373
|
+
export function shellSpawnArgs(configured: string[] = []): string[] {
|
|
374
|
+
if (configured.length > 0) return [...configured]
|
|
375
|
+
return process.platform === 'win32' ? [] : ['-l']
|
|
376
|
+
}
|