careermate 0.1.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.
Files changed (124) hide show
  1. package/README.md +256 -0
  2. package/THIRD_PARTY_NOTICES.md +40 -0
  3. package/apps/mcp/src/index.ts +66 -0
  4. package/apps/web/DESIGN_GUIDE.md +105 -0
  5. package/apps/web/UI_CONTRACT.md +44 -0
  6. package/apps/web/public/app.js +118 -0
  7. package/apps/web/public/fonts/PretendardVariable.woff2 +0 -0
  8. package/apps/web/public/index.html +41 -0
  9. package/apps/web/public/lib.js +282 -0
  10. package/apps/web/public/pages/applications.js +98 -0
  11. package/apps/web/public/pages/documents.js +446 -0
  12. package/apps/web/public/pages/home.js +263 -0
  13. package/apps/web/public/pages/interview.js +230 -0
  14. package/apps/web/public/pages/jobs.js +494 -0
  15. package/apps/web/public/pages/profile.js +576 -0
  16. package/apps/web/public/pages/settings.js +233 -0
  17. package/apps/web/public/styles.css +426 -0
  18. package/apps/web/src/exports.ts +68 -0
  19. package/apps/web/src/http.ts +180 -0
  20. package/apps/web/src/index.ts +49 -0
  21. package/apps/web/src/info.ts +50 -0
  22. package/apps/web/src/routes.ts +350 -0
  23. package/apps/web/src/security.ts +102 -0
  24. package/apps/web/src/server.ts +141 -0
  25. package/apps/web/src/settings.ts +88 -0
  26. package/bin/careermate.mjs +74 -0
  27. package/dist/careermate.mcpb +0 -0
  28. package/dist/install-page/index.html +474 -0
  29. package/dist/install-page/style.css +391 -0
  30. package/dist/install-page/vercel.json +20 -0
  31. package/dist/mcp-smoke.err +3 -0
  32. package/dist/mcp.mjs +23704 -0
  33. package/dist/mcpb-stage/README.md +219 -0
  34. package/dist/mcpb-stage/dist/install-page/index.html +434 -0
  35. package/dist/mcpb-stage/dist/install-page/style.css +407 -0
  36. package/dist/mcpb-stage/dist/install-page/vercel.json +20 -0
  37. package/dist/mcpb-stage/dist/mcp.mjs +23704 -0
  38. package/dist/mcpb-stage/dist/public/app.js +118 -0
  39. package/dist/mcpb-stage/dist/public/fonts/PretendardVariable.woff2 +0 -0
  40. package/dist/mcpb-stage/dist/public/index.html +41 -0
  41. package/dist/mcpb-stage/dist/public/lib.js +282 -0
  42. package/dist/mcpb-stage/dist/public/pages/applications.js +98 -0
  43. package/dist/mcpb-stage/dist/public/pages/documents.js +446 -0
  44. package/dist/mcpb-stage/dist/public/pages/home.js +263 -0
  45. package/dist/mcpb-stage/dist/public/pages/interview.js +230 -0
  46. package/dist/mcpb-stage/dist/public/pages/jobs.js +494 -0
  47. package/dist/mcpb-stage/dist/public/pages/profile.js +576 -0
  48. package/dist/mcpb-stage/dist/public/pages/settings.js +233 -0
  49. package/dist/mcpb-stage/dist/public/styles.css +420 -0
  50. package/dist/mcpb-stage/dist/web.mjs +7240 -0
  51. package/dist/mcpb-stage/manifest.json +40 -0
  52. package/dist/public/app.js +118 -0
  53. package/dist/public/fonts/PretendardVariable.woff2 +0 -0
  54. package/dist/public/index.html +41 -0
  55. package/dist/public/lib.js +282 -0
  56. package/dist/public/pages/applications.js +98 -0
  57. package/dist/public/pages/documents.js +446 -0
  58. package/dist/public/pages/home.js +263 -0
  59. package/dist/public/pages/interview.js +230 -0
  60. package/dist/public/pages/jobs.js +494 -0
  61. package/dist/public/pages/profile.js +576 -0
  62. package/dist/public/pages/settings.js +233 -0
  63. package/dist/public/styles.css +426 -0
  64. package/dist/web.mjs +7240 -0
  65. package/docs/ARCHITECTURE.md +208 -0
  66. package/docs/CHANGES_V1.md +103 -0
  67. package/docs/DATA_MODEL.md +460 -0
  68. package/docs/DECISIONS.md +277 -0
  69. package/docs/DEMO.md +242 -0
  70. package/docs/INSTALL.md +148 -0
  71. package/docs/INSTALL_AND_USAGE.md +99 -0
  72. package/docs/MCP_TOOLS.md +233 -0
  73. package/docs/ROADMAP.md +134 -0
  74. package/docs/START_WORKFLOW.md +125 -0
  75. package/docs/SUPPORTED_AI_APPS.md +60 -0
  76. package/docs/TODO.md +57 -0
  77. package/docs/UX_NOTES.md +247 -0
  78. package/docs/WORKFLOWS.md +200 -0
  79. package/install-page/index.html +474 -0
  80. package/install-page/style.css +391 -0
  81. package/install-page/vercel.json +20 -0
  82. package/package.json +68 -0
  83. package/packages/core/src/context.ts +74 -0
  84. package/packages/core/src/index.ts +8 -0
  85. package/packages/core/src/onboarding.ts +81 -0
  86. package/packages/core/src/services.ts +146 -0
  87. package/packages/core/src/summary.ts +104 -0
  88. package/packages/db/src/connection.ts +46 -0
  89. package/packages/db/src/index.ts +22 -0
  90. package/packages/db/src/paths.ts +41 -0
  91. package/packages/db/src/repositories.ts +828 -0
  92. package/packages/db/src/runtime.ts +58 -0
  93. package/packages/db/src/schema.ts +189 -0
  94. package/packages/exporters/src/html.ts +113 -0
  95. package/packages/exporters/src/index.ts +364 -0
  96. package/packages/exporters/src/markdown.ts +178 -0
  97. package/packages/mcp-tools/src/bridge.ts +83 -0
  98. package/packages/mcp-tools/src/index.ts +8 -0
  99. package/packages/mcp-tools/src/result.ts +49 -0
  100. package/packages/mcp-tools/src/tools.ts +455 -0
  101. package/packages/parsers/src/html.ts +86 -0
  102. package/packages/parsers/src/index.ts +228 -0
  103. package/packages/parsers/src/keywords.ts +151 -0
  104. package/packages/prompts/src/humanize.ts +59 -0
  105. package/packages/prompts/src/index.ts +82 -0
  106. package/packages/prompts/src/install.ts +43 -0
  107. package/packages/prompts/src/onboarding.ts +35 -0
  108. package/packages/prompts/src/system.ts +53 -0
  109. package/packages/shared/src/enums.ts +103 -0
  110. package/packages/shared/src/index.ts +18 -0
  111. package/packages/shared/src/schemas.ts +398 -0
  112. package/packages/workflows/src/definitions.ts +107 -0
  113. package/packages/workflows/src/index.ts +39 -0
  114. package/scripts/build-dist.mjs +62 -0
  115. package/scripts/build-mcpb.mjs +70 -0
  116. package/scripts/doctor.ts +81 -0
  117. package/scripts/init.ts +342 -0
  118. package/scripts/mcp-probe.ts +55 -0
  119. package/scripts/migrate.ts +6 -0
  120. package/scripts/run.mjs +33 -0
  121. package/scripts/seed.ts +129 -0
  122. package/scripts/test.ts +117 -0
  123. package/scripts/ui-smoke.ts +73 -0
  124. package/tsconfig.json +29 -0
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/build-dist.mjs — 배포용 플레인 JS 번들 빌드 (esbuild).
4
+ *
5
+ * 개발은 그대로 tsx 무빌드로 돌리고, 배포(.mcpb / npm publish / 추후 독립 .exe)에서는
6
+ * tsx·tsconfig 경로별칭 런타임 의존성을 없애기 위해 두 진입점을 단일 ESM 파일로 번들한다.
7
+ *
8
+ * dist/mcp.mjs MCP stdio 서버 (Claude Desktop이 실행)
9
+ * dist/web.mjs 로컬 대시보드 서버
10
+ * dist/public/ 대시보드 정적 자산 (apps/web/public 복사)
11
+ * dist/install-page/ 설치 안내 페이지 복사
12
+ *
13
+ * `define: __BUNDLED__=true` 로 런타임에서 번들 레이아웃을 인지(정적경로/대시보드 기동 분기).
14
+ * node: 내장 모듈(node:sqlite 등)은 external로 두므로 실행에는 Node ≥22.5가 필요(런타임은 동봉 대상).
15
+ */
16
+ import esbuild from 'esbuild';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const DIST = path.join(ROOT, 'dist');
23
+
24
+ async function main() {
25
+ console.log('CareerMate dist 번들 빌드(esbuild)…');
26
+ fs.rmSync(path.join(DIST, 'mcp.mjs'), { force: true });
27
+ fs.rmSync(path.join(DIST, 'web.mjs'), { force: true });
28
+
29
+ await esbuild.build({
30
+ absWorkingDir: ROOT,
31
+ entryPoints: {
32
+ mcp: 'apps/mcp/src/index.ts',
33
+ web: 'apps/web/src/index.ts',
34
+ },
35
+ outdir: 'dist',
36
+ outExtension: { '.js': '.mjs' },
37
+ bundle: true,
38
+ platform: 'node',
39
+ format: 'esm',
40
+ target: 'node22',
41
+ tsconfig: 'tsconfig.json',
42
+ define: { __BUNDLED__: 'true' },
43
+ // ESM 출력에서 CJS 의존성이 require/__dirname을 써도 동작하도록 shim 주입.
44
+ banner: {
45
+ js: "import { createRequire as __cr } from 'module'; const require = __cr(import.meta.url);",
46
+ },
47
+ logLevel: 'info',
48
+ });
49
+
50
+ // 정적 자산 복사 (번들에는 코드만 들어가므로 별도 복사).
51
+ fs.rmSync(path.join(DIST, 'public'), { recursive: true, force: true });
52
+ fs.rmSync(path.join(DIST, 'install-page'), { recursive: true, force: true });
53
+ fs.cpSync(path.join(ROOT, 'apps', 'web', 'public'), path.join(DIST, 'public'), { recursive: true });
54
+ fs.cpSync(path.join(ROOT, 'install-page'), path.join(DIST, 'install-page'), { recursive: true });
55
+
56
+ console.log('✅ dist 빌드 완료: dist/mcp.mjs, dist/web.mjs, dist/public, dist/install-page');
57
+ }
58
+
59
+ main().catch((err) => {
60
+ console.error('dist 빌드 실패:', err instanceof Error ? err.message : err);
61
+ process.exitCode = 1;
62
+ });
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/build-mcpb.mjs — Claude Desktop용 .mcpb(원클릭 설치 번들) 빌드.
4
+ *
5
+ * .mcpb = manifest.json + 실행 코드를 담은 zip. Claude Desktop이 Node를 내장 제공한다.
6
+ * 이전엔 소스+tsx+node_modules를 통째로 담았지만, 이제는 esbuild로 만든 플레인 JS 번들
7
+ * (dist/)만 담는다 → tsx/경로별칭 런타임 의존성 제거, 용량 대폭 축소.
8
+ *
9
+ * 절차:
10
+ * 1) node scripts/build-dist.mjs 로 dist/ 번들 생성(없거나 강제 시)
11
+ * 2) dist/mcb-stage/ 에 manifest.json + dist/ 만 복사
12
+ * 3) 공식 패커(@anthropic-ai/mcpb)로 dist/careermate.mcpb 생성
13
+ *
14
+ * 사용: node scripts/build-mcpb.mjs
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { spawnSync } from 'node:child_process';
20
+
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const DIST = path.join(ROOT, 'dist');
23
+ const STAGE = path.join(DIST, 'mcpb-stage');
24
+ const OUT = path.join(DIST, 'careermate.mcpb');
25
+
26
+ function run(cmd, args, opts = {}) {
27
+ const r = spawnSync(cmd, args, { stdio: 'inherit', shell: process.platform === 'win32', ...opts });
28
+ return r.status === 0;
29
+ }
30
+
31
+ function main() {
32
+ console.log('CareerMate .mcpb 빌드를 시작합니다…\n');
33
+
34
+ console.log('1) dist 번들 빌드…');
35
+ if (!run('node', [path.join(ROOT, 'scripts', 'build-dist.mjs')], { cwd: ROOT })) {
36
+ console.error('\n✗ dist 번들 빌드 실패.');
37
+ process.exitCode = 1;
38
+ return;
39
+ }
40
+
41
+ console.log('\n2) 번들 스테이징(manifest + dist)…');
42
+ fs.rmSync(STAGE, { recursive: true, force: true });
43
+ fs.mkdirSync(STAGE, { recursive: true });
44
+ fs.copyFileSync(path.join(ROOT, 'manifest.json'), path.join(STAGE, 'manifest.json'));
45
+ // dist/ 에서 산출물만 복사 (스테이지/번들 산출물 자기참조 제외).
46
+ fs.mkdirSync(path.join(STAGE, 'dist'), { recursive: true });
47
+ for (const name of ['mcp.mjs', 'web.mjs', 'public', 'install-page']) {
48
+ const src = path.join(DIST, name);
49
+ if (fs.existsSync(src)) fs.cpSync(src, path.join(STAGE, 'dist', name), { recursive: true });
50
+ }
51
+ for (const extra of ['README.md']) {
52
+ const src = path.join(ROOT, extra);
53
+ if (fs.existsSync(src)) fs.copyFileSync(src, path.join(STAGE, extra));
54
+ }
55
+
56
+ console.log('\n3) .mcpb 패키징…');
57
+ fs.rmSync(OUT, { force: true });
58
+ if (!run('npx', ['-y', '@anthropic-ai/mcpb', 'pack', STAGE, OUT])) {
59
+ console.error('\n✗ @anthropic-ai/mcpb 패커 실행 실패.');
60
+ console.error(' 수동 패킹: cd dist/mcpb-stage && npx @anthropic-ai/mcpb pack . ../careermate.mcpb');
61
+ process.exitCode = 1;
62
+ return;
63
+ }
64
+
65
+ const size = (fs.statSync(OUT).size / (1024 * 1024)).toFixed(1);
66
+ console.log(`\n✅ 완성: ${OUT} (${size} MB)`);
67
+ console.log(' 배포: 이 파일을 GitHub Releases 등에 올리면 유저가 더블클릭만 하면 Claude Desktop에 설치됩니다.');
68
+ }
69
+
70
+ main();
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `npm run doctor` — a friendly health check non-technical users can run when
3
+ * something looks off. Verifies the runtime, the data directory, the database,
4
+ * and reports whether the dashboard/MCP are wired up. Reads only; never mutates
5
+ * user data beyond ensuring the schema exists.
6
+ */
7
+ import fs from 'node:fs';
8
+ import { getDataDir, getDbPath, getDb, readRuntimeInfo, isProcessAlive } from '@careermate/db';
9
+ import {
10
+ profileRepo, experienceRepo, projectRepo, skillRepo, documentRepo,
11
+ coverLetterRepo, jobRepo, applicationRepo, interviewRepo,
12
+ } from '@careermate/db';
13
+ import { TOOLS } from '@careermate/mcp-tools';
14
+
15
+ const ok = (s: string) => console.log(` ✅ ${s}`);
16
+ const warn = (s: string) => console.log(` ⚠️ ${s}`);
17
+ const info = (s: string) => console.log(` · ${s}`);
18
+
19
+ function checkNode(): boolean {
20
+ const [maj, min] = process.versions.node.split('.').map(Number);
21
+ const good = maj > 22 || (maj === 22 && min >= 5);
22
+ if (good) ok(`Node.js ${process.version} (node:sqlite 내장 사용 — 별도 빌드 불필요)`);
23
+ else warn(`Node.js ${process.version} — 22.5 이상이 필요합니다. 업데이트를 권장합니다.`);
24
+ return good;
25
+ }
26
+
27
+ function main(): void {
28
+ console.log('\nCareerMate 상태 점검\n' + '─'.repeat(40));
29
+
30
+ const nodeOk = checkNode();
31
+
32
+ // Data directory
33
+ const dir = getDataDir();
34
+ try {
35
+ fs.accessSync(dir, fs.constants.W_OK);
36
+ ok(`데이터 폴더 쓰기 가능: ${dir}`);
37
+ } catch {
38
+ warn(`데이터 폴더에 쓸 수 없습니다: ${dir}`);
39
+ }
40
+
41
+ // Database
42
+ let dbOk = false;
43
+ try {
44
+ const db = getDb();
45
+ const v = db.prepare(`SELECT value FROM _meta WHERE key='schema_version'`).get() as { value: string } | undefined;
46
+ ok(`데이터베이스 정상 (스키마 v${v?.value ?? '?'}) — ${getDbPath()}`);
47
+ dbOk = true;
48
+ } catch (e) {
49
+ warn(`데이터베이스 열기 실패: ${e instanceof Error ? e.message : e}`);
50
+ }
51
+
52
+ // Contents
53
+ if (dbOk) {
54
+ const counts = {
55
+ 프로필: profileRepo.get()?.name ? 1 : 0,
56
+ 경력: experienceRepo.list().length,
57
+ 프로젝트: projectRepo.list().length,
58
+ 기술: skillRepo.list().length,
59
+ 문서: documentRepo.list().length,
60
+ 자기소개서: coverLetterRepo.list().length,
61
+ 공고: jobRepo.list().length,
62
+ 지원: applicationRepo.list().length,
63
+ 면접준비: interviewRepo.list().length,
64
+ };
65
+ info('저장된 데이터: ' + Object.entries(counts).map(([k, v]) => `${k} ${v}`).join(' · '));
66
+ }
67
+
68
+ // MCP tools
69
+ ok(`MCP 도구 ${TOOLS.length}개 등록됨`);
70
+
71
+ // Dashboard running?
72
+ const rt = readRuntimeInfo();
73
+ if (rt && isProcessAlive(rt.pid)) ok(`대시보드 실행 중: ${rt.url}`);
74
+ else info('대시보드가 실행 중이 아닙니다. `npm start` 로 실행하세요.');
75
+
76
+ console.log('─'.repeat(40));
77
+ console.log(nodeOk && dbOk ? '핵심 점검 통과 ✅\n' : '일부 항목을 확인해 주세요 ⚠️\n');
78
+ process.exit(nodeOk && dbOk ? 0 : 1);
79
+ }
80
+
81
+ main();
@@ -0,0 +1,342 @@
1
+ /**
2
+ * scripts/init.ts — CareerMate 자동 설치/연결.
3
+ *
4
+ * 비개발자도 명령 한 번(또는 AI 에이전트가 INSTALL.md를 따라가며)으로 끝나도록, AI 클라이언트의
5
+ * MCP 설정에 CareerMate 서버를 자동 등록하고 로컬 데이터 폴더를 준비한다. CareerMate 안에는
6
+ * LLM이 없으므로, 이 단계가 끝나면 사용자의 AI가 MCP로 로컬 데이터를 읽고 쓸 수 있다.
7
+ *
8
+ * v1이 지원하는 연결 대상(우선순위 순):
9
+ * - Claude Desktop → claude_desktop_config.json (JSON · mcpServers)
10
+ * - Claude Code → <프로젝트>/.mcp.json (JSON · mcpServers, project scope)
11
+ * - Codex CLI → ~/.codex/config.toml (TOML · [mcp_servers.careermate])
12
+ * - Cursor → ~/.cursor/mcp.json (JSON · mcpServers, 기타 MCP 클라이언트 호환)
13
+ *
14
+ * 사용:
15
+ * careermate init 감지되는 클라이언트에 등록 (기본: 로컬 실행 모드)
16
+ * careermate init --client claude-code 특정 클라이언트만 (claude | claude-code | codex | cursor)
17
+ * careermate init --all-clients 감지 여부와 무관하게 지원하는 모든 클라이언트에 등록
18
+ * careermate init --npx MCP 실행을 npx 방식으로 등록(공개 배포/개발자용)
19
+ * careermate init --print 실제로 쓰지 않고 등록할 설정/명령만 출력
20
+ *
21
+ * 주의(메모리: careerflow-windows-tsx-exit-gotcha): top-level await + process.exit 조합은
22
+ * Windows tsx에서 종료코드를 오염시키므로, 동기 main()으로 두고 실패는 process.exitCode로만 표시한다.
23
+ */
24
+ import os from 'node:os';
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ /** npm에 공개되는 패키지 이름 = MCP 서버 키로도 사용. */
30
+ const PKG = 'careermate';
31
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
+
33
+ type ConfigFormat = 'mcp-json' | 'codex-toml';
34
+
35
+ interface ClientTarget {
36
+ id: string;
37
+ label: string;
38
+ format: ConfigFormat;
39
+ configPath: string;
40
+ /** JSON 클라이언트 중 `"type": "stdio"`를 함께 써야 하는 경우(Claude Code). */
41
+ jsonType?: boolean;
42
+ /** project scope처럼 항상 적용 가능한 대상은 감지 없이 기본 포함. */
43
+ alwaysPresent?: boolean;
44
+ }
45
+
46
+ interface ServerEntry {
47
+ command: string;
48
+ args: string[];
49
+ }
50
+
51
+ interface WriteResult {
52
+ changed: boolean;
53
+ backedUp: string | null;
54
+ replacedExisting: boolean;
55
+ }
56
+
57
+ /** 데이터 폴더(@careermate/db의 getDataDir와 동일 규칙). db 패키지를 끌어오지 않으려고 인라인. */
58
+ function ensureDataDir(): string {
59
+ const override = process.env.CAREERMATE_DATA_DIR?.trim();
60
+ const dir = override && override.length > 0 ? override : path.join(os.homedir(), '.careermate');
61
+ fs.mkdirSync(dir, { recursive: true });
62
+ return dir;
63
+ }
64
+
65
+ function claudeDesktopConfigPath(): string {
66
+ const home = os.homedir();
67
+ if (process.platform === 'win32') {
68
+ const appData = process.env.APPDATA ?? path.join(home, 'AppData', 'Roaming');
69
+ return path.join(appData, 'Claude', 'claude_desktop_config.json');
70
+ }
71
+ if (process.platform === 'darwin') {
72
+ return path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
73
+ }
74
+ return path.join(home, '.config', 'Claude', 'claude_desktop_config.json');
75
+ }
76
+
77
+ function cursorConfigPath(): string {
78
+ return path.join(os.homedir(), '.cursor', 'mcp.json');
79
+ }
80
+
81
+ /** Claude Code project scope 설정: 사용자가 `claude`를 띄우는 CareerMate 폴더(ROOT)에 둔다. */
82
+ function claudeCodeConfigPath(): string {
83
+ return path.join(ROOT, '.mcp.json');
84
+ }
85
+
86
+ /** Codex CLI 글로벌 설정(CODEX_HOME 우선). */
87
+ function codexConfigPath(): string {
88
+ const home = process.env.CODEX_HOME?.trim();
89
+ const base = home && home.length > 0 ? home : path.join(os.homedir(), '.codex');
90
+ return path.join(base, 'config.toml');
91
+ }
92
+
93
+ function allTargets(): ClientTarget[] {
94
+ return [
95
+ { id: 'claude', label: 'Claude Desktop', format: 'mcp-json', configPath: claudeDesktopConfigPath() },
96
+ { id: 'claude-code', label: 'Claude Code', format: 'mcp-json', configPath: claudeCodeConfigPath(), jsonType: true, alwaysPresent: true },
97
+ { id: 'codex', label: 'Codex CLI', format: 'codex-toml', configPath: codexConfigPath() },
98
+ { id: 'cursor', label: 'Cursor (기타 MCP 클라이언트)', format: 'mcp-json', configPath: cursorConfigPath() },
99
+ ];
100
+ }
101
+
102
+ /**
103
+ * 대상이 이 컴퓨터에 "있어 보이는가" — 설정 파일이나 그 부모 폴더가 존재하면 설치된 것으로 본다.
104
+ * 기본 `init`은 감지된 대상에만 써서 쓰지 않는 도구의 설정 파일을 만들지 않는다(임의 파일 생성 최소화).
105
+ */
106
+ function isPresent(t: ClientTarget): boolean {
107
+ if (t.alwaysPresent) return true;
108
+ return fs.existsSync(t.configPath) || fs.existsSync(path.dirname(t.configPath));
109
+ }
110
+
111
+ function pickTargets(which: string, forceAll: boolean): ClientTarget[] {
112
+ const all = allTargets();
113
+ if (which !== 'all') return all.filter((t) => t.id === which);
114
+ if (forceAll) return all;
115
+ return all.filter(isPresent);
116
+ }
117
+
118
+ /**
119
+ * MCP 서버 실행 방법.
120
+ * - 기본(local): 현재 Node로 이 패키지의 bin을 직접 호출 → PATH/네트워크에 의존하지 않아
121
+ * 설치 파일 안에 동봉된 런타임에서도 그대로 동작.
122
+ * - --npx: npm 레지스트리에서 받아 실행(개발자/공개 배포용). npx 캐시 경로는 휘발성이라
123
+ * npx로 실행된 init은 자동으로 이 방식을 기본값으로 쓴다(절대경로가 곧 깨지는 것을 방지).
124
+ */
125
+ function serverEntry(useNpx: boolean): ServerEntry {
126
+ if (useNpx) return { command: 'npx', args: ['-y', PKG, 'mcp'] };
127
+ return { command: process.execPath, args: [path.join(ROOT, 'bin', 'careermate.mjs'), 'mcp'] };
128
+ }
129
+
130
+ /** 이 프로세스가 `npx`를 통해 실행됐는지 추정(휘발성 캐시에서 돔). */
131
+ function runningUnderNpx(): boolean {
132
+ const ua = process.env.npm_config_user_agent ?? '';
133
+ if (/\bnpx\b/.test(ua)) return true;
134
+ // npx 캐시 디렉터리 흔적(`_npx`)이 패키지 루트 경로에 있으면 npx 실행으로 본다.
135
+ return ROOT.split(path.sep).includes('_npx');
136
+ }
137
+
138
+ function readJsonObject(file: string): Record<string, unknown> {
139
+ try {
140
+ const raw = fs.readFileSync(file, 'utf8').trim();
141
+ if (!raw) return {};
142
+ const parsed = JSON.parse(raw);
143
+ return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
144
+ } catch {
145
+ return {};
146
+ }
147
+ }
148
+
149
+ function backupFile(file: string): string | null {
150
+ // 타임스탬프를 붙여 이전 백업을 덮어쓰지 않는다(원본을 영구 보존).
151
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
152
+ const dest = `${file}.careermate-backup-${stamp}`;
153
+ try {
154
+ fs.copyFileSync(file, dest);
155
+ return dest;
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ /** Claude Desktop / Claude Code / Cursor 등 `mcpServers` JSON 형식. */
162
+ function writeMcpJson(target: ClientTarget, server: ServerEntry): WriteResult {
163
+ const existed = fs.existsSync(target.configPath);
164
+ const json = existed ? readJsonObject(target.configPath) : {};
165
+ const servers =
166
+ json.mcpServers && typeof json.mcpServers === 'object'
167
+ ? (json.mcpServers as Record<string, unknown>)
168
+ : {};
169
+
170
+ const prev = servers[PKG];
171
+ const before = JSON.stringify(prev ?? null);
172
+ // 사용자가 careermate 항목에 직접 넣어둔 env(예: CAREERMATE_DATA_DIR)는 보존한다.
173
+ const next: Record<string, unknown> = {};
174
+ if (target.jsonType) next.type = 'stdio';
175
+ next.command = server.command;
176
+ next.args = server.args;
177
+ if (prev && typeof prev === 'object' && 'env' in (prev as Record<string, unknown>)) {
178
+ next.env = (prev as Record<string, unknown>).env;
179
+ }
180
+ servers[PKG] = next;
181
+ json.mcpServers = servers;
182
+ const changed = JSON.stringify(servers[PKG]) !== before;
183
+ const replacedExisting = prev != null && changed;
184
+
185
+ let backedUp: string | null = null;
186
+ if (existed && changed) backedUp = backupFile(target.configPath);
187
+ fs.mkdirSync(path.dirname(target.configPath), { recursive: true });
188
+ fs.writeFileSync(target.configPath, `${JSON.stringify(json, null, 2)}\n`, 'utf8');
189
+ return { changed, backedUp, replacedExisting };
190
+ }
191
+
192
+ /** TOML 기본 문자열로 직렬화(역슬래시/따옴표 이스케이프). JSON 문자열 규칙과 호환된다. */
193
+ function tomlString(s: string): string {
194
+ return JSON.stringify(s);
195
+ }
196
+
197
+ /** Codex 설정의 careermate 블록(헤더 + command + args). 우리가 형태를 통제하므로 단순하다. */
198
+ function codexBlock(server: ServerEntry): string {
199
+ const args = server.args.map(tomlString).join(', ');
200
+ return [`[mcp_servers.${PKG}]`, `command = ${tomlString(server.command)}`, `args = [${args}]`].join('\n');
201
+ }
202
+
203
+ /**
204
+ * 기존 config.toml에 careermate 블록을 안전하게 삽입/교체한다.
205
+ * - TOML 파서를 끌어오지 않고 텍스트로 처리하되, 우리가 만든 블록만 건드린다.
206
+ * - 블록 경계는 "다음 `[` 로 시작하는 테이블 헤더"까지 → `[mcp_servers.careermate.env]`
207
+ * 같은 사용자 하위 테이블은 보존된다.
208
+ */
209
+ function upsertCodexBlock(raw: string, block: string): { next: string; found: boolean } {
210
+ const lines = raw.length ? raw.split(/\r?\n/) : [];
211
+ const headerRe = /^\s*\[\s*mcp_servers\s*\.\s*careermate\s*\]\s*$/;
212
+ const tableRe = /^\s*\[/;
213
+ const start = lines.findIndex((l) => headerRe.test(l));
214
+
215
+ if (start === -1) {
216
+ const trimmed = raw.replace(/\s+$/, '');
217
+ const next = trimmed.length ? `${trimmed}\n\n${block}\n` : `${block}\n`;
218
+ return { next, found: false };
219
+ }
220
+
221
+ let end = lines.length;
222
+ for (let j = start + 1; j < lines.length; j++) {
223
+ if (tableRe.test(lines[j])) {
224
+ end = j;
225
+ break;
226
+ }
227
+ }
228
+ const merged = [...lines.slice(0, start), ...block.split('\n'), ...lines.slice(end)];
229
+ let next = merged.join('\n');
230
+ if (!next.endsWith('\n')) next += '\n';
231
+ return { next, found: true };
232
+ }
233
+
234
+ /** Codex CLI: `~/.codex/config.toml` 의 `[mcp_servers.careermate]` 테이블. */
235
+ function writeCodexToml(target: ClientTarget, server: ServerEntry): WriteResult {
236
+ const existed = fs.existsSync(target.configPath);
237
+ let raw = '';
238
+ if (existed) {
239
+ try {
240
+ raw = fs.readFileSync(target.configPath, 'utf8');
241
+ } catch {
242
+ raw = '';
243
+ }
244
+ }
245
+ const { next, found } = upsertCodexBlock(raw, codexBlock(server));
246
+ const changed = next.trim() !== raw.trim();
247
+
248
+ let backedUp: string | null = null;
249
+ if (existed && changed) backedUp = backupFile(target.configPath);
250
+ fs.mkdirSync(path.dirname(target.configPath), { recursive: true });
251
+ fs.writeFileSync(target.configPath, next, 'utf8');
252
+ return { changed, backedUp, replacedExisting: found && changed };
253
+ }
254
+
255
+ function writeTarget(target: ClientTarget, server: ServerEntry): WriteResult {
256
+ return target.format === 'codex-toml' ? writeCodexToml(target, server) : writeMcpJson(target, server);
257
+ }
258
+
259
+ function flagValue(argv: string[], name: string): string | undefined {
260
+ const i = argv.indexOf(name);
261
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : undefined;
262
+ }
263
+
264
+ /** 셸에 그대로 붙여넣을 수 있는 명령 문자열(공백 포함 인자는 따옴표). */
265
+ function shellJoin(parts: string[]): string {
266
+ return parts.map((p) => (/\s/.test(p) ? `"${p}"` : p)).join(' ');
267
+ }
268
+
269
+ function printConfigs(server: ServerEntry): void {
270
+ const cmd = [server.command, ...server.args];
271
+ console.log('CareerMate를 AI 클라이언트에 직접 연결하는 방법입니다.\n');
272
+
273
+ console.log('① Claude Desktop / Claude Code / Cursor — MCP 설정(mcpServers)에 추가:');
274
+ console.log(
275
+ JSON.stringify(
276
+ { mcpServers: { [PKG]: { type: 'stdio', command: server.command, args: server.args } } },
277
+ null,
278
+ 2,
279
+ ),
280
+ );
281
+ console.log('\n · Claude Code는 위 내용을 프로젝트 루트의 .mcp.json 으로 저장하면 됩니다.');
282
+ console.log(` · 또는 명령으로: ${shellJoin(['claude', 'mcp', 'add', '--scope', 'project', '--transport', 'stdio', PKG, '--', ...cmd])}`);
283
+
284
+ console.log('\n② Codex CLI — ~/.codex/config.toml 에 추가:');
285
+ console.log(codexBlock(server));
286
+ console.log(`\n · 또는 명령으로: ${shellJoin(['codex', 'mcp', 'add', PKG, '--', ...cmd])}`);
287
+ }
288
+
289
+ function main(): void {
290
+ const argv = process.argv.slice(2);
291
+ const useNpx = argv.includes('--npx') || runningUnderNpx();
292
+ const printOnly = argv.includes('--print');
293
+ const forceAll = argv.includes('--all-clients');
294
+ const which = flagValue(argv, '--client') ?? 'all';
295
+
296
+ const server = serverEntry(useNpx);
297
+
298
+ if (printOnly) {
299
+ printConfigs(server);
300
+ return;
301
+ }
302
+
303
+ console.log('CareerMate 설치를 시작합니다…\n');
304
+ const dataDir = ensureDataDir();
305
+ console.log(`• 데이터 폴더 준비됨: ${dataDir}`);
306
+
307
+ const targets = pickTargets(which, forceAll);
308
+ let connected = 0;
309
+ const connectedIds = new Set<string>();
310
+ for (const t of targets) {
311
+ try {
312
+ const { changed, backedUp, replacedExisting } = writeTarget(t, server);
313
+ connected += 1;
314
+ connectedIds.add(t.id);
315
+ console.log(
316
+ `• ${t.label} 연결 ${changed ? '완료' : '이미 최신'}: ${t.configPath}` +
317
+ (replacedExisting ? `\n (기존 careermate 설정을 새 설정으로 교체했습니다)` : '') +
318
+ (backedUp ? `\n (기존 설정 백업: ${backedUp})` : ''),
319
+ );
320
+ } catch (e) {
321
+ console.log(`• ${t.label} 연결 실패: ${e instanceof Error ? e.message : e}`);
322
+ }
323
+ }
324
+
325
+ if (connected === 0) {
326
+ console.log('\n연결할 AI 클라이언트를 찾지 못했습니다.');
327
+ console.log('특정 클라이언트를 지정하거나(--client claude|claude-code|codex|cursor),');
328
+ console.log('설정을 직접 복사해 추가할 수 있어요: careermate init --print');
329
+ process.exitCode = 1;
330
+ return;
331
+ }
332
+
333
+ console.log('\n✅ 거의 끝났습니다. 다음만 해주세요:');
334
+ console.log(' 1) 연결한 AI 클라이언트를 완전히 종료했다가 다시 켜기');
335
+ if (connectedIds.has('claude-code')) {
336
+ console.log(' (Claude Code는 프로젝트의 .mcp.json을 처음 띄울 때 1회 "승인"을 물어봅니다 — 승인해 주세요.)');
337
+ }
338
+ console.log(' 2) AI에게: "get_onboarding_status 호출해서 연결됐는지 확인해줘"');
339
+ console.log(' 3) 내 데이터 대시보드 열기: careermate start');
340
+ }
341
+
342
+ main();
@@ -0,0 +1,55 @@
1
+ /**
2
+ * One-shot MCP probe used by `npm test`. Connects to the CareerMate MCP server
3
+ * over real stdio, exercises a few tools, and prints a single JSON line of
4
+ * results to stdout. The parent test invokes this via spawnSync (which fully
5
+ * reaps the child), so the parent's own exit code stays clean on Windows where
6
+ * a long-lived tsx child otherwise corrupts it.
7
+ *
8
+ * Inherits CAREERMATE_DATA_DIR from the parent so it hits the same database.
9
+ */
10
+ import path from 'node:path';
11
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
12
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
13
+
14
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')), '..');
15
+
16
+ const transport = new StdioClientTransport({
17
+ command: process.execPath,
18
+ args: ['--no-warnings', '--experimental-sqlite', '--import', 'tsx', 'apps/mcp/src/index.ts'],
19
+ env: { ...process.env } as Record<string, string>,
20
+ cwd: repoRoot,
21
+ stderr: 'ignore',
22
+ });
23
+
24
+ const client = new Client({ name: 'mcp-probe', version: '1.0.0' });
25
+ const parse = (r: any) => {
26
+ const j = r.content?.find((c: any) => typeof c.text === 'string' && c.text.startsWith('```json'));
27
+ return j ? JSON.parse(j.text.replace(/```json\n|\n```/g, '')) : r.structuredContent;
28
+ };
29
+
30
+ try {
31
+ await client.connect(transport);
32
+ const tools = (await client.listTools()).tools;
33
+ const profile = parse(await client.callTool({ name: 'get_profile', arguments: {} }));
34
+ const job = parse(await client.callTool({ name: 'save_job_posting', arguments: { company: '쿠팡', position: '데이터 엔지니어' } }));
35
+ const out = {
36
+ ok: true,
37
+ toolCount: tools.length,
38
+ hasContext: tools.some((t) => t.name === 'get_application_context'),
39
+ profileName: profile?.name ?? null,
40
+ profileHeadline: profile?.headline ?? null,
41
+ jobId: job?.id ?? null,
42
+ };
43
+ process.stdout.write('RESULT ' + JSON.stringify(out) + '\n');
44
+ await client.close();
45
+ const pid = (transport as unknown as { _process?: { pid?: number } })._process?.pid;
46
+ if (pid && process.platform === 'win32') {
47
+ const { spawnSync } = await import('node:child_process');
48
+ spawnSync('taskkill', ['/F', '/T', '/PID', String(pid)], { stdio: 'ignore' });
49
+ }
50
+ } catch (e) {
51
+ process.stdout.write('RESULT ' + JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }) + '\n');
52
+ }
53
+ // Exit code intentionally ignored by the parent (it reads stdout); may be
54
+ // corrupted by the tsx child teardown on Windows, which is fine here.
55
+ setTimeout(() => process.exit(0), 300).unref();
@@ -0,0 +1,6 @@
1
+ /** Initialize/upgrade the local database. Safe to run repeatedly. */
2
+ import { getDb, getDbPath } from '@careermate/db';
3
+
4
+ getDb(); // opens + migrates
5
+ console.log(`✅ CareerMate 데이터베이스 준비 완료`);
6
+ console.log(` 위치: ${getDbPath()}`);
@@ -0,0 +1,33 @@
1
+ // Plain-Node test runner (no tsx). Runs a tsx test script as a child and derives
2
+ // the real pass/fail from a stdout verdict sentinel.
3
+ //
4
+ // Why: on Windows, a tsx module that uses top-level `await` and then calls
5
+ // process.exit() exits with a corrupted code (the esbuild loader is mid-flight).
6
+ // This runner is plain Node, so ITS exit code is reliable; it ignores the
7
+ // child's mangled code and trusts the printed verdict instead.
8
+ import { spawnSync } from 'node:child_process';
9
+ import process from 'node:process';
10
+
11
+ const [, , scriptPath, sentinel] = process.argv;
12
+ if (!scriptPath || !sentinel) {
13
+ console.error('usage: node scripts/run.mjs <script.ts> "<PASS SENTINEL>"');
14
+ process.exit(2);
15
+ }
16
+
17
+ const res = spawnSync(process.execPath, ['--no-warnings', '--experimental-sqlite', '--import', 'tsx', scriptPath], {
18
+ encoding: 'utf8',
19
+ env: process.env,
20
+ timeout: 180000,
21
+ });
22
+
23
+ // Surface the child's output (minus the noisy Windows libuv teardown lines).
24
+ const clean = (s) =>
25
+ (s || '')
26
+ .split('\n')
27
+ .filter((l) => !/Assertion failed|async\.c|UV_HANDLE|^Node\.js v\d/.test(l))
28
+ .join('\n');
29
+ process.stdout.write(clean(res.stdout));
30
+ if (res.stderr) process.stderr.write(clean(res.stderr));
31
+
32
+ const passed = (res.stdout || '').includes(sentinel);
33
+ process.exit(passed ? 0 : 1);