lunel-cli 0.1.77 → 0.1.79

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 (2) hide show
  1. package/dist/index.js +243 -486
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -14,8 +14,6 @@ import { createServer, createConnection } from "net";
14
14
  import { createInterface } from "readline";
15
15
  const DEFAULT_PROXY_URL = normalizeGatewayUrl(process.env.LUNEL_PROXY_URL || "https://gateway.lunel.dev");
16
16
  const MANAGER_URL = normalizeGatewayUrl(process.env.LUNEL_MANAGER_URL || "https://manager.lunel.dev");
17
- const CLOUD_JOB_CONFIG_PATH = "/etc/lunel/job.json";
18
- const VM_HEARTBEAT_INTERVAL_MS = 10_000;
19
17
  const CLI_ARGS = process.argv.slice(2);
20
18
  import { createRequire } from "module";
21
19
  const __require = createRequire(import.meta.url);
@@ -58,7 +56,6 @@ let aiManager = null;
58
56
  let currentSessionCode = null;
59
57
  let currentSessionPassword = null;
60
58
  let currentPrimaryGateway = DEFAULT_PROXY_URL;
61
- let currentBackupGateway = null;
62
59
  let activeGatewayUrl = DEFAULT_PROXY_URL;
63
60
  let shuttingDown = false;
64
61
  let activeControlWs = null;
@@ -70,51 +67,6 @@ function logWithTimestamp(scope, message, fields) {
70
67
  }
71
68
  const activeTunnels = new Map();
72
69
  const PORT_SYNC_INTERVAL_MS = 30_000;
73
- let cloudJobConfig = null;
74
- let vmHeartbeatTimer = null;
75
- async function readCloudJobConfig() {
76
- try {
77
- const data = await fs.readFile(CLOUD_JOB_CONFIG_PATH, "utf-8");
78
- return JSON.parse(data);
79
- }
80
- catch {
81
- return null;
82
- }
83
- }
84
- function startVmHeartbeat(resumeToken) {
85
- if (!cloudJobConfig?.session_code)
86
- return;
87
- if (vmHeartbeatTimer)
88
- return;
89
- const cfg = cloudJobConfig;
90
- const sendHeartbeat = () => {
91
- if (shuttingDown)
92
- return;
93
- fetch(`${MANAGER_URL}/v1/vm/heartbeat`, {
94
- method: "POST",
95
- headers: { "Content-Type": "application/json" },
96
- body: JSON.stringify({
97
- sandboxId: cfg.sandbox_id,
98
- resumeToken,
99
- sandmanUrl: cfg.sandman_url || "",
100
- repoUrl: cfg.repo_url,
101
- branch: cfg.branch,
102
- vmProfile: cfg.vm_profile || "",
103
- }),
104
- }).catch((err) => {
105
- if (!shuttingDown)
106
- console.warn("[vm] heartbeat failed:", err.message);
107
- });
108
- };
109
- sendHeartbeat();
110
- vmHeartbeatTimer = setInterval(sendHeartbeat, VM_HEARTBEAT_INTERVAL_MS);
111
- }
112
- function stopVmHeartbeat() {
113
- if (vmHeartbeatTimer) {
114
- clearInterval(vmHeartbeatTimer);
115
- vmHeartbeatTimer = null;
116
- }
117
- }
118
70
  const CLI_LOCAL_TCP_CONNECT_TIMEOUT_MS = 2_500;
119
71
  const PROXY_WS_CONNECT_TIMEOUT_MS = 12_000;
120
72
  const TUNNEL_SETUP_BUDGET_MS = 18_000;
@@ -260,7 +212,6 @@ function parseExtraPortsFromArgs(args) {
260
212
  }
261
213
  const EXTRA_PORTS = parseExtraPortsFromArgs(CLI_ARGS);
262
214
  const SCAN_PORTS = Array.from(new Set([...DEV_PORTS, ...EXTRA_PORTS])).sort((a, b) => a - b);
263
- const RUN_ABCD_MODE = CLI_ARGS.includes("--run-abcd");
264
215
  function samePortSet(a, b) {
265
216
  if (a.length !== b.length)
266
217
  return false;
@@ -340,45 +291,19 @@ function generatePersistentSecret(length) {
340
291
  }
341
292
  return out;
342
293
  }
