codebase-onboarder 0.3.0 → 0.4.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
@@ -202,7 +202,7 @@ 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** — 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.
205
+ - **Self-hosted** — a fresh setup binds `0.0.0.0` for direct LAN/VPS access. Any IP-literal address is accepted, while arbitrary Host names are not. Remote browsers get a themed access-key login page and exchange the key for a 7-day signed `HttpOnly`, `SameSite=Strict` session cookie; true localhost requests skip login. API clients can continue using `Authorization: Bearer <access-key>`. Rotate the key with `onboarder config key rotate`; the old key and every old browser session die on the next request.
206
206
 
207
207
  ```bash
208
208
  onboarder setup # interactive wizard
@@ -212,7 +212,13 @@ onboarder config show # current settings (key masked)
212
212
  onboarder config set host 0.0.0.0 # direct VPS/LAN access (domain optional)
213
213
  onboarder config set port 4311 # move away from a busy port
214
214
  onboarder config key rotate # mint a new access key
215
- onboarder status # running PID, stopped, or unmanaged port owner
215
+ onboarder start # foreground; Ctrl-C stops it
216
+ onboarder start background # detached; keeps running after you close the terminal
217
+ onboarder logs # last 40 log lines (-n <count>, -f to follow)
218
+ onboarder start startup install # also start automatically at every login
219
+ onboarder start startup status # is a login item installed, and is it live?
220
+ onboarder start startup remove # take the login item back out
221
+ onboarder status # running PID, mode, uptime, stopped, or unmanaged port owner
216
222
  onboarder stop # stop a PID-file-managed instance
217
223
  onboarder restart # graceful stop, then start
218
224
  onboarder tunnel cloudflare # expose via a Cloudflare quick tunnel
@@ -223,7 +229,41 @@ onboarder https status # domain, URL, Caddyfile, and Caddy sta
223
229
  onboarder doctor # config, access key, ports, DNS, TLS, and tunnels
224
230
  ```
