github-search-cli 9.4.1 → 9.4.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +65 -1
  2. package/README.md +268 -237
  3. package/bin/ghfind.js +98 -89
  4. package/completions/ghfind.bash +1 -1
  5. package/completions/ghfind.fish +3 -1
  6. package/completions/ghfind.zsh +6 -0
  7. package/dist/{chunk-IVUU2KGR.js → chunk-GYIL4S5X.js} +4 -1
  8. package/dist/cli.js +1179 -403
  9. package/dist/{config-GNZOQRTH.js → config-OCIPF2IF.js} +1 -1
  10. package/package.json +16 -5
  11. package/release-notes/9.3.0.md +32 -29
  12. package/scripts/build.ts +34 -19
  13. package/scripts/generate-svg-demo.js +218 -168
  14. package/scripts/postinstall.js +176 -176
  15. package/src/bookmarks.ts +69 -69
  16. package/src/cli.ts +650 -566
  17. package/src/compare.ts +69 -47
  18. package/src/config.ts +70 -65
  19. package/src/deepdive.ts +149 -118
  20. package/src/doctor.ts +287 -287
  21. package/src/error-report.ts +8 -12
  22. package/src/errors.ts +62 -59
  23. package/src/explore.ts +34 -29
  24. package/src/export.ts +73 -63
  25. package/src/help.ts +157 -157
  26. package/src/history.ts +69 -55
  27. package/src/init.ts +60 -55
  28. package/src/landing.ts +106 -104
  29. package/src/login.ts +186 -0
  30. package/src/markdown-render.ts +1 -6
  31. package/src/notifications.ts +75 -59
  32. package/src/open-url.ts +16 -16
  33. package/src/org.ts +299 -0
  34. package/src/output.ts +153 -137
  35. package/src/package-browser.ts +317 -305
  36. package/src/package.ts +149 -137
  37. package/src/pipe.ts +60 -54
  38. package/src/releases.ts +182 -144
  39. package/src/saved-searches.ts +72 -50
  40. package/src/search-state.ts +283 -229
  41. package/src/search.ts +678 -653
  42. package/src/session.ts +26 -24
  43. package/src/share.ts +61 -54
  44. package/src/status.ts +68 -61
  45. package/src/storage.ts +136 -106
  46. package/src/themes.ts +259 -116
  47. package/src/tips.ts +42 -42
  48. package/src/toast.ts +70 -69
  49. package/src/trending-parser.ts +104 -0
  50. package/src/trending.ts +481 -462
  51. package/src/tui.ts +3015 -2867
  52. package/src/types.ts +160 -158
  53. package/src/update-check.ts +231 -215
  54. package/src/version.ts +33 -0
  55. package/src/watch.ts +95 -74
