github-search-cli 9.4.0 → 9.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +280 -192
- package/README.md +263 -237
- package/dist/cli.js +448 -91
- package/package.json +84 -74
- package/src/cli.ts +634 -566
- package/src/error-report.ts +8 -12
- package/src/org.ts +271 -0
- package/src/tui/overlays/help.ts +19 -0
- package/src/tui/overlays/registry.ts +52 -0
- package/src/tui.ts +3019 -2867
- package/src/update-check.ts +231 -215
- package/src/version.ts +33 -0
package/src/update-check.ts
CHANGED
|
@@ -1,215 +1,231 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Update checker — checks npm registry for a newer ghfind version.
|
|
3
|
-
*
|
|
4
|
-
* Uses the npm registry HTTP API (no subprocess needed — Bun has native fetch).
|
|
5
|
-
* Persists check state in the XDG state dir via storage.ts.
|
|
6
|
-
*/
|
|
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";
|
|
12
|
-
|
|
13
|
-
const REGISTRY_URL = "https://registry.npmjs.org/github-search-cli/latest";
|
|
14
|
-
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
15
|
-
const REQUEST_TIMEOUT_MS = 5000;
|
|
16
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
-
const PKG_DIR = join(__dirname, "..");
|
|
18
|
-
|
|
19
|
-
export interface UpdateState {
|
|
20
|
-
/** Timestamp of last successful check. */
|
|
21
|
-
lastCheck: number;
|
|
22
|
-
/** Timestamp until which we should suppress the panel (snooze). */
|
|
23
|
-
snoozeUntil: number;
|
|
24
|
-
/** If true, never show the update panel again. */
|
|
25
|
-
suppressed: boolean;
|
|
26
|
-
/** Latest version found on last check (for display). */
|
|
27
|
-
latestVersion?: string;
|
|
28
|
-
/** Version at time of last successful install (populated before performUpdate). */
|
|
29
|
-
lastInstalledVersion?: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const STATE_FILE = "update-state.json";
|
|
33
|
-
export const DEFAULTS: UpdateState = {
|
|
34
|
-
lastCheck: 0,
|
|
35
|
-
snoozeUntil: 0,
|
|
36
|
-
suppressed: false,
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
/** Read update-check state from disk. */
|
|
40
|
-
export function readUpdateState(): UpdateState {
|
|
41
|
-
return { ...DEFAULTS, ...readJSON<Partial<UpdateState>>(STATE_FILE, {}) };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Write update-check state to disk. */
|
|
45
|
-
export function writeUpdateState(state: UpdateState): void {
|
|
46
|
-
writeJSON(STATE_FILE, state);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Whether we should run an update check now, based on cached state.
|
|
51
|
-
* Returns false if: suppressed, snoozed, or checked within the last 24h.
|
|
52
|
-
*/
|
|
53
|
-
export function shouldCheckUpdate(): boolean {
|
|
54
|
-
const state = readUpdateState();
|
|
55
|
-
if (state.suppressed) return false;
|
|
56
|
-
if (Date.now() < state.snoozeUntil) return false;
|
|
57
|
-
if (Date.now() - state.lastCheck < CHECK_INTERVAL_MS) return false;
|
|
58
|
-
return true;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Compare two semver strings. Returns true if `latest` is newer than `current`.
|
|
63
|
-
* Uses Bun's built-in semver if available, falls back to simple comparison.
|
|
64
|
-
*/
|
|
65
|
-
export function isNewerVersion(current: string, latest: string): boolean {
|
|
66
|
-
// ponytail: Bun.semver.order is the right tool — native, correct on edge cases
|
|
67
|
-
if (typeof Bun !== "undefined" && Bun.semver) {
|
|
68
|
-
return Bun.semver.order(current, latest) < 0;
|
|
69
|
-
}
|
|
70
|
-
// Fallback: naive string comparison (works for simple semver; does not handle pre-release tags)
|
|
71
|
-
const curParts = current.split(".").map((s) => Number(s.split("-")[0]));
|
|
72
|
-
const newParts = latest.split(".").map((s) => Number(s.split("-")[0]));
|
|
73
|
-
for (let i = 0; i < Math.max(curParts.length, newParts.length); i++) {
|
|
74
|
-
const c = curParts[i] ?? 0;
|
|
75
|
-
const n = newParts[i] ?? 0;
|
|
76
|
-
if (n > c) return true;
|
|
77
|
-
if (n < c) return false;
|
|
78
|
-
}
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Check the npm registry for a newer version of ghfind.
|
|
84
|
-
* Returns the latest version string if an update is available, or null.
|
|
85
|
-
* Never throws — failures (offline, timeout, DNS) return null silently.
|
|
86
|
-
*/
|
|
87
|
-
export async function checkForUpdate(
|
|
88
|
-
currentVersion: string,
|
|
89
|
-
): Promise<string | null> {
|
|
90
|
-
if (process.env.DEBUG)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (
|
|
107
|
-
return
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Update checker — checks npm registry for a newer ghfind version.
|
|
3
|
+
*
|
|
4
|
+
* Uses the npm registry HTTP API (no subprocess needed — Bun has native fetch).
|
|
5
|
+
* Persists check state in the XDG state dir via storage.ts.
|
|
6
|
+
*/
|
|
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";
|
|
12
|
+
|
|
13
|
+
const REGISTRY_URL = "https://registry.npmjs.org/github-search-cli/latest";
|
|
14
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
15
|
+
const REQUEST_TIMEOUT_MS = 5000;
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const PKG_DIR = join(__dirname, "..");
|
|
18
|
+
|
|
19
|
+
export interface UpdateState {
|
|
20
|
+
/** Timestamp of last successful check. */
|
|
21
|
+
lastCheck: number;
|
|
22
|
+
/** Timestamp until which we should suppress the panel (snooze). */
|
|
23
|
+
snoozeUntil: number;
|
|
24
|
+
/** If true, never show the update panel again. */
|
|
25
|
+
suppressed: boolean;
|
|
26
|
+
/** Latest version found on last check (for display). */
|
|
27
|
+
latestVersion?: string;
|
|
28
|
+
/** Version at time of last successful install (populated before performUpdate). */
|
|
29
|
+
lastInstalledVersion?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const STATE_FILE = "update-state.json";
|
|
33
|
+
export const DEFAULTS: UpdateState = {
|
|
34
|
+
lastCheck: 0,
|
|
35
|
+
snoozeUntil: 0,
|
|
36
|
+
suppressed: false,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Read update-check state from disk. */
|
|
40
|
+
export function readUpdateState(): UpdateState {
|
|
41
|
+
return { ...DEFAULTS, ...readJSON<Partial<UpdateState>>(STATE_FILE, {}) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Write update-check state to disk. */
|
|
45
|
+
export function writeUpdateState(state: UpdateState): void {
|
|
46
|
+
writeJSON(STATE_FILE, state);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether we should run an update check now, based on cached state.
|
|
51
|
+
* Returns false if: suppressed, snoozed, or checked within the last 24h.
|
|
52
|
+
*/
|
|
53
|
+
export function shouldCheckUpdate(): boolean {
|
|
54
|
+
const state = readUpdateState();
|
|
55
|
+
if (state.suppressed) return false;
|
|
56
|
+
if (Date.now() < state.snoozeUntil) return false;
|
|
57
|
+
if (Date.now() - state.lastCheck < CHECK_INTERVAL_MS) return false;
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Compare two semver strings. Returns true if `latest` is newer than `current`.
|
|
63
|
+
* Uses Bun's built-in semver if available, falls back to simple comparison.
|
|
64
|
+
*/
|
|
65
|
+
export function isNewerVersion(current: string, latest: string): boolean {
|
|
66
|
+
// ponytail: Bun.semver.order is the right tool — native, correct on edge cases
|
|
67
|
+
if (typeof Bun !== "undefined" && Bun.semver) {
|
|
68
|
+
return Bun.semver.order(current, latest) < 0;
|
|
69
|
+
}
|
|
70
|
+
// Fallback: naive string comparison (works for simple semver; does not handle pre-release tags)
|
|
71
|
+
const curParts = current.split(".").map((s) => Number(s.split("-")[0]));
|
|
72
|
+
const newParts = latest.split(".").map((s) => Number(s.split("-")[0]));
|
|
73
|
+
for (let i = 0; i < Math.max(curParts.length, newParts.length); i++) {
|
|
74
|
+
const c = curParts[i] ?? 0;
|
|
75
|
+
const n = newParts[i] ?? 0;
|
|
76
|
+
if (n > c) return true;
|
|
77
|
+
if (n < c) return false;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check the npm registry for a newer version of ghfind.
|
|
84
|
+
* Returns the latest version string if an update is available, or null.
|
|
85
|
+
* Never throws — failures (offline, timeout, DNS) return null silently.
|
|
86
|
+
*/
|
|
87
|
+
export async function checkForUpdate(
|
|
88
|
+
currentVersion: string,
|
|
89
|
+
): Promise<string | null> {
|
|
90
|
+
if (process.env.DEBUG)
|
|
91
|
+
debugLog(`Checking for update (current: ${currentVersion})`);
|
|
92
|
+
try {
|
|
93
|
+
const res = await fetch(REGISTRY_URL, {
|
|
94
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
95
|
+
});
|
|
96
|
+
if (!res.ok) {
|
|
97
|
+
if (process.env.DEBUG) debugLog(`Registry returned ${res.status}`);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const data = (await res.json()) as { version?: string };
|
|
101
|
+
const latest = data.version;
|
|
102
|
+
if (!latest) {
|
|
103
|
+
if (process.env.DEBUG) debugLog("No version in registry response");
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
if (process.env.DEBUG) debugLog(`Registry latest: ${latest}`);
|
|
107
|
+
if (!isNewerVersion(currentVersion, latest)) return null;
|
|
108
|
+
return latest;
|
|
109
|
+
} catch (e) {
|
|
110
|
+
if (process.env.DEBUG)
|
|
111
|
+
debugLog(
|
|
112
|
+
`Update check failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
113
|
+
);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Mark the update check as done. Stores the latest version if one was found.
|
|
120
|
+
*/
|
|
121
|
+
export function markUpdateChecked(latestVersion?: string): void {
|
|
122
|
+
writeUpdateState({
|
|
123
|
+
...readUpdateState(),
|
|
124
|
+
lastCheck: Date.now(),
|
|
125
|
+
latestVersion,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Snooze update notifications for the given number of days.
|
|
131
|
+
*/
|
|
132
|
+
export function snoozeUpdateNotices(days: number): void {
|
|
133
|
+
writeUpdateState({
|
|
134
|
+
...readUpdateState(),
|
|
135
|
+
snoozeUntil: Date.now() + days * 24 * 60 * 60 * 1000,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Permanently suppress the update panel.
|
|
141
|
+
*/
|
|
142
|
+
export function suppressUpdateNotices(): void {
|
|
143
|
+
writeUpdateState({
|
|
144
|
+
...readUpdateState(),
|
|
145
|
+
suppressed: true,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Attempt to update ghfind in-place.
|
|
151
|
+
* Uses `process.versions.bun` for reliable runtime detection (works on all platforms).
|
|
152
|
+
*/
|
|
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];
|
|
161
|
+
|
|
162
|
+
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));
|
|
181
|
+
});
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Record the current version as "last installed" before performing an update.
|
|
189
|
+
* This lets the post-upgrade startup detect the version change and show notes.
|
|
190
|
+
*/
|
|
191
|
+
export function recordPreUpdateState(currentVersion: string): void {
|
|
192
|
+
writeUpdateState({
|
|
193
|
+
...readUpdateState(),
|
|
194
|
+
lastInstalledVersion: currentVersion,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Fetch release notes for a given version.
|
|
200
|
+
* Tries release-notes/<version>.md first, then falls back to CHANGELOG.md section.
|
|
201
|
+
* Returns the markdown text or null if not found.
|
|
202
|
+
*/
|
|
203
|
+
export function fetchReleaseNotes(version: string): string | null {
|
|
204
|
+
// ponytail: embedded notes are the primary source — they ship in the tarball, no network
|
|
205
|
+
try {
|
|
206
|
+
const notesPath = join(PKG_DIR, "release-notes", `${version}.md`);
|
|
207
|
+
const md = readFileSync(notesPath, "utf-8");
|
|
208
|
+
if (md.trim().length > 0) return md;
|
|
209
|
+
} catch {
|
|
210
|
+
// File doesn't exist for this version — fall through to CHANGELOG
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ponytail: CHANGELOG.md fallback — parse section for this version
|
|
214
|
+
try {
|
|
215
|
+
const changelogPath = join(PKG_DIR, "CHANGELOG.md");
|
|
216
|
+
const changelog = readFileSync(changelogPath, "utf-8");
|
|
217
|
+
// ponytail: find "## [version]" header, slice until next "## "
|
|
218
|
+
const header = "## [" + version + "]";
|
|
219
|
+
const start = changelog.indexOf(header);
|
|
220
|
+
if (start === -1) return null;
|
|
221
|
+
const bodyStart = start + header.length;
|
|
222
|
+
const rest = changelog.substring(bodyStart);
|
|
223
|
+
const nextHeaderIdx = rest.indexOf("\n## ");
|
|
224
|
+
const end =
|
|
225
|
+
nextHeaderIdx === -1 ? changelog.length : bodyStart + nextHeaderIdx;
|
|
226
|
+
return changelog.substring(bodyStart, end).trim();
|
|
227
|
+
} catch {
|
|
228
|
+
// CHANGELOG.md doesn't exist either
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package version resolution.
|
|
3
|
+
*
|
|
4
|
+
* Primary source: `__GHFIND_VERSION__`, injected at build time by tsup
|
|
5
|
+
* (`define`) and by `bun build --compile` (`--define`) — required for
|
|
6
|
+
* standalone single-file binaries where package.json is not present.
|
|
7
|
+
* Fallback: read package.json relative to this module (source / dist trees).
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "fs";
|
|
10
|
+
import { join, dirname } from "path";
|
|
11
|
+
import { fileURLToPath } from "url";
|
|
12
|
+
|
|
13
|
+
declare const __GHFIND_VERSION__: string | undefined;
|
|
14
|
+
|
|
15
|
+
let cached: string | null = null;
|
|
16
|
+
|
|
17
|
+
export function getVersion(): string {
|
|
18
|
+
if (cached) return cached;
|
|
19
|
+
if (typeof __GHFIND_VERSION__ === "string") {
|
|
20
|
+
cached = __GHFIND_VERSION__;
|
|
21
|
+
return cached;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const pkg = JSON.parse(
|
|
26
|
+
readFileSync(join(here, "..", "package.json"), "utf-8"),
|
|
27
|
+
) as { version?: string };
|
|
28
|
+
cached = pkg.version ?? "unknown";
|
|
29
|
+
} catch {
|
|
30
|
+
cached = "unknown";
|
|
31
|
+
}
|
|
32
|
+
return cached;
|
|
33
|
+
}
|