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 +21 -21
- package/README.md +44 -3
- package/bin/zerogterm.cjs +23 -23
- package/dist/main/main/external-links.js +79 -0
- package/dist/main/main/local-fs.js +115 -0
- package/dist/main/main/main.js +91 -1
- package/dist/main/main/preload.cjs +26 -1
- package/dist/main/main/session-service.js +38 -20
- package/dist/main/main/sftp-protocol.js +279 -0
- package/dist/main/main/sftp-service.js +465 -0
- package/dist/main/main/shell-catalog.js +45 -0
- package/dist/main/shared/ansi.js +34 -0
- package/dist/main/shared/files.js +88 -0
- package/dist/renderer/assets/index-BjXtNztF.css +1 -0
- package/dist/renderer/assets/index-Ogyjvi0e.js +19 -0
- package/dist/renderer/index.html +4 -4
- package/package.json +2 -2
- package/dist/renderer/assets/index-BJsoL2QL.js +0 -17
- package/dist/renderer/assets/index-BPfYUrvm.css +0 -1
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
// The stateful half of SFTP support: one live `sftp` client per remote host,
|
|
2
|
+
// with commands serialised onto it and its answers turned into data by
|
|
3
|
+
// sftp-protocol.ts.
|
|
4
|
+
//
|
|
5
|
+
// The client runs on a pty rather than on pipes. That is not incidental: with
|
|
6
|
+
// pipes, OpenSSH cannot ask for a key passphrase, a password, or a decision
|
|
7
|
+
// about an unknown host key — it either finds an agent identity or fails — and
|
|
8
|
+
// it prints no transfer progress. On a pty all three become possible, at the
|
|
9
|
+
// cost of having to read output written for a person.
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { baseName, joinRemote, sortEntries } from '../shared/files.js';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
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
|
+
/**
|
|
17
|
+
* Wide enough that a long `ls -l` record is never wrapped by the pty, which
|
|
18
|
+
* would split one file's row across two lines and lose its name. Rows are
|
|
19
|
+
* nominal: nothing here is drawn.
|
|
20
|
+
*/
|
|
21
|
+
const PTY_COLS = 512;
|
|
22
|
+
const PTY_ROWS = 24;
|
|
23
|
+
/** How long the client may go silent before a command is given up on. */
|
|
24
|
+
const IDLE_MS = 25_000;
|
|
25
|
+
/**
|
|
26
|
+
* The same limit for transfers, which are silent only if they have stalled:
|
|
27
|
+
* the progress meter reprints about once a second while bytes are moving, and
|
|
28
|
+
* every frame of it resets the clock.
|
|
29
|
+
*/
|
|
30
|
+
const TRANSFER_IDLE_MS = 60_000;
|
|
31
|
+
/** Opening waits longer, because it may be waiting on a person to type. */
|
|
32
|
+
const OPEN_IDLE_MS = 120_000;
|
|
33
|
+
/**
|
|
34
|
+
* How long the client may say *nothing at all* before opening is given up on.
|
|
35
|
+
*
|
|
36
|
+
* Separate from the idle limit above, which exists to allow for a person
|
|
37
|
+
* typing a password. Silence before a single byte has arrived is a different
|
|
38
|
+
* situation: the client has not reached the host, or is stuck before it can
|
|
39
|
+
* ask anything, and two minutes of a spinner teaches the user nothing.
|
|
40
|
+
* ConnectTimeout is 20s, so this only fires when even that did not report.
|
|
41
|
+
*/
|
|
42
|
+
const FIRST_BYTE_MS = 35_000;
|
|
43
|
+
/**
|
|
44
|
+
* How long a terminator probe waits before trying the other one.
|
|
45
|
+
*
|
|
46
|
+
* Long enough for a real host across a network to answer `pwd`, short enough
|
|
47
|
+
* that trying both is not felt as a hang.
|
|
48
|
+
*/
|
|
49
|
+
const PROBE_MS = 5_000;
|
|
50
|
+
/**
|
|
51
|
+
* The two ways to end a line, in the order they are tried.
|
|
52
|
+
*
|
|
53
|
+
* Which one submits a command depends on whether the client has its interactive
|
|
54
|
+
* line editor running, and that is not knowable in advance:
|
|
55
|
+
*
|
|
56
|
+
* - With no editor the client reads lines itself and a newline ends one, while a
|
|
57
|
+
* carriage return does nothing. Verified against the native Windows client
|
|
58
|
+
* driven with `-D`.
|
|
59
|
+
* - With the editor running — which is the case on a real connection, and shows
|
|
60
|
+
* itself by echoing what was typed — the editor accepts the line on carriage
|
|
61
|
+
* return, the key an Enter press actually sends. A newline is inserted rather
|
|
62
|
+
* than accepted, which looks exactly like the client having gone silent.
|
|
63
|
+
*
|
|
64
|
+
* So the connection asks. Hardcoding either one breaks the other, and guessing
|
|
65
|
+
* from the platform gets it wrong for the same client used two ways.
|
|
66
|
+
*/
|
|
67
|
+
const TERMINATORS = ['\n', '\r'];
|
|
68
|
+
/** A ceiling on live connections, so a stuck panel cannot spawn clients forever. */
|
|
69
|
+
const MAX_SESSIONS = 6;
|
|
70
|
+
export class SftpService {
|
|
71
|
+
connections = new Map();
|
|
72
|
+
spawnPty;
|
|
73
|
+
onEvent;
|
|
74
|
+
constructor(options = {}) {
|
|
75
|
+
this.spawnPty = options.spawnPty ?? ((file, args, ptyOptions) => require('node-pty').spawn(file, args, ptyOptions));
|
|
76
|
+
this.onEvent = options.onEvent ?? (() => undefined);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Connect to a host and settle on a starting directory.
|
|
80
|
+
*
|
|
81
|
+
* Resolves only once the client is ready for commands, which may be after the
|
|
82
|
+
* user has answered a password or host-key question — those arrive as events
|
|
83
|
+
* while this promise is still pending, and are answered with answerPrompt().
|
|
84
|
+
*/
|
|
85
|
+
async open(target, cwd) {
|
|
86
|
+
// A connection to this host is worth reusing: authenticating again — with a
|
|
87
|
+
// password or a key passphrase — is a real cost to pay for closing a panel
|
|
88
|
+
// and reopening it. Connections are dropped when the window closes.
|
|
89
|
+
const reusable = this.connections.get(this.findByTarget(target)?.id ?? '');
|
|
90
|
+
if (reusable) {
|
|
91
|
+
if (cwd && cwd !== '~' && isSafeRemotePath(cwd) && cwd !== reusable.cwd) {
|
|
92
|
+
try {
|
|
93
|
+
reusable.cwd = await this.enqueue(reusable, () => this.changeDirectory(reusable, cwd));
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
this.onEvent({ type: 'status', sessionId: reusable.id, message: `${cwd} is not reachable; staying in ${reusable.cwd}.` });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { id: reusable.id, target: reusable.target, cwd: reusable.cwd };
|
|
100
|
+
}
|
|
101
|
+
if (this.connections.size >= MAX_SESSIONS) {
|
|
102
|
+
throw new Error('Too many open transfer connections. Close one and try again.');
|
|
103
|
+
}
|
|
104
|
+
const { file, args } = buildSftpArgs(target);
|
|
105
|
+
const id = `sftp:${randomUUID()}`;
|
|
106
|
+
// Logged because which client got picked is the first thing worth knowing
|
|
107
|
+
// when a connection does not work on someone else's machine.
|
|
108
|
+
console.log('[zerog] sftp open', { file, args });
|
|
109
|
+
const pty = this.spawnPty(file, args, {
|
|
110
|
+
name: 'xterm-256color',
|
|
111
|
+
cols: PTY_COLS,
|
|
112
|
+
rows: PTY_ROWS,
|
|
113
|
+
cwd: homedir(),
|
|
114
|
+
env: process.env
|
|
115
|
+
});
|
|
116
|
+
const connection = {
|
|
117
|
+
id,
|
|
118
|
+
target,
|
|
119
|
+
cwd: '.',
|
|
120
|
+
pty,
|
|
121
|
+
buffer: '',
|
|
122
|
+
waiter: null,
|
|
123
|
+
queue: Promise.resolve(),
|
|
124
|
+
terminator: TERMINATORS[0],
|
|
125
|
+
closed: false
|
|
126
|
+
};
|
|
127
|
+
this.connections.set(id, connection);
|
|
128
|
+
pty.onData((data) => this.receive(connection, data));
|
|
129
|
+
pty.onExit(() => this.handleExit(connection));
|
|
130
|
+
try {
|
|
131
|
+
await this.waitForPrompt(connection, '', OPEN_IDLE_MS, { firstByteMs: FIRST_BYTE_MS });
|
|
132
|
+
// Ask the server where "here" is before trusting any relative path: the
|
|
133
|
+
// panel shows this path, and the login directory is not always the home
|
|
134
|
+
// directory the local side would guess. This same question doubles as the
|
|
135
|
+
// probe for what ends a line, since it is the first one asked.
|
|
136
|
+
const home = await this.probeTerminator(connection);
|
|
137
|
+
connection.cwd = home;
|
|
138
|
+
if (cwd && cwd !== '~' && isSafeRemotePath(cwd)) {
|
|
139
|
+
try {
|
|
140
|
+
connection.cwd = await this.changeDirectory(connection, cwd);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
// A stale or unreadable terminal cwd must not cost the user the whole
|
|
144
|
+
// panel — start at the login directory and say why.
|
|
145
|
+
this.onEvent({ type: 'status', sessionId: id, message: `${cwd} is not reachable; starting in ${home}.` });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { id, target, cwd: connection.cwd };
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
this.destroy(connection);
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async list(sessionId, path) {
|
|
156
|
+
const connection = this.get(sessionId);
|
|
157
|
+
return this.enqueue(connection, async () => {
|
|
158
|
+
if (path)
|
|
159
|
+
connection.cwd = await this.changeDirectory(connection, path);
|
|
160
|
+
else
|
|
161
|
+
connection.cwd = await this.pwd(connection);
|
|
162
|
+
// `ls` with no path argument lists the working directory, which keeps
|
|
163
|
+
// every filename out of the glob expander. -a includes dotfiles, -n keeps
|
|
164
|
+
// owner columns numeric so a long user name cannot merge two columns.
|
|
165
|
+
const body = await this.command(connection, 'ls -lan', IDLE_MS);
|
|
166
|
+
const failure = findError(body);
|
|
167
|
+
if (failure)
|
|
168
|
+
throw new Error(failure);
|
|
169
|
+
return { path: connection.cwd, entries: sortEntries(parseListing(body)) };
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
async mkdir(sessionId, path) {
|
|
173
|
+
const connection = this.get(sessionId);
|
|
174
|
+
await this.enqueue(connection, () => this.checked(connection, `mkdir ${quoteRemotePath(path)}`, IDLE_MS));
|
|
175
|
+
}
|
|
176
|
+
async rename(sessionId, from, to) {
|
|
177
|
+
const connection = this.get(sessionId);
|
|
178
|
+
await this.enqueue(connection, () => this.checked(connection, `rename ${quoteRemotePath(from)} ${quoteRemotePath(to)}`, IDLE_MS));
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Delete one entry. Directories use `rmdir`, which fails on a non-empty one —
|
|
182
|
+
* a recursive remote delete is not something this panel should be able to do
|
|
183
|
+
* by accident, and the terminal is one keystroke away for the rest.
|
|
184
|
+
*/
|
|
185
|
+
async remove(sessionId, path, kind) {
|
|
186
|
+
const connection = this.get(sessionId);
|
|
187
|
+
const command = kind === 'directory' ? `rmdir ${quoteRemotePath(path)}` : `rm ${quoteRemotePath(path)}`;
|
|
188
|
+
await this.enqueue(connection, () => this.checked(connection, command, IDLE_MS));
|
|
189
|
+
}
|
|
190
|
+
async upload(sessionId, localPath, remoteDir) {
|
|
191
|
+
const connection = this.get(sessionId);
|
|
192
|
+
const name = baseName(localPath);
|
|
193
|
+
const destination = joinRemote(remoteDir, name);
|
|
194
|
+
// -p keeps the modification time, so an uploaded file does not look newer
|
|
195
|
+
// than the build that produced it.
|
|
196
|
+
const command = `put -p ${quoteLocalPath(localPath)} ${quoteRemotePath(destination)}`;
|
|
197
|
+
await this.enqueue(connection, () => this.checked(connection, command, TRANSFER_IDLE_MS, true));
|
|
198
|
+
}
|
|
199
|
+
async download(sessionId, remotePath, localDir, kind) {
|
|
200
|
+
const connection = this.get(sessionId);
|
|
201
|
+
const name = baseName(remotePath);
|
|
202
|
+
const destination = `${localDir.replace(/[\\/]+$/, '')}/${name}`;
|
|
203
|
+
const recursive = kind === 'directory' ? '-r ' : '';
|
|
204
|
+
const command = `get ${recursive}-p ${quoteRemotePath(remotePath)} ${quoteLocalPath(destination)}`;
|
|
205
|
+
await this.enqueue(connection, () => this.checked(connection, command, TRANSFER_IDLE_MS, true));
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Send an answer to a pending password, passphrase, or host-key question.
|
|
209
|
+
*
|
|
210
|
+
* Written straight to the pty and never stored, logged, or echoed back to the
|
|
211
|
+
* renderer. The pending command's own promise is what resolves afterwards.
|
|
212
|
+
*/
|
|
213
|
+
answerPrompt(sessionId, answer) {
|
|
214
|
+
const connection = this.get(sessionId);
|
|
215
|
+
if (/[\r\n]/.test(answer))
|
|
216
|
+
throw new Error('An answer must be a single line.');
|
|
217
|
+
// Forget which question was asked: a rejected password is followed by the
|
|
218
|
+
// very same prompt text, and the user has to be given the field again.
|
|
219
|
+
lastQuestion.delete(sessionId);
|
|
220
|
+
connection.pty.write(`${answer}${connection.terminator}`);
|
|
221
|
+
}
|
|
222
|
+
close(sessionId) {
|
|
223
|
+
const connection = this.connections.get(sessionId);
|
|
224
|
+
if (!connection)
|
|
225
|
+
return;
|
|
226
|
+
if (!connection.closed) {
|
|
227
|
+
try {
|
|
228
|
+
// Ask for a clean goodbye first: it flushes any half-written file on the
|
|
229
|
+
// server rather than leaving a truncated upload behind.
|
|
230
|
+
connection.pty.write(`bye${connection.terminator}`);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
/* the pty may already be gone */
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
setTimeout(() => this.destroy(connection), 250);
|
|
237
|
+
}
|
|
238
|
+
closeAll() {
|
|
239
|
+
for (const id of [...this.connections.keys()])
|
|
240
|
+
this.close(id);
|
|
241
|
+
}
|
|
242
|
+
/** Handles held for a target, so the panel can reuse one connection per host. */
|
|
243
|
+
findByTarget(target) {
|
|
244
|
+
for (const connection of this.connections.values()) {
|
|
245
|
+
if (connection.target === target && !connection.closed) {
|
|
246
|
+
return { id: connection.id, target: connection.target, cwd: connection.cwd };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
get(sessionId) {
|
|
252
|
+
const connection = this.connections.get(sessionId);
|
|
253
|
+
if (!connection || connection.closed)
|
|
254
|
+
throw new Error('This transfer connection is closed. Reopen the transfer panel.');
|
|
255
|
+
return connection;
|
|
256
|
+
}
|
|
257
|
+
/** Serialise work onto one connection: there is a single pty and one prompt. */
|
|
258
|
+
enqueue(connection, work) {
|
|
259
|
+
const result = connection.queue.then(work, work);
|
|
260
|
+
// The chain must survive a rejected step, or one failed command would poison
|
|
261
|
+
// every later one on the same connection.
|
|
262
|
+
connection.queue = result.catch(() => undefined);
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Settle on a line terminator by asking `pwd` until one of them answers.
|
|
267
|
+
*
|
|
268
|
+
* The reply is kept rather than thrown away: it is the login directory, which
|
|
269
|
+
* opening needs anyway. A terminator that does not submit produces silence, so
|
|
270
|
+
* each is given PROBE_MS before the next is tried.
|
|
271
|
+
*/
|
|
272
|
+
async probeTerminator(connection) {
|
|
273
|
+
for (const terminator of TERMINATORS) {
|
|
274
|
+
connection.terminator = terminator;
|
|
275
|
+
// Twice per terminator: a rejected attempt can leave the client holding a
|
|
276
|
+
// character that ends the next line early, answering it with nothing.
|
|
277
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
278
|
+
let body;
|
|
279
|
+
try {
|
|
280
|
+
body = await this.command(connection, 'pwd', PROBE_MS);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
const failure = findError(body);
|
|
286
|
+
// On the native Windows OpenSSH client, an LF is echoed into its
|
|
287
|
+
// interactive editor but does not submit the line. When the first CR
|
|
288
|
+
// is then sent, the editor may flush the buffered probe as `Invalid
|
|
289
|
+
// command.` before the following CR submits a clean one. That is a
|
|
290
|
+
// probe miss, not a remote SFTP failure; the next attempt is deliberate.
|
|
291
|
+
if (failure && !/^invalid command\.?$/i.test(failure))
|
|
292
|
+
throw new Error(failure);
|
|
293
|
+
const path = parsePwd(body);
|
|
294
|
+
if (path) {
|
|
295
|
+
console.log('[zerog] sftp line terminator', { target: connection.target, terminator: JSON.stringify(terminator) });
|
|
296
|
+
return path;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
throw new Error('The sftp client accepted neither line ending, so no command could be sent to it. '
|
|
301
|
+
+ 'Run `node scripts/sftp-doctor.mjs <host>` to see the exchange.');
|
|
302
|
+
}
|
|
303
|
+
async pwd(connection) {
|
|
304
|
+
const body = await this.command(connection, 'pwd', IDLE_MS);
|
|
305
|
+
const failure = findError(body);
|
|
306
|
+
if (failure)
|
|
307
|
+
throw new Error(failure);
|
|
308
|
+
const path = parsePwd(body);
|
|
309
|
+
if (!path)
|
|
310
|
+
throw new Error('The server did not report a working directory.');
|
|
311
|
+
return path;
|
|
312
|
+
}
|
|
313
|
+
async changeDirectory(connection, path) {
|
|
314
|
+
await this.checked(connection, `cd ${quoteRemotePath(path)}`, IDLE_MS);
|
|
315
|
+
return this.pwd(connection);
|
|
316
|
+
}
|
|
317
|
+
/** Run a command and turn anything that looks like a failure into a throw. */
|
|
318
|
+
async checked(connection, command, idleMs, reportProgress = false) {
|
|
319
|
+
const body = await this.command(connection, command, idleMs, reportProgress);
|
|
320
|
+
const failure = findError(body);
|
|
321
|
+
if (failure)
|
|
322
|
+
throw new Error(failure);
|
|
323
|
+
}
|
|
324
|
+
command(connection, command, idleMs, reportProgress = false) {
|
|
325
|
+
if (connection.closed)
|
|
326
|
+
return Promise.reject(new Error('This transfer connection is closed.'));
|
|
327
|
+
connection.buffer = '';
|
|
328
|
+
const pending = this.waitForPrompt(connection, command, idleMs, { reportProgress });
|
|
329
|
+
connection.pty.write(`${command}${connection.terminator}`);
|
|
330
|
+
return pending;
|
|
331
|
+
}
|
|
332
|
+
waitForPrompt(connection, command, idleMs, options = {}) {
|
|
333
|
+
return new Promise((resolve, reject) => {
|
|
334
|
+
if (connection.waiter) {
|
|
335
|
+
reject(new Error('A transfer command is already running on this connection.'));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const waiter = {
|
|
339
|
+
resolve,
|
|
340
|
+
reject,
|
|
341
|
+
idleMs,
|
|
342
|
+
command,
|
|
343
|
+
reportProgress: options.reportProgress ?? false,
|
|
344
|
+
// The first deadline may be shorter than the idle one; every later
|
|
345
|
+
// deadline is re-armed by receive() at idleMs.
|
|
346
|
+
timer: setTimeout(() => this.fail(connection, this.silenceError(connection)), options.firstByteMs ?? idleMs)
|
|
347
|
+
};
|
|
348
|
+
connection.waiter = waiter;
|
|
349
|
+
// Output that arrived between the write and this handler being installed
|
|
350
|
+
// is already in the buffer, so re-run the check rather than waiting for
|
|
351
|
+
// the next chunk that may never come.
|
|
352
|
+
this.settle(connection);
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
receive(connection, data) {
|
|
356
|
+
// Nothing is being waited for, so nothing arriving now answers a command.
|
|
357
|
+
// Dropping it is what keeps request and response paired: a repainted prompt
|
|
358
|
+
// left in the buffer would satisfy the *next* command instantly, and every
|
|
359
|
+
// answer after that would be attributed to the command before it.
|
|
360
|
+
if (!connection.waiter)
|
|
361
|
+
return;
|
|
362
|
+
connection.buffer += data;
|
|
363
|
+
// Bounded: a directory of a hundred thousand files must not become a
|
|
364
|
+
// hundred megabytes of retained string. Keeping the tail is right for every
|
|
365
|
+
// consumer here — the prompt, the last meter frame, the final error line.
|
|
366
|
+
if (connection.buffer.length > 4_000_000)
|
|
367
|
+
connection.buffer = connection.buffer.slice(-2_000_000);
|
|
368
|
+
const waiter = connection.waiter;
|
|
369
|
+
if (waiter) {
|
|
370
|
+
clearTimeout(waiter.timer);
|
|
371
|
+
waiter.timer = setTimeout(() => this.fail(connection, this.silenceError(connection)), waiter.idleMs);
|
|
372
|
+
if (waiter.reportProgress) {
|
|
373
|
+
const progress = parseProgress(data);
|
|
374
|
+
if (progress)
|
|
375
|
+
this.onEvent({ type: 'progress', sessionId: connection.id, ...progress });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
this.settle(connection);
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Decide whether the connection's current output ends the wait: a prompt for
|
|
382
|
+
* the next command, a question for the user, or a failure that ends it all.
|
|
383
|
+
*/
|
|
384
|
+
settle(connection) {
|
|
385
|
+
const waiter = connection.waiter;
|
|
386
|
+
if (!waiter)
|
|
387
|
+
return;
|
|
388
|
+
const fatal = detectFatal(connection.buffer);
|
|
389
|
+
if (fatal) {
|
|
390
|
+
this.fail(connection, new Error(fatal));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const question = detectPrompt(connection.buffer);
|
|
394
|
+
if (question) {
|
|
395
|
+
// Ask once per question: the buffer keeps growing, and a re-emitted
|
|
396
|
+
// prompt would replace the field the user is already typing into.
|
|
397
|
+
const text = question.text;
|
|
398
|
+
if (connection.buffer.length && text !== lastQuestion.get(connection.id)) {
|
|
399
|
+
lastQuestion.set(connection.id, text);
|
|
400
|
+
this.onEvent({ type: 'prompt', prompt: { sessionId: connection.id, kind: question.kind, text } });
|
|
401
|
+
}
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
// A bare prompt with nothing before it is the *normal* answer to a command
|
|
405
|
+
// that succeeded quietly — verified against the real client, which replies
|
|
406
|
+
// to `cd` and `mkdir` with the prompt alone, and which ConPTY does not echo
|
|
407
|
+
// the command back through. Waiting for more than this hangs every silent
|
|
408
|
+
// command; that pairing is kept honest by receive() instead.
|
|
409
|
+
if (!endsWithPrompt(connection.buffer))
|
|
410
|
+
return;
|
|
411
|
+
clearTimeout(waiter.timer);
|
|
412
|
+
connection.waiter = null;
|
|
413
|
+
lastQuestion.delete(connection.id);
|
|
414
|
+
waiter.resolve(responseBody(connection.buffer, waiter.command));
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Why nothing came back, in as much detail as the client gave.
|
|
418
|
+
*
|
|
419
|
+
* "The remote host stopped responding" was the whole message before, which
|
|
420
|
+
* cannot distinguish a host that was never reached from one asking something
|
|
421
|
+
* unrecognised from an answer that was misparsed. Quoting the client's own
|
|
422
|
+
* last line separates those three, and naming the doctor script gives the user
|
|
423
|
+
* somewhere to go when it does not.
|
|
424
|
+
*/
|
|
425
|
+
silenceError(connection) {
|
|
426
|
+
const said = lastClientMessage(connection.buffer);
|
|
427
|
+
if (!said) {
|
|
428
|
+
return new Error('The sftp client produced no output, so the host was never reached or is stuck before it can report why. '
|
|
429
|
+
+ 'Run `node scripts/sftp-doctor.mjs <host>` to see the connection attempt.');
|
|
430
|
+
}
|
|
431
|
+
return new Error(`The remote host stopped responding. The sftp client last said: "${said}". `
|
|
432
|
+
+ 'If that is a question, ZeroG did not recognise it — `node scripts/sftp-doctor.mjs <host>` shows the whole exchange.');
|
|
433
|
+
}
|
|
434
|
+
fail(connection, error) {
|
|
435
|
+
const waiter = connection.waiter;
|
|
436
|
+
connection.waiter = null;
|
|
437
|
+
if (waiter) {
|
|
438
|
+
clearTimeout(waiter.timer);
|
|
439
|
+
waiter.reject(error);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
handleExit(connection) {
|
|
443
|
+
const wasOpen = !connection.closed;
|
|
444
|
+
connection.closed = true;
|
|
445
|
+
this.fail(connection, new Error('The transfer connection closed.'));
|
|
446
|
+
this.connections.delete(connection.id);
|
|
447
|
+
lastQuestion.delete(connection.id);
|
|
448
|
+
if (wasOpen) {
|
|
449
|
+
this.onEvent({ type: 'closed', sessionId: connection.id, message: `Transfer connection to ${connection.target} closed.` });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
destroy(connection) {
|
|
453
|
+
connection.closed = true;
|
|
454
|
+
try {
|
|
455
|
+
connection.pty.kill();
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
/* already gone */
|
|
459
|
+
}
|
|
460
|
+
this.connections.delete(connection.id);
|
|
461
|
+
lastQuestion.delete(connection.id);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
/** The last question each connection asked, so it is only surfaced once. */
|
|
465
|
+
const lastQuestion = new Map();
|
|
@@ -46,6 +46,7 @@ function resolved(options) {
|
|
|
46
46
|
path: options.path ?? process.env.PATH ?? '',
|
|
47
47
|
pathExt: options.pathExt ?? process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM',
|
|
48
48
|
loginShell: options.loginShell ?? process.env.SHELL,
|
|
49
|
+
env: options.env ?? process.env,
|
|
49
50
|
isFile: options.isFile ?? defaultIsFile
|
|
50
51
|
};
|
|
51
52
|
}
|
|
@@ -90,6 +91,50 @@ export function findExecutable(command, options = {}, accept = () => true) {
|
|
|
90
91
|
}
|
|
91
92
|
return undefined;
|
|
92
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Where Windows puts the OpenSSH tools when they are not on PATH.
|
|
96
|
+
*
|
|
97
|
+
* PATH is the right first answer, and the only one that finds a tool installed
|
|
98
|
+
* somewhere unusual. But a desktop application is long-running: enabling the
|
|
99
|
+
* OpenSSH client feature extends PATH for processes started *afterwards*, so an
|
|
100
|
+
* app already open has a stale environment and would conclude that a machine
|
|
101
|
+
* plainly holding ssh.exe does not have it. These are the locations worth
|
|
102
|
+
* checking before saying that.
|
|
103
|
+
*/
|
|
104
|
+
function windowsOpenSshDirectories(env) {
|
|
105
|
+
const programFiles = env.ProgramFiles ?? 'C:\\Program Files';
|
|
106
|
+
return [
|
|
107
|
+
joinPath(env.SystemRoot ?? 'C:\\Windows', 'System32\\OpenSSH', true),
|
|
108
|
+
joinPath(programFiles, 'Git\\usr\\bin', true),
|
|
109
|
+
joinPath(env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Git\\usr\\bin', true)
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Locate an OpenSSH command line tool as a path a pty can start.
|
|
114
|
+
*
|
|
115
|
+
* Shared by the terminal side (`ssh`) and the transfer side (`sftp`) so there is
|
|
116
|
+
* one answer to "where is OpenSSH" rather than one per caller. An absolute path
|
|
117
|
+
* is what matters: node-pty hands the file straight to CreateProcess on Windows,
|
|
118
|
+
* which searches PATH but does not append `.exe`, so a bare `ssh` fails there on
|
|
119
|
+
* a machine that has it.
|
|
120
|
+
*
|
|
121
|
+
* Returns undefined when the tool cannot be found, leaving the wording of that
|
|
122
|
+
* failure to the caller, which knows what the user was trying to do.
|
|
123
|
+
*/
|
|
124
|
+
export function findOpenSshTool(command, options = {}) {
|
|
125
|
+
const onPath = findExecutable(command, options);
|
|
126
|
+
if (onPath)
|
|
127
|
+
return onPath;
|
|
128
|
+
const { windows, env, isFile } = resolved(options);
|
|
129
|
+
if (!windows)
|
|
130
|
+
return undefined;
|
|
131
|
+
for (const directory of windowsOpenSshDirectories(env)) {
|
|
132
|
+
const candidate = joinPath(directory, `${command}.exe`, true);
|
|
133
|
+
if (isFile(candidate))
|
|
134
|
+
return candidate;
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
93
138
|
/**
|
|
94
139
|
* Is this the `bash.exe` shim Windows installs for WSL?
|
|
95
140
|
*
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Turning terminal output back into plain text.
|
|
2
|
+
//
|
|
3
|
+
// Both the renderer (prompt sniffing) and the main process (reading the sftp
|
|
4
|
+
// client's answers off a pty) have to inspect output that was written to be
|
|
5
|
+
// displayed, not parsed: it carries colour, cursor movement and window-title
|
|
6
|
+
// sequences, and on Windows ConPTY it carries them even when the program itself
|
|
7
|
+
// emitted none.
|
|
8
|
+
//
|
|
9
|
+
// The patterns are built from named character constants rather than written as
|
|
10
|
+
// regex literals: an escape or bell inside a literal is an invisible control
|
|
11
|
+
// character in the source, which is both easy to destroy in a later edit and
|
|
12
|
+
// reported as a probable mistake by static analysis. Here it is deliberate.
|
|
13
|
+
const ESC = String.fromCharCode(27);
|
|
14
|
+
const BEL = String.fromCharCode(7);
|
|
15
|
+
/** OSC (window title, cwd reports), terminated by BEL or ESC backslash. */
|
|
16
|
+
export const OSC_SEQUENCE = new RegExp(ESC + '\\][^' + BEL + ESC + ']*(?:' + BEL + '|' + ESC + '\\\\)', 'g');
|
|
17
|
+
/** CSI (colour, cursor movement, erase). */
|
|
18
|
+
export const CSI_SEQUENCE = new RegExp(ESC + '\\[[0-9;?]*[ -/]*[@-~]', 'g');
|
|
19
|
+
/** Two-character escapes: charset selects, keypad mode, save/restore cursor. */
|
|
20
|
+
const SHORT_ESCAPE = new RegExp(ESC + '[()#%*+][0-9A-Za-z]|' + ESC + '[=><78MDEHc]', 'g');
|
|
21
|
+
/**
|
|
22
|
+
* Drop the display control sequences, keeping the characters a person would see.
|
|
23
|
+
*
|
|
24
|
+
* Carriage returns are kept: a progress meter overwrites its own line with them,
|
|
25
|
+
* and the reader needs to see that a new meter frame started. Line feeds are
|
|
26
|
+
* kept for the same reason — they separate the records being parsed.
|
|
27
|
+
*/
|
|
28
|
+
export function stripAnsi(value) {
|
|
29
|
+
return value
|
|
30
|
+
.replace(OSC_SEQUENCE, '')
|
|
31
|
+
.replace(CSI_SEQUENCE, '')
|
|
32
|
+
.replace(SHORT_ESCAPE, '')
|
|
33
|
+
.replace(new RegExp(BEL, 'g'), '');
|
|
34
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Path and listing arithmetic shared by the transfer panel and the services
|
|
2
|
+
// behind it. Pure, so it can be unit tested and so the renderer can reason about
|
|
3
|
+
// paths without a filesystem — the renderer has no Node path module, and the
|
|
4
|
+
// remote side is POSIX regardless of what the local machine is.
|
|
5
|
+
/** Directories first, then names, case-insensitively — the order a person scans. */
|
|
6
|
+
export function sortEntries(entries) {
|
|
7
|
+
return [...entries].sort((a, b) => {
|
|
8
|
+
const rank = (entry) => (entry.kind === 'directory' ? 0 : 1);
|
|
9
|
+
if (rank(a) !== rank(b))
|
|
10
|
+
return rank(a) - rank(b);
|
|
11
|
+
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
/** Join a remote directory and a child name. Remote paths are always POSIX. */
|
|
15
|
+
export function joinRemote(dir, name) {
|
|
16
|
+
if (name.startsWith('/'))
|
|
17
|
+
return name;
|
|
18
|
+
return dir === '/' ? `/${name}` : `${dir.replace(/\/+$/, '')}/${name}`;
|
|
19
|
+
}
|
|
20
|
+
/** The remote directory above this one; the root is its own parent. */
|
|
21
|
+
export function parentRemote(path) {
|
|
22
|
+
const trimmed = path.replace(/\/+$/, '');
|
|
23
|
+
const cut = trimmed.lastIndexOf('/');
|
|
24
|
+
if (cut <= 0)
|
|
25
|
+
return '/';
|
|
26
|
+
return trimmed.slice(0, cut);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Is this local path a Windows one?
|
|
30
|
+
*
|
|
31
|
+
* Decided from the path itself rather than from the platform: the renderer is
|
|
32
|
+
* given paths by the main process and has no way to ask which OS it is on.
|
|
33
|
+
*/
|
|
34
|
+
export function isWindowsPath(path) {
|
|
35
|
+
return /^[A-Za-z]:/.test(path) || path.startsWith('\\\\') || path.includes('\\');
|
|
36
|
+
}
|
|
37
|
+
export function joinLocal(dir, name) {
|
|
38
|
+
const separator = isWindowsPath(dir) ? '\\' : '/';
|
|
39
|
+
// The trim also strips a root's own separator, which the rejoin puts back:
|
|
40
|
+
// `C:\` becomes `C:` and then `C:\name`, and `/` becomes '' and then `/name`.
|
|
41
|
+
const base = dir.replace(/[\\/]+$/, '');
|
|
42
|
+
return `${base}${separator}${name}`;
|
|
43
|
+
}
|
|
44
|
+
/** The local directory above this one; a root is its own parent. */
|
|
45
|
+
export function parentLocal(path) {
|
|
46
|
+
const windows = isWindowsPath(path);
|
|
47
|
+
const trimmed = path.replace(/[\\/]+$/, '');
|
|
48
|
+
const cut = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
|
|
49
|
+
if (cut < 0)
|
|
50
|
+
return path;
|
|
51
|
+
if (!windows)
|
|
52
|
+
return cut === 0 ? '/' : trimmed.slice(0, cut);
|
|
53
|
+
const head = trimmed.slice(0, cut);
|
|
54
|
+
// `C:\Users` -> `C:\`, not `C:`, which no API accepts as a directory.
|
|
55
|
+
return /^[A-Za-z]:$/.test(head) ? `${head}\\` : head || '\\';
|
|
56
|
+
}
|
|
57
|
+
export function baseName(path) {
|
|
58
|
+
const parts = path.split(/[\\/]/).filter(Boolean);
|
|
59
|
+
return parts[parts.length - 1] ?? path;
|
|
60
|
+
}
|
|
61
|
+
/** A size a person can read at a glance. Directories are not measured. */
|
|
62
|
+
export function formatSize(entry) {
|
|
63
|
+
if (entry.kind === 'directory')
|
|
64
|
+
return '—';
|
|
65
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
66
|
+
let value = entry.size;
|
|
67
|
+
let unit = 0;
|
|
68
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
69
|
+
value /= 1024;
|
|
70
|
+
unit += 1;
|
|
71
|
+
}
|
|
72
|
+
return `${unit === 0 ? value : value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The modified column.
|
|
76
|
+
*
|
|
77
|
+
* Local listings carry an ISO timestamp; remote ones carry whatever the server's
|
|
78
|
+
* `ls` printed, which is already meant for reading and is passed through.
|
|
79
|
+
*/
|
|
80
|
+
export function formatModified(value) {
|
|
81
|
+
if (!value)
|
|
82
|
+
return '';
|
|
83
|
+
const iso = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
|
84
|
+
if (!iso)
|
|
85
|
+
return value;
|
|
86
|
+
const [, year, month, day, hour, minute] = iso;
|
|
87
|
+
return `${year}-${month}-${day} ${hour}:${minute}`;
|
|
88
|
+
}
|