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

838 lines
36 KiB
JavaScript

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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
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 = `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n <key>Label</key><string>cool.okk.zed-settings-sync</string>\n <key>ProgramArguments</key><array><string>${xmlEscape(process.execPath)}</string><string>${xmlEscape(scriptPath)}</string><string>auto-sync</string></array>\n <key>StartInterval</key><integer>${intervalMinutes * 60}</integer>\n <key>RunAtLoad</key><true/>\n</dict></plist>\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 || "<missing>"}`);
}
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;
}