careermate 0.1.1 → 0.1.3
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/LICENSE +21 -0
- package/README.md +14 -10
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/apps/mcp/src/index.ts +4 -5
- package/apps/web/demo/demo-shim.js +536 -0
- package/apps/web/demo/demo.css +75 -0
- package/apps/web/demo/seed.js +269 -0
- package/apps/web/public/app.js +34 -2
- package/apps/web/public/index.html +10 -2
- package/apps/web/public/lib.js +134 -22
- package/apps/web/public/pages/applications.js +2 -9
- package/apps/web/public/pages/documents.js +73 -52
- package/apps/web/public/pages/home.js +16 -18
- package/apps/web/public/pages/interview.js +9 -12
- package/apps/web/public/pages/jobs.js +40 -41
- package/apps/web/public/pages/profile.js +69 -89
- package/apps/web/public/pages/settings.js +15 -10
- package/apps/web/public/styles.css +63 -4
- package/apps/web/src/http.ts +31 -4
- package/apps/web/src/info.ts +3 -3
- package/apps/web/src/routes.ts +2 -1
- package/apps/web/src/server.ts +1 -4
- package/dist/doctor.mjs +72715 -697
- package/dist/install-page/analytics.js +97 -0
- package/dist/install-page/index.html +94 -112
- package/dist/install-page/landing.js +54 -0
- package/dist/install-page/og.png +0 -0
- package/dist/install-page/robots.txt +4 -0
- package/dist/install-page/sitemap.xml +21 -0
- package/dist/install-page/start.html +26 -46
- package/dist/install-page/style.css +105 -37
- package/dist/mcp.mjs +74044 -2062
- package/dist/migrate.mjs +110 -90
- package/dist/pdf.worker.min.mjs +28 -0
- package/dist/public/app.js +34 -2
- package/dist/public/index.html +10 -2
- package/dist/public/lib.js +134 -22
- package/dist/public/pages/applications.js +2 -9
- package/dist/public/pages/documents.js +73 -52
- package/dist/public/pages/home.js +16 -18
- package/dist/public/pages/interview.js +9 -12
- package/dist/public/pages/jobs.js +40 -41
- package/dist/public/pages/profile.js +69 -89
- package/dist/public/pages/settings.js +15 -10
- package/dist/public/styles.css +63 -4
- package/dist/web.mjs +39740 -326
- package/package.json +6 -3
- package/packages/db/src/paths.ts +11 -0
- package/packages/db/src/repositories.ts +18 -1
- package/packages/mcp-tools/src/bridge.ts +55 -32
- package/packages/mcp-tools/src/index.ts +9 -0
- package/packages/mcp-tools/src/tools.ts +198 -75
- package/packages/mcp-tools/src/update.ts +330 -0
- package/packages/parsers/src/documents.ts +533 -0
- package/packages/parsers/src/index.ts +8 -5
- package/packages/prompts/src/onboarding.ts +2 -2
- package/packages/shared/src/build-info.ts +22 -0
- package/packages/shared/src/index.ts +1 -0
- package/packages/shared/src/schemas.ts +138 -83
- package/packages/workflows/src/definitions.ts +4 -2
- package/scripts/build-demo.mjs +130 -0
- package/scripts/build-dist.mjs +26 -1
- package/scripts/doctor.ts +16 -3
- package/scripts/gen-og.mjs +1 -1
- package/scripts/test.ts +25 -0
- package/scripts/ui-smoke.ts +32 -4
- package/docs/ARCHITECTURE.md +0 -208
- package/docs/CHANGES_V1.md +0 -103
- package/docs/DATA_MODEL.md +0 -460
- package/docs/DECISIONS.md +0 -277
- package/docs/DEMO.md +0 -242
- package/docs/MCP_TOOLS.md +0 -252
- package/docs/ROADMAP.md +0 -134
- package/docs/RUNTIME_DISTRIBUTION_RESEARCH.md +0 -135
- package/docs/START_WORKFLOW.md +0 -131
- package/docs/SUPPORTED_AI_APPS.md +0 -112
- package/docs/TODO.md +0 -57
- package/docs/UX_NOTES.md +0 -247
- package/docs/WORKFLOWS.md +0 -200
|
@@ -20,6 +20,61 @@ import {
|
|
|
20
20
|
const isoDateTime = z.string();
|
|
21
21
|
const id = z.string();
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Reject script-capable URL schemes (javascript:, data:, vbscript:, file:, …)
|
|
25
|
+
* while allowing http(s), mailto, and scheme-less/relative links. Links are
|
|
26
|
+
* rendered as clickable anchors in the dashboard, so a `javascript:` URL stored
|
|
27
|
+
* here would otherwise execute when clicked (DOM-XSS). The dashboard also
|
|
28
|
+
* sanitizes href/src at render time (lib.js `safeUrl`); this is the write-time
|
|
29
|
+
* half of that defense-in-depth.
|
|
30
|
+
*/
|
|
31
|
+
function isSafeUrl(u: string): boolean {
|
|
32
|
+
const s = u.trim();
|
|
33
|
+
// An explicit scheme (text before the first ':') must be http(s) or mailto.
|
|
34
|
+
// Scheme-less values (relative paths, bare domains, #anchors) cannot execute.
|
|
35
|
+
return !/^[a-z][a-z0-9+.-]*:/i.test(s) || /^(https?:|mailto:)/i.test(s);
|
|
36
|
+
}
|
|
37
|
+
const UNSAFE_URL_MSG = 'javascript: 같은 위험한 링크는 사용할 수 없습니다. http(s) 링크를 사용하세요.';
|
|
38
|
+
const MAX_URL = 2_000;
|
|
39
|
+
const SafeUrl = z.string().min(1).max(MAX_URL).refine(isSafeUrl, { message: UNSAFE_URL_MSG });
|
|
40
|
+
const SafeUrlOptional = z
|
|
41
|
+
.string()
|
|
42
|
+
.max(MAX_URL)
|
|
43
|
+
.optional()
|
|
44
|
+
.refine((u) => u == null || u === '' || isSafeUrl(u), { message: UNSAFE_URL_MSG });
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Length-bounded string helpers. MCP/HTTP inputs are otherwise unbounded, so a
|
|
48
|
+
* malicious or buggy client could send multi-MB strings to exhaust memory/disk.
|
|
49
|
+
* These caps are generous for real résumés/cover letters but reject abuse.
|
|
50
|
+
* - LINE : single-line fields (names, titles, locations)
|
|
51
|
+
* - NOTE : short multi-line fields (summaries, notes)
|
|
52
|
+
* - BODY : full document bodies (resume / cover letter / job description)
|
|
53
|
+
* - ITEM : one array element; ITEMS : array length
|
|
54
|
+
*/
|
|
55
|
+
const MAX_LINE = 500;
|
|
56
|
+
const MAX_NOTE = 10_000;
|
|
57
|
+
const MAX_BODY = 200_000;
|
|
58
|
+
const MAX_ITEM = 2_000;
|
|
59
|
+
const MAX_ITEMS = 500;
|
|
60
|
+
const EMPTY_MSG = '내용이 비어 있습니다(공백만으로는 저장할 수 없습니다).';
|
|
61
|
+
|
|
62
|
+
/** Required single-line value: rejects whitespace-only, trims, bounds length. */
|
|
63
|
+
const reqLine = z.string().trim().min(1).max(MAX_LINE);
|
|
64
|
+
/** Optional single-line value (bounded). */
|
|
65
|
+
const optLine = z.string().max(MAX_LINE).optional();
|
|
66
|
+
/** Optional short multi-line note (bounded). */
|
|
67
|
+
const optNote = z.string().max(MAX_NOTE).optional();
|
|
68
|
+
/** Optional full document body (bounded). */
|
|
69
|
+
const optBody = z.string().max(MAX_BODY).optional();
|
|
70
|
+
/** Required full document body: bounded, rejects whitespace-only (no trim — preserve formatting). */
|
|
71
|
+
const reqBody = z
|
|
72
|
+
.string()
|
|
73
|
+
.max(MAX_BODY)
|
|
74
|
+
.refine((s) => s.trim().length > 0, { message: EMPTY_MSG });
|
|
75
|
+
/** Bounded array of bounded strings. */
|
|
76
|
+
const strList = z.array(z.string().max(MAX_ITEM)).max(MAX_ITEMS);
|
|
77
|
+
|
|
23
78
|
const baseRecord = {
|
|
24
79
|
id,
|
|
25
80
|
created_at: isoDateTime,
|
|
@@ -29,23 +84,23 @@ const baseRecord = {
|
|
|
29
84
|
/* ------------------------------------------------------------------ Profile */
|
|
30
85
|
|
|
31
86
|
export const LinkSchema = z.object({
|
|
32
|
-
label:
|
|
33
|
-
url:
|
|
87
|
+
label: reqLine,
|
|
88
|
+
url: SafeUrl,
|
|
34
89
|
});
|
|
35
90
|
export type Link = z.infer<typeof LinkSchema>;
|
|
36
91
|
|
|
37
92
|
export const ProfileInputSchema = z.object({
|
|
38
|
-
name:
|
|
39
|
-
email:
|
|
40
|
-
phone:
|
|
41
|
-
location:
|
|
42
|
-
headline: z.string().optional().describe('한 줄 소개 / 직무 타이틀'),
|
|
43
|
-
summary: z.string().optional().describe('자기소개 요약'),
|
|
44
|
-
desired_roles:
|
|
45
|
-
desired_conditions: z.string().optional().describe('희망 근무 조건 (연봉, 지역, 근무형태 등)'),
|
|
46
|
-
preferred_tone: z.string().optional().describe('자기소개서 선호 문체 (예: 담백하고 구체적)'),
|
|
47
|
-
emphasis_points:
|
|
48
|
-
links: z.array(LinkSchema).optional().describe('포트폴리오/깃허브/링크드인 등'),
|
|
93
|
+
name: reqLine.optional(),
|
|
94
|
+
email: optLine,
|
|
95
|
+
phone: optLine,
|
|
96
|
+
location: optLine,
|
|
97
|
+
headline: z.string().max(MAX_LINE).optional().describe('한 줄 소개 / 직무 타이틀'),
|
|
98
|
+
summary: z.string().max(MAX_NOTE).optional().describe('자기소개 요약'),
|
|
99
|
+
desired_roles: strList.optional().describe('희망 직무'),
|
|
100
|
+
desired_conditions: z.string().max(MAX_NOTE).optional().describe('희망 근무 조건 (연봉, 지역, 근무형태 등)'),
|
|
101
|
+
preferred_tone: z.string().max(MAX_LINE).optional().describe('자기소개서 선호 문체 (예: 담백하고 구체적)'),
|
|
102
|
+
emphasis_points: strList.optional().describe('강조하고 싶은 핵심 포인트'),
|
|
103
|
+
links: z.array(LinkSchema).max(MAX_ITEMS).optional().describe('포트폴리오/깃허브/링크드인 등'),
|
|
49
104
|
});
|
|
50
105
|
export type ProfileInput = z.infer<typeof ProfileInputSchema>;
|
|
51
106
|
|
|
@@ -68,15 +123,15 @@ export type ProfileRecord = z.infer<typeof ProfileRecordSchema>;
|
|
|
68
123
|
/* --------------------------------------------------------------- Experience */
|
|
69
124
|
|
|
70
125
|
export const ExperienceInputSchema = z.object({
|
|
71
|
-
company:
|
|
72
|
-
role:
|
|
73
|
-
employment_type: z.string().optional().describe('정규직/계약직/인턴/프리랜서 등'),
|
|
74
|
-
start_date: z.string().optional().describe('YYYY-MM 또는 YYYY-MM-DD'),
|
|
75
|
-
end_date: z.string().optional().describe('재직 중이면 비워두기'),
|
|
126
|
+
company: reqLine,
|
|
127
|
+
role: optLine,
|
|
128
|
+
employment_type: z.string().max(MAX_LINE).optional().describe('정규직/계약직/인턴/프리랜서 등'),
|
|
129
|
+
start_date: z.string().max(MAX_LINE).optional().describe('YYYY-MM 또는 YYYY-MM-DD'),
|
|
130
|
+
end_date: z.string().max(MAX_LINE).optional().describe('재직 중이면 비워두기'),
|
|
76
131
|
is_current: z.boolean().optional(),
|
|
77
|
-
description:
|
|
78
|
-
achievements:
|
|
79
|
-
tech:
|
|
132
|
+
description: optBody,
|
|
133
|
+
achievements: strList.optional().describe('성과/업적 (정량 지표 권장)'),
|
|
134
|
+
tech: strList.optional().describe('사용 기술/도구'),
|
|
80
135
|
order_index: z.number().optional(),
|
|
81
136
|
});
|
|
82
137
|
export type ExperienceInput = z.infer<typeof ExperienceInputSchema>;
|
|
@@ -99,14 +154,14 @@ export type ExperienceRecord = z.infer<typeof ExperienceRecordSchema>;
|
|
|
99
154
|
/* ------------------------------------------------------------------ Project */
|
|
100
155
|
|
|
101
156
|
export const ProjectInputSchema = z.object({
|
|
102
|
-
name:
|
|
103
|
-
role:
|
|
104
|
-
description:
|
|
105
|
-
highlights:
|
|
106
|
-
tech:
|
|
107
|
-
url:
|
|
108
|
-
start_date:
|
|
109
|
-
end_date:
|
|
157
|
+
name: reqLine,
|
|
158
|
+
role: optLine,
|
|
159
|
+
description: optBody,
|
|
160
|
+
highlights: strList.optional(),
|
|
161
|
+
tech: strList.optional(),
|
|
162
|
+
url: SafeUrlOptional,
|
|
163
|
+
start_date: optLine,
|
|
164
|
+
end_date: optLine,
|
|
110
165
|
order_index: z.number().optional(),
|
|
111
166
|
});
|
|
112
167
|
export type ProjectInput = z.infer<typeof ProjectInputSchema>;
|
|
@@ -128,9 +183,9 @@ export type ProjectRecord = z.infer<typeof ProjectRecordSchema>;
|
|
|
128
183
|
/* -------------------------------------------------------------------- Skill */
|
|
129
184
|
|
|
130
185
|
export const SkillInputSchema = z.object({
|
|
131
|
-
name:
|
|
132
|
-
category: z.string().optional().describe('언어/프레임워크/툴/소프트스킬 등'),
|
|
133
|
-
level: z.string().optional().describe('상/중/하 또는 자유 서술'),
|
|
186
|
+
name: reqLine,
|
|
187
|
+
category: z.string().max(MAX_LINE).optional().describe('언어/프레임워크/툴/소프트스킬 등'),
|
|
188
|
+
level: z.string().max(MAX_LINE).optional().describe('상/중/하 또는 자유 서술'),
|
|
134
189
|
years: z.number().optional(),
|
|
135
190
|
order_index: z.number().optional(),
|
|
136
191
|
});
|
|
@@ -150,11 +205,11 @@ export type SkillRecord = z.infer<typeof SkillRecordSchema>;
|
|
|
150
205
|
|
|
151
206
|
export const DocumentInputSchema = z.object({
|
|
152
207
|
kind: z.enum(DOCUMENT_KINDS),
|
|
153
|
-
title:
|
|
154
|
-
content:
|
|
208
|
+
title: reqLine,
|
|
209
|
+
content: reqBody.describe('Markdown 또는 일반 텍스트 본문'),
|
|
155
210
|
source: z.enum(CONTENT_SOURCES).optional(),
|
|
156
211
|
is_primary: z.boolean().optional().describe('대표 문서 여부'),
|
|
157
|
-
tags:
|
|
212
|
+
tags: strList.optional(),
|
|
158
213
|
});
|
|
159
214
|
export type DocumentInput = z.infer<typeof DocumentInputSchema>;
|
|
160
215
|
|
|
@@ -172,21 +227,21 @@ export type DocumentRecord = z.infer<typeof DocumentRecordSchema>;
|
|
|
172
227
|
/* -------------------------------------------------------------- CoverLetter */
|
|
173
228
|
|
|
174
229
|
export const CoverLetterInputSchema = z.object({
|
|
175
|
-
title:
|
|
176
|
-
job_id:
|
|
230
|
+
title: reqLine,
|
|
231
|
+
job_id: optLine.describe('특정 공고에 연결할 경우'),
|
|
177
232
|
is_primary: z.boolean().optional(),
|
|
178
|
-
content:
|
|
233
|
+
content: optBody.describe('초기 버전 본문 (있으면 v1로 저장)'),
|
|
179
234
|
source: z.enum(CONTENT_SOURCES).optional(),
|
|
180
|
-
note:
|
|
235
|
+
note: optNote.describe('초기 버전 메모'),
|
|
181
236
|
});
|
|
182
237
|
export type CoverLetterInput = z.infer<typeof CoverLetterInputSchema>;
|
|
183
238
|
|
|
184
239
|
export const CoverLetterVersionInputSchema = z.object({
|
|
185
|
-
cover_letter_id:
|
|
186
|
-
title: z.string().optional().describe('새 자기소개서를 만들 때 제목'),
|
|
187
|
-
job_id:
|
|
188
|
-
content:
|
|
189
|
-
note:
|
|
240
|
+
cover_letter_id: optLine.describe('기존 자기소개서에 새 버전 추가 시'),
|
|
241
|
+
title: z.string().max(MAX_LINE).optional().describe('새 자기소개서를 만들 때 제목'),
|
|
242
|
+
job_id: optLine,
|
|
243
|
+
content: reqBody,
|
|
244
|
+
note: optNote.describe('이 버전의 변경 요약 (예: 지원동기 보강)'),
|
|
190
245
|
source: z.enum(CONTENT_SOURCES).optional(),
|
|
191
246
|
set_current: z.boolean().optional().describe('이 버전을 현재 버전으로 지정 (기본 true)'),
|
|
192
247
|
});
|
|
@@ -218,16 +273,16 @@ export type CoverLetterRecord = z.infer<typeof CoverLetterRecordSchema>;
|
|
|
218
273
|
/* --------------------------------------------------------------------- Job */
|
|
219
274
|
|
|
220
275
|
export const JobInputSchema = z.object({
|
|
221
|
-
company:
|
|
222
|
-
position:
|
|
223
|
-
url:
|
|
224
|
-
location:
|
|
225
|
-
employment_type:
|
|
226
|
-
description:
|
|
227
|
-
requirements:
|
|
228
|
-
keywords:
|
|
229
|
-
deadline: z.string().optional().describe('마감일 YYYY-MM-DD'),
|
|
230
|
-
source: z.string().optional().describe('출처 (사람인/원티드/직접 입력 등)'),
|
|
276
|
+
company: reqLine,
|
|
277
|
+
position: reqLine.describe('직무/포지션명'),
|
|
278
|
+
url: SafeUrlOptional,
|
|
279
|
+
location: optLine,
|
|
280
|
+
employment_type: optLine,
|
|
281
|
+
description: optBody.describe('공고 원문 또는 정리된 텍스트'),
|
|
282
|
+
requirements: strList.optional().describe('자격요건/우대사항 핵심'),
|
|
283
|
+
keywords: strList.optional().describe('핵심 키워드'),
|
|
284
|
+
deadline: z.string().max(MAX_LINE).optional().describe('마감일 YYYY-MM-DD'),
|
|
285
|
+
source: z.string().max(MAX_LINE).optional().describe('출처 (사람인/원티드/직접 입력 등)'),
|
|
231
286
|
});
|
|
232
287
|
export type JobInput = z.infer<typeof JobInputSchema>;
|
|
233
288
|
|
|
@@ -249,14 +304,14 @@ export type JobRecord = z.infer<typeof JobRecordSchema>;
|
|
|
249
304
|
/* ------------------------------------------------------------- FitAnalysis */
|
|
250
305
|
|
|
251
306
|
export const FitAnalysisInputSchema = z.object({
|
|
252
|
-
job_id:
|
|
307
|
+
job_id: reqLine.describe('분석 대상 공고 ID'),
|
|
253
308
|
score: z.number().min(0).max(100).optional().describe('종합 적합도 0~100'),
|
|
254
|
-
summary: z.string().optional().describe('한두 문단 요약'),
|
|
255
|
-
strengths:
|
|
256
|
-
gaps:
|
|
257
|
-
matched_keywords:
|
|
258
|
-
missing_keywords:
|
|
259
|
-
recommendations:
|
|
309
|
+
summary: z.string().max(MAX_NOTE).optional().describe('한두 문단 요약'),
|
|
310
|
+
strengths: strList.optional().describe('강점 / 잘 맞는 부분'),
|
|
311
|
+
gaps: strList.optional().describe('부족한 부분 / 보완 필요'),
|
|
312
|
+
matched_keywords: strList.optional(),
|
|
313
|
+
missing_keywords: strList.optional(),
|
|
314
|
+
recommendations: strList.optional().describe('자기소개서/지원 전략 제안'),
|
|
260
315
|
});
|
|
261
316
|
export type FitAnalysisInput = z.infer<typeof FitAnalysisInputSchema>;
|
|
262
317
|
|
|
@@ -276,20 +331,20 @@ export type FitAnalysisRecord = z.infer<typeof FitAnalysisRecordSchema>;
|
|
|
276
331
|
/* ------------------------------------------------------------- Application */
|
|
277
332
|
|
|
278
333
|
export const ApplicationInputSchema = z.object({
|
|
279
|
-
job_id:
|
|
334
|
+
job_id: reqLine,
|
|
280
335
|
status: z.enum(APPLICATION_STATUSES).optional(),
|
|
281
|
-
resume_id:
|
|
282
|
-
cover_letter_id:
|
|
283
|
-
applied_at:
|
|
284
|
-
notes:
|
|
336
|
+
resume_id: optLine,
|
|
337
|
+
cover_letter_id: optLine,
|
|
338
|
+
applied_at: optLine,
|
|
339
|
+
notes: optNote,
|
|
285
340
|
});
|
|
286
341
|
export type ApplicationInput = z.infer<typeof ApplicationInputSchema>;
|
|
287
342
|
|
|
288
343
|
export const ApplicationStatusUpdateSchema = z.object({
|
|
289
|
-
job_id:
|
|
290
|
-
application_id:
|
|
344
|
+
job_id: optLine,
|
|
345
|
+
application_id: optLine,
|
|
291
346
|
status: z.enum(APPLICATION_STATUSES),
|
|
292
|
-
note:
|
|
347
|
+
note: optNote.describe('상태 변경 사유/메모'),
|
|
293
348
|
});
|
|
294
349
|
export type ApplicationStatusUpdate = z.infer<typeof ApplicationStatusUpdateSchema>;
|
|
295
350
|
|
|
@@ -307,28 +362,28 @@ export type ApplicationRecord = z.infer<typeof ApplicationRecordSchema>;
|
|
|
307
362
|
/* ----------------------------------------------------------- InterviewPrep */
|
|
308
363
|
|
|
309
364
|
export const StarGuideSchema = z.object({
|
|
310
|
-
question: z.string(),
|
|
311
|
-
situation:
|
|
312
|
-
task:
|
|
313
|
-
action:
|
|
314
|
-
result:
|
|
365
|
+
question: z.string().max(MAX_NOTE),
|
|
366
|
+
situation: optNote,
|
|
367
|
+
task: optNote,
|
|
368
|
+
action: optNote,
|
|
369
|
+
result: optNote,
|
|
315
370
|
});
|
|
316
371
|
export type StarGuide = z.infer<typeof StarGuideSchema>;
|
|
317
372
|
|
|
318
373
|
export const InterviewQuestionSchema = z.object({
|
|
319
|
-
question: z.string(),
|
|
320
|
-
intent: z.string().optional().describe('질문 의도'),
|
|
321
|
-
followups:
|
|
322
|
-
answer_outline:
|
|
374
|
+
question: z.string().max(MAX_NOTE),
|
|
375
|
+
intent: z.string().max(MAX_NOTE).optional().describe('질문 의도'),
|
|
376
|
+
followups: strList.optional().describe('예상 꼬리 질문'),
|
|
377
|
+
answer_outline: optBody.describe('답변 가이드/핵심 포인트'),
|
|
323
378
|
});
|
|
324
379
|
export type InterviewQuestion = z.infer<typeof InterviewQuestionSchema>;
|
|
325
380
|
|
|
326
381
|
export const InterviewPrepInputSchema = z.object({
|
|
327
|
-
job_id:
|
|
328
|
-
questions: z.array(InterviewQuestionSchema).optional(),
|
|
329
|
-
star_guides: z.array(StarGuideSchema).optional(),
|
|
330
|
-
self_introduction:
|
|
331
|
-
notes:
|
|
382
|
+
job_id: reqLine,
|
|
383
|
+
questions: z.array(InterviewQuestionSchema).max(MAX_ITEMS).optional(),
|
|
384
|
+
star_guides: z.array(StarGuideSchema).max(MAX_ITEMS).optional(),
|
|
385
|
+
self_introduction: optBody.describe('1분 자기소개 초안'),
|
|
386
|
+
notes: optNote.describe('면접 후기/메모'),
|
|
332
387
|
});
|
|
333
388
|
export type InterviewPrepInput = z.infer<typeof InterviewPrepInputSchema>;
|
|
334
389
|
|
|
@@ -29,10 +29,12 @@ export const WORKFLOWS: WorkflowDefinition[] = [
|
|
|
29
29
|
'사용자가 처음 CareerMate에 연결했거나, "시작하자 / 셋업해줘 / 프로필 등록"이라고 요청할 때.',
|
|
30
30
|
steps: [
|
|
31
31
|
'`get_onboarding_status`를 호출해 현재 등록 상태(has_profile, has_resume, has_cover_letter, has_experience, has_skills, profile_completeness, next_steps)를 확인한다.',
|
|
32
|
-
'비어 있는 항목을 사용자에게 쉬운 한국어로 안내하고,
|
|
32
|
+
'비어 있는 항목을 사용자에게 쉬운 한국어로 안내하고, 먼저 "기존에 작성해 둔 이력서·경력기술서 파일이 있는지" 묻는다(hwp/docx/pdf 모두 가능).',
|
|
33
|
+
'파일 입력 분기: ① 사용자가 "파일이 있다"고 하면 `open_inbox`로 인입 폴더를 열어 파일을 넣게 하고, "다 넣었다"고 하면 `read_inbox`로 본문을 읽는다(pdf·이미지는 read_inbox가 경로만 주므로 클라이언트의 파일 읽기로 직접 읽는다). ② 사용자가 파일 경로를 직접 알려주면 `read_document`로 읽는다. ③ "없다/직접 입력/텍스트로 붙여넣기"면 폴더를 열지 말고 받은 텍스트를 그대로 쓴다. (묻지도 않고 open_inbox로 폴더를 자동으로 열지 않는다.)',
|
|
33
34
|
'수집한 정보를 구조화해 `save_profile`로 저장한다(이름·연락처·headline·summary·desired_roles·desired_conditions, 그리고 글쓰기 선호인 preferred_tone과 emphasis_points 포함). 파일 출처는 source=upload, 직접 입력은 manual.',
|
|
34
35
|
'업로드한 이력서/경력기술서/포트폴리오 본문을 `add_resume`로 저장한다(kind·title 지정, 대표 문서는 is_primary=true).',
|
|
35
|
-
'
|
|
36
|
+
'경력·프로젝트·스킬이 파악되면 `add_experience`·`add_project`·`add_skill`로 각각 저장한다(완성도의 has_experience/has_skills에 반영).',
|
|
37
|
+
'기존 자기소개서가 있으면 `save_cover_letter_version`으로 저장한다(title+content면 v1로 저장, 없으면 나중에 작성 가능함을 안내).',
|
|
36
38
|
'`open_dashboard`를 호출해 사용자가 자기 데이터를 확인하게 한다.',
|
|
37
39
|
'다시 `get_onboarding_status`로 완성도를 확인하고, 다음 단계(공고 분석 등)를 제안한다.',
|
|
38
40
|
],
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// build-demo.mjs — generate the no-server demo dashboard served at
|
|
3
|
+
// careermate.life/demo.
|
|
4
|
+
//
|
|
5
|
+
// What it does:
|
|
6
|
+
// 1. Copy apps/web/public/* -> install-page/demo/
|
|
7
|
+
// 2. Rewrite root-absolute asset paths (/lib.js, /pages/, /styles.css, ...)
|
|
8
|
+
// to /demo/... so they resolve under the subpath. (/api/* is left alone —
|
|
9
|
+
// the shim intercepts those at runtime.)
|
|
10
|
+
// 3. Copy the demo overlay (demo-shim.js, seed.js, demo.css) into install-page/demo/.
|
|
11
|
+
// 4. Inject the shim + demo.css into the copied index.html, and a hint that
|
|
12
|
+
// this is a demo build.
|
|
13
|
+
//
|
|
14
|
+
// The generated install-page/demo/ folder is committed so Vercel (which serves
|
|
15
|
+
// install-page/ statically) publishes it with no build step or config change.
|
|
16
|
+
//
|
|
17
|
+
// Run: node scripts/build-demo.mjs
|
|
18
|
+
//
|
|
19
|
+
// Note: avoids fs.rmSync(recursive) — it hard-crashes on Node 25 / Windows
|
|
20
|
+
// (see project memory). Uses a manual recursive remove instead.
|
|
21
|
+
// =============================================================================
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
28
|
+
const SRC = path.join(ROOT, 'apps', 'web', 'public');
|
|
29
|
+
const OVERLAY = path.join(ROOT, 'apps', 'web', 'demo');
|
|
30
|
+
const OUT = path.join(ROOT, 'install-page', 'demo');
|
|
31
|
+
|
|
32
|
+
const TEXT_EXT = new Set(['.html', '.js', '.css', '.svg', '.json', '.txt', '.md']);
|
|
33
|
+
|
|
34
|
+
/** Manual recursive delete (Node-25-safe; fs.rmSync recursive crashes there). */
|
|
35
|
+
function rimraf(target) {
|
|
36
|
+
let st;
|
|
37
|
+
try { st = fs.lstatSync(target); } catch { return; }
|
|
38
|
+
if (st.isDirectory()) {
|
|
39
|
+
for (const entry of fs.readdirSync(target)) rimraf(path.join(target, entry));
|
|
40
|
+
fs.rmdirSync(target);
|
|
41
|
+
} else {
|
|
42
|
+
fs.unlinkSync(target);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Rewrite root-absolute references to assets so they work under /demo/. */
|
|
47
|
+
function rewritePaths(code) {
|
|
48
|
+
return code
|
|
49
|
+
// import/href/src strings: '/lib.js', "/app.js", '/styles.css', '/icon-180.png'
|
|
50
|
+
.replace(/(['"`])\/(lib\.js|app\.js|styles\.css|icon-180\.png)/g, '$1/demo/$2')
|
|
51
|
+
// directories referenced as '/pages/...' or '/fonts/...' (JS/HTML)
|
|
52
|
+
.replace(/(['"`])\/(pages|fonts)\//g, '$1/demo/$2/')
|
|
53
|
+
// CSS url(/fonts/...) — with or without quotes
|
|
54
|
+
.replace(/url\(\s*(['"]?)\/fonts\//g, 'url($1/demo/fonts/');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function copyTree(src, out) {
|
|
58
|
+
fs.mkdirSync(out, { recursive: true });
|
|
59
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
60
|
+
const from = path.join(src, entry.name);
|
|
61
|
+
const to = path.join(out, entry.name);
|
|
62
|
+
if (entry.isDirectory()) { copyTree(from, to); continue; }
|
|
63
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
64
|
+
if (TEXT_EXT.has(ext)) {
|
|
65
|
+
fs.writeFileSync(to, rewritePaths(fs.readFileSync(from, 'utf8')));
|
|
66
|
+
} else {
|
|
67
|
+
fs.copyFileSync(from, to);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function injectDemo(htmlPath) {
|
|
73
|
+
let html = fs.readFileSync(htmlPath, 'utf8');
|
|
74
|
+
|
|
75
|
+
// Pre-paint theme resolver — same key/default as the marketing site so the
|
|
76
|
+
// theme is shared and there's no flash (FOUC) before app.js runs.
|
|
77
|
+
const themeScript =
|
|
78
|
+
'\n <!-- Resolve the saved theme before first paint (shared with the marketing site; default dark). -->\n' +
|
|
79
|
+
' <script>\n' +
|
|
80
|
+
' (function () {\n' +
|
|
81
|
+
" try {\n" +
|
|
82
|
+
" var t = window.localStorage.getItem('careermate-theme') || window.localStorage.getItem('cf-theme');\n" +
|
|
83
|
+
" document.documentElement.setAttribute('data-theme', t === 'light' || t === 'dark' ? t : 'dark');\n" +
|
|
84
|
+
" } catch (e) { document.documentElement.setAttribute('data-theme', 'dark'); }\n" +
|
|
85
|
+
' })();\n' +
|
|
86
|
+
' </script>';
|
|
87
|
+
html = html.replace(
|
|
88
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1" />',
|
|
89
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1" />' + themeScript,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// demo.css after the dashboard stylesheet
|
|
93
|
+
html = html.replace(
|
|
94
|
+
'<link rel="stylesheet" href="/demo/styles.css" />',
|
|
95
|
+
'<link rel="stylesheet" href="/demo/styles.css" />\n <link rel="stylesheet" href="/demo/demo.css" />',
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Shim must run before app.js. Both are ES modules; module scripts execute in
|
|
99
|
+
// document order, so placing the shim immediately before app.js guarantees the
|
|
100
|
+
// fetch patch is installed before any page code calls it.
|
|
101
|
+
html = html.replace(
|
|
102
|
+
'<script type="module" src="/demo/app.js"></script>',
|
|
103
|
+
'<script type="module" src="/demo/demo-shim.js"></script>\n <script type="module" src="/demo/app.js"></script>',
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Title hint so the browser tab reads as a demo.
|
|
107
|
+
html = html.replace(
|
|
108
|
+
'<title>CareerMate — 내 커리어 흐름 관리</title>',
|
|
109
|
+
'<title>CareerMate 데모 — 대시보드 미리보기</title>',
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
fs.writeFileSync(htmlPath, html);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* ------------------------------------------------------------------- run */
|
|
116
|
+
console.log('· clearing', path.relative(ROOT, OUT));
|
|
117
|
+
rimraf(OUT);
|
|
118
|
+
|
|
119
|
+
console.log('· copying dashboard', path.relative(ROOT, SRC), '->', path.relative(ROOT, OUT));
|
|
120
|
+
copyTree(SRC, OUT);
|
|
121
|
+
|
|
122
|
+
console.log('· copying demo overlay');
|
|
123
|
+
for (const f of ['demo-shim.js', 'seed.js', 'demo.css']) {
|
|
124
|
+
fs.copyFileSync(path.join(OVERLAY, f), path.join(OUT, f));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log('· injecting shim + styles into index.html');
|
|
128
|
+
injectDemo(path.join(OUT, 'index.html'));
|
|
129
|
+
|
|
130
|
+
console.log('✓ demo built at', path.relative(ROOT, OUT), '— serve install-page/ and open /demo');
|
package/scripts/build-dist.mjs
CHANGED
|
@@ -67,11 +67,36 @@ async function main() {
|
|
|
67
67
|
// install-page/careermate.mcpb 는 공개 사이트(careermate.life)의 다운로드 산출물일 뿐,
|
|
68
68
|
// 번들 대시보드의 /install/ 라우트에는 필요 없다. 이걸 dist로 들이면 .mcpb 빌드가 직전
|
|
69
69
|
// 번들(자기 자신, ~6MB)을 다시 접어 넣어 매 빌드마다 용량이 불어난다(자기참조). 제외한다.
|
|
70
|
+
// install-page/demo 는 공개 사이트(careermate.life/demo) 전용 미리보기다. 자산 경로가
|
|
71
|
+
// /demo/ 로 재작성돼 있어 번들 대시보드의 /install/ 라우트에서는 동작하지 않고, 폰트까지
|
|
72
|
+
// 포함해 ~2.5MB를 헛되이 키운다. dist에서 제외한다.
|
|
73
|
+
const demoDir = path.join(ROOT, 'install-page', 'demo');
|
|
70
74
|
fs.cpSync(path.join(ROOT, 'install-page'), path.join(DIST, 'install-page'), {
|
|
71
75
|
recursive: true,
|
|
72
|
-
filter: (src) =>
|
|
76
|
+
filter: (src) => {
|
|
77
|
+
const base = path.basename(src);
|
|
78
|
+
// OMC/OMX 오케스트레이션 상태(세션ID·리플레이 로그)는 사용자에게 불필요하고
|
|
79
|
+
// 게시 tarball로 새면 안 된다. install-page에 남아 있어도 dist→npm에는 제외한다.
|
|
80
|
+
if (base === '.omc' || base === '.omx') return false;
|
|
81
|
+
return base !== 'careermate.mcpb' && src !== demoDir;
|
|
82
|
+
},
|
|
73
83
|
});
|
|
74
84
|
|
|
85
|
+
// pdfjs 워커 사이드카: pdf.mjs는 번들에 인라인되지만, 워커는 런타임에 별도 파일로
|
|
86
|
+
// 동적 로드된다(메인스레드 fake worker). 번들 옆에 복사해 두고 documents.ts가
|
|
87
|
+
// workerSrc로 가리킨다. 이게 없으면 PDF 추출 시 "fake worker" 로드가 실패한다.
|
|
88
|
+
// (node_modules가 없는 .mcpb 배포에서도 PDF가 동작하도록 dist에 자기완결로 동봉.)
|
|
89
|
+
const pdfWorker = path.join(ROOT, 'node_modules', 'pdfjs-dist', 'legacy', 'build', 'pdf.worker.min.mjs');
|
|
90
|
+
if (!fs.existsSync(pdfWorker)) {
|
|
91
|
+
// 경고로 넘기면 워커 없는 번들이 조용히 게시되고 런타임에 모든 PDF 추출이 깨진다(빌드는 초록).
|
|
92
|
+
// 빌드를 실패시켜 누락을 게시 전에 드러낸다. pdfjs 업그레이드로 워커 경로가 바뀌면 여기서 잡힌다.
|
|
93
|
+
throw new Error(
|
|
94
|
+
`pdfjs 워커를 찾지 못했습니다: ${pdfWorker}\n` +
|
|
95
|
+
`pdfjs-dist가 설치됐는지, 워커 파일명/경로가 바뀌지 않았는지 확인하세요(PDF 추출의 필수 자산).`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
fs.copyFileSync(pdfWorker, path.join(DIST, 'pdf.worker.min.mjs'));
|
|
99
|
+
|
|
75
100
|
console.log('✅ dist 빌드 완료: dist/mcp.mjs, dist/web.mjs, dist/doctor.mjs, dist/migrate.mjs, dist/public, dist/install-page');
|
|
76
101
|
}
|
|
77
102
|
|
package/scripts/doctor.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
profileRepo, experienceRepo, projectRepo, skillRepo, documentRepo,
|
|
11
11
|
coverLetterRepo, jobRepo, applicationRepo, interviewRepo,
|
|
12
12
|
} from '@careermate/db';
|
|
13
|
-
import { TOOLS } from '@careermate/mcp-tools';
|
|
13
|
+
import { TOOLS, getUpdateStatusAsync } from '@careermate/mcp-tools';
|
|
14
14
|
|
|
15
15
|
const ok = (s: string) => console.log(` ✅ ${s}`);
|
|
16
16
|
const warn = (s: string) => console.log(` ⚠️ ${s}`);
|
|
@@ -24,7 +24,7 @@ function checkNode(): boolean {
|
|
|
24
24
|
return good;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
function main(): void {
|
|
27
|
+
async function main(): Promise<void> {
|
|
28
28
|
console.log('\nCareerMate 상태 점검\n' + '─'.repeat(40));
|
|
29
29
|
|
|
30
30
|
const nodeOk = checkNode();
|
|
@@ -68,6 +68,16 @@ function main(): void {
|
|
|
68
68
|
// MCP tools
|
|
69
69
|
ok(`MCP 도구 ${TOOLS.length}개 등록됨`);
|
|
70
70
|
|
|
71
|
+
// Update available?
|
|
72
|
+
try {
|
|
73
|
+
const u = await getUpdateStatusAsync();
|
|
74
|
+
if (u.latest === null) info(`버전 v${u.current} (업데이트 확인 생략 — 오프라인이거나 일시적 오류)`);
|
|
75
|
+
else if (u.update_available) warn(`새 버전 v${u.latest} 있음 (현재 v${u.current}). 업데이트: ${u.update_command}`);
|
|
76
|
+
else ok(`최신 버전 v${u.current}`);
|
|
77
|
+
} catch {
|
|
78
|
+
/* 업데이트 확인 실패는 점검을 막지 않는다. */
|
|
79
|
+
}
|
|
80
|
+
|
|
71
81
|
// Dashboard running?
|
|
72
82
|
const rt = readRuntimeInfo();
|
|
73
83
|
if (rt && isProcessAlive(rt.pid)) ok(`대시보드 실행 중: ${rt.url}`);
|
|
@@ -78,4 +88,7 @@ function main(): void {
|
|
|
78
88
|
process.exit(nodeOk && dbOk ? 0 : 1);
|
|
79
89
|
}
|
|
80
90
|
|
|
81
|
-
main()
|
|
91
|
+
main().catch((e) => {
|
|
92
|
+
console.error(e instanceof Error ? e.message : e);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
});
|
package/scripts/gen-og.mjs
CHANGED
|
@@ -46,7 +46,7 @@ const ogHtml = `<!doctype html><meta charset="utf-8"><style>
|
|
|
46
46
|
<div class="h">내 <span class="accent">AI</span>를<br>나만의 <span class="accent">커리어 비서</span>로</div>
|
|
47
47
|
<div class="sub">이력서·자기소개서·지원 현황을 이 컴퓨터 안에서 관리하세요.<br>분석·글쓰기는 평소 쓰던 AI가, 데이터 보관은 CareerMate가.</div>
|
|
48
48
|
</div>
|
|
49
|
-
<div class="pill"><span class="dot"></span>로컬 전용 · MCP ·
|
|
49
|
+
<div class="pill"><span class="dot"></span>로컬 전용 · MCP · 무료</div>
|
|
50
50
|
</div>`;
|
|
51
51
|
|
|
52
52
|
const iconHtml = (size) => `<!doctype html><meta charset="utf-8"><style>
|
package/scripts/test.ts
CHANGED
|
@@ -107,6 +107,31 @@ ok('MCP가 API의 수정(한 줄 소개)을 즉시 봄 (←)', pr.profileHeadlin
|
|
|
107
107
|
ok('MCP save_job_posting', !!pr.jobId);
|
|
108
108
|
ok('대시보드(API)가 MCP가 저장한 공고를 봄 (→)', (await json('GET', '/api/jobs')).jobs.some((j: any) => j.id === pr.jobId));
|
|
109
109
|
|
|
110
|
+
/* --------------------------------------------- 5. 내보내기 HTML 인젝션 차단 (회귀) */
|
|
111
|
+
// Cover-letter/resume bodies are user/agent text rendered into exported .html
|
|
112
|
+
// (opened in a browser → live XSS surface). markdownToHtml MUST neutralize raw
|
|
113
|
+
// HTML and dangerous link schemes. These assertions lock that property so a
|
|
114
|
+
// future refactor of the markdown renderer can't silently reintroduce XSS.
|
|
115
|
+
section('5) 내보내기 HTML 인젝션 차단 (회귀)');
|
|
116
|
+
const { markdownToHtml } = await import('../packages/exporters/src/markdown.ts');
|
|
117
|
+
const { toPrintableHtml } = await import('../packages/exporters/src/html.ts');
|
|
118
|
+
|
|
119
|
+
const imgPayload = markdownToHtml('<img src=x onerror="alert(1)">');
|
|
120
|
+
ok('raw <img onerror> 무력화', !imgPayload.includes('<img') && imgPayload.includes('<img') && !imgPayload.includes('onerror="alert'));
|
|
121
|
+
|
|
122
|
+
const scriptPayload = markdownToHtml('<script>alert(1)</script>');
|
|
123
|
+
ok('raw <script> 무력화', !scriptPayload.includes('<script>') && scriptPayload.includes('<script>'));
|
|
124
|
+
|
|
125
|
+
const jsLink = markdownToHtml('[클릭](javascript:alert(1))');
|
|
126
|
+
ok('javascript: 링크 차단(→ #)', jsLink.includes('href="#"') && !jsLink.toLowerCase().includes('javascript:'));
|
|
127
|
+
|
|
128
|
+
// Attribute-breakout attempt: a quote inside an otherwise-valid https URL.
|
|
129
|
+
const breakout = markdownToHtml('[x](https://a.com" onmouseover="alert(1))');
|
|
130
|
+
ok('링크 속성 탈출 차단', !breakout.includes('onmouseover="alert') && breakout.includes('"'));
|
|
131
|
+
|
|
132
|
+
const titleInj = toPrintableHtml('</title><script>alert(1)</script>', '본문');
|
|
133
|
+
ok('내보내기 제목 인젝션 차단', !titleInj.includes('<script>') && !titleInj.includes('</title><script>'));
|
|
134
|
+
|
|
110
135
|
console.log(`\n${'='.repeat(40)}`);
|
|
111
136
|
console.log(`결과: ${pass} 통과 · ${fail} 실패`);
|
|
112
137
|
console.log(fail === 0 ? '✅ 전체 통과' : '❌ 실패 있음');
|