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.
@@ -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
@@ -15,7 +15,13 @@ import type {
15
15
  SortStrategy,
16
16
  CacheEntry,
17
17
  } from "./types";
18
- import { NetworkError, RateLimitError, ParseError } from "./errors";
18
+ import {
19
+ NetworkError,
20
+ RateLimitError,
21
+ ParseError,
22
+ AuthError,
23
+ ForbiddenError,
24
+ } from "./errors";
19
25
  import { parseTrendingHtml, type RawTrendingRepo } from "./trending-parser";
20
26
 
21
27
  export interface Logger {
@@ -58,6 +64,10 @@ export class MemoryCache<T> {
58
64
  }
59
65
 
60
66
  set(key: string, data: T, ttlMs?: number): void {
67
+ // Re-inserting an existing key must move it to the end of the
68
+ // insertion-ordered Map, otherwise a refreshed entry keeps its original
69
+ // position and is the first one evicted — FIFO instead of LRU.
70
+ this.cache.delete(key);
61
71
  if (this.cache.size >= MAX_CACHE_SIZE) {
62
72
  const firstKey = this.cache.keys().next().value;
63
73
  if (firstKey !== undefined) this.cache.delete(firstKey);
@@ -444,7 +454,14 @@ export class GitHubSearchAdapter implements SearchAdapter {
444
454
  this.logger.error(
445
455
  `[github] non-rate-limit 403: ${body.slice(0, 200)}`,
446
456
  );
447
- throw new NetworkError();
457
+ throw new ForbiddenError();
458
+ }
459
+ if (res.status === 401) {
460
+ const body = await res.text().catch(() => "");
461
+ this.logger.error(
462
+ `[github] auth rejected (401): ${body.slice(0, 200)}`,
463
+ );
464
+ throw new AuthError();
448
465
  }
449
466
  if (!res.ok) {
450
467
  const body = await res.text().catch(() => "");
@@ -488,6 +505,10 @@ export class GitHubSearchAdapter implements SearchAdapter {
488
505
  lastErr = undefined;
489
506
  break;
490
507
  } catch (err) {
508
+ // RateLimitError must propagate: swallowing it turns the failure
509
+ // into a silent zero-result "success" and the user never learns
510
+ // why their search died.
511
+ if (err instanceof RateLimitError) throw err;
491
512
  lastErr = err as Error;
492
513
  if (rateLimited) break;
493
514
  }
package/src/session.ts CHANGED
@@ -6,9 +6,63 @@ import { readJSON, writeJSON } from "./storage";
6
6
 
7
7
  const SESSION_FILE = "session.json";
8
8
 
9
+ /** Upper bound for a plausible search query; anything longer is garbage. */
10
+ const MAX_QUERY_LENGTH = 200;
11
+
12
+ /**
13
+ * Drop JSON-shaped or oversized queries. A search line is never raw JSON
14
+ * (objects/arrays always arrive as paste payloads — e.g. an MCP handshake
15
+ * request that a client piped into the TUI) and never thousands of chars,
16
+ * so such a "query" is corruption, not intent. The TUI's input handler
17
+ * applies the same shape rule to live pastes.
18
+ */
19
+ export function sanitizeQuery(query: unknown): string | undefined {
20
+ if (typeof query !== "string") return undefined;
21
+ const trimmed = query.trim();
22
+ if (trimmed.length === 0 || trimmed.length > MAX_QUERY_LENGTH) {
23
+ return undefined;
24
+ }
25
+ if (
26
+ (trimmed.startsWith("{") && trimmed.endsWith("}")) ||
27
+ (trimmed.startsWith("[") && trimmed.endsWith("]"))
28
+ ) {
29
+ return undefined;
30
+ }
31
+ return trimmed;
32
+ }
33
+
34
+ /** Keep only well-formed, known-good fields from a session snapshot. */
35
+ export function sanitizeSession(
36
+ state: SessionState | null | undefined,
37
+ ): SessionState | null {
38
+ if (!state || typeof state !== "object") return null;
39
+ const mode =
40
+ state.mode === "trending" ||
41
+ state.mode === "packages" ||
42
+ state.mode === "search"
43
+ ? state.mode
44
+ : "search";
45
+ const trendingTab =
46
+ typeof state.trendingTab === "string" && state.trendingTab.length > 0
47
+ ? state.trendingTab
48
+ : "This Week";
49
+ const limit =
50
+ typeof state.limit === "number" && Number.isFinite(state.limit)
51
+ ? state.limit
52
+ : 50;
53
+ const sort = typeof state.sort === "string" ? state.sort : "best-match";
54
+ return {
55
+ mode,
56
+ query: sanitizeQuery(state.query) ?? "",
57
+ sort,
58
+ limit,
59
+ trendingTab,
60
+ };
61
+ }
62
+
9
63
  export function saveSession(state: SessionState): void {
10
64
  try {
11
- writeJSON(SESSION_FILE, state);
65
+ writeJSON(SESSION_FILE, sanitizeSession(state));
12
66
  } catch (err) {
13
67
  if (process.env.DEBUG)
14
68
  console.error(
@@ -18,7 +72,9 @@ export function saveSession(state: SessionState): void {
18
72
  }
19
73
 
20
74
  export function restoreSession(): SessionState | null {
21
- return readJSON<SessionState | null>(SESSION_FILE, null);
75
+ // Anything read from disk passes the same sanitizer as anything written —
76
+ // protects against files corrupted before this guard existed.
77
+ return sanitizeSession(readJSON<SessionState | null>(SESSION_FILE, null));
22
78
  }
23
79
 
24
80
  export function clearSession(): void {
@@ -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
- };