relayax-cli 0.1.94 → 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.
@@ -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 = ['.relay'];
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)) {
@@ -77,14 +77,17 @@ 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
- if (fs_1.default.existsSync(path_1.default.join(projectPath, '.relay', 'relay.yaml'))) {
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(projectPath, d);
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;
@@ -98,16 +101,20 @@ const LEGACY_LOCAL_COMMAND_IDS = ['relay-explore', 'relay-install', 'relay-publi
98
101
  * - portfolio/ (루트) → .relay/portfolio/
99
102
  * - 로컬 레거시 슬래시 커맨드 제거 (글로벌로 이동되므로)
100
103
  */
104
+ /** 루트에 있으면 .relay/ 안으로 옮길 디렉토리 */
105
+ const LEGACY_CONTENT_DIRS = ['skills', 'agents', 'rules', 'commands', 'portfolio'];
101
106
  function detectLegacy(projectPath) {
102
107
  const details = [];
103
108
  if (fs_1.default.existsSync(path_1.default.join(projectPath, 'relay.yaml'))) {
104
109
  details.push('relay.yaml → .relay/relay.yaml');
105
110
  }
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
+ 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
+ }
111
118
  }
112
119
  }
113
120
  // 로컬에 레거시 슬래시 커맨드가 있는지 확인
@@ -118,8 +125,8 @@ function detectLegacy(projectPath) {
118
125
  continue;
119
126
  for (const cmdId of LEGACY_LOCAL_COMMAND_IDS) {
120
127
  if (fs_1.default.existsSync(path_1.default.join(cmdDir, `${cmdId}.md`))) {
121
- details.push(`${tool.skillsDir}/commands/relay/${cmdId}.md 제거 (글로벌로 이동)`);
122
- break; // 한 번만 표시
128
+ details.push(`${tool.skillsDir}/commands/relay/ 레거시 커맨드 정리`);
129
+ break;
123
130
  }
124
131
  }
125
132
  }
@@ -128,20 +135,23 @@ function detectLegacy(projectPath) {
128
135
  function runMigration(projectPath) {
129
136
  const result = { relayYaml: false, portfolio: false, localCommandsCleaned: 0 };
130
137
  const relayDir = path_1.default.join(projectPath, '.relay');
138
+ fs_1.default.mkdirSync(relayDir, { recursive: true });
131
139
  // 1. relay.yaml 이동
132
140
  const legacyYaml = path_1.default.join(projectPath, 'relay.yaml');
133
141
  const newYaml = path_1.default.join(relayDir, 'relay.yaml');
134
142
  if (fs_1.default.existsSync(legacyYaml) && !fs_1.default.existsSync(newYaml)) {
135
- fs_1.default.mkdirSync(relayDir, { recursive: true });
136
143
  fs_1.default.renameSync(legacyYaml, newYaml);
137
144
  result.relayYaml = true;
138
145
  }
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;
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
+ }
145
155
  }
146
156
  // 3. 로컬 레거시 슬래시 커맨드 제거
147
157
  const detected = (0, ai_tools_js_1.detectAgentCLIs)(projectPath);
@@ -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
- optional?: boolean;
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;
@@ -177,6 +177,7 @@ async function publishToApi(token, tarPath, metadata, teamDir, portfolioEntries)
177
177
  method: 'POST',
178
178
  headers: { Authorization: `Bearer ${token}` },
179
179
  body: form,
180
+ redirect: 'error',
180
181
  });
181
182
  const body = await res.json();
182
183
  if (!res.ok) {
@@ -235,9 +236,9 @@ function registerPublish(program) {
235
236
  { name: '초대 코드 필요', value: 'invite-only' },
236
237
  ],
237
238
  });