343
- function normalizePairingRoot(input) {
344
- try {
345
- return fssync.realpathSync(input);
346
- }
347
- catch {
348
- return path.resolve(input);
349
- }
350
- }
351
294
  async function readCliConfig() {
352
295
  try {
353
296
  const raw = await fs.readFile(CLI_CONFIG_PATH, "utf-8");
354
297
  const parsed = JSON.parse(raw);
355
- const pairings = Array.isArray(parsed.pairings)
356
- ? parsed.pairings
357
- .filter((entry) => {
358
- return Boolean(entry &&
359
- typeof entry.secret === "string" &&
360
- typeof entry.root === "string" &&
361
- typeof entry.hostname === "string" &&
362
- typeof entry.phoneId === "string" &&
363
- typeof entry.pairedAt === "number" &&
364
- typeof entry.lastUsedAt === "number");
365
- })
366
- .map((entry) => ({
367
- ...entry,
368
- root: normalizePairingRoot(entry.root),
369
- }))
370
- : [];
371
298
  return {
372
299
  version: 1,
373
300
  deviceId: typeof parsed.deviceId === "string" && parsed.deviceId ? parsed.deviceId : generatePersistentSecret(32),
374
- pairings,
375
301
  };
376
302
  }
377
303
  catch {
378
304
  return {
379
305
  version: 1,
380
306
  deviceId: generatePersistentSecret(32),
381
- pairings: [],
382
307
  };
383
308
  }
384
309
  }
@@ -393,42 +318,6 @@ async function getCliConfig() {
393
318
  }
394
319
  return await cliConfigPromise;
395
320
  }
396
- async function persistCliConfig(mutator) {
397
- const current = await getCliConfig();
398
- const next = mutator({
399
- version: 1,
400
- deviceId: current.deviceId,
401
- pairings: [...current.pairings],
402
- });
403
- cliConfigPromise = Promise.resolve(next);
404
- await writeCliConfig(next);
405
- return next;
406
- }
407
- async function getSavedPairingForRoot(root) {
408
- const normalizedRoot = normalizePairingRoot(root);
409
- const config = await getCliConfig();
410
- const matches = config.pairings
411
- .filter((entry) => entry.root === normalizedRoot)
412
- .sort((a, b) => b.lastUsedAt - a.lastUsedAt);
413
- return matches[0] || null;
414
- }
415
- async function savePairing(pairing) {
416
- await persistCliConfig((config) => {
417
- const deduped = config.pairings.filter((entry) => entry.secret !== pairing.secret);
418
- deduped.push(pairing);
419
- deduped.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
420
- return {
421
- ...config,
422
- pairings: deduped.slice(0, 50),
423
- };
424
- });
425
- }
426
- async function removePairing(secret) {
427
- await persistCliConfig((config) => ({
428
- ...config,
429
- pairings: config.pairings.filter((entry) => entry.secret !== secret),
430
- }));
431
- }
432
321
  // ============================================================================
433
322
  // File System Handlers
434
323
  // ============================================================================
@@ -2323,12 +2212,7 @@ async function processMessage(message) {
2323
2212
  result = handleSystemPing();
2324
2213
  break;
2325
2214
  case "pairDevice": {
2326
- const phoneId = typeof payload.phoneId === "string" ? payload.phoneId.trim() : "";
2327
- if (!phoneId) {
2328
- throw Object.assign(new Error("phoneId is required"), { code: "EINVAL" });
2329
- }
2330
- result = { ...(await registerPersistentPairing(phoneId)) };
2331
- break;
2215
+ throw Object.assign(new Error("pairDevice is no longer supported"), { code: "EINVAL" });
2332
2216
  }
2333
2217
  default:
2334
2218
  throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
@@ -2651,124 +2535,75 @@ function normalizeGatewayUrl(input) {
2651
2535
  const path = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, "");
2652
2536
  return `${parsed.protocol}//${parsed.host}${path}`;
2653
2537
  }
