claude-relay 2.4.2 → 2.5.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 (75) hide show
  1. package/bin/cli.js +1 -2350
  2. package/package.json +7 -42
  3. package/LICENSE +0 -21
  4. package/README.md +0 -281
  5. package/lib/cli-sessions.js +0 -270
  6. package/lib/config.js +0 -222
  7. package/lib/daemon.js +0 -423
  8. package/lib/ipc.js +0 -112
  9. package/lib/pages.js +0 -714
  10. package/lib/project.js +0 -1224
  11. package/lib/public/app.js +0 -2157
  12. package/lib/public/apple-touch-icon.png +0 -0
  13. package/lib/public/css/base.css +0 -145
  14. package/lib/public/css/diff.css +0 -128
  15. package/lib/public/css/filebrowser.css +0 -1076
  16. package/lib/public/css/highlight.css +0 -144
  17. package/lib/public/css/input.css +0 -512
  18. package/lib/public/css/menus.css +0 -683
  19. package/lib/public/css/messages.css +0 -1159
  20. package/lib/public/css/overlays.css +0 -731
  21. package/lib/public/css/rewind.css +0 -529
  22. package/lib/public/css/sidebar.css +0 -794
  23. package/lib/public/favicon.svg +0 -26
  24. package/lib/public/icon-192.png +0 -0
  25. package/lib/public/icon-512.png +0 -0
  26. package/lib/public/icon-mono.svg +0 -19
  27. package/lib/public/index.html +0 -460
  28. package/lib/public/manifest.json +0 -27
  29. package/lib/public/modules/diff.js +0 -398
  30. package/lib/public/modules/events.js +0 -21
  31. package/lib/public/modules/filebrowser.js +0 -1375
  32. package/lib/public/modules/fileicons.js +0 -172
  33. package/lib/public/modules/icons.js +0 -54
  34. package/lib/public/modules/input.js +0 -578
  35. package/lib/public/modules/markdown.js +0 -149
  36. package/lib/public/modules/notifications.js +0 -643
  37. package/lib/public/modules/qrcode.js +0 -70
  38. package/lib/public/modules/rewind.js +0 -334
  39. package/lib/public/modules/sidebar.js +0 -628
  40. package/lib/public/modules/state.js +0 -3
  41. package/lib/public/modules/terminal.js +0 -658
  42. package/lib/public/modules/theme.js +0 -622
  43. package/lib/public/modules/tools.js +0 -1410
  44. package/lib/public/modules/utils.js +0 -56
  45. package/lib/public/style.css +0 -10
  46. package/lib/public/sw.js +0 -75
  47. package/lib/push.js +0 -125
  48. package/lib/sdk-bridge.js +0 -771
  49. package/lib/server.js +0 -577
  50. package/lib/sessions.js +0 -402
  51. package/lib/terminal-manager.js +0 -187
  52. package/lib/terminal.js +0 -24
  53. package/lib/themes/ayu-light.json +0 -9
  54. package/lib/themes/catppuccin-latte.json +0 -9
  55. package/lib/themes/catppuccin-mocha.json +0 -9
  56. package/lib/themes/claude-light.json +0 -9
  57. package/lib/themes/claude.json +0 -9
  58. package/lib/themes/dracula.json +0 -9
  59. package/lib/themes/everforest-light.json +0 -9
  60. package/lib/themes/everforest.json +0 -9
  61. package/lib/themes/github-light.json +0 -9
  62. package/lib/themes/gruvbox-dark.json +0 -9
  63. package/lib/themes/gruvbox-light.json +0 -9
  64. package/lib/themes/monokai.json +0 -9
  65. package/lib/themes/nord-light.json +0 -9
  66. package/lib/themes/nord.json +0 -9
  67. package/lib/themes/one-dark.json +0 -9
  68. package/lib/themes/one-light.json +0 -9
  69. package/lib/themes/rose-pine-dawn.json +0 -9
  70. package/lib/themes/rose-pine.json +0 -9
  71. package/lib/themes/solarized-dark.json +0 -9
  72. package/lib/themes/solarized-light.json +0 -9
  73. package/lib/themes/tokyo-night-light.json +0 -9
  74. package/lib/themes/tokyo-night.json +0 -9
  75. package/lib/updater.js +0 -96
package/bin/cli.js CHANGED
@@ -1,2351 +1,2 @@
1
1
  #!/usr/bin/env node
