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.
@@ -0,0 +1,549 @@
1
+ // The views: the website's screens, drawn as text.
2
+ //
3
+ // Every function here is pure — `(repo, args) => string` — and none of them
4
+ // print. That is what makes the terminal app testable: the tests assert on the
5
+ // strings, with no TTY, no readline, and no process to spawn. The app layer
6
+ // decides where they go.
7
+ //
8
+ // The drawing primitives are the same ones the CLI banner and the server's
9
+ // startup line already use (`server/layout.js` for geometry, `cli/ui.js` for
10
+ // color), so a panel here is fitted and elided to the terminal exactly the way
11
+ // every other panel in this project is, including after a resize.
12
+
13
+ import { bold, cyan, dim, ok, warn, bad, panel, row, fit, termWidth } from '../ui.js';
14
+ import { fileFacts, readRepoFile, resolveTarget, searchRepo, explainRepoFile, explainRepoFolder, explainRepoOverview } from './session.js';
15
+ import { scanCaveats } from '../../shared/analyzer/explainLocal.js';
16
+
17
+ const MAX = 200; // a ceiling on any list, so one command cannot flood a terminal
18
+
19
+ // Glyphs degrade for terminals that cannot show them. TUIKit's rule: a missing
20
+ // glyph is a rendering bug the user should never have to report, so the ASCII
21
+ // set is a first-class path, not a fallback we hope nobody needs.
22
+ const GLYPHS = process.platform === 'win32' && !process.env.WT_SESSION
23
+ ? { dir: '+', file: '-', entry: '>', hub: '*', arrow: '->', warn: '!', good: '+' }
24
+ : { dir: '▸', file: '·', entry: '▶', hub: '◆', arrow: '→', warn: '⚠', good: '✓' };
25
+
26
+ // The footer is a hint bar, and a hint bar that wraps is worse than no hint bar
27
+ // — it is the first thing printed, so it sets the impression of everything else.
28
+ // On a narrow terminal it drops hints rather than wrapping.
29
+ function hintBar(hints) {
30
+ const room = Math.max(8, termWidth() - 2);
31
+ const parts = [];
32
+ for (const h of hints) {
33
+ const next = parts.length ? parts.join(' · ') + ' · ' + h : h;
34
+ if (next.length > room) break;
35
+ parts.push(h);
36
+ }
37
+ const line = parts.join(' · ') || hints[0].slice(0, room);
38
+ return dim(' ' + line);
39
+ }
40
+
41
+ // Word-wrap plain text to the terminal, preserving the indent on continuation
42
+ // lines. Applied *before* painting, so the wrap never has to understand ANSI —
43
+ // the styled string is built from already-wrapped plain text.
44
+ //
45
+ // This is what keeps a long caveat or a paragraph of explanation from running
46
+ // off the right edge. The engine's own messages are written as sentences for a
47
+ // browser panel; a terminal needs them folded.
48
+ function wrapText(text, indent = ' ', room = termWidth() - indent.length) {
49
+ const out = [];
50
+ for (const para of String(text).split('\n')) {
51
+ if (!para.trim()) {
52
+ out.push('');
53
+ continue;
54
+ }
55
+ let line = '';
56
+ for (const word of para.split(/\s+/)) {
57
+ if (!line) {
58
+ line = word;
59
+ } else if (line.length + 1 + word.length <= room) {
60
+ line += ' ' + word;
61
+ } else {
62
+ out.push(indent + line);
63
+ line = word;
64
+ }
65
+ // A single word longer than the room is hard-split rather than allowed to
66
+ // overflow — a long import specifier is exactly the case that shows up.
67
+ while (line.length > room) {
68
+ out.push(indent + line.slice(0, room));
69
+ line = line.slice(room);
70
+ }
71
+ }
72
+ out.push(indent + line);
73
+ }
74
+ return out.join('\n');
75
+ }
76
+
77
+ // -------------------------------------------------------------- overview ---
78
+
79
+ // The landing view. Answers "what am I looking at, and where do I start" in one
80
+ // screen — the same three questions the site's first paint answers.
81
+ export function overview(repo) {
82
+ const s = repo.scan.stats;
83
+ const h = repo.health;
84
+ const langs = repo.languages.slice(0, 4).map((l) => `${l.label} ${l.loc}`).join(', ')
85
+ + (repo.languages.length > 4 ? `, +${repo.languages.length - 4} more` : '');
86
+
87
+ const rows = [
88
+ row('folder', repo.root),
89
+ row('files', `${s.filesParsed} parsed` + (s.skipped ? dim(` (${s.skipped} skipped)`) : '') + ` · ${s.edgeCount} imports`),
90
+ row('languages', langs || '—'),
91
+ ];
92
+ if (repo.manifest.packageName) rows.push(row('package', repo.manifest.packageName));
93
+ rows.push(row('license', repo.scan.license?.name || 'unknown'));
94
+ if (repo.facts.entries.length) rows.push(row('start at', repo.facts.entries.slice(0, 3).join(', ')));
95
+ if (repo.facts.hubs.length) {
96
+ const top = repo.facts.hubs[0];
97
+ rows.push(row('top hub', `${top.path} ${dim(`(${top.fanIn} files)`)}`));
98
+ }
99
+ rows.push(row('health', gradeColor(h.grade) + dim(' score ') + `${h.score}/100`));
100
+
101
+ const notes = scanCaveats(repo.scan);
102
+ const out = [panel(repo.name, rows)];
103
+ if (notes.length) out.push(dim(wrapText(notes.join('\n'))));
104
+ out.push(hintBar(['tour explains it', 'tree lists it', 'find searches it', 'help lists everything']));
105
+ return out.join('\n');
106
+ }
107
+
108
+ function gradeColor(grade) {
109
+ if (grade === 'A' || grade === 'B') return ok(grade);
110
+ if (grade === 'C') return warn(grade);
111
+ return bad(grade);
112
+ }
113
+
114
+ // ------------------------------------------------------------------ tree ---
115
+
116
+ // The file tree. Directories first, then files, each marked with the role that
117
+ // makes it worth noticing — an entry point or a hub is the two things a new
118
+ // reader is looking for, and marking them here saves a separate `hubs` command
119
+ // on every repo.
120
+ export function tree(repo, { sub = '', depth = 2, limit = MAX } = {}) {
121
+ const files = repo.scan.files;
122
+ const prefix = String(sub || '').replace(/^\/+|\/+$/g, '');
123
+ const scoped = prefix ? files.filter((f) => f.path.startsWith(prefix + '/')) : files;
124
+ if (!scoped.length) return dim(` Nothing under ${prefix || 'the root'}.`);
125
+
126
+ const root = { name: prefix || repo.name, path: prefix, dirs: new Map(), files: [] };
127
+ for (const f of scoped) {
128
+ const rel = prefix ? f.path.slice(prefix.length + 1) : f.path;
129
+ const parts = rel.split('/');
130
+ let node = root;
131
+ for (let i = 0; i < parts.length - 1; i++) {
132
+ const name = parts[i];
133
+ if (!node.dirs.has(name)) {
134
+ node.dirs.set(name, {
135
+ name,
136
+ path: node.path ? `${node.path}/${name}` : name,
137
+ dirs: new Map(),
138
+ files: [],
139
+ });
140
+ }
141
+ node = node.dirs.get(name);
142
+ }
143
+ node.files.push(f);
144
+ }
145
+
146
+ const lines = [];
147
+ let budget = Math.min(Number(limit) || MAX, MAX);
148
+ const walk = (node, depthLeft, indent) => {
149
+ for (const child of [...node.dirs.values()].sort((a, b) => a.name.localeCompare(b.name))) {
150
+ if (budget <= 0) return;
151
+ const room = Math.max(8, termWidth() - indent.length - 12);
152
+ lines.push(indent + dim(GLYPHS.dir + ' ') + cyan(fit(child.name, room)) + dim(` ${countUnder(child)}`));
153
+ budget--;
154
+ if (depthLeft > 1) walk(child, depthLeft - 1, indent + ' ');
155
+ }
156
+ for (const f of node.files) {
157
+ if (budget <= 0) return;
158
+ lines.push(indent + fileLine(repo, f));
159
+ budget--;
160
+ }
161
+ };
162
+ walk(root, Math.max(1, Number(depth) || 2), ' ');
163
+
164
+ const out = [bold(' tree · ' + (prefix || '.')) + dim(` ${scoped.length} files, depth ${Math.max(1, Number(depth) || 2)}`), lines.join('\n')];
165
+ if (budget <= 0) out.push(dim(' … list stopped early — tree <folder> <depth> to go deeper'));
166
+ return out.join('\n');
167
+ }
168
+
169
+ function fileLine(repo, f) {
170
+ const role = fileFacts(repo, f).role;
171
+ const badge = role === 'entry' ? ok(GLYPHS.entry) : role === 'hub' ? warn(GLYPHS.hub) : dim(GLYPHS.file);
172
+ const room = Math.max(10, termWidth() - 22);
173
+ return badge + ' ' + fit(f.name, room) + ' ' + dim(String(f.ext || '').padEnd(6)) + dim(String(f.loc || 0).padStart(5));
174
+ }
175
+
176
+ function countUnder(node) {
177
+ let n = node.files.length;
178
+ for (const d of node.dirs.values()) n += countUnder(d);
179
+ return n;
180
+ }
181
+
182
+ // ------------------------------------------------------------------ find ---
183
+
184
+ // Search, using the site's query language. The result rows carry the same three
185
+ // things the site's palette shows — path, line, snippet — fitted to the
186
+ // terminal, with the path keeping the room it needs because it is what
187
+ // identifies the hit.
188
+ export function find(repo, { query = '', limit = 12 } = {}) {
189
+ if (!String(query).trim()) {
190
+ return dim(' Try: find resolveImport · find ext:rs -test · find "exact phrase" · find /regex/');
191
+ }
192
+ const res = searchRepo(repo, query, { limit: Math.min(Number(limit) || 12, MAX) });
193
+ if (res.error) return bad(' ' + res.error);
194
+ if (!res.results.length) return dim(` No match for "${query}" in ${res.indexed} indexed files.`);
195
+
196
+ const w = termWidth();
197
+ const pathRoom = Math.max(16, Math.min(46, Math.floor(w * 0.42)));
198
+ const lines = res.results.map((r) => {
199
+ const snippetRoom = w - pathRoom - 18;
200
+ const snip = snippetRoom > 10 ? dim(' ' + fit(r.snippet, snippetRoom, { tail: false })) : '';
201
+ return ' ' + fit(r.path, pathRoom) + dim(String(r.line).padStart(5)) + ' ' + snip;
202
+ });
203
+ const kind = res.advanced ? 'filtered query' : 'query';
204
+ const head = dim(` ${res.total} match${res.total === 1 ? '' : 'es'} (${kind}) across ${res.indexed} indexed files`);
205
+ return [head, ...lines, hintBar(['show <path> to read one', 'deps <path> to trace it'])].join('\n');
206
+ }
207
+
208
+ // ------------------------------------------------------------------ show ---
209
+
210
+ // Read a file in the terminal. This is the one view that cannot be pure — it
211
+ // touches the disk — so it is the one async view, and it goes through the same
212
+ // containment check the HTTP route uses rather than reaching for a path itself.
213
+ export async function show(repo, { target = '', from = 0, count = 0 } = {}) {
214
+ const found = resolveTarget(repo, target);
215
+ if (found.error) return targetError(found);
216
+ if (found.folder) return dim(` ${found.folder.path} is a folder — tree ${found.folder.path} 2, or explain ${found.folder.path}`);
217
+
218
+ const file = found.file;
219
+ const text = await readRepoFile(repo, file.path);
220
+ const lines = text.split('\n');
221
+ const start = Math.max(1, Number(from) || 1);
222
+ const room = count > 0 ? Math.min(Number(count), 400) : Math.min(lines.length, 40);
223
+ const end = Math.min(lines.length, start + room - 1);
224
+
225
+ const gutter = String(end).length;
226
+ const body = lines.slice(start - 1, end).map((line, i) => {
227
+ const n = String(start + i).padStart(gutter);
228
+ return dim(n + ' ') + fit(line.replace(/\t/g, ' '), Math.max(20, termWidth() - gutter - 3), { tail: false });
229
+ });
230
+
231
+ const f = fileFacts(repo, file);
232
+ const head = panel(file.path, [
233
+ { label: 'role', value: f.role },
234
+ { label: 'size', value: `${f.loc} loc` + (f.complexity ? ` · complexity ${f.complexity}` : '') },
235
+ { label: 'graph', value: `${f.fanIn} in ${f.fanOut} out` + (f.inCycle ? ' ' + bad('in a cycle') : '') },
236
+ ]);
237
+ const shown = `${start}–${end} of ${lines.length}`;
238
+ return [head, dim(` ${shown}`), ...body, hintBar([`deps ${file.name} for connections`, `explain ${file.name} in prose`])].join('\n');
239
+ }
240
+
241
+ // Render a resolver miss the same way everywhere: the reason, then the
242
+ // candidates. A "did you mean" is only useful if it is actionable.
243
+ export function targetError(found) {
244
+ const lines = [bad(' ' + found.error)];
245
+ if (found.candidates) {
246
+ for (const c of found.candidates) lines.push(' ' + cyan(c));
247
+ lines.push(dim(' show <one of these>'));
248
+ }
249
+ return lines.join('\n');
250
+ }
251
+
252
+ // ------------------------------------------------------------------ deps ---
253
+
254
+ // What a file connects to, in both directions, plus the verdict the graph has
255
+ // on it. The two lists are the point: "what does this pull in" and "what breaks
256
+ // if this breaks" are different questions and a single edge count answers
257
+ // neither.
258
+ export function deps(repo, { target = '', limit = 15 } = {}) {
259
+ const found = resolveTarget(repo, target);
260
+ if (found.error) return targetError(found);
261
+ if (found.folder) return dim(` ${found.folder.path} is a folder — try a file inside it.`);
262
+
263
+ const p = found.file.path;
264
+ const imports = (repo.facts.importsOf[p] || []).slice(0, limit);
265
+ const importers = (repo.facts.importers[p] || []).slice(0, limit);
266
+ const f = fileFacts(repo, found.file);
267
+ const w = termWidth();
268
+
269
+ const head = [panel(found.file.path, [
270
+ { label: 'role', value: f.role },
271
+ { label: 'dependents', value: `${f.fanIn} file${f.fanIn === 1 ? '' : 's'} import this` },
272
+ { label: 'imports', value: `${f.fanOut} file${f.fanOut === 1 ? '' : 's'} pulled in` },
273
+ ])];
274
+ if (f.inCycle) head.push(bad(' ' + GLYPHS.warn + ' part of a circular import — refactoring here ripples'));
275
+
276
+ const list = (title, items) => {
277
+ if (!items.length) return dim(` ${title}: none`);
278
+ return [dim(` ${title}:`), ...items.map((x) => ' ' + cyan(fit(x, w - 6)))].join('\n');
279
+ };
280
+ return [...head, list('imports', imports), list('imported by', importers)].join('\n');
281
+ }
282
+
283
+ // --------------------------------------------------------------- explain ---
284
+
285
+ // The prose explanation, straight from `explainLocal.js` — the same notes the
286
+ // site shows when no AI key is configured, with no key required and nothing
287
+ // sent anywhere. Markdown backticks are the one bit of markup kept, turned into
288
+ // terminal emphasis.
289
+ export function explain(repo, { target = '' } = {}) {
290
+ if (!String(target).trim()) return renderProse(explainRepoOverview(repo));
291
+ const found = resolveTarget(repo, target);
292
+ if (found.error) return targetError(found);
293
+ if (found.folder) return renderProse(explainRepoFolder(repo, found.folder));
294
+ return renderProse(explainRepoFile(repo, found.file));
295
+ }
296
+
297
+ // `**bold**` and `` `code` `` are the only two marks `explainLocal` emits. They
298
+ // become terminal styling; everything else passes through untouched, so the
299
+ // prose still reads correctly with color off. Wrapping happens on the plain
300
+ // text first, then the marks are styled in place — styling after wrapping keeps
301
+ // the wrap from having to measure escape sequences.
302
+ function renderProse(md) {
303
+ return String(md)
304
+ .split('\n\n')
305
+ .map((para) => {
306
+ const styled = para
307
+ .replace(/`([^`]+)`/g, (_, c) => '\u0000' + c + '\u0000')
308
+ .replace(/\*\*([^*]+)\*\*/g, (_, c) => '\u0001' + c + '\u0001');
309
+ return wrapText(styled)
310
+ .replace(/\u0000([^\u0000]+)\u0000/g, (_, c) => cyan(c))
311
+ .replace(/\u0001([^\u0001]+)\u0001/g, (_, c) => bold(c));
312
+ })
313
+ .join('\n\n');
314
+ }
315
+
316
+ // ------------------------------------------------------------------ tour ---
317
+
318
+ // The guided read. The same eight stops the site and the MCP server hand out,
319
+ // in the same order, with the same one-line reason for each — a person at the
320
+ // prompt and an agent asking the MCP server are being told the same thing about
321
+ // the same repository.
322
+ export function tour(repo) {
323
+ if (!repo.tour.length) return dim(' No tour stops found — is this a code repository?');
324
+ const lines = repo.tour.map((stop, i) => {
325
+ const n = dim(String(i + 1).padStart(2) + '.');
326
+ const head = n + ' ' + cyan(fit(stop.path, Math.max(20, Math.floor(termWidth() * 0.45))));
327
+ return [head, dim(wrapText(stop.why, ' '))].join('\n');
328
+ });
329
+ return [bold(' reading order'), ...lines, hintBar(['show <path> to read one'])].join('\n');
330
+ }
331
+
332
+ // ---------------------------------------------------------------- health ---
333
+
334
+ // The health report. The grade leads because that is the question; the
335
+ // breakdown follows because a letter with no reasons is not actionable.
336
+ export function health(repo) {
337
+ const h = repo.health;
338
+ const rows = [
339
+ { label: 'grade', value: gradeColor(h.grade) + dim(` score ${h.score}/100`) },
340
+ { label: 'debt', value: `${h.totals.crit} critical · ${h.totals.high} high findings` },
341
+ { label: 'complexity', value: `avg ${h.totals.avgCx} per file` },
342
+ { label: 'orphans', value: `${Math.round(h.totals.orphanRatio * 100)}% of files import nothing` },
343
+ { label: 'cycles', value: String(h.totals.cycles) },
344
+ ];
345
+ if (h.effortHours) rows.push({ label: 'effort', value: `~${h.effortHours}h to address (${h.debtCategory})` });
346
+
347
+ const risky = [...(h.perFile || [])]
348
+ .filter((f) => (f.risk || 0) > 0)
349
+ .sort((a, b) => b.risk - a.risk)
350
+ .slice(0, 8);
351
+ const out = [panel('health', rows)];
352
+ if (risky.length) {
353
+ out.push(dim(' riskiest files:'));
354
+ for (const f of risky) {
355
+ // Risk is an integer 0–100; printing "58.00" would imply a precision the
356
+ // number does not have.
357
+ const risk = Number(f.risk);
358
+ out.push(' ' + warn(GLYPHS.warn + ' ') + cyan(fit(f.path, Math.max(20, termWidth() - 40)))
359
+ + dim(` risk ${risk}${f.blast !== undefined ? ` blast ${f.blast}` : ''}`));
360
+ }
361
+ }
362
+ if (h.breakdown?.length) {
363
+ // The breakdown is the score itemized — each entry is what a factor cost.
364
+ // Showing the label without the points would explain the grade without
365
+ // showing the arithmetic, which is the part that makes it actionable.
366
+ out.push(dim(' what moved the score:'));
367
+ for (const b of h.breakdown) {
368
+ const pts = bad(String(b.points));
369
+ const room = Math.max(12, termWidth() - 10);
370
+ out.push(' ' + dim('· ') + fit(b.label, room) + ' ' + pts);
371
+ }
372
+ }
373
+ return out.join('\n');
374
+ }
375
+
376
+ // ------------------------------------------------------------------ hubs ---
377
+
378
+ // The load-bearing files: the ones everything else reaches for. Ranked, because
379
+ // "which file is load-bearing" only has an answer as an ordering.
380
+ export function hubs(repo, { limit = 12 } = {}) {
381
+ const list = repo.facts.hubs.slice(0, Math.min(Number(limit) || 12, MAX));
382
+ if (!list.length) return dim(' No hubs — nothing here is imported by two or more files.');
383
+ const w = termWidth();
384
+ const top = list[0].fanIn || 1;
385
+ const barRoom = Math.max(0, Math.min(24, w - 46));
386
+ return [bold(' most depended-on files'), ...list.map((h) => {
387
+ const bar = barRoom > 4 ? dim(' ' + '█'.repeat(Math.max(1, Math.round((h.fanIn / top) * barRoom)))) : '';
388
+ return ' ' + cyan(fit(h.path, Math.max(20, w - 12 - barRoom))) + dim(` ${h.fanIn} in / ${h.fanOut} out`) + bar;
389
+ })].join('\n');
390
+ }
391
+
392
+ // ---------------------------------------------------------------- layers ---
393
+
394
+ // Import depth, shallowest first. This is the architecture as a staircase:
395
+ // layer 0 is what runs, and each step down is something it can reach.
396
+ export function layers(repo, { limit = 12 } = {}) {
397
+ const ls = repo.layers.layers;
398
+ if (!ls.length) return dim(' No layers — no import chain starts anywhere we recognize.');
399
+ const out = [bold(' import depth')];
400
+ ls.slice(0, Math.min(Number(limit) || 12, 40)).forEach((files, i) => {
401
+ const shown = files.slice(0, 3).join(', ');
402
+ const more = files.length > 3 ? dim(` +${files.length - 3} more`) : '';
403
+ const room = Math.max(12, termWidth() - String(i).length - 14 - (more ? String(more.length + 2) : 0));
404
+ out.push(dim(` layer ${i} (${files.length}) `) + cyan(fit(shown, room)) + more);
405
+ });
406
+ if (repo.layers.unreachable.length) {
407
+ out.push(dim(wrapText(`${repo.layers.unreachable.length} files are not reachable from any entry point`)));
408
+ }
409
+ return out.join('\n');
410
+ }
411
+
412
+ // -------------------------------------------------------------- patterns ---
413
+
414
+ // The architecture, in prose. These are the observations `detectPatterns`
415
+ // makes, in the order a senior dev would mention them.
416
+ export function patterns(repo) {
417
+ if (!repo.patterns.length) return dim(' No patterns stood out.');
418
+ const out = [bold(' what this codebase looks like')];
419
+ for (const p of repo.patterns) {
420
+ const mark = p.tone === 'good' ? ok(GLYPHS.good) : p.tone === 'warn' ? bad(GLYPHS.warn) : warn('·');
421
+ out.push(' ' + mark + ' ' + bold(p.title));
422
+ out.push(dim(wrapText(p.detail, ' ')));
423
+ if (p.paths?.length) {
424
+ out.push(dim(' ' + fit(p.paths.slice(0, 4).join(' '), Math.max(10, termWidth() - 4))));
425
+ }
426
+ }
427
+ return out.join('\n');
428
+ }
429
+
430
+ // ----------------------------------------------------------------- stats ---
431
+
432
+ // The numbers, grouped by the question each answers: what is it made of, where
433
+ // is the bulk of it, and what is the heaviest thing in it.
434
+ export function stats(repo, { limit = 10 } = {}) {
435
+ const n = Math.min(Number(limit) || 10, MAX);
436
+ const s = repo.scan.stats;
437
+ const top = [...repo.scan.files].sort((a, b) => (b.loc || 0) - (a.loc || 0)).slice(0, n);
438
+ const folders = [...repo.scan.folders].sort((a, b) => (b.loc || 0) - (a.loc || 0)).slice(0, n);
439
+ const cx = [...repo.scan.files].filter((f) => f.complexity > 0).sort((a, b) => b.complexity - a.complexity).slice(0, 5);
440
+
441
+ const totalLoc = repo.languages.reduce((s2, l) => s2 + l.loc, 0);
442
+ const out = [panel('stats', [
443
+ { label: 'files', value: `${s.filesParsed} parsed of ${s.filesTotal} seen` },
444
+ { label: 'lines', value: `${totalLoc} code` + (s.truncated ? warn(` (partial scan)`) : '') },
445
+ { label: 'imports', value: `${s.edgeCount} resolved` + (s.imports?.total ? dim(` ${s.imports.confidence}% placed`) : '') },
446
+ { label: 'tests', value: `${repo.facts.testCoverage.ratio}% of non-test files are covered by a test import` },
447
+ ])];
448
+
449
+ out.push(bold(' languages'));
450
+ for (const l of repo.languages.slice(0, 8)) {
451
+ out.push(' ' + cyan(l.label.padEnd(14)) + dim(String(l.loc).padStart(7)));
452
+ }
453
+ if (folders.length) {
454
+ out.push(bold(' biggest folders (loc)'));
455
+ for (const d of folders) out.push(' ' + cyan(fit(d.path, 34).padEnd(34)) + dim(String(d.loc).padStart(7)));
456
+ }
457
+ if (top.length) {
458
+ out.push(bold(' biggest files (loc)'));
459
+ for (const f of top) out.push(' ' + cyan(fit(f.path, 34).padEnd(34)) + dim(String(f.loc).padStart(7)));
460
+ }
461
+ if (cx.length) {
462
+ out.push(bold(' most complex'));
463
+ for (const f of cx) out.push(' ' + cyan(fit(f.path, 34).padEnd(34)) + dim(String(f.complexity).padStart(7)));
464
+ }
465
+ return out.join('\n');
466
+ }
467
+
468
+ // -------------------------------------------------------------- security ---
469
+
470
+ export function security(repo, { limit = 12 } = {}) {
471
+ const sec = repo.security;
472
+ if (!sec.total) return ok(' No findings from the built-in rules.');
473
+ const order = ['critical', 'high', 'medium', 'low', 'info'];
474
+ const counts = order.filter((k) => sec.counts[k]).map((k) => `${sec.counts[k]} ${k}`).join(' · ');
475
+ const out = [panel('security', [
476
+ { label: 'grade', value: gradeColor(sec.grade) + dim(` score ${sec.score}/100`) },
477
+ { label: 'findings', value: counts },
478
+ ])];
479
+ for (const f of sec.files.slice(0, Math.min(Number(limit) || 12, MAX))) {
480
+ out.push(' ' + (f.worst === 'critical' || f.worst === 'high' ? bad : warn)(GLYPHS.warn + ' ')
481
+ + cyan(fit(f.path, Math.max(20, termWidth() - 34))) + dim(` ${f.count} · worst ${f.worst}`));
482
+ }
483
+ out.push(dim(wrapText('Pattern-based heuristics only — not a substitute for a real audit.')));
484
+ return out.join('\n');
485
+ }
486
+
487
+ // ----------------------------------------------------------------- stack ---
488
+
489
+ // The dependency picture the site shows, from the same `analyzeStack`.
490
+ export function stack(repo, { limit = 20 } = {}) {
491
+ const st = repo.stack;
492
+ if (!st.items.length) return dim(wrapText('No recognized dependencies (no package manifest found?).'));
493
+ const out = [bold(' stack') + dim(` ${st.pm.join(', ') || 'no package manager'}`)];
494
+ for (const it of st.items.slice(0, Math.min(Number(limit) || 20, MAX))) {
495
+ const v = it.version ? dim(' ' + it.version) : '';
496
+ out.push(' ' + cyan(it.name.padEnd(28)) + dim(String(it.category).padEnd(12)) + v + (it.dev ? dim(' dev') : ''));
497
+ }
498
+ return out.join('\n');
499
+ }
500
+
501
+ // ----------------------------------------------------------------- entry ---
502
+
503
+ export function entry(repo) {
504
+ if (!repo.facts.entries.length) {
505
+ return dim(' No entry points recognized. tour falls back to the most depended-on files.');
506
+ }
507
+ return [bold(' entry points'), ...repo.facts.entries.map((p) => {
508
+ const out = repo.facts.fanOut[p] || 0;
509
+ return ' ' + ok(GLYPHS.entry + ' ') + cyan(fit(p, Math.max(20, termWidth() - 24))) + dim(` reaches ${out} files`);
510
+ })].join('\n');
511
+ }
512
+
513
+ // ------------------------------------------------------------- externals ---
514
+
515
+ // Outside packages, and who pulls them in. Dependency drift is the useful part:
516
+ // declared-but-unused and used-but-undeclared are the two lists a dependency
517
+ // audit actually acts on.
518
+ export function externals(repo) {
519
+ const ext = repo.scan.externals || [];
520
+ if (!ext.length) return dim(' No external imports found.');
521
+ const sorted = [...ext].sort((a, b) => (b.usedBy?.length || 0) - (a.usedBy?.length || 0));
522
+ const out = [bold(' external packages')];
523
+ for (const x of sorted.slice(0, 30)) {
524
+ out.push(' ' + cyan(String(x.name).padEnd(28)) + dim(`${x.usedBy?.length || 0} files`));
525
+ }
526
+ const drift = repo.facts.depsDrift;
527
+ if (drift?.undeclaredImported?.length) {
528
+ out.push(bold(' imported but not declared'));
529
+ out.push(wrapText(drift.undeclaredImported.join(', '), ' ').split('\n').map(warn).join('\n'));
530
+ }
531
+ if (drift?.unusedDeclared?.length) {
532
+ out.push(bold(' declared but never imported'));
533
+ out.push(dim(wrapText(drift.unusedDeclared.join(', '), ' ')));
534
+ }
535
+ return out.join('\n');
536
+ }
537
+
538
+ // ----------------------------------------------------------------- about ---
539
+
540
+ // Which binary is answering. When the terminal and the website disagree, this
541
+ // is the first thing to ask.
542
+ export function about(repo, version = '') {
543
+ return panel('onboarder', [
544
+ { label: 'version', value: version },
545
+ { label: 'repo', value: repo.root },
546
+ { label: 'indexed', value: `${repo.searchIndex?.totalDocs ?? 0} files for find` },
547
+ { label: 'scanned', value: repo.scan.scannedAt },
548
+ ]);
549
+ }
package/cli/main.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  runSetup, runStart, runStartBackground, runStartup, runLogs, runStatus, runStop, runRestart,
12
12
  runConfig, runConfigKey, runConfigReset, runDoctor, runTunnel, runHttps,
13
13
  } from './commands.js';
14
+ import { runExplore } from './explorer/app.js';
14
15
 
15
16
  const PACKAGE = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
16
17
 
@@ -18,20 +19,29 @@ const HELP = `
18
19
  🧭 Onboarder — drop a path, get a map.
19
20
 
20
21
  Usage
21
- onboarder Start the server (runs setup first if needed)
22
- onboarder setup | onboard Configure interactively (the wizard)
23
- onboarder start Start in the foreground (Ctrl-C stops it)
22
+ onboarder Explore a codebase here, interactively
23
+ onboarder explore [folder] The same, pointed somewhere else (alias: tui)
24
+ onboarder start Start the web UI in the foreground (Ctrl-C stops it)
24
25
  onboarder start background Start detached — keeps running after you close the terminal
25
26
  onboarder start startup Run automatically at login [install|remove|status]
27
+ onboarder web Alias for \`onboarder start\`
26
28
  onboarder logs Show recent log lines (-n <count>, -f to follow)
27
- onboarder status Show whether the server is running
28
- onboarder stop Stop the running server
29
+ onboarder status Show whether the web UI is running
30
+ onboarder stop Stop the running web UI
29
31
  onboarder restart Stop and start again
30
32
  onboarder config [<…>] show | get <key> | set <key> <value> | path | reset | key <rotate|show|set>
31
33
  onboarder tunnel <name> cloudflare | tailscale
32
34
  onboarder https <action> check | setup | start | stop | status
33
35
  onboarder doctor Check the machine and the config
34
36
 
37
+ In the explorer
38
+ map tour explain what this is, where to start, and why
39
+ tree find show deps browse it, search it, read it, trace it
40
+ health hubs layers patterns the analysis, in the same words the site uses
41
+ stats security stack entry numbers, findings, dependencies
42
+ cd rescan web switch repo, reload, open the web UI
43
+ help exit everything, and the way out
44
+
35
45
  Setup flags (interactive wizard skips what they answer)
36
46
  --mode local|self-hosted --host <addr> --port <n> --domain <name>
37
47
  --https Set up automatic HTTPS through Caddy
@@ -41,6 +51,7 @@ const HELP = `
41
51
 
42
52
  General flags
43
53
  --config <file> Use this config file (or ONBOARDER_CONFIG)
54
+ --server With bare \`onboarder\`, start the web UI instead of exploring
44
55
  --non-interactive Never prompt; flags + defaults are the answers
45
56
  -y, --yes Answer yes to confirmations
46
57
  --json Machine-readable output
@@ -55,11 +66,11 @@ const HELP = `
55
66
  fg | foreground alias for start
56
67
 
57
68
  Examples
58
- onboarder setup
59
- onboarder start background # leave it running, close the terminal
69
+ onboarder # explore the repo you are standing in
70
+ onboarder explore ~/code/my-app # explore somewhere else
71
+ onboarder start background # leave the web UI running, close the terminal
60
72
  onboarder logs -f # watch what it is doing
61
73
  onboarder start startup install # also start it every time you log in
62
- onboarder start background && open http://localhost:4310
63
74
  onboarder setup --non-interactive --mode local --port 4310
64
75
  onboarder setup --non-interactive --mode self-hosted --domain map.example.com --https --start
65
76
  onboarder config set tunnel.cloudflare true && onboarder tunnel cloudflare
@@ -93,6 +104,7 @@ const OPTIONS = {
93
104
  lines: { type: 'string', short: 'n' },
94
105
  follow: { type: 'boolean', short: 'f' },
95
106
  timeout: { type: 'string' },
107
+ server: { type: 'boolean' },
96
108
  };
97
109
 
98
110
  // parseArgs speaks kebab-case; the wizard's flags speak camelCase.
@@ -126,6 +138,22 @@ export async function main(argv = process.argv.slice(2)) {
126
138
  console.log(HELP);
127
139
  return 0;
128
140
  case undefined:
141
+ // The entry point. On a terminal, bare `onboarder` explores the repo you
142
+ // are standing in — the thing people actually want from this tool, and
143
+ // what the name promises. Everywhere else (a pipe, CI, a script) it
144
+ // still starts the server, because an interactive session with no input
145
+ // source is a hang, and nothing about a background job wants a prompt.
146
+ // `--server` forces the old behavior even on a terminal.
147
+ if (!flags.server && process.stdin.isTTY && process.stdout.isTTY) {
148
+ return codeOf(await runExplore({ flags, target: '.' }));
149
+ }
150
+ return codeOf(await runStart({ flags }));
151
+ case 'explore':
152
+ case 'tui':
153
+ case 'shell':
154
+ return codeOf(await runExplore({ flags, target: sub || '.' }));
155
+ case 'web':
156
+ case 'site':
129
157
  return codeOf(await runStart({ flags }));
130
158
  case 'start':
131
159
  // `onboarder start [background|fg|startup [action]]`. The sub-verb is a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebase-onboarder",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Drop a path. Get a map. A zero-dependency codebase visualizer with a CLI onboarding wizard, a web UI, and an optional key-gated self-hosted mode.",
5
5
  "type": "module",
6
6
  "bin": {