github-search-cli 9.8.2 → 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/CHANGELOG.md +129 -0
- package/README.md +65 -26
- package/dist/cli.js +898 -184
- package/docs/HOW-TO.md +254 -0
- package/package.json +2 -1
- package/release-notes/9.8.3.md +98 -0
- package/release-notes/9.9.0.md +193 -0
- package/src/cli.ts +13 -11
- package/src/errors.ts +56 -5
- package/src/help.ts +11 -0
- package/src/image-loader.ts +57 -2
- package/src/markdown-images.ts +93 -4
- package/src/markdown-view.ts +49 -20
- package/src/mcp-server.ts +34 -2
- package/src/search-state.ts +8 -10
- package/src/search.ts +701 -12
- package/src/session.ts +58 -2
- package/src/trending.ts +2 -6
- package/src/tui.ts +236 -50
- package/src/types.ts +11 -1
- package/src/update-check.ts +72 -6
- package/src/watch.ts +16 -11
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
|
-
|
|
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 {
|
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,
|
|
@@ -56,7 +59,14 @@ import {
|
|
|
56
59
|
import { tabSince, TAB_NAMES, fmtStars } from "./trending";
|
|
57
60
|
import { loadConfig, saveConfig } from "./config";
|
|
58
61
|
import { buildHelpSections, HELP_KEYS_COLUMN } from "./help";
|
|
59
|
-
import {
|
|
62
|
+
import {
|
|
63
|
+
SearchCliError,
|
|
64
|
+
NoResultsError,
|
|
65
|
+
NetworkError,
|
|
66
|
+
RateLimitError,
|
|
67
|
+
AuthError,
|
|
68
|
+
ForbiddenError,
|
|
69
|
+
} from "./errors";
|
|
60
70
|
import {
|
|
61
71
|
appendHistory,
|
|
62
72
|
readHistory,
|
|
@@ -113,6 +123,7 @@ import {
|
|
|
113
123
|
fetchReleaseNotes,
|
|
114
124
|
readUpdateState,
|
|
115
125
|
recordPreUpdateState,
|
|
126
|
+
markPostUpgradeSeen,
|
|
116
127
|
} from "./update-check";
|
|
117
128
|
import { debugLog } from "./storage";
|
|
118
129
|
import {
|
|
@@ -145,12 +156,41 @@ const colors: Record<string, string> = {
|
|
|
145
156
|
};
|
|
146
157
|
|
|
147
158
|
// ─── Logger ───────────────────────────────────────────────────────────
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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();
|
|
154
194
|
|
|
155
195
|
// ─── Sort cycle ───────────────────────────────────────────────────────
|
|
156
196
|
const SORT_MODES: { key: SortStrategy; label: string }[] = [
|
|
@@ -204,7 +244,10 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
204
244
|
|
|
205
245
|
// ── Config ──
|
|
206
246
|
const config = loadConfig();
|
|
207
|
-
|
|
247
|
+
// Mutable: the AuthError flow lets the user fix or clear the token without
|
|
248
|
+
// restarting (the next search re-reads this variable).
|
|
249
|
+
let githubToken: string | undefined =
|
|
250
|
+
config.githubToken || process.env.GITHUB_TOKEN;
|
|
208
251
|
|
|
209
252
|
// Apply theme
|
|
210
253
|
const theme = loadTheme(theme_override || config.theme);
|
|
@@ -438,6 +481,11 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
438
481
|
currentMode = "search";
|
|
439
482
|
showLanding(false);
|
|
440
483
|
showSearchMode();
|
|
484
|
+
// Restore the saved query (if any) as a prefill, never auto-run it.
|
|
485
|
+
if (currentQueryInput) {
|
|
486
|
+
searchInput.value = currentQueryInput;
|
|
487
|
+
renderer.requestRender();
|
|
488
|
+
}
|
|
441
489
|
},
|
|
442
490
|
},
|
|
443
491
|
{
|
|
@@ -1610,6 +1658,9 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1610
1658
|
if (state.lastInstalledVersion === currentVersion) return;
|
|
1611
1659
|
// We just upgraded — show notes for the new version
|
|
1612
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);
|
|
1613
1664
|
updateSelectedOption = 0;
|
|
1614
1665
|
updateHeader.content = ` Updated ghfind ${state.lastInstalledVersion} → ${currentVersion}\n`;
|
|
1615
1666
|
updateNotes.setContent(
|
|
@@ -1923,12 +1974,125 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1923
1974
|
}
|
|
1924
1975
|
}, 2000);
|
|
1925
1976
|
|
|
1977
|
+
// ── Token fix flow (from 401/403 error hints) ───────────────────────
|
|
1978
|
+
// Inline prompt box that slides over the search input row. Enter saves the
|
|
1979
|
+
// token to config and re-runs the failed query; Esc cancels.
|
|
1980
|
+
const tokenPromptBox = new BoxRenderable(renderer, {
|
|
1981
|
+
visible: false,
|
|
1982
|
+
position: "absolute",
|
|
1983
|
+
top: 0,
|
|
1984
|
+
left: 0,
|
|
1985
|
+
width: "100%",
|
|
1986
|
+
flexDirection: "row",
|
|
1987
|
+
paddingY: 1,
|
|
1988
|
+
paddingLeft: 1,
|
|
1989
|
+
backgroundColor: colors.surface,
|
|
1990
|
+
});
|
|
1991
|
+
const tokenPromptLabel = new TextRenderable(renderer, {
|
|
1992
|
+
content: " GitHub token:",
|
|
1993
|
+
fg: colors.accent,
|
|
1994
|
+
height: 1,
|
|
1995
|
+
});
|
|
1996
|
+
const tokenPromptInput = new InputRenderable(renderer, {
|
|
1997
|
+
placeholder: "paste token, Enter to save — Esc cancels",
|
|
1998
|
+
value: "",
|
|
1999
|
+
backgroundColor: colors.surface,
|
|
2000
|
+
textColor: colors.text,
|
|
2001
|
+
flexGrow: 1,
|
|
2002
|
+
});
|
|
2003
|
+
tokenPromptBox.add(tokenPromptLabel);
|
|
2004
|
+
tokenPromptBox.add(tokenPromptInput);
|
|
2005
|
+
root.add(tokenPromptBox);
|
|
2006
|
+
|
|
2007
|
+
let tokenPromptQuery: string | null = null;
|
|
2008
|
+
|
|
2009
|
+
function showTokenPrompt(query: string) {
|
|
2010
|
+
tokenPromptQuery = query;
|
|
2011
|
+
tokenPromptInput.value = "";
|
|
2012
|
+
tokenPromptBox.visible = true;
|
|
2013
|
+
searchBox.visible = false;
|
|
2014
|
+
tokenPromptInput.focus();
|
|
2015
|
+
renderer.requestRender();
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
function hideTokenPrompt() {
|
|
2019
|
+
tokenPromptBox.visible = false;
|
|
2020
|
+
tokenPromptQuery = null;
|
|
2021
|
+
if (currentMode === "search" || currentMode === "packages") {
|
|
2022
|
+
searchBox.visible = true;
|
|
2023
|
+
searchInput.focus();
|
|
2024
|
+
}
|
|
2025
|
+
renderer.requestRender();
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
function persistToken(token: string): void {
|
|
2029
|
+
githubToken = token;
|
|
2030
|
+
config.githubToken = token;
|
|
2031
|
+
saveConfig(config);
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
tokenPromptInput.on("enter", () => {
|
|
2035
|
+
const token = tokenPromptInput.value.trim();
|
|
2036
|
+
const query = tokenPromptQuery;
|
|
2037
|
+
hideTokenPrompt();
|
|
2038
|
+
if (!token || !query) return;
|
|
2039
|
+
persistToken(token);
|
|
2040
|
+
setStatus("Token saved");
|
|
2041
|
+
doSearch(query);
|
|
2042
|
+
});
|
|
2043
|
+
|
|
2044
|
+
// ── Error-hint key actions ([t] fix token, [u] unset) ──────────────
|
|
2045
|
+
|
|
2046
|
+
function fixTokenAction() {
|
|
2047
|
+
showTokenPrompt(currentQueryInput || "stars:>10000");
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
function unsetTokenAction() {
|
|
2051
|
+
githubToken = undefined;
|
|
2052
|
+
if (config.githubToken) {
|
|
2053
|
+
delete config.githubToken;
|
|
2054
|
+
saveConfig(config);
|
|
2055
|
+
}
|
|
2056
|
+
setStatus("Token cleared — searching without one");
|
|
2057
|
+
if (currentQueryInput) doSearch(currentQueryInput);
|
|
2058
|
+
}
|
|
2059
|
+
|
|
1926
2060
|
// ── Helper functions ────────────────────────────────────────────────
|
|
1927
2061
|
|
|
1928
2062
|
function setStatus(msg: string) {
|
|
1929
2063
|
statusMgr.set("idle", msg);
|
|
1930
2064
|
}
|
|
1931
2065
|
|
|
2066
|
+
/**
|
|
2067
|
+
* Which error family is showing in the status bar, for the bracketed key
|
|
2068
|
+
* hints the messages advertise ([r]etry, [t]/[c] token, [u]nset).
|
|
2069
|
+
* Null once the next search starts, so the hint keys can't hijack
|
|
2070
|
+
* theme/compare forever after a transient failure.
|
|
2071
|
+
*/
|
|
2072
|
+
type ErrorHintKind = "auth" | "forbidden" | "ratelimit" | "network" | null;
|
|
2073
|
+
let errorHint: ErrorHintKind = null;
|
|
2074
|
+
|
|
2075
|
+
/** Render an error in the status bar and remember which hints apply. */
|
|
2076
|
+
function reportError(err: unknown) {
|
|
2077
|
+
errorHint =
|
|
2078
|
+
err instanceof AuthError
|
|
2079
|
+
? "auth"
|
|
2080
|
+
: err instanceof ForbiddenError
|
|
2081
|
+
? "forbidden"
|
|
2082
|
+
: err instanceof RateLimitError
|
|
2083
|
+
? "ratelimit"
|
|
2084
|
+
: err instanceof NetworkError
|
|
2085
|
+
? "network"
|
|
2086
|
+
: null;
|
|
2087
|
+
const msg =
|
|
2088
|
+
err instanceof SearchCliError
|
|
2089
|
+
? err.userMessage
|
|
2090
|
+
: err instanceof Error
|
|
2091
|
+
? err.message
|
|
2092
|
+
: String(err);
|
|
2093
|
+
statusMgr.set("error", msg.slice(0, 60));
|
|
2094
|
+
}
|
|
2095
|
+
|
|
1932
2096
|
/** Session snapshot; only restorable modes are persisted (see SessionState). */
|
|
1933
2097
|
function buildSessionState(): SessionState {
|
|
1934
2098
|
const mode: SessionState["mode"] =
|
|
@@ -2222,6 +2386,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
2222
2386
|
async function loadTrending() {
|
|
2223
2387
|
currentMode = "trending";
|
|
2224
2388
|
isLoading = true;
|
|
2389
|
+
errorHint = null;
|
|
2225
2390
|
searchBox.visible = false;
|
|
2226
2391
|
toolbarText.visible = false;
|
|
2227
2392
|
trendingTabBox.visible = true;
|
|
@@ -2236,16 +2401,13 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
2236
2401
|
|
|
2237
2402
|
try {
|
|
2238
2403
|
const searchModule = new SearchModule(new TrendingAdapter());
|
|
2239
|
-
const response = await searchModule.search(
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
trendingSince: tabSince(trendingTab),
|
|
2247
|
-
},
|
|
2248
|
-
);
|
|
2404
|
+
const response = await searchModule.search(trendingQuery(), {
|
|
2405
|
+
limit: 25,
|
|
2406
|
+
sort: "stars",
|
|
2407
|
+
json: false,
|
|
2408
|
+
verbose: false,
|
|
2409
|
+
trendingSince: tabSince(trendingTab),
|
|
2410
|
+
});
|
|
2249
2411
|
const fetched = response.repos;
|
|
2250
2412
|
const since = tabSince(trendingTab);
|
|
2251
2413
|
trendingPeriod =
|
|
@@ -2277,16 +2439,10 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
2277
2439
|
resultCount: fetched.length,
|
|
2278
2440
|
});
|
|
2279
2441
|
} catch (err) {
|
|
2280
|
-
const msg =
|
|
2281
|
-
err instanceof SearchCliError
|
|
2282
|
-
? err.userMessage
|
|
2283
|
-
: err instanceof Error
|
|
2284
|
-
? err.message
|
|
2285
|
-
: String(err);
|
|
2286
2442
|
resultsSelect.options = [
|
|
2287
2443
|
{ name: " (error)", description: "", value: null },
|
|
2288
2444
|
];
|
|
2289
|
-
|
|
2445
|
+
reportError(err);
|
|
2290
2446
|
}
|
|
2291
2447
|
isLoading = false;
|
|
2292
2448
|
renderer.requestRender();
|
|
@@ -2329,6 +2485,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
2329
2485
|
if (q === "" || isLoading) return;
|
|
2330
2486
|
currentQueryInput = q;
|
|
2331
2487
|
isLoading = true;
|
|
2488
|
+
errorHint = null; // npm errors never carry GitHub auth hints
|
|
2332
2489
|
resultsSelect.options = [
|
|
2333
2490
|
{ name: " Searching packages...", description: "", value: null },
|
|
2334
2491
|
];
|
|
@@ -2367,6 +2524,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
2367
2524
|
const q = queryText.trim();
|
|
2368
2525
|
if (q === "" || isLoading) return;
|
|
2369
2526
|
|
|
2527
|
+
errorHint = null; // a fresh search retires the previous error's hint keys
|
|
2370
2528
|
if (!append) currentPage = 1;
|
|
2371
2529
|
deepDiveActive = false;
|
|
2372
2530
|
|
|
@@ -2403,7 +2561,12 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
2403
2561
|
}
|
|
2404
2562
|
|
|
2405
2563
|
if (currentRepos.length === 0) {
|
|
2406
|
-
|
|
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
|
+
);
|
|
2407
2570
|
} else {
|
|
2408
2571
|
resultsSelect.options = currentRepos.map((r) => ({
|
|
2409
2572
|
name: formatResultLine(r),
|
|
@@ -2434,13 +2597,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
2434
2597
|
{ name: " (error)", description: "", value: null },
|
|
2435
2598
|
];
|
|
2436
2599
|
}
|
|
2437
|
-
|
|
2438
|
-
err instanceof SearchCliError
|
|
2439
|
-
? err.userMessage
|
|
2440
|
-
: err instanceof Error
|
|
2441
|
-
? err.message
|
|
2442
|
-
: String(err);
|
|
2443
|
-
statusMgr.set("error", msg.slice(0, 60));
|
|
2600
|
+
reportError(err);
|
|
2444
2601
|
}
|
|
2445
2602
|
|
|
2446
2603
|
isLoading = false;
|
|
@@ -2558,6 +2715,18 @@ ${pack.description ?? ""}`;
|
|
|
2558
2715
|
|
|
2559
2716
|
// ── Global keyboard shortcuts ──────────────────────────────────────
|
|
2560
2717
|
renderer.keyInput.on("keypress", (key) => {
|
|
2718
|
+
// ── Token prompt (own keyboard scope) ──────────────────────────
|
|
2719
|
+
if (tokenPromptBox.visible) {
|
|
2720
|
+
if (key.name === "escape") {
|
|
2721
|
+
hideTokenPrompt();
|
|
2722
|
+
setStatus("Token entry cancelled");
|
|
2723
|
+
renderer.requestRender();
|
|
2724
|
+
return;
|
|
2725
|
+
}
|
|
2726
|
+
if (key.name === "enter" || key.name === "return") return; // InputRenderable fires "enter"
|
|
2727
|
+
return; // let the input consume everything else (letters, paste, etc.)
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2561
2730
|
// ── Overlay-mode handling ────────────────────────────────────────
|
|
2562
2731
|
if (currentOverlay !== "none") {
|
|
2563
2732
|
// Escape or q closes any overlay (except: leader uses Esc for back-nav, q to close)
|
|
@@ -2930,6 +3099,34 @@ ${pack.description ?? ""}`;
|
|
|
2930
3099
|
return;
|
|
2931
3100
|
}
|
|
2932
3101
|
|
|
3102
|
+
// Error-hint keys — active only while a status-bar error that advertises
|
|
3103
|
+
// them ([r]etry / [t]oken / [u]nset / [c]ycle) is on screen; the next
|
|
3104
|
+
// search clears the state, returning t/c to their normal roles.
|
|
3105
|
+
if (!searchInput.focused && errorHint) {
|
|
3106
|
+
if (key.name === "r") {
|
|
3107
|
+
errorHint = null;
|
|
3108
|
+
if (currentMode === "trending") loadTrending();
|
|
3109
|
+
else if (currentQueryInput) doSearch(currentQueryInput);
|
|
3110
|
+
renderer.requestRender();
|
|
3111
|
+
return;
|
|
3112
|
+
}
|
|
3113
|
+
if (
|
|
3114
|
+
key.name === "t" &&
|
|
3115
|
+
(errorHint === "auth" || errorHint === "forbidden")
|
|
3116
|
+
) {
|
|
3117
|
+
fixTokenAction();
|
|
3118
|
+
return;
|
|
3119
|
+
}
|
|
3120
|
+
if (key.name === "u" && errorHint === "auth") {
|
|
3121
|
+
unsetTokenAction();
|
|
3122
|
+
return;
|
|
3123
|
+
}
|
|
3124
|
+
if (key.name === "c" && errorHint === "ratelimit") {
|
|
3125
|
+
fixTokenAction(); // TUI manages a single token — prompt for a different one
|
|
3126
|
+
return;
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
|
|
2933
3130
|
// 't' toggles theme and persists to config
|
|
2934
3131
|
if (key.name === "t" && !searchInput.focused) {
|
|
2935
3132
|
const current = config.theme;
|
|
@@ -3049,26 +3246,15 @@ ${pack.description ?? ""}`;
|
|
|
3049
3246
|
// ── Update check (before start) ──────────────────────────────────────
|
|
3050
3247
|
const _currentVersion = getVersion();
|
|
3051
3248
|
// ── Start ──────────────────────────────────────────────────────────
|
|
3052
|
-
//
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3249
|
+
// Always start on the landing menu. Saved sessions restore preferences
|
|
3250
|
+
// (sort/limit/trending tab) and pre-fill the query once a mode is picked,
|
|
3251
|
+
// but never auto-run a search — a stale or corrupted session (e.g. an MCP
|
|
3252
|
+
// handshake blob saved as a query) used to hijack startup and skip the
|
|
3253
|
+
// menu on every launch.
|
|
3254
|
+
showLanding(true);
|
|
3056
3255
|
checkForUpdateAndShow();
|
|
3057
3256
|
checkPostUpgradePanel();
|
|
3058
3257
|
renderer.start();
|
|
3059
|
-
if (session?.mode) {
|
|
3060
|
-
if (currentMode === "trending") {
|
|
3061
|
-
loadTrending();
|
|
3062
|
-
} else {
|
|
3063
|
-
searchInput.value = currentQueryInput;
|
|
3064
|
-
if (currentQueryInput) {
|
|
3065
|
-
doSearch(currentQueryInput);
|
|
3066
|
-
} else {
|
|
3067
|
-
showSearchMode();
|
|
3068
|
-
}
|
|
3069
|
-
searchInput.focus();
|
|
3070
|
-
}
|
|
3071
|
-
}
|
|
3072
3258
|
renderer.requestRender();
|
|
3073
3259
|
}
|
|
3074
3260
|
|
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. */
|
package/src/update-check.ts
CHANGED
|
@@ -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
|
|
72
|
-
|
|
73
|
-
|
|
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)
|
|
78
|
-
|
|
106
|
+
if (n > c) {
|
|
107
|
+
order = 1;
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
if (n < c) {
|
|
111
|
+
order = -1;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
79
114
|
}
|
|
80
|
-
return
|
|
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.
|