github-search-cli 9.8.1 → 9.8.3

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/tui.ts CHANGED
@@ -56,7 +56,14 @@ import {
56
56
  import { tabSince, TAB_NAMES, fmtStars } from "./trending";
57
57
  import { loadConfig, saveConfig } from "./config";
58
58
  import { buildHelpSections, HELP_KEYS_COLUMN } from "./help";
59
- import { SearchCliError, NoResultsError } from "./errors";
59
+ import {
60
+ SearchCliError,
61
+ NoResultsError,
62
+ NetworkError,
63
+ RateLimitError,
64
+ AuthError,
65
+ ForbiddenError,
66
+ } from "./errors";
60
67
  import {
61
68
  appendHistory,
62
69
  readHistory,
@@ -204,7 +211,10 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
204
211
 
205
212
  // ── Config ──
206
213
  const config = loadConfig();
207
- const githubToken = config.githubToken || process.env.GITHUB_TOKEN;
214
+ // Mutable: the AuthError flow lets the user fix or clear the token without
215
+ // restarting (the next search re-reads this variable).
216
+ let githubToken: string | undefined =
217
+ config.githubToken || process.env.GITHUB_TOKEN;
208
218
 
209
219
  // Apply theme
210
220
  const theme = loadTheme(theme_override || config.theme);
@@ -438,6 +448,11 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
438
448
  currentMode = "search";
439
449
  showLanding(false);
440
450
  showSearchMode();
451
+ // Restore the saved query (if any) as a prefill, never auto-run it.
452
+ if (currentQueryInput) {
453
+ searchInput.value = currentQueryInput;
454
+ renderer.requestRender();
455
+ }
441
456
  },
442
457
  },
443
458
  {
@@ -1891,6 +1906,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1891
1906
  try {
1892
1907
  const readme = await fetchReadme(repo.owner, repo.name, {
1893
1908
  token: githubToken,
1909
+ private: repo.private,
1894
1910
  });
1895
1911
  if (readme) {
1896
1912
  readmeView.setContent(readme.text, readme.sourceUrl);
@@ -1922,12 +1938,125 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1922
1938
  }
1923
1939
  }, 2000);
1924
1940
 
1941
+ // ── Token fix flow (from 401/403 error hints) ───────────────────────
1942
+ // Inline prompt box that slides over the search input row. Enter saves the
1943
+ // token to config and re-runs the failed query; Esc cancels.
1944
+ const tokenPromptBox = new BoxRenderable(renderer, {
1945
+ visible: false,
1946
+ position: "absolute",
1947
+ top: 0,
1948
+ left: 0,
1949
+ width: "100%",
1950
+ flexDirection: "row",
1951
+ paddingY: 1,
1952
+ paddingLeft: 1,
1953
+ backgroundColor: colors.surface,
1954
+ });
1955
+ const tokenPromptLabel = new TextRenderable(renderer, {
1956
+ content: " GitHub token:",
1957
+ fg: colors.accent,
1958
+ height: 1,
1959
+ });
1960
+ const tokenPromptInput = new InputRenderable(renderer, {
1961
+ placeholder: "paste token, Enter to save — Esc cancels",
1962
+ value: "",
1963
+ backgroundColor: colors.surface,
1964
+ textColor: colors.text,
1965
+ flexGrow: 1,
1966
+ });
1967
+ tokenPromptBox.add(tokenPromptLabel);
1968
+ tokenPromptBox.add(tokenPromptInput);
1969
+ root.add(tokenPromptBox);
1970
+
1971
+ let tokenPromptQuery: string | null = null;
1972
+
1973
+ function showTokenPrompt(query: string) {
1974
+ tokenPromptQuery = query;
1975
+ tokenPromptInput.value = "";
1976
+ tokenPromptBox.visible = true;
1977
+ searchBox.visible = false;
1978
+ tokenPromptInput.focus();
1979
+ renderer.requestRender();
1980
+ }
1981
+
1982
+ function hideTokenPrompt() {
1983
+ tokenPromptBox.visible = false;
1984
+ tokenPromptQuery = null;
1985
+ if (currentMode === "search" || currentMode === "packages") {
1986
+ searchBox.visible = true;
1987
+ searchInput.focus();
1988
+ }
1989
+ renderer.requestRender();
1990
+ }
1991
+
1992
+ function persistToken(token: string): void {
1993
+ githubToken = token;
1994
+ config.githubToken = token;
1995
+ saveConfig(config);
1996
+ }
1997
+
1998
+ tokenPromptInput.on("enter", () => {
1999
+ const token = tokenPromptInput.value.trim();
2000
+ const query = tokenPromptQuery;
2001
+ hideTokenPrompt();
2002
+ if (!token || !query) return;
2003
+ persistToken(token);
2004
+ setStatus("Token saved");
2005
+ doSearch(query);
2006
+ });
2007
+
2008
+ // ── Error-hint key actions ([t] fix token, [u] unset) ──────────────
2009
+
2010
+ function fixTokenAction() {
2011
+ showTokenPrompt(currentQueryInput || "stars:>10000");
2012
+ }
2013
+
2014
+ function unsetTokenAction() {
2015
+ githubToken = undefined;
2016
+ if (config.githubToken) {
2017
+ delete config.githubToken;
2018
+ saveConfig(config);
2019
+ }
2020
+ setStatus("Token cleared — searching without one");
2021
+ if (currentQueryInput) doSearch(currentQueryInput);
2022
+ }
2023
+
1925
2024
  // ── Helper functions ────────────────────────────────────────────────
