codebase-onboarder 0.3.1 → 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 +43 -2
- package/cli/commands.js +292 -12
- package/cli/main.js +39 -3
- package/cli/ui.js +34 -0
- package/package.json +1 -1
- package/server/daemon.js +195 -0
- package/server/index.js +27 -7
- package/server/logger.js +96 -15
- package/server/pidfile.js +38 -1
- package/server/startup.js +261 -0
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,38 @@ 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.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
|
+
|
|
226
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.
|
|
227
265
|
|
|
228
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.
|
|
@@ -255,7 +293,10 @@ codebase-onboarder/
|
|
|
255
293
|
├── server/ # Zero-dependency Node.js HTTP server
|
|
256
294
|
│ ├── index.js # createServer / startServer / startup banner
|
|
257
295
|
│ ├── config.js # Settings schema, normalization, atomic 0600 writes
|
|
258
|
-
│ ├── pidfile.js # PID
|
|
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
|
|
259
300
|
│ ├── router.js # Route table, live per-request settings, auth & CSRF gates
|
|
260
301
|
│ ├── auth.js # Signed HttpOnly browser sessions
|
|
261
302
|
│ ├── 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,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
|
-
|
|
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(
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
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,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
|
+
"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/server/daemon.js
ADDED
|
@@ -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,8 @@ 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';
|
|
27
28
|
|
|
28
29
|
const logger = createLogger();
|
|
29
30
|
|
|
@@ -164,14 +165,33 @@ export async function startServer({ configFile = configPath(), openBrowser, log
|
|
|
164
165
|
throw listenError(error, { host, port, pid: recorded && pidIsAlive(recorded) ? recorded : null });
|
|
165
166
|
}
|
|
166
167
|
|
|
168
|
+
// The process record is optional by design, so each write is guarded on its
|
|
169
|
+
// own: a read-only config directory must not stop a server that has already
|
|
170
|
+
// bound its port, but a *programming* error here would otherwise be swallowed
|
|
171
|
+
// and look like "it started, but nothing recorded it".
|
|
172
|
+
try { writePidFile(configFile); } catch { /* read-only config dir */ }
|
|
173
|
+
// How this process was launched. `background` is a detached child with a log
|
|
174
|
+
// file; `startup` is one the OS supervisor started at login (also with a log
|
|
175
|
+
// file); anything else is a person typing `onboarder start` in a terminal.
|
|
176
|
+
const mode = process.env.ONBOARDER_LAUNCH || (process.env.ONBOARDER_BACKGROUND ? 'background' : 'foreground');
|
|
177
|
+
const logFile = mode === 'foreground' ? null : logPath(configFile);
|
|
167
178
|
try {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
179
|
+
// Beside the pid: how this instance was launched and where its log goes, so
|
|
180
|
+
// `onboarder status` answers "how long has it been up, and how do I see its
|
|
181
|
+
// output" from a record instead of guessing.
|
|
182
|
+
writeRunInfo(configFile, {
|
|
183
|
+
mode,
|
|
184
|
+
host,
|
|
185
|
+
port,
|
|
186
|
+
url: serverUrls(live).local,
|
|
187
|
+
log: logFile,
|
|
188
|
+
node: process.version,
|
|
189
|
+
});
|
|
190
|
+
} catch (error) {
|
|
191
|
+
logger.warn('could not write the run record', { file: runInfoPath(configFile), error: error.message });
|
|
174
192
|
}
|
|
193
|
+
server.once('close', () => removePidFile(configFile));
|
|
194
|
+
process.once('exit', () => removePidFile(configFile));
|
|
175
195
|
|
|
176
196
|
log(startupBanner(live, { configFile }));
|
|
177
197
|
|
package/server/logger.js
CHANGED
|
@@ -1,22 +1,103 @@
|
|
|
1
|
+
// Every line Onboarder writes, in two faces.
|
|
2
|
+
//
|
|
3
|
+
// The entry itself is data — `{ ts, time, level, msg, ...fields }` — and the
|
|
4
|
+
// renderer decides how it looks. JSON goes to pipes, log files and anything
|
|
5
|
+
// parsing us; the aligned human line goes to a terminal. Picking the face once,
|
|
6
|
+
// here, is what keeps a log file readable *and* machine-parseable instead of
|
|
7
|
+
// half one thing.
|
|
8
|
+
//
|
|
9
|
+
// A `time` field (local HH:MM:SS.mmm) rides along with the ISO `ts` on purpose:
|
|
10
|
+
// tailing a file gives you the string, not a Date, and a reader wants their own
|
|
11
|
+
// clock, not UTC.
|
|
12
|
+
|
|
1
13
|
const levels = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
2
14
|
|
|
3
|
-
|
|
15
|
+
// Worst first — the order any summary or sort should use.
|
|
16
|
+
export const SEVERITY = { error: 0, warn: 1, info: 2, debug: 3 };
|
|
17
|
+
|
|
18
|
+
const CODES = { red: 31, green: 32, yellow: 33, cyan: 36, gray: 90 };
|
|
19
|
+
const LEVEL_COLOR = { debug: 'gray', info: 'cyan', warn: 'yellow', error: 'red' };
|
|
20
|
+
|
|
21
|
+
// The server sits *under* the CLI, so it cannot import `cli/ui.js` without
|
|
22
|
+
// inverting the dependency. These three lines of ANSI are the whole price.
|
|
23
|
+
function paint(text, color, enabled) {
|
|
24
|
+
return enabled && color ? `\x1b[${CODES[color]}m${text}\x1b[0m` : String(text);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Fields that are part of the envelope or already rendered into the message.
|
|
28
|
+
const ENVELOPE = new Set(['ts', 'time', 'level', 'msg', 'method', 'path', 'status', 'ms', 'line', 'scope']);
|
|
29
|
+
|
|
30
|
+
// One line, no styling: what a log file holds and what a test asserts on.
|
|
31
|
+
export function formatMessage(entry) {
|
|
32
|
+
if (entry.line) return `${entry.msg ?? ''} ${entry.line}`.trim();
|
|
33
|
+
if (entry.method) {
|
|
34
|
+
const status = entry.status === undefined ? '' : ` ${entry.status}`;
|
|
35
|
+
const took = entry.ms === undefined ? '' : ` (${entry.ms}ms)`;
|
|
36
|
+
return `${entry.method} ${entry.path}${status}${took}`;
|
|
37
|
+
}
|
|
38
|
+
const extra = Object.entries(entry)
|
|
39
|
+
.filter(([key, value]) => !ENVELOPE.has(key) && value !== undefined && value !== null)
|
|
40
|
+
.map(([key, value]) => `${key}=${typeof value === 'string' ? value : JSON.stringify(value)}`);
|
|
41
|
+
return [entry.msg ?? '', ...extra].filter(Boolean).join(' ');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Every log line is `time LEVEL message`, with the level in a fixed 5-wide
|
|
45
|
+
// column so the messages of an `INFO` and a `ERROR` line start at the same
|
|
46
|
+
// offset. That alignment is the entire point: a wall of request logs is only
|
|
47
|
+
// scannable if the eye can find the message column without reading.
|
|
48
|
+
const LEVEL_WIDTH = 5;
|
|
49
|
+
const GUTTER = ' ';
|
|
50
|
+
|
|
51
|
+
export function formatEntry(entry) {
|
|
52
|
+
const time = String(entry.time || String(entry.ts || '').slice(11, 23));
|
|
53
|
+
const level = String(entry.level || 'info').toUpperCase().padEnd(LEVEL_WIDTH);
|
|
54
|
+
return `${time}${GUTTER}${level}${GUTTER}${formatMessage(entry)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function renderEntry(entry, { color = false } = {}) {
|
|
58
|
+
const time = String(entry.time || String(entry.ts || '').slice(11, 19));
|
|
59
|
+
const level = String(entry.level || 'info').toUpperCase().padEnd(LEVEL_WIDTH);
|
|
60
|
+
return paint(time, 'gray', color) + GUTTER + paint(level, LEVEL_COLOR[entry.level] || 'gray', color) + GUTTER + formatMessage(entry);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function logEntry(level, msg, extra = {}) {
|
|
64
|
+
const now = new Date();
|
|
65
|
+
return {
|
|
66
|
+
ts: now.toISOString(),
|
|
67
|
+
// Built from the local parts, not `toTimeString()`: that carries a timezone
|
|
68
|
+
// abbreviation whose width changes (`GMT` vs ` PDT`), which is exactly what
|
|
69
|
+
// makes a log column ragged. Always 12 characters, always the reader's clock.
|
|
70
|
+
time: [now.getHours(), now.getMinutes(), now.getSeconds()]
|
|
71
|
+
.map((part) => String(part).padStart(2, '0'))
|
|
72
|
+
.join(':') + '.' + String(now.getMilliseconds()).padStart(3, '0'),
|
|
73
|
+
level,
|
|
74
|
+
msg,
|
|
75
|
+
...extra,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createLogger(level = process.env.LOG_LEVEL || 'info', options = {}) {
|
|
4
80
|
const minLevel = levels[level] ?? levels.info;
|
|
5
|
-
|
|
81
|
+
// `pretty` is the default for a terminal *and* for a log file (aligned text is
|
|
82
|
+
// what a person reads at 2am); `ONBOARDER_LOG=json` is the machine escape
|
|
83
|
+
// hatch, and so is a non-TTY consumer that parses stdout.
|
|
84
|
+
const format = options.format || process.env.ONBOARDER_LOG || 'pretty';
|
|
85
|
+
const color = options.color ?? (Boolean(process.stdout.isTTY) && !process.env.NO_COLOR);
|
|
86
|
+
const out = options.stdout || process.stdout;
|
|
87
|
+
const err = options.stderr || process.stderr;
|
|
88
|
+
|
|
89
|
+
function write(entry) {
|
|
90
|
+
if (format === 'json') {
|
|
91
|
+
const line = JSON.stringify(entry) + '\n';
|
|
92
|
+
(entry.level === 'warn' || entry.level === 'error' ? err : out).write(line);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
(entry.level === 'warn' || entry.level === 'error' ? err : out).write(renderEntry(entry, { color }) + '\n');
|
|
96
|
+
}
|
|
97
|
+
|
|
6
98
|
function log(lvl, msg, extra = {}) {
|
|
7
99
|
if (levels[lvl] < minLevel) return;
|
|
8
|
-
|
|
9
|
-
ts: new Date().toISOString(),
|
|
10
|
-
level: lvl,
|
|
11
|
-
msg,
|
|
12
|
-
...extra
|
|
13
|
-
};
|
|
14
|
-
const out = JSON.stringify(entry) + '\\n';
|
|
15
|
-
if (lvl === 'warn' || lvl === 'error') {
|
|
16
|
-
process.stderr.write(out);
|
|
17
|
-
} else {
|
|
18
|
-
process.stdout.write(out);
|
|
19
|
-
}
|
|
100
|
+
write(logEntry(lvl, msg, extra));
|
|
20
101
|
}
|
|
21
102
|
|
|
22
103
|
return {
|
|
@@ -24,6 +105,6 @@ export function createLogger(level = process.env.LOG_LEVEL || 'info') {
|
|
|
24
105
|
info: (msg, extra) => log('info', msg, extra),
|
|
25
106
|
warn: (msg, extra) => log('warn', msg, extra),
|
|
26
107
|
error: (msg, extra) => log('error', msg, extra),
|
|
27
|
-
http: (req) => log('info', '
|
|
108
|
+
http: (req) => log(req.status >= 500 ? 'error' : 'info', 'http', req),
|
|
28
109
|
};
|
|
29
110
|
}
|
package/server/pidfile.js
CHANGED
|
@@ -45,5 +45,42 @@ export function writePidFile(configFile) {
|
|
|
45
45
|
// Remove the file only when it still points at `pid` — a second server that
|
|
46
46
|
// rewrote the file must not lose its record because the first one exited.
|
|
47
47
|
export function removePidFile(configFile, pid = process.pid) {
|
|
48
|
-
if (readPidFile(configFile) === pid)
|
|
48
|
+
if (readPidFile(configFile) === pid) {
|
|
49
|
+
fs.rmSync(pidPath(configFile), { force: true });
|
|
50
|
+
removeRunInfo(configFile);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The pid answers "is it running?"; it cannot answer "how is it running?".
|
|
55
|
+
// Which mode a live server was launched in (foreground, background, a login
|
|
56
|
+
// item), where its log file is, and when it booted are all things `onboarder
|
|
57
|
+
// status` should be able to print without guessing — so they are written
|
|
58
|
+
// beside the pid, best effort, exactly like the pid itself.
|
|
59
|
+
export function runInfoPath(configFile) {
|
|
60
|
+
return path.join(path.dirname(path.resolve(configFile)), 'onboarder.run.json');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function writeRunInfo(configFile, info = {}) {
|
|
64
|
+
try {
|
|
65
|
+
fs.writeFileSync(runInfoPath(configFile), JSON.stringify({
|
|
66
|
+
pid: process.pid,
|
|
67
|
+
startedAt: new Date().toISOString(),
|
|
68
|
+
...info,
|
|
69
|
+
}, null, 2) + '\n', { mode: 0o600 });
|
|
70
|
+
} catch {
|
|
71
|
+
// A read-only config dir must never stop a server from booting.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function readRunInfo(configFile) {
|
|
76
|
+
try {
|
|
77
|
+
const value = JSON.parse(fs.readFileSync(runInfoPath(configFile), 'utf8'));
|
|
78
|
+
return value && typeof value === 'object' ? value : null;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function removeRunInfo(configFile) {
|
|
85
|
+
try { fs.rmSync(runInfoPath(configFile), { force: true }); } catch { /* nothing to clean */ }
|
|
49
86
|
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Start Onboarder when the machine boots, the way a person actually wants a
|
|
2
|
+
// server they always want running to behave.
|
|
3
|
+
//
|
|
4
|
+
// "Startup" is three different mechanisms wearing one name, so this module
|
|
5
|
+
// detects the platform and speaks its dialect — and, importantly, the *file
|
|
6
|
+
// writing* is separated from the *service loading* so both are testable without
|
|
7
|
+
// a Mac or a systemd:
|
|
8
|
+
//
|
|
9
|
+
// macOS ~/Library/LaunchAgents/<label>.plist → launchctl bootstrap
|
|
10
|
+
// Linux ~/.config/systemd/user/<unit>.service → systemctl --user enable --now
|
|
11
|
+
// Windows %APPDATA%\...\Startup\Onboarder.cmd → being there is enough
|
|
12
|
+
// other nothing; we say so rather than pretending
|
|
13
|
+
//
|
|
14
|
+
// Every artifact is generated from the *same* command line a person would type
|
|
15
|
+
// (`<node> <cli> start background --config <file>`), so a login-started server
|
|
16
|
+
// is indistinguishable from a hand-started one — same config, same pid file,
|
|
17
|
+
// same `onboarder stop`.
|
|
18
|
+
//
|
|
19
|
+
// Deliberately no `sudo`, no writing into launchd's system domain, and nothing
|
|
20
|
+
// outside the user's own home. A login item is a convenience; it must never be
|
|
21
|
+
// the thing that needs an administrator password.
|
|
22
|
+
|
|
23
|
+
import { promises as fs } from 'node:fs';
|
|
24
|
+
import os from 'node:os';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { spawnSync } from 'node:child_process';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
import { configPath } from './config.js';
|
|
30
|
+
|
|
31
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
|
|
33
|
+
export const LABEL = 'com.onboarder.server';
|
|
34
|
+
export const UNIT = 'onboarder.service';
|
|
35
|
+
|
|
36
|
+
export function cliEntry() {
|
|
37
|
+
// `bin/onboarder.js` next to `server/`, whether we were run from a checkout
|
|
38
|
+
// or from a global install. The env override is what lets a test point the
|
|
39
|
+
// generated unit at a fixture instead of the real machine.
|
|
40
|
+
return process.env.ONBOARDER_CLI || path.resolve(HERE, '..', 'bin', 'onboarder.js');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The argv a login item runs.
|
|
44
|
+
//
|
|
45
|
+
// Note what is *not* here: `background`. launchd and systemd are already
|
|
46
|
+
// supervisors — they hold the process, restart it, and capture its output. A
|
|
47
|
+
// login item that spawned a detached grandchild and exited immediately would
|
|
48
|
+
// leave the supervisor watching a corpse, which defeats `KeepAlive` and turns a
|
|
49
|
+
// busy port into a respawn loop. So the generated command is a plain foreground
|
|
50
|
+
// `start`, and the OS owns the lifecycle from there.
|
|
51
|
+
export function startupCommand(configFile = configPath(), { node = process.execPath, entry = cliEntry() } = {}) {
|
|
52
|
+
return [node, entry, 'start', '--config', configFile];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function xmlEscape(value) {
|
|
56
|
+
const map = { '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' };
|
|
57
|
+
return String(value).replace(/[<>&'"]/g, (c) => map[c]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function argList(args) {
|
|
61
|
+
return args.map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function renderLaunchAgent({ configFile = configPath(), log = '', ...options } = {}) {
|
|
65
|
+
const args = startupCommand(configFile, options);
|
|
66
|
+
const streams = log
|
|
67
|
+
? ` <key>StandardOutPath</key>\n <string>${xmlEscape(log)}</string>\n <key>StandardErrorPath</key>\n <string>${xmlEscape(log)}</string>\n`
|
|
68
|
+
: '';
|
|
69
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
70
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
71
|
+
<plist version="1.0">
|
|
72
|
+
<dict>
|
|
73
|
+
<key>Label</key>
|
|
74
|
+
<string>${xmlEscape(LABEL)}</string>
|
|
75
|
+
<key>ProgramArguments</key>
|
|
76
|
+
<array>
|
|
77
|
+
${argList(args)}
|
|
78
|
+
</array>
|
|
79
|
+
<key>RunAtLoad</key>
|
|
80
|
+
<true/>
|
|
81
|
+
<key>KeepAlive</key>
|
|
82
|
+
<dict>
|
|
83
|
+
<key>SuccessfulExit</key>
|
|
84
|
+
<false/>
|
|
85
|
+
</dict>
|
|
86
|
+
${streams} <key>EnvironmentVariables</key>
|
|
87
|
+
<dict>
|
|
88
|
+
<key>PATH</key>
|
|
89
|
+
<string>${xmlEscape(process.env.PATH || '/usr/local/bin:/usr/bin:/bin')}</string>
|
|
90
|
+
<key>ONBOARDER_LAUNCH</key>
|
|
91
|
+
<string>startup</string>
|
|
92
|
+
</dict>
|
|
93
|
+
</dict>
|
|
94
|
+
</plist>
|
|
95
|
+
`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function renderSystemdUnit({ configFile = configPath(), log = '', ...options } = {}) {
|
|
99
|
+
const args = startupCommand(configFile, options);
|
|
100
|
+
// `Restart=always` would fight `onboarder stop`: a deliberate stop must stay
|
|
101
|
+
// stopped, so only a *failed* exit is restarted. `on-failure` is the honest
|
|
102
|
+
// setting here, and `SuccessfulExit=false` above is its launchd spelling.
|
|
103
|
+
const exec = args.map((a) => (/[\s"]/.test(a) ? JSON.stringify(a) : a)).join(' ');
|
|
104
|
+
return `[Unit]
|
|
105
|
+
Description=Onboarder — codebase visualizer and onboarding map
|
|
106
|
+
Documentation=https://github.com/Amitpandey88/onboarder
|
|
107
|
+
After=network-online.target
|
|
108
|
+
Wants=network-online.target
|
|
109
|
+
|
|
110
|
+
[Service]
|
|
111
|
+
Type=simple
|
|
112
|
+
ExecStart=${exec}
|
|
113
|
+
Restart=on-failure
|
|
114
|
+
RestartSec=3
|
|
115
|
+
${log ? `StandardOutput=append:${log}\nStandardError=append:${log}\n` : ''}Environment=NO_COLOR=1
|
|
116
|
+
Environment=ONBOARDER_LAUNCH=startup
|
|
117
|
+
|
|
118
|
+
[Install]
|
|
119
|
+
WantedBy=default.target
|
|
120
|
+
`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function renderWindowsStartup({ configFile = configPath(), log = '', ...options } = {}) {
|
|
124
|
+
const args = startupCommand(configFile, options);
|
|
125
|
+
const line = args.map((a) => `"${a}"`).join(' ');
|
|
126
|
+
return `@echo off\r\nrem Managed by "onboarder start startup". Edits are replaced.\r\nstart "" /b ${line}${log ? ` >> "${log}" 2>&1` : ''}\r\n`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The target carries its own platform, so every message about it can name the
|
|
130
|
+
// platform it was resolved for rather than the one this process happens to run
|
|
131
|
+
// on. A test (or a future cross-platform installer) resolves a plan9 target on a
|
|
132
|
+
// Mac; saying "darwin" in that error would be a lie.
|
|
133
|
+
export function startupTarget({ platform = process.platform, home = os.homedir(), env = process.env } = {}) {
|
|
134
|
+
// The per-user launchd domain. The system domain needs root, which this tool
|
|
135
|
+
// never asks for.
|
|
136
|
+
const domain = `gui/${process.getuid?.() ?? 501}`;
|
|
137
|
+
if (platform === 'darwin') {
|
|
138
|
+
return {
|
|
139
|
+
kind: 'launchd',
|
|
140
|
+
platform,
|
|
141
|
+
label: LABEL,
|
|
142
|
+
path: path.join(home, 'Library', 'LaunchAgents', LABEL + '.plist'),
|
|
143
|
+
render: renderLaunchAgent,
|
|
144
|
+
load: ['launchctl', 'bootstrap', domain, '{file}'],
|
|
145
|
+
unload: ['launchctl', 'bootout', `${domain}/{label}`],
|
|
146
|
+
status: ['launchctl', 'print', `${domain}/{label}`],
|
|
147
|
+
hint: 'launchd agent in ~/Library/LaunchAgents',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (platform === 'linux') {
|
|
151
|
+
const configHome = env.XDG_CONFIG_HOME || path.join(home, '.config');
|
|
152
|
+
return {
|
|
153
|
+
kind: 'systemd',
|
|
154
|
+
platform,
|
|
155
|
+
label: UNIT,
|
|
156
|
+
path: path.join(configHome, 'systemd', 'user', UNIT),
|
|
157
|
+
render: renderSystemdUnit,
|
|
158
|
+
daemonReload: ['systemctl', '--user', 'daemon-reload'],
|
|
159
|
+
load: ['systemctl', '--user', 'enable', '--now', UNIT],
|
|
160
|
+
unload: ['systemctl', '--user', 'disable', '--now', UNIT],
|
|
161
|
+
status: ['systemctl', '--user', 'is-active', UNIT],
|
|
162
|
+
hint: 'systemd --user unit (no root; starts with your session)',
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (platform === 'win32') {
|
|
166
|
+
const appData = env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
167
|
+
// Native separators throughout: this string goes into a .cmd file and into
|
|
168
|
+
// the Startup folder itself, and `path.join` would emit `\` mixed with `/`
|
|
169
|
+
// — which Windows tolerates in a file path but no one should have to read.
|
|
170
|
+
return {
|
|
171
|
+
kind: 'startup-folder',
|
|
172
|
+
platform,
|
|
173
|
+
label: 'Onboarder.cmd',
|
|
174
|
+
path: [
|
|
175
|
+
appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'Onboarder.cmd',
|
|
176
|
+
].join('\\'),
|
|
177
|
+
render: renderWindowsStartup,
|
|
178
|
+
load: null, // the file *is* the registration
|
|
179
|
+
unload: null,
|
|
180
|
+
status: null,
|
|
181
|
+
hint: 'script in the Windows Startup folder',
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return { kind: 'unsupported', platform, label: '', path: '', render: null, load: null, unload: null, status: null, hint: '' };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Substitute `{file}` / `{label}` and run without a shell — an argument is an
|
|
188
|
+
// argument, never a command line somebody else gets to compose. `spawn` is
|
|
189
|
+
// injected so a test can assert on the argv without touching launchd.
|
|
190
|
+
function exec(argv, { spawn = spawnSync } = {}) {
|
|
191
|
+
const result = spawn(argv[0], argv.slice(1), { encoding: 'utf8' });
|
|
192
|
+
return {
|
|
193
|
+
ok: !result.error && result.status === 0,
|
|
194
|
+
status: result.status ?? null,
|
|
195
|
+
output: String(result.stdout || result.stderr || '').trim(),
|
|
196
|
+
error: result.error?.message || '',
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const fill = (argv, target) => argv
|
|
201
|
+
.map((a) => a.replace('{file}', target.path).replace('{label}', target.label));
|
|
202
|
+
|
|
203
|
+
export async function startupInstalled(target = startupTarget()) {
|
|
204
|
+
if (!target.path) return false;
|
|
205
|
+
try { await fs.access(target.path); return true; } catch { return false; }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function startupStatus(target = startupTarget(), { run = spawnSync } = {}) {
|
|
209
|
+
const installed = await startupInstalled(target);
|
|
210
|
+
let active = null;
|
|
211
|
+
if (installed && target.status) {
|
|
212
|
+
const result = exec(fill(target.status, target), { spawn: run });
|
|
213
|
+
// systemd answers `active`/`inactive` on stdout; `launchctl print` succeeds
|
|
214
|
+
// only for a job that is actually loaded. Both answer "is it live now".
|
|
215
|
+
const text = result.output.toLowerCase();
|
|
216
|
+
active = target.kind === 'systemd'
|
|
217
|
+
? text.includes('active') && !text.includes('inactive')
|
|
218
|
+
: result.ok;
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
kind: target.kind,
|
|
222
|
+
supported: target.kind !== 'unsupported',
|
|
223
|
+
label: target.label,
|
|
224
|
+
path: target.path,
|
|
225
|
+
installed,
|
|
226
|
+
active,
|
|
227
|
+
detail: target.kind === 'unsupported'
|
|
228
|
+
? `no automatic-startup mechanism for ${target.platform || process.platform}`
|
|
229
|
+
: installed
|
|
230
|
+
? (active ? `running — ${target.hint}` : `installed, not running — ${target.hint}`)
|
|
231
|
+
: 'not installed',
|
|
232
|
+
hint: target.hint,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function installStartup({ configFile = configPath(), log = '', target = startupTarget(), run = spawnSync } = {}) {
|
|
237
|
+
if (target.kind === 'unsupported') {
|
|
238
|
+
return { ok: false, reason: `There is no automatic-startup mechanism Onboarder knows how to write on ${target.platform || process.platform}. Start it with \`onboarder start background\` from whatever your machine runs at boot.` };
|
|
239
|
+
}
|
|
240
|
+
await fs.mkdir(path.dirname(target.path), { recursive: true });
|
|
241
|
+
await fs.writeFile(target.path, target.render({ configFile, log }), { encoding: 'utf8', mode: 0o600 });
|
|
242
|
+
if (target.kind === 'systemd' && target.daemonReload) exec(fill(target.daemonReload, target), { spawn: run });
|
|
243
|
+
if (!target.load) return { ok: true, path: target.path, loaded: true, output: '' };
|
|
244
|
+
const result = exec(fill(target.load, target), { spawn: run });
|
|
245
|
+
// A unit that is already loaded is a success, not a failure — installing twice
|
|
246
|
+
// has to be idempotent, and `launchctl` says so in three different ways.
|
|
247
|
+
const already = /already (been )?(loaded|active|exists|running)|115|unit .*already/i.test(result.output);
|
|
248
|
+
if (!result.ok && !already) {
|
|
249
|
+
return { ok: false, path: target.path, output: result.output || result.error, error: result.error };
|
|
250
|
+
}
|
|
251
|
+
return { ok: true, path: target.path, loaded: true, output: result.output };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function removeStartup({ target = startupTarget(), run = spawnSync } = {}) {
|
|
255
|
+
if (target.kind === 'unsupported') return { ok: false, reason: 'Nothing is installed for this platform.' };
|
|
256
|
+
let output = '';
|
|
257
|
+
if (target.unload) output = exec(fill(target.unload, target), { spawn: run }).output;
|
|
258
|
+
await fs.rm(target.path, { force: true });
|
|
259
|
+
if (target.kind === 'systemd' && target.daemonReload) exec(fill(target.daemonReload, target), { spawn: run });
|
|
260
|
+
return { ok: true, path: target.path, output };
|
|
261
|
+
}
|