github-search-cli 9.4.2 → 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 (52) hide show
  1. package/CHANGELOG.md +26 -4
  2. package/README.md +8 -3
  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 +761 -342
  9. package/dist/{config-GNZOQRTH.js → config-OCIPF2IF.js} +1 -1
  10. package/package.json +6 -3
  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 +17 -1
  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/errors.ts +62 -59
  22. package/src/explore.ts +34 -29
  23. package/src/export.ts +73 -63
  24. package/src/help.ts +157 -157
  25. package/src/history.ts +69 -55
  26. package/src/init.ts +60 -55
  27. package/src/landing.ts +106 -104
  28. package/src/login.ts +186 -0
  29. package/src/markdown-render.ts +1 -6
  30. package/src/notifications.ts +75 -59
  31. package/src/open-url.ts +16 -16
  32. package/src/org.ts +43 -15
  33. package/src/output.ts +153 -137
  34. package/src/package-browser.ts +317 -305
  35. package/src/package.ts +149 -137
  36. package/src/pipe.ts +60 -54
  37. package/src/releases.ts +182 -144
  38. package/src/saved-searches.ts +72 -50
  39. package/src/search-state.ts +283 -229
  40. package/src/search.ts +678 -653
  41. package/src/session.ts +26 -24
  42. package/src/share.ts +61 -54
  43. package/src/status.ts +68 -61
  44. package/src/storage.ts +136 -106
  45. package/src/themes.ts +259 -116
  46. package/src/tips.ts +42 -42
  47. package/src/toast.ts +70 -69
  48. package/src/trending-parser.ts +104 -0
  49. package/src/trending.ts +481 -462
  50. package/src/tui.ts +149 -153
  51. package/src/types.ts +160 -158
  52. 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
+ }