github-search-cli 9.3.0 → 9.4.0

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.
@@ -0,0 +1,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> = { 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
+ }
package/src/tips.ts CHANGED
@@ -1,6 +1,26 @@
1
1
  /**
2
2
  * Startup tips — rotate through helpful hints.
3
3
  */
4
+
5
+ /** Rotates tips without repeating the same one twice in a row. */
6
+ export class TipRotator {
7
+ private tips: string[];
8
+ private lastIndex = -1;
9
+
10
+ constructor(tips?: string[]) {
11
+ this.tips = tips ?? TIPS;
12
+ }
13
+
14
+ next(): string {
15
+ let idx: number;
16
+ do {
17
+ idx = Math.floor(Math.random() * this.tips.length);
18
+ } while (idx === this.lastIndex && this.tips.length > 1);
19
+ this.lastIndex = idx;
20
+ return this.tips[idx];
21
+ }
22
+ }
23
+
4
24
  const TIPS = [
5
25
  "💡 Tip: Press ? to see all keyboard shortcuts",
6
26
  "💡 Tip: Use Ctrl+R to recall past searches",
@@ -14,14 +34,9 @@ const TIPS = [
14
34
  "💡 Tip: Press Ctrl+P to copy a repo link to clipboard",
15
35
  ];
16
36
 
17
- let lastIndex = -1;
37
+ export const defaultRotator = new TipRotator();
18
38
 
19
39
  /** Get the next tip (never repeats the same tip twice in a row). */
20
40
  export function nextTip(): string {
21
- let idx: number;
22
- do {
23
- idx = Math.floor(Math.random() * TIPS.length);
24
- } while (idx === lastIndex && TIPS.length > 1);
25
- lastIndex = idx;
26
- return TIPS[idx];
41
+ return defaultRotator.next();
27
42
  }
package/src/toast.ts ADDED
@@ -0,0 +1,69 @@
1
+ /** Toast popup — absolute-positioned card pinned top-right, auto-hides. */
2
+ import { BoxRenderable, TextRenderable } from "@opentui/core";
3
+
4
+ /** Only the color keys the toast reads (lazily, so runtime theme swaps apply). */
5
+ export interface ToastColors {
6
+ green: string;
7
+ yellow: string;
8
+ text: string;
9
+ surfaceDim: string;
10
+ }
11
+
12
+ export function createToast(
13
+ renderer: Parameters<BoxRenderable["constructor"]>[0],
14
+ root: { add(child: unknown): unknown },
15
+ colors: ToastColors,
16
+ ) {
17
+ let timer: ReturnType<typeof setTimeout> | undefined;
18
+ const box = new BoxRenderable(renderer, {
19
+ visible: false,
20
+ position: "absolute",
21
+ top: 1,
22
+ right: 2,
23
+ height: 3,
24
+ border: true,
25
+ borderStyle: "rounded",
26
+ borderColor: colors.green,
27
+ backgroundColor: colors.surfaceDim,
28
+ zIndex: 100,
29
+ });
30
+ const text = new TextRenderable(renderer, {
31
+ content: "",
32
+ color: colors.text,
33
+ backgroundColor: colors.surfaceDim,
34
+ height: 1,
35
+ });
36
+ box.add(text);
37
+ root.add(box);
38
+
39
+ return {
40
+ /** Show a message; accent picks the border color. Re-showing resets the timer. */
41
+ show(msg: string, ms = 2000, accent?: string): void {
42
+ text.content = msg;
43
+ box.borderColor = accent ?? colors.green;
44
+ // Message + paddingX(2) + side borders(2)
45
+ box.width = msg.length + 4;
46
+ box.visible = true;
47
+ renderer.requestRender();
48
+ clearTimeout(timer);
49
+ timer = setTimeout(() => {
50
+ box.visible = false;
51
+ renderer.requestRender();
52
+ }, ms);
53
+ },
54
+ hide(): void {
55
+ clearTimeout(timer);
56
+ if (!box.visible) return;
57
+ box.visible = false;
58
+ renderer.requestRender();
59
+ },
60
+ get visible(): boolean {
61
+ return box.visible;
62
+ },
63
+ get borderColor(): string {
64
+ return box.borderColor as string;
65
+ },
66
+ };
67
+ }
68
+
69
+ export type Toast = ReturnType<typeof createToast>;
package/src/tui.ts CHANGED
@@ -113,6 +113,9 @@ import {
113
113
  snoozeUpdateNotices,
114
114
  suppressUpdateNotices,
115
115
  performUpdate,
116
+ fetchReleaseNotes,
117
+ readUpdateState,
118
+ recordPreUpdateState,
116
119
  } from "./update-check";
117
120
  import { debugLog } from "./storage";
118
121
  const colors = {
@@ -1527,6 +1530,33 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1527
1530
  showOverlay("leader");
1528
1531
  }
1529
1532
 
1533
+ // ponytail: post-upgrade panel — fires when lastInstalledVersion != current version
1534
+ // (no network, just reads bundled release notes for the new version)
1535
+ function checkPostUpgradePanel() {
1536
+ const state = readUpdateState();
1537
+ if (state.suppressed) return;
1538
+ if (!cachedVersion) {
1539
+ try {
1540
+ const pkg = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf-8"));
1541
+ cachedVersion = pkg.version;
1542
+ } catch {
1543
+ return;
1544
+ }
1545
+ }
1546
+ const currentVersion = cachedVersion;
1547
+ if (!state.lastInstalledVersion) return;
1548
+ if (state.lastInstalledVersion === currentVersion) return;
1549
+ // We just upgraded — show notes for the new version
1550
+ const notes = fetchReleaseNotes(currentVersion);
1551
+ const notesBlock = notes
1552
+ ? renderMarkdown(notes, { width: 60 })
1553
+ : " No release notes found for this version.";
1554
+ updateSelectedOption = 0;
1555
+ updateText.content = ` Updated ghfind ${state.lastInstalledVersion} → ${currentVersion}\n\n${notesBlock}`;
1556
+ renderUpdateOptions();
1557
+ showOverlay("update");
1558
+ }
1559
+
1530
1560
  async function checkForUpdateAndShow(force: boolean = false) {
1531
1561
  if (!force && !shouldCheckUpdate()) return;
1532
1562
  if (cachedVersion === null) {
@@ -1544,7 +1574,11 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1544
1574
  markUpdateChecked(latest ?? undefined);
1545
1575
  if (latest) {
1546
1576
  updateSelectedOption = 0;
1547
- updateText.content = ` A new version is available\n\n Current: ${currentVersion}\n Latest: ${latest}\n\n Run "npm install -g ghfind"`;
1577
+ const notes = fetchReleaseNotes(latest);
1578
+ const notesBlock = notes
1579
+ ? renderMarkdown(notes, { width: 60 })
1580
+ : ` A new version is available\n\n Current: ${currentVersion}\n Latest: ${latest}\n\n Run "npm install -g ghfind"`;
1581
+ updateText.content = ` A new version is available\n\n Current: ${currentVersion}\n Latest: ${latest}\n\n${notesBlock}`;
1548
1582
  renderUpdateOptions();
1549
1583
  showOverlay("update");
1550
1584
  } else {
@@ -2577,6 +2611,8 @@ ${pack.description ?? ""}`;
2577
2611
  const action = ["now", "later", "never"][updateSelectedOption] as "now" | "later" | "never" | undefined;
2578
2612
  if (action === "now") {
2579
2613
  showOverlay("none");
2614
+ // ponytail: record the running version so the next launch detects the upgrade
2615
+ recordPreUpdateState(cachedVersion ?? "");
2580
2616
  performUpdate().then((ok) => {
2581
2617
  if (ok) {
2582
2618
  setStatus("✓ Update complete — restart ghfind");
@@ -2772,6 +2808,7 @@ ${pack.description ?? ""}`;
2772
2808
  showLanding(true);
2773
2809
  }
2774
2810
  checkForUpdateAndShow();
2811
+ checkPostUpgradePanel();
2775
2812
  renderer.start();
2776
2813
  if (session && session.mode) {
2777
2814
  if (currentMode === "trending") {
package/src/types.ts CHANGED
@@ -104,6 +104,8 @@ export interface Bookmark {
104
104
  savedAt: number;
105
105
  tags: string[];
106
106
  notes?: string;
107
+ /** Releases published after this are "unseen". Set when the Releases panel is viewed. */
108
+ lastSeenAt?: number;
107
109
  }
108
110
 
109
111
  /** A named, saved search. */
@@ -120,7 +122,7 @@ export interface SavedSearch {
120
122
 
121
123
  /** Session state restored on startup. */
122
124
  export interface SessionState {
123
- mode: "search" | "trending" | "packages";
125
+ mode: "search" | "trending" | "packages" | "bookmarks";
124
126
  query: string;
125
127
  sort: SortStrategy;
126
128
  limit: number;
@@ -6,10 +6,15 @@
6
6
  */
7
7
  import { readJSON, writeJSON, debugLog } from "./storage";
8
8
  import { stateDir } from "./config";
9
+ import { readFileSync } from "fs";
10
+ import { join, dirname } from "path";
11
+ import { fileURLToPath } from "url";
9
12
 
10
13
  const REGISTRY_URL = "https://registry.npmjs.org/github-search-cli/latest";
11
14
  const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
12
15
  const REQUEST_TIMEOUT_MS = 5000;
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const PKG_DIR = join(__dirname, "..");
13
18
 
14
19
  export interface UpdateState {
15
20
  /** Timestamp of last successful check. */
@@ -20,6 +25,8 @@ export interface UpdateState {
20
25
  suppressed: boolean;
21
26
  /** Latest version found on last check (for display). */
22
27
  latestVersion?: string;
28
+ /** Version at time of last successful install (populated before performUpdate). */
29
+ lastInstalledVersion?: string;
23
30
  }
24
31
 
25
32
  const STATE_FILE = "update-state.json";
@@ -160,3 +167,49 @@ export async function performUpdate(): Promise<boolean> {
160
167
  return false;
161
168
  }
162
169
  }
170
+
171
+
172
+ /**
173
+ * Record the current version as "last installed" before performing an update.
174
+ * This lets the post-upgrade startup detect the version change and show notes.
175
+ */
176
+ export function recordPreUpdateState(currentVersion: string): void {
177
+ writeUpdateState({
178
+ ...readUpdateState(),
179
+ lastInstalledVersion: currentVersion,
180
+ });
181
+ }
182
+
183
+ /**
184
+ * Fetch release notes for a given version.
185
+ * Tries release-notes/<version>.md first, then falls back to CHANGELOG.md section.
186
+ * Returns the markdown text or null if not found.
187
+ */
188
+ export function fetchReleaseNotes(version: string): string | null {
189
+ // ponytail: embedded notes are the primary source — they ship in the tarball, no network
190
+ try {
191
+ const notesPath = join(PKG_DIR, "release-notes", `${version}.md`);
192
+ const md = readFileSync(notesPath, "utf-8");
193
+ if (md.trim().length > 0) return md;
194
+ } catch {
195
+ // File doesn't exist for this version — fall through to CHANGELOG
196
+ }
197
+
198
+ // ponytail: CHANGELOG.md fallback — parse section for this version
199
+ try {
200
+ const changelogPath = join(PKG_DIR, "CHANGELOG.md");
201
+ const changelog = readFileSync(changelogPath, "utf-8");
202
+ // ponytail: find "## [version]" header, slice until next "## "
203
+ const header = "## [" + version + "]";
204
+ const start = changelog.indexOf(header);
205
+ if (start === -1) return null;
206
+ const bodyStart = start + header.length;
207
+ const rest = changelog.substring(bodyStart);
208
+ const nextHeaderIdx = rest.indexOf("\n## ");
209
+ const end = nextHeaderIdx === -1 ? changelog.length : bodyStart + nextHeaderIdx;
210
+ return changelog.substring(bodyStart, end).trim();
211
+ } catch {
212
+ // CHANGELOG.md doesn't exist either
213
+ }
214
+ return null;
215
+ }