package/src/releases.ts CHANGED
@@ -1,144 +1,182 @@
1
- /**
2
- * Release tracker — checks bookmarked repos for new GitHub releases.
3
- *
4
- * Decisions (map 003):
5
- * - Signal: new GitHub releases only (`GET /repos/{o}/{r}/releases?per_page=5`).
6
- * - Seen state: `lastSeenAt` per bookmark; "new" = published after it.
7
- * - Rate budget: etag conditional requests + token when present.
8
- * - Prereleases shown (tagged `[pre]`), drafts never.
9
- *
10
- * ponytail: sequential fetches — fine for ≤100 bookmarks at launch;
11
- * parallelize with p-limit if users report slow startup.
12
- */
13
- import type { Bookmark } from "./types";
14
- import { readJSON, writeJSON } from "./storage";
15
- import { getBookmarks, getLastSeen, markSeen } from "./bookmarks";
16
- import { addNotification } from "./notifications";
17
- import { loadConfig } from "./config";
18
-
19
- const RELEASES_FILE = "release-cache.json";
20
- const PER_REPO = 5;
21
-
22
- export interface TrackedRelease {
23
- id: number;
24
- tagName: string;
25
- name: string;
26
- url: string;
27
- publishedAt: string;
28
- prerelease: boolean;
29
- }
30
-
31
- /** Persisted cache: etag + last-known releases per repo. Only what the feed needs. */
32
- interface RepoReleaseState {
33
- etag?: string;
34
- checkedAt: number;
35
- releases: TrackedRelease[];
36
- }
37
-
38
- type ReleaseCache = Record<string, RepoReleaseState>;
39
-
40
- function loadCache(): ReleaseCache {
41
- return readJSON<ReleaseCache>(RELEASES_FILE, {});
42
- }
43
-
44
- function saveCache(cache: ReleaseCache): void {
45
- writeJSON(RELEASES_FILE, cache);
46
- }
47
-
48
- function toTracked(raw: Array<Record<string, unknown>>): TrackedRelease[] {
49
- return raw
50
- .filter((r) => r.draft !== true) // drafts: never shown
51
- .map((r) => ({
52
- id: r.id as number,
53
- tagName: r.tag_name as string,
54
- name: (r.name as string) || (r.tag_name as string),
55
- url: r.html_url as string,
56
- publishedAt: (r.published_at as string) || "",
57
- prerelease: r.prerelease === true,
58
- }));
59
- }
60
-
61
- /** Fetch /releases for one repo using its cached etag. */
62
- async function fetchReleases(
63
- fullName: string,
64
- etag: string | undefined,
65
- token: string | undefined,
66
- ): Promise<{ status: number; etag?: string; releases?: TrackedRelease[] }> {
67
- const headers: Record<string, string> = { Accept: "application/vnd.github+json" };
68
- if (etag) headers["If-None-Match"] = etag;
69
- if (token) headers["Authorization"] = `Bearer ${token}`;
70
- const res = await fetch(`https://api.github.com/repos/${fullName}/releases?per_page=${PER_REPO}`, { headers });
71
- if (res.status === 304 || !res.ok) return { status: res.status };
72
- return {
73
- status: 200,
74
- etag: res.headers.get("etag") ?? undefined,
75
- releases: toTracked((await res.json()) as Array<Record<string, unknown>>),
76
- };
77
- }
78
-
79
- /**
80
- * Check all bookmarks for releases newer than their lastSeenAt.
81
- * Returns one result per bookmark so callers can render feeds or counts.
82
- */
83
- export async function checkReleases(
84
- opts: { token?: string } = {},
85
- ): Promise<Array<{ fullName: string; newReleases: TrackedRelease[]; error?: string }>> {
86
- const bookmarks = getBookmarks();
87
- const cache = loadCache();
88
- const config = loadConfig();
89
- const token = opts.token ?? config.githubToken ?? process.env.GITHUB_TOKEN;
90
- const results: Array<{ fullName: string; newReleases: TrackedRelease[]; error?: string }> = [];
91
-
92
- for (const bm of bookmarks) {
93
- try {
94
- const prev = cache[bm.repo.fullName];
95
- const lastSeen = getLastSeen(bm.repo.fullName);
96
- const isNew = (r: TrackedRelease) =>
97
- !r.publishedAt || new Date(r.publishedAt).getTime() > lastSeen;
98
-
99
- const { status, etag, releases } = await fetchReleases(bm.repo.fullName, prev?.etag, token);
100
-
101
- if (status === 304 && prev) {
102
- // Unchanged upstream; recompute against lastSeenAt (it may have moved).
103
- results.push({ fullName: bm.repo.fullName, newReleases: prev.releases.filter(isNew) });
104
- continue;
105
- }
106
- if (status !== 200 || !releases) {
107
- results.push({ fullName: bm.repo.fullName, newReleases: [], error: `HTTP ${status}` });
108
- continue;
109
- }
110
- cache[bm.repo.fullName] = { checkedAt: Date.now(), etag, releases };
111
- results.push({ fullName: bm.repo.fullName, newReleases: releases.filter(isNew) });
112
- } catch (err) {
113
- results.push({ fullName: bm.repo.fullName, newReleases: [], error: (err as Error).message });
114
- }
115
- }
116
-
117
- saveCache(cache);
118
- return results;
119
- }
120
-
121
- /** Check + push unseen releases into the notification center, then mark them seen. Returns per-repo results. */
122
- export async function checkAndNotify(opts: { token?: string } = {}): Promise<Array<{ fullName: string; newReleases: TrackedRelease[]; error?: string }>> {
123
- const results = await checkReleases(opts);
124
- for (const r of results) {
125
- for (const rel of r.newReleases) {
126
- addNotification(
127
- "release",
128
- `${r.fullName} released ${rel.tagName}${rel.prerelease ? " [pre]" : ""}`,
129
- );
130
- }
131
- if (r.newReleases.length > 0) markSeen(r.fullName);
132
- }
133
- return results;
134
- }
135
-
136
- /** All cached releases across bookmarks, newest first (for the Releases panel / CLI). */
137
- export function allCachedReleases(): Array<TrackedRelease & { fullName: string }> {
138
- const cache = loadCache();
139
- const bookmarks = getBookmarks();
140
- return Object.entries(cache)
141
- .filter(([fullName]) => bookmarks.some((b) => b.repo.fullName === fullName))
142
- .flatMap(([fullName, s]) => s.releases.map((r) => ({ ...r, fullName })))
143
- .sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime());
144
- }
1
+ /**
2
+ * Release tracker — checks bookmarked repos for new GitHub releases.
3
+ *
4
+ * Decisions (map 003):
5
+ * - Signal: new GitHub releases only (`GET /repos/{o}/{r}/releases?per_page=5`).
6
+ * - Seen state: `lastSeenAt` per bookmark; "new" = published after it.
7
+ * - Rate budget: etag conditional requests + token when present.
8
+ * - Prereleases shown (tagged `[pre]`), drafts never.
9
+ *
10
+ * ponytail: sequential fetches — fine for ≤100 bookmarks at launch;
11
+ * parallelize with p-limit if users report slow startup.
12
+ */
13
+ import type { Bookmark } from "./types";
14
+ import { readJSON, writeJSON } from "./storage";
15
+ import { getBookmarks, getLastSeen, markSeen } from "./bookmarks";
16
+ import { addNotification } from "./notifications";
17
+ import { loadConfig } from "./config";
18
+
19
+ const RELEASES_FILE = "release-cache.json";
20
+ const PER_REPO = 5;
21
+
22
+ export interface TrackedRelease {
23
+ id: number;
24
+ tagName: string;
25
+ name: string;
26
+ url: string;
27
+ publishedAt: string;
28
+ prerelease: boolean;
29
+ }
30
+
31
+ /** Persisted cache: etag + last-known releases per repo. Only what the feed needs. */
32
+ interface RepoReleaseState {
33
+ etag?: string;
34
+ checkedAt: number;
35
+ releases: TrackedRelease[];
36
+ }
37
+
38
+ type ReleaseCache = Record<string, RepoReleaseState>;
39
+
40
+ function loadCache(): ReleaseCache {
41
+ return readJSON<ReleaseCache>(RELEASES_FILE, {});
42
+ }
43
+
44
+ function saveCache(cache: ReleaseCache): void {
45
+ writeJSON(RELEASES_FILE, cache);
46
+ }
47
+
48
+ function toTracked(raw: Array<Record<string, unknown>>): TrackedRelease[] {
49
+ return raw
50
+ .filter((r) => r.draft !== true) // drafts: never shown
51
+ .map((r) => ({
52
+ id: r.id as number,
53
+ tagName: r.tag_name as string,
54
+ name: (r.name as string) || (r.tag_name as string),
55
+ url: r.html_url as string,
56
+ publishedAt: (r.published_at as string) || "",
57
+ prerelease: r.prerelease === true,
58
+ }));
59
+ }
60
+
61
+ /** Fetch /releases for one repo using its cached etag. */
62
+ async function fetchReleases(
63
+ fullName: string,
64
+ etag: string | undefined,
65
+ token: string | undefined,
66
+ ): Promise<{ status: number; etag?: string; releases?: TrackedRelease[] }> {
67
+ const headers: Record<string, string> = {
68
+ Accept: "application/vnd.github+json",
69
+ };
70
+ if (etag) headers["If-None-Match"] = etag;
71
+ if (token) headers["Authorization"] = `Bearer ${token}`;
72
+ const res = await fetch(
73
+ `https://api.github.com/repos/${fullName}/releases?per_page=${PER_REPO}`,
74
+ { headers },
75
+ );
76
+ if (res.status === 304 || !res.ok) return { status: res.status };
77
+ return {
78
+ status: 200,
79
+ etag: res.headers.get("etag") ?? undefined,
80
+ releases: toTracked((await res.json()) as Array<Record<string, unknown>>),
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Check all bookmarks for releases newer than their lastSeenAt.
86
+ * Returns one result per bookmark so callers can render feeds or counts.
87
+ */
88
+ export async function checkReleases(
89
+ opts: { token?: string } = {},
90
+ ): Promise<
91
+ Array<{ fullName: string; newReleases: TrackedRelease[]; error?: string }>
92
+ > {
93
+ const bookmarks = getBookmarks();
94
+ const cache = loadCache();
95
+ const config = loadConfig();
96
+ const token = opts.token ?? config.githubToken ?? process.env.GITHUB_TOKEN;
97
+ const results: Array<{
98
+ fullName: string;
99
+ newReleases: TrackedRelease[];
100
+ error?: string;
101
+ }> = [];
102
+
103
+ for (const bm of bookmarks) {
104
+ try {
105
+ const prev = cache[bm.repo.fullName];
106
+ const lastSeen = getLastSeen(bm.repo.fullName);
107
+ const isNew = (r: TrackedRelease) =>
108
+ !r.publishedAt || new Date(r.publishedAt).getTime() > lastSeen;
109
+
110
+ const { status, etag, releases } = await fetchReleases(
111
+ bm.repo.fullName,
112
+ prev?.etag,
113
+ token,
114
+ );
115
+
116
+ if (status === 304 && prev) {
117
+ // Unchanged upstream; recompute against lastSeenAt (it may have moved).
118
+ results.push({
119
+ fullName: bm.repo.fullName,
120
+ newReleases: prev.releases.filter(isNew),
121
+ });
122
+ continue;
123
+ }
124
+ if (status !== 200 || !releases) {
125
+ results.push({
126
+ fullName: bm.repo.fullName,
127
+ newReleases: [],
128
+ error: `HTTP ${status}`,
129
+ });
130
+ continue;
131
+ }
132
+ cache[bm.repo.fullName] = { checkedAt: Date.now(), etag, releases };
133
+ results.push({
134
+ fullName: bm.repo.fullName,
135
+ newReleases: releases.filter(isNew),
136
+ });
137
+ } catch (err) {
138
+ results.push({
139
+ fullName: bm.repo.fullName,
140
+ newReleases: [],
141
+ error: (err as Error).message,
142
+ });
143
+ }
144
+ }
145
+
146
+ saveCache(cache);
147
+ return results;
148
+ }
149
+
150
+ /** Check + push unseen releases into the notification center, then mark them seen. Returns per-repo results. */
151
+ export async function checkAndNotify(
152
+ opts: { token?: string } = {},
153
+ ): Promise<
154
+ Array<{ fullName: string; newReleases: TrackedRelease[]; error?: string }>
155
+ > {
156
+ const results = await checkReleases(opts);
157
+ for (const r of results) {
158
+ for (const rel of r.newReleases) {
159
+ addNotification(
160
+ "release",
161
+ `${r.fullName} released ${rel.tagName}${rel.prerelease ? " [pre]" : ""}`,
162
+ );
163
+ }
164
+ if (r.newReleases.length > 0) markSeen(r.fullName);
165
+ }
166
+ return results;
167
+ }
168
+
169
+ /** All cached releases across bookmarks, newest first (for the Releases panel / CLI). */
170
+ export function allCachedReleases(): Array<
171
+ TrackedRelease & { fullName: string }
172
+ > {
173
+ const cache = loadCache();
174
+ const bookmarks = getBookmarks();
175
+ return Object.entries(cache)
176
+ .filter(([fullName]) => bookmarks.some((b) => b.repo.fullName === fullName))
177
+ .flatMap(([fullName, s]) => s.releases.map((r) => ({ ...r, fullName })))
178
+ .sort(
179
+ (a, b) =>
180
+ new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(),
181
+ );
182
+ }
@@ -1,50 +1,72 @@
1
- /**
2
- * Saved searches — named queries you can re-run anytime.
3
- */
4
- import type { SavedSearch, SortStrategy } from "./types";
5
- import { readJSON, writeJSON } from "./storage";
6
-
7
- const SAVED_FILE = "saved-searches.json";
8
-
9
- function loadRaw(): SavedSearch[] {
10
- return readJSON<SavedSearch[]>(SAVED_FILE, []);
11
- }
12
-
13
- function saveRaw(searches: SavedSearch[]): void {
14
- writeJSON(SAVED_FILE, searches);
15
- }
16
-
17
- /** Get all saved searches, sorted by last run time (most recent first). */
18
- export function getSavedSearches(): SavedSearch[] {
19
- return loadRaw().sort((a, b) => (b.lastRunAt ?? b.createdAt) - (a.lastRunAt ?? a.createdAt));
20
- }
21
-
22
- /** Save a new search. Overwrites if name already exists. */
23
- export function saveSearch(name: string, query: string, mode: "search" | "trending", sort: SortStrategy, limit: number, tab?: string): SavedSearch {
24
- const searches = loadRaw();
25
- const now = Date.now();
26
- const existing = searches.findIndex((s) => s.name === name);
27
- const entry: SavedSearch = { name, query, mode, sort, limit, tab, createdAt: now, lastRunAt: now };
28
- if (existing >= 0) {
29
- searches[existing] = { ...searches[existing], ...entry, createdAt: searches[existing].createdAt };
30
- } else {
31
- searches.push(entry);
32
- }
33
- saveRaw(searches);
34
- return entry;
35
- }
36
-
37
- /** Delete a saved search by name. */
38
- export function deleteSavedSearch(name: string): void {
39
- saveRaw(loadRaw().filter((s) => s.name !== name));
40
- }
41
-
42
- /** Update lastRunAt for a saved search. */
43
- export function touchSavedSearch(name: string): void {
44
- const searches = loadRaw();
45
- const found = searches.find((s) => s.name === name);
46
- if (found) {
47
- found.lastRunAt = Date.now();
48
- saveRaw(searches);
49
- }
50
- }
1
+ /**
2
+ * Saved searches — named queries you can re-run anytime.
3
+ */
4
+ import type { SavedSearch, SortStrategy } from "./types";
5
+ import { readJSON, writeJSON } from "./storage";
6
+
7
+ const SAVED_FILE = "saved-searches.json";
8
+
9
+ function loadRaw(): SavedSearch[] {
10
+ return readJSON<SavedSearch[]>(SAVED_FILE, []);
11
+ }
12
+
13
+ function saveRaw(searches: SavedSearch[]): void {
14
+ writeJSON(SAVED_FILE, searches);
15
+ }
16
+
17
+ /** Get all saved searches, sorted by last run time (most recent first). */
18
+ export function getSavedSearches(): SavedSearch[] {
19
+ return loadRaw().sort(
20
+ (a, b) => (b.lastRunAt ?? b.createdAt) - (a.lastRunAt ?? a.createdAt),
21
+ );
22
+ }
23
+
24
+ /** Save a new search. Overwrites if name already exists. */
25
+ export function saveSearch(
26
+ name: string,
27
+ query: string,
28
+ mode: "search" | "trending",
29
+ sort: SortStrategy,
30
+ limit: number,
31
+ tab?: string,
32
+ ): SavedSearch {
33
+ const searches = loadRaw();
34
+ const now = Date.now();
35
+ const existing = searches.findIndex((s) => s.name === name);
36
+ const entry: SavedSearch = {
37
+ name,
38
+ query,
39
+ mode,
40
+ sort,
41
+ limit,
42
+ tab,
43
+ createdAt: now,
44
+ lastRunAt: now,
45
+ };
46
+ if (existing >= 0) {
47
+ searches[existing] = {
48
+ ...searches[existing],
49
+ ...entry,
50
+ createdAt: searches[existing].createdAt,
51
+ };
52
+ } else {
53
+ searches.push(entry);
54
+ }
55
+ saveRaw(searches);
56
+ return entry;
57
+ }
58
+
59
+ /** Delete a saved search by name. */
60
+ export function deleteSavedSearch(name: string): void {
61
+ saveRaw(loadRaw().filter((s) => s.name !== name));
62
+ }
63
+
64
+ /** Update lastRunAt for a saved search. */
65
+ export function touchSavedSearch(name: string): void {
66
+ const searches = loadRaw();
67
+ const found = searches.find((s) => s.name === name);
68
+ if (found) {
69
+ found.lastRunAt = Date.now();
70
+ saveRaw(searches);
71
+ }
72
+ }