codebase-onboarder 0.5.0 → 0.6.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/cli/main.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  runConfig, runConfigKey, runConfigReset, runDoctor, runTunnel, runHttps,
13
13
  } from './commands.js';
14
14
  import { runExplore } from './explorer/app.js';
15
+ import { setColorEnabled } from './ui.js';
15
16
 
16
17
  const PACKAGE = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
17
18
 
@@ -20,7 +21,7 @@ const HELP = `
20
21
 
21
22
  Usage
22
23
  onboarder Explore a codebase here, interactively
23
- onboarder explore [folder] The same, pointed somewhere else (alias: tui)
24
+ onboarder explore [folder|url] The same, pointed somewhere else (alias: tui)
24
25
  onboarder start Start the web UI in the foreground (Ctrl-C stops it)
25
26
  onboarder start background Start detached — keeps running after you close the terminal
26
27
  onboarder start startup Run automatically at login [install|remove|status]
@@ -37,10 +38,16 @@ const HELP = `
37
38
  In the explorer
38
39
  map tour explain what this is, where to start, and why
39
40
  tree find show deps browse it, search it, read it, trace it
41
+ graph blast symbols the dependency tree, the blast radius, the outline
40
42
  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
+ coupling clusters risks the heat grid, the module groups, what is wrong
44
+ log hotspots blame git history, hot files, who wrote a line
45
+ diagram docs Mermaid source, and prose you can write out
46
+ cd rescan web switch repo (or clone a URL), reload, open the web UI
47
+ github what GitHub says about this repo — stars, issues, license
48
+ !<command> run a shell command without leaving
43
49
  help exit everything, and the way out
50
+ Tab completes commands, then file paths
44
51
 
45
52
  Setup flags (interactive wizard skips what they answer)
46
53
  --mode local|self-hosted --host <addr> --port <n> --domain <name>
