codebase-onboarder 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,195 @@
1
+ // Running the server without the terminal that asked for it.
2
+ //
3
+ // `onboarder start` keeps the process attached: closing the shell kills it, and
4
+ // that is the right behavior for a foreground command. `onboarder start
5
+ // background` wants the opposite — the whole point is that the terminal can go
6
+ // away — so this module does what a shell job control cannot do portably: it
7
+ // re-launches the CLI as a **detached** child with its stdio pointed at a log
8
+ // file, unrefs it, and then waits for the port to actually answer before it
9
+ // claims success.
10
+ //
11
+ // Two rules keep this honest:
12
+ //
13
+ // 1. Never report "running" on the strength of the spawn alone. `spawn`
14
+ // returning a pid proves a process was created, not that it bound the port
15
+ // or survived settings validation. Readiness is an HTTP answer from
16
+ // `/api/health`, polled with a deadline; on timeout the caller gets the
17
+ // child's last log lines, not a false green light.
18
+ // 2. Detach properly, or "background" is a lie. `detached: true` puts the
19
+ // child in its own process group, and `unref()` drops our handle on it, so
20
+ // a Ctrl-C in the parent terminal does not take the server down with it.
21
+
22
+ import { spawn } from 'node:child_process';
23
+ import fs from 'node:fs';
24
+ import fsp from 'node:fs/promises';
25
+ import http from 'node:http';
26
+ import path from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ import { configPath } from './config.js';
30
+ import { readPidFile } from './pidfile.js';
31
+
32
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
33
+
34
+ // The installed entry point, not `process.argv[1]`: a globally installed
35
+ // `onboarder` is a symlink into node_modules, and the child has to run the real
36
+ // file to find its siblings.
37
+ export const CLI_ENTRY = path.resolve(HERE, '..', 'bin', 'onboarder.js');
38
+
39
+ // One file per config, beside the config, so `--config` isolation (tests,
40
+ // containers, several profiles) carries the log with it.
41
+ export function logPath(configFile = configPath()) {
42
+ return path.join(path.dirname(path.resolve(configFile)), 'onboarder.log');
43
+ }
44
+
45
+ // Rotated history. One generation is deliberate: a log that grows without bound
46
+ // is a bug report waiting to happen, and one previous file is enough to see what
47
+ // happened just before a crash.
48
+ export const MAX_LOG_BYTES = 2 * 1024 * 1024;
49
+
50
+ export async function rotateLogIfNeeded(file = logPath(), maxBytes = MAX_LOG_BYTES) {
51
+ try {
52
+ const { size } = await fsp.stat(file);
53
+ if (size <= maxBytes) return false;
54
+ await fsp.rename(file, file + '.1');
55
+ return true;
56
+ } catch {
57
+ return false; // no log yet, or not ours to move
58
+ }
59
+ }
60
+
61
+ export async function readLog(file = logPath(), bytes = 64 * 1024) {
62
+ try {
63
+ const handle = await fsp.open(file, 'r');
64
+ try {
65
+ const { size } = await handle.stat();
66
+ const start = Math.max(0, size - bytes);
67
+ const buffer = Buffer.alloc(size - start);
68
+ await handle.read(buffer, 0, buffer.length, start);
69
+ return buffer.toString('utf8');
70
+ } finally {
71
+ await handle.close();
72
+ }
73
+ } catch {
74
+ return '';
75
+ }
76
+ }
77
+
78
+ // The last `count` lines, oldest first — the shape `onboarder logs` prints and
79
+ // the shape an error report wants pasted into it.
80
+ export async function tailLog(file = logPath(), count = 40) {
81
+ const text = await readLog(file);
82
+ const lines = text.split('\n').filter((line) => line.trim());
83
+ return lines.slice(-Math.max(1, count));
84
+ }
85
+
86
+ export async function logExists(file = logPath()) {
87
+ try { await fsp.access(file); return true; } catch { return false; }
88
+ }
89
+
90
+ // Ask the server whether it is up. A 2xx–4xx from /api/health means the socket
91
+ // is bound and the router is serving; the body is not interesting, the answer is.
92
+ export function probe(url, timeoutMs = 1000) {
93
+ return new Promise((resolve) => {
94
+ const request = http.get(url, { timeout: timeoutMs }, (res) => {
95
+ res.resume();
96
+ resolve(res.statusCode >= 200 && res.statusCode < 500);
97
+ });
98
+ request.on('timeout', () => { request.destroy(); resolve(false); });
99
+ request.on('error', () => resolve(false));
100
+ });
101
+ }
102
+
103
+ export async function waitForReady(url, { timeoutMs = 20000, intervalMs = 200, check = probe } = {}) {
104
+ const deadline = Date.now() + timeoutMs;
105
+ for (;;) {
106
+ if (await check(url)) return true;
107
+ if (Date.now() >= deadline) return false;
108
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
109
+ }
110
+ }
111
+
112
+ // Re-run this same CLI, detached. The child is a plain foreground `start` — it
113
+ // writes the pid file, prints its own banner, and handles signals exactly as it
114
+ // always has. Nothing about the server changes; only who is holding the terminal
115
+ // does.
116
+ export function spawnDetached({ configFile = configPath(), entry = CLI_ENTRY, env = process.env, log = logPath(configFile) } = {}) {
117
+ const fd = fs.openSync(log, 'a');
118
+ try {
119
+ const child = spawn(process.execPath, [entry, 'start', '--config', configFile], {
120
+ detached: true,
121
+ stdio: ['ignore', fd, fd],
122
+ env: { ...env, ONBOARDER_BACKGROUND: '1' },
123
+ });
124
+ child.on('error', () => {});
125
+ child.unref();
126
+ return child.pid;
127
+ } finally {
128
+ fs.closeSync(fd);
129
+ }
130
+ }
131
+
132
+ // Does the OS still have this process? Signal 0 asks without delivering.
133
+ function alive(pid) {
134
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
135
+ }
136
+
137
+ // Wait for the detached child to become the *recorded* server. The pid file is
138
+ // the authority here, not the spawn pid: it is what `status` and `stop` read,
139
+ // so waiting on it means the process we report is the one the user can control.
140
+ export async function waitForPidFile(configFile, { timeoutMs = 20000, intervalMs = 150, readPid = readPidFile, isAlive = alive } = {}) {
141
+ const deadline = Date.now() + timeoutMs;
142
+ for (;;) {
143
+ const pid = readPid(configFile);
144
+ if (pid && isAlive(pid)) return pid;
145
+ if (Date.now() >= deadline) return null;
146
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
147
+ }
148
+ }
149
+
150
+ // Follow a growing file. Polling rather than fs.watch: the log is appended by a
151
+ // *different* process, and watchers on a file another process holds open are
152
+ // unreliable across platforms (and absent on some network mounts). Half a second
153
+ // of latency on a human-facing log tail is invisible.
154
+ export function followLog(file, onLine, { intervalMs = 500, from = 'end' } = {}) {
155
+ let position = 0;
156
+ let partial = '';
157
+ let stopped = false;
158
+ const stop = () => { stopped = true; };
159
+
160
+ if (from === 'start') {
161
+ fsp.readFile(file, 'utf8').then(
162
+ (text) => { for (const line of text.split('\n')) if (line) onLine(line); },
163
+ () => {},
164
+ );
165
+ }
166
+
167
+ const tick = async () => {
168
+ if (stopped) return;
169
+ try {
170
+ const { size } = await fsp.stat(file);
171
+ if (size < position) { position = 0; partial = ''; } // rotated under us
172
+ if (size > position) {
173
+ const handle = await fsp.open(file, 'r');
174
+ try {
175
+ const length = size - position;
176
+ const buffer = Buffer.alloc(length);
177
+ await handle.read(buffer, 0, length, position);
178
+ position = size;
179
+ // A read can land mid-line; hold the remainder until its newline shows.
180
+ const lines = (partial + buffer.toString('utf8')).split('\n');
181
+ partial = lines.pop() ?? '';
182
+ for (const line of lines) onLine(line);
183
+ } finally {
184
+ await handle.close();
185
+ }
186
+ }
187
+ } catch {
188
+ // The file may not exist yet (first run) — try again on the next tick.
189
+ }
190
+ if (!stopped) setTimeout(tick, intervalMs).unref();
191
+ };
192
+
193
+ setTimeout(tick, intervalMs).unref();
194
+ return stop;
195
+ }
package/server/index.js CHANGED
@@ -23,7 +23,9 @@ import { createLogger } from './logger.js';
23
23
  import { createMcpRunner } from './mcp/runner.js';