225
231
 
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.
232
+ ### Running it in the background
233
+
234
+ `onboarder start` runs in the foreground on purpose: it is a normal command, and Ctrl-C stops it. When you want the server to outlive the terminal, use `start background`. It re-launches the same CLI as a detached process with its output going to `onboarder.log` beside your config, then **waits for the server to actually answer** `/api/health` before reporting success. A port conflict comes back as a failure with the log tail attached, not as a cheerful green light that dies a second later.
235
+
236
+ ```bash
237
+ onboarder start background # returns once it is serving
238
+ onboarder logs -f # watch it, Ctrl-C to stop watching (not the server)
239
+ onboarder stop # stop it
240
+ ```
241
+
242
+ Log lines are column-aligned — a fixed-width local timestamp, a fixed-width level, then the message — so a wall of requests stays scannable:
243
+
244
+ ```
245
+ 16:09:38.985 INFO GET /api/health 200 (1ms)
246
+ 16:09:46.460 INFO GET /nope 404 (1ms)
247
+ 16:10:21.925 WARN config port=4310 reason="already in use"
248
+ ```
249
+
250
+ Set `ONBOARDER_LOG=json` for one JSON object per line instead (the log file is plain text by default so it stays readable; `--json` on `status`/`logs` is the machine-readable surface).
251
+
252
+ ### Starting at login
253
+
254
+ `onboarder start startup install` registers Onboarder with whatever your OS uses for login items, and never asks for `sudo`:
255
+
256
+ | Platform | What it writes | How it loads |
257
+ |---|---|---|
258
+ | macOS | `~/Library/LaunchAgents/com.onboarder.server.plist` | `launchctl bootstrap gui/$UID` |
259
+ | Linux | `~/.config/systemd/user/onboarder.service` | `systemctl --user enable --now` |
260
+ | Windows | `%APPDATA%\...\Startup\Onboarder.cmd` | the file *is* the registration |
261
+
262
+ All three run the same `onboarder start --config <your config>`, so a login-started server is indistinguishable from a hand-started one — same config, same pid file, same `onboarder stop`. The generated unit uses `Restart=on-failure` (and launchd's `KeepAlive`/`SuccessfulExit=false`), so a crash is restarted but a deliberate `onboarder stop` stays stopped. `onboarder start startup remove` unloads and deletes it; a running server is left alone. On a platform with no known mechanism, the command says so instead of pretending.
263
+
264
+ A fresh self-hosted setup uses `0.0.0.0`, so a VPS is reachable at `http://<server-ip>:<port>` without a reverse proxy. The server accepts IPv4 and IPv6 IP literals, including a public address that reaches the host through provider NAT, but still rejects arbitrary DNS Host headers. A domain is optional for direct-IP access. If a domain is entered, setup asks whether to enable automatic HTTPS.
265
+
266
+ When a remote browser opens the URL, Onboarder shows its themed sign-in page. The access key is sent in a POST body—not in the URL—and the browser stores only the signed session cookie. Opening the same server through `http://localhost:<port>` on that machine skips the page. Caddy and tunnel connections remain authenticated because their public Host is not loopback.
227
267
 
228
268
  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
269
 
@@ -236,7 +276,7 @@ onboarder https check
236
276
  onboarder https setup
237
277
  ```
238
278
 
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.
279
+ Onboarder writes a private `Caddyfile` beside `config.json`, validates it, and asks Caddy to obtain and renew the certificate. It never runs `sudo` or installs packages silently. Caddy proxies `https://map.example.com` to `http://127.0.0.1:4310`; Onboarder recognizes the connection as remote and shows the access-key login page. A bare public IP cannot use a normal trusted domain certificate.
240
280
 
241
281
  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
282
 
@@ -253,8 +293,13 @@ codebase-onboarder/
253
293
  ├── server/ # Zero-dependency Node.js HTTP server
254
294
  │ ├── index.js # createServer / startServer / startup banner
255
295
  │ ├── config.js # Settings schema, normalization, atomic 0600 writes
256
- │ ├── pidfile.js # PID ownership for status / stop / restart
257
- │ ├── router.js # Route table, live per-request settings, Bearer gate
296
+ │ ├── pidfile.js # PID + run record (mode, url, log) for status / stop / restart
297
+ │ ├── daemon.js # Detached background start, log file, readiness probe
298
+ │ ├── startup.js # Login items: launchd plist / systemd --user unit / Startup folder
299
+ │ ├── logger.js # Aligned-text or JSON log lines from one entry shape
300
+ │ ├── router.js # Route table, live per-request settings, auth & CSRF gates
301
+ │ ├── auth.js # Signed HttpOnly browser sessions
302
+ │ ├── apiAuth.js # Login/status/logout endpoints
258
303
  │ ├── apiSettings.js# GET/PUT /api/settings, key rotation
259
304
  │ ├── tunnel.js # Cloudflare & Tailscale status/commands
260
305
  │ ├── https.js # Caddy config, ACME/TLS readiness & lifecycle
@@ -269,8 +314,9 @@ codebase-onboarder/
269
314
  ├── public/ # Frontend client application
270
315
  │ ├── js/ # Vanilla ES modules (State, Inspector, Views, Settings)
271
316
  │ ├── vendor/ # Vendored Mermaid & Monaco Editor (Offline)
272
- │ └── index.html # Main application interface
273
- └── tests/ # Comprehensive node:test suite (560 unit tests)
317
+ │ ├── index.html # Main application interface
318
+ │ └── login.html # Self-hosted access-key sign-in
319
+ └── tests/ # Comprehensive node:test suite (565 tests)
274
320
  ```
275
321
 
276
322
  ---
@@ -280,7 +326,7 @@ codebase-onboarder/
280
326
  Onboarder includes a comprehensive automated test suite built with Node's native test runner:
281
327
 
282
328
  ```bash
283
- # Run all 560 tests
329
+ # Run all 565 tests
284
330
  npm test
285
331
  ```
286
332
 
package/cli/commands.js CHANGED
@@ -12,13 +12,15 @@ import { spawn } from 'node:child_process';
12
12
 
13
13
  import {
14
14
  DEFAULT_SETTINGS, configPath, configExists, readSettings, writeSettings,
15
- publicSettings, serverUrls, maskAccessKey,
15
+ publicSettings, serverUrls, maskAccessKey, isLoopbackHost,
16
16
  } from '../server/config.js';
17
17
  import { startServer } from '../server/index.js';
18
- import { pidIsAlive, readPidFile, removePidFile } from '../server/pidfile.js';
18
+ import { pidIsAlive, readPidFile, removePidFile, readRunInfo, runInfoPath } from '../server/pidfile.js';
19
+ import { logPath, rotateLogIfNeeded, tailLog, followLog, logExists, spawnDetached, waitForPidFile, waitForReady } from '../server/daemon.js';
20
+ import { installStartup, removeStartup, startupStatus, startupTarget } from '../server/startup.js';
19
21
  import { tunnelStatus, cloudflareCommand, tailscaleCommand, installHint, findOnPath } from '../server/tunnel.js';
20
22
  import { caddyRun, caddyValidate, httpsReadiness, httpsStatus, writeCaddyfile } from '../server/https.js';
21
- import { bold, cyan, dim, ok, warn, bad, kv, tick, cross, dash, welcomeBanner } from './ui.js';
23
+ import { bold, cyan, dim, ok, warn, bad, paint, kv, tick, cross, dash, welcomeBanner, panel, row, hint } from './ui.js';
22
24
  import { buildSteps, unansweredSteps, defaultOf, applyFlags, answersToSettings, summaryLines } from './wizard.js';
23
25
  import { runSteps, WizardCancelled } from './prompt.js';
24
26
 
@@ -41,6 +43,9 @@ export async function runSetup({ flags = {}, out = console.log, err = console.er
41
43
  printSummary(out, settings, { revealKey: Boolean(patchHasNewKey(flags)) });
42
44
  }
43
45
  await printTunnelFollowup(out, settings);
46
+ // `start` may be the flag `--start` (true) or the mode a subcommand asked
47
+ // for ('background') — both mean "boot it now", differently.
48
+ if (flags.start === 'background') return runStartBackground({ flags, out, err });
44
49
  if (flags.start) return runStart({ flags, out, err });
45
50
  return 0;
46
51
  }
@@ -95,9 +100,9 @@ export async function runSetup({ flags = {}, out = console.log, err = console.er
95
100
  }
96
101
  await printTunnelFollowup(out, settings);
97
102
 
98
- if (flags.start || await confirm(out, 'Start Onboarder now?', true)) {
103
+ if (flags.start === 'background' || (!flags.start && await confirm(out, 'Start Onboarder now?', true))) {
99
104
  out('');
100
- return runStart({ flags, out, err });
105
+ return flags.start === 'background' ? runStartBackground({ flags, out, err }) : runStart({ flags, out, err });
101
106
  }
102
107
  out(dim(settings.https
103
108
  ? ' Later: `onboarder start` (starts Caddy automatically), or `onboarder https status`'
@@ -145,6 +150,38 @@ async function printTunnelFollowup(out, settings) {
145
150
 
146
151
  // ---------------------------------------------------------------- start ---
147
152
 
153
+ // What the person is about to be looking at, as a titled panel instead of a
154
+ // loose run of lines. One function serves the foreground banner and the
155
+ // background confirmation, so "what does `onboarder start` tell me" has exactly
156
+ // one answer no matter which mode you used.
157
+ export function serverDetails(started, { configFile, pid = null, mode = 'foreground', logFile = '' } = {}) {
158
+ const { settings, host, port } = started;
159
+ const urls = serverUrls(settings);
160
+ const rows = [
161
+ row('URL', bold(cyan(urls.local))),
162
+ row('Bind', `${host}:${port}`),
163
+ row('Mode', settings.mode === 'self-hosted'
164
+ ? (isLoopbackHost(host) ? 'self-hosted (loopback — use a tunnel for remote access)' : 'self-hosted (network reachable, access key required)')
165
+ : 'local (this machine only)'),
166
+ ];
167
+ if (urls.network) rows.push(row('Network', urls.network));
168
+ if (urls.domain) rows.push(row('Public', urls.domain));
169
+ if (settings.mode === 'self-hosted') {
170
+ rows.push(row('Access key', settings.accessKey ? maskAccessKey(settings.accessKey) : bad('NOT SET — every API call is refused')));
171
+ }
172
+ rows.push(row('PID', String(pid ?? process.pid)));
173
+ rows.push(row('Config', dim(configFile || '')));
174
+ if (logFile) rows.push(row('Logs', dim(logFile)));
175
+ return { rows, urls, mode };
176
+ }
177
+
178
+ function printServerDetails(out, started, options) {
179
+ const { rows } = serverDetails(started, options);
180
+ out('');
181
+ out(panel('Onboarder is running', rows));
182
+ out('');
183
+ }
184
+
148
185
  export async function runStart({ flags = {}, out = console.log, err = console.error } = {}) {
149
186
  const file = flags.config || configPath();
150
187
  if (!await configExists(file)) {
@@ -165,7 +202,9 @@ export async function runStart({ flags = {}, out = console.log, err = console.er
165
202
  }
166
203
  if (recorded) removePidFile(file, recorded);
167
204
 
168
- const started = await startServer({ configFile: file, log: out });
205
+ // `quiet`: the CLI prints its own details panel below, so the loose banner
206
+ // would be the same facts twice. `node server/index.js` still gets the banner.
207
+ const started = await startServer({ configFile: file, log: () => {}, openBrowser: false });
169
208
  if (started.settings.https) {
170
209
  out('');
171
210
  const httpsCode = await runHttps('setup', { flags, out: flags.json ? () => {} : out, err });
@@ -175,13 +214,116 @@ export async function runStart({ flags = {}, out = console.log, err = console.er
175
214
  }
176
215
  }
177
216
  if (flags.json) out(JSON.stringify({ host: started.host, port: started.port, url: serverUrls(started.settings).local }));
217
+ // `start` foreground prints the details itself rather than letting
218
+ // `startServer` print the old loose banner — the panel replaces it. The
219
+ // background child lands here too (that is the point of reusing this path),
220
+ // and ONBOARDER_BACKGROUND is what tells the two apart: one is attached to a
221
+ // terminal you are about to close, the other is already detached from it.
222
+ printServerDetails(out, started, { configFile: file, mode: 'foreground' });
223
+ if (process.env.ONBOARDER_LAUNCH) {
224
+ // Started by launchd/systemd/the Startup folder: these lines are going into
225
+ // a log file nobody is watching, so they say what the supervisor is doing
226
+ // rather than telling a person to press Ctrl-C.
227
+ out(dim(` Launched at login by ${process.env.ONBOARDER_LAUNCH}. This process is supervised — stop it with \`onboarder stop\`.`));
228
+ } else if (process.env.ONBOARDER_BACKGROUND) {
229
+ out(dim(' Started in the background — this process is now independent of any terminal.'));
230
+ } else {
231
+ out(dim(' Running in the foreground. Ctrl-C stops it; closing this terminal stops it too.'));
232
+ out(dim(' To keep it alive after you close the terminal: ') + cyan('onboarder start background'));
233
+ }
234
+ out('');
178
235
  // The listening server holds the event loop; resolve so callers/tests know
