careermate 0.0.4 → 0.0.6

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 (124) hide show
  1. package/AGENTS.md +11 -7
  2. package/INSTALL.md +18 -16
  3. package/README.md +28 -26
  4. package/THIRD_PARTY_NOTICES.md +2 -3
  5. package/bin/careermate.mjs +8 -1
  6. package/dist/career-os/README.md +8 -3
  7. package/dist/career-os/eop/career-description.md +136 -0
  8. package/dist/career-os/eop/cover-letter.md +7 -5
  9. package/dist/career-os/eop/fit-analysis.md +15 -5
  10. package/dist/career-os/eop/interview-prep.md +209 -21
  11. package/dist/career-os/eop/job-analysis.md +5 -3
  12. package/dist/career-os/eop/profile-extraction.md +5 -4
  13. package/dist/career-os/knowledge/cover-letter.md +4 -4
  14. package/dist/career-os/knowledge/fit-matching.md +8 -8
  15. package/dist/career-os/knowledge/interview-behavioral.md +76 -8
  16. package/dist/career-os/knowledge/interview-technical.md +106 -4
  17. package/dist/career-os/knowledge/linkedin-profile.md +1 -1
  18. package/dist/career-os/knowledge/networking-referrals.md +1 -1
  19. package/dist/career-os/knowledge/offer-evaluation-decision.md +10 -10
  20. package/dist/career-os/knowledge/portfolio.md +3 -3
  21. package/dist/career-os/knowledge/verifiers/ats-compat.md +8 -4
  22. package/dist/career-os/knowledge/verifiers/human-voice.md +2 -2
  23. package/dist/career-os/knowledge/verifiers/recency-staleness.md +1 -1
  24. package/dist/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
  25. package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -1
  26. package/dist/doctor.mjs +1019 -127
  27. package/dist/mcp.mjs +1036 -126
  28. package/dist/migrate.mjs +52 -1
  29. package/dist/public/app.js +42 -16
  30. package/dist/public/i18n/en/applications.js +23 -0
  31. package/dist/public/i18n/en/common.js +22 -0
  32. package/dist/public/i18n/en/documents.js +133 -0
  33. package/dist/public/i18n/en/home.js +75 -0
  34. package/dist/public/i18n/en/interview.js +69 -0
  35. package/dist/public/i18n/en/jobs.js +152 -0
  36. package/dist/public/i18n/en/nav.js +18 -0
  37. package/dist/public/i18n/en/profile.js +201 -0
  38. package/dist/public/i18n/en/settings.js +127 -0
  39. package/dist/public/i18n/en/status.js +22 -0
  40. package/dist/public/i18n/en.js +17 -0
  41. package/dist/public/i18n/ko/applications.js +23 -0
  42. package/dist/public/i18n/ko/common.js +22 -0
  43. package/dist/public/i18n/ko/documents.js +133 -0
  44. package/dist/public/i18n/ko/home.js +63 -0
  45. package/dist/public/i18n/ko/interview.js +69 -0
  46. package/dist/public/i18n/ko/jobs.js +152 -0
  47. package/dist/public/i18n/ko/nav.js +18 -0
  48. package/dist/public/i18n/ko/profile.js +201 -0
  49. package/dist/public/i18n/ko/settings.js +127 -0
  50. package/dist/public/i18n/ko/status.js +23 -0
  51. package/dist/public/i18n/ko.js +18 -0
  52. package/dist/public/i18n.js +55 -0
  53. package/dist/public/index.html +1 -0
  54. package/dist/public/lib.js +25 -17
  55. package/dist/public/pages/applications.js +17 -21
  56. package/dist/public/pages/documents.js +153 -107
  57. package/dist/public/pages/home.js +88 -53
  58. package/dist/public/pages/interview.js +105 -51
  59. package/dist/public/pages/jobs.js +330 -106
  60. package/dist/public/pages/profile.js +157 -152
  61. package/dist/public/pages/settings.js +270 -72
  62. package/dist/public/styles.css +38 -3
  63. package/dist/web.mjs +859 -103
  64. package/docs/FAQ.md +26 -5
  65. package/docs/MCP_TOOLS.md +26 -12
  66. package/docs/START_WORKFLOW.md +2 -2
  67. package/docs/SUPPORTED_AI_APPS.md +13 -0
  68. package/docs/WORKFLOWS.md +80 -23
  69. package/docs/career-os/README.md +8 -3
  70. package/docs/career-os/eop/career-description.md +136 -0
  71. package/docs/career-os/eop/cover-letter.md +7 -5
  72. package/docs/career-os/eop/fit-analysis.md +15 -5
  73. package/docs/career-os/eop/interview-prep.md +209 -21
  74. package/docs/career-os/eop/job-analysis.md +5 -3
  75. package/docs/career-os/eop/profile-extraction.md +5 -4
  76. package/docs/career-os/knowledge/cover-letter.md +4 -4
  77. package/docs/career-os/knowledge/fit-matching.md +8 -8
  78. package/docs/career-os/knowledge/interview-behavioral.md +76 -8
  79. package/docs/career-os/knowledge/interview-technical.md +106 -4
  80. package/docs/career-os/knowledge/linkedin-profile.md +1 -1
  81. package/docs/career-os/knowledge/networking-referrals.md +1 -1
  82. package/docs/career-os/knowledge/offer-evaluation-decision.md +10 -10
  83. package/docs/career-os/knowledge/portfolio.md +3 -3
  84. package/docs/career-os/knowledge/verifiers/ats-compat.md +8 -4
  85. package/docs/career-os/knowledge/verifiers/human-voice.md +2 -2
  86. package/docs/career-os/knowledge/verifiers/recency-staleness.md +1 -1
  87. package/docs/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
  88. package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -1
  89. package/package.json +2 -2
  90. package/packages/core/src/onboarding.ts +2 -1
  91. package/packages/core/src/services.ts +138 -21
  92. package/packages/core/src/summary.ts +59 -1
  93. package/packages/core/src/verify/lint.ts +4 -0
  94. package/packages/db/src/index.ts +1 -0
  95. package/packages/db/src/repositories.ts +0 -0
  96. package/packages/db/src/schema.ts +17 -0
  97. package/packages/exporters/src/index.ts +28 -17
  98. package/packages/knowledge/src/index.ts +46 -7
  99. package/packages/mcp-tools/src/bridge.ts +194 -13
  100. package/packages/mcp-tools/src/tools.ts +390 -38
  101. package/packages/mcp-tools/src/update.ts +31 -16
  102. package/packages/parsers/src/documents.ts +12 -1
  103. package/packages/prompts/src/humanize.ts +46 -0
  104. package/packages/prompts/src/index.ts +5 -1
  105. package/packages/prompts/src/install.ts +4 -4
  106. package/packages/prompts/src/onboarding.ts +4 -4
  107. package/packages/prompts/src/system.ts +17 -8
  108. package/packages/shared/src/enums.ts +14 -4
  109. package/packages/shared/src/schemas.ts +41 -0
  110. package/packages/workflows/src/definitions.ts +47 -10
  111. package/scripts/init.ts +284 -13
  112. package/dist/site/analytics.js +0 -97
  113. package/dist/site/favicon.svg +0 -1
  114. package/dist/site/fonts/PretendardVariable.woff2 +0 -0
  115. package/dist/site/icon-180.png +0 -0
  116. package/dist/site/icon-512.png +0 -0
  117. package/dist/site/index.html +0 -565
  118. package/dist/site/landing.js +0 -54
  119. package/dist/site/llms-install.txt +0 -218
  120. package/dist/site/robots.txt +0 -4
  121. package/dist/site/sitemap.xml +0 -21
  122. package/dist/site/start.html +0 -353
  123. package/dist/site/style.css +0 -559
  124. package/dist/site/vercel.json +0 -20
