codebase-onboarder 0.4.1 β 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 +53 -2
- 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/package.json +1 -1
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.
|
|
@@ -312,6 +356,13 @@ Cloudflare quick tunnels and Tailscale remain supported. They terminate TLS and
|
|
|
312
356
|
codebase-onboarder/
|
|
313
357
|
βββ bin/ # npm entry point (shebang trampoline) & postinstall note
|
|
314
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
|
|
315
366
|
βββ server/ # Zero-dependency Node.js HTTP server
|
|
316
367
|
β βββ index.js # createServer / startServer / startup banner
|
|
317
368
|
β βββ config.js # Settings schema, normalization, atomic 0600 writes
|
|
@@ -339,7 +390,7 @@ codebase-onboarder/
|
|
|
339
390
|
β βββ vendor/ # Vendored Mermaid & Monaco Editor (Offline)
|
|
340
391
|
β βββ index.html # Main application interface
|
|
341
392
|
β βββ login.html # Self-hosted access-key sign-in
|
|
342
|
-
βββ tests/ # Comprehensive node:test suite
|
|
393
|
+
βββ tests/ # Comprehensive node:test suite
|
|
343
394
|
```
|
|
344
395
|
|
|
345
396
|
---
|
|
@@ -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
|
+
}
|