2
-
3
- var os = require("os");
4
- var fs = require("fs");
5
- var path = require("path");
6
- var { execSync, execFileSync, spawn } = require("child_process");
7
- var qrcode = require("qrcode-terminal");
8
- var net = require("net");
9
-
10
- // Detect dev mode before loading config (env must be set before require)
11
- var _isDev = (process.argv[1] && path.basename(process.argv[1]) === "claude-relay-dev") || process.argv.includes("--dev");
12
- if (_isDev) {
13
- process.env.CLAUDE_RELAY_HOME = path.join(os.homedir(), ".claude-relay-dev");
14
- }
15
-
16
- var { loadConfig, saveConfig, configPath, socketPath, logPath, ensureConfigDir, isDaemonAlive, isDaemonAliveAsync, generateSlug, clearStaleConfig, loadClayrc, saveClayrc, readCrashInfo } = require("../lib/config");
17
- var { sendIPCCommand } = require("../lib/ipc");
18
- var { generateAuthToken } = require("../lib/server");
19
-
20
- function openUrl(url) {
21
- try {
22
- if (process.platform === "win32") {
23
- spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true, windowsHide: true }).unref();
24
- } else {
25
- var cmd = process.platform === "darwin" ? "open" : "xdg-open";
26
- spawn(cmd, [url], { stdio: "ignore", detached: true }).unref();
27
- }
28
- } catch (e) {}
29
- }
30
-
31
- var args = process.argv.slice(2);
32
- var port = _isDev ? 2635 : 2633;
33
- var useHttps = true;
34
- var skipUpdate = false;
35
- var debugMode = false;
36
- var autoYes = false;
37
- var cliPin = null;
38
- var shutdownMode = false;
39
- var addPath = null;
40
- var removePath = null;
41
- var listMode = false;
42
- var dangerouslySkipPermissions = false;
43
- var headlessMode = false;
44
- var watchMode = false;
45
-
46
- for (var i = 0; i < args.length; i++) {
47
- if (args[i] === "-p" || args[i] === "--port") {
48
- port = parseInt(args[i + 1], 10);
49
- if (isNaN(port)) {
50
- console.error("Invalid port number");
51
- process.exit(1);
52
- }
53
- i++;
54
- } else if (args[i] === "--no-https") {
55
- useHttps = false;
56
- } else if (args[i] === "--no-update" || args[i] === "--skip-update") {
57
- skipUpdate = true;
58
- } else if (args[i] === "--dev") {
59
- // Already handled above for CLAUDE_RELAY_HOME, just skip
60
- } else if (args[i] === "--watch" || args[i] === "-w") {
61
- watchMode = true;
62
- } else if (args[i] === "--debug") {
63
- debugMode = true;
64
- } else if (args[i] === "-y" || args[i] === "--yes") {
65
- autoYes = true;
66
- } else if (args[i] === "--pin") {
67
- cliPin = args[i + 1] || null;
68
- i++;
69
- } else if (args[i] === "--shutdown") {
70
- shutdownMode = true;
71
- } else if (args[i] === "--add") {
72
- addPath = args[i + 1] || ".";
73
- i++;
74
- } else if (args[i] === "--remove") {
75
- removePath = args[i + 1] || null;
76
- i++;
77
- } else if (args[i] === "--list") {
78
- listMode = true;
79
- } else if (args[i] === "--headless") {
80
- headlessMode = true;
81
- autoYes = true;
82
- } else if (args[i] === "--dangerously-skip-permissions") {
83
- dangerouslySkipPermissions = true;
84
- } else if (args[i] === "-h" || args[i] === "--help") {
85
- console.log("Usage: claude-relay [-p|--port <port>] [--no-https] [--no-update] [--debug] [-y|--yes] [--pin <pin>] [--shutdown]");
86
- console.log(" claude-relay --add <path> Add a project to the running daemon");
87
- console.log(" claude-relay --remove <path> Remove a project from the running daemon");
88
- console.log(" claude-relay --list List registered projects");
89
- console.log("");
90
- console.log("Options:");
91
- console.log(" -p, --port <port> Port to listen on (default: 2633)");
92
- console.log(" --no-https Disable HTTPS (enabled by default via mkcert)");
93
- console.log(" --no-update Skip auto-update check on startup");
94
- console.log(" --debug Enable debug panel in the web UI");
95
- console.log(" -y, --yes Skip interactive prompts (accept defaults)");
96
- console.log(" --pin <pin> Set 6-digit PIN (use with --yes)");
97
- console.log(" --shutdown Shut down the running relay daemon");
98
- console.log(" --add <path> Add a project directory (use '.' for current)");
99
- console.log(" --remove <path> Remove a project directory");
100
- console.log(" --list List all registered projects");
101
- console.log(" --headless Start daemon and exit immediately (implies --yes)");
102
- console.log(" --dangerously-skip-permissions");
103
- console.log(" Bypass all permission prompts (requires --pin)");
104
- process.exit(0);
105
- }
106
- }
107
-
108
- // Dev mode implies debug + skip update
109
- if (_isDev) {
110
- debugMode = true;
111
- skipUpdate = true;
112
- }
113
-
114
- // --- Handle --shutdown before anything else ---
115
- if (shutdownMode) {
116
- var shutdownConfig = loadConfig();
117
- isDaemonAliveAsync(shutdownConfig).then(function (alive) {
118
- if (!alive) {
119
- console.error("No running daemon found.");
120
- process.exit(1);
121
- }
122
- sendIPCCommand(socketPath(), { cmd: "shutdown" }).then(function () {
123
- console.log("Server stopped.");
124
- clearStaleConfig();
125
- process.exit(0);
126
- }).catch(function (err) {
127
- console.error("Shutdown failed:", err.message);
128
- process.exit(1);
129
- });
130
- });
131
- return;
132
- }
133
-
134
- // --- Handle --add before anything else ---
135
- if (addPath !== null) {
136
- var absAdd = path.resolve(addPath);
137
- try {
138
- var stat = fs.statSync(absAdd);
139
- if (!stat.isDirectory()) {
140
- console.error("Not a directory: " + absAdd);
141
- process.exit(1);
142
- }
143
- } catch (e) {
144
- console.error("Directory not found: " + absAdd);
145
- process.exit(1);
146
- }
147
- var addConfig = loadConfig();
148
- isDaemonAliveAsync(addConfig).then(function (alive) {
149
- if (!alive) {
150
- console.error("No running daemon. Start with: npx claude-relay");
151
- process.exit(1);
152
- }
153
- sendIPCCommand(socketPath(), { cmd: "add_project", path: absAdd }).then(function (res) {
154
- if (res.ok) {
155
- if (res.existing) {
156
- console.log("Already registered: " + res.slug);
157
- } else {
158
- console.log("Added: " + res.slug + " \u2192 " + absAdd);
159
- }
160
- process.exit(0);
161
- } else {
162
- console.error("Failed: " + (res.error || "unknown error"));
163
- process.exit(1);
164
- }
165
- });
166
- });
167
- return;
168
- }
169
-
170
- // --- Handle --remove before anything else ---
171
- if (removePath !== null) {
172
- var absRemove = path.resolve(removePath);
173
- var removeConfig = loadConfig();
174
- isDaemonAliveAsync(removeConfig).then(function (alive) {
175
- if (!alive) {
176
- console.error("No running daemon. Start with: npx claude-relay");
177
- process.exit(1);
178
- }
179
- sendIPCCommand(socketPath(), { cmd: "remove_project", path: absRemove }).then(function (res) {
180
- if (res.ok) {
181
- console.log("Removed: " + path.basename(absRemove));
182
- process.exit(0);
183
- } else {
184
- console.error("Failed: " + (res.error || "project not found"));
185
- process.exit(1);
186
- }
187
- });
188
- });
189
- return;
190
- }
191
-
192
- // --- Handle --list before anything else ---
193
- if (listMode) {
194
- var listConfig = loadConfig();
195
- isDaemonAliveAsync(listConfig).then(function (alive) {
196
- if (!alive) {
197
- console.error("No running daemon. Start with: npx claude-relay");
198
- process.exit(1);
199
- }
200
- sendIPCCommand(socketPath(), { cmd: "get_status" }).then(function (res) {
201
- if (!res.ok || !res.projects || res.projects.length === 0) {
202
- console.log("No projects registered.");
203
- process.exit(0);
204
- return;
205
- }
206
- console.log("Projects (" + res.projects.length + "):\n");
207
- for (var p = 0; p < res.projects.length; p++) {
208
- var proj = res.projects[p];
209
- var label = " " + proj.slug;
210
- if (proj.title) label += " (" + proj.title + ")";
211
- label += "\n " + proj.path;
212
- console.log(label);
213
- }
214
- console.log("");
215
- process.exit(0);
216
- });
217
- });
218
- return;
219
- }
220
-
221
- var cwd = process.cwd();
222
-
223
- // --- ANSI helpers ---
224
- var isBasicTerm = process.env.TERM_PROGRAM === "Apple_Terminal";
225
- var a = {
226
- reset: "\x1b[0m",
227
- bold: "\x1b[1m",
228
- dim: "\x1b[2m",
229
- cyan: "\x1b[36m",
230
- green: "\x1b[32m",
231
- yellow: "\x1b[33m",
232
- red: "\x1b[31m",
233
- };
234
-
235
- function gradient(text) {
236
- if (isBasicTerm) {
237
- return a.yellow + text + a.reset;
238
- }
239
- // Orange (#DA7756) → Gold (#D4A574)
240
- var r0 = 218, g0 = 119, b0 = 86;
241
- var r1 = 212, g1 = 165, b1 = 116;
242
- var out = "";
243
- var len = text.length;
244
- for (var i = 0; i < len; i++) {
245
- var t = len > 1 ? i / (len - 1) : 0;
246
- var r = Math.round(r0 + (r1 - r0) * t);
247
- var g = Math.round(g0 + (g1 - g0) * t);
248
- var b = Math.round(b0 + (b1 - b0) * t);
249
- out += "\x1b[38;2;" + r + ";" + g + ";" + b + "m" + text[i];
250
- }
251
- return out + a.reset;
252
- }
253
-
254
- var sym = {
255
- pointer: a.cyan + "◆" + a.reset,
256
- done: a.green + "◇" + a.reset,
257
- bar: a.dim + "│" + a.reset,
258
- end: a.dim + "└" + a.reset,
259
- warn: a.yellow + "▲" + a.reset,
260
- };
261
-
262
- function log(s) { console.log(" " + s); }
263
-
264
- function clearUp(n) {
265
- for (var i = 0; i < n; i++) {
266
- process.stdout.write("\x1b[1A\x1b[2K");
267
- }
268
- }
269
-
270
- // --- Daemon watcher ---
271
- // Polls daemon socket; if connection fails, the server is down.
272
- var _daemonWatcher = null;
273
-
274
- function startDaemonWatcher() {
275
- if (_daemonWatcher) return;
276
- _daemonWatcher = setInterval(function () {
277
- var client = net.connect(socketPath());
278
- var timer = setTimeout(function () {
279
- client.destroy();
280
- onDaemonDied();
281
- }, 1500);
282
- client.on("connect", function () {
283
- clearTimeout(timer);
284
- client.destroy();
285
- });
286
- client.on("error", function () {
287
- clearTimeout(timer);
288
- client.destroy();
289
- onDaemonDied();
290
- });
291
- }, 3000);
292
- }
293
-
294
- function stopDaemonWatcher() {
295
- if (_daemonWatcher) {
296
- clearInterval(_daemonWatcher);
297
- _daemonWatcher = null;
298
- }
299
- }
300
-
301
- var _restartAttempts = 0;
302
- var MAX_RESTART_ATTEMPTS = 5;
303
- var _restartBackoffStart = 0;
304
-
305
- function onDaemonDied() {
306
- stopDaemonWatcher();
307
- // Clean up stdin in case a prompt is active
308
- try {
309
- process.stdin.setRawMode(false);
310
- process.stdin.pause();
311
- process.stdin.removeAllListeners("data");
312
- } catch (e) {}
313
-
314
- // Check if this was a crash (crash.json exists) vs intentional shutdown
315
- var crashInfo = readCrashInfo();
316
- if (!crashInfo) {
317
- // Intentional shutdown, no restart
318
- log("");
319
- log(sym.warn + " " + a.yellow + "Server has been shut down." + a.reset);
320
- log(a.dim + " Run " + a.reset + "npx claude-relay" + a.dim + " to start again." + a.reset);
321
- log("");
322
- process.exit(0);
323
- return;
324
- }
325
-
326
- // Reset backoff counter if enough time has passed since last restart burst
327
- var now = Date.now();
328
- if (_restartBackoffStart && now - _restartBackoffStart > 60000) {
329
- _restartAttempts = 0;
330
- }
331
-
332
- _restartAttempts++;
333
- if (_restartAttempts > MAX_RESTART_ATTEMPTS) {
334
- log("");
335
- log(sym.warn + " " + a.red + "Server crashed too many times (" + MAX_RESTART_ATTEMPTS + " attempts). Giving up." + a.reset);
336
- if (crashInfo.reason) {
337
- log(a.dim + " " + crashInfo.reason.split("\n")[0] + a.reset);
338
- }
339
- log(a.dim + " Check logs: " + a.reset + logPath());
340
- log("");
341
- process.exit(1);
342
- return;
343
- }
344
-
345
- if (_restartAttempts === 1) _restartBackoffStart = now;
346
-
347
- log("");
348
- log(sym.warn + " " + a.yellow + "Server crashed. Restarting... (" + _restartAttempts + "/" + MAX_RESTART_ATTEMPTS + ")" + a.reset);
349
- if (crashInfo.reason) {
350
- log(a.dim + " " + crashInfo.reason.split("\n")[0] + a.reset);
351
- }
352
-
353
- // Re-fork the daemon from saved config
354
- restartDaemonFromConfig();
355
- }
356
-
357
- async function restartDaemonFromConfig() {
358
- var lastConfig = loadConfig();
359
- if (!lastConfig || !lastConfig.projects) {
360
- log(a.red + " No config found. Cannot restart." + a.reset);
361
- process.exit(1);
362
- return;
363
- }
364
-
365
- clearStaleConfig();
366
-
367
- // Wait for port to be released
368
- var targetPort = lastConfig.port || port;
369
- var waited = 0;
370
- while (waited < 3000) {
371
- var free = await isPortFree(targetPort);
372
- if (free) break;
373
- await new Promise(function (resolve) { setTimeout(resolve, 300); });
374
- waited += 300;
375
- }
376
-
377
- // Rebuild config (preserve everything except pid)
378
- var newConfig = {
379
- pid: null,
380
- port: targetPort,
381
- pinHash: lastConfig.pinHash || null,
382
- tls: lastConfig.tls !== undefined ? lastConfig.tls : useHttps,
383
- debug: lastConfig.debug || false,
384
- keepAwake: lastConfig.keepAwake || false,
385
- dangerouslySkipPermissions: lastConfig.dangerouslySkipPermissions || false,
386
- projects: (lastConfig.projects || []).filter(function (p) {
387
- return fs.existsSync(p.path);
388
- }),
389
- };
390
-
391
- ensureConfigDir();
392
- saveConfig(newConfig);
393
-
394
- var daemonScript = path.join(__dirname, "..", "lib", "daemon.js");
395
- var logFile = logPath();
396
- var logFd = fs.openSync(logFile, "a");
397
-
398
- var child = spawn(process.execPath, [daemonScript], {
399
- detached: true,
400
- windowsHide: true,
401
- stdio: ["ignore", logFd, logFd],
402
- env: Object.assign({}, process.env, {
403
- CLAUDE_RELAY_CONFIG: configPath(),
404
- }),
405
- });
406
- child.unref();
407
- fs.closeSync(logFd);
408
-
409
- newConfig.pid = child.pid;
410
- saveConfig(newConfig);
411
-
412
- // Wait and verify (retry up to 5 seconds)
413
- var alive = false;
414
- for (var rc = 0; rc < 10; rc++) {
415
- await new Promise(function (resolve) { setTimeout(resolve, 500); });
416
- alive = await isDaemonAliveAsync(newConfig);
417
- if (alive) break;
418
- }
419
- if (!alive) {
420
- log(a.red + " Restart failed. Check logs: " + a.reset + logFile);
421
- process.exit(1);
422
- return;
423
- }
424
- var ip = getLocalIP();
425
- log(sym.done + " " + a.green + "Server restarted successfully." + a.reset);
426
- log("");
427
- showMainMenu(newConfig, ip);
428
- }
429
-
430
- // --- Network ---
431
- function getLocalIP() {
432
- var interfaces = os.networkInterfaces();
433
-
434
- // Prefer Tailscale IP
435
- for (var name in interfaces) {
436
- if (/^(tailscale|utun)/.test(name)) {
437
- for (var j = 0; j < interfaces[name].length; j++) {
438
- var addr = interfaces[name][j];
439
- if (addr.family === "IPv4" && !addr.internal && addr.address.startsWith("100.")) {
440
- return addr.address;
441
- }
442
- }
443
- }
444
- }
445
-
446
- // All interfaces for Tailscale CGNAT range
447
- for (var addrs of Object.values(interfaces)) {
448
- for (var k = 0; k < addrs.length; k++) {
449
- if (addrs[k].family === "IPv4" && !addrs[k].internal && addrs[k].address.startsWith("100.")) {
450
- return addrs[k].address;
451
- }
452
- }
453
- }
454
-
455
- // Fall back to LAN IP
456
- for (var addrs2 of Object.values(interfaces)) {
457
- for (var m = 0; m < addrs2.length; m++) {
458
- if (addrs2[m].family === "IPv4" && !addrs2[m].internal) {
459
- return addrs2[m].address;
460
- }
461
- }
462
- }
463
-
464
- return "localhost";
465
- }
466
-
467
- // --- Certs ---
468
- function isRoutableIP(addr) {
469
- if (addr.startsWith("10.")) return true;
470
- if (addr.startsWith("192.168.")) return true;
471
- if (addr.startsWith("100.")) {
472
- var second = parseInt(addr.split(".")[1], 10);
473
- return second >= 64 && second <= 127; // CGNAT (Tailscale)
474
- }
475
- if (addr.startsWith("172.")) {
476
- var second = parseInt(addr.split(".")[1], 10);
477
- return second >= 16 && second <= 31;
478
- }
479
- return false;
480
- }
481
-
482
- function getAllIPs() {
483
- var ips = [];
484
- var ifaces = os.networkInterfaces();
485
- for (var addrs of Object.values(ifaces)) {
486
- for (var j = 0; j < addrs.length; j++) {
487
- if (addrs[j].family === "IPv4" && !addrs[j].internal && isRoutableIP(addrs[j].address)) {
488
- ips.push(addrs[j].address);
489
- }
490
- }
491
- }
492
- return ips;
493
- }
494
-
495
- function ensureCerts(ip) {
496
- var homeDir = os.homedir();
497
- var certDir = path.join(process.env.CLAUDE_RELAY_HOME || path.join(homeDir, ".claude-relay"), "certs");
498
- var keyPath = path.join(certDir, "key.pem");
499
- var certPath = path.join(certDir, "cert.pem");
500
-
501
- var legacyDir = path.join(cwd, ".claude-relay", "certs");
502
- var legacyKey = path.join(legacyDir, "key.pem");
503
- var legacyCert = path.join(legacyDir, "cert.pem");
504
- if (!fs.existsSync(keyPath) && fs.existsSync(legacyKey) && fs.existsSync(legacyCert)) {
505
- fs.mkdirSync(certDir, { recursive: true });
506
- fs.copyFileSync(legacyKey, keyPath);
507
- fs.copyFileSync(legacyCert, certPath);
508
- }
509
-
510
- var caRoot = null;
511
- try {
512
- caRoot = path.join(
513
- execSync("mkcert -CAROOT", { encoding: "utf8" }).trim(),
514
- "rootCA.pem"
515
- );
516
- if (!fs.existsSync(caRoot)) caRoot = null;
517
- } catch (e) {}
518
-
519
- // Collect all IPv4 addresses (Tailscale + LAN)
520
- var allIPs = getAllIPs();
521
-
522
- if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
523
- var needRegen = false;
524
- try {
525
- var certText = execFileSync("openssl", ["x509", "-in", certPath, "-text", "-noout"], { encoding: "utf8" });
526
- for (var i = 0; i < allIPs.length; i++) {
527
- if (certText.indexOf(allIPs[i]) === -1) {
528
- needRegen = true;
529
- break;
530
- }
531
- }
532
- } catch (e) { needRegen = true; }
533
- if (!needRegen) return { key: keyPath, cert: certPath, caRoot: caRoot };
534
- }
535
-
536
- fs.mkdirSync(certDir, { recursive: true });
537
-
538
- var domains = ["localhost", "127.0.0.1", "::1"];
539
- for (var i = 0; i < allIPs.length; i++) {
540
- if (domains.indexOf(allIPs[i]) === -1) domains.push(allIPs[i]);
541
- }
542
-
543
- try {
544
- var mkcertArgs = ["-key-file", keyPath, "-cert-file", certPath].concat(domains);
545
- execFileSync("mkcert", mkcertArgs, { stdio: "pipe" });
546
- } catch (err) {
547
- return null;
548
- }
549
-
550
- return { key: keyPath, cert: certPath, caRoot: caRoot };
551
- }
552
-
553
- // --- Logo ---
554
- function printLogo() {
555
- var c = isBasicTerm ? a.yellow : "\x1b[38;2;218;119;86m";
556
- var r = a.reset;
557
- var lines = [
558
- " ██████╗ ██╗ █████╗ ██╗ ██╗ ██████╗ ███████╗ ██████╗ ███████╗ ██╗ █████╗ ██╗ ██╗",
559
- " ██╔════╝ ██║ ██╔══██╗ ██║ ██║ ██╔══██╗ ██╔════╝ ██╔══██╗ ██╔════╝ ██║ ██╔══██╗ ╚██╗ ██╔╝",
560
- " ██║ ██║ ███████║ ██║ ██║ ██║ ██║ █████╗ ██████╔╝ █████╗ ██║ ███████║ ╚████╔╝ ",
561
- " ██║ ██║ ██╔══██║ ██║ ██║ ██║ ██║ ██╔══╝ ██╔══██╗ ██╔══╝ ██║ ██╔══██║ ╚██╔╝ ",
562
- " ╚██████╗ ███████╗ ██║ ██║ ╚██████╔╝ ██████╔╝ ███████╗ ██║ ██║ ███████╗ ███████╗ ██║ ██║ ██║ ",
563
- " ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ",
564
- ];
565
- console.log("");
566
- for (var i = 0; i < lines.length; i++) {
567
- console.log(c + lines[i] + r);
568
- }
569
- }
570
-
571
- // --- Interactive prompts ---
572
- function promptToggle(title, desc, defaultValue, callback) {
573
- var value = defaultValue || false;
574
-
575
- function renderToggle() {
576
- var yes = value
577
- ? a.green + a.bold + "● Yes" + a.reset
578
- : a.dim + "○ Yes" + a.reset;
579
- var no = !value
580
- ? a.green + a.bold + "● No" + a.reset
581
- : a.dim + "○ No" + a.reset;
582
- return yes + a.dim + " / " + a.reset + no;
583
- }
584
-
585
- var lines = 2;
586
- log(sym.pointer + " " + a.bold + title + a.reset);
587
- if (desc) {
588
- log(sym.bar + " " + a.dim + desc + a.reset);
589
- lines = 3;
590
- }
591
- process.stdout.write(" " + sym.bar + " " + renderToggle());
592
-
593
- process.stdin.setRawMode(true);
594
- process.stdin.resume();
595
- process.stdin.setEncoding("utf8");
596
-
597
- process.stdin.on("data", function onToggle(ch) {
598
- if (ch === "\x1b[D" || ch === "\x1b[C" || ch === "\t") {
599
- value = !value;
600
- process.stdout.write("\x1b[2K\r " + sym.bar + " " + renderToggle());
601
- } else if (ch === "y" || ch === "Y") {
602
- value = true;
603
- process.stdout.write("\x1b[2K\r " + sym.bar + " " + renderToggle());
604
- } else if (ch === "n" || ch === "N") {
605
- value = false;
606
- process.stdout.write("\x1b[2K\r " + sym.bar + " " + renderToggle());
607
- } else if (ch === "\r" || ch === "\n") {
608
- process.stdin.setRawMode(false);
609
- process.stdin.pause();
610
- process.stdin.removeListener("data", onToggle);
611
- process.stdout.write("\n");
612
- clearUp(lines);
613
- var result = value ? a.green + "Yes" + a.reset : a.dim + "No" + a.reset;
614
- log(sym.done + " " + title + " " + a.dim + "·" + a.reset + " " + result);
615
- callback(value);
616
- } else if (ch === "\x03") {
617
- process.stdout.write("\n");
618
- clearUp(lines);
619
- log(sym.end + " " + a.dim + "Cancelled" + a.reset);
620
- process.exit(0);
621
- }
622
- });
623
- }
624
-
625
- function promptPin(callback) {
626
- log(sym.pointer + " " + a.bold + "PIN protection" + a.reset);
627
- log(sym.bar + " " + a.dim + "Require a 6-digit PIN to access the web UI. Enter to skip." + a.reset);
628
- process.stdout.write(" " + sym.bar + " ");
629
-
630
- var pin = "";
631
- process.stdin.setRawMode(true);
632
- process.stdin.resume();
633
- process.stdin.setEncoding("utf8");
634
-
635
- process.stdin.on("data", function onPin(ch) {
636
- if (ch === "\r" || ch === "\n") {
637
- process.stdin.setRawMode(false);
638
- process.stdin.pause();
639
- process.stdin.removeListener("data", onPin);
640
- process.stdout.write("\n");
641
-
642
- if (pin !== "" && !/^\d{6}$/.test(pin)) {
643
- clearUp(3);
644
- log(sym.done + " PIN protection " + a.red + "Must be exactly 6 digits" + a.reset);
645
- log(sym.end);
646
- process.exit(1);
647
- return;
648
- }
649
-
650
- clearUp(3);
651
- if (pin) {
652
- log(sym.done + " PIN protection " + a.dim + "·" + a.reset + " " + a.green + "Enabled" + a.reset);
653
- } else {
654
- log(sym.done + " PIN protection " + a.dim + "· Skipped" + a.reset);
655
- }
656
- log(sym.bar);
657
- callback(pin || null);
658
- } else if (ch === "\x03") {
659
- process.stdout.write("\n");
660
- clearUp(3);
661
- log(sym.end + " " + a.dim + "Cancelled" + a.reset);
662
- process.exit(0);
663
- } else if (ch === "\x7f" || ch === "\b") {
664
- if (pin.length > 0) {
665
- pin = pin.slice(0, -1);
666
- process.stdout.write("\b \b");
667
- }
668
- } else if (/\d/.test(ch) && pin.length < 6) {
669
- pin += ch;
670
- process.stdout.write(a.cyan + "●" + a.reset);
671
- }
672
- });
673
- }
674
-
675
- /**
676
- * Text input prompt with placeholder and Tab directory completion.
677
- * title: prompt label, placeholder: dimmed hint, callback(value)
678
- * Enter with empty input returns placeholder value.
679
- * Tab completes directory paths.
680
- */
681
- function promptText(title, placeholder, callback) {
682
- var prefix = " " + sym.bar + " ";
683
- var hintLine = "";
684
- var lineCount = 2;
685
-
686
- log(sym.pointer + " " + a.bold + title + a.reset + " " + a.dim + "(esc to go back)" + a.reset);
687
- process.stdout.write(prefix + a.dim + placeholder + a.reset);
688
- // Move cursor to start of placeholder
689
- process.stdout.write("\r" + prefix);
690
-
691
- var text = "";
692
- var showingPlaceholder = true;
693
- process.stdin.setRawMode(true);
694
- process.stdin.resume();
695
- process.stdin.setEncoding("utf8");
696
-
697
- function redrawInput() {
698
- process.stdout.write("\x1b[2K\r" + prefix + text);
699
- }
700
-
701
- function clearHint() {
702
- if (hintLine) {
703
- // Erase the hint line below
704
- process.stdout.write("\n\x1b[2K\x1b[1A");
705
- hintLine = "";
706
- lineCount = 2;
707
- }
708
- }
709
-
710
- function showHint(msg) {
711
- clearHint();
712
- hintLine = msg;
713
- lineCount = 3;
714
- // Print hint below, then move cursor back up
715
- process.stdout.write("\n" + prefix + a.dim + msg + a.reset + "\x1b[1A");
716
- redrawInput();
717
- }
718
-
719
- function tabComplete() {
720
- var current = text || "";
721
- if (!current) current = "/";
722
-
723
- // Resolve ~ to home
724
- if (current.charAt(0) === "~") {
725
- current = os.homedir() + current.substring(1);
726
- }
727
-
728
- var resolved = path.resolve(current);
729
- var dir, partial;
730
-
731
- try {
732
- var st = fs.statSync(resolved);
733
- if (st.isDirectory()) {
734
- // Current text is a full directory — list its children
735
- dir = resolved;
736
- partial = "";
737
- } else {
738
- dir = path.dirname(resolved);
739
- partial = path.basename(resolved);
740
- }
741
- } catch (e) {
742
- // Path doesn't exist — complete from parent
743
- dir = path.dirname(resolved);
744
- partial = path.basename(resolved);
745
- }
746
-
747
- var entries;
748
- try {
749
- entries = fs.readdirSync(dir);
750
- } catch (e) {
751
- return; // Can't read directory
752
- }
753
-
754
- // Filter to directories only, matching partial prefix
755
- var matches = [];
756
- var lowerPartial = partial.toLowerCase();
757
- for (var i = 0; i < entries.length; i++) {
758
- if (entries[i].charAt(0) === "." && !partial.startsWith(".")) continue;
759
- if (lowerPartial && entries[i].toLowerCase().indexOf(lowerPartial) !== 0) continue;
760
- try {
761
- var full = path.join(dir, entries[i]);
762
- if (fs.statSync(full).isDirectory()) {
763
- matches.push(entries[i]);
764
- }
765
- } catch (e) {}
766
- }
767
-
768
- if (matches.length === 0) return;
769
-
770
- if (matches.length === 1) {
771
- // Single match — complete it
772
- var completed = path.join(dir, matches[0]) + path.sep;
773
- text = completed;
774
- showingPlaceholder = false;
775
- clearHint();
776
- redrawInput();
777
- } else {
778
- // Multiple matches — find longest common prefix and show candidates
779
- var common = matches[0];
780
- for (var m = 1; m < matches.length; m++) {
781
- var k = 0;
782
- while (k < common.length && k < matches[m].length && common.charAt(k) === matches[m].charAt(k)) k++;
783
- common = common.substring(0, k);
784
- }
785
-
786
- if (common.length > partial.length) {
787
- // Extend to common prefix
788
- text = path.join(dir, common);
789
- showingPlaceholder = false;
790
- }
791
-
792
- // Show candidates as hint
793
- var display = matches.slice(0, 6).join(" ");
794
- if (matches.length > 6) display += " " + a.dim + "+" + (matches.length - 6) + " more" + a.reset;
795
- showHint(display);
796
- }
797
- }
798
-
799
- process.stdin.on("data", function onText(ch) {
800
- if (ch === "\r" || ch === "\n") {
801
- process.stdin.setRawMode(false);
802
- process.stdin.pause();
803
- process.stdin.removeListener("data", onText);
804
- var result = text || placeholder;
805
- clearHint();
806
- process.stdout.write("\n");
807
- clearUp(2);
808
- log(sym.done + " " + title + " " + a.dim + "·" + a.reset + " " + result);
809
- callback(result);
810
- } else if (ch === "\x1b" || ch === "\x03") {
811
- process.stdin.setRawMode(false);
812
- process.stdin.pause();
813
- process.stdin.removeListener("data", onText);
814
- clearHint();
815
- process.stdout.write("\n");
816
- clearUp(2);
817
- if (ch === "\x03") {
818
- log(sym.end + " " + a.dim + "Cancelled" + a.reset);
819
- process.exit(0);
820
- }
821
- callback(null);
822
- } else if (ch === "\t") {
823
- if (showingPlaceholder) {
824
- // Accept placeholder first
825
- text = placeholder;
826
- showingPlaceholder = false;
827
- redrawInput();
828
- }
829
- tabComplete();
830
- } else if (ch === "\x7f" || ch === "\b") {
831
- if (text.length > 0) {
832
- text = text.slice(0, -1);
833
- clearHint();
834
- if (text.length === 0) {
835
- // Re-show placeholder
836
- showingPlaceholder = true;
837
- process.stdout.write("\x1b[2K\r" + prefix + a.dim + placeholder + a.reset);
838
- process.stdout.write("\r" + prefix);
839
- } else {
840
- redrawInput();
841
- }
842
- }
843
- } else if (ch >= " ") {
844
- if (showingPlaceholder) {
845
- showingPlaceholder = false;
846
- }
847
- clearHint();
848
- text += ch;
849
- redrawInput();
850
- }
851
- });
852
- }
853
-
854
- /**
855
- * Select menu: arrow keys to navigate, enter to select.
856
- * items: [{ label, value, desc? }]
857
- */
858
- function promptSelect(title, items, callback, opts) {
859
- var idx = 0;
860
- // Build hotkeys map: { key: handler }
861
- var hotkeys = {};
862
- if (opts && opts.key && opts.onKey) {
863
- hotkeys[opts.key] = opts.onKey;
864
- }
865
- if (opts && opts.keys) {
866
- for (var ki = 0; ki < opts.keys.length; ki++) {
867
- hotkeys[opts.keys[ki].key] = opts.keys[ki].onKey;
868
- }
869
- }
870
- var hintLines = null;
871
- if (opts && opts.hint) {
872
- hintLines = Array.isArray(opts.hint) ? opts.hint : [opts.hint];
873
- }
874
-
875
- function render() {
876
- var out = "";
877
- for (var i = 0; i < items.length; i++) {
878
- var prefix = i === idx
879
- ? a.green + a.bold + " ● " + a.reset
880
- : a.dim + " ○ " + a.reset;
881
- out += " " + sym.bar + prefix + items[i].label + "\n";
882
- }
883
- return out;
884
- }
885
-
886
- log(sym.pointer + " " + a.bold + title + a.reset);
887
- process.stdout.write(render());
888
-
889
- // Render hint lines below the menu tree
890
- var hintBoxLines = 0;
891
- if (hintLines) {
892
- log(sym.end);
893
- for (var h = 0; h < hintLines.length; h++) {
894
- log(" " + gradient(hintLines[h]));
895
- }
896
- hintBoxLines = 1 + hintLines.length; // sym.end + lines
897
- }
898
-
899
- var lineCount = items.length + 1 + hintBoxLines;
900
-
901
- process.stdin.setRawMode(true);
902
- process.stdin.resume();
903
- process.stdin.setEncoding("utf8");
904
-
905
- process.stdin.on("data", function onSelect(ch) {
906
- if (ch === "\x1b[A") { // up
907
- if (idx > 0) idx--;
908
- } else if (ch === "\x1b[B") { // down
909
- if (idx < items.length - 1) idx++;
910
- } else if (ch === "\r" || ch === "\n") {
911
- process.stdin.setRawMode(false);
912
- process.stdin.pause();
913
- process.stdin.removeListener("data", onSelect);
914
- clearUp(lineCount);
915
- log(sym.done + " " + title + " " + a.dim + "·" + a.reset + " " + items[idx].label);
916
- callback(items[idx].value);
917
- return;
918
- } else if (ch === "\x03") {
919
- process.stdout.write("\n");
920
- process.exit(0);
921
- } else if (hotkeys[ch]) {
922
- process.stdin.setRawMode(false);
923
- process.stdin.pause();
924
- process.stdin.removeListener("data", onSelect);
925
- clearUp(lineCount);
926
- hotkeys[ch]();
927
- return;
928
- } else if (ch === "\x7f" || ch === "\b") {
929
- // Backspace — trigger "back" if available
930
- for (var bi = 0; bi < items.length; bi++) {
931
- if (items[bi].value === "back") {
932
- process.stdin.setRawMode(false);
933
- process.stdin.pause();
934
- process.stdin.removeListener("data", onSelect);
935
- clearUp(lineCount);
936
- log(sym.done + " " + title + " " + a.dim + "·" + a.reset + " " + items[bi].label);
937
- callback("back");
938
- return;
939
- }
940
- }
941
- return;
942
- } else {
943
- return;
944
- }
945
- // Redraw
946
- clearUp(items.length + hintBoxLines);
947
- process.stdout.write(render());
948
- // Re-render hint lines
949
- if (hintLines) {
950
- log(sym.end);
951
- for (var rh = 0; rh < hintLines.length; rh++) {
952
- log(" " + gradient(hintLines[rh]));
953
- }
954
- }
955
- });
956
- }
957
-
958
- /**
959
- * Multi-select menu: space to toggle, enter to confirm.
960
- * items: [{ label, value, checked? }]
961
- * callback(selectedValues[])
962
- */
963
- function promptMultiSelect(title, items, callback) {
964
- var selected = [];
965
- for (var si = 0; si < items.length; si++) {
966
- selected.push(items[si].checked !== false);
967
- }
968
- var idx = 0;
969
-
970
- function render() {
971
- var out = "";
972
- for (var i = 0; i < items.length; i++) {
973
- var cursor = i === idx ? a.cyan + ">" + a.reset : " ";
974
- var check = selected[i]
975
- ? a.green + a.bold + "■" + a.reset
976
- : a.dim + "□" + a.reset;
977
- out += " " + sym.bar + " " + cursor + " " + check + " " + items[i].label + "\n";
978
- }
979
- out += " " + sym.bar + " " + a.dim + "space: toggle · enter: confirm" + a.reset + "\n";
980
- return out;
981
- }
982
-
983
- log(sym.pointer + " " + a.bold + title + a.reset);
984
- process.stdout.write(render());
985
-
986
- var lineCount = items.length + 2; // title + items + hint
987
-
988
- process.stdin.setRawMode(true);
989
- process.stdin.resume();
990
- process.stdin.setEncoding("utf8");
991
-
992
- process.stdin.on("data", function onMulti(ch) {
993
- if (ch === "\x1b[A") { // up
994
- if (idx > 0) idx--;
995
- } else if (ch === "\x1b[B") { // down
996
- if (idx < items.length - 1) idx++;
997
- } else if (ch === " ") { // toggle
998
- selected[idx] = !selected[idx];
999
- } else if (ch === "a" || ch === "A") { // toggle all
1000
- var allSelected = selected.every(function (s) { return s; });
1001
- for (var ai = 0; ai < selected.length; ai++) selected[ai] = !allSelected;
1002
- } else if (ch === "\r" || ch === "\n") {
1003
- process.stdin.setRawMode(false);
1004
- process.stdin.pause();
1005
- process.stdin.removeListener("data", onMulti);
1006
- clearUp(lineCount);
1007
- var result = [];
1008
- var labels = [];
1009
- for (var ri = 0; ri < items.length; ri++) {
1010
- if (selected[ri]) {
1011
- result.push(items[ri].value);
1012
- labels.push(items[ri].label);
1013
- }
1014
- }
1015
- var summary = result.length === items.length
1016
- ? "All (" + result.length + ")"
1017
- : result.length + " of " + items.length;
1018
- log(sym.done + " " + title + " " + a.dim + "·" + a.reset + " " + summary);
1019
- callback(result);
1020
- return;
1021
- } else if (ch === "\x03") {
1022
- process.stdout.write("\n");
1023
- process.exit(0);
1024
- } else if (ch === "\x1b") {
1025
- // Escape — select none
1026
- process.stdin.setRawMode(false);
1027
- process.stdin.pause();
1028
- process.stdin.removeListener("data", onMulti);
1029
- clearUp(lineCount);
1030
- log(sym.done + " " + title + " " + a.dim + "· Skipped" + a.reset);
1031
- callback([]);
1032
- return;
1033
- } else {
1034
- return;
1035
- }
1036
- // Redraw
1037
- clearUp(items.length + 1); // items + hint (not title)
1038
- process.stdout.write(render());
1039
- });
1040
- }
1041
-
1042
- // --- Port availability ---
1043
-
1044
- function isPortFree(p) {
1045
- return new Promise(function (resolve) {
1046
- var srv = net.createServer();
1047
- srv.once("error", function () { resolve(false); });
1048
- srv.once("listening", function () { srv.close(function () { resolve(true); }); });
1049
- srv.listen(p);
1050
- });
1051
- }
1052
-
1053
- // --- Detect tools ---
1054
- function getTailscaleIP() {
1055
- var interfaces = os.networkInterfaces();
1056
- for (var name in interfaces) {
1057
- if (/^(tailscale|utun)/.test(name)) {
1058
- for (var i = 0; i < interfaces[name].length; i++) {
1059
- var addr = interfaces[name][i];
1060
- if (addr.family === "IPv4" && !addr.internal && addr.address.startsWith("100.")) {
1061
- return addr.address;
1062
- }
1063
- }
1064
- }
1065
- }
1066
- for (var addrs of Object.values(interfaces)) {
1067
- for (var j = 0; j < addrs.length; j++) {
1068
- if (addrs[j].family === "IPv4" && !addrs[j].internal && addrs[j].address.startsWith("100.")) {
1069
- return addrs[j].address;
1070
- }
1071
- }
1072
- }
1073
- return null;
1074
- }
1075
-
1076
- function hasTailscale() {
1077
- return getTailscaleIP() !== null;
1078
- }
1079
-
1080
- function hasMkcert() {
1081
- try {
1082
- execSync("mkcert -CAROOT", { stdio: "pipe", encoding: "utf8" });
1083
- return true;
1084
- } catch (e) { return false; }
1085
- }
1086
-
1087
- // ==============================
1088
- // Restore projects from ~/.clayrc
1089
- // ==============================
1090
- function promptRestoreProjects(projects, callback) {
1091
- log(sym.bar);
1092
- log(sym.pointer + " " + a.bold + "Previous projects found" + a.reset);
1093
- log(sym.bar + " " + a.dim + "Restore projects from your last session?" + a.reset);
1094
- log(sym.bar);
1095
-
1096
- var items = projects.map(function (p) {
1097
- var name = p.title || path.basename(p.path);
1098
- return {
1099
- label: a.bold + name + a.reset + " " + a.dim + p.path + a.reset,
1100
- value: p,
1101
- checked: true,
1102
- };
1103
- });
1104
-
1105
- promptMultiSelect("Restore projects", items, function (selected) {
1106
- // Remove unselected projects from ~/.clayrc
1107
- if (selected.length < projects.length) {
1108
- var selectedPaths = {};
1109
- for (var si = 0; si < selected.length; si++) {
1110
- selectedPaths[selected[si].path] = true;
1111
- }
1112
- try {
1113
- var rc = loadClayrc();
1114
- rc.recentProjects = (rc.recentProjects || []).filter(function (p) {
1115
- return selectedPaths[p.path];
1116
- });
1117
- saveClayrc(rc);
1118
- } catch (e) {}
1119
- }
1120
-
1121
- log(sym.bar);
1122
- if (selected.length > 0) {
1123
- log(sym.done + " " + a.green + "Restoring " + selected.length + (selected.length === 1 ? " project" : " projects") + a.reset);
1124
- } else {
1125
- log(sym.done + " " + a.dim + "Starting fresh" + a.reset);
1126
- }
1127
- log(sym.end + " " + a.dim + "Starting relay..." + a.reset);
1128
- log("");
1129
- callback(selected);
1130
- });
1131
- }
1132
-
1133
- // ==============================
1134
- // First-run setup (no daemon)
1135
- // ==============================
1136
- function setup(callback) {
1137
- console.clear();
1138
- printLogo();
1139
- log("");
1140
- log(sym.pointer + " " + a.bold + "Claude Relay" + a.reset + a.dim + " · Unofficial, open-source project" + a.reset);
1141
- log(sym.bar);
1142
- log(sym.bar + " " + a.dim + "Anyone with the URL gets full Claude Code access to this machine." + a.reset);
1143
- log(sym.bar + " " + a.dim + "Use a private network (Tailscale, VPN)." + a.reset);
1144
- log(sym.bar + " " + a.dim + "The authors assume no responsibility for any damage or data loss." + a.reset);
1145
- log(sym.bar);
1146
-
1147
- promptToggle("Accept and continue", null, true, function (accepted) {
1148
- if (!accepted) {
1149
- log(sym.end + " " + a.dim + "Aborted." + a.reset);
1150
- log("");
1151
- process.exit(0);
1152
- return;
1153
- }
1154
- log(sym.bar);
1155
-
1156
- function askPort() {
1157
- promptText("Port", String(port), function (val) {
1158
- if (val === null) {
1159
- log(sym.end + " " + a.dim + "Aborted." + a.reset);
1160
- log("");
1161
- process.exit(0);
1162
- return;
1163
- }
1164
- var p = parseInt(val, 10);
1165
- if (!p || p < 1 || p > 65535) {
1166
- log(sym.warn + " " + a.red + "Invalid port number" + a.reset);
1167
- askPort();
1168
- return;
1169
- }
1170
- isPortFree(p).then(function (free) {
1171
- if (!free) {
1172
- log(sym.warn + " " + a.yellow + "Port " + p + " is already in use" + a.reset);
1173
- askPort();
1174
- return;
1175
- }
1176
- port = p;
1177
- log(sym.bar);
1178
-
1179
- promptPin(function (pin) {
1180
- if (process.platform === "darwin") {
1181
- promptToggle("Keep awake", "Prevent system sleep while relay is running", false, function (keepAwake) {
1182
- callback(pin, keepAwake);
1183
- });
1184
- } else {
1185
- callback(pin, false);
1186
- }
1187
- });
1188
- });
1189
- });
1190
- }
1191
- askPort();
1192
- });
1193
- }
1194
-
1195
- // ==============================
1196
- // Fork the daemon process
1197
- // ==============================
1198
- async function forkDaemon(pin, keepAwake, extraProjects, addCwd) {
1199
- var ip = getLocalIP();
1200
- var hasTls = false;
1201
-
1202
- if (useHttps) {
1203
- var certPaths = ensureCerts(ip);
1204
- if (certPaths) {
1205
- hasTls = true;
1206
- } else {
1207
- log(sym.warn + " " + a.yellow + "HTTPS unavailable" + a.reset + a.dim + " · mkcert not installed" + a.reset);
1208
- }
1209
- }
1210
-
1211
- // Check port availability
1212
- var portFree = await isPortFree(port);
1213
- if (!portFree) {
1214
- log(a.red + "Port " + port + " is already in use." + a.reset);
1215
- log(a.dim + "Is another Claude Relay daemon running?" + a.reset);
1216
- process.exit(1);
1217
- return;
1218
- }
1219
-
1220
- var allProjects = [];
1221
- var usedSlugs = [];
1222
-
1223
- // Only include cwd if explicitly requested
1224
- if (addCwd) {
1225
- var slug = generateSlug(cwd, []);
1226
- allProjects.push({ path: cwd, slug: slug, addedAt: Date.now() });
1227
- usedSlugs.push(slug);
1228
- }
1229
-
1230
- // Add restored projects (from ~/.clayrc)
1231
- if (extraProjects && extraProjects.length > 0) {
1232
- for (var ep = 0; ep < extraProjects.length; ep++) {
1233
- var rp = extraProjects[ep];
1234
- if (rp.path === cwd) continue; // skip if same as cwd
1235
- if (!fs.existsSync(rp.path)) continue; // skip missing directories
1236
- var rpSlug = generateSlug(rp.path, usedSlugs);
1237
- usedSlugs.push(rpSlug);
1238
- allProjects.push({ path: rp.path, slug: rpSlug, title: rp.title || undefined, addedAt: rp.addedAt || Date.now() });
1239
- }
1240
- }
1241
-
1242
- var config = {
1243
- pid: null,
1244
- port: port,
1245
- pinHash: pin ? generateAuthToken(pin) : null,
1246
- tls: hasTls,
1247
- debug: debugMode,
1248
- keepAwake: keepAwake,
1249
- dangerouslySkipPermissions: dangerouslySkipPermissions,
1250
- projects: allProjects,
1251
- };
1252
-
1253
- ensureConfigDir();
1254
- saveConfig(config);
1255
-
1256
- // Fork daemon
1257
- var daemonScript = path.join(__dirname, "..", "lib", "daemon.js");
1258
- var logFile = logPath();
1259
- var logFd = fs.openSync(logFile, "a");
1260
-
1261
- var child = spawn(process.execPath, [daemonScript], {
1262
- detached: true,
1263
- windowsHide: true,
1264
- stdio: ["ignore", logFd, logFd],
1265
- env: Object.assign({}, process.env, {
1266
- CLAUDE_RELAY_CONFIG: configPath(),
1267
- }),
1268
- });
1269
- child.unref();
1270
- fs.closeSync(logFd);
1271
-
1272
- // Update config with PID
1273
- config.pid = child.pid;
1274
- saveConfig(config);
1275
-
1276
- // Wait for daemon to start (retry up to 5 seconds)
1277
- var alive = false;
1278
- for (var attempt = 0; attempt < 10; attempt++) {
1279
- await new Promise(function (resolve) { setTimeout(resolve, 500); });
1280
- alive = await isDaemonAliveAsync(config);
1281
- if (alive) break;
1282
- }
1283
- if (!alive) {
1284
- log(a.red + "Failed to start daemon. Check logs:" + a.reset);
1285
- log(a.dim + logFile + a.reset);
1286
- clearStaleConfig();
1287
- process.exit(1);
1288
- return;
1289
- }
1290
-
1291
- // Headless mode — print status and exit immediately
1292
- if (headlessMode) {
1293
- var protocol = config.tls ? "https" : "http";
1294
- var url = protocol + "://" + ip + ":" + config.port;
1295
- console.log(" " + sym.done + " Daemon started (PID " + config.pid + ")");
1296
- console.log(" " + sym.done + " " + url);
1297
- console.log(" " + sym.done + " Headless mode — exiting CLI");
1298
- process.exit(0);
1299
- return;
1300
- }
1301
-
1302
- // Show success + QR
1303
- showServerStarted(config, ip);
1304
- }
1305
-
1306
- // ==============================
1307
- // Dev mode — foreground daemon with file watching
1308
- // ==============================
1309
- async function devMode(pin, keepAwake, existingPinHash) {
1310
- var ip = getLocalIP();
1311
- var hasTls = false;
1312
-
1313
- if (useHttps) {
1314
- var certPaths = ensureCerts(ip);
1315
- if (certPaths) hasTls = true;
1316
- }
1317
-
1318
- var portFree = await isPortFree(port);
1319
- if (!portFree) {
1320
- console.log("\x1b[31m[dev] Port " + port + " is already in use.\x1b[0m");
1321
- process.exit(1);
1322
- return;
1323
- }
1324
-
1325
- var slug = generateSlug(cwd, []);
1326
- var allProjects = [{ path: cwd, slug: slug, addedAt: Date.now() }];
1327
-
1328
- // Restore previous projects
1329
- var rc = loadClayrc();
1330
- var restorable = (rc.recentProjects || []).filter(function (p) {
1331
- return p.path !== cwd && fs.existsSync(p.path);
1332
- });
1333
- var usedSlugs = [slug];
1334
- for (var ri = 0; ri < restorable.length; ri++) {
1335
- var rp = restorable[ri];
1336
- var rpSlug = generateSlug(rp.path, usedSlugs);
1337
- usedSlugs.push(rpSlug);
1338
- allProjects.push({ path: rp.path, slug: rpSlug, title: rp.title || undefined, addedAt: rp.addedAt || Date.now() });
1339
- }
1340
-
1341
- var config = {
1342
- pid: null,
1343
- port: port,
1344
- pinHash: existingPinHash || (pin ? generateAuthToken(pin) : null),
1345
- tls: hasTls,
1346
- debug: true,
1347
- keepAwake: keepAwake || false,
1348
- dangerouslySkipPermissions: dangerouslySkipPermissions,
1349
- projects: allProjects,
1350
- };
1351
-
1352
- ensureConfigDir();
1353
- saveConfig(config);
1354
-
1355
- var daemonScript = path.join(__dirname, "..", "lib", "daemon.js");
1356
- var libDir = path.join(__dirname, "..", "lib");
1357
- var child = null;
1358
- var intentionalKill = false;
1359
- var debounceTimer = null;
1360
-
1361
- function spawnDaemon() {
1362
- child = spawn(process.execPath, [daemonScript], {
1363
- stdio: ["ignore", "inherit", "inherit"],
1364
- env: Object.assign({}, process.env, {
1365
- CLAUDE_RELAY_CONFIG: configPath(),
1366
- }),
1367
- });
1368
-
1369
- child.on("exit", function (code) {
1370
- child = null;
1371
- if (intentionalKill) {
1372
- intentionalKill = false;
1373
- return;
1374
- }
1375
- // Exit code 120 = update restart — respawn daemon with current dev code
1376
- if (code === 120) {
1377
- console.log("\x1b[36m[dev]\x1b[0m Update restart — respawning daemon...");
1378
- console.log("");
1379
- setTimeout(spawnDaemon, 500);
1380
- return;
1381
- }
1382
- // Unexpected exit — auto restart
1383
- console.log("\x1b[33m[dev] Daemon exited (code " + code + "), restarting...\x1b[0m");
1384
- setTimeout(spawnDaemon, 500);
1385
- });
1386
- }
1387
-
1388
- function restartDaemon() {
1389
- intentionalKill = true;
1390
- if (child) {
1391
- child.kill("SIGTERM");
1392
- // Give it a moment to shut down, then spawn
1393
- setTimeout(spawnDaemon, 300);
1394
- } else {
1395
- intentionalKill = false;
1396
- spawnDaemon();
1397
- }
1398
- }
1399
-
1400
- console.log("\x1b[36m[dev]\x1b[0m Starting relay on port " + port + "...");
1401
- if (watchMode) {
1402
- console.log("\x1b[36m[dev]\x1b[0m Watching lib/ for changes (excluding lib/public/)");
1403
- }
1404
- console.log("");
1405
-
1406
- spawnDaemon();
1407
-
1408
- // Wait for daemon to be ready, then show CLI menu
1409
- config.pid = child ? child.pid : null;
1410
- saveConfig(config);
1411
-
1412
- var daemonReady = false;
1413
- for (var da = 0; da < 10; da++) {
1414
- await new Promise(function (resolve) { setTimeout(resolve, 500); });
1415
- daemonReady = await isDaemonAliveAsync(config);
1416
- if (daemonReady) break;
1417
- }
1418
- if (daemonReady) {
1419
- showServerStarted(config, ip);
1420
- }
1421
-
1422
- // Watch lib/ for server-side file changes (only with --watch)
1423
- var watcher = null;
1424
- if (watchMode) {
1425
- watcher = fs.watch(libDir, { recursive: true }, function (eventType, filename) {
1426
- if (!filename) return;
1427
- // Skip client-side files — they're served from disk
1428
- if (filename.startsWith("public" + path.sep) || filename.startsWith("public/")) return;
1429
- // Skip non-JS files
1430
- if (!filename.endsWith(".js")) return;
1431
-
1432
- if (debounceTimer) clearTimeout(debounceTimer);
1433
- debounceTimer = setTimeout(function () {
1434
- console.log("\x1b[36m[dev]\x1b[0m File changed: lib/" + filename);
1435
- console.log("\x1b[36m[dev]\x1b[0m Restarting...");
1436
- console.log("");
1437
- restartDaemon();
1438
- }, 300);
1439
- });
1440
- }
1441
-
1442
- // Clean exit on Ctrl+C
1443
- var shuttingDown = false;
1444
- process.on("SIGINT", function () {
1445
- if (shuttingDown) return;
1446
- shuttingDown = true;
1447
- console.log("\n\x1b[36m[dev]\x1b[0m Shutting down...");
1448
- if (watcher) watcher.close();
1449
- if (debounceTimer) clearTimeout(debounceTimer);
1450
- intentionalKill = true;
1451
- if (child) {
1452
- child.kill("SIGTERM");
1453
- child.on("exit", function () {
1454
- clearStaleConfig();
1455
- process.exit(0);
1456
- });
1457
- // Force kill after 3s
1458
- setTimeout(function () { process.exit(0); }, 3000);
1459
- } else {
1460
- clearStaleConfig();
1461
- process.exit(0);
1462
- }
1463
- });
1464
- }
1465
-
1466
- // ==============================
1467
- // Restart daemon with TLS enabled
1468
- // ==============================
1469
- async function restartDaemonWithTLS(config, callback) {
1470
- var ip = getLocalIP();
1471
- var certPaths = ensureCerts(ip);
1472
- if (!certPaths) {
1473
- callback(config);
1474
- return;
1475
- }
1476
-
1477
- // Shut down old daemon
1478
- stopDaemonWatcher();
1479
- try {
1480
- await sendIPCCommand(socketPath(), { cmd: "shutdown" });
1481
- } catch (e) {}
1482
-
1483
- // Wait for port to be released
1484
- var waited = 0;
1485
- while (waited < 5000) {
1486
- await new Promise(function (resolve) { setTimeout(resolve, 300); });
1487
- waited += 300;
1488
- var free = await isPortFree(config.port);
1489
- if (free) break;
1490
- }
1491
- clearStaleConfig();
1492
-
1493
- // Re-fork with TLS
1494
- var newConfig = {
1495
- pid: null,
1496
- port: config.port,
1497
- pinHash: config.pinHash || null,
1498
- tls: true,
1499
- debug: config.debug || false,
1500
- keepAwake: config.keepAwake || false,
1501
- dangerouslySkipPermissions: config.dangerouslySkipPermissions || false,
1502
- projects: config.projects || [],
1503
- };
1504
-
1505
- ensureConfigDir();
1506
- saveConfig(newConfig);
1507
-
1508
- var daemonScript = path.join(__dirname, "..", "lib", "daemon.js");
1509
- var logFile = logPath();
1510
- var logFd = fs.openSync(logFile, "a");
1511
-
1512
- var child = spawn(process.execPath, [daemonScript], {
1513
- detached: true,
1514
- windowsHide: true,
1515
- stdio: ["ignore", logFd, logFd],
1516
- env: Object.assign({}, process.env, {
1517
- CLAUDE_RELAY_CONFIG: configPath(),
1518
- }),
1519
- });
1520
- child.unref();
1521
- fs.closeSync(logFd);
1522
-
1523
- newConfig.pid = child.pid;
1524
- saveConfig(newConfig);
1525
-
1526
- var alive = false;
1527
- for (var ra = 0; ra < 10; ra++) {
1528
- await new Promise(function (resolve) { setTimeout(resolve, 500); });
1529
- alive = await isDaemonAliveAsync(newConfig);
1530
- if (alive) break;
1531
- }
1532
- if (!alive) {
1533
- log(sym.warn + " " + a.yellow + "Failed to restart with HTTPS, falling back to HTTP..." + a.reset);
1534
- // Re-fork without TLS so the server is at least running
1535
- newConfig.tls = false;
1536
- saveConfig(newConfig);
1537
- var logFd2 = fs.openSync(logFile, "a");
1538
- var child2 = spawn(process.execPath, [daemonScript], {
1539
- detached: true,
1540
- windowsHide: true,
1541
- stdio: ["ignore", logFd2, logFd2],
1542
- env: Object.assign({}, process.env, {
1543
- CLAUDE_RELAY_CONFIG: configPath(),
1544
- }),
1545
- });
1546
- child2.unref();
1547
- fs.closeSync(logFd2);
1548
- newConfig.pid = child2.pid;
1549
- saveConfig(newConfig);
1550
- for (var rb = 0; rb < 10; rb++) {
1551
- await new Promise(function (resolve) { setTimeout(resolve, 500); });
1552
- var retryAlive = await isDaemonAliveAsync(newConfig);
1553
- if (retryAlive) break;
1554
- }
1555
- startDaemonWatcher();
1556
- callback(newConfig);
1557
- return;
1558
- }
1559
-
1560
- startDaemonWatcher();
1561
- callback(newConfig);
1562
- }
1563
-
1564
- // ==============================
1565
- // Show server started info
1566
- // ==============================
1567
- function showServerStarted(config, ip) {
1568
- showMainMenu(config, ip);
1569
- }
1570
-
1571
- // ==============================
1572
- // Main management menu
1573
- // ==============================
1574
- function showMainMenu(config, ip) {
1575
- startDaemonWatcher();
1576
- var protocol = config.tls ? "https" : "http";
1577
- var url = protocol + "://" + ip + ":" + config.port;
1578
-
1579
- sendIPCCommand(socketPath(), { cmd: "get_status" }).then(function (status) {
1580
- var projs = (status && status.projects) || [];
1581
- var totalSessions = 0;
1582
- var totalAwaiting = 0;
1583
- for (var i = 0; i < projs.length; i++) {
1584
- totalSessions += projs[i].sessions || 0;
1585
- if (projs[i].isProcessing) totalAwaiting++;
1586
- }
1587
-
1588
- console.clear();
1589
- printLogo();
1590
- log("");
1591
-
1592
- function afterQr() {
1593
- // Status line
1594
- log(" " + a.dim + "claude-relay" + a.reset + " " + a.dim + "v" + currentVersion + a.reset + a.dim + " — " + url + a.reset);
1595
- var parts = [];
1596
- parts.push(a.bold + projs.length + a.reset + a.dim + (projs.length === 1 ? " project" : " projects"));
1597
- parts.push(a.reset + a.bold + totalSessions + a.reset + a.dim + (totalSessions === 1 ? " session" : " sessions"));
1598
- if (totalAwaiting > 0) {
1599
- parts.push(a.reset + a.yellow + a.bold + totalAwaiting + a.reset + a.yellow + " awaiting" + a.reset + a.dim);
1600
- }
1601
- log(" " + a.dim + parts.join(a.reset + a.dim + " · ") + a.reset);
1602
- log(" Press " + a.bold + "o" + a.reset + " to open in browser");
1603
- log("");
1604
-
1605
- showMenuItems();
1606
- }
1607
-
1608
- if (ip !== "localhost") {
1609
- qrcode.generate(url, { small: !isBasicTerm }, function (code) {
1610
- var lines = code.split("\n").map(function (l) { return " " + l; }).join("\n");
1611
- console.log(lines);
1612
- afterQr();
1613
- });
1614
- } else {
1615
- log(a.bold + " " + url + a.reset);
1616
- log("");
1617
- afterQr();
1618
- }
1619
-
1620
- function showMenuItems() {
1621
- var items = [
1622
- { label: "Setup notifications", value: "notifications" },
1623
- { label: "Projects", value: "projects" },
1624
- { label: "Settings", value: "settings" },
1625
- { label: "Shut down server", value: "shutdown" },
1626
- { label: "Keep server alive & exit", value: "exit" },
1627
- ];
1628
-
1629
- promptSelect("What would you like to do?", items, function (choice) {
1630
- switch (choice) {
1631
- case "notifications":
1632
- showSetupGuide(config, ip, function () {
1633
- config = loadConfig() || config;
1634
- showMainMenu(config, ip);
1635
- });
1636
- break;
1637
-
1638
- case "projects":
1639
- showProjectsMenu(config, ip);
1640
- break;
1641
-
1642
- case "settings":
1643
- showSettingsMenu(config, ip);
1644
- break;
1645
-
1646
- case "shutdown":
1647
- log(sym.bar);
1648
- log(sym.bar + " " + a.yellow + "This will stop the server completely." + a.reset);
1649
- log(sym.bar + " " + a.dim + "All connected sessions will be disconnected." + a.reset);
1650
- log(sym.bar);
1651
- promptSelect("Are you sure?", [
1652
- { label: "Cancel", value: "cancel" },
1653
- { label: "Shut down", value: "confirm" },
1654
- ], function (confirm) {
1655
- if (confirm === "confirm") {
1656
- stopDaemonWatcher();
1657
- sendIPCCommand(socketPath(), { cmd: "shutdown" }).then(function () {
1658
- log(sym.done + " " + a.green + "Server stopped." + a.reset);
1659
- log("");
1660
- clearStaleConfig();
1661
- process.exit(0);
1662
- });
1663
- } else {
1664
- showMainMenu(config, ip);
1665
- }
1666
- });
1667
- break;
1668
-
1669
- case "exit":
1670
- log("");
1671
- log(" " + a.bold + "Bye!" + a.reset + " " + a.dim + "Server is still running in background." + a.reset);
1672
- log(" " + a.dim + "Run " + a.reset + "npx claude-relay" + a.dim + " to come back here." + a.reset);
1673
- log("");
1674
- process.exit(0);
1675
- break;
1676
- }
1677
- }, {
1678
- hint: [
1679
- "Run npx claude-relay in other directories to add more projects.",
1680
- "★ github.com/chadbyte/claude-relay — Press s to star the repo",
1681
- ],
1682
- keys: [
1683
- { key: "o", onKey: function () {
1684
- openUrl(url);
1685
- showMainMenu(config, ip);
1686
- }},
1687
- { key: "s", onKey: function () {
1688
- openUrl("https://github.com/chadbyte/claude-relay");
1689
- showMainMenu(config, ip);
1690
- }},
1691
- ],
1692
- });
1693
- }
1694
- });
1695
- }
1696
-
1697
- // ==============================
1698
- // Projects sub-menu
1699
- // ==============================
1700
- function showProjectsMenu(config, ip) {
1701
- sendIPCCommand(socketPath(), { cmd: "get_status" }).then(function (status) {
1702
- if (!status.ok) {
1703
- log(a.red + "Failed to get status" + a.reset);
1704
- showMainMenu(config, ip);
1705
- return;
1706
- }
1707
-
1708
- console.clear();
1709
- printLogo();
1710
- log("");
1711
- log(sym.pointer + " " + a.bold + "Projects" + a.reset);
1712
- log(sym.bar);
1713
-
1714
- var projs = status.projects || [];
1715
- for (var i = 0; i < projs.length; i++) {
1716
- var p = projs[i];
1717
- var statusIcon = p.isProcessing ? "⚡" : (p.clients > 0 ? "🟢" : "⏸");
1718
- var sessionLabel = p.sessions === 1 ? "1 session" : p.sessions + " sessions";
1719
- var projName = p.title || p.project;
1720
- log(sym.bar + " " + a.bold + projName + a.reset + " " + sessionLabel + " " + statusIcon);
1721
- log(sym.bar + " " + a.dim + p.path + a.reset);
1722
- if (i < projs.length - 1) log(sym.bar);
1723
- }
1724
- log(sym.bar);
1725
-
1726
- // Build menu items
1727
- var items = [];
1728
-
1729
- // Check if cwd is already registered
1730
- var cwdRegistered = false;
1731
- for (var j = 0; j < projs.length; j++) {
1732
- if (projs[j].path === cwd) {
1733
- cwdRegistered = true;
1734
- break;
1735
- }
1736
- }
1737
- if (!cwdRegistered) {
1738
- items.push({ label: "+ Add " + a.bold + path.basename(cwd) + a.reset + " " + a.dim + "(" + cwd + ")" + a.reset, value: "add_cwd" });
1739
- }
1740
- items.push({ label: "+ Add project...", value: "add_other" });
1741
-
1742
- for (var k = 0; k < projs.length; k++) {
1743
- var itemLabel = projs[k].title || projs[k].project;
1744
- items.push({ label: itemLabel, value: "detail:" + projs[k].slug });
1745
- }
1746
- items.push({ label: "Back", value: "back" });
1747
-
1748
- promptSelect("Select", items, function (choice) {
1749
- if (choice === "back") {
1750
- console.clear();
1751
- printLogo();
1752
- log("");
1753
- showMainMenu(config, ip);
1754
- } else if (choice === "add_cwd") {
1755
- sendIPCCommand(socketPath(), { cmd: "add_project", path: cwd }).then(function (res) {
1756
- if (res.ok) {
1757
- log(sym.done + " " + a.green + "Added: " + res.slug + a.reset);
1758
- config = loadConfig() || config;
1759
- } else {
1760
- log(sym.warn + " " + a.yellow + (res.error || "Failed") + a.reset);
1761
- }
1762
- log("");
1763
- showProjectsMenu(config, ip);
1764
- });
1765
- } else if (choice === "add_other") {
1766
- log(sym.bar);
1767
- promptText("Directory path", cwd, function (dirPath) {
1768
- if (dirPath === null) {
1769
- showProjectsMenu(config, ip);
1770
- return;
1771
- }
1772
- var absPath = path.resolve(dirPath);
1773
- try {
1774
- var stat = fs.statSync(absPath);
1775
- if (!stat.isDirectory()) {
1776
- log(sym.warn + " " + a.red + "Not a directory: " + absPath + a.reset);
1777
- setTimeout(function () { showProjectsMenu(config, ip); }, 2000);
1778
- return;
1779
- }
1780
- } catch (e) {
1781
- log(sym.warn + " " + a.red + "Directory not found: " + absPath + a.reset);
1782
- setTimeout(function () { showProjectsMenu(config, ip); }, 2000);
1783
- return;
1784
- }
1785
- var alreadyExists = false;
1786
- for (var pi = 0; pi < projs.length; pi++) {
1787
- if (projs[pi].path === absPath) {
1788
- alreadyExists = true;
1789
- break;
1790
- }
1791
- }
1792
- if (alreadyExists) {
1793
- log(sym.done + " " + a.yellow + "Already added: " + path.basename(absPath) + a.reset + " " + a.dim + "(" + absPath + ")" + a.reset);
1794
- setTimeout(function () { showProjectsMenu(config, ip); }, 2000);
1795
- return;
1796
- }
1797
- sendIPCCommand(socketPath(), { cmd: "add_project", path: absPath }).then(function (res) {
1798
- if (res.ok) {
1799
- log(sym.done + " " + a.green + "Added: " + res.slug + a.reset + " " + a.dim + "(" + absPath + ")" + a.reset);
1800
- config = loadConfig() || config;
1801
- } else {
1802
- log(sym.warn + " " + a.yellow + (res.error || "Failed") + a.reset);
1803
- }
1804
- setTimeout(function () { showProjectsMenu(config, ip); }, 2000);
1805
- });
1806
- });
1807
- } else if (choice.startsWith("detail:")) {
1808
- var detailSlug = choice.substring(7);
1809
- showProjectDetail(config, ip, detailSlug, projs);
1810
- }
1811
- });
1812
- });
1813
- }
1814
-
1815
- // ==============================
1816
- // Project detail
1817
- // ==============================
1818
- function showProjectDetail(config, ip, slug, projects) {
1819
- var proj = null;
1820
- for (var i = 0; i < projects.length; i++) {
1821
- if (projects[i].slug === slug) {
1822
- proj = projects[i];
1823
- break;
1824
- }
1825
- }
1826
- if (!proj) {
1827
- showProjectsMenu(config, ip);
1828
- return;
1829
- }
1830
-
1831
- var displayName = proj.title || proj.project;
1832
-
1833
- console.clear();
1834
- printLogo();
1835
- log("");
1836
- log(sym.pointer + " " + a.bold + displayName + a.reset + " " + a.dim + proj.slug + " · " + proj.path + a.reset);
1837
- log(sym.bar);
1838
- var sessionLabel = proj.sessions === 1 ? "1 session" : proj.sessions + " sessions";
1839
- var clientLabel = proj.clients === 1 ? "1 client" : proj.clients + " clients";
1840
- log(sym.bar + " " + sessionLabel + " · " + clientLabel);
1841
- if (proj.title) {
1842
- log(sym.bar + " " + a.dim + "Title: " + a.reset + proj.title);
1843
- }
1844
- log(sym.bar);
1845
-
1846
- var items = [
1847
- { label: proj.title ? "Change title" : "Set title", value: "title" },
1848
- { label: "Remove project", value: "remove" },
1849
- { label: "Back", value: "back" },
1850
- ];
1851
-
1852
- promptSelect("What would you like to do?", items, function (choice) {
1853
- if (choice === "title") {
1854
- log(sym.bar);
1855
- promptText("Project title", proj.title || proj.project, function (newTitle) {
1856
- if (newTitle === null) {
1857
- showProjectDetail(config, ip, slug, projects);
1858
- return;
1859
- }
1860
- var titleVal = newTitle.trim();
1861
- // If same as directory name, clear custom title
1862
- if (titleVal === proj.project || titleVal === "") {
1863
- titleVal = null;
1864
- }
1865
- sendIPCCommand(socketPath(), { cmd: "set_project_title", slug: slug, title: titleVal }).then(function (res) {
1866
- if (res.ok) {
1867
- proj.title = titleVal;
1868
- config = loadConfig() || config;
1869
- log(sym.done + " " + a.green + "Title updated" + a.reset);
1870
- } else {
1871
- log(sym.warn + " " + a.yellow + (res.error || "Failed") + a.reset);
1872
- }
1873
- log("");
1874
- showProjectDetail(config, ip, slug, projects);
1875
- });
1876
- });
1877
- } else if (choice === "remove") {
1878
- sendIPCCommand(socketPath(), { cmd: "remove_project", slug: slug }).then(function (res) {
1879
- if (res.ok) {
1880
- log(sym.done + " " + a.green + "Removed: " + slug + a.reset);
1881
- config = loadConfig() || config;
1882
- } else {
1883
- log(sym.warn + " " + a.yellow + (res.error || "Failed") + a.reset);
1884
- }
1885
- log("");
1886
- showProjectsMenu(config, ip);
1887
- });
1888
- } else {
1889
- showProjectsMenu(config, ip);
1890
- }
1891
- });
1892
- }
1893
-
1894
- // ==============================
1895
- // Setup guide (2x2 toggle flow)
1896
- // ==============================
1897
- function showSetupGuide(config, ip, goBack) {
1898
- var protocol = config.tls ? "https" : "http";
1899
- var wantRemote = false;
1900
- var wantPush = false;
1901
-
1902
- console.clear();
1903
- printLogo();
1904
- log("");
1905
- log(sym.pointer + " " + a.bold + "Setup Notifications" + a.reset);
1906
- log(sym.bar);
1907
-
1908
- function redraw(renderFn) {
1909
- console.clear();
1910
- printLogo();
1911
- log("");
1912
- log(sym.pointer + " " + a.bold + "Setup Notifications" + a.reset);
1913
- log(sym.bar);
1914
- if (wantRemote) log(sym.done + " Access from outside your network? " + a.dim + "·" + a.reset + " " + a.green + "Yes" + a.reset);
1915
- else log(sym.done + " Access from outside your network? " + a.dim + "· No" + a.reset);
1916
- log(sym.bar);
1917
- if (wantPush) log(sym.done + " Want push notifications? " + a.dim + "·" + a.reset + " " + a.green + "Yes" + a.reset);
1918
- else log(sym.done + " Want push notifications? " + a.dim + "· No" + a.reset);
1919
- log(sym.bar);
1920
- renderFn();
1921
- }
1922
-
1923
- promptToggle("Access from outside your network?", "Requires Tailscale on both devices", false, function (remote) {
1924
- wantRemote = remote;
1925
- log(sym.bar);
1926
- promptToggle("Want push notifications?", "Requires HTTPS (mkcert certificate)", false, function (push) {
1927
- wantPush = push;
1928
- log(sym.bar);
1929
- afterToggles();
1930
- });
1931
- });
1932
-
1933
- function afterToggles() {
1934
- if (!wantRemote && !wantPush) {
1935
- log(sym.done + " " + a.green + "All set!" + a.reset + a.dim + " · No additional setup needed." + a.reset);
1936
- log(sym.end);
1937
- log("");
1938
- promptSelect("Back?", [{ label: "Back", value: "back" }], function () {
1939
- goBack();
1940
- });
1941
- return;
1942
- }
1943
- if (wantRemote) {
1944
- renderTailscale();
1945
- } else {
1946
- renderHttps();
1947
- }
1948
- }
1949
-
1950
- function renderTailscale() {
1951
- var tsIP = getTailscaleIP();
1952
-
1953
- log(sym.pointer + " " + a.bold + "Tailscale Setup" + a.reset);
1954
- if (tsIP) {
1955
- log(sym.bar + " " + a.green + "Tailscale is running" + a.reset + a.dim + " · " + tsIP + a.reset);
1956
- log(sym.bar);
1957
- log(sym.bar + " On your phone/tablet:");
1958
- log(sym.bar + " " + a.dim + "1. Install Tailscale (App Store / Google Play)" + a.reset);
1959
- log(sym.bar + " " + a.dim + "2. Sign in with the same account" + a.reset);
1960
- log(sym.bar);
1961
- renderHttps();
1962
- } else {
1963
- log(sym.bar + " " + a.yellow + "Tailscale not found on this machine." + a.reset);
1964
- log(sym.bar + " " + a.dim + "Install: " + a.reset + "https://tailscale.com/download");
1965
- log(sym.bar + " " + a.dim + "Then run: " + a.reset + "tailscale up");
1966
- log(sym.bar);
1967
- log(sym.bar + " On your phone/tablet:");
1968
- log(sym.bar + " " + a.dim + "1. Install Tailscale (App Store / Google Play)" + a.reset);
1969
- log(sym.bar + " " + a.dim + "2. Sign in with the same account" + a.reset);
1970
- log(sym.bar);
1971
- promptSelect("Select", [
1972
- { label: "Re-check", value: "recheck" },
1973
- { label: "Back", value: "back" },
1974
- ], function (choice) {
1975
- if (choice === "recheck") {
1976
- redraw(renderTailscale);
1977
- } else {
1978
- goBack();
1979
- }
1980
- });
1981
- }
1982
- }
1983
-
1984
- function renderHttps() {
1985
- if (!wantPush) {
1986
- showSetupQR();
1987
- return;
1988
- }
1989
-
1990
- var mcReady = hasMkcert();
1991
- log(sym.pointer + " " + a.bold + "HTTPS Setup (for push notifications)" + a.reset);
1992
- if (mcReady) {
1993
- log(sym.bar + " " + a.green + "mkcert is installed" + a.reset);
1994
- if (!config.tls) {
1995
- log(sym.bar + " " + a.dim + "Restarting server with HTTPS..." + a.reset);
1996
- restartDaemonWithTLS(config, function (newConfig) {
1997
- config = newConfig;
1998
- log(sym.bar);
1999
- showSetupQR();
2000
- });
2001
- return;
2002
- }
2003
- log(sym.bar);
2004
- showSetupQR();
2005
- } else {
2006
- log(sym.bar + " " + a.yellow + "mkcert not found." + a.reset);
2007
- var mkcertHint = process.platform === "win32"
2008
- ? "choco install mkcert && mkcert -install"
2009
- : process.platform === "darwin"
2010
- ? "brew install mkcert && mkcert -install"
2011
- : "apt install mkcert && mkcert -install";
2012
- log(sym.bar + " " + a.dim + "Install: " + a.reset + mkcertHint);
2013
- log(sym.bar);
2014
- promptSelect("Select", [
2015
- { label: "Re-check", value: "recheck" },
2016
- { label: "Back", value: "back" },
2017
- ], function (choice) {
2018
- if (choice === "recheck") {
2019
- redraw(renderHttps);
2020
- } else {
2021
- goBack();
2022
- }
2023
- });
2024
- }
2025
- }
2026
-
2027
- function showSetupQR() {
2028
- var tsIP = getTailscaleIP();
2029
- var lanIP = null;
2030
- if (!wantRemote) {
2031
- var allIPs = getAllIPs();
2032
- for (var j = 0; j < allIPs.length; j++) {
2033
- if (!allIPs[j].startsWith("100.")) { lanIP = allIPs[j]; break; }
2034
- }
2035
- }
2036
- var setupIP = wantRemote ? (tsIP || ip) : (lanIP || ip);
2037
- var setupQuery = wantRemote ? "" : "?mode=lan";
2038
- // Always use HTTP onboarding URL for QR/setup when TLS is active
2039
- var setupUrl = config.tls
2040
- ? "http://" + setupIP + ":" + (config.port + 1) + "/setup" + setupQuery
2041
- : "http://" + setupIP + ":" + config.port + "/setup" + setupQuery;
2042
- log(sym.pointer + " " + a.bold + "Continue on your device" + a.reset);
2043
- log(sym.bar + " " + a.dim + "Scan the QR code or open:" + a.reset);
2044
- log(sym.bar + " " + a.bold + setupUrl + a.reset);
2045
- log(sym.bar);
2046
- qrcode.generate(setupUrl, { small: !isBasicTerm }, function (code) {
2047
- var lines = code.split("\n").map(function (l) { return " " + sym.bar + " " + l; }).join("\n");
2048
- console.log(lines);
2049
- log(sym.bar);
2050
- if (wantRemote) {
2051
- log(sym.bar + " " + a.dim + "Can't connect? Make sure Tailscale is installed on your phone too." + a.reset);
2052
- } else {
2053
- log(sym.bar + " " + a.dim + "Can't connect? Your phone must be on the same Wi-Fi network." + a.reset);
2054
- }
2055
- log(sym.bar);
2056
- log(sym.done + " " + a.dim + "Setup complete." + a.reset);
2057
- log(sym.end);
2058
- log("");
2059
- promptSelect("Back?", [{ label: "Back", value: "back" }], function () {
2060
- goBack();
2061
- });
2062
- });
2063
- }
2064
- }
2065
-
2066
- // ==============================
2067
- // Settings sub-menu
2068
- // ==============================
2069
- function showSettingsMenu(config, ip) {
2070
- sendIPCCommand(socketPath(), { cmd: "get_status" }).then(function (status) {
2071
- var isAwake = status && status.keepAwake;
2072
-
2073
- console.clear();
2074
- printLogo();
2075
- log("");
2076
- log(sym.pointer + " " + a.bold + "Settings" + a.reset);
2077
- log(sym.bar);
2078
-
2079
- // Detect current state
2080
- var tsIP = getTailscaleIP();
2081
- var tsOk = tsIP !== null;
2082
- var mcOk = hasMkcert();
2083
-
2084
- var tsStatus = tsOk
2085
- ? a.green + "Connected" + a.reset + a.dim + " · " + tsIP + a.reset
2086
- : a.dim + "Not detected" + a.reset;
2087
- var mcStatus = mcOk
2088
- ? a.green + "Installed" + a.reset
2089
- : a.dim + "Not found" + a.reset;
2090
- var tlsStatus = config.tls
2091
- ? a.green + "Enabled" + a.reset
2092
- : a.dim + "Disabled" + a.reset;
2093
- var pinStatus = config.pinHash
2094
- ? a.green + "Enabled" + a.reset
2095
- : a.dim + "Off" + a.reset;
2096
- var awakeStatus = isAwake
2097
- ? a.green + "On" + a.reset
2098
- : a.dim + "Off" + a.reset;
2099
-
2100
- log(sym.bar + " Tailscale " + tsStatus);
2101
- log(sym.bar + " mkcert " + mcStatus);
2102
- log(sym.bar + " HTTPS " + tlsStatus);
2103
- log(sym.bar + " PIN " + pinStatus);
2104
- if (process.platform === "darwin") {
2105
- log(sym.bar + " Keep awake " + awakeStatus);
2106
- }
2107
- log(sym.bar);
2108
-
2109
- // Build items
2110
- var items = [
2111
- { label: "Setup notifications", value: "guide" },
2112
- ];
2113
-
2114
- if (config.pinHash) {
2115
- items.push({ label: "Change PIN", value: "pin" });
2116
- items.push({ label: "Remove PIN", value: "remove_pin" });
2117
- } else {
2118
- items.push({ label: "Set PIN", value: "pin" });
2119
- }
2120
- if (process.platform === "darwin") {
2121
- items.push({ label: isAwake ? "Disable keep awake" : "Enable keep awake", value: "awake" });
2122
- }
2123
- items.push({ label: "View logs", value: "logs" });
2124
- items.push({ label: "Back", value: "back" });
2125
-
2126
- promptSelect("Select", items, function (choice) {
2127
- switch (choice) {
2128
- case "guide":
2129
- showSetupGuide(config, ip, function () {
2130
- config = loadConfig() || config;
2131
- showSettingsMenu(config, ip);
2132
- });
2133
- break;
2134
-
2135
- case "pin":
2136
- log(sym.bar);
2137
- promptPin(function (pin) {
2138
- if (pin) {
2139
- var hash = generateAuthToken(pin);
2140
- sendIPCCommand(socketPath(), { cmd: "set_pin", pinHash: hash }).then(function () {
2141
- config.pinHash = hash;
2142
- log(sym.done + " " + a.green + "PIN updated" + a.reset);
2143
- log("");
2144
- showSettingsMenu(config, ip);
2145
- });
2146
- } else {
2147
- showSettingsMenu(config, ip);
2148
- }
2149
- });
2150
- break;
2151
-
2152
- case "remove_pin":
2153
- sendIPCCommand(socketPath(), { cmd: "set_pin", pinHash: null }).then(function () {
2154
- config.pinHash = null;
2155
- log(sym.done + " " + a.dim + "PIN removed" + a.reset);
2156
- log("");
2157
- showSettingsMenu(config, ip);
2158
- });
2159
- break;
2160
-
2161
- case "logs":
2162
- console.clear();
2163
- log(a.bold + "Daemon logs" + a.reset + " " + a.dim + "(" + logPath() + ")" + a.reset);
2164
- log("");
2165
- try {
2166
- var logContent = fs.readFileSync(logPath(), "utf8");
2167
- var logLines = logContent.split("\n").slice(-30);
2168
- for (var li = 0; li < logLines.length; li++) {
2169
- log(a.dim + logLines[li] + a.reset);
2170
- }
2171
- } catch (e) {
2172
- log(a.dim + "(empty)" + a.reset);
2173
- }
2174
- log("");
2175
- promptSelect("Back?", [{ label: "Back", value: "back" }], function () {
2176
- showSettingsMenu(config, ip);
2177
- });
2178
- break;
2179
-
2180
- case "awake":
2181
- sendIPCCommand(socketPath(), { cmd: "set_keep_awake", value: !isAwake }).then(function (res) {
2182
- if (res.ok) {
2183
- config.keepAwake = !isAwake;
2184
- }
2185
- showSettingsMenu(config, ip);
2186
- });
2187
- break;
2188
-
2189
- case "back":
2190
- showMainMenu(config, ip);
2191
- break;
2192
- }
2193
- });
2194
- });
2195
- }
2196
-
2197
- // ==============================
2198
- // Main entry: daemon alive?
2199
- // ==============================
2200
- var { checkAndUpdate } = require("../lib/updater");
2201
- var currentVersion = require("../package.json").version;
2202
-
2203
- (async function () {
2204
- var updated = await checkAndUpdate(currentVersion, skipUpdate);
2205
- if (updated) return;
2206
-
2207
- // Dev mode — foreground daemon with file watching
2208
- if (_isDev) {
2209
- var devConfig = loadConfig();
2210
- var devAlive = devConfig ? await isDaemonAliveAsync(devConfig) : false;
2211
- if (devAlive) {
2212
- console.log("\x1b[36m[dev]\x1b[0m Shutting down existing daemon...");
2213
- await sendIPCCommand(socketPath(), { cmd: "shutdown" });
2214
- clearStaleConfig();
2215
- await new Promise(function (resolve) { setTimeout(resolve, 500); });
2216
- }
2217
- // First run — go through setup (disclaimer, port, PIN, etc.)
2218
- if (!devConfig) {
2219
- setup(function (pin, keepAwake) {
2220
- devMode(pin, keepAwake, null);
2221
- });
2222
- } else {
2223
- // Reuse existing PIN hash from previous config
2224
- await devMode(cliPin || null, devConfig.keepAwake || false, devConfig.pinHash || null);
2225
- }
2226
- return;
2227
- }
2228
-
2229
- var config = loadConfig();
2230
- var alive = config ? await isDaemonAliveAsync(config) : false;
2231
-
2232
- if (!alive && config && config.pid) {
2233
- // Stale config
2234
- clearStaleConfig();
2235
- config = null;
2236
- }
2237
-
2238
- if (alive) {
2239
- // Headless mode — daemon already running, just report and exit
2240
- if (headlessMode) {
2241
- var protocol = config.tls ? "https" : "http";
2242
- var ip = getLocalIP();
2243
- var url = protocol + "://" + ip + ":" + config.port;
2244
- console.log(" " + sym.done + " Daemon already running (PID " + config.pid + ")");
2245
- console.log(" " + sym.done + " " + url);
2246
- process.exit(0);
2247
- return;
2248
- }
2249
-
2250
- // Daemon is running — auto-add cwd if needed, then show menu
2251
- var ip = getLocalIP();
2252
-
2253
- var status = await sendIPCCommand(socketPath(), { cmd: "get_status" });
2254
- if (!status.ok) {
2255
- log(a.red + "Daemon not responding" + a.reset);
2256
- clearStaleConfig();
2257
- process.exit(1);
2258
- return;
2259
- }
2260
-
2261
- // Check if cwd needs to be added
2262
- var projs = status.projects || [];
2263
- var cwdRegistered = false;
2264
- for (var j = 0; j < projs.length; j++) {
2265
- if (projs[j].path === cwd) {
2266
- cwdRegistered = true;
2267
- break;
2268
- }
2269
- }
2270
-
2271
- if (!cwdRegistered) {
2272
- var slug = path.basename(cwd).toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "project";
2273
- console.clear();
2274
- printLogo();
2275
- log("");
2276
- log(sym.pointer + " " + a.bold + "Add this project?" + a.reset);
2277
- log(sym.bar);
2278
- log(sym.bar + " " + a.dim + cwd + a.reset);
2279
- log(sym.bar);
2280
- promptSelect("Add " + a.green + slug + a.reset + " to relay?", [
2281
- { label: "Yes", value: "yes" },
2282
- { label: "No", value: "no" },
2283
- ], function (answer) {
2284
- if (answer === "yes") {
2285
- sendIPCCommand(socketPath(), { cmd: "add_project", path: cwd }).then(function (res) {
2286
- if (res.ok) {
2287
- config = loadConfig() || config;
2288
- log(sym.done + " " + a.green + "Added: " + (res.slug || slug) + a.reset);
2289
- }
2290
- log("");
2291
- showMainMenu(config || { pid: status.pid, port: status.port, tls: status.tls }, ip);
2292
- });
2293
- } else {
2294
- showMainMenu(config || { pid: status.pid, port: status.port, tls: status.tls }, ip);
2295
- }
2296
- });
2297
- } else {
2298
- showMainMenu(config || { pid: status.pid, port: status.port, tls: status.tls }, ip);
2299
- }
2300
- } else {
2301
- // No daemon running — first-time setup
2302
- if (autoYes) {
2303
- var pin = cliPin || null;
2304
- if (dangerouslySkipPermissions && !pin) {
2305
- console.error(" " + sym.warn + " " + a.red + "--dangerously-skip-permissions requires --pin <pin>" + a.reset);
2306
- process.exit(1);
2307
- return;
2308
- }
2309
- console.log(" " + sym.done + " Auto-accepted disclaimer");
2310
- console.log(" " + sym.done + " PIN: " + (pin ? "Enabled" : "Skipped"));
2311
- if (dangerouslySkipPermissions) {
2312
- console.log(" " + sym.warn + " " + a.yellow + "Skip permissions mode enabled" + a.reset);
2313
- }
2314
- var autoRc = loadClayrc();
2315
- var autoRestorable = (autoRc.recentProjects || []).filter(function (p) {
2316
- return p.path !== cwd && fs.existsSync(p.path);
2317
- });
2318
- if (autoRestorable.length > 0) {
2319
- console.log(" " + sym.done + " Restoring " + autoRestorable.length + " previous project(s)");
2320
- }
2321
- var hasRestorable = autoRestorable.length > 0;
2322
- await forkDaemon(pin, false, hasRestorable ? autoRestorable : undefined, !hasRestorable);
2323
- } else {
2324
- setup(function (pin, keepAwake) {
2325
- if (dangerouslySkipPermissions && !pin) {
2326
- log(sym.warn + " " + a.red + "--dangerously-skip-permissions requires a PIN." + a.reset);
2327
- log(a.dim + " Please set a PIN to use skip permissions mode." + a.reset);
2328
- process.exit(1);
2329
- return;
2330
- }
2331
-
2332
- // Check ~/.clayrc for previous projects to restore
2333
- var rc = loadClayrc();
2334
- var restorable = (rc.recentProjects || []).filter(function (p) {
2335
- return p.path !== cwd && fs.existsSync(p.path);
2336
- });
2337
-
2338
- if (restorable.length > 0) {
2339
- promptRestoreProjects(restorable, function (selected) {
2340
- forkDaemon(pin, keepAwake, selected, false);
2341
- });
2342
- } else {
2343
- log(sym.bar);
2344
- log(sym.end + " " + a.dim + "Starting relay..." + a.reset);
2345
- log("");
2346
- forkDaemon(pin, keepAwake, undefined, true);
2347
- }
2348
- });
2349
- }
2350
- }
2351
- })();
2
+ require("clay-server/bin/cli.js");