github-search-cli 9.8.1 → 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.
@@ -3,7 +3,8 @@
3
3
  * error, print it, and offer to open it in the user's browser.
4
4
  *
5
5
  * Privacy: never auto-posts. The user sees the URL and decides whether to
6
- * submit. We redact env vars and only include what the diagnostic needs.
6
+ * submit. Credentials found in argv or the error text are redacted before
7
+ * they can reach the URL, and a redacted report is never auto-opened.
7
8
  */
8
9
  import {
9
10
  platform as osPlatform,
@@ -24,6 +25,59 @@ void _osArch;
24
25
  const REPO = "codersdfs/search-cli";
25
26
  const NEW_ISSUE_URL = `https://github.com/${REPO}/issues/new`;
26
27
 
28
+ const SECRET_PLACEHOLDER = "***";
29
+
30
+ /** Flags whose *next* argument is a credential. */
31
+ const SECRET_FLAGS = /^(?:--token|-t)$/;
32
+
33
+ /** GitHub credential shapes: classic (ghp_/gho_/ghu_/ghs_/ghr_) and fine-grained. */
34
+ const SECRET_PATTERNS: RegExp[] = [
35
+ /gh[pousr]_[A-Za-z0-9]{16,}/g,
36
+ /github_pat_[A-Za-z0-9_]{20,}/g,
37
+ ];
38
+
39
+ /** Replace anything that looks like a token inside a free-form string. */
40
+ export function redactSecrets(text: string): string {
41
+ let out = text;
42
+ for (const pattern of SECRET_PATTERNS) {
43
+ out = out.replace(pattern, SECRET_PLACEHOLDER);
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /**
49
+ * Redact an argv: the value following a credential flag, `--token=…`/`-t=…`
50
+ * forms, and any bare argument that looks like a token.
51
+ */
52
+ export function redactArgv(argv: string[]): string[] {
53
+ const out: string[] = [];
54
+ let redactNext = false;
55
+ for (const arg of argv) {
56
+ if (redactNext) {
57
+ out.push(SECRET_PLACEHOLDER);
58
+ redactNext = false;
59
+ continue;
60
+ }
61
+ if (SECRET_FLAGS.test(arg)) {
62
+ out.push(arg);
63
+ redactNext = true;
64
+ continue;
65
+ }
66
+ const eq = arg.indexOf("=");
67
+ if (eq > 0 && SECRET_FLAGS.test(arg.slice(0, eq))) {
68
+ out.push(`${arg.slice(0, eq)}=${SECRET_PLACEHOLDER}`);
69
+ continue;
70
+ }
71
+ out.push(redactSecrets(arg));
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /** True when `argv` carries a credential that redaction would remove. */
77
+ function argvHasSecret(argv: string[]): boolean {
78
+ return redactArgv(argv).some((arg, i) => arg !== argv[i]);
79
+ }
80
+
27
81
  function getPackageVersion(): string {
28
82
  return getVersion();
29
83
  }
@@ -43,10 +97,12 @@ export function formatErrorBody(
43
97
  context: { command: string; argv: string[] },
44
98
  ): string {
45
99
  const e = err instanceof Error ? err : new Error(String(err));
46
- // biome-ignore lint/suspicious/noControlCharactersInRegex: strips ANSI color escapes from stack traces
47
- const stack = (e.stack ?? "(no stack)").replace(/\u001b\[[0-9;]*m/g, "");
48
- const message = (e.message || "(no message)").slice(0, 500);
49
- const cmd = `${context.command} ${context.argv.join(" ")}`.trim();
100
+ const stack = redactSecrets(
101
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: strips ANSI color escapes from stack traces
102
+ (e.stack ?? "(no stack)").replace(/\u001b\[[0-9;]*m/g, ""),
103
+ );
104
+ const message = redactSecrets(e.message || "(no message)").slice(0, 500);
105
+ const cmd = `${context.command} ${redactArgv(context.argv).join(" ")}`.trim();
50
106
  const ghfindVersion = getPackageVersion();
51
107
 
52
108
  return [
@@ -88,7 +144,9 @@ export function buildIssueUrl(
88
144
  context: { command: string; argv: string[] },
89
145
  ): string {
90
146
  const e = err instanceof Error ? err : new Error(String(err));
91
- const title = e.message.slice(0, 80).replace(/[\r\n]+/g, " ");
147
+ const title = redactSecrets(e.message)
148
+ .slice(0, 80)
149
+ .replace(/[\r\n]+/g, " ");
92
150
  const body = formatErrorBody(err, context);
93
151
  const params = new URLSearchParams({
94
152
  title: `[bug] ${title || "Unhandled error"}`,
@@ -138,15 +196,27 @@ export function reportError(
138
196
  argv: process.argv.slice(2),
139
197
  },
140
198
  ): string {
141
- const url = buildIssueUrl(err, context);
199
+ const hadSecret = argvHasSecret(context.argv);
200
+ // Build the URL from the scrubbed argv, never the raw one.
201
+ const url = buildIssueUrl(err, {
202
+ command: context.command,
203
+ argv: redactArgv(context.argv),
204
+ });
142
205
  console.error("");
143
206
  console.error(" ⚠ ghfind encountered an unexpected error.");
144
207
  console.error("");
145
- console.error(" Help us fix it by opening a bug report with the");
146
- console.error(" details pre-filled. Your browser will open the new-issue");
147
- console.error(" page; review and submit (or close the tab to skip).");
208
+ if (hadSecret) {
209
+ console.error(" A token was found in the command line and has been");
210
+ console.error(" redacted. Your browser will NOT be opened — review the");
211
+ console.error(" scrubbed URL below before sharing it anywhere.");
212
+ } else {
213
+ console.error(" Help us fix it by opening a bug report with the");
214
+ console.error(" details pre-filled. Your browser will open the new-issue");
215
+ console.error(" page; review and submit (or close the tab to skip).");
216
+ }
148
217
  console.error("");
149
- const opened = tryOpenUrl(url);
218
+ // Never auto-open a report that contained a credential.
219
+ const opened = hadSecret ? false : tryOpenUrl(url);
150
220
  if (opened) {
151
221
  console.error(` → Opened: ${url}`);
152
222
  } else {
package/src/errors.ts CHANGED
@@ -4,7 +4,6 @@ export class SearchCliError extends Error {
4
4
  constructor(
5
5
  message: string,
6
6
  public readonly userMessage: string,
7
- public readonly recoverable: boolean = true,
8
7
  ) {
9
8
  super(message);
10
9
  this.name = "SearchCliError";
package/src/history.ts CHANGED
@@ -31,13 +31,10 @@ export function readHistory(): HistoryEntry[] {
31
31
  export function deleteHistoryEntry(index: number): void {
32
32
  const entries = readHistory();
33
33
  if (index < 0 || index >= entries.length) return;
34
- const removed = entries[index];
35
- const remaining = entries.filter(
36
- (e) => e.timestamp !== removed.timestamp || e.query !== removed.query,
37
- );
34
+ entries.splice(index, 1);
38
35
  writeRaw(
39
36
  HISTORY_FILE,
40
- `${remaining
37
+ `${entries
41
38
  .reverse()
42
39
  .map((e) => JSON.stringify(e))
43
40
  .join("\n")}\n`,
package/src/init.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * ponytail: simple stdin prompts, no curses/readline UI.
5
5
  */
6
6
  import { createInterface } from "node:readline";
7
- import { saveConfig } from "./config";
7
+ import { saveConfig, configPath } from "./config";
8
8
  import { isLikelyGithubToken } from "./login";
9
9
  import { listThemes } from "./themes";
10
10
  import type { Config, SortStrategy } from "./types";
@@ -53,7 +53,6 @@ export async function runInitWizard(): Promise<void> {
53
53
  if (token) config.githubToken = token;
54
54
 
55
55
  saveConfig(config);
56
- const { configPath } = await import("./config.ts");
57
56
  console.log(` ✓ Config saved to ${configPath()}`);
58
57
  console.log(" Run 'ghfind' to start browsing!");
59
58
  console.log("");
package/src/mcp-server.ts CHANGED
@@ -510,19 +510,14 @@ const TOOLS: ToolDefinition[] = [
510
510
  throw new McpToolError("repos must contain 2-5 repo fullNames.");
511
511
  }
512
512
  const token = resolveToken(state, args.token);
513
- const provider = createGitHubSearch(undefined, token ? [token] : []);
514
513
  const found: Repo[] = [];
515
514
  const missing: string[] = [];
516
515
  for (const fullName of names) {
517
- const res = await provider.search(parseQuery(fullName), {
518
- limit: 1,
519
- sort: "stars",
520
- json: false,
521
- verbose: false,
522
- token,
523
- });
524
- if (res.repos.length > 0) found.push(res.repos[0]);
525
- else missing.push(fullName);
516
+ try {
517
+ found.push(await resolveRepoFromRef(fullName, token));
518
+ } catch {
519
+ missing.push(fullName);
520
+ }
526
521
  }
527
522
  if (found.length < 2) {
528
523
  throw new McpToolError(
@@ -12,10 +12,14 @@ export interface Notification {
12
12
 
13
13
  let nextId = 1;
14
14
  let notifs: Notification[] = [];
15
+ let loaded = false;
15
16
  const MAX_NOTIFS = 50;
16
17
  const FILE = "notifications.json";
17
18
 
18
- function load(): void {
19
+ /** Load once per process: re-reading on an empty list would reissue ids. */
20
+ function ensureLoaded(): void {
21
+ if (loaded) return;
22
+ loaded = true;
19
23
  notifs = readJSON<Notification[]>(FILE, []);
20
24
  nextId = Math.max(...notifs.map((n) => n.id), 0) + 1;
21
25
  }
@@ -28,7 +32,7 @@ export function addNotification(
28
32
  type: Notification["type"],
29
33
  message: string,
30
34
  ): Notification {
31
- if (notifs.length === 0) load();
35
+ ensureLoaded();
32
36
  const n: Notification = {
33
37
  id: nextId++,
34
38
  type,
@@ -60,16 +64,18 @@ function iconFor(type: Notification["type"]): string {
60
64
  }
61
65
 
62
66
  export function getNotifications(): Notification[] {
63
- if (notifs.length === 0) load();
67
+ ensureLoaded();
64
68
  return [...notifs];
65
69
  }
66
70
 
67
71
  export function dismissNotification(id: number): void {
72
+ ensureLoaded();
68
73
  notifs = notifs.filter((n) => n.id !== id);
69
74
  save();
70
75
  }
71
76
 
72
77
  export function dismissAll(): void {
78
+ ensureLoaded();
73
79
  notifs = [];
74
80
  save();
75
81
  }
package/src/org.ts CHANGED
@@ -171,20 +171,21 @@ export async function fetchOrgProfile(
171
171
  if (orgPayload.public_repos > 0) {
172
172
  const perPage = Math.min(limit, 100);
173
173
  const maxPages = Math.ceil(limit / perPage);
174
- for (let page = 1; page <= maxPages; page++) {
175
- const url =
176
- `https://api.github.com/orgs/${encodeURIComponent(name)}/repos` +
177
- `?per_page=${perPage}&sort=pushed&page=${page}`;
178
- const res = await fetch(url, { headers: apiHeaders(token) });
179
- if (!res.ok) {
180
- // Org metadata succeeded; degraded summary beats a hard failure.
181
- break;
182
- }
183
- const items = (await res.json()) as GitHubRepoPayload[];
184
- if (!Array.isArray(items)) break; // unexpected shape — degrade gracefully
185
- repos.push(...items);
186
- if (repos.length >= limit || items.length < perPage) break;
187
- }
174
+ const pages = await Promise.all(
175
+ Array.from({ length: maxPages }, (_, i) => i + 1).map(async (page) => {
176
+ const url =
177
+ `https://api.github.com/orgs/${encodeURIComponent(name)}/repos` +
178
+ `?per_page=${perPage}&sort=pushed&page=${page}`;
179
+ const res = await fetch(url, { headers: apiHeaders(token) });
180
+ if (!res.ok) {
181
+ // Org metadata succeeded; degraded summary beats a hard failure.
182
+ return [];
183
+ }
184
+ const items = (await res.json()) as GitHubRepoPayload[];
185
+ return Array.isArray(items) ? items : []; // unexpected shape — skip
186
+ }),
187
+ );
188
+ repos.push(...pages.flat());
188
189
  }
189
190
 
190
191
  return normalizeOrg(orgPayload, repos.slice(0, limit));
package/src/output.ts CHANGED
@@ -136,18 +136,6 @@ export async function pipeExec(repos: Repo[], target: string): Promise<void> {
136
136
  }
137
137
  }
138
138
 
139
- // ─── Backward-compatible re-exports ─────────────────────────────────────
139
+ // ─── Narrowed aliases ──────────────────────────────────────────────────
140
140
 
141
141
  export type ExportFormat = "json" | "csv" | "markdown" | "text";
142
- export type FormatLine =
143
- "urls" | "names" | "ssh-urls" | "clone-commands" | "ids";
144
-
145
- /** @deprecated Use `format()` instead. */
146
- export function formatRepos(repos: Repo[], fmt: ExportFormat): string {
147
- return format(repos, fmt);
148
- }
149
-
150
- /** @deprecated Use `format()` instead. */
151
- export function formatLines(repos: Repo[], fmt: FormatLine): string {
152
- return format(repos, fmt);
153
- }
package/src/readme.ts CHANGED
@@ -27,17 +27,33 @@ const CANDIDATES: ReadonlyArray<readonly [string, string]> = [
27
27
  ["main", "README.rst"],
28
28
  ];
29
29
 
30
+ /**
31
+ * Headers for a README request. raw.githubusercontent.com is not the GitHub
32
+ * API, so the token is only ever attached to api.github.com — and only for
33
+ * private repos, which are the sole reason to authenticate a README fetch.
34
+ */
35
+ export function buildReadmeHeaders(
36
+ url: string,
37
+ token: string | undefined,
38
+ isPrivate = false,
39
+ ): Record<string, string> {
40
+ const headers: Record<string, string> = { "User-Agent": "ghfind/1.0" };
41
+ if (token && isPrivate && new URL(url).origin === "https://api.github.com") {
42
+ headers.Authorization = `Bearer ${token}`;
43
+ }
44
+ return headers;
45
+ }
46
+
30
47
  export async function fetchReadme(
31
48
  owner: string,
32
49
  repo: string,
33
- opts: { token?: string; fetchImpl?: typeof fetch } = {},
50
+ opts: { token?: string; private?: boolean; fetchImpl?: typeof fetch } = {},
34
51
  ): Promise<Readme | null> {
35
52
  const fetchImpl = opts.fetchImpl ?? fetch;
36
- const headers: Record<string, string> = { "User-Agent": "ghfind/1.0" };
37
- if (opts.token) headers.Authorization = `Bearer ${opts.token}`;
38
53
 
39
54
  const attempts = CANDIDATES.map(async ([branch, file]) => {
40
55
  const url = readmeBaseUrl(owner, repo, branch, file);
56
+ const headers = buildReadmeHeaders(url, opts.token, opts.private);
41
57
  const res = await fetchImpl(url, { headers });
42
58
  // Reject rather than resolve with null: Promise.any settles on the first
43
59
  // *fulfilled* promise, so a 404 that resolved would beat the real README.
package/src/releases.ts CHANGED
@@ -5,6 +5,8 @@ import { loadConfig } from "./config";
5
5
 
6
6
  const RELEASES_FILE = "release-cache.json";
7
7
  const PER_REPO = 5;
8
+ /** Bookmarks checked in parallel — enough to be fast, few enough to stay polite. */
9
+ const CONCURRENCY = 6;
8
10
 
9
11
  export interface TrackedRelease {
10
12
  id: number;
@@ -87,47 +89,47 @@ export async function checkReleases(
87
89
  error?: string;
88
90
  }> = [];
89
91
 
90
- for (const bm of bookmarks) {
92
+ const checkOne = async (
93
+ fullName: string,
94
+ ): Promise<{
95
+ fullName: string;
96
+ newReleases: TrackedRelease[];
97
+ error?: string;
98
+ }> => {
91
99
  try {
92
- const prev = cache[bm.repo.fullName];
93
- const lastSeen = getLastSeen(bm.repo.fullName);
100
+ const prev = cache[fullName];
101
+ const lastSeen = getLastSeen(fullName);
94
102
  const isNew = (r: TrackedRelease) =>
95
103
  !r.publishedAt || new Date(r.publishedAt).getTime() > lastSeen;
96
104
 
97
105
  const { status, etag, releases } = await fetchReleases(
98
- bm.repo.fullName,
106
+ fullName,
99
107
  prev?.etag,
100
108
  token,
101
109
  );
102
110
 
103
111
  if (status === 304 && prev) {
104
112
  // Unchanged upstream; recompute against lastSeenAt (it may have moved).
105
- results.push({
106
- fullName: bm.repo.fullName,
107
- newReleases: prev.releases.filter(isNew),
108
- });
109
- continue;
113
+ return { fullName, newReleases: prev.releases.filter(isNew) };
110
114
  }
111
115
  if (status !== 200 || !releases) {
112
- results.push({
113
- fullName: bm.repo.fullName,
114
- newReleases: [],
115
- error: `HTTP ${status}`,
116
- });
117
- continue;
116
+ return { fullName, newReleases: [], error: `HTTP ${status}` };
118
117
  }
119
- cache[bm.repo.fullName] = { checkedAt: Date.now(), etag, releases };
120
- results.push({
121
- fullName: bm.repo.fullName,
122
- newReleases: releases.filter(isNew),
123
- });
118
+ cache[fullName] = { checkedAt: Date.now(), etag, releases };
119
+ return { fullName, newReleases: releases.filter(isNew) };
124
120
  } catch (err) {
125
- results.push({
126
- fullName: bm.repo.fullName,
127
- newReleases: [],
128
- error: (err as Error).message,
129
- });
121
+ return { fullName, newReleases: [], error: (err as Error).message };
130
122
  }
123
+ };
124
+
125
+ // Run in small batches rather than one-at-a-time: a serial loop over every
126
+ // bookmark spends all its time waiting on the network, and bursts straight
127
+ // through the unauthenticated rate limit.
128
+ for (let i = 0; i < bookmarks.length; i += CONCURRENCY) {
129
+ const batch = bookmarks
130
+ .slice(i, i + CONCURRENCY)
131
+ .map((bm) => checkOne(bm.repo.fullName));
132
+ results.push(...(await Promise.all(batch)));
131
133
  }
132
134
 
133
135
  saveCache(cache);
@@ -158,9 +160,9 @@ export function allCachedReleases(): Array<
158
160
  TrackedRelease & { fullName: string }
159
161
  > {
160
162
  const cache = loadCache();
161
- const bookmarks = getBookmarks();
163
+ const bookmarked = new Set(getBookmarks().map((b) => b.repo.fullName));
162
164
  return Object.entries(cache)
163
- .filter(([fullName]) => bookmarks.some((b) => b.repo.fullName === fullName))
165
+ .filter(([fullName]) => bookmarked.has(fullName))
164
166
  .flatMap(([fullName, s]) => s.releases.map((r) => ({ ...r, fullName })))
165
167
  .sort(
166
168
  (a, b) =>
package/src/search.ts CHANGED
@@ -58,6 +58,10 @@ export class MemoryCache<T> {
58
58
  }
59
59
 
60
60
  set(key: string, data: T, ttlMs?: number): void {
61
+ // Re-inserting an existing key must move it to the end of the
62
+ // insertion-ordered Map, otherwise a refreshed entry keeps its original
63
+ // position and is the first one evicted — FIFO instead of LRU.
64
+ this.cache.delete(key);
61
65
  if (this.cache.size >= MAX_CACHE_SIZE) {
62
66
  const firstKey = this.cache.keys().next().value;
63
67
  if (firstKey !== undefined) this.cache.delete(firstKey);
@@ -80,6 +80,13 @@ export const MIN_QUERY_LENGTH = 2;
80
80
  /** Result cap, applied to both sources. */
81
81
  export const MAX_SKILL_RESULTS = 100;
82
82
 
83
+ /**
84
+ * The only shape accepted for registry `source` / `skillId`. Both end up
85
+ * inside a shell command (`npx skills add …`) that an agent is expected to
86
+ * run, so anything with shell metacharacters is rejected outright.
87
+ */
88
+ const SAFE_ID_RE = /^[A-Za-z0-9._/-]+$/;
89
+
83
90
  // ─── Local roots ──────────────────────────────────────────────────────────
84
91
 
85
92
  /** One scan root: where it is, and the label results carry. */
@@ -193,6 +200,9 @@ export function parseSkillFrontmatter(
193
200
 
194
201
  // ─── Local scan ───────────────────────────────────────────────────────────
195
202
 
203
+ /** Memoised scan results, keyed by root path + label. */
204
+ const scanCache = new Map<string, LocalSkillHit[]>();
205
+
196
206
  /**
197
207
  * Walk the roots and collect every readable SKILL.md, de-duplicated by name.
198
208
  *
@@ -203,6 +213,12 @@ export function parseSkillFrontmatter(
203
213
  export function scanLocalSkills(
204
214
  roots: SkillRoot[] = resolveSkillRoots(),
205
215
  ): LocalSkillHit[] {
216
+ // The filesystem does not change within a process, so a repeat scan is pure
217
+ // duplicate blocking I/O. Keyed on the roots so callers passing their own
218
+ // roots (tests, GHFIND_SKILL_ROOTS) still get a distinct cache entry.
219
+ const key = roots.map((r) => `${r.path}\u0000${r.label}`).join("\u0001");
220
+ const cached = scanCache.get(key);
221
+ if (cached) return cached;
206
222
  const byName = new Map<string, LocalSkillHit>();
207
223
  for (const root of roots) {
208
224
  for (const dir of walkDirs(root.path)) {
@@ -224,7 +240,9 @@ export function scanLocalSkills(
224
240
  }
225
241
  }
226
242
  }
227
- return [...byName.values()];
243
+ const hits = [...byName.values()];
244
+ scanCache.set(key, hits);
245
+ return hits;
228
246
  }
229
247
 
230
248
  /** Directory entries, or null when the directory is missing or unreadable. */
@@ -326,7 +344,20 @@ function normalizeRegistrySkill(raw: unknown): RegistrySkillHit | null {
326
344
  const obj = raw as Record<string, unknown>;
327
345
  const source = typeof obj.source === "string" ? obj.source : "";
328
346
  const skillId = typeof obj.skillId === "string" ? obj.skillId : "";
329
- if (!source || !skillId) return null;
347
+ // The registry is untrusted input and these two are interpolated into a
348
+ // shell command and a URL handed to an agent, so anything but a plain
349
+ // owner/repo + skill-id shape is dropped rather than escaped.
350
+ // `SAFE_ID_RE` allows `/` and `.`, so `../` passes it — reject traversal
351
+ // segments explicitly; they would land in the URL path and the npx command.
352
+ const hasTraversal = (s: string) => s === ".." || s.includes("..");
353
+ if (
354
+ !SAFE_ID_RE.test(source) ||
355
+ !SAFE_ID_RE.test(skillId) ||
356
+ hasTraversal(source) ||
357
+ hasTraversal(skillId)
358
+ ) {
359
+ return null;
360
+ }
330
361
  const name =
331
362
  typeof obj.name === "string" && obj.name.trim() ? obj.name : skillId;
332
363
  const installs = typeof obj.installs === "number" ? obj.installs : 0;
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
@@ -1891,6 +1891,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1891
1891
  try {
1892
1892
  const readme = await fetchReadme(repo.owner, repo.name, {
1893
1893
  token: githubToken,
1894
+ private: repo.private,
1894
1895
  });
1895
1896
  if (readme) {
1896
1897
  readmeView.setContent(readme.text, readme.sourceUrl);
@@ -2426,10 +2427,13 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
2426
2427
  }
2427
2428
  }
2428
2429
  } catch (err) {
2429
- currentRepos = [];
2430
- resultsSelect.options = [
2431
- { name: " (error)", description: "", value: null },
2432
- ];
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
+ }
2433
2437
  const msg =
2434
2438
  err instanceof SearchCliError
2435
2439
  ? err.userMessage
@@ -3071,18 +3075,12 @@ ${pack.description ?? ""}`;
3071
3075
  // ── Utilities ─────────────────────────────────────────────────────────
3072
3076
 
3073
3077
  function formatResultLine(repo: Repo): string {
3074
- const stars = formatStars(repo.stars);
3078
+ const stars = `★ ${fmtStars(repo.stars)}`;
3075
3079
  const lang = (repo.language ?? "?").padEnd(12).slice(0, 12);
3076
3080
  const name = repo.fullName.padEnd(40).slice(0, 40);
3077
3081
  return `${name} ${lang} ${stars}`;
3078
3082
  }
3079
3083
 
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
3084
  function formatToolbar(
3087
3085
  sort: SortStrategy | PackageSortMode,
3088
3086
  limit: number,