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 CHANGED
@@ -3,7 +3,7 @@
3
3
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
4
4
  [![Node.js](https://img.shields.io/badge/node-%3E%3D20.0.0-brightgreen.svg)](https://nodejs.org/)
5
5
  [![Zero Dependencies](https://img.shields.io/badge/runtime%20dependencies-0-success.svg)](package.json)
6
- [![Tests](https://img.shields.io/badge/tests-546%20passing-brightgreen.svg)](tests/)
6
+ [![Tests](https://img.shields.io/badge/tests-551%20passing-brightgreen.svg)](tests/)
7
7
 
8
8
  > **Drop a path. Get a map.**
9
9
  > A lightweight, zero-dependency codebase visualizer and architectural map generator that runs entirely on your local machine.
@@ -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 (OpenClaw-style)
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 domain map.example.com
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 (546 unit tests)
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 546 tests
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 { server, host, port } = await startServer({ configFile: file, log: out });
157
- if (flags.json) out(JSON.stringify({ host, port, url: `http://127.0.0.1:${port}/` }));
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 { server, host, port, code: 0 };
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: loopback || Boolean(settings.domain), required: !loopback,
345
- detail: settings.domain || (loopback ? '(none — a tunnel provides the name)' : 'MISSING — a LAN bind needs a domain'),
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 { runSetup, runStart, runConfig, runConfigKey, runConfigReset, runDoctor, runTunnel } from './commands.js';
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 config <…> show | get <key> | set <key> <value> | path | reset | key <rotate|show|set>
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 — required for a direct LAN bind, optional when a tunnel provides the name',
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.1.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
@@ -261,4 +261,3 @@ export async function updateServerSettings(patch) {
261
261
  export function rotateServerAccessKey() {
262
262
  return postJSON('/api/settings/access-key', {});
263
263
  }
264
-
@@ -166,4 +166,3 @@ export function createServerDrawer(options = {}) {
166
166
  isOpen: () => !dom.serverDrawer.hidden,
167
167
  };
168
168
  }
169
-
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
- if (settings.mode === 'self-hosted' && !isLoopbackHost(settings.host) && !settings.domain) {
95
- throw new Error('A self-hosted network bind needs a domain (or keep the server on loopback behind a tunnel).');
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)) hosts.add(settings.host.toLowerCase());
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
- ? ' Mode self-hosted — the network can reach this; every API call needs the access key'
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
- await new Promise((resolve, reject) => {
134
- server.once('error', reject);
135
- server.listen(port, host, resolve);
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
 
@@ -206,4 +206,3 @@ export {
206
206
  docFileRow, fileFactsLine, fileStaticDoc, folderStaticDoc,
207
207
  searchDocuments,
208
208
  };
209
-
@@ -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 `&lt;script&gt;` 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
- '&nbsp;': ' ', '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'",
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(/&nbsp;|&amp;|&lt;|&gt;|&quot;|&#39;/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();