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.
Files changed (93) hide show
  1. package/README.md +10 -7
  2. package/apps/mcp/src/index.ts +3 -1
  3. package/apps/web/DESIGN_GUIDE.md +2 -2
  4. package/apps/web/public/icon-180.png +0 -0
  5. package/apps/web/public/index.html +4 -1
  6. package/apps/web/public/lib.js +1 -1
  7. package/apps/web/public/pages/applications.js +17 -1
  8. package/apps/web/public/pages/settings.js +6 -4
  9. package/apps/web/public/styles.css +49 -36
  10. package/apps/web/src/info.ts +9 -22
  11. package/apps/web/src/routes.ts +17 -13
  12. package/apps/web/src/server.ts +33 -25
  13. package/apps/web/src/settings.ts +35 -17
  14. package/bin/careermate.mjs +8 -5
  15. package/dist/{mcpb-stage/dist/web.mjs โ†’ doctor.mjs} +1122 -1450
  16. package/dist/install-page/favicon.svg +1 -0
  17. package/dist/install-page/icon-180.png +0 -0
  18. package/dist/install-page/icon-512.png +0 -0
  19. package/dist/install-page/index.html +309 -167
  20. package/dist/install-page/llms-install.txt +202 -0
  21. package/dist/install-page/og.png +0 -0
  22. package/dist/install-page/shots/applications-dark.png +0 -0
  23. package/dist/install-page/shots/applications-light.png +0 -0
  24. package/dist/install-page/shots/documents-dark.png +0 -0
  25. package/dist/install-page/shots/documents-light.png +0 -0
  26. package/dist/install-page/shots/home-dark.png +0 -0
  27. package/dist/install-page/shots/home-light.png +0 -0
  28. package/dist/install-page/shots/interview-dark.png +0 -0
  29. package/dist/install-page/shots/interview-light.png +0 -0
  30. package/dist/install-page/shots/jobs-dark.png +0 -0
  31. package/dist/install-page/shots/jobs-light.png +0 -0
  32. package/dist/install-page/shots/settings-dark.png +0 -0
  33. package/dist/install-page/shots/settings-light.png +0 -0
  34. package/dist/install-page/start.html +346 -0
  35. package/dist/install-page/style.css +94 -3
  36. package/dist/mcp.mjs +326 -68
  37. package/dist/migrate.mjs +4715 -0
  38. package/dist/public/icon-180.png +0 -0
  39. package/dist/public/index.html +4 -1
  40. package/dist/public/lib.js +1 -1
  41. package/dist/public/pages/applications.js +17 -1
  42. package/dist/public/pages/settings.js +6 -4
  43. package/dist/public/styles.css +49 -36
  44. package/dist/web.mjs +323 -129
  45. package/docs/DEMO.md +3 -3
  46. package/docs/MCP_TOOLS.md +21 -2
  47. package/docs/ROADMAP.md +3 -3
  48. package/docs/RUNTIME_DISTRIBUTION_RESEARCH.md +135 -0
  49. package/docs/START_WORKFLOW.md +6 -0
  50. package/docs/SUPPORTED_AI_APPS.md +85 -33
  51. package/docs/WORKFLOWS.md +1 -1
  52. package/package.json +3 -2
  53. package/packages/core/src/services.ts +42 -1
  54. package/packages/db/src/connection.ts +30 -0
  55. package/packages/db/src/index.ts +1 -0
  56. package/packages/db/src/repositories.ts +109 -63
  57. package/packages/db/src/schema.ts +118 -0
  58. package/packages/exporters/src/html.ts +9 -12
  59. package/packages/mcp-tools/src/tools.ts +81 -0
  60. package/packages/shared/src/enums.ts +39 -0
  61. package/scripts/build-dist.mjs +27 -8
  62. package/scripts/build-mcpb.mjs +60 -4
  63. package/scripts/gen-og.mjs +86 -0
  64. package/scripts/init.ts +32 -3
  65. package/scripts/seed.ts +5 -5
  66. package/scripts/shots-seed.ts +192 -0
  67. package/scripts/shots.ts +89 -0
  68. package/tsconfig.json +3 -1
  69. package/dist/careermate.mcpb +0 -0
  70. package/dist/mcp-smoke.err +0 -3
  71. package/dist/mcpb-stage/README.md +0 -219
  72. package/dist/mcpb-stage/dist/install-page/index.html +0 -434
  73. package/dist/mcpb-stage/dist/install-page/style.css +0 -407
  74. package/dist/mcpb-stage/dist/install-page/vercel.json +0 -20
  75. package/dist/mcpb-stage/dist/mcp.mjs +0 -23704
  76. package/dist/mcpb-stage/dist/public/app.js +0 -118
  77. package/dist/mcpb-stage/dist/public/index.html +0 -41
  78. package/dist/mcpb-stage/dist/public/lib.js +0 -282
  79. package/dist/mcpb-stage/dist/public/pages/applications.js +0 -98
  80. package/dist/mcpb-stage/dist/public/pages/documents.js +0 -446
  81. package/dist/mcpb-stage/dist/public/pages/home.js +0 -263
  82. package/dist/mcpb-stage/dist/public/pages/interview.js +0 -230
  83. package/dist/mcpb-stage/dist/public/pages/jobs.js +0 -494
  84. package/dist/mcpb-stage/dist/public/pages/profile.js +0 -576
  85. package/dist/mcpb-stage/dist/public/pages/settings.js +0 -233
  86. package/dist/mcpb-stage/dist/public/styles.css +0 -420
  87. package/dist/mcpb-stage/manifest.json +0 -40
  88. package/docs/INSTALL.md +0 -148
  89. package/docs/INSTALL_AND_USAGE.md +0 -99
  90. package/install-page/index.html +0 -474
  91. package/install-page/style.css +0 -391
  92. package/install-page/vercel.json +0 -20
  93. /package/dist/{mcpb-stage/dist/public โ†’ install-page}/fonts/PretendardVariable.woff2 +0 -0
