codebase-onboarder 0.1.0 → 0.2.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 +15 -6
- package/cli/commands.js +108 -6
- package/cli/main.js +22 -3
- package/cli/prompt.js +5 -0
- package/cli/wizard.js +16 -4
- package/package.json +1 -1
- package/public/js/api.js +0 -1
- package/public/js/serverSettings.js +0 -1
- package/server/config.js +28 -4
- package/server/index.js +34 -6
- package/server/mcp/analysis.js +0 -1
- package/server/pidfile.js +49 -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.
|
|
@@ -205,16 +205,24 @@ Onboarder has two modes, one config file, and three ways to edit it — the CLI
|
|
|
205
205
|
- **Self-hosted** — reachable on your network or domain; every API call requires a Bearer access key. Rotate it from the drawer or with `onboarder config key rotate`; the old key dies on the next request, no restart needed.
|
|
206
206
|
|
|
207
207
|
```bash
|
|
208
|
-
onboarder setup # interactive wizard
|
|
208
|
+
onboarder setup # interactive wizard
|
|
209
209
|
onboarder setup --mode self-hosted \
|
|
210
210
|
--domain map.example.com --non-interactive
|
|
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 doctor # config, port, and tunnel checks
|
|
219
|
+
onboarder doctor # config, port, reachability, and tunnel checks
|
|
216
220
|
```
|
|
217
221
|
|
|
222
|
+
Direct self-hosting does not require DNS: `--host 0.0.0.0` accepts visitors at `http://<server-ip>:<port>`. Put a reverse proxy and TLS in front of it for production. Binding to `127.0.0.1` is still the safer default and is appropriate behind Cloudflare or Tailscale.
|
|
223
|
+
|
|
224
|
+
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
|
+
|
|
218
226
|
Cloudflare quick tunnels and Tailscale are supported as reachability layers — the server keeps its loopback bind and the tunnel dials `127.0.0.1`. `ONBOARDER_CONFIG=/path/config.json` overrides the config location (handy for tests and containers).
|
|
219
227
|
|
|
220
228
|
---
|
|
@@ -228,6 +236,7 @@ codebase-onboarder/
|
|
|
228
236
|
├── server/ # Zero-dependency Node.js HTTP server
|
|
229
237
|
│ ├── index.js # createServer / startServer / startup banner
|
|
230
238
|
│ ├── config.js # Settings schema, normalization, atomic 0600 writes
|
|
239
|
+
│ ├── pidfile.js # PID ownership for status / stop / restart
|
|
231
240
|
│ ├── router.js # Route table, live per-request settings, Bearer gate
|
|
232
241
|
│ ├── apiSettings.js# GET/PUT /api/settings, key rotation
|
|
233
242
|
│ ├── tunnel.js # Cloudflare & Tailscale status/commands
|
|
@@ -243,7 +252,7 @@ codebase-onboarder/
|
|
|
243
252
|
│ ├── js/ # Vanilla ES modules (State, Inspector, Views, Settings)
|
|
244
253
|
│ ├── vendor/ # Vendored Mermaid & Monaco Editor (Offline)
|
|
245
254
|
│ └── index.html # Main application interface
|
|
246
|
-
└── tests/ # Comprehensive node:test suite (
|
|
255
|
+
└── tests/ # Comprehensive node:test suite (551 unit tests)
|
|
247
256
|
```
|
|
248
257
|
|
|
249
258
|
---
|
|
@@ -253,7 +262,7 @@ codebase-onboarder/
|
|
|
253
262
|
Onboarder includes a comprehensive automated test suite built with Node's native test runner:
|
|
254
263
|
|
|
255
264
|
```bash
|
|
256
|
-
# Run all
|
|
265
|
+
# Run all 551 tests
|
|
257
266
|
npm test
|
|
258
267
|
```
|
|
259
268
|
|
package/cli/commands.js
CHANGED
|
@@ -15,6 +15,7 @@ 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';
|
|
19
20
|
import { bold, cyan, dim, ok, warn, bad, kv, tick, cross, dash, welcomeBanner } from './ui.js';
|
|
20
21
|
import { buildSteps, pendingSteps, defaultOf, applyFlags, answersToSettings, summaryLines } from './wizard.js';
|
|
@@ -153,11 +154,113 @@ export async function runStart({ flags = {}, out = console.log, err = console.er
|
|
|
153
154
|
}
|
|
154
155
|
out(dim(` No config at ${file} — starting with defaults (local mode).`));
|
|
155
156
|
}
|
|
156
|
-
const
|
|
157
|
-
if (
|
|
157
|
+
const recorded = readPidFile(file);
|
|
158
|
+
if (recorded && pidIsAlive(recorded)) {
|
|
159
|
+
err(` Onboarder is already running (PID ${recorded}).`);
|
|
160
|
+
err(dim(' Use `onboarder status`, `onboarder stop`, or `onboarder restart`.'));
|
|
161
|
+
return 1;
|
|
162
|
+
}
|
|
163
|
+
if (recorded) removePidFile(file, recorded);
|
|
164
|
+
|
|
165
|
+
const started = await startServer({ configFile: file, log: out });
|
|
166
|
+
if (flags.json) out(JSON.stringify({ host: started.host, port: started.port, url: serverUrls(started.settings).local }));
|
|
158
167
|
// The listening server holds the event loop; resolve so callers/tests know
|
|
159
168
|
// we are up, but leave the process running.
|
|
160
|
-
return {
|
|
169
|
+
return { ...started, code: 0 };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ------------------------------------------------------------- lifecycle ---
|
|
173
|
+
|
|
174
|
+
export async function runStatus({ flags = {}, out = console.log } = {}) {
|
|
175
|
+
const file = flags.config || configPath();
|
|
176
|
+
const pid = readPidFile(file);
|
|
177
|
+
let running = pid && pidIsAlive(pid);
|
|
178
|
+
let settings = null;
|
|
179
|
+
try { settings = await readSettings(file); } catch { /* defaults are still meaningful */ }
|
|
180
|
+
const portBusy = settings ? !(await portIsFree(settings.host, settings.port)) : false;
|
|
181
|
+
if (pid && !running) removePidFile(file, pid);
|
|
182
|
+
const result = {
|
|
183
|
+
running: Boolean(running || portBusy),
|
|
184
|
+
pid: running ? pid : null,
|
|
185
|
+
port: settings ? `${settings.host}:${settings.port}` : null,
|
|
186
|
+
portBusy,
|
|
187
|
+
managed: Boolean(running),
|
|
188
|
+
configFile: file,
|
|
189
|
+
};
|
|
190
|
+
if (flags.json) {
|
|
191
|
+
out(JSON.stringify(result, null, 2));
|
|
192
|
+
} else {
|
|
193
|
+
out('');
|
|
194
|
+
out(bold(' Onboarder status'));
|
|
195
|
+
if (running) out(`${tick}Running PID ${pid}`);
|
|
196
|
+
else if (portBusy) out(`${warn('!')}Port busy ${result.port} (no Onboarder PID record)`);
|
|
197
|
+
else out(`${dash}Stopped`);
|
|
198
|
+
out(kv('Config', file));
|
|
199
|
+
if (result.portBusy && !running) out(dim(' Inspect it with `ss -ltnp` or `lsof -i :' + settings.port + '` before stopping another process.'));
|
|
200
|
+
}
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function waitForExit(pid, timeoutMs = 5000) {
|
|
205
|
+
return new Promise((resolve) => {
|
|
206
|
+
const started = Date.now();
|
|
207
|
+
const poll = () => {
|
|
208
|
+
if (!pidIsAlive(pid)) return resolve(true);
|
|
209
|
+
if (Date.now() - started >= timeoutMs) return resolve(false);
|
|
210
|
+
setTimeout(poll, 100);
|
|
211
|
+
};
|
|
212
|
+
poll();
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function runStop({ flags = {}, out = console.log, err = console.error } = {}) {
|
|
217
|
+
const file = flags.config || configPath();
|
|
218
|
+
const pid = readPidFile(file);
|
|
219
|
+
if (!pid) {
|
|
220
|
+
const settings = await readSettings(file);
|
|
221
|
+
const portBusy = !(await portIsFree(settings.host, settings.port));
|
|
222
|
+
if (flags.json) out(JSON.stringify({ stopped: false, reason: portBusy ? 'unmanaged busy port' : 'not running', port: `${settings.host}:${settings.port}` }, null, 2));
|
|
223
|
+
else if (portBusy) {
|
|
224
|
+
out(`${warn('!')}Port busy ${settings.host}:${settings.port} — no Onboarder process record.`);
|
|
225
|
+
out(dim(' Inspect it with `ss -ltnp` or `lsof -i :' + settings.port + '`; `onboarder stop` will not kill an unmanaged process.'));
|
|
226
|
+
} else {
|
|
227
|
+
out(`${dash}Not running.`);
|
|
228
|
+
}
|
|
229
|
+
return portBusy ? 1 : 0;
|
|
230
|
+
}
|
|
231
|
+
if (!pidIsAlive(pid)) {
|
|
232
|
+
removePidFile(file, pid);
|
|
233
|
+
if (flags.json) out(JSON.stringify({ stopped: true, pid, stale: true }, null, 2));
|
|
234
|
+
else out(tick + `Removed stale process record for PID ${pid}.`);
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
process.kill(pid, 'SIGTERM');
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (error?.code !== 'ESRCH') throw error;
|
|
241
|
+
}
|
|
242
|
+
const stopped = await waitForExit(pid);
|
|
243
|
+
if (!stopped && pidIsAlive(pid)) {
|
|
244
|
+
err(` PID ${pid} did not stop after SIGTERM.`);
|
|
245
|
+
err(dim(' Check it with `ps -p ' + pid + ' -f`, then stop it only if it really is Onboarder.'));
|
|
246
|
+
return 1;
|
|
247
|
+
}
|
|
248
|
+
removePidFile(file, pid);
|
|
249
|
+
if (flags.json) out(JSON.stringify({ stopped: true, pid }, null, 2));
|
|
250
|
+
else out(tick + `Stopped Onboarder (PID ${pid}).`);
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function runRestart(options = {}) {
|
|
255
|
+
const file = options.flags?.config || configPath();
|
|
256
|
+
const pid = readPidFile(file);
|
|
257
|
+
if (pid && pidIsAlive(pid)) {
|
|
258
|
+
const stopped = await runStop(options);
|
|
259
|
+
if (stopped !== 0) return stopped;
|
|
260
|
+
} else if (pid) {
|
|
261
|
+
removePidFile(file, pid);
|
|
262
|
+
}
|
|
263
|
+
return runStart(options);
|
|
161
264
|
}
|
|
162
265
|
|
|
163
266
|
// --------------------------------------------------------------- config ---
|
|
@@ -341,8 +444,8 @@ export async function runDoctor({ flags = {}, out = console.log } = {}) {
|
|
|
341
444
|
});
|
|
342
445
|
const loopback = ['127.0.0.1', 'localhost', '::1', '[::1]'].includes(settings.host);
|
|
343
446
|
checks.push({
|
|
344
|
-
id: 'domain', ok:
|
|
345
|
-
detail: settings.domain || (loopback ? '(none — a tunnel provides the name)' : '
|
|
447
|
+
id: 'domain', ok: true, required: false,
|
|
448
|
+
detail: settings.domain || (loopback ? '(none — a tunnel or local reverse proxy provides the name)' : '(none — visitors can connect by server IP)'),
|
|
346
449
|
});
|
|
347
450
|
}
|
|
348
451
|
}
|
|
@@ -433,4 +536,3 @@ export async function runTunnel(kind, { flags = {}, out = console.log, err = con
|
|
|
433
536
|
}
|
|
434
537
|
throw new Error('Usage: onboarder tunnel <cloudflare|tailscale>');
|
|
435
538
|
}
|
|
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,
|
|
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,7 +21,10 @@ 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
|
|
23
29
|
onboarder doctor Check the machine and the config
|
|
24
30
|
|
|
@@ -37,6 +43,10 @@ const HELP = `
|
|
|
37
43
|
--no-color Plain output (NO_COLOR works too)
|
|
38
44
|
-h, --help This text -v, --version Print the version
|
|
39
45
|
|
|
46
|
+
Also accepted
|
|
47
|
+
help | --help onboarder help | onboarder --help
|
|
48
|
+
config | config show
|
|
49
|
+
|
|
40
50
|
Examples
|
|
41
51
|
onboarder setup
|
|
42
52
|
onboarder setup --non-interactive --mode local --port 4310
|
|
@@ -97,9 +107,18 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
97
107
|
const [cmd, sub, ...rest] = positionals;
|
|
98
108
|
try {
|
|
99
109
|
switch (cmd) {
|
|
110
|
+
case 'help':
|
|
111
|
+
console.log(HELP);
|
|
112
|
+
return 0;
|
|
100
113
|
case undefined:
|
|
101
114
|
case 'start':
|
|
102
115
|
return codeOf(await runStart({ flags }));
|
|
116
|
+
case 'status':
|
|
117
|
+
return codeOf(await runStatus({ flags }));
|
|
118
|
+
case 'stop':
|
|
119
|
+
return codeOf(await runStop({ flags }));
|
|
120
|
+
case 'restart':
|
|
121
|
+
return codeOf(await runRestart({ flags }));
|
|
103
122
|
case 'setup':
|
|
104
123
|
case 'onboard':
|
|
105
124
|
case 'init':
|
|
@@ -107,7 +126,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
107
126
|
case 'config':
|
|
108
127
|
if (sub === 'key') return codeOf(await runConfigKey(rest[0], rest.slice(1), { flags }));
|
|
109
128
|
if (sub === 'reset') return codeOf(await runConfigReset({ flags }));
|
|
110
|
-
return codeOf(await runConfig(sub, rest, { flags }));
|
|
129
|
+
return codeOf(await runConfig(sub || 'show', rest, { flags }));
|
|
111
130
|
case 'tunnel':
|
|
112
131
|
return codeOf(await runTunnel(sub, { flags }));
|
|
113
132
|
case 'doctor':
|
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.
|
|
@@ -113,8 +113,8 @@ export function buildSteps(current = DEFAULT_SETTINGS) {
|
|
|
113
113
|
},
|
|
114
114
|
{
|
|
115
115
|
id: 'domain', section: 'Server', type: 'text',
|
|
116
|
-
question: 'Domain (optional behind a tunnel)',
|
|
117
|
-
hint: 'e.g. map.example.com —
|
|
116
|
+
question: 'Domain (optional; not needed when reached by IP or behind a tunnel)',
|
|
117
|
+
hint: 'e.g. map.example.com — use a hostname only when visitors will connect by domain',
|
|
118
118
|
default: current.domain || '',
|
|
119
119
|
validate: validDomain,
|
|
120
120
|
when: (a) => a.mode === 'self-hosted',
|
|
@@ -190,6 +190,16 @@ export function pendingSteps(steps, answers = {}) {
|
|
|
190
190
|
return steps.filter((s) => !(s.id in answers) && (!s.when || s.when(answers)));
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
// Steps the flags did not answer, in order — and nothing more. Unlike
|
|
194
|
+
// `pendingSteps` this never looks at `when`: conditions read answers that
|
|
195
|
+
// only exist mid-run ("ask about the bind once mode is self-hosted"), so
|
|
196
|
+
// evaluating them before the first question prunes branches the typed answers
|
|
197
|
+
// would reopen. The renderer (`prompt.js#runSteps`) evaluates `when` live,
|
|
198
|
+
// one question at a time, which is the only moment it can be answered truly.
|
|
199
|
+
export function unansweredSteps(steps, answers = {}) {
|
|
200
|
+
return steps.filter((s) => !(s.id in answers));
|
|
201
|
+
}
|
|
202
|
+
|
|
193
203
|
// Defaults may depend on earlier answers (`default` as a function).
|
|
194
204
|
export function defaultOf(step, answers) {
|
|
195
205
|
return typeof step.default === 'function' ? step.default(answers) : step.default;
|
|
@@ -311,6 +321,9 @@ export function summaryLines(settings, { revealKey = false } = {}) {
|
|
|
311
321
|
lines.push(['Access key', revealKey
|
|
312
322
|
? settings.accessKey
|
|
313
323
|
: (settings.accessKey ? settings.accessKey.slice(0, 6) + '… (hidden)' : '(none — the API will refuse every call)')]);
|
|
324
|
+
if (isLoopbackHost(settings.host)) {
|
|
325
|
+
lines.push(['Network', 'loopback only — use a tunnel or bind 0.0.0.0 for direct access']);
|
|
326
|
+
}
|
|
314
327
|
}
|
|
315
328
|
const who = [settings.account.name, settings.account.email].filter(Boolean).join(' · ');
|
|
316
329
|
if (who) lines.push(['Profile', who]);
|
|
@@ -322,4 +335,3 @@ export function summaryLines(settings, { revealKey = false } = {}) {
|
|
|
322
335
|
lines.push(['Auto-open', settings.autoOpen ? 'yes' : 'no']);
|
|
323
336
|
return lines;
|
|
324
337
|
}
|
|
325
|
-
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codebase-onboarder",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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/js/api.js
CHANGED
package/server/config.js
CHANGED
|
@@ -91,9 +91,10 @@ export function normalizeSettings(value = {}) {
|
|
|
91
91
|
if (settings.mode === 'local' && !isLoopbackHost(settings.host)) {
|
|
92
92
|
throw new Error('Local mode only binds to 127.0.0.1, localhost, or ::1. Choose self-hosted mode for a network bind.');
|
|
93
93
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
// Self-hosted needs no domain: a bare-IP bind (a VPS with no DNS name) is a
|
|
95
|
+
// legitimate layout. The rebinding guard still answers only to the
|
|
96
|
+
// configured domain or this machine's own interface addresses (see
|
|
97
|
+
// allowedHosts), and every API call needs the access key either way.
|
|
97
98
|
return settings;
|
|
98
99
|
}
|
|
99
100
|
|
|
@@ -154,11 +155,34 @@ export function publicSettings(value = DEFAULT_SETTINGS) {
|
|
|
154
155
|
// safe because it can only widen Host acceptance in self-hosted mode, where
|
|
155
156
|
// every API call already needs the access key — the guard's job there is
|
|
156
157
|
// keeping drive-by traffic off the static files, not authentication.
|
|
158
|
+
// A wildcard bind (0.0.0.0 / ::) has no name of its own, so the names it
|
|
159
|
+
// honestly answers to are this machine's own interface addresses — a request
|
|
160
|
+
// addressed to 10.0.0.2 or a public VPS IP really did arrive here. Read at
|
|
161
|
+
// call time (the router asks per request), so a DHCP lease change does not
|
|
162
|
+
// strand the guard on a stale address.
|
|
163
|
+
function ownInterfaceHosts() {
|
|
164
|
+
const hosts = [];
|
|
165
|
+
for (const list of Object.values(os.networkInterfaces())) {
|
|
166
|
+
for (const addr of list || []) {
|
|
167
|
+
if (addr.internal) continue; // loopback is always allowed anyway
|
|
168
|
+
hosts.push(addr.address);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return hosts;
|
|
172
|
+
}
|
|
173
|
+
|
|
157
174
|
export function allowedHosts(value = DEFAULT_SETTINGS) {
|
|
158
175
|
const settings = normalizeSettings(value);
|
|
159
176
|
const hosts = new Set();
|
|
160
177
|
if (settings.domain) hosts.add(settings.domain);
|
|
161
|
-
if (!isLoopbackHost(settings.host))
|
|
178
|
+
if (!isLoopbackHost(settings.host)) {
|
|
179
|
+
// A wildcard bind is useful as a literal Host value to diagnostics, even
|
|
180
|
+
// though browsers normally address the machine by one of its real IPs.
|
|
181
|
+
hosts.add(settings.host.toLowerCase());
|
|
182
|
+
if (settings.host === '0.0.0.0' || settings.host === '::') {
|
|
183
|
+
for (const ip of ownInterfaceHosts()) hosts.add(ip);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
162
186
|
if (settings.tunnel.cloudflare) hosts.add('*.trycloudflare.com');
|
|
163
187
|
if (settings.tunnel.tailscale) hosts.add('*.ts.net');
|
|
164
188
|
return [...hosts];
|
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 { 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,7 +67,9 @@ 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);
|
|
@@ -102,6 +105,17 @@ export function openInBrowser(url) {
|
|
|
102
105
|
}
|
|
103
106
|
}
|
|
104
107
|
|
|
108
|
+
export function listenError(error, { host, port, pid = null } = {}) {
|
|
109
|
+
if (error?.code === 'EADDRINUSE') {
|
|
110
|
+
const owner = pid ? ` Another Onboarder process is running as PID ${pid}.` : '';
|
|
111
|
+
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 });
|
|
112
|
+
}
|
|
113
|
+
if (error?.code === 'EACCES') {
|
|
114
|
+
return new Error(`Cannot bind ${host}:${port}: permission denied. Ports below 1024 normally require elevated privileges; choose a port above 1024.`, { cause: error });
|
|
115
|
+
}
|
|
116
|
+
return error;
|
|
117
|
+
}
|
|
118
|
+
|
|
105
119
|
// The real boot: read the settings, bind what they say, print the banner.
|
|
106
120
|
// Both `node server/index.js` and `onboarder start` land here.
|
|
107
121
|
export async function startServer({ configFile = configPath(), openBrowser, log = console.log } = {}) {
|
|
@@ -130,10 +144,24 @@ export async function startServer({ configFile = configPath(), openBrowser, log
|
|
|
130
144
|
});
|
|
131
145
|
}
|
|
132
146
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
147
|
+
try {
|
|
148
|
+
await new Promise((resolve, reject) => {
|
|
149
|
+
server.once('error', reject);
|
|
150
|
+
server.listen(port, host, resolve);
|
|
151
|
+
});
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const recorded = readPidFile(configFile);
|
|
154
|
+
throw listenError(error, { host, port, pid: recorded && pidIsAlive(recorded) ? recorded : null });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
writePidFile(configFile);
|
|
159
|
+
server.once('close', () => removePidFile(configFile));
|
|
160
|
+
process.once('exit', () => removePidFile(configFile));
|
|
161
|
+
} catch {
|
|
162
|
+
// The server is useful even when a read-only config directory cannot hold
|
|
163
|
+
// the optional process record; binding and serving are the real contract.
|
|
164
|
+
}
|
|
137
165
|
|
|
138
166
|
log(startupBanner(live, { configFile }));
|
|
139
167
|
|
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
|
+
}
|
|
@@ -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();
|