lunel-cli 0.1.29 → 0.1.30

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 +276 -132
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -11,7 +11,8 @@ import * as os from "os";
11
11
  import { spawn, execSync } from "child_process";
12
12
  import { createServer, createConnection } from "net";
13
13
  import { createInterface } from "readline";
14
- const PROXY_URL = process.env.LUNEL_PROXY_URL || "https://gateway.lunel.dev";
14
+ const DEFAULT_PROXY_URL = process.env.LUNEL_PROXY_URL || "https://gateway.lunel.dev";
15
+ const MANAGER_URL = process.env.LUNEL_MANAGER_URL || "https://manager.lunel.dev";
15
16
  import { createRequire } from "module";
16
17
  const __require = createRequire(import.meta.url);
17
18
  const VERSION = __require("../package.json").version;
@@ -30,6 +31,13 @@ let lastCpuInfo = null;
30
31
  let opencodeClient = null;
31
32
  // Proxy tunnel management
32
33
  let currentSessionCode = null;
34
+ let currentSessionPassword = null;
35
+ let currentPrimaryGateway = DEFAULT_PROXY_URL;
36
+ let currentBackupGateway = null;
37
+ let activeGatewayUrl = DEFAULT_PROXY_URL;
38
+ let shuttingDown = false;
39
+ let activeControlWs = null;
40
+ let activeDataWs = null;
33
41
  const activeTunnels = new Map();
34
42
  // Popular development server ports to scan on connect
35
43
  const DEV_PORTS = [
@@ -1433,7 +1441,7 @@ async function handleProxyConnect(payload) {
1433
1441
  throw Object.assign(new Error("tunnelId is required"), { code: "EINVAL" });
1434
1442
  if (!port)
1435
1443
  throw Object.assign(new Error("port is required"), { code: "EINVAL" });
1436
- if (!currentSessionCode)
1444
+ if (!currentSessionCode && !currentSessionPassword)
1437
1445
  throw Object.assign(new Error("no active session"), { code: "ENOENT" });
1438
1446
  // 1. Open TCP connection to the local service
1439
1447
  const tcpSocket = createConnection({ port, host: "127.0.0.1" });
@@ -1452,8 +1460,11 @@ async function handleProxyConnect(payload) {
1452
1460
  });
1453
1461
  });
1454
1462
  // 2. Open proxy WebSocket to gateway
1455
- const wsBase = PROXY_URL.replace(/^http/, "ws");
1456
- const proxyWsUrl = `${wsBase}/v1/ws/proxy?code=${currentSessionCode}&tunnelId=${tunnelId}&role=cli`;
1463
+ const wsBase = activeGatewayUrl.replace(/^http/, "ws");
1464
+ const authQuery = currentSessionPassword
1465
+ ? `password=${encodeURIComponent(currentSessionPassword)}`
1466
+ : `code=${encodeURIComponent(currentSessionCode)}`;
1467
+ const proxyWsUrl = `${wsBase}/v1/ws/proxy?${authQuery}&tunnelId=${encodeURIComponent(tunnelId)}&role=cli`;
1457
1468
  const proxyWs = new WebSocket(proxyWsUrl);