@@ -40,11 +40,39 @@ export interface HomeSummary {
40
40
  in_progress: { application: ApplicationRecord; job: JobRecord | null; status_label: string }[];
41
41
  recent_cover_letters: CoverLetterRecord[];
42
42
  interview_todo: { job: JobRecord; status_label: string }[];
43
+ deadlines: { job: JobRecord; status: ApplicationStatus; status_label: string; days_left: number }[];
44
+ followups: { job: JobRecord; days_since: number }[];
43
45
  recent_activity: ActivityRecord[];
44
46
  }
45
47
 
46
48
  const ACTIVE: ApplicationStatus[] = ['draft', 'planned', 'applied', 'document_passed', 'interview'];
47
49
 
50
+ // Deadline nudge applies only while the posting still needs action (not yet applied).
51
+ const DEADLINE_ACTIONABLE: ApplicationStatus[] = ['draft', 'planned'];
52
+ // Surface a deadline once it's within this many days (overdue items are always shown).
53
+ const DEADLINE_SOON_DAYS = 14;
54
+ // Suggest a follow-up when an application has sat in 'applied' this many days with no response.
55
+ const FOLLOWUP_STALE_DAYS = 10;
56
+ const DAY_MS = 86400000;
57
+
58
+ /** Whole days from `now`'s local midnight to a `YYYY-MM-DD` date (future > 0, today = 0, past < 0); null if unparseable. */
59
+ function daysUntilDate(dateStr: string | null | undefined, now: Date): number | null {
60
+ if (!dateStr) return null;
61
+ const d = new Date(`${dateStr}T00:00:00`);
62
+ if (isNaN(d.getTime())) return null;
63
+ const today = new Date(now);
64
+ today.setHours(0, 0, 0, 0);
65
+ return Math.round((d.getTime() - today.getTime()) / DAY_MS);
66
+ }
67
+
68
+ /** Whole days elapsed since an ISO timestamp up to `now`; null if unparseable. */
69
+ function daysSinceTimestamp(iso: string | null | undefined, now: Date): number | null {
70
+ if (!iso) return null;
71
+ const d = new Date(iso);
72
+ if (isNaN(d.getTime())) return null;
73
+ return Math.floor((now.getTime() - d.getTime()) / DAY_MS);
74
+ }
75
+
48
76
  export function jobWithMeta(job: JobRecord): JobWithMeta {
49
77
  const app = applicationRepo.getByJob(job.id);
50
78
  const fit = fitRepo.getByJob(job.id);
@@ -52,11 +80,12 @@ export function jobWithMeta(job: JobRecord): JobWithMeta {
52
80
  return { ...job, status, status_label: APPLICATION_STATUS_LABELS[status], fit_score: fit?.score ?? null };
53
81
  }
54
82
 
55
- export function getHomeSummary(): HomeSummary {
83
+ export function getHomeSummary(now: Date = new Date()): HomeSummary {
56
84
  const onboarding = getOnboardingStatus();
57
85
  const jobs = jobRepo.list();
58
86
  const applications = applicationRepo.list();
59
87
  const coverLetters = coverLetterRepo.list();
88
+ const statusByJob = new Map(applications.map((a) => [a.job_id, a]));
60
89
 
61
90
  const status_breakdown = (Object.keys(APPLICATION_STATUS_LABELS) as ApplicationStatus[]).map((status) => ({
62
91
  status,
@@ -75,6 +104,33 @@ export function getHomeSummary(): HomeSummary {
75
104
  })
76
105
  .filter((x): x is { job: JobRecord; status_label: string } => x !== null);
77
106
 
107
+ // Deadline nudges: postings still awaiting action (draft/planned) whose deadline
108
+ // is overdue or within DEADLINE_SOON_DAYS. Most urgent (overdue) first.
109
+ const deadlines = jobs
110
+ .map((job) => {
111
+ const status = statusByJob.get(job.id)?.status ?? 'draft';
112
+ if (!DEADLINE_ACTIONABLE.includes(status)) return null;
113
+ const days_left = daysUntilDate(job.deadline, now);
114
+ if (days_left == null || days_left > DEADLINE_SOON_DAYS) return null;
115
+ return { job, status, status_label: APPLICATION_STATUS_LABELS[status], days_left };
116
+ })
117
+ .filter((x): x is { job: JobRecord; status: ApplicationStatus; status_label: string; days_left: number } => x !== null)
118
+ .sort((a, b) => a.days_left - b.days_left)
119
+ .slice(0, 5);
120
+
121
+ // Follow-up nudges: applications sitting in 'applied' with no response for a while.
122
+ const followups = applications
123
+ .filter((a) => a.status === 'applied')
124
+ .map((a) => {
125
+ const days_since = daysSinceTimestamp(a.applied_at, now);
126
+ if (days_since == null || days_since < FOLLOWUP_STALE_DAYS) return null;
127
+ const job = jobRepo.get(a.job_id);
128
+ return job ? { job, days_since } : null;
129
+ })
130
+ .filter((x): x is { job: JobRecord; days_since: number } => x !== null)
131
+ .sort((a, b) => b.days_since - a.days_since)
132
+ .slice(0, 5);
133
+
78
134
  return {
79
135
  onboarding,
80
136
  counts: {
@@ -95,6 +151,8 @@ export function getHomeSummary(): HomeSummary {
95
151
  }),
96
152
  recent_cover_letters: coverLetters.slice(0, 5),
97
153
  interview_todo,
154
+ deadlines,
155
+ followups,
98
156
  recent_activity: activityRepo.recent(10),
99
157
  };
100
158
  }
@@ -198,6 +198,10 @@ export function lintArtifact(
198
198
  const strict = !!opts.strict;
199
199
 
200
200
  const blocking: LintReport['blocking'] = [];
201
+ // Save-gate currently fires for cover letters only. The other `kind`s
202
+ // ('resume' | 'fit' | 'interview') are accepted and linted (signals/provenance
203
+ // still computed) but not yet hard-gated — that's the deferred B-2 scope, not a
204
+ // dead branch. See docs/career-os EOP B-2.
201
205
  if (corpusAvailable && kind === 'cover_letter') {
202
206
  // Always block suspected fabrication (numbers tracing to nothing).
203
207
  if (provenance.fabricated.length > 0) {
@@ -20,5 +20,6 @@ export {
20
20
  applicationRepo,
21
21
  interviewRepo,
22
22
  activityRepo,
23
+ timelineRepo,
23
24
  getEntityCounts,
24
25
  } from './repositories.ts';
Binary file
@@ -283,6 +283,23 @@ export const MIGRATIONS: string[] = [
283
283
  COMMIT;
284
284
  PRAGMA foreign_keys=ON;
285
285
  `,
286
+
287
+ // v3 — per-job timeline entries. Document/cover-letter refs live in payload
288
+ // snapshots instead of FKs so deleting a document does not erase the history.
289
+ `
290
+ CREATE TABLE IF NOT EXISTS application_timeline_events (
291
+ id TEXT PRIMARY KEY,
292
+ job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
293
+ type TEXT NOT NULL,
294
+ title TEXT NOT NULL,
295
+ summary TEXT,
296
+ payload TEXT NOT NULL DEFAULT '{}',
297
+ occurred_at TEXT NOT NULL,
298
+ created_at TEXT NOT NULL
299
+ );
300
+
301
+ CREATE INDEX IF NOT EXISTS idx_timeline_job ON application_timeline_events(job_id, occurred_at ASC);
302
+ `,
286
303
  ];
287
304
 
288
305
  export function migrate(db: DatabaseSync): { from: number; to: number } {
@@ -284,23 +284,34 @@ export function interviewPrepToMarkdown(
284
284
  ? joinBlocks(['## 1분 자기소개', prep.self_introduction.trim()])
285
285
  : '';
286
286
 
287
- const questionsBlock = prep.questions?.length
288
- ? joinBlocks([
289
- '## 예상 질문',
290
- ...prep.questions.map((q, idx) =>
291
- joinBlocks([
292
- `### Q${idx + 1}. ${q.question}`,
293
- q.intent?.trim() ? kv('의도', q.intent) : '',
294
- q.answer_outline?.trim()
295
- ? joinBlocks(['**답변 가이드**', q.answer_outline.trim()])
296
- : '',
297
- q.followups?.length
298
- ? joinBlocks(['**예상 꼬리 질문**', bulletList(q.followups)])
299
- : '',
300
- ]),
301
- ),
302
- ])
303
- : '';
287
+ const renderQuestion = (q: InterviewPrepRecord['questions'][number], idx: number, level: string) =>
288
+ joinBlocks([
289
+ `${level} Q${idx + 1}. ${q.question}`,
290
+ q.intent?.trim() ? kv('의도', q.intent) : '',
291
+ q.answer_outline?.trim()
292
+ ? joinBlocks(['**답변 가이드**', q.answer_outline.trim()])
293
+ : '',
294
+ q.followups?.length
295
+ ? joinBlocks(['**예상 꼬리 질문**', bulletList(q.followups)])
296
+ : '',
297
+ ]);
298
+
299
+ let questionsBlock = '';
300
+ if (prep.questions?.length) {
301
+ const technical = prep.questions.filter((q) => (q.category ?? 'technical') === 'technical');
302
+ const behavioral = prep.questions.filter((q) => q.category === 'behavioral');
303
+ // Split into 기술 / 인성·컬처핏 sub-sections only when both tracks are present;
304
+ // a single-track sheet stays flat (backward-compatible output).
305
+ questionsBlock = behavioral.length
306
+ ? joinBlocks([
307
+ '## 예상 질문',
308
+ technical.length
309
+ ? joinBlocks(['### 기술 면접', ...technical.map((q, i) => renderQuestion(q, i, '####'))])
310
+ : '',
311
+ joinBlocks(['### 인성·컬처핏 면접', ...behavioral.map((q, i) => renderQuestion(q, i, '####'))]),
312
+ ])
313
+ : joinBlocks(['## 예상 질문', ...prep.questions.map((q, i) => renderQuestion(q, i, '###'))]);
314
+ }
304
315
 
305
316
  const starBlock = prep.star_guides?.length
306
317
  ? joinBlocks([
@@ -58,12 +58,13 @@ export const VERIFIER_IDS = [
58
58
  ] as const;
59
59
  export type VerifierId = (typeof VERIFIER_IDS)[number];
60
60
 
61
- /** 5 per-feature execution procedures — docs/career-os/eop/<feature>.md. */
61
+ /** Per-feature execution procedures — docs/career-os/eop/<feature>.md. */
62
62
  export const EOP_FEATURES = [
63
63
  'fit-analysis',
64
64
  'job-analysis',
65
65
  'profile-extraction',
66
66
  'cover-letter',
67
+ 'career-description',
67
68
  'interview-prep',
68
69
  ] as const;
69
70
  export type EopFeature = (typeof EOP_FEATURES)[number];
@@ -95,7 +96,7 @@ export function getEop(feature: EopFeature): string {
95
96
 
96
97
  /** A goal → which EOP, expert playbooks, and verifiers to apply, in order. */
97
98
  export interface CareerRoute {
98
- eop?: EopFeature;
99
+ eop?: EopFeature | EopFeature[];
99
100
  expertSequence: ExpertDomain[];
100
101
  verifierSequence: VerifierId[];
101
102
  loop?: 'draft_verify_revise';
@@ -109,7 +110,7 @@ export const CAREER_ROUTES: Record<string, CareerRoute> = {
109
110
  verifierSequence: ['truthfulness', 'consistency'],
110
111
  },
111
112
  analyze_job: {
112
- eop: 'job-analysis',
113
+ eop: ['job-analysis', 'fit-analysis'],
113
114
  expertSequence: ['fit-matching', 'company-research'],
114
115
  verifierSequence: ['truthfulness', 'consistency', 'responsiveness-on-target'],
115
116
  loop: 'draft_verify_revise',
@@ -120,12 +121,47 @@ export const CAREER_ROUTES: Record<string, CareerRoute> = {
120
121
  verifierSequence: ['human-voice', 'truthfulness', 'ats-compat', 'consistency'],
121
122
  loop: 'draft_verify_revise',
122
123
  },
124
+ write_career_description: {
125
+ eop: 'career-description',
126
+ expertSequence: ['resume', 'ats'],
127
+ verifierSequence: ['truthfulness', 'consistency', 'ats-compat'],
128
+ loop: 'draft_verify_revise',
129
+ },
123
130
  prepare_interview: {
124
131
  eop: 'interview-prep',
125
- expertSequence: ['interview-behavioral', 'interview-technical', 'company-research'],
132
+ expertSequence: ['recruiter-screen', 'interview-behavioral', 'interview-technical', 'company-research'],
126
133
  verifierSequence: ['truthfulness', 'consistency'],
127
134
  loop: 'draft_verify_revise',
128
135
  },
136
+ // R7 A2: manage_application_status had no route → rejection-triage / offer-evaluation /
137
+ // salary-negotiation were orphaned (served only on-demand via get_playbook). Route the
138
+ // decision-stage playbooks so they are pushed when the user manages application outcomes.
139
+ // R7 A4: A2 routed the decision-stage playbooks, but the EOP-free lifecycle domains
140
+ // onboarding-first-90-days and networking-referrals were still on-demand-only. Anchor them
141
+ // here (the only EOP-free lifecycle route). NOTE: networking's strongest lever is *before*
142
+ // applied (pre-referral, networking-referrals §2) — this route is the surfacing point, not
143
+ // the recommended timing, so surface networking early when the user is pre-application.
144
+ // linkedin-profile/portfolio stay on-demand pending a dedicated personal-brand workflow id
145
+ // (FIXES.md / STATUS §7.9 follow-up — onboarding EOP is résumé-import-only, so brand-
146
+ // creation playbooks would blur its stage meaning). Additive wiring (A1/A2 precedent).
147
+ manage_application_status: {
148
+ expertSequence: [
149
+ 'rejection-triage-iteration',
150
+ 'offer-evaluation-decision',
151
+ 'salary-negotiation',
152
+ 'onboarding-first-90-days',
153
+ 'networking-referrals',
154
+ ],
155
+ verifierSequence: ['truthfulness', 'consistency'],
156
+ },
157
+ // R7 후속: linkedin-profile·portfolio는 onboarding(이력서 임포트)과 단계 의미가
158
+ // 달라(개인 브랜드 자산 *생성*) 전용 워크플로우로 분리(consensus 권고). networking은
159
+ // manage_application_status에 이미 정박. 저장은 add_resume(kind=portfolio); linkedin
160
+ // 전용 저장/검증은 Phase B.
161
+ build_personal_brand: {
162
+ expertSequence: ['linkedin-profile', 'portfolio'],
163
+ verifierSequence: ['truthfulness', 'human-voice', 'consistency'],
164
+ },
129
165
  };
130
166
 
131
167
  export function getRoute(id: string): CareerRoute | undefined {
@@ -142,12 +178,15 @@ export function renderRouteGuide(id: string): string | undefined {
142
178
  const route = CAREER_ROUTES[id];
143
179
  if (!route) return undefined;
144
180
  const lines: string[] = [];
145
- if (route.eop) {
181
+ const eops = route.eop ? (Array.isArray(route.eop) ? route.eop : [route.eop]) : [];
182
+ if (eops.length) {
146
183
  lines.push('---', '');
147
184
  lines.push('## 전문가 실행 절차 (EOP) — 즉답하지 말고 이 절차를 따르세요');
148
185
  lines.push('');
149
- lines.push(getEop(route.eop));
150
- lines.push('');
186
+ for (const e of eops) {
187
+ lines.push(getEop(e));
188
+ lines.push('');
189
+ }
151
190
  }
152
191
  lines.push('## 이 작업에 적용할 전문가 플레이북');
153
192
  lines.push('작성 직전, 아래 도메인의 깊은 지식을 받아 적용하세요:');
@@ -3,12 +3,13 @@
3
3
  *
4
4
  * `open_dashboard` / `open_application` need the running web server's URL. The
5
5
  * web server publishes it to a runtime file (see @careermate/db runtime). If the
6
- * server isn't running we start it (detached) and wait for it to register.
6
+ * server isn't running we start it in the background and wait for it to register.
7
7
  */
8
- import { spawn, type SpawnOptions } from 'node:child_process';
8
+ import { spawn, spawnSync, type SpawnOptions } from 'node:child_process';
9
+ import fs from 'node:fs';
9
10
  import path from 'node:path';
10
11
  import { fileURLToPath } from 'node:url';
11
- import { readRuntimeInfo, isProcessAlive } from '@careermate/db';
12
+ import { getDataDir, readRuntimeInfo, isProcessAlive, type RuntimeInfo } from '@careermate/db';
12
13
  import { BUNDLED } from '@careermate/shared';
13
14
 
14
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -19,9 +20,103 @@ export function projectRoot(): string {
19
20
  return REPO_ROOT;
20
21
  }
21
22
 
22
- function liveServer() {
23
+ const HEALTH_TIMEOUT_MS = 800;
24
+ let pendingStart: Promise<{ url: string } | null> | null = null;
25
+
26
+ function dashboardLogPath(): string {
27
+ return path.join(getDataDir(), 'server.log');
28
+ }
29
+
30
+ function launchdPlistPath(): string {
31
+ return path.join(getDataDir(), 'dashboard.launchd.plist');
32
+ }
33
+
34
+ function launchdLabel(): string {
35
+ let hash = 5381;
36
+ for (const ch of getDataDir()) hash = ((hash * 33) ^ ch.charCodeAt(0)) >>> 0;
37
+ return `life.careermate.dashboard.${hash.toString(16)}`;
38
+ }
39
+
40
+ function xmlEscape(value: string): string {
41
+ return value
42
+ .replace(/&/g, '&amp;')
43
+ .replace(/</g, '&lt;')
44
+ .replace(/>/g, '&gt;')
45
+ .replace(/"/g, '&quot;')
46
+ .replace(/'/g, '&apos;');
47
+ }
48
+
49
+ function plistString(key: string, value: string): string {
50
+ return ` <key>${xmlEscape(key)}</key>\n <string>${xmlEscape(value)}</string>`;
51
+ }
52
+
53
+ function renderLaunchdPlist(bin: string, args: string[], cwd: string, log: string): string {
54
+ const env: Record<string, string> = {
55
+ CAREERMATE_DATA_DIR: getDataDir(),
56
+ CAREERMATE_NO_OPEN: '1',
57
+ };
58
+ if (process.env.CAREERMATE_PORT) env.CAREERMATE_PORT = process.env.CAREERMATE_PORT;
59
+ if (process.env.PATH) env.PATH = process.env.PATH;
60
+
61
+ const programArgs = [bin, ...args].map((arg) => ` <string>${xmlEscape(arg)}</string>`).join('\n');
62
+ const envEntries = Object.entries(env).map(([key, value]) => `${plistString(key, value)}`).join('\n');
63
+
64
+ return `<?xml version="1.0" encoding="UTF-8"?>
65
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
66
+ <plist version="1.0">
67
+ <dict>
68
+ <key>Label</key>
69
+ <string>${xmlEscape(launchdLabel())}</string>
70
+ <key>ProgramArguments</key>
71
+ <array>
72
+ ${programArgs}
73
+ </array>
74
+ <key>WorkingDirectory</key>
75
+ <string>${xmlEscape(cwd)}</string>
76
+ <key>EnvironmentVariables</key>
77
+ <dict>
78
+ ${envEntries}
79
+ </dict>
80
+ <key>RunAtLoad</key>
81
+ <true/>
82
+ <key>KeepAlive</key>
83
+ <false/>
84
+ <key>StandardOutPath</key>
85
+ <string>${xmlEscape(log)}</string>
86
+ <key>StandardErrorPath</key>
87
+ <string>${xmlEscape(log)}</string>
88
+ </dict>
89
+ </plist>
90
+ `;
91
+ }
92
+
93
+ function delay(ms: number): Promise<void> {
94
+ return new Promise((resolve) => setTimeout(resolve, ms));
95
+ }
96
+
97
+ async function pingDashboard(url: string, timeoutMs = HEALTH_TIMEOUT_MS): Promise<boolean> {
98
+ const controller = new AbortController();
99
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
100
+ timer.unref?.();
101
+ try {
102
+ const healthUrl = new URL('/api/health', url);
103
+ const res = await fetch(healthUrl, {
104
+ signal: controller.signal,
105
+ headers: { accept: 'application/json' },
106
+ });
107
+ if (!res.ok) return false;
108
+ const body = (await res.json().catch(() => null)) as { ok?: unknown } | null;
109
+ return body?.ok === true;
110
+ } catch {
111
+ return false;
112
+ } finally {
113
+ clearTimeout(timer);
114
+ }
115
+ }
116
+
117
+ async function liveServer(): Promise<RuntimeInfo | null> {
23
118
  const info = readRuntimeInfo();
24
- if (info && isProcessAlive(info.pid)) return info;
119
+ if (info && isProcessAlive(info.pid) && await pingDashboard(info.url)) return info;
25
120
  return null;
26
121
  }
27
122
 
@@ -33,36 +128,122 @@ function liveServer() {
33
128
  * guards the synchronous spawn() call, not the later event — so attach the
34
129
  * listener here. stdio defaults to 'ignore'; callers may override via opts.
35
130
  */
36
- function spawnDetached(cmd: string, args: string[], opts: SpawnOptions = {}): void {
131
+ function spawnDetached(cmd: string, args: string[], opts: SpawnOptions = {}): number | null {
37
132
  try {
38
133
  const child = spawn(cmd, args, { stdio: 'ignore', detached: true, ...opts });
39
134
  child.on('error', () => {
40
135
  /* binary missing / spawn failed — best-effort, never crash */
41
136
  });
42
137
  child.unref();
138
+ return child.pid ?? null;
43
139
  } catch {
44
140
  /* best-effort */
141
+ return null;
45
142
  }
46
143
  }
47
144
 
48
- /** Start the dashboard server detached and wait until it publishes its address. */
145
+ function spawnViaLaunchd(bin: string, args: string[], cwd: string, log: string): boolean {
146
+ if (process.platform !== 'darwin' || process.env.CAREERMATE_LAUNCHER === 'detached') return false;
147
+ if (typeof process.getuid !== 'function') return false;
148
+
149
+ const domain = `gui/${process.getuid()}`;
150
+ const label = launchdLabel();
151
+ const plist = launchdPlistPath();
152
+ try {
153
+ fs.writeFileSync(plist, renderLaunchdPlist(bin, args, cwd, log), 'utf8');
154
+ // Remove a stale loaded job for the same data dir. If it is not loaded,
155
+ // launchctl exits non-zero; that is fine and should not block bootstrap.
156
+ spawnSync('launchctl', ['bootout', `${domain}/${label}`], { stdio: 'ignore' });
157
+ const res = spawnSync('launchctl', ['bootstrap', domain, plist], {
158
+ encoding: 'utf8',
159
+ timeout: 5000,
160
+ });
161
+ if (res.status === 0) return true;
162
+ fs.appendFileSync(log, `[${new Date().toISOString()}] launchctl bootstrap failed: ${res.stderr || res.stdout || res.status}\n`);
163
+ } catch (e) {
164
+ try {
165
+ fs.appendFileSync(log, `[${new Date().toISOString()}] launchctl launch failed: ${e instanceof Error ? e.message : String(e)}\n`);
166
+ } catch {
167
+ /* ignore */
168
+ }
169
+ }
170
+ return false;
171
+ }
172
+
173
+ function spawnDashboardDetached(bin: string, args: string[], cwd: string, logFd: number | null): void {
174
+ spawnDetached(bin, args, {
175
+ cwd,
176
+ env: { ...process.env, CAREERMATE_NO_OPEN: '1' },
177
+ stdio: logFd === null ? 'ignore' : ['ignore', logFd, logFd],
178
+ });
179
+ }
180
+
181
+ /** Start the dashboard server in the background and wait until it publishes its address. */
49
182
  export async function ensureServer(timeoutMs = 12000): Promise<{ url: string } | null> {
50
- const existing = liveServer();
183
+ const existing = await liveServer();
51
184
  if (existing) return { url: existing.url };
185
+ if (pendingStart) return pendingStart;
52
186
 
187
+ pendingStart = startServerBackground(timeoutMs);
188
+ try {
189
+ return await pendingStart;
190
+ } finally {
191
+ pendingStart = null;
192
+ }
193
+ }
194
+
195
+ async function startServerBackground(timeoutMs: number): Promise<{ url: string } | null> {
53
196
  // prod(번들): dist/web.mjs를 플레인 node로 실행. dev: tsx로 소스 실행.
54
197
  const [bin, args, cwd] = BUNDLED
55
198
  ? [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];
199
+ : [
200
+ process.execPath,
201
+ ['--no-warnings', '--experimental-sqlite', '--import', 'tsx', path.join(REPO_ROOT, 'apps/web/src/index.ts')],
202
+ REPO_ROOT,
203
+ ];
57
204
  // Spawn failures (sync or async) just leave the server down; the poll loop
58
205
  // 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' } });
206
+ const log = dashboardLogPath();
207
+ let logFd: number | null = null;
208
+ try {
209
+ logFd = fs.openSync(log, 'a');
210
+ fs.writeSync(logFd, `\n[${new Date().toISOString()}] CareerMate dashboard background launch\n`);
211
+ } catch {
212
+ logFd = null;
213
+ }
214
+ let launchedViaLaunchd = false;
215
+ try {
216
+ launchedViaLaunchd = spawnViaLaunchd(bin as string, args as string[], cwd as string, log);
217
+ if (!launchedViaLaunchd) spawnDashboardDetached(bin as string, args as string[], cwd as string, logFd);
218
+ } finally {
219
+ if (logFd !== null) {
220
+ try { fs.closeSync(logFd); } catch { /* ignore */ }
221
+ }
222
+ }
60
223
 
61
224
  const start = Date.now();
62
225
  while (Date.now() - start < timeoutMs) {
63
- const info = liveServer();
226
+ const info = await liveServer();
64
227
  if (info) return { url: info.url };
65
- await new Promise((r) => setTimeout(r, 300));
228
+ await delay(300);
229
+ }
230
+
231
+ // If launchd accepted the job but the server never became healthy, give the
232
+ // simpler detached path one chance. This protects dev/source installs where
233
+ // launchd has a thinner environment than the invoking AI client.
234
+ if (launchedViaLaunchd) {
235
+ try {
236
+ fs.appendFileSync(log, `[${new Date().toISOString()}] launchd did not produce a healthy server; falling back to detached spawn\n`);
237
+ } catch {
238
+ /* ignore */
239
+ }
240
+ spawnDashboardDetached(bin as string, args as string[], cwd as string, null);
241
+ const fallbackStart = Date.now();
242
+ while (Date.now() - fallbackStart < Math.min(timeoutMs, 8000)) {
243
+ const info = await liveServer();
244
+ if (info) return { url: info.url };
245
+ await delay(300);
246
+ }
66
247
  }
67
248
  return null;
68
249
  }
@@ -98,7 +279,7 @@ export function openInFileManager(dir: string): void {
98
279
 
99
280
  /** Resolve the dashboard URL, starting the server if needed. */
100
281
  export async function resolveDashboardUrl(hash = ''): Promise<{ url: string; running: boolean }> {
101
- const live = liveServer();
282
+ const live = await liveServer();
102
283
  if (live) return { url: live.url + hash, running: true };
103
284
  const started = await ensureServer();
104
285
  if (started) return { url: started.url + hash, running: true };