codexapp 0.1.28 → 0.1.29

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-cli/index.js CHANGED
@@ -3,21 +3,22 @@
3
3
  // src/cli/index.ts
4
4
  import { createServer as createServer2 } from "http";
5
5
  import { chmodSync, createWriteStream, existsSync as existsSync2, mkdirSync } from "fs";
6
- import { readFile as readFile2 } from "fs/promises";
6
+ import { readFile as readFile3 } from "fs/promises";
7
7
  import { homedir as homedir2, networkInterfaces } from "os";
8
- import { join as join3 } from "path";
8
+ import { join as join4 } from "path";
9
9
  import { spawn as spawn2, spawnSync } from "child_process";
10
+ import { createInterface } from "readline/promises";
10
11
  import { fileURLToPath as fileURLToPath2 } from "url";
11
- import { dirname as dirname2 } from "path";
12
+ import { dirname as dirname3 } from "path";
12
13
  import { get as httpsGet } from "https";
13
14
  import { Command } from "commander";
14
15
  import qrcode from "qrcode-terminal";
15
16
 
16
17
  // src/server/httpServer.ts
17
18
  import { fileURLToPath } from "url";
18
- import { dirname, extname, isAbsolute as isAbsolute2, join as join2 } from "path";
19
+ import { dirname as dirname2, extname as extname2, isAbsolute as isAbsolute2, join as join3 } from "path";
19
20
  import { existsSync } from "fs";
20
- import { readdir as readdir2, stat as stat2 } from "fs/promises";
21
+ import { writeFile as writeFile2, stat as stat3 } from "fs/promises";
21
22
  import express from "express";
22
23
 
23
24
  // src/server/codexAppServerBridge.ts
@@ -1578,32 +1579,77 @@ function createAuthSession(password) {
1578
1579
  };
1579
1580
  }
1580
1581
 
