codebase-onboarder 0.1.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 +36 -9
- package/cli/commands.js +224 -13
- package/cli/main.js +29 -4
- package/cli/prompt.js +5 -0
- package/cli/wizard.js +41 -11
- package/package.json +1 -1
- package/public/index.html +8 -1
- package/public/js/api.js +0 -1
- package/public/js/serverSettings.js +17 -6
- package/server/apiSettings.js +8 -4
- package/server/config.js +49 -5
- package/server/httpGuards.js +4 -0
- package/server/https.js +142 -0
- package/server/index.js +49 -11
- package/server/mcp/analysis.js +0 -1
- package/server/pidfile.js +49 -0
- package/server/tunnel.js +5 -0
- package/server/.fuse_hidden0000000800000001 +0 -36
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,20 +202,45 @@ 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; 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
|
-
onboarder setup # interactive wizard
|
|
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
|
-
onboarder config set
|
|
212
|
+
onboarder config set host 0.0.0.0 # direct VPS/LAN access (domain optional)
|
|
213
|
+
onboarder config set port 4311 # move away from a busy port
|
|
213
214
|
onboarder config key rotate # mint a new access key
|
|
215
|
+
onboarder status # running PID, stopped, or unmanaged port owner
|
|
216
|
+
onboarder stop # stop a PID-file-managed instance
|
|
217
|
+
onboarder restart # graceful stop, then start
|
|
214
218
|
onboarder tunnel cloudflare # expose via a Cloudflare quick tunnel
|
|
215
|
-
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
|
|
216
224
|
```
|
|
217
225
|
|
|
218
|
-
|
|
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.
|
|
240
|
+
|
|
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.
|
|
242
|
+
|
|
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.
|
|
219
244
|
|
|
220
245
|
---
|
|
221
246
|
|
|
@@ -228,9 +253,11 @@ codebase-onboarder/
|
|
|
228
253
|
├── server/ # Zero-dependency Node.js HTTP server
|
|
229
254
|
│ ├── index.js # createServer / startServer / startup banner
|
|
230
255
|
│ ├── config.js # Settings schema, normalization, atomic 0600 writes
|
|
256
|
+
│ ├── pidfile.js # PID ownership for status / stop / restart
|
|
231
257
|
│ ├── router.js # Route table, live per-request settings, Bearer gate
|
|
232
258
|
│ ├── apiSettings.js# GET/PUT /api/settings, key rotation
|
|
233
259
|
│ ├── tunnel.js # Cloudflare & Tailscale status/commands
|
|
260
|
+
│ ├── https.js # Caddy config, ACME/TLS readiness & lifecycle
|
|
234
261
|
│ ├── httpGuards.js# Host verification & CSRF/rebinding guards
|
|
235
262
|
│ ├── apiScan.js # Local & remote scan coordination
|
|
236
263
|
│ ├── apiFile.js # Path-traversal safe file serving
|
|
@@ -243,7 +270,7 @@ codebase-onboarder/
|
|
|
243
270
|
│ ├── js/ # Vanilla ES modules (State, Inspector, Views, Settings)
|
|
244
271
|
│ ├── vendor/ # Vendored Mermaid & Monaco Editor (Offline)
|
|
245
272
|
│ └── index.html # Main application interface
|
|
246
|
-
└── tests/ # Comprehensive node:test suite (
|
|
273
|
+
└── tests/ # Comprehensive node:test suite (560 unit tests)
|
|
247
274
|
```
|
|
248
275
|
|
|
249
276
|
---
|
|
@@ -253,7 +280,7 @@ codebase-onboarder/
|
|
|
253
280
|
Onboarder includes a comprehensive automated test suite built with Node's native test runner:
|
|
254
281
|
|
|
255
282
|
```bash
|
|
256
|
-
# Run all
|
|
283
|
+
# Run all 560 tests
|
|
257
284
|
npm test
|
|
258
285
|
```
|
|
259
286
|
|
package/cli/commands.js
CHANGED
|
@@ -15,9 +15,11 @@ import {
|
|
|
15
15
|
publicSettings, serverUrls, maskAccessKey,
|
|
16
16
|
} from '../server/config.js';
|
|
17
17
|
import { startServer } from '../server/index.js';
|
|
18
|
+
import { pidIsAlive, readPidFile, removePidFile } from '../server/pidfile.js';
|
|
18
19
|
import { tunnelStatus, cloudflareCommand, tailscaleCommand, installHint, findOnPath } from '../server/tunnel.js';
|
|
20
|
+
import { caddyRun, caddyValidate, httpsReadiness, httpsStatus, writeCaddyfile } from '../server/https.js';
|
|
19
21
|
import { bold, cyan, dim, ok, warn, bad, kv, tick, cross, dash, welcomeBanner } from './ui.js';
|
|
20
|
-
import { buildSteps,
|
|
22
|
+
import { buildSteps, unansweredSteps, defaultOf, applyFlags, answersToSettings, summaryLines } from './wizard.js';
|
|
21
23
|
import { runSteps, WizardCancelled } from './prompt.js';
|
|
22
24
|
|
|
23
25
|
// ---------------------------------------------------------------- setup ---
|
|
@@ -48,7 +50,7 @@ export async function runSetup({ flags = {}, out = console.log, err = console.er
|
|
|
48
50
|
|
|
49
51
|
// Flag answers go in first; the wizard only asks what is left.
|
|
50
52
|
const seed = {};
|
|
51
|
-
for (const id of ['name', 'email', 'mode', 'host', 'port', 'domain', 'autoOpen']) {
|
|
53
|
+
for (const id of ['name', 'email', 'mode', 'host', 'port', 'domain', 'https', 'autoOpen']) {
|
|
52
54
|
if (flags[id] !== undefined) seed[id] = flags[id];
|
|
53
55
|
}
|
|
54
56
|
if (flags.provider !== undefined) seed.provider = flags.provider;
|
|
@@ -57,7 +59,7 @@ export async function runSetup({ flags = {}, out = console.log, err = console.er
|
|
|
57
59
|
|
|
58
60
|
let answers;
|
|
59
61
|
try {
|
|
60
|
-
answers = await runSteps(
|
|
62
|
+
answers = await runSteps(unansweredSteps(buildSteps(current), seed), seed);
|
|
61
63
|
} catch (e) {
|
|
62
64
|
if (e instanceof WizardCancelled) {
|
|
63
65
|
err('\n Setup cancelled — nothing was written.');
|
|
@@ -97,7 +99,9 @@ export async function runSetup({ flags = {}, out = console.log, err = console.er
|
|
|
97
99
|
out('');
|
|
98
100
|
return runStart({ flags, out, err });
|
|
99
101
|
}
|
|
100
|
-
out(dim(
|
|
102
|
+
out(dim(settings.https
|
|
103
|
+
? ' Later: `onboarder start` (starts Caddy automatically), or `onboarder https status`'
|
|
104
|
+
: ' Later: `onboarder start`'));
|
|
101
105
|
return 0;
|
|
102
106
|
}
|
|
103
107
|
|
|
@@ -153,11 +157,121 @@ export async function runStart({ flags = {}, out = console.log, err = console.er
|
|
|
153
157
|
}
|
|
154
158
|
out(dim(` No config at ${file} — starting with defaults (local mode).`));
|
|
155
159
|
}
|
|
156
|
-
const
|
|
157
|
-
if (
|
|
160
|
+
const recorded = readPidFile(file);
|
|
161
|
+
if (recorded && pidIsAlive(recorded)) {
|
|
162
|
+
err(` Onboarder is already running (PID ${recorded}).`);
|
|
163
|
+
err(dim(' Use `onboarder status`, `onboarder stop`, or `onboarder restart`.'));
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
if (recorded) removePidFile(file, recorded);
|
|
167
|
+
|
|
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
|
+
}
|
|
177
|
+
if (flags.json) out(JSON.stringify({ host: started.host, port: started.port, url: serverUrls(started.settings).local }));
|
|
158
178
|
// The listening server holds the event loop; resolve so callers/tests know
|
|
159
179
|
// we are up, but leave the process running.
|
|
160
|
-
return {
|
|
180
|
+
return { ...started, code: 0 };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ------------------------------------------------------------- lifecycle ---
|
|
184
|
+
|
|
185
|
+
export async function runStatus({ flags = {}, out = console.log } = {}) {
|
|
186
|
+
const file = flags.config || configPath();
|
|
187
|
+
const pid = readPidFile(file);
|
|
188
|
+
let running = pid && pidIsAlive(pid);
|
|
189
|
+
let settings = null;
|
|
190
|
+
try { settings = await readSettings(file); } catch { /* defaults are still meaningful */ }
|
|
191
|
+
const portBusy = settings ? !(await portIsFree(settings.host, settings.port)) : false;
|
|
192
|
+
if (pid && !running) removePidFile(file, pid);
|
|
193
|
+
const result = {
|
|
194
|
+
running: Boolean(running || portBusy),
|
|
195
|
+
pid: running ? pid : null,
|
|
196
|
+
port: settings ? `${settings.host}:${settings.port}` : null,
|
|
197
|
+
portBusy,
|
|
198
|
+
managed: Boolean(running),
|
|
199
|
+
configFile: file,
|
|
200
|
+
};
|
|
201
|
+
if (flags.json) {
|
|
202
|
+
out(JSON.stringify(result, null, 2));
|
|
203
|
+
} else {
|
|
204
|
+
out('');
|
|
205
|
+
out(bold(' Onboarder status'));
|
|
206
|
+
if (running) out(`${tick}Running PID ${pid}`);
|
|
207
|
+
else if (portBusy) out(`${warn('!')}Port busy ${result.port} (no Onboarder PID record)`);
|
|
208
|
+
else out(`${dash}Stopped`);
|
|
209
|
+
out(kv('Config', file));
|
|
210
|
+
if (result.portBusy && !running) out(dim(' Inspect it with `ss -ltnp` or `lsof -i :' + settings.port + '` before stopping another process.'));
|
|
211
|
+
}
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function waitForExit(pid, timeoutMs = 5000) {
|
|
216
|
+
return new Promise((resolve) => {
|
|
217
|
+
const started = Date.now();
|
|
218
|
+
const poll = () => {
|
|
219
|
+
if (!pidIsAlive(pid)) return resolve(true);
|
|
220
|
+
if (Date.now() - started >= timeoutMs) return resolve(false);
|
|
221
|
+
setTimeout(poll, 100);
|
|
222
|
+
};
|
|
223
|
+
poll();
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function runStop({ flags = {}, out = console.log, err = console.error } = {}) {
|
|
228
|
+
const file = flags.config || configPath();
|
|
229
|
+
const pid = readPidFile(file);
|
|
230
|
+
if (!pid) {
|
|
231
|
+
const settings = await readSettings(file);
|
|
232
|
+
const portBusy = !(await portIsFree(settings.host, settings.port));
|
|
233
|
+
if (flags.json) out(JSON.stringify({ stopped: false, reason: portBusy ? 'unmanaged busy port' : 'not running', port: `${settings.host}:${settings.port}` }, null, 2));
|
|
234
|
+
else if (portBusy) {
|
|
235
|
+
out(`${warn('!')}Port busy ${settings.host}:${settings.port} — no Onboarder process record.`);
|
|
236
|
+
out(dim(' Inspect it with `ss -ltnp` or `lsof -i :' + settings.port + '`; `onboarder stop` will not kill an unmanaged process.'));
|
|
237
|
+
} else {
|
|
238
|
+
out(`${dash}Not running.`);
|
|
239
|
+
}
|
|
240
|
+
return portBusy ? 1 : 0;
|
|
241
|
+
}
|
|
242
|
+
if (!pidIsAlive(pid)) {
|
|
243
|
+
removePidFile(file, pid);
|
|
244
|
+
if (flags.json) out(JSON.stringify({ stopped: true, pid, stale: true }, null, 2));
|
|
245
|
+
else out(tick + `Removed stale process record for PID ${pid}.`);
|
|
246
|
+
return 0;
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
process.kill(pid, 'SIGTERM');
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (error?.code !== 'ESRCH') throw error;
|
|
252
|
+
}
|
|
253
|
+
const stopped = await waitForExit(pid);
|
|
254
|
+
if (!stopped && pidIsAlive(pid)) {
|
|
255
|
+
err(` PID ${pid} did not stop after SIGTERM.`);
|
|
256
|
+
err(dim(' Check it with `ps -p ' + pid + ' -f`, then stop it only if it really is Onboarder.'));
|
|
257
|
+
return 1;
|
|
258
|
+
}
|
|
259
|
+
removePidFile(file, pid);
|
|
260
|
+
if (flags.json) out(JSON.stringify({ stopped: true, pid }, null, 2));
|
|
261
|
+
else out(tick + `Stopped Onboarder (PID ${pid}).`);
|
|
262
|
+
return 0;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export async function runRestart(options = {}) {
|
|
266
|
+
const file = options.flags?.config || configPath();
|
|
267
|
+
const pid = readPidFile(file);
|
|
268
|
+
if (pid && pidIsAlive(pid)) {
|
|
269
|
+
const stopped = await runStop(options);
|
|
270
|
+
if (stopped !== 0) return stopped;
|
|
271
|
+
} else if (pid) {
|
|
272
|
+
removePidFile(file, pid);
|
|
273
|
+
}
|
|
274
|
+
return runStart(options);
|
|
161
275
|
}
|
|
162
276
|
|
|
163
277
|
// --------------------------------------------------------------- config ---
|
|
@@ -169,6 +283,7 @@ const SETTABLE = {
|
|
|
169
283
|
host: (v) => v,
|
|
170
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; },
|
|
171
285
|
domain: (v) => v,
|
|
286
|
+
https: (v) => ['true', 'yes', '1', 'on'].includes(String(v).toLowerCase()),
|
|
172
287
|
autoOpen: (v) => ['true', 'yes', '1', 'on'].includes(String(v).toLowerCase()),
|
|
173
288
|
'account.name': (v) => v,
|
|
174
289
|
'account.email': (v) => v,
|
|
@@ -210,6 +325,7 @@ export async function runConfig(sub, args, { flags = {}, out = console.log } = {
|
|
|
210
325
|
out(kv('Mode', settings.mode));
|
|
211
326
|
out(kv('Bind', `${settings.host}:${settings.port}`));
|
|
212
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'));
|
|
213
329
|
out(kv('Local', urls.local));
|
|
214
330
|
if (urls.network) out(kv('Network', urls.network));
|
|
215
331
|
if (urls.domain) out(kv('Public', urls.domain));
|
|
@@ -341,17 +457,27 @@ export async function runDoctor({ flags = {}, out = console.log } = {}) {
|
|
|
341
457
|
});
|
|
342
458
|
const loopback = ['127.0.0.1', 'localhost', '::1', '[::1]'].includes(settings.host);
|
|
343
459
|
checks.push({
|
|
344
|
-
id: 'domain', ok:
|
|
345
|
-
detail: settings.domain || (loopback ? '(none — a tunnel provides the name)' : '
|
|
460
|
+
id: 'domain', ok: true, required: false,
|
|
461
|
+
detail: settings.domain || (loopback ? '(none — a tunnel or local reverse proxy provides the name)' : '(none — visitors can connect by server IP)'),
|
|
346
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
|
+
}
|
|
347
469
|
}
|
|
348
470
|
}
|
|
349
471
|
|
|
350
|
-
for (const name of ['git', 'cloudflared', 'tailscale']) {
|
|
351
|
-
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']);
|
|
352
476
|
const found = Boolean(findOnPath(name));
|
|
353
477
|
checks.push({
|
|
354
|
-
id: name,
|
|
478
|
+
id: name,
|
|
479
|
+
ok: found || !wanted,
|
|
480
|
+
required: name === 'git' || (name === 'caddy' && settings?.https),
|
|
355
481
|
detail: found ? 'installed' : wanted ? 'not installed — ' + installHint(name) : 'not installed (not needed for your settings)',
|
|
356
482
|
});
|
|
357
483
|
}
|
|
@@ -378,6 +504,92 @@ function portIsFree(host, port) {
|
|
|
378
504
|
});
|
|
379
505
|
}
|
|
380
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
|
+
|
|
381
593
|
// --------------------------------------------------------------- tunnel ---
|
|
382
594
|
|
|
383
595
|
export async function runTunnel(kind, { flags = {}, out = console.log, err = console.error } = {}) {
|
|
@@ -433,4 +645,3 @@ export async function runTunnel(kind, { flags = {}, out = console.log, err = con
|
|
|
433
645
|
}
|
|
434
646
|
throw new Error('Usage: onboarder tunnel <cloudflare|tailscale>');
|
|
435
647
|
}
|
|
436
|
-
|
package/cli/main.js
CHANGED
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import { parseArgs } from 'node:util';
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
runSetup, runStart, runStatus, runStop, runRestart,
|
|
12
|
+
runConfig, runConfigKey, runConfigReset, runDoctor, runTunnel, runHttps,
|
|
13
|
+
} from './commands.js';
|
|
11
14
|
|
|
12
15
|
const PACKAGE = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
13
16
|
|
|
@@ -18,12 +21,17 @@ const HELP = `
|
|
|
18
21
|
onboarder Start the server (runs setup first if needed)
|
|
19
22
|
onboarder setup | onboard Configure interactively (the wizard)
|
|
20
23
|
onboarder start Start the server
|
|
21
|
-
onboarder
|
|
24
|
+
onboarder status Show whether the server is running
|
|
25
|
+
onboarder stop Stop the running server
|
|
26
|
+
onboarder restart Stop and start again
|
|
27
|
+
onboarder config [<…>] show | get <key> | set <key> <value> | path | reset | key <rotate|show|set>
|
|
22
28
|
onboarder tunnel <name> cloudflare | tailscale
|
|
29
|
+
onboarder https <action> check | setup | start | stop | status
|
|
23
30
|
onboarder doctor Check the machine and the config
|
|
24
31
|
|
|
25
32
|
Setup flags (interactive wizard skips what they answer)
|
|
26
33
|
--mode local|self-hosted --host <addr> --port <n> --domain <name>
|
|
34
|
+
--https Set up automatic HTTPS through Caddy
|
|
27
35
|
--access-key generate|<k> --name <n> --email <e>
|
|
28
36
|
--provider none|openai-compatible|ollama|openrouter|custom
|
|
29
37
|
--base-url <url> --model <m> --cloudflare --tailscale
|
|
@@ -37,10 +45,15 @@ const HELP = `
|
|
|
37
45
|
--no-color Plain output (NO_COLOR works too)
|
|
38
46
|
-h, --help This text -v, --version Print the version
|
|
39
47
|
|
|
48
|
+
Also accepted
|
|
49
|
+
help | --help onboarder help | onboarder --help
|
|
50
|
+
config | config show
|
|
51
|
+
|
|
40
52
|
Examples
|
|
41
53
|
onboarder setup
|
|
42
54
|
onboarder setup --non-interactive --mode local --port 4310
|
|
43
|
-
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
|
|
44
57
|
onboarder config set tunnel.cloudflare true && onboarder tunnel cloudflare
|
|
45
58
|
`;
|
|
46
59
|
|
|
@@ -59,6 +72,7 @@ const OPTIONS = {
|
|
|
59
72
|
host: { type: 'string' },
|
|
60
73
|
port: { type: 'string' },
|
|
61
74
|
domain: { type: 'string' },
|
|
75
|
+
https: { type: 'boolean' },
|
|
62
76
|
'access-key': { type: 'string' },
|
|
63
77
|
name: { type: 'string' },
|
|
64
78
|
email: { type: 'string' },
|
|
@@ -97,9 +111,18 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
97
111
|
const [cmd, sub, ...rest] = positionals;
|
|
98
112
|
try {
|
|
99
113
|
switch (cmd) {
|
|
114
|
+
case 'help':
|
|
115
|
+
console.log(HELP);
|
|
116
|
+
return 0;
|
|
100
117
|
case undefined:
|
|
101
118
|
case 'start':
|
|
102
119
|
return codeOf(await runStart({ flags }));
|
|
120
|
+
case 'status':
|
|
121
|
+
return codeOf(await runStatus({ flags }));
|
|
122
|
+
case 'stop':
|
|
123
|
+
return codeOf(await runStop({ flags }));
|
|
124
|
+
case 'restart':
|
|
125
|
+
return codeOf(await runRestart({ flags }));
|
|
103
126
|
case 'setup':
|
|
104
127
|
case 'onboard':
|
|
105
128
|
case 'init':
|
|
@@ -107,9 +130,11 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
107
130
|
case 'config':
|
|
108
131
|
if (sub === 'key') return codeOf(await runConfigKey(rest[0], rest.slice(1), { flags }));
|
|
109
132
|
if (sub === 'reset') return codeOf(await runConfigReset({ flags }));
|
|
110
|
-
return codeOf(await runConfig(sub, rest, { flags }));
|
|
133
|
+
return codeOf(await runConfig(sub || 'show', rest, { flags }));
|
|
111
134
|
case 'tunnel':
|
|
112
135
|
return codeOf(await runTunnel(sub, { flags }));
|
|
136
|
+
case 'https':
|
|
137
|
+
return codeOf(await runHttps(sub || 'status', { flags }));
|
|
113
138
|
case 'doctor':
|
|
114
139
|
return codeOf(await runDoctor({ flags }));
|
|
115
140
|
default:
|
package/cli/prompt.js
CHANGED
|
@@ -32,6 +32,11 @@ export async function runSteps(steps, answers = {}, io = {}) {
|
|
|
32
32
|
try {
|
|
33
33
|
let lastSection = null;
|
|
34
34
|
for (const step of steps) {
|
|
35
|
+
// `when` is evaluated here, one question at a time — never upfront.
|
|
36
|
+
// Conditions read answers collected earlier in the same run ("ask about
|
|
37
|
+
// the bind only when mode is self-hosted"), so filtering the list
|
|
38
|
+
// before the first question would prune branches the answers reopen.
|
|
39
|
+
if (step.when && !step.when(answers)) continue;
|
|
35
40
|
if (step.section && step.section !== lastSection) {
|
|
36
41
|
output.write(section(step.section) + '\n');
|
|
37
42
|
lastSection = step.section;
|
package/cli/wizard.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import os from 'node:os';
|
|
14
14
|
|
|
15
|
-
import { DEFAULT_SETTINGS, generateAccessKey, normalizeSettings } from '../server/config.js';
|
|
15
|
+
import { DEFAULT_SETTINGS, generateAccessKey, isLoopbackHost, normalizeSettings } from '../server/config.js';
|
|
16
16
|
|
|
17
17
|
// Provider presets for the AI account step. `none` is the honest default: the
|
|
18
18
|
// app works fully offline, and the browser can still hold its own key.
|
|
@@ -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',
|
|
@@ -190,6 +202,16 @@ export function pendingSteps(steps, answers = {}) {
|
|
|
190
202
|
return steps.filter((s) => !(s.id in answers) && (!s.when || s.when(answers)));
|
|
191
203
|
}
|
|
192
204
|
|
|
205
|
+
// Steps the flags did not answer, in order — and nothing more. Unlike
|
|
206
|
+
// `pendingSteps` this never looks at `when`: conditions read answers that
|
|
207
|
+
// only exist mid-run ("ask about the bind once mode is self-hosted"), so
|
|
208
|
+
// evaluating them before the first question prunes branches the typed answers
|
|
209
|
+
// would reopen. The renderer (`prompt.js#runSteps`) evaluates `when` live,
|
|
210
|
+
// one question at a time, which is the only moment it can be answered truly.
|
|
211
|
+
export function unansweredSteps(steps, answers = {}) {
|
|
212
|
+
return steps.filter((s) => !(s.id in answers));
|
|
213
|
+
}
|
|
214
|
+
|
|
193
215
|
// Defaults may depend on earlier answers (`default` as a function).
|
|
194
216
|
export function defaultOf(step, answers) {
|
|
195
217
|
return typeof step.default === 'function' ? step.default(answers) : step.default;
|
|
@@ -221,9 +243,10 @@ export function answersToSettings(current, answers) {
|
|
|
221
243
|
return normalizeSettings({
|
|
222
244
|
version: current.version,
|
|
223
245
|
mode: selfHosted ? 'self-hosted' : 'local',
|
|
224
|
-
host: selfHosted ? (answers.host ||
|
|
246
|
+
host: selfHosted ? (answers.host || '0.0.0.0') : '127.0.0.1',
|
|
225
247
|
port: Number(answers.port ?? current.port ?? DEFAULT_SETTINGS.port),
|
|
226
248
|
domain: selfHosted ? String(answers.domain ?? current.domain ?? '').trim().toLowerCase() : '',
|
|
249
|
+
https: selfHosted && Boolean(answers.domain) && Boolean(answers.https),
|
|
227
250
|
accessKey,
|
|
228
251
|
autoOpen: answers.autoOpen ?? current.autoOpen ?? false,
|
|
229
252
|
account,
|
|
@@ -255,6 +278,7 @@ export function applyFlags(current, flags = {}) {
|
|
|
255
278
|
if (err) throw new Error('--domain: ' + err);
|
|
256
279
|
patch.domain = String(flags.domain).trim().toLowerCase();
|
|
257
280
|
}
|
|
281
|
+
if (flags.https !== undefined) patch.https = Boolean(flags.https);
|
|
258
282
|
if (flags.accessKey === 'generate') patch.accessKey = generateAccessKey();
|
|
259
283
|
else if (flags.accessKey !== undefined) {
|
|
260
284
|
if (String(flags.accessKey).trim().length < 16) throw new Error('--access-key must be at least 16 characters, or "generate".');
|
|
@@ -290,11 +314,15 @@ export function applyFlags(current, flags = {}) {
|
|
|
290
314
|
account: { ...(current.account || DEFAULT_SETTINGS.account), ...account },
|
|
291
315
|
tunnel: { ...(current.tunnel || DEFAULT_SETTINGS.tunnel), ...tunnel },
|
|
292
316
|
};
|
|
293
|
-
//
|
|
294
|
-
//
|
|
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
|
+
}
|
|
295
322
|
if (merged.mode === 'local') {
|
|
296
323
|
if (!['127.0.0.1', 'localhost', '::1', '[::1]'].includes(merged.host)) merged.host = '127.0.0.1';
|
|
297
324
|
if (flags.domain === undefined) merged.domain = '';
|
|
325
|
+
if (flags.https === undefined) merged.https = false;
|
|
298
326
|
}
|
|
299
327
|
return { patch, settings: normalizeSettings(merged) };
|
|
300
328
|
}
|
|
@@ -306,11 +334,14 @@ export function summaryLines(settings, { revealKey = false } = {}) {
|
|
|
306
334
|
['Mode', settings.mode],
|
|
307
335
|
['Bind', `${settings.host}:${settings.port}`],
|
|
308
336
|
];
|
|
309
|
-
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)]);
|
|
310
338
|
if (settings.mode === 'self-hosted') {
|
|
311
339
|
lines.push(['Access key', revealKey
|
|
312
340
|
? settings.accessKey
|
|
313
341
|
: (settings.accessKey ? settings.accessKey.slice(0, 6) + '… (hidden)' : '(none — the API will refuse every call)')]);
|
|
342
|
+
if (isLoopbackHost(settings.host)) {
|
|
343
|
+
lines.push(['Network', 'loopback only — use a tunnel or bind 0.0.0.0 for direct access']);
|
|
344
|
+
}
|
|
314
345
|
}
|
|
315
346
|
const who = [settings.account.name, settings.account.email].filter(Boolean).join(' · ');
|
|
316
347
|
if (who) lines.push(['Profile', who]);
|
|
@@ -322,4 +353,3 @@ export function summaryLines(settings, { revealKey = false } = {}) {
|
|
|
322
353
|
lines.push(['Auto-open', settings.autoOpen ? 'yes' : 'no']);
|
|
323
354
|
return lines;
|
|
324
355
|
}
|
|
325
|
-
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codebase-onboarder",
|
|
3
|
-
"version": "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
|
|
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>
|
package/public/js/api.js
CHANGED
|
@@ -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')
|
|
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
|
-
|
|
124
|
-
?
|
|
125
|
-
:
|
|
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);
|
|
@@ -166,4 +178,3 @@ export function createServerDrawer(options = {}) {
|
|
|
166
178
|
isOpen: () => !dom.serverDrawer.hidden,
|
|
167
179
|
};
|
|
168
180
|
}
|
|
169
|
-
|
package/server/apiSettings.js
CHANGED
|
@@ -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.
|
|
52
|
-
//
|
|
53
|
-
//
|
|
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 =
|
|
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,9 +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
|
}
|
|
94
|
-
if (settings.
|
|
95
|
-
throw new Error('
|
|
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.');
|
|
96
101
|
}
|
|
102
|
+
// Self-hosted needs no domain: a bare-IP bind (a VPS with no DNS name) is a
|
|
103
|
+
// legitimate layout. Every API call needs the access key either way.
|
|
97
104
|
return settings;
|
|
98
105
|
}
|
|
99
106
|
|
|
@@ -112,7 +119,7 @@ export function serverUrls(value = DEFAULT_SETTINGS) {
|
|
|
112
119
|
? `http://<this-machine>:${settings.port} (every interface)`
|
|
113
120
|
: `http://${settings.host}:${settings.port}`;
|
|
114
121
|
}
|
|
115
|
-
if (settings.domain) urls.domain = `https://${settings.domain}`;
|
|
122
|
+
if (settings.domain) urls.domain = settings.https ? `https://${settings.domain}` : `http://${settings.domain}:${settings.port}`;
|
|
116
123
|
return urls;
|
|
117
124
|
}
|
|
118
125
|
|
|
@@ -123,6 +130,12 @@ export function maskAccessKey(key) {
|
|
|
123
130
|
return `${value.slice(0, 6)}…${value.slice(-4)}`;
|
|
124
131
|
}
|
|
125
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
|
+
|
|
126
139
|
export function publicSettings(value = DEFAULT_SETTINGS) {
|
|
127
140
|
const settings = normalizeSettings(value);
|
|
128
141
|
return {
|
|
@@ -131,6 +144,7 @@ export function publicSettings(value = DEFAULT_SETTINGS) {
|
|
|
131
144
|
host: settings.host,
|
|
132
145
|
port: settings.port,
|
|
133
146
|
domain: settings.domain,
|
|
147
|
+
https: settings.https,
|
|
134
148
|
autoOpen: settings.autoOpen,
|
|
135
149
|
hasAccessKey: Boolean(settings.accessKey),
|
|
136
150
|
accessKeyMasked: maskAccessKey(settings.accessKey),
|
|
@@ -154,11 +168,41 @@ export function publicSettings(value = DEFAULT_SETTINGS) {
|
|
|
154
168
|
// safe because it can only widen Host acceptance in self-hosted mode, where
|
|
155
169
|
// every API call already needs the access key — the guard's job there is
|
|
156
170
|
// keeping drive-by traffic off the static files, not authentication.
|
|
171
|
+
// A wildcard bind (0.0.0.0 / ::) has no name of its own, so the names it
|
|
172
|
+
// honestly answers to are this machine's own interface addresses — a request
|
|
173
|
+
// addressed to 10.0.0.2 or a public VPS IP really did arrive here. Read at
|
|
174
|
+
// call time (the router asks per request), so a DHCP lease change does not
|
|
175
|
+
// strand the guard on a stale address.
|
|
176
|
+
function ownInterfaceHosts() {
|
|
177
|
+
const hosts = [];
|
|
178
|
+
for (const list of Object.values(os.networkInterfaces())) {
|
|
179
|
+
for (const addr of list || []) {
|
|
180
|
+
if (addr.internal) continue; // loopback is always allowed anyway
|
|
181
|
+
hosts.push(addr.address);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return hosts;
|
|
185
|
+
}
|
|
186
|
+
|
|
157
187
|
export function allowedHosts(value = DEFAULT_SETTINGS) {
|
|
158
188
|
const settings = normalizeSettings(value);
|
|
159
189
|
const hosts = new Set();
|
|
160
190
|
if (settings.domain) hosts.add(settings.domain);
|
|
161
|
-
if (!isLoopbackHost(settings.host))
|
|
191
|
+
if (!isLoopbackHost(settings.host)) {
|
|
192
|
+
// A wildcard bind is useful as a literal Host value to diagnostics, even
|
|
193
|
+
// though browsers normally address the machine by one of its real IPs.
|
|
194
|
+
hosts.add(settings.host.toLowerCase());
|
|
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);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
162
206
|
if (settings.tunnel.cloudflare) hosts.add('*.trycloudflare.com');
|
|
163
207
|
if (settings.tunnel.tailscale) hosts.add('*.ts.net');
|
|
164
208
|
return [...hosts];
|
package/server/httpGuards.js
CHANGED
|
@@ -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".
|
package/server/https.js
ADDED
|
@@ -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,8 +21,9 @@ 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, readSettings, serverUrls } from './config.js';
|
|
24
|
+
import { browserUrl, configPath, isLoopbackHost, readSettings, serverUrls } from './config.js';
|
|
25
25
|
import { tunnelStatus } from './tunnel.js';
|
|
26
|
+
import { pidIsAlive, readPidFile, removePidFile, writePidFile } from './pidfile.js';
|
|
26
27
|
|
|
27
28
|
const logger = createLogger();
|
|
28
29
|
|
|
@@ -66,11 +67,23 @@ export function startupBanner(settings, { configFile } = {}) {
|
|
|
66
67
|
const urls = serverUrls(settings);
|
|
67
68
|
const lines = ['', ' Onboarder is up.'];
|
|
68
69
|
lines.push(settings.mode === 'self-hosted'
|
|
69
|
-
?
|
|
70
|
+
? (isLoopbackHost(settings.host)
|
|
71
|
+
? ' Mode self-hosted — loopback bind; use a tunnel or change the host for direct network access'
|
|
72
|
+
: ' Mode self-hosted — the network can reach this; every API call needs the access key')
|
|
70
73
|
: ' Mode local — only this machine can reach it');
|
|
71
74
|
lines.push(' Local ' + urls.local);
|
|
72
75
|
if (urls.network) lines.push(' Network ' + urls.network);
|
|
73
|
-
if (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
|
+
}
|
|
74
87
|
if (settings.mode === 'self-hosted' && !settings.accessKey) {
|
|
75
88
|
lines.push(' WARNING self-hosted with no access key — every API call is refused until one is set.');
|
|
76
89
|
lines.push(' Run `onboarder setup` or `onboarder config key rotate`.');
|
|
@@ -102,6 +115,17 @@ export function openInBrowser(url) {
|
|
|
102
115
|
}
|
|
103
116
|
}
|
|
104
117
|
|
|
118
|
+
export function listenError(error, { host, port, pid = null } = {}) {
|
|
119
|
+
if (error?.code === 'EADDRINUSE') {
|
|
120
|
+
const owner = pid ? ` Another Onboarder process is running as PID ${pid}.` : '';
|
|
121
|
+
return new Error(`${host}:${port} is already in use.${owner} Try \`onboarder status\`, \`onboarder stop\`, or choose another port with \`onboarder config set port <number>\`.`, { cause: error });
|
|
122
|
+
}
|
|
123
|
+
if (error?.code === 'EACCES') {
|
|
124
|
+
return new Error(`Cannot bind ${host}:${port}: permission denied. Ports below 1024 normally require elevated privileges; choose a port above 1024.`, { cause: error });
|
|
125
|
+
}
|
|
126
|
+
return error;
|
|
127
|
+
}
|
|
128
|
+
|
|
105
129
|
// The real boot: read the settings, bind what they say, print the banner.
|
|
106
130
|
// Both `node server/index.js` and `onboarder start` land here.
|
|
107
131
|
export async function startServer({ configFile = configPath(), openBrowser, log = console.log } = {}) {
|
|
@@ -116,7 +140,7 @@ export async function startServer({ configFile = configPath(), openBrowser, log
|
|
|
116
140
|
...CONFIG,
|
|
117
141
|
configPath: configFile,
|
|
118
142
|
getSettings: () => readSettings(configFile),
|
|
119
|
-
boot: { host, port },
|
|
143
|
+
boot: { host, port, domain: settings.domain, https: settings.https },
|
|
120
144
|
});
|
|
121
145
|
|
|
122
146
|
installExitCleanup();
|
|
@@ -130,17 +154,31 @@ export async function startServer({ configFile = configPath(), openBrowser, log
|
|
|
130
154
|
});
|
|
131
155
|
}
|
|
132
156
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
157
|
+
try {
|
|
158
|
+
await new Promise((resolve, reject) => {
|
|
159
|
+
server.once('error', reject);
|
|
160
|
+
server.listen(port, host, resolve);
|
|
161
|
+
});
|
|
162
|
+
} catch (error) {
|
|
163
|
+
const recorded = readPidFile(configFile);
|
|
164
|
+
throw listenError(error, { host, port, pid: recorded && pidIsAlive(recorded) ? recorded : null });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
writePidFile(configFile);
|
|
169
|
+
server.once('close', () => removePidFile(configFile));
|
|
170
|
+
process.once('exit', () => removePidFile(configFile));
|
|
171
|
+
} catch {
|
|
172
|
+
// The server is useful even when a read-only config directory cannot hold
|
|
173
|
+
// the optional process record; binding and serving are the real contract.
|
|
174
|
+
}
|
|
137
175
|
|
|
138
176
|
log(startupBanner(live, { configFile }));
|
|
139
177
|
|
|
140
178
|
const shouldOpen = openBrowser ?? (live.autoOpen && process.stdout.isTTY && !process.env.NO_OPEN);
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
if (shouldOpen) openInBrowser(
|
|
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));
|
|
144
182
|
return { server, settings: live, host, port };
|
|
145
183
|
}
|
|
146
184
|
|
package/server/mcp/analysis.js
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Who is serving? One small file next to the config: `onboarder.pid`.
|
|
2
|
+
//
|
|
3
|
+
// The auto-open flow starts the server detached from the terminal that asked
|
|
4
|
+
// for it, which makes "is it running?" a question ps(1) answers badly. The
|
|
5
|
+
// pidfile is the answer the CLI can act on: `onboarder status` reads it,
|
|
6
|
+
// `onboarder stop` kills it, and a busy-port error can say "that is us,
|
|
7
|
+
// pid N" instead of shrugging. Writes are best-effort — a read-only config
|
|
8
|
+
// dir must never stop the server from booting.
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
export function pidPath(configFile) {
|
|
14
|
+
return path.join(path.dirname(path.resolve(configFile)), 'onboarder.pid');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The pid in the file, or null when there is no file or it is garbage. A
|
|
18
|
+
// garbage file is treated as absent rather than parsed charitably — it was
|
|
19
|
+
// either written by us (one integer) or it is not ours to interpret.
|
|
20
|
+
export function readPidFile(configFile) {
|
|
21
|
+
try {
|
|
22
|
+
const pid = Number(fs.readFileSync(pidPath(configFile), 'utf8').trim());
|
|
23
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Signal 0 probes existence without delivering anything. EPERM means the
|
|
30
|
+
// process exists but belongs to someone else — still alive, just not ours
|
|
31
|
+
// to signal.
|
|
32
|
+
export function pidIsAlive(pid) {
|
|
33
|
+
try {
|
|
34
|
+
process.kill(pid, 0);
|
|
35
|
+
return true;
|
|
36
|
+
} catch (e) {
|
|
37
|
+
return e.code === 'EPERM';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function writePidFile(configFile) {
|
|
42
|
+
fs.writeFileSync(pidPath(configFile), String(process.pid) + '\n', { mode: 0o600 });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Remove the file only when it still points at `pid` — a second server that
|
|
46
|
+
// rewrote the file must not lose its record because the first one exited.
|
|
47
|
+
export function removePidFile(configFile, pid = process.pid) {
|
|
48
|
+
if (readPidFile(configFile) === pid) fs.rmSync(pidPath(configFile), { force: true });
|
|
49
|
+
}
|
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/)'
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
// HTML to readable text, for the docs fetch. Not a parser and not trying to be:
|
|
2
|
-
// the output is fed to a language model as context, so what matters is that the
|
|
3
|
-
// prose survives and the markup, scripts and styles do not.
|
|
4
|
-
//
|
|
5
|
-
// Order is the whole trick. Script and style bodies go first — their contents are
|
|
6
|
-
// text, not tags, so stripping tags first would leave a page of minified
|
|
7
|
-
// JavaScript behind. The title is lifted before the general tag strip, because
|
|
8
|
-
// after it there is no way to tell the title from the first paragraph. Entities
|
|
9
|
-
// are decoded last, so a `<script>` written *about* HTML in the docs
|
|
10
|
-
// cannot turn back into markup that the earlier passes would have removed.
|
|
11
|
-
|
|
12
|
-
// Enough context for a model to summarise from without sending a whole site.
|
|
13
|
-
const MAX_TEXT = 14000;
|
|
14
|
-
|
|
15
|
-
const ENTITIES = {
|
|
16
|
-
' ': ' ', '&': '&', '<': '<', '>': '>', '"': '"', ''': "'",
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
export function htmlToText(html, limit = MAX_TEXT) {
|
|
20
|
-
let s = String(html);
|
|
21
|
-
s = s.replace(/<script[\s\S]*?<\/script>/gi, ' ');
|
|
22
|
-
s = s.replace(/<style[\s\S]*?<\/style>/gi, ' ');
|
|
23
|
-
|
|
24
|
-
const rawTitle = (s.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [, ''])[1];
|
|
25
|
-
const title = collapse(rawTitle.replace(/<[^>]+>/g, ' '));
|
|
26
|
-
|
|
27
|
-
s = s.replace(/<[^>]+>/g, ' ');
|
|
28
|
-
s = s.replace(/ |&|<|>|"|'/g, (m) => ENTITIES[m]);
|
|
29
|
-
// Horizontal whitespace collapses but newlines survive as paragraph breaks:
|
|
30
|
-
// a docs page read as one long line loses the structure a summary needs.
|
|
31
|
-
s = s.replace(/[ \t ]+/g, ' ').replace(/\n\s*\n+/g, '\n\n').trim();
|
|
32
|
-
|
|
33
|
-
return { title, text: s.slice(0, limit) };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const collapse = (s) => s.replace(/\s+/g, ' ').trim();
|