careermate 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +10 -7
  2. package/apps/mcp/src/index.ts +3 -1
  3. package/apps/web/DESIGN_GUIDE.md +2 -2
  4. package/apps/web/public/icon-180.png +0 -0
  5. package/apps/web/public/index.html +4 -1
  6. package/apps/web/public/lib.js +1 -1
  7. package/apps/web/public/pages/applications.js +17 -1
  8. package/apps/web/public/pages/settings.js +6 -4
  9. package/apps/web/public/styles.css +49 -36
  10. package/apps/web/src/info.ts +9 -22
  11. package/apps/web/src/routes.ts +17 -13
  12. package/apps/web/src/server.ts +33 -25
  13. package/apps/web/src/settings.ts +35 -17
  14. package/bin/careermate.mjs +8 -5
  15. package/dist/{mcpb-stage/dist/web.mjs → doctor.mjs} +1122 -1450
  16. package/dist/install-page/favicon.svg +1 -0
  17. package/dist/install-page/icon-180.png +0 -0
  18. package/dist/install-page/icon-512.png +0 -0
  19. package/dist/install-page/index.html +309 -167
  20. package/dist/install-page/llms-install.txt +202 -0
  21. package/dist/install-page/og.png +0 -0
  22. package/dist/install-page/shots/applications-dark.png +0 -0
  23. package/dist/install-page/shots/applications-light.png +0 -0
  24. package/dist/install-page/shots/documents-dark.png +0 -0
  25. package/dist/install-page/shots/documents-light.png +0 -0
  26. package/dist/install-page/shots/home-dark.png +0 -0
  27. package/dist/install-page/shots/home-light.png +0 -0
  28. package/dist/install-page/shots/interview-dark.png +0 -0
  29. package/dist/install-page/shots/interview-light.png +0 -0
  30. package/dist/install-page/shots/jobs-dark.png +0 -0
  31. package/dist/install-page/shots/jobs-light.png +0 -0
  32. package/dist/install-page/shots/settings-dark.png +0 -0
  33. package/dist/install-page/shots/settings-light.png +0 -0
  34. package/dist/install-page/start.html +346 -0
  35. package/dist/install-page/style.css +94 -3
  36. package/dist/mcp.mjs +326 -68
  37. package/dist/migrate.mjs +4715 -0
  38. package/dist/public/icon-180.png +0 -0
  39. package/dist/public/index.html +4 -1
  40. package/dist/public/lib.js +1 -1
  41. package/dist/public/pages/applications.js +17 -1
  42. package/dist/public/pages/settings.js +6 -4
  43. package/dist/public/styles.css +49 -36
  44. package/dist/web.mjs +323 -129
  45. package/docs/DEMO.md +3 -3
  46. package/docs/MCP_TOOLS.md +21 -2
  47. package/docs/ROADMAP.md +3 -3
  48. package/docs/RUNTIME_DISTRIBUTION_RESEARCH.md +135 -0
  49. package/docs/START_WORKFLOW.md +6 -0
  50. package/docs/SUPPORTED_AI_APPS.md +85 -33
  51. package/docs/WORKFLOWS.md +1 -1
  52. package/package.json +3 -2
  53. package/packages/core/src/services.ts +42 -1
  54. package/packages/db/src/connection.ts +30 -0
  55. package/packages/db/src/index.ts +1 -0
  56. package/packages/db/src/repositories.ts +109 -63
  57. package/packages/db/src/schema.ts +118 -0
  58. package/packages/exporters/src/html.ts +9 -12
  59. package/packages/mcp-tools/src/tools.ts +81 -0
  60. package/packages/shared/src/enums.ts +39 -0
  61. package/scripts/build-dist.mjs +27 -8
  62. package/scripts/build-mcpb.mjs +60 -4
  63. package/scripts/gen-og.mjs +86 -0
  64. package/scripts/init.ts +32 -3
  65. package/scripts/seed.ts +5 -5
  66. package/scripts/shots-seed.ts +192 -0
  67. package/scripts/shots.ts +89 -0
  68. package/tsconfig.json +3 -1
  69. package/dist/careermate.mcpb +0 -0
  70. package/dist/mcp-smoke.err +0 -3
  71. package/dist/mcpb-stage/README.md +0 -219
  72. package/dist/mcpb-stage/dist/install-page/index.html +0 -434
  73. package/dist/mcpb-stage/dist/install-page/style.css +0 -407
  74. package/dist/mcpb-stage/dist/install-page/vercel.json +0 -20
  75. package/dist/mcpb-stage/dist/mcp.mjs +0 -23704
  76. package/dist/mcpb-stage/dist/public/app.js +0 -118
  77. package/dist/mcpb-stage/dist/public/index.html +0 -41
  78. package/dist/mcpb-stage/dist/public/lib.js +0 -282
  79. package/dist/mcpb-stage/dist/public/pages/applications.js +0 -98
  80. package/dist/mcpb-stage/dist/public/pages/documents.js +0 -446
  81. package/dist/mcpb-stage/dist/public/pages/home.js +0 -263
  82. package/dist/mcpb-stage/dist/public/pages/interview.js +0 -230
  83. package/dist/mcpb-stage/dist/public/pages/jobs.js +0 -494
  84. package/dist/mcpb-stage/dist/public/pages/profile.js +0 -576
  85. package/dist/mcpb-stage/dist/public/pages/settings.js +0 -233
  86. package/dist/mcpb-stage/dist/public/styles.css +0 -420
  87. package/dist/mcpb-stage/manifest.json +0 -40
  88. package/docs/INSTALL.md +0 -148
  89. package/docs/INSTALL_AND_USAGE.md +0 -99
  90. package/install-page/index.html +0 -474
  91. package/install-page/style.css +0 -391
  92. package/install-page/vercel.json +0 -20
  93. /package/dist/{mcpb-stage/dist/public → install-page}/fonts/PretendardVariable.woff2 +0 -0
