pairshell-cli 0.0.1

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/src/start.js ADDED
@@ -0,0 +1,453 @@
1
+ import http from "node:http";
2
+ import { randomBytes } from "node:crypto";
3
+ import { writeFileSync, mkdirSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { WebSocketServer } from "ws";
7
+ import qrcode from "qrcode-terminal";
8
+ import { resolve } from "./providers/index.js";
9
+ import { PairingStore } from "./pairing.js";
10
+ import { SessionStore } from "./session.js";
11
+ import { lanAddress } from "./net.js";
12
+ import { bridgeSession } from "./pty-bridge.js";
13
+ import { readConfig, getProvider } from "./config.js";
14
+ import { deriveKey } from "./crypto.js";
15
+ import { ok, info, warn, fail, header, kv, pairingUrl, BOLD, RESET } from "./format.js";
16
+ import fs from "node:fs/promises";
17
+ import { exec } from "node:child_process";
18
+
19
+ const DEFAULT_TUNNEL_PORT = 7442;
20
+ const MAX_PORT_TRIES = 10;
21
+ const AUTH_TIMEOUT_MS = 10000;
22
+ const MAX_PENDING_CONNECTIONS = 5;
23
+
24
+ function toPairingUrl(base, token) {
25
+ const url = new URL(base);
26
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
27
+ url.pathname = "/";
28
+ url.searchParams.set("token", token);
29
+ return url.toString();
30
+ }
31
+
32
+ function sessionListHandler(req, res, sessions) {
33
+ if (req.method === "GET" && req.url === "/sessions") {
34
+ res.writeHead(200, { "Content-Type": "application/json" });
35
+ res.end(JSON.stringify(sessions.list()));
36
+ return true;
37
+ }
38
+ return false;
39
+ }
40
+
41
+ async function revokeHandler(req, res, sessions) {
42
+ if (req.method === "POST" && req.url === "/revoke") {
43
+ let body = "";
44
+ for await (const chunk of req) body += chunk;
45
+ let data;
46
+ try { data = JSON.parse(body); } catch {}
47
+ if (!data || !data.deviceId) {
48
+ res.writeHead(400, { "Content-Type": "application/json" });
49
+ res.end(JSON.stringify({ error: "deviceId required" }));
50
+ return true;
51
+ }
52
+ const removed = sessions.revoke(data.deviceId);
53
+ res.writeHead(removed ? 200 : 404, { "Content-Type": "application/json" });
54
+ res.end(JSON.stringify({ revoked: removed, deviceId: data.deviceId }));
55
+ return true;
56
+ }
57
+ return false;
58
+ }
59
+
60
+ function healthHandler(req, res) {
61
+ if (req.method === "GET" && (req.url === "/" || req.url === "/health")) {
62
+ res.writeHead(200, {
63
+ "Content-Type": "application/json",
64
+ "Access-Control-Allow-Origin": "*",
65
+ });
66
+ res.end(JSON.stringify({ status: "ok", service: "pairshell-cli" }));
67
+ return true;
68
+ }
69
+ return false;
70
+ }
71
+
72
+ async function bindServer(server, preferred, host) {
73
+ let port = preferred;
74
+ for (let attempt = 0; attempt < MAX_PORT_TRIES; attempt++) {
75
+ const tryPort = port > 0 ? port + attempt : 0;
76
+ try {
77
+ await new Promise((resolve, reject) => {
78
+ server.once("error", reject);
79
+ server.listen(tryPort, host, resolve);
80
+ });
81
+ return server.address().port;
82
+ } catch (err) {
83
+ if (err.code !== "EADDRINUSE") throw err;
84
+ if (tryPort === 0) throw err;
85
+ }
86
+ }
87
+ throw new Error(`Could not find an available port after ${MAX_PORT_TRIES} tries`);
88
+ }
89
+
90
+ function printQr(wsUrl, token) {
91
+ console.log(`\n ${BOLD}Paste this in the PairShell app:${RESET}`);
92
+ console.log(` ${pairingUrl(wsUrl)}\n`);
93
+ qrcode.generate(wsUrl, { small: true });
94
+ }
95
+
96
+ export async function startCommand(opts) {
97
+ if (opts.path) {
98
+ try {
99
+ process.chdir(opts.path);
100
+ info(`Changed to ${opts.path}`);
101
+ } catch (err) {
102
+ fail(`Cannot access path: ${err.message}`);
103
+ process.exit(1);
104
+ }
105
+ }
106
+
107
+ const useTunnel = opts.tunnel !== false;
108
+ const providerName = opts.provider || getProvider();
109
+
110
+ const server = http.createServer((req, res) => {
111
+ if (healthHandler(req, res)) return;
112
+ res.writeHead(426, { "Content-Type": "text/plain" });
113
+ res.end("PairShell requires a WebSocket connection.\n");
114
+ });
115
+
116
+ const adminServer = http.createServer((req, res) => {
117
+ if (sessionListHandler(req, res, sessions)) return;
118
+ if (revokeHandler(req, res, sessions)) return;
119
+ res.writeHead(404).end();
120
+ });
121
+
122
+ const wss = new WebSocketServer({ server });
123
+ const pairing = new PairingStore();
124
+ const sessions = new SessionStore();
125
+
126
+ header("Starting PairShell");
127
+
128
+ let port = opts.port || (useTunnel ? DEFAULT_TUNNEL_PORT : 0);
129
+ const boundPort = await bindServer(server, port);
130
+ if (port && boundPort !== port) {
131
+ warn(`Port ${port} was in use, bound to ${boundPort} instead`);
132
+ }
133
+ info(`WebSocket server listening on port ${boundPort}`);
134
+
135
+ const adminPort = await bindServer(adminServer, boundPort + 1000, "127.0.0.1");
136
+ adminServer.unref();
137
+ info(`Admin HTTP server on 127.0.0.1:${adminPort}`);
138
+
139
+ const activeFile = join(homedir(), ".pairshell", "active.json");
140
+ try {
141
+ mkdirSync(join(homedir(), ".pairshell"), { recursive: true });
142
+ writeFileSync(activeFile, JSON.stringify({ port: adminPort }), "utf8");
143
+ } catch {}
144
+
145
+ let tunnelProc = null;
146
+ let publicBase;
147
+
148
+ if (useTunnel) {
149
+ const prov = resolve(providerName);
150
+ const config = readConfig();
151
+ const check = prov.checkSetup(config);
152
+ if (!check.ok) {
153
+ fail(`${prov.label} is not configured: ${check.message}`);
154
+ console.log(`\n Run ${BOLD}pairshell-cli setup ${providerName}${RESET} first.\n`);
155
+ process.exit(1);
156
+ }
157
+
158
+ info(`Starting ${prov.label} tunnel ...`);
159
+ const result = await prov.startTunnel(boundPort, config);
160
+ publicBase = result.url;
161
+ tunnelProc = result.child;
162
+ ok(`${prov.label} tunnel established`);
163
+ } else {
164
+ publicBase = `http://${lanAddress()}:${boundPort}`;
165
+ info("Local-network mode (no tunnel)");
166
+ }
167
+
168
+ const token = pairing.create();
169
+ const wsUrl = toPairingUrl(publicBase, token);
170
+
171
+ header("Pairing Info");
172
+ kv([
173
+ ["Provider", useTunnel ? `${providerName} (outbound-only tunnel, no inbound ports)` : "local network"],
174
+ ["Working dir", process.cwd()],
175
+ ]);
176
+ printQr(wsUrl, token);
177
+
178
+ let pendingConnections = 0;
179
+
180
+ wss.on("connection", (ws, req) => {
181
+ const ip = req.socket.remoteAddress || "unknown";
182
+ info(`Incoming connection from ${ip}`);
183
+
184
+ const requestUrl = new URL(req.url, "http://localhost");
185
+ const queryToken = requestUrl.searchParams.get("token");
186
+ const querySession = requestUrl.searchParams.get("session");
187
+
188
+ if (pendingConnections >= MAX_PENDING_CONNECTIONS) {
189
+ ws.close(1013, "too many pending connections");
190
+ return;
191
+ }
192
+ pendingConnections++;
193
+
194
+ const authTimer = setTimeout(() => {
195
+ ws.close(4001, "auth timeout");
196
+ }, AUTH_TIMEOUT_MS);
197
+
198
+ let authed = false;
199
+
200
+ const authorize = (presented) => {
201
+ if (authed) return true;
202
+ const key = deriveKey(presented);
203
+
204
+ const sessionMeta = sessions.validate(presented);
205
+ if (sessionMeta) {
206
+ authed = true;
207
+ clearTimeout(authTimer);
208
+ pendingConnections = Math.max(0, pendingConnections - 1);
209
+ ok(`Device reconnected (session: ${sessionMeta.deviceId})`);
210
+ info("Opening terminal session ...");
211
+ bridgeSession(ws, { cols: 80, rows: 24, sharedKey: key });
212
+ return true;
213
+ }
214
+
215
+ if (!pairing.consume(presented)) {
216
+ warn(`Connection rejected: token ${presented ? presented.slice(0, 8) : "null"}... is invalid or already consumed.`);
217
+ ws.close(4001, "invalid or already-used pairing token");
218
+ return false;
219
+ }
220
+ authed = true;
221
+ clearTimeout(authTimer);
222
+ pendingConnections = Math.max(0, pendingConnections - 1);
223
+
224
+ const deviceId = randomBytes(8).toString("hex");
225
+ const sessionToken = sessions.create(deviceId);
226
+ try {
227
+ ws.send(JSON.stringify({ type: "paired", sessionToken, deviceId }));
228
+ } catch {}
229
+
230
+ ok("Device paired successfully");
231
+ info("Opening terminal session ...");
232
+ bridgeSession(ws, { cols: 80, rows: 24, sharedKey: key });
233
+ return true;
234
+ };
235
+
236
+ if (querySession && authorize(querySession)) return;
237
+ if (queryToken && authorize(queryToken)) return;
238
+
239
+ ws.on("message", async (message) => {
240
+ if (typeof message !== "string") return;
241
+ let control;
242
+ try {
243
+ control = JSON.parse(message);
244
+ } catch {
245
+ return;
246
+ }
247
+ if (!control || typeof control.type !== "string") return;
248
+
249
+ if (!authed) {
250
+ if (control.type === "auth" && typeof control.token === "string") {
251
+ authorize(control.token);
252
+ }
253
+ return;
254
+ }
255
+
256
+ // Handle File and Git requests once authenticated
257
+ try {
258
+ await handleControlMessage(ws, control, process.cwd());
259
+ } catch (err) {
260
+ warn(`Error handling control message ${control.type}: ${err.message}`);
261
+ }
262
+ });
263
+
264
+ ws.once("close", () => {
265
+ clearTimeout(authTimer);
266
+ if (!authed) pendingConnections = Math.max(0, pendingConnections - 1);
267
+ });
268
+ });
269
+
270
+ const shutdown = () => {
271
+ header("Shutting Down");
272
+ try { wss.close(); } catch {}
273
+ try { server.close(); } catch {}
274
+ try { adminServer.close(); } catch {}
275
+ if (tunnelProc) {
276
+ try { tunnelProc.kill("SIGTERM"); } catch {}
277
+ }
278
+ ok("PairShell stopped");
279
+ process.exit(0);
280
+ };
281
+
282
+ process.on("SIGINT", shutdown);
283
+ process.on("SIGTERM", shutdown);
284
+
285
+ if (process.stdin.isTTY) {
286
+ process.stdin.setRawMode(true);
287
+ process.stdin.on("data", (buf) => {
288
+ for (const byte of buf) {
289
+ if (byte === 0x03) {
290
+ shutdown();
291
+ return;
292
+ }
293
+ if (byte === 0x0d || byte === 0x0a) {
294
+ const newToken = pairing.create();
295
+ const newWsUrl = toPairingUrl(publicBase, newToken);
296
+ header("New Pairing Info");
297
+ printQr(newWsUrl, newToken);
298
+ }
299
+ }
300
+ });
301
+ }
302
+
303
+ console.log(`\n ${BOLD}Press Enter${RESET} for a fresh pairing code ${BOLD}Ctrl-C${RESET} to stop\n`);
304
+ }
305
+
306
+ async function handleControlMessage(ws, control, baseDir) {
307
+ switch (control.type) {
308
+ case "file_list_request": {
309
+ const relativePath = control.path || "./";
310
+ const targetPath = join(baseDir, relativePath);
311
+ try {
312
+ const entries = await fs.readdir(targetPath, { withFileTypes: true });
313
+ const files = entries
314
+ .filter(e => !e.name.startsWith(".") && e.name !== "node_modules" && e.name !== "build")
315
+ .map(e => ({
316
+ name: e.name,
317
+ isDirectory: e.isDirectory()
318
+ }));
319
+ ws.send(JSON.stringify({
320
+ type: "file_list_response",
321
+ path: relativePath,
322
+ files
323
+ }));
324
+ } catch (err) {
325
+ ws.send(JSON.stringify({
326
+ type: "file_list_response",
327
+ path: relativePath,
328
+ files: [],
329
+ error: err.message
330
+ }));
331
+ }
332
+ break;
333
+ }
334
+ case "file_read_request": {
335
+ const targetPath = join(baseDir, control.path);
336
+ try {
337
+ const content = await fs.readFile(targetPath, "utf8");
338
+ ws.send(JSON.stringify({
339
+ type: "file_read_response",
340
+ path: control.path,
341
+ content,
342
+ success: true
343
+ }));
344
+ } catch (err) {
345
+ ws.send(JSON.stringify({
346
+ type: "file_read_response",
347
+ path: control.path,
348
+ content: "",
349
+ success: false,
350
+ error: err.message
351
+ }));
352
+ }
353
+ break;
354
+ }
355
+ case "file_write_request": {
356
+ const targetPath = join(baseDir, control.path);
357
+ try {
358
+ const dir = join(targetPath, "..");
359
+ await fs.mkdir(dir, { recursive: true });
360
+ await fs.writeFile(targetPath, control.content, "utf8");
361
+ ws.send(JSON.stringify({
362
+ type: "file_write_response",
363
+ path: control.path,
364
+ success: true
365
+ }));
366
+ } catch (err) {
367
+ ws.send(JSON.stringify({
368
+ type: "file_write_response",
369
+ path: control.path,
370
+ success: false,
371
+ error: err.message
372
+ }));
373
+ }
374
+ break;
375
+ }
376
+ case "git_status_request": {
377
+ exec("git branch --show-current && git status -s", { cwd: baseDir }, (err, stdout) => {
378
+ if (err) {
379
+ ws.send(JSON.stringify({
380
+ type: "git_status_response",
381
+ branch: "unknown",
382
+ changes: [],
383
+ error: err.message
384
+ }));
385
+ return;
386
+ }
387
+ const lines = stdout.split("\n").filter(Boolean);
388
+ const branch = lines[0] || "main";
389
+ const changes = lines.slice(1).map(line => {
390
+ const typeChar = line.slice(0, 2).trim();
391
+ const file = line.slice(3).trim();
392
+ const type = typeChar === "??" ? "untracked" :
393
+ typeChar === "A" ? "added" :
394
+ typeChar === "D" ? "deleted" : "modified";
395
+ return { file, type, additions: 0, deletions: 0 };
396
+ });
397
+ ws.send(JSON.stringify({
398
+ type: "git_status_response",
399
+ branch,
400
+ changes
401
+ }));
402
+ });
403
+ break;
404
+ }
405
+ case "git_stage_request": {
406
+ const files = control.files || [];
407
+ const filesArg = files.includes("*") ? "." : files.map(f => `"${f}"`).join(" ");
408
+ exec(`git add ${filesArg}`, { cwd: baseDir }, (err) => {
409
+ ws.send(JSON.stringify({
410
+ type: "git_stage_response",
411
+ success: !err,
412
+ error: err ? err.message : null
413
+ }));
414
+ });
415
+ break;
416
+ }
417
+ case "git_commit_request": {
418
+ const message = control.message || "Commit from PairShell";
419
+ exec(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: baseDir }, (err, stdout) => {
420
+ const hash = !err ? (stdout.match(/\[\w+ ([a-f0-9]+)\]/) || [])[1] : null;
421
+ ws.send(JSON.stringify({
422
+ type: "git_commit_response",
423
+ success: !err,
424
+ hash,
425
+ error: err ? err.message : null
426
+ }));
427
+ });
428
+ break;
429
+ }
430
+ case "git_action_request": {
431
+ const action = control.action || "fetch";
432
+ if (!["fetch", "pull", "push"].includes(action)) {
433
+ ws.send(JSON.stringify({
434
+ type: "git_action_response",
435
+ action,
436
+ success: false,
437
+ error: "Invalid git action"
438
+ }));
439
+ break;
440
+ }
441
+ exec(`git ${action}`, { cwd: baseDir }, (err, stdout, stderr) => {
442
+ ws.send(JSON.stringify({
443
+ type: "git_action_response",
444
+ action,
445
+ success: !err,
446
+ logs: stdout + "\n" + stderr,
447
+ error: err ? err.message : null
448
+ }));
449
+ });
450
+ break;
451
+ }
452
+ }
453
+ }
package/src/status.js ADDED
@@ -0,0 +1,32 @@
1
+ import { readConfig, getProvider } from "./config.js";
2
+ import { resolve } from "./providers/index.js";
3
+ import { lanAddress } from "./net.js";
4
+ import { header, kv, info, BOLD, RESET } from "./format.js";
5
+
6
+ export function statusCommand() {
7
+ header("PairShell Status");
8
+
9
+ const config = readConfig();
10
+ const providerName = getProvider();
11
+ const prov = resolve(providerName);
12
+ const check = prov.checkSetup(config);
13
+
14
+ const items = [
15
+ ["Provider", prov.label],
16
+ ["Status", check.ok ? "configured" : `${BOLD}(not configured)${RESET}`],
17
+ ["LAN address", lanAddress()],
18
+ ["Config file", "~/.pairshell/config.json"],
19
+ ];
20
+
21
+ if (check.ok && check.message) {
22
+ items.splice(1, 0, ["Detail", check.message]);
23
+ }
24
+
25
+ kv(items);
26
+
27
+ if (!check.ok) {
28
+ console.log(`\n Run ${BOLD}pairshell-cli setup ${providerName}${RESET} to configure ${prov.label}.\n`);
29
+ } else {
30
+ console.log(`\n Run ${BOLD}pairshell-cli start${RESET} to begin.\n`);
31
+ }
32
+ }