import { createHash } from "node:crypto"; import { appendFile, copyFile, mkdir, readFile, readdir, rename, rm, stat, writeFile, } from "node:fs/promises"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; import { createInterface } from "node:readline/promises"; const BUNDLE_NAME = "zed-settings-sync.json"; const GIST_DESCRIPTION = "Zed settings sync"; const REQUIRED_FILES = ["settings.json", "keymap.json", "tasks.json"]; const FIXED_FILES = [ ...REQUIRED_FILES, "debug.json", "scripts/toggle-file-scan-exclusions.mjs", "scripts/file-exclusions.json", "scripts/zed-settings-sync.mjs", ]; const OPTIONAL_DIRECTORIES = ["snippets", "themes"]; const LOCAL_SYNC_CONFIG = "scripts/settings-sync.json"; const DEFAULT_SYNC_CONFIG = { gist_id: "", last_synced_hash: "", auto_sync: false, interval_minutes: 15, last_run_at: "", last_result: "", }; const LOCK_MAX_AGE_MS = 10 * 60 * 1000; const LOG_MAX_BYTES = 1024 * 1024; const SCHEDULER_NAME = "ZedSettingsSync"; function zedConfigDirectory() { if (process.env.ZED_CONFIG_DIR) return resolve(process.env.ZED_CONFIG_DIR); if (process.platform === "win32") { if (!process.env.APPDATA) throw new Error("APPDATA is not set"); return join(process.env.APPDATA, "Zed"); } return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "zed"); } function timestamp() { return new Date().toISOString().replace(/[:.]/g, "-"); } function now() { return new Date().toISOString(); } async function writeAtomically(path, contents) { await mkdir(dirname(path), { recursive: true }); const temporaryPath = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`); try { await writeFile(temporaryPath, contents, "utf8"); await rename(temporaryPath, path); } finally { await rm(temporaryPath, { force: true }); } } async function listFilesRecursively(directory, prefix) { if (!existsSync(directory)) return []; const results = []; for (const entry of await readdir(directory, { withFileTypes: true })) { const absolutePath = join(directory, entry.name); const relativePath = `${prefix}/${entry.name}`; if (entry.isDirectory()) results.push(...await listFilesRecursively(absolutePath, relativePath)); else if (entry.isFile()) results.push(relativePath); } return results; } async function synchronizedPaths(configDirectory) { const paths = FIXED_FILES.filter((path) => existsSync(join(configDirectory, ...path.split("/")))); for (const directory of OPTIONAL_DIRECTORIES) { paths.push(...await listFilesRecursively(join(configDirectory, directory), directory)); } return [...new Set(paths)].sort(); } function isAllowedBundlePath(path) { if (typeof path !== "string" || path.startsWith("/") || path.includes("\\")) return false; const segments = path.split("/"); if (segments.some((segment) => !segment || segment === "." || segment === "..")) return false; if (FIXED_FILES.includes(path) || path === LOCAL_SYNC_CONFIG) return true; return OPTIONAL_DIRECTORIES.some((directory) => path.startsWith(`${directory}/`)); } function replaceTaskPath(source, path, placeholder) { return source .replaceAll(path.replaceAll("\\", "\\\\"), placeholder) .replaceAll(path.replaceAll("\\", "/"), placeholder); } function portableTasks(source, configDirectory) { return replaceTaskPath( replaceTaskPath( source, join(configDirectory, "scripts", "toggle-file-scan-exclusions.mjs"), "__TOGGLE_SCRIPT__", ), join(configDirectory, "scripts", "zed-settings-sync.mjs"), "__SYNC_SCRIPT__", ); } function installedTasks(source, configDirectory) { return source .replaceAll( "__TOGGLE_SCRIPT__", join(configDirectory, "scripts", "toggle-file-scan-exclusions.mjs").replaceAll("\\", "/"), ) .replaceAll( "__SYNC_SCRIPT__", join(configDirectory, "scripts", "zed-settings-sync.mjs").replaceAll("\\", "/"), ); } function hashContents(contents) { return createHash("sha256").update(contents).digest("hex"); } async function createBundle() { const configDirectory = zedConfigDirectory(); const files = {}; for (const relativePath of await synchronizedPaths(configDirectory)) { const absolutePath = join(configDirectory, ...relativePath.split("/")); let contents = await readFile(absolutePath, "utf8"); if (relativePath === "tasks.json") contents = portableTasks(contents, configDirectory); files[relativePath] = contents; } for (const required of REQUIRED_FILES) { if (!(required in files)) throw new Error(`Cannot export: ${required} is missing`); } const contents = serializeBundle(files); return { files, contents, hash: hashContents(contents) }; } function serializeBundle(files) { return `${JSON.stringify(files, null, 2)}\n`; } function parseBundle(contents, sourceName) { let files; try { files = JSON.parse(contents); } catch (error) { throw new Error(`Invalid JSON in ${sourceName}: ${error.message}`); } if (!files || Array.isArray(files) || typeof files !== "object") { throw new Error(`Invalid Zed settings bundle: ${sourceName}`); } for (const required of REQUIRED_FILES) { if (typeof files[required] !== "string") throw new Error(`Invalid bundle: ${required} is missing`); } for (const [path, value] of Object.entries(files)) { if (!isAllowedBundlePath(path) || typeof value !== "string") { throw new Error(`Invalid bundle file: ${path}`); } } return { files, hash: hashContents(contents) }; } async function exportBundle(path) { const bundle = await createBundle(); await writeAtomically(path, bundle.contents); console.log(`Exported Zed settings to ${path}`); console.log(`SHA-256: ${bundle.hash}`); return bundle; } async function backupCurrentConfiguration(configDirectory) { const backupDirectory = join(configDirectory, "backups", `sync-import-${timestamp()}`); await mkdir(backupDirectory, { recursive: true }); for (const relativePath of await synchronizedPaths(configDirectory)) { const source = join(configDirectory, ...relativePath.split("/")); const destination = join(backupDirectory, ...relativePath.split("/")); await mkdir(dirname(destination), { recursive: true }); await copyFile(source, destination); } return backupDirectory; } async function installBundleContents(contents, sourceName) { const parsed = parseBundle(contents, sourceName); const configDirectory = zedConfigDirectory(); const backupDirectory = await backupCurrentConfiguration(configDirectory); for (const [relativePath, sourceContents] of Object.entries(parsed.files)) { if (relativePath === LOCAL_SYNC_CONFIG) continue; const destination = join(configDirectory, ...relativePath.split("/")); const installedContents = relativePath === "tasks.json" ? installedTasks(sourceContents, configDirectory) : sourceContents; await writeAtomically(destination, installedContents); } console.log(`Imported Zed settings from ${sourceName}`); console.log(`Previous settings were backed up to ${backupDirectory}`); return { ...parsed, backupDirectory }; } async function importBundle(path) { return installBundleContents(await readFile(path, "utf8"), path); } function runPowerShell(command, extraEnv = {}) { const result = spawnSync("powershell.exe", ["-NoProfile", "-STA", "-Command", command], { encoding: "utf8", env: { ...process.env, ...extraEnv }, windowsHide: false, }); if (result.status !== 0) throw new Error(result.stderr.trim() || "Windows dialog failed"); return result.stdout.trim(); } function nativeFileDialog(mode) { if (process.env.ZED_SYNC_NO_DIALOGS === "1") return ""; if (process.platform === "win32") { const common = "[Console]::OutputEncoding=[Text.Encoding]::UTF8; Add-Type -AssemblyName System.Windows.Forms; $o=New-Object System.Windows.Forms.Form; $o.TopMost=$true; $o.ShowInTaskbar=$false; $o.Opacity=0; $o.Show(); $o.Activate(); "; const command = mode === "save" ? `${common}$d=New-Object System.Windows.Forms.SaveFileDialog; $d.Filter='Zed settings (*.json)|*.json'; $d.FileName='${BUNDLE_NAME}'; $d.AddExtension=$true; if($d.ShowDialog($o) -eq 'OK'){[Console]::Write($d.FileName)}; $o.Close()` : `${common}$d=New-Object System.Windows.Forms.OpenFileDialog; $d.Filter='Zed settings (*.json)|*.json'; $d.FileName='${BUNDLE_NAME}'; if($d.ShowDialog($o) -eq 'OK'){[Console]::Write($d.FileName)}; $o.Close()`; return runPowerShell(command); } if (process.platform === "darwin") { const script = mode === "save" ? `POSIX path of (choose file name with prompt "Export Zed settings" default name "${BUNDLE_NAME}")` : "POSIX path of (choose file with prompt \"Import Zed settings\")"; const result = spawnSync("osascript", ["-e", script], { encoding: "utf8" }); return result.status === 0 ? result.stdout.trim() : ""; } const args = ["--file-selection", `--title=${mode === "save" ? "Export" : "Import"} Zed settings`]; if (mode === "save") args.push("--save", "--confirm-overwrite", `--filename=${BUNDLE_NAME}`); const result = spawnSync("zenity", args, { encoding: "utf8" }); return result.status === 0 ? result.stdout.trim() : ""; } async function readSyncConfiguration() { const path = join(zedConfigDirectory(), ...LOCAL_SYNC_CONFIG.split("/")); let source = {}; try { source = JSON.parse(await readFile(path, "utf8")); } catch (error) { if (error.code !== "ENOENT") throw error; } return { path, config: { ...DEFAULT_SYNC_CONFIG, gist_id: typeof source.gist_id === "string" ? source.gist_id : "", last_synced_hash: typeof source.last_synced_hash === "string" ? source.last_synced_hash : "", auto_sync: source.auto_sync === true, interval_minutes: Number.isInteger(source.interval_minutes) && source.interval_minutes >= 5 ? source.interval_minutes : DEFAULT_SYNC_CONFIG.interval_minutes, last_run_at: typeof source.last_run_at === "string" ? source.last_run_at : "", last_result: typeof source.last_result === "string" ? source.last_result : "", }, }; } async function saveSyncConfiguration(path, config) { await writeAtomically(path, `${JSON.stringify(config, null, 2)}\n`); } function normalizeGistId(value) { const matches = value.trim().match(/[a-f0-9]{8,}/gi); if (!matches?.length) throw new Error("Enter a valid GitHub Gist ID or URL"); return matches.at(-1).toLowerCase(); } function sanitizedEnvironment() { const env = { ...process.env }; delete env.GH_TOKEN; delete env.GITHUB_TOKEN; return env; } function execute(command, args, options = {}) { return spawnSync(command, args, { encoding: options.stdio === "inherit" ? undefined : "utf8", env: options.env || process.env, input: options.input, stdio: options.stdio || "pipe", windowsHide: options.windowsHide ?? true, }); } function ghMissingMessage() { if (process.platform === "win32") return "GitHub CLI is required. Install it with: winget install GitHub.cli"; if (process.platform === "darwin") return "GitHub CLI is required. Install it with: brew install gh"; return "GitHub CLI is required. Install it from https://cli.github.com/"; } function executeGh(args, options = {}) { if (process.env.ZED_SYNC_GH_SCRIPT) { return execute(process.execPath, [process.env.ZED_SYNC_GH_SCRIPT, ...args], options); } return execute("gh", args, options); } function testGhAuthentication(env) { const user = executeGh(["api", "user", "--jq", ".login"], { env }); if (user.error?.code === "ENOENT") return { ok: false, missing: true, message: ghMissingMessage() }; if (user.status !== 0) { return { ok: false, login: "", message: (user.stderr || user.stdout || "GitHub authentication failed").trim() }; } const gists = executeGh(["api", "gists?per_page=1", "--silent"], { env }); return { ok: gists.status === 0, login: user.stdout.trim(), message: gists.status === 0 ? "" : (gists.stderr || gists.stdout || "The GitHub credential cannot access Gists").trim(), }; } async function resolveGhAuthentication({ interactive = false, requireStored = false } = {}) { const hasEnvironmentToken = Boolean(process.env.GH_TOKEN || process.env.GITHUB_TOKEN); if (!requireStored) { const current = testGhAuthentication(process.env); if (current.missing) throw new Error(current.message); if (current.ok) { return { env: process.env, login: current.login, source: hasEnvironmentToken ? "environment" : "credential-store" }; } if (hasEnvironmentToken) console.log("The inherited GitHub token is invalid; trying stored GitHub CLI credentials."); } const cleanEnv = sanitizedEnvironment(); const stored = testGhAuthentication(cleanEnv); if (stored.missing) throw new Error(stored.message); if (stored.ok) return { env: cleanEnv, login: stored.login, source: "credential-store" }; if (!interactive) throw new Error("GitHub is not authenticated. Run 'Zed Settings: Set Up / Reconfigure Sync...'."); console.log("Opening GitHub browser authentication. No personal access token needs to be pasted."); const authenticationArgs = stored.login ? ["auth", "refresh", "--hostname", "github.com", "--scopes", "gist"] : ["auth", "login", "--hostname", "github.com", "--web", "--clipboard", "--scopes", "gist"]; const login = executeGh( authenticationArgs, { env: cleanEnv, stdio: "inherit", windowsHide: false }, ); if (login.status !== 0) throw new Error("GitHub browser authentication was cancelled or failed."); const verified = testGhAuthentication(cleanEnv); if (!verified.ok) throw new Error(verified.message || "GitHub authentication could not be verified."); return { env: cleanEnv, login: verified.login, source: "credential-store" }; } function runGh(args, auth, input) { const result = executeGh(args, { env: auth.env, input }); if (result.error?.code === "ENOENT") throw new Error(ghMissingMessage()); if (result.status !== 0) throw new Error((result.stderr || result.stdout || `gh ${args[0]} failed`).trim()); return result.stdout.trim(); } function readRemoteGist(gistId, auth) { const gist = JSON.parse(runGh(["api", `gists/${gistId}`], auth)); const file = gist.files?.[BUNDLE_NAME]; if (!file) return { gist, parsed: null, contents: null }; if (file.truncated) throw new Error(`${BUNDLE_NAME} is too large to read through the Gist API`); return { gist, parsed: parseBundle(file.content, `Gist ${gistId}`), contents: file.content }; } function updateRemoteGist(gistId, contents, auth) { const payload = JSON.stringify({ files: { [BUNDLE_NAME]: { content: contents } } }); return JSON.parse(runGh(["api", "--method", "PATCH", `gists/${gistId}`, "--input", "-"], auth, payload)); } function createRemoteGist(contents, auth) { const payload = JSON.stringify({ description: GIST_DESCRIPTION, public: false, files: { [BUNDLE_NAME]: { content: contents } }, }); return JSON.parse(runGh(["api", "--method", "POST", "gists", "--input", "-"], auth, payload)); } function discoverGists(auth) { const raw = runGh(["api", "--paginate", "--slurp", "gists?per_page=100"], auth); const pages = JSON.parse(raw); const gists = (Array.isArray(pages[0]) ? pages.flat() : pages) .filter((gist) => gist.files?.[BUNDLE_NAME]) .sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at))); return gists; } function shortHash(hash) { return hash ? hash.slice(0, 12) : "none"; } function classifySync(localHash, remoteHash, lastHash) { if (!remoteHash) return "remote-missing"; if (localHash === remoteHash) return "synchronized"; if (!lastHash) return "no-baseline"; if (localHash === lastHash) return "remote-changed"; if (remoteHash === lastHash) return "local-changed"; return "conflict"; } let scriptedAnswers; async function ask(question, defaultValue = "") { if (scriptedAnswers === undefined) { try { scriptedAnswers = JSON.parse(process.env.ZED_SYNC_ANSWERS || "[]"); } catch { scriptedAnswers = []; } } if (scriptedAnswers.length) { const answer = String(scriptedAnswers.shift()); console.log(`${question}${answer}`); return answer; } if (!process.stdin.isTTY) throw new Error("Interactive input is required. Run this command from the Zed Task Picker."); const rl = createInterface({ input: process.stdin, output: process.stdout }); try { return (await rl.question(question)).trim() || defaultValue; } finally { rl.close(); } } async function askYesNo(question, defaultYes = false) { const answer = (await ask(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"} `, defaultYes ? "y" : "n")).toLowerCase(); return answer === "y" || answer === "yes"; } async function appendLog(message) { const logDirectory = join(zedConfigDirectory(), "logs"); const logPath = join(logDirectory, "settings-sync.log"); await mkdir(logDirectory, { recursive: true }); try { if ((await stat(logPath)).size > LOG_MAX_BYTES) { await rm(`${logPath}.3`, { force: true }); if (existsSync(`${logPath}.2`)) await rename(`${logPath}.2`, `${logPath}.3`); if (existsSync(`${logPath}.1`)) await rename(`${logPath}.1`, `${logPath}.2`); await rename(logPath, `${logPath}.1`); } } catch (error) { if (error.code !== "ENOENT") throw error; } await appendFile(logPath, `${now()} ${message}\n`, "utf8"); } async function recordResult(state, result) { state.config.last_run_at = now(); state.config.last_result = result; await saveSyncConfiguration(state.path, state.config); await appendLog(result); } function notify(message) { if (process.env.ZED_SYNC_NO_NOTIFICATIONS === "1") return; if (process.platform === "win32") { execute("msg.exe", [process.env.USERNAME || "*", message]); } else if (process.platform === "darwin") { const escaped = message.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); execute("osascript", ["-e", `display notification "${escaped}" with title "Zed Settings Sync"`]); } else { execute("notify-send", ["Zed Settings Sync", message]); } } async function withSyncLock(action) { const lockPath = join(zedConfigDirectory(), "scripts", ".settings-sync.lock"); try { await mkdir(lockPath, { recursive: false }); } catch (error) { if (error.code !== "EEXIST") throw error; const age = Date.now() - (await stat(lockPath)).mtimeMs; if (age <= LOCK_MAX_AGE_MS) { console.log("Another settings sync is already running."); return null; } await rm(lockPath, { recursive: true, force: true }); await mkdir(lockPath); } try { await writeFile(join(lockPath, "owner.json"), JSON.stringify({ pid: process.pid, started_at: now() })); return await action(); } finally { await rm(lockPath, { recursive: true, force: true }); } } async function selectSetupGist(gists, state) { console.log("\nAvailable Zed settings Gists:"); gists.forEach((gist, index) => { const current = gist.id === state.config.gist_id ? " (current)" : ""; console.log(` ${index + 1}. ${gist.description || "Untitled"} - ${gist.id}${current} - ${gist.updated_at}`); }); console.log(" N. Create a new Secret Gist from this machine"); console.log(" M. Enter another Gist URL or ID"); console.log(" Q. Cancel"); const defaultChoice = gists.length === 1 ? "1" : (gists.length === 0 ? "n" : "q"); const choice = (await ask(`Choose [${defaultChoice.toUpperCase()}]: `, defaultChoice)).toLowerCase(); if (choice === "q") return { action: "cancel" }; if (choice === "n") return { action: "create" }; if (choice === "m") return { action: "existing", gistId: normalizeGistId(await ask("Gist URL or ID: ")) }; const index = Number.parseInt(choice, 10) - 1; if (!Number.isInteger(index) || !gists[index]) throw new Error("Invalid Gist selection"); return { action: "existing", gistId: gists[index].id }; } async function setupSync({ offerAutomaticSync = true } = {}) { console.log("Zed Settings Sync Setup\n"); const auth = await resolveGhAuthentication({ interactive: true }); console.log(`Connected to GitHub as ${auth.login} (${auth.source}).`); const state = await readSyncConfiguration(); const gists = discoverGists(auth); if (state.config.gist_id && !gists.some((gist) => gist.id === state.config.gist_id)) { try { gists.unshift(readRemoteGist(state.config.gist_id, auth).gist); } catch (error) { console.log(`The currently configured Gist could not be loaded: ${error.message}`); } } const selection = await selectSetupGist(gists, state); if (selection.action === "cancel") { console.log("Setup cancelled. Local Zed settings remain installed and unchanged."); return; } const local = await createBundle(); if (selection.action === "create") { const created = createRemoteGist(local.contents, auth); state.config.gist_id = created.id; state.config.last_synced_hash = local.hash; await recordResult(state, `Created and uploaded Secret Gist ${created.id}`); console.log(`\nCreated Secret Gist: ${created.html_url || created.id}`); console.log(`SHA-256: ${local.hash}`); } else { const remote = readRemoteGist(selection.gistId, auth); console.log(`\nRemote: ${remote.gist.html_url || selection.gistId}`); state.config.gist_id = selection.gistId; if (!remote.parsed) { console.log(`This Gist does not contain ${BUNDLE_NAME}.`); if (!await askYesNo("Upload this machine's current Zed settings to it?", true)) return; updateRemoteGist(selection.gistId, local.contents, auth); state.config.last_synced_hash = local.hash; await recordResult(state, `Initialized Gist ${selection.gistId} with this machine's settings`); } else { console.log(`Local SHA-256: ${shortHash(local.hash)}`); console.log(`Remote SHA-256: ${shortHash(remote.parsed.hash)}`); console.log(" P. Pull remote settings onto this machine (backup first)"); console.log(" U. Upload this machine's settings to the Gist"); console.log(" Q. Cancel"); const direction = (await ask("Initial sync direction [P]: ", "p")).toLowerCase(); if (direction === "q") return; if (direction === "p") { if (local.hash !== remote.parsed.hash) await installBundleContents(remote.contents, `Gist ${selection.gistId}`); state.config.last_synced_hash = remote.parsed.hash; await recordResult(state, `Pulled settings from Gist ${selection.gistId}`); } else if (direction === "u") { updateRemoteGist(selection.gistId, local.contents, auth); state.config.last_synced_hash = local.hash; await recordResult(state, `Uploaded settings to Gist ${selection.gistId}`); } else { throw new Error("Invalid initial sync direction"); } } await saveSyncConfiguration(state.path, state.config); console.log(`Configured Gist ${selection.gistId}.`); } if (offerAutomaticSync) { if (await askYesNo("Enable automatic synchronization every 15 minutes?", false)) { await enableAutomaticSync(); } else { console.log("Automatic synchronization remains disabled. Use Push Now and Pull Now from the Task Picker."); } } } async function requireConfiguredState() { const state = await readSyncConfiguration(); if (state.config.gist_id) return state; console.log("No Gist is configured. Starting setup...\n"); await setupSync(); const configured = await readSyncConfiguration(); return configured.config.gist_id ? configured : null; } async function pushGist() { return withSyncLock(async () => { const state = await requireConfiguredState(); if (!state) return; const auth = await resolveGhAuthentication({ interactive: true }); const local = await createBundle(); const remote = readRemoteGist(state.config.gist_id, auth); const remoteHash = remote.parsed?.hash || ""; const status = classifySync(local.hash, remoteHash, state.config.last_synced_hash); if (["remote-changed", "conflict", "no-baseline"].includes(status)) { console.log(`Remote settings differ (${status}).`); console.log(`Local: ${shortHash(local.hash)} Remote: ${shortHash(remoteHash)} Last: ${shortHash(state.config.last_synced_hash)}`); if (!await askYesNo("Overwrite the remote Gist with this machine's settings?", false)) return; } updateRemoteGist(state.config.gist_id, local.contents, auth); state.config.last_synced_hash = local.hash; await recordResult(state, `Uploaded settings to Gist ${state.config.gist_id}`); console.log(`Uploaded to ${remote.gist.html_url || state.config.gist_id}`); console.log(`SHA-256: ${local.hash}`); }); } async function pullGist() { return withSyncLock(async () => { const state = await requireConfiguredState(); if (!state) return; const auth = await resolveGhAuthentication({ interactive: true }); const local = await createBundle(); const remote = readRemoteGist(state.config.gist_id, auth); if (!remote.parsed) throw new Error(`Gist ${state.config.gist_id} does not contain ${BUNDLE_NAME}`); const status = classifySync(local.hash, remote.parsed.hash, state.config.last_synced_hash); if (["local-changed", "conflict", "no-baseline"].includes(status)) { console.log(`Local settings differ (${status}).`); console.log(`Local: ${shortHash(local.hash)} Remote: ${shortHash(remote.parsed.hash)} Last: ${shortHash(state.config.last_synced_hash)}`); if (!await askYesNo("Replace local settings with the Gist version? A backup will be created first.", false)) return; } if (local.hash !== remote.parsed.hash) await installBundleContents(remote.contents, `Gist ${state.config.gist_id}`); state.config.last_synced_hash = remote.parsed.hash; await recordResult(state, `Pulled settings from Gist ${state.config.gist_id}`); console.log(`Downloaded from ${remote.gist.html_url || state.config.gist_id}`); console.log(`SHA-256: ${remote.parsed.hash}`); }); } async function showStatus() { const state = await readSyncConfiguration(); if (!state.config.gist_id) { console.log("Sync is not configured. Run 'Zed Settings: Set Up / Reconfigure Sync...'."); return; } const auth = await resolveGhAuthentication(); const local = await createBundle(); const remote = readRemoteGist(state.config.gist_id, auth); const status = classifySync(local.hash, remote.parsed?.hash || "", state.config.last_synced_hash); console.log(`Gist: ${remote.gist.html_url || state.config.gist_id}`); console.log(`Status: ${status}`); console.log(`Automatic sync: ${state.config.auto_sync ? `enabled (${state.config.interval_minutes} minutes)` : "disabled"}`); console.log(`Local: ${shortHash(local.hash)}`); console.log(`Remote: ${shortHash(remote.parsed?.hash || "")}`); console.log(`Last: ${shortHash(state.config.last_synced_hash)}`); console.log(`Last run: ${state.config.last_run_at || "never"}`); console.log(`Last result: ${state.config.last_result || "none"}`); } async function automaticSync() { return withSyncLock(async () => { const state = await readSyncConfiguration(); if (!state.config.auto_sync) return; try { if (!state.config.gist_id) throw new Error("Automatic sync requires setup"); const auth = await resolveGhAuthentication({ requireStored: true }); const local = await createBundle(); const remote = readRemoteGist(state.config.gist_id, auth); const remoteHash = remote.parsed?.hash || ""; const status = classifySync(local.hash, remoteHash, state.config.last_synced_hash); if (status === "synchronized") { await recordResult(state, "Automatic sync: already synchronized"); } else if (status === "local-changed") { updateRemoteGist(state.config.gist_id, local.contents, auth); state.config.last_synced_hash = local.hash; await recordResult(state, "Automatic sync: uploaded local changes"); } else if (status === "remote-changed") { await installBundleContents(remote.contents, `Gist ${state.config.gist_id}`); state.config.last_synced_hash = remote.parsed.hash; await recordResult(state, "Automatic sync: downloaded remote changes"); } else { const result = `Automatic sync stopped: ${status}`; const shouldNotify = state.config.last_result !== result; await recordResult(state, result); if (shouldNotify) notify(`${result}. Open the Zed Task Picker to resolve it.`); } } catch (error) { const result = `Automatic sync failed: ${error.message}`; const shouldNotify = state.config.last_result !== result; await recordResult(state, result); if (shouldNotify) notify(result); throw error; } }); } function shellQuote(value) { return `'${value.replaceAll("'", `'"'"'`)}'`; } function xmlEscape(value) { return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); } function schedulerPlatform() { return process.env.ZED_SYNC_PLATFORM || process.platform; } async function configureScheduler(enable, intervalMinutes) { const platform = schedulerPlatform(); const scriptPath = resolve(process.argv[1]); const dryRun = process.env.ZED_SYNC_SCHEDULER_DRY_RUN === "1"; const actions = []; const run = (command, args, options = {}) => { actions.push([command, ...args].join(" ")); if (dryRun) return { status: 0, stdout: "", stderr: "" }; return execute(command, args, options); }; if (platform === "win32") { if (enable) { const taskCommand = `\"${process.execPath}\" \"${scriptPath}\" auto-sync`; const result = run("schtasks.exe", ["/Create", "/SC", "MINUTE", "/MO", String(intervalMinutes), "/TN", SCHEDULER_NAME, "/TR", taskCommand, "/F"]); if (result.status !== 0) throw new Error((result.stderr || result.stdout || "Could not create Windows scheduled task").trim()); } else { run("schtasks.exe", ["/Delete", "/TN", SCHEDULER_NAME, "/F"]); } } else if (platform === "darwin") { const plistPath = join(homedir(), "Library", "LaunchAgents", "cool.okk.zed-settings-sync.plist"); run("launchctl", ["unload", plistPath]); if (enable) { const plist = `\n\n\n Labelcool.okk.zed-settings-sync\n ProgramArguments${xmlEscape(process.execPath)}${xmlEscape(scriptPath)}auto-sync\n StartInterval${intervalMinutes * 60}\n RunAtLoad\n\n`; if (!dryRun) await writeAtomically(plistPath, plist); actions.push(`write ${plistPath}`); const result = run("launchctl", ["load", plistPath]); if (result.status !== 0) throw new Error((result.stderr || "Could not load LaunchAgent").trim()); } else if (!dryRun) { await rm(plistPath, { force: true }); } } else { const systemdDirectory = join(homedir(), ".config", "systemd", "user"); const servicePath = join(systemdDirectory, "zed-settings-sync.service"); const timerPath = join(systemdDirectory, "zed-settings-sync.timer"); const systemd = run("systemctl", ["--user", "--version"]); const systemdSession = systemd.status === 0 ? run("systemctl", ["--user", "show-environment"]) : { status: 1 }; if (enable && systemdSession.status === 0) { const service = `[Unit]\nDescription=Synchronize Zed settings\n\n[Service]\nType=oneshot\nExecStart=\"${process.execPath.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}\" \"${scriptPath.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}\" auto-sync\n`; const timer = `[Unit]\nDescription=Synchronize Zed settings every ${intervalMinutes} minutes\n\n[Timer]\nOnBootSec=2min\nOnUnitActiveSec=${intervalMinutes}min\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n`; if (!dryRun) { await writeAtomically(servicePath, service); await writeAtomically(timerPath, timer); } actions.push(`write ${servicePath}`, `write ${timerPath}`); run("systemctl", ["--user", "daemon-reload"]); const result = run("systemctl", ["--user", "enable", "--now", "zed-settings-sync.timer"]); if (result.status !== 0) throw new Error((result.stderr || "Could not enable systemd user timer").trim()); } else if (!enable) { if (systemd.status === 0) run("systemctl", ["--user", "disable", "--now", "zed-settings-sync.timer"]); if (!dryRun) { await rm(servicePath, { force: true }); await rm(timerPath, { force: true }); } if (systemdSession.status === 0) run("systemctl", ["--user", "daemon-reload"]); const current = run("crontab", ["-l"]); if (current.status === 0) { const lines = current.stdout.split(/\r?\n/).filter((line) => line && !line.includes("# zed-settings-sync")); run("crontab", ["-"], { input: `${lines.join("\n")}\n` }); } } else { const current = run("crontab", ["-l"]); const marker = "# zed-settings-sync"; const lines = (current.status === 0 ? current.stdout : "").split(/\r?\n/).filter((line) => line && !line.includes(marker)); lines.push(`*/${intervalMinutes} * * * * ${shellQuote(process.execPath)} ${shellQuote(scriptPath)} auto-sync ${marker}`); const updated = `${lines.join("\n")}\n`; const result = run("crontab", ["-"], { input: updated }); if (result.status !== 0) throw new Error((result.stderr || "Could not update crontab").trim()); } } if (dryRun) console.log(actions.join("\n")); } async function enableAutomaticSync() { const state = await readSyncConfiguration(); if (!state.config.gist_id) { console.log("Sync must be configured before automatic synchronization can be enabled."); await setupSync({ offerAutomaticSync: false }); } const configured = await readSyncConfiguration(); if (!configured.config.gist_id) return; const auth = await resolveGhAuthentication({ interactive: true, requireStored: true }); console.log(`Stored GitHub credentials verified for ${auth.login}.`); await configureScheduler(true, configured.config.interval_minutes); configured.config.auto_sync = true; await recordResult(configured, `Automatic sync enabled every ${configured.config.interval_minutes} minutes`); console.log(`Automatic synchronization is enabled every ${configured.config.interval_minutes} minutes.`); } async function disableAutomaticSync() { const state = await readSyncConfiguration(); await configureScheduler(false, state.config.interval_minutes); state.config.auto_sync = false; await recordResult(state, "Automatic sync disabled"); console.log("Automatic synchronization is disabled."); } async function main() { const [mode, explicitPath] = process.argv.slice(2); if (mode === "export") return exportBundle(resolve(explicitPath)); if (mode === "import") return importBundle(resolve(explicitPath)); if (mode === "export-dialog") { const path = nativeFileDialog("save"); if (path) await exportBundle(path); return; } if (mode === "import-dialog") { const path = nativeFileDialog("open"); if (path) await importBundle(path); return; } if (mode === "setup") return setupSync(); if (mode === "status") return showStatus(); if (mode === "push") return pushGist(); if (mode === "pull") return pullGist(); if (mode === "auto-sync") return automaticSync(); if (mode === "auto-enable") return enableAutomaticSync(); if (mode === "auto-disable") return disableAutomaticSync(); throw new Error(`Unknown mode: ${mode || ""}`); } try { await main(); } catch (error) { console.error(error.stack || error.message); try { await appendLog(`ERROR ${error.message}`); } catch { // The terminal remains the primary error channel. } process.exitCode = 1; }