import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; import { join, dirname } from 'node:path'; import { z } from 'zod'; const OllamaSettingsSchema = z.object({ endpoint: z.string().url(), model: z.string().min(1) }).strict(); const SettingsSchema = z.object({ ollama: OllamaSettingsSchema.optional(), // v0.2.9 Cut B — AI-less mode toggle. 기존 settings 파일에 없으면 isAiEnabled() 가 // true 로 fallback (기본 enabled). zod default 는 file 이 존재 + 키 부재일 때만 적용 — // load() 의 file-missing 분기에선 cache={} 라 isAiEnabled() 의 fallback 이 작동. ai_enabled: z.boolean().optional(), onboarding_completed: z.boolean().optional(), // v0.3.0 Cut E — 양방향 git sync 설정. 모두 optional — 미구성 시 sync 비활성. sync_repo_url: z.string().nullable().optional(), sync_auto_enabled: z.boolean().optional(), sync_interval_min: z.number().int().min(5).optional(), // v0.3.1 Cut F vision_model: z.string().nullable().optional(), vision_capable_cache: z.array(z.string()).optional(), vision_cache_at: z.string().optional(), // v0.4 Task 11 — promotion candidate 영속화 + sidebar 레이아웃. promotion_dismissed_tags: z.array(z.string()).optional(), promotion_snoozed_until_ms: z.number().int().optional(), sidebar_visible: z.boolean().optional(), sidebar_width: z.number().int().min(180).max(400).optional() }).strict(); export type Settings = z.infer; export type OllamaSettings = z.infer; export class SettingsService { private filePath: string; private cache: Settings | null = null; constructor(profileDir: string) { this.filePath = join(profileDir, 'settings.json'); } async load(): Promise { if (this.cache !== null) return this.cache; try { const raw = await readFile(this.filePath, 'utf8'); const parsed = JSON.parse(raw); this.cache = SettingsSchema.parse(parsed); } catch { this.cache = {}; } return this.cache; } /** * v0.2.9 Cut B Task 12 — settings:get IPC 핸들러용 read-only accessor. * 첫 launch onboarding 분기에서 onboarding_completed 키 확인. */ async getAll(): Promise { return this.load(); } async setOllama(value: OllamaSettings): Promise { const validated = OllamaSettingsSchema.parse(value); const current = await this.load(); const next: Settings = { ...current, ollama: validated }; await this.persist(next); } /** * v0.2.9 Cut B — AI-less mode 의 기본값은 enabled (true). 기존 settings 파일을 * 가진 사용자 (ai_enabled 키 부재) 도 무영향. */ async isAiEnabled(): Promise { const s = await this.load(); return s.ai_enabled ?? true; } async setAiEnabled(value: boolean): Promise { const current = await this.load(); const next: Settings = { ...current, ai_enabled: value }; await this.persist(next); } /** v0.2.9 Cut B — 첫 실행 onboarding completion 표지. 기본 false. */ async isOnboardingCompleted(): Promise { const s = await this.load(); return s.onboarding_completed ?? false; } async setOnboardingCompleted(value: boolean): Promise { const current = await this.load(); const next: Settings = { ...current, onboarding_completed: value }; await this.persist(next); } /** * v0.3.0 Cut E — sync 저장소 URL. null/빈 문자열 = sync 비활성. 본 메서드는 값만 저장, * git init/remote add 는 별도 호출자 (settings:configure-sync IPC) 가 담당. */ async getSyncRepoUrl(): Promise { const s = await this.load(); return s.sync_repo_url ?? null; } async setSyncRepoUrl(value: string | null): Promise { const current = await this.load(); const next: Settings = { ...current, sync_repo_url: value }; await this.persist(next); } /** v0.3.0 Cut E — 자동 주기 sync 활성. configured 일 때만 의미 있음. 기본 true. */ async isAutoSyncEnabled(): Promise { const s = await this.load(); return s.sync_auto_enabled ?? true; } async setAutoSyncEnabled(value: boolean): Promise { const current = await this.load(); const next: Settings = { ...current, sync_auto_enabled: value }; await this.persist(next); } /** v0.3.0 Cut E — 자동 주기 sync interval (분). 기본 30, min 5. */ async getSyncIntervalMin(): Promise { const s = await this.load(); return s.sync_interval_min ?? 30; } async setSyncIntervalMin(value: number): Promise { if (!Number.isInteger(value) || value < 5) { throw new Error(`sync_interval_min must be an integer >= 5 (got ${value})`); } const current = await this.load(); const next: Settings = { ...current, sync_interval_min: value }; await this.persist(next); } /** v0.3.1 Cut F — 선택된 vision model. null = 미선택. */ async getVisionModel(): Promise { const s = await this.load(); return s.vision_model ?? null; } async setVisionModel(value: string | null): Promise { const current = await this.load(); const next: Settings = { ...current, vision_model: value }; await this.persist(next); } /** v0.3.1 Cut F — /api/tags 조회 결과 캐시. 기본 빈 배열 + null timestamp. */ async getVisionCapableCache(): Promise<{ models: string[]; at: string | null }> { const s = await this.load(); return { models: s.vision_capable_cache ?? [], at: s.vision_cache_at ?? null }; } async setVisionCapableCache(models: string[], now: Date): Promise { const current = await this.load(); const next: Settings = { ...current, vision_capable_cache: models, vision_cache_at: now.toISOString() }; await this.persist(next); } // v0.4 Task 11 — promotion candidate 영속화. async getPromotionDismissedTags(): Promise { const s = await this.load(); return s.promotion_dismissed_tags ?? []; } async addPromotionDismissedTag(tag: string): Promise { const s = await this.load(); const list = new Set(s.promotion_dismissed_tags ?? []); list.add(tag); await this.persist({ ...s, promotion_dismissed_tags: [...list] }); } async getPromotionSnoozeUntil(): Promise { const s = await this.load(); return s.promotion_snoozed_until_ms ?? 0; } async setPromotionSnoozeUntil(ms: number): Promise { const s = await this.load(); await this.persist({ ...s, promotion_snoozed_until_ms: ms }); } // v0.4 Task 15 — sidebar 레이아웃 영속화. async getSidebarVisible(): Promise { const s = await this.load(); return s.sidebar_visible ?? true; } async setSidebarVisible(v: boolean): Promise { const s = await this.load(); await this.persist({ ...s, sidebar_visible: v }); } async getSidebarWidth(): Promise { const s = await this.load(); return s.sidebar_width ?? 240; } async setSidebarWidth(v: number): Promise { const s = await this.load(); await this.persist({ ...s, sidebar_width: v }); } private async persist(next: Settings): Promise { await mkdir(dirname(this.filePath), { recursive: true }); const tmpPath = this.filePath + '.tmp'; await writeFile(tmpPath, JSON.stringify(next, null, 2), 'utf8'); await rename(tmpPath, this.filePath); this.cache = next; } }