nomadexapp 0.1.0

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 (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +224 -0
  3. package/bin/nomadex.mjs +1026 -0
  4. package/dist/assets/apl-Dgw9L7wA.js +1 -0
  5. package/dist/assets/asciiarmor-B3T-Ib7X.js +1 -0
  6. package/dist/assets/asn1-C0KgO0Wt.js +1 -0
  7. package/dist/assets/asterisk-D7tOY-Ef.js +1 -0
  8. package/dist/assets/brainfuck-Dr6WXxS2.js +1 -0
  9. package/dist/assets/clike-DQfG49Bz.js +1 -0
  10. package/dist/assets/clojure-DsDsbt3V.js +1 -0
  11. package/dist/assets/cmake-CaoY67QC.js +1 -0
  12. package/dist/assets/cobol-ng5WMKOY.js +1 -0
  13. package/dist/assets/coffeescript-L66efR1O.js +1 -0
  14. package/dist/assets/commonlisp-rk__8_Ow.js +1 -0
  15. package/dist/assets/crystal-C1BeQSjD.js +1 -0
  16. package/dist/assets/css-zrdFFahf.js +1 -0
  17. package/dist/assets/cypher-DTKBhe_T.js +1 -0
  18. package/dist/assets/d-CDf89RKe.js +1 -0
  19. package/dist/assets/diff-BY4_GICG.js +1 -0
  20. package/dist/assets/dist--QplBylv.js +6 -0
  21. package/dist/assets/dist-BOz4jprC.js +1 -0
  22. package/dist/assets/dist-BSpuB3dZ.js +1 -0
  23. package/dist/assets/dist-BVTS1puf.js +1 -0
  24. package/dist/assets/dist-BvjF9csH.js +1 -0
  25. package/dist/assets/dist-ByyB_57-.js +11 -0
  26. package/dist/assets/dist-C5sjJwrm.js +1 -0
  27. package/dist/assets/dist-C6S96i9v.js +1 -0
  28. package/dist/assets/dist-CUQQ4au1.js +1 -0
  29. package/dist/assets/dist-C_ZftsU-.js +1 -0
  30. package/dist/assets/dist-Cc03jTPl.js +1 -0
  31. package/dist/assets/dist-CftiQ_Tf.js +1 -0
  32. package/dist/assets/dist-ChuUwcd4.js +2 -0
  33. package/dist/assets/dist-Cuva2wRW.js +1 -0
  34. package/dist/assets/dist-CyFWfPpu.js +9 -0
  35. package/dist/assets/dist-D6kXrcQY.js +1 -0
  36. package/dist/assets/dist-DH3Q_V27.js +23 -0
  37. package/dist/assets/dist-DIQDx3Dq.js +1 -0
  38. package/dist/assets/dist-DO7LTg3a.js +1 -0
  39. package/dist/assets/dist-DYBvJnrG.js +1 -0
  40. package/dist/assets/dist-DhhFPaYP.js +13 -0
  41. package/dist/assets/dist-elfU6IUT.js +1 -0
  42. package/dist/assets/dist-mCTXNkQ9.js +1 -0
  43. package/dist/assets/dockerfile-BBRxgo6-.js +1 -0
  44. package/dist/assets/dtd-BOpcurKR.js +1 -0
  45. package/dist/assets/dylan-DrmRAfMX.js +1 -0
  46. package/dist/assets/ebnf-DgECi_W8.js +1 -0
  47. package/dist/assets/ecl-CWDhUBvl.js +1 -0
  48. package/dist/assets/eiffel-DBpGy7gs.js +1 -0
  49. package/dist/assets/elm-Dj_ZNWRH.js +1 -0
  50. package/dist/assets/erlang-Br4TQPB7.js +1 -0
  51. package/dist/assets/factor-bi5FmZTz.js +1 -0
  52. package/dist/assets/fcl-Dh6ibuJG.js +1 -0
  53. package/dist/assets/forth-fmpuPpMA.js +1 -0
  54. package/dist/assets/fortran-CZwiXuJZ.js +1 -0
  55. package/dist/assets/gas-DotCoURX.js +1 -0
  56. package/dist/assets/gherkin-Bw4mmeRF.js +1 -0
  57. package/dist/assets/groovy-D7xek06K.js +1 -0
  58. package/dist/assets/haskell-D5bv2Ijt.js +1 -0
  59. package/dist/assets/haxe-UA0z6FgT.js +1 -0
  60. package/dist/assets/http-DAkDDIPY.js +1 -0
  61. package/dist/assets/idl-DYj57TA2.js +1 -0
  62. package/dist/assets/index-BpFNmvYv.js +214 -0
  63. package/dist/assets/index-De1iEP2f.css +1 -0
  64. package/dist/assets/javascript-B9Zm1tmO.js +1 -0
  65. package/dist/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  66. package/dist/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  67. package/dist/assets/jetbrains-mono-cyrillic-600-normal-8K4wrrwR.woff +0 -0
  68. package/dist/assets/jetbrains-mono-cyrillic-600-normal-EVf6-Yzo.woff2 +0 -0
  69. package/dist/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  70. package/dist/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  71. package/dist/assets/jetbrains-mono-greek-600-normal-H7WoG9Et.woff2 +0 -0
  72. package/dist/assets/jetbrains-mono-greek-600-normal-mc2nkWzM.woff +0 -0
  73. package/dist/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  74. package/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  75. package/dist/assets/jetbrains-mono-latin-600-normal-BfsvjouI.woff +0 -0
  76. package/dist/assets/jetbrains-mono-latin-600-normal-C8RAYTDA.woff2 +0 -0
  77. package/dist/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  78. package/dist/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  79. package/dist/assets/jetbrains-mono-latin-ext-600-normal-BfB_LPfz.woff2 +0 -0
  80. package/dist/assets/jetbrains-mono-latin-ext-600-normal-DObL3zCW.woff +0 -0
  81. package/dist/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  82. package/dist/assets/jetbrains-mono-vietnamese-600-normal-OWROknRo.woff +0 -0
  83. package/dist/assets/julia-CpHdnx_N.js +1 -0
  84. package/dist/assets/livescript-DcEJKRZ4.js +1 -0
  85. package/dist/assets/lua-s-LPZohT.js +1 -0
  86. package/dist/assets/mathematica-c8TKcpav.js +1 -0
  87. package/dist/assets/mbox-CUr7-CiI.js +1 -0
  88. package/dist/assets/mirc-pTdL1Ym3.js +1 -0
  89. package/dist/assets/mllike-DsgO-njf.js +1 -0
  90. package/dist/assets/modelica-PhVkZonB.js +1 -0
  91. package/dist/assets/mscgen-ByD0zE8S.js +1 -0
  92. package/dist/assets/mumps-DhD4kjw9.js +1 -0
  93. package/dist/assets/nginx-CJc6p_yt.js +1 -0
  94. package/dist/assets/nsis-BHchP6IS.js +1 -0
  95. package/dist/assets/ntriples-D1v3iWDa.js +1 -0
  96. package/dist/assets/octave-D_WvWxqk.js +1 -0
  97. package/dist/assets/oz-CmdX4m3g.js +1 -0
  98. package/dist/assets/pascal-hNOkBp6J.js +1 -0
  99. package/dist/assets/perl-NQdG1b_r.js +1 -0
  100. package/dist/assets/pig-CXzedlZV.js +1 -0
  101. package/dist/assets/powershell-RXDVpFWf.js +1 -0
  102. package/dist/assets/properties-BTvtucqR.js +1 -0
  103. package/dist/assets/protobuf-zHRYyuba.js +1 -0
  104. package/dist/assets/pug-C39WZDG2.js +1 -0
  105. package/dist/assets/puppet-CMNLsBcD.js +1 -0
  106. package/dist/assets/python-_LoDBjEZ.js +1 -0
  107. package/dist/assets/q-SLmsw_60.js +1 -0
  108. package/dist/assets/r-CocOMjw8.js +1 -0
  109. package/dist/assets/rpm-WgkTpiCm.js +1 -0
  110. package/dist/assets/ruby-DquNeiGE.js +1 -0
  111. package/dist/assets/sas-sJxciv6G.js +1 -0
  112. package/dist/assets/scheme-DEjJDX5Z.js +1 -0
  113. package/dist/assets/shell-DhAwSAum.js +1 -0
  114. package/dist/assets/sieve-CcW8-k2R.js +1 -0
  115. package/dist/assets/simple-mode-BwNcRC8_.js +1 -0
  116. package/dist/assets/smalltalk-nXkQ5zwM.js +1 -0
  117. package/dist/assets/solr-DO54nYOg.js +1 -0
  118. package/dist/assets/sora-latin-400-normal-CRt88UEn.woff2 +0 -0
  119. package/dist/assets/sora-latin-400-normal-OW7qkl5a.woff +0 -0
  120. package/dist/assets/sora-latin-600-normal-1_7fyUAY.woff +0 -0
  121. package/dist/assets/sora-latin-600-normal-Cdg4DaK0.woff2 +0 -0
  122. package/dist/assets/sora-latin-700-normal-9waGdLWo.woff2 +0 -0
  123. package/dist/assets/sora-latin-700-normal-BKPfQAnC.woff +0 -0
  124. package/dist/assets/sora-latin-ext-400-normal-BmhJC382.woff +0 -0
  125. package/dist/assets/sora-latin-ext-400-normal-Twk1CgKs.woff2 +0 -0
  126. package/dist/assets/sora-latin-ext-600-normal-Cue1zdhl.woff2 +0 -0
  127. package/dist/assets/sora-latin-ext-600-normal-DLOJK0Ta.woff +0 -0
  128. package/dist/assets/sora-latin-ext-700-normal-DM0oy5s8.woff2 +0 -0
  129. package/dist/assets/sora-latin-ext-700-normal-Oc7uZIYt.woff +0 -0
  130. package/dist/assets/sparql-CxAS_pwk.js +1 -0
  131. package/dist/assets/spreadsheet-DWzAMKF2.js +1 -0
  132. package/dist/assets/sql-C_mHN2XB.js +1 -0
  133. package/dist/assets/stex-BmuQ0Xsx.js +1 -0
  134. package/dist/assets/stylus-CQ8h_EBd.js +1 -0
  135. package/dist/assets/swift-D0Eq5raO.js +1 -0
  136. package/dist/assets/tcl-D7utka2n.js +1 -0
  137. package/dist/assets/textile-XEeoev-k.js +1 -0
  138. package/dist/assets/tiddlywiki-CecErCuV.js +1 -0
  139. package/dist/assets/tiki-BlL-kki4.js +1 -0
  140. package/dist/assets/toml-mmtg9INI.js +1 -0
  141. package/dist/assets/troff-Crc1ZuaH.js +1 -0
  142. package/dist/assets/ttcn-Cri02b1f.js +1 -0
  143. package/dist/assets/ttcn-cfg--4YiJN4x.js +1 -0
  144. package/dist/assets/turtle-CUuoUr2N.js +1 -0
  145. package/dist/assets/vb-BrHulZxe.js +1 -0
  146. package/dist/assets/vbscript-vyHcteNE.js +1 -0
  147. package/dist/assets/velocity-DN4o8rq-.js +1 -0
  148. package/dist/assets/verilog-B0DGHLZN.js +1 -0
  149. package/dist/assets/vhdl-WeAF-qEm.js +1 -0
  150. package/dist/assets/webidl-C_bKeJT2.js +1 -0
  151. package/dist/assets/xquery-CLyH3kVm.js +1 -0
  152. package/dist/assets/yacas-Dq3LuV9B.js +1 -0
  153. package/dist/assets/z80-Br7weo_y.js +1 -0
  154. package/dist/favicon.svg +54 -0
  155. package/dist/icons.svg +24 -0
  156. package/dist/index.html +59 -0
  157. package/docs/ARCHITECTURE.md +194 -0
  158. package/docs/SETUP.md +242 -0
  159. package/docs/ui-desktop.png +0 -0
  160. package/docs/ui-desktop.svg +190 -0
  161. package/docs/ui-mobile.png +0 -0
  162. package/docs/ui-mobile.svg +113 -0
  163. package/package.json +79 -0
@@ -0,0 +1,1026 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createServer } from "node:http";
4
+ import { statSync, readFileSync, existsSync } from "node:fs";
5
+ import { readFile } from "node:fs/promises";
6
+ import net from "node:net";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import process from "node:process";
10
+ import { spawn } from "node:child_process";
11
+ import { randomBytes, timingSafeEqual } from "node:crypto";
12
+ import { createRequire } from "node:module";
13
+ import { createInterface } from "node:readline/promises";
14
+ import { fileURLToPath } from "node:url";
15
+ import httpProxy from "http-proxy";
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const packageRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
19
+ const distDir = path.join(packageRoot, "dist");
20
+ const packageJson = JSON.parse(
21
+ readFileSync(path.join(packageRoot, "package.json"), "utf8"),
22
+ );
23
+ const launchCwd = process.cwd();
24
+ const children = [];
25
+
26
+ const CONTENT_TYPES = {
27
+ ".css": "text/css; charset=utf-8",
28
+ ".gif": "image/gif",
29
+ ".html": "text/html; charset=utf-8",
30
+ ".ico": "image/x-icon",
31
+ ".jpeg": "image/jpeg",
32
+ ".jpg": "image/jpeg",
33
+ ".js": "text/javascript; charset=utf-8",
34
+ ".json": "application/json; charset=utf-8",
35
+ ".map": "application/json; charset=utf-8",
36
+ ".md": "text/markdown; charset=utf-8",
37
+ ".png": "image/png",
38
+ ".svg": "image/svg+xml",
39
+ ".txt": "text/plain; charset=utf-8",
40
+ ".webp": "image/webp",
41
+ ".woff": "font/woff",
42
+ ".woff2": "font/woff2",
43
+ };
44
+
45
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
46
+
47
+ const parseArgs = (argv) => {
48
+ const options = {
49
+ host: process.env.NOMADEX_HOST ?? "0.0.0.0",
50
+ uiPort:
51
+ process.env.NOMADEX_UI_PORT ??
52
+ process.env.VITE_CODEX_UI_PORT ??
53
+ "3784",
54
+ wsUrl:
55
+ process.env.NOMADEX_WS_URL ??
56
+ process.env.VITE_CODEX_WS_URL ??
57
+ "ws://127.0.0.1:3901",
58
+ authRelayTarget:
59
+ process.env.NOMADEX_AUTH_RELAY_TARGET ??
60
+ process.env.VITE_CODEX_AUTH_RELAY_TARGET ??
61
+ "http://127.0.0.1:1455",
62
+ password: process.env.NOMADEX_PASSWORD ?? "",
63
+ updateCheck:
64
+ process.env.NOMADEX_NO_UPDATE_CHECK === "1" ? false : true,
65
+ };
66
+
67
+ for (let index = 0; index < argv.length; index += 1) {
68
+ const arg = argv[index];
69
+ if (arg === "--help" || arg === "-h") {
70
+ options.help = true;
71
+ continue;
72
+ }
73
+ if (arg === "--version" || arg === "-v") {
74
+ options.version = true;
75
+ continue;
76
+ }
77
+ if (arg === "--host") {
78
+ options.host = argv[index + 1] ?? options.host;
79
+ index += 1;
80
+ continue;
81
+ }
82
+ if (arg === "--port") {
83
+ options.uiPort = argv[index + 1] ?? options.uiPort;
84
+ index += 1;
85
+ continue;
86
+ }
87
+ if (arg === "--ws-url") {
88
+ options.wsUrl = argv[index + 1] ?? options.wsUrl;
89
+ index += 1;
90
+ continue;
91
+ }
92
+ if (arg === "--auth-relay-target") {
93
+ options.authRelayTarget = argv[index + 1] ?? options.authRelayTarget;
94
+ index += 1;
95
+ continue;
96
+ }
97
+ if (arg === "--password") {
98
+ options.password = argv[index + 1] ?? options.password;
99
+ index += 1;
100
+ continue;
101
+ }
102
+ if (arg === "--no-update-check") {
103
+ options.updateCheck = false;
104
+ }
105
+ }
106
+
107
+ return {
108
+ ...options,
109
+ uiPort: Number(options.uiPort),
110
+ };
111
+ };
112
+
113
+ const options = parseArgs(process.argv.slice(2));
114
+
115
+ if (options.help) {
116
+ process.stdout.write(`Nomadex ${packageJson.version}
117
+
118
+ Usage:
119
+ nomadexapp
120
+ nomadexapp --port 3784 --ws-url ws://127.0.0.1:3901
121
+
122
+ Options:
123
+ --host <host> Host to bind the UI server (default: 0.0.0.0)
124
+ --port <port> UI port (default: 3784)
125
+ --ws-url <url> App-server websocket target (default: ws://127.0.0.1:3901)
126
+ --auth-relay-target <url> Auth relay HTTP target (default: http://127.0.0.1:1455)
127
+ --password <value> UI password (default: generated per launch)
128
+ --no-update-check Skip npm registry update prompt
129
+ --help Show this help
130
+ --version Print the package version
131
+ `);
132
+ process.exit(0);
133
+ }
134
+
135
+ if (options.version) {
136
+ process.stdout.write(`${packageJson.version}\n`);
137
+ process.exit(0);
138
+ }
139
+
140
+ if (!Number.isInteger(options.uiPort) || options.uiPort <= 0) {
141
+ console.error("[nomadexapp] Invalid UI port.");
142
+ process.exit(1);
143
+ }
144
+
145
+ const rawLaunchPassword = options.password.trim();
146
+ const appPassword = rawLaunchPassword || randomBytes(9).toString("base64url");
147
+ const passwordSource = rawLaunchPassword ? "configured" : "generated";
148
+ const sessionCookieName = "nomadex_session";
149
+ const uiSessions = new Set();
150
+
151
+ const isTruthyYes = (value) => /^(1|y|yes|true)$/iu.test(value.trim());
152
+
153
+ const parseVersion = (value) =>
154
+ value
155
+ .split(".")
156
+ .map((part) => Number.parseInt(part.replace(/[^0-9].*$/u, ""), 10) || 0);
157
+
158
+ const compareVersions = (left, right) => {
159
+ const maxLength = Math.max(left.length, right.length);
160
+ for (let index = 0; index < maxLength; index += 1) {
161
+ const a = left[index] ?? 0;
162
+ const b = right[index] ?? 0;
163
+ if (a !== b) {
164
+ return a < b ? -1 : 1;
165
+ }
166
+ }
167
+ return 0;
168
+ };
169
+
170
+ const parseCookies = (rawCookie) => {
171
+ const cookies = {};
172
+ for (const part of rawCookie?.split(";") ?? []) {
173
+ const separatorIndex = part.indexOf("=");
174
+ if (separatorIndex <= 0) {
175
+ continue;
176
+ }
177
+ const name = part.slice(0, separatorIndex).trim();
178
+ const value = part.slice(separatorIndex + 1).trim();
179
+ if (!name) {
180
+ continue;
181
+ }
182
+ cookies[name] = decodeURIComponent(value);
183
+ }
184
+ return cookies;
185
+ };
186
+
187
+ const createSessionToken = () => {
188
+ const token = randomBytes(24).toString("base64url");
189
+ uiSessions.add(token);
190
+ return token;
191
+ };
192
+
193
+ const getSessionToken = (req) => parseCookies(req.headers.cookie)[sessionCookieName] ?? null;
194
+
195
+ const isAuthenticatedRequest = (req) => {
196
+ const token = getSessionToken(req);
197
+ return token ? uiSessions.has(token) : false;
198
+ };
199
+
200
+ const setSessionCookie = (res, token) => {
201
+ res.setHeader(
202
+ "Set-Cookie",
203
+ `${sessionCookieName}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax`,
204
+ );
205
+ };
206
+
207
+ const clearSessionCookie = (res) => {
208
+ res.setHeader(
209
+ "Set-Cookie",
210
+ `${sessionCookieName}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
211
+ );
212
+ };
213
+
214
+ const sanitizeNextPath = (value) => {
215
+ if (typeof value !== "string") {
216
+ return "/threads";
217
+ }
218
+ const trimmed = value.trim();
219
+ if (!trimmed.startsWith("/")) {
220
+ return "/threads";
221
+ }
222
+ if (trimmed.startsWith("//")) {
223
+ return "/threads";
224
+ }
225
+ return trimmed;
226
+ };
227
+
228
+ const matchesPassword = (value) => {
229
+ const provided = Buffer.from(value, "utf8");
230
+ const expected = Buffer.from(appPassword, "utf8");
231
+ if (provided.length !== expected.length) {
232
+ return false;
233
+ }
234
+ return timingSafeEqual(provided, expected);
235
+ };
236
+
237
+ const renderLoginPage = ({ errorMessage = "", nextPath = "/threads" } = {}) => {
238
+ const escapedMessage = errorMessage
239
+ ? `<p class="login-note login-note-error">${escapeHtml(errorMessage)}</p>`
240
+ : '<p class="login-note">Enter the Nomadex access password shown in the launcher terminal.</p>';
241
+ const safeNext = escapeHtml(sanitizeNextPath(nextPath));
242
+
243
+ return `<!doctype html>
244
+ <html lang="en">
245
+ <head>
246
+ <meta charset="utf-8" />
247
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
248
+ <title>Nomadex Access</title>
249
+ <style>
250
+ :root {
251
+ color-scheme: dark;
252
+ }
253
+ body {
254
+ margin: 0;
255
+ min-height: 100vh;
256
+ display: grid;
257
+ place-items: center;
258
+ background: radial-gradient(circle at top, rgba(72, 219, 203, 0.12), transparent 42%), #0d1118;
259
+ color: #f3f5ff;
260
+ font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
261
+ padding: 24px;
262
+ }
263
+ .card {
264
+ width: min(100%, 420px);
265
+ padding: 24px;
266
+ border-radius: 20px;
267
+ background: #141a24;
268
+ border: 1px solid #283245;
269
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.38);
270
+ }
271
+ h1 {
272
+ margin: 0 0 10px;
273
+ font-size: 20px;
274
+ }
275
+ p {
276
+ margin: 0;
277
+ color: #b4bdd4;
278
+ line-height: 1.55;
279
+ }
280
+ .login-note {
281
+ margin-bottom: 18px;
282
+ }
283
+ .login-note-error {
284
+ color: #ff97ac;
285
+ }
286
+ label {
287
+ display: block;
288
+ margin-bottom: 8px;
289
+ color: #dfe5f6;
290
+ font-size: 13px;
291
+ }
292
+ input {
293
+ width: 100%;
294
+ box-sizing: border-box;
295
+ border: 1px solid #2f3c52;
296
+ border-radius: 14px;
297
+ background: #0f141d;
298
+ color: #f3f5ff;
299
+ padding: 14px 16px;
300
+ font: inherit;
301
+ outline: none;
302
+ }
303
+ input:focus {
304
+ border-color: #57dbc9;
305
+ box-shadow: 0 0 0 3px rgba(87, 219, 201, 0.16);
306
+ }
307
+ button {
308
+ margin-top: 14px;
309
+ width: 100%;
310
+ border: 0;
311
+ border-radius: 14px;
312
+ background: #57dbc9;
313
+ color: #08211d;
314
+ padding: 14px 16px;
315
+ font: inherit;
316
+ font-weight: 700;
317
+ cursor: pointer;
318
+ }
319
+ .foot {
320
+ margin-top: 14px;
321
+ font-size: 12px;
322
+ color: #90a0bf;
323
+ }
324
+ </style>
325
+ </head>
326
+ <body>
327
+ <form class="card" method="post" action="/login">
328
+ <h1>Nomadex Access</h1>
329
+ ${escapedMessage}
330
+ <input type="hidden" name="next" value="${safeNext}" />
331
+ <label for="password">Password</label>
332
+ <input id="password" name="password" type="password" autocomplete="current-password" autofocus />
333
+ <button type="submit">Unlock workspace</button>
334
+ <p class="foot">This password is generated by the running Nomadex launcher unless you set <code>NOMADEX_PASSWORD</code>.</p>
335
+ </form>
336
+ </body>
337
+ </html>`;
338
+ };
339
+
340
+ const promptForUpdate = async () => {
341
+ if (!options.updateCheck || !process.stdin.isTTY || !process.stdout.isTTY) {
342
+ return false;
343
+ }
344
+
345
+ try {
346
+ const response = await fetch(
347
+ `https://registry.npmjs.org/${encodeURIComponent(packageJson.name)}/latest`,
348
+ {
349
+ signal: AbortSignal.timeout(1500),
350
+ },
351
+ );
352
+ if (!response.ok) {
353
+ return false;
354
+ }
355
+
356
+ const latest = await response.json();
357
+ const latestVersion =
358
+ typeof latest?.version === "string" ? latest.version.trim() : "";
359
+
360
+ if (!latestVersion) {
361
+ return false;
362
+ }
363
+
364
+ if (
365
+ compareVersions(parseVersion(packageJson.version), parseVersion(latestVersion)) >=
366
+ 0
367
+ ) {
368
+ return false;
369
+ }
370
+
371
+ console.log(
372
+ `[nomadexapp] Update available: ${packageJson.version} -> ${latestVersion}`,
373
+ );
374
+ const rl = createInterface({
375
+ input: process.stdin,
376
+ output: process.stdout,
377
+ });
378
+
379
+ try {
380
+ const answer = await rl.question(
381
+ "Start the latest Nomadex package instead? [y/N] ",
382
+ );
383
+ if (!isTruthyYes(answer)) {
384
+ return false;
385
+ }
386
+ } finally {
387
+ rl.close();
388
+ }
389
+
390
+ const npxCommand = process.platform === "win32" ? "npx.cmd" : "npx";
391
+ const restart = spawn(
392
+ npxCommand,
393
+ ["--yes", `${packageJson.name}@${latestVersion}`, ...process.argv.slice(2)],
394
+ {
395
+ stdio: "inherit",
396
+ env: process.env,
397
+ },
398
+ );
399
+
400
+ restart.once("error", (error) => {
401
+ console.error(
402
+ `[nomadexapp] Failed to start updated package: ${
403
+ error instanceof Error ? error.message : String(error)
404
+ }`,
405
+ );
406
+ process.exit(1);
407
+ });
408
+
409
+ await new Promise((resolve) => {
410
+ restart.once("spawn", resolve);
411
+ });
412
+
413
+ return true;
414
+ } catch {
415
+ return false;
416
+ }
417
+ };
418
+
419
+ const wsUrl = new URL(options.wsUrl);
420
+ const wsHost = wsUrl.hostname;
421
+ const wsPort = Number(wsUrl.port || (wsUrl.protocol === "wss:" ? 443 : 80));
422
+ const readyzUrl = (() => {
423
+ const target = new URL(options.wsUrl);
424
+ target.protocol = target.protocol === "wss:" ? "https:" : "http:";
425
+ target.pathname = "/readyz";
426
+ target.search = "";
427
+ target.hash = "";
428
+ return target;
429
+ })();
430
+ const authRelayTarget = options.authRelayTarget;
431
+
432
+ const resolveLocalNodePackageLaunch = (packageName, binName) => {
433
+ try {
434
+ const packageJsonPath = require.resolve(`${packageName}/package.json`);
435
+ const dependencyPackage = JSON.parse(readFileSync(packageJsonPath, "utf8"));
436
+ const relativeBin =
437
+ typeof dependencyPackage.bin === "string"
438
+ ? dependencyPackage.bin
439
+ : dependencyPackage.bin?.[binName];
440
+
441
+ if (!relativeBin) {
442
+ return null;
443
+ }
444
+
445
+ return {
446
+ command: process.execPath,
447
+ args: [path.resolve(path.dirname(packageJsonPath), relativeBin)],
448
+ shell: false,
449
+ source: `${packageName} dependency`,
450
+ };
451
+ } catch {
452
+ return null;
453
+ }
454
+ };
455
+
456
+ const getCodexLaunch = () =>
457
+ resolveLocalNodePackageLaunch("@openai/codex", "codex") ?? {
458
+ command: process.platform === "win32" ? "codex.cmd" : "codex",
459
+ args: [],
460
+ shell: false,
461
+ source: "global PATH",
462
+ };
463
+
464
+ const isPortOpen = (targetHost, targetPort) =>
465
+ new Promise((resolve) => {
466
+ const socket = new net.Socket();
467
+ socket.setTimeout(400);
468
+ socket.once("connect", () => {
469
+ socket.destroy();
470
+ resolve(true);
471
+ });
472
+ socket.once("timeout", () => {
473
+ socket.destroy();
474
+ resolve(false);
475
+ });
476
+ socket.once("error", () => {
477
+ socket.destroy();
478
+ resolve(false);
479
+ });
480
+ socket.connect(targetPort, targetHost);
481
+ });
482
+
483
+ const isCodexAppServerReady = async () => {
484
+ try {
485
+ const response = await fetch(readyzUrl, {
486
+ signal: AbortSignal.timeout(500),
487
+ });
488
+ return response.ok;
489
+ } catch {
490
+ return false;
491
+ }
492
+ };
493
+
494
+ const ensureUiPortAvailable = async () => {
495
+ if (!(await isPortOpen("127.0.0.1", options.uiPort))) {
496
+ return;
497
+ }
498
+
499
+ throw new Error(
500
+ `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.`,
501
+ );
502
+ };
503
+
504
+ const formatSpawnError = (error) => {
505
+ if (
506
+ error &&
507
+ typeof error === "object" &&
508
+ "code" in error &&
509
+ error.code === "ENOENT"
510
+ ) {
511
+ return "Could not find the Codex CLI. Install `@openai/codex` or use the bundled package dependency.";
512
+ }
513
+
514
+ if (error instanceof Error) {
515
+ return `Failed to start the Codex CLI: ${error.message}`;
516
+ }
517
+
518
+ return `Failed to start the Codex CLI: ${String(error)}`;
519
+ };
520
+
521
+ const stopChildren = () => {
522
+ for (const child of children) {
523
+ if (!child.killed) {
524
+ child.kill("SIGTERM");
525
+ }
526
+ }
527
+ };
528
+
529
+ const shutdown = (code) => {
530
+ stopChildren();
531
+ process.exit(code);
532
+ };
533
+
534
+ process.on("SIGINT", () => shutdown(130));
535
+ process.on("SIGTERM", () => shutdown(143));
536
+
537
+ const ensureDistBuilt = () => {
538
+ const indexPath = path.join(distDir, "index.html");
539
+ if (!existsSync(indexPath)) {
540
+ throw new Error(
541
+ "Nomadex build output is missing. Run `npm run build` before packing or publishing the package.",
542
+ );
543
+ }
544
+ };
545
+
546
+ const ensureAppServer = async () => {
547
+ if (await isPortOpen(wsHost, wsPort)) {
548
+ if (await isCodexAppServerReady()) {
549
+ console.log(`[nomadexapp] Reusing Codex app-server at ${options.wsUrl}`);
550
+ return;
551
+ }
552
+
553
+ throw new Error(
554
+ `Port ${wsPort} on ${wsHost} is already in use, but it is not responding like a Codex app-server.`,
555
+ );
556
+ }
557
+
558
+ const codexLaunch = getCodexLaunch();
559
+ console.log(`[nomadexapp] Starting Codex app-server at ${options.wsUrl}`);
560
+ const appServer = spawn(
561
+ codexLaunch.command,
562
+ [...codexLaunch.args, "app-server", "--listen", options.wsUrl],
563
+ {
564
+ cwd: launchCwd,
565
+ stdio: "inherit",
566
+ env: process.env,
567
+ shell: codexLaunch.shell,
568
+ },
569
+ );
570
+
571
+ let appServerError = null;
572
+ appServer.once("error", (error) => {
573
+ appServerError = error;
574
+ });
575
+
576
+ children.push(appServer);
577
+
578
+ for (let attempt = 0; attempt < 50; attempt += 1) {
579
+ if (appServerError) {
580
+ throw new Error(formatSpawnError(appServerError));
581
+ }
582
+ if (await isCodexAppServerReady()) {
583
+ return;
584
+ }
585
+ if (appServer.exitCode !== null) {
586
+ throw new Error(
587
+ `Codex app-server exited with code ${appServer.exitCode} (${codexLaunch.source}).`,
588
+ );
589
+ }
590
+ await sleep(200);
591
+ }
592
+
593
+ throw new Error(`Timed out waiting for Codex app-server at ${options.wsUrl}`);
594
+ };
595
+
596
+ const sendText = (res, statusCode, message) => {
597
+ res.statusCode = statusCode;
598
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
599
+ res.end(message);
600
+ };
601
+
602
+ const sendJson = (res, statusCode, payload) => {
603
+ res.statusCode = statusCode;
604
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
605
+ res.end(JSON.stringify(payload));
606
+ };
607
+
608
+ const sendHtml = (res, statusCode, body) => {
609
+ res.statusCode = statusCode;
610
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
611
+ res.end(body);
612
+ };
613
+
614
+ const escapeHtml = (value) =>
615
+ value
616
+ .replaceAll("&", "&amp;")
617
+ .replaceAll("<", "&lt;")
618
+ .replaceAll(">", "&gt;")
619
+ .replaceAll('"', "&quot;")
620
+ .replaceAll("'", "&#39;");
621
+
622
+ const stripHtml = (value) =>
623
+ value
624
+ .replace(/<br\s*\/?>/gi, "\n")
625
+ .replace(/<[^>]+>/g, " ")
626
+ .replace(/\s+/g, " ")
627
+ .trim();
628
+
629
+ const extractAuthRelayMessage = (body) => {
630
+ const messageMatch = body.match(/<p class="message">([\s\S]*?)<\/p>/i);
631
+ if (messageMatch) {
632
+ return stripHtml(messageMatch[1]);
633
+ }
634
+
635
+ const titleMatch = body.match(/<h1>([\s\S]*?)<\/h1>/i);
636
+ if (titleMatch) {
637
+ return stripHtml(titleMatch[1]);
638
+ }
639
+
640
+ return stripHtml(body) || body;
641
+ };
642
+
643
+ const readRequestBody = async (req) => {
644
+ const chunks = [];
645
+ for await (const chunk of req) {
646
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
647
+ }
648
+ return Buffer.concat(chunks).toString("utf8");
649
+ };
650
+
651
+ const relayAuthCallback = async (search) => {
652
+ const target = new URL("/auth/callback", authRelayTarget);
653
+ target.search = search;
654
+
655
+ const response = await fetch(target, {
656
+ method: "GET",
657
+ });
658
+ const body = await response.text();
659
+ const message = extractAuthRelayMessage(body);
660
+ const failedByBody =
661
+ /sign-in could not be completed|token_exchange_failed|state mismatch/i.test(
662
+ body,
663
+ );
664
+
665
+ return {
666
+ ok: response.ok && !failedByBody,
667
+ status: response.ok && !failedByBody ? 200 : response.status,
668
+ text: message,
669
+ };
670
+ };
671
+
672
+ const normalizeLocalPath = (rawValue) => {
673
+ const value = rawValue?.trim();
674
+ if (!value) {
675
+ return null;
676
+ }
677
+
678
+ try {
679
+ if (value.startsWith("file://")) {
680
+ return decodeURIComponent(new URL(value).pathname);
681
+ }
682
+ } catch {
683
+ return null;
684
+ }
685
+
686
+ return value;
687
+ };
688
+
689
+ const sendLocalFile = async (res, filePath) => {
690
+ const normalizedPath = normalizeLocalPath(filePath);
691
+ if (!normalizedPath || !path.isAbsolute(normalizedPath)) {
692
+ sendText(res, 400, "Invalid local path");
693
+ return;
694
+ }
695
+
696
+ try {
697
+ const fileStat = statSync(normalizedPath);
698
+ if (!fileStat.isFile()) {
699
+ sendText(res, 404, "Not a file");
700
+ return;
701
+ }
702
+
703
+ const body = await readFile(normalizedPath);
704
+ const extension = path.extname(normalizedPath).toLowerCase();
705
+ res.statusCode = 200;
706
+ res.setHeader("Cache-Control", "no-store");
707
+ res.setHeader("X-Content-Type-Options", "nosniff");
708
+ res.setHeader(
709
+ "Content-Type",
710
+ CONTENT_TYPES[extension] ?? "application/octet-stream",
711
+ );
712
+ res.end(body);
713
+ } catch {
714
+ sendText(res, 404, "File not found");
715
+ }
716
+ };
717
+
718
+ const serveStaticFile = async (res, targetPath) => {
719
+ const body = await readFile(targetPath);
720
+ const extension = path.extname(targetPath).toLowerCase();
721
+ res.statusCode = 200;
722
+ res.setHeader(
723
+ "Content-Type",
724
+ CONTENT_TYPES[extension] ?? "application/octet-stream",
725
+ );
726
+ res.end(body);
727
+ };
728
+
729
+ const isSafeDistPath = (pathname) => {
730
+ const normalized = path.posix.normalize(pathname);
731
+ return !normalized.startsWith("/../") && normalized !== "/..";
732
+ };
733
+
734
+ const resolveDistPath = (pathname) => {
735
+ const safePath = pathname === "/" ? "/index.html" : pathname;
736
+ if (!isSafeDistPath(safePath)) {
737
+ return null;
738
+ }
739
+
740
+ const candidate = path.join(distDir, safePath.replace(/^\/+/u, ""));
741
+ if (!candidate.startsWith(distDir)) {
742
+ return null;
743
+ }
744
+
745
+ return candidate;
746
+ };
747
+
748
+ const getPreferredIp = () => {
749
+ const interfaces = os.networkInterfaces();
750
+ for (const entries of Object.values(interfaces)) {
751
+ for (const entry of entries ?? []) {
752
+ if (entry.family === "IPv4" && !entry.internal) {
753
+ return entry.address;
754
+ }
755
+ }
756
+ }
757
+ return null;
758
+ };
759
+
760
+ const wsProxy = httpProxy.createProxyServer({
761
+ target: options.wsUrl,
762
+ ws: true,
763
+ changeOrigin: true,
764
+ });
765
+
766
+ wsProxy.on("proxyReqWs", (proxyReq) => {
767
+ if (!proxyReq.headersSent) {
768
+ proxyReq.removeHeader("origin");
769
+ }
770
+ });
771
+
772
+ wsProxy.on("error", (error, req, resOrSocket) => {
773
+ const message = error instanceof Error ? error.message : String(error);
774
+ if ("writableEnded" in resOrSocket) {
775
+ if (!resOrSocket.headersSent) {
776
+ sendText(resOrSocket, 502, message);
777
+ }
778
+ return;
779
+ }
780
+
781
+ try {
782
+ resOrSocket.end();
783
+ } catch {
784
+ // Ignore broken upgrade sockets.
785
+ }
786
+ });
787
+
788
+ const server = createServer(async (req, res) => {
789
+ if (!req.url) {
790
+ sendText(res, 400, "Missing request URL");
791
+ return;
792
+ }
793
+
794
+ const requestUrl = new URL(req.url, "http://nomadex.local");
795
+ const { pathname } = requestUrl;
796
+
797
+ if (pathname === "/healthz") {
798
+ sendJson(res, 200, { ok: true });
799
+ return;
800
+ }
801
+
802
+ if (req.method === "GET" && pathname === "/login") {
803
+ sendHtml(
804
+ res,
805
+ 200,
806
+ renderLoginPage({
807
+ nextPath: sanitizeNextPath(
808
+ requestUrl.searchParams.get("next") ?? "/threads",
809
+ ),
810
+ }),
811
+ );
812
+ return;
813
+ }
814
+
815
+ if (req.method === "POST" && pathname === "/login") {
816
+ const rawBody = await readRequestBody(req);
817
+ const params = new URLSearchParams(rawBody);
818
+ const submittedPassword = params.get("password") ?? "";
819
+ const nextPath = sanitizeNextPath(params.get("next") ?? "/threads");
820
+
821
+ if (!matchesPassword(submittedPassword)) {
822
+ clearSessionCookie(res);
823
+ sendHtml(
824
+ res,
825
+ 401,
826
+ renderLoginPage({
827
+ errorMessage: "Incorrect password. Use the password shown in the Nomadex launcher.",
828
+ nextPath,
829
+ }),
830
+ );
831
+ return;
832
+ }
833
+
834
+ const sessionToken = createSessionToken();
835
+ setSessionCookie(res, sessionToken);
836
+ res.statusCode = 302;
837
+ res.setHeader("Location", nextPath);
838
+ res.end();
839
+ return;
840
+ }
841
+
842
+ if (req.method === "GET" && pathname === "/auth/callback") {
843
+ try {
844
+ const result = await relayAuthCallback(requestUrl.search);
845
+ const ok = result.ok;
846
+ const message = escapeHtml(
847
+ result.text || (ok ? "Login completed." : "Login relay failed."),
848
+ );
849
+
850
+ sendHtml(
851
+ res,
852
+ ok ? 200 : result.status,
853
+ `<!doctype html>
854
+ <html lang="en">
855
+ <head>
856
+ <meta charset="utf-8" />
857
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
858
+ <title>Nomadex Login</title>
859
+ <style>
860
+ body { font-family: sans-serif; background:#111219; color:#f3f5ff; padding:24px; }
861
+ .card { max-width:560px; margin:40px auto; padding:20px; border:1px solid #31354a; border-radius:16px; background:#171a24; }
862
+ a { color:#7fd8c8; }
863
+ pre { white-space:pre-wrap; word-break:break-word; color:#ccd2ec; }
864
+ </style>
865
+ </head>
866
+ <body>
867
+ <div class="card">
868
+ <h1>${ok ? "Nomadex login completed" : "Nomadex login relay failed"}</h1>
869
+ <pre>${message}</pre>
870
+ <p><a href="/threads">Return to Nomadex</a></p>
871
+ ${ok ? '<script>setTimeout(()=>window.location.replace("/threads"), 1200)</script>' : ""}
872
+ </div>
873
+ </body>
874
+ </html>`,
875
+ );
876
+ } catch (error) {
877
+ sendText(
878
+ res,
879
+ 502,
880
+ error instanceof Error ? error.message : "Failed to relay auth callback",
881
+ );
882
+ }
883
+ return;
884
+ }
885
+
886
+ if (!isAuthenticatedRequest(req)) {
887
+ if (req.method === "GET" || req.method === "HEAD") {
888
+ sendHtml(
889
+ res,
890
+ 401,
891
+ renderLoginPage({
892
+ errorMessage: "Enter the Nomadex access password to open this workspace.",
893
+ nextPath: `${requestUrl.pathname}${requestUrl.search}`,
894
+ }),
895
+ );
896
+ return;
897
+ }
898
+
899
+ sendJson(res, 401, {
900
+ error: "Unauthorized",
901
+ message: "Enter the Nomadex access password first.",
902
+ });
903
+ return;
904
+ }
905
+
906
+ if (
907
+ req.method === "POST" &&
908
+ /^\/[a-z0-9-]+-auth\/complete$/iu.test(pathname)
909
+ ) {
910
+ try {
911
+ const rawBody = await readRequestBody(req);
912
+ const parsed = rawBody ? JSON.parse(rawBody) : {};
913
+ const callbackUrl =
914
+ typeof parsed.callbackUrl === "string" ? parsed.callbackUrl.trim() : "";
915
+
916
+ if (!callbackUrl) {
917
+ sendJson(res, 400, { error: "Missing callbackUrl" });
918
+ return;
919
+ }
920
+
921
+ const callback = new URL(callbackUrl);
922
+ if (callback.pathname !== "/auth/callback") {
923
+ sendJson(res, 400, {
924
+ error: "Callback URL must target /auth/callback",
925
+ });
926
+ return;
927
+ }
928
+
929
+ const result = await relayAuthCallback(callback.search);
930
+ sendJson(res, result.ok ? 200 : result.status, {
931
+ ok: result.ok,
932
+ status: result.status,
933
+ message: result.text,
934
+ });
935
+ } catch (error) {
936
+ sendJson(res, 502, {
937
+ error:
938
+ error instanceof Error
939
+ ? error.message
940
+ : "Failed to complete mobile login",
941
+ });
942
+ }
943
+ return;
944
+ }
945
+
946
+ if (req.method === "GET" && /-local-image$/iu.test(pathname)) {
947
+ await sendLocalFile(res, requestUrl.searchParams.get("path"));
948
+ return;
949
+ }
950
+
951
+ const browseMatch = pathname.match(/^\/[a-z0-9-]+-local-browse(\/.*)$/iu);
952
+ if (req.method === "GET" && browseMatch?.[1]) {
953
+ await sendLocalFile(res, decodeURI(browseMatch[1]));
954
+ return;
955
+ }
956
+
957
+ if (req.method !== "GET" && req.method !== "HEAD") {
958
+ sendText(res, 405, "Method not allowed");
959
+ return;
960
+ }
961
+
962
+ const directAssetPath = resolveDistPath(pathname);
963
+ if (directAssetPath && existsSync(directAssetPath)) {
964
+ await serveStaticFile(res, directAssetPath);
965
+ return;
966
+ }
967
+
968
+ await serveStaticFile(res, path.join(distDir, "index.html"));
969
+ });
970
+
971
+ server.on("upgrade", (req, socket, head) => {
972
+ try {
973
+ const requestUrl = new URL(req.url ?? "/", "http://nomadex.local");
974
+ const pathname = requestUrl.pathname;
975
+
976
+ if (
977
+ pathname === "/codex-ws" ||
978
+ pathname === "/codex-api/ws" ||
979
+ /-ws$/iu.test(pathname)
980
+ ) {
981
+ if (!isAuthenticatedRequest(req)) {
982
+ socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
983
+ socket.destroy();
984
+ return;
985
+ }
986
+ req.url = "/";
987
+ wsProxy.ws(req, socket, head);
988
+ return;
989
+ }
990
+ } catch {
991
+ // Ignore malformed upgrade URLs and destroy the socket below.
992
+ }
993
+
994
+ socket.destroy();
995
+ });
996
+
997
+ try {
998
+ const restarted = await promptForUpdate();
999
+ if (restarted) {
1000
+ process.exit(0);
1001
+ }
1002
+
1003
+ ensureDistBuilt();
1004
+ await ensureAppServer();
1005
+ await ensureUiPortAvailable();
1006
+
1007
+ await new Promise((resolve, reject) => {
1008
+ server.once("error", reject);
1009
+ server.listen(options.uiPort, options.host, resolve);
1010
+ });
1011
+
1012
+ const preferredIp = getPreferredIp();
1013
+ console.log(`[nomadexapp] UI ready at http://127.0.0.1:${options.uiPort}`);
1014
+ if (preferredIp) {
1015
+ console.log(`[nomadexapp] LAN access: http://${preferredIp}:${options.uiPort}`);
1016
+ }
1017
+ console.log(
1018
+ `[nomadexapp] UI password (${passwordSource}): ${appPassword}`,
1019
+ );
1020
+ } catch (error) {
1021
+ stopChildren();
1022
+ console.error(
1023
+ `[nomadexapp] ${error instanceof Error ? error.message : String(error)}`,
1024
+ );
1025
+ process.exit(1);
1026
+ }