relayax-cli 0.3.58 → 0.3.61
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.
|
@@ -1,10 +1,39 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
+
type DiffStatus = 'added' | 'modified' | 'deleted' | 'unchanged';
|
|
3
|
+
interface DiffEntry {
|
|
4
|
+
relPath: string;
|
|
5
|
+
status: DiffStatus;
|
|
6
|
+
}
|
|
2
7
|
import type { ContentType } from '../lib/ai-tools.js';
|
|
3
8
|
export interface ContentEntry {
|
|
4
9
|
name: string;
|
|
5
10
|
type: ContentType;
|
|
6
11
|
from: string;
|
|
7
12
|
}
|
|
13
|
+
type ContentDiffStatus = 'modified' | 'unchanged' | 'source_missing';
|
|
14
|
+
interface ContentDiffEntry {
|
|
15
|
+
name: string;
|
|
16
|
+
type: ContentType;
|
|
17
|
+
status: ContentDiffStatus;
|
|
18
|
+
files?: DiffEntry[];
|
|
19
|
+
}
|
|
20
|
+
interface NewItemEntry {
|
|
21
|
+
name: string;
|
|
22
|
+
type: ContentType;
|
|
23
|
+
source: string;
|
|
24
|
+
relativePath: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* contents 매니페스트 기반으로 각 항목의 원본과 .relay/ 복사본을 비교한다.
|
|
28
|
+
*/
|
|
29
|
+
export declare function computeContentsDiff(contents: ContentEntry[], relayDir: string, projectPath: string): {
|
|
30
|
+
diff: ContentDiffEntry[];
|
|
31
|
+
newItems: NewItemEntry[];
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* contents 항목 단위로 from → .relay/ 동기화한다.
|
|
35
|
+
*/
|
|
36
|
+
export declare function syncContentsToRelay(contents: ContentEntry[], contentsDiff: ContentDiffEntry[], relayDir: string, projectPath: string): void;
|
|
8
37
|
/**
|
|
9
38
|
* 패키지 홈 디렉토리를 결정한다.
|
|
10
39
|
* 1. 프로젝트에 .relay/가 있으면 → projectPath/.relay/
|
|
@@ -18,3 +47,4 @@ export declare function resolveRelayDir(projectPath: string, slug?: string): str
|
|
|
18
47
|
*/
|
|
19
48
|
export declare function initGlobalAgentHome(slug: string, yamlData: Record<string, unknown>): string;
|
|
20
49
|
export declare function registerPackage(program: Command): void;
|
|
50
|
+
export {};
|
package/dist/commands/package.js
CHANGED
|
@@ -3,6 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.computeContentsDiff = computeContentsDiff;
|
|
7
|
+
exports.syncContentsToRelay = syncContentsToRelay;
|
|
6
8
|
exports.resolveRelayDir = resolveRelayDir;
|
|
7
9
|
exports.initGlobalAgentHome = initGlobalAgentHome;
|
|
8
10
|
exports.registerPackage = registerPackage;
|
package/dist/mcp/server.js
CHANGED
|
@@ -23,18 +23,18 @@ const js_yaml_1 = __importDefault(require("js-yaml"));
|
|
|
23
23
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
24
24
|
const pkg = require('../../package.json');
|
|
25
25
|
// ─── Helpers ───
|
|
26
|
-
async function
|
|
26
|
+
async function resolveUserInfo(token) {
|
|
27
27
|
try {
|
|
28
28
|
const res = await fetch(`${config_js_1.API_URL}/api/auth/me`, {
|
|
29
29
|
headers: { Authorization: `Bearer ${token}` },
|
|
30
30
|
});
|
|
31
31
|
if (!res.ok)
|
|
32
|
-
return
|
|
32
|
+
return {};
|
|
33
33
|
const body = await res.json();
|
|
34
|
-
return body.username;
|
|
34
|
+
return { username: body.username, email: body.email };
|
|
35
35
|
}
|
|
36
36
|
catch {
|
|
37
|
-
return
|
|
37
|
+
return {};
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
function countFiles(dir) {
|
|
@@ -208,8 +208,12 @@ function createMcpServer() {
|
|
|
208
208
|
const projectPath = resolveMcpProjectPath(project_path);
|
|
209
209
|
const token = await (0, config_js_1.getValidToken)();
|
|
210
210
|
let username;
|
|
211
|
-
|
|
212
|
-
|
|
211
|
+
let email;
|
|
212
|
+
if (token) {
|
|
213
|
+
const info = await resolveUserInfo(token);
|
|
214
|
+
username = info.username;
|
|
215
|
+
email = info.email;
|
|
216
|
+
}
|
|
213
217
|
const detected = (0, ai_tools_js_1.detectAgentCLIs)(projectPath);
|
|
214
218
|
const mounted = (0, ai_tools_js_1.detectMountedCLIs)();
|
|
215
219
|
const relayYaml = path_1.default.join(projectPath, '.relay', 'relay.yaml');
|
|
@@ -226,7 +230,7 @@ function createMcpServer() {
|
|
|
226
230
|
const cliUpdate = await checkCliVersion(true);
|
|
227
231
|
return { content: [jsonText({
|
|
228
232
|
cli: { version: pkg.version, update_available: cliUpdate ? cliUpdate.latest : null },
|
|
229
|
-
login: { authenticated: !!token, username },
|
|
233
|
+
login: { authenticated: !!token, username, email },
|
|
230
234
|
agent_clis: detected.map((t) => t.name),
|
|
231
235
|
mounted_paths: mounted.map((m) => m.basePath),
|
|
232
236
|
project,
|
|
@@ -291,6 +295,108 @@ function createMcpServer() {
|
|
|
291
295
|
}
|
|
292
296
|
return { content: [jsonText({ sources })] };
|
|
293
297
|
});
|
|
298
|
+
server.tool('relay_package', '소스 디렉토리에서 .relay/로 콘텐츠를 패키징합니다. mode: init(최초 소스 탐색), sync(변경 반영), migrate(source→contents 마이그레이션)', {
|
|
299
|
+
mode: zod_1.z.enum(['init', 'sync', 'migrate']).describe('패키징 모드'),
|
|
300
|
+
project_path: zod_1.z.string().optional().describe('프로젝트 경로'),
|
|
301
|
+
}, async ({ mode, project_path }) => {
|
|
302
|
+
try {
|
|
303
|
+
const projectPath = resolveMcpProjectPath(project_path);
|
|
304
|
+
const homeDir = (0, paths_js_1.resolveHome)();
|
|
305
|
+
const relayDir = path_1.default.join(projectPath, '.relay');
|
|
306
|
+
const relayYamlPath = path_1.default.join(relayDir, 'relay.yaml');
|
|
307
|
+
if (mode === 'init') {
|
|
308
|
+
// 최초 패키징: 소스 탐색
|
|
309
|
+
const { detectGlobalCLIs } = await import('../lib/ai-tools.js');
|
|
310
|
+
const localTools = (0, ai_tools_js_1.detectAgentCLIs)(projectPath);
|
|
311
|
+
const globalTools = detectGlobalCLIs(homeDir);
|
|
312
|
+
const sources = [];
|
|
313
|
+
for (const tool of localTools) {
|
|
314
|
+
const items = (0, ai_tools_js_1.scanLocalItems)(projectPath, tool);
|
|
315
|
+
if (items.length > 0)
|
|
316
|
+
sources.push({ path: tool.skillsDir, location: 'local', name: tool.name, items: items.map((i) => ({ name: i.name, type: i.type, relativePath: i.relativePath })) });
|
|
317
|
+
}
|
|
318
|
+
for (const tool of globalTools) {
|
|
319
|
+
const items = (0, ai_tools_js_1.scanGlobalItems)(tool, homeDir);
|
|
320
|
+
if (items.length > 0)
|
|
321
|
+
sources.push({ path: `~/${tool.skillsDir}`, location: 'global', name: `${tool.name} (global)`, items: items.map((i) => ({ name: i.name, type: i.type, relativePath: i.relativePath })) });
|
|
322
|
+
}
|
|
323
|
+
for (const { tool, basePath } of (0, ai_tools_js_1.detectMountedCLIs)()) {
|
|
324
|
+
const items = (0, ai_tools_js_1.scanMountedItems)(basePath, tool);
|
|
325
|
+
if (items.length > 0)
|
|
326
|
+
sources.push({ path: `${basePath}/${tool.skillsDir}`, location: 'mounted', name: `${tool.name} (mounted)`, items: items.map((i) => ({ name: i.name, type: i.type, relativePath: i.relativePath })) });
|
|
327
|
+
}
|
|
328
|
+
// 기존 글로벌 에이전트 패키지 스캔
|
|
329
|
+
const globalAgentsDir = path_1.default.join(homeDir, '.relay', 'agents');
|
|
330
|
+
const existingAgents = [];
|
|
331
|
+
if (fs_1.default.existsSync(globalAgentsDir)) {
|
|
332
|
+
for (const entry of fs_1.default.readdirSync(globalAgentsDir, { withFileTypes: true })) {
|
|
333
|
+
if (!entry.isDirectory() || entry.name.startsWith('.'))
|
|
334
|
+
continue;
|
|
335
|
+
const agentYaml = path_1.default.join(globalAgentsDir, entry.name, 'relay.yaml');
|
|
336
|
+
if (fs_1.default.existsSync(agentYaml)) {
|
|
337
|
+
try {
|
|
338
|
+
const cfg = js_yaml_1.default.load(fs_1.default.readFileSync(agentYaml, 'utf-8'));
|
|
339
|
+
existingAgents.push({ slug: cfg.slug ?? entry.name, name: cfg.name ?? entry.name, version: cfg.version ?? '0.0.0', path: `~/.relay/agents/${entry.name}` });
|
|
340
|
+
}
|
|
341
|
+
catch { /* skip */ }
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return { content: [jsonText({ status: 'init_required', sources, existing_agents: existingAgents })] };
|
|
346
|
+
}
|
|
347
|
+
// sync / migrate는 relay.yaml이 필요
|
|
348
|
+
if (!fs_1.default.existsSync(relayYamlPath)) {
|
|
349
|
+
return { content: [jsonText({ error: 'NOT_INITIALIZED', message: '.relay/relay.yaml이 없습니다. mode: init으로 먼저 실행하세요.' })], isError: true };
|
|
350
|
+
}
|
|
351
|
+
if (mode === 'migrate') {
|
|
352
|
+
const yamlMigrate = fs_1.default.readFileSync(relayYamlPath, 'utf-8');
|
|
353
|
+
const cfgMigrate = js_yaml_1.default.load(yamlMigrate);
|
|
354
|
+
if (cfgMigrate.contents) {
|
|
355
|
+
return { content: [jsonText({ status: 'already_migrated', message: '이미 contents 형식입니다.' })] };
|
|
356
|
+
}
|
|
357
|
+
const legacySource = cfgMigrate.source;
|
|
358
|
+
if (!legacySource) {
|
|
359
|
+
return { content: [jsonText({ status: 'no_source', message: 'source 필드가 없습니다.' })], isError: true };
|
|
360
|
+
}
|
|
361
|
+
const localTools = (0, ai_tools_js_1.detectAgentCLIs)(projectPath);
|
|
362
|
+
const tool = localTools.find((t) => t.skillsDir === legacySource);
|
|
363
|
+
const migratedContents = [];
|
|
364
|
+
if (tool) {
|
|
365
|
+
const items = (0, ai_tools_js_1.scanLocalItems)(projectPath, tool);
|
|
366
|
+
for (const item of items) {
|
|
367
|
+
migratedContents.push({ name: item.name, type: item.type, from: `${legacySource}/${item.relativePath}` });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
delete cfgMigrate.source;
|
|
371
|
+
cfgMigrate.contents = migratedContents;
|
|
372
|
+
fs_1.default.writeFileSync(relayYamlPath, js_yaml_1.default.dump(cfgMigrate, { lineWidth: 120 }), 'utf-8');
|
|
373
|
+
return { content: [jsonText({ status: 'migrated', contents: migratedContents })] };
|
|
374
|
+
}
|
|
375
|
+
// mode === 'sync'
|
|
376
|
+
const { computeContentsDiff, syncContentsToRelay } = await import('../commands/package.js');
|
|
377
|
+
const yamlContent = fs_1.default.readFileSync(relayYamlPath, 'utf-8');
|
|
378
|
+
const config = js_yaml_1.default.load(yamlContent);
|
|
379
|
+
const contents = config.contents ?? [];
|
|
380
|
+
if (contents.length === 0) {
|
|
381
|
+
return { content: [jsonText({ status: 'no_contents', message: 'relay.yaml에 contents가 없습니다.' })], isError: true };
|
|
382
|
+
}
|
|
383
|
+
const { diff: contentsDiff, newItems } = computeContentsDiff(contents, relayDir, projectPath);
|
|
384
|
+
const hasChanges = contentsDiff.some((d) => d.status === 'modified');
|
|
385
|
+
if (hasChanges) {
|
|
386
|
+
syncContentsToRelay(contents, contentsDiff, relayDir, projectPath);
|
|
387
|
+
}
|
|
388
|
+
const summary = {
|
|
389
|
+
modified: contentsDiff.filter((d) => d.status === 'modified').length,
|
|
390
|
+
unchanged: contentsDiff.filter((d) => d.status === 'unchanged').length,
|
|
391
|
+
source_missing: contentsDiff.filter((d) => d.status === 'source_missing').length,
|
|
392
|
+
new_available: newItems.length,
|
|
393
|
+
};
|
|
394
|
+
return { content: [jsonText({ diff: contentsDiff, new_items: newItems, synced: hasChanges, summary })] };
|
|
395
|
+
}
|
|
396
|
+
catch (err) {
|
|
397
|
+
return { content: [jsonText({ error: String(err) })], isError: true };
|
|
398
|
+
}
|
|
399
|
+
});
|
|
294
400
|
server.tool('relay_org_list', '소속 Organization 목록을 조회합니다', {}, async () => {
|
|
295
401
|
try {
|
|
296
402
|
const token = await (0, config_js_1.getValidToken)();
|
|
@@ -522,8 +628,8 @@ function createMcpServer() {
|
|
|
522
628
|
// 이미 로그인되어 있는지 확인
|
|
523
629
|
const existingToken = await (0, config_js_1.getValidToken)();
|
|
524
630
|
if (existingToken) {
|
|
525
|
-
const username = await
|
|
526
|
-
return { content: [jsonText({ status: 'already_authenticated', username })] };
|
|
631
|
+
const { username, email } = await resolveUserInfo(existingToken);
|
|
632
|
+
return { content: [jsonText({ status: 'already_authenticated', username, email })] };
|
|
527
633
|
}
|
|
528
634
|
// Device code 발급
|
|
529
635
|
const res = await fetch(`${config_js_1.API_URL}/api/auth/device/request`, { method: 'POST' });
|
|
@@ -561,8 +667,8 @@ function createMcpServer() {
|
|
|
561
667
|
refresh_token: data.refresh_token,
|
|
562
668
|
expires_at: data.expires_at ? Number(data.expires_at) : undefined,
|
|
563
669
|
});
|
|
564
|
-
const username = await
|
|
565
|
-
return { content: [jsonText({ status: 'ok', message: '로그인 완료', username })] };
|
|
670
|
+
const { username, email } = await resolveUserInfo(data.token);
|
|
671
|
+
return { content: [jsonText({ status: 'ok', message: '로그인 완료', username, email })] };
|
|
566
672
|
}
|
|
567
673
|
}
|
|
568
674
|
return { content: [jsonText({ status: 'timeout', verification_url, user_code, message: `브라우저에서 ${verification_url} 을 열고 코드 ${user_code} 를 입력해주세요.` })], isError: true };
|
|
@@ -59,6 +59,9 @@ cat ~/Library/Application\ Support/Claude/claude_desktop_config.json 2>/dev/null
|
|
|
59
59
|
| "relay install X" | `relay install X` | `relay_install` tool 호출 (`slug: "X"`) |
|
|
60
60
|
| "relay publish" | `relay publish` | `relay_publish` tool 호출 |
|
|
61
61
|
| "relay login" | `relay login` | `relay_login` tool 호출 |
|
|
62
|
+
| "relay package --init" | `relay package --init --json` | `relay_package` tool 호출 (`mode: "init"`) |
|
|
63
|
+
| "relay package --sync" | `relay package --sync --json` | `relay_package` tool 호출 (`mode: "sync"`) |
|
|
64
|
+
| "relay package --migrate" | `relay package --migrate --json` | `relay_package` tool 호출 (`mode: "migrate"`) |
|
|
62
65
|
| "relay scan" | `relay package --init` | `relay_scan` tool 호출 |
|
|
63
66
|
| "relay check-update X" | `relay check-update X` | `relay_check_update` tool 호출 (`slug: "X"`) |
|
|
64
67
|
| "relay orgs list" | `relay orgs list --json` | `relay_org_list` tool 호출 |
|
package/dist/prompts/publish.md
CHANGED
|
@@ -28,6 +28,13 @@
|
|
|
28
28
|
|
|
29
29
|
#### A. 최초 배포 (.relay/relay.yaml이 없음)
|
|
30
30
|
|
|
31
|
+
**환경 B (MCP)의 경우:**
|
|
32
|
+
`relay_package` MCP tool (`mode: "init"`)을 사용합니다. CLI의 `relay package --init --json`과 동일한 결과를 반환합니다.
|
|
33
|
+
- 결과의 `sources[]`는 배포할 콘텐츠 **후보** 목록입니다. 전부 배포 대상이 아닙니다.
|
|
34
|
+
- 사용자에게 어떤 콘텐츠를 패키지에 포함할지 반드시 물어보세요.
|
|
35
|
+
|
|
36
|
+
**환경 A (터미널)의 경우:**
|
|
37
|
+
|
|
31
38
|
##### 1단계: 소스 탐색
|
|
32
39
|
|
|
33
40
|
`relay package --init --json` 실행
|
|
@@ -172,6 +179,14 @@ contents:
|
|
|
172
179
|
|
|
173
180
|
#### B. 재배포 (.relay/relay.yaml이 있음)
|
|
174
181
|
|
|
182
|
+
**환경 B (MCP)의 경우:**
|
|
183
|
+
`relay_package` MCP tool을 사용합니다:
|
|
184
|
+
- `mode: "migrate"` — B-0 마이그레이션 (source → contents)
|
|
185
|
+
- `mode: "sync"` — B-1~B-2 동기화 + 변경 반영
|
|
186
|
+
- sync 결과의 `new_items`가 있으면 B-3 새 콘텐츠 추가도 진행합니다.
|
|
187
|
+
|
|
188
|
+
**환경 A (터미널)의 경우:**
|
|
189
|
+
|
|
175
190
|
##### B-0. 기존 source 필드 마이그레이션
|
|
176
191
|
|
|
177
192
|
relay.yaml에 기존 `source` 필드만 있고 `contents`가 없으면:
|
|
@@ -276,7 +291,11 @@ Organization 목록을 조회합니다:
|
|
|
276
291
|
- question: "Organization이 없습니다. 비공개 배포를 하려면 Organization이 필요합니다. Organization을 만들까요?"
|
|
277
292
|
- options: `["Organization 생성", "Organization 없이 계속 (공개/링크공유만 가능)"]`
|
|
278
293
|
- "Organization 생성" 선택 시:
|
|
279
|
-
-
|
|
294
|
+
- 로그인 정보(username, email)를 기반으로 Organization 이름을 추천합니다:
|
|
295
|
+
- 업무용 이메일(커스텀 도메인)이면 → 도메인에서 회사명 추출하여 추천. 예: `haemin@relayax.com` → "relayax"
|
|
296
|
+
- 비업무용 이메일(gmail.com, naver.com, kakao.com, daum.net, hotmail.com, outlook.com, yahoo.com, icloud.com 등 무료 메일)이면 → username을 추천. 예: `haemin` → "haemin"
|
|
297
|
+
- email이 없으면 → username을 추천
|
|
298
|
+
- **사용자 질문 도구 호출:** question: "Organization 이름을 입력하세요. (추천: {추천이름})"
|
|
280
299
|
- 환경 A: `relay orgs create "이름" --json` 실행
|
|
281
300
|
- 환경 B: `relay_org_create` MCP tool 호출
|
|
282
301
|
- 생성 후 org 목록을 갱신합니다.
|
|
@@ -300,18 +319,19 @@ relay.yaml의 `visibility` 설정을 확인합니다.
|
|
|
300
319
|
|
|
301
320
|
Org가 선택된 경우:
|
|
302
321
|
- **사용자 질문 도구 호출:**
|
|
303
|
-
- question: "공개 범위를 선택하세요"
|
|
304
|
-
- options: `["
|
|
322
|
+
- question: "{org_name} Organization에 배포합니다. 공개 범위를 선택하세요"
|
|
323
|
+
- options: `["public — 외부인 포함 누구나 설치", "internal — 조직 구성원 누구나 설치", "private — 조직 내에서도 허가받은 사람만 설치"]`
|
|
305
324
|
|
|
306
|
-
Org가 없는
|
|
325
|
+
Org가 없는 경우 (개인 배포):
|
|
307
326
|
- **사용자 질문 도구 호출:**
|
|
308
327
|
- question: "공개 범위를 선택하세요"
|
|
309
|
-
- options: `["
|
|
328
|
+
- options: `["public — 누구나 검색하여 설치 가능", "private — 접근 링크를 받은 사람만 설치 가능"]`
|
|
310
329
|
|
|
311
330
|
**응답 처리:**
|
|
312
|
-
|
|
313
|
-
- "
|
|
314
|
-
- "
|
|
331
|
+
|
|
332
|
+
- "public" → relay.yaml에 `visibility: public` 저장
|
|
333
|
+
- "internal" → relay.yaml에 `visibility: internal` 저장
|
|
334
|
+
- "private" → relay.yaml에 `visibility: private` 저장. 배포 후 웹 대시보드에서 접근 코드를 생성하여 공유할 수 있다고 안내.
|
|
315
335
|
|
|
316
336
|
**재배포 (visibility 이미 설정됨):**
|
|
317
337
|
|
|
@@ -325,12 +345,21 @@ Org가 없는 경우:
|
|
|
325
345
|
|
|
326
346
|
.relay/ 내 모든 파일을 자동 분석합니다.
|
|
327
347
|
|
|
328
|
-
#### 2-1. 시크릿 스캔 (자동)
|
|
348
|
+
#### 2-1. 시크릿 & 개인정보 스캔 (자동)
|
|
349
|
+
|
|
350
|
+
**시크릿 스캔:**
|
|
329
351
|
- 하드코딩된 API 키, 토큰, 비밀번호, Private Key 등을 탐색합니다.
|
|
330
352
|
- 예: sk-..., ghp_..., AKIA..., Bearer 토큰, JWT, -----BEGIN PRIVATE KEY----- 등
|
|
331
353
|
- 발견 시 **즉시 사용자에게 경고**하고, 환경변수로 대체하도록 안내합니다.
|
|
332
354
|
- 시크릿이 제거되지 않으면 배포를 진행하지 않습니다.
|
|
333
355
|
|
|
356
|
+
**개인정보 스캔:**
|
|
357
|
+
- 이메일 주소, 전화번호, 실명, 주소 등 개인정보가 포함된 파일을 탐색합니다.
|
|
358
|
+
- **중요: 패키지에 포함된 모든 파일은 설치한 사람이 볼 수 있습니다.** 상세페이지에 노출되지 않더라도 패키지 자체에 포함됩니다.
|
|
359
|
+
- 발견 시 사용자에게 경고하고 제거/수정 여부를 확인받습니다:
|
|
360
|
+
- "⚠ {파일명}에 개인정보({종류})가 포함되어 있습니다. 이 파일은 패키지에 포함되어 설치한 사람이 볼 수 있습니다."
|
|
361
|
+
- **사용자 질문 도구 호출:** question: "개인정보가 포함된 파일을 어떻게 처리할까요?", options: `["제거 후 배포", "그대로 배포", "취소"]`
|
|
362
|
+
|
|
334
363
|
#### 2-2. 환경변수 & 의존성 분석 (자동)
|
|
335
364
|
분석 대상:
|
|
336
365
|
- **env**: 환경변수 참조 (process.env.*, ${VAR}, os.environ 등)
|