@team-semicolon/semo-cli 4.13.0 → 4.15.0

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.
@@ -0,0 +1,517 @@
1
+ "use strict";
2
+ /**
3
+ * semo incubator — 인큐베이터 세션 관리
4
+ *
5
+ * semo incubator create --service-id <uuid> --channel <name> — 프로젝트 세션 생성
6
+ * semo incubator list — 활성 세션 목록
7
+ * semo incubator stop --service-id <uuid> — 세션 중지 + 아카이브
8
+ * semo incubator status --service-id <uuid> — 세션 상태 조회
9
+ */
10
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ var desc = Object.getOwnPropertyDescriptor(m, k);
13
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
14
+ desc = { enumerable: true, get: function() { return m[k]; } };
15
+ }
16
+ Object.defineProperty(o, k2, desc);
17
+ }) : (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ o[k2] = m[k];
20
+ }));
21
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
22
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
23
+ }) : function(o, v) {
24
+ o["default"] = v;
25
+ });
26
+ var __importStar = (this && this.__importStar) || (function () {
27
+ var ownKeys = function(o) {
28
+ ownKeys = Object.getOwnPropertyNames || function (o) {
29
+ var ar = [];
30
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
31
+ return ar;
32
+ };
33
+ return ownKeys(o);
34
+ };
35
+ return function (mod) {
36
+ if (mod && mod.__esModule) return mod;
37
+ var result = {};
38
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
39
+ __setModuleDefault(result, mod);
40
+ return result;
41
+ };
42
+ })();
43
+ var __importDefault = (this && this.__importDefault) || function (mod) {
44
+ return (mod && mod.__esModule) ? mod : { "default": mod };
45
+ };
46
+ Object.defineProperty(exports, "__esModule", { value: true });
47
+ exports.registerIncubatorCommands = registerIncubatorCommands;
48
+ const chalk_1 = __importDefault(require("chalk"));
49
+ const ora_1 = __importDefault(require("ora"));
50
+ const fs = __importStar(require("fs"));
51
+ const path = __importStar(require("path"));
52
+ const os = __importStar(require("os"));
53
+ const database_1 = require("../database");
54
+ // ============================================================
55
+ // Constants
56
+ // ============================================================
57
+ const SESSIONS_ROOT = path.join(os.homedir(), '.semo-sessions');
58
+ const SHARED_AGENTS = path.join(os.homedir(), 'Desktop', 'Sources', 'semicolon', 'projects', 'semo', '.claude', 'agents');
59
+ const SHARED_SKILLS = path.join(os.homedir(), '.claude', 'skills');
60
+ const SHARED_HOOKS = path.join(os.homedir(), '.openclaw-shared', 'hooks');
61
+ const SEMO_ENV = path.join(os.homedir(), '.claude', 'semo', '.env');
62
+ const LAUNCHD_DIR = path.join(os.homedir(), 'Library', 'LaunchAgents');
63
+ function ensureSessionDir(serviceId) {
64
+ const sessionDir = path.join(SESSIONS_ROOT, serviceId);
65
+ const dirs = [sessionDir, path.join(sessionDir, '.claude'), path.join(sessionDir, 'memory')];
66
+ for (const dir of dirs) {
67
+ fs.mkdirSync(dir, { recursive: true });
68
+ }
69
+ return sessionDir;
70
+ }
71
+ function createSymlinks(sessionDir) {
72
+ const links = [
73
+ [SHARED_AGENTS, path.join(sessionDir, '.claude', 'agents')],
74
+ [SHARED_SKILLS, path.join(sessionDir, '.claude', 'skills')],
75
+ ];
76
+ for (const [target, linkPath] of links) {
77
+ if (fs.existsSync(linkPath)) {
78
+ const stat = fs.lstatSync(linkPath);
79
+ if (stat.isSymbolicLink())
80
+ fs.unlinkSync(linkPath);
81
+ else
82
+ continue; // 실제 디렉토리면 건드리지 않음
83
+ }
84
+ if (fs.existsSync(target)) {
85
+ fs.symlinkSync(target, linkPath, 'dir');
86
+ }
87
+ }
88
+ }
89
+ function writeClaudeMd(sessionDir, serviceId, serviceName, channel) {
90
+ const content = `# Incubator Session — ${serviceName}
91
+
92
+ > Auto-generated by \`semo incubator create\`
93
+ > Service ID: ${serviceId}
94
+ > Channel: ${channel || '(미할당)'}
95
+ > Created: ${new Date().toISOString().slice(0, 10)}
96
+
97
+ ## Context
98
+
99
+ 이 세션은 인큐베이터 프로젝트 **${serviceName}**의 전용 Claude Code 세션이다.
100
+
101
+ ### 라우팅 규칙
102
+
103
+ 유저 메시지를 받으면 아래 우선순위로 봇 Agent를 선택:
104
+
105
+ 1. **명시적 봇 지정**: "디자인팀에게 물어봐" → Agent(designclaw)
106
+ 2. **현재 Phase 기반**: Dashboard API에서 current_phase 조회 → PHASE_ASSIGNEES 매핑
107
+ 3. **키워드 분기**: 인프라/배포 → infraclaw, 리뷰/PR → reviewclaw, 마케팅/SEO → growthclaw
108
+ 4. **폴백**: Agent(semiclaw) — PM/오케스트레이터
109
+
110
+ ### Phase → 봇 매핑
111
+
112
+ | Phase | 봇 | 트랙 |
113
+ |-------|-----|------|
114
+ | 0 | semiclaw | A |
115
+ | 1-3 | planclaw | A |
116
+ | 4 | designclaw | A |
117
+ | 5-6 | planclaw | A |
118
+ | 7-8 | workclaw | A |
119
+ | 9 | planclaw | A |
120
+ | Infra 0-2 | infraclaw | B |
121
+
122
+ ### 응답 포맷
123
+
124
+ 모든 응답 앞에 \`[봇이름]\` 접두사를 붙여 어떤 전문 봇이 응답했는지 표시.
125
+ 예: \`[PlanClaw] 비즈니스 모델 분석 결과입니다...\`
126
+
127
+ ## KB 도메인
128
+
129
+ - 서비스 정보: \`semo kb get ${serviceName} base-information\`
130
+ - 프로젝트 현황: Dashboard API \`GET /api/gfp/{service_id}\`
131
+
132
+ ## Shared Resources
133
+
134
+ - Agents: .claude/agents/ (심링크 → 공유)
135
+ - Skills: .claude/skills/ (심링크 → 공유)
136
+ - KB: semo kb CLI
137
+ `;
138
+ fs.writeFileSync(path.join(sessionDir, 'CLAUDE.md'), content, 'utf-8');
139
+ }
140
+ const CHANNEL_SLACK_DIR = path.join(os.homedir(), 'Desktop', 'Sources', 'semicolon', 'projects', 'semo', 'packages', 'channel-slack');
141
+ function writeSettingsJson(sessionDir, serviceId, channel) {
142
+ const settings = {
143
+ permissions: {
144
+ allow: ['Bash(semo kb:*)', 'Bash(semo context:*)', 'Bash(curl:*)', 'Bash(node:*)'],
145
+ },
146
+ hooks: {
147
+ SessionStart: [
148
+ {
149
+ matcher: '',
150
+ hooks: [
151
+ {
152
+ type: 'command',
153
+ command: `. ${SEMO_ENV} 2>/dev/null; semo context sync 2>/dev/null || true`,
154
+ timeout: 30,
155
+ },
156
+ {
157
+ type: 'command',
158
+ command: `bash ${SHARED_HOOKS}/cron-reregister.sh`,
159
+ timeout: 5000,
160
+ },
161
+ ],
162
+ },
163
+ ],
164
+ Stop: [
165
+ {
166
+ matcher: '',
167
+ hooks: [
168
+ {
169
+ type: 'command',
170
+ command: `. ${SEMO_ENV} 2>/dev/null; semo context push 2>/dev/null || true`,
171
+ timeout: 30,
172
+ },
173
+ ],
174
+ },
175
+ ],
176
+ },
177
+ };
178
+ fs.writeFileSync(path.join(sessionDir, '.claude', 'settings.json'), JSON.stringify(settings, null, 2), 'utf-8');
179
+ // .mcp.json — Channel Slack 플러그인 등록
180
+ const mcpConfig = {
181
+ mcpServers: {
182
+ 'semo-channel-slack': {
183
+ command: 'npx',
184
+ args: ['tsx', path.join(CHANNEL_SLACK_DIR, 'src', 'index.ts')],
185
+ env: {
186
+ SLACK_CHANNEL_ID: channel || '',
187
+ SEMO_SERVICE_ID: serviceId || '',
188
+ },
189
+ },
190
+ },
191
+ };
192
+ fs.writeFileSync(path.join(sessionDir, '.mcp.json'), JSON.stringify(mcpConfig, null, 2), 'utf-8');
193
+ }
194
+ function writeMemoryFiles(sessionDir, serviceName) {
195
+ fs.writeFileSync(path.join(sessionDir, 'memory', 'MEMORY.md'), `# ${serviceName} — Incubator Session Memory\n\n(초기 상태)\n`, 'utf-8');
196
+ fs.writeFileSync(path.join(sessionDir, 'memory', 'context.md'), `# Session Context\n\n- Phase: 0 (온보딩)\n- Track A: 대기\n- Track B: 미시작\n`, 'utf-8');
197
+ }
198
+ // ============================================================
199
+ // launchd plist
200
+ // ============================================================
201
+ function getLaunchdLabel(serviceId) {
202
+ return `com.semo.incubator.${serviceId.slice(0, 8)}`;
203
+ }
204
+ function writeLaunchdPlist(serviceId, sessionDir) {
205
+ fs.mkdirSync(LAUNCHD_DIR, { recursive: true });
206
+ const label = getLaunchdLabel(serviceId);
207
+ const plistPath = path.join(LAUNCHD_DIR, `${label}.plist`);
208
+ const logDir = path.join(sessionDir, 'logs');
209
+ fs.mkdirSync(logDir, { recursive: true });
210
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
211
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
212
+ <plist version="1.0">
213
+ <dict>
214
+ <key>Label</key>
215
+ <string>${label}</string>
216
+ <key>ProgramArguments</key>
217
+ <array>
218
+ <string>/usr/local/bin/claude</string>
219
+ <string>--resume</string>
220
+ <string>--dangerously-skip-permissions</string>
221
+ </array>
222
+ <key>WorkingDirectory</key>
223
+ <string>${sessionDir}</string>
224
+ <key>EnvironmentVariables</key>
225
+ <dict>
226
+ <key>HOME</key>
227
+ <string>${os.homedir()}</string>
228
+ <key>PATH</key>
229
+ <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
230
+ </dict>
231
+ <key>RunAtLoad</key>
232
+ <false/>
233
+ <key>KeepAlive</key>
234
+ <false/>
235
+ <key>StandardOutPath</key>
236
+ <string>${logDir}/stdout.log</string>
237
+ <key>StandardErrorPath</key>
238
+ <string>${logDir}/stderr.log</string>
239
+ </dict>
240
+ </plist>`;
241
+ fs.writeFileSync(plistPath, plist, 'utf-8');
242
+ return plistPath;
243
+ }
244
+ async function upsertSessionRecord(serviceId, serviceName, channel, sessionDir) {
245
+ const pool = (0, database_1.getPool)();
246
+ await pool.query(`INSERT INTO semo.incubator_sessions
247
+ (service_id, service_name, channel, session_dir, status)
248
+ VALUES ($1, $2, $3, $4, 'active')
249
+ ON CONFLICT (service_id) DO UPDATE SET
250
+ status = 'active',
251
+ session_dir = $4,
252
+ channel = COALESCE($3, semo.incubator_sessions.channel)`, [serviceId, serviceName, channel, sessionDir]);
253
+ }
254
+ async function updateSessionStatus(serviceId, status) {
255
+ const pool = (0, database_1.getPool)();
256
+ await pool.query(`UPDATE semo.incubator_sessions SET status = $2 WHERE service_id = $1`, [
257
+ serviceId,
258
+ status,
259
+ ]);
260
+ }
261
+ async function listSessions() {
262
+ const pool = (0, database_1.getPool)();
263
+ const result = await pool.query(`SELECT service_id, service_name, channel, session_dir, status,
264
+ created_at::text
265
+ FROM semo.incubator_sessions
266
+ ORDER BY created_at DESC`);
267
+ return result.rows;
268
+ }
269
+ async function getSession(serviceId) {
270
+ const pool = (0, database_1.getPool)();
271
+ const result = await pool.query(`SELECT service_id, service_name, channel, session_dir, status,
272
+ created_at::text
273
+ FROM semo.incubator_sessions
274
+ WHERE service_id = $1`, [serviceId]);
275
+ return result.rows[0] || null;
276
+ }
277
+ // ============================================================
278
+ // Command Registration
279
+ // ============================================================
280
+ function registerIncubatorCommands(program) {
281
+ const incubator = program
282
+ .command('incubator')
283
+ .description('인큐베이터 세션 관리 — 생성, 목록, 중지');
284
+ // ── semo incubator create ──
285
+ incubator
286
+ .command('create')
287
+ .description('인큐베이터 프로젝트용 Claude Code 세션 생성')
288
+ .requiredOption('--service-id <uuid>', '서비스 UUID')
289
+ .option('--channel <name>', 'Slack 채널명 (예: #incubator-요리왕)')
290
+ .option('--name <name>', '서비스명 (미지정 시 DB에서 조회)')
291
+ .action(async (options) => {
292
+ const spinner = (0, ora_1.default)('인큐베이터 세션 생성 중...').start();
293
+ try {
294
+ // 1. 서비스명 확인
295
+ let serviceName = options.name || options.serviceId.slice(0, 8);
296
+ if (!options.name) {
297
+ try {
298
+ const pool = (0, database_1.getPool)();
299
+ const result = await pool.query(`SELECT name FROM semo.services WHERE service_id = $1
300
+ UNION ALL
301
+ SELECT value->>'name' FROM semo.knowledge_base
302
+ WHERE domain = $1 AND key = 'base-information'
303
+ LIMIT 1`, [options.serviceId]);
304
+ if (result.rows[0]) {
305
+ serviceName = result.rows[0].name || serviceName;
306
+ }
307
+ }
308
+ catch {
309
+ // DB 접속 실패 시 UUID 앞 8자리 사용
310
+ }
311
+ }
312
+ // 2. 세션 디렉토리 생성
313
+ spinner.text = '디렉토리 구조 생성...';
314
+ const sessionDir = ensureSessionDir(options.serviceId);
315
+ // 3. 심링크 설정
316
+ spinner.text = '공유 리소스 심링크...';
317
+ createSymlinks(sessionDir);
318
+ // 4. CLAUDE.md 생성
319
+ spinner.text = 'CLAUDE.md 생성...';
320
+ writeClaudeMd(sessionDir, options.serviceId, serviceName, options.channel);
321
+ // 5. settings.json 생성
322
+ writeSettingsJson(sessionDir, options.serviceId, options.channel);
323
+ // 6. memory 파일 생성
324
+ writeMemoryFiles(sessionDir, serviceName);
325
+ // 7. launchd plist 생성
326
+ spinner.text = 'launchd plist 생성...';
327
+ const plistPath = writeLaunchdPlist(options.serviceId, sessionDir);
328
+ // 8. DB 기록
329
+ spinner.text = 'DB 기록...';
330
+ try {
331
+ await upsertSessionRecord(options.serviceId, serviceName, options.channel || null, sessionDir);
332
+ }
333
+ catch {
334
+ // DB 없어도 로컬 세션은 생성
335
+ }
336
+ spinner.succeed(`인큐베이터 세션 생성 완료: ${serviceName}`);
337
+ console.log();
338
+ console.log(chalk_1.default.gray(' 세션 디렉토리:'), sessionDir);
339
+ console.log(chalk_1.default.gray(' launchd plist:'), plistPath);
340
+ console.log(chalk_1.default.gray(' 채널:'), options.channel || '(미할당)');
341
+ console.log();
342
+ console.log(chalk_1.default.cyan('세션 시작:'), `cd ${sessionDir} && claude`);
343
+ console.log(chalk_1.default.cyan('서비스로 시작:'), `launchctl load ${plistPath}`);
344
+ }
345
+ catch (err) {
346
+ spinner.fail(`세션 생성 실패: ${err instanceof Error ? err.message : err}`);
347
+ }
348
+ finally {
349
+ await (0, database_1.closeConnection)();
350
+ }
351
+ });
352
+ // ── semo incubator list ──
353
+ incubator
354
+ .command('list')
355
+ .description('활성 인큐베이터 세션 목록')
356
+ .option('--all', '중지/아카이브 포함')
357
+ .action(async (options) => {
358
+ const spinner = (0, ora_1.default)('세션 목록 조회 중...').start();
359
+ try {
360
+ // DB 조회 시도
361
+ let sessions = [];
362
+ try {
363
+ sessions = await listSessions();
364
+ }
365
+ catch {
366
+ // DB 실패 시 로컬 디렉토리 스캔
367
+ spinner.text = '로컬 디렉토리 스캔...';
368
+ if (fs.existsSync(SESSIONS_ROOT)) {
369
+ const dirs = fs.readdirSync(SESSIONS_ROOT);
370
+ sessions = dirs
371
+ .filter((d) => fs.existsSync(path.join(SESSIONS_ROOT, d, 'CLAUDE.md')))
372
+ .map((d) => ({
373
+ service_id: d,
374
+ service_name: d.slice(0, 8),
375
+ channel: null,
376
+ session_dir: path.join(SESSIONS_ROOT, d),
377
+ status: 'active',
378
+ created_at: '',
379
+ }));
380
+ }
381
+ }
382
+ if (!options.all) {
383
+ sessions = sessions.filter((s) => s.status === 'active');
384
+ }
385
+ spinner.stop();
386
+ if (sessions.length === 0) {
387
+ console.log(chalk_1.default.yellow('활성 인큐베이터 세션이 없습니다.'));
388
+ return;
389
+ }
390
+ console.log(chalk_1.default.bold(`인큐베이터 세션 (${sessions.length}개)\n`));
391
+ for (const s of sessions) {
392
+ const statusIcon = s.status === 'active'
393
+ ? chalk_1.default.green('●')
394
+ : s.status === 'stopped'
395
+ ? chalk_1.default.yellow('○')
396
+ : chalk_1.default.gray('◌');
397
+ console.log(` ${statusIcon} ${chalk_1.default.bold(s.service_name)} ${chalk_1.default.gray(`(${s.service_id.slice(0, 8)})`)}`);
398
+ if (s.channel)
399
+ console.log(` 채널: ${s.channel}`);
400
+ if (s.created_at)
401
+ console.log(` 생성: ${s.created_at.slice(0, 10)}`);
402
+ console.log(` 경로: ${s.session_dir}`);
403
+ console.log();
404
+ }
405
+ }
406
+ catch (err) {
407
+ spinner.fail(`목록 조회 실패: ${err instanceof Error ? err.message : err}`);
408
+ }
409
+ finally {
410
+ await (0, database_1.closeConnection)();
411
+ }
412
+ });
413
+ // ── semo incubator stop ──
414
+ incubator
415
+ .command('stop')
416
+ .description('인큐베이터 세션 중지')
417
+ .requiredOption('--service-id <uuid>', '서비스 UUID')
418
+ .option('--archive', '세션 디렉토리 아카이브')
419
+ .action(async (options) => {
420
+ const spinner = (0, ora_1.default)('세션 중지 중...').start();
421
+ try {
422
+ const sessionDir = path.join(SESSIONS_ROOT, options.serviceId);
423
+ const label = getLaunchdLabel(options.serviceId);
424
+ const plistPath = path.join(LAUNCHD_DIR, `${label}.plist`);
425
+ // 1. launchd 언로드
426
+ if (fs.existsSync(plistPath)) {
427
+ try {
428
+ const { execSync } = await import('child_process');
429
+ execSync(`launchctl unload ${plistPath} 2>/dev/null || true`);
430
+ fs.unlinkSync(plistPath);
431
+ spinner.text = 'launchd 서비스 중지...';
432
+ }
433
+ catch {
434
+ // 이미 언로드된 경우 무시
435
+ }
436
+ }
437
+ // 2. DB 상태 업데이트
438
+ try {
439
+ await updateSessionStatus(options.serviceId, options.archive ? 'archived' : 'stopped');
440
+ }
441
+ catch {
442
+ // DB 없어도 계속
443
+ }
444
+ // 3. 아카이브
445
+ if (options.archive && fs.existsSync(sessionDir)) {
446
+ const archiveDir = path.join(os.homedir(), '.semo-sessions-archive');
447
+ fs.mkdirSync(archiveDir, { recursive: true });
448
+ const archivePath = path.join(archiveDir, options.serviceId);
449
+ fs.renameSync(sessionDir, archivePath);
450
+ spinner.succeed(`세션 아카이브 완료: ${archivePath}`);
451
+ }
452
+ else {
453
+ spinner.succeed(`세션 중지 완료: ${options.serviceId.slice(0, 8)}`);
454
+ if (fs.existsSync(sessionDir)) {
455
+ console.log(chalk_1.default.gray(` 디렉토리 유지: ${sessionDir}`));
456
+ console.log(chalk_1.default.gray(` 완전 삭제: semo incubator stop --service-id ${options.serviceId} --archive`));
457
+ }
458
+ }
459
+ }
460
+ catch (err) {
461
+ spinner.fail(`세션 중지 실패: ${err instanceof Error ? err.message : err}`);
462
+ }
463
+ finally {
464
+ await (0, database_1.closeConnection)();
465
+ }
466
+ });
467
+ // ── semo incubator status ──
468
+ incubator
469
+ .command('status')
470
+ .description('인큐베이터 세션 상태 조회')
471
+ .requiredOption('--service-id <uuid>', '서비스 UUID')
472
+ .action(async (options) => {
473
+ try {
474
+ const sessionDir = path.join(SESSIONS_ROOT, options.serviceId);
475
+ const label = getLaunchdLabel(options.serviceId);
476
+ const plistPath = path.join(LAUNCHD_DIR, `${label}.plist`);
477
+ console.log(chalk_1.default.bold(`\n세션 상태: ${options.serviceId.slice(0, 8)}\n`));
478
+ // 디렉토리 존재 여부
479
+ const dirExists = fs.existsSync(sessionDir);
480
+ console.log(` 디렉토리: ${dirExists ? chalk_1.default.green('존재') : chalk_1.default.red('없음')} ${chalk_1.default.gray(sessionDir)}`);
481
+ // CLAUDE.md 존재 여부
482
+ if (dirExists) {
483
+ const claudeMd = fs.existsSync(path.join(sessionDir, 'CLAUDE.md'));
484
+ console.log(` CLAUDE.md: ${claudeMd ? chalk_1.default.green('있음') : chalk_1.default.red('없음')}`);
485
+ // 심링크 상태
486
+ const agentsLink = path.join(sessionDir, '.claude', 'agents');
487
+ const skillsLink = path.join(sessionDir, '.claude', 'skills');
488
+ const agentsOk = fs.existsSync(agentsLink) && fs.lstatSync(agentsLink).isSymbolicLink();
489
+ const skillsOk = fs.existsSync(skillsLink) && fs.lstatSync(skillsLink).isSymbolicLink();
490
+ console.log(` 심링크: agents=${agentsOk ? chalk_1.default.green('OK') : chalk_1.default.red('X')} skills=${skillsOk ? chalk_1.default.green('OK') : chalk_1.default.red('X')}`);
491
+ }
492
+ // launchd 상태
493
+ const plistExists = fs.existsSync(plistPath);
494
+ console.log(` launchd: ${plistExists ? chalk_1.default.green('등록') : chalk_1.default.gray('미등록')}`);
495
+ // DB 상태
496
+ try {
497
+ const session = await getSession(options.serviceId);
498
+ if (session) {
499
+ console.log(` DB 상태: ${chalk_1.default.cyan(session.status)}`);
500
+ console.log(` 생성일: ${session.created_at?.slice(0, 10) || '?'}`);
501
+ if (session.channel)
502
+ console.log(` 채널: ${session.channel}`);
503
+ }
504
+ else {
505
+ console.log(` DB 상태: ${chalk_1.default.gray('미등록')}`);
506
+ }
507
+ }
508
+ catch {
509
+ console.log(` DB 상태: ${chalk_1.default.gray('(DB 연결 불가)')}`);
510
+ }
511
+ console.log();
512
+ }
513
+ finally {
514
+ await (0, database_1.closeConnection)();
515
+ }
516
+ });
517
+ }
@@ -19,11 +19,11 @@ const service_migrate_1 = require("../service-migrate");
19
19
  function registerServiceCommands(program) {
20
20
  const service = program
21
21
  .command("service")
22
- .description("서비스 관리 — 이식, 목록 조회");
22
+ .description("서비스 관리 — 이식, 목록 조회, 진단, 업데이트");
23
23
  // ── semo service migrate ──
24
24
  service
25
25
  .command("migrate")
26
- .description("기존 서비스를 service_projects 테이블에 이식")
26
+ .description("기존 서비스를 services 테이블에 이식")
27
27
  .option("--domain <name>", "특정 서비스 도메인")
28
28
  .option("--all", "미등록 서비스 전체 이식")
29
29
  .option("--dry-run", "미리보기 (DB 변경 없음)")
@@ -51,7 +51,7 @@ function registerServiceCommands(program) {
51
51
  // 도메인이 있는데 미등록 목록에 없으면 → 이미 등록됨 또는 미존재
52
52
  const registered = await (0, service_migrate_1.getRegisteredServices)(pool);
53
53
  if (registered.includes(options.domain)) {
54
- spinner.info(`'${options.domain}'은(는) 이미 service_projects에 등록되어 있습니다.`);
54
+ spinner.info(`'${options.domain}'은(는) 이미 services에 등록되어 있습니다.`);
55
55
  }
56
56
  else {
57
57
  spinner.fail(`'${options.domain}'은(는) 온톨로지에 service 타입으로 등록되지 않았습니다.`);
@@ -139,4 +139,145 @@ function registerServiceCommands(program) {
139
139
  await (0, database_1.closeConnection)();
140
140
  }
141
141
  });
142
+ // ── semo service diagnose ──
143
+ service
144
+ .command("diagnose")
145
+ .description("서비스 KB ↔ services 교차 진단")
146
+ .requiredOption("--domain <name>", "진단할 서비스 도메인")
147
+ .action(async (options) => {
148
+ const pool = (0, database_1.getPool)();
149
+ const spinner = (0, ora_1.default)("서비스 진단 중...").start();
150
+ try {
151
+ const result = await (0, service_migrate_1.diagnoseServiceStatus)(pool, options.domain);
152
+ spinner.stop();
153
+ console.log(chalk_1.default.cyan.bold(`\n🔍 서비스 진단: ${result.domain}\n`));
154
+ // KB 상태
155
+ if (result.kbEntryCount > 0) {
156
+ console.log(chalk_1.default.bold(" ┌─ KB ─────────────────────────────┐"));
157
+ console.log(` │ 도메인: ${result.domain} (service)`);
158
+ console.log(` │ KB 엔트리: ${result.kbEntryCount}개`);
159
+ console.log(` │ 키: ${result.kbKeys.join(", ")}`);
160
+ if (result.missingRequired.length > 0) {
161
+ console.log(chalk_1.default.yellow(` │ 필수 키 누락: ${result.missingRequired.join(", ")}`));
162
+ }
163
+ console.log(chalk_1.default.bold(" └──────────────────────────────────┘\n"));
164
+ }
165
+ else {
166
+ console.log(chalk_1.default.yellow(" KB 도메인 없음\n"));
167
+ }
168
+ // services 상태
169
+ const sp = result.serviceProject;
170
+ if (sp) {
171
+ console.log(chalk_1.default.bold(" ┌─ services ───────────────────────┐"));
172
+ console.log(` │ service_id: ${sp.service_id}`);
173
+ console.log(` │ project_name: ${sp.project_name}`);
174
+ console.log(` │ owner_name: ${sp.owner_name}`);
175
+ console.log(` │ lifecycle: ${sp.lifecycle}`);
176
+ console.log(` │ current_phase: ${sp.current_phase}`);
177
+ console.log(` │ status: ${sp.status}`);
178
+ if (sp.launched_at) {
179
+ console.log(` │ launched_at: ${sp.launched_at}`);
180
+ }
181
+ console.log(chalk_1.default.bold(" └─────────────────────────────────┘\n"));
182
+ }
183
+ else {
184
+ console.log(chalk_1.default.yellow(" services 미등록\n"));
185
+ }
186
+ // 교차 검증
187
+ if (result.mismatches.length > 0) {
188
+ console.log(chalk_1.default.bold(" ┌─ 교차 검증 ──────────────────────┐"));
189
+ for (const m of result.mismatches) {
190
+ console.log(chalk_1.default.yellow(` │ ⚠️ ${m.field}: KB=${m.kbValue}, SP=${m.spValue}`));
191
+ console.log(chalk_1.default.gray(` │ 기대값: ${m.expected}`));
192
+ }
193
+ console.log(chalk_1.default.bold(" └─────────────────────────────────┘\n"));
194
+ }
195
+ // Verdict
196
+ const verdictMap = {
197
+ healthy: chalk_1.default.green("✅ HEALTHY — 정합성 양호"),
198
+ mismatch: chalk_1.default.yellow(`⚠️ MISMATCH (${result.mismatches.length}건)`),
199
+ "kb-only": chalk_1.default.yellow("⚠️ KB-ONLY — services 미등록"),
200
+ "sp-only": chalk_1.default.red("❌ SP-ONLY — KB 도메인 없음 (비정상)"),
201
+ missing: chalk_1.default.red("❌ MISSING — KB, services 모두 없음"),
202
+ };
203
+ console.log(` 결과: ${verdictMap[result.verdict] ?? result.verdict}\n`);
204
+ }
205
+ catch (err) {
206
+ spinner.fail(`진단 실패: ${err.message}`);
207
+ }
208
+ finally {
209
+ await (0, database_1.closeConnection)();
210
+ }
211
+ });
212
+ // ── semo service update ──
213
+ service
214
+ .command("update")
215
+ .description("services 레코드 업데이트")
216
+ .requiredOption("--domain <name>", "대상 서비스 도메인")
217
+ .option("--status <status>", "서비스 상태 (active|paused|completed)")
218
+ .option("--lifecycle <lifecycle>", "라이프사이클 (build|ops|sunset)")
219
+ .option("--phase <number>", "현재 phase (0-9)", parseInt)
220
+ .option("--name <projectName>", "프로젝트명")
221
+ .option("--owner <ownerName>", "오너명")
222
+ .action(async (options) => {
223
+ const pool = (0, database_1.getPool)();
224
+ const spinner = (0, ora_1.default)("서비스 업데이트 중...").start();
225
+ try {
226
+ // Validate enum values
227
+ const validStatuses = ["active", "paused", "completed"];
228
+ if (options.status && !validStatuses.includes(options.status)) {
229
+ spinner.fail(`잘못된 status: '${options.status}' (허용: ${validStatuses.join(", ")})`);
230
+ await (0, database_1.closeConnection)();
231
+ return;
232
+ }
233
+ const validLifecycles = ["build", "ops", "sunset"];
234
+ if (options.lifecycle && !validLifecycles.includes(options.lifecycle)) {
235
+ spinner.fail(`잘못된 lifecycle: '${options.lifecycle}' (허용: ${validLifecycles.join(", ")})`);
236
+ await (0, database_1.closeConnection)();
237
+ return;
238
+ }
239
+ if (options.phase !== undefined && (options.phase < 0 || options.phase > 9)) {
240
+ spinner.fail(`잘못된 phase: ${options.phase} (허용: 0-9)`);
241
+ await (0, database_1.closeConnection)();
242
+ return;
243
+ }
244
+ const updates = {};
245
+ if (options.status)
246
+ updates.status = options.status;
247
+ if (options.lifecycle)
248
+ updates.lifecycle = options.lifecycle;
249
+ if (options.phase !== undefined)
250
+ updates.current_phase = options.phase;
251
+ if (options.name)
252
+ updates.project_name = options.name;
253
+ if (options.owner)
254
+ updates.owner_name = options.owner;
255
+ if (Object.keys(updates).length === 0) {
256
+ spinner.info("업데이트할 항목이 없습니다. --status, --lifecycle, --phase 등을 지정하세요.");
257
+ await (0, database_1.closeConnection)();
258
+ return;
259
+ }
260
+ const result = await (0, service_migrate_1.updateServiceProject)(pool, options.domain, updates);
261
+ if (!result) {
262
+ spinner.fail(`'${options.domain}'은(는) services에 등록되지 않았습니다.`);
263
+ await (0, database_1.closeConnection)();
264
+ return;
265
+ }
266
+ spinner.succeed(`'${options.domain}' 업데이트 완료`);
267
+ console.log(chalk_1.default.gray(` project_name: ${result.project_name}\n` +
268
+ ` owner_name: ${result.owner_name}\n` +
269
+ ` lifecycle: ${result.lifecycle}\n` +
270
+ ` current_phase: ${result.current_phase}\n` +
271
+ ` status: ${result.status}\n` +
272
+ ` launched_at: ${result.launched_at ?? "(없음)"}\n` +
273
+ ` updated_at: ${result.updated_at}`));
274
+ console.log();
275
+ }
276
+ catch (err) {
277
+ spinner.fail(`업데이트 실패: ${err.message}`);
278
+ }
279
+ finally {
280
+ await (0, database_1.closeConnection)();
281
+ }
282
+ });
142
283
  }