linkshell-cli 0.2.57 → 0.2.59

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,114 @@ 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
+ await runLogin();
378
+ });
379
+
380
+ program
381
+ .command("logout")
382
+ .description("Log out of LinkShell")
383
+ .action(() => {
384
+ runLogout();
385
+ });
386
+
387
+ program
388
+ .command("list")
389
+ .description("List your sessions on official gateways")
390
+ .action(async () => {
391
+ const { getValidToken, loadAuth, SUPABASE_URL, SUPABASE_ANON_KEY } =
392
+ await import("./auth.js");
393
+ const token = await getValidToken();
394
+ if (!token) {
395
+ process.stderr.write(
396
+ "\n Not logged in. Run: linkshell login\n\n",
397
+ );
398
+ return;
399
+ }
400
+
401
+ const auth = loadAuth();
402
+ process.stderr.write(`\n Logged in as ${auth?.email || auth?.userId || "unknown"}\n\n`);
403
+
404
+ // Fetch official gateways
405
+ let gateways: { url: string; name: string; region: string | null }[] = [];
406
+ try {
407
+ const res = await fetch(
408
+ `${SUPABASE_URL}/rest/v1/linkshell_official_gateways?enabled=eq.true&select=url,name,region`,
409
+ {
410
+ headers: {
411
+ Authorization: `Bearer ${token}`,
412
+ apikey: SUPABASE_ANON_KEY,
413
+ },
414
+ signal: AbortSignal.timeout(10_000),
415
+ },
416
+ );
417
+ if (res.ok) {
418
+ gateways = (await res.json()) as typeof gateways;
419
+ }
420
+ } catch {}
421
+
422
+ if (gateways.length === 0) {
423
+ process.stderr.write(" No official gateways available.\n\n");
424
+ return;
425
+ }
426
+
427
+ process.stderr.write(" Official Gateways:\n\n");
428
+
429
+ for (const gw of gateways) {
430
+ const label = gw.region ? `${gw.name} (${gw.region})` : gw.name;
431
+ // Fetch user's sessions on this gateway
432
+ let sessions: {
433
+ id: string;
434
+ provider: string | null;
435
+ projectName: string | null;
436
+ hasHost: boolean;
437
+ lastActivity: number;
438
+ }[] = [];
439
+ try {
440
+ const res = await fetch(`${gw.url}/sessions/mine`, {
441
+ headers: { Authorization: `Bearer ${token}` },
442
+ signal: AbortSignal.timeout(5_000),
443
+ });
444
+ if (res.ok) {
445
+ const body = (await res.json()) as { sessions: typeof sessions };
446
+ sessions = body.sessions;
447
+ }
448
+ } catch {}
449
+
450
+ process.stderr.write(` \x1b[32m✓\x1b[0m ${label}\n`);
451
+ process.stderr.write(` ${gw.url}\n`);
452
+
453
+ if (sessions.length === 0) {
454
+ process.stderr.write(" (no active sessions)\n\n");
455
+ } else {
456
+ for (const s of sessions) {
457
+ const ago = Math.round((Date.now() - s.lastActivity) / 60_000);
458
+ const agoStr = ago < 1 ? "just now" : `${ago}m ago`;
459
+ const info = [s.provider, s.projectName].filter(Boolean).join(" · ");
460
+ const hostIcon = s.hasHost ? "\x1b[32m●\x1b[0m" : "\x1b[31m●\x1b[0m";
461
+ process.stderr.write(
462
+ ` └ ${hostIcon} ${s.id.slice(0, 8)} — ${info || "unknown"} · ${agoStr}\n`,
463
+ );
464
+ }
465
+ process.stderr.write("\n");
466
+ }
467
+ }
468
+
469
+ process.stderr.write(
470
+ " Connect: linkshell start --gateway <url> --provider claude\n\n",
471
+ );
472
+ });
473
+
355
474
  program.parseAsync(process.argv).catch((error: unknown) => {
356
475
  const message = error instanceof Error ? error.message : String(error);
357
476
  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);
@@ -609,13 +653,14 @@ export class BridgeSession {
609
653
 
610
654
  localWs.on("close", (code, reason) => {
611
655
  this.tunnelSockets.delete(requestId);
656
+ const safeCode = typeof code === "number" && code >= 1000 && code <= 4999 ? code : 1000;
612
657
  this.send(
613
658
  createEnvelope({
614
659
  type: "tunnel.ws.close",
615
660
  sessionId: this.sessionId,
616
661
  payload: {
617
662
  requestId,
618
- code,
663
+ code: safeCode,
619
664
  reason: reason?.toString() || "",
620
665
  },
621
666
  }),
@@ -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();