@@ -6,6 +6,7 @@
6
6
  import {
7
7
  newId,
8
8
  now,
9
+ assertStatusTransition,
9
10
  type ProfileInput,
10
11
  type ProfileRecord,
11
12
  type ExperienceInput,
@@ -33,7 +34,7 @@ import {
33
34
  type ContentSource,
34
35
  type DocumentKind,
35
36
  } from '@careermate/shared';
36
- import { getDb, toJson, fromJson, toBit, fromBit } from './connection.ts';
37
+ import { getDb, tx, toJson, fromJson, toBit, fromBit } from './connection.ts';
37
38
 
38
39
  const PROFILE_ID = 'profile-singleton';
39
40
 
@@ -364,34 +365,40 @@ export const documentRepo = {
364
365
  const ts = now();
365
366
  const id = newId('doc_');
366
367
  const db = getDb();
367
- if (input.is_primary) {
368
- db.prepare(`UPDATE documents SET is_primary=0 WHERE kind=?`).run(input.kind);
369
- }
370
- db.prepare(
371
- `INSERT INTO documents (id,kind,title,content,source,is_primary,tags,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)`,
372
- ).run(
373
- id,
374
- input.kind,
375
- input.title,
376
- input.content ?? '',
377
- (input.source ?? 'manual') as ContentSource,
378
- toBit(input.is_primary),
379
- toJson(input.tags ?? []),
380
- ts,
381
- ts,
382
- );
383
- return this.get(id)!;
368
+ // Demoting the previous primary + inserting the new doc must be atomic.
369
+ return tx(() => {
370
+ if (input.is_primary) {
371
+ db.prepare(`UPDATE documents SET is_primary=0 WHERE kind=?`).run(input.kind);
372
+ }
373
+ db.prepare(
374
+ `INSERT INTO documents (id,kind,title,content,source,is_primary,tags,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)`,
375
+ ).run(
376
+ id,
377
+ input.kind,
378
+ input.title,
379
+ input.content ?? '',
380
+ (input.source ?? 'manual') as ContentSource,
381
+ toBit(input.is_primary),
382
+ toJson(input.tags ?? []),
383
+ ts,
384
+ ts,
385
+ );
386
+ return this.get(id)!;
387
+ });
384
388
  },
385
389
  update(id: string, input: Partial<DocumentInput>): DocumentRecord | null {
386
390
  const cur = this.get(id);
387
391
  if (!cur) return null;
388
392
  const db = getDb();
389
393
  const m = { ...cur, ...input } as DocumentRecord;
390
- if (input.is_primary) db.prepare(`UPDATE documents SET is_primary=0 WHERE kind=?`).run(m.kind);
391
- db.prepare(
392
- `UPDATE documents SET kind=?,title=?,content=?,source=?,is_primary=?,tags=?,updated_at=? WHERE id=?`,
393
- ).run(m.kind, m.title, m.content, m.source, toBit(m.is_primary), toJson(m.tags), now(), id);
394
- return this.get(id);
394
+ // Demoting the previous primary + updating this doc must be atomic.
395
+ return tx(() => {
396
+ if (input.is_primary) db.prepare(`UPDATE documents SET is_primary=0 WHERE kind=?`).run(m.kind);
397
+ db.prepare(
398
+ `UPDATE documents SET kind=?,title=?,content=?,source=?,is_primary=?,tags=?,updated_at=? WHERE id=?`,
399
+ ).run(m.kind, m.title, m.content, m.source, toBit(m.is_primary), toJson(m.tags), now(), id);
400
+ return this.get(id);
401
+ });
395
402
  },
396
403
  remove(id: string): boolean {
397
404
  return getDb().prepare(`DELETE FROM documents WHERE id=?`).run(id).changes > 0;
@@ -453,11 +460,13 @@ export const coverLetterRepo = {
453
460
  const ts = now();
454
461
  const id = newId('cl_');
455
462
  const db = getDb();
456
- if (opts.is_primary) db.prepare(`UPDATE cover_letters SET is_primary=0`).run();
457
- db.prepare(
458
- `INSERT INTO cover_letters (id,title,job_id,is_primary,current_version_id,created_at,updated_at) VALUES (?,?,?,?,?,?,?)`,
459
- ).run(id, opts.title, opts.job_id ?? null, toBit(opts.is_primary), null, ts, ts);
460
- return this.get(id)!;
463
+ return tx(() => {
464
+ if (opts.is_primary) db.prepare(`UPDATE cover_letters SET is_primary=0`).run();
465
+ db.prepare(
466
+ `INSERT INTO cover_letters (id,title,job_id,is_primary,current_version_id,created_at,updated_at) VALUES (?,?,?,?,?,?,?)`,
467
+ ).run(id, opts.title, opts.job_id ?? null, toBit(opts.is_primary), null, ts, ts);
468
+ return this.get(id)!;
469
+ });
461
470
  },
462
471
  /** Append a new version. Creates the cover letter if cover_letter_id is omitted. */
463
472
  addVersion(opts: {
@@ -470,32 +479,36 @@ export const coverLetterRepo = {
470
479
  set_current?: boolean;
471
480
  }): { coverLetter: CoverLetterRecord; version: CoverLetterVersionRecord } {
472
481
  const db = getDb();
473
- let clId = opts.cover_letter_id;
474
- if (!clId) {
475
- const cl = this.create({ title: opts.title ?? '์ž๊ธฐ์†Œ๊ฐœ์„œ', job_id: opts.job_id ?? null });
476
- clId = cl.id;
477
- }
478
- const maxRow = db
479
- .prepare(`SELECT MAX(version_no) AS m FROM cover_letter_versions WHERE cover_letter_id=?`)
480
- .get(clId) as { m: number | null };
481
- const versionNo = (maxRow.m ?? 0) + 1;
482
- const vid = newId('clv_');
483
- const ts = now();
484
- db.prepare(
485
- `INSERT INTO cover_letter_versions (id,cover_letter_id,version_no,content,note,source,created_at) VALUES (?,?,?,?,?,?,?)`,
486
- ).run(vid, clId, versionNo, opts.content, opts.note ?? null, opts.source ?? 'ai', ts);
487
-
488
- const setCurrent = opts.set_current !== false;
489
- if (setCurrent) {
490
- db.prepare(`UPDATE cover_letters SET current_version_id=?, updated_at=? WHERE id=?`).run(vid, ts, clId);
491
- } else {
492
- db.prepare(`UPDATE cover_letters SET updated_at=? WHERE id=?`).run(ts, clId);
493
- }
494
- if (opts.title) db.prepare(`UPDATE cover_letters SET title=? WHERE id=?`).run(opts.title, clId);
495
- if (opts.job_id !== undefined)
496
- db.prepare(`UPDATE cover_letters SET job_id=? WHERE id=?`).run(opts.job_id, clId);
497
-
498
- return { coverLetter: this.get(clId)!, version: mapVersion(db.prepare(`SELECT * FROM cover_letter_versions WHERE id=?`).get(vid)) };
482
+ // Create-if-needed + version INSERT + current_version_id/title/job_id UPDATEs
483
+ // must all commit together, or mapCoverLetter's "current" lookup goes stale.
484
+ return tx(() => {
485
+ let clId = opts.cover_letter_id;
486
+ if (!clId) {
487
+ const cl = this.create({ title: opts.title ?? '์ž๊ธฐ์†Œ๊ฐœ์„œ', job_id: opts.job_id ?? null });
488
+ clId = cl.id;
489
+ }
490
+ const maxRow = db
491
+ .prepare(`SELECT MAX(version_no) AS m FROM cover_letter_versions WHERE cover_letter_id=?`)
492
+ .get(clId) as { m: number | null };
493
+ const versionNo = (maxRow.m ?? 0) + 1;
494
+ const vid = newId('clv_');
495
+ const ts = now();
496
+ db.prepare(
497
+ `INSERT INTO cover_letter_versions (id,cover_letter_id,version_no,content,note,source,created_at) VALUES (?,?,?,?,?,?,?)`,
498
+ ).run(vid, clId, versionNo, opts.content, opts.note ?? null, opts.source ?? 'ai', ts);
499
+
500
+ const setCurrent = opts.set_current !== false;
501
+ if (setCurrent) {
502
+ db.prepare(`UPDATE cover_letters SET current_version_id=?, updated_at=? WHERE id=?`).run(vid, ts, clId);
503
+ } else {
504
+ db.prepare(`UPDATE cover_letters SET updated_at=? WHERE id=?`).run(ts, clId);
505
+ }
506
+ if (opts.title) db.prepare(`UPDATE cover_letters SET title=? WHERE id=?`).run(opts.title, clId);
507
+ if (opts.job_id !== undefined)
508
+ db.prepare(`UPDATE cover_letters SET job_id=? WHERE id=?`).run(opts.job_id, clId);
509
+
510
+ return { coverLetter: this.get(clId)!, version: mapVersion(db.prepare(`SELECT * FROM cover_letter_versions WHERE id=?`).get(vid)) };
511
+ });
499
512
  },
500
513
  setCurrentVersion(coverLetterId: string, versionId: string): CoverLetterRecord | null {
501
514
  getDb()
@@ -505,14 +518,18 @@ export const coverLetterRepo = {
505
518
  },
506
519
  setPrimary(id: string): CoverLetterRecord | null {
507
520
  const db = getDb();
508
- db.prepare(`UPDATE cover_letters SET is_primary=0`).run();
509
- db.prepare(`UPDATE cover_letters SET is_primary=1, updated_at=? WHERE id=?`).run(now(), id);
510
- return this.get(id);
521
+ return tx(() => {
522
+ db.prepare(`UPDATE cover_letters SET is_primary=0`).run();
523
+ db.prepare(`UPDATE cover_letters SET is_primary=1, updated_at=? WHERE id=?`).run(now(), id);
524
+ return this.get(id);
525
+ });
511
526
  },
512
527
  remove(id: string): boolean {
513
528
  const db = getDb();
514
- db.prepare(`DELETE FROM cover_letter_versions WHERE cover_letter_id=?`).run(id);
515
- return db.prepare(`DELETE FROM cover_letters WHERE id=?`).run(id).changes > 0;
529
+ return tx(() => {
530
+ db.prepare(`DELETE FROM cover_letter_versions WHERE cover_letter_id=?`).run(id);
531
+ return db.prepare(`DELETE FROM cover_letters WHERE id=?`).run(id).changes > 0;
532
+ });
516
533
  },
517
534
  };
518
535
 
@@ -603,10 +620,13 @@ export const jobRepo = {
603
620
  },
604
621
  remove(id: string): boolean {
605
622
  const db = getDb();
606
- db.prepare(`DELETE FROM fit_analyses WHERE job_id=?`).run(id);
607
- db.prepare(`DELETE FROM applications WHERE job_id=?`).run(id);
608
- db.prepare(`DELETE FROM interview_preps WHERE job_id=?`).run(id);
609
- return db.prepare(`DELETE FROM jobs WHERE id=?`).run(id).changes > 0;
623
+ // Delete the job and all its dependent rows atomically (no orphans on failure).
624
+ return tx(() => {
625
+ db.prepare(`DELETE FROM fit_analyses WHERE job_id=?`).run(id);
626
+ db.prepare(`DELETE FROM applications WHERE job_id=?`).run(id);
627
+ db.prepare(`DELETE FROM interview_preps WHERE job_id=?`).run(id);
628
+ return db.prepare(`DELETE FROM jobs WHERE id=?`).run(id).changes > 0;
629
+ });
610
630
  },
611
631
  };
612
632
 
@@ -727,6 +747,8 @@ export const applicationRepo = {
727
747
  },
728
748
  upsert(input: ApplicationInput): ApplicationRecord {
729
749
  const app = this.ensure(input.job_id, input.status ?? 'draft');
750
+ // A status change through upsert must respect the same lifecycle gate as setStatus.
751
+ if (input.status && input.status !== app.status) assertStatusTransition(app.status, input.status);
730
752
  const m = { ...app, ...input } as ApplicationRecord;
731
753
  getDb()
732
754
  .prepare(
@@ -737,6 +759,7 @@ export const applicationRepo = {
737
759
  },
738
760
  setStatus(jobId: string, status: ApplicationStatus, note?: string): ApplicationRecord {
739
761
  const app = this.ensure(jobId);
762
+ assertStatusTransition(app.status, status);
740
763
  const appliedAt =
741
764
  status === 'applied' && !app.applied_at ? now() : app.applied_at;
742
765
  const notes = note ? [app.notes, note].filter(Boolean).join('\n') : app.notes;
@@ -826,3 +849,26 @@ export const activityRepo = {
826
849
  .all(limit) as ActivityRecord[];
827
850
  },
828
851
  };
852
+
853
+ /* ------------------------------------------------------------------ Counts */
854
+
855
+ /**
856
+ * Entity counts for the dashboard/Settings/health endpoint. Uses COUNT(*) per
857
+ * table instead of loading every row (the /api/health call hits this), and keeps
858
+ * the raw SQL in the db layer. Table names are hardcoded constants, not input.
859
+ */
860
+ export function getEntityCounts(): Record<string, number> {
861
+ const db = getDb();
862
+ const c = (t: string): number => (db.prepare(`SELECT COUNT(*) AS n FROM ${t}`).get() as { n: number }).n;
863
+ return {
864
+ profile: c('profile'),
865
+ experiences: c('experiences'),
866
+ projects: c('projects'),
867
+ skills: c('skills'),
868
+ documents: c('documents'),
869
+ cover_letters: c('cover_letters'),
870
+ jobs: c('jobs'),
871
+ applications: c('applications'),
872
+ interview_preps: c('interview_preps'),
873
+ };
874
+ }
@@ -165,6 +165,124 @@ export const MIGRATIONS: string[] = [
165
165
  CREATE INDEX IF NOT EXISTS idx_cl_job ON cover_letters(job_id);
166
166
  CREATE INDEX IF NOT EXISTS idx_activities_created ON activities(created_at DESC);
167
167
  `,
168
+
169
+ // v2 โ€” add foreign keys so PRAGMA foreign_keys=ON is actually enforced.
170
+ //
171
+ // SQLite cannot ALTER a table to add constraints, so each table that needs an
172
+ // FK is recreated (create new โ†’ copy โ†’ drop old โ†’ rename). The copy also
173
+ // repairs pre-existing orphans: child rows whose parent is gone are dropped
174
+ // (CASCADE intent) and dangling optional refs are nulled (SET NULL intent), so
175
+ // a subsequent PRAGMA foreign_key_check stays clean. FKs are disabled for the
176
+ // rebuild and the whole thing runs in one transaction (auto-rolled-back if the
177
+ // process dies mid-migration, since the version bump happens only afterwards).
178
+ `
179
+ PRAGMA foreign_keys=OFF;
180
+ BEGIN;
181
+
182
+ -- cover_letters.job_id -> jobs(id) ON DELETE SET NULL
183
+ CREATE TABLE cover_letters_new (
184
+ id TEXT PRIMARY KEY,
185
+ title TEXT NOT NULL,
186
+ job_id TEXT REFERENCES jobs(id) ON DELETE SET NULL,
187
+ is_primary INTEGER NOT NULL DEFAULT 0,
188
+ current_version_id TEXT,
189
+ created_at TEXT NOT NULL,
190
+ updated_at TEXT NOT NULL
191
+ );
192
+ INSERT INTO cover_letters_new (id,title,job_id,is_primary,current_version_id,created_at,updated_at)
193
+ SELECT id,title,
194
+ CASE WHEN job_id IN (SELECT id FROM jobs) THEN job_id ELSE NULL END,
195
+ is_primary,current_version_id,created_at,updated_at
196
+ FROM cover_letters;
197
+ DROP TABLE cover_letters;
198
+ ALTER TABLE cover_letters_new RENAME TO cover_letters;
199
+
200
+ -- cover_letter_versions.cover_letter_id -> cover_letters(id) ON DELETE CASCADE
201
+ CREATE TABLE cover_letter_versions_new (
202
+ id TEXT PRIMARY KEY,
203
+ cover_letter_id TEXT NOT NULL REFERENCES cover_letters(id) ON DELETE CASCADE,
204
+ version_no INTEGER NOT NULL,
205
+ content TEXT NOT NULL,
206
+ note TEXT,
207
+ source TEXT NOT NULL DEFAULT 'ai',
208
+ created_at TEXT NOT NULL
209
+ );
210
+ INSERT INTO cover_letter_versions_new (id,cover_letter_id,version_no,content,note,source,created_at)
211
+ SELECT id,cover_letter_id,version_no,content,note,source,created_at
212
+ FROM cover_letter_versions
213
+ WHERE cover_letter_id IN (SELECT id FROM cover_letters);
214
+ DROP TABLE cover_letter_versions;
215
+ ALTER TABLE cover_letter_versions_new RENAME TO cover_letter_versions;
216
+
217
+ -- fit_analyses.job_id -> jobs(id) ON DELETE CASCADE
218
+ CREATE TABLE fit_analyses_new (
219
+ id TEXT PRIMARY KEY,
220
+ job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
221
+ score REAL,
222
+ summary TEXT,
223
+ strengths TEXT NOT NULL DEFAULT '[]',
224
+ gaps TEXT NOT NULL DEFAULT '[]',
225
+ matched_keywords TEXT NOT NULL DEFAULT '[]',
226
+ missing_keywords TEXT NOT NULL DEFAULT '[]',
227
+ recommendations TEXT NOT NULL DEFAULT '[]',
228
+ created_at TEXT NOT NULL,
229
+ updated_at TEXT NOT NULL
230
+ );
231
+ INSERT INTO fit_analyses_new (id,job_id,score,summary,strengths,gaps,matched_keywords,missing_keywords,recommendations,created_at,updated_at)
232
+ SELECT id,job_id,score,summary,strengths,gaps,matched_keywords,missing_keywords,recommendations,created_at,updated_at
233
+ FROM fit_analyses
234
+ WHERE job_id IN (SELECT id FROM jobs);
235
+ DROP TABLE fit_analyses;
236
+ ALTER TABLE fit_analyses_new RENAME TO fit_analyses;
237
+
238
+ -- applications: job_id CASCADE, resume_id/cover_letter_id SET NULL
239
+ CREATE TABLE applications_new (
240
+ id TEXT PRIMARY KEY,
241
+ job_id TEXT NOT NULL UNIQUE REFERENCES jobs(id) ON DELETE CASCADE,
242
+ status TEXT NOT NULL DEFAULT 'draft',
243
+ resume_id TEXT REFERENCES documents(id) ON DELETE SET NULL,
244
+ cover_letter_id TEXT REFERENCES cover_letters(id) ON DELETE SET NULL,
245
+ applied_at TEXT,
246
+ notes TEXT,
247
+ created_at TEXT NOT NULL,
248
+ updated_at TEXT NOT NULL
249
+ );
250
+ INSERT INTO applications_new (id,job_id,status,resume_id,cover_letter_id,applied_at,notes,created_at,updated_at)
251
+ SELECT id,job_id,status,
252
+ CASE WHEN resume_id IN (SELECT id FROM documents) THEN resume_id ELSE NULL END,
253
+ CASE WHEN cover_letter_id IN (SELECT id FROM cover_letters) THEN cover_letter_id ELSE NULL END,
254
+ applied_at,notes,created_at,updated_at
255
+ FROM applications
256
+ WHERE job_id IN (SELECT id FROM jobs);
257
+ DROP TABLE applications;
258
+ ALTER TABLE applications_new RENAME TO applications;
259
+
260
+ -- interview_preps.job_id -> jobs(id) ON DELETE CASCADE
261
+ CREATE TABLE interview_preps_new (
262
+ id TEXT PRIMARY KEY,
263
+ job_id TEXT NOT NULL UNIQUE REFERENCES jobs(id) ON DELETE CASCADE,
264
+ questions TEXT NOT NULL DEFAULT '[]',
265
+ star_guides TEXT NOT NULL DEFAULT '[]',
266
+ self_introduction TEXT,
267
+ notes TEXT,
268
+ created_at TEXT NOT NULL,
269
+ updated_at TEXT NOT NULL
270
+ );
271
+ INSERT INTO interview_preps_new (id,job_id,questions,star_guides,self_introduction,notes,created_at,updated_at)
272
+ SELECT id,job_id,questions,star_guides,self_introduction,notes,created_at,updated_at
273
+ FROM interview_preps
274
+ WHERE job_id IN (SELECT id FROM jobs);
275
+ DROP TABLE interview_preps;
276
+ ALTER TABLE interview_preps_new RENAME TO interview_preps;
277
+
278
+ -- Recreate indexes dropped along with their tables.
279
+ CREATE INDEX IF NOT EXISTS idx_fit_job ON fit_analyses(job_id);
280
+ CREATE INDEX IF NOT EXISTS idx_clv_letter ON cover_letter_versions(cover_letter_id);
281
+ CREATE INDEX IF NOT EXISTS idx_cl_job ON cover_letters(job_id);
282
+
283
+ COMMIT;
284
+ PRAGMA foreign_keys=ON;
285
+ `,
168
286
  ];
169
287
 
170
288
  export function migrate(db: DatabaseSync): { from: number; to: number } {
@@ -18,20 +18,17 @@ export function escapeHtml(input: string | null | undefined): string {
18
18
  .replace(/'/g, '&#39;');
19
19
  }
20
20
 
21
- /** Heuristic: does this string already look like an HTML fragment? */
22
- function looksLikeHtml(s: string): boolean {
23
- return /<\/?(p|h[1-6]|ul|ol|li|div|section|article|br|hr|strong|em|a|table)\b/i.test(s);
24
- }
25
-
26
21
  /**
27
- * Wrap a body (markdown OR ready HTML) in a complete, print-optimized HTML
28
- * document. If `body` already looks like HTML it is used verbatim; otherwise it
29
- * is rendered through the tiny markdown renderer.
22
+ * Wrap a markdown body in a complete, print-optimized HTML document.
23
+ *
24
+ * The body is ALWAYS rendered through {@link markdownToHtml}, which HTML-escapes
25
+ * its input before re-introducing only its own safe tags. Every exporter feeds
26
+ * this markdown it built itself, so there is no need to accept raw HTML โ€” and
27
+ * accepting it would let user content (e.g. a cover-letter body containing
28
+ * `<img onerror=...>`) execute when the exported file is opened in a browser.
30
29
  */
31
- export function toPrintableHtml(title: string, bodyMarkdownOrHtml: string): string {
32
- const body = looksLikeHtml(bodyMarkdownOrHtml)
33
- ? bodyMarkdownOrHtml
34
- : markdownToHtml(bodyMarkdownOrHtml);
30
+ export function toPrintableHtml(title: string, bodyMarkdown: string): string {
31
+ const body = markdownToHtml(bodyMarkdown);
35
32
  const safeTitle = escapeHtml(title);
36
33
 
37
34
  return `<!doctype html>
@@ -15,6 +15,9 @@ import fs from 'node:fs';
15
15
  import path from 'node:path';
16
16
  import {
17
17
  ProfileInputSchema,
18
+ ExperienceInputSchema,
19
+ ProjectInputSchema,
20
+ SkillInputSchema,
18
21
  JobInputSchema,
19
22
  FitAnalysisInputSchema,
20
23
  CoverLetterVersionInputSchema,
@@ -26,6 +29,9 @@ import {
26
29
  } from '@careermate/shared';
27
30
  import {
28
31
  profileRepo,
32
+ experienceRepo,
33
+ projectRepo,
34
+ skillRepo,
29
35
  documentRepo,
30
36
  coverLetterRepo,
31
37
  jobRepo,
@@ -40,6 +46,9 @@ import {
40
46
  getApplicationContext,
41
47
  saveProfile,
42
48
  addResume,
49
+ addExperience,
50
+ addProject,
51
+ addSkill,
43
52
  saveJobPosting,
44
53
  saveFitAnalysis,
45
54
  saveCoverLetterVersion,
@@ -156,6 +165,78 @@ export const TOOLS: ToolDef[] = [
156
165
  },
157
166
  },
158
167
 
168
+ /* ----------------------------------------------------------- experiences */
169
+ {
170
+ name: 'add_experience',
171
+ title: '๊ฒฝ๋ ฅ ์ถ”๊ฐ€',
172
+ description:
173
+ '๊ตฌ์กฐํ™”๋œ ๊ฒฝ๋ ฅ(์ง์žฅ ๋‹จ์œ„)์„ ๋ณ„๋„ ํ•ญ๋ชฉ์œผ๋กœ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. ์ด๋ ฅ์„œ ๋ณธ๋ฌธ(add_resume)๊ณผ ๋‹ฌ๋ฆฌ, ํšŒ์‚ฌยท์ง๋ฌดยท๊ธฐ๊ฐ„ยท์„ฑ๊ณผ๋ฅผ ํ•„๋“œ๋กœ ๋‚˜๋ˆ  ์ €์žฅํ•ด ์ ํ•ฉ๋„ ๋ถ„์„/์ž์†Œ์„œ ์ž‘์„ฑ ์‹œ ์ •๋ฐ€ํ•˜๊ฒŒ ํ™œ์šฉ๋ฉ๋‹ˆ๋‹ค. company๋Š” ํ•„์ˆ˜์ด๋ฉฐ, achievements์—๋Š” ์ •๋Ÿ‰ ์ง€ํ‘œ๊ฐ€ ๋‹ด๊ธด ์„ฑ๊ณผ๋ฅผ, tech์—๋Š” ์‚ฌ์šฉ ๊ธฐ์ˆ ์„ ๋ฐฐ์—ด๋กœ ๋„ฃ์œผ์„ธ์š”. ์˜จ๋ณด๋”ฉ ์™„์„ฑ๋„(get_onboarding_status)์˜ has_experience๋ฅผ ์ฑ„์šฐ๋ ค๋ฉด ์ด ๋„๊ตฌ๋กœ 1๊ฑด ์ด์ƒ ์ถ”๊ฐ€ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.',
174
+ inputSchema: ExperienceInputSchema.shape,
175
+ handler: (args) => {
176
+ const exp = addExperience(args);
177
+ return ok(`๊ฒฝ๋ ฅ '${exp.company}${exp.role ? ` ยท ${exp.role}` : ''}'์„(๋ฅผ) ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค.`, exp);
178
+ },
179
+ },
180
+ {
181
+ name: 'get_experiences',
182
+ title: '๊ฒฝ๋ ฅ ๋ชฉ๋ก ์กฐํšŒ',
183
+ description: '์ €์žฅ๋œ ๊ฒฝ๋ ฅ์„ ์‹œ๊ฐ„ ์—ญ์ˆœ์œผ๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. (์ „์ฒด ๋งฅ๋ฝ์ด ํ•„์š”ํ•˜๋ฉด get_application_context๋ฅผ ์“ฐ์„ธ์š”.)',
184
+ inputSchema: {},
185
+ readOnly: true,
186
+ handler: () => {
187
+ const list = experienceRepo.list();
188
+ return ok(`๊ฒฝ๋ ฅ ${list.length}๊ฑด`, list);
189
+ },
190
+ },
191
+
192
+ /* -------------------------------------------------------------- projects */
193
+ {
194
+ name: 'add_project',
195
+ title: 'ํ”„๋กœ์ ํŠธ ์ถ”๊ฐ€',
196
+ description:
197
+ '๋Œ€ํ‘œ ํ”„๋กœ์ ํŠธ๋ฅผ ๋ณ„๋„ ํ•ญ๋ชฉ์œผ๋กœ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. name์€ ํ•„์ˆ˜์ด๋ฉฐ, highlights(ํ•ต์‹ฌ ์„ฑ๊ณผ)์™€ tech(์‚ฌ์šฉ ๊ธฐ์ˆ )๋ฅผ ๋ฐฐ์—ด๋กœ ๋„ฃ์œผ๋ฉด ์ž์†Œ์„œยท๋ฉด์ ‘ ์ค€๋น„์—์„œ ๊ทผ๊ฑฐ๋กœ ํ™œ์šฉ๋ฉ๋‹ˆ๋‹ค. url์— ๊ฒฐ๊ณผ๋ฌผ ๋งํฌ๋ฅผ ๋„ฃ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.',
198
+ inputSchema: ProjectInputSchema.shape,
199
+ handler: (args) => {
200
+ const prj = addProject(args);
201
+ return ok(`ํ”„๋กœ์ ํŠธ '${prj.name}'์„(๋ฅผ) ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค.`, prj);
202
+ },
203
+ },
204
+ {
205
+ name: 'get_projects',
206
+ title: 'ํ”„๋กœ์ ํŠธ ๋ชฉ๋ก ์กฐํšŒ',
207
+ description: '์ €์žฅ๋œ ํ”„๋กœ์ ํŠธ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. (์ „์ฒด ๋งฅ๋ฝ์ด ํ•„์š”ํ•˜๋ฉด get_application_context๋ฅผ ์“ฐ์„ธ์š”.)',
208
+ inputSchema: {},
209
+ readOnly: true,
210
+ handler: () => {
211
+ const list = projectRepo.list();
212
+ return ok(`ํ”„๋กœ์ ํŠธ ${list.length}๊ฑด`, list);
213
+ },
214
+ },
215
+
216
+ /* ---------------------------------------------------------------- skills */
217
+ {
218
+ name: 'add_skill',
219
+ title: '๊ธฐ์ˆ ์Šคํƒ ์ถ”๊ฐ€',
220
+ description:
221
+ '๋ณด์œ  ๊ธฐ์ˆ ์Šคํƒ์„ ๋ณ„๋„ ํ•ญ๋ชฉ์œผ๋กœ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. name์€ ํ•„์ˆ˜์ด๋ฉฐ, category(์–ธ์–ด/ํ”„๋ ˆ์ž„์›Œํฌ/ํˆด/์†Œํ”„ํŠธ์Šคํ‚ฌ ๋“ฑ)ยทlevelยทyears๋กœ ์ˆ™๋ จ๋„๋ฅผ ํ•จ๊ป˜ ๊ธฐ๋กํ•˜๋ฉด ๊ณต๊ณ  ํ‚ค์›Œ๋“œ ๋งค์นญ์ด ์ •ํ™•ํ•ด์ง‘๋‹ˆ๋‹ค. ์˜จ๋ณด๋”ฉ ์™„์„ฑ๋„์˜ has_skills๋ฅผ ์ฑ„์šฐ๋ ค๋ฉด ์ด ๋„๊ตฌ๋กœ 1๊ฑด ์ด์ƒ ์ถ”๊ฐ€ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๋Ÿฌ ๊ฐœ๋ฉด ๋„๊ตฌ๋ฅผ ๋ฐ˜๋ณต ํ˜ธ์ถœํ•˜์„ธ์š”.',
222
+ inputSchema: SkillInputSchema.shape,
223
+ handler: (args) => {
224
+ const skill = addSkill(args);
225
+ return ok(`๊ธฐ์ˆ  '${skill.name}'์„(๋ฅผ) ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค.`, skill);
226
+ },
227
+ },
228
+ {
229
+ name: 'get_skills',
230
+ title: '๊ธฐ์ˆ ์Šคํƒ ๋ชฉ๋ก ์กฐํšŒ',
231
+ description: '์ €์žฅ๋œ ๊ธฐ์ˆ ์Šคํƒ์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. (์ „์ฒด ๋งฅ๋ฝ์ด ํ•„์š”ํ•˜๋ฉด get_application_context๋ฅผ ์“ฐ์„ธ์š”.)',
232
+ inputSchema: {},
233
+ readOnly: true,
234
+ handler: () => {
235
+ const list = skillRepo.list();
236
+ return ok(`๊ธฐ์ˆ  ${list.length}๊ฑด`, list);
237
+ },
238
+ },
239
+
159
240
  /* ---------------------------------------------------------- cover letters */
160
241
  {
161
242
  name: 'add_cover_letter',
@@ -51,6 +51,45 @@ export const INTERVIEW_UNLOCK_STATUSES: ApplicationStatus[] = [
51
51
  'final_passed',
52
52
  ];
53
53
 
54
+ /**
55
+ * Allowed application-status transitions (server-enforced lifecycle).
56
+ *
57
+ * Design note: CareerMate lets the user's AI record where an application *really*
58
+ * is, so `draft` (the initial, untracked state) may jump to ANY real-world stage.
59
+ * Once an application is being tracked, it may move forward, be corrected
60
+ * backward among active stages, be parked (`on_hold`) or closed (`rejected`) from
61
+ * anywhere, and be reopened/resumed from those terminal states. The one move we
62
+ * forbid is regressing an *active* application back to `draft` ("untracked"),
63
+ * which would silently drop its lifecycle history. The hard spec invariant
64
+ * (interview prep requires `document_passed`+) is enforced separately at the
65
+ * service layer via {@link INTERVIEW_UNLOCK_STATUSES}.
66
+ */
67
+ export const ALLOWED_STATUS_TRANSITIONS: Record<ApplicationStatus, ApplicationStatus[]> = {
68
+ draft: ['planned', 'applied', 'document_passed', 'interview', 'final_passed', 'rejected', 'on_hold'],
69
+ planned: ['applied', 'document_passed', 'interview', 'final_passed', 'rejected', 'on_hold'],
70
+ applied: ['planned', 'document_passed', 'interview', 'final_passed', 'rejected', 'on_hold'],
71
+ document_passed: ['planned', 'applied', 'interview', 'final_passed', 'rejected', 'on_hold'],
72
+ interview: ['planned', 'applied', 'document_passed', 'final_passed', 'rejected', 'on_hold'],
73
+ final_passed: ['document_passed', 'interview', 'rejected', 'on_hold'],
74
+ rejected: ['draft', 'planned', 'applied', 'document_passed', 'interview', 'final_passed', 'on_hold'],
75
+ on_hold: ['draft', 'planned', 'applied', 'document_passed', 'interview', 'final_passed', 'rejected'],
76
+ };
77
+
78
+ /** Whether moving from `from` to `to` is a permitted status transition (sameโ†’same is always allowed). */
79
+ export function canTransitionStatus(from: ApplicationStatus, to: ApplicationStatus): boolean {
80
+ if (from === to) return true;
81
+ return ALLOWED_STATUS_TRANSITIONS[from]?.includes(to) ?? false;
82
+ }
83
+
84
+ /** Throw a clear, user-facing (Korean) error when a status transition is not allowed. */
85
+ export function assertStatusTransition(from: ApplicationStatus, to: ApplicationStatus): void {
86
+ if (!canTransitionStatus(from, to)) {
87
+ throw new Error(
88
+ `'${APPLICATION_STATUS_LABELS[from]}'์—์„œ '${APPLICATION_STATUS_LABELS[to]}'(์œผ)๋กœ๋Š” ์ƒํƒœ๋ฅผ ๋ฐ”๊ฟ€ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.`,
89
+ );
90
+ }
91
+ }
92
+
54
93
  /** Stored document kinds (resumes and career descriptions). Cover letters live in their own tables. */
55
94
  export const DOCUMENT_KINDS = ['resume', 'career_description', 'portfolio', 'other'] as const;
56
95
  export type DocumentKind = (typeof DOCUMENT_KINDS)[number];
@@ -3,10 +3,12 @@
3
3
  * scripts/build-dist.mjs โ€” ๋ฐฐํฌ์šฉ ํ”Œ๋ ˆ์ธ JS ๋ฒˆ๋“ค ๋นŒ๋“œ (esbuild).
4
4
  *
5
5
  * ๊ฐœ๋ฐœ์€ ๊ทธ๋Œ€๋กœ tsx ๋ฌด๋นŒ๋“œ๋กœ ๋Œ๋ฆฌ๊ณ , ๋ฐฐํฌ(.mcpb / npm publish / ์ถ”ํ›„ ๋…๋ฆฝ .exe)์—์„œ๋Š”
6
- * tsxยทtsconfig ๊ฒฝ๋กœ๋ณ„์นญ ๋Ÿฐํƒ€์ž„ ์˜์กด์„ฑ์„ ์—†์• ๊ธฐ ์œ„ํ•ด ๋‘ ์ง„์ž…์ ์„ ๋‹จ์ผ ESM ํŒŒ์ผ๋กœ ๋ฒˆ๋“คํ•œ๋‹ค.
6
+ * tsxยทtsconfig ๊ฒฝ๋กœ๋ณ„์นญ ๋Ÿฐํƒ€์ž„ ์˜์กด์„ฑ์„ ์—†์• ๊ธฐ ์œ„ํ•ด ๋„ค ์ง„์ž…์ ์„ ๊ฐ๊ฐ ๋‹จ์ผ ESM ํŒŒ์ผ๋กœ ๋ฒˆ๋“คํ•œ๋‹ค.
7
7
  *
8
- * dist/mcp.mjs MCP stdio ์„œ๋ฒ„ (Claude Desktop์ด ์‹คํ–‰)
9
- * dist/web.mjs ๋กœ์ปฌ ๋Œ€์‹œ๋ณด๋“œ ์„œ๋ฒ„
8
+ * dist/mcp.mjs MCP stdio ์„œ๋ฒ„ (Claude Desktop์ด ์‹คํ–‰)
9
+ * dist/web.mjs ๋กœ์ปฌ ๋Œ€์‹œ๋ณด๋“œ ์„œ๋ฒ„
10
+ * dist/doctor.mjs ์„ค์น˜/ํ™˜๊ฒฝ ์ ๊ฒ€ CLI
11
+ * dist/migrate.mjs DB ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ CLI
10
12
  * dist/public/ ๋Œ€์‹œ๋ณด๋“œ ์ •์  ์ž์‚ฐ (apps/web/public ๋ณต์‚ฌ)
11
13
  * dist/install-page/ ์„ค์น˜ ์•ˆ๋‚ด ํŽ˜์ด์ง€ ๋ณต์‚ฌ
12
14
  *
@@ -23,14 +25,25 @@ const DIST = path.join(ROOT, 'dist');
23
25
 
24
26
  async function main() {
25
27
  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
+ // package.json์„ ๋ฒ„์ „ ๋‹จ์ผ ์ถœ์ฒ˜๋กœ ์‚ผ์•„ ๋ฒˆ๋“ค์— ์ฃผ์ž…(์•ฑ๋ณ„ ํ•˜๋“œ์ฝ”๋”ฉ ๋ฒ„์ „ ๋“œ๋ฆฌํ”„ํŠธ ๋ฐฉ์ง€).
29
+ const pkgVersion = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
30
+ // dist๋ฅผ ํ†ต์งธ๋กœ ๋น„์šฐ๊ณ  ๋‹ค์‹œ ๋งŒ๋“ ๋‹ค. build-dist๊ฐ€ ์†Œ์œ ํ•˜๋Š” ์‚ฐ์ถœ๋ฌผ(mcp/web/doctor/migrate/
31
+ // public/install-page)๋งŒ ๋‚จ๊ธฐ๊ธฐ ์œ„ํ•จ. ์ด๋ ‡๊ฒŒ ํ•ด์•ผ ์ง์ „ build:mcpb๊ฐ€ ๋‚จ๊ธด mcpb-stage/ยท
32
+ // careermate.mcpb(์ˆ˜ MB)๋‚˜ ํ…Œ์ŠคํŠธ ์ž”์—ฌ๋ฌผ(mcp-smoke.err)์ด npm tarball์— ์„ž์—ฌ ๋“ค์–ด๊ฐ€์ง€ ์•Š๋Š”๋‹ค
33
+ // (npm publish์˜ prepack์€ build-dist๋งŒ ์‹คํ–‰ํ•˜๋ฏ€๋กœ ์—ฌ๊ธฐ์„œ ๋น„์šฐ๋ฉด ๊ฒŒ์‹œ๋ณธ์ด ๊นจ๋—ํ•˜๋‹ค).
34
+ // build:mcpb๋Š” ์ด ํ•จ์ˆ˜๋ฅผ ๋จผ์ € ํ˜ธ์ถœํ•œ ๋’ค mcpb-stage/ยทcareermate.mcpb๋ฅผ ๋‹ค์‹œ ์ƒ์„ฑํ•˜๋ฏ€๋กœ ์•ˆ์ „.
35
+ fs.rmSync(DIST, { recursive: true, force: true });
36
+ fs.mkdirSync(DIST, { recursive: true });
28
37
 
29
38
  await esbuild.build({
30
39
  absWorkingDir: ROOT,
31
40
  entryPoints: {
32
41
  mcp: 'apps/mcp/src/index.ts',
33
42
  web: 'apps/web/src/index.ts',
43
+ // doctor/migrate๋„ ๋ฒˆ๋“คํ•ด ๋‘”๋‹ค. ์ด ๋‘˜์€ @careermate/* ์›Œํฌ์ŠคํŽ˜์ด์Šค ํŒจํ‚ค์ง€๋ฅผ importํ•˜๋Š”๋ฐ,
44
+ // ๊ฒŒ์‹œ tarball์—๋Š” ๊ทธ ํŒจํ‚ค์ง€๋“ค์ด resolvableํ•˜์ง€ ์•Š์•„ tsx ํด๋ฐฑ์ด ์‹คํŒจํ•œ๋‹ค(๋ฒˆ๋“ค์ด๋ฉด ์ธ๋ผ์ธ๋ผ ๋™์ž‘).
45
+ doctor: 'scripts/doctor.ts',
46
+ migrate: 'scripts/migrate.ts',
34
47
  },
35
48
  outdir: 'dist',
36
49
  outExtension: { '.js': '.mjs' },
@@ -39,7 +52,7 @@ async function main() {
39
52
  format: 'esm',
40
53
  target: 'node22',
41
54
  tsconfig: 'tsconfig.json',
42
- define: { __BUNDLED__: 'true' },
55
+ define: { __BUNDLED__: 'true', __APP_VERSION__: JSON.stringify(pkgVersion) },
43
56
  // ESM ์ถœ๋ ฅ์—์„œ CJS ์˜์กด์„ฑ์ด require/__dirname์„ ์จ๋„ ๋™์ž‘ํ•˜๋„๋ก shim ์ฃผ์ž….
44
57
  banner: {
45
58
  js: "import { createRequire as __cr } from 'module'; const require = __cr(import.meta.url);",
@@ -51,9 +64,15 @@ async function main() {
51
64
  fs.rmSync(path.join(DIST, 'public'), { recursive: true, force: true });
52
65
  fs.rmSync(path.join(DIST, 'install-page'), { recursive: true, force: true });
53
66
  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 });
67
+ // install-page/careermate.mcpb ๋Š” ๊ณต๊ฐœ ์‚ฌ์ดํŠธ(careermate.life)์˜ ๋‹ค์šด๋กœ๋“œ ์‚ฐ์ถœ๋ฌผ์ผ ๋ฟ,
68
+ // ๋ฒˆ๋“ค ๋Œ€์‹œ๋ณด๋“œ์˜ /install/ ๋ผ์šฐํŠธ์—๋Š” ํ•„์š” ์—†๋‹ค. ์ด๊ฑธ dist๋กœ ๋“ค์ด๋ฉด .mcpb ๋นŒ๋“œ๊ฐ€ ์ง์ „
69
+ // ๋ฒˆ๋“ค(์ž๊ธฐ ์ž์‹ , ~6MB)์„ ๋‹ค์‹œ ์ ‘์–ด ๋„ฃ์–ด ๋งค ๋นŒ๋“œ๋งˆ๋‹ค ์šฉ๋Ÿ‰์ด ๋ถˆ์–ด๋‚œ๋‹ค(์ž๊ธฐ์ฐธ์กฐ). ์ œ์™ธํ•œ๋‹ค.
70
+ fs.cpSync(path.join(ROOT, 'install-page'), path.join(DIST, 'install-page'), {
71
+ recursive: true,
72
+ filter: (src) => path.basename(src) !== 'careermate.mcpb',
73
+ });
55
74
 
56
- console.log('โœ… dist ๋นŒ๋“œ ์™„๋ฃŒ: dist/mcp.mjs, dist/web.mjs, dist/public, dist/install-page');
75
+ console.log('โœ… dist ๋นŒ๋“œ ์™„๋ฃŒ: dist/mcp.mjs, dist/web.mjs, dist/doctor.mjs, dist/migrate.mjs, dist/public, dist/install-page');
57
76
  }
58
77
 
59
78
  main().catch((err) => {