@@ -68,6 +75,7 @@ const HELP = `
68
75
  Examples
69
76
  onboarder # explore the repo you are standing in
70
77
  onboarder explore ~/code/my-app # explore somewhere else
78
+ onboarder explore https://github.com/expressjs/express # clone one and read it
71
79
  onboarder start background # leave the web UI running, close the terminal
72
80
  onboarder logs -f # watch what it is doing
73
81
  onboarder start startup install # also start it every time you log in
@@ -85,7 +93,7 @@ const OPTIONS = {
85
93
  reveal: { type: 'boolean' },
86
94
  verbose: { type: 'boolean' },
87
95
  start: { type: 'boolean' },
88
- color: { type: 'boolean' }, // --no-color falls out of parseArgs for free
96
+ color: { type: 'boolean' }, // `--no-color` is lifted out by extractNegatedFlags; --color forces it on
89
97
  config: { type: 'string' },
90
98
  mode: { type: 'string' },
91
99
  host: { type: 'string' },
@@ -117,10 +125,32 @@ function normalizeFlags(values) {
117
125
  return out;
118
126
  }
119
127
 
128
+ // `parseArgs` cannot express a negated boolean. `--color=false` is rejected
129
+ // outright, `--color false` sets `color: true` and leaves `false` behind as a
130
+ // positional, and `--no-color` is an unknown option — so the documented flag
131
+ // had never worked, for any command, and `--no-color status` would try to run a
132
+ // command called `no-color`.
133
+ //
134
+ // There is no parser-level spelling for "this boolean is false", so the flag is
135
+ // lifted out of argv before parsing and applied to the parsed flags after. That
136
+ // keeps `parseArgs` strict about genuinely unknown options (which is the point
137
+ // of a tool that writes a config file) while making the one negation the CLI
138
+ // documents actually work.
139
+ function extractNegatedFlags(argv) {
140
+ const rest = [];
141
+ let noColor = false;
142
+ for (const arg of argv) {
143
+ if (arg === '--no-color') noColor = true;
144
+ else rest.push(arg);
145
+ }
146
+ return { argv: rest, noColor };
147
+ }
148
+
120
149
  export async function main(argv = process.argv.slice(2)) {
150
+ const { argv: argsIn, noColor } = extractNegatedFlags(argv);
121
151
  let parsed;
122
152
  try {
123
- parsed = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true });
153
+ parsed = parseArgs({ args: argsIn, options: OPTIONS, allowPositionals: true });
124
154
  } catch (e) {
125
155
  console.error(' ' + e.message + '\n' + HELP);
126
156
  return 2;
@@ -128,6 +158,20 @@ export async function main(argv = process.argv.slice(2)) {
128
158
  const { values, positionals } = parsed;
129
159
  const flags = normalizeFlags(values);
130
160
 
161
+ // Color is decided here, after the flags exist and before anything is
162
+ // painted. An explicit `--no-color` wins over `FORCE_COLOR`: a person who
163
+ // typed the flag meant it, and a stray `FORCE_COLOR` inherited from a CI
164
+ // profile should not override the thing they just asked for.
165
+ if (noColor) {
166
+ flags.color = false;
167
+ setColorEnabled(false);
168
+ } else if (flags.color === true) {
169
+ // `--color` was accepted by the parser and then ignored, which is the same
170
+ // lie in the other direction. It is the documented way to keep ANSI in a
171
+ // captured log, so it has to actually turn color on.
172
+ setColorEnabled(true);
173
+ }
174
+
131
175
  if (flags.help) { console.log(HELP); return 0; }
132
176
  if (flags.version) { console.log(PACKAGE.version); return 0; }
133
177
 
package/cli/ui.js CHANGED
@@ -4,7 +4,36 @@
4
4
  // all strip styling in one place. Nothing here is load-bearing — every message
5
5
  // has to read fine as plain text, because that is how logs and CI see it.
6
6
 
7
- export const supportsColor = process.stdout.isTTY && !process.env.NO_COLOR;
7
+ // Color resolution, in strict precedence order:
8
+ //
9
+ // 1. An explicit `--no-color` / `NO_COLOR` → off. A person who asked for plain
10
+ // text gets plain text, whatever the environment says.
11
+ // 2. An explicit `--color` / `FORCE_COLOR` → on. This is the documented way
12
+ // to keep ANSI in a captured log or a pipe, so it has to beat the TTY sniff.
13
+ // 3. Otherwise, a TTY → on.
14
+ // 4. Otherwise → off.
15
+ //
16
+ // Every step reads live rather than caching at import, because `main` parses
17
+ // flags *after* the module graph has loaded. The old `const supportsColor = ...`
18
+ // decided once, at import, which is before `--no-color` was knowable — so the
19
+ // documented flags were silently ignored everywhere.
20
+ let forced = null; // null = undecided, true = force on, false = force off
21
+
22
+ export function setColorEnabled(on) {
23
+ forced = on === undefined ? null : Boolean(on);
24
+ }
25
+
26
+ export function colorEnabled() {
27
+ if (forced === false) return false;
28
+ if (forced === true) return true;
29
+ if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false;
30
+ if (process.env.FORCE_COLOR) return true;
31
+ return Boolean(process.stdout.isTTY);
32
+ }
33
+
34
+ export function supportsColor() {
35
+ return colorEnabled();
36
+ }
8
37
 
9
38
  const CODES = {
10
39
  reset: 0, bold: 1, dim: 2, italic: 3,
@@ -12,7 +41,7 @@ const CODES = {
12
41
  };
13
42
 
14
43
  export function paint(text, ...styles) {
15
- if (!supportsColor || !styles.length) return String(text);
44
+ if (!colorEnabled() || !styles.length) return String(text);
16
45
  const open = styles.map((s) => `\x1b[${CODES[s]}m`).join('');
17
46
  return `${open}${text}\x1b[0m`;
18
47
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebase-onboarder",
3
- "version": "0.5.0",
3
+ "version": "0.6.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": {
@@ -239,11 +239,18 @@ export function lineageCoverage(scan) {
239
239
  return out.join('\n');
240
240
  }
241
241
 
242
- // Both URL shapes git accepts: https://github.com/owner/repo(.git) and
243
- // git@github.com:owner/repo(.git). Anything else is not GitHub and gets no
244
- // remote lookup.
242
+ // The GitHub URL parsing and the reading of the API payload also exist in
243
+ // `/shared/analyzer/github.js`, where the terminal reads them. This copy is not
244
+ // an oversight and cannot simply be deleted: this module is loaded by Node tests
245
+ // (`tests/about.test.js`), and a `/shared/…` specifier does not resolve in Node,
246
+ // so importing the shared version here would make this whole file unloadable
247
+ // outside a browser. The duplication is therefore pinned by a test —
248
+ // "the two GitHub URL parsers agree" — rather than left to drift, which is what
249
+ // two unconnected copies of this regex had already done: the browser counted
250
+ // `watchers_count` (a legacy alias for the *stargazer* count) and called it
251
+ // "watching", printing the star number twice.
245
252
  export function githubRepoPath(url) {
246
- const m = String(url).match(/github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/);
253
+ const m = String(url ?? '').match(/github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/);
247
254
  return m ? m[1] : null;
248
255
  }
249
256
 
@@ -262,21 +269,27 @@ async function loadRemoteInfo(repoPath) {
262
269
  : `GitHub answered ${res.status}.`);
263
270
  }
264
271
  const r = await res.json();
272
+ // `subscribers_count`, not `watchers_count`. The API carries both and
273
+ // `watchers_count` is a legacy alias for the *stargazer* count, so reading
274
+ // it printed the star number a second time and called it "watching". The
275
+ // real watcher count is the subscribers number.
276
+ const watching = Number.isFinite(r.subscribers_count) ? r.subscribers_count : 0;
277
+ const license = r.license?.spdx_id && r.license.spdx_id !== 'NOASSERTION' ? r.license.spdx_id : '';
265
278
  box.innerHTML = `
266
279
  <div class="about-numbers">
267
280
  ${aboutNum(r.stargazers_count ?? 0, 'stars')}
268
281
  ${aboutNum(r.forks_count ?? 0, 'forks')}
269
- ${aboutNum(r.watchers_count ?? 0, 'watching')}
282
+ ${aboutNum(watching, 'watching')}
270
283
  ${aboutNum(r.open_issues_count ?? 0, 'open issues')}
271
284
  </div>
272
285
  ${r.description ? `<p class="about-desc">${escapeHtml(r.description)}</p>` : ''}
273
286
  <ul class="about-lineage">
274
- ${r.license?.spdx_id ? `<li>License: ${escapeHtml(r.license.spdx_id)}</li>` : ''}
287
+ ${license ? `<li>License: ${escapeHtml(license)}</li>` : ''}
275
288
  <li>Created ${aboutDate(r.created_at)} · last push ${aboutDate(r.pushed_at)}.</li>
276
289
  ${r.default_branch ? `<li>Default branch: <code>${escapeHtml(r.default_branch)}</code></li>` : ''}
277
290
  ${r.topics?.length ? `<li>Topics: ${r.topics.map((t) => `<span class="stat-chip">${escapeHtml(t)}</span>`).join(' ')}</li>` : ''}
278
291
  ${r.homepage ? `<li><a href="${escapeHtml(r.homepage)}" target="_blank" rel="noopener noreferrer">${escapeHtml(r.homepage)}</a></li>` : ''}
279
- <li class="doc-readme-none">GitHub keeps view counts private to the repo's owner — “watching” is the closest public number.</li>
292
+ <li class="doc-readme-none">These are GitHub's numbers, not this repository's — <code>health</code> and <code>risks</code> are about the code.</li>
280
293
  </ul>`;
281
294
  } catch (err) {
282
295
  box.innerHTML = `<span class="doc-readme-none">${escapeHtml(err.message)}</span>`;
@@ -0,0 +1,61 @@
1
+ // The URL a local checkout came from — `git remote get-url origin`.
2
+ //
3
+ // The web app never needs this: it only shows remote facts for a repo it cloned
4
+ // itself, where it already knows the URL it was given. A terminal session is
5
+ // usually started *inside* somebody's checkout, so `github` there has no URL to
6
+ // work from and has to ask git. That is the one place this surface is a strict
7
+ // improvement on the site rather than a copy of it.
8
+ //
9
+ // Same discipline as `gitHistory.js`: git is spawned with an argument array,
10
+ // never through a shell, and a repo with no remote is an expected outcome rather
11
+ // than an error — a plain folder, a tarball, and a checkout with the remote
12
+ // removed are all normal.
13
+
14
+ import { spawn } from 'node:child_process';
15
+
16
+ const TIMEOUT_MS = 10_000;
17
+
18
+ /**
19
+ * The `origin` remote of a checkout, or null with a reason.
20
+ *
21
+ * `origin` specifically, not "the first remote": origin is the one that means
22
+ * "where this came from". A fork's `upstream` is a different repository, and
23
+ * reporting that repo's stars for a fork is a confident wrong answer.
24
+ */
25
+ export function gitRemoteOrigin(root) {
26
+ return new Promise((resolve) => {
27
+ let child;
28
+ try {
29
+ child = spawn('git', ['-C', root, 'remote', 'get-url', 'origin'], {
30
+ stdio: ['ignore', 'pipe', 'pipe'],
31
+ });
32
+ } catch (err) {
33
+ resolve({ ok: false, reason: 'Could not run git: ' + err.message });
34
+ return;
35
+ }
36
+
37
+ let out = '';
38
+ let stderr = '';
39
+ const cap = (buf, max) => (buf.length > max ? buf.slice(-max) : buf);
40
+ child.stdout.on('data', (c) => { out = cap(out + c, 2000); });
41
+ child.stderr.on('data', (c) => { stderr = cap(stderr + c, 2000); });
42
+
43
+ const timer = setTimeout(() => {
44
+ child.kill('SIGKILL');
45
+ resolve({ ok: false, reason: 'git took too long to say where this came from.' });
46
+ }, TIMEOUT_MS);
47
+
48
+ // `once`, and the promise is only ever settled once, so a timeout that
49
+ // races a late `close` cannot resolve an already-resolved promise.
50
+ const settle = (value) => { clearTimeout(timer); resolve(value); };
51
+
52
+ child.on('error', (err) => settle({ ok: false, reason: 'Could not run git: ' + err.message }));
53
+ child.on('close', (code) => {
54
+ const url = out.trim();
55
+ if (code === 0 && url) settle({ ok: true, url });
56
+ else if (/not a git repository/i.test(stderr)) settle({ ok: false, reason: 'This folder is not a git checkout, so it has no remote.' });
57
+ else if (code === 0) settle({ ok: false, reason: 'This checkout has no origin remote.' });
58
+ else settle({ ok: false, reason: 'git would not say: ' + (stderr.trim().split('\n').pop() || ('exited ' + code)) });
59
+ });
60
+ });
61
+ }
package/server/layout.js CHANGED
@@ -44,26 +44,53 @@ export function fit(text, max, { tail = true } = {}) {
44
44
  // 'frame'. The default is identity, which is what the server banner and the
45
45
  // `--no-color` path want.
46
46
  export function panel(title, rows, { indent = ' ', columns, paint = (t) => String(t) } = {}) {
47
- // The hard ceiling is the terminal. A floor below it would be a lie: on a 40
48
- // column terminal a 59-wide panel is exactly the overflow this exists to
49
- // prevent, so the floor only guards against a nonsensical zero, and short
50
- // titles/values are handled by the `fit` calls below rather than by a minimum.
51
- const available = Math.max(24, (columns || termWidth()) - indent.length);
52
- const labelWidth = Math.min(12, Math.max(...rows.map((r) => width(r.label ?? '')), 0));
53
- // indent(2) + gap(2) + label + gap(2) + value + right border(2)
54
- const valueRoom = Math.max(8, available - 2 - labelWidth - 2 - 2);
47
+ // The hard ceiling is the terminal, and it is the *only* ceiling. This used to
48
+ // clamp to a 24-column floor "to guard against a nonsensical zero", but a
49
+ // floor above the available width is precisely the overflow this function
50
+ // exists to prevent: in a 10-column terminal it produced 26-column lines and
51
+ // wrapped the border. A terminal can be narrower than a box; below the point
52
+ // where a bordered box is readable at all, the frame degrades to a plain
53
+ // aligned list, which is still honest output instead of a lie about width.
54
+ const total = Math.max(1, Math.floor(columns || termWidth()) - indent.length);
55
+
56
+ // A border costs 2 cells on each side; the label column costs `labelWidth`,
57
+ // then a 2-cell gap, then a content indent. Below what is left there is no
58
+ // room for a readable value, so the border is dropped entirely rather than
59
+ // drawn wider than the terminal. The content indent goes too: at a 3-column
60
+ // terminal, indent(2) + content(2) + one character is already 5, and the
61
+ // indent is decoration, not information.
62
+ const borderWorthIt = total >= 16;
63
+ const bodyIndent = total >= 8 ? 2 : 0;
64
+ const labelWidth = borderWorthIt ? Math.min(12, Math.max(...rows.map((r) => width(r.label ?? '')), 0)) : 0;
65
+ const gap = labelWidth ? 2 : 0;
66
+ const valueRoom = Math.max(1, total - (borderWorthIt ? 2 : 0) - labelWidth - gap - bodyIndent);
67
+
55
68
  const body = rows.map((r) => (r.hint
56
- ? ' ' + ' '.repeat(labelWidth + 2) + paint(fit(r.hint, valueRoom), 'hint')
57
- : ' ' + paint(fit(r.label ?? '', labelWidth).padEnd(labelWidth), 'label') + ' ' + fit(r.value ?? '', valueRoom)));
69
+ ? ' '.repeat(labelWidth + gap) + paint(fit(r.hint, valueRoom), 'hint')
70
+ : (labelWidth ? paint(fit(r.label ?? '', labelWidth).padEnd(labelWidth), 'label') + ' '.repeat(gap) : '')
71
+ + fit(r.value ?? '', valueRoom)));
72
+
73
+ if (!borderWorthIt) {
74
+ // No room for a frame. Still fitted, still labeled, just not boxed. The
75
+ // indent goes first — it is decoration, and at a 1–2 column terminal the
76
+ // 2-space indent plus a single content character is already 3, which wraps.
77
+ const lead = ' '.repeat(Math.max(0, Math.min(indent.length, total - 1)));
78
+ return [
79
+ paint(fit(title, total), 'title'),
80
+ ...body.map((line) => lead + ' '.repeat(bodyIndent) + fit(line, Math.max(1, total - lead.length - bodyIndent))),
81
+ ].join('\n');
82
+ }
58
83
 
59
84
  // Frame width = whatever the body needs, capped to what the terminal has.
60
- const wanted = Math.max(...body.map(width), 12);
61
- const inner = Math.max(8, Math.min(available - 2, wanted));
62
- const shownTitle = fit(title, Math.max(2, inner - 4));
85
+ // The 2-cell content indent belongs to the body, so it counts toward the
86
+ // width the border has to enclose.
87
+ const wanted = Math.max(...body.map((line) => width(' ' + line)), 1);
88
+ const inner = Math.min(total - 2, Math.max(1, wanted));
89
+ const shownTitle = fit(title, Math.max(0, inner - 4));
63
90
  const top = paint('┌─ ', 'frame') + paint(shownTitle, 'title')
64
91
  + ' ' + paint('─'.repeat(Math.max(0, inner - shownTitle.length - 3)) + '┐', 'frame');
65
92
  const bottom = paint('└' + '─'.repeat(inner) + '┘', 'frame');
66
- return [top, ...body, bottom].map((line) => indent + line).join('\n');
93
+ return [indent + top, ...body.map((line) => indent + ' ' + line), indent + bottom].join('\n');
67
94
  }
68
95
 
69
96
  // A one-line note rendered in the panel's muted voice.
@@ -0,0 +1,81 @@
1
+ // Reading a GitHub repository's public facts — the part the browser and the
2
+ // terminal both need, and the part that was quietly wrong in one of them.
3
+ //
4
+ // This module is pure: it takes an already-parsed API payload and returns plain
5
+ // data. The `fetch` deliberately stays on either side of it, because the two
6
+ // callers can do things the other cannot — the browser must not hold a token,
7
+ // and the terminal can. `shared/` may not know which runtime it is in, so the
8
+ // network call is not here; only the reading of the answer is.
9
+ //
10
+ // One implementation, because the previous arrangement had the browser with its
11
+ // own copy of the URL regex and its own idea of which counter means "watching",
12
+ // and the two drifted apart without anything noticing.
13
+
14
+ /**
15
+ * The `owner/repo` a git URL points at, or null if it is not a GitHub URL.
16
+ *
17
+ * Both URL shapes git accepts: `https://github.com/owner/repo(.git)` and
18
+ * `git@github.com:owner/repo(.git)`. Anything else is not GitHub, and gets no
19
+ * remote lookup — a GitLab or Bitbucket clone is a real outcome, not an error.
20
+ */
21
+ export function githubRepoPath(url) {
22
+ const m = String(url ?? '').match(/github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/);
23
+ return m ? m[1] : null;
24
+ }
25
+
26
+ /**
27
+ * The public facts worth showing, read out of `GET /repos/{owner}/{repo}`.
28
+ *
29
+ * Returns null for anything that is not an object, so a caller can tell "no
30
+ * facts" from "all zeroes" without a try/catch around every field.
31
+ */
32
+ export function remoteFacts(json) {
33
+ if (!json || typeof json !== 'object') return null;
34
+ const count = (v) => (Number.isFinite(v) && v >= 0 ? Math.trunc(v) : 0);
35
+
36
+ return {
37
+ stars: count(json.stargazers_count),
38
+ forks: count(json.forks_count),
39
+ // `subscribers_count`, not `watchers_count`. The API has carried both for
40
+ // years and `watchers_count` is a legacy alias for the *stargazer* count —
41
+ // so labelling it "watching" printed the star number twice and called one
42
+ // of them watchers. The real watcher count is the subscribers number.
43
+ watching: count(json.subscribers_count),
44
+ issues: count(json.open_issues_count),
45
+ description: typeof json.description === 'string' ? json.description.trim() : '',
46
+ // "NOASSERTION" is GitHub's way of saying it found a license file it could
47
+ // not identify, which is not a license anyone can comply with.
48
+ license: json.license?.spdx_id && json.license.spdx_id !== 'NOASSERTION'
49
+ ? json.license.spdx_id
50
+ : '',
51
+ created: typeof json.created_at === 'string' ? json.created_at : '',
52
+ pushed: typeof json.pushed_at === 'string' ? json.pushed_at : '',
53
+ branch: typeof json.default_branch === 'string' ? json.default_branch : '',
54
+ topics: Array.isArray(json.topics)
55
+ ? json.topics.filter((t) => typeof t === 'string' && t).slice(0, 12)
56
+ : [],
57
+ homepage: typeof json.homepage === 'string' ? json.homepage.trim() : '',
58
+ archived: Boolean(json.archived),
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Why a remote lookup did not produce facts, in the words a person can act on.
64
+ *
65
+ * 403, 404 and 429 are separated because they mean opposite things — one is
66
+ * "ask less often, or authenticate", one is "private, or gone, or never there",
67
+ * and one is "slow down" — and collapsing them into "GitHub said no" helps
68
+ * nobody diagnose anything.
69
+ */
70
+ export function remoteError(status) {
71
+ if (status === 403) {
72
+ return 'GitHub rate-limited the ask — the unauthenticated allowance is 60 an hour.';
73
+ }
74
+ if (status === 404) {
75
+ return 'GitHub has no such repository, or it is private.';
76
+ }
77
+ if (status === 429) {
78
+ return 'GitHub is throttling this machine — try again in a minute.';
79
+ }
80
+ return 'GitHub answered ' + status + '.';
81
+ }