@seojin08/skill-finder 0.0.0-stage → 0.3.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.
Files changed (82) hide show
  1. package/.env.example +3 -0
  2. package/CHANGELOG.md +39 -0
  3. package/LICENSE +21 -0
  4. package/README.md +86 -2
  5. package/bin/skill-finder.mjs +389 -0
  6. package/package.json +39 -4
  7. package/server/src/analyze/frontmatter.js +130 -0
  8. package/server/src/analyze/quality.js +34 -0
  9. package/server/src/analyze/risk.js +123 -0
  10. package/server/src/app.js +57 -0
  11. package/server/src/bootstrap.js +46 -0
  12. package/server/src/claude/cli.js +89 -0
  13. package/server/src/config.js +70 -0
  14. package/server/src/context.js +1 -0
  15. package/server/src/db/index.js +128 -0
  16. package/server/src/db/schema.js +95 -0
  17. package/server/src/db/store.js +353 -0
  18. package/server/src/discovery/classifyRepo.js +81 -0
  19. package/server/src/discovery/genericRules.js +180 -0
  20. package/server/src/discovery/pluginRules.js +59 -0
  21. package/server/src/discovery/tree.js +79 -0
  22. package/server/src/index.js +55 -0
  23. package/server/src/indexer.js +502 -0
  24. package/server/src/install/installed.js +206 -0
  25. package/server/src/install/plan.js +118 -0
  26. package/server/src/install/run.js +97 -0
  27. package/server/src/install/scan.js +70 -0
  28. package/server/src/install/summary.js +14 -0
  29. package/server/src/install/uninstall.js +59 -0
  30. package/server/src/install/validate.js +24 -0
  31. package/server/src/jobs.js +107 -0
  32. package/server/src/recommend/readme.js +508 -0
  33. package/server/src/release.js +41 -0
  34. package/server/src/routes/admin.js +203 -0
  35. package/server/src/routes/favorites.js +61 -0
  36. package/server/src/routes/health.js +27 -0
  37. package/server/src/routes/install.js +283 -0
  38. package/server/src/routes/presets.js +66 -0
  39. package/server/src/routes/recommend.js +36 -0
  40. package/server/src/routes/search.js +53 -0
  41. package/server/src/routes/skills.js +214 -0
  42. package/server/src/search/activity.js +13 -0
  43. package/server/src/search/category.js +109 -0
  44. package/server/src/search/koreanTerms.js +284 -0
  45. package/server/src/search/pipeline.js +975 -0
  46. package/server/src/search/rank.js +138 -0
  47. package/server/src/search/result.js +129 -0
  48. package/server/src/search/searchLog.js +53 -0
  49. package/server/src/security.js +53 -0
  50. package/server/src/settings.js +56 -0
  51. package/server/src/sources/gitTree.js +101 -0
  52. package/server/src/sources/github.js +503 -0
  53. package/server/src/sources/marketplace.js +78 -0
  54. package/server/src/sources/pluginSource.js +98 -0
  55. package/server/src/sources/popularRepos.js +74 -0
  56. package/server/src/sources/skillsSh.js +74 -0
  57. package/server/src/tasks.js +216 -0
  58. package/server/src/update.js +53 -0
  59. package/server/src/util/folderPicker.js +208 -0
  60. package/server/src/util/http.js +41 -0
  61. package/server/src/util/limiter.js +110 -0
  62. package/server/src/util/log.js +94 -0
  63. package/server/src/util/ndjson.js +27 -0
  64. package/server/src/util/proc.js +80 -0
  65. package/server/src/util/resolveFolder.js +78 -0
  66. package/server/src/util/sanitize.js +45 -0
  67. package/server/src/util/sizedCache.js +74 -0
  68. package/server/src/util/textFiles.js +9 -0
  69. package/server/src/util/tokens.js +21 -0
  70. package/server/src/version.js +12 -0
  71. package/web/dist/LICENSE.txt +21 -0
  72. package/web/dist/THIRD-PARTY-LICENSES.txt +2267 -0
  73. package/web/dist/assets/ArasakaGame-B0V_LZLl.css +1 -0
  74. package/web/dist/assets/ArasakaGame-C1Wkmqik.js +36 -0
  75. package/web/dist/assets/index-BgL1OaU4.css +2 -0
  76. package/web/dist/assets/index-Ec2XBK4-.js +66 -0
  77. package/web/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  78. package/web/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  79. package/web/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  80. package/web/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  81. package/web/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  82. package/web/dist/index.html +15 -0
