lunel-cli 0.1.32 → 0.1.34

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 +203 -22
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ const ignore = Ignore.default;
8
8
  import * as fs from "fs/promises";
9
9
  import * as path from "path";
10
10
  import * as os from "os";
11
- import { spawn, execSync } from "child_process";
11
+ import { spawn, execSync, execFileSync } from "child_process";
12
12
  import { createServer, createConnection } from "net";
13
13
  import { createInterface } from "readline";
14
14
  const DEFAULT_PROXY_URL = process.env.LUNEL_PROXY_URL || "https://gateway.lunel.dev";
@@ -17,6 +17,9 @@ const CLI_ARGS = process.argv.slice(2);
17
17
  import { createRequire } from "module";
18
18
  const __require = createRequire(import.meta.url);
19
19
  const VERSION = __require("../package.json").version;
20
+ const PTY_RELEASE_INFO_URL = process.env.LUNEL_PTY_INFO_URL ||
21
+ "https://raw.githubusercontent.com/ssbharambe-m/pty-releases/refs/heads/main/info.json";
22
+ const VERBOSE_AI_LOGS = process.env.LUNEL_DEBUG_AI === "1";
20
23
  // Root directory - sandbox all file operations to this
21
24
  const ROOT_DIR = process.cwd();
22
25
  // Terminal sessions (managed by Rust PTY binary)
@@ -44,6 +47,13 @@ const PORT_SYNC_INTERVAL_MS = 30_000;
44
47
  let portSyncTimer = null;
45
48
  let portScanInFlight = false;
46
49
  let lastDiscoveredPorts = [];
