github-search-cli 9.8.1 → 9.8.3

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/dist/cli.js CHANGED
@@ -1,21 +1,11 @@
1
- import {
2
- cacheDir,
3
- configPath,
4
- loadConfig,
5
- saveConfig,
6
- stateDir
7
- } from "./chunk-GYIL4S5X.js";
8
-
9
1
  // src/errors.ts
10
2
  var SearchCliError = class extends Error {
11
- constructor(message, userMessage, recoverable = true) {
3
+ constructor(message, userMessage) {
12
4
  super(message);
13
5
  this.userMessage = userMessage;
14
- this.recoverable = recoverable;
15
6
  this.name = "SearchCliError";
16
7
  }
17
8
  userMessage;
18
- recoverable;
19
9
  };
20
10
  var NetworkError = class extends SearchCliError {
21
11
  constructor() {
@@ -26,6 +16,20 @@ var NetworkError = class extends SearchCliError {
26
16
  this.name = "NetworkError";
27
17
  }
28
18
  };
19
+ var AuthError = class extends SearchCliError {
20
+ constructor() {
21
+ const msg = "\u26A0 Bad GitHub token (401). Press [t] to fix it, [u] to search without one";
22
+ super("GitHub API returned 401 (invalid or revoked token)", msg);
23
+ this.name = "AuthError";
24
+ }
25
+ };
26
+ var ForbiddenError = class extends SearchCliError {
27
+ constructor() {
28
+ const msg = "\u26A0 Forbidden (403). If your org enforces SSO, authorize the token; [t] to change it";
29
+ super("GitHub API returned 403 (token lacks access)", msg);
30
+ this.name = "ForbiddenError";
31
+ }
32
+ };
29
33
  var RateLimitError = class extends SearchCliError {
30
34
  constructor(hasToken, resetSeconds) {
31
35
  const msg = hasToken ? `\u26A0 API rate limit exceeded (5,000/hr). Resets in ${resetSeconds ?? "?"}s. [r]etry [c]hange token` : "\u26A0 Rate limited (60/hr). Set GITHUB_TOKEN for 5,000/hr. [r]etry";
@@ -154,6 +158,7 @@ var MemoryCache = class {
154
158
  return entry.data;
155
159
  }
156
160
  set(key, data, ttlMs) {
161
+ this.cache.delete(key);
157
162
  if (this.cache.size >= MAX_CACHE_SIZE) {
158
163
  const firstKey = this.cache.keys().next().value;
159
164
  if (firstKey !== void 0) this.cache.delete(firstKey);
@@ -439,7 +444,14 @@ var GitHubSearchAdapter = class {
439
444
  this.logger.error(
440
445
  `[github] non-rate-limit 403: ${body.slice(0, 200)}`
441
446
  );
442
- throw new NetworkError();
447
+ throw new ForbiddenError();
448
+ }
449
+ if (res.status === 401) {
450
+ const body = await res.text().catch(() => "");
451
+ this.logger.error(
452
+ `[github] auth rejected (401): ${body.slice(0, 200)}`
453
+ );
454
+ throw new AuthError();
443
455
  }
444
456
  if (!res.ok) {
445
457
  const body = await res.text().catch(() => "");
@@ -476,6 +488,7 @@ var GitHubSearchAdapter = class {
476
488
  lastErr = void 0;
477
489
  break;
478
490
  } catch (err) {
491
+ if (err instanceof RateLimitError) throw err;
479
492
  lastErr = err;
480
493
  if (rateLimited) break;
481
494
  }
@@ -1190,13 +1203,199 @@ async function launchPackageBrowser() {
1190
1203
  }
1191
1204
 
1192
1205
  // src/doctor.ts
1193
- import { existsSync, readFileSync, mkdirSync } from "fs";
1206
+ import { existsSync as existsSync2, readFileSync as readFileSync3, mkdirSync as mkdirSync2 } from "fs";
1194
1207
  import { spawnSync } from "child_process";
1195
- import { join, dirname } from "path";
1208
+ import { join as join3, dirname } from "path";
1196
1209
  import { fileURLToPath } from "url";
1197
- import { homedir, platform, arch } from "os";
1210
+ import { homedir as homedir3, platform, arch } from "os";
1211
+
1212
+ // src/config.ts
1213
+ import { homedir } from "os";
1214
+ import { join } from "path";
1215
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "fs";
1216
+ function stateDir() {
1217
+ const xdg = process.env.XDG_STATE_HOME;
1218
+ const base = xdg ? join(xdg, "ghfind") : join(homedir(), ".local", "share", "ghfind");
1219
+ return base;
1220
+ }
1221
+ function cacheDir() {
1222
+ const xdg = process.env.XDG_CACHE_HOME;
1223
+ const base = xdg ? join(xdg, "ghfind") : join(homedir(), ".cache", "ghfind");
1224
+ return base;
1225
+ }
1226
+ var DEFAULTS = {
1227
+ defaultSort: "best-match",
1228
+ defaultLimit: 50,
1229
+ theme: "tokyo-night",
1230
+ cacheTtlSeconds: 300,
1231
+ defaultTab: "search"
1232
+ };
1233
+ function configPath() {
1234
+ const envPath = process.env.GHFIND_CONFIG;
1235
+ if (envPath) return envPath;
1236
+ const xdg = process.env.XDG_CONFIG_HOME;
1237
+ const base = xdg ? join(xdg, "ghfind") : join(homedir(), ".config", "ghfind");
1238
+ return join(base, "config.json");
1239
+ }
1240
+ function loadConfig() {
1241
+ const path = configPath();
1242
+ try {
1243
+ const raw = readFileSync(path, "utf-8");
1244
+ const parsed = JSON.parse(raw);
1245
+ return mergeWithEnv({ ...DEFAULTS, ...parsed });
1246
+ } catch {
1247
+ return mergeWithEnv({ ...DEFAULTS });
1248
+ }
1249
+ }
1250
+ function saveConfig(config) {
1251
+ const path = configPath();
1252
+ const dir = path.slice(
1253
+ 0,
1254
+ Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))
1255
+ );
1256
+ mkdirSync(dir, { recursive: true });
1257
+ writeFileSync(path, JSON.stringify(config, null, 2), {
1258
+ encoding: "utf-8",
1259
+ mode: 384
1260
+ });
1261
+ try {
1262
+ chmodSync(path, 384);
1263
+ } catch {
1264
+ }
1265
+ }
1266
+ function mergeWithEnv(config) {
1267
+ if (process.env.GITHUB_TOKEN) config.githubToken = process.env.GITHUB_TOKEN;
1268
+ return config;
1269
+ }
1270
+
1271
+ // src/login.ts
1272
+ import { createInterface } from "readline";
1273
+ import { homedir as homedir2 } from "os";
1274
+ import { join as join2 } from "path";
1275
+ import { existsSync, readFileSync as readFileSync2 } from "fs";
1276
+ var TOKEN_PREFIXES = ["ghp_", "gho_", "github_pat_"];
1277
+ function isLikelyGithubToken(token) {
1278
+ return TOKEN_PREFIXES.some((prefix) => token.startsWith(prefix));
1279
+ }
1280
+ function maskToken(token) {
1281
+ if (token.length <= 11) return `${token.slice(0, 4)}\u2026`;
1282
+ return `${token.slice(0, 7)}\u2026${token.slice(-4)}`;
1283
+ }
1284
+ function ghCliConfigDir() {
1285
+ return process.env.GH_CONFIG_DIR || join2(homedir2(), ".config", "gh");
1286
+ }
1287
+ function parseHostsYmlToken(text, host = "github.com") {
1288
+ let inHost = false;
1289
+ for (const line of text.split(/\r?\n/)) {
1290
+ const trimmed = line.trim();
1291
+ if (/^[A-Za-z0-9.-]+:\s*$/.test(trimmed)) {
1292
+ inHost = trimmed.slice(0, -1).trim() === host;
1293
+ continue;
1294
+ }
1295
+ if (inHost) {
1296
+ const match = trimmed.match(/^oauth_token:\s*["']?([^"'\s]+)/);
1297
+ if (match) return match[1];
1298
+ }
1299
+ }
1300
+ return void 0;
1301
+ }
1302
+ function readGhCliToken(ghDir = ghCliConfigDir(), host = process.env.GH_HOST ?? "github.com") {
1303
+ const hostsFile = join2(ghDir, "hosts.yml");
1304
+ if (!existsSync(hostsFile)) return void 0;
1305
+ try {
1306
+ return parseHostsYmlToken(readFileSync2(hostsFile, "utf-8"), host);
1307
+ } catch {
1308
+ return void 0;
1309
+ }
1310
+ }
1311
+ function persistToken(token, deps) {
1312
+ const config = deps.readConfig();
1313
+ config.githubToken = token;
1314
+ deps.writeConfig(config);
1315
+ }
1316
+ function createAsk() {
1317
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1318
+ return (query) => new Promise((resolve) => {
1319
+ rl.question(query, (answer) => {
1320
+ rl.close();
1321
+ resolve(answer.trim());
1322
+ });
1323
+ });
1324
+ }
1325
+ function defaultLoginDeps() {
1326
+ return {
1327
+ ask: createAsk(),
1328
+ readGhCliToken: () => readGhCliToken(),
1329
+ readConfig: loadConfig,
1330
+ writeConfig: (config) => saveConfig(config)
1331
+ };
1332
+ }
1333
+ function successNote() {
1334
+ return [
1335
+ " Authentication raises the search rate limit from 60 to",
1336
+ " 5,000 requests/hour. Remove the token anytime by editing",
1337
+ ` ${configPath()} \u2014 delete the "githubToken" line and save.`
1338
+ ];
1339
+ }
1340
+ async function runLoginWizard(deps = defaultLoginDeps()) {
1341
+ const { ask: ask2 } = deps;
1342
+ console.log("");
1343
+ console.log(" ghfind login");
1344
+ console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
1345
+ console.log("");
1346
+ if (process.env.GITHUB_TOKEN) {
1347
+ console.log(
1348
+ " \u2139 GITHUB_TOKEN is set in the environment and already takes precedence."
1349
+ );
1350
+ console.log("");
1351
+ }
1352
+ const current = deps.readConfig();
1353
+ if (current.githubToken) {
1354
+ console.log(
1355
+ ` \u2713 A token is already saved: ${maskToken(current.githubToken)}`
1356
+ );
1357
+ }
1358
+ const ghToken = deps.readGhCliToken();
1359
+ if (ghToken) {
1360
+ console.log(" \u2713 Found a token managed by the GitHub CLI (gh).");
1361
+ const answer = await ask2(" Import it into ghfind? [Y/n]: ");
1362
+ if (answer.toLowerCase() !== "n") {
1363
+ persistToken(ghToken, deps);
1364
+ finishLogin(ghToken);
1365
+ return;
1366
+ }
1367
+ }
1368
+ const pasted = await ask2(" Paste a GitHub token (Enter to skip): ");
1369
+ if (!pasted) {
1370
+ console.log(" Skipped \u2014 no token saved.");
1371
+ console.log("");
1372
+ return;
1373
+ }
1374
+ if (!isLikelyGithubToken(pasted)) {
1375
+ console.log(
1376
+ ` \u26A0 That doesn't look like a GitHub token (expected one of: ${TOKEN_PREFIXES.join(", ")}).`
1377
+ );
1378
+ const confirm = await ask2(" Save it anyway? [y/N]: ");
1379
+ if (confirm.toLowerCase() !== "y") {
1380
+ console.log(" Cancelled \u2014 nothing saved.");
1381
+ console.log("");
1382
+ return;
1383
+ }
1384
+ }
1385
+ persistToken(pasted, deps);
1386
+ finishLogin(pasted);
1387
+ }
1388
+ function finishLogin(token) {
1389
+ console.log(` \u2713 Token saved to ${configPath()}`);
1390
+ console.log(` (masked: ${maskToken(token)})`);
1391
+ console.log("");
1392
+ for (const line of successNote()) console.log(line);
1393
+ console.log("");
1394
+ }
1395
+
1396
+ // src/doctor.ts
1198
1397
  var __dirname = dirname(fileURLToPath(import.meta.url));
1199
- var PKG_DIR = join(__dirname, "..");
1398
+ var PKG_DIR = join3(__dirname, "..");
1200
1399
  var results = [];
1201
1400
  function add(status, label, detail) {
1202
1401
  results.push({ status, label, detail });
@@ -1222,7 +1421,7 @@ function hasSystemBun() {
1222
1421
  return res.status === 0 ? { ok: true, version: res.stdout.trim() } : { ok: false };
1223
1422
  }
1224
1423
  function bundledBunPath() {
1225
- return join(PKG_DIR, "vendor", platform() === "win32" ? "bun.exe" : "bun");
1424
+ return join3(PKG_DIR, "vendor", platform() === "win32" ? "bun.exe" : "bun");
1226
1425
  }
1227
1426
  function bunRuns(bin) {
1228
1427
  const res = run(bin, ["--version"]);
@@ -1232,7 +1431,7 @@ function bunRuns(bin) {
1232
1431
  function detectLibc() {
1233
1432
  if (platform() !== "linux") return "unknown";
1234
1433
  try {
1235
- const osRelease = readFileSync("/etc/os-release", "utf8");
1434
+ const osRelease = readFileSync3("/etc/os-release", "utf8");
1236
1435
  if (/alpine|musl/i.test(osRelease)) return "musl";
1237
1436
  } catch {
1238
1437
  }
@@ -1253,7 +1452,7 @@ async function checkRuntime() {
1253
1452
  }
1254
1453
  function checkBundledBun() {
1255
1454
  const path = bundledBunPath();
1256
- if (!existsSync(path)) {
1455
+ if (!existsSync2(path)) {
1257
1456
  add(
1258
1457
  "WARN",
1259
1458
  "Bundled Bun",
@@ -1302,8 +1501,8 @@ function checkNodeVersion() {
1302
1501
  }
1303
1502
  }
1304
1503
  function checkBuildOutput() {
1305
- const dist = join(PKG_DIR, "dist", "cli.js");
1306
- if (existsSync(dist)) {
1504
+ const dist = join3(PKG_DIR, "dist", "cli.js");
1505
+ if (existsSync2(dist)) {
1307
1506
  add("PASS", "Build output", `${dist}`);
1308
1507
  } else {
1309
1508
  add(
@@ -1314,8 +1513,8 @@ function checkBuildOutput() {
1314
1513
  }
1315
1514
  }
1316
1515
  function checkSource() {
1317
- const src = join(PKG_DIR, "src", "cli.ts");
1318
- if (existsSync(src)) {
1516
+ const src = join3(PKG_DIR, "src", "cli.ts");
1517
+ if (existsSync2(src)) {
1319
1518
  add("PASS", "Source", `${src}`);
1320
1519
  } else {
1321
1520
  add("FAIL", "Source", "src/cli.ts not found. Reinstall the package.");
@@ -1331,7 +1530,7 @@ function checkTuiBackend() {
1331
1530
  }
1332
1531
  function checkConfig() {
1333
1532
  const path = configPath();
1334
- if (!existsSync(path)) {
1533
+ if (!existsSync2(path)) {
1335
1534
  add(
1336
1535
  "WARN",
1337
1536
  "Config",
@@ -1340,7 +1539,7 @@ function checkConfig() {
1340
1539
  return;
1341
1540
  }
1342
1541
  try {
1343
- const raw = readFileSync(path, "utf-8");
1542
+ const raw = readFileSync3(path, "utf-8");
1344
1543
  JSON.parse(raw);
1345
1544
  add("PASS", "Config", `${path} (valid JSON)`);
1346
1545
  } catch (err) {
@@ -1355,8 +1554,8 @@ function checkGithubToken() {
1355
1554
  const config = loadConfig();
1356
1555
  const token = config.githubToken || process.env.GITHUB_TOKEN;
1357
1556
  if (token) {
1358
- const preview = token.slice(0, 7);
1359
- add("PASS", "GitHub token", `Configured (${preview}\u2026).`);
1557
+ const preview = maskToken(token);
1558
+ add("PASS", "GitHub token", `Configured (${preview}).`);
1360
1559
  } else {
1361
1560
  add(
1362
1561
  "WARN",
@@ -1371,7 +1570,7 @@ function checkDirs() {
1371
1570
  ["Cache dir", cacheDir()]
1372
1571
  ]) {
1373
1572
  try {
1374
- mkdirSync(dir, { recursive: true });
1573
+ mkdirSync2(dir, { recursive: true });
1375
1574
  add("PASS", label, dir);
1376
1575
  } catch (err) {
1377
1576
  add(
@@ -1387,7 +1586,7 @@ var BIN_NAME = "ghfind";
1387
1586
  function runningVersion() {
1388
1587
  try {
1389
1588
  const pkg = JSON.parse(
1390
- readFileSync(join(PKG_DIR, "package.json"), "utf8")
1589
+ readFileSync3(join3(PKG_DIR, "package.json"), "utf8")
1391
1590
  );
1392
1591
  return pkg.version ?? "";
1393
1592
  } catch {
@@ -1397,11 +1596,11 @@ function runningVersion() {
1397
1596
  function installVersionAt(shimPath) {
1398
1597
  const dir = dirname(shimPath);
1399
1598
  for (const ancestor of [dir, dirname(dir)]) {
1400
- for (const sub of ["node_modules", join("lib", "node_modules")]) {
1401
- const pkgJson = join(ancestor, sub, PKG_NAME, "package.json");
1402
- if (existsSync(pkgJson)) {
1599
+ for (const sub of ["node_modules", join3("lib", "node_modules")]) {
1600
+ const pkgJson = join3(ancestor, sub, PKG_NAME, "package.json");
1601
+ if (existsSync2(pkgJson)) {
1403
1602
  try {
1404
- const pkg = JSON.parse(readFileSync(pkgJson, "utf8"));
1603
+ const pkg = JSON.parse(readFileSync3(pkgJson, "utf8"));
1405
1604
  return { version: pkg.version ?? "", dir: dirname(pkgJson) };
1406
1605
  } catch {
1407
1606
  }
@@ -1481,7 +1680,7 @@ function printReport() {
1481
1680
  return `${code}${s}\x1B[0m`;
1482
1681
  };
1483
1682
  console.log("\n ghfind doctor \u2014 environment diagnostics");
1484
- console.log(` ${platform()} ${arch} \xB7 ${homedir()}`);
1683
+ console.log(` ${platform()} ${arch} \xB7 ${homedir3()}`);
1485
1684
  console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
1486
1685
  for (const r of results) {
1487
1686
  console.log(
@@ -1531,20 +1730,20 @@ import {
1531
1730
  import { spawn as spawn2 } from "child_process";
1532
1731
 
1533
1732
  // src/version.ts
1534
- import { readFileSync as readFileSync2 } from "fs";
1535
- import { join as join2, dirname as dirname2 } from "path";
1733
+ import { readFileSync as readFileSync4 } from "fs";
1734
+ import { join as join4, dirname as dirname2 } from "path";
1536
1735
  import { fileURLToPath as fileURLToPath2 } from "url";
1537
1736
  var cached = null;
1538
1737
  function getVersion() {
1539
1738
  if (cached) return cached;
1540
1739
  if (true) {
1541
- cached = "9.8.1";
1740
+ cached = "9.8.3";
1542
1741
  return cached;
1543
1742
  }
1544
1743
  try {
1545
1744
  const here = dirname2(fileURLToPath2(import.meta.url));
1546
1745
  const pkg = JSON.parse(
1547
- readFileSync2(join2(here, "..", "package.json"), "utf-8")
1746
+ readFileSync4(join4(here, "..", "package.json"), "utf-8")
1548
1747
  );
1549
1748
  cached = pkg.version ?? "unknown";
1550
1749
  } catch {
@@ -1556,6 +1755,45 @@ function getVersion() {
1556
1755
  // src/error-report.ts
1557
1756
  var REPO = "codersdfs/search-cli";
1558
1757
  var NEW_ISSUE_URL = `https://github.com/${REPO}/issues/new`;
1758
+ var SECRET_PLACEHOLDER = "***";
1759
+ var SECRET_FLAGS = /^(?:--token|-t)$/;
1760
+ var SECRET_PATTERNS = [
1761
+ /gh[pousr]_[A-Za-z0-9]{16,}/g,
1762
+ /github_pat_[A-Za-z0-9_]{20,}/g
1763
+ ];
1764
+ function redactSecrets(text) {
1765
+ let out = text;
1766
+ for (const pattern of SECRET_PATTERNS) {
1767
+ out = out.replace(pattern, SECRET_PLACEHOLDER);
1768
+ }
1769
+ return out;
1770
+ }
1771
+ function redactArgv(argv) {
1772
+ const out = [];
1773
+ let redactNext = false;
1774
+ for (const arg of argv) {
1775
+ if (redactNext) {
1776
+ out.push(SECRET_PLACEHOLDER);
1777
+ redactNext = false;
1778
+ continue;
1779
+ }
1780
+ if (SECRET_FLAGS.test(arg)) {
1781
+ out.push(arg);
1782
+ redactNext = true;
1783
+ continue;
1784
+ }
1785
+ const eq = arg.indexOf("=");
1786
+ if (eq > 0 && SECRET_FLAGS.test(arg.slice(0, eq))) {
1787
+ out.push(`${arg.slice(0, eq)}=${SECRET_PLACEHOLDER}`);
1788
+ continue;
1789
+ }
1790
+ out.push(redactSecrets(arg));
1791
+ }
1792
+ return out;
1793
+ }
1794
+ function argvHasSecret(argv) {
1795
+ return redactArgv(argv).some((arg, i) => arg !== argv[i]);
1796
+ }
1559
1797
  function getPackageVersion() {
1560
1798
  return getVersion();
1561
1799
  }
@@ -1565,9 +1803,12 @@ function runtimeName() {
1565
1803
  }
1566
1804
  function formatErrorBody(err, context) {
1567
1805
  const e = err instanceof Error ? err : new Error(String(err));
1568
- const stack = (e.stack ?? "(no stack)").replace(/\u001b\[[0-9;]*m/g, "");
1569
- const message = (e.message || "(no message)").slice(0, 500);
1570
- const cmd = `${context.command} ${context.argv.join(" ")}`.trim();
1806
+ const stack = redactSecrets(
1807
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: strips ANSI color escapes from stack traces
1808
+ (e.stack ?? "(no stack)").replace(/\u001b\[[0-9;]*m/g, "")
1809
+ );
1810
+ const message = redactSecrets(e.message || "(no message)").slice(0, 500);
1811
+ const cmd = `${context.command} ${redactArgv(context.argv).join(" ")}`.trim();
1571
1812
  const ghfindVersion = getPackageVersion();
1572
1813
  return [
1573
1814
  "## What happened",
@@ -1601,7 +1842,7 @@ function formatErrorBody(err, context) {
1601
1842
  }
1602
1843
  function buildIssueUrl(err, context) {
1603
1844
  const e = err instanceof Error ? err : new Error(String(err));
1604
- const title = e.message.slice(0, 80).replace(/[\r\n]+/g, " ");
1845
+ const title = redactSecrets(e.message).slice(0, 80).replace(/[\r\n]+/g, " ");
1605
1846
  const body = formatErrorBody(err, context);
1606
1847
  const params = new URLSearchParams({
1607
1848
  title: `[bug] ${title || "Unhandled error"}`,
@@ -1633,15 +1874,25 @@ function reportError(err, context = {
1633
1874
  command: "ghfind",
1634
1875
  argv: process.argv.slice(2)
1635
1876
  }) {
1636
- const url = buildIssueUrl(err, context);
1877
+ const hadSecret = argvHasSecret(context.argv);
1878
+ const url = buildIssueUrl(err, {
1879
+ command: context.command,
1880
+ argv: redactArgv(context.argv)
1881
+ });
1637
1882
  console.error("");
1638
1883
  console.error(" \u26A0 ghfind encountered an unexpected error.");
1639
1884
  console.error("");
1640
- console.error(" Help us fix it by opening a bug report with the");
1641
- console.error(" details pre-filled. Your browser will open the new-issue");
1642
- console.error(" page; review and submit (or close the tab to skip).");
1885
+ if (hadSecret) {
1886
+ console.error(" A token was found in the command line and has been");
1887
+ console.error(" redacted. Your browser will NOT be opened \u2014 review the");
1888
+ console.error(" scrubbed URL below before sharing it anywhere.");
1889
+ } else {
1890
+ console.error(" Help us fix it by opening a bug report with the");
1891
+ console.error(" details pre-filled. Your browser will open the new-issue");
1892
+ console.error(" page; review and submit (or close the tab to skip).");
1893
+ }
1643
1894
  console.error("");
1644
- const opened = tryOpenUrl(url);
1895
+ const opened = hadSecret ? false : tryOpenUrl(url);
1645
1896
  if (opened) {
1646
1897
  console.error(` \u2192 Opened: ${url}`);
1647
1898
  } else {
@@ -1654,7 +1905,7 @@ function reportError(err, context = {
1654
1905
  }
1655
1906
 
1656
1907
  // src/output.ts
1657
- import { writeFileSync } from "fs";
1908
+ import { writeFileSync as writeFileSync2 } from "fs";
1658
1909
  var FORMATS = /* @__PURE__ */ new Map([
1659
1910
  // Export formats
1660
1911
  ["json", formatJson],
@@ -1722,7 +1973,7 @@ function exportToFile(repos, fmt) {
1722
1973
  const ext = fmt === "markdown" ? "md" : fmt;
1723
1974
  const ts = Date.now();
1724
1975
  const path = `./ghfind-export-${ts}.${ext}`;
1725
- writeFileSync(path, format(repos, fmt), "utf-8");
1976
+ writeFileSync2(path, format(repos, fmt), "utf-8");
1726
1977
  return path;
1727
1978
  }
1728
1979
  async function pipeExec(repos, target) {
@@ -1880,17 +2131,18 @@ async function fetchOrgProfile(org, options = {}) {
1880
2131
  if (orgPayload.public_repos > 0) {
1881
2132
  const perPage = Math.min(limit, 100);
1882
2133
  const maxPages = Math.ceil(limit / perPage);
1883
- for (let page = 1; page <= maxPages; page++) {
1884
- const url = `https://api.github.com/orgs/${encodeURIComponent(name)}/repos?per_page=${perPage}&sort=pushed&page=${page}`;
1885
- const res = await fetch(url, { headers: apiHeaders(token) });
1886
- if (!res.ok) {
1887
- break;
1888
- }
1889
- const items = await res.json();
1890
- if (!Array.isArray(items)) break;
1891
- repos.push(...items);
1892
- if (repos.length >= limit || items.length < perPage) break;
1893
- }
2134
+ const pages = await Promise.all(
2135
+ Array.from({ length: maxPages }, (_, i) => i + 1).map(async (page) => {
2136
+ const url = `https://api.github.com/orgs/${encodeURIComponent(name)}/repos?per_page=${perPage}&sort=pushed&page=${page}`;
2137
+ const res = await fetch(url, { headers: apiHeaders(token) });
2138
+ if (!res.ok) {
2139
+ return [];
2140
+ }
2141
+ const items = await res.json();
2142
+ return Array.isArray(items) ? items : [];
2143
+ })
2144
+ );
2145
+ repos.push(...pages.flat());
1894
2146
  }
1895
2147
  return normalizeOrg(orgPayload, repos.slice(0, limit));
1896
2148
  }
@@ -2094,17 +2346,18 @@ async function fetchUserProfile(user, options = {}) {
2094
2346
  if (userPayload.public_repos > 0) {
2095
2347
  const perPage = Math.min(limit, 100);
2096
2348
  const maxPages = Math.ceil(limit / perPage);
2097
- for (let page = 1; page <= maxPages; page++) {
2098
- const url = `https://api.github.com/users/${encodeURIComponent(name)}/repos?per_page=${perPage}&sort=pushed&page=${page}`;
2099
- const res = await fetch(url, { headers: apiHeaders2(token) });
2100
- if (!res.ok) {
2101
- break;
2102
- }
2103
- const items = await res.json();
2104
- if (!Array.isArray(items)) break;
2105
- repos.push(...items);
2106
- if (repos.length >= limit || items.length < perPage) break;
2107
- }
2349
+ const pages = await Promise.all(
2350
+ Array.from({ length: maxPages }, (_, i) => i + 1).map(async (page) => {
2351
+ const url = `https://api.github.com/users/${encodeURIComponent(name)}/repos?per_page=${perPage}&sort=pushed&page=${page}`;
2352
+ const res = await fetch(url, { headers: apiHeaders2(token) });
2353
+ if (!res.ok) {
2354
+ return [];
2355
+ }
2356
+ const items = await res.json();
2357
+ return Array.isArray(items) ? items : [];
2358
+ })
2359
+ );
2360
+ repos.push(...pages.flat());
2108
2361
  }
2109
2362
  return normalizeUser(userPayload, repos.slice(0, limit));
2110
2363
  }
@@ -2220,34 +2473,48 @@ function formatUserMarkdown(p) {
2220
2473
 
2221
2474
  // src/storage.ts
2222
2475
  import {
2223
- existsSync as existsSync2,
2224
- mkdirSync as mkdirSync2,
2225
- readFileSync as readFileSync3,
2226
- writeFileSync as writeFileSync2,
2476
+ mkdirSync as mkdirSync3,
2477
+ readFileSync as readFileSync5,
2478
+ writeFileSync as writeFileSync3,
2227
2479
  appendFileSync,
2228
- unlinkSync
2480
+ unlinkSync,
2481
+ renameSync
2229
2482
  } from "fs";
2230
- import { join as join3 } from "path";
2483
+ import { join as join5 } from "path";
2231
2484
  function ensureDir() {
2232
- mkdirSync2(stateDir(), { recursive: true });
2485
+ mkdirSync3(stateDir(), { recursive: true });
2233
2486
  }
2234
2487
  function readJSON(filename, fallback) {
2235
2488
  ensureDir();
2236
2489
  try {
2237
- return JSON.parse(readFileSync3(join3(stateDir(), filename), "utf-8"));
2490
+ return JSON.parse(readFileSync5(join5(stateDir(), filename), "utf-8"));
2238
2491
  } catch {
2239
2492
  return fallback;
2240
2493
  }
2241
2494
  }
2242
- function writeJSON(filename, data) {
2495
+ function writeFileAtomic(filename, data) {
2243
2496
  ensureDir();
2244
- writeFileSync2(join3(stateDir(), filename), JSON.stringify(data, null, 2));
2497
+ const target = join5(stateDir(), filename);
2498
+ const tmp = `${target}.tmp-${process.pid}`;
2499
+ try {
2500
+ writeFileSync3(tmp, data, "utf-8");
2501
+ renameSync(tmp, target);
2502
+ } catch (e) {
2503
+ try {
2504
+ unlinkSync(tmp);
2505
+ } catch {
2506
+ }
2507
+ throw e;
2508
+ }
2509
+ }
2510
+ function writeJSON(filename, data) {
2511
+ writeFileAtomic(filename, JSON.stringify(data, null, 2));
2245
2512
  }
2246
2513
  function appendJSONL(filename, obj) {
2247
2514
  ensureDir();
2248
2515
  try {
2249
2516
  appendFileSync(
2250
- join3(stateDir(), filename),
2517
+ join5(stateDir(), filename),
2251
2518
  `${JSON.stringify(obj)}
2252
2519
  `,
2253
2520
  "utf-8"
@@ -2258,7 +2525,7 @@ function appendJSONL(filename, obj) {
2258
2525
  function readJSONL(filename) {
2259
2526
  ensureDir();
2260
2527
  try {
2261
- const raw = readFileSync3(join3(stateDir(), filename), "utf-8").trim();
2528
+ const raw = readFileSync5(join5(stateDir(), filename), "utf-8").trim();
2262
2529
  if (!raw) return [];
2263
2530
  return raw.split("\n").filter(Boolean).map((l) => JSON.parse(l));
2264
2531
  } catch {
@@ -2266,14 +2533,13 @@ function readJSONL(filename) {
2266
2533
  }
2267
2534
  }
2268
2535
  function writeRaw(filename, data) {
2269
- ensureDir();
2270
- writeFileSync2(join3(stateDir(), filename), data);
2536
+ writeFileAtomic(filename, data);
2271
2537
  }
2272
2538
  function debugLog(msg) {
2273
2539
  if (!process.env.DEBUG) return;
2274
2540
  try {
2275
2541
  appendFileSync(
2276
- join3(stateDir(), "ghfind-debug.log"),
2542
+ join5(stateDir(), "ghfind-debug.log"),
2277
2543
  `[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}
2278
2544
  `,
2279
2545
  "utf-8"
@@ -2330,9 +2596,12 @@ function markSeen(fullName) {
2330
2596
  // src/notifications.ts
2331
2597
  var nextId = 1;
2332
2598
  var notifs = [];
2599
+ var loaded = false;
2333
2600
  var MAX_NOTIFS = 50;
2334
2601
  var FILE = "notifications.json";
2335
- function load() {
2602
+ function ensureLoaded() {
2603
+ if (loaded) return;
2604
+ loaded = true;
2336
2605
  notifs = readJSON(FILE, []);
2337
2606
  nextId = Math.max(...notifs.map((n) => n.id), 0) + 1;
2338
2607
  }
@@ -2340,7 +2609,7 @@ function save() {
2340
2609
  writeJSON(FILE, notifs);
2341
2610
  }
2342
2611
  function addNotification(type, message) {
2343
- if (notifs.length === 0) load();
2612
+ ensureLoaded();
2344
2613
  const n = {
2345
2614
  id: nextId++,
2346
2615
  type,
@@ -2370,14 +2639,16 @@ function iconFor(type) {
2370
2639
  }
2371
2640
  }
2372
2641
  function getNotifications() {
2373
- if (notifs.length === 0) load();
2642
+ ensureLoaded();
2374
2643
  return [...notifs];
2375
2644
  }
2376
2645
  function dismissNotification(id) {
2646
+ ensureLoaded();
2377
2647
  notifs = notifs.filter((n) => n.id !== id);
2378
2648
  save();
2379
2649
  }
2380
2650
  function dismissAll() {
2651
+ ensureLoaded();
2381
2652
  notifs = [];
2382
2653
  save();
2383
2654
  }
@@ -2385,6 +2656,7 @@ function dismissAll() {
2385
2656
  // src/releases.ts
2386
2657
  var RELEASES_FILE = "release-cache.json";
2387
2658
  var PER_REPO = 5;
2659
+ var CONCURRENCY = 6;
2388
2660
  function loadCache() {
2389
2661
  return readJSON(RELEASES_FILE, {});
2390
2662
  }
@@ -2424,43 +2696,31 @@ async function checkReleases(opts = {}) {
2424
2696
  const config = loadConfig();
2425
2697
  const token = opts.token ?? config.githubToken ?? process.env.GITHUB_TOKEN;
2426
2698
  const results2 = [];
2427
- for (const bm of bookmarks) {
2699
+ const checkOne = async (fullName) => {
2428
2700
  try {
2429
- const prev = cache2[bm.repo.fullName];
2430
- const lastSeen = getLastSeen(bm.repo.fullName);
2701
+ const prev = cache2[fullName];
2702
+ const lastSeen = getLastSeen(fullName);
2431
2703
  const isNew = (r) => !r.publishedAt || new Date(r.publishedAt).getTime() > lastSeen;
2432
2704
  const { status, etag, releases } = await fetchReleases(
2433
- bm.repo.fullName,
2705
+ fullName,
2434
2706
  prev?.etag,
2435
2707
  token
2436
2708
  );
2437
2709
  if (status === 304 && prev) {
2438
- results2.push({
2439
- fullName: bm.repo.fullName,
2440
- newReleases: prev.releases.filter(isNew)
2441
- });
2442
- continue;
2710
+ return { fullName, newReleases: prev.releases.filter(isNew) };
2443
2711
  }
2444
2712
  if (status !== 200 || !releases) {
2445
- results2.push({
2446
- fullName: bm.repo.fullName,
2447
- newReleases: [],
2448
- error: `HTTP ${status}`
2449
- });
2450
- continue;
2713
+ return { fullName, newReleases: [], error: `HTTP ${status}` };
2451
2714
  }
2452
- cache2[bm.repo.fullName] = { checkedAt: Date.now(), etag, releases };
2453
- results2.push({
2454
- fullName: bm.repo.fullName,
2455
- newReleases: releases.filter(isNew)
2456
- });
2715
+ cache2[fullName] = { checkedAt: Date.now(), etag, releases };
2716
+ return { fullName, newReleases: releases.filter(isNew) };
2457
2717
  } catch (err) {
2458
- results2.push({
2459
- fullName: bm.repo.fullName,
2460
- newReleases: [],
2461
- error: err.message
2462
- });
2718
+ return { fullName, newReleases: [], error: err.message };
2463
2719
  }
2720
+ };
2721
+ for (let i = 0; i < bookmarks.length; i += CONCURRENCY) {
2722
+ const batch = bookmarks.slice(i, i + CONCURRENCY).map((bm) => checkOne(bm.repo.fullName));
2723
+ results2.push(...await Promise.all(batch));
2464
2724
  }
2465
2725
  saveCache(cache2);
2466
2726
  return results2;
@@ -2480,144 +2740,17 @@ async function checkAndNotify(opts = {}) {
2480
2740
  }
2481
2741
  function allCachedReleases() {
2482
2742
  const cache2 = loadCache();
2483
- const bookmarks = getBookmarks();
2484
- return Object.entries(cache2).filter(([fullName]) => bookmarks.some((b) => b.repo.fullName === fullName)).flatMap(([fullName, s]) => s.releases.map((r) => ({ ...r, fullName }))).sort(
2743
+ const bookmarked = new Set(getBookmarks().map((b) => b.repo.fullName));
2744
+ return Object.entries(cache2).filter(([fullName]) => bookmarked.has(fullName)).flatMap(([fullName, s]) => s.releases.map((r) => ({ ...r, fullName }))).sort(
2485
2745
  (a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()
2486
2746
  );
2487
2747
  }
2488
2748
 
2489
2749
  // src/init.ts
2490
2750
  import { createInterface as createInterface2 } from "readline";
2491
-
2492
- // src/login.ts
2493
- import { createInterface } from "readline";
2494
- import { homedir as homedir2 } from "os";
2495
- import { join as join4 } from "path";
2496
- import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
2497
- var TOKEN_PREFIXES = ["ghp_", "gho_", "github_pat_"];
2498
- function isLikelyGithubToken(token) {
2499
- return TOKEN_PREFIXES.some((prefix) => token.startsWith(prefix));
2500
- }
2501
- function maskToken(token) {
2502
- if (token.length <= 11) return `${token.slice(0, 4)}\u2026`;
2503
- return `${token.slice(0, 7)}\u2026${token.slice(-4)}`;
2504
- }
2505
- function ghCliConfigDir() {
2506
- return process.env.GH_CONFIG_DIR || join4(homedir2(), ".config", "gh");
2507
- }
2508
- function parseHostsYmlToken(text, host = "github.com") {
2509
- let inHost = false;
2510
- for (const line of text.split(/\r?\n/)) {
2511
- const trimmed = line.trim();
2512
- if (/^[A-Za-z0-9.-]+:\s*$/.test(trimmed)) {
2513
- inHost = trimmed.slice(0, -1).trim() === host;
2514
- continue;
2515
- }
2516
- if (inHost) {
2517
- const match = trimmed.match(/^oauth_token:\s*["']?([^"'\s]+)/);
2518
- if (match) return match[1];
2519
- }
2520
- }
2521
- return void 0;
2522
- }
2523
- function readGhCliToken(ghDir = ghCliConfigDir(), host = process.env.GH_HOST ?? "github.com") {
2524
- const hostsFile = join4(ghDir, "hosts.yml");
2525
- if (!existsSync3(hostsFile)) return void 0;
2526
- try {
2527
- return parseHostsYmlToken(readFileSync4(hostsFile, "utf-8"), host);
2528
- } catch {
2529
- return void 0;
2530
- }
2531
- }
2532
- function persistToken(token, deps) {
2533
- const config = deps.readConfig();
2534
- config.githubToken = token;
2535
- deps.writeConfig(config);
2536
- }
2537
- function createAsk() {
2538
- const rl = createInterface({ input: process.stdin, output: process.stdout });
2539
- return (query) => new Promise((resolve) => {
2540
- rl.question(query, (answer) => {
2541
- rl.close();
2542
- resolve(answer.trim());
2543
- });
2544
- });
2545
- }
2546
- function defaultLoginDeps() {
2547
- return {
2548
- ask: createAsk(),
2549
- readGhCliToken: () => readGhCliToken(),
2550
- readConfig: loadConfig,
2551
- writeConfig: (config) => saveConfig(config)
2552
- };
2553
- }
2554
- function successNote() {
2555
- return [
2556
- " Authentication raises the search rate limit from 60 to",
2557
- " 5,000 requests/hour. Remove the token anytime by editing",
2558
- ` ${configPath()} \u2014 delete the "githubToken" line and save.`
2559
- ];
2560
- }
2561
- async function runLoginWizard(deps = defaultLoginDeps()) {
2562
- const { ask: ask2 } = deps;
2563
- console.log("");
2564
- console.log(" ghfind login");
2565
- console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
2566
- console.log("");
2567
- if (process.env.GITHUB_TOKEN) {
2568
- console.log(
2569
- " \u2139 GITHUB_TOKEN is set in the environment and already takes precedence."
2570
- );
2571
- console.log("");
2572
- }
2573
- const current = deps.readConfig();
2574
- if (current.githubToken) {
2575
- console.log(
2576
- ` \u2713 A token is already saved: ${maskToken(current.githubToken)}`
2577
- );
2578
- }
2579
- const ghToken = deps.readGhCliToken();
2580
- if (ghToken) {
2581
- console.log(" \u2713 Found a token managed by the GitHub CLI (gh).");
2582
- const answer = await ask2(" Import it into ghfind? [Y/n]: ");
2583
- if (answer.toLowerCase() !== "n") {
2584
- persistToken(ghToken, deps);
2585
- finishLogin(ghToken);
2586
- return;
2587
- }
2588
- }
2589
- const pasted = await ask2(" Paste a GitHub token (Enter to skip): ");
2590
- if (!pasted) {
2591
- console.log(" Skipped \u2014 no token saved.");
2592
- console.log("");
2593
- return;
2594
- }
2595
- if (!isLikelyGithubToken(pasted)) {
2596
- console.log(
2597
- ` \u26A0 That doesn't look like a GitHub token (expected one of: ${TOKEN_PREFIXES.join(", ")}).`
2598
- );
2599
- const confirm = await ask2(" Save it anyway? [y/N]: ");
2600
- if (confirm.toLowerCase() !== "y") {
2601
- console.log(" Cancelled \u2014 nothing saved.");
2602
- console.log("");
2603
- return;
2604
- }
2605
- }
2606
- persistToken(pasted, deps);
2607
- finishLogin(pasted);
2608
- }
2609
- function finishLogin(token) {
2610
- console.log(` \u2713 Token saved to ${configPath()}`);
2611
- console.log(` (masked: ${maskToken(token)})`);
2612
- console.log("");
2613
- for (const line of successNote()) console.log(line);
2614
- console.log("");
2615
- }
2616
-
2617
- // src/init.ts
2618
- function ask(query) {
2619
- const rl = createInterface2({ input: process.stdin, output: process.stdout });
2620
- return new Promise((resolve) => {
2751
+ function ask(query) {
2752
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
2753
+ return new Promise((resolve) => {
2621
2754
  rl.question(query, (answer) => {
2622
2755
  rl.close();
2623
2756
  resolve(answer.trim());
@@ -2650,8 +2783,7 @@ async function runInitWizard() {
2650
2783
  };
2651
2784
  if (token) config.githubToken = token;
2652
2785
  saveConfig(config);
2653
- const { configPath: configPath2 } = await import("./config-OCIPF2IF.js");
2654
- console.log(` \u2713 Config saved to ${configPath2()}`);
2786
+ console.log(` \u2713 Config saved to ${configPath()}`);
2655
2787
  console.log(" Run 'ghfind' to start browsing!");
2656
2788
  console.log("");
2657
2789
  }
@@ -2755,6 +2887,336 @@ function comparisonMarkdown(repos) {
2755
2887
  return [header, sep, ...rows].join("\n");
2756
2888
  }
2757
2889
 
2890
+ // src/markdown-images.ts
2891
+ var MIN_IMAGE_PIXELS = 100;
2892
+ var IMG_TAG = /<img\b[^>]*?src\s*=\s*["']([^"']+)["'][^>]*>/gi;
2893
+ var ALT_ATTR = /\balt\s*=\s*["']([^"']*)["']/i;
2894
+ var LENGTH_ATTR = /\b(width|height)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
2895
+ var PIXEL_VALUE = /^(\d+)(?:px)?$/i;
2896
+ var HTML_TAG = /<\/?[a-z][^>]*>/gi;
2897
+ function declaredImageSize(tag) {
2898
+ const values = {};
2899
+ for (const [, name, dq, sq] of tag.matchAll(LENGTH_ATTR)) {
2900
+ const raw = dq ?? sq ?? "";
2901
+ const match = PIXEL_VALUE.exec(raw.trim());
2902
+ if (!match) continue;
2903
+ const n = Number.parseInt(match[1], 10);
2904
+ if (Number.isFinite(n) && n > 0) {
2905
+ values[name] = n;
2906
+ }
2907
+ }
2908
+ if (values.width === void 0 && values.height === void 0)
2909
+ return void 0;
2910
+ return {
2911
+ width: values.width ?? values.height ?? 0,
2912
+ height: values.height ?? values.width ?? 0
2913
+ };
2914
+ }
2915
+ function declaresTooSmall(size) {
2916
+ if (!size) return false;
2917
+ return size.width < MIN_IMAGE_PIXELS || size.height < MIN_IMAGE_PIXELS;
2918
+ }
2919
+ function imagesFromHtml(raw) {
2920
+ if (!raw || !/<img\b/i.test(raw)) return [];
2921
+ const withoutTags = raw.replace(IMG_TAG, "").replace(/<\/?(?:p|div|center|br|a|picture|source)\b[^>]*>/gi, "").trim();
2922
+ if (withoutTags) return [];
2923
+ const refs = [];
2924
+ for (const match of raw.matchAll(IMG_TAG)) {
2925
+ const tag = match[0];
2926
+ const alt = ALT_ATTR.exec(tag)?.[1] ?? "";
2927
+ refs.push({ href: match[1], alt, declared: declaredImageSize(tag) });
2928
+ }
2929
+ return refs;
2930
+ }
2931
+ function inlinePlainText(tokens) {
2932
+ if (!tokens) return "";
2933
+ let out = "";
2934
+ for (const token of tokens) {
2935
+ if (token.type === "image") continue;
2936
+ if (token.type === "html") {
2937
+ out += (token.raw ?? "").replace(HTML_TAG, "");
2938
+ continue;
2939
+ }
2940
+ if (token.type === "br") {
2941
+ out += "\n";
2942
+ continue;
2943
+ }
2944
+ if (token.text && (!token.tokens || token.tokens.length === 0)) {
2945
+ out += token.text;
2946
+ continue;
2947
+ }
2948
+ if (token.tokens?.length) {
2949
+ out += inlinePlainText(token.tokens);
2950
+ continue;
2951
+ }
2952
+ if (token.raw) out += token.raw.replace(HTML_TAG, "");
2953
+ }
2954
+ return out;
2955
+ }
2956
+ function extractImageRefs(token) {
2957
+ const images = [];
2958
+ let linkUrl;
2959
+ const collect = (inline2) => {
2960
+ if (inline2.type === "image") {
2961
+ if (inline2.href)
2962
+ images.push({ href: inline2.href, alt: inline2.text ?? "", linkUrl });
2963
+ return true;
2964
+ }
2965
+ if (inline2.type === "link" || inline2.type === "linkReference") {
2966
+ if (inline2.tokens?.length) {
2967
+ const outer = linkUrl;
2968
+ linkUrl = inline2.href || outer;
2969
+ const ok = inline2.tokens.every(collect);
2970
+ linkUrl = outer;
2971
+ return ok;
2972
+ }
2973
+ return false;
2974
+ }
2975
+ if (inline2.type === "html") {
2976
+ const refs = imagesFromHtml(inline2.raw);
2977
+ if (refs.length > 0) {
2978
+ images.push(...refs);
2979
+ return true;
2980
+ }
2981
+ return !(inline2.raw ?? "").replace(HTML_TAG, "").trim();
2982
+ }
2983
+ if (inline2.type === "text" || inline2.type === "escape") {
2984
+ return !(inline2.text ?? "").trim();
2985
+ }
2986
+ return false;
2987
+ };
2988
+ if (token.type === "image" && token.href) {
2989
+ return [{ href: token.href, alt: token.text ?? "" }];
2990
+ }
2991
+ if (token.type === "html") {
2992
+ const refs = imagesFromHtml(token.raw);
2993
+ return refs.length > 0 ? dropTooSmall(refs) : null;
2994
+ }
2995
+ const inline = token.tokens ?? [];
2996
+ if (inline.length === 0) return null;
2997
+ for (const child of inline) {
2998
+ if (!collect(child)) return null;
2999
+ }
3000
+ return images.length > 0 ? dropTooSmall(images) : null;
3001
+ }
3002
+ function dropTooSmall(refs) {
3003
+ return refs.filter((image) => !declaresTooSmall(image.declared));
3004
+ }
3005
+
3006
+ // src/image-loader.ts
3007
+ var MAX_IMAGE_BYTES = 6 * 1024 * 1024;
3008
+ var BADGE_HOST = /(^|\.)(shields\.io|badgen\.net|badge\.fury\.io|codecov\.io|coveralls\.io|travis-ci\.(org|com)|snyk\.io|sonarcloud\.io|david-dm\.(org|io))$/i;
3009
+ var RAW_HOSTS = /* @__PURE__ */ new Set([
3010
+ "raw.githubusercontent.com",
3011
+ "camo.githubusercontent.com",
3012
+ "user-images.githubusercontent.com",
3013
+ "private-user-images.githubusercontent.com",
3014
+ "avatars.githubusercontent.com",
3015
+ "objects.githubusercontent.com"
3016
+ ]);
3017
+ var RASTER_EXT = /\.(png|jpe?g|webp|gif|bmp|avif|heic|tiff?)(?:[?#]|$)/i;
3018
+ var CACHE_LIMIT = 24;
3019
+ var MAX_CONCURRENT_IMAGE_FETCHES = 4;
3020
+ var cache = /* @__PURE__ */ new Map();
3021
+ var activeFetches = 0;
3022
+ var waiting = [];
3023
+ function releaseSlot() {
3024
+ activeFetches--;
3025
+ waiting.shift()?.();
3026
+ }
3027
+ async function withSlot(job) {
3028
+ if (activeFetches >= MAX_CONCURRENT_IMAGE_FETCHES) {
3029
+ await new Promise((resolve) => waiting.push(resolve));
3030
+ }
3031
+ activeFetches++;
3032
+ try {
3033
+ return await job();
3034
+ } finally {
3035
+ releaseSlot();
3036
+ }
3037
+ }
3038
+ function decodeDataUri(uri) {
3039
+ const match = /^data:([^;,]+)?(;base64)?,(.*)$/is.exec(uri);
3040
+ if (!match) return null;
3041
+ const mime = (match[1] ?? "").toLowerCase();
3042
+ if (mime.includes("svg")) return null;
3043
+ const payload = match[3];
3044
+ try {
3045
+ if (match[2]) return new Uint8Array(Buffer.from(payload, "base64"));
3046
+ return new Uint8Array(Buffer.from(decodeURIComponent(payload), "binary"));
3047
+ } catch {
3048
+ return null;
3049
+ }
3050
+ }
3051
+ function toRawGitHubUrl(url) {
3052
+ if (url.hostname.toLowerCase() !== "github.com") return url;
3053
+ const match = /^\/([^/]+)\/([^/]+)\/(?:blob|raw)\/(.+)$/.exec(url.pathname);
3054
+ if (!match) return url;
3055
+ const [, owner, repo, rest] = match;
3056
+ return new URL(
3057
+ `https://raw.githubusercontent.com/${owner}/${repo}/${rest}${url.search}`
3058
+ );
3059
+ }
3060
+ function resolveRootRelative(path, base) {
3061
+ let parsed;
3062
+ try {
3063
+ parsed = new URL(base);
3064
+ } catch {
3065
+ return null;
3066
+ }
3067
+ if (parsed.hostname.toLowerCase() !== "raw.githubusercontent.com") {
3068
+ return null;
3069
+ }
3070
+ const segments = parsed.pathname.split("/").filter(Boolean);
3071
+ if (segments.length < 3) return null;
3072
+ return new URL(`${parsed.origin}/${segments.slice(0, 3).join("/")}${path}`);
3073
+ }
3074
+ function resolveReadmeImageUrl(raw, base) {
3075
+ const cleaned = raw.trim();
3076
+ if (!cleaned) return null;
3077
+ if (/^data:/i.test(cleaned)) {
3078
+ return isRenderableImageUrl(cleaned) ? cleaned : null;
3079
+ }
3080
+ let url = null;
3081
+ try {
3082
+ url = cleaned.startsWith("/") ? resolveRootRelative(cleaned, base) : new URL(cleaned, base);
3083
+ } catch {
3084
+ return null;
3085
+ }
3086
+ if (!url) return null;
3087
+ url = toRawGitHubUrl(url);
3088
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
3089
+ url.hash = "";
3090
+ const resolved = url.toString();
3091
+ return isRenderableImageUrl(resolved) ? resolved : null;
3092
+ }
3093
+ function isRenderableImageUrl(url) {
3094
+ if (/^data:/i.test(url)) return !/svg/i.test(url.slice(0, 64));
3095
+ let parsed;
3096
+ try {
3097
+ parsed = new URL(url);
3098
+ } catch {
3099
+ return false;
3100
+ }
3101
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
3102
+ if (BADGE_HOST.test(parsed.hostname)) return false;
3103
+ const path = parsed.pathname.toLowerCase();
3104
+ if (path.endsWith(".svg") || path.endsWith(".svgz")) return false;
3105
+ if (parsed.hostname.toLowerCase().endsWith("github.com")) {
3106
+ if (/^\/user-attachments\//.test(path)) return true;
3107
+ if (!RAW_HOSTS.has(parsed.hostname)) {
3108
+ return /\.(png|jpe?g|webp|gif|bmp|avif|heic|tiff?)$/i.test(path);
3109
+ }
3110
+ }
3111
+ if (/\.[a-z0-9]{1,5}$/.test(path) && !RASTER_EXT.test(path)) return false;
3112
+ return true;
3113
+ }
3114
+ function isGitHubHost(hostname) {
3115
+ return /(^|\.)githubusercontent\.com$|(^|\.)github\.com$/i.test(hostname);
3116
+ }
3117
+ async function fetchImageBytes(url, opts = {}) {
3118
+ const cached2 = cache.get(url);
3119
+ if (cached2) return cached2;
3120
+ const pending = withSlot(() => loadImageBytes(url, opts));
3121
+ cache.set(url, pending);
3122
+ while (cache.size > CACHE_LIMIT) {
3123
+ const oldest = cache.keys().next().value;
3124
+ if (oldest === void 0) break;
3125
+ cache.delete(oldest);
3126
+ }
3127
+ return pending;
3128
+ }
3129
+ async function loadImageBytes(url, opts) {
3130
+ if (/^data:/i.test(url)) return decodeDataUri(url);
3131
+ const fetchImpl = opts.fetchImpl ?? fetch;
3132
+ const timeoutMs = opts.timeoutMs ?? 1e4;
3133
+ const controller = new AbortController();
3134
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
3135
+ try {
3136
+ const headers = {
3137
+ "User-Agent": "ghfind/1.0",
3138
+ Accept: "image/*,*/*;q=0.5"
3139
+ };
3140
+ let host = "";
3141
+ try {
3142
+ host = new URL(url).hostname;
3143
+ } catch {
3144
+ return null;
3145
+ }
3146
+ if (opts.token && isGitHubHost(host)) {
3147
+ headers.Authorization = `Bearer ${opts.token}`;
3148
+ }
3149
+ const res = await fetchImpl(url, { headers, signal: controller.signal });
3150
+ if (!res.ok) return null;
3151
+ const contentType = res.headers.get("content-type") ?? "";
3152
+ if (contentType && !/^image\//i.test(contentType)) {
3153
+ const githubHost = isGitHubHost(host);
3154
+ if (!githubHost || !/octet-stream/i.test(contentType)) return null;
3155
+ }
3156
+ if (/svg/i.test(contentType)) return null;
3157
+ const declared = Number(res.headers.get("content-length") ?? "0");
3158
+ if (declared > MAX_IMAGE_BYTES) return null;
3159
+ const buffer = new Uint8Array(await res.arrayBuffer());
3160
+ if (buffer.byteLength === 0 || buffer.byteLength > MAX_IMAGE_BYTES) {
3161
+ return null;
3162
+ }
3163
+ return buffer;
3164
+ } catch {
3165
+ return null;
3166
+ } finally {
3167
+ clearTimeout(timer);
3168
+ }
3169
+ }
3170
+ function readmeBaseUrl(owner, repo, branch, path = "README.md") {
3171
+ return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`;
3172
+ }
3173
+ async function imageSizeFromBytes(bytes) {
3174
+ const ImageCtor = globalThis.Bun?.Image;
3175
+ if (!ImageCtor) return void 0;
3176
+ try {
3177
+ const meta = await new ImageCtor(bytes).metadata();
3178
+ if (!meta.width || !meta.height) return void 0;
3179
+ return { width: meta.width, height: meta.height };
3180
+ } catch {
3181
+ return void 0;
3182
+ }
3183
+ }
3184
+ async function shouldRenderImage(bytes, declared) {
3185
+ if (declared) return !declaresTooSmall(declared);
3186
+ const measured = await imageSizeFromBytes(bytes);
3187
+ if (!measured) return true;
3188
+ return !declaresTooSmall(measured);
3189
+ }
3190
+
3191
+ // src/readme.ts
3192
+ var CANDIDATES = [
3193
+ ["main", "README.md"],
3194
+ ["master", "README.md"],
3195
+ ["main", "README.rst"]
3196
+ ];
3197
+ function buildReadmeHeaders(url, token, isPrivate = false) {
3198
+ const headers = { "User-Agent": "ghfind/1.0" };
3199
+ if (token && isPrivate && new URL(url).origin === "https://api.github.com") {
3200
+ headers.Authorization = `Bearer ${token}`;
3201
+ }
3202
+ return headers;
3203
+ }
3204
+ async function fetchReadme(owner, repo, opts = {}) {
3205
+ const fetchImpl = opts.fetchImpl ?? fetch;
3206
+ const attempts = CANDIDATES.map(async ([branch, file]) => {
3207
+ const url = readmeBaseUrl(owner, repo, branch, file);
3208
+ const headers = buildReadmeHeaders(url, opts.token, opts.private);
3209
+ const res = await fetchImpl(url, { headers });
3210
+ if (!res.ok) throw new Error(`no readme at ${url}`);
3211
+ return { text: await res.text(), sourceUrl: url };
3212
+ });
3213
+ try {
3214
+ return await Promise.any(attempts);
3215
+ } catch {
3216
+ return null;
3217
+ }
3218
+ }
3219
+
2758
3220
  // src/deepdive.ts
2759
3221
  var USER_AGENT = "ghfind/1.0";
2760
3222
  function buildHeaders(token) {
@@ -2799,42 +3261,46 @@ function repoFromRef(fullName) {
2799
3261
  async function resolveRepoFromRef(fullName, token) {
2800
3262
  const ref = parseRepoRef(fullName);
2801
3263
  const canonical = `${ref.owner}/${ref.name}`;
3264
+ let res;
2802
3265
  try {
2803
- const res = await fetch(`https://api.github.com/repos/${canonical}`, {
3266
+ res = await fetch(`https://api.github.com/repos/${canonical}`, {
2804
3267
  headers: buildHeaders(token)
2805
3268
  });
2806
- if (res.status === 404) {
2807
- throw new Error(`Repo not found: ${canonical}`);
2808
- }
2809
- if (res.ok) {
2810
- const api = await res.json();
2811
- return {
2812
- id: 0,
2813
- fullName: canonical,
2814
- name: ref.name,
2815
- owner: ref.owner,
2816
- description: api.description,
2817
- url: api.html_url,
2818
- stars: api.stargazers_count ?? 0,
2819
- forks: api.forks_count ?? 0,
2820
- watchers: api.subscribers_count ?? 0,
2821
- language: api.language,
2822
- topics: api.topics ?? [],
2823
- archived: api.archived ?? false,
2824
- isFork: api.fork ?? false,
2825
- private: false,
2826
- createdAt: api.created_at ?? "",
2827
- updatedAt: api.updated_at ?? "",
2828
- pushedAt: api.pushed_at ?? "",
2829
- score: 0
2830
- };
2831
- }
2832
- } catch (err) {
2833
- if (err instanceof Error && err.message.startsWith("Repo not found")) {
2834
- throw err;
2835
- }
3269
+ } catch {
3270
+ return repoFromRef(canonical);
3271
+ }
3272
+ if (res.status === 404) {
3273
+ throw new Error(`Repo not found: ${canonical}`);
3274
+ }
3275
+ if (!res.ok) {
3276
+ throw new Error(`GitHub API error ${res.status} for ${canonical}`);
3277
+ }
3278
+ let api;
3279
+ try {
3280
+ api = await res.json();
3281
+ } catch {
3282
+ return repoFromRef(canonical);
2836
3283
  }
2837
- return repoFromRef(canonical);
3284
+ return {
3285
+ id: 0,
3286
+ fullName: canonical,
3287
+ name: ref.name,
3288
+ owner: ref.owner,
3289
+ description: api.description,
3290
+ url: api.html_url,
3291
+ stars: api.stargazers_count ?? 0,
3292
+ forks: api.forks_count ?? 0,
3293
+ watchers: api.subscribers_count ?? 0,
3294
+ language: api.language,
3295
+ topics: api.topics ?? [],
3296
+ archived: api.archived ?? false,
3297
+ isFork: api.fork ?? false,
3298
+ private: api.private ?? false,
3299
+ createdAt: api.created_at ?? "",
3300
+ updatedAt: api.updated_at ?? "",
3301
+ pushedAt: api.pushed_at ?? "",
3302
+ score: 0
3303
+ };
2838
3304
  }
2839
3305
  var README_MAX_CHARS = 5e3;
2840
3306
  async function fetchDeepDiveRaw(repo, token) {
@@ -2848,20 +3314,11 @@ async function fetchDeepDiveRaw(repo, token) {
2848
3314
  { headers }
2849
3315
  )
2850
3316
  ]);
2851
- let readmeText = "";
2852
- for (const path of [
2853
- `${repo.owner}/${repo.name}/main/README.md`,
2854
- `${repo.owner}/${repo.name}/master/README.md`,
2855
- `${repo.owner}/${repo.name}/main/README.rst`
2856
- ]) {
2857
- const r = await fetch(`https://raw.githubusercontent.com/${path}`, {
2858
- headers
2859
- });
2860
- if (r.ok) {
2861
- readmeText = await r.text();
2862
- break;
2863
- }
2864
- }
3317
+ const readme = await fetchReadme(repo.owner, repo.name, {
3318
+ token: repo.private ? token : void 0,
3319
+ private: repo.private
3320
+ });
3321
+ const readmeText = readme?.text ?? "";
2865
3322
  const langEntries = languagesRes.ok ? Object.entries(
2866
3323
  await languagesRes.json().catch(() => ({}))
2867
3324
  ) : [];
@@ -2952,8 +3409,8 @@ function buildDeepDiveText(data) {
2952
3409
  }
2953
3410
 
2954
3411
  // src/cli.ts
2955
- import { readFileSync as readFileSync7 } from "fs";
2956
- import { join as join7, dirname as dirname4 } from "path";
3412
+ import { readFileSync as readFileSync8 } from "fs";
3413
+ import { join as join8, dirname as dirname4 } from "path";
2957
3414
  import { fileURLToPath as fileURLToPath4 } from "url";
2958
3415
 
2959
3416
  // src/tui.ts
@@ -3038,6 +3495,17 @@ function buildHelpSections() {
3038
3495
  { keys: "PageUp", action: "Scroll to top of results" }
3039
3496
  ]
3040
3497
  },
3498
+ {
3499
+ title: " Error Recovery ",
3500
+ titleColor: "red",
3501
+ rows: [
3502
+ { keys: "r", action: "Retry after an error" },
3503
+ { keys: "t", action: "Fix GitHub token (401/403)" },
3504
+ { keys: "u", action: "Search without a token (401)" },
3505
+ { keys: "c", action: "Change token (rate limit)" }
3506
+ ],
3507
+ note: "Active only while the matching error is in the status bar."
3508
+ },
3041
3509
  {
3042
3510
  title: " Trending Mode ",
3043
3511
  titleColor: "yellow",
@@ -3176,13 +3644,10 @@ function readHistory() {
3176
3644
  function deleteHistoryEntry(index) {
3177
3645
  const entries = readHistory();
3178
3646
  if (index < 0 || index >= entries.length) return;
3179
- const removed = entries[index];
3180
- const remaining = entries.filter(
3181
- (e) => e.timestamp !== removed.timestamp || e.query !== removed.query
3182
- );
3647
+ entries.splice(index, 1);
3183
3648
  writeRaw(
3184
3649
  HISTORY_FILE,
3185
- `${remaining.reverse().map((e) => JSON.stringify(e)).join("\n")}
3650
+ `${entries.reverse().map((e) => JSON.stringify(e)).join("\n")}
3186
3651
  `
3187
3652
  );
3188
3653
  }
@@ -3253,9 +3718,35 @@ function touchSavedSearch(name) {
3253
3718
 
3254
3719
  // src/session.ts
3255
3720
  var SESSION_FILE = "session.json";
3721
+ var MAX_QUERY_LENGTH = 200;
3722
+ function sanitizeQuery(query) {
3723
+ if (typeof query !== "string") return void 0;
3724
+ const trimmed = query.trim();
3725
+ if (trimmed.length === 0 || trimmed.length > MAX_QUERY_LENGTH) {
3726
+ return void 0;
3727
+ }
3728
+ if (trimmed.startsWith("{") && trimmed.endsWith("}") || trimmed.startsWith("[") && trimmed.endsWith("]")) {
3729
+ return void 0;
3730
+ }
3731
+ return trimmed;
3732
+ }
3733
+ function sanitizeSession(state) {
3734
+ if (!state || typeof state !== "object") return null;
3735
+ const mode = state.mode === "trending" || state.mode === "packages" || state.mode === "search" ? state.mode : "search";
3736
+ const trendingTab = typeof state.trendingTab === "string" && state.trendingTab.length > 0 ? state.trendingTab : "This Week";
3737
+ const limit = typeof state.limit === "number" && Number.isFinite(state.limit) ? state.limit : 50;
3738
+ const sort = typeof state.sort === "string" ? state.sort : "best-match";
3739
+ return {
3740
+ mode,
3741
+ query: sanitizeQuery(state.query) ?? "",
3742
+ sort,
3743
+ limit,
3744
+ trendingTab
3745
+ };
3746
+ }
3256
3747
  function saveSession(state) {
3257
3748
  try {
3258
- writeJSON(SESSION_FILE, state);
3749
+ writeJSON(SESSION_FILE, sanitizeSession(state));
3259
3750
  } catch (err) {
3260
3751
  if (process.env.DEBUG)
3261
3752
  console.error(
@@ -3264,7 +3755,7 @@ function saveSession(state) {
3264
3755
  }
3265
3756
  }
3266
3757
  function restoreSession() {
3267
- return readJSON(SESSION_FILE, null);
3758
+ return sanitizeSession(readJSON(SESSION_FILE, null));
3268
3759
  }
3269
3760
 
3270
3761
  // src/markdown-view.ts
@@ -3505,314 +3996,69 @@ function fitImageToCells(imgWidth, imgHeight, opts) {
3505
3996
  const rowsPerCol = cellAspect * (imgHeight / imgWidth);
3506
3997
  let cols = Math.max(minCols, Math.floor(opts.maxCols));
3507
3998
  let rows = Math.max(1, Math.round(cols * rowsPerCol));
3508
- if (rows > opts.maxRows) {
3509
- rows = Math.max(1, opts.maxRows);
3510
- cols = Math.max(
3511
- minCols,
3512
- Math.min(cols, Math.floor(rows / Math.max(rowsPerCol, 1e-6)))
3513
- );
3514
- }
3515
- return { cols, rows };
3516
- }
3517
- function pixelAt(img, x, y, background) {
3518
- const o = (y * img.width + x) * 4;
3519
- const a = img.data[o + 3] / 255;
3520
- if (a >= 1) return [img.data[o], img.data[o + 1], img.data[o + 2]];
3521
- const mix = (channel, bg) => Math.round(channel * a + bg * (1 - a));
3522
- return [
3523
- mix(img.data[o], background[0]),
3524
- mix(img.data[o + 1], background[1]),
3525
- mix(img.data[o + 2], background[2])
3526
- ];
3527
- }
3528
- function toHalfBlocks(img, opts = {}) {
3529
- const background = opts.background ?? [0, 0, 0];
3530
- const cells = [];
3531
- for (let row = 0; row * 2 < img.height; row++) {
3532
- const cellsRow = [];
3533
- const topY = row * 2;
3534
- const bottomY = topY + 1;
3535
- for (let x = 0; x < img.width; x++) {
3536
- const top = pixelAt(img, x, topY, background);
3537
- const bottom = bottomY < img.height ? pixelAt(img, x, bottomY, background) : top;
3538
- cellsRow.push({ top, bottom });
3539
- }
3540
- cells.push(cellsRow);
3541
- }
3542
- return { cols: img.width, rows: cells.length, cells };
3543
- }
3544
- function toImageRuns(image) {
3545
- return image.cells.map((cells) => {
3546
- const runs = [];
3547
- for (const cell of cells) {
3548
- const last = runs[runs.length - 1];
3549
- if (last && last.top[0] === cell.top[0] && last.top[1] === cell.top[1] && last.top[2] === cell.top[2] && last.bottom[0] === cell.bottom[0] && last.bottom[1] === cell.bottom[1] && last.bottom[2] === cell.bottom[2]) {
3550
- last.text += HALF_BLOCK;
3551
- continue;
3552
- }
3553
- runs.push({ text: HALF_BLOCK, top: cell.top, bottom: cell.bottom });
3554
- }
3555
- return runs;
3556
- });
3557
- }
3558
- var MAX_SOURCE_PIXELS = 2e7;
3559
- async function rasterizeImage(bytes, opts) {
3560
- const ImageCtor = globalThis.Bun?.Image;
3561
- if (!ImageCtor) return null;
3562
- const source = new ImageCtor(bytes, { maxPixels: MAX_SOURCE_PIXELS });
3563
- try {
3564
- const meta = await source.metadata();
3565
- const { cols, rows } = fitImageToCells(meta.width, meta.height, opts);
3566
- const png = await source.resize(cols, rows * 2, { fit: "fill" }).png({ compressionLevel: 1 }).bytes();
3567
- return toHalfBlocks(decodePng(png), { background: opts.background });
3568
- } catch {
3569
- return null;
3570
- }
3571
- }
3572
-
3573
- // src/image-loader.ts
3574
- var MAX_IMAGE_BYTES = 6 * 1024 * 1024;
3575
- var BADGE_HOST = /(^|\.)(shields\.io|badgen\.net|badge\.fury\.io|codecov\.io|coveralls\.io|travis-ci\.(org|com)|snyk\.io|sonarcloud\.io|david-dm\.(org|io))$/i;
3576
- var RAW_HOSTS = /* @__PURE__ */ new Set([
3577
- "raw.githubusercontent.com",
3578
- "camo.githubusercontent.com",
3579
- "user-images.githubusercontent.com",
3580
- "private-user-images.githubusercontent.com",
3581
- "avatars.githubusercontent.com",
3582
- "objects.githubusercontent.com"
3583
- ]);
3584
- var RASTER_EXT = /\.(png|jpe?g|webp|gif|bmp|avif|heic|tiff?)(?:[?#]|$)/i;
3585
- var CACHE_LIMIT = 24;
3586
- var MAX_CONCURRENT_IMAGE_FETCHES = 4;
3587
- var cache = /* @__PURE__ */ new Map();
3588
- var activeFetches = 0;
3589
- var waiting = [];
3590
- function releaseSlot() {
3591
- activeFetches--;
3592
- waiting.shift()?.();
3593
- }
3594
- async function withSlot(job) {
3595
- if (activeFetches >= MAX_CONCURRENT_IMAGE_FETCHES) {
3596
- await new Promise((resolve) => waiting.push(resolve));
3597
- }
3598
- activeFetches++;
3599
- try {
3600
- return await job();
3601
- } finally {
3602
- releaseSlot();
3603
- }
3604
- }
3605
- function decodeDataUri(uri) {
3606
- const match = /^data:([^;,]+)?(;base64)?,(.*)$/is.exec(uri);
3607
- if (!match) return null;
3608
- const mime = (match[1] ?? "").toLowerCase();
3609
- if (mime.includes("svg")) return null;
3610
- const payload = match[3];
3611
- try {
3612
- if (match[2]) return new Uint8Array(Buffer.from(payload, "base64"));
3613
- return new Uint8Array(Buffer.from(decodeURIComponent(payload), "binary"));
3614
- } catch {
3615
- return null;
3616
- }
3617
- }
3618
- function toRawGitHubUrl(url) {
3619
- if (url.hostname.toLowerCase() !== "github.com") return url;
3620
- const match = /^\/([^/]+)\/([^/]+)\/(?:blob|raw)\/(.+)$/.exec(url.pathname);
3621
- if (!match) return url;
3622
- const [, owner, repo, rest] = match;
3623
- return new URL(
3624
- `https://raw.githubusercontent.com/${owner}/${repo}/${rest}${url.search}`
3625
- );
3626
- }
3627
- function resolveRootRelative(path, base) {
3628
- let parsed;
3629
- try {
3630
- parsed = new URL(base);
3631
- } catch {
3632
- return null;
3633
- }
3634
- if (parsed.hostname.toLowerCase() !== "raw.githubusercontent.com") {
3635
- return null;
3636
- }
3637
- const segments = parsed.pathname.split("/").filter(Boolean);
3638
- if (segments.length < 3) return null;
3639
- return new URL(`${parsed.origin}/${segments.slice(0, 3).join("/")}${path}`);
3640
- }
3641
- function resolveReadmeImageUrl(raw, base) {
3642
- const cleaned = raw.trim();
3643
- if (!cleaned) return null;
3644
- if (/^data:/i.test(cleaned)) {
3645
- return isRenderableImageUrl(cleaned) ? cleaned : null;
3646
- }
3647
- let url = null;
3648
- try {
3649
- url = cleaned.startsWith("/") ? resolveRootRelative(cleaned, base) : new URL(cleaned, base);
3650
- } catch {
3651
- return null;
3652
- }
3653
- if (!url) return null;
3654
- url = toRawGitHubUrl(url);
3655
- if (url.protocol !== "http:" && url.protocol !== "https:") return null;
3656
- url.hash = "";
3657
- const resolved = url.toString();
3658
- return isRenderableImageUrl(resolved) ? resolved : null;
3659
- }
3660
- function isRenderableImageUrl(url) {
3661
- if (/^data:/i.test(url)) return !/svg/i.test(url.slice(0, 64));
3662
- let parsed;
3663
- try {
3664
- parsed = new URL(url);
3665
- } catch {
3666
- return false;
3667
- }
3668
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
3669
- if (BADGE_HOST.test(parsed.hostname)) return false;
3670
- const path = parsed.pathname.toLowerCase();
3671
- if (path.endsWith(".svg") || path.endsWith(".svgz")) return false;
3672
- if (parsed.hostname.toLowerCase().endsWith("github.com")) {
3673
- if (/^\/user-attachments\//.test(path)) return true;
3674
- if (!RAW_HOSTS.has(parsed.hostname)) {
3675
- return /\.(png|jpe?g|webp|gif|bmp|avif|heic|tiff?)$/i.test(path);
3676
- }
3677
- }
3678
- if (/\.[a-z0-9]{1,5}$/.test(path) && !RASTER_EXT.test(path)) return false;
3679
- return true;
3680
- }
3681
- function isGitHubHost(hostname) {
3682
- return /(^|\.)githubusercontent\.com$|(^|\.)github\.com$/i.test(hostname);
3683
- }
3684
- async function fetchImageBytes(url, opts = {}) {
3685
- const cached2 = cache.get(url);
3686
- if (cached2) return cached2;
3687
- const pending = withSlot(() => loadImageBytes(url, opts));
3688
- cache.set(url, pending);
3689
- while (cache.size > CACHE_LIMIT) {
3690
- const oldest = cache.keys().next().value;
3691
- if (oldest === void 0) break;
3692
- cache.delete(oldest);
3693
- }
3694
- return pending;
3695
- }
3696
- async function loadImageBytes(url, opts) {
3697
- if (/^data:/i.test(url)) return decodeDataUri(url);
3698
- const fetchImpl = opts.fetchImpl ?? fetch;
3699
- const timeoutMs = opts.timeoutMs ?? 1e4;
3700
- const controller = new AbortController();
3701
- const timer = setTimeout(() => controller.abort(), timeoutMs);
3702
- try {
3703
- const headers = {
3704
- "User-Agent": "ghfind/1.0",
3705
- Accept: "image/*,*/*;q=0.5"
3706
- };
3707
- let host = "";
3708
- try {
3709
- host = new URL(url).hostname;
3710
- } catch {
3711
- return null;
3712
- }
3713
- if (opts.token && isGitHubHost(host)) {
3714
- headers.Authorization = `Bearer ${opts.token}`;
3715
- }
3716
- const res = await fetchImpl(url, { headers, signal: controller.signal });
3717
- if (!res.ok) return null;
3718
- const contentType = res.headers.get("content-type") ?? "";
3719
- if (contentType && !/^image\//i.test(contentType)) {
3720
- const githubHost = isGitHubHost(host);
3721
- if (!githubHost || !/octet-stream/i.test(contentType)) return null;
3722
- }
3723
- if (/svg/i.test(contentType)) return null;
3724
- const declared = Number(res.headers.get("content-length") ?? "0");
3725
- if (declared > MAX_IMAGE_BYTES) return null;
3726
- const buffer = new Uint8Array(await res.arrayBuffer());
3727
- if (buffer.byteLength === 0 || buffer.byteLength > MAX_IMAGE_BYTES) {
3728
- return null;
3729
- }
3730
- return buffer;
3731
- } catch {
3732
- return null;
3733
- } finally {
3734
- clearTimeout(timer);
3999
+ if (rows > opts.maxRows) {
4000
+ rows = Math.max(1, opts.maxRows);
4001
+ cols = Math.max(
4002
+ minCols,
4003
+ Math.min(cols, Math.floor(rows / Math.max(rowsPerCol, 1e-6)))
4004
+ );
3735
4005
  }
4006
+ return { cols, rows };
3736
4007
  }
3737
- function readmeBaseUrl(owner, repo, branch, path = "README.md") {
3738
- return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`;
3739
- }
3740
-
3741
- // src/markdown-images.ts
3742
- var IMG_TAG = /<img\b[^>]*?src\s*=\s*["']([^"']+)["'][^>]*>/gi;
3743
- var ALT_ATTR = /\balt\s*=\s*["']([^"']*)["']/i;
3744
- var HTML_TAG = /<\/?[a-z][^>]*>/gi;
3745
- function imagesFromHtml(raw) {
3746
- if (!raw || !/<img\b/i.test(raw)) return [];
3747
- const withoutTags = raw.replace(IMG_TAG, "").replace(/<\/?(?:p|div|center|br|a|picture|source)\b[^>]*>/gi, "").trim();
3748
- if (withoutTags) return [];
3749
- const refs = [];
3750
- for (const match of raw.matchAll(IMG_TAG)) {
3751
- const tag = match[0];
3752
- const alt = ALT_ATTR.exec(tag)?.[1] ?? "";
3753
- refs.push({ href: match[1], alt });
3754
- }
3755
- return refs;
4008
+ function pixelAt(img, x, y, background) {
4009
+ const o = (y * img.width + x) * 4;
4010
+ const a = img.data[o + 3] / 255;
4011
+ if (a >= 1) return [img.data[o], img.data[o + 1], img.data[o + 2]];
4012
+ const mix = (channel, bg) => Math.round(channel * a + bg * (1 - a));
4013
+ return [
4014
+ mix(img.data[o], background[0]),
4015
+ mix(img.data[o + 1], background[1]),
4016
+ mix(img.data[o + 2], background[2])
4017
+ ];
3756
4018
  }
3757
- function inlinePlainText(tokens) {
3758
- if (!tokens) return "";
3759
- let out = "";
3760
- for (const token of tokens) {
3761
- if (token.type === "image") continue;
3762
- if (token.type === "html") {
3763
- out += (token.raw ?? "").replace(HTML_TAG, "");
3764
- continue;
3765
- }
3766
- if (token.type === "br") {
3767
- out += "\n";
3768
- continue;
3769
- }
3770
- if (token.text && (!token.tokens || token.tokens.length === 0)) {
3771
- out += token.text;
3772
- continue;
3773
- }
3774
- if (token.tokens?.length) {
3775
- out += inlinePlainText(token.tokens);
3776
- continue;
4019
+ function toHalfBlocks(img, opts = {}) {
4020
+ const background = opts.background ?? [0, 0, 0];
4021
+ const cells = [];
4022
+ for (let row = 0; row * 2 < img.height; row++) {
4023
+ const cellsRow = [];
4024
+ const topY = row * 2;
4025
+ const bottomY = topY + 1;
4026
+ for (let x = 0; x < img.width; x++) {
4027
+ const top = pixelAt(img, x, topY, background);
4028
+ const bottom = bottomY < img.height ? pixelAt(img, x, bottomY, background) : top;
4029
+ cellsRow.push({ top, bottom });
3777
4030
  }
3778
- if (token.raw) out += token.raw.replace(HTML_TAG, "");
4031
+ cells.push(cellsRow);
3779
4032
  }
3780
- return out;
4033
+ return { cols: img.width, rows: cells.length, cells };
3781
4034
  }
3782
- function extractImageRefs(token) {
3783
- const images = [];
3784
- const collect = (inline2) => {
3785
- if (inline2.type === "image") {
3786
- if (inline2.href)
3787
- images.push({ href: inline2.href, alt: inline2.text ?? "" });
3788
- return true;
3789
- }
3790
- if (inline2.type === "html") {
3791
- const refs = imagesFromHtml(inline2.raw);
3792
- if (refs.length > 0) {
3793
- images.push(...refs);
3794
- return true;
4035
+ function toImageRuns(image) {
4036
+ return image.cells.map((cells) => {
4037
+ const runs = [];
4038
+ for (const cell of cells) {
4039
+ const last = runs[runs.length - 1];
4040
+ if (last && last.top[0] === cell.top[0] && last.top[1] === cell.top[1] && last.top[2] === cell.top[2] && last.bottom[0] === cell.bottom[0] && last.bottom[1] === cell.bottom[1] && last.bottom[2] === cell.bottom[2]) {
4041
+ last.text += HALF_BLOCK;
4042
+ continue;
3795
4043
  }
3796
- return !(inline2.raw ?? "").replace(HTML_TAG, "").trim();
3797
- }
3798
- if (inline2.type === "text" || inline2.type === "escape") {
3799
- return !(inline2.text ?? "").trim();
4044
+ runs.push({ text: HALF_BLOCK, top: cell.top, bottom: cell.bottom });
3800
4045
  }
3801
- return false;
3802
- };
3803
- if (token.type === "image" && token.href) {
3804
- return [{ href: token.href, alt: token.text ?? "" }];
3805
- }
3806
- if (token.type === "html") {
3807
- const refs = imagesFromHtml(token.raw);
3808
- return refs.length > 0 ? refs : null;
3809
- }
3810
- const inline = token.tokens ?? [];
3811
- if (inline.length === 0) return null;
3812
- for (const child of inline) {
3813
- if (!collect(child)) return null;
4046
+ return runs;
4047
+ });
4048
+ }
4049
+ var MAX_SOURCE_PIXELS = 2e7;
4050
+ async function rasterizeImage(bytes, opts) {
4051
+ const ImageCtor = globalThis.Bun?.Image;
4052
+ if (!ImageCtor) return null;
4053
+ const source = new ImageCtor(bytes, { maxPixels: MAX_SOURCE_PIXELS });
4054
+ try {
4055
+ const meta = await source.metadata();
4056
+ const { cols, rows } = fitImageToCells(meta.width, meta.height, opts);
4057
+ const png = await source.resize(cols, rows * 2, { fit: "fill" }).png({ compressionLevel: 1 }).bytes();
4058
+ return toHalfBlocks(decodePng(png), { background: opts.background });
4059
+ } catch {
4060
+ return null;
3814
4061
  }
3815
- return images.length > 0 ? images : null;
3816
4062
  }
3817
4063
 
3818
4064
  // src/markdown-view.ts
@@ -3850,17 +4096,19 @@ function hexToRgb(hex) {
3850
4096
  function rgba(color) {
3851
4097
  return RGBA.fromValues(color[0], color[1], color[2], 255);
3852
4098
  }
3853
- function imageStyledText(image) {
4099
+ function imageStyledText(image, linkUrl) {
3854
4100
  const chunks = [];
3855
4101
  toImageRuns(image).forEach((runs, rowIndex) => {
3856
4102
  if (rowIndex > 0) chunks.push({ __isChunk: true, text: "\n" });
3857
4103
  for (const run2 of runs) {
3858
- chunks.push({
4104
+ const chunk = {
3859
4105
  __isChunk: true,
3860
4106
  text: run2.text,
3861
4107
  fg: rgba(run2.top),
3862
4108
  bg: rgba(run2.bottom)
3863
- });
4109
+ };
4110
+ if (linkUrl) chunk.link = { url: linkUrl };
4111
+ chunks.push(chunk);
3864
4112
  }
3865
4113
  });
3866
4114
  return new StyledText3(chunks);
@@ -3887,15 +4135,21 @@ function createMarkdownView(opts) {
3887
4135
  tableOptions: { style: "grid" },
3888
4136
  width: "100%"
3889
4137
  });
3890
- const caption = (label, color = muted()) => new TextRenderable3(renderer, {
3891
- content: ` ${label}`,
3892
- fg: color,
4138
+ const caption = (label, linkUrl, color = muted()) => new TextRenderable3(renderer, {
4139
+ content: new StyledText3([
4140
+ {
4141
+ __isChunk: true,
4142
+ text: ` ${label}`,
4143
+ fg: RGBA.fromHex(color),
4144
+ ...linkUrl ? { link: { url: linkUrl } } : {}
4145
+ }
4146
+ ]),
3893
4147
  bg: bg(),
3894
4148
  wrapMode: "word",
3895
4149
  flexShrink: 0,
3896
4150
  width: "100%"
3897
4151
  });
3898
- const rasterize = (url) => {
4152
+ const rasterize = (url, declared) => {
3899
4153
  const maxCols = Math.max(8, Math.floor(opts.getImageWidth?.() ?? 100));
3900
4154
  const key = `${maxCols}|${url}`;
3901
4155
  const cached2 = rasterCache.get(key);
@@ -3905,6 +4159,7 @@ function createMarkdownView(opts) {
3905
4159
  if (!loader) return null;
3906
4160
  const bytes = await loader(url);
3907
4161
  if (!bytes) return null;
4162
+ if (!await shouldRenderImage(bytes, declared)) return null;
3908
4163
  return rasterizeImage(bytes, {
3909
4164
  maxCols,
3910
4165
  maxRows: maxImageRows,
@@ -3922,24 +4177,25 @@ function createMarkdownView(opts) {
3922
4177
  const imageNode = (image) => {
3923
4178
  const label = `\u{1F5BC} ${image.alt.trim() || "image"}`;
3924
4179
  const url = imageBase ? resolveReadmeImageUrl(image.href, imageBase) : null;
3925
- if (!imagesEnabled || !url || !opts.loadImage) return caption(label);
4180
+ if (!imagesEnabled || !url || !opts.loadImage)
4181
+ return caption(label, image.linkUrl);
3926
4182
  const container = new BoxRenderable3(renderer, {
3927
4183
  flexDirection: "column",
3928
4184
  flexShrink: 0,
3929
4185
  width: "100%"
3930
4186
  });
3931
- const placeholder = caption(`${label} (loading\u2026)`);
4187
+ const placeholder = caption(`${label} (loading\u2026)`, image.linkUrl);
3932
4188
  container.add(placeholder);
3933
- void rasterize(url).then(
4189
+ void rasterize(url, image.declared).then(
3934
4190
  (cells) => {
3935
4191
  if (container.isDestroyed || !container.parent) return;
3936
4192
  container.remove(placeholder);
3937
4193
  placeholder.destroy();
3938
- container.add(caption(label));
4194
+ container.add(caption(label, image.linkUrl));
3939
4195
  if (cells) {
3940
4196
  container.add(
3941
4197
  new TextRenderable3(renderer, {
3942
- content: imageStyledText(cells),
4198
+ content: imageStyledText(cells, image.linkUrl),
3943
4199
  bg: bg(),
3944
4200
  wrapMode: "none",
3945
4201
  selectable: false,
@@ -3981,17 +4237,22 @@ function createMarkdownView(opts) {
3981
4237
  const buildRenderNode = () => (token, _context) => {
3982
4238
  if (token.type === "heading") return headingNode(token);
3983
4239
  const images = extractImageRefs(token);
3984
- if (images && images.length > 0) {
3985
- const box = new BoxRenderable3(renderer, {
3986
- flexDirection: "column",
4240
+ if (images === null) return void 0;
4241
+ if (images.length === 0) {
4242
+ return new BoxRenderable3(renderer, {
3987
4243
  flexShrink: 0,
3988
4244
  width: "100%",
3989
- marginTop: 1
4245
+ height: 0
3990
4246
  });
3991
- for (const image of images) box.add(imageNode(image));
3992
- return box;
3993
4247
  }
3994
- return void 0;
4248
+ const box = new BoxRenderable3(renderer, {
4249
+ flexDirection: "column",
4250
+ flexShrink: 0,
4251
+ width: "100%",
4252
+ marginTop: 1
4253
+ });
4254
+ for (const image of images) box.add(imageNode(image));
4255
+ return box;
3995
4256
  };
3996
4257
  renderable.renderNode = buildRenderNode();
3997
4258
  return {
@@ -4017,29 +4278,6 @@ function createMarkdownView(opts) {
4017
4278
  };
4018
4279
  }
4019
4280
 
4020
- // src/readme.ts
4021
- var CANDIDATES = [
4022
- ["main", "README.md"],
4023
- ["master", "README.md"],
4024
- ["main", "README.rst"]
4025
- ];
4026
- async function fetchReadme(owner, repo, opts = {}) {
4027
- const fetchImpl = opts.fetchImpl ?? fetch;
4028
- const headers = { "User-Agent": "ghfind/1.0" };
4029
- if (opts.token) headers.Authorization = `Bearer ${opts.token}`;
4030
- const attempts = CANDIDATES.map(async ([branch, file]) => {
4031
- const url = readmeBaseUrl(owner, repo, branch, file);
4032
- const res = await fetchImpl(url, { headers });
4033
- if (!res.ok) throw new Error(`no readme at ${url}`);
4034
- return { text: await res.text(), sourceUrl: url };
4035
- });
4036
- try {
4037
- return await Promise.any(attempts);
4038
- } catch {
4039
- return null;
4040
- }
4041
- }
4042
-
4043
4281
  // src/explore.ts
4044
4282
  async function fetchTopics() {
4045
4283
  const res = await fetch(
@@ -4189,23 +4427,23 @@ function nextTip() {
4189
4427
  }
4190
4428
 
4191
4429
  // src/update-check.ts
4192
- import { readFileSync as readFileSync5 } from "fs";
4193
- import { join as join5, dirname as dirname3, posix } from "path";
4430
+ import { readFileSync as readFileSync6 } from "fs";
4431
+ import { join as join6, dirname as dirname3, posix } from "path";
4194
4432
  import { fileURLToPath as fileURLToPath3 } from "url";
4195
4433
  var REGISTRY_URL = "https://registry.npmjs.org/github-search-cli/latest";
4196
4434
  var PKG_NAME2 = "github-search-cli";
4197
4435
  var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
4198
4436
  var REQUEST_TIMEOUT_MS = 5e3;
4199
4437
  var __dirname2 = dirname3(fileURLToPath3(import.meta.url));
4200
- var PKG_DIR2 = join5(__dirname2, "..");
4438
+ var PKG_DIR2 = join6(__dirname2, "..");
4201
4439
  var STATE_FILE = "update-state.json";
4202
- var DEFAULTS = {
4440
+ var DEFAULTS2 = {
4203
4441
  lastCheck: 0,
4204
4442
  snoozeUntil: 0,
4205
4443
  suppressed: false
4206
4444
  };
4207
4445
  function readUpdateState() {
4208
- return { ...DEFAULTS, ...readJSON(STATE_FILE, {}) };
4446
+ return { ...DEFAULTS2, ...readJSON(STATE_FILE, {}) };
4209
4447
  }
4210
4448
  function writeUpdateState(state) {
4211
4449
  writeJSON(STATE_FILE, state);
@@ -4338,14 +4576,14 @@ function recordPreUpdateState(currentVersion) {
4338
4576
  }
4339
4577
  function fetchReleaseNotes(version) {
4340
4578
  try {
4341
- const notesPath = join5(PKG_DIR2, "release-notes", `${version}.md`);
4342
- const md = readFileSync5(notesPath, "utf-8");
4579
+ const notesPath = join6(PKG_DIR2, "release-notes", `${version}.md`);
4580
+ const md = readFileSync6(notesPath, "utf-8");
4343
4581
  if (md.trim().length > 0) return md;
4344
4582
  } catch {
4345
4583
  }
4346
4584
  try {
4347
- const changelogPath = join5(PKG_DIR2, "CHANGELOG.md");
4348
- const changelog = readFileSync5(changelogPath, "utf-8");
4585
+ const changelogPath = join6(PKG_DIR2, "CHANGELOG.md");
4586
+ const changelog = readFileSync6(changelogPath, "utf-8");
4349
4587
  const header = `## [${version}]`;
4350
4588
  const start = changelog.indexOf(header);
4351
4589
  if (start === -1) return null;
@@ -4464,7 +4702,7 @@ async function launchBrowser(theme_override) {
4464
4702
  const root = renderer.root;
4465
4703
  root.flexDirection = "column";
4466
4704
  const config = loadConfig();
4467
- const githubToken = config.githubToken || process.env.GITHUB_TOKEN;
4705
+ let githubToken = config.githubToken || process.env.GITHUB_TOKEN;
4468
4706
  const theme = loadTheme(theme_override || config.theme);
4469
4707
  Object.assign(colors, theme);
4470
4708
  if (terminalBg) {
@@ -4647,6 +4885,10 @@ async function launchBrowser(theme_override) {
4647
4885
  currentMode = "search";
4648
4886
  showLanding(false);
4649
4887
  showSearchMode();
4888
+ if (currentQueryInput) {
4889
+ searchInput.value = currentQueryInput;
4890
+ renderer.requestRender();
4891
+ }
4650
4892
  }
4651
4893
  },
4652
4894
  {
@@ -5942,7 +6184,8 @@ async function launchBrowser(theme_override) {
5942
6184
  showOverlay("readme");
5943
6185
  try {
5944
6186
  const readme = await fetchReadme(repo.owner, repo.name, {
5945
- token: githubToken
6187
+ token: githubToken,
6188
+ private: repo.private
5946
6189
  });
5947
6190
  if (readme) {
5948
6191
  readmeView.setContent(readme.text, readme.sourceUrl);
@@ -5970,9 +6213,85 @@ async function launchBrowser(theme_override) {
5970
6213
  setStatus(nextTip());
5971
6214
  }
5972
6215
  }, 2e3);
6216
+ const tokenPromptBox = new BoxRenderable5(renderer, {
6217
+ visible: false,
6218
+ position: "absolute",
6219
+ top: 0,
6220
+ left: 0,
6221
+ width: "100%",
6222
+ flexDirection: "row",
6223
+ paddingY: 1,
6224
+ paddingLeft: 1,
6225
+ backgroundColor: colors.surface
6226
+ });
6227
+ const tokenPromptLabel = new TextRenderable5(renderer, {
6228
+ content: " GitHub token:",
6229
+ fg: colors.accent,
6230
+ height: 1
6231
+ });
6232
+ const tokenPromptInput = new InputRenderable(renderer, {
6233
+ placeholder: "paste token, Enter to save \u2014 Esc cancels",
6234
+ value: "",
6235
+ backgroundColor: colors.surface,
6236
+ textColor: colors.text,
6237
+ flexGrow: 1
6238
+ });
6239
+ tokenPromptBox.add(tokenPromptLabel);
6240
+ tokenPromptBox.add(tokenPromptInput);
6241
+ root.add(tokenPromptBox);
6242
+ let tokenPromptQuery = null;
6243
+ function showTokenPrompt(query) {
6244
+ tokenPromptQuery = query;
6245
+ tokenPromptInput.value = "";
6246
+ tokenPromptBox.visible = true;
6247
+ searchBox.visible = false;
6248
+ tokenPromptInput.focus();
6249
+ renderer.requestRender();
6250
+ }
6251
+ function hideTokenPrompt() {
6252
+ tokenPromptBox.visible = false;
6253
+ tokenPromptQuery = null;
6254
+ if (currentMode === "search" || currentMode === "packages") {
6255
+ searchBox.visible = true;
6256
+ searchInput.focus();
6257
+ }
6258
+ renderer.requestRender();
6259
+ }
6260
+ function persistToken2(token) {
6261
+ githubToken = token;
6262
+ config.githubToken = token;
6263
+ saveConfig(config);
6264
+ }
6265
+ tokenPromptInput.on("enter", () => {
6266
+ const token = tokenPromptInput.value.trim();
6267
+ const query = tokenPromptQuery;
6268
+ hideTokenPrompt();
6269
+ if (!token || !query) return;
6270
+ persistToken2(token);
6271
+ setStatus("Token saved");
6272
+ doSearch(query);
6273
+ });
6274
+ function fixTokenAction() {
6275
+ showTokenPrompt(currentQueryInput || "stars:>10000");
6276
+ }
6277
+ function unsetTokenAction() {
6278
+ githubToken = void 0;
6279
+ if (config.githubToken) {
6280
+ delete config.githubToken;
6281
+ saveConfig(config);
6282
+ }
6283
+ setStatus("Token cleared \u2014 searching without one");
6284
+ if (currentQueryInput) doSearch(currentQueryInput);
6285
+ }
5973
6286
  function setStatus(msg) {
5974
6287
  statusMgr.set("idle", msg);
5975
6288
  }
6289
+ let errorHint = null;
6290
+ function reportError2(err) {
6291
+ errorHint = err instanceof AuthError ? "auth" : err instanceof ForbiddenError ? "forbidden" : err instanceof RateLimitError ? "ratelimit" : err instanceof NetworkError ? "network" : null;
6292
+ const msg = err instanceof SearchCliError ? err.userMessage : err instanceof Error ? err.message : String(err);
6293
+ statusMgr.set("error", msg.slice(0, 60));
6294
+ }
5976
6295
  function buildSessionState() {
5977
6296
  const mode = currentMode === "trending" ? "trending" : currentMode === "packages" ? "packages" : "search";
5978
6297
  return {
@@ -6189,6 +6508,7 @@ async function launchBrowser(theme_override) {
6189
6508
  async function loadTrending() {
6190
6509
  currentMode = "trending";
6191
6510
  isLoading = true;
6511
+ errorHint = null;
6192
6512
  searchBox.visible = false;
6193
6513
  toolbarText.visible = false;
6194
6514
  trendingTabBox.visible = true;
@@ -6238,11 +6558,10 @@ async function launchBrowser(theme_override) {
6238
6558
  resultCount: fetched.length
6239
6559
  });
6240
6560
  } catch (err) {
6241
- const msg = err instanceof SearchCliError ? err.userMessage : err instanceof Error ? err.message : String(err);
6242
6561
  resultsSelect.options = [
6243
6562
  { name: " (error)", description: "", value: null }
6244
6563
  ];
6245
- statusMgr.set("error", msg.slice(0, 60));
6564
+ reportError2(err);
6246
6565
  }
6247
6566
  isLoading = false;
6248
6567
  renderer.requestRender();
@@ -6281,6 +6600,7 @@ async function launchBrowser(theme_override) {
6281
6600
  if (q === "" || isLoading) return;
6282
6601
  currentQueryInput = q;
6283
6602
  isLoading = true;
6603
+ errorHint = null;
6284
6604
  resultsSelect.options = [
6285
6605
  { name: " Searching packages...", description: "", value: null }
6286
6606
  ];
@@ -6318,6 +6638,7 @@ async function launchBrowser(theme_override) {
6318
6638
  async function doSearch(queryText, append = false) {
6319
6639
  const q = queryText.trim();
6320
6640
  if (q === "" || isLoading) return;
6641
+ errorHint = null;
6321
6642
  if (!append) currentPage = 1;
6322
6643
  deepDiveActive = false;
6323
6644
  isLoading = true;
@@ -6374,12 +6695,13 @@ async function launchBrowser(theme_override) {
6374
6695
  }
6375
6696
  }
6376
6697
  } catch (err) {
6377
- currentRepos = [];
6378
- resultsSelect.options = [
6379
- { name: " (error)", description: "", value: null }
6380
- ];
6381
- const msg = err instanceof SearchCliError ? err.userMessage : err instanceof Error ? err.message : String(err);
6382
- statusMgr.set("error", msg.slice(0, 60));
6698
+ if (!append) {
6699
+ currentRepos = [];
6700
+ resultsSelect.options = [
6701
+ { name: " (error)", description: "", value: null }
6702
+ ];
6703
+ }
6704
+ reportError2(err);
6383
6705
  }
6384
6706
  isLoading = false;
6385
6707
  renderer.requestRender();
@@ -6476,6 +6798,16 @@ ${pack.description ?? ""}`;
6476
6798
  }
6477
6799
  });
6478
6800
  renderer.keyInput.on("keypress", (key) => {
6801
+ if (tokenPromptBox.visible) {
6802
+ if (key.name === "escape") {
6803
+ hideTokenPrompt();
6804
+ setStatus("Token entry cancelled");
6805
+ renderer.requestRender();
6806
+ return;
6807
+ }
6808
+ if (key.name === "enter" || key.name === "return") return;
6809
+ return;
6810
+ }
6479
6811
  if (currentOverlay !== "none") {
6480
6812
  if (key.name === "escape") {
6481
6813
  if (currentOverlay === "leader") {
@@ -6779,6 +7111,27 @@ ${pack.description ?? ""}`;
6779
7111
  showLeaderMenu();
6780
7112
  return;
6781
7113
  }
7114
+ if (!searchInput.focused && errorHint) {
7115
+ if (key.name === "r") {
7116
+ errorHint = null;
7117
+ if (currentMode === "trending") loadTrending();
7118
+ else if (currentQueryInput) doSearch(currentQueryInput);
7119
+ renderer.requestRender();
7120
+ return;
7121
+ }
7122
+ if (key.name === "t" && (errorHint === "auth" || errorHint === "forbidden")) {
7123
+ fixTokenAction();
7124
+ return;
7125
+ }
7126
+ if (key.name === "u" && errorHint === "auth") {
7127
+ unsetTokenAction();
7128
+ return;
7129
+ }
7130
+ if (key.name === "c" && errorHint === "ratelimit") {
7131
+ fixTokenAction();
7132
+ return;
7133
+ }
7134
+ }
6782
7135
  if (key.name === "t" && !searchInput.focused) {
6783
7136
  const current = config.theme;
6784
7137
  const themes = listThemes();
@@ -6873,38 +7226,18 @@ ${pack.description ?? ""}`;
6873
7226
  }
6874
7227
  });
6875
7228
  const _currentVersion = getVersion();
6876
- if (!session?.mode) {
6877
- showLanding(true);
6878
- }
7229
+ showLanding(true);
6879
7230
  checkForUpdateAndShow();
6880
7231
  checkPostUpgradePanel();
6881
7232
  renderer.start();
6882
- if (session?.mode) {
6883
- if (currentMode === "trending") {
6884
- loadTrending();
6885
- } else {
6886
- searchInput.value = currentQueryInput;
6887
- if (currentQueryInput) {
6888
- doSearch(currentQueryInput);
6889
- } else {
6890
- showSearchMode();
6891
- }
6892
- searchInput.focus();
6893
- }
6894
- }
6895
7233
  renderer.requestRender();
6896
7234
  }
6897
7235
  function formatResultLine(repo) {
6898
- const stars = formatStars(repo.stars);
7236
+ const stars = `\u2605 ${fmtStars(repo.stars)}`;
6899
7237
  const lang = (repo.language ?? "?").padEnd(12).slice(0, 12);
6900
7238
  const name = repo.fullName.padEnd(40).slice(0, 40);
6901
7239
  return `${name} ${lang} ${stars}`;
6902
7240
  }
6903
- function formatStars(n) {
6904
- if (n >= 1e6) return `\u2605 ${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M`;
6905
- if (n >= 1e3) return `\u2605 ${(n / 1e3).toFixed(1).replace(/\.0$/, "")}k`;
6906
- return `\u2605 ${n}`;
6907
- }
6908
7241
  function formatToolbar(sort, limit, totalCount) {
6909
7242
  const sortLabel = SORT_MODES.find((m) => m.key === sort)?.label ?? sort;
6910
7243
  return ` sort: ${sortLabel} limit: ${limit} results: ${totalCount.toLocaleString()}`;
@@ -7034,14 +7367,15 @@ function formatSkillCatalog() {
7034
7367
  }
7035
7368
 
7036
7369
  // src/skill-finder.ts
7037
- import { readdirSync, readFileSync as readFileSync6 } from "fs";
7038
- import { homedir as homedir3 } from "os";
7039
- import { delimiter, join as join6 } from "path";
7370
+ import { readdirSync, readFileSync as readFileSync7 } from "fs";
7371
+ import { homedir as homedir4 } from "os";
7372
+ import { delimiter, join as join7 } from "path";
7040
7373
  var REGISTRY_SEARCH_ENDPOINT = "https://skills.sh/api/search";
7041
7374
  var MIN_QUERY_LENGTH = 2;
7042
7375
  var MAX_SKILL_RESULTS = 100;
7376
+ var SAFE_ID_RE = /^[A-Za-z0-9._/-]+$/;
7043
7377
  function displayPath(path) {
7044
- const home = homedir3();
7378
+ const home = homedir4();
7045
7379
  const normalized = path.replace(/\\/g, "/");
7046
7380
  const homeNorm = home.replace(/\\/g, "/").replace(/\/$/, "");
7047
7381
  if (normalized === homeNorm) return "~";
@@ -7051,12 +7385,12 @@ function displayPath(path) {
7051
7385
  return normalized;
7052
7386
  }
7053
7387
  function defaultSkillRoots(cwd = process.cwd()) {
7054
- const home = homedir3();
7388
+ const home = homedir4();
7055
7389
  return [
7056
- join6(home, ".codex", "skills"),
7057
- join6(home, ".agents", "skills"),
7058
- join6(cwd, ".codex", "skills"),
7059
- join6(cwd, ".github", "skills")
7390
+ join7(home, ".codex", "skills"),
7391
+ join7(home, ".agents", "skills"),
7392
+ join7(cwd, ".codex", "skills"),
7393
+ join7(cwd, ".github", "skills")
7060
7394
  ];
7061
7395
  }
7062
7396
  function resolveSkillRoots(cwd = process.cwd()) {
@@ -7098,16 +7432,20 @@ function parseSkillFrontmatter(text) {
7098
7432
  if (!name) return null;
7099
7433
  return { name, description: fields.description ?? "" };
7100
7434
  }
7435
+ var scanCache = /* @__PURE__ */ new Map();
7101
7436
  function scanLocalSkills(roots = resolveSkillRoots()) {
7437
+ const key = roots.map((r) => `${r.path}\0${r.label}`).join("");
7438
+ const cached2 = scanCache.get(key);
7439
+ if (cached2) return cached2;
7102
7440
  const byName = /* @__PURE__ */ new Map();
7103
7441
  for (const root of roots) {
7104
7442
  for (const dir of walkDirs(root.path)) {
7105
7443
  const entries = listDir(dir);
7106
7444
  if (!entries) continue;
7107
7445
  if (!entries.some((e) => e.isFile() && e.name === "SKILL.md")) continue;
7108
- const file = join6(dir, "SKILL.md");
7446
+ const file = join7(dir, "SKILL.md");
7109
7447
  try {
7110
- const parsed = parseSkillFrontmatter(readFileSync6(file, "utf-8"));
7448
+ const parsed = parseSkillFrontmatter(readFileSync7(file, "utf-8"));
7111
7449
  if (!parsed || byName.has(parsed.name)) continue;
7112
7450
  byName.set(parsed.name, {
7113
7451
  name: parsed.name,
@@ -7119,7 +7457,9 @@ function scanLocalSkills(roots = resolveSkillRoots()) {
7119
7457
  }
7120
7458
  }
7121
7459
  }
7122
- return [...byName.values()];
7460
+ const hits = [...byName.values()];
7461
+ scanCache.set(key, hits);
7462
+ return hits;
7123
7463
  }
7124
7464
  function listDir(dir) {
7125
7465
  try {
@@ -7138,7 +7478,7 @@ function* walkDirs(root) {
7138
7478
  if (depth >= MAX_DEPTH) continue;
7139
7479
  for (const entry of entries) {
7140
7480
  if (!entry.isDirectory() || SKIP_DIRS.has(entry.name)) continue;
7141
- queue.push([join6(dir, entry.name), depth + 1]);
7481
+ queue.push([join7(dir, entry.name), depth + 1]);
7142
7482
  }
7143
7483
  }
7144
7484
  }
@@ -7184,7 +7524,10 @@ function normalizeRegistrySkill(raw) {
7184
7524
  const obj = raw;
7185
7525
  const source = typeof obj.source === "string" ? obj.source : "";
7186
7526
  const skillId = typeof obj.skillId === "string" ? obj.skillId : "";
7187
- if (!source || !skillId) return null;
7527
+ const hasTraversal = (s) => s === ".." || s.includes("..");
7528
+ if (!SAFE_ID_RE.test(source) || !SAFE_ID_RE.test(skillId) || hasTraversal(source) || hasTraversal(skillId)) {
7529
+ return null;
7530
+ }
7188
7531
  const name = typeof obj.name === "string" && obj.name.trim() ? obj.name : skillId;
7189
7532
  const installs = typeof obj.installs === "number" ? obj.installs : 0;
7190
7533
  const id = typeof obj.id === "string" && obj.id ? obj.id : `${source}/${skillId}`;
@@ -7374,7 +7717,18 @@ var TOOLS = [
7374
7717
  verbose: false,
7375
7718
  token
7376
7719
  };
7377
- const response = await provider.search(parsed, options);
7720
+ let response;
7721
+ try {
7722
+ response = await provider.search(parsed, options);
7723
+ } catch (err) {
7724
+ if (err instanceof RateLimitError) {
7725
+ return {
7726
+ text: `GitHub API rate limit exceeded for "${query}". Set a token (GITHUB_TOKEN or the tool's token argument) to raise the limit from 60/hr to 5,000/hr.`,
7727
+ data: { totalCount: 0, repos: [], rateLimited: true }
7728
+ };
7729
+ }
7730
+ throw err;
7731
+ }
7378
7732
  const repos = rankRepos(response.repos, sort);
7379
7733
  try {
7380
7734
  appendHistory({
@@ -7580,19 +7934,14 @@ var TOOLS = [
7580
7934
  throw new McpToolError("repos must contain 2-5 repo fullNames.");
7581
7935
  }
7582
7936
  const token = resolveToken(state, args.token);
7583
- const provider = createGitHubSearch(void 0, token ? [token] : []);
7584
7937
  const found = [];
7585
7938
  const missing = [];
7586
7939
  for (const fullName of names) {
7587
- const res = await provider.search(parseQuery(fullName), {
7588
- limit: 1,
7589
- sort: "stars",
7590
- json: false,
7591
- verbose: false,
7592
- token
7593
- });
7594
- if (res.repos.length > 0) found.push(res.repos[0]);
7595
- else missing.push(fullName);
7940
+ try {
7941
+ found.push(await resolveRepoFromRef(fullName, token));
7942
+ } catch {
7943
+ missing.push(fullName);
7944
+ }
7596
7945
  }
7597
7946
  if (found.length < 2) {
7598
7947
  throw new McpToolError(
@@ -7895,7 +8244,16 @@ async function runMcpServer(options = {}) {
7895
8244
  // src/cli.ts
7896
8245
  process.env.GHFIND_CLI_RUN = "1";
7897
8246
  var __dirname3 = dirname4(fileURLToPath4(import.meta.url));
7898
- var PKG_DIR3 = join7(__dirname3, "..");
8247
+ var PKG_DIR3 = join8(__dirname3, "..");
8248
+ var SORT_VALUES = [
8249
+ "best-match",
8250
+ "stars",
8251
+ "updated",
8252
+ "forks",
8253
+ "score",
8254
+ "downloads",
8255
+ "name"
8256
+ ];
7899
8257
  function parseArgs(args) {
7900
8258
  const flags = {
7901
8259
  query: "",
@@ -7994,9 +8352,17 @@ function parseArgs(args) {
7994
8352
  }
7995
8353
  break;
7996
8354
  }
7997
- case "--sort":
7998
- flags.sort = args[++i];
8355
+ case "--sort": {
8356
+ const sort = args[++i];
8357
+ if (!sort || !SORT_VALUES.includes(sort)) {
8358
+ console.error(
8359
+ `Invalid sort: ${sort ?? "(missing)"} (use best-match|stars|updated|forks).`
8360
+ );
8361
+ process.exit(1);
8362
+ }
8363
+ flags.sort = sort;
7999
8364
  break;
8365
+ }
8000
8366
  case "--token":
8001
8367
  flags.token = args[++i];
8002
8368
  break;
@@ -8209,8 +8575,8 @@ Options:
8209
8575
  if (flags.completion) {
8210
8576
  const shell = flags.completion;
8211
8577
  try {
8212
- const content = readFileSync7(
8213
- join7(PKG_DIR3, "completions", `ghfind.${shell}`),
8578
+ const content = readFileSync8(
8579
+ join8(PKG_DIR3, "completions", `ghfind.${shell}`),
8214
8580
  "utf-8"
8215
8581
  );
8216
8582
  console.log(content);
@@ -8524,10 +8890,11 @@ function rowsToCsv(header, rows) {
8524
8890
  return [header, ...rows].map((row) => row.map(quote).join(",")).join("\n");
8525
8891
  }
8526
8892
  function rowsToMarkdown(header, rows) {
8893
+ const cell = (value) => value.replace(/\|/g, "\\|");
8527
8894
  return [
8528
- `| ${header.join(" | ")} |`,
8895
+ `| ${header.map(cell).join(" | ")} |`,
8529
8896
  `|${header.map(() => "---").join("|")}|`,
8530
- ...rows.map((row) => `| ${row.join(" | ")} |`)
8897
+ ...rows.map((row) => `| ${row.map(cell).join(" | ")} |`)
8531
8898
  ].join("\n");
8532
8899
  }
8533
8900
  function truncate2(text, width) {
@@ -8567,7 +8934,12 @@ async function runReadme(flags) {
8567
8934
  }
8568
8935
  try {
8569
8936
  const { owner, name } = repoFromRef(ref);
8570
- const readme = await fetchReadme(owner, name, { token: flags.token });
8937
+ const readme = await fetchReadme(owner, name, {
8938
+ // A bare ref carries no visibility info, so the token is never
8939
+ // forwarded to raw.githubusercontent.com.
8940
+ token: flags.token,
8941
+ private: false
8942
+ });
8571
8943
  if (!readme) {
8572
8944
  console.error(`No README found for ${owner}/${name}.`);
8573
8945
  process.exit(1);
@@ -8816,22 +9188,14 @@ async function runNonInteractive(flags, outputFormat) {
8816
9188
  }
8817
9189
  },
8818
9190
  compare: async (_ctx) => {
8819
- const search = createGitHubSearch(void 0, [flags.token ?? ""]);
8820
9191
  const repos = [];
8821
9192
  const missing = [];
8822
9193
  for (const fullName of flags.compare) {
8823
- const res = await search.search(
8824
- { keywords: [fullName], qualifiers: [], raw: fullName },
8825
- {
8826
- limit: 1,
8827
- sort: "stars",
8828
- json: false,
8829
- verbose: false,
8830
- token: flags.token
8831
- }
8832
- );
8833
- if (res.repos.length > 0) repos.push(...res.repos);
8834
- else missing.push(fullName);
9194
+ try {
9195
+ repos.push(await resolveRepoFromRef(fullName, flags.token));
9196
+ } catch {
9197
+ missing.push(fullName);
9198
+ }
8835
9199
  }
8836
9200
  if (repos.length < 2) {
8837
9201
  console.error(
@@ -8858,10 +9222,8 @@ Not found: ${missing.join(", ")}` : "";
8858
9222
  await handlers[key](ctx);
8859
9223
  }
8860
9224
  main().catch((err) => {
8861
- if (err instanceof Error && err.name === "SearchCliError") {
8862
- console.error(
8863
- err instanceof SearchCliError ? err.userMessage : err.message
8864
- );
9225
+ if (err instanceof SearchCliError) {
9226
+ console.error(err.userMessage);
8865
9227
  } else {
8866
9228
  console.error(err instanceof Error ? err.message : String(err));
8867
9229
  reportError(err);