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/server/logger.js CHANGED
@@ -1,22 +1,322 @@
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
- export function createLogger(level = process.env.LOG_LEVEL || 'info') {
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
+ // `summary` is here so the suppressed-noise footer does not print itself as
29
+ // `summary=true` — it is a rendering flag, not data.
30
+ const ENVELOPE = new Set(['ts', 'time', 'level', 'msg', 'method', 'path', 'status', 'ms', 'line', 'scope', 'summary']);
31
+
32
+ // Request classification — the difference between a person doing something and
33
+ // a browser fetching a file.
34
+ //
35
+ // This is the single most important thing about Onboarder's logs. A page load
36
+ // pulls ~90 ES modules, a stylesheet, and two vendored libraries: 90 log lines
37
+ // describing zero user actions, repeated on every reload and on every auth
38
+ // check. The MCP status poll adds one more line every nine seconds, forever. Log
39
+ // all of it and the real events — a scan, a login, a 500 — are buried.
40
+ //
41
+ // So: a request is an **action** if it is an API call, and **noise** if it is a
42
+ // static asset. Noise is not thrown away, it is *counted* (see `NoiseCounter`),
43
+ // because "nothing happened" and "90 files were served" are different facts and
44
+ // only one of them is interesting most of the time.
45
+ export function classifyPath(pathname = '') {
46
+ const path = String(pathname).split('?')[0];
47
+ if (path === '/api/health') return 'poll'; // uptime checks
48
+ if (path.startsWith('/api/')) return 'action';
49
+ return 'noise';
50
+ }
51
+
52
+ export function classifyRequest(req = {}) {
53
+ const kind = classifyPath(req.path);
54
+ // A failure is always an action, whatever it was: a 404 on a missing asset is
55
+ // a broken build, and a 500 is a bug. Neither is noise to be summarized away.
56
+ if (Number(req.status) >= 400) return 'action';
57
+ if (req.method && req.method !== 'GET' && req.method !== 'HEAD') return 'action';
58
+ return kind;
59
+ }
60
+
61
+ // One line, no styling: what a log file holds and what a test asserts on.
62
+ export function formatMessage(entry) {
63
+ if (entry.line) return `${entry.msg ?? ''} ${entry.line}`.trim();
64
+ if (entry.method) {
65
+ const status = entry.status === undefined ? '' : ` ${entry.status}`;
66
+ const took = entry.ms === undefined ? '' : ` (${entry.ms}ms)`;
67
+ return `${entry.method} ${entry.path}${status}${took}`;
68
+ }
69
+ const extra = Object.entries(entry)
70
+ .filter(([key, value]) => !ENVELOPE.has(key) && value !== undefined && value !== null)
71
+ .map(([key, value]) => `${key}=${typeof value === 'string' ? value : JSON.stringify(value)}`);
72
+ return [entry.msg ?? '', ...extra].filter(Boolean).join(' ');
73
+ }
74
+
75
+ // Every log line is `time LEVEL message`, with the level in a fixed 5-wide
76
+ // column so the messages of an `INFO` and a `ERROR` line start at the same
77
+ // offset. That alignment is the entire point: a wall of request logs is only
78
+ // scannable if the eye can find the message column without reading.
79
+ const LEVEL_WIDTH = 5;
80
+ const GUTTER = ' ';
81
+
82
+ // The part of a path that identifies it: the filename with its extension. When
83
+ // a line has to be cut, this is what must survive — `…typescript.js` is useful,
84
+ // `…script` is not. A path with no filename (`/` or `/a/`) falls back to its tail.
85
+ export function identifyingTail(text) {
86
+ const value = String(text);
87
+ const name = value.slice(value.lastIndexOf('/') + 1);
88
+ return name.length >= 4 ? name : value.slice(-8);
89
+ }
90
+
91
+ // How the message area adapts to the terminal. Wide terminals get the whole
92
+ // path; narrow ones get the part that identifies it, elided in the middle — the
93
+ // tail of a path is the informative half, so it survives and the boring prefix
94
+ // is what gets dropped.
95
+ export function fitPath(text, max) {
96
+ const value = String(text);
97
+ if (max <= 0) return '';
98
+ if (value.length <= max) return value;
99
+ if (max === 1) return '…';
100
+ // An ellipsis stands in for the dropped prefix, so do not also add a leading
101
+ // slash: `/…/file.js` reads as a path, `//file.js` reads as a typo. The kept
102
+ // tail is the filename, so a long path loses its directories rather than
103
+ // losing its extension — `…typescript.js` is useful, `…typescript` is not.
104
+ const tail = identifyingTail(value);
105
+ if (tail.length + 1 <= max) return '…' + tail;
106
+ return '…' + value.slice(-(max - 1));
107
+ }
108
+
109
+ // How much room the message gets, and whether the level column is affordable.
110
+ //
111
+ // The fixed prefix is the time, two gutters, and the level column. On a narrow
112
+ // terminal that fixed cost eats the whole line, so two things give way: below 52
113
+ // columns the level column is dropped, and below 24 the timestamp reduces to
114
+ // HH:MM. Below 12 there is no room for any prefix at all, so both are dropped.
115
+ //
116
+ // Crucially the prefix never exceeds the terminal. An earlier version floored
117
+ // `messageWidth` at 12, which meant a 20-column line asked for 20 columns of
118
+ // message, overflowed, and got clamped from the front — producing `pt.js 200`,
119
+ // the tail of a line with its head cut off. The prefix is the part that must
120
+ // yield, not the message.
121
+ export const LEVEL_MIN_COLUMNS = 52;
122
+ export const SHORT_TIME_COLUMNS = 24;
123
+ export const NO_TIME_COLUMNS = 12;
124
+
125
+ export function showLevel(columns) {
126
+ return (columns || 80) >= LEVEL_MIN_COLUMNS;
127
+ }
128
+
129
+ export function showFullTime(columns) {
130
+ return (columns || 80) >= SHORT_TIME_COLUMNS;
131
+ }
132
+
133
+ export function showTime(columns) {
134
+ return (columns || 80) >= NO_TIME_COLUMNS;
135
+ }
136
+
137
+ // The time as it will actually be printed, so callers do not re-derive the same
138
+ // decision and get it subtly wrong.
139
+ export function timeColumn(entry, columns) {
140
+ if (!showTime(columns)) return '';
141
+ const raw = String(entry.time || String(entry.ts || '').slice(11, 19));
142
+ return showFullTime(columns) ? raw : raw.slice(0, 5);
143
+ }
144
+
145
+ // Everything before the message: time + gutter + optional level + gutter.
146
+ //
147
+ // Deliberately computed from the *width* alone, not from an entry: an earlier
148
+ // version asked `timeColumn({}, columns)`, which has no timestamp and so
149
+ // reported a zero-width prefix — and then every line was built 19 columns too
150
+ // long and overflowed. The prefix is a property of the terminal, not of the log
151
+ // line, so it is computed from the terminal.
152
+ export function prefixWidth(columns) {
153
+ if (!showTime(columns)) return 0;
154
+ const time = showFullTime(columns) ? 12 : 5;
155
+ return time + GUTTER.length + (showLevel(columns) ? LEVEL_WIDTH + GUTTER.length : 0);
156
+ }
157
+
158
+ export function messageWidth(columns) {
159
+ const total = columns || 80;
160
+ return Math.max(0, total - prefixWidth(total));
161
+ }
162
+
163
+ export function formatEntry(entry, { columns } = {}) {
164
+ const time = timeColumn(entry, columns);
165
+ const level = showLevel(columns) ? entryLevel(entry) + GUTTER : '';
166
+ const head = time ? time + GUTTER + level : '';
167
+ // The message was already fitted to `messageWidth` and the head is exactly
168
+ // `prefixWidth`, so the line is exactly the terminal width by construction —
169
+ // no trailing clamp, and therefore no risk of cutting a word in half.
170
+ return head + fitMessage(entry, columns);
171
+ }
172
+
173
+ // One line, fitted to the terminal.
174
+ //
175
+ // What gets sacrificed as space runs out, in order: the timing, then the HTTP
176
+ // status, then the level column, then the *method* — and the path's tail is
177
+ // protected, because the filename is the one thing that still identifies the
178
+ // request. A 40-column terminal gets `…zer/languages/typescript.js` (the
179
+ // extension intact); an 80-column one gets the whole sentence.
180
+ function fitMessage(entry, columns) {
181
+ const text = formatMessage(entry);
182
+ const room = messageWidth(columns);
183
+ if (text.length <= room) return text;
184
+ if (!entry.method || !entry.path) return fitPath(text, room);
185
+
186
+ const status = entry.status === undefined ? '' : ` ${entry.status}`;
187
+ const method = `${entry.method} `;
188
+ // Drop the status before the method, and the method before any of the path.
189
+ const withStatus = method + fitPath(entry.path, room - method.length - status.length) + status;
190
+ if (withStatus.length <= room) return withStatus;
191
+ const withoutStatus = method + fitPath(entry.path, room - method.length);
192
+ if (withoutStatus.length <= room) return withoutStatus;
193
+ // Last resort: the path alone, at exactly the room available. `fitPath` is
194
+ // given the exact width, so this is always exactly `room` — never over, which
195
+ // is what would make the caller's clamp cut the *front* of the line and leave
196
+ // an unreadable tail like `pt.js 200`.
197
+ return fitPath(entry.path, room);
198
+ }
199
+
200
+ // A summary of suppressed noise renders as a footnote, not an event: dim, and
201
+ // with no level column, so the eye skips it while scanning for real lines. It
202
+ // occupies the same time gutter so it still lines up in the scrollback.
203
+ export function renderEntry(entry, { color = false, columns } = {}) {
204
+ const message = fitMessage(entry, columns);
205
+ const limit = Math.max(8, columns || 80);
206
+ if (entry.summary) {
207
+ const pad = showLevel(columns) ? ' '.repeat(LEVEL_WIDTH) + GUTTER : '';
208
+ return paint(timeColumn(entry, columns), 'gray', color)
209
+ + GUTTER + paint(GUTTER + pad + message, 'gray', color);
210
+ }
211
+ const painted = paint(timeColumn(entry, columns), 'gray', color) + GUTTER
212
+ + (showLevel(columns) ? paint(entryLevel(entry), LEVEL_COLOR[entry.level] || 'gray', color) + GUTTER : '')
213
+ + message;
214
+ // Clamp on *visible* width: the ANSI codes cost nothing on screen, so slicing
215
+ // the painted string by character count would cut a real character and leave
216
+ // the escape sequence unterminated.
217
+ return visibleWidth(painted) <= limit ? painted : trimVisible(painted, limit);
218
+ }
219
+
220
+ // Visible width, ANSI excluded.
221
+ function visibleWidth(text) {
222
+ return String(text).replace(/\x1b\[[0-9;]*m/g, '').length;
223
+ }
224
+
225
+ // Cut a painted string to `max` visible columns, passing escape sequences through
226
+ // and never splitting one, then resetting so the terminal is left clean.
227
+ function trimVisible(text, max) {
228
+ const pattern = /\x1b\[[0-9;]*m/g;
229
+ let out = '';
230
+ let seen = 0;
231
+ let last = 0;
232
+ let match;
233
+ while ((match = pattern.exec(text)) !== null) {
234
+ const chunk = text.slice(last, match.index);
235
+ if (seen + chunk.length >= max) return out + chunk.slice(0, Math.max(0, max - seen)) + '\x1b[0m';
236
+ out += chunk + match[0];
237
+ seen += chunk.length;
238
+ last = pattern.lastIndex;
239
+ }
240
+ const rest = text.slice(last);
241
+ return out + (seen + rest.length > max ? rest.slice(0, max - seen) : rest);
242
+ }
243
+
244
+ function entryLevel(entry) {
245
+ return String(entry.level || 'info').toUpperCase().padEnd(LEVEL_WIDTH);
246
+ }
247
+
248
+ // A human summary of suppressed noise, so silence is never ambiguous. "Nothing
249
+ // happened" and "94 files were served" are different facts; printing the second
250
+ // as one line every so often keeps the first honest.
251
+ export function noiseSummary(count, ms = 0) {
252
+ const files = `${count} static file${count === 1 ? '' : 's'}`;
253
+ const took = ms >= 1000 ? ` in ${(ms / 1000).toFixed(1)}s` : ms ? ` in ${ms}ms` : '';
254
+ return `served ${files}${took}`;
255
+ }
256
+
257
+ export function logEntry(level, msg, extra = {}) {
258
+ const now = new Date();
259
+ return {
260
+ ts: now.toISOString(),
261
+ // Built from the local parts, not `toTimeString()`: that carries a timezone
262
+ // abbreviation whose width changes (`GMT` vs ` PDT`), which is exactly what
263
+ // makes a log column ragged. Always 12 characters, always the reader's clock.
264
+ time: [now.getHours(), now.getMinutes(), now.getSeconds()]
265
+ .map((part) => String(part).padStart(2, '0'))
266
+ .join(':') + '.' + String(now.getMilliseconds()).padStart(3, '0'),
267
+ level,
268
+ msg,
269
+ ...extra,
270
+ };
271
+ }
272
+
273
+ export function createLogger(level = process.env.LOG_LEVEL || 'info', options = {}) {
4
274
  const minLevel = levels[level] ?? levels.info;
5
-
275
+ // `pretty` is the default for a terminal *and* for a log file (aligned text is
276
+ // what a person reads at 2am); `ONBOARDER_LOG=json` is the machine escape
277
+ // hatch, and so is a non-TTY consumer that parses stdout.
278
+ const format = options.format || process.env.ONBOARDER_LOG || 'pretty';
279
+ const color = options.color ?? (Boolean(process.stdout.isTTY) && !process.env.NO_COLOR);
280
+ const out = options.stdout || process.stdout;
281
+ const err = options.stderr || process.stderr;
282
+
283
+ // The noise policy. `info` (the default) drops successful static assets and
284
+ // uptime polls; `debug` shows every request, which is what you want when you
285
+ // are debugging the server rather than watching it. ONBOARDER_LOG_VERBOSE=1 is
286
+ // the same escape hatch without changing the level.
287
+ const quiet = options.quiet ?? (minLevel > levels.debug && process.env.ONBOARDER_LOG_VERBOSE !== '1');
288
+ // Read the terminal width per write, not once at construction: a log line
289
+ // printed after the user resized their window should use the new width.
290
+ const columns = options.columns ?? (() => out.columns || undefined);
291
+
292
+ // Suppressed-asset accounting. Assets are counted, not printed, and the tally
293
+ // is flushed as ONE dim summary line once it reaches `flushAt` — so a page
294
+ // load that serves 94 files produces one line, and a terminal that is idle
295
+ // still admits the server is busy rather than looking dead.
296
+ const flushAt = options.flushAt ?? 40;
297
+ let suppressed = 0;
298
+ let since = Date.now();
299
+
300
+ function flush(force = false) {
301
+ if (!suppressed) return;
302
+ if (!force && suppressed < flushAt) return;
303
+ write(logEntry('info', noiseSummary(suppressed, Date.now() - since), { summary: true }));
304
+ suppressed = 0;
305
+ since = Date.now();
306
+ }
307
+
308
+ function write(entry) {
309
+ if (format === 'json') {
310
+ const line = JSON.stringify(entry) + '\n';
311
+ (entry.level === 'warn' || entry.level === 'error' ? err : out).write(line);
312
+ return;
313
+ }
314
+ (entry.level === 'warn' || entry.level === 'error' ? err : out).write(renderEntry(entry, { color, columns: columns() }) + '\n');
315
+ }
316
+
6
317
  function log(lvl, msg, extra = {}) {
7
318
  if (levels[lvl] < minLevel) return;
8
- const entry = {
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
- }
319
+ write(logEntry(lvl, msg, extra));
20
320
  }
21
321
 
22
322
  return {
@@ -24,6 +324,20 @@ export function createLogger(level = process.env.LOG_LEVEL || 'info') {
24
324
  info: (msg, extra) => log('info', msg, extra),
25
325
  warn: (msg, extra) => log('warn', msg, extra),
26
326
  error: (msg, extra) => log('error', msg, extra),
27
- http: (req) => log('info', 'HTTP Request', req)
327
+ // The one place the noise policy lives. A successful GET of a static file is
328
+ // counted, not printed; a scan, a login, or any failure is printed.
329
+ http: (req) => {
330
+ if (quiet && classifyRequest(req) === 'noise') {
331
+ suppressed += 1;
332
+ flush();
333
+ return;
334
+ }
335
+ if (quiet && classifyPath(req.path) === 'poll') return;
336
+ flush();
337
+ log(Number(req.status) >= 500 ? 'error' : 'info', 'http', req);
338
+ },
339
+ // Let a shutdown path (or a test) get the tally out before the process dies.
340
+ flush: () => flush(true),
341
+ suppressed: () => suppressed,
28
342
  };
29
343
  }
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) fs.rmSync(pidPath(configFile), { force: true });
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 = { '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' };
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
+ }
@@ -52,9 +52,14 @@ export function computeFacts(scan, manifest = {}) {
52
52
  .map((f) => f.path)
53
53
  .sort();
54
54
 
55
+ // A hub is a file other files depend on. The threshold is 2, not 3: in a
56
+ // repo with 20 files, "imported by two others" *is* the most-depended-upon
57
+ // file, and a threshold of 3 reported no hubs at all for a whole small project
58
+ // — the map of a small repo is exactly where a hub is most useful. Two is the
59
+ // lowest value that still means "more than one other file reaches for this".
55
60
  const hubs = files
56
61
  .map((f) => ({ path: f.path, fanIn: fanIn.get(f.path) || 0, fanOut: fanOut.get(f.path) || 0 }))
57
- .filter((h) => h.fanIn >= 3)
62
+ .filter((h) => h.fanIn >= 2)
58
63
  .sort((a, b) => b.fanIn - a.fanIn);
59
64
 
60
65
  const orphans = files