github-search-cli 9.10.0 → 9.10.1

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/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());
@@ -3518,12 +3543,18 @@ ${pack.description ?? ""}`;
3518
3543
  // ── Update check (before start) ──────────────────────────────────────
3519
3544
  const _currentVersion = getVersion();
3520
3545
  // ── Start ──────────────────────────────────────────────────────────
3521
- // Always start on the landing menu. Saved sessions restore preferences
3546
+ // Default is the landing menu. Saved sessions restore preferences
3522
3547
  // (sort/limit/trending tab) and pre-fill the query once a mode is picked,
3523
3548
  // but never auto-run a search — a stale or corrupted session (e.g. an MCP
3524
3549
  // handshake blob saved as a query) used to hijack startup and skip the
3525
- // menu on every launch.
3526
- showLanding(true);
3550
+ // menu on every launch. An explicit `ghfind --trending` is not a stale
3551
+ // session though: it is the mode the user asked for, so it skips the menu.
3552
+ if (startMode === "trending") {
3553
+ showLanding(false);
3554
+ loadTrending();
3555
+ } else {
3556
+ showLanding(true);
3557
+ }
3527
3558
  checkForUpdateAndShow();
3528
3559
  checkPostUpgradePanel();
3529
3560
  renderer.start();
@@ -3549,11 +3580,22 @@ function formatToolbar(
3549
3580
  }
3550
3581
 
3551
3582
  function cleanup(): void {
3583
+ // Stop the spinner before exiting: process.exit() would kill the process
3584
+ // anyway, but a tick already queued between here and the exit can still fire.
3585
+ activeStatusMgr?.dispose();
3586
+ activeStatusMgr = null;
3552
3587
  try {
3553
3588
  rotateHistory();
3554
3589
  } catch {
3555
3590
  /* non-critical */
3556
3591
  }
3592
+ // Release the terminal (raw mode, alternate screen) before the hard exit.
3593
+ try {
3594
+ activeRenderer?.destroy();
3595
+ } catch {
3596
+ /* non-critical */
3597
+ }
3598
+ activeRenderer = null;
3557
3599
  process.exit(0);
3558
3600
  }
3559
3601
 
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
- }