codebase-onboarder 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -2
- package/cli/commands.js +323 -12
- package/cli/main.js +39 -3
- package/cli/ui.js +18 -0
- package/package.json +1 -1
- package/server/daemon.js +195 -0
- package/server/index.js +59 -35
- package/server/layout.js +73 -0
- package/server/logger.js +329 -15
- package/server/pidfile.js +38 -1
- package/server/startup.js +261 -0
- package/shared/analyzer/graph.js +6 -1
- package/shared/analyzer/languages/csharp.js +81 -6
- package/shared/analyzer/languages/java.js +79 -4
- package/shared/analyzer/languages/rust.js +104 -2
- package/shared/analyzer/scan.js +37 -6
package/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,9 @@ import { createLogger } from './logger.js';
|
|
|
23
23
|
import { createMcpRunner } from './mcp/runner.js';
|
|
24
24
|
import { browserUrl, configPath, isLoopbackHost, readSettings, serverUrls } from './config.js';
|
|
25
25
|
import { tunnelStatus } from './tunnel.js';
|
|
26
|
-
import { pidIsAlive, readPidFile, removePidFile, writePidFile } from './pidfile.js';
|
|
26
|
+
import { pidIsAlive, readPidFile, removePidFile, writePidFile, writeRunInfo, runInfoPath } from './pidfile.js';
|
|
27
|
+
import { logPath } from './daemon.js';
|
|
28
|
+
import { panel, row } from './layout.js';
|
|
27
29
|
|
|
28
30
|
const logger = createLogger();
|
|
29
31
|
|
|
@@ -63,42 +65,45 @@ export function createServer(config = CONFIG) {
|
|
|
63
65
|
|
|
64
66
|
// What the terminal shows once the socket is listening. A pure string builder
|
|
65
67
|
// so the CLI prints exactly this too, and so a test can read it.
|
|
66
|
-
|
|
68
|
+
//
|
|
69
|
+
// The shape is label/value rows, not prose, because every fact here has to be
|
|
70
|
+
// readable at a glance and copy-pasteable. The one long sentence it used to
|
|
71
|
+
// print — "self-hosted — the network can reach this; every API call needs the
|
|
72
|
+
// access key" — wrapped unpredictably at any terminal width and buried the URL
|
|
73
|
+
// people actually need. Now the detail lives on its own row.
|
|
74
|
+
export function startupBanner(settings, { configFile, columns } = {}) {
|
|
67
75
|
const urls = serverUrls(settings);
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
if (settings.mode === 'self-hosted' && settings.accessKey) {
|
|
85
|
-
lines.push(' Remote browsers show an access-key sign-in page; the key is never put in the URL.');
|
|
76
|
+
const rows = [];
|
|
77
|
+
const add = (label, value) => rows.push([label, value]);
|
|
78
|
+
|
|
79
|
+
add('URL', urls.local);
|
|
80
|
+
if (urls.network) add('Network', urls.network);
|
|
81
|
+
if (urls.domain) add('Public', urls.domain);
|
|
82
|
+
add('Bind', `${settings.host}:${settings.port}`);
|
|
83
|
+
add('Mode', settings.mode === 'self-hosted'
|
|
84
|
+
? (isLoopbackHost(settings.host) ? 'self-hosted (loopback — use a tunnel)' : 'self-hosted (network reachable)')
|
|
85
|
+
: 'local (this machine only)');
|
|
86
|
+
|
|
87
|
+
if (settings.mode === 'self-hosted') {
|
|
88
|
+
add('Auth', settings.accessKey
|
|
89
|
+
? 'access key required for remote browsers'
|
|
90
|
+
: 'NO ACCESS KEY — every API call is refused');
|
|
86
91
|
}
|
|
87
|
-
if (settings.
|
|
88
|
-
|
|
89
|
-
lines.push(' Run `onboarder setup` or `onboarder config key rotate`.');
|
|
92
|
+
if (settings.domain) {
|
|
93
|
+
add('HTTPS', settings.https ? 'Caddy terminates TLS for this domain' : 'off — `onboarder https setup` enables it');
|
|
90
94
|
}
|
|
91
95
|
const tunnels = tunnelStatus(settings);
|
|
92
96
|
for (const name of ['cloudflare', 'tailscale']) {
|
|
93
97
|
const t = tunnels[name];
|
|
94
98
|
if (!t.enabled) continue;
|
|
95
|
-
|
|
96
|
-
? ` Tunnel ${name}: ${t.command}`
|
|
97
|
-
: ` Tunnel ${name} is enabled but its CLI is not installed — ${t.install}`);
|
|
99
|
+
add('Tunnel', t.installed ? `${name}: ${t.command}` : `${name} enabled, CLI missing — ${t.install}`);
|
|
98
100
|
}
|
|
99
|
-
if (configFile)
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
if (configFile) add('Config', configFile);
|
|
102
|
+
|
|
103
|
+
// Rendered through the same panel helper the CLI uses, so `node server/index.js`
|
|
104
|
+
// and `onboarder start` cannot drift apart — and so both adapt to the terminal
|
|
105
|
+
// width instead of assuming 80 columns.
|
|
106
|
+
return '\n' + panel('Onboarder is up', rows.map(([label, value]) => row(label, value)), { columns }) + '\n';
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
// Ask the OS to open the app. Best-effort and detached: a missing opener on a
|
|
@@ -164,14 +169,33 @@ export async function startServer({ configFile = configPath(), openBrowser, log
|
|
|
164
169
|
throw listenError(error, { host, port, pid: recorded && pidIsAlive(recorded) ? recorded : null });
|
|
165
170
|
}
|
|
166
171
|
|
|
172
|
+
// The process record is optional by design, so each write is guarded on its
|
|
173
|
+
// own: a read-only config directory must not stop a server that has already
|
|
174
|
+
// bound its port, but a *programming* error here would otherwise be swallowed
|
|
175
|
+
// and look like "it started, but nothing recorded it".
|
|
176
|
+
try { writePidFile(configFile); } catch { /* read-only config dir */ }
|
|
177
|
+
// How this process was launched. `background` is a detached child with a log
|
|
178
|
+
// file; `startup` is one the OS supervisor started at login (also with a log
|
|
179
|
+
// file); anything else is a person typing `onboarder start` in a terminal.
|
|
180
|
+
const mode = process.env.ONBOARDER_LAUNCH || (process.env.ONBOARDER_BACKGROUND ? 'background' : 'foreground');
|
|
181
|
+
const logFile = mode === 'foreground' ? null : logPath(configFile);
|
|
167
182
|
try {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
183
|
+
// Beside the pid: how this instance was launched and where its log goes, so
|
|
184
|
+
// `onboarder status` answers "how long has it been up, and how do I see its
|
|
185
|
+
// output" from a record instead of guessing.
|
|
186
|
+
writeRunInfo(configFile, {
|
|
187
|
+
mode,
|
|
188
|
+
host,
|
|
189
|
+
port,
|
|
190
|
+
url: serverUrls(live).local,
|
|
191
|
+
log: logFile,
|
|
192
|
+
node: process.version,
|
|
193
|
+
});
|
|
194
|
+
} catch (error) {
|
|
195
|
+
logger.warn('could not write the run record', { file: runInfoPath(configFile), error: error.message });
|
|
174
196
|
}
|
|
197
|
+
server.once('close', () => removePidFile(configFile));
|
|
198
|
+
process.once('exit', () => removePidFile(configFile));
|
|
175
199
|
|
|
176
200
|
log(startupBanner(live, { configFile }));
|
|
177
201
|
|
package/server/layout.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Terminal geometry, with no color and no dependencies.
|
|
2
|
+
//
|
|
3
|
+
// This lives here, under `server/`, because two very different callers need
|
|
4
|
+
// identical layout: the CLI (which paints) and the server's own startup banner
|
|
5
|
+
// (which must not import `cli/ui.js` — that would invert the dependency, since
|
|
6
|
+
// the CLI sits on top of the server). So the *shape* is here and the *color* is
|
|
7
|
+
// injected by the caller as a `paint(text, style)` function.
|
|
8
|
+
//
|
|
9
|
+
// Everything is pure string math on purpose: a layout decision is a thing you
|
|
10
|
+
// want to unit-test without spawning a terminal, and the whole reason panels
|
|
11
|
+
// used to look broken is that this math was scattered through the callers.
|
|
12
|
+
|
|
13
|
+
export function width(text) {
|
|
14
|
+
return String(text).replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The terminal we are drawing into, asked at call time rather than cached at
|
|
18
|
+
// import: a panel printed after the user resized their window should use the
|
|
19
|
+
// new size. Falls back to COLUMNS, then 80, so a pipe or a log file still gets
|
|
20
|
+
// sane output instead of `undefined`.
|
|
21
|
+
export function termWidth(stream = process.stdout) {
|
|
22
|
+
return stream.columns || Number(process.env.COLUMNS) || 80;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Shorten to fit, with a real ellipsis. The middle is elided rather than the
|
|
26
|
+
// tail, because in a path or URL the end is the part that identifies it.
|
|
27
|
+
export function fit(text, max, { tail = true } = {}) {
|
|
28
|
+
const value = String(text);
|
|
29
|
+
if (max <= 0) return '';
|
|
30
|
+
if (value.length <= max) return value;
|
|
31
|
+
if (max === 1) return '…';
|
|
32
|
+
if (!tail) return value.slice(0, max - 1) + '…';
|
|
33
|
+
const head = Math.ceil((max - 1) * 0.4);
|
|
34
|
+
const rest = max - 1 - head;
|
|
35
|
+
return (head ? value.slice(0, head) + '…' : '…') + value.slice(value.length - rest);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A titled block of rows that fits the terminal it is printed into. The frame is
|
|
39
|
+
// sized from its contents, then clamped to the available width, and every value
|
|
40
|
+
// is elided to fit rather than allowed to spill past the border — a panel whose
|
|
41
|
+
// right edge is off-screen is what makes output "mess the terminal".
|
|
42
|
+
//
|
|
43
|
+
// `paint` is injected: `(text, style) => string`, where style is 'label' or
|
|
44
|
+
// 'frame'. The default is identity, which is what the server banner and the
|
|
45
|
+
// `--no-color` path want.
|
|
46
|
+
export function panel(title, rows, { indent = ' ', columns, paint = (t) => String(t) } = {}) {
|
|
47
|
+
// The hard ceiling is the terminal. A floor below it would be a lie: on a 40
|
|
48
|
+
// column terminal a 59-wide panel is exactly the overflow this exists to
|
|
49
|
+
// prevent, so the floor only guards against a nonsensical zero, and short
|
|
50
|
+
// titles/values are handled by the `fit` calls below rather than by a minimum.
|
|
51
|
+
const available = Math.max(24, (columns || termWidth()) - indent.length);
|
|
52
|
+
const labelWidth = Math.min(12, Math.max(...rows.map((r) => width(r.label ?? '')), 0));
|
|
53
|
+
// indent(2) + gap(2) + label + gap(2) + value + right border(2)
|
|
54
|
+
const valueRoom = Math.max(8, available - 2 - labelWidth - 2 - 2);
|
|
55
|
+
const body = rows.map((r) => (r.hint
|
|
56
|
+
? ' ' + ' '.repeat(labelWidth + 2) + paint(fit(r.hint, valueRoom), 'hint')
|
|
57
|
+
: ' ' + paint(fit(r.label ?? '', labelWidth).padEnd(labelWidth), 'label') + ' ' + fit(r.value ?? '', valueRoom)));
|
|
58
|
+
|
|
59
|
+
// Frame width = whatever the body needs, capped to what the terminal has.
|
|
60
|
+
const wanted = Math.max(...body.map(width), 12);
|
|
61
|
+
const inner = Math.max(8, Math.min(available - 2, wanted));
|
|
62
|
+
const shownTitle = fit(title, Math.max(2, inner - 4));
|
|
63
|
+
const top = paint('┌─ ', 'frame') + paint(shownTitle, 'title')
|
|
64
|
+
+ ' ' + paint('─'.repeat(Math.max(0, inner - shownTitle.length - 3)) + '┐', 'frame');
|
|
65
|
+
const bottom = paint('└' + '─'.repeat(inner) + '┘', 'frame');
|
|
66
|
+
return [top, ...body, bottom].map((line) => indent + line).join('\n');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A one-line note rendered in the panel's muted voice.
|
|
70
|
+
export const hint = (text) => ({ hint: text });
|
|
71
|
+
|
|
72
|
+
// A label/value row. Values are stringified here so a caller can pass a number.
|
|
73
|
+
export const row = (label, value) => ({ label, value: String(value) });
|