careermate 0.1.0 → 0.1.2

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 (134) hide show
  1. package/README.md +22 -17
  2. package/THIRD_PARTY_NOTICES.md +15 -0
  3. package/apps/mcp/src/index.ts +4 -3
  4. package/apps/web/DESIGN_GUIDE.md +2 -2
  5. package/apps/web/demo/demo-shim.js +536 -0
  6. package/apps/web/demo/demo.css +75 -0
  7. package/apps/web/demo/seed.js +269 -0
  8. package/apps/web/public/app.js +34 -2
  9. package/apps/web/public/icon-180.png +0 -0
  10. package/apps/web/public/index.html +14 -3
  11. package/apps/web/public/lib.js +135 -23
  12. package/apps/web/public/pages/applications.js +18 -9
  13. package/apps/web/public/pages/documents.js +66 -50
  14. package/apps/web/public/pages/home.js +12 -11
  15. package/apps/web/public/pages/interview.js +9 -12
  16. package/apps/web/public/pages/jobs.js +39 -36
  17. package/apps/web/public/pages/profile.js +66 -86
  18. package/apps/web/public/pages/settings.js +21 -14
  19. package/apps/web/public/styles.css +105 -38
  20. package/apps/web/src/http.ts +31 -4
  21. package/apps/web/src/info.ts +9 -22
  22. package/apps/web/src/routes.ts +19 -14
  23. package/apps/web/src/server.ts +34 -29
  24. package/apps/web/src/settings.ts +35 -17
  25. package/bin/careermate.mjs +8 -5
  26. package/dist/doctor.mjs +78908 -0
  27. package/dist/install-page/.omc/state/agent-replay-55a58c0d-bb6e-43ea-8a05-4b6c93b80b34.jsonl +12 -0
  28. package/dist/install-page/.omc/state/idle-notif-cooldown.json +3 -0
  29. package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/mission-state.json +147 -0
  30. package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/pre-tool-advisory-throttle.json +22 -0
  31. package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/subagent-tracking-state.json +44 -0
  32. package/dist/install-page/favicon.svg +1 -0
  33. package/dist/install-page/icon-180.png +0 -0
  34. package/dist/install-page/icon-512.png +0 -0
  35. package/dist/install-page/index.html +328 -225
  36. package/dist/install-page/landing.js +54 -0
  37. package/dist/install-page/llms-install.txt +202 -0
  38. package/dist/install-page/og.png +0 -0
  39. package/dist/install-page/shots/applications-dark.png +0 -0
  40. package/dist/install-page/shots/applications-light.png +0 -0
  41. package/dist/install-page/shots/documents-dark.png +0 -0
  42. package/dist/install-page/shots/documents-light.png +0 -0
  43. package/dist/install-page/shots/home-dark.png +0 -0
  44. package/dist/install-page/shots/home-light.png +0 -0
  45. package/dist/install-page/shots/interview-dark.png +0 -0
  46. package/dist/install-page/shots/interview-light.png +0 -0
  47. package/dist/install-page/shots/jobs-dark.png +0 -0
  48. package/dist/install-page/shots/jobs-light.png +0 -0
  49. package/dist/install-page/shots/settings-dark.png +0 -0
  50. package/dist/install-page/shots/settings-light.png +0 -0
  51. package/dist/install-page/start.html +323 -0
  52. package/dist/install-page/style.css +128 -4
  53. package/dist/mcp.mjs +74225 -2007
  54. package/dist/{mcpb-stage/dist/web.mjs → migrate.mjs} +142 -2660
  55. package/dist/pdf.worker.min.mjs +28 -0
  56. package/dist/public/app.js +34 -2
  57. package/dist/public/icon-180.png +0 -0
  58. package/dist/public/index.html +14 -3
  59. package/dist/public/lib.js +135 -23
  60. package/dist/public/pages/applications.js +18 -9
  61. package/dist/public/pages/documents.js +66 -50
  62. package/dist/public/pages/home.js +12 -11
  63. package/dist/public/pages/interview.js +9 -12
  64. package/dist/public/pages/jobs.js +39 -36
  65. package/dist/public/pages/profile.js +66 -86
  66. package/dist/public/pages/settings.js +21 -14
  67. package/dist/public/styles.css +105 -38
  68. package/dist/web.mjs +39950 -369
  69. package/docs/ARCHITECTURE.md +5 -5
  70. package/docs/DECISIONS.md +1 -1
  71. package/docs/DEMO.md +6 -6
  72. package/docs/MCP_TOOLS.md +68 -9
  73. package/docs/ROADMAP.md +11 -10
  74. package/docs/RUNTIME_DISTRIBUTION_RESEARCH.md +135 -0
  75. package/docs/START_WORKFLOW.md +19 -7
  76. package/docs/SUPPORTED_AI_APPS.md +85 -33
  77. package/docs/TODO.md +1 -1
  78. package/docs/WORKFLOWS.md +8 -6
  79. package/docs/superpowers/specs/2026-06-16-demo-dashboard-deploy-design.md +64 -0
  80. package/package.json +8 -3
  81. package/packages/core/src/services.ts +42 -1
  82. package/packages/db/src/connection.ts +30 -0
  83. package/packages/db/src/index.ts +1 -0
  84. package/packages/db/src/paths.ts +11 -0
  85. package/packages/db/src/repositories.ts +109 -63
  86. package/packages/db/src/schema.ts +118 -0
  87. package/packages/exporters/src/html.ts +9 -12
  88. package/packages/mcp-tools/src/bridge.ts +55 -32
  89. package/packages/mcp-tools/src/index.ts +9 -0
  90. package/packages/mcp-tools/src/tools.ts +269 -65
  91. package/packages/mcp-tools/src/update.ts +330 -0
  92. package/packages/parsers/src/documents.ts +521 -0
  93. package/packages/parsers/src/index.ts +8 -5
  94. package/packages/prompts/src/onboarding.ts +2 -2
  95. package/packages/shared/src/build-info.ts +22 -0
  96. package/packages/shared/src/enums.ts +39 -0
  97. package/packages/shared/src/index.ts +1 -0
  98. package/packages/shared/src/schemas.ts +24 -3
  99. package/packages/workflows/src/definitions.ts +4 -2
  100. package/scripts/build-demo.mjs +130 -0
  101. package/scripts/build-dist.mjs +46 -8
  102. package/scripts/build-mcpb.mjs +60 -4
  103. package/scripts/doctor.ts +16 -3
  104. package/scripts/gen-og.mjs +86 -0
  105. package/scripts/init.ts +32 -3
  106. package/scripts/seed.ts +5 -5
  107. package/scripts/shots-seed.ts +192 -0
  108. package/scripts/shots.ts +89 -0
  109. package/tsconfig.json +3 -1
  110. package/dist/careermate.mcpb +0 -0
  111. package/dist/mcp-smoke.err +0 -3
  112. package/dist/mcpb-stage/README.md +0 -219
  113. package/dist/mcpb-stage/dist/install-page/index.html +0 -434
  114. package/dist/mcpb-stage/dist/install-page/style.css +0 -407
  115. package/dist/mcpb-stage/dist/install-page/vercel.json +0 -20
  116. package/dist/mcpb-stage/dist/mcp.mjs +0 -23704
  117. package/dist/mcpb-stage/dist/public/app.js +0 -118
  118. package/dist/mcpb-stage/dist/public/index.html +0 -41
  119. package/dist/mcpb-stage/dist/public/lib.js +0 -282
  120. package/dist/mcpb-stage/dist/public/pages/applications.js +0 -98
  121. package/dist/mcpb-stage/dist/public/pages/documents.js +0 -446
  122. package/dist/mcpb-stage/dist/public/pages/home.js +0 -263
  123. package/dist/mcpb-stage/dist/public/pages/interview.js +0 -230
  124. package/dist/mcpb-stage/dist/public/pages/jobs.js +0 -494
  125. package/dist/mcpb-stage/dist/public/pages/profile.js +0 -576
  126. package/dist/mcpb-stage/dist/public/pages/settings.js +0 -233
  127. package/dist/mcpb-stage/dist/public/styles.css +0 -420
  128. package/dist/mcpb-stage/manifest.json +0 -40
  129. package/docs/INSTALL.md +0 -148
  130. package/docs/INSTALL_AND_USAGE.md +0 -99
  131. package/install-page/index.html +0 -474
  132. package/install-page/style.css +0 -391
  133. package/install-page/vercel.json +0 -20
  134. /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>
