codebase-onboarder 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
4
4
  [![Node.js](https://img.shields.io/badge/node-%3E%3D20.0.0-brightgreen.svg)](https://nodejs.org/)
5
5
  [![Zero Dependencies](https://img.shields.io/badge/runtime%20dependencies-0-success.svg)](package.json)
6
- [![Tests](https://img.shields.io/badge/tests-551%20passing-brightgreen.svg)](tests/)
6
+ [![Tests](https://img.shields.io/badge/tests-560%20passing-brightgreen.svg)](tests/)
7
7
 
8
8
  > **Drop a path. Get a map.**
9
9
  > A lightweight, zero-dependency codebase visualizer and architectural map generator that runs entirely on your local machine.
@@ -202,12 +202,12 @@ with it instead of orphaning a process holding the scan cache.
202
202
  Onboarder has two modes, one config file, and three ways to edit it — the CLI wizard, CLI flags, and the web UI's Server drawer all write the same validated `config.json` (`~/.config/onboarder/config.json`, mode `0600`).
203
203
 
204
204
  - **Local (default)** — binds to loopback only, asks for no credentials. The safe default.
205
- - **Self-hosted** — reachable on your network or domain; every API call requires a Bearer access key. Rotate it from the drawer or with `onboarder config key rotate`; the old key dies on the next request, no restart needed.
205
+ - **Self-hosted** — a fresh setup binds `0.0.0.0` for direct LAN/VPS access; every API call requires a Bearer access key. Re-running setup preserves an existing loopback tunnel layout. Rotate the key with `onboarder config key rotate`; the old key dies on the next request.
206
206
 
207
207
  ```bash
208
208
  onboarder setup # interactive wizard
209
209
  onboarder setup --mode self-hosted \
210
- --domain map.example.com --non-interactive
210
+ --domain map.example.com --https --start
211
211
  onboarder config show # current settings (key masked)
212
212
  onboarder config set host 0.0.0.0 # direct VPS/LAN access (domain optional)
213
213
  onboarder config set port 4311 # move away from a busy port
@@ -216,14 +216,31 @@ onboarder status # running PID, stopped, or unmanaged po
216
216
  onboarder stop # stop a PID-file-managed instance
217
217
  onboarder restart # graceful stop, then start
218
218
  onboarder tunnel cloudflare # expose via a Cloudflare quick tunnel
219
- onboarder doctor # config, port, reachability, and tunnel checks
219
+ onboarder tunnel tailscale # expose privately over the tailnet
220
+ onboarder https check # DNS, ports 80/443, and Caddy readiness
221
+ onboarder https setup # write/validate Caddyfile, obtain TLS, reload/start Caddy
222
+ onboarder https status # domain, URL, Caddyfile, and Caddy state
223
+ onboarder doctor # config, access key, ports, DNS, TLS, and tunnels
220
224
  ```
221
225
 
222
- Direct self-hosting does not require DNS: `--host 0.0.0.0` accepts visitors at `http://<server-ip>:<port>`. Put a reverse proxy and TLS in front of it for production. Binding to `127.0.0.1` is still the safer default and is appropriate behind Cloudflare or Tailscale.
226
+ A fresh self-hosted setup uses `0.0.0.0`, so a VPS is reachable at `http://<server-ip>:<port>` without a reverse proxy. A domain is optional for direct-IP access. If a domain is entered, setup asks whether to enable automatic HTTPS.
227
+
228
+ For trusted HTTPS, DNS must already point the domain to the VPS and inbound TCP `80` and `443` must be allowed in both the cloud security group/NSG and the host firewall. On Ubuntu:
229
+
230
+ ```bash
231
+ sudo apt update
232
+ sudo apt install caddy
233
+ sudo ufw allow 80/tcp
234
+ sudo ufw allow 443/tcp
235
+ onboarder https check
236
+ onboarder https setup
237
+ ```
238
+
239
+ Onboarder writes a private `Caddyfile` beside `config.json`, validates it, and asks Caddy to obtain and renew the certificate. It never runs `sudo` or installs packages silently. Caddy proxies `https://map.example.com` to `http://127.0.0.1:4310`; Onboarder continues to enforce the access key on every API call. A bare public IP cannot use a normal trusted domain certificate.
223
240
 
224
241
  If startup reports `EADDRINUSE`, run `onboarder status` first. If it identifies an Onboarder PID, use `onboarder stop` or `onboarder restart`; otherwise inspect the unrelated listener with `ss -ltnp` or `lsof -i :4310`, or choose another port. The error names these recovery commands instead of printing only the raw Node error.
225
242
 
226
- Cloudflare quick tunnels and Tailscale are supported as reachability layers — the server keeps its loopback bind and the tunnel dials `127.0.0.1`. `ONBOARDER_CONFIG=/path/config.json` overrides the config location (handy for tests and containers).
243
+ Cloudflare quick tunnels and Tailscale remain supported. They terminate TLS and dial `127.0.0.1`, so an existing loopback setup is preserved. `ONBOARDER_CONFIG=/path/config.json` overrides the config location for tests and containers.
227
244
 
228
245
  ---
229
246
 
@@ -240,6 +257,7 @@ codebase-onboarder/
240
257
  │ ├── router.js # Route table, live per-request settings, Bearer gate
241
258
  │ ├── apiSettings.js# GET/PUT /api/settings, key rotation
242
259
  │ ├── tunnel.js # Cloudflare & Tailscale status/commands
260
+ │ ├── https.js # Caddy config, ACME/TLS readiness & lifecycle
243
261
  │ ├── httpGuards.js# Host verification & CSRF/rebinding guards
244
262
  │ ├── apiScan.js # Local & remote scan coordination
245
263
  │ ├── apiFile.js # Path-traversal safe file serving
@@ -252,7 +270,7 @@ codebase-onboarder/
252
270
  │ ├── js/ # Vanilla ES modules (State, Inspector, Views, Settings)
253
271
  │ ├── vendor/ # Vendored Mermaid & Monaco Editor (Offline)
254
272
  │ └── index.html # Main application interface
255
- └── tests/ # Comprehensive node:test suite (551 unit tests)
273
+ └── tests/ # Comprehensive node:test suite (560 unit tests)
256
274
  ```
257
275
 
258
276
  ---
@@ -262,7 +280,7 @@ codebase-onboarder/
262
280
  Onboarder includes a comprehensive automated test suite built with Node's native test runner:
263
281
 
264
282
  ```bash
265
- # Run all 551 tests
283
+ # Run all 560 tests
266
284
  npm test
267
285
  ```
268
286
 
package/cli/commands.js CHANGED
@@ -17,8 +17,9 @@ import {
17
17
  import { startServer } from '../server/index.js';
18
18
  import { pidIsAlive, readPidFile, removePidFile } from '../server/pidfile.js';
19
19
  import { tunnelStatus, cloudflareCommand, tailscaleCommand, installHint, findOnPath } from '../server/tunnel.js';
20
+ import { caddyRun, caddyValidate, httpsReadiness, httpsStatus, writeCaddyfile } from '../server/https.js';
20
21
  import { bold, cyan, dim, ok, warn, bad, kv, tick, cross, dash, welcomeBanner } from './ui.js';
21
- import { buildSteps, pendingSteps, defaultOf, applyFlags, answersToSettings, summaryLines } from './wizard.js';
22
+ import { buildSteps, unansweredSteps, defaultOf, applyFlags, answersToSettings, summaryLines } from './wizard.js';
22
23
  import { runSteps, WizardCancelled } from './prompt.js';
23
24
 
24
25
  // ---------------------------------------------------------------- setup ---
@@ -49,7 +50,7 @@ export async function runSetup({ flags = {}, out = console.log, err = console.er
49
50
 
50
51
  // Flag answers go in first; the wizard only asks what is left.
51
52
  const seed = {};
52
- for (const id of ['name', 'email', 'mode', 'host', 'port', 'domain', 'autoOpen']) {
53
+ for (const id of ['name', 'email', 'mode', 'host', 'port', 'domain', 'https', 'autoOpen']) {
53
54
  if (flags[id] !== undefined) seed[id] = flags[id];
54
55
  }
55
56
  if (flags.provider !== undefined) seed.provider = flags.provider;
@@ -58,7 +59,7 @@ export async function runSetup({ flags = {}, out = console.log, err = console.er
58
59
 
59
60
  let answers;
60
61
  try {
61
- answers = await runSteps(pendingSteps(buildSteps(current), seed), seed);
62
+ answers = await runSteps(unansweredSteps(buildSteps(current), seed), seed);
62
63
  } catch (e) {
63
64
  if (e instanceof WizardCancelled) {
64
65
  err('\n Setup cancelled — nothing was written.');
@@ -98,7 +99,9 @@ export async function runSetup({ flags = {}, out = console.log, err = console.er
98
99
  out('');
99
100
  return runStart({ flags, out, err });
100
101
  }
101
- out(dim(' Later: `onboarder start`'));
102
+ out(dim(settings.https
103
+ ? ' Later: `onboarder start` (starts Caddy automatically), or `onboarder https status`'
104
+ : ' Later: `onboarder start`'));
102
105
  return 0;
103
106
  }
104
107
 
@@ -163,6 +166,14 @@ export async function runStart({ flags = {}, out = console.log, err = console.er
163
166
  if (recorded) removePidFile(file, recorded);
164
167
 
165
168
  const started = await startServer({ configFile: file, log: out });
169
+ if (started.settings.https) {
170
+ out('');
171
+ const httpsCode = await runHttps('setup', { flags, out: flags.json ? () => {} : out, err });
172
+ if (httpsCode !== 0) {
173
+ await new Promise((resolve) => started.server.close(resolve));
174
+ return httpsCode;
175
+ }
176
+ }
166
177
  if (flags.json) out(JSON.stringify({ host: started.host, port: started.port, url: serverUrls(started.settings).local }));
167
178
  // The listening server holds the event loop; resolve so callers/tests know
168
179
  // we are up, but leave the process running.
@@ -272,6 +283,7 @@ const SETTABLE = {
272
283
  host: (v) => v,
273
284
  port: (v) => { const n = Number(v); if (!Number.isInteger(n) || n < 1 || n > 65535) throw new Error('port must be 1-65535.'); return n; },
274
285
  domain: (v) => v,
286
+ https: (v) => ['true', 'yes', '1', 'on'].includes(String(v).toLowerCase()),
275
287
  autoOpen: (v) => ['true', 'yes', '1', 'on'].includes(String(v).toLowerCase()),
276
288
  'account.name': (v) => v,
277
289
  'account.email': (v) => v,
@@ -313,6 +325,7 @@ export async function runConfig(sub, args, { flags = {}, out = console.log } = {
313
325
  out(kv('Mode', settings.mode));
314
326
  out(kv('Bind', `${settings.host}:${settings.port}`));
315
327
  if (settings.domain) out(kv('Domain', settings.domain));
328
+ out(kv('HTTPS', settings.https ? `enabled — ${urls.domain}` : settings.domain ? 'disabled (plain HTTP)' : 'no domain configured'));
316
329
  out(kv('Local', urls.local));
317
330
  if (urls.network) out(kv('Network', urls.network));
318
331
  if (urls.domain) out(kv('Public', urls.domain));
@@ -447,14 +460,24 @@ export async function runDoctor({ flags = {}, out = console.log } = {}) {
447
460
  id: 'domain', ok: true, required: false,
448
461
  detail: settings.domain || (loopback ? '(none — a tunnel or local reverse proxy provides the name)' : '(none — visitors can connect by server IP)'),
449
462
  });
463
+ if (settings.https) {
464
+ const readiness = await httpsReadiness(settings, { configFile: file });
465
+ for (const check of readiness.checks.slice(1).filter((item) => item.id !== 'caddy')) {
466
+ checks.push({ ...check, required: check.id !== 'dns-target' });
467
+ }
468
+ }
450
469
  }
451
470
  }
452
471
 
453
- for (const name of ['git', 'cloudflared', 'tailscale']) {
454
- const wanted = name === 'git' || (settings && settings.tunnel?.[name === 'cloudflared' ? 'cloudflare' : 'tailscale']);
472
+ for (const name of ['git', 'cloudflared', 'tailscale', 'caddy']) {
473
+ const wanted = name === 'git'
474
+ || (name === 'caddy' && settings?.https)
475
+ || (settings && settings.tunnel?.[name === 'cloudflared' ? 'cloudflare' : 'tailscale']);
455
476
  const found = Boolean(findOnPath(name));
456
477
  checks.push({
457
- id: name, ok: found || !wanted, required: name === 'git',
478
+ id: name,
479
+ ok: found || !wanted,
480
+ required: name === 'git' || (name === 'caddy' && settings?.https),
458
481
  detail: found ? 'installed' : wanted ? 'not installed — ' + installHint(name) : 'not installed (not needed for your settings)',
459
482
  });
460
483
  }
@@ -481,6 +504,92 @@ function portIsFree(host, port) {
481
504
  });
482
505
  }
483
506
 
507
+ // ------------------------------------------------------------------ https ---
508
+
509
+ export async function runHttps(action = 'status', { flags = {}, out = console.log, err = console.error } = {}) {
510
+ const file = flags.config || configPath();
511
+ const settings = await readSettings(file);
512
+ const status = httpsStatus(settings, file);
513
+ if (action === 'status') {
514
+ if (flags.json) out(JSON.stringify(status, null, 2));
515
+ else {
516
+ out('');
517
+ out(bold(' Onboarder HTTPS'));
518
+ out(kv('Enabled', status.enabled ? 'yes' : 'no'));
519
+ out(kv('Domain', status.domain || '(not configured)'));
520
+ out(kv('URL', status.url || '(none)'));
521
+ out(kv('Caddy', status.caddyInstalled ? status.caddyVersion : 'not installed'));
522
+ out(kv('Caddyfile', status.caddyfile));
523
+ if (status.enabled) out(dim(' `onboarder https check` verifies DNS and ports before issuance.'));
524
+ out('');
525
+ }
526
+ return status.enabled ? 0 : 1;
527
+ }
528
+ if (action === 'check') {
529
+ const readiness = await httpsReadiness(settings, { configFile: file });
530
+ if (flags.json) out(JSON.stringify(readiness, null, 2));
531
+ else {
532
+ out('');
533
+ out(bold(' HTTPS readiness'));
534
+ for (const check of readiness.checks) out(`${check.ok ? tick : check.required ? cross : dash}${check.id} — ${check.detail}`);
535
+ if (!readiness.ok) {
536
+ out('');
537
+ out(warn(' Fix the required items, then run `onboarder https setup` again.'));
538
+ out(dim(' DNS: point an A/AAAA record to this VPS. Firewall: allow inbound 80 and 443.'));
539
+ }
540
+ out('');
541
+ }
542
+ return readiness.ok ? 0 : 1;
543
+ }
544
+ if (action === 'setup' || action === 'start') {
545
+ const readiness = await httpsReadiness(settings, { configFile: file });
546
+ if (!readiness.ok) {
547
+ err(bad(' HTTPS setup is not ready:'));
548
+ for (const check of readiness.checks.filter((item) => item.required && !item.ok)) err(cross + check.id + ' — ' + check.detail);
549
+ err(' Point DNS at this VPS, allow inbound TCP 80/443, install Caddy, then retry.');
550
+ return 1;
551
+ }
552
+ const caddyfile = await writeCaddyfile(settings, file);
553
+ out(dim(' Caddyfile: ' + caddyfile));
554
+ await caddyValidate(settings, file);
555
+
556
+ // Ubuntu's package usually leaves Caddy running as a service. Reload first
557
+ // so repeat setup is idempotent and does not mistake Caddy for a foreign
558
+ // listener. Reload talks only to Caddy's local admin endpoint and never
559
+ // stops an unknown process.
560
+ let result;
561
+ let reloaded = false;
562
+ try {
563
+ result = caddyRun(settings, file, 'reload');
564
+ reloaded = true;
565
+ } catch {
566
+ try {
567
+ result = caddyRun(settings, file, 'start');
568
+ } catch (error) {
569
+ err(bad(' Caddy could not start: ' + (error.message || error)));
570
+ if (process.platform !== 'win32') {
571
+ err(' On Ubuntu, start the packaged service once:');
572
+ err(' sudo systemctl enable --now caddy');
573
+ err(' Then run `onboarder https setup` again.');
574
+ }
575
+ return 1;
576
+ }
577
+ }
578
+ out(tick + (reloaded ? 'Caddy reloaded. It is using' : 'Caddy started. It is using') + ' https://' + settings.domain + '.');
579
+ out(' Public URL https://' + settings.domain);
580
+ out(dim(' Upstream http://127.0.0.1:' + settings.port));
581
+ if (result.output) out(dim(' ' + result.output.split('\n').slice(-3).join('\n ')));
582
+ return 0;
583
+ }
584
+ if (action === 'stop') {
585
+ if (!status.caddyInstalled) { err(' Caddy is not installed.'); return 1; }
586
+ caddyRun(settings, file, 'stop');
587
+ out(tick + 'Caddy stopped. The Onboarder HTTP server is unchanged.');
588
+ return 0;
589
+ }
590
+ throw new Error('Usage: onboarder https <check|setup|start|stop|status>');
591
+ }
592
+
484
593
  // --------------------------------------------------------------- tunnel ---
485
594
 
486
595
  export async function runTunnel(kind, { flags = {}, out = console.log, err = console.error } = {}) {
package/cli/main.js CHANGED
@@ -9,7 +9,7 @@ import { parseArgs } from 'node:util';
9
9
 
10
10
  import {
11
11
  runSetup, runStart, runStatus, runStop, runRestart,
12
- runConfig, runConfigKey, runConfigReset, runDoctor, runTunnel,
12
+ runConfig, runConfigKey, runConfigReset, runDoctor, runTunnel, runHttps,
13
13
  } from './commands.js';
14
14
 
15
15
  const PACKAGE = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
@@ -26,10 +26,12 @@ const HELP = `
26
26
  onboarder restart Stop and start again
27
27
  onboarder config [<…>] show | get <key> | set <key> <value> | path | reset | key <rotate|show|set>
28
28
  onboarder tunnel <name> cloudflare | tailscale
29
+ onboarder https <action> check | setup | start | stop | status
29
30
  onboarder doctor Check the machine and the config
30
31
 
31
32
  Setup flags (interactive wizard skips what they answer)
32
33
  --mode local|self-hosted --host <addr> --port <n> --domain <name>
34
+ --https Set up automatic HTTPS through Caddy
33
35
  --access-key generate|<k> --name <n> --email <e>
34
36
  --provider none|openai-compatible|ollama|openrouter|custom
35
37
  --base-url <url> --model <m> --cloudflare --tailscale
@@ -50,7 +52,8 @@ const HELP = `
50
52
  Examples
51
53
  onboarder setup
52
54
  onboarder setup --non-interactive --mode local --port 4310
53
- onboarder setup --non-interactive --mode self-hosted --domain map.example.com --access-key generate
55
+ onboarder setup --non-interactive --mode self-hosted --host 0.0.0.0
56
+ onboarder setup --non-interactive --mode self-hosted --domain map.example.com --https --start
54
57
  onboarder config set tunnel.cloudflare true && onboarder tunnel cloudflare
55
58
  `;
56
59
 
@@ -69,6 +72,7 @@ const OPTIONS = {
69
72
  host: { type: 'string' },
70
73
  port: { type: 'string' },
71
74
  domain: { type: 'string' },
75
+ https: { type: 'boolean' },
72
76
  'access-key': { type: 'string' },
73
77
  name: { type: 'string' },
74
78
  email: { type: 'string' },
@@ -129,6 +133,8 @@ export async function main(argv = process.argv.slice(2)) {
129
133
  return codeOf(await runConfig(sub || 'show', rest, { flags }));
130
134
  case 'tunnel':
131
135
  return codeOf(await runTunnel(sub, { flags }));
136
+ case 'https':
137
+ return codeOf(await runHttps(sub || 'status', { flags }));
132
138
  case 'doctor':
133
139
  return codeOf(await runDoctor({ flags }));
134
140
  default:
package/cli/wizard.js CHANGED
@@ -99,10 +99,15 @@ export function buildSteps(current = DEFAULT_SETTINGS) {
99
99
  id: 'host', section: 'Server', type: 'choice',
100
100
  question: 'What should the server bind to?',
101
101
  choices: [
102
- { value: '127.0.0.1', label: 'Loopback, behind a tunnel (recommended)', hint: 'Cloudflare or Tailscale terminates TLS and dials 127.0.0.1' },
103
- { value: '0.0.0.0', label: 'Every interface (LAN)', hint: 'other machines on this network reach it directly — plain HTTP, so front it with a domain + proxy for TLS' },
102
+ { value: '127.0.0.1', label: 'Loopback only — behind Cloudflare or Tailscale', hint: 'safest remote layout; the tunnel/proxy connects to 127.0.0.1' },
103
+ { value: '0.0.0.0', label: 'Every interface — direct public/LAN access', hint: 'accessible by server IP; recommended for a cloud VPS' },
104
104
  ],
105
- default: current.host === '0.0.0.0' ? '0.0.0.0' : '127.0.0.1',
105
+ default: (answers) => {
106
+ const currentHost = current.host;
107
+ if (answers.host) return answers.host;
108
+ if (current.mode === 'self-hosted' && ['127.0.0.1', 'localhost', '::1', '[::1]'].includes(currentHost)) return currentHost;
109
+ return '0.0.0.0';
110
+ },
106
111
  when: (a) => a.mode === 'self-hosted',
107
112
  },
108
113
  {
@@ -113,12 +118,19 @@ export function buildSteps(current = DEFAULT_SETTINGS) {
113
118
  },
114
119
  {
115
120
  id: 'domain', section: 'Server', type: 'text',
116
- question: 'Domain (optional; not needed when reached by IP or behind a tunnel)',
117
- hint: 'e.g. map.example.com — use a hostname only when visitors will connect by domain',
121
+ question: 'Domain (optional for direct IP or a tunnel)',
122
+ hint: 'e.g. map.example.com — point an A/AAAA record to this VPS to enable trusted HTTPS',
118
123
  default: current.domain || '',
119
124
  validate: validDomain,
120
125
  when: (a) => a.mode === 'self-hosted',
121
126
  },
127
+ {
128
+ id: 'https', section: 'HTTPS', type: 'confirm',
129
+ question: 'Set up automatic HTTPS with Caddy?',
130
+ hint: 'Caddy obtains and renews the certificate, proxies 80/443, and sends traffic to this app',
131
+ default: Boolean(current.domain && current.https),
132
+ when: (a) => a.mode === 'self-hosted' && Boolean(a.domain),
133
+ },
122
134
  {
123
135
  id: 'keyChoice', section: 'Security', type: 'choice',
124
136
  question: 'Access key — the one credential remote devices must hold',
@@ -231,9 +243,10 @@ export function answersToSettings(current, answers) {
231
243
  return normalizeSettings({
232
244
  version: current.version,
233
245
  mode: selfHosted ? 'self-hosted' : 'local',
234
- host: selfHosted ? (answers.host || current.host || '127.0.0.1') : '127.0.0.1',
246
+ host: selfHosted ? (answers.host || '0.0.0.0') : '127.0.0.1',
235
247
  port: Number(answers.port ?? current.port ?? DEFAULT_SETTINGS.port),
236
248
  domain: selfHosted ? String(answers.domain ?? current.domain ?? '').trim().toLowerCase() : '',
249
+ https: selfHosted && Boolean(answers.domain) && Boolean(answers.https),
237
250
  accessKey,
238
251
  autoOpen: answers.autoOpen ?? current.autoOpen ?? false,
239
252
  account,
@@ -265,6 +278,7 @@ export function applyFlags(current, flags = {}) {
265
278
  if (err) throw new Error('--domain: ' + err);
266
279
  patch.domain = String(flags.domain).trim().toLowerCase();
267
280
  }
281
+ if (flags.https !== undefined) patch.https = Boolean(flags.https);
268
282
  if (flags.accessKey === 'generate') patch.accessKey = generateAccessKey();
269
283
  else if (flags.accessKey !== undefined) {
270
284
  if (String(flags.accessKey).trim().length < 16) throw new Error('--access-key must be at least 16 characters, or "generate".');
@@ -300,11 +314,15 @@ export function applyFlags(current, flags = {}) {
300
314
  account: { ...(current.account || DEFAULT_SETTINGS.account), ...account },
301
315
  tunnel: { ...(current.tunnel || DEFAULT_SETTINGS.tunnel), ...tunnel },
302
316
  };
303
- // Mode drove host/domain rules inside normalize; when flags move a config to
304
- // local mode the network fields have to come along rather than linger.
317
+ // A fresh self-hosted setup is direct-first for cloud/VPS use. Re-running setup
318
+ // preserves an existing deliberate loopback choice for tunnels and Caddy.
319
+ if (flags.mode === 'self-hosted' && flags.host === undefined && current.mode !== 'self-hosted') {
320
+ merged.host = '0.0.0.0';
321
+ }
305
322
  if (merged.mode === 'local') {
306
323
  if (!['127.0.0.1', 'localhost', '::1', '[::1]'].includes(merged.host)) merged.host = '127.0.0.1';
307
324
  if (flags.domain === undefined) merged.domain = '';
325
+ if (flags.https === undefined) merged.https = false;
308
326
  }
309
327
  return { patch, settings: normalizeSettings(merged) };
310
328
  }
@@ -316,7 +334,7 @@ export function summaryLines(settings, { revealKey = false } = {}) {
316
334
  ['Mode', settings.mode],
317
335
  ['Bind', `${settings.host}:${settings.port}`],
318
336
  ];
319
- if (settings.domain) lines.push(['Domain', 'https://' + settings.domain]);
337
+ if (settings.domain) lines.push(['Domain', (settings.https ? 'https://' : 'http://') + settings.domain + (settings.https ? '' : ':' + settings.port)]);
320
338
  if (settings.mode === 'self-hosted') {
321
339
  lines.push(['Access key', revealKey
322
340
  ? settings.accessKey
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebase-onboarder",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Drop a path. Get a map. A zero-dependency codebase visualizer with a CLI onboarding wizard, a web UI, and an optional key-gated self-hosted mode.",
5
5
  "type": "module",
6
6
  "bin": {
package/public/index.html CHANGED
@@ -389,9 +389,16 @@
389
389
  </label>
390
390
  </div>
391
391
  <label class="field" id="srvDomainField">
392
- <span class="field-label">Domain (optional for LAN, required for a public name)</span>
392
+ <span class="field-label">Domain (optional for direct IP or a tunnel)</span>
393
393
  <input type="text" id="srvDomain" class="text-input" placeholder="map.example.com" spellcheck="false">
394
394
  </label>
395
+ <div id="srvHttpsField" hidden>
396
+ <label class="check-row">
397
+ <input type="checkbox" id="srvHttps">
398
+ <span>Automatic HTTPS with Caddy</span>
399
+ </label>
400
+ <p class="drawer-fine" id="srvHttpsStatus"></p>
401
+ </div>
395
402
  <label class="check-row">
396
403
  <input type="checkbox" id="srvAutoOpen">
397
404
  <span>Open the browser when the server starts</span>
@@ -29,7 +29,7 @@ export function createServerDrawer(options = {}) {
29
29
  const dom = {};
30
30
  [
31
31
  'serverDrawer', 'serverScrim', 'serverClose', 'serverModeNote', 'srvMode', 'srvHost', 'srvPort',
32
- 'srvDomain', 'srvDomainField', 'srvAutoOpen', 'srvKeySection', 'srvKeyMasked', 'srvRotate',
32
+ 'srvDomain', 'srvDomainField', 'srvHttps', 'srvHttpsField', 'srvHttpsStatus', 'srvAutoOpen', 'srvKeySection', 'srvKeyMasked', 'srvRotate',
33
33
  'srvFreshKeyRow', 'srvFreshKey', 'srvCopyKey', 'srvKeyHint', 'srvName', 'srvEmail',
34
34
  'srvTunnelCloudflare', 'srvTunnelCloudflareStatus', 'srvTunnelTailscale', 'srvTunnelTailscaleStatus',
35
35
  'srvSave', 'srvStatus', 'srvConfigPath',
@@ -61,6 +61,10 @@ export function createServerDrawer(options = {}) {
61
61
  dom.srvHost.value = s.host;
62
62
  dom.srvPort.value = s.port;
63
63
  dom.srvDomain.value = s.domain || '';
64
+ dom.srvHttps.checked = Boolean(s.https);
65
+ dom.srvHttpsStatus.textContent = data.https?.caddyInstalled
66
+ ? `Caddy ${data.https.caddyVersion || 'installed'}. ${s.https ? 'Run `onboarder https setup` after saving.' : 'Enable HTTPS above, save, then run the command.'}`
67
+ : 'Caddy is not installed. Ubuntu: sudo apt install caddy; macOS: brew install caddy.';
64
68
  dom.srvAutoOpen.checked = Boolean(s.autoOpen);
65
69
  dom.srvName.value = s.account?.name || '';
66
70
  dom.srvEmail.value = s.account?.email || '';
@@ -82,6 +86,7 @@ export function createServerDrawer(options = {}) {
82
86
  const remote = dom.srvMode.value === 'self-hosted';
83
87
  dom.srvKeySection.hidden = !remote;
84
88
  dom.srvDomainField.hidden = !remote;
89
+ dom.srvHttpsField.hidden = !remote || !dom.srvDomain.value.trim();
85
90
  dom.serverModeNote.textContent = MODE_NOTES[dom.srvMode.value] || '';
86
91
  }
87
92
 
@@ -113,16 +118,22 @@ export function createServerDrawer(options = {}) {
113
118
  account: { name: dom.srvName.value.trim(), email: dom.srvEmail.value.trim() },
114
119
  tunnel: { cloudflare: dom.srvTunnelCloudflare.checked, tailscale: dom.srvTunnelTailscale.checked },
115
120
  };
116
- if (patch.mode === 'self-hosted') patch.domain = dom.srvDomain.value.trim();
121
+ if (patch.mode === 'self-hosted') {
122
+ patch.domain = dom.srvDomain.value.trim();
123
+ patch.https = dom.srvHttps.checked;
124
+ }
117
125
  status('Saving…');
118
126
  try {
119
127
  const data = await updateServerSettings(patch);
120
128
  render({ ...current, settings: data.settings });
121
129
  const restart = (data.restartRequired || []).join(' and ');
130
+ const httpsChanged = (data.restartRequired || []).some((key) => key === 'domain' || key === 'https');
122
131
  status(
123
- restart
124
- ? `Saved. The ${restart} change takes effect after a restart — the running socket cannot move.`
125
- : 'Saved — live already.',
132
+ httpsChanged
133
+ ? 'Saved. Run `onboarder https setup` to validate the Caddyfile and reload TLS.'
134
+ : restart
135
+ ? `Saved. The ${restart} change takes effect after a restart — the running socket cannot move.`
136
+ : 'Saved — live already.',
126
137
  'ok',
127
138
  );
128
139
  } catch (err) {
@@ -147,6 +158,7 @@ export function createServerDrawer(options = {}) {
147
158
  }
148
159
 
149
160
  dom.srvMode.addEventListener('change', applyModeVisibility);
161
+ dom.srvDomain.addEventListener('input', applyModeVisibility);
150
162
  dom.srvSave.addEventListener('click', save);
151
163
  dom.srvRotate.addEventListener('click', rotate);
152
164
  dom.serverClose.addEventListener('click', close);
@@ -13,6 +13,7 @@ import {
13
13
  } from './config.js';
14
14
  import { sendError, sendJSON } from './http.js';
15
15
  import { tunnelStatus } from './tunnel.js';
16
+ import { httpsStatus } from './https.js';
16
17
 
17
18
  // Where this request's settings live. The router's config object carries the
18
19
  // path when the server was booted with one; tests and a bare `createServer()`
@@ -37,6 +38,7 @@ async function publicBody(config) {
37
38
  configFile: settingsFile(config) || configPath(),
38
39
  node: process.version,
39
40
  tunnels: tunnelStatus(settings),
41
+ https: httpsStatus(settings, settingsFile(config) || configPath()),
40
42
  };
41
43
  }
42
44
 
@@ -48,15 +50,17 @@ export async function handleGetSettings(req, res, config) {
48
50
  }
49
51
  }
50
52
 
51
- // Which changes the running server cannot absorb. Auth, domain, and tunnel
52
- // flags are read live from disk on each request; the bind is a socket that
53
- // already exists.
53
+ // Which changes the running server cannot absorb. Access-key and tunnel flags
54
+ // are read live per request. Host/port are bound sockets. Domain/HTTPS change
55
+ // the managed Caddyfile and certificate, so Caddy must validate/reload too.
54
56
  function restartRequired(config, next) {
55
57
  const boot = config?.boot;
56
58
  if (!boot) return [];
57
59
  const changed = [];
58
60
  if (next.host !== boot.host) changed.push('host');
59
61
  if (next.port !== boot.port) changed.push('port');
62
+ if (next.domain !== boot.domain) changed.push('domain');
63
+ if (next.https !== boot.https) changed.push('https');
60
64
  return changed;
61
65
  }
62
66
 
@@ -70,7 +74,7 @@ export async function handleUpdateSettings(res, body, config) {
70
74
  }
71
75
  // Only known keys may be patched — a typo like "ports" must be a loud 400,
72
76
  // not a silently ignored write that the UI then claims it saved.
73
- const allowed = new Set(['mode', 'host', 'port', 'domain', 'autoOpen', 'account', 'tunnel']);
77
+ const allowed = new Set(['mode', 'host', 'port', 'domain', 'https', 'autoOpen', 'account', 'tunnel']);
74
78
  const unknown = Object.keys(body).filter((k) => k !== 'accessKey' && !allowed.has(k));
75
79
  if (unknown.length) {
76
80
  return sendError(res, 400, `Unknown setting${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}.`);
package/server/config.js CHANGED
@@ -7,7 +7,7 @@ import { randomBytes, timingSafeEqual } from 'node:crypto';
7
7
  import os from 'node:os';
8
8
  import path from 'node:path';
9
9
 
10
- export const CONFIG_VERSION = 1;
10
+ export const CONFIG_VERSION = 2;
11
11
  export const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
12
12
  export const DEFAULT_SETTINGS = Object.freeze({
13
13
  version: CONFIG_VERSION,
@@ -15,6 +15,10 @@ export const DEFAULT_SETTINGS = Object.freeze({
15
15
  host: '127.0.0.1',
16
16
  port: 4310,
17
17
  domain: '',
18
+ // When a domain is configured, ask Caddy to obtain and renew a trusted TLS
19
+ // certificate and reverse-proxy this app. The certificate itself belongs to
20
+ // Caddy; the server still speaks plain HTTP to its local upstream.
21
+ https: false,
18
22
  accessKey: '',
19
23
  // Whether `onboarder start` opens the app in a browser once it is listening.
20
24
  // Off in the schema so `npm start` from a checkout stays quiet; the setup
@@ -82,6 +86,7 @@ export function normalizeSettings(value = {}) {
82
86
  host,
83
87
  port: normalizePort(value.port ?? DEFAULT_SETTINGS.port),
84
88
  domain: normalizeDomain(value.domain),
89
+ https: Boolean(value.https),
85
90
  accessKey: cleanString(value.accessKey, 256),
86
91
  autoOpen: value.autoOpen === undefined ? DEFAULT_SETTINGS.autoOpen : Boolean(value.autoOpen),
87
92
  account: normalizeAccount(value.account),
@@ -91,10 +96,11 @@ export function normalizeSettings(value = {}) {
91
96
  if (settings.mode === 'local' && !isLoopbackHost(settings.host)) {
92
97
  throw new Error('Local mode only binds to 127.0.0.1, localhost, or ::1. Choose self-hosted mode for a network bind.');
93
98
  }
99
+ if (settings.https && !settings.domain) {
100
+ throw new Error('HTTPS needs a domain such as map.example.com. A public IP alone cannot be used for a normal trusted certificate.');
101
+ }
94
102
  // Self-hosted needs no domain: a bare-IP bind (a VPS with no DNS name) is a
95
- // legitimate layout. The rebinding guard still answers only to the
96
- // configured domain or this machine's own interface addresses (see
97
- // allowedHosts), and every API call needs the access key either way.
103
+ // legitimate layout. Every API call needs the access key either way.
98
104
  return settings;
99
105
  }
100
106
 
@@ -113,7 +119,7 @@ export function serverUrls(value = DEFAULT_SETTINGS) {
113
119
  ? `http://<this-machine>:${settings.port} (every interface)`
114
120
  : `http://${settings.host}:${settings.port}`;
115
121
  }
116
- if (settings.domain) urls.domain = `https://${settings.domain}`;
122
+ if (settings.domain) urls.domain = settings.https ? `https://${settings.domain}` : `http://${settings.domain}:${settings.port}`;
117
123
  return urls;
118
124
  }
119
125
 
@@ -124,6 +130,12 @@ export function maskAccessKey(key) {
124
130
  return `${value.slice(0, 6)}…${value.slice(-4)}`;
125
131
  }
126
132
 
133
+ export function browserUrl(settings) {
134
+ const url = new URL(serverUrls(settings).local);
135
+ if (settings.mode === 'self-hosted' && settings.accessKey) url.searchParams.set('key', settings.accessKey);
136
+ return url.toString();
137
+ }
138
+
127
139
  export function publicSettings(value = DEFAULT_SETTINGS) {
128
140
  const settings = normalizeSettings(value);
129
141
  return {
@@ -132,6 +144,7 @@ export function publicSettings(value = DEFAULT_SETTINGS) {
132
144
  host: settings.host,
133
145
  port: settings.port,
134
146
  domain: settings.domain,
147
+ https: settings.https,
135
148
  autoOpen: settings.autoOpen,
136
149
  hasAccessKey: Boolean(settings.accessKey),
137
150
  accessKeyMasked: maskAccessKey(settings.accessKey),
@@ -179,8 +192,15 @@ export function allowedHosts(value = DEFAULT_SETTINGS) {
179
192
  // A wildcard bind is useful as a literal Host value to diagnostics, even
180
193
  // though browsers normally address the machine by one of its real IPs.
181
194
  hosts.add(settings.host.toLowerCase());
182
- if (settings.host === '0.0.0.0' || settings.host === '::') {
183
- for (const ip of ownInterfaceHosts()) hosts.add(ip);
195
+ if (settings.host === '0.0.0.0') {
196
+ // A cloud VPS commonly reaches its public address through provider NAT,
197
+ // so that address is not present in os.networkInterfaces(). These markers
198
+ // let the guard accept an IP literal without accepting arbitrary domains.
199
+ hosts.add('ipv4:*');
200
+ for (const ip of ownInterfaceHosts()) if (!ip.includes(':')) hosts.add(ip);
201
+ } else if (settings.host === '::') {
202
+ hosts.add('ipv6:*');
203
+ for (const ip of ownInterfaceHosts()) if (ip.includes(':')) hosts.add(ip);
184
204
  }
185
205
  }
186
206
  if (settings.tunnel.cloudflare) hosts.add('*.trycloudflare.com');
@@ -7,6 +7,8 @@
7
7
  // open from driving this server on their behalf. Binding to 127.0.0.1 keeps
8
8
  // the network out; it does nothing about the browser, which is already inside.
9
9
 
10
+ import net from 'node:net';
11
+
10
12
  const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
11
13
 
12
14
  // "localhost:4310" -> "localhost", "[::1]:4310" -> "[::1]"
@@ -41,6 +43,8 @@ export function rebindingReason(req, extraHosts = []) {
41
43
  const name = hostnameOf(host);
42
44
  if (LOOPBACK_HOSTS.has(name)) return null;
43
45
  for (const extra of extraHosts) {
46
+ if (extra === 'ipv4:*' && net.isIP(name) === 4) return null;
47
+ if (extra === 'ipv6:*' && net.isIP(name.replace(/^\[|\]$/g, '')) === 6) return null;
44
48
  if (extra.startsWith('*.')) {
45
49
  // "*.trycloudflare.com" matches "abc.trycloudflare.com" but not the bare
46
50
  // suffix itself and not "evil-trycloudflare.com".
@@ -0,0 +1,142 @@
1
+ import { promises as dns } from 'node:dns';
2
+ import { promises as fs, statSync } from 'node:fs';
3
+ import net from 'node:net';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { spawnSync } from 'node:child_process';
7
+
8
+ import { configPath, normalizeSettings } from './config.js';
9
+ import { findOnPath, installHint } from './tunnel.js';
10
+
11
+ export function caddyfilePath(configFile = configPath()) {
12
+ return path.join(path.dirname(configFile), 'Caddyfile');
13
+ }
14
+
15
+ export function renderCaddyfile(settings) {
16
+ const s = normalizeSettings(settings);
17
+ if (!s.domain) throw new Error('HTTPS needs a domain. Run `onboarder setup` and enter one first.');
18
+ return `# Managed by Onboarder. Edits are replaced by \`onboarder https setup\`.\n${s.domain} {\n reverse_proxy 127.0.0.1:${s.port}\n}\n`;
19
+ }
20
+
21
+ function portIsFree(port, host = '0.0.0.0') {
22
+ return new Promise((resolve) => {
23
+ const probe = net.createServer();
24
+ probe.once('error', () => resolve(false));
25
+ probe.listen(port, host, () => probe.close(() => resolve(true)));
26
+ });
27
+ }
28
+
29
+ async function resolveDomain(domain) {
30
+ const [v4, v6] = await Promise.allSettled([dns.resolve4(domain), dns.resolve6(domain)]);
31
+ const addresses = [
32
+ ...(v4.status === 'fulfilled' ? v4.value : []),
33
+ ...(v6.status === 'fulfilled' ? v6.value : []),
34
+ ];
35
+ return [...new Set(addresses)];
36
+ }
37
+
38
+ function localAddresses() {
39
+ const found = [];
40
+ for (const list of Object.values(os.networkInterfaces())) {
41
+ for (const address of list || []) if (!address.internal) found.push(address.address);
42
+ }
43
+ return found;
44
+ }
45
+
46
+ async function caddyIsRunning(probe = portIsFree) {
47
+ // If 80 and 443 are both occupied, probe Caddy's local admin endpoint. This
48
+ // distinguishes the Ubuntu service from an unknown listener without sudo.
49
+ if (await probe(80)) return false;
50
+ if (await probe(443)) return false;
51
+ return new Promise((resolve) => {
52
+ const socket = net.connect(2019, '127.0.0.1');
53
+ const finish = (value) => { socket.destroy(); resolve(value); };
54
+ socket.setTimeout(800, () => finish(false));
55
+ socket.once('connect', () => finish(true));
56
+ socket.once('error', () => finish(false));
57
+ });
58
+ }
59
+
60
+ export async function httpsReadiness(settings, { configFile = configPath(), resolve = resolveDomain, probe = portIsFree, tool = findOnPath, running = caddyIsRunning } = {}) {
61
+ const s = normalizeSettings(settings);
62
+ const caddy = tool('caddy');
63
+ const checks = [{ id: 'domain', ok: Boolean(s.domain), required: true, detail: s.domain || 'not configured' }];
64
+ if (!s.domain) return { ok: false, checks, caddy, addresses: [], local: [] };
65
+
66
+ let addresses = [];
67
+ let dnsError = '';
68
+ try { addresses = await resolve(s.domain); } catch (error) { dnsError = error.code || error.message; }
69
+ checks.push({ id: 'dns', ok: addresses.length > 0, required: true, detail: addresses.length ? addresses.join(', ') : `does not resolve${dnsError ? ` (${dnsError})` : ''}` });
70
+ const local = localAddresses();
71
+ const matches = addresses.some((address) => local.includes(address));
72
+ checks.push({ id: 'dns-target', ok: matches, required: false, detail: matches ? 'resolves to this machine' : 'does not match a local interface address (NAT/public-IP and proxy setups can still be valid)' });
73
+
74
+ const serviceRunning = await running(probe);
75
+ for (const port of [80, 443]) {
76
+ const free = await probe(port);
77
+ const ownedByCaddy = !free && serviceRunning;
78
+ checks.push({
79
+ id: `port-${port}`,
80
+ ok: free || ownedByCaddy,
81
+ required: true,
82
+ detail: free ? 'available for Caddy' : ownedByCaddy ? 'in use by the running Caddy service' : 'already in use — stop the existing web server or proxy',
83
+ });
84
+ }
85
+ checks.push({ id: 'caddy', ok: caddy.installed, required: true, detail: caddy.installed ? caddy.version : 'not installed — ' + installHint('caddy') });
86
+ return { ok: checks.every((check) => check.ok || !check.required), checks, caddy, addresses, local };
87
+ }
88
+
89
+ export function httpsStatus(settings, configFile = configPath()) {
90
+ const s = normalizeSettings(settings);
91
+ const file = caddyfilePath(configFile);
92
+ const caddy = findOnPath('caddy');
93
+ let configured = false;
94
+ try { configured = Boolean(statSync(file)); } catch { configured = false; }
95
+ return {
96
+ enabled: Boolean(s.domain && s.https),
97
+ domain: s.domain,
98
+ url: s.domain && s.https ? `https://${s.domain}` : '',
99
+ caddyfile: file,
100
+ caddyInstalled: caddy.installed,
101
+ caddyVersion: caddy.version || '',
102
+ configured,
103
+ command: s.domain && s.https ? `caddy start --config ${file}` : '',
104
+ };
105
+ }
106
+
107
+ export async function writeCaddyfile(settings, configFile = configPath()) {
108
+ const file = caddyfilePath(configFile);
109
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
110
+ await fs.writeFile(file, renderCaddyfile(settings), { encoding: 'utf8', mode: 0o600 });
111
+ return file;
112
+ }
113
+
114
+ export async function caddyValidate(settings, configFile = configPath()) {
115
+ const s = normalizeSettings(settings);
116
+ if (!s.domain) throw new Error('HTTPS needs a domain. Run `onboarder setup` and enter one first.');
117
+ const caddy = findOnPath('caddy');
118
+ if (!caddy.installed) throw new Error('Caddy is not installed. ' + installHint('caddy'));
119
+ const file = caddyfilePath(configFile);
120
+ const result = spawnSync('caddy', ['validate', '--config', file], { encoding: 'utf8' });
121
+ if (result.error) throw result.error;
122
+ if (result.status !== 0) throw new Error(String(result.stderr || result.stdout || 'Caddy configuration is invalid.').trim());
123
+ return { file, output: String(result.stdout || result.stderr || '').trim() };
124
+ }
125
+
126
+ export function caddyRun(settings, configFile = configPath(), action = 'start') {
127
+ const s = normalizeSettings(settings);
128
+ if (!s.domain || !s.https) throw new Error('Enable HTTPS with a domain first: `onboarder setup`.');
129
+ const caddy = findOnPath('caddy');
130
+ if (!caddy.installed) throw new Error('Caddy is not installed. ' + installHint('caddy'));
131
+ const file = caddyfilePath(configFile);
132
+ const result = spawnSync('caddy', [action, '--config', file], { encoding: 'utf8' });
133
+ if (result.error) throw result.error;
134
+ if (result.status !== 0) {
135
+ const message = String(result.stderr || result.stdout || `caddy ${action} failed`).trim();
136
+ if (/permission denied|address already in use/i.test(message) && process.platform !== 'win32') {
137
+ throw new Error(`${message}\nStart the packaged service with: sudo systemctl enable --now caddy`);
138
+ }
139
+ throw new Error(message);
140
+ }
141
+ return { action, file, output: String(result.stdout || result.stderr || '').trim() };
142
+ }
package/server/index.js CHANGED
@@ -21,7 +21,7 @@ import { createRouter } from './router.js';
21
21
  import { installExitCleanup } from './sessions.js';
22
22
  import { createLogger } from './logger.js';
23
23
  import { createMcpRunner } from './mcp/runner.js';
24
- import { configPath, isLoopbackHost, readSettings, serverUrls } from './config.js';
24
+ import { browserUrl, configPath, isLoopbackHost, readSettings, serverUrls } from './config.js';
25
25
  import { tunnelStatus } from './tunnel.js';
26
26
  import { pidIsAlive, readPidFile, removePidFile, writePidFile } from './pidfile.js';
27
27
 
@@ -73,7 +73,17 @@ export function startupBanner(settings, { configFile } = {}) {
73
73
  : ' Mode local — only this machine can reach it');
74
74
  lines.push(' Local ' + urls.local);
75
75
  if (urls.network) lines.push(' Network ' + urls.network);
76
- if (urls.domain) lines.push(' Domain ' + urls.domain);
76
+ if (urls.domain) {
77
+ lines.push(' Domain ' + urls.domain);
78
+ if (settings.domain && !settings.https) {
79
+ lines.push(' HTTPS disabled — `onboarder setup` or `onboarder https setup` enables trusted TLS');
80
+ } else if (settings.https) {
81
+ lines.push(' HTTPS Caddy obtains, renews, and terminates TLS for this domain');
82
+ }
83
+ }
84
+ if (settings.mode === 'self-hosted' && settings.accessKey) {
85
+ lines.push(' Browser this URL is opened with the access key automatically; the key is removed from the address bar.');
86
+ }
77
87
  if (settings.mode === 'self-hosted' && !settings.accessKey) {
78
88
  lines.push(' WARNING self-hosted with no access key — every API call is refused until one is set.');
79
89
  lines.push(' Run `onboarder setup` or `onboarder config key rotate`.');
@@ -130,7 +140,7 @@ export async function startServer({ configFile = configPath(), openBrowser, log
130
140
  ...CONFIG,
131
141
  configPath: configFile,
132
142
  getSettings: () => readSettings(configFile),
133
- boot: { host, port },
143
+ boot: { host, port, domain: settings.domain, https: settings.https },
134
144
  });
135
145
 
136
146
  installExitCleanup();
@@ -166,9 +176,9 @@ export async function startServer({ configFile = configPath(), openBrowser, log
166
176
  log(startupBanner(live, { configFile }));
167
177
 
168
178
  const shouldOpen = openBrowser ?? (live.autoOpen && process.stdout.isTTY && !process.env.NO_OPEN);
169
- // Remote visitors still need the key; what opens locally is the loopback URL,
170
- // which in local mode needs nothing and in self-hosted mode asks for the key.
171
- if (shouldOpen) openInBrowser(serverUrls(live).local);
179
+ // The browser adopts a self-hosted key from the query string, stores it locally,
180
+ // and removes the secret from the visible URL before any API request.
181
+ if (shouldOpen) openInBrowser(browserUrl(live));
172
182
  return { server, settings: live, host, port };
173
183
  }
174
184
 
package/server/tunnel.js CHANGED
@@ -50,6 +50,11 @@ export function tailscaleCommand(settings) {
50
50
  }
51
51
 
52
52
  export function installHint(name) {
53
+ if (name === 'caddy') {
54
+ return process.platform === 'darwin'
55
+ ? 'brew install caddy'
56
+ : 'Install Caddy with: sudo apt install caddy';
57
+ }
53
58
  if (name === 'cloudflared') {
54
59
  return process.platform === 'darwin'
55
60
  ? 'brew install cloudflared (or see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)'