ghbounty 1.0.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 +124 -0
- package/dist/display.d.ts +42 -0
- package/dist/display.d.ts.map +1 -0
- package/dist/display.js +249 -0
- package/dist/display.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +190 -0
- package/dist/index.js.map +1 -0
- package/dist/notifier.d.ts +21 -0
- package/dist/notifier.d.ts.map +1 -0
- package/dist/notifier.js +75 -0
- package/dist/notifier.js.map +1 -0
- package/dist/parser.d.ts +27 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +138 -0
- package/dist/parser.js.map +1 -0
- package/dist/scanner.d.ts +45 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +204 -0
- package/dist/scanner.js.map +1 -0
- package/dist/store.d.ts +54 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +107 -0
- package/dist/store.js.map +1 -0
- package/package.json +33 -0
- package/src/display.ts +284 -0
- package/src/index.ts +251 -0
- package/src/notifier.ts +96 -0
- package/src/parser.ts +185 -0
- package/src/scanner.ts +275 -0
- package/src/store.ts +139 -0
- package/tsconfig.json +19 -0
package/dist/store.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* store.ts — Persistent storage for stats and watch state.
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
const DATA_DIR = join(homedir(), ".ghbounty");
|
|
8
|
+
const STATS_FILE = join(DATA_DIR, "stats.json");
|
|
9
|
+
const SEEN_FILE = join(DATA_DIR, "seen.json");
|
|
10
|
+
function ensureDir() {
|
|
11
|
+
if (!existsSync(DATA_DIR)) {
|
|
12
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function readJSON(filepath, fallback) {
|
|
16
|
+
try {
|
|
17
|
+
const data = readFileSync(filepath, "utf-8");
|
|
18
|
+
return JSON.parse(data);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return fallback;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function writeJSON(filepath, data) {
|
|
25
|
+
ensureDir();
|
|
26
|
+
writeFileSync(filepath, JSON.stringify(data, null, 2), "utf-8");
|
|
27
|
+
}
|
|
28
|
+
const DEFAULT_STATS = {
|
|
29
|
+
totalScans: 0,
|
|
30
|
+
totalBountiesFound: 0,
|
|
31
|
+
totalValueTracked: 0,
|
|
32
|
+
languageCounts: {},
|
|
33
|
+
repoCounts: {},
|
|
34
|
+
lastScanAt: null,
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Load stats from disk.
|
|
38
|
+
*/
|
|
39
|
+
export function loadStats() {
|
|
40
|
+
return readJSON(STATS_FILE, { ...DEFAULT_STATS });
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Save stats to disk.
|
|
44
|
+
*/
|
|
45
|
+
export function saveStats(stats) {
|
|
46
|
+
writeJSON(STATS_FILE, stats);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Record a scan result in stats.
|
|
50
|
+
*/
|
|
51
|
+
export function recordScan(bountyCount, totalValue, languages, repos) {
|
|
52
|
+
const stats = loadStats();
|
|
53
|
+
stats.totalScans++;
|
|
54
|
+
stats.totalBountiesFound += bountyCount;
|
|
55
|
+
stats.totalValueTracked += totalValue;
|
|
56
|
+
stats.lastScanAt = new Date().toISOString();
|
|
57
|
+
for (const lang of languages) {
|
|
58
|
+
if (lang) {
|
|
59
|
+
stats.languageCounts[lang] = (stats.languageCounts[lang] || 0) + 1;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const repo of repos) {
|
|
63
|
+
stats.repoCounts[repo] = (stats.repoCounts[repo] || 0) + 1;
|
|
64
|
+
}
|
|
65
|
+
saveStats(stats);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Get formatted stats for display.
|
|
69
|
+
*/
|
|
70
|
+
export function getFormattedStats() {
|
|
71
|
+
const stats = loadStats();
|
|
72
|
+
const topLanguages = Object.entries(stats.languageCounts)
|
|
73
|
+
.map(([lang, count]) => ({ lang, count }))
|
|
74
|
+
.sort((a, b) => b.count - a.count);
|
|
75
|
+
const topRepos = Object.entries(stats.repoCounts)
|
|
76
|
+
.map(([repo, count]) => ({ repo, count }))
|
|
77
|
+
.sort((a, b) => b.count - a.count);
|
|
78
|
+
return {
|
|
79
|
+
totalScans: stats.totalScans,
|
|
80
|
+
totalBountiesFound: stats.totalBountiesFound,
|
|
81
|
+
totalValueTracked: stats.totalValueTracked,
|
|
82
|
+
topLanguages,
|
|
83
|
+
topRepos,
|
|
84
|
+
lastScanAt: stats.lastScanAt,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Load the set of previously seen bounty URLs.
|
|
89
|
+
*/
|
|
90
|
+
export function loadSeenBounties() {
|
|
91
|
+
const data = readJSON(SEEN_FILE, []);
|
|
92
|
+
return new Set(data);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Save the set of seen bounty URLs.
|
|
96
|
+
*/
|
|
97
|
+
export function saveSeenBounties(seen) {
|
|
98
|
+
writeJSON(SEEN_FILE, Array.from(seen));
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Reset all stats.
|
|
102
|
+
*/
|
|
103
|
+
export function resetStats() {
|
|
104
|
+
saveStats({ ...DEFAULT_STATS });
|
|
105
|
+
saveSeenBounties(new Set());
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.js","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ghbounty",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "GitHub bounty aggregator CLI — find and track bounties across GitHub",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"ghbounty": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"dev": "tsc --watch",
|
|
13
|
+
"start": "node dist/index.js",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"github",
|
|
18
|
+
"bounty",
|
|
19
|
+
"bug-bounty",
|
|
20
|
+
"cli",
|
|
21
|
+
"open-source",
|
|
22
|
+
"rewards"
|
|
23
|
+
],
|
|
24
|
+
"author": "",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"commander": "^12.1.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^22.0.0",
|
|
31
|
+
"typescript": "^5.7.0"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/display.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* display.ts — Table output formatting for bounty data.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { BountyIssue } from "./scanner.js";
|
|
6
|
+
|
|
7
|
+
interface Column {
|
|
8
|
+
header: string;
|
|
9
|
+
key: string;
|
|
10
|
+
width: number;
|
|
11
|
+
align?: "left" | "right" | "center";
|
|
12
|
+
formatter?: (value: any, row: BountyIssue) => string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const COLUMNS: Column[] = [
|
|
16
|
+
{
|
|
17
|
+
header: "Amount",
|
|
18
|
+
key: "amountFormatted",
|
|
19
|
+
width: 10,
|
|
20
|
+
align: "right",
|
|
21
|
+
formatter: (v) => colorAmount(v),
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
header: "Repository",
|
|
25
|
+
key: "repo",
|
|
26
|
+
width: 30,
|
|
27
|
+
formatter: (v) => truncate(v, 30),
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
header: "Title",
|
|
31
|
+
key: "title",
|
|
32
|
+
width: 45,
|
|
33
|
+
formatter: (v) => truncate(v, 45),
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
header: "Lang",
|
|
37
|
+
key: "language",
|
|
38
|
+
width: 12,
|
|
39
|
+
formatter: (v) => v || "—",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
header: "Comments",
|
|
43
|
+
key: "comments",
|
|
44
|
+
width: 8,
|
|
45
|
+
align: "right",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
header: "Updated",
|
|
49
|
+
key: "updatedAt",
|
|
50
|
+
width: 12,
|
|
51
|
+
formatter: (v) => formatRelativeDate(v),
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// ANSI color codes
|
|
56
|
+
const COLORS = {
|
|
57
|
+
reset: "\x1b[0m",
|
|
58
|
+
bold: "\x1b[1m",
|
|
59
|
+
dim: "\x1b[2m",
|
|
60
|
+
green: "\x1b[32m",
|
|
61
|
+
yellow: "\x1b[33m",
|
|
62
|
+
cyan: "\x1b[36m",
|
|
63
|
+
white: "\x1b[37m",
|
|
64
|
+
bgGreen: "\x1b[42m",
|
|
65
|
+
red: "\x1b[31m",
|
|
66
|
+
magenta: "\x1b[35m",
|
|
67
|
+
underline: "\x1b[4m",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function c(color: keyof typeof COLORS, text: string): string {
|
|
71
|
+
return `${COLORS[color]}${text}${COLORS.reset}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function colorAmount(amount: string): string {
|
|
75
|
+
if (amount === "TBD") return c("dim", amount);
|
|
76
|
+
const num = parseInt(amount.replace(/[$,]/g, ""));
|
|
77
|
+
if (num >= 1000) return c("green", c("bold", amount));
|
|
78
|
+
if (num >= 500) return c("green", amount);
|
|
79
|
+
if (num >= 100) return c("yellow", amount);
|
|
80
|
+
return amount;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function truncate(str: string, maxLen: number): string {
|
|
84
|
+
if (!str) return "";
|
|
85
|
+
if (str.length <= maxLen) return str;
|
|
86
|
+
return str.slice(0, maxLen - 1) + "\u2026";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function formatRelativeDate(dateStr: string): string {
|
|
90
|
+
const date = new Date(dateStr);
|
|
91
|
+
const now = new Date();
|
|
92
|
+
const diffMs = now.getTime() - date.getTime();
|
|
93
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
94
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
95
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
96
|
+
|
|
97
|
+
if (diffMins < 1) return "just now";
|
|
98
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
99
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
100
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
101
|
+
if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`;
|
|
102
|
+
if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`;
|
|
103
|
+
return `${Math.floor(diffDays / 365)}y ago`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function pad(str: string, width: number, align: string = "left"): string {
|
|
107
|
+
// Strip ANSI codes for length calculation
|
|
108
|
+
const stripped = str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
109
|
+
const diff = width - stripped.length;
|
|
110
|
+
if (diff <= 0) return str;
|
|
111
|
+
|
|
112
|
+
if (align === "right") return " ".repeat(diff) + str;
|
|
113
|
+
if (align === "center") {
|
|
114
|
+
const left = Math.floor(diff / 2);
|
|
115
|
+
return " ".repeat(left) + str + " ".repeat(diff - left);
|
|
116
|
+
}
|
|
117
|
+
return str + " ".repeat(diff);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Render a table of bounties.
|
|
122
|
+
*/
|
|
123
|
+
export function renderTable(bounties: BountyIssue[]): string {
|
|
124
|
+
const lines: string[] = [];
|
|
125
|
+
const sep = " ";
|
|
126
|
+
|
|
127
|
+
// Header
|
|
128
|
+
const headerLine = COLUMNS.map((col) =>
|
|
129
|
+
c("bold", pad(col.header, col.width, col.align))
|
|
130
|
+
).join(sep);
|
|
131
|
+
lines.push(headerLine);
|
|
132
|
+
|
|
133
|
+
// Separator
|
|
134
|
+
const separatorLine = COLUMNS.map((col) => "\u2500".repeat(col.width)).join(sep);
|
|
135
|
+
lines.push(c("dim", separatorLine));
|
|
136
|
+
|
|
137
|
+
// Rows
|
|
138
|
+
for (const bounty of bounties) {
|
|
139
|
+
const cells = COLUMNS.map((col) => {
|
|
140
|
+
let value = (bounty as any)[col.key];
|
|
141
|
+
if (col.formatter) {
|
|
142
|
+
value = col.formatter(value, bounty);
|
|
143
|
+
} else {
|
|
144
|
+
value = String(value ?? "");
|
|
145
|
+
}
|
|
146
|
+
return pad(value, col.width, col.align);
|
|
147
|
+
});
|
|
148
|
+
lines.push(cells.join(sep));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return lines.join("\n");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Render a summary header.
|
|
156
|
+
*/
|
|
157
|
+
export function renderSummary(
|
|
158
|
+
bounties: BountyIssue[],
|
|
159
|
+
elapsed: number
|
|
160
|
+
): string {
|
|
161
|
+
const totalBounties = bounties.length;
|
|
162
|
+
const withAmount = bounties.filter((b) => b.amount !== null);
|
|
163
|
+
const totalValue = withAmount.reduce((sum, b) => sum + (b.amount || 0), 0);
|
|
164
|
+
const avgValue =
|
|
165
|
+
withAmount.length > 0 ? Math.round(totalValue / withAmount.length) : 0;
|
|
166
|
+
const topAmount = withAmount.length > 0 ? withAmount[0].amount! : 0;
|
|
167
|
+
|
|
168
|
+
const lines = [
|
|
169
|
+
"",
|
|
170
|
+
c("bold", c("cyan", " ghbounty") + " — GitHub Bounty Scanner"),
|
|
171
|
+
c("dim", ` Scanned in ${(elapsed / 1000).toFixed(1)}s`),
|
|
172
|
+
"",
|
|
173
|
+
` ${c("bold", String(totalBounties))} bounties found | ` +
|
|
174
|
+
`Total: ${c("green", c("bold", "$" + totalValue.toLocaleString()))} | ` +
|
|
175
|
+
`Avg: ${c("yellow", "$" + avgValue.toLocaleString())} | ` +
|
|
176
|
+
`Top: ${c("green", c("bold", "$" + topAmount.toLocaleString()))}`,
|
|
177
|
+
"",
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
return lines.join("\n");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Render a detailed view of a single bounty.
|
|
185
|
+
*/
|
|
186
|
+
export function renderBountyDetail(bounty: BountyIssue): string {
|
|
187
|
+
const lines = [
|
|
188
|
+
"",
|
|
189
|
+
c("bold", ` ${bounty.title}`),
|
|
190
|
+
` ${c("dim", bounty.repo)} #${bounty.id}`,
|
|
191
|
+
"",
|
|
192
|
+
` Amount: ${colorAmount(bounty.amountFormatted)}`,
|
|
193
|
+
` Author: ${bounty.author}`,
|
|
194
|
+
` Language: ${bounty.language || "Unknown"}`,
|
|
195
|
+
` Labels: ${bounty.labels.join(", ") || "none"}`,
|
|
196
|
+
` Comments: ${bounty.comments}`,
|
|
197
|
+
` Updated: ${formatRelativeDate(bounty.updatedAt)}`,
|
|
198
|
+
` URL: ${c("underline", c("cyan", bounty.url))}`,
|
|
199
|
+
"",
|
|
200
|
+
];
|
|
201
|
+
return lines.join("\n");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Render stats dashboard.
|
|
206
|
+
*/
|
|
207
|
+
export function renderStats(stats: {
|
|
208
|
+
totalScans: number;
|
|
209
|
+
totalBountiesFound: number;
|
|
210
|
+
totalValueTracked: number;
|
|
211
|
+
topLanguages: { lang: string; count: number }[];
|
|
212
|
+
topRepos: { repo: string; count: number }[];
|
|
213
|
+
lastScanAt: string | null;
|
|
214
|
+
}): string {
|
|
215
|
+
const lines = [
|
|
216
|
+
"",
|
|
217
|
+
c("bold", c("cyan", " ghbounty") + " — Your Bounty Hunting Stats"),
|
|
218
|
+
"",
|
|
219
|
+
` Total scans: ${c("bold", String(stats.totalScans))}`,
|
|
220
|
+
` Bounties found: ${c("bold", String(stats.totalBountiesFound))}`,
|
|
221
|
+
` Value tracked: ${c("green", c("bold", "$" + stats.totalValueTracked.toLocaleString()))}`,
|
|
222
|
+
` Last scan: ${stats.lastScanAt ? formatRelativeDate(stats.lastScanAt) : "never"}`,
|
|
223
|
+
"",
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
if (stats.topLanguages.length > 0) {
|
|
227
|
+
lines.push(c("bold", " Top Languages:"));
|
|
228
|
+
for (const { lang, count } of stats.topLanguages.slice(0, 5)) {
|
|
229
|
+
const bar = "\u2588".repeat(Math.min(count, 20));
|
|
230
|
+
lines.push(` ${pad(lang, 15)} ${c("cyan", bar)} ${count}`);
|
|
231
|
+
}
|
|
232
|
+
lines.push("");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (stats.topRepos.length > 0) {
|
|
236
|
+
lines.push(c("bold", " Top Repositories:"));
|
|
237
|
+
for (const { repo, count } of stats.topRepos.slice(0, 5)) {
|
|
238
|
+
lines.push(` ${pad(truncate(repo, 35), 37)} ${c("yellow", String(count))} bounties`);
|
|
239
|
+
}
|
|
240
|
+
lines.push("");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return lines.join("\n");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Render a spinner frame for loading states.
|
|
248
|
+
*/
|
|
249
|
+
const SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
250
|
+
|
|
251
|
+
export function getSpinnerFrame(tick: number): string {
|
|
252
|
+
return SPINNER_FRAMES[tick % SPINNER_FRAMES.length];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Render a "no results" message.
|
|
257
|
+
*/
|
|
258
|
+
export function renderNoResults(options?: { language?: string; minAmount?: number }): string {
|
|
259
|
+
const lines = [
|
|
260
|
+
"",
|
|
261
|
+
c("yellow", " No bounties found matching your criteria."),
|
|
262
|
+
"",
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
if (options?.language || options?.minAmount) {
|
|
266
|
+
lines.push(c("dim", " Active filters:"));
|
|
267
|
+
if (options.language) {
|
|
268
|
+
lines.push(c("dim", ` Language: ${options.language}`));
|
|
269
|
+
}
|
|
270
|
+
if (options.minAmount) {
|
|
271
|
+
lines.push(c("dim", ` Min amount: $${options.minAmount}`));
|
|
272
|
+
}
|
|
273
|
+
lines.push("");
|
|
274
|
+
lines.push(c("dim", " Try broadening your search criteria."));
|
|
275
|
+
lines.push("");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
lines.push(c("dim", " Tips:"));
|
|
279
|
+
lines.push(c("dim", " - Set GITHUB_TOKEN for higher API rate limits"));
|
|
280
|
+
lines.push(c("dim", " - Try: ghbounty scan --deep for broader search"));
|
|
281
|
+
lines.push("");
|
|
282
|
+
|
|
283
|
+
return lines.join("\n");
|
|
284
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ghbounty — GitHub Bounty Aggregator CLI
|
|
5
|
+
*
|
|
6
|
+
* Find and track bounties across GitHub repositories.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Command } from "commander";
|
|
10
|
+
import { scanBounties, deepScan } from "./scanner.js";
|
|
11
|
+
import type { ScanOptions, BountyIssue } from "./scanner.js";
|
|
12
|
+
import { notifyNewBounty, notifyBatchBounties } from "./notifier.js";
|
|
13
|
+
import {
|
|
14
|
+
renderTable,
|
|
15
|
+
renderSummary,
|
|
16
|
+
renderNoResults,
|
|
17
|
+
renderStats,
|
|
18
|
+
renderBountyDetail,
|
|
19
|
+
getSpinnerFrame,
|
|
20
|
+
} from "./display.js";
|
|
21
|
+
import {
|
|
22
|
+
recordScan,
|
|
23
|
+
getFormattedStats,
|
|
24
|
+
loadSeenBounties,
|
|
25
|
+
saveSeenBounties,
|
|
26
|
+
resetStats,
|
|
27
|
+
} from "./store.js";
|
|
28
|
+
|
|
29
|
+
const program = new Command();
|
|
30
|
+
|
|
31
|
+
program
|
|
32
|
+
.name("ghbounty")
|
|
33
|
+
.description("GitHub bounty aggregator — find and track bounties across GitHub")
|
|
34
|
+
.version("1.0.0");
|
|
35
|
+
|
|
36
|
+
// ─── scan command ───────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
program
|
|
39
|
+
.command("scan")
|
|
40
|
+
.description("Scan GitHub for open bounty issues")
|
|
41
|
+
.option("--min <amount>", "Minimum bounty amount in USD", parseInt)
|
|
42
|
+
.option("--max <amount>", "Maximum bounty amount in USD", parseInt)
|
|
43
|
+
.option("--lang <language>", "Filter by programming language")
|
|
44
|
+
.option("--sort <field>", "Sort by: created, updated, comments", "updated")
|
|
45
|
+
.option("--limit <n>", "Max results to show", parseInt)
|
|
46
|
+
.option("--deep", "Deep scan with multiple search strategies")
|
|
47
|
+
.option("--json", "Output as JSON")
|
|
48
|
+
.option("--detail", "Show detailed view of each bounty")
|
|
49
|
+
.action(async (opts) => {
|
|
50
|
+
const startTime = Date.now();
|
|
51
|
+
|
|
52
|
+
// Show scanning indicator
|
|
53
|
+
process.stdout.write("\n Scanning GitHub for bounties");
|
|
54
|
+
const spinnerInterval = setInterval(() => {
|
|
55
|
+
const frame = getSpinnerFrame(Math.floor(Date.now() / 100));
|
|
56
|
+
process.stdout.write(`\r ${frame} Scanning GitHub for bounties...`);
|
|
57
|
+
}, 100);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const scanOpts: ScanOptions = {
|
|
61
|
+
minAmount: opts.min,
|
|
62
|
+
maxAmount: opts.max,
|
|
63
|
+
language: opts.lang,
|
|
64
|
+
sort: opts.sort,
|
|
65
|
+
limit: opts.limit || 30,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
let bounties: BountyIssue[];
|
|
69
|
+
|
|
70
|
+
if (opts.deep) {
|
|
71
|
+
bounties = await deepScan(scanOpts);
|
|
72
|
+
} else {
|
|
73
|
+
bounties = await scanBounties(scanOpts);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
clearInterval(spinnerInterval);
|
|
77
|
+
process.stdout.write("\r" + " ".repeat(60) + "\r"); // Clear spinner line
|
|
78
|
+
|
|
79
|
+
const elapsed = Date.now() - startTime;
|
|
80
|
+
|
|
81
|
+
if (bounties.length === 0) {
|
|
82
|
+
console.log(renderNoResults({ language: opts.lang, minAmount: opts.min }));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Record stats
|
|
87
|
+
const totalValue = bounties
|
|
88
|
+
.filter((b) => b.amount !== null)
|
|
89
|
+
.reduce((sum, b) => sum + (b.amount || 0), 0);
|
|
90
|
+
recordScan(
|
|
91
|
+
bounties.length,
|
|
92
|
+
totalValue,
|
|
93
|
+
bounties.map((b) => b.language),
|
|
94
|
+
bounties.map((b) => b.repo)
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
if (opts.json) {
|
|
98
|
+
console.log(JSON.stringify(bounties, null, 2));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.log(renderSummary(bounties, elapsed));
|
|
103
|
+
|
|
104
|
+
if (opts.detail) {
|
|
105
|
+
for (const bounty of bounties) {
|
|
106
|
+
console.log(renderBountyDetail(bounty));
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
console.log(renderTable(bounties));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
console.log(
|
|
113
|
+
`\n \x1b[2mTip: Use --detail for full info, or --json for machine-readable output\x1b[0m\n`
|
|
114
|
+
);
|
|
115
|
+
} catch (err: any) {
|
|
116
|
+
clearInterval(spinnerInterval);
|
|
117
|
+
process.stdout.write("\r" + " ".repeat(60) + "\r");
|
|
118
|
+
console.error(`\n \x1b[31mError:\x1b[0m ${err.message}\n`);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ─── watch command ──────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
program
|
|
126
|
+
.command("watch")
|
|
127
|
+
.description("Continuously monitor for new bounties with desktop notifications")
|
|
128
|
+
.option("--min <amount>", "Minimum bounty amount in USD", parseInt)
|
|
129
|
+
.option("--lang <language>", "Filter by programming language")
|
|
130
|
+
.option(
|
|
131
|
+
"--interval <seconds>",
|
|
132
|
+
"Check interval in seconds (default: 300)",
|
|
133
|
+
parseInt
|
|
134
|
+
)
|
|
135
|
+
.option("--quiet", "Only notify, don't print to console")
|
|
136
|
+
.action(async (opts) => {
|
|
137
|
+
const intervalSec = opts.interval || 300;
|
|
138
|
+
const seen = loadSeenBounties();
|
|
139
|
+
|
|
140
|
+
console.log(`\n \x1b[1m\x1b[36mghbounty\x1b[0m — Watch Mode`);
|
|
141
|
+
console.log(` Checking every ${intervalSec}s for new bounties...`);
|
|
142
|
+
if (opts.lang) console.log(` Language filter: ${opts.lang}`);
|
|
143
|
+
if (opts.min) console.log(` Min amount: $${opts.min}`);
|
|
144
|
+
console.log(` Press Ctrl+C to stop.\n`);
|
|
145
|
+
|
|
146
|
+
const check = async () => {
|
|
147
|
+
const timestamp = new Date().toLocaleTimeString();
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const bounties = await scanBounties({
|
|
151
|
+
minAmount: opts.min,
|
|
152
|
+
language: opts.lang,
|
|
153
|
+
limit: 50,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const newBounties = bounties.filter((b) => !seen.has(b.url));
|
|
157
|
+
|
|
158
|
+
if (newBounties.length > 0) {
|
|
159
|
+
// Mark as seen
|
|
160
|
+
for (const b of newBounties) {
|
|
161
|
+
seen.add(b.url);
|
|
162
|
+
}
|
|
163
|
+
saveSeenBounties(seen);
|
|
164
|
+
|
|
165
|
+
// Record stats
|
|
166
|
+
const totalValue = newBounties
|
|
167
|
+
.filter((b) => b.amount !== null)
|
|
168
|
+
.reduce((sum, b) => sum + (b.amount || 0), 0);
|
|
169
|
+
recordScan(
|
|
170
|
+
newBounties.length,
|
|
171
|
+
totalValue,
|
|
172
|
+
newBounties.map((b) => b.language),
|
|
173
|
+
newBounties.map((b) => b.repo)
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
// Notify
|
|
177
|
+
await notifyBatchBounties(newBounties);
|
|
178
|
+
|
|
179
|
+
if (!opts.quiet) {
|
|
180
|
+
console.log(
|
|
181
|
+
` \x1b[32m[${timestamp}]\x1b[0m ${newBounties.length} new bounties found!`
|
|
182
|
+
);
|
|
183
|
+
console.log(renderTable(newBounties));
|
|
184
|
+
console.log();
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
if (!opts.quiet) {
|
|
188
|
+
process.stdout.write(
|
|
189
|
+
`\r \x1b[2m[${timestamp}] No new bounties. Next check in ${intervalSec}s...\x1b[0m`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} catch (err: any) {
|
|
194
|
+
console.error(`\n \x1b[31m[${timestamp}] Error: ${err.message}\x1b[0m`);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// Initial check
|
|
199
|
+
await check();
|
|
200
|
+
|
|
201
|
+
// Periodic checks
|
|
202
|
+
setInterval(check, intervalSec * 1000);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ─── stats command ──────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
program
|
|
208
|
+
.command("stats")
|
|
209
|
+
.description("Show your bounty hunting statistics")
|
|
210
|
+
.option("--reset", "Reset all stats")
|
|
211
|
+
.option("--json", "Output as JSON")
|
|
212
|
+
.action((opts) => {
|
|
213
|
+
if (opts.reset) {
|
|
214
|
+
resetStats();
|
|
215
|
+
console.log("\n \x1b[32mStats reset successfully.\x1b[0m\n");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const stats = getFormattedStats();
|
|
220
|
+
|
|
221
|
+
if (opts.json) {
|
|
222
|
+
console.log(JSON.stringify(stats, null, 2));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
console.log(renderStats(stats));
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ─── open command (bonus utility) ───────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
program
|
|
232
|
+
.command("open <url>")
|
|
233
|
+
.description("Open a bounty URL in your browser")
|
|
234
|
+
.action((url) => {
|
|
235
|
+
import("node:child_process").then(({ execSync }) => {
|
|
236
|
+
try {
|
|
237
|
+
if (process.platform === "darwin") {
|
|
238
|
+
execSync(`open "${url}"`);
|
|
239
|
+
} else if (process.platform === "linux") {
|
|
240
|
+
execSync(`xdg-open "${url}"`);
|
|
241
|
+
} else {
|
|
242
|
+
execSync(`start "${url}"`);
|
|
243
|
+
}
|
|
244
|
+
console.log(`\n Opened: ${url}\n`);
|
|
245
|
+
} catch {
|
|
246
|
+
console.log(`\n Could not open browser. URL: ${url}\n`);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
program.parse();
|