github-search-cli 9.10.0 → 9.10.2

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/src/cli.ts CHANGED
@@ -25,6 +25,8 @@ import { reportError } from "./error-report";
25
25
  import {
26
26
  format as formatOutput,
27
27
  pipeExec,
28
+ FORMAT_VALUES,
29
+ PIPE_TARGETS,
28
30
  type Format,
29
31
  type ExportFormat,
30
32
  } from "./output";
@@ -104,6 +106,8 @@ interface CLIFlags {
104
106
  token?: string;
105
107
  trending: boolean;
106
108
  since: string;
109
+ /** True when --since was passed (watch falls back to the query-string convention otherwise). */
110
+ sinceExplicit: boolean;
107
111
  pipe?: string;
108
112
  format?: string;
109
113
  watch: boolean;
@@ -156,6 +160,9 @@ const SORT_VALUES = [
156
160
  "name",
157
161
  ];
158
162
 
163
+ /** Valid `--since` values — the periods github.com/trending accepts. */
164
+ const SINCE_VALUES: readonly string[] = ["daily", "weekly", "monthly"];
165
+
159
166
  function parseArgs(args: string[]): CLIFlags {
160
167
  const flags: CLIFlags = {
161
168
  query: "",
@@ -168,6 +175,7 @@ function parseArgs(args: string[]): CLIFlags {
168
175
  token: process.env.GITHUB_TOKEN,
169
176
  trending: false,
170
177
  since: "daily",
178
+ sinceExplicit: false,
171
179
  watch: false,
172
180
  releases: false,
173
181
  interval: 300,
@@ -276,15 +284,51 @@ function parseArgs(args: string[]): CLIFlags {
276
284
  case "--token":
277
285
  flags.token = args[++i];
278
286
  break;
279
- case "--since":
280
- flags.since = args[++i];
287
+ case "--interval": {
288
+ const parsed = parseInt(args[++i], 10);
289
+ if (Number.isNaN(parsed) || parsed < 1) {
290
+ console.error(
291
+ `Invalid interval: ${args[i] ?? "(missing)"} (must be a whole number of seconds, e.g. 300).`,
292
+ );
293
+ process.exit(1);
294
+ }
295
+ flags.interval = parsed;
281
296
  break;
282
- case "--pipe":
283
- flags.pipe = args[++i];
297
+ }
298
+ case "--since": {
299
+ const since = args[++i];
300
+ if (!since || !SINCE_VALUES.includes(since)) {
301
+ console.error(
302
+ `Invalid since: ${since ?? "(missing)"} (use daily|weekly|monthly).`,
303
+ );
304
+ process.exit(1);
305
+ }
306
+ flags.since = since;
307
+ flags.sinceExplicit = true;
284
308
  break;
285
- case "--format":
286
- flags.format = args[++i];
309
+ }
310
+ case "--pipe": {
311
+ const target = args[++i];
312
+ if (!target || !PIPE_TARGETS.includes(target)) {
313
+ console.error(
314
+ `Invalid pipe target: ${target ?? "(missing)"} (use open|clone).`,
315
+ );
316
+ process.exit(1);
317
+ }
318
+ flags.pipe = target;
319
+ break;
320
+ }
321
+ case "--format": {
322
+ const fmt = args[++i];
323
+ if (!fmt || !FORMAT_VALUES.includes(fmt)) {
324
+ console.error(
325
+ `Invalid format: ${fmt ?? "(missing)"} (use ${FORMAT_VALUES.join("|")}).`,
326
+ );
327
+ process.exit(1);
328
+ }
329
+ flags.format = fmt;
287
330
  break;
331
+ }
288
332
  case "--registry":
289
333
  flags.registry = args[++i] ?? "npm";
290
334
  break;
@@ -636,7 +680,13 @@ Options:
636
680
  return;
637
681
  }
638
682
 
639
- await launchBrowser(flags.theme);
683
+ await launchBrowser(
684
+ flags.theme,
685
+ // `ghfind --trending` with no query/format promises the trending view
686
+ // (TRACKER.md), but the bare flag used to fall through to the landing
687
+ // menu. An explicit flag is explicit intent — skip the menu.
688
+ flags.trending ? "trending" : undefined,
689
+ );
640
690
  }
641
691
 
642
692
  interface SearchContext {
@@ -1197,6 +1247,7 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
1197
1247
  token: flags.token,
1198
1248
  intervalMs: flags.interval * 1000,
1199
1249
  trending: flags.trending,
1250
+ since: flags.sinceExplicit ? flags.since : undefined,
1200
1251
  },
1201
1252
  (repos, delta) => {
1202
1253
  tick++;
@@ -1298,6 +1349,25 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
1298
1349
  ? "format"
1299
1350
  : "search";
1300
1351
 
1352
+ // The search/format/pipe/watch handlers all need a query. Without this
1353
+ // gate an empty query was sent to GitHub as `q=" "`, which returns 422 and
1354
+ // surfaced to the user as a misleading "Network error — check your
1355
+ // connection" retry loop.
1356
+ const needsQuery =
1357
+ key === "search" ||
1358
+ key === "format" ||
1359
+ key === "pipe" ||
1360
+ (key === "watch" && !flags.trending);
1361
+ if (needsQuery && flags.query.trim() === "") {
1362
+ console.error("⚠ No search query given.");
1363
+ console.error(
1364
+ ' Usage: ghfind "<query>" [--count|--format <fmt>|--pipe <tgt>|--watch]',
1365
+ );
1366
+ console.error(' Example: ghfind "language:Rust stars:>500" --count');
1367
+ console.error(" Run `ghfind --help` for all options.");
1368
+ process.exit(1);
1369
+ }
1370
+
1301
1371
  const ctx = buildSearchContext(flags);
1302
1372
  await handlers[key](ctx);
1303
1373
  }
package/src/history.ts CHANGED
@@ -29,6 +29,10 @@ export function readHistory(): HistoryEntry[] {
29
29
 
30
30
  /** Delete a single history entry by index (0 = newest). */
31
31
  export function deleteHistoryEntry(index: number): void {
32
+ // The index comes from the overlay, which lists readHistory()'s deduplicated
33
+ // newest-first view — so delete against that same view. Raw lines can't be
34
+ // addressed by this index: consecutive duplicate queries are collapsed in
35
+ // the view, which shifts every raw-line position after them.
32
36
  const entries = readHistory();
33
37
  if (index < 0 || index >= entries.length) return;
34
38
  entries.splice(index, 1);
@@ -59,8 +63,3 @@ export function rotateHistory(): void {
59
63
  .join("\n")}\n`,
60
64
  );
61
65
  }
62
-
63
- /** Count entries in history file. */
64
- export function historyCount(): number {
65
- return readJSONL<HistoryEntry>(HISTORY_FILE).length;
66
- }
package/src/mcp-server.ts CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  type PackageSortMode,
39
39
  } from "./package";
40
40
  import { fetchOrgProfile, formatOrgText } from "./org";
41
- import { fetchUserProfile, formatUserJson } from "./user";
41
+ import { fetchUserProfile, formatUserText } from "./user";
42
42
  import {
43
43
  fetchDeepDive,
44
44
  buildDeepDiveText,
@@ -511,7 +511,9 @@ const TOOLS: ToolDefinition[] = [
511
511
  token: resolveToken(state, args.token),
512
512
  limit,
513
513
  });
514
- return { text: formatUserJson(profile), data: { ...profile } };
514
+ // Human-readable text like ghfind_org_profile (the structured data
515
+ // still rides along in `data` for callers that want the raw numbers).
516
+ return { text: formatUserText(profile), data: { ...profile } };
515
517
  },
516
518
  },
517
519
  {
package/src/output.ts CHANGED
@@ -22,8 +22,11 @@ export type Format =
22
22
 
23
23
  export type FormatFn = (repos: Repo[]) => string;
24
24
 
25
+ /** Accepted `--pipe` targets — the cases `pipeExec` actually handles. */
26
+ export const PIPE_TARGETS: readonly string[] = ["open", "clone"];
27
+
25
28
  /** Registry of all format functions. Adding a format = 1 line here. */
26
- const FORMATS: Map<Format, FormatFn> = new Map([
29
+ const FORMATS: Map<Format, FormatFn> = new Map<Format, FormatFn>([
27
30
  // Export formats
28
31
  ["json", formatJson],
29
32
  ["csv", formatCsv],
@@ -37,6 +40,13 @@ const FORMATS: Map<Format, FormatFn> = new Map([
37
40
  ["ids", formatIds],
38
41
  ]);
39
42
 
43
+ /**
44
+ * Every accepted `--format` value, derived from the registry above so the CLI
45
+ * can validate up front instead of throwing `Unknown format` mid-run (a plain
46
+ * typo used to route into the crash-report flow).
47
+ */
48
+ export const FORMAT_VALUES: readonly string[] = [...FORMATS.keys()];
49
+
40
50
  // ── Export format functions ────────────────────────────────────────────
41
51
 
42
52
  export function formatJson(repos: Repo[]): string {
@@ -265,6 +265,8 @@ export async function launchPackageBrowser(): Promise<void> {
265
265
  renderer.keyInput.on("keypress", (key: import("@opentui/core").KeyEvent) => {
266
266
  const name = key.name || "";
267
267
  if (name === "q" || name === "escape") {
268
+ // Release the terminal before the hard exit, same as the main TUI.
269
+ renderer.destroy();
268
270
  process.exit(0);
269
271
  return;
270
272
  }
package/src/status.ts CHANGED
@@ -12,12 +12,16 @@ export class StatusManager {
12
12
  private type: StatusType = "idle";
13
13
  private message = "";
14
14
  private onUpdate: (text: string) => void;
15
+ private disposed = false;
15
16
 
16
17
  constructor(onUpdate: (text: string) => void) {
17
18
  this.onUpdate = onUpdate;
18
19
  }
19
20
 
20
21
  set(type: StatusType, message: string): void {
22
+ // Late async continuations (a search resolving after quit) can call
23
+ // set() long after the UI is gone; a disposed manager is inert.
24
+ if (this.disposed) return;
21
25
  this.stopSpinner();
22
26
  this.type = type;
23
27
  this.message = message;
@@ -32,6 +36,7 @@ export class StatusManager {
32
36
  }
33
37
 
34
38
  private startSpinner(frames: string[]): void {
39
+ if (this.disposed) return;
35
40
  this.spinnerIndex = 0;
36
41
  this.emit(frames[0]);
37
42
  this.spinnerInterval = setInterval(() => {
@@ -48,6 +53,8 @@ export class StatusManager {
48
53
  }
49
54
 
50
55
  private emit(spinner?: string): void {
56
+ // A tick can already be queued when dispose() clears the interval.
57
+ if (this.disposed) return;
51
58
  const icon =
52
59
  this.type === "searching"
53
60
  ? "🔍"
@@ -63,6 +70,39 @@ export class StatusManager {
63
70
  }
64
71
 
65
72
  dispose(): void {
73
+ this.disposed = true;
66
74
  this.stopSpinner();
67
75
  }
68
76
  }
77
+
78
+ /**
79
+ * The status-bar renderable, narrowed to what the manager needs. TextRenderable
80
+ * satisfies this structurally (isDestroyed is a public getter on Renderable).
81
+ */
82
+ export interface StatusBarTarget {
83
+ readonly isDestroyed: boolean;
84
+ // Accessor pair, not `content: string`: TextRenderable's getter widens to
85
+ // StyledText while its setter accepts a plain string. `unknown` on the read
86
+ // side keeps the renderable assignable; we only ever write strings.
87
+ get content(): unknown;
88
+ set content(value: string);
89
+ }
90
+
91
+ /**
92
+ * Build a StatusManager wired to a status bar, plus a hook to request a frame.
93
+ *
94
+ * Writes are dropped once the renderable is destroyed. The renderer tears its
95
+ * tree down on Ctrl+C / SIGTERM / terminal close but leaves the event loop
96
+ * running, so an unguarded spinner tick would assign to a destroyed TextBuffer
97
+ * and throw out of a timer callback, taking the whole process with it.
98
+ */
99
+ export function createStatusManager(
100
+ bar: StatusBarTarget,
101
+ onFrame: () => void,
102
+ ): StatusManager {
103
+ return new StatusManager((text) => {
104
+ if (bar.isDestroyed) return;
105
+ bar.content = ` ${text}`;
106
+ onFrame();
107
+ });
108
+ }
package/src/trending.ts CHANGED
@@ -398,7 +398,7 @@ export async function launchTrending(): Promise<void> {
398
398
  paddingX: 1,
399
399
  });
400
400
  const footerText = new TextRenderable(renderer, {
401
- content: " ↑↓ navigate ↵ open / search 1-3 tab q quit r refresh",
401
+ content: " ↑↓ navigate ↵ open / top 1-3 tab q quit r refresh",
402
402
  fg: C.muted,
403
403
  height: 1,
404
404
  });
@@ -430,6 +430,8 @@ export async function launchTrending(): Promise<void> {
430
430
  renderer.keyInput.on("keypress", (key) => {
431
431
  const name = key.name || "";
432
432
  if (name === "q") {
433
+ // Release the terminal before the hard exit, same as the main TUI.
434
+ renderer.destroy();
433
435
  process.exit(0);
434
436
  return;
435
437
  }
package/src/tui.ts CHANGED
@@ -108,7 +108,7 @@ import {
108
108
  PACKAGE_SORT_MODES,
109
109
  type PackageSortMode,
110
110
  } from "./package";
111
- import { StatusManager } from "./status";
111
+ import { type StatusManager, createStatusManager } from "./status";
112
112
  import {
113
113
  getNotifications,
114
114
  dismissNotification,
@@ -199,6 +199,15 @@ function createTuiLogger(): Logger {
199
199
 
200
200
  const logger: Logger = createTuiLogger();
201
201
 
202
+ // The live StatusManager for the current renderer. Kept at module scope so the
203
+ // hoisted cleanup() can dispose the spinner interval on the quit path; a stale
204
+ // interval would otherwise outlive the destroyed renderable it writes to.
205
+ let activeStatusMgr: StatusManager | null = null;
206
+ // The live renderer, so the hoisted cleanup() can release the terminal
207
+ // (raw mode, alternate screen) before process.exit() — same reasoning as
208
+ // activeStatusMgr above.
209
+ let activeRenderer: CliRenderer | null = null;
210
+
202
211
  // ─── Sort cycle ───────────────────────────────────────────────────────
203
212
  const SORT_MODES: { key: SortStrategy; label: string }[] = [
204
213
  { key: "best-match", label: "best-match" },
@@ -208,7 +217,10 @@ const SORT_MODES: { key: SortStrategy; label: string }[] = [
208
217
  ];
209
218
 
210
219
  // ─── Main ─────────────────────────────────────────────────────────────
211
- export async function launchBrowser(theme_override?: string): Promise<void> {
220
+ export async function launchBrowser(
221
+ theme_override?: string,
222
+ startMode?: "trending",
223
+ ): Promise<void> {
212
224
  // Ask the terminal for its real background color (OSC 11) BEFORE the
213
225
  // renderer takes over stdin — after that, reading the reply gets messy.
214
226
  // Used below so the app backdrop matches the user's terminal theme.
@@ -247,6 +259,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
247
259
  }
248
260
 
249
261
  const root = renderer.root;
262
+ activeRenderer = renderer;
250
263
  root.flexDirection = "column";
251
264
 
252
265
  // ── Config ──
@@ -692,6 +705,10 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
692
705
  leaderBox.visible = false;
693
706
  leaderDim.visible = false;
694
707
  readmeBox.visible = false;
708
+ // The org panel is a flexGrow sibling of `body` just like every other
709
+ // overlay — omitting it here meant it stayed visible after Esc, permanently
710
+ // stealing half the content height and every click in the lower half.
711
+ orgBox.visible = false;
695
712
  updateBox.visible = false;
696
713
  updateDim.visible = false;
697
714
  if (type === "none") {
@@ -1929,6 +1946,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1929
1946
  },
1930
1947
  });
1931
1948
  const orgText = new TextRenderable(renderer, {
1949
+ ...NON_INTERACTIVE_TEXT,
1932
1950
  content: "",
1933
1951
  fg: colors.text,
1934
1952
  bg: colors.bg,
@@ -1936,6 +1954,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1936
1954
  orgScroll.add(orgText);
1937
1955
  orgBox.add(orgScroll);
1938
1956
  const orgFooter = new TextRenderable(renderer, {
1957
+ ...NON_INTERACTIVE_TEXT,
1939
1958
  content: " ↑↓/jk scroll Esc/q close",
1940
1959
  fg: colors.muted,
1941
1960
  bg: colors.bg,
@@ -2130,10 +2149,16 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
2130
2149
  renderer.requestRender();
2131
2150
  };
2132
2151
 
2133
- const statusMgr = new StatusManager((text) => {
2134
- statusBar.content = ` ${text}`;
2135
- renderer.requestRender();
2136
- });
2152
+ // Held at module scope so cleanup() (a hoisted function declaration, called
2153
+ // from key handlers defined above the manager's creation) can still reach it.
2154
+ activeStatusMgr = createStatusManager(statusBar, () =>
2155
+ renderer.requestRender(),
2156
+ );
2157
+ const statusMgr = activeStatusMgr;
2158
+ // The renderer tears down its renderables on Ctrl+C / SIGTERM / terminal
2159
+ // close, but the 120ms spinner interval is ours. Without this, the next tick
2160
+ // assigns to a destroyed TextBuffer and throws out of a timer callback.
2161
+ renderer.on("destroy", () => statusMgr.dispose());
2137
2162
  setTimeout(() => {
2138
2163
  if (currentOverlay === "none") {
2139
2164
  setStatus(nextTip());
@@ -3433,6 +3458,20 @@ ${pack.description ?? ""}`;
3433
3458
  showOverlay("help");
3434
3459
  return;
3435
3460
  }
3461
+ // Ctrl+E opens the export chooser (CSV / Markdown / JSON / plain text)
3462
+ // straight from the results — the shortcut the startup tip advertises.
3463
+ // Claim the chord first: while the query input is focused its default
3464
+ // emacs binding would otherwise also treat Ctrl+E as "move to line end".
3465
+ if (key.ctrl && key.name === "e") {
3466
+ key.stopPropagation();
3467
+ if (currentRepos.length > 0) {
3468
+ showOverlay("export");
3469
+ } else {
3470
+ setStatus("No results to export — run a search first");
3471
+ }
3472
+ renderer.requestRender();
3473
+ return;
3474
+ }
3436
3475
 
3437
3476
  // Tab auto-completes qualifiers in search input
3438
3477
  if (key.name === "tab") {
@@ -3518,12 +3557,18 @@ ${pack.description ?? ""}`;
3518
3557
  // ── Update check (before start) ──────────────────────────────────────
3519
3558
  const _currentVersion = getVersion();
3520
3559
  // ── Start ──────────────────────────────────────────────────────────
3521
- // Always start on the landing menu. Saved sessions restore preferences
3560
+ // Default is the landing menu. Saved sessions restore preferences
3522
3561
  // (sort/limit/trending tab) and pre-fill the query once a mode is picked,
3523
3562
  // but never auto-run a search — a stale or corrupted session (e.g. an MCP
3524
3563
  // handshake blob saved as a query) used to hijack startup and skip the
3525
- // menu on every launch.
3526
- showLanding(true);
3564
+ // menu on every launch. An explicit `ghfind --trending` is not a stale
3565
+ // session though: it is the mode the user asked for, so it skips the menu.
3566
+ if (startMode === "trending") {
3567
+ showLanding(false);
3568
+ loadTrending();
3569
+ } else {
3570
+ showLanding(true);
3571
+ }
3527
3572
  checkForUpdateAndShow();
3528
3573
  checkPostUpgradePanel();
3529
3574
  renderer.start();
@@ -3549,11 +3594,22 @@ function formatToolbar(
3549
3594
  }
3550
3595
 
3551
3596
  function cleanup(): void {
3597
+ // Stop the spinner before exiting: process.exit() would kill the process
3598
+ // anyway, but a tick already queued between here and the exit can still fire.
3599
+ activeStatusMgr?.dispose();
3600
+ activeStatusMgr = null;
3552
3601
  try {
3553
3602
  rotateHistory();
3554
3603
  } catch {
3555
3604
  /* non-critical */
3556
3605
  }
3606
+ // Release the terminal (raw mode, alternate screen) before the hard exit.
3607
+ try {
3608
+ activeRenderer?.destroy();
3609
+ } catch {
3610
+ /* non-critical */
3611
+ }
3612
+ activeRenderer = null;
3557
3613
  process.exit(0);
3558
3614
  }
3559
3615
 
package/src/watch.ts CHANGED
@@ -19,6 +19,23 @@ export interface WatchOptions {
19
19
  token?: string;
20
20
  intervalMs: number;
21
21
  trending?: boolean;
22
+ /** Trending period (`--since`); falls back to the query-string convention. */
23
+ since?: string;
24
+ }
25
+
26
+ /**
27
+ * Resolve the trending period for a watch tick. `--since` wins when it names a
28
+ * real period; otherwise fall back to the legacy `ghfind --watch weekly`
29
+ * convention where the query string itself carried the period.
30
+ */
31
+ export function resolveTrendingSince(
32
+ opts: Pick<WatchOptions, "query" | "since">,
33
+ ): "daily" | "weekly" | "monthly" {
34
+ if (opts.since === "weekly" || opts.since === "monthly") return opts.since;
35
+ if (opts.since === "daily") return "daily";
36
+ if (opts.query === "weekly") return "weekly";
37
+ if (opts.query === "monthly") return "monthly";
38
+ return "daily";
22
39
  }
23
40
 
24
41
  const MAX_FAILS = 5;
@@ -44,12 +61,7 @@ export async function runWatch(
44
61
  try {
45
62
  let repos: Repo[];
46
63
  if (opts.trending) {
47
- const since =
48
- opts.query === "weekly"
49
- ? "weekly"
50
- : opts.query === "monthly"
51
- ? "monthly"
52
- : "daily";
64
+ const since = resolveTrendingSince(opts);
53
65
  const trendingSearch = createTrendingSearch();
54
66
  const response = await trendingSearch.search(trendingQuery(), {
55
67
  limit: opts.limit,
package/src/export.ts DELETED
@@ -1,73 +0,0 @@
1
- /**
2
- * Export — format repos as JSON, CSV, Markdown, or plain text.
3
- */
4
- import { writeFileSync } from "node:fs";
5
- import type { Repo } from "./types";
6
-
7
- export type ExportFormat = "json" | "csv" | "markdown" | "text";
8
-
9
- export function formatJson(repos: Repo[]): string {
10
- return JSON.stringify(repos, null, 2);
11
- }
12
-
13
- function csvEscape(val: unknown): string {
14
- const str = String(val ?? "");
15
- if (str.includes('"') || str.includes(",") || str.includes("\n")) {
16
- return `"${str.replace(/"/g, '""')}"`;
17
- }
18
- return str;
19
- }
20
-
21
- /** Format repos as CSV string. */
22
- export function formatCsv(repos: Repo[]): string {
23
- const header = "rank,full_name,stars,forks,language,url";
24
- const rows = repos.map(
25
- (r, i) =>
26
- `${i + 1},${csvEscape(r.fullName)},${r.stars},${r.forks},${csvEscape(r.language ?? "")},${csvEscape(r.url)}`,
27
- );
28
- return [header, ...rows].join("\n");
29
- }
30
-
31
- /** Format repos as Markdown table. */
32
- export function formatMarkdown(repos: Repo[]): string {
33
- const header = "| # | Repo | Stars | Forks | Language |";
34
- const sep = "|---|------|-------|-------|----------|";
35
- const rows = repos.map(
36
- (r, i) =>
37
- `| ${i + 1} | [${r.fullName}](${r.url}) | ${r.stars.toLocaleString()} | ${r.forks.toLocaleString()} | ${r.language ?? "—"} |`,
38
- );
39
- return [header, sep, ...rows].join("\n");
40
- }
41
-
42
- /** Format repos as plain text, one per line. */
43
- export function formatText(repos: Repo[]): string {
44
- return repos
45
- .map(
46
- (r, i) =>
47
- `${i + 1}. ${r.fullName} ★ ${r.stars.toLocaleString()} ${r.url}`,
48
- )
49
- .join("\n");
50
- }
51
-
52
- /** Format repos in the given format. */
53
- export function formatRepos(repos: Repo[], format: ExportFormat): string {
54
- switch (format) {
55
- case "json":
56
- return formatJson(repos);
57
- case "csv":
58
- return formatCsv(repos);
59
- case "markdown":
60
- return formatMarkdown(repos);
61
- case "text":
62
- return formatText(repos);
63
- }
64
- }
65
-
66
- /** Export repos to a file. Returns the file path. */
67
- export function exportToFile(repos: Repo[], format: ExportFormat): string {
68
- const ext = format === "markdown" ? "md" : format;
69
- const ts = Date.now();
70
- const path = `./ghfind-export-${ts}.${ext}`;
71
- writeFileSync(path, formatRepos(repos, format), "utf-8");
72
- return path;
73
- }
package/src/pipe.ts DELETED
@@ -1,60 +0,0 @@
1
- /**
2
- * Pipe-friendly format modifiers — output repo data as lines for shell piping.
3
- */
4
- import type { Repo } from "./types";
5
- import { openUrl } from "./open-url";
6
-
7
- export type FormatLine =
8
- "urls" | "names" | "ssh-urls" | "clone-commands" | "ids";
9
-
10
- export function formatUrls(repos: Repo[]): string {
11
- return repos.map((r) => r.url).join("\n");
12
- }
13
-
14
- export function formatNames(repos: Repo[]): string {
15
- return repos.map((r) => r.fullName).join("\n");
16
- }
17
-
18
- export function formatSshUrls(repos: Repo[]): string {
19
- return repos.map((r) => `git@github.com:${r.fullName}.git`).join("\n");
20
- }
21
-
22
- export function formatCloneCommands(repos: Repo[]): string {
23
- return repos
24
- .map((r) => `git clone git@github.com:${r.fullName}.git`)
25
- .join("\n");
26
- }
27
-
28
- export function formatIds(repos: Repo[]): string {
29
- return repos.map((r) => String(r.id)).join("\n");
30
- }
31
-
32
- export function formatLines(repos: Repo[], format: FormatLine): string {
33
- switch (format) {
34
- case "urls":
35
- return formatUrls(repos);
36
- case "names":
37
- return formatNames(repos);
38
- case "ssh-urls":
39
- return formatSshUrls(repos);
40
- case "clone-commands":
41
- return formatCloneCommands(repos);
42
- case "ids":
43
- return formatIds(repos);
44
- }
45
- }
46
-
47
- /** Execute a pipe target (clone, open) on repos. */
48
- export async function pipeExec(repos: Repo[], target: string): Promise<void> {
49
- for (const repo of repos) {
50
- const url = repo.url;
51
- switch (target) {
52
- case "open":
53
- await openUrl(url);
54
- break;
55
- case "clone":
56
- console.log(`git clone git@github.com:${repo.fullName}.git`);
57
- break;
58
- }
59
- }
60
- }