zerogterm 0.2.0-alpha.2 → 0.4.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/README.md +102 -27
- package/dist/main/main/clipboard.js +30 -0
- package/dist/main/main/main.js +8 -2
- package/dist/main/main/session-service.js +32 -44
- package/dist/main/main/shell-catalog.js +193 -0
- package/dist/main/main/ssh-inventory.js +4 -1
- package/dist/renderer/assets/{index-5pgRaxBt.css → index-DSL4JtQn.css} +1 -1
- package/dist/renderer/assets/index-x_BAzbUO.js +17 -0
- package/dist/renderer/assets/voice-worker-DoNT0zEM.js +37 -0
- package/dist/renderer/index.html +3 -3
- package/package.json +6 -2
- package/dist/renderer/assets/index-BWVjX0UX.js +0 -17
- package/dist/renderer/assets/voice-worker-Bde9NFkO.js +0 -37
package/README.md
CHANGED
|
@@ -1,21 +1,35 @@
|
|
|
1
1
|
# ZeroG Terminal
|
|
2
2
|
|
|
3
|
-
ZeroG Terminal is
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
3
|
+
ZeroG Terminal is an Electron workspace manager for persistent terminal and SSH sessions on Linux and Windows, with smart features to help keep you productive. It is designed with AI tooling in mind: leave Codex, Claude Code, or other terminal-based agents running in persistent sessions, then return to them without repeating setup and resume commands.
|
|
4
|
+
|
|
5
|
+
ZeroG Terminal is an alpha project, but it is already useful as a multi-session terminal and SSH workspace. It uses `screen` where available for resumable sessions and falls back to a direct shell PTY when `screen` is not installed.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Multi-pane workspaces with stack, vertical split, horizontal split, and four-pane grid layouts.
|
|
10
|
+
- Maximize a focused pane and cycle between sessions without losing the other panes.
|
|
11
|
+
- Local sessions powered by Bash, PowerShell, WSL, and other supported shell backends; persistent sessions use `screen` where available, with a process-only fallback when it is unavailable.
|
|
12
|
+
- SSH sessions for hosts, `user@host`, and `user@host:port` targets.
|
|
13
|
+
- SSH configuration discovery from known connections, including remote `screen` session discovery.
|
|
14
|
+
- Reconnect to existing local or remote `screen` sessions from the Screens view.
|
|
15
|
+
- Remote screen attachment that waits for SSH readiness before sending commands, including host and port-aware matching.
|
|
16
|
+
- Session history for reconnecting to sessions after a relaunch, with bounded structured history and no stored secrets.
|
|
17
|
+
- Workspaces for grouping sessions and quickly switching between projects or tasks.
|
|
18
|
+
- Session overview, collapsible sidebar, keyboard shortcuts, and light/dark themes.
|
|
19
|
+
- xterm.js terminal rendering with scrollback preservation while changing layouts.
|
|
20
|
+
- Local voice input, either with Whisper ONNX inside the app through Transformers.js or through a transcription server on this machine; transcribed text is typed into the selected terminal without automatic execution.
|
|
21
|
+
- A settings panel for appearance, terminal behaviour, session defaults, and speech recognition, including a built-in recognition test.
|
|
22
|
+
- AI command suggestion and approval UI, keeping command execution explicit.
|
|
23
|
+
- Sandboxed Electron renderer, context isolation, disabled Node integration, and a narrow typed preload API.
|
|
24
|
+
- Safe argument-array handling and validation around SSH and `screen` session operations.
|
|
25
|
+
|
|
26
|
+
The project is particularly useful for terminal-based AI development workflows: start an agent in a persistent session, disconnect or suffer an interrupted connection, and reconnect later to see what it has done and continue working.
|
|
27
|
+
|
|
28
|
+
See the project walkthrough on [YouTube](https://youtu.be/4aJZCxLHD14).
|
|
15
29
|
|
|
16
30
|
## Release status
|
|
17
31
|
|
|
18
|
-
ZeroG Terminal is currently a public alpha. The
|
|
32
|
+
ZeroG Terminal is currently a public alpha. The current release is `0.3.0-alpha.1`; the version history is tracked in [versions.txt](versions.txt).
|
|
19
33
|
|
|
20
34
|
The npm package contains the built Electron application and project documentation. It is intended for early adopters and testing rather than production use.
|
|
21
35
|
|
|
@@ -28,8 +42,73 @@ The npm package contains the built Electron application and project documentatio
|
|
|
28
42
|
- `Ctrl+Shift+T` — new local terminal in the current workspace
|
|
29
43
|
- `Ctrl+Shift+O` — session overview
|
|
30
44
|
- `Ctrl+Shift+B` — toggle sessions sidebar
|
|
45
|
+
- `Ctrl+Shift+,` — settings
|
|
31
46
|
- `Esc` — close overview / dialogs, cancel voice recording
|
|
32
47
|
|
|
48
|
+
Selecting text with the mouse also copies it, and programs running inside a
|
|
49
|
+
terminal can copy to the system clipboard themselves through the OSC 52 escape
|
|
50
|
+
sequence — this is how TUI tools such as CLI coding agents, tmux and Neovim put
|
|
51
|
+
text on the clipboard, including over SSH. Reading the clipboard through OSC 52
|
|
52
|
+
is refused, so a program on a remote host cannot see what you last copied.
|
|
53
|
+
|
|
54
|
+
## Settings
|
|
55
|
+
|
|
56
|
+
Settings open from the gear at the bottom of the left rail, the avatar in the
|
|
57
|
+
title bar, or `Ctrl+Shift+,`. Changes apply immediately and are remembered
|
|
58
|
+
between launches; each page can be reset on its own.
|
|
59
|
+
|
|
60
|
+
- **Appearance** — theme, terminal font, size, line height and letter spacing,
|
|
61
|
+
with a live preview. Panes restyle in place and keep their scrollback.
|
|
62
|
+
- **Terminal** — scrollback lines, cursor style and blink, and copy-on-select.
|
|
63
|
+
- **Sessions** — default shell and WSL distribution for new terminals, the
|
|
64
|
+
layout to start in, and whether the sidebar starts collapsed.
|
|
65
|
+
- **AI & voice** — whether AI suggestions need approval before running, and
|
|
66
|
+
whether a transcript is typed straight into the pane or shown for review
|
|
67
|
+
first. Neither option presses Enter for you.
|
|
68
|
+
- **Speech recognition** — engine, model and tuning, described below.
|
|
69
|
+
|
|
70
|
+
### Speech recognition
|
|
71
|
+
|
|
72
|
+
Two engines are available.
|
|
73
|
+
|
|
74
|
+
**Built-in** runs Whisper as ONNX inside the app through Transformers.js, with
|
|
75
|
+
nothing else to install. Choose the model (tiny, base or small; English-only or
|
|
76
|
+
multilingual), the weight precision, and whether to compute on CPU (WASM) or
|
|
77
|
+
GPU (WebGPU) — WebGPU falls back to WASM when it is unavailable. The panel shows
|
|
78
|
+
the download for the chosen combination, from about 41 MB for tiny at q8 to
|
|
79
|
+
about 968 MB for small at full precision; models are cached after first use.
|
|
80
|
+
Multilingual models add language and transcribe/translate options, which
|
|
81
|
+
English-only checkpoints reject and so do not show.
|
|
82
|
+
|
|
83
|
+
**Local server** posts the recorded audio as a WAV file to a transcription
|
|
84
|
+
server on this machine, using the OpenAI `/v1/audio/transcriptions` shape that
|
|
85
|
+
whisper.cpp's server, LM Studio, faster-whisper-server and similar tools speak.
|
|
86
|
+
This is the way to use a model the built-in engine cannot load — a GGUF build
|
|
87
|
+
such as `unslothai/Qwen3-ASR-0.6B-GGUF` needs a llama.cpp-family runtime, so
|
|
88
|
+
something else has to host it. The URL must be on this machine; a non-loopback
|
|
89
|
+
address is refused rather than sent.
|
|
90
|
+
|
|
91
|
+
Both engines share the maximum utterance length and the silence threshold, and
|
|
92
|
+
the **Try it** button on that page records a phrase and shows the transcript,
|
|
93
|
+
the recording level and how long transcription took, without typing into a
|
|
94
|
+
terminal. It transcribes even below the silence threshold and says so, which is
|
|
95
|
+
how the threshold gets tuned for a particular microphone.
|
|
96
|
+
|
|
97
|
+
## Installation and usage
|
|
98
|
+
|
|
99
|
+
The simplest way to try the published package is through `npx`:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
npx zerogterm
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The package downloads the application and launches it. To use the launcher repeatedly without downloading on each invocation, install it globally:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
npm install --global zerogterm
|
|
109
|
+
zerogterm
|
|
110
|
+
```
|
|
111
|
+
|
|
33
112
|
## Development
|
|
34
113
|
|
|
35
114
|
```bash
|
|
@@ -40,34 +119,30 @@ npm run build
|
|
|
40
119
|
npm start
|
|
41
120
|
```
|
|
42
121
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
```bash
|
|
46
|
-
npx zerogterm
|
|
47
|
-
```
|
|
122
|
+
Runtime prerequisites on the host include Node.js and the native build tools required by `node-pty`. Install `screen` as well for persistent, discoverable sessions.
|
|
48
123
|
|
|
49
|
-
|
|
124
|
+
On Fedora/RHEL-like Linux systems:
|
|
50
125
|
|
|
51
126
|
```bash
|
|
52
127
|
sudo dnf install screen make gcc-c++ python3
|
|
53
|
-
npm install node-pty
|
|
54
128
|
```
|
|
55
129
|
|
|
56
|
-
|
|
130
|
+
On Windows, use a supported Node.js installation and choose PowerShell or WSL when creating a local session. WSL distributions can be selected from the local-session dialog. Remote SSH sessions work independently of the local shell backend.
|
|
131
|
+
|
|
132
|
+
`node-pty` is required for terminal I/O. When `screen` is installed, local sessions are persistent and discoverable after relaunch. Without `screen`, ZeroG falls back to a direct shell PTY and labels the session as process-only; that shell is lost when the application exits. Install `screen` for full persistence:
|
|
57
133
|
|
|
58
134
|
```bash
|
|
59
135
|
sudo dnf install screen
|
|
60
136
|
```
|
|
61
137
|
|
|
62
|
-
##
|
|
138
|
+
## Verification
|
|
139
|
+
|
|
140
|
+
The current main branch has the following local verification coverage:
|
|
63
141
|
|
|
64
142
|
- `npm run typecheck`: passes.
|
|
65
|
-
- `npm test`: passes (
|
|
143
|
+
- `npm test`: passes (28 tests covering remote-screen parsing and prompt readiness, session history, SSH inventory and argument validation, session service behavior, and voice input helpers).
|
|
66
144
|
- `npm run build`: passes and writes `dist/main` plus `dist/renderer`.
|
|
67
|
-
-
|
|
68
|
-
- `npm audit --omit=dev`: reports no known production vulnerabilities.
|
|
69
|
-
|
|
70
|
-
The live screen + node-pty smoke test creates a temporary named session, writes a marker through the PTY, observes it, and cleans up the session.
|
|
145
|
+
- `npm audit --omit=dev`: production dependency auditing is part of the project quality checks.
|
|
71
146
|
|
|
72
147
|
## License
|
|
73
148
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// System clipboard writes, verified.
|
|
2
|
+
//
|
|
3
|
+
// Only the main process can reach the OS clipboard, and Electron's writeText
|
|
4
|
+
// returns nothing: when the write loses a race for the clipboard — common on
|
|
5
|
+
// Windows, where a single owner holds it and clipboard-history tools poll it —
|
|
6
|
+
// the renderer has no way to know the text never landed. Reading back turns
|
|
7
|
+
// that silent loss into a retry, and then into a reportable error.
|
|
8
|
+
/**
|
|
9
|
+
* Did the text land, allowing for line-ending translation?
|
|
10
|
+
*
|
|
11
|
+
* Windows stores clipboard text with CRLF line endings, so a multi-line copy
|
|
12
|
+
* legitimately reads back changed. Comparing raw would report every multi-line
|
|
13
|
+
* copy as a failure.
|
|
14
|
+
*/
|
|
15
|
+
export function clipboardTextMatches(written, readBack) {
|
|
16
|
+
return written.replace(/\r\n/g, '\n') === readBack.replace(/\r\n/g, '\n');
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Write text to the clipboard and confirm it is there, retrying once.
|
|
20
|
+
*
|
|
21
|
+
* The retry is the useful part: clipboard ownership contention is momentary,
|
|
22
|
+
* and a second attempt normally succeeds where the first was refused.
|
|
23
|
+
*/
|
|
24
|
+
export function writeClipboardText(clipboard, text) {
|
|
25
|
+
clipboard.writeText(text);
|
|
26
|
+
if (clipboardTextMatches(text, clipboard.readText()))
|
|
27
|
+
return true;
|
|
28
|
+
clipboard.writeText(text);
|
|
29
|
+
return clipboardTextMatches(text, clipboard.readText());
|
|
30
|
+
}
|
package/dist/main/main/main.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { app, BrowserWindow, clipboard, ipcMain, Menu, session } from 'electron';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import {
|
|
4
|
+
import { writeClipboardText } from './clipboard.js';
|
|
5
|
+
import { ScreenService, parseWslDistributions } from './session-service.js';
|
|
6
|
+
import { discoverShellBackends } from './shell-catalog.js';
|
|
5
7
|
import { SessionHistoryStore, defaultHistoryPath } from './session-history.js';
|
|
6
8
|
import { buildRemoteScreenAttachArgs, buildRemoteScreenDiscoveryArgs, listKnownConnections, parseRemoteScreenList, validateKnownConnection } from './ssh-inventory.js';
|
|
7
9
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
@@ -128,7 +130,11 @@ ipcMain.on('terminal:resize', (_event, sessionId, cols, rows) => {
|
|
|
128
130
|
ipcMain.handle('clipboard:writeText', (_event, text) => {
|
|
129
131
|
if (typeof text !== 'string')
|
|
130
132
|
throw new Error('clipboard:writeText requires a string');
|
|
131
|
-
|
|
133
|
+
// Reject rather than resolve on a lost write: the renderer shows the message
|
|
134
|
+
// in the status bar, so the user is not told text was copied when it was not.
|
|
135
|
+
if (!writeClipboardText(clipboard, text)) {
|
|
136
|
+
throw new Error('Clipboard write failed — another application is holding the clipboard');
|
|
137
|
+
}
|
|
132
138
|
});
|
|
133
139
|
ipcMain.handle('clipboard:readText', () => clipboard.readText());
|
|
134
140
|
ipcMain.handle('ai:suggest', () => ({
|
|
@@ -3,11 +3,17 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
+
import { defaultShellBackend, isLocalShellBackend, resolveShellBackend } from './shell-catalog.js';
|
|
6
7
|
const execFileAsync = promisify(execFile);
|
|
7
8
|
const require = createRequire(import.meta.url);
|
|
8
9
|
const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,48}$/;
|
|
9
|
-
/**
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* host, user@host, host:port, user@host:port — no shell metacharacters.
|
|
12
|
+
* Host and user must start alphanumeric: a leading '-' would be parsed by
|
|
13
|
+
* ssh's getopt as an option, and `-Fsome.cfg` can point ssh at an attacker
|
|
14
|
+
* -chosen config file (hence ProxyCommand) without any shell involvement.
|
|
15
|
+
*/
|
|
16
|
+
const SSH_TARGET = /^(?:([A-Za-z0-9][A-Za-z0-9._-]*)@)?([A-Za-z0-9][A-Za-z0-9.-]*)(?::(\d{1,5}))?$/;
|
|
11
17
|
export function validateSessionName(name) {
|
|
12
18
|
const value = name.trim();
|
|
13
19
|
if (!NAME.test(value)) {
|
|
@@ -32,37 +38,14 @@ export function validateSshTarget(input) {
|
|
|
32
38
|
const args = ['-tt'];
|
|
33
39
|
if (portText)
|
|
34
40
|
args.push('-p', portText);
|
|
35
|
-
|
|
41
|
+
// '--' ends option parsing, so the destination can never be read as a flag.
|
|
42
|
+
args.push('--', destination);
|
|
36
43
|
return { target: value, args };
|
|
37
44
|
}
|
|
38
45
|
export function parseWslDistributions(output) {
|
|
39
46
|
return output.split(/\r?\n/).slice(1).map((line) => line.replace(/^\*?\s*/, '').trim())
|
|
40
47
|
.map((line) => line.split(/\s{2,}/)[0]).filter((name) => /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$/.test(name));
|
|
41
48
|
}
|
|
42
|
-
export function shellBackendArgs(backend, distribution) {
|
|
43
|
-
if (backend === 'wsl') {
|
|
44
|
-
if (distribution && !/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$/.test(distribution.trim()))
|
|
45
|
-
throw new Error('Invalid WSL distribution name.');
|
|
46
|
-
return { backend, executable: process.platform === 'win32' ? 'wsl.exe' : 'wsl', args: distribution ? ['-d', distribution.trim()] : [], label: distribution ? `WSL · ${distribution.trim()}` : 'WSL', wslDistribution: distribution?.trim() };
|
|
47
|
-
}
|
|
48
|
-
if (backend === 'powershell')
|
|
49
|
-
return { backend, executable: process.platform === 'win32' ? 'pwsh.exe' : 'pwsh', args: [], label: 'PowerShell' };
|
|
50
|
-
if (backend === 'zsh')
|
|
51
|
-
return { backend, executable: 'zsh', args: [], label: 'zsh' };
|
|
52
|
-
return { backend: 'bash', executable: 'bash', args: [], label: 'bash' };
|
|
53
|
-
}
|
|
54
|
-
export async function discoverShellBackends() {
|
|
55
|
-
const result = [shellBackendArgs('bash')];
|
|
56
|
-
for (const backend of ['powershell', 'wsl']) {
|
|
57
|
-
const candidate = shellBackendArgs(backend);
|
|
58
|
-
try {
|
|
59
|
-
await execFileAsync(candidate.executable, backend === 'wsl' ? ['--status'] : ['-NoProfile', '-Command', '$PSVersionTable.PSVersion.ToString()']);
|
|
60
|
-
result.push(candidate);
|
|
61
|
-
}
|
|
62
|
-
catch { /* unavailable */ }
|
|
63
|
-
}
|
|
64
|
-
return result;
|
|
65
|
-
}
|
|
66
49
|
export function parseScreenList(output) {
|
|
67
50
|
return output
|
|
68
51
|
.split('\n')
|
|
@@ -104,7 +87,12 @@ export class ScreenService {
|
|
|
104
87
|
sshSessions = new Map();
|
|
105
88
|
fallbackLocalSessions = new Map();
|
|
106
89
|
onEvent;
|
|
107
|
-
|
|
90
|
+
/** Injectable so tests can exercise routing and teardown without a real shell. */
|
|
91
|
+
spawnPty;
|
|
92
|
+
constructor(options = {}) {
|
|
93
|
+
this.onEvent = options.onEvent;
|
|
94
|
+
this.spawnPty = options.spawnPty ?? ((file, args, ptyOptions) => loadPty().spawn(file, args, ptyOptions));
|
|
95
|
+
}
|
|
108
96
|
async available() {
|
|
109
97
|
try {
|
|
110
98
|
await execFileAsync('screen', ['--version']);
|
|
@@ -148,11 +136,15 @@ export class ScreenService {
|
|
|
148
136
|
async createLocal(nameOrRequest, cwd = homedir()) {
|
|
149
137
|
const request = typeof nameOrRequest === 'string' ? { name: nameOrRequest, cwd } : nameOrRequest;
|
|
150
138
|
const safeName = validateSessionName(request.name);
|
|
151
|
-
|
|
152
|
-
|
|
139
|
+
// No backend named means "whatever this machine prefers": a native shell
|
|
140
|
+
// on Windows, the login shell or bash on Unix. resolveShellBackend throws by
|
|
141
|
+
// name when a named backend is not installed, so a missing shell is a clear
|
|
142
|
+
// message rather than a pty that dies on spawn.
|
|
143
|
+
const shell = request.backend
|
|
144
|
+
? resolveShellBackend(request.backend, request.wslDistribution)
|
|
145
|
+
: defaultShellBackend();
|
|
146
|
+
const backend = shell.backend;
|
|
153
147
|
const requestedCwd = request.cwd ?? homedir();
|
|
154
|
-
if (backend !== 'bash' && !(await executableAvailable(shell.executable)))
|
|
155
|
-
throw new Error(`${shell.label} is not installed or unavailable.`);
|
|
156
148
|
if (!(await this.available())) {
|
|
157
149
|
const fallback = { id: `local:${safeName}`, name: safeName, kind: 'local', host: 'local', cwd: requestedCwd, status: 'detached', lastSeen: new Date().toISOString(), persistence: 'process', backend, scope: 'local', source: 'active', wslDistribution: shell.wslDistribution };
|
|
158
150
|
this.fallbackLocalSessions.set(fallback.id, fallback);
|
|
@@ -193,12 +185,16 @@ export class ScreenService {
|
|
|
193
185
|
if (fallback) {
|
|
194
186
|
fallback.status = 'connected';
|
|
195
187
|
fallback.lastSeen = new Date().toISOString();
|
|
196
|
-
const shell =
|
|
188
|
+
const shell = isLocalShellBackend(fallback.backend)
|
|
189
|
+
? resolveShellBackend(fallback.backend, fallback.wslDistribution)
|
|
190
|
+
: defaultShellBackend();
|
|
197
191
|
this.spawnCommand(id, shell.executable, shell.args, onData, onExit, fallback.cwd);
|
|
198
192
|
this.onEvent?.('attached', fallback, true);
|
|
199
193
|
return { ...fallback };
|
|
200
194
|
}
|
|
201
|
-
|
|
195
|
+
// Session ids cross the IPC boundary from the renderer, so re-validate
|
|
196
|
+
// rather than trusting that createLocal produced this one.
|
|
197
|
+
const name = validateSessionName(id.slice('local:'.length));
|
|
202
198
|
this.spawnCommand(id, 'screen', ['-x', name], onData, onExit);
|
|
203
199
|
this.onEvent?.('attached', existing, true);
|
|
204
200
|
return { ...existing, status: 'connected', persistence: 'screen' };
|
|
@@ -226,7 +222,7 @@ export class ScreenService {
|
|
|
226
222
|
const fallback = this.fallbackLocalSessions.get(id);
|
|
227
223
|
if (fallback)
|
|
228
224
|
return fallback;
|
|
229
|
-
const name = id.slice('local:'.length);
|
|
225
|
+
const name = validateSessionName(id.slice('local:'.length));
|
|
230
226
|
return {
|
|
231
227
|
id,
|
|
232
228
|
name,
|
|
@@ -244,8 +240,7 @@ export class ScreenService {
|
|
|
244
240
|
return session;
|
|
245
241
|
}
|
|
246
242
|
spawnCommand(sessionId, file, args, onData, onExit, cwd = homedir()) {
|
|
247
|
-
const
|
|
248
|
-
const proc = pty.spawn(file, args, {
|
|
243
|
+
const proc = this.spawnPty(file, args, {
|
|
249
244
|
name: 'xterm-256color',
|
|
250
245
|
cols: 120,
|
|
251
246
|
rows: 32,
|
|
@@ -310,10 +305,3 @@ export class ScreenService {
|
|
|
310
305
|
this.onEvent?.('closed', session, false);
|
|
311
306
|
}
|
|
312
307
|
}
|
|
313
|
-
async function executableAvailable(executable) { try {
|
|
314
|
-
await execFileAsync(executable, ['--version']);
|
|
315
|
-
return true;
|
|
316
|
-
}
|
|
317
|
-
catch {
|
|
318
|
-
return false;
|
|
319
|
-
} }
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Which shells this machine can actually start, and what to spawn for each.
|
|
2
|
+
//
|
|
3
|
+
// Ported forward from the cross-platform discovery work in 690f01d on
|
|
4
|
+
// fix/cross-platform-and-review-fixes, which never reached dev. What shipped
|
|
5
|
+
// instead knew only `pwsh.exe`, so a Windows machine without PowerShell 7 — the
|
|
6
|
+
// common case, since 7 is a separate install — was offered no PowerShell at
|
|
7
|
+
// all, and the two shells Windows always has, Windows PowerShell and Command
|
|
8
|
+
// Prompt, could not be chosen. bash was offered unconditionally on every
|
|
9
|
+
// platform, including where it cannot start.
|
|
10
|
+
//
|
|
11
|
+
// Candidates are resolved against PATH rather than probed by spawning them.
|
|
12
|
+
// Spawning was slow and wrong in two ways: it cold-started PowerShell on CI,
|
|
13
|
+
// which intermittently blew vitest's timeout, and it made `--version` the test
|
|
14
|
+
// of existence, which Command Prompt does not answer. Resolving also yields an
|
|
15
|
+
// absolute path, which is what node-pty needs on Windows — it does not apply
|
|
16
|
+
// PATHEXT, so a bare `bash` fails there with "File not found:".
|
|
17
|
+
import { statSync } from 'node:fs';
|
|
18
|
+
/** Distro names reach `wsl.exe -d <name>`; a leading '-' would read as a flag. */
|
|
19
|
+
const WSL_DISTRIBUTION = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$/;
|
|
20
|
+
const LOCAL_SHELL_BACKENDS = [
|
|
21
|
+
'bash', 'zsh', 'fish', 'sh', 'powershell', 'pwsh', 'cmd', 'wsl'
|
|
22
|
+
];
|
|
23
|
+
/**
|
|
24
|
+
* Does this value name a shell?
|
|
25
|
+
*
|
|
26
|
+
* A stored session's backend can also be a transport — 'screen' for a session
|
|
27
|
+
* inside screen, 'ssh' for a remote one — and it arrives from the history file
|
|
28
|
+
* on disk, so it is narrowed rather than asserted.
|
|
29
|
+
*/
|
|
30
|
+
export function isLocalShellBackend(value) {
|
|
31
|
+
return typeof value === 'string' && LOCAL_SHELL_BACKENDS.includes(value);
|
|
32
|
+
}
|
|
33
|
+
function defaultIsFile(candidate) {
|
|
34
|
+
try {
|
|
35
|
+
return statSync(candidate).isFile();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function resolved(options) {
|
|
42
|
+
const platform = options.platform ?? process.platform;
|
|
43
|
+
return {
|
|
44
|
+
platform,
|
|
45
|
+
windows: platform === 'win32',
|
|
46
|
+
path: options.path ?? process.env.PATH ?? '',
|
|
47
|
+
pathExt: options.pathExt ?? process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM',
|
|
48
|
+
loginShell: options.loginShell ?? process.env.SHELL,
|
|
49
|
+
isFile: options.isFile ?? defaultIsFile
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Path arithmetic for the *target* platform, not the host.
|
|
54
|
+
*
|
|
55
|
+
* node:path's `delimiter`, `sep` and `join` follow whichever platform this code
|
|
56
|
+
* is running on, which is right in production and wrong for reasoning about a
|
|
57
|
+
* platform passed in — splitting a Unix PATH on ';' yields one long entry that
|
|
58
|
+
* matches nothing. Doing it explicitly keeps the module honest either way.
|
|
59
|
+
*/
|
|
60
|
+
function pathParts(value, windows) {
|
|
61
|
+
return value.split(windows ? ';' : ':').filter(Boolean);
|
|
62
|
+
}
|
|
63
|
+
function joinPath(directory, name, windows) {
|
|
64
|
+
const separator = windows ? '\\' : '/';
|
|
65
|
+
const trimmed = directory.replace(/[\\/]+$/, '');
|
|
66
|
+
return `${trimmed}${separator}${name}`;
|
|
67
|
+
}
|
|
68
|
+
function fileName(value) {
|
|
69
|
+
return value.split(/[\\/]/).pop() ?? value;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Resolve a bare command name against PATH without spawning anything.
|
|
73
|
+
*
|
|
74
|
+
* On Windows the extension is usually part of the command already, but PATHEXT
|
|
75
|
+
* is honoured so `pwsh` finds `pwsh.exe` — the empty extension is tried first so
|
|
76
|
+
* an exact name still wins.
|
|
77
|
+
*
|
|
78
|
+
* `accept` lets a caller reject a match and keep searching later PATH entries,
|
|
79
|
+
* which is what finding Git Bash behind System32's shim requires.
|
|
80
|
+
*/
|
|
81
|
+
export function findExecutable(command, options = {}, accept = () => true) {
|
|
82
|
+
const { windows, path, pathExt, isFile } = resolved(options);
|
|
83
|
+
const extensions = windows ? ['', ...pathExt.split(';').filter(Boolean)] : [''];
|
|
84
|
+
for (const directory of pathParts(path, windows)) {
|
|
85
|
+
for (const extension of extensions) {
|
|
86
|
+
const candidate = joinPath(directory, command + extension, windows);
|
|
87
|
+
if (isFile(candidate) && accept(candidate))
|
|
88
|
+
return candidate;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Is this the `bash.exe` shim Windows installs for WSL?
|
|
95
|
+
*
|
|
96
|
+
* System32's bash.exe is present on every Windows 10 and 11 machine whether or
|
|
97
|
+
* not a distribution is installed, and it launches WSL rather than Git Bash.
|
|
98
|
+
* Offering it would mean a "Git Bash" entry that starts something else, or
|
|
99
|
+
* nothing at all — and WSL already has its own backend. Rejecting it as a
|
|
100
|
+
* candidate rather than abandoning the search lets Git for Windows' own
|
|
101
|
+
* bash.exe, further along PATH, still be found.
|
|
102
|
+
*/
|
|
103
|
+
function isWslBashShim(file) {
|
|
104
|
+
return /[\\/]windows[\\/]system32[\\/]/i.test(file);
|
|
105
|
+
}
|
|
106
|
+
/** The shells worth offering on this platform, in the order they are preferred. */
|
|
107
|
+
function shellCandidates(windows) {
|
|
108
|
+
if (windows) {
|
|
109
|
+
return [
|
|
110
|
+
{ backend: 'powershell', label: 'Windows PowerShell', command: 'powershell.exe' },
|
|
111
|
+
{ backend: 'pwsh', label: 'PowerShell 7', command: 'pwsh.exe' },
|
|
112
|
+
{ backend: 'cmd', label: 'Command Prompt', command: 'cmd.exe' },
|
|
113
|
+
{ backend: 'wsl', label: 'WSL', command: 'wsl.exe' },
|
|
114
|
+
// Git for Windows ships bash; useful, but not a login shell for the OS.
|
|
115
|
+
{ backend: 'bash', label: 'Git Bash', command: 'bash.exe' }
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
return [
|
|
119
|
+
{ backend: 'bash', label: 'Bash', command: 'bash' },
|
|
120
|
+
{ backend: 'zsh', label: 'Zsh', command: 'zsh' },
|
|
121
|
+
{ backend: 'fish', label: 'Fish', command: 'fish' },
|
|
122
|
+
{ backend: 'sh', label: 'sh', command: 'sh' }
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Move the user's login shell to the front, and say so in its label.
|
|
127
|
+
*
|
|
128
|
+
* $SHELL is what the user chose for themselves, so it belongs first — but only
|
|
129
|
+
* as an ordering: a login shell that is not one of the known backends is left
|
|
130
|
+
* alone rather than spawned under a name the rest of the app cannot describe.
|
|
131
|
+
*/
|
|
132
|
+
function preferLoginShell(shells, loginShell) {
|
|
133
|
+
if (!loginShell)
|
|
134
|
+
return shells;
|
|
135
|
+
const name = fileName(loginShell);
|
|
136
|
+
const index = shells.findIndex((shell) => shell.backend === name);
|
|
137
|
+
if (index <= 0)
|
|
138
|
+
return shells;
|
|
139
|
+
const preferred = { ...shells[index], label: `${shells[index].label} (login shell)` };
|
|
140
|
+
return [preferred, ...shells.filter((_, position) => position !== index)];
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Every shell that exists on this machine, most preferred first.
|
|
144
|
+
*
|
|
145
|
+
* The first entry is the platform default: a native shell on Windows, the login
|
|
146
|
+
* shell or bash on Unix.
|
|
147
|
+
*/
|
|
148
|
+
export function discoverShellBackends(options = {}) {
|
|
149
|
+
const context = resolved(options);
|
|
150
|
+
const found = [];
|
|
151
|
+
for (const candidate of shellCandidates(context.windows)) {
|
|
152
|
+
const skipShim = context.windows && candidate.backend === 'bash';
|
|
153
|
+
const file = findExecutable(candidate.command, options, skipShim ? (match) => !isWslBashShim(match) : undefined);
|
|
154
|
+
if (!file)
|
|
155
|
+
continue;
|
|
156
|
+
found.push({
|
|
157
|
+
backend: candidate.backend,
|
|
158
|
+
executable: file,
|
|
159
|
+
args: candidate.args ?? [],
|
|
160
|
+
label: candidate.label
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return context.windows ? found : preferLoginShell(found, context.loginShell);
|
|
164
|
+
}
|
|
165
|
+
/** The backend a new session gets when the caller names none. */
|
|
166
|
+
export function defaultShellBackend(options = {}) {
|
|
167
|
+
const shells = discoverShellBackends(options);
|
|
168
|
+
if (!shells.length)
|
|
169
|
+
throw new Error('No usable shell was found on PATH.');
|
|
170
|
+
return shells[0];
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* What to spawn for a requested backend.
|
|
174
|
+
*
|
|
175
|
+
* The backend name crosses the IPC boundary from the renderer, so it is looked
|
|
176
|
+
* up in the catalogue rather than treated as a command: the renderer can name a
|
|
177
|
+
* shell, never an executable. A backend that is not installed fails here, by
|
|
178
|
+
* name, instead of as a pty that dies on spawn.
|
|
179
|
+
*/
|
|
180
|
+
export function resolveShellBackend(backend, distribution, options = {}) {
|
|
181
|
+
const match = discoverShellBackends(options).find((shell) => shell.backend === backend);
|
|
182
|
+
if (!match) {
|
|
183
|
+
const label = shellCandidates(resolved(options).windows).find((candidate) => candidate.backend === backend)?.label ?? backend;
|
|
184
|
+
throw new Error(`${label} is not installed or unavailable.`);
|
|
185
|
+
}
|
|
186
|
+
if (backend !== 'wsl' || !distribution)
|
|
187
|
+
return match;
|
|
188
|
+
const trimmed = distribution.trim();
|
|
189
|
+
if (!WSL_DISTRIBUTION.test(trimmed)) {
|
|
190
|
+
throw new Error('WSL distribution names may contain letters, numbers, spaces, _, ., and - only.');
|
|
191
|
+
}
|
|
192
|
+
return { ...match, args: ['-d', trimmed], label: `WSL · ${trimmed}`, wslDistribution: trimmed };
|
|
193
|
+
}
|
|
@@ -2,7 +2,10 @@ import { homedir } from 'node:os';
|
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
4
4
|
const HOST = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,253}$/;
|
|
5
|
-
|
|
5
|
+
// Must start alphanumeric like TOKEN/HOST above: the user is concatenated into
|
|
6
|
+
// `user@host`, so a leading '-' makes the whole destination look like an option
|
|
7
|
+
// to ssh's getopt (`-Fevil.cfg@host` reads an attacker-chosen config file).
|
|
8
|
+
const USER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
6
9
|
const SCREEN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,48}$/;
|
|
7
10
|
function words(value) {
|
|
8
11
|
const result = [];
|