import path from "path"; import type { Snapshot, RestoreResult, ConfigSnapshot } from "./types"; import type { RoutesManifest } from "../spec/schema"; import { readLockfile, writeLockfile, generateLockfile, LOCKFILE_PATH, readMcpConfig, } from "../lockfile"; import { validateAndReport } from "../config"; const MANDU_DIR = ".mandu"; const SPEC_DIR = "spec"; const MANIFEST_FILE = "routes.manifest.json"; const SLOTS_DIR = "slots"; const HISTORY_DIR = "history"; const SNAPSHOTS_DIR = "snapshots"; function reverseCopy(items: readonly T[]): T[] { const reversed: T[] = []; for (let index = items.length - 1; index >= 0; index -= 1) { reversed.push(items[index] as T); } return reversed; } /** * 스냅샷 ID 생성 (YYYYMMDD-HHmmss-xxx) */ function generateSnapshotId(): string { const now = new Date(); const date = now.toISOString().slice(0, 10).replace(/-/g, ""); const time = now.toISOString().slice(11, 19).replace(/:/g, ""); const random = Math.random().toString(36).slice(2, 5); return `${date}-${time}-${random}`; } /** * 스냅샷 저장 경로 반환 */ function getSnapshotPath(rootDir: string, snapshotId: string): string { return path.join(rootDir, MANDU_DIR, HISTORY_DIR, SNAPSHOTS_DIR, `${snapshotId}.snapshot.json`); } /** * Slot 파일들의 내용을 수집 */ async function collectSlotContents(rootDir: string): Promise> { const slotsDir = path.join(rootDir, SPEC_DIR, SLOTS_DIR); const contents: Record = {}; try { const entries = await Array.fromAsync( new Bun.Glob("**/*.ts").scan({ cwd: slotsDir, onlyFiles: true, }) ); await Promise.all( entries.map(async (entry) => { const filePath = path.join(slotsDir, entry); const file = Bun.file(filePath); if (await file.exists()) { contents[entry] = await file.text(); } }) ); } catch { // slots 디렉토리가 없는 경우 빈 객체 반환 } return contents; } /** * 현재 spec 상태의 스냅샷 생성 */ export async function createSnapshot(rootDir: string): Promise { const manduDir = path.join(rootDir, MANDU_DIR); const manifestPath = path.join(manduDir, MANIFEST_FILE); // Manifest 읽기 (필수) const manifestFile = Bun.file(manifestPath); if (!(await manifestFile.exists())) { throw new Error(`Manifest not found: ${manifestPath}`); } const manifest: RoutesManifest = await manifestFile.json(); // Lock은 FS-First 전환으로 제거됨 - 하위호환을 위해 null 유지 const lock = null; // Slot 내용 수집 const slotContents = await collectSlotContents(rootDir); // Config 스냅샷 생성 (lockfile 포함) let configSnapshot: ConfigSnapshot | undefined; try { const config = await validateAndReport(rootDir); if (config) { const existingLockfile = await readLockfile(rootDir); let mcpConfig: Record | null = null; try { mcpConfig = await readMcpConfig(rootDir); } catch { // ignore MCP config errors in snapshot } const lockfile = existingLockfile ?? generateLockfile(config, { includeSnapshot: true }, mcpConfig); configSnapshot = { lockfile, configHash: lockfile.configHash, }; } } catch { // Config 로드 실패 시 스킵 } const id = generateSnapshotId(); return { id, timestamp: new Date().toISOString(), manifest, lock, slotContents, configSnapshot, }; } /** * 스냅샷 파일 읽기 */ export async function readSnapshot(snapshotPath: string): Promise { try { const file = Bun.file(snapshotPath); if (!(await file.exists())) { return null; } return await file.json(); } catch { return null; } } /** * 스냅샷 파일 저장 */ export async function writeSnapshot(rootDir: string, snapshot: Snapshot): Promise { const snapshotPath = getSnapshotPath(rootDir, snapshot.id); const snapshotDir = path.dirname(snapshotPath); // 디렉토리 생성 await Bun.write(path.join(snapshotDir, ".gitkeep"), ""); // 스냅샷 저장 await Bun.write(snapshotPath, JSON.stringify(snapshot, null, 2)); } /** * 스냅샷 ID로 스냅샷 읽기 */ export async function readSnapshotById(rootDir: string, snapshotId: string): Promise { const snapshotPath = getSnapshotPath(rootDir, snapshotId); return readSnapshot(snapshotPath); } /** * 스냅샷으로부터 상태 복원 */ export async function restoreSnapshot(rootDir: string, snapshot: Snapshot): Promise { const manduDir = path.join(rootDir, MANDU_DIR); const manifestPath = path.join(manduDir, MANIFEST_FILE); const slotsDir = path.join(rootDir, SPEC_DIR, SLOTS_DIR); const restoredFiles: string[] = []; const failedFiles: string[] = []; const errors: string[] = []; // 1. Manifest 복원 try { await Bun.write(manifestPath, JSON.stringify(snapshot.manifest, null, 2)); restoredFiles.push(MANIFEST_FILE); } catch (error) { failedFiles.push(MANIFEST_FILE); errors.push(`Failed to restore manifest: ${error instanceof Error ? error.message : String(error)}`); } // 2. Slot 파일들 복원 for (const [relativePath, content] of Object.entries(snapshot.slotContents)) { const filePath = path.join(slotsDir, relativePath); try { // 디렉토리 확보 const dir = path.dirname(filePath); await Bun.write(path.join(dir, ".gitkeep"), ""); await Bun.write(filePath, content); restoredFiles.push(`${SLOTS_DIR}/${relativePath}`); } catch (error) { failedFiles.push(`${SLOTS_DIR}/${relativePath}`); errors.push( `Failed to restore slot ${relativePath}: ${error instanceof Error ? error.message : String(error)}` ); } } // 3. Config Lockfile 복원 (있는 경우) if (snapshot.configSnapshot) { try { await writeLockfile(rootDir, snapshot.configSnapshot.lockfile); restoredFiles.push(LOCKFILE_PATH); } catch (error) { failedFiles.push(LOCKFILE_PATH); errors.push( `Failed to restore config lockfile: ${error instanceof Error ? error.message : String(error)}` ); } } return { success: failedFiles.length === 0, restoredFiles, failedFiles, errors, }; } /** * 스냅샷 삭제 */ export async function deleteSnapshot(rootDir: string, snapshotId: string): Promise { const snapshotPath = getSnapshotPath(rootDir, snapshotId); try { const file = Bun.file(snapshotPath); if (await file.exists()) { const { unlink } = await import("fs/promises"); await unlink(snapshotPath); return true; } return false; } catch { return false; } } /** * 모든 스냅샷 ID 목록 조회 */ export async function listSnapshotIds(rootDir: string): Promise { const snapshotsDir = path.join(rootDir, MANDU_DIR, HISTORY_DIR, SNAPSHOTS_DIR); try { const entries = await Array.fromAsync( new Bun.Glob("*.snapshot.json").scan({ cwd: snapshotsDir, onlyFiles: true, }) ); return reverseCopy( entries .map((entry) => entry.replace(".snapshot.json", "")) .sort() ); // 최신 순 } catch { return []; } }