github-search-cli 0.7.0 → 0.8.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/README.md +66 -104
- package/bin/ghfind.js +14 -0
- package/completions/{search-cli.bash → ghfind.bash} +4 -4
- package/completions/ghfind.fish +19 -0
- package/completions/{search-cli.zsh → ghfind.zsh} +27 -27
- package/package.json +17 -9
- package/src/bookmarks.ts +69 -69
- package/src/cli.ts +239 -252
- package/src/compare.ts +46 -46
- package/src/config.ts +65 -65
- package/src/deepdive.ts +118 -124
- package/src/errors.ts +59 -62
- package/src/explore.ts +29 -30
- package/src/export.ts +63 -64
- package/src/help.ts +22 -30
- package/src/history.ts +91 -91
- package/src/init.ts +55 -55
- package/src/notifications.ts +65 -69
- package/src/open-url.ts +0 -1
- package/src/output.ts +67 -74
- package/src/pipe.ts +54 -60
- package/src/saved-searches.ts +58 -58
- package/src/search.ts +555 -0
- package/src/session.ts +36 -37
- package/src/share.ts +54 -49
- package/src/status.ts +61 -61
- package/src/themes.ts +52 -52
- package/src/tips.ts +27 -27
- package/src/trending.ts +556 -563
- package/src/tui.ts +1665 -1520
- package/src/types.ts +139 -147
- package/src/watch.ts +62 -65
- package/bin/search-cli.js +0 -17
- package/completions/search-cli.fish +0 -19
- package/src/cache.ts +0 -64
- package/src/normalizer.ts +0 -66
- package/src/provider.ts +0 -165
- package/src/query.ts +0 -249
- package/src/ranking.ts +0 -57
package/src/export.ts
CHANGED
|
@@ -1,64 +1,63 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Export — format repos as JSON, CSV, Markdown, or plain text.
|
|
3
|
-
*/
|
|
4
|
-
import { writeFileSync } from "fs";
|
|
5
|
-
import type { Repo } from "./types
|
|
6
|
-
|
|
7
|
-
export type ExportFormat = "json" | "csv" | "markdown" | "text";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
);
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
case "
|
|
51
|
-
case "
|
|
52
|
-
case "
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Export — format repos as JSON, CSV, Markdown, or plain text.
|
|
3
|
+
*/
|
|
4
|
+
import { writeFileSync } from "fs";
|
|
5
|
+
import type { Repo } from "./types";
|
|
6
|
+
|
|
7
|
+
export type ExportFormat = "json" | "csv" | "markdown" | "text";
|
|
8
|
+
|
|
9
|
+
export function formatJson(repos: Repo[]): string {
|
|
10
|
+
return JSON.stringify(repos, null, 2);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function csvEscape(val: unknown): string {
|
|
14
|
+
const str = String(val ?? "");
|
|
15
|
+
if (str.includes('"') || str.includes(",") || str.includes("\n")) {
|
|
16
|
+
return `"${str.replace(/"/g, '""')}"`;
|
|
17
|
+
}
|
|
18
|
+
return str;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Format repos as CSV string. */
|
|
22
|
+
export function formatCsv(repos: Repo[]): string {
|
|
23
|
+
const header = "rank,full_name,stars,forks,language,url";
|
|
24
|
+
const rows = repos.map((r, i) =>
|
|
25
|
+
`${i + 1},${csvEscape(r.fullName)},${r.stars},${r.forks},${csvEscape(r.language ?? "")},${csvEscape(r.url)}`,
|
|
26
|
+
);
|
|
27
|
+
return [header, ...rows].join("\n");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Format repos as Markdown table. */
|
|
31
|
+
export function formatMarkdown(repos: Repo[]): string {
|
|
32
|
+
const header = "| # | Repo | Stars | Forks | Language |";
|
|
33
|
+
const sep = "|---|------|-------|-------|----------|";
|
|
34
|
+
const rows = repos.map(
|
|
35
|
+
(r, i) =>
|
|
36
|
+
`| ${i + 1} | [${r.fullName}](${r.url}) | ${r.stars.toLocaleString()} | ${r.forks.toLocaleString()} | ${r.language ?? "—"} |`,
|
|
37
|
+
);
|
|
38
|
+
return [header, sep, ...rows].join("\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Format repos as plain text, one per line. */
|
|
42
|
+
export function formatText(repos: Repo[]): string {
|
|
43
|
+
return repos.map((r, i) => `${i + 1}. ${r.fullName} ★ ${r.stars.toLocaleString()} ${r.url}`).join("\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Format repos in the given format. */
|
|
47
|
+
export function formatRepos(repos: Repo[], format: ExportFormat): string {
|
|
48
|
+
switch (format) {
|
|
49
|
+
case "json": return formatJson(repos);
|
|
50
|
+
case "csv": return formatCsv(repos);
|
|
51
|
+
case "markdown": return formatMarkdown(repos);
|
|
52
|
+
case "text": return formatText(repos);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Export repos to a file. Returns the file path. */
|
|
57
|
+
export function exportToFile(repos: Repo[], format: ExportFormat): string {
|
|
58
|
+
const ext = format === "markdown" ? "md" : format;
|
|
59
|
+
const ts = Date.now();
|
|
60
|
+
const path = `./ghfind-export-${ts}.${ext}`;
|
|
61
|
+
writeFileSync(path, formatRepos(repos, format), "utf-8");
|
|
62
|
+
return path;
|
|
63
|
+
}
|
package/src/help.ts
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Help screen — structured sections rendered by OpenTUI components.
|
|
3
|
-
*
|
|
4
|
-
* Each section has a title (with category color) and formatted rows.
|
|
5
|
-
* The TUI builds these into Box + TextRenderable components inside a ScrollBox.
|
|
6
|
-
*/
|
|
1
|
+
/** Help screen — structured sections rendered by OpenTUI components. */
|
|
7
2
|
|
|
8
3
|
export interface HelpRow {
|
|
9
4
|
keys: string;
|
|
@@ -25,23 +20,20 @@ export function buildHelpSections(): HelpSection[] {
|
|
|
25
20
|
titleColor: "blue",
|
|
26
21
|
rows: [
|
|
27
22
|
{ keys: "↑ ↓ / j k", action: "Move selection" },
|
|
28
|
-
{ keys: "Enter
|
|
23
|
+
{ keys: "Enter", action: "Open repo in browser" },
|
|
29
24
|
{ keys: "/", action: "Focus search input" },
|
|
30
|
-
{ keys: "
|
|
25
|
+
{ keys: "Space", action: "Open command menu" },
|
|
31
26
|
{ keys: "? / Ctrl+H", action: "Toggle this help screen" },
|
|
32
27
|
{ keys: "q", action: "Quit" },
|
|
33
28
|
],
|
|
34
29
|
},
|
|
35
30
|
{
|
|
36
|
-
title: "
|
|
31
|
+
title: " Main View ",
|
|
37
32
|
titleColor: "green",
|
|
38
33
|
rows: [
|
|
39
|
-
{ keys: "s", action: "Cycle sort: best-match → stars → …" },
|
|
40
|
-
{ keys: "l", action: "Cycle limit: 10 → 25 → 50 → 100" },
|
|
41
|
-
{ keys: "r", action: "Refresh / re-run last search" },
|
|
42
|
-
{ keys: "g", action: "Toggle activity graph fullscreen" },
|
|
43
34
|
{ keys: "Tab", action: "Auto-complete qualifier" },
|
|
44
|
-
{ keys: "
|
|
35
|
+
{ keys: "PageDown", action: "Next page of results" },
|
|
36
|
+
{ keys: "PageUp", action: "Scroll to top of results" },
|
|
45
37
|
],
|
|
46
38
|
},
|
|
47
39
|
{
|
|
@@ -50,32 +42,32 @@ export function buildHelpSections(): HelpSection[] {
|
|
|
50
42
|
rows: [
|
|
51
43
|
{ keys: "1-5", action: "Switch tab: Today → Week → Month → …" },
|
|
52
44
|
{ keys: "← → / h l", action: "Navigate tabs" },
|
|
53
|
-
{ keys: "r", action: "Refresh" },
|
|
54
45
|
],
|
|
55
46
|
},
|
|
56
47
|
{
|
|
57
|
-
title: "
|
|
48
|
+
title: " Command Menu (Space)",
|
|
58
49
|
titleColor: "purple",
|
|
59
50
|
rows: [
|
|
60
|
-
{ keys: "
|
|
61
|
-
{ keys: "
|
|
62
|
-
{ keys: "
|
|
63
|
-
{ keys: "
|
|
64
|
-
{ keys: "
|
|
65
|
-
{ keys: "
|
|
66
|
-
{ keys: "
|
|
51
|
+
{ keys: "Sort", action: "Cycle sort: best-match → stars → …" },
|
|
52
|
+
{ keys: "Limit", action: "Cycle limit: 10 → 25 → 50 → 100" },
|
|
53
|
+
{ keys: "Refresh", action: "Re-run current search" },
|
|
54
|
+
{ keys: "Graph", action: "Toggle commit activity chart" },
|
|
55
|
+
{ keys: "Readme", action: "Full README viewer" },
|
|
56
|
+
{ keys: "Deep-dive", action: "Languages + contributors + excerpt" },
|
|
57
|
+
{ keys: "Bookmark", action: "Save / unsave selected repo" },
|
|
58
|
+
{ keys: "Compare", action: "Add to / remove from comparison" },
|
|
59
|
+
{ keys: "Trending", action: "Switch to trending browser" },
|
|
67
60
|
],
|
|
61
|
+
note: "Also: Open, History, Export, Share, Topics, Saved, Notifs, Help",
|
|
68
62
|
},
|
|
69
63
|
{
|
|
70
|
-
title: "
|
|
64
|
+
title: " Overlay Keys ",
|
|
71
65
|
titleColor: "teal",
|
|
72
66
|
rows: [
|
|
73
|
-
{ keys: "
|
|
74
|
-
{ keys: "
|
|
75
|
-
{ keys: "
|
|
76
|
-
{ keys: "
|
|
77
|
-
{ keys: "Ctrl+S", action: "Save current search" },
|
|
78
|
-
{ keys: "Ctrl+P", action: "Share selected repo" },
|
|
67
|
+
{ keys: "Esc / q", action: "Close any overlay" },
|
|
68
|
+
{ keys: "Enter", action: "Confirm selection / run action" },
|
|
69
|
+
{ keys: "d", action: "Delete entry (history, bookmarks, saved)" },
|
|
70
|
+
{ keys: "↑ ↓ / j k", action: "Navigate overlay list" },
|
|
79
71
|
],
|
|
80
72
|
},
|
|
81
73
|
{
|
package/src/history.ts
CHANGED
|
@@ -1,91 +1,91 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Persistent search history — append-only JSONL with rotation.
|
|
3
|
-
*/
|
|
4
|
-
import { existsSync, mkdirSync, readFileSync, appendFileSync, writeFileSync, unlinkSync } from "fs";
|
|
5
|
-
import { join } from "path";
|
|
6
|
-
import type { HistoryEntry } from "./types
|
|
7
|
-
import { stateDir } from "./config
|
|
8
|
-
|
|
9
|
-
const HISTORY_FILE = join(stateDir(), "history.jsonl");
|
|
10
|
-
const MAX_ENTRIES = 500;
|
|
11
|
-
|
|
12
|
-
function ensureDir() {
|
|
13
|
-
mkdirSync(stateDir(), { recursive: true });
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** Append one entry to the history log. */
|
|
17
|
-
export function appendHistory(entry: HistoryEntry): void {
|
|
18
|
-
ensureDir();
|
|
19
|
-
const line = JSON.stringify(entry) + "\n";
|
|
20
|
-
try {
|
|
21
|
-
appendFileSync(HISTORY_FILE, line, "utf-8");
|
|
22
|
-
} catch {
|
|
23
|
-
// non-critical
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Read all history entries, newest first, deduplicating consecutive identical queries. */
|
|
28
|
-
export function readHistory(): HistoryEntry[] {
|
|
29
|
-
try {
|
|
30
|
-
const raw = readFileSync(HISTORY_FILE, "utf-8").trim();
|
|
31
|
-
if (!raw) return [];
|
|
32
|
-
const lines = raw.split("\n").filter(Boolean);
|
|
33
|
-
const entries: HistoryEntry[] = lines.map((l) => JSON.parse(l) as HistoryEntry);
|
|
34
|
-
|
|
35
|
-
// Sorted oldest-first in file; reverse to newest-first
|
|
36
|
-
entries.reverse();
|
|
37
|
-
|
|
38
|
-
// Deduplicate consecutive identical queries (keep newest)
|
|
39
|
-
const deduped: HistoryEntry[] = [];
|
|
40
|
-
let lastQuery: string | undefined;
|
|
41
|
-
for (const e of entries) {
|
|
42
|
-
if (e.query !== lastQuery) {
|
|
43
|
-
deduped.push(e);
|
|
44
|
-
lastQuery = e.query;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return deduped;
|
|
48
|
-
} catch {
|
|
49
|
-
return [];
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Delete a single history entry by index (0 = newest). */
|
|
54
|
-
export function deleteHistoryEntry(index: number): void {
|
|
55
|
-
const entries = readHistory();
|
|
56
|
-
if (index < 0 || index >= entries.length) return;
|
|
57
|
-
const removed = entries[index];
|
|
58
|
-
// Rewrite file without the removed entry
|
|
59
|
-
const remaining = entries.filter((e) => e.timestamp !== removed.timestamp || e.query !== removed.query);
|
|
60
|
-
ensureDir();
|
|
61
|
-
// Reverse back to oldest-first for file order
|
|
62
|
-
writeFileSync(HISTORY_FILE, remaining.reverse().map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Clear all history. */
|
|
66
|
-
export function clearHistory(): void {
|
|
67
|
-
try {
|
|
68
|
-
writeFileSync(HISTORY_FILE, "");
|
|
69
|
-
} catch {
|
|
70
|
-
// non-critical
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Rotate history to max entries (trim oldest). */
|
|
75
|
-
export function rotateHistory(): void {
|
|
76
|
-
const entries = readHistory();
|
|
77
|
-
if (entries.length <= MAX_ENTRIES) return;
|
|
78
|
-
const trimmed = entries.slice(0, MAX_ENTRIES);
|
|
79
|
-
ensureDir();
|
|
80
|
-
writeFileSync(HISTORY_FILE, trimmed.reverse().map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Count entries in history file. */
|
|
84
|
-
export function historyCount(): number {
|
|
85
|
-
try {
|
|
86
|
-
const raw = readFileSync(HISTORY_FILE, "utf-8").trim();
|
|
87
|
-
return raw ? raw.split("\n").length : 0;
|
|
88
|
-
} catch {
|
|
89
|
-
return 0;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Persistent search history — append-only JSONL with rotation.
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, appendFileSync, writeFileSync, unlinkSync } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import type { HistoryEntry } from "./types";
|
|
7
|
+
import { stateDir } from "./config";
|
|
8
|
+
|
|
9
|
+
const HISTORY_FILE = join(stateDir(), "history.jsonl");
|
|
10
|
+
const MAX_ENTRIES = 500;
|
|
11
|
+
|
|
12
|
+
function ensureDir() {
|
|
13
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Append one entry to the history log. */
|
|
17
|
+
export function appendHistory(entry: HistoryEntry): void {
|
|
18
|
+
ensureDir();
|
|
19
|
+
const line = JSON.stringify(entry) + "\n";
|
|
20
|
+
try {
|
|
21
|
+
appendFileSync(HISTORY_FILE, line, "utf-8");
|
|
22
|
+
} catch {
|
|
23
|
+
// non-critical
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Read all history entries, newest first, deduplicating consecutive identical queries. */
|
|
28
|
+
export function readHistory(): HistoryEntry[] {
|
|
29
|
+
try {
|
|
30
|
+
const raw = readFileSync(HISTORY_FILE, "utf-8").trim();
|
|
31
|
+
if (!raw) return [];
|
|
32
|
+
const lines = raw.split("\n").filter(Boolean);
|
|
33
|
+
const entries: HistoryEntry[] = lines.map((l) => JSON.parse(l) as HistoryEntry);
|
|
34
|
+
|
|
35
|
+
// Sorted oldest-first in file; reverse to newest-first
|
|
36
|
+
entries.reverse();
|
|
37
|
+
|
|
38
|
+
// Deduplicate consecutive identical queries (keep newest)
|
|
39
|
+
const deduped: HistoryEntry[] = [];
|
|
40
|
+
let lastQuery: string | undefined;
|
|
41
|
+
for (const e of entries) {
|
|
42
|
+
if (e.query !== lastQuery) {
|
|
43
|
+
deduped.push(e);
|
|
44
|
+
lastQuery = e.query;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return deduped;
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Delete a single history entry by index (0 = newest). */
|
|
54
|
+
export function deleteHistoryEntry(index: number): void {
|
|
55
|
+
const entries = readHistory();
|
|
56
|
+
if (index < 0 || index >= entries.length) return;
|
|
57
|
+
const removed = entries[index];
|
|
58
|
+
// Rewrite file without the removed entry
|
|
59
|
+
const remaining = entries.filter((e) => e.timestamp !== removed.timestamp || e.query !== removed.query);
|
|
60
|
+
ensureDir();
|
|
61
|
+
// Reverse back to oldest-first for file order
|
|
62
|
+
writeFileSync(HISTORY_FILE, remaining.reverse().map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Clear all history. */
|
|
66
|
+
export function clearHistory(): void {
|
|
67
|
+
try {
|
|
68
|
+
writeFileSync(HISTORY_FILE, "");
|
|
69
|
+
} catch {
|
|
70
|
+
// non-critical
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Rotate history to max entries (trim oldest). */
|
|
75
|
+
export function rotateHistory(): void {
|
|
76
|
+
const entries = readHistory();
|
|
77
|
+
if (entries.length <= MAX_ENTRIES) return;
|
|
78
|
+
const trimmed = entries.slice(0, MAX_ENTRIES);
|
|
79
|
+
ensureDir();
|
|
80
|
+
writeFileSync(HISTORY_FILE, trimmed.reverse().map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Count entries in history file. */
|
|
84
|
+
export function historyCount(): number {
|
|
85
|
+
try {
|
|
86
|
+
const raw = readFileSync(HISTORY_FILE, "utf-8").trim();
|
|
87
|
+
return raw ? raw.split("\n").length : 0;
|
|
88
|
+
} catch {
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/init.ts
CHANGED
|
@@ -1,55 +1,55 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `
|
|
3
|
-
*
|
|
4
|
-
* ponytail: simple stdin prompts, no curses/readline UI.
|
|
5
|
-
*/
|
|
6
|
-
import { createInterface } from "readline";
|
|
7
|
-
import { saveConfig } from "./config
|
|
8
|
-
import { listThemes } from "./themes
|
|
9
|
-
import type { Config, SortStrategy } from "./types
|
|
10
|
-
|
|
11
|
-
function ask(query: string): Promise<string> {
|
|
12
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
13
|
-
return new Promise((resolve) => {
|
|
14
|
-
rl.question(query, (answer) => {
|
|
15
|
-
rl.close();
|
|
16
|
-
resolve(answer.trim());
|
|
17
|
-
});
|
|
18
|
-
});
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export async function runInitWizard(): Promise<void> {
|
|
22
|
-
console.log("");
|
|
23
|
-
console.log("
|
|
24
|
-
console.log(" ──────────────────────");
|
|
25
|
-
console.log("");
|
|
26
|
-
|
|
27
|
-
const token = await ask(" GitHub token (optional, press Enter to skip): ");
|
|
28
|
-
if (token && !token.startsWith("ghp_") && !token.startsWith("github_pat_") && !token.startsWith("gho_")) {
|
|
29
|
-
console.log(" ⚠ Token doesn't look like a GitHub token (should start with ghp_, gho_, or github_pat_)");
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const sortStr = await ask(" Default sort [best-match/stars/updated/forks] (best-match): ");
|
|
33
|
-
const limitStr = await ask(" Default result limit [10-100] (50): ");
|
|
34
|
-
|
|
35
|
-
const themes = listThemes();
|
|
36
|
-
const themeStr = await ask(` Theme [${themes.join("/")}] (tokyo-night): `);
|
|
37
|
-
|
|
38
|
-
const config: Config = {
|
|
39
|
-
defaultSort: (["best-match", "stars", "updated", "forks"].includes(sortStr)
|
|
40
|
-
? sortStr
|
|
41
|
-
: "best-match") as SortStrategy,
|
|
42
|
-
defaultLimit: Math.min(100, Math.max(10, parseInt(limitStr) || 50)),
|
|
43
|
-
theme: themes.includes(themeStr) ? themeStr : "tokyo-night",
|
|
44
|
-
cacheTtlSeconds: 300,
|
|
45
|
-
defaultTab: "search",
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
if (token) config.githubToken = token;
|
|
49
|
-
|
|
50
|
-
saveConfig(config);
|
|
51
|
-
const { configPath } = await import("./config.ts");
|
|
52
|
-
console.log(` ✓ Config saved to ${configPath()}`);
|
|
53
|
-
console.log(" Run '
|
|
54
|
-
console.log("");
|
|
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 { listThemes } from "./themes";
|
|
9
|
+
import type { Config, SortStrategy } from "./types";
|
|
10
|
+
|
|
11
|
+
function ask(query: string): Promise<string> {
|
|
12
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
rl.question(query, (answer) => {
|
|
15
|
+
rl.close();
|
|
16
|
+
resolve(answer.trim());
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function runInitWizard(): Promise<void> {
|
|
22
|
+
console.log("");
|
|
23
|
+
console.log(" ghfind setup wizard");
|
|
24
|
+
console.log(" ──────────────────────");
|
|
25
|
+
console.log("");
|
|
26
|
+
|
|
27
|
+
const token = await ask(" GitHub token (optional, press Enter to skip): ");
|
|
28
|
+
if (token && !token.startsWith("ghp_") && !token.startsWith("github_pat_") && !token.startsWith("gho_")) {
|
|
29
|
+
console.log(" ⚠ Token doesn't look like a GitHub token (should start with ghp_, gho_, or github_pat_)");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const sortStr = await ask(" Default sort [best-match/stars/updated/forks] (best-match): ");
|
|
33
|
+
const limitStr = await ask(" Default result limit [10-100] (50): ");
|
|
34
|
+
|
|
35
|
+
const themes = listThemes();
|
|
36
|
+
const themeStr = await ask(` Theme [${themes.join("/")}] (tokyo-night): `);
|
|
37
|
+
|
|
38
|
+
const config: Config = {
|
|
39
|
+
defaultSort: (["best-match", "stars", "updated", "forks"].includes(sortStr)
|
|
40
|
+
? sortStr
|
|
41
|
+
: "best-match") as SortStrategy,
|
|
42
|
+
defaultLimit: Math.min(100, Math.max(10, parseInt(limitStr) || 50)),
|
|
43
|
+
theme: themes.includes(themeStr) ? themeStr : "tokyo-night",
|
|
44
|
+
cacheTtlSeconds: 300,
|
|
45
|
+
defaultTab: "search",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
if (token) config.githubToken = token;
|
|
49
|
+
|
|
50
|
+
saveConfig(config);
|
|
51
|
+
const { configPath } = await import("./config.ts");
|
|
52
|
+
console.log(` ✓ Config saved to ${configPath()}`);
|
|
53
|
+
console.log(" Run 'ghfind' to start browsing!");
|
|
54
|
+
console.log("");
|
|
55
|
+
}
|
package/src/notifications.ts
CHANGED
|
@@ -1,69 +1,65 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
case "
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
export function dismissAll(): void {
|
|
67
|
-
notifs = [];
|
|
68
|
-
save();
|
|
69
|
-
}
|
|
1
|
+
/** Notification center — ephemeral alerts for rate limits, watch changes, etc. */
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { stateDir } from "./config";
|
|
5
|
+
|
|
6
|
+
export interface Notification {
|
|
7
|
+
id: number;
|
|
8
|
+
type: "info" | "warning" | "rate-limit" | "watch-change" | "upgrade";
|
|
9
|
+
icon: string;
|
|
10
|
+
message: string;
|
|
11
|
+
timestamp: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let nextId = 1;
|
|
15
|
+
let notifs: Notification[] = [];
|
|
16
|
+
const MAX_NOTIFS = 50;
|
|
17
|
+
const FILE = join(stateDir(), "notifications.json");
|
|
18
|
+
|
|
19
|
+
function load(): void {
|
|
20
|
+
try {
|
|
21
|
+
notifs = JSON.parse(readFileSync(FILE, "utf-8"));
|
|
22
|
+
nextId = (Math.max(...notifs.map((n) => n.id), 0) + 1);
|
|
23
|
+
} catch {
|
|
24
|
+
notifs = [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function save(): void {
|
|
29
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
30
|
+
writeFileSync(FILE, JSON.stringify(notifs));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function addNotification(type: Notification["type"], message: string): Notification {
|
|
34
|
+
if (notifs.length === 0) load();
|
|
35
|
+
const n: Notification = { id: nextId++, type, icon: iconFor(type), message, timestamp: Date.now() };
|
|
36
|
+
notifs.unshift(n);
|
|
37
|
+
if (notifs.length > MAX_NOTIFS) notifs = notifs.slice(0, MAX_NOTIFS);
|
|
38
|
+
save();
|
|
39
|
+
return n;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function iconFor(type: Notification["type"]): string {
|
|
43
|
+
switch (type) {
|
|
44
|
+
case "info": return "ℹ";
|
|
45
|
+
case "warning": return "⚠";
|
|
46
|
+
case "rate-limit": return "🚫";
|
|
47
|
+
case "watch-change": return "🔔";
|
|
48
|
+
case "upgrade": return "📦";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getNotifications(): Notification[] {
|
|
53
|
+
if (notifs.length === 0) load();
|
|
54
|
+
return [...notifs];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function dismissNotification(id: number): void {
|
|
58
|
+
notifs = notifs.filter((n) => n.id !== id);
|
|
59
|
+
save();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function dismissAll(): void {
|
|
63
|
+
notifs = [];
|
|
64
|
+
save();
|
|
65
|
+
}
|