238
- console.error('\n\x1b[2m💡 프로필에 연락처를 설정하면 설치 시 명함이 전달됩니다: relayax.com/dashboard/edit\x1b[0m');
239
+ console.error('\n\x1b[2m💡 프로필에 연락처를 설정하면 설치 시 명함이 전달됩니다: www.relayax.com/dashboard/edit\x1b[0m');
239
240
  if (visibility === 'invite-only') {
240
- console.error('\x1b[2m💡 invite-only 팀은 웹 대시보드에서 사용자를 초대하세요: relayax.com/dashboard\x1b[0m');
241
+ console.error('\x1b[2m💡 invite-only 팀은 웹 대시보드에서 사용자를 초대하세요: www.relayax.com/dashboard\x1b[0m');
241
242
  }
242
243
  console.error('');
243
244
  const tags = tagsRaw
@@ -268,11 +269,11 @@ function registerPublish(program) {
268
269
  }
269
270
  // Profile hint
270
271
  if (isTTY) {
271
- console.error('💡 프로필에 연락처를 설정하면 설치 시 명함이 전달됩니다: relayax.com/dashboard/edit');
272
+ console.error('💡 프로필에 연락처를 설정하면 설치 시 명함이 전달됩니다: www.relayax.com/dashboard/edit');
272
273
  }
273
- // Validate structure
274
+ // Validate structure (콘텐츠는 .relay/ 안에 있음)
274
275
  const hasDirs = VALID_DIRS.some((d) => {
275
- const dirPath = path_1.default.join(teamDir, d);
276
+ const dirPath = path_1.default.join(relayDir, d);
276
277
  if (!fs_1.default.existsSync(dirPath))
277
278
  return false;
278
279
  return fs_1.default.readdirSync(dirPath).filter((f) => !f.startsWith('.')).length > 0;
@@ -280,7 +281,7 @@ function registerPublish(program) {
280
281
  if (!hasDirs) {
281
282
  console.error(JSON.stringify({
282
283
  error: 'EMPTY_PACKAGE',
283
- message: 'skills/, agents/, rules/, commands/ 중 하나 이상에 파일이 있어야 합니다.',
284
+ message: '.relay/ 안에 skills/, agents/, rules/, commands/ 중 하나 이상에 파일이 있어야 합니다.',
284
285
  }));
285
286
  process.exit(1);
286
287
  }
@@ -293,15 +294,15 @@ function registerPublish(program) {
293
294
  }));
294
295
  process.exit(1);
295
296
  }
296
- const detectedCommands = detectCommands(teamDir);
297
+ const detectedCommands = detectCommands(relayDir);
297
298
  const components = {
298
- agents: countDir(teamDir, 'agents'),
299
- rules: countDir(teamDir, 'rules'),
300
- skills: countDir(teamDir, 'skills'),
299
+ agents: countDir(relayDir, 'agents'),
300
+ rules: countDir(relayDir, 'rules'),
301
+ skills: countDir(relayDir, 'skills'),
301
302
  };
302
303
  // Collect portfolio and long_description
