github-reporadar 0.1.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.
@@ -0,0 +1,655 @@
1
+ import { execFileSync, execFile } from "node:child_process";
2
+ import { readFileSync, mkdirSync, writeFileSync, createReadStream, existsSync, statSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve, extname, normalize, sep, dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { createServer } from "node:http";
7
+ function toLocalYMD(d) {
8
+ const y = d.getFullYear();
9
+ const m = String(d.getMonth() + 1).padStart(2, "0");
10
+ const day = String(d.getDate()).padStart(2, "0");
11
+ return `${y}-${m}-${day}`;
12
+ }
13
+ function dateRange(now, days) {
14
+ const dates = [];
15
+ for (let i = days - 1; i >= 0; i--) {
16
+ dates.push(toLocalYMD(new Date(now.getTime() - i * 24 * 60 * 60 * 1e3)));
17
+ }
18
+ return dates;
19
+ }
20
+ function daysSince(iso, now) {
21
+ return Math.max(0, Math.floor((now.getTime() - new Date(iso).getTime()) / (24 * 60 * 60 * 1e3)));
22
+ }
23
+ function buildRepoStats(repos, activity, days, now) {
24
+ const dates = dateRange(now, days);
25
+ const dateIndex = new Map(dates.map((d, i) => [d, i]));
26
+ const byRepo = /* @__PURE__ */ new Map();
27
+ for (const a of activity) {
28
+ const idx = dateIndex.get(a.date);
29
+ if (idx === void 0) continue;
30
+ let arr = byRepo.get(a.repo);
31
+ if (!arr) {
32
+ arr = new Array(days).fill(0);
33
+ byRepo.set(a.repo, arr);
34
+ }
35
+ arr[idx] += a.commits;
36
+ }
37
+ return repos.map((repo) => {
38
+ const dailyCommits = byRepo.get(repo.nameWithOwner) ?? new Array(days).fill(0);
39
+ return {
40
+ repo,
41
+ totalCommits: dailyCommits.reduce((s, n) => s + n, 0),
42
+ daysSincePush: daysSince(repo.pushedAt, now),
43
+ dailyCommits
44
+ };
45
+ });
46
+ }
47
+ function sortRepoStats(stats, key) {
48
+ const sorted = [...stats];
49
+ switch (key) {
50
+ case "active":
51
+ sorted.sort((a, b) => b.totalCommits - a.totalCommits);
52
+ break;
53
+ case "recent":
54
+ sorted.sort((a, b) => a.daysSincePush - b.daysSincePush);
55
+ break;
56
+ case "stale":
57
+ sorted.sort((a, b) => b.daysSincePush - a.daysSincePush);
58
+ break;
59
+ }
60
+ return sorted;
61
+ }
62
+ const CACHE_DIR = join(import.meta.dirname, "..", "public", "data");
63
+ const dim = (s) => `\x1B[2m${s}\x1B[0m`;
64
+ const bold = (s) => `\x1B[1m${s}\x1B[0m`;
65
+ const green = (s) => `\x1B[32m${s}\x1B[0m`;
66
+ const yellow = (s) => `\x1B[33m${s}\x1B[0m`;
67
+ const red = (s) => `\x1B[31m${s}\x1B[0m`;
68
+ const cyan = (s) => `\x1B[36m${s}\x1B[0m`;
69
+ const SPARK_CHARS = ["·", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
70
+ function sparkline(daily, width) {
71
+ const bucketSize = Math.ceil(daily.length / width);
72
+ const buckets = [];
73
+ for (let i = 0; i < daily.length; i += bucketSize) {
74
+ buckets.push(daily.slice(i, i + bucketSize).reduce((s, n) => s + n, 0));
75
+ }
76
+ const max = Math.max(...buckets, 1);
77
+ return buckets.map((v) => v === 0 ? SPARK_CHARS[0] : SPARK_CHARS[1 + Math.min(7, Math.floor(v / max * 7))]).join("");
78
+ }
79
+ function argValue(flag) {
80
+ const i = process.argv.indexOf(flag);
81
+ return i !== -1 ? process.argv[i + 1] : void 0;
82
+ }
83
+ function runBrief({ dataDir, days, sort, limit, includePrivate }) {
84
+ let repos, activity, meta;
85
+ try {
86
+ repos = JSON.parse(readFileSync(join(dataDir, "repos.json"), "utf8"));
87
+ activity = JSON.parse(readFileSync(join(dataDir, "activity.json"), "utf8"));
88
+ meta = JSON.parse(readFileSync(join(dataDir, "meta.json"), "utf8"));
89
+ } catch {
90
+ throw new Error(`キャッシュがありません(${dataDir})。先にsyncを実行してください。`);
91
+ }
92
+ const now = /* @__PURE__ */ new Date();
93
+ const visibleRepos = includePrivate ? repos : repos.filter((r) => !r.isPrivate);
94
+ const stats = sortRepoStats(buildRepoStats(visibleRepos, activity, days, now), sort);
95
+ const shown = stats.slice(0, limit);
96
+ const nameWidth = Math.max(...shown.map((s) => s.repo.nameWithOwner.length), 10);
97
+ console.log(
98
+ bold(`GitHub RepoRadar`) + dim(
99
+ ` ${days}日間 / sort:${sort} / sync: ${meta.lastSyncAt.slice(0, 16).replace("T", " ")} (${meta.login})`
100
+ )
101
+ );
102
+ console.log();
103
+ for (const s of shown) {
104
+ const ago = s.daysSincePush === 0 ? green("today ") : s.daysSincePush <= 7 ? yellow(`${String(s.daysSincePush).padStart(2)}d ago`) : red(`${String(s.daysSincePush).padStart(2)}d ago`);
105
+ const lastMsg = s.repo.lastCommits[0]?.message.split("\n")[0] ?? dim("(no commits)");
106
+ const priv = s.repo.isPrivate ? dim(" 🔒") : "";
107
+ console.log(
108
+ `${cyan(s.repo.nameWithOwner.padEnd(nameWidth))}${priv} ${sparkline(s.dailyCommits, 20)} ${String(s.totalCommits).padStart(4)}c ${ago} ${dim(lastMsg.slice(0, 60))}`
109
+ );
110
+ }
111
+ console.log();
112
+ const total = stats.reduce((sum, s) => sum + s.totalCommits, 0);
113
+ console.log(dim(`${stats.length} repos / ${total} commits (直近${days}日)。全件はWeb UIで。`));
114
+ }
115
+ const isMain$1 = process.argv[1]?.endsWith("brief.ts") || process.argv[1]?.endsWith("brief.js");
116
+ if (isMain$1) {
117
+ try {
118
+ runBrief({
119
+ dataDir: CACHE_DIR,
120
+ days: Number(argValue("--days") ?? 30),
121
+ sort: argValue("--sort") ?? "active",
122
+ limit: Number(argValue("--limit") ?? 15),
123
+ includePrivate: process.argv.includes("--private")
124
+ });
125
+ } catch (err) {
126
+ console.error(err instanceof Error ? err.message : err);
127
+ process.exit(1);
128
+ }
129
+ }
130
+ function createLimiter(limit) {
131
+ if (!Number.isInteger(limit) || limit < 1) {
132
+ throw new RangeError(`createLimiter: limitは1以上の整数が必要 (受け取った値: ${limit})`);
133
+ }
134
+ let active = 0;
135
+ const waiting = [];
136
+ let failure = null;
137
+ let failed = false;
138
+ return async function run(fn) {
139
+ while (active >= limit) await new Promise((resolve2) => waiting.push(resolve2));
140
+ if (failed) {
141
+ waiting.shift()?.();
142
+ throw failure;
143
+ }
144
+ active++;
145
+ try {
146
+ return await fn();
147
+ } catch (err) {
148
+ if (!failed) {
149
+ failed = true;
150
+ failure = err;
151
+ }
152
+ throw err;
153
+ } finally {
154
+ active--;
155
+ waiting.shift()?.();
156
+ }
157
+ };
158
+ }
159
+ function getToken() {
160
+ return execFileSync("gh", ["auth", "token"], { encoding: "utf8" }).trim();
161
+ }
162
+ const API = "https://api.github.com";
163
+ const MAX_COMMIT_PAGES = 10;
164
+ const CONCURRENCY = 16;
165
+ const MAX_RETRIES = 3;
166
+ async function rest(token, path) {
167
+ const url = path.startsWith("https://") ? path : `${API}${path}`;
168
+ for (let attempt = 0; ; attempt++) {
169
+ const res = await fetch(url, {
170
+ headers: {
171
+ Authorization: `Bearer ${token}`,
172
+ Accept: "application/vnd.github+json",
173
+ "X-GitHub-Api-Version": "2022-11-28"
174
+ }
175
+ });
176
+ if (res.ok) {
177
+ const link = res.headers.get("link");
178
+ const next = link?.match(/<([^>]+)>;\s*rel="next"/)?.[1] ?? null;
179
+ return { json: await res.json(), next };
180
+ }
181
+ const retryable = res.status === 403 || res.status === 429 || res.status >= 500;
182
+ if (retryable && attempt < MAX_RETRIES) {
183
+ const retryAfter = Number(res.headers.get("retry-after"));
184
+ const reset = Number(res.headers.get("x-ratelimit-reset"));
185
+ let waitMs = 2e3 * (attempt + 1);
186
+ if (Number.isFinite(retryAfter) && retryAfter > 0) waitMs = retryAfter * 1e3;
187
+ else if (Number.isFinite(reset) && reset > 0) waitMs = Math.max(0, reset * 1e3 - Date.now());
188
+ await new Promise((r) => setTimeout(r, Math.min(waitMs, 6e4)));
189
+ continue;
190
+ }
191
+ throw new Error(`GitHub API HTTP ${res.status} for ${path}: ${await res.text()}`);
192
+ }
193
+ }
194
+ async function fetchLogin(token) {
195
+ const { json } = await rest(token, "/user");
196
+ return json.login;
197
+ }
198
+ async function listRecentRepos(token, since, includePrivate) {
199
+ const repos = [];
200
+ const visibility = "";
201
+ let url = `/user/repos?sort=pushed&per_page=100&affiliation=owner${visibility}`;
202
+ while (url) {
203
+ const { json, next } = await rest(token, url);
204
+ const page = json;
205
+ repos.push(...page);
206
+ if (page.length === 0 || new Date(page[page.length - 1].pushed_at) < since) break;
207
+ url = next;
208
+ }
209
+ return repos.filter((r) => {
210
+ return new Date(r.pushed_at) >= since;
211
+ });
212
+ }
213
+ async function listCommits(token, repo, since) {
214
+ const commits = [];
215
+ let url = `/repos/${repo.full_name}/commits?since=${since.toISOString()}&per_page=100`;
216
+ let pages = 0;
217
+ while (url && pages < MAX_COMMIT_PAGES) {
218
+ let result;
219
+ try {
220
+ result = await rest(token, url);
221
+ } catch (err) {
222
+ if (err instanceof Error && err.message.includes("HTTP 409")) return [];
223
+ throw err;
224
+ }
225
+ commits.push(...result.json);
226
+ url = result.next;
227
+ pages++;
228
+ }
229
+ return commits.filter((c) => c.author?.type !== "Bot");
230
+ }
231
+ async function fetchOpenCounts(token, fullNames, limit) {
232
+ const result = /* @__PURE__ */ new Map();
233
+ const CHUNK = 25;
234
+ const chunks = [];
235
+ for (let i = 0; i < fullNames.length; i += CHUNK) chunks.push(fullNames.slice(i, i + CHUNK));
236
+ await Promise.all(chunks.map((chunk) => limit(async () => {
237
+ const fields = chunk.map((name, j) => {
238
+ const [owner, repo] = name.split("/");
239
+ return `r${j}: repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(repo)}) { issues(states: OPEN) { totalCount } pullRequests(states: OPEN) { totalCount } }`;
240
+ }).join("\n");
241
+ const res = await fetch(`${API}/graphql`, {
242
+ method: "POST",
243
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
244
+ body: JSON.stringify({ query: `query {
245
+ ${fields}
246
+ }` })
247
+ });
248
+ if (!res.ok) throw new Error(`GitHub GraphQL HTTP ${res.status}: ${await res.text()}`);
249
+ const json = await res.json();
250
+ chunk.forEach((name, j) => {
251
+ const node = json.data?.[`r${j}`];
252
+ result.set(name, {
253
+ pr: node?.pullRequests.totalCount ?? 0,
254
+ issue: node?.issues.totalCount ?? 0
255
+ });
256
+ });
257
+ })));
258
+ return result;
259
+ }
260
+ async function listOpenIssues(token, repo) {
261
+ const items = [];
262
+ let url = `/repos/${repo.full_name}/issues?state=open&per_page=100`;
263
+ let pages = 0;
264
+ while (url && pages < 5) {
265
+ let result;
266
+ try {
267
+ result = await rest(token, url);
268
+ } catch (err) {
269
+ if (err instanceof Error && err.message.includes("HTTP 409")) return [];
270
+ throw err;
271
+ }
272
+ for (const it of result.json) {
273
+ items.push({
274
+ repo: repo.full_name,
275
+ type: it.pull_request ? "pr" : "issue",
276
+ number: it.number,
277
+ title: it.title,
278
+ state: it.state,
279
+ labels: it.labels.map((l) => l.name),
280
+ updatedAt: it.updated_at,
281
+ url: it.html_url,
282
+ comments: it.comments
283
+ });
284
+ }
285
+ url = result.next;
286
+ pages++;
287
+ }
288
+ return items;
289
+ }
290
+ function createProgress(repoTotal, issueTotal) {
291
+ const enabled = process.stdout.isTTY === true;
292
+ let repos = 0;
293
+ let issues = 0;
294
+ const render = () => {
295
+ if (enabled) process.stdout.write(`\r repos: ${repos}/${repoTotal} issues/PRs: ${issues}/${issueTotal}`);
296
+ };
297
+ return {
298
+ repoDone: () => {
299
+ repos++;
300
+ render();
301
+ },
302
+ issueDone: () => {
303
+ issues++;
304
+ render();
305
+ },
306
+ finish: () => {
307
+ if (enabled && repoTotal > 0) process.stdout.write("\n");
308
+ }
309
+ };
310
+ }
311
+ async function fetchActivity(token, from, opts) {
312
+ const limit = createLimiter(CONCURRENCY);
313
+ const [login, recentRepos] = await Promise.all([
314
+ fetchLogin(token),
315
+ listRecentRepos(token, from)
316
+ ]);
317
+ const openCounts = await fetchOpenCounts(token, recentRepos.map((r) => r.full_name), limit);
318
+ const issueRepos = recentRepos.filter((r) => {
319
+ const c = openCounts.get(r.full_name);
320
+ return c != null && c.pr + c.issue > 0;
321
+ });
322
+ const progress = createProgress(recentRepos.length, issueRepos.length);
323
+ const commitsPromise = Promise.all(
324
+ recentRepos.map(
325
+ (r) => limit(() => listCommits(token, r, from)).then((commits) => {
326
+ progress.repoDone();
327
+ return commits;
328
+ })
329
+ )
330
+ );
331
+ const issuesPromise = Promise.all(
332
+ issueRepos.map(
333
+ (r) => limit(() => listOpenIssues(token, r)).then((items) => {
334
+ progress.issueDone();
335
+ return items;
336
+ })
337
+ )
338
+ );
339
+ const [commitsByRepo, issuesByRepo] = await Promise.all([commitsPromise, issuesPromise]);
340
+ progress.finish();
341
+ const repos = [];
342
+ const activity = [];
343
+ recentRepos.forEach((r, i) => {
344
+ const commits = commitsByRepo[i];
345
+ if (commits.length === 0) return;
346
+ const daily = /* @__PURE__ */ new Map();
347
+ for (const c of commits) {
348
+ const ymd = toLocalYMD(new Date(c.commit.author.date));
349
+ daily.set(ymd, (daily.get(ymd) ?? 0) + 1);
350
+ }
351
+ for (const [date, count] of daily) {
352
+ activity.push({ date, repo: r.full_name, commits: count });
353
+ }
354
+ const counts = openCounts.get(r.full_name) ?? { pr: 0, issue: 0 };
355
+ repos.push({
356
+ nameWithOwner: r.full_name,
357
+ url: r.html_url,
358
+ description: r.description,
359
+ isPrivate: r.private,
360
+ primaryLanguage: r.language,
361
+ pushedAt: r.pushed_at,
362
+ openPrCount: counts.pr,
363
+ openIssueCount: counts.issue,
364
+ lastCommits: commits.slice(0, 5).map((c) => ({
365
+ message: c.commit.message,
366
+ committedDate: c.commit.author.date
367
+ }))
368
+ });
369
+ });
370
+ const issues = issuesByRepo.flat();
371
+ return { login, repos, activity, issues };
372
+ }
373
+ const REPO_CACHE_DIR = join(import.meta.dirname, "..", "public", "data");
374
+ const DEFAULT_WINDOW_DAYS = 90;
375
+ async function runSync(opts) {
376
+ const windowDays = opts.days ?? DEFAULT_WINDOW_DAYS;
377
+ const log = opts.log ?? ((line) => console.log(line));
378
+ if (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365) {
379
+ throw new Error(`--days は 1〜365 で指定してください: ${windowDays}`);
380
+ }
381
+ const to = /* @__PURE__ */ new Date();
382
+ const from = new Date(to.getTime() - windowDays * 24 * 60 * 60 * 1e3);
383
+ log(`GitHub RepoRadar sync: ${from.toISOString().slice(0, 10)} 〜 ${to.toISOString().slice(0, 10)} (${windowDays}日)`);
384
+ const token = getToken();
385
+ const { login, repos, activity, issues } = await fetchActivity(token, from);
386
+ mkdirSync(opts.dataDir, { recursive: true });
387
+ const meta = { lastSyncAt: to.toISOString(), login, windowDays, includesPrivate: true };
388
+ writeFileSync(join(opts.dataDir, "repos.json"), JSON.stringify(repos, null, 2));
389
+ writeFileSync(join(opts.dataDir, "activity.json"), JSON.stringify(activity, null, 2));
390
+ writeFileSync(join(opts.dataDir, "issues.json"), JSON.stringify(issues, null, 2));
391
+ writeFileSync(join(opts.dataDir, "meta.json"), JSON.stringify(meta, null, 2));
392
+ const totalCommits = activity.reduce((sum, a) => sum + a.commits, 0);
393
+ log(`OK: ${repos.length} repos / ${totalCommits} commits / ${issues.length} open issues+PRs → ${opts.dataDir}`);
394
+ return { login, repos: repos.length, commits: totalCommits, issues: issues.length };
395
+ }
396
+ const isMain = process.argv[1]?.endsWith("sync.ts") || process.argv[1]?.endsWith("sync.js");
397
+ if (isMain) {
398
+ const daysArg = process.argv.indexOf("--days");
399
+ const days = daysArg !== -1 ? Number(process.argv[daysArg + 1]) : void 0;
400
+ runSync({ dataDir: REPO_CACHE_DIR, days }).catch((err) => {
401
+ console.error(err instanceof Error ? err.message : err);
402
+ process.exit(1);
403
+ });
404
+ }
405
+ const COMMANDS = ["serve", "sync", "brief", "help"];
406
+ const SORTS = ["active", "recent", "stale"];
407
+ const BRIEF_DAYS = [7, 30, 90];
408
+ const DEFAULTS = {
409
+ command: "serve",
410
+ days: 90,
411
+ port: 5177,
412
+ open: true,
413
+ sync: true,
414
+ dataDir: null,
415
+ includePrivate: false,
416
+ sort: "active",
417
+ limit: 15
418
+ };
419
+ function intIn(flag, raw, min, max) {
420
+ if (raw === void 0 || raw.startsWith("--")) throw new RangeError(`${flag} には値が要ります`);
421
+ const n = Number(raw);
422
+ if (!Number.isInteger(n) || n < min || n > max) {
423
+ throw new RangeError(`${flag} は${min}〜${max} の整数で指定してください(受け取った値: ${raw})`);
424
+ }
425
+ return n;
426
+ }
427
+ function parseArgs(argv) {
428
+ const out = { ...DEFAULTS };
429
+ let command = null;
430
+ for (let i = 0; i < argv.length; i++) {
431
+ const a = argv[i];
432
+ switch (a) {
433
+ case "--help":
434
+ case "-h":
435
+ command = "help";
436
+ break;
437
+ case "--no-open":
438
+ out.open = false;
439
+ break;
440
+ case "--no-sync":
441
+ out.sync = false;
442
+ break;
443
+ case "--private":
444
+ out.includePrivate = true;
445
+ break;
446
+ case "--days":
447
+ out.days = intIn(a, argv[++i], 1, 365);
448
+ break;
449
+ case "--port":
450
+ out.port = intIn(a, argv[++i], 0, 65535);
451
+ break;
452
+ case "--limit":
453
+ out.limit = intIn(a, argv[++i], 1, 1e3);
454
+ break;
455
+ case "--data-dir": {
456
+ const v = argv[++i];
457
+ if (v === void 0 || v.startsWith("--")) throw new RangeError(`${a} には値が要ります`);
458
+ out.dataDir = v;
459
+ break;
460
+ }
461
+ case "--sort": {
462
+ const v = argv[++i];
463
+ if (!SORTS.includes(v)) throw new RangeError(`--sortは${SORTS.join(" | ")} のどれか(受け取った値: ${v})`);
464
+ out.sort = v;
465
+ break;
466
+ }
467
+ default:
468
+ if (a.startsWith("-")) throw new RangeError(`知らないフラグです: ${a}`);
469
+ if (!COMMANDS.includes(a)) throw new RangeError(`知らないコマンドです: ${a}(${COMMANDS.join(" | ")})`);
470
+ if (command && command !== "help") throw new RangeError(`コマンドは1つだけ: ${command} と${a}`);
471
+ command = a;
472
+ }
473
+ }
474
+ out.command = command ?? "serve";
475
+ if (out.command === "brief" && !BRIEF_DAYS.includes(out.days)) {
476
+ throw new RangeError(`briefの --daysは${BRIEF_DAYS.join(" | ")} のどれか(受け取った値: ${out.days})`);
477
+ }
478
+ return out;
479
+ }
480
+ const USAGE = `github-reporadar — 自分のGitHub活動を一晩ぶん眺める計器盤
481
+
482
+ 使い方:
483
+ npx github-reporadar同期してからローカルで開く(既定)
484
+ npx github-reporadar sync同期だけ
485
+ npx github-reporadar brief端末に要約を出す
486
+
487
+ フラグ:
488
+ --days N取得する期間(1〜365、既定90)。briefは7 | 30 | 90
489
+ --port N待ち受けポート(既定5177。使用中なら次を探す)
490
+ --no-openブラウザを開かない
491
+ --no-sync同期せずに手元のデータで開く
492
+ --data-dir DIRデータの置き場(既定: $XDG_CACHE_HOMEか~/.cacheのgithub-reporadar/data)
493
+ --sort KEY briefの並び: active | recent | stale
494
+ --limit N briefの件数(既定15)
495
+ --private briefにprivate repoも出す
496
+
497
+ トークンはgh CLI(gh auth token)から実行時に取り、どこにも保存しません。
498
+ 待ち受けは127.0.0.1だけです。データにはprivate repo名とコミット文が含まれます。
499
+ `;
500
+ function defaultDataDir(env, home) {
501
+ const base = env.XDG_CACHE_HOME && env.XDG_CACHE_HOME !== "" ? env.XDG_CACHE_HOME : join(home, ".cache");
502
+ return join(base, "github-reporadar", "data");
503
+ }
504
+ const HOST = "127.0.0.1";
505
+ const DATA_FILES = /* @__PURE__ */ new Set(["repos.json", "activity.json", "issues.json", "meta.json"]);
506
+ const PORT_TRIES = 20;
507
+ const TYPES = {
508
+ ".html": "text/html; charset=utf-8",
509
+ ".js": "text/javascript; charset=utf-8",
510
+ ".mjs": "text/javascript; charset=utf-8",
511
+ ".css": "text/css; charset=utf-8",
512
+ ".json": "application/json; charset=utf-8",
513
+ ".svg": "image/svg+xml",
514
+ ".png": "image/png",
515
+ ".ico": "image/x-icon",
516
+ ".woff2": "font/woff2",
517
+ ".woff": "font/woff",
518
+ ".txt": "text/plain; charset=utf-8",
519
+ ".webmanifest": "application/manifest+json"
520
+ };
521
+ function contentTypeFor(file) {
522
+ return TYPES[extname(file).toLowerCase()] ?? "application/octet-stream";
523
+ }
524
+ function insideRoot(root, rel) {
525
+ const full = resolve(root, "." + normalize("/" + rel));
526
+ const base = resolve(root);
527
+ return full === base || full.startsWith(base + sep) ? full : null;
528
+ }
529
+ function isFile(p) {
530
+ try {
531
+ return existsSync(p) && statSync(p).isFile();
532
+ } catch {
533
+ return false;
534
+ }
535
+ }
536
+ function resolveRequest(rawPath, dist, dataDir) {
537
+ let pathname;
538
+ try {
539
+ pathname = decodeURIComponent(new URL(rawPath, "http://localhost").pathname);
540
+ } catch {
541
+ return null;
542
+ }
543
+ if (pathname.startsWith("/data/")) {
544
+ const name = pathname.slice("/data/".length);
545
+ if (!DATA_FILES.has(name)) return null;
546
+ const file = insideRoot(dataDir, name);
547
+ return file ? { file, fallback: false } : null;
548
+ }
549
+ const candidate = insideRoot(dist, pathname);
550
+ if (candidate && isFile(candidate)) return { file: candidate, fallback: false };
551
+ return { file: join(dist, "index.html"), fallback: true };
552
+ }
553
+ function listen(server, port) {
554
+ return new Promise((ok, ng) => {
555
+ const onError = (e) => {
556
+ server.off("listening", onListening);
557
+ ng(e);
558
+ };
559
+ const onListening = () => {
560
+ server.off("error", onError);
561
+ const addr = server.address();
562
+ ok(typeof addr === "object" && addr ? addr.port : port);
563
+ };
564
+ server.once("error", onError);
565
+ server.once("listening", onListening);
566
+ server.listen(port, HOST);
567
+ });
568
+ }
569
+ async function startServer(opts) {
570
+ const server = createServer((req, res) => {
571
+ if (req.method !== "GET" && req.method !== "HEAD") {
572
+ res.writeHead(405).end();
573
+ return;
574
+ }
575
+ const r = resolveRequest(req.url ?? "/", opts.dist, opts.dataDir);
576
+ if (!r || !isFile(r.file)) {
577
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("not found");
578
+ return;
579
+ }
580
+ const isData = r.file.startsWith(resolve(opts.dataDir));
581
+ res.writeHead(200, {
582
+ "content-type": contentTypeFor(r.file),
583
+ // データは同期のたびに変わる。index.htmlはハッシュ付きassetを指すので毎回確かめる
584
+ "cache-control": isData || r.fallback ? "no-store" : "no-cache"
585
+ });
586
+ if (req.method === "HEAD") {
587
+ res.end();
588
+ return;
589
+ }
590
+ createReadStream(r.file).pipe(res);
591
+ });
592
+ let port = opts.port;
593
+ for (let i = 0; ; i++) {
594
+ try {
595
+ port = await listen(server, port);
596
+ break;
597
+ } catch (e) {
598
+ const code = e.code;
599
+ if (code !== "EADDRINUSE" || opts.port === 0 || i >= PORT_TRIES) throw e;
600
+ port += 1;
601
+ }
602
+ }
603
+ return {
604
+ port,
605
+ host: HOST,
606
+ close: () => new Promise((ok) => server.close(() => ok()))
607
+ };
608
+ }
609
+ function distDir() {
610
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..", "dist");
611
+ }
612
+ function openBrowser(url) {
613
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
614
+ execFile(cmd, args, () => {
615
+ });
616
+ }
617
+ async function main() {
618
+ const args = parseArgs(process.argv.slice(2));
619
+ if (args.command === "help") {
620
+ process.stdout.write(USAGE);
621
+ return;
622
+ }
623
+ const dataDir = args.dataDir ?? defaultDataDir(process.env, homedir());
624
+ if (args.command === "sync" || args.command === "serve" && args.sync) {
625
+ await runSync({ dataDir, days: args.days });
626
+ }
627
+ if (args.command === "sync") return;
628
+ if (args.command === "brief") {
629
+ runBrief({ dataDir, days: args.days, sort: args.sort, limit: args.limit, includePrivate: args.includePrivate });
630
+ return;
631
+ }
632
+ if (!existsSync(join(dataDir, "meta.json"))) {
633
+ throw new Error(`データがありません: ${dataDir}
634
+ 先に \`github-reporadar sync\`を実行するか、--no-syncを外してください`);
635
+ }
636
+ const dist = distDir();
637
+ if (!existsSync(join(dist, "index.html"))) {
638
+ throw new Error(`viewerのビルドが見つかりません: ${dist}
639
+ 開発中なら \`pnpm build\`を先に実行してください`);
640
+ }
641
+ const server = await startServer({ dist, dataDir, port: args.port });
642
+ const url = `http://${server.host}:${server.port}/`;
643
+ console.log(`GitHub RepoRadar: ${url} (データ: ${dataDir})`);
644
+ console.log("止めるにはCtrl+C");
645
+ if (args.open) openBrowser(url);
646
+ const stop = () => {
647
+ server.close().then(() => process.exit(0));
648
+ };
649
+ process.on("SIGINT", stop);
650
+ process.on("SIGTERM", stop);
651
+ }
652
+ main().catch((err) => {
653
+ console.error(err instanceof Error ? err.message : err);
654
+ process.exit(1);
655
+ });