Fix Gist setup and first-run bootstrap

This commit is contained in:
2026-07-29 17:43:26 +08:00
parent 13cf327f48
commit f19466a5df
9 changed files with 189 additions and 65 deletions
+4 -4
View File
@@ -18,7 +18,7 @@ macOS/Linux
curl -fsSL https://git.okk.cool/purp1e/zed-sync/raw/branch/master/bootstrap-from-gist.sh | sh
```
脚本会处理 GitHub 登录询问 Gist URL/ID、从 Gist 下载单个 `zed-settings-sync.json`备份当前 Zed 配置并完成安装安装后重启 Zed。
脚本会处理 GitHub 登录询问 Gist URL/ID。输入已有 Gist 下载其配置;直接留空会从本仓库种子配置自动创建新的 Secret Gist。随后脚本会备份当前 Zed 配置并完成安装安装后重启 Zed。
For first-machine setup, new-machine onboarding, daily Gist synchronization, and conflict handling, see [`SYNC-MANUAL.zh-CN.md`](./SYNC-MANUAL.zh-CN.md).
@@ -38,12 +38,12 @@ For first-machine setup, new-machine onboarding, daily Gist synchronization, and
Open the Task Picker and search for `Zed Settings`:
- `Export JSON...` and `Import JSON...` show a native file picker.
- `Configure GitHub Gist...` accepts a Gist ID or complete URL and remembers it on this machine.
- `Configure GitHub Gist...` accepts an existing Gist ID or complete URL and remembers it on this machine.
- `Show GitHub Gist Status` compares the current, remote, and last synchronized SHA-256 hashes.
- `Push to GitHub Gist` and `Pull from GitHub Gist` use the remembered Gist and ask before overwriting divergent settings.
- `Push to GitHub Gist` creates a new Secret Gist when none is configured; later Push/Pull operations use the remembered Gist and ask before overwriting divergent settings.
- `Authenticate GitHub...` runs `gh auth login` in the terminal.
GitHub CLI (`gh`) must be installed. The Gist must already exist and may contain other files; synchronization only creates or replaces `zed-settings-sync.json` inside it.
GitHub CLI (`gh`) must be installed and authenticated. An existing Gist may contain other files; synchronization only creates or replaces `zed-settings-sync.json` inside it.
The synchronized JSON is deliberately flat. Its top-level keys are paths such as `settings.json`, `keymap.json`, and `scripts/file-exclusions.json`, whose values are the complete file contents. No version, metadata, `files` wrapper, or hash is embedded. SHA-256 values are calculated at runtime and only the last synchronized value is retained locally.
+9 -10
View File
@@ -22,9 +22,9 @@
## 2. 准备 Gist
1. 登录 GitHub,打开 <https://gist.github.com/>
2. 创建一个 Secret Gist。GitHub 不允许创建完全空白的 Gist,可以先放一个 `README.md`
3. 复制完整 Gist URL 或末尾的 Gist ID
不需要预先创建 Gist:第一次执行 Push 时,如果本机尚未配置 Gist,系统会询问是否自动创建新的 Secret Gist
已有 Gist 时,也可以登录 GitHub、打开 <https://gist.github.com/>,复制完整 Gist URL 或末尾的 Gist ID,再通过 `Configure GitHub Gist...` 关联
Gist ID 不是访问令牌。身份认证由 GitHub CLI `gh` 管理。
@@ -58,13 +58,12 @@ Zed Settings: Authenticate GitHub...
gh auth login
```
### 3.2 保存 Gist
### 3.2 创建或关联 Gist
1.`Mod+R`
2. 运行 `Zed Settings: Configure GitHub Gist...`
3. 粘贴 Gist URL 或 Gist ID
4. 运行 `Zed Settings: Push to GitHub Gist`
5. 运行 `Zed Settings: Show GitHub Gist Status`,确认状态为 `Synchronized`
2. 没有 Gist 时直接运行 `Zed Settings: Push to GitHub Gist`,确认自动创建
3. 已有 Gist 时先运行 `Zed Settings: Configure GitHub Gist...` 并粘贴 URL/ID,再执行 Push
4. 运行 `Zed Settings: Show GitHub Gist Status`,确认状态为 `Synchronized`
Push 只会在该 Gist 中创建或更新 `zed-settings-sync.json`,不会删除 Gist 中的其他文件。
@@ -126,8 +125,8 @@ curl -fsSL https://<Gitea域名>/<用户>/<仓库>/raw/branch/main/bootstrap-fro
1. 检查 Node.js 和 `gh`
2. 在尚未登录时运行 `gh auth login`
3. 询问 Gist URL 或 ID。
4. 从 GitHub API 读取 `zed-settings-sync.json`
3. 询问 Gist URL 或 ID;留空时自动创建新的 Secret Gist
4. 输入已有 Gist 时从 GitHub API 读取配置;留空时使用本仓库的 `zed-settings-sync.json` 种子配置
5. 验证顶层文件路径,防止写出 Zed 配置目录。
6. 备份新机器已有配置。
7. 安装设置、键位、Tasks、snippets、themes 和辅助脚本。
+19 -3
View File
@@ -48,18 +48,20 @@ function Write-Atomic([string]$Path, [string]$Contents) {
}
Require-Command "node" "winget install OpenJS.NodeJS.LTS"
if (-not $env:ZED_SETTINGS_BUNDLE_PATH) {
Require-Command "gh" "winget install GitHub.cli"
Remove-Item Env:GH_TOKEN,Env:GITHUB_TOKEN -ErrorAction SilentlyContinue
& gh auth status *> $null
if ($LASTEXITCODE -ne 0) {
Write-Host "GitHub authentication is required."
& gh auth login
if ($LASTEXITCODE -ne 0) { throw "GitHub authentication failed." }
}
}
$entered = if ($env:ZED_SETTINGS_GIST_ID) { $env:ZED_SETTINGS_GIST_ID } else { Read-Host "GitHub Gist ID or full URL" }
$gistId = Get-GistId $entered
$entered = if ($env:ZED_SETTINGS_GIST_ID) { $env:ZED_SETTINGS_GIST_ID } else { Read-Host "GitHub Gist ID or URL (leave blank to create a new Secret Gist)" }
if ($env:ZED_SETTINGS_BUNDLE_PATH) {
$gistId = Get-GistId $entered
$bundleSource = [IO.File]::ReadAllText($env:ZED_SETTINGS_BUNDLE_PATH, [Text.Encoding]::UTF8)
} else {
$token = (& gh auth token).Trim()
@@ -70,12 +72,26 @@ if ($env:ZED_SETTINGS_BUNDLE_PATH) {
"User-Agent" = "zed-settings-bootstrap"
"X-GitHub-Api-Version" = "2022-11-28"
}
if ([string]::IsNullOrWhiteSpace($entered)) {
$seedUrl = "https://git.okk.cool/purp1e/zed-sync/raw/branch/master/zed-settings-sync.json"
$bundleSource = (Invoke-WebRequest -UseBasicParsing -Uri $seedUrl).Content
$payload = @{
description = "Zed settings sync"
public = $false
files = @{ $bundleName = @{ content = $bundleSource } }
} | ConvertTo-Json -Depth 5
$created = Invoke-RestMethod -Method Post -Uri "https://api.github.com/gists" -Headers $headers -ContentType "application/json" -Body ([Text.Encoding]::UTF8.GetBytes($payload))
$gistId = $created.id
Write-Host "Created Secret Gist: $($created.html_url)"
} else {
$gistId = Get-GistId $entered
$gist = Invoke-RestMethod -Uri "https://api.github.com/gists/$gistId" -Headers $headers
$remoteFile = $gist.files.$bundleName
if (-not $remoteFile) { throw "Gist $gistId does not contain $bundleName." }
if ($remoteFile.truncated) { throw "$bundleName is too large for the Gist API response." }
$bundleSource = [string]$remoteFile.content
}
}
try {
$bundle = $bundleSource | ConvertFrom-Json
+40 -8
View File
@@ -5,22 +5,51 @@ 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 full URL: ' >/dev/tty
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')
@@ -29,15 +58,18 @@ if [ -z "$gist_id" ]; then
printf '%s\n' 'Enter a valid GitHub Gist ID or URL.' >&2
exit 1
fi
temporary_dir=$(mktemp -d)
trap 'rm -rf "$temporary_dir"' EXIT INT TERM
response="$temporary_dir/gist.json"
if [ -z "${ZED_SETTINGS_BUNDLE_PATH:-}" ]; then
gh api "gists/$gist_id" > "$response"
fi
node - "$response" "$gist_id" "${ZED_SETTINGS_BUNDLE_PATH:-}" <<'NODE'
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");
+3
View File
@@ -22,4 +22,7 @@ foreach ($script in @(
}
[IO.File]::WriteAllText((Join-Path $target "tasks.json"), $tasks, $utf8)
node (Join-Path $source "scripts\zed-settings-sync.mjs") export (Join-Path $target "zed-settings-sync.json")
if ($LASTEXITCODE -ne 0) { throw "Failed to generate zed-settings-sync.json" }
Write-Host "Exported Zed configuration from $source"
+2
View File
@@ -19,4 +19,6 @@ const tasks = fs.readFileSync(source, "utf8")
fs.writeFileSync(target, tasks);
NODE
node "$source/scripts/zed-settings-sync.mjs" export "$target/zed-settings-sync.json"
printf 'Exported Zed configuration from %s\n' "$source"
+70 -10
View File
@@ -191,7 +191,7 @@ function runPowerShell(command, extraEnv = {}) {
const result = spawnSync("powershell.exe", ["-NoProfile", "-STA", "-Command", command], {
encoding: "utf8",
env: { ...process.env, ...extraEnv },
windowsHide: true,
windowsHide: false,
});
if (result.status !== 0) throw new Error(result.stderr.trim() || "Windows dialog failed");
return result.stdout.trim();
@@ -199,10 +199,10 @@ function runPowerShell(command, extraEnv = {}) {
function nativeFileDialog(mode) {
if (process.platform === "win32") {
const common = "[Console]::OutputEncoding=[Text.Encoding]::UTF8; Add-Type -AssemblyName System.Windows.Forms; ";
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() -eq 'OK'){[Console]::Write($d.FileName)}`
: `${common}$d=New-Object System.Windows.Forms.OpenFileDialog; $d.Filter='Zed settings (*.json)|*.json'; $d.FileName='${BUNDLE_NAME}'; if($d.ShowDialog() -eq 'OK'){[Console]::Write($d.FileName)}`;
? `${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") {
@@ -220,7 +220,42 @@ function nativeFileDialog(mode) {
function nativeInput(prompt, title, defaultValue = "") {
if (process.platform === "win32") {
const command = "[Console]::OutputEncoding=[Text.Encoding]::UTF8; Add-Type -AssemblyName Microsoft.VisualBasic; [Console]::Write([Microsoft.VisualBasic.Interaction]::InputBox($env:ZED_SYNC_PROMPT,$env:ZED_SYNC_TITLE,$env:ZED_SYNC_DEFAULT))";
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,
@@ -239,7 +274,7 @@ function nativeInput(prompt, title, defaultValue = "") {
function nativeMessage(message, title = "Zed Settings Sync", error = false) {
if (process.platform === "win32") {
const command = "Add-Type -AssemblyName System.Windows.Forms; [void][System.Windows.Forms.MessageBox]::Show($env:ZED_SYNC_MESSAGE,$env:ZED_SYNC_TITLE,'OK',$env:ZED_SYNC_ICON)";
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,
@@ -257,7 +292,7 @@ function nativeMessage(message, title = "Zed Settings Sync", error = false) {
function nativeConfirm(message, title = "Zed Settings Sync") {
if (process.platform === "win32") {
const command = "Add-Type -AssemblyName System.Windows.Forms; [Console]::Write([System.Windows.Forms.MessageBox]::Show($env:ZED_SYNC_MESSAGE,$env:ZED_SYNC_TITLE,'YesNo','Warning'))";
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") {
@@ -297,6 +332,7 @@ function normalizeGistId(value) {
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",
@@ -313,7 +349,10 @@ async function promptForGist() {
}
function runGh(args, input) {
const result = spawnSync("gh", args, { encoding: "utf8", input, windowsHide: true });
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");
}
@@ -334,6 +373,15 @@ function updateRemoteGist(gistId, contents) {
return JSON.parse(runGh(["api", "--method", "PATCH", `gists/${gistId}`, "--input", "-"], payload));
}
function createRemoteGist(contents) {
const payload = JSON.stringify({
description: "Zed settings sync",
public: false,
files: { [BUNDLE_NAME]: { content: contents } },
});
return JSON.parse(runGh(["api", "--method", "POST", "gists", "--input", "-"], payload));
}
function shortHash(hash) {
return hash ? hash.slice(0, 12) : "none";
}
@@ -345,9 +393,21 @@ async function configureGist() {
}
async function pushGist() {
const state = await promptForGist();
if (!state) return;
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;
+12 -8
View File
@@ -107,8 +107,8 @@
"gist-configure"
],
"allow_concurrent_runs": false,
"reveal": "never",
"hide": "always",
"reveal": "no_focus",
"hide": "never",
"show_summary": false,
"show_command": false,
"save": "all"
@@ -121,8 +121,8 @@
"gist-status"
],
"allow_concurrent_runs": false,
"reveal": "never",
"hide": "always",
"reveal": "no_focus",
"hide": "never",
"show_summary": false,
"show_command": false,
"save": "all"
@@ -136,7 +136,7 @@
],
"allow_concurrent_runs": false,
"reveal": "no_focus",
"hide": "on_success",
"hide": "never",
"show_summary": false,
"show_command": false,
"save": "all"
@@ -150,15 +150,19 @@
],
"allow_concurrent_runs": false,
"reveal": "no_focus",
"hide": "on_success",
"hide": "never",
"show_summary": false,
"show_command": false,
"save": "all"
},
{
"label": "Zed Settings: Authenticate GitHub...",
"command": "gh",
"args": ["auth", "login"],
"command": "powershell",
"args": [
"-NoExit",
"-Command",
"Remove-Item Env:GH_TOKEN,Env:GITHUB_TOKEN -ErrorAction SilentlyContinue; gh auth login"
],
"allow_concurrent_runs": false,
"reveal": "always",
"hide": "never",
File diff suppressed because one or more lines are too long