github-search-cli 9.4.0 → 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.
@@ -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 { platform as osPlatform, arch as osArch, version as nodeVersion, versions as nodeVersions } from "process";
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 { readFileSync } from "fs";
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
- try {
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
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Help overlay — smallest of the 12, uses only pure data from src/help.ts.
3
+ * First overlay migrated out of tui.ts to prove the registry seam works.
4
+ */
5
+ import { buildHelpSections, HELP_KEYS_COLUMN } from "../../help";
6
+ import type { Overlay } from "./registry";
7
+
8
+ export const helpOverlay: Overlay = {
9
+ id: "help",
10
+ build() {
11
+ // Defer to the existing data builder; rendering stays in tui.ts until
12
+ // the next migration step (this is a structural refactor, not a
13
+ // visual rewrite).
14
+ return {
15
+ sections: buildHelpSections(),
16
+ keysColumn: HELP_KEYS_COLUMN,
17
+ };
18
+ },
19
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Overlay registry seam.
3
+ *
4
+ * Each overlay owns its own renderable subtree, its own keybinds, and its
5
+ * own refresh logic. The TUI shell no longer hard-codes if/else-if chains
6
+ * over id strings; it queries this registry.
7
+ *
8
+ * When migrating an overlay out of src/tui.ts, add its module here and
9
+ * register it in OVERLAYS. The test suite in tests/overlay-registry.test.ts
10
+ * fails if the registry is out of sync with the dispatcher's id set.
11
+ */
12
+
13
+ export interface Overlay {
14
+ /** Stable id matched by the dispatcher's setOverlay(id) calls. */
15
+ id: string;
16
+ /**
17
+ * Build the root renderable for this overlay. Called once during shell
18
+ * construction; the caller adds the result to the modal layer.
19
+ */
20
+ build(): unknown;
21
+ /**
22
+ * Optional refresh hook fired every time the overlay becomes visible.
23
+ * Use this to re-read history, re-list bookmarks, etc.
24
+ */
25
+ onShow?(): void;
26
+ /**
27
+ * Optional teardown fired when the overlay is hidden. Cancel pending
28
+ * fetches, drop focus, etc.
29
+ */
30
+ onHide?(): void;
31
+ /**
32
+ * Optional key handler. Return true if the overlay consumed the key
33
+ * (caller should not propagate to the main view).
34
+ */
35
+ handleKey?(key: string): boolean;
36
+ }
37
+
38
+ // ponytail: registry is an array (not a Map) to keep import order trivial
39
+ // and to let tests inspect id order. Add when we exceed ~20 overlays.
40
+ export const OVERLAYS: Overlay[] = [];
41
+ // helpOverlay intentionally not auto-registered: the migration is gradual.
42
+ // Uncomment when src/tui.ts is ready to consume the registry instead of
43
+ // building helpBox inline.
44
+ // import { helpOverlay } from "./help";
45
+ // OVERLAYS.push(helpOverlay);
46
+
47
+ const byId = new Map<string, Overlay>();
48
+ for (const o of OVERLAYS) byId.set(o.id, o);
49
+
50
+ export function getOverlay(id: string): Overlay | undefined {
51
+ return byId.get(id);
52
+ }