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.
- package/README.md +66 -2
- package/cli/commands.js +323 -12
- package/cli/main.js +39 -3
- package/cli/ui.js +18 -0
- package/package.json +1 -1
- package/server/daemon.js +195 -0
- package/server/index.js +59 -35
- package/server/layout.js +73 -0
- package/server/logger.js +329 -15
- package/server/pidfile.js +38 -1
- package/server/startup.js +261 -0
- package/shared/analyzer/graph.js +6 -1
- package/shared/analyzer/languages/csharp.js +81 -6
- package/shared/analyzer/languages/java.js +79 -4
- package/shared/analyzer/languages/rust.js +104 -2
- package/shared/analyzer/scan.js +37 -6
package/README.md
CHANGED
|
@@ -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
|
|
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,6 +229,60 @@ 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
|
|
|
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 INFO GET /api/scan 200 (1.2s)
|
|
246
|
+
16:09:46 INFO POST /api/auth/login 200 (2ms)
|
|
247
|
+
16:10:03 WARN could not write the run record error=EACCES
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### What gets logged (and what does not)
|
|
251
|
+
|
|
252
|
+
A single page load pulls ~90 ES modules, a stylesheet, and two vendored libraries. Logging each one buries every real event — a scan, a login, a 500 — under a hundred lines that describe nobody doing anything, repeated on every reload. So requests are classified:
|
|
253
|
+
|
|
254
|
+
| Request | Logged? |
|
|
255
|
+
|---|---|
|
|
256
|
+
| `GET /api/…` (a scan, a login, a settings save) | **yes** — this is a person doing something |
|
|
257
|
+
| Any `POST`/`PUT`/`DELETE` | **yes**, whatever the path |
|
|
258
|
+
| Any `4xx` or `5xx` | **yes** — a missing asset is a broken build, a 500 is a bug |
|
|
259
|
+
| `GET /api/health` (uptime poll) | no — it is not an event |
|
|
260
|
+
| `GET /app.js`, `/js/tree.js`, `/styles.css`, `/vendor/…` | **counted, not printed** |
|
|
261
|
+
|
|
262
|
+
Suppressed assets are not thrown away. Every 40 of them, one dim footnote is printed, so an idle terminal still says what it served rather than looking dead:
|
|
263
|
+
|
|
264
|
+
```
|
|
265
|
+
16:09:38 served 40 static files in 1.9s
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
`LOG_LEVEL=debug` (or `ONBOARDER_LOG_VERBOSE=1`) turns every request back on for when you are debugging the server rather than watching it. `ONBOARDER_LOG=json` switches the format to one JSON object per line for anything parsing the log.
|
|
269
|
+
|
|
270
|
+
### Terminal width
|
|
271
|
+
|
|
272
|
+
Every panel and log line is fitted to the terminal it is printed into, and a foreground `onboarder start` redraws its panel on `SIGWINCH` — resize the window and the border stays on screen instead of hanging off the edge. Long values are elided in the middle (the tail of a path is the part that identifies it), and below 52 columns the level column is dropped to make room for the message.
|
|
273
|
+
|
|
274
|
+
### Starting at login
|
|
275
|
+
|
|
276
|
+
`onboarder start startup install` registers Onboarder with whatever your OS uses for login items, and never asks for `sudo`:
|
|
277
|
+
|
|
278
|
+
| Platform | What it writes | How it loads |
|
|
279
|
+
|---|---|---|
|
|
280
|
+
| macOS | `~/Library/LaunchAgents/com.onboarder.server.plist` | `launchctl bootstrap gui/$UID` |
|
|
281
|
+
| Linux | `~/.config/systemd/user/onboarder.service` | `systemctl --user enable --now` |
|
|
282
|
+
| Windows | `%APPDATA%\...\Startup\Onboarder.cmd` | the file *is* the registration |
|
|
283
|
+
|
|
284
|
+
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.
|
|
285
|
+
|
|
226
286
|
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.
|
|
227
287
|
|
|
228
288
|
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.
|
|
@@ -255,7 +315,11 @@ codebase-onboarder/
|
|
|
255
315
|
├── server/ # Zero-dependency Node.js HTTP server
|
|
256
316
|
│ ├── index.js # createServer / startServer / startup banner
|
|
257
317
|
│ ├── config.js # Settings schema, normalization, atomic 0600 writes
|
|
258
|
-
│ ├── pidfile.js # PID
|
|
318
|
+
│ ├── pidfile.js # PID + run record (mode, url, log) for status / stop / restart
|
|
319
|
+
│ ├── daemon.js # Detached background start, log file, readiness probe
|
|
320
|
+
│ ├── startup.js # Login items: launchd plist / systemd --user unit / Startup folder
|
|
321
|
+
│ ├── logger.js # Aligned-text or JSON log lines from one entry shape
|
|
322
|
+
│ ├── layout.js # Shared terminal geometry: width, fit, panel, resize
|
|
259
323
|
│ ├── router.js # Route table, live per-request settings, auth & CSRF gates
|
|
260
324
|
│ ├── auth.js # Signed HttpOnly browser sessions
|
|
261
325
|
│ ├── apiAuth.js # Login/status/logout endpoints
|
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,66 @@ 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
|
+
|
|
185
|
+
// Keep a foreground panel at the terminal's current width.
|
|
186
|
+
//
|
|
187
|
+
// Resizing a window after the server started used to leave a panel whose right
|
|
188
|
+
// border was off-screen — the information was correct but unreadable, which is
|
|
189
|
+
// the same complaint as "it messes up the terminal". On SIGWINCH we redraw the
|
|
190
|
+
// block in place: move the cursor up over the lines we own, clear them, and
|
|
191
|
+
// reprint at the new width. Only the panel is redrawn, not the log scrollback
|
|
192
|
+
// above it, so nothing the user has already read is disturbed.
|
|
193
|
+
//
|
|
194
|
+
// A non-TTY (a pipe, a log file) never redraws: there is no cursor to move and
|
|
195
|
+
// re-printing would just duplicate the block.
|
|
196
|
+
export function watchResize(render, stream = process.stdout) {
|
|
197
|
+
if (!stream.isTTY || typeof process.stdout.on !== 'function') return () => {};
|
|
198
|
+
let previous = '';
|
|
199
|
+
const onResize = () => {
|
|
200
|
+
const next = render();
|
|
201
|
+
if (next === previous) return;
|
|
202
|
+
const lines = previous ? previous.split('\n').length : 0;
|
|
203
|
+
// Up over the old block, clear it, print the new one. `\x1b[J` clears from
|
|
204
|
+
// the cursor to the end of the screen, which is exactly the old block.
|
|
205
|
+
stream.write(lines ? `\x1b[${lines}A\x1b[J` : '');
|
|
206
|
+
stream.write(next);
|
|
207
|
+
previous = next;
|
|
208
|
+
};
|
|
209
|
+
process.stdout.on('SIGWINCH', onResize);
|
|
210
|
+
return () => process.stdout.removeListener('SIGWINCH', onResize);
|
|
211
|
+
}
|
|
212
|
+
|
|
148
213
|
export async function runStart({ flags = {}, out = console.log, err = console.error } = {}) {
|
|
149
214
|
const file = flags.config || configPath();
|
|
150
215
|
if (!await configExists(file)) {
|
|
@@ -165,7 +230,9 @@ export async function runStart({ flags = {}, out = console.log, err = console.er
|
|
|
165
230
|
}
|
|
166
231
|
if (recorded) removePidFile(file, recorded);
|
|
167
232
|
|
|
168
|
-
|
|
233
|
+
// `quiet`: the CLI prints its own details panel below, so the loose banner
|
|
234
|
+
// would be the same facts twice. `node server/index.js` still gets the banner.
|
|
235
|
+
const started = await startServer({ configFile: file, log: () => {}, openBrowser: false });
|
|
169
236
|
if (started.settings.https) {
|
|
170
237
|
out('');
|
|
171
238
|
const httpsCode = await runHttps('setup', { flags, out: flags.json ? () => {} : out, err });
|
|
@@ -175,13 +242,119 @@ export async function runStart({ flags = {}, out = console.log, err = console.er
|
|
|
175
242
|
}
|
|
176
243
|
}
|
|
177
244
|
if (flags.json) out(JSON.stringify({ host: started.host, port: started.port, url: serverUrls(started.settings).local }));
|
|
245
|
+
// `start` foreground prints the details itself rather than letting
|
|
246
|
+
// `startServer` print the old loose banner — the panel replaces it. The
|
|
247
|
+
// background child lands here too (that is the point of reusing this path),
|
|
248
|
+
// and ONBOARDER_BACKGROUND is what tells the two apart: one is attached to a
|
|
249
|
+
// terminal you are about to close, the other is already detached from it.
|
|
250
|
+
printServerDetails(out, started, { configFile: file, mode: 'foreground' });
|
|
251
|
+
// Redraw the panel when the terminal is resized, so the border stays on screen
|
|
252
|
+
// and long paths re-elide to the new width instead of hanging off the edge.
|
|
253
|
+
watchResize(() => '\n' + panel('Onboarder is running', serverDetails(started, { configFile: file }).rows) + '\n');
|
|
254
|
+
if (process.env.ONBOARDER_LAUNCH) {
|
|
255
|
+
// Started by launchd/systemd/the Startup folder: these lines are going into
|
|
256
|
+
// a log file nobody is watching, so they say what the supervisor is doing
|
|
257
|
+
// rather than telling a person to press Ctrl-C.
|
|
258
|
+
out(dim(` Launched at login by ${process.env.ONBOARDER_LAUNCH}. This process is supervised — stop it with \`onboarder stop\`.`));
|
|
259
|
+
} else if (process.env.ONBOARDER_BACKGROUND) {
|
|
260
|
+
out(dim(' Started in the background — this process is now independent of any terminal.'));
|
|
261
|
+
} else {
|
|
262
|
+
out(dim(' Running in the foreground. Ctrl-C stops it; closing this terminal stops it too.'));
|
|
263
|
+
out(dim(' To keep it alive after you close the terminal: ') + cyan('onboarder start background'));
|
|
264
|
+
}
|
|
265
|
+
out('');
|
|
178
266
|
// The listening server holds the event loop; resolve so callers/tests know
|
|
179
267
|
// we are up, but leave the process running.
|
|
180
268
|
return { ...started, code: 0 };
|
|
181
269
|
}
|
|
182
270
|
|
|
271
|
+
// `onboarder start background` — same server, no terminal attached. The child is
|
|
272
|
+
// an ordinary foreground `start`; all this does is detach it and then *wait for
|
|
273
|
+
// it to answer* before reporting success, so a bind failure surfaces here as a
|
|
274
|
+
// failure with the log tail attached rather than a cheerful lie.
|
|
275
|
+
export async function runStartBackground({ flags = {}, out = console.log, err = console.error } = {}) {
|
|
276
|
+
const file = flags.config || configPath();
|
|
277
|
+
if (!await configExists(file) && process.stdout.isTTY && !flags.nonInteractive) {
|
|
278
|
+
out(dim(' No settings yet — running setup first.'));
|
|
279
|
+
return runSetup({ flags: { ...flags, start: 'background' }, out, err });
|
|
280
|
+
}
|
|
281
|
+
const recorded = readPidFile(file);
|
|
282
|
+
if (recorded && pidIsAlive(recorded)) {
|
|
283
|
+
err(` Onboarder is already running (PID ${recorded}).`);
|
|
284
|
+
err(dim(' Use `onboarder status`, `onboarder stop`, or `onboarder restart`.'));
|
|
285
|
+
return 1;
|
|
286
|
+
}
|
|
287
|
+
if (recorded) removePidFile(file, recorded);
|
|
288
|
+
|
|
289
|
+
const settings = await readSettings(file);
|
|
290
|
+
const log = logPath(file);
|
|
291
|
+
await rotateLogIfNeeded(log);
|
|
292
|
+
|
|
293
|
+
spawnDetached({ configFile: file, log });
|
|
294
|
+
const pid = await waitForPidFile(file, { timeoutMs: flags.timeout ? Number(flags.timeout) * 1000 : 20000 });
|
|
295
|
+
const urls = serverUrls(settings);
|
|
296
|
+
const ready = pid ? await waitForReady(new URL('/api/health', urls.local).toString(), { timeoutMs: 8000 }) : false;
|
|
297
|
+
|
|
298
|
+
if (!ready) {
|
|
299
|
+
// Two different failures with two different fixes, so they are reported
|
|
300
|
+
// differently. No pid record means the child never finished binding — the
|
|
301
|
+
// log holds the reason (a busy port, an invalid config). A live pid that will
|
|
302
|
+
// not answer means it bound and then something in front of it is in the way
|
|
303
|
+
// (a proxy, a firewall), and no amount of log-reading will show that.
|
|
304
|
+
if (!pid) {
|
|
305
|
+
err(bad(' Onboarder did not start in the background.'));
|
|
306
|
+
const tail = await tailLog(log, 15);
|
|
307
|
+
if (tail.length) {
|
|
308
|
+
err(dim(` Last lines of ${log}:`));
|
|
309
|
+
for (const line of tail) err(' ' + line);
|
|
310
|
+
}
|
|
311
|
+
err(dim(' Fix the cause above, then run `onboarder start background` again.'));
|
|
312
|
+
} else {
|
|
313
|
+
err(bad(` Onboarder is running (PID ${pid}) but ${urls.local} is not answering.`));
|
|
314
|
+
err(dim(` Its log is ${log}. If a proxy or firewall fronts this port, check that first.`));
|
|
315
|
+
err(dim(' Otherwise: `onboarder stop`, then `onboarder start background` again.'));
|
|
316
|
+
}
|
|
317
|
+
return 1;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (flags.json) {
|
|
321
|
+
out(JSON.stringify({ background: true, pid, url: urls.local, log, configFile: file }, null, 2));
|
|
322
|
+
return 0;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
out('');
|
|
326
|
+
out(panel('Onboarder is running in the background', [
|
|
327
|
+
row('URL', bold(cyan(urls.local))),
|
|
328
|
+
row('PID', String(pid)),
|
|
329
|
+
row('Mode', 'background — survives closing this terminal'),
|
|
330
|
+
row('Logs', dim(log)),
|
|
331
|
+
row('Config', dim(file)),
|
|
332
|
+
]));
|
|
333
|
+
out('');
|
|
334
|
+
out(dim(' Follow the log: ') + cyan('onboarder logs -f'));
|
|
335
|
+
out(dim(' Stop it: ') + cyan('onboarder stop'));
|
|
336
|
+
out(dim(' Check on it: ') + cyan('onboarder status'));
|
|
337
|
+
out('');
|
|
338
|
+
return 0;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
|
|
183
342
|
// ------------------------------------------------------------- lifecycle ---
|
|
184
343
|
|
|
344
|
+
// Panel rows carry their own inline marker rather than the `tick`/`cross`/`dash`
|
|
345
|
+
// constants: those bake in a two-column indent for standalone lines, which would
|
|
346
|
+
// push the first row out of the panel's label column.
|
|
347
|
+
const MARK = { yes: (s) => paint('✓ ', 'green') + s, no: (s) => paint('– ', 'gray') + s, warn: (s) => paint('! ', 'yellow') + s };
|
|
348
|
+
|
|
349
|
+
// How a live instance presents itself, in one sentence per mode. The point of
|
|
350
|
+
// the sentence is the thing someone actually needs to know: can I close my
|
|
351
|
+
// terminal, or will that kill it?
|
|
352
|
+
const MODE_NOTES = {
|
|
353
|
+
background: 'background — survives closing the terminal',
|
|
354
|
+
startup: 'started at login — the OS restarts it if it stops',
|
|
355
|
+
foreground: 'foreground — stops when you press Ctrl-C or close the terminal',
|
|
356
|
+
};
|
|
357
|
+
|
|
185
358
|
export async function runStatus({ flags = {}, out = console.log } = {}) {
|
|
186
359
|
const file = flags.config || configPath();
|
|
187
360
|
const pid = readPidFile(file);
|
|
@@ -190,6 +363,9 @@ export async function runStatus({ flags = {}, out = console.log } = {}) {
|
|
|
190
363
|
try { settings = await readSettings(file); } catch { /* defaults are still meaningful */ }
|
|
191
364
|
const portBusy = settings ? !(await portIsFree(settings.host, settings.port)) : false;
|
|
192
365
|
if (pid && !running) removePidFile(file, pid);
|
|
366
|
+
// The pid says whether it is up; the run record says *how* it came up, which
|
|
367
|
+
// is what tells someone whether closing their terminal will kill it.
|
|
368
|
+
const info = running ? readRunInfo(file) : null;
|
|
193
369
|
const result = {
|
|
194
370
|
running: Boolean(running || portBusy),
|
|
195
371
|
pid: running ? pid : null,
|
|
@@ -197,21 +373,53 @@ export async function runStatus({ flags = {}, out = console.log } = {}) {
|
|
|
197
373
|
portBusy,
|
|
198
374
|
managed: Boolean(running),
|
|
199
375
|
configFile: file,
|
|
376
|
+
mode: running ? (info?.mode || 'foreground') : null,
|
|
377
|
+
url: running ? (info?.url || (settings ? serverUrls(settings).local : null)) : null,
|
|
378
|
+
uptimeMs: running && info?.startedAt ? Date.now() - Date.parse(info.startedAt) : null,
|
|
379
|
+
log: running && info?.log ? info.log : null,
|
|
380
|
+
startup: (await startupStatus()).installed,
|
|
200
381
|
};
|
|
201
382
|
if (flags.json) {
|
|
202
383
|
out(JSON.stringify(result, null, 2));
|
|
203
384
|
} else {
|
|
204
385
|
out('');
|
|
205
|
-
out(
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
386
|
+
out(panel('Onboarder status', [
|
|
387
|
+
row('State', running
|
|
388
|
+
? MARK.yes(`Running (PID ${pid})`)
|
|
389
|
+
: portBusy
|
|
390
|
+
? MARK.warn(`Port busy — ${result.port}, but no Onboarder process record`)
|
|
391
|
+
: MARK.no('Stopped')),
|
|
392
|
+
...(running ? [
|
|
393
|
+
row('URL', cyan(result.url || '')),
|
|
394
|
+
row('Mode', MODE_NOTES[result.mode] || MODE_NOTES.foreground),
|
|
395
|
+
...(result.uptimeMs ? [row('Uptime', humanDuration(result.uptimeMs))] : []),
|
|
396
|
+
...(result.log ? [row('Logs', dim(result.log))] : []),
|
|
397
|
+
] : []),
|
|
398
|
+
row('Config', dim(file)),
|
|
399
|
+
row('At login', result.startup ? MARK.yes('Enabled') : MARK.no('Not enabled — `onboarder start startup install`')),
|
|
400
|
+
...(result.portBusy && !running
|
|
401
|
+
? [hint('Inspect the listener with `ss -ltnp` or `lsof -i :' + settings.port + '` before stopping another process.')]
|
|
402
|
+
: []),
|
|
403
|
+
]));
|
|
404
|
+
out('');
|
|
211
405
|
}
|
|
212
406
|
return 0;
|
|
213
407
|
}
|
|
214
408
|
|
|
409
|
+
// "3d 4h", "12m 5s" — coarse on purpose. Precision past the second is noise on
|
|
410
|
+
// something a person glances at.
|
|
411
|
+
export function humanDuration(ms) {
|
|
412
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
413
|
+
const days = Math.floor(total / 86400);
|
|
414
|
+
const hours = Math.floor((total % 86400) / 3600);
|
|
415
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
416
|
+
const seconds = total % 60;
|
|
417
|
+
if (days) return `${days}d ${hours}h`;
|
|
418
|
+
if (hours) return `${hours}h ${minutes}m`;
|
|
419
|
+
if (minutes) return `${minutes}m ${seconds}s`;
|
|
420
|
+
return `${seconds}s`;
|
|
421
|
+
}
|
|
422
|
+
|
|
215
423
|
function waitForExit(pid, timeoutMs = 5000) {
|
|
216
424
|
return new Promise((resolve) => {
|
|
217
425
|
const started = Date.now();
|
|
@@ -274,6 +482,109 @@ export async function runRestart(options = {}) {
|
|
|
274
482
|
return runStart(options);
|
|
275
483
|
}
|
|
276
484
|
|
|
485
|
+
// ----------------------------------------------------------------- logs ---
|
|
486
|
+
|
|
487
|
+
// `onboarder logs` — the answer to "it is running in the background, what is it
|
|
488
|
+
// doing?". Prints the tail of the same file the background child writes, and
|
|
489
|
+
// `-f` follows it. Line-oriented and level-colored, so a wall of request logs
|
|
490
|
+
// stays scannable instead of being one undifferentiated block.
|
|
491
|
+
export async function runLogs({ flags = {}, out = console.log, err = console.error } = {}) {
|
|
492
|
+
const file = flags.config || configPath();
|
|
493
|
+
const log = logPath(file);
|
|
494
|
+
const lines = flags.lines === undefined ? 40 : Math.max(1, Number(flags.lines) || 40);
|
|
495
|
+
if (!await logExists(log)) {
|
|
496
|
+
if (flags.json) out(JSON.stringify({ log, exists: false, lines: [] }, null, 2));
|
|
497
|
+
else {
|
|
498
|
+
err(` No log file yet at ${log}.`);
|
|
499
|
+
err(dim(' A foreground `onboarder start` prints to the terminal; only `onboarder start background` writes this file.'));
|
|
500
|
+
}
|
|
501
|
+
return flags.json ? 0 : 1;
|
|
502
|
+
}
|
|
503
|
+
if (!flags.follow) {
|
|
504
|
+
const tail = await tailLog(log, lines);
|
|
505
|
+
if (flags.json) { out(JSON.stringify({ log, exists: true, lines: tail }, null, 2)); return 0; }
|
|
506
|
+
out('');
|
|
507
|
+
out(bold(` ${log}`) + dim(` (last ${tail.length} of ${lines})`));
|
|
508
|
+
out('');
|
|
509
|
+
for (const line of tail) out(' ' + line);
|
|
510
|
+
out('');
|
|
511
|
+
return 0;
|
|
512
|
+
}
|
|
513
|
+
if (flags.json) { err(' --follow and --json do not combine; pick one.'); return 2; }
|
|
514
|
+
out('');
|
|
515
|
+
out(bold(` Following ${log}`) + dim(' (Ctrl-C to stop)'));
|
|
516
|
+
out('');
|
|
517
|
+
const stop = followLog(log, (line) => out(' ' + line), { from: 'start' });
|
|
518
|
+
const finish = () => { stop(); process.exit(0); };
|
|
519
|
+
process.once('SIGINT', finish);
|
|
520
|
+
await new Promise((resolve) => process.once('exit', resolve));
|
|
521
|
+
return 0;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// -------------------------------------------------------------- startup ---
|
|
525
|
+
|
|
526
|
+
// `onboarder start startup` — run at login, not just right now. Three verbs
|
|
527
|
+
// because the three actions are genuinely different: `install` writes the
|
|
528
|
+
// artifact and hands it to the OS, `remove` takes it back out, `status` only
|
|
529
|
+
// looks. `install` also starts it once so the person is not left waiting for the
|
|
530
|
+
// next reboot to find out whether it worked.
|
|
531
|
+
export async function runStartup(action = 'status', { flags = {}, out = console.log, err = console.error } = {}) {
|
|
532
|
+
const file = flags.config || configPath();
|
|
533
|
+
const target = startupTarget();
|
|
534
|
+
const log = logPath(file);
|
|
535
|
+
|
|
536
|
+
if (action === 'install' || action === 'enable' || action === 'on') {
|
|
537
|
+
const result = await installStartup({ configFile: file, log, target });
|
|
538
|
+
if (!result.ok) {
|
|
539
|
+
err(bad(' Could not install the startup entry.'));
|
|
540
|
+
err(' ' + (result.reason || result.output || result.error || 'unknown error'));
|
|
541
|
+
return 1;
|
|
542
|
+
}
|
|
543
|
+
out(tick + `Onboarder will start at login (${target.hint}).`);
|
|
544
|
+
out(kv('Startup file', result.path));
|
|
545
|
+
if (result.output) out(dim(' ' + result.output.split('\n').slice(-2).join('\n ')));
|
|
546
|
+
out('');
|
|
547
|
+
out(dim(' Start it right now too: ') + cyan('onboarder start background'));
|
|
548
|
+
out(dim(' Turn it off again: ') + cyan('onboarder start startup remove'));
|
|
549
|
+
out('');
|
|
550
|
+
return 0;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (action === 'remove' || action === 'disable' || action === 'off' || action === 'uninstall') {
|
|
554
|
+
const status = await startupStatus(target);
|
|
555
|
+
if (!status.installed) {
|
|
556
|
+
out(dash + 'No startup entry is installed — nothing to remove.');
|
|
557
|
+
return 0;
|
|
558
|
+
}
|
|
559
|
+
const result = await removeStartup({ target });
|
|
560
|
+
if (!result.ok) { err(bad(' Could not remove the startup entry: ' + (result.reason || 'unknown error'))); return 1; }
|
|
561
|
+
out(tick + 'Startup entry removed. A running server is untouched — use `onboarder stop` for that.');
|
|
562
|
+
out(kv('Removed', result.path));
|
|
563
|
+
out('');
|
|
564
|
+
return 0;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (action === 'status' || action === undefined) {
|
|
568
|
+
const status = await startupStatus(target);
|
|
569
|
+
if (flags.json) { out(JSON.stringify(status, null, 2)); return status.installed ? 0 : 1; }
|
|
570
|
+
out('');
|
|
571
|
+
out(panel('Onboarder at login', [
|
|
572
|
+
row('State', status.installed
|
|
573
|
+
? (status.active ? MARK.yes('Enabled and running') : MARK.warn('Enabled, not running'))
|
|
574
|
+
: MARK.no('Not enabled')),
|
|
575
|
+
row('How', status.detail),
|
|
576
|
+
...(status.path ? [row('File', dim(status.path))] : []),
|
|
577
|
+
...(status.supported ? [hint(status.installed
|
|
578
|
+
? 'Remove it with `onboarder start startup remove`.'
|
|
579
|
+
: 'Enable it with `onboarder start startup install`.')] : []),
|
|
580
|
+
]));
|
|
581
|
+
out('');
|
|
582
|
+
return status.installed ? 0 : 1;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
throw new Error('Usage: onboarder start startup [install|remove|status]');
|
|
586
|
+
}
|
|
587
|
+
|
|
277
588
|
// --------------------------------------------------------------- config ---
|
|
278
589
|
|
|
279
590
|
// Keys `config set` may touch — the same allow-list the HTTP API enforces, so
|
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
|
|
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,21 @@ 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
|
+
// The layout math (width, fit, panel) lives in `server/layout.js` so the server's
|
|
58
|
+
// own startup banner can use the identical geometry without importing this file
|
|
59
|
+
// and inverting the dependency. This module is only the *color* on top of it.
|
|
60
|
+
import { width, fit, termWidth, panel as layoutPanel, hint, row } from '../server/layout.js';
|
|
61
|
+
|
|
62
|
+
export { width, fit, termWidth, hint, row };
|
|
63
|
+
|
|
64
|
+
// A titled block of rows that fits the terminal it is printed into. Same shape
|
|
65
|
+
// the server banner uses; the difference is only that this one paints.
|
|
66
|
+
export function panel(title, rows, options = {}) {
|
|
67
|
+
const styles = { label: 'gray', frame: 'gray', hint: 'gray', title: 'bold' };
|
|
68
|
+
return layoutPanel(title, rows, {
|
|
69
|
+
...options,
|
|
70
|
+
paint: (text, style) => paint(text, styles[style] || 'gray'),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codebase-onboarder",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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": {
|