linkshell-cli 0.2.58 → 0.2.60

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/index.ts CHANGED
@@ -5,6 +5,9 @@ import { resolveProviderConfig } from "./providers.js";
5
5
  import { loadConfig } from "./config.js";
6
6
  import { runDoctor } from "./commands/doctor.js";
7
7
  import { runSetup } from "./commands/setup.js";
8
+ import { runUpgrade } from "./commands/upgrade.js";
9
+ import { runLogin } from "./commands/login.js";
10
+ import { runLogout } from "./commands/logout.js";
8
11
  import { getLanIp } from "./utils/lan-ip.js";
9
12
 
10
13
  import { createRequire } from "node:module";
@@ -158,6 +161,13 @@ program
158
161
  // Save PID for status/stop
159
162
  daemon.savePid("bridge", process.pid);
160
163
 
164
+ // Load auth token if logged in
165
+ let authToken: string | undefined;
166
+ try {
167
+ const { getValidToken } = await import("./auth.js");
168
+ authToken = (await getValidToken()) ?? undefined;
169
+ } catch {}
170
+
161
171
  const session = new BridgeSession({
162
172
  gatewayUrl,
163
173
  gatewayHttpUrl,
@@ -170,6 +180,7 @@ program
170
180
  verbose: Boolean(options.verbose),
171
181
  screen: Boolean(options.screen),
172
182
  providerConfig,
183
+ authToken,
173
184
  });
174
185
 
175
186
  const cleanup = async () => {
@@ -352,6 +363,204 @@ program
352
363
  await runSetup();
353
364
  });
354
365
 
