careermate 0.1.0 → 0.1.1
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/README.md +10 -7
- package/apps/mcp/src/index.ts +3 -1
- package/apps/web/DESIGN_GUIDE.md +2 -2
- package/apps/web/public/icon-180.png +0 -0
- package/apps/web/public/index.html +4 -1
- package/apps/web/public/lib.js +1 -1
- package/apps/web/public/pages/applications.js +17 -1
- package/apps/web/public/pages/settings.js +6 -4
- package/apps/web/public/styles.css +49 -36
- package/apps/web/src/info.ts +9 -22
- package/apps/web/src/routes.ts +17 -13
- package/apps/web/src/server.ts +33 -25
- package/apps/web/src/settings.ts +35 -17
- package/bin/careermate.mjs +8 -5
- package/dist/{mcpb-stage/dist/web.mjs → doctor.mjs} +1122 -1450
- package/dist/install-page/favicon.svg +1 -0
- package/dist/install-page/icon-180.png +0 -0
- package/dist/install-page/icon-512.png +0 -0
- package/dist/install-page/index.html +309 -167
- package/dist/install-page/llms-install.txt +202 -0
- package/dist/install-page/og.png +0 -0
- package/dist/install-page/shots/applications-dark.png +0 -0
- package/dist/install-page/shots/applications-light.png +0 -0
- package/dist/install-page/shots/documents-dark.png +0 -0
- package/dist/install-page/shots/documents-light.png +0 -0
- package/dist/install-page/shots/home-dark.png +0 -0
- package/dist/install-page/shots/home-light.png +0 -0
- package/dist/install-page/shots/interview-dark.png +0 -0
- package/dist/install-page/shots/interview-light.png +0 -0
- package/dist/install-page/shots/jobs-dark.png +0 -0
- package/dist/install-page/shots/jobs-light.png +0 -0
- package/dist/install-page/shots/settings-dark.png +0 -0
- package/dist/install-page/shots/settings-light.png +0 -0
- package/dist/install-page/start.html +346 -0
- package/dist/install-page/style.css +94 -3
- package/dist/mcp.mjs +326 -68
- package/dist/migrate.mjs +4715 -0
- package/dist/public/icon-180.png +0 -0
- package/dist/public/index.html +4 -1
- package/dist/public/lib.js +1 -1
- package/dist/public/pages/applications.js +17 -1
- package/dist/public/pages/settings.js +6 -4
- package/dist/public/styles.css +49 -36
- package/dist/web.mjs +323 -129
- package/docs/DEMO.md +3 -3
- package/docs/MCP_TOOLS.md +21 -2
- package/docs/ROADMAP.md +3 -3
- package/docs/RUNTIME_DISTRIBUTION_RESEARCH.md +135 -0
- package/docs/START_WORKFLOW.md +6 -0
- package/docs/SUPPORTED_AI_APPS.md +85 -33
- package/docs/WORKFLOWS.md +1 -1
- package/package.json +3 -2
- package/packages/core/src/services.ts +42 -1
- package/packages/db/src/connection.ts +30 -0
- package/packages/db/src/index.ts +1 -0
- package/packages/db/src/repositories.ts +109 -63
- package/packages/db/src/schema.ts +118 -0
- package/packages/exporters/src/html.ts +9 -12
- package/packages/mcp-tools/src/tools.ts +81 -0
- package/packages/shared/src/enums.ts +39 -0
- package/scripts/build-dist.mjs +27 -8
- package/scripts/build-mcpb.mjs +60 -4
- package/scripts/gen-og.mjs +86 -0
- package/scripts/init.ts +32 -3
- package/scripts/seed.ts +5 -5
- package/scripts/shots-seed.ts +192 -0
- package/scripts/shots.ts +89 -0
- package/tsconfig.json +3 -1
- package/dist/careermate.mcpb +0 -0
- package/dist/mcp-smoke.err +0 -3
- package/dist/mcpb-stage/README.md +0 -219
- package/dist/mcpb-stage/dist/install-page/index.html +0 -434
- package/dist/mcpb-stage/dist/install-page/style.css +0 -407
- package/dist/mcpb-stage/dist/install-page/vercel.json +0 -20
- package/dist/mcpb-stage/dist/mcp.mjs +0 -23704
- package/dist/mcpb-stage/dist/public/app.js +0 -118
- package/dist/mcpb-stage/dist/public/index.html +0 -41
- package/dist/mcpb-stage/dist/public/lib.js +0 -282
- package/dist/mcpb-stage/dist/public/pages/applications.js +0 -98
- package/dist/mcpb-stage/dist/public/pages/documents.js +0 -446
- package/dist/mcpb-stage/dist/public/pages/home.js +0 -263
- package/dist/mcpb-stage/dist/public/pages/interview.js +0 -230
- package/dist/mcpb-stage/dist/public/pages/jobs.js +0 -494
- package/dist/mcpb-stage/dist/public/pages/profile.js +0 -576
- package/dist/mcpb-stage/dist/public/pages/settings.js +0 -233
- package/dist/mcpb-stage/dist/public/styles.css +0 -420
- package/dist/mcpb-stage/manifest.json +0 -40
- package/docs/INSTALL.md +0 -148
- package/docs/INSTALL_AND_USAGE.md +0 -99
- package/install-page/index.html +0 -474
- package/install-page/style.css +0 -391
- package/install-page/vercel.json +0 -20
- /package/dist/{mcpb-stage/dist/public → install-page}/fonts/PretendardVariable.woff2 +0 -0
package/README.md
CHANGED
|
@@ -55,11 +55,11 @@
|
|
|
55
55
|
```bash
|
|
56
56
|
node --version
|
|
57
57
|
```
|
|
58
|
-
`v22.5.0` 미만이거나 명령을 찾지 못하면 https://nodejs.org 에서 최신 LTS
|
|
58
|
+
`v22.5.0` 미만이거나 명령을 찾지 못하면 설치하세요 — Windows: `winget install OpenJS.NodeJS.LTS`, macOS: `brew install node`, 그 외: https://nodejs.org 에서 최신 LTS.
|
|
59
59
|
|
|
60
60
|
### 1) 설치
|
|
61
61
|
|
|
62
|
-
빌드 단계가 없습니다. TypeScript는 `tsx`로 바로 실행됩니다.
|
|
62
|
+
빌드 단계가 없습니다. TypeScript는 `tsx`로 바로 실행됩니다. (소스를 직접 받으려면 `git clone https://github.com/osntak/CareerMate.git` — 현재 **비공개 저장소**라 접근 권한이 필요하며, 권한이 없으면 클론 없이 `npx -y careermate init`을 쓰세요.)
|
|
63
63
|
|
|
64
64
|
```bash
|
|
65
65
|
npm install
|
|
@@ -91,9 +91,11 @@ v1에서 권장하는 연결 방식은 셋입니다. 아래 순서대로 골라
|
|
|
91
91
|
|
|
92
92
|
#### ① Claude Desktop 연결 (.mcpb 원클릭)
|
|
93
93
|
|
|
94
|
-
터미널·Node 없이 가장 간단하게 붙이는 **하나의 연결 방법**입니다. `dist/careermate.mcpb`(`npm run build:mcpb`로 생성)를
|
|
94
|
+
터미널·Node 없이 가장 간단하게 붙이는 **하나의 연결 방법**입니다. `dist/careermate.mcpb`(`npm run build:mcpb`로 생성)를 Claude Desktop의 **Settings → Extensions → Advanced settings → Install extension…** 에서 선택해 추가하고, Claude Desktop을 재시작합니다. (더블클릭·드래그는 Windows에서 파일 연결이 안 잡혀 반응이 없을 수 있습니다.)
|
|
95
95
|
|
|
96
|
-
#### ② Claude Code
|
|
96
|
+
#### ② Claude Code (CLI 또는 Claude Desktop의 **Code 탭**)
|
|
97
|
+
|
|
98
|
+
터미널이 부담스러우면 **Claude 데스크톱 앱의 "Code" 탭**(앱 안의 Claude Code, 통합 터미널 내장)이 비개발자에게 가장 매끄럽습니다 — 작업 폴더 하나를 잡고 데이터까지 그 안에 두는 방식입니다. 자세한 건 [`docs/SUPPORTED_AI_APPS.md`](docs/SUPPORTED_AI_APPS.md)(또는 사람용 안내 https://careermate.life). (Pro 이상 구독 필요, Windows는 Git 사전 설치.)
|
|
97
99
|
|
|
98
100
|
CareerMate 폴더에서 Claude Code를 실행한 뒤, 이렇게 시켜 주세요.
|
|
99
101
|
|
|
@@ -115,7 +117,7 @@ Codex가 `~/.codex/config.toml`에 MCP 서버를 등록합니다. 연결은 Code
|
|
|
115
117
|
|
|
116
118
|
> **기타 MCP 클라이언트(Cursor 등)** — Cursor·Cline·Windsurf 같은 로컬 stdio MCP 클라이언트는 `npm run init`(또는 `npm run init -- --print`)으로 등록할 수 있습니다.
|
|
117
119
|
|
|
118
|
-
연결 후 AI 클라이언트를 **완전히 재시작**하고, `get_onboarding_status`를 호출해 달라고 시켜 연결을 검증하세요. 자세한 절차는 AI용 런북 **[`INSTALL.md`](INSTALL.md)
|
|
120
|
+
연결 후 AI 클라이언트를 **완전히 재시작**하고, `get_onboarding_status`를 호출해 달라고 시켜 연결을 검증하세요. 자세한 절차는 AI용 런북 **[`INSTALL.md`](INSTALL.md)**, 사람용 설치 안내 **https://careermate.life**, 설치 후 사용법 **[`docs/START_WORKFLOW.md`](docs/START_WORKFLOW.md)**를 참고하세요.
|
|
119
121
|
|
|
120
122
|
> **가장 간단한 길 — npx:** `careermate`가 npm에 게시되어 클론 없이 `npx -y careermate init` 한 줄로 등록됩니다. 소스 폴더에서 작업 중이면 `npm install` 후 `npm run init`을 써도 됩니다.
|
|
121
123
|
|
|
@@ -195,6 +197,8 @@ CareerMate/
|
|
|
195
197
|
| `npm run migrate` | DB 생성/업그레이드 |
|
|
196
198
|
| `npm run doctor` | 설치/환경 점검 |
|
|
197
199
|
| `npm run seed` | 예시 데이터 삽입 |
|
|
200
|
+
| `npm run build` | 배포용 플레인 JS 번들(`dist/`) 생성 (esbuild) |
|
|
201
|
+
| `npm run build:mcpb` | Claude Desktop용 `.mcpb` 번들 빌드 → `dist/careermate.mcpb`(+ `install-page/`로 복사). manifest+서버코드를 한 파일로 묶어 배포(careermate.life에 호스팅). |
|
|
198
202
|
| `npm test` | E2E 테스트 실행 |
|
|
199
203
|
| `npm run test:ui` | Playwright UI 스모크 테스트 |
|
|
200
204
|
| `npm run typecheck` | 타입 검사 (`tsc --noEmit`) |
|
|
@@ -232,8 +236,7 @@ CareerMate/
|
|
|
232
236
|
|
|
233
237
|
- [`INSTALL.md`](INSTALL.md) — AI 어시스턴트가 따라가는 설치·연결 런북(소스 직접 실행 기준).
|
|
234
238
|
- [`AGENTS.md`](AGENTS.md) — Codex용 영속 지침. / [`CLAUDE.md`](CLAUDE.md) — Claude Code용 영속 지침.
|
|
235
|
-
- [`docs/SUPPORTED_AI_APPS.md`](docs/SUPPORTED_AI_APPS.md) —
|
|
236
|
-
- [`docs/INSTALL_AND_USAGE.md`](docs/INSTALL_AND_USAGE.md) — 사람용 두 트랙 설치(.mcpb 원클릭 / 터미널)와 설치 후 사용법.
|
|
239
|
+
- [`docs/SUPPORTED_AI_APPS.md`](docs/SUPPORTED_AI_APPS.md) — **설치 레퍼런스 허브**: 지원 앱 매트릭스 + 설치 3방식(에이전트/npx/.mcpb) + 클라이언트별 수동 MCP 설정 + 알려진 제약. (사람용 안내는 https://careermate.life)
|
|
237
240
|
- [`docs/START_WORKFLOW.md`](docs/START_WORKFLOW.md) — 등록부터 면접 준비까지 단계별 작업 런북.
|
|
238
241
|
- [`docs/TODO.md`](docs/TODO.md) — 남은 작업과 v1 범위 밖 항목.
|
|
239
242
|
|
package/apps/mcp/src/index.ts
CHANGED
|
@@ -13,7 +13,9 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
13
13
|
import { getDb, getDataDir } from '@careermate/db';
|
|
14
14
|
import { TOOLS, toCallToolResult } from '@careermate/mcp-tools';
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// 번들 빌드(esbuild)에서 package.json 버전으로 치환된다. tsx 개발 실행에서는 정의되지 않아 typeof로 안전 분기.
|
|
17
|
+
declare const __APP_VERSION__: string | undefined;
|
|
18
|
+
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0-dev';
|
|
17
19
|
|
|
18
20
|
async function main(): Promise<void> {
|
|
19
21
|
// Ensure the DB is created/migrated before serving any tool call.
|
package/apps/web/DESIGN_GUIDE.md
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
3. **필러 설명 금지.** 설명 줄은 *수치·제약·상태·자명하지 않은 이유*를 말할 때만 허용. 제목을 바꿔 말하면 삭제. **"한눈에" 금지.**
|
|
28
28
|
4. **설정/MCP 안내는 단 한 곳(홈 '시작하기' 카드)에만.** 빈 상태는 화면별 **고유 한 문장**. "ChatGPT/Claude에게 …저장됩니다" 반복·`MCP_HINT` 공용 상수 금지.
|
|
29
29
|
5. **위계는 타이포·여백으로, 박스로 X.** 단일 빈 상태/단일 블록을 `.card`로 감싸지 않는다. 카드는 **반복·선택 가능한 객체**(공고 행, 자소서, 보드 카드)에만. **카드 in 카드 금지.**
|
|
30
|
-
6. **악센트(
|
|
30
|
+
6. **악센트(에메랄드 `--accent: #0f7256`)는 절제.** 1차 버튼·링크·포커스 링·active nav에만. 랜딩(install-page)과 동일한 크림 페이퍼 + 에메랄드 팔레트로 통일(인디고 폐기). **장식용 보라 그라데이션 금지** (`.sidebar__logo`, `.progress__bar`의 `135deg accent→#7c3aed`는 평면 `var(--accent)`로). 코드에 raw hex 금지 — 색은 CSS 변수로만.
|
|
31
31
|
7. **버튼 라벨은 동사+명사, 흐름 내내 일관.** 모달 저장 버튼은 `저장`이 아니라 **`공고 저장`/`경력 저장`**. 생성 라벨과 성공 토스트의 명사가 일치(`공고 저장`→`공고를 저장했습니다`). 맨동사는 `취소`·`닫기`·`복사`만.
|
|
32
32
|
8. **인덱스 페이지는 툴바+행으로 직행.** 설명 헤더 블록은 settings/profile/온보딩/상세에만. 상세(`jobs/:id`)의 회사명 `<h1>`은 nav가 못 보여주는 *콘텐츠*이므로 유일한 정당한 예외.
|
|
33
33
|
9. **숫자 열은 우정렬 + `.tnum`.** 적합도 점수·파일 크기 등은 `text-align:right`로 자릿수를 정렬.
|
|
@@ -52,7 +52,7 @@ PageHead( (인덱스 페이지) → 제거 (상단바 제목으로 충분
|
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
추가 육안 테스트(페이지마다):
|
|
55
|
-
- **눈 가늘게 뜨기 테스트**: 채워진
|
|
55
|
+
- **눈 가늘게 뜨기 테스트**: 채워진 악센트(에메랄드) 버튼은 정확히 1개. 2개가 경쟁하면 하나를 ghost로 강등.
|
|
56
56
|
- **h1 개수**: 인덱스 0개(사이드바가 제목) / 상세·설정·프로필 1개. h1→h3 건너뜀 없음.
|
|
57
57
|
- **빈 상태 형태**: 제목 + 고유 한 문장 + (있다면) 버튼 1개. 검색결과 없음은 '필터 초기화'지 생성 CTA가 아님.
|
|
58
58
|
|
|
Binary file
|
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>CareerMate — 내 커리어 흐름 관리</title>
|
|
7
7
|
<meta name="description" content="ChatGPT/Claude와 함께 쓰는 로컬 커리어 관리 도구" />
|
|
8
|
-
<
|
|
8
|
+
<meta name="theme-color" content="#faf8f4" media="(prefers-color-scheme: light)" />
|
|
9
|
+
<meta name="theme-color" content="#14130f" media="(prefers-color-scheme: dark)" />
|
|
10
|
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><rect width='24' height='24' rx='6' fill='%230f7256'/><text x='12' y='17' font-size='14' text-anchor='middle' fill='white' font-family='sans-serif' font-weight='bold'>C</text></svg>" />
|
|
11
|
+
<link rel="apple-touch-icon" href="/icon-180.png" />
|
|
9
12
|
<link rel="preload" href="/fonts/PretendardVariable.woff2" as="font" type="font/woff2" crossorigin />
|
|
10
13
|
<link rel="stylesheet" href="/styles.css" />
|
|
11
14
|
</head>
|
package/apps/web/public/lib.js
CHANGED
|
@@ -49,7 +49,7 @@ export function mount(node, ...children) { clear(node); appendChildren(node, chi
|
|
|
49
49
|
const ICONS = {
|
|
50
50
|
home: '<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/>',
|
|
51
51
|
user: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-6 8-6s8 2 8 6"/>',
|
|
52
|
-
briefcase: '<rect x="3" y="
|
|
52
|
+
briefcase: '<rect x="3" y="8" width="18" height="11" rx="2.5"/><path d="M8.5 8V6.5a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2V8"/><path d="M3 13h18"/>',
|
|
53
53
|
layers: '<path d="m12 3 9 5-9 5-9-5 9-5Z"/><path d="m3 13 9 5 9-5"/>',
|
|
54
54
|
file: '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z"/><path d="M14 3v5h5"/>',
|
|
55
55
|
mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><path d="M12 18v3"/>',
|
|
@@ -12,6 +12,19 @@ const STATUS_COLOR = {
|
|
|
12
12
|
final_passed: 'var(--green)', on_hold: 'var(--slate)', rejected: 'var(--red)',
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
// deadline(YYYY-MM-DD) → "마감 D-7" / "마감 D-day" / "마감 지남" (null if absent/invalid)
|
|
16
|
+
function ddayLabel(deadline) {
|
|
17
|
+
if (!deadline) return null;
|
|
18
|
+
const d = new Date(`${deadline}T00:00:00`);
|
|
19
|
+
if (isNaN(d.getTime())) return null;
|
|
20
|
+
const today = new Date();
|
|
21
|
+
today.setHours(0, 0, 0, 0);
|
|
22
|
+
const days = Math.round((d.getTime() - today.getTime()) / 86400000);
|
|
23
|
+
if (days > 0) return `마감 D-${days}`;
|
|
24
|
+
if (days === 0) return '마감 D-day';
|
|
25
|
+
return '마감 지남';
|
|
26
|
+
}
|
|
27
|
+
|
|
15
28
|
export async function render(ctx) {
|
|
16
29
|
ctx.setActions([]);
|
|
17
30
|
const data = await get('/api/applications');
|
|
@@ -58,6 +71,9 @@ export async function render(ctx) {
|
|
|
58
71
|
|
|
59
72
|
function AppRow(app) {
|
|
60
73
|
const job = app.job || {};
|
|
74
|
+
const subBits = [job.position, job.location].filter(Boolean);
|
|
75
|
+
const dday = ddayLabel(job.deadline);
|
|
76
|
+
if (dday) subBits.push(dday);
|
|
61
77
|
const sel = Select(
|
|
62
78
|
statusOptions.map((o) => ({ ...o, selected: o.value === app.status })),
|
|
63
79
|
{
|
|
@@ -69,7 +85,7 @@ export async function render(ctx) {
|
|
|
69
85
|
);
|
|
70
86
|
return ListRow({
|
|
71
87
|
title: job.company || '—',
|
|
72
|
-
sub:
|
|
88
|
+
sub: subBits.join(' · '),
|
|
73
89
|
trailing: [
|
|
74
90
|
app.fit_score != null
|
|
75
91
|
? el('span', { class: `strong tnum ${scoreClass(app.fit_score)}` }, `${app.fit_score}점`)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Settings — data location, connection, theme, backup/export, and danger zone.
|
|
2
2
|
// Everything stays local; this page surfaces where data lives and how to manage it.
|
|
3
3
|
import {
|
|
4
|
-
el, get, post, icon, navigate, Card,
|
|
4
|
+
el, get, post, icon, navigate, Card, Btn, IconBtn, Input,
|
|
5
5
|
openModal, copyText, downloadUrl, fmtRelative, toastOk, toastError, mount,
|
|
6
6
|
} from '/lib.js';
|
|
7
7
|
|
|
@@ -134,12 +134,14 @@ function Theme() {
|
|
|
134
134
|
|
|
135
135
|
/* ---------------------------------------------------------- 내 데이터 */
|
|
136
136
|
function MyData(counts = {}) {
|
|
137
|
-
const
|
|
138
|
-
|
|
137
|
+
const items = Object.entries(COUNT_LABELS).map(([key, label]) =>
|
|
138
|
+
el('div', { class: 'countstrip__item' },
|
|
139
|
+
el('div', { class: 'countstrip__num tnum' }, String(counts[key] ?? 0)),
|
|
140
|
+
el('div', { class: 'countstrip__label' }, label)));
|
|
139
141
|
return Card({
|
|
140
142
|
title: '내 데이터',
|
|
141
143
|
sub: '이 컴퓨터에 저장된 항목 수',
|
|
142
|
-
body: el('div', { class: '
|
|
144
|
+
body: el('div', { class: 'countstrip' }, ...items),
|
|
143
145
|
});
|
|
144
146
|
}
|
|
145
147
|
|
|
@@ -19,24 +19,24 @@
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
:root {
|
|
22
|
-
/*
|
|
23
|
-
--bg: #
|
|
24
|
-
--bg-subtle: #
|
|
25
|
-
--bg-sunken: #
|
|
22
|
+
/* Warm paper neutrals (light) — unified with the public landing page */
|
|
23
|
+
--bg: #faf8f4;
|
|
24
|
+
--bg-subtle: #f2efe8;
|
|
25
|
+
--bg-sunken: #ece8df;
|
|
26
26
|
--surface: #ffffff;
|
|
27
|
-
--surface-hover: #
|
|
28
|
-
--border: #
|
|
29
|
-
--border-strong: #
|
|
30
|
-
--text: #
|
|
31
|
-
--text-secondary: #
|
|
32
|
-
--text-tertiary: #
|
|
27
|
+
--surface-hover: #f5f2ec;
|
|
28
|
+
--border: #e6e1d6;
|
|
29
|
+
--border-strong: #d6d0c2;
|
|
30
|
+
--text: #1c1a16;
|
|
31
|
+
--text-secondary: #595449;
|
|
32
|
+
--text-tertiary: #8b867a;
|
|
33
33
|
--text-on-accent: #ffffff;
|
|
34
34
|
|
|
35
|
-
/* Accent —
|
|
36
|
-
--accent: #
|
|
37
|
-
--accent-hover: #
|
|
38
|
-
--accent-soft: #
|
|
39
|
-
--accent-border: #
|
|
35
|
+
/* Accent — deep emerald (single flat accent, no gradient) */
|
|
36
|
+
--accent: #0f7256;
|
|
37
|
+
--accent-hover: #0b5b44;
|
|
38
|
+
--accent-soft: #e7f1ec;
|
|
39
|
+
--accent-border: #bcdccd;
|
|
40
40
|
|
|
41
41
|
/* Semantic */
|
|
42
42
|
--green: #15803d; --green-soft: #ecfdf3; --green-border: #bbf7d0;
|
|
@@ -67,31 +67,38 @@
|
|
|
67
67
|
|
|
68
68
|
@media (prefers-color-scheme: dark) {
|
|
69
69
|
:root:not([data-theme='light']) {
|
|
70
|
-
--bg: #
|
|
71
|
-
--bg-subtle: #
|
|
72
|
-
--bg-sunken: #
|
|
73
|
-
--surface: #
|
|
74
|
-
--surface-hover: #
|
|
75
|
-
--border: #
|
|
76
|
-
--border-strong: #
|
|
77
|
-
--text: #
|
|
78
|
-
--text-secondary: #
|
|
79
|
-
--text-tertiary: #
|
|
80
|
-
--accent: #
|
|
81
|
-
--accent-hover: #
|
|
82
|
-
--accent-soft: #
|
|
83
|
-
--accent-border: #
|
|
70
|
+
--bg: #14130f;
|
|
71
|
+
--bg-subtle: #201d17;
|
|
72
|
+
--bg-sunken: #100f0b;
|
|
73
|
+
--surface: #1b1915;
|
|
74
|
+
--surface-hover: #262219;
|
|
75
|
+
--border: #2e2a22;
|
|
76
|
+
--border-strong: #3e3930;
|
|
77
|
+
--text: #f2eee5;
|
|
78
|
+
--text-secondary: #aaa599;
|
|
79
|
+
--text-tertiary: #79746a;
|
|
80
|
+
--accent: #34d399;
|
|
81
|
+
--accent-hover: #6ee7b7;
|
|
82
|
+
--accent-soft: #11281d;
|
|
83
|
+
--accent-border: #1f4f3c;
|
|
84
|
+
--text-on-accent: #05241a;
|
|
84
85
|
--green-soft:#0e2a1a; --teal-soft:#0c2826; --blue-soft:#11203f; --violet-soft:#1e1638;
|
|
85
86
|
--amber-soft:#2a2008; --red-soft:#2c1414; --slate-soft:#1f242b;
|
|
87
|
+
/* lighter semantic foregrounds so status badges stay legible on dark soft bg */
|
|
88
|
+
--green:#4ade80; --teal:#2dd4bf; --blue:#60a5fa; --violet:#a78bfa; --amber:#fbbf24; --red:#ef4444; --slate:#94a3b8;
|
|
89
|
+
--green-border:#1f4f3c; --teal-border:#155e57; --blue-border:#1e3a6b; --violet-border:#3b2f6b; --amber-border:#5c4410; --red-border:#5c2020; --slate-border:#3a4250;
|
|
86
90
|
--shadow-lg: 0 12px 40px rgba(0,0,0,.5);
|
|
87
91
|
}
|
|
88
92
|
}
|
|
89
93
|
:root[data-theme='dark'] {
|
|
90
|
-
--bg:#
|
|
91
|
-
--border:#
|
|
92
|
-
--accent:#
|
|
94
|
+
--bg:#14130f; --bg-subtle:#201d17; --bg-sunken:#100f0b; --surface:#1b1915; --surface-hover:#262219;
|
|
95
|
+
--border:#2e2a22; --border-strong:#3e3930; --text:#f2eee5; --text-secondary:#aaa599; --text-tertiary:#79746a;
|
|
96
|
+
--accent:#34d399; --accent-hover:#6ee7b7; --accent-soft:#11281d; --accent-border:#1f4f3c; --text-on-accent:#05241a;
|
|
93
97
|
--green-soft:#0e2a1a; --teal-soft:#0c2826; --blue-soft:#11203f; --violet-soft:#1e1638;
|
|
94
|
-
--amber-soft:#2a2008; --red-soft:#2c1414; --slate-soft:#1f242b;
|
|
98
|
+
--amber-soft:#2a2008; --red-soft:#2c1414; --slate-soft:#1f242b;
|
|
99
|
+
--green:#4ade80; --teal:#2dd4bf; --blue:#60a5fa; --violet:#a78bfa; --amber:#fbbf24; --red:#ef4444; --slate:#94a3b8;
|
|
100
|
+
--green-border:#1f4f3c; --teal-border:#155e57; --blue-border:#1e3a6b; --violet-border:#3b2f6b; --amber-border:#5c4410; --red-border:#5c2020; --slate-border:#3a4250;
|
|
101
|
+
--shadow-lg:0 12px 40px rgba(0,0,0,.5);
|
|
95
102
|
}
|
|
96
103
|
|
|
97
104
|
* { box-sizing: border-box; }
|
|
@@ -120,7 +127,7 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
|
|
|
120
127
|
.sidebar__logo {
|
|
121
128
|
width: 30px; height: 30px; border-radius: 8px; flex: none;
|
|
122
129
|
background: var(--accent);
|
|
123
|
-
display: grid; place-items: center; color:
|
|
130
|
+
display: grid; place-items: center; color: var(--text-on-accent); font-weight: 800; font-size: 15px;
|
|
124
131
|
}
|
|
125
132
|
.sidebar__title { font-weight: 700; font-size: 15px; letter-spacing: -.01em; }
|
|
126
133
|
.sidebar__subtitle { font-size: 12px; color: var(--text-tertiary); margin-top: 1px; }
|
|
@@ -135,7 +142,7 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
|
|
|
135
142
|
.nav__item svg { width: 17px; height: 17px; flex: none; }
|
|
136
143
|
.nav__badge { margin-left: auto; font-size: 11.5px; background: var(--bg-sunken); color: var(--text-secondary);
|
|
137
144
|
padding: 1px 7px; border-radius: 999px; font-weight: 600; }
|
|
138
|
-
.nav__item.is-active .nav__badge { background: var(--accent); color:
|
|
145
|
+
.nav__item.is-active .nav__badge { background: var(--accent); color: var(--text-on-accent); }
|
|
139
146
|
.sidebar__foot { padding: 12px 16px; border-top: 1px solid var(--border); font-size: 12px; color: var(--text-tertiary); }
|
|
140
147
|
.sidebar__foot .dot { display:inline-block; width:7px; height:7px; border-radius:999px; background: var(--green); margin-right:6px; }
|
|
141
148
|
|
|
@@ -184,6 +191,12 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
|
|
|
184
191
|
.stat__label svg { width: 15px; height: 15px; color: var(--text-tertiary); }
|
|
185
192
|
.stat__value { font-size: 26px; font-weight: 700; letter-spacing: -.02em; margin-top: 6px; }
|
|
186
193
|
.stat__hint { font-size: 12.5px; color: var(--text-tertiary); margin-top: 2px; }
|
|
194
|
+
/* borderless count strip (settings 내 데이터) — flat counts, never a card-in-card */
|
|
195
|
+
.countstrip { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px 24px; }
|
|
196
|
+
.countstrip__item { display: flex; flex-direction: column; gap: 2px; }
|
|
197
|
+
.countstrip__num { font-size: 24px; font-weight: 700; letter-spacing: -.02em; }
|
|
198
|
+
.countstrip__label { font-size: 13px; color: var(--text-secondary); }
|
|
199
|
+
@media (max-width: 560px){ .countstrip { grid-template-columns: repeat(2, 1fr); } }
|
|
187
200
|
|
|
188
201
|
/* ----------------------------------------------------------------- buttons */
|
|
189
202
|
.btn {
|
|
@@ -195,7 +208,7 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
|
|
|
195
208
|
.btn:hover { background: var(--surface-hover); }
|
|
196
209
|
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
|
197
210
|
.btn svg { width: 15px; height: 15px; }
|
|
198
|
-
.btn--primary { background: var(--accent); border-color: var(--accent); color:
|
|
211
|
+
.btn--primary { background: var(--accent); border-color: var(--accent); color: var(--text-on-accent); }
|
|
199
212
|
.btn--primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
|
200
213
|
.btn--ghost { border-color: transparent; background: transparent; }
|
|
201
214
|
.btn--ghost:hover { background: var(--surface-hover); }
|
package/apps/web/src/info.ts
CHANGED
|
@@ -3,21 +3,18 @@
|
|
|
3
3
|
* endpoint. Lets non-technical users *see* where their data lives, which is a
|
|
4
4
|
* core privacy promise: everything is on this machine.
|
|
5
5
|
*/
|
|
6
|
-
import { getDataDir, getDbPath, getExportsDir, getBackupsDir } from '@careermate/db';
|
|
7
6
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
coverLetterRepo,
|
|
14
|
-
jobRepo,
|
|
15
|
-
applicationRepo,
|
|
16
|
-
interviewRepo,
|
|
7
|
+
getDataDir,
|
|
8
|
+
getDbPath,
|
|
9
|
+
getExportsDir,
|
|
10
|
+
getBackupsDir,
|
|
11
|
+
getEntityCounts,
|
|
17
12
|
} from '@careermate/db';
|
|
18
13
|
|
|
19
14
|
export const APP_NAME = 'CareerMate';
|
|
20
|
-
|
|
15
|
+
// 번들 빌드(esbuild)에서 package.json 버전으로 치환된다. tsx 개발 실행에서는 정의되지 않아 typeof로 안전 분기.
|
|
16
|
+
declare const __APP_VERSION__: string | undefined;
|
|
17
|
+
export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0-dev';
|
|
21
18
|
|
|
22
19
|
let _port = 0;
|
|
23
20
|
export function setServerPort(port: number): void {
|
|
@@ -35,16 +32,6 @@ export function getServerInfo() {
|
|
|
35
32
|
exports_dir: getExportsDir(),
|
|
36
33
|
backups_dir: getBackupsDir(),
|
|
37
34
|
node_version: process.version,
|
|
38
|
-
counts:
|
|
39
|
-
profile: profileRepo.get() ? 1 : 0,
|
|
40
|
-
experiences: experienceRepo.list().length,
|
|
41
|
-
projects: projectRepo.list().length,
|
|
42
|
-
skills: skillRepo.list().length,
|
|
43
|
-
documents: documentRepo.list().length,
|
|
44
|
-
cover_letters: coverLetterRepo.list().length,
|
|
45
|
-
jobs: jobRepo.list().length,
|
|
46
|
-
applications: applicationRepo.list().length,
|
|
47
|
-
interview_preps: interviewRepo.list().length,
|
|
48
|
-
},
|
|
35
|
+
counts: getEntityCounts(),
|
|
49
36
|
};
|
|
50
37
|
}
|
package/apps/web/src/routes.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
DOCUMENT_KIND_LABELS,
|
|
24
24
|
APPLICATION_BOARD_ORDER,
|
|
25
25
|
type DocumentKind,
|
|
26
|
+
type JobInput,
|
|
26
27
|
} from '@careermate/shared';
|
|
27
28
|
import {
|
|
28
29
|
profileRepo,
|
|
@@ -44,6 +45,9 @@ import {
|
|
|
44
45
|
listRecentActivity,
|
|
45
46
|
saveProfile,
|
|
46
47
|
addResume,
|
|
48
|
+
addExperience,
|
|
49
|
+
addProject,
|
|
50
|
+
addSkill,
|
|
47
51
|
saveJobPosting,
|
|
48
52
|
saveFitAnalysis,
|
|
49
53
|
saveCoverLetterVersion,
|
|
@@ -95,9 +99,11 @@ export function registerApiRoutes(router: Router): void {
|
|
|
95
99
|
router.get('/api/health', () => ({ ok: true, ...getServerInfo() }));
|
|
96
100
|
router.get('/api/onboarding', () => getOnboardingStatus());
|
|
97
101
|
router.get('/api/summary', () => getHomeSummary());
|
|
98
|
-
router.get('/api/activity', (ctx) =>
|
|
99
|
-
|
|
100
|
-
|
|
102
|
+
router.get('/api/activity', (ctx) => {
|
|
103
|
+
const raw = Number(ctx.query.get('limit'));
|
|
104
|
+
const limit = Number.isFinite(raw) && raw > 0 ? Math.min(raw, 100) : 30;
|
|
105
|
+
return { activities: listRecentActivity(limit) };
|
|
106
|
+
});
|
|
101
107
|
router.get('/api/context', (ctx) =>
|
|
102
108
|
getApplicationContext({ job_id: ctx.query.get('job_id') ?? undefined }),
|
|
103
109
|
);
|
|
@@ -119,9 +125,7 @@ export function registerApiRoutes(router: Router): void {
|
|
|
119
125
|
router.get('/api/experiences', () => ({ experiences: experienceRepo.list() }));
|
|
120
126
|
router.post('/api/experiences', async (ctx) => {
|
|
121
127
|
const input = await readJsonBody(ctx.req, ExperienceInputSchema);
|
|
122
|
-
|
|
123
|
-
activityRepo.log('profile_updated', `경력(${exp.company})을 추가했습니다.`, 'experience', exp.id);
|
|
124
|
-
return { experience: exp };
|
|
128
|
+
return { experience: addExperience(input) };
|
|
125
129
|
});
|
|
126
130
|
router.put('/api/experiences/:id', async (ctx) => {
|
|
127
131
|
const input = await readJsonBody(ctx.req, ExperienceInputSchema.partial());
|
|
@@ -135,9 +139,7 @@ export function registerApiRoutes(router: Router): void {
|
|
|
135
139
|
router.get('/api/projects', () => ({ projects: projectRepo.list() }));
|
|
136
140
|
router.post('/api/projects', async (ctx) => {
|
|
137
141
|
const input = await readJsonBody(ctx.req, ProjectInputSchema);
|
|
138
|
-
|
|
139
|
-
activityRepo.log('profile_updated', `프로젝트(${prj.name})를 추가했습니다.`, 'project', prj.id);
|
|
140
|
-
return { project: prj };
|
|
142
|
+
return { project: addProject(input) };
|
|
141
143
|
});
|
|
142
144
|
router.put('/api/projects/:id', async (ctx) => {
|
|
143
145
|
const input = await readJsonBody(ctx.req, ProjectInputSchema.partial());
|
|
@@ -151,7 +153,7 @@ export function registerApiRoutes(router: Router): void {
|
|
|
151
153
|
router.get('/api/skills', () => ({ skills: skillRepo.list() }));
|
|
152
154
|
router.post('/api/skills', async (ctx) => {
|
|
153
155
|
const input = await readJsonBody(ctx.req, SkillInputSchema);
|
|
154
|
-
return { skill:
|
|
156
|
+
return { skill: addSkill(input) };
|
|
155
157
|
});
|
|
156
158
|
router.put('/api/skills/:id', async (ctx) => {
|
|
157
159
|
const input = await readJsonBody(ctx.req, SkillInputSchema.partial());
|
|
@@ -239,9 +241,11 @@ export function registerApiRoutes(router: Router): void {
|
|
|
239
241
|
router.get('/api/jobs/:id', (ctx) => ({ job: jobDetail(id(ctx)) }));
|
|
240
242
|
router.put('/api/jobs/:id', async (ctx) => {
|
|
241
243
|
const input = await readJsonBody(ctx.req, JobInputSchema.partial());
|
|
242
|
-
const
|
|
243
|
-
if (!
|
|
244
|
-
|
|
244
|
+
const jid = id(ctx);
|
|
245
|
+
if (!jobRepo.get(jid)) throw new HttpError(404, '공고를 찾을 수 없습니다.');
|
|
246
|
+
// upsert가 id로 기존 레코드와 병합하므로 누락 필드는 기존 값이 유지된다.
|
|
247
|
+
// (존재를 위에서 확인했으므로 partial을 JobInput으로 좁혀 전달.)
|
|
248
|
+
const job = jobRepo.upsert(input as JobInput, jid);
|
|
245
249
|
return { job: jobWithMeta(job) };
|
|
246
250
|
});
|
|
247
251
|
router.delete('/api/jobs/:id', (ctx) => ({ ok: jobRepo.remove(id(ctx)) }));
|
package/apps/web/src/server.ts
CHANGED
|
@@ -77,9 +77,11 @@ export function createServer(): http.Server {
|
|
|
77
77
|
return;
|
|
78
78
|
}
|
|
79
79
|
if (pathname.startsWith('/install/')) {
|
|
80
|
+
// The install page is public static guidance and uses no API/CSRF token,
|
|
81
|
+
// so do not inject the session token there (least privilege).
|
|
80
82
|
const rel = pathname === '/install/' ? 'index.html' : pathname.slice('/install/'.length);
|
|
81
|
-
if (serveStatic(res, INSTALL_ROOT, rel
|
|
82
|
-
if (serveStatic(res, INSTALL_ROOT, 'index.html'
|
|
83
|
+
if (serveStatic(res, INSTALL_ROOT, rel)) return;
|
|
84
|
+
if (serveStatic(res, INSTALL_ROOT, 'index.html')) return;
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
// Dashboard static assets, with SPA fallback to index.html.
|
|
@@ -113,29 +115,35 @@ export function startServer(preferredPort: number): Promise<StartedServer> {
|
|
|
113
115
|
|
|
114
116
|
return new Promise((resolve, reject) => {
|
|
115
117
|
let attempt = 0;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const url = `http://127.0.0.1:${port}`;
|
|
128
|
-
// Publish our address so the MCP server (a separate process) can find us.
|
|
129
|
-
writeRuntimeInfo({ url, port, pid: process.pid, started_at: new Date().toISOString() });
|
|
130
|
-
const cleanup = () => {
|
|
131
|
-
clearRuntimeInfo();
|
|
132
|
-
process.exit(0);
|
|
133
|
-
};
|
|
134
|
-
process.on('SIGINT', cleanup);
|
|
135
|
-
process.on('SIGTERM', cleanup);
|
|
136
|
-
resolve({ server, port, url });
|
|
137
|
-
});
|
|
118
|
+
// Persistent handlers (attached once). Re-calling server.listen() on the same
|
|
119
|
+
// server for retries must NOT pass a one-time 'listening' callback per attempt:
|
|
120
|
+
// those accumulate and all fire on the eventual bind, so the FIRST (wrong) port
|
|
121
|
+
// would win the resolve. Instead read the real bound port from server.address().
|
|
122
|
+
const onError = (e: NodeJS.ErrnoException) => {
|
|
123
|
+
if (e.code === 'EADDRINUSE' && attempt < 15) {
|
|
124
|
+
attempt++;
|
|
125
|
+
server.listen(preferredPort + attempt, '127.0.0.1');
|
|
126
|
+
} else {
|
|
127
|
+
reject(e);
|
|
128
|
+
}
|
|
138
129
|
};
|
|
139
|
-
|
|
130
|
+
server.on('error', onError);
|
|
131
|
+
server.once('listening', () => {
|
|
132
|
+
server.removeListener('error', onError);
|
|
133
|
+
const addr = server.address();
|
|
134
|
+
const port = typeof addr === 'object' && addr ? addr.port : preferredPort;
|
|
135
|
+
setServerPort(port);
|
|
136
|
+
const url = `http://127.0.0.1:${port}`;
|
|
137
|
+
// Publish our address so the MCP server (a separate process) can find us.
|
|
138
|
+
writeRuntimeInfo({ url, port, pid: process.pid, started_at: new Date().toISOString() });
|
|
139
|
+
const cleanup = () => {
|
|
140
|
+
clearRuntimeInfo();
|
|
141
|
+
process.exit(0);
|
|
142
|
+
};
|
|
143
|
+
process.on('SIGINT', cleanup);
|
|
144
|
+
process.on('SIGTERM', cleanup);
|
|
145
|
+
resolve({ server, port, url });
|
|
146
|
+
});
|
|
147
|
+
server.listen(preferredPort, '127.0.0.1');
|
|
140
148
|
});
|
|
141
149
|
}
|
package/apps/web/src/settings.ts
CHANGED
|
@@ -4,28 +4,32 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
7
8
|
import { getDb, getDbPath, getBackupsDir, getDataDir } from '@careermate/db';
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Every user-data table, discovered from the live schema rather than a hand-kept
|
|
12
|
+
* list — so a table added by a future migration is automatically included in
|
|
13
|
+
* export/backup/reset (a stale list would silently skip it, e.g. dropping data
|
|
14
|
+
* on reset that was never backed up). `_meta` (schema_version) is intentionally
|
|
15
|
+
* excluded so reset keeps the migration state. Names come from sqlite_master,
|
|
16
|
+
* not user input, so interpolating them into SQL below is safe.
|
|
17
|
+
*/
|
|
18
|
+
function listTables(db: DatabaseSync): string[] {
|
|
19
|
+
return (
|
|
20
|
+
db
|
|
21
|
+
.prepare(
|
|
22
|
+
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name != '_meta' ORDER BY name`,
|
|
23
|
+
)
|
|
24
|
+
.all() as { name: string }[]
|
|
25
|
+
).map((r) => r.name);
|
|
26
|
+
}
|
|
23
27
|
|
|
24
28
|
/** Full machine-readable dump of every table — portable backup / GDPR-style export. */
|
|
25
29
|
export function exportAll(): { exported_at: string; version: number; tables: Record<string, unknown[]> } {
|
|
26
30
|
const db = getDb();
|
|
27
31
|
const tables: Record<string, unknown[]> = {};
|
|
28
|
-
for (const t of
|
|
32
|
+
for (const t of listTables(db)) {
|
|
29
33
|
tables[t] = db.prepare(`SELECT * FROM ${t}`).all();
|
|
30
34
|
}
|
|
31
35
|
const ver = db.prepare(`SELECT value FROM _meta WHERE key='schema_version'`).get() as
|
|
@@ -40,8 +44,22 @@ export function createBackup(): { backup_path: string; json_path: string } {
|
|
|
40
44
|
const dir = getBackupsDir();
|
|
41
45
|
const dbBackup = path.join(dir, `careermate-${stamp}.sqlite`);
|
|
42
46
|
const jsonBackup = path.join(dir, `careermate-${stamp}.json`);
|
|
43
|
-
|
|
47
|
+
|
|
48
|
+
// Defense in depth: the backup target must stay inside the backups directory,
|
|
49
|
+
// even though the filename is built from a controlled timestamp.
|
|
50
|
+
const resolvedDir = path.resolve(dir);
|
|
51
|
+
if (!path.resolve(dbBackup).startsWith(resolvedDir + path.sep)) {
|
|
52
|
+
throw new Error('백업 경로가 백업 폴더를 벗어났습니다.');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Prefer SQLite's VACUUM INTO for a WAL-consistent snapshot. node:sqlite's
|
|
56
|
+
// exec() can't bind parameters, so the path is embedded as a quoted string
|
|
57
|
+
// literal (single quotes doubled). If the path can't be embedded safely
|
|
58
|
+
// (NUL / newline), fall back to a plain file copy instead of risking a
|
|
59
|
+
// malformed statement.
|
|
60
|
+
const safeForLiteral = !/[\0\n\r]/.test(dbBackup);
|
|
44
61
|
try {
|
|
62
|
+
if (!safeForLiteral) throw new Error('backup path not safe for SQL literal');
|
|
45
63
|
getDb().exec(`VACUUM INTO '${dbBackup.replace(/'/g, "''")}'`);
|
|
46
64
|
} catch {
|
|
47
65
|
fs.copyFileSync(getDbPath(), dbBackup);
|
|
@@ -60,7 +78,7 @@ export function resetAll(confirm: string): { ok: boolean; backup_path?: string }
|
|
|
60
78
|
const db = getDb();
|
|
61
79
|
db.exec('BEGIN');
|
|
62
80
|
try {
|
|
63
|
-
for (const t of
|
|
81
|
+
for (const t of listTables(db)) db.exec(`DELETE FROM ${t}`);
|
|
64
82
|
db.exec('COMMIT');
|
|
65
83
|
} catch (e) {
|
|
66
84
|
db.exec('ROLLBACK');
|
package/bin/careermate.mjs
CHANGED
|
@@ -33,11 +33,14 @@ const BUNDLES = {
|
|
|
33
33
|
mcp: 'dist/mcp.mjs',
|
|
34
34
|
start: 'dist/web.mjs',
|
|
35
35
|
dashboard: 'dist/web.mjs',
|
|
36
|
+
doctor: 'dist/doctor.mjs',
|
|
37
|
+
migrate: 'dist/migrate.mjs',
|
|
36
38
|
};
|
|
37
39
|
|
|
38
40
|
const [cmd = 'help', ...rest] = process.argv.slice(2);
|
|
39
41
|
|
|
40
|
-
|
|
42
|
+
const isHelp = cmd === 'help' || cmd === '--help' || cmd === '-h';
|
|
43
|
+
if (isHelp || !ENTRIES[cmd]) {
|
|
41
44
|
const lines = [
|
|
42
45
|
'CareerMate — 내 AI를 커리어 비서로 (로컬 MCP 도구)',
|
|
43
46
|
'',
|
|
@@ -50,10 +53,10 @@ if (cmd === 'help' || cmd === '--help' || cmd === '-h' || !ENTRIES[cmd]) {
|
|
|
50
53
|
'',
|
|
51
54
|
'처음이라면: careermate init → AI 클라이언트 재시작',
|
|
52
55
|
];
|
|
53
|
-
// 알 수 없는
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
process.exit(
|
|
56
|
+
// 명시적 도움말은 stdout+exit0, 알 수 없는 명령은 stderr+exit1
|
|
57
|
+
// (stdout은 MCP 전송 채널이므로 오류 안내로 오염시키지 않는다).
|
|
58
|
+
(isHelp ? console.log : console.error)(lines.join('\n'));
|
|
59
|
+
process.exit(isHelp ? 0 : 1);
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
const bundle = BUNDLES[cmd] ? path.join(ROOT, BUNDLES[cmd]) : null;
|