179
236
  // we are up, but leave the process running.
180
237
  return { ...started, code: 0 };
181
238
  }
182
239
 
240
+ // `onboarder start background` — same server, no terminal attached. The child is
241
+ // an ordinary foreground `start`; all this does is detach it and then *wait for
242
+ // it to answer* before reporting success, so a bind failure surfaces here as a
243
+ // failure with the log tail attached rather than a cheerful lie.
244
+ export async function runStartBackground({ flags = {}, out = console.log, err = console.error } = {}) {
245
+ const file = flags.config || configPath();
246
+ if (!await configExists(file) && process.stdout.isTTY && !flags.nonInteractive) {
247
+ out(dim(' No settings yet — running setup first.'));
248
+ return runSetup({ flags: { ...flags, start: 'background' }, out, err });
249
+ }
250
+ const recorded = readPidFile(file);
251
+ if (recorded && pidIsAlive(recorded)) {
252
+ err(` Onboarder is already running (PID ${recorded}).`);
253
+ err(dim(' Use `onboarder status`, `onboarder stop`, or `onboarder restart`.'));
254
+ return 1;
255
+ }
256
+ if (recorded) removePidFile(file, recorded);
257
+
258
+ const settings = await readSettings(file);
259
+ const log = logPath(file);
260
+ await rotateLogIfNeeded(log);
261
+
262
+ spawnDetached({ configFile: file, log });
263
+ const pid = await waitForPidFile(file, { timeoutMs: flags.timeout ? Number(flags.timeout) * 1000 : 20000 });
264
+ const urls = serverUrls(settings);
265
+ const ready = pid ? await waitForReady(new URL('/api/health', urls.local).toString(), { timeoutMs: 8000 }) : false;
266
+
267
+ if (!ready) {
268
+ // Two different failures with two different fixes, so they are reported
269
+ // differently. No pid record means the child never finished binding — the
270
+ // log holds the reason (a busy port, an invalid config). A live pid that will
271
+ // not answer means it bound and then something in front of it is in the way
272
+ // (a proxy, a firewall), and no amount of log-reading will show that.
273
+ if (!pid) {
274
+ err(bad(' Onboarder did not start in the background.'));
275
+ const tail = await tailLog(log, 15);
276
+ if (tail.length) {
277
+ err(dim(` Last lines of ${log}:`));
278
+ for (const line of tail) err(' ' + line);
279
+ }
280
+ err(dim(' Fix the cause above, then run `onboarder start background` again.'));
281
+ } else {
282
+ err(bad(` Onboarder is running (PID ${pid}) but ${urls.local} is not answering.`));
283
+ err(dim(` Its log is ${log}. If a proxy or firewall fronts this port, check that first.`));
284
+ err(dim(' Otherwise: `onboarder stop`, then `onboarder start background` again.'));
285
+ }
286
+ return 1;
287
+ }
288
+
289
+ if (flags.json) {
290
+ out(JSON.stringify({ background: true, pid, url: urls.local, log, configFile: file }, null, 2));
291
+ return 0;
292
+ }
293
+
294
+ out('');
295
+ out(panel('Onboarder is running in the background', [
296
+ row('URL', bold(cyan(urls.local))),
297
+ row('PID', String(pid)),
298
+ row('Mode', 'background — survives closing this terminal'),
299
+ row('Logs', dim(log)),
300
+ row('Config', dim(file)),
301
+ ]));
302
+ out('');
303
+ out(dim(' Follow the log: ') + cyan('onboarder logs -f'));
304
+ out(dim(' Stop it: ') + cyan('onboarder stop'));
305
+ out(dim(' Check on it: ') + cyan('onboarder status'));
306
+ out('');
307
+ return 0;
308
+ }
309
+
310
+
183
311
  // ------------------------------------------------------------- lifecycle ---
