codebase-onboarder 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -9
- package/cli/commands.js +293 -13
- package/cli/main.js +39 -3
- package/cli/ui.js +34 -0
- package/package.json +1 -1
- package/public/index.html +2 -1
- package/public/js/api.js +21 -56
- package/public/js/serverSettings.js +18 -9
- package/public/login.html +34 -0
- package/server/apiAuth.js +32 -0
- package/server/apiSettings.js +6 -2
- package/server/auth.js +72 -0
- package/server/config.js +17 -13
- package/server/daemon.js +195 -0
- package/server/http.js +2 -2
- package/server/httpGuards.js +1 -0
- package/server/index.js +30 -10
- package/server/logger.js +96 -15
- package/server/pidfile.js +38 -1
- package/server/router.js +47 -18
- package/server/startup.js +261 -0
package/server/http.js
CHANGED
|
@@ -37,8 +37,8 @@ export function readBody(req, limit = MAX_BODY_BYTES) {
|
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export function sendJSON(res, status, data) {
|
|
41
|
-
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
40
|
+
export function sendJSON(res, status, data, headers = {}) {
|
|
41
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...headers });
|
|
42
42
|
res.end(JSON.stringify(data));
|
|
43
43
|
}
|
|
44
44
|
|
package/server/httpGuards.js
CHANGED
|
@@ -43,6 +43,7 @@ export function rebindingReason(req, extraHosts = []) {
|
|
|
43
43
|
const name = hostnameOf(host);
|
|
44
44
|
if (LOOPBACK_HOSTS.has(name)) return null;
|
|
45
45
|
for (const extra of extraHosts) {
|
|
46
|
+
if (extra === 'ip:*' && net.isIP(name.replace(/^\[|\]$/g, '')) > 0) return null;
|
|
46
47
|
if (extra === 'ipv4:*' && net.isIP(name) === 4) return null;
|
|
47
48
|
if (extra === 'ipv6:*' && net.isIP(name.replace(/^\[|\]$/g, '')) === 6) return null;
|
|
48
49
|
if (extra.startsWith('*.')) {
|
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
|
|
|
@@ -82,7 +83,7 @@ export function startupBanner(settings, { configFile } = {}) {
|
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
85
|
if (settings.mode === 'self-hosted' && settings.accessKey) {
|
|
85
|
-
lines.push('
|
|
86
|
+
lines.push(' Remote browsers show an access-key sign-in page; the key is never put in the URL.');
|
|
86
87
|
}
|
|
87
88
|
if (settings.mode === 'self-hosted' && !settings.accessKey) {
|
|
88
89
|
lines.push(' WARNING self-hosted with no access key — every API call is refused until one is set.');
|
|
@@ -164,20 +165,39 @@ 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
|
|
|
178
198
|
const shouldOpen = openBrowser ?? (live.autoOpen && process.stdout.isTTY && !process.env.NO_OPEN);
|
|
179
|
-
//
|
|
180
|
-
//
|
|
199
|
+
// Local requests never need a credential. Remote browsers are sent to the
|
|
200
|
+
// themed login page and exchange the key for an HttpOnly session cookie.
|
|
181
201
|
if (shouldOpen) openInBrowser(browserUrl(live));
|
|
182
202
|
return { server, settings: live, host, port };
|
|
183
203
|
}
|
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
|
}
|
package/server/router.js
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
|
-
// The route table and the
|
|
1
|
+
// The route table and the gates in front of it.
|
|
2
2
|
//
|
|
3
3
|
// Everything arrives here: one function decides whether a request is allowed to
|
|
4
4
|
// be answered at all, then which handler answers it. The routes are a list rather
|
|
5
5
|
// than a ladder of `if` statements so that the whole surface of the server is
|
|
6
|
-
// visible in one screen
|
|
6
|
+
// visible in one screen, and everything else is a static file.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// `evil.com` into our own origin, and `Origin`/`Sec-Fetch-Site` stops a page the
|
|
13
|
-
// person happened to have open from driving the API.
|
|
8
|
+
// There are three concerns. Local mode relies on `Host`, `Origin`, and
|
|
9
|
+
// `Sec-Fetch-Site` to stop another page and DNS rebinding from driving it.
|
|
10
|
+
// Self-hosted mode adds real access-key authentication: local browser requests
|
|
11
|
+
// stay open, remote browsers get a signed session, and API clients use Bearer.
|
|
14
12
|
|
|
15
13
|
import { handleDocs } from './apiDocs.js';
|
|
16
14
|
import { handleFile } from './apiFile.js';
|
|
@@ -25,7 +23,9 @@ import { handleDiff, handleDiffRefs } from './apiDiff.js';
|
|
|
25
23
|
import { handleToolsInstall, handleToolsRun, handleToolsStatus } from './apiTools.js';
|
|
26
24
|
import { handleMcpStart, handleMcpStatus, handleMcpStop, handleMcpCommand } from './apiMcp.js';
|
|
27
25
|
import { handleGetSettings, handleRotateAccessKey, handleUpdateSettings } from './apiSettings.js';
|
|
28
|
-
import {
|
|
26
|
+
import { handleAuthStatus, handleLogin, handleLogout } from './apiAuth.js';
|
|
27
|
+
import { accessKeysMatch, allowedHosts, authReason, bearerToken, DEFAULT_SETTINGS } from './config.js';
|
|
28
|
+
import { hasValidSession } from './auth.js';
|
|
29
29
|
|
|
30
30
|
const ROUTES = [
|
|
31
31
|
{
|
|
@@ -110,6 +110,18 @@ const ROUTES = [
|
|
|
110
110
|
method: 'GET', path: '/api/health',
|
|
111
111
|
run: ({ res }) => sendJSON(res, 200, { ok: true }),
|
|
112
112
|
},
|
|
113
|
+
{
|
|
114
|
+
method: 'GET', path: '/api/auth/status', sameOrigin: true,
|
|
115
|
+
run: ({ req, res, settings }) => handleAuthStatus(req, res, settings),
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
method: 'POST', path: '/api/auth/login', body: true, sameOrigin: true,
|
|
119
|
+
run: ({ req, res, body, settings }) => handleLogin(req, res, body, settings),
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
method: 'POST', path: '/api/auth/logout', body: true, sameOrigin: true,
|
|
123
|
+
run: ({ req, res }) => handleLogout(req, res),
|
|
124
|
+
},
|
|
113
125
|
{
|
|
114
126
|
// The settings drawer and the CLI read the same public shape: everything
|
|
115
127
|
// about the configuration except the access key itself.
|
|
@@ -128,7 +140,7 @@ const ROUTES = [
|
|
|
128
140
|
// this response, never readable again. In self-hosted mode this endpoint
|
|
129
141
|
// is itself behind the current key, so rotation requires possession.
|
|
130
142
|
method: 'POST', path: '/api/settings/access-key', body: true,
|
|
131
|
-
run: ({ res, config }) => handleRotateAccessKey(res, config),
|
|
143
|
+
run: ({ req, res, config }) => handleRotateAccessKey(req, res, config),
|
|
132
144
|
},
|
|
133
145
|
];
|
|
134
146
|
|
|
@@ -184,6 +196,7 @@ export function createRouter(config) {
|
|
|
184
196
|
}
|
|
185
197
|
|
|
186
198
|
const found = matchRoute(req.method, url.pathname);
|
|
199
|
+
const publicAuthRoute = ['/api/health', '/api/auth/status', '/api/auth/login', '/api/auth/logout'].includes(url.pathname);
|
|
187
200
|
if (req.method !== 'GET' || found?.route.sameOrigin) {
|
|
188
201
|
const foreign = crossOriginReason(req);
|
|
189
202
|
if (foreign) {
|
|
@@ -191,18 +204,34 @@ export function createRouter(config) {
|
|
|
191
204
|
}
|
|
192
205
|
}
|
|
193
206
|
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
207
|
+
// Bearer clients keep their existing API contract. A browser gets a signed,
|
|
208
|
+
// HttpOnly session from the login form instead of storing the raw key.
|
|
209
|
+
const bearerClient = accessKeysMatch(settings.accessKey, bearerToken(req));
|
|
210
|
+
const browserAuthenticated = hasValidSession(req, settings);
|
|
211
|
+
const authDenied = authReason(req, settings);
|
|
212
|
+
const locallyExempt = !authDenied;
|
|
213
|
+
const authenticated = bearerClient || browserAuthenticated || locallyExempt;
|
|
214
|
+
const remoteSelfHosted = settings.mode === 'self-hosted' && Boolean(authDenied);
|
|
215
|
+
|
|
216
|
+
if (remoteSelfHosted && !authenticated && !publicAuthRoute) {
|
|
217
|
+
// API callers keep a machine-readable 401. A browser navigation gets the
|
|
218
|
+
// themed sign-in document so the user never has to paste JSON into a tab.
|
|
219
|
+
const accepts = String(req.headers?.accept || '');
|
|
220
|
+
if (req.method === 'GET' && (url.pathname === '/' || accepts.includes('text/html'))) {
|
|
221
|
+
res.statusCode = 200;
|
|
222
|
+
return await serveStatic(res, '/login.html', config);
|
|
223
|
+
}
|
|
224
|
+
return sendError(res, 401, authDenied || 'Sign in with the Onboarder access key first.');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (url.pathname === '/api/auth/logout') {
|
|
228
|
+
// Always clear the browser cookie, even if it had already expired.
|
|
229
|
+
return handleLogout(req, res);
|
|
201
230
|
}
|
|
202
231
|
|
|
203
232
|
if (found) {
|
|
204
233
|
const body = found.route.body ? await readBody(req) : null;
|
|
205
|
-
return await found.route.run({ req, res, url, body, rest: found.rest, config });
|
|
234
|
+
return await found.route.run({ req, res, url, body, rest: found.rest, config, settings });
|
|
206
235
|
}
|
|
207
236
|
|
|
208
237
|
if (req.method === 'GET') return await serveStatic(res, url.pathname, config);
|
|
@@ -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
|
+
}
|