Something went wrong. Try again.
A simple DAV sync application / cli
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230import { readFileSync } from "node:fs";import { currentPlaces, install, installed, launchCommand, planFor, uninstall } from "./autostart.ts";import { ConfigStore, paths } from "./config.ts";import { runService, servicePid } from "./service.ts";import { discoverRoot, withScheme } from "./sync/discover.ts";import { syncFolder } from "./sync/engine.ts";import { listCollections } from "./sync/remote.ts";import { StateStore } from "./sync/state.ts";import type { Action, FolderConfig } from "./sync/types.ts";
const HELP = `davsync: two-way sync of local folders with WebDAV
usage: davsync [command] [folder...]
(no command) open the window: add and configure folders, sync them on demand, inspect their state and what each sync did service run the service: sync every enabled folder at its interval until stopped (what "install" starts at login) sync [folder...] sync once (all enabled folders, or the ones named) and exit plan [folder...] show what a sync would do, without doing it status list folders and how their last sync went install [--print] start the service whenever you log in (systemd user unit, LaunchAgent, or Startup folder); --print shows what it writes uninstall stop starting it at login, and stop it now discover <server> [user] find the WebDAV root behind a server's name, and say how (the password is read from DAVSYNC_PASSWORD, or asked for) version print the version
Folders are named by name or id. Configuration: ${paths.config}State: ${paths.database}`;
const VERSION = (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
const [command = "window", ...args] = process.argv.slice(2);
try { process.exitCode = await main(command, args);} catch (error) { console.error(`davsync: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1;}
async function main(command: string, args: string[]): Promise<number> { switch (command) { // "setup" and "run" are what these were called before; still accepted, so a // start-at-login entry written back then keeps working. case "window": case "setup": { const { openSetup } = await import("./ui/app.ts"); try { await openSetup(); } catch (error) { throw new Error(`could not open the window: ${error instanceof Error ? error.message : String(error)}\n(for the commands that need no window: davsync help)`); } return 0; } case "service": case "run": { const config = await new ConfigStore().load(); const store = new StateStore(paths.database); await runService(config, store, (line) => console.log(`${new Date().toISOString()} ${line}`)); store.close(); return 0; } case "sync": case "plan": return syncOnce(args, command === "plan"); case "status": return status(); case "discover": return discover(args); case "install": return installAutostart(args.includes("--print")); case "uninstall": return uninstallAutostart(); case "version": case "--version": console.log(`davsync ${VERSION}`); return 0; case "help": case "--help": case "-h": process.stdout.write(HELP); return 0; default: process.stderr.write(`davsync: unknown command “${command}”\n\n${HELP}`); return 64; }}
function chosenFolders(config: ConfigStore, names: string[]): FolderConfig[] { if (names.length === 0) return config.folders().filter((folder) => folder.enabled); return names.map((name) => { const folder = config.find(name); if (!folder) throw new Error(`no folder called “${name}”; see davsync status`); return folder; });}
async function syncOnce(names: string[], dryRun: boolean): Promise<number> { const config = await new ConfigStore().load(); const folders = chosenFolders(config, names); if (folders.length === 0) { console.log("No folders to sync. Add one in the window: davsync"); return 0; } const store = new StateStore(paths.database); let failed = false; for (const folder of folders) { const result = await syncFolder(folder, store, { dryRun, lockDir: paths.locks, password: (chosen) => config.password(chosen) }); console.log(`${folder.name}: ${result.status}${result.error ? ` — ${result.error}` : ""}`); result.plan?.actions.forEach((action, index) => { const outcome = result.outcomes[index]; const mark = dryRun ? "·" : outcome?.status === "done" ? "✓" : outcome?.status === "held" ? "⊘" : outcome?.status === "skipped" ? "–" : "✗"; console.log(` ${mark} ${describe(action)} (${action.reason}${outcome?.detail ? `; ${outcome.detail}` : ""})`); }); if (result.status === "failed" || result.status === "partial") failed = true; } store.close(); return failed ? 1 : 0;}
async function status(): Promise<number> { const config = await new ConfigStore().load(); const store = new StateStore(paths.database); const pid = servicePid(); console.log(pid ? `service: running (pid ${pid})` : "service: not running (start it with: davsync service, or at every login: davsync install)"); const autostart = await installed(planFor(launchCommand(), currentPlaces())).catch(() => null); console.log(autostart ? `starts at login: yes (${autostart})` : "starts at login: no (davsync install)"); for (const folder of config.folders()) { const [last] = store.runs(folder.id, 1); const when = last ? new Date(last.started).toLocaleString() : "never"; const outcome = last ? `${last.status}${last.error ? ` — ${last.error}` : ""}` : ""; console.log(`${folder.enabled ? "●" : "○"} ${folder.name} [${folder.id}] ${folder.localPath} ⇄ ${folder.remoteUrl}`); console.log(` every ${folder.interval}s; last sync ${when} ${outcome}`); } if (config.folders().length === 0) console.log("No folders yet. Add one in the window: davsync"); store.close(); return 0;}
async function installAutostart(printOnly: boolean): Promise<number> { const plan = planFor(launchCommand(), currentPlaces()); if (printOnly) { for (const file of plan.files) console.log(`# ${file.path}\n${file.content}`); for (const command of plan.commands) console.log(`$ ${command.map((word) => (/[\s'"]/.test(word) ? JSON.stringify(word) : word)).join(" ")}`); return 0; } const config = await new ConfigStore().load(); const { problems } = await install(plan); console.log(`Installed a ${plan.kind}: ${plan.files.map((file) => file.path).join(", ")}`); for (const problem of problems) console.error(` but: ${problem}`); if (problems.length) return 1; console.log(`davsync now starts when you log in, and is running. What it does: ${plan.logs}`); if (config.folders().length === 0) console.log("No folders yet: add one in the window (davsync); the service picks it up."); return 0;}
async function uninstallAutostart(): Promise<number> { const plan = planFor(launchCommand(), currentPlaces()); const { problems } = await uninstall(plan, () => { const pid = servicePid(); if (pid) process.kill(pid, "SIGTERM"); }); console.log(`Removed the ${plan.kind}; davsync no longer starts at login, and the service is stopped.`); for (const problem of problems) console.error(` but: ${problem}`); return problems.length ? 1 : 0;}
async function discover([typed, username = ""]: string[]): Promise<number> { if (!typed) throw new Error("usage: davsync discover <server> [user]"); const password = username ? (process.env.DAVSYNC_PASSWORD ?? (await askHidden(`Password for ${username}: `))) : ""; try { const found = await discoverRoot({ serverUrl: withScheme(typed), username, password }); for (const step of found.steps) console.log(` ${step}`); console.log(`WebDAV root: ${found.serverUrl}`); const listing = await listCollections({ serverUrl: found.serverUrl, username, password }, ""); console.log(`Folders in it: ${listing.folders.map((folder) => folder.label).join(", ") || "(none)"}`); return 0; } catch (error) { console.error((error as Error).message); return 1; }}
/** Reads a line from the terminal without echoing it. */function askHidden(prompt: string): Promise<string> { return new Promise((resolve, reject) => { if (!process.stdin.isTTY) return reject(new Error("no terminal to ask for the password on; set DAVSYNC_PASSWORD")); process.stderr.write(prompt); process.stdin.setRawMode(true); process.stdin.resume(); let entered = ""; const onData = (chunk: Buffer) => { for (const char of chunk.toString("utf8")) { if (char === "\r" || char === "\n") { process.stdin.setRawMode(false); process.stdin.pause(); process.stdin.off("data", onData); process.stderr.write("\n"); return resolve(entered); } if (char === "\u0003") { process.stdin.setRawMode(false); return reject(new Error("cancelled")); } if (char === "\u007f") entered = entered.slice(0, -1); else entered += char; } }; process.stdin.on("data", onData); });}
function describe(action: Action): string { switch (action.type) { case "move": return `move ${action.on}: ${action.from} → ${action.to}`; case "mkdir": case "delete": return `${action.type} ${action.on}: ${action.path}`; case "forget": return `forget ${action.base}`; default: return `${action.type} ${action.path}`; }}