remotosh 1.0.2 → 1.2.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.
Files changed (2) hide show
  1. package/bin/remoto.js +318 -175
  2. package/package.json +1 -1
package/bin/remoto.js CHANGED
@@ -1,227 +1,365 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import os from 'os';
4
+ import fs from 'fs';
5
+ import path from 'path';
4
6
  import pty from 'node-pty';
5
7
  import qrcode from 'qrcode-terminal';
6
8
  import WebSocket from 'ws';
7
9
  import chalk from 'chalk';
10
+ import { randomBytes } from 'crypto';
8
11
 
9
12
  // Configuration
10
13
  const WS_SERVER_URL = process.env.REMOTO_WS_URL || 'wss://remoto-ws.fly.dev';
11
14
  const WEB_APP_URL = process.env.REMOTO_WEB_URL || 'https://remoto.sh';
12
- const API_KEY = process.env.REMOTO_API_KEY;
15
+ const CONFIG_DIR = path.join(os.homedir(), '.remoto');
16
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
17
+
18
+ // Sensitive environment variable patterns to filter out
19
+ const SENSITIVE_ENV_PATTERNS = [
20
+ /^AWS_/i, /^AZURE_/i, /^GCP_/i, /^GOOGLE_/i,
21
+ /^GITHUB_TOKEN$/i, /^GH_TOKEN$/i, /^GITLAB_/i,
22
+ /^NPM_TOKEN$/i, /^DOCKER_/i, /^KUBERNETES_/i, /^K8S_/i,
23
+ /SECRET/i, /PASSWORD/i, /PRIVATE_KEY/i, /API_KEY/i,
24
+ /AUTH_TOKEN/i, /ACCESS_TOKEN/i, /REFRESH_TOKEN/i,
25
+ /DATABASE_URL/i, /DB_PASSWORD/i, /POSTGRES_/i, /MYSQL_/i, /MONGO_/i, /REDIS_/i,
26
+ /STRIPE_/i, /TWILIO_/i, /SENDGRID_/i, /MAILGUN_/i,
27
+ /SUPABASE_/i, /FIREBASE_/i, /OPENAI_/i, /ANTHROPIC_/i, /SLACK_/i, /DISCORD_/i,
28
+ ];
29
+
30
+ // Create sanitized environment for PTY
31
+ function getSanitizedEnv() {
32
+ const env = { ...process.env };
33
+ for (const key of Object.keys(env)) {
34
+ if (SENSITIVE_ENV_PATTERNS.some(pattern => pattern.test(key))) {
35
+ delete env[key];
36
+ }
37
+ }
38
+ return env;
39
+ }
13
40
 
14
- // Detect shell
15
- const shell = process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : 'zsh');
41
+ // Load saved config
42
+ function loadConfig() {
43
+ try {
44
+ if (fs.existsSync(CONFIG_FILE)) {
45
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
46
+ }
47
+ } catch (e) {
48
+ // Ignore errors
49
+ }
50
+ return {};
51
+ }
16
52
 
17
- // Terminal dimensions
18
- let cols = process.stdout.columns || 80;
19
- let rows = process.stdout.rows || 24;
53
+ // Save config
54
+ function saveConfig(config) {
55
+ try {
56
+ if (!fs.existsSync(CONFIG_DIR)) {
57
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
58
+ }
59
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
60
+ } catch (e) {
61
+ // Ignore errors
62
+ }
63
+ }
20
64
 
