github-search-cli 9.8.0 → 9.8.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/storage.ts CHANGED
@@ -2,16 +2,15 @@
2
2
  * Storage adapter — single point of file I/O for state files.
3
3
  *
4
4
  * All domain modules (history, bookmarks, saved-searches, notifications,
5
- * session, config) delegate file I/O here. Swap for an in-memory
6
- * implementation in tests.
5
+ * session, config) delegate file I/O here.
7
6
  */
8
7
  import {
9
- existsSync,
10
8
  mkdirSync,
11
9
  readFileSync,
12
10
  writeFileSync,
13
11
  appendFileSync,
14
12
  unlinkSync,
13
+ renameSync,
15
14
  } from "node:fs";
16
15
  import { join } from "node:path";
17
16
  import { stateDir } from "./config";
@@ -31,10 +30,27 @@ export function readJSON<T>(filename: string, fallback: T): T {
31
30
  }
32
31
  }
33
32
 
33
+ /** Write a file atomically: temp file in the same dir, then rename. */
34
+ function writeFileAtomic(filename: string, data: string): void {
35
+ ensureDir();
36
+ const target = join(stateDir(), filename);
37
+ const tmp = `${target}.tmp-${process.pid}`;
38
+ try {
39
+ writeFileSync(tmp, data, "utf-8");
40
+ renameSync(tmp, target);
41
+ } catch (e) {
42
+ try {
43
+ unlinkSync(tmp);
44
+ } catch {
45
+ // temp already gone
46
+ }
47
+ throw e;
48
+ }
49
+ }
50
+
34
51
  /** Write data as JSON to a file. */
35
52
  export function writeJSON(filename: string, data: unknown): void {
36
- ensureDir();
37
- writeFileSync(join(stateDir(), filename), JSON.stringify(data, null, 2));
53
+ writeFileAtomic(filename, JSON.stringify(data, null, 2));
38
54
  }
39
55
 
40
56
  /** Append one JSON object as a line to a JSONL file. */
@@ -68,8 +84,7 @@ export function readJSONL<T>(filename: string): T[] {
68
84
 
69
85
  /** Write raw text to a file (bypasses JSON encoding). */
70
86
  export function writeRaw(filename: string, data: string): void {
71
- ensureDir();
72
- writeFileSync(join(stateDir(), filename), data);
87
+ writeFileAtomic(filename, data);
73
88
  }
74
89
 
75
90
  /** Delete a file if it exists. */
@@ -94,43 +109,3 @@ export function debugLog(msg: string): void {
94
109
  /* ignore */
95
110
  }
96
111
  }
97
-
98
- /** Check if a file exists in the state dir. */
99
- export function fileExists(filename: string): boolean {
100
- return existsSync(join(stateDir(), filename));
101
- }
102
-
103
- // ─── In-memory implementation for tests ────────────────────────────────
104
-
105
- const memStore = new Map<string, string>();
106
-
107
- export const memoryStorage = {
108
- readJSON<T>(filename: string, fallback: T): T {
109
- const raw = memStore.get(filename);
110
- if (!raw) return fallback;
111
- try {
112
- return JSON.parse(raw) as T;
113
- } catch {
114
- return fallback;
115
- }
116
- },
117
- writeJSON(filename: string, data: unknown): void {
118
- memStore.set(filename, JSON.stringify(data, null, 2));
119
- },
120
- appendJSONL(filename: string, obj: unknown): void {
121
- const existing = memStore.get(filename) ?? "";
122
- memStore.set(filename, `${existing + JSON.stringify(obj)}\n`);
123
- },
124
- readJSONL<T>(filename: string): T[] {
125
- const raw = memStore.get(filename);
126
- if (!raw) return [];
127
- return raw
128
- .trim()
129
- .split("\n")
130
- .filter(Boolean)
131
- .map((l) => JSON.parse(l) as T);
132
- },
133
- clear(): void {
134
- memStore.clear();
135
- },
136
- };
package/src/tui.ts CHANGED
@@ -75,7 +75,8 @@ import { saveSession, restoreSession } from "./session";
75
75
  import { fetchDeepDive, buildDeepDiveText } from "./deepdive";
