@team-semicolon/semo-cli 4.12.0 → 4.13.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.
@@ -86,6 +86,9 @@ function registerCommitmentsCommands(program) {
86
86
  JSON.stringify(steps),
87
87
  ]);
88
88
  console.log(chalk_1.default.green(`✔ commitment created: ${id}`));
89
+ if (!options.sourceType) {
90
+ console.log(chalk_1.default.yellow(`⚠ source-type 미지정: 자동 검증 불가. --source-type 권장.`));
91
+ }
89
92
  console.log(JSON.stringify({ id, bot_id: options.botId, title: options.title, deadline_at: deadlineAt }));
90
93
  }
91
94
  catch (err) {
@@ -239,7 +242,8 @@ function registerCommitmentsCommands(program) {
239
242
  }
240
243
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
241
244
  params.push(parseInt(options.limit));
242
- const result = await pool.query(`SELECT id, bot_id, status, title, deadline_at::text, steps,
245
+ const result = await pool.query(`SELECT id, bot_id, status, title, description, source_type, source_ref,
246
+ deadline_at::text, steps,
243
247
  last_heartbeat_at::text, created_at::text, completed_at::text, metadata
244
248
  FROM semo.bot_commitments
245
249
  ${where}
@@ -290,6 +294,8 @@ function registerCommitmentsCommands(program) {
290
294
  .command("watch")
291
295
  .description("워치독 — 활성 약속의 health 상태 조회")
292
296
  .option("--format <type>", "출력 형식 (table|json)", "table")
297
+ .option("--bot-id <id>", "특정 봇만 필터")
298
+ .option("--exclude-bot <id>", "특정 봇 제외")
293
299
  .action(async (options) => {
294
300
  const connected = await (0, database_1.isDbConnected)();
295
301
  if (!connected) {
@@ -299,17 +305,33 @@ function registerCommitmentsCommands(program) {
299
305
  }
300
306
  try {
301
307
  const pool = (0, database_1.getPool)();
302
- const result = await pool.query(`SELECT id, bot_id, status, title, deadline_at::text,
308
+ const conditions = [];
309
+ const params = [];
310
+ let paramIdx = 1;
311
+ if (options.botId) {
312
+ conditions.push(`bot_id = $${paramIdx++}`);
313
+ params.push(options.botId);
314
+ }
315
+ if (options.excludeBot) {
316
+ conditions.push(`bot_id != $${paramIdx++}`);
317
+ params.push(options.excludeBot);
318
+ }
319
+ const whereClause = conditions.length > 0
320
+ ? `WHERE ${conditions.join(" AND ")}`
321
+ : "";
322
+ const result = await pool.query(`SELECT id, bot_id, status, title, description, source_type, source_ref,
323
+ deadline_at::text,
303
324
  health, minutes_since_heartbeat, minutes_overdue,
304
325
  steps, last_heartbeat_at::text, created_at::text, metadata
305
326
  FROM semo.v_active_commitments
327
+ ${whereClause}
306
328
  ORDER BY
307
329
  CASE health
308
330
  WHEN 'overdue' THEN 0
309
331
  WHEN 'stale' THEN 1
310
332
  ELSE 2
311
333
  END,
312
- deadline_at ASC NULLS LAST`);
334
+ deadline_at ASC NULLS LAST`, params);
313
335
  if (options.format === "json") {
314
336
  const summary = {
315
337
  total: result.rows.length,
@@ -0,0 +1,9 @@
1
+ /**
2
+ * commitments 테스트 — bot_commitments CRUD + 워치독 뷰 검증
3
+ *
4
+ * 실행: npx ts-node packages/cli/src/commands/commitments.test.ts
5
+ *
6
+ * 실제 DB 연결 필요 (semo.bot_commitments 테이블).
7
+ * 테스트 데이터는 bot_id='__test__' 접두사로 생성하며 종료 시 정리.
8
+ */
9
+ export {};
@@ -0,0 +1,223 @@
1
+ "use strict";
2
+ /**
3
+ * commitments 테스트 — bot_commitments CRUD + 워치독 뷰 검증
4
+ *
5
+ * 실행: npx ts-node packages/cli/src/commands/commitments.test.ts
6
+ *
7
+ * 실제 DB 연결 필요 (semo.bot_commitments 테이블).
8
+ * 테스트 데이터는 bot_id='__test__' 접두사로 생성하며 종료 시 정리.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ const database_1 = require("../database");
12
+ const TEST_BOT = "__test__";
13
+ let passed = 0;
14
+ let failed = 0;
15
+ const createdIds = [];
16
+ function assert(condition, message) {
17
+ if (condition) {
18
+ passed++;
19
+ console.log(` ✅ ${message}`);
20
+ }
21
+ else {
22
+ failed++;
23
+ console.log(` ❌ ${message}`);
24
+ }
25
+ }
26
+ function makeId() {
27
+ const rand = Math.random().toString(36).slice(2, 6);
28
+ return `cmt-${TEST_BOT}-${Date.now()}-${rand}`;
29
+ }
30
+ async function cleanup() {
31
+ try {
32
+ const pool = (0, database_1.getPool)();
33
+ await pool.query(`DELETE FROM semo.bot_commitments WHERE bot_id = $1`, [TEST_BOT]);
34
+ }
35
+ catch { /* ignore */ }
36
+ }
37
+ async function run() {
38
+ console.log("\n🧪 commitments.test.ts\n");
39
+ const connected = await (0, database_1.isDbConnected)();
40
+ if (!connected) {
41
+ console.log("❌ DB 연결 실패 — 테스트 스킵");
42
+ process.exit(1);
43
+ }
44
+ const pool = (0, database_1.getPool)();
45
+ // 사전 정리
46
+ await cleanup();
47
+ // ── Case 1: CREATE — 기본 생성 ─────────────────────────────────────────────
48
+ console.log("Case 1: 기본 생성");
49
+ const id1 = makeId();
50
+ createdIds.push(id1);
51
+ {
52
+ await pool.query(`INSERT INTO semo.bot_commitments (id, bot_id, status, title, deadline_at)
53
+ VALUES ($1, $2, 'pending', '테스트 약속 1', NOW() + INTERVAL '15 minutes')`, [id1, TEST_BOT]);
54
+ const r = await pool.query(`SELECT * FROM semo.bot_commitments WHERE id = $1`, [id1]);
55
+ assert(r.rows.length === 1, "INSERT 성공");
56
+ assert(r.rows[0].status === "pending", "초기 상태 = pending");
57
+ assert(r.rows[0].bot_id === TEST_BOT, `bot_id = ${TEST_BOT}`);
58
+ assert(r.rows[0].completed_at === null, "completed_at = null (아직 완료 전)");
59
+ }
60
+ // ── Case 2: CREATE — steps JSON ────────────────────────────────────────────
61
+ console.log("Case 2: steps JSON 저장");
62
+ const id2 = makeId();
63
+ createdIds.push(id2);
64
+ {
65
+ const steps = [
66
+ { label: "Typography", done: false },
67
+ { label: "Layout", done: false },
68
+ { label: "Motion", done: false },
69
+ ];
70
+ await pool.query(`INSERT INTO semo.bot_commitments (id, bot_id, status, title, steps)
71
+ VALUES ($1, $2, 'pending', 'Steps 테스트', $3)`, [id2, TEST_BOT, JSON.stringify(steps)]);
72
+ const r = await pool.query(`SELECT steps FROM semo.bot_commitments WHERE id = $1`, [id2]);
73
+ const savedSteps = r.rows[0].steps;
74
+ assert(Array.isArray(savedSteps), "steps는 배열");
75
+ assert(savedSteps.length === 3, `steps 3개 (got ${savedSteps.length})`);
76
+ assert(savedSteps[0].label === "Typography", "첫 step label 일치");
77
+ assert(savedSteps.every((s) => !s.done), "모든 step done=false");
78
+ }
79
+ // ── Case 3: UPDATE — heartbeat ─────────────────────────────────────────────
80
+ console.log("Case 3: heartbeat 갱신");
81
+ {
82
+ await pool.query(`UPDATE semo.bot_commitments
83
+ SET last_heartbeat_at = NOW(),
84
+ status = CASE WHEN status = 'pending' THEN 'active' ELSE status END
85
+ WHERE id = $1`, [id1]);
86
+ const r = await pool.query(`SELECT status, last_heartbeat_at FROM semo.bot_commitments WHERE id = $1`, [id1]);
87
+ assert(r.rows[0].status === "active", "heartbeat → status = active");
88
+ assert(r.rows[0].last_heartbeat_at !== null, "last_heartbeat_at 채워짐");
89
+ }
90
+ // ── Case 4: UPDATE — step-done ─────────────────────────────────────────────
91
+ console.log("Case 4: step 완료 처리");
92
+ {
93
+ await pool.query(`UPDATE semo.bot_commitments
94
+ SET steps = (
95
+ SELECT jsonb_agg(
96
+ CASE
97
+ WHEN elem->>'label' = $2 THEN jsonb_set(elem, '{done}', 'true')
98
+ ELSE elem
99
+ END
100
+ )
101
+ FROM jsonb_array_elements(steps) AS elem
102
+ )
103
+ WHERE id = $1`, [id2, "Typography"]);
104
+ const r = await pool.query(`SELECT steps FROM semo.bot_commitments WHERE id = $1`, [id2]);
105
+ const steps = r.rows[0].steps;
106
+ const typo = steps.find((s) => s.label === "Typography");
107
+ const layout = steps.find((s) => s.label === "Layout");
108
+ assert(typo.done === true, "Typography done=true");
109
+ assert(layout.done === false, "Layout 여전히 done=false");
110
+ }
111
+ // ── Case 5: DONE — completed_at 트리거 ────────────────────────────────────
112
+ console.log("Case 5: done → completed_at 자동 채움");
113
+ {
114
+ await pool.query(`UPDATE semo.bot_commitments SET status = 'done' WHERE id = $1`, [id1]);
115
+ const r = await pool.query(`SELECT status, completed_at FROM semo.bot_commitments WHERE id = $1`, [id1]);
116
+ assert(r.rows[0].status === "done", "status = done");
117
+ assert(r.rows[0].completed_at !== null, "completed_at 자동 채워짐 (트리거)");
118
+ }
119
+ // ── Case 6: FAIL — metadata.fail_reason ────────────────────────────────────
120
+ console.log("Case 6: fail + reason 저장");
121
+ const id3 = makeId();
122
+ createdIds.push(id3);
123
+ {
124
+ await pool.query(`INSERT INTO semo.bot_commitments (id, bot_id, status, title)
125
+ VALUES ($1, $2, 'active', '실패 테스트')`, [id3, TEST_BOT]);
126
+ await pool.query(`UPDATE semo.bot_commitments
127
+ SET status = 'failed', metadata = metadata || jsonb_build_object('fail_reason', $2::text)
128
+ WHERE id = $1`, [id3, "빌드 실패"]);
129
+ const r = await pool.query(`SELECT status, completed_at, metadata FROM semo.bot_commitments WHERE id = $1`, [id3]);
130
+ assert(r.rows[0].status === "failed", "status = failed");
131
+ assert(r.rows[0].completed_at !== null, "completed_at 자동 채워짐");
132
+ assert(r.rows[0].metadata.fail_reason === "빌드 실패", "metadata.fail_reason 저장됨");
133
+ }
134
+ // ── Case 7: updated_at 트리거 ──────────────────────────────────────────────
135
+ console.log("Case 7: updated_at 자동 갱신");
136
+ {
137
+ const before = await pool.query(`SELECT updated_at FROM semo.bot_commitments WHERE id = $1`, [id2]);
138
+ // 최소 1ms 대기
139
+ await new Promise((r) => setTimeout(r, 50));
140
+ await pool.query(`UPDATE semo.bot_commitments SET title = 'Updated title' WHERE id = $1`, [id2]);
141
+ const after = await pool.query(`SELECT updated_at FROM semo.bot_commitments WHERE id = $1`, [id2]);
142
+ const t1 = new Date(before.rows[0].updated_at).getTime();
143
+ const t2 = new Date(after.rows[0].updated_at).getTime();
144
+ assert(t2 > t1, `updated_at 갱신됨 (${t2 - t1}ms 차이)`);
145
+ }
146
+ // ── Case 8: v_active_commitments 뷰 ───────────────────────────────────────
147
+ console.log("Case 8: v_active_commitments 뷰");
148
+ const id4 = makeId();
149
+ createdIds.push(id4);
150
+ {
151
+ // overdue commitment 생성 (deadline 과거)
152
+ await pool.query(`INSERT INTO semo.bot_commitments (id, bot_id, status, title, deadline_at)
153
+ VALUES ($1, $2, 'active', 'Overdue 테스트', NOW() - INTERVAL '10 minutes')`, [id4, TEST_BOT]);
154
+ const r = await pool.query(`SELECT health, minutes_overdue FROM semo.v_active_commitments WHERE id = $1`, [id4]);
155
+ assert(r.rows.length === 1, "뷰에서 조회 가능");
156
+ assert(r.rows[0].health === "overdue", `health = overdue (got ${r.rows[0].health})`);
157
+ assert(r.rows[0].minutes_overdue > 0, `minutes_overdue > 0 (got ${r.rows[0].minutes_overdue})`);
158
+ }
159
+ // ── Case 9: stale 감지 ─────────────────────────────────────────────────────
160
+ console.log("Case 9: stale heartbeat 감지");
161
+ const id5 = makeId();
162
+ createdIds.push(id5);
163
+ {
164
+ await pool.query(`INSERT INTO semo.bot_commitments (id, bot_id, status, title, last_heartbeat_at)
165
+ VALUES ($1, $2, 'active', 'Stale 테스트', NOW() - INTERVAL '45 minutes')`, [id5, TEST_BOT]);
166
+ const r = await pool.query(`SELECT health, minutes_since_heartbeat FROM semo.v_active_commitments WHERE id = $1`, [id5]);
167
+ assert(r.rows[0].health === "stale", `health = stale (got ${r.rows[0].health})`);
168
+ assert(r.rows[0].minutes_since_heartbeat >= 44, `heartbeat 45분+ 전`);
169
+ }
170
+ // ── Case 10: on-track ──────────────────────────────────────────────────────
171
+ console.log("Case 10: on-track (정상)");
172
+ const id6 = makeId();
173
+ createdIds.push(id6);
174
+ {
175
+ await pool.query(`INSERT INTO semo.bot_commitments (id, bot_id, status, title, deadline_at, last_heartbeat_at)
176
+ VALUES ($1, $2, 'active', 'On-track 테스트', NOW() + INTERVAL '30 minutes', NOW())`, [id6, TEST_BOT]);
177
+ const r = await pool.query(`SELECT health FROM semo.v_active_commitments WHERE id = $1`, [id6]);
178
+ assert(r.rows[0].health === "on-track", `health = on-track (got ${r.rows[0].health})`);
179
+ }
180
+ // ── Case 11: 완료된 건은 뷰에서 제외 ──────────────────────────────────────
181
+ console.log("Case 11: 완료된 commitment는 뷰에서 제외");
182
+ {
183
+ const r = await pool.query(`SELECT * FROM semo.v_active_commitments WHERE id = $1`, [id1] // Case 5에서 done 처리됨
184
+ );
185
+ assert(r.rows.length === 0, "done 상태는 v_active_commitments에서 제외");
186
+ }
187
+ // ── Case 12: 인덱스 사용 확인 ─────────────────────────────────────────────
188
+ console.log("Case 12: 인덱스 존재 확인");
189
+ {
190
+ const r = await pool.query(`SELECT indexname FROM pg_indexes WHERE tablename = 'bot_commitments' AND schemaname = 'semo' ORDER BY indexname`);
191
+ const names = r.rows.map((row) => row.indexname);
192
+ assert(names.includes("idx_commitments_active"), "idx_commitments_active 존재");
193
+ assert(names.includes("idx_commitments_bot"), "idx_commitments_bot 존재");
194
+ }
195
+ // ── Case 13: watch --bot-id 필터 ─────────────────────────────────────────
196
+ console.log("Case 13: watch --bot-id 필터");
197
+ {
198
+ // __test__ 봇의 활성 커밋먼트만 조회 (id4=overdue, id5=stale, id6=on-track)
199
+ const r = await pool.query(`SELECT id FROM semo.v_active_commitments WHERE bot_id = $1`, [TEST_BOT]);
200
+ assert(r.rows.length >= 3, `--bot-id 필터: __test__ 항목 3건+ (got ${r.rows.length})`);
201
+ assert(r.rows.every((row) => row.id.startsWith("cmt-__test__")), "모두 __test__ 봇 소유");
202
+ }
203
+ // ── Case 14: watch --exclude-bot 필터 ──────────────────────────────────
204
+ console.log("Case 14: watch --exclude-bot 필터");
205
+ {
206
+ const r = await pool.query(`SELECT id, bot_id FROM semo.v_active_commitments WHERE bot_id != $1`, [TEST_BOT]);
207
+ const hasTestBot = r.rows.some((row) => row.bot_id === TEST_BOT);
208
+ assert(!hasTestBot, "--exclude-bot 필터: __test__ 봇 제외됨");
209
+ }
210
+ // ── 정리 ──────────────────────────────────────────────────────────────────
211
+ await cleanup();
212
+ // ── 결과 ──────────────────────────────────────────────────────────────────
213
+ console.log(`\n${"─".repeat(40)}`);
214
+ console.log(`총 ${passed + failed}개 테스트: ✅ ${passed} passed, ❌ ${failed} failed\n`);
215
+ await (0, database_1.closeConnection)();
216
+ process.exit(failed > 0 ? 1 : 0);
217
+ }
218
+ run().catch(async (err) => {
219
+ console.error("테스트 런타임 에러:", err);
220
+ await cleanup();
221
+ await (0, database_1.closeConnection)();
222
+ process.exit(1);
223
+ });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * semo service — 서비스 관리
3
+ *
4
+ * semo service migrate --domain {name} — 특정 서비스 이식
5
+ * semo service migrate --all — 미등록 전체 이식
6
+ * semo service migrate --all --dry-run — 미리보기
7
+ * semo service list — 등록/미등록 서비스 목록
8
+ */
9
+ import { Command } from "commander";
10
+ export declare function registerServiceCommands(program: Command): void;
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ /**
3
+ * semo service — 서비스 관리
4
+ *
5
+ * semo service migrate --domain {name} — 특정 서비스 이식
6
+ * semo service migrate --all — 미등록 전체 이식
7
+ * semo service migrate --all --dry-run — 미리보기
8
+ * semo service list — 등록/미등록 서비스 목록
9
+ */
10
+ var __importDefault = (this && this.__importDefault) || function (mod) {
11
+ return (mod && mod.__esModule) ? mod : { "default": mod };
12
+ };
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.registerServiceCommands = registerServiceCommands;
15
+ const chalk_1 = __importDefault(require("chalk"));
16
+ const ora_1 = __importDefault(require("ora"));
17
+ const database_1 = require("../database");
18
+ const service_migrate_1 = require("../service-migrate");
19
+ function registerServiceCommands(program) {
20
+ const service = program
21
+ .command("service")
22
+ .description("서비스 관리 — 이식, 목록 조회");
23
+ // ── semo service migrate ──
24
+ service
25
+ .command("migrate")
26
+ .description("기존 서비스를 service_projects 테이블에 이식")
27
+ .option("--domain <name>", "특정 서비스 도메인")
28
+ .option("--all", "미등록 서비스 전체 이식")
29
+ .option("--dry-run", "미리보기 (DB 변경 없음)")
30
+ .action(async (options) => {
31
+ if (!options.domain && !options.all) {
32
+ console.log(chalk_1.default.yellow("--domain <name> 또는 --all 옵션이 필요합니다."));
33
+ await (0, database_1.closeConnection)();
34
+ return;
35
+ }
36
+ const pool = (0, database_1.getPool)();
37
+ const spinner = (0, ora_1.default)("서비스 이식 준비 중...").start();
38
+ try {
39
+ // 1. 미등록 서비스 조회
40
+ const unregistered = await (0, service_migrate_1.getUnregisteredServices)(pool);
41
+ if (unregistered.length === 0) {
42
+ spinner.succeed("모든 서비스가 이미 등록되어 있습니다.");
43
+ await (0, database_1.closeConnection)();
44
+ return;
45
+ }
46
+ // 2. 대상 필터링
47
+ let targets = unregistered;
48
+ if (options.domain) {
49
+ targets = unregistered.filter((s) => s.domain === options.domain);
50
+ if (targets.length === 0) {
51
+ // 도메인이 있는데 미등록 목록에 없으면 → 이미 등록됨 또는 미존재
52
+ const registered = await (0, service_migrate_1.getRegisteredServices)(pool);
53
+ if (registered.includes(options.domain)) {
54
+ spinner.info(`'${options.domain}'은(는) 이미 service_projects에 등록되어 있습니다.`);
55
+ }
56
+ else {
57
+ spinner.fail(`'${options.domain}'은(는) 온톨로지에 service 타입으로 등록되지 않았습니다.`);
58
+ }
59
+ await (0, database_1.closeConnection)();
60
+ return;
61
+ }
62
+ }
63
+ spinner.text = `${targets.length}개 서비스 감사(audit) 중...`;
64
+ // 3. 각 서비스 audit
65
+ const results = [];
66
+ for (const svc of targets) {
67
+ const audit = await (0, service_migrate_1.auditServiceKBEntries)(pool, svc.domain, svc.description, svc.created_at);
68
+ if (options.dryRun) {
69
+ results.push({ domain: svc.domain, action: "skipped", audit });
70
+ continue;
71
+ }
72
+ // 4. INSERT
73
+ try {
74
+ const row = (0, service_migrate_1.buildServiceProjectRow)(audit);
75
+ // KB 엔트리 수 조회
76
+ const countResult = await pool.query("SELECT COUNT(*)::int as cnt FROM semo.knowledge_base WHERE domain = $1", [svc.domain]);
77
+ const kbEntryCount = countResult.rows[0]?.cnt ?? 0;
78
+ const projectId = await (0, service_migrate_1.insertServiceProject)(pool, row);
79
+ // 5. pm-summary projection 쓰기
80
+ await (0, service_migrate_1.writePmSummaryToKB)(pool, svc.domain, projectId, row, kbEntryCount);
81
+ results.push({
82
+ domain: svc.domain,
83
+ action: "created",
84
+ projectId,
85
+ audit,
86
+ });
87
+ }
88
+ catch (err) {
89
+ results.push({
90
+ domain: svc.domain,
91
+ action: "error",
92
+ audit,
93
+ error: err.message,
94
+ });
95
+ }
96
+ }
97
+ spinner.stop();
98
+ // 6. 결과 리포트
99
+ if (options.dryRun) {
100
+ (0, service_migrate_1.printDryRunReport)(results);
101
+ }
102
+ else {
103
+ (0, service_migrate_1.printAuditReport)(results);
104
+ }
105
+ }
106
+ catch (err) {
107
+ spinner.fail(`이식 실패: ${err.message}`);
108
+ }
109
+ finally {
110
+ await (0, database_1.closeConnection)();
111
+ }
112
+ });
113
+ // ── semo service list ──
114
+ service
115
+ .command("list")
116
+ .description("서비스 목록 (등록/미등록 상태 포함)")
117
+ .action(async () => {
118
+ const pool = (0, database_1.getPool)();
119
+ try {
120
+ const registered = await (0, service_migrate_1.getRegisteredServices)(pool);
121
+ const unregistered = await (0, service_migrate_1.getUnregisteredServices)(pool);
122
+ console.log(chalk_1.default.cyan.bold("\n📋 서비스 목록\n"));
123
+ if (registered.length > 0) {
124
+ console.log(chalk_1.default.green(` ✓ 등록됨 (${registered.length}개):`));
125
+ for (const d of registered.sort()) {
126
+ console.log(chalk_1.default.gray(` ${d}`));
127
+ }
128
+ }
129
+ if (unregistered.length > 0) {
130
+ console.log(chalk_1.default.yellow(`\n ○ 미등록 (${unregistered.length}개):`));
131
+ for (const s of unregistered) {
132
+ const desc = s.description ? chalk_1.default.gray(` — ${s.description.substring(0, 60)}`) : "";
133
+ console.log(` ${s.domain}${desc}`);
134
+ }
135
+ }
136
+ console.log();
137
+ }
138
+ finally {
139
+ await (0, database_1.closeConnection)();
140
+ }
141
+ });
142
+ }
package/dist/index.js CHANGED
@@ -66,6 +66,7 @@ const db_1 = require("./commands/db");
66
66
  const memory_1 = require("./commands/memory");
67
67
  const test_1 = require("./commands/test");
68
68
  const commitments_1 = require("./commands/commitments");
69
+ const service_1 = require("./commands/service");
69
70
  const global_cache_1 = require("./global-cache");
70
71
  const semo_workspace_1 = require("./semo-workspace");
71
72
  const PACKAGE_NAME = "@team-semicolon/semo-cli";
@@ -2354,6 +2355,7 @@ ontoCmd
2354
2355
  (0, memory_1.registerMemoryCommands)(program);
2355
2356
  (0, test_1.registerTestCommands)(program);
2356
2357
  (0, commitments_1.registerCommitmentsCommands)(program);
2358
+ (0, service_1.registerServiceCommands)(program);
2357
2359
  // === semo skills — DB 시딩 ===
2358
2360
  /**
2359
2361
  * SKILL.md frontmatter 파싱 (YAML 파서 없이 regex 기반)
package/dist/kb.js CHANGED
@@ -773,7 +773,7 @@ async function kbUpsert(pool, entry) {
773
773
  const typeResult = await schemaClient.query("SELECT entity_type FROM semo.ontology WHERE domain = $1 AND entity_type IS NOT NULL", [entry.domain]);
774
774
  if (typeResult.rows.length > 0) {
775
775
  const entityType = typeResult.rows[0].entity_type;
776
- const schemaResult = await schemaClient.query("SELECT scheme_key, COALESCE(key_type, 'singleton') as key_type FROM semo.kb_type_schema WHERE type_key = $1", [entityType]);
776
+ const schemaResult = await schemaClient.query("SELECT scheme_key, COALESCE(key_type, 'singleton') as key_type, COALESCE(source, 'manual') as source FROM semo.kb_type_schema WHERE type_key = $1", [entityType]);
777
777
  const schemas = schemaResult.rows;
778
778
  if (schemas.length > 0) {
779
779
  const match = schemas.find(s => s.scheme_key === key);
@@ -784,6 +784,16 @@ async function kbUpsert(pool, entry) {
784
784
  error: `키 '${key}'은(는) '${entityType}' 타입의 스키마에 허용되지 않습니다. 허용 키: [${allowedKeys.join(", ")}]`,
785
785
  };
786
786
  }
787
+ // Projection key 차단: pm-pipeline만 쓰기 허용
788
+ if (match.source === "projection") {
789
+ const createdBy = entry.created_by ?? "";
790
+ if (!createdBy.startsWith("pm-") && !createdBy.startsWith("gfp-")) {
791
+ return {
792
+ success: false,
793
+ error: `키 '${key}'은(는) projection 키입니다 (PM 파이프라인에서 자동 동기화). 직접 쓰기가 차단됩니다.`,
794
+ };
795
+ }
796
+ }
787
797
  if (match.key_type === "singleton" && subKey !== "") {
788
798
  return { success: false, error: `키 '${key}'은(는) singleton이므로 sub_key가 비어야 합니다.` };
789
799
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Service Migration — 기존 운영 서비스를 service_projects 테이블에 이식
3
+ *
4
+ * KB 온톨로지에 service 타입으로 등록된 도메인 중 service_projects에 미등록된 것을
5
+ * 자동으로 이식. KB 엔트리 전수 조사(audit) 후 매핑/비매핑 분류.
6
+ */
7
+ import { Pool } from "pg";
8
+ export interface AuditEntry {
9
+ key: string;
10
+ subKey: string;
11
+ target: string;
12
+ value: string;
13
+ }
14
+ export interface AuditResult {
15
+ domain: string;
16
+ ontologyDescription: string | null;
17
+ ontologyCreatedAt: string | null;
18
+ mapped: {
19
+ key: string;
20
+ target: string;
21
+ value: string;
22
+ }[];
23
+ metadata: {
24
+ key: string;
25
+ value: string;
26
+ }[];
27
+ kbOnly: {
28
+ key: string;
29
+ count: number;
30
+ }[];
31
+ projection: {
32
+ key: string;
33
+ count: number;
34
+ }[];
35
+ warnings: {
36
+ key: string;
37
+ reason: string;
38
+ suggestion: string;
39
+ }[];
40
+ missingRequired: string[];
41
+ }
42
+ export interface MigrationRow {
43
+ project_name: string;
44
+ owner_name: string;
45
+ service_domain: string;
46
+ status: string;
47
+ lifecycle: string;
48
+ launched_at: string | null;
49
+ metadata: Record<string, unknown>;
50
+ }
51
+ export interface MigrationResult {
52
+ domain: string;
53
+ action: "created" | "skipped" | "error";
54
+ projectId?: string;
55
+ audit: AuditResult;
56
+ error?: string;
57
+ }
58
+ export declare function getUnregisteredServices(pool: Pool): Promise<Array<{
59
+ domain: string;
60
+ description: string | null;
61
+ created_at: string | null;
62
+ }>>;
63
+ export declare function getRegisteredServices(pool: Pool): Promise<string[]>;
64
+ export declare function auditServiceKBEntries(pool: Pool, domain: string, ontologyDescription: string | null, ontologyCreatedAt: string | null): Promise<AuditResult>;
65
+ export declare function buildServiceProjectRow(audit: AuditResult): MigrationRow;
66
+ export declare function insertServiceProject(pool: Pool, row: MigrationRow): Promise<string>;
67
+ export declare function writePmSummaryToKB(pool: Pool, domain: string, projectId: string, row: MigrationRow, kbEntryCount: number): Promise<void>;
68
+ export declare function printAuditReport(results: MigrationResult[]): void;
69
+ export declare function printDryRunReport(results: MigrationResult[]): void;
@@ -0,0 +1,282 @@
1
+ "use strict";
2
+ /**
3
+ * Service Migration — 기존 운영 서비스를 service_projects 테이블에 이식
4
+ *
5
+ * KB 온톨로지에 service 타입으로 등록된 도메인 중 service_projects에 미등록된 것을
6
+ * 자동으로 이식. KB 엔트리 전수 조사(audit) 후 매핑/비매핑 분류.
7
+ */
8
+ var __importDefault = (this && this.__importDefault) || function (mod) {
9
+ return (mod && mod.__esModule) ? mod : { "default": mod };
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.getUnregisteredServices = getUnregisteredServices;
13
+ exports.getRegisteredServices = getRegisteredServices;
14
+ exports.auditServiceKBEntries = auditServiceKBEntries;
15
+ exports.buildServiceProjectRow = buildServiceProjectRow;
16
+ exports.insertServiceProject = insertServiceProject;
17
+ exports.writePmSummaryToKB = writePmSummaryToKB;
18
+ exports.printAuditReport = printAuditReport;
19
+ exports.printDryRunReport = printDryRunReport;
20
+ const chalk_1 = __importDefault(require("chalk"));
21
+ const kb_1 = require("./kb");
22
+ // ── KB Status Mapping ──
23
+ const STATUS_MAP = {
24
+ active: { lifecycle: "ops", status: "active" },
25
+ hold: { lifecycle: "ops", status: "paused" },
26
+ maintenance: { lifecycle: "ops", status: "active" },
27
+ completed: { lifecycle: "sunset", status: "completed" },
28
+ deprecated: { lifecycle: "sunset", status: "completed" },
29
+ };
30
+ // Keys that map directly to service_projects columns
31
+ const COLUMN_KEYS = {
32
+ "base-information": "project_name",
33
+ po: "owner_name",
34
+ status: "status+lifecycle",
35
+ };
36
+ // Keys that go into metadata JSONB
37
+ const METADATA_KEYS = new Set([
38
+ "repo",
39
+ "slack-channel",
40
+ "tech-stack",
41
+ "service-url",
42
+ "bm",
43
+ ]);
44
+ // Keys that stay in KB only (normal)
45
+ const KB_ONLY_KEYS = new Set([
46
+ "current-situation",
47
+ "kpi",
48
+ "milestone",
49
+ "decision",
50
+ "process",
51
+ "infra",
52
+ ]);
53
+ // Projection keys (auto-managed by pm-pipeline)
54
+ const PROJECTION_KEYS = new Set([
55
+ "spec",
56
+ "pm-status",
57
+ "gfp-status",
58
+ "gfp-id",
59
+ "infra-status",
60
+ "pm-summary",
61
+ ]);
62
+ // ── Core Functions ──
63
+ async function getUnregisteredServices(pool) {
64
+ const result = await pool.query(`SELECT o.domain, o.description, o.created_at::text
65
+ FROM semo.ontology o
66
+ WHERE o.entity_type = 'service'
67
+ AND o.domain NOT LIKE 'e2e-%'
68
+ AND NOT EXISTS (
69
+ SELECT 1 FROM semo.service_projects sp WHERE sp.service_domain = o.domain
70
+ )
71
+ ORDER BY o.domain`);
72
+ return result.rows;
73
+ }
74
+ async function getRegisteredServices(pool) {
75
+ const result = await pool.query(`SELECT service_domain FROM semo.service_projects WHERE service_domain IS NOT NULL`);
76
+ return result.rows.map((r) => r.service_domain);
77
+ }
78
+ async function auditServiceKBEntries(pool, domain, ontologyDescription, ontologyCreatedAt) {
79
+ // Fetch ALL KB entries for this domain
80
+ const entriesResult = await pool.query(`SELECT key, sub_key, content
81
+ FROM semo.knowledge_base
82
+ WHERE domain = $1
83
+ ORDER BY key, sub_key`, [domain]);
84
+ const entries = entriesResult.rows.map((e) => ({ ...e, content: (e.content ?? "").substring(0, 500) }));
85
+ // Fetch allowed keys from type schema
86
+ const schemaResult = await pool.query(`SELECT scheme_key, COALESCE(source, 'manual') as source
87
+ FROM semo.kb_type_schema WHERE type_key = 'service'`);
88
+ const allowedKeys = new Set(schemaResult.rows.map((r) => r.scheme_key));
89
+ const audit = {
90
+ domain,
91
+ ontologyDescription,
92
+ ontologyCreatedAt,
93
+ mapped: [],
94
+ metadata: [],
95
+ kbOnly: [],
96
+ projection: [],
97
+ warnings: [],
98
+ missingRequired: [],
99
+ };
100
+ // Group entries by key
101
+ const byKey = new Map();
102
+ for (const e of entries) {
103
+ const arr = byKey.get(e.key) || [];
104
+ arr.push({ sub_key: e.sub_key, content: e.content });
105
+ byKey.set(e.key, arr);
106
+ }
107
+ for (const [key, items] of byKey) {
108
+ if (COLUMN_KEYS[key]) {
109
+ // Maps to service_projects column
110
+ const firstContent = items[0]?.content ?? "";
111
+ audit.mapped.push({
112
+ key,
113
+ target: COLUMN_KEYS[key],
114
+ value: firstContent.substring(0, 200),
115
+ });
116
+ }
117
+ else if (METADATA_KEYS.has(key)) {
118
+ const firstContent = items[0]?.content ?? "";
119
+ audit.metadata.push({ key, value: firstContent.substring(0, 200) });
120
+ }
121
+ else if (KB_ONLY_KEYS.has(key)) {
122
+ audit.kbOnly.push({ key, count: items.length });
123
+ }
124
+ else if (PROJECTION_KEYS.has(key)) {
125
+ audit.projection.push({ key, count: items.length });
126
+ }
127
+ else if (!allowedKeys.has(key)) {
128
+ // Unknown key — not in type schema
129
+ audit.warnings.push({
130
+ key,
131
+ reason: `'${key}'은(는) service 타입 스키마에 등록되지 않은 키`,
132
+ suggestion: `semo kb ontology --action add-key --type service --key ${key} --key-type collection`,
133
+ });
134
+ }
135
+ else {
136
+ // In schema but not in our classification — treat as KB-only
137
+ audit.kbOnly.push({ key, count: items.length });
138
+ }
139
+ }
140
+ // Check required keys
141
+ for (const reqKey of ["base-information", "po", "status"]) {
142
+ if (!byKey.has(reqKey)) {
143
+ audit.missingRequired.push(reqKey);
144
+ }
145
+ }
146
+ return audit;
147
+ }
148
+ function buildServiceProjectRow(audit) {
149
+ // Extract project_name from base-information
150
+ const baseInfo = audit.mapped.find((m) => m.key === "base-information");
151
+ let projectName = audit.domain; // fallback
152
+ if (baseInfo) {
153
+ // Take first line or first sentence as project name
154
+ const firstLine = baseInfo.value.split("\n")[0].trim();
155
+ // Try to extract name pattern like "AXOracle — 오라클 정보 플랫폼"
156
+ const match = firstLine.match(/^([^—–\-]+)/);
157
+ projectName = match ? match[1].trim() : firstLine.substring(0, 100);
158
+ }
159
+ else if (audit.ontologyDescription) {
160
+ const match = audit.ontologyDescription.match(/^([^—–\-]+)/);
161
+ projectName = match ? match[1].trim() : audit.ontologyDescription.substring(0, 100);
162
+ }
163
+ // Extract owner from po
164
+ const poEntry = audit.mapped.find((m) => m.key === "po");
165
+ const ownerName = poEntry ? poEntry.value.split("\n")[0].trim().substring(0, 100) : "TBD";
166
+ // Map status
167
+ const statusEntry = audit.mapped.find((m) => m.key === "status");
168
+ const rawStatus = statusEntry
169
+ ? statusEntry.value.split("\n")[0].trim().toLowerCase()
170
+ : "active";
171
+ const mapping = STATUS_MAP[rawStatus] ?? STATUS_MAP["active"];
172
+ // Build metadata from repo, slack-channel, tech-stack, etc.
173
+ const meta = { source: "kb-migration" };
174
+ for (const m of audit.metadata) {
175
+ meta[m.key] = m.value;
176
+ }
177
+ return {
178
+ project_name: projectName,
179
+ owner_name: ownerName,
180
+ service_domain: audit.domain,
181
+ status: mapping.status,
182
+ lifecycle: mapping.lifecycle,
183
+ launched_at: audit.ontologyCreatedAt,
184
+ metadata: meta,
185
+ };
186
+ }
187
+ async function insertServiceProject(pool, row) {
188
+ const result = await pool.query(`INSERT INTO semo.service_projects
189
+ (project_name, owner_name, service_domain, status, lifecycle, launched_at, metadata)
190
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
191
+ RETURNING gfp_id`, [
192
+ row.project_name,
193
+ row.owner_name,
194
+ row.service_domain,
195
+ row.status,
196
+ row.lifecycle,
197
+ row.launched_at,
198
+ JSON.stringify(row.metadata),
199
+ ]);
200
+ return result.rows[0].gfp_id;
201
+ }
202
+ async function writePmSummaryToKB(pool, domain, projectId, row, kbEntryCount) {
203
+ const content = [
204
+ `project_id: ${projectId}`,
205
+ `project_name: ${row.project_name}`,
206
+ `lifecycle: ${row.lifecycle}`,
207
+ `status: ${row.status}`,
208
+ `source: kb-migration`,
209
+ `registered_at: ${new Date().toISOString()}`,
210
+ `kb_entries: ${kbEntryCount}`,
211
+ `owner: ${row.owner_name}`,
212
+ ].join("\n");
213
+ await (0, kb_1.kbUpsert)(pool, {
214
+ domain,
215
+ key: "pm-summary",
216
+ content,
217
+ created_by: "pm-pipeline",
218
+ });
219
+ }
220
+ // ── Reporting ──
221
+ function printAuditReport(results) {
222
+ const created = results.filter((r) => r.action === "created");
223
+ const skipped = results.filter((r) => r.action === "skipped");
224
+ const errors = results.filter((r) => r.action === "error");
225
+ console.log(chalk_1.default.cyan.bold(`\n📊 서비스 이식 결과\n`));
226
+ console.log(` ${chalk_1.default.green(`✓ 등록: ${created.length}`)} ${chalk_1.default.yellow(`⊘ 건너뜀: ${skipped.length}`)} ${chalk_1.default.red(`✗ 오류: ${errors.length}`)}`);
227
+ for (const r of results) {
228
+ const icon = r.action === "created" ? chalk_1.default.green("✓") :
229
+ r.action === "skipped" ? chalk_1.default.yellow("⊘") :
230
+ chalk_1.default.red("✗");
231
+ console.log(`\n ${icon} ${chalk_1.default.bold(r.domain)}`);
232
+ if (r.action === "created") {
233
+ console.log(chalk_1.default.gray(` project_id: ${r.projectId}\n` +
234
+ ` mapped: ${r.audit.mapped.map((m) => m.key).join(", ") || "(없음)"}\n` +
235
+ ` metadata: ${r.audit.metadata.map((m) => m.key).join(", ") || "(없음)"}\n` +
236
+ ` kb-only: ${r.audit.kbOnly.map((k) => `${k.key}(${k.count})`).join(", ") || "(없음)"}`));
237
+ }
238
+ if (r.action === "error") {
239
+ console.log(chalk_1.default.red(` ${r.error}`));
240
+ }
241
+ // Warnings
242
+ if (r.audit.warnings.length > 0) {
243
+ console.log(chalk_1.default.yellow(" ⚠ 비표준 키:"));
244
+ for (const w of r.audit.warnings) {
245
+ console.log(chalk_1.default.yellow(` ${w.key}: ${w.reason}`));
246
+ console.log(chalk_1.default.gray(` → ${w.suggestion}`));
247
+ }
248
+ }
249
+ // Missing required
250
+ if (r.audit.missingRequired.length > 0) {
251
+ console.log(chalk_1.default.yellow(` ⚠ 필수 키 누락: ${r.audit.missingRequired.join(", ")}`));
252
+ }
253
+ }
254
+ console.log();
255
+ }
256
+ function printDryRunReport(results) {
257
+ console.log(chalk_1.default.cyan.bold(`\n🔍 서비스 이식 미리보기 (dry-run)\n`));
258
+ console.log(chalk_1.default.gray(` DB 변경 없이 audit 결과만 표시합니다.\n`));
259
+ for (const r of results) {
260
+ const row = buildServiceProjectRow(r.audit);
261
+ console.log(chalk_1.default.bold(` ${r.domain}`));
262
+ console.log(chalk_1.default.gray(` → project_name: ${row.project_name}\n` +
263
+ ` → owner_name: ${row.owner_name}\n` +
264
+ ` → lifecycle: ${row.lifecycle}, status: ${row.status}\n` +
265
+ ` → launched_at: ${row.launched_at ?? 'NOW()'}\n` +
266
+ ` → metadata keys: ${Object.keys(row.metadata).join(", ")}`));
267
+ if (r.audit.kbOnly.length > 0) {
268
+ console.log(chalk_1.default.gray(` → kb-only: ${r.audit.kbOnly.map((k) => `${k.key}(${k.count})`).join(", ")}`));
269
+ }
270
+ if (r.audit.warnings.length > 0) {
271
+ for (const w of r.audit.warnings) {
272
+ console.log(chalk_1.default.yellow(` ⚠ ${w.key}: ${w.reason}`));
273
+ console.log(chalk_1.default.gray(` → ${w.suggestion}`));
274
+ }
275
+ }
276
+ if (r.audit.missingRequired.length > 0) {
277
+ console.log(chalk_1.default.yellow(` ⚠ 필수 키 누락: ${r.audit.missingRequired.join(", ")}`));
278
+ }
279
+ console.log();
280
+ }
281
+ console.log(chalk_1.default.cyan(` 총 ${results.length}개 서비스 이식 대상\n`));
282
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-semicolon/semo-cli",
3
- "version": "4.12.0",
3
+ "version": "4.13.0",
4
4
  "description": "SEMO CLI - AI Agent Orchestration Framework Installer",
5
5
  "main": "dist/index.js",
6
6
  "bin": {