21
- console.clear();
22
- console.log(chalk.bold.white('\n remoto'));
23
- console.log(chalk.dim(' control your terminal from your phone\n'));
24
- console.log(chalk.dim(' connecting...'));
25
-
26
- // Connect to WebSocket server (API key is optional)
27
- const wsUrl = API_KEY
28
- ? `${WS_SERVER_URL}/cli/?apiKey=${encodeURIComponent(API_KEY)}`
29
- : `${WS_SERVER_URL}/cli/`;
30
- const ws = new WebSocket(wsUrl);
31
-
32
- let ptyProcess = null;
33
- let sessionId = null;
34
- let sessionToken = null;
35
- let isAnonymous = true;
36
- let outputBuffer = '';
37
- let flushTimeout = null;
38
-
39
- ws.on('open', () => {
40
- // Connection established, wait for session_created message
41
- });
65
+ // Open URL in browser
66
+ async function openBrowser(url) {
67
+ const { exec } = await import('child_process');
68
+ const platform = os.platform();
69
+
70
+ let command;
71
+ if (platform === 'darwin') {
72
+ command = `open "${url}"`;
73
+ } else if (platform === 'win32') {
74
+ command = `start "" "${url}"`;
75
+ } else {
76
+ command = `xdg-open "${url}"`;
77
+ }
42
78
 
43
- ws.on('message', (data) => {
44
- const raw = data.toString();
45
- console.log(chalk.dim(` [debug] received: ${raw.substring(0, 100)}`));
46
- try {
47
- const message = JSON.parse(raw);
48
- handleServerMessage(message);
49
- } catch (err) {
50
- console.error(chalk.red(` invalid message from server: ${err.message}`));
51
- console.error(chalk.red(` raw data: ${raw.substring(0, 50)}`));
79
+ exec(command, (err) => {
80
+ if (err) {
81
+ console.log(chalk.dim(` couldn't open browser automatically`));
82
+ console.log(chalk.dim(` open this URL manually: ${url}`));
83
+ }
84
+ });
85
+ }
86
+
87
+ // Poll for auth completion
88
+ async function pollForAuth(deviceCode) {
89
+ const pollUrl = `${WEB_APP_URL}/api/cli-auth/poll?code=${deviceCode}`;
90
+
91
+ for (let i = 0; i < 60; i++) { // Poll for 5 minutes max
92
+ await new Promise(r => setTimeout(r, 5000)); // Wait 5 seconds
93
+
94
+ try {
95
+ const response = await fetch(pollUrl);
96
+ const data = await response.json();
97
+
98
+ if (data.status === 'authorized' && data.token) {
99
+ return data.token;
100
+ } else if (data.status === 'expired') {
101
+ return null;
102
+ }
103
+ // status === 'pending' - keep polling
104
+ } catch (e) {
105
+ // Network error, keep trying
106
+ }
52
107
  }
53
- });
54
108
 
55
- ws.on('close', (code, reason) => {
56
- console.log(chalk.dim(`\n disconnected (${code})`));
57
- cleanup();
58
- });
109
+ return null;
110
+ }
59
111
 
60
- ws.on('error', (err) => {
61
- console.error(chalk.red(`\n connection error: ${err.message}`));
62
- if (err.message.includes('ECONNREFUSED')) {
63
- console.log(chalk.dim('\n make sure you have internet access'));
112
+ // Main auth flow
113
+ async function authenticate() {
114
+ const config = loadConfig();
115
+
116
+ // Check if we have a saved token
117
+ if (config.token) {
118
+ return config.token;
64
119
  }
65
- process.exit(1);
66
- });
67
120
 
68
- function handleServerMessage(message) {
69
- switch (message.type) {
70
- case 'session_created':
71
- sessionId = message.sessionId;
72
- sessionToken = message.sessionToken;
73
- isAnonymous = message.isAnonymous;
74
- showQRCode();
75
- startPTY();
76
- break;
77
-
78
- case 'phone_connected':
79
- console.log(chalk.green(`\n phone connected`));
80
- if (message.phoneCount > 1) {
81
- console.log(chalk.dim(` ${message.phoneCount} devices connected`));
82
- }
83
- console.log('');
84
- break;
85
-
86
- case 'phone_disconnected':
87
- if (message.phoneCount > 0) {
88
- console.log(chalk.yellow(`\n phone disconnected (${message.phoneCount} remaining)\n`));
89
- } else {
90
- console.log(chalk.yellow(`\n phone disconnected\n`));
91
- }
92
- break;
121
+ // No token - need to authenticate via browser
122
+ console.log(chalk.yellow(' login required\n'));
93
123
 
94
- case 'input':
95
- // Input from phone
96
- console.log(chalk.dim(` [debug] input handler, ptyProcess exists: ${!!ptyProcess}`));
97
- if (ptyProcess) {
98
- console.log(chalk.dim(` [debug] writing to pty: "${message.data}"`));
99
- ptyProcess.write(message.data);
100
- } else {
101
- console.log(chalk.red(' [debug] ptyProcess is null!'));
102
- }
103
- break;
124
+ // Generate device code
125
+ const deviceCode = randomBytes(16).toString('hex');
126
+ const authUrl = `${WEB_APP_URL}/cli-auth?code=${deviceCode}`;
104
127
 
105
- case 'resize':
106
- // Resize from phone
107
- if (ptyProcess && message.cols && message.rows) {
108
- ptyProcess.resize(message.cols, message.rows);
109
- }
110
- break;
128
+ console.log(chalk.white(' opening browser to login...\n'));
129
+
130
+ await openBrowser(authUrl);
111
131
 
112
- default:
113
- // Ignore unknown messages
132
+ console.log(chalk.dim(` if browser didn't open, go to:`));
133
+ console.log(chalk.cyan(` ${authUrl}\n`));
134
+ console.log(chalk.dim(' waiting for login...'));
135
+
136
+ const token = await pollForAuth(deviceCode);
137
+
138
+ if (!token) {
139
+ console.log(chalk.red('\n login timed out or was cancelled'));
140
+ console.log(chalk.dim(' run `npx remotosh` to try again\n'));
141
+ process.exit(1);
114
142
  }
143
+
144
+ // Save token
145
+ saveConfig({ token });
146
+ console.log(chalk.green('\n logged in successfully!\n'));
147
+
148
+ return token;
115
149
  }
116
150
 
117
- function showQRCode() {
118
- const connectionUrl = `${WEB_APP_URL}/session/${sessionId}?token=${sessionToken}`;
151
+ // Detect shell
152
+ const shell = process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : 'zsh');
119
153
 
154
+ // Terminal dimensions
155
+ let cols = process.stdout.columns || 80;
156
+ let rows = process.stdout.rows || 24;
157
+
158
+ // Main
159
+ async function main() {
120
160
  console.clear();
121
161
  console.log(chalk.bold.white('\n remoto'));
122
162
  console.log(chalk.dim(' control your terminal from your phone\n'));
123
163
 
124
- qrcode.generate(connectionUrl, { small: true }, (qr) => {
125
- // Indent the QR code
126
- const indentedQr = qr.split('\n').map(line => ' ' + line).join('\n');
127
- console.log(indentedQr);
128
- console.log(chalk.dim(`\n ${connectionUrl}\n`));
129
- console.log(chalk.dim(' scan the qr code or open the link on your phone'));
130
- console.log(chalk.dim(' waiting for connection...\n'));
131
- console.log(chalk.dim('─'.repeat(Math.min(cols, 60))));
132
- });
133
- }
134
-
135
- function startPTY() {
136
- // Initialize PTY
137
- console.log(chalk.dim(` [debug] spawning shell: ${shell}`));
138
- try {
139
- ptyProcess = pty.spawn(shell, [], {
140
- name: 'xterm-256color',
141
- cols,
142
- rows,
143
- cwd: process.cwd(),
144
- env: process.env,
145
- });
146
- console.log(chalk.dim(` [debug] pty spawned successfully, pid: ${ptyProcess.pid}`));
147
- } catch (err) {
148
- console.error(chalk.red(` [debug] pty spawn failed: ${err.message}`));
149
- return;
164
+ // Handle logout command
165
+ if (process.argv[2] === 'logout') {
166
+ saveConfig({});
167
+ console.log(chalk.green(' logged out successfully\n'));
168
+ process.exit(0);
150
169
  }
151
170
 
152
- // Handle PTY output
153
- ptyProcess.onData((data) => {
154
- // Write to local terminal
155
- process.stdout.write(data);
156
-
157
- // Buffer and send to server
158
- outputBuffer += data;
159
-
160
- if (flushTimeout) clearTimeout(flushTimeout);
161
- flushTimeout = setTimeout(() => {
162
- if (outputBuffer && ws.readyState === WebSocket.OPEN) {
163
- // Chunk large outputs
164
- const chunks = chunkString(outputBuffer, 16000);
165
- for (const chunk of chunks) {
166
- ws.send(JSON.stringify({ type: 'output', data: chunk }));
167
- }
168
- outputBuffer = '';
169
- }
170
- }, 30);
171
- });
171
+ // Authenticate
172
+ const token = await authenticate();
172
173
 
173
- // Handle PTY exit
174
- ptyProcess.onExit(({ exitCode }) => {
175
- console.log(chalk.dim(`\n session ended`));
174
+ console.log(chalk.dim(' connecting...'));
176
175
 
177
- // Show account nudge for anonymous users
178
- if (isAnonymous) {
179
- console.log(chalk.dim('\n ─────────────────────────────────────────'));
180
- console.log(chalk.white('\n create an account to save session history'));
181
- console.log(chalk.dim(` ${WEB_APP_URL}/signup\n`));
182
- }
176
+ // Connect to WebSocket server
177
+ const wsUrl = `${WS_SERVER_URL}/cli/?token=${encodeURIComponent(token)}`;
178
+ const ws = new WebSocket(wsUrl);
183
179
 
184
- if (ws.readyState === WebSocket.OPEN) {
185
- ws.send(JSON.stringify({ type: 'exit', code: exitCode }));
186
- ws.close();
180
+ let ptyProcess = null;
181
+ let sessionId = null;
182
+ let sessionToken = null;
183
+ let maxDuration = null;
184
+ let outputBuffer = '';
185
+ let flushTimeout = null;
186
+
187
+ ws.on('open', () => {
188
+ // Connection established, wait for session_created message
189
+ });
190
+
191
+ ws.on('message', (data) => {
192
+ try {
193
+ const message = JSON.parse(data.toString());
194
+ handleServerMessage(message);
195
+ } catch (err) {
196
+ // Ignore invalid messages
187
197
  }
188
- process.exit(exitCode);
189
198
  });
190
199
 
191
- // Handle local terminal resize
192
- process.stdout.on('resize', () => {
193
- cols = process.stdout.columns;
194
- rows = process.stdout.rows;
195
- if (ptyProcess) {
196
- ptyProcess.resize(cols, rows);
200
+ ws.on('close', (code, reason) => {
201
+ const reasonStr = reason?.toString() || '';
202
+
203
+ if (code === 4001 || code === 4002) {
204
+ // Invalid/expired token - clear it and re-auth
205
+ saveConfig({});
206
+ console.log(chalk.yellow('\n session expired, please login again'));
207
+ console.log(chalk.dim(' run `npx remotosh` to login\n'));
208
+ } else if (code === 4004) {
209
+ console.log(chalk.yellow('\n session limit reached'));
210
+ console.log(chalk.dim(' free plan allows 2 concurrent sessions'));
211
+ console.log(chalk.dim(' close an existing session and try again\n'));
212
+ } else if (reasonStr.includes('expired')) {
213
+ console.log(chalk.yellow('\n session expired (1 hour limit)'));
214
+ console.log(chalk.dim(' run `npx remotosh` to start a new session\n'));
215
+ } else {
216
+ console.log(chalk.dim(`\n disconnected\n`));
197
217
  }
218
+ cleanup();
198
219
  });
199
220
 
200
- // Handle local input
201
- process.stdin.setRawMode(true);
202
- process.stdin.resume();
203
- process.stdin.on('data', (data) => {
204
- if (ptyProcess) {
205
- ptyProcess.write(data.toString());
221
+ ws.on('error', (err) => {
222
+ console.error(chalk.red(`\n connection error: ${err.message}`));
223
+ if (err.message.includes('ECONNREFUSED')) {
224
+ console.log(chalk.dim('\n make sure you have internet access'));
206
225
  }
226
+ process.exit(1);
207
227
  });
208
- }
209
228
 
210
- function cleanup() {
211
- if (ptyProcess) {
212
- ptyProcess.kill();
229
+ function handleServerMessage(message) {
230
+ switch (message.type) {
231
+ case 'session_created':
232
+ sessionId = message.sessionId;
233
+ sessionToken = message.sessionToken;
234
+ maxDuration = message.maxDuration;
235
+ showQRCode();
236
+ startPTY();
237
+ break;
238
+
239
+ case 'phone_connected':
240
+ console.log(chalk.green(`\n phone connected`));
241
+ if (message.phoneCount > 1) {
242
+ console.log(chalk.dim(` ${message.phoneCount} devices connected`));
243
+ }
244
+ console.log('');
245
+ break;
246
+
247
+ case 'phone_disconnected':
248
+ if (message.phoneCount > 0) {
249
+ console.log(chalk.yellow(`\n phone disconnected (${message.phoneCount} remaining)\n`));
250
+ } else {
251
+ console.log(chalk.yellow(`\n phone disconnected\n`));
252
+ }
253
+ break;
254
+
255
+ case 'session_expiring':
256
+ console.log(chalk.yellow(`\n session expiring in ${message.minutesRemaining} minutes\n`));
257
+ break;
258
+
259
+ case 'input':
260
+ if (ptyProcess) {
261
+ ptyProcess.write(message.data);
262
+ }
263
+ break;
264
+
265
+ case 'resize':
266
+ if (ptyProcess && message.cols && message.rows) {
267
+ ptyProcess.resize(message.cols, message.rows);
268
+ }
269
+ break;
270
+ }
213
271
  }
214
- if (ws.readyState === WebSocket.OPEN) {
215
- ws.close();
272
+
273
+ function showQRCode() {
274
+ const connectionUrl = `${WEB_APP_URL}/session/${sessionId}?token=${sessionToken}`;
275
+ const durationMinutes = maxDuration ? Math.floor(maxDuration / 60000) : 60;
276
+
277
+ console.clear();
278
+ console.log(chalk.bold.white('\n remoto'));
279
+ console.log(chalk.dim(' control your terminal from your phone\n'));
280
+
281
+ qrcode.generate(connectionUrl, { small: true }, (qr) => {
282
+ const indentedQr = qr.split('\n').map(line => ' ' + line).join('\n');
283
+ console.log(indentedQr);
284
+ console.log(chalk.dim(`\n ${connectionUrl}\n`));
285
+ console.log(chalk.dim(' scan the qr code or open the link on your phone'));
286
+ console.log(chalk.dim(` session expires in ${durationMinutes} minutes`));
287
+ console.log(chalk.dim(' waiting for connection...\n'));
288
+ console.log(chalk.dim('─'.repeat(Math.min(cols, 60))));
289
+ });
216
290
  }
217
- process.exit();
218
- }
219
291
 
220
- // Cleanup on exit
221
- process.on('SIGINT', cleanup);
222
- process.on('SIGTERM', cleanup);
292
+ function startPTY() {
293
+ try {
294
+ const sanitizedEnv = getSanitizedEnv();
295
+ ptyProcess = pty.spawn(shell, [], {
296
+ name: 'xterm-256color',
297
+ cols,
298
+ rows,
299
+ cwd: process.cwd(),
300
+ env: sanitizedEnv,
301
+ });
302
+ } catch (err) {
303
+ console.error(chalk.red(` failed to start shell: ${err.message}`));
304
+ return;
305
+ }
306
+
307
+ ptyProcess.onData((data) => {
308
+ process.stdout.write(data);
309
+ outputBuffer += data;
310
+
311
+ if (flushTimeout) clearTimeout(flushTimeout);
312
+ flushTimeout = setTimeout(() => {
313
+ if (outputBuffer && ws.readyState === WebSocket.OPEN) {
314
+ const chunks = chunkString(outputBuffer, 16000);
315
+ for (const chunk of chunks) {
316
+ ws.send(JSON.stringify({ type: 'output', data: chunk }));
317
+ }
318
+ outputBuffer = '';
319
+ }
320
+ }, 30);
321
+ });
322
+
323
+ ptyProcess.onExit(({ exitCode }) => {
324
+ console.log(chalk.dim(`\n session ended`));
325
+ if (ws.readyState === WebSocket.OPEN) {
326
+ ws.send(JSON.stringify({ type: 'exit', code: exitCode }));
327
+ ws.close();
328
+ }
329
+ process.exit(exitCode);
330
+ });
331
+
332
+ process.stdout.on('resize', () => {
333
+ cols = process.stdout.columns;
334
+ rows = process.stdout.rows;
335
+ if (ptyProcess) {
336
+ ptyProcess.resize(cols, rows);
337
+ }
338
+ });
339
+
340
+ process.stdin.setRawMode(true);
341
+ process.stdin.resume();
342
+ process.stdin.on('data', (data) => {
343
+ if (ptyProcess) {
344
+ ptyProcess.write(data.toString());
345
+ }
346
+ });
347
+ }
348
+
349
+ function cleanup() {
350
+ if (ptyProcess) {
351
+ ptyProcess.kill();
352
+ }
353
+ if (ws.readyState === WebSocket.OPEN) {
354
+ ws.close();
355
+ }
356
+ process.exit();
357
+ }
358
+
359
+ process.on('SIGINT', cleanup);
360
+ process.on('SIGTERM', cleanup);
361
+ }
223
362
 
224
- // Helper to chunk strings
225
363
  function chunkString(str, size) {
226
364
  const chunks = [];
227
365
  for (let i = 0; i < str.length; i += size) {
@@ -229,3 +367,8 @@ function chunkString(str, size) {
229
367
  }
230
368
  return chunks.length ? chunks : [''];
231
369
  }
370
+
371
+ main().catch(err => {
372
+ console.error(chalk.red(` error: ${err.message}`));
373
+ process.exit(1);
374
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remotosh",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "description": "Control your terminal from your phone",
5
5
  "type": "module",
6
6
  "bin": {