Files
zed-sync/scripts/zed-settings-sync.mjs
T

501 lines
21 KiB
JavaScript

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 || "<missing>"}`);
}
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;
}