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.
- package/README.md +22 -17
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/apps/mcp/src/index.ts +4 -3
- package/apps/web/DESIGN_GUIDE.md +2 -2
- package/apps/web/demo/demo-shim.js +536 -0
- package/apps/web/demo/demo.css +75 -0
- package/apps/web/demo/seed.js +269 -0
- package/apps/web/public/app.js +34 -2
- package/apps/web/public/icon-180.png +0 -0
- package/apps/web/public/index.html +14 -3
- package/apps/web/public/lib.js +135 -23
- package/apps/web/public/pages/applications.js +18 -9
- package/apps/web/public/pages/documents.js +66 -50
- package/apps/web/public/pages/home.js +12 -11
- package/apps/web/public/pages/interview.js +9 -12
- package/apps/web/public/pages/jobs.js +39 -36
- package/apps/web/public/pages/profile.js +66 -86
- package/apps/web/public/pages/settings.js +21 -14
- package/apps/web/public/styles.css +105 -38
- package/apps/web/src/http.ts +31 -4
- package/apps/web/src/info.ts +9 -22
- package/apps/web/src/routes.ts +19 -14
- package/apps/web/src/server.ts +34 -29
- package/apps/web/src/settings.ts +35 -17
- package/bin/careermate.mjs +8 -5
- package/dist/doctor.mjs +78908 -0
- package/dist/install-page/.omc/state/agent-replay-55a58c0d-bb6e-43ea-8a05-4b6c93b80b34.jsonl +12 -0
- package/dist/install-page/.omc/state/idle-notif-cooldown.json +3 -0
- package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/mission-state.json +147 -0
- package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/pre-tool-advisory-throttle.json +22 -0
- package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/subagent-tracking-state.json +44 -0
- package/dist/install-page/favicon.svg +1 -0
- package/dist/install-page/icon-180.png +0 -0
- package/dist/install-page/icon-512.png +0 -0
- package/dist/install-page/index.html +328 -225
- package/dist/install-page/landing.js +54 -0
- package/dist/install-page/llms-install.txt +202 -0
- package/dist/install-page/og.png +0 -0
- package/dist/install-page/shots/applications-dark.png +0 -0
- package/dist/install-page/shots/applications-light.png +0 -0
- package/dist/install-page/shots/documents-dark.png +0 -0
- package/dist/install-page/shots/documents-light.png +0 -0
- package/dist/install-page/shots/home-dark.png +0 -0
- package/dist/install-page/shots/home-light.png +0 -0
- package/dist/install-page/shots/interview-dark.png +0 -0
- package/dist/install-page/shots/interview-light.png +0 -0
- package/dist/install-page/shots/jobs-dark.png +0 -0
- package/dist/install-page/shots/jobs-light.png +0 -0
- package/dist/install-page/shots/settings-dark.png +0 -0
- package/dist/install-page/shots/settings-light.png +0 -0
- package/dist/install-page/start.html +323 -0
- package/dist/install-page/style.css +128 -4
- package/dist/mcp.mjs +74225 -2007
- package/dist/{mcpb-stage/dist/web.mjs → migrate.mjs} +142 -2660
- package/dist/pdf.worker.min.mjs +28 -0
- package/dist/public/app.js +34 -2
- package/dist/public/icon-180.png +0 -0
- package/dist/public/index.html +14 -3
- package/dist/public/lib.js +135 -23
- package/dist/public/pages/applications.js +18 -9
- package/dist/public/pages/documents.js +66 -50
- package/dist/public/pages/home.js +12 -11
- package/dist/public/pages/interview.js +9 -12
- package/dist/public/pages/jobs.js +39 -36
- package/dist/public/pages/profile.js +66 -86
- package/dist/public/pages/settings.js +21 -14
- package/dist/public/styles.css +105 -38
- package/dist/web.mjs +39950 -369
- package/docs/ARCHITECTURE.md +5 -5
- package/docs/DECISIONS.md +1 -1
- package/docs/DEMO.md +6 -6
- package/docs/MCP_TOOLS.md +68 -9
- package/docs/ROADMAP.md +11 -10
- package/docs/RUNTIME_DISTRIBUTION_RESEARCH.md +135 -0
- package/docs/START_WORKFLOW.md +19 -7
- package/docs/SUPPORTED_AI_APPS.md +85 -33
- package/docs/TODO.md +1 -1
- package/docs/WORKFLOWS.md +8 -6
- package/docs/superpowers/specs/2026-06-16-demo-dashboard-deploy-design.md +64 -0
- package/package.json +8 -3
- package/packages/core/src/services.ts +42 -1
- package/packages/db/src/connection.ts +30 -0
- package/packages/db/src/index.ts +1 -0
- package/packages/db/src/paths.ts +11 -0
- package/packages/db/src/repositories.ts +109 -63
- package/packages/db/src/schema.ts +118 -0
- package/packages/exporters/src/html.ts +9 -12
- package/packages/mcp-tools/src/bridge.ts +55 -32
- package/packages/mcp-tools/src/index.ts +9 -0
- package/packages/mcp-tools/src/tools.ts +269 -65
- package/packages/mcp-tools/src/update.ts +330 -0
- package/packages/parsers/src/documents.ts +521 -0
- package/packages/parsers/src/index.ts +8 -5
- package/packages/prompts/src/onboarding.ts +2 -2
- package/packages/shared/src/build-info.ts +22 -0
- package/packages/shared/src/enums.ts +39 -0
- package/packages/shared/src/index.ts +1 -0
- package/packages/shared/src/schemas.ts +24 -3
- package/packages/workflows/src/definitions.ts +4 -2
- package/scripts/build-demo.mjs +130 -0
- package/scripts/build-dist.mjs +46 -8
- package/scripts/build-mcpb.mjs +60 -4
- package/scripts/doctor.ts +16 -3
- package/scripts/gen-og.mjs +86 -0
- package/scripts/init.ts +32 -3
- package/scripts/seed.ts +5 -5
- package/scripts/shots-seed.ts +192 -0
- package/scripts/shots.ts +89 -0
- package/tsconfig.json +3 -1
- package/dist/careermate.mcpb +0 -0
- package/dist/mcp-smoke.err +0 -3
- package/dist/mcpb-stage/README.md +0 -219
- package/dist/mcpb-stage/dist/install-page/index.html +0 -434
- package/dist/mcpb-stage/dist/install-page/style.css +0 -407
- package/dist/mcpb-stage/dist/install-page/vercel.json +0 -20
- package/dist/mcpb-stage/dist/mcp.mjs +0 -23704
- package/dist/mcpb-stage/dist/public/app.js +0 -118
- package/dist/mcpb-stage/dist/public/index.html +0 -41
- package/dist/mcpb-stage/dist/public/lib.js +0 -282
- package/dist/mcpb-stage/dist/public/pages/applications.js +0 -98
- package/dist/mcpb-stage/dist/public/pages/documents.js +0 -446
- package/dist/mcpb-stage/dist/public/pages/home.js +0 -263
- package/dist/mcpb-stage/dist/public/pages/interview.js +0 -230
- package/dist/mcpb-stage/dist/public/pages/jobs.js +0 -494
- package/dist/mcpb-stage/dist/public/pages/profile.js +0 -576
- package/dist/mcpb-stage/dist/public/pages/settings.js +0 -233
- package/dist/mcpb-stage/dist/public/styles.css +0 -420
- package/dist/mcpb-stage/manifest.json +0 -40
- package/docs/INSTALL.md +0 -148
- package/docs/INSTALL_AND_USAGE.md +0 -99
- package/install-page/index.html +0 -474
- package/install-page/style.css +0 -391
- package/install-page/vercel.json +0 -20
- /package/dist/{mcpb-stage/dist/public → install-page}/fonts/PretendardVariable.woff2 +0 -0
|
@@ -19,24 +19,24 @@
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
:root {
|
|
22
|
-
/*
|
|
23
|
-
--bg: #
|
|
24
|
-
--bg-subtle: #
|
|
25
|
-
--bg-sunken: #
|
|
22
|
+
/* Warm paper neutrals (light) — unified with the public landing page */
|
|
23
|
+
--bg: #faf8f4;
|
|
24
|
+
--bg-subtle: #f2efe8;
|
|
25
|
+
--bg-sunken: #ece8df;
|
|
26
26
|
--surface: #ffffff;
|
|
27
|
-
--surface-hover: #
|
|
28
|
-
--border: #
|
|
29
|
-
--border-strong: #
|
|
30
|
-
--text: #
|
|
31
|
-
--text-secondary: #
|
|
32
|
-
--text-tertiary: #
|
|
27
|
+
--surface-hover: #f5f2ec;
|
|
28
|
+
--border: #e6e1d6;
|
|
29
|
+
--border-strong: #d6d0c2;
|
|
30
|
+
--text: #1c1a16;
|
|
31
|
+
--text-secondary: #595449;
|
|
32
|
+
--text-tertiary: #8b867a;
|
|
33
33
|
--text-on-accent: #ffffff;
|
|
34
34
|
|
|
35
|
-
/* Accent —
|
|
36
|
-
--accent: #
|
|
37
|
-
--accent-hover: #
|
|
38
|
-
--accent-soft: #
|
|
39
|
-
--accent-border: #
|
|
35
|
+
/* Accent — deep emerald (single flat accent, no gradient) */
|
|
36
|
+
--accent: #0f7256;
|
|
37
|
+
--accent-hover: #0b5b44;
|
|
38
|
+
--accent-soft: #e7f1ec;
|
|
39
|
+
--accent-border: #bcdccd;
|
|
40
40
|
|
|
41
41
|
/* Semantic */
|
|
42
42
|
--green: #15803d; --green-soft: #ecfdf3; --green-border: #bbf7d0;
|
|
@@ -67,31 +67,38 @@
|
|
|
67
67
|
|
|
68
68
|
@media (prefers-color-scheme: dark) {
|
|
69
69
|
:root:not([data-theme='light']) {
|
|
70
|
-
--bg: #
|
|
71
|
-
--bg-subtle: #
|
|
72
|
-
--bg-sunken: #
|
|
73
|
-
--surface: #
|
|
74
|
-
--surface-hover: #
|
|
75
|
-
--border: #
|
|
76
|
-
--border-strong: #
|
|
77
|
-
--text: #
|
|
78
|
-
--text-secondary: #
|
|
79
|
-
--text-tertiary: #
|
|
80
|
-
--accent: #
|
|
81
|
-
--accent-hover: #
|
|
82
|
-
--accent-soft: #
|
|
83
|
-
--accent-border: #
|
|
70
|
+
--bg: #14130f;
|
|
71
|
+
--bg-subtle: #201d17;
|
|
72
|
+
--bg-sunken: #100f0b;
|
|
73
|
+
--surface: #1b1915;
|
|
74
|
+
--surface-hover: #262219;
|
|
75
|
+
--border: #2e2a22;
|
|
76
|
+
--border-strong: #3e3930;
|
|
77
|
+
--text: #f2eee5;
|
|
78
|
+
--text-secondary: #aaa599;
|
|
79
|
+
--text-tertiary: #79746a;
|
|
80
|
+
--accent: #34d399;
|
|
81
|
+
--accent-hover: #6ee7b7;
|
|
82
|
+
--accent-soft: #11281d;
|
|
83
|
+
--accent-border: #1f4f3c;
|
|
84
|
+
--text-on-accent: #05241a;
|
|
84
85
|
--green-soft:#0e2a1a; --teal-soft:#0c2826; --blue-soft:#11203f; --violet-soft:#1e1638;
|
|
85
86
|
--amber-soft:#2a2008; --red-soft:#2c1414; --slate-soft:#1f242b;
|
|
87
|
+
/* lighter semantic foregrounds so status badges stay legible on dark soft bg */
|
|
88
|
+
--green:#4ade80; --teal:#2dd4bf; --blue:#60a5fa; --violet:#a78bfa; --amber:#fbbf24; --red:#ef4444; --slate:#94a3b8;
|
|
89
|
+
--green-border:#1f4f3c; --teal-border:#155e57; --blue-border:#1e3a6b; --violet-border:#3b2f6b; --amber-border:#5c4410; --red-border:#5c2020; --slate-border:#3a4250;
|
|
86
90
|
--shadow-lg: 0 12px 40px rgba(0,0,0,.5);
|
|
87
91
|
}
|
|
88
92
|
}
|
|
89
93
|
:root[data-theme='dark'] {
|
|
90
|
-
--bg:#
|
|
91
|
-
--border:#
|
|
92
|
-
--accent:#
|
|
94
|
+
--bg:#14130f; --bg-subtle:#201d17; --bg-sunken:#100f0b; --surface:#1b1915; --surface-hover:#262219;
|
|
95
|
+
--border:#2e2a22; --border-strong:#3e3930; --text:#f2eee5; --text-secondary:#aaa599; --text-tertiary:#79746a;
|
|
96
|
+
--accent:#34d399; --accent-hover:#6ee7b7; --accent-soft:#11281d; --accent-border:#1f4f3c; --text-on-accent:#05241a;
|
|
93
97
|
--green-soft:#0e2a1a; --teal-soft:#0c2826; --blue-soft:#11203f; --violet-soft:#1e1638;
|
|
94
|
-
--amber-soft:#2a2008; --red-soft:#2c1414; --slate-soft:#1f242b;
|
|
98
|
+
--amber-soft:#2a2008; --red-soft:#2c1414; --slate-soft:#1f242b;
|
|
99
|
+
--green:#4ade80; --teal:#2dd4bf; --blue:#60a5fa; --violet:#a78bfa; --amber:#fbbf24; --red:#ef4444; --slate:#94a3b8;
|
|
100
|
+
--green-border:#1f4f3c; --teal-border:#155e57; --blue-border:#1e3a6b; --violet-border:#3b2f6b; --amber-border:#5c4410; --red-border:#5c2020; --slate-border:#3a4250;
|
|
101
|
+
--shadow-lg:0 12px 40px rgba(0,0,0,.5);
|
|
95
102
|
}
|
|
96
103
|
|
|
97
104
|
* { box-sizing: border-box; }
|
|
@@ -120,7 +127,7 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
|
|
|
120
127
|
.sidebar__logo {
|
|
121
128
|
width: 30px; height: 30px; border-radius: 8px; flex: none;
|
|
122
129
|
background: var(--accent);
|
|
123
|
-
display: grid; place-items: center; color:
|
|
130
|
+
display: grid; place-items: center; color: var(--text-on-accent); font-weight: 800; font-size: 15px;
|
|
124
131
|
}
|
|
125
132
|
.sidebar__title { font-weight: 700; font-size: 15px; letter-spacing: -.01em; }
|
|
126
133
|
.sidebar__subtitle { font-size: 12px; color: var(--text-tertiary); margin-top: 1px; }
|
|
@@ -135,7 +142,7 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
|
|
|
135
142
|
.nav__item svg { width: 17px; height: 17px; flex: none; }
|
|
136
143
|
.nav__badge { margin-left: auto; font-size: 11.5px; background: var(--bg-sunken); color: var(--text-secondary);
|
|
137
144
|
padding: 1px 7px; border-radius: 999px; font-weight: 600; }
|
|
138
|
-
.nav__item.is-active .nav__badge { background: var(--accent); color:
|
|
145
|
+
.nav__item.is-active .nav__badge { background: var(--accent); color: var(--text-on-accent); }
|
|
139
146
|
.sidebar__foot { padding: 12px 16px; border-top: 1px solid var(--border); font-size: 12px; color: var(--text-tertiary); }
|
|
140
147
|
.sidebar__foot .dot { display:inline-block; width:7px; height:7px; border-radius:999px; background: var(--green); margin-right:6px; }
|
|
141
148
|
|
|
@@ -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){
|
|
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:
|
|
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 {
|
|
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
|
+
}
|
package/apps/web/src/http.ts
CHANGED
|
@@ -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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
}
|
package/apps/web/src/info.ts
CHANGED
|
@@ -3,21 +3,18 @@
|
|
|
3
3
|
* endpoint. Lets non-technical users *see* where their data lives, which is a
|
|
4
4
|
* core privacy promise: everything is on this machine.
|
|
5
5
|
*/
|
|
6
|
-
import { getDataDir, getDbPath, getExportsDir, getBackupsDir } from '@careermate/db';
|
|
7
6
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
coverLetterRepo,
|
|
14
|
-
jobRepo,
|
|
15
|
-
applicationRepo,
|
|
16
|
-
interviewRepo,
|
|
7
|
+
getDataDir,
|
|
8
|
+
getDbPath,
|
|
9
|
+
getExportsDir,
|
|
10
|
+
getBackupsDir,
|
|
11
|
+
getEntityCounts,
|
|
17
12
|
} from '@careermate/db';
|
|
13
|
+
import { APP_VERSION } from '@careermate/shared';
|
|
18
14
|
|
|
19
15
|
export const APP_NAME = 'CareerMate';
|
|
20
|
-
|
|
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
|
}
|
package/apps/web/src/routes.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
243
|
-
if (!
|
|
244
|
-
|
|
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) =>
|
|
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,
|
package/apps/web/src/server.ts
CHANGED
|
@@ -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
|
|
82
|
-
if (serveStatic(res, INSTALL_ROOT, 'index.html'
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const url = `http://127.0.0.1:${port}`;
|
|
128
|
-
// Publish our address so the MCP server (a separate process) can find us.
|
|
129
|
-
writeRuntimeInfo({ url, port, pid: process.pid, started_at: new Date().toISOString() });
|
|
130
|
-
const cleanup = () => {
|
|
131
|
-
clearRuntimeInfo();
|
|
132
|
-
process.exit(0);
|
|
133
|
-
};
|
|
134
|
-
process.on('SIGINT', cleanup);
|
|
135
|
-
process.on('SIGTERM', cleanup);
|
|
136
|
-
resolve({ server, port, url });
|
|
137
|
-
});
|
|
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
|
-
|
|
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
|
}
|
package/apps/web/src/settings.ts
CHANGED
|
@@ -4,28 +4,32 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
7
8
|
import { getDb, getDbPath, getBackupsDir, getDataDir } from '@careermate/db';
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Every user-data table, discovered from the live schema rather than a hand-kept
|
|
12
|
+
* list — so a table added by a future migration is automatically included in
|
|
13
|
+
* export/backup/reset (a stale list would silently skip it, e.g. dropping data
|
|
14
|
+
* on reset that was never backed up). `_meta` (schema_version) is intentionally
|
|
15
|
+
* excluded so reset keeps the migration state. Names come from sqlite_master,
|
|
16
|
+
* not user input, so interpolating them into SQL below is safe.
|
|
17
|
+
*/
|
|
18
|
+
function listTables(db: DatabaseSync): string[] {
|
|
19
|
+
return (
|
|
20
|
+
db
|
|
21
|
+
.prepare(
|
|
22
|
+
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name != '_meta' ORDER BY name`,
|
|
23
|
+
)
|
|
24
|
+
.all() as { name: string }[]
|
|
25
|
+
).map((r) => r.name);
|
|
26
|
+
}
|
|
23
27
|
|
|
24
28
|
/** Full machine-readable dump of every table — portable backup / GDPR-style export. */
|
|
25
29
|
export function exportAll(): { exported_at: string; version: number; tables: Record<string, unknown[]> } {
|
|
26
30
|
const db = getDb();
|
|
27
31
|
const tables: Record<string, unknown[]> = {};
|
|
28
|
-
for (const t of
|
|
32
|
+
for (const t of listTables(db)) {
|
|
29
33
|
tables[t] = db.prepare(`SELECT * FROM ${t}`).all();
|
|
30
34
|
}
|
|
31
35
|
const ver = db.prepare(`SELECT value FROM _meta WHERE key='schema_version'`).get() as
|
|
@@ -40,8 +44,22 @@ export function createBackup(): { backup_path: string; json_path: string } {
|
|
|
40
44
|
const dir = getBackupsDir();
|
|
41
45
|
const dbBackup = path.join(dir, `careermate-${stamp}.sqlite`);
|
|
42
46
|
const jsonBackup = path.join(dir, `careermate-${stamp}.json`);
|
|
43
|
-
|
|
47
|
+
|
|
48
|
+
// Defense in depth: the backup target must stay inside the backups directory,
|
|
49
|
+
// even though the filename is built from a controlled timestamp.
|
|
50
|
+
const resolvedDir = path.resolve(dir);
|
|
51
|
+
if (!path.resolve(dbBackup).startsWith(resolvedDir + path.sep)) {
|
|
52
|
+
throw new Error('백업 경로가 백업 폴더를 벗어났습니다.');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Prefer SQLite's VACUUM INTO for a WAL-consistent snapshot. node:sqlite's
|
|
56
|
+
// exec() can't bind parameters, so the path is embedded as a quoted string
|
|
57
|
+
// literal (single quotes doubled). If the path can't be embedded safely
|
|
58
|
+
// (NUL / newline), fall back to a plain file copy instead of risking a
|
|
59
|
+
// malformed statement.
|
|
60
|
+
const safeForLiteral = !/[\0\n\r]/.test(dbBackup);
|
|
44
61
|
try {
|
|
62
|
+
if (!safeForLiteral) throw new Error('backup path not safe for SQL literal');
|
|
45
63
|
getDb().exec(`VACUUM INTO '${dbBackup.replace(/'/g, "''")}'`);
|
|
46
64
|
} catch {
|
|
47
65
|
fs.copyFileSync(getDbPath(), dbBackup);
|
|
@@ -60,7 +78,7 @@ export function resetAll(confirm: string): { ok: boolean; backup_path?: string }
|
|
|
60
78
|
const db = getDb();
|
|
61
79
|
db.exec('BEGIN');
|
|
62
80
|
try {
|
|
63
|
-
for (const t of
|
|
81
|
+
for (const t of listTables(db)) db.exec(`DELETE FROM ${t}`);
|
|
64
82
|
db.exec('COMMIT');
|
|
65
83
|
} catch (e) {
|
|
66
84
|
db.exec('ROLLBACK');
|
package/bin/careermate.mjs
CHANGED
|
@@ -33,11 +33,14 @@ const BUNDLES = {
|
|
|
33
33
|
mcp: 'dist/mcp.mjs',
|
|
34
34
|
start: 'dist/web.mjs',
|
|
35
35
|
dashboard: 'dist/web.mjs',
|
|
36
|
+
doctor: 'dist/doctor.mjs',
|
|
37
|
+
migrate: 'dist/migrate.mjs',
|
|
36
38
|
};
|
|
37
39
|
|
|
38
40
|
const [cmd = 'help', ...rest] = process.argv.slice(2);
|
|
39
41
|
|
|
40
|
-
|
|
42
|
+
const isHelp = cmd === 'help' || cmd === '--help' || cmd === '-h';
|
|
43
|
+
if (isHelp || !ENTRIES[cmd]) {
|
|
41
44
|
const lines = [
|
|
42
45
|
'CareerMate — 내 AI를 커리어 비서로 (로컬 MCP 도구)',
|
|
43
46
|
'',
|
|
@@ -50,10 +53,10 @@ if (cmd === 'help' || cmd === '--help' || cmd === '-h' || !ENTRIES[cmd]) {
|
|
|
50
53
|
'',
|
|
51
54
|
'처음이라면: careermate init → AI 클라이언트 재시작',
|
|
52
55
|
];
|
|
53
|
-
// 알 수 없는
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
process.exit(
|
|
56
|
+
// 명시적 도움말은 stdout+exit0, 알 수 없는 명령은 stderr+exit1
|
|
57
|
+
// (stdout은 MCP 전송 채널이므로 오류 안내로 오염시키지 않는다).
|
|
58
|
+
(isHelp ? console.log : console.error)(lines.join('\n'));
|
|
59
|
+
process.exit(isHelp ? 0 : 1);
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
const bundle = BUNDLES[cmd] ? path.join(ROOT, BUNDLES[cmd]) : null;
|