@@ -0,0 +1,503 @@
1
+ import { join } from 'node:path';
2
+ import { fetchText, USER_AGENT } from "../util/http.js";
3
+ import { SizedCache } from "../util/sizedCache.js";
4
+ import { sanitizeLine } from "../util/sanitize.js";
5
+ import { RateLimitWait, Semaphore, sleep, TokenBucket } from "../util/limiter.js";
6
+ import { gitTree, lsRemote } from "./gitTree.js";
7
+ /** Tree entries the tree cache may hold (perf debate 1-3); kubernetes, the largest seen, has about 42,000. */
8
+ export const TREE_CACHE_ENTRIES = 50_000;
9
+ const REF_TTL_MS = 10 * 60 * 1000;
10
+ export class GitHubError extends Error {
11
+ status;
12
+ constructor(message, status) {
13
+ super(message);
14
+ this.status = status;
15
+ }
16
+ }
17
+ const DAY_MS = 24 * 60 * 60 * 1000;
18
+ const API = 'https://api.github.com';
19
+ /** Code and repository search answers are kept this long (the `ghcode:`/`ghrepo:` cache keys). */
20
+ const SEARCH_CACHE_MS = DAY_MS;
21
+ /** Repos per GraphQL metadata call (the alias form GitHub allows comfortably). */
22
+ export const GRAPHQL_BATCH = 100;
23
+ const REPO_RE = /^[A-Za-z0-9-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
24
+ /** A relative path inside a repo: no leading slash, backslash, empty, `.` or `..` segment, control character. */
25
+ function isSafeRepoPath(path) {
26
+ return path.length <= 1024 && !/[\\\x00-\x1f]/.test(path) && path.split('/').every((seg) => seg !== '' && seg !== '.' && seg !== '..');
27
+ }
28
+ /** Raw files (SKILL.md included) are read up to this many bytes; the rest is cut off. */
29
+ export const RAW_MAX_BYTES = 512 * 1024;
30
+ export class GitHub {
31
+ store;
32
+ config;
33
+ workRoot;
34
+ // GitHub allows 10 code searches a minute; a burst of 5 keeps a refilling minute under that
35
+ codeSearch = new TokenBucket(9, 5);
36
+ searchWithToken = new TokenBucket(28);
37
+ searchNoToken = new TokenBucket(9, 5); // unauthenticated search is 10/min
38
+ core = { remaining: null, reset: null };
39
+ /** Last seen remaining/reset per rate-limit resource (core, search, code_search, graphql). */
40
+ limits = new Map();
41
+ searchInFlight = new Map();
42
+ /** Bounded by tree entries, not count (perf debate 1-3): ~175 bytes each, so about 9MB at most. */
43
+ treeCache = new SizedCache({ maxSize: TREE_CACHE_ENTRIES, ttlMs: 30 * 60 * 1000, sizeOf: (t) => t.entries.length });
44
+ refCache = new Map();
45
+ /** raw.githubusercontent.com answers bursts of hundreds of parallel downloads with 429s; keep it civil. */
46
+ rawSlots = new Semaphore(24);
47
+ metaInFlight = new Map();
48
+ constructor(store, config) {
49
+ this.store = store;
50
+ this.config = config;
51
+ this.workRoot = join(config.dataDir, 'tmp', 'git');
52
+ }
53
+ get hasToken() {
54
+ return !!this.config.githubToken;
55
+ }
56
+ /** Whether the core REST quota allows a call (keeps a small reserve). */
57
+ coreAvailable(reserve = 0) {
58
+ if (this.core.remaining == null)
59
+ return true;
60
+ if (this.core.reset != null && Date.now() / 1000 > this.core.reset)
61
+ return true;
62
+ return this.core.remaining > reserve;
63
+ }
64
+ headers(extra = {}) {
65
+ const h = {
66
+ 'User-Agent': USER_AGENT,
67
+ Accept: 'application/vnd.github+json',
68
+ 'X-GitHub-Api-Version': '2022-11-28',
69
+ ...extra,
70
+ };
71
+ if (this.config.githubToken)
72
+ h.Authorization = `Bearer ${this.config.githubToken}`;
73
+ return h;
74
+ }
75
+ get search() {
76
+ return this.hasToken ? this.searchWithToken : this.searchNoToken;
77
+ }
78
+ /**
79
+ * Throws at once while GitHub has said this resource's quota is used up (until its reset time), so a search
80
+ * does not wait for or spend a call on a certain 403.
81
+ */
82
+ checkLimit(resource) {
83
+ const l = this.limits.get(resource);
84
+ if (l && l.remaining <= 0 && Date.now() / 1000 < l.reset) {
85
+ const label = { code_search: '코드 검색', search: '저장소 검색', graphql: 'GraphQL', core: 'API' }[resource] ?? resource;
86
+ throw new GitHubError(`GitHub ${label} 한도 소진 (${Math.ceil(l.reset - Date.now() / 1000)}초 뒤 다시 가능)`, 429);
87
+ }
88
+ }
89
+ /**
90
+ * `resource` + `maxWaitMs`: search-path calls. They check the remembered quota first, give up instead of
91
+ * queuing past `maxWaitMs`, and never sleep on retry-after (the search would have moved on by then).
92
+ */
93
+ async api(path, opts = {}) {
94
+ if (opts.resource)
95
+ this.checkLimit(opts.resource);
96
+ for (let attempt = 0; attempt < 2; attempt++) {
97
+ if (opts.bucket) {
98
+ try {
99
+ await opts.bucket.take({ maxWaitMs: opts.maxWaitMs });
100
+ }
101
+ catch (err) {
102
+ if (err instanceof RateLimitWait)
103
+ throw new GitHubError(`GitHub 호출 한도 대기 (${err.message})`, 429);
104
+ throw err;
105
+ }
106
+ }
107
+ const res = await fetch(API + path, {
108
+ headers: this.headers({ ...(opts.etag ? { 'If-None-Match': opts.etag } : {}), ...(opts.accept ? { Accept: opts.accept } : {}) }),
109
+ signal: AbortSignal.timeout(20_000),
110
+ });
111
+ const resource = res.headers.get('x-ratelimit-resource');
112
+ const remaining = res.headers.get('x-ratelimit-remaining');
113
+ if (resource === 'core' && remaining != null) {
114
+ this.core = { remaining: Number(remaining), reset: Number(res.headers.get('x-ratelimit-reset')) || null };
115
+ }
116
+ if (resource && remaining != null) {
117
+ this.limits.set(resource, { remaining: Number(remaining), reset: Number(res.headers.get('x-ratelimit-reset')) || 0 });
118
+ }
119
+ if (res.status === 403 || res.status === 429) {
120
+ const retryAfter = Number(res.headers.get('retry-after'));
121
+ if (retryAfter > 0 && opts.resource && opts.maxWaitMs != null) {
122
+ // secondary limit on the search path: remember it as a closed circuit instead of sleeping
123
+ // (background collection has time, so it still sleeps and retries below)
124
+ this.limits.set(opts.resource, { remaining: 0, reset: Date.now() / 1000 + Math.min(retryAfter, 600) });
125
+ await res.body?.cancel().catch(() => { });
126
+ throw new GitHubError('GitHub API 한도 초과', res.status);
127
+ }
128
+ if (retryAfter > 0 && retryAfter <= 60 && attempt === 0) {
129
+ await res.body?.cancel().catch(() => { });
130
+ await sleep(retryAfter * 1000);
131
+ continue;
132
+ }
133
+ if (remaining === '0') {
134
+ await res.body?.cancel().catch(() => { });
135
+ throw new GitHubError('GitHub API 한도 초과', res.status);
136
+ }
137
+ }
138
+ return res;
139
+ }
140
+ throw new GitHubError('GitHub API 재시도 실패', 429);
141
+ }
142
+ /**
143
+ * Commit SHA for a ref (or default branch), cached for 10 minutes: the commits API with a token
144
+ * (~0.4s), otherwise `git ls-remote` (a process spawn, ~0.6–1s on Windows).
145
+ */
146
+ resolveRef(repo, ref) {
147
+ const key = `${repo.toLowerCase()}@${ref ?? 'HEAD'}`;
148
+ const hit = this.refCache.get(key);
149
+ if (hit && Date.now() - hit.at < REF_TTL_MS)
150
+ return hit.value;
151
+ const value = this.shaViaApi(repo, ref ?? 'HEAD')
152
+ .then((sha) => (sha ? { sha, defaultBranch: null } : lsRemote(repo, ref)))
153
+ .catch(() => lsRemote(repo, ref))
154
+ .catch(() => null);
155
+ this.refCache.set(key, { at: Date.now(), value });
156
+ // expired commits are dropped, not only skipped (they used to stay for the life of the app)
157
+ if (this.refCache.size > 200)
158
+ for (const [k, v] of this.refCache)
159
+ if (Date.now() - v.at >= REF_TTL_MS)
160
+ this.refCache.delete(k);
161
+ return value;
162
+ }
163
+ async shaViaApi(repo, ref) {
164
+ if (!this.hasToken || !this.coreAvailable(50) || !REPO_RE.test(repo) || !/^[\w./-]{1,200}$/.test(ref))
165
+ return null;
166
+ const res = await this.api(`/repos/${repo}/commits/${ref.split('/').map(encodeURIComponent).join('/')}`, { accept: 'application/vnd.github.sha' });
167
+ if (!res.ok) {
168
+ await res.body?.cancel().catch(() => { });
169
+ return null;
170
+ }
171
+ const sha = (await res.text()).trim();
172
+ return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
173
+ }
174
+ /** Full recursive tree at a commit: tree API with a token and spare quota, otherwise a git partial fetch. */
175
+ getTree(repo, sha) {
176
+ const key = `${repo.toLowerCase()}@${sha}`;
177
+ let p = this.treeCache.get(key);
178
+ if (!p) {
179
+ p = this.loadTree(repo, sha);
180
+ this.treeCache.set(key, p);
181
+ }
182
+ return p;
183
+ }
184
+ /**
185
+ * Tree at the default branch head. With a token the HEAD commit SHA and the HEAD tree are fetched in
186
+ * parallel (one round trip instead of two); without one it is a single git fetch.
187
+ */
188
+ async getTreeAtHead(repo) {
189
+ if (!REPO_RE.test(repo))
190
+ throw new Error(`invalid repo: ${repo}`);
191
+ if (this.hasToken && this.coreAvailable(50)) {
192
+ const [ref, entries] = await Promise.all([this.resolveRef(repo, null), this.treeViaApi(repo, 'HEAD').catch(() => null)]);
193
+ if (!ref)
194
+ throw new Error(`저장소를 찾을 수 없습니다: ${repo}`);
195
+ const key = `${repo.toLowerCase()}@${ref.sha}`;
196
+ if (entries && !this.treeCache.get(key)) {
197
+ this.treeCache.set(key, Promise.resolve({ repo, sha: ref.sha, entries, via: 'api', commitDate: this.store.getRepo(repo)?.pushed_at ?? null }));
198
+ }
199
+ return this.getTree(repo, ref.sha);
200
+ }
201
+ const { sha, entries, commitDate } = await gitTree(repo, 'HEAD', this.workRoot);
202
+ const tree = { repo, sha, entries, via: 'git', commitDate };
203
+ this.treeCache.set(`${repo.toLowerCase()}@${sha}`, Promise.resolve(tree));
204
+ return tree;
205
+ }
206
+ /** Blob entries from the recursive tree API; null when truncated or the quota is short (caller falls back to git). */
207
+ async treeViaApi(repo, ref) {
208
+ if (!this.hasToken || !this.coreAvailable(50))
209
+ return null;
210
+ try {
211
+ const res = await this.api(`/repos/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=1`);
212
+ if (!res.ok) {
213
+ await res.body?.cancel().catch(() => { });
214
+ if (res.status === 404 || res.status === 409 || res.status === 422)
215
+ throw new GitHubError(`tree ${res.status}`, res.status);
216
+ return null;
217
+ }
218
+ const body = (await res.json());
219
+ if (body.truncated)
220
+ return null;
221
+ return body.tree.filter((e) => e.type === 'blob').map((e) => ({ path: e.path, size: e.size ?? null, sha: e.sha ?? null }));
222
+ }
223
+ catch (err) {
224
+ if (err instanceof GitHubError && err.status !== 403 && err.status !== 429)
225
+ throw err;
226
+ return null;
227
+ }
228
+ }
229
+ async loadTree(repo, sha) {
230
+ if (!REPO_RE.test(repo))
231
+ throw new Error(`invalid repo: ${repo}`);
232
+ const entries = await this.treeViaApi(repo, sha);
233
+ if (entries)
234
+ return { repo, sha, entries, via: 'api', commitDate: this.store.getRepo(repo)?.pushed_at ?? null };
235
+ const git = await gitTree(repo, sha, this.workRoot);
236
+ return { repo, sha, entries: git.entries, via: 'git', commitDate: git.commitDate };
237
+ }
238
+ /**
239
+ * One file from raw.githubusercontent.com. Shares a concurrency cap with every other raw download and
240
+ * retries throttling (429), server errors and network failures, so a busy moment is not mistaken for
241
+ * a missing file. A real 404 comes back as `status: 404, text: null`.
242
+ */
243
+ async getRawText(repo, ref, path, maxBytes = RAW_MAX_BYTES) {
244
+ const url = `https://raw.githubusercontent.com/${repo}/${ref}/${path.split('/').map(encodeURIComponent).join('/')}`;
245
+ for (let attempt = 0;; attempt++) {
246
+ try {
247
+ const res = await this.rawSlots.run(() => fetchText(url, { maxBytes, timeoutMs: 15_000 }));
248
+ if ((res.status === 429 || res.status >= 500) && attempt < 2) {
249
+ await sleep(400 * (attempt + 1));
250
+ continue;
251
+ }
252
+ return res;
253
+ }
254
+ catch (err) {
255
+ if (attempt >= 2)
256
+ throw err;
257
+ await sleep(400 * (attempt + 1));
258
+ }
259
+ }
260
+ }
261
+ /** true / false when GitHub says the repo exists or not (404: deleted or private); null when unknown. */
262
+ async repoExists(repo) {
263
+ if (!REPO_RE.test(repo))
264
+ return false;
265
+ if (this.hasToken && this.coreAvailable(10)) {
266
+ try {
267
+ const res = await this.api(`/repos/${repo}`);
268
+ await res.body?.cancel().catch(() => { });
269
+ if (res.ok)
270
+ return true;
271
+ if (res.status === 404)
272
+ return false;
273
+ }
274
+ catch {
275
+ // fall through to git
276
+ }
277
+ }
278
+ // ls-remote fails the same way for a missing repo and a network problem, so only "exists" is certain.
279
+ return (await lsRemote(repo, null).catch(() => null)) != null ? true : null;
280
+ }
281
+ /** Repository metadata cached for 24h; returns cached (possibly stale) data when the quota is short. */
282
+ getRepoMeta(repo) {
283
+ // Several lookups in one search often ask for the same repo at once; share the request.
284
+ const key = repo.toLowerCase();
285
+ let p = this.metaInFlight.get(key);
286
+ if (!p) {
287
+ p = this.loadRepoMeta(repo).finally(() => this.metaInFlight.delete(key));
288
+ this.metaInFlight.set(key, p);
289
+ }
290
+ return p;
291
+ }
292
+ async loadRepoMeta(repo) {
293
+ const cached = this.store.getRepo(repo);
294
+ if (cached?.fetched_at && Date.now() - Date.parse(cached.fetched_at) < DAY_MS)
295
+ return cached;
296
+ if (!this.coreAvailable(this.hasToken ? 100 : 10))
297
+ return cached ?? null;
298
+ try {
299
+ const res = await this.api(`/repos/${repo}`, { etag: cached?.etag });
300
+ if (res.status === 304 && cached) {
301
+ this.store.touchRepo(cached.full_name);
302
+ return { ...cached, fetched_at: new Date().toISOString() };
303
+ }
304
+ if (!res.ok) {
305
+ await res.body?.cancel().catch(() => { });
306
+ // A deleted or private repo is stored as "stars unknown" for a day, as the GraphQL path does for
307
+ // NOT_FOUND, instead of being asked again in every search (perf debate 1-2).
308
+ if (res.status === 404 && cached?.stars == null) {
309
+ const row = { full_name: cached?.full_name ?? repo, stars: null, pushed_at: null, archived: null, fork: null, license: null, default_branch: null, etag: null, fetched_at: new Date().toISOString() };
310
+ this.store.upsertRepo(row);
311
+ return row;
312
+ }
313
+ return cached ?? null;
314
+ }
315
+ const j = (await res.json());
316
+ const row = {
317
+ full_name: cached?.full_name ?? repo,
318
+ stars: j.stargazers_count,
319
+ pushed_at: j.pushed_at,
320
+ archived: j.archived ? 1 : 0,
321
+ fork: j.fork ? 1 : 0,
322
+ license: j.license?.spdx_id ?? null,
323
+ default_branch: j.default_branch,
324
+ etag: res.headers.get('etag'),
325
+ fetched_at: new Date().toISOString(),
326
+ };
327
+ this.store.upsertRepo(row);
328
+ return row;
329
+ }
330
+ catch {
331
+ return cached ?? null;
332
+ }
333
+ }
334
+ /**
335
+ * Star counts and metadata for up to GRAPHQL_BATCH repos in one GraphQL call (~1 point of the 5,000/hour
336
+ * budget, against one REST call each). Repos GitHub reports as not found are stored with no stars and no
337
+ * default branch, so they are not asked about again until the next refresh. Returns how many were stored.
338
+ */
339
+ async refreshRepoMeta(repos) {
340
+ if (!this.hasToken)
341
+ return 0;
342
+ this.checkLimit('graphql');
343
+ const list = repos.filter((r) => REPO_RE.test(r)).slice(0, GRAPHQL_BATCH);
344
+ if (list.length === 0)
345
+ return 0;
346
+ const vars = {};
347
+ const defs = [];
348
+ const fields = [];
349
+ list.forEach((r, i) => {
350
+ const [owner, name] = r.split('/');
351
+ vars[`o${i}`] = owner;
352
+ vars[`n${i}`] = name;
353
+ defs.push(`$o${i}: String!, $n${i}: String!`);
354
+ fields.push(`r${i}: repository(owner: $o${i}, name: $n${i}) { stargazerCount pushedAt isArchived isFork licenseInfo { spdxId } defaultBranchRef { name } }`);
355
+ });
356
+ const res = await fetch(`${API}/graphql`, {
357
+ method: 'POST',
358
+ headers: this.headers({ 'Content-Type': 'application/json' }),
359
+ body: JSON.stringify({ query: `query(${defs.join(', ')}) { ${fields.join(' ')} }`, variables: vars }),
360
+ signal: AbortSignal.timeout(30_000),
361
+ });
362
+ const remaining = res.headers.get('x-ratelimit-remaining');
363
+ if (remaining != null)
364
+ this.limits.set('graphql', { remaining: Number(remaining), reset: Number(res.headers.get('x-ratelimit-reset')) || 0 });
365
+ if (!res.ok)
366
+ throw new GitHubError(`GitHub GraphQL 실패 (${res.status})`, res.status);
367
+ const body = (await res.json());
368
+ if ((body.errors ?? []).some((e) => e.type === 'RATE_LIMITED'))
369
+ throw new GitHubError('GitHub GraphQL 한도 초과', 429);
370
+ const notFound = new Set((body.errors ?? []).filter((e) => e.type === 'NOT_FOUND').map((e) => String(e.path?.[0] ?? '')));
371
+ const at = new Date().toISOString();
372
+ let stored = 0;
373
+ list.forEach((repo, i) => {
374
+ const g = body.data?.[`r${i}`];
375
+ const cached = this.store.getRepo(repo);
376
+ const base = { full_name: cached?.full_name ?? repo, etag: cached?.etag ?? null, fetched_at: at };
377
+ if (g && typeof g.stargazerCount === 'number') {
378
+ this.store.upsertRepo({
379
+ ...base,
380
+ stars: g.stargazerCount,
381
+ pushed_at: typeof g.pushedAt === 'string' ? g.pushedAt : null,
382
+ archived: g.isArchived ? 1 : 0,
383
+ fork: g.isFork ? 1 : 0,
384
+ license: typeof g.licenseInfo?.spdxId === 'string' ? g.licenseInfo.spdxId : null,
385
+ default_branch: typeof g.defaultBranchRef?.name === 'string' ? g.defaultBranchRef.name : null,
386
+ });
387
+ stored++;
388
+ }
389
+ else if (notFound.has(`r${i}`)) {
390
+ if (cached?.stars != null) {
391
+ // known before (the token may just not see it now): keep what we had, ask again next refresh
392
+ this.store.touchRepo(cached.full_name);
393
+ }
394
+ else {
395
+ // deleted or private and never seen: remembered as unknown ("모르면 숨김") instead of asked every search
396
+ this.store.upsertRepo({ ...base, stars: null, pushed_at: null, archived: null, fork: null, license: null, default_branch: null });
397
+ }
398
+ stored++;
399
+ }
400
+ // any other error (a timeout, a permission problem) leaves the row as it was
401
+ });
402
+ return stored;
403
+ }
404
+ /**
405
+ * One answer per key for SEARCH_CACHE_MS and one request in flight per key: the same keywords from the
406
+ * "별 미만 보기"/"위험 모두 보기" re-search, a repeat search, or after a sync no longer spend the 10/min
407
+ * code-search quota. Kept apart from the result cache (`search:`), which syncs clear.
408
+ */
409
+ cachedSearch(key, load) {
410
+ const cached = this.store.getCached(key, SEARCH_CACHE_MS);
411
+ if (cached !== undefined)
412
+ return Promise.resolve(cached);
413
+ let p = this.searchInFlight.get(key);
414
+ if (!p) {
415
+ p = load()
416
+ .then(({ value, complete }) => {
417
+ if (complete)
418
+ this.store.putCached(key, value);
419
+ return value;
420
+ })
421
+ .finally(() => this.searchInFlight.delete(key));
422
+ this.searchInFlight.set(key, p);
423
+ }
424
+ return p;
425
+ }
426
+ /** `filename:SKILL.md` code search. Requires a token (10/min, we use 9). */
427
+ async searchSkillFiles(keywords, perPage = 50, opts = {}) {
428
+ if (!this.hasToken)
429
+ throw new GitHubError('GitHub 토큰이 없어 코드 검색을 건너뜁니다', 401);
430
+ const terms = keywords
431
+ .map((k) => k.replace(/[^\p{L}\p{N}\s_-]/gu, ' ').trim())
432
+ .filter(Boolean)
433
+ .map((k) => (/\s/.test(k) ? `"${k}"` : k));
434
+ if (terms.length === 0)
435
+ return [];
436
+ const q = ['filename:SKILL.md', ...terms].join(' ');
437
+ return this.cachedSearch(`ghcode:v1:${perPage}:${q.toLowerCase()}`, async () => {
438
+ const res = await this.api(`/search/code?q=${encodeURIComponent(q)}&per_page=${perPage}`, {
439
+ bucket: this.codeSearch,
440
+ resource: 'code_search',
441
+ maxWaitMs: opts.maxWaitMs,
442
+ });
443
+ if (!res.ok) {
444
+ const text = await res.text().catch(() => '');
445
+ throw new GitHubError(`GitHub 코드 검색 실패 (${res.status}) ${text.slice(0, 120)}`, res.status);
446
+ }
447
+ const body = (await res.json());
448
+ // The repo and path go into raw URLs and skill ids later, so only well-formed ones are kept.
449
+ const value = body.items
450
+ .filter((i) => i.path === 'SKILL.md' || i.path.endsWith('/SKILL.md'))
451
+ .filter((i) => REPO_RE.test(i.repository?.full_name ?? '') && isSafeRepoPath(i.path))
452
+ .map((i) => ({ repo: i.repository.full_name, path: i.path, htmlUrl: i.html_url }));
453
+ // a search GitHub cut short is used once, not kept
454
+ return { value, complete: body.incomplete_results !== true };
455
+ });
456
+ }
457
+ /**
458
+ * Repository search (unlike code search it supports `stars:>=N`). Up to 100 per query, most-starred first;
459
+ * star counts are stored so later filters do not need another call.
460
+ */
461
+ /** `fresh`: ask GitHub even if a cached answer exists (popular-repo collection looks for new repos). */
462
+ async searchRepos(q, perPage = 100, opts = {}) {
463
+ if (opts.fresh)
464
+ return (await this.loadRepos(q, perPage, opts.maxWaitMs)).value;
465
+ return this.cachedSearch(`ghrepo:v1:${this.hasToken ? 't' : 'n'}:${perPage}:${q.toLowerCase()}`, () => this.loadRepos(q, perPage, opts.maxWaitMs));
466
+ }
467
+ async loadRepos(q, perPage, maxWaitMs) {
468
+ const res = await this.api(`/search/repositories?q=${encodeURIComponent(q)}&sort=stars&order=desc&per_page=${perPage}`, {
469
+ bucket: this.search,
470
+ resource: 'search',
471
+ maxWaitMs,
472
+ });
473
+ if (!res.ok) {
474
+ const text = await res.text().catch(() => '');
475
+ throw new GitHubError(`GitHub 저장소 검색 실패 (${res.status}) ${text.slice(0, 120)}`, res.status);
476
+ }
477
+ const body = (await res.json());
478
+ const out = [];
479
+ for (const i of body.items) {
480
+ if (!REPO_RE.test(i.full_name) || i.archived)
481
+ continue;
482
+ const cached = this.store.getRepo(i.full_name);
483
+ this.store.upsertRepo({
484
+ full_name: cached?.full_name ?? i.full_name,
485
+ stars: i.stargazers_count,
486
+ pushed_at: i.pushed_at,
487
+ archived: i.archived ? 1 : 0,
488
+ fork: i.fork ? 1 : 0,
489
+ license: i.license?.spdx_id ?? null,
490
+ default_branch: i.default_branch,
491
+ etag: cached?.etag ?? null,
492
+ fetched_at: new Date().toISOString(),
493
+ });
494
+ out.push({
495
+ repo: i.full_name,
496
+ stars: i.stargazers_count,
497
+ description: i.description ? sanitizeLine(i.description).slice(0, 300) : null,
498
+ topics: (i.topics ?? []).filter((t) => /^[a-z0-9-]{1,50}$/.test(t)).slice(0, 20),
499
+ });
500
+ }
501
+ return { value: out, complete: body.incomplete_results !== true };
502
+ }
503
+ }
@@ -0,0 +1,78 @@
1
+ import { parseMarketplace, MARKETPLACE_JSON } from "../discovery/classifyRepo.js";
2
+ import { sanitizeLine } from "../util/sanitize.js";
3
+ import { normalizeSource, sourceColumns } from "./pluginSource.js";
4
+ const textOrNull = (v) => (typeof v === 'string' && v.trim() ? sanitizeLine(v) : null);
5
+ /** Reads a marketplace.json at the repo's current head. Used both for sync and for "add marketplace". */
6
+ export async function fetchMarketplaceManifest(github, repo) {
7
+ const ref = await github.resolveRef(repo, null);
8
+ if (!ref)
9
+ throw new Error(`저장소를 찾을 수 없습니다: ${repo}`);
10
+ const res = await github.getRawText(repo, ref.sha, MARKETPLACE_JSON, 32 * 1024 * 1024);
11
+ if (res.text == null)
12
+ throw new Error(`marketplace.json을 가져오지 못했습니다 (${res.status})`);
13
+ if (res.truncated)
14
+ throw new Error('marketplace.json이 너무 큽니다');
15
+ return { sha: ref.sha, etag: res.etag, manifest: parseMarketplace(res.text) };
16
+ }
17
+ /**
18
+ * Upserts plugins from marketplace.json. Skips the download when the repo head commit is unchanged.
19
+ * Relative-path plugins get the marketplace commit as their `sha`, so a new commit re-queues them for indexing.
20
+ */
21
+ export async function syncMarketplace(store, github, name, force = false) {
22
+ const mp = store.getMarketplace(name);
23
+ if (!mp)
24
+ throw new Error(`unknown marketplace ${name}`);
25
+ try {
26
+ const head = await github.resolveRef(mp.repo, null);
27
+ if (!head)
28
+ throw new Error(`저장소를 찾을 수 없습니다: ${mp.repo}`);
29
+ if (!force && head.sha === mp.commit_sha && mp.synced_at && store.listPlugins(name).length > 0) {
30
+ store.updateMarketplaceSync(name, {});
31
+ return { name, changed: false, pluginCount: mp.plugin_count ?? 0 };
32
+ }
33
+ const { sha, etag, manifest } = await fetchMarketplaceManifest(github, mp.repo);
34
+ const pluginRoot = manifest.metadata?.pluginRoot ?? null;
35
+ store.transaction(() => {
36
+ const keep = new Set();
37
+ for (const entry of manifest.plugins) {
38
+ const id = `${entry.name}@${name}`;
39
+ keep.add(id);
40
+ const src = normalizeSource(entry.source, pluginRoot);
41
+ const cols = sourceColumns(src, mp.repo);
42
+ if (src.kind === 'relative')
43
+ cols.sha = sha;
44
+ const keywords = [
45
+ ...(Array.isArray(entry.keywords) ? entry.keywords : []),
46
+ ...(Array.isArray(entry.tags) ? entry.tags : []),
47
+ ].filter((k) => typeof k === 'string');
48
+ const stored = { entry, pluginRoot };
49
+ store.upsertPlugin({
50
+ id,
51
+ marketplace: name,
52
+ name: entry.name,
53
+ display_name: textOrNull(entry.displayName),
54
+ description: textOrNull(entry.description),
55
+ category: textOrNull(entry.category),
56
+ keywords: keywords.length ? sanitizeLine(keywords.join(' ')) : null,
57
+ source_json: JSON.stringify(entry.source ?? null),
58
+ source_kind: src.kind,
59
+ repo: cols.repo,
60
+ path: cols.path,
61
+ ref: cols.ref,
62
+ sha: cols.sha,
63
+ strict: typeof entry.strict === 'boolean' ? (entry.strict ? 1 : 0) : null,
64
+ entry_json: JSON.stringify(stored),
65
+ });
66
+ }
67
+ for (const p of store.listPlugins(name))
68
+ if (!keep.has(p.id))
69
+ store.deletePlugin(p.id);
70
+ });
71
+ store.updateMarketplaceSync(name, { etag, plugin_count: manifest.plugins.length, commit_sha: sha, error: null });
72
+ return { name, changed: true, pluginCount: manifest.plugins.length };
73
+ }
74
+ catch (err) {
75
+ store.updateMarketplaceSync(name, { error: String(err.message ?? err).slice(0, 300) });
76
+ throw err;
77
+ }
78
+ }
@@ -0,0 +1,98 @@
1
+ import { resolveRel } from "../discovery/tree.js";
2
+ const OWNER_REPO_RE = /^[A-Za-z0-9-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
3
+ /** owner/repo from a GitHub URL (https, ssh, git@) or `owner/repo` shorthand; null for other hosts. */
4
+ export function parseGithubRepo(input) {
5
+ const s = input.trim();
6
+ if (OWNER_REPO_RE.test(s))
7
+ return s.replace(/\.git$/, '');
8
+ const m = s.match(/^(?:https?:\/\/|git\+https:\/\/|ssh:\/\/git@)(?:www\.)?github\.com[/:]([^/]+)\/([^/#?]+?)(?:\.git)?\/?(?:[#?].*)?$/i) ??
9
+ s.match(/^git@github\.com:([^/]+)\/([^/#?]+?)(?:\.git)?$/i);
10
+ if (!m)
11
+ return null;
12
+ const repo = `${m[1]}/${m[2]}`;
13
+ return OWNER_REPO_RE.test(repo) ? repo : null;
14
+ }
15
+ const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
16
+ const SHA_RE = /^[0-9a-f]{40}$/i;
17
+ const sha = (v) => {
18
+ const s = str(v);
19
+ return s && SHA_RE.test(s) ? s.toLowerCase() : null;
20
+ };
21
+ const cleanPath = (v) => {
22
+ const s = str(v);
23
+ if (!s)
24
+ return null;
25
+ const r = resolveRel('', s);
26
+ return r === '' ? null : r;
27
+ };
28
+ /** Normalizes a marketplace entry `source` (Claude Code marketplace reference). */
29
+ export function normalizeSource(source, pluginRoot) {
30
+ if (typeof source === 'string') {
31
+ const raw = source;
32
+ const isBare = !raw.startsWith('./') && raw !== '.' && !raw.startsWith('../');
33
+ const base = isBare && pluginRoot ? (resolveRel('', pluginRoot) ?? '') : '';
34
+ const path = resolveRel(base, raw);
35
+ if (path == null)
36
+ return { kind: 'unknown', raw: source };
37
+ return { kind: 'relative', raw, path };
38
+ }
39
+ if (!source || typeof source !== 'object')
40
+ return { kind: 'unknown', raw: source };
41
+ const s = source;
42
+ switch (s.source) {
43
+ case 'github': {
44
+ const repo = str(s.repo);
45
+ if (!repo || !OWNER_REPO_RE.test(repo))
46
+ return { kind: 'unknown', raw: source };
47
+ return { kind: 'github', repo, ref: str(s.ref), sha: sha(s.sha), path: cleanPath(s.path) };
48
+ }
49
+ case 'url': {
50
+ const url = str(s.url);
51
+ if (!url)
52
+ return { kind: 'unknown', raw: source };
53
+ return { kind: 'url', url, repo: parseGithubRepo(url), ref: str(s.ref), sha: sha(s.sha), path: cleanPath(s.path) };
54
+ }
55
+ case 'git-subdir': {
56
+ const url = str(s.url);
57
+ const path = cleanPath(s.path);
58
+ if (!url || !path)
59
+ return { kind: 'unknown', raw: source };
60
+ return { kind: 'git-subdir', url, repo: parseGithubRepo(url), ref: str(s.ref), sha: sha(s.sha), path };
61
+ }
62
+ case 'npm':
63
+ case 'archive':
64
+ case 'command':
65
+ return { kind: s.source, raw: source };
66
+ default:
67
+ return { kind: 'unknown', raw: source };
68
+ }
69
+ }
70
+ /**
71
+ * Plugin root for a relative source once the marketplace tree is known: a `./` path with `metadata.pluginRoot`
72
+ * prefers `<pluginRoot>/<path>` when that folder exists, else the marketplace-root reading.
73
+ */
74
+ export function resolveRelativeRoot(raw, pluginRoot, tree) {
75
+ const isBare = !raw.startsWith('./') && raw !== '.' && !raw.startsWith('../');
76
+ const root = pluginRoot ? resolveRel('', pluginRoot) : null;
77
+ if (isBare)
78
+ return resolveRel(root ?? '', raw);
79
+ if (root) {
80
+ const withRoot = resolveRel(root, raw);
81
+ if (withRoot != null && withRoot !== root && tree.hasDir(withRoot))
82
+ return withRoot;
83
+ }
84
+ return resolveRel('', raw);
85
+ }
86
+ export function sourceColumns(src, marketplaceRepo) {
87
+ switch (src.kind) {
88
+ case 'relative':
89
+ return { repo: marketplaceRepo, path: src.path || null, ref: null, sha: null };
90
+ case 'github':
91
+ return { repo: src.repo, path: src.path, ref: src.ref, sha: src.sha };
92
+ case 'url':
93
+ case 'git-subdir':
94
+ return { repo: src.repo, path: src.path, ref: src.ref, sha: src.sha };
95
+ default:
96
+ return { repo: null, path: null, ref: null, sha: null };
97
+ }
98
+ }