relayax-cli 0.4.34 → 0.4.36
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.
|
@@ -34,7 +34,13 @@ export declare function computeContentsDiff(contents: ContentEntry[], relayDir:
|
|
|
34
34
|
/**
|
|
35
35
|
* contents 항목 단위로 from → .relay/ 동기화한다.
|
|
36
36
|
*/
|
|
37
|
-
export declare function syncContentsToRelay(contents: ContentEntry[], contentsDiff: ContentDiffEntry[], relayDir: string, projectPath: string):
|
|
37
|
+
export declare function syncContentsToRelay(contents: ContentEntry[], contentsDiff: ContentDiffEntry[], relayDir: string, projectPath: string): {
|
|
38
|
+
removed: string[];
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* .relay/ 내에 있지만 contents 매니페스트에 없는 orphan 항목을 찾는다.
|
|
42
|
+
*/
|
|
43
|
+
export declare function findOrphanItems(contents: ContentEntry[], relayDir: string): string[];
|
|
38
44
|
/**
|
|
39
45
|
* 패키지 홈 디렉토리를 결정한다.
|
|
40
46
|
* 1. 프로젝트에 .relay/가 있으면 → projectPath/.relay/
|
package/dist/commands/package.js
CHANGED
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.computeContentsDiff = computeContentsDiff;
|
|
7
7
|
exports.syncContentsToRelay = syncContentsToRelay;
|
|
8
|
+
exports.findOrphanItems = findOrphanItems;
|
|
8
9
|
exports.resolveRelayDir = resolveRelayDir;
|
|
9
10
|
exports.initGlobalAgentHome = initGlobalAgentHome;
|
|
10
11
|
exports.registerPackage = registerPackage;
|
|
@@ -214,27 +215,60 @@ function discoverNewItems(contents, projectPath) {
|
|
|
214
215
|
* contents 항목 단위로 from → .relay/ 동기화한다.
|
|
215
216
|
*/
|
|
216
217
|
function syncContentsToRelay(contents, contentsDiff, relayDir, projectPath) {
|
|
218
|
+
const removed = [];
|
|
217
219
|
for (const diffEntry of contentsDiff) {
|
|
220
|
+
const content = contents.find((c) => c.name === diffEntry.name && c.type === diffEntry.type);
|
|
221
|
+
// source_missing: 소스에서 삭제됨 → .relay/에서도 제거
|
|
222
|
+
if (diffEntry.status === 'source_missing') {
|
|
223
|
+
const relaySubPath = content ? deriveRelaySubPath(content) : `${diffEntry.type}s/${diffEntry.name}`;
|
|
224
|
+
const relayTarget = path_1.default.join(relayDir, relaySubPath);
|
|
225
|
+
if (fs_1.default.existsSync(relayTarget)) {
|
|
226
|
+
fs_1.default.rmSync(relayTarget, { recursive: true, force: true });
|
|
227
|
+
removed.push(relaySubPath);
|
|
228
|
+
}
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
218
231
|
if (diffEntry.status !== 'modified')
|
|
219
232
|
continue;
|
|
220
|
-
const content = contents.find((c) => c.name === diffEntry.name && c.type === diffEntry.type);
|
|
221
233
|
if (!content)
|
|
222
234
|
continue;
|
|
223
235
|
const absFrom = resolveFromPath(getFromPath(content), projectPath);
|
|
224
236
|
const relaySubPath = deriveRelaySubPath(content);
|
|
225
237
|
const relayTarget = path_1.default.join(relayDir, relaySubPath);
|
|
226
|
-
// 단일 파일인 경우 직접 복사
|
|
238
|
+
// 단일 파일인 경우 직접 복사
|
|
227
239
|
if (fs_1.default.existsSync(absFrom) && fs_1.default.statSync(absFrom).isFile()) {
|
|
228
240
|
fs_1.default.mkdirSync(path_1.default.dirname(relayTarget), { recursive: true });
|
|
229
241
|
fs_1.default.copyFileSync(absFrom, relayTarget);
|
|
230
242
|
continue;
|
|
231
243
|
}
|
|
232
|
-
// 디렉토리인 경우 diff 기반 동기화
|
|
244
|
+
// 디렉토리인 경우 diff 기반 동기화 (deleted 포함)
|
|
233
245
|
const sourceFiles = scanPath(absFrom);
|
|
234
246
|
const relayFiles = scanPath(relayTarget);
|
|
235
247
|
const fileDiff = computeDiff(sourceFiles, relayFiles);
|
|
236
248
|
syncToRelay(absFrom, relayTarget, fileDiff);
|
|
237
249
|
}
|
|
250
|
+
return { removed };
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* .relay/ 내에 있지만 contents 매니페스트에 없는 orphan 항목을 찾는다.
|
|
254
|
+
*/
|
|
255
|
+
function findOrphanItems(contents, relayDir) {
|
|
256
|
+
const contentPaths = new Set(contents.map((c) => deriveRelaySubPath(c)));
|
|
257
|
+
const orphans = [];
|
|
258
|
+
for (const dir of SYNC_DIRS) {
|
|
259
|
+
const fullDir = path_1.default.join(relayDir, dir);
|
|
260
|
+
if (!fs_1.default.existsSync(fullDir))
|
|
261
|
+
continue;
|
|
262
|
+
for (const entry of fs_1.default.readdirSync(fullDir, { withFileTypes: true })) {
|
|
263
|
+
if (entry.name.startsWith('.'))
|
|
264
|
+
continue;
|
|
265
|
+
const relPath = `${dir}/${entry.name}`;
|
|
266
|
+
if (!contentPaths.has(relPath)) {
|
|
267
|
+
orphans.push(relPath);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return orphans;
|
|
238
272
|
}
|
|
239
273
|
// ─── Global Agent Home ───
|
|
240
274
|
/**
|
|
@@ -462,20 +496,31 @@ function registerPackage(program) {
|
|
|
462
496
|
}
|
|
463
497
|
// contents 기반 diff 계산
|
|
464
498
|
const { diff: contentsDiff, newItems } = computeContentsDiff(contents, relayDir, projectPath);
|
|
499
|
+
const orphans = findOrphanItems(contents, relayDir);
|
|
465
500
|
const summary = {
|
|
466
501
|
modified: contentsDiff.filter((d) => d.status === 'modified').length,
|
|
467
502
|
unchanged: contentsDiff.filter((d) => d.status === 'unchanged').length,
|
|
468
503
|
source_missing: contentsDiff.filter((d) => d.status === 'source_missing').length,
|
|
469
504
|
new_available: newItems.length,
|
|
505
|
+
orphaned: orphans.length,
|
|
470
506
|
};
|
|
471
|
-
const hasChanges = summary.modified > 0;
|
|
472
|
-
// --sync: contents 단위 동기화
|
|
507
|
+
const hasChanges = summary.modified > 0 || summary.source_missing > 0 || summary.orphaned > 0;
|
|
508
|
+
// --sync: contents 단위 동기화 + orphan 정리
|
|
473
509
|
if (opts.sync && hasChanges) {
|
|
474
|
-
syncContentsToRelay(contents, contentsDiff, relayDir, projectPath);
|
|
510
|
+
const { removed } = syncContentsToRelay(contents, contentsDiff, relayDir, projectPath);
|
|
511
|
+
// orphan 항목 삭제
|
|
512
|
+
for (const orphan of orphans) {
|
|
513
|
+
const orphanPath = path_1.default.join(relayDir, orphan);
|
|
514
|
+
if (fs_1.default.existsSync(orphanPath)) {
|
|
515
|
+
fs_1.default.rmSync(orphanPath, { recursive: true, force: true });
|
|
516
|
+
removed.push(orphan);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
475
519
|
}
|
|
476
520
|
const result = {
|
|
477
521
|
diff: contentsDiff.filter((d) => d.status !== 'unchanged'),
|
|
478
522
|
new_items: newItems,
|
|
523
|
+
orphans,
|
|
479
524
|
synced: opts.sync === true && hasChanges,
|
|
480
525
|
summary,
|
|
481
526
|
};
|
|
@@ -499,6 +544,12 @@ function registerPackage(program) {
|
|
|
499
544
|
}
|
|
500
545
|
}
|
|
501
546
|
}
|
|
547
|
+
if (orphans.length > 0) {
|
|
548
|
+
console.error('\n \x1b[33m.relay/에만 존재 (소스에서 삭제됨):\x1b[0m');
|
|
549
|
+
for (const orphan of orphans) {
|
|
550
|
+
console.error(` \x1b[31m✗ ${orphan}\x1b[0m`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
502
553
|
if (newItems.length > 0) {
|
|
503
554
|
console.error('\n 새로 발견된 콘텐츠:');
|
|
504
555
|
for (const item of newItems) {
|
|
@@ -506,7 +557,7 @@ function registerPackage(program) {
|
|
|
506
557
|
}
|
|
507
558
|
}
|
|
508
559
|
console.error('');
|
|
509
|
-
console.error(` 합계: 변경 ${summary.modified}, 유지 ${summary.unchanged}, 원본 없음 ${summary.source_missing}, 신규 ${summary.new_available}`);
|
|
560
|
+
console.error(` 합계: 변경 ${summary.modified}, 유지 ${summary.unchanged}, 원본 없음 ${summary.source_missing}, 신규 ${summary.new_available}, 고아 ${summary.orphaned}`);
|
|
510
561
|
if (opts.sync) {
|
|
511
562
|
console.error('\n✓ .relay/에 반영 완료');
|
|
512
563
|
}
|
package/dist/commands/publish.js
CHANGED
|
@@ -16,6 +16,7 @@ const error_report_js_1 = require("../lib/error-report.js");
|
|
|
16
16
|
const step_tracker_js_1 = require("../lib/step-tracker.js");
|
|
17
17
|
const git_operations_js_1 = require("../lib/git-operations.js");
|
|
18
18
|
const setup_command_js_1 = require("../lib/setup-command.js");
|
|
19
|
+
const requires_suggest_js_1 = require("../lib/requires-suggest.js");
|
|
19
20
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
20
21
|
const cliPkg = require('../../package.json');
|
|
21
22
|
const VALID_DIRS = ['skills', 'agents', 'rules', 'commands', 'bin'];
|
|
@@ -681,6 +682,28 @@ function registerPublish(program) {
|
|
|
681
682
|
console.error(` → relay.yaml에 visibility: ${config.visibility} 저장됨 (${visLabelMap[config.visibility]})\n`);
|
|
682
683
|
}
|
|
683
684
|
}
|
|
685
|
+
// ── Requires 자동 감지 + 제안 ──
|
|
686
|
+
if (isTTY && !json) {
|
|
687
|
+
const suggestions = (0, requires_suggest_js_1.suggestRequires)(relayDir, config.requires);
|
|
688
|
+
if (suggestions.length > 0) {
|
|
689
|
+
console.error('\n\x1b[33m⚡ requires에 빠진 항목이 감지되었습니다:\x1b[0m');
|
|
690
|
+
for (const line of (0, requires_suggest_js_1.formatSuggestions)(suggestions)) {
|
|
691
|
+
console.error(line);
|
|
692
|
+
}
|
|
693
|
+
const { confirm } = await import('@inquirer/prompts');
|
|
694
|
+
const addThem = await confirm({
|
|
695
|
+
message: 'requires에 추가할까요?',
|
|
696
|
+
default: true,
|
|
697
|
+
});
|
|
698
|
+
if (addThem) {
|
|
699
|
+
config.requires = (0, requires_suggest_js_1.mergeIntoRequires)(config.requires ?? {}, suggestions);
|
|
700
|
+
const yamlData = js_yaml_1.default.load(fs_1.default.readFileSync(relayYamlPath, 'utf-8'));
|
|
701
|
+
yamlData.requires = config.requires;
|
|
702
|
+
fs_1.default.writeFileSync(relayYamlPath, js_yaml_1.default.dump(yamlData, { lineWidth: 120 }), 'utf-8');
|
|
703
|
+
console.error(' → relay.yaml에 requires 업데이트됨\n');
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
684
707
|
// Generate setup command BEFORE detectCommands so it's included in metadata
|
|
685
708
|
{
|
|
686
709
|
const commandsDir = path_1.default.join(relayDir, 'commands');
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Requires } from '../commands/publish.js';
|
|
2
|
+
interface Suggestion {
|
|
3
|
+
category: 'env' | 'cli' | 'pip' | 'runtime';
|
|
4
|
+
name: string;
|
|
5
|
+
source: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
setup_hint?: string;
|
|
8
|
+
install?: string;
|
|
9
|
+
required?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* .relay/ 디렉토리를 스캔하여 requires에 빠진 항목을 제안한다.
|
|
13
|
+
*/
|
|
14
|
+
export declare function suggestRequires(relayDir: string, currentRequires?: Requires): Suggestion[];
|
|
15
|
+
/**
|
|
16
|
+
* 제안 항목을 사람이 읽기 좋은 형태로 포맷한다.
|
|
17
|
+
*/
|
|
18
|
+
export declare function formatSuggestions(suggestions: Suggestion[]): string[];
|
|
19
|
+
/**
|
|
20
|
+
* 제안 항목을 requires 객체에 병합한다.
|
|
21
|
+
*/
|
|
22
|
+
export declare function mergeIntoRequires(requires: Requires, suggestions: Suggestion[]): Requires;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.suggestRequires = suggestRequires;
|
|
7
|
+
exports.formatSuggestions = formatSuggestions;
|
|
8
|
+
exports.mergeIntoRequires = mergeIntoRequires;
|
|
9
|
+
const fs_1 = __importDefault(require("fs"));
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
11
|
+
// Python stdlib — 이 목록에 없는 import는 pip 패키지 후보
|
|
12
|
+
const PYTHON_STDLIB = new Set([
|
|
13
|
+
'abc', 'argparse', 'ast', 'asyncio', 'base64', 'bisect', 'calendar',
|
|
14
|
+
'cmath', 'codecs', 'collections', 'concurrent', 'contextlib', 'copy',
|
|
15
|
+
'csv', 'ctypes', 'dataclasses', 'datetime', 'decimal', 'difflib',
|
|
16
|
+
'email', 'enum', 'errno', 'filecmp', 'fnmatch', 'fractions', 'ftplib',
|
|
17
|
+
'functools', 'gc', 'gettext', 'glob', 'gzip', 'hashlib', 'heapq',
|
|
18
|
+
'hmac', 'html', 'http', 'imaplib', 'importlib', 'inspect', 'io',
|
|
19
|
+
'itertools', 'json', 'keyword', 'linecache', 'locale', 'logging',
|
|
20
|
+
'lzma', 'math', 'mimetypes', 'multiprocessing', 'numbers', 'operator',
|
|
21
|
+
'os', 'pathlib', 'pickle', 'platform', 'plistlib', 'pprint',
|
|
22
|
+
'profile', 'pstats', 'queue', 'random', 're', 'readline',
|
|
23
|
+
'reprlib', 'resource', 'rlcompleter', 'sched', 'secrets', 'select',
|
|
24
|
+
'shelve', 'shlex', 'shutil', 'signal', 'site', 'smtplib', 'socket',
|
|
25
|
+
'socketserver', 'sqlite3', 'ssl', 'stat', 'statistics', 'string',
|
|
26
|
+
'struct', 'subprocess', 'sys', 'sysconfig', 'syslog', 'tempfile',
|
|
27
|
+
'textwrap', 'threading', 'time', 'timeit', 'token', 'tokenize',
|
|
28
|
+
'tomllib', 'trace', 'traceback', 'tracemalloc', 'types', 'typing',
|
|
29
|
+
'unicodedata', 'unittest', 'urllib', 'uuid', 'venv', 'warnings',
|
|
30
|
+
'wave', 'weakref', 'webbrowser', 'xml', 'xmlrpc', 'zipfile', 'zipimport', 'zlib',
|
|
31
|
+
// 자주 쓰이는 서브모듈
|
|
32
|
+
'os.path', 'collections.abc', 'concurrent.futures', 'urllib.parse',
|
|
33
|
+
'http.client', 'http.server', 'email.mime',
|
|
34
|
+
]);
|
|
35
|
+
// pip 패키지명 → import 이름 매핑 (다른 경우만)
|
|
36
|
+
const PIP_IMPORT_MAP = {
|
|
37
|
+
'Pillow': 'PIL',
|
|
38
|
+
'google-genai': 'google',
|
|
39
|
+
'scikit-learn': 'sklearn',
|
|
40
|
+
'python-dotenv': 'dotenv',
|
|
41
|
+
'beautifulsoup4': 'bs4',
|
|
42
|
+
'opencv-python': 'cv2',
|
|
43
|
+
'pyyaml': 'yaml',
|
|
44
|
+
};
|
|
45
|
+
// import 이름 → pip 패키지명 역매핑
|
|
46
|
+
const IMPORT_TO_PIP = {};
|
|
47
|
+
for (const [pip, imp] of Object.entries(PIP_IMPORT_MAP)) {
|
|
48
|
+
IMPORT_TO_PIP[imp] = pip;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* .relay/ 디렉토리를 스캔하여 requires에 빠진 항목을 제안한다.
|
|
52
|
+
*/
|
|
53
|
+
function suggestRequires(relayDir, currentRequires) {
|
|
54
|
+
const suggestions = [];
|
|
55
|
+
const existing = normalizeExisting(currentRequires);
|
|
56
|
+
// 모든 스크립트 파일 수집
|
|
57
|
+
const scripts = findScripts(relayDir);
|
|
58
|
+
for (const scriptPath of scripts) {
|
|
59
|
+
const content = fs_1.default.readFileSync(scriptPath, 'utf-8');
|
|
60
|
+
const relName = path_1.default.relative(relayDir, scriptPath);
|
|
61
|
+
const ext = path_1.default.extname(scriptPath);
|
|
62
|
+
// Python 스크립트
|
|
63
|
+
if (ext === '.py') {
|
|
64
|
+
// 환경변수 감지: os.environ.get("VAR") / os.environ["VAR"] / os.getenv("VAR")
|
|
65
|
+
const envPattern = /os\.environ\.get\(\s*['"](\w+)['"]/g;
|
|
66
|
+
const envPattern2 = /os\.environ\[['"](\w+)['"]\]/g;
|
|
67
|
+
const envPattern3 = /os\.getenv\(\s*['"](\w+)['"]/g;
|
|
68
|
+
for (const pattern of [envPattern, envPattern2, envPattern3]) {
|
|
69
|
+
let match;
|
|
70
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
71
|
+
const varName = match[1];
|
|
72
|
+
if (!existing.envNames.has(varName)) {
|
|
73
|
+
suggestions.push({ category: 'env', name: varName, source: relName });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// pip 패키지 감지: import xxx / from xxx import
|
|
78
|
+
const importPattern = /^(?:import|from)\s+(\w+)/gm;
|
|
79
|
+
let match;
|
|
80
|
+
while ((match = importPattern.exec(content)) !== null) {
|
|
81
|
+
const moduleName = match[1];
|
|
82
|
+
if (PYTHON_STDLIB.has(moduleName))
|
|
83
|
+
continue;
|
|
84
|
+
const pipName = IMPORT_TO_PIP[moduleName] ?? moduleName;
|
|
85
|
+
if (!existing.pipNames.has(pipName) && !existing.pipNames.has(moduleName)) {
|
|
86
|
+
suggestions.push({ category: 'pip', name: pipName, source: relName });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// shebang → python3 런타임
|
|
90
|
+
if (content.startsWith('#!/') && content.includes('python')) {
|
|
91
|
+
if (!existing.hasRuntime.python) {
|
|
92
|
+
suggestions.push({ category: 'runtime', name: 'python', source: relName });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Node/TS 스크립트
|
|
97
|
+
if (ext === '.js' || ext === '.ts' || ext === '.mjs') {
|
|
98
|
+
// 환경변수 감지: process.env.VAR / process.env['VAR']
|
|
99
|
+
const envPattern = /process\.env\.(\w+)/g;
|
|
100
|
+
const envPattern2 = /process\.env\[['"](\w+)['"]\]/g;
|
|
101
|
+
for (const pattern of [envPattern, envPattern2]) {
|
|
102
|
+
let match;
|
|
103
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
104
|
+
const varName = match[1];
|
|
105
|
+
if (['NODE_ENV', 'PATH', 'HOME', 'PWD'].includes(varName))
|
|
106
|
+
continue;
|
|
107
|
+
if (!existing.envNames.has(varName)) {
|
|
108
|
+
suggestions.push({ category: 'env', name: varName, source: relName });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// setup-* 스킬 감지: 관련 env가 requires에 없으면 제안
|
|
115
|
+
const skillsDir = path_1.default.join(relayDir, 'skills');
|
|
116
|
+
if (fs_1.default.existsSync(skillsDir)) {
|
|
117
|
+
for (const entry of fs_1.default.readdirSync(skillsDir, { withFileTypes: true })) {
|
|
118
|
+
if (entry.isDirectory() && entry.name.startsWith('setup-')) {
|
|
119
|
+
const setupName = entry.name; // e.g., setup-kling
|
|
120
|
+
const skillMd = path_1.default.join(skillsDir, setupName, 'SKILL.md');
|
|
121
|
+
if (fs_1.default.existsSync(skillMd)) {
|
|
122
|
+
const skillContent = fs_1.default.readFileSync(skillMd, 'utf-8');
|
|
123
|
+
// SKILL.md에서 환경변수 참조 찾기
|
|
124
|
+
const envRefs = /[A-Z][A-Z0-9_]{2,}/g;
|
|
125
|
+
let match;
|
|
126
|
+
while ((match = envRefs.exec(skillContent)) !== null) {
|
|
127
|
+
const varName = match[0];
|
|
128
|
+
if (['SKILL', 'WHEN', 'THEN', 'SHALL', 'MUST', 'TODO', 'NOTE', 'IMPORTANT', 'WARNING', 'ERROR', 'JSON', 'API', 'URL', 'HTTP', 'HTTPS', 'MCP', 'CLI', 'SDK', 'README', 'YAML'].includes(varName))
|
|
129
|
+
continue;
|
|
130
|
+
if (varName.endsWith('_KEY') || varName.endsWith('_TOKEN') || varName.endsWith('_SECRET') || varName.endsWith('_SESSION')) {
|
|
131
|
+
if (!existing.envNames.has(varName)) {
|
|
132
|
+
suggestions.push({
|
|
133
|
+
category: 'env',
|
|
134
|
+
name: varName,
|
|
135
|
+
source: `skills/${setupName}/SKILL.md`,
|
|
136
|
+
setup_hint: `/${setupName} 스킬을 실행하세요`,
|
|
137
|
+
required: false,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// CLI 도구 감지: subprocess.run(['cmd', ...]) / execSync('cmd ...')
|
|
147
|
+
for (const scriptPath of scripts) {
|
|
148
|
+
const content = fs_1.default.readFileSync(scriptPath, 'utf-8');
|
|
149
|
+
const relName = path_1.default.relative(relayDir, scriptPath);
|
|
150
|
+
// Python subprocess
|
|
151
|
+
const subprocessPattern = /subprocess\.(?:run|Popen|call)\(\s*\[?\s*['"](\w+)['"]/g;
|
|
152
|
+
let match;
|
|
153
|
+
while ((match = subprocessPattern.exec(content)) !== null) {
|
|
154
|
+
const cmd = match[1];
|
|
155
|
+
if (['python', 'python3', 'node', 'npm', 'npx'].includes(cmd))
|
|
156
|
+
continue;
|
|
157
|
+
if (!existing.cliNames.has(cmd)) {
|
|
158
|
+
suggestions.push({ category: 'cli', name: cmd, source: relName });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Node execSync
|
|
162
|
+
const execPattern = /execSync\(\s*['"`](\w+)/g;
|
|
163
|
+
while ((match = execPattern.exec(content)) !== null) {
|
|
164
|
+
const cmd = match[1];
|
|
165
|
+
if (['node', 'npm', 'npx', 'git'].includes(cmd))
|
|
166
|
+
continue;
|
|
167
|
+
if (!existing.cliNames.has(cmd)) {
|
|
168
|
+
suggestions.push({ category: 'cli', name: cmd, source: relName });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// 중복 제거
|
|
173
|
+
const seen = new Set();
|
|
174
|
+
return suggestions.filter((s) => {
|
|
175
|
+
const key = `${s.category}:${s.name}`;
|
|
176
|
+
if (seen.has(key))
|
|
177
|
+
return false;
|
|
178
|
+
seen.add(key);
|
|
179
|
+
return true;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
function normalizeExisting(requires) {
|
|
183
|
+
const envNames = new Set();
|
|
184
|
+
const cliNames = new Set();
|
|
185
|
+
const pipNames = new Set();
|
|
186
|
+
const npmNames = new Set();
|
|
187
|
+
const hasRuntime = { node: false, python: false };
|
|
188
|
+
if (!requires)
|
|
189
|
+
return { envNames, cliNames, pipNames, npmNames, hasRuntime };
|
|
190
|
+
for (const e of requires.env ?? [])
|
|
191
|
+
envNames.add(e.name);
|
|
192
|
+
for (const c of requires.cli ?? [])
|
|
193
|
+
cliNames.add(c.name);
|
|
194
|
+
for (const n of requires.npm ?? [])
|
|
195
|
+
npmNames.add(typeof n === 'string' ? n : n.name);
|
|
196
|
+
// pip은 Requires 타입에 없지만 relay.yaml에 존재할 수 있음
|
|
197
|
+
const raw = requires;
|
|
198
|
+
if (Array.isArray(raw.pip)) {
|
|
199
|
+
for (const p of raw.pip)
|
|
200
|
+
pipNames.add(typeof p === 'string' ? p : p.name);
|
|
201
|
+
}
|
|
202
|
+
if (requires.runtime?.node)
|
|
203
|
+
hasRuntime.node = true;
|
|
204
|
+
if (requires.runtime?.python)
|
|
205
|
+
hasRuntime.python = true;
|
|
206
|
+
return { envNames, cliNames, pipNames, npmNames, hasRuntime };
|
|
207
|
+
}
|
|
208
|
+
function findScripts(dir) {
|
|
209
|
+
const scripts = [];
|
|
210
|
+
if (!fs_1.default.existsSync(dir))
|
|
211
|
+
return scripts;
|
|
212
|
+
function walk(d) {
|
|
213
|
+
for (const entry of fs_1.default.readdirSync(d, { withFileTypes: true })) {
|
|
214
|
+
if (entry.name.startsWith('.'))
|
|
215
|
+
continue;
|
|
216
|
+
const full = path_1.default.join(d, entry.name);
|
|
217
|
+
if (entry.isDirectory()) {
|
|
218
|
+
walk(full);
|
|
219
|
+
}
|
|
220
|
+
else if (/\.(py|js|ts|mjs|sh)$/.test(entry.name)) {
|
|
221
|
+
scripts.push(full);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
walk(dir);
|
|
226
|
+
return scripts;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* 제안 항목을 사람이 읽기 좋은 형태로 포맷한다.
|
|
230
|
+
*/
|
|
231
|
+
function formatSuggestions(suggestions) {
|
|
232
|
+
const lines = [];
|
|
233
|
+
const envs = suggestions.filter((s) => s.category === 'env');
|
|
234
|
+
const clis = suggestions.filter((s) => s.category === 'cli');
|
|
235
|
+
const pips = suggestions.filter((s) => s.category === 'pip');
|
|
236
|
+
const runtimes = suggestions.filter((s) => s.category === 'runtime');
|
|
237
|
+
if (envs.length > 0) {
|
|
238
|
+
lines.push(' 환경변수:');
|
|
239
|
+
for (const e of envs) {
|
|
240
|
+
const hint = e.setup_hint ? ` (${e.setup_hint})` : '';
|
|
241
|
+
lines.push(` ${e.name}${hint} — ${e.source}에서 감지`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (clis.length > 0) {
|
|
245
|
+
lines.push(' CLI 도구:');
|
|
246
|
+
for (const c of clis)
|
|
247
|
+
lines.push(` ${c.name} — ${c.source}에서 감지`);
|
|
248
|
+
}
|
|
249
|
+
if (pips.length > 0) {
|
|
250
|
+
lines.push(' pip 패키지:');
|
|
251
|
+
for (const p of pips)
|
|
252
|
+
lines.push(` ${p.name} — ${p.source}에서 감지`);
|
|
253
|
+
}
|
|
254
|
+
if (runtimes.length > 0) {
|
|
255
|
+
lines.push(' 런타임:');
|
|
256
|
+
for (const r of runtimes)
|
|
257
|
+
lines.push(` ${r.name} — ${r.source}에서 감지`);
|
|
258
|
+
}
|
|
259
|
+
return lines;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* 제안 항목을 requires 객체에 병합한다.
|
|
263
|
+
*/
|
|
264
|
+
function mergeIntoRequires(requires, suggestions) {
|
|
265
|
+
const result = { ...requires };
|
|
266
|
+
for (const s of suggestions) {
|
|
267
|
+
if (s.category === 'env') {
|
|
268
|
+
if (!result.env)
|
|
269
|
+
result.env = [];
|
|
270
|
+
result.env.push({
|
|
271
|
+
name: s.name,
|
|
272
|
+
required: s.required ?? true,
|
|
273
|
+
description: s.description,
|
|
274
|
+
setup_hint: s.setup_hint,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
else if (s.category === 'cli') {
|
|
278
|
+
if (!result.cli)
|
|
279
|
+
result.cli = [];
|
|
280
|
+
result.cli.push({
|
|
281
|
+
name: s.name,
|
|
282
|
+
install: s.install,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
else if (s.category === 'runtime') {
|
|
286
|
+
if (!result.runtime)
|
|
287
|
+
result.runtime = {};
|
|
288
|
+
if (s.name === 'python')
|
|
289
|
+
result.runtime.python = '>=3.8';
|
|
290
|
+
if (s.name === 'node')
|
|
291
|
+
result.runtime.node = '>=18';
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return result;
|
|
295
|
+
}
|