@clazic/urban 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clazic/urban",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "도시계획연구 보고서 자동 수집·지식베이스 데몬",
5
5
  "type": "module",
6
6
  "engines": {
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "license": "MIT",
19
19
  "dependencies": {
20
- "@clazic/kordoc": "^2.4.18",
20
+ "@clazic/kordoc": "^2.4.19",
21
21
  "@modelcontextprotocol/sdk": "^1.12.0",
22
22
  "@rhwp/core": "^0.7.2",
23
23
  "better-sqlite3": "^12.9.0",
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Phase H-7: e2e-mcp.js
4
+ *
5
+ * Urban KB MCP 도구 E2E 검증 스크립트
6
+ * 실제 DB와 Wiki 파일을 사용해 8개 도구를 순차 호출하고
7
+ * 응답 스키마를 검증한다.
8
+ *
9
+ * 사용법:
10
+ * node scripts/e2e-mcp.js
11
+ *
12
+ * Exit code: 0 = 전체 PASS, 1 = 1건 이상 FAIL
13
+ */
14
+
15
+ import { join } from 'node:path';
16
+ import { homedir } from 'node:os';
17
+ import { existsSync, readdirSync } from 'node:fs';
18
+ import { createRequire } from 'node:module';
19
+
20
+ const require = createRequire(import.meta.url);
21
+
22
+ const URBAN_HOME = process.env.URBAN_HOME ?? join(homedir(), '.urban');
23
+ const WIKI_DIR = join(URBAN_HOME, 'wiki');
24
+ const MAIN_DB_PATH = join(URBAN_HOME, 'urban.db');
25
+ const GRAPH_DB_PATH = join(URBAN_HOME, 'graph.db');
26
+
27
+ // DB 열기
28
+ let mainDb = null;
29
+ let graphDb = null;
30
+
31
+ try {
32
+ const Database = require('better-sqlite3');
33
+ if (existsSync(MAIN_DB_PATH)) mainDb = new Database(MAIN_DB_PATH, { readonly: true });
34
+ if (existsSync(GRAPH_DB_PATH)) graphDb = new Database(GRAPH_DB_PATH, { readonly: true });
35
+ } catch (e) {
36
+ console.error('❌ DB 초기화 실패:', e.message);
37
+ process.exit(1);
38
+ }
39
+
40
+ if (!mainDb || !graphDb) {
41
+ console.error('❌ urban.db 또는 graph.db 없음');
42
+ process.exit(1);
43
+ }
44
+
45
+ // MCP 핸들러 임포트
46
+ const {
47
+ handleSearch,
48
+ handleNeighbors,
49
+ handlePath,
50
+ handleListWiki,
51
+ handleReadWiki,
52
+ handleSearchWiki,
53
+ handleListReports,
54
+ handleReadReport,
55
+ } = await import('../src/mcp/handlers.js');
56
+
57
+ // 테스트 유틸
58
+ let passCount = 0;
59
+ let failCount = 0;
60
+
61
+ function pass(name, detail = '') {
62
+ passCount++;
63
+ console.log(` ✅ PASS ${name}${detail ? ` — ${detail}` : ''}`);
64
+ }
65
+
66
+ function fail(name, reason) {
67
+ failCount++;
68
+ console.error(` ❌ FAIL ${name} — ${reason}`);
69
+ }
70
+
71
+ function checkShape(result, keys, name) {
72
+ for (const key of keys) {
73
+ if (!(key in result)) {
74
+ fail(name, `응답에 '${key}' 키 없음`);
75
+ return false;
76
+ }
77
+ }
78
+ return true;
79
+ }
80
+
81
+ // ── 동적 테스트 데이터 수집 ──────────────────────────────────────────────
82
+
83
+ const sampleTopic = graphDb.prepare(
84
+ `SELECT id FROM graph_nodes WHERE type='topic' ORDER BY degree DESC LIMIT 1`
85
+ ).get();
86
+ const sampleTopicId = sampleTopic?.id ?? 'topics:도시계획';
87
+
88
+ const connectedReport = graphDb.prepare(
89
+ `SELECT source FROM graph_edges WHERE target=? AND relation='about' LIMIT 1`
90
+ ).get(sampleTopicId);
91
+ const sampleReportId = connectedReport?.source ?? null;
92
+
93
+ // md_path가 실제 존재하는 INDEXED 보고서 탐색
94
+ let sampleHash = null;
95
+ {
96
+ const rows = mainDb.prepare(
97
+ `SELECT hash, md_path FROM reports WHERE status='INDEXED' AND md_path IS NOT NULL LIMIT 50`
98
+ ).all();
99
+ for (const row of rows) {
100
+ if (existsSync(row.md_path)) {
101
+ sampleHash = row.hash;
102
+ break;
103
+ }
104
+ }
105
+ }
106
+
107
+ const wikiTopicsDir = join(WIKI_DIR, 'topics');
108
+ let sampleWikiSlug = null;
109
+ if (existsSync(wikiTopicsDir)) {
110
+ const mdFiles = readdirSync(wikiTopicsDir).filter(f => f.endsWith('.md'));
111
+ if (mdFiles.length > 0) sampleWikiSlug = `topics/${mdFiles[0].replace(/\.md$/, '')}`;
112
+ }
113
+
114
+ // ── 테스트 실행 ────────────────────────────────────────────────────────────
115
+
116
+ console.log('\n🔍 Urban KB MCP E2E 검증\n');
117
+ console.log(` DB: ${MAIN_DB_PATH}`);
118
+ console.log(` GraphDB: ${GRAPH_DB_PATH}`);
119
+ console.log(` Wiki: ${WIKI_DIR}`);
120
+ console.log(` 샘플 topic: ${sampleTopicId}`);
121
+ console.log(` 샘플 report: ${sampleHash ?? '(없음)'}`);
122
+ console.log('');
123
+
124
+ // 1. kb.search
125
+ {
126
+ const r = await handleSearch({ query: '도시', limit: 5, graphDb });
127
+ if (checkShape(r, ['nodes', 'total'], 'kb.search')) {
128
+ if (!Array.isArray(r.nodes)) fail('kb.search', 'nodes가 배열이 아님');
129
+ else pass('kb.search', `nodes=${r.nodes.length}건`);
130
+ }
131
+ }
132
+
133
+ // 2. kb.neighbors
134
+ {
135
+ const r = await handleNeighbors({ nodeId: sampleTopicId, depth: 1, graphDb });
136
+ if (checkShape(r, ['nodes', 'edges'], 'kb.neighbors')) {
137
+ if (!Array.isArray(r.nodes) || !Array.isArray(r.edges))
138
+ fail('kb.neighbors', 'nodes/edges가 배열이 아님');
139
+ else pass('kb.neighbors', `nodes=${r.nodes.length}, edges=${r.edges.length}`);
140
+ }
141
+ }
142
+
143
+ // 3. kb.path
144
+ {
145
+ const from = sampleReportId ?? 'report:nonexistent';
146
+ const r = await handlePath({ from, to: sampleTopicId, graphDb });
147
+ if (checkShape(r, ['path', 'edges'], 'kb.path')) {
148
+ const found = r.path !== null;
149
+ pass('kb.path', `found=${found}, path=${r.path?.length ?? 0}단계, edges=${r.edges?.length ?? 0}`);
150
+ }
151
+ }
152
+
153
+ // 4. kb.list_wiki
154
+ {
155
+ const r = await handleListWiki({ wikiDir: WIKI_DIR });
156
+ if (checkShape(r, ['pages', 'total'], 'kb.list_wiki')) {
157
+ if (!Array.isArray(r.pages)) fail('kb.list_wiki', 'pages가 배열이 아님');
158
+ else pass('kb.list_wiki', `${r.pages.length}건`);
159
+ }
160
+ }
161
+
162
+ // 5. kb.read_wiki
163
+ if (sampleWikiSlug) {
164
+ const r = await handleReadWiki({ slug: sampleWikiSlug, wikiDir: WIKI_DIR });
165
+ if ('error' in r) fail('kb.read_wiki', `error=${r.error}`);
166
+ else if (checkShape(r, ['slug', 'content'], 'kb.read_wiki')) {
167
+ if (typeof r.content !== 'string') fail('kb.read_wiki', 'content가 문자열이 아님');
168
+ else pass('kb.read_wiki', `slug=${r.slug}, ${r.content.length}자`);
169
+ }
170
+ } else {
171
+ fail('kb.read_wiki', 'Wiki topics 파일 없음');
172
+ }
173
+
174
+ // 6. kb.search_wiki
175
+ {
176
+ const r = await handleSearchWiki({ query: '도시', limit: 5, mainDb });
177
+ if (checkShape(r, ['results', 'total'], 'kb.search_wiki')) {
178
+ if (!Array.isArray(r.results)) fail('kb.search_wiki', 'results가 배열이 아님');
179
+ else pass('kb.search_wiki', `${r.results.length}건`);
180
+ }
181
+ }
182
+
183
+ // 7. kb.list_reports
184
+ {
185
+ const r = await handleListReports({ limit: 5, offset: 0, mainDb });
186
+ if (checkShape(r, ['items', 'total'], 'kb.list_reports')) {
187
+ if (!Array.isArray(r.items)) fail('kb.list_reports', 'items가 배열이 아님');
188
+ else pass('kb.list_reports', `${r.items.length}건 (total=${r.total})`);
189
+ }
190
+ }
191
+
192
+ // 8. kb.read_report
193
+ if (sampleHash) {
194
+ const r = await handleReadReport({ docid: sampleHash, mainDb, wikiDir: WIKI_DIR });
195
+ if ('error' in r) fail('kb.read_report', `error=${r.error}`);
196
+ else if (checkShape(r, ['hash', 'title'], 'kb.read_report')) {
197
+ pass('kb.read_report', `hash=${r.hash?.slice(0,8)}, "${String(r.title).slice(0,25)}..."`);
198
+ }
199
+ } else {
200
+ fail('kb.read_report', 'INDEXED 보고서 없음');
201
+ }
202
+
203
+ // ── 결과 요약 ─────────────────────────────────────────────────────────────
204
+
205
+ console.log(`\n${'─'.repeat(50)}`);
206
+ console.log(`결과: ${passCount}/${passCount + failCount} PASS`);
207
+
208
+ if (failCount === 0) {
209
+ console.log('✅ 모든 MCP 도구 E2E 검증 통과\n');
210
+ process.exit(0);
211
+ } else {
212
+ console.error(`❌ ${failCount}건 FAIL\n`);
213
+ process.exit(1);
214
+ }
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ // postinstall: graphify 설치 → urban 자동 설정 → 데몬 등록 + 시작
3
+ // 모든 단계는 실패해도 설치 자체를 중단하지 않음 (exit 0 보장)
4
+ import { existsSync, mkdirSync, writeFileSync } from 'fs';
5
+ import { homedir } from 'os';
6
+ import { join, dirname } from 'path';
7
+ import { fileURLToPath } from 'url';
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+
11
+ try {
12
+ // ── 1. graphify 설치 (선택 기능, 실패 무시) ─────────────────────────────────
13
+ await import('./setup-graphify.js').catch(() => {});
14
+
15
+ // ── 2. urban 자동 설정 (기본값으로 .env 생성) ─────────────────────────────────
16
+ const URBAN_HOME = process.env.URBAN_HOME ?? join(homedir(), '.urban');
17
+ const URBAN_PORT = process.env.URBAN_PORT ?? '7777';
18
+
19
+ for (const dir of ['', 'logs', 'wiki', 'inbox', 'archive', 'backups']) {
20
+ mkdirSync(join(URBAN_HOME, dir), { recursive: true });
21
+ }
22
+
23
+ const envPath = join(URBAN_HOME, '.env');
24
+ if (!existsSync(envPath)) {
25
+ writeFileSync(envPath, [
26
+ `URBAN_HOME=${URBAN_HOME}`,
27
+ `URBAN_PORT=${URBAN_PORT}`,
28
+ '',
29
+ ].join('\n'), 'utf8');
30
+ console.log(`[urban] 설정 파일 생성: ${envPath}`);
31
+ } else {
32
+ console.log(`[urban] 설정 파일 이미 존재: ${envPath}`);
33
+ }
34
+
35
+ // ── 3. 데몬 등록 + 시작 ───────────────────────────────────────────────────────
36
+ try {
37
+ const { installDaemon, startDaemon } = await import('../src/install/daemon.js');
38
+ const installResult = await installDaemon();
39
+ if (installResult.ok) {
40
+ console.log(`[urban] 데몬 등록 완료: ${installResult.message}`);
41
+ const startResult = await startDaemon();
42
+ console.log(`[urban] 데몬 시작: ${startResult.message}`);
43
+ console.log(`\n[urban] 설치 완료 → http://localhost:${URBAN_PORT}\n`);
44
+ } else {
45
+ console.warn(`[urban] 데몬 자동 등록 실패 — 'urban install && urban start' 로 수동 시작`);
46
+ console.warn(` 원인: ${installResult.message}`);
47
+ }
48
+ } catch (err) {
49
+ console.warn(`[urban] 데몬 자동 시작 건너뜀 — 'urban install && urban start' 로 수동 시작`);
50
+ }
51
+ } catch (err) {
52
+ // postinstall 전체 실패해도 npm install은 성공으로 처리
53
+ console.warn(`[urban] postinstall 경고: ${err.message}`);
54
+ }
55
+
56
+ process.exit(0);
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ // postinstall: uv 설치 확인 → graphifyy 가상환경 구성 + 터미널용 symlink 생성
3
+ import { execSync, spawnSync } from 'child_process';
4
+ import { existsSync, mkdirSync, symlinkSync, unlinkSync, writeFileSync } from 'fs';
5
+ import { homedir } from 'os';
6
+ import { join } from 'path';
7
+
8
+ const isWin = process.platform === 'win32';
9
+ const PYTHON_VERSION = '3.12';
10
+ const uvEnvDir = join(homedir(), '.urban', '.venv');
11
+ const pythonBin = join(uvEnvDir, isWin ? 'Scripts/python.exe' : 'bin/python');
12
+ const graphifyBin = join(uvEnvDir, isWin ? 'Scripts/graphify.exe' : 'bin/graphify');
13
+ const localBin = join(homedir(), '.local', 'bin');
14
+ const symlinkTarget = join(localBin, isWin ? 'graphify.cmd' : 'graphify');
15
+
16
+ function findUv() {
17
+ try { execSync(isWin ? 'where uv' : 'which uv', { stdio: 'ignore' }); return 'uv'; } catch {}
18
+ const candidates = isWin
19
+ ? [join(homedir(), '.local', 'bin', 'uv.exe'), join(homedir(), '.cargo', 'bin', 'uv.exe')]
20
+ : [join(homedir(), '.local', 'bin', 'uv'), join(homedir(), '.cargo', 'bin', 'uv')];
21
+ return candidates.find(p => existsSync(p)) ?? null;
22
+ }
23
+
24
+ function installUv() {
25
+ console.log('[urban] uv 설치 중...');
26
+ if (isWin) {
27
+ execSync('powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"', { stdio: 'inherit' });
28
+ } else {
29
+ execSync('curl -LsSf https://astral.sh/uv/install.sh | sh', { stdio: 'inherit' });
30
+ }
31
+ return findUv();
32
+ }
33
+
34
+ // uv 자체 관리 Python 경로 반환 (없으면 자동 설치)
35
+ function ensureUvPython(uv) {
36
+ console.log(`[urban] uv Python ${PYTHON_VERSION} 준비 중...`);
37
+ spawnSync(uv, ['python', 'install', PYTHON_VERSION], { stdio: 'inherit', shell: isWin });
38
+ const result = spawnSync(uv, ['python', 'find', PYTHON_VERSION], { stdio: 'pipe', shell: isWin, encoding: 'utf8' });
39
+ const path = result.stdout?.trim();
40
+ if (!path) throw new Error(`uv python find ${PYTHON_VERSION} 실패`);
41
+ console.log(`[urban] uv Python 경로: ${path}`);
42
+ return path;
43
+ }
44
+
45
+ function run(cmd, args, opts = {}) {
46
+ const r = spawnSync(cmd, args, { stdio: 'inherit', shell: isWin, ...opts });
47
+ if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} 실패 (exit ${r.status})`);
48
+ }
49
+
50
+ // 터미널 어디서나 실행 가능하도록 symlink(Unix) 또는 .cmd 래퍼(Windows) 생성
51
+ function createSymlink() {
52
+ mkdirSync(localBin, { recursive: true });
53
+
54
+ if (isWin) {
55
+ const wrapper = `@echo off\n"${graphifyBin}" %*\n`;
56
+ writeFileSync(symlinkTarget, wrapper);
57
+ console.log('[urban] graphify 래퍼 생성 →', symlinkTarget);
58
+ } else {
59
+ try { unlinkSync(symlinkTarget); } catch {}
60
+ symlinkSync(graphifyBin, symlinkTarget);
61
+ console.log('[urban] graphify symlink 생성 →', symlinkTarget);
62
+ }
63
+
64
+ // ~/.local/bin 이 PATH에 없으면 안내
65
+ const pathDirs = (process.env.PATH ?? '').split(isWin ? ';' : ':');
66
+ if (!pathDirs.includes(localBin)) {
67
+ console.warn(`[urban] ⚠️ ${localBin} 이 PATH에 없습니다.`);
68
+ if (isWin) {
69
+ console.warn(` setx PATH "%PATH%;${localBin}"`);
70
+ } else {
71
+ const rc = process.env.SHELL?.includes('zsh') ? '~/.zshrc' : '~/.bashrc';
72
+ console.warn(` ${rc} 에 추가: export PATH="$HOME/.local/bin:$PATH"`);
73
+ }
74
+ }
75
+ }
76
+
77
+ if (process.env.URBAN_SKIP_GRAPHIFY === '1') {
78
+ console.log('[urban] URBAN_SKIP_GRAPHIFY=1 — graphify 설치 건너뜀');
79
+ process.exit(0);
80
+ }
81
+
82
+ try {
83
+ let uv = findUv();
84
+ if (!uv) uv = installUv();
85
+ if (!uv) throw new Error('uv 설치 실패 — https://docs.astral.sh/uv/getting-started/installation 참고');
86
+
87
+ if (existsSync(pythonBin)) {
88
+ console.log('[urban] graphify 환경이 이미 존재합니다:', uvEnvDir);
89
+ createSymlink();
90
+ process.exit(0);
91
+ }
92
+
93
+ // uv 자체 관리 Python 사용 (Homebrew·시스템 Python 무시)
94
+ const uvPython = ensureUvPython(uv);
95
+
96
+ console.log('[urban] graphify 가상환경 생성 중...');
97
+ run(uv, ['venv', uvEnvDir, '--python', uvPython]);
98
+
99
+ console.log('[urban] graphifyy 설치 중...');
100
+ run(uv, ['pip', 'install', '--python', pythonBin, 'graphifyy']);
101
+
102
+ console.log('[urban] graphify 설치 완료 →', uvEnvDir);
103
+ createSymlink();
104
+ } catch (err) {
105
+ console.warn('[urban] graphify 설치 실패 (선택 기능이므로 계속):', err.message);
106
+ process.exit(0);
107
+ }