76
76
  import { buildComparisonTable } from "./compare";
77
77
  import { createMarkdownView } from "./markdown-view";
78
- import { fetchImageBytes, readmeBaseUrl } from "./image-loader";
78
+ import { fetchImageBytes } from "./image-loader";
79
+ import { fetchReadme } from "./readme";
79
80
  import { fetchTopics } from "./explore";
80
81
  import { exportToFile, type ExportFormat } from "./output";
81
82
  import {
@@ -1888,28 +1889,14 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1888
1889
  readmeScroll.scrollTop = 0;
1889
1890
  showOverlay("readme");
1890
1891
  try {
1891
- const headers: Record<string, string> = { "User-Agent": "ghfind/1.0" };
1892
- if (githubToken) headers.Authorization = `Bearer ${githubToken}`;
1893
- let text = "";
1894
- let sourceUrl = "";
1895
- for (const [branch, file] of [
1896
- ["main", "README.md"],
1897
- ["master", "README.md"],
1898
- ["main", "README.rst"],
1899
- ] as const) {
1900
- // The README's own URL doubles as the base for relative image paths.
1901
- const url = readmeBaseUrl(repo.owner, repo.name, branch, file);
1902
- const r = await fetch(url, { headers });
1903
- if (r.ok) {
1904
- text = await r.text();
1905
- sourceUrl = url;
1906
- break;
1907
- }
1908
- }
1909
- if (!text) {
1910
- readmeView.setContent(`_(no README found for ${repo.fullName})_`);
1892
+ const readme = await fetchReadme(repo.owner, repo.name, {
1893
+ token: githubToken,
1894
+ private: repo.private,
1895
+ });
1896
+ if (readme) {
1897
+ readmeView.setContent(readme.text, readme.sourceUrl);
1911
1898
  } else {
1912
- readmeView.setContent(text, sourceUrl);
1899
+ readmeView.setContent(`_(no README found for ${repo.fullName})_`);
1913
1900
  }
1914
1901
  } catch {
1915
1902
  readmeView.setContent("_Failed to load README._");
@@ -2440,10 +2427,13 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
2440
2427
  }
2441
2428
  }
2442
2429
  } catch (err) {
2443
- currentRepos = [];
2444
- resultsSelect.options = [
2445
- { name: " (error)", description: "", value: null },
2446
- ];
2430
+ // A failed "load more" page must not wipe the pages already on screen.
2431
+ if (!append) {
2432
+ currentRepos = [];
2433
+ resultsSelect.options = [
2434
+ { name: " (error)", description: "", value: null },
2435
+ ];
2436
+ }
2447
2437
  const msg =
2448
2438
  err instanceof SearchCliError
2449
2439
  ? err.userMessage
@@ -3085,18 +3075,12 @@ ${pack.description ?? ""}`;
3085
3075
  // ── Utilities ─────────────────────────────────────────────────────────
3086
3076
 
3087
3077
  function formatResultLine(repo: Repo): string {
3088
- const stars = formatStars(repo.stars);
3078
+ const stars = `★ ${fmtStars(repo.stars)}`;
3089
3079
  const lang = (repo.language ?? "?").padEnd(12).slice(0, 12);
3090
3080
  const name = repo.fullName.padEnd(40).slice(0, 40);
3091
3081
  return `${name} ${lang} ${stars}`;
3092
3082
  }
3093
3083
 
3094
- function formatStars(n: number): string {
3095
- if (n >= 1000000) return `★ ${(n / 1000000).toFixed(1).replace(/\.0$/, "")}M`;
3096
- if (n >= 1000) return `★ ${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
3097
- return `★ ${n}`;
3098
- }
3099
-
3100
3084
  function formatToolbar(
3101
3085
  sort: SortStrategy | PackageSortMode,
3102
3086
  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
- };