Files
zed-sync/bootstrap-from-gist.sh
T

154 lines
6.0 KiB
Bash

#!/usr/bin/env sh
set -eu
command -v node >/dev/null 2>&1 || {
printf '%s\n' 'Node.js is required. Install it with your system package manager.' >&2
exit 1
}
if [ -z "${ZED_SETTINGS_BUNDLE_PATH:-}" ]; then
command -v gh >/dev/null 2>&1 || {
printf '%s\n' 'GitHub CLI (gh) is required: https://cli.github.com/' >&2
exit 1
}
unset GH_TOKEN GITHUB_TOKEN
if ! gh auth status >/dev/null 2>&1; then
printf '%s\n' 'GitHub authentication is required.'
gh auth login </dev/tty >/dev/tty
fi
fi
if [ -n "${ZED_SETTINGS_GIST_ID:-}" ]; then
entered=$ZED_SETTINGS_GIST_ID
else
printf 'GitHub Gist ID or URL (leave blank to create a new Secret Gist): ' >/dev/tty
IFS= read -r entered </dev/tty
fi
temporary_dir=$(mktemp -d)
trap 'rm -rf "$temporary_dir"' EXIT INT TERM
response="$temporary_dir/gist.json"
bundle_path=${ZED_SETTINGS_BUNDLE_PATH:-}
if [ -n "$bundle_path" ]; then
gist_id=$(printf '%s' "$entered" | sed -nE 's#.*[^a-fA-F0-9]([a-fA-F0-9]{8,})/?$#\1#p')
elif [ -z "$entered" ]; then
bundle_path="$temporary_dir/zed-settings-sync.json"
payload="$temporary_dir/create-gist.json"
curl -fsSL 'https://git.okk.cool/purp1e/zed-sync/raw/branch/master/zed-settings-sync.json' -o "$bundle_path"
node - "$bundle_path" "$payload" <<'NODE_CREATE'
const fs = require("node:fs");
const [bundlePath, payloadPath] = process.argv.slice(2);
const content = fs.readFileSync(bundlePath, "utf8");
fs.writeFileSync(payloadPath, JSON.stringify({
description: "Zed settings sync",
public: false,
files: { "zed-settings-sync.json": { content } },
}));
NODE_CREATE
gh api --method POST gists --input "$payload" > "$response"
gist_id=$(node -e 'const g=require(process.argv[1]); process.stdout.write(g.id)' "$response")
gist_url=$(node -e 'const g=require(process.argv[1]); process.stdout.write(g.html_url)' "$response")
printf 'Created Secret Gist: %s\n' "$gist_url"
else
gist_id=$(printf '%s' "$entered" | sed -nE 's#.*[^a-fA-F0-9]([a-fA-F0-9]{8,})/?$#\1#p')
if [ -z "$gist_id" ]; then
gist_id=$(printf '%s' "$entered" | sed -nE 's#^([a-fA-F0-9]{8,})$#\1#p')
fi
if [ -z "$gist_id" ]; then
printf '%s\n' 'Enter a valid GitHub Gist ID or URL.' >&2
exit 1
fi
gh api "gists/$gist_id" > "$response"
fi
if [ -z "${gist_id:-}" ]; then
gist_id=$(printf '%s' "$entered" | sed -nE 's#^([a-fA-F0-9]{8,})$#\1#p')
fi
if [ -z "$gist_id" ]; then
printf '%s\n' 'Enter a valid GitHub Gist ID or URL.' >&2
exit 1
fi
node - "$response" "$gist_id" "$bundle_path" <<'NODE'
const { createHash } = require("node:crypto");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const [responsePath, gistId, localBundlePath] = process.argv.slice(2);
const bundleName = "zed-settings-sync.json";
let bundleSource;
if (localBundlePath) {
bundleSource = fs.readFileSync(localBundlePath, "utf8");
} else {
const gist = JSON.parse(fs.readFileSync(responsePath, "utf8"));
const remoteFile = gist.files?.[bundleName];
if (!remoteFile) throw new Error(`Gist ${gistId} does not contain ${bundleName}.`);
if (remoteFile.truncated) throw new Error(`${bundleName} is too large for the Gist API response.`);
bundleSource = remoteFile.content;
}
const bundle = JSON.parse(bundleSource);
const fixed = new Set([
"settings.json",
"keymap.json",
"tasks.json",
"debug.json",
"scripts/toggle-file-scan-exclusions.mjs",
"scripts/file-exclusions.json",
"scripts/zed-settings-sync.mjs",
]);
function allowed(relativePath) {
const parts = relativePath.split("/");
return typeof relativePath === "string"
&& !relativePath.startsWith("/")
&& !relativePath.includes("\\")
&& !parts.some((part) => !part || part === "." || part === "..")
&& (fixed.has(relativePath) || relativePath.startsWith("snippets/") || relativePath.startsWith("themes/"));
}
for (const required of ["settings.json", "keymap.json", "tasks.json"]) {
if (typeof bundle[required] !== "string") throw new Error(`The bundle is missing ${required}.`);
}
for (const [relativePath, contents] of Object.entries(bundle)) {
if (!allowed(relativePath) || typeof contents !== "string") throw new Error(`Invalid bundle file: ${relativePath}`);
}
const target = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "zed");
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const backup = path.join(target, "backups", `gist-bootstrap-${stamp}`);
fs.mkdirSync(backup, { recursive: true });
for (const relativePath of Object.keys(bundle)) {
const existing = path.join(target, ...relativePath.split("/"));
if (!fs.existsSync(existing)) continue;
const backupPath = path.join(backup, ...relativePath.split("/"));
fs.mkdirSync(path.dirname(backupPath), { recursive: true });
fs.copyFileSync(existing, backupPath);
}
const toggleScript = path.join(target, "scripts", "toggle-file-scan-exclusions.mjs").replaceAll("\\", "/");
const syncScript = path.join(target, "scripts", "zed-settings-sync.mjs").replaceAll("\\", "/");
for (const [relativePath, sourceContents] of Object.entries(bundle)) {
const destination = path.join(target, ...relativePath.split("/"));
let contents = sourceContents;
if (relativePath === "tasks.json") {
contents = contents.replaceAll("__TOGGLE_SCRIPT__", toggleScript).replaceAll("__SYNC_SCRIPT__", syncScript);
}
fs.mkdirSync(path.dirname(destination), { recursive: true });
const temporary = path.join(path.dirname(destination), `.${path.basename(destination)}.${process.pid}.tmp`);
fs.writeFileSync(temporary, contents, "utf8");
fs.renameSync(temporary, destination);
}
const hash = createHash("sha256").update(bundleSource).digest("hex");
const syncStatePath = path.join(target, "scripts", "settings-sync.json");
fs.mkdirSync(path.dirname(syncStatePath), { recursive: true });
fs.writeFileSync(syncStatePath, `${JSON.stringify({ gist_id: gistId.toLowerCase(), last_synced_hash: hash }, null, 2)}\n`, "utf8");
console.log(`Installed Zed settings from Gist ${gistId}`);
console.log(`SHA-256: ${hash}`);
console.log(`Backup: ${backup}`);
console.log("Restart Zed to load the synchronized configuration.");
NODE