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 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
- # First run: a short setup wizard, then the server
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.985 INFO GET /api/health 200 (1ms)
246
- 16:09:46.460 INFO GET /nope 404 (1ms)
247
- 16:10:21.925 WARN config port=4310 reason="already in use"
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
- Set `ONBOARDER_LOG=json` for one JSON object per line instead (the log file is plain text by default so it stays readable; `--json` on `status`/`logs` is the machine-readable surface).
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 (565 tests)
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
+ }