1926
2025
 
1927
2026
  function setStatus(msg: string) {
1928
2027
  statusMgr.set("idle", msg);
1929
2028
  }
1930
2029
 
2030
+ /**
2031
+ * Which error family is showing in the status bar, for the bracketed key
2032
+ * hints the messages advertise ([r]etry, [t]/[c] token, [u]nset).
2033
+ * Null once the next search starts, so the hint keys can't hijack
2034
+ * theme/compare forever after a transient failure.
2035
+ */
2036
+ type ErrorHintKind = "auth" | "forbidden" | "ratelimit" | "network" | null;
2037
+ let errorHint: ErrorHintKind = null;
2038
+
2039
+ /** Render an error in the status bar and remember which hints apply. */
2040
+ function reportError(err: unknown) {
2041
+ errorHint =
2042
+ err instanceof AuthError
2043
+ ? "auth"
2044
+ : err instanceof ForbiddenError
2045
+ ? "forbidden"
2046
+ : err instanceof RateLimitError
2047
+ ? "ratelimit"
2048
+ : err instanceof NetworkError
2049
+ ? "network"
2050
+ : null;
2051
+ const msg =
2052
+ err instanceof SearchCliError
2053
+ ? err.userMessage
2054
+ : err instanceof Error
2055
+ ? err.message
2056
+ : String(err);
2057
+ statusMgr.set("error", msg.slice(0, 60));
2058
+ }
2059
+
1931
2060
  /** Session snapshot; only restorable modes are persisted (see SessionState). */
1932
2061
  function buildSessionState(): SessionState {
1933
2062
  const mode: SessionState["mode"] =
@@ -2221,6 +2350,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
2221
2350
  async function loadTrending() {
2222
2351
  currentMode = "trending";
2223
2352
  isLoading = true;
2353
+ errorHint = null;
2224
2354
  searchBox.visible = false;
2225
2355
  toolbarText.visible = false;
2226
2356
  trendingTabBox.visible = true;
@@ -2276,16 +2406,10 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
2276
2406
  resultCount: fetched.length,
2277
2407
  });
2278
2408
  } catch (err) {
2279
- const msg =
2280
- err instanceof SearchCliError
2281
- ? err.userMessage
2282
- : err instanceof Error
2283
- ? err.message
2284
- : String(err);
2285
2409
  resultsSelect.options = [
2286
2410
  { name: " (error)", description: "", value: null },
2287
2411
  ];
2288
- statusMgr.set("error", msg.slice(0, 60));
2412
+ reportError(err);
2289
2413
  }
2290
2414
  isLoading = false;
2291
2415
  renderer.requestRender();
@@ -2328,6 +2452,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
2328
2452
  if (q === "" || isLoading) return;
2329
2453
  currentQueryInput = q;
2330
2454
  isLoading = true;
2455
+ errorHint = null; // npm errors never carry GitHub auth hints
2331
2456
  resultsSelect.options = [
2332
2457
  { name: " Searching packages...", description: "", value: null },
2333
2458
  ];
@@ -2366,6 +2491,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
2366
2491
  const q = queryText.trim();
2367
2492
  if (q === "" || isLoading) return;
2368
2493
 
2494
+ errorHint = null; // a fresh search retires the previous error's hint keys
2369
2495
  if (!append) currentPage = 1;
2370
2496
  deepDiveActive = false;
2371
2497
 
@@ -2426,17 +2552,14 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
2426
2552
  }
2427
2553
  }
2428
2554
  } catch (err) {
2429
- currentRepos = [];
2430
- resultsSelect.options = [
2431
- { name: " (error)", description: "", value: null },
2432
- ];
2433
- const msg =
2434
- err instanceof SearchCliError
2435
- ? err.userMessage
2436
- : err instanceof Error
2437
- ? err.message
2438
- : String(err);
2439
- statusMgr.set("error", msg.slice(0, 60));
2555
+ // A failed "load more" page must not wipe the pages already on screen.
2556
+ if (!append) {
2557
+ currentRepos = [];
2558
+ resultsSelect.options = [
2559
+ { name: " (error)", description: "", value: null },
2560
+ ];
2561
+ }
2562
+ reportError(err);
2440
2563
  }