303
- const portfolioEntries = collectPortfolio(teamDir, config.portfolio);
304
- const longDescription = resolveLongDescription(teamDir, config.long_description);
304
+ const portfolioEntries = collectPortfolio(relayDir, config.portfolio);
305
+ const longDescription = resolveLongDescription(relayDir, config.long_description);
305
306
  const metadata = {
306
307
  slug: config.slug,
307
308
  name: config.name,
@@ -323,11 +324,11 @@ function registerPublish(program) {
323
324
  }
324
325
  let tarPath = null;
325
326
  try {
326
- tarPath = await createTarball(teamDir);
327
+ tarPath = await createTarball(relayDir);
327
328
  if (!json) {
328
329
  console.error(`업로드 중...`);
329
330
  }
330
- const result = await publishToApi(token, tarPath, metadata, teamDir, portfolioEntries);
331
+ const result = await publishToApi(token, tarPath, metadata, relayDir, portfolioEntries);
331
332
  if (json) {
332
333
  console.log(JSON.stringify(result));
333
334
  }
@@ -193,7 +193,7 @@ exports.BUILDER_COMMANDS = [
193
193
  {
194
194
  id: 'relay-publish',
195
195
  description: '현재 팀 패키지를 포트폴리오와 함께 relay 마켓플레이스에 배포합니다',
196
- body: `현재 디렉토리의 에이전트 팀을 분석하고, 포트폴리오를 생성한 뒤, relay 마켓플레이스에 배포합니다.
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
209
  - .relay/relay.yaml이 있으면 읽고, 없으면 사용자에게 팀 정보(name, slug, description, tags)를 물어보고 생성합니다.
210
210
 
211
- ### 3. 포트폴리오 생성
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
- - Skills 목록 (이름 + 설명)
217
- - Commands 목록 (이름 + 설명)
218
- - Agents 목록
219
- - Rules 목록
220
- - 비시각적 팀의 경우 기술 스택이나 데이터 종류 등 추가 정보
221
- - 생성된 HTML을 Playwright로 스크린샷 캡처합니다. (gstack 또는 webapp-testing 스킬 활용)
289
+ - 분석된 팀 구조를 HTML로 생성합니다.
290
+ - 생성된 HTML을 Playwright로 스크린샷 캡처합니다.
222
291
  - 결과 PNG를 ./.relay/portfolio/team-overview.png에 저장합니다.
223
292
 
224
293
  #### Layer 2: 결과물 쇼케이스 (선택)
225
294
  - output/, results/, examples/ 디렉토리를 스캔합니다.
226
- - 발견된 결과물(PNG, JPG, HTML, PDF)을 사용자에게 보여줍니다.
227
- - HTML 파일은 Playwright 스크린샷으로 변환합니다.
228
- - 사용자가 포트폴리오에 포함할 항목을 선택합니다.
295
+ - 발견된 결과물을 사용자에게 보여주고 포트폴리오 포함 여부를 선택합니다.
229
296
  - 선택된 이미지를 ./.relay/portfolio/에 저장합니다.
297
+ - 각 이미지는 2MB 이하여야 합니다.
230
298
 
231
- ### 4. 메타데이터 생성
299
+ ### 5. 메타데이터 생성
232
300
  - description: skills 내용 기반으로 자동 생성합니다.
233
301
  - long_description: 팀 소개 마크다운을 자동 생성합니다 (README.md가 있으면 활용).
234
302
  - tags: 팀 특성에 맞는 태그를 추천합니다.
235
303
  - 사용자에게 확인: "이대로 배포할까요?"
236
304
 
237
- ### 5. .relay/relay.yaml 업데이트
238
- - 생성/수정된 메타데이터와 포트폴리오 경로를 .relay/relay.yaml에 반영합니다.
305
+ ### 6. .relay/relay.yaml 업데이트
306
+ - 생성/수정된 메타데이터, requires, 포트폴리오 경로를 .relay/relay.yaml에 반영합니다.
239
307
 
240
- ### 6. 배포
308
+ ### 7. 배포
241
309
  - \`relay publish\` 명령어를 실행합니다.
242
310
  - 배포 결과와 마켓플레이스 URL을 보여줍니다.
243
311
 
244
312
  ## 예시
245
313
 
246
314
  사용자: /relay-publish
247
- → 팀 구조 분석: skills 3개, commands 5개, agents 2
248
- Layer 1: 구성 시각화 HTML 생성 → 스크린샷 캡처
249
- Layer 2: output/ 스캔 → "카드뉴스 예시.png, PDF 보고서.png 발견. 포트폴리오에 포함할까요?"
250
- 사용자 확인 .relay/relay.yaml 업데이트
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/contents-team"`,
324
+ → "배포 완료! URL: https://relayax.com/teams/my-team"`,
253
325
  },
254
326
  ];
255
327
  /** 하위 호환 — 기존 코드에서 RELAY_COMMANDS를 참조하는 경우 */
@@ -1,5 +1,5 @@
1
1
  import type { InstalledRegistry } from '../types.js';
2
- export declare const API_URL = "https://relayax.com";
2
+ export declare const API_URL = "https://www.relayax.com";
3
3
  /**
4
4
  * 설치 경로를 결정한다.
5
5
  * 1. --path 옵션이 있으면 그대로 사용
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relayax-cli",
3
- "version": "0.1.94",
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": {