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/README.md +23 -2
- package/cli/explorer/advanced.js +457 -0
- package/cli/explorer/app.js +107 -11
- package/cli/explorer/commands.js +186 -14
- package/cli/explorer/github.js +75 -0
- package/cli/explorer/graphs.js +178 -0
- package/cli/explorer/session.js +95 -5
- package/cli/explorer/views.js +216 -115
- package/cli/explorer/wrap.js +49 -0
- package/cli/main.js +49 -5
- 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
package/cli/explorer/app.js
CHANGED
|
@@ -19,12 +19,14 @@ import readline from 'node:readline';
|
|
|
19
19
|
import fs from 'node:fs';
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
|
|
22
|
-
import { openRepo } from './session.js';
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
22
|
+
import { openRepo, openRepoOrClone, isGitUrl, remoteUrlFor, closeRemote } from './session.js';
|
|
23
|
+
import { fetchRepoFacts } from './github.js';
|
|
24
|
+
import { CLEAR, EXIT, commandNames, completer, helpText, lookup, shellEscape, tokenize } from './commands.js';
|
|
25
|
+
import { overview, github as githubView } from './views.js';
|
|
25
26
|
import { bold, cyan, dim, ok, bad, paint } from '../ui.js';
|
|
26
27
|
import { configPath, readSettings, serverUrls } from '../../server/config.js';
|
|
27
28
|
import { readPidFile, pidIsAlive } from '../../server/pidfile.js';
|
|
29
|
+
import { expandHome } from '../../server/paths.js';
|
|
28
30
|
|
|
29
31
|
const VERSION = JSON.parse(
|
|
30
32
|
fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8')
|
|
@@ -52,10 +54,16 @@ export async function runExplore({ target = '.', flags = {}, out = console.log,
|
|
|
52
54
|
|
|
53
55
|
let repo;
|
|
54
56
|
try {
|
|
55
|
-
|
|
57
|
+
// The launch argument accepts a URL for the same reason `cd` does: someone
|
|
58
|
+
// who has never seen this repo should be able to name it and read it. A
|
|
59
|
+
// silent ten-second clone looks like a hang, so the URL case says what it
|
|
60
|
+
// is doing before it starts.
|
|
61
|
+
if (isGitUrl(target)) out(dim(` cloning ${target}…`));
|
|
62
|
+
repo = await openRepoOrClone(target, { onProgress: progressReporter(out) });
|
|
56
63
|
} catch (e) {
|
|
57
64
|
err(' ' + bad((e.message || String(e))));
|
|
58
65
|
err(dim(' Point it at a folder: onboarder explore /path/to/repo'));
|
|
66
|
+
err(dim(' …or a git URL: onboarder explore https://github.com/org/repo'));
|
|
59
67
|
return 1;
|
|
60
68
|
}
|
|
61
69
|
|
|
@@ -112,6 +120,7 @@ function session({ repo, flags, out, err, version }) {
|
|
|
112
120
|
help: (topic) => helpText(ctx, topic),
|
|
113
121
|
rescan: () => reload(ctx, ctx.repo.root),
|
|
114
122
|
loadRepo: (where) => reload(ctx, where),
|
|
123
|
+
github: () => askGithub(ctx),
|
|
115
124
|
web: () => startWeb(ctx),
|
|
116
125
|
};
|
|
117
126
|
|
|
@@ -121,14 +130,36 @@ function session({ repo, flags, out, err, version }) {
|
|
|
121
130
|
prompt: promptFor(repo),
|
|
122
131
|
terminal: true,
|
|
123
132
|
historySize: 200,
|
|
133
|
+
// The completer closes over the *current* repo rather than the one captured
|
|
134
|
+
// here, so after a `cd` it completes paths in the new repository. Reading
|
|
135
|
+
// `ctx.repo` at completion time is the whole trick.
|
|
136
|
+
completer: (line) => completer(ctx.repo)(line),
|
|
124
137
|
});
|
|
125
138
|
|
|
126
139
|
let queue = Promise.resolve();
|
|
127
140
|
let closed = false;
|
|
128
141
|
let interrupts = 0;
|
|
142
|
+
let onResize = null;
|
|
129
143
|
|
|
144
|
+
// One poisoned promise must not end the session. `handleLine` catches its own
|
|
145
|
+
// errors, but anything thrown outside it — a prompt write to a closed stream,
|
|
146
|
+
// an error in a command's argument handling — would otherwise reject this
|
|
147
|
+
// chain and silently swallow every command typed afterwards. The session would
|
|
148
|
+
// look alive and do nothing, which is the worst failure mode a REPL has.
|
|
130
149
|
rl.on('line', (line) => {
|
|
131
|
-
queue = queue
|
|
150
|
+
queue = queue
|
|
151
|
+
.then(() => handleLine(ctx, line, rl, out, err))
|
|
152
|
+
.catch((e) => {
|
|
153
|
+
err(' ' + bad((e?.message || String(e))));
|
|
154
|
+
if (!closed) {
|
|
155
|
+
try {
|
|
156
|
+
rl.setPrompt(promptFor(ctx.repo));
|
|
157
|
+
rl.prompt();
|
|
158
|
+
} catch {
|
|
159
|
+
// The stream is gone; the close handler finishes the session.
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
});
|
|
132
163
|
});
|
|
133
164
|
|
|
134
165
|
// Ctrl-D on an empty line is the universal "I'm done". On a line with text,
|
|
@@ -140,7 +171,9 @@ function session({ repo, flags, out, err, version }) {
|
|
|
140
171
|
// its input is followed immediately by `exit`. The exit waits on the queue.
|
|
141
172
|
rl.on('close', () => {
|
|
142
173
|
closed = true;
|
|
143
|
-
|
|
174
|
+
// The listener is removed on every exit path, including the interval one
|
|
175
|
+
// below, so a session that ends by any route leaves stdout as it found it.
|
|
176
|
+
if (onResize) process.stdout.off('resize', onResize);
|
|
144
177
|
});
|
|
145
178
|
|
|
146
179
|
// Ctrl-C twice leaves. Once clears the line, which is what readline already
|
|
@@ -158,7 +191,7 @@ function session({ repo, flags, out, err, version }) {
|
|
|
158
191
|
});
|
|
159
192
|
|
|
160
193
|
// A resize redraws rather than leaving the prompt stranded mid-wrap.
|
|
161
|
-
|
|
194
|
+
onResize = () => {
|
|
162
195
|
if (!closed) rl.write(null, { ctrl: true, name: 'l' });
|
|
163
196
|
};
|
|
164
197
|
process.stdout.on('resize', onResize);
|
|
@@ -174,6 +207,11 @@ function session({ repo, flags, out, err, version }) {
|
|
|
174
207
|
queue.then(() => {
|
|
175
208
|
out('');
|
|
176
209
|
out(dim(' bye.'));
|
|
210
|
+
// A clone this session made is a temp directory, and the session is the
|
|
211
|
+
// only thing that knows about it. Removing it last — after the last
|
|
212
|
+
// command has finished reading files out of it — is the only ordering
|
|
213
|
+
// that cannot pull the ground out from under a command still running.
|
|
214
|
+
closeRemote(ctx.repo).catch(() => {});
|
|
177
215
|
resolve(0);
|
|
178
216
|
});
|
|
179
217
|
}, 20);
|
|
@@ -191,6 +229,18 @@ async function handleLine(ctx, line, rl, out, err) {
|
|
|
191
229
|
return;
|
|
192
230
|
}
|
|
193
231
|
const [name, ...args] = words;
|
|
232
|
+
|
|
233
|
+
// `!cmd` runs a shell command from inside the session and prints its output.
|
|
234
|
+
// It is the only way out to a shell, which is the point: the set of things
|
|
235
|
+
// that can happen here stays small enough to remember.
|
|
236
|
+
if (name.startsWith('!') && name.length > 1) {
|
|
237
|
+
const result = await shellEscape(line.replace(/^!\s*/, ''), ctx.repo.root);
|
|
238
|
+
if (result) out(result);
|
|
239
|
+
rl.setPrompt(promptFor(ctx.repo));
|
|
240
|
+
rl.prompt();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
194
244
|
const cmd = lookup(name);
|
|
195
245
|
|
|
196
246
|
if (!cmd) {
|
|
@@ -227,12 +277,51 @@ async function handleLine(ctx, line, rl, out, err) {
|
|
|
227
277
|
// so the new name reaches the prompt only after it succeeds.
|
|
228
278
|
async function reload(ctx, where) {
|
|
229
279
|
const target = String(where || '').trim();
|
|
230
|
-
if (!target)
|
|
231
|
-
|
|
280
|
+
if (!target) {
|
|
281
|
+
return ' cd needs a folder or a URL — `cd ../other-repo`, `cd https://github.com/org/repo`.';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// `rescan` re-reads the folder the session is already in. Re-cloning the URL
|
|
285
|
+
// to get the same bytes back would be slow and would change the temp
|
|
286
|
+
// directory out from under the session, so a target that resolves to the
|
|
287
|
+
// current root takes the local path even when the repo remembers a URL.
|
|
288
|
+
const reloadingCurrent = !isGitUrl(target) && path.resolve(expandHome(target)) === path.resolve(ctx.repo.root);
|
|
289
|
+
const previous = ctx.repo;
|
|
290
|
+
const next = reloadingCurrent
|
|
291
|
+
? await openRepo(previous.root)
|
|
292
|
+
: await openRepoOrClone(target, { onProgress: () => {} });
|
|
293
|
+
|
|
294
|
+
// A reload of the current folder produces a repo that has never heard of the
|
|
295
|
+
// URL it was cloned from: `openRepo` reads a directory, and a directory does
|
|
296
|
+
// not know where it came from. Carrying the three fields across is what keeps
|
|
297
|
+
// `rescan` from silently demoting a clone to a nameless temp folder — the
|
|
298
|
+
// prompt, `about` and `github` all read them.
|
|
299
|
+
if (reloadingCurrent) {
|
|
300
|
+
next.gitUrl = previous.gitUrl;
|
|
301
|
+
next.cloneDir = previous.cloneDir;
|
|
302
|
+
next.tempId = previous.tempId;
|
|
303
|
+
if (previous.name !== next.name && !previous.cloneDir) next.name = previous.name;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// The old clone is only dropped once the new one has loaded. Doing it the
|
|
307
|
+
// other way round means a typo in a URL leaves you with nothing loaded *and*
|
|
308
|
+
// the repo you were reading deleted from under you.
|
|
309
|
+
if (!reloadingCurrent) await closeRemote(previous);
|
|
232
310
|
ctx.repo = next;
|
|
233
311
|
return '\n' + overview(next);
|
|
234
312
|
}
|
|
235
313
|
|
|
314
|
+
// Ask GitHub about the loaded repo and print the answer. Network failures are
|
|
315
|
+
// the expected case, not the exception — this is a command someone types on a
|
|
316
|
+
// plane — so every one of them comes back as a line of text.
|
|
317
|
+
async function askGithub(ctx) {
|
|
318
|
+
const url = await remoteUrlFor(ctx.repo);
|
|
319
|
+
if (!url) {
|
|
320
|
+
return githubView(ctx.repo, { ok: false, reason: 'This folder has no GitHub remote — there is nothing to ask about.' });
|
|
321
|
+
}
|
|
322
|
+
return githubView(ctx.repo, await fetchRepoFacts(url));
|
|
323
|
+
}
|
|
324
|
+
|
|
236
325
|
// The bridge between the two surfaces. If the server is already up this just
|
|
237
326
|
// prints where it is; if not, it starts it detached, so the person keeps their
|
|
238
327
|
// session. The URL is the same one `onboarder start` would print, because it
|
|
@@ -244,12 +333,19 @@ async function startWeb(ctx) {
|
|
|
244
333
|
const recorded = readPidFile(file);
|
|
245
334
|
|
|
246
335
|
if (recorded && pidIsAlive(recorded)) {
|
|
247
|
-
return [' ' + ok('Already running.') + dim(` PID ${recorded
|
|
336
|
+
return [' ' + ok('Already running.') + dim(` PID ${recorded}`), ' ' + cyan(urls.local)].join('\n');
|
|
248
337
|
}
|
|
249
338
|
|
|
250
339
|
const { runStartBackground } = await import('../commands.js');
|
|
251
340
|
const code = await runStartBackground({
|
|
252
|
-
|
|
341
|
+
// `nonInteractive` is not optional here. With no config on disk,
|
|
342
|
+
// `runStartBackground` hands off to `runSetup`, which opens its own readline
|
|
343
|
+
// on the same stdin this session is already reading — the wizard and the
|
|
344
|
+
// explorer would then compete for keystrokes, and the explorer could process
|
|
345
|
+
// a wizard answer as a command. Inside a session the server either starts
|
|
346
|
+
// from the config that exists or reports that there is none; the person can
|
|
347
|
+
// run `onboarder setup` deliberately if they want the wizard.
|
|
348
|
+
flags: { ...ctx.flags, json: true, nonInteractive: true },
|
|
253
349
|
out: () => {},
|
|
254
350
|
err: () => {},
|
|
255
351
|
});
|
package/cli/explorer/commands.js
CHANGED
|
@@ -11,6 +11,14 @@
|
|
|
11
11
|
// handled once, in the tokenizer, and every command sees the same shape.
|
|
12
12
|
|
|
13
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);
|
|
14
22
|
|
|
15
23
|
export const EXIT = Symbol('exit');
|
|
16
24
|
export const CLEAR = Symbol('clear');
|
|
@@ -18,8 +26,10 @@ export const CLEAR = Symbol('clear');
|
|
|
18
26
|
export const COMMANDS = [
|
|
19
27
|
{
|
|
20
28
|
name: 'help', aliases: ['?'], group: 'basics',
|
|
21
|
-
usage: 'help', summary: 'This list.',
|
|
22
|
-
|
|
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(' ')),
|
|
23
33
|
},
|
|
24
34
|
{
|
|
25
35
|
name: 'map', aliases: ['overview', 'home'], group: 'basics',
|
|
@@ -42,10 +52,12 @@ export const COMMANDS = [
|
|
|
42
52
|
run: (ctx, args) => {
|
|
43
53
|
// A lone number is the depth, not a folder called "2". `tree 3` is what
|
|
44
54
|
// everyone types when they want to see more; making them spell
|
|
45
|
-
// `tree . 3` would be pedantry in a tool built to be forgiving.
|
|
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.
|
|
46
57
|
const onlyDepth = args.length === 1 && /^\d+$/.test(args[0]);
|
|
58
|
+
const sub = onlyDepth ? '' : (args[0] || '');
|
|
47
59
|
return V.tree(ctx.repo, {
|
|
48
|
-
sub:
|
|
60
|
+
sub: sub === '.' ? '' : sub,
|
|
49
61
|
depth: onlyDepth ? Number(args[0]) : (Number(args[1]) || 2),
|
|
50
62
|
});
|
|
51
63
|
},
|
|
@@ -58,10 +70,14 @@ export const COMMANDS = [
|
|
|
58
70
|
{
|
|
59
71
|
name: 'show', aliases: ['open', 'cat', 'read'], group: 'navigate',
|
|
60
72
|
usage: 'show <file> [from] [count]', summary: 'Read a file. Name it loosely: `show logger.js` works.',
|
|
61
|
-
run: (ctx, args) => V.show(ctx.repo, {
|
|
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
|
+
}),
|
|
62
78
|
},
|
|
63
79
|
{
|
|
64
|
-
name: 'deps', aliases: ['connections'
|
|
80
|
+
name: 'deps', aliases: ['connections'], group: 'navigate',
|
|
65
81
|
usage: 'deps <file>', summary: 'What a file imports, and what imports it.',
|
|
66
82
|
run: (ctx, args) => V.deps(ctx.repo, { target: args.join(' ') }),
|
|
67
83
|
},
|
|
@@ -110,6 +126,75 @@ export const COMMANDS = [
|
|
|
110
126
|
usage: 'externals', summary: 'External packages, plus dependency drift.',
|
|
111
127
|
run: (ctx) => V.externals(ctx.repo),
|
|
112
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
|
+
},
|
|
113
198
|
{
|
|
114
199
|
name: 'about', aliases: ['version'], group: 'session',
|
|
115
200
|
usage: 'about', summary: 'Version, repo path, and what is indexed.',
|
|
@@ -120,9 +205,14 @@ export const COMMANDS = [
|
|
|
120
205
|
usage: 'rescan', summary: 'Re-read the repo from disk.',
|
|
121
206
|
run: (ctx) => ctx.rescan(),
|
|
122
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
|
+
},
|
|
123
213
|
{
|
|
124
214
|
name: 'cd', aliases: ['open-repo', 'use'], group: 'session',
|
|
125
|
-
usage: 'cd <folder>', summary: 'Load
|
|
215
|
+
usage: 'cd <folder|url>', summary: 'Load another repository, or clone one by URL.',
|
|
126
216
|
run: (ctx, args) => ctx.loadRepo(args.join(' ')),
|
|
127
217
|
},
|
|
128
218
|
{
|
|
@@ -161,27 +251,41 @@ export function commandNames() {
|
|
|
161
251
|
}
|
|
162
252
|
|
|
163
253
|
// `help <command>` answers about one command; bare `help` lists them, grouped so
|
|
164
|
-
// the shape of the tool is visible rather than alphabetical.
|
|
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.
|
|
165
257
|
export function helpText(ctx, topic = '') {
|
|
258
|
+
const w = termWidth();
|
|
166
259
|
if (topic) {
|
|
167
260
|
const cmd = lookup(topic);
|
|
168
|
-
if (!cmd) return ` No command called "${topic}". Try \`help
|
|
261
|
+
if (!cmd) return fit(` No command called "${topic}". Try \`help\`.`, Math.max(10, w - 2));
|
|
169
262
|
const also = cmd.aliases.length ? ` (also: ${cmd.aliases.join(', ')})` : '';
|
|
170
|
-
return [
|
|
263
|
+
return [
|
|
264
|
+
fit(' ' + cmd.usage + also, Math.max(10, w - 2)),
|
|
265
|
+
wrapText(cmd.summary, ' '),
|
|
266
|
+
].join('\n');
|
|
171
267
|
}
|
|
172
268
|
const groups = new Map();
|
|
173
269
|
for (const cmd of COMMANDS) {
|
|
174
270
|
if (!groups.has(cmd.group)) groups.set(cmd.group, []);
|
|
175
271
|
groups.get(cmd.group).push(cmd);
|
|
176
272
|
}
|
|
177
|
-
|
|
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)));
|
|
178
277
|
const out = [];
|
|
179
278
|
for (const [group, list] of groups) {
|
|
180
|
-
out.push(' ' + group.toUpperCase());
|
|
181
|
-
for (const c of list)
|
|
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
|
+
}
|
|
182
285
|
}
|
|
183
286
|
out.push('');
|
|
184
|
-
out.push(' ' + (ctx?.repo ? ctx.repo.name : 'onboarder')
|
|
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)));
|
|
185
289
|
return out.join('\n');
|
|
186
290
|
}
|
|
187
291
|
|
|
@@ -195,3 +299,71 @@ export function tokenize(line) {
|
|
|
195
299
|
while ((m = re.exec(String(line || '')))) out.push(m[1] ?? m[2] ?? m[3]);
|
|
196
300
|
return out;
|
|
197
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
|
+
}
|