import * as fs from 'fs'; import * as path from 'path'; import * as readline from 'readline'; import chalk from 'chalk'; import { readConfig, readState, writeState, getProjectRoot, SyncState } from '../config'; import { ApiClient } from '../api'; import { sanitizeName, buildFolderPath, writeEndpointToDisk, findEndpointDirs, readEndpointFromDisk, } from '../files'; import { computeEndpointHash } from '../hash'; function ask(question: string): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); return new Promise((resolve) => { rl.question(question, (answer: string) => { rl.close(); resolve(answer.trim()); }); }); } interface PullDiff { newEndpoints: { name: string; folder: string }[]; updatedEndpoints: { name: string; folder: string; serverDate: string }[]; deletedEndpoints: { name: string; folder: string }[]; unchangedCount: number; localConflicts: { name: string; folder: string }[]; // locally modified AND server modified } export async function pullCommand(force = false): Promise { const root = getProjectRoot(); const config = readConfig(root); const state = readState(root); const api = new ApiClient(config); console.log(chalk.gray('Pulling from server...')); const data = await api.pull(); const { endpoints, folders } = data; console.log( chalk.gray( `Server: ${endpoints.length} endpoints, ${folders.length} folders` ) ); // Build folders map const foldersMap = new Map(); for (const f of folders) { foldersMap.set(f.id, f); } // Detect local modifications const localModifiedIds = new Set(); if (state.last_sync) { const endpointDirs = findEndpointDirs(root); for (const dir of endpointDirs) { const ep = readEndpointFromDisk(dir); if (!ep || !ep.id) continue; const stateEntry = state.endpoints[ep.id]; if (!stateEntry || !stateEntry.hash) continue; const currentHash = computeEndpointHash(ep); if (currentHash !== stateEntry.hash) { localModifiedIds.add(ep.id); } } } // Build diff preview const diff: PullDiff = { newEndpoints: [], updatedEndpoints: [], deletedEndpoints: [], unchangedCount: 0, localConflicts: [], }; for (const ep of endpoints) { const folderName = ep.folder_name || '_no_folder'; if (!state.endpoints[ep.id]) { diff.newEndpoints.push({ name: ep.name, folder: folderName }); } else { const serverTime = new Date(ep.updated_at).getTime(); const stateTime = new Date(state.endpoints[ep.id].updated_at).getTime(); if (serverTime > stateTime) { // Server has a newer version if (localModifiedIds.has(ep.id)) { // CONFLICT: both local and server changed diff.localConflicts.push({ name: ep.name, folder: folderName }); } else { diff.updatedEndpoints.push({ name: ep.name, folder: folderName, serverDate: ep.updated_at, }); } } else { diff.unchangedCount++; } } } // Detect server deletions const serverIds = new Set(endpoints.map((e: any) => e.id)); for (const [id, info] of Object.entries(state.endpoints)) { if (!serverIds.has(id)) { const dirName = path.basename(info.folder_path); const parentName = path.basename(path.dirname(info.folder_path)); diff.deletedEndpoints.push({ name: dirName, folder: parentName }); } } // Show preview const totalChanges = diff.newEndpoints.length + diff.updatedEndpoints.length + diff.deletedEndpoints.length + diff.localConflicts.length; if (totalChanges === 0) { console.log(chalk.green('\nEverything is up to date.')); return; } console.log(chalk.bold('\nIncoming changes from server:\n')); for (const item of diff.newEndpoints) { console.log(chalk.green(` + new: ${item.folder}/${item.name}`)); } for (const item of diff.updatedEndpoints) { console.log(chalk.blue(` ~ updated: ${item.folder}/${item.name}`)); console.log(chalk.gray(` server updated: ${new Date(item.serverDate).toLocaleString()}`)); } for (const item of diff.deletedEndpoints) { console.log(chalk.red(` - deleted: ${item.folder}/${item.name}`)); } for (const item of diff.localConflicts) { console.log(chalk.redBright(` ! CONFLICT: ${item.folder}/${item.name}`)); console.log(chalk.redBright(` changed locally AND on server`)); } if (diff.unchangedCount > 0) { console.log(chalk.gray(`\n ${diff.unchangedCount} unchanged`)); } // Handle conflicts if (diff.localConflicts.length > 0 && !force) { console.log( chalk.yellow( `\n${diff.localConflicts.length} conflict(s): you edited these locally, but they were also changed on the server.` ) ); console.log(chalk.yellow('Options:')); console.log(chalk.yellow(' 1) "kisync pull --force" — overwrite your local changes with server version')); console.log(chalk.yellow(' 2) "kisync push" — push your changes first (server version will be overwritten)')); console.log(chalk.yellow(' 3) "kisync push --force" — force push if server also changed')); return; } // If there are locally modified files that DON'T conflict (server hasn't changed them), // those are safe — pull won't touch them. But warn if force is used. const safeLocalModified = [...localModifiedIds].filter( (id) => !diff.localConflicts.find((c) => { const ep = endpoints.find((e: any) => e.id === id); return ep && c.name === ep.name; }) ); if (force && safeLocalModified.length > 0) { console.log( chalk.yellow(`\n--force: ${safeLocalModified.length} locally modified endpoint(s) will be overwritten.`) ); } // Confirm if (!force) { const confirm = await ask('\nApply these changes? (y/N): '); if (confirm.toLowerCase() !== 'y') { console.log('Aborted.'); return; } } // Apply changes const newState: SyncState = { endpoints: {}, folders: {}, last_sync: '' }; // Create folder structure for (const folder of folders) { const folderPath = buildFolderPath(folder.id, foldersMap, root); fs.mkdirSync(folderPath, { recursive: true }); const folderMeta = { id: folder.id, name: folder.name, parent_id: folder.parent_id, }; fs.writeFileSync( path.join(folderPath, '_folder.json'), JSON.stringify(folderMeta, null, 2), 'utf-8' ); newState.folders[folder.id] = { updated_at: folder.updated_at, path: path.relative(root, folderPath), }; } // Write endpoints let applied = 0; let skipped = 0; for (const ep of endpoints) { let endpointDir: string; if (ep.folder_id && foldersMap.has(ep.folder_id)) { const folderPath = buildFolderPath(ep.folder_id, foldersMap, root); endpointDir = path.join(folderPath, sanitizeName(ep.name)); } else { endpointDir = path.join(root, '_no_folder', sanitizeName(ep.name)); } const isConflict = diff.localConflicts.some((c) => c.name === ep.name); // Skip conflicts unless force if (isConflict && !force) { // Keep local version in state but mark with server's updated_at if (state.endpoints[ep.id]) { newState.endpoints[ep.id] = state.endpoints[ep.id]; } skipped++; continue; } // If endpoint moved to different folder, clean up old location if (state.endpoints[ep.id]) { const oldRelPath = state.endpoints[ep.id].folder_path; const oldAbsPath = path.join(root, oldRelPath); const newRelPath = path.relative(root, endpointDir); if (oldRelPath !== newRelPath && fs.existsSync(oldAbsPath)) { fs.rmSync(oldAbsPath, { recursive: true, force: true }); } } writeEndpointToDisk(ep, endpointDir); // Хеш считаем от того, что реально записалось на диск, // чтобы он совпадал при последующем readEndpointFromDisk в status const diskEp = readEndpointFromDisk(endpointDir); const hash = computeEndpointHash(diskEp || ep); newState.endpoints[ep.id] = { updated_at: ep.updated_at, folder_path: path.relative(root, endpointDir), hash, }; applied++; } // Clean up endpoints deleted on server let deleted = 0; for (const [id, info] of Object.entries(state.endpoints)) { if (!serverIds.has(id)) { const oldPath = path.join(root, info.folder_path); if (fs.existsSync(oldPath)) { fs.rmSync(oldPath, { recursive: true, force: true }); deleted++; } } } cleanEmptyDirs(root); newState.last_sync = new Date().toISOString(); writeState(newState, root); // Summary console.log(''); console.log(chalk.green(`Pull complete: ${applied} applied, ${deleted} deleted, ${skipped} skipped.`)); } function cleanEmptyDirs(dir: string): void { if (!fs.existsSync(dir)) return; const entries = fs.readdirSync(dir); for (const entry of entries) { const fullPath = path.join(dir, entry); if (fs.statSync(fullPath).isDirectory()) { cleanEmptyDirs(fullPath); } } const remaining = fs.readdirSync(dir); if (remaining.length === 0 && dir !== process.cwd()) { fs.rmdirSync(dir); } }