linkshell-cli 0.1.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 (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +115 -0
  3. package/dist/cli/src/commands/doctor.d.ts +1 -0
  4. package/dist/cli/src/commands/doctor.js +112 -0
  5. package/dist/cli/src/commands/doctor.js.map +1 -0
  6. package/dist/cli/src/commands/setup.d.ts +1 -0
  7. package/dist/cli/src/commands/setup.js +48 -0
  8. package/dist/cli/src/commands/setup.js.map +1 -0
  9. package/dist/cli/src/config.d.ts +12 -0
  10. package/dist/cli/src/config.js +23 -0
  11. package/dist/cli/src/config.js.map +1 -0
  12. package/dist/cli/src/index.d.ts +2 -0
  13. package/dist/cli/src/index.js +108 -0
  14. package/dist/cli/src/index.js.map +1 -0
  15. package/dist/cli/src/providers.d.ts +12 -0
  16. package/dist/cli/src/providers.js +61 -0
  17. package/dist/cli/src/providers.js.map +1 -0
  18. package/dist/cli/src/runtime/bridge-session.d.ts +40 -0
  19. package/dist/cli/src/runtime/bridge-session.js +317 -0
  20. package/dist/cli/src/runtime/bridge-session.js.map +1 -0
  21. package/dist/cli/src/runtime/scrollback.d.ts +11 -0
  22. package/dist/cli/src/runtime/scrollback.js +33 -0
  23. package/dist/cli/src/runtime/scrollback.js.map +1 -0
  24. package/dist/cli/src/utils/lan-ip.d.ts +5 -0
  25. package/dist/cli/src/utils/lan-ip.js +20 -0
  26. package/dist/cli/src/utils/lan-ip.js.map +1 -0
  27. package/dist/cli/tsconfig.tsbuildinfo +1 -0
  28. package/dist/shared-protocol/src/index.d.ts +380 -0
  29. package/dist/shared-protocol/src/index.js +158 -0
  30. package/dist/shared-protocol/src/index.js.map +1 -0
  31. package/package.json +49 -0
  32. package/src/commands/doctor.ts +119 -0
  33. package/src/commands/setup.ts +65 -0
  34. package/src/config.ts +34 -0
  35. package/src/index.ts +139 -0
  36. package/src/providers.ts +91 -0
  37. package/src/runtime/bridge-session.ts +407 -0
  38. package/src/runtime/scrollback.ts +43 -0
  39. package/src/types/qrcode-terminal.d.ts +7 -0
  40. package/src/utils/lan-ip.ts +19 -0
@@ -0,0 +1,407 @@
1
+ import * as pty from "node-pty";
2
+ import WebSocket from "ws";
3
+ import { hostname } from "node:os";
4
+ import {
5
+ createEnvelope,
6
+ parseEnvelope,
7
+ parseTypedPayload,
8
+ serializeEnvelope,
9
+ PROTOCOL_VERSION,
10
+ } from "@linkshell/protocol";
11
+ import type { Envelope } from "@linkshell/protocol";
12
+ import type { ProviderConfig } from "../providers.js";
13
+ import { ScrollbackBuffer } from "./scrollback.js";
14
+
15
+ export interface BridgeSessionOptions {
16
+ gatewayUrl: string;
17
+ gatewayHttpUrl: string;
18
+ pairingGateway?: string;
19
+ sessionId?: string;
20
+ cols: number;
21
+ rows: number;
22
+ clientName: string;
23
+ verbose?: boolean;
24
+ providerConfig: ProviderConfig;
25
+ }
26
+
27
+ const HEARTBEAT_INTERVAL = 15_000;
28
+ const RECONNECT_BASE_DELAY = 1_000;
29
+ const RECONNECT_MAX_DELAY = 30_000;
30
+ const RECONNECT_MAX_ATTEMPTS = 20;
31
+
32
+ function getPairingGatewayParam(gatewayHttpUrl: string): string | undefined {
33
+ try {
34
+ const url = new URL(gatewayHttpUrl);
35
+ const hostname = url.hostname.trim().toLowerCase();
36
+ if (
37
+ hostname === "localhost" ||
38
+ hostname === "127.0.0.1" ||
39
+ hostname === "0.0.0.0" ||
40
+ hostname === "::1"
41
+ ) {
42
+ return undefined;
43
+ }
44
+ return url.toString().replace(/\/+$/, "");
45
+ } catch {
46
+ return gatewayHttpUrl.replace(/\/+$/, "") || undefined;
47
+ }
48
+ }
49
+
50
+ function resolvePairingGateway(
51
+ gatewayHttpUrl: string,
52
+ pairingGateway?: string,
53
+ ): string | undefined {
54
+ const override = pairingGateway?.trim();
55
+ if (!override) {
56
+ return getPairingGatewayParam(gatewayHttpUrl);
57
+ }
58
+
59
+ try {
60
+ const absoluteUrl = new URL(override);
61
+ return absoluteUrl.toString().replace(/\/+$/, "");
62
+ } catch {
63
+ try {
64
+ const baseUrl = new URL(gatewayHttpUrl);
65
+ const normalizedHost = override
66
+ .replace(/^https?:\/\//i, "")
67
+ .replace(/\/.*$/, "")
68
+ .trim();
69
+
70
+ if (!normalizedHost) {
71
+ return getPairingGatewayParam(gatewayHttpUrl);
72
+ }
73
+
74
+ baseUrl.hostname = normalizedHost;
75
+ return baseUrl.toString().replace(/\/+$/, "");
76
+ } catch {
77
+ return override.replace(/\/+$/, "") || undefined;
78
+ }
79
+ }
80
+ }
81
+
82
+ export class BridgeSession {
83
+ private readonly options: BridgeSessionOptions;
84
+ private socket: WebSocket | undefined;
85
+ private terminal: pty.IPty | undefined;
86
+ private outputSeq = 0;
87
+ private lastAckedSeq = -1;
88
+ private scrollback = new ScrollbackBuffer(1000);
89
+ private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
90
+ private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
91
+ private reconnectAttempts = 0;
92
+ private reconnecting = false;
93
+ private sessionId = "";
94
+ private exited = false;
95
+ private stopped = false;
96
+
97
+ constructor(options: BridgeSessionOptions) {
98
+ this.options = options;
99
+ this.sessionId = options.sessionId ?? "";
100
+ }
101
+
102
+ private log(msg: string): void {
103
+ if (this.options.verbose) {
104
+ process.stderr.write(`[bridge:verbose] ${msg}\n`);
105
+ }
106
+ }
107
+
108
+ async start(): Promise<void> {
109
+ this.log(`starting session (gateway=${this.options.gatewayUrl}, provider=${this.options.providerConfig.provider})`);
110
+ if (!this.sessionId) {
111
+ await this.createPairing();
112
+ }
113
+ this.spawnTerminal();
114
+ this.connectGateway();
115
+ }
116
+
117
+ private async createPairing(): Promise<void> {
118
+ const res = await fetch(`${this.options.gatewayHttpUrl}/pairings`, {
119
+ method: "POST",
120
+ headers: { "content-type": "application/json" },
121
+ body: JSON.stringify({}),
122
+ });
123
+ if (!res.ok) {
124
+ throw new Error(`Failed to create pairing: ${res.status}`);
125
+ }
126
+ const body = (await res.json()) as {
127
+ sessionId: string;
128
+ pairingCode: string;
129
+ expiresAt: string;
130
+ };
131
+ this.sessionId = body.sessionId;
132
+
133
+ const pairingGateway = resolvePairingGateway(
134
+ this.options.gatewayHttpUrl,
135
+ this.options.pairingGateway,
136
+ );
137
+ const deepLink = pairingGateway
138
+ ? `linkshell://pair?code=${body.pairingCode}&gateway=${encodeURIComponent(pairingGateway)}`
139
+ : `linkshell://pair?code=${body.pairingCode}`;
140
+
141
+ process.stderr.write(
142
+ `\n \x1b[1mPairing code: \x1b[36m${body.pairingCode}\x1b[0m\n`,
143
+ );
144
+ process.stderr.write(` Session: ${body.sessionId}\n`);
145
+ process.stderr.write(` Expires: ${body.expiresAt}\n\n`);
146
+ if (!pairingGateway) {
147
+ process.stderr.write(
148
+ " Note: QR will use the app's current gateway because the CLI is pointed at a local-only address.\n\n",
149
+ );
150
+ } else if (this.options.pairingGateway) {
151
+ process.stderr.write(` Pairing gateway: ${pairingGateway}\n\n`);
152
+ }
153
+
154
+ // Show QR code for mobile scanning
155
+ try {
156
+ const qrModule = await import("qrcode-terminal");
157
+ const qrDriver = qrModule.default ?? qrModule;
158
+ if (typeof qrDriver.generate === "function") {
159
+ qrDriver.generate(deepLink, { small: true }, (code: string) => {
160
+ process.stderr.write(` Scan to connect:\n`);
161
+ for (const line of code.split("\n")) {
162
+ process.stderr.write(` ${line}\n`);
163
+ }
164
+ process.stderr.write(`\n`);
165
+ });
166
+ }
167
+ } catch {
168
+ // qrcode-terminal not available, skip
169
+ }
170
+
171
+ process.stderr.write(` Deep link: ${deepLink}\n\n`);
172
+ }
173
+
174
+ private connectGateway(): void {
175
+ if (this.stopped) {
176
+ return;
177
+ }
178
+
179
+ const url = new URL(this.options.gatewayUrl);
180
+ url.searchParams.set("sessionId", this.sessionId);
181
+ url.searchParams.set("role", "host");
182
+
183
+ this.socket = new WebSocket(url);
184
+
185
+ this.socket.on("open", () => {
186
+ process.stderr.write(
187
+ this.reconnectAttempts > 0
188
+ ? "[bridge] gateway reconnected\n"
189
+ : "[bridge] gateway connected\n",
190
+ );
191
+ this.reconnectAttempts = 0;
192
+ this.reconnecting = false;
193
+ this.send(
194
+ createEnvelope({
195
+ type: "session.connect",
196
+ sessionId: this.sessionId,
197
+ payload: {
198
+ role: "host" as const,
199
+ clientName: this.options.clientName,
200
+ provider: this.options.providerConfig.provider,
201
+ protocolVersion: PROTOCOL_VERSION,
202
+ hostname: hostname(),
203
+ },
204
+ }),
205
+ );
206
+ this.startHeartbeat();
207
+ });
208
+
209
+ this.socket.on("message", (data) => {
210
+ const envelope = parseEnvelope(data.toString());
211
+ this.log(`recv ${envelope.type}${envelope.seq !== undefined ? ` seq=${envelope.seq}` : ""}`);
212
+ this.handleMessage(envelope);
213
+ });
214
+
215
+ this.socket.on("close", (code, reasonBuffer) => {
216
+ this.stopHeartbeat();
217
+ this.socket = undefined;
218
+ const reason = reasonBuffer.toString();
219
+ process.stderr.write(
220
+ `[bridge] gateway connection closed (code=${code}${reason ? `, reason=${reason}` : ""})\n`,
221
+ );
222
+ if (!this.exited) {
223
+ this.scheduleReconnect();
224
+ }
225
+ });
226
+
227
+ this.socket.on("error", (error) => {
228
+ process.stderr.write(`[bridge] gateway error: ${error.message}\n`);
229
+ });
230
+ }
231
+
232
+ private handleMessage(envelope: Envelope): void {
233
+ switch (envelope.type) {
234
+ case "terminal.input": {
235
+ const p = parseTypedPayload("terminal.input", envelope.payload);
236
+ this.terminal?.write(p.data);
237
+ break;
238
+ }
239
+ case "terminal.resize": {
240
+ const p = parseTypedPayload("terminal.resize", envelope.payload);
241
+ this.terminal?.resize(p.cols, p.rows);
242
+ break;
243
+ }
244
+ case "session.ack": {
245
+ const p = parseTypedPayload("session.ack", envelope.payload);
246
+ this.lastAckedSeq = Math.max(this.lastAckedSeq, p.seq);
247
+ this.scrollback.trimUpTo(this.lastAckedSeq);
248
+ break;
249
+ }
250
+ case "session.resume": {
251
+ const p = parseTypedPayload("session.resume", envelope.payload);
252
+ this.replayFrom(p.lastAckedSeq);
253
+ break;
254
+ }
255
+ case "session.heartbeat":
256
+ break;
257
+ default:
258
+ break;
259
+ }
260
+ }
261
+
262
+ private replayFrom(seq: number): void {
263
+ const messages = this.scrollback.replayFrom(seq);
264
+ for (const msg of messages) {
265
+ const payload = msg.payload as {
266
+ stream: string;
267
+ data: string;
268
+ encoding: string;
269
+ isReplay: boolean;
270
+ isFinal: boolean;
271
+ };
272
+ this.send(
273
+ createEnvelope({
274
+ type: "terminal.output",
275
+ sessionId: this.sessionId,
276
+ seq: msg.seq,
277
+ payload: { ...payload, isReplay: true },
278
+ }),
279
+ );
280
+ }
281
+ }
282
+
283
+ private spawnTerminal(): void {
284
+ // Filter out undefined env values — node-pty's native posix_spawnp chokes on them
285
+ const cleanEnv: Record<string, string> = {};
286
+ for (const [k, v] of Object.entries(this.options.providerConfig.env)) {
287
+ if (v !== undefined) cleanEnv[k] = v;
288
+ }
289
+
290
+ this.terminal = pty.spawn(
291
+ this.options.providerConfig.command,
292
+ this.options.providerConfig.args,
293
+ {
294
+ name: "xterm-256color",
295
+ cols: this.options.cols,
296
+ rows: this.options.rows,
297
+ cwd: process.cwd(),
298
+ env: cleanEnv,
299
+ },
300
+ );
301
+
302
+ this.terminal.onData((data) => {
303
+ const seq = this.outputSeq++;
304
+ const envelope = createEnvelope({
305
+ type: "terminal.output",
306
+ sessionId: this.sessionId,
307
+ seq,
308
+ payload: {
309
+ stream: "stdout" as const,
310
+ data,
311
+ encoding: "utf8" as const,
312
+ isReplay: false,
313
+ isFinal: false,
314
+ },
315
+ });
316
+ this.scrollback.push(envelope);
317
+ this.send(envelope);
318
+ });
319
+
320
+ this.terminal.onExit(({ exitCode, signal }) => {
321
+ this.exited = true;
322
+ const envelope = createEnvelope({
323
+ type: "terminal.exit",
324
+ sessionId: this.sessionId,
325
+ payload: { exitCode, signal },
326
+ });
327
+ this.send(envelope);
328
+ setTimeout(() => {
329
+ this.stopHeartbeat();
330
+ this.socket?.close();
331
+ }, 500);
332
+ process.exitCode = exitCode ?? 0;
333
+ });
334
+ }
335
+
336
+ private send(message: Envelope): void {
337
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
338
+ return;
339
+ }
340
+ this.socket.send(serializeEnvelope(message));
341
+ }
342
+
343
+ private startHeartbeat(): void {
344
+ this.stopHeartbeat();
345
+ this.heartbeatTimer = setInterval(() => {
346
+ this.send(
347
+ createEnvelope({
348
+ type: "session.heartbeat",
349
+ sessionId: this.sessionId,
350
+ payload: { ts: Date.now() },
351
+ }),
352
+ );
353
+ }, HEARTBEAT_INTERVAL);
354
+ }
355
+
356
+ private stopHeartbeat(): void {
357
+ if (this.heartbeatTimer) {
358
+ clearInterval(this.heartbeatTimer);
359
+ this.heartbeatTimer = undefined;
360
+ }
361
+ }
362
+
363
+ private scheduleReconnect(): void {
364
+ if (this.reconnecting || this.reconnectAttempts >= RECONNECT_MAX_ATTEMPTS) {
365
+ process.stderr.write(
366
+ "[bridge] max reconnect attempts reached, stopping bridge session\n",
367
+ );
368
+ this.stop(1);
369
+ return;
370
+ }
371
+ this.reconnecting = true;
372
+ const delay = Math.min(
373
+ RECONNECT_BASE_DELAY * 2 ** this.reconnectAttempts,
374
+ RECONNECT_MAX_DELAY,
375
+ );
376
+ this.reconnectAttempts++;
377
+ process.stderr.write(
378
+ `[bridge] reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})\n`,
379
+ );
380
+ this.reconnectTimer = setTimeout(() => {
381
+ this.reconnectTimer = undefined;
382
+ if (this.stopped || this.exited) {
383
+ return;
384
+ }
385
+ this.reconnecting = false;
386
+ this.connectGateway();
387
+ }, delay);
388
+ }
389
+
390
+ stop(exitCode = 0): void {
391
+ if (this.stopped) {
392
+ return;
393
+ }
394
+
395
+ this.stopped = true;
396
+ this.exited = true;
397
+ this.stopHeartbeat();
398
+ if (this.reconnectTimer) {
399
+ clearTimeout(this.reconnectTimer);
400
+ this.reconnectTimer = undefined;
401
+ }
402
+ this.socket?.close();
403
+ this.socket = undefined;
404
+ this.terminal?.kill();
405
+ process.exitCode = exitCode;
406
+ }
407
+ }
@@ -0,0 +1,43 @@
1
+ import type { Envelope } from "@linkshell/protocol";
2
+
3
+ export class ScrollbackBuffer {
4
+ private buffer: Envelope[] = [];
5
+ private readonly capacity: number;
6
+
7
+ constructor(capacity = 1000) {
8
+ this.capacity = capacity;
9
+ }
10
+
11
+ push(envelope: Envelope): void {
12
+ if (this.buffer.length >= this.capacity) {
13
+ this.buffer.shift();
14
+ }
15
+ this.buffer.push(envelope);
16
+ }
17
+
18
+ replayFrom(seq: number): Envelope[] {
19
+ return this.buffer.filter(
20
+ (e) => e.seq !== undefined && e.seq > seq,
21
+ );
22
+ }
23
+
24
+ trimUpTo(seq: number): void {
25
+ const idx = this.buffer.findIndex(
26
+ (e) => e.seq !== undefined && e.seq > seq,
27
+ );
28
+ if (idx > 0) {
29
+ this.buffer.splice(0, idx);
30
+ } else if (idx === -1) {
31
+ this.buffer = [];
32
+ }
33
+ }
34
+
35
+ get size(): number {
36
+ return this.buffer.length;
37
+ }
38
+
39
+ get lastSeq(): number {
40
+ const last = this.buffer[this.buffer.length - 1];
41
+ return last?.seq ?? -1;
42
+ }
43
+ }
@@ -0,0 +1,7 @@
1
+ declare module "qrcode-terminal" {
2
+ export function generate(
3
+ text: string,
4
+ options?: { small?: boolean },
5
+ callback?: (code: string) => void,
6
+ ): void;
7
+ }
@@ -0,0 +1,19 @@
1
+ import { networkInterfaces } from "node:os";
2
+
3
+ /**
4
+ * Get the first non-internal IPv4 address (LAN IP).
5
+ * Falls back to 127.0.0.1 if none found.
6
+ */
7
+ export function getLanIp(): string {
8
+ const nets = networkInterfaces();
9
+ for (const name of Object.keys(nets)) {
10
+ const addrs = nets[name];
11
+ if (!addrs) continue;
12
+ for (const addr of addrs) {
13
+ if (addr.family === "IPv4" && !addr.internal) {
14
+ return addr.address;
15
+ }
16
+ }
17
+ }
18
+ return "127.0.0.1";
19
+ }