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/CHANGELOG.md +120 -0
- package/LICENSE +21 -21
- package/README.md +45 -25
- package/completions/ghfind.bash +55 -55
- package/completions/ghfind.fish +28 -28
- package/completions/ghfind.zsh +52 -52
- package/demo.svg +71 -71
- package/dist/cli.js +1097 -735
- package/package.json +1 -1
- package/release-notes/9.8.2.md +65 -0
- package/release-notes/9.8.3.md +98 -0
- package/scripts/brew.rb +53 -53
- package/scripts/postinstall.js +38 -0
- package/src/cli.ts +40 -22
- package/src/config.ts +12 -2
- package/src/deepdive.ts +70 -66
- package/src/doctor.ts +3 -2
- package/src/error-report.ts +81 -11
- package/src/errors.ts +27 -1
- package/src/help.ts +11 -0
- package/src/history.ts +2 -5
- package/src/image-loader.ts +57 -2
- package/src/init.ts +1 -2
- package/src/markdown-images.ts +93 -4
- package/src/markdown-view.ts +49 -20
- package/src/mcp-server.ts +22 -11
- package/src/notifications.ts +9 -3
- package/src/org.ts +15 -14
- package/src/output.ts +1 -13
- package/src/readme.ts +19 -3
- package/src/releases.ts +29 -27
- package/src/search.ts +23 -2
- package/src/session.ts +58 -2
- package/src/skill-finder.ts +33 -2
- package/src/storage.ts +22 -47
- package/src/tui.ts +190 -44
- package/src/user.ts +15 -14
- package/dist/chunk-GYIL4S5X.js +0 -59
- package/dist/config-OCIPF2IF.js +0 -14
package/src/notifications.ts
CHANGED
|
@@ -12,10 +12,14 @@ export interface Notification {
|
|
|
12
12
|
|
|
13
13
|
let nextId = 1;
|
|
14
14
|
let notifs: Notification[] = [];
|
|
15
|
+
let loaded = false;
|
|
15
16
|
const MAX_NOTIFS = 50;
|
|
16
17
|
const FILE = "notifications.json";
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
/** Load once per process: re-reading on an empty list would reissue ids. */
|
|
20
|
+
function ensureLoaded(): void {
|
|
21
|
+
if (loaded) return;
|
|
22
|
+
loaded = true;
|
|
19
23
|
notifs = readJSON<Notification[]>(FILE, []);
|
|
20
24
|
nextId = Math.max(...notifs.map((n) => n.id), 0) + 1;
|
|
21
25
|
}
|
|
@@ -28,7 +32,7 @@ export function addNotification(
|
|
|
28
32
|
type: Notification["type"],
|
|
29
33
|
message: string,
|
|
30
34
|
): Notification {
|
|
31
|
-
|
|
35
|
+
ensureLoaded();
|
|
32
36
|
const n: Notification = {
|
|
33
37
|
id: nextId++,
|
|
34
38
|
type,
|
|
@@ -60,16 +64,18 @@ function iconFor(type: Notification["type"]): string {
|
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
export function getNotifications(): Notification[] {
|
|
63
|
-
|
|
67
|
+
ensureLoaded();
|
|
64
68
|
return [...notifs];
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
export function dismissNotification(id: number): void {
|
|
72
|
+
ensureLoaded();
|
|
68
73
|
notifs = notifs.filter((n) => n.id !== id);
|
|
69
74
|
save();
|
|
70
75
|
}
|
|
71
76
|
|
|
72
77
|
export function dismissAll(): void {
|
|
78
|
+
ensureLoaded();
|
|
73
79
|
notifs = [];
|
|
74
80
|
save();
|
|
75
81
|
}
|
package/src/org.ts
CHANGED
|
@@ -171,20 +171,21 @@ export async function fetchOrgProfile(
|
|
|
171
171
|
if (orgPayload.public_repos > 0) {
|
|
172
172
|
const perPage = Math.min(limit, 100);
|
|
173
173
|
const maxPages = Math.ceil(limit / perPage);
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
174
|
+
const pages = await Promise.all(
|
|
175
|
+
Array.from({ length: maxPages }, (_, i) => i + 1).map(async (page) => {
|
|
176
|
+
const url =
|
|
177
|
+
`https://api.github.com/orgs/${encodeURIComponent(name)}/repos` +
|
|
178
|
+
`?per_page=${perPage}&sort=pushed&page=${page}`;
|
|
179
|
+
const res = await fetch(url, { headers: apiHeaders(token) });
|
|
180
|
+
if (!res.ok) {
|
|
181
|
+
// Org metadata succeeded; degraded summary beats a hard failure.
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
const items = (await res.json()) as GitHubRepoPayload[];
|
|
185
|
+
return Array.isArray(items) ? items : []; // unexpected shape — skip
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
repos.push(...pages.flat());
|
|
188
189
|
}
|
|
189
190
|
|
|
190
191
|
return normalizeOrg(orgPayload, repos.slice(0, limit));
|
package/src/output.ts
CHANGED
|
@@ -136,18 +136,6 @@ export async function pipeExec(repos: Repo[], target: string): Promise<void> {
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
// ───
|
|
139
|
+
// ─── Narrowed aliases ──────────────────────────────────────────────────
|
|
140
140
|
|
|
141
141
|
export type ExportFormat = "json" | "csv" | "markdown" | "text";
|
|
142
|
-
export type FormatLine =
|
|
143
|
-
"urls" | "names" | "ssh-urls" | "clone-commands" | "ids";
|
|
144
|
-
|
|
145
|
-
/** @deprecated Use `format()` instead. */
|
|
146
|
-
export function formatRepos(repos: Repo[], fmt: ExportFormat): string {
|
|
147
|
-
return format(repos, fmt);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** @deprecated Use `format()` instead. */
|
|
151
|
-
export function formatLines(repos: Repo[], fmt: FormatLine): string {
|
|
152
|
-
return format(repos, fmt);
|
|
153
|
-
}
|
package/src/readme.ts
CHANGED
|
@@ -27,17 +27,33 @@ const CANDIDATES: ReadonlyArray<readonly [string, string]> = [
|
|
|
27
27
|
["main", "README.rst"],
|
|
28
28
|
];
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Headers for a README request. raw.githubusercontent.com is not the GitHub
|
|
32
|
+
* API, so the token is only ever attached to api.github.com — and only for
|
|
33
|
+
* private repos, which are the sole reason to authenticate a README fetch.
|
|
34
|
+
*/
|
|
35
|
+
export function buildReadmeHeaders(
|
|
36
|
+
url: string,
|
|
37
|
+
token: string | undefined,
|
|
38
|
+
isPrivate = false,
|
|
39
|
+
): Record<string, string> {
|
|
40
|
+
const headers: Record<string, string> = { "User-Agent": "ghfind/1.0" };
|
|
41
|
+
if (token && isPrivate && new URL(url).origin === "https://api.github.com") {
|
|
42
|
+
headers.Authorization = `Bearer ${token}`;
|
|
43
|
+
}
|
|
44
|
+
return headers;
|
|
45
|
+
}
|
|
46
|
+
|
|
30
47
|
export async function fetchReadme(
|
|
31
48
|
owner: string,
|
|
32
49
|
repo: string,
|
|
33
|
-
opts: { token?: string; fetchImpl?: typeof fetch } = {},
|
|
50
|
+
opts: { token?: string; private?: boolean; fetchImpl?: typeof fetch } = {},
|
|
34
51
|
): Promise<Readme | null> {
|
|
35
52
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
36
|
-
const headers: Record<string, string> = { "User-Agent": "ghfind/1.0" };
|
|
37
|
-
if (opts.token) headers.Authorization = `Bearer ${opts.token}`;
|
|
38
53
|
|
|
39
54
|
const attempts = CANDIDATES.map(async ([branch, file]) => {
|
|
40
55
|
const url = readmeBaseUrl(owner, repo, branch, file);
|
|
56
|
+
const headers = buildReadmeHeaders(url, opts.token, opts.private);
|
|
41
57
|
const res = await fetchImpl(url, { headers });
|
|
42
58
|
// Reject rather than resolve with null: Promise.any settles on the first
|
|
43
59
|
// *fulfilled* promise, so a 404 that resolved would beat the real README.
|
package/src/releases.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { loadConfig } from "./config";
|
|
|
5
5
|
|
|
6
6
|
const RELEASES_FILE = "release-cache.json";
|
|
7
7
|
const PER_REPO = 5;
|
|
8
|
+
/** Bookmarks checked in parallel — enough to be fast, few enough to stay polite. */
|
|
9
|
+
const CONCURRENCY = 6;
|
|
8
10
|
|
|
9
11
|
export interface TrackedRelease {
|
|
10
12
|
id: number;
|
|
@@ -87,47 +89,47 @@ export async function checkReleases(
|
|
|
87
89
|
error?: string;
|
|
88
90
|
}> = [];
|
|
89
91
|
|
|
90
|
-
|
|
92
|
+
const checkOne = async (
|
|
93
|
+
fullName: string,
|
|
94
|
+
): Promise<{
|
|
95
|
+
fullName: string;
|
|
96
|
+
newReleases: TrackedRelease[];
|
|
97
|
+
error?: string;
|
|
98
|
+
}> => {
|
|
91
99
|
try {
|
|
92
|
-
const prev = cache[
|
|
93
|
-
const lastSeen = getLastSeen(
|
|
100
|
+
const prev = cache[fullName];
|
|
101
|
+
const lastSeen = getLastSeen(fullName);
|
|
94
102
|
const isNew = (r: TrackedRelease) =>
|
|
95
103
|
!r.publishedAt || new Date(r.publishedAt).getTime() > lastSeen;
|
|
96
104
|
|
|
97
105
|
const { status, etag, releases } = await fetchReleases(
|
|
98
|
-
|
|
106
|
+
fullName,
|
|
99
107
|
prev?.etag,
|
|
100
108
|
token,
|
|
101
109
|
);
|
|
102
110
|
|
|
103
111
|
if (status === 304 && prev) {
|
|
104
112
|
// Unchanged upstream; recompute against lastSeenAt (it may have moved).
|
|
105
|
-
|
|
106
|
-
fullName: bm.repo.fullName,
|
|
107
|
-
newReleases: prev.releases.filter(isNew),
|
|
108
|
-
});
|
|
109
|
-
continue;
|
|
113
|
+
return { fullName, newReleases: prev.releases.filter(isNew) };
|
|
110
114
|
}
|
|
111
115
|
if (status !== 200 || !releases) {
|
|
112
|
-
|
|
113
|
-
fullName: bm.repo.fullName,
|
|
114
|
-
newReleases: [],
|
|
115
|
-
error: `HTTP ${status}`,
|
|
116
|
-
});
|
|
117
|
-
continue;
|
|
116
|
+
return { fullName, newReleases: [], error: `HTTP ${status}` };
|
|
118
117
|
}
|
|
119
|
-
cache[
|
|
120
|
-
|
|
121
|
-
fullName: bm.repo.fullName,
|
|
122
|
-
newReleases: releases.filter(isNew),
|
|
123
|
-
});
|
|
118
|
+
cache[fullName] = { checkedAt: Date.now(), etag, releases };
|
|
119
|
+
return { fullName, newReleases: releases.filter(isNew) };
|
|
124
120
|
} catch (err) {
|
|
125
|
-
|
|
126
|
-
fullName: bm.repo.fullName,
|
|
127
|
-
newReleases: [],
|
|
128
|
-
error: (err as Error).message,
|
|
129
|
-
});
|
|
121
|
+
return { fullName, newReleases: [], error: (err as Error).message };
|
|
130
122
|
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Run in small batches rather than one-at-a-time: a serial loop over every
|
|
126
|
+
// bookmark spends all its time waiting on the network, and bursts straight
|
|
127
|
+
// through the unauthenticated rate limit.
|
|
128
|
+
for (let i = 0; i < bookmarks.length; i += CONCURRENCY) {
|
|
129
|
+
const batch = bookmarks
|
|
130
|
+
.slice(i, i + CONCURRENCY)
|
|
131
|
+
.map((bm) => checkOne(bm.repo.fullName));
|
|
132
|
+
results.push(...(await Promise.all(batch)));
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
saveCache(cache);
|
|
@@ -158,9 +160,9 @@ export function allCachedReleases(): Array<
|
|
|
158
160
|
TrackedRelease & { fullName: string }
|
|
159
161
|
> {
|
|
160
162
|
const cache = loadCache();
|
|
161
|
-
const
|
|
163
|
+
const bookmarked = new Set(getBookmarks().map((b) => b.repo.fullName));
|
|
162
164
|
return Object.entries(cache)
|
|
163
|
-
.filter(([fullName]) =>
|
|
165
|
+
.filter(([fullName]) => bookmarked.has(fullName))
|
|
164
166
|
.flatMap(([fullName, s]) => s.releases.map((r) => ({ ...r, fullName })))
|
|
165
167
|
.sort(
|
|
166
168
|
(a, b) =>
|
package/src/search.ts
CHANGED
|
@@ -15,7 +15,13 @@ import type {
|
|
|
15
15
|
SortStrategy,
|
|
16
16
|
CacheEntry,
|
|
17
17
|
} from "./types";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
NetworkError,
|
|
20
|
+
RateLimitError,
|
|
21
|
+
ParseError,
|
|
22
|
+
AuthError,
|
|
23
|
+
ForbiddenError,
|
|
24
|
+
} from "./errors";
|
|
19
25
|
import { parseTrendingHtml, type RawTrendingRepo } from "./trending-parser";
|
|
20
26
|
|
|
21
27
|
export interface Logger {
|
|
@@ -58,6 +64,10 @@ export class MemoryCache<T> {
|
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
set(key: string, data: T, ttlMs?: number): void {
|
|
67
|
+
// Re-inserting an existing key must move it to the end of the
|
|
68
|
+
// insertion-ordered Map, otherwise a refreshed entry keeps its original
|
|
69
|
+
// position and is the first one evicted — FIFO instead of LRU.
|
|
70
|
+
this.cache.delete(key);
|
|
61
71
|
if (this.cache.size >= MAX_CACHE_SIZE) {
|
|
62
72
|
const firstKey = this.cache.keys().next().value;
|
|
63
73
|
if (firstKey !== undefined) this.cache.delete(firstKey);
|
|
@@ -444,7 +454,14 @@ export class GitHubSearchAdapter implements SearchAdapter {
|
|
|
444
454
|
this.logger.error(
|
|
445
455
|
`[github] non-rate-limit 403: ${body.slice(0, 200)}`,
|
|
446
456
|
);
|
|
447
|
-
throw new
|
|
457
|
+
throw new ForbiddenError();
|
|
458
|
+
}
|
|
459
|
+
if (res.status === 401) {
|
|
460
|
+
const body = await res.text().catch(() => "");
|
|
461
|
+
this.logger.error(
|
|
462
|
+
`[github] auth rejected (401): ${body.slice(0, 200)}`,
|
|
463
|
+
);
|
|
464
|
+
throw new AuthError();
|
|
448
465
|
}
|
|
449
466
|
if (!res.ok) {
|
|
450
467
|
const body = await res.text().catch(() => "");
|
|
@@ -488,6 +505,10 @@ export class GitHubSearchAdapter implements SearchAdapter {
|
|
|
488
505
|
lastErr = undefined;
|
|
489
506
|
break;
|
|
490
507
|
} catch (err) {
|
|
508
|
+
// RateLimitError must propagate: swallowing it turns the failure
|
|
509
|
+
// into a silent zero-result "success" and the user never learns
|
|
510
|
+
// why their search died.
|
|
511
|
+
if (err instanceof RateLimitError) throw err;
|
|
491
512
|
lastErr = err as Error;
|
|
492
513
|
if (rateLimited) break;
|
|
493
514
|
}
|
package/src/session.ts
CHANGED
|
@@ -6,9 +6,63 @@ import { readJSON, writeJSON } from "./storage";
|
|
|
6
6
|
|
|
7
7
|
const SESSION_FILE = "session.json";
|
|
8
8
|
|
|
9
|
+
/** Upper bound for a plausible search query; anything longer is garbage. */
|
|
10
|
+
const MAX_QUERY_LENGTH = 200;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Drop JSON-shaped or oversized queries. A search line is never raw JSON
|
|
14
|
+
* (objects/arrays always arrive as paste payloads — e.g. an MCP handshake
|
|
15
|
+
* request that a client piped into the TUI) and never thousands of chars,
|
|
16
|
+
* so such a "query" is corruption, not intent. The TUI's input handler
|
|
17
|
+
* applies the same shape rule to live pastes.
|
|
18
|
+
*/
|
|
19
|
+
export function sanitizeQuery(query: unknown): string | undefined {
|
|
20
|
+
if (typeof query !== "string") return undefined;
|
|
21
|
+
const trimmed = query.trim();
|
|
22
|
+
if (trimmed.length === 0 || trimmed.length > MAX_QUERY_LENGTH) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
if (
|
|
26
|
+
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
|
27
|
+
(trimmed.startsWith("[") && trimmed.endsWith("]"))
|
|
28
|
+
) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
return trimmed;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Keep only well-formed, known-good fields from a session snapshot. */
|
|
35
|
+
export function sanitizeSession(
|
|
36
|
+
state: SessionState | null | undefined,
|
|
37
|
+
): SessionState | null {
|
|
38
|
+
if (!state || typeof state !== "object") return null;
|
|
39
|
+
const mode =
|
|
40
|
+
state.mode === "trending" ||
|
|
41
|
+
state.mode === "packages" ||
|
|
42
|
+
state.mode === "search"
|
|
43
|
+
? state.mode
|
|
44
|
+
: "search";
|
|
45
|
+
const trendingTab =
|
|
46
|
+
typeof state.trendingTab === "string" && state.trendingTab.length > 0
|
|
47
|
+
? state.trendingTab
|
|
48
|
+
: "This Week";
|
|
49
|
+
const limit =
|
|
50
|
+
typeof state.limit === "number" && Number.isFinite(state.limit)
|
|
51
|
+
? state.limit
|
|
52
|
+
: 50;
|
|
53
|
+
const sort = typeof state.sort === "string" ? state.sort : "best-match";
|
|
54
|
+
return {
|
|
55
|
+
mode,
|
|
56
|
+
query: sanitizeQuery(state.query) ?? "",
|
|
57
|
+
sort,
|
|
58
|
+
limit,
|
|
59
|
+
trendingTab,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
9
63
|
export function saveSession(state: SessionState): void {
|
|
10
64
|
try {
|
|
11
|
-
writeJSON(SESSION_FILE, state);
|
|
65
|
+
writeJSON(SESSION_FILE, sanitizeSession(state));
|
|
12
66
|
} catch (err) {
|
|
13
67
|
if (process.env.DEBUG)
|
|
14
68
|
console.error(
|
|
@@ -18,7 +72,9 @@ export function saveSession(state: SessionState): void {
|
|
|
18
72
|
}
|
|
19
73
|
|
|
20
74
|
export function restoreSession(): SessionState | null {
|
|
21
|
-
|
|
75
|
+
// Anything read from disk passes the same sanitizer as anything written —
|
|
76
|
+
// protects against files corrupted before this guard existed.
|
|
77
|
+
return sanitizeSession(readJSON<SessionState | null>(SESSION_FILE, null));
|
|
22
78
|
}
|
|
23
79
|
|
|
24
80
|
export function clearSession(): void {
|
package/src/skill-finder.ts
CHANGED
|
@@ -80,6 +80,13 @@ export const MIN_QUERY_LENGTH = 2;
|
|
|
80
80
|
/** Result cap, applied to both sources. */
|
|
81
81
|
export const MAX_SKILL_RESULTS = 100;
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* The only shape accepted for registry `source` / `skillId`. Both end up
|
|
85
|
+
* inside a shell command (`npx skills add …`) that an agent is expected to
|
|
86
|
+
* run, so anything with shell metacharacters is rejected outright.
|
|
87
|
+
*/
|
|
88
|
+
const SAFE_ID_RE = /^[A-Za-z0-9._/-]+$/;
|
|
89
|
+
|
|
83
90
|
// ─── Local roots ──────────────────────────────────────────────────────────
|
|
84
91
|
|
|
85
92
|
/** One scan root: where it is, and the label results carry. */
|
|
@@ -193,6 +200,9 @@ export function parseSkillFrontmatter(
|
|
|
193
200
|
|
|
194
201
|
// ─── Local scan ───────────────────────────────────────────────────────────
|
|
195
202
|
|
|
203
|
+
/** Memoised scan results, keyed by root path + label. */
|
|
204
|
+
const scanCache = new Map<string, LocalSkillHit[]>();
|
|
205
|
+
|
|
196
206
|
/**
|
|
197
207
|
* Walk the roots and collect every readable SKILL.md, de-duplicated by name.
|
|
198
208
|
*
|
|
@@ -203,6 +213,12 @@ export function parseSkillFrontmatter(
|
|
|
203
213
|
export function scanLocalSkills(
|
|
204
214
|
roots: SkillRoot[] = resolveSkillRoots(),
|
|
205
215
|
): LocalSkillHit[] {
|
|
216
|
+
// The filesystem does not change within a process, so a repeat scan is pure
|
|
217
|
+
// duplicate blocking I/O. Keyed on the roots so callers passing their own
|
|
218
|
+
// roots (tests, GHFIND_SKILL_ROOTS) still get a distinct cache entry.
|
|
219
|
+
const key = roots.map((r) => `${r.path}\u0000${r.label}`).join("\u0001");
|
|
220
|
+
const cached = scanCache.get(key);
|
|
221
|
+
if (cached) return cached;
|
|
206
222
|
const byName = new Map<string, LocalSkillHit>();
|
|
207
223
|
for (const root of roots) {
|
|
208
224
|
for (const dir of walkDirs(root.path)) {
|
|
@@ -224,7 +240,9 @@ export function scanLocalSkills(
|
|
|
224
240
|
}
|
|
225
241
|
}
|
|
226
242
|
}
|
|
227
|
-
|
|
243
|
+
const hits = [...byName.values()];
|
|
244
|
+
scanCache.set(key, hits);
|
|
245
|
+
return hits;
|
|
228
246
|
}
|
|
229
247
|
|
|
230
248
|
/** Directory entries, or null when the directory is missing or unreadable. */
|
|
@@ -326,7 +344,20 @@ function normalizeRegistrySkill(raw: unknown): RegistrySkillHit | null {
|
|
|
326
344
|
const obj = raw as Record<string, unknown>;
|
|
327
345
|
const source = typeof obj.source === "string" ? obj.source : "";
|
|
328
346
|
const skillId = typeof obj.skillId === "string" ? obj.skillId : "";
|
|
329
|
-
|
|
347
|
+
// The registry is untrusted input and these two are interpolated into a
|
|
348
|
+
// shell command and a URL handed to an agent, so anything but a plain
|
|
349
|
+
// owner/repo + skill-id shape is dropped rather than escaped.
|
|
350
|
+
// `SAFE_ID_RE` allows `/` and `.`, so `../` passes it — reject traversal
|
|
351
|
+
// segments explicitly; they would land in the URL path and the npx command.
|
|
352
|
+
const hasTraversal = (s: string) => s === ".." || s.includes("..");
|
|
353
|
+
if (
|
|
354
|
+
!SAFE_ID_RE.test(source) ||
|
|
355
|
+
!SAFE_ID_RE.test(skillId) ||
|
|
356
|
+
hasTraversal(source) ||
|
|
357
|
+
hasTraversal(skillId)
|
|
358
|
+
) {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
330
361
|
const name =
|
|
331
362
|
typeof obj.name === "string" && obj.name.trim() ? obj.name : skillId;
|
|
332
363
|
const installs = typeof obj.installs === "number" ? obj.installs : 0;
|
package/src/storage.ts
CHANGED
|
@@ -2,16 +2,15 @@
|
|
|
2
2
|
* Storage adapter — single point of file I/O for state files.
|
|
3
3
|
*
|
|
4
4
|
* All domain modules (history, bookmarks, saved-searches, notifications,
|
|
5
|
-
* session, config) delegate file I/O here.
|
|
6
|
-
* implementation in tests.
|
|
5
|
+
* session, config) delegate file I/O here.
|
|
7
6
|
*/
|
|
8
7
|
import {
|
|
9
|
-
existsSync,
|
|
10
8
|
mkdirSync,
|
|
11
9
|
readFileSync,
|
|
12
10
|
writeFileSync,
|
|
13
11
|
appendFileSync,
|
|
14
12
|
unlinkSync,
|
|
13
|
+
renameSync,
|
|
15
14
|
} from "node:fs";
|
|
16
15
|
import { join } from "node:path";
|
|
17
16
|
import { stateDir } from "./config";
|
|
@@ -31,10 +30,27 @@ export function readJSON<T>(filename: string, fallback: T): T {
|
|
|
31
30
|
}
|
|
32
31
|
}
|
|
33
32
|
|
|
33
|
+
/** Write a file atomically: temp file in the same dir, then rename. */
|
|
34
|
+
function writeFileAtomic(filename: string, data: string): void {
|
|
35
|
+
ensureDir();
|
|
36
|
+
const target = join(stateDir(), filename);
|
|
37
|
+
const tmp = `${target}.tmp-${process.pid}`;
|
|
38
|
+
try {
|
|
39
|
+
writeFileSync(tmp, data, "utf-8");
|
|
40
|
+
renameSync(tmp, target);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
try {
|
|
43
|
+
unlinkSync(tmp);
|
|
44
|
+
} catch {
|
|
45
|
+
// temp already gone
|
|
46
|
+
}
|
|
47
|
+
throw e;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
34
51
|
/** Write data as JSON to a file. */
|
|
35
52
|
export function writeJSON(filename: string, data: unknown): void {
|
|
36
|
-
|
|
37
|
-
writeFileSync(join(stateDir(), filename), JSON.stringify(data, null, 2));
|
|
53
|
+
writeFileAtomic(filename, JSON.stringify(data, null, 2));
|
|
38
54
|
}
|
|
39
55
|
|
|
40
56
|
/** Append one JSON object as a line to a JSONL file. */
|
|
@@ -68,8 +84,7 @@ export function readJSONL<T>(filename: string): T[] {
|
|
|
68
84
|
|
|
69
85
|
/** Write raw text to a file (bypasses JSON encoding). */
|
|
70
86
|
export function writeRaw(filename: string, data: string): void {
|
|
71
|
-
|
|
72
|
-
writeFileSync(join(stateDir(), filename), data);
|
|
87
|
+
writeFileAtomic(filename, data);
|
|
73
88
|
}
|
|
74
89
|
|
|
75
90
|
/** Delete a file if it exists. */
|
|
@@ -94,43 +109,3 @@ export function debugLog(msg: string): void {
|
|
|
94
109
|
/* ignore */
|
|
95
110
|
}
|
|
96
111
|
}
|
|
97
|
-
|
|
98
|
-
/** Check if a file exists in the state dir. */
|
|
99
|
-
export function fileExists(filename: string): boolean {
|
|
100
|
-
return existsSync(join(stateDir(), filename));
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// ─── In-memory implementation for tests ────────────────────────────────
|
|
104
|
-
|
|
105
|
-
const memStore = new Map<string, string>();
|
|
106
|
-
|
|
107
|
-
export const memoryStorage = {
|
|
108
|
-
readJSON<T>(filename: string, fallback: T): T {
|
|
109
|
-
const raw = memStore.get(filename);
|
|
110
|
-
if (!raw) return fallback;
|
|
111
|
-
try {
|
|
112
|
-
return JSON.parse(raw) as T;
|
|
113
|
-
} catch {
|
|
114
|
-
return fallback;
|
|
115
|
-
}
|
|
116
|
-
},
|
|
117
|
-
writeJSON(filename: string, data: unknown): void {
|
|
118
|
-
memStore.set(filename, JSON.stringify(data, null, 2));
|
|
119
|
-
},
|
|
120
|
-
appendJSONL(filename: string, obj: unknown): void {
|
|
121
|
-
const existing = memStore.get(filename) ?? "";
|
|
122
|
-
memStore.set(filename, `${existing + JSON.stringify(obj)}\n`);
|
|
123
|
-
},
|
|
124
|
-
readJSONL<T>(filename: string): T[] {
|
|
125
|
-
const raw = memStore.get(filename);
|
|
126
|
-
if (!raw) return [];
|
|
127
|
-
return raw
|
|
128
|
-
.trim()
|
|
129
|
-
.split("\n")
|
|
130
|
-
.filter(Boolean)
|
|
131
|
-
.map((l) => JSON.parse(l) as T);
|
|
132
|
-
},
|
|
133
|
-
clear(): void {
|
|
134
|
-
memStore.clear();
|
|
135
|
-
},
|
|
136
|
-
};
|