- kisync update: show available releases from Gitea - kisync update --apply: download and replace exe in-place - Background update check after every command (non-blocking) - Fix: compute hash from disk-read object (not server object) to prevent false "modified" status after pull due to line-ending differences Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
306 lines
9.3 KiB
TypeScript
306 lines
9.3 KiB
TypeScript
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<string> {
|
|
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<void> {
|
|
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<string, any>();
|
|
for (const f of folders) {
|
|
foldersMap.set(f.id, f);
|
|
}
|
|
|
|
// Detect local modifications
|
|
const localModifiedIds = new Set<string>();
|
|
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);
|
|
}
|
|
}
|