Redesign Zed Gist sync workflow

This commit is contained in:
2026-07-30 12:53:31 +08:00
parent f19466a5df
commit 4263d6a25d
14 changed files with 1490 additions and 792 deletions
+5 -1
View File
@@ -1,4 +1,8 @@
{
"gist_id": "",
"last_synced_hash": ""
"last_synced_hash": "",
"auto_sync": false,
"interval_minutes": 15,
"last_run_at": "",
"last_result": ""
}
+531 -194
View File
@@ -1,11 +1,23 @@
import { createHash } from "node:crypto";
import { copyFile, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
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,
@@ -16,8 +28,20 @@ const FIXED_FILES = [
];
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");
@@ -29,6 +53,10 @@ 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`);
@@ -133,9 +161,7 @@ function parseBundle(contents, sourceName) {
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`);
}
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") {
@@ -170,7 +196,6 @@ async function installBundleContents(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"
@@ -198,6 +223,7 @@ function runPowerShell(command, extraEnv = {}) {
}
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"
@@ -218,91 +244,6 @@ function nativeFileDialog(mode) {
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 = {};
@@ -314,8 +255,15 @@ async function readSyncConfiguration() {
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 : "",
},
};
}
@@ -330,140 +278,526 @@ function normalizeGistId(value) {
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) {
function sanitizedEnvironment() {
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");
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);
}
if (result.status !== 0) throw new Error(result.stderr.trim() || `gh ${args[0]} failed`);
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) {
const gist = JSON.parse(runGh(["api", `gists/${gistId}`]));
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) {
function updateRemoteGist(gistId, contents, auth) {
const payload = JSON.stringify({ files: { [BUNDLE_NAME]: { content: contents } } });
return JSON.parse(runGh(["api", "--method", "PATCH", `gists/${gistId}`, "--input", "-"], payload));
return JSON.parse(runGh(["api", "--method", "PATCH", `gists/${gistId}`, "--input", "-"], auth, payload));
}
function createRemoteGist(contents) {
function createRemoteGist(contents, auth) {
const payload = JSON.stringify({
description: "Zed settings sync",
description: GIST_DESCRIPTION,
public: false,
files: { [BUNDLE_NAME]: { content: contents } },
});
return JSON.parse(runGh(["api", "--method", "POST", "gists", "--input", "-"], payload));
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";
}
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).`);
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() {
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;
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;
}
}
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"}`);
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() {
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;
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;
}
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"}`);
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 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 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() {
@@ -480,10 +814,13 @@ async function main() {
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();
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>"}`);
}
@@ -492,9 +829,9 @@ try {
} catch (error) {
console.error(error.stack || error.message);
try {
nativeMessage(error.message, "Zed Settings Sync Error", true);
await appendLog(`ERROR ${error.message}`);
} catch {
// The terminal output remains available when the native dialog backend is unavailable.
// The terminal remains the primary error channel.
}
process.exitCode = 1;
}