184
312
 
313
+ // Panel rows carry their own inline marker rather than the `tick`/`cross`/`dash`
314
+ // constants: those bake in a two-column indent for standalone lines, which would
315
+ // push the first row out of the panel's label column.
316
+ const MARK = { yes: (s) => paint('✓ ', 'green') + s, no: (s) => paint('– ', 'gray') + s, warn: (s) => paint('! ', 'yellow') + s };
317
+
318
+ // How a live instance presents itself, in one sentence per mode. The point of
319
+ // the sentence is the thing someone actually needs to know: can I close my
320
+ // terminal, or will that kill it?
321
+ const MODE_NOTES = {
322
+ background: 'background — survives closing the terminal',
323
+ startup: 'started at login — the OS restarts it if it stops',
324
+ foreground: 'foreground — stops when you press Ctrl-C or close the terminal',
325
+ };
326
+
185
327
  export async function runStatus({ flags = {}, out = console.log } = {}) {
186
328
  const file = flags.config || configPath();
187
329
  const pid = readPidFile(file);
@@ -190,6 +332,9 @@ export async function runStatus({ flags = {}, out = console.log } = {}) {
190
332
  try { settings = await readSettings(file); } catch { /* defaults are still meaningful */ }
191
333
  const portBusy = settings ? !(await portIsFree(settings.host, settings.port)) : false;
192
334
  if (pid && !running) removePidFile(file, pid);
335
+ // The pid says whether it is up; the run record says *how* it came up, which
336
+ // is what tells someone whether closing their terminal will kill it.
337
+ const info = running ? readRunInfo(file) : null;
193
338
  const result = {
194
339
  running: Boolean(running || portBusy),
195
340
  pid: running ? pid : null,
@@ -197,21 +342,53 @@ export async function runStatus({ flags = {}, out = console.log } = {}) {
197
342
  portBusy,
198
343
  managed: Boolean(running),
199
344
  configFile: file,
345
+ mode: running ? (info?.mode || 'foreground') : null,
346
+ url: running ? (info?.url || (settings ? serverUrls(settings).local : null)) : null,
347
+ uptimeMs: running && info?.startedAt ? Date.now() - Date.parse(info.startedAt) : null,
348
+ log: running && info?.log ? info.log : null,
349
+ startup: (await startupStatus()).installed,
200
350
  };
201
351
  if (flags.json) {
202
352
  out(JSON.stringify(result, null, 2));
203
353
  } else {
204
354
  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.'));
355
+ out(panel('Onboarder status', [
356
+ row('State', running
357
+ ? MARK.yes(`Running (PID ${pid})`)
358
+ : portBusy
359
+ ? MARK.warn(`Port busy — ${result.port}, but no Onboarder process record`)
360
+ : MARK.no('Stopped')),
361
+ ...(running ? [
362
+ row('URL', cyan(result.url || '')),
363
+ row('Mode', MODE_NOTES[result.mode] || MODE_NOTES.foreground),
364
+ ...(result.uptimeMs ? [row('Uptime', humanDuration(result.uptimeMs))] : []),
365
+ ...(result.log ? [row('Logs', dim(result.log))] : []),
366
+ ] : []),
367
+ row('Config', dim(file)),
368
+ row('At login', result.startup ? MARK.yes('Enabled') : MARK.no('Not enabled — `onboarder start startup install`')),
369
+ ...(result.portBusy && !running
370
+ ? [hint('Inspect the listener with `ss -ltnp` or `lsof -i :' + settings.port + '` before stopping another process.')]
371
+ : []),
372
+ ]));
373
+ out('');
211
374
  }
212
375
  return 0;
213
376
  }
214
377
 
378
+ // "3d 4h", "12m 5s" — coarse on purpose. Precision past the second is noise on
379
+ // something a person glances at.
380
+ export function humanDuration(ms) {
381
+ const total = Math.max(0, Math.floor(ms / 1000));
382
+ const days = Math.floor(total / 86400);
383
+ const hours = Math.floor((total % 86400) / 3600);
384
+ const minutes = Math.floor((total % 3600) / 60);
385
+ const seconds = total % 60;
386
+ if (days) return `${days}d ${hours}h`;
387
+ if (hours) return `${hours}h ${minutes}m`;
388
+ if (minutes) return `${minutes}m ${seconds}s`;
389
+ return `${seconds}s`;
390
+ }
391
+
215
392
  function waitForExit(pid, timeoutMs = 5000) {
216
393
  return new Promise((resolve) => {
217
394
  const started = Date.now();
@@ -274,6 +451,109 @@ export async function runRestart(options = {}) {
274
451
  return runStart(options);
275
452
  }
276
453
 
454
+ // ----------------------------------------------------------------- logs ---
455
+
456
+ // `onboarder logs` — the answer to "it is running in the background, what is it
457
+ // doing?". Prints the tail of the same file the background child writes, and
458
+ // `-f` follows it. Line-oriented and level-colored, so a wall of request logs
459
+ // stays scannable instead of being one undifferentiated block.
460
+ export async function runLogs({ flags = {}, out = console.log, err = console.error } = {}) {
461
+ const file = flags.config || configPath();
462
+ const log = logPath(file);
463
+ const lines = flags.lines === undefined ? 40 : Math.max(1, Number(flags.lines) || 40);
464
+ if (!await logExists(log)) {
465
+ if (flags.json) out(JSON.stringify({ log, exists: false, lines: [] }, null, 2));
466
+ else {
467
+ err(` No log file yet at ${log}.`);
468
+ err(dim(' A foreground `onboarder start` prints to the terminal; only `onboarder start background` writes this file.'));
469
+ }
470
+ return flags.json ? 0 : 1;
471
+ }
472
+ if (!flags.follow) {
473
+ const tail = await tailLog(log, lines);
474
+ if (flags.json) { out(JSON.stringify({ log, exists: true, lines: tail }, null, 2)); return 0; }
475
+ out('');
476
+ out(bold(` ${log}`) + dim(` (last ${tail.length} of ${lines})`));
477
+ out('');
478
+ for (const line of tail) out(' ' + line);
479
+ out('');
480
+ return 0;
481
+ }
482
+ if (flags.json) { err(' --follow and --json do not combine; pick one.'); return 2; }
483
+ out('');
484
+ out(bold(` Following ${log}`) + dim(' (Ctrl-C to stop)'));
485
+ out('');
486
+ const stop = followLog(log, (line) => out(' ' + line), { from: 'start' });
487
+ const finish = () => { stop(); process.exit(0); };
488
+ process.once('SIGINT', finish);
489
+ await new Promise((resolve) => process.once('exit', resolve));
490
+ return 0;
491
+ }
492
+
493
+ // -------------------------------------------------------------- startup ---
494
+
495
+ // `onboarder start startup` — run at login, not just right now. Three verbs
496
+ // because the three actions are genuinely different: `install` writes the
497
+ // artifact and hands it to the OS, `remove` takes it back out, `status` only
498
+ // looks. `install` also starts it once so the person is not left waiting for the
499
+ // next reboot to find out whether it worked.
500
+ export async function runStartup(action = 'status', { flags = {}, out = console.log, err = console.error } = {}) {
501
+ const file = flags.config || configPath();
502
+ const target = startupTarget();
503
+ const log = logPath(file);
504
+
505
+ if (action === 'install' || action === 'enable' || action === 'on') {
506
+ const result = await installStartup({ configFile: file, log, target });
507
+ if (!result.ok) {
508
+ err(bad(' Could not install the startup entry.'));
509
+ err(' ' + (result.reason || result.output || result.error || 'unknown error'));
510
+ return 1;
511
+ }
512
+ out(tick + `Onboarder will start at login (${target.hint}).`);
513
+ out(kv('Startup file', result.path));
514
+ if (result.output) out(dim(' ' + result.output.split('\n').slice(-2).join('\n ')));
515
+ out('');
516
+ out(dim(' Start it right now too: ') + cyan('onboarder start background'));
517
+ out(dim(' Turn it off again: ') + cyan('onboarder start startup remove'));
518
+ out('');
519
+ return 0;
520
+ }
521
+
522
+ if (action === 'remove' || action === 'disable' || action === 'off' || action === 'uninstall') {
523
+ const status = await startupStatus(target);
524
+ if (!status.installed) {
525
+ out(dash + 'No startup entry is installed — nothing to remove.');
526
+ return 0;
527
+ }
528
+ const result = await removeStartup({ target });
529
+ if (!result.ok) { err(bad(' Could not remove the startup entry: ' + (result.reason || 'unknown error'))); return 1; }
530
+ out(tick + 'Startup entry removed. A running server is untouched — use `onboarder stop` for that.');
531
+ out(kv('Removed', result.path));
532
+ out('');
533
+ return 0;
534
+ }
535
+
536
+ if (action === 'status' || action === undefined) {
537
+ const status = await startupStatus(target);
538
+ if (flags.json) { out(JSON.stringify(status, null, 2)); return status.installed ? 0 : 1; }
539
+ out('');
540
+ out(panel('Onboarder at login', [
541
+ row('State', status.installed
542
+ ? (status.active ? MARK.yes('Enabled and running') : MARK.warn('Enabled, not running'))
543
+ : MARK.no('Not enabled')),
544
+ row('How', status.detail),
545
+ ...(status.path ? [row('File', dim(status.path))] : []),
546
+ ...(status.supported ? [hint(status.installed
547
+ ? 'Remove it with `onboarder start startup remove`.'
548
+ : 'Enable it with `onboarder start startup install`.')] : []),
549
+ ]));
550
+ out('');
551
+ return status.installed ? 0 : 1;
552
+ }
553
+
554
+ throw new Error('Usage: onboarder start startup [install|remove|status]');
555
+ }
556
+
277
557
  // --------------------------------------------------------------- config ---
278
558
 
279
559
  // Keys `config set` may touch — the same allow-list the HTTP API enforces, so
@@ -628,7 +908,7 @@ export async function runTunnel(kind, { flags = {}, out = console.log, err = con
628
908
  if (m && !announced) {
629
909
  announced = true;
630
910
  out(tick + 'Public URL: ' + bold(m[0]));
631
- out(dim(' Anyone with the URL still needs the access key: ' + m[0] + '?key=<your-key>'));
911
+ out(dim(' Anyone with the URL still needs the access key: ' + m[0]));
632
912
  }
633
913
  if (flags.verbose) process.stderr.write(text);
634
914
  });
package/cli/main.js CHANGED
@@ -8,7 +8,7 @@ import fs from 'node:fs';
8
8
  import { parseArgs } from 'node:util';
9
9
 
10
10
  import {
11
- runSetup, runStart, runStatus, runStop, runRestart,
11
+ runSetup, runStart, runStartBackground, runStartup, runLogs, runStatus, runStop, runRestart,
12
12
  runConfig, runConfigKey, runConfigReset, runDoctor, runTunnel, runHttps,
13
13
  } from './commands.js';
14
14
 
@@ -20,7 +20,10 @@ const HELP = `
20
20
  Usage
21
21
  onboarder Start the server (runs setup first if needed)
22
22
  onboarder setup | onboard Configure interactively (the wizard)
23
- onboarder start Start the server
23
+ onboarder start Start in the foreground (Ctrl-C stops it)
24
+ onboarder start background Start detached — keeps running after you close the terminal
25
+ onboarder start startup Run automatically at login [install|remove|status]
26
+ onboarder logs Show recent log lines (-n <count>, -f to follow)
24
27
  onboarder status Show whether the server is running
25
28
  onboarder stop Stop the running server
26
29
  onboarder restart Stop and start again
@@ -48,11 +51,16 @@ const HELP = `
48
51
  Also accepted
49
52
  help | --help onboarder help | onboarder --help
50
53
  config | config show
54
+ bg | detached alias for start background
55
+ fg | foreground alias for start
51
56
 
52
57
  Examples
53
58
  onboarder setup
59
+ onboarder start background # leave it running, close the terminal
60
+ onboarder logs -f # watch what it is doing
61
+ onboarder start startup install # also start it every time you log in
62
+ onboarder start background && open http://localhost:4310
54
63
  onboarder setup --non-interactive --mode local --port 4310
55
- onboarder setup --non-interactive --mode self-hosted --host 0.0.0.0
56
64
  onboarder setup --non-interactive --mode self-hosted --domain map.example.com --https --start
57
65
  onboarder config set tunnel.cloudflare true && onboarder tunnel cloudflare
58
66
  `;
@@ -82,6 +90,9 @@ const OPTIONS = {
82
90
  cloudflare: { type: 'boolean' },
83
91
  tailscale: { type: 'boolean' },
84
92
  'auto-open': { type: 'boolean' },
93
+ lines: { type: 'string', short: 'n' },
94
+ follow: { type: 'boolean', short: 'f' },
95
+ timeout: { type: 'string' },
85
96
  };
86
97
 
87
98
  // parseArgs speaks kebab-case; the wizard's flags speak camelCase.
@@ -115,8 +126,33 @@ export async function main(argv = process.argv.slice(2)) {
115
126
  console.log(HELP);
116
127
  return 0;
117
128
  case undefined:
129
+ return codeOf(await runStart({ flags }));
118
130
  case 'start':
131
+ // `onboarder start [background|fg|startup [action]]`. The sub-verb is a
132
+ // positional, not a flag, because `onboarder start` has to keep working
133
+ // exactly as it did and `startup install` is a different verb from
134
+ // `start`.
135
+ if (sub === 'background' || sub === 'bg' || sub === 'daemon' || sub === 'detached') {
136
+ return codeOf(await runStartBackground({ flags }));
137
+ }
138
+ if (sub === 'startup' || sub === 'login' || sub === 'autostart') {
139
+ return codeOf(await runStartup(rest[0] || 'status', { flags }));
140
+ }
141
+ if (sub === 'fg' || sub === 'foreground') return codeOf(await runStart({ flags }));
142
+ if (sub === 'status') return codeOf(await runStatus({ flags }));
143
+ if (sub === 'stop') return codeOf(await runStop({ flags }));
144
+ if (sub === 'logs') return codeOf(await runLogs({ flags }));
145
+ if (sub) {
146
+ console.error(' Unknown start mode: ' + sub + '\n' + HELP);
147
+ return 2;
148
+ }
119
149
  return codeOf(await runStart({ flags }));
150
+ case 'logs':
151
+ case 'log':
152
+ return codeOf(await runLogs({ flags }));
153
+ case 'startup':
154
+ case 'autostart':
155
+ return codeOf(await runStartup(sub || 'status', { flags }));
120
156
  case 'status':
121
157
  return codeOf(await runStatus({ flags }));
122
158
  case 'stop':
package/cli/ui.js CHANGED
@@ -53,3 +53,37 @@ export function kv(label, value) {
53
53
  export const tick = ok(' ✓ ');
54
54
  export const cross = bad(' ✗ ');
55
55
  export const dash = dim(' – ');
56
+
57
+ // Visible width, ANSI codes excluded. Padding has to be computed on what the
58
+ // terminal *shows*, not on the bytes we wrote, or every colored row grows a
59
+ // few columns and the column stops being a column.
60
+ export function width(text) {
61
+ return String(text).replace(/\x1b\[[0-9;]*m/g, '').length;
62
+ }
63
+
64
+ // A titled block of rows. The box is drawn from the widest row rather than a
65
+ // fixed 80 columns, so it stays aligned in a narrow terminal and does not stretch
66
+ // across a wide one.
67
+ export function panel(title, rows, { indent = ' ' } = {}) {
68
+ const labelWidth = Math.max(...rows.map((r) => width(r.label ?? '')), 0);
69
+ // Every row starts two columns in; the label column is then as wide as the
70
+ // longest label, so all the values line up regardless of label length.
71
+ const body = rows.map((r) => r.hint
72
+ ? ' ' + ' '.repeat(labelWidth + 4) + dim(r.hint)
73
+ : ' ' + paint((r.label ?? '').padEnd(labelWidth), 'gray') + ' ' + (r.value ?? ''));
74
+ // The frame is sized from its contents: a long path widens the box instead of
75
+ // spilling out of it, and a short one does not stretch to 80 columns.
76
+ const inner = Math.max(title.length + 4, ...body.map(width), 24);
77
+ const top = '┌─ ' + bold(title) + ' ' + '─'.repeat(Math.max(1, inner - title.length - 3)) + '┐';
78
+ const bottom = '└' + '─'.repeat(inner) + '┘';
79
+ return [top, ...body, bottom].map((line) => indent + (line === top || line === bottom ? paint(line, 'gray') : line)).join('\n');
80
+ }
81
+
82
+ // A one-line hint under a row, wrapped in the panel's dim voice.
83
+ export const hint = (text) => ({ hint: text });
84
+
85
+ // Multi-line values (a command, a path list) still align on the first line.
86
+ export function row(label, value) {
87
+ return { label, value: String(value) };
88
+ }
89
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebase-onboarder",
3
- "version": "0.3.0",
3
+ "version": "0.4.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
@@ -411,13 +411,14 @@
411
411
  <button class="btn btn-ghost btn-sm" id="srvRotate" title="Mint a new key; the old one dies immediately">Rotate</button>
412
412
  </div>
413
413
  <div class="server-fresh-key" id="srvFreshKeyRow" hidden>
414
- <p class="drawer-fine">New key — shown once. It is already saved in this browser:</p>
414
+ <p class="drawer-fine">New key — shown once. This browser has been signed in with it:</p>
415
415
  <div class="mcp-config-row">
416
416
  <input type="text" class="text-input mcp-config-input" id="srvFreshKey" readonly spellcheck="false">
417
417
  <button class="btn btn-ghost btn-sm" id="srvCopyKey">Copy</button>
418
418
  </div>
419
419
  </div>
420
420
  <p class="drawer-fine" id="srvKeyHint"></p>
421
+ <button class="btn btn-ghost btn-sm" id="srvLogout">Sign out this browser</button>
421
422
  </div>
422
423
 
423
424
  <div class="server-section">