zerogterm 0.5.0-alpha.1 → 0.7.0-alpha1

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.
@@ -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.'
@@ -1,33 +1,58 @@
1
- const { contextBridge, ipcRenderer } = require('electron');
2
-
3
- const api = {
4
- listSessions: () => ipcRenderer.invoke('sessions:list'),
5
- listHistory: () => ipcRenderer.invoke('sessions:history'),
6
- removeHistory: (entryId) => ipcRenderer.invoke('sessions:historyRemove', entryId),
7
- listBackends: () => ipcRenderer.invoke('sessions:backends'),
8
- listWslDistributions: () => ipcRenderer.invoke('sessions:wslDistributions'),
9
- createLocalSession: (request) => ipcRenderer.invoke('sessions:createLocal', request),
10
- createSshSession: (request) => ipcRenderer.invoke('sessions:createSsh', request),
11
- listKnownConnections: () => ipcRenderer.invoke('connections:listKnown'),
12
- discoverRemoteScreens: (connection) => ipcRenderer.invoke('screens:discoverRemote', connection),
13
- buildRemoteScreenAttach: (connection, screenName) => ipcRenderer.invoke('screens:attachRemote', connection, screenName),
14
- attachSession: (id, size) => ipcRenderer.invoke('sessions:attach', id, size),
15
- closeSession: (id) => ipcRenderer.invoke('sessions:close', id),
16
- write: (sessionId, data) => ipcRenderer.send('terminal:write', sessionId, data),
17
- resize: (sessionId, cols, rows) => ipcRenderer.send('terminal:resize', sessionId, cols, rows),
18
- copyText: (text) => ipcRenderer.invoke('clipboard:writeText', text),
19
- readText: () => ipcRenderer.invoke('clipboard:readText'),
20
- onData: (callback) => {
21
- const listener = (_event, sessionId, data) => callback(sessionId, data);
22
- ipcRenderer.on('terminal:data', listener);
23
- return () => ipcRenderer.removeListener('terminal:data', listener);
24
- },
25
- onStatus: (callback) => {
26
- const listener = (_event, sessionId, message) => callback(sessionId, message);
27
- ipcRenderer.on('terminal:status', listener);
28
- return () => ipcRenderer.removeListener('terminal:status', listener);
29
- },
30
- requestAiCommand: () => ipcRenderer.invoke('ai:suggest')
31
- };
32
-
33
- contextBridge.exposeInMainWorld('zerog', api);
1
+ const { contextBridge, ipcRenderer } = require('electron');
2
+
3
+ const api = {
4
+ listSessions: () => ipcRenderer.invoke('sessions:list'),
5
+ listHistory: () => ipcRenderer.invoke('sessions:history'),
6
+ removeHistory: (entryId) => ipcRenderer.invoke('sessions:historyRemove', entryId),
7
+ listBackends: () => ipcRenderer.invoke('sessions:backends'),
8
+ listWslDistributions: () => ipcRenderer.invoke('sessions:wslDistributions'),
9
+ createLocalSession: (request) => ipcRenderer.invoke('sessions:createLocal', request),
10
+ createSshSession: (request) => ipcRenderer.invoke('sessions:createSsh', request),
11
+ listKnownConnections: () => ipcRenderer.invoke('connections:listKnown'),
12
+ discoverRemoteScreens: (connection) => ipcRenderer.invoke('screens:discoverRemote', connection),
13
+ buildRemoteScreenAttach: (connection, screenName) => ipcRenderer.invoke('screens:attachRemote', connection, screenName),
14
+ attachSession: (id, size) => ipcRenderer.invoke('sessions:attach', id, size),
15
+ closeSession: (id) => ipcRenderer.invoke('sessions:close', id),
16
+ write: (sessionId, data) => ipcRenderer.send('terminal:write', sessionId, data),
17
+ resize: (sessionId, cols, rows) => ipcRenderer.send('terminal:resize', sessionId, cols, rows),
18
+ copyText: (text) => ipcRenderer.invoke('clipboard:writeText', text),
19
+ readText: () => ipcRenderer.invoke('clipboard:readText'),
20
+ onData: (callback) => {
21
+ const listener = (_event, sessionId, data) => callback(sessionId, data);
22
+ ipcRenderer.on('terminal:data', listener);
23
+ return () => ipcRenderer.removeListener('terminal:data', listener);
24
+ },
25
+ onStatus: (callback) => {
26
+ const listener = (_event, sessionId, message) => callback(sessionId, message);
27
+ ipcRenderer.on('terminal:status', listener);
28
+ return () => ipcRenderer.removeListener('terminal:status', listener);
29
+ },
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
+ }
56
+ };
57
+
58
+ contextBridge.exposeInMainWorld('zerog', api);
@@ -3,7 +3,7 @@ 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
+ import { defaultShellBackend, findOpenSshTool, isLocalShellBackend, resolveShellBackend } from './shell-catalog.js';
7
7
  const execFileAsync = promisify(execFile);
8
8
  const require = createRequire(import.meta.url);
9
9
  const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,48}$/;
@@ -41,7 +41,13 @@ export function validateSessionName(name) {
41
41
  }
42
42
  return value;
43
43
  }
44
- 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) {
45
51
  const value = input.trim();
46
52
  const match = value.match(SSH_TARGET);
47
53
  if (!match) {
@@ -54,13 +60,38 @@ export function validateSshTarget(input) {
54
60
  throw new Error('SSH port must be between 1 and 65535.');
55
61
  }
56
62
  }
57
- 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);
58
73
  const args = ['-tt'];
59
- if (portText)
60
- args.push('-p', portText);
74
+ if (port)
75
+ args.push('-p', port);
61
76
  // '--' ends option parsing, so the destination can never be read as a flag.
62
77
  args.push('--', destination);
63
- 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;
64
95
  }
65
96
  export function parseWslDistributions(output) {
66
97
  return output.split(/\r?\n/).slice(1).map((line) => line.replace(/^\*?\s*/, '').trim())
@@ -240,7 +271,7 @@ export class ScreenService {
240
271
  session.status = 'connected';
241
272
  session.lastSeen = new Date().toISOString();
242
273
  try {
243
- this.spawnCommand(id, 'ssh', args, onData, onExit, homedir(), size);
274
+ this.spawnCommand(id, sshExecutable(), args, onData, onExit, homedir(), size);
244
275
  }
245
276
  catch (error) {
246
277
  session.status = 'error';