1581
- // src/server/httpServer.ts
1582
- import { WebSocketServer } from "ws";
1583
- var __dirname = dirname(fileURLToPath(import.meta.url));
1584
- var distDir = join2(__dirname, "..", "dist");
1585
- var spaEntryFile = join2(distDir, "index.html");
1586
- var IMAGE_CONTENT_TYPES = {
1587
- ".avif": "image/avif",
1588
- ".bmp": "image/bmp",
1589
- ".gif": "image/gif",
1590
- ".jpeg": "image/jpeg",
1591
- ".jpg": "image/jpeg",
1592
- ".png": "image/png",
1593
- ".svg": "image/svg+xml",
1594
- ".webp": "image/webp"
1595
- };
1596
- function normalizeLocalImagePath(rawPath) {
1597
- const trimmed = rawPath.trim();
1598
- if (!trimmed) return "";
1599
- if (trimmed.startsWith("file://")) {
1600
- try {
1601
- return decodeURIComponent(trimmed.replace(/^file:\/\//u, ""));
1602
- } catch {
1603
- return trimmed.replace(/^file:\/\//u, "");
1604
- }
1582
+ // src/server/localBrowseUi.ts
1583
+ import { dirname, extname, join as join2 } from "path";
1584
+ import { open, readFile as readFile2, readdir as readdir2, stat as stat2 } from "fs/promises";
1585
+ var TEXT_EDITABLE_EXTENSIONS = /* @__PURE__ */ new Set([
1586
+ ".txt",
1587
+ ".md",
1588
+ ".json",
1589
+ ".js",
1590
+ ".ts",
1591
+ ".tsx",
1592
+ ".jsx",
1593
+ ".css",
1594
+ ".scss",
1595
+ ".html",
1596
+ ".htm",
1597
+ ".xml",
1598
+ ".yml",
1599
+ ".yaml",
1600
+ ".log",
1601
+ ".csv",
1602
+ ".env",
1603
+ ".py",
1604
+ ".sh",
1605
+ ".toml",
1606
+ ".ini",
1607
+ ".conf",
1608
+ ".sql",
1609
+ ".bat",
1610
+ ".cmd",
1611
+ ".ps1"
1612
+ ]);
1613
+ function languageForPath(pathValue) {
1614
+ const extension = extname(pathValue).toLowerCase();
1615
+ switch (extension) {
1616
+ case ".js":
1617
+ return "javascript";
1618
+ case ".ts":
1619
+ return "typescript";
1620
+ case ".jsx":
1621
+ return "javascript";
1622
+ case ".tsx":
1623
+ return "typescript";
1624
+ case ".py":
1625
+ return "python";
1626
+ case ".sh":
1627
+ return "sh";
1628
+ case ".css":
1629
+ case ".scss":
1630
+ return "css";
1631
+ case ".html":
1632
+ case ".htm":
1633
+ return "html";
1634
+ case ".json":
1635
+ return "json";
1636
+ case ".md":
1637
+ return "markdown";
1638
+ case ".yaml":
1639
+ case ".yml":
1640
+ return "yaml";
1641
+ case ".xml":
1642
+ return "xml";
1643
+ case ".sql":
1644
+ return "sql";
1645
+ case ".toml":
1646
+ return "ini";
1647
+ case ".ini":
1648
+ case ".conf":
1649
+ return "ini";
1650
+ default:
1651
+ return "plaintext";
1605
1652
  }
1606
- return trimmed;
1607
1653
  }
1608
1654
  function normalizeLocalPath(rawPath) {
1609
1655
  const trimmed = rawPath.trim();
@@ -1625,39 +1671,102 @@ function decodeBrowsePath(rawPath) {
1625
1671
  return rawPath;
1626
1672
  }
1627
1673
  }
1674
+ function isTextEditablePath(pathValue) {
1675
+ return TEXT_EDITABLE_EXTENSIONS.has(extname(pathValue).toLowerCase());
1676
+ }
1677
+ function looksLikeTextBuffer(buffer) {
1678
+ if (buffer.length === 0) return true;
1679
+ for (const byte of buffer) {
1680
+ if (byte === 0) return false;
1681
+ }
1682
+ const decoded = buffer.toString("utf8");
1683
+ const replacementCount = (decoded.match(/\uFFFD/gu) ?? []).length;
1684
+ return replacementCount / decoded.length < 0.05;
1685
+ }
1686
+ async function probeFileIsText(localPath) {
1687
+ const handle = await open(localPath, "r");
1688
+ try {
1689
+ const sample = Buffer.allocUnsafe(4096);
1690
+ const { bytesRead } = await handle.read(sample, 0, sample.length, 0);
1691
+ return looksLikeTextBuffer(sample.subarray(0, bytesRead));
1692
+ } finally {
1693
+ await handle.close();
1694
+ }
1695
+ }
1696
+ async function isTextEditableFile(localPath) {
1697
+ if (isTextEditablePath(localPath)) return true;
1698
+ try {
1699
+ const fileStat = await stat2(localPath);
1700
+ if (!fileStat.isFile()) return false;
1701
+ return await probeFileIsText(localPath);
1702
+ } catch {
1703
+ return false;
1704
+ }
1705
+ }
1628
1706
  function escapeHtml(value) {
1629
1707
  return value.replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;").replace(/"/gu, "&quot;").replace(/'/gu, "&#39;");
1630
1708
  }
1631
1709
  function toBrowseHref(pathValue) {
1632
1710
  return `/codex-local-browse${encodeURI(pathValue)}`;
1633
1711
  }
1634
- async function renderDirectoryListing(res, localPath) {
1712
+ function toEditHref(pathValue) {
1713
+ return `/codex-local-edit${encodeURI(pathValue)}`;
1714
+ }
1715
+ function escapeForInlineScriptString(value) {
1716
+ return JSON.stringify(value).replace(/<\//gu, "<\\/").replace(/<!--/gu, "<\\!--").replace(/\u2028/gu, "\\u2028").replace(/\u2029/gu, "\\u2029");
1717
+ }
1718
+ async function getDirectoryItems(localPath) {
1635
1719
  const entries = await readdir2(localPath, { withFileTypes: true });
1636
- const sorted = entries.slice().sort((a, b) => {
1637
- if (a.isDirectory() && !b.isDirectory()) return -1;
1638
- if (!a.isDirectory() && b.isDirectory()) return 1;
1720
+ const withMeta = await Promise.all(entries.map(async (entry) => {
1721
+ const entryPath = join2(localPath, entry.name);
1722
+ const entryStat = await stat2(entryPath);
1723
+ const editable = !entry.isDirectory() && await isTextEditableFile(entryPath);
1724
+ return {
1725
+ name: entry.name,
1726
+ path: entryPath,
1727
+ isDirectory: entry.isDirectory(),
1728
+ editable,
1729
+ mtimeMs: entryStat.mtimeMs
1730
+ };
1731
+ }));
1732
+ return withMeta.sort((a, b) => {
1733
+ if (b.mtimeMs !== a.mtimeMs) return b.mtimeMs - a.mtimeMs;
1734
+ if (a.isDirectory && !b.isDirectory) return -1;
1735
+ if (!a.isDirectory && b.isDirectory) return 1;
1639
1736
  return a.name.localeCompare(b.name);
1640
1737
  });
1738
+ }
1739
+ async function createDirectoryListingHtml(localPath) {
1740
+ const items = await getDirectoryItems(localPath);
1641
1741
  const parentPath = dirname(localPath);
1642
- const rows = sorted.map((entry) => {
1643
- const entryPath = join2(localPath, entry.name);
1644
- const suffix = entry.isDirectory() ? "/" : "";
1645
- return `<li><a href="${escapeHtml(toBrowseHref(entryPath))}">${escapeHtml(entry.name)}${suffix}</a></li>`;
1742
+ const rows = items.map((item) => {
1743
+ const suffix = item.isDirectory ? "/" : "";
1744
+ const editAction = item.editable ? ` <a class="icon-btn" aria-label="Edit ${escapeHtml(item.name)}" href="${escapeHtml(toEditHref(item.path))}" title="Edit">\u270F\uFE0F</a>` : "";
1745
+ return `<li class="file-row"><a class="file-link" href="${escapeHtml(toBrowseHref(item.path))}">${escapeHtml(item.name)}${suffix}</a>${editAction}</li>`;
1646
1746
  }).join("\n");
1647
1747
  const parentLink = localPath !== parentPath ? `<p><a href="${escapeHtml(toBrowseHref(parentPath))}">..</a></p>` : "";
1648
- const html = `<!doctype html>
1748
+ return `<!doctype html>
1649
1749
  <html lang="en">
1650
1750
  <head>
1651
1751
  <meta charset="utf-8" />
1652
1752
  <meta name="viewport" content="width=device-width, initial-scale=1" />
1653
1753
  <title>Index of ${escapeHtml(localPath)}</title>
1654
1754
  <style>
1655
- body { font-family: ui-monospace, Menlo, Monaco, monospace; margin: 24px; background: #0b1020; color: #dbe6ff; }
1755
+ body { font-family: ui-monospace, Menlo, Monaco, monospace; margin: 16px; background: #0b1020; color: #dbe6ff; }
1656
1756
  a { color: #8cc2ff; text-decoration: none; }
1657
1757
  a:hover { text-decoration: underline; }
1658
- ul { list-style: none; padding: 0; margin: 12px 0 0; }
1659
- li { padding: 3px 0; }
1758
+ ul { list-style: none; padding: 0; margin: 12px 0 0; display: flex; flex-direction: column; gap: 8px; }
1759
+ .file-row { display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 10px; }
1760
+ .file-link { display: block; padding: 10px 12px; border: 1px solid #28405f; border-radius: 10px; background: #0f1b33; overflow-wrap: anywhere; }
1761
+ .icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 42px; height: 42px; border: 1px solid #36557a; border-radius: 10px; background: #162643; text-decoration: none; }
1762
+ .icon-btn:hover { filter: brightness(1.08); text-decoration: none; }
1660
1763
  h1 { font-size: 18px; margin: 0; word-break: break-all; }
1764
+ @media (max-width: 640px) {
1765
+ body { margin: 12px; }
1766
+ .file-row { gap: 8px; }
1767
+ .file-link { font-size: 15px; padding: 12px; }
1768
+ .icon-btn { width: 44px; height: 44px; }
1769
+ }
1661
1770
  </style>
1662
1771
  </head>
1663
1772
  <body>
@@ -1666,7 +1775,107 @@ async function renderDirectoryListing(res, localPath) {
1666
1775
  <ul>${rows}</ul>
1667
1776
  </body>
1668
1777
  </html>`;
1669
- res.status(200).type("text/html; charset=utf-8").send(html);
1778
+ }
1779
+ async function createTextEditorHtml(localPath) {
1780
+ const content = await readFile2(localPath, "utf8");
1781
+ const parentPath = dirname(localPath);
1782
+ const language = languageForPath(localPath);
1783
+ const safeContentLiteral = escapeForInlineScriptString(content);
1784
+ return `<!doctype html>
1785
+ <html lang="en">
1786
+ <head>
1787
+ <meta charset="utf-8" />
1788
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1789
+ <title>Edit ${escapeHtml(localPath)}</title>
1790
+ <style>
1791
+ html, body { width: 100%; height: 100%; margin: 0; }
1792
+ body { font-family: ui-monospace, Menlo, Monaco, monospace; background: #0b1020; color: #dbe6ff; display: flex; flex-direction: column; overflow: hidden; }
1793
+ .toolbar { position: sticky; top: 0; z-index: 10; display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; background: #0b1020; border-bottom: 1px solid #243a5a; }
1794
+ .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
1795
+ button, a { background: #1b2a4a; color: #dbe6ff; border: 1px solid #345; padding: 6px 10px; border-radius: 6px; text-decoration: none; cursor: pointer; }
1796
+ button:hover, a:hover { filter: brightness(1.08); }
1797
+ #editor { flex: 1 1 auto; min-height: 0; width: 100%; border: none; overflow: hidden; }
1798
+ #status { margin-left: 8px; color: #8cc2ff; }
1799
+ .ace_editor { background: #07101f !important; color: #dbe6ff !important; width: 100% !important; height: 100% !important; }
1800
+ .ace_gutter { background: #07101f !important; color: #6f8eb5 !important; }
1801
+ .ace_marker-layer .ace_active-line { background: #10213c !important; }
1802
+ .ace_marker-layer .ace_selection { background: rgba(140, 194, 255, 0.3) !important; }
1803
+ .meta { opacity: 0.9; font-size: 12px; overflow-wrap: anywhere; }
1804
+ </style>
1805
+ </head>
1806
+ <body>
1807
+ <div class="toolbar">
1808
+ <div class="row">
1809
+ <a href="${escapeHtml(toBrowseHref(parentPath))}">Back</a>
1810
+ <button id="saveBtn" type="button">Save</button>
1811
+ <span id="status"></span>
1812
+ </div>
1813
+ <div class="meta">${escapeHtml(localPath)} \xB7 ${escapeHtml(language)}</div>
1814
+ </div>
1815
+ <div id="editor"></div>
1816
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.2/ace.js"></script>
1817
+ <script>
1818
+ const saveBtn = document.getElementById('saveBtn');
1819
+ const status = document.getElementById('status');
1820
+ const editor = ace.edit('editor');
1821
+ editor.setTheme('ace/theme/tomorrow_night');
1822
+ editor.session.setMode('ace/mode/${escapeHtml(language)}');
1823
+ editor.setValue(${safeContentLiteral}, -1);
1824
+ editor.setOptions({
1825
+ fontSize: '13px',
1826
+ wrap: true,
1827
+ showPrintMargin: false,
1828
+ useSoftTabs: true,
1829
+ tabSize: 2,
1830
+ behavioursEnabled: true,
1831
+ });
1832
+ editor.resize();
1833
+
1834
+ saveBtn.addEventListener('click', async () => {
1835
+ status.textContent = 'Saving...';
1836
+ const response = await fetch(location.pathname, {
1837
+ method: 'PUT',
1838
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
1839
+ body: editor.getValue(),
1840
+ });
1841
+ status.textContent = response.ok ? 'Saved' : 'Save failed';
1842
+ });
1843
+ </script>
1844
+ </body>
1845
+ </html>`;
1846
+ }
1847
+
1848
+ // src/server/httpServer.ts
1849
+ import { WebSocketServer } from "ws";
1850
+ var __dirname = dirname2(fileURLToPath(import.meta.url));
1851
+ var distDir = join3(__dirname, "..", "dist");
1852
+ var spaEntryFile = join3(distDir, "index.html");
1853
+ var IMAGE_CONTENT_TYPES = {
1854
+ ".avif": "image/avif",
1855
+ ".bmp": "image/bmp",
1856
+ ".gif": "image/gif",
1857
+ ".jpeg": "image/jpeg",
1858
+ ".jpg": "image/jpeg",
1859
+ ".png": "image/png",
1860
+ ".svg": "image/svg+xml",
1861
+ ".webp": "image/webp"
1862
+ };
1863
+ function normalizeLocalImagePath(rawPath) {
1864
+ const trimmed = rawPath.trim();
1865
+ if (!trimmed) return "";
1866
+ if (trimmed.startsWith("file://")) {
1867
+ try {
1868
+ return decodeURIComponent(trimmed.replace(/^file:\/\//u, ""));
1869
+ } catch {
1870
+ return trimmed.replace(/^file:\/\//u, "");
1871
+ }
1872
+ }
1873
+ return trimmed;
1874
+ }
1875
+ function readWildcardPathParam(value) {
1876
+ if (typeof value === "string") return value;
1877
+ if (Array.isArray(value)) return value.join("/");
1878
+ return "";
1670
1879
  }
1671
1880
  function createServer(options = {}) {
1672
1881
  const app = express();
@@ -1683,7 +1892,7 @@ function createServer(options = {}) {
1683
1892
  res.status(400).json({ error: "Expected absolute local file path." });
1684
1893
  return;
1685
1894
  }
1686
- const contentType = IMAGE_CONTENT_TYPES[extname(localPath).toLowerCase()];
1895
+ const contentType = IMAGE_CONTENT_TYPES[extname2(localPath).toLowerCase()];
1687
1896
  if (!contentType) {
1688
1897
  res.status(415).json({ error: "Unsupported image type." });
1689
1898
  return;
@@ -1710,17 +1919,18 @@ function createServer(options = {}) {
1710
1919
  });
1711
1920
  });
1712
1921
  app.get("/codex-local-browse/*path", async (req, res) => {
1713
- const rawPath = typeof req.params.path === "string" ? req.params.path : "";
1922
+ const rawPath = readWildcardPathParam(req.params.path);
1714
1923
  const localPath = decodeBrowsePath(`/${rawPath}`);
1715
1924
  if (!localPath || !isAbsolute2(localPath)) {
1716
1925
  res.status(400).json({ error: "Expected absolute local file path." });
1717
1926
  return;
1718
1927
  }
1719
1928
  try {
1720
- const fileStat = await stat2(localPath);
1929
+ const fileStat = await stat3(localPath);
1721
1930
  res.setHeader("Cache-Control", "private, no-store");
1722
1931
  if (fileStat.isDirectory()) {
1723
- await renderDirectoryListing(res, localPath);
1932
+ const html = await createDirectoryListingHtml(localPath);
1933
+ res.status(200).type("text/html; charset=utf-8").send(html);
1724
1934
  return;
1725
1935
  }
1726
1936
  res.sendFile(localPath, { dotfiles: "allow" }, (error) => {
@@ -1731,6 +1941,44 @@ function createServer(options = {}) {
1731
1941
  res.status(404).json({ error: "File not found." });
1732
1942
  }
1733
1943
  });
1944
+ app.get("/codex-local-edit/*path", async (req, res) => {
1945
+ const rawPath = readWildcardPathParam(req.params.path);
1946
+ const localPath = decodeBrowsePath(`/${rawPath}`);
1947
+ if (!localPath || !isAbsolute2(localPath)) {
1948
+ res.status(400).json({ error: "Expected absolute local file path." });
1949
+ return;
1950
+ }
1951
+ try {
1952
+ const fileStat = await stat3(localPath);
1953
+ if (!fileStat.isFile()) {
1954
+ res.status(400).json({ error: "Expected file path." });
1955
+ return;
1956
+ }
1957
+ const html = await createTextEditorHtml(localPath);
1958
+ res.status(200).type("text/html; charset=utf-8").send(html);
1959
+ } catch {
1960
+ res.status(404).json({ error: "File not found." });
1961
+ }
1962
+ });
1963
+ app.put("/codex-local-edit/*path", express.text({ type: "*/*", limit: "10mb" }), async (req, res) => {
1964
+ const rawPath = readWildcardPathParam(req.params.path);
1965
+ const localPath = decodeBrowsePath(`/${rawPath}`);
1966
+ if (!localPath || !isAbsolute2(localPath)) {
1967
+ res.status(400).json({ error: "Expected absolute local file path." });
1968
+ return;
1969
+ }
1970
+ if (!await isTextEditableFile(localPath)) {
1971
+ res.status(415).json({ error: "Only text-like files are editable." });
1972
+ return;
1973
+ }
1974
+ const body = typeof req.body === "string" ? req.body : "";
1975
+ try {
1976
+ await writeFile2(localPath, body, "utf8");
1977
+ res.status(200).json({ ok: true });
1978
+ } catch {
1979
+ res.status(404).json({ error: "File not found." });
1980
+ }
1981
+ });
1734
1982
  const hasFrontendAssets = existsSync(spaEntryFile);
1735
1983
  if (hasFrontendAssets) {
1736
1984
  app.use(express.static(distDir));
@@ -1802,11 +2050,11 @@ function generatePassword() {
1802
2050
 
1803
2051
  // src/cli/index.ts
1804
2052
  var program = new Command().name("codexui").description("Web interface for Codex app-server");
1805
- var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
2053
+ var __dirname2 = dirname3(fileURLToPath2(import.meta.url));
1806
2054
  async function readCliVersion() {
1807
2055
  try {
1808
- const packageJsonPath = join3(__dirname2, "..", "package.json");
1809
- const raw = await readFile2(packageJsonPath, "utf8");
2056
+ const packageJsonPath = join4(__dirname2, "..", "package.json");
2057
+ const raw = await readFile3(packageJsonPath, "utf8");
1810
2058
  const parsed = JSON.parse(raw);
1811
2059
  return typeof parsed.version === "string" ? parsed.version : "unknown";
1812
2060
  } catch {
@@ -1831,13 +2079,13 @@ function runWithStatus(command, args) {
1831
2079
  return result.status ?? -1;
1832
2080
  }
1833
2081
  function getUserNpmPrefix() {
1834
- return join3(homedir2(), ".npm-global");
2082
+ return join4(homedir2(), ".npm-global");
1835
2083
  }
1836
2084
  function resolveCodexCommand() {
1837
2085
  if (canRun("codex", ["--version"])) {
1838
2086
  return "codex";
1839
2087
  }
1840
- const userCandidate = join3(getUserNpmPrefix(), "bin", "codex");
2088
+ const userCandidate = join4(getUserNpmPrefix(), "bin", "codex");
1841
2089
  if (existsSync2(userCandidate) && canRun(userCandidate, ["--version"])) {
1842
2090
  return userCandidate;
1843
2091
  }
@@ -1845,7 +2093,7 @@ function resolveCodexCommand() {
1845
2093
  if (!prefix) {
1846
2094
  return null;
1847
2095
  }
1848
- const candidate = join3(prefix, "bin", "codex");
2096
+ const candidate = join4(prefix, "bin", "codex");
1849
2097
  if (existsSync2(candidate) && canRun(candidate, ["--version"])) {
1850
2098
  return candidate;
1851
2099
  }
@@ -1855,7 +2103,7 @@ function resolveCloudflaredCommand() {
1855
2103
  if (canRun("cloudflared", ["--version"])) {
1856
2104
  return "cloudflared";
1857
2105
  }
1858
- const localCandidate = join3(homedir2(), ".local", "bin", "cloudflared");
2106
+ const localCandidate = join4(homedir2(), ".local", "bin", "cloudflared");
1859
2107
  if (existsSync2(localCandidate) && canRun(localCandidate, ["--version"])) {
1860
2108
  return localCandidate;
1861
2109
  }
@@ -1909,9 +2157,9 @@ async function ensureCloudflaredInstalledLinux() {
1909
2157
  if (!mappedArch) {
1910
2158
  throw new Error(`cloudflared auto-install is not supported for Linux architecture: ${process.arch}`);
1911
2159
  }
1912
- const userBinDir = join3(homedir2(), ".local", "bin");
2160
+ const userBinDir = join4(homedir2(), ".local", "bin");
1913
2161
  mkdirSync(userBinDir, { recursive: true });
1914
- const destination = join3(userBinDir, "cloudflared");
2162
+ const destination = join4(userBinDir, "cloudflared");
1915
2163
  const downloadUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${mappedArch}`;
1916
2164
  console.log("\ncloudflared not found. Installing to ~/.local/bin...\n");
1917
2165
  await downloadFile(downloadUrl, destination);
@@ -1924,9 +2172,34 @@ async function ensureCloudflaredInstalledLinux() {
1924
2172
  console.log("\ncloudflared installed.\n");
1925
2173
  return installed;
1926
2174
  }
2175
+ async function shouldInstallCloudflaredInteractively() {
2176
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
2177
+ console.warn("\n[cloudflared] cloudflared is missing and terminal is non-interactive, skipping install.");
2178
+ return false;
2179
+ }
2180
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
2181
+ try {
2182
+ const answer = await prompt.question("cloudflared is not installed. Install it now to ~/.local/bin? [y/N] ");
2183
+ const normalized = answer.trim().toLowerCase();
2184
+ return normalized === "y" || normalized === "yes";
2185
+ } finally {
2186
+ prompt.close();
2187
+ }
2188
+ }
2189
+ async function resolveCloudflaredForTunnel() {
2190
+ const current = resolveCloudflaredCommand();
2191
+ if (current) {
2192
+ return current;
2193
+ }
2194
+ const installApproved = await shouldInstallCloudflaredInteractively();
2195
+ if (!installApproved) {
2196
+ return null;
2197
+ }
2198
+ return ensureCloudflaredInstalledLinux();
2199
+ }
1927
2200
  function hasCodexAuth() {
1928
- const codexHome = process.env.CODEX_HOME?.trim() || join3(homedir2(), ".codex");
1929
- return existsSync2(join3(codexHome, "auth.json"));
2201
+ const codexHome = process.env.CODEX_HOME?.trim() || join4(homedir2(), ".codex");
2202
+ return existsSync2(join4(codexHome, "auth.json"));
1930
2203
  }
1931
2204
  function ensureCodexInstalled() {
1932
2205
  let codexCommand = resolveCodexCommand();
@@ -1944,7 +2217,7 @@ function ensureCodexInstalled() {
1944
2217
  Global npm install requires elevated permissions. Retrying with --prefix ${userPrefix}...
1945
2218
  `);
1946
2219
  runOrFail("npm", ["install", "-g", "--prefix", userPrefix, pkg], `${label} (user prefix)`);
1947
- process.env.PATH = `${join3(userPrefix, "bin")}:${process.env.PATH ?? ""}`;
2220
+ process.env.PATH = `${join4(userPrefix, "bin")}:${process.env.PATH ?? ""}`;
1948
2221
  };
1949
2222
  if (isTermuxRuntime()) {
1950
2223
  console.log("\nCodex CLI not found. Installing Termux-compatible Codex CLI from npm...\n");
@@ -2098,7 +2371,10 @@ async function startServer(options) {
2098
2371
  let tunnelUrl = null;
2099
2372
  if (options.tunnel) {
2100
2373
  try {
2101
- const cloudflaredCommand = await ensureCloudflaredInstalledLinux() ?? "cloudflared";
2374
+ const cloudflaredCommand = await resolveCloudflaredForTunnel();
2375
+ if (!cloudflaredCommand) {
2376
+ throw new Error("cloudflared is not installed");
2377
+ }
2102
2378
  const tunnel = await startCloudflaredTunnel(cloudflaredCommand, port);
2103
2379
  tunnelChild = tunnel.process;
2104
2380
  tunnelUrl = tunnel.url;