2441
2564
 
2442
2565
  isLoading = false;
@@ -2554,6 +2677,18 @@ ${pack.description ?? ""}`;
2554
2677
 
2555
2678
  // ── Global keyboard shortcuts ──────────────────────────────────────
2556
2679
  renderer.keyInput.on("keypress", (key) => {
2680
+ // ── Token prompt (own keyboard scope) ──────────────────────────
2681
+ if (tokenPromptBox.visible) {
2682
+ if (key.name === "escape") {
2683
+ hideTokenPrompt();
2684
+ setStatus("Token entry cancelled");
2685
+ renderer.requestRender();
2686
+ return;
2687
+ }
2688
+ if (key.name === "enter" || key.name === "return") return; // InputRenderable fires "enter"
2689
+ return; // let the input consume everything else (letters, paste, etc.)
2690
+ }
2691
+
2557
2692
  // ── Overlay-mode handling ────────────────────────────────────────
2558
2693
  if (currentOverlay !== "none") {
2559
2694
  // Escape or q closes any overlay (except: leader uses Esc for back-nav, q to close)
@@ -2926,6 +3061,34 @@ ${pack.description ?? ""}`;
2926
3061
  return;
2927
3062
  }
2928
3063
 
3064
+ // Error-hint keys — active only while a status-bar error that advertises
3065
+ // them ([r]etry / [t]oken / [u]nset / [c]ycle) is on screen; the next
3066
+ // search clears the state, returning t/c to their normal roles.
3067
+ if (!searchInput.focused && errorHint) {
3068
+ if (key.name === "r") {
3069
+ errorHint = null;
3070
+ if (currentMode === "trending") loadTrending();
3071
+ else if (currentQueryInput) doSearch(currentQueryInput);
3072
+ renderer.requestRender();
3073
+ return;
3074
+ }
3075
+ if (
3076
+ key.name === "t" &&
3077
+ (errorHint === "auth" || errorHint === "forbidden")
3078
+ ) {
3079
+ fixTokenAction();
3080
+ return;
3081
+ }
3082
+ if (key.name === "u" && errorHint === "auth") {
3083
+ unsetTokenAction();
3084
+ return;
3085
+ }
3086
+ if (key.name === "c" && errorHint === "ratelimit") {
3087
+ fixTokenAction(); // TUI manages a single token — prompt for a different one
3088
+ return;
3089
+ }
3090
+ }
3091
+
2929
3092
  // 't' toggles theme and persists to config
