@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.
- package/.env.example +3 -0
- package/CHANGELOG.md +39 -0
- package/LICENSE +21 -0
- package/README.md +86 -2
- package/bin/skill-finder.mjs +389 -0
- package/package.json +39 -4
- package/server/src/analyze/frontmatter.js +130 -0
- package/server/src/analyze/quality.js +34 -0
- package/server/src/analyze/risk.js +123 -0
- package/server/src/app.js +57 -0
- package/server/src/bootstrap.js +46 -0
- package/server/src/claude/cli.js +89 -0
- package/server/src/config.js +70 -0
- package/server/src/context.js +1 -0
- package/server/src/db/index.js +128 -0
- package/server/src/db/schema.js +95 -0
- package/server/src/db/store.js +353 -0
- package/server/src/discovery/classifyRepo.js +81 -0
- package/server/src/discovery/genericRules.js +180 -0
- package/server/src/discovery/pluginRules.js +59 -0
- package/server/src/discovery/tree.js +79 -0
- package/server/src/index.js +55 -0
- package/server/src/indexer.js +502 -0
- package/server/src/install/installed.js +206 -0
- package/server/src/install/plan.js +118 -0
- package/server/src/install/run.js +97 -0
- package/server/src/install/scan.js +70 -0
- package/server/src/install/summary.js +14 -0
- package/server/src/install/uninstall.js +59 -0
- package/server/src/install/validate.js +24 -0
- package/server/src/jobs.js +107 -0
- package/server/src/recommend/readme.js +508 -0
- package/server/src/release.js +41 -0
- package/server/src/routes/admin.js +203 -0
- package/server/src/routes/favorites.js +61 -0
- package/server/src/routes/health.js +27 -0
- package/server/src/routes/install.js +283 -0
- package/server/src/routes/presets.js +66 -0
- package/server/src/routes/recommend.js +36 -0
- package/server/src/routes/search.js +53 -0
- package/server/src/routes/skills.js +214 -0
- package/server/src/search/activity.js +13 -0
- package/server/src/search/category.js +109 -0
- package/server/src/search/koreanTerms.js +284 -0
- package/server/src/search/pipeline.js +975 -0
- package/server/src/search/rank.js +138 -0
- package/server/src/search/result.js +129 -0
- package/server/src/search/searchLog.js +53 -0
- package/server/src/security.js +53 -0
- package/server/src/settings.js +56 -0
- package/server/src/sources/gitTree.js +101 -0
- package/server/src/sources/github.js +503 -0
- package/server/src/sources/marketplace.js +78 -0
- package/server/src/sources/pluginSource.js +98 -0
- package/server/src/sources/popularRepos.js +74 -0
- package/server/src/sources/skillsSh.js +74 -0
- package/server/src/tasks.js +216 -0
- package/server/src/update.js +53 -0
- package/server/src/util/folderPicker.js +208 -0
- package/server/src/util/http.js +41 -0
- package/server/src/util/limiter.js +110 -0
- package/server/src/util/log.js +94 -0
- package/server/src/util/ndjson.js +27 -0
- package/server/src/util/proc.js +80 -0
- package/server/src/util/resolveFolder.js +78 -0
- package/server/src/util/sanitize.js +45 -0
- package/server/src/util/sizedCache.js +74 -0
- package/server/src/util/textFiles.js +9 -0
- package/server/src/util/tokens.js +21 -0
- package/server/src/version.js +12 -0
- package/web/dist/LICENSE.txt +21 -0
- package/web/dist/THIRD-PARTY-LICENSES.txt +2267 -0
- package/web/dist/assets/ArasakaGame-B0V_LZLl.css +1 -0
- package/web/dist/assets/ArasakaGame-C1Wkmqik.js +36 -0
- package/web/dist/assets/index-BgL1OaU4.css +2 -0
- package/web/dist/assets/index-Ec2XBK4-.js +66 -0
- package/web/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/web/dist/index.html +15 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { parse } from 'yaml';
|
|
2
|
+
import { sanitizeLine, sanitizeName, sanitizeText } from "../util/sanitize.js";
|
|
3
|
+
/**
|
|
4
|
+
* YAML is parsed on the main thread; deeply nested frontmatter takes ~0.2s each, so a repo full of them
|
|
5
|
+
* could block the server for minutes. Real frontmatter is far smaller (largest stored: 13.7K chars).
|
|
6
|
+
*/
|
|
7
|
+
export const FRONTMATTER_MAX_CHARS = 16 * 1024;
|
|
8
|
+
// Claude Code only treats the block as frontmatter when the very first line is `---`.
|
|
9
|
+
const FRONTMATTER_RE = /^---[ \t]*\r?\n(?:([\s\S]*?)\r?\n)?---[ \t]*(?:\r?\n|$)([\s\S]*)$/;
|
|
10
|
+
function regexField(block, key) {
|
|
11
|
+
const m = block.match(new RegExp(`^${key}:[ \\t]*(.+)$`, 'm'));
|
|
12
|
+
if (!m)
|
|
13
|
+
return undefined;
|
|
14
|
+
return m[1].trim().replace(/^(['"])(.*)\1$/, '$2');
|
|
15
|
+
}
|
|
16
|
+
export function parseSkillMd(raw) {
|
|
17
|
+
const text = raw.replace(/^/, '');
|
|
18
|
+
const m = text.match(FRONTMATTER_RE);
|
|
19
|
+
if (!m)
|
|
20
|
+
return { hasFrontmatter: false, frontmatter: {}, body: text, yamlError: null };
|
|
21
|
+
const block = m[1] ?? '';
|
|
22
|
+
const body = m[2] ?? '';
|
|
23
|
+
if (block.length > FRONTMATTER_MAX_CHARS) {
|
|
24
|
+
const fallback = {};
|
|
25
|
+
const name = regexField(block, 'name');
|
|
26
|
+
const description = regexField(block, 'description');
|
|
27
|
+
if (name)
|
|
28
|
+
fallback.name = name;
|
|
29
|
+
if (description)
|
|
30
|
+
fallback.description = description;
|
|
31
|
+
const yamlError = `frontmatter가 ${FRONTMATTER_MAX_CHARS / 1024}KB를 넘어 해석하지 않았습니다`;
|
|
32
|
+
return { hasFrontmatter: true, frontmatter: fallback, body, yamlError, frontmatterTooLarge: true };
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const data = parse(block, { prettyErrors: false });
|
|
36
|
+
if (data == null)
|
|
37
|
+
return { hasFrontmatter: true, frontmatter: {}, body, yamlError: null };
|
|
38
|
+
if (typeof data !== 'object' || Array.isArray(data)) {
|
|
39
|
+
return { hasFrontmatter: true, frontmatter: {}, body, yamlError: 'frontmatter가 매핑이 아닙니다' };
|
|
40
|
+
}
|
|
41
|
+
return { hasFrontmatter: true, frontmatter: data, body, yamlError: null };
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
const fallback = {};
|
|
45
|
+
const name = regexField(block, 'name');
|
|
46
|
+
const description = regexField(block, 'description');
|
|
47
|
+
if (name)
|
|
48
|
+
fallback.name = name;
|
|
49
|
+
if (description)
|
|
50
|
+
fallback.description = description;
|
|
51
|
+
return { hasFrontmatter: true, frontmatter: fallback, body, yamlError: String(err.message ?? err).split('\n')[0] };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Splits `allowed-tools` given as a YAML list or a comma/space separated string, respecting parentheses. */
|
|
55
|
+
export function parseAllowedTools(value) {
|
|
56
|
+
if (Array.isArray(value))
|
|
57
|
+
return value.map((v) => String(v).trim()).filter(Boolean);
|
|
58
|
+
if (typeof value !== 'string')
|
|
59
|
+
return [];
|
|
60
|
+
const out = [];
|
|
61
|
+
let depth = 0;
|
|
62
|
+
let cur = '';
|
|
63
|
+
for (const ch of value) {
|
|
64
|
+
if (ch === '(')
|
|
65
|
+
depth++;
|
|
66
|
+
if (ch === ')')
|
|
67
|
+
depth = Math.max(0, depth - 1);
|
|
68
|
+
if (depth === 0 && (ch === ',' || /\s/.test(ch))) {
|
|
69
|
+
if (cur.trim())
|
|
70
|
+
out.push(cur.trim());
|
|
71
|
+
cur = '';
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
cur += ch;
|
|
75
|
+
}
|
|
76
|
+
if (cur.trim())
|
|
77
|
+
out.push(cur.trim());
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
const asString = (v) => (typeof v === 'string' && v.trim() ? v : null);
|
|
81
|
+
function firstBodyLine(body) {
|
|
82
|
+
for (const line of body.split('\n')) {
|
|
83
|
+
const t = line.replace(/^#+\s*/, '').trim();
|
|
84
|
+
if (t)
|
|
85
|
+
return t;
|
|
86
|
+
}
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
const PICKED_FIELDS = [
|
|
90
|
+
'name',
|
|
91
|
+
'description',
|
|
92
|
+
'when_to_use',
|
|
93
|
+
'allowed-tools',
|
|
94
|
+
'disable-model-invocation',
|
|
95
|
+
'user-invocable',
|
|
96
|
+
'hooks',
|
|
97
|
+
'paths',
|
|
98
|
+
'model',
|
|
99
|
+
'context',
|
|
100
|
+
'license',
|
|
101
|
+
'compatibility',
|
|
102
|
+
'metadata',
|
|
103
|
+
];
|
|
104
|
+
/** Applies Claude Code's defaults: name falls back to the folder, description to the first body line. */
|
|
105
|
+
export function extractSkillMeta(raw, folderName) {
|
|
106
|
+
const parsed = parseSkillMd(sanitizeText(raw));
|
|
107
|
+
const fm = parsed.frontmatter;
|
|
108
|
+
const nameFm = asString(fm.name);
|
|
109
|
+
const descFm = asString(fm.description);
|
|
110
|
+
const picked = {};
|
|
111
|
+
for (const k of PICKED_FIELDS)
|
|
112
|
+
if (fm[k] !== undefined)
|
|
113
|
+
picked[k] = fm[k];
|
|
114
|
+
const body = parsed.body;
|
|
115
|
+
return {
|
|
116
|
+
name: sanitizeName(nameFm ?? folderName),
|
|
117
|
+
description: sanitizeLine(descFm ?? firstBodyLine(body)),
|
|
118
|
+
whenToUse: asString(fm.when_to_use) ? sanitizeLine(fm.when_to_use) : null,
|
|
119
|
+
nameFromFrontmatter: nameFm ? sanitizeName(nameFm) : null,
|
|
120
|
+
descriptionFromFrontmatter: descFm ? sanitizeLine(descFm) : null,
|
|
121
|
+
allowedTools: parseAllowedTools(fm['allowed-tools']),
|
|
122
|
+
hasHooks: fm.hooks != null && fm.hooks !== false,
|
|
123
|
+
frontmatter: picked,
|
|
124
|
+
body,
|
|
125
|
+
bodyLines: body.length === 0 ? 0 : body.replace(/\n$/, '').split('\n').length,
|
|
126
|
+
hasFrontmatter: parsed.hasFrontmatter,
|
|
127
|
+
yamlError: parsed.yamlError,
|
|
128
|
+
frontmatterTooLarge: parsed.frontmatterTooLarge === true,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const LISTING_LIMIT = 1536;
|
|
2
|
+
/** Badge-only checks (agentskills.io spec + Claude Code listing limits); never part of the score. */
|
|
3
|
+
export function qualityWarnings(meta, folderName) {
|
|
4
|
+
const out = [];
|
|
5
|
+
if (!meta.hasFrontmatter)
|
|
6
|
+
out.push('noFrontmatter');
|
|
7
|
+
if (meta.yamlError)
|
|
8
|
+
out.push('yamlError');
|
|
9
|
+
const name = meta.nameFromFrontmatter;
|
|
10
|
+
if (!name)
|
|
11
|
+
out.push('nameMissing');
|
|
12
|
+
else {
|
|
13
|
+
const len = Array.from(name).length;
|
|
14
|
+
if (len < 1 || len > 64)
|
|
15
|
+
out.push('nameLength');
|
|
16
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name))
|
|
17
|
+
out.push('nameFormat');
|
|
18
|
+
if (folderName && name !== folderName)
|
|
19
|
+
out.push('nameMismatch');
|
|
20
|
+
}
|
|
21
|
+
const desc = meta.descriptionFromFrontmatter;
|
|
22
|
+
if (!desc)
|
|
23
|
+
out.push('descriptionMissing');
|
|
24
|
+
else if (Array.from(desc).length > 1024)
|
|
25
|
+
out.push('descriptionLength');
|
|
26
|
+
if (meta.bodyLines > 500)
|
|
27
|
+
out.push('bodyTooLong');
|
|
28
|
+
const listing = (meta.description ?? '') + (meta.whenToUse ?? '');
|
|
29
|
+
if (Array.from(listing).length > LISTING_LIMIT)
|
|
30
|
+
out.push('listingTruncated');
|
|
31
|
+
if (/anthropic|claude/i.test(meta.name))
|
|
32
|
+
out.push('reservedWord');
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bumped whenever a rule reading the SKILL.md text changes, so rows checked under older rules are checked
|
|
3
|
+
* again (stored per row, and part of the search cache key). 2: the wider download-and-run rule (U3).
|
|
4
|
+
*/
|
|
5
|
+
export const RISK_RULES_VERSION = 2;
|
|
6
|
+
/** Flags that come from the SKILL.md text; the others come from the file list or the plugin. */
|
|
7
|
+
export const TEXT_RISK_FLAGS = new Set([
|
|
8
|
+
'preapprovedBash',
|
|
9
|
+
'unscopedBash',
|
|
10
|
+
'shellInjection',
|
|
11
|
+
'skillHooks',
|
|
12
|
+
'pluginRootDependency',
|
|
13
|
+
'curlPipeShell',
|
|
14
|
+
'rmRf',
|
|
15
|
+
'unscanned',
|
|
16
|
+
]);
|
|
17
|
+
const SCRIPT_EXT = /\.(sh|bash|zsh|py|js|mjs|cjs|ts|rb|pl|ps1|bat|cmd|exe)$/i;
|
|
18
|
+
const SHELL_INLINE = /(^|[\s(])!`[^`\n]+`/m;
|
|
19
|
+
const SHELL_BLOCK = /^[ \t]*```!/m;
|
|
20
|
+
// The two checks below scan remote SKILL.md text, so they must stay linear in its length: the single regexes
|
|
21
|
+
// they replace (`\b(curl|wget)\b[^\n|]*\|…sh` and a nested-`[a-zA-Z]*` rm -rf) took 46s and 207s on a
|
|
22
|
+
// crafted 512KB file, blocking the whole server. They catch everything those regexes did (fuzzed) and, since
|
|
23
|
+
// the search debate (U3, user decision), the other common download-and-run forms too. Rows indexed before that
|
|
24
|
+
// are checked again in the background (tasks.ts recheckRiskRules, RISK_RULES_VERSION).
|
|
25
|
+
const DOWNLOADER = /\b(?:curl|wget|irm|iwr|invoke-webrequest|invoke-restmethod)\b/i;
|
|
26
|
+
// sh-like shells run stdin whatever follows; python/node/perl/ruby only when the command ends there
|
|
27
|
+
// (`| python3 -`): `| python -m json.tool` just formats the output, and `| curl | node |` is a markdown table.
|
|
28
|
+
// Paths and sudo options never cross a `|`: `[^\s|]` keeps one match from running past the next pipe, which
|
|
29
|
+
// made each pipe rescan the rest of the text (quadratic, 110s on 512KB of "/curl|").
|
|
30
|
+
const PIPE_TO_SHELL = /\s*(?:sudo[ \t]+(?:-[^\s|]+[ \t]+)*)?(?:(?:\/[^\s|]*\/)?env[ \t]+)?(?:\/[^\s|]*\/)?(?:(?:(?:ba|z|da|k)?sh|iex|invoke-expression)\b|(?:python[0-9.]*|node|perl|ruby)(?:[ \t]+-)?(?=[ \t]*(?:[\r\n;&)'"`]|$)))/iy;
|
|
31
|
+
/** `bash <(curl …)`, `sh -c "$(wget …)"`, `eval "$(curl …)"`, `source <(curl …)`: a shell running a download. */
|
|
32
|
+
const SUBST_TO_SHELL = /\b(?:(?:ba|z|da|k)?sh|eval|source)\b[^\n<$]{0,80}[<$]\([ \t]*(?:curl|wget)\b/i;
|
|
33
|
+
/** PowerShell `iex (iwr …)`, `Invoke-Expression (New-Object Net.WebClient).DownloadString(…)`. */
|
|
34
|
+
const PS_EXPR = /\b(?:iex|invoke-expression)[ \t]*\(+[ \t]*(?:iwr|irm|invoke-webrequest|invoke-restmethod|new-object[ \t]+(?:system\.)?net\.webclient)\b/i;
|
|
35
|
+
/**
|
|
36
|
+
* A downloader (curl, wget, PowerShell iwr/irm) in a `|`- or newline-delimited segment that ends in `|` followed by
|
|
37
|
+
* a shell or interpreter; or a shell/iex running a download's output directly. A line ending in `\` continues.
|
|
38
|
+
*/
|
|
39
|
+
export function hasCurlPipeShell(raw) {
|
|
40
|
+
const text = raw.replace(/\\\r?\n/g, ' ');
|
|
41
|
+
if (SUBST_TO_SHELL.test(text) || PS_EXPR.test(text))
|
|
42
|
+
return true;
|
|
43
|
+
let start = 0;
|
|
44
|
+
for (let i = 0; i <= text.length; i++) {
|
|
45
|
+
const ch = text[i];
|
|
46
|
+
if (i < text.length && ch !== '|' && ch !== '\n')
|
|
47
|
+
continue;
|
|
48
|
+
if (ch === '|' && DOWNLOADER.test(text.slice(start, i))) {
|
|
49
|
+
PIPE_TO_SHELL.lastIndex = i + 1; // \s* may cross newlines, as in the original
|
|
50
|
+
if (PIPE_TO_SHELL.test(text))
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
start = i + 1;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
const RM_FLAGS = /\brm\s+-([a-zA-Z]+)(?!\w)/g;
|
|
58
|
+
const RM_SPLIT_FLAGS = /\brm\s+-([rRfF])\s+-([rRfF])\b/g;
|
|
59
|
+
/** `rm -rf` in any flag order (`-fr`, `-Rf`, `-rfv`, `-r -f`). */
|
|
60
|
+
export function hasRmRf(text) {
|
|
61
|
+
for (const m of text.matchAll(RM_FLAGS))
|
|
62
|
+
if (/[rR]/.test(m[1]) && /[fF]/.test(m[1]))
|
|
63
|
+
return true;
|
|
64
|
+
for (const m of text.matchAll(RM_SPLIT_FLAGS))
|
|
65
|
+
if (m[1].toLowerCase() !== m[2].toLowerCase())
|
|
66
|
+
return true;
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
const PLUGIN_ROOT = /\$\{?CLAUDE_PLUGIN_ROOT\}?/;
|
|
70
|
+
export function skillRiskFlags(input) {
|
|
71
|
+
const flags = new Set();
|
|
72
|
+
if (input.files.some((f) => SCRIPT_EXT.test(f)))
|
|
73
|
+
flags.add('scripts');
|
|
74
|
+
for (const tool of input.allowedTools) {
|
|
75
|
+
const t = tool.trim();
|
|
76
|
+
if (/^Bash(?:\(|$)/.test(t))
|
|
77
|
+
flags.add('preapprovedBash');
|
|
78
|
+
if (t === 'Bash' || /^Bash\(\s*\*\s*\)$/.test(t))
|
|
79
|
+
flags.add('unscopedBash');
|
|
80
|
+
}
|
|
81
|
+
if (SHELL_INLINE.test(input.body) || SHELL_BLOCK.test(input.body))
|
|
82
|
+
flags.add('shellInjection');
|
|
83
|
+
if (input.hasHooks)
|
|
84
|
+
flags.add('skillHooks');
|
|
85
|
+
if (PLUGIN_ROOT.test(input.raw))
|
|
86
|
+
flags.add('pluginRootDependency');
|
|
87
|
+
if (hasCurlPipeShell(input.raw))
|
|
88
|
+
flags.add('curlPipeShell');
|
|
89
|
+
if (hasRmRf(input.raw))
|
|
90
|
+
flags.add('rmRf');
|
|
91
|
+
if (input.partial)
|
|
92
|
+
flags.add('unscanned');
|
|
93
|
+
return [...flags];
|
|
94
|
+
}
|
|
95
|
+
export function pluginRiskFlags(signals, sourceKind) {
|
|
96
|
+
const flags = [];
|
|
97
|
+
if (signals?.hooks)
|
|
98
|
+
flags.push('pluginHooks');
|
|
99
|
+
if (signals?.mcp)
|
|
100
|
+
flags.push('pluginMcp');
|
|
101
|
+
if (signals?.bin)
|
|
102
|
+
flags.push('pluginBin');
|
|
103
|
+
if (signals?.monitors)
|
|
104
|
+
flags.push('pluginMonitors');
|
|
105
|
+
if (sourceKind === 'command')
|
|
106
|
+
flags.push('commandSource');
|
|
107
|
+
return flags;
|
|
108
|
+
}
|
|
109
|
+
// Shown, but they do not raise the level: rm -rf is common cleanup, and pluginRootDependency says how the skill must
|
|
110
|
+
// be installed (as a plugin), not that it is dangerous (UI debate D-3). Scripts and a plugin's bin run only through
|
|
111
|
+
// Bash, which Claude Code asks about first; what runs without asking (hooks, MCP servers, monitors, pre-approved
|
|
112
|
+
// Bash, shell injection) stays "주의" (user decision 2026-11-02: 60% of skills were "주의", official pdf too).
|
|
113
|
+
const INFO_ONLY = new Set(['rmRf', 'pluginRootDependency', 'scripts', 'pluginBin']);
|
|
114
|
+
export function riskLevel(flags) {
|
|
115
|
+
const has = (f) => flags.includes(f);
|
|
116
|
+
// unscanned stands in for unscopedBash too: an oversized frontmatter hides `allowed-tools: Bash` from the check
|
|
117
|
+
if ((has('shellInjection') && (has('unscopedBash') || has('unscanned'))) || has('curlPipeShell') || has('commandSource'))
|
|
118
|
+
return 'high';
|
|
119
|
+
if (flags.some((f) => !INFO_ONLY.has(f)))
|
|
120
|
+
return 'medium';
|
|
121
|
+
return 'low';
|
|
122
|
+
}
|
|
123
|
+
export const RISK_ORDER = { low: 0, medium: 1, high: 2 };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import express, {} from 'express';
|
|
4
|
+
import { WEB_DIST_DIR } from "./config.js";
|
|
5
|
+
import { adminRouter } from "./routes/admin.js";
|
|
6
|
+
import { healthRouter } from "./routes/health.js";
|
|
7
|
+
import { installRouter } from "./routes/install.js";
|
|
8
|
+
import { presetsRouter } from "./routes/presets.js";
|
|
9
|
+
import { favoritesRouter } from "./routes/favorites.js";
|
|
10
|
+
import { recommendRouter } from "./routes/recommend.js";
|
|
11
|
+
import { searchRouter } from "./routes/search.js";
|
|
12
|
+
import { skillsRouter } from "./routes/skills.js";
|
|
13
|
+
import { CSP, securityMiddleware } from "./security.js";
|
|
14
|
+
export function createApp(ctx, opts = {}) {
|
|
15
|
+
const app = express();
|
|
16
|
+
app.disable('x-powered-by');
|
|
17
|
+
app.set('etag', false);
|
|
18
|
+
// /API/... must not reach the /api routes past a check written for /api (project review 2026-11-02)
|
|
19
|
+
app.set('case sensitive routing', true);
|
|
20
|
+
app.use(securityMiddleware(ctx.config));
|
|
21
|
+
app.use('/api', express.json({ limit: '64kb' }));
|
|
22
|
+
app.use('/api', healthRouter(ctx));
|
|
23
|
+
app.use('/api', searchRouter(ctx, opts.searchDeps));
|
|
24
|
+
app.use('/api', skillsRouter(ctx));
|
|
25
|
+
app.use('/api', installRouter(ctx));
|
|
26
|
+
app.use('/api', presetsRouter(ctx));
|
|
27
|
+
app.use('/api', favoritesRouter(ctx));
|
|
28
|
+
app.use('/api', recommendRouter(ctx));
|
|
29
|
+
app.use('/api', adminRouter(ctx));
|
|
30
|
+
// any other /api path, in any case (/API/... is not an API route and not a page either)
|
|
31
|
+
app.use((req, res, next) => {
|
|
32
|
+
if (!req.path.toLowerCase().startsWith('/api'))
|
|
33
|
+
return next();
|
|
34
|
+
res.status(404).json({ error: '없는 API 주소입니다' });
|
|
35
|
+
});
|
|
36
|
+
if (existsSync(WEB_DIST_DIR)) {
|
|
37
|
+
app.use((_req, res, next) => {
|
|
38
|
+
res.setHeader('Content-Security-Policy', CSP);
|
|
39
|
+
next();
|
|
40
|
+
});
|
|
41
|
+
// index.html must never be cached (it names the current hashed bundles); it is served by the fallback below.
|
|
42
|
+
app.use(express.static(WEB_DIST_DIR, { index: false, maxAge: '1h' }));
|
|
43
|
+
app.get(/^\/(?!api\/).*/i, (_req, res) => {
|
|
44
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
45
|
+
res.sendFile(join(WEB_DIST_DIR, 'index.html'));
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const onError = (err, _req, res, _next) => {
|
|
49
|
+
const status = typeof err?.status === 'number' && err.status >= 400 && err.status < 600 ? err.status : 500;
|
|
50
|
+
if (status >= 500)
|
|
51
|
+
console.error('[api]', err);
|
|
52
|
+
if (!res.headersSent)
|
|
53
|
+
res.status(status).json({ error: status >= 500 ? '서버 오류' : String(err.message ?? err) });
|
|
54
|
+
};
|
|
55
|
+
app.use(onError);
|
|
56
|
+
return app;
|
|
57
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { copyFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isCorruptDb, latestBackup, openDb, setAsideCorrupt } from "./db/index.js";
|
|
4
|
+
import { Store } from "./db/store.js";
|
|
5
|
+
import { Indexer } from "./indexer.js";
|
|
6
|
+
import { Jobs } from "./jobs.js";
|
|
7
|
+
import { seedMarketplaces } from "./settings.js";
|
|
8
|
+
import { GitHub } from "./sources/github.js";
|
|
9
|
+
import { APP_VERSION } from "./version.js";
|
|
10
|
+
export function createContext(config, dbPath = join(config.dataDir, 'skill-finder.db')) {
|
|
11
|
+
const store = new Store(openDb(dbPath, { backupLabel: dbPath === ':memory:' ? null : APP_VERSION }));
|
|
12
|
+
seedMarketplaces(store);
|
|
13
|
+
const github = new GitHub(store, config);
|
|
14
|
+
const indexer = new Indexer(store, github);
|
|
15
|
+
return { config, store, github, indexer, jobs: new Jobs() };
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* createContext for the server: a damaged database file (project review #4) is moved aside and the newest backup
|
|
19
|
+
* made before a migration is put in its place, or, without one, the app starts with an empty one (the index is
|
|
20
|
+
* collected again; settings and the search log start over). Each step is said on the console and in the log.
|
|
21
|
+
*/
|
|
22
|
+
export function openContext(config) {
|
|
23
|
+
const dbPath = join(config.dataDir, 'skill-finder.db');
|
|
24
|
+
let restored = null;
|
|
25
|
+
for (;;) {
|
|
26
|
+
try {
|
|
27
|
+
return createContext(config, dbPath);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
if (!isCorruptDb(err))
|
|
31
|
+
throw err;
|
|
32
|
+
const moved = setAsideCorrupt(dbPath);
|
|
33
|
+
console.error(`[db] 데이터 파일이 손상되어 열 수 없습니다. 살펴볼 수 있게 옮겨 두었습니다: ${moved}`);
|
|
34
|
+
const backup = restored ? null : latestBackup(dbPath);
|
|
35
|
+
if (backup) {
|
|
36
|
+
copyFileSync(backup, dbPath);
|
|
37
|
+
restored = backup;
|
|
38
|
+
console.error(`[db] 백업으로 되살려 시작합니다: ${backup}`);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
console.error('[db] 새 데이터로 시작합니다. 스킬 목록은 다시 모으고, 설정과 검색 기록은 처음 값으로 돌아갑니다.');
|
|
42
|
+
return createContext(config, dbPath);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { accessSync, constants, statSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { delimiter, join } from 'node:path';
|
|
4
|
+
import { run } from "../util/proc.js";
|
|
5
|
+
const isWin = process.platform === 'win32';
|
|
6
|
+
function isFile(p) {
|
|
7
|
+
try {
|
|
8
|
+
if (!statSync(p).isFile())
|
|
9
|
+
return false;
|
|
10
|
+
if (!isWin)
|
|
11
|
+
accessSync(p, constants.X_OK);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Finds the `claude` executable. On Windows a native `.exe` wins; a `.cmd` shim needs a shell. */
|
|
19
|
+
export function findClaudeBinary() {
|
|
20
|
+
const dirs = (process.env.PATH ?? '').split(delimiter).filter(Boolean);
|
|
21
|
+
const home = homedir();
|
|
22
|
+
if (isWin) {
|
|
23
|
+
const exeDirs = [...dirs, join(home, '.local', 'bin'), join(home, '.claude', 'local')];
|
|
24
|
+
for (const d of exeDirs) {
|
|
25
|
+
const p = join(d, 'claude.exe');
|
|
26
|
+
if (isFile(p))
|
|
27
|
+
return { file: p, shell: false };
|
|
28
|
+
}
|
|
29
|
+
const cmdDirs = [...dirs, join(process.env.APPDATA ?? join(home, 'AppData', 'Roaming'), 'npm')];
|
|
30
|
+
for (const d of cmdDirs) {
|
|
31
|
+
const p = join(d, 'claude.cmd');
|
|
32
|
+
if (isFile(p))
|
|
33
|
+
return { file: p, shell: true };
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
for (const d of [...dirs, join(home, '.local', 'bin'), join(home, '.claude', 'local')]) {
|
|
38
|
+
const p = join(d, 'claude');
|
|
39
|
+
if (isFile(p))
|
|
40
|
+
return { file: p, shell: false };
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
let cached;
|
|
45
|
+
export function claudeBinary() {
|
|
46
|
+
if (cached === undefined)
|
|
47
|
+
cached = findClaudeBinary();
|
|
48
|
+
return cached;
|
|
49
|
+
}
|
|
50
|
+
/** Args are validated by callers; with a `.cmd` shim each one is double-quoted for cmd.exe. */
|
|
51
|
+
function quoteForCmd(arg) {
|
|
52
|
+
if (/["%^&|<>!\r\n]/.test(arg))
|
|
53
|
+
throw new Error(`unsafe argument for shell: ${arg}`);
|
|
54
|
+
return `"${arg}"`;
|
|
55
|
+
}
|
|
56
|
+
export async function runClaude(args, opts = {}) {
|
|
57
|
+
const bin = claudeBinary();
|
|
58
|
+
if (!bin)
|
|
59
|
+
throw new Error('claude 실행 파일을 찾을 수 없습니다');
|
|
60
|
+
if (bin.shell)
|
|
61
|
+
return run(quoteForCmd(bin.file), args.map(quoteForCmd), { ...opts, shell: true });
|
|
62
|
+
return run(bin.file, args, opts);
|
|
63
|
+
}
|
|
64
|
+
/** Display form of a command line (for previews and logs). */
|
|
65
|
+
export function formatCommand(file, args) {
|
|
66
|
+
return [file, ...args].map((a) => (/^[A-Za-z0-9._@/:=-]+$/.test(a) ? a : `"${a}"`)).join(' ');
|
|
67
|
+
}
|
|
68
|
+
const VERSION_RETRY_MS = 60_000;
|
|
69
|
+
let versionPromise = null;
|
|
70
|
+
let versionFailedAt = 0;
|
|
71
|
+
/**
|
|
72
|
+
* `claude --version`, asked once. A failure is asked again after a minute: Claude Code installed while the app
|
|
73
|
+
* runs would otherwise show as missing until a restart.
|
|
74
|
+
*/
|
|
75
|
+
export function claudeVersion() {
|
|
76
|
+
if (versionPromise && versionFailedAt && Date.now() - versionFailedAt >= VERSION_RETRY_MS) {
|
|
77
|
+
versionPromise = null;
|
|
78
|
+
if (cached === null)
|
|
79
|
+
cached = undefined; // look for the binary again too
|
|
80
|
+
}
|
|
81
|
+
versionPromise ??= runClaude(['--version'], { timeoutMs: 15_000 })
|
|
82
|
+
.then((r) => (r.exitCode === 0 ? r.stdout.trim().split(/\s+/)[0] || null : null))
|
|
83
|
+
.catch(() => null)
|
|
84
|
+
.then((v) => {
|
|
85
|
+
versionFailedAt = v ? 0 : Date.now();
|
|
86
|
+
return v;
|
|
87
|
+
});
|
|
88
|
+
return versionPromise;
|
|
89
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
export const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
6
|
+
export const SERVER_DIR = join(ROOT_DIR, 'server');
|
|
7
|
+
export const WEB_DIST_DIR = join(ROOT_DIR, 'web', 'dist');
|
|
8
|
+
/** The release launcher keeps the token outside the app folder (UI debate D-6); from the environment only. */
|
|
9
|
+
export const ENV_PATH = process.env.SKILL_FINDER_ENV_PATH ? resolve(process.env.SKILL_FINDER_ENV_PATH) : join(ROOT_DIR, '.env');
|
|
10
|
+
export const SKILLS_CLI_VERSION = '1.7.4';
|
|
11
|
+
/** The bundled skills CLI: under the app, or wherever npm put it when installed as a package (npx hoists it). */
|
|
12
|
+
export const SKILLS_CLI_PATH = (() => {
|
|
13
|
+
const local = join(ROOT_DIR, 'node_modules', 'skills', 'bin', 'cli.mjs');
|
|
14
|
+
if (existsSync(local))
|
|
15
|
+
return local;
|
|
16
|
+
try {
|
|
17
|
+
return join(dirname(createRequire(import.meta.url).resolve('skills/package.json')), 'bin', 'cli.mjs');
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return local;
|
|
21
|
+
}
|
|
22
|
+
})();
|
|
23
|
+
/** Vite dev server port (web/vite.config.ts); allowed as a Host/Origin in --dev mode only. */
|
|
24
|
+
export const DEV_VITE_PORT = 5174;
|
|
25
|
+
function parseEnvFile(text) {
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const line of text.split(/\r?\n/)) {
|
|
28
|
+
const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
|
|
29
|
+
if (!m)
|
|
30
|
+
continue;
|
|
31
|
+
let value = m[2].trim();
|
|
32
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
|
|
33
|
+
value = value.slice(1, -1);
|
|
34
|
+
out[m[1]] = value;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
const fileEnv = existsSync(ENV_PATH) ? parseEnvFile(readFileSync(ENV_PATH, 'utf8')) : {};
|
|
39
|
+
const env = (key) => process.env[key] ?? fileEnv[key];
|
|
40
|
+
export const config = {
|
|
41
|
+
port: Number(env('PORT') ?? 4317) || 4317,
|
|
42
|
+
dev: process.argv.includes('--dev') || env('SKILL_FINDER_DEV') === '1',
|
|
43
|
+
dataDir: resolve(env('SKILL_FINDER_DATA_DIR') ?? join(ROOT_DIR, 'data')),
|
|
44
|
+
githubToken: env('GITHUB_TOKEN')?.trim() || null,
|
|
45
|
+
};
|
|
46
|
+
export function ensureDataDirs() {
|
|
47
|
+
mkdirSync(join(config.dataDir, 'tmp', 'git'), { recursive: true });
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Writes GITHUB_TOKEN into .env (creating it if needed) and updates the in-memory config. Written to a temporary
|
|
51
|
+
* file first and renamed over .env, so a crash or a full disk mid-write cannot leave a cut-off .env.
|
|
52
|
+
*/
|
|
53
|
+
export function saveGithubToken(token) {
|
|
54
|
+
const value = token?.trim() || '';
|
|
55
|
+
const lines = existsSync(ENV_PATH) ? readFileSync(ENV_PATH, 'utf8').split(/\r?\n/) : [];
|
|
56
|
+
let found = false;
|
|
57
|
+
const next = lines.map((line) => {
|
|
58
|
+
if (/^\s*(?:export\s+)?GITHUB_TOKEN\s*=/.test(line)) {
|
|
59
|
+
found = true;
|
|
60
|
+
return `GITHUB_TOKEN=${value}`;
|
|
61
|
+
}
|
|
62
|
+
return line;
|
|
63
|
+
});
|
|
64
|
+
if (!found)
|
|
65
|
+
next.push(`GITHUB_TOKEN=${value}`);
|
|
66
|
+
const tmp = `${ENV_PATH}.${process.pid}.tmp`;
|
|
67
|
+
writeFileSync(tmp, next.join('\n').replace(/\n*$/, '\n'), { encoding: 'utf8', mode: 0o600 });
|
|
68
|
+
renameSync(tmp, ENV_PATH);
|
|
69
|
+
config.githubToken = value || null;
|
|
70
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|