github-search-cli 9.8.3 → 9.9.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.
package/src/trending.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  import type { CliRenderer } from "@opentui/core";
15
15
  import type { Repo, ParsedQuery } from "./types";
16
16
  import { openUrl } from "./open-url";
17
- import { SearchModule, TrendingAdapter } from "./search";
17
+ import { SearchModule, TrendingAdapter, trendingQuery } from "./search";
18
18
  import { detectTerminalBackground, deriveSurfaceLayers } from "./themes";
19
19
  // ─── Tokyo Night palette ──────────────────────────────────────────────
20
20
  const C = {
@@ -207,11 +207,7 @@ export async function launchTrending(): Promise<void> {
207
207
  renderer.requestRender();
208
208
  try {
209
209
  const searchModule = new SearchModule(new TrendingAdapter());
210
- const parsed: ParsedQuery = {
211
- keywords: [],
212
- qualifiers: [],
213
- raw: "trending",
214
- };
210
+ const parsed: ParsedQuery = trendingQuery();
215
211
  const response = await searchModule.search(parsed, {
216
212
  limit: 25,
217
213
  sort: "stars",
package/src/tui.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  SelectRenderable,
32
32
  ScrollBoxRenderable,
33
33
  } from "@opentui/core";
34
+ import { appendFileSync } from "node:fs";
34
35
  import type { CliRenderer } from "@opentui/core";
35
36
  import type {
36
37
  Repo,
@@ -47,6 +48,8 @@ import {
47
48
  applyFlagFilters,
48
49
  validateQuery,
49
50
  suggestFor,
51
+ suggestRelaxations,
52
+ trendingQuery,
50
53
  rankRepos,
51
54
  createGitHubSearch,
52
55
  SearchModule,
@@ -120,6 +123,7 @@ import {
120
123
  fetchReleaseNotes,
121
124
  readUpdateState,
122
125
  recordPreUpdateState,
126
+ markPostUpgradeSeen,
123
127
  } from "./update-check";
124
128
  import { debugLog } from "./storage";
125
129
  import {
@@ -152,12 +156,41 @@ const colors: Record<string, string> = {
152
156
  };
153
157
 
154
158
  // ─── Logger ───────────────────────────────────────────────────────────
155
- const logger: Logger = {
156
- debug: () => {},
157
- info: () => {},
158
- warn: () => {},
159
- error: () => {},
160
- };
159
+ /**
160
+ * TUI diagnostic log.
161
+ *
162
+ * The TUI owns the screen, so it cannot print debug output to stdout without
163
+ * corrupting the render — and the logger used to be a silent no-op, which made
164
+ * API failures (403/401) undiagnosable from the status-bar message alone.
165
+ *
166
+ * Set GHFIND_LOG=<path> to append timestamped lines to that file. Unset (the
167
+ * default) keeps logging completely off, so there is no cost in normal use.
168
+ * Only the shape of a response is recorded; tokens and headers are redacted.
169
+ */
170
+ function createTuiLogger(): Logger {
171
+ const target = process.env.GHFIND_LOG;
172
+ if (!target) {
173
+ return { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
174
+ }
175
+ const write = (level: string, msg: string) => {
176
+ try {
177
+ appendFileSync(
178
+ target,
179
+ `[${new Date().toISOString()}] [${level}] ${msg}\n`,
180
+ );
181
+ } catch {
182
+ // A broken log path must never take down the TUI.
183
+ }
184
+ };
185
+ return {
186
+ debug: (m) => write("debug", m),
187
+ info: (m) => write("info", m),
188
+ warn: (m) => write("warn", m),
189
+ error: (m) => write("error", m),
190
+ };
191
+ }
192
+
193
+ const logger: Logger = createTuiLogger();
161
194
 
162
195
  // ─── Sort cycle ───────────────────────────────────────────────────────
163
196
  const SORT_MODES: { key: SortStrategy; label: string }[] = [
@@ -1625,6 +1658,9 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1625
1658
  if (state.lastInstalledVersion === currentVersion) return;
1626
1659
  // We just upgraded — show notes for the new version
1627
1660
  const notes = fetchReleaseNotes(currentVersion);
1661
+ // Acknowledge immediately: this panel's trigger is a version inequality,
1662
+ // so leaving it unacknowledged makes it reappear on every launch.
1663
+ markPostUpgradeSeen(currentVersion);
1628
1664
  updateSelectedOption = 0;
1629
1665
  updateHeader.content = ` Updated ghfind ${state.lastInstalledVersion} → ${currentVersion}\n`;
1630
1666
  updateNotes.setContent(
@@ -2365,16 +2401,13 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
2365
2401
 
2366
2402
  try {
2367
2403
  const searchModule = new SearchModule(new TrendingAdapter());
2368
- const response = await searchModule.search(
2369
- { keywords: [], qualifiers: [], raw: "trending" },
2370
- {
2371
- limit: 25,
2372
- sort: "stars",
2373
- json: false,
2374
- verbose: false,
2375
- trendingSince: tabSince(trendingTab),
2376
- },
2377
- );
2404
+ const response = await searchModule.search(trendingQuery(), {
2405
+ limit: 25,
2406
+ sort: "stars",
2407
+ json: false,
2408
+ verbose: false,
2409
+ trendingSince: tabSince(trendingTab),
2410
+ });
2378
2411
  const fetched = response.repos;
2379
2412
  const since = tabSince(trendingTab);
2380
2413
  trendingPeriod =
@@ -2528,7 +2561,12 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
2528
2561
  }
2529
2562
 
2530
2563
  if (currentRepos.length === 0) {
2531
- throw new NoResultsError(q);
2564
+ // A zero-result search usually means one qualifier over-constrained
2565
+ // it. Offer the ranked loosenings instead of generic advice.
2566
+ throw new NoResultsError(
2567
+ q,
2568
+ suggestRelaxations(parsed).map((r) => r.suggestion),
2569
+ );
2532
2570
  } else {
2533
2571
  resultsSelect.options = currentRepos.map((r) => ({
2534
2572
  name: formatResultLine(r),
package/src/types.ts CHANGED
@@ -10,14 +10,24 @@ export interface Qualifier {
10
10
  negated: boolean;
11
11
  }
12
12
 
13
+ /** A normalization applied to a qualifier while parsing (typo fix, `10k` → `>=10000`). */
14
+ export interface QueryCorrection {
15
+ /** What the user typed, e.g. `langauge:rust`. */
16
+ from: string;
17
+ /** What it was normalized to, e.g. `language:Rust`. */
18
+ to: string;
19
+ }
20
+
13
21
  /** The fully parsed, normalized representation of a user's search request. */
14
22
  export interface ParsedQuery {
15
23
  /** Free-text keywords (everything that is not a qualifier). */
16
24
  keywords: string[];
17
- /** Structured qualifiers extracted from the query string. */
25
+ /** Structured qualifiers extracted from the query string, normalized. */
18
26
  qualifiers: Qualifier[];
19
27
  /** The original, untouched query string (preserved for debugging/display). */
20
28
  raw: string;
29
+ /** Normalizations applied while parsing. Empty when the query was already canonical. */
30
+ corrections: QueryCorrection[];
21
31
  }
22
32
 
23
33
  /** A repository as returned by a search provider, normalized to a stable shape. */
@@ -59,6 +59,32 @@ export function shouldCheckUpdate(): boolean {
59
59
  return true;
60
60
  }
61
61
 
62
+ /**
63
+ * Split a semver string into comparable numeric parts.
64
+ *
65
+ * A leading `v` is tolerated. Pre-release (`-beta.1`) and build (`+build.5`)
66
+ * suffixes are both stripped first, so `9.9.0+build.5` and `9.9.0` compare as
67
+ * the same release — leaving them in makes `Number("0+build")` NaN.
68
+ */
69
+ function semverParts(v: string): number[] {
70
+ const core = v.trim().replace(/^v/i, "").split(/[-+]/)[0];
71
+ return core.split(".").map((s) => {
72
+ const n = Number(s);
73
+ return Number.isFinite(n) ? n : 0;
74
+ });
75
+ }
76
+
77
+ /**
78
+ * True when `v` carries a pre-release tag (`-beta.1`, `-rc.2`, …).
79
+ * npm semantics: a pre-release is OLDER than the matching release.
80
+ */
81
+ function isPrerelease(v: string): boolean {
82
+ const core = v.trim().replace(/^v/i, "");
83
+ const plus = core.indexOf("+");
84
+ const coreOnly = plus === -1 ? core : core.slice(0, plus);
85
+ return coreOnly.includes("-");
86
+ }
87
+
62
88
  /**
63
89
  * Compare two semver strings. Returns true if `latest` is newer than `current`.
64
90
  * Uses Bun's built-in semver if available, falls back to simple comparison.
@@ -68,16 +94,27 @@ export function isNewerVersion(current: string, latest: string): boolean {
68
94
  if (typeof Bun !== "undefined" && Bun.semver) {
69
95
  return Bun.semver.order(current, latest) < 0;
70
96
  }
71
- // Fallback: naive string comparison (works for simple semver; does not handle pre-release tags)
72
- const curParts = current.split(".").map((s) => Number(s.split("-")[0]));
73
- const newParts = latest.split(".").map((s) => Number(s.split("-")[0]));
97
+ // Fallback for Node/compiled binaries. npm orders `1.0.0-beta.1` BEFORE
98
+ // `1.0.0`, so a prerelease only counts as an upgrade when its numeric core
99
+ // is genuinely ahead — never for the same core, which would be a downgrade.
100
+ const curParts = semverParts(current);
101
+ const newParts = semverParts(latest);
102
+ let order = 0;
74
103
  for (let i = 0; i < Math.max(curParts.length, newParts.length); i++) {
75
104
  const c = curParts[i] ?? 0;
76
105
  const n = newParts[i] ?? 0;
77
- if (n > c) return true;
78
- if (n < c) return false;
106
+ if (n > c) {
107
+ order = 1;
108
+ break;
109
+ }
110
+ if (n < c) {
111
+ order = -1;
112
+ break;
113
+ }
79
114
  }
80
- return false;
115
+ if (order !== 0) return order > 0;
116
+ // Same numeric core: only a prerelease -> release move is an upgrade.
117
+ return isPrerelease(current) && !isPrerelease(latest);
81
118
  }
82
119
 
83
120
  /**
@@ -257,6 +294,35 @@ export function recordPreUpdateState(currentVersion: string): void {
257
294
  });
258
295
  }
259
296
 
297
+ /**
298
+ * Acknowledge that the post-upgrade panel for `shownVersion` has been seen.
299
+ *
300
+ * The panel's trigger is `lastInstalledVersion !== currentVersion`, so without
301
+ * this write the state stays "upgraded" forever and the panel reappears on
302
+ * every single launch — including after the user dismissed it with "later" or
303
+ * "never", since neither touches `lastInstalledVersion`. Writing the running
304
+ * version makes the condition false exactly once.
305
+ *
306
+ * Best-effort: a failed write must not block the panel from being shown.
307
+ */
308
+ export function markPostUpgradeSeen(currentVersion: string): void {
309
+ try {
310
+ const state = readUpdateState();
311
+ // Already acknowledged — nothing to do.
312
+ if (state.lastInstalledVersion === currentVersion) return;
313
+ // Only acknowledge a recorded version that is genuinely OLDER than the
314
+ // running one (the upgrade we just showed notes for). If the recorded
315
+ // version is NEWER — e.g. an update landed mid-session, or the install was
316
+ // rolled back — leave it alone so the next launch still reports something.
317
+ if (isNewerVersion(currentVersion, state.lastInstalledVersion ?? "")) {
318
+ return;
319
+ }
320
+ writeUpdateState({ ...state, lastInstalledVersion: currentVersion });
321
+ } catch {
322
+ // non-critical
323
+ }
324
+ }
325
+
260
326
  /**
261
327
  * Fetch release notes for a given version.
262
328
  * Tries release-notes/<version>.md first, then falls back to CHANGELOG.md section.
package/src/watch.ts CHANGED
@@ -8,8 +8,9 @@ import {
8
8
  applyFlagFilters,
9
9
  rankRepos,
10
10
  createGitHubSearch,
11
+ validateQuery,
11
12
  } from "./search";
12
- import { createTrendingSearch } from "./search";
13
+ import { createTrendingSearch, trendingQuery } from "./search";
13
14
 
14
15
  export interface WatchOptions {
15
16
  query: string;
@@ -32,6 +33,13 @@ export async function runWatch(
32
33
  let failCount = 0;
33
34
  let intervalId: ReturnType<typeof setInterval> | null = null;
34
35
 
36
+ // Validate once, before the poll loop starts. A malformed query is not
37
+ // transient, so letting it reach the tick would burn the retry budget and
38
+ // report the same error five times instead of failing fast.
39
+ if (!opts.trending) {
40
+ validateQuery(applyFlagFilters(parseQuery(opts.query), {}));
41
+ }
42
+
35
43
  const tick = async () => {
36
44
  try {
37
45
  let repos: Repo[];
@@ -43,16 +51,13 @@ export async function runWatch(
43
51
  ? "monthly"
44
52
  : "daily";
45
53
  const trendingSearch = createTrendingSearch();
46
- const response = await trendingSearch.search(
47
- { keywords: [], qualifiers: [], raw: "trending" },
48
- {
49
- limit: opts.limit,
50
- sort: opts.sort,
51
- json: false,
52
- verbose: false,
53
- trendingSince: since,
54
- },
55
- );
54
+ const response = await trendingSearch.search(trendingQuery(), {
55
+ limit: opts.limit,
56
+ sort: opts.sort,
57
+ json: false,
58
+ verbose: false,
59
+ trendingSince: since,
60
+ });
56
61
  repos = response.repos;
57
62
  } else {
58
63
  const parsed = applyFlagFilters(parseQuery(opts.query), {});