2930
3093
  if (key.name === "t" && !searchInput.focused) {
2931
3094
  const current = config.theme;
@@ -3045,44 +3208,27 @@ ${pack.description ?? ""}`;
3045
3208
  // ── Update check (before start) ──────────────────────────────────────
3046
3209
  const _currentVersion = getVersion();
3047
3210
  // ── Start ──────────────────────────────────────────────────────────
3048
- // Check for updates (non-blocking — doesn't delay TUI startup)
3049
- if (!session?.mode) {
3050
- showLanding(true);
3051
- }
3211
+ // Always start on the landing menu. Saved sessions restore preferences
3212
+ // (sort/limit/trending tab) and pre-fill the query once a mode is picked,
3213
+ // but never auto-run a search — a stale or corrupted session (e.g. an MCP
3214
+ // handshake blob saved as a query) used to hijack startup and skip the
3215
+ // menu on every launch.
3216
+ showLanding(true);
3052
3217
  checkForUpdateAndShow();
3053
3218
  checkPostUpgradePanel();
3054
3219
  renderer.start();
3055
- if (session?.mode) {
3056
- if (currentMode === "trending") {
3057
- loadTrending();
3058
- } else {
3059
- searchInput.value = currentQueryInput;
3060
- if (currentQueryInput) {
3061
- doSearch(currentQueryInput);
3062
- } else {
3063
- showSearchMode();
3064
- }
3065
- searchInput.focus();
3066
- }
3067
- }
3068
3220
  renderer.requestRender();
3069
3221
  }
3070
3222
 
3071
3223
  // ── Utilities ─────────────────────────────────────────────────────────
3072
3224
 
3073
3225
  function formatResultLine(repo: Repo): string {
3074
- const stars = formatStars(repo.stars);
3226
+ const stars = `★ ${fmtStars(repo.stars)}`;
3075
3227
  const lang = (repo.language ?? "?").padEnd(12).slice(0, 12);
3076
3228
  const name = repo.fullName.padEnd(40).slice(0, 40);
3077
3229
  return `${name} ${lang} ${stars}`;
3078
3230
  }
3079
3231
 
3080
- function formatStars(n: number): string {
3081
- if (n >= 1000000) return `★ ${(n / 1000000).toFixed(1).replace(/\.0$/, "")}M`;
3082
- if (n >= 1000) return `★ ${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
3083
- return `★ ${n}`;
3084
- }
3085
-
3086
3232
  function formatToolbar(
3087
3233
  sort: SortStrategy | PackageSortMode,
3088
3234
  limit: number,
package/src/user.ts CHANGED
@@ -212,20 +212,21 @@ export async function fetchUserProfile(
212
212
  if (userPayload.public_repos > 0) {
213
213
  const perPage = Math.min(limit, 100);
214
214
  const maxPages = Math.ceil(limit / perPage);
215
- for (let page = 1; page <= maxPages; page++) {
216
- const url =
217
- `https://api.github.com/users/${encodeURIComponent(name)}/repos` +
218
- `?per_page=${perPage}&sort=pushed&page=${page}`;
219
- const res = await fetch(url, { headers: apiHeaders(token) });
220
- if (!res.ok) {
221
- // User metadata succeeded; degraded summary beats a hard failure.
222
- break;
223
- }
224
- const items = (await res.json()) as GitHubRepoPayload[];
225
- if (!Array.isArray(items)) break; // unexpected shape — degrade gracefully
226
- repos.push(...items);
227
- if (repos.length >= limit || items.length < perPage) break;
228
- }
215
+ const pages = await Promise.all(
216
+ Array.from({ length: maxPages }, (_, i) => i + 1).map(async (page) => {
217
+ const url =
218
+ `https://api.github.com/users/${encodeURIComponent(name)}/repos` +
219
+ `?per_page=${perPage}&sort=pushed&page=${page}`;
220
+ const res = await fetch(url, { headers: apiHeaders(token) });
221
+ if (!res.ok) {
222
+ // User metadata succeeded; degraded summary beats a hard failure.
223
+ return [];
224
+ }
225
+ const items = (await res.json()) as GitHubRepoPayload[];
226
+ return Array.isArray(items) ? items : []; // unexpected shape — skip
227
+ }),
228
+ );
229
+ repos.push(...pages.flat());
229
230
  }
230
231
 
231
232
  return normalizeUser(userPayload, repos.slice(0, limit));
@@ -1,59 +0,0 @@
1
- // src/config.ts
2
- import { homedir } from "os";
3
- import { join } from "path";
4
- import { mkdirSync, readFileSync, writeFileSync } from "fs";
5
- function stateDir() {
6
- const xdg = process.env.XDG_STATE_HOME;
7
- const base = xdg ? join(xdg, "ghfind") : join(homedir(), ".local", "share", "ghfind");
8
- return base;
9
- }
10
- function cacheDir() {
11
- const xdg = process.env.XDG_CACHE_HOME;
12
- const base = xdg ? join(xdg, "ghfind") : join(homedir(), ".cache", "ghfind");
13
- return base;
14
- }
15
- var DEFAULTS = {
16
- defaultSort: "best-match",
17
- defaultLimit: 50,
18
- theme: "tokyo-night",
19
- cacheTtlSeconds: 300,
20
- defaultTab: "search"
21
- };
22
- function configPath() {
23
- const envPath = process.env.GHFIND_CONFIG;
24
- if (envPath) return envPath;
25
- const xdg = process.env.XDG_CONFIG_HOME;
26
- const base = xdg ? join(xdg, "ghfind") : join(homedir(), ".config", "ghfind");
27
- return join(base, "config.json");
28
- }
29
- function loadConfig() {
30
- const path = configPath();
31
- try {
32
- const raw = readFileSync(path, "utf-8");
33
- const parsed = JSON.parse(raw);
34
- return mergeWithEnv({ ...DEFAULTS, ...parsed });
35
- } catch {
36
- return mergeWithEnv({ ...DEFAULTS });
37
- }
38
- }
39
- function saveConfig(config) {
40
- const path = configPath();
41
- const dir = path.slice(
42
- 0,
43
- Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))
44
- );
45
- mkdirSync(dir, { recursive: true });
46
- writeFileSync(path, JSON.stringify(config, null, 2));
47
- }
48
- function mergeWithEnv(config) {
49
- if (process.env.GITHUB_TOKEN) config.githubToken = process.env.GITHUB_TOKEN;
50
- return config;
51
- }
52
-
53
- export {
54
- stateDir,
55
- cacheDir,
56
- configPath,
57
- loadConfig,
58
- saveConfig
59
- };
@@ -1,14 +0,0 @@
1
- import {
2
- cacheDir,
3
- configPath,
4
- loadConfig,
5
- saveConfig,
6
- stateDir
7
- } from "./chunk-GYIL4S5X.js";
8
- export {
9
- cacheDir,
10
- configPath,
11
- loadConfig,
12
- saveConfig,
13
- stateDir
14
- };