relayax-cli 0.1.94 → 0.1.96
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/dist/commands/create.js +2 -2
- package/dist/commands/init.js +6 -114
- package/dist/commands/publish.d.ts +27 -2
- package/dist/commands/publish.js +111 -44
- package/dist/lib/command-adapter.js +128 -27
- package/dist/lib/config.d.ts +1 -1
- package/dist/lib/config.js +1 -1
- package/dist/lib/security-scan.d.ts +19 -0
- package/dist/lib/security-scan.js +114 -0
- package/package.json +1 -1
package/dist/commands/create.js
CHANGED
|
@@ -9,7 +9,7 @@ const path_1 = __importDefault(require("path"));
|
|
|
9
9
|
const js_yaml_1 = __importDefault(require("js-yaml"));
|
|
10
10
|
const ai_tools_js_1 = require("../lib/ai-tools.js");
|
|
11
11
|
const command_adapter_js_1 = require("../lib/command-adapter.js");
|
|
12
|
-
const DEFAULT_DIRS = ['skills', 'commands'];
|
|
12
|
+
const DEFAULT_DIRS = ['.relay/skills', '.relay/commands'];
|
|
13
13
|
/**
|
|
14
14
|
* 글로벌 User 커맨드가 없으면 설치한다.
|
|
15
15
|
*/
|
|
@@ -82,7 +82,7 @@ function registerCreate(program) {
|
|
|
82
82
|
};
|
|
83
83
|
fs_1.default.writeFileSync(relayYamlPath, js_yaml_1.default.dump(yamlData, { lineWidth: 120 }), 'utf-8');
|
|
84
84
|
// 4. 디렉토리 구조 생성
|
|
85
|
-
const createdDirs = [
|
|
85
|
+
const createdDirs = [];
|
|
86
86
|
for (const dir of DEFAULT_DIRS) {
|
|
87
87
|
const dirPath = path_1.default.join(projectPath, dir);
|
|
88
88
|
if (!fs_1.default.existsSync(dirPath)) {
|
package/dist/commands/init.js
CHANGED
|
@@ -77,93 +77,22 @@ function hasGlobalUserCommands() {
|
|
|
77
77
|
return command_adapter_js_1.USER_COMMANDS.every((cmd) => fs_1.default.existsSync((0, command_adapter_js_1.getGlobalCommandPath)(cmd.id)));
|
|
78
78
|
}
|
|
79
79
|
/**
|
|
80
|
-
* 팀 프로젝트인지 감지한다 (.relay/relay.yaml 또는 팀 디렉토리 구조).
|
|
80
|
+
* 팀 프로젝트인지 감지한다 (.relay/ 디렉토리 내 relay.yaml 또는 팀 디렉토리 구조).
|
|
81
81
|
*/
|
|
82
82
|
function isTeamProject(projectPath) {
|
|
83
|
-
|
|
83
|
+
const relayDir = path_1.default.join(projectPath, '.relay');
|
|
84
|
+
if (!fs_1.default.existsSync(relayDir))
|
|
85
|
+
return false;
|
|
86
|
+
if (fs_1.default.existsSync(path_1.default.join(relayDir, 'relay.yaml'))) {
|
|
84
87
|
return true;
|
|
85
88
|
}
|
|
86
89
|
return VALID_TEAM_DIRS.some((d) => {
|
|
87
|
-
const dirPath = path_1.default.join(
|
|
90
|
+
const dirPath = path_1.default.join(relayDir, d);
|
|
88
91
|
if (!fs_1.default.existsSync(dirPath))
|
|
89
92
|
return false;
|
|
90
93
|
return fs_1.default.readdirSync(dirPath).filter((f) => !f.startsWith('.')).length > 0;
|
|
91
94
|
});
|
|
92
95
|
}
|
|
93
|
-
/** 레거시 User 커맨드 ID (이전에 로컬에 설치되던 것) */
|
|
94
|
-
const LEGACY_LOCAL_COMMAND_IDS = ['relay-explore', 'relay-install', 'relay-publish'];
|
|
95
|
-
/**
|
|
96
|
-
* 레거시 구조를 감지하고 마이그레이션한다.
|
|
97
|
-
* - relay.yaml (루트) → .relay/relay.yaml
|
|
98
|
-
* - portfolio/ (루트) → .relay/portfolio/
|
|
99
|
-
* - 로컬 레거시 슬래시 커맨드 제거 (글로벌로 이동되므로)
|
|
100
|
-
*/
|
|
101
|
-
function detectLegacy(projectPath) {
|
|
102
|
-
const details = [];
|
|
103
|
-
if (fs_1.default.existsSync(path_1.default.join(projectPath, 'relay.yaml'))) {
|
|
104
|
-
details.push('relay.yaml → .relay/relay.yaml');
|
|
105
|
-
}
|
|
106
|
-
const legacyPortfolio = path_1.default.join(projectPath, 'portfolio');
|
|
107
|
-
if (fs_1.default.existsSync(legacyPortfolio) && fs_1.default.statSync(legacyPortfolio).isDirectory()) {
|
|
108
|
-
const hasImages = fs_1.default.readdirSync(legacyPortfolio).some((f) => ['.png', '.jpg', '.jpeg', '.webp'].some((ext) => f.toLowerCase().endsWith(ext)));
|
|
109
|
-
if (hasImages) {
|
|
110
|
-
details.push('portfolio/ → .relay/portfolio/');
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
// 로컬에 레거시 슬래시 커맨드가 있는지 확인
|
|
114
|
-
const detected = (0, ai_tools_js_1.detectAgentCLIs)(projectPath);
|
|
115
|
-
for (const tool of detected) {
|
|
116
|
-
const cmdDir = path_1.default.join(projectPath, tool.skillsDir, 'commands', 'relay');
|
|
117
|
-
if (!fs_1.default.existsSync(cmdDir))
|
|
118
|
-
continue;
|
|
119
|
-
for (const cmdId of LEGACY_LOCAL_COMMAND_IDS) {
|
|
120
|
-
if (fs_1.default.existsSync(path_1.default.join(cmdDir, `${cmdId}.md`))) {
|
|
121
|
-
details.push(`${tool.skillsDir}/commands/relay/${cmdId}.md 제거 (글로벌로 이동)`);
|
|
122
|
-
break; // 한 번만 표시
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
return { hasLegacy: details.length > 0, details };
|
|
127
|
-
}
|
|
128
|
-
function runMigration(projectPath) {
|
|
129
|
-
const result = { relayYaml: false, portfolio: false, localCommandsCleaned: 0 };
|
|
130
|
-
const relayDir = path_1.default.join(projectPath, '.relay');
|
|
131
|
-
// 1. relay.yaml 이동
|
|
132
|
-
const legacyYaml = path_1.default.join(projectPath, 'relay.yaml');
|
|
133
|
-
const newYaml = path_1.default.join(relayDir, 'relay.yaml');
|
|
134
|
-
if (fs_1.default.existsSync(legacyYaml) && !fs_1.default.existsSync(newYaml)) {
|
|
135
|
-
fs_1.default.mkdirSync(relayDir, { recursive: true });
|
|
136
|
-
fs_1.default.renameSync(legacyYaml, newYaml);
|
|
137
|
-
result.relayYaml = true;
|
|
138
|
-
}
|
|
139
|
-
// 2. portfolio/ 이동
|
|
140
|
-
const legacyPortfolio = path_1.default.join(projectPath, 'portfolio');
|
|
141
|
-
const newPortfolio = path_1.default.join(relayDir, 'portfolio');
|
|
142
|
-
if (fs_1.default.existsSync(legacyPortfolio) && fs_1.default.statSync(legacyPortfolio).isDirectory() && !fs_1.default.existsSync(newPortfolio)) {
|
|
143
|
-
fs_1.default.renameSync(legacyPortfolio, newPortfolio);
|
|
144
|
-
result.portfolio = true;
|
|
145
|
-
}
|
|
146
|
-
// 3. 로컬 레거시 슬래시 커맨드 제거
|
|
147
|
-
const detected = (0, ai_tools_js_1.detectAgentCLIs)(projectPath);
|
|
148
|
-
for (const tool of detected) {
|
|
149
|
-
const cmdDir = path_1.default.join(projectPath, tool.skillsDir, 'commands', 'relay');
|
|
150
|
-
if (!fs_1.default.existsSync(cmdDir))
|
|
151
|
-
continue;
|
|
152
|
-
for (const cmdId of LEGACY_LOCAL_COMMAND_IDS) {
|
|
153
|
-
const cmdPath = path_1.default.join(cmdDir, `${cmdId}.md`);
|
|
154
|
-
if (fs_1.default.existsSync(cmdPath)) {
|
|
155
|
-
fs_1.default.unlinkSync(cmdPath);
|
|
156
|
-
result.localCommandsCleaned++;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
// 디렉토리가 비었으면 삭제
|
|
160
|
-
const remaining = fs_1.default.readdirSync(cmdDir);
|
|
161
|
-
if (remaining.length === 0) {
|
|
162
|
-
fs_1.default.rmdirSync(cmdDir);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
return result;
|
|
166
|
-
}
|
|
167
96
|
function registerInit(program) {
|
|
168
97
|
program
|
|
169
98
|
.command('init')
|
|
@@ -173,42 +102,6 @@ function registerInit(program) {
|
|
|
173
102
|
.action(async (opts) => {
|
|
174
103
|
const json = program.opts().json ?? false;
|
|
175
104
|
const projectPath = process.cwd();
|
|
176
|
-
// ── 0. 레거시 마이그레이션 ──
|
|
177
|
-
const legacy = detectLegacy(projectPath);
|
|
178
|
-
let migrated = false;
|
|
179
|
-
if (legacy.hasLegacy) {
|
|
180
|
-
if (json) {
|
|
181
|
-
// JSON 모드: 자동 마이그레이션
|
|
182
|
-
const migrationResult = runMigration(projectPath);
|
|
183
|
-
migrated = migrationResult.relayYaml || migrationResult.portfolio || migrationResult.localCommandsCleaned > 0;
|
|
184
|
-
}
|
|
185
|
-
else if (process.stdin.isTTY) {
|
|
186
|
-
console.log('\n \x1b[33m⚠ 레거시 구조 감지\x1b[0m\n');
|
|
187
|
-
for (const d of legacy.details) {
|
|
188
|
-
console.log(` ${d}`);
|
|
189
|
-
}
|
|
190
|
-
console.log();
|
|
191
|
-
const { confirm } = await import('@inquirer/prompts');
|
|
192
|
-
const doMigrate = await confirm({ message: '마이그레이션할까요?', default: true });
|
|
193
|
-
if (doMigrate) {
|
|
194
|
-
const migrationResult = runMigration(projectPath);
|
|
195
|
-
migrated = true;
|
|
196
|
-
console.log(`\n \x1b[32m✓ 마이그레이션 완료\x1b[0m`);
|
|
197
|
-
if (migrationResult.relayYaml)
|
|
198
|
-
console.log(' relay.yaml → .relay/relay.yaml');
|
|
199
|
-
if (migrationResult.portfolio)
|
|
200
|
-
console.log(' portfolio/ → .relay/portfolio/');
|
|
201
|
-
if (migrationResult.localCommandsCleaned > 0)
|
|
202
|
-
console.log(` 레거시 로컬 커맨드 ${migrationResult.localCommandsCleaned}개 제거`);
|
|
203
|
-
console.log();
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
else {
|
|
207
|
-
// 비TTY, 비JSON: 자동 마이그레이션
|
|
208
|
-
const migrationResult = runMigration(projectPath);
|
|
209
|
-
migrated = migrationResult.relayYaml || migrationResult.portfolio || migrationResult.localCommandsCleaned > 0;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
105
|
const detected = (0, ai_tools_js_1.detectAgentCLIs)(projectPath);
|
|
213
106
|
const detectedIds = new Set(detected.map((t) => t.value));
|
|
214
107
|
const isBuilder = isTeamProject(projectPath);
|
|
@@ -293,7 +186,6 @@ function registerInit(program) {
|
|
|
293
186
|
console.log(JSON.stringify({
|
|
294
187
|
status: 'ok',
|
|
295
188
|
mode: isBuilder ? 'builder' : 'user',
|
|
296
|
-
migrated,
|
|
297
189
|
global: {
|
|
298
190
|
status: globalStatus,
|
|
299
191
|
path: (0, command_adapter_js_1.getGlobalCommandDir)(),
|
|
@@ -2,21 +2,46 @@ import { Command } from 'commander';
|
|
|
2
2
|
export interface RequiresCli {
|
|
3
3
|
name: string;
|
|
4
4
|
install?: string;
|
|
5
|
+
required?: boolean;
|
|
5
6
|
}
|
|
6
7
|
export interface RequiresMcp {
|
|
7
8
|
name: string;
|
|
8
9
|
package?: string;
|
|
10
|
+
required?: boolean;
|
|
11
|
+
config?: {
|
|
12
|
+
command: string;
|
|
13
|
+
args?: string[];
|
|
14
|
+
};
|
|
15
|
+
env?: string[];
|
|
16
|
+
}
|
|
17
|
+
export interface RequiresConnector {
|
|
18
|
+
name: string;
|
|
19
|
+
type: string;
|
|
20
|
+
auth?: string;
|
|
21
|
+
env?: string;
|
|
22
|
+
required?: boolean;
|
|
23
|
+
description?: string;
|
|
9
24
|
}
|
|
10
25
|
export interface RequiresEnv {
|
|
11
26
|
name: string;
|
|
12
|
-
|
|
27
|
+
required?: boolean;
|
|
13
28
|
description?: string;
|
|
14
29
|
}
|
|
30
|
+
export interface RequiresNpm {
|
|
31
|
+
name: string;
|
|
32
|
+
required?: boolean;
|
|
33
|
+
}
|
|
15
34
|
export interface Requires {
|
|
16
35
|
cli?: RequiresCli[];
|
|
17
36
|
mcp?: RequiresMcp[];
|
|
18
|
-
npm?: string[];
|
|
37
|
+
npm?: (string | RequiresNpm)[];
|
|
19
38
|
env?: RequiresEnv[];
|
|
20
39
|
teams?: string[];
|
|
40
|
+
connectors?: RequiresConnector[];
|
|
41
|
+
runtime?: {
|
|
42
|
+
node?: string;
|
|
43
|
+
python?: string;
|
|
44
|
+
};
|
|
45
|
+
permissions?: string[];
|
|
21
46
|
}
|
|
22
47
|
export declare function registerPublish(program: Command): void;
|
package/dist/commands/publish.js
CHANGED
|
@@ -21,13 +21,34 @@ function parseRelayYaml(content) {
|
|
|
21
21
|
const tags = Array.isArray(raw.tags)
|
|
22
22
|
? raw.tags.map((t) => String(t))
|
|
23
23
|
: [];
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
// Parse portfolio slots structure
|
|
25
|
+
const rawPortfolio = raw.portfolio;
|
|
26
|
+
const portfolio = {};
|
|
27
|
+
if (rawPortfolio && typeof rawPortfolio === 'object' && !Array.isArray(rawPortfolio)) {
|
|
28
|
+
// New slot-based format: { cover: {...}, demo: {...}, gallery: [...] }
|
|
29
|
+
if (rawPortfolio.cover && typeof rawPortfolio.cover === 'object') {
|
|
30
|
+
const c = rawPortfolio.cover;
|
|
31
|
+
if (c.path)
|
|
32
|
+
portfolio.cover = { path: String(c.path) };
|
|
33
|
+
}
|
|
34
|
+
if (rawPortfolio.demo && typeof rawPortfolio.demo === 'object') {
|
|
35
|
+
const d = rawPortfolio.demo;
|
|
36
|
+
portfolio.demo = {
|
|
37
|
+
type: String(d.type ?? 'gif'),
|
|
38
|
+
path: d.path ? String(d.path) : undefined,
|
|
39
|
+
url: d.url ? String(d.url) : undefined,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (Array.isArray(rawPortfolio.gallery)) {
|
|
43
|
+
portfolio.gallery = rawPortfolio.gallery
|
|
44
|
+
.map((g) => ({
|
|
45
|
+
path: String(g.path ?? ''),
|
|
46
|
+
title: String(g.title ?? ''),
|
|
47
|
+
description: g.description ? String(g.description) : undefined,
|
|
48
|
+
}))
|
|
49
|
+
.filter((g) => g.path);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
31
52
|
const requires = raw.requires;
|
|
32
53
|
const rawVisibility = String(raw.visibility ?? '');
|
|
33
54
|
const visibility = rawVisibility === 'login-only' ? 'login-only'
|
|
@@ -86,28 +107,58 @@ function countDir(teamDir, dirName) {
|
|
|
86
107
|
return fs_1.default.readdirSync(dirPath).filter((f) => !f.startsWith('.')).length;
|
|
87
108
|
}
|
|
88
109
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* 2. 없으면 ./portfolio/ 디렉토리 자동 스캔
|
|
110
|
+
* 슬롯 기반 포트폴리오를 PortfolioEntry[] 로 평탄화한다.
|
|
111
|
+
* relay.yaml에 슬롯이 정의되어 있으면 사용, 없으면 portfolio/ 자동 스캔.
|
|
92
112
|
*/
|
|
93
|
-
function collectPortfolio(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
113
|
+
function collectPortfolio(relayDir, slots) {
|
|
114
|
+
const entries = [];
|
|
115
|
+
// Cover
|
|
116
|
+
if (slots.cover?.path) {
|
|
117
|
+
const absPath = path_1.default.resolve(relayDir, slots.cover.path);
|
|
118
|
+
if (fs_1.default.existsSync(absPath)) {
|
|
119
|
+
entries.push({ path: slots.cover.path, title: 'Cover', slot_type: 'cover' });
|
|
120
|
+
}
|
|
99
121
|
}
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
122
|
+
// Demo
|
|
123
|
+
if (slots.demo) {
|
|
124
|
+
if (slots.demo.type === 'video_url' && slots.demo.url) {
|
|
125
|
+
entries.push({ path: '', title: 'Demo', slot_type: 'demo', demo_url: slots.demo.url });
|
|
126
|
+
}
|
|
127
|
+
else if (slots.demo.path) {
|
|
128
|
+
const absPath = path_1.default.resolve(relayDir, slots.demo.path);
|
|
129
|
+
if (fs_1.default.existsSync(absPath)) {
|
|
130
|
+
entries.push({ path: slots.demo.path, title: 'Demo', slot_type: 'demo' });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Gallery
|
|
135
|
+
if (slots.gallery && slots.gallery.length > 0) {
|
|
136
|
+
for (const g of slots.gallery.slice(0, 5)) {
|
|
137
|
+
const absPath = path_1.default.resolve(relayDir, g.path);
|
|
138
|
+
if (fs_1.default.existsSync(absPath)) {
|
|
139
|
+
entries.push({ path: g.path, title: g.title, description: g.description, slot_type: 'gallery' });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// If no slots defined, auto-scan portfolio/ directory
|
|
144
|
+
if (entries.length === 0) {
|
|
145
|
+
const portfolioDir = path_1.default.join(relayDir, 'portfolio');
|
|
146
|
+
if (fs_1.default.existsSync(portfolioDir)) {
|
|
147
|
+
const files = fs_1.default.readdirSync(portfolioDir)
|
|
148
|
+
.filter((f) => IMAGE_EXTS.some((ext) => f.toLowerCase().endsWith(ext)))
|
|
149
|
+
.sort();
|
|
150
|
+
// First image as cover, rest as gallery
|
|
151
|
+
for (let i = 0; i < files.length && i < 6; i++) {
|
|
152
|
+
const f = files[i];
|
|
153
|
+
entries.push({
|
|
154
|
+
path: path_1.default.join('portfolio', f),
|
|
155
|
+
title: path_1.default.basename(f, path_1.default.extname(f)).replace(/[-_]/g, ' '),
|
|
156
|
+
slot_type: i === 0 ? 'cover' : 'gallery',
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return entries;
|
|
111
162
|
}
|
|
112
163
|
/**
|
|
113
164
|
* long_description을 결정한다.
|
|
@@ -144,12 +195,24 @@ async function publishToApi(token, tarPath, metadata, teamDir, portfolioEntries)
|
|
|
144
195
|
const form = new FormData();
|
|
145
196
|
form.append('package', blob, `${metadata.slug}-${metadata.version}.tar.gz`);
|
|
146
197
|
form.append('metadata', JSON.stringify(metadata));
|
|
147
|
-
// Attach portfolio images (with size validation)
|
|
198
|
+
// Attach portfolio images (with size validation and slot_type)
|
|
148
199
|
if (portfolioEntries.length > 0) {
|
|
149
200
|
const portfolioMeta = [];
|
|
150
201
|
let totalImageSize = 0;
|
|
202
|
+
let fileIndex = 0;
|
|
151
203
|
for (let i = 0; i < portfolioEntries.length; i++) {
|
|
152
204
|
const entry = portfolioEntries[i];
|
|
205
|
+
// video_url demo has no file to upload
|
|
206
|
+
if (entry.slot_type === 'demo' && entry.demo_url && !entry.path) {
|
|
207
|
+
portfolioMeta.push({
|
|
208
|
+
title: entry.title,
|
|
209
|
+
description: entry.description,
|
|
210
|
+
sort_order: i,
|
|
211
|
+
slot_type: entry.slot_type,
|
|
212
|
+
demo_url: entry.demo_url,
|
|
213
|
+
});
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
153
216
|
const absPath = path_1.default.resolve(teamDir, entry.path);
|
|
154
217
|
const imgBuffer = fs_1.default.readFileSync(absPath);
|
|
155
218
|
if (imgBuffer.length > MAX_IMAGE_SIZE) {
|
|
@@ -162,13 +225,16 @@ async function publishToApi(token, tarPath, metadata, teamDir, portfolioEntries)
|
|
|
162
225
|
throw new Error(`포트폴리오 이미지 총 크기가 너무 큽니다 (${totalMB}MB). 최대 ${MAX_TOTAL_UPLOAD_SIZE / 1024 / 1024}MB까지 허용됩니다.`);
|
|
163
226
|
}
|
|
164
227
|
const ext = path_1.default.extname(entry.path).slice(1) || 'png';
|
|
165
|
-
const mimeType = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'webp' ? 'image/webp' : 'image/png';
|
|
228
|
+
const mimeType = ext === 'gif' ? 'image/gif' : ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'webp' ? 'image/webp' : 'image/png';
|
|
166
229
|
const imgBlob = new Blob([imgBuffer], { type: mimeType });
|
|
167
|
-
form.append(`portfolio[${
|
|
230
|
+
form.append(`portfolio[${fileIndex}]`, imgBlob, path_1.default.basename(entry.path));
|
|
231
|
+
fileIndex++;
|
|
168
232
|
portfolioMeta.push({
|
|
169
233
|
title: entry.title,
|
|
170
234
|
description: entry.description,
|
|
171
235
|
sort_order: i,
|
|
236
|
+
slot_type: entry.slot_type,
|
|
237
|
+
demo_url: entry.demo_url,
|
|
172
238
|
});
|
|
173
239
|
}
|
|
174
240
|
form.append('portfolio_meta', JSON.stringify(portfolioMeta));
|
|
@@ -177,6 +243,7 @@ async function publishToApi(token, tarPath, metadata, teamDir, portfolioEntries)
|
|
|
177
243
|
method: 'POST',
|
|
178
244
|
headers: { Authorization: `Bearer ${token}` },
|
|
179
245
|
body: form,
|
|
246
|
+
redirect: 'error',
|
|
180
247
|
});
|
|
181
248
|
const body = await res.json();
|
|
182
249
|
if (!res.ok) {
|
|
@@ -235,9 +302,9 @@ function registerPublish(program) {
|
|
|
235
302
|
{ name: '초대 코드 필요', value: 'invite-only' },
|
|
236
303
|
],
|
|
237
304
|
});
|
|
238
|
-
console.error('\n\x1b[2m💡 프로필에 연락처를 설정하면 설치 시 명함이 전달됩니다: relayax.com/dashboard/edit\x1b[0m');
|
|
305
|
+
console.error('\n\x1b[2m💡 프로필에 연락처를 설정하면 설치 시 명함이 전달됩니다: www.relayax.com/dashboard/edit\x1b[0m');
|
|
239
306
|
if (visibility === 'invite-only') {
|
|
240
|
-
console.error('\x1b[2m💡 invite-only 팀은 웹 대시보드에서 사용자를 초대하세요: relayax.com/dashboard\x1b[0m');
|
|
307
|
+
console.error('\x1b[2m💡 invite-only 팀은 웹 대시보드에서 사용자를 초대하세요: www.relayax.com/dashboard\x1b[0m');
|
|
241
308
|
}
|
|
242
309
|
console.error('');
|
|
243
310
|
const tags = tagsRaw
|
|
@@ -268,11 +335,11 @@ function registerPublish(program) {
|
|
|
268
335
|
}
|
|
269
336
|
// Profile hint
|
|
270
337
|
if (isTTY) {
|
|
271
|
-
console.error('💡 프로필에 연락처를 설정하면 설치 시 명함이 전달됩니다: relayax.com/dashboard/edit');
|
|
338
|
+
console.error('💡 프로필에 연락처를 설정하면 설치 시 명함이 전달됩니다: www.relayax.com/dashboard/edit');
|
|
272
339
|
}
|
|
273
|
-
// Validate structure
|
|
340
|
+
// Validate structure (콘텐츠는 .relay/ 안에 있음)
|
|
274
341
|
const hasDirs = VALID_DIRS.some((d) => {
|
|
275
|
-
const dirPath = path_1.default.join(
|
|
342
|
+
const dirPath = path_1.default.join(relayDir, d);
|
|
276
343
|
if (!fs_1.default.existsSync(dirPath))
|
|
277
344
|
return false;
|
|
278
345
|
return fs_1.default.readdirSync(dirPath).filter((f) => !f.startsWith('.')).length > 0;
|
|
@@ -280,7 +347,7 @@ function registerPublish(program) {
|
|
|
280
347
|
if (!hasDirs) {
|
|
281
348
|
console.error(JSON.stringify({
|
|
282
349
|
error: 'EMPTY_PACKAGE',
|
|
283
|
-
message: 'skills/, agents/, rules/, commands/ 중 하나 이상에 파일이 있어야 합니다.',
|
|
350
|
+
message: '.relay/ 안에 skills/, agents/, rules/, commands/ 중 하나 이상에 파일이 있어야 합니다.',
|
|
284
351
|
}));
|
|
285
352
|
process.exit(1);
|
|
286
353
|
}
|
|
@@ -293,15 +360,15 @@ function registerPublish(program) {
|
|
|
293
360
|
}));
|
|
294
361
|
process.exit(1);
|
|
295
362
|
}
|
|
296
|
-
const detectedCommands = detectCommands(
|
|
363
|
+
const detectedCommands = detectCommands(relayDir);
|
|
297
364
|
const components = {
|
|
298
|
-
agents: countDir(
|
|
299
|
-
rules: countDir(
|
|
300
|
-
skills: countDir(
|
|
365
|
+
agents: countDir(relayDir, 'agents'),
|
|
366
|
+
rules: countDir(relayDir, 'rules'),
|
|
367
|
+
skills: countDir(relayDir, 'skills'),
|
|
301
368
|
};
|
|
302
369
|
// Collect portfolio and long_description
|
|
303
|
-
const portfolioEntries = collectPortfolio(
|
|
304
|
-
const longDescription = resolveLongDescription(
|
|
370
|
+
const portfolioEntries = collectPortfolio(relayDir, config.portfolio);
|
|
371
|
+
const longDescription = resolveLongDescription(relayDir, config.long_description);
|
|
305
372
|
const metadata = {
|
|
306
373
|
slug: config.slug,
|
|
307
374
|
name: config.name,
|
|
@@ -323,11 +390,11 @@ function registerPublish(program) {
|
|
|
323
390
|
}
|
|
324
391
|
let tarPath = null;
|
|
325
392
|
try {
|
|
326
|
-
tarPath = await createTarball(
|
|
393
|
+
tarPath = await createTarball(relayDir);
|
|
327
394
|
if (!json) {
|
|
328
395
|
console.error(`업로드 중...`);
|
|
329
396
|
}
|
|
330
|
-
const result = await publishToApi(token, tarPath, metadata,
|
|
397
|
+
const result = await publishToApi(token, tarPath, metadata, relayDir, portfolioEntries);
|
|
331
398
|
if (json) {
|
|
332
399
|
console.log(JSON.stringify(result));
|
|
333
400
|
}
|
|
@@ -193,7 +193,7 @@ exports.BUILDER_COMMANDS = [
|
|
|
193
193
|
{
|
|
194
194
|
id: 'relay-publish',
|
|
195
195
|
description: '현재 팀 패키지를 포트폴리오와 함께 relay 마켓플레이스에 배포합니다',
|
|
196
|
-
body: `현재 디렉토리의 에이전트
|
|
196
|
+
body: `현재 디렉토리의 에이전트 팀(.relay/)을 분석하고, 보안 점검 및 requirements를 구성한 뒤, 포트폴리오를 생성하고 relay 마켓플레이스에 배포합니다.
|
|
197
197
|
|
|
198
198
|
## 실행 단계
|
|
199
199
|
|
|
@@ -204,52 +204,153 @@ exports.BUILDER_COMMANDS = [
|
|
|
204
204
|
${LOGIN_JIT_GUIDE}
|
|
205
205
|
|
|
206
206
|
### 2. 팀 구조 분석
|
|
207
|
-
- skills/, agents/, rules/, commands
|
|
207
|
+
- .relay/ 디렉토리의 skills/, agents/, rules/, commands/를 탐색합니다.
|
|
208
208
|
- 각 파일의 이름과 description을 추출합니다.
|
|
209
209
|
- .relay/relay.yaml이 있으면 읽고, 없으면 사용자에게 팀 정보(name, slug, description, tags)를 물어보고 생성합니다.
|
|
210
210
|
|
|
211
|
-
### 3.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
211
|
+
### 3. 보안 점검 & Requirements 구성 (HITL)
|
|
212
|
+
|
|
213
|
+
.relay/ 내 모든 파일을 읽고 아래 항목을 분석합니다.
|
|
214
|
+
|
|
215
|
+
#### 3-1. 시크릿 스캔
|
|
216
|
+
- 하드코딩된 API 키, 토큰, 비밀번호, Private Key 등을 탐색합니다.
|
|
217
|
+
- 예: sk-..., ghp_..., AKIA..., Bearer 토큰, JWT, -----BEGIN PRIVATE KEY----- 등
|
|
218
|
+
- 발견 시 **즉시 사용자에게 경고**하고, 환경변수로 대체하도록 안내합니다.
|
|
219
|
+
- 시크릿이 제거되지 않으면 배포를 진행하지 않습니다.
|
|
220
|
+
|
|
221
|
+
#### 3-2. 환경변수 감지
|
|
222
|
+
- 파일 내 환경변수 참조를 감지합니다 (process.env.*, \${VAR}, os.environ 등).
|
|
223
|
+
- 각 환경변수에 대해 사용자에게 확인합니다:
|
|
224
|
+
- "이 환경변수가 필수인가요, 선택인가요?"
|
|
225
|
+
- 설명을 추가할 수 있습니다.
|
|
226
|
+
|
|
227
|
+
#### 3-3. 의존성 감지
|
|
228
|
+
아래 카테고리별로 분석하고, 사용자에게 필수/선택 여부를 확인합니다:
|
|
229
|
+
|
|
230
|
+
- **cli**: 파일에서 참조하는 CLI 도구 (playwright, ffmpeg, sharp 등)
|
|
231
|
+
- **npm**: import/require되는 npm 패키지
|
|
232
|
+
- **mcp**: MCP 서버 설정이 필요한 경우 (name, package, config, 필요한 env)
|
|
233
|
+
- **connectors**: 외부 서비스 연결 (Notion API, Slack webhook, Supabase 등 — type, auth 방식, 필요한 env)
|
|
234
|
+
- **runtime**: Node.js/Python 등 최소 버전 요구
|
|
235
|
+
- **permissions**: 필요한 에이전트 권한 (filesystem, network, shell)
|
|
236
|
+
- **teams**: 의존하는 다른 relay 팀
|
|
237
|
+
|
|
238
|
+
각 항목에 대해:
|
|
239
|
+
- \`required: true/false\` — 없으면 팀이 동작하지 않는지, 부분 동작인지
|
|
240
|
+
- \`description\` — 왜 필요한지 짧은 설명
|
|
241
|
+
|
|
242
|
+
#### 3-4. .relay/relay.yaml에 requires 반영
|
|
243
|
+
분석 결과를 .relay/relay.yaml의 requires 섹션에 저장합니다.
|
|
244
|
+
|
|
245
|
+
\`\`\`yaml
|
|
246
|
+
# .relay/relay.yaml requires 구조
|
|
247
|
+
requires:
|
|
248
|
+
env:
|
|
249
|
+
- name: OPENAI_API_KEY
|
|
250
|
+
required: true
|
|
251
|
+
description: "LLM API 호출에 필요"
|
|
252
|
+
- name: SLACK_WEBHOOK_URL
|
|
253
|
+
required: false
|
|
254
|
+
description: "알림 전송 (선택)"
|
|
255
|
+
cli:
|
|
256
|
+
- name: playwright
|
|
257
|
+
install: "npx playwright install"
|
|
258
|
+
required: true
|
|
259
|
+
npm:
|
|
260
|
+
- name: sharp
|
|
261
|
+
required: true
|
|
262
|
+
mcp:
|
|
263
|
+
- name: supabase
|
|
264
|
+
package: "@supabase/mcp-server"
|
|
265
|
+
required: false
|
|
266
|
+
config:
|
|
267
|
+
command: "npx"
|
|
268
|
+
args: ["-y", "@supabase/mcp-server"]
|
|
269
|
+
env: [SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY]
|
|
270
|
+
connectors:
|
|
271
|
+
- name: notion
|
|
272
|
+
type: api
|
|
273
|
+
auth: bearer_token
|
|
274
|
+
env: NOTION_API_KEY
|
|
275
|
+
required: false
|
|
276
|
+
description: "Notion 페이지 읽기/쓰기"
|
|
277
|
+
runtime:
|
|
278
|
+
node: ">=18"
|
|
279
|
+
permissions:
|
|
280
|
+
- filesystem
|
|
281
|
+
- network
|
|
282
|
+
teams:
|
|
283
|
+
- contents-team
|
|
284
|
+
\`\`\`
|
|
285
|
+
|
|
286
|
+
### 4. 포트폴리오 생성 (슬롯 기반)
|
|
287
|
+
|
|
288
|
+
포트폴리오는 3가지 슬롯으로 구성됩니다. .relay/portfolio/ 디렉토리에 저장합니다.
|
|
289
|
+
|
|
290
|
+
#### 슬롯 1: cover (필수 — 마켓 리스팅 카드)
|
|
291
|
+
- 규격: 1200x630px, WebP, 최대 500KB
|
|
292
|
+
- 팀 구조를 요약하는 카드 HTML을 생성합니다:
|
|
215
293
|
- 팀 이름, 버전
|
|
216
294
|
- Skills 목록 (이름 + 설명)
|
|
217
|
-
- Commands 목록
|
|
218
|
-
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
295
|
+
- Commands 목록
|
|
296
|
+
- 주요 기능 요약
|
|
297
|
+
- 생성된 HTML을 Playwright로 1200x630 뷰포트에서 스크린샷 캡처합니다.
|
|
298
|
+
- .relay/portfolio/cover.png에 저장합니다.
|
|
299
|
+
- 사용자에게 "이 cover를 사용할까요?" 확인. 직접 제공도 가능.
|
|
300
|
+
|
|
301
|
+
#### 슬롯 2: demo (선택 — 동작 시연)
|
|
302
|
+
- 팀 유형에 따라 제안 여부를 판단합니다:
|
|
303
|
+
- 브라우저 자동화 키워드 감지 (playwright, puppeteer, crawl, scrape, browser) → GIF 데모 제안
|
|
304
|
+
- 그 외 → 건너뜀 (사용자가 원하면 수동 추가)
|
|
305
|
+
- GIF: 최대 5MB, .relay/portfolio/demo.gif에 저장
|
|
306
|
+
- 또는 외부 영상 URL (YouTube, Loom 등)을 relay.yaml에 기록
|
|
307
|
+
- 사용자에게 "데모를 녹화할까요?" / "영상 URL이 있나요?" 확인
|
|
308
|
+
|
|
309
|
+
#### 슬롯 3: gallery (선택 — 결과물 쇼케이스, 최대 5장)
|
|
310
|
+
- 규격: 800x600px 이하, WebP, 각 500KB 이하
|
|
225
311
|
- output/, results/, examples/ 디렉토리를 스캔합니다.
|
|
226
|
-
-
|
|
227
|
-
- HTML
|
|
312
|
+
- 큰 이미지 → 사용자에게 "어느 영역을 보여줄까요?" 확인 후 핵심 영역 crop
|
|
313
|
+
- HTML 파일 → Playwright 스크린샷으로 변환
|
|
228
314
|
- 사용자가 포트폴리오에 포함할 항목을 선택합니다.
|
|
229
|
-
-
|
|
315
|
+
- .relay/portfolio/에 저장합니다.
|
|
230
316
|
|
|
231
|
-
###
|
|
317
|
+
### 5. 메타데이터 생성
|
|
232
318
|
- description: skills 내용 기반으로 자동 생성합니다.
|
|
233
319
|
- long_description: 팀 소개 마크다운을 자동 생성합니다 (README.md가 있으면 활용).
|
|
234
320
|
- tags: 팀 특성에 맞는 태그를 추천합니다.
|
|
235
321
|
- 사용자에게 확인: "이대로 배포할까요?"
|
|
236
322
|
|
|
237
|
-
###
|
|
238
|
-
-
|
|
239
|
-
|
|
240
|
-
|
|
323
|
+
### 6. .relay/relay.yaml 업데이트
|
|
324
|
+
- 메타데이터, requires, 포트폴리오 슬롯을 .relay/relay.yaml에 반영합니다.
|
|
325
|
+
|
|
326
|
+
\`\`\`yaml
|
|
327
|
+
portfolio:
|
|
328
|
+
cover:
|
|
329
|
+
path: portfolio/cover.png
|
|
330
|
+
demo: # 선택
|
|
331
|
+
type: gif
|
|
332
|
+
path: portfolio/demo.gif
|
|
333
|
+
gallery: # 선택, 최대 5장
|
|
334
|
+
- path: portfolio/example-1.png
|
|
335
|
+
title: "카드뉴스 예시"
|
|
336
|
+
\`\`\`
|
|
337
|
+
|
|
338
|
+
### 7. 배포
|
|
241
339
|
- \`relay publish\` 명령어를 실행합니다.
|
|
242
340
|
- 배포 결과와 마켓플레이스 URL을 보여줍니다.
|
|
243
341
|
|
|
244
342
|
## 예시
|
|
245
343
|
|
|
246
344
|
사용자: /relay-publish
|
|
247
|
-
→ 팀 구조 분석: skills 3개, commands 5
|
|
248
|
-
→
|
|
249
|
-
→
|
|
250
|
-
→
|
|
345
|
+
→ 팀 구조 분석: skills 3개, commands 5개
|
|
346
|
+
→ 보안 스캔: ✓ 시크릿 없음
|
|
347
|
+
→ 환경변수 감지: OPENAI_API_KEY (필수), DATABASE_URL (선택)
|
|
348
|
+
→ requires 업데이트 완료
|
|
349
|
+
→ cover 생성: 팀 구조 카드 HTML → 1200x630 스크린샷
|
|
350
|
+
→ demo: "브라우저 자동화 감지. GIF 데모 녹화할까요?" → Yes → 녹화 완료
|
|
351
|
+
→ gallery: output/ 스캔 → "카드뉴스 예시.png 포함?" → Yes → crop + 리사이즈
|
|
251
352
|
→ relay publish 실행
|
|
252
|
-
→ "배포 완료! URL: https://relayax.com/teams/
|
|
353
|
+
→ "배포 완료! URL: https://relayax.com/teams/my-team"`,
|
|
253
354
|
},
|
|
254
355
|
];
|
|
255
356
|
/** 하위 호환 — 기존 코드에서 RELAY_COMMANDS를 참조하는 경우 */
|
package/dist/lib/config.d.ts
CHANGED
package/dist/lib/config.js
CHANGED
|
@@ -14,7 +14,7 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
14
14
|
const path_1 = __importDefault(require("path"));
|
|
15
15
|
const os_1 = __importDefault(require("os"));
|
|
16
16
|
const ai_tools_js_1 = require("./ai-tools.js");
|
|
17
|
-
exports.API_URL = 'https://relayax.com';
|
|
17
|
+
exports.API_URL = 'https://www.relayax.com';
|
|
18
18
|
const RELAY_DIR = path_1.default.join(os_1.default.homedir(), '.relay');
|
|
19
19
|
const INSTALLED_FILE = path_1.default.join(RELAY_DIR, 'installed.json');
|
|
20
20
|
/**
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface SecretFinding {
|
|
2
|
+
file: string;
|
|
3
|
+
line: number;
|
|
4
|
+
label: string;
|
|
5
|
+
snippet: string;
|
|
6
|
+
}
|
|
7
|
+
export interface EnvRequirement {
|
|
8
|
+
name: string;
|
|
9
|
+
optional: boolean;
|
|
10
|
+
description: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ScanResult {
|
|
13
|
+
secrets: SecretFinding[];
|
|
14
|
+
envRequirements: EnvRequirement[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* .relay/ 디렉토리 내 모든 텍스트 파일을 스캔한다.
|
|
18
|
+
*/
|
|
19
|
+
export declare function scanForSecrets(relayDir: string): ScanResult;
|
|
@@ -0,0 +1,114 @@
|
|
|
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.scanForSecrets = scanForSecrets;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
/** 시크릿으로 의심되는 하드코딩 값 패턴 */
|
|
10
|
+
const SECRET_VALUE_PATTERNS = [
|
|
11
|
+
{ pattern: /sk-[a-zA-Z0-9_-]{20,}/, label: 'OpenAI API Key' },
|
|
12
|
+
{ pattern: /sk-ant-[a-zA-Z0-9_-]{20,}/, label: 'Anthropic API Key' },
|
|
13
|
+
{ pattern: /xoxb-[0-9]+-[a-zA-Z0-9]+/, label: 'Slack Bot Token' },
|
|
14
|
+
{ pattern: /xoxp-[0-9]+-[a-zA-Z0-9]+/, label: 'Slack User Token' },
|
|
15
|
+
{ pattern: /ghp_[a-zA-Z0-9]{36,}/, label: 'GitHub Personal Access Token' },
|
|
16
|
+
{ pattern: /gho_[a-zA-Z0-9]{36,}/, label: 'GitHub OAuth Token' },
|
|
17
|
+
{ pattern: /glpat-[a-zA-Z0-9\-_]{20,}/, label: 'GitLab Personal Access Token' },
|
|
18
|
+
{ pattern: /AKIA[0-9A-Z]{16}/, label: 'AWS Access Key ID' },
|
|
19
|
+
{ pattern: /eyJ[a-zA-Z0-9_-]{50,}\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/, label: 'JWT Token' },
|
|
20
|
+
{ pattern: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/, label: 'Private Key' },
|
|
21
|
+
{ pattern: /AIza[a-zA-Z0-9_-]{35}/, label: 'Google API Key' },
|
|
22
|
+
{ pattern: /SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}/, label: 'SendGrid API Key' },
|
|
23
|
+
{ pattern: /pk_live_[a-zA-Z0-9]{24,}/, label: 'Stripe Publishable Key' },
|
|
24
|
+
{ pattern: /sk_live_[a-zA-Z0-9]{24,}/, label: 'Stripe Secret Key' },
|
|
25
|
+
{ pattern: /sq0atp-[a-zA-Z0-9_-]{22}/, label: 'Square Access Token' },
|
|
26
|
+
];
|
|
27
|
+
/** 환경변수 참조 패턴 */
|
|
28
|
+
const ENV_REF_PATTERNS = [
|
|
29
|
+
// process.env.VAR_NAME or process.env['VAR_NAME']
|
|
30
|
+
/process\.env\.([A-Z][A-Z0-9_]+)/g,
|
|
31
|
+
/process\.env\[['"]([A-Z][A-Z0-9_]+)['"]\]/g,
|
|
32
|
+
// $VAR_NAME or ${VAR_NAME} in shell/template contexts
|
|
33
|
+
/\$\{([A-Z][A-Z0-9_]+)\}/g,
|
|
34
|
+
/\$([A-Z][A-Z0-9_]{2,})/g,
|
|
35
|
+
// os.environ['VAR'] or os.environ.get('VAR') (Python)
|
|
36
|
+
/os\.environ(?:\.get)?\(?['"]([A-Z][A-Z0-9_]+)['"]/g,
|
|
37
|
+
// ENV['VAR'] or ENV.fetch('VAR') (Ruby)
|
|
38
|
+
/ENV(?:\.fetch)?\(?['"]([A-Z][A-Z0-9_]+)['"]/g,
|
|
39
|
+
];
|
|
40
|
+
/** 시크릿성 환경변수 이름 키워드 */
|
|
41
|
+
const SECRET_ENV_KEYWORDS = [
|
|
42
|
+
'KEY', 'TOKEN', 'SECRET', 'PASSWORD', 'CREDENTIAL', 'AUTH',
|
|
43
|
+
'API_KEY', 'ACCESS_KEY', 'PRIVATE', 'SIGNING',
|
|
44
|
+
];
|
|
45
|
+
function isSecretEnvName(name) {
|
|
46
|
+
const upper = name.toUpperCase();
|
|
47
|
+
return SECRET_ENV_KEYWORDS.some((kw) => upper.includes(kw));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* .relay/ 디렉토리 내 모든 텍스트 파일을 스캔한다.
|
|
51
|
+
*/
|
|
52
|
+
function scanForSecrets(relayDir) {
|
|
53
|
+
const secrets = [];
|
|
54
|
+
const envNames = new Set();
|
|
55
|
+
const textExts = ['.md', '.txt', '.yaml', '.yml', '.json', '.ts', '.js', '.py', '.sh', '.rb', '.toml'];
|
|
56
|
+
function walkDir(dir) {
|
|
57
|
+
if (!fs_1.default.existsSync(dir))
|
|
58
|
+
return;
|
|
59
|
+
for (const entry of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
|
|
60
|
+
const fullPath = path_1.default.join(dir, entry.name);
|
|
61
|
+
if (entry.isDirectory()) {
|
|
62
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules')
|
|
63
|
+
continue;
|
|
64
|
+
walkDir(fullPath);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
const ext = path_1.default.extname(entry.name).toLowerCase();
|
|
68
|
+
if (!textExts.includes(ext))
|
|
69
|
+
continue;
|
|
70
|
+
scanFile(fullPath, relayDir);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function scanFile(filePath, baseDir) {
|
|
75
|
+
let content;
|
|
76
|
+
try {
|
|
77
|
+
content = fs_1.default.readFileSync(filePath, 'utf-8');
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const relPath = path_1.default.relative(baseDir, filePath);
|
|
83
|
+
const lines = content.split('\n');
|
|
84
|
+
// 1. 하드코딩된 시크릿 검사
|
|
85
|
+
for (let i = 0; i < lines.length; i++) {
|
|
86
|
+
const line = lines[i];
|
|
87
|
+
for (const { pattern, label } of SECRET_VALUE_PATTERNS) {
|
|
88
|
+
if (pattern.test(line)) {
|
|
89
|
+
const snippet = line.trim().length > 80 ? line.trim().slice(0, 77) + '...' : line.trim();
|
|
90
|
+
secrets.push({ file: relPath, line: i + 1, label, snippet });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// 2. 환경변수 참조 감지
|
|
95
|
+
for (const pattern of ENV_REF_PATTERNS) {
|
|
96
|
+
const regex = new RegExp(pattern.source, pattern.flags);
|
|
97
|
+
let match;
|
|
98
|
+
while ((match = regex.exec(content)) !== null) {
|
|
99
|
+
if (match[1])
|
|
100
|
+
envNames.add(match[1]);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
walkDir(relayDir);
|
|
105
|
+
// 환경변수를 requirements로 변환
|
|
106
|
+
const envRequirements = [...envNames]
|
|
107
|
+
.sort()
|
|
108
|
+
.map((name) => ({
|
|
109
|
+
name,
|
|
110
|
+
optional: !isSecretEnvName(name),
|
|
111
|
+
description: isSecretEnvName(name) ? `${name} (필수 — 시크릿)` : name,
|
|
112
|
+
}));
|
|
113
|
+
return { secrets, envRequirements };
|
|
114
|
+
}
|