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
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import {
|
|
7
7
|
newId,
|
|
8
8
|
now,
|
|
9
|
+
assertStatusTransition,
|
|
9
10
|
type ProfileInput,
|
|
10
11
|
type ProfileRecord,
|
|
11
12
|
type ExperienceInput,
|
|
@@ -33,7 +34,7 @@ import {
|
|
|
33
34
|
type ContentSource,
|
|
34
35
|
type DocumentKind,
|
|
35
36
|
} from '@careermate/shared';
|
|
36
|
-
import { getDb, toJson, fromJson, toBit, fromBit } from './connection.ts';
|
|
37
|
+
import { getDb, tx, toJson, fromJson, toBit, fromBit } from './connection.ts';
|
|
37
38
|
|
|
38
39
|
const PROFILE_ID = 'profile-singleton';
|
|
39
40
|
|
|
@@ -364,34 +365,40 @@ export const documentRepo = {
|
|
|
364
365
|
const ts = now();
|
|
365
366
|
const id = newId('doc_');
|
|
366
367
|
const db = getDb();
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
368
|
+
// Demoting the previous primary + inserting the new doc must be atomic.
|
|
369
|
+
return tx(() => {
|
|
370
|
+
if (input.is_primary) {
|
|
371
|
+
db.prepare(`UPDATE documents SET is_primary=0 WHERE kind=?`).run(input.kind);
|
|
372
|
+
}
|
|
373
|
+
db.prepare(
|
|
374
|
+
`INSERT INTO documents (id,kind,title,content,source,is_primary,tags,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)`,
|
|
375
|
+
).run(
|
|
376
|
+
id,
|
|
377
|
+
input.kind,
|
|
378
|
+
input.title,
|
|
379
|
+
input.content ?? '',
|
|
380
|
+
(input.source ?? 'manual') as ContentSource,
|
|
381
|
+
toBit(input.is_primary),
|
|
382
|
+
toJson(input.tags ?? []),
|
|
383
|
+
ts,
|
|
384
|
+
ts,
|
|
385
|
+
);
|
|
386
|
+
return this.get(id)!;
|
|
387
|
+
});
|
|
384
388
|
},
|
|
385
389
|
update(id: string, input: Partial<DocumentInput>): DocumentRecord | null {
|
|
386
390
|
const cur = this.get(id);
|
|
387
391
|
if (!cur) return null;
|
|
388
392
|
const db = getDb();
|
|
389
393
|
const m = { ...cur, ...input } as DocumentRecord;
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
`UPDATE documents SET
|
|
393
|
-
|
|
394
|
-
|
|
394
|
+
// Demoting the previous primary + updating this doc must be atomic.
|
|
395
|
+
return tx(() => {
|
|
396
|
+
if (input.is_primary) db.prepare(`UPDATE documents SET is_primary=0 WHERE kind=?`).run(m.kind);
|
|
397
|
+
db.prepare(
|
|
398
|
+
`UPDATE documents SET kind=?,title=?,content=?,source=?,is_primary=?,tags=?,updated_at=? WHERE id=?`,
|
|
399
|
+
).run(m.kind, m.title, m.content, m.source, toBit(m.is_primary), toJson(m.tags), now(), id);
|
|
400
|
+
return this.get(id);
|
|
401
|
+
});
|
|
395
402
|
},
|
|
396
403
|
remove(id: string): boolean {
|
|
397
404
|
return getDb().prepare(`DELETE FROM documents WHERE id=?`).run(id).changes > 0;
|
|
@@ -453,11 +460,13 @@ export const coverLetterRepo = {
|
|
|
453
460
|
const ts = now();
|
|
454
461
|
const id = newId('cl_');
|
|
455
462
|
const db = getDb();
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
463
|
+
return tx(() => {
|
|
464
|
+
if (opts.is_primary) db.prepare(`UPDATE cover_letters SET is_primary=0`).run();
|
|
465
|
+
db.prepare(
|
|
466
|
+
`INSERT INTO cover_letters (id,title,job_id,is_primary,current_version_id,created_at,updated_at) VALUES (?,?,?,?,?,?,?)`,
|
|
467
|
+
).run(id, opts.title, opts.job_id ?? null, toBit(opts.is_primary), null, ts, ts);
|
|
468
|
+
return this.get(id)!;
|
|
469
|
+
});
|
|
461
470
|
},
|
|
462
471
|
/** Append a new version. Creates the cover letter if cover_letter_id is omitted. */
|
|
463
472
|
addVersion(opts: {
|
|
@@ -470,32 +479,36 @@ export const coverLetterRepo = {
|
|
|
470
479
|
set_current?: boolean;
|
|
471
480
|
}): { coverLetter: CoverLetterRecord; version: CoverLetterVersionRecord } {
|
|
472
481
|
const db = getDb();
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
clId =
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
482
|
+
// Create-if-needed + version INSERT + current_version_id/title/job_id UPDATEs
|
|
483
|
+
// must all commit together, or mapCoverLetter's "current" lookup goes stale.
|
|
484
|
+
return tx(() => {
|
|
485
|
+
let clId = opts.cover_letter_id;
|
|
486
|
+
if (!clId) {
|
|
487
|
+
const cl = this.create({ title: opts.title ?? '자기소개서', job_id: opts.job_id ?? null });
|
|
488
|
+
clId = cl.id;
|
|
489
|
+
}
|
|
490
|
+
const maxRow = db
|
|
491
|
+
.prepare(`SELECT MAX(version_no) AS m FROM cover_letter_versions WHERE cover_letter_id=?`)
|
|
492
|
+
.get(clId) as { m: number | null };
|
|
493
|
+
const versionNo = (maxRow.m ?? 0) + 1;
|
|
494
|
+
const vid = newId('clv_');
|
|
495
|
+
const ts = now();
|
|
496
|
+
db.prepare(
|
|
497
|
+
`INSERT INTO cover_letter_versions (id,cover_letter_id,version_no,content,note,source,created_at) VALUES (?,?,?,?,?,?,?)`,
|
|
498
|
+
).run(vid, clId, versionNo, opts.content, opts.note ?? null, opts.source ?? 'ai', ts);
|
|
499
|
+
|
|
500
|
+
const setCurrent = opts.set_current !== false;
|
|
501
|
+
if (setCurrent) {
|
|
502
|
+
db.prepare(`UPDATE cover_letters SET current_version_id=?, updated_at=? WHERE id=?`).run(vid, ts, clId);
|
|
503
|
+
} else {
|
|
504
|
+
db.prepare(`UPDATE cover_letters SET updated_at=? WHERE id=?`).run(ts, clId);
|
|
505
|
+
}
|
|
506
|
+
if (opts.title) db.prepare(`UPDATE cover_letters SET title=? WHERE id=?`).run(opts.title, clId);
|
|
507
|
+
if (opts.job_id !== undefined)
|
|
508
|
+
db.prepare(`UPDATE cover_letters SET job_id=? WHERE id=?`).run(opts.job_id, clId);
|
|
509
|
+
|
|
510
|
+
return { coverLetter: this.get(clId)!, version: mapVersion(db.prepare(`SELECT * FROM cover_letter_versions WHERE id=?`).get(vid)) };
|
|
511
|
+
});
|
|
499
512
|
},
|
|
500
513
|
setCurrentVersion(coverLetterId: string, versionId: string): CoverLetterRecord | null {
|
|
501
514
|
getDb()
|
|
@@ -505,14 +518,18 @@ export const coverLetterRepo = {
|
|
|
505
518
|
},
|
|
506
519
|
setPrimary(id: string): CoverLetterRecord | null {
|
|
507
520
|
const db = getDb();
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
521
|
+
return tx(() => {
|
|
522
|
+
db.prepare(`UPDATE cover_letters SET is_primary=0`).run();
|
|
523
|
+
db.prepare(`UPDATE cover_letters SET is_primary=1, updated_at=? WHERE id=?`).run(now(), id);
|
|
524
|
+
return this.get(id);
|
|
525
|
+
});
|
|
511
526
|
},
|
|
512
527
|
remove(id: string): boolean {
|
|
513
528
|
const db = getDb();
|
|
514
|
-
|
|
515
|
-
|
|
529
|
+
return tx(() => {
|
|
530
|
+
db.prepare(`DELETE FROM cover_letter_versions WHERE cover_letter_id=?`).run(id);
|
|
531
|
+
return db.prepare(`DELETE FROM cover_letters WHERE id=?`).run(id).changes > 0;
|
|
532
|
+
});
|
|
516
533
|
},
|
|
517
534
|
};
|
|
518
535
|
|
|
@@ -603,10 +620,13 @@ export const jobRepo = {
|
|
|
603
620
|
},
|
|
604
621
|
remove(id: string): boolean {
|
|
605
622
|
const db = getDb();
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
623
|
+
// Delete the job and all its dependent rows atomically (no orphans on failure).
|
|
624
|
+
return tx(() => {
|
|
625
|
+
db.prepare(`DELETE FROM fit_analyses WHERE job_id=?`).run(id);
|
|
626
|
+
db.prepare(`DELETE FROM applications WHERE job_id=?`).run(id);
|
|
627
|
+
db.prepare(`DELETE FROM interview_preps WHERE job_id=?`).run(id);
|
|
628
|
+
return db.prepare(`DELETE FROM jobs WHERE id=?`).run(id).changes > 0;
|
|
629
|
+
});
|
|
610
630
|
},
|
|
611
631
|
};
|
|
612
632
|
|
|
@@ -727,6 +747,8 @@ export const applicationRepo = {
|
|
|
727
747
|
},
|
|
728
748
|
upsert(input: ApplicationInput): ApplicationRecord {
|
|
729
749
|
const app = this.ensure(input.job_id, input.status ?? 'draft');
|
|
750
|
+
// A status change through upsert must respect the same lifecycle gate as setStatus.
|
|
751
|
+
if (input.status && input.status !== app.status) assertStatusTransition(app.status, input.status);
|
|
730
752
|
const m = { ...app, ...input } as ApplicationRecord;
|
|
731
753
|
getDb()
|
|
732
754
|
.prepare(
|
|
@@ -737,6 +759,7 @@ export const applicationRepo = {
|
|
|
737
759
|
},
|
|
738
760
|
setStatus(jobId: string, status: ApplicationStatus, note?: string): ApplicationRecord {
|
|
739
761
|
const app = this.ensure(jobId);
|
|
762
|
+
assertStatusTransition(app.status, status);
|
|
740
763
|
const appliedAt =
|
|
741
764
|
status === 'applied' && !app.applied_at ? now() : app.applied_at;
|
|
742
765
|
const notes = note ? [app.notes, note].filter(Boolean).join('\n') : app.notes;
|
|
@@ -826,3 +849,26 @@ export const activityRepo = {
|
|
|
826
849
|
.all(limit) as ActivityRecord[];
|
|
827
850
|
},
|
|
828
851
|
};
|
|
852
|
+
|
|
853
|
+
/* ------------------------------------------------------------------ Counts */
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Entity counts for the dashboard/Settings/health endpoint. Uses COUNT(*) per
|
|
857
|
+
* table instead of loading every row (the /api/health call hits this), and keeps
|
|
858
|
+
* the raw SQL in the db layer. Table names are hardcoded constants, not input.
|
|
859
|
+
*/
|
|
860
|
+
export function getEntityCounts(): Record<string, number> {
|
|
861
|
+
const db = getDb();
|
|
862
|
+
const c = (t: string): number => (db.prepare(`SELECT COUNT(*) AS n FROM ${t}`).get() as { n: number }).n;
|
|
863
|
+
return {
|
|
864
|
+
profile: c('profile'),
|
|
865
|
+
experiences: c('experiences'),
|
|
866
|
+
projects: c('projects'),
|
|
867
|
+
skills: c('skills'),
|
|
868
|
+
documents: c('documents'),
|
|
869
|
+
cover_letters: c('cover_letters'),
|
|
870
|
+
jobs: c('jobs'),
|
|
871
|
+
applications: c('applications'),
|
|
872
|
+
interview_preps: c('interview_preps'),
|
|
873
|
+
};
|
|
874
|
+
}
|
|
@@ -165,6 +165,124 @@ export const MIGRATIONS: string[] = [
|
|
|
165
165
|
CREATE INDEX IF NOT EXISTS idx_cl_job ON cover_letters(job_id);
|
|
166
166
|
CREATE INDEX IF NOT EXISTS idx_activities_created ON activities(created_at DESC);
|
|
167
167
|
`,
|
|
168
|
+
|
|
169
|
+
// v2 — add foreign keys so PRAGMA foreign_keys=ON is actually enforced.
|
|
170
|
+
//
|
|
171
|
+
// SQLite cannot ALTER a table to add constraints, so each table that needs an
|
|
172
|
+
// FK is recreated (create new → copy → drop old → rename). The copy also
|
|
173
|
+
// repairs pre-existing orphans: child rows whose parent is gone are dropped
|
|
174
|
+
// (CASCADE intent) and dangling optional refs are nulled (SET NULL intent), so
|
|
175
|
+
// a subsequent PRAGMA foreign_key_check stays clean. FKs are disabled for the
|
|
176
|
+
// rebuild and the whole thing runs in one transaction (auto-rolled-back if the
|
|
177
|
+
// process dies mid-migration, since the version bump happens only afterwards).
|
|
178
|
+
`
|
|
179
|
+
PRAGMA foreign_keys=OFF;
|
|
180
|
+
BEGIN;
|
|
181
|
+
|
|
182
|
+
-- cover_letters.job_id -> jobs(id) ON DELETE SET NULL
|
|
183
|
+
CREATE TABLE cover_letters_new (
|
|
184
|
+
id TEXT PRIMARY KEY,
|
|
185
|
+
title TEXT NOT NULL,
|
|
186
|
+
job_id TEXT REFERENCES jobs(id) ON DELETE SET NULL,
|
|
187
|
+
is_primary INTEGER NOT NULL DEFAULT 0,
|
|
188
|
+
current_version_id TEXT,
|
|
189
|
+
created_at TEXT NOT NULL,
|
|
190
|
+
updated_at TEXT NOT NULL
|
|
191
|
+
);
|
|
192
|
+
INSERT INTO cover_letters_new (id,title,job_id,is_primary,current_version_id,created_at,updated_at)
|
|
193
|
+
SELECT id,title,
|
|
194
|
+
CASE WHEN job_id IN (SELECT id FROM jobs) THEN job_id ELSE NULL END,
|
|
195
|
+
is_primary,current_version_id,created_at,updated_at
|
|
196
|
+
FROM cover_letters;
|
|
197
|
+
DROP TABLE cover_letters;
|
|
198
|
+
ALTER TABLE cover_letters_new RENAME TO cover_letters;
|
|
199
|
+
|
|
200
|
+
-- cover_letter_versions.cover_letter_id -> cover_letters(id) ON DELETE CASCADE
|
|
201
|
+
CREATE TABLE cover_letter_versions_new (
|
|
202
|
+
id TEXT PRIMARY KEY,
|
|
203
|
+
cover_letter_id TEXT NOT NULL REFERENCES cover_letters(id) ON DELETE CASCADE,
|
|
204
|
+
version_no INTEGER NOT NULL,
|
|
205
|
+
content TEXT NOT NULL,
|
|
206
|
+
note TEXT,
|
|
207
|
+
source TEXT NOT NULL DEFAULT 'ai',
|
|
208
|
+
created_at TEXT NOT NULL
|
|
209
|
+
);
|
|
210
|
+
INSERT INTO cover_letter_versions_new (id,cover_letter_id,version_no,content,note,source,created_at)
|
|
211
|
+
SELECT id,cover_letter_id,version_no,content,note,source,created_at
|
|
212
|
+
FROM cover_letter_versions
|
|
213
|
+
WHERE cover_letter_id IN (SELECT id FROM cover_letters);
|
|
214
|
+
DROP TABLE cover_letter_versions;
|
|
215
|
+
ALTER TABLE cover_letter_versions_new RENAME TO cover_letter_versions;
|
|
216
|
+
|
|
217
|
+
-- fit_analyses.job_id -> jobs(id) ON DELETE CASCADE
|
|
218
|
+
CREATE TABLE fit_analyses_new (
|
|
219
|
+
id TEXT PRIMARY KEY,
|
|
220
|
+
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
|
221
|
+
score REAL,
|
|
222
|
+
summary TEXT,
|
|
223
|
+
strengths TEXT NOT NULL DEFAULT '[]',
|
|
224
|
+
gaps TEXT NOT NULL DEFAULT '[]',
|
|
225
|
+
matched_keywords TEXT NOT NULL DEFAULT '[]',
|
|
226
|
+
missing_keywords TEXT NOT NULL DEFAULT '[]',
|
|
227
|
+
recommendations TEXT NOT NULL DEFAULT '[]',
|
|
228
|
+
created_at TEXT NOT NULL,
|
|
229
|
+
updated_at TEXT NOT NULL
|
|
230
|
+
);
|
|
231
|
+
INSERT INTO fit_analyses_new (id,job_id,score,summary,strengths,gaps,matched_keywords,missing_keywords,recommendations,created_at,updated_at)
|
|
232
|
+
SELECT id,job_id,score,summary,strengths,gaps,matched_keywords,missing_keywords,recommendations,created_at,updated_at
|
|
233
|
+
FROM fit_analyses
|
|
234
|
+
WHERE job_id IN (SELECT id FROM jobs);
|
|
235
|
+
DROP TABLE fit_analyses;
|
|
236
|
+
ALTER TABLE fit_analyses_new RENAME TO fit_analyses;
|
|
237
|
+
|
|
238
|
+
-- applications: job_id CASCADE, resume_id/cover_letter_id SET NULL
|
|
239
|
+
CREATE TABLE applications_new (
|
|
240
|
+
id TEXT PRIMARY KEY,
|
|
241
|
+
job_id TEXT NOT NULL UNIQUE REFERENCES jobs(id) ON DELETE CASCADE,
|
|
242
|
+
status TEXT NOT NULL DEFAULT 'draft',
|
|
243
|
+
resume_id TEXT REFERENCES documents(id) ON DELETE SET NULL,
|
|
244
|
+
cover_letter_id TEXT REFERENCES cover_letters(id) ON DELETE SET NULL,
|
|
245
|
+
applied_at TEXT,
|
|
246
|
+
notes TEXT,
|
|
247
|
+
created_at TEXT NOT NULL,
|
|
248
|
+
updated_at TEXT NOT NULL
|
|
249
|
+
);
|
|
250
|
+
INSERT INTO applications_new (id,job_id,status,resume_id,cover_letter_id,applied_at,notes,created_at,updated_at)
|
|
251
|
+
SELECT id,job_id,status,
|
|
252
|
+
CASE WHEN resume_id IN (SELECT id FROM documents) THEN resume_id ELSE NULL END,
|
|
253
|
+
CASE WHEN cover_letter_id IN (SELECT id FROM cover_letters) THEN cover_letter_id ELSE NULL END,
|
|
254
|
+
applied_at,notes,created_at,updated_at
|
|
255
|
+
FROM applications
|
|
256
|
+
WHERE job_id IN (SELECT id FROM jobs);
|
|
257
|
+
DROP TABLE applications;
|
|
258
|
+
ALTER TABLE applications_new RENAME TO applications;
|
|
259
|
+
|
|
260
|
+
-- interview_preps.job_id -> jobs(id) ON DELETE CASCADE
|
|
261
|
+
CREATE TABLE interview_preps_new (
|
|
262
|
+
id TEXT PRIMARY KEY,
|
|
263
|
+
job_id TEXT NOT NULL UNIQUE REFERENCES jobs(id) ON DELETE CASCADE,
|
|
264
|
+
questions TEXT NOT NULL DEFAULT '[]',
|
|
265
|
+
star_guides TEXT NOT NULL DEFAULT '[]',
|
|
266
|
+
self_introduction TEXT,
|
|
267
|
+
notes TEXT,
|
|
268
|
+
created_at TEXT NOT NULL,
|
|
269
|
+
updated_at TEXT NOT NULL
|
|
270
|
+
);
|
|
271
|
+
INSERT INTO interview_preps_new (id,job_id,questions,star_guides,self_introduction,notes,created_at,updated_at)
|
|
272
|
+
SELECT id,job_id,questions,star_guides,self_introduction,notes,created_at,updated_at
|
|
273
|
+
FROM interview_preps
|
|
274
|
+
WHERE job_id IN (SELECT id FROM jobs);
|
|
275
|
+
DROP TABLE interview_preps;
|
|
276
|
+
ALTER TABLE interview_preps_new RENAME TO interview_preps;
|
|
277
|
+
|
|
278
|
+
-- Recreate indexes dropped along with their tables.
|
|
279
|
+
CREATE INDEX IF NOT EXISTS idx_fit_job ON fit_analyses(job_id);
|
|
280
|
+
CREATE INDEX IF NOT EXISTS idx_clv_letter ON cover_letter_versions(cover_letter_id);
|
|
281
|
+
CREATE INDEX IF NOT EXISTS idx_cl_job ON cover_letters(job_id);
|
|
282
|
+
|
|
283
|
+
COMMIT;
|
|
284
|
+
PRAGMA foreign_keys=ON;
|
|
285
|
+
`,
|
|
168
286
|
];
|
|
169
287
|
|
|
170
288
|
export function migrate(db: DatabaseSync): { from: number; to: number } {
|
|
@@ -18,20 +18,17 @@ export function escapeHtml(input: string | null | undefined): string {
|
|
|
18
18
|
.replace(/'/g, ''');
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/** Heuristic: does this string already look like an HTML fragment? */
|
|
22
|
-
function looksLikeHtml(s: string): boolean {
|
|
23
|
-
return /<\/?(p|h[1-6]|ul|ol|li|div|section|article|br|hr|strong|em|a|table)\b/i.test(s);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
21
|
/**
|
|
27
|
-
* Wrap a
|
|
28
|
-
*
|
|
29
|
-
* is rendered through
|
|
22
|
+
* Wrap a markdown body in a complete, print-optimized HTML document.
|
|
23
|
+
*
|
|
24
|
+
* The body is ALWAYS rendered through {@link markdownToHtml}, which HTML-escapes
|
|
25
|
+
* its input before re-introducing only its own safe tags. Every exporter feeds
|
|
26
|
+
* this markdown it built itself, so there is no need to accept raw HTML — and
|
|
27
|
+
* accepting it would let user content (e.g. a cover-letter body containing
|
|
28
|
+
* `<img onerror=...>`) execute when the exported file is opened in a browser.
|
|
30
29
|
*/
|
|
31
|
-
export function toPrintableHtml(title: string,
|
|
32
|
-
const body =
|
|
33
|
-
? bodyMarkdownOrHtml
|
|
34
|
-
: markdownToHtml(bodyMarkdownOrHtml);
|
|
30
|
+
export function toPrintableHtml(title: string, bodyMarkdown: string): string {
|
|
31
|
+
const body = markdownToHtml(bodyMarkdown);
|
|
35
32
|
const safeTitle = escapeHtml(title);
|
|
36
33
|
|
|
37
34
|
return `<!doctype html>
|
|
@@ -5,14 +5,11 @@
|
|
|
5
5
|
* web server publishes it to a runtime file (see @careermate/db runtime). If the
|
|
6
6
|
* server isn't running we start it (detached) and wait for it to register.
|
|
7
7
|
*/
|
|
8
|
-
import { spawn } from 'node:child_process';
|
|
8
|
+
import { spawn, type SpawnOptions } from 'node:child_process';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
import { readRuntimeInfo, isProcessAlive } from '@careermate/db';
|
|
12
|
-
|
|
13
|
-
// 번들 빌드(esbuild)에서 true로 치환된다. tsx 개발 실행에서는 정의되지 않아 typeof로 안전 분기.
|
|
14
|
-
declare const __BUNDLED__: boolean | undefined;
|
|
15
|
-
const BUNDLED = typeof __BUNDLED__ !== 'undefined' && __BUNDLED__;
|
|
12
|
+
import { BUNDLED } from '@careermate/shared';
|
|
16
13
|
|
|
17
14
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
15
|
// packages/mcp-tools/src -> repo root (dev). 번들에서는 __dirname이 dist/ 이다.
|
|
@@ -28,26 +25,38 @@ function liveServer() {
|
|
|
28
25
|
return null;
|
|
29
26
|
}
|
|
30
27
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Fire-and-forget detached spawn that NEVER throws — synchronously OR
|
|
30
|
+
* asynchronously. A missing binary (e.g. `xdg-open` on a headless Linux box)
|
|
31
|
+
* makes spawn emit an async `'error'` event; with no listener that becomes an
|
|
32
|
+
* uncaughtException and crashes the MCP process. A surrounding try/catch only
|
|
33
|
+
* guards the synchronous spawn() call, not the later event — so attach the
|
|
34
|
+
* listener here. stdio defaults to 'ignore'; callers may override via opts.
|
|
35
|
+
*/
|
|
36
|
+
function spawnDetached(cmd: string, args: string[], opts: SpawnOptions = {}): void {
|
|
36
37
|
try {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
: [process.execPath, ['--no-warnings', '--experimental-sqlite', '--import', 'tsx', 'apps/web/src/index.ts'], REPO_ROOT];
|
|
41
|
-
const child = spawn(bin, args, {
|
|
42
|
-
cwd,
|
|
43
|
-
detached: true,
|
|
44
|
-
stdio: 'ignore',
|
|
45
|
-
env: { ...process.env, CAREERMATE_NO_OPEN: '1' },
|
|
38
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true, ...opts });
|
|
39
|
+
child.on('error', () => {
|
|
40
|
+
/* binary missing / spawn failed — best-effort, never crash */
|
|
46
41
|
});
|
|
47
42
|
child.unref();
|
|
48
43
|
} catch {
|
|
49
|
-
|
|
44
|
+
/* best-effort */
|
|
50
45
|
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Start the dashboard server detached and wait until it publishes its address. */
|
|
49
|
+
export async function ensureServer(timeoutMs = 12000): Promise<{ url: string } | null> {
|
|
50
|
+
const existing = liveServer();
|
|
51
|
+
if (existing) return { url: existing.url };
|
|
52
|
+
|
|
53
|
+
// prod(번들): dist/web.mjs를 플레인 node로 실행. dev: tsx로 소스 실행.
|
|
54
|
+
const [bin, args, cwd] = BUNDLED
|
|
55
|
+
? [process.execPath, ['--experimental-sqlite', path.join(__dirname, 'web.mjs')], __dirname]
|
|
56
|
+
: [process.execPath, ['--no-warnings', '--experimental-sqlite', '--import', 'tsx', 'apps/web/src/index.ts'], REPO_ROOT];
|
|
57
|
+
// Spawn failures (sync or async) just leave the server down; the poll loop
|
|
58
|
+
// below then times out and returns null — no crash.
|
|
59
|
+
spawnDetached(bin as string, args as string[], { cwd: cwd as string, env: { ...process.env, CAREERMATE_NO_OPEN: '1' } });
|
|
51
60
|
|
|
52
61
|
const start = Date.now();
|
|
53
62
|
while (Date.now() - start < timeoutMs) {
|
|
@@ -58,21 +67,35 @@ export async function ensureServer(timeoutMs = 12000): Promise<{ url: string } |
|
|
|
58
67
|
return null;
|
|
59
68
|
}
|
|
60
69
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
/** Open a URL or a local folder via the OS default handler. Best-effort, never throws. */
|
|
71
|
+
function openOsTarget(target: string): void {
|
|
72
|
+
switch (process.platform) {
|
|
73
|
+
case 'win32':
|
|
74
|
+
// `start` is a cmd builtin; the empty "" is the window-title arg so a
|
|
75
|
+
// quoted target isn't mistaken for the title. (explorer also opens URLs,
|
|
76
|
+
// but `start` is the canonical default-handler launcher.)
|
|
77
|
+
spawnDetached('cmd', ['/c', 'start', '', target]);
|
|
78
|
+
break;
|
|
79
|
+
case 'darwin':
|
|
80
|
+
spawnDetached('open', [target]);
|
|
81
|
+
break;
|
|
82
|
+
default:
|
|
83
|
+
spawnDetached('xdg-open', [target]);
|
|
73
84
|
}
|
|
74
85
|
}
|
|
75
86
|
|
|
87
|
+
export function openInBrowser(url: string): void {
|
|
88
|
+
openOsTarget(url);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Open a local folder in the OS file manager (Explorer/Finder/xdg). Best-effort. */
|
|
92
|
+
export function openInFileManager(dir: string): void {
|
|
93
|
+
// Windows: `explorer <dir>` is the idiomatic folder opener (start "" <dir>
|
|
94
|
+
// also works, but explorer focuses an existing window if already open).
|
|
95
|
+
if (process.platform === 'win32') spawnDetached('explorer', [dir]);
|
|
96
|
+
else openOsTarget(dir);
|
|
97
|
+
}
|
|
98
|
+
|
|
76
99
|
/** Resolve the dashboard URL, starting the server if needed. */
|
|
77
100
|
export async function resolveDashboardUrl(hash = ''): Promise<{ url: string; running: boolean }> {
|
|
78
101
|
const live = liveServer();
|
|
@@ -6,3 +6,12 @@
|
|
|
6
6
|
export { TOOLS, EXPORTS_LOCATION } from './tools.ts';
|
|
7
7
|
export { ok, fail, toCallToolResult, type ToolDef, type ToolResult } from './result.ts';
|
|
8
8
|
export { resolveDashboardUrl, openInBrowser, ensureServer, projectRoot } from './bridge.ts';
|
|
9
|
+
export {
|
|
10
|
+
getUpdateStatus,
|
|
11
|
+
getUpdateStatusAsync,
|
|
12
|
+
runSelfUpdate,
|
|
13
|
+
installPrefix,
|
|
14
|
+
compareVersions,
|
|
15
|
+
type UpdateStatus,
|
|
16
|
+
type UpdateResult,
|
|
17
|
+
} from './update.ts';
|