@@ -5,14 +5,11 @@
5
5
  * web server publishes it to a runtime file (see @careermate/db runtime). If the
6
6
  * server isn't running we start it (detached) and wait for it to register.
7
7
  */
8
- import { spawn } from 'node:child_process';
8
+ import { spawn, type SpawnOptions } from 'node:child_process';
9
9
  import path from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { readRuntimeInfo, isProcessAlive } from '@careermate/db';
12
-
13
- // 번들 빌드(esbuild)에서 true로 치환된다. tsx 개발 실행에서는 정의되지 않아 typeof로 안전 분기.
14
- declare const __BUNDLED__: boolean | undefined;
15
- const BUNDLED = typeof __BUNDLED__ !== 'undefined' && __BUNDLED__;
12
+ import { BUNDLED } from '@careermate/shared';
16
13
 
17
14
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
15
  // packages/mcp-tools/src -> repo root (dev). 번들에서는 __dirname이 dist/ 이다.
@@ -28,26 +25,38 @@ function liveServer() {
28
25
  return null;
29
26
  }
30
27
 
31
- /** Start the dashboard server detached and wait until it publishes its address. */
32
- export async function ensureServer(timeoutMs = 12000): Promise<{ url: string } | null> {
33
- const existing = liveServer();
34
- if (existing) return { url: existing.url };
35
-
28
+ /**
29
+ * Fire-and-forget detached spawn that NEVER throws — synchronously OR
30
+ * asynchronously. A missing binary (e.g. `xdg-open` on a headless Linux box)
31
+ * makes spawn emit an async `'error'` event; with no listener that becomes an
32
+ * uncaughtException and crashes the MCP process. A surrounding try/catch only
33
+ * guards the synchronous spawn() call, not the later event — so attach the
34
+ * listener here. stdio defaults to 'ignore'; callers may override via opts.
35
+ */
36
+ function spawnDetached(cmd: string, args: string[], opts: SpawnOptions = {}): void {
36
37
  try {
37
- // prod(번들): dist/web.mjs를 플레인 node로 실행. dev: tsx로 소스 실행.
38
- const [bin, args, cwd] = BUNDLED
39
- ? [process.execPath, ['--experimental-sqlite', path.join(__dirname, 'web.mjs')], __dirname]
40
- : [process.execPath, ['--no-warnings', '--experimental-sqlite', '--import', 'tsx', 'apps/web/src/index.ts'], REPO_ROOT];
41
- const child = spawn(bin, args, {
42
- cwd,
43
- detached: true,
44
- stdio: 'ignore',
45
- env: { ...process.env, CAREERMATE_NO_OPEN: '1' },
38
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true, ...opts });
39
+ child.on('error', () => {
40
+ /* binary missing / spawn failed — best-effort, never crash */
46
41
  });
47
42
  child.unref();
48
43
  } catch {
49
- return null;
44
+ /* best-effort */
50
45
  }
46
+ }
47
+
48
+ /** Start the dashboard server detached and wait until it publishes its address. */
49
+ export async function ensureServer(timeoutMs = 12000): Promise<{ url: string } | null> {
50
+ const existing = liveServer();
51
+ if (existing) return { url: existing.url };
52
+
53
+ // prod(번들): dist/web.mjs를 플레인 node로 실행. dev: tsx로 소스 실행.
54
+ const [bin, args, cwd] = BUNDLED
55
+ ? [process.execPath, ['--experimental-sqlite', path.join(__dirname, 'web.mjs')], __dirname]
56
+ : [process.execPath, ['--no-warnings', '--experimental-sqlite', '--import', 'tsx', 'apps/web/src/index.ts'], REPO_ROOT];
57
+ // Spawn failures (sync or async) just leave the server down; the poll loop
58
+ // below then times out and returns null — no crash.
59
+ spawnDetached(bin as string, args as string[], { cwd: cwd as string, env: { ...process.env, CAREERMATE_NO_OPEN: '1' } });
51
60
 
52
61
  const start = Date.now();
53
62
  while (Date.now() - start < timeoutMs) {
@@ -58,21 +67,35 @@ export async function ensureServer(timeoutMs = 12000): Promise<{ url: string } |
58
67
  return null;
59
68
  }
60
69
 
61
- export function openInBrowser(url: string): void {
62
- const platform = process.platform;
63
- try {
64
- if (platform === 'win32') {
65
- spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref();
66
- } else if (platform === 'darwin') {
67
- spawn('open', [url], { stdio: 'ignore', detached: true }).unref();
68
- } else {
69
- spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref();
70
- }
71
- } catch {
72
- /* best-effort */
70
+ /** Open a URL or a local folder via the OS default handler. Best-effort, never throws. */
71
+ function openOsTarget(target: string): void {
72
+ switch (process.platform) {
73
+ case 'win32':
74
+ // `start` is a cmd builtin; the empty "" is the window-title arg so a
75
+ // quoted target isn't mistaken for the title. (explorer also opens URLs,
76
+ // but `start` is the canonical default-handler launcher.)
77
+ spawnDetached('cmd', ['/c', 'start', '', target]);
78
+ break;
79
+ case 'darwin':
80
+ spawnDetached('open', [target]);
81
+ break;
82
+ default:
83
+ spawnDetached('xdg-open', [target]);
73
84
  }
74
85
  }
75
86
 
87
+ export function openInBrowser(url: string): void {
88
+ openOsTarget(url);
89
+ }
90
+
91
+ /** Open a local folder in the OS file manager (Explorer/Finder/xdg). Best-effort. */
92
+ export function openInFileManager(dir: string): void {
93
+ // Windows: `explorer <dir>` is the idiomatic folder opener (start "" <dir>
94
+ // also works, but explorer focuses an existing window if already open).
95
+ if (process.platform === 'win32') spawnDetached('explorer', [dir]);
96
+ else openOsTarget(dir);
97
+ }
98
+
76
99
  /** Resolve the dashboard URL, starting the server if needed. */
77
100
  export async function resolveDashboardUrl(hash = ''): Promise<{ url: string; running: boolean }> {
78
101
  const live = liveServer();
@@ -6,3 +6,12 @@
6
6
  export { TOOLS, EXPORTS_LOCATION } from './tools.ts';
7
7
  export { ok, fail, toCallToolResult, type ToolDef, type ToolResult } from './result.ts';
8
8
  export { resolveDashboardUrl, openInBrowser, ensureServer, projectRoot } from './bridge.ts';
9
+ export {
10
+ getUpdateStatus,
11
+ getUpdateStatusAsync,
12
+ runSelfUpdate,
13
+ installPrefix,
14
+ compareVersions,
15
+ type UpdateStatus,
16
+ type UpdateResult,
17
+ } from './update.ts';