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.
- package/CHANGELOG.md +26 -4
- package/README.md +8 -3
- package/bin/ghfind.js +98 -89
- package/completions/ghfind.bash +1 -1
- package/completions/ghfind.fish +3 -1
- package/completions/ghfind.zsh +6 -0
- package/dist/{chunk-IVUU2KGR.js → chunk-GYIL4S5X.js} +4 -1
- package/dist/cli.js +761 -342
- package/dist/{config-GNZOQRTH.js → config-OCIPF2IF.js} +1 -1
- package/package.json +6 -3
- package/release-notes/9.3.0.md +32 -29
- package/scripts/build.ts +34 -19
- package/scripts/generate-svg-demo.js +218 -168
- package/scripts/postinstall.js +176 -176
- package/src/bookmarks.ts +69 -69
- package/src/cli.ts +17 -1
- package/src/compare.ts +69 -47
- package/src/config.ts +70 -65
- package/src/deepdive.ts +149 -118
- package/src/doctor.ts +287 -287
- package/src/errors.ts +62 -59
- package/src/explore.ts +34 -29
- package/src/export.ts +73 -63
- package/src/help.ts +157 -157
- package/src/history.ts +69 -55
- package/src/init.ts +60 -55
- package/src/landing.ts +106 -104
- package/src/login.ts +186 -0
- package/src/markdown-render.ts +1 -6
- package/src/notifications.ts +75 -59
- package/src/open-url.ts +16 -16
- package/src/org.ts +43 -15
- package/src/output.ts +153 -137
- package/src/package-browser.ts +317 -305
- package/src/package.ts +149 -137
- package/src/pipe.ts +60 -54
- package/src/releases.ts +182 -144
- package/src/saved-searches.ts +72 -50
- package/src/search-state.ts +283 -229
- package/src/search.ts +678 -653
- package/src/session.ts +26 -24
- package/src/share.ts +61 -54
- package/src/status.ts +68 -61
- package/src/storage.ts +136 -106
- package/src/themes.ts +259 -116
- package/src/tips.ts +42 -42
- package/src/toast.ts +70 -69
- package/src/trending-parser.ts +104 -0
- package/src/trending.ts +481 -462
- package/src/tui.ts +149 -153
- package/src/types.ts +160 -158
- package/src/watch.ts +95 -74
package/src/history.ts
CHANGED
|
@@ -1,55 +1,69 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Persistent search history — append-only JSONL with rotation.
|
|
3
|
-
*/
|
|
4
|
-
import type { HistoryEntry } from "./types";
|
|
5
|
-
import { readJSONL, appendJSONL, writeJSON, writeRaw } from "./storage";
|
|
6
|
-
|
|
7
|
-
const HISTORY_FILE = "history.jsonl";
|
|
8
|
-
const MAX_ENTRIES = 500;
|
|
9
|
-
|
|
10
|
-
/** Append one entry to the history log. */
|
|
11
|
-
export function appendHistory(entry: HistoryEntry): void {
|
|
12
|
-
appendJSONL(HISTORY_FILE, entry);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** Read all history entries, newest first, deduplicating consecutive identical queries. */
|
|
16
|
-
export function readHistory(): HistoryEntry[] {
|
|
17
|
-
const entries = readJSONL<HistoryEntry>(HISTORY_FILE);
|
|
18
|
-
entries.reverse();
|
|
19
|
-
const deduped: HistoryEntry[] = [];
|
|
20
|
-
let lastQuery: string | undefined;
|
|
21
|
-
for (const e of entries) {
|
|
22
|
-
if (e.query !== lastQuery) {
|
|
23
|
-
deduped.push(e);
|
|
24
|
-
lastQuery = e.query;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
return deduped;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Delete a single history entry by index (0 = newest). */
|
|
31
|
-
export function deleteHistoryEntry(index: number): void {
|
|
32
|
-
const entries = readHistory();
|
|
33
|
-
if (index < 0 || index >= entries.length) return;
|
|
34
|
-
const removed = entries[index];
|
|
35
|
-
const remaining = entries.filter(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
writeRaw(HISTORY_FILE,
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
export function
|
|
54
|
-
|
|
55
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Persistent search history — append-only JSONL with rotation.
|
|
3
|
+
*/
|
|
4
|
+
import type { HistoryEntry } from "./types";
|
|
5
|
+
import { readJSONL, appendJSONL, writeJSON, writeRaw } from "./storage";
|
|
6
|
+
|
|
7
|
+
const HISTORY_FILE = "history.jsonl";
|
|
8
|
+
const MAX_ENTRIES = 500;
|
|
9
|
+
|
|
10
|
+
/** Append one entry to the history log. */
|
|
11
|
+
export function appendHistory(entry: HistoryEntry): void {
|
|
12
|
+
appendJSONL(HISTORY_FILE, entry);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Read all history entries, newest first, deduplicating consecutive identical queries. */
|
|
16
|
+
export function readHistory(): HistoryEntry[] {
|
|
17
|
+
const entries = readJSONL<HistoryEntry>(HISTORY_FILE);
|
|
18
|
+
entries.reverse();
|
|
19
|
+
const deduped: HistoryEntry[] = [];
|
|
20
|
+
let lastQuery: string | undefined;
|
|
21
|
+
for (const e of entries) {
|
|
22
|
+
if (e.query !== lastQuery) {
|
|
23
|
+
deduped.push(e);
|
|
24
|
+
lastQuery = e.query;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return deduped;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Delete a single history entry by index (0 = newest). */
|
|
31
|
+
export function deleteHistoryEntry(index: number): void {
|
|
32
|
+
const entries = readHistory();
|
|
33
|
+
if (index < 0 || index >= entries.length) return;
|
|
34
|
+
const removed = entries[index];
|
|
35
|
+
const remaining = entries.filter(
|
|
36
|
+
(e) => e.timestamp !== removed.timestamp || e.query !== removed.query,
|
|
37
|
+
);
|
|
38
|
+
writeRaw(
|
|
39
|
+
HISTORY_FILE,
|
|
40
|
+
remaining
|
|
41
|
+
.reverse()
|
|
42
|
+
.map((e) => JSON.stringify(e))
|
|
43
|
+
.join("\n") + "\n",
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Clear all history. */
|
|
48
|
+
export function clearHistory(): void {
|
|
49
|
+
writeRaw(HISTORY_FILE, "");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Rotate history to max entries (trim oldest). */
|
|
53
|
+
export function rotateHistory(): void {
|
|
54
|
+
const entries = readHistory();
|
|
55
|
+
if (entries.length <= MAX_ENTRIES) return;
|
|
56
|
+
const trimmed = entries.slice(0, MAX_ENTRIES);
|
|
57
|
+
writeRaw(
|
|
58
|
+
HISTORY_FILE,
|
|
59
|
+
trimmed
|
|
60
|
+
.reverse()
|
|
61
|
+
.map((e) => JSON.stringify(e))
|
|
62
|
+
.join("\n") + "\n",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Count entries in history file. */
|
|
67
|
+
export function historyCount(): number {
|
|
68
|
+
return readJSONL<HistoryEntry>(HISTORY_FILE).length;
|
|
69
|
+
}
|
package/src/init.ts
CHANGED
|
@@ -1,55 +1,60 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `ghfind init` wizard — interactive one-time config setup.
|
|
3
|
-
*
|
|
4
|
-
* ponytail: simple stdin prompts, no curses/readline UI.
|
|
5
|
-
*/
|
|
6
|
-
import { createInterface } from "readline";
|
|
7
|
-
import { saveConfig } from "./config";
|
|
8
|
-
import {
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
console.log("
|
|
24
|
-
console.log("
|
|
25
|
-
console.log("");
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
1
|
+
/**
|
|
2
|
+
* `ghfind init` wizard — interactive one-time config setup.
|
|
3
|
+
*
|
|
4
|
+
* ponytail: simple stdin prompts, no curses/readline UI.
|
|
5
|
+
*/
|
|
6
|
+
import { createInterface } from "readline";
|
|
7
|
+
import { saveConfig } from "./config";
|
|
8
|
+
import { isLikelyGithubToken } from "./login";
|
|
9
|
+
import { listThemes } from "./themes";
|
|
10
|
+
import type { Config, SortStrategy } from "./types";
|
|
11
|
+
|
|
12
|
+
function ask(query: string): Promise<string> {
|
|
13
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
rl.question(query, (answer) => {
|
|
16
|
+
rl.close();
|
|
17
|
+
resolve(answer.trim());
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function runInitWizard(): Promise<void> {
|
|
23
|
+
console.log("");
|
|
24
|
+
console.log(" ghfind setup wizard");
|
|
25
|
+
console.log(" ──────────────────────");
|
|
26
|
+
console.log("");
|
|
27
|
+
|
|
28
|
+
const token = await ask(" GitHub token (optional, press Enter to skip): ");
|
|
29
|
+
if (token && !isLikelyGithubToken(token)) {
|
|
30
|
+
console.log(
|
|
31
|
+
" ⚠ Token doesn't look like a GitHub token (should start with ghp_, gho_, or github_pat_)",
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const sortStr = await ask(
|
|
36
|
+
" Default sort [best-match/stars/updated/forks] (best-match): ",
|
|
37
|
+
);
|
|
38
|
+
const limitStr = await ask(" Default result limit [10-100] (50): ");
|
|
39
|
+
|
|
40
|
+
const themes = listThemes();
|
|
41
|
+
const themeStr = await ask(` Theme [${themes.join("/")}] (tokyo-night): `);
|
|
42
|
+
|
|
43
|
+
const config: Config = {
|
|
44
|
+
defaultSort: (["best-match", "stars", "updated", "forks"].includes(sortStr)
|
|
45
|
+
? sortStr
|
|
46
|
+
: "best-match") as SortStrategy,
|
|
47
|
+
defaultLimit: Math.min(100, Math.max(10, parseInt(limitStr) || 50)),
|
|
48
|
+
theme: themes.includes(themeStr) ? themeStr : themes[0],
|
|
49
|
+
cacheTtlSeconds: 300,
|
|
50
|
+
defaultTab: "search",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
if (token) config.githubToken = token;
|
|
54
|
+
|
|
55
|
+
saveConfig(config);
|
|
56
|
+
const { configPath } = await import("./config.ts");
|
|
57
|
+
console.log(` ✓ Config saved to ${configPath()}`);
|
|
58
|
+
console.log(" Run 'ghfind' to start browsing!");
|
|
59
|
+
console.log("");
|
|
60
|
+
}
|
package/src/landing.ts
CHANGED
|
@@ -1,104 +1,106 @@
|
|
|
1
|
-
/** Landing screen pieces that are worth testing in isolation. */
|
|
2
|
-
import { BoxRenderable, TextRenderable } from "@opentui/core";
|
|
3
|
-
|
|
4
|
-
export interface LandingButtonColors {
|
|
5
|
-
accent: string;
|
|
6
|
-
border: string;
|
|
7
|
-
text: string;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/** Label for the landing-screen bookmarks button. */
|
|
11
|
-
export function bookmarksButtonLabel(count: number): string {
|
|
12
|
-
if (count <= 0) return "\u{1F516} Bookmarks (none yet)";
|
|
13
|
-
if (count === 1) return "\u{1F516} Bookmarks (1 saved)";
|
|
14
|
-
return `\u{1F516} Bookmarks (${count} saved)`;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Whether the landing screen should consume navigation keys.
|
|
19
|
-
* False once another mode owns the screen (search, trending, packages,
|
|
20
|
-
* or the bookmarks page) or an overlay (help, ...) is open on top.
|
|
21
|
-
*/
|
|
22
|
-
export function landingKeysActive(mode: string, overlay: string): boolean {
|
|
23
|
-
return mode === "landing" && overlay === "none";
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Keys the landing screen consumes; anything else falls through. */
|
|
27
|
-
const LANDING_KEYS = new Set([
|
|
28
|
-
"up",
|
|
29
|
-
"k",
|
|
30
|
-
"down",
|
|
31
|
-
"j",
|
|
32
|
-
"enter",
|
|
33
|
-
"return",
|
|
34
|
-
"b",
|
|
35
|
-
"?",
|
|
36
|
-
"h",
|
|
37
|
-
"q",
|
|
38
|
-
]);
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Whether the landing screen consumes this key. Callers must stop propagation
|
|
42
|
-
* for these, or the *same* event reaches the global handler next — e.g.
|
|
43
|
-
* Enter opening the bookmarks page and then immediately being re-read as
|
|
44
|
-
* "open the selected bookmark in a browser".
|
|
45
|
-
*/
|
|
46
|
-
export function landingConsumesKey(name: string): boolean {
|
|
47
|
-
return LANDING_KEYS.has(name);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Clamped selection movement for the landing menu (no wrap-around). */
|
|
51
|
-
export function moveSelection(
|
|
52
|
-
current: number,
|
|
53
|
-
delta: number,
|
|
54
|
-
total: number,
|
|
55
|
-
): number {
|
|
56
|
-
if (total <= 0) return 0;
|
|
57
|
-
return Math.min(total - 1, Math.max(0, current + delta));
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Compact bordered button rendered under the landing mode cards.
|
|
62
|
-
* Highlights like a card when selected; label carries the bookmark count.
|
|
63
|
-
*/
|
|
64
|
-
export function createBookmarksButton(
|
|
65
|
-
renderer: ConstructorParameters<typeof BoxRenderable>[0],
|
|
66
|
-
colors: LandingButtonColors,
|
|
67
|
-
) {
|
|
68
|
-
const box = new BoxRenderable(renderer, {
|
|
69
|
-
height: 3,
|
|
70
|
-
width: "60%",
|
|
71
|
-
marginTop: 1,
|
|
72
|
-
border: true,
|
|
73
|
-
borderColor: colors.border,
|
|
74
|
-
paddingX: 1,
|
|
75
|
-
alignItems: "center",
|
|
76
|
-
justifyContent: "center",
|
|
77
|
-
focusable: false,
|
|
78
|
-
});
|
|
79
|
-
const label = new TextRenderable(renderer, {
|
|
80
|
-
content: bookmarksButtonLabel(0),
|
|
81
|
-
|
|
82
|
-
height: 1,
|
|
83
|
-
});
|
|
84
|
-
box.add(label);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
|
|
1
|
+
/** Landing screen pieces that are worth testing in isolation. */
|
|
2
|
+
import { BoxRenderable, TextRenderable } from "@opentui/core";
|
|
3
|
+
|
|
4
|
+
export interface LandingButtonColors {
|
|
5
|
+
accent: string;
|
|
6
|
+
border: string;
|
|
7
|
+
text: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Label for the landing-screen bookmarks button. */
|
|
11
|
+
export function bookmarksButtonLabel(count: number): string {
|
|
12
|
+
if (count <= 0) return "\u{1F516} Bookmarks (none yet)";
|
|
13
|
+
if (count === 1) return "\u{1F516} Bookmarks (1 saved)";
|
|
14
|
+
return `\u{1F516} Bookmarks (${count} saved)`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Whether the landing screen should consume navigation keys.
|
|
19
|
+
* False once another mode owns the screen (search, trending, packages,
|
|
20
|
+
* or the bookmarks page) or an overlay (help, ...) is open on top.
|
|
21
|
+
*/
|
|
22
|
+
export function landingKeysActive(mode: string, overlay: string): boolean {
|
|
23
|
+
return mode === "landing" && overlay === "none";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Keys the landing screen consumes; anything else falls through. */
|
|
27
|
+
const LANDING_KEYS = new Set([
|
|
28
|
+
"up",
|
|
29
|
+
"k",
|
|
30
|
+
"down",
|
|
31
|
+
"j",
|
|
32
|
+
"enter",
|
|
33
|
+
"return",
|
|
34
|
+
"b",
|
|
35
|
+
"?",
|
|
36
|
+
"h",
|
|
37
|
+
"q",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether the landing screen consumes this key. Callers must stop propagation
|
|
42
|
+
* for these, or the *same* event reaches the global handler next — e.g.
|
|
43
|
+
* Enter opening the bookmarks page and then immediately being re-read as
|
|
44
|
+
* "open the selected bookmark in a browser".
|
|
45
|
+
*/
|
|
46
|
+
export function landingConsumesKey(name: string): boolean {
|
|
47
|
+
return LANDING_KEYS.has(name);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Clamped selection movement for the landing menu (no wrap-around). */
|
|
51
|
+
export function moveSelection(
|
|
52
|
+
current: number,
|
|
53
|
+
delta: number,
|
|
54
|
+
total: number,
|
|
55
|
+
): number {
|
|
56
|
+
if (total <= 0) return 0;
|
|
57
|
+
return Math.min(total - 1, Math.max(0, current + delta));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Compact bordered button rendered under the landing mode cards.
|
|
62
|
+
* Highlights like a card when selected; label carries the bookmark count.
|
|
63
|
+
*/
|
|
64
|
+
export function createBookmarksButton(
|
|
65
|
+
renderer: ConstructorParameters<typeof BoxRenderable>[0],
|
|
66
|
+
colors: LandingButtonColors,
|
|
67
|
+
) {
|
|
68
|
+
const box = new BoxRenderable(renderer, {
|
|
69
|
+
height: 3,
|
|
70
|
+
width: "60%",
|
|
71
|
+
marginTop: 1,
|
|
72
|
+
border: true,
|
|
73
|
+
borderColor: colors.border,
|
|
74
|
+
paddingX: 1,
|
|
75
|
+
alignItems: "center",
|
|
76
|
+
justifyContent: "center",
|
|
77
|
+
focusable: false,
|
|
78
|
+
});
|
|
79
|
+
const label = new TextRenderable(renderer, {
|
|
80
|
+
content: bookmarksButtonLabel(0),
|
|
81
|
+
fg: colors.text,
|
|
82
|
+
height: 1,
|
|
83
|
+
});
|
|
84
|
+
box.add(label);
|
|
85
|
+
let currentLabel = bookmarksButtonLabel(0);
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
box,
|
|
89
|
+
setCount(n: number): void {
|
|
90
|
+
currentLabel = bookmarksButtonLabel(n);
|
|
91
|
+
label.content = currentLabel;
|
|
92
|
+
},
|
|
93
|
+
setSelected(selected: boolean): void {
|
|
94
|
+
box.borderColor = selected ? colors.accent : colors.border;
|
|
95
|
+
label.fg = selected ? colors.accent : colors.text;
|
|
96
|
+
},
|
|
97
|
+
get labelText(): string {
|
|
98
|
+
return currentLabel;
|
|
99
|
+
},
|
|
100
|
+
get borderColor(): unknown {
|
|
101
|
+
return box.borderColor;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type BookmarksButton = ReturnType<typeof createBookmarksButton>;
|
package/src/login.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ghfind login` — store a GitHub token for higher rate limits.
|
|
3
|
+
*
|
|
4
|
+
* Detects a token already managed by the GitHub CLI (`gh`) and offers to
|
|
5
|
+
* import it; otherwise accepts a pasted token. Writes to the same config file
|
|
6
|
+
* used by `ghfind init` (~/.config/ghfind/config.json). `GITHUB_TOKEN` in the
|
|
7
|
+
* environment always takes precedence at runtime (see src/config.ts).
|
|
8
|
+
*/
|
|
9
|
+
import { createInterface } from "readline";
|
|
10
|
+
import { homedir } from "os";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { existsSync, readFileSync } from "fs";
|
|
13
|
+
import { loadConfig, saveConfig, configPath } from "./config";
|
|
14
|
+
import type { Config } from "./types";
|
|
15
|
+
|
|
16
|
+
/** Prefixes that identify a GitHub-issued credential. */
|
|
17
|
+
export const TOKEN_PREFIXES = ["ghp_", "gho_", "github_pat_"] as const;
|
|
18
|
+
|
|
19
|
+
/** True when the string looks like a GitHub-issued token. */
|
|
20
|
+
export function isLikelyGithubToken(token: string): boolean {
|
|
21
|
+
return TOKEN_PREFIXES.some((prefix) => token.startsWith(prefix));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Mask a token for display: `ghp_ab…wxyz`. */
|
|
25
|
+
export function maskToken(token: string): string {
|
|
26
|
+
if (token.length <= 11) return `${token.slice(0, 4)}…`;
|
|
27
|
+
return `${token.slice(0, 7)}…${token.slice(-4)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Directory where the `gh` CLI stores credentials. */
|
|
31
|
+
export function ghCliConfigDir(): string {
|
|
32
|
+
return process.env.GH_CONFIG_DIR || join(homedir(), ".config", "gh");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extract the `oauth_token` for a given host from `hosts.yml` (the gh CLI
|
|
37
|
+
* credential file). Top-level host keys are scanned line-by-line so tokens
|
|
38
|
+
* belonging to other hosts are never picked up.
|
|
39
|
+
*/
|
|
40
|
+
export function parseHostsYmlToken(
|
|
41
|
+
text: string,
|
|
42
|
+
host = "github.com",
|
|
43
|
+
): string | undefined {
|
|
44
|
+
let inHost = false;
|
|
45
|
+
for (const line of text.split(/\r?\n/)) {
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
// Top-level key, e.g. `github.com:` or `github.com:\n` at column 0.
|
|
48
|
+
if (/^[A-Za-z0-9.-]+:\s*$/.test(trimmed)) {
|
|
49
|
+
inHost = trimmed.slice(0, -1).trim() === host;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (inHost) {
|
|
53
|
+
const match = trimmed.match(/^oauth_token:\s*["']?([^"'\s]+)/);
|
|
54
|
+
if (match) return match[1];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Read the gh CLI token for `host` (default `github.com`) if one exists. */
|
|
61
|
+
export function readGhCliToken(
|
|
62
|
+
ghDir: string = ghCliConfigDir(),
|
|
63
|
+
host = process.env.GH_HOST ?? "github.com",
|
|
64
|
+
): string | undefined {
|
|
65
|
+
const hostsFile = join(ghDir, "hosts.yml");
|
|
66
|
+
if (!existsSync(hostsFile)) return undefined;
|
|
67
|
+
try {
|
|
68
|
+
return parseHostsYmlToken(readFileSync(hostsFile, "utf-8"), host);
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Write the token into the loaded config and persist it. */
|
|
75
|
+
export function persistToken(
|
|
76
|
+
token: string,
|
|
77
|
+
deps: Pick<LoginDeps, "readConfig" | "writeConfig">,
|
|
78
|
+
): void {
|
|
79
|
+
const config = deps.readConfig();
|
|
80
|
+
config.githubToken = token;
|
|
81
|
+
deps.writeConfig(config);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Interactive pieces of the wizard, injectable for testing. */
|
|
85
|
+
export interface LoginDeps {
|
|
86
|
+
ask: (query: string) => Promise<string>;
|
|
87
|
+
readGhCliToken: () => string | undefined;
|
|
88
|
+
readConfig: () => Config;
|
|
89
|
+
writeConfig: (config: Config) => void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Command-line yes/no + free-text prompt helper. */
|
|
93
|
+
export function createAsk(): (query: string) => Promise<string> {
|
|
94
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
95
|
+
return (query) =>
|
|
96
|
+
new Promise((resolve) => {
|
|
97
|
+
rl.question(query, (answer) => {
|
|
98
|
+
rl.close();
|
|
99
|
+
resolve(answer.trim());
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Production defaults for the wizard dependencies. */
|
|
105
|
+
export function defaultLoginDeps(): LoginDeps {
|
|
106
|
+
return {
|
|
107
|
+
ask: createAsk(),
|
|
108
|
+
readGhCliToken: () => readGhCliToken(),
|
|
109
|
+
readConfig: loadConfig,
|
|
110
|
+
writeConfig: (config) => saveConfig(config),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function successNote(): string[] {
|
|
115
|
+
return [
|
|
116
|
+
" Authentication raises the search rate limit from 60 to",
|
|
117
|
+
" 5,000 requests/hour. Remove the token anytime by editing",
|
|
118
|
+
` ${configPath()} — delete the "githubToken" line and save.`,
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `ghfind login` wizard: import from gh CLI or accept a pasted token. */
|
|
123
|
+
export async function runLoginWizard(
|
|
124
|
+
deps: LoginDeps = defaultLoginDeps(),
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
const { ask } = deps;
|
|
127
|
+
console.log("");
|
|
128
|
+
console.log(" ghfind login");
|
|
129
|
+
console.log(" ────────────");
|
|
130
|
+
console.log("");
|
|
131
|
+
|
|
132
|
+
if (process.env.GITHUB_TOKEN) {
|
|
133
|
+
console.log(
|
|
134
|
+
" ℹ GITHUB_TOKEN is set in the environment and already takes precedence.",
|
|
135
|
+
);
|
|
136
|
+
console.log("");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const current = deps.readConfig();
|
|
140
|
+
if (current.githubToken) {
|
|
141
|
+
console.log(
|
|
142
|
+
` ✓ A token is already saved: ${maskToken(current.githubToken)}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const ghToken = deps.readGhCliToken();
|
|
147
|
+
if (ghToken) {
|
|
148
|
+
console.log(" ✓ Found a token managed by the GitHub CLI (gh).");
|
|
149
|
+
const answer = await ask(" Import it into ghfind? [Y/n]: ");
|
|
150
|
+
if (answer.toLowerCase() !== "n") {
|
|
151
|
+
persistToken(ghToken, deps);
|
|
152
|
+
finishLogin(ghToken);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const pasted = await ask(" Paste a GitHub token (Enter to skip): ");
|
|
158
|
+
if (!pasted) {
|
|
159
|
+
console.log(" Skipped — no token saved.");
|
|
160
|
+
console.log("");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!isLikelyGithubToken(pasted)) {
|
|
165
|
+
console.log(
|
|
166
|
+
` ⚠ That doesn't look like a GitHub token (expected one of: ${TOKEN_PREFIXES.join(", ")}).`,
|
|
167
|
+
);
|
|
168
|
+
const confirm = await ask(" Save it anyway? [y/N]: ");
|
|
169
|
+
if (confirm.toLowerCase() !== "y") {
|
|
170
|
+
console.log(" Cancelled — nothing saved.");
|
|
171
|
+
console.log("");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
persistToken(pasted, deps);
|
|
177
|
+
finishLogin(pasted);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function finishLogin(token: string): void {
|
|
181
|
+
console.log(` ✓ Token saved to ${configPath()}`);
|
|
182
|
+
console.log(` (masked: ${maskToken(token)})`);
|
|
183
|
+
console.log("");
|
|
184
|
+
for (const line of successNote()) console.log(line);
|
|
185
|
+
console.log("");
|
|
186
|
+
}
|
package/src/markdown-render.ts
CHANGED
|
@@ -49,12 +49,7 @@ export function renderMarkdown(md: string, opts: RenderOptions = {}): string {
|
|
|
49
49
|
const colCount = Math.max(...dataRows.map((r) => r.length));
|
|
50
50
|
const colW: number[] = [];
|
|
51
51
|
for (let ci = 0; ci < colCount; ci++) {
|
|
52
|
-
colW.push(
|
|
53
|
-
Math.max(
|
|
54
|
-
3,
|
|
55
|
-
...dataRows.map((r) => (r[ci] ?? "").length),
|
|
56
|
-
),
|
|
57
|
-
);
|
|
52
|
+
colW.push(Math.max(3, ...dataRows.map((r) => (r[ci] ?? "").length)));
|
|
58
53
|
}
|
|
59
54
|
const totalW = colW.reduce((a, b) => a + b, 0) + colCount * 3 - 1;
|
|
60
55
|
if (totalW > width) {
|