import { createHash } from "node:crypto"; import { copyFile, mkdir, readFile, readdir, rename, rm, 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"; const BUNDLE_NAME = "zed-settings-sync.json"; 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"; function zedConfigDirectory() { 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, "-"); } 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)) { // Gist identity and sync history belong to each machine and are never imported. 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.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() : ""; } function nativeInput(prompt, title, defaultValue = "") { if (process.platform === "win32") { const command = ` [Console]::OutputEncoding=[Text.Encoding]::UTF8 Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing $f=New-Object System.Windows.Forms.Form $f.Text=$env:ZED_SYNC_TITLE $f.Size=New-Object System.Drawing.Size(620,190) $f.StartPosition='CenterScreen' $f.TopMost=$true $f.ShowInTaskbar=$true $f.FormBorderStyle='FixedDialog' $f.MaximizeBox=$false $f.MinimizeBox=$false $l=New-Object System.Windows.Forms.Label $l.Text=$env:ZED_SYNC_PROMPT $l.AutoSize=$false $l.Location=New-Object System.Drawing.Point(14,14) $l.Size=New-Object System.Drawing.Size(575,42) $t=New-Object System.Windows.Forms.TextBox $t.Text=$env:ZED_SYNC_DEFAULT $t.Location=New-Object System.Drawing.Point(14,62) $t.Size=New-Object System.Drawing.Size(575,24) $ok=New-Object System.Windows.Forms.Button $ok.Text='OK' $ok.DialogResult=[System.Windows.Forms.DialogResult]::OK $ok.Location=New-Object System.Drawing.Point(427,105) $cancel=New-Object System.Windows.Forms.Button $cancel.Text='Cancel' $cancel.DialogResult=[System.Windows.Forms.DialogResult]::Cancel $cancel.Location=New-Object System.Drawing.Point(514,105) $f.Controls.AddRange(@($l,$t,$ok,$cancel)) $f.AcceptButton=$ok $f.CancelButton=$cancel $f.Add_Shown({$f.Activate();$t.Focus();$t.SelectAll()}) if($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK){[Console]::Write($t.Text)} `; return runPowerShell(command, { ZED_SYNC_PROMPT: prompt, ZED_SYNC_TITLE: title, ZED_SYNC_DEFAULT: defaultValue, }); } if (process.platform === "darwin") { const escaped = (value) => value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); const script = `text returned of (display dialog "${escaped(prompt)}" with title "${escaped(title)}" default answer "${escaped(defaultValue)}")`; const result = spawnSync("osascript", ["-e", script], { encoding: "utf8" }); return result.status === 0 ? result.stdout.trim() : ""; } const result = spawnSync("zenity", ["--entry", `--title=${title}`, `--text=${prompt}`, `--entry-text=${defaultValue}`], { encoding: "utf8" }); return result.status === 0 ? result.stdout.trim() : ""; } function nativeMessage(message, title = "Zed Settings Sync", error = false) { if (process.platform === "win32") { const command = "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(); [void][System.Windows.Forms.MessageBox]::Show($o,$env:ZED_SYNC_MESSAGE,$env:ZED_SYNC_TITLE,'OK',$env:ZED_SYNC_ICON); $o.Close()"; runPowerShell(command, { ZED_SYNC_MESSAGE: message, ZED_SYNC_TITLE: title, ZED_SYNC_ICON: error ? "Error" : "Information", }); return; } if (process.platform === "darwin") { const escaped = message.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); spawnSync("osascript", ["-e", `display dialog "${escaped}" with title "${title}" buttons {"OK"}`]); return; } spawnSync("zenity", [error ? "--error" : "--info", `--title=${title}`, `--text=${message}`]); } function nativeConfirm(message, title = "Zed Settings Sync") { if (process.platform === "win32") { const command = "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(); [Console]::Write([System.Windows.Forms.MessageBox]::Show($o,$env:ZED_SYNC_MESSAGE,$env:ZED_SYNC_TITLE,'YesNo','Warning')); $o.Close()"; return runPowerShell(command, { ZED_SYNC_MESSAGE: message, ZED_SYNC_TITLE: title }) === "Yes"; } if (process.platform === "darwin") { const escaped = message.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); const result = spawnSync("osascript", ["-e", `button returned of (display dialog "${escaped}" with title "${title}" buttons {"Cancel", "Continue"} default button "Continue" cancel button "Cancel")`], { encoding: "utf8" }); return result.status === 0 && result.stdout.trim() === "Continue"; } return spawnSync("zenity", ["--question", `--title=${title}`, `--text=${message}`]).status === 0; } 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: { gist_id: typeof source.gist_id === "string" ? source.gist_id : "", last_synced_hash: typeof source.last_synced_hash === "string" ? source.last_synced_hash : "", }, }; } 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(); } async function promptForGist() { const state = await readSyncConfiguration(); console.log("Opening Gist configuration dialog..."); const entered = nativeInput( "Paste the GitHub Gist ID or full Gist URL. The previous value is remembered on this machine.", "Zed Settings Gist", state.config.gist_id, ); if (!entered) return null; const gistId = normalizeGistId(entered); if (gistId !== state.config.gist_id) { state.config.gist_id = gistId; state.config.last_synced_hash = ""; await saveSyncConfiguration(state.path, state.config); } return state; } function runGh(args, input) { const env = { ...process.env }; delete env.GH_TOKEN; delete env.GITHUB_TOKEN; const result = spawnSync("gh", args, { encoding: "utf8", env, input, windowsHide: true }); if (result.error?.code === "ENOENT") { throw new Error("GitHub CLI (gh) is not installed. Install it, then run: gh auth login"); } if (result.status !== 0) throw new Error(result.stderr.trim() || `gh ${args[0]} failed`); return result.stdout.trim(); } function readRemoteGist(gistId) { const gist = JSON.parse(runGh(["api", `gists/${gistId}`])); 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) { const payload = JSON.stringify({ files: { [BUNDLE_NAME]: { content: contents } } }); return JSON.parse(runGh(["api", "--method", "PATCH", `gists/${gistId}`, "--input", "-"], payload)); } function createRemoteGist(contents) { const payload = JSON.stringify({ description: "Zed settings sync", public: false, files: { [BUNDLE_NAME]: { content: contents } }, }); return JSON.parse(runGh(["api", "--method", "POST", "gists", "--input", "-"], payload)); } function shortHash(hash) { return hash ? hash.slice(0, 12) : "none"; } async function configureGist() { const state = await promptForGist(); if (!state) return; nativeMessage(`Saved Gist ID:\n${state.config.gist_id}\n\nAuthentication is managed by GitHub CLI (gh).`); } async function pushGist() { const state = await readSyncConfiguration(); const localBundle = await createBundle(); if (!state.config.gist_id) { console.log("No Gist is configured. Asking to create one..."); const proceed = nativeConfirm( "No GitHub Gist is configured. Create a new Secret Gist from this machine's current Zed settings?", ); if (!proceed) return; const created = createRemoteGist(localBundle.contents); state.config.gist_id = created.id; state.config.last_synced_hash = localBundle.hash; await saveSyncConfiguration(state.path, state.config); nativeMessage(`Created and uploaded Secret Gist:\n${created.html_url || created.id}\n\nSHA-256: ${localBundle.hash}`); return; } const remote = readRemoteGist(state.config.gist_id); const remoteHash = remote.parsed?.hash || ""; const lastHash = state.config.last_synced_hash; if (remoteHash && remoteHash !== localBundle.hash) { const remoteChanged = !lastHash || remoteHash !== lastHash; if (remoteChanged) { const proceed = nativeConfirm( `The Gist contains different settings.\n\nLocal: ${shortHash(localBundle.hash)}\nRemote: ${shortHash(remoteHash)}\nLast sync: ${shortHash(lastHash)}\n\nOverwrite the Gist with this machine's settings?`, ); if (!proceed) return; } } const updated = updateRemoteGist(state.config.gist_id, localBundle.contents); state.config.last_synced_hash = localBundle.hash; await saveSyncConfiguration(state.path, state.config); nativeMessage(`Uploaded to Gist ${state.config.gist_id}\n\nSHA-256: ${localBundle.hash}\nUpdated: ${updated.updated_at || "unknown"}`); } async function pullGist() { const state = await promptForGist(); if (!state) return; const localBundle = await createBundle(); const remote = readRemoteGist(state.config.gist_id); if (!remote.parsed) throw new Error(`Gist ${state.config.gist_id} does not contain ${BUNDLE_NAME}`); if (localBundle.hash !== remote.parsed.hash) { const localChanged = !state.config.last_synced_hash || localBundle.hash !== state.config.last_synced_hash; if (localChanged) { const proceed = nativeConfirm( `This machine contains different settings.\n\nLocal: ${shortHash(localBundle.hash)}\nRemote: ${shortHash(remote.parsed.hash)}\nLast sync: ${shortHash(state.config.last_synced_hash)}\n\nReplace local settings with the Gist version? A backup will be created first.`, ); if (!proceed) return; } await installBundleContents(remote.contents, `Gist ${state.config.gist_id}`); } state.config.last_synced_hash = remote.parsed.hash; await saveSyncConfiguration(state.path, state.config); nativeMessage(`Downloaded from Gist ${state.config.gist_id}\n\nSHA-256: ${remote.parsed.hash}\nRemote updated: ${remote.gist.updated_at || "unknown"}`); } async function showGistStatus() { const state = await promptForGist(); if (!state) return; const localBundle = await createBundle(); const remote = readRemoteGist(state.config.gist_id); const remoteHash = remote.parsed?.hash || ""; const lastHash = state.config.last_synced_hash; let status = "The Gist does not contain a Zed settings file."; if (remoteHash === localBundle.hash) status = "Synchronized"; else if (!lastHash) status = "Not synchronized on this machine"; else if (localBundle.hash === lastHash) status = "Remote changes available"; else if (remoteHash === lastHash) status = "Local changes pending upload"; else status = "Conflict: local and remote settings both changed"; nativeMessage( `Gist: ${remote.gist.html_url || state.config.gist_id}\nStatus: ${status}\n\nLocal: ${shortHash(localBundle.hash)}\nRemote: ${shortHash(remoteHash)}\nLast sync: ${shortHash(lastHash)}\nRemote updated: ${remote.gist.updated_at || "unknown"}`, "Zed Settings Gist Status", ); } 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 === "gist-configure") return configureGist(); if (mode === "gist-status") return showGistStatus(); if (mode === "gist-push") return pushGist(); if (mode === "gist-pull") return pullGist(); throw new Error(`Unknown mode: ${mode || ""}`); } try { await main(); } catch (error) { console.error(error.stack || error.message); try { nativeMessage(error.message, "Zed Settings Sync Error", true); } catch { // The terminal output remains available when the native dialog backend is unavailable. } process.exitCode = 1; }