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.
@@ -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 { CLEAR, EXIT, commandNames, helpText, lookup, tokenize } from './commands.js';
24
- import { overview } from './views.js';
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
- repo = await openRepo(target, { onProgress: progressReporter(out) });
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.then(() => handleLine(ctx, line, rl, out, err));
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
- process.stdout.off('resize', onResize);
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
- const onResize = () => {
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) return ' cd needs a folder — `cd ../other-repo`.';
231
- const next = await openRepo(target, { onProgress: () => {} });
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.pid}`), ' ' + cyan(urls.local)].join('\n');
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
- flags: { ...ctx.flags, json: true },
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
  });
@@ -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
- run: (ctx) => ctx.help(),
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: onlyDepth ? '' : (args[0] || ''),
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, { target: args[0] || '', from: Number(args[1]) || 0, count: Number(args[2]) || 0 }),
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', 'graph'], group: 'navigate',
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 a different repository.',
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 [' ' + cmd.usage + also, ' ' + cmd.summary].join('\n');
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
- const room = Math.max(...COMMANDS.map((c) => c.usage.length));
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) out.push(' ' + c.usage.padEnd(room) + ' ' + c.summary);
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') + ' · Ctrl-D or `exit` to leave · Ctrl-C twice to quit');
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
+ }