careermate 0.0.3 → 0.0.5
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/AGENTS.md +3 -0
- package/INSTALL.md +2 -1
- package/README.md +8 -6
- package/dist/doctor.mjs +526 -100
- package/dist/mcp.mjs +528 -102
- package/dist/migrate.mjs +23 -1
- package/dist/public/lib.js +6 -1
- package/dist/public/pages/applications.js +7 -0
- package/dist/public/pages/jobs.js +22 -15
- package/dist/public/pages/settings.js +160 -14
- package/dist/public/styles.css +19 -2
- package/dist/site/index.html +88 -228
- package/dist/site/landing.js +109 -0
- package/dist/site/start.html +154 -131
- package/dist/site/style.css +169 -63
- package/dist/web.mjs +503 -41
- package/docs/FAQ.md +3 -2
- package/docs/MCP_TOOLS.md +16 -8
- package/docs/START_WORKFLOW.md +2 -1
- package/docs/WORKFLOWS.md +6 -6
- package/package.json +2 -2
- package/packages/core/src/services.ts +69 -7
- package/packages/db/src/repositories.ts +0 -0
- package/packages/mcp-tools/src/bridge.ts +194 -13
- package/packages/mcp-tools/src/tools.ts +166 -35
- package/packages/mcp-tools/src/update.ts +1 -1
- package/packages/prompts/src/onboarding.ts +1 -1
- package/packages/prompts/src/system.ts +10 -2
- package/packages/shared/src/enums.ts +7 -2
- package/packages/shared/src/errors.ts +33 -0
- package/packages/shared/src/index.ts +1 -0
- package/packages/shared/src/schemas.ts +56 -2
- package/packages/workflows/src/definitions.ts +3 -3
package/docs/FAQ.md
CHANGED
|
@@ -32,10 +32,11 @@ CareerMate는 **Node.js 22.5.0 이상**이 필요합니다(`node:sqlite` 사용)
|
|
|
32
32
|
## 대시보드
|
|
33
33
|
|
|
34
34
|
### "대시보드가 안 열려요" (`http://127.0.0.1:4319`)
|
|
35
|
-
- 그 주소는 **CareerMate가 실행 중일 때만** 열립니다. AI에게
|
|
36
|
-
서버가
|
|
35
|
+
- 그 주소는 **CareerMate가 실행 중일 때만** 열립니다. AI에게 **"대시보드 열어줘"** 라고 하면
|
|
36
|
+
서버가 꺼져 있을 때도 백그라운드로 자동 시작한 뒤 브라우저를 엽니다.
|
|
37
37
|
- **포트가 이미 사용 중**이면 자동으로 다른 포트로 폴백합니다. 환경변수
|
|
38
38
|
`CAREERMATE_PORT`로 고정할 수 있습니다.
|
|
39
|
+
- 자동 시작이 실패하면 `~/.careermate/server.log`를 확인하세요.
|
|
39
40
|
- 대시보드는 보안상 **`127.0.0.1`(이 컴퓨터)** 에만 열립니다. 다른 기기에서는 접속되지
|
|
40
41
|
않습니다(의도된 동작).
|
|
41
42
|
|
package/docs/MCP_TOOLS.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
CareerMate는 **MCP-우선** 로컬 커리어 관리 도구입니다. 사용자는 자신의 ChatGPT/Claude/Gemini와 대화하고, 그 AI가 여기 정의된 MCP 도구를 호출해 로컬 커리어 DB(`~/.careermate/careermate.sqlite`)를 읽고 씁니다. **CareerMate 내부에는 LLM이 없습니다.** 적합도 분석·자기소개서 작성 같은 추론은 모두 사용자의 AI가 수행하고, CareerMate는 데이터를 저장하고 다시 돌려주는 역할만 합니다.
|
|
4
4
|
|
|
5
|
-
이 문서는 `packages/mcp-tools/src/tools.ts`에 정의된 커리어 워크플로 **
|
|
5
|
+
이 문서는 `packages/mcp-tools/src/tools.ts`에 정의된 커리어 워크플로 **37개 도구**의 레퍼런스입니다. 도구의 입력 파라미터는 `packages/shared/src/schemas.ts`의 zod 스키마와 일치합니다.
|
|
6
6
|
|
|
7
7
|
> 설계 원칙: 이 도구들은 단순 CRUD 래퍼가 아닙니다. 각 도구는 AI가 사용자를 대신해 수행하는 실제 작업에 대응하며, 설명에 *언제* 그리고 *어떻게* 호출해야 하는지가 담겨 있습니다. 가장 중요한 도구는 [`get_application_context`](#get_application_context-) 입니다.
|
|
8
8
|
|
|
@@ -16,7 +16,7 @@ CareerMate는 **MCP-우선** 로컬 커리어 관리 도구입니다. 사용자
|
|
|
16
16
|
|
|
17
17
|
| 읽기전용(✅) 도구 | 쓰기/부수효과(✏️) 도구 |
|
|
18
18
|
| --- | --- |
|
|
19
|
-
| `read_document`, `read_inbox`, `get_onboarding_status`, `get_profile`, `get_resumes`, `get_experiences`, `get_projects`, `get_skills`, `get_cover_letters`, `get_job_posting`, `list_jobs`, `get_application_context`, `list_recent_activity`, `get_workflow_guide`, `get_playbook`, `get_verifier`, `validate_cover_letter`, `get_writing_style_guide`, `check_for_update` | `open_inbox`, `save_profile`, `add_resume`, `add_experience`, `add_project`, `add_skill`, `save_cover_letter_version`, `set_verify_mode`, `save_job_posting`, `save_fit_analysis`, `update_application_status`, `save_interview_prep`, `export_cover_letter`, `open_dashboard`, `open_application`, `update_careermate` |
|
|
19
|
+
| `read_document`, `read_inbox`, `get_onboarding_status`, `get_profile`, `get_resumes`, `get_experiences`, `get_projects`, `get_skills`, `get_cover_letters`, `get_job_posting`, `list_jobs`, `get_application_context`, `list_recent_activity`, `get_workflow_guide`, `get_playbook`, `get_verifier`, `validate_cover_letter`, `get_writing_style_guide`, `check_for_update` | `open_inbox`, `save_profile`, `add_resume`, `add_experience`, `add_project`, `add_skill`, `save_cover_letter_version`, `delete_cover_letter`, `set_verify_mode`, `save_job_posting`, `delete_job_posting`, `save_fit_analysis`, `update_application_status`, `save_interview_prep`, `export_cover_letter`, `open_dashboard`, `open_application`, `update_careermate` |
|
|
20
20
|
|
|
21
21
|
---
|
|
22
22
|
|
|
@@ -97,16 +97,17 @@ AI 클라이언트가 연결되면 이 도구를 가장 먼저 호출해 상태
|
|
|
97
97
|
|
|
98
98
|
| 도구 | 목적 | 주요 입력 | 반환 | 언제 호출 | 읽기전용 |
|
|
99
99
|
| --- | --- | --- | --- | --- | --- |
|
|
100
|
-
| `add_experience` | 경력(직장 단위) 항목 저장 | `company`(필수), `role?`, `employment_type?`, `start_date?`, `end_date?`, `is_current?`, `description?`, `achievements?`, `tech?`, `order_index?` |
|
|
100
|
+
| `add_experience` | 경력(직장 단위) 항목 저장 | `experiences[]` — 각 항목: `company`(필수), `role?`, `employment_type?`, `start_date?`, `end_date?`, `is_current?`, `description?`, `achievements?`, `tech?`, `order_index?` | `ExperienceRecord[]` + 신규/갱신 건수 | 이력서에서 경력을 구조화해 보관할 때(여러 건 한 번에) | ✏️ |
|
|
101
101
|
| `get_experiences` | 저장된 경력을 시간 역순으로 조회 | 없음 | `ExperienceRecord[]` | 경력 목록만 빠르게 확인할 때 | ✅ |
|
|
102
|
-
| `add_project` | 대표 프로젝트 항목 저장 | `name`(필수), `role?`, `description?`, `highlights?`, `tech?`, `url?`, `start_date?`, `end_date?`, `order_index?` |
|
|
102
|
+
| `add_project` | 대표 프로젝트 항목 저장 | `projects[]` — 각 항목: `name`(필수), `role?`, `description?`, `highlights?`, `tech?`, `url?`, `start_date?`, `end_date?`, `order_index?` | `ProjectRecord[]` + 신규/갱신 건수 | 프로젝트 이력을 구조화해 보관할 때(여러 건 한 번에) | ✏️ |
|
|
103
103
|
| `get_projects` | 저장된 프로젝트 조회 | 없음 | `ProjectRecord[]` | 프로젝트 목록만 확인할 때 | ✅ |
|
|
104
|
-
| `add_skill` | 기술스택 항목 저장
|
|
104
|
+
| `add_skill` | 기술스택 항목 저장 | `skills[]` — 각 항목: `name`(필수), `category?`, `level?`, `years?`, `order_index?` | `SkillRecord[]` + 신규/갱신 건수 | 보유 기술을 정리해 둘 때(여러 개 한 번에) | ✏️ |
|
|
105
105
|
| `get_skills` | 저장된 기술스택 조회 | 없음 | `SkillRecord[]` | 기술 목록만 확인할 때 | ✅ |
|
|
106
106
|
|
|
107
|
+
- 세 도구 모두 **배열을 받아 여러 건을 한 번에 저장**합니다(한 건이어도 배열로). 항목마다 도구를 반복 호출하지 마세요.
|
|
108
|
+
- **멱등 저장**: 같은 항목(스킬=`name`, 경력=`company`+`role`+`start_date`, 프로젝트=`name`, 대소문자 무시)을 다시 넣어도 중복 행을 만들지 않고 갱신합니다. 누락한 필드는 기존 값을 보존합니다. 한 번의 호출은 한 트랜잭션으로 원자 처리됩니다.
|
|
107
109
|
- `achievements`/`highlights`에는 정량 지표가 담긴 성과를, `tech`에는 사용 기술을 배열로 넣으면 적합도 분석·자소서·면접 준비에서 근거로 활용됩니다.
|
|
108
|
-
-
|
|
109
|
-
- 입력 스키마: `ExperienceInputSchema` / `ProjectInputSchema` / `SkillInputSchema`(`packages/shared/src/schemas.ts`).
|
|
110
|
+
- 입력 스키마: `ExperienceBatchInputSchema` / `ProjectBatchInputSchema` / `SkillBatchInputSchema`(각각 `experiences`/`projects`/`skills` 배열, 요소는 `ExperienceInputSchema` / `ProjectInputSchema` / `SkillInputSchema`; `packages/shared/src/schemas.ts`).
|
|
110
111
|
|
|
111
112
|
---
|
|
112
113
|
|
|
@@ -116,6 +117,7 @@ AI 클라이언트가 연결되면 이 도구를 가장 먼저 호출해 상태
|
|
|
116
117
|
| --- | --- | --- | --- | --- | --- |
|
|
117
118
|
| `get_cover_letters` | 저장된 자기소개서 조회(공고별 필터 가능). 각 항목은 현재 버전 내용·버전 수 포함 | `job_id?` | `CoverLetterRecord[]` | 자소서 목록/내용을 확인할 때 | ✅ |
|
|
118
119
|
| `save_cover_letter_version` | 자기소개서의 새 버전 저장 — 자소서 작성 워크플로우의 핵심 저장 단계 | `content`(필수), `cover_letter_id?`, `title?`, `job_id?`, `note?`, `source?`, `set_current?` | `{ coverLetter, version }` | 새 공고용 자소서를 생성·갱신해 버전으로 남길 때 | ✏️ |
|
|
120
|
+
| `delete_cover_letter` | 자기소개서 삭제(모든 버전 포함) | `cover_letter_id`, `confirm:"DELETE"` | 삭제 결과 | 사용자가 목록에서 삭제 대상을 확인한 뒤 | ✏️ |
|
|
119
121
|
|
|
120
122
|
`save_cover_letter_version` 동작(`CoverLetterVersionInputSchema`):
|
|
121
123
|
|
|
@@ -126,6 +128,8 @@ AI 클라이언트가 연결되면 이 도구를 가장 먼저 호출해 상태
|
|
|
126
128
|
|
|
127
129
|
> 기존 자소서를 학습용으로 보관하든 새 공고용 자소서를 새로 쓰든, 저장은 모두 `save_cover_letter_version`으로 합니다. `title`+`content`만 주면 새 자소서의 v1로 저장됩니다.
|
|
128
130
|
|
|
131
|
+
`delete_cover_letter`는 되돌릴 수 없는 작업이므로, 먼저 `get_cover_letters`로 목록을 확인하고 사용자가 삭제할 항목을 명확히 고른 뒤 호출합니다. 삭제된 자기소개서의 버전은 함께 사라지며, 연결된 지원 기록은 자기소개서 연결만 비워집니다.
|
|
132
|
+
|
|
129
133
|
---
|
|
130
134
|
|
|
131
135
|
## 5. 채용공고 (Jobs)
|
|
@@ -135,10 +139,12 @@ AI 클라이언트가 연결되면 이 도구를 가장 먼저 호출해 상태
|
|
|
135
139
|
| `save_job_posting` | 채용공고 저장(있으면 갱신). 저장 시 지원(application) 항목 자동 생성 | `company`, `position`(필수), `url?`, `location?`, `employment_type?`, `description?`, `requirements?`, `keywords?`, `deadline?`, `source?` | 저장된 `JobRecord`(`job_id` 포함) | 공고를 DB에 등록/갱신할 때 | ✏️ |
|
|
136
140
|
| `get_job_posting` | 공고 1건 상세 조회 | `job_id` | `{ job, fit, application, cover_letters, interview }` | 한 공고의 분석·상태·자소서·면접 자료를 한 번에 볼 때 | ✅ |
|
|
137
141
|
| `list_jobs` | 저장된 모든 공고를 지원 상태·적합도 점수와 함께 목록화 | 없음 | 공고 메타 포함 배열 | 전체 공고를 훑어볼 때 | ✅ |
|
|
142
|
+
| `delete_job_posting` | 채용공고 삭제 | `job_id`, `confirm:"DELETE"` | 삭제 결과 | 사용자가 목록에서 삭제 대상을 확인한 뒤 | ✏️ |
|
|
138
143
|
|
|
139
144
|
- `url`이 같으면 중복 생성 없이 갱신됩니다.
|
|
140
145
|
- `requirements`에는 자격요건/우대사항 핵심, `keywords`에는 핵심 키워드를 정리해 넣으면 이후 적합도 분석·자소서 작성에 활용됩니다.
|
|
141
146
|
- 붙여넣은 공고 원문의 구조화(회사명/직무/마감일/키워드 추출)는 AI가 직접 수행한 뒤 `save_job_posting`으로 저장합니다.
|
|
147
|
+
- `delete_job_posting`은 연결된 지원 상태·적합도 분석·면접 준비를 함께 삭제합니다. 연결된 자기소개서는 삭제하지 않고 공고 연결만 해제합니다.
|
|
142
148
|
|
|
143
149
|
---
|
|
144
150
|
|
|
@@ -146,10 +152,11 @@ AI 클라이언트가 연결되면 이 도구를 가장 먼저 호출해 상태
|
|
|
146
152
|
|
|
147
153
|
| 도구 | 목적 | 주요 입력 | 반환 | 언제 호출 | 읽기전용 |
|
|
148
154
|
| --- | --- | --- | --- | --- | --- |
|
|
149
|
-
| `save_fit_analysis` | AI가 도출한 적합도 분석 결과 저장(같은 공고에 다시 저장하면 갱신) | `job_id`(필수), `score?`(0~100), `summary?`, `strengths?`, `gaps?`, `matched_keywords?`, `missing_keywords?`, `recommendations?` | 저장된 `FitAnalysisRecord` | 공고와 사용자 정보를 비교 분석한 결과를 저장할 때 | ✏️ |
|
|
155
|
+
| `save_fit_analysis` | AI가 도출한 적합도 분석 결과 저장(같은 공고에 다시 저장하면 갱신) | `job_id`(필수), `score?`(0~100), `summary?`, `strengths?`, `gaps?`, `matched_keywords?`, `missing_keywords?`, `recommendations?` | 저장된 `FitAnalysisRecord` + `suggested_next_action` | 공고와 사용자 정보를 비교 분석한 결과를 저장할 때 | ✏️ |
|
|
150
156
|
|
|
151
157
|
- `job_id`는 필수이며, 먼저 `save_job_posting`으로 공고가 저장되어 있어야 합니다.
|
|
152
158
|
- 분석 자체는 AI가 수행하고, 이 도구는 결과만 저장합니다. 분석 전에는 반드시 [`get_application_context`](#get_application_context-)로 맥락을 가져오세요.
|
|
159
|
+
- 저장 후 응답에는 "이 공고에 맞춘 자기소개서를 써드릴까요?" 선택지가 함께 제공됩니다. AI는 곧장 작성하지 말고 사용자가 "네"를 고른 경우에만 자기소개서 작성 흐름으로 이어갑니다.
|
|
153
160
|
|
|
154
161
|
---
|
|
155
162
|
|
|
@@ -282,6 +289,7 @@ CareerMate를 최신 버전으로 유지하기 위한 유지보수 도구입니
|
|
|
282
289
|
| `update_careermate` | 설치 폴더에 `careermate@latest`를 npm으로 설치해 최신 버전으로 업데이트 | 없음 | 업데이트 결과(`{ ok, message, manual? }`) | 사용자가 명시적으로 "업데이트해줘"라고 할 때만 | ✏️ |
|
|
283
290
|
|
|
284
291
|
- `check_for_update`는 확인만 하며 데이터를 변경하지 않습니다. 새 버전이 있으면 `update_careermate`로 적용하세요.
|
|
292
|
+
- 사용자가 "업데이트해줘", "CareerMate 업데이트해줘", "최신으로 올려줘"처럼 적용을 명시하면 `update_careermate`를 바로 호출합니다. 이 도구가 최신 버전 확인도 함께 하므로 별도 확인 질문은 필요 없습니다.
|
|
285
293
|
- `update_careermate` 성공 후에는 적용을 위해 AI 앱(또는 MCP 연결) **재시작**이 필요합니다. 재시작 시 데이터베이스는 자동 마이그레이션되어 기존 데이터가 보존됩니다.
|
|
286
294
|
- 자동 설치가 막히면(개발 모드·비표준 설치) 직접 칠 명령(`manual`)을 안내합니다.
|
|
287
295
|
|
package/docs/START_WORKFLOW.md
CHANGED
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
### 3-1. 경력·프로젝트·스킬 등록 (있는 경우) — `add_experience` · `add_project` · `add_skill`
|
|
39
39
|
|
|
40
40
|
- 이력서에서 파악되는 경력·프로젝트·보유 기술은 본문 저장과 별개로 `add_experience` / `add_project` / `add_skill`로 항목 단위 저장하면 적합도 분석·자소서·면접 준비에서 정밀하게 활용됩니다.
|
|
41
|
-
-
|
|
41
|
+
- 세 도구는 모두 배열을 받습니다 — 파악한 경력/프로젝트/기술을 **한 번에 모아** 넘기세요(항목마다 반복 호출하지 않습니다). 같은 항목을 다시 넣어도 중복 없이 갱신됩니다.
|
|
42
|
+
- 경력 `achievements`에는 가능하면 정량 지표를 넣으세요.
|
|
42
43
|
- 이 항목들은 `get_onboarding_status`의 `has_experience` / `has_skills`와 프로필 완성도(0~100)에 반영됩니다.
|
|
43
44
|
|
|
44
45
|
### 4. 자기소개서 등록 (있는 경우) — `save_cover_letter_version`
|
package/docs/WORKFLOWS.md
CHANGED
|
@@ -8,7 +8,7 @@ CareerMate는 내부에 LLM을 두지 않는다. 분석과 작성은 사용자
|
|
|
8
8
|
|
|
9
9
|
- 연결 직후 AI가 따라가는 시작 런북은 [시작 런북](./START_WORKFLOW.md)을 참고한다.
|
|
10
10
|
- 설치·연결 절차는 [설치 런북](../INSTALL.md)(또는 [지원 앱·설치 레퍼런스](./SUPPORTED_AI_APPS.md))을 참고한다.
|
|
11
|
-
- MCP 도구
|
|
11
|
+
- MCP 도구 37종의 입출력 명세는 코드(`packages/mcp-tools/src/tools.ts`)에 정의되어 있으며, AI는 각 도구의 `inputSchema`로 직접 확인할 수 있다.
|
|
12
12
|
|
|
13
13
|
## 워크플로우 5종 개요
|
|
14
14
|
|
|
@@ -41,7 +41,7 @@ CareerMate는 내부에 LLM을 두지 않는다. 분석과 작성은 사용자
|
|
|
41
41
|
2. 비어 있는 항목을 사용자에게 쉬운 한국어로 안내하고, 이력서/자기소개서 파일 업로드 또는 정보 입력을 요청한다.
|
|
42
42
|
3. 수집한 정보를 구조화해 `save_profile`로 저장한다(이름·연락처·`headline`·`summary`·`desired_roles`·`desired_conditions`, 그리고 글쓰기 선호인 `preferred_tone`과 `emphasis_points` 포함). 파일 출처는 `source=upload`, 직접 입력은 `manual`.
|
|
43
43
|
4. 업로드한 이력서/경력기술서/포트폴리오 본문을 `add_resume`로 저장한다(`kind`·`title` 지정, 대표 문서는 `is_primary=true`).
|
|
44
|
-
5. 경력·프로젝트·스킬이 파악되면 `add_experience`·`add_project`·`add_skill`로
|
|
44
|
+
5. 경력·프로젝트·스킬이 파악되면 `add_experience`·`add_project`·`add_skill`로 저장한다 — 각 도구는 배열을 받으므로 파악한 항목을 한 번에 모아 넘긴다(항목마다 반복 호출하지 않는다). 같은 항목을 다시 넣어도 중복 없이 갱신된다(완성도의 `has_experience`/`has_skills`에 반영).
|
|
45
45
|
6. 기존 자기소개서가 있으면 `save_cover_letter_version`으로 저장한다(`title`+`content`면 v1로 저장, 없으면 나중에 작성 가능함을 안내).
|
|
46
46
|
7. `open_dashboard`를 호출해 사용자가 자기 데이터를 확인하게 한다.
|
|
47
47
|
8. 다시 `get_onboarding_status`로 완성도를 확인하고, 다음 단계(공고 분석 등)를 제안한다.
|
|
@@ -74,8 +74,8 @@ get_onboarding_status
|
|
|
74
74
|
4. **공고 저장** — 아직 저장되지 않은 공고라면 `save_job_posting`으로 공고를 저장하고 `job_id`를 확보한다.
|
|
75
75
|
5. **분석 저장** — 도출한 분석 결과를 `save_fit_analysis`로 저장한다(`job_id`, `score`, `summary`, `strengths`, `gaps`, `matched_keywords`, `missing_keywords`, `recommendations`).
|
|
76
76
|
6. **사용자 설명** — 분석 결과를 사용자에게 쉬운 한국어로 설명한다(잘 맞는 점, 보완할 점, 추천 전략). 기술 용어는 빼고 전달한다.
|
|
77
|
-
7. **자소서 제안** — "이 공고에 맞춘 자기소개서를 써드릴까요?"라고
|
|
78
|
-
8. **동의 시 자소서 저장** — 사용자가
|
|
77
|
+
7. **자소서 선택 제안** — 분석 뒤 곧장 자기소개서를 쓰지 말고 "이 공고에 맞춘 자기소개서를 써드릴까요?"라고 묻고 선택지를 제시한다: ① "네, 이 공고에 맞춘 자기소개서를 써줘" ② "아니요, 분석만 저장해줘".
|
|
78
|
+
8. **동의 시 자소서 저장** — 사용자가 ①을 선택하면 적합도 분석과 글쓰기 선호를 반영해 자기소개서를 작성하고 `save_cover_letter_version`으로 저장한다(`job_id` 연결, `note`에 작성 의도 요약). ②를 선택하면 자소서 작성 없이 멈추고 저장 결과와 대시보드 확인만 안내한다.
|
|
79
79
|
9. **지원 건 열기** — `open_application`으로 해당 지원 건을 열어 사용자가 결과를 확인하게 하고, 지원 상태 변경(예: 지원 예정/지원 완료)을 제안한다.
|
|
80
80
|
|
|
81
81
|
**도구 호출 순서 요약(정식 흐름):**
|
|
@@ -87,8 +87,8 @@ get_onboarding_status
|
|
|
87
87
|
→ save_job_posting # 미저장 공고일 때만, job_id 확보
|
|
88
88
|
→ save_fit_analysis # job_id 연결
|
|
89
89
|
→ (사용자에게 쉬운 한국어로 설명)
|
|
90
|
-
→ (자기소개서 작성 제안)
|
|
91
|
-
→ save_cover_letter_version #
|
|
90
|
+
→ (자기소개서 작성 여부 선택지 제안)
|
|
91
|
+
→ save_cover_letter_version # 사용자가 "네"를 선택한 경우에만, job_id·note
|
|
92
92
|
→ open_application # 결과 확인 + 상태 변경 제안
|
|
93
93
|
```
|
|
94
94
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "careermate",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "CareerMate — an MCP-first, local-first career workspace. Connect it to a local AI agent (Claude Desktop, Claude Code, Codex); CareerMate stores your career data locally on your machine and exposes it over MCP.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"shots:seed": "node --no-warnings --experimental-sqlite --import tsx scripts/shots-seed.ts",
|
|
67
67
|
"shots": "node --no-warnings --experimental-sqlite --import tsx scripts/shots.ts",
|
|
68
68
|
"doctor": "node --no-warnings --experimental-sqlite --import tsx scripts/doctor.ts",
|
|
69
|
-
"test": "node --no-warnings scripts/run.mjs scripts/test-init.ts \"INIT_TEST_VERDICT PASS\" && node --no-warnings scripts/run.mjs scripts/test.ts \"TEST_VERDICT PASS\"",
|
|
69
|
+
"test": "node --no-warnings scripts/run.mjs scripts/test-init.ts \"INIT_TEST_VERDICT PASS\" && node --no-warnings scripts/run.mjs scripts/test-bridge.ts \"BRIDGE_TEST_VERDICT PASS\" && node --no-warnings scripts/run.mjs scripts/test.ts \"TEST_VERDICT PASS\"",
|
|
70
70
|
"test:ui": "node --no-warnings scripts/run.mjs scripts/ui-smoke.ts \"UI_VERDICT PASS\"",
|
|
71
71
|
"typecheck": "tsc --noEmit"
|
|
72
72
|
},
|
|
@@ -28,6 +28,9 @@ import {
|
|
|
28
28
|
type SkillRecord,
|
|
29
29
|
APPLICATION_STATUS_LABELS,
|
|
30
30
|
INTERVIEW_UNLOCK_STATUSES,
|
|
31
|
+
CareerMateError,
|
|
32
|
+
notFound,
|
|
33
|
+
conflict,
|
|
31
34
|
} from '@careermate/shared';
|
|
32
35
|
import {
|
|
33
36
|
jobRepo,
|
|
@@ -126,6 +129,65 @@ export function addSkill(input: SkillInput): SkillRecord {
|
|
|
126
129
|
return skill;
|
|
127
130
|
}
|
|
128
131
|
|
|
132
|
+
/* ---------- Batch variants: add many in one call (idempotent upsert) ---------- */
|
|
133
|
+
// The MCP tools (add_experience/add_project/add_skill) accept an array so the AI
|
|
134
|
+
// can save everything extracted from a résumé in a single call instead of looping
|
|
135
|
+
// once per item. The repos dedupe by natural key (company+role+start / name) and
|
|
136
|
+
// merge non-destructively, so re-extracting the same data doesn't create dupes.
|
|
137
|
+
// One activity entry summarizes the whole batch (names only — never body text).
|
|
138
|
+
|
|
139
|
+
/** Truncated, name-only summary suffix for batch activity logs (privacy-safe). */
|
|
140
|
+
function batchNames(names: string[]): string {
|
|
141
|
+
const shown = names.filter(Boolean).slice(0, 8).join(', ');
|
|
142
|
+
if (!shown) return '';
|
|
143
|
+
return `: ${shown}${names.length > 8 ? ' 외' : ''}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function addExperiences(inputs: ExperienceInput[]): {
|
|
147
|
+
records: ExperienceRecord[];
|
|
148
|
+
created: number;
|
|
149
|
+
updated: number;
|
|
150
|
+
} {
|
|
151
|
+
const res = experienceRepo.addMany(inputs);
|
|
152
|
+
activityRepo.log(
|
|
153
|
+
'profile_updated',
|
|
154
|
+
`경력 ${res.records.length}건을 저장했습니다(신규 ${res.created} · 갱신 ${res.updated})${batchNames(res.records.map((e) => e.company))}.`,
|
|
155
|
+
'experience',
|
|
156
|
+
res.records[0]?.id,
|
|
157
|
+
);
|
|
158
|
+
return res;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function addProjects(inputs: ProjectInput[]): {
|
|
162
|
+
records: ProjectRecord[];
|
|
163
|
+
created: number;
|
|
164
|
+
updated: number;
|
|
165
|
+
} {
|
|
166
|
+
const res = projectRepo.addMany(inputs);
|
|
167
|
+
activityRepo.log(
|
|
168
|
+
'profile_updated',
|
|
169
|
+
`프로젝트 ${res.records.length}건을 저장했습니다(신규 ${res.created} · 갱신 ${res.updated})${batchNames(res.records.map((p) => p.name))}.`,
|
|
170
|
+
'project',
|
|
171
|
+
res.records[0]?.id,
|
|
172
|
+
);
|
|
173
|
+
return res;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function addSkills(inputs: SkillInput[]): {
|
|
177
|
+
records: SkillRecord[];
|
|
178
|
+
created: number;
|
|
179
|
+
updated: number;
|
|
180
|
+
} {
|
|
181
|
+
const res = skillRepo.addMany(inputs);
|
|
182
|
+
activityRepo.log(
|
|
183
|
+
'profile_updated',
|
|
184
|
+
`기술 ${res.records.length}개를 저장했습니다(신규 ${res.created} · 갱신 ${res.updated})${batchNames(res.records.map((s) => s.name))}.`,
|
|
185
|
+
'skill',
|
|
186
|
+
res.records[0]?.id,
|
|
187
|
+
);
|
|
188
|
+
return res;
|
|
189
|
+
}
|
|
190
|
+
|
|
129
191
|
/* -------------------------------------------------------------------- Jobs */
|
|
130
192
|
|
|
131
193
|
/** Save (or update) a posting and make sure an application row tracks it. */
|
|
@@ -140,7 +202,7 @@ export function saveJobPosting(input: JobInput, id?: string): { job: JobRecord;
|
|
|
140
202
|
|
|
141
203
|
export function saveFitAnalysis(input: FitAnalysisInput): FitAnalysisRecord {
|
|
142
204
|
const job = jobRepo.get(input.job_id);
|
|
143
|
-
if (!job) throw
|
|
205
|
+
if (!job) throw notFound('공고를 찾을 수 없습니다. 먼저 공고를 저장한 뒤 다시 시도하세요.');
|
|
144
206
|
applicationRepo.ensure(job.id, 'draft');
|
|
145
207
|
const fit = fitRepo.save(input);
|
|
146
208
|
const scoreText = fit.score != null ? ` (적합도 ${fit.score}점)` : '';
|
|
@@ -165,7 +227,7 @@ export function saveCoverLetterVersion(input: CoverLetterVersionInput & { force?
|
|
|
165
227
|
const tail = strictHit
|
|
166
228
|
? `엄격 모드가 켜져 있어, 올린 이력서 본문에 없는 수치는 막힙니다. 이력서를 올리거나 엄격 모드를 끄거나, 의도한 값이면 force: true로 저장하세요.`
|
|
167
229
|
: `이 수치는 저장된 경력·이력서·프로젝트에 없습니다. 원본 데이터의 실제 수치로 고치거나, 의도한 값이 맞다면 먼저 경력/프로젝트에 그 수치를 추가한 뒤 다시 저장하세요(그대로 저장하려면 force: true).`;
|
|
168
|
-
throw new
|
|
230
|
+
throw new CareerMateError(`저장 전 자동 점검에서 막혔습니다 — ${detail}. ${tail}`, 400, 'save_gate');
|
|
169
231
|
}
|
|
170
232
|
const { coverLetter, version } = coverLetterRepo.addVersion({
|
|
171
233
|
cover_letter_id: input.cover_letter_id,
|
|
@@ -207,7 +269,7 @@ export function updateApplicationStatus(
|
|
|
207
269
|
note?: string,
|
|
208
270
|
): StatusChangeResult {
|
|
209
271
|
const job = jobRepo.get(jobId);
|
|
210
|
-
if (!job) throw
|
|
272
|
+
if (!job) throw notFound('공고를 찾을 수 없습니다.');
|
|
211
273
|
const application = applicationRepo.setStatus(jobId, status, note);
|
|
212
274
|
activityRepo.log(
|
|
213
275
|
'application_status_changed',
|
|
@@ -220,7 +282,7 @@ export function updateApplicationStatus(
|
|
|
220
282
|
const hasPrep = !!interviewRepo.getByJob(jobId);
|
|
221
283
|
const hint =
|
|
222
284
|
interview_unlocked && !hasPrep
|
|
223
|
-
? '서류 단계를 통과했어요. 이 공고 기준으로 예상 면접 질문과 1분 자기소개를 준비할까요?
|
|
285
|
+
? '서류 단계를 통과했어요. 이 공고 기준으로 예상 면접 질문과 1분 자기소개를 준비할까요?'
|
|
224
286
|
: null;
|
|
225
287
|
|
|
226
288
|
return { application, job, interview_unlocked, hint };
|
|
@@ -230,14 +292,14 @@ export function updateApplicationStatus(
|
|
|
230
292
|
|
|
231
293
|
export function saveInterviewPrep(input: InterviewPrepInput): InterviewPrepRecord {
|
|
232
294
|
const job = jobRepo.get(input.job_id);
|
|
233
|
-
if (!job) throw
|
|
295
|
+
if (!job) throw notFound('공고를 찾을 수 없습니다.');
|
|
234
296
|
// Spec gate: interview prep is only allowed once the application has reached
|
|
235
297
|
// 서류 합격(document_passed) or beyond. Enforce it server-side, not just in prompts.
|
|
236
298
|
const app = applicationRepo.getByJob(job.id);
|
|
237
299
|
if (!app || !INTERVIEW_UNLOCK_STATUSES.includes(app.status)) {
|
|
238
300
|
const current = app ? APPLICATION_STATUS_LABELS[app.status] : '미지원';
|
|
239
|
-
throw
|
|
240
|
-
`면접 준비는 '서류 합격' 이상 상태에서만 저장할 수 있습니다. 현재 상태: '${current}'. 먼저
|
|
301
|
+
throw conflict(
|
|
302
|
+
`면접 준비는 '서류 합격' 이상 상태에서만 저장할 수 있습니다. 현재 상태: '${current}'. 먼저 지원 상태를 '서류 합격' 이상으로 바꾼 뒤 다시 시도하세요.`,
|
|
241
303
|
);
|
|
242
304
|
}
|
|
243
305
|
const prep = interviewRepo.save(input);
|
|
Binary file
|
|
@@ -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
|
|
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
|
-
|
|
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, '&')
|
|
43
|
+
.replace(/</g, '<')
|
|
44
|
+
.replace(/>/g, '>')
|
|
45
|
+
.replace(/"/g, '"')
|
|
46
|
+
.replace(/'/g, ''');
|
|
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 = {}):
|
|
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
|
-
|
|
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
|
-
: [
|
|
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
|
-
|
|
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
|
|
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 };
|