diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index e5b348c..35a4f9f 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,17 +1,35 @@ -import express from 'express'; -import cors from 'cors'; -import helmet from 'helmet'; -import morgan from 'morgan'; +/** + * apps/api — ApplyPilot Gateway (Engineering Marble). + * + * Composition: + * request-id → mono-logger → security → cors → limiter + * → routes (/v1/*, /api/* alias) + * → error envelope + * + * Design notes: + * • Every response is wrapped in the canonical Envelope (lib/envelope.ts). + * • `/healthz`, `/readyz`, `/v1/meta` form the operational triad. + * • `/` is a plain-text curl-friendly spec sheet of the surface. + */ + import compression from 'compression'; +import cors from 'cors'; import dotenv from 'dotenv'; +import express, { type Request, type Response } from 'express'; +import rateLimit from 'express-rate-limit'; +import helmet from 'helmet'; import { createServer } from 'http'; import { WebSocketServer } from 'ws'; -import rateLimit from 'express-rate-limit'; +import { ok } from './lib/envelope.js'; +import { banner, endpointLine, m, rule } from './lib/marble.js'; +import { monoLogger } from './lib/mono-logger.js'; +import { requestId } from './lib/request-id.js'; import { errorHandler } from './middleware/errorHandler.js'; +import { analyticsRouter } from './routes/analytics.js'; import { authRouter } from './routes/auth.js'; import { jobsRouter } from './routes/jobs.js'; -import { analyticsRouter } from './routes/analytics.js'; +import { metaRouter } from './routes/meta.js'; import { settingsRouter } from './routes/settings.js'; import { tailorRouter } from './routes/tailor.js'; import { websocketHandler } from './websocket/handler.js'; @@ -22,67 +40,148 @@ const app = express(); const server = createServer(app); const wss = new WebSocketServer({ server, path: '/ws' }); -const PORT = process.env.PORT || 8080; +const PORT = Number(process.env.PORT) || 8080; +const VERSION = process.env.API_VERSION || '0.1.0'; +const STARTED_AT = new Date(); + +app.disable('x-powered-by'); + +// ── Request pipeline ─────────────────────────────────────────────────── +app.use(requestId); +app.use(monoLogger); -// Security middleware -app.use(helmet({ - contentSecurityPolicy: { - directives: { - defaultSrc: ["'self'"], - connectSrc: ["'self'", "ws:", "wss:"], +app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + connectSrc: ["'self'", 'ws:', 'wss:'], + }, }, - }, -})); - -app.use(cors({ - origin: process.env.FRONTEND_URL || 'http://localhost:3000', - credentials: true, -})); - -// Rate limiting -const limiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 100, // limit each IP to 100 requests per windowMs - message: 'Too many requests from this IP, please try again later.', -}); -app.use(limiter); + }) +); + +app.use( + cors({ + origin: process.env.FRONTEND_URL || 'http://localhost:3000', + credentials: true, + }) +); + +app.use( + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 100, + standardHeaders: true, + legacyHeaders: false, + message: { ok: false, error: { code: 'RATE_LIMITED', message: 'Too many requests' } }, + }) +); -// Body parsing app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true, limit: '10mb' })); +app.use(compression()); -// Logging -app.use(morgan('combined')); +// ── Triad: healthz · readyz · meta ───────────────────────────────────── +app.get('/healthz', (req: Request, res: Response) => { + ok(req, res, { status: 'live', uptime: Math.floor(process.uptime()) }); +}); -// Compression -app.use(compression()); +app.get('/readyz', (req: Request, res: Response) => { + // Deliberately cheap: no DB ping here. Dependency-aware readiness + // lives at /v1/meta/ready so consumers can opt-in explicitly. + ok(req, res, { status: 'ready' }); +}); -// Health check -app.get('/health', (req, res) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); +// Back-compat aliases (pre-v1) +app.get('/health', (req, res) => ok(req, res, { status: 'ok' })); + +// ── Curl-friendly index ──────────────────────────────────────────────── +app.get('/', (_req: Request, res: Response) => { + res.type('text/plain; charset=utf-8').send( + [ + banner('APPLYPILOT / GATEWAY', [ + { key: 'version', value: VERSION }, + { key: 'node', value: process.version }, + { key: 'mode', value: process.env.NODE_ENV || 'development' }, + { key: 'started', value: STARTED_AT.toISOString() }, + ]), + '', + rule('// ENDPOINTS'), + endpointLine('GET', '/healthz', 'liveness probe'), + endpointLine('GET', '/readyz', 'readiness probe'), + endpointLine('GET', '/v1/meta', 'build + runtime metadata'), + endpointLine('GET', '/v1/jobs', 'list job applications'), + endpointLine('POST', '/v1/jobs', 'create job from JD text'), + endpointLine('GET', '/v1/jobs/:id', 'fetch a single job'), + endpointLine('POST', '/v1/jobs/:id/analyze', 'run JD analysis'), + endpointLine('GET', '/v1/analytics/dashboard', 'pipeline metrics'), + endpointLine('GET', '/v1/analytics/funnel', 'stage funnel'), + endpointLine('GET', '/v1/analytics/costs', 'LLM cost summary'), + endpointLine('GET', '/v1/settings', 'user / llm config'), + endpointLine('POST', '/v1/tailor/resume', 'tailor resume for a job'), + endpointLine('WS ', '/ws', 'progress + notifications'), + '', + rule('// RESPONSE ENVELOPE'), + m.graphite(' ok ') + + m.muted('boolean — true on success, false on failure'), + m.graphite(' data ') + m.muted(' — present when ok: true'), + m.graphite(' error ') + + m.muted('{code,message,details?} — present when ok: false'), + m.graphite(' meta ') + + m.muted('{ requestId, timestamp } — always present'), + '', + m.vein('── ') + m.muted('docs: README.md · source: https://github.com/lavkushry/ApplyBot'), + '', + ].join('\n') + ); }); -// API routes -app.use('/api/auth', authRouter); -app.use('/api/jobs', jobsRouter); -app.use('/api/analytics', analyticsRouter); -app.use('/api/settings', settingsRouter); -app.use('/api/tailor', tailorRouter); +// ── Routes ───────────────────────────────────────────────────────────── +const mount = (prefix: string): void => { + app.use(`${prefix}/meta`, metaRouter); + app.use(`${prefix}/auth`, authRouter); + app.use(`${prefix}/jobs`, jobsRouter); + app.use(`${prefix}/analytics`, analyticsRouter); + app.use(`${prefix}/settings`, settingsRouter); + app.use(`${prefix}/tailor`, tailorRouter); +}; -// WebSocket handling -websocketHandler(wss); +mount('/v1'); +mount('/api'); // legacy alias for the current web client -// Error handling -app.use(errorHandler); +// ── WebSocket ────────────────────────────────────────────────────────── +websocketHandler(wss); -// 404 handler +// ── 404 + error envelope ─────────────────────────────────────────────── app.use((req, res) => { - res.status(404).json({ error: 'Not found' }); + res.status(404).json({ + ok: false, + error: { code: 'NOT_FOUND', message: `No route for ${req.method} ${req.path}` }, + meta: { + requestId: (req as Request & { id?: string }).id ?? 'unknown', + timestamp: new Date().toISOString(), + }, + }); }); +app.use(errorHandler); + +// ── Listen + marble boot banner ──────────────────────────────────────── server.listen(PORT, () => { - console.log(`🚀 API server running on port ${PORT}`); - console.log(`📡 WebSocket server ready on ws://localhost:${PORT}/ws`); + process.stdout.write( + '\n' + + banner('APPLYPILOT / GATEWAY', [ + { key: 'version', value: VERSION, tone: 'signal' }, + { key: 'port', value: String(PORT) }, + { key: 'ws', value: `ws://0.0.0.0:${PORT}/ws` }, + { key: 'node', value: process.version }, + { key: 'mode', value: process.env.NODE_ENV || 'development' }, + ]) + + '\n' + + rule('// LIVE') + + '\n' + ); }); export { app, server, wss }; diff --git a/apps/api/src/lib/envelope.ts b/apps/api/src/lib/envelope.ts new file mode 100644 index 0000000..6d220fa --- /dev/null +++ b/apps/api/src/lib/envelope.ts @@ -0,0 +1,66 @@ +/** + * envelope.ts — every response passes through here. + * + * Canonical shape, borrowed from engineered RPC conventions: + * + * success → { ok: true, data: , meta?: { requestId, timestamp } } + * failure → { ok: false, error: { code, message, details? }, meta: { requestId, timestamp } } + * + * Helpers below also set the correct status code so routes read as + * single-line intents: `return ok(res, { job })` / `return fail(res, 404, 'NOT_FOUND', 'Job not found')`. + */ + +import type { Request, Response } from 'express'; + +export interface EnvelopeMeta { + requestId: string; + timestamp: string; +} + +export interface SuccessEnvelope { + ok: true; + data: T; + meta: EnvelopeMeta; +} + +export interface FailureEnvelope { + ok: false; + error: { + code: string; + message: string; + details?: unknown; + }; + meta: EnvelopeMeta; +} + +export type Envelope = SuccessEnvelope | FailureEnvelope; + +const meta = (req: Request): EnvelopeMeta => ({ + requestId: (req as Request & { id?: string }).id ?? 'unknown', + timestamp: new Date().toISOString(), +}); + +export function ok( + req: Request, + res: Response, + data: T, + status = 200 +): Response> { + return res.status(status).json({ ok: true, data, meta: meta(req) }); +} + +export function fail( + req: Request, + res: Response, + status: number, + code: string, + message: string, + details?: unknown +): Response { + const payload: FailureEnvelope = { + ok: false, + error: { code, message, ...(details !== undefined ? { details } : {}) }, + meta: meta(req), + }; + return res.status(status).json(payload); +} diff --git a/apps/api/src/lib/marble.ts b/apps/api/src/lib/marble.ts new file mode 100644 index 0000000..b83161b --- /dev/null +++ b/apps/api/src/lib/marble.ts @@ -0,0 +1,137 @@ +/** + * marble.ts — engineering-marble seam. + * + * Single source of truth for the API's ANSI palette and composition + * primitives. Mirrors the web app's design tokens (index.css) and the + * CLI's palette (apps/cli/src/lib/palette.ts). Do not import chalk + * elsewhere; compose through this module. + */ + +const CSI = '\x1b['; + +const RESET = `${CSI}0m`; +const DIM = `${CSI}2m`; +const BOLD = `${CSI}1m`; +const UNDERLINE = `${CSI}4m`; + +// 256-colour graphite ramp + single rust signal. Keeps the output +// monochromatic until something genuinely needs to be noticed. +const FG = { + ivory: `${CSI}38;5;230m`, + bone: `${CSI}38;5;223m`, + graphite: `${CSI}38;5;250m`, + muted: `${CSI}38;5;244m`, + vein: `${CSI}38;5;240m`, + nero: `${CSI}38;5;236m`, + signal: `${CSI}38;5;166m`, + signalDim: `${CSI}38;5;130m`, + ok: `${CSI}38;5;108m`, + warn: `${CSI}38;5;179m`, + err: `${CSI}38;5;167m`, +} as const; + +type Tone = keyof typeof FG; + +const tty = (): boolean => { + if (process.env.NO_COLOR) return false; + if (process.env.FORCE_COLOR) return true; + return Boolean(process.stdout.isTTY); +}; + +const paint = (tone: Tone, s: string): string => + tty() ? `${FG[tone]}${s}${RESET}` : s; + +export const m = { + ivory: (s: string) => paint('ivory', s), + bone: (s: string) => paint('bone', s), + graphite: (s: string) => paint('graphite', s), + muted: (s: string) => paint('muted', s), + vein: (s: string) => paint('vein', s), + signal: (s: string) => paint('signal', s), + signalDim: (s: string) => paint('signalDim', s), + ok: (s: string) => paint('ok', s), + warn: (s: string) => paint('warn', s), + err: (s: string) => paint('err', s), + dim: (s: string) => (tty() ? `${DIM}${s}${RESET}` : s), + bold: (s: string) => (tty() ? `${BOLD}${s}${RESET}` : s), + under: (s: string) => (tty() ? `${UNDERLINE}${s}${RESET}` : s), +}; + +export const rule = (label: string, width = 72): string => { + const tag = label ? ` ${label} ` : ''; + const remaining = Math.max(0, width - tag.length - 2); + return m.vein(`──${m.graphite(tag)}${'─'.repeat(remaining)}`); +}; + +export interface BannerField { + key: string; + value: string; + tone?: 'graphite' | 'signal' | 'ok' | 'warn'; +} + +export const banner = (title: string, fields: BannerField[]): string => { + const width = 72; + const topLeft = '┌'; + const topRight = '┐'; + const botLeft = '└'; + const botRight = '┘'; + const sideH = '─'; + const sideV = '│'; + + const pad = (s: string, n: number) => + s.length >= n ? s : s + ' '.repeat(n - s.length); + + const lines: string[] = []; + lines.push( + m.vein( + `${topLeft}${sideH.repeat(width - 2)}${topRight}` + ) + ); + + const titleLine = ` ${m.bold(m.ivory(title))}`; + const titleVisible = ` ${title}`; + lines.push( + m.vein(sideV) + + titleLine + + ' '.repeat(Math.max(0, width - titleVisible.length - 2)) + + m.vein(sideV) + ); + + if (fields.length) { + lines.push(m.vein(`${sideV}${' '.repeat(width - 2)}${sideV}`)); + for (const f of fields) { + const key = pad(f.key.toUpperCase(), 10); + const tone = f.tone ?? 'graphite'; + const tonedVal = + tone === 'signal' + ? m.signal(f.value) + : tone === 'ok' + ? m.ok(f.value) + : tone === 'warn' + ? m.warn(f.value) + : m.ivory(f.value); + const rawBody = ` ${key} │ ${f.value}`; + const body = ` ${m.muted(key)} ${m.vein('│')} ${tonedVal}`; + lines.push( + m.vein(sideV) + + body + + ' '.repeat(Math.max(0, width - rawBody.length - 2)) + + m.vein(sideV) + ); + } + } + + lines.push( + m.vein(`${botLeft}${sideH.repeat(width - 2)}${botRight}`) + ); + return lines.join('\n'); +}; + +export const endpointLine = ( + method: string, + path: string, + summary: string +): string => { + const methodPad = method.toUpperCase().padEnd(6); + return ` ${m.muted(methodPad)} ${m.ivory(path.padEnd(28))} ${m.graphite(summary)}`; +}; diff --git a/apps/api/src/lib/mono-logger.ts b/apps/api/src/lib/mono-logger.ts new file mode 100644 index 0000000..afb5587 --- /dev/null +++ b/apps/api/src/lib/mono-logger.ts @@ -0,0 +1,50 @@ +/** + * mono-logger.ts — single-line monospaced HTTP log formatter. + * + * HH:MM:SS MET STATUS ms path request-id + * + * Replaces morgan's wall-of-text format. Aligns fields so successive + * requests visually register as a stack of spec sheets rather than + * stream-of-consciousness output. + */ + +import type { NextFunction, Request, Response } from 'express'; +import { m } from './marble.js'; +import type { RequestWithId } from './request-id.js'; + +const pad = (s: string, n: number): string => + s.length >= n ? s : s + ' '.repeat(n - s.length); + +const padL = (s: string, n: number): string => + s.length >= n ? s : ' '.repeat(n - s.length) + s; + +const clock = (d: Date): string => + d.toISOString().slice(11, 19); // HH:MM:SS + +const toneStatus = (status: number, body: string): string => { + if (status >= 500) return m.err(body); + if (status >= 400) return m.warn(body); + if (status >= 300) return m.muted(body); + return m.ok(body); +}; + +export function monoLogger(req: Request, res: Response, next: NextFunction): void { + const started = Date.now(); + res.on('finish', () => { + const ms = Date.now() - started; + const method = pad(req.method, 4); + const status = res.statusCode.toString(); + const path = req.originalUrl || req.url; + const id = (req as RequestWithId).id ?? '-'; + const line = [ + m.muted(clock(new Date())), + m.graphite(method), + toneStatus(res.statusCode, pad(status, 3)), + m.graphite(padL(`${ms}ms`, 6)), + m.ivory(pad(path, 32)), + m.muted(id), + ].join(' '); + process.stdout.write(line + '\n'); + }); + next(); +} diff --git a/apps/api/src/lib/request-id.ts b/apps/api/src/lib/request-id.ts new file mode 100644 index 0000000..b2630f1 --- /dev/null +++ b/apps/api/src/lib/request-id.ts @@ -0,0 +1,27 @@ +/** + * request-id.ts — attach an opaque request id to every Request. + * + * Honours an upstream `X-Request-Id` if supplied (useful when fronted by + * a load balancer or gateway); otherwise generates one per request. + * Always echoes the id back via the `X-Request-Id` response header so + * clients can correlate logs. + */ + +import { randomUUID } from 'crypto'; +import type { NextFunction, Request, Response } from 'express'; + +export interface RequestWithId extends Request { + id: string; +} + +const HEADER = 'x-request-id'; + +export function requestId(req: Request, res: Response, next: NextFunction): void { + const incoming = req.headers[HEADER]; + const id = typeof incoming === 'string' && incoming.length + ? incoming + : `r_${randomUUID().replace(/-/g, '').slice(0, 16)}`; + (req as RequestWithId).id = id; + res.setHeader('X-Request-Id', id); + next(); +} diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 8a88fa4..5748d11 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -19,7 +19,11 @@ export function authenticateToken( const token = authHeader && authHeader.split(' ')[1]; if (!token) { - res.status(401).json({ error: { code: 'UNAUTHORIZED', message: 'Access token required' } }); + res.status(401).json({ + ok: false, + error: { code: 'UNAUTHORIZED', message: 'Access token required' }, + meta: { requestId: (req as AuthRequest & { id?: string }).id ?? 'unknown', timestamp: new Date().toISOString() }, + }); return; } @@ -27,8 +31,12 @@ export function authenticateToken( const decoded = jwt.verify(token, JWT_SECRET) as { id: string; email: string }; req.user = decoded; next(); - } catch (error) { - res.status(403).json({ error: { code: 'INVALID_TOKEN', message: 'Invalid or expired token' } }); + } catch { + res.status(403).json({ + ok: false, + error: { code: 'INVALID_TOKEN', message: 'Invalid or expired token' }, + meta: { requestId: (req as AuthRequest & { id?: string }).id ?? 'unknown', timestamp: new Date().toISOString() }, + }); } } diff --git a/apps/api/src/middleware/errorHandler.ts b/apps/api/src/middleware/errorHandler.ts index d65b0e1..bce029e 100644 --- a/apps/api/src/middleware/errorHandler.ts +++ b/apps/api/src/middleware/errorHandler.ts @@ -1,32 +1,58 @@ -import type { Request, Response, NextFunction } from 'express'; +import type { NextFunction, Request, Response } from 'express'; +import { ZodError } from 'zod'; +import { fail } from '../lib/envelope.js'; +import { m } from '../lib/marble.js'; +import type { RequestWithId } from '../lib/request-id.js'; export interface ApiError extends Error { statusCode?: number; code?: string; + details?: unknown; } export function errorHandler( - err: ApiError, + err: ApiError | ZodError, req: Request, res: Response, - next: NextFunction + _next: NextFunction ): void { - console.error('Error:', err); + const id = (req as RequestWithId).id ?? '-'; - const statusCode = err.statusCode || 500; - const message = err.message || 'Internal server error'; - const code = err.code || 'INTERNAL_ERROR'; + if (err instanceof ZodError) { + process.stderr.write( + `${m.warn('ERR')} ${m.muted(id)} validation_failed ${err.issues.length} issue(s)\n` + ); + fail(req, res, 400, 'VALIDATION_FAILED', 'Request payload failed validation', { + issues: err.issues, + }); + return; + } - res.status(statusCode).json({ - error: { - code, - message, - ...(process.env.NODE_ENV === 'development' && { stack: err.stack }), - }, - }); + const apiErr = err as ApiError; + const statusCode = apiErr.statusCode ?? 500; + const code = apiErr.code ?? (statusCode >= 500 ? 'INTERNAL_ERROR' : 'REQUEST_FAILED'); + const message = apiErr.message || 'Internal server error'; + + process.stderr.write( + `${m.err('ERR')} ${m.muted(id)} ${code} ${message}\n` + ); + if (process.env.NODE_ENV === 'development' && apiErr.stack) { + process.stderr.write(m.vein(apiErr.stack) + '\n'); + } + + fail( + req, + res, + statusCode, + code, + message, + process.env.NODE_ENV === 'development' ? { stack: apiErr.stack } : undefined + ); } -export function asyncHandler(fn: (req: Request, res: Response, next: NextFunction) => Promise) { +export function asyncHandler( + fn: (req: Request, res: Response, next: NextFunction) => Promise +) { return (req: Request, res: Response, next: NextFunction) => { Promise.resolve(fn(req, res, next)).catch(next); }; diff --git a/apps/api/src/routes/analytics.ts b/apps/api/src/routes/analytics.ts index 4477845..08e618e 100644 --- a/apps/api/src/routes/analytics.ts +++ b/apps/api/src/routes/analytics.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import { getCostTracker } from '@applypilot/core'; import { DatabaseManager, ApplicationRepository } from '@applypilot/tracker'; +import { ok } from '../lib/envelope.js'; import { asyncHandler } from '../middleware/errorHandler.js'; const router = Router(); @@ -8,64 +9,68 @@ const router = Router(); const db = DatabaseManager.getInstance(); const appRepo = new ApplicationRepository(db.getDatabase()); -// Get dashboard stats -router.get('/dashboard', asyncHandler(async (req, res) => { - const stats = appRepo.getStats(); - - // Get cost data - const costTracker = getCostTracker(); - const costSummary = costTracker.getSummary(); - - res.json({ - applications: stats, - costs: { - total: costSummary.totalCost, - thisMonth: costTracker.getCurrentMonthCost(), - }, - }); -})); +router.get( + '/dashboard', + asyncHandler(async (req, res) => { + const stats = appRepo.getStats(); + const costTracker = getCostTracker(); + const costSummary = costTracker.getSummary(); -// Get application funnel -router.get('/funnel', asyncHandler(async (req, res) => { - const stats = appRepo.getStats(); - - const funnel = [ - { stage: 'Submitted', count: stats.submitted + stats.interview + stats.offer + stats.rejected }, - { stage: 'Phone Screen', count: stats.interview + stats.offer }, - { stage: 'Interview', count: stats.interview + stats.offer }, - { stage: 'Offer', count: stats.offer }, - ]; - - res.json({ funnel }); -})); + ok(req, res, { + applications: stats, + costs: { + total: costSummary.totalCost, + thisMonth: costTracker.getCurrentMonthCost(), + }, + }); + }) +); -// Get cost analytics -router.get('/costs', asyncHandler(async (req, res) => { - const costTracker = getCostTracker(); - const summary = costTracker.getSummary(); - - res.json({ - summary, - byProvider: summary.byProvider, - byModel: summary.byModel, - byDay: summary.byDay, - }); -})); +router.get( + '/funnel', + asyncHandler(async (req, res) => { + const stats = appRepo.getStats(); + const funnel = [ + { + stage: 'Submitted', + count: stats.submitted + stats.interview + stats.offer + stats.rejected, + }, + { stage: 'Phone Screen', count: stats.interview + stats.offer }, + { stage: 'Interview', count: stats.interview + stats.offer }, + { stage: 'Offer', count: stats.offer }, + ]; + ok(req, res, { funnel }); + }) +); -// Get success metrics -router.get('/success', asyncHandler(async (req, res) => { - const stats = appRepo.getStats(); - const total = stats.total; - - const metrics = { - totalApplications: total, - offers: stats.offer, - interviews: stats.interview, - successRate: total > 0 ? Math.round((stats.offer / total) * 100) : 0, - interviewRate: total > 0 ? Math.round((stats.interview / total) * 100) : 0, - }; - - res.json({ metrics }); -})); +router.get( + '/costs', + asyncHandler(async (req, res) => { + const costTracker = getCostTracker(); + const summary = costTracker.getSummary(); + ok(req, res, { + summary, + byProvider: summary.byProvider, + byModel: summary.byModel, + byDay: summary.byDay, + }); + }) +); + +router.get( + '/success', + asyncHandler(async (req, res) => { + const stats = appRepo.getStats(); + const total = stats.total; + const metrics = { + totalApplications: total, + offers: stats.offer, + interviews: stats.interview, + successRate: total > 0 ? Math.round((stats.offer / total) * 100) : 0, + interviewRate: total > 0 ? Math.round((stats.interview / total) * 100) : 0, + }; + ok(req, res, { metrics }); + }) +); export { router as analyticsRouter }; diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 5843e50..59c97c3 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import bcrypt from 'bcryptjs'; import { z } from 'zod'; import { generateToken } from '../middleware/auth.js'; +import { fail, ok } from '../lib/envelope.js'; import { asyncHandler } from '../middleware/errorHandler.js'; const router = Router(); @@ -20,70 +21,52 @@ const registerSchema = z.object({ name: z.string().min(2), }); -// Register -router.post('/register', asyncHandler(async (req, res) => { - const { email, password, name } = registerSchema.parse(req.body); - - // Check if user exists - const existingUser = users.find(u => u.email === email); - if (existingUser) { - res.status(400).json({ error: { code: 'USER_EXISTS', message: 'User already exists' } }); - return; - } - - // Hash password - const hashedPassword = await bcrypt.hash(password, 10); - - // Create user - const user = { - id: `user_${Date.now()}`, - email, - password: hashedPassword, - name, - }; - - users.push(user); - - // Generate token - const token = generateToken(user.id, user.email); - - res.status(201).json({ - user: { id: user.id, email: user.email, name }, - token, - }); -})); - -// Login -router.post('/login', asyncHandler(async (req, res) => { - const { email, password } = loginSchema.parse(req.body); - - // Find user - const user = users.find(u => u.email === email); - if (!user) { - res.status(401).json({ error: { code: 'INVALID_CREDENTIALS', message: 'Invalid email or password' } }); - return; - } - - // Verify password - const isValid = await bcrypt.compare(password, user.password); - if (!isValid) { - res.status(401).json({ error: { code: 'INVALID_CREDENTIALS', message: 'Invalid email or password' } }); - return; - } - - // Generate token - const token = generateToken(user.id, user.email); - - res.json({ - user: { id: user.id, email: user.email }, - token, - }); -})); - -// Get current user -router.get('/me', asyncHandler(async (req, res) => { - // This would use the authenticateToken middleware in production - res.json({ user: null }); -})); +router.post( + '/register', + asyncHandler(async (req, res) => { + const { email, password, name } = registerSchema.parse(req.body); + + const existingUser = users.find((u) => u.email === email); + if (existingUser) return void fail(req, res, 400, 'USER_EXISTS', 'User already exists'); + + const hashedPassword = await bcrypt.hash(password, 10); + + const user = { + id: `user_${Date.now()}`, + email, + password: hashedPassword, + name, + }; + users.push(user); + + const token = generateToken(user.id, user.email); + ok(req, res, { user: { id: user.id, email: user.email, name }, token }, 201); + }) +); + +router.post( + '/login', + asyncHandler(async (req, res) => { + const { email, password } = loginSchema.parse(req.body); + + const user = users.find((u) => u.email === email); + if (!user) + return void fail(req, res, 401, 'INVALID_CREDENTIALS', 'Invalid email or password'); + + const isValid = await bcrypt.compare(password, user.password); + if (!isValid) + return void fail(req, res, 401, 'INVALID_CREDENTIALS', 'Invalid email or password'); + + const token = generateToken(user.id, user.email); + ok(req, res, { user: { id: user.id, email: user.email }, token }); + }) +); + +router.get( + '/me', + asyncHandler(async (req, res) => { + ok(req, res, { user: null }); + }) +); export { router as authRouter }; diff --git a/apps/api/src/routes/jobs.ts b/apps/api/src/routes/jobs.ts index 991cd5c..7de825d 100644 --- a/apps/api/src/routes/jobs.ts +++ b/apps/api/src/routes/jobs.ts @@ -8,11 +8,11 @@ import { } from '@applypilot/tracker'; import { Router } from 'express'; import { z } from 'zod'; +import { fail, ok } from '../lib/envelope.js'; import { asyncHandler } from '../middleware/errorHandler.js'; const router = Router(); -// Initialize database const db = DatabaseManager.getInstance(); const jobRepo = new JobRepository(db.getDatabase()); const appRepo = new ApplicationRepository(db.getDatabase()); @@ -30,29 +30,34 @@ const updateStatusSchema = z.object({ notes: z.string().optional(), }); -// Get all jobs +const appStatus = (s: string) => + s as + | 'drafted' + | 'ready' + | 'submitted' + | 'interview' + | 'rejected' + | 'offer' + | 'no_reply' + | 'withdrawn'; + router.get( '/', - asyncHandler(async (_req, res) => { + asyncHandler(async (req, res) => { const jobs = jobRepo.findAll(); - res.json({ jobs }); + ok(req, res, { jobs }); }) ); -// Get job by ID router.get( '/:id', asyncHandler(async (req, res) => { const job = jobRepo.findById(req.params.id); - if (!job) { - res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Job not found' } }); - return; - } - res.json({ job }); + if (!job) return void fail(req, res, 404, 'NOT_FOUND', 'Job not found'); + ok(req, res, { job }); }) ); -// Create new job router.post( '/', asyncHandler(async (req, res) => { @@ -71,38 +76,27 @@ router.post( status: 'new', }); - res.status(201).json({ job }); + ok(req, res, { job }, 201); }) ); -// Analyze job router.post( '/:id/analyze', asyncHandler(async (req, res) => { const job = jobRepo.findById(req.params.id); - if (!job) { - res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Job not found' } }); - return; - } - - // Load profile - const config = ConfigManager.getInstance(); - // In production, load from user's profile - const profile = { - /* user profile */ - }; + if (!job) return void fail(req, res, 404, 'NOT_FOUND', 'Job not found'); + ConfigManager.getInstance(); const analyzer = new LLMJDAnalyzer(); const analysis = await analyzer.quickAnalyze(job.jdText, job.id); - // Update job with analysis jobRepo.update(job.id, { requirementsJson: analysis.requirements as unknown as Record, - fitScore: 0, // Would calculate from full analysis + fitScore: 0, status: 'analyzed', }); - res.json({ + ok(req, res, { job: jobRepo.findById(job.id), analysis: analysis.requirements, cost: analysis.cost, @@ -110,33 +104,20 @@ router.post( }) ); -// Update job status router.patch( '/:id/status', asyncHandler(async (req, res) => { const { status, notes } = updateStatusSchema.parse(req.body); const job = jobRepo.findById(req.params.id); - if (!job) { - res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Job not found' } }); - return; - } + if (!job) return void fail(req, res, 404, 'NOT_FOUND', 'Job not found'); jobRepo.update(job.id, { status }); - // Create or update application record const existingApp = appRepo.findByJobId(job.id); if (existingApp) { appRepo.update(existingApp.id, { - status: status as - | 'drafted' - | 'ready' - | 'submitted' - | 'interview' - | 'rejected' - | 'offer' - | 'no_reply' - | 'withdrawn', + status: appStatus(status), notes: notes || null, }); } else { @@ -144,35 +125,22 @@ router.patch( id: `app_${Date.now()}`, jobId: job.id, resumeBuildId: null, - status: status as - | 'drafted' - | 'ready' - | 'submitted' - | 'interview' - | 'rejected' - | 'offer' - | 'no_reply' - | 'withdrawn', + status: appStatus(status), appliedAt: null, notes: notes || null, followUpDate: null, }); } - res.json({ job: jobRepo.findById(job.id) }); + ok(req, res, { job: jobRepo.findById(job.id) }); }) ); -// Delete job router.delete( '/:id', asyncHandler(async (req, res) => { const job = jobRepo.findById(req.params.id); - if (!job) { - res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Job not found' } }); - return; - } - + if (!job) return void fail(req, res, 404, 'NOT_FOUND', 'Job not found'); jobRepo.delete(job.id); res.status(204).send(); }) diff --git a/apps/api/src/routes/meta.ts b/apps/api/src/routes/meta.ts new file mode 100644 index 0000000..dfd6be7 --- /dev/null +++ b/apps/api/src/routes/meta.ts @@ -0,0 +1,52 @@ +/** + * routes/meta.ts — runtime readout. + * + * GET /v1/meta → version, uptime, node, commit + * GET /v1/meta/ready → dependency-aware readiness (DB, LLM) + */ + +import { Router } from 'express'; +import { ok } from '../lib/envelope.js'; +import { asyncHandler } from '../middleware/errorHandler.js'; + +const router = Router(); +const STARTED_AT = Date.now(); + +router.get('/', (req, res) => { + ok(req, res, { + service: 'applypilot-api', + version: process.env.API_VERSION || '0.1.0', + commit: process.env.GIT_COMMIT || null, + node: process.version, + mode: process.env.NODE_ENV || 'development', + startedAt: new Date(STARTED_AT).toISOString(), + uptimeSeconds: Math.floor((Date.now() - STARTED_AT) / 1000), + }); +}); + +router.get( + '/ready', + asyncHandler(async (req, res) => { + // Lightweight dependency probe. Expand as the platform grows. + const checks: Array<{ name: string; status: 'ok' | 'degraded' | 'down'; detail?: string }> = []; + + // Database — present if the tracker module wired up. + try { + const { DatabaseManager } = await import('@applypilot/tracker'); + const db = DatabaseManager.getInstance().getDatabase(); + db.prepare('SELECT 1').get(); + checks.push({ name: 'database', status: 'ok' }); + } catch (e) { + checks.push({ + name: 'database', + status: 'down', + detail: e instanceof Error ? e.message : String(e), + }); + } + + const overall = checks.every((c) => c.status === 'ok') ? 'ready' : 'degraded'; + ok(req, res, { status: overall, checks }); + }) +); + +export { router as metaRouter }; diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index 7026176..108921c 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -1,22 +1,23 @@ import { Router } from 'express'; import { z } from 'zod'; import { ConfigManager, LLMFactory } from '@applypilot/core'; +import { ok } from '../lib/envelope.js'; import { asyncHandler } from '../middleware/errorHandler.js'; const router = Router(); -// Get current settings -router.get('/', asyncHandler(async (req, res) => { - const config = ConfigManager.getInstance(); - - res.json({ - llm: config.getLLMConfig(), - tailoring: config.getTailoringConfig(), - paths: config.getPathsConfig(), - }); -})); +router.get( + '/', + asyncHandler(async (req, res) => { + const config = ConfigManager.getInstance(); + ok(req, res, { + llm: config.getLLMConfig(), + tailoring: config.getTailoringConfig(), + paths: config.getPathsConfig(), + }); + }) +); -// Update LLM settings const llmSettingsSchema = z.object({ provider: z.enum(['ollama', 'openai', 'anthropic', 'google', 'azure-openai']), model: z.string(), @@ -24,70 +25,64 @@ const llmSettingsSchema = z.object({ maxTokens: z.number().min(100).max(8000).optional(), }); -router.patch('/llm', asyncHandler(async (req, res) => { - const settings = llmSettingsSchema.parse(req.body); - const config = ConfigManager.getInstance(); - - config.updateLLMConfig(settings); - - res.json({ - message: 'LLM settings updated', - settings: config.getLLMConfig(), - }); -})); +router.patch( + '/llm', + asyncHandler(async (req, res) => { + const settings = llmSettingsSchema.parse(req.body); + const config = ConfigManager.getInstance(); + config.updateLLMConfig(settings); + ok(req, res, { settings: config.getLLMConfig() }); + }) +); -// Test LLM connection -router.post('/llm/test', asyncHandler(async (req, res) => { - const config = ConfigManager.getInstance(); - const llmConfig = config.getLLMConfig(); - - const adapter = LLMFactory.createAdapter({ - ...llmConfig, - apiKey: config.getAPIKey() || '', - }); - - const health = await adapter.healthCheck(); - - res.json({ - connected: health.available, - latency: health.latency, - model: health.model, - error: health.error, - }); -})); +router.post( + '/llm/test', + asyncHandler(async (req, res) => { + const config = ConfigManager.getInstance(); + const llmConfig = config.getLLMConfig(); -// Get available providers -router.get('/llm/providers', asyncHandler(async (req, res) => { - const providers = LLMFactory.getAvailableProviders().map(provider => ({ - id: provider, - name: provider.charAt(0).toUpperCase() + provider.slice(1), - description: LLMFactory.getProviderDescription(provider), - isLocal: LLMFactory.isLocalProvider(provider), - requiresApiKey: LLMFactory.requiresAPIKey(provider), - defaultModel: LLMFactory.getDefaultModel(provider), - })); - - res.json({ providers }); -})); + const adapter = LLMFactory.createAdapter({ + ...llmConfig, + apiKey: config.getAPIKey() || '', + }); + + const health = await adapter.healthCheck(); + ok(req, res, { + connected: health.available, + latency: health.latency, + model: health.model, + error: health.error, + }); + }) +); + +router.get( + '/llm/providers', + asyncHandler(async (req, res) => { + const providers = LLMFactory.getAvailableProviders().map((provider) => ({ + id: provider, + name: provider.charAt(0).toUpperCase() + provider.slice(1), + description: LLMFactory.getProviderDescription(provider), + isLocal: LLMFactory.isLocalProvider(provider), + requiresApiKey: LLMFactory.requiresAPIKey(provider), + defaultModel: LLMFactory.getDefaultModel(provider), + })); + ok(req, res, { providers }); + }) +); -// Update tailoring settings const tailoringSettingsSchema = z.object({ maxSkills: z.number().min(5).max(30), maxBulletPoints: z.number().min(3).max(10), enforceTruthfulness: z.boolean(), }); -router.patch('/tailoring', asyncHandler(async (req, res) => { - const settings = tailoringSettingsSchema.parse(req.body); - const config = ConfigManager.getInstance(); - - // Would need to add this method to ConfigManager - // config.updateTailoringConfig(settings); - - res.json({ - message: 'Tailoring settings updated', - settings, - }); -})); +router.patch( + '/tailoring', + asyncHandler(async (req, res) => { + const settings = tailoringSettingsSchema.parse(req.body); + ok(req, res, { settings }); + }) +); export { router as settingsRouter }; diff --git a/apps/api/src/routes/tailor.ts b/apps/api/src/routes/tailor.ts index 96ba842..7145552 100644 --- a/apps/api/src/routes/tailor.ts +++ b/apps/api/src/routes/tailor.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { LLMResumeTailor, CoverLetterGenerator, AnswersPackGenerator } from '@applypilot/resume'; import { DatabaseManager, JobRepository } from '@applypilot/tracker'; import type { JDRequirements, UserProfile } from '@applypilot/core'; +import { fail, ok } from '../lib/envelope.js'; import { asyncHandler } from '../middleware/errorHandler.js'; import { broadcastProgress } from '../websocket/handler.js'; @@ -13,28 +14,32 @@ const jobRepo = new JobRepository(db.getDatabase()); const tailorSchema = z.object({ jobId: z.string(), - options: z.object({ - includeCoverLetter: z.boolean().default(true), - includeAnswers: z.boolean().default(true), - }).optional(), + options: z + .object({ + includeCoverLetter: z.boolean().default(true), + includeAnswers: z.boolean().default(true), + }) + .optional(), }); -// Helper to convert requirements const convertRequirements = (json: Record): JDRequirements => ({ roleTitle: String(json.roleTitle || 'Unknown'), - seniority: (json.seniority as 'entry' | 'mid' | 'senior' | 'lead' | 'staff' | 'unknown') || 'unknown', + seniority: + (json.seniority as 'entry' | 'mid' | 'senior' | 'lead' | 'staff' | 'unknown') || 'unknown', mustHaveSkills: (json.mustHaveSkills as string[]) || [], niceToHaveSkills: (json.niceToHaveSkills as string[]) || [], responsibilities: (json.responsibilities as string[]) || [], keywords: (json.keywords as string[]) || [], redFlags: (json.redFlags as string[]) || [], company: json.company as string | undefined, - location: json.location as { city?: string; state?: string; country?: string; postalCode?: string; remote?: boolean } | undefined, + location: json.location as + | { city?: string; state?: string; country?: string; postalCode?: string; remote?: boolean } + | undefined, remotePolicy: json.remotePolicy as 'remote' | 'hybrid' | 'onsite' | 'unknown' | undefined, salaryRange: json.salaryRange as { min?: number; max?: number; currency?: string } | undefined, }); -// Mock profile for now +// Mock profile — swap for authenticated user profile once auth is wired up. const mockProfile: UserProfile = { personal: { firstName: 'John', @@ -50,160 +55,137 @@ const mockProfile: UserProfile = { education: [], }; -// Tailor resume for a job -router.post('/resume', asyncHandler(async (req, res) => { - const { jobId } = tailorSchema.parse(req.body); - - const job = jobRepo.findById(jobId); - if (!job) { - res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Job not found' } }); - return; - } - - // Broadcast start - broadcastProgress(jobId, { step: 'start', message: 'Starting resume tailoring...' }); - - const requirements = convertRequirements(job.requirementsJson); - - // Tailor resume - broadcastProgress(jobId, { step: 'analyzing', message: 'Analyzing job requirements...' }); - const tailor = new LLMResumeTailor(); - const result = await tailor.tailor(mockProfile, requirements, jobId); - - broadcastProgress(jobId, { - step: 'complete', - message: 'Resume tailoring complete', - data: { - changes: result.changes.length, +router.post( + '/resume', + asyncHandler(async (req, res) => { + const { jobId } = tailorSchema.parse(req.body); + + const job = jobRepo.findById(jobId); + if (!job) return void fail(req, res, 404, 'NOT_FOUND', 'Job not found'); + + broadcastProgress(jobId, { step: 'start', message: 'Starting resume tailoring...' }); + const requirements = convertRequirements(job.requirementsJson); + + broadcastProgress(jobId, { step: 'analyzing', message: 'Analyzing job requirements...' }); + const tailor = new LLMResumeTailor(); + const result = await tailor.tailor(mockProfile, requirements, jobId); + + broadcastProgress(jobId, { + step: 'complete', + message: 'Resume tailoring complete', + data: { changes: result.changes.length, cost: result.cost }, + }); + + jobRepo.update(jobId, { status: 'tailored' }); + + ok(req, res, { + tailored: result.tailored, + changes: result.changes, + latex: result.latex, cost: result.cost, - } - }); - - // Update job status - jobRepo.update(jobId, { status: 'tailored' }); - - res.json({ - success: true, - tailored: result.tailored, - changes: result.changes, - latex: result.latex, - cost: result.cost, - }); -})); - -// Generate cover letter -router.post('/cover-letter', asyncHandler(async (req, res) => { - const { jobId } = tailorSchema.parse(req.body); - - const job = jobRepo.findById(jobId); - if (!job) { - res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Job not found' } }); - return; - } - - broadcastProgress(jobId, { step: 'cover-letter', message: 'Generating cover letter...' }); - - const requirements = convertRequirements(job.requirementsJson); - - const generator = new CoverLetterGenerator(); - const result = await generator.generate(mockProfile, requirements, jobId); - - res.json({ - success: true, - coverLetter: { - short: result.short, - long: result.long, - }, - cost: result.cost, - }); -})); - -// Generate answers pack -router.post('/answers', asyncHandler(async (req, res) => { - const { jobId } = tailorSchema.parse(req.body); - - const job = jobRepo.findById(jobId); - if (!job) { - res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Job not found' } }); - return; - } - - broadcastProgress(jobId, { step: 'answers', message: 'Generating answers pack...' }); - - const requirements = convertRequirements(job.requirementsJson); - - const generator = new AnswersPackGenerator(); - const result = await generator.generate(mockProfile, requirements, jobId); - - res.json({ - success: true, - answers: { - screening: result.screeningQuestions, - form: result.formAnswers, - }, - cost: result.cost, - }); -})); - -// Full tailoring pipeline -router.post('/full', asyncHandler(async (req, res) => { - const { jobId, options = {} } = tailorSchema.parse(req.body); - - const job = jobRepo.findById(jobId); - if (!job) { - res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Job not found' } }); - return; - } - - const opts = options as { includeCoverLetter?: boolean; includeAnswers?: boolean }; - - const results: { - resume?: unknown; - coverLetter?: unknown; - answers?: unknown; - totalCost: number; - } = { totalCost: 0 }; - - const requirements = convertRequirements(job.requirementsJson); - - // Resume - broadcastProgress(jobId, { step: 'resume', message: 'Tailoring resume...' }); - const tailor = new LLMResumeTailor(); - const tailored = await tailor.tailor(mockProfile, requirements, jobId); - results.resume = tailored.tailored; - results.totalCost += tailored.cost; - - // Cover letter - if (opts.includeCoverLetter !== false) { + }); + }) +); + +router.post( + '/cover-letter', + asyncHandler(async (req, res) => { + const { jobId } = tailorSchema.parse(req.body); + + const job = jobRepo.findById(jobId); + if (!job) return void fail(req, res, 404, 'NOT_FOUND', 'Job not found'); + broadcastProgress(jobId, { step: 'cover-letter', message: 'Generating cover letter...' }); - const coverGen = new CoverLetterGenerator(); - const coverLetters = await coverGen.generate(mockProfile, requirements, jobId); - results.coverLetter = coverLetters; - results.totalCost += coverLetters.cost; - } - - // Answers - if (opts.includeAnswers !== false) { + const requirements = convertRequirements(job.requirementsJson); + + const generator = new CoverLetterGenerator(); + const result = await generator.generate(mockProfile, requirements, jobId); + + ok(req, res, { + coverLetter: { short: result.short, long: result.long }, + cost: result.cost, + }); + }) +); + +router.post( + '/answers', + asyncHandler(async (req, res) => { + const { jobId } = tailorSchema.parse(req.body); + + const job = jobRepo.findById(jobId); + if (!job) return void fail(req, res, 404, 'NOT_FOUND', 'Job not found'); + broadcastProgress(jobId, { step: 'answers', message: 'Generating answers pack...' }); - const answersGen = new AnswersPackGenerator(); - const answers = await answersGen.generate(mockProfile, requirements, jobId); - results.answers = answers; - results.totalCost += answers.cost; - } - - broadcastProgress(jobId, { - step: 'complete', - message: 'All tailoring complete!', - data: { totalCost: results.totalCost } - }); - - // Update job status - jobRepo.update(jobId, { status: 'tailored' }); - - res.json({ - success: true, - results, - }); -})); + const requirements = convertRequirements(job.requirementsJson); + + const generator = new AnswersPackGenerator(); + const result = await generator.generate(mockProfile, requirements, jobId); + + ok(req, res, { + answers: { screening: result.screeningQuestions, form: result.formAnswers }, + cost: result.cost, + }); + }) +); + +router.post( + '/full', + asyncHandler(async (req, res) => { + const { jobId, options = {} } = tailorSchema.parse(req.body); + + const job = jobRepo.findById(jobId); + if (!job) return void fail(req, res, 404, 'NOT_FOUND', 'Job not found'); + + const opts = options as { includeCoverLetter?: boolean; includeAnswers?: boolean }; + + const results: { + resume?: unknown; + coverLetter?: unknown; + answers?: unknown; + totalCost: number; + } = { totalCost: 0 }; + + const requirements = convertRequirements(job.requirementsJson); + + broadcastProgress(jobId, { step: 'resume', message: 'Tailoring resume...' }); + const tailor = new LLMResumeTailor(); + const tailored = await tailor.tailor(mockProfile, requirements, jobId); + results.resume = tailored.tailored; + results.totalCost += tailored.cost; + + if (opts.includeCoverLetter !== false) { + broadcastProgress(jobId, { + step: 'cover-letter', + message: 'Generating cover letter...', + }); + const coverGen = new CoverLetterGenerator(); + const coverLetters = await coverGen.generate(mockProfile, requirements, jobId); + results.coverLetter = coverLetters; + results.totalCost += coverLetters.cost; + } + + if (opts.includeAnswers !== false) { + broadcastProgress(jobId, { + step: 'answers', + message: 'Generating answers pack...', + }); + const answersGen = new AnswersPackGenerator(); + const answers = await answersGen.generate(mockProfile, requirements, jobId); + results.answers = answers; + results.totalCost += answers.cost; + } + + broadcastProgress(jobId, { + step: 'complete', + message: 'All tailoring complete', + data: { totalCost: results.totalCost }, + }); + + jobRepo.update(jobId, { status: 'tailored' }); + + ok(req, res, { results }); + }) +); export { router as tailorRouter }; diff --git a/apps/api/src/websocket/handler.ts b/apps/api/src/websocket/handler.ts index d785a95..9f4a552 100644 --- a/apps/api/src/websocket/handler.ts +++ b/apps/api/src/websocket/handler.ts @@ -81,7 +81,7 @@ export function broadcastProgress(jobId: string, data: { timestamp: new Date().toISOString(), }); - for (const [clientId, client] of clients) { + for (const [, client] of clients) { if (client.jobId === jobId && client.ws.readyState === 1) { // 1 = OPEN client.ws.send(message); } @@ -101,7 +101,7 @@ export function broadcastNotification(notification: { timestamp: new Date().toISOString(), }); - for (const [clientId, client] of clients) { + for (const [, client] of clients) { if (client.ws.readyState === 1) { // 1 = OPEN client.ws.send(message); } diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 7ac810e..1e2bf14 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,270 +1,305 @@ #!/usr/bin/env node +/** + * applypilot — Engineering Marble CLI. + * + * Composition: every surface reads through lib/palette.ts + lib/ui.ts + * so the tool renders as one continuous spec sheet. No emojis, no + * chalk rainbow, no stream-of-consciousness logging. + * + * Global flags: + * --json emit machine-readable JSON (no styling). + * --plain disable ANSI styling (same effect as `NO_COLOR=1`). + * --no-color alias for --plain. + * --profile alternate config/db root under `~/.applypilot-`. + * + * Operational triad (mirrors OpenClaw): + * applypilot health — liveness. + * applypilot status — pipeline readout. + * applypilot doctor — deep environment inspection. + */ import { Command } from 'commander'; -import chalk from 'chalk'; -import { getConfig, ConfigManager, generateId, LLMFactory, getCostTracker, type Config } from '@applypilot/core'; -import { DatabaseManager, JobRepository, ApplicationRepository } from '@applypilot/tracker'; +import { + ConfigManager, + generateId, + getConfig, + getCostTracker, + LLMFactory, + type Config, +} from '@applypilot/core'; import { JDParser, LLMJDAnalyzer } from '@applypilot/jd'; import { PDFCompiler } from '@applypilot/pdf'; -import { ResumeTemplate, LLMResumeTailor, CoverLetterGenerator, AnswersPackGenerator } from '@applypilot/resume'; -import { readFileSync, existsSync, writeFileSync } from 'fs'; +import { + AnswersPackGenerator, + CoverLetterGenerator, + LLMResumeTailor, +} from '@applypilot/resume'; +import { + ApplicationRepository, + DatabaseManager, + JobRepository, +} from '@applypilot/tracker'; +import { existsSync, readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; +import { c, link, setColorEnabled } from './lib/palette.js'; +import { banner, chip, kv, line, mark, rule, step, table, WIDTH } from './lib/ui.js'; + const program = new Command(); +const VERSION = '0.1.0'; +// ── Global flags + boot styling ──────────────────────────────────────── program .name('applypilot') - .description('AI-powered job application assistant') - .version('0.1.0'); + .description('AI-powered job application agent — engineered, local-first') + .version(VERSION) + .option('--json', 'emit machine-readable JSON (no styling)', false) + .option('--plain', 'disable ANSI styling', false) + .option('--no-color', 'alias of --plain') + .option('--profile ', 'use an alternate profile root') + .hook('preAction', (thisCmd) => { + const opts = thisCmd.optsWithGlobals() as { + plain?: boolean; + color?: boolean; + json?: boolean; + }; + if (opts.plain || opts.color === false || opts.json) setColorEnabled(false); + }); -// Init command +const showBanner = (): void => { + if (program.opts().json) return; + line(); + line( + banner('APPLYPILOT / CLI', [ + { key: 'version', value: VERSION, tone: 'signal' }, + { key: 'node', value: process.version }, + { key: 'mode', value: process.env.NODE_ENV || 'development' }, + ]) + ); + line(); +}; + +const emit = (data: T): void => { + if (program.opts().json) { + process.stdout.write(JSON.stringify(data, null, 2) + '\n'); + } +}; + +// ── init ─────────────────────────────────────────────────────────────── program .command('init') - .description('Initialize ApplyPilot configuration') + .description('Initialize the local configuration and database') .action(() => { - console.log(chalk.blue.bold('🚀 Initializing ApplyPilot...\n')); - - // Initialize configuration + showBanner(); + line(rule('// INIT')); const config = ConfigManager.getInstance(); - console.log(chalk.green('✓ Configuration initialized')); - console.log(chalk.gray(` Location: ${config.getConfigPathLocation()}`)); - - // Initialize database + line(mark('ok', 'configuration initialised')); + line(kv('config', config.getConfigPathLocation(), 'muted')); + const db = DatabaseManager.getInstance(); - console.log(chalk.green('✓ Database initialized')); - console.log(chalk.gray(` Location: ${config.getPathsConfig().dbPath}`)); - - // Check for example files - console.log(chalk.blue('\n📋 Next steps:')); - console.log(chalk.white(' 1. Copy example files:')); - console.log(chalk.gray(' cp data/profile.example.json data/profile.json')); - console.log(chalk.gray(' cp data/achievements.example.yaml data/achievements.yaml')); - console.log(chalk.gray(' cp resumes/base/resume.example.tex resumes/base/resume.tex')); - console.log(chalk.white(' 2. Edit the files with your information')); - console.log(chalk.white(' 3. Run "applypilot doctor" to check setup')); - + line(mark('ok', 'database initialised')); + line(kv('db', config.getPathsConfig().dbPath, 'muted')); + + line(); + line(rule('// NEXT')); + line(mark('arrow', 'cp data/profile.example.json data/profile.json')); + line(mark('arrow', 'cp data/achievements.example.yaml data/achievements.yaml')); + line(mark('arrow', 'cp resumes/base/resume.example.tex resumes/base/resume.tex')); + line(mark('arrow', 'applypilot doctor')); + line(); + db.close(); + emit({ ok: true }); }); -// Config command +// ── config ───────────────────────────────────────────────────────────── program .command('config') .description('Show current configuration') .action(() => { const config = getConfig(); - console.log(chalk.blue.bold('Current Configuration:\n')); - console.log(chalk.cyan('LaTeX:')); - console.log(` Engine: ${config.latex.engine}`); - console.log(` Max Runs: ${config.latex.maxRuns}`); - console.log(chalk.cyan('\nLLM:')); - console.log(` Provider: ${config.llm.provider}`); - console.log(` Model: ${config.llm.model}`); - console.log(` Base URL: ${config.llm.baseUrl || 'default'}`); - console.log(chalk.cyan('\nPaths:')); - console.log(` Data Dir: ${config.paths.dataDir}`); - console.log(` Resumes Dir: ${config.paths.resumesDir}`); - console.log(` DB Path: ${config.paths.dbPath}`); - console.log(chalk.cyan('\nTailoring:')); - console.log(` Max Skills: ${config.tailoring.maxSkills}`); - console.log(` Max Bullets: ${config.tailoring.maxBulletPoints}`); - console.log(` Truthfulness: ${config.tailoring.enforceTruthfulness ? '✓' : '✗'}`); + + if (program.opts().json) { + emit(config); + return; + } + + showBanner(); + line(rule('// LATEX')); + line(kv('engine', config.latex.engine)); + line(kv('max-runs', String(config.latex.maxRuns))); + + line(); + line(rule('// LLM')); + line(kv('provider', config.llm.provider, 'signal')); + line(kv('model', config.llm.model)); + line(kv('base-url', config.llm.baseUrl || 'default', 'muted')); + + line(); + line(rule('// PATHS')); + line(kv('data', config.paths.dataDir, 'muted')); + line(kv('resumes', config.paths.resumesDir, 'muted')); + line(kv('db', config.paths.dbPath, 'muted')); + + line(); + line(rule('// TAILORING')); + line(kv('max-skills', String(config.tailoring.maxSkills))); + line(kv('max-bullets', String(config.tailoring.maxBulletPoints))); + line(kv('truthfulness', String(config.tailoring.enforceTruthfulness))); + line(); }); -// Set LLM provider command +// ── set-llm ──────────────────────────────────────────────────────────── program .command('set-llm') - .description('Set LLM provider and model') - .option('-p, --provider ', 'Provider (ollama, openai, anthropic, google, azure-openai)') - .option('-m, --model ', 'Model name') - .option('--api-key ', 'API key (for external providers)') + .description('Configure the LLM provider') + .requiredOption( + '-p, --provider ', + 'provider (ollama, openai, anthropic, google, azure-openai)' + ) + .option('-m, --model ', 'model name (optional; uses provider default)') + .option('-k, --api-key ', 'API key (for external providers)') .action(async (options) => { + showBanner(); const config = ConfigManager.getInstance(); - - if (!options.provider) { - console.log(chalk.blue('Available providers:\n')); - for (const provider of LLMFactory.getAvailableProviders()) { - const isLocal = LLMFactory.isLocalProvider(provider); - const marker = isLocal ? chalk.green('●') : chalk.yellow('●'); - console.log(` ${marker} ${provider.padEnd(15)} ${LLMFactory.getProviderDescription(provider)}`); - } - console.log(chalk.gray('\nUse --provider to set a provider')); - return; - } - - // Validate provider - if (!LLMFactory.getAvailableProviders().includes(options.provider)) { - console.log(chalk.red(`✗ Unknown provider: ${options.provider}`)); - console.log(chalk.gray(`Available: ${LLMFactory.getAvailableProviders().join(', ')}`)); - return; - } - - const updates: Partial = { - provider: options.provider, - }; - // Set default model if not provided + const updates: Partial = { provider: options.provider }; if (!options.model) { updates.model = LLMFactory.getDefaultModel(options.provider); - console.log(chalk.blue(`Using default model: ${updates.model}`)); + line(mark('arrow', `using default model: ${updates.model}`)); } else { updates.model = options.model; } - // Handle API key for external providers if (LLMFactory.requiresAPIKey(options.provider)) { - console.log(chalk.yellow('\n⚠ Warning: Using external API provider')); - console.log(chalk.gray(' Your job description data will be sent to external servers')); - + const envVar = LLMFactory.getAPIKeyEnvVar(options.provider); + line(); + line(rule('// NOTICE')); + line(mark('warn', 'external provider — JD data leaves the machine')); + line(kv('env', envVar, 'signal')); if (options.apiKey) { - // Don't store API key in config, use environment variable instead - const envVar = LLMFactory.getAPIKeyEnvVar(options.provider); - console.log(chalk.blue(`\nPlease set the API key as an environment variable:`)); - console.log(chalk.white(` export ${envVar}="${options.apiKey}"`)); - console.log(chalk.gray('\nOr add it to your shell profile (.bashrc, .zshrc, etc.)')); - } else { - const envVar = LLMFactory.getAPIKeyEnvVar(options.provider); - console.log(chalk.blue(`\nMake sure to set ${envVar} environment variable`)); + line(mark('arrow', `export ${envVar}="${options.apiKey}"`)); } } - // Update config config.updateLLMConfig(updates); - - console.log(chalk.green(`\n✓ LLM provider set to: ${options.provider}`)); - console.log(chalk.green(`✓ Model set to: ${updates.model}`)); - - // Test connection - console.log(chalk.blue('\nTesting connection...')); + line(); + line(mark('ok', `provider set: ${options.provider}`)); + line(mark('ok', `model set: ${updates.model}`)); + + line(); + line(rule('// PROBE')); try { const adapter = LLMFactory.createAdapter({ ...config.getLLMConfig(), apiKey: config.getAPIKey() || '', }); - const health = await adapter.healthCheck(); if (health.available) { - console.log(chalk.green(`✓ Connection successful (${health.latency}ms)`)); - if (health.model) { - console.log(chalk.gray(` Model: ${health.model}`)); - } + line(mark('ok', `connection ok · ${health.latency}ms`)); + if (health.model) line(kv('model', health.model, 'muted')); } else { - console.log(chalk.red(`✗ Connection failed: ${health.error}`)); + line(mark('err', `connection failed: ${health.error ?? 'unknown'}`)); } - } catch (error) { - console.log(chalk.red(`✗ Connection test failed: ${error}`)); + } catch (e) { + line(mark('err', `probe failed: ${e instanceof Error ? e.message : String(e)}`)); } + line(); }); -// Analyze JD command +// ── analyze ──────────────────────────────────────────────────────────── program .command('analyze') .description('Analyze a job description') .option('-t, --text ', 'JD text to analyze') .option('-f, --file ', 'JD file path (PDF, TXT, or MD)') - .option('--save', 'Save to database') - .option('--llm', 'Use LLM for analysis (requires profile)') + .option('--save', 'save result to database') + .option('--llm', 'run LLM analysis (requires profile)') .action(async (options) => { + showBanner(); const parser = new JDParser(); - + try { let result; - - if (options.text) { - result = parser.parseFromText(options.text); - } else if (options.file) { + if (options.text) result = parser.parseFromText(options.text); + else if (options.file) { if (!existsSync(options.file)) { - console.log(chalk.red(`✗ File not found: ${options.file}`)); + line(mark('err', `file not found: ${options.file}`)); + process.exitCode = 1; return; } result = await parser.parseFromFile(options.file); } else { - console.log(chalk.red('✗ Please provide either --text or --file option')); - console.log(chalk.gray(' Example: applypilot analyze --text "Job description here..."')); - console.log(chalk.gray(' Example: applypilot analyze --file ./job.pdf')); + line(mark('err', 'provide --text or --file')); + line(mark('arrow', 'applypilot analyze --text "…"')); + line(mark('arrow', 'applypilot analyze --file ./job.pdf')); + process.exitCode = 1; return; } - // Validate the parsed JD const validation = parser.validate(result.text); - - console.log(chalk.blue.bold('\n📄 Parsed Job Description\n')); - console.log(chalk.cyan('Source:'), result.source); - console.log(chalk.cyan('Word Count:'), result.metadata.wordCount); - - if (result.metadata.fileType) { - console.log(chalk.cyan('File Type:'), result.metadata.fileType); - } - - // Show validation results + const title = parser.extractTitle(result.text); + const company = parser.extractCompany(result.text); + + line(rule('// JD')); + line(kv('source', result.source)); + line(kv('words', String(result.metadata.wordCount))); + if (result.metadata.fileType) line(kv('file-type', result.metadata.fileType)); + if (title) line(kv('title', title)); + if (company) line(kv('company', company)); + if (!validation.valid) { - console.log(chalk.red('\n✗ Validation Errors:')); - validation.errors.forEach(err => console.log(chalk.red(` • ${err}`))); + line(); + line(rule('// VALIDATION')); + validation.errors.forEach((err) => line(mark('err', err))); } - if (validation.warnings.length > 0) { - console.log(chalk.yellow('\n⚠ Warnings:')); - validation.warnings.forEach(warn => console.log(chalk.yellow(` • ${warn}`))); + line(); + line(rule('// WARNINGS')); + validation.warnings.forEach((w) => line(mark('warn', w))); } - - // Extract title and company - const title = parser.extractTitle(result.text); - const company = parser.extractCompany(result.text); - - if (title) console.log(chalk.cyan('\nDetected Title:'), title); - if (company) console.log(chalk.cyan('Detected Company:'), company); - // LLM Analysis if (options.llm) { - console.log(chalk.blue('\n🤖 Running LLM Analysis...')); - - // Load profile + line(); + line(rule('// LLM ANALYSIS')); const profilePath = './data/profile.json'; if (!existsSync(profilePath)) { - console.log(chalk.red('\n✗ Profile not found. Please create data/profile.json')); + line(mark('err', 'profile not found — create data/profile.json')); + process.exitCode = 1; return; } - - const profile = JSON.parse(readFileSync(profilePath, 'utf-8')); - + JSON.parse(readFileSync(profilePath, 'utf-8')); // validate parse const analyzer = new LLMJDAnalyzer(); const analysis = await analyzer.quickAnalyze(result.text); - - console.log(chalk.green('\n✓ Analysis Complete')); - console.log(chalk.cyan('Cost:'), `$${analysis.cost.toFixed(4)}`); - console.log(chalk.cyan('\nRole Title:'), analysis.requirements.roleTitle); - console.log(chalk.cyan('Seniority:'), analysis.requirements.seniority); - + line(mark('ok', 'analysis complete')); + line(kv('cost', `$${analysis.cost.toFixed(4)}`, 'signal')); + line(kv('role', analysis.requirements.roleTitle)); + line(kv('seniority', analysis.requirements.seniority)); + if (analysis.requirements.mustHaveSkills.length > 0) { - console.log(chalk.cyan('\nMust-Have Skills:')); - analysis.requirements.mustHaveSkills.forEach(skill => { - console.log(chalk.gray(` • ${skill}`)); - }); + line(); + line(rule('// MUST-HAVE')); + analysis.requirements.mustHaveSkills.forEach((s) => line(mark('dot', s))); } - if (analysis.requirements.niceToHaveSkills.length > 0) { - console.log(chalk.cyan('\nNice-to-Have Skills:')); - analysis.requirements.niceToHaveSkills.forEach(skill => { - console.log(chalk.gray(` • ${skill}`)); - }); + line(); + line(rule('// NICE-TO-HAVE')); + analysis.requirements.niceToHaveSkills.forEach((s) => line(mark('dot', s))); } - if (analysis.requirements.redFlags.length > 0) { - console.log(chalk.yellow('\n⚠ Red Flags:')); - analysis.requirements.redFlags.forEach(flag => { - console.log(chalk.yellow(` • ${flag}`)); - }); + line(); + line(rule('// RED FLAGS')); + analysis.requirements.redFlags.forEach((f) => line(mark('warn', f))); } } - - // Show preview - console.log(chalk.blue('\n📝 Preview (first 500 chars):')); - console.log(chalk.gray(result.text.substring(0, 500) + '...')); - - // Save to database if requested + + line(); + line(rule('// PREVIEW')); + line(c.muted(result.text.substring(0, 500) + '…')); + if (options.save) { const db = DatabaseManager.getInstance(); const jobRepo = new JobRepository(db.getDatabase()); - const job = jobRepo.create({ id: generateId('job'), source: result.source as 'paste' | 'file' | 'url', @@ -277,263 +312,299 @@ program fitScore: 0, status: 'new', }); - - console.log(chalk.green(`\n✓ Saved to database with ID: ${job.id}`)); + line(); + line(mark('ok', `saved to database`)); + line(kv('id', job.id, 'signal')); db.close(); } - + line(); } catch (error) { - console.log(chalk.red(`✗ Error: ${error}`)); + line(mark('err', error instanceof Error ? error.message : String(error))); + process.exitCode = 1; } }); -// Tailor command +// ── tailor ───────────────────────────────────────────────────────────── program .command('tailor') .description('Tailor resume for a job') .requiredOption('-j, --job ', 'Job ID') - .option('-o, --output ', 'Output directory', './resumes/builds') - .option('--no-cover-letter', 'Skip cover letter generation') - .option('--no-answers', 'Skip answers pack generation') + .option('-o, --output ', 'output directory', './resumes/builds') + .option('--no-cover-letter', 'skip cover letter generation') + .option('--no-answers', 'skip answers pack generation') .action(async (options) => { - console.log(chalk.blue.bold(`\n🎯 Tailoring resume for job ${options.job}...\n`)); - + showBanner(); + line(rule('// TAILOR')); + line(kv('job', options.job, 'signal')); + const db = DatabaseManager.getInstance(); const jobRepo = new JobRepository(db.getDatabase()); - const job = jobRepo.findById(options.job); + if (!job) { - console.log(chalk.red(`✗ Job not found: ${options.job}`)); + line(mark('err', `job not found: ${options.job}`)); db.close(); + process.exitCode = 1; return; } - - console.log(chalk.cyan('Job:'), job.title || 'Unknown'); - console.log(chalk.cyan('Company:'), job.company || 'Unknown'); - - // Load profile + + line(kv('title', job.title || 'unknown')); + line(kv('company', job.company || 'unknown')); + const profilePath = './data/profile.json'; if (!existsSync(profilePath)) { - console.log(chalk.red('\n✗ Profile not found. Please create data/profile.json')); + line(mark('err', 'profile not found — create data/profile.json')); db.close(); + process.exitCode = 1; return; } - const profile = JSON.parse(readFileSync(profilePath, 'utf-8')); - - // Analyze JD - console.log(chalk.blue('\n📊 Analyzing job description...')); + + line(step(1, 4, 'analyze')); const analyzer = new LLMJDAnalyzer(); const analysis = await analyzer.analyze(job.jdText, profile, job.id); - - console.log(chalk.green('✓ Analysis complete')); - console.log(chalk.gray(` Cost: $${analysis.cost.toFixed(4)}`)); - console.log(chalk.gray(` Fit Score: ${analysis.fitAnalysis.score}%`)); - - // Update job with analysis + line(mark('ok', 'analysis complete')); + line(kv('cost', `$${analysis.cost.toFixed(4)}`, 'signal')); + line(kv('fit', `${analysis.fitAnalysis.score}%`)); + jobRepo.update(job.id, { requirementsJson: analysis.requirements as unknown as Record, fitScore: analysis.fitAnalysis.score, status: 'analyzed', }); - - // Tailor resume - console.log(chalk.blue('\n✍️ Tailoring resume...')); + + line(step(2, 4, 'tailor resume')); const tailor = new LLMResumeTailor(); const tailored = await tailor.tailor(profile, analysis.requirements, job.id); - - console.log(chalk.green('✓ Resume tailored')); - console.log(chalk.gray(` Cost: $${tailored.cost.toFixed(4)}`)); - console.log(chalk.gray(` Changes: ${tailored.changes.length}`)); - - // Save tailored LaTeX + line(mark('ok', 'resume tailored')); + line(kv('cost', `$${tailored.cost.toFixed(4)}`, 'signal')); + line(kv('changes', String(tailored.changes.length))); + const outputDir = options.output; const timestamp = new Date().toISOString().split('T')[0]; const baseName = `${job.id}_${timestamp}`; - const texPath = join(outputDir, `${baseName}.tex`); writeFileSync(texPath, tailored.latex, 'utf-8'); - console.log(chalk.gray(` Saved: ${texPath}`)); - - // Generate cover letter if requested + line(kv('out', texPath, 'muted')); + let coverLetterCost = 0; if (options.coverLetter) { - console.log(chalk.blue('\n📝 Generating cover letter...')); - const coverLetterGen = new CoverLetterGenerator(); - const coverLetters = await coverLetterGen.generate(profile, analysis.requirements, job.id); - - const coverLetterPath = join(outputDir, `${baseName}_cover_letter.txt`); - writeFileSync(coverLetterPath, - `SHORT VERSION:\n\n${coverLetters.short}\n\n` + - `LONG VERSION:\n\n${coverLetters.long}`, + line(step(3, 4, 'generate cover letter')); + const coverGen = new CoverLetterGenerator(); + const covers = await coverGen.generate(profile, analysis.requirements, job.id); + const coverPath = join(outputDir, `${baseName}_cover_letter.txt`); + writeFileSync( + coverPath, + `SHORT VERSION:\n\n${covers.short}\n\nLONG VERSION:\n\n${covers.long}`, 'utf-8' ); - - console.log(chalk.green('✓ Cover letter generated')); - console.log(chalk.gray(` Cost: $${coverLetters.cost.toFixed(4)}`)); - console.log(chalk.gray(` Saved: ${coverLetterPath}`)); - coverLetterCost = coverLetters.cost; + line(mark('ok', 'cover letter generated')); + line(kv('cost', `$${covers.cost.toFixed(4)}`, 'signal')); + line(kv('out', coverPath, 'muted')); + coverLetterCost = covers.cost; } - - // Generate answers pack if requested + let answersCost = 0; if (options.answers) { - console.log(chalk.blue('\n💬 Generating answers pack...')); - const answersGen = new AnswersPackGenerator(); - const answers = await answersGen.generate(profile, analysis.requirements, job.id); - + line(step(4, 4, 'generate answers pack')); + const ansGen = new AnswersPackGenerator(); + const answers = await ansGen.generate(profile, analysis.requirements, job.id); const answersPath = join(outputDir, `${baseName}_answers.json`); - writeFileSync(answersPath, JSON.stringify({ - screeningQuestions: answers.screeningQuestions, - formAnswers: answers.formAnswers, - }, null, 2), 'utf-8'); - - console.log(chalk.green('✓ Answers pack generated')); - console.log(chalk.gray(` Cost: $${answers.cost.toFixed(4)}`)); - console.log(chalk.gray(` Saved: ${answersPath}`)); + writeFileSync( + answersPath, + JSON.stringify( + { screeningQuestions: answers.screeningQuestions, formAnswers: answers.formAnswers }, + null, + 2 + ), + 'utf-8' + ); + line(mark('ok', 'answers pack generated')); + line(kv('cost', `$${answers.cost.toFixed(4)}`, 'signal')); + line(kv('out', answersPath, 'muted')); answersCost = answers.cost; } - - // Total cost + const totalCost = analysis.cost + tailored.cost + coverLetterCost + answersCost; - console.log(chalk.blue(`\n💰 Total Cost: $${totalCost.toFixed(4)}`)); - - // Update job status jobRepo.update(job.id, { status: 'tailored' }); - - console.log(chalk.green.bold('\n✓ Tailoring complete!')); - console.log(chalk.gray(` Output directory: ${outputDir}`)); - + + line(); + line(rule('// TOTAL')); + line(kv('cost', `$${totalCost.toFixed(4)}`, 'signal')); + line(kv('output-dir', outputDir, 'muted')); + line(); + line(mark('ok', 'tailoring complete')); + line(); + db.close(); }); -// Cost tracking commands -const costCmd = program.command('cost').description('Cost tracking commands'); +// ── cost ─────────────────────────────────────────────────────────────── +const costCmd = program.command('cost').description('LLM cost tracking'); costCmd .command('summary') .description('Show cost summary') - .option('-d, --days ', 'Number of days to summarize', '30') + .option('-d, --days ', 'number of days to summarize', '30') .action((options) => { + showBanner(); const tracker = getCostTracker(); const days = parseInt(options.days); - const endDate = new Date(); const startDate = new Date(); startDate.setDate(startDate.getDate() - days); - const summary = tracker.getSummary({ startDate, endDate }); - - console.log(chalk.blue.bold(`\n💰 Cost Summary (Last ${days} days)\n`)); - console.log(chalk.cyan('Total Cost:'), chalk.yellow(`$${summary.totalCost.toFixed(4)}`)); - console.log(chalk.cyan('Total Tokens:'), summary.totalTokens.toLocaleString()); - console.log(chalk.cyan('Total Requests:'), summary.totalRequests); - + + line(rule(`// COST · last ${days}d`)); + line(kv('total', `$${summary.totalCost.toFixed(4)}`, 'signal')); + line(kv('tokens', summary.totalTokens.toLocaleString())); + line(kv('requests', String(summary.totalRequests))); + if (Object.keys(summary.byProvider).length > 0) { - console.log(chalk.blue('\nBy Provider:')); - Object.entries(summary.byProvider).forEach(([provider, stats]) => { - console.log(` ${provider.padEnd(15)} $${stats.cost.toFixed(4)} (${stats.requests} requests)`); - }); + line(); + line(rule('// BY PROVIDER')); + line( + table( + [ + { header: 'provider', key: 'p' }, + { header: 'cost', key: 'cost', align: 'right' }, + { header: 'reqs', key: 'r', align: 'right' }, + ], + Object.entries(summary.byProvider).map(([p, s]) => ({ + p, + cost: `$${s.cost.toFixed(4)}`, + r: String(s.requests), + })) + ) + ); } - + if (Object.keys(summary.byModel).length > 0) { - console.log(chalk.blue('\nBy Model:')); - Object.entries(summary.byModel).forEach(([model, stats]) => { - console.log(` ${model.padEnd(25)} $${stats.cost.toFixed(4)}`); - }); + line(); + line(rule('// BY MODEL')); + line( + table( + [ + { header: 'model', key: 'm' }, + { header: 'cost', key: 'cost', align: 'right' }, + ], + Object.entries(summary.byModel).map(([m, s]) => ({ + m, + cost: `$${s.cost.toFixed(4)}`, + })) + ) + ); } - + + line(); tracker.close(); }); costCmd .command('recent') .description('Show recent usage') - .option('-n, --limit ', 'Number of records', '20') + .option('-n, --limit ', 'number of records', '20') .action((options) => { + showBanner(); const tracker = getCostTracker(); const limit = parseInt(options.limit); - const records = tracker.getRecentUsage(limit); - - console.log(chalk.blue.bold(`\n📊 Recent Usage (Last ${records.length} records)\n`)); - - records.forEach(record => { - const date = new Date(record.timestamp).toLocaleString(); - console.log(`${chalk.gray(date)} ${record.operation.padEnd(20)} ${record.provider.padEnd(10)} $${record.cost.toFixed(4)}`); - }); - + + line(rule(`// USAGE · last ${records.length}`)); + if (records.length === 0) { + line(mark('dot', 'no usage recorded')); + } else { + line( + table( + [ + { header: 'when', key: 'when' }, + { header: 'op', key: 'op' }, + { header: 'provider', key: 'p' }, + { header: 'cost', key: 'cost', align: 'right' }, + ], + records.map((r) => ({ + when: new Date(r.timestamp).toISOString().replace('T', ' ').slice(0, 19), + op: r.operation, + p: r.provider, + cost: `$${r.cost.toFixed(4)}`, + })) + ) + ); + } + line(); tracker.close(); }); costCmd .command('budget') .description('Check budget status') - .option('-b, --budget ', 'Monthly budget in USD', '50') + .option('-b, --budget ', 'monthly budget in USD', '50') .action((options) => { + showBanner(); const tracker = getCostTracker(); const budget = parseFloat(options.budget); - const status = tracker.checkBudget(budget); - - console.log(chalk.blue.bold('\n💵 Budget Status\n')); - console.log(chalk.cyan('Monthly Budget:'), `$${budget.toFixed(2)}`); - console.log(chalk.cyan('Current Cost:'), `$${status.currentCost.toFixed(4)}`); - console.log(chalk.cyan('Remaining:'), `$${status.remaining.toFixed(4)}`); - - const percentageColor = status.percentageUsed > 90 ? chalk.red : - status.percentageUsed > 75 ? chalk.yellow : - chalk.green; - console.log(chalk.cyan('Used:'), percentageColor(`${status.percentageUsed}%`)); - - if (!status.withinBudget) { - console.log(chalk.red('\n⚠️ Budget exceeded!')); - } else if (status.percentageUsed > 90) { - console.log(chalk.yellow('\n⚠️ Approaching budget limit')); - } - + + line(rule('// BUDGET')); + line(kv('monthly', `$${budget.toFixed(2)}`)); + line(kv('current', `$${status.currentCost.toFixed(4)}`)); + line(kv('remaining', `$${status.remaining.toFixed(4)}`)); + + const tone = + status.percentageUsed > 90 ? 'err' : status.percentageUsed > 75 ? 'warn' : 'ok'; + line(kv('used', `${status.percentageUsed}%`, tone)); + + line(); + if (!status.withinBudget) line(mark('err', 'budget exceeded')); + else if (status.percentageUsed > 90) line(mark('warn', 'approaching budget limit')); + else line(mark('ok', 'within budget')); + line(); + tracker.close(); }); -// Track commands -const trackCmd = program.command('track').description('Application tracking commands'); +// ── track ────────────────────────────────────────────────────────────── +const trackCmd = program.command('track').description('Application tracking'); trackCmd .command('list') .description('List all applications') - .option('-s, --status ', 'Filter by status (drafted, ready, submitted, interview, rejected, offer)') + .option('-s, --status ', 'filter by status') .action((options) => { + showBanner(); const db = DatabaseManager.getInstance(); const appRepo = new ApplicationRepository(db.getDatabase()); - const apps = appRepo.findAll(options.status ? { status: options.status } : undefined); - + if (apps.length === 0) { - console.log(chalk.yellow('No applications found.')); - console.log(chalk.gray('Use "applypilot analyze --save" to add jobs.')); - } else { - console.log(chalk.blue.bold(`\n📋 Applications (${apps.length}):\n`)); - - apps.forEach(app => { - const statusColor = { - drafted: chalk.gray, - ready: chalk.blue, - submitted: chalk.cyan, - interview: chalk.yellow, - rejected: chalk.red, - offer: chalk.green, - no_reply: chalk.gray, - withdrawn: chalk.gray, - }[app.status] || chalk.white; - - console.log(`${statusColor('●')} ${app.id.substring(0, 8)}...`); - console.log(` Status: ${statusColor(app.status)}`); - console.log(` Last Update: ${app.lastUpdate}`); - if (app.notes) console.log(` Notes: ${app.notes}`); - console.log(); - }); + line(rule('// APPLICATIONS · 0')); + line(mark('dot', 'no applications found')); + line(mark('arrow', 'applypilot analyze --save')); + line(); + db.close(); + return; + } + + const statusTone = (s: string) => + ({ + drafted: 'muted', + ready: 'signal', + submitted: 'default', + interview: 'warn', + rejected: 'err', + offer: 'ok', + no_reply: 'muted', + withdrawn: 'muted', + })[s] as 'muted' | 'signal' | 'default' | 'warn' | 'err' | 'ok' | undefined; + + line(rule(`// APPLICATIONS · ${apps.length}`)); + line(); + for (const app of apps) { + line(` ${chip(app.status, statusTone(app.status) ?? 'default')} ${c.muted(app.id.substring(0, 8))}…`); + line(kv('updated', app.lastUpdate, 'muted')); + if (app.notes) line(kv('notes', app.notes, 'muted')); + line(); } - db.close(); }); @@ -541,45 +612,39 @@ trackCmd .command('stats') .description('Show application statistics') .action(() => { + showBanner(); const db = DatabaseManager.getInstance(); const appRepo = new ApplicationRepository(db.getDatabase()); - const stats = appRepo.getStats(); - - console.log(chalk.blue.bold('\n📊 Application Statistics\n')); - - const statusColors: Record string> = { - drafted: chalk.gray, - ready: chalk.blue, - submitted: chalk.cyan, - interview: chalk.yellow, - rejected: chalk.red, - offer: chalk.green, - no_reply: chalk.gray, - withdrawn: chalk.gray, - total: chalk.white.bold, - }; - - Object.entries(stats).forEach(([status, count]) => { - const color = statusColors[status] || chalk.white; - const label = status.charAt(0).toUpperCase() + status.slice(1).replace('_', ' '); - console.log(`${color('●')} ${label.padEnd(12)} ${count.toString().padStart(3)}`); - }); - + + line(rule('// STATS')); + line( + table( + [ + { header: 'stage', key: 's' }, + { header: 'count', key: 'n', align: 'right' }, + ], + Object.entries(stats).map(([s, n]) => ({ + s: s.replace('_', ' '), + n: String(n), + })) + ) + ); + line(); db.close(); }); trackCmd .command('add') - .description('Add a new job manually') - .requiredOption('-t, --title ', 'Job title') - .requiredOption('-c, --company <company>', 'Company name') - .option('-u, --url <url>', 'Job URL') - .option('-p, --portal <portal>', 'Job portal (linkedin, indeed, etc.)') + .description('Add a job manually') + .requiredOption('-t, --title <title>', 'job title') + .requiredOption('-c, --company <company>', 'company name') + .option('-u, --url <url>', 'job URL') + .option('-p, --portal <portal>', 'job portal (linkedin, indeed, etc.)') .action((options) => { + showBanner(); const db = DatabaseManager.getInstance(); const jobRepo = new JobRepository(db.getDatabase()); - const job = jobRepo.create({ id: generateId('job'), source: 'paste', @@ -592,276 +657,251 @@ trackCmd fitScore: 0, status: 'new', }); - - console.log(chalk.green(`✓ Added job: ${options.title} at ${options.company}`)); - console.log(chalk.gray(` ID: ${job.id}`)); - + line(rule('// ADDED')); + line(kv('title', options.title)); + line(kv('company', options.company)); + line(kv('id', job.id, 'signal')); + line(); db.close(); }); -// Onboard command - wizard setup +// ── onboard ──────────────────────────────────────────────────────────── program .command('onboard') - .description('Interactive setup wizard for new users') - .option('--quick', 'Quick setup mode (skip optional steps)') - .action(async (options) => { - console.log(chalk.blue.bold('\n🚀 Welcome to ApplyPilot!\n')); - console.log(chalk.gray('Let\'s get you set up for your job search journey.\n')); - - const mode = options.quick ? 'Quick' : 'Advanced'; - console.log(chalk.cyan(`Mode: ${mode} Setup\n`)); - - // Step 1: Check prerequisites - console.log(chalk.blue('Step 1: Checking Prerequisites\n')); - - const checks = { - node: false, - git: false, - latex: false, - llm: false, - }; - - // Check Node.js - try { - const nodeVersion = process.version; - checks.node = true; - console.log(chalk.green('✓ Node.js'), chalk.gray(nodeVersion)); - } catch { - console.log(chalk.red('✗ Node.js not found')); - } - - // Check Git - try { - checks.git = true; - console.log(chalk.green('✓ Git'), chalk.gray('installed')); - } catch { - console.log(chalk.yellow('⚠ Git not found (optional)')); - } - - // Step 2: LLM Configuration - console.log(chalk.blue('\nStep 2: LLM Configuration\n')); - - console.log(chalk.white('Choose your LLM provider:\n')); - console.log(chalk.gray(' 1. Ollama (local, free)')); - console.log(chalk.gray(' 2. OpenAI (GPT-4, requires API key)')); - console.log(chalk.gray(' 3. Anthropic (Claude, requires API key)')); - console.log(chalk.gray(' 4. Skip for now\n')); + .description('Interactive setup for new users') + .option('--quick', 'quick setup (skip optional steps)') + .action((options) => { + showBanner(); + line(rule('// ONBOARD')); + line(kv('mode', options.quick ? 'quick' : 'advanced')); - // In a real implementation, this would use inquirer for interactive prompts - // For now, show what the wizard would do - console.log(chalk.cyan('→ Would prompt for LLM provider selection')); - console.log(chalk.cyan('→ Would configure API keys if needed')); - console.log(chalk.cyan('→ Would test connection\n')); + line(step(1, 5, 'prerequisites')); + line(kv('node', process.version, 'ok')); + line(kv('git', 'installed', 'ok')); - // Step 3: Profile Setup - console.log(chalk.blue('Step 3: Profile Setup\n')); + line(step(2, 5, 'llm configuration')); + line(mark('arrow', 'ollama — local, free')); + line(mark('arrow', 'openai — gpt-4, requires API key')); + line(mark('arrow', 'anthropic — claude, requires API key')); + line(mark('arrow', 'skip — configure later via applypilot set-llm')); + line(step(3, 5, 'profile')); const profilePath = './data/profile.json'; if (existsSync(profilePath)) { - console.log(chalk.green('✓ Profile file already exists')); + line(mark('ok', 'profile file exists')); try { const profile = JSON.parse(readFileSync(profilePath, 'utf-8')); - console.log(chalk.gray(` Name: ${profile.personal?.firstName} ${profile.personal?.lastName}`)); + line(kv('name', `${profile.personal?.firstName ?? ''} ${profile.personal?.lastName ?? ''}`.trim())); } catch { - console.log(chalk.yellow(' ⚠ Profile file may be invalid')); + line(mark('warn', 'profile file present but invalid JSON')); } } else { - console.log(chalk.cyan('→ Would create profile.json from template')); - console.log(chalk.cyan('→ Would prompt for basic information:\n')); - console.log(chalk.gray(' • Name')); - console.log(chalk.gray(' • Email')); - console.log(chalk.gray(' • Phone')); - console.log(chalk.gray(' • Location')); - console.log(chalk.gray(' • Target roles')); - console.log(chalk.gray(' • Skills\n')); + line(mark('arrow', 'will create from template on first run')); } - // Step 4: Resume Template - console.log(chalk.blue('Step 4: Resume Template\n')); - - const templatePath = './resumes/base/resume.tex'; - if (existsSync(templatePath)) { - console.log(chalk.green('✓ Resume template found')); - } else { - console.log(chalk.cyan('→ Would create resume.tex from example template')); - console.log(chalk.cyan('→ Would customize with your profile information\n')); - } - - // Step 5: Database Setup - console.log(chalk.blue('Step 5: Database Setup\n')); + line(step(4, 5, 'resume template')); + line( + existsSync('./resumes/base/resume.tex') + ? mark('ok', 'resume template found') + : mark('arrow', 'cp resumes/base/resume.example.tex resumes/base/resume.tex') + ); + line(step(5, 5, 'database')); try { const db = DatabaseManager.getInstance(); - console.log(chalk.green('✓ Database initialized')); + line(mark('ok', 'database ready')); db.close(); - } catch (error) { - console.log(chalk.red('✗ Database setup failed')); + } catch { + line(mark('err', 'database setup failed')); } - // Summary - console.log(chalk.blue('\n📋 Setup Summary\n')); + line(); + line(rule('// NEXT')); + line(mark('arrow', 'edit your profile: data/profile.json')); + line(mark('arrow', 'customize resume: resumes/base/resume.tex')); + line(mark('arrow', 'applypilot doctor')); + line(mark('arrow', 'applypilot analyze --file job.pdf')); + + line(); + line(rule('// LINKS')); + line(mark('dot', link('docs', 'https://applypilot.dev/docs'))); + line(mark('dot', link('issues', 'https://github.com/lavkushry/ApplyBot/issues'))); + line(); + }); - if (options.quick) { - console.log(chalk.gray('Quick setup completed with defaults.')); - console.log(chalk.gray('You can customize settings later using:')); - console.log(chalk.white(' applypilot config')); - console.log(chalk.white(' applypilot set-llm\n')); - } else { - console.log(chalk.gray('Advanced setup would guide through:')); - console.log(chalk.gray(' • LLM provider selection and configuration')); - console.log(chalk.gray(' • Profile creation with detailed information')); - console.log(chalk.gray(' • Resume template customization')); - console.log(chalk.gray(' • Job search preferences')); - console.log(chalk.gray(' • Notification settings\n')); +// ── health ───────────────────────────────────────────────────────────── +program + .command('health') + .description('Liveness check — fast, no external calls') + .action(() => { + const payload = { + ok: true, + service: 'applypilot-cli', + version: VERSION, + node: process.version, + uptimeSeconds: Math.floor(process.uptime()), + timestamp: new Date().toISOString(), + }; + if (program.opts().json) { + emit(payload); + return; + } + showBanner(); + line(rule('// HEALTH')); + line(kv('service', payload.service)); + line(kv('version', payload.version, 'signal')); + line(kv('node', payload.node)); + line(kv('uptime', `${payload.uptimeSeconds}s`)); + line(kv('status', 'live', 'ok')); + line(); + }); + +// ── status ───────────────────────────────────────────────────────────── +program + .command('status') + .description('Pipeline readout — jobs + applications + recent cost') + .action(() => { + const db = DatabaseManager.getInstance(); + const jobRepo = new JobRepository(db.getDatabase()); + const appRepo = new ApplicationRepository(db.getDatabase()); + const tracker = getCostTracker(); + + const jobs = jobRepo.findAll(); + const stats = appRepo.getStats(); + const cost = tracker.getCurrentMonthCost(); + + if (program.opts().json) { + emit({ jobs: jobs.length, stats, costThisMonth: cost }); + db.close(); + tracker.close(); + return; } - console.log(chalk.green.bold('✓ Onboarding complete!\n')); - console.log(chalk.white('Next steps:')); - console.log(chalk.gray(' 1. Edit your profile: data/profile.json')); - console.log(chalk.gray(' 2. Customize your resume: resumes/base/resume.tex')); - console.log(chalk.gray(' 3. Run doctor to verify: applypilot doctor')); - console.log(chalk.gray(' 4. Analyze your first job: applypilot analyze --file job.pdf\n')); - - console.log(chalk.blue('Need help?')); - console.log(chalk.gray(' Documentation: https://applypilot.dev/docs')); - console.log(chalk.gray(' Community: https://discord.gg/applypilot')); - console.log(chalk.gray(' Issues: https://github.com/applypilot/applypilot/issues\n')); + showBanner(); + line(rule('// PIPELINE')); + line(kv('jobs', String(jobs.length))); + line(kv('offers', String(stats.offer), 'ok')); + line(kv('interviews', String(stats.interview), 'warn')); + line(kv('submitted', String(stats.submitted))); + line(kv('rejected', String(stats.rejected), 'err')); + + line(); + line(rule('// COST · month')); + line(kv('spent', `$${cost.toFixed(4)}`, 'signal')); + line(); + + db.close(); + tracker.close(); }); -// Doctor command - check setup +// ── doctor ───────────────────────────────────────────────────────────── program .command('doctor') - .description('Check system setup and dependencies') + .description('Deep environment inspection') .action(async () => { - console.log(chalk.blue.bold('🔍 Checking ApplyPilot setup...\n')); - + showBanner(); + line(rule('// DOCTOR')); let allGood = true; - - // Check config + try { - const config = getConfig(); - console.log(chalk.green('✓ Configuration loaded')); - console.log(chalk.gray(` Path: ${ConfigManager.getInstance().getConfigPathLocation()}`)); - } catch (error) { - console.log(chalk.red('✗ Configuration error')); + getConfig(); + line(mark('ok', 'configuration loaded')); + line(kv('path', ConfigManager.getInstance().getConfigPathLocation(), 'muted')); + } catch { + line(mark('err', 'configuration error')); allGood = false; } - - // Check database + try { const db = DatabaseManager.getInstance(); - console.log(chalk.green('✓ Database connected')); + line(mark('ok', 'database connected')); db.close(); - } catch (error) { - console.log(chalk.red('✗ Database connection failed')); + } catch { + line(mark('err', 'database connection failed')); allGood = false; } - - // Check LLM + + line(); + line(rule('// LLM')); try { const config = ConfigManager.getInstance(); const llmConfig = config.getLLMConfig(); - - console.log(chalk.blue('\n🤖 LLM Configuration:')); - console.log(chalk.gray(` Provider: ${llmConfig.provider}`)); - console.log(chalk.gray(` Model: ${llmConfig.model}`)); - + line(kv('provider', llmConfig.provider, 'signal')); + line(kv('model', llmConfig.model)); const isLocal = LLMFactory.isLocalProvider(llmConfig.provider); - + if (!isLocal) { - console.log(chalk.yellow(' ⚠ Using external API provider')); const apiKey = config.getAPIKey(); - if (apiKey) { - console.log(chalk.green(' ✓ API key configured')); - } else { - console.log(chalk.red(` ✗ API key not found (set ${config.getAPIKeyEnvVar()})`)); + if (apiKey) line(mark('ok', 'api key configured')); + else { + line(mark('err', `api key not set — export ${config.getAPIKeyEnvVar()}`)); allGood = false; } } - - // Test connection + const adapter = LLMFactory.createAdapter({ ...llmConfig, apiKey: config.getAPIKey() || '', }); - const validation = adapter.validateConfig(); if (!validation.valid) { - console.log(chalk.red(' ✗ Configuration validation failed:')); - validation.errors.forEach(err => console.log(chalk.red(` • ${err}`))); + validation.errors.forEach((err) => line(mark('err', err))); allGood = false; } else { - console.log(chalk.blue(' Testing connection...')); const health = await adapter.healthCheck(); if (health.available) { - console.log(chalk.green(` ✓ LLM connection successful (${health.latency}ms)`)); - if (health.model) { - console.log(chalk.gray(` Model: ${health.model}`)); - } + line(mark('ok', `connection ok · ${health.latency}ms`)); + if (health.model) line(kv('health-model', health.model, 'muted')); } else { - console.log(chalk.red(` ✗ LLM connection failed: ${health.error}`)); - if (isLocal) { - console.log(chalk.gray(' Make sure Ollama is running: ollama serve')); - } + line(mark('err', `connection failed: ${health.error ?? 'unknown'}`)); + if (isLocal) line(mark('arrow', 'start ollama: ollama serve')); allGood = false; } } - } catch (error) { - console.log(chalk.red('✗ LLM check failed')); + } catch { + line(mark('err', 'llm check failed')); allGood = false; } - - // Check LaTeX + + line(); + line(rule('// LATEX')); try { - const config = getConfig(); - const compiler = new PDFCompiler(config.latex); + const cfg = getConfig(); + const compiler = new PDFCompiler(cfg.latex); const check = await compiler.checkEngine(); - if (check.available) { - console.log(chalk.green(`\n✓ LaTeX engine available: ${check.engine}`)); - if (check.version) { - console.log(chalk.gray(` Version: ${check.version}`)); - } + line(mark('ok', `engine: ${check.engine}`)); + if (check.version) line(kv('version', check.version, 'muted')); } else { - console.log(chalk.red('\n✗ LaTeX engine not found')); - console.log(chalk.gray(PDFCompiler.getInstallInstructions())); + line(mark('err', 'latex engine not found')); + line(c.muted(PDFCompiler.getInstallInstructions())); allGood = false; } - } catch (error) { - console.log(chalk.red('\n✗ LaTeX check failed')); + } catch { + line(mark('err', 'latex check failed')); allGood = false; } - - // Check profile files - const profilePath = './data/profile.json'; - if (existsSync(profilePath)) { - console.log(chalk.green('\n✓ Profile file found')); - try { - const profile = JSON.parse(readFileSync(profilePath, 'utf-8')); - console.log(chalk.gray(` Name: ${profile.personal?.firstName} ${profile.personal?.lastName}`)); - } catch { - console.log(chalk.yellow(' ⚠ Profile file exists but may be invalid JSON')); - } - } else { - console.log(chalk.yellow('\n⚠ Profile file not found')); - console.log(chalk.gray(' Run: cp data/profile.example.json data/profile.json')); - } - - // Check resume template - const templatePath = './resumes/base/resume.tex'; - if (existsSync(templatePath)) { - console.log(chalk.green('✓ Resume template found')); - } else { - console.log(chalk.yellow('⚠ Resume template not found')); - console.log(chalk.gray(' Run: cp resumes/base/resume.example.tex resumes/base/resume.tex')); + + line(); + line(rule('// FILES')); + if (existsSync('./data/profile.json')) line(mark('ok', 'data/profile.json')); + else { + line(mark('warn', 'data/profile.json not found')); + allGood = false; } - - console.log(); - if (allGood) { - console.log(chalk.green.bold('✓ All checks passed! ApplyPilot is ready to use.')); - } else { - console.log(chalk.yellow.bold('⚠ Some checks failed. Please fix the issues above.')); + if (existsSync('./resumes/base/resume.tex')) line(mark('ok', 'resumes/base/resume.tex')); + else { + line(mark('warn', 'resumes/base/resume.tex not found')); + allGood = false; } + + line(); + line(rule('// RESULT')); + if (allGood) line(mark('ok', 'all checks passed — ready')); + else line(mark('warn', 'some checks failed — see above')); + line(); + + process.exitCode = allGood ? 0 : 1; }); program.parse(); diff --git a/apps/cli/src/lib/palette.ts b/apps/cli/src/lib/palette.ts new file mode 100644 index 0000000..8885fed --- /dev/null +++ b/apps/cli/src/lib/palette.ts @@ -0,0 +1,55 @@ +/** + * palette.ts — the marble seam for the CLI. + * + * Single source of truth for CLI colours. Mirrors the web app's design + * tokens (ivory / graphite / bone / vein / signal rust) and the API's + * marble.ts so the whole stack reads as one object. + * + * The palette is TTY-aware: when stdout is not a terminal, when + * `--no-color` is set, or when `NO_COLOR` is present in the environment, + * all styling collapses to plain text. + */ + +const CSI = '\x1b['; +const RESET = `${CSI}0m`; + +let disabled = false; + +export const setColorEnabled = (on: boolean): void => { + disabled = !on; +}; + +const isTTY = (): boolean => { + if (disabled) return false; + if (process.env.NO_COLOR) return false; + if (process.env.FORCE_COLOR) return true; + return Boolean(process.stdout.isTTY); +}; + +const wrap = (open: string) => (s: string) => + isTTY() ? `${CSI}${open}m${s}${RESET}` : s; + +// 256-colour graphite ramp; deliberately constrained. +export const c = { + ivory: wrap('38;5;230'), + bone: wrap('38;5;223'), + graphite: wrap('38;5;250'), + muted: wrap('38;5;244'), + vein: wrap('38;5;240'), + nero: wrap('38;5;236'), + signal: wrap('38;5;166'), + signalDim: wrap('38;5;130'), + ok: wrap('38;5;108'), + warn: wrap('38;5;179'), + err: wrap('38;5;167'), + dim: wrap('2'), + bold: wrap('1'), + under: wrap('4'), +}; + +/** OSC-8 hyperlink; falls back to plain text when colour is disabled. */ +export const link = (label: string, url: string): string => { + if (!isTTY()) return `${label} (${url})`; + const BEL = '\x07'; + return `\x1b]8;;${url}${BEL}${label}\x1b]8;;${BEL}`; +}; diff --git a/apps/cli/src/lib/ui.ts b/apps/cli/src/lib/ui.ts new file mode 100644 index 0000000..b4f5f28 --- /dev/null +++ b/apps/cli/src/lib/ui.ts @@ -0,0 +1,173 @@ +/** + * ui.ts — composition primitives over palette.ts. + * + * Keeps output consistently mono-marble across every command: + * • banner(title, fields) — headline spec sheet. + * • rule(label, width) — section divider `── // LABEL ───`. + * • kv(label, value) — two-column readout. + * • chip(label, tone) — outlined status chip. + * • step(index, total, label) — procedural marker `[2/5] label`. + * • mark(kind, text) — replaces emoji ticks. + * • table(rows) — mono-aligned table. + */ + +import { c } from './palette.js'; + +export type Tone = 'default' | 'muted' | 'signal' | 'ok' | 'warn' | 'err'; + +const toneFn = (t: Tone) => { + switch (t) { + case 'muted': + return c.muted; + case 'signal': + return c.signal; + case 'ok': + return c.ok; + case 'warn': + return c.warn; + case 'err': + return c.err; + default: + return c.ivory; + } +}; + +const visibleLen = (s: string): number => + s.replace(/\x1b\[[0-9;]*m/g, '').length; + +const pad = (s: string, n: number): string => { + const diff = n - visibleLen(s); + return diff <= 0 ? s : s + ' '.repeat(diff); +}; + +const padL = (s: string, n: number): string => { + const diff = n - visibleLen(s); + return diff <= 0 ? s : ' '.repeat(diff) + s; +}; + +export const WIDTH = 72; + +export const rule = (label = '', width = WIDTH): string => { + const tag = label ? ` ${label} ` : ''; + const remaining = Math.max(0, width - tag.length - 2); + return c.vein('──') + c.graphite(tag) + c.vein('─'.repeat(remaining)); +}; + +export interface BannerField { + key: string; + value: string; + tone?: Tone; +} + +export const banner = (title: string, fields: BannerField[] = []): string => { + const w = WIDTH; + const top = c.vein('┌' + '─'.repeat(w - 2) + '┐'); + const bot = c.vein('└' + '─'.repeat(w - 2) + '┘'); + const sep = c.vein('│'); + + const lines: string[] = [top]; + const titleBody = ` ${c.bold(c.ivory(title))}`; + lines.push(sep + titleBody + ' '.repeat(Math.max(0, w - 2 - visibleLen(titleBody))) + sep); + + if (fields.length) { + lines.push(sep + ' '.repeat(w - 2) + sep); + for (const f of fields) { + const key = c.muted(pad(f.key.toUpperCase(), 10)); + const val = toneFn(f.tone ?? 'default')(f.value); + const body = ` ${key} ${c.vein('│')} ${val}`; + lines.push(sep + body + ' '.repeat(Math.max(0, w - 2 - visibleLen(body))) + sep); + } + } + + lines.push(bot); + return lines.join('\n'); +}; + +export const kv = (label: string, value: string, tone: Tone = 'default'): string => { + const key = c.muted(pad(label.toUpperCase(), 14)); + return ` ${key} ${c.vein('│')} ${toneFn(tone)(value)}`; +}; + +export const step = (i: number, total: number, label: string): string => { + const idx = c.muted(`[${i}/${total}]`); + return `\n${idx} ${c.ivory(label)}`; +}; + +export const mark = ( + kind: 'ok' | 'warn' | 'err' | 'dot' | 'arrow', + text: string +): string => { + switch (kind) { + case 'ok': + return `${c.ok('✓')} ${c.ivory(text)}`; + case 'warn': + return `${c.warn('!')} ${c.ivory(text)}`; + case 'err': + return `${c.err('✗')} ${c.ivory(text)}`; + case 'arrow': + return `${c.muted('→')} ${c.graphite(text)}`; + case 'dot': + default: + return `${c.muted('·')} ${c.graphite(text)}`; + } +}; + +export const chip = (label: string, tone: Tone = 'default'): string => { + const up = label.toUpperCase(); + const dot = + tone === 'ok' + ? c.ok('●') + : tone === 'warn' + ? c.warn('●') + : tone === 'err' + ? c.err('●') + : tone === 'signal' + ? c.signal('●') + : c.muted('●'); + const body = `${dot} ${c.ivory(up)}`; + return `${c.vein('[')} ${body} ${c.vein(']')}`; +}; + +export interface Column { + header: string; + key: string; + align?: 'left' | 'right'; + width?: number; +} + +export const table = ( + cols: Column[], + rows: Array<Record<string, string>> +): string => { + const widths = cols.map( + (col) => + col.width ?? + Math.max( + col.header.length, + ...rows.map((r) => visibleLen(r[col.key] ?? '')) + ) + ); + + const align = (col: Column, val: string, w: number) => + col.align === 'right' ? padL(val, w) : pad(val, w); + + const headerLine = cols + .map((col, i) => c.muted(align(col, col.header.toUpperCase(), widths[i]!))) + .join(c.vein(' │ ')); + + const sep = cols + .map((_, i) => c.vein('─'.repeat(widths[i]!))) + .join(c.vein('──┼──')); + + const bodyLines = rows.map((r) => + cols + .map((col, i) => align(col, r[col.key] ?? '', widths[i]!)) + .join(c.vein(' │ ')) + ); + + return [' ' + headerLine, ' ' + sep, ...bodyLines.map((l) => ' ' + l)].join('\n'); +}; + +export const line = (s = ''): void => { + process.stdout.write(s + '\n'); +};