github-search-cli 9.7.0 → 9.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,12 +5,13 @@
5
5
  * Persists check state in the XDG state dir via storage.ts.
6
6
  */
7
7
  import { readJSON, writeJSON, debugLog } from "./storage";
8
- import { stateDir } from "./config";
9
- import { readFileSync } from "fs";
10
- import { join, dirname } from "path";
11
- import { fileURLToPath } from "url";
8
+ import { readFileSync } from "node:fs";
9
+ import { join, dirname, posix } from "node:path";
10
+ import { fileURLToPath } from "node:url";
12
11
 
13
12
  const REGISTRY_URL = "https://registry.npmjs.org/github-search-cli/latest";
13
+ // The npm package name is `github-search-cli`; `ghfind` is the bin name.
14
+ const PKG_NAME = "github-search-cli";
14
15
  const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
15
16
  const REQUEST_TIMEOUT_MS = 5000;
16
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -147,39 +148,100 @@ export function suppressUpdateNotices(): void {
147
148
  }
148
149
 
149
150
  /**
150
- * Attempt to update ghfind in-place.
151
- * Uses `process.versions.bun` for reliable runtime detection (works on all platforms).
151
+ * Where the running install lives and which package manager owns it.
152
+ *
153
+ * Detection walks up from the package dir to its `node_modules` ancestor:
154
+ * - npm global install: `<prefix>/node_modules/<pkg>` (Windows) or
155
+ * `<prefix>/lib/node_modules/<pkg>` (Unix)
156
+ * - Bun global install: `<bun-home>/install/global/node_modules/<pkg>`
157
+ * - Anything else (compiled binary, `bunx`/`npx` cache edges, dev checkout
158
+ * without a `node_modules` ancestor): `null` — self-update is refused
159
+ * rather than npm-installing a copy the user isn't running.
160
+ */
161
+ export interface InstallLocation {
162
+ channel: "npm" | "bun";
163
+ /** npm prefix that owns the running install (npm channel only). */
164
+ prefix?: string;
165
+ }
166
+
167
+ /**
168
+ * Detect the install channel and owning npm prefix for a package directory.
169
+ * Separators are normalized so layouts match regardless of the host OS.
152
170
  */
