codebase-onboarder 0.4.1 → 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/README.md +74 -2
- package/cli/explorer/advanced.js +457 -0
- package/cli/explorer/app.js +391 -0
- package/cli/explorer/commands.js +369 -0
- package/cli/explorer/github.js +75 -0
- package/cli/explorer/graphs.js +178 -0
- package/cli/explorer/session.js +251 -0
- package/cli/explorer/views.js +650 -0
- package/cli/explorer/wrap.js +49 -0
- package/cli/main.js +82 -10
- package/cli/ui.js +31 -2
- package/package.json +1 -1
- package/public/js/about.js +20 -7
- package/server/gitRemote.js +61 -0
- package/server/layout.js +41 -14
- package/shared/analyzer/github.js +81 -0
|
@@ -0,0 +1,369 @@
|
|
|
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
|
+
import * as A from './advanced.js';
|
|
15
|
+
import { lineNumber } from './session.js';
|
|
16
|
+
import { bold, cyan, dim, ok, fit, termWidth } from '../ui.js';
|
|
17
|
+
import { wrapText } from './wrap.js';
|
|
18
|
+
import { execFile } from 'node:child_process';
|
|
19
|
+
import { promisify } from 'node:util';
|
|
20
|
+
|
|
21
|
+
const run = promisify(execFile);
|
|
22
|
+
|
|
23
|
+
export const EXIT = Symbol('exit');
|
|
24
|
+
export const CLEAR = Symbol('clear');
|
|
25
|
+
|
|
26
|
+
export const COMMANDS = [
|
|
27
|
+
{
|
|
28
|
+
name: 'help', aliases: ['?'], group: 'basics',
|
|
29
|
+
usage: 'help [command]', summary: 'This list, or everything about one command.',
|
|
30
|
+
// The topic is forwarded. `help find` used to ignore its argument and print
|
|
31
|
+
// the whole list, which reads as the command not working.
|
|
32
|
+
run: (ctx, args) => ctx.help(args.join(' ')),
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'map', aliases: ['overview', 'home'], group: 'basics',
|
|
36
|
+
usage: 'map', summary: 'What this repo is, and where to start.',
|
|
37
|
+
run: (ctx) => V.overview(ctx.repo),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'tour', aliases: ['start', 'onboarding'], group: 'basics',
|
|
41
|
+
usage: 'tour', summary: 'The reading order a new teammate should follow.',
|
|
42
|
+
run: (ctx) => V.tour(ctx.repo),
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'explain', aliases: ['why', 'what'], group: 'basics',
|
|
46
|
+
usage: 'explain [file|folder]', summary: 'This repo, or one file/folder, in prose. No AI key needed.',
|
|
47
|
+
run: (ctx, args) => V.explain(ctx.repo, { target: args.join(' ') }),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'tree', aliases: ['ls', 'files'], group: 'navigate',
|
|
51
|
+
usage: 'tree [folder] [depth]', summary: 'The file tree, with entries and hubs marked.',
|
|
52
|
+
run: (ctx, args) => {
|
|
53
|
+
// A lone number is the depth, not a folder called "2". `tree 3` is what
|
|
54
|
+
// everyone types when they want to see more; making them spell
|
|
55
|
+
// `tree . 3` would be pedantry in a tool built to be forgiving. `.` is the
|
|
56
|
+
// root, so it must not be taken as a folder name either.
|
|
57
|
+
const onlyDepth = args.length === 1 && /^\d+$/.test(args[0]);
|
|
58
|
+
const sub = onlyDepth ? '' : (args[0] || '');
|
|
59
|
+
return V.tree(ctx.repo, {
|
|
60
|
+
sub: sub === '.' ? '' : sub,
|
|
61
|
+
depth: onlyDepth ? Number(args[0]) : (Number(args[1]) || 2),
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'find', aliases: ['search', 'grep'], group: 'navigate',
|
|
67
|
+
usage: 'find <query>', summary: 'Search. Supports ext:js, -exclude, "phrases", /regex/.',
|
|
68
|
+
run: (ctx, args) => V.find(ctx.repo, { query: args.join(' ') }),
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'show', aliases: ['open', 'cat', 'read'], group: 'navigate',
|
|
72
|
+
usage: 'show <file> [from] [count]', summary: 'Read a file. Name it loosely: `show logger.js` works.',
|
|
73
|
+
run: (ctx, args) => V.show(ctx.repo, {
|
|
74
|
+
target: args[0] || '',
|
|
75
|
+
from: lineNumber(args[1], 0),
|
|
76
|
+
count: lineNumber(args[2], 0),
|
|
77
|
+
}),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'deps', aliases: ['connections'], group: 'navigate',
|
|
81
|
+
usage: 'deps <file>', summary: 'What a file imports, and what imports it.',
|
|
82
|
+
run: (ctx, args) => V.deps(ctx.repo, { target: args.join(' ') }),
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'health', aliases: ['grade'], group: 'analyze',
|
|
86
|
+
usage: 'health', summary: 'Health grade, riskiest files, debt.',
|
|
87
|
+
run: (ctx) => V.health(ctx.repo),
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'hubs', aliases: ['core'], group: 'analyze',
|
|
91
|
+
usage: 'hubs', summary: 'The most depended-on files.',
|
|
92
|
+
run: (ctx) => V.hubs(ctx.repo),
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: 'layers', aliases: ['depth'], group: 'analyze',
|
|
96
|
+
usage: 'layers', summary: 'Import depth, shallowest first.',
|
|
97
|
+
run: (ctx) => V.layers(ctx.repo),
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'patterns', aliases: ['architecture'], group: 'analyze',
|
|
101
|
+
usage: 'patterns', summary: 'What the architecture looks like, in prose.',
|
|
102
|
+
run: (ctx) => V.patterns(ctx.repo),
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: 'stats', aliases: ['numbers'], group: 'analyze',
|
|
106
|
+
usage: 'stats', summary: 'Languages, biggest folders and files, complexity.',
|
|
107
|
+
run: (ctx) => V.stats(ctx.repo),
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'security', aliases: ['audit'], group: 'analyze',
|
|
111
|
+
usage: 'security', summary: 'Heuristic findings from the built-in rules.',
|
|
112
|
+
run: (ctx) => V.security(ctx.repo),
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: 'stack', aliases: ['packages'], group: 'analyze',
|
|
116
|
+
usage: 'stack', summary: 'Declared dependencies and frameworks.',
|
|
117
|
+
run: (ctx) => V.stack(ctx.repo),
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: 'entry', aliases: ['entries'], group: 'analyze',
|
|
121
|
+
usage: 'entry', summary: 'Recognized entry points and how far they reach.',
|
|
122
|
+
run: (ctx) => V.entry(ctx.repo),
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'externals', aliases: ['drift'], group: 'analyze',
|
|
126
|
+
usage: 'externals', summary: 'External packages, plus dependency drift.',
|
|
127
|
+
run: (ctx) => V.externals(ctx.repo),
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: 'graph', aliases: ['tree-graph'], group: 'navigate',
|
|
131
|
+
usage: 'graph <file> [depth]', summary: 'The dependency tree around a file, as arrows.',
|
|
132
|
+
run: (ctx, args) => A.graph(ctx.repo, { target: args[0] || '', depth: Number(args[1]) || 2 }),
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: 'blast', aliases: ['impact', 'radius'], group: 'navigate',
|
|
136
|
+
usage: 'blast <file>', summary: 'What breaks if this file breaks, transitively.',
|
|
137
|
+
run: (ctx, args) => A.blast(ctx.repo, { target: args.join(' ') }),
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: 'symbols', aliases: ['outline', 'functions'], group: 'navigate',
|
|
141
|
+
usage: 'symbols <file>', summary: 'Functions, classes and exports in a file.',
|
|
142
|
+
run: (ctx, args) => A.symbols(ctx.repo, { target: args.join(' ') }),
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'docs', aliases: ['document'], group: 'analyze',
|
|
146
|
+
usage: 'docs [file|folder] | docs --write', summary: 'Prose docs for a file, or write ONBOARDER.md.',
|
|
147
|
+
run: async (ctx, args) => {
|
|
148
|
+
// `--write` is the one command here that touches the filesystem. It is
|
|
149
|
+
// opt-in, explicit, and prints where it wrote — a map tool should never
|
|
150
|
+
// leave a file behind just because someone asked what the docs say.
|
|
151
|
+
if (args[0] === '--write') {
|
|
152
|
+
const abs = await A.writeDocs(ctx.repo, args[1] || 'ONBOARDER.md');
|
|
153
|
+
return ' ' + ok('Wrote ') + cyan(abs);
|
|
154
|
+
}
|
|
155
|
+
return A.docs(ctx.repo, { target: args.filter((a) => a !== '--write').join(' ') });
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: 'log', aliases: ['history', 'commits'], group: 'git',
|
|
160
|
+
usage: 'log [count]', summary: 'Recent commits, and who wrote them.',
|
|
161
|
+
run: (ctx, args) => A.log(ctx.repo, { limit: Number(args[0]) || 15 }),
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: 'hotspots', aliases: ['churn'], group: 'git',
|
|
165
|
+
usage: 'hotspots [count]', summary: 'Files that are both complex and frequently changed.',
|
|
166
|
+
run: (ctx, args) => A.hotspots(ctx.repo, { limit: Number(args[0]) || 12 }),
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: 'blame', aliases: ['authors'], group: 'git',
|
|
170
|
+
usage: 'blame <file>', summary: 'Who wrote each line of a file.',
|
|
171
|
+
run: (ctx, args) => A.blame(ctx.repo, { target: args.join(' ') }),
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
name: 'coupling', aliases: ['matrix', 'heatmap'], group: 'analyze',
|
|
175
|
+
usage: 'coupling', summary: 'Folder-to-folder import traffic, as a heat grid.',
|
|
176
|
+
run: (ctx) => A.coupling(ctx.repo),
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: 'clusters', aliases: ['communities', 'modules'], group: 'analyze',
|
|
180
|
+
usage: 'clusters', summary: 'Groups of files that import each other more than the rest.',
|
|
181
|
+
run: (ctx) => A.clusters(ctx.repo),
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: 'diagram', aliases: ['mermaid'], group: 'analyze',
|
|
185
|
+
usage: 'diagram [file]', summary: 'Mermaid source for the repo, or one file.',
|
|
186
|
+
run: (ctx, args) => A.diagram(ctx.repo, { target: args.join(' ') }),
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: 'layers-diagram', aliases: [], group: 'analyze',
|
|
190
|
+
usage: 'layers-diagram', summary: 'Mermaid source for the layer stack.',
|
|
191
|
+
run: (ctx) => A.layerDiagram(ctx.repo),
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: 'risks', aliases: ['problems', 'smells'], group: 'analyze',
|
|
195
|
+
usage: 'risks', summary: 'Cycles, orphans, dead exports, drift — in one list.',
|
|
196
|
+
run: (ctx) => A.risks(ctx.repo),
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'about', aliases: ['version'], group: 'session',
|
|
200
|
+
usage: 'about', summary: 'Version, repo path, and what is indexed.',
|
|
201
|
+
run: (ctx) => V.about(ctx.repo, ctx.version),
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
name: 'rescan', aliases: ['reload'], group: 'session',
|
|
205
|
+
usage: 'rescan', summary: 'Re-read the repo from disk.',
|
|
206
|
+
run: (ctx) => ctx.rescan(),
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
name: 'github', aliases: ['remote', 'repo'], group: 'session',
|
|
210
|
+
usage: 'github', summary: "What GitHub says about this repo — stars, issues, license.",
|
|
211
|
+
run: (ctx) => ctx.github(),
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: 'cd', aliases: ['open-repo', 'use'], group: 'session',
|
|
215
|
+
usage: 'cd <folder|url>', summary: 'Load another repository, or clone one by URL.',
|
|
216
|
+
run: (ctx, args) => ctx.loadRepo(args.join(' ')),
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
name: 'web', aliases: ['site', 'serve'], group: 'session',
|
|
220
|
+
usage: 'web', summary: 'Start the web UI and print its URL.',
|
|
221
|
+
run: (ctx) => ctx.web(),
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
name: 'clear', aliases: ['cls'], group: 'session',
|
|
225
|
+
usage: 'clear', summary: 'Clear the screen.',
|
|
226
|
+
run: () => CLEAR,
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
name: 'exit', aliases: ['quit', 'q'], group: 'session',
|
|
230
|
+
usage: 'exit', summary: 'Leave. Ctrl-D does the same.',
|
|
231
|
+
run: () => EXIT,
|
|
232
|
+
},
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
// One lookup for every spelling a person might type, built once at import.
|
|
236
|
+
const BY_NAME = new Map();
|
|
237
|
+
for (const cmd of COMMANDS) {
|
|
238
|
+
BY_NAME.set(cmd.name, cmd);
|
|
239
|
+
for (const a of cmd.aliases) BY_NAME.set(a, cmd);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function lookup(word) {
|
|
243
|
+
return BY_NAME.get(String(word || '').toLowerCase());
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Every canonical name, for "did you mean" matching. Aliases are deliberately
|
|
247
|
+
// excluded: suggesting `open` when someone typed `sho` is less useful than
|
|
248
|
+
// suggesting `show`, which is the word they were reaching for.
|
|
249
|
+
export function commandNames() {
|
|
250
|
+
return COMMANDS.map((c) => c.name);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// `help <command>` answers about one command; bare `help` lists them, grouped so
|
|
254
|
+
// the shape of the tool is visible rather than alphabetical. Every row is
|
|
255
|
+
// fitted, because a long summary on a narrow terminal used to wrap into the
|
|
256
|
+
// next row and make the list unreadable.
|
|
257
|
+
export function helpText(ctx, topic = '') {
|
|
258
|
+
const w = termWidth();
|
|
259
|
+
if (topic) {
|
|
260
|
+
const cmd = lookup(topic);
|
|
261
|
+
if (!cmd) return fit(` No command called "${topic}". Try \`help\`.`, Math.max(10, w - 2));
|
|
262
|
+
const also = cmd.aliases.length ? ` (also: ${cmd.aliases.join(', ')})` : '';
|
|
263
|
+
return [
|
|
264
|
+
fit(' ' + cmd.usage + also, Math.max(10, w - 2)),
|
|
265
|
+
wrapText(cmd.summary, ' '),
|
|
266
|
+
].join('\n');
|
|
267
|
+
}
|
|
268
|
+
const groups = new Map();
|
|
269
|
+
for (const cmd of COMMANDS) {
|
|
270
|
+
if (!groups.has(cmd.group)) groups.set(cmd.group, []);
|
|
271
|
+
groups.get(cmd.group).push(cmd);
|
|
272
|
+
}
|
|
273
|
+
// Two cells of indent, two of gap, and the summary gets whatever is left. The
|
|
274
|
+
// usage column shrinks with the terminal rather than pushing the text off it.
|
|
275
|
+
const longest = Math.max(...COMMANDS.map((c) => c.usage.length));
|
|
276
|
+
const usageRoom = Math.max(8, Math.min(longest, Math.floor(w * 0.4)));
|
|
277
|
+
const out = [];
|
|
278
|
+
for (const [group, list] of groups) {
|
|
279
|
+
out.push(fit(' ' + group.toUpperCase(), Math.max(10, w - 2)));
|
|
280
|
+
for (const c of list) {
|
|
281
|
+
const left = ' ' + fit(c.usage, usageRoom);
|
|
282
|
+
const room = w - left.length - 2;
|
|
283
|
+
out.push(room > 8 ? left + ' ' + fit(c.summary, room, { tail: false }) : left);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
out.push('');
|
|
287
|
+
out.push(fit(' ' + (ctx?.repo ? ctx.repo.name : 'onboarder')
|
|
288
|
+
+ ' · Ctrl-D or `exit` to leave · Ctrl-C twice to quit', Math.max(10, w - 2)));
|
|
289
|
+
return out.join('\n');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Split a typed line into words, honoring quotes so `find "exact phrase"` and
|
|
293
|
+
// `show "my file.js"` arrive as one argument each. Done once, here, so no
|
|
294
|
+
// command has to re-implement it.
|
|
295
|
+
export function tokenize(line) {
|
|
296
|
+
const out = [];
|
|
297
|
+
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
298
|
+
let m;
|
|
299
|
+
while ((m = re.exec(String(line || '')))) out.push(m[1] ?? m[2] ?? m[3]);
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Tab completion, because a REPL where you retype `shared/analyzer/graph.js` one
|
|
304
|
+
// letter at a time is not a REPL, it is a punishment.
|
|
305
|
+
//
|
|
306
|
+
// Two contexts, chosen by what is already typed. Before the first space, the line
|
|
307
|
+
// is a command name, so it completes against the command table — including
|
|
308
|
+
// aliases, because a person who types `ls` wants `tree` to finish. After a space,
|
|
309
|
+
// it is an argument, so it completes against real file paths in the loaded repo,
|
|
310
|
+
// which is the thing that is tedious to type and impossible to remember.
|
|
311
|
+
//
|
|
312
|
+
// `shellEscape` is the other half of a good REPL: `!git status` runs a shell
|
|
313
|
+
// command and hands the output back, so nobody has to leave the session to run
|
|
314
|
+
// one thing. It is deliberately the *only* way out to a shell, so the set of
|
|
315
|
+
// things that can happen in a session stays legible.
|
|
316
|
+
function completer(repo) {
|
|
317
|
+
return function complete(line) {
|
|
318
|
+
const trailingSpace = /\s$/.test(line);
|
|
319
|
+
const parts = line.split(/\s+/);
|
|
320
|
+
// Completing a command (no space yet, or trailing space after one word).
|
|
321
|
+
if (parts.length <= 1 || (parts.length === 2 && trailingSpace)) {
|
|
322
|
+
const hits = [...BY_NAME.keys()].filter((n) => n.startsWith(parts[0] || '')).sort();
|
|
323
|
+
return [hits.length ? hits : parts, parts[0] || ''];
|
|
324
|
+
}
|
|
325
|
+
// Completing a path argument against the loaded repo. Matches at any depth,
|
|
326
|
+
// not just from the root: `show rend` should find `src/render.js`, which is
|
|
327
|
+
// the case the resolver's forgiving path lookup already handles — the
|
|
328
|
+
// completer has to agree with it or Tab contradicts what the command does.
|
|
329
|
+
const frag = parts[parts.length - 1] || '';
|
|
330
|
+
const slash = frag.lastIndexOf('/');
|
|
331
|
+
const dir = slash === -1 ? '' : frag.slice(0, slash + 1);
|
|
332
|
+
const base = slash === -1 ? frag : frag.slice(slash + 1);
|
|
333
|
+
const seen = new Set();
|
|
334
|
+
const hits = [];
|
|
335
|
+
for (const f of repo.scan.allFiles || repo.scan.files) {
|
|
336
|
+
if (dir && !f.startsWith(dir)) continue;
|
|
337
|
+
// With no directory typed, match the *last* segment so `rend` finds
|
|
338
|
+
// `src/render.js`; with one typed, match the segment being completed.
|
|
339
|
+
const name = dir ? f.slice(dir.length) : f.slice(f.lastIndexOf('/') + 1);
|
|
340
|
+
if (!name.startsWith(base)) continue;
|
|
341
|
+
if (seen.has(name)) continue;
|
|
342
|
+
seen.add(name);
|
|
343
|
+
hits.push(name);
|
|
344
|
+
if (hits.length >= 200) break;
|
|
345
|
+
}
|
|
346
|
+
return [hits.length ? hits : [frag], frag];
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Run a shell command and capture its output. A non-zero exit is not a crash —
|
|
351
|
+
// `grep` that finds nothing is a normal answer — so the code is reported, not
|
|
352
|
+
// thrown. There is no `shell: true`: the whole line is the argument vector, so
|
|
353
|
+
// nothing in a filename can be interpreted as a second command.
|
|
354
|
+
async function shellEscape(line, cwd) {
|
|
355
|
+
const parts = tokenize(line);
|
|
356
|
+
if (!parts.length) return null;
|
|
357
|
+
try {
|
|
358
|
+
const { stdout, stderr } = await run(parts[0], parts.slice(1), { cwd, maxBuffer: 8 * 1024 * 1024 });
|
|
359
|
+
const out = String(stdout || '').trimEnd();
|
|
360
|
+
const err = String(stderr || '').trimEnd();
|
|
361
|
+
return [out, err].filter(Boolean).join('\n') || dim(' (no output)');
|
|
362
|
+
} catch (e) {
|
|
363
|
+
const code = e.code ?? e.status;
|
|
364
|
+
const err = String(e.stderr || '').trimEnd() || String(e.message || '').trimEnd();
|
|
365
|
+
return dim(' exit ' + (code ?? '?') + (err ? '\n ' + err : ''));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export { completer, shellEscape };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Asking GitHub what it knows about a repository — the terminal half of the
|
|
2
|
+
// feature the web app calls "From the remote".
|
|
3
|
+
//
|
|
4
|
+
// The `fetch` lives here rather than in `shared/analyzer/github.js` because it
|
|
5
|
+
// can only happen in one runtime at a time, and because this side can do
|
|
6
|
+
// something the browser cannot: send a token. `GITHUB_TOKEN` (or `GH_TOKEN`, the
|
|
7
|
+
// name the `gh` CLI uses) turns GitHub's 60-requests-an-hour guest allowance
|
|
8
|
+
// into 5,000. The browser has nowhere to keep a secret, which is precisely why
|
|
9
|
+
// this is better in a terminal than on the web.
|
|
10
|
+
//
|
|
11
|
+
// A failed lookup is never fatal. `github` on a repo with no remote, a private
|
|
12
|
+
// one, or a machine with no network is a normal thing to type, so every path
|
|
13
|
+
// returns the honest reason instead of throwing.
|
|
14
|
+
|
|
15
|
+
import { githubRepoPath, remoteError, remoteFacts } from '../../shared/analyzer/github.js';
|
|
16
|
+
|
|
17
|
+
const API = 'https://api.github.com/repos/';
|
|
18
|
+
const TIMEOUT_MS = 8000;
|
|
19
|
+
|
|
20
|
+
// The token is read per call rather than at import: a session that exports one
|
|
21
|
+
// mid-flight should not need to be restarted to pick it up.
|
|
22
|
+
function token() {
|
|
23
|
+
const t = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
24
|
+
return typeof t === 'string' && t.trim() ? t.trim() : '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Look up a GitHub repository's public facts.
|
|
29
|
+
*
|
|
30
|
+
* `fetchImpl` is a parameter so this is testable without a network, and so the
|
|
31
|
+
* caller can pass an already-aborted-aware fetch if it has one.
|
|
32
|
+
*
|
|
33
|
+
* Returns `{ ok: true, facts, repoPath }` or `{ ok: false, reason }`. Never
|
|
34
|
+
* throws — the reason is the return value, because every failure here is
|
|
35
|
+
* something to print, not something to crash a session over.
|
|
36
|
+
*/
|
|
37
|
+
export async function fetchRepoFacts(url, { fetchImpl = globalThis.fetch } = {}) {
|
|
38
|
+
const repoPath = githubRepoPath(url);
|
|
39
|
+
if (!repoPath) {
|
|
40
|
+
return { ok: false, reason: 'That is not a GitHub URL, so there are no remote facts to ask for.' };
|
|
41
|
+
}
|
|
42
|
+
if (typeof fetchImpl !== 'function') {
|
|
43
|
+
return { ok: false, reason: 'This runtime has no fetch, so GitHub cannot be asked.' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const headers = { accept: 'application/vnd.github+json', 'user-agent': 'onboarder' };
|
|
47
|
+
const auth = token();
|
|
48
|
+
if (auth) headers.authorization = 'Bearer ' + auth;
|
|
49
|
+
|
|
50
|
+
let res;
|
|
51
|
+
try {
|
|
52
|
+
res = await fetchImpl(API + repoPath, {
|
|
53
|
+
headers,
|
|
54
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
55
|
+
});
|
|
56
|
+
} catch (err) {
|
|
57
|
+
// A timeout and a refused connection are the same thing to the person who
|
|
58
|
+
// typed the command: it did not come back. The message is kept because
|
|
59
|
+
// "GitHub could not be reached" and "the certificate is not trusted" are
|
|
60
|
+
// very different problems on very different machines.
|
|
61
|
+
const detail = /timeout|abort/i.test(err?.message || '') ? ' (it took too long)' : '';
|
|
62
|
+
return { ok: false, reason: 'Could not reach GitHub' + detail + ' — ' + (err?.message || err) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!res.ok) return { ok: false, reason: remoteError(res.status) };
|
|
66
|
+
|
|
67
|
+
let facts;
|
|
68
|
+
try {
|
|
69
|
+
facts = remoteFacts(await res.json());
|
|
70
|
+
} catch {
|
|
71
|
+
return { ok: false, reason: 'GitHub sent something that is not JSON.' };
|
|
72
|
+
}
|
|
73
|
+
if (!facts) return { ok: false, reason: 'GitHub sent no repository facts.' };
|
|
74
|
+
return { ok: true, facts, repoPath };
|
|
75
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Rendering primitives that the terminal can do and a browser cannot afford to.
|
|
2
|
+
//
|
|
3
|
+
// The site's coupling matrix is a canvas heat grid and its mind map is SVG. In a
|
|
4
|
+
// terminal the honest translation of "a heat grid" is block characters, and the
|
|
5
|
+
// honest translation of "a dependency tree" is the one everybody already knows
|
|
6
|
+
// how to read: an indented tree with arrows. Neither is a worse version of the
|
|
7
|
+
// web view — they are the terminal's own idiom, drawn from the same numbers.
|
|
8
|
+
|
|
9
|
+
import { fit, termWidth } from '../ui.js';
|
|
10
|
+
import { width } from '../../server/layout.js';
|
|
11
|
+
|
|
12
|
+
// Five ramp steps. Unicode 2591–2588, with an ASCII floor for terminals that
|
|
13
|
+
// cannot show them. Density reads as "how dark", which survives being printed in
|
|
14
|
+
// black and white, redirected to a file, or read by someone who cannot tell the
|
|
15
|
+
// ramp apart — the thing a color-only heatmap cannot promise.
|
|
16
|
+
const RAMP_UNICODE = [' ', '░', '▒', '▓', '█'];
|
|
17
|
+
const RAMP_ASCII = ['.', ':', '+', '*', '#'];
|
|
18
|
+
|
|
19
|
+
// A row that is `marker + value + tail`, where the tail is the first thing to go
|
|
20
|
+
// when the terminal is too narrow to hold it.
|
|
21
|
+
//
|
|
22
|
+
// This is the same mistake in five places — health risks, security findings,
|
|
23
|
+
// entry points, symbol rows, and layer notes — and fixing each by hand means the
|
|
24
|
+
// sixth gets it wrong too. The rule in one place: if the tail leaves less than
|
|
25
|
+
// `MIN_PATH` cells for the value, drop the tail and give the value the rest.
|
|
26
|
+
//
|
|
27
|
+
// `marker` may be styled, so its width is measured with `width()` (which strips
|
|
28
|
+
// ANSI) rather than `.length`. Measuring a styled string counts escape-sequence
|
|
29
|
+
// characters as if they occupied cells, which is how a "fitted" row still
|
|
30
|
+
// overflows by the length of the color codes.
|
|
31
|
+
const MIN_PATH = 8;
|
|
32
|
+
|
|
33
|
+
// `pad` is the indent *inside* the width budget, not a prefix glued on
|
|
34
|
+
// afterwards. An earlier version returned `marker + value` and let the caller
|
|
35
|
+
// prepend `' '`, which is four cells the helper could not see — so every row
|
|
36
|
+
// came out four cells too wide, which is exactly the overflow this exists to
|
|
37
|
+
// prevent. The indent is part of the row now.
|
|
38
|
+
//
|
|
39
|
+
// The value always gets at least one cell and never more than the terminal has
|
|
40
|
+
// left, even when the marker alone is wider than the terminal. That last case is
|
|
41
|
+
// the one a `Math.max(1, …)` floor gets wrong: a floor of 1 plus an
|
|
42
|
+
// over-wide marker is still an overflow, so the value is clamped by `w` itself
|
|
43
|
+
// and the marker is fitted too.
|
|
44
|
+
export function labelled(marker, value, tail = '', { pad = 0 } = {}) {
|
|
45
|
+
const w = termWidth();
|
|
46
|
+
const indent = ' '.repeat(Math.max(0, Math.min(pad, Math.max(0, w - 1))));
|
|
47
|
+
const markerRoom = Math.max(0, w - width(indent) - 1);
|
|
48
|
+
const head = fit(marker, markerRoom);
|
|
49
|
+
const lead = width(indent) + width(head);
|
|
50
|
+
const withTail = w - lead - width(tail);
|
|
51
|
+
if (tail && withTail > MIN_PATH) return indent + head + fit(value, withTail) + tail;
|
|
52
|
+
return indent + head + fit(value, Math.max(0, w - lead));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const glyphs = () => (process.platform === 'win32' && !process.env.WT_SESSION ? RAMP_ASCII : RAMP_UNICODE);
|
|
56
|
+
|
|
57
|
+
// The strongest signal a number gets, as a fraction of the largest, in five
|
|
58
|
+
// steps. Zero is always the blank cell rather than the faintest mark: "nothing"
|
|
59
|
+
// and "a little" are different facts, and a heat grid that gives zero a speck of
|
|
60
|
+
// ink makes every map look busier than it is.
|
|
61
|
+
//
|
|
62
|
+
// Returns the *character*, always a string — an early version returned the
|
|
63
|
+
// number 0 for "empty", which then blew up on `.padEnd()` the first time a
|
|
64
|
+
// genuinely-zero cell was drawn, i.e. only on repos with real structure.
|
|
65
|
+
function step(value, max) {
|
|
66
|
+
const ramp = glyphs();
|
|
67
|
+
if (!value || max <= 0) return ramp[0];
|
|
68
|
+
const i = Math.min(ramp.length - 1, Math.max(1, Math.ceil((value / max) * (ramp.length - 1))));
|
|
69
|
+
return ramp[i];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export { step };
|
|
73
|
+
|
|
74
|
+
// A horizontal bar, sized as a fraction of the largest value. Exported so every
|
|
75
|
+
// ranked list draws its bars identically instead of each view re-deriving the
|
|
76
|
+
// scale and drifting.
|
|
77
|
+
export function bar(value, max, room) {
|
|
78
|
+
if (room <= 0 || !value || max <= 0) return '';
|
|
79
|
+
const ramp = glyphs();
|
|
80
|
+
const full = Math.max(1, Math.round((value / max) * room));
|
|
81
|
+
return ramp[ramp.length - 1].repeat(Math.min(room, full));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A vertical bar chart, because some questions are about magnitude across
|
|
85
|
+
// categories and reading them off a horizontal list is harder than it needs to
|
|
86
|
+
// be. Columns collapse to a single row when the terminal is too narrow to hold
|
|
87
|
+
// them, rather than drawing a squeezed, unreadable grid.
|
|
88
|
+
export function columns(items, { room = 40, rows = 10 } = {}) {
|
|
89
|
+
if (!items.length) return [];
|
|
90
|
+
const width = Math.max(1, Math.min(items.length, room));
|
|
91
|
+
const stepSize = Math.ceil(items.length / width);
|
|
92
|
+
const shown = stepSize > 1 ? items.filter((_, i) => i % stepSize === 0).slice(0, width) : items;
|
|
93
|
+
const top = Math.max(...shown.map((i) => i.value)) || 1;
|
|
94
|
+
const solid = glyphs()[glyphs().length - 1];
|
|
95
|
+
const out = [];
|
|
96
|
+
for (let level = rows; level >= 1; level--) {
|
|
97
|
+
const threshold = (level / rows) * top;
|
|
98
|
+
const line = shown.map((i) => (i.value >= threshold ? solid : ' ')).join('');
|
|
99
|
+
if (line.trim()) out.push(' ' + line);
|
|
100
|
+
}
|
|
101
|
+
out.push(' ' + shown.map((i) => String(i.label).slice(0, 1)).join(''));
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The folder-to-folder coupling grid, as a matrix. Row label, then one cell per
|
|
106
|
+
// folder, heaviest traffic darkest. Columns are addressed by letter so a cell
|
|
107
|
+
// can be named rather than counted. The diagonal is blank: a folder's traffic
|
|
108
|
+
// with itself is not coupling, it is just being that folder.
|
|
109
|
+
export function couplingGrid(repo, { maxFolders = 8, columns: cols } = {}) {
|
|
110
|
+
const matrix = repo.coupling;
|
|
111
|
+
if (!matrix || !matrix.folders.length) return [];
|
|
112
|
+
const folders = matrix.folders.slice(0, maxFolders);
|
|
113
|
+
const w = cols || termWidth();
|
|
114
|
+
const labelRoom = Math.min(18, Math.max(...folders.map((f) => f.length)) + 1);
|
|
115
|
+
const cellRoom = Math.max(1, Math.floor((w - labelRoom - 4) / folders.length));
|
|
116
|
+
const cellW = Math.max(1, Math.min(2, cellRoom));
|
|
117
|
+
|
|
118
|
+
const head = ' '.repeat(labelRoom) + folders.map((_, i) => String.fromCharCode(65 + i).padEnd(cellW)).join('');
|
|
119
|
+
// The rule is sized to the grid but never past the terminal, so a very narrow
|
|
120
|
+
// window gets a short rule rather than a wrapped one.
|
|
121
|
+
const rule = '─'.repeat(Math.max(1, Math.min(labelRoom + folders.length * cellW, Math.max(1, w - 2))));
|
|
122
|
+
const out = [' ' + fit(head, w - 2), ' ' + rule];
|
|
123
|
+
|
|
124
|
+
folders.forEach((from, r) => {
|
|
125
|
+
let line = fit(from, labelRoom);
|
|
126
|
+
folders.forEach((to, c) => {
|
|
127
|
+
if (r === c) {
|
|
128
|
+
line += ' '.repeat(cellW);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const n = matrix.counts.get(from + '->' + to) || 0;
|
|
132
|
+
line += step(n, matrix.max).padEnd(cellW);
|
|
133
|
+
});
|
|
134
|
+
out.push(' ' + fit(line, w - 2));
|
|
135
|
+
});
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A dependency tree rooted at a file, drawn as a tree with the one piece of
|
|
140
|
+
// information that makes a tree readable — which way each edge points. This is
|
|
141
|
+
// the terminal's answer to the site's force-directed graph: a browser can show
|
|
142
|
+
// every edge at once, a terminal cannot, so it shows the part that answers "what
|
|
143
|
+
// breaks if I break this".
|
|
144
|
+
//
|
|
145
|
+
// The `seen` set makes the walk terminate on a cyclic graph — without it, a
|
|
146
|
+
// two-file import cycle would recurse until the stack gave out. The cost is that
|
|
147
|
+
// a file reached by two different paths is drawn only under the first, which is
|
|
148
|
+
// the right trade for a bounded tree.
|
|
149
|
+
export function depTree(repo, rootPath, { depth = 2, direction = 'both', room } = {}) {
|
|
150
|
+
const w = room || termWidth();
|
|
151
|
+
const out = [];
|
|
152
|
+
const seen = new Set([rootPath]);
|
|
153
|
+
|
|
154
|
+
const walk = (path, prefix, left, arrow) => {
|
|
155
|
+
out.push(fit(prefix + arrow + ' ' + path, w - 2));
|
|
156
|
+
if (left <= 0) return;
|
|
157
|
+
|
|
158
|
+
const kids = [];
|
|
159
|
+
if (direction !== 'up') {
|
|
160
|
+
for (const child of repo.facts.importsOf[path] || []) {
|
|
161
|
+
if (!seen.has(child)) kids.push({ path: child, arrow: '→' });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (direction !== 'down') {
|
|
165
|
+
for (const parent of repo.facts.importers[path] || []) {
|
|
166
|
+
if (!seen.has(parent)) kids.push({ path: parent, arrow: '←' });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
kids.forEach((k, i) => {
|
|
170
|
+
if (seen.has(k.path)) return;
|
|
171
|
+
seen.add(k.path);
|
|
172
|
+
walk(k.path, prefix + (i === kids.length - 1 ? ' ' : '│ '), left - 1, k.arrow);
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
walk(rootPath, '', depth, '●');
|
|
177
|
+
return out;
|
|
178
|
+
}
|