1458
1469
  await new Promise((resolve, reject) => {
1459
1470
  const timeout = setTimeout(() => {
@@ -1808,11 +1819,35 @@ async function processMessage(message) {
1808
1819
  };
1809
1820
  }
1810
1821
  }
1811
- // ============================================================================
1812
- // WebSocket Connection
1813
- // ============================================================================
1814
- async function createSession() {
1815
- const response = await fetch(`${PROXY_URL}/v1/session`, {
1822
+ function normalizeGatewayUrl(input) {
1823
+ if (/^https?:\/\//.test(input))
1824
+ return input.replace(/\/+$/, "");
1825
+ return `https://${input}`.replace(/\/+$/, "");
1826
+ }
1827
+ async function discoverGateways() {
1828
+ try {
1829
+ const response = await fetch(`${MANAGER_URL}/v1/gateways`, {
1830
+ method: "GET",
1831
+ headers: { "Content-Type": "application/json" },
1832
+ });
1833
+ if (!response.ok) {
1834
+ throw new Error(`manager returned ${response.status}`);
1835
+ }
1836
+ const data = (await response.json());
1837
+ const gateways = (data.gateways || []).map((x) => normalizeGatewayUrl(x)).filter(Boolean);
1838
+ const primary = data.primary ? normalizeGatewayUrl(data.primary) : gateways[0];
1839
+ const backup = data.backup ? normalizeGatewayUrl(data.backup) : gateways[1] || null;
1840
+ if (!primary)
1841
+ throw new Error("manager returned no healthy gateways");
1842
+ return { primary, backup };
1843
+ }
1844
+ catch (err) {
1845
+ console.warn(`[manager] gateway discovery failed, using default: ${err.message}`);
1846
+ return { primary: DEFAULT_PROXY_URL, backup: null };
1847
+ }
1848
+ }
1849
+ async function createSession(gatewayUrl) {
1850
+ const response = await fetch(`${gatewayUrl}/v1/session`, {
1816
1851
  method: "POST",
1817
1852
  headers: { "Content-Type": "application/json" },
1818
1853
  });
@@ -1822,142 +1857,244 @@ async function createSession() {
1822
1857
  const data = (await response.json());
1823
1858
  return data.code;
1824
1859
  }
1825
- function displayQR(code) {
1860
+ function displayQR(primaryGateway, backupGateway, code) {
1861
+ const qrPayload = backupGateway
1862
+ ? `${primaryGateway},${backupGateway},${code}`
1863
+ : `${primaryGateway},${code}`;
1826
1864
  console.log("\n");
1827
- qrcode.generate(code, { small: true }, (qr) => {
1865
+ qrcode.generate(qrPayload, { small: true }, (qr) => {
1828
1866
  console.log(qr);
1829
1867
  console.log(`\n Session code: ${code}\n`);
1868
+ console.log(` Primary gateway: ${primaryGateway}`);
1869
+ if (backupGateway) {
1870
+ console.log(` Backup gateway: ${backupGateway}`);
1871
+ }
1830
1872
  console.log(` Root directory: ${ROOT_DIR}\n`);
1831
1873
  console.log(" Scan the QR code with the Lunel app to connect.");
1832
1874
  console.log(" Press Ctrl+C to exit.\n");
1833
1875
  });
1834
1876
  }
1835
- function connectWebSocket(code) {
1836
- currentSessionCode = code;
1837
- const wsBase = PROXY_URL.replace(/^http/, "ws");
1838
- const controlUrl = `${wsBase}/v1/ws/cli/control?code=${code}`;
1839
- const dataUrl = `${wsBase}/v1/ws/cli/data?code=${code}`;
1840
- console.log("Connecting to proxy...");
1841
- // Control channel
1842
- const controlWs = new WebSocket(controlUrl);
1843
- let controlConnected = false;
1844
- // Data channel
1845
- const dataWs = new WebSocket(dataUrl);
1846
- let dataConnected = false;
1847
- // Store data channel reference for terminal output
1848
- dataChannel = dataWs;
1849
- function checkFullyConnected() {
1850
- if (controlConnected && dataConnected) {
1851
- console.log("Connected to proxy (control + data channels). Waiting for app...\n");
1877
+ function buildWsUrl(gatewayUrl, role, channel) {
1878
+ const wsBase = gatewayUrl.replace(/^http/, "ws");
1879
+ const query = new URLSearchParams();
1880
+ if (currentSessionPassword) {
1881
+ query.set("password", currentSessionPassword);
1882
+ }
1883
+ else if (currentSessionCode) {
1884
+ query.set("code", currentSessionCode);
1885
+ if (currentBackupGateway) {
1886
+ query.set("backup", currentBackupGateway);
1852
1887
  }
1853
1888
  }
1854
- // Control channel handlers
1855
- controlWs.on("open", () => {
1856
- controlConnected = true;
1857
- checkFullyConnected();
1858
- });
1859
- controlWs.on("message", async (data) => {
1889
+ else {
1890
+ throw new Error("missing code/password for websocket connect");
1891
+ }
1892
+ return `${wsBase}/v1/ws/${role}/${channel}?${query.toString()}`;
1893
+ }
1894
+ function gracefulShutdown() {
1895
+ shuttingDown = true;
1896
+ console.log("\nShutting down...");
1897
+ if (ptyProcess) {
1898
+ ptyProcess.kill();
1899
+ ptyProcess = null;
1900
+ }
1901
+ terminals.clear();
1902
+ for (const [pid, managedProc] of processes) {
1903
+ managedProc.proc.kill();
1904
+ }
1905
+ processes.clear();
1906
+ processOutputBuffers.clear();
1907
+ cleanupAllTunnels();
1908
+ if (activeControlWs && (activeControlWs.readyState === WebSocket.OPEN || activeControlWs.readyState === WebSocket.CONNECTING)) {
1909
+ activeControlWs.close();
1910
+ }
1911
+ if (activeDataWs && (activeDataWs.readyState === WebSocket.OPEN || activeDataWs.readyState === WebSocket.CONNECTING)) {
1912
+ activeDataWs.close();
1913
+ }
1914
+ process.exit(0);
1915
+ }
1916
+ async function connectWebSocket() {
1917
+ const gateways = currentBackupGateway ? [currentPrimaryGateway, currentBackupGateway] : [currentPrimaryGateway];
1918
+ const uniqueGateways = Array.from(new Set(gateways));
1919
+ for (const gatewayUrl of uniqueGateways) {
1860
1920
  try {
1861
- const message = JSON.parse(data.toString());
1862
- // Handle system messages
1863
- if (message.type === "connected") {
1864
- return;
1865
- }
1866
- if (message.type === "peer_connected") {
1867
- console.log("App connected!\n");
1868
- // Scan dev ports and notify app
1869
- scanDevPorts().then((openPorts) => {
1870
- if (openPorts.length > 0) {
1871
- console.log(`Found ${openPorts.length} open dev port(s): ${openPorts.join(", ")}`);
1921
+ await new Promise((resolve, reject) => {
1922
+ activeGatewayUrl = gatewayUrl;
1923
+ const controlUrl = buildWsUrl(gatewayUrl, "cli", "control");
1924
+ const dataUrl = buildWsUrl(gatewayUrl, "cli", "data");
1925
+ console.log(`Connecting to gateway ${gatewayUrl}...`);
1926
+ const controlWs = new WebSocket(controlUrl);
1927
+ const dataWs = new WebSocket(dataUrl);
1928
+ activeControlWs = controlWs;
1929
+ activeDataWs = dataWs;
1930
+ dataChannel = dataWs;
1931
+ let controlConnected = false;
1932
+ let dataConnected = false;
1933
+ let settled = false;
1934
+ let closeHandled = false;
1935
+ let closeReason = "";
1936
+ const failConnection = (reason) => {
1937
+ if (settled)
1938
+ return;
1939
+ settled = true;
1940
+ reject(new Error(reason));
1941
+ };
1942
+ const checkFullyConnected = () => {
1943
+ if (controlConnected && dataConnected && !settled) {
1944
+ settled = true;
1945
+ console.log("Connected to gateway (control + data channels).\n");
1946
+ resolve();
1947
+ }
1948
+ };
1949
+ const handleClose = (reason) => {
1950
+ if (closeHandled || shuttingDown)
1951
+ return;
1952
+ closeHandled = true;
1953
+ closeReason = reason;
1954
+ cleanupAllTunnels();
1955
+ setTimeout(() => {
1956
+ if (shuttingDown)
1957
+ return;
1958
+ void handleConnectionDrop(closeReason);
1959
+ }, 50);
1960
+ };
1961
+ controlWs.on("open", () => {
1962
+ controlConnected = true;
1963
+ checkFullyConnected();
1964
+ });
1965
+ controlWs.on("message", async (data) => {
1966
+ try {
1967
+ const message = JSON.parse(data.toString());
1968
+ if ("type" in message) {
1969
+ if (message.type === "connected")
1970
+ return;
1971
+ if (message.type === "session_password" && message.password) {
1972
+ currentSessionPassword = message.password;
1973
+ console.log("[session] received reconnect password");
1974
+ return;
1975
+ }
1976
+ if (message.type === "peer_connected") {
1977
+ console.log("App connected!\n");
1978
+ scanDevPorts().then((openPorts) => {
1979
+ if (openPorts.length > 0) {
1980
+ console.log(`Found ${openPorts.length} open dev port(s): ${openPorts.join(", ")}`);
1981
+ }
1982
+ if (controlWs.readyState === WebSocket.OPEN) {
1983
+ controlWs.send(JSON.stringify({
1984
+ v: 1,
1985
+ id: `evt-${Date.now()}`,
1986
+ ns: "proxy",
1987
+ action: "ports_discovered",
1988
+ payload: { ports: openPorts },
1989
+ }));
1990
+ }
1991
+ }).catch((err) => {
1992
+ console.error("Port scan failed:", err);
1993
+ });
1994
+ return;
1995
+ }
1996
+ if (message.type === "peer_disconnected") {
1997
+ console.log("App disconnected. Waiting for reconnect window.\n");
1998
+ return;
1999
+ }
2000
+ if (message.type === "app_disconnected") {
2001
+ if (message.reconnectDeadline) {
2002
+ console.log(`[session] app disconnected, waiting until ${new Date(message.reconnectDeadline).toISOString()}`);
2003
+ }
2004
+ return;
2005
+ }
2006
+ if (message.type === "close_connection") {
2007
+ console.log(`[session] closed by gateway: ${message.reason || "expired"}`);
2008
+ gracefulShutdown();
2009
+ return;
2010
+ }
2011
+ }
2012
+ if (message.v === 1) {
2013
+ const response = await processMessage(message);
2014
+ controlWs.send(JSON.stringify(response));
2015
+ }
1872
2016
  }
1873
- if (controlWs.readyState === WebSocket.OPEN) {
1874
- controlWs.send(JSON.stringify({
1875
- v: 1,
1876
- id: `evt-${Date.now()}`,
1877
- ns: "proxy",
1878
- action: "ports_discovered",
1879
- payload: { ports: openPorts },
1880
- }));
2017
+ catch (error) {
2018
+ console.error("Error processing control message:", error);
1881
2019
  }
1882
- }).catch((err) => {
1883
- console.error("Port scan failed:", err);
1884
2020
  });
1885
- return;
1886
- }
1887
- if (message.type === "peer_disconnected") {
1888
- console.log("App disconnected.\n");
1889
- return;
1890
- }
1891
- // Handle v1 protocol messages
1892
- if (message.v === 1) {
1893
- const response = await processMessage(message);
1894
- controlWs.send(JSON.stringify(response));
1895
- }
1896
- }
1897
- catch (error) {
1898
- console.error("Error processing control message:", error);
1899
- }
1900
- });
1901
- controlWs.on("close", (code, reason) => {
1902
- console.log(`\nControl channel disconnected (${code}: ${reason.toString()})`);
1903
- cleanupAllTunnels();
1904
- dataWs.close();
1905
- process.exit(0);
1906
- });
1907
- controlWs.on("error", (error) => {
1908
- console.error("Control WebSocket error:", error.message);
1909
- });
1910
- // Data channel handlers
1911
- dataWs.on("open", () => {
1912
- dataConnected = true;
1913
- checkFullyConnected();
1914
- });
1915
- dataWs.on("message", async (data) => {
1916
- try {
1917
- const message = JSON.parse(data.toString());
1918
- // Handle system messages
1919
- if (message.type === "connected") {
1920
- return;
1921
- }
1922
- // Handle v1 protocol messages
1923
- if (message.v === 1) {
1924
- const response = await processMessage(message);
1925
- dataWs.send(JSON.stringify(response));
1926
- }
1927
- }
1928
- catch (error) {
1929
- console.error("Error processing data message:", error);
1930
- }
1931
- });
1932
- dataWs.on("close", (code, reason) => {
1933
- console.log(`\nData channel disconnected (${code}: ${reason.toString()})`);
1934
- cleanupAllTunnels();
1935
- controlWs.close();
1936
- process.exit(0);
1937
- });
1938
- dataWs.on("error", (error) => {
1939
- console.error("Data WebSocket error:", error.message);
1940
- });
1941
- // Handle graceful shutdown
1942
- process.on("SIGINT", () => {
1943
- console.log("\nShutting down...");
1944
- // Kill PTY process (kills all terminals)
1945
- if (ptyProcess) {
1946
- ptyProcess.kill();
1947
- ptyProcess = null;
2021
+ controlWs.on("close", (code, reason) => {
2022
+ if (!settled) {
2023
+ failConnection(`control close before ready (${code}: ${reason.toString()})`);
2024
+ return;
2025
+ }
2026
+ handleClose(`control closed (${code}: ${reason.toString()})`);
2027
+ });
2028
+ controlWs.on("error", (error) => {
2029
+ if (!settled) {
2030
+ failConnection(`control ws error: ${error.message}`);
2031
+ return;
2032
+ }
2033
+ console.error("Control WebSocket error:", error.message);
2034
+ });
2035
+ dataWs.on("open", () => {
2036
+ dataConnected = true;
2037
+ checkFullyConnected();
2038
+ });
2039
+ dataWs.on("message", async (data) => {
2040
+ try {
2041
+ const message = JSON.parse(data.toString());
2042
+ if (message.type === "connected")
2043
+ return;
2044
+ if (message.v === 1) {
2045
+ const response = await processMessage(message);
2046
+ dataWs.send(JSON.stringify(response));
2047
+ }
2048
+ }
2049
+ catch (error) {
2050
+ console.error("Error processing data message:", error);
2051
+ }
2052
+ });
2053
+ dataWs.on("close", (code, reason) => {
2054
+ if (!settled) {
2055
+ failConnection(`data close before ready (${code}: ${reason.toString()})`);
2056
+ return;
2057
+ }
2058
+ handleClose(`data closed (${code}: ${reason.toString()})`);
2059
+ });
2060
+ dataWs.on("error", (error) => {
2061
+ if (!settled) {
2062
+ failConnection(`data ws error: ${error.message}`);
2063
+ return;
2064
+ }
2065
+ console.error("Data WebSocket error:", error.message);
2066
+ });
2067
+ setTimeout(() => {
2068
+ if (!settled) {
2069
+ failConnection("connection timeout");
2070
+ }
2071
+ }, 10000);
2072
+ });
2073
+ return;
1948
2074
  }
1949
- terminals.clear();
1950
- // Kill all managed processes
1951
- for (const [pid, managedProc] of processes) {
1952
- managedProc.proc.kill();
2075
+ catch (err) {
2076
+ console.error(`[gateway] failed ${gatewayUrl}: ${err.message}`);
1953
2077
  }
1954
- processes.clear();
1955
- processOutputBuffers.clear();
1956
- cleanupAllTunnels();
1957
- controlWs.close();
1958
- dataWs.close();
1959
- process.exit(0);
1960
- });
2078
+ }
2079
+ throw new Error("unable to connect to any gateway");
2080
+ }
2081
+ async function handleConnectionDrop(reason) {
2082
+ if (shuttingDown)
2083
+ return;
2084
+ console.log(`\nDisconnected: ${reason}`);
2085
+ if (!currentSessionPassword) {
2086
+ console.error("No reconnect password available. Exiting.");
2087
+ gracefulShutdown();
2088
+ return;
2089
+ }
2090
+ try {
2091
+ await connectWebSocket();
2092
+ console.log(`[reconnect] connected via ${activeGatewayUrl}`);
2093
+ }
2094
+ catch (err) {
2095
+ console.error(`[reconnect] failed on all gateways: ${err.message}`);
2096
+ gracefulShutdown();
2097
+ }
1961
2098
  }
1962
2099
  async function main() {
1963
2100
  console.log("Lunel CLI v" + VERSION);
@@ -1989,9 +2126,14 @@ async function main() {
1989
2126
  console.log("OpenCode ready.\n");
1990
2127
  // Subscribe to OpenCode events
1991
2128
  subscribeToOpenCodeEvents(client);
1992
- const code = await createSession();
1993
- displayQR(code);
1994
- connectWebSocket(code);
2129
+ const gateways = await discoverGateways();
2130
+ currentPrimaryGateway = gateways.primary;
2131
+ currentBackupGateway = gateways.backup;
2132
+ activeGatewayUrl = currentPrimaryGateway;
2133
+ const code = await createSession(currentPrimaryGateway);
2134
+ currentSessionCode = code;
2135
+ displayQR(currentPrimaryGateway, currentBackupGateway, code);
2136
+ await connectWebSocket();
1995
2137
  }
1996
2138
  catch (error) {
1997
2139
  if (error instanceof Error) {
@@ -2005,4 +2147,6 @@ async function main() {
2005
2147
  process.exit(1);
2006
2148
  }
2007
2149
  }
2150
+ process.on("SIGINT", gracefulShutdown);
2151
+ process.on("SIGTERM", gracefulShutdown);
2008
2152
  main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lunel-cli",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
4
4
  "author": [
5
5
  {
6
6
  "name": "Soham Bharambe",