153
- export async function performUpdate(): Promise<boolean> {
154
- // The npm package name is `github-search-cli`; `ghfind` is the bin name.
155
- const pkg = "github-search-cli";
156
- const isBun =
157
- typeof process.versions !== "undefined" && "bun" in process.versions;
158
- const cmd = isBun
159
- ? ["bun", "install", "-g", pkg]
160
- : ["npm", "install", "-g", pkg];
171
+ export function detectInstallLocation(pkgDir: string): InstallLocation | null {
172
+ const norm = pkgDir.split("\\").join("/");
173
+ const nodeModules = posix.dirname(norm);
174
+ if (posix.basename(nodeModules) !== "node_modules") return null;
175
+ const owner = posix.dirname(nodeModules);
176
+ // Bun's global root is ~/.bun by default (BUN_INSTALL override may rename
177
+ // it, but the default layout is what `bun install -g` produces for the
178
+ // overwhelming majority of installs).
179
+ if (/(^|\/)\.bun(\/|$)/.test(owner)) return { channel: "bun" };
180
+ // Unix npm global layout nests one level deeper: <prefix>/lib/node_modules
181
+ const prefix = posix.basename(owner) === "lib" ? posix.dirname(owner) : owner;
182
+ return { channel: "npm", prefix };
183
+ }
184
+
185
+ /**
186
+ * Build the install command for an update, targeting the npm prefix that
187
+ * owns the running install. Under Bun this must NOT be `bun install -g`:
188
+ * that writes to Bun's own global dir, leaving the running npm copy stale
189
+ * while still exiting 0.
190
+ */
191
+ export function buildUpdateCommand(loc: InstallLocation): string[] {
192
+ if (loc.channel === "bun") return ["bun", "install", "-g", PKG_NAME];
193
+ const args = ["npm", "install", "-g"];
194
+ if (loc.prefix) args.push("--prefix", loc.prefix);
195
+ args.push(PKG_NAME);
196
+ return args;
197
+ }
198
+
199
+ /** Minimal quoting for win32 spawns, which go through a shell. */
200
+ function quoteWin32(arg: string): string {
201
+ return /\s/.test(arg) ? `"${arg}"` : arg;
202
+ }
161
203
 
204
+ /**
205
+ * Attempt to update ghfind in-place.
206
+ *
207
+ * Runs the install command through `node:child_process` on every runtime
208
+ * (works under Bun and Node alike; on Windows npm is `npm.cmd`, which needs
209
+ * `shell: true` to resolve). Always returns a definitive boolean — the TUI
210
+ * surfaces false as "Update failed".
211
+ */
212
+ export async function performUpdate(
213
+ location: InstallLocation | null = detectInstallLocation(PKG_DIR),
214
+ ): Promise<boolean> {
215
+ if (!location) {
216
+ debugLog(
217
+ "Update skipped: the running install is not under a node_modules tree " +
218
+ "(compiled binary or dev checkout). Update it manually.",
219
+ );
220
+ return false;
221
+ }
222
+ const argv = buildUpdateCommand(location);
162
223
  try {
163
- if (isBun) {
164
- const proc = Bun.spawn(cmd, {
165
- stdout: "pipe",
166
- stderr: "pipe",
167
- });
168
- await proc.exited;
169
- if (proc.exitCode !== 0) {
170
- const err = await proc.stderr.text();
171
- debugLog(`Install failed (code ${proc.exitCode}): ${err}`);
172
- }
173
- return proc.exitCode === 0;
174
- }
175
- // Node fallback
176
- const { spawn } = await import("child_process");
177
- return await new Promise<boolean>((resolve) => {
178
- const proc = spawn(cmd[0], cmd.slice(1), { stdio: "ignore" });
179
- proc.on("close", (code) => resolve(code === 0));
180
- proc.on("error", () => resolve(false));
224
+ const { spawn } = await import("node:child_process");
225
+ const useShell = process.platform === "win32";
226
+ const proc = spawn(
227
+ argv[0],
228
+ useShell ? argv.slice(1).map(quoteWin32) : argv.slice(1),
229
+ { stdio: ["ignore", "ignore", "pipe"], shell: useShell },
230
+ );
231
+ let stderr = "";
232
+ proc.stderr?.on("data", (chunk: Buffer) => {
233
+ stderr += String(chunk);
181
234
  });
182
- } catch {
235
+ const code = await new Promise<number | null>((resolve) => {
236
+ proc.on("close", (c) => resolve(c));
237
+ proc.on("error", () => resolve(-1));
238
+ });
239
+ if (code !== 0) {
240
+ debugLog(`Install failed (code ${code}): ${stderr.trim()}`);
241
+ }
242
+ return code === 0;
243
+ } catch (e) {
244
+ debugLog(`Update failed: ${e instanceof Error ? e.message : String(e)}`);
183
245
  return false;
184
246
  }
185
247
  }
@@ -215,7 +277,7 @@ export function fetchReleaseNotes(version: string): string | null {
215
277
  const changelogPath = join(PKG_DIR, "CHANGELOG.md");
216
278
  const changelog = readFileSync(changelogPath, "utf-8");
217
279
  // ponytail: find "## [version]" header, slice until next "## "
218
- const header = "## [" + version + "]";
280
+ const header = `## [${version}]`;
219
281
  const start = changelog.indexOf(header);
220
282
  if (start === -1) return null;
221
283
  const bodyStart = start + header.length;
package/src/version.ts CHANGED
@@ -6,9 +6,9 @@
6
6
  * standalone single-file binaries where package.json is not present.
7
7
  * Fallback: read package.json relative to this module (source / dist trees).
8
8
  */
9
- import { readFileSync } from "fs";
10
- import { join, dirname } from "path";
11
- import { fileURLToPath } from "url";
9
+ import { readFileSync } from "node:fs";
10
+ import { join, dirname } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
12
 
13
13
  declare const __GHFIND_VERSION__: string | undefined;
14
14
 
package/src/watch.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Watch mode — periodically re-run a search and show changes.
3
3
  *
4
4
  */
5
- import type { ParsedQuery, SearchOptions, Repo } from "./types";
5
+ import type { SearchOptions, Repo } from "./types";
6
6
  import {
7
7
  parseQuery,
8
8
  applyFlagFilters,
@@ -1,211 +0,0 @@
1
- /**
2
- * Minimal markdown-to-terminal-text renderer.
3
- *
4
- * Renders headings, code blocks, lists, tables, blockquotes, horizontal rules,
5
- * bold/italic, and links into a plain-text representation suitable for a
6
- * TextRenderable in the TUI.
7
- */
8
-
9
- interface RenderOptions {
10
- /** Terminal width for wrapping and table layout. Default: 80. */
11
- width?: number;
12
- }
13
-
14
- /** Render markdown text to terminal-friendly plain text. */
15
- export function renderMarkdown(md: string, opts: RenderOptions = {}): string {
16
- const width = opts.width ?? 80;
17
- const lines = md.split("\n");
18
- const out: string[] = [];
19
- let inCodeBlock = false;
20
- let codeLang = "";
21
- let codeLines: string[] = [];
22
- let inTable = false;
23
- let tableRows: string[][] = [];
24
-
25
- function flushCode() {
26
- if (codeLines.length === 0) return;
27
- const maxLen = Math.max(...codeLines.map((l) => l.length), codeLang.length);
28
- const barW = Math.min(maxLen + 4, width - 2);
29
- out.push("┌" + "─".repeat(barW) + "┐");
30
- if (codeLang) out.push("│ " + codeLang.padEnd(barW - 1) + "│");
31
- for (const cl of codeLines) {
32
- out.push("│ " + cl.padEnd(barW - 1) + "│");
33
- }
34
- out.push("└" + "─".repeat(barW) + "┘");
35
- out.push("");
36
- codeLines = [];
37
- codeLang = "";
38
- }
39
-
40
- function flushTable() {
41
- if (tableRows.length === 0) return;
42
- const dataRows = tableRows.filter(
43
- (r) => !r.every((c) => /^[\s\-:|]+$/.test(c)),
44
- );
45
- if (dataRows.length === 0) {
46
- tableRows = [];
47
- return;
48
- }
49
- const colCount = Math.max(...dataRows.map((r) => r.length));
50
- const colW: number[] = [];
51
- for (let ci = 0; ci < colCount; ci++) {
52
- colW.push(Math.max(3, ...dataRows.map((r) => (r[ci] ?? "").length)));
53
- }
54
- const totalW = colW.reduce((a, b) => a + b, 0) + colCount * 3 - 1;
55
- if (totalW > width) {
56
- for (const row of dataRows) {
57
- out.push(" " + row.map((c) => c.trim()).join(" │ "));
58
- }
59
- } else {
60
- const sep = "┌" + colW.map((w) => "─".repeat(w + 2)).join("┬") + "┐";
61
- const mid = "├" + colW.map((w) => "─".repeat(w + 2)).join("┼") + "┤";
62
- const end = "└" + colW.map((w) => "─".repeat(w + 2)).join("┴") + "┘";
63
- out.push(sep);
64
- dataRows.forEach((row, i) => {
65
- const cells = colW.map((w, ci) => (row[ci] ?? "").trim().padEnd(w));
66
- out.push("│ " + cells.map((c) => ` ${c} `).join("│") + "│");
67
- if (i === 0) out.push(mid);
68
- });
69
- out.push(end);
70
- }
71
- out.push("");
72
- tableRows = [];
73
- }
74
-
75
- function renderInline(line: string): string {
76
- // Bold
77
- line = line.replace(/\*\*(.+?)\*\*/g, "$1");
78
- // Italic (no asterisks)
79
- line = line.replace(/\*([^*]+?)\*/g, "$1");
80
- // Inline code
81
- line = line.replace(/`([^`]+)`/g, "⟨$1⟩");
82
- // Links [text](url)
83
- line = line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)");
84
- // Images
85
- line = line.replace(/!\[([^\]]*)\]\([^)]+\)/g, "🖼 $1");
86
- return line;
87
- }
88
-
89
- function wrapLine(line: string, maxW: number): string[] {
90
- if (line.length <= maxW) return [line];
91
- const parts: string[] = [];
92
- let remaining = line;
93
- while (remaining.length > maxW) {
94
- let breakAt = maxW;
95
- const spaceIdx = remaining.lastIndexOf(" ", maxW);
96
- if (spaceIdx > maxW / 2) breakAt = spaceIdx;
97
- parts.push(remaining.slice(0, breakAt));
98
- remaining = remaining.slice(breakAt).replace(/^ /, "");
99
- }
100
- if (remaining) parts.push(remaining);
101
- return parts;
102
- }
103
-
104
- for (let i = 0; i < lines.length; i++) {
105
- let line = lines[i];
106
-
107
- // Code block boundaries
108
- if (/^```/.test(line)) {
109
- if (inCodeBlock) {
110
- flushCode();
111
- inCodeBlock = false;
112
- } else {
113
- inCodeBlock = true;
114
- codeLang = line.slice(3).trim();
115
- }
116
- continue;
117
- }
118
- if (inCodeBlock) {
119
- codeLines.push(line);
120
- continue;
121
- }
122
-
123
- // Table row
124
- if (line.includes("|")) {
125
- const cells = line
126
- .split("|")
127
- .map((c) => c.trim())
128
- .filter((_, idx, arr) => idx > 0 && idx < arr.length - 1);
129
- if (cells.length >= 2) {
130
- inTable = true;
131
- tableRows.push(cells);
132
- continue;
133
- }
134
- } else if (inTable) {
135
- flushTable();
136
- inTable = false;
137
- }
138
-
139
- // Horizontal rule
140
- if (/^(\s*[-*_]){3,}\s*$/.test(line)) {
141
- out.push("─".repeat(Math.min(width, 40)));
142
- out.push("");
143
- continue;
144
- }
145
-
146
- // Headings
147
- const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
148
- if (headingMatch) {
149
- const level = headingMatch[1].length;
150
- const text = renderInline(headingMatch[2]);
151
- const prefix = "█ ".repeat(level);
152
- out.push("");
153
- out.push(prefix + text);
154
- out.push("");
155
- continue;
156
- }
157
-
158
- // Blockquote
159
- const bqMatch = line.match(/^>\s?(.*)/);
160
- if (bqMatch) {
161
- const inner = renderInline(bqMatch[1]);
162
- for (const wl of wrapLine(inner, width - 4)) {
163
- out.push("│ " + wl);
164
- }
165
- continue;
166
- }
167
-
168
- // Unordered list
169
- const ulMatch = line.match(/^(\s*)[-*+]\s+(.*)/);
170
- if (ulMatch) {
171
- const indent = ulMatch[1].length;
172
- const depth = Math.floor(indent / 2);
173
- const prefix = " ".repeat(depth) + "• ";
174
- const inner = renderInline(ulMatch[2]);
175
- for (const wl of wrapLine(inner, width - prefix.length)) {
176
- out.push(prefix + wl);
177
- }
178
- continue;
179
- }
180
-
181
- // Ordered list
182
- const olMatch = line.match(/^(\s*)\d+\.\s+(.*)/);
183
- if (olMatch) {
184
- const indent = olMatch[1].length;
185
- const depth = Math.floor(indent / 2);
186
- const prefix = " ".repeat(depth) + "◦ ";
187
- const inner = renderInline(olMatch[2]);
188
- for (const wl of wrapLine(inner, width - prefix.length)) {
189
- out.push(prefix + wl);
190
- }
191
- continue;
192
- }
193
-
194
- // Empty line
195
- if (line.trim() === "") {
196
- out.push("");
197
- continue;
198
- }
199
-
200
- // Paragraph
201
- const rendered = renderInline(line);
202
- for (const wl of wrapLine(rendered, width)) {
203
- out.push(wl);
204
- }
205
- }
206
-
207
- if (inCodeBlock) flushCode();
208
- if (inTable) flushTable();
209
-
210
- return out.join("\n");
211
- }