codebase-onboarder 0.2.0 → 0.3.1
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 +33 -10
- package/cli/commands.js +117 -8
- package/cli/main.js +8 -2
- package/cli/wizard.js +27 -9
- package/package.json +1 -1
- package/public/index.html +10 -2
- package/public/js/api.js +21 -56
- package/public/js/serverSettings.js +34 -13
- package/public/login.html +34 -0
- package/server/apiAuth.js +32 -0
- package/server/apiSettings.js +14 -6
- package/server/auth.js +72 -0
- package/server/config.js +32 -8
- package/server/http.js +2 -2
- package/server/httpGuards.js +5 -0
- package/server/https.js +142 -0
- package/server/index.js +16 -6
- package/server/router.js +47 -18
- package/server/tunnel.js +5 -0
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](LICENSE)
|
|
4
4
|
[](https://nodejs.org/)
|
|
5
5
|
[](package.json)
|
|
6
|
-
[](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** —
|
|
205
|
+
- **Self-hosted** — a fresh setup binds `0.0.0.0` for direct LAN/VPS access. Any IP-literal address is accepted, while arbitrary Host names are not. Remote browsers get a themed access-key login page and exchange the key for a 7-day signed `HttpOnly`, `SameSite=Strict` session cookie; true localhost requests skip login. API clients can continue using `Authorization: Bearer <access-key>`. Rotate the key with `onboarder config key rotate`; the old key and every old browser session die 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 --
|
|
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,33 @@ 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
|
|
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
|
-
|
|
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. The server accepts IPv4 and IPv6 IP literals, including a public address that reaches the host through provider NAT, but still rejects arbitrary DNS Host headers. A domain is optional for direct-IP access. If a domain is entered, setup asks whether to enable automatic HTTPS.
|
|
227
|
+
|
|
228
|
+
When a remote browser opens the URL, Onboarder shows its themed sign-in page. The access key is sent in a POST body—not in the URL—and the browser stores only the signed session cookie. Opening the same server through `http://localhost:<port>` on that machine skips the page. Caddy and tunnel connections remain authenticated because their public Host is not loopback.
|
|
229
|
+
|
|
230
|
+
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:
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
sudo apt update
|
|
234
|
+
sudo apt install caddy
|
|
235
|
+
sudo ufw allow 80/tcp
|
|
236
|
+
sudo ufw allow 443/tcp
|
|
237
|
+
onboarder https check
|
|
238
|
+
onboarder https setup
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
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 recognizes the connection as remote and shows the access-key login page. A bare public IP cannot use a normal trusted domain certificate.
|
|
223
242
|
|
|
224
243
|
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
244
|
|
|
226
|
-
Cloudflare quick tunnels and Tailscale
|
|
245
|
+
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
246
|
|
|
228
247
|
---
|
|
229
248
|
|
|
@@ -237,9 +256,12 @@ codebase-onboarder/
|
|
|
237
256
|
│ ├── index.js # createServer / startServer / startup banner
|
|
238
257
|
│ ├── config.js # Settings schema, normalization, atomic 0600 writes
|
|
239
258
|
│ ├── pidfile.js # PID ownership for status / stop / restart
|
|
240
|
-
│ ├── router.js # Route table, live per-request settings,
|
|
259
|
+
│ ├── router.js # Route table, live per-request settings, auth & CSRF gates
|
|
260
|
+
│ ├── auth.js # Signed HttpOnly browser sessions
|
|
261
|
+
│ ├── apiAuth.js # Login/status/logout endpoints
|
|
241
262
|
│ ├── apiSettings.js# GET/PUT /api/settings, key rotation
|
|
242
263
|
│ ├── tunnel.js # Cloudflare & Tailscale status/commands
|
|
264
|
+
│ ├── https.js # Caddy config, ACME/TLS readiness & lifecycle
|
|
243
265
|
│ ├── httpGuards.js# Host verification & CSRF/rebinding guards
|
|
244
266
|
│ ├── apiScan.js # Local & remote scan coordination
|
|
245
267
|
│ ├── apiFile.js # Path-traversal safe file serving
|
|
@@ -251,8 +273,9 @@ codebase-onboarder/
|
|
|
251
273
|
├── public/ # Frontend client application
|
|
252
274
|
│ ├── js/ # Vanilla ES modules (State, Inspector, Views, Settings)
|
|
253
275
|
│ ├── vendor/ # Vendored Mermaid & Monaco Editor (Offline)
|
|
254
|
-
│
|
|
255
|
-
└──
|
|
276
|
+
│ ├── index.html # Main application interface
|
|
277
|
+
│ └── login.html # Self-hosted access-key sign-in
|
|
278
|
+
└── tests/ # Comprehensive node:test suite (565 tests)
|
|
256
279
|
```
|
|
257
280
|
|
|
258
281
|
---
|
|
@@ -262,7 +285,7 @@ codebase-onboarder/
|
|
|
262
285
|
Onboarder includes a comprehensive automated test suite built with Node's native test runner:
|
|
263
286
|
|
|
264
287
|
```bash
|
|
265
|
-
# Run all
|
|
288
|
+
# Run all 565 tests
|
|
266
289
|
npm test
|
|
267
290
|
```
|
|
268
291
|
|
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,
|
|
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(
|
|
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(
|
|
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'
|
|
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,
|
|
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 } = {}) {
|
|
@@ -519,7 +628,7 @@ export async function runTunnel(kind, { flags = {}, out = console.log, err = con
|
|
|
519
628
|
if (m && !announced) {
|
|
520
629
|
announced = true;
|
|
521
630
|
out(tick + 'Public URL: ' + bold(m[0]));
|
|
522
|
-
out(dim(' Anyone with the URL still needs the access key: ' + m[0]
|
|
631
|
+
out(dim(' Anyone with the URL still needs the access key: ' + m[0]));
|
|
523
632
|
}
|
|
524
633
|
if (flags.verbose) process.stderr.write(text);
|
|
525
634
|
});
|
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 --
|
|
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
|
|
103
|
-
{ value: '0.0.0.0', label: 'Every interface
|
|
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:
|
|
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
|
|
117
|
-
hint: 'e.g. map.example.com —
|
|
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 ||
|
|
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
|
-
//
|
|
304
|
-
//
|
|
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.
|
|
3
|
+
"version": "0.3.1",
|
|
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
|
|
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>
|
|
@@ -404,13 +411,14 @@
|
|
|
404
411
|
<button class="btn btn-ghost btn-sm" id="srvRotate" title="Mint a new key; the old one dies immediately">Rotate</button>
|
|
405
412
|
</div>
|
|
406
413
|
<div class="server-fresh-key" id="srvFreshKeyRow" hidden>
|
|
407
|
-
<p class="drawer-fine">New key — shown once.
|
|
414
|
+
<p class="drawer-fine">New key — shown once. This browser has been signed in with it:</p>
|
|
408
415
|
<div class="mcp-config-row">
|
|
409
416
|
<input type="text" class="text-input mcp-config-input" id="srvFreshKey" readonly spellcheck="false">
|
|
410
417
|
<button class="btn btn-ghost btn-sm" id="srvCopyKey">Copy</button>
|
|
411
418
|
</div>
|
|
412
419
|
</div>
|
|
413
420
|
<p class="drawer-fine" id="srvKeyHint"></p>
|
|
421
|
+
<button class="btn btn-ghost btn-sm" id="srvLogout">Sign out this browser</button>
|
|
414
422
|
</div>
|
|
415
423
|
|
|
416
424
|
<div class="server-section">
|
package/public/js/api.js
CHANGED
|
@@ -1,58 +1,19 @@
|
|
|
1
1
|
// Server round-trips. Thin and honest: JSON in, JSON (or an SSE stream) out,
|
|
2
2
|
// server error messages surfaced untouched.
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// the URL (adopted once, then stripped so it does not linger in history), or
|
|
9
|
-
// the person pastes it into the Server drawer. Either way it lives in
|
|
10
|
-
// localStorage and rides along as an Authorization header from then on. Local
|
|
11
|
-
// mode ignores the header entirely, so sending it is harmless.
|
|
4
|
+
// Browser access is an HttpOnly session cookie set by the themed self-hosted
|
|
5
|
+
// login page. Same-origin fetches include it automatically, and JavaScript never
|
|
6
|
+
// reads or stores the access key. API clients can still use `Authorization:
|
|
7
|
+
// Bearer …`; local mode needs no credential at all.
|
|
12
8
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
// Lazy on purpose, and a function declaration for the same reason: running
|
|
18
|
-
// this at import would touch `window`, and the front-end test suite requires
|
|
19
|
-
// every module to be importable from Node. The first API call is still early
|
|
20
|
-
// enough — the key is adopted before any request leaves.
|
|
21
|
-
function adoptKeyFromUrl() {
|
|
22
|
-
if (urlKeyChecked) return;
|
|
23
|
-
urlKeyChecked = true;
|
|
24
|
-
try {
|
|
25
|
-
const url = new URL(window.location.href);
|
|
26
|
-
const key = url.searchParams.get('key');
|
|
27
|
-
if (!key) return;
|
|
28
|
-
localStorage.setItem(ACCESS_KEY_STORAGE, key);
|
|
29
|
-
url.searchParams.delete('key');
|
|
30
|
-
window.history.replaceState(null, '', url);
|
|
31
|
-
} catch {
|
|
32
|
-
/* no usable URL API — the drawer can still take the key by hand */
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function getAccessKey() {
|
|
37
|
-
adoptKeyFromUrl();
|
|
38
|
-
return localStorage.getItem(ACCESS_KEY_STORAGE) || '';
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function setAccessKey(key) {
|
|
42
|
-
const trimmed = String(key || '').trim();
|
|
43
|
-
if (trimmed) localStorage.setItem(ACCESS_KEY_STORAGE, trimmed);
|
|
44
|
-
else localStorage.removeItem(ACCESS_KEY_STORAGE);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function authHeaders() {
|
|
48
|
-
const key = getAccessKey();
|
|
49
|
-
return key ? { authorization: `Bearer ${key}` } : {};
|
|
50
|
-
}
|
|
9
|
+
// 0.3.0 briefly stored a raw self-hosted key here. Remove that legacy value on
|
|
10
|
+
// first load so upgrading also removes the old secret rather than merely ignoring it.
|
|
11
|
+
try { localStorage.removeItem('onboarder.accessKey'); } catch { /* storage may be unavailable */ }
|
|
51
12
|
|
|
52
13
|
async function postJSON(url, body) {
|
|
53
14
|
const res = await fetch(url, {
|
|
54
15
|
method: 'POST',
|
|
55
|
-
headers: { 'content-type': 'application/json'
|
|
16
|
+
headers: { 'content-type': 'application/json' },
|
|
56
17
|
body: JSON.stringify(body),
|
|
57
18
|
});
|
|
58
19
|
let data = null;
|
|
@@ -73,14 +34,14 @@ export function scanOnServer(payload) {
|
|
|
73
34
|
|
|
74
35
|
export async function cleanupClone(cloneId) {
|
|
75
36
|
try {
|
|
76
|
-
await fetch('/api/scan/' + encodeURIComponent(cloneId), { method: 'DELETE'
|
|
37
|
+
await fetch('/api/scan/' + encodeURIComponent(cloneId), { method: 'DELETE' });
|
|
77
38
|
} catch {
|
|
78
39
|
/* best-effort: the temp dir expires on its own eventually */
|
|
79
40
|
}
|
|
80
41
|
}
|
|
81
42
|
|
|
82
43
|
export async function fetchFileText(scanId, path) {
|
|
83
|
-
const res = await fetch('/api/file?scan=' + encodeURIComponent(scanId) + '&path=' + encodeURIComponent(path)
|
|
44
|
+
const res = await fetch('/api/file?scan=' + encodeURIComponent(scanId) + '&path=' + encodeURIComponent(path));
|
|
84
45
|
if (!res.ok) throw new Error('Could not read that file from the server.');
|
|
85
46
|
return res.text();
|
|
86
47
|
}
|
|
@@ -90,7 +51,7 @@ export async function fetchFileText(scanId, path) {
|
|
|
90
51
|
// tool at, and this returns that honestly instead of pretending.
|
|
91
52
|
export async function fetchToolsStatus() {
|
|
92
53
|
try {
|
|
93
|
-
const res = await fetch('/api/tools'
|
|
54
|
+
const res = await fetch('/api/tools');
|
|
94
55
|
if (!res.ok) return null;
|
|
95
56
|
const data = await res.json();
|
|
96
57
|
return data && data.tools ? data.tools : null;
|
|
@@ -122,7 +83,7 @@ export async function streamToolInstall(tool, onEvent) {
|
|
|
122
83
|
const emit = typeof onEvent === 'function' ? onEvent : () => {};
|
|
123
84
|
const res = await fetch('/api/tools/install', {
|
|
124
85
|
method: 'POST',
|
|
125
|
-
headers: { 'content-type': 'application/json'
|
|
86
|
+
headers: { 'content-type': 'application/json' },
|
|
126
87
|
body: JSON.stringify({ tool }),
|
|
127
88
|
});
|
|
128
89
|
if (!res.ok || !res.body) {
|
|
@@ -160,7 +121,7 @@ export async function streamToolInstall(tool, onEvent) {
|
|
|
160
121
|
// this machine, which is exactly the side effect `postJSON` is here for.
|
|
161
122
|
export async function fetchMcpStatus() {
|
|
162
123
|
try {
|
|
163
|
-
const res = await fetch('/api/mcp'
|
|
124
|
+
const res = await fetch('/api/mcp');
|
|
164
125
|
if (!res.ok) return null;
|
|
165
126
|
return await res.json();
|
|
166
127
|
} catch {
|
|
@@ -181,7 +142,7 @@ export function stopMcpServer() {
|
|
|
181
142
|
// than on every poll.
|
|
182
143
|
export async function fetchMcpCommand() {
|
|
183
144
|
try {
|
|
184
|
-
const res = await fetch('/api/mcp/command'
|
|
145
|
+
const res = await fetch('/api/mcp/command');
|
|
185
146
|
if (!res.ok) return null;
|
|
186
147
|
return await res.json();
|
|
187
148
|
} catch {
|
|
@@ -195,7 +156,7 @@ export async function fetchMcpCommand() {
|
|
|
195
156
|
export async function* streamExplain({ baseUrl, apiKey, model, messages, maxTokens = 1200, providerOptions = {} }) {
|
|
196
157
|
const res = await fetch('/api/explain', {
|
|
197
158
|
method: 'POST',
|
|
198
|
-
headers: { 'content-type': 'application/json'
|
|
159
|
+
headers: { 'content-type': 'application/json' },
|
|
199
160
|
body: JSON.stringify({ baseUrl, apiKey, model, messages, stream: true, max_tokens: maxTokens, ...providerOptions }),
|
|
200
161
|
});
|
|
201
162
|
|
|
@@ -241,7 +202,7 @@ export async function* streamExplain({ baseUrl, apiKey, model, messages, maxToke
|
|
|
241
202
|
// rotation is a POST because it mints a new key on the server — the one and
|
|
242
203
|
// only time a key ever crosses the wire in the clear.
|
|
243
204
|
export async function fetchServerSettings() {
|
|
244
|
-
const res = await fetch('/api/settings'
|
|
205
|
+
const res = await fetch('/api/settings');
|
|
245
206
|
const data = await res.json().catch(() => null);
|
|
246
207
|
if (!res.ok) throw new Error(data?.error || `The server said ${res.status}.`);
|
|
247
208
|
return data;
|
|
@@ -250,7 +211,7 @@ export async function fetchServerSettings() {
|
|
|
250
211
|
export async function updateServerSettings(patch) {
|
|
251
212
|
const res = await fetch('/api/settings', {
|
|
252
213
|
method: 'PUT',
|
|
253
|
-
headers: { 'content-type': 'application/json'
|
|
214
|
+
headers: { 'content-type': 'application/json' },
|
|
254
215
|
body: JSON.stringify(patch),
|
|
255
216
|
});
|
|
256
217
|
const data = await res.json().catch(() => null);
|
|
@@ -261,3 +222,7 @@ export async function updateServerSettings(patch) {
|
|
|
261
222
|
export function rotateServerAccessKey() {
|
|
262
223
|
return postJSON('/api/settings/access-key', {});
|
|
263
224
|
}
|
|
225
|
+
|
|
226
|
+
export function logoutRemoteSession() {
|
|
227
|
+
return postJSON('/api/auth/logout', {});
|
|
228
|
+
}
|