@seojin08/skill-finder 0.3.2 → 0.3.4
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 +20 -0
- package/README.md +2 -2
- package/bin/skill-finder.mjs +4 -4
- package/package.json +4 -3
- package/seed-index.json.gz +0 -0
- package/server/src/app.js +3 -0
- package/server/src/bootstrap.js +2 -1
- package/server/src/config.js +16 -4
- package/server/src/db/schema.js +3 -0
- package/server/src/db/seed.js +89 -0
- package/server/src/db/store.js +4 -3
- package/server/src/discovery/genericRules.js +3 -1
- package/server/src/index.js +11 -0
- package/server/src/indexer.js +5 -1
- package/server/src/install/installed.js +96 -26
- package/server/src/recommend/readme.js +14 -2
- package/server/src/routes/favorites.js +55 -10
- package/server/src/routes/install.js +33 -7
- package/server/src/routes/translate.js +35 -0
- package/server/src/search/pipeline.js +3 -1
- package/server/src/search/result.js +9 -1
- package/server/src/security.js +4 -1
- package/server/src/sources/gitTree.js +11 -1
- package/server/src/sources/github.js +17 -2
- package/server/src/tasks.js +45 -5
- package/server/src/translate/models.js +140 -0
- package/web/dist/THIRD-PARTY-LICENSES.txt +6 -0
- package/web/dist/assets/{ArasakaGame-C1Wkmqik.js → ArasakaGame-B2qY9Lds.js} +2 -2
- package/web/dist/assets/__vite-browser-external-BDfy7Drd.js +1 -0
- package/web/dist/assets/index-BJ6vm3xw.js +66 -0
- package/web/dist/assets/index-BiikysiH.css +2 -0
- package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/web/dist/assets/translator-Cyk_ZOaM.js +2 -0
- package/web/dist/bergamot/bergamot-translator-worker.js +2919 -0
- package/web/dist/bergamot/bergamot-translator-worker.wasm +0 -0
- package/web/dist/bergamot/translator-worker.js +475 -0
- package/web/dist/index.html +3 -2
- package/web/dist/assets/index-BgL1OaU4.css +0 -2
- package/web/dist/assets/index-Ec2XBK4-.js +0 -66
|
@@ -1,14 +1,34 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { installSummary } from "../install/summary.js";
|
|
4
|
+
import { isRepo } from "../install/validate.js";
|
|
5
|
+
import { indexItem, storeLookup } from "../search/result.js";
|
|
6
|
+
import { sanitizeLine } from "../util/sanitize.js";
|
|
5
7
|
const FavoriteBody = z.object({ id: z.string().min(1).max(400) });
|
|
6
8
|
/**
|
|
7
9
|
* `name`: a search result not in the index yet (still being checked) is kept by it until it is. Unusable (empty,
|
|
8
10
|
* not text) it is left out rather than refused, so an indexed skill still gets its star.
|
|
9
11
|
*/
|
|
10
|
-
const FavoriteAdd = FavoriteBody.extend({ name: z.unknown().optional() });
|
|
12
|
+
const FavoriteAdd = FavoriteBody.extend({ name: z.unknown().optional(), repo: z.unknown().optional() });
|
|
11
13
|
const usableName = (v) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, 200) : undefined);
|
|
14
|
+
/** A favorite GitHub repository (user request 2026-11-03): `repo:` and the name in lower case, so one per repo. */
|
|
15
|
+
const REPO_PREFIX = 'repo:';
|
|
16
|
+
/** What the search showed of the repo; anything unusable is left out rather than refused. */
|
|
17
|
+
function repoMeta(v) {
|
|
18
|
+
const o = (v && typeof v === 'object' ? v : {});
|
|
19
|
+
const description = typeof o.description === 'string' && o.description.trim() ? sanitizeLine(o.description).slice(0, 500) : null;
|
|
20
|
+
const topics = Array.isArray(o.topics) ? o.topics.filter((x) => typeof x === 'string' && /^[a-z0-9][a-z0-9-]{0,49}$/.test(x)).slice(0, 30) : [];
|
|
21
|
+
return { description, topics };
|
|
22
|
+
}
|
|
23
|
+
/** One damaged row must not take the whole favorites list down with it. */
|
|
24
|
+
function parseJson(text) {
|
|
25
|
+
try {
|
|
26
|
+
return text ? JSON.parse(text) : null;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
12
32
|
/**
|
|
13
33
|
* This PC's favorite skills (user decision 2026-11-02). No accounts: the app runs for one person on 127.0.0.1, so a
|
|
14
34
|
* login kept only here protected nothing. The API's header and same-origin checks apply as everywhere.
|
|
@@ -19,28 +39,52 @@ export function favoritesRouter(ctx) {
|
|
|
19
39
|
r.get('/favorites/ids', (_req, res) => {
|
|
20
40
|
res.json({ ids: store.listFavorites().map((f) => f.skill_id) });
|
|
21
41
|
});
|
|
22
|
-
/** The favorites as result cards;
|
|
42
|
+
/** The favorites as result cards and repository cards; skills that left the index are listed by name. */
|
|
23
43
|
r.get('/favorites', (_req, res) => {
|
|
24
44
|
const lookup = storeLookup(store);
|
|
25
45
|
const items = [];
|
|
46
|
+
const repos = [];
|
|
26
47
|
const gone = [];
|
|
27
48
|
for (const f of store.listFavorites()) {
|
|
49
|
+
if (f.skill_id.startsWith(REPO_PREFIX)) {
|
|
50
|
+
const repo = f.skill_name;
|
|
51
|
+
const meta = repoMeta(parseJson(f.meta_json));
|
|
52
|
+
repos.push({
|
|
53
|
+
repo,
|
|
54
|
+
url: `https://github.com/${repo}`,
|
|
55
|
+
...meta,
|
|
56
|
+
stars: store.getRepo(repo)?.stars ?? null,
|
|
57
|
+
// as the search's repository card lists them
|
|
58
|
+
skills: store
|
|
59
|
+
.findSkillsByRepo(repo)
|
|
60
|
+
.filter((s) => s.description && s.origin !== 'wellknown')
|
|
61
|
+
.slice(0, 30)
|
|
62
|
+
.map((s) => installSummary(s, lookup)),
|
|
63
|
+
});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
28
66
|
const s = store.getSkill(f.skill_id);
|
|
29
67
|
if (!s) {
|
|
30
68
|
gone.push({ id: f.skill_id, name: f.skill_name });
|
|
31
69
|
continue;
|
|
32
70
|
}
|
|
33
|
-
|
|
34
|
-
const source = plugin ? marketplaceSource(s, plugin) : githubSource(s);
|
|
35
|
-
const breakdown = { R: 0, P: 0, F: freshness(s.last_commit_at), T: TRUST_SCORE[trustTier(s, plugin)] };
|
|
36
|
-
items.push(buildResultItem({ skill: s, lookup, sources: source ? [source] : [], installs: freshInstalls(s), stars: s.repo ? (store.getRepo(s.repo)?.stars ?? null) : null, breakdown, score: 0 }));
|
|
71
|
+
items.push(indexItem(store, lookup, s));
|
|
37
72
|
}
|
|
38
|
-
res.json({ items, gone });
|
|
73
|
+
res.json({ items, repos, gone });
|
|
39
74
|
});
|
|
40
75
|
r.post('/favorites', (req, res) => {
|
|
41
76
|
const parsed = FavoriteAdd.safeParse(req.body);
|
|
42
77
|
if (!parsed.success)
|
|
43
78
|
return void res.status(400).json({ error: '스킬 id가 없습니다' });
|
|
79
|
+
if (parsed.data.id.startsWith(REPO_PREFIX)) {
|
|
80
|
+
const repo = parsed.data.id.slice(REPO_PREFIX.length);
|
|
81
|
+
if (!isRepo(repo))
|
|
82
|
+
return void res.status(400).json({ error: '저장소 이름이 올바르지 않습니다' });
|
|
83
|
+
// the name as GitHub writes it (morluto/rea), when it is a repo name and the same repo
|
|
84
|
+
const name = usableName(parsed.data.name);
|
|
85
|
+
store.addFavorite(REPO_PREFIX + repo.toLowerCase(), isRepo(name) && name.toLowerCase() === repo.toLowerCase() ? name : repo, repoMeta(parsed.data.repo));
|
|
86
|
+
return void res.json({ ok: true });
|
|
87
|
+
}
|
|
44
88
|
const skill = store.getSkill(parsed.data.id);
|
|
45
89
|
// not indexed: listed by name on the favorites page, as a card once the index has it
|
|
46
90
|
const name = skill?.name ?? usableName(parsed.data.name);
|
|
@@ -54,7 +98,8 @@ export function favoritesRouter(ctx) {
|
|
|
54
98
|
const parsed = FavoriteBody.safeParse({ id: req.query.id });
|
|
55
99
|
if (!parsed.success)
|
|
56
100
|
return void res.status(400).json({ error: '스킬 id가 없습니다' });
|
|
57
|
-
|
|
101
|
+
// repository favorites are kept in lower case (POST)
|
|
102
|
+
store.removeFavorite(parsed.data.id.startsWith(REPO_PREFIX) ? parsed.data.id.toLowerCase() : parsed.data.id);
|
|
58
103
|
res.json({ ok: true });
|
|
59
104
|
});
|
|
60
105
|
return r;
|
|
@@ -5,7 +5,8 @@ import { Router } from 'express';
|
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { buildSteps, planInstall } from "../install/plan.js";
|
|
7
7
|
import { executeInstall } from "../install/run.js";
|
|
8
|
-
import { existingSkillFolder, forgetPlugins, installedPluginCommits, listInstalledPlugins, pluginCommitKey, pluginIndexInfo, pluginSkills, scanInstalledSkills } from "../install/installed.js";
|
|
8
|
+
import { existingSkillFolder, forgetPlugins, installedPluginCommits, listInstalledPlugins, pathKey, pluginCommitKey, pluginIndexInfo, pluginSkills, scanInstalledSkills, scanPluginSkills } from "../install/installed.js";
|
|
9
|
+
import { indexItem, storeLookup } from "../search/result.js";
|
|
9
10
|
import { uninstallPlugin, uninstallSkill } from "../install/uninstall.js";
|
|
10
11
|
import { isProjectDir } from "../install/validate.js";
|
|
11
12
|
import { getSettings } from "../settings.js";
|
|
@@ -241,7 +242,8 @@ export function installRouter(ctx) {
|
|
|
241
242
|
}
|
|
242
243
|
const listed = await listInstalledPlugins(projectPath ?? homedir(), true);
|
|
243
244
|
const commits = installedPluginCommits();
|
|
244
|
-
|
|
245
|
+
// with a folder, another project's project and local plugins are not this one's (user report 2026-11-04)
|
|
246
|
+
const plugins = listed.plugins.filter((o) => !projectPath || (o.scope !== 'project' && o.scope !== 'local') || pathKey(o.projectPath ?? '') === pathKey(projectPath)).map((o) => {
|
|
245
247
|
const { scope, id: pid } = o;
|
|
246
248
|
const ownProject = o.projectPath;
|
|
247
249
|
// `claude plugin list` reports enabled for the folder it runs in; read the settings file that owns this install instead.
|
|
@@ -263,19 +265,43 @@ export function installRouter(ctx) {
|
|
|
263
265
|
...(await scanInstalledSkills(store, 'user', null)),
|
|
264
266
|
...(projectPath ? await scanInstalledSkills(store, 'project', projectPath) : []),
|
|
265
267
|
];
|
|
266
|
-
|
|
268
|
+
// The skills of the plugins that apply here: everyone's (user, synced) and this folder's. Without a folder the
|
|
269
|
+
// project plugins listed above belong to other folders. One row per folder: a plugin installed for the user and
|
|
270
|
+
// for a project shares its install folder (code review 2026-11-04).
|
|
271
|
+
const applying = plugins.filter((p) => (p.scope !== 'project' && p.scope !== 'local') || projectPath);
|
|
272
|
+
const seen = new Set();
|
|
273
|
+
const pluginSkillRows = (await Promise.all(applying.map((p) => scanPluginSkills(store, { ...p, enabled: typeof p.enabled === 'boolean' ? p.enabled : null }))))
|
|
274
|
+
.flat()
|
|
275
|
+
.filter((s) => !seen.has(s.path) && seen.add(s.path));
|
|
276
|
+
// the index's card for each indexed one: provider, category and install method for the side filters
|
|
277
|
+
const lookup = storeLookup(store);
|
|
278
|
+
const items = {};
|
|
279
|
+
for (const s of [...skills, ...pluginSkillRows]) {
|
|
280
|
+
const row = s.skillId ? store.getSkill(s.skillId) : undefined;
|
|
281
|
+
if (row && !items[row.id])
|
|
282
|
+
items[row.id] = indexItem(store, lookup, row);
|
|
283
|
+
}
|
|
284
|
+
res.json({ plugins, pluginError: listed.error, skills, pluginSkills: pluginSkillRows, items, projects: store.listInstalledProjects() });
|
|
267
285
|
});
|
|
268
286
|
// What is installed, by index id, for the "설치됨" badges on results (UI debate 3-1). Project skills are those of
|
|
269
|
-
// the
|
|
270
|
-
|
|
271
|
-
|
|
287
|
+
// `projectPath` (the folder README 추천 analyzed, user request 2026-11-04), else of the default project folder in
|
|
288
|
+
// the settings; with `projectPath`, project and local plugins of other folders are left out. Unknown ones are
|
|
289
|
+
// simply absent: no badge, never "not installed".
|
|
290
|
+
r.get('/installed/ids', async (req, res) => {
|
|
291
|
+
const asked = typeof req.query.projectPath === 'string' && req.query.projectPath.trim() ? req.query.projectPath.trim() : null;
|
|
292
|
+
if (asked && !isProjectDir(asked)) {
|
|
293
|
+
res.status(400).json({ error: '프로젝트 경로는 존재하는 디렉터리의 절대경로여야 합니다' });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const project = asked ?? getSettings(store).defaultProjectPath;
|
|
272
297
|
const listed = await listInstalledPlugins();
|
|
273
298
|
const skills = [
|
|
274
299
|
...(await scanInstalledSkills(store, 'user', null)),
|
|
275
300
|
...(isProjectDir(project) ? await scanInstalledSkills(store, 'project', project) : []),
|
|
276
301
|
];
|
|
302
|
+
const inFolder = (p) => !asked || (p.scope !== 'project' && p.scope !== 'local') || pathKey(p.projectPath ?? '') === pathKey(asked);
|
|
277
303
|
res.json({
|
|
278
|
-
plugins: listed.plugins.map((p) => ({ id: p.id, scope: p.scope })),
|
|
304
|
+
plugins: listed.plugins.filter(inFolder).map((p) => ({ id: p.id, scope: p.scope })),
|
|
279
305
|
skills: skills.filter((s) => s.skillId).map((s) => ({ id: s.skillId, scope: s.scope })),
|
|
280
306
|
});
|
|
281
307
|
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import express, { Router } from 'express';
|
|
2
|
+
import { MODEL_LANGS, MODEL_URL_PATH } from "../translate/models.js";
|
|
3
|
+
const langOf = (v) => (MODEL_LANGS.includes(v) ? v : null);
|
|
4
|
+
/** The translation model for browsers without a built-in translator: its state, and the one-time download. */
|
|
5
|
+
export function translateRouter(ctx) {
|
|
6
|
+
const r = Router();
|
|
7
|
+
r.get('/translate/models/:lang', (req, res) => {
|
|
8
|
+
const lang = langOf(req.params.lang);
|
|
9
|
+
if (!lang)
|
|
10
|
+
return void res.status(404).json({ error: '번역 모델이 없는 언어입니다' });
|
|
11
|
+
res.json(ctx.translateModels.status(lang));
|
|
12
|
+
});
|
|
13
|
+
r.post('/translate/models/:lang', (req, res) => {
|
|
14
|
+
const lang = langOf(req.params.lang);
|
|
15
|
+
if (!lang)
|
|
16
|
+
return void res.status(404).json({ error: '번역 모델이 없는 언어입니다' });
|
|
17
|
+
res.json(ctx.translateModels.start(lang));
|
|
18
|
+
});
|
|
19
|
+
return r;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The downloaded files and the engine's registry, outside /api: the engine fetches them without the API header.
|
|
23
|
+
* Read only; the registry's addresses carry each file's hash (?v=), so the files are cached for good.
|
|
24
|
+
*/
|
|
25
|
+
export function translateFiles(ctx) {
|
|
26
|
+
const r = Router();
|
|
27
|
+
r.get(`${MODEL_URL_PATH}/index.json`, (_req, res) => {
|
|
28
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
29
|
+
res.json(ctx.translateModels.registry());
|
|
30
|
+
});
|
|
31
|
+
r.use(MODEL_URL_PATH, express.static(ctx.translateModels.root, { index: false, dotfiles: 'deny', maxAge: '30d', immutable: true }));
|
|
32
|
+
// a file that is not there is a 404, not the page's index.html (the engine saw that only as an integrity error)
|
|
33
|
+
r.use(MODEL_URL_PATH, (_req, res) => void res.status(404).end());
|
|
34
|
+
return r;
|
|
35
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { RISK_ORDER, RISK_RULES_VERSION } from "../analyze/risk.js";
|
|
3
|
+
import { SKILLS_CLI_VERSION } from "../config.js";
|
|
3
4
|
import { basename } from "../discovery/tree.js";
|
|
4
5
|
import { normalizeSkillName, PluginUnavailableError, skillId } from "../indexer.js";
|
|
5
6
|
import { installSummary } from "../install/summary.js";
|
|
@@ -132,7 +133,8 @@ export async function runSearch(deps, query, filters, emit) {
|
|
|
132
133
|
const sources = new Set(filters.sources?.length ? filters.sources : ['marketplace', 'skillssh', 'github']);
|
|
133
134
|
const maxRisk = filters.maxRisk ?? 'medium';
|
|
134
135
|
// `r` is the result-item shape; bump it when items gain fields so cached searches are rebuilt.
|
|
135
|
-
|
|
136
|
+
// c: cached results carry install commands naming the skills CLI version (`npx skills@x.y.z …`)
|
|
137
|
+
const key = cacheKey(query, filters, { w: settings.weights, t: !!ctx.config.githubToken, s: minStars, r: RESULTS_VERSION, k: RISK_RULES_VERSION, c: SKILLS_CLI_VERSION });
|
|
136
138
|
// The English query for a Korean one goes out first, cached search or not: it comes from the query alone,
|
|
137
139
|
// and the button helps most exactly when there is little else to show.
|
|
138
140
|
const suggestion = koreanSuggestion(query);
|
|
@@ -2,7 +2,7 @@ import { RISK_ORDER } from "../analyze/risk.js";
|
|
|
2
2
|
import { planInstall } from "../install/plan.js";
|
|
3
3
|
import { truncate } from "../util/sanitize.js";
|
|
4
4
|
import { categorize } from "./category.js";
|
|
5
|
-
import { trustTier } from "./rank.js";
|
|
5
|
+
import { freshInstalls, freshness, TRUST_SCORE, trustTier } from "./rank.js";
|
|
6
6
|
export const TRUST_LABEL = { official: '공식', partner: '파트너', community: '커뮤니티' };
|
|
7
7
|
export function treeUrl(repo, ref, dir) {
|
|
8
8
|
const base = `https://github.com/${repo}`;
|
|
@@ -81,6 +81,14 @@ export function storeLookup(store) {
|
|
|
81
81
|
},
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
|
+
/** An indexed skill as a card outside a search (favorites, installed): no relevance or popularity score. */
|
|
85
|
+
export function indexItem(store, lookup, skill) {
|
|
86
|
+
const plugin = lookup.plugin(skill.plugin_id);
|
|
87
|
+
const source = plugin ? marketplaceSource(skill, plugin) : githubSource(skill);
|
|
88
|
+
const breakdown = { R: 0, P: 0, F: freshness(skill.last_commit_at), T: TRUST_SCORE[trustTier(skill, plugin)] };
|
|
89
|
+
const stars = skill.repo ? (store.getRepo(skill.repo)?.stars ?? null) : null;
|
|
90
|
+
return buildResultItem({ skill, lookup, sources: source ? [source] : [], installs: freshInstalls(skill), stars, breakdown, score: 0 });
|
|
91
|
+
}
|
|
84
92
|
export function buildResultItem(a) {
|
|
85
93
|
const { skill } = a;
|
|
86
94
|
const plugin = a.lookup.plugin(skill.plugin_id);
|
package/server/src/security.js
CHANGED
|
@@ -49,5 +49,8 @@ export function securityMiddleware(opts) {
|
|
|
49
49
|
next();
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
|
-
|
|
52
|
+
// 'wasm-unsafe-eval': the translation engine is WebAssembly (served from here); it allows compiling WASM, not eval.
|
|
53
|
+
// Its worker takes the policy of its own script's response, but the page has it too for browsers that give workers
|
|
54
|
+
// the page's policy.
|
|
55
|
+
export const CSP = "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; " +
|
|
53
56
|
"connect-src 'self'; font-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'";
|
|
@@ -2,6 +2,13 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
2
2
|
import { mkdir, rm } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { run } from "../util/proc.js";
|
|
5
|
+
/**
|
|
6
|
+
* git asked for a login: GitHub answers so for a repo that is deleted or private (credentials are turned off
|
|
7
|
+
* here), so it is gone, not a network hiccup. It used to count as "could not read" in searches and was asked
|
|
8
|
+
* again on every sync (2026-11-03).
|
|
9
|
+
*/
|
|
10
|
+
export class RepoUnreachableError extends Error {
|
|
11
|
+
}
|
|
5
12
|
const REPO_RE = /^[A-Za-z0-9-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
|
|
6
13
|
const SHA_RE = /^[0-9a-f]{40}$/i;
|
|
7
14
|
const REF_RE = /^[A-Za-z0-9._\/-]{1,200}$/;
|
|
@@ -75,8 +82,11 @@ export async function gitTree(repo, sha, workRoot) {
|
|
|
75
82
|
if (init.exitCode !== 0)
|
|
76
83
|
throw new Error(`git init failed: ${init.stderr.trim()}`);
|
|
77
84
|
const fetch = await git(['fetch', '-q', '--depth', '1', '--filter=blob:none', '--no-tags', repoUrl(repo), sha], dir);
|
|
78
|
-
if (fetch.exitCode !== 0)
|
|
85
|
+
if (fetch.exitCode !== 0) {
|
|
86
|
+
if (/could not read Username|Repository not found/i.test(fetch.stderr))
|
|
87
|
+
throw new RepoUnreachableError(`저장소를 찾을 수 없습니다: ${repo}`);
|
|
79
88
|
throw new Error(`git fetch failed: ${fetch.stderr.trim().split('\n')[0] || fetch.exitCode}`);
|
|
89
|
+
}
|
|
80
90
|
if (sha === 'HEAD') {
|
|
81
91
|
const rev = await git(['rev-parse', 'FETCH_HEAD'], dir);
|
|
82
92
|
sha = rev.stdout.trim();
|
|
@@ -27,6 +27,8 @@ function isSafeRepoPath(path) {
|
|
|
27
27
|
}
|
|
28
28
|
/** Raw files (SKILL.md included) are read up to this many bytes; the rest is cut off. */
|
|
29
29
|
export const RAW_MAX_BYTES = 512 * 1024;
|
|
30
|
+
/** First wait after a raw download got 429, doubled on each retry. */
|
|
31
|
+
const RAW_THROTTLE_WAIT_MS = 1000;
|
|
30
32
|
export class GitHub {
|
|
31
33
|
store;
|
|
32
34
|
config;
|
|
@@ -44,6 +46,8 @@ export class GitHub {
|
|
|
44
46
|
refCache = new Map();
|
|
45
47
|
/** raw.githubusercontent.com answers bursts of hundreds of parallel downloads with 429s; keep it civil. */
|
|
46
48
|
rawSlots = new Semaphore(24);
|
|
49
|
+
/** raw.githubusercontent.com said 429: no download starts before this time. */
|
|
50
|
+
rawPausedUntil = 0;
|
|
47
51
|
metaInFlight = new Map();
|
|
48
52
|
constructor(store, config) {
|
|
49
53
|
this.store = store;
|
|
@@ -244,8 +248,19 @@ export class GitHub {
|
|
|
244
248
|
const url = `https://raw.githubusercontent.com/${repo}/${ref}/${path.split('/').map(encodeURIComponent).join('/')}`;
|
|
245
249
|
for (let attempt = 0;; attempt++) {
|
|
246
250
|
try {
|
|
247
|
-
const res = await this.rawSlots.run(() =>
|
|
248
|
-
|
|
251
|
+
const res = await this.rawSlots.run(async () => {
|
|
252
|
+
const pause = this.rawPausedUntil - Date.now();
|
|
253
|
+
if (pause > 0)
|
|
254
|
+
await sleep(pause);
|
|
255
|
+
return fetchText(url, { maxBytes, timeoutMs: 15_000 });
|
|
256
|
+
});
|
|
257
|
+
if (res.status === 429 && attempt < 3) {
|
|
258
|
+
// Throttled: every download waits (1, 2, 4 s), not only this one. Retrying after 0.4 s still failed
|
|
259
|
+
// while a repo with hundreds of skills was read (2026-11-03).
|
|
260
|
+
this.rawPausedUntil = Math.max(this.rawPausedUntil, Date.now() + RAW_THROTTLE_WAIT_MS * 2 ** attempt);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (res.status >= 500 && attempt < 2) {
|
|
249
264
|
await sleep(400 * (attempt + 1));
|
|
250
265
|
continue;
|
|
251
266
|
}
|
package/server/src/tasks.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { RISK_RULES_VERSION, riskLevel } from "./analyze/risk.js";
|
|
2
2
|
import { skillMdPath } from "./discovery/tree.js";
|
|
3
3
|
import { PluginUnavailableError, recheckRisk } from "./indexer.js";
|
|
4
|
-
import { GRAPHQL_BATCH } from "./sources/github.js";
|
|
4
|
+
import { GitHubError, GRAPHQL_BATCH } from "./sources/github.js";
|
|
5
5
|
import { syncMarketplace } from "./sources/marketplace.js";
|
|
6
6
|
import { collectPopularRepos, popularState } from "./sources/popularRepos.js";
|
|
7
7
|
import { searchActivity } from "./search/activity.js";
|
|
@@ -41,18 +41,25 @@ export function startSync(ctx, marketplace, force = false) {
|
|
|
41
41
|
.listMarketplaces()
|
|
42
42
|
.filter((m) => (marketplace ? m.name === marketplace : m.enabled === 1));
|
|
43
43
|
ctl.setTotal(targets.length);
|
|
44
|
+
const eager = [];
|
|
44
45
|
for (const m of targets) {
|
|
45
46
|
await ctl.checkpoint();
|
|
46
47
|
try {
|
|
47
48
|
await syncMarketplace(ctx.store, ctx.github, m.name, force);
|
|
48
49
|
ctl.tick();
|
|
50
|
+
if (m.index_mode === 'eager')
|
|
51
|
+
eager.push(m.name);
|
|
49
52
|
}
|
|
50
53
|
catch (err) {
|
|
51
54
|
ctl.tick(`${m.name}: ${String(err.message ?? err).slice(0, 200)}`);
|
|
52
|
-
continue;
|
|
53
55
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
}
|
|
57
|
+
// Every list first, then the skills of the marketplace with the fewest plugins to read: Anthropic's own skills
|
|
58
|
+
// (5 plugins) used to wait about 100 s for the 300-plugin official list on a first run (2026-11-03).
|
|
59
|
+
const pending = new Map(eager.map((name) => [name, ctx.store.listPluginsNeedingIndex(name).length]));
|
|
60
|
+
for (const name of eager.sort((a, b) => pending.get(a) - pending.get(b))) {
|
|
61
|
+
await indexPending(ctx, name, ctl);
|
|
62
|
+
ctx.store.clearSearchCache();
|
|
56
63
|
}
|
|
57
64
|
ctx.store.clearSearchCache();
|
|
58
65
|
if (!marketplace && getSettings(ctx.store).autoCollect) {
|
|
@@ -68,6 +75,30 @@ const POPULAR_EVERY_MS = 24 * 60 * 60 * 1000;
|
|
|
68
75
|
export const REPO_META_EVERY_MS = 6 * 60 * 60 * 1000;
|
|
69
76
|
const REPO_META_STALE_MS = 18 * 60 * 60 * 1000;
|
|
70
77
|
let repoMetaRunning = false;
|
|
78
|
+
/** The smallest group asked again in halves; a group this small that still fails waits for the next run. */
|
|
79
|
+
const REPO_META_MIN_SPLIT = 25;
|
|
80
|
+
/** GitHub gave up on the query (502/503/504, usually when it takes too long) or our own 30 s limit ran out. */
|
|
81
|
+
const gaveUp = (err) => (err instanceof GitHubError && [502, 503, 504].includes(err.status)) || err?.name === 'TimeoutError';
|
|
82
|
+
/**
|
|
83
|
+
* One group of repos, asked again in halves when GitHub gives up on it: a hundred lookups in one query can take
|
|
84
|
+
* too long, and one 502 used to end the whole run, leaving the rest stale until the next one 6 hours later
|
|
85
|
+
* (2026-11-03). Other errors (limit reached, network) still end the run.
|
|
86
|
+
*/
|
|
87
|
+
async function refreshRepoGroup(ctx, repos) {
|
|
88
|
+
try {
|
|
89
|
+
return { stored: await ctx.github.refreshRepoMeta(repos), left: 0 };
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
if (!gaveUp(err))
|
|
93
|
+
throw err;
|
|
94
|
+
if (repos.length <= REPO_META_MIN_SPLIT)
|
|
95
|
+
return { stored: 0, left: repos.length, error: err };
|
|
96
|
+
const half = Math.ceil(repos.length / 2);
|
|
97
|
+
const a = await refreshRepoGroup(ctx, repos.slice(0, half));
|
|
98
|
+
const b = await refreshRepoGroup(ctx, repos.slice(half));
|
|
99
|
+
return { stored: a.stored + b.stored, left: a.left + b.left, error: b.error ?? a.error };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
71
102
|
/**
|
|
72
103
|
* Keeps star counts of the repos skills come from current (GraphQL, 100 per call), so a search reads them
|
|
73
104
|
* from the database instead of asking GitHub per repo, and fills repos never fetched (indexed without a
|
|
@@ -80,11 +111,20 @@ export async function refreshRepoMeta(ctx) {
|
|
|
80
111
|
let stored = 0;
|
|
81
112
|
try {
|
|
82
113
|
const repos = ctx.store.listReposToRefresh(new Date(Date.now() - REPO_META_STALE_MS).toISOString(), 10_000);
|
|
114
|
+
let left = 0;
|
|
83
115
|
for (let i = 0; i < repos.length; i += GRAPHQL_BATCH) {
|
|
84
116
|
while (searchActivity.busy())
|
|
85
117
|
await sleep(500);
|
|
86
|
-
|
|
118
|
+
const group = repos.slice(i, i + GRAPHQL_BATCH);
|
|
119
|
+
const r = await refreshRepoGroup(ctx, group);
|
|
120
|
+
stored += r.stored;
|
|
121
|
+
left += r.left;
|
|
122
|
+
// not one piece answered: GitHub is down rather than slow, so stop asking until the next run
|
|
123
|
+
if (r.left === group.length)
|
|
124
|
+
throw r.error;
|
|
87
125
|
}
|
|
126
|
+
if (left)
|
|
127
|
+
console.warn('[repo-meta]', `GitHub가 일부 요청에 답하지 않아 저장소 ${left}개는 다음 갱신 때 다시 묻습니다`);
|
|
88
128
|
}
|
|
89
129
|
catch (err) {
|
|
90
130
|
console.warn('[repo-meta]', String(err.message ?? err).slice(0, 200));
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { zstdDecompress } from 'node:zlib';
|
|
7
|
+
export const MODEL_LANGS = ['ko', 'ja'];
|
|
8
|
+
const CDN = 'https://firefox-settings-attachments.cdn.mozilla.net/';
|
|
9
|
+
/**
|
|
10
|
+
* Pinned from Firefox's Remote Settings collection `translations-models-v2` (desktop records, version 3.0), the
|
|
11
|
+
* versions tried with the engine. A newer model may need a newer engine, so they are changed together.
|
|
12
|
+
*/
|
|
13
|
+
export const MODEL_FILES = {
|
|
14
|
+
ko: [
|
|
15
|
+
{ part: 'lex', name: 'lex.50.50.enko.s2t.bin', location: 'main-workspace/translations-models-v2/493336e3-8e7b-4223-86c5-e88846a85d46.zst', size: 2675843, sha256: '7216367b7f524c0ae1f39353e5c4622b230d1c2e47924182a5853418c1e69f64', unpackedSize: 6676356, unpackedSha256: '7734265822ce315c72334ad538e698ab3900ab8e8f04bd43768d0aecad6b6df7' },
|
|
16
|
+
{ part: 'model', name: 'model.enko.intgemm.alphas.bin', location: 'main-workspace/translations-models-v2/e4e7e781-bd8e-4dae-9f8e-d0c8a8a5b07b.zst', size: 44942689, sha256: 'ebe3a360c2a007e31df8595bc01a856376c195718ed3c0a27b336eb9b349d313', unpackedSize: 59504955, unpackedSha256: '1c310a79b61b8824b2eb26b045db043d92722f3e66ea06998f7c89f48da9f6bc' },
|
|
17
|
+
{ part: 'vocab', name: 'vocab.enko.spm', location: 'main-workspace/translations-models-v2/ac6a46cf-1cf0-4ea0-b1ad-9fa40d6f6513.zst', size: 595440, sha256: 'dc5b8d46f6dff08950bf36db741f52431a41466cf33f5a105d1c5154586559d2', unpackedSize: 1406926, unpackedSha256: '709bf0e425345e0e92ce41ed84348a1296a2107c92bdb6624404450ffecbd1f9' },
|
|
18
|
+
],
|
|
19
|
+
ja: [
|
|
20
|
+
{ part: 'lex', name: 'lex.50.50.enja.s2t.bin', location: 'main-workspace/translations-models-v2/83e22dc9-40c4-4ac1-8c25-5d3877b5f628.zst', size: 1751977, sha256: '8954d5b63432366992b22ad2aaa11895e2643014835049fe677655540a705915', unpackedSize: 4128360, unpackedSha256: 'edfb7eb47b98a2689b804ab3614c31b24f1257aa54b1154da3d85e1ae8152d9f' },
|
|
21
|
+
{ part: 'trgvocab', name: 'trgvocab.enja.spm', location: 'main-workspace/translations-models-v2/e54d177c-1131-4fbf-90ea-6741a98d6675.zst', size: 361180, sha256: '5384abf34f0e1298f795bd14909a577272059041e60f91a5eb10500e35d0ee98', unpackedSize: 827144, unpackedSha256: '3b3d9f8f3a034d98d0a476f1794fa79c01e4e98a967ceb6777a66ba2d03ec1e1' },
|
|
22
|
+
{ part: 'srcvocab', name: 'srcvocab.enja.spm', location: 'main-workspace/translations-models-v2/d10e4482-b9ba-4565-ab44-5ba3cebcf94b.zst', size: 345747, sha256: 'dd1e93d625d9a8d8a42f75255343f302ea37527933eda1a490652b66a7408a64', unpackedSize: 796275, unpackedSha256: '970c98d174fc01e0339fbabbf45af36a4be3f26f819ec1a5ea1189f71e091889' },
|
|
23
|
+
{ part: 'model', name: 'model.enja.intgemm.alphas.bin', location: 'main-workspace/translations-models-v2/a01a5318-41c7-4266-8fe1-66c105d6d711.zst', size: 32431590, sha256: 'f92259b9904812350b71f440c3328f3e2e856f31c1a3ab6002756380dd908e00', unpackedSize: 43849787, unpackedSha256: '59ae659f9bb63e4f81f474fe3c03d3f4499434b5f9e779fab7c12a45f31fd562' },
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
/** The address the page reads the files from (outside /api: the engine's fetch sends no X-Skill-Finder header). */
|
|
27
|
+
export const MODEL_URL_PATH = '/translate-models';
|
|
28
|
+
const FILE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
29
|
+
const sha256 = (b) => createHash('sha256').update(b).digest('hex');
|
|
30
|
+
const pairDir = (lang) => `en${lang}`;
|
|
31
|
+
/** Written last, so a half-finished download is never taken for a model. Its content names the pinned files. */
|
|
32
|
+
const READY = 'ready.json';
|
|
33
|
+
export class TranslateModels {
|
|
34
|
+
dir;
|
|
35
|
+
fetchImpl;
|
|
36
|
+
files;
|
|
37
|
+
running = new Map();
|
|
38
|
+
/** `dir`: where the unpacked files live (`<dataDir>/translate-models`), served as {@link MODEL_URL_PATH}. `files`: tests. */
|
|
39
|
+
constructor(dir, fetchImpl = (...a) => fetch(...a), files = MODEL_FILES) {
|
|
40
|
+
this.dir = dir;
|
|
41
|
+
this.fetchImpl = fetchImpl;
|
|
42
|
+
this.files = files;
|
|
43
|
+
}
|
|
44
|
+
get root() {
|
|
45
|
+
return this.dir;
|
|
46
|
+
}
|
|
47
|
+
readyMark(lang) {
|
|
48
|
+
return JSON.stringify(this.files[lang].map((f) => f.unpackedSha256));
|
|
49
|
+
}
|
|
50
|
+
/** The mark, and every file still there at its size (one deleted by hand or quarantined means download again). */
|
|
51
|
+
ready(lang) {
|
|
52
|
+
try {
|
|
53
|
+
const dir = join(this.dir, pairDir(lang));
|
|
54
|
+
return readFileSync(join(dir, READY), 'utf8') === this.readyMark(lang) && this.files[lang].every((f) => statSync(join(dir, f.name)).size === f.unpackedSize);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
status(lang) {
|
|
61
|
+
const run = this.running.get(lang);
|
|
62
|
+
if (run && run.state !== 'ready')
|
|
63
|
+
return { ...run };
|
|
64
|
+
const total = this.files[lang].reduce((n, f) => n + f.size, 0);
|
|
65
|
+
return this.ready(lang) ? { lang, state: 'ready', received: total, total, error: null } : { lang, state: 'absent', received: 0, total, error: null };
|
|
66
|
+
}
|
|
67
|
+
/** Starts the download unless it is there or under way; the returned status is polled with {@link status}. */
|
|
68
|
+
start(lang) {
|
|
69
|
+
const now = this.status(lang);
|
|
70
|
+
if (now.state === 'ready' || now.state === 'downloading')
|
|
71
|
+
return now;
|
|
72
|
+
const run = { lang, state: 'downloading', received: 0, total: now.total, error: null };
|
|
73
|
+
this.running.set(lang, run);
|
|
74
|
+
this.download(lang, run).then(() => (run.state = 'ready'), (err) => {
|
|
75
|
+
run.state = 'error';
|
|
76
|
+
run.error = String(err?.message ?? err).slice(0, 300);
|
|
77
|
+
console.warn('[translate]', run.error);
|
|
78
|
+
});
|
|
79
|
+
return { ...run };
|
|
80
|
+
}
|
|
81
|
+
async download(lang, run) {
|
|
82
|
+
const dir = join(this.dir, pairDir(lang));
|
|
83
|
+
await mkdir(dir, { recursive: true });
|
|
84
|
+
await rm(join(dir, READY), { force: true });
|
|
85
|
+
for (const f of this.files[lang]) {
|
|
86
|
+
const target = join(dir, f.name);
|
|
87
|
+
if (existsSync(target) && sha256(readFileSync(target)) === f.unpackedSha256) {
|
|
88
|
+
run.received += f.size;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// network failures and the time limit in the app's words (they used to show as English runtime messages)
|
|
92
|
+
const failed = (err) => new Error(`${f.name}: 내려받지 못했습니다 (${err?.name === 'TimeoutError' ? '시간 초과' : '연결 실패'})`);
|
|
93
|
+
const res = await this.fetchImpl(CDN + f.location, { signal: AbortSignal.timeout(FILE_TIMEOUT_MS) }).catch((err) => {
|
|
94
|
+
throw failed(err);
|
|
95
|
+
});
|
|
96
|
+
if (!res.ok || !res.body)
|
|
97
|
+
throw new Error(`${f.name}: 내려받지 못했습니다 (${res.status})`);
|
|
98
|
+
const chunks = [];
|
|
99
|
+
let size = 0;
|
|
100
|
+
try {
|
|
101
|
+
for await (const chunk of res.body) {
|
|
102
|
+
size += chunk.byteLength;
|
|
103
|
+
if (size > f.size)
|
|
104
|
+
throw new Error(`${f.name}: 크기가 맞지 않습니다`);
|
|
105
|
+
chunks.push(chunk);
|
|
106
|
+
run.received += chunk.byteLength;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
throw size > f.size ? err : failed(err);
|
|
111
|
+
}
|
|
112
|
+
const packed = Buffer.concat(chunks);
|
|
113
|
+
if (sha256(packed) !== f.sha256)
|
|
114
|
+
throw new Error(`${f.name}: SHA-256이 맞지 않습니다`);
|
|
115
|
+
const unpacked = await promisify(zstdDecompress)(packed);
|
|
116
|
+
if (unpacked.length !== f.unpackedSize || sha256(unpacked) !== f.unpackedSha256)
|
|
117
|
+
throw new Error(`${f.name}: 압축을 푼 파일이 맞지 않습니다`);
|
|
118
|
+
await writeFile(`${target}.part`, unpacked);
|
|
119
|
+
await rename(`${target}.part`, target);
|
|
120
|
+
}
|
|
121
|
+
await writeFile(join(dir, READY), this.readyMark(lang));
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The engine's model registry for the languages that are ready: per pair, its files with their addresses and
|
|
125
|
+
* SHA-256 (the engine checks them again with subresource integrity). The address carries the file's hash, so a
|
|
126
|
+
* browser that kept an older model (cached for good) asks for the new one when the pins change.
|
|
127
|
+
*/
|
|
128
|
+
registry() {
|
|
129
|
+
const out = {};
|
|
130
|
+
for (const lang of MODEL_LANGS) {
|
|
131
|
+
if (!this.ready(lang))
|
|
132
|
+
continue;
|
|
133
|
+
out[pairDir(lang)] = Object.fromEntries(this.files[lang].map((f) => [
|
|
134
|
+
f.part,
|
|
135
|
+
{ name: `${MODEL_URL_PATH}/${pairDir(lang)}/${f.name}?v=${f.unpackedSha256.slice(0, 16)}`, size: f.unpackedSize, expectedSha256Hash: f.unpackedSha256 },
|
|
136
|
+
]));
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -73,6 +73,8 @@ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
|
73
73
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
74
74
|
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
75
75
|
|
|
76
|
+
## @mkljczk/bergamot-translator - 0.4.16 (MPL-2.0)
|
|
77
|
+
|
|
76
78
|
## @phosphor-icons/react - 2.1.10 (MIT)
|
|
77
79
|
|
|
78
80
|
MIT License
|
|
@@ -2265,3 +2267,7 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
|
2265
2267
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
2266
2268
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
2267
2269
|
SOFTWARE.
|
|
2270
|
+
|
|
2271
|
+
## @mkljczk/bergamot-translator - 0.4.16 (MPL-2.0)
|
|
2272
|
+
|
|
2273
|
+
The translation engine in bergamot/ is shipped unmodified. Its source (version 0.4.16): https://codeberg.org/mkljczk/bergamot-translator (MPL-2.0, https://mozilla.org/MPL/2.0/). Its WebAssembly is built from bergamot-translator with the libraries it includes, such as Marian NMT (MIT), SentencePiece (Apache-2.0) and intgemm (MIT); their notices are in that source. The translation models are Mozilla's (MPL-2.0), downloaded from Mozilla when turned on.
|