366
+ program
367
+ .command("upgrade")
368
+ .description("Upgrade LinkShell to the latest version")
369
+ .action(async () => {
370
+ await runUpgrade();
371
+ });
372
+
373
+ program
374
+ .command("login")
375
+ .description("Log in to LinkShell (enables premium gateway)")
376
+ .action(async () => {
377
+ const result = await runLogin();
378
+ if (!result) return;
379
+
380
+ if (result.plan !== "pro") {
381
+ process.stderr.write(
382
+ " Upgrade to Pro for official gateway access: https://itool.tech\n\n",
383
+ );
384
+ return;
385
+ }
386
+
387
+ // Pro user — fetch official gateways and offer to connect
388
+ const { SUPABASE_URL, SUPABASE_ANON_KEY } = await import("./auth.js");
389
+ let gateways: { url: string; name: string; region: string | null }[] = [];
390
+ try {
391
+ const res = await fetch(
392
+ `${SUPABASE_URL}/rest/v1/linkshell_official_gateways?enabled=eq.true&select=url,name,region`,
393
+ {
394
+ headers: {
395
+ Authorization: `Bearer ${result.accessToken}`,
396
+ apikey: SUPABASE_ANON_KEY,
397
+ },
398
+ signal: AbortSignal.timeout(10_000),
399
+ },
400
+ );
401
+ if (res.ok) {
402
+ gateways = (await res.json()) as typeof gateways;
403
+ }
404
+ } catch {}
405
+
406
+ if (gateways.length === 0) {
407
+ process.stderr.write(" No official gateways available yet.\n\n");
408
+ return;
409
+ }
410
+
411
+ process.stderr.write(" Available gateways:\n\n");
412
+ for (let i = 0; i < gateways.length; i++) {
413
+ const gw = gateways[i]!;
414
+ const label = gw.region ? `${gw.name} (${gw.region})` : gw.name;
415
+ process.stderr.write(` [${i + 1}] ${label} ${gw.url}\n`);
416
+ }
417
+ process.stderr.write(` [0] Skip\n\n`);
418
+
419
+ const { createInterface } = await import("node:readline");
420
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
421
+ const answer = await new Promise<string>((res) => {
422
+ rl.question(" Connect to gateway [1]: ", (ans) => {
423
+ rl.close();
424
+ res(ans.trim());
425
+ });
426
+ });
427
+
428
+ if (answer === "0") return;
429
+
430
+ const idx = (answer === "" ? 1 : Number(answer)) - 1;
431
+ if (idx < 0 || idx >= gateways.length || Number.isNaN(idx)) {
432
+ process.stderr.write(" Invalid choice.\n\n");
433
+ return;
434
+ }
435
+
436
+ const chosen = gateways[idx]!;
437
+ const gwWsUrl = chosen.url.replace(/\/$/, "") + "/ws";
438
+
439
+ process.stderr.write(`\n Connecting to ${chosen.name}...\n`);
440
+
441
+ // Start daemon with the chosen gateway
442
+ const daemon = await import("./utils/daemon.js");
443
+ const existingPid = daemon.readPid("bridge");
444
+ if (existingPid) {
445
+ process.stderr.write(
446
+ ` Bridge already running (PID ${existingPid}). Run: linkshell stop\n\n`,
447
+ );
448
+ return;
449
+ }
450
+
451
+ const childArgs = [
452
+ "start",
453
+ "--_foreground-bridge",
454
+ "--gateway", gwWsUrl,
455
+ "--provider", config.provider ?? "claude",
456
+ "--client-name", config.clientName ?? "local-cli",
457
+ "--cols", String(config.cols ?? 120),
458
+ "--rows", String(config.rows ?? 36),
459
+ ];
460
+
461
+ const pid = daemon.spawnDaemon("bridge", childArgs);
462
+ process.stderr.write(`\n \x1b[32m✓\x1b[0m Bridge started in background (PID ${pid})\n`);
463
+ process.stderr.write(` Gateway: ${chosen.name}\n`);
464
+ process.stderr.write(` Open the LinkShell app on your phone to connect.\n\n`);
465
+ process.stderr.write(` Stop: linkshell stop\n`);
466
+ process.stderr.write(` Status: linkshell status\n`);
467
+ process.stderr.write(` Logs: tail -f ${daemon.getLogFile("bridge")}\n\n`);
468
+ });
469
+
470
+ program
471
+ .command("logout")
472
+ .description("Log out of LinkShell")
473
+ .action(() => {
474
+ runLogout();
475
+ });
476
+
477
+ program
478
+ .command("list")
479
+ .description("List your sessions on official gateways")
480
+ .action(async () => {
481
+ const { getValidToken, loadAuth, SUPABASE_URL, SUPABASE_ANON_KEY } =
482
+ await import("./auth.js");
483
+ const token = await getValidToken();
484
+ if (!token) {
485
+ process.stderr.write(
486
+ "\n Not logged in. Run: linkshell login\n\n",
487
+ );
488
+ return;
489
+ }
490
+
491
+ const auth = loadAuth();
492
+ process.stderr.write(`\n Logged in as ${auth?.email || auth?.userId || "unknown"}\n\n`);
493
+
494
+ // Fetch official gateways
495
+ let gateways: { url: string; name: string; region: string | null }[] = [];
496
+ try {
497
+ const res = await fetch(
498
+ `${SUPABASE_URL}/rest/v1/linkshell_official_gateways?enabled=eq.true&select=url,name,region`,
499
+ {
500
+ headers: {
501
+ Authorization: `Bearer ${token}`,
502
+ apikey: SUPABASE_ANON_KEY,
503
+ },
504
+ signal: AbortSignal.timeout(10_000),
505
+ },
506
+ );
507
+ if (res.ok) {
508
+ gateways = (await res.json()) as typeof gateways;
509
+ }
510
+ } catch {}
511
+
512
+ if (gateways.length === 0) {
513
+ process.stderr.write(" No official gateways available.\n\n");
514
+ return;
515
+ }
516
+
517
+ process.stderr.write(" Official Gateways:\n\n");
518
+
519
+ for (const gw of gateways) {
520
+ const label = gw.region ? `${gw.name} (${gw.region})` : gw.name;
521
+ // Fetch user's sessions on this gateway
522
+ let sessions: {
523
+ id: string;
524
+ provider: string | null;
525
+ projectName: string | null;
526
+ hasHost: boolean;
527
+ lastActivity: number;
528
+ }[] = [];
529
+ try {
530
+ const res = await fetch(`${gw.url}/sessions/mine`, {
531
+ headers: { Authorization: `Bearer ${token}` },
532
+ signal: AbortSignal.timeout(5_000),
533
+ });
534
+ if (res.ok) {
535
+ const body = (await res.json()) as { sessions: typeof sessions };
536
+ sessions = body.sessions;
537
+ }
538
+ } catch {}
539
+
540
+ process.stderr.write(` \x1b[32m✓\x1b[0m ${label}\n`);
541
+ process.stderr.write(` ${gw.url}\n`);
542
+
543
+ if (sessions.length === 0) {
544
+ process.stderr.write(" (no active sessions)\n\n");
545
+ } else {
546
+ for (const s of sessions) {
547
+ const ago = Math.round((Date.now() - s.lastActivity) / 60_000);
548
+ const agoStr = ago < 1 ? "just now" : `${ago}m ago`;
549
+ const info = [s.provider, s.projectName].filter(Boolean).join(" · ");
550
+ const hostIcon = s.hasHost ? "\x1b[32m●\x1b[0m" : "\x1b[31m●\x1b[0m";
551
+ process.stderr.write(
552
+ ` └ ${hostIcon} ${s.id.slice(0, 8)} — ${info || "unknown"} · ${agoStr}\n`,
553
+ );
554
+ }
555
+ process.stderr.write("\n");
556
+ }
557
+ }
558
+
559
+ process.stderr.write(
560
+ " Connect: linkshell start --gateway <url> --provider claude\n\n",
561
+ );
562
+ });
563
+
355
564
  program.parseAsync(process.argv).catch((error: unknown) => {
356
565
  const message = error instanceof Error ? error.message : String(error);
357
566
  process.stderr.write(`${message}\n`);
@@ -31,6 +31,7 @@ export interface BridgeSessionOptions {
31
31
  verbose?: boolean;
32
32
  screen?: boolean;
33
33
  providerConfig: ProviderConfig;
34
+ authToken?: string;
34
35
  }
35
36
 
36
37
  const HEARTBEAT_INTERVAL = 15_000;
@@ -158,9 +159,13 @@ export class BridgeSession {
158
159
  }
159
160
 
160
161
  private async createPairing(): Promise<void> {
162
+ const headers: Record<string, string> = { "content-type": "application/json" };
163
+ if (this.options.authToken) {
164
+ headers["Authorization"] = `Bearer ${this.options.authToken}`;
165
+ }
161
166
  const res = await fetch(`${this.options.gatewayHttpUrl}/pairings`, {
162
167
  method: "POST",
163
- headers: { "content-type": "application/json" },
168
+ headers,
164
169
  body: JSON.stringify({}),
165
170
  });
166
171
  if (!res.ok) {
@@ -222,6 +227,9 @@ export class BridgeSession {
222
227
  const url = new URL(this.options.gatewayUrl);
223
228
  url.searchParams.set("sessionId", this.sessionId);
224
229
  url.searchParams.set("role", "host");
230
+ if (this.options.authToken) {
231
+ url.searchParams.set("auth_token", this.options.authToken);
232
+ }
225
233
 
226
234
  this.socket = new WebSocket(url);
227
235
 
@@ -401,6 +409,42 @@ export class BridgeSession {
401
409
  this.sendTerminalList();
402
410
  break;
403
411
  }
412
+ case "terminal.history.request": {
413
+ const p = parseTypedPayload("terminal.history.request", envelope.payload);
414
+ const count = p.count ?? 100;
415
+ let entries: string[] = [];
416
+ let shell = "unknown";
417
+ try {
418
+ const home = homedir();
419
+ // Try zsh first, then bash
420
+ const histFiles = [
421
+ { path: join(home, ".zsh_history"), shell: "zsh" },
422
+ { path: join(home, ".bash_history"), shell: "bash" },
423
+ ];
424
+ for (const hf of histFiles) {
425
+ if (existsSync(hf.path)) {
426
+ const raw = readFileSync(hf.path, "utf8");
427
+ const lines = raw.split("\n").filter(Boolean);
428
+ // zsh history lines may start with ": <timestamp>:0;" — strip prefix
429
+ const parsed = lines.map((l) => {
430
+ const m = l.match(/^:\s*\d+:\d+;(.*)$/);
431
+ return m ? m[1]! : l;
432
+ });
433
+ // Deduplicate and take last N
434
+ const unique = [...new Set(parsed.reverse())].slice(0, count).reverse();
435
+ entries = unique;
436
+ shell = hf.shell;
437
+ break;
438
+ }
439
+ }
440
+ } catch {}
441
+ this.send(createEnvelope({
442
+ type: "terminal.history.response",
443
+ sessionId: this.sessionId,
444
+ payload: { entries, shell },
445
+ }));
446
+ break;
447
+ }
404
448
  case "session.ack": {
405
449
  const p = parseTypedPayload("session.ack", envelope.payload);
406
450
  const term = this.terminals.get(tid);
@@ -270,7 +270,7 @@ export class ScreenShare {
270
270
 
271
271
  // RTP state
272
272
  let seqNum = 0;
273
- let timestamp = 0;
273
+ let timestamp = -Math.floor(90000 / fps); // so first bump yields 0
274
274
  const clockRate = 90000;
275
275
  const frameDuration = Math.floor(clockRate / fps);
276
276
  const ssrc = (Math.random() * 0xffffffff) >>> 0;
@@ -350,7 +350,7 @@ export class ScreenShare {
350
350
  }
351
351
  };
352
352
 
353
- const extractNalUnits = () => {
353
+ const extractNalUnits = (flush = false) => {
354
354
  // Find NAL unit boundaries (0x00000001 or 0x000001)
355
355
  const units: Buffer[] = [];
356
356
  let start = -1;
@@ -373,11 +373,22 @@ export class ScreenShare {
373
373
  }
374
374
  }
375
375
 
376
- if (start >= 0 && units.length > 0) {
376
+ // On flush (ffmpeg exit), emit the trailing NAL unit too
377
+ if (flush && start >= 0) {
378
+ units.push(nalBuffer.subarray(start));
379
+ nalBuffer = Buffer.alloc(0);
380
+ return units;
381
+ }
382
+
383
+ if (start >= 0) {
377
384
  // Keep remaining data from last start code onward
378
385
  nalBuffer = nalBuffer.subarray(start);
379
- } else if (start < 0) {
380
- // No start code found yet, keep accumulating
386
+ }
387
+
388
+ // Safety cap: if buffer grows beyond 4MB without producing NALs, discard
389
+ if (nalBuffer.length > 4 * 1024 * 1024 && units.length === 0) {
390
+ process.stderr.write(`[screen-share] NAL buffer exceeded 4MB, resetting\n`);
391
+ nalBuffer = Buffer.alloc(0);
381
392
  }
382
393
 
383
394
  return units;
@@ -401,6 +412,11 @@ export class ScreenShare {
401
412
  });
402
413
 
403
414
  this.ffmpeg.on("exit", (code) => {
415
+ // Flush any remaining NAL unit in the buffer
416
+ const remaining = extractNalUnits(true);
417
+ for (const unit of remaining) {
418
+ sendNalUnit(unit);
419
+ }
404
420
  if (this.active) {
405
421
  process.stderr.write(`[screen-share] ffmpeg exited with code ${code}\n`);
406
422
  this.stop();