codebase-onboarder 0.4.0 β 0.5.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 +80 -6
- package/cli/commands.js +31 -0
- package/cli/explorer/app.js +295 -0
- package/cli/explorer/commands.js +197 -0
- package/cli/explorer/session.js +161 -0
- package/cli/explorer/views.js +549 -0
- package/cli/main.js +36 -8
- package/cli/ui.js +13 -29
- package/package.json +1 -1
- package/server/index.js +32 -28
- package/server/layout.js +73 -0
- package/server/logger.js +244 -11
- package/shared/analyzer/graph.js +6 -1
- package/shared/analyzer/languages/csharp.js +81 -6
- package/shared/analyzer/languages/java.js +79 -4
- package/shared/analyzer/languages/rust.js +104 -2
- package/shared/analyzer/scan.js +37 -6
package/README.md
CHANGED
|
@@ -22,8 +22,11 @@ Onboarder reads a software repository the way a senior engineer would: starting
|
|
|
22
22
|
# Install from npm (Node 20+, zero runtime dependencies)
|
|
23
23
|
npm install -g codebase-onboarder
|
|
24
24
|
|
|
25
|
-
#
|
|
25
|
+
# Explore the repo you are standing in, right in your terminal
|
|
26
26
|
onboarder
|
|
27
|
+
|
|
28
|
+
# β¦or start the web UI instead
|
|
29
|
+
onboarder start
|
|
27
30
|
```
|
|
28
31
|
|
|
29
32
|
Or from source:
|
|
@@ -36,12 +39,53 @@ npm start
|
|
|
36
39
|
|
|
37
40
|
Open **http://localhost:4310** in your browser (the CLI opens it for you).
|
|
38
41
|
|
|
42
|
+
- **Two front doors, one engine.** `onboarder` opens an interactive terminal session; `onboarder start` opens the web UI. Both run the *same* analyzer, so they can never disagree about a repository.
|
|
39
43
|
- **Zero build steps**: Native ES modules.
|
|
40
44
|
- **Zero runtime dependencies**: Powered by Node.js built-ins (`node:http`, `node:fs`, `node:crypto`).
|
|
41
45
|
- **Offline ready**: Vendored Mermaid.js and Monaco Editor builds are included in `public/vendor/`.
|
|
42
46
|
|
|
43
47
|
---
|
|
44
48
|
|
|
49
|
+
## π₯οΈ The terminal app
|
|
50
|
+
|
|
51
|
+
Type `onboarder` in any repo and you get the website, in your terminal. No server, no browser, no network β it reads the folder directly.
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
onboarder # explore the current directory
|
|
55
|
+
onboarder explore ~/work/some-repo
|
|
56
|
+
onboarder --server # force the web UI (what bare `onboarder` used to do)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
It lands on a `map` of the repo, then waits:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
codebase > tour
|
|
63
|
+
codebase > find resolveImport
|
|
64
|
+
codebase > deps logger.js
|
|
65
|
+
codebase > show logger.js
|
|
66
|
+
codebase > exit
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
| | |
|
|
70
|
+
|---|---|
|
|
71
|
+
| **map Β· tour Β· explain** | What this is, the reading order for a new teammate, and a file or folder explained in prose |
|
|
72
|
+
| **tree Β· find Β· show Β· deps** | Browse it, search it, read it, and trace what it connects to |
|
|
73
|
+
| **health Β· hubs Β· layers Β· patterns** | The analysis, in the same words the site uses |
|
|
74
|
+
| **stats Β· security Β· stack Β· entry Β· externals** | Numbers, findings, dependencies, and drift |
|
|
75
|
+
| **cd Β· rescan Β· web** | Switch repo, reload from disk, or start the web UI without leaving |
|
|
76
|
+
|
|
77
|
+
**It is the website's engine, not a reimplementation.** `onboarder explore` runs the same modules in the same order as `POST /api/scan` β `scanRepo` β `detectManifest` β `computeFacts` β `buildSearchIndex` β and the same projections the browser's views are projections of. A second implementation would drift, and a drifted map is worse than no map.
|
|
78
|
+
|
|
79
|
+
Details worth knowing:
|
|
80
|
+
|
|
81
|
+
- **Forgiving paths.** `show logger.js` finds `server/logger.js`. A genuinely ambiguous name (`index.js`) lists the candidates instead of guessing β silently picking the wrong file is how a map loses your trust.
|
|
82
|
+
- **The site's search language.** `find ext:rs -test`, `find "exact phrase"`, `find /regex/` all work because it is the same query parser the web palette uses.
|
|
83
|
+
- **Fits your terminal.** Every line is fitted to the current width and re-fits on resize; prose wraps instead of running off the edge. `NO_COLOR` and `COLUMNS` are honored.
|
|
84
|
+
- **It refuses to hang.** Without a TTY, `onboarder explore` explains itself and exits rather than waiting for input that will never come. Scripts and CI keep working.
|
|
85
|
+
- **Bridges to the web.** `web` starts the UI in the background and prints the URL, without ending your session.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
45
89
|
## π Ways to Load a Repository
|
|
46
90
|
|
|
47
91
|
1. **Local Directory** β Enter any absolute path (`/Users/you/projects/repo` or `~/work/repo`). Analyzed in-place without copying files.
|
|
@@ -242,12 +286,34 @@ onboarder stop # stop it
|
|
|
242
286
|
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
287
|
|
|
244
288
|
```
|
|
245
|
-
16:09:38
|
|
246
|
-
16:09:46
|
|
247
|
-
16:10:
|
|
289
|
+
16:09:38 INFO GET /api/scan 200 (1.2s)
|
|
290
|
+
16:09:46 INFO POST /api/auth/login 200 (2ms)
|
|
291
|
+
16:10:03 WARN could not write the run record error=EACCES
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### What gets logged (and what does not)
|
|
295
|
+
|
|
296
|
+
A single page load pulls ~90 ES modules, a stylesheet, and two vendored libraries. Logging each one buries every real event β a scan, a login, a 500 β under a hundred lines that describe nobody doing anything, repeated on every reload. So requests are classified:
|
|
297
|
+
|
|
298
|
+
| Request | Logged? |
|
|
299
|
+
|---|---|
|
|
300
|
+
| `GET /api/β¦` (a scan, a login, a settings save) | **yes** β this is a person doing something |
|
|
301
|
+
| Any `POST`/`PUT`/`DELETE` | **yes**, whatever the path |
|
|
302
|
+
| Any `4xx` or `5xx` | **yes** β a missing asset is a broken build, a 500 is a bug |
|
|
303
|
+
| `GET /api/health` (uptime poll) | no β it is not an event |
|
|
304
|
+
| `GET /app.js`, `/js/tree.js`, `/styles.css`, `/vendor/β¦` | **counted, not printed** |
|
|
305
|
+
|
|
306
|
+
Suppressed assets are not thrown away. Every 40 of them, one dim footnote is printed, so an idle terminal still says what it served rather than looking dead:
|
|
307
|
+
|
|
308
|
+
```
|
|
309
|
+
16:09:38 served 40 static files in 1.9s
|
|
248
310
|
```
|
|
249
311
|
|
|
250
|
-
|
|
312
|
+
`LOG_LEVEL=debug` (or `ONBOARDER_LOG_VERBOSE=1`) turns every request back on for when you are debugging the server rather than watching it. `ONBOARDER_LOG=json` switches the format to one JSON object per line for anything parsing the log.
|
|
313
|
+
|
|
314
|
+
### Terminal width
|
|
315
|
+
|
|
316
|
+
Every panel and log line is fitted to the terminal it is printed into, and a foreground `onboarder start` redraws its panel on `SIGWINCH` β resize the window and the border stays on screen instead of hanging off the edge. Long values are elided in the middle (the tail of a path is the part that identifies it), and below 52 columns the level column is dropped to make room for the message.
|
|
251
317
|
|
|
252
318
|
### Starting at login
|
|
253
319
|
|
|
@@ -290,6 +356,13 @@ Cloudflare quick tunnels and Tailscale remain supported. They terminate TLS and
|
|
|
290
356
|
codebase-onboarder/
|
|
291
357
|
βββ bin/ # npm entry point (shebang trampoline) & postinstall note
|
|
292
358
|
βββ cli/ # Onboarding wizard, config commands, doctor, tunnels
|
|
359
|
+
β βββ main.js # argv β command; bare `onboarder` picks explorer vs server
|
|
360
|
+
β βββ ui.js # Terminal paint; --no-color / NO_COLOR strip it in one place
|
|
361
|
+
β βββ explorer/ # The terminal app
|
|
362
|
+
β βββ app.js # The readline session, TTY guards, `web` bridge
|
|
363
|
+
β βββ session.js # Loads a repo exactly as POST /api/scan does
|
|
364
|
+
β βββ views.js # The site's screens, drawn as text (pure functions)
|
|
365
|
+
β βββ commands.js# One command table: dispatch, help, and tests share it
|
|
293
366
|
βββ server/ # Zero-dependency Node.js HTTP server
|
|
294
367
|
β βββ index.js # createServer / startServer / startup banner
|
|
295
368
|
β βββ config.js # Settings schema, normalization, atomic 0600 writes
|
|
@@ -297,6 +370,7 @@ codebase-onboarder/
|
|
|
297
370
|
β βββ daemon.js # Detached background start, log file, readiness probe
|
|
298
371
|
β βββ startup.js # Login items: launchd plist / systemd --user unit / Startup folder
|
|
299
372
|
β βββ logger.js # Aligned-text or JSON log lines from one entry shape
|
|
373
|
+
β βββ layout.js # Shared terminal geometry: width, fit, panel, resize
|
|
300
374
|
β βββ router.js # Route table, live per-request settings, auth & CSRF gates
|
|
301
375
|
β βββ auth.js # Signed HttpOnly browser sessions
|
|
302
376
|
β βββ apiAuth.js # Login/status/logout endpoints
|
|
@@ -316,7 +390,7 @@ codebase-onboarder/
|
|
|
316
390
|
β βββ vendor/ # Vendored Mermaid & Monaco Editor (Offline)
|
|
317
391
|
β βββ index.html # Main application interface
|
|
318
392
|
β βββ login.html # Self-hosted access-key sign-in
|
|
319
|
-
βββ tests/ # Comprehensive node:test suite
|
|
393
|
+
βββ tests/ # Comprehensive node:test suite
|
|
320
394
|
```
|
|
321
395
|
|
|
322
396
|
---
|
package/cli/commands.js
CHANGED
|
@@ -182,6 +182,34 @@ function printServerDetails(out, started, options) {
|
|
|
182
182
|
out('');
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
// Keep a foreground panel at the terminal's current width.
|
|
186
|
+
//
|
|
187
|
+
// Resizing a window after the server started used to leave a panel whose right
|
|
188
|
+
// border was off-screen β the information was correct but unreadable, which is
|
|
189
|
+
// the same complaint as "it messes up the terminal". On SIGWINCH we redraw the
|
|
190
|
+
// block in place: move the cursor up over the lines we own, clear them, and
|
|
191
|
+
// reprint at the new width. Only the panel is redrawn, not the log scrollback
|
|
192
|
+
// above it, so nothing the user has already read is disturbed.
|
|
193
|
+
//
|
|
194
|
+
// A non-TTY (a pipe, a log file) never redraws: there is no cursor to move and
|
|
195
|
+
// re-printing would just duplicate the block.
|
|
196
|
+
export function watchResize(render, stream = process.stdout) {
|
|
197
|
+
if (!stream.isTTY || typeof process.stdout.on !== 'function') return () => {};
|
|
198
|
+
let previous = '';
|
|
199
|
+
const onResize = () => {
|
|
200
|
+
const next = render();
|
|
201
|
+
if (next === previous) return;
|
|
202
|
+
const lines = previous ? previous.split('\n').length : 0;
|
|
203
|
+
// Up over the old block, clear it, print the new one. `\x1b[J` clears from
|
|
204
|
+
// the cursor to the end of the screen, which is exactly the old block.
|
|
205
|
+
stream.write(lines ? `\x1b[${lines}A\x1b[J` : '');
|
|
206
|
+
stream.write(next);
|
|
207
|
+
previous = next;
|
|
208
|
+
};
|
|
209
|
+
process.stdout.on('SIGWINCH', onResize);
|
|
210
|
+
return () => process.stdout.removeListener('SIGWINCH', onResize);
|
|
211
|
+
}
|
|
212
|
+
|
|
185
213
|
export async function runStart({ flags = {}, out = console.log, err = console.error } = {}) {
|
|
186
214
|
const file = flags.config || configPath();
|
|
187
215
|
if (!await configExists(file)) {
|
|
@@ -220,6 +248,9 @@ export async function runStart({ flags = {}, out = console.log, err = console.er
|
|
|
220
248
|
// and ONBOARDER_BACKGROUND is what tells the two apart: one is attached to a
|
|
221
249
|
// terminal you are about to close, the other is already detached from it.
|
|
222
250
|
printServerDetails(out, started, { configFile: file, mode: 'foreground' });
|
|
251
|
+
// Redraw the panel when the terminal is resized, so the border stays on screen
|
|
252
|
+
// and long paths re-elide to the new width instead of hanging off the edge.
|
|
253
|
+
watchResize(() => '\n' + panel('Onboarder is running', serverDetails(started, { configFile: file }).rows) + '\n');
|
|
223
254
|
if (process.env.ONBOARDER_LAUNCH) {
|
|
224
255
|
// Started by launchd/systemd/the Startup folder: these lines are going into
|
|
225
256
|
// a log file nobody is watching, so they say what the supervisor is doing
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// The interactive session.
|
|
2
|
+
//
|
|
3
|
+
// This is a prompt, not a full-screen TUI, and that is a deliberate choice.
|
|
4
|
+
// A curses app takes the scrollback, the selection, and copy-paste away, and it
|
|
5
|
+
// behaves badly over SSH and inside a tmux pane β which is exactly where people
|
|
6
|
+
// read a codebase. A readline session keeps all of that, prints scrollable
|
|
7
|
+
// output you can select, and degrades to something scriptable. The research
|
|
8
|
+
// agrees: a CLI is a conversation, and a TUI earns its cost only when you are
|
|
9
|
+
// manipulating state with the keyboard rather than reading.
|
|
10
|
+
//
|
|
11
|
+
// Two rules from the design research are load-bearing here:
|
|
12
|
+
// * TTY detection. A session that needs a terminal must never be started by a
|
|
13
|
+
// pipe, a cron job, or CI β so `runExplore` refuses and explains instead of
|
|
14
|
+
// hanging forever waiting for input nobody is there to type.
|
|
15
|
+
// * Flags β env β config precedence. `NO_COLOR` and `COLUMNS` are honored
|
|
16
|
+
// before anything is drawn, so a redirect gets plain, fitted text.
|
|
17
|
+
|
|
18
|
+
import readline from 'node:readline';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
import { openRepo } from './session.js';
|
|
23
|
+
import { CLEAR, EXIT, commandNames, helpText, lookup, tokenize } from './commands.js';
|
|
24
|
+
import { overview } from './views.js';
|
|
25
|
+
import { bold, cyan, dim, ok, bad, paint } from '../ui.js';
|
|
26
|
+
import { configPath, readSettings, serverUrls } from '../../server/config.js';
|
|
27
|
+
import { readPidFile, pidIsAlive } from '../../server/pidfile.js';
|
|
28
|
+
|
|
29
|
+
const VERSION = JSON.parse(
|
|
30
|
+
fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8')
|
|
31
|
+
).version;
|
|
32
|
+
|
|
33
|
+
// The one-time note. `onboarder` used to start a web server; now it opens this.
|
|
34
|
+
// Someone who upgrades and has muscle memory for the old thing deserves to be
|
|
35
|
+
// told once where the server went, and then never again.
|
|
36
|
+
const HINT_MARKER = 'explorer-hint-v1';
|
|
37
|
+
|
|
38
|
+
export async function runExplore({ target = '.', flags = {}, out = console.log, err = console.error, version = VERSION } = {}) {
|
|
39
|
+
// The guard. `stdin` matters as much as `stdout`: a session with no input
|
|
40
|
+
// source is a hang, and a hang in CI is worse than any error.
|
|
41
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
42
|
+
out('');
|
|
43
|
+
out(' The terminal explorer needs an interactive terminal.');
|
|
44
|
+
out(dim(' In a script or a pipe, use these instead:'));
|
|
45
|
+
out(dim(' onboarder start start the web UI'));
|
|
46
|
+
out(dim(' onboarder start background start it detached'));
|
|
47
|
+
out(dim(' onboarder status | stop manage a running one'));
|
|
48
|
+
out(dim(' onboarder --help everything else'));
|
|
49
|
+
out('');
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let repo;
|
|
54
|
+
try {
|
|
55
|
+
repo = await openRepo(target, { onProgress: progressReporter(out) });
|
|
56
|
+
} catch (e) {
|
|
57
|
+
err(' ' + bad((e.message || String(e))));
|
|
58
|
+
err(dim(' Point it at a folder: onboarder explore /path/to/repo'));
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
out('');
|
|
63
|
+
out(overview(repo));
|
|
64
|
+
out('');
|
|
65
|
+
if (await shouldShowHint(flags)) {
|
|
66
|
+
out(dim(' Note: `onboarder` used to start the web server. That is now `onboarder start`'));
|
|
67
|
+
out(dim(' (or the `web` command here) β this session reads the repo directly,'));
|
|
68
|
+
out(dim(' so it needs no server and works offline.'));
|
|
69
|
+
out('');
|
|
70
|
+
}
|
|
71
|
+
return session({ repo, flags, out, err, version });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Scanning a large monorepo is the one genuinely slow thing this does, so it
|
|
75
|
+
// says so. A silent eight seconds is indistinguishable from a hang.
|
|
76
|
+
function progressReporter(out) {
|
|
77
|
+
let last = 0;
|
|
78
|
+
return ({ phase, done }) => {
|
|
79
|
+
if (!process.stdout.isTTY) return;
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
if (phase === 'parse' && done && now - last > 400) {
|
|
82
|
+
last = now;
|
|
83
|
+
out(dim(`\r scanning⦠${done} files`));
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function shouldShowHint(flags) {
|
|
89
|
+
if (process.env.ONBOARDER_NO_HINT) return false;
|
|
90
|
+
try {
|
|
91
|
+
const marker = path.join(path.dirname(flags.config || configPath()), HINT_MARKER);
|
|
92
|
+
const seen = await fs.promises.stat(marker).then(() => true).catch(() => false);
|
|
93
|
+
if (seen) return false;
|
|
94
|
+
await fs.promises.mkdir(path.dirname(marker), { recursive: true });
|
|
95
|
+
await fs.promises.writeFile(marker, 'shown\n');
|
|
96
|
+
return true;
|
|
97
|
+
} catch {
|
|
98
|
+
// A read-only config home is not a reason to nag on every launch.
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The read loop. Commands are queued rather than awaited inline, because
|
|
104
|
+
// readline emits the next line while a slow command is still running and two
|
|
105
|
+
// interleaved `cd`s would leave the session pointing at a repo nobody asked
|
|
106
|
+
// for.
|
|
107
|
+
function session({ repo, flags, out, err, version }) {
|
|
108
|
+
const ctx = {
|
|
109
|
+
repo,
|
|
110
|
+
version,
|
|
111
|
+
flags,
|
|
112
|
+
help: (topic) => helpText(ctx, topic),
|
|
113
|
+
rescan: () => reload(ctx, ctx.repo.root),
|
|
114
|
+
loadRepo: (where) => reload(ctx, where),
|
|
115
|
+
web: () => startWeb(ctx),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const rl = readline.createInterface({
|
|
119
|
+
input: process.stdin,
|
|
120
|
+
output: process.stdout,
|
|
121
|
+
prompt: promptFor(repo),
|
|
122
|
+
terminal: true,
|
|
123
|
+
historySize: 200,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
let queue = Promise.resolve();
|
|
127
|
+
let closed = false;
|
|
128
|
+
let interrupts = 0;
|
|
129
|
+
|
|
130
|
+
rl.on('line', (line) => {
|
|
131
|
+
queue = queue.then(() => handleLine(ctx, line, rl, out, err));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Ctrl-D on an empty line is the universal "I'm done". On a line with text,
|
|
135
|
+
// readline handles it itself; this only sees the empty-line case.
|
|
136
|
+
//
|
|
137
|
+
// Closing does NOT end the session. A piped or fast-typed sequence of commands
|
|
138
|
+
// can already be queued when the last line arrives, and resolving here would
|
|
139
|
+
// throw those away mid-flight β which is exactly what happens to `tour` when
|
|
140
|
+
// its input is followed immediately by `exit`. The exit waits on the queue.
|
|
141
|
+
rl.on('close', () => {
|
|
142
|
+
closed = true;
|
|
143
|
+
process.stdout.off('resize', onResize);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Ctrl-C twice leaves. Once clears the line, which is what readline already
|
|
147
|
+
// does, and matches every other REPL people use.
|
|
148
|
+
rl.on('SIGINT', () => {
|
|
149
|
+
if (closed) return;
|
|
150
|
+
interrupts++;
|
|
151
|
+
if (interrupts >= 2) {
|
|
152
|
+
rl.close();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
out(dim('\n Ctrl-C again to leave.'));
|
|
156
|
+
rl.setPrompt(promptFor(ctx.repo));
|
|
157
|
+
rl.prompt();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// A resize redraws rather than leaving the prompt stranded mid-wrap.
|
|
161
|
+
const onResize = () => {
|
|
162
|
+
if (!closed) rl.write(null, { ctrl: true, name: 'l' });
|
|
163
|
+
};
|
|
164
|
+
process.stdout.on('resize', onResize);
|
|
165
|
+
|
|
166
|
+
queue = queue.then(() => rl.prompt());
|
|
167
|
+
|
|
168
|
+
return new Promise((resolve) => {
|
|
169
|
+
const poll = setInterval(() => {
|
|
170
|
+
if (!closed) return;
|
|
171
|
+
clearInterval(poll);
|
|
172
|
+
// Drain whatever is still running, then say goodbye. A command that throws
|
|
173
|
+
// has already been caught in `handleLine`, so this cannot reject.
|
|
174
|
+
queue.then(() => {
|
|
175
|
+
out('');
|
|
176
|
+
out(dim(' bye.'));
|
|
177
|
+
resolve(0);
|
|
178
|
+
});
|
|
179
|
+
}, 20);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function promptFor(repo) {
|
|
184
|
+
return paint(' ', 'cyan') + bold(repo.name.slice(0, 24)) + paint(' > ', 'gray');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function handleLine(ctx, line, rl, out, err) {
|
|
188
|
+
const words = tokenize(line);
|
|
189
|
+
if (!words.length) {
|
|
190
|
+
rl.prompt();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const [name, ...args] = words;
|
|
194
|
+
const cmd = lookup(name);
|
|
195
|
+
|
|
196
|
+
if (!cmd) {
|
|
197
|
+
// The nearest command by edit distance is almost always what was meant, and
|
|
198
|
+
// a one-line "did you mean" beats a paragraph about `help`.
|
|
199
|
+
const near = nearest(name);
|
|
200
|
+
err(' ' + bad('Unknown command: ' + name) + (near ? dim(' did you mean `' + near + '`?') : dim(' try `help`')));
|
|
201
|
+
rl.prompt();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const result = await cmd.run(ctx, args);
|
|
207
|
+
if (result === EXIT) {
|
|
208
|
+
rl.close();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (result === CLEAR) {
|
|
212
|
+
out('\x1b[2J\x1b[3J\x1b[H');
|
|
213
|
+
} else if (result) {
|
|
214
|
+
out(result);
|
|
215
|
+
}
|
|
216
|
+
} catch (e) {
|
|
217
|
+
err(' ' + bad((e.message || String(e))));
|
|
218
|
+
}
|
|
219
|
+
// The prompt carries the repo name, and `cd` can change which repo that is.
|
|
220
|
+
// Re-reading it after every command is cheaper than tracking which commands
|
|
221
|
+
// swap the repo, and it cannot go stale.
|
|
222
|
+
rl.setPrompt(promptFor(ctx.repo));
|
|
223
|
+
rl.prompt();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Swap the loaded repo. Used by `cd` and `rescan`; the scan is the slow part,
|
|
227
|
+
// so the new name reaches the prompt only after it succeeds.
|
|
228
|
+
async function reload(ctx, where) {
|
|
229
|
+
const target = String(where || '').trim();
|
|
230
|
+
if (!target) return ' cd needs a folder β `cd ../other-repo`.';
|
|
231
|
+
const next = await openRepo(target, { onProgress: () => {} });
|
|
232
|
+
ctx.repo = next;
|
|
233
|
+
return '\n' + overview(next);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// The bridge between the two surfaces. If the server is already up this just
|
|
237
|
+
// prints where it is; if not, it starts it detached, so the person keeps their
|
|
238
|
+
// session. The URL is the same one `onboarder start` would print, because it
|
|
239
|
+
// comes from the same `serverUrls` the CLI and the banner use.
|
|
240
|
+
async function startWeb(ctx) {
|
|
241
|
+
const file = ctx.flags.config || configPath();
|
|
242
|
+
const settings = await readSettings(file).catch(() => null);
|
|
243
|
+
const urls = serverUrls(settings || undefined);
|
|
244
|
+
const recorded = readPidFile(file);
|
|
245
|
+
|
|
246
|
+
if (recorded && pidIsAlive(recorded)) {
|
|
247
|
+
return [' ' + ok('Already running.') + dim(` PID ${recorded.pid}`), ' ' + cyan(urls.local)].join('\n');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const { runStartBackground } = await import('../commands.js');
|
|
251
|
+
const code = await runStartBackground({
|
|
252
|
+
flags: { ...ctx.flags, json: true },
|
|
253
|
+
out: () => {},
|
|
254
|
+
err: () => {},
|
|
255
|
+
});
|
|
256
|
+
if (code !== 0) return ' ' + bad('Could not start the web UI.') + dim(' Try `onboarder doctor`.');
|
|
257
|
+
return [
|
|
258
|
+
' ' + ok('Web UI started in the background.'),
|
|
259
|
+
' ' + cyan(urls.local),
|
|
260
|
+
dim(' onboarder logs -f to watch it Β· onboarder stop to shut it down'),
|
|
261
|
+
].join('\n');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Cheap "did you mean": plain Levenshtein over the command names, with a
|
|
265
|
+
// distance cap of 2 so a wildly misspelled word gets "try `help`" instead of a
|
|
266
|
+
// confident wrong suggestion. A wrong suggestion is worse than none.
|
|
267
|
+
function nearest(word) {
|
|
268
|
+
const w = String(word).toLowerCase();
|
|
269
|
+
let best = null;
|
|
270
|
+
let bestScore = Infinity;
|
|
271
|
+
for (const name of commandNames()) {
|
|
272
|
+
const d = distance(w, name);
|
|
273
|
+
if (d < bestScore) {
|
|
274
|
+
bestScore = d;
|
|
275
|
+
best = name;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return bestScore <= 2 ? best : null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function distance(a, b) {
|
|
282
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
283
|
+
for (let i = 1; i <= a.length; i++) {
|
|
284
|
+
const cur = [i];
|
|
285
|
+
for (let j = 1; j <= b.length; j++) {
|
|
286
|
+
cur[j] = Math.min(
|
|
287
|
+
prev[j] + 1,
|
|
288
|
+
cur[j - 1] + 1,
|
|
289
|
+
prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
prev = cur;
|
|
293
|
+
}
|
|
294
|
+
return prev[b.length];
|
|
295
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// The command table.
|
|
2
|
+
//
|
|
3
|
+
// One list, used three ways: to dispatch what someone typed, to build the `help`
|
|
4
|
+
// screen, and to assert in the tests that every command is reachable, has a
|
|
5
|
+
// summary, and is wired to a real view. A command that exists in one of those
|
|
6
|
+
// places but not the others is the kind of thing nobody notices until a user
|
|
7
|
+
// types it, so the table is the only place a command is defined at all.
|
|
8
|
+
//
|
|
9
|
+
// `run(ctx, args)` returns a string to print, or a marker the app layer acts on
|
|
10
|
+
// (`EXIT`, `CLEAR`). `args` is an array of already-parsed words, so quoting is
|
|
11
|
+
// handled once, in the tokenizer, and every command sees the same shape.
|
|
12
|
+
|
|
13
|
+
import * as V from './views.js';
|
|
14
|
+
|
|
15
|
+
export const EXIT = Symbol('exit');
|
|
16
|
+
export const CLEAR = Symbol('clear');
|
|
17
|
+
|
|
18
|
+
export const COMMANDS = [
|
|
19
|
+
{
|
|
20
|
+
name: 'help', aliases: ['?'], group: 'basics',
|
|
21
|
+
usage: 'help', summary: 'This list.',
|
|
22
|
+
run: (ctx) => ctx.help(),
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'map', aliases: ['overview', 'home'], group: 'basics',
|
|
26
|
+
usage: 'map', summary: 'What this repo is, and where to start.',
|
|
27
|
+
run: (ctx) => V.overview(ctx.repo),
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'tour', aliases: ['start', 'onboarding'], group: 'basics',
|
|
31
|
+
usage: 'tour', summary: 'The reading order a new teammate should follow.',
|
|
32
|
+
run: (ctx) => V.tour(ctx.repo),
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'explain', aliases: ['why', 'what'], group: 'basics',
|
|
36
|
+
usage: 'explain [file|folder]', summary: 'This repo, or one file/folder, in prose. No AI key needed.',
|
|
37
|
+
run: (ctx, args) => V.explain(ctx.repo, { target: args.join(' ') }),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'tree', aliases: ['ls', 'files'], group: 'navigate',
|
|
41
|
+
usage: 'tree [folder] [depth]', summary: 'The file tree, with entries and hubs marked.',
|
|
42
|
+
run: (ctx, args) => {
|
|
43
|
+
// A lone number is the depth, not a folder called "2". `tree 3` is what
|
|
44
|
+
// everyone types when they want to see more; making them spell
|
|
45
|
+
// `tree . 3` would be pedantry in a tool built to be forgiving.
|
|
46
|
+
const onlyDepth = args.length === 1 && /^\d+$/.test(args[0]);
|
|
47
|
+
return V.tree(ctx.repo, {
|
|
48
|
+
sub: onlyDepth ? '' : (args[0] || ''),
|
|
49
|
+
depth: onlyDepth ? Number(args[0]) : (Number(args[1]) || 2),
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'find', aliases: ['search', 'grep'], group: 'navigate',
|
|
55
|
+
usage: 'find <query>', summary: 'Search. Supports ext:js, -exclude, "phrases", /regex/.',
|
|
56
|
+
run: (ctx, args) => V.find(ctx.repo, { query: args.join(' ') }),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'show', aliases: ['open', 'cat', 'read'], group: 'navigate',
|
|
60
|
+
usage: 'show <file> [from] [count]', summary: 'Read a file. Name it loosely: `show logger.js` works.',
|
|
61
|
+
run: (ctx, args) => V.show(ctx.repo, { target: args[0] || '', from: Number(args[1]) || 0, count: Number(args[2]) || 0 }),
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'deps', aliases: ['connections', 'graph'], group: 'navigate',
|
|
65
|
+
usage: 'deps <file>', summary: 'What a file imports, and what imports it.',
|
|
66
|
+
run: (ctx, args) => V.deps(ctx.repo, { target: args.join(' ') }),
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'health', aliases: ['grade'], group: 'analyze',
|
|
70
|
+
usage: 'health', summary: 'Health grade, riskiest files, debt.',
|
|
71
|
+
run: (ctx) => V.health(ctx.repo),
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'hubs', aliases: ['core'], group: 'analyze',
|
|
75
|
+
usage: 'hubs', summary: 'The most depended-on files.',
|
|
76
|
+
run: (ctx) => V.hubs(ctx.repo),
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'layers', aliases: ['depth'], group: 'analyze',
|
|
80
|
+
usage: 'layers', summary: 'Import depth, shallowest first.',
|
|
81
|
+
run: (ctx) => V.layers(ctx.repo),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'patterns', aliases: ['architecture'], group: 'analyze',
|
|
85
|
+
usage: 'patterns', summary: 'What the architecture looks like, in prose.',
|
|
86
|
+
run: (ctx) => V.patterns(ctx.repo),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: 'stats', aliases: ['numbers'], group: 'analyze',
|
|
90
|
+
usage: 'stats', summary: 'Languages, biggest folders and files, complexity.',
|
|
91
|
+
run: (ctx) => V.stats(ctx.repo),
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: 'security', aliases: ['audit'], group: 'analyze',
|
|
95
|
+
usage: 'security', summary: 'Heuristic findings from the built-in rules.',
|
|
96
|
+
run: (ctx) => V.security(ctx.repo),
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'stack', aliases: ['packages'], group: 'analyze',
|
|
100
|
+
usage: 'stack', summary: 'Declared dependencies and frameworks.',
|
|
101
|
+
run: (ctx) => V.stack(ctx.repo),
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: 'entry', aliases: ['entries'], group: 'analyze',
|
|
105
|
+
usage: 'entry', summary: 'Recognized entry points and how far they reach.',
|
|
106
|
+
run: (ctx) => V.entry(ctx.repo),
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: 'externals', aliases: ['drift'], group: 'analyze',
|
|
110
|
+
usage: 'externals', summary: 'External packages, plus dependency drift.',
|
|
111
|
+
run: (ctx) => V.externals(ctx.repo),
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: 'about', aliases: ['version'], group: 'session',
|
|
115
|
+
usage: 'about', summary: 'Version, repo path, and what is indexed.',
|
|
116
|
+
run: (ctx) => V.about(ctx.repo, ctx.version),
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'rescan', aliases: ['reload'], group: 'session',
|
|
120
|
+
usage: 'rescan', summary: 'Re-read the repo from disk.',
|
|
121
|
+
run: (ctx) => ctx.rescan(),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'cd', aliases: ['open-repo', 'use'], group: 'session',
|
|
125
|
+
usage: 'cd <folder>', summary: 'Load a different repository.',
|
|
126
|
+
run: (ctx, args) => ctx.loadRepo(args.join(' ')),
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'web', aliases: ['site', 'serve'], group: 'session',
|
|
130
|
+
usage: 'web', summary: 'Start the web UI and print its URL.',
|
|
131
|
+
run: (ctx) => ctx.web(),
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: 'clear', aliases: ['cls'], group: 'session',
|
|
135
|
+
usage: 'clear', summary: 'Clear the screen.',
|
|
136
|
+
run: () => CLEAR,
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: 'exit', aliases: ['quit', 'q'], group: 'session',
|
|
140
|
+
usage: 'exit', summary: 'Leave. Ctrl-D does the same.',
|
|
141
|
+
run: () => EXIT,
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
// One lookup for every spelling a person might type, built once at import.
|
|
146
|
+
const BY_NAME = new Map();
|
|
147
|
+
for (const cmd of COMMANDS) {
|
|
148
|
+
BY_NAME.set(cmd.name, cmd);
|
|
149
|
+
for (const a of cmd.aliases) BY_NAME.set(a, cmd);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function lookup(word) {
|
|
153
|
+
return BY_NAME.get(String(word || '').toLowerCase());
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Every canonical name, for "did you mean" matching. Aliases are deliberately
|
|
157
|
+
// excluded: suggesting `open` when someone typed `sho` is less useful than
|
|
158
|
+
// suggesting `show`, which is the word they were reaching for.
|
|
159
|
+
export function commandNames() {
|
|
160
|
+
return COMMANDS.map((c) => c.name);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// `help <command>` answers about one command; bare `help` lists them, grouped so
|
|
164
|
+
// the shape of the tool is visible rather than alphabetical.
|
|
165
|
+
export function helpText(ctx, topic = '') {
|
|
166
|
+
if (topic) {
|
|
167
|
+
const cmd = lookup(topic);
|
|
168
|
+
if (!cmd) return ` No command called "${topic}". Try \`help\`.`;
|
|
169
|
+
const also = cmd.aliases.length ? ` (also: ${cmd.aliases.join(', ')})` : '';
|
|
170
|
+
return [' ' + cmd.usage + also, ' ' + cmd.summary].join('\n');
|
|
171
|
+
}
|
|
172
|
+
const groups = new Map();
|
|
173
|
+
for (const cmd of COMMANDS) {
|
|
174
|
+
if (!groups.has(cmd.group)) groups.set(cmd.group, []);
|
|
175
|
+
groups.get(cmd.group).push(cmd);
|
|
176
|
+
}
|
|
177
|
+
const room = Math.max(...COMMANDS.map((c) => c.usage.length));
|
|
178
|
+
const out = [];
|
|
179
|
+
for (const [group, list] of groups) {
|
|
180
|
+
out.push(' ' + group.toUpperCase());
|
|
181
|
+
for (const c of list) out.push(' ' + c.usage.padEnd(room) + ' ' + c.summary);
|
|
182
|
+
}
|
|
183
|
+
out.push('');
|
|
184
|
+
out.push(' ' + (ctx?.repo ? ctx.repo.name : 'onboarder') + ' Β· Ctrl-D or `exit` to leave Β· Ctrl-C twice to quit');
|
|
185
|
+
return out.join('\n');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Split a typed line into words, honoring quotes so `find "exact phrase"` and
|
|
189
|
+
// `show "my file.js"` arrive as one argument each. Done once, here, so no
|
|
190
|
+
// command has to re-implement it.
|
|
191
|
+
export function tokenize(line) {
|
|
192
|
+
const out = [];
|
|
193
|
+
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
194
|
+
let m;
|
|
195
|
+
while ((m = re.exec(String(line || '')))) out.push(m[1] ?? m[2] ?? m[3]);
|
|
196
|
+
return out;
|
|
197
|
+
}
|