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/dist/cli/src/auth.d.ts +23 -0
- package/dist/cli/src/auth.js +90 -0
- package/dist/cli/src/auth.js.map +1 -0
- package/dist/cli/src/commands/login.d.ts +17 -0
- package/dist/cli/src/commands/login.js +143 -0
- package/dist/cli/src/commands/login.js.map +1 -0
- package/dist/cli/src/commands/logout.d.ts +1 -0
- package/dist/cli/src/commands/logout.js +11 -0
- package/dist/cli/src/commands/logout.js.map +1 -0
- package/dist/cli/src/commands/upgrade.d.ts +1 -0
- package/dist/cli/src/commands/upgrade.js +70 -0
- package/dist/cli/src/commands/upgrade.js.map +1 -0
- package/dist/cli/src/index.js +168 -0
- package/dist/cli/src/index.js.map +1 -1
- package/dist/cli/src/runtime/bridge-session.d.ts +1 -0
- package/dist/cli/src/runtime/bridge-session.js +45 -1
- package/dist/cli/src/runtime/bridge-session.js.map +1 -1
- package/dist/cli/src/runtime/screen-share.js +18 -5
- package/dist/cli/src/runtime/screen-share.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +42 -8
- package/dist/shared-protocol/src/index.js +10 -0
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +1 -1
- package/src/auth.ts +110 -0
- package/src/commands/login.ts +192 -0
- package/src/commands/logout.ts +12 -0
- package/src/commands/upgrade.ts +74 -0
- package/src/index.ts +209 -0
- package/src/runtime/bridge-session.ts +45 -1
- package/src/runtime/screen-share.ts +21 -5
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
|
|
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
|
-
|
|
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
|
-
}
|
|
380
|
-
|
|
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();
|