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.
- package/dist/index.js +175 -33
- 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
|
-
//
|
|
271
|
-
const isBinary = content.
|
|
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
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
socket.
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
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
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
const
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
}
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
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.
|
|
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": "
|
|
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": {
|