838 lines
36 KiB
JavaScript
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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
}
|
|
|
|
function schedulerPlatform() {
|
|
return process.env.ZED_SYNC_PLATFORM || process.platform;
|
|
}
|
|
|
|
async function configureScheduler(enable, intervalMinutes) {
|
|
const platform = schedulerPlatform();
|
|
const scriptPath = resolve(process.argv[1]);
|
|
const dryRun = process.env.ZED_SYNC_SCHEDULER_DRY_RUN === "1";
|
|
const actions = [];
|
|
const run = (command, args, options = {}) => {
|
|
actions.push([command, ...args].join(" "));
|
|
if (dryRun) return { status: 0, stdout: "", stderr: "" };
|
|
return execute(command, args, options);
|
|
};
|
|
|
|
if (platform === "win32") {
|
|
if (enable) {
|
|
const taskCommand = `\"${process.execPath}\" \"${scriptPath}\" auto-sync`;
|
|
const result = run("schtasks.exe", ["/Create", "/SC", "MINUTE", "/MO", String(intervalMinutes), "/TN", SCHEDULER_NAME, "/TR", taskCommand, "/F"]);
|
|
if (result.status !== 0) throw new Error((result.stderr || result.stdout || "Could not create Windows scheduled task").trim());
|
|
} else {
|
|
run("schtasks.exe", ["/Delete", "/TN", SCHEDULER_NAME, "/F"]);
|
|
}
|
|
} else if (platform === "darwin") {
|
|
const plistPath = join(homedir(), "Library", "LaunchAgents", "cool.okk.zed-settings-sync.plist");
|
|
run("launchctl", ["unload", plistPath]);
|
|
if (enable) {
|
|
const plist = `<?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;
|
|
}
|