termbeam 1.8.1 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -68
- package/bin/termbeam.js +129 -0
- package/package.json +1 -1
- package/public/terminal.html +226 -1
- package/src/cli.js +15 -0
- package/src/client.js +169 -0
- package/src/resume.js +387 -0
- package/src/routes.js +108 -2
- package/src/server.js +46 -6
- package/src/sessions.js +1 -1
package/src/client.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
const WebSocket = require('ws');
|
|
2
|
+
|
|
3
|
+
const DETACH_KEY = '\x02'; // Ctrl+B
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create a terminal client that pipes stdin/stdout over WebSocket.
|
|
7
|
+
* Resolves when detached or session exits. Rejects on connection error.
|
|
8
|
+
*
|
|
9
|
+
* @param {object} opts
|
|
10
|
+
* @param {string} opts.url WebSocket URL (ws://host:port/ws)
|
|
11
|
+
* @param {string} [opts.password] Server password (null for no-auth mode)
|
|
12
|
+
* @param {string} opts.sessionId Session ID to connect to
|
|
13
|
+
* @param {string} [opts.sessionName] Session name (for display)
|
|
14
|
+
* @param {string} [opts.detachKey] Key to detach (default: Ctrl+B)
|
|
15
|
+
* @returns {Promise<{reason: string}>}
|
|
16
|
+
*/
|
|
17
|
+
function createTerminalClient({
|
|
18
|
+
url,
|
|
19
|
+
password,
|
|
20
|
+
sessionId,
|
|
21
|
+
sessionName = 'session',
|
|
22
|
+
detachKey = DETACH_KEY,
|
|
23
|
+
detachLabel = 'Ctrl+B',
|
|
24
|
+
}) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const ws = new WebSocket(url);
|
|
27
|
+
let cleaned = false;
|
|
28
|
+
let bannerTimer = null;
|
|
29
|
+
let bannerShown = false;
|
|
30
|
+
let onData = null;
|
|
31
|
+
let onSigwinch = null;
|
|
32
|
+
|
|
33
|
+
function showBanner() {
|
|
34
|
+
if (!cleaned && !bannerShown) {
|
|
35
|
+
bannerShown = true;
|
|
36
|
+
process.stdout.write(
|
|
37
|
+
`\r\n\x1b[33m attached: ${sessionName} ─── ${detachLabel} to detach\x1b[0m\r\n\r\n`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
bannerTimer = null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function debounceBanner() {
|
|
44
|
+
if (bannerShown) return;
|
|
45
|
+
if (bannerTimer) clearTimeout(bannerTimer);
|
|
46
|
+
bannerTimer = setTimeout(showBanner, 500);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resetTerminal() {
|
|
50
|
+
if (bannerTimer) clearTimeout(bannerTimer);
|
|
51
|
+
process.stdout.write('\x1b]0;\x07');
|
|
52
|
+
if (process.stdin.isTTY && process.stdin.isRaw) {
|
|
53
|
+
process.stdin.setRawMode(false);
|
|
54
|
+
}
|
|
55
|
+
if (onData) process.stdin.removeListener('data', onData);
|
|
56
|
+
process.stdin.pause();
|
|
57
|
+
if (onSigwinch) process.removeListener('SIGWINCH', onSigwinch);
|
|
58
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
59
|
+
ws.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function cleanup(reason) {
|
|
64
|
+
if (cleaned) return;
|
|
65
|
+
cleaned = true;
|
|
66
|
+
resetTerminal();
|
|
67
|
+
resolve({ reason });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
ws.on('open', () => {
|
|
71
|
+
if (password) {
|
|
72
|
+
ws.send(JSON.stringify({ type: 'auth', password }));
|
|
73
|
+
} else {
|
|
74
|
+
ws.send(JSON.stringify({ type: 'attach', sessionId }));
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
ws.on('message', (raw) => {
|
|
79
|
+
let msg;
|
|
80
|
+
try {
|
|
81
|
+
msg = JSON.parse(raw);
|
|
82
|
+
} catch {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (msg.type === 'auth_ok') {
|
|
87
|
+
ws.send(JSON.stringify({ type: 'attach', sessionId }));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (msg.type === 'attached') {
|
|
92
|
+
// Set terminal title to show we're attached
|
|
93
|
+
process.stdout.write(`\x1b]0;[termbeam] ${sessionName} — ${detachLabel} to detach\x07`);
|
|
94
|
+
|
|
95
|
+
const refs = {};
|
|
96
|
+
enterRawMode(ws, detachKey, cleanup, refs);
|
|
97
|
+
onData = refs.onData;
|
|
98
|
+
onSigwinch = refs.onSigwinch;
|
|
99
|
+
sendResize(ws);
|
|
100
|
+
debounceBanner();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (msg.type === 'output') {
|
|
105
|
+
debounceBanner();
|
|
106
|
+
process.stdout.write(msg.data);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (msg.type === 'exit') {
|
|
111
|
+
cleanup(`session exited with code ${msg.code}`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (msg.type === 'error') {
|
|
116
|
+
cleanup(`error: ${msg.message}`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
ws.on('error', (err) => {
|
|
122
|
+
if (!cleaned) {
|
|
123
|
+
cleaned = true;
|
|
124
|
+
resetTerminal();
|
|
125
|
+
reject(err);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
ws.on('close', () => {
|
|
130
|
+
cleanup('connection closed');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function enterRawMode(ws, detachKey, cleanup, refs) {
|
|
136
|
+
if (process.stdin.isTTY) {
|
|
137
|
+
process.stdin.setRawMode(true);
|
|
138
|
+
}
|
|
139
|
+
process.stdin.resume();
|
|
140
|
+
|
|
141
|
+
refs.onData = (data) => {
|
|
142
|
+
const str = data.toString();
|
|
143
|
+
if (str === detachKey) {
|
|
144
|
+
cleanup('detached');
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
148
|
+
ws.send(JSON.stringify({ type: 'input', data: str }));
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
process.stdin.on('data', refs.onData);
|
|
152
|
+
|
|
153
|
+
refs.onSigwinch = () => sendResize(ws);
|
|
154
|
+
process.on('SIGWINCH', refs.onSigwinch);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function sendResize(ws) {
|
|
158
|
+
if (ws.readyState === WebSocket.OPEN && process.stdout.columns && process.stdout.rows) {
|
|
159
|
+
ws.send(
|
|
160
|
+
JSON.stringify({
|
|
161
|
+
type: 'resize',
|
|
162
|
+
cols: process.stdout.columns,
|
|
163
|
+
rows: process.stdout.rows,
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = { createTerminalClient };
|
package/src/resume.js
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const { createTerminalClient } = require('./client');
|
|
6
|
+
const { bold, dim, red, yellow, choose, createRL, ask } = require('./prompts');
|
|
7
|
+
|
|
8
|
+
const CONFIG_DIR = process.env.TERMBEAM_CONFIG_DIR || path.join(os.homedir(), '.termbeam');
|
|
9
|
+
const CONNECTION_FILE = path.join(CONFIG_DIR, 'connection.json');
|
|
10
|
+
|
|
11
|
+
// ── Connection config ────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
function readConnectionConfig() {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(fs.readFileSync(CONNECTION_FILE, 'utf8'));
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeConnectionConfig({ port, host, password }) {
|
|
22
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
23
|
+
fs.writeFileSync(CONNECTION_FILE, JSON.stringify({ port, host, password }, null, 2) + '\n', {
|
|
24
|
+
mode: 0o600,
|
|
25
|
+
});
|
|
26
|
+
// Ensure restrictive permissions even if the file already existed
|
|
27
|
+
if (process.platform !== 'win32') {
|
|
28
|
+
try {
|
|
29
|
+
fs.chmodSync(CONNECTION_FILE, 0o600);
|
|
30
|
+
} catch {
|
|
31
|
+
/* best-effort */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function removeConnectionConfig() {
|
|
37
|
+
try {
|
|
38
|
+
fs.unlinkSync(CONNECTION_FILE);
|
|
39
|
+
} catch {
|
|
40
|
+
/* ignore */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Arg parsing ──────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
function parseDetachKey(value) {
|
|
47
|
+
// \xNN hex escape → control character
|
|
48
|
+
const hexMatch = value.match(/^\\x([0-9a-fA-F]{2})$/);
|
|
49
|
+
if (hexMatch) return String.fromCharCode(parseInt(hexMatch[1], 16));
|
|
50
|
+
// ^X or ctrl+X → control character
|
|
51
|
+
const caretMatch = value.match(/^\^([A-Za-z])$/);
|
|
52
|
+
if (caretMatch) return String.fromCharCode(caretMatch[1].toUpperCase().charCodeAt(0) - 64);
|
|
53
|
+
const ctrlMatch = value.match(/^ctrl\+([A-Za-z])$/i);
|
|
54
|
+
if (ctrlMatch) return String.fromCharCode(ctrlMatch[1].toUpperCase().charCodeAt(0) - 64);
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseResumeArgs(args) {
|
|
59
|
+
let name = null;
|
|
60
|
+
let port = null;
|
|
61
|
+
let host = null;
|
|
62
|
+
let password = null;
|
|
63
|
+
let detachKey = null;
|
|
64
|
+
|
|
65
|
+
for (let i = 0; i < args.length; i++) {
|
|
66
|
+
if (args[i] === '--port' && args[i + 1]) {
|
|
67
|
+
port = parseInt(args[++i], 10);
|
|
68
|
+
} else if (args[i] === '--host' && args[i + 1]) {
|
|
69
|
+
host = args[++i];
|
|
70
|
+
} else if (args[i] === '--password' && args[i + 1]) {
|
|
71
|
+
password = args[++i];
|
|
72
|
+
} else if (args[i].startsWith('--password=')) {
|
|
73
|
+
password = args[i].split('=')[1];
|
|
74
|
+
} else if (args[i] === '--detach-key' && args[i + 1]) {
|
|
75
|
+
detachKey = parseDetachKey(args[++i]);
|
|
76
|
+
} else if (args[i] === '--help' || args[i] === '-h') {
|
|
77
|
+
return { help: true };
|
|
78
|
+
} else if (args[i].startsWith('--')) {
|
|
79
|
+
console.error(`Unknown flag: ${args[i]}`);
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
return { help: true };
|
|
82
|
+
} else if (!args[i].startsWith('-')) {
|
|
83
|
+
name = args[i];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { name, port, host, password, detachKey };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── HTTP helpers ─────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
function httpRequest(urlStr, options = {}) {
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
const url = new URL(urlStr);
|
|
95
|
+
const reqOpts = {
|
|
96
|
+
hostname: url.hostname,
|
|
97
|
+
port: url.port,
|
|
98
|
+
path: url.pathname,
|
|
99
|
+
method: options.method || 'GET',
|
|
100
|
+
headers: { ...options.headers },
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const req = http.request(reqOpts, (res) => {
|
|
104
|
+
let body = '';
|
|
105
|
+
res.on('data', (chunk) => (body += chunk));
|
|
106
|
+
res.on('end', () => resolve({ status: res.statusCode, body, headers: res.headers }));
|
|
107
|
+
});
|
|
108
|
+
req.on('error', reject);
|
|
109
|
+
if (options.body) req.write(options.body);
|
|
110
|
+
req.end();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function fetchSessions(baseUrl, password) {
|
|
115
|
+
const headers = {};
|
|
116
|
+
if (password) headers.Authorization = `Bearer ${password}`;
|
|
117
|
+
|
|
118
|
+
const res = await httpRequest(`${baseUrl}/api/sessions`, { headers });
|
|
119
|
+
if (res.status === 401) {
|
|
120
|
+
throw new Error('unauthorized');
|
|
121
|
+
}
|
|
122
|
+
if (res.status >= 400) {
|
|
123
|
+
throw new Error(`HTTP ${res.status}: ${res.body}`);
|
|
124
|
+
}
|
|
125
|
+
return JSON.parse(res.body);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Formatting ───────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
function formatUptime(createdAt) {
|
|
131
|
+
const ms = Date.now() - new Date(createdAt).getTime();
|
|
132
|
+
const secs = Math.floor(ms / 1000);
|
|
133
|
+
if (secs < 60) return `${secs}s`;
|
|
134
|
+
const mins = Math.floor(secs / 60);
|
|
135
|
+
if (mins < 60) return `${mins}m`;
|
|
136
|
+
const hours = Math.floor(mins / 60);
|
|
137
|
+
if (hours < 24) return `${hours}h ${mins % 60}m`;
|
|
138
|
+
const days = Math.floor(hours / 24);
|
|
139
|
+
return `${days}d ${hours % 24}h`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function shortId(id) {
|
|
143
|
+
return id.slice(0, 8);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Help text ────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
function detachKeyLabel(key) {
|
|
149
|
+
if (!key || key === '\x02') return 'Ctrl+B';
|
|
150
|
+
if (key.length === 1 && key.charCodeAt(0) < 27) {
|
|
151
|
+
return `Ctrl+${String.fromCharCode(key.charCodeAt(0) + 64)}`;
|
|
152
|
+
}
|
|
153
|
+
return key;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function printResumeHelp() {
|
|
157
|
+
console.log(`
|
|
158
|
+
${bold('termbeam resume')} — Reconnect to a running session
|
|
159
|
+
|
|
160
|
+
${bold('Usage:')}
|
|
161
|
+
termbeam resume [name] [options]
|
|
162
|
+
|
|
163
|
+
${bold('Arguments:')}
|
|
164
|
+
name Session name to connect to (auto-selects if unique match)
|
|
165
|
+
|
|
166
|
+
${bold('Options:')}
|
|
167
|
+
--port <port> Server port (default: from ~/.termbeam/connection.json or 3456)
|
|
168
|
+
--host <host> Server host (default: from config or localhost)
|
|
169
|
+
--password <pw> Server password (default: from config or prompt)
|
|
170
|
+
--detach-key <key> Detach key combo (default: Ctrl+B)
|
|
171
|
+
-h, --help Show this help
|
|
172
|
+
|
|
173
|
+
${bold('Examples:')}
|
|
174
|
+
termbeam resume Select from running sessions
|
|
175
|
+
termbeam resume my-project Connect to session named "my-project"
|
|
176
|
+
termbeam resume --port 4000 Connect to server on port 4000
|
|
177
|
+
|
|
178
|
+
${dim('Press Ctrl+B to detach from a session without closing it.')}
|
|
179
|
+
`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Commands ─────────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
async function resolveConnection(args) {
|
|
185
|
+
const opts = parseResumeArgs(args);
|
|
186
|
+
if (opts.help) return { help: true };
|
|
187
|
+
|
|
188
|
+
const saved = readConnectionConfig();
|
|
189
|
+
const host = opts.host || (saved && saved.host) || 'localhost';
|
|
190
|
+
const port = opts.port || (saved && saved.port) || 3456;
|
|
191
|
+
let password = opts.password || (saved && saved.password) || null;
|
|
192
|
+
const connHost = host === 'localhost' ? '127.0.0.1' : host;
|
|
193
|
+
const baseUrl = `http://${connHost}:${port}`;
|
|
194
|
+
const displayUrl = `http://${connHost === '127.0.0.1' ? 'localhost' : connHost}:${port}`;
|
|
195
|
+
|
|
196
|
+
// Try to fetch sessions, handle auth errors
|
|
197
|
+
let sessions;
|
|
198
|
+
try {
|
|
199
|
+
sessions = await fetchSessions(baseUrl, password);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if (err.message === 'unauthorized') {
|
|
202
|
+
// Prompt for password if none was explicitly provided
|
|
203
|
+
if (!opts.password) {
|
|
204
|
+
const rl = createRL();
|
|
205
|
+
password = await ask(rl, ` Password for ${displayUrl}:`);
|
|
206
|
+
rl.close();
|
|
207
|
+
try {
|
|
208
|
+
sessions = await fetchSessions(baseUrl, password);
|
|
209
|
+
} catch {
|
|
210
|
+
console.error(red(' Authentication failed.'));
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
console.error(red(' Authentication failed.'));
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
} else if (err.code === 'ECONNREFUSED') {
|
|
218
|
+
return { refused: true, displayUrl };
|
|
219
|
+
} else {
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { host, port, password, baseUrl, displayUrl, sessions, opts };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function resume(args) {
|
|
228
|
+
if (process.env.TERMBEAM_SESSION) {
|
|
229
|
+
console.error(red(' Already inside a TermBeam session. Detach first (Ctrl+B).'));
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const conn = await resolveConnection(args);
|
|
234
|
+
if (conn.help) {
|
|
235
|
+
printResumeHelp();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (conn.refused) {
|
|
239
|
+
console.error(red(' No TermBeam server is running.'));
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const { host, port, password, sessions, opts } = conn;
|
|
244
|
+
|
|
245
|
+
if (sessions.length === 0) {
|
|
246
|
+
console.error(red(' No active sessions on the server.'));
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let session;
|
|
251
|
+
|
|
252
|
+
if (opts.name) {
|
|
253
|
+
// Match by name (case-insensitive) or by ID prefix
|
|
254
|
+
session =
|
|
255
|
+
sessions.find((s) => s.name.toLowerCase() === opts.name.toLowerCase()) ||
|
|
256
|
+
sessions.find((s) => s.id.startsWith(opts.name));
|
|
257
|
+
|
|
258
|
+
if (!session) {
|
|
259
|
+
console.error(red(` No session matching "${opts.name}".`));
|
|
260
|
+
console.log(dim(' Available sessions:'));
|
|
261
|
+
for (const s of sessions) {
|
|
262
|
+
console.log(dim(` ${s.name} (${shortId(s.id)})`));
|
|
263
|
+
}
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
} else if (sessions.length === 1) {
|
|
267
|
+
session = sessions[0];
|
|
268
|
+
} else {
|
|
269
|
+
// Interactive chooser
|
|
270
|
+
const rl = createRL();
|
|
271
|
+
const choices = sessions.map((s) => ({
|
|
272
|
+
label: `${s.name} ${dim(shortId(s.id))}`,
|
|
273
|
+
hint: `${s.cwd} · ${formatUptime(s.createdAt)} · ${s.clients} client${s.clients !== 1 ? 's' : ''}`,
|
|
274
|
+
}));
|
|
275
|
+
|
|
276
|
+
console.log('');
|
|
277
|
+
const { index } = await choose(rl, ` ${bold('Select a session:')}`, choices);
|
|
278
|
+
rl.close();
|
|
279
|
+
session = sessions[index];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const wsHost = host === 'localhost' ? '127.0.0.1' : host;
|
|
283
|
+
const wsUrl = `ws://${wsHost}:${port}/ws`;
|
|
284
|
+
const detachKey = opts.detachKey || '\x02';
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
const { reason } = await createTerminalClient({
|
|
288
|
+
url: wsUrl,
|
|
289
|
+
password,
|
|
290
|
+
sessionId: session.id,
|
|
291
|
+
sessionName: session.name,
|
|
292
|
+
detachKey,
|
|
293
|
+
detachLabel: detachKeyLabel(detachKey),
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
console.log('');
|
|
297
|
+
if (reason === 'detached') {
|
|
298
|
+
console.log(yellow(` Detached from ${bold(session.name)}.`));
|
|
299
|
+
} else if (reason && reason.startsWith('session exited')) {
|
|
300
|
+
console.log(dim(` Session ${bold(session.name)} ended.`));
|
|
301
|
+
} else {
|
|
302
|
+
console.log(dim(` Disconnected from ${bold(session.name)}.`));
|
|
303
|
+
}
|
|
304
|
+
} catch (err) {
|
|
305
|
+
console.error(red(` Connection failed: ${err.message}`));
|
|
306
|
+
process.exit(1);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function list() {
|
|
311
|
+
const saved = readConnectionConfig();
|
|
312
|
+
const host = (saved && saved.host) || 'localhost';
|
|
313
|
+
const port = (saved && saved.port) || 3456;
|
|
314
|
+
let password = (saved && saved.password) || null;
|
|
315
|
+
const connHost = host === 'localhost' ? '127.0.0.1' : host;
|
|
316
|
+
const baseUrl = `http://${connHost}:${port}`;
|
|
317
|
+
const displayUrl = `http://${connHost === '127.0.0.1' ? 'localhost' : connHost}:${port}`;
|
|
318
|
+
|
|
319
|
+
let sessions;
|
|
320
|
+
try {
|
|
321
|
+
sessions = await fetchSessions(baseUrl, password);
|
|
322
|
+
} catch (err) {
|
|
323
|
+
if (err.message === 'unauthorized') {
|
|
324
|
+
if (!password) {
|
|
325
|
+
const rl = createRL();
|
|
326
|
+
password = await ask(rl, ` Password for ${displayUrl}:`);
|
|
327
|
+
rl.close();
|
|
328
|
+
try {
|
|
329
|
+
sessions = await fetchSessions(baseUrl, password);
|
|
330
|
+
} catch {
|
|
331
|
+
console.error(red(' Authentication failed.'));
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
} else {
|
|
335
|
+
console.error(red(' Authentication failed.'));
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
} else if (err.code === 'ECONNREFUSED') {
|
|
339
|
+
console.log(dim(' No TermBeam server is running.'));
|
|
340
|
+
return;
|
|
341
|
+
} else {
|
|
342
|
+
console.error(red(` Connection failed: ${err.message}`));
|
|
343
|
+
process.exit(1);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (sessions.length === 0) {
|
|
348
|
+
console.log(dim(' No active sessions.'));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
console.log('');
|
|
353
|
+
console.log(
|
|
354
|
+
bold(` ${sessions.length} session${sessions.length !== 1 ? 's' : ''} on ${displayUrl}`),
|
|
355
|
+
);
|
|
356
|
+
console.log('');
|
|
357
|
+
|
|
358
|
+
// Table header
|
|
359
|
+
const nameW = Math.max(6, ...sessions.map((s) => s.name.length));
|
|
360
|
+
const cwdW = Math.max(4, ...sessions.map((s) => s.cwd.length));
|
|
361
|
+
|
|
362
|
+
console.log(
|
|
363
|
+
dim(
|
|
364
|
+
` ${'NAME'.padEnd(nameW)} ${'ID'.padEnd(8)} ${'CWD'.padEnd(cwdW)} ${'UPTIME'.padEnd(8)} CLIENTS`,
|
|
365
|
+
),
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
for (const s of sessions) {
|
|
369
|
+
const uptime = formatUptime(s.createdAt);
|
|
370
|
+
console.log(
|
|
371
|
+
` ${bold(s.name.padEnd(nameW))} ${dim(shortId(s.id).padEnd(8))} ${s.cwd.padEnd(cwdW)} ${uptime.padEnd(8)} ${s.clients}`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
console.log('');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
module.exports = {
|
|
378
|
+
resume,
|
|
379
|
+
list,
|
|
380
|
+
writeConnectionConfig,
|
|
381
|
+
removeConnectionConfig,
|
|
382
|
+
readConnectionConfig,
|
|
383
|
+
printResumeHelp,
|
|
384
|
+
parseDetachKey,
|
|
385
|
+
CONFIG_DIR,
|
|
386
|
+
CONNECTION_FILE,
|
|
387
|
+
};
|
package/src/routes.js
CHANGED
|
@@ -69,7 +69,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
69
69
|
const token = auth.generateToken();
|
|
70
70
|
res.cookie('pty_token', token, {
|
|
71
71
|
httpOnly: true,
|
|
72
|
-
sameSite: '
|
|
72
|
+
sameSite: 'strict',
|
|
73
73
|
maxAge: 24 * 60 * 60 * 1000,
|
|
74
74
|
secure: req.secure,
|
|
75
75
|
});
|
|
@@ -99,7 +99,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
99
99
|
const token = auth.generateToken();
|
|
100
100
|
res.cookie('pty_token', token, {
|
|
101
101
|
httpOnly: true,
|
|
102
|
-
sameSite: '
|
|
102
|
+
sameSite: 'strict',
|
|
103
103
|
maxAge: 24 * 60 * 60 * 1000,
|
|
104
104
|
secure: req.secure,
|
|
105
105
|
});
|
|
@@ -308,6 +308,112 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
308
308
|
res.sendFile(filepath);
|
|
309
309
|
});
|
|
310
310
|
|
|
311
|
+
// General file upload to a session's working directory
|
|
312
|
+
app.post('/api/sessions/:id/upload', apiRateLimit, auth.middleware, (req, res) => {
|
|
313
|
+
const session = sessions.get(req.params.id);
|
|
314
|
+
if (!session) {
|
|
315
|
+
return res.status(404).json({ error: 'Session not found' });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const rawName = req.headers['x-filename'];
|
|
319
|
+
if (!rawName || typeof rawName !== 'string') {
|
|
320
|
+
return res.status(400).json({ error: 'Missing X-Filename header' });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Sanitize: take only the basename, strip control chars, collapse whitespace
|
|
324
|
+
const sanitized = path
|
|
325
|
+
.basename(rawName)
|
|
326
|
+
.replace(/[\x00-\x1f]/g, '')
|
|
327
|
+
.replace(/\s+/g, ' ')
|
|
328
|
+
.trim();
|
|
329
|
+
if (!sanitized || sanitized === '.' || sanitized === '..') {
|
|
330
|
+
return res.status(400).json({ error: 'Invalid filename' });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Resolve target directory: optional X-Target-Dir header, falls back to session cwd
|
|
334
|
+
const rawTargetDir = req.headers['x-target-dir'];
|
|
335
|
+
let targetDir = session.cwd;
|
|
336
|
+
if (rawTargetDir && typeof rawTargetDir === 'string') {
|
|
337
|
+
if (!path.isAbsolute(rawTargetDir)) {
|
|
338
|
+
return res.status(400).json({ error: 'Target directory must be an absolute path' });
|
|
339
|
+
}
|
|
340
|
+
const resolved = path.resolve(rawTargetDir);
|
|
341
|
+
try {
|
|
342
|
+
if (fs.statSync(resolved).isDirectory()) {
|
|
343
|
+
targetDir = resolved;
|
|
344
|
+
} else {
|
|
345
|
+
return res.status(400).json({ error: 'Target directory is not a directory' });
|
|
346
|
+
}
|
|
347
|
+
} catch {
|
|
348
|
+
return res.status(400).json({ error: 'Target directory does not exist' });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// Defense-in-depth: ensure destPath is still inside targetDir after join
|
|
352
|
+
const destPath = path.join(targetDir, sanitized);
|
|
353
|
+
if (
|
|
354
|
+
!path.resolve(destPath).startsWith(path.resolve(targetDir) + path.sep) &&
|
|
355
|
+
path.resolve(destPath) !== path.resolve(targetDir)
|
|
356
|
+
) {
|
|
357
|
+
return res.status(400).json({ error: 'Invalid filename' });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const chunks = [];
|
|
361
|
+
let size = 0;
|
|
362
|
+
let aborted = false;
|
|
363
|
+
const limit = 10 * 1024 * 1024;
|
|
364
|
+
|
|
365
|
+
req.on('data', (chunk) => {
|
|
366
|
+
if (aborted) return;
|
|
367
|
+
size += chunk.length;
|
|
368
|
+
if (size > limit) {
|
|
369
|
+
aborted = true;
|
|
370
|
+
log.warn(`File upload rejected: too large (${size} bytes)`);
|
|
371
|
+
res.status(413).json({ error: 'File too large (max 10 MB)' });
|
|
372
|
+
req.resume();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
chunks.push(chunk);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
req.on('end', () => {
|
|
379
|
+
if (aborted) return;
|
|
380
|
+
const buffer = Buffer.concat(chunks);
|
|
381
|
+
if (!buffer.length) {
|
|
382
|
+
return res.status(400).json({ error: 'Empty file' });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Atomic write with dedup: use wx flag to fail on existing file, retry with suffix
|
|
386
|
+
const ext = path.extname(sanitized);
|
|
387
|
+
const base = path.basename(sanitized, ext);
|
|
388
|
+
let destPath = path.join(targetDir, sanitized);
|
|
389
|
+
let written = false;
|
|
390
|
+
for (let n = 0; n < 100; n++) {
|
|
391
|
+
const candidate = n === 0 ? destPath : path.join(targetDir, `${base} (${n})${ext}`);
|
|
392
|
+
try {
|
|
393
|
+
fs.writeFileSync(candidate, buffer, { flag: 'wx' });
|
|
394
|
+
destPath = candidate;
|
|
395
|
+
written = true;
|
|
396
|
+
break;
|
|
397
|
+
} catch (err) {
|
|
398
|
+
if (err.code === 'EEXIST') continue;
|
|
399
|
+
log.error(`File upload write error: ${err.message}`);
|
|
400
|
+
return res.status(500).json({ error: 'Failed to write file' });
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (!written) {
|
|
404
|
+
return res.status(409).json({ error: 'Too many filename collisions' });
|
|
405
|
+
}
|
|
406
|
+
const finalName = path.basename(destPath);
|
|
407
|
+
log.info(`File upload: ${finalName} → ${targetDir} (${buffer.length} bytes)`);
|
|
408
|
+
res.json({ name: finalName, path: destPath, size: buffer.length });
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
req.on('error', (err) => {
|
|
412
|
+
log.error(`File upload error: ${err.message}`);
|
|
413
|
+
res.status(500).json({ error: 'Upload failed' });
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
311
417
|
// Directory listing for folder browser
|
|
312
418
|
app.get('/api/dirs', apiRateLimit, auth.middleware, (req, res) => {
|
|
313
419
|
const query = req.query.q || config.cwd + path.sep;
|