lunel-cli 0.1.78 → 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 +242 -568
  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,157 +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
- currentSessionPassword = normalized.secret;
2762
- return normalized;
2763
- }
2764
- async function lookupPersistentPairings() {
2765
- const config = await getCliConfig();
2766
- const response = await fetch(`${MANAGER_URL}/v1/pairings/lookup`, {
2767
- method: "POST",
2768
- headers: { "Content-Type": "application/json" },
2769
- body: JSON.stringify({
2770
- pcId: config.deviceId,
2771
- root: ROOT_DIR,
2772
- }),
2773
- });
2774
- if (!response.ok) {
2775
- throw new Error(`Failed to look up persistent pairings (${response.status})`);
2597
+ if (typeof payload.proxyUrl !== "string" || !payload.proxyUrl) {
2598
+ throw new Error("Manager returned invalid proxy assignment");
2776
2599
  }
2777
- const payload = await response.json();
2778
- const pairings = Array.isArray(payload.pairings) ? payload.pairings : [];
2779
- return pairings
2780
- .filter((entry) => (entry &&
2781
- typeof entry.secret === "string" &&
2782
- typeof entry.hostname === "string" &&
2783
- typeof entry.root === "string" &&
2784
- typeof entry.phoneId === "string" &&
2785
- typeof entry.pairedAt === "number" &&
2786
- typeof entry.lastUsedAt === "number"))
2787
- .map((entry) => ({
2788
- secret: entry.secret,
2789
- root: normalizePairingRoot(entry.root),
2790
- hostname: entry.hostname,
2791
- phoneId: entry.phoneId,
2792
- pairedAt: entry.pairedAt,
2793
- lastUsedAt: entry.lastUsedAt,
2794
- }));
2600
+ return normalizeGatewayUrl(payload.proxyUrl);
2795
2601
  }
2796
- function displayQR(primaryGateway, backupGateway, code) {
2602
+ function displayQR(code) {
2797
2603
  console.log("\n");
2798
2604
  qrcode.generate(code, { small: true }, (qr) => {
2799
2605
  console.log(qr);
2800
2606
  console.log(`\n Session code: ${code}\n`);
2801
- console.log(` Primary gateway: ${primaryGateway}`);
2802
- if (backupGateway) {
2803
- console.log(` Backup gateway: ${backupGateway}`);
2804
- }
2805
2607
  console.log(` Root directory: ${ROOT_DIR}\n`);
2806
2608
  console.log(" Scan the QR code with the Lunel app to connect.");
2807
2609
  console.log(" Press Ctrl+C to exit.\n");
@@ -2816,14 +2618,8 @@ function buildWsUrl(gatewayUrl, role, channel) {
2816
2618
  if (currentSessionPassword) {
2817
2619
  query.set("password", currentSessionPassword);
2818
2620
  }
2819
- else if (currentSessionCode) {
2820
- query.set("code", currentSessionCode);
2821
- if (currentBackupGateway) {
2822
- query.set("backup", currentBackupGateway);
2823
- }
2824
- }
2825
2621
  else {
2826
- throw new Error("missing code/password for websocket connect");
2622
+ throw new Error("missing password for websocket connect");
2827
2623
  }
2828
2624
  return `${wsBase}/v1/ws/${role}/${channel}?${query.toString()}`;
2829
2625
  }
@@ -2831,7 +2627,6 @@ function gracefulShutdown() {
2831
2627
  shuttingDown = true;
2832
2628
  console.log("\nShutting down...");
2833
2629
  void aiManager?.destroy();
2834
- stopVmHeartbeat();
2835
2630
  stopPortSync();
2836
2631
  if (ptyProcess) {
2837
2632
  ptyProcess.kill();
@@ -2853,216 +2648,195 @@ function gracefulShutdown() {
2853
2648
  process.exit(0);
2854
2649
  }
2855
2650
  async function connectWebSocket() {
2856
- const gateways = currentBackupGateway ? [currentPrimaryGateway, currentBackupGateway] : [currentPrimaryGateway];
2857
- const uniqueGateways = Array.from(new Set(gateways));
2858
- for (const gatewayUrl of uniqueGateways) {
2859
- try {
2860
- await new Promise((resolve, reject) => {
2861
- activeGatewayUrl = gatewayUrl;
2862
- const controlUrl = buildWsUrl(gatewayUrl, "cli", "control");
2863
- const dataUrl = buildWsUrl(gatewayUrl, "cli", "data");
2864
- console.log(`Connecting to gateway ${gatewayUrl}...`);
2865
- const controlWs = new WebSocket(controlUrl);
2866
- const dataWs = new WebSocket(dataUrl);
2867
- activeControlWs = controlWs;
2868
- activeDataWs = dataWs;
2869
- dataChannel = dataWs;
2870
- let controlConnected = false;
2871
- let dataConnected = false;
2872
- let settled = false;
2873
- let closeHandled = false;
2874
- let closeReason = "";
2875
- const failConnection = (reason) => {
2876
- 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")
2877
2702
  return;
2878
- settled = true;
2879
- reject(new Error(reason));
2880
- };
2881
- const checkFullyConnected = () => {
2882
- if (controlConnected && dataConnected && !settled) {
2883
- settled = true;
2884
- console.log("Connected to gateway (control + data channels).\n");
2885
- resolve();
2886
- }
2887
- };
2888
- const handleClose = (reason) => {
2889
- if (closeHandled || shuttingDown)
2703
+ if (message.type === "peer_connected") {
2704
+ console.log("App connected!\n");
2705
+ startPortSync(controlWs);
2890
2706
  return;
2891
- closeHandled = true;
2892
- closeReason = reason;
2893
- stopPortSync();
2894
- cleanupAllTunnels();
2895
- setTimeout(() => {
2896
- if (shuttingDown)
2897
- return;
2898
- void handleConnectionDrop(closeReason);
2899
- }, 50);
2900
- };
2901
- controlWs.on("open", () => {
2902
- controlConnected = true;
2903
- checkFullyConnected();
2904
- });
2905
- controlWs.on("message", async (data) => {
2906
- try {
2907
- const message = JSON.parse(data.toString());
2908
- if ("type" in message) {
2909
- if (message.type === "connected")
2910
- return;
2911
- if (message.type === "session_password" && message.password) {
2912
- const nextPassword = message.password;
2913
- if (currentSessionPassword && currentSessionPassword.length > nextPassword.length) {
2914
- return;
2915
- }
2916
- if (!currentSessionPassword)
2917
- resetReplayBuffer(); // new session
2918
- currentSessionPassword = nextPassword;
2919
- console.log("[session] received reconnect password");
2920
- return;
2921
- }
2922
- if (message.type === "peer_connected") {
2923
- console.log("App connected!\n");
2924
- startPortSync(controlWs);
2925
- return;
2926
- }
2927
- if (message.type === "peer_disconnected") {
2928
- console.log("App disconnected. Waiting for reconnect window.\n");
2929
- stopPortSync();
2930
- return;
2931
- }
2932
- if (message.type === "app_disconnected") {
2933
- if (message.reconnectDeadline) {
2934
- console.log(`[session] app disconnected, waiting until ${new Date(message.reconnectDeadline).toISOString()}`);
2935
- }
2936
- return;
2937
- }
2938
- if (message.type === "close_connection") {
2939
- const reason = message.reason || "expired";
2940
- console.log(`[session] closed by gateway: ${reason}`);
2941
- if (reason === "session ended from app") {
2942
- console.log("[session] Run `npx lunel-cli` again and scan the new QR code to reconnect.");
2943
- }
2944
- gracefulShutdown();
2945
- return;
2946
- }
2947
- }
2948
- if (isProtocolResponse(message)) {
2949
- // Ignore server/app responses forwarded over WS; CLI only processes requests.
2950
- return;
2951
- }
2952
- if (isProtocolRequest(message)) {
2953
- const response = await processMessage(message);
2954
- sendResponseOnData(response, dataWs);
2955
- return;
2956
- }
2957
- console.warn("[router] Ignoring non-request control frame");
2958
- }
2959
- catch (error) {
2960
- console.error("Error processing control message:", error);
2961
2707
  }
2962
- });
2963
- controlWs.on("close", (code, reason) => {
2964
- if (!settled) {
2965
- 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();
2966
2711
  return;
2967
2712
  }
2968
- handleClose(`control closed (${code}: ${reason.toString()})`);
2969
- });
2970
- controlWs.on("error", (error) => {
2971
- if (!settled) {
2972
- 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
+ }
2973
2717
  return;
2974
2718
  }
2975
- console.error("Control WebSocket error:", error.message);
2976
- });
2977
- dataWs.on("open", () => {
2978
- dataConnected = true;
2979
- checkFullyConnected();
2980
- });
2981
- dataWs.on("message", async (data) => {
2982
- try {
2983
- const raw = JSON.parse(data.toString());
2984
- if (raw.type === "connected")
2985
- return;
2986
- // E2EE handshake messages (always plaintext)
2987
- if (raw.type === "e2ee_hello" && typeof raw.pubkey === "string") {
2988
- e2eeHandlePeerHello(raw.pubkey, dataWs);
2989
- return;
2990
- }
2991
- if (raw.type === "e2ee_secure_ready") {
2992
- e2eeHandlePeerReady();
2993
- return;
2994
- }
2995
- // Reconnect request: reset E2EE so fresh handshake happens, then replay
2996
- if (raw.ns === "system" && raw.action === "reconnect") {
2997
- e2eeReset();
2998
- const lastSeq = Number(raw.payload?.lastSeq ?? 0);
2999
- const toReplay = replayBuffer.filter((e) => e.seq > lastSeq);
3000
- console.log(`[replay] replaying ${toReplay.length} messages after seq ${lastSeq}`);
3001
- // Replay without encryption — E2EE handshake hasn't completed yet
3002
- for (const entry of toReplay)
3003
- dataWs.send(JSON.stringify(entry.msg));
3004
- return;
3005
- }
3006
- // Decrypt payload if E2EE is active
3007
- let message = raw;
3008
- if (e2eeActive && typeof raw.enc === "string") {
3009
- try {
3010
- message = { ...raw, payload: e2eeDecrypt(raw.enc) };
3011
- delete message.enc;
3012
- }
3013
- catch (decErr) {
3014
- console.error("[e2ee] decryption failed:", decErr.message);
3015
- return;
3016
- }
3017
- }
3018
- if (isProtocolResponse(message)) {
3019
- // Ignore server/app responses forwarded over WS; CLI only processes requests.
3020
- 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.");
3021
2724
  }
3022
- if (isProtocolRequest(message)) {
3023
- const response = await processMessage(message);
3024
- sendResponseOnData(response, dataWs);
3025
- return;
3026
- }
3027
- console.warn("[router] Ignoring non-request data frame");
3028
- }
3029
- catch (error) {
3030
- console.error("Error processing data message:", error);
3031
- }
3032
- });
3033
- dataWs.on("close", (code, reason) => {
3034
- // Reset backpressure state so reconnect starts fresh
3035
- dataChannelPaused = false;
3036
- if (dataChannelDrainTimer) {
3037
- clearInterval(dataChannelDrainTimer);
3038
- dataChannelDrainTimer = null;
3039
- }
3040
- if (!settled) {
3041
- failConnection(`data close before ready (${code}: ${reason.toString()})`);
2725
+ gracefulShutdown();
3042
2726
  return;
3043
2727
  }
3044
- handleClose(`data closed (${code}: ${reason.toString()})`);
3045
- });
3046
- dataWs.on("error", (error) => {
3047
- if (!settled) {
3048
- failConnection(`data ws error: ${error.message}`);
3049
- 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;
3050
2793
  }
3051
- console.error("Data WebSocket error:", error.message);
3052
- });
3053
- setTimeout(() => {
3054
- if (!settled) {
3055
- failConnection("connection timeout");
2794
+ catch (decErr) {
2795
+ console.error("[e2ee] decryption failed:", decErr.message);
2796
+ return;
3056
2797
  }
3057
- }, 10000);
3058
- });
3059
- return;
3060
- }
3061
- catch (err) {
3062
- console.error(`[gateway] failed ${gatewayUrl}: ${err.message}`);
3063
- }
3064
- }
3065
- 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
+ });
3066
2840
  }
3067
2841
  async function handleConnectionDrop(reason) {
3068
2842
  if (shuttingDown)
@@ -3079,16 +2853,7 @@ async function handleConnectionDrop(reason) {
3079
2853
  const base = Math.min(250 * 2 ** (attempt - 1), 30_000);
3080
2854
  const delayMs = Math.round(base * (0.8 + Math.random() * 0.4));
3081
2855
  try {
3082
- const resolved = await resolveSessionByResumeToken(currentSessionPassword);
3083
- if (!resolved) {
3084
- console.error("[reconnect] session no longer exists or is finalized");
3085
- gracefulShutdown();
3086
- return;
3087
- }
3088
- currentSessionCode = resolved.code;
3089
- currentSessionPassword = resolved.password;
3090
- currentPrimaryGateway = normalizeGatewayUrl(resolved.primary);
3091
- currentBackupGateway = resolved.backup ? normalizeGatewayUrl(resolved.backup) : null;
2856
+ currentPrimaryGateway = await getAssignedProxyUrl(currentSessionPassword);
3092
2857
  await connectWebSocket();
3093
2858
  console.log(`[reconnect] connected via ${activeGatewayUrl}`);
3094
2859
  return;
@@ -3105,14 +2870,6 @@ async function main() {
3105
2870
  if (EXTRA_PORTS.length > 0) {
3106
2871
  console.log(`Extra ports enabled: ${EXTRA_PORTS.join(", ")}`);
3107
2872
  }
3108
- // Detect cloud VM mode
3109
- cloudJobConfig = await readCloudJobConfig();
3110
- if (cloudJobConfig?.session_code) {
3111
- console.log(`[cloud] Running in VM mode (sandbox: ${cloudJobConfig.sandbox_id})`);
3112
- }
3113
- if (RUN_ABCD_MODE) {
3114
- console.log("[review] Running in pinned review session mode (code: abcd)");
3115
- }
3116
2873
  try {
3117
2874
  await getCliConfig();
3118
2875
  console.log("Checking PTY runtime...");
@@ -3133,98 +2890,15 @@ async function main() {
3133
2890
  checkDataChannelBackpressure();
3134
2891
  }
3135
2892
  });
3136
- let session;
3137
- if (RUN_ABCD_MODE) {
3138
- session = await createSessionFromManager({ requestedCode: "abcd" });
3139
- console.log(`[review] Attached to session ${session.code} via ${session.primary}`);
3140
- }
3141
- else if (cloudJobConfig?.session_code) {
3142
- // Cloud mode: connect to an existing session assigned by the manager
3143
- session = await connectToCloudSession(cloudJobConfig.session_code);
3144
- console.log(`[cloud] Connected to session ${session.code} via ${session.primary}`);
3145
- }
3146
- else {
3147
- let savedPairing = await getSavedPairingForRoot(ROOT_DIR);
3148
- if (!savedPairing) {
3149
- try {
3150
- const remotePairings = await lookupPersistentPairings();
3151
- for (const pairing of remotePairings) {
3152
- await savePairing(pairing);
3153
- }
3154
- savedPairing = remotePairings.sort((a, b) => b.lastUsedAt - a.lastUsedAt)[0] || null;
3155
- }
3156
- catch {
3157
- // fall back to QR
3158
- }
3159
- }
3160
- if (savedPairing) {
3161
- try {
3162
- const resolved = await resolveSessionByResumeToken(savedPairing.secret);
3163
- if (resolved) {
3164
- session = resolved;
3165
- console.log(`[pairing] Waiting on saved connection for ${savedPairing.hostname}`);
3166
- }
3167
- else {
3168
- await removePairing(savedPairing.secret);
3169
- const remotePairings = await lookupPersistentPairings().catch(() => []);
3170
- const fallbackPairing = remotePairings.sort((a, b) => b.lastUsedAt - a.lastUsedAt)[0] || null;
3171
- if (fallbackPairing) {
3172
- await savePairing(fallbackPairing);
3173
- const fallbackResolved = await resolveSessionByResumeToken(fallbackPairing.secret);
3174
- if (fallbackResolved) {
3175
- session = fallbackResolved;
3176
- console.log(`[pairing] Waiting on saved connection for ${fallbackPairing.hostname}`);
3177
- }
3178
- else {
3179
- await removePairing(fallbackPairing.secret);
3180
- session = await createSessionFromManager();
3181
- displayQR(normalizeGatewayUrl(session.primary), session.backup ? normalizeGatewayUrl(session.backup) : null, session.code);
3182
- }
3183
- }
3184
- else {
3185
- session = await createSessionFromManager();
3186
- displayQR(normalizeGatewayUrl(session.primary), session.backup ? normalizeGatewayUrl(session.backup) : null, session.code);
3187
- }
3188
- }
3189
- }
3190
- catch {
3191
- await removePairing(savedPairing.secret);
3192
- const remotePairings = await lookupPersistentPairings().catch(() => []);
3193
- const fallbackPairing = remotePairings.sort((a, b) => b.lastUsedAt - a.lastUsedAt)[0] || null;
3194
- if (fallbackPairing) {
3195
- await savePairing(fallbackPairing);
3196
- const fallbackResolved = await resolveSessionByResumeToken(fallbackPairing.secret);
3197
- if (fallbackResolved) {
3198
- session = fallbackResolved;
3199
- console.log(`[pairing] Waiting on saved connection for ${fallbackPairing.hostname}`);
3200
- }
3201
- else {
3202
- await removePairing(fallbackPairing.secret);
3203
- session = await createSessionFromManager();
3204
- displayQR(normalizeGatewayUrl(session.primary), session.backup ? normalizeGatewayUrl(session.backup) : null, session.code);
3205
- }
3206
- }
3207
- else {
3208
- session = await createSessionFromManager();
3209
- displayQR(normalizeGatewayUrl(session.primary), session.backup ? normalizeGatewayUrl(session.backup) : null, session.code);
3210
- }
3211
- }
3212
- }
3213
- else {
3214
- // Public mode: create a new session and show QR code
3215
- session = await createSessionFromManager();
3216
- displayQR(normalizeGatewayUrl(session.primary), session.backup ? normalizeGatewayUrl(session.backup) : null, session.code);
3217
- }
3218
- }
3219
- currentPrimaryGateway = normalizeGatewayUrl(session.primary);
3220
- currentBackupGateway = session.backup ? normalizeGatewayUrl(session.backup) : null;
3221
- 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);
3222
2901
  activeGatewayUrl = currentPrimaryGateway;
3223
- currentSessionCode = session.code;
3224
- // Start VM heartbeat in cloud mode (session.password = resumeToken)
3225
- if (cloudJobConfig?.session_code) {
3226
- startVmHeartbeat(session.password);
3227
- }
3228
2902
  await connectWebSocket();
3229
2903
  }
3230
2904
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lunel-cli",
3
- "version": "0.1.78",
3
+ "version": "0.1.79",
4
4
  "author": [
5
5
  {
6
6
  "name": "Soham Bharambe",