lunel-cli 0.1.35 → 0.1.38

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 +175 -33
  2. package/package.json +3 -2
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import * as os from "os";
11
11
  import { spawn, execSync, execFileSync } from "child_process";
12
12
  import { createServer, createConnection } from "net";
13
13
  import { createInterface } from "readline";
14
+ import { simpleGit } from "simple-git";
14
15
  const DEFAULT_PROXY_URL = process.env.LUNEL_PROXY_URL || "https://gateway.lunel.dev";
15
16
  const MANAGER_URL = process.env.LUNEL_MANAGER_URL || "https://manager.lunel.dev";
16
17
  const CLI_ARGS = process.argv.slice(2);
@@ -22,6 +23,7 @@ const PTY_RELEASE_INFO_URL = process.env.LUNEL_PTY_INFO_URL ||
22
23
  const VERBOSE_AI_LOGS = process.env.LUNEL_DEBUG_AI === "1";
23
24
  // Root directory - sandbox all file operations to this
24
25
  const ROOT_DIR = process.cwd();
26
+ const gitClient = simpleGit(ROOT_DIR);
25
27
  // Terminal sessions (managed by Rust PTY binary)
26
28
  const terminals = new Set();
27
29
  // PTY binary process
@@ -51,6 +53,7 @@ const PROXY_WS_CONNECT_RETRY_ATTEMPTS = 1;
51
53
  const PROXY_WS_RETRY_JITTER_MIN_MS = 200;
52
54
  const PROXY_WS_RETRY_JITTER_MAX_MS = 500;
53
55
  const PROXY_TUNNEL_LINGER_MS = 1_200;
56
+ const LOOPBACK_HOSTS = ["127.0.0.1", "::1"];
54
57
  let portSyncTimer = null;
55
58
  let portScanInFlight = false;
56
59
  let lastDiscoveredPorts = [];
