diff --git a/README.md b/README.md index f59ce5f..34bcafe 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,27 @@ davsync run # the service: sync every enabled folder at its inter davsync sync [folder...] # sync once and exit davsync plan [folder...] # show what a sync would do, without doing it davsync status +davsync install # start the service whenever you log in +davsync uninstall # stop doing that, and stop the service ``` +`install` uses what each system has for this: + +- **Linux:** a systemd user unit, `~/.config/systemd/user/davsync.service`. + It is enabled for `default.target` and restarted if it fails. Logs: + `journalctl --user -u davsync`. +- **macOS:** a LaunchAgent, `~/Library/LaunchAgents/com.pipobscure.davsync.plist`. + Logs: `~/Library/Logs/davsync/davsync.log`. +- **Windows:** a script, `%LOCALAPPDATA%\davsync\davsync-startup.ps1`, and a + shortcut in the Startup folder that runs it hidden. A `.ps1` placed in the + Startup folder itself would only open in Notepad. Logs: + `%LOCALAPPDATA%\davsync\davsync.log`. + +Each starts this installation's own bundle with this node, by their absolute +paths, so it does not depend on `PATH`. Install from where the bundle will stay +(`bundle install` puts it in `~/.local/bin`). `install --print` shows what it +would write. + Needs Node 26.11.1 or later, and Chrome or Chromium for `setup`. ## How a sync works diff --git a/src/autostart.ts b/src/autostart.ts new file mode 100644 index 0000000..bccec0a --- /dev/null +++ b/src/autostart.ts @@ -0,0 +1,296 @@ +import { spawnSync } from "node:child_process"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, posix, resolve, win32 } from "node:path"; +import { isSea } from "node:sea"; +import { fileURLToPath } from "node:url"; + +// Starting the sync service when the user logs in, the way each system does it: +// a systemd user unit on Linux, a LaunchAgent on macOS, and on Windows a script +// started by a shortcut in the user's Startup folder. + +/** How to start `davsync run`, independent of PATH. */ +export interface Launch { + program: string; + args: string[]; + /** Variables that pointed this installation at its config and state; carried over. */ + env: Record; +} + +/** + * The command line that runs this very installation's service: node by its + * absolute path, and the bundle it was started from (or the checkout's + * src/main.ts). A bundle updated in place is picked up at the next start. + */ +export function launchCommand(): Launch { + const env: Record = {}; + for (const name of ["DAVSYNC_CONFIG_DIR", "DAVSYNC_STATE_DIR"]) { + if (process.env[name]) env[name] = resolve(process.env[name]!); + } + if (isSea()) return { program: process.execPath, args: ["run"], env }; + const archive = vfsArchive(process.execArgv); + if (archive) { + return { program: process.execPath, args: ["--no-warnings", "--experimental-vfs", `--vfs-load=${archive}`, "--", "run"], env }; + } + return { program: process.execPath, args: ["--no-warnings", fileURLToPath(new URL("./main.ts", import.meta.url)), "run"], env }; +} + +/** The archive a bundle was mounted from: `--vfs-load=` or `--vfs-load `. */ +export function vfsArchive(execArgv: readonly string[]): string | null { + for (let index = 0; index < execArgv.length; index++) { + const arg = execArgv[index]!; + if (arg.startsWith("--vfs-load=")) return resolve(arg.slice("--vfs-load=".length)); + if (arg === "--vfs-load" && execArgv[index + 1]) return resolve(execArgv[index + 1]!); + } + return null; +} + +/** What installing writes and runs on one system. Pure, so every system's version can be tested anywhere. */ +export interface Plan { + kind: string; + files: { path: string; content: string }[]; + /** Run in order once the files are written; a failure is reported, not fatal. */ + commands: string[][]; + /** Removed, in order, by uninstall. */ + remove: string[]; + /** Run by uninstall before the files go. */ + stop: string[][]; + /** Where to look for what it does. */ + logs: string; +} + +export interface Places { + platform: NodeJS.Platform; + home: string; + uid: number; + xdgConfigHome?: string | undefined; + appData?: string | undefined; + localAppData?: string | undefined; +} + +export function currentPlaces(): Places { + return { + platform: process.platform, + home: homedir(), + uid: process.getuid?.() ?? 0, + xdgConfigHome: process.env.XDG_CONFIG_HOME || undefined, + appData: process.env.APPDATA, + localAppData: process.env.LOCALAPPDATA, + }; +} + +const LABEL = "com.pipobscure.davsync"; + +export function planFor(launch: Launch, places: Places): Plan { + switch (places.platform) { + case "linux": + return systemdPlan(launch, places); + case "darwin": + return launchAgentPlan(launch, places); + case "win32": + return startupPlan(launch, places); + default: + throw new Error(`davsync does not know how to start at login on ${places.platform}; run “davsync run” from your session's startup instead`); + } +} + +// ---- Linux: a systemd user unit ------------------------------------------------------- + +function systemdPlan(launch: Launch, places: Places): Plan { + const unit = posix.join(places.xdgConfigHome ?? posix.join(places.home, ".config"), "systemd", "user", "davsync.service"); + const environment = Object.entries(launch.env).map(([name, value]) => `Environment=${systemdQuote(`${name}=${value}`)}\n`); + const content = `# Written by “davsync install”; “davsync uninstall” removes it. +[Unit] +Description=davsync: two-way sync of folders with WebDAV + +[Service] +Type=simple +ExecStart=${[launch.program, ...launch.args].map(systemdQuote).join(" ")} +${environment.join("")}Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target +`; + return { + kind: "systemd user unit", + files: [{ path: unit, content }], + commands: [ + ["systemctl", "--user", "daemon-reload"], + ["systemctl", "--user", "enable", "davsync.service"], + // restart, not start: an install over an older one picks up the new command. + ["systemctl", "--user", "restart", "davsync.service"], + ], + stop: [["systemctl", "--user", "disable", "--now", "davsync.service"]], + remove: [unit], + logs: "journalctl --user -u davsync", + }; +} + +/** systemd's quoting for one word of ExecStart= or Environment=. */ +export function systemdQuote(word: string): string { + return `"${word.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%").replace(/\$/g, "$$$$")}"`; +} + +// ---- macOS: a LaunchAgent ---------------------------------------------------------------- + +function launchAgentPlan(launch: Launch, places: Places): Plan { + const plist = posix.join(places.home, "Library", "LaunchAgents", `${LABEL}.plist`); + const logs = posix.join(places.home, "Library", "Logs", "davsync"); + const strings = (values: string[], indent: string) => values.map((value) => `${indent}${xmlEscape(value)}\n`).join(""); + const environment = Object.keys(launch.env).length + ? ` EnvironmentVariables\n \n${Object.entries(launch.env) + .map(([name, value]) => ` ${xmlEscape(name)}\n ${xmlEscape(value)}\n`) + .join("")} \n` + : ""; + const content = ` + + + + + Label + ${LABEL} + ProgramArguments + +${strings([launch.program, ...launch.args], " ")} +${environment} RunAtLoad + + KeepAlive + + SuccessfulExit + + + ThrottleInterval + 30 + ProcessType + Background + StandardOutPath + ${xmlEscape(posix.join(logs, "davsync.log"))} + StandardErrorPath + ${xmlEscape(posix.join(logs, "davsync.log"))} + + +`; + const domain = `gui/${places.uid}`; + return { + kind: "LaunchAgent", + files: [{ path: plist, content }], + commands: [ + ["mkdir", "-p", logs], + // Unloading one that is not loaded fails; that is fine. + ["launchctl", "bootout", `${domain}/${LABEL}`], + ["launchctl", "bootstrap", domain, plist], + ], + stop: [["launchctl", "bootout", `${domain}/${LABEL}`]], + remove: [plist], + logs: posix.join(logs, "davsync.log"), + }; +} + +function xmlEscape(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">"); +} + +// ---- Windows: a script, started by a shortcut in the Startup folder ------------------------- + +function startupPlan(launch: Launch, places: Places): Plan { + const appData = places.appData ?? win32.join(places.home, "AppData", "Roaming"); + const localAppData = places.localAppData ?? win32.join(places.home, "AppData", "Local"); + const dir = win32.join(localAppData, "davsync"); + const script = win32.join(dir, "davsync-startup.ps1"); + const log = win32.join(dir, "davsync.log"); + const shortcut = win32.join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "davsync.lnk"); + const environment = Object.entries(launch.env).map(([name, value]) => `$env:${name} = ${psQuote(value)}\n`).join(""); + const content = `# Written by “davsync install”; “davsync uninstall” removes it. +# Started at logon by the davsync shortcut in the Startup folder: a .ps1 placed +# there itself would only be opened in Notepad. Runs the sync service, restarts +# it if it fails, and writes what it says to ${log}. +${environment}do { + & ${psQuote(launch.program)} ${launch.args.map(psQuote).join(" ")} *>> ${psQuote(log)} + $code = $LASTEXITCODE + if ($code -ne 0) { Start-Sleep -Seconds 30 } +} while ($code -ne 0) +`; + const powershellArgs = `-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "${script}"`; + const makeShortcut = [ + `$shortcut = (New-Object -ComObject WScript.Shell).CreateShortcut(${psQuote(shortcut)})`, + `$shortcut.TargetPath = 'powershell.exe'`, + `$shortcut.Arguments = ${psQuote(powershellArgs)}`, + // 7: minimised, so even the moment before -WindowStyle Hidden applies shows nothing. + `$shortcut.WindowStyle = 7`, + `$shortcut.Description = 'davsync: two-way sync of folders with WebDAV'`, + `$shortcut.Save()`, + ].join("; "); + return { + kind: "Startup folder shortcut", + files: [{ path: script, content }], + commands: [ + ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", makeShortcut], + // Start it now, as logging in would. + ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", `Start-Process powershell.exe -WindowStyle Hidden -ArgumentList ${psQuote(powershellArgs)}`], + ], + stop: [], + remove: [shortcut, script], + logs: log, + }; +} + +/** PowerShell's single-quoted string: nothing inside is interpreted, and ' doubles. */ +export function psQuote(text: string): string { + return `'${text.replace(/'/g, "''")}'`; +} + +// ---- Doing it ---------------------------------------------------------------------------------- + +export interface Report { + plan: Plan; + /** Commands that failed, and what they said. */ + problems: string[]; +} + +export async function install(plan: Plan): Promise { + for (const file of plan.files) { + await mkdir(dirname(file.path), { recursive: true }); + await writeFile(file.path, file.content); + } + const problems: string[] = []; + for (const command of plan.commands) { + // Unloading what is not loaded may fail; it is a precaution, not a step. + const optional = command[1] === "bootout"; + const problem = run(command); + if (problem && !optional) problems.push(problem); + } + return { plan, problems }; +} + +export async function uninstall(plan: Plan, stopService: () => void): Promise { + const problems: string[] = []; + for (const command of plan.stop) run(command); + // Windows has no service manager to stop it; and on any system, a service + // started by hand would otherwise keep running. + stopService(); + for (const path of plan.remove) await rm(path, { force: true }); + if (plan.kind === "systemd user unit") { + const problem = run(["systemctl", "--user", "daemon-reload"]); + if (problem) problems.push(problem); + } + return { plan, problems }; +} + +/** Whether this system's start-at-login file is in place, and the command it starts. */ +export async function installed(plan: Plan): Promise { + const file = plan.files[0]; + if (!file) return null; + return readFile(file.path, "utf8").then( + () => file.path, + () => null, + ); +} + +function run(command: string[]): string | null { + const [program, ...args] = command as [string, ...string[]]; + const result = spawnSync(program, args, { encoding: "utf8" }); + if (result.error) return `${program}: ${(result.error as NodeJS.ErrnoException).code === "ENOENT" ? "not found" : result.error.message}`; + if (result.status !== 0) return `${command.slice(0, 4).join(" ")}: ${(result.stderr || result.stdout).trim() || `exit ${result.status}`}`; + return null; +} diff --git a/src/main.ts b/src/main.ts index b72de63..a9afc8f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,5 @@ import { 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"; @@ -16,6 +17,9 @@ usage: davsync [folder...] 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 [user] find the WebDAV root behind a server's name, and say how (the password is read from DAVSYNC_PASSWORD, or asked for) @@ -59,6 +63,10 @@ async function main(command: string, args: string[]): Promise { 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}`); @@ -111,6 +119,8 @@ async function status(): Promise { const store = new StateStore(paths.database); const pid = servicePid(); console.log(pid ? `service: running (pid ${pid})` : "service: not running (start it with: davsync run)"); + 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"; @@ -123,6 +133,34 @@ async function status(): Promise { return 0; } +async function installAutostart(printOnly: boolean): Promise { + 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 with “davsync setup”; the service picks it up."); + return 0; +} + +async function uninstallAutostart(): Promise { + 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 { if (!typed) throw new Error("usage: davsync discover [user]"); const password = username ? (process.env.DAVSYNC_PASSWORD ?? (await askHidden(`Password for ${username}: `))) : ""; diff --git a/test/autostart.test.ts b/test/autostart.test.ts new file mode 100644 index 0000000..e0e4374 --- /dev/null +++ b/test/autostart.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { type Launch, planFor, psQuote, systemdQuote, vfsArchive } from "../src/autostart.ts"; + +// What `davsync install` writes on each system, checked here for all three. + +const launch: Launch = { + program: "/opt/node 26/bin/node", + args: ["--no-warnings", "--experimental-vfs", "--vfs-load=/home/me/.local/bin/davsync", "--", "run"], + env: { DAVSYNC_CONFIG_DIR: "/home/me/my $config%dir" }, +}; + +test("the bundle a running app came from is found in node's arguments", () => { + assert.equal(vfsArchive(["--no-warnings", "--experimental-vfs", "--vfs-load=/home/me/.local/bin/davsync"]), "/home/me/.local/bin/davsync"); + assert.equal(vfsArchive(["--vfs-load", "/x/app.nzip"]), "/x/app.nzip"); + assert.equal(vfsArchive(["--no-warnings"]), null); +}); + +test("Linux: a systemd user unit, started at login, restarted on failure", () => { + const plan = planFor(launch, { platform: "linux", home: "/home/me", uid: 1000 }); + assert.equal(plan.files[0]!.path, "/home/me/.config/systemd/user/davsync.service"); + const unit = plan.files[0]!.content; + assert.match(unit, /^ExecStart="\/opt\/node 26\/bin\/node" "--no-warnings" "--experimental-vfs" "--vfs-load=\/home\/me\/.local\/bin\/davsync" "--" "run"$/m); + // systemd expands $ and % itself; both are escaped. + assert.match(unit, /^Environment="DAVSYNC_CONFIG_DIR=\/home\/me\/my \$\$config%%dir"$/m); + assert.match(unit, /^Restart=on-failure$/m); + assert.match(unit, /^WantedBy=default.target$/m); + assert.deepEqual(plan.commands.at(-1), ["systemctl", "--user", "restart", "davsync.service"]); + assert.deepEqual(plan.stop, [["systemctl", "--user", "disable", "--now", "davsync.service"]]); + const xdg = planFor(launch, { platform: "linux", home: "/home/me", uid: 1000, xdgConfigHome: "/cfg" }); + assert.equal(xdg.files[0]!.path, "/cfg/systemd/user/davsync.service"); +}); + +test("macOS: a LaunchAgent, loaded into the user's GUI session", () => { + const plan = planFor(launch, { platform: "darwin", home: "/Users/me", uid: 501 }); + assert.equal(plan.files[0]!.path, "/Users/me/Library/LaunchAgents/com.pipobscure.davsync.plist"); + const plist = plan.files[0]!.content; + assert.match(plist, /ProgramArguments<\/key>\s*\s*\/opt\/node 26\/bin\/node<\/string>/); + assert.match(plist, /RunAtLoad<\/key>\s*/); + assert.match(plist, /DAVSYNC_CONFIG_DIR<\/key>\s*\/home\/me\/my \$config%dir<\/string>/); + assert.ok(plan.commands.some((command) => command.join(" ") === `launchctl bootstrap gui/501 ${plan.files[0]!.path}`)); + assert.equal(plan.logs, "/Users/me/Library/Logs/davsync/davsync.log"); +}); + +test("Windows: a script, and a shortcut in the Startup folder that runs it hidden", () => { + const plan = planFor( + { ...launch, program: "C:\\Program Files\\nodejs\\node.exe", args: ["--vfs-load=C:\\Users\\me\\it's\\davsync.nzip", "--", "run"] }, + { platform: "win32", home: "C:\\Users\\me", uid: 0, appData: "C:\\Users\\me\\AppData\\Roaming", localAppData: "C:\\Users\\me\\AppData\\Local" }, + ); + const script = plan.files[0]!; + assert.match(script.path, /AppData.Local.davsync.davsync-startup\.ps1$/); + assert.match(script.content, /& 'C:\\Program Files\\nodejs\\node\.exe' '--vfs-load=C:\\Users\\me\\it''s\\davsync\.nzip' '--' 'run'/); + assert.match(script.content, /\$env:DAVSYNC_CONFIG_DIR = '\/home\/me\/my \$config%dir'/); + const shortcut = plan.commands[0]!.at(-1)!; + assert.match(shortcut, /Start Menu.Programs.Startup.davsync\.lnk/); + assert.match(shortcut, /-WindowStyle Hidden -File/); + assert.equal(plan.remove.length, 2); +}); + +test("other systems are told plainly", () => { + assert.throws(() => planFor(launch, { platform: "freebsd", home: "/home/me", uid: 1000 }), /does not know how to start at login on freebsd/); +}); + +test("quoting for systemd and PowerShell", () => { + assert.equal(systemdQuote('a "b" \\c $d %e'), '"a \\"b\\" \\\\c $$d %%e"'); + assert.equal(psQuote("it's"), "'it''s'"); +});