github-search-cli 9.8.2 → 9.8.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.
- package/CHANGELOG.md +57 -0
- package/README.md +35 -18
- package/dist/cli.js +360 -122
- package/package.json +1 -1
- package/release-notes/9.8.3.md +98 -0
- package/src/errors.ts +27 -0
- 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 +17 -1
- package/src/search.ts +19 -2
- package/src/session.ts +58 -2
- package/src/tui.ts +181 -33
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/tui.ts
CHANGED
|
@@ -56,7 +56,14 @@ import {
|
|
|
56
56
|
import { tabSince, TAB_NAMES, fmtStars } from "./trending";
|
|
57
57
|
import { loadConfig, saveConfig } from "./config";
|
|
58
58
|
import { buildHelpSections, HELP_KEYS_COLUMN } from "./help";
|
|
59
|
-
import {
|
|
59
|
+
import {
|
|
60
|
+
SearchCliError,
|
|
61
|
+
NoResultsError,
|
|
62
|
+
NetworkError,
|
|
63
|
+
RateLimitError,
|
|
64
|
+
AuthError,
|
|
65
|
+
ForbiddenError,
|
|
66
|
+
} from "./errors";
|
|
60
67
|
import {
|
|
61
68
|
appendHistory,
|
|
62
69
|
readHistory,
|
|
@@ -204,7 +211,10 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
204
211
|
|
|
205
212
|
// ── Config ──
|
|
206
213
|
const config = loadConfig();
|
|
207
|
-
|
|
214
|
+
// Mutable: the AuthError flow lets the user fix or clear the token without
|
|
215
|
+
// restarting (the next search re-reads this variable).
|
|
216
|
+
let githubToken: string | undefined =
|
|
217
|
+
config.githubToken || process.env.GITHUB_TOKEN;
|
|
208
218
|
|
|
209
219
|
// Apply theme
|
|
210
220
|
const theme = loadTheme(theme_override || config.theme);
|
|
@@ -438,6 +448,11 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
438
448
|
currentMode = "search";
|
|
439
449
|
showLanding(false);
|
|
440
450
|
showSearchMode();
|
|
451
|
+
// Restore the saved query (if any) as a prefill, never auto-run it.
|
|
452
|
+
if (currentQueryInput) {
|
|
453
|
+
searchInput.value = currentQueryInput;
|
|
454
|
+
renderer.requestRender();
|
|
455
|
+
}
|
|
441
456
|
},
|
|
442
457
|
},
|
|
443
458
|
{
|
|
@@ -1923,12 +1938,125 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1923
1938
|
}
|
|
1924
1939
|
}, 2000);
|
|
1925
1940
|
|
|
1941
|
+
// ── Token fix flow (from 401/403 error hints) ───────────────────────
|
|
1942
|
+
// Inline prompt box that slides over the search input row. Enter saves the
|
|
1943
|
+
// token to config and re-runs the failed query; Esc cancels.
|
|
1944
|
+
const tokenPromptBox = new BoxRenderable(renderer, {
|
|
1945
|
+
visible: false,
|
|
1946
|
+
position: "absolute",
|
|
1947
|
+
top: 0,
|
|
1948
|
+
left: 0,
|
|
1949
|
+
width: "100%",
|
|
1950
|
+
flexDirection: "row",
|
|
1951
|
+
paddingY: 1,
|
|
1952
|
+
paddingLeft: 1,
|
|
1953
|
+
backgroundColor: colors.surface,
|
|
1954
|
+
});
|
|
1955
|
+
const tokenPromptLabel = new TextRenderable(renderer, {
|
|
1956
|
+
content: " GitHub token:",
|
|
1957
|
+
fg: colors.accent,
|
|
1958
|
+
height: 1,
|
|
1959
|
+
});
|
|
1960
|
+
const tokenPromptInput = new InputRenderable(renderer, {
|
|
1961
|
+
placeholder: "paste token, Enter to save — Esc cancels",
|
|
1962
|
+
value: "",
|
|
1963
|
+
backgroundColor: colors.surface,
|
|
1964
|
+
textColor: colors.text,
|
|
1965
|
+
flexGrow: 1,
|
|
1966
|
+
});
|
|
1967
|
+
tokenPromptBox.add(tokenPromptLabel);
|
|
1968
|
+
tokenPromptBox.add(tokenPromptInput);
|
|
1969
|
+
root.add(tokenPromptBox);
|
|
1970
|
+
|
|
1971
|
+
let tokenPromptQuery: string | null = null;
|
|
1972
|
+
|
|
1973
|
+
function showTokenPrompt(query: string) {
|
|
1974
|
+
tokenPromptQuery = query;
|
|
1975
|
+
tokenPromptInput.value = "";
|
|
1976
|
+
tokenPromptBox.visible = true;
|
|
1977
|
+
searchBox.visible = false;
|
|
1978
|
+
tokenPromptInput.focus();
|
|
1979
|
+
renderer.requestRender();
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
function hideTokenPrompt() {
|
|
1983
|
+
tokenPromptBox.visible = false;
|
|
1984
|
+
tokenPromptQuery = null;
|
|
1985
|
+
if (currentMode === "search" || currentMode === "packages") {
|
|
1986
|
+
searchBox.visible = true;
|
|
1987
|
+
searchInput.focus();
|
|
1988
|
+
}
|
|
1989
|
+
renderer.requestRender();
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
function persistToken(token: string): void {
|
|
1993
|
+
githubToken = token;
|
|
1994
|
+
config.githubToken = token;
|
|
1995
|
+
saveConfig(config);
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
tokenPromptInput.on("enter", () => {
|
|
1999
|
+
const token = tokenPromptInput.value.trim();
|
|
2000
|
+
const query = tokenPromptQuery;
|
|
2001
|
+
hideTokenPrompt();
|
|
2002
|
+
if (!token || !query) return;
|
|
2003
|
+
persistToken(token);
|
|
2004
|
+
setStatus("Token saved");
|
|
2005
|
+
doSearch(query);
|
|
2006
|
+
});
|
|
2007
|
+
|
|
2008
|
+
// ── Error-hint key actions ([t] fix token, [u] unset) ──────────────
|
|
2009
|
+
|
|
2010
|
+
function fixTokenAction() {
|
|
2011
|
+
showTokenPrompt(currentQueryInput || "stars:>10000");
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
function unsetTokenAction() {
|
|
2015
|
+
githubToken = undefined;
|
|
2016
|
+
if (config.githubToken) {
|
|
2017
|
+
delete config.githubToken;
|
|
2018
|
+
saveConfig(config);
|
|
2019
|
+
}
|
|
2020
|
+
setStatus("Token cleared — searching without one");
|
|
2021
|
+
if (currentQueryInput) doSearch(currentQueryInput);
|
|
2022
|
+
}
|
|
2023
|
+
|
|
1926
2024
|
// ── Helper functions ────────────────────────────────────────────────
|
|
1927
2025
|
|
|
1928
2026
|
function setStatus(msg: string) {
|
|
1929
2027
|
statusMgr.set("idle", msg);
|
|
1930
2028
|
}
|
|
1931
2029
|
|
|
2030
|
+
/**
|
|
2031
|
+
* Which error family is showing in the status bar, for the bracketed key
|
|
2032
|
+
* hints the messages advertise ([r]etry, [t]/[c] token, [u]nset).
|
|
2033
|
+
* Null once the next search starts, so the hint keys can't hijack
|
|
2034
|
+
* theme/compare forever after a transient failure.
|
|
2035
|
+
*/
|
|
2036
|
+
type ErrorHintKind = "auth" | "forbidden" | "ratelimit" | "network" | null;
|
|
2037
|
+
let errorHint: ErrorHintKind = null;
|
|
2038
|
+
|
|
2039
|
+
/** Render an error in the status bar and remember which hints apply. */
|
|
2040
|
+
function reportError(err: unknown) {
|
|
2041
|
+
errorHint =
|
|
2042
|
+
err instanceof AuthError
|
|
2043
|
+
? "auth"
|
|
2044
|
+
: err instanceof ForbiddenError
|
|
2045
|
+
? "forbidden"
|
|
2046
|
+
: err instanceof RateLimitError
|
|
2047
|
+
? "ratelimit"
|
|
2048
|
+
: err instanceof NetworkError
|
|
2049
|
+
? "network"
|
|
2050
|
+
: null;
|
|
2051
|
+
const msg =
|
|
2052
|
+
err instanceof SearchCliError
|
|
2053
|
+
? err.userMessage
|
|
2054
|
+
: err instanceof Error
|
|
2055
|
+
? err.message
|
|
2056
|
+
: String(err);
|
|
2057
|
+
statusMgr.set("error", msg.slice(0, 60));
|
|
2058
|
+
}
|
|
2059
|
+
|
|
1932
2060
|
/** Session snapshot; only restorable modes are persisted (see SessionState). */
|
|
1933
2061
|
function buildSessionState(): SessionState {
|
|
1934
2062
|
const mode: SessionState["mode"] =
|
|
@@ -2222,6 +2350,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
2222
2350
|
async function loadTrending() {
|
|
2223
2351
|
currentMode = "trending";
|
|
2224
2352
|
isLoading = true;
|
|
2353
|
+
errorHint = null;
|
|
2225
2354
|
searchBox.visible = false;
|
|
2226
2355
|
toolbarText.visible = false;
|
|
2227
2356
|
trendingTabBox.visible = true;
|
|
@@ -2277,16 +2406,10 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
2277
2406
|
resultCount: fetched.length,
|
|
2278
2407
|
});
|
|
2279
2408
|
} catch (err) {
|
|
2280
|
-
const msg =
|
|
2281
|
-
err instanceof SearchCliError
|
|
2282
|
-
? err.userMessage
|
|
2283
|
-
: err instanceof Error
|
|
2284
|
-
? err.message
|
|
2285
|
-
: String(err);
|
|
2286
2409
|
resultsSelect.options = [
|
|
2287
2410
|
{ name: " (error)", description: "", value: null },
|
|
2288
2411
|
];
|
|
2289
|
-
|
|
2412
|
+
reportError(err);
|
|
2290
2413
|
}
|
|
2291
2414
|
isLoading = false;
|
|
2292
2415
|
renderer.requestRender();
|
|
@@ -2329,6 +2452,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
2329
2452
|
if (q === "" || isLoading) return;
|
|
2330
2453
|
currentQueryInput = q;
|
|
2331
2454
|
isLoading = true;
|
|
2455
|
+
errorHint = null; // npm errors never carry GitHub auth hints
|
|
2332
2456
|
resultsSelect.options = [
|
|
2333
2457
|
{ name: " Searching packages...", description: "", value: null },
|
|
2334
2458
|
];
|
|
@@ -2367,6 +2491,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
2367
2491
|
const q = queryText.trim();
|
|
2368
2492
|
if (q === "" || isLoading) return;
|
|
2369
2493
|
|
|
2494
|
+
errorHint = null; // a fresh search retires the previous error's hint keys
|
|
2370
2495
|
if (!append) currentPage = 1;
|
|
2371
2496
|
deepDiveActive = false;
|
|
2372
2497
|
|
|
@@ -2434,13 +2559,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
2434
2559
|
{ name: " (error)", description: "", value: null },
|
|
2435
2560
|
];
|
|
2436
2561
|
}
|
|
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));
|
|
2562
|
+
reportError(err);
|
|
2444
2563
|
}
|
|
2445
2564
|
|
|
2446
2565
|
isLoading = false;
|
|
@@ -2558,6 +2677,18 @@ ${pack.description ?? ""}`;
|
|
|
2558
2677
|
|
|
2559
2678
|
// ── Global keyboard shortcuts ──────────────────────────────────────
|
|
2560
2679
|
renderer.keyInput.on("keypress", (key) => {
|
|
2680
|
+
// ── Token prompt (own keyboard scope) ──────────────────────────
|
|
2681
|
+
if (tokenPromptBox.visible) {
|
|
2682
|
+
if (key.name === "escape") {
|
|
2683
|
+
hideTokenPrompt();
|
|
2684
|
+
setStatus("Token entry cancelled");
|
|
2685
|
+
renderer.requestRender();
|
|
2686
|
+
return;
|
|
2687
|
+
}
|
|
2688
|
+
if (key.name === "enter" || key.name === "return") return; // InputRenderable fires "enter"
|
|
2689
|
+
return; // let the input consume everything else (letters, paste, etc.)
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2561
2692
|
// ── Overlay-mode handling ────────────────────────────────────────
|
|
2562
2693
|
if (currentOverlay !== "none") {
|
|
2563
2694
|
// Escape or q closes any overlay (except: leader uses Esc for back-nav, q to close)
|
|
@@ -2930,6 +3061,34 @@ ${pack.description ?? ""}`;
|
|
|
2930
3061
|
return;
|
|
2931
3062
|
}
|
|
2932
3063
|
|
|
3064
|
+
// Error-hint keys — active only while a status-bar error that advertises
|
|
3065
|
+
// them ([r]etry / [t]oken / [u]nset / [c]ycle) is on screen; the next
|
|
3066
|
+
// search clears the state, returning t/c to their normal roles.
|
|
3067
|
+
if (!searchInput.focused && errorHint) {
|
|
3068
|
+
if (key.name === "r") {
|
|
3069
|
+
errorHint = null;
|
|
3070
|
+
if (currentMode === "trending") loadTrending();
|
|
3071
|
+
else if (currentQueryInput) doSearch(currentQueryInput);
|
|
3072
|
+
renderer.requestRender();
|
|
3073
|
+
return;
|
|
3074
|
+
}
|
|
3075
|
+
if (
|
|
3076
|
+
key.name === "t" &&
|
|
3077
|
+
(errorHint === "auth" || errorHint === "forbidden")
|
|
3078
|
+
) {
|
|
3079
|
+
fixTokenAction();
|
|
3080
|
+
return;
|
|
3081
|
+
}
|
|
3082
|
+
if (key.name === "u" && errorHint === "auth") {
|
|
3083
|
+
unsetTokenAction();
|
|
3084
|
+
return;
|
|
3085
|
+
}
|
|
3086
|
+
if (key.name === "c" && errorHint === "ratelimit") {
|
|
3087
|
+
fixTokenAction(); // TUI manages a single token — prompt for a different one
|
|
3088
|
+
return;
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
|
|
2933
3092
|
// 't' toggles theme and persists to config
|
|
2934
3093
|
if (key.name === "t" && !searchInput.focused) {
|
|
2935
3094
|
const current = config.theme;
|
|
@@ -3049,26 +3208,15 @@ ${pack.description ?? ""}`;
|
|
|
3049
3208
|
// ── Update check (before start) ──────────────────────────────────────
|
|
3050
3209
|
const _currentVersion = getVersion();
|
|
3051
3210
|
// ── Start ──────────────────────────────────────────────────────────
|
|
3052
|
-
//
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3211
|
+
// Always start on the landing menu. Saved sessions restore preferences
|
|
3212
|
+
// (sort/limit/trending tab) and pre-fill the query once a mode is picked,
|
|
3213
|
+
// but never auto-run a search — a stale or corrupted session (e.g. an MCP
|
|
3214
|
+
// handshake blob saved as a query) used to hijack startup and skip the
|
|
3215
|
+
// menu on every launch.
|
|
3216
|
+
showLanding(true);
|
|
3056
3217
|
checkForUpdateAndShow();
|
|
3057
3218
|
checkPostUpgradePanel();
|
|
3058
3219
|
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
3220
|
renderer.requestRender();
|
|
3073
3221
|
}
|
|
3074
3222
|
|