2654
- async function createSessionFromManager(opts = {}) {
2655
- const response = await fetch(`${MANAGER_URL}/v1/session`, {
2656
- method: "POST",
2657
- headers: { "Content-Type": "application/json" },
2658
- body: JSON.stringify(opts.requestedCode ? { requestedCode: opts.requestedCode } : {}),
2659
- });
2538
+ async function createQrCode() {
2539
+ const response = await fetch(`${MANAGER_URL}/v2/qr`);
2660
2540
  if (!response.ok) {
2661
- throw new Error(`Failed to create session from manager: ${response.status}`);
2541
+ throw new Error(`Failed to create QR code from manager: ${response.status}`);
2662
2542
  }
2663
2543
  return (await response.json());
2664
2544
  }
2665
- async function connectToCloudSession(sessionCode) {
2666
- const response = await fetch(`${MANAGER_URL}/v1/session/resolve`, {
2667
- method: "POST",
2668
- headers: { "Content-Type": "application/json" },
2669
- body: JSON.stringify({ code: sessionCode }),
2670
- });
2671
- if (!response.ok) {
2672
- throw new Error(`Failed to look up cloud session ${sessionCode}: ${response.status}`);
2673
- }
2674
- const snapshot = await response.json();
2675
- if (!snapshot.exists || !snapshot.valid || !snapshot.primary || !snapshot.resumeToken || !snapshot.code) {
2676
- throw new Error(`Failed to look up cloud session ${sessionCode}: invalid or expired session`);
2677
- }
2678
- return {
2679
- sessionId: snapshot.sessionId || "",
2680
- code: snapshot.code,
2681
- password: snapshot.resumeToken,
2682
- primary: snapshot.primary,
2683
- backup: snapshot.backup ?? null,
2684
- state: snapshot.state,
2685
- expiresAt: snapshot.expiresAt,
2686
- };
2687
- }
2688
- async function resolveSessionByResumeToken(resumeToken) {
2689
- const response = await fetch(`${MANAGER_URL}/v1/session/resolve`, {
2690
- method: "POST",
2691
- headers: { "Content-Type": "application/json" },
2692
- body: JSON.stringify({ resumeToken }),
2545
+ async function assembleWithCode(code) {
2546
+ const wsUrl = `${MANAGER_URL.replace(/^https:/, "wss:")}/v2/assemble?code=${encodeURIComponent(code)}&role=cli`;
2547
+ return await new Promise((resolve, reject) => {
2548
+ const ws = new WebSocket(wsUrl);
2549
+ let settled = false;
2550
+ const fail = (error) => {
2551
+ if (settled)
2552
+ return;
2553
+ settled = true;
2554
+ try {
2555
+ ws.close();
2556
+ }
2557
+ catch {
2558
+ // ignore
2559
+ }
2560
+ reject(error);
2561
+ };
2562
+ ws.on("message", (data) => {
2563
+ try {
2564
+ const parsed = JSON.parse(data.toString());
2565
+ if (parsed.type !== "assembled" || typeof parsed.code !== "string" || typeof parsed.password !== "string") {
2566
+ fail(new Error("Invalid assemble payload"));
2567
+ return;
2568
+ }
2569
+ if (settled)
2570
+ return;
2571
+ settled = true;
2572
+ ws.send(JSON.stringify({ type: "ack" }));
2573
+ resolve({ code: parsed.code, password: parsed.password });
2574
+ }
2575
+ catch (error) {
2576
+ fail(error instanceof Error ? error : new Error(String(error)));
2577
+ }
2578
+ });
2579
+ ws.on("close", (codeValue, reason) => {
2580
+ if (settled)
2581
+ return;
2582
+ fail(new Error(`Assemble socket closed (${codeValue}: ${reason.toString()})`));
2583
+ });
2584
+ ws.on("error", (error) => {
2585
+ fail(new Error(`Assemble socket error: ${error.message}`));
2586
+ });
2693
2587
  });
2694
- if (!response.ok) {
2695
- throw new Error(`Failed to resolve session from manager: ${response.status}`);
2696
- }
2697
- const snapshot = await response.json();
2698
- if (!snapshot.exists || !snapshot.valid || !snapshot.primary || !snapshot.resumeToken || !snapshot.code) {
2699
- if (snapshot.reason === "session_finalized" || snapshot.reason === "not_found") {
2700
- return null;
2701
- }
2702
- if (snapshot.state === "ended" || snapshot.state === "expired") {
2703
- return null;
2704
- }
2705
- throw new Error(`Session resolve returned invalid snapshot (${snapshot.reason || "unknown"})`);
2706
- }
2707
- return {
2708
- sessionId: snapshot.sessionId || "",
2709
- code: snapshot.code || "",
2710
- password: snapshot.resumeToken,
2711
- primary: snapshot.primary,
2712
- backup: snapshot.backup ?? null,
2713
- state: snapshot.state,
2714
- expiresAt: snapshot.expiresAt,
2715
- };
2716
2588
  }
2717
- async function registerPersistentPairing(phoneId) {
2718
- if (!currentSessionPassword) {
2719
- throw new Error("No active session secret available for pairing");
2720
- }
2721
- const config = await getCliConfig();
2722
- const response = await fetch(`${MANAGER_URL}/v1/pairings/register`, {
2723
- method: "POST",
2724
- headers: { "Content-Type": "application/json" },
2725
- body: JSON.stringify({
2726
- activeSecret: currentSessionPassword,
2727
- phoneId,
2728
- pcId: config.deviceId,
2729
- root: ROOT_DIR,
2730
- hostname: os.hostname(),
2731
- }),
2732
- });
2589
+ async function getAssignedProxyUrl(password) {
2590
+ const url = new URL("/v2/proxy", MANAGER_URL);
2591
+ url.searchParams.set("password", password);
2592
+ const response = await fetch(url);
2733
2593
  if (!response.ok) {
2734
- throw new Error(`Failed to register persistent pairing (${response.status})`);
2594
+ throw new Error(`Failed to get proxy from manager: ${response.status}`);
2735
2595
  }
2736
2596
  const payload = await response.json();
2737
- if (typeof payload.secret !== "string" ||
2738
- typeof payload.hostname !== "string" ||
2739
- typeof payload.root !== "string" ||
2740
- typeof payload.phoneId !== "string" ||
2741
- typeof payload.pairedAt !== "number" ||
2742
- typeof payload.lastUsedAt !== "number") {
2743
- throw new Error("Manager returned invalid pairing response");
2744
- }
2745
- const normalized = {
2746
- secret: payload.secret,
2747
- hostname: payload.hostname,
2748
- root: normalizePairingRoot(payload.root),
2749
- phoneId: payload.phoneId,
2750
- pairedAt: payload.pairedAt,
2751
- lastUsedAt: payload.lastUsedAt,
2752
- };
2753
- await savePairing({
2754
- secret: normalized.secret,
2755
- root: normalized.root,
2756
- hostname: normalized.hostname,
2757
- phoneId: normalized.phoneId,
2758
- pairedAt: normalized.pairedAt,
2759
- lastUsedAt: normalized.lastUsedAt,
2760
- });
2761
- return normalized;
2597
+ if (typeof payload.proxyUrl !== "string" || !payload.proxyUrl) {
2598
+ throw new Error("Manager returned invalid proxy assignment");
2599
+ }
2600
+ return normalizeGatewayUrl(payload.proxyUrl);
2762
2601
  }
2763
- function displayQR(primaryGateway, backupGateway, code) {
2602
+ function displayQR(code) {
2764
2603
  console.log("\n");
2765
2604
  qrcode.generate(code, { small: true }, (qr) => {
2766
2605
  console.log(qr);
2767
2606
  console.log(`\n Session code: ${code}\n`);
2768
- console.log(` Primary gateway: ${primaryGateway}`);
2769
- if (backupGateway) {
2770
- console.log(` Backup gateway: ${backupGateway}`);
2771
- }
2772
2607
  console.log(` Root directory: ${ROOT_DIR}\n`);
2773
2608
  console.log(" Scan the QR code with the Lunel app to connect.");
2774
2609
  console.log(" Press Ctrl+C to exit.\n");
@@ -2783,14 +2618,8 @@ function buildWsUrl(gatewayUrl, role, channel) {
2783
2618
  if (currentSessionPassword) {
2784
2619
  query.set("password", currentSessionPassword);
2785
2620
  }
2786
- else if (currentSessionCode) {
2787
- query.set("code", currentSessionCode);
2788
- if (currentBackupGateway) {
2789
- query.set("backup", currentBackupGateway);
2790
- }
2791
- }
2792
2621
  else {
2793
- throw new Error("missing code/password for websocket connect");
2622
+ throw new Error("missing password for websocket connect");
2794
2623
  }
2795
2624
  return `${wsBase}/v1/ws/${role}/${channel}?${query.toString()}`;
2796
2625
  }
@@ -2798,7 +2627,6 @@ function gracefulShutdown() {
2798
2627
  shuttingDown = true;
2799
2628
  console.log("\nShutting down...");
2800
2629
  void aiManager?.destroy();
2801
- stopVmHeartbeat();
2802
2630
  stopPortSync();
2803
2631
  if (ptyProcess) {
2804
2632
  ptyProcess.kill();
@@ -2820,212 +2648,195 @@ function gracefulShutdown() {
2820
2648
  process.exit(0);
2821
2649
  }
2822
2650
  async function connectWebSocket() {
2823
- const gateways = currentBackupGateway ? [currentPrimaryGateway, currentBackupGateway] : [currentPrimaryGateway];
2824
- const uniqueGateways = Array.from(new Set(gateways));
2825
- for (const gatewayUrl of uniqueGateways) {
2826
- try {
2827
- await new Promise((resolve, reject) => {
2828
- activeGatewayUrl = gatewayUrl;
2829
- const controlUrl = buildWsUrl(gatewayUrl, "cli", "control");
2830
- const dataUrl = buildWsUrl(gatewayUrl, "cli", "data");
2831
- console.log(`Connecting to gateway ${gatewayUrl}...`);
2832
- const controlWs = new WebSocket(controlUrl);
2833
- const dataWs = new WebSocket(dataUrl);
2834
- activeControlWs = controlWs;
2835
- activeDataWs = dataWs;
2836
- dataChannel = dataWs;
2837
- let controlConnected = false;
2838
- let dataConnected = false;
2839
- let settled = false;
2840
- let closeHandled = false;
2841
- let closeReason = "";
2842
- const failConnection = (reason) => {
2843
- if (settled)
2651
+ const gatewayUrl = currentPrimaryGateway;
2652
+ await new Promise((resolve, reject) => {
2653
+ activeGatewayUrl = gatewayUrl;
2654
+ const controlUrl = buildWsUrl(gatewayUrl, "cli", "control");
2655
+ const dataUrl = buildWsUrl(gatewayUrl, "cli", "data");
2656
+ console.log(`Connecting to gateway ${gatewayUrl}...`);
2657
+ const controlWs = new WebSocket(controlUrl);
2658
+ const dataWs = new WebSocket(dataUrl);
2659
+ activeControlWs = controlWs;
2660
+ activeDataWs = dataWs;
2661
+ dataChannel = dataWs;
2662
+ let controlConnected = false;
2663
+ let dataConnected = false;
2664
+ let settled = false;
2665
+ let closeHandled = false;
2666
+ let closeReason = "";
2667
+ const failConnection = (reason) => {
2668
+ if (settled)
2669
+ return;
2670
+ settled = true;
2671
+ reject(new Error(reason));
2672
+ };
2673
+ const checkFullyConnected = () => {
2674
+ if (controlConnected && dataConnected && !settled) {
2675
+ settled = true;
2676
+ console.log("Connected to gateway (control + data channels).\n");
2677
+ resolve();
2678
+ }
2679
+ };
2680
+ const handleClose = (reason) => {
2681
+ if (closeHandled || shuttingDown)
2682
+ return;
2683
+ closeHandled = true;
2684
+ closeReason = reason;
2685
+ stopPortSync();
2686
+ cleanupAllTunnels();
2687
+ setTimeout(() => {
2688
+ if (shuttingDown)
2689
+ return;
2690
+ void handleConnectionDrop(closeReason);
2691
+ }, 50);
2692
+ };
2693
+ controlWs.on("open", () => {
2694
+ controlConnected = true;
2695
+ checkFullyConnected();
2696
+ });
2697
+ controlWs.on("message", async (data) => {
2698
+ try {
2699
+ const message = JSON.parse(data.toString());
2700
+ if ("type" in message) {
2701
+ if (message.type === "connected")
2844
2702
  return;
2845
- settled = true;
2846
- reject(new Error(reason));
2847
- };
2848
- const checkFullyConnected = () => {
2849
- if (controlConnected && dataConnected && !settled) {
2850
- settled = true;
2851
- console.log("Connected to gateway (control + data channels).\n");
2852
- resolve();
2853
- }
2854
- };
2855
- const handleClose = (reason) => {
2856
- if (closeHandled || shuttingDown)
2703
+ if (message.type === "peer_connected") {
2704
+ console.log("App connected!\n");
2705
+ startPortSync(controlWs);
2857
2706
  return;
2858
- closeHandled = true;
2859
- closeReason = reason;
2860
- stopPortSync();
2861
- cleanupAllTunnels();
2862
- setTimeout(() => {
2863
- if (shuttingDown)
2864
- return;
2865
- void handleConnectionDrop(closeReason);
2866
- }, 50);
2867
- };
2868
- controlWs.on("open", () => {
2869
- controlConnected = true;
2870
- checkFullyConnected();
2871
- });
2872
- controlWs.on("message", async (data) => {
2873
- try {
2874
- const message = JSON.parse(data.toString());
2875
- if ("type" in message) {
2876
- if (message.type === "connected")
2877
- return;
2878
- if (message.type === "session_password" && message.password) {
2879
- if (!currentSessionPassword)
2880
- resetReplayBuffer(); // new session
2881
- currentSessionPassword = message.password;
2882
- console.log("[session] received reconnect password");
2883
- return;
2884
- }
2885
- if (message.type === "peer_connected") {
2886
- console.log("App connected!\n");
2887
- startPortSync(controlWs);
2888
- return;
2889
- }
2890
- if (message.type === "peer_disconnected") {
2891
- console.log("App disconnected. Waiting for reconnect window.\n");
2892
- stopPortSync();
2893
- return;
2894
- }
2895
- if (message.type === "app_disconnected") {
2896
- if (message.reconnectDeadline) {
2897
- console.log(`[session] app disconnected, waiting until ${new Date(message.reconnectDeadline).toISOString()}`);
2898
- }
2899
- return;
2900
- }
2901
- if (message.type === "close_connection") {
2902
- const reason = message.reason || "expired";
2903
- console.log(`[session] closed by gateway: ${reason}`);
2904
- if (reason === "session ended from app") {
2905
- console.log("[session] Run `npx lunel-cli` again and scan the new QR code to reconnect.");
2906
- }
2907
- gracefulShutdown();
2908
- return;
2909
- }
2910
- }
2911
- if (isProtocolResponse(message)) {
2912
- // Ignore server/app responses forwarded over WS; CLI only processes requests.
2913
- return;
2914
- }
2915
- if (isProtocolRequest(message)) {
2916
- const response = await processMessage(message);
2917
- sendResponseOnData(response, dataWs);
2918
- return;
2919
- }
2920
- console.warn("[router] Ignoring non-request control frame");
2921
- }
2922
- catch (error) {
2923
- console.error("Error processing control message:", error);
2924
2707
  }
2925
- });
2926
- controlWs.on("close", (code, reason) => {
2927
- if (!settled) {
2928
- failConnection(`control close before ready (${code}: ${reason.toString()})`);
2708
+ if (message.type === "peer_disconnected") {
2709
+ console.log("App disconnected. Waiting for reconnect window.\n");
2710
+ stopPortSync();
2929
2711
  return;
2930
2712
  }
2931
- handleClose(`control closed (${code}: ${reason.toString()})`);
2932
- });
2933
- controlWs.on("error", (error) => {
2934
- if (!settled) {
2935
- failConnection(`control ws error: ${error.message}`);
2713
+ if (message.type === "app_disconnected") {
2714
+ if (message.reconnectDeadline) {
2715
+ console.log(`[session] app disconnected, waiting until ${new Date(message.reconnectDeadline).toISOString()}`);
2716
+ }
2936
2717
  return;
2937
2718
  }
2938
- console.error("Control WebSocket error:", error.message);
2939
- });
2940
- dataWs.on("open", () => {
2941
- dataConnected = true;
2942
- checkFullyConnected();
2943
- });
2944
- dataWs.on("message", async (data) => {
2945
- try {
2946
- const raw = JSON.parse(data.toString());
2947
- if (raw.type === "connected")
2948
- return;
2949
- // E2EE handshake messages (always plaintext)
2950
- if (raw.type === "e2ee_hello" && typeof raw.pubkey === "string") {
2951
- e2eeHandlePeerHello(raw.pubkey, dataWs);
2952
- return;
2953
- }
2954
- if (raw.type === "e2ee_secure_ready") {
2955
- e2eeHandlePeerReady();
2956
- return;
2957
- }
2958
- // Reconnect request: reset E2EE so fresh handshake happens, then replay
2959
- if (raw.ns === "system" && raw.action === "reconnect") {
2960
- e2eeReset();
2961
- const lastSeq = Number(raw.payload?.lastSeq ?? 0);
2962
- const toReplay = replayBuffer.filter((e) => e.seq > lastSeq);
2963
- console.log(`[replay] replaying ${toReplay.length} messages after seq ${lastSeq}`);
2964
- // Replay without encryption — E2EE handshake hasn't completed yet
2965
- for (const entry of toReplay)
2966
- dataWs.send(JSON.stringify(entry.msg));
2967
- return;
2968
- }
2969
- // Decrypt payload if E2EE is active
2970
- let message = raw;
2971
- if (e2eeActive && typeof raw.enc === "string") {
2972
- try {
2973
- message = { ...raw, payload: e2eeDecrypt(raw.enc) };
2974
- delete message.enc;
2975
- }
2976
- catch (decErr) {
2977
- console.error("[e2ee] decryption failed:", decErr.message);
2978
- return;
2979
- }
2980
- }
2981
- if (isProtocolResponse(message)) {
2982
- // Ignore server/app responses forwarded over WS; CLI only processes requests.
2983
- return;
2719
+ if (message.type === "close_connection") {
2720
+ const reason = message.reason || "expired";
2721
+ console.log(`[session] closed by gateway: ${reason}`);
2722
+ if (reason === "session ended from app") {
2723
+ console.log("[session] Run `npx lunel-cli` again and scan the new QR code to reconnect.");
2984
2724
  }
2985
- if (isProtocolRequest(message)) {
2986
- const response = await processMessage(message);
2987
- sendResponseOnData(response, dataWs);
2988
- return;
2989
- }
2990
- console.warn("[router] Ignoring non-request data frame");
2991
- }
2992
- catch (error) {
2993
- console.error("Error processing data message:", error);
2994
- }
2995
- });
2996
- dataWs.on("close", (code, reason) => {
2997
- // Reset backpressure state so reconnect starts fresh
2998
- dataChannelPaused = false;
2999
- if (dataChannelDrainTimer) {
3000
- clearInterval(dataChannelDrainTimer);
3001
- dataChannelDrainTimer = null;
3002
- }
3003
- if (!settled) {
3004
- failConnection(`data close before ready (${code}: ${reason.toString()})`);
2725
+ gracefulShutdown();
3005
2726
  return;
3006
2727
  }
3007
- handleClose(`data closed (${code}: ${reason.toString()})`);
3008
- });
3009
- dataWs.on("error", (error) => {
3010
- if (!settled) {
3011
- failConnection(`data ws error: ${error.message}`);
3012
- return;
2728
+ }
2729
+ if (isProtocolResponse(message)) {
2730
+ // Ignore server/app responses forwarded over WS; CLI only processes requests.
2731
+ return;
2732
+ }
2733
+ if (isProtocolRequest(message)) {
2734
+ const response = await processMessage(message);
2735
+ sendResponseOnData(response, dataWs);
2736
+ return;
2737
+ }
2738
+ console.warn("[router] Ignoring non-request control frame");
2739
+ }
2740
+ catch (error) {
2741
+ console.error("Error processing control message:", error);
2742
+ }
2743
+ });
2744
+ controlWs.on("close", (code, reason) => {
2745
+ if (!settled) {
2746
+ failConnection(`control close before ready (${code}: ${reason.toString()})`);
2747
+ return;
2748
+ }
2749
+ handleClose(`control closed (${code}: ${reason.toString()})`);
2750
+ });
2751
+ controlWs.on("error", (error) => {
2752
+ if (!settled) {
2753
+ failConnection(`control ws error: ${error.message}`);
2754
+ return;
2755
+ }
2756
+ console.error("Control WebSocket error:", error.message);
2757
+ });
2758
+ dataWs.on("open", () => {
2759
+ dataConnected = true;
2760
+ checkFullyConnected();
2761
+ });
2762
+ dataWs.on("message", async (data) => {
2763
+ try {
2764
+ const raw = JSON.parse(data.toString());
2765
+ if (raw.type === "connected")
2766
+ return;
2767
+ // E2EE handshake messages (always plaintext)
2768
+ if (raw.type === "e2ee_hello" && typeof raw.pubkey === "string") {
2769
+ e2eeHandlePeerHello(raw.pubkey, dataWs);
2770
+ return;
2771
+ }
2772
+ if (raw.type === "e2ee_secure_ready") {
2773
+ e2eeHandlePeerReady();
2774
+ return;
2775
+ }
2776
+ // Reconnect request: reset E2EE so fresh handshake happens, then replay
2777
+ if (raw.ns === "system" && raw.action === "reconnect") {
2778
+ e2eeReset();
2779
+ const lastSeq = Number(raw.payload?.lastSeq ?? 0);
2780
+ const toReplay = replayBuffer.filter((e) => e.seq > lastSeq);
2781
+ console.log(`[replay] replaying ${toReplay.length} messages after seq ${lastSeq}`);
2782
+ // Replay without encryption — E2EE handshake hasn't completed yet
2783
+ for (const entry of toReplay)
2784
+ dataWs.send(JSON.stringify(entry.msg));
2785
+ return;
2786
+ }
2787
+ // Decrypt payload if E2EE is active
2788
+ let message = raw;
2789
+ if (e2eeActive && typeof raw.enc === "string") {
2790
+ try {
2791
+ message = { ...raw, payload: e2eeDecrypt(raw.enc) };
2792
+ delete message.enc;
3013
2793
  }
3014
- console.error("Data WebSocket error:", error.message);
3015
- });
3016
- setTimeout(() => {
3017
- if (!settled) {
3018
- failConnection("connection timeout");
2794
+ catch (decErr) {
2795
+ console.error("[e2ee] decryption failed:", decErr.message);
2796
+ return;
3019
2797
  }
3020
- }, 10000);
3021
- });
3022
- return;
3023
- }
3024
- catch (err) {
3025
- console.error(`[gateway] failed ${gatewayUrl}: ${err.message}`);
3026
- }
3027
- }
3028
- throw new Error("unable to connect to any gateway");
2798
+ }
2799
+ if (isProtocolResponse(message)) {
2800
+ // Ignore server/app responses forwarded over WS; CLI only processes requests.
2801
+ return;
2802
+ }
2803
+ if (isProtocolRequest(message)) {
2804
+ const response = await processMessage(message);
2805
+ sendResponseOnData(response, dataWs);
2806
+ return;
2807
+ }
2808
+ console.warn("[router] Ignoring non-request data frame");
2809
+ }
2810
+ catch (error) {
2811
+ console.error("Error processing data message:", error);
2812
+ }
2813
+ });
2814
+ dataWs.on("close", (code, reason) => {
2815
+ // Reset backpressure state so reconnect starts fresh
2816
+ dataChannelPaused = false;
2817
+ if (dataChannelDrainTimer) {
2818
+ clearInterval(dataChannelDrainTimer);
2819
+ dataChannelDrainTimer = null;
2820
+ }
2821
+ if (!settled) {
2822
+ failConnection(`data close before ready (${code}: ${reason.toString()})`);
2823
+ return;
2824
+ }
2825
+ handleClose(`data closed (${code}: ${reason.toString()})`);
2826
+ });
2827
+ dataWs.on("error", (error) => {
2828
+ if (!settled) {
2829
+ failConnection(`data ws error: ${error.message}`);
2830
+ return;
2831
+ }
2832
+ console.error("Data WebSocket error:", error.message);
2833
+ });
2834
+ setTimeout(() => {
2835
+ if (!settled) {
2836
+ failConnection("connection timeout");
2837
+ }
2838
+ }, 10000);
2839
+ });
3029
2840
  }
3030
2841
  async function handleConnectionDrop(reason) {
3031
2842
  if (shuttingDown)
@@ -3042,16 +2853,7 @@ async function handleConnectionDrop(reason) {
3042
2853
  const base = Math.min(250 * 2 ** (attempt - 1), 30_000);
3043
2854
  const delayMs = Math.round(base * (0.8 + Math.random() * 0.4));
3044
2855
  try {
3045
- const resolved = await resolveSessionByResumeToken(currentSessionPassword);
3046
- if (!resolved) {
3047
- console.error("[reconnect] session no longer exists or is finalized");
3048
- gracefulShutdown();
3049
- return;
3050
- }
3051
- currentSessionCode = resolved.code;
3052
- currentSessionPassword = resolved.password;
3053
- currentPrimaryGateway = normalizeGatewayUrl(resolved.primary);
3054
- currentBackupGateway = resolved.backup ? normalizeGatewayUrl(resolved.backup) : null;
2856
+ currentPrimaryGateway = await getAssignedProxyUrl(currentSessionPassword);
3055
2857
  await connectWebSocket();
3056
2858
  console.log(`[reconnect] connected via ${activeGatewayUrl}`);
3057
2859
  return;
@@ -3068,14 +2870,6 @@ async function main() {
3068
2870
  if (EXTRA_PORTS.length > 0) {
3069
2871
  console.log(`Extra ports enabled: ${EXTRA_PORTS.join(", ")}`);
3070
2872
  }
3071
- // Detect cloud VM mode
3072
- cloudJobConfig = await readCloudJobConfig();
3073
- if (cloudJobConfig?.session_code) {
3074
- console.log(`[cloud] Running in VM mode (sandbox: ${cloudJobConfig.sandbox_id})`);
3075
- }
3076
- if (RUN_ABCD_MODE) {
3077
- console.log("[review] Running in pinned review session mode (code: abcd)");
3078
- }
3079
2873
  try {
3080
2874
  await getCliConfig();
3081
2875
  console.log("Checking PTY runtime...");
@@ -3096,52 +2890,15 @@ async function main() {
3096
2890
  checkDataChannelBackpressure();
3097
2891
  }
3098
2892
  });
3099
- let session;
3100
- if (RUN_ABCD_MODE) {
3101
- session = await createSessionFromManager({ requestedCode: "abcd" });
3102
- console.log(`[review] Attached to session ${session.code} via ${session.primary}`);
3103
- }
3104
- else if (cloudJobConfig?.session_code) {
3105
- // Cloud mode: connect to an existing session assigned by the manager
3106
- session = await connectToCloudSession(cloudJobConfig.session_code);
3107
- console.log(`[cloud] Connected to session ${session.code} via ${session.primary}`);
3108
- }
3109
- else {
3110
- const savedPairing = await getSavedPairingForRoot(ROOT_DIR);
3111
- if (savedPairing) {
3112
- try {
3113
- const resolved = await resolveSessionByResumeToken(savedPairing.secret);
3114
- if (resolved) {
3115
- session = resolved;
3116
- console.log(`[pairing] Waiting on saved connection for ${savedPairing.hostname}`);
3117
- }
3118
- else {
3119
- await removePairing(savedPairing.secret);
3120
- session = await createSessionFromManager();
3121
- displayQR(normalizeGatewayUrl(session.primary), session.backup ? normalizeGatewayUrl(session.backup) : null, session.code);
3122
- }
3123
- }
3124
- catch {
3125
- await removePairing(savedPairing.secret);
3126
- session = await createSessionFromManager();
3127
- displayQR(normalizeGatewayUrl(session.primary), session.backup ? normalizeGatewayUrl(session.backup) : null, session.code);
3128
- }
3129
- }
3130
- else {
3131
- // Public mode: create a new session and show QR code
3132
- session = await createSessionFromManager();
3133
- displayQR(normalizeGatewayUrl(session.primary), session.backup ? normalizeGatewayUrl(session.backup) : null, session.code);
3134
- }
3135
- }
3136
- currentPrimaryGateway = normalizeGatewayUrl(session.primary);
3137
- currentBackupGateway = session.backup ? normalizeGatewayUrl(session.backup) : null;
3138
- currentSessionPassword = session.password;
2893
+ const qr = await createQrCode();
2894
+ currentSessionCode = qr.code;
2895
+ displayQR(qr.code);
2896
+ const assembled = await assembleWithCode(qr.code);
2897
+ resetReplayBuffer();
2898
+ currentSessionCode = assembled.code;
2899
+ currentSessionPassword = assembled.password;
2900
+ currentPrimaryGateway = await getAssignedProxyUrl(assembled.password);
3139
2901
  activeGatewayUrl = currentPrimaryGateway;
3140
- currentSessionCode = session.code;
3141
- // Start VM heartbeat in cloud mode (session.password = resumeToken)
3142
- if (cloudJobConfig?.session_code) {
3143
- startVmHeartbeat(session.password);
3144
- }
3145
2902
  await connectWebSocket();
3146
2903
  }
3147
2904
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lunel-cli",
3
- "version": "0.1.77",
3
+ "version": "0.1.79",
4
4
  "author": [
5
5
  {
6
6
  "name": "Soham Bharambe",