package/dist/web.mjs CHANGED
@@ -216,6 +216,123 @@ var MIGRATIONS = [
216
216
  CREATE INDEX IF NOT EXISTS idx_clv_letter ON cover_letter_versions(cover_letter_id);
217
217
  CREATE INDEX IF NOT EXISTS idx_cl_job ON cover_letters(job_id);
218
218
  CREATE INDEX IF NOT EXISTS idx_activities_created ON activities(created_at DESC);
219
+ `,
220
+ // v2 — add foreign keys so PRAGMA foreign_keys=ON is actually enforced.
221
+ //
222
+ // SQLite cannot ALTER a table to add constraints, so each table that needs an
223
+ // FK is recreated (create new → copy → drop old → rename). The copy also
224
+ // repairs pre-existing orphans: child rows whose parent is gone are dropped
225
+ // (CASCADE intent) and dangling optional refs are nulled (SET NULL intent), so
226
+ // a subsequent PRAGMA foreign_key_check stays clean. FKs are disabled for the
227
+ // rebuild and the whole thing runs in one transaction (auto-rolled-back if the
228
+ // process dies mid-migration, since the version bump happens only afterwards).
229
+ `
230
+ PRAGMA foreign_keys=OFF;
231
+ BEGIN;
232
+
233
+ -- cover_letters.job_id -> jobs(id) ON DELETE SET NULL
234
+ CREATE TABLE cover_letters_new (
235
+ id TEXT PRIMARY KEY,
236
+ title TEXT NOT NULL,
237
+ job_id TEXT REFERENCES jobs(id) ON DELETE SET NULL,
238
+ is_primary INTEGER NOT NULL DEFAULT 0,
239
+ current_version_id TEXT,
240
+ created_at TEXT NOT NULL,
241
+ updated_at TEXT NOT NULL
242
+ );
243
+ INSERT INTO cover_letters_new (id,title,job_id,is_primary,current_version_id,created_at,updated_at)
244
+ SELECT id,title,
245
+ CASE WHEN job_id IN (SELECT id FROM jobs) THEN job_id ELSE NULL END,
246
+ is_primary,current_version_id,created_at,updated_at
247
+ FROM cover_letters;
248
+ DROP TABLE cover_letters;
249
+ ALTER TABLE cover_letters_new RENAME TO cover_letters;
250
+
251
+ -- cover_letter_versions.cover_letter_id -> cover_letters(id) ON DELETE CASCADE
252
+ CREATE TABLE cover_letter_versions_new (
253
+ id TEXT PRIMARY KEY,
254
+ cover_letter_id TEXT NOT NULL REFERENCES cover_letters(id) ON DELETE CASCADE,
255
+ version_no INTEGER NOT NULL,
256
+ content TEXT NOT NULL,
257
+ note TEXT,
258
+ source TEXT NOT NULL DEFAULT 'ai',
259
+ created_at TEXT NOT NULL
260
+ );
261
+ INSERT INTO cover_letter_versions_new (id,cover_letter_id,version_no,content,note,source,created_at)
262
+ SELECT id,cover_letter_id,version_no,content,note,source,created_at
263
+ FROM cover_letter_versions
264
+ WHERE cover_letter_id IN (SELECT id FROM cover_letters);
265
+ DROP TABLE cover_letter_versions;
266
+ ALTER TABLE cover_letter_versions_new RENAME TO cover_letter_versions;
267
+
268
+ -- fit_analyses.job_id -> jobs(id) ON DELETE CASCADE
269
+ CREATE TABLE fit_analyses_new (
270
+ id TEXT PRIMARY KEY,
271
+ job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
272
+ score REAL,
273
+ summary TEXT,
274
+ strengths TEXT NOT NULL DEFAULT '[]',
275
+ gaps TEXT NOT NULL DEFAULT '[]',
276
+ matched_keywords TEXT NOT NULL DEFAULT '[]',
277
+ missing_keywords TEXT NOT NULL DEFAULT '[]',
278
+ recommendations TEXT NOT NULL DEFAULT '[]',
279
+ created_at TEXT NOT NULL,
280
+ updated_at TEXT NOT NULL
281
+ );
282
+ INSERT INTO fit_analyses_new (id,job_id,score,summary,strengths,gaps,matched_keywords,missing_keywords,recommendations,created_at,updated_at)
283
+ SELECT id,job_id,score,summary,strengths,gaps,matched_keywords,missing_keywords,recommendations,created_at,updated_at
284
+ FROM fit_analyses
285
+ WHERE job_id IN (SELECT id FROM jobs);
286
+ DROP TABLE fit_analyses;
287
+ ALTER TABLE fit_analyses_new RENAME TO fit_analyses;
288
+
289
+ -- applications: job_id CASCADE, resume_id/cover_letter_id SET NULL
290
+ CREATE TABLE applications_new (
291
+ id TEXT PRIMARY KEY,
292
+ job_id TEXT NOT NULL UNIQUE REFERENCES jobs(id) ON DELETE CASCADE,
293
+ status TEXT NOT NULL DEFAULT 'draft',
294
+ resume_id TEXT REFERENCES documents(id) ON DELETE SET NULL,
295
+ cover_letter_id TEXT REFERENCES cover_letters(id) ON DELETE SET NULL,
296
+ applied_at TEXT,
297
+ notes TEXT,
298
+ created_at TEXT NOT NULL,
299
+ updated_at TEXT NOT NULL
300
+ );
301
+ INSERT INTO applications_new (id,job_id,status,resume_id,cover_letter_id,applied_at,notes,created_at,updated_at)
302
+ SELECT id,job_id,status,
303
+ CASE WHEN resume_id IN (SELECT id FROM documents) THEN resume_id ELSE NULL END,
304
+ CASE WHEN cover_letter_id IN (SELECT id FROM cover_letters) THEN cover_letter_id ELSE NULL END,
305
+ applied_at,notes,created_at,updated_at
306
+ FROM applications
307
+ WHERE job_id IN (SELECT id FROM jobs);
308
+ DROP TABLE applications;
309
+ ALTER TABLE applications_new RENAME TO applications;
310
+
311
+ -- interview_preps.job_id -> jobs(id) ON DELETE CASCADE
312
+ CREATE TABLE interview_preps_new (
313
+ id TEXT PRIMARY KEY,
314
+ job_id TEXT NOT NULL UNIQUE REFERENCES jobs(id) ON DELETE CASCADE,
315
+ questions TEXT NOT NULL DEFAULT '[]',
316
+ star_guides TEXT NOT NULL DEFAULT '[]',
317
+ self_introduction TEXT,
318
+ notes TEXT,
319
+ created_at TEXT NOT NULL,
320
+ updated_at TEXT NOT NULL
321
+ );
322
+ INSERT INTO interview_preps_new (id,job_id,questions,star_guides,self_introduction,notes,created_at,updated_at)
323
+ SELECT id,job_id,questions,star_guides,self_introduction,notes,created_at,updated_at
324
+ FROM interview_preps
325
+ WHERE job_id IN (SELECT id FROM jobs);
326
+ DROP TABLE interview_preps;
327
+ ALTER TABLE interview_preps_new RENAME TO interview_preps;
328
+
329
+ -- Recreate indexes dropped along with their tables.
330
+ CREATE INDEX IF NOT EXISTS idx_fit_job ON fit_analyses(job_id);
331
+ CREATE INDEX IF NOT EXISTS idx_clv_letter ON cover_letter_versions(cover_letter_id);
332
+ CREATE INDEX IF NOT EXISTS idx_cl_job ON cover_letters(job_id);
333
+
334
+ COMMIT;
335
+ PRAGMA foreign_keys=ON;
219
336
  `
220
337
  ];
221
338
  function migrate(db) {
@@ -247,6 +364,26 @@ function getDb() {
247
364
  _db = db;
248
365
  return db;
249
366
  }
367
+ var _inTx = false;
368
+ function tx(fn) {
369
+ if (_inTx) return fn();
370
+ const db = getDb();
371
+ db.exec("BEGIN");
372
+ _inTx = true;
373
+ try {
374
+ const result = fn();
375
+ db.exec("COMMIT");
376
+ return result;
377
+ } catch (err) {
378
+ try {
379
+ db.exec("ROLLBACK");
380
+ } catch {
381
+ }
382
+ throw err;
383
+ } finally {
384
+ _inTx = false;
385
+ }
386
+ }
250
387
  function toJson(value) {
251
388
  return JSON.stringify(value ?? null);
252
389
  }
@@ -305,6 +442,27 @@ var INTERVIEW_UNLOCK_STATUSES = [
305
442
  "interview",
306
443
  "final_passed"
307
444
  ];
445
+ var ALLOWED_STATUS_TRANSITIONS = {
446
+ draft: ["planned", "applied", "document_passed", "interview", "final_passed", "rejected", "on_hold"],
447
+ planned: ["applied", "document_passed", "interview", "final_passed", "rejected", "on_hold"],
448
+ applied: ["planned", "document_passed", "interview", "final_passed", "rejected", "on_hold"],
449
+ document_passed: ["planned", "applied", "interview", "final_passed", "rejected", "on_hold"],
450
+ interview: ["planned", "applied", "document_passed", "final_passed", "rejected", "on_hold"],
451
+ final_passed: ["document_passed", "interview", "rejected", "on_hold"],
452
+ rejected: ["draft", "planned", "applied", "document_passed", "interview", "final_passed", "on_hold"],
453
+ on_hold: ["draft", "planned", "applied", "document_passed", "interview", "final_passed", "rejected"]
454
+ };
455
+ function canTransitionStatus(from, to) {
456
+ if (from === to) return true;
457
+ return ALLOWED_STATUS_TRANSITIONS[from]?.includes(to) ?? false;
458
+ }
459
+ function assertStatusTransition(from, to) {
460
+ if (!canTransitionStatus(from, to)) {
461
+ throw new Error(
462
+ `'${APPLICATION_STATUS_LABELS[from]}'\uC5D0\uC11C '${APPLICATION_STATUS_LABELS[to]}'(\uC73C)\uB85C\uB294 \uC0C1\uD0DC\uB97C \uBC14\uAFC0 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.`
463
+ );
464
+ }
465
+ }
308
466
  var DOCUMENT_KINDS = ["resume", "career_description", "portfolio", "other"];
309
467
  var DOCUMENT_KIND_LABELS = {
310
468
  resume: "\uC774\uB825\uC11C",
@@ -4969,34 +5127,38 @@ var documentRepo = {
4969
5127
  const ts = now();
4970
5128
  const id3 = newId("doc_");
4971
5129
  const db = getDb();
4972
- if (input.is_primary) {
4973
- db.prepare(`UPDATE documents SET is_primary=0 WHERE kind=?`).run(input.kind);
4974
- }
4975
- db.prepare(
4976
- `INSERT INTO documents (id,kind,title,content,source,is_primary,tags,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)`
4977
- ).run(
4978
- id3,
4979
- input.kind,
4980
- input.title,
4981
- input.content ?? "",
4982
- input.source ?? "manual",
4983
- toBit(input.is_primary),
4984
- toJson(input.tags ?? []),
4985
- ts,
4986
- ts
4987
- );
4988
- return this.get(id3);
5130
+ return tx(() => {
5131
+ if (input.is_primary) {
5132
+ db.prepare(`UPDATE documents SET is_primary=0 WHERE kind=?`).run(input.kind);
5133
+ }
5134
+ db.prepare(
5135
+ `INSERT INTO documents (id,kind,title,content,source,is_primary,tags,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)`
5136
+ ).run(
5137
+ id3,
5138
+ input.kind,
5139
+ input.title,
5140
+ input.content ?? "",
5141
+ input.source ?? "manual",
5142
+ toBit(input.is_primary),
5143
+ toJson(input.tags ?? []),
5144
+ ts,
5145
+ ts
5146
+ );
5147
+ return this.get(id3);
5148
+ });
4989
5149
  },
4990
5150
  update(id3, input) {
4991
5151
  const cur = this.get(id3);
4992
5152
  if (!cur) return null;
4993
5153
  const db = getDb();
4994
5154
  const m = { ...cur, ...input };
4995
- if (input.is_primary) db.prepare(`UPDATE documents SET is_primary=0 WHERE kind=?`).run(m.kind);
4996
- db.prepare(
4997
- `UPDATE documents SET kind=?,title=?,content=?,source=?,is_primary=?,tags=?,updated_at=? WHERE id=?`
4998
- ).run(m.kind, m.title, m.content, m.source, toBit(m.is_primary), toJson(m.tags), now(), id3);
4999
- return this.get(id3);
5155
+ return tx(() => {
5156
+ if (input.is_primary) db.prepare(`UPDATE documents SET is_primary=0 WHERE kind=?`).run(m.kind);
5157
+ db.prepare(
5158
+ `UPDATE documents SET kind=?,title=?,content=?,source=?,is_primary=?,tags=?,updated_at=? WHERE id=?`
5159
+ ).run(m.kind, m.title, m.content, m.source, toBit(m.is_primary), toJson(m.tags), now(), id3);
5160
+ return this.get(id3);
5161
+ });
5000
5162
  },
5001
5163
  remove(id3) {
5002
5164
  return getDb().prepare(`DELETE FROM documents WHERE id=?`).run(id3).changes > 0;
@@ -5045,37 +5207,41 @@ var coverLetterRepo = {
5045
5207
  const ts = now();
5046
5208
  const id3 = newId("cl_");
5047
5209
  const db = getDb();
5048
- if (opts.is_primary) db.prepare(`UPDATE cover_letters SET is_primary=0`).run();
5049
- db.prepare(
5050
- `INSERT INTO cover_letters (id,title,job_id,is_primary,current_version_id,created_at,updated_at) VALUES (?,?,?,?,?,?,?)`
5051
- ).run(id3, opts.title, opts.job_id ?? null, toBit(opts.is_primary), null, ts, ts);
5052
- return this.get(id3);
5210
+ return tx(() => {
5211
+ if (opts.is_primary) db.prepare(`UPDATE cover_letters SET is_primary=0`).run();
5212
+ db.prepare(
5213
+ `INSERT INTO cover_letters (id,title,job_id,is_primary,current_version_id,created_at,updated_at) VALUES (?,?,?,?,?,?,?)`
5214
+ ).run(id3, opts.title, opts.job_id ?? null, toBit(opts.is_primary), null, ts, ts);
5215
+ return this.get(id3);
5216
+ });
5053
5217
  },
5054
5218
  /** Append a new version. Creates the cover letter if cover_letter_id is omitted. */
5055
5219
  addVersion(opts) {
5056
5220
  const db = getDb();
5057
- let clId = opts.cover_letter_id;
5058
- if (!clId) {
5059
- const cl = this.create({ title: opts.title ?? "\uC790\uAE30\uC18C\uAC1C\uC11C", job_id: opts.job_id ?? null });
5060
- clId = cl.id;
5061
- }
5062
- const maxRow = db.prepare(`SELECT MAX(version_no) AS m FROM cover_letter_versions WHERE cover_letter_id=?`).get(clId);
5063
- const versionNo = (maxRow.m ?? 0) + 1;
5064
- const vid = newId("clv_");
5065
- const ts = now();
5066
- db.prepare(
5067
- `INSERT INTO cover_letter_versions (id,cover_letter_id,version_no,content,note,source,created_at) VALUES (?,?,?,?,?,?,?)`
5068
- ).run(vid, clId, versionNo, opts.content, opts.note ?? null, opts.source ?? "ai", ts);
5069
- const setCurrent = opts.set_current !== false;
5070
- if (setCurrent) {
5071
- db.prepare(`UPDATE cover_letters SET current_version_id=?, updated_at=? WHERE id=?`).run(vid, ts, clId);
5072
- } else {
5073
- db.prepare(`UPDATE cover_letters SET updated_at=? WHERE id=?`).run(ts, clId);
5074
- }
5075
- if (opts.title) db.prepare(`UPDATE cover_letters SET title=? WHERE id=?`).run(opts.title, clId);
5076
- if (opts.job_id !== void 0)
5077
- db.prepare(`UPDATE cover_letters SET job_id=? WHERE id=?`).run(opts.job_id, clId);
5078
- return { coverLetter: this.get(clId), version: mapVersion(db.prepare(`SELECT * FROM cover_letter_versions WHERE id=?`).get(vid)) };
5221
+ return tx(() => {
5222
+ let clId = opts.cover_letter_id;
5223
+ if (!clId) {
5224
+ const cl = this.create({ title: opts.title ?? "\uC790\uAE30\uC18C\uAC1C\uC11C", job_id: opts.job_id ?? null });
5225
+ clId = cl.id;
5226
+ }
5227
+ const maxRow = db.prepare(`SELECT MAX(version_no) AS m FROM cover_letter_versions WHERE cover_letter_id=?`).get(clId);
5228
+ const versionNo = (maxRow.m ?? 0) + 1;
5229
+ const vid = newId("clv_");
5230
+ const ts = now();
5231
+ db.prepare(
5232
+ `INSERT INTO cover_letter_versions (id,cover_letter_id,version_no,content,note,source,created_at) VALUES (?,?,?,?,?,?,?)`
5233
+ ).run(vid, clId, versionNo, opts.content, opts.note ?? null, opts.source ?? "ai", ts);
5234
+ const setCurrent = opts.set_current !== false;
5235
+ if (setCurrent) {
5236
+ db.prepare(`UPDATE cover_letters SET current_version_id=?, updated_at=? WHERE id=?`).run(vid, ts, clId);
5237
+ } else {
5238
+ db.prepare(`UPDATE cover_letters SET updated_at=? WHERE id=?`).run(ts, clId);
5239
+ }
5240
+ if (opts.title) db.prepare(`UPDATE cover_letters SET title=? WHERE id=?`).run(opts.title, clId);
5241
+ if (opts.job_id !== void 0)
5242
+ db.prepare(`UPDATE cover_letters SET job_id=? WHERE id=?`).run(opts.job_id, clId);
5243
+ return { coverLetter: this.get(clId), version: mapVersion(db.prepare(`SELECT * FROM cover_letter_versions WHERE id=?`).get(vid)) };
5244
+ });
5079
5245
  },
5080
5246
  setCurrentVersion(coverLetterId, versionId) {
5081
5247
  getDb().prepare(`UPDATE cover_letters SET current_version_id=?, updated_at=? WHERE id=?`).run(versionId, now(), coverLetterId);
@@ -5083,14 +5249,18 @@ var coverLetterRepo = {
5083
5249
  },
5084
5250
  setPrimary(id3) {
5085
5251
  const db = getDb();
5086
- db.prepare(`UPDATE cover_letters SET is_primary=0`).run();
5087
- db.prepare(`UPDATE cover_letters SET is_primary=1, updated_at=? WHERE id=?`).run(now(), id3);
5088
- return this.get(id3);
5252
+ return tx(() => {
5253
+ db.prepare(`UPDATE cover_letters SET is_primary=0`).run();
5254
+ db.prepare(`UPDATE cover_letters SET is_primary=1, updated_at=? WHERE id=?`).run(now(), id3);
5255
+ return this.get(id3);
5256
+ });
5089
5257
  },
5090
5258
  remove(id3) {
5091
5259
  const db = getDb();
5092
- db.prepare(`DELETE FROM cover_letter_versions WHERE cover_letter_id=?`).run(id3);
5093
- return db.prepare(`DELETE FROM cover_letters WHERE id=?`).run(id3).changes > 0;
5260
+ return tx(() => {
5261
+ db.prepare(`DELETE FROM cover_letter_versions WHERE cover_letter_id=?`).run(id3);
5262
+ return db.prepare(`DELETE FROM cover_letters WHERE id=?`).run(id3).changes > 0;
5263
+ });
5094
5264
  }
5095
5265
  };
5096
5266
  function mapJob(r) {
@@ -5175,10 +5345,12 @@ var jobRepo = {
5175
5345
  },
5176
5346
  remove(id3) {
5177
5347
  const db = getDb();
5178
- db.prepare(`DELETE FROM fit_analyses WHERE job_id=?`).run(id3);
5179
- db.prepare(`DELETE FROM applications WHERE job_id=?`).run(id3);
5180
- db.prepare(`DELETE FROM interview_preps WHERE job_id=?`).run(id3);
5181
- return db.prepare(`DELETE FROM jobs WHERE id=?`).run(id3).changes > 0;
5348
+ return tx(() => {
5349
+ db.prepare(`DELETE FROM fit_analyses WHERE job_id=?`).run(id3);
5350
+ db.prepare(`DELETE FROM applications WHERE job_id=?`).run(id3);
5351
+ db.prepare(`DELETE FROM interview_preps WHERE job_id=?`).run(id3);
5352
+ return db.prepare(`DELETE FROM jobs WHERE id=?`).run(id3).changes > 0;
5353
+ });
5182
5354
  }
5183
5355
  };
5184
5356
  function mapFit(r) {
@@ -5285,6 +5457,7 @@ var applicationRepo = {
5285
5457
  },
5286
5458
  upsert(input) {
5287
5459
  const app = this.ensure(input.job_id, input.status ?? "draft");
5460
+ if (input.status && input.status !== app.status) assertStatusTransition(app.status, input.status);
5288
5461
  const m = { ...app, ...input };
5289
5462
  getDb().prepare(
5290
5463
  `UPDATE applications SET status=?,resume_id=?,cover_letter_id=?,applied_at=?,notes=?,updated_at=? WHERE id=?`
@@ -5293,6 +5466,7 @@ var applicationRepo = {
5293
5466
  },
5294
5467
  setStatus(jobId, status, note) {
5295
5468
  const app = this.ensure(jobId);
5469
+ assertStatusTransition(app.status, status);
5296
5470
  const appliedAt = status === "applied" && !app.applied_at ? now() : app.applied_at;
5297
5471
  const notes = note ? [app.notes, note].filter(Boolean).join("\n") : app.notes;
5298
5472
  getDb().prepare(`UPDATE applications SET status=?, applied_at=?, notes=?, updated_at=? WHERE id=?`).run(status, appliedAt, notes, now(), app.id);
@@ -5368,6 +5542,21 @@ var activityRepo = {
5368
5542
  return getDb().prepare(`SELECT * FROM activities ORDER BY created_at DESC LIMIT ?`).all(limit);
5369
5543
  }
5370
5544
  };
5545
+ function getEntityCounts() {
5546
+ const db = getDb();
5547
+ const c = (t) => db.prepare(`SELECT COUNT(*) AS n FROM ${t}`).get().n;
5548
+ return {
5549
+ profile: c("profile"),
5550
+ experiences: c("experiences"),
5551
+ projects: c("projects"),
5552
+ skills: c("skills"),
5553
+ documents: c("documents"),
5554
+ cover_letters: c("cover_letters"),
5555
+ jobs: c("jobs"),
5556
+ applications: c("applications"),
5557
+ interview_preps: c("interview_preps")
5558
+ };
5559
+ }
5371
5560
 
5372
5561
  // apps/web/src/security.ts
5373
5562
  import crypto from "node:crypto";
@@ -5677,6 +5866,21 @@ function addResume(input) {
5677
5866
  activityRepo.log("resume_added", `${doc.title} \uBB38\uC11C\uB97C \uCD94\uAC00\uD588\uC2B5\uB2C8\uB2E4.`, "document", doc.id);
5678
5867
  return doc;
5679
5868
  }
5869
+ function addExperience(input) {
5870
+ const exp = experienceRepo.add(input);
5871
+ activityRepo.log("profile_updated", `\uACBD\uB825(${exp.company})\uC744 \uCD94\uAC00\uD588\uC2B5\uB2C8\uB2E4.`, "experience", exp.id);
5872
+ return exp;
5873
+ }
5874
+ function addProject(input) {
5875
+ const prj = projectRepo.add(input);
5876
+ activityRepo.log("profile_updated", `\uD504\uB85C\uC81D\uD2B8(${prj.name})\uB97C \uCD94\uAC00\uD588\uC2B5\uB2C8\uB2E4.`, "project", prj.id);
5877
+ return prj;
5878
+ }
5879
+ function addSkill(input) {
5880
+ const skill = skillRepo.add(input);
5881
+ activityRepo.log("profile_updated", `\uAE30\uC220(${skill.name})\uC744 \uCD94\uAC00\uD588\uC2B5\uB2C8\uB2E4.`, "skill", skill.id);
5882
+ return skill;
5883
+ }
5680
5884
  function saveJobPosting(input, id3) {
5681
5885
  const job = jobRepo.upsert(input, id3);
5682
5886
  const application = applicationRepo.ensure(job.id, "draft");
@@ -5734,7 +5938,13 @@ function updateApplicationStatus(jobId, status, note) {
5734
5938
  function saveInterviewPrep(input) {
5735
5939
  const job = jobRepo.get(input.job_id);
5736
5940
  if (!job) throw new Error(`\uACF5\uACE0\uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${input.job_id}`);
5737
- applicationRepo.ensure(job.id);
5941
+ const app = applicationRepo.getByJob(job.id);
5942
+ if (!app || !INTERVIEW_UNLOCK_STATUSES.includes(app.status)) {
5943
+ const current = app ? APPLICATION_STATUS_LABELS[app.status] : "\uBBF8\uC9C0\uC6D0";
5944
+ throw new Error(
5945
+ `\uBA74\uC811 \uC900\uBE44\uB294 '\uC11C\uB958 \uD569\uACA9' \uC774\uC0C1 \uC0C1\uD0DC\uC5D0\uC11C\uB9CC \uC800\uC7A5\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4. \uD604\uC7AC \uC0C1\uD0DC: '${current}'. \uBA3C\uC800 update_application_status\uB85C \uC0C1\uD0DC\uB97C \uC62C\uB9B0 \uB4A4 \uB2E4\uC2DC \uC2DC\uB3C4\uD558\uC138\uC694.`
5946
+ );
5947
+ }
5738
5948
  const prep = interviewRepo.save(input);
5739
5949
  activityRepo.log("interview_prep_saved", `${job.company} \xB7 ${job.position} \uBA74\uC811 \uC900\uBE44 \uC790\uB8CC\uB97C \uC800\uC7A5\uD588\uC2B5\uB2C8\uB2E4.`, "interview_prep", prep.id);
5740
5950
  return prep;
@@ -6360,11 +6570,8 @@ function cleanJobPosting(raw) {
6360
6570
  function escapeHtml(input) {
6361
6571
  return (input ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
6362
6572
  }
6363
- function looksLikeHtml2(s) {
6364
- return /<\/?(p|h[1-6]|ul|ol|li|div|section|article|br|hr|strong|em|a|table)\b/i.test(s);
6365
- }
6366
- function toPrintableHtml(title, bodyMarkdownOrHtml) {
6367
- const body = looksLikeHtml2(bodyMarkdownOrHtml) ? bodyMarkdownOrHtml : markdownToHtml(bodyMarkdownOrHtml);
6573
+ function toPrintableHtml(title, bodyMarkdown) {
6574
+ const body = markdownToHtml(bodyMarkdown);
6368
6575
  const safeTitle = escapeHtml(title);
6369
6576
  return `<!doctype html>
6370
6577
  <html lang="ko">
@@ -6765,7 +6972,7 @@ function exportInterview(jobId, format) {
6765
6972
 
6766
6973
  // apps/web/src/info.ts
6767
6974
  var APP_NAME = "CareerMate";
6768
- var APP_VERSION = "0.1.0";
6975
+ var APP_VERSION = true ? "0.1.1" : "0.0.0-dev";
6769
6976
  var _port = 0;
6770
6977
  function setServerPort(port) {
6771
6978
  _port = port;
@@ -6781,41 +6988,22 @@ function getServerInfo() {
6781
6988
  exports_dir: getExportsDir(),
6782
6989
  backups_dir: getBackupsDir(),
6783
6990
  node_version: process.version,
6784
- counts: {
6785
- profile: profileRepo.get() ? 1 : 0,
6786
- experiences: experienceRepo.list().length,
6787
- projects: projectRepo.list().length,
6788
- skills: skillRepo.list().length,
6789
- documents: documentRepo.list().length,
6790
- cover_letters: coverLetterRepo.list().length,
6791
- jobs: jobRepo.list().length,
6792
- applications: applicationRepo.list().length,
6793
- interview_preps: interviewRepo.list().length
6794
- }
6991
+ counts: getEntityCounts()
6795
6992
  };
6796
6993
  }
6797
6994
 
6798
6995
  // apps/web/src/settings.ts
6799
6996
  import fs4 from "node:fs";
6800
6997
  import path4 from "node:path";
6801
- var TABLES = [
6802
- "profile",
6803
- "experiences",
6804
- "projects",
6805
- "skills",
6806
- "documents",
6807
- "cover_letters",
6808
- "cover_letter_versions",
6809
- "jobs",
6810
- "fit_analyses",
6811
- "applications",
6812
- "interview_preps",
6813
- "activities"
6814
- ];
6998
+ function listTables(db) {
6999
+ return db.prepare(
7000
+ `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name != '_meta' ORDER BY name`
7001
+ ).all().map((r) => r.name);
7002
+ }
6815
7003
  function exportAll() {
6816
7004
  const db = getDb();
6817
7005
  const tables = {};
6818
- for (const t of TABLES) {
7006
+ for (const t of listTables(db)) {
6819
7007
  tables[t] = db.prepare(`SELECT * FROM ${t}`).all();
6820
7008
  }
6821
7009
  const ver = db.prepare(`SELECT value FROM _meta WHERE key='schema_version'`).get();
@@ -6826,7 +7014,13 @@ function createBackup() {
6826
7014
  const dir = getBackupsDir();
6827
7015
  const dbBackup = path4.join(dir, `careermate-${stamp}.sqlite`);
6828
7016
  const jsonBackup = path4.join(dir, `careermate-${stamp}.json`);
7017
+ const resolvedDir = path4.resolve(dir);
7018
+ if (!path4.resolve(dbBackup).startsWith(resolvedDir + path4.sep)) {
7019
+ throw new Error("\uBC31\uC5C5 \uACBD\uB85C\uAC00 \uBC31\uC5C5 \uD3F4\uB354\uB97C \uBC97\uC5B4\uB0AC\uC2B5\uB2C8\uB2E4.");
7020
+ }
7021
+ const safeForLiteral = !/[\0\n\r]/.test(dbBackup);
6829
7022
  try {
7023
+ if (!safeForLiteral) throw new Error("backup path not safe for SQL literal");
6830
7024
  getDb().exec(`VACUUM INTO '${dbBackup.replace(/'/g, "''")}'`);
6831
7025
  } catch {
6832
7026
  fs4.copyFileSync(getDbPath(), dbBackup);
@@ -6842,7 +7036,7 @@ function resetAll(confirm) {
6842
7036
  const db = getDb();
6843
7037
  db.exec("BEGIN");
6844
7038
  try {
6845
- for (const t of TABLES) db.exec(`DELETE FROM ${t}`);
7039
+ for (const t of listTables(db)) db.exec(`DELETE FROM ${t}`);
6846
7040
  db.exec("COMMIT");
6847
7041
  } catch (e) {
6848
7042
  db.exec("ROLLBACK");
@@ -6891,9 +7085,11 @@ function registerApiRoutes(router2) {
6891
7085
  router2.get("/api/health", () => ({ ok: true, ...getServerInfo() }));
6892
7086
  router2.get("/api/onboarding", () => getOnboardingStatus());
6893
7087
  router2.get("/api/summary", () => getHomeSummary());
6894
- router2.get("/api/activity", (ctx) => ({
6895
- activities: listRecentActivity(Number(ctx.query.get("limit") ?? 30))
6896
- }));
7088
+ router2.get("/api/activity", (ctx) => {
7089
+ const raw = Number(ctx.query.get("limit"));
7090
+ const limit = Number.isFinite(raw) && raw > 0 ? Math.min(raw, 100) : 30;
7091
+ return { activities: listRecentActivity(limit) };
7092
+ });
6897
7093
  router2.get(
6898
7094
  "/api/context",
6899
7095
  (ctx) => getApplicationContext({ job_id: ctx.query.get("job_id") ?? void 0 })
@@ -6912,9 +7108,7 @@ function registerApiRoutes(router2) {
6912
7108
  router2.get("/api/experiences", () => ({ experiences: experienceRepo.list() }));
6913
7109
  router2.post("/api/experiences", async (ctx) => {
6914
7110
  const input = await readJsonBody(ctx.req, ExperienceInputSchema);
6915
- const exp = experienceRepo.add(input);
6916
- activityRepo.log("profile_updated", `\uACBD\uB825(${exp.company})\uC744 \uCD94\uAC00\uD588\uC2B5\uB2C8\uB2E4.`, "experience", exp.id);
6917
- return { experience: exp };
7111
+ return { experience: addExperience(input) };
6918
7112
  });
6919
7113
  router2.put("/api/experiences/:id", async (ctx) => {
6920
7114
  const input = await readJsonBody(ctx.req, ExperienceInputSchema.partial());
@@ -6926,9 +7120,7 @@ function registerApiRoutes(router2) {
6926
7120
  router2.get("/api/projects", () => ({ projects: projectRepo.list() }));
6927
7121
  router2.post("/api/projects", async (ctx) => {
6928
7122
  const input = await readJsonBody(ctx.req, ProjectInputSchema);
6929
- const prj = projectRepo.add(input);
6930
- activityRepo.log("profile_updated", `\uD504\uB85C\uC81D\uD2B8(${prj.name})\uB97C \uCD94\uAC00\uD588\uC2B5\uB2C8\uB2E4.`, "project", prj.id);
6931
- return { project: prj };
7123
+ return { project: addProject(input) };
6932
7124
  });
6933
7125
  router2.put("/api/projects/:id", async (ctx) => {
6934
7126
  const input = await readJsonBody(ctx.req, ProjectInputSchema.partial());
@@ -6940,7 +7132,7 @@ function registerApiRoutes(router2) {
6940
7132
  router2.get("/api/skills", () => ({ skills: skillRepo.list() }));
6941
7133
  router2.post("/api/skills", async (ctx) => {
6942
7134
  const input = await readJsonBody(ctx.req, SkillInputSchema);
6943
- return { skill: skillRepo.add(input) };
7135
+ return { skill: addSkill(input) };
6944
7136
  });
6945
7137
  router2.put("/api/skills/:id", async (ctx) => {
6946
7138
  const input = await readJsonBody(ctx.req, SkillInputSchema.partial());
@@ -7022,9 +7214,9 @@ function registerApiRoutes(router2) {
7022
7214
  router2.get("/api/jobs/:id", (ctx) => ({ job: jobDetail(id2(ctx)) }));
7023
7215
  router2.put("/api/jobs/:id", async (ctx) => {
7024
7216
  const input = await readJsonBody(ctx.req, JobInputSchema.partial());
7025
- const existing = jobRepo.get(id2(ctx));
7026
- if (!existing) throw new HttpError(404, "\uACF5\uACE0\uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.");
7027
- const job = jobRepo.upsert({ ...existing, ...input }, existing.id);
7217
+ const jid = id2(ctx);
7218
+ if (!jobRepo.get(jid)) throw new HttpError(404, "\uACF5\uACE0\uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.");
7219
+ const job = jobRepo.upsert(input, jid);
7028
7220
  return { job: jobWithMeta(job) };
7029
7221
  });
7030
7222
  router2.delete("/api/jobs/:id", (ctx) => ({ ok: jobRepo.remove(id2(ctx)) }));
@@ -7153,8 +7345,8 @@ function createServer() {
7153
7345
  }
7154
7346
  if (pathname.startsWith("/install/")) {
7155
7347
  const rel2 = pathname === "/install/" ? "index.html" : pathname.slice("/install/".length);
7156
- if (serveStatic(res, INSTALL_ROOT, rel2, injectToken)) return;
7157
- if (serveStatic(res, INSTALL_ROOT, "index.html", injectToken)) return;
7348
+ if (serveStatic(res, INSTALL_ROOT, rel2)) return;
7349
+ if (serveStatic(res, INSTALL_ROOT, "index.html")) return;
7158
7350
  }
7159
7351
  const rel = pathname === "/" ? "index.html" : pathname.slice(1);
7160
7352
  if (serveStatic(res, WEB_ROOT, rel, injectToken)) return;
@@ -7176,29 +7368,31 @@ function startServer(preferredPort) {
7176
7368
  const server = createServer();
7177
7369
  return new Promise((resolve, reject) => {
7178
7370
  let attempt = 0;
7179
- const tryListen = (port) => {
7180
- server.once("error", (e) => {
7181
- if (e.code === "EADDRINUSE" && attempt < 15) {
7182
- attempt++;
7183
- tryListen(port + 1);
7184
- } else {
7185
- reject(e);
7186
- }
7187
- });
7188
- server.listen(port, "127.0.0.1", () => {
7189
- setServerPort(port);
7190
- const url = `http://127.0.0.1:${port}`;
7191
- writeRuntimeInfo({ url, port, pid: process.pid, started_at: (/* @__PURE__ */ new Date()).toISOString() });
7192
- const cleanup = () => {
7193
- clearRuntimeInfo();
7194
- process.exit(0);
7195
- };
7196
- process.on("SIGINT", cleanup);
7197
- process.on("SIGTERM", cleanup);
7198
- resolve({ server, port, url });
7199
- });
7371
+ const onError = (e) => {
7372
+ if (e.code === "EADDRINUSE" && attempt < 15) {
7373
+ attempt++;
7374
+ server.listen(preferredPort + attempt, "127.0.0.1");
7375
+ } else {
7376
+ reject(e);
7377
+ }
7200
7378
  };
7201
- tryListen(preferredPort);
7379
+ server.on("error", onError);
7380
+ server.once("listening", () => {
7381
+ server.removeListener("error", onError);
7382
+ const addr = server.address();
7383
+ const port = typeof addr === "object" && addr ? addr.port : preferredPort;
7384
+ setServerPort(port);
7385
+ const url = `http://127.0.0.1:${port}`;
7386
+ writeRuntimeInfo({ url, port, pid: process.pid, started_at: (/* @__PURE__ */ new Date()).toISOString() });
7387
+ const cleanup = () => {
7388
+ clearRuntimeInfo();
7389
+ process.exit(0);
7390
+ };
7391
+ process.on("SIGINT", cleanup);
7392
+ process.on("SIGTERM", cleanup);
7393
+ resolve({ server, port, url });
7394
+ });
7395
+ server.listen(preferredPort, "127.0.0.1");
7202
7396
  });
7203
7397
  }
7204
7398
 
package/docs/DEMO.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  > 핵심 모델: **CareerMate 안에는 LLM이 없습니다.** 분석·작성은 당신의 AI(ChatGPT/Claude/Gemini)가 수행하고, CareerMate는 로컬 DB에 데이터를 읽고/쓰는 **MCP 도구**를 제공합니다. 모든 데이터는 로컬(`~/.careermate`)에만 저장됩니다.
9
9
  >
10
- > 관련 문서: [`INSTALL.md`](./INSTALL.md) · [`START_WORKFLOW.md`](./START_WORKFLOW.md)
10
+ > 관련 문서: [`INSTALL.md`](../INSTALL.md) · [`START_WORKFLOW.md`](./START_WORKFLOW.md)
11
11
 
12
12
  ---
13
13
 
@@ -106,7 +106,7 @@ npm start
106
106
 
107
107
  ### 5단계 — AI 클라이언트에서 MCP 연결 확인 🧑
108
108
 
109
- - CareerMate는 **로컬 stdio MCP 서버**라, 내 컴퓨터에서 실행되며 로컬 프로그램을 띄울 수 있는 클라이언트에서만 동작합니다 — Claude Desktop · Cursor · Cline · Windsurf · Claude Code. (ChatGPT·Gemini의 웹/앱은 클라우드에서 돌아 로컬 MCP 서버에 직접 연결할 수 없습니다.) 설치는 두 갈래입니다: 비개발자는 `.mcpb` 원클릭, 개발자는 `npm run init` 자동 등록 — 자세한 절차는 [`INSTALL_AND_USAGE.md`](./INSTALL_AND_USAGE.md)를 참고하세요. MCP 서버 자체는 보통 다음 형태로 기동됩니다(클라이언트가 자동 실행).
109
+ - CareerMate는 **로컬 stdio MCP 서버**라, 내 컴퓨터에서 실행되며 로컬 프로그램을 띄울 수 있는 클라이언트에서만 동작합니다 — Claude Desktop · Cursor · Cline · Windsurf · Claude Code. (ChatGPT·Gemini의 웹/앱은 클라우드에서 돌아 로컬 MCP 서버에 직접 연결할 수 없습니다.) 설치는 크게 세 갈래입니다: 에이전트가 한 문장으로 대신 설치 / 터미널 `npx -y careermate init` / Claude Desktop `.mcpb` — 자세한 절차는 [`SUPPORTED_AI_APPS.md`](./SUPPORTED_AI_APPS.md)(또는 사람용 안내 https://careermate.life)를 참고하세요. MCP 서버 자체는 보통 다음 형태로 기동됩니다(클라이언트가 자동 실행).
110
110
 
111
111
  ```bash
112
112
  npm run mcp
@@ -239,4 +239,4 @@ npm run mcp
239
239
  7. `save_interview_prep` — 면접 준비 저장
240
240
  8. `open_application` / `open_dashboard` — 사용자에게 결과 보여주기
241
241
 
242
- > 더 자세한 단계별 런북은 [`START_WORKFLOW.md`](./START_WORKFLOW.md), 설치·연결은 [`INSTALL.md`](./INSTALL.md)를 참고하세요.
242
+ > 더 자세한 단계별 런북은 [`START_WORKFLOW.md`](./START_WORKFLOW.md), 설치·연결은 [`INSTALL.md`](../INSTALL.md)를 참고하세요.