24
24
  import { browserUrl, configPath, isLoopbackHost, readSettings, serverUrls } from './config.js';
25
25
  import { tunnelStatus } from './tunnel.js';
26
- import { pidIsAlive, readPidFile, removePidFile, writePidFile } from './pidfile.js';
26
+ import { pidIsAlive, readPidFile, removePidFile, writePidFile, writeRunInfo, runInfoPath } from './pidfile.js';
27
+ import { logPath } from './daemon.js';
28
+ import { panel, row } from './layout.js';
27
29
 
28
30
  const logger = createLogger();
29
31
 
@@ -63,42 +65,45 @@ export function createServer(config = CONFIG) {
63
65
 
64
66
  // What the terminal shows once the socket is listening. A pure string builder
65
67
  // so the CLI prints exactly this too, and so a test can read it.
66
- export function startupBanner(settings, { configFile } = {}) {
68
+ //
69
+ // The shape is label/value rows, not prose, because every fact here has to be
70
+ // readable at a glance and copy-pasteable. The one long sentence it used to
71
+ // print — "self-hosted — the network can reach this; every API call needs the
72
+ // access key" — wrapped unpredictably at any terminal width and buried the URL
73
+ // people actually need. Now the detail lives on its own row.
74
+ export function startupBanner(settings, { configFile, columns } = {}) {
67
75
  const urls = serverUrls(settings);
68
- const lines = ['', ' Onboarder is up.'];
69
- lines.push(settings.mode === 'self-hosted'
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')
73
- : ' Mode local — only this machine can reach it');
74
- lines.push(' Local ' + urls.local);
75
- if (urls.network) lines.push(' Network ' + urls.network);
76
- if (urls.domain) {
77
- lines.push(' Domain ' + urls.domain);
78
- if (settings.domain && !settings.https) {
79
- lines.push(' HTTPS disabled — `onboarder setup` or `onboarder https setup` enables trusted TLS');
80
- } else if (settings.https) {
81
- lines.push(' HTTPS Caddy obtains, renews, and terminates TLS for this domain');
82
- }
83
- }
84
- if (settings.mode === 'self-hosted' && settings.accessKey) {
85
- lines.push(' Remote browsers show an access-key sign-in page; the key is never put in the URL.');
76
+ const rows = [];
77
+ const add = (label, value) => rows.push([label, value]);
78
+
79
+ add('URL', urls.local);
80
+ if (urls.network) add('Network', urls.network);
81
+ if (urls.domain) add('Public', urls.domain);
82
+ add('Bind', `${settings.host}:${settings.port}`);
83
+ add('Mode', settings.mode === 'self-hosted'
84
+ ? (isLoopbackHost(settings.host) ? 'self-hosted (loopback — use a tunnel)' : 'self-hosted (network reachable)')
85
+ : 'local (this machine only)');
86
+
87
+ if (settings.mode === 'self-hosted') {
88
+ add('Auth', settings.accessKey
89
+ ? 'access key required for remote browsers'
90
+ : 'NO ACCESS KEY — every API call is refused');
86
91
  }
87
- if (settings.mode === 'self-hosted' && !settings.accessKey) {
88
- lines.push(' WARNING self-hosted with no access key — every API call is refused until one is set.');
89
- lines.push(' Run `onboarder setup` or `onboarder config key rotate`.');
92
+ if (settings.domain) {
93
+ add('HTTPS', settings.https ? 'Caddy terminates TLS for this domain' : 'off — `onboarder https setup` enables it');
90
94
  }
91
95
  const tunnels = tunnelStatus(settings);
92
96
  for (const name of ['cloudflare', 'tailscale']) {
93
97
  const t = tunnels[name];
94
98
  if (!t.enabled) continue;
95
- lines.push(t.installed
96
- ? ` Tunnel ${name}: ${t.command}`
97
- : ` Tunnel ${name} is enabled but its CLI is not installed — ${t.install}`);
99
+ add('Tunnel', t.installed ? `${name}: ${t.command}` : `${name} enabled, CLI missing — ${t.install}`);
98
100
  }
99
- if (configFile) lines.push(' Config ' + configFile);
100
- lines.push('');
101
- return lines.join('\n');
101
+ if (configFile) add('Config', configFile);
102
+
103
+ // Rendered through the same panel helper the CLI uses, so `node server/index.js`
104
+ // and `onboarder start` cannot drift apart — and so both adapt to the terminal
105
+ // width instead of assuming 80 columns.
106
+ return '\n' + panel('Onboarder is up', rows.map(([label, value]) => row(label, value)), { columns }) + '\n';
102
107
  }
103
108
 
104
109
  // Ask the OS to open the app. Best-effort and detached: a missing opener on a
@@ -164,14 +169,33 @@ export async function startServer({ configFile = configPath(), openBrowser, log
164
169
  throw listenError(error, { host, port, pid: recorded && pidIsAlive(recorded) ? recorded : null });
165
170
  }
166
171
 
172
+ // The process record is optional by design, so each write is guarded on its
173
+ // own: a read-only config directory must not stop a server that has already
174
+ // bound its port, but a *programming* error here would otherwise be swallowed
175
+ // and look like "it started, but nothing recorded it".
176
+ try { writePidFile(configFile); } catch { /* read-only config dir */ }
177
+ // How this process was launched. `background` is a detached child with a log
178
+ // file; `startup` is one the OS supervisor started at login (also with a log
179
+ // file); anything else is a person typing `onboarder start` in a terminal.
180
+ const mode = process.env.ONBOARDER_LAUNCH || (process.env.ONBOARDER_BACKGROUND ? 'background' : 'foreground');
181
+ const logFile = mode === 'foreground' ? null : logPath(configFile);
167
182
  try {
168
- writePidFile(configFile);
169
- server.once('close', () => removePidFile(configFile));
170
- process.once('exit', () => removePidFile(configFile));
171
- } catch {
172
- // The server is useful even when a read-only config directory cannot hold
173
- // the optional process record; binding and serving are the real contract.
183
+ // Beside the pid: how this instance was launched and where its log goes, so
184
+ // `onboarder status` answers "how long has it been up, and how do I see its
185
+ // output" from a record instead of guessing.
186
+ writeRunInfo(configFile, {
187
+ mode,
188
+ host,
189
+ port,
190
+ url: serverUrls(live).local,
191
+ log: logFile,
192
+ node: process.version,
193
+ });
194
+ } catch (error) {
195
+ logger.warn('could not write the run record', { file: runInfoPath(configFile), error: error.message });
174
196
  }
197
+ server.once('close', () => removePidFile(configFile));
198
+ process.once('exit', () => removePidFile(configFile));
175
199
 
176
200
  log(startupBanner(live, { configFile }));
177
201
 
@@ -0,0 +1,73 @@
1
+ // Terminal geometry, with no color and no dependencies.
2
+ //
3
+ // This lives here, under `server/`, because two very different callers need
4
+ // identical layout: the CLI (which paints) and the server's own startup banner
5
+ // (which must not import `cli/ui.js` — that would invert the dependency, since
6
+ // the CLI sits on top of the server). So the *shape* is here and the *color* is
7
+ // injected by the caller as a `paint(text, style)` function.
8
+ //
9
+ // Everything is pure string math on purpose: a layout decision is a thing you
10
+ // want to unit-test without spawning a terminal, and the whole reason panels
11
+ // used to look broken is that this math was scattered through the callers.
12
+
13
+ export function width(text) {
14
+ return String(text).replace(/\x1b\[[0-9;]*m/g, '').length;
15
+ }
16
+
17
+ // The terminal we are drawing into, asked at call time rather than cached at
18
+ // import: a panel printed after the user resized their window should use the
19
+ // new size. Falls back to COLUMNS, then 80, so a pipe or a log file still gets
20
+ // sane output instead of `undefined`.
21
+ export function termWidth(stream = process.stdout) {
22
+ return stream.columns || Number(process.env.COLUMNS) || 80;
23
+ }
24
+
25
+ // Shorten to fit, with a real ellipsis. The middle is elided rather than the
26
+ // tail, because in a path or URL the end is the part that identifies it.
27
+ export function fit(text, max, { tail = true } = {}) {
28
+ const value = String(text);
29
+ if (max <= 0) return '';
30
+ if (value.length <= max) return value;
31
+ if (max === 1) return '…';
32
+ if (!tail) return value.slice(0, max - 1) + '…';
33
+ const head = Math.ceil((max - 1) * 0.4);
34
+ const rest = max - 1 - head;
35
+ return (head ? value.slice(0, head) + '…' : '…') + value.slice(value.length - rest);
36
+ }
37
+
38
+ // A titled block of rows that fits the terminal it is printed into. The frame is
39
+ // sized from its contents, then clamped to the available width, and every value
40
+ // is elided to fit rather than allowed to spill past the border — a panel whose
41
+ // right edge is off-screen is what makes output "mess the terminal".
42
+ //
43
+ // `paint` is injected: `(text, style) => string`, where style is 'label' or
44
+ // 'frame'. The default is identity, which is what the server banner and the
45
+ // `--no-color` path want.
46
+ export function panel(title, rows, { indent = ' ', columns, paint = (t) => String(t) } = {}) {
47
+ // The hard ceiling is the terminal. A floor below it would be a lie: on a 40
48
+ // column terminal a 59-wide panel is exactly the overflow this exists to
49
+ // prevent, so the floor only guards against a nonsensical zero, and short
50
+ // titles/values are handled by the `fit` calls below rather than by a minimum.
51
+ const available = Math.max(24, (columns || termWidth()) - indent.length);
52
+ const labelWidth = Math.min(12, Math.max(...rows.map((r) => width(r.label ?? '')), 0));
53
+ // indent(2) + gap(2) + label + gap(2) + value + right border(2)
54
+ const valueRoom = Math.max(8, available - 2 - labelWidth - 2 - 2);
55
+ const body = rows.map((r) => (r.hint
56
+ ? ' ' + ' '.repeat(labelWidth + 2) + paint(fit(r.hint, valueRoom), 'hint')
57
+ : ' ' + paint(fit(r.label ?? '', labelWidth).padEnd(labelWidth), 'label') + ' ' + fit(r.value ?? '', valueRoom)));
58
+
59
+ // Frame width = whatever the body needs, capped to what the terminal has.
60
+ const wanted = Math.max(...body.map(width), 12);
61
+ const inner = Math.max(8, Math.min(available - 2, wanted));
62
+ const shownTitle = fit(title, Math.max(2, inner - 4));
63
+ const top = paint('┌─ ', 'frame') + paint(shownTitle, 'title')
64
+ + ' ' + paint('─'.repeat(Math.max(0, inner - shownTitle.length - 3)) + '┐', 'frame');
65
+ const bottom = paint('└' + '─'.repeat(inner) + '┘', 'frame');
66
+ return [top, ...body, bottom].map((line) => indent + line).join('\n');
67
+ }
68
+
69
+ // A one-line note rendered in the panel's muted voice.
70
+ export const hint = (text) => ({ hint: text });
71
+
72
+ // A label/value row. Values are stringified here so a caller can pass a number.
73
+ export const row = (label, value) => ({ label, value: String(value) });