relayax-cli 0.1.93 → 0.1.95
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 +9 -7
- package/dist/commands/init.js +124 -3
- package/dist/commands/publish.d.ts +27 -2
- package/dist/commands/publish.js +40 -23
- package/dist/commands/status.js +1 -1
- package/dist/lib/command-adapter.js +101 -29
- package/dist/lib/config.d.ts +1 -1
- package/dist/lib/config.js +1 -1
- package/dist/lib/config.js.bak +75 -0
- package/dist/lib/security-scan.d.ts +19 -0
- package/dist/lib/security-scan.js +114 -0
- package/package.json +2 -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
|
*/
|
|
@@ -31,15 +31,16 @@ function registerCreate(program) {
|
|
|
31
31
|
.action(async (name) => {
|
|
32
32
|
const json = program.opts().json ?? false;
|
|
33
33
|
const projectPath = process.cwd();
|
|
34
|
-
const
|
|
34
|
+
const relayDir = path_1.default.join(projectPath, '.relay');
|
|
35
|
+
const relayYamlPath = path_1.default.join(relayDir, 'relay.yaml');
|
|
35
36
|
const isTTY = Boolean(process.stdin.isTTY) && !json;
|
|
36
|
-
// 1. relay.yaml 이미 존재하면 에러
|
|
37
|
+
// 1. .relay/relay.yaml 이미 존재하면 에러
|
|
37
38
|
if (fs_1.default.existsSync(relayYamlPath)) {
|
|
38
39
|
if (json) {
|
|
39
|
-
console.error(JSON.stringify({ error: 'ALREADY_EXISTS', message: 'relay.yaml이 이미 존재합니다.' }));
|
|
40
|
+
console.error(JSON.stringify({ error: 'ALREADY_EXISTS', message: '.relay/relay.yaml이 이미 존재합니다.' }));
|
|
40
41
|
}
|
|
41
42
|
else {
|
|
42
|
-
console.error('relay.yaml이 이미 존재합니다. 기존 팀 프로젝트에서는 `relay init`을 사용하세요.');
|
|
43
|
+
console.error('.relay/relay.yaml이 이미 존재합니다. 기존 팀 프로젝트에서는 `relay init`을 사용하세요.');
|
|
43
44
|
}
|
|
44
45
|
process.exit(1);
|
|
45
46
|
}
|
|
@@ -69,7 +70,8 @@ function registerCreate(program) {
|
|
|
69
70
|
],
|
|
70
71
|
});
|
|
71
72
|
}
|
|
72
|
-
// 3. relay.yaml 생성
|
|
73
|
+
// 3. .relay/relay.yaml 생성
|
|
74
|
+
fs_1.default.mkdirSync(relayDir, { recursive: true });
|
|
73
75
|
const yamlData = {
|
|
74
76
|
name,
|
|
75
77
|
slug: defaultSlug,
|
|
@@ -118,7 +120,7 @@ function registerCreate(program) {
|
|
|
118
120
|
}
|
|
119
121
|
else {
|
|
120
122
|
console.log(`\n\x1b[32m✓ ${name} 팀 프로젝트 생성 완료\x1b[0m\n`);
|
|
121
|
-
console.log(` relay.yaml 생성됨`);
|
|
123
|
+
console.log(` .relay/relay.yaml 생성됨`);
|
|
122
124
|
if (createdDirs.length > 0) {
|
|
123
125
|
console.log(` 디렉토리 생성: ${createdDirs.join(', ')}`);
|
|
124
126
|
}
|
package/dist/commands/init.js
CHANGED
|
@@ -77,19 +77,103 @@ 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.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
|
}
|
|
96
|
+
/** 레거시 User 커맨드 ID (이전에 로컬에 설치되던 것) */
|
|
97
|
+
const LEGACY_LOCAL_COMMAND_IDS = ['relay-explore', 'relay-install', 'relay-publish'];
|
|
98
|
+
/**
|
|
99
|
+
* 레거시 구조를 감지하고 마이그레이션한다.
|
|
100
|
+
* - relay.yaml (루트) → .relay/relay.yaml
|
|
101
|
+
* - portfolio/ (루트) → .relay/portfolio/
|
|
102
|
+
* - 로컬 레거시 슬래시 커맨드 제거 (글로벌로 이동되므로)
|
|
103
|
+
*/
|
|
104
|
+
/** 루트에 있으면 .relay/ 안으로 옮길 디렉토리 */
|
|
105
|
+
const LEGACY_CONTENT_DIRS = ['skills', 'agents', 'rules', 'commands', 'portfolio'];
|
|
106
|
+
function detectLegacy(projectPath) {
|
|
107
|
+
const details = [];
|
|
108
|
+
if (fs_1.default.existsSync(path_1.default.join(projectPath, 'relay.yaml'))) {
|
|
109
|
+
details.push('relay.yaml → .relay/relay.yaml');
|
|
110
|
+
}
|
|
111
|
+
for (const dir of LEGACY_CONTENT_DIRS) {
|
|
112
|
+
const legacyDir = path_1.default.join(projectPath, dir);
|
|
113
|
+
if (fs_1.default.existsSync(legacyDir) && fs_1.default.statSync(legacyDir).isDirectory()) {
|
|
114
|
+
const hasFiles = fs_1.default.readdirSync(legacyDir).filter((f) => !f.startsWith('.')).length > 0;
|
|
115
|
+
if (hasFiles) {
|
|
116
|
+
details.push(`${dir}/ → .relay/${dir}/`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// 로컬에 레거시 슬래시 커맨드가 있는지 확인
|
|
121
|
+
const detected = (0, ai_tools_js_1.detectAgentCLIs)(projectPath);
|
|
122
|
+
for (const tool of detected) {
|
|
123
|
+
const cmdDir = path_1.default.join(projectPath, tool.skillsDir, 'commands', 'relay');
|
|
124
|
+
if (!fs_1.default.existsSync(cmdDir))
|
|
125
|
+
continue;
|
|
126
|
+
for (const cmdId of LEGACY_LOCAL_COMMAND_IDS) {
|
|
127
|
+
if (fs_1.default.existsSync(path_1.default.join(cmdDir, `${cmdId}.md`))) {
|
|
128
|
+
details.push(`${tool.skillsDir}/commands/relay/ 레거시 커맨드 정리`);
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { hasLegacy: details.length > 0, details };
|
|
134
|
+
}
|
|
135
|
+
function runMigration(projectPath) {
|
|
136
|
+
const result = { relayYaml: false, portfolio: false, localCommandsCleaned: 0 };
|
|
137
|
+
const relayDir = path_1.default.join(projectPath, '.relay');
|
|
138
|
+
fs_1.default.mkdirSync(relayDir, { recursive: true });
|
|
139
|
+
// 1. relay.yaml 이동
|
|
140
|
+
const legacyYaml = path_1.default.join(projectPath, 'relay.yaml');
|
|
141
|
+
const newYaml = path_1.default.join(relayDir, 'relay.yaml');
|
|
142
|
+
if (fs_1.default.existsSync(legacyYaml) && !fs_1.default.existsSync(newYaml)) {
|
|
143
|
+
fs_1.default.renameSync(legacyYaml, newYaml);
|
|
144
|
+
result.relayYaml = true;
|
|
145
|
+
}
|
|
146
|
+
// 2. 콘텐츠 디렉토리 이동 (skills, agents, rules, commands, portfolio)
|
|
147
|
+
for (const dir of LEGACY_CONTENT_DIRS) {
|
|
148
|
+
const legacyDir = path_1.default.join(projectPath, dir);
|
|
149
|
+
const newDir = path_1.default.join(relayDir, dir);
|
|
150
|
+
if (fs_1.default.existsSync(legacyDir) && fs_1.default.statSync(legacyDir).isDirectory() && !fs_1.default.existsSync(newDir)) {
|
|
151
|
+
fs_1.default.renameSync(legacyDir, newDir);
|
|
152
|
+
if (dir === 'portfolio')
|
|
153
|
+
result.portfolio = true;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// 3. 로컬 레거시 슬래시 커맨드 제거
|
|
157
|
+
const detected = (0, ai_tools_js_1.detectAgentCLIs)(projectPath);
|
|
158
|
+
for (const tool of detected) {
|
|
159
|
+
const cmdDir = path_1.default.join(projectPath, tool.skillsDir, 'commands', 'relay');
|
|
160
|
+
if (!fs_1.default.existsSync(cmdDir))
|
|
161
|
+
continue;
|
|
162
|
+
for (const cmdId of LEGACY_LOCAL_COMMAND_IDS) {
|
|
163
|
+
const cmdPath = path_1.default.join(cmdDir, `${cmdId}.md`);
|
|
164
|
+
if (fs_1.default.existsSync(cmdPath)) {
|
|
165
|
+
fs_1.default.unlinkSync(cmdPath);
|
|
166
|
+
result.localCommandsCleaned++;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// 디렉토리가 비었으면 삭제
|
|
170
|
+
const remaining = fs_1.default.readdirSync(cmdDir);
|
|
171
|
+
if (remaining.length === 0) {
|
|
172
|
+
fs_1.default.rmdirSync(cmdDir);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
93
177
|
function registerInit(program) {
|
|
94
178
|
program
|
|
95
179
|
.command('init')
|
|
@@ -99,6 +183,42 @@ function registerInit(program) {
|
|
|
99
183
|
.action(async (opts) => {
|
|
100
184
|
const json = program.opts().json ?? false;
|
|
101
185
|
const projectPath = process.cwd();
|
|
186
|
+
// ── 0. 레거시 마이그레이션 ──
|
|
187
|
+
const legacy = detectLegacy(projectPath);
|
|
188
|
+
let migrated = false;
|
|
189
|
+
if (legacy.hasLegacy) {
|
|
190
|
+
if (json) {
|
|
191
|
+
// JSON 모드: 자동 마이그레이션
|
|
192
|
+
const migrationResult = runMigration(projectPath);
|
|
193
|
+
migrated = migrationResult.relayYaml || migrationResult.portfolio || migrationResult.localCommandsCleaned > 0;
|
|
194
|
+
}
|
|
195
|
+
else if (process.stdin.isTTY) {
|
|
196
|
+
console.log('\n \x1b[33m⚠ 레거시 구조 감지\x1b[0m\n');
|
|
197
|
+
for (const d of legacy.details) {
|
|
198
|
+
console.log(` ${d}`);
|
|
199
|
+
}
|
|
200
|
+
console.log();
|
|
201
|
+
const { confirm } = await import('@inquirer/prompts');
|
|
202
|
+
const doMigrate = await confirm({ message: '마이그레이션할까요?', default: true });
|
|
203
|
+
if (doMigrate) {
|
|
204
|
+
const migrationResult = runMigration(projectPath);
|
|
205
|
+
migrated = true;
|
|
206
|
+
console.log(`\n \x1b[32m✓ 마이그레이션 완료\x1b[0m`);
|
|
207
|
+
if (migrationResult.relayYaml)
|
|
208
|
+
console.log(' relay.yaml → .relay/relay.yaml');
|
|
209
|
+
if (migrationResult.portfolio)
|
|
210
|
+
console.log(' portfolio/ → .relay/portfolio/');
|
|
211
|
+
if (migrationResult.localCommandsCleaned > 0)
|
|
212
|
+
console.log(` 레거시 로컬 커맨드 ${migrationResult.localCommandsCleaned}개 제거`);
|
|
213
|
+
console.log();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
// 비TTY, 비JSON: 자동 마이그레이션
|
|
218
|
+
const migrationResult = runMigration(projectPath);
|
|
219
|
+
migrated = migrationResult.relayYaml || migrationResult.portfolio || migrationResult.localCommandsCleaned > 0;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
102
222
|
const detected = (0, ai_tools_js_1.detectAgentCLIs)(projectPath);
|
|
103
223
|
const detectedIds = new Set(detected.map((t) => t.value));
|
|
104
224
|
const isBuilder = isTeamProject(projectPath);
|
|
@@ -183,6 +303,7 @@ function registerInit(program) {
|
|
|
183
303
|
console.log(JSON.stringify({
|
|
184
304
|
status: 'ok',
|
|
185
305
|
mode: isBuilder ? 'builder' : 'user',
|
|
306
|
+
migrated,
|
|
186
307
|
global: {
|
|
187
308
|
status: globalStatus,
|
|
188
309
|
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
|
@@ -12,6 +12,10 @@ const tar_1 = require("tar");
|
|
|
12
12
|
const config_js_1 = require("../lib/config.js");
|
|
13
13
|
const VALID_DIRS = ['skills', 'agents', 'rules', 'commands'];
|
|
14
14
|
const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.webp'];
|
|
15
|
+
/** 개별 포트폴리오 이미지 최대 크기 (2 MB) */
|
|
16
|
+
const MAX_IMAGE_SIZE = 2 * 1024 * 1024;
|
|
17
|
+
/** 전체 업로드 최대 크기 (10 MB) */
|
|
18
|
+
const MAX_TOTAL_UPLOAD_SIZE = 10 * 1024 * 1024;
|
|
15
19
|
function parseRelayYaml(content) {
|
|
16
20
|
const raw = js_yaml_1.default.load(content) ?? {};
|
|
17
21
|
const tags = Array.isArray(raw.tags)
|
|
@@ -93,15 +97,15 @@ function collectPortfolio(teamDir, yamlPortfolio) {
|
|
|
93
97
|
return fs_1.default.existsSync(absPath);
|
|
94
98
|
});
|
|
95
99
|
}
|
|
96
|
-
// Auto-scan
|
|
97
|
-
const portfolioDir = path_1.default.join(teamDir, 'portfolio');
|
|
100
|
+
// Auto-scan .relay/portfolio/
|
|
101
|
+
const portfolioDir = path_1.default.join(teamDir, '.relay', 'portfolio');
|
|
98
102
|
if (!fs_1.default.existsSync(portfolioDir))
|
|
99
103
|
return [];
|
|
100
104
|
const files = fs_1.default.readdirSync(portfolioDir)
|
|
101
105
|
.filter((f) => IMAGE_EXTS.some((ext) => f.toLowerCase().endsWith(ext)))
|
|
102
106
|
.sort();
|
|
103
107
|
return files.map((f) => ({
|
|
104
|
-
path: path_1.default.join('portfolio', f),
|
|
108
|
+
path: path_1.default.join('.relay', 'portfolio', f),
|
|
105
109
|
title: path_1.default.basename(f, path_1.default.extname(f)).replace(/[-_]/g, ' '),
|
|
106
110
|
}));
|
|
107
111
|
}
|
|
@@ -140,13 +144,23 @@ async function publishToApi(token, tarPath, metadata, teamDir, portfolioEntries)
|
|
|
140
144
|
const form = new FormData();
|
|
141
145
|
form.append('package', blob, `${metadata.slug}-${metadata.version}.tar.gz`);
|
|
142
146
|
form.append('metadata', JSON.stringify(metadata));
|
|
143
|
-
// Attach portfolio images
|
|
147
|
+
// Attach portfolio images (with size validation)
|
|
144
148
|
if (portfolioEntries.length > 0) {
|
|
145
149
|
const portfolioMeta = [];
|
|
150
|
+
let totalImageSize = 0;
|
|
146
151
|
for (let i = 0; i < portfolioEntries.length; i++) {
|
|
147
152
|
const entry = portfolioEntries[i];
|
|
148
153
|
const absPath = path_1.default.resolve(teamDir, entry.path);
|
|
149
154
|
const imgBuffer = fs_1.default.readFileSync(absPath);
|
|
155
|
+
if (imgBuffer.length > MAX_IMAGE_SIZE) {
|
|
156
|
+
const sizeMB = (imgBuffer.length / 1024 / 1024).toFixed(1);
|
|
157
|
+
throw new Error(`포트폴리오 이미지 '${path_1.default.basename(entry.path)}'이(가) 너무 큽니다 (${sizeMB}MB). 최대 ${MAX_IMAGE_SIZE / 1024 / 1024}MB까지 허용됩니다.`);
|
|
158
|
+
}
|
|
159
|
+
totalImageSize += imgBuffer.length;
|
|
160
|
+
if (totalImageSize > MAX_TOTAL_UPLOAD_SIZE) {
|
|
161
|
+
const totalMB = (totalImageSize / 1024 / 1024).toFixed(1);
|
|
162
|
+
throw new Error(`포트폴리오 이미지 총 크기가 너무 큽니다 (${totalMB}MB). 최대 ${MAX_TOTAL_UPLOAD_SIZE / 1024 / 1024}MB까지 허용됩니다.`);
|
|
163
|
+
}
|
|
150
164
|
const ext = path_1.default.extname(entry.path).slice(1) || 'png';
|
|
151
165
|
const mimeType = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'webp' ? 'image/webp' : 'image/png';
|
|
152
166
|
const imgBlob = new Blob([imgBuffer], { type: mimeType });
|
|
@@ -163,6 +177,7 @@ async function publishToApi(token, tarPath, metadata, teamDir, portfolioEntries)
|
|
|
163
177
|
method: 'POST',
|
|
164
178
|
headers: { Authorization: `Bearer ${token}` },
|
|
165
179
|
body: form,
|
|
180
|
+
redirect: 'error',
|
|
166
181
|
});
|
|
167
182
|
const body = await res.json();
|
|
168
183
|
if (!res.ok) {
|
|
@@ -179,14 +194,15 @@ function registerPublish(program) {
|
|
|
179
194
|
.action(async (opts) => {
|
|
180
195
|
const json = program.opts().json ?? false;
|
|
181
196
|
const teamDir = process.cwd();
|
|
182
|
-
const
|
|
197
|
+
const relayDir = path_1.default.join(teamDir, '.relay');
|
|
198
|
+
const relayYamlPath = path_1.default.join(relayDir, 'relay.yaml');
|
|
183
199
|
const isTTY = Boolean(process.stdin.isTTY) && !json;
|
|
184
|
-
// Check relay.yaml exists
|
|
200
|
+
// Check .relay/relay.yaml exists
|
|
185
201
|
if (!fs_1.default.existsSync(relayYamlPath)) {
|
|
186
202
|
if (!isTTY) {
|
|
187
203
|
console.error(JSON.stringify({
|
|
188
204
|
error: 'NOT_INITIALIZED',
|
|
189
|
-
message: 'relay.yaml이 없습니다. 먼저 `relay
|
|
205
|
+
message: '.relay/relay.yaml이 없습니다. 먼저 `relay create`를 실행하세요.',
|
|
190
206
|
}));
|
|
191
207
|
process.exit(1);
|
|
192
208
|
}
|
|
@@ -195,7 +211,7 @@ function registerPublish(program) {
|
|
|
195
211
|
const dirName = path_1.default.basename(teamDir);
|
|
196
212
|
const defaultSlug = dirName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
197
213
|
console.error('\n\x1b[36m릴레이 팀 패키지를 초기화합니다.\x1b[0m');
|
|
198
|
-
console.error('relay.yaml을 생성하기 위해 몇 가지 정보를 입력해주세요.\n');
|
|
214
|
+
console.error('.relay/relay.yaml을 생성하기 위해 몇 가지 정보를 입력해주세요.\n');
|
|
199
215
|
const name = await promptInput({
|
|
200
216
|
message: '팀 이름:',
|
|
201
217
|
default: dirName,
|
|
@@ -220,9 +236,9 @@ function registerPublish(program) {
|
|
|
220
236
|
{ name: '초대 코드 필요', value: 'invite-only' },
|
|
221
237
|
],
|
|
222
238
|
});
|
|
223
|
-
console.error('\n\x1b[2m💡 프로필에 연락처를 설정하면 설치 시 명함이 전달됩니다: relayax.com/dashboard/edit\x1b[0m');
|
|
239
|
+
console.error('\n\x1b[2m💡 프로필에 연락처를 설정하면 설치 시 명함이 전달됩니다: www.relayax.com/dashboard/edit\x1b[0m');
|
|
224
240
|
if (visibility === 'invite-only') {
|
|
225
|
-
console.error('\x1b[2m💡 invite-only 팀은
|
|
241
|
+
console.error('\x1b[2m💡 invite-only 팀은 웹 대시보드에서 사용자를 초대하세요: www.relayax.com/dashboard\x1b[0m');
|
|
226
242
|
}
|
|
227
243
|
console.error('');
|
|
228
244
|
const tags = tagsRaw
|
|
@@ -237,8 +253,9 @@ function registerPublish(program) {
|
|
|
237
253
|
tags,
|
|
238
254
|
visibility,
|
|
239
255
|
};
|
|
256
|
+
fs_1.default.mkdirSync(relayDir, { recursive: true });
|
|
240
257
|
fs_1.default.writeFileSync(relayYamlPath, js_yaml_1.default.dump(yamlData, { lineWidth: 120 }), 'utf-8');
|
|
241
|
-
console.error(`\n\x1b[32m✓ relay.yaml이 생성되었습니다.\x1b[0m\n`);
|
|
258
|
+
console.error(`\n\x1b[32m✓ .relay/relay.yaml이 생성되었습니다.\x1b[0m\n`);
|
|
242
259
|
}
|
|
243
260
|
// Parse relay.yaml
|
|
244
261
|
const yamlContent = fs_1.default.readFileSync(relayYamlPath, 'utf-8');
|
|
@@ -252,11 +269,11 @@ function registerPublish(program) {
|
|
|
252
269
|
}
|
|
253
270
|
// Profile hint
|
|
254
271
|
if (isTTY) {
|
|
255
|
-
console.error('💡 프로필에 연락처를 설정하면 설치 시 명함이 전달됩니다: relayax.com/dashboard/edit');
|
|
272
|
+
console.error('💡 프로필에 연락처를 설정하면 설치 시 명함이 전달됩니다: www.relayax.com/dashboard/edit');
|
|
256
273
|
}
|
|
257
|
-
// Validate structure
|
|
274
|
+
// Validate structure (콘텐츠는 .relay/ 안에 있음)
|
|
258
275
|
const hasDirs = VALID_DIRS.some((d) => {
|
|
259
|
-
const dirPath = path_1.default.join(
|
|
276
|
+
const dirPath = path_1.default.join(relayDir, d);
|
|
260
277
|
if (!fs_1.default.existsSync(dirPath))
|
|
261
278
|
return false;
|
|
262
279
|
return fs_1.default.readdirSync(dirPath).filter((f) => !f.startsWith('.')).length > 0;
|
|
@@ -264,7 +281,7 @@ function registerPublish(program) {
|
|
|
264
281
|
if (!hasDirs) {
|
|
265
282
|
console.error(JSON.stringify({
|
|
266
283
|
error: 'EMPTY_PACKAGE',
|
|
267
|
-
message: 'skills/, agents/, rules/, commands/ 중 하나 이상에 파일이 있어야 합니다.',
|
|
284
|
+
message: '.relay/ 안에 skills/, agents/, rules/, commands/ 중 하나 이상에 파일이 있어야 합니다.',
|
|
268
285
|
}));
|
|
269
286
|
process.exit(1);
|
|
270
287
|
}
|
|
@@ -277,15 +294,15 @@ function registerPublish(program) {
|
|
|
277
294
|
}));
|
|
278
295
|
process.exit(1);
|
|
279
296
|
}
|
|
280
|
-
const detectedCommands = detectCommands(
|
|
297
|
+
const detectedCommands = detectCommands(relayDir);
|
|
281
298
|
const components = {
|
|
282
|
-
agents: countDir(
|
|
283
|
-
rules: countDir(
|
|
284
|
-
skills: countDir(
|
|
299
|
+
agents: countDir(relayDir, 'agents'),
|
|
300
|
+
rules: countDir(relayDir, 'rules'),
|
|
301
|
+
skills: countDir(relayDir, 'skills'),
|
|
285
302
|
};
|
|
286
303
|
// Collect portfolio and long_description
|
|
287
|
-
const portfolioEntries = collectPortfolio(
|
|
288
|
-
const longDescription = resolveLongDescription(
|
|
304
|
+
const portfolioEntries = collectPortfolio(relayDir, config.portfolio);
|
|
305
|
+
const longDescription = resolveLongDescription(relayDir, config.long_description);
|
|
289
306
|
const metadata = {
|
|
290
307
|
slug: config.slug,
|
|
291
308
|
name: config.name,
|
|
@@ -307,11 +324,11 @@ function registerPublish(program) {
|
|
|
307
324
|
}
|
|
308
325
|
let tarPath = null;
|
|
309
326
|
try {
|
|
310
|
-
tarPath = await createTarball(
|
|
327
|
+
tarPath = await createTarball(relayDir);
|
|
311
328
|
if (!json) {
|
|
312
329
|
console.error(`업로드 중...`);
|
|
313
330
|
}
|
|
314
|
-
const result = await publishToApi(token, tarPath, metadata,
|
|
331
|
+
const result = await publishToApi(token, tarPath, metadata, relayDir, portfolioEntries);
|
|
315
332
|
if (json) {
|
|
316
333
|
console.log(JSON.stringify(result));
|
|
317
334
|
}
|
package/dist/commands/status.js
CHANGED
|
@@ -48,7 +48,7 @@ function registerStatus(program) {
|
|
|
48
48
|
hasLocal = command_adapter_js_1.BUILDER_COMMANDS.some((cmd) => fs_1.default.existsSync(path_1.default.join(localDir, `${cmd.id}.md`)));
|
|
49
49
|
}
|
|
50
50
|
// 3. 팀 정보
|
|
51
|
-
const relayYamlPath = path_1.default.join(projectPath, 'relay.yaml');
|
|
51
|
+
const relayYamlPath = path_1.default.join(projectPath, '.relay', 'relay.yaml');
|
|
52
52
|
let team = null;
|
|
53
53
|
if (fs_1.default.existsSync(relayYamlPath)) {
|
|
54
54
|
try {
|
|
@@ -88,7 +88,7 @@ exports.USER_COMMANDS = [
|
|
|
88
88
|
- 설치 결과를 확인합니다 (설치된 파일 수, 사용 가능한 커맨드 목록).
|
|
89
89
|
|
|
90
90
|
### 2. 의존성 확인 및 설치
|
|
91
|
-
설치된 팀의 relay.yaml에 \`requires\` 섹션이 있으면 각 항목을 확인하고 처리합니다:
|
|
91
|
+
설치된 팀의 .relay/relay.yaml에 \`requires\` 섹션이 있으면 각 항목을 확인하고 처리합니다:
|
|
92
92
|
|
|
93
93
|
- **cli**: \`which <name>\`으로 확인 → 없으면 install 명령 실행 또는 안내
|
|
94
94
|
- **npm**: \`npm list <package>\`로 확인 → 없으면 \`npm install\`
|
|
@@ -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,124 @@ 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
|
-
- relay.yaml이 있으면 읽고, 없으면 사용자에게 팀 정보(name, slug, description, tags)를 물어보고 생성합니다.
|
|
210
|
-
|
|
211
|
-
### 3.
|
|
209
|
+
- .relay/relay.yaml이 있으면 읽고, 없으면 사용자에게 팀 정보(name, slug, description, tags)를 물어보고 생성합니다.
|
|
210
|
+
|
|
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. 포트폴리오 생성
|
|
212
287
|
|
|
213
288
|
#### Layer 1: 팀 구성 시각화 (자동)
|
|
214
|
-
- 분석된 팀 구조를 HTML로 생성합니다.
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
- Commands 목록 (이름 + 설명)
|
|
218
|
-
- Agents 목록
|
|
219
|
-
- Rules 목록
|
|
220
|
-
- 비시각적 팀의 경우 기술 스택이나 데이터 종류 등 추가 정보
|
|
221
|
-
- 생성된 HTML을 Playwright로 스크린샷 캡처합니다. (gstack 또는 webapp-testing 스킬 활용)
|
|
222
|
-
- 결과 PNG를 ./portfolio/team-overview.png에 저장합니다.
|
|
289
|
+
- 분석된 팀 구조를 HTML로 생성합니다.
|
|
290
|
+
- 생성된 HTML을 Playwright로 스크린샷 캡처합니다.
|
|
291
|
+
- 결과 PNG를 ./.relay/portfolio/team-overview.png에 저장합니다.
|
|
223
292
|
|
|
224
293
|
#### Layer 2: 결과물 쇼케이스 (선택)
|
|
225
|
-
- output/, results/, examples
|
|
226
|
-
- 발견된
|
|
227
|
-
-
|
|
228
|
-
-
|
|
229
|
-
- 선택된 이미지를 ./portfolio/에 저장합니다.
|
|
294
|
+
- output/, results/, examples/ 디렉토리를 스캔합니다.
|
|
295
|
+
- 발견된 결과물을 사용자에게 보여주고 포트폴리오 포함 여부를 선택합니다.
|
|
296
|
+
- 선택된 이미지를 ./.relay/portfolio/에 저장합니다.
|
|
297
|
+
- 각 이미지는 2MB 이하여야 합니다.
|
|
230
298
|
|
|
231
|
-
###
|
|
299
|
+
### 5. 메타데이터 생성
|
|
232
300
|
- description: skills 내용 기반으로 자동 생성합니다.
|
|
233
301
|
- long_description: 팀 소개 마크다운을 자동 생성합니다 (README.md가 있으면 활용).
|
|
234
302
|
- tags: 팀 특성에 맞는 태그를 추천합니다.
|
|
235
303
|
- 사용자에게 확인: "이대로 배포할까요?"
|
|
236
304
|
|
|
237
|
-
###
|
|
238
|
-
- 생성/수정된
|
|
305
|
+
### 6. .relay/relay.yaml 업데이트
|
|
306
|
+
- 생성/수정된 메타데이터, requires, 포트폴리오 경로를 .relay/relay.yaml에 반영합니다.
|
|
239
307
|
|
|
240
|
-
###
|
|
308
|
+
### 7. 배포
|
|
241
309
|
- \`relay publish\` 명령어를 실행합니다.
|
|
242
310
|
- 배포 결과와 마켓플레이스 URL을 보여줍니다.
|
|
243
311
|
|
|
244
312
|
## 예시
|
|
245
313
|
|
|
246
314
|
사용자: /relay-publish
|
|
247
|
-
→ 팀 구조 분석: skills 3개, commands 5
|
|
248
|
-
→
|
|
249
|
-
→
|
|
250
|
-
→
|
|
315
|
+
→ 팀 구조 분석: skills 3개, commands 5개
|
|
316
|
+
→ 보안 스캔: ✓ 시크릿 없음
|
|
317
|
+
→ 환경변수 감지: OPENAI_API_KEY, DATABASE_URL
|
|
318
|
+
→ "OPENAI_API_KEY — 필수인가요?" → Yes
|
|
319
|
+
→ "DATABASE_URL — 필수인가요?" → No, 선택
|
|
320
|
+
→ npm 의존성 감지: sharp (필수)
|
|
321
|
+
→ requires 업데이트 완료
|
|
322
|
+
→ 포트폴리오 생성 → 사용자 확인
|
|
251
323
|
→ relay publish 실행
|
|
252
|
-
→ "배포 완료! URL: https://relayax.com/teams/
|
|
324
|
+
→ "배포 완료! URL: https://relayax.com/teams/my-team"`,
|
|
253
325
|
},
|
|
254
326
|
];
|
|
255
327
|
/** 하위 호환 — 기존 코드에서 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,75 @@
|
|
|
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.API_URL = void 0;
|
|
7
|
+
exports.getInstallPath = getInstallPath;
|
|
8
|
+
exports.ensureRelayDir = ensureRelayDir;
|
|
9
|
+
exports.loadToken = loadToken;
|
|
10
|
+
exports.saveToken = saveToken;
|
|
11
|
+
exports.loadInstalled = loadInstalled;
|
|
12
|
+
exports.saveInstalled = saveInstalled;
|
|
13
|
+
const fs_1 = __importDefault(require("fs"));
|
|
14
|
+
const path_1 = __importDefault(require("path"));
|
|
15
|
+
const os_1 = __importDefault(require("os"));
|
|
16
|
+
const ai_tools_js_1 = require("./ai-tools.js");
|
|
17
|
+
exports.API_URL = 'https://relayax.com';
|
|
18
|
+
const RELAY_DIR = path_1.default.join(os_1.default.homedir(), '.relay');
|
|
19
|
+
const INSTALLED_FILE = path_1.default.join(RELAY_DIR, 'installed.json');
|
|
20
|
+
/**
|
|
21
|
+
* 설치 경로를 결정한다.
|
|
22
|
+
* 1. --path 옵션이 있으면 그대로 사용
|
|
23
|
+
* 2. 에이전트 CLI 자동 감지 → 감지된 경로 사용
|
|
24
|
+
* 3. 감지 안 되면 현재 디렉토리에 직접 설치
|
|
25
|
+
*/
|
|
26
|
+
function getInstallPath(override) {
|
|
27
|
+
if (override) {
|
|
28
|
+
const resolved = override.startsWith('~')
|
|
29
|
+
? path_1.default.join(os_1.default.homedir(), override.slice(1))
|
|
30
|
+
: path_1.default.resolve(override);
|
|
31
|
+
return resolved;
|
|
32
|
+
}
|
|
33
|
+
const cwd = process.cwd();
|
|
34
|
+
const detected = (0, ai_tools_js_1.detectAgentCLIs)(cwd);
|
|
35
|
+
if (detected.length >= 1) {
|
|
36
|
+
return path_1.default.join(cwd, detected[0].skillsDir);
|
|
37
|
+
}
|
|
38
|
+
return cwd;
|
|
39
|
+
}
|
|
40
|
+
function ensureRelayDir() {
|
|
41
|
+
if (!fs_1.default.existsSync(RELAY_DIR)) {
|
|
42
|
+
fs_1.default.mkdirSync(RELAY_DIR, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function loadToken() {
|
|
46
|
+
const tokenFile = path_1.default.join(RELAY_DIR, 'token');
|
|
47
|
+
if (!fs_1.default.existsSync(tokenFile))
|
|
48
|
+
return undefined;
|
|
49
|
+
try {
|
|
50
|
+
return fs_1.default.readFileSync(tokenFile, 'utf-8').trim() || undefined;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function saveToken(token) {
|
|
57
|
+
ensureRelayDir();
|
|
58
|
+
fs_1.default.writeFileSync(path_1.default.join(RELAY_DIR, 'token'), token);
|
|
59
|
+
}
|
|
60
|
+
function loadInstalled() {
|
|
61
|
+
if (!fs_1.default.existsSync(INSTALLED_FILE)) {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const raw = fs_1.default.readFileSync(INSTALLED_FILE, 'utf-8');
|
|
66
|
+
return JSON.parse(raw);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function saveInstalled(registry) {
|
|
73
|
+
ensureRelayDir();
|
|
74
|
+
fs_1.default.writeFileSync(INSTALLED_FILE, JSON.stringify(registry, null, 2));
|
|
75
|
+
}
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "relayax-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.95",
|
|
4
4
|
"description": "RelayAX Agent Team Marketplace CLI - Install and manage agent teams",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@inquirer/prompts": "^8.3.2",
|
|
37
37
|
"commander": "^13.1.0",
|
|
38
|
+
"form-data": "^4.0.5",
|
|
38
39
|
"js-yaml": "^4.1.1",
|
|
39
40
|
"tar": "^7.4.0"
|
|
40
41
|
},
|