Redesign Zed Gist sync workflow
This commit is contained in:
+531
-194
@@ -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("&", "&").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() {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user