careermate 0.1.0
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 +256 -0
- package/THIRD_PARTY_NOTICES.md +40 -0
- package/apps/mcp/src/index.ts +66 -0
- package/apps/web/DESIGN_GUIDE.md +105 -0
- package/apps/web/UI_CONTRACT.md +44 -0
- package/apps/web/public/app.js +118 -0
- package/apps/web/public/fonts/PretendardVariable.woff2 +0 -0
- package/apps/web/public/index.html +41 -0
- package/apps/web/public/lib.js +282 -0
- package/apps/web/public/pages/applications.js +98 -0
- package/apps/web/public/pages/documents.js +446 -0
- package/apps/web/public/pages/home.js +263 -0
- package/apps/web/public/pages/interview.js +230 -0
- package/apps/web/public/pages/jobs.js +494 -0
- package/apps/web/public/pages/profile.js +576 -0
- package/apps/web/public/pages/settings.js +233 -0
- package/apps/web/public/styles.css +426 -0
- package/apps/web/src/exports.ts +68 -0
- package/apps/web/src/http.ts +180 -0
- package/apps/web/src/index.ts +49 -0
- package/apps/web/src/info.ts +50 -0
- package/apps/web/src/routes.ts +350 -0
- package/apps/web/src/security.ts +102 -0
- package/apps/web/src/server.ts +141 -0
- package/apps/web/src/settings.ts +88 -0
- package/bin/careermate.mjs +74 -0
- package/dist/careermate.mcpb +0 -0
- package/dist/install-page/index.html +474 -0
- package/dist/install-page/style.css +391 -0
- package/dist/install-page/vercel.json +20 -0
- package/dist/mcp-smoke.err +3 -0
- package/dist/mcp.mjs +23704 -0
- package/dist/mcpb-stage/README.md +219 -0
- package/dist/mcpb-stage/dist/install-page/index.html +434 -0
- package/dist/mcpb-stage/dist/install-page/style.css +407 -0
- package/dist/mcpb-stage/dist/install-page/vercel.json +20 -0
- package/dist/mcpb-stage/dist/mcp.mjs +23704 -0
- package/dist/mcpb-stage/dist/public/app.js +118 -0
- package/dist/mcpb-stage/dist/public/fonts/PretendardVariable.woff2 +0 -0
- package/dist/mcpb-stage/dist/public/index.html +41 -0
- package/dist/mcpb-stage/dist/public/lib.js +282 -0
- package/dist/mcpb-stage/dist/public/pages/applications.js +98 -0
- package/dist/mcpb-stage/dist/public/pages/documents.js +446 -0
- package/dist/mcpb-stage/dist/public/pages/home.js +263 -0
- package/dist/mcpb-stage/dist/public/pages/interview.js +230 -0
- package/dist/mcpb-stage/dist/public/pages/jobs.js +494 -0
- package/dist/mcpb-stage/dist/public/pages/profile.js +576 -0
- package/dist/mcpb-stage/dist/public/pages/settings.js +233 -0
- package/dist/mcpb-stage/dist/public/styles.css +420 -0
- package/dist/mcpb-stage/dist/web.mjs +7240 -0
- package/dist/mcpb-stage/manifest.json +40 -0
- package/dist/public/app.js +118 -0
- package/dist/public/fonts/PretendardVariable.woff2 +0 -0
- package/dist/public/index.html +41 -0
- package/dist/public/lib.js +282 -0
- package/dist/public/pages/applications.js +98 -0
- package/dist/public/pages/documents.js +446 -0
- package/dist/public/pages/home.js +263 -0
- package/dist/public/pages/interview.js +230 -0
- package/dist/public/pages/jobs.js +494 -0
- package/dist/public/pages/profile.js +576 -0
- package/dist/public/pages/settings.js +233 -0
- package/dist/public/styles.css +426 -0
- package/dist/web.mjs +7240 -0
- package/docs/ARCHITECTURE.md +208 -0
- package/docs/CHANGES_V1.md +103 -0
- package/docs/DATA_MODEL.md +460 -0
- package/docs/DECISIONS.md +277 -0
- package/docs/DEMO.md +242 -0
- package/docs/INSTALL.md +148 -0
- package/docs/INSTALL_AND_USAGE.md +99 -0
- package/docs/MCP_TOOLS.md +233 -0
- package/docs/ROADMAP.md +134 -0
- package/docs/START_WORKFLOW.md +125 -0
- package/docs/SUPPORTED_AI_APPS.md +60 -0
- package/docs/TODO.md +57 -0
- package/docs/UX_NOTES.md +247 -0
- package/docs/WORKFLOWS.md +200 -0
- package/install-page/index.html +474 -0
- package/install-page/style.css +391 -0
- package/install-page/vercel.json +20 -0
- package/package.json +68 -0
- package/packages/core/src/context.ts +74 -0
- package/packages/core/src/index.ts +8 -0
- package/packages/core/src/onboarding.ts +81 -0
- package/packages/core/src/services.ts +146 -0
- package/packages/core/src/summary.ts +104 -0
- package/packages/db/src/connection.ts +46 -0
- package/packages/db/src/index.ts +22 -0
- package/packages/db/src/paths.ts +41 -0
- package/packages/db/src/repositories.ts +828 -0
- package/packages/db/src/runtime.ts +58 -0
- package/packages/db/src/schema.ts +189 -0
- package/packages/exporters/src/html.ts +113 -0
- package/packages/exporters/src/index.ts +364 -0
- package/packages/exporters/src/markdown.ts +178 -0
- package/packages/mcp-tools/src/bridge.ts +83 -0
- package/packages/mcp-tools/src/index.ts +8 -0
- package/packages/mcp-tools/src/result.ts +49 -0
- package/packages/mcp-tools/src/tools.ts +455 -0
- package/packages/parsers/src/html.ts +86 -0
- package/packages/parsers/src/index.ts +228 -0
- package/packages/parsers/src/keywords.ts +151 -0
- package/packages/prompts/src/humanize.ts +59 -0
- package/packages/prompts/src/index.ts +82 -0
- package/packages/prompts/src/install.ts +43 -0
- package/packages/prompts/src/onboarding.ts +35 -0
- package/packages/prompts/src/system.ts +53 -0
- package/packages/shared/src/enums.ts +103 -0
- package/packages/shared/src/index.ts +18 -0
- package/packages/shared/src/schemas.ts +398 -0
- package/packages/workflows/src/definitions.ts +107 -0
- package/packages/workflows/src/index.ts +39 -0
- package/scripts/build-dist.mjs +62 -0
- package/scripts/build-mcpb.mjs +70 -0
- package/scripts/doctor.ts +81 -0
- package/scripts/init.ts +342 -0
- package/scripts/mcp-probe.ts +55 -0
- package/scripts/migrate.ts +6 -0
- package/scripts/run.mjs +33 -0
- package/scripts/seed.ts +129 -0
- package/scripts/test.ts +117 -0
- package/scripts/ui-smoke.ts +73 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST API surface. Every endpoint mirrors a core use-case so the dashboard and
|
|
3
|
+
* the MCP server stay in lockstep. Handlers return plain objects (serialized to
|
|
4
|
+
* JSON by the server) or throw HttpError for clean failures.
|
|
5
|
+
*
|
|
6
|
+
* Body validation goes through zod schemas from @careermate/shared — nothing
|
|
7
|
+
* untrusted reaches the DB unvalidated.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
ProfileInputSchema,
|
|
11
|
+
ExperienceInputSchema,
|
|
12
|
+
ProjectInputSchema,
|
|
13
|
+
SkillInputSchema,
|
|
14
|
+
DocumentInputSchema,
|
|
15
|
+
CoverLetterInputSchema,
|
|
16
|
+
CoverLetterVersionInputSchema,
|
|
17
|
+
JobInputSchema,
|
|
18
|
+
FitAnalysisInputSchema,
|
|
19
|
+
ApplicationInputSchema,
|
|
20
|
+
ApplicationStatusUpdateSchema,
|
|
21
|
+
InterviewPrepInputSchema,
|
|
22
|
+
APPLICATION_STATUS_LABELS,
|
|
23
|
+
DOCUMENT_KIND_LABELS,
|
|
24
|
+
APPLICATION_BOARD_ORDER,
|
|
25
|
+
type DocumentKind,
|
|
26
|
+
} from '@careermate/shared';
|
|
27
|
+
import {
|
|
28
|
+
profileRepo,
|
|
29
|
+
experienceRepo,
|
|
30
|
+
projectRepo,
|
|
31
|
+
skillRepo,
|
|
32
|
+
documentRepo,
|
|
33
|
+
coverLetterRepo,
|
|
34
|
+
jobRepo,
|
|
35
|
+
fitRepo,
|
|
36
|
+
applicationRepo,
|
|
37
|
+
interviewRepo,
|
|
38
|
+
activityRepo,
|
|
39
|
+
} from '@careermate/db';
|
|
40
|
+
import {
|
|
41
|
+
getOnboardingStatus,
|
|
42
|
+
getApplicationContext,
|
|
43
|
+
getHomeSummary,
|
|
44
|
+
listRecentActivity,
|
|
45
|
+
saveProfile,
|
|
46
|
+
addResume,
|
|
47
|
+
saveJobPosting,
|
|
48
|
+
saveFitAnalysis,
|
|
49
|
+
saveCoverLetterVersion,
|
|
50
|
+
updateApplicationStatus,
|
|
51
|
+
saveInterviewPrep,
|
|
52
|
+
jobWithMeta,
|
|
53
|
+
} from '@careermate/core';
|
|
54
|
+
import { PROMPTS } from '@careermate/prompts';
|
|
55
|
+
import { WORKFLOWS } from '@careermate/workflows';
|
|
56
|
+
import { cleanJobPosting, extractText } from '@careermate/parsers';
|
|
57
|
+
import { z } from 'zod';
|
|
58
|
+
import { Router, HttpError, readJsonBody, type Ctx } from './http.ts';
|
|
59
|
+
import { exportCoverLetter, exportDocument, exportProfile, exportInterview, type ExportFormat } from './exports.ts';
|
|
60
|
+
import { getServerInfo } from './info.ts';
|
|
61
|
+
import { exportAll, createBackup, resetAll, listBackups } from './settings.ts';
|
|
62
|
+
|
|
63
|
+
function id(ctx: Ctx, key = 'id'): string {
|
|
64
|
+
const v = ctx.params[key];
|
|
65
|
+
if (!v) throw new HttpError(400, 'id가 필요합니다.');
|
|
66
|
+
return v;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function fmt(ctx: Ctx): ExportFormat {
|
|
70
|
+
const f = (ctx.query.get('format') ?? 'md').toLowerCase();
|
|
71
|
+
return (['md', 'html', 'txt'].includes(f) ? f : 'md') as ExportFormat;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Assemble the full detail bundle for one job (used by the Jobs/Applications UI). */
|
|
75
|
+
function jobDetail(jobId: string) {
|
|
76
|
+
const job = jobRepo.get(jobId);
|
|
77
|
+
if (!job) throw new HttpError(404, '공고를 찾을 수 없습니다.');
|
|
78
|
+
const application = applicationRepo.getByJob(jobId);
|
|
79
|
+
const fit = fitRepo.getByJob(jobId);
|
|
80
|
+
const cover_letters = coverLetterRepo.listByJob(jobId);
|
|
81
|
+
const interview = interviewRepo.getByJob(jobId);
|
|
82
|
+
const related = jobRepo.relatedTo(job.company, job.position, job.id).map(jobWithMeta);
|
|
83
|
+
return {
|
|
84
|
+
...jobWithMeta(job),
|
|
85
|
+
application,
|
|
86
|
+
fit,
|
|
87
|
+
cover_letters,
|
|
88
|
+
interview,
|
|
89
|
+
related,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function registerApiRoutes(router: Router): void {
|
|
94
|
+
/* --------------------------------------------------------------- meta */
|
|
95
|
+
router.get('/api/health', () => ({ ok: true, ...getServerInfo() }));
|
|
96
|
+
router.get('/api/onboarding', () => getOnboardingStatus());
|
|
97
|
+
router.get('/api/summary', () => getHomeSummary());
|
|
98
|
+
router.get('/api/activity', (ctx) => ({
|
|
99
|
+
activities: listRecentActivity(Number(ctx.query.get('limit') ?? 30)),
|
|
100
|
+
}));
|
|
101
|
+
router.get('/api/context', (ctx) =>
|
|
102
|
+
getApplicationContext({ job_id: ctx.query.get('job_id') ?? undefined }),
|
|
103
|
+
);
|
|
104
|
+
router.get('/api/prompts', () => ({ prompts: PROMPTS }));
|
|
105
|
+
router.get('/api/workflows', () => ({ workflows: WORKFLOWS }));
|
|
106
|
+
router.get('/api/meta', () => ({
|
|
107
|
+
statuses: APPLICATION_BOARD_ORDER.map((s) => ({ value: s, label: APPLICATION_STATUS_LABELS[s] })),
|
|
108
|
+
document_kinds: Object.entries(DOCUMENT_KIND_LABELS).map(([value, label]) => ({ value, label })),
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
/* ------------------------------------------------------------ profile */
|
|
112
|
+
router.get('/api/profile', () => ({ profile: profileRepo.get() }));
|
|
113
|
+
router.put('/api/profile', async (ctx) => {
|
|
114
|
+
const input = await readJsonBody(ctx.req, ProfileInputSchema);
|
|
115
|
+
return { profile: saveProfile(input) };
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/* --------------------------------------------------------- experiences */
|
|
119
|
+
router.get('/api/experiences', () => ({ experiences: experienceRepo.list() }));
|
|
120
|
+
router.post('/api/experiences', async (ctx) => {
|
|
121
|
+
const input = await readJsonBody(ctx.req, ExperienceInputSchema);
|
|
122
|
+
const exp = experienceRepo.add(input);
|
|
123
|
+
activityRepo.log('profile_updated', `경력(${exp.company})을 추가했습니다.`, 'experience', exp.id);
|
|
124
|
+
return { experience: exp };
|
|
125
|
+
});
|
|
126
|
+
router.put('/api/experiences/:id', async (ctx) => {
|
|
127
|
+
const input = await readJsonBody(ctx.req, ExperienceInputSchema.partial());
|
|
128
|
+
const exp = experienceRepo.update(id(ctx), input);
|
|
129
|
+
if (!exp) throw new HttpError(404, '경력을 찾을 수 없습니다.');
|
|
130
|
+
return { experience: exp };
|
|
131
|
+
});
|
|
132
|
+
router.delete('/api/experiences/:id', (ctx) => ({ ok: experienceRepo.remove(id(ctx)) }));
|
|
133
|
+
|
|
134
|
+
/* ------------------------------------------------------------ projects */
|
|
135
|
+
router.get('/api/projects', () => ({ projects: projectRepo.list() }));
|
|
136
|
+
router.post('/api/projects', async (ctx) => {
|
|
137
|
+
const input = await readJsonBody(ctx.req, ProjectInputSchema);
|
|
138
|
+
const prj = projectRepo.add(input);
|
|
139
|
+
activityRepo.log('profile_updated', `프로젝트(${prj.name})를 추가했습니다.`, 'project', prj.id);
|
|
140
|
+
return { project: prj };
|
|
141
|
+
});
|
|
142
|
+
router.put('/api/projects/:id', async (ctx) => {
|
|
143
|
+
const input = await readJsonBody(ctx.req, ProjectInputSchema.partial());
|
|
144
|
+
const prj = projectRepo.update(id(ctx), input);
|
|
145
|
+
if (!prj) throw new HttpError(404, '프로젝트를 찾을 수 없습니다.');
|
|
146
|
+
return { project: prj };
|
|
147
|
+
});
|
|
148
|
+
router.delete('/api/projects/:id', (ctx) => ({ ok: projectRepo.remove(id(ctx)) }));
|
|
149
|
+
|
|
150
|
+
/* -------------------------------------------------------------- skills */
|
|
151
|
+
router.get('/api/skills', () => ({ skills: skillRepo.list() }));
|
|
152
|
+
router.post('/api/skills', async (ctx) => {
|
|
153
|
+
const input = await readJsonBody(ctx.req, SkillInputSchema);
|
|
154
|
+
return { skill: skillRepo.add(input) };
|
|
155
|
+
});
|
|
156
|
+
router.put('/api/skills/:id', async (ctx) => {
|
|
157
|
+
const input = await readJsonBody(ctx.req, SkillInputSchema.partial());
|
|
158
|
+
const skill = skillRepo.update(id(ctx), input);
|
|
159
|
+
if (!skill) throw new HttpError(404, '기술을 찾을 수 없습니다.');
|
|
160
|
+
return { skill };
|
|
161
|
+
});
|
|
162
|
+
router.delete('/api/skills/:id', (ctx) => ({ ok: skillRepo.remove(id(ctx)) }));
|
|
163
|
+
|
|
164
|
+
/* ----------------------------------------------------------- documents */
|
|
165
|
+
router.get('/api/documents', (ctx) => {
|
|
166
|
+
const kind = ctx.query.get('kind') as DocumentKind | null;
|
|
167
|
+
return { documents: documentRepo.list(kind ?? undefined) };
|
|
168
|
+
});
|
|
169
|
+
router.post('/api/documents', async (ctx) => {
|
|
170
|
+
const input = await readJsonBody(ctx.req, DocumentInputSchema);
|
|
171
|
+
return { document: addResume(input) };
|
|
172
|
+
});
|
|
173
|
+
router.get('/api/documents/:id', (ctx) => {
|
|
174
|
+
const doc = documentRepo.get(id(ctx));
|
|
175
|
+
if (!doc) throw new HttpError(404, '문서를 찾을 수 없습니다.');
|
|
176
|
+
return { document: doc };
|
|
177
|
+
});
|
|
178
|
+
router.put('/api/documents/:id', async (ctx) => {
|
|
179
|
+
const input = await readJsonBody(ctx.req, DocumentInputSchema.partial());
|
|
180
|
+
const doc = documentRepo.update(id(ctx), input);
|
|
181
|
+
if (!doc) throw new HttpError(404, '문서를 찾을 수 없습니다.');
|
|
182
|
+
return { document: doc };
|
|
183
|
+
});
|
|
184
|
+
router.delete('/api/documents/:id', (ctx) => ({ ok: documentRepo.remove(id(ctx)) }));
|
|
185
|
+
|
|
186
|
+
/* ------------------------------------------------------- cover letters */
|
|
187
|
+
router.get('/api/cover-letters', () => ({ cover_letters: coverLetterRepo.list() }));
|
|
188
|
+
router.post('/api/cover-letters', async (ctx) => {
|
|
189
|
+
const input = await readJsonBody(ctx.req, CoverLetterInputSchema);
|
|
190
|
+
if (input.content) {
|
|
191
|
+
const { coverLetter } = saveCoverLetterVersion({
|
|
192
|
+
title: input.title,
|
|
193
|
+
job_id: input.job_id,
|
|
194
|
+
content: input.content,
|
|
195
|
+
note: input.note,
|
|
196
|
+
source: input.source ?? 'manual',
|
|
197
|
+
});
|
|
198
|
+
if (input.is_primary) coverLetterRepo.setPrimary(coverLetter.id);
|
|
199
|
+
activityRepo.log('cover_letter_added', `${coverLetter.title} 자기소개서를 추가했습니다.`, 'cover_letter', coverLetter.id);
|
|
200
|
+
return { cover_letter: coverLetterRepo.get(coverLetter.id) };
|
|
201
|
+
}
|
|
202
|
+
const cl = coverLetterRepo.create({ title: input.title, job_id: input.job_id ?? null, is_primary: input.is_primary });
|
|
203
|
+
activityRepo.log('cover_letter_added', `${cl.title} 자기소개서를 추가했습니다.`, 'cover_letter', cl.id);
|
|
204
|
+
return { cover_letter: cl };
|
|
205
|
+
});
|
|
206
|
+
router.get('/api/cover-letters/:id', (ctx) => {
|
|
207
|
+
const cl = coverLetterRepo.get(id(ctx), true);
|
|
208
|
+
if (!cl) throw new HttpError(404, '자기소개서를 찾을 수 없습니다.');
|
|
209
|
+
return { cover_letter: cl };
|
|
210
|
+
});
|
|
211
|
+
router.post('/api/cover-letters/:id/versions', async (ctx) => {
|
|
212
|
+
const input = await readJsonBody(
|
|
213
|
+
ctx.req,
|
|
214
|
+
CoverLetterVersionInputSchema.omit({ cover_letter_id: true }),
|
|
215
|
+
);
|
|
216
|
+
const { coverLetter, version } = saveCoverLetterVersion({ ...input, cover_letter_id: id(ctx) });
|
|
217
|
+
return { cover_letter: coverLetter, version };
|
|
218
|
+
});
|
|
219
|
+
router.put('/api/cover-letters/:id/current-version', async (ctx) => {
|
|
220
|
+
const { version_id } = await readJsonBody(ctx.req, z.object({ version_id: z.string() }));
|
|
221
|
+
const cl = coverLetterRepo.setCurrentVersion(id(ctx), version_id);
|
|
222
|
+
if (!cl) throw new HttpError(404, '자기소개서를 찾을 수 없습니다.');
|
|
223
|
+
return { cover_letter: cl };
|
|
224
|
+
});
|
|
225
|
+
router.put('/api/cover-letters/:id/primary', (ctx) => {
|
|
226
|
+
const cl = coverLetterRepo.setPrimary(id(ctx));
|
|
227
|
+
if (!cl) throw new HttpError(404, '자기소개서를 찾을 수 없습니다.');
|
|
228
|
+
return { cover_letter: cl };
|
|
229
|
+
});
|
|
230
|
+
router.delete('/api/cover-letters/:id', (ctx) => ({ ok: coverLetterRepo.remove(id(ctx)) }));
|
|
231
|
+
|
|
232
|
+
/* ---------------------------------------------------------------- jobs */
|
|
233
|
+
router.get('/api/jobs', () => ({ jobs: jobRepo.list().map(jobWithMeta) }));
|
|
234
|
+
router.post('/api/jobs', async (ctx) => {
|
|
235
|
+
const input = await readJsonBody(ctx.req, JobInputSchema);
|
|
236
|
+
const { job, application } = saveJobPosting(input);
|
|
237
|
+
return { job: jobWithMeta(job), application };
|
|
238
|
+
});
|
|
239
|
+
router.get('/api/jobs/:id', (ctx) => ({ job: jobDetail(id(ctx)) }));
|
|
240
|
+
router.put('/api/jobs/:id', async (ctx) => {
|
|
241
|
+
const input = await readJsonBody(ctx.req, JobInputSchema.partial());
|
|
242
|
+
const existing = jobRepo.get(id(ctx));
|
|
243
|
+
if (!existing) throw new HttpError(404, '공고를 찾을 수 없습니다.');
|
|
244
|
+
const job = jobRepo.upsert({ ...existing, ...input } as any, existing.id);
|
|
245
|
+
return { job: jobWithMeta(job) };
|
|
246
|
+
});
|
|
247
|
+
router.delete('/api/jobs/:id', (ctx) => ({ ok: jobRepo.remove(id(ctx)) }));
|
|
248
|
+
|
|
249
|
+
/* ----------------------------------------------------- fit / analysis */
|
|
250
|
+
router.put('/api/jobs/:id/fit', async (ctx) => {
|
|
251
|
+
const input = await readJsonBody(ctx.req, FitAnalysisInputSchema.omit({ job_id: true }));
|
|
252
|
+
return { fit: saveFitAnalysis({ ...input, job_id: id(ctx) }) };
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
/* --------------------------------------------------------- interview */
|
|
256
|
+
// All saved interview preps joined with their jobs, plus jobs eligible for prep
|
|
257
|
+
// (status at/after 서류 합격) that don't have prep yet — powers the Interview page.
|
|
258
|
+
router.get('/api/interview', () => {
|
|
259
|
+
const preps = interviewRepo.list().map((p) => ({ ...p, job: jobRepo.get(p.job_id) }));
|
|
260
|
+
const eligible = applicationRepo
|
|
261
|
+
.list()
|
|
262
|
+
.filter((a) => ['document_passed', 'interview', 'final_passed'].includes(a.status))
|
|
263
|
+
.map((a) => ({
|
|
264
|
+
job: jobRepo.get(a.job_id),
|
|
265
|
+
status: a.status,
|
|
266
|
+
status_label: APPLICATION_STATUS_LABELS[a.status],
|
|
267
|
+
has_prep: !!interviewRepo.getByJob(a.job_id),
|
|
268
|
+
}))
|
|
269
|
+
.filter((x) => x.job);
|
|
270
|
+
return { preps, eligible };
|
|
271
|
+
});
|
|
272
|
+
router.get('/api/jobs/:id/interview', (ctx) => ({ interview: interviewRepo.getByJob(id(ctx)) }));
|
|
273
|
+
router.put('/api/jobs/:id/interview', async (ctx) => {
|
|
274
|
+
const input = await readJsonBody(ctx.req, InterviewPrepInputSchema.omit({ job_id: true }));
|
|
275
|
+
return { interview: saveInterviewPrep({ ...input, job_id: id(ctx) }) };
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
/* ------------------------------------------------------- applications */
|
|
279
|
+
router.get('/api/applications', () => {
|
|
280
|
+
const apps = applicationRepo.list().map((a) => ({
|
|
281
|
+
...a,
|
|
282
|
+
status_label: APPLICATION_STATUS_LABELS[a.status],
|
|
283
|
+
job: jobRepo.get(a.job_id),
|
|
284
|
+
fit_score: fitRepo.getByJob(a.job_id)?.score ?? null,
|
|
285
|
+
}));
|
|
286
|
+
return { applications: apps, board_order: APPLICATION_BOARD_ORDER };
|
|
287
|
+
});
|
|
288
|
+
router.put('/api/applications/:jobId/status', async (ctx) => {
|
|
289
|
+
const body = await readJsonBody(ctx.req, ApplicationStatusUpdateSchema.omit({ job_id: true, application_id: true }));
|
|
290
|
+
return updateApplicationStatus(id(ctx, 'jobId'), body.status, body.note);
|
|
291
|
+
});
|
|
292
|
+
router.put('/api/applications/:jobId', async (ctx) => {
|
|
293
|
+
const body = await readJsonBody(ctx.req, ApplicationInputSchema.omit({ job_id: true }));
|
|
294
|
+
return { application: applicationRepo.upsert({ ...body, job_id: id(ctx, 'jobId') }) };
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
/* ------------------------------------------------------------- export */
|
|
298
|
+
router.get('/api/export/cover-letter/:id', (ctx) => download(exportCoverLetter(id(ctx), fmt(ctx))));
|
|
299
|
+
router.get('/api/export/document/:id', (ctx) => download(exportDocument(id(ctx), fmt(ctx))));
|
|
300
|
+
router.get('/api/export/profile', (ctx) => download(exportProfile(fmt(ctx))));
|
|
301
|
+
router.get('/api/export/interview/:jobId', (ctx) => download(exportInterview(id(ctx, 'jobId'), fmt(ctx))));
|
|
302
|
+
|
|
303
|
+
/* ----------------------------------------------------------- settings */
|
|
304
|
+
router.get('/api/settings', () => ({
|
|
305
|
+
info: getServerInfo(),
|
|
306
|
+
backups: listBackups(),
|
|
307
|
+
}));
|
|
308
|
+
router.get('/api/settings/export-all', () =>
|
|
309
|
+
download({
|
|
310
|
+
filename: `careermate-backup-${new Date().toISOString().slice(0, 10)}.json`,
|
|
311
|
+
mimeType: 'application/json; charset=utf-8',
|
|
312
|
+
content: JSON.stringify(exportAll(), null, 2),
|
|
313
|
+
}),
|
|
314
|
+
);
|
|
315
|
+
router.post('/api/settings/backup', () => createBackup());
|
|
316
|
+
router.post('/api/settings/reset', async (ctx) => {
|
|
317
|
+
const { confirm } = await readJsonBody(ctx.req, z.object({ confirm: z.string() }));
|
|
318
|
+
const result = resetAll(confirm);
|
|
319
|
+
if (!result.ok) throw new HttpError(400, '초기화를 진행하려면 confirm 값으로 "DELETE"를 보내야 합니다.');
|
|
320
|
+
activityRepo.log('profile_updated', '모든 데이터를 초기화했습니다(백업 생성됨).');
|
|
321
|
+
return result;
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
/* -------------------------------------------------------- parse helper */
|
|
325
|
+
// Lets the dashboard pre-clean a pasted posting before saving (AI can do this
|
|
326
|
+
// too, but the UI offers it for the manual "save a posting" path).
|
|
327
|
+
router.post('/api/parse/job', async (ctx) => {
|
|
328
|
+
const { raw } = await readJsonBody(ctx.req, z.object({ raw: z.string() }));
|
|
329
|
+
return cleanJobPosting(raw);
|
|
330
|
+
});
|
|
331
|
+
router.post('/api/parse/text', async (ctx) => {
|
|
332
|
+
const body = await readJsonBody(
|
|
333
|
+
ctx.req,
|
|
334
|
+
z.object({ filename: z.string().optional(), mimeType: z.string().optional(), content: z.string() }),
|
|
335
|
+
);
|
|
336
|
+
return extractText(body);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Wrap an ExportResult so the server streams it as a file download instead of JSON. */
|
|
341
|
+
import type { ExportResult } from '@careermate/exporters';
|
|
342
|
+
export interface DownloadResponse {
|
|
343
|
+
__download: ExportResult;
|
|
344
|
+
}
|
|
345
|
+
export function download(result: ExportResult): DownloadResponse {
|
|
346
|
+
return { __download: result };
|
|
347
|
+
}
|
|
348
|
+
export function isDownload(v: unknown): v is DownloadResponse {
|
|
349
|
+
return typeof v === 'object' && v !== null && '__download' in v;
|
|
350
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-server security.
|
|
3
|
+
*
|
|
4
|
+
* A server bound to localhost is still reachable by *any* web page the user
|
|
5
|
+
* visits (the browser will happily send requests to 127.0.0.1). Two classic
|
|
6
|
+
* attacks follow: DNS-rebinding (a malicious site points its hostname at
|
|
7
|
+
* 127.0.0.1) and CSRF (a malicious page POSTs to our API using the user's
|
|
8
|
+
* browser). We defend with three cheap, layered checks:
|
|
9
|
+
*
|
|
10
|
+
* 1. Host allow-list — blocks DNS-rebinding (Host must be a loopback name).
|
|
11
|
+
* 2. Origin allow-list — cross-site requests carry a foreign Origin; reject.
|
|
12
|
+
* 3. Per-session CSRF token — mutations require a header only same-origin
|
|
13
|
+
* scripts can read (it's injected into our HTML).
|
|
14
|
+
*
|
|
15
|
+
* We never emit permissive CORS headers, so cross-origin pages cannot read any
|
|
16
|
+
* response even if a request slips through. Everything stays on the machine.
|
|
17
|
+
*/
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
import type { IncomingMessage } from 'node:http';
|
|
20
|
+
|
|
21
|
+
/** Random token minted once per server start; embedded into served HTML. */
|
|
22
|
+
export const SESSION_TOKEN = crypto.randomBytes(24).toString('hex');
|
|
23
|
+
export const TOKEN_HEADER = 'x-careermate-token';
|
|
24
|
+
|
|
25
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
|
26
|
+
|
|
27
|
+
function hostOnly(value: string | undefined): string | null {
|
|
28
|
+
if (!value) return null;
|
|
29
|
+
// Strip port. Handle IPv6 in brackets.
|
|
30
|
+
if (value.startsWith('[')) {
|
|
31
|
+
const end = value.indexOf(']');
|
|
32
|
+
return end === -1 ? value.toLowerCase() : value.slice(0, end + 1).toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
return value.split(':')[0]!.toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isLoopbackHost(hostHeader: string | undefined): boolean {
|
|
38
|
+
const h = hostOnly(hostHeader);
|
|
39
|
+
return h != null && LOOPBACK_HOSTS.has(h);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function originHost(origin: string | undefined): string | null {
|
|
43
|
+
if (!origin || origin === 'null') return null;
|
|
44
|
+
try {
|
|
45
|
+
return new URL(origin).hostname.toLowerCase();
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SecurityDecision {
|
|
52
|
+
ok: boolean;
|
|
53
|
+
status?: number;
|
|
54
|
+
reason?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Returns whether a request is allowed. `req.method` and headers are inspected;
|
|
61
|
+
* no body is read here (and bodies are never logged anywhere).
|
|
62
|
+
*/
|
|
63
|
+
export function checkRequest(req: IncomingMessage): SecurityDecision {
|
|
64
|
+
// 1. Anti DNS-rebinding: Host must be a loopback name.
|
|
65
|
+
if (!isLoopbackHost(req.headers.host)) {
|
|
66
|
+
return { ok: false, status: 403, reason: 'invalid_host' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 2. Cross-origin guard: if an Origin is present it must be loopback.
|
|
70
|
+
const origin = req.headers.origin;
|
|
71
|
+
if (origin) {
|
|
72
|
+
const oh = originHost(origin);
|
|
73
|
+
if (oh == null || !LOOPBACK_HOSTS.has(oh)) {
|
|
74
|
+
return { ok: false, status: 403, reason: 'cross_origin' };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 3. CSRF token on mutations.
|
|
79
|
+
if (MUTATING.has(req.method ?? '')) {
|
|
80
|
+
const token = req.headers[TOKEN_HEADER];
|
|
81
|
+
const provided = Array.isArray(token) ? token[0] : token;
|
|
82
|
+
if (!provided || !timingSafeEqual(provided, SESSION_TOKEN)) {
|
|
83
|
+
return { ok: false, status: 403, reason: 'invalid_csrf_token' };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { ok: true };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
91
|
+
const ab = Buffer.from(a);
|
|
92
|
+
const bb = Buffer.from(b);
|
|
93
|
+
if (ab.length !== bb.length) return false;
|
|
94
|
+
return crypto.timingSafeEqual(ab, bb);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Inject the session token into an HTML document so same-origin JS can use it. */
|
|
98
|
+
export function injectToken(html: string): string {
|
|
99
|
+
const tag = `<meta name="careermate-token" content="${SESSION_TOKEN}">`;
|
|
100
|
+
if (html.includes('</head>')) return html.replace('</head>', ` ${tag}\n</head>`);
|
|
101
|
+
return tag + html;
|
|
102
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP server: security gate → API router → static dashboard (SPA fallback) and
|
|
3
|
+
* the install page. Bound to loopback only.
|
|
4
|
+
*/
|
|
5
|
+
import http from 'node:http';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { getDb, writeRuntimeInfo, clearRuntimeInfo } from '@careermate/db';
|
|
9
|
+
import { checkRequest, injectToken } from './security.ts';
|
|
10
|
+
import { Router, sendJson, sendDownload, serveStatic, HttpError } from './http.ts';
|
|
11
|
+
import { registerApiRoutes, isDownload } from './routes.ts';
|
|
12
|
+
import { setServerPort } from './info.ts';
|
|
13
|
+
|
|
14
|
+
// 번들 빌드(esbuild)에서 true로 치환된다. tsx 개발 실행에서는 정의되지 않아 typeof로 안전 분기.
|
|
15
|
+
declare const __BUNDLED__: boolean | undefined;
|
|
16
|
+
const BUNDLED = typeof __BUNDLED__ !== 'undefined' && __BUNDLED__;
|
|
17
|
+
|
|
18
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
// dev(tsx): apps/web/src 기준 상대경로 · prod(dist/web.mjs): dist 기준.
|
|
20
|
+
const WEB_ROOT = BUNDLED ? path.resolve(__dirname, 'public') : path.resolve(__dirname, '..', 'public');
|
|
21
|
+
const INSTALL_ROOT = BUNDLED
|
|
22
|
+
? path.resolve(__dirname, 'install-page')
|
|
23
|
+
: path.resolve(__dirname, '..', '..', '..', 'install-page');
|
|
24
|
+
|
|
25
|
+
const router = new Router();
|
|
26
|
+
registerApiRoutes(router);
|
|
27
|
+
|
|
28
|
+
export interface StartedServer {
|
|
29
|
+
server: http.Server;
|
|
30
|
+
port: number;
|
|
31
|
+
url: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createServer(): http.Server {
|
|
35
|
+
return http.createServer(async (req, res) => {
|
|
36
|
+
try {
|
|
37
|
+
// Security gate runs before any work.
|
|
38
|
+
const decision = checkRequest(req);
|
|
39
|
+
if (!decision.ok) {
|
|
40
|
+
sendJson(res, decision.status ?? 403, { error: '접근이 거부되었습니다.', code: decision.reason });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
|
45
|
+
const pathname = url.pathname;
|
|
46
|
+
|
|
47
|
+
// API routes.
|
|
48
|
+
if (pathname.startsWith('/api/')) {
|
|
49
|
+
const matched = router.match(req.method ?? 'GET', pathname);
|
|
50
|
+
if (!matched) {
|
|
51
|
+
sendJson(res, 404, { error: '존재하지 않는 API 경로입니다.', path: pathname });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const result = await matched.handler({
|
|
55
|
+
req,
|
|
56
|
+
res,
|
|
57
|
+
params: matched.params,
|
|
58
|
+
query: url.searchParams,
|
|
59
|
+
url,
|
|
60
|
+
});
|
|
61
|
+
if (res.writableEnded) return;
|
|
62
|
+
if (isDownload(result)) {
|
|
63
|
+
const d = result.__download;
|
|
64
|
+
sendDownload(res, d.filename, d.mimeType, d.content);
|
|
65
|
+
} else {
|
|
66
|
+
sendJson(res, 200, result ?? { ok: true });
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Install page (human + AI onboarding) served under /install.
|
|
72
|
+
// Redirect the bare /install to /install/ so the page's relative asset
|
|
73
|
+
// URLs (style.css, etc.) resolve under /install/ instead of the site root.
|
|
74
|
+
if (pathname === '/install') {
|
|
75
|
+
res.writeHead(308, { Location: `/install/${url.search}` });
|
|
76
|
+
res.end();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (pathname.startsWith('/install/')) {
|
|
80
|
+
const rel = pathname === '/install/' ? 'index.html' : pathname.slice('/install/'.length);
|
|
81
|
+
if (serveStatic(res, INSTALL_ROOT, rel, injectToken)) return;
|
|
82
|
+
if (serveStatic(res, INSTALL_ROOT, 'index.html', injectToken)) return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Dashboard static assets, with SPA fallback to index.html.
|
|
86
|
+
const rel = pathname === '/' ? 'index.html' : pathname.slice(1);
|
|
87
|
+
if (serveStatic(res, WEB_ROOT, rel, injectToken)) return;
|
|
88
|
+
if (serveStatic(res, WEB_ROOT, 'index.html', injectToken)) return;
|
|
89
|
+
|
|
90
|
+
sendJson(res, 404, { error: 'Not found' });
|
|
91
|
+
} catch (err) {
|
|
92
|
+
if (err instanceof HttpError) {
|
|
93
|
+
sendJson(res, err.status, { error: err.message, code: err.code });
|
|
94
|
+
} else {
|
|
95
|
+
// Never leak résumé/cover-letter content into logs or responses.
|
|
96
|
+
const message = err instanceof Error ? err.message : '알 수 없는 오류';
|
|
97
|
+
console.error('[careermate] 요청 처리 중 오류:', message);
|
|
98
|
+
sendJson(res, 500, { error: '서버 오류가 발생했습니다.' });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Start on the desired port, falling back to the next free port if taken.
|
|
106
|
+
* Always binds 127.0.0.1 — never 0.0.0.0 — so the server is unreachable from the
|
|
107
|
+
* network.
|
|
108
|
+
*/
|
|
109
|
+
export function startServer(preferredPort: number): Promise<StartedServer> {
|
|
110
|
+
// Ensure the DB exists/migrated before accepting requests.
|
|
111
|
+
getDb();
|
|
112
|
+
const server = createServer();
|
|
113
|
+
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
let attempt = 0;
|
|
116
|
+
const tryListen = (port: number) => {
|
|
117
|
+
server.once('error', (e: NodeJS.ErrnoException) => {
|
|
118
|
+
if (e.code === 'EADDRINUSE' && attempt < 15) {
|
|
119
|
+
attempt++;
|
|
120
|
+
tryListen(port + 1);
|
|
121
|
+
} else {
|
|
122
|
+
reject(e);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
server.listen(port, '127.0.0.1', () => {
|
|
126
|
+
setServerPort(port);
|
|
127
|
+
const url = `http://127.0.0.1:${port}`;
|
|
128
|
+
// Publish our address so the MCP server (a separate process) can find us.
|
|
129
|
+
writeRuntimeInfo({ url, port, pid: process.pid, started_at: new Date().toISOString() });
|
|
130
|
+
const cleanup = () => {
|
|
131
|
+
clearRuntimeInfo();
|
|
132
|
+
process.exit(0);
|
|
133
|
+
};
|
|
134
|
+
process.on('SIGINT', cleanup);
|
|
135
|
+
process.on('SIGTERM', cleanup);
|
|
136
|
+
resolve({ server, port, url });
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
tryListen(preferredPort);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data management — the user's right to see, export, back up, and delete their
|
|
3
|
+
* own data. All operations are local file operations; nothing leaves the machine.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { getDb, getDbPath, getBackupsDir, getDataDir } from '@careermate/db';
|
|
8
|
+
|
|
9
|
+
const TABLES = [
|
|
10
|
+
'profile',
|
|
11
|
+
'experiences',
|
|
12
|
+
'projects',
|
|
13
|
+
'skills',
|
|
14
|
+
'documents',
|
|
15
|
+
'cover_letters',
|
|
16
|
+
'cover_letter_versions',
|
|
17
|
+
'jobs',
|
|
18
|
+
'fit_analyses',
|
|
19
|
+
'applications',
|
|
20
|
+
'interview_preps',
|
|
21
|
+
'activities',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/** Full machine-readable dump of every table — portable backup / GDPR-style export. */
|
|
25
|
+
export function exportAll(): { exported_at: string; version: number; tables: Record<string, unknown[]> } {
|
|
26
|
+
const db = getDb();
|
|
27
|
+
const tables: Record<string, unknown[]> = {};
|
|
28
|
+
for (const t of TABLES) {
|
|
29
|
+
tables[t] = db.prepare(`SELECT * FROM ${t}`).all();
|
|
30
|
+
}
|
|
31
|
+
const ver = db.prepare(`SELECT value FROM _meta WHERE key='schema_version'`).get() as
|
|
32
|
+
| { value: string }
|
|
33
|
+
| undefined;
|
|
34
|
+
return { exported_at: new Date().toISOString(), version: Number(ver?.value ?? 0), tables };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Copy the live SQLite file + a JSON dump into the backups directory. */
|
|
38
|
+
export function createBackup(): { backup_path: string; json_path: string } {
|
|
39
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
40
|
+
const dir = getBackupsDir();
|
|
41
|
+
const dbBackup = path.join(dir, `careermate-${stamp}.sqlite`);
|
|
42
|
+
const jsonBackup = path.join(dir, `careermate-${stamp}.json`);
|
|
43
|
+
// Use SQLite's online backup via VACUUM INTO for a consistent snapshot.
|
|
44
|
+
try {
|
|
45
|
+
getDb().exec(`VACUUM INTO '${dbBackup.replace(/'/g, "''")}'`);
|
|
46
|
+
} catch {
|
|
47
|
+
fs.copyFileSync(getDbPath(), dbBackup);
|
|
48
|
+
}
|
|
49
|
+
fs.writeFileSync(jsonBackup, JSON.stringify(exportAll(), null, 2), 'utf8');
|
|
50
|
+
return { backup_path: dbBackup, json_path: jsonBackup };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Wipe all user data. Requires an explicit confirmation string to avoid mistakes. */
|
|
54
|
+
export function resetAll(confirm: string): { ok: boolean; backup_path?: string } {
|
|
55
|
+
if (confirm !== 'DELETE') {
|
|
56
|
+
return { ok: false };
|
|
57
|
+
}
|
|
58
|
+
// Safety: always back up before destroying.
|
|
59
|
+
const { backup_path } = createBackup();
|
|
60
|
+
const db = getDb();
|
|
61
|
+
db.exec('BEGIN');
|
|
62
|
+
try {
|
|
63
|
+
for (const t of TABLES) db.exec(`DELETE FROM ${t}`);
|
|
64
|
+
db.exec('COMMIT');
|
|
65
|
+
} catch (e) {
|
|
66
|
+
db.exec('ROLLBACK');
|
|
67
|
+
throw e;
|
|
68
|
+
}
|
|
69
|
+
return { ok: true, backup_path };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function listBackups(): { filename: string; path: string; size: number; created_at: string }[] {
|
|
73
|
+
const dir = getBackupsDir();
|
|
74
|
+
if (!fs.existsSync(dir)) return [];
|
|
75
|
+
return fs
|
|
76
|
+
.readdirSync(dir)
|
|
77
|
+
.filter((f) => f.endsWith('.sqlite') || f.endsWith('.json'))
|
|
78
|
+
.map((f) => {
|
|
79
|
+
const p = path.join(dir, f);
|
|
80
|
+
const st = fs.statSync(p);
|
|
81
|
+
return { filename: f, path: p, size: st.size, created_at: st.mtime.toISOString() };
|
|
82
|
+
})
|
|
83
|
+
.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getDataLocation(): { data_dir: string; db_path: string } {
|
|
87
|
+
return { data_dir: getDataDir(), db_path: getDbPath() };
|
|
88
|
+
}
|