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
@@ -19,24 +19,24 @@
19
19
  }
20
20
 
21
21
  :root {
22
- /* Neutral palette (light) */
23
- --bg: #ffffff;
24
- --bg-subtle: #f7f7f8;
25
- --bg-sunken: #f1f1f3;
22
+ /* Warm paper neutrals (light) — unified with the public landing page */
23
+ --bg: #faf8f4;
24
+ --bg-subtle: #f2efe8;
25
+ --bg-sunken: #ece8df;
26
26
  --surface: #ffffff;
27
- --surface-hover: #f7f7f8;
28
- --border: #e6e6e9;
29
- --border-strong: #d4d4d8;
30
- --text: #1c1c1f;
31
- --text-secondary: #5c5c66;
32
- --text-tertiary: #8a8a94;
27
+ --surface-hover: #f5f2ec;
28
+ --border: #e6e1d6;
29
+ --border-strong: #d6d0c2;
30
+ --text: #1c1a16;
31
+ --text-secondary: #595449;
32
+ --text-tertiary: #8b867a;
33
33
  --text-on-accent: #ffffff;
34
34
 
35
- /* Accent — calm indigo */
36
- --accent: #4f46e5;
37
- --accent-hover: #4338ca;
38
- --accent-soft: #eef2ff;
39
- --accent-border: #c7d2fe;
35
+ /* Accent — deep emerald (single flat accent, no gradient) */
36
+ --accent: #0f7256;
37
+ --accent-hover: #0b5b44;
38
+ --accent-soft: #e7f1ec;
39
+ --accent-border: #bcdccd;
40
40
 
41
41
  /* Semantic */
42
42
  --green: #15803d; --green-soft: #ecfdf3; --green-border: #bbf7d0;
@@ -67,31 +67,38 @@
67
67
 
68
68
  @media (prefers-color-scheme: dark) {
69
69
  :root:not([data-theme='light']) {
70
- --bg: #161618;
71
- --bg-subtle: #1d1d20;
72
- --bg-sunken: #0f0f11;
73
- --surface: #1d1d20;
74
- --surface-hover: #26262a;
75
- --border: #2c2c31;
76
- --border-strong: #3a3a41;
77
- --text: #ededf0;
78
- --text-secondary: #a5a5b0;
79
- --text-tertiary: #74747e;
80
- --accent: #818cf8;
81
- --accent-hover: #a5b4fc;
82
- --accent-soft: #1e1b3a;
83
- --accent-border: #3730a3;
70
+ --bg: #14130f;
71
+ --bg-subtle: #201d17;
72
+ --bg-sunken: #100f0b;
73
+ --surface: #1b1915;
74
+ --surface-hover: #262219;
75
+ --border: #2e2a22;
76
+ --border-strong: #3e3930;
77
+ --text: #f2eee5;
78
+ --text-secondary: #aaa599;
79
+ --text-tertiary: #79746a;
80
+ --accent: #34d399;
81
+ --accent-hover: #6ee7b7;
82
+ --accent-soft: #11281d;
83
+ --accent-border: #1f4f3c;
84
+ --text-on-accent: #05241a;
84
85
  --green-soft:#0e2a1a; --teal-soft:#0c2826; --blue-soft:#11203f; --violet-soft:#1e1638;
85
86
  --amber-soft:#2a2008; --red-soft:#2c1414; --slate-soft:#1f242b;
87
+ /* lighter semantic foregrounds so status badges stay legible on dark soft bg */
88
+ --green:#4ade80; --teal:#2dd4bf; --blue:#60a5fa; --violet:#a78bfa; --amber:#fbbf24; --red:#ef4444; --slate:#94a3b8;
89
+ --green-border:#1f4f3c; --teal-border:#155e57; --blue-border:#1e3a6b; --violet-border:#3b2f6b; --amber-border:#5c4410; --red-border:#5c2020; --slate-border:#3a4250;
86
90
  --shadow-lg: 0 12px 40px rgba(0,0,0,.5);
87
91
  }
88
92
  }
89
93
  :root[data-theme='dark'] {
90
- --bg:#161618; --bg-subtle:#1d1d20; --bg-sunken:#0f0f11; --surface:#1d1d20; --surface-hover:#26262a;
91
- --border:#2c2c31; --border-strong:#3a3a41; --text:#ededf0; --text-secondary:#a5a5b0; --text-tertiary:#74747e;
92
- --accent:#818cf8; --accent-hover:#a5b4fc; --accent-soft:#1e1b3a; --accent-border:#3730a3;
94
+ --bg:#14130f; --bg-subtle:#201d17; --bg-sunken:#100f0b; --surface:#1b1915; --surface-hover:#262219;
95
+ --border:#2e2a22; --border-strong:#3e3930; --text:#f2eee5; --text-secondary:#aaa599; --text-tertiary:#79746a;
96
+ --accent:#34d399; --accent-hover:#6ee7b7; --accent-soft:#11281d; --accent-border:#1f4f3c; --text-on-accent:#05241a;
93
97
  --green-soft:#0e2a1a; --teal-soft:#0c2826; --blue-soft:#11203f; --violet-soft:#1e1638;
94
- --amber-soft:#2a2008; --red-soft:#2c1414; --slate-soft:#1f242b; --shadow-lg:0 12px 40px rgba(0,0,0,.5);
98
+ --amber-soft:#2a2008; --red-soft:#2c1414; --slate-soft:#1f242b;
99
+ --green:#4ade80; --teal:#2dd4bf; --blue:#60a5fa; --violet:#a78bfa; --amber:#fbbf24; --red:#ef4444; --slate:#94a3b8;
100
+ --green-border:#1f4f3c; --teal-border:#155e57; --blue-border:#1e3a6b; --violet-border:#3b2f6b; --amber-border:#5c4410; --red-border:#5c2020; --slate-border:#3a4250;
101
+ --shadow-lg:0 12px 40px rgba(0,0,0,.5);
95
102
  }
96
103
 
97
104
  * { box-sizing: border-box; }
@@ -120,7 +127,7 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
120
127
  .sidebar__logo {
121
128
  width: 30px; height: 30px; border-radius: 8px; flex: none;
122
129
  background: var(--accent);
123
- display: grid; place-items: center; color: #fff; font-weight: 800; font-size: 15px;
130
+ display: grid; place-items: center; color: var(--text-on-accent); font-weight: 800; font-size: 15px;
124
131
  }
125
132
  .sidebar__title { font-weight: 700; font-size: 15px; letter-spacing: -.01em; }
126
133
  .sidebar__subtitle { font-size: 12px; color: var(--text-tertiary); margin-top: 1px; }
@@ -135,7 +142,7 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
135
142
  .nav__item svg { width: 17px; height: 17px; flex: none; }
136
143
  .nav__badge { margin-left: auto; font-size: 11.5px; background: var(--bg-sunken); color: var(--text-secondary);
137
144
  padding: 1px 7px; border-radius: 999px; font-weight: 600; }
138
- .nav__item.is-active .nav__badge { background: var(--accent); color: #fff; }
145
+ .nav__item.is-active .nav__badge { background: var(--accent); color: var(--text-on-accent); }
139
146
  .sidebar__foot { padding: 12px 16px; border-top: 1px solid var(--border); font-size: 12px; color: var(--text-tertiary); }
140
147
  .sidebar__foot .dot { display:inline-block; width:7px; height:7px; border-radius:999px; background: var(--green); margin-right:6px; }
141
148
 
@@ -148,6 +155,13 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
148
155
  .topbar__title { font-size: 15px; font-weight: 650; }
149
156
  .topbar__crumb { color: var(--text-tertiary); }
150
157
  .topbar__spacer { flex: 1; }
158
+ /* mobile nav: hamburger (hidden ≥900px) + off-canvas drawer scrim */
159
+ .nav-toggle { display: none; width: 34px; height: 34px; flex: none; place-items: center;
160
+ border: 1px solid var(--border); background: var(--surface); border-radius: var(--radius-sm);
161
+ color: var(--text-secondary); cursor: pointer; margin-right: 4px; }
162
+ .nav-toggle:hover { background: var(--surface-hover); color: var(--text); }
163
+ .nav-toggle svg { width: 20px; height: 20px; }
164
+ .nav-scrim { display: none; position: fixed; inset: 0; z-index: 40; background: rgba(0,0,0,.4); }
151
165
  .view { padding: 24px; max-width: var(--maxw); width: 100%; margin: 0 auto; }
152
166
  .view--wide { max-width: 1280px; }
153
167
 
@@ -161,7 +175,16 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
161
175
  .grid--2 { grid-template-columns: repeat(2, 1fr); }
162
176
  .grid--3 { grid-template-columns: repeat(3, 1fr); }
163
177
  .grid--4 { grid-template-columns: repeat(4, 1fr); }
164
- @media (max-width: 900px){ .grid--2,.grid--3,.grid--4{ grid-template-columns: 1fr; } .app{ grid-template-columns: 1fr; } .sidebar{ display:none; } }
178
+ @media (max-width: 900px){
179
+ .grid--2,.grid--3,.grid--4{ grid-template-columns: 1fr; }
180
+ .app{ grid-template-columns: 1fr; }
181
+ /* sidebar slides in as an off-canvas drawer; the topbar hamburger toggles it */
182
+ .sidebar{ position: fixed; top: 0; left: 0; bottom: 0; height: 100%; z-index: 50;
183
+ transform: translateX(-100%); transition: transform .22s ease; box-shadow: var(--shadow-lg); }
184
+ .app.nav-open .sidebar{ transform: none; }
185
+ .app.nav-open .nav-scrim{ display: block; }
186
+ .nav-toggle{ display: inline-grid; }
187
+ }
165
188
 
166
189
  /* ----------------------------------------------------------------- card */
167
190
  .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
@@ -184,6 +207,12 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
184
207
  .stat__label svg { width: 15px; height: 15px; color: var(--text-tertiary); }
185
208
  .stat__value { font-size: 26px; font-weight: 700; letter-spacing: -.02em; margin-top: 6px; }
186
209
  .stat__hint { font-size: 12.5px; color: var(--text-tertiary); margin-top: 2px; }
210
+ /* borderless count strip (settings 내 데이터) — flat counts, never a card-in-card */
211
+ .countstrip { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px 24px; }
212
+ .countstrip__item { display: flex; flex-direction: column; gap: 2px; }
213
+ .countstrip__num { font-size: 24px; font-weight: 700; letter-spacing: -.02em; }
214
+ .countstrip__label { font-size: 13px; color: var(--text-secondary); }
215
+ @media (max-width: 560px){ .countstrip { grid-template-columns: repeat(2, 1fr); } }
187
216
 
188
217
  /* ----------------------------------------------------------------- buttons */
189
218
  .btn {
@@ -195,7 +224,7 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
195
224
  .btn:hover { background: var(--surface-hover); }
196
225
  .btn:disabled { opacity: .5; cursor: not-allowed; }
197
226
  .btn svg { width: 15px; height: 15px; }
198
- .btn--primary { background: var(--accent); border-color: var(--accent); color: #fff; }
227
+ .btn--primary { background: var(--accent); border-color: var(--accent); color: var(--text-on-accent); }
199
228
  .btn--primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
200
229
  .btn--ghost { border-color: transparent; background: transparent; }
201
230
  .btn--ghost:hover { background: var(--surface-hover); }
@@ -216,7 +245,14 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
216
245
  background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
217
246
  padding: 8px 11px; transition: border-color .12s, box-shadow .12s;
218
247
  }
219
- .input:focus, .textarea:focus, .select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
248
+ .input:focus, .textarea:focus, .select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
249
+ /* keyboard focus must be clearly visible (the soft ring alone fails WCAG 1.4.11/2.4.11) */
250
+ .input:focus-visible, .textarea:focus-visible, .select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; box-shadow: none; }
251
+ /* skip link — hidden until focused, then drops into the top-left */
252
+ .skip-link { position: fixed; top: 8px; left: 8px; z-index: 200; transform: translateY(-200%);
253
+ background: var(--accent); color: var(--text-on-accent); padding: 8px 14px; border: 0;
254
+ border-radius: var(--radius-sm); font: inherit; font-weight: 600; cursor: pointer; transition: transform .15s; }
255
+ .skip-link:focus { transform: none; outline: 2px solid var(--text-on-accent); outline-offset: 2px; }
220
256
  .textarea { resize: vertical; min-height: 90px; line-height: 1.6; }
221
257
  .textarea--lg { min-height: 280px; font-size: 15px; }
222
258
  .input::placeholder, .textarea::placeholder { color: var(--text-tertiary); }
@@ -255,6 +291,9 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
255
291
  .table tbody tr.is-clickable:hover { background: var(--surface-hover); }
256
292
  .table .muted { color: var(--text-tertiary); }
257
293
  .table .strong { font-weight: 600; }
294
+ /* keyboard-reachable lead-cell link that looks identical to plain text */
295
+ .cell-link { color: inherit; text-decoration: none; }
296
+ .cell-link:hover { text-decoration: none; }
258
297
 
259
298
  /* ----------------------------------------------------------------- progress */
260
299
  .progress { height: 7px; background: var(--bg-sunken); border-radius: 999px; overflow: hidden; }
@@ -377,6 +416,9 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
377
416
  .modal__head { padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; }
378
417
  .modal__head h3 { font-size: 15px; }
379
418
  .modal__body { padding: 20px; overflow-y: auto; }
419
+ .modal__error { margin: 0 20px 4px; padding: 10px 12px; border-radius: var(--radius); border: 1px solid var(--red-border);
420
+ background: var(--red-soft); color: var(--red); font-size: 13px; line-height: 1.5; }
421
+ .modal__error[hidden] { display: none; }
380
422
  .modal__foot { padding: 14px 20px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; }
381
423
  @keyframes pop { from { transform: translate(-50%,-46%); opacity: 0; } to { transform: translate(-50%,-50%); opacity: 1; } }
382
424
  @keyframes fade { from { opacity: 0; } to { opacity: 1; } }
@@ -413,6 +455,7 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
413
455
  .skeleton { background: linear-gradient(90deg, var(--bg-sunken) 25%, var(--surface-hover) 37%, var(--bg-sunken) 63%); background-size: 400% 100%; animation: shimmer 1.4s infinite; border-radius: 6px; }
414
456
  @keyframes shimmer { 0% { background-position: 100% 0; } 100% { background-position: 0 0; } }
415
457
  .hide { display: none !important; }
458
+ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
416
459
 
417
460
  /* score color helper */
418
461
  .score-strong { color: var(--green); } .score-mid { color: var(--amber); } .score-weak { color: var(--red); }
@@ -424,3 +467,27 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
424
467
  .callout--privacy svg { color: var(--text-tertiary); }
425
468
  .callout__title { font-weight: 600; margin-bottom: 2px; }
426
469
  .callout__body { color: var(--text-secondary); font-size: 13px; }
470
+
471
+ /* ----------------------------------------------------------------- phone layout */
472
+ /* Placed last so these win over the base widths (media queries add no specificity). */
473
+ @media (max-width: 640px){
474
+ .topbar{ padding: 0 12px; gap: 8px; }
475
+ /* title takes the room between hamburger and actions and truncates instead of
476
+ collapsing to vertical text; the flex spacer isn't needed at this width */
477
+ .topbar__title{ flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
478
+ .topbar__spacer{ display: none; }
479
+ .input--inline{ width: 110px; max-width: 32vw; }
480
+ .view{ padding: 16px; }
481
+ /* jobs table: drop the secondary 위치·마감일 column so 회사·직무·적합도·상태 all fit */
482
+ .table--jobs th:nth-child(2), .table--jobs td:nth-child(2){ display: none; }
483
+ /* keep the 적합도 score ("88점") on one line in the narrow column */
484
+ .table--jobs th:nth-child(3), .table--jobs td:nth-child(3){ white-space: nowrap; }
485
+ .table--jobs td:nth-child(3) .tnum{ font-size: 13px; }
486
+ /* any remaining wide table can still scroll instead of crushing columns */
487
+ .table-scroll{ overflow-x: auto; -webkit-overflow-scrolling: touch; }
488
+ /* stack key/value so long emails·paths·URLs aren't clipped by the fixed label column */
489
+ .kv{ grid-template-columns: 1fr; gap: 0; }
490
+ .kv dt{ margin-top: 10px; }
491
+ .kv dd{ margin: 0 0 2px; }
492
+ .page-head{ flex-wrap: wrap; }
493
+ }
@@ -107,8 +107,13 @@ export async function readJsonBody<T>(req: IncomingMessage, schema?: ZodType<T>)
107
107
  return schema.parse(parsed);
108
108
  } catch (e) {
109
109
  if (e instanceof ZodError) {
110
- const msg = e.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join(', ');
111
- throw new HttpError(400, `입력값 검증 실패 — ${msg}`, 'validation');
110
+ // Messages already written as full Korean guidance are shown as-is (the zod
111
+ // path like "links.0.url" is developer jargon). Generic/English messages keep
112
+ // a field hint so the user knows which input to fix.
113
+ const msg = e.issues
114
+ .map((i) => (/[가-힣]/.test(i.message) ? i.message : `${i.path.join('.') || '입력값'}: ${i.message}`))
115
+ .join('\n');
116
+ throw new HttpError(400, msg, 'validation');
112
117
  }
113
118
  throw e;
114
119
  }
@@ -170,11 +175,33 @@ export function serveStatic(
170
175
  const mime = MIME[ext] ?? 'application/octet-stream';
171
176
  let payload: Buffer | string = fs.readFileSync(resolved);
172
177
  if (transform && (ext === '.html')) payload = transform(payload.toString('utf8'));
173
- res.writeHead(200, {
178
+ const headers: Record<string, string> = {
174
179
  'Content-Type': mime,
175
180
  'X-Content-Type-Options': 'nosniff',
176
181
  'Cache-Control': ext === '.html' ? 'no-store' : 'no-cache',
177
- });
182
+ };
183
+ if (ext === '.html') {
184
+ // Content-Security-Policy: with no inline scripts and only same-origin
185
+ // assets, any XSS that slips past escaping can't load external code or
186
+ // exfiltrate (connect-src 'self'). Dashboard pages (served with the token
187
+ // transform) hold the CSRF token and render user-controlled career data, so
188
+ // they get a strict no-inline-script policy. The install page (no transform)
189
+ // is static marketing with inline bootstrap scripts (theme/carousel), so it
190
+ // permits inline script but keeps every other hardening directive.
191
+ const scriptSrc = transform ? "script-src 'self'" : "script-src 'self' 'unsafe-inline'";
192
+ headers['Content-Security-Policy'] = [
193
+ "default-src 'none'",
194
+ scriptSrc,
195
+ "style-src 'self' 'unsafe-inline'", // inline style="" attrs + JS-set element styles
196
+ "img-src 'self' data:", // data: favicon + local screenshots/icons
197
+ "font-src 'self'",
198
+ "connect-src 'self'",
199
+ "base-uri 'none'",
200
+ "form-action 'none'",
201
+ "frame-ancestors 'none'", // clickjacking guard
202
+ ].join('; ');
203
+ }
204
+ res.writeHead(200, headers);
178
205
  res.end(payload);
179
206
  return true;
180
207
  }
@@ -3,21 +3,18 @@
3
3
  * endpoint. Lets non-technical users *see* where their data lives, which is a
4
4
  * core privacy promise: everything is on this machine.
5
5
  */
6
- import { getDataDir, getDbPath, getExportsDir, getBackupsDir } from '@careermate/db';
7
6
  import {
8
- profileRepo,
9
- experienceRepo,
10
- projectRepo,
11
- skillRepo,
12
- documentRepo,
13
- coverLetterRepo,
14
- jobRepo,
15
- applicationRepo,
16
- interviewRepo,
7
+ getDataDir,
8
+ getDbPath,
9
+ getExportsDir,
10
+ getBackupsDir,
11
+ getEntityCounts,
17
12
  } from '@careermate/db';
13
+ import { APP_VERSION } from '@careermate/shared';
18
14
 
19
15
  export const APP_NAME = 'CareerMate';
20
- export const APP_VERSION = '0.1.0';
16
+ // 버전은 @careermate/shared 단일 출처에서. (번들에서 package.json 버전으로 치환)
17
+ export { APP_VERSION };
21
18
 
22
19
  let _port = 0;
23
20
  export function setServerPort(port: number): void {
@@ -35,16 +32,6 @@ export function getServerInfo() {
35
32
  exports_dir: getExportsDir(),
36
33
  backups_dir: getBackupsDir(),
37
34
  node_version: process.version,
38
- counts: {
39
- profile: profileRepo.get() ? 1 : 0,
40
- experiences: experienceRepo.list().length,
41
- projects: projectRepo.list().length,
42
- skills: skillRepo.list().length,
43
- documents: documentRepo.list().length,
44
- cover_letters: coverLetterRepo.list().length,
45
- jobs: jobRepo.list().length,
46
- applications: applicationRepo.list().length,
47
- interview_preps: interviewRepo.list().length,
48
- },
35
+ counts: getEntityCounts(),
49
36
  };
50
37
  }
@@ -22,7 +22,9 @@ import {
22
22
  APPLICATION_STATUS_LABELS,
23
23
  DOCUMENT_KIND_LABELS,
24
24
  APPLICATION_BOARD_ORDER,
25
+ INTERVIEW_UNLOCK_STATUSES,
25
26
  type DocumentKind,
27
+ type JobInput,
26
28
  } from '@careermate/shared';
27
29
  import {
28
30
  profileRepo,
@@ -44,6 +46,9 @@ import {
44
46
  listRecentActivity,
45
47
  saveProfile,
46
48
  addResume,
49
+ addExperience,
50
+ addProject,
51
+ addSkill,
47
52
  saveJobPosting,
48
53
  saveFitAnalysis,
49
54
  saveCoverLetterVersion,
@@ -95,9 +100,11 @@ export function registerApiRoutes(router: Router): void {
95
100
  router.get('/api/health', () => ({ ok: true, ...getServerInfo() }));
96
101
  router.get('/api/onboarding', () => getOnboardingStatus());
97
102
  router.get('/api/summary', () => getHomeSummary());
98
- router.get('/api/activity', (ctx) => ({
99
- activities: listRecentActivity(Number(ctx.query.get('limit') ?? 30)),
100
- }));
103
+ router.get('/api/activity', (ctx) => {
104
+ const raw = Number(ctx.query.get('limit'));
105
+ const limit = Number.isFinite(raw) && raw > 0 ? Math.min(raw, 100) : 30;
106
+ return { activities: listRecentActivity(limit) };
107
+ });
101
108
  router.get('/api/context', (ctx) =>
102
109
  getApplicationContext({ job_id: ctx.query.get('job_id') ?? undefined }),
103
110
  );
@@ -119,9 +126,7 @@ export function registerApiRoutes(router: Router): void {
119
126
  router.get('/api/experiences', () => ({ experiences: experienceRepo.list() }));
120
127
  router.post('/api/experiences', async (ctx) => {
121
128
  const input = await readJsonBody(ctx.req, ExperienceInputSchema);
122
- const exp = experienceRepo.add(input);
123
- activityRepo.log('profile_updated', `경력(${exp.company})을 추가했습니다.`, 'experience', exp.id);
124
- return { experience: exp };
129
+ return { experience: addExperience(input) };
125
130
  });
126
131
  router.put('/api/experiences/:id', async (ctx) => {
127
132
  const input = await readJsonBody(ctx.req, ExperienceInputSchema.partial());
@@ -135,9 +140,7 @@ export function registerApiRoutes(router: Router): void {
135
140
  router.get('/api/projects', () => ({ projects: projectRepo.list() }));
136
141
  router.post('/api/projects', async (ctx) => {
137
142
  const input = await readJsonBody(ctx.req, ProjectInputSchema);
138
- const prj = projectRepo.add(input);
139
- activityRepo.log('profile_updated', `프로젝트(${prj.name})를 추가했습니다.`, 'project', prj.id);
140
- return { project: prj };
143
+ return { project: addProject(input) };
141
144
  });
142
145
  router.put('/api/projects/:id', async (ctx) => {
143
146
  const input = await readJsonBody(ctx.req, ProjectInputSchema.partial());
@@ -151,7 +154,7 @@ export function registerApiRoutes(router: Router): void {
151
154
  router.get('/api/skills', () => ({ skills: skillRepo.list() }));
152
155
  router.post('/api/skills', async (ctx) => {
153
156
  const input = await readJsonBody(ctx.req, SkillInputSchema);
154
- return { skill: skillRepo.add(input) };
157
+ return { skill: addSkill(input) };
155
158
  });
156
159
  router.put('/api/skills/:id', async (ctx) => {
157
160
  const input = await readJsonBody(ctx.req, SkillInputSchema.partial());
@@ -239,9 +242,11 @@ export function registerApiRoutes(router: Router): void {
239
242
  router.get('/api/jobs/:id', (ctx) => ({ job: jobDetail(id(ctx)) }));
240
243
  router.put('/api/jobs/:id', async (ctx) => {
241
244
  const input = await readJsonBody(ctx.req, JobInputSchema.partial());
242
- const existing = jobRepo.get(id(ctx));
243
- if (!existing) throw new HttpError(404, '공고를 찾을 수 없습니다.');
244
- const job = jobRepo.upsert({ ...existing, ...input } as any, existing.id);
245
+ const jid = id(ctx);
246
+ if (!jobRepo.get(jid)) throw new HttpError(404, '공고를 찾을 수 없습니다.');
247
+ // upsert가 id로 기존 레코드와 병합하므로 누락 필드는 기존 값이 유지된다.
248
+ // (존재를 위에서 확인했으므로 partial을 JobInput으로 좁혀 전달.)
249
+ const job = jobRepo.upsert(input as JobInput, jid);
245
250
  return { job: jobWithMeta(job) };
246
251
  });
247
252
  router.delete('/api/jobs/:id', (ctx) => ({ ok: jobRepo.remove(id(ctx)) }));
@@ -259,7 +264,7 @@ export function registerApiRoutes(router: Router): void {
259
264
  const preps = interviewRepo.list().map((p) => ({ ...p, job: jobRepo.get(p.job_id) }));
260
265
  const eligible = applicationRepo
261
266
  .list()
262
- .filter((a) => ['document_passed', 'interview', 'final_passed'].includes(a.status))
267
+ .filter((a) => INTERVIEW_UNLOCK_STATUSES.includes(a.status))
263
268
  .map((a) => ({
264
269
  job: jobRepo.get(a.job_id),
265
270
  status: a.status,
@@ -6,15 +6,12 @@ import http from 'node:http';
6
6
  import path from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { getDb, writeRuntimeInfo, clearRuntimeInfo } from '@careermate/db';
9
+ import { BUNDLED } from '@careermate/shared';
9
10
  import { checkRequest, injectToken } from './security.ts';
10
11
  import { Router, sendJson, sendDownload, serveStatic, HttpError } from './http.ts';
11
12
  import { registerApiRoutes, isDownload } from './routes.ts';
12
13
  import { setServerPort } from './info.ts';
13
14
 
14
- // 번들 빌드(esbuild)에서 true로 치환된다. tsx 개발 실행에서는 정의되지 않아 typeof로 안전 분기.
15
- declare const __BUNDLED__: boolean | undefined;
16
- const BUNDLED = typeof __BUNDLED__ !== 'undefined' && __BUNDLED__;
17
-
18
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
16
  // dev(tsx): apps/web/src 기준 상대경로 · prod(dist/web.mjs): dist 기준.
20
17
  const WEB_ROOT = BUNDLED ? path.resolve(__dirname, 'public') : path.resolve(__dirname, '..', 'public');
@@ -77,9 +74,11 @@ export function createServer(): http.Server {
77
74
  return;
78
75
  }
79
76
  if (pathname.startsWith('/install/')) {
77
+ // The install page is public static guidance and uses no API/CSRF token,
78
+ // so do not inject the session token there (least privilege).
80
79
  const rel = pathname === '/install/' ? 'index.html' : pathname.slice('/install/'.length);
81
- if (serveStatic(res, INSTALL_ROOT, rel, injectToken)) return;
82
- if (serveStatic(res, INSTALL_ROOT, 'index.html', injectToken)) return;
80
+ if (serveStatic(res, INSTALL_ROOT, rel)) return;
81
+ if (serveStatic(res, INSTALL_ROOT, 'index.html')) return;
83
82
  }
84
83
 
85
84
  // Dashboard static assets, with SPA fallback to index.html.
@@ -113,29 +112,35 @@ export function startServer(preferredPort: number): Promise<StartedServer> {
113
112
 
114
113
  return new Promise((resolve, reject) => {
115
114
  let attempt = 0;
116
- const tryListen = (port: number) => {
117
- server.once('error', (e: NodeJS.ErrnoException) => {
118
- if (e.code === 'EADDRINUSE' && attempt < 15) {
119
- attempt++;
120
- tryListen(port + 1);
121
- } else {
122
- reject(e);
123
- }
124
- });
125
- server.listen(port, '127.0.0.1', () => {
126
- setServerPort(port);
127
- const url = `http://127.0.0.1:${port}`;
128
- // Publish our address so the MCP server (a separate process) can find us.
129
- writeRuntimeInfo({ url, port, pid: process.pid, started_at: new Date().toISOString() });
130
- const cleanup = () => {
131
- clearRuntimeInfo();
132
- process.exit(0);
133
- };
134
- process.on('SIGINT', cleanup);
135
- process.on('SIGTERM', cleanup);
136
- resolve({ server, port, url });
137
- });
115
+ // Persistent handlers (attached once). Re-calling server.listen() on the same
116
+ // server for retries must NOT pass a one-time 'listening' callback per attempt:
117
+ // those accumulate and all fire on the eventual bind, so the FIRST (wrong) port
118
+ // would win the resolve. Instead read the real bound port from server.address().
119
+ const onError = (e: NodeJS.ErrnoException) => {
120
+ if (e.code === 'EADDRINUSE' && attempt < 15) {
121
+ attempt++;
122
+ server.listen(preferredPort + attempt, '127.0.0.1');
123
+ } else {
124
+ reject(e);
125
+ }
138
126
  };
139
- tryListen(preferredPort);
127
+ server.on('error', onError);
128
+ server.once('listening', () => {
129
+ server.removeListener('error', onError);
130
+ const addr = server.address();
131
+ const port = typeof addr === 'object' && addr ? addr.port : preferredPort;
132
+ setServerPort(port);
133
+ const url = `http://127.0.0.1:${port}`;
134
+ // Publish our address so the MCP server (a separate process) can find us.
135
+ writeRuntimeInfo({ url, port, pid: process.pid, started_at: new Date().toISOString() });
136
+ const cleanup = () => {
137
+ clearRuntimeInfo();
138
+ process.exit(0);
139
+ };
140
+ process.on('SIGINT', cleanup);
141
+ process.on('SIGTERM', cleanup);
142
+ resolve({ server, port, url });
143
+ });
144
+ server.listen(preferredPort, '127.0.0.1');
140
145
  });
141
146
  }
@@ -4,28 +4,32 @@
4
4
  */
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
+ import type { DatabaseSync } from 'node:sqlite';
7
8
  import { getDb, getDbPath, getBackupsDir, getDataDir } from '@careermate/db';
8
9
 
9
- const TABLES = [
10
- 'profile',
11
- 'experiences',
12
- 'projects',
13
- 'skills',
14
- 'documents',
15
- 'cover_letters',
16
- 'cover_letter_versions',
17
- 'jobs',
18
- 'fit_analyses',
19
- 'applications',
20
- 'interview_preps',
21
- 'activities',
22
- ];
10
+ /**
11
+ * Every user-data table, discovered from the live schema rather than a hand-kept
12
+ * list — so a table added by a future migration is automatically included in
13
+ * export/backup/reset (a stale list would silently skip it, e.g. dropping data
14
+ * on reset that was never backed up). `_meta` (schema_version) is intentionally
15
+ * excluded so reset keeps the migration state. Names come from sqlite_master,
16
+ * not user input, so interpolating them into SQL below is safe.
17
+ */
18
+ function listTables(db: DatabaseSync): string[] {
19
+ return (
20
+ db
21
+ .prepare(
22
+ `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name != '_meta' ORDER BY name`,
23
+ )
24
+ .all() as { name: string }[]
25
+ ).map((r) => r.name);
26
+ }
23
27
 
24
28
  /** Full machine-readable dump of every table — portable backup / GDPR-style export. */
25
29
  export function exportAll(): { exported_at: string; version: number; tables: Record<string, unknown[]> } {
26
30
  const db = getDb();
27
31
  const tables: Record<string, unknown[]> = {};
28
- for (const t of TABLES) {
32
+ for (const t of listTables(db)) {
29
33
  tables[t] = db.prepare(`SELECT * FROM ${t}`).all();
30
34
  }
31
35
  const ver = db.prepare(`SELECT value FROM _meta WHERE key='schema_version'`).get() as
@@ -40,8 +44,22 @@ export function createBackup(): { backup_path: string; json_path: string } {
40
44
  const dir = getBackupsDir();
41
45
  const dbBackup = path.join(dir, `careermate-${stamp}.sqlite`);
42
46
  const jsonBackup = path.join(dir, `careermate-${stamp}.json`);
43
- // Use SQLite's online backup via VACUUM INTO for a consistent snapshot.
47
+
48
+ // Defense in depth: the backup target must stay inside the backups directory,
49
+ // even though the filename is built from a controlled timestamp.
50
+ const resolvedDir = path.resolve(dir);
51
+ if (!path.resolve(dbBackup).startsWith(resolvedDir + path.sep)) {
52
+ throw new Error('백업 경로가 백업 폴더를 벗어났습니다.');
53
+ }
54
+
55
+ // Prefer SQLite's VACUUM INTO for a WAL-consistent snapshot. node:sqlite's
56
+ // exec() can't bind parameters, so the path is embedded as a quoted string
57
+ // literal (single quotes doubled). If the path can't be embedded safely
58
+ // (NUL / newline), fall back to a plain file copy instead of risking a
59
+ // malformed statement.
60
+ const safeForLiteral = !/[\0\n\r]/.test(dbBackup);
44
61
  try {
62
+ if (!safeForLiteral) throw new Error('backup path not safe for SQL literal');
45
63
  getDb().exec(`VACUUM INTO '${dbBackup.replace(/'/g, "''")}'`);
46
64
  } catch {
47
65
  fs.copyFileSync(getDbPath(), dbBackup);
@@ -60,7 +78,7 @@ export function resetAll(confirm: string): { ok: boolean; backup_path?: string }
60
78
  const db = getDb();
61
79
  db.exec('BEGIN');
62
80
  try {
63
- for (const t of TABLES) db.exec(`DELETE FROM ${t}`);
81
+ for (const t of listTables(db)) db.exec(`DELETE FROM ${t}`);
64
82
  db.exec('COMMIT');
65
83
  } catch (e) {
66
84
  db.exec('ROLLBACK');
@@ -33,11 +33,14 @@ const BUNDLES = {
33
33
  mcp: 'dist/mcp.mjs',
34
34
  start: 'dist/web.mjs',
35
35
  dashboard: 'dist/web.mjs',
36
+ doctor: 'dist/doctor.mjs',
37
+ migrate: 'dist/migrate.mjs',
36
38
  };
37
39
 
38
40
  const [cmd = 'help', ...rest] = process.argv.slice(2);
39
41
 
40
- if (cmd === 'help' || cmd === '--help' || cmd === '-h' || !ENTRIES[cmd]) {
42
+ const isHelp = cmd === 'help' || cmd === '--help' || cmd === '-h';
43
+ if (isHelp || !ENTRIES[cmd]) {
41
44
  const lines = [
42
45
  'CareerMate — 내 AI를 커리어 비서로 (로컬 MCP 도구)',
43
46
  '',
@@ -50,10 +53,10 @@ if (cmd === 'help' || cmd === '--help' || cmd === '-h' || !ENTRIES[cmd]) {
50
53
  '',
51
54
  '처음이라면: careermate init → AI 클라이언트 재시작',
52
55
  ];
53
- // 알 수 없는 명령이면 안내를 stderr로 보내 stdout(MCP 전송 채널)을 오염시키지 않는다.
54
- const out = ENTRIES[cmd] ? console.log : console.error;
55
- out(lines.join('\n'));
56
- process.exit(cmd === 'help' || cmd === '--help' || cmd === '-h' ? 0 : 1);
56
+ // 명시적 도움말은 stdout+exit0, 알 수 없는 명령은 stderr+exit1
57
+ // (stdout은 MCP 전송 채널이므로 오류 안내로 오염시키지 않는다).
58
+ (isHelp ? console.log : console.error)(lines.join('\n'));
59
+ process.exit(isHelp ? 0 : 1);
57
60
  }
58
61
 
59
62
  const bundle = BUNDLES[cmd] ? path.join(ROOT, BUNDLES[cmd]) : null;