@@ -219,6 +222,21 @@ function assertSafePath(requestedPath) {
219
222
  // ============================================================================
220
223
  // File System Handlers
221
224
  // ============================================================================
225
+ function isLikelyBinaryBuffer(buffer) {
226
+ if (buffer.length === 0)
227
+ return false;
228
+ if (buffer.includes(0x00))
229
+ return true;
230
+ let suspicious = 0;
231
+ for (let i = 0; i < buffer.length; i++) {
232
+ const byte = buffer[i];
233
+ const isPrintableAscii = byte >= 0x20 && byte <= 0x7e;
234
+ const isCommonControl = byte === 0x09 || byte === 0x0a || byte === 0x0d;
235
+ if (!isPrintableAscii && !isCommonControl)
236
+ suspicious++;
237
+ }
238
+ return suspicious / buffer.length > 0.3;
239
+ }
222
240
  async function handleFsLs(payload) {
223
241
  const reqPath = payload.path || ".";
224
242
  const safePath = assertSafePath(reqPath);
@@ -257,6 +275,25 @@ async function handleFsStat(payload) {
257
275
  mtime: stat.mtimeMs,
258
276
  mode: stat.mode,
259
277
  };
278
+ if (stat.isFile()) {
279
+ try {
280
+ const fd = await fs.open(safePath, "r");
281
+ try {
282
+ const sampleSize = Math.min(stat.size, 8192);
283
+ const sample = Buffer.alloc(sampleSize);
284
+ if (sampleSize > 0) {
285
+ await fd.read(sample, 0, sampleSize, 0);
286
+ }
287
+ result.isBinary = isLikelyBinaryBuffer(sample);
288
+ }
289
+ finally {
290
+ await fd.close();
291
+ }
292
+ }
293
+ catch {
294
+ // Keep stat resilient even if sampling fails
295
+ }
296
+ }
260
297
  return result;
261
298
  }
262
299
  async function handleFsRead(payload) {
@@ -267,8 +304,8 @@ async function handleFsRead(payload) {
267
304
  // Check if binary
268
305
  const stat = await fs.stat(safePath);
269
306
  const content = await fs.readFile(safePath);
270
- // Try to detect if binary
271
- const isBinary = content.includes(0x00);
307
+ // Detect if binary
308
+ const isBinary = isLikelyBinaryBuffer(content.subarray(0, 8192));
272
309
  if (isBinary) {
273
310
  return {
274
311
  path: reqPath,
@@ -569,6 +606,68 @@ async function handleGitLog(payload) {
569
606
  });
570
607
  return { commits };
571
608
  }
609
+ async function handleGitCommitDetails(payload) {
610
+ const hash = payload.hash;
611
+ if (!hash)
612
+ throw Object.assign(new Error("hash is required"), { code: "EINVAL" });
613
+ try {
614
+ const logResult = await gitClient.log({
615
+ from: hash,
616
+ to: hash,
617
+ maxCount: 1,
618
+ format: {
619
+ fullHash: "%H",
620
+ message: "%s",
621
+ author: "%an",
622
+ timestamp: "%at",
623
+ },
624
+ });
625
+ const latest = logResult.latest;
626
+ if (!latest) {
627
+ throw Object.assign(new Error("Commit not found"), { code: "EGIT" });
628
+ }
629
+ const filesRaw = await gitClient.show(["--name-status", "--format=", hash]);
630
+ const files = filesRaw
631
+ .split(/\r?\n/)
632
+ .map((line) => line.trim())
633
+ .filter(Boolean)
634
+ .map((line) => {
635
+ const parts = line.split("\t");
636
+ const status = parts[0] || "?";
637
+ // Handles regular + rename/copy name-status output.
638
+ const path = parts[2] || parts[1] || "";
639
+ return { status, path };
640
+ })
641
+ .filter((entry) => !!entry.path);
642
+ const diff = await gitClient.show(["--patch", "--format=", hash]);
643
+ const fileDiffs = {};
644
+ const fileChunks = diff.split(/^diff --git /m).filter(Boolean);
645
+ for (const chunk of fileChunks) {
646
+ const patch = `diff --git ${chunk}`;
647
+ const firstLine = chunk.split(/\r?\n/, 1)[0] || "";
648
+ const match = firstLine.match(/^a\/(.+?) b\/(.+)$/);
649
+ if (match?.[2]) {
650
+ fileDiffs[match[2]] = patch;
651
+ }
652
+ }
653
+ return {
654
+ commit: {
655
+ hash: latest.fullHash.substring(0, 7),
656
+ fullHash: latest.fullHash,
657
+ message: latest.message,
658
+ author: latest.author,
659
+ date: parseInt(latest.timestamp, 10) * 1000,
660
+ },
661
+ files,
662
+ diff,
663
+ fileDiffs,
664
+ };
665
+ }
666
+ catch (err) {
667
+ const message = err instanceof Error ? err.message : "git show failed";
668
+ throw Object.assign(new Error(message), { code: "EGIT" });
669
+ }
670
+ }
572
671
  async function handleGitDiff(payload) {
573
672
  const filepath = payload.path;
574
673
  const staged = payload.staged === true;
@@ -1690,20 +1789,36 @@ async function scanDevPorts() {
1690
1789
  const openPorts = [];
1691
1790
  const checks = SCAN_PORTS.map((port) => {
1692
1791
  return new Promise((resolve) => {
1693
- const socket = createConnection({ port, host: "127.0.0.1" });
1694
- socket.setTimeout(200);
1695
- socket.on("connect", () => {
1696
- openPorts.push(port);
1697
- socket.destroy();
1698
- resolve();
1699
- });
1700
- socket.on("timeout", () => {
1701
- socket.destroy();
1702
- resolve();
1703
- });
1704
- socket.on("error", () => {
1705
- resolve();
1706
- });
1792
+ let finished = false;
1793
+ let pending = LOOPBACK_HOSTS.length;
1794
+ for (const host of LOOPBACK_HOSTS) {
1795
+ const socket = createConnection({ port, host });
1796
+ socket.setTimeout(200);
1797
+ socket.on("connect", () => {
1798
+ if (finished)
1799
+ return;
1800
+ finished = true;
1801
+ openPorts.push(port);
1802
+ socket.destroy();
1803
+ resolve();
1804
+ });
1805
+ const onDone = () => {
1806
+ if (finished)
1807
+ return;
1808
+ pending -= 1;
1809
+ if (pending <= 0) {
1810
+ finished = true;
1811
+ resolve();
1812
+ }
1813
+ };
1814
+ socket.on("timeout", () => {
1815
+ socket.destroy();
1816
+ onDone();
1817
+ });
1818
+ socket.on("error", () => {
1819
+ onDone();
1820
+ });
1821
+ }
1707
1822
  });
1708
1823
  });
1709
1824
  await Promise.all(checks);
@@ -1765,23 +1880,47 @@ async function handleProxyConnect(payload) {
1765
1880
  if (getRemainingSetupMs() <= 0) {
1766
1881
  throw Object.assign(new Error("Tunnel setup timeout before start"), { code: "ETIMEOUT" });
1767
1882
  }
1768
- // 1. Open TCP connection to the local service
1769
- const tcpSocket = createConnection({ port, host: "127.0.0.1" });
1770
- const tcpConnectTimeoutMs = Math.min(CLI_LOCAL_TCP_CONNECT_TIMEOUT_MS, Math.max(250, getRemainingSetupMs()));
1771
- await new Promise((resolve, reject) => {
1772
- const timeout = setTimeout(() => {
1773
- tcpSocket.destroy();
1774
- reject(Object.assign(new Error(`TCP connect timeout to localhost:${port}`), { code: "ETIMEOUT" }));
1775
- }, tcpConnectTimeoutMs);
1776
- tcpSocket.on("connect", () => {
1777
- clearTimeout(timeout);
1778
- resolve();
1779
- });
1780
- tcpSocket.on("error", (err) => {
1781
- clearTimeout(timeout);
1782
- reject(Object.assign(new Error(`TCP connect failed: ${err.message}`), { code: "ECONNREFUSED" }));
1783
- });
1784
- });
1883
+ // 1. Open TCP connection to the local service (dual-stack localhost fallback)
1884
+ let tcpSocket = null;
1885
+ let tcpConnectError = null;
1886
+ for (const host of LOOPBACK_HOSTS) {
1887
+ const remainingMs = getRemainingSetupMs();
1888
+ if (remainingMs <= 0) {
1889
+ throw Object.assign(new Error("Tunnel setup timeout before local TCP connect"), { code: "ETIMEOUT" });
1890
+ }
1891
+ const tcpConnectTimeoutMs = Math.min(CLI_LOCAL_TCP_CONNECT_TIMEOUT_MS, Math.max(250, remainingMs));
1892
+ const candidate = createConnection({ port, host });
1893
+ try {
1894
+ await new Promise((resolve, reject) => {
1895
+ const timeout = setTimeout(() => {
1896
+ candidate.destroy();
1897
+ reject(Object.assign(new Error(`TCP connect timeout to ${host}:${port}`), { code: "ETIMEOUT" }));
1898
+ }, tcpConnectTimeoutMs);
1899
+ candidate.on("connect", () => {
1900
+ clearTimeout(timeout);
1901
+ resolve();
1902
+ });
1903
+ candidate.on("error", (err) => {
1904
+ clearTimeout(timeout);
1905
+ reject(Object.assign(new Error(`TCP connect failed to ${host}:${port}: ${err.message}`), { code: "ECONNREFUSED" }));
1906
+ });
1907
+ });
1908
+ tcpSocket = candidate;
1909
+ break;
1910
+ }
1911
+ catch (error) {
1912
+ tcpConnectError = error;
1913
+ try {
1914
+ candidate.destroy();
1915
+ }
1916
+ catch {
1917
+ // ignore
1918
+ }
1919
+ }
1920
+ }
1921
+ if (!tcpSocket) {
1922
+ throw tcpConnectError || Object.assign(new Error(`TCP connect failed to localhost:${port}`), { code: "ECONNREFUSED" });
1923
+ }
1785
1924
  // 2. Open proxy WebSocket to gateway
1786
1925
  const wsBase = activeGatewayUrl.replace(/^http/, "ws");
1787
1926
  const authQuery = currentSessionPassword
@@ -2069,6 +2208,9 @@ async function processMessage(message) {
2069
2208
  case "log":
2070
2209
  result = await handleGitLog(payload);
2071
2210
  break;
2211
+ case "commitDetails":
2212
+ result = await handleGitCommitDetails(payload);
2213
+ break;
2072
2214
  case "diff":
2073
2215
  result = await handleGitDiff(payload);
2074
2216
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lunel-cli",
3
- "version": "0.1.35",
3
+ "version": "0.1.38",
4
4
  "author": [
5
5
  {
6
6
  "name": "Soham Bharambe",
@@ -14,7 +14,7 @@
14
14
  "license": "Functional Source License, Version 1.1, Apache 2.0 Future License",
15
15
  "type": "module",
16
16
  "bin": {
17
- "lunel-cli": "./dist/index.js"
17
+ "lunel-cli": "dist/index.js"
18
18
  },
19
19
  "files": [
20
20
  "dist",
@@ -29,6 +29,7 @@
29
29
  "@opencode-ai/sdk": "^1.1.56",
30
30
  "ignore": "^6.0.2",
31
31
  "qrcode-terminal": "^0.12.0",
32
+ "simple-git": "^3.32.3",
32
33
  "ws": "^8.18.0"
33
34
  },
34
35
  "devDependencies": {