zerogterm 0.6.0-alpha.1 → 0.7.0-alpha2

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 CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Stephen Phillips
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stephen Phillips
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -7,13 +7,16 @@ ZeroG Terminal is an alpha project, but it is already useful as a multi-session
7
7
  ## Features
8
8
 
9
9
  - Multi-pane workspaces with stack, vertical split, horizontal split, and four-pane grid layouts.
10
- - Draggable dividers between panes and beside the sidebar, so a split does not have to be an even one. Sizes are remembered between launches.
10
+ - Draggable dividers between panes and beside the sidebar, so a split does not have to be an even one. Sizes are remembered between launches.
11
+ - A compact navigation rail for sessions, overview, settings, SSH connections, and opening a new local terminal, while keeping the sessions sidebar collapsible.
12
+ - Layout controls that restore a split, even up its pane sizes, and maximize or restore a pane in a predictable sequence.
11
13
  - Maximize a focused pane and cycle between sessions without losing the other panes.
12
14
  - 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.
13
15
  - SSH sessions for hosts, `user@host`, and `user@host:port` targets.
14
16
  - SSH configuration discovery from known connections, including remote `screen` session discovery.
15
17
  - Reconnect to existing local or remote `screen` sessions from the Screens view.
16
18
  - Remote screen attachment that waits for SSH readiness before sending commands, including host and port-aware matching.
19
+ - 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.
17
20
  - Session history for reconnecting to sessions after a relaunch, with bounded structured history and no stored secrets.
18
21
  - Workspaces for grouping sessions and quickly switching between projects or tasks.
19
22
  - Session overview, collapsible sidebar, keyboard shortcuts, and light/dark themes.
