nomadexapp 0.2.0 → 0.2.2

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/bin/nomadex.mjs +182 -23
  2. package/package.json +1 -1
package/bin/nomadex.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { createServer } from "node:http";
4
- import { statSync, readFileSync, existsSync } from "node:fs";
4
+ import { statSync, readFileSync, existsSync, realpathSync } from "node:fs";
5
5
  import { readFile } from "node:fs/promises";
6
6
  import net from "node:net";
7
7
  import os from "node:os";
@@ -162,6 +162,9 @@ const parseVersion = (value) =>
162
162
  .split(".")
163
163
  .map((part) => Number.parseInt(part.replace(/[^0-9].*$/u, ""), 10) || 0);
164
164
 
165
+ const isInteractivePromptAvailable = () =>
166
+ Boolean(process.stdin.isTTY && process.stdout.isTTY);
167
+
165
168
  const compareVersions = (left, right) => {
166
169
  const maxLength = Math.max(left.length, right.length);
167
170
  for (let index = 0; index < maxLength; index += 1) {
@@ -345,7 +348,7 @@ const renderLoginPage = ({ errorMessage = "", nextPath = "/threads" } = {}) => {
345
348
  };
346
349
 
347
350
  const promptForUpdate = async () => {
348
- if (!options.updateCheck || !process.stdin.isTTY || !process.stdout.isTTY) {
351
+ if (!options.updateCheck || !isInteractivePromptAvailable()) {
349
352
  return false;
350
353
  }
351
354
 
@@ -424,16 +427,17 @@ const promptForUpdate = async () => {
424
427
  };
425
428
 
426
429
  const wsUrl = new URL(options.wsUrl);
427
- const wsHost = wsUrl.hostname;
428
- const wsPort = Number(wsUrl.port || (wsUrl.protocol === "wss:" ? 443 : 80));
429
- const readyzUrl = (() => {
430
- const target = new URL(options.wsUrl);
430
+ const getWsHost = () => wsUrl.hostname;
431
+ const getWsPort = () =>
432
+ Number(wsUrl.port || (wsUrl.protocol === "wss:" ? 443 : 80));
433
+ const getReadyzUrl = () => {
434
+ const target = new URL(wsUrl);
431
435
  target.protocol = target.protocol === "wss:" ? "https:" : "http:";
432
436
  target.pathname = "/readyz";
433
437
  target.search = "";
434
438
  target.hash = "";
435
439
  return target;
436
- })();
440
+ };
437
441
  const authRelayTarget = options.authRelayTarget;
438
442
  const isTermuxEnvironment = () => {
439
443
  const prefix = process.env.PREFIX ?? "";
@@ -443,6 +447,31 @@ const isTermuxEnvironment = () => {
443
447
  );
444
448
  };
445
449
 
450
+ const isLocalHost = (host) =>
451
+ ["127.0.0.1", "0.0.0.0", "localhost", "::1", "[::1]"].includes(
452
+ host.toLowerCase(),
453
+ );
454
+
455
+ const getPortProbeHost = (host) => {
456
+ const normalized = host.toLowerCase();
457
+ if (normalized === "localhost" || normalized === "0.0.0.0") {
458
+ return "127.0.0.1";
459
+ }
460
+ if (normalized === "[::1]") {
461
+ return "::1";
462
+ }
463
+ return host;
464
+ };
465
+
466
+ const isTransientNpxCodexPath = (candidate) => {
467
+ const normalized = candidate.replaceAll("\\", "/");
468
+ return (
469
+ normalized.includes("/.npm/_npx/") ||
470
+ normalized.includes("/node_modules/.bin/codex") ||
471
+ normalized.includes("/node_modules/@openai/codex/")
472
+ );
473
+ };
474
+
446
475
  const resolveLocalNodePackageLaunch = (packageName, binName) => {
447
476
  try {
448
477
  const packageJsonPath = require.resolve(`${packageName}/package.json`);
@@ -467,14 +496,24 @@ const resolveLocalNodePackageLaunch = (packageName, binName) => {
467
496
  }
468
497
  };
469
498
 
470
- const resolveExecutableOnPath = (commandNames) => {
499
+ const resolveExecutableOnPath = (commandNames, options = {}) => {
500
+ const { exclude } = options;
471
501
  const pathDirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
472
502
 
473
503
  for (const dir of pathDirs) {
474
504
  for (const commandName of commandNames) {
475
505
  const candidate = path.join(dir, commandName);
476
506
  try {
477
- if (statSync(candidate).isFile()) {
507
+ if (!statSync(candidate).isFile()) {
508
+ continue;
509
+ }
510
+
511
+ const resolved = realpathSync(candidate);
512
+ if (exclude?.(candidate, resolved)) {
513
+ continue;
514
+ }
515
+
516
+ if (resolved) {
478
517
  return candidate;
479
518
  }
480
519
  } catch {
@@ -504,6 +543,49 @@ const resolvePathCodexLaunch = () => {
504
543
  };
505
544
  };
506
545
 
546
+ const resolveTermuxCodexLaunch = () => {
547
+ const prefix = process.env.PREFIX?.trim() || "/data/data/com.termux/files/usr";
548
+ const directCandidates = [
549
+ path.join(prefix, "bin", "codex"),
550
+ "/data/data/com.termux/files/usr/bin/codex",
551
+ ];
552
+
553
+ for (const candidate of directCandidates) {
554
+ try {
555
+ if (statSync(candidate).isFile()) {
556
+ return {
557
+ command: candidate,
558
+ args: [],
559
+ shell: false,
560
+ source: "Termux codex",
561
+ };
562
+ }
563
+ } catch {
564
+ continue;
565
+ }
566
+ }
567
+
568
+ const commandNames =
569
+ process.platform === "win32"
570
+ ? ["codex.cmd", "codex.exe", "codex.bat", "codex"]
571
+ : ["codex"];
572
+ const command = resolveExecutableOnPath(commandNames, {
573
+ exclude: (candidate, resolved) =>
574
+ isTransientNpxCodexPath(candidate) || isTransientNpxCodexPath(resolved),
575
+ });
576
+
577
+ if (!command) {
578
+ return null;
579
+ }
580
+
581
+ return {
582
+ command,
583
+ args: [],
584
+ shell: false,
585
+ source: "Termux codex",
586
+ };
587
+ };
588
+
507
589
  const getCodexLaunch = () => {
508
590
  const explicitCommand = options.codexCommand.trim();
509
591
  if (explicitCommand) {
@@ -516,7 +598,9 @@ const getCodexLaunch = () => {
516
598
  }
517
599
 
518
600
  const dependencyLaunch = resolveLocalNodePackageLaunch("@openai/codex", "codex");
519
- const pathLaunch = resolvePathCodexLaunch();
601
+ const pathLaunch = isTermuxEnvironment()
602
+ ? resolveTermuxCodexLaunch()
603
+ : resolvePathCodexLaunch();
520
604
 
521
605
  if (isTermuxEnvironment()) {
522
606
  return pathLaunch ?? dependencyLaunch ?? {
@@ -554,9 +638,37 @@ const isPortOpen = (targetHost, targetPort) =>
554
638
  socket.connect(targetPort, targetHost);
555
639
  });
556
640
 
641
+ const findNextFreePort = async (targetHost, startPort, attempts = 20) => {
642
+ for (let port = startPort + 1; port < startPort + 1 + attempts; port += 1) {
643
+ if (!(await isPortOpen(targetHost, port))) {
644
+ return port;
645
+ }
646
+ }
647
+ return null;
648
+ };
649
+
650
+ const confirmAlternatePort = async (message) => {
651
+ if (!isInteractivePromptAvailable()) {
652
+ return true;
653
+ }
654
+
655
+ const rl = createInterface({
656
+ input: process.stdin,
657
+ output: process.stdout,
658
+ });
659
+
660
+ try {
661
+ const answer = await rl.question(`${message} [Y/n] `);
662
+ const normalized = answer.trim().toLowerCase();
663
+ return normalized === "" || !["n", "no"].includes(normalized);
664
+ } finally {
665
+ rl.close();
666
+ }
667
+ };
668
+
557
669
  const isCodexAppServerReady = async () => {
558
670
  try {
559
- const response = await fetch(readyzUrl, {
671
+ const response = await fetch(getReadyzUrl(), {
560
672
  signal: AbortSignal.timeout(500),
561
673
  });
562
674
  return response.ok;
@@ -570,9 +682,26 @@ const ensureUiPortAvailable = async () => {
570
682
  return;
571
683
  }
572
684
 
573
- throw new Error(
574
- `UI port ${options.uiPort} is already in use. Open the existing UI at http://127.0.0.1:${options.uiPort} or choose another port with --port.`,
685
+ const nextPort = await findNextFreePort("127.0.0.1", options.uiPort);
686
+ if (nextPort === null) {
687
+ throw new Error(
688
+ `UI port ${options.uiPort} is already in use and no nearby free port was found. Open the existing UI at http://127.0.0.1:${options.uiPort} or choose another port with --port.`,
689
+ );
690
+ }
691
+
692
+ const useAlternatePort = await confirmAlternatePort(
693
+ `[nomadexapp] UI port ${options.uiPort} is already in use. Use ${nextPort} instead?`,
694
+ );
695
+ if (!useAlternatePort) {
696
+ throw new Error(
697
+ `UI port ${options.uiPort} is already in use. Open the existing UI at http://127.0.0.1:${options.uiPort} or choose another port with --port.`,
698
+ );
699
+ }
700
+
701
+ console.log(
702
+ `[nomadexapp] UI port ${options.uiPort} is busy. Using ${nextPort} instead.`,
575
703
  );
704
+ options.uiPort = nextPort;
576
705
  };
577
706
 
578
707
  const formatSpawnError = (error) => {
@@ -621,22 +750,50 @@ const ensureDistBuilt = () => {
621
750
  };
622
751
 
623
752
  const ensureAppServer = async () => {
624
- if (await isPortOpen(wsHost, wsPort)) {
753
+ const wsHost = getWsHost();
754
+ const wsPort = getWsPort();
755
+ const probeHost = getPortProbeHost(wsHost);
756
+
757
+ if (await isPortOpen(probeHost, wsPort)) {
625
758
  if (await isCodexAppServerReady()) {
626
- console.log(`[nomadexapp] Reusing Codex app-server at ${options.wsUrl}`);
759
+ console.log(`[nomadexapp] Reusing Codex app-server at ${wsUrl}`);
627
760
  return;
628
761
  }
629
762
 
630
- throw new Error(
631
- `Port ${wsPort} on ${wsHost} is already in use, but it is not responding like a Codex app-server.`,
763
+ if (!isLocalHost(wsHost)) {
764
+ throw new Error(
765
+ `Port ${wsPort} on ${wsHost} is already in use, but it is not responding like a Codex app-server.`,
766
+ );
767
+ }
768
+
769
+ const nextPort = await findNextFreePort(probeHost, wsPort);
770
+ if (nextPort === null) {
771
+ throw new Error(
772
+ `Port ${wsPort} on ${wsHost} is already in use, and no nearby free local app-server port was found.`,
773
+ );
774
+ }
775
+
776
+ const useAlternatePort = await confirmAlternatePort(
777
+ `[nomadexapp] App-server port ${wsPort} is in use by another process. Use ${nextPort} instead?`,
778
+ );
779
+ if (!useAlternatePort) {
780
+ throw new Error(
781
+ `Port ${wsPort} on ${wsHost} is already in use, but it is not responding like a Codex app-server.`,
782
+ );
783
+ }
784
+
785
+ wsUrl.port = String(nextPort);
786
+ options.wsUrl = wsUrl.toString();
787
+ console.log(
788
+ `[nomadexapp] App-server port ${wsPort} is busy. Using ${wsUrl} instead.`,
632
789
  );
633
790
  }
634
791
 
635
792
  const codexLaunch = getCodexLaunch();
636
- console.log(`[nomadexapp] Starting Codex app-server at ${options.wsUrl}`);
793
+ console.log(`[nomadexapp] Starting Codex app-server at ${wsUrl}`);
637
794
  const appServer = spawn(
638
795
  codexLaunch.command,
639
- [...codexLaunch.args, "app-server", "--listen", options.wsUrl],
796
+ [...codexLaunch.args, "app-server", "--listen", wsUrl.toString()],
640
797
  {
641
798
  cwd: launchCwd,
642
799
  stdio: "inherit",
@@ -661,7 +818,8 @@ const ensureAppServer = async () => {
661
818
  }
662
819
  if (appServer.exitCode !== null) {
663
820
  const termuxHint =
664
- isTermuxEnvironment() && codexLaunch.source !== "PATH codex"
821
+ isTermuxEnvironment() &&
822
+ !["PATH codex", "Termux codex"].includes(codexLaunch.source)
665
823
  ? " Termux detected. Install `@mmmbuto/codex-cli-termux@latest`, ensure `codex` is on PATH, or launch Nomadex with `--codex-cmd codex`."
666
824
  : "";
667
825
  throw new Error(
@@ -671,7 +829,7 @@ const ensureAppServer = async () => {
671
829
  await sleep(200);
672
830
  }
673
831
 
674
- throw new Error(`Timed out waiting for Codex app-server at ${options.wsUrl}`);
832
+ throw new Error(`Timed out waiting for Codex app-server at ${wsUrl}`);
675
833
  };
676
834
 
677
835
  const sendText = (res, statusCode, message) => {
@@ -839,7 +997,6 @@ const getPreferredIp = () => {
839
997
  };
840
998
 
841
999
  const wsProxy = httpProxy.createProxyServer({
842
- target: options.wsUrl,
843
1000
  ws: true,
844
1001
  changeOrigin: true,
845
1002
  });
@@ -1065,7 +1222,9 @@ server.on("upgrade", (req, socket, head) => {
1065
1222
  return;
1066
1223
  }
1067
1224
  req.url = "/";
1068
- wsProxy.ws(req, socket, head);
1225
+ wsProxy.ws(req, socket, head, {
1226
+ target: wsUrl.toString(),
1227
+ });
1069
1228
  return;
1070
1229
  }
1071
1230
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nomadexapp",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Remote browser workspace for local coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",