github-search-cli 9.4.1 → 9.4.2
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 +42 -0
- package/README.md +263 -237
- package/dist/cli.js +448 -91
- package/package.json +11 -3
- package/src/cli.ts +634 -566
- package/src/error-report.ts +8 -12
- package/src/org.ts +271 -0
- package/src/tui.ts +3019 -2867
- package/src/update-check.ts +231 -215
- package/src/version.ts +33 -0
package/src/error-report.ts
CHANGED
|
@@ -5,11 +5,14 @@
|
|
|
5
5
|
* Privacy: never auto-posts. The user sees the URL and decides whether to
|
|
6
6
|
* submit. We redact env vars and only include what the diagnostic needs.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
platform as osPlatform,
|
|
10
|
+
arch as osArch,
|
|
11
|
+
version as nodeVersion,
|
|
12
|
+
versions as nodeVersions,
|
|
13
|
+
} from "process";
|
|
9
14
|
import { spawn } from "child_process";
|
|
10
|
-
import {
|
|
11
|
-
import { join, dirname } from "path";
|
|
12
|
-
import { fileURLToPath } from "url";
|
|
15
|
+
import { getVersion } from "./version";
|
|
13
16
|
|
|
14
17
|
// ponytail: in bun, process.platform is a string property; in node, same.
|
|
15
18
|
// We need it as a string, not a function call.
|
|
@@ -22,14 +25,7 @@ const REPO = "codersdfs/search-cli";
|
|
|
22
25
|
const NEW_ISSUE_URL = `https://github.com/${REPO}/issues/new`;
|
|
23
26
|
|
|
24
27
|
function getPackageVersion(): string {
|
|
25
|
-
|
|
26
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
|
-
const pkgPath = join(__dirname, "..", "package.json");
|
|
28
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version?: string };
|
|
29
|
-
return pkg.version ?? "unknown";
|
|
30
|
-
} catch {
|
|
31
|
-
return "unknown";
|
|
32
|
-
}
|
|
28
|
+
return getVersion();
|
|
33
29
|
}
|
|
34
30
|
|
|
35
31
|
function runtimeName(): string {
|
package/src/org.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Org profile module — `ghfind org <name>`.
|
|
3
|
+
*
|
|
4
|
+
* Fetches an organization's metadata and public repos from the GitHub REST API,
|
|
5
|
+
* aggregates them into a single OrgProfile summary (repo count, total stars,
|
|
6
|
+
* top languages, most-starred repos), and renders it as text/JSON/CSV/Markdown.
|
|
7
|
+
*
|
|
8
|
+
* Shape mirrors the package-search module: a pure aggregate function +
|
|
9
|
+
* normalize function that tests can drive with fixtures, and thin formatters.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface OrgProfile {
|
|
13
|
+
login: string;
|
|
14
|
+
name: string | null;
|
|
15
|
+
description: string | null;
|
|
16
|
+
url: string;
|
|
17
|
+
blog: string | null;
|
|
18
|
+
location: string | null;
|
|
19
|
+
createdAt: string;
|
|
20
|
+
/** public_repos as reported by the org payload (all public repos, not just fetched). */
|
|
21
|
+
publicRepoCount: number;
|
|
22
|
+
/** Number of repos actually fetched and aggregated (bounded by the fetch limit). */
|
|
23
|
+
fetchedRepoCount: number;
|
|
24
|
+
totalStars: number;
|
|
25
|
+
totalForks: number;
|
|
26
|
+
/** Top languages by repo count, most-used first. */
|
|
27
|
+
topLanguages: { language: string; repos: number }[];
|
|
28
|
+
/** Most-starred fetched repos, best first. */
|
|
29
|
+
topRepos: OrgRepo[];
|
|
30
|
+
/** Recently pushed fetched repos, most recent first. */
|
|
31
|
+
activeRepos: OrgRepo[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface OrgRepo {
|
|
35
|
+
fullName: string;
|
|
36
|
+
url: string;
|
|
37
|
+
description: string | null;
|
|
38
|
+
language: string | null;
|
|
39
|
+
stars: number;
|
|
40
|
+
forks: number;
|
|
41
|
+
pushedAt: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Raw shapes from the GitHub REST API (only the fields we read). */
|
|
45
|
+
interface GitHubOrgPayload {
|
|
46
|
+
login: string;
|
|
47
|
+
name?: string | null;
|
|
48
|
+
description?: string | null;
|
|
49
|
+
html_url: string;
|
|
50
|
+
blog?: string | null;
|
|
51
|
+
location?: string | null;
|
|
52
|
+
created_at: string;
|
|
53
|
+
public_repos: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface GitHubRepoPayload {
|
|
57
|
+
full_name: string;
|
|
58
|
+
html_url: string;
|
|
59
|
+
description: string | null;
|
|
60
|
+
language: string | null;
|
|
61
|
+
stargazers_count: number;
|
|
62
|
+
forks_count: number;
|
|
63
|
+
pushed_at: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface FetchOrgOptions {
|
|
67
|
+
token?: string;
|
|
68
|
+
/** How many repos to fetch & aggregate (default 100 — one request). */
|
|
69
|
+
limit?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function apiHeaders(token?: string): Record<string, string> {
|
|
73
|
+
return {
|
|
74
|
+
Accept: "application/vnd.github+json",
|
|
75
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
76
|
+
"User-Agent": "ghfind",
|
|
77
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function normalizeOrgRepo(item: GitHubRepoPayload): OrgRepo {
|
|
82
|
+
return {
|
|
83
|
+
fullName: item.full_name ?? "",
|
|
84
|
+
url: item.html_url ?? "",
|
|
85
|
+
description: item.description ?? null,
|
|
86
|
+
language: item.language ?? null,
|
|
87
|
+
stars: item.stargazers_count ?? 0,
|
|
88
|
+
forks: item.forks_count ?? 0,
|
|
89
|
+
pushedAt: item.pushed_at ?? "",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function normalizeOrg(
|
|
94
|
+
payload: GitHubOrgPayload,
|
|
95
|
+
repos: GitHubRepoPayload[],
|
|
96
|
+
): OrgProfile {
|
|
97
|
+
const orgRepos = (repos ?? []).map(normalizeOrgRepo);
|
|
98
|
+
|
|
99
|
+
const langCounts = new Map<string, number>();
|
|
100
|
+
for (const r of orgRepos) {
|
|
101
|
+
if (!r.language) continue;
|
|
102
|
+
langCounts.set(r.language, (langCounts.get(r.language) ?? 0) + 1);
|
|
103
|
+
}
|
|
104
|
+
const topLanguages = [...langCounts.entries()]
|
|
105
|
+
.map(([language, count]) => ({ language, repos: count }))
|
|
106
|
+
.sort((a, b) => b.repos - a.repos || a.language.localeCompare(b.language))
|
|
107
|
+
.slice(0, 5);
|
|
108
|
+
|
|
109
|
+
const byStars = [...orgRepos].sort(
|
|
110
|
+
(a, b) => b.stars - a.stars || a.fullName.localeCompare(b.fullName),
|
|
111
|
+
);
|
|
112
|
+
const byPushed = [...orgRepos]
|
|
113
|
+
.filter((r) => r.pushedAt)
|
|
114
|
+
.sort((a, b) => Date.parse(b.pushedAt) - Date.parse(a.pushedAt));
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
login: payload.login ?? "",
|
|
118
|
+
name: payload.name ?? null,
|
|
119
|
+
description: payload.description ?? null,
|
|
120
|
+
url: payload.html_url ?? "",
|
|
121
|
+
blog: payload.blog ?? null,
|
|
122
|
+
location: payload.location ?? null,
|
|
123
|
+
createdAt: payload.created_at ?? "",
|
|
124
|
+
publicRepoCount: payload.public_repos ?? 0,
|
|
125
|
+
fetchedRepoCount: orgRepos.length,
|
|
126
|
+
totalStars: orgRepos.reduce((sum, r) => sum + r.stars, 0),
|
|
127
|
+
totalForks: orgRepos.reduce((sum, r) => sum + r.forks, 0),
|
|
128
|
+
topLanguages,
|
|
129
|
+
topRepos: byStars.slice(0, 5),
|
|
130
|
+
activeRepos: byPushed.slice(0, 5),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Fetch an org's profile: metadata + up to `limit` public repos, aggregated. */
|
|
135
|
+
export async function fetchOrgProfile(
|
|
136
|
+
org: string,
|
|
137
|
+
options: FetchOrgOptions = {},
|
|
138
|
+
): Promise<OrgProfile> {
|
|
139
|
+
const name = org.trim().replace(/^@/, "");
|
|
140
|
+
if (!name) throw new Error("Usage: ghfind org <org-name>");
|
|
141
|
+
|
|
142
|
+
const token = options.token;
|
|
143
|
+
const limit = Math.min(Math.max(options.limit ?? 100, 1), 500);
|
|
144
|
+
|
|
145
|
+
let orgPayload: GitHubOrgPayload;
|
|
146
|
+
try {
|
|
147
|
+
const res = await fetch(`https://api.github.com/orgs/${encodeURIComponent(name)}`, {
|
|
148
|
+
headers: apiHeaders(token),
|
|
149
|
+
});
|
|
150
|
+
if (res.status === 404) {
|
|
151
|
+
throw new Error(`Organization "${name}" not found.`);
|
|
152
|
+
}
|
|
153
|
+
if (!res.ok) {
|
|
154
|
+
const body = await res.text().catch(() => "");
|
|
155
|
+
throw new Error(`GitHub API error ${res.status}: ${body.slice(0, 200)}`);
|
|
156
|
+
}
|
|
157
|
+
orgPayload = await res.json() as GitHubOrgPayload;
|
|
158
|
+
} catch (err) {
|
|
159
|
+
if (err instanceof Error && err.message.includes(`Organization "${name}"`)) throw err;
|
|
160
|
+
throw new Error(`Failed to fetch org "${name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Orgs with no public repos don't need the repos request.
|
|
164
|
+
const repos: GitHubRepoPayload[] = [];
|
|
165
|
+
if (orgPayload.public_repos > 0) {
|
|
166
|
+
const perPage = Math.min(limit, 100);
|
|
167
|
+
const maxPages = Math.ceil(limit / perPage);
|
|
168
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
169
|
+
const url =
|
|
170
|
+
`https://api.github.com/orgs/${encodeURIComponent(name)}/repos` +
|
|
171
|
+
`?per_page=${perPage}&sort=pushed&page=${page}`;
|
|
172
|
+
const res = await fetch(url, { headers: apiHeaders(token) });
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
// Org metadata succeeded; degraded summary beats a hard failure.
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
const items = (await res.json()) as GitHubRepoPayload[];
|
|
178
|
+
if (!Array.isArray(items)) break; // unexpected shape — degrade gracefully
|
|
179
|
+
repos.push(...items);
|
|
180
|
+
if (repos.length >= limit || items.length < perPage) break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return normalizeOrg(orgPayload, repos.slice(0, limit));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─── Formatters ────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
export function formatOrgText(p: OrgProfile): string {
|
|
190
|
+
const lines: string[] = [];
|
|
191
|
+
const title = p.name ? `${p.login} — ${p.name}` : p.login;
|
|
192
|
+
lines.push(title);
|
|
193
|
+
if (p.description) lines.push(p.description);
|
|
194
|
+
|
|
195
|
+
const meta: string[] = [];
|
|
196
|
+
if (p.location) meta.push(`Location ${p.location}`);
|
|
197
|
+
if (p.blog) meta.push(`Web ${p.blog}`);
|
|
198
|
+
if (p.createdAt) meta.push(`Created ${p.createdAt.slice(0, 10)}`);
|
|
199
|
+
if (meta.length > 0) lines.push(meta.join("\n"));
|
|
200
|
+
|
|
201
|
+
lines.push(`Repos ${p.publicRepoCount.toLocaleString()} public${p.fetchedRepoCount < p.publicRepoCount ? ` (aggregated ${p.fetchedRepoCount})` : ""}`);
|
|
202
|
+
lines.push(`Stars ${p.totalStars.toLocaleString()} (sum of aggregated repos)`);
|
|
203
|
+
lines.push(`Forks ${p.totalForks.toLocaleString()} (sum of aggregated repos)`);
|
|
204
|
+
lines.push(`URL ${p.url}`);
|
|
205
|
+
|
|
206
|
+
if (p.topLanguages.length > 0) {
|
|
207
|
+
const langs = p.topLanguages
|
|
208
|
+
.map((l) => `${l.language} (${l.repos})`)
|
|
209
|
+
.join(", ");
|
|
210
|
+
lines.push(`Languages ${langs}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (p.topRepos.length > 0) {
|
|
214
|
+
lines.push("");
|
|
215
|
+
lines.push("Top repos by stars:");
|
|
216
|
+
for (const r of p.topRepos) {
|
|
217
|
+
lines.push(` ${r.fullName} ★ ${r.stars.toLocaleString()} ${r.language ?? ""}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (p.activeRepos.length > 0) {
|
|
221
|
+
lines.push("");
|
|
222
|
+
lines.push("Recently active:");
|
|
223
|
+
for (const r of p.activeRepos) {
|
|
224
|
+
lines.push(` ${r.fullName} pushed ${r.pushedAt.slice(0, 10)}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return lines.join("\n");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function formatOrgJson(p: OrgProfile): string {
|
|
231
|
+
return JSON.stringify(p, null, 2);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function formatOrgCsv(p: OrgProfile): string {
|
|
235
|
+
const esc = (v: string) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v);
|
|
236
|
+
const row = [
|
|
237
|
+
p.login,
|
|
238
|
+
p.name ?? "",
|
|
239
|
+
p.url,
|
|
240
|
+
String(p.publicRepoCount),
|
|
241
|
+
String(p.totalStars),
|
|
242
|
+
String(p.totalForks),
|
|
243
|
+
p.topLanguages.map((l) => l.language).join(";"),
|
|
244
|
+
p.createdAt,
|
|
245
|
+
].map(esc);
|
|
246
|
+
return ["login,name,url,public_repos,total_stars,total_forks,top_languages,created_at", row.join(",")].join("\n");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function formatOrgMarkdown(p: OrgProfile): string {
|
|
250
|
+
const lines: string[] = [];
|
|
251
|
+
lines.push(`# ${p.login}${p.name ? ` — ${p.name}` : ""}`);
|
|
252
|
+
if (p.description) lines.push("", p.description);
|
|
253
|
+
lines.push(
|
|
254
|
+
"",
|
|
255
|
+
"| Metric | Value |",
|
|
256
|
+
"| --- | --- |",
|
|
257
|
+
`| Public repos | ${p.publicRepoCount} |`,
|
|
258
|
+
`| Total stars (aggregated) | ${p.totalStars.toLocaleString()} |`,
|
|
259
|
+
`| Total forks (aggregated) | ${p.totalForks.toLocaleString()} |`,
|
|
260
|
+
);
|
|
261
|
+
if (p.topLanguages.length > 0) {
|
|
262
|
+
lines.push(`| Top languages | ${p.topLanguages.map((l) => `${l.language} (${l.repos})`).join(", ")} |`);
|
|
263
|
+
}
|
|
264
|
+
if (p.topRepos.length > 0) {
|
|
265
|
+
lines.push("", "## Top repos by stars", "", "| Repo | Stars | Language |", "| --- | --- | --- |");
|
|
266
|
+
for (const r of p.topRepos) {
|
|
267
|
+
lines.push(`| [${r.fullName}](${r.url}) | ${r.stars.toLocaleString()} | ${r.language ?? "—"} |`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return lines.join("\n");
|
|
271
|
+
}
|