50
+ function redactSensitive(input) {
51
+ const text = typeof input === "string" ? input : JSON.stringify(input);
52
+ return text
53
+ .replace(/([A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,})/g, "[redacted_jwt]")
54
+ .replace(/(password|token|authorization|resumeToken|x-manager-password)\s*[:=]\s*["']?[^"',\s}]+/gi, "$1=[redacted]")
55
+ .replace(/[A-Za-z0-9+/=_-]{40,}/g, "[redacted_secret]");
56
+ }
47
57
  // Popular development server ports to scan on connect
48
58
  const DEV_PORTS = [
49
59
  1234, // Parcel
@@ -595,14 +605,157 @@ async function handleGitDiscard(payload) {
595
605
  // Terminal Handlers (delegates to Rust PTY binary)
596
606
  // ============================================================================
597
607
  let dataChannel = null;
608
+ let ensurePtyBinaryPromise = null;
609
+ function normalizeJsonWithTrailingCommas(text) {
610
+ return text.replace(/,\s*([}\]])/g, "$1");
611
+ }
612
+ function compareSemver(a, b) {
613
+ const aParts = a.split(".").map((part) => Number.parseInt(part, 10) || 0);
614
+ const bParts = b.split(".").map((part) => Number.parseInt(part, 10) || 0);
615
+ const max = Math.max(aParts.length, bParts.length);
616
+ for (let i = 0; i < max; i++) {
617
+ const left = aParts[i] ?? 0;
618
+ const right = bParts[i] ?? 0;
619
+ if (left !== right)
620
+ return left > right ? 1 : -1;
621
+ }
622
+ return 0;
623
+ }
624
+ function getLunelConfigDir() {
625
+ const platform = os.platform();
626
+ if (platform === "win32") {
627
+ const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
628
+ return path.join(appData, "lunel");
629
+ }
630
+ if (platform === "darwin") {
631
+ return path.join(os.homedir(), "Library", "Application Support", "lunel");
632
+ }
633
+ const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
634
+ return path.join(xdg, "lunel");
635
+ }
598
636
  function getPtyBinaryPath() {
599
- // TODO: Switch to GitHub releases download for production
600
- return path.join(os.homedir(), "lunel-pty");
637
+ const binName = os.platform() === "win32" ? "lunel-pty.exe" : "lunel-pty";
638
+ return path.join(getLunelConfigDir(), "pty-releases", binName);
639
+ }
640
+ function getPtyPlatformKey() {
641
+ const platform = os.platform();
642
+ if (platform === "win32")
643
+ return "windows";
644
+ if (platform === "linux")
645
+ return "linux";
646
+ if (platform === "darwin")
647
+ return "macos";
648
+ throw new Error(`Unsupported platform for PTY: ${platform}`);
649
+ }
650
+ function getPtyArchKey() {
651
+ const arch = os.arch();
652
+ if (arch === "arm64" || arch === "arm")
653
+ return "arm";
654
+ if (arch === "x64" || arch === "ia32")
655
+ return "x86";
656
+ throw new Error(`Unsupported architecture for PTY: ${arch}`);
657
+ }
658
+ async function fetchPtyReleaseInfo() {
659
+ const response = await fetch(PTY_RELEASE_INFO_URL);
660
+ if (!response.ok) {
661
+ throw new Error(`Failed to fetch PTY release info (${response.status})`);
662
+ }
663
+ const raw = await response.text();
664
+ const parsed = JSON.parse(raw);
665
+ if (!parsed?.version) {
666
+ throw new Error("Invalid PTY release info: missing version");
667
+ }
668
+ return parsed;
669
+ }
670
+ function readInstalledPtyVersion(binPath) {
671
+ try {
672
+ const output = execFileSync(binPath, ["--version"], {
673
+ encoding: "utf8",
674
+ stdio: ["ignore", "pipe", "ignore"],
675
+ });
676
+ const version = output.trim();
677
+ return version || null;
678
+ }
679
+ catch {
680
+ return null;
681
+ }
682
+ }
683
+ async function downloadPtyBinary(url, destination) {
684
+ const tempPath = `${destination}.download`;
685
+ console.log("[pty] Downloading PTY [downloading...]");
686
+ const response = await fetch(url);
687
+ if (!response.ok) {
688
+ throw new Error(`Failed to download PTY binary (${response.status})`);
689
+ }
690
+ if (!response.body) {
691
+ throw new Error("PTY download response had no body");
692
+ }
693
+ const reader = response.body.getReader();
694
+ const chunks = [];
695
+ let totalBytes = 0;
696
+ while (true) {
697
+ const { done, value } = await reader.read();
698
+ if (done)
699
+ break;
700
+ if (value) {
701
+ chunks.push(value);
702
+ totalBytes += value.byteLength;
703
+ }
704
+ }
705
+ const binary = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
706
+ await fs.writeFile(tempPath, binary);
707
+ if (os.platform() !== "win32") {
708
+ await fs.chmod(tempPath, 0o755);
709
+ }
710
+ await fs.rename(tempPath, destination);
711
+ console.log(`[pty] Downloaded PTY (${Math.max(1, Math.round(totalBytes / 1024))} KB)`);
712
+ }
713
+ async function ensurePtyBinaryReady() {
714
+ if (ensurePtyBinaryPromise)
715
+ return ensurePtyBinaryPromise;
716
+ ensurePtyBinaryPromise = (async () => {
717
+ const binPath = getPtyBinaryPath();
718
+ await fs.mkdir(path.dirname(binPath), { recursive: true });
719
+ const releaseInfo = await fetchPtyReleaseInfo();
720
+ const platformKey = getPtyPlatformKey();
721
+ const archKey = getPtyArchKey();
722
+ const downloadUrl = releaseInfo[platformKey]?.[archKey] || null;
723
+ if (!downloadUrl) {
724
+ throw new Error(`PTY binary is not available for ${platformKey}/${archKey} in release ${releaseInfo.version}`);
725
+ }
726
+ let hasBinary = true;
727
+ try {
728
+ await fs.access(binPath);
729
+ }
730
+ catch {
731
+ hasBinary = false;
732
+ }
733
+ const installedVersion = hasBinary ? readInstalledPtyVersion(binPath) : null;
734
+ const shouldDownload = !hasBinary ||
735
+ !installedVersion ||
736
+ compareSemver(installedVersion, releaseInfo.version) < 0;
737
+ if (!shouldDownload)
738
+ return binPath;
739
+ if (!hasBinary) {
740
+ console.log(`[pty] PTY missing. Installing ${releaseInfo.version}...`);
741
+ }
742
+ else {
743
+ console.log(`[pty] PTY outdated (${installedVersion} -> ${releaseInfo.version}). Updating...`);
744
+ }
745
+ await downloadPtyBinary(downloadUrl, binPath);
746
+ return binPath;
747
+ })();
748
+ try {
749
+ return await ensurePtyBinaryPromise;
750
+ }
751
+ finally {
752
+ ensurePtyBinaryPromise = null;
753
+ }
601
754
  }
602
- function ensurePtyProcess() {
755
+ async function ensurePtyProcess() {
603
756
  if (ptyProcess && ptyProcess.exitCode === null)
604
757
  return;
605
- const binPath = getPtyBinaryPath();
758
+ const binPath = await ensurePtyBinaryReady();
606
759
  ptyProcess = spawn(binPath, [], {
607
760
  cwd: ROOT_DIR,
608
761
  stdio: ["pipe", "pipe", "pipe"],
@@ -690,7 +843,7 @@ function sendToPty(cmd) {
690
843
  ptyProcess.stdin.write(JSON.stringify(cmd) + "\n");
691
844
  }
692
845
  async function handleTerminalSpawn(payload) {
693
- ensurePtyProcess();
846
+ await ensurePtyProcess();
694
847
  const shell = payload.shell || process.env.SHELL || "/bin/sh";
695
848
  const cols = payload.cols || 80;
696
849
  const rows = payload.rows || 24;
@@ -1230,30 +1383,36 @@ function requireData(response, label) {
1230
1383
  const errMsg = response.error
1231
1384
  ? (typeof response.error === "string" ? response.error : JSON.stringify(response.error))
1232
1385
  : `${label} returned no data`;
1233
- console.error(`[ai] ${label} failed:`, errMsg, "raw response:", JSON.stringify(response).substring(0, 500));
1386
+ console.error(`[ai] ${label} failed:`, redactSensitive(errMsg), "raw response:", redactSensitive(JSON.stringify(response).substring(0, 500)));
1234
1387
  throw new Error(errMsg);
1235
1388
  }
1236
1389
  return response.data;
1237
1390
  }
1238
1391
  async function handleAiCreateSession(payload) {
1239
1392
  const title = payload.title || undefined;
1240
- console.log("[ai] createSession called, title:", title);
1393
+ if (VERBOSE_AI_LOGS)
1394
+ console.log("[ai] createSession called");
1241
1395
  try {
1242
1396
  const response = await opencodeClient.session.create({ body: { title } });
1243
- console.log("[ai] createSession response ok:", !!response.data, "error:", response.error ? JSON.stringify(response.error).substring(0, 200) : "none");
1397
+ if (VERBOSE_AI_LOGS) {
1398
+ console.log("[ai] createSession response ok:", !!response.data, "error:", response.error ? redactSensitive(JSON.stringify(response.error).substring(0, 200)) : "none");
1399
+ }
1244
1400
  return { session: requireData(response, "session.create") };
1245
1401
  }
1246
1402
  catch (err) {
1247
- console.error("[ai] createSession exception:", err.message, err.stack);
1403
+ console.error("[ai] createSession exception:", redactSensitive(err.message));
1248
1404
  throw err;
1249
1405
  }
1250
1406
  }
1251
1407
  async function handleAiListSessions() {
1252
- console.log("[ai] listSessions called");
1408
+ if (VERBOSE_AI_LOGS)
1409
+ console.log("[ai] listSessions called");
1253
1410
  try {
1254
1411
  const response = await opencodeClient.session.list();
1255
1412
  const data = requireData(response, "session.list");
1256
- console.log("[ai] listSessions returned", Array.isArray(data) ? data.length : typeof data, "sessions");
1413
+ if (VERBOSE_AI_LOGS) {
1414
+ console.log("[ai] listSessions returned", Array.isArray(data) ? data.length : typeof data, "sessions");
1415
+ }
1257
1416
  return { sessions: data };
1258
1417
  }
1259
1418
  catch (err) {
@@ -1275,7 +1434,8 @@ async function handleAiDeleteSession(payload) {
1275
1434
  }
1276
1435
  async function handleAiGetMessages(payload) {
1277
1436
  const id = payload.id;
1278
- console.log("[ai] getMessages called, sessionId:", id);
1437
+ if (VERBOSE_AI_LOGS)
1438
+ console.log("[ai] getMessages called");
1279
1439
  try {
1280
1440
  const response = await opencodeClient.session.messages({ path: { id } });
1281
1441
  const raw = requireData(response, "session.messages");
@@ -1287,7 +1447,8 @@ async function handleAiGetMessages(payload) {
1287
1447
  parts: m.parts || [],
1288
1448
  time: m.info.time,
1289
1449
  }));
1290
- console.log("[ai] getMessages returned", messages.length, "messages");
1450
+ if (VERBOSE_AI_LOGS)
1451
+ console.log("[ai] getMessages returned", messages.length, "messages");
1291
1452
  return { messages };
1292
1453
  }
1293
1454
  catch (err) {
@@ -1300,7 +1461,14 @@ async function handleAiPrompt(payload) {
1300
1461
  const text = payload.text;
1301
1462
  const model = payload.model;
1302
1463
  const agent = payload.agent;
1303
- console.log("[ai] prompt called, sessionId:", sessionId, "model:", JSON.stringify(model), "agent:", agent, "text:", text.substring(0, 100));
1464
+ if (VERBOSE_AI_LOGS) {
1465
+ console.log("[ai] prompt called", {
1466
+ hasSessionId: Boolean(sessionId),
1467
+ model: redactSensitive(JSON.stringify(model || {})),
1468
+ hasAgent: Boolean(agent),
1469
+ textLength: typeof text === "string" ? text.length : 0,
1470
+ });
1471
+ }
1304
1472
  // Fire and forget — results stream via SSE events forwarded on data channel
1305
1473
  opencodeClient.session.prompt({
1306
1474
  path: { id: sessionId },
@@ -1332,11 +1500,14 @@ async function handleAiAbort(payload) {
1332
1500
  return {};
1333
1501
  }
1334
1502
  async function handleAiAgents() {
1335
- console.log("[ai] getAgents called");
1503
+ if (VERBOSE_AI_LOGS)
1504
+ console.log("[ai] getAgents called");
1336
1505
  try {
1337
1506
  const response = await opencodeClient.app.agents();
1338
1507
  const data = requireData(response, "app.agents");
1339
- console.log("[ai] getAgents returned:", JSON.stringify(data).substring(0, 300));
1508
+ if (VERBOSE_AI_LOGS) {
1509
+ console.log("[ai] getAgents returned:", redactSensitive(JSON.stringify(data).substring(0, 300)));
1510
+ }
1340
1511
  return { agents: data };
1341
1512
  }
1342
1513
  catch (err) {
@@ -1345,11 +1516,14 @@ async function handleAiAgents() {
1345
1516
  }
1346
1517
  }
1347
1518
  async function handleAiProviders() {
1348
- console.log("[ai] getProviders called");
1519
+ if (VERBOSE_AI_LOGS)
1520
+ console.log("[ai] getProviders called");
1349
1521
  try {
1350
1522
  const response = await opencodeClient.config.providers();
1351
1523
  const data = requireData(response, "config.providers");
1352
- console.log("[ai] getProviders returned", data.providers?.length, "providers, defaults:", JSON.stringify(data.default));
1524
+ if (VERBOSE_AI_LOGS) {
1525
+ console.log("[ai] getProviders returned", data.providers?.length, "providers, defaults:", redactSensitive(JSON.stringify(data.default)));
1526
+ }
1353
1527
  return { providers: data.providers, default: data.default };
1354
1528
  }
1355
1529
  catch (err) {
@@ -1427,7 +1601,7 @@ async function subscribeToOpenCodeEvents(client) {
1427
1601
  ? parsed.payload
1428
1602
  : parsed;
1429
1603
  if (!base || typeof base.type !== "string") {
1430
- console.warn("[sse] Dropped malformed event:", JSON.stringify(parsed).substring(0, 200));
1604
+ console.warn("[sse] Dropped malformed event:", redactSensitive(JSON.stringify(parsed).substring(0, 200)));
1431
1605
  continue;
1432
1606
  }
1433
1607
  console.log("[sse]", base.type);
@@ -1639,7 +1813,7 @@ async function processMessage(message) {
1639
1813
  }
1640
1814
  // Validate required fields
1641
1815
  if (!ns || !action) {
1642
- console.warn("[router] Ignoring message with missing ns/action:", JSON.stringify(message).substring(0, 300));
1816
+ console.warn("[router] Ignoring message with missing ns/action:", redactSensitive(JSON.stringify(message).substring(0, 300)));
1643
1817
  return {
1644
1818
  v: 1,
1645
1819
  id,
@@ -2053,7 +2227,11 @@ async function connectWebSocket() {
2053
2227
  return;
2054
2228
  }
2055
2229
  if (message.type === "close_connection") {
2056
- console.log(`[session] closed by gateway: ${message.reason || "expired"}`);
2230
+ const reason = message.reason || "expired";
2231
+ console.log(`[session] closed by gateway: ${reason}`);
2232
+ if (reason === "session ended from app") {
2233
+ console.log("[session] Run `npx lunel-cli` again and scan the new QR code to reconnect.");
2234
+ }
2057
2235
  gracefulShutdown();
2058
2236
  return;
2059
2237
  }
@@ -2152,6 +2330,9 @@ async function main() {
2152
2330
  console.log(`Extra ports enabled: ${EXTRA_PORTS.join(", ")}`);
2153
2331
  }
2154
2332
  try {
2333
+ console.log("Checking PTY runtime...");
2334
+ await ensurePtyBinaryReady();
2335
+ console.log("PTY runtime ready.\n");
2155
2336
  // Generate auth credentials (like CodeNomad does)
2156
2337
  const opencodeUsername = "lunel";
2157
2338
  const opencodePassword = crypto.randomBytes(32).toString("base64url");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lunel-cli",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "author": [
5
5
  {
6
6
  "name": "Soham Bharambe",