zerogterm 0.9.4-alpha.1 → 1.0.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.
- package/README.md +36 -12
- package/bin/zerogterm.cjs +7 -1
- package/dist/main/main/main.js +32 -6
- package/dist/main/main/port-forward-service.js +2 -5
- package/dist/main/main/pty-lifecycle.js +47 -0
- package/dist/main/main/session-service.js +17 -15
- package/dist/main/main/sftp-service.js +2 -3
- package/dist/main/main/ssh-inventory.js +7 -5
- package/dist/renderer/assets/index-BeAly2uZ.js +149 -0
- package/dist/renderer/index.html +1 -1
- package/package.json +37 -10
- package/scripts/patch-node-pty.mjs +55 -0
- package/dist/manifest.json +0 -1
- package/dist/renderer/assets/index-CbW026Ci.js +0 -149
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
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
4
|
|
|
5
|
-
ZeroG Terminal is
|
|
5
|
+
ZeroG Terminal is 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
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
@@ -15,7 +15,8 @@ ZeroG Terminal is an alpha project, but it is already useful as a multi-session
|
|
|
15
15
|
- SSH sessions for hosts, `user@host`, and `user@host:port` targets.
|
|
16
16
|
- SSH configuration discovery from known connections, including remote `screen` session discovery. In the sidebar's Connections tab, clicking a saved connection opens the connect dialog with the host filled in, and double-clicking it skips the dialog and opens the host in a new pane.
|
|
17
17
|
- Reconnect to existing local or remote `screen` sessions from the Screens view.
|
|
18
|
-
- Remote screen
|
|
18
|
+
- Remote screen discovery for the selected SSH destination, including manually entered hosts, with refresh and visible failure states. Background discovery uses key/agent authentication; password-only hosts cannot be queried through the terminal connection.
|
|
19
|
+
- Remote screen attachment launched directly through SSH, preserving the account, custom port and SSH config alias, with authentication and host-key prompts left to the user.
|
|
19
20
|
- Shared ports over SSH, in a Ports view opened from the rail: forward a port on a remote host so it answers on this machine, or a port here so it answers on the remote. Each tunnel is its own SSH connection, so a host needs no terminal open first. Ports bind to loopback unless you widen them, and are remembered between launches.
|
|
20
21
|
- A directory browser inside a pane, opened from the folder button in its title bar: the pane splits, terminal on one side and the directories on the other, with a draggable divider. Double-click a folder to take the shell there. It works on SSH panes and on local ones, WSL included, follows a `cd` you type by hand, and is remembered per pane when you switch workspaces or relaunch.
|
|
21
22
|
- An SFTP transfer panel, opened from the ⇅ button above the panes: local files on the left, the active SSH session's host on the right, with upload, download, new folder, rename, and delete. It connects to the host that session is already using and opens at the directory its shell is standing in, so a file can go straight to the project being worked on.
|
|
@@ -38,9 +39,9 @@ See the project walkthrough on [YouTube](https://youtu.be/4aJZCxLHD14).
|
|
|
38
39
|
|
|
39
40
|
## Release status
|
|
40
41
|
|
|
41
|
-
ZeroG Terminal is
|
|
42
|
+
ZeroG Terminal 1.0.0 is the first stable release. The current release is `1.0.0`; the version history is tracked in [versions.txt](versions.txt). The running version is shown beside the wordmark in the title bar, read from the app itself rather than written into the interface, so it is accurate in a packaged build too.
|
|
42
43
|
|
|
43
|
-
The GitHub Releases page provides a Windows x64 installer and portable executable plus a Linux x64 AppImage and Debian package for each desktop release.
|
|
44
|
+
The GitHub Releases page provides a Windows x64 installer and portable executable plus a Linux x64 AppImage and Debian package for each desktop release. The npm package remains available for developers who prefer to launch ZeroG from Node.js.
|
|
44
45
|
|
|
45
46
|
## Terminal shortcuts
|
|
46
47
|
|
|
@@ -386,13 +387,13 @@ how the threshold gets tuned for a particular microphone.
|
|
|
386
387
|
The simplest way to try the published package is through `npx`:
|
|
387
388
|
|
|
388
389
|
```bash
|
|
389
|
-
npx zerogterm
|
|
390
|
+
npx --package=electron@43 --package=zerogterm zerogterm
|
|
390
391
|
```
|
|
391
392
|
|
|
392
|
-
The
|
|
393
|
+
The npm launcher needs Electron alongside ZeroG Terminal; the command above installs both into the temporary npx environment. To use the launcher repeatedly without downloading on each invocation, install it globally:
|
|
393
394
|
|
|
394
395
|
```bash
|
|
395
|
-
npm install --global zerogterm
|
|
396
|
+
npm install --global electron@43 zerogterm
|
|
396
397
|
zerogterm
|
|
397
398
|
```
|
|
398
399
|
|
|
@@ -405,14 +406,14 @@ project workspaces, open SSH sessions, and request commands for your approval.
|
|
|
405
406
|
1. Start ZeroG Terminal with MCP enabled:
|
|
406
407
|
|
|
407
408
|
```bash
|
|
408
|
-
ZEROG_MCP_ENABLED=1 npx zerogterm
|
|
409
|
+
ZEROG_MCP_ENABLED=1 npx --package=electron@43 --package=zerogterm zerogterm
|
|
409
410
|
```
|
|
410
411
|
|
|
411
412
|
On Windows PowerShell, use:
|
|
412
413
|
|
|
413
414
|
```powershell
|
|
414
415
|
$env:ZEROG_MCP_ENABLED = "1"
|
|
415
|
-
npx zerogterm
|
|
416
|
+
npx --package=electron@43 --package=zerogterm zerogterm
|
|
416
417
|
```
|
|
417
418
|
|
|
418
419
|
2. Open **Settings → AI & voice → MCP** in ZeroG Terminal. Copy the displayed
|
|
@@ -469,7 +470,7 @@ general one — there, `npm install` compiles it.
|
|
|
469
470
|
|
|
470
471
|
### Desktop releases
|
|
471
472
|
|
|
472
|
-
Download the latest release for your platform from [GitHub Releases](https://github.com/HappyMonkeyAI/ZeroGTerm/releases).
|
|
473
|
+
Download the latest release for your platform from [GitHub Releases](https://github.com/HappyMonkeyAI/ZeroGTerm/releases). Review the release notes before installing; SSH, WSL, PowerShell, Git Bash, and any remote `screen` sessions are provided by the host system rather than bundled with the app.
|
|
473
474
|
|
|
474
475
|
#### Windows
|
|
475
476
|
|
|
@@ -502,14 +503,37 @@ picker), and Git Bash where Git for Windows is installed.
|
|
|
502
503
|
do not survive app exit. Remote SSH sessions are unaffected by the local shell
|
|
503
504
|
backend, and a remote host with `screen` still gives persistent sessions there.
|
|
504
505
|
|
|
506
|
+
The pinned `node-pty` dependency receives a small ConPTY teardown patch during
|
|
507
|
+
install and build (`scripts/patch-node-pty.mjs`). Review that patch before changing
|
|
508
|
+
its version. Run `node scripts/pty-shutdown-smoke.cjs` on Windows to check forced
|
|
509
|
+
and graceful shutdown with a real child process. Release evaluation evidence and
|
|
510
|
+
remaining sign-off items are in [the release check report](docs/release-readiness-screen-check.md).
|
|
511
|
+
|
|
512
|
+
Windows source packaging rebuilds native dependencies. Install Visual Studio 2022
|
|
513
|
+
Build Tools with the C++ desktop workload by importing [`.vsconfig`](.vsconfig),
|
|
514
|
+
then run `powershell -ExecutionPolicy Bypass -File scripts/ensure-windows-build-tools.ps1`.
|
|
515
|
+
The helper checks the selected MSVC toolset and installs its missing Spectre libraries
|
|
516
|
+
through Visual Studio Installer if needed; Windows may request administrator approval.
|
|
517
|
+
Then run `npm run package:win`. The normal native rebuild and installed application
|
|
518
|
+
have been verified; compiler protections remain enabled.
|
|
519
|
+
|
|
520
|
+
Linux packaging uses `/opt/ZeroGTerminal` to avoid Electron's sandbox launcher issue
|
|
521
|
+
with spaces in installation paths; the application menu still says **ZeroG Terminal**.
|
|
522
|
+
The Debian package declares its audio and graphics runtime libraries explicitly.
|
|
523
|
+
|
|
524
|
+
The Electron downloader has a scoped `global-agent` override to remove the vulnerable
|
|
525
|
+
`sprintf-js` dependency chain. `node scripts/check-downloader-proxy.mjs` verifies proxy
|
|
526
|
+
routing and `NO_PROXY` bypass using local fixtures. CI runs this check and the full audit.
|
|
527
|
+
|
|
528
|
+
|
|
505
529
|
## Verification
|
|
506
530
|
|
|
507
531
|
The current main branch has the following local verification coverage:
|
|
508
532
|
|
|
509
533
|
- `npm run typecheck`: passes.
|
|
510
|
-
- `npm test`:
|
|
534
|
+
- `npm test`: 64 test files; 1,169 passed and 3 platform skips on Windows, all 1,172 passed on Linux with `ZEROG_LIVE_SCREEN=1`.
|
|
511
535
|
- `npm run build`: passes and writes `dist/main` plus `dist/renderer`.
|
|
512
|
-
- `npm audit --
|
|
536
|
+
- `npm audit --audit-level=moderate`: zero advisories in the verified dependency tree.
|
|
513
537
|
|
|
514
538
|
## License
|
|
515
539
|
|
package/bin/zerogterm.cjs
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
const { spawn } = require('node:child_process');
|
|
4
4
|
const path = require('node:path');
|
|
5
|
-
|
|
5
|
+
let electron;
|
|
6
|
+
try {
|
|
7
|
+
electron = require('electron');
|
|
8
|
+
} catch {
|
|
9
|
+
console.error('Electron runtime was not found. Install both packages: npm install --global electron@43 zerogterm');
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
6
12
|
|
|
7
13
|
const packageRoot = path.resolve(__dirname, '..');
|
|
8
14
|
const child = spawn(electron, [packageRoot, ...process.argv.slice(2)], {
|
package/dist/main/main/main.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { shutdownPtys } from './pty-lifecycle.js';
|
|
1
2
|
import { app, BrowserWindow, clipboard, ipcMain, Menu, safeStorage, session, shell } from 'electron';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { randomUUID } from 'node:crypto';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import { writeClipboardText } from './clipboard.js';
|
|
6
|
-
import { ScreenService, parseWslDistributions } from './session-service.js';
|
|
7
|
+
import { ScreenService, sshExecutable, parseWslDistributions } from './session-service.js';
|
|
7
8
|
import { discoverShellBackends } from './shell-catalog.js';
|
|
8
9
|
import { SessionHistoryStore, defaultHistoryPath } from './session-history.js';
|
|
9
10
|
import { CommandHistoryStore, defaultCommandHistoryPath } from './command-history-store.js';
|
|
@@ -517,7 +518,9 @@ ipcMain.handle('sessions:createSsh', async (_event, request) => {
|
|
|
517
518
|
throw new Error('createSshSession requires { target: string }');
|
|
518
519
|
}
|
|
519
520
|
const name = typeof request.name === 'string' ? request.name : undefined;
|
|
520
|
-
|
|
521
|
+
if (request.screenName !== undefined && typeof request.screenName !== 'string')
|
|
522
|
+
throw new Error('Invalid screen name');
|
|
523
|
+
return service.createSsh(request.target, name, request.screenName);
|
|
521
524
|
});
|
|
522
525
|
ipcMain.handle('connections:listKnown', () => listKnownConnections());
|
|
523
526
|
ipcMain.handle('screens:discoverRemote', async (_event, input) => {
|
|
@@ -526,11 +529,15 @@ ipcMain.handle('screens:discoverRemote', async (_event, input) => {
|
|
|
526
529
|
const { execFile } = await import('node:child_process');
|
|
527
530
|
const { promisify } = await import('node:util');
|
|
528
531
|
try {
|
|
529
|
-
const { stdout } = await promisify(execFile)(
|
|
532
|
+
const { stdout } = await promisify(execFile)(sshExecutable(), command.args, { timeout: 15000, maxBuffer: 256 * 1024 });
|
|
530
533
|
return parseRemoteScreenList(stdout, connection.alias);
|
|
531
534
|
}
|
|
532
535
|
catch (error) {
|
|
533
|
-
|
|
536
|
+
// screen exits with 1 when there are no sockets. Other failures must reach
|
|
537
|
+
// the UI rather than pretending that the host has no running sessions.
|
|
538
|
+
if (error?.code === 1 && /No Sockets found/i.test(error.stdout ?? ''))
|
|
539
|
+
return [];
|
|
540
|
+
throw new Error('Screen discovery failed. Check SSH key/agent access and that screen is installed on the host.');
|
|
534
541
|
}
|
|
535
542
|
});
|
|
536
543
|
ipcMain.handle('screens:attachRemote', (_event, input, screenName) => {
|
|
@@ -783,8 +790,27 @@ app.whenReady().then(() => {
|
|
|
783
790
|
createWindow();
|
|
784
791
|
});
|
|
785
792
|
});
|
|
786
|
-
|
|
787
|
-
|
|
793
|
+
let shutdownStarted = false;
|
|
794
|
+
let shutdownComplete = false;
|
|
795
|
+
app.on('before-quit', (event) => {
|
|
796
|
+
if (shutdownComplete)
|
|
797
|
+
return;
|
|
798
|
+
event.preventDefault();
|
|
799
|
+
if (shutdownStarted)
|
|
800
|
+
return;
|
|
801
|
+
shutdownStarted = true;
|
|
802
|
+
service.detachAll();
|
|
803
|
+
sftp.closeAll();
|
|
804
|
+
forwards.closeAll();
|
|
805
|
+
void Promise.allSettled([shutdownPtys(), agentHooks.stop()]).then((results) => {
|
|
806
|
+
for (const result of results) {
|
|
807
|
+
if (result.status === 'rejected')
|
|
808
|
+
console.error('[zerog] shutdown failed', result.reason);
|
|
809
|
+
}
|
|
810
|
+
}).finally(() => {
|
|
811
|
+
shutdownComplete = true;
|
|
812
|
+
app.quit();
|
|
813
|
+
});
|
|
788
814
|
});
|
|
789
815
|
app.on('window-all-closed', () => {
|
|
790
816
|
if (process.platform !== 'darwin')
|
|
@@ -1,11 +1,8 @@
|
|
|
1
|
+
import { spawnManagedPty } from './pty-lifecycle.js';
|
|
1
2
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { createRequire } from 'node:module';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { FORWARD_SETTLE_MS, buildForwardArgs, describeForward, readForwardOutcome } from './port-forward-protocol.js';
|
|
5
5
|
import { detectPrompt } from './sftp-protocol.js';
|
|
6
|
-
// node-pty is a native addon with no ESM entry point, and this file is compiled
|
|
7
|
-
// as a module. Same shim as session-service.ts and sftp-service.ts.
|
|
8
|
-
const require = createRequire(import.meta.url);
|
|
9
6
|
/** A ceiling on live tunnels, so a stuck panel cannot spawn clients forever. */
|
|
10
7
|
const MAX_FORWARDS = 16;
|
|
11
8
|
/** Nothing at all from the client within this long means the host never answered. */
|
|
@@ -33,7 +30,7 @@ export class PortForwardService {
|
|
|
33
30
|
onEvent;
|
|
34
31
|
settleMs;
|
|
35
32
|
constructor(options = {}) {
|
|
36
|
-
this.spawnPty = options.spawnPty ??
|
|
33
|
+
this.spawnPty = options.spawnPty ?? spawnManagedPty;
|
|
37
34
|
this.onEvent = options.onEvent ?? (() => undefined);
|
|
38
35
|
this.settleMs = options.settleMs ?? FORWARD_SETTLE_MS;
|
|
39
36
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
const require = createRequire(import.meta.url);
|
|
3
|
+
/** All three PTY owners must finish native teardown before Electron exits. */
|
|
4
|
+
export class PtyLifecycle {
|
|
5
|
+
spawn;
|
|
6
|
+
closing = false;
|
|
7
|
+
processes = new Map();
|
|
8
|
+
constructor(spawn) {
|
|
9
|
+
this.spawn = spawn;
|
|
10
|
+
}
|
|
11
|
+
start = (file, args, options) => {
|
|
12
|
+
if (this.closing)
|
|
13
|
+
throw new Error('The application is shutting down.');
|
|
14
|
+
const process = this.spawn(file, args, options);
|
|
15
|
+
let exited;
|
|
16
|
+
this.processes.set(process, new Promise((resolve) => { exited = resolve; }));
|
|
17
|
+
process.onExit(() => { this.processes.delete(process); exited(); });
|
|
18
|
+
return process;
|
|
19
|
+
};
|
|
20
|
+
async shutdown(timeoutMs = 6000) {
|
|
21
|
+
this.closing = true;
|
|
22
|
+
const waiting = [...this.processes.values()];
|
|
23
|
+
for (const process of this.processes.keys()) {
|
|
24
|
+
try {
|
|
25
|
+
process.kill();
|
|
26
|
+
}
|
|
27
|
+
catch { /* exit callback may already be queued */ }
|
|
28
|
+
}
|
|
29
|
+
if (!waiting.length)
|
|
30
|
+
return;
|
|
31
|
+
let timer;
|
|
32
|
+
try {
|
|
33
|
+
await Promise.race([
|
|
34
|
+
Promise.all(waiting),
|
|
35
|
+
new Promise((_, reject) => {
|
|
36
|
+
timer = setTimeout(() => reject(new Error('PTY shutdown timed out.')), timeoutMs);
|
|
37
|
+
})
|
|
38
|
+
]);
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const lifecycle = new PtyLifecycle((file, args, options) => require('node-pty').spawn(file, args, options));
|
|
46
|
+
export const spawnManagedPty = lifecycle.start;
|
|
47
|
+
export const shutdownPtys = () => lifecycle.shutdown();
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
+
import { spawnManagedPty } from './pty-lifecycle.js';
|
|
1
2
|
import { execFile } from 'node:child_process';
|
|
2
3
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { createRequire } from 'node:module';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { WSL_HOME, defaultShellBackend, findOpenSshTool, isLocalShellBackend, ptyStartDirectory, resolveShellBackend, withWslStartDirectory } from './shell-catalog.js';
|
|
7
7
|
const execFileAsync = promisify(execFile);
|
|
8
|
-
const require = createRequire(import.meta.url);
|
|
9
8
|
const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,48}$/;
|
|
10
9
|
/**
|
|
11
10
|
* host, user@host, host:port, user@host:port — no shell metacharacters.
|
|
@@ -125,14 +124,6 @@ export function parseScreenList(output) {
|
|
|
125
124
|
];
|
|
126
125
|
});
|
|
127
126
|
}
|
|
128
|
-
function loadPty() {
|
|
129
|
-
try {
|
|
130
|
-
return require('node-pty');
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
throw new Error('PTY support is not installed. Run: npm install node-pty (needs make/gcc-c++).');
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
127
|
export class ScreenService {
|
|
137
128
|
ptys = new Map();
|
|
138
129
|
sshSessions = new Map();
|
|
@@ -150,7 +141,7 @@ export class ScreenService {
|
|
|
150
141
|
spawnPty;
|
|
151
142
|
constructor(options = {}) {
|
|
152
143
|
this.onEvent = options.onEvent;
|
|
153
|
-
this.spawnPty = options.spawnPty ??
|
|
144
|
+
this.spawnPty = options.spawnPty ?? spawnManagedPty;
|
|
154
145
|
}
|
|
155
146
|
async available() {
|
|
156
147
|
try {
|
|
@@ -221,8 +212,10 @@ export class ScreenService {
|
|
|
221
212
|
this.onEvent?.('created', session, true);
|
|
222
213
|
return session;
|
|
223
214
|
}
|
|
224
|
-
async createSsh(targetInput, name) {
|
|
215
|
+
async createSsh(targetInput, name, screenName) {
|
|
225
216
|
const { target } = validateSshTarget(targetInput);
|
|
217
|
+
if (screenName !== undefined)
|
|
218
|
+
validateSessionName(screenName);
|
|
226
219
|
const safeName = validateSessionName(name?.trim() || target.replace(/[^a-zA-Z0-9_.-]+/g, '-').slice(0, 40));
|
|
227
220
|
const session = {
|
|
228
221
|
id: `ssh:${randomUUID()}`,
|
|
@@ -233,7 +226,8 @@ export class ScreenService {
|
|
|
233
226
|
status: 'detached',
|
|
234
227
|
lastSeen: new Date().toISOString(),
|
|
235
228
|
sshTarget: target,
|
|
236
|
-
backend: 'ssh',
|
|
229
|
+
backend: screenName ? 'screen' : 'ssh',
|
|
230
|
+
...(screenName ? { screenName, persistence: 'screen' } : {}),
|
|
237
231
|
scope: 'remote',
|
|
238
232
|
source: 'active'
|
|
239
233
|
};
|
|
@@ -285,6 +279,8 @@ export class ScreenService {
|
|
|
285
279
|
throw new Error(`Unknown session: ${id}`);
|
|
286
280
|
}
|
|
287
281
|
const { args } = validateSshTarget(session.sshTarget);
|
|
282
|
+
if (session.screenName)
|
|
283
|
+
args.push('screen', '-x', validateSessionName(session.screenName));
|
|
288
284
|
session.status = 'connected';
|
|
289
285
|
session.lastSeen = new Date().toISOString();
|
|
290
286
|
try {
|
|
@@ -327,7 +323,9 @@ export class ScreenService {
|
|
|
327
323
|
cols,
|
|
328
324
|
rows,
|
|
329
325
|
cwd,
|
|
330
|
-
|
|
326
|
+
// ConPTY does not set TERM from node-pty's name option. OpenSSH needs
|
|
327
|
+
// it to request a remote terminal that screen can recognise.
|
|
328
|
+
env: { ...process.env, TERM: 'xterm-256color' }
|
|
331
329
|
});
|
|
332
330
|
const handle = {
|
|
333
331
|
write: (data) => proc.write(data),
|
|
@@ -341,9 +339,13 @@ export class ScreenService {
|
|
|
341
339
|
}
|
|
342
340
|
}
|
|
343
341
|
};
|
|
344
|
-
|
|
342
|
+
const record = { handle, kind: sessionId.startsWith('ssh:') ? 'ssh' : 'local', cols, rows };
|
|
343
|
+
this.ptys.set(sessionId, record);
|
|
345
344
|
proc.onData(onData);
|
|
346
345
|
proc.onExit(() => {
|
|
346
|
+
// A detached PTY can exit after a new attachment already owns this id.
|
|
347
|
+
if (this.ptys.get(sessionId) !== record)
|
|
348
|
+
return;
|
|
347
349
|
this.ptys.delete(sessionId);
|
|
348
350
|
const fallback = this.fallbackLocalSessions.get(sessionId);
|
|
349
351
|
if (fallback) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawnManagedPty } from './pty-lifecycle.js';
|
|
1
2
|
// The stateful half of SFTP support: one live `sftp` client per remote host,
|
|
2
3
|
// with commands serialised onto it and its answers turned into data by
|
|
3
4
|
// sftp-protocol.ts.
|
|
@@ -10,9 +11,7 @@
|
|
|
10
11
|
import { randomUUID } from 'node:crypto';
|
|
11
12
|
import { homedir } from 'node:os';
|
|
12
13
|
import { baseName, joinRemote, sortEntries } from '../shared/files.js';
|
|
13
|
-
import { createRequire } from 'node:module';
|
|
14
14
|
import { buildSftpArgs, detectFatal, detectPrompt, endsWithPrompt, findError, isSafeRemotePath, parseListing, parseProgress, lastClientMessage, parsePwd, quoteLocalPath, quoteRemotePath, responseBody } from './sftp-protocol.js';
|
|
15
|
-
const require = createRequire(import.meta.url);
|
|
16
15
|
/**
|
|
17
16
|
* Wide enough that a long `ls -l` record is never wrapped by the pty, which
|
|
18
17
|
* would split one file's row across two lines and lose its name. Rows are
|
|
@@ -72,7 +71,7 @@ export class SftpService {
|
|
|
72
71
|
spawnPty;
|
|
73
72
|
onEvent;
|
|
74
73
|
constructor(options = {}) {
|
|
75
|
-
this.spawnPty = options.spawnPty ??
|
|
74
|
+
this.spawnPty = options.spawnPty ?? spawnManagedPty;
|
|
76
75
|
this.onEvent = options.onEvent ?? (() => undefined);
|
|
77
76
|
}
|
|
78
77
|
/**
|
|
@@ -102,26 +102,28 @@ export function validateKnownConnection(input) {
|
|
|
102
102
|
}
|
|
103
103
|
function sshArgs(connection) {
|
|
104
104
|
const c = validateKnownConnection(connection);
|
|
105
|
-
|
|
105
|
+
// Keep config aliases intact so IdentityFile, ProxyJump and Match still apply.
|
|
106
|
+
const host = c.source ? c.alias : (c.hostName ?? c.alias);
|
|
107
|
+
const destination = c.user ? `${c.user}@${host}` : host;
|
|
106
108
|
const args = [];
|
|
107
109
|
if (c.port)
|
|
108
110
|
args.push('-p', String(c.port));
|
|
109
|
-
args.push(
|
|
111
|
+
args.push('--', destination);
|
|
110
112
|
return args;
|
|
111
113
|
}
|
|
112
114
|
export function buildRemoteScreenDiscoveryArgs(connection) {
|
|
113
|
-
return { file: 'ssh', args: [...sshArgs(connection), 'screen', '-ls'] };
|
|
115
|
+
return { file: 'ssh', args: ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', ...sshArgs(connection), 'screen', '-ls'] };
|
|
114
116
|
}
|
|
115
117
|
export function buildRemoteScreenAttachArgs(connection, screenName) {
|
|
116
118
|
if (!SCREEN.test(screenName))
|
|
117
119
|
throw new Error('Invalid screen session name.');
|
|
118
|
-
return { file: 'ssh', args: [...sshArgs(connection), 'screen', '-x', screenName] };
|
|
120
|
+
return { file: 'ssh', args: ['-tt', ...sshArgs(connection), 'screen', '-x', screenName] };
|
|
119
121
|
}
|
|
120
122
|
export function parseRemoteScreenList(output, host) {
|
|
121
123
|
if (!TOKEN.test(host))
|
|
122
124
|
throw new Error('Invalid remote host identity.');
|
|
123
125
|
return output.split(/\r?\n/).flatMap((line) => {
|
|
124
|
-
const match = line.trim().match(/^\d+\.(\S+)\s
|
|
126
|
+
const match = line.trim().match(/^\d+\.(\S+)\s+(?:\([^)]*\)\s+)*\((Attached|Detached)\)\s*$/i);
|
|
125
127
|
if (!match || !SCREEN.test(match[1]))
|
|
126
128
|
return [];
|
|
127
129
|
const name = match[1];
|