@@ -31,7 +34,7 @@ See the project walkthrough on [YouTube](https://youtu.be/4aJZCxLHD14).
31
34
 
32
35
  ## Release status
33
36
 
34
- ZeroG Terminal is currently a public alpha. The current release is `0.5.0-alpha.1`; the version history is tracked in [versions.txt](versions.txt).
37
+ ZeroG Terminal is currently a public alpha. The current release is `0.7.0-alpha2`; the version history is tracked in [versions.txt](versions.txt).
35
38
 
36
39
  The npm package contains the built Electron application and project documentation. It is intended for early adopters and testing rather than production use.
37
40
 
@@ -47,6 +50,13 @@ The npm package contains the built Electron application and project documentatio
47
50
  - `Ctrl+Shift+,` — settings
48
51
  - `Esc` — close overview / dialogs, cancel voice recording
49
52
 
53
+ Clicking a link in a pane opens it in your own browser rather than in a window
54
+ of ZeroG. Hovering one first shows where it actually goes in the status bar,
55
+ which matters because a terminal hyperlink can be labelled with anything. Only
56
+ web links and `mailto:` addresses are opened; anything else says so in the
57
+ status bar instead, since a link in terminal output can name a scheme that would
58
+ start local software.
59
+
50
60
  Selecting text with the mouse also copies it, and programs running inside a
51
61
  terminal can copy to the system clipboard themselves through the OSC 52 escape
52
62
  sequence — this is how TUI tools such as CLI coding agents, tmux and Neovim put
@@ -64,6 +74,37 @@ a time, and Enter or a double-click puts it back in the middle. One divider
64
74
  position is shared by every layout, so a split you set up in the vertical split
65
75
  is the same split you get in the four-pane grid.
66
76
 
77
+ ## Transferring files over SFTP
78
+
79
+ The ⇅ button above the panes opens a two-pane transfer panel: this computer on
80
+ the left, the active SSH session's host on the right. It is only available while
81
+ an SSH session is selected, and the button says why when it is not.
82
+
83
+ The connection is made with the system `sftp` client, so it uses the same
84
+ `~/.ssh/config`, agent, keys, and `known_hosts` as the terminal beside it. A
85
+ password, a key passphrase, or an unknown host key is asked for inside the
86
+ panel — ZeroG never answers a host-key question on your behalf, and the
87
+ fingerprint is shown with the question. Nothing typed there is stored.
88
+
89
+ The remote side opens at the directory the terminal's shell is currently in,
90
+ where that can be known without disturbing the session. ZeroG reads it from
91
+ OSC 7 — the sequence a shell emits to report its directory — and otherwise from
92
+ the path in the prompt; it never types `pwd` into your session to find out. When
93
+ neither is available the panel opens at the login directory.
94
+
95
+ Select files with a click, or several with Ctrl-click or Shift-click, then Upload or Download.
96
+ Double-click a folder to open it, or type a path into the folder box. New folder,
97
+ rename, and delete act on one selected item; deleting asks first, and a folder
98
+ must be empty, so a single click can never remove a tree. Remote folders can be
99
+ downloaded recursively with the system `sftp` client; uploads remain file-only,
100
+ so a local folder is never copied accidentally as a different job.
101
+
102
+ Filenames containing quotes, backslashes, or the wildcard characters `* ? [ ]`
103
+ are refused with a message rather than acted on. The `sftp` client re-reads its
104
+ own arguments through a glob pass, and there is no encoding of those characters
105
+ that is provably correct for every command — being approximately right about
106
+ which file to delete is not good enough.
107
+
67
108
  ## Settings
68
109
 
69
110
  Settings open from the gear at the bottom of the left rail, the avatar in the
@@ -180,7 +221,7 @@ backend, and a remote host with `screen` still gives persistent sessions there.
180
221
  The current main branch has the following local verification coverage:
181
222
 
182
223
  - `npm run typecheck`: passes.
183
- - `npm test`: passes (167 tests covering session service behaviour and PTY sizing, shell discovery, SSH inventory and argument validation, remote-screen parsing and prompt readiness, session history, the session dialog, settings, terminal clipboard and OSC 52 handling, dialog dismissal, and the speech and voice helpers; one further test needs a real `screen` and is opt-in through `ZEROG_LIVE_SCREEN=1`).
224
+ - `npm test`: passes (251 tests covering session service behaviour and PTY sizing, shell discovery, SSH inventory and argument validation, remote-screen parsing and prompt readiness, session history, the session dialog, settings, terminal clipboard and OSC 52 handling, dialog dismissal, the speech and voice helpers, external link handling, and the SFTP transfer path — command quoting, listing and error parsing, authentication prompts, local filesystem operations, and working-directory detection; one further test needs a real `screen` and is opt-in through `ZEROG_LIVE_SCREEN=1`).
184
225
  - `npm run build`: passes and writes `dist/main` plus `dist/renderer`.
185
226
  - `npm audit --omit=dev`: production dependency auditing is part of the project quality checks.
186
227
 
package/bin/zerogterm.cjs CHANGED
@@ -1,23 +1,23 @@
1
- #!/usr/bin/env node
2
-
3
- const { spawn } = require('node:child_process');
4
- const path = require('node:path');
5
- const electron = require('electron');
6
-
7
- const packageRoot = path.resolve(__dirname, '..');
8
- const child = spawn(electron, [packageRoot, ...process.argv.slice(2)], {
9
- stdio: 'inherit'
10
- });
11
-
12
- child.on('error', (error) => {
13
- console.error(`Failed to start ZeroG Terminal: ${error.message}`);
14
- process.exitCode = 1;
15
- });
16
-
17
- child.on('close', (code, signal) => {
18
- if (signal) {
19
- process.kill(process.pid, signal);
20
- return;
21
- }
22
- process.exitCode = code ?? 1;
23
- });
1
+ #!/usr/bin/env node
2
+
3
+ const { spawn } = require('node:child_process');
4
+ const path = require('node:path');
5
+ const electron = require('electron');
6
+
7
+ const packageRoot = path.resolve(__dirname, '..');
8
+ const child = spawn(electron, [packageRoot, ...process.argv.slice(2)], {
9
+ stdio: 'inherit'
10
+ });
11
+
12
+ child.on('error', (error) => {
13
+ console.error(`Failed to start ZeroG Terminal: ${error.message}`);
14
+ process.exitCode = 1;
15
+ });
16
+
17
+ child.on('close', (code, signal) => {
18
+ if (signal) {
19
+ process.kill(process.pid, signal);
20
+ return;
21
+ }
22
+ process.exitCode = code ?? 1;
23
+ });
@@ -0,0 +1,79 @@
1
+ // Deciding which links may leave the application, and by which door.
2
+ //
3
+ // A link in a pane is not something the user wrote: it arrives in terminal
4
+ // output, which SECURITY.md is explicit about treating as untrusted. Worse, OSC 8
5
+ // lets a remote host display one thing and link to another, so the URL handed
6
+ // over here has no relationship to the text the user clicked on.
7
+ //
8
+ // The operating system will do a great deal with a URL. `file:` reaches the local
9
+ // disk; on Windows `smb:` reaches a network share and leaks credentials to it;
10
+ // and installed applications register their own schemes, some of which take a
11
+ // path or a command. Handing any of those to the shell on a single click would
12
+ // make a remote host's output into a way of starting local software. So this is
13
+ // an allowlist, not a blocklist: the schemes a person means when they click a
14
+ // link in a terminal, and nothing else.
15
+ //
16
+ // Kept pure and free of Electron so the rule can be tested directly.
17
+ /** Schemes worth opening: the web, and an address to write to. */
18
+ const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
19
+ /**
20
+ * A URL is only long enough to be suspicious once it is absurd. The limit is
21
+ * here because the string is passed to the operating system, not because a real
22
+ * link is ever near it.
23
+ */
24
+ const MAX_URL = 2048;
25
+ /** Written as escapes: a literal control character in source is invisible. */
26
+ const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/;
27
+ /**
28
+ * Should this link be handed to the system browser?
29
+ *
30
+ * The URL is re-parsed rather than pattern-matched: a scheme is only what the
31
+ * parser says it is, and `java\tscript:` or a leading space are not the checks a
32
+ * regex tends to get right. The parsed form is what gets returned, so the value
33
+ * that reaches the shell is the one that was inspected.
34
+ */
35
+ export function decideExternalLink(value) {
36
+ if (typeof value !== 'string' || !value.trim()) {
37
+ return { open: false, reason: 'That link is empty.' };
38
+ }
39
+ if (value.length > MAX_URL) {
40
+ return { open: false, reason: 'That link is too long to open.' };
41
+ }
42
+ // A control character cannot appear in a URL, and its presence means the value
43
+ // was assembled to be read one way and used another.
44
+ if (CONTROL_CHARACTER.test(value)) {
45
+ return { open: false, reason: 'That link contains control characters.' };
46
+ }
47
+ let parsed;
48
+ try {
49
+ parsed = new URL(value);
50
+ }
51
+ catch {
52
+ return { open: false, reason: 'That is not a link this can open.' };
53
+ }
54
+ if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {
55
+ // Name the scheme: the user clicked something that looked like a link, and
56
+ // "refused" without saying why is not enough to judge what just happened.
57
+ return { open: false, reason: `ZeroG only opens web links; this one is ${parsed.protocol.replace(':', '')}.` };
58
+ }
59
+ return { open: true, url: parsed.href };
60
+ }
61
+ /**
62
+ * Is this URL the application's own window content?
63
+ *
64
+ * Used to tell a genuine outward link from the app navigating itself — the dev
65
+ * server and the packaged `file:` index are the only two things the window is
66
+ * ever meant to be showing.
67
+ */
68
+ export function isApplicationUrl(value, current) {
69
+ if (value === current)
70
+ return true;
71
+ try {
72
+ const target = new URL(value);
73
+ const app = new URL(current);
74
+ return target.origin === app.origin && target.pathname === app.pathname;
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
@@ -0,0 +1,115 @@
1
+ // The local half of the transfer panel: directory listings and the three edits
2
+ // the panel can make locally.
3
+ //
4
+ // Nothing here reads file *contents*. The renderer is sandboxed and has no
5
+ // filesystem access of its own, and this module is deliberately the narrowest
6
+ // widening of that boundary that a file browser needs: names, sizes, kinds, and
7
+ // explicit create/rename/delete on a path the user pointed at.
8
+ import { constants } from 'node:fs';
9
+ import { access, lstat, mkdir, readdir, rename, rm, rmdir } from 'node:fs/promises';
10
+ import { homedir } from 'node:os';
11
+ import { isAbsolute, join, normalize, resolve } from 'node:path';
12
+ import { sortEntries } from '../shared/files.js';
13
+ /**
14
+ * A path this process will act on.
15
+ *
16
+ * Paths arrive from the renderer, so they are re-derived here rather than
17
+ * trusted: NUL terminates the path for the C library underneath and can make a
18
+ * check and the later operation disagree about which file is meant, and a
19
+ * relative path would resolve against whatever directory the app happens to
20
+ * have — never something the user chose.
21
+ */
22
+ export function resolveLocalPath(input) {
23
+ if (typeof input !== 'string' || !input)
24
+ throw new Error('A filesystem path is required.');
25
+ if (input.includes('\0'))
26
+ throw new Error('That path is not valid.');
27
+ if (input.length > 4096)
28
+ throw new Error('That path is too long.');
29
+ const expanded = input === '~' || input.startsWith('~/') || input.startsWith('~\\')
30
+ ? join(homedir(), input.slice(1))
31
+ : input;
32
+ if (!isAbsolute(expanded))
33
+ throw new Error('Only absolute paths can be browsed.');
34
+ return resolve(normalize(expanded));
35
+ }
36
+ export function localHome() {
37
+ return homedir();
38
+ }
39
+ /**
40
+ * List a directory.
41
+ *
42
+ * A stat per entry is what makes kind and size real, and it is allowed to fail:
43
+ * a broken symlink or a file that has just been deleted should leave a row in
44
+ * the listing rather than emptying the pane.
45
+ *
46
+ * Entries are stat'd with lstat, so a symlink is reported as a symlink whatever
47
+ * it points at. Reporting a link to a directory as a directory instead would be
48
+ * a lie with consequences: deleting it would use `rmdir`, which refuses a
49
+ * symlink, so the row could not be removed at all. It also matches what the
50
+ * remote side shows, since `ls -l` describes the link rather than its target.
51
+ * Entering one still works — see isNavigable — because opening it simply reads
52
+ * through, and a link to a file reports that it is not a directory.
53
+ */
54
+ export async function listLocalDirectory(path) {
55
+ const target = path ? resolveLocalPath(path) : homedir();
56
+ await access(target, constants.R_OK);
57
+ const dirents = await readdir(target, { withFileTypes: true });
58
+ const entries = await Promise.all(dirents.map(async (dirent) => {
59
+ const full = join(target, dirent.name);
60
+ try {
61
+ const info = await lstat(full);
62
+ return {
63
+ name: dirent.name,
64
+ kind: info.isSymbolicLink() ? 'symlink' : info.isDirectory() ? 'directory' : 'file',
65
+ size: info.size,
66
+ modified: info.mtime.toISOString()
67
+ };
68
+ }
69
+ catch {
70
+ return { name: dirent.name, kind: dirent.isSymbolicLink() ? 'symlink' : dirent.isDirectory() ? 'directory' : 'file', size: 0 };
71
+ }
72
+ }));
73
+ return { path: target, entries: sortEntries(entries) };
74
+ }
75
+ export async function createLocalDirectory(path) {
76
+ // No recursive create: the panel creates one folder in the directory on
77
+ // screen, and a mistyped path should fail rather than build a tree.
78
+ await mkdir(resolveLocalPath(path));
79
+ }
80
+ /**
81
+ * Rename an entry, refusing to land on a name that is already taken.
82
+ *
83
+ * rename() would silently replace an existing file, and the panel's rename is a
84
+ * relabel rather than an overwrite. The check is not atomic: nothing in Node's
85
+ * API offers a no-clobber rename on both platforms, so a different process that
86
+ * creates that name in the moment between the check and the rename would still
87
+ * be overwritten. That is a guard against the user's own mistake — the name is
88
+ * already in the folder they are looking at — not a guarantee against another
89
+ * writer, and anything able to win that race could equally overwrite the file
90
+ * directly.
91
+ */
92
+ export async function renameLocalEntry(from, to) {
93
+ const source = resolveLocalPath(from);
94
+ const destination = resolveLocalPath(to);
95
+ const clash = await lstat(destination).then(() => true, () => false);
96
+ if (clash)
97
+ throw new Error('Something with that name already exists here.');
98
+ await rename(source, destination);
99
+ }
100
+ /**
101
+ * Delete one entry.
102
+ *
103
+ * Directories use rmdir, so a non-empty one fails: this panel deletes what the
104
+ * user pointed at, and a recursive local delete triggered by a single click is
105
+ * not a mistake worth making possible.
106
+ */
107
+ export async function removeLocalEntry(path, kind) {
108
+ const target = resolveLocalPath(path);
109
+ if (target === resolve(homedir()))
110
+ throw new Error('Refusing to delete the home directory.');
111
+ if (kind === 'directory')
112
+ await rmdir(target);
113
+ else
114
+ await rm(target, { force: false });
115
+ }
@@ -1,4 +1,4 @@
1
- import { app, BrowserWindow, clipboard, ipcMain, Menu, session } from 'electron';
1
+ import { app, BrowserWindow, clipboard, ipcMain, Menu, session, shell } from 'electron';
2
2
  import { join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { writeClipboardText } from './clipboard.js';
@@ -6,10 +6,16 @@ import { ScreenService, parseWslDistributions } from './session-service.js';
6
6
  import { discoverShellBackends } from './shell-catalog.js';
7
7
  import { SessionHistoryStore, defaultHistoryPath } from './session-history.js';
8
8
  import { buildRemoteScreenAttachArgs, buildRemoteScreenDiscoveryArgs, listKnownConnections, parseRemoteScreenList, validateKnownConnection } from './ssh-inventory.js';
9
+ import { createLocalDirectory, listLocalDirectory, localHome, removeLocalEntry, renameLocalEntry } from './local-fs.js';
10
+ import { decideExternalLink, isApplicationUrl } from './external-links.js';
11
+ import { SftpService } from './sftp-service.js';
9
12
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
10
13
  const history = new SessionHistoryStore({ filePath: defaultHistoryPath(app.getPath('userData')) });
11
14
  const service = new ScreenService({ onEvent: (event, session, available) => { void history.record(event, session, available); } });
12
15
  let win;
16
+ // Transfer connections outlive any single panel opening, so the panel can be
17
+ // closed and reopened without re-authenticating to the host.
18
+ const sftp = new SftpService({ onEvent: (event) => win?.webContents.send('sftp:event', event) });
13
19
  /** A pane's measured size, as it arrives from the renderer. */
14
20
  function parsePtySize(value) {
15
21
  if (!value || typeof value !== 'object')
@@ -41,6 +47,29 @@ function createWindow() {
41
47
  sandbox: true
42
48
  }
43
49
  });
50
+ // A link in a pane belongs in the user's browser, not in a window of this app.
51
+ //
52
+ // Electron's default answer to window.open is a new BrowserWindow, and xterm
53
+ // activates an OSC 8 hyperlink by calling exactly that — so clicking a link in
54
+ // a terminal opened a bare Electron window with no address bar, no profile and
55
+ // no extensions, which is nobody's browser. Both handlers below refuse to
56
+ // navigate and hand the URL to the desktop instead.
57
+ //
58
+ // They are also the backstop for the renderer's own link handling: whatever
59
+ // asks for a window here, from any code path now or later, cannot get one.
60
+ win.webContents.setWindowOpenHandler(({ url }) => {
61
+ openExternalLink(url);
62
+ return { action: 'deny' };
63
+ });
64
+ // The same for a link that would replace the workspace with a web page, which
65
+ // would take every pane down with it.
66
+ const contents = win.webContents;
67
+ contents.on('will-navigate', (event, url) => {
68
+ if (isApplicationUrl(url, contents.getURL()))
69
+ return;
70
+ event.preventDefault();
71
+ openExternalLink(url);
72
+ });
44
73
  win.webContents.on('did-fail-load', (_event, code, desc, url) => {
45
74
  console.error('[zerog] did-fail-load', { code, desc, url });
46
75
  });
@@ -61,11 +90,41 @@ function createWindow() {
61
90
  win.on('closed', () => {
62
91
  win = undefined;
63
92
  service.detachAll();
93
+ sftp.closeAll();
64
94
  });
65
95
  }
66
96
  function isRecord(value) {
67
97
  return typeof value === 'object' && value !== null;
68
98
  }
99
+ /**
100
+ * Hand a link to the desktop, if it is one this should open.
101
+ *
102
+ * The decision is in external-links.ts and is an allowlist of schemes: the URL
103
+ * comes from terminal output, and the operating system will do a great deal more
104
+ * with `file:` or an application's own scheme than open a web page. A refusal is
105
+ * reported in the status bar rather than silently dropped, so a click that does
106
+ * nothing still says why.
107
+ */
108
+ function openExternalLink(url) {
109
+ const decision = decideExternalLink(url);
110
+ if (!decision.open) {
111
+ win?.webContents.send('links:refused', decision.reason);
112
+ console.warn('[zerog] refused to open link', { url, reason: decision.reason });
113
+ return;
114
+ }
115
+ void shell.openExternal(decision.url).catch((error) => {
116
+ win?.webContents.send('links:refused', 'That link could not be opened.');
117
+ console.error('[zerog] openExternal failed', error);
118
+ });
119
+ }
120
+ ipcMain.handle('links:openExternal', (_event, url) => {
121
+ const decision = decideExternalLink(url);
122
+ // Rejecting rather than resolving quietly: the renderer puts the reason in the
123
+ // status bar, so the user is never told nothing at all.
124
+ if (!decision.open)
125
+ throw new Error(decision.reason);
126
+ return shell.openExternal(decision.url);
127
+ });
69
128
  ipcMain.handle('sessions:list', () => service.list());
70
129
  ipcMain.handle('sessions:history', () => history.list());
71
130
  ipcMain.handle('sessions:historyRemove', (_event, entryId) => history.remove(entryId));
@@ -146,6 +205,37 @@ ipcMain.handle('clipboard:writeText', (_event, text) => {
146
205
  }
147
206
  });
148
207
  ipcMain.handle('clipboard:readText', () => clipboard.readText());
208
+ /** A string that crossed the IPC boundary and is about to be used as one. */
209
+ function requireString(value, field) {
210
+ if (typeof value !== 'string' || !value)
211
+ throw new Error(`${field} is required.`);
212
+ return value;
213
+ }
214
+ function requireEntryKind(value) {
215
+ if (value === 'file' || value === 'directory' || value === 'symlink')
216
+ return value;
217
+ throw new Error('An entry kind of file, directory, or symlink is required.');
218
+ }
219
+ ipcMain.handle('fs:localHome', () => localHome());
220
+ ipcMain.handle('fs:listLocal', (_event, path) => listLocalDirectory(typeof path === 'string' && path ? path : undefined));
221
+ ipcMain.handle('fs:mkdirLocal', (_event, path) => createLocalDirectory(requireString(path, 'A folder path')));
222
+ ipcMain.handle('fs:renameLocal', (_event, from, to) => renameLocalEntry(requireString(from, 'The current path'), requireString(to, 'The new path')));
223
+ ipcMain.handle('fs:removeLocal', (_event, path, kind) => removeLocalEntry(requireString(path, 'A path'), requireEntryKind(kind)));
224
+ ipcMain.handle('sftp:open', (_event, target, cwd) => sftp.open(requireString(target, 'An SSH target'), typeof cwd === 'string' && cwd ? cwd : undefined));
225
+ ipcMain.handle('sftp:list', (_event, id, path) => sftp.list(requireString(id, 'A transfer connection'), typeof path === 'string' && path ? path : undefined));
226
+ ipcMain.handle('sftp:mkdir', (_event, id, path) => sftp.mkdir(requireString(id, 'A transfer connection'), requireString(path, 'A folder path')));
227
+ ipcMain.handle('sftp:rename', (_event, id, from, to) => sftp.rename(requireString(id, 'A transfer connection'), requireString(from, 'The current path'), requireString(to, 'The new path')));
228
+ ipcMain.handle('sftp:remove', (_event, id, path, kind) => sftp.remove(requireString(id, 'A transfer connection'), requireString(path, 'A path'), requireEntryKind(kind)));
229
+ ipcMain.handle('sftp:upload', (_event, id, localPath, remoteDir) => sftp.upload(requireString(id, 'A transfer connection'), requireString(localPath, 'A local file'), requireString(remoteDir, 'A remote folder')));
230
+ ipcMain.handle('sftp:download', (_event, id, remotePath, localDir, kind) => sftp.download(requireString(id, 'A transfer connection'), requireString(remotePath, 'A remote path'), requireString(localDir, 'A local folder'), requireEntryKind(kind)));
231
+ // The answer is a secret in two of the three cases, so it is passed straight
232
+ // through and never returned, logged, or kept.
233
+ ipcMain.handle('sftp:answerPrompt', (_event, id, answer) => {
234
+ if (typeof answer !== 'string')
235
+ throw new Error('An answer is required.');
236
+ sftp.answerPrompt(requireString(id, 'A transfer connection'), answer);
237
+ });
238
+ ipcMain.handle('sftp:close', (_event, id) => sftp.close(requireString(id, 'A transfer connection')));
149
239
  ipcMain.handle('ai:suggest', () => ({
150
240
  command: 'git status --short',
151
241
  explanation: 'Read-only preview of changed files in the active workspace.'
@@ -27,7 +27,32 @@ const api = {
27
27
  ipcRenderer.on('terminal:status', listener);
28
28
  return () => ipcRenderer.removeListener('terminal:status', listener);
29
29
  },
30
- requestAiCommand: () => ipcRenderer.invoke('ai:suggest')
30
+ requestAiCommand: () => ipcRenderer.invoke('ai:suggest'),
31
+ listLocalDirectory: (path) => ipcRenderer.invoke('fs:listLocal', path),
32
+ localHome: () => ipcRenderer.invoke('fs:localHome'),
33
+ createLocalDirectory: (path) => ipcRenderer.invoke('fs:mkdirLocal', path),
34
+ renameLocalEntry: (from, to) => ipcRenderer.invoke('fs:renameLocal', from, to),
35
+ removeLocalEntry: (path, kind) => ipcRenderer.invoke('fs:removeLocal', path, kind),
36
+ sftpOpen: (target, cwd) => ipcRenderer.invoke('sftp:open', target, cwd),
37
+ sftpList: (sessionId, path) => ipcRenderer.invoke('sftp:list', sessionId, path),
38
+ sftpMkdir: (sessionId, path) => ipcRenderer.invoke('sftp:mkdir', sessionId, path),
39
+ sftpRename: (sessionId, from, to) => ipcRenderer.invoke('sftp:rename', sessionId, from, to),
40
+ sftpRemove: (sessionId, path, kind) => ipcRenderer.invoke('sftp:remove', sessionId, path, kind),
41
+ sftpUpload: (sessionId, localPath, remoteDir) => ipcRenderer.invoke('sftp:upload', sessionId, localPath, remoteDir),
42
+ sftpDownload: (sessionId, remotePath, localDir, kind) => ipcRenderer.invoke('sftp:download', sessionId, remotePath, localDir, kind),
43
+ sftpAnswerPrompt: (sessionId, answer) => ipcRenderer.invoke('sftp:answerPrompt', sessionId, answer),
44
+ sftpClose: (sessionId) => ipcRenderer.invoke('sftp:close', sessionId),
45
+ onSftpEvent: (callback) => {
46
+ const listener = (_event, payload) => callback(payload);
47
+ ipcRenderer.on('sftp:event', listener);
48
+ return () => ipcRenderer.removeListener('sftp:event', listener);
49
+ },
50
+ openExternal: (url) => ipcRenderer.invoke('links:openExternal', url),
51
+ onLinkRefused: (callback) => {
52
+ const listener = (_event, reason) => callback(reason);
53
+ ipcRenderer.on('links:refused', listener);
54
+ return () => ipcRenderer.removeListener('links:refused', listener);
55
+ }
31
56
  };
32
57
 
33
58
  contextBridge.exposeInMainWorld('zerog', api);
@@ -1,11 +1,9 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { existsSync } from 'node:fs';
4
3
  import { createRequire } from 'node:module';
5
4
  import { homedir } from 'node:os';
6
- import { join } from 'node:path';
7
5
  import { promisify } from 'node:util';
8
- import { defaultShellBackend, isLocalShellBackend, resolveShellBackend } from './shell-catalog.js';
6
+ import { defaultShellBackend, findOpenSshTool, isLocalShellBackend, resolveShellBackend } from './shell-catalog.js';
9
7
  const execFileAsync = promisify(execFile);
10
8
  const require = createRequire(import.meta.url);
11
9
  const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,48}$/;
@@ -16,17 +14,6 @@ const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,48}$/;
16
14
  * -chosen config file (hence ProxyCommand) without any shell involvement.
17
15
  */
18
16
  const SSH_TARGET = /^(?:([A-Za-z0-9][A-Za-z0-9._-]*)@)?([A-Za-z0-9][A-Za-z0-9.-]*)(?::(\d{1,5}))?$/;
19
- function resolveSshExecutable() {
20
- if (process.platform !== 'win32')
21
- return 'ssh';
22
- const windowsRoot = process.env.SystemRoot ?? 'C:\\Windows';
23
- const candidates = [
24
- join(windowsRoot, 'System32', 'OpenSSH', 'ssh.exe'),
25
- join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'usr', 'bin', 'ssh.exe'),
26
- join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Git', 'usr', 'bin', 'ssh.exe')
27
- ];
28
- return candidates.find((candidate) => existsSync(candidate)) ?? 'ssh.exe';
29
- }
30
17
  /**
31
18
  * Size a pty starts at when the pane could not be measured — a placeholder,
32
19
  * not a preference. Panes pass their real size through attach().
@@ -54,7 +41,13 @@ export function validateSessionName(name) {
54
41
  }
55
42
  return value;
56
43
  }
57
- export function validateSshTarget(input) {
44
+ /**
45
+ * Split an SSH target into the pieces a client needs, rejecting anything that
46
+ * is not one. Shared with the SFTP side (see sftp-protocol.ts) so that both
47
+ * clients are handed destinations vetted by the same rule — a target ssh would
48
+ * read as an option must not become an sftp option either.
49
+ */
50
+ export function parseSshTarget(input) {
58
51
  const value = input.trim();
59
52
  const match = value.match(SSH_TARGET);
60
53
  if (!match) {
@@ -67,13 +60,38 @@ export function validateSshTarget(input) {
67
60
  throw new Error('SSH port must be between 1 and 65535.');
68
61
  }
69
62
  }
70
- const destination = user ? `${user}@${host}` : host;
63
+ return {
64
+ target: value,
65
+ destination: user ? `${user}@${host}` : host,
66
+ ...(user ? { user } : {}),
67
+ host,
68
+ ...(portText ? { port: portText } : {})
69
+ };
70
+ }
71
+ export function validateSshTarget(input) {
72
+ const { target, destination, port } = parseSshTarget(input);
71
73
  const args = ['-tt'];
72
- if (portText)
73
- args.push('-p', portText);
74
+ if (port)
75
+ args.push('-p', port);
74
76
  // '--' ends option parsing, so the destination can never be read as a flag.
75
77
  args.push('--', destination);
76
- return { target: value, args };
78
+ return { target, args };
79
+ }
80
+ /**
81
+ * The ssh client, as a path a pty can actually start.
82
+ *
83
+ * node-pty passes the file straight to CreateProcess on Windows, which does not
84
+ * append `.exe` — so a pty asked for `ssh` fails with "File not found" on a
85
+ * machine that has it. Resolving it here also turns a missing client into a
86
+ * sentence rather than that message. Where OpenSSH is looked for lives in
87
+ * findOpenSshTool, shared with the transfer side's sftpExecutable.
88
+ */
89
+ export function sshExecutable(options = {}) {
90
+ const file = findOpenSshTool('ssh', options);
91
+ if (!file) {
92
+ throw new Error('The OpenSSH client was not found. Install the OpenSSH client tools to connect over SSH.');
93
+ }
94
+ return file;
77
95
  }
78
96
  export function parseWslDistributions(output) {
79
97
  return output.split(/\r?\n/).slice(1).map((line) => line.replace(/^\*?\s*/, '').trim())
@@ -253,7 +271,7 @@ export class ScreenService {
253
271
  session.status = 'connected';
254
272
  session.lastSeen = new Date().toISOString();
255
273
  try {
256
- this.spawnCommand(id, resolveSshExecutable(), args, onData, onExit, homedir(), size);
274
+ this.spawnCommand(id, sshExecutable(), args, onData, onExit, homedir(), size);
257
275
  }
258
276
  catch (error) {
259
277
  session.status = 'error';