clay-server 2.16.0 → 2.17.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  [![npm version](https://img.shields.io/npm/v/clay-server)](https://www.npmjs.com/package/clay-server) [![npm downloads](https://img.shields.io/npm/dw/clay-server)](https://www.npmjs.com/package/clay-server) [![GitHub stars](https://img.shields.io/github/stars/chadbyte/clay)](https://github.com/chadbyte/clay) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/chadbyte/clay/blob/main/LICENSE)
10
10
 
11
- Clay gives Claude Code a browser UI that runs on any device. Use it from your phone, run it on macOS, Windows, or Linux. Invite teammates, manage multiple projects from one sidebar, and get push notifications when Claude needs you. Built on the official Claude Agent SDK, not a terminal parser. Your machine is the server. No cloud relay in between, no extra network surface.
11
+ Clay gives Claude Code a browser UI that runs on any device. Use it from your phone, run it on macOS, Windows, or Linux. Invite teammates, manage multiple projects from one sidebar, and get push notifications when Claude needs you. HTTPS and push notifications work out of the box with zero config. Built on the official Claude Agent SDK, not a terminal parser. Your machine is the server. No cloud relay in between, no extra network surface.
12
12
 
13
13
  ---
14
14
 
@@ -98,7 +98,7 @@ Take it further with Ralph Loop, an autonomous coding loop built into Clay. The
98
98
 
99
99
  Your data flows directly from your machine to the Anthropic API, exactly as it does when you use the CLI. Clay adds a browser layer on top, not a middleman.
100
100
 
101
- PIN authentication, per-project/session permissions, and HTTPS are supported by default. For local network use, this is sufficient. For remote access, we recommend a VPN like Tailscale.
101
+ HTTPS is enabled by default with a builtin certificate. PIN authentication and per-project/session permissions are built in. For local network use, this is sufficient. For remote access, we recommend a VPN like Tailscale.
102
102
 
103
103
  ---
104
104
 
@@ -166,20 +166,32 @@ Yes. Create as many as you need. A code reviewer, a writing partner, a project m
166
166
 
167
167
  ---
168
168
 
169
- ## HTTPS for Push
169
+ ## HTTPS
170
170
 
171
- Everything works out of the box. Only push notifications require HTTPS.
171
+ HTTPS is enabled by default using a builtin wildcard certificate for `*.d.clay.studio`. No setup required. Available from `v2.17.0-beta.2`. Your browser connects to a URL like:
172
172
 
173
- Set it up once with [mkcert](https://github.com/FiloSottile/mkcert):
173
+ ```
174
+ https://192-168-1-50.d.clay.studio:2633
175
+ ```
176
+
177
+ The domain resolves to your local IP. All traffic stays on your network. See [clay-dns](clay-dns/) for details on how this works.
178
+
179
+ Push notifications require HTTPS, so they work out of the box with this setup. Install Clay as a PWA on your device to receive them.
180
+
181
+ <details>
182
+ <summary><strong>Alternative: local certificate with mkcert</strong></summary>
183
+
184
+ If you prefer to use a locally generated certificate (e.g. air-gapped environments where DNS is unavailable):
174
185
 
175
186
  ```bash
176
187
  brew install mkcert
177
188
  mkcert -install
189
+ npx clay-server --local-cert
178
190
  ```
179
191
 
180
- Certificates are auto-generated. The setup wizard handles the rest.
192
+ This generates a self-signed certificate trusted by your machine. The setup wizard will guide you through installing the CA on other devices.
181
193
 
182
- If push registration fails: check that your browser trusts the HTTPS certificate and that your phone can reach the server address.
194
+ </details>
183
195
 
184
196
  ---
185
197
 
@@ -192,6 +204,7 @@ npx clay-server --yes # Skip interactive prompts (use defaults)
192
204
  npx clay-server -y --pin 123456
193
205
  # Non-interactive + PIN (for scripts/CI)
194
206
  npx clay-server --no-https # Disable HTTPS
207
+ npx clay-server --local-cert # Use local certificate (mkcert) instead of builtin
195
208
  npx clay-server --no-update # Skip update check
196
209
  npx clay-server --debug # Enable debug panel
197
210
  npx clay-server --add . # Add current directory to running daemon
package/bin/cli.js CHANGED
@@ -52,6 +52,8 @@ function openUrl(url) {
52
52
  var args = process.argv.slice(2);
53
53
  var port = _isDev ? 2635 : 2633;
54
54
  var useHttps = true;
55
+ var forceMkcert = false;
56
+ var forceBuiltin = false;
55
57
  var skipUpdate = false;
56
58
  var debugMode = false;
57
59
  var autoYes = false;
@@ -81,6 +83,10 @@ for (var i = 0; i < args.length; i++) {
81
83
  i++;
82
84
  } else if (args[i] === "--no-https") {
83
85
  useHttps = false;
86
+ } else if (args[i] === "--local-cert") {
87
+ forceMkcert = true;
88
+ } else if (args[i] === "--builtin-cert") {
89
+ forceBuiltin = true;
84
90
  } else if (args[i] === "--no-update" || args[i] === "--skip-update") {
85
91
  skipUpdate = true;
86
92
  } else if (args[i] === "--dev") {
@@ -124,7 +130,9 @@ for (var i = 0; i < args.length; i++) {
124
130
  console.log("Options:");
125
131
  console.log(" -p, --port <port> Port to listen on (default: 2633)");
126
132
  console.log(" --host <address> Address to bind to (default: 0.0.0.0)");
127
- console.log(" --no-https Disable HTTPS (enabled by default via mkcert)");
133
+ console.log(" --no-https Disable HTTPS (enabled by default)");
134
+ console.log(" --local-cert Use local certificate (mkcert), suppress migration notice");
135
+ console.log(" --builtin-cert Use builtin certificate even if mkcert is installed");
128
136
  console.log(" --no-update Skip auto-update check on startup");
129
137
  console.log(" --debug Enable debug panel in the web UI");
130
138
  console.log(" -y, --yes Skip interactive prompts (accept defaults)");
@@ -564,7 +572,44 @@ function getAllIPs() {
564
572
  return ips;
565
573
  }
566
574
 
575
+ function getBuiltinCert() {
576
+ try {
577
+ var certDir = path.join(__dirname, "..", "lib", "certs");
578
+ var keyPath = path.join(certDir, "privkey.pem");
579
+ var certPath = path.join(certDir, "fullchain.pem");
580
+ if (!fs.existsSync(keyPath) || !fs.existsSync(certPath)) return null;
581
+
582
+ // Check expiry
583
+ var certText = execFileSync("openssl", [
584
+ "x509", "-in", certPath, "-noout", "-enddate"
585
+ ], { encoding: "utf8" });
586
+ var m = certText.match(/notAfter=(.+)/);
587
+ if (m) {
588
+ var expiry = new Date(m[1]);
589
+ var now = new Date();
590
+ // Skip if expiring within 7 days
591
+ if (expiry.getTime() - now.getTime() < 7 * 24 * 60 * 60 * 1000) return null;
592
+ }
593
+
594
+ return { key: keyPath, cert: certPath, caRoot: null, builtin: true };
595
+ } catch (e) {
596
+ return null;
597
+ }
598
+ }
599
+
600
+ function toClayStudioUrl(ip, port, protocol) {
601
+ var dashed = ip.replace(/\./g, "-");
602
+ return protocol + "://" + dashed + ".d.clay.studio:" + port;
603
+ }
604
+
567
605
  function ensureCerts(ip) {
606
+ // --builtin-cert: skip mkcert entirely, go straight to builtin
607
+ if (forceBuiltin) {
608
+ var builtin = getBuiltinCert();
609
+ if (builtin) return builtin;
610
+ return null;
611
+ }
612
+
568
613
  var homeDir = os.homedir();
569
614
  var certDir = path.join(process.env.CLAY_HOME || path.join(homeDir, ".clay"), "certs");
570
615
  var keyPath = path.join(certDir, "key.pem");
@@ -579,24 +624,30 @@ function ensureCerts(ip) {
579
624
  fs.copyFileSync(legacyCert, certPath);
580
625
  }
581
626
 
627
+ var mkcertInstalled = hasMkcert();
628
+
582
629
  var caRoot = null;
583
- try {
584
- caRoot = path.join(
585
- execSync("mkcert -CAROOT", { encoding: "utf8" }).trim(),
586
- "rootCA.pem"
587
- );
588
- if (!fs.existsSync(caRoot)) caRoot = null;
589
- } catch (e) {}
630
+ if (mkcertInstalled) {
631
+ try {
632
+ caRoot = path.join(
633
+ execSync("mkcert -CAROOT", { encoding: "utf8" }).trim(),
634
+ "rootCA.pem"
635
+ );
636
+ if (!fs.existsSync(caRoot)) caRoot = null;
637
+ } catch (e) {}
638
+ }
590
639
 
591
640
  // Collect all IPv4 addresses (Tailscale + LAN)
592
641
  var allIPs = getAllIPs();
593
642
 
594
643
  if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
595
644
  var needRegen = false;
645
+ var isMkcertCert = false;
596
646
  try {
597
647
  var certText = execFileSync("openssl", ["x509", "-in", certPath, "-text", "-noout"], { encoding: "utf8" });
598
648
  // If cert is from an external CA (e.g. Tailscale/Let's Encrypt), never regenerate
599
649
  if (certText.indexOf("mkcert") === -1) return { key: keyPath, cert: certPath, caRoot: caRoot };
650
+ isMkcertCert = true;
600
651
  for (var i = 0; i < allIPs.length; i++) {
601
652
  if (certText.indexOf(allIPs[i]) === -1) {
602
653
  needRegen = true;
@@ -604,24 +655,41 @@ function ensureCerts(ip) {
604
655
  }
605
656
  }
606
657
  } catch (e) { needRegen = true; }
607
- if (!needRegen) return { key: keyPath, cert: certPath, caRoot: caRoot };
658
+ // mkcert cert but mkcert uninstalled: CA is gone, cert is untrusted. Skip it.
659
+ if (isMkcertCert && !mkcertInstalled) needRegen = true;
660
+ if (!needRegen) {
661
+ return { key: keyPath, cert: certPath, caRoot: caRoot, mkcertDetected: mkcertInstalled && !forceMkcert };
662
+ }
608
663
  }
609
664
 
610
- fs.mkdirSync(certDir, { recursive: true });
665
+ // mkcert installed: generate local cert (legacy behavior)
666
+ if (mkcertInstalled) {
667
+ fs.mkdirSync(certDir, { recursive: true });
668
+
669
+ var domains = ["localhost", "127.0.0.1", "::1"];
670
+ for (var i = 0; i < allIPs.length; i++) {
671
+ if (domains.indexOf(allIPs[i]) === -1) domains.push(allIPs[i]);
672
+ }
673
+
674
+ try {
675
+ var mkcertArgs = ["-key-file", keyPath, "-cert-file", certPath].concat(domains);
676
+ execFileSync("mkcert", mkcertArgs, { stdio: "pipe" });
677
+ } catch (err) {
678
+ // mkcert generation failed, fall through to builtin
679
+ }
611
680
 
612
- var domains = ["localhost", "127.0.0.1", "::1"];
613
- for (var i = 0; i < allIPs.length; i++) {
614
- if (domains.indexOf(allIPs[i]) === -1) domains.push(allIPs[i]);
681
+ if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
682
+ return { key: keyPath, cert: certPath, caRoot: caRoot, mkcertDetected: !forceMkcert };
683
+ }
615
684
  }
616
685
 
617
- try {
618
- var mkcertArgs = ["-key-file", keyPath, "-cert-file", certPath].concat(domains);
619
- execFileSync("mkcert", mkcertArgs, { stdio: "pipe" });
620
- } catch (err) {
621
- return null;
686
+ // Fallback: builtin cert (unless --local-cert forces mkcert-only)
687
+ if (!forceMkcert) {
688
+ var builtin = getBuiltinCert();
689
+ if (builtin) return builtin;
622
690
  }
623
691
 
624
- return { key: keyPath, cert: certPath, caRoot: caRoot };
692
+ return null;
625
693
  }
626
694
 
627
695
  // --- Logo ---
@@ -1395,11 +1463,15 @@ function setup(callback) {
1395
1463
  async function forkDaemon(mode, keepAwake, extraProjects, addCwd, wantOsUsers) {
1396
1464
  var ip = getLocalIP();
1397
1465
  var hasTls = false;
1466
+ var hasBuiltinCert = false;
1467
+ var mkcertDetected = false;
1398
1468
 
1399
1469
  if (useHttps) {
1400
1470
  var certPaths = ensureCerts(ip);
1401
1471
  if (certPaths) {
1402
1472
  hasTls = true;
1473
+ if (certPaths.builtin) hasBuiltinCert = true;
1474
+ if (certPaths.mkcertDetected) mkcertDetected = true;
1403
1475
  } else {
1404
1476
  log(sym.warn + " " + a.yellow + "HTTPS unavailable" + a.reset + a.dim + " · mkcert not installed" + a.reset);
1405
1477
  }
@@ -1473,6 +1545,8 @@ async function forkDaemon(mode, keepAwake, extraProjects, addCwd, wantOsUsers) {
1473
1545
  host: host,
1474
1546
  pinHash: mode === "multi" && cliPin ? generateAuthToken(cliPin) : null,
1475
1547
  tls: hasTls,
1548
+ builtinCert: hasBuiltinCert,
1549
+ mkcertDetected: mkcertDetected,
1476
1550
  debug: debugMode,
1477
1551
  keepAwake: keepAwake,
1478
1552
  dangerouslySkipPermissions: dangerouslySkipPermissions,
@@ -1547,9 +1621,13 @@ async function forkDaemon(mode, keepAwake, extraProjects, addCwd, wantOsUsers) {
1547
1621
  // Headless mode — print status and exit immediately
1548
1622
  if (headlessMode) {
1549
1623
  var protocol = config.tls ? "https" : "http";
1550
- var url = protocol + "://" + ip + ":" + config.port;
1624
+ var url = config.builtinCert
1625
+ ? toClayStudioUrl(ip, config.port, protocol)
1626
+ : protocol + "://" + ip + ":" + config.port;
1551
1627
  console.log(" " + sym.done + " Daemon started (PID " + config.pid + ")");
1552
1628
  console.log(" " + sym.done + " " + url);
1629
+ if (config.builtinCert) console.log(" " + sym.done + " d.clay.studio provides HTTPS certificates only. Your traffic never leaves your network.");
1630
+ if (config.mkcertDetected) console.log(" " + sym.warn + " Clay now ships with a builtin HTTPS certificate. To use it, pass --builtin-cert or uninstall mkcert.");
1553
1631
  console.log(" " + sym.done + " Headless mode — exiting CLI");
1554
1632
  process.exit(0);
1555
1633
  return;
@@ -1565,10 +1643,16 @@ async function forkDaemon(mode, keepAwake, extraProjects, addCwd, wantOsUsers) {
1565
1643
  async function devMode(mode, keepAwake, existingPinHash) {
1566
1644
  var ip = getLocalIP();
1567
1645
  var hasTls = false;
1646
+ var hasBuiltinCert = false;
1647
+ var mkcertDetected = false;
1568
1648
 
1569
1649
  if (useHttps) {
1570
1650
  var certPaths = ensureCerts(ip);
1571
- if (certPaths) hasTls = true;
1651
+ if (certPaths) {
1652
+ hasTls = true;
1653
+ if (certPaths.builtin) hasBuiltinCert = true;
1654
+ if (certPaths.mkcertDetected) mkcertDetected = true;
1655
+ }
1572
1656
  }
1573
1657
 
1574
1658
  var portFree = await isPortFree(port);
@@ -1630,6 +1714,8 @@ async function devMode(mode, keepAwake, existingPinHash) {
1630
1714
  host: host,
1631
1715
  pinHash: existingPinHash || null,
1632
1716
  tls: hasTls,
1717
+ builtinCert: hasBuiltinCert,
1718
+ mkcertDetected: mkcertDetected,
1633
1719
  debug: true,
1634
1720
  keepAwake: keepAwake || false,
1635
1721
  dangerouslySkipPermissions: dangerouslySkipPermissions,
@@ -1778,6 +1864,8 @@ async function restartDaemonWithTLS(config, callback) {
1778
1864
  callback(config);
1779
1865
  return;
1780
1866
  }
1867
+ var hasBuiltinCert = !!(certPaths && certPaths.builtin);
1868
+ var mkcertDetected = !!(certPaths && certPaths.mkcertDetected);
1781
1869
 
1782
1870
  // Shut down old daemon
1783
1871
  stopDaemonWatcher();
@@ -1801,6 +1889,8 @@ async function restartDaemonWithTLS(config, callback) {
1801
1889
  port: config.port,
1802
1890
  pinHash: config.pinHash || null,
1803
1891
  tls: true,
1892
+ builtinCert: hasBuiltinCert,
1893
+ mkcertDetected: mkcertDetected,
1804
1894
  debug: config.debug || false,
1805
1895
  keepAwake: config.keepAwake || false,
1806
1896
  dangerouslySkipPermissions: config.dangerouslySkipPermissions || false,
@@ -1879,7 +1969,9 @@ function showServerStarted(config, ip) {
1879
1969
  function showMainMenu(config, ip) {
1880
1970
  startDaemonWatcher();
1881
1971
  var protocol = config.tls ? "https" : "http";
1882
- var url = protocol + "://" + ip + ":" + config.port;
1972
+ var url = config.builtinCert
1973
+ ? toClayStudioUrl(ip, config.port, protocol)
1974
+ : protocol + "://" + ip + ":" + config.port;
1883
1975
 
1884
1976
  sendIPCCommand(socketPath(), { cmd: "get_status" }).then(function (status) {
1885
1977
  var projs = (status && status.projects) || [];
@@ -1897,6 +1989,7 @@ function showMainMenu(config, ip) {
1897
1989
  function afterQr() {
1898
1990
  // Status line
1899
1991
  log(" " + a.dim + "clay" + a.reset + " " + a.dim + "v" + currentVersion + a.reset + a.dim + " — " + url + a.reset);
1992
+ if (config.builtinCert) log(" " + a.dim + "d.clay.studio provides HTTPS certificates only. Your traffic never leaves your network." + a.reset);
1900
1993
  var parts = [];
1901
1994
  parts.push(a.bold + projs.length + a.reset + a.dim + (projs.length === 1 ? " project" : " projects"));
1902
1995
  parts.push(a.reset + a.bold + totalSessions + a.reset + a.dim + (totalSessions === 1 ? " session" : " sessions"));
@@ -1907,6 +2000,13 @@ function showMainMenu(config, ip) {
1907
2000
  log(" Press " + a.bold + "o" + a.reset + " to open in browser");
1908
2001
  log("");
1909
2002
 
2003
+ if (config.mkcertDetected) {
2004
+ log(" " + sym.warn + " " + a.yellow + "Clay now ships with a builtin HTTPS certificate." + a.reset);
2005
+ log(" " + a.dim + "No more CA setup on each device." + a.reset);
2006
+ log(" " + a.dim + "To use it, pass --builtin-cert or uninstall mkcert." + a.reset);
2007
+ log("");
2008
+ }
2009
+
1910
2010
  showMenuItems();
1911
2011
  }
1912
2012
 
@@ -1925,7 +2025,6 @@ function showMainMenu(config, ip) {
1925
2025
  function showMenuItems() {
1926
2026
  var items = [
1927
2027
  { label: "Setup notifications", value: "notifications" },
1928
- { label: "Projects", value: "projects" },
1929
2028
  { label: "Settings", value: "settings" },
1930
2029
  { label: "Shut down server", value: "shutdown" },
1931
2030
  { label: "Keep server alive & exit", value: "exit" },
@@ -1940,10 +2039,6 @@ function showMainMenu(config, ip) {
1940
2039
  });
1941
2040
  break;
1942
2041
 
1943
- case "projects":
1944
- showProjectsMenu(config, ip);
1945
- break;
1946
-
1947
2042
  case "settings":
1948
2043
  showSettingsMenu(config, ip);
1949
2044
  break;
@@ -1981,9 +2076,8 @@ function showMainMenu(config, ip) {
1981
2076
  }
1982
2077
  }, {
1983
2078
  hint: [
1984
- "claude-relay has been renamed to clay-server · npx clay-server",
1985
2079
  "Run npx clay-server in other directories to add more projects.",
1986
- "★ github.com/chadbyte/claude-relay — Press s to star the repo",
2080
+ "★ github.com/chadbyte/clay — Press s to star the repo",
1987
2081
  ],
1988
2082
  keys: [
1989
2083
  { key: "o", onKey: function () {
@@ -1991,7 +2085,7 @@ function showMainMenu(config, ip) {
1991
2085
  showMainMenu(config, ip);
1992
2086
  }},
1993
2087
  { key: "s", onKey: function () {
1994
- openUrl("https://github.com/chadbyte/claude-relay");
2088
+ openUrl("https://github.com/chadbyte/clay");
1995
2089
  showMainMenu(config, ip);
1996
2090
  }},
1997
2091
  ],
@@ -2000,203 +2094,6 @@ function showMainMenu(config, ip) {
2000
2094
  });
2001
2095
  }
2002
2096
 
2003
- // ==============================
2004
- // Projects sub-menu
2005
- // ==============================
2006
- function showProjectsMenu(config, ip) {
2007
- sendIPCCommand(socketPath(), { cmd: "get_status" }).then(function (status) {
2008
- if (!status.ok) {
2009
- log(a.red + "Failed to get status" + a.reset);
2010
- showMainMenu(config, ip);
2011
- return;
2012
- }
2013
-
2014
- console.clear();
2015
- printLogo();
2016
- log("");
2017
- log(sym.pointer + " " + a.bold + "Projects" + a.reset);
2018
- log(sym.bar);
2019
-
2020
- var projs = status.projects || [];
2021
- for (var i = 0; i < projs.length; i++) {
2022
- var p = projs[i];
2023
- var statusIcon = p.isProcessing ? "⚡" : (p.clients > 0 ? "🟢" : "⏸");
2024
- var sessionLabel = p.sessions === 1 ? "1 session" : p.sessions + " sessions";
2025
- var projName = p.title || p.project;
2026
- log(sym.bar + " " + a.bold + projName + a.reset + " " + sessionLabel + " " + statusIcon);
2027
- log(sym.bar + " " + a.dim + p.path + a.reset);
2028
- if (i < projs.length - 1) log(sym.bar);
2029
- }
2030
- log(sym.bar);
2031
-
2032
- // Build menu items
2033
- var items = [];
2034
-
2035
- // Check if cwd is already registered
2036
- var cwdRegistered = false;
2037
- for (var j = 0; j < projs.length; j++) {
2038
- if (projs[j].path === cwd) {
2039
- cwdRegistered = true;
2040
- break;
2041
- }
2042
- }
2043
- if (!cwdRegistered) {
2044
- items.push({ label: "+ Add " + a.bold + path.basename(cwd) + a.reset + " " + a.dim + "(" + cwd + ")" + a.reset, value: "add_cwd" });
2045
- }
2046
- items.push({ label: "+ Add project...", value: "add_other" });
2047
-
2048
- for (var k = 0; k < projs.length; k++) {
2049
- var itemLabel = projs[k].title || projs[k].project;
2050
- items.push({ label: itemLabel, value: "detail:" + projs[k].slug });
2051
- }
2052
- items.push({ label: "Back", value: "back" });
2053
-
2054
- promptSelect("Select", items, function (choice) {
2055
- if (choice === "back") {
2056
- console.clear();
2057
- printLogo();
2058
- log("");
2059
- showMainMenu(config, ip);
2060
- } else if (choice === "add_cwd") {
2061
- sendIPCCommand(socketPath(), { cmd: "add_project", path: cwd }).then(function (res) {
2062
- if (res.ok) {
2063
- log(sym.done + " " + a.green + "Added: " + res.slug + a.reset);
2064
- config = loadConfig() || config;
2065
- } else {
2066
- log(sym.warn + " " + a.yellow + (res.error || "Failed") + a.reset);
2067
- }
2068
- log("");
2069
- showProjectsMenu(config, ip);
2070
- });
2071
- } else if (choice === "add_other") {
2072
- log(sym.bar);
2073
- promptText("Directory path", cwd, function (dirPath) {
2074
- if (dirPath === null) {
2075
- showProjectsMenu(config, ip);
2076
- return;
2077
- }
2078
- var absPath = path.resolve(dirPath);
2079
- try {
2080
- var stat = fs.statSync(absPath);
2081
- if (!stat.isDirectory()) {
2082
- log(sym.warn + " " + a.red + "Not a directory: " + absPath + a.reset);
2083
- setTimeout(function () { showProjectsMenu(config, ip); }, 2000);
2084
- return;
2085
- }
2086
- } catch (e) {
2087
- log(sym.warn + " " + a.red + "Directory not found: " + absPath + a.reset);
2088
- setTimeout(function () { showProjectsMenu(config, ip); }, 2000);
2089
- return;
2090
- }
2091
- var alreadyExists = false;
2092
- for (var pi = 0; pi < projs.length; pi++) {
2093
- if (projs[pi].path === absPath) {
2094
- alreadyExists = true;
2095
- break;
2096
- }
2097
- }
2098
- if (alreadyExists) {
2099
- log(sym.done + " " + a.yellow + "Already added: " + path.basename(absPath) + a.reset + " " + a.dim + "(" + absPath + ")" + a.reset);
2100
- setTimeout(function () { showProjectsMenu(config, ip); }, 2000);
2101
- return;
2102
- }
2103
- sendIPCCommand(socketPath(), { cmd: "add_project", path: absPath }).then(function (res) {
2104
- if (res.ok) {
2105
- log(sym.done + " " + a.green + "Added: " + res.slug + a.reset + " " + a.dim + "(" + absPath + ")" + a.reset);
2106
- config = loadConfig() || config;
2107
- } else {
2108
- log(sym.warn + " " + a.yellow + (res.error || "Failed") + a.reset);
2109
- }
2110
- setTimeout(function () { showProjectsMenu(config, ip); }, 2000);
2111
- });
2112
- });
2113
- } else if (choice.startsWith("detail:")) {
2114
- var detailSlug = choice.substring(7);
2115
- showProjectDetail(config, ip, detailSlug, projs);
2116
- }
2117
- });
2118
- });
2119
- }
2120
-
2121
- // ==============================
2122
- // Project detail
2123
- // ==============================
2124
- function showProjectDetail(config, ip, slug, projects) {
2125
- var proj = null;
2126
- for (var i = 0; i < projects.length; i++) {
2127
- if (projects[i].slug === slug) {
2128
- proj = projects[i];
2129
- break;
2130
- }
2131
- }
2132
- if (!proj) {
2133
- showProjectsMenu(config, ip);
2134
- return;
2135
- }
2136
-
2137
- var displayName = proj.title || proj.project;
2138
-
2139
- console.clear();
2140
- printLogo();
2141
- log("");
2142
- log(sym.pointer + " " + a.bold + displayName + a.reset + " " + a.dim + proj.slug + " · " + proj.path + a.reset);
2143
- log(sym.bar);
2144
- var sessionLabel = proj.sessions === 1 ? "1 session" : proj.sessions + " sessions";
2145
- var clientLabel = proj.clients === 1 ? "1 client" : proj.clients + " clients";
2146
- log(sym.bar + " " + sessionLabel + " · " + clientLabel);
2147
- if (proj.title) {
2148
- log(sym.bar + " " + a.dim + "Title: " + a.reset + proj.title);
2149
- }
2150
- log(sym.bar);
2151
-
2152
- var items = [
2153
- { label: proj.title ? "Change title" : "Set title", value: "title" },
2154
- { label: "Remove project", value: "remove" },
2155
- { label: "Back", value: "back" },
2156
- ];
2157
-
2158
- promptSelect("What would you like to do?", items, function (choice) {
2159
- if (choice === "title") {
2160
- log(sym.bar);
2161
- promptText("Project title", proj.title || proj.project, function (newTitle) {
2162
- if (newTitle === null) {
2163
- showProjectDetail(config, ip, slug, projects);
2164
- return;
2165
- }
2166
- var titleVal = newTitle.trim();
2167
- // If same as directory name, clear custom title
2168
- if (titleVal === proj.project || titleVal === "") {
2169
- titleVal = null;
2170
- }
2171
- sendIPCCommand(socketPath(), { cmd: "set_project_title", slug: slug, title: titleVal }).then(function (res) {
2172
- if (res.ok) {
2173
- proj.title = titleVal;
2174
- config = loadConfig() || config;
2175
- log(sym.done + " " + a.green + "Title updated" + a.reset);
2176
- } else {
2177
- log(sym.warn + " " + a.yellow + (res.error || "Failed") + a.reset);
2178
- }
2179
- log("");
2180
- showProjectDetail(config, ip, slug, projects);
2181
- });
2182
- });
2183
- } else if (choice === "remove") {
2184
- sendIPCCommand(socketPath(), { cmd: "remove_project", slug: slug }).then(function (res) {
2185
- if (res.ok) {
2186
- log(sym.done + " " + a.green + "Removed: " + slug + a.reset);
2187
- config = loadConfig() || config;
2188
- } else {
2189
- log(sym.warn + " " + a.yellow + (res.error || "Failed") + a.reset);
2190
- }
2191
- log("");
2192
- showProjectsMenu(config, ip);
2193
- });
2194
- } else {
2195
- showProjectsMenu(config, ip);
2196
- }
2197
- });
2198
- }
2199
-
2200
2097
  // ==============================
2201
2098
  // Setup guide (2x2 toggle flow)
2202
2099
  // ==============================
@@ -2229,7 +2126,7 @@ function showSetupGuide(config, ip, goBack) {
2229
2126
  promptToggle("Access from outside your network?", "Requires Tailscale on both devices", false, function (remote) {
2230
2127
  wantRemote = remote;
2231
2128
  log(sym.bar);
2232
- promptToggle("Want push notifications?", "Requires HTTPS (mkcert certificate)", false, function (push) {
2129
+ promptToggle("Want push notifications?", "Requires HTTPS", false, function (push) {
2233
2130
  wantPush = push;
2234
2131
  log(sym.bar);
2235
2132
  afterToggles();
@@ -2293,6 +2190,15 @@ function showSetupGuide(config, ip, goBack) {
2293
2190
  return;
2294
2191
  }
2295
2192
 
2193
+ // Builtin cert: HTTPS already active, skip mkcert flow entirely
2194
+ if (config.builtinCert) {
2195
+ log(sym.pointer + " " + a.bold + "HTTPS" + a.reset + a.dim + " · Enabled (builtin certificate)" + a.reset);
2196
+ log(sym.bar);
2197
+ showSetupQR();
2198
+ return;
2199
+ }
2200
+
2201
+ // mkcert flow (--mkcert or fallback)
2296
2202
  var mcReady = hasMkcert();
2297
2203
  log(sym.pointer + " " + a.bold + "HTTPS Setup (for push notifications)" + a.reset);
2298
2204
  if (mcReady) {
@@ -2341,10 +2247,16 @@ function showSetupGuide(config, ip, goBack) {
2341
2247
  }
2342
2248
  var setupIP = wantRemote ? (tsIP || ip) : (lanIP || ip);
2343
2249
  var setupQuery = wantRemote ? "" : "?mode=lan";
2344
- // Always use HTTP onboarding URL for QR/setup when TLS is active
2345
- var setupUrl = config.tls
2346
- ? "http://" + setupIP + ":" + (config.port + 1) + "/setup" + setupQuery
2347
- : "http://" + setupIP + ":" + config.port + "/setup" + setupQuery;
2250
+ // Builtin cert: link directly to the app with push notification guide
2251
+ // mkcert: use HTTP onboarding server for CA install flow
2252
+ var setupUrl;
2253
+ if (config.builtinCert) {
2254
+ setupUrl = toClayStudioUrl(setupIP, config.port, "https") + "/pwa";
2255
+ } else if (config.tls) {
2256
+ setupUrl = "http://" + setupIP + ":" + (config.port + 1) + "/setup" + setupQuery;
2257
+ } else {
2258
+ setupUrl = "http://" + setupIP + ":" + config.port + "/setup" + setupQuery;
2259
+ }
2348
2260
  log(sym.pointer + " " + a.bold + "Continue on your device" + a.reset);
2349
2261
  log(sym.bar + " " + a.dim + "Scan the QR code or open:" + a.reset);
2350
2262
  log(sym.bar + " " + a.bold + setupUrl + a.reset);
@@ -2435,11 +2347,13 @@ function showSettingsMenu(config, ip) {
2435
2347
  { label: "Setup notifications", value: "guide" },
2436
2348
  ];
2437
2349
 
2438
- if (config.pinHash) {
2439
- items.push({ label: "Change PIN", value: "pin" });
2440
- items.push({ label: "Remove PIN", value: "remove_pin" });
2441
- } else {
2442
- items.push({ label: "Set PIN", value: "pin" });
2350
+ if (!muEnabled) {
2351
+ if (config.pinHash) {
2352
+ items.push({ label: "Change PIN", value: "pin" });
2353
+ items.push({ label: "Remove PIN", value: "remove_pin" });
2354
+ } else {
2355
+ items.push({ label: "Set PIN", value: "pin" });
2356
+ }
2443
2357
  }
2444
2358
  if (muEnabled) {
2445
2359
  items.push({ label: "Disable multi-user mode", value: "disable_multi_user" });
@@ -2759,7 +2673,9 @@ function showSettingsMenu(config, ip) {
2759
2673
  return;
2760
2674
  }
2761
2675
  var protocol = config.tls ? "https" : "http";
2762
- var recoveryUrl = protocol + "://" + ip + ":" + config.port + "/recover/" + recoveryUrlPath;
2676
+ var recoveryUrl = config.builtinCert
2677
+ ? toClayStudioUrl(ip, config.port, protocol) + "/recover/" + recoveryUrlPath
2678
+ : protocol + "://" + ip + ":" + config.port + "/recover/" + recoveryUrlPath;
2763
2679
  log(sym.bar);
2764
2680
  log(sym.bar + " " + a.yellow + sym.warn + " Admin Password Recovery" + a.reset);
2765
2681
  log(sym.bar);
@@ -2849,9 +2765,12 @@ var currentVersion = require("../package.json").version;
2849
2765
  if (headlessMode) {
2850
2766
  var protocol = config.tls ? "https" : "http";
2851
2767
  var ip = getLocalIP();
2852
- var url = protocol + "://" + ip + ":" + config.port;
2768
+ var url = config.builtinCert
2769
+ ? toClayStudioUrl(ip, config.port, protocol)
2770
+ : protocol + "://" + ip + ":" + config.port;
2853
2771
  console.log(" " + sym.done + " Daemon already running (PID " + config.pid + ")");
2854
2772
  console.log(" " + sym.done + " " + url);
2773
+ if (config.builtinCert) console.log(" " + sym.done + " d.clay.studio provides HTTPS certificates only. Your traffic never leaves your network.");
2855
2774
  process.exit(0);
2856
2775
  return;
2857
2776
  }