Redesign Zed Gist sync workflow

This commit is contained in:
2026-07-30 12:53:31 +08:00
parent f19466a5df
commit 4263d6a25d
14 changed files with 1490 additions and 792 deletions
+77 -61
View File
@@ -1,102 +1,118 @@
# Zed portable configuration # Zed portable configuration
This directory is the portable source for Zed settings, keybindings, tasks, extension installation preferences, and the file-exclusion toggle.
## 一行安装 ## 一行安装
前置依赖:已安装 Node.js 和 GitHub CLI `gh`
Windows PowerShell Windows PowerShell
```powershell ```powershell
irm https://git.okk.cool/purp1e/zed-sync/raw/branch/master/bootstrap-from-gist.ps1 | iex irm https://git.okk.cool/purp1e/zed-sync/raw/branch/master/bootstrap.ps1 | iex
``` ```
macOS/Linux macOS / Linux
```sh ```sh
curl -fsSL https://git.okk.cool/purp1e/zed-sync/raw/branch/master/bootstrap-from-gist.sh | sh curl -fsSL https://git.okk.cool/purp1e/zed-sync/raw/branch/master/bootstrap.sh | sh
``` ```
脚本会处理 GitHub 登录并询问 Gist URL/ID。输入已有 Gist 会下载其配置;直接留空会从本仓库种子配置自动创建新的 Secret Gist。随后脚本会备份当前 Zed 配置并完成安装,安装后重启 Zed 安装只需要 Node.js,会先备份并安装本地 Zed 配置,不要求 GitHub 登录,也不要求预先创建 Gist。安装完成后可以立即进入同步向导,也可以稍后在 Zed Task Picker 中运行 `Zed Settings: Set Up / Reconfigure Sync...`
For first-machine setup, new-machine onboarding, daily Gist synchronization, and conflict handling, see [`SYNC-MANUAL.zh-CN.md`](./SYNC-MANUAL.zh-CN.md). GitHub 同步额外需要 [GitHub CLI](https://cli.github.com/) `gh`。向导使用浏览器完成 GitHub 授权,不需要手工创建或粘贴 Personal Access Token。
## Files 完整的新机器、日常同步、自动同步和冲突处理说明见 [SYNC-MANUAL.zh-CN.md](./SYNC-MANUAL.zh-CN.md)。
- `settings.json`: editor settings and extension installation list. Provider secrets are not stored here. ## 同步体验
- `keymap.json`: cross-platform bindings using `secondary-` for Command/Ctrl.
- `tasks.json`: task template. Script-path placeholders are rendered during installation.
- `scripts/file-exclusions.json`: the only file to edit when changing permanent or toggleable exclusion rules.
- `scripts/toggle-file-scan-exclusions.mjs`: updates `file_scan_exclusions` in the active Zed settings.
- `scripts/settings-sync.json`: machine-local Gist ID and last synchronized hash. Export and import never overwrite it.
- `scripts/zed-settings-sync.mjs`: single-JSON import, export, hashing, and Gist synchronization.
- `bootstrap-from-gist.ps1` / `bootstrap-from-gist.sh`: standalone one-line installers for a fresh machine. They download the single JSON directly from Gist and do not need the rest of this directory.
## Zed Task Picker commands - 云端始终只有一个扁平的 `zed-settings-sync.json`
- 首次设置会自动发现当前 GitHub 账号中已有的 Zed 设置 Gist。
- 没有可用 Gist 时,可直接创建新的 Secret Gist 并上传本机设置。
- Gist ID、同步基线和自动同步状态只保存在当前机器,不会上传。
- `GH_TOKEN` 有效时会正常使用;无效时自动回退到 GitHub CLI 凭据存储。
- Push、Pull、Setup 和 Status 都在可见终端中运行,不会再静默失败。
- 自动同步默认关闭,可选择每 15 分钟检测一次。
Open the Task Picker and search for `Zed Settings`: Secret Gist 不会列在公开资料和搜索中,但它不是严格私有存储:任何拿到链接的人都可以读取。同步包不会包含 API Key,但仍应避免在 Zed 配置和代码片段中保存秘密。
- `Export JSON...` and `Import JSON...` show a native file picker. ## Zed Tasks
- `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` 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 and authenticated. An existing Gist may contain other files; synchronization only creates or replaces `zed-settings-sync.json` inside it. 打开 Task Picker,搜索 `Zed Settings`
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. | Task | 用途 |
|---|---|
| `Set Up / Reconfigure Sync...` | 浏览器登录 GitHub,发现、创建或更换 Gist |
| `Show Sync Status` | 查看本地、远端、同步基线和自动同步状态 |
| `Push Now` | 手动上传本机设置;覆盖远端前确认 |
| `Pull Now` | 手动下载远端设置;覆盖本机前确认并备份 |
| `Enable Automatic Sync` | 安装用户级 15 分钟定时任务 |
| `Disable Automatic Sync` | 删除定时任务并关闭自动同步 |
| `Export JSON...` | 用系统文件选择器导出单个 JSON |
| `Import JSON...` | 从单个 JSON 导入并先备份当前配置 |
A secret Gist is unlisted, not access-controlled: anyone who obtains its URL can read it. This bundle deliberately excludes API keys, but a synchronized folder or private Git repository is the safer choice for configuration that contains sensitive paths or identifiers. 快捷键:
## Installed keybindings - `Mod+R`:打开 Task Picker。
- `Mod+; U`:运行 `Push Now`
- `Mod+; D`:运行 `Pull Now`
- `Mod+9` / `Mod+0`:当前编辑器区域的上一个 / 下一个 Tab。
- `Alt+1``Alt+5`:切换编辑器区域。
- `Mod+Shift+R`:切换最近项目。
- `Mod+; K`:重载 Zed。
- `Mod+; C`:打开 Git Panel;再次按下聚焦 Commit Message。
- `Mod+R`: open the Task Picker; search for `Zed Settings` to import, export, push, or pull. `Mod` 在 macOS 是 Command,在 Windows/Linux 是 Ctrl;配置通过 Zed 的 `secondary-` 语法跨平台复用。
- `Mod+; U`: upload the current single JSON to the configured Gist.
- `Mod+; D`: download and import the single JSON from the configured Gist.
- `Mod+9` / `Mod+0`: activate the previous / next tab in the current pane.
- `Alt+1` through `Alt+5`: activate editor pane 1 through 5.
- `Mod+Shift+R`: switch to a recent project.
- `Mod+; K`: reload Zed.
- `Mod+; C`: focus the Git panel; press it again to focus the commit-message editor.
`Mod` is Command on macOS and Ctrl on Windows/Linux because the keymap uses Zed's `secondary-` syntax. `Alt` maps to Option on macOS. ## 自动同步规则
## Windows 自动同步使用当前用户级系统调度器:
Install this copy into Zed: - WindowsTask Scheduler 的 `ZedSettingsSync`
- macOS`~/Library/LaunchAgents/cool.okk.zed-settings-sync.plist`
- Linux:优先使用 systemd user timer,不可用时回退到带标记的 crontab。
状态处理:
| 状态 | 自动行为 |
|---|---|
| 本地和远端一致 | 不修改 |
| 只有本地变化 | Push |
| 只有远端变化 | 备份后 Pull |
| 两边都变化 | 停止并通知,不覆盖 |
| 没有同步基线 | 停止并通知,不猜测方向 |
| 网络或认证失败 | 记录错误,不改变配置或基线 |
日志位于 Zed 配置目录的 `logs/settings-sync.log`,超过 1 MB 后自动轮换。自动同步要求 GitHub CLI 凭据保存在系统凭据存储中;仅存在于某个进程内的 `GH_TOKEN` 不足以供后台任务长期使用。
## 仓库文件
- `settings.json`:Zed 设置和扩展安装清单,不包含 Provider 密钥。
- `keymap.json`:跨平台键位。
- `tasks.json`:Zed Tasks 模板,安装时替换脚本路径占位符。
- `scripts/file-exclusions.json`:修改常驻和可切换的文件排除规则。
- `scripts/toggle-file-scan-exclusions.mjs`:切换 Project Panel 文件显隐。
- `scripts/settings-sync.json`:机器本地状态,不进入同步包。
- `scripts/zed-settings-sync.mjs`:导入、导出、Gist 和自动同步核心。
- `bootstrap.ps1` / `bootstrap.sh`:新机器非阻塞安装器。
- `zed-settings-sync.json`:可以上传到 Gist 的单个扁平同步文件。
AI / Codex ACP 设置见 [AI-SETUP.md](./AI-SETUP.md)。
## 本地开发
从仓库安装到当前机器:
```powershell ```powershell
.\install.ps1 .\install.ps1
``` ```
Export later changes from Zed back into this directory: 从当前 Zed 配置重新生成仓库配置和单文件:
```powershell ```powershell
.\export.ps1 .\export.ps1
``` ```
## macOS and Linux 运行隔离测试:
Node.js is required for the exclusion toggle and task-template rendering.
```sh ```sh
sh ./install.sh node --test tests/settings-sync.test.mjs
sh ./export.sh
``` ```
## Private Git synchronization 测试使用模拟 `gh` 和临时 Zed 目录,不会访问或创建真实 Gist。
Initialize this directory once and push it to a private remote:
```sh
git init
git add .
git commit -m "Add portable Zed configuration"
git remote add origin <private-repository-url>
git push -u origin main
```
On another machine, clone the repository and run the platform installer. After changing Zed settings, run the export script, review `git diff`, then commit and push. On other machines, pull and run the installer again.
Zed account login does not currently synchronize these files. API keys remain in each machine's system keychain or environment variables and must not be committed.
+142 -175
View File
@@ -1,255 +1,222 @@
# Zed 设置同步使用手册 # Zed 设置同步手册
本方案使用 GitHub Gist 中的单个 `zed-settings-sync.json` 同步 Zed 设置。JSON 顶层直接保存各配置文件的路径和完整内容;SHA-256 只在运行时计算,不写入同步文件 本方案把 Zed 的设置、键位、Tasks、文件排除规则、代码片段和主题整理为一个扁平的 `zed-settings-sync.json`,通过 GitHub Gist 在机器间同步。同步文件内部不保存哈希、Gist ID、账号或 token
## 1. 同步内容 ## 准备工作
会同步 本地安装需要
- `settings.json`:界面、字体、主题、编辑器行为及扩展自动安装清单。 - Node.js
- `keymap.json`:自定义键位。 - Windows PowerShell,或 macOS/Linux 的 `curl`
- `tasks.json`:文件显隐和设置同步任务。
- `scripts/` 中的文件显隐及同步脚本。
- 非空的 `snippets/``themes/` 目录。
不会同步 启用 GitHub Gist 同步还需要 GitHub CLI `gh`
- GitHub 登录凭据、API Key 和系统环境变量。
- 本机保存的 Gist ID 和上次同步哈希。
- Zed 数据库、缓存、聊天记录、项目文件及扩展二进制。
扩展 ID 位于 `settings.json``auto_install_extensions` 中。导入后由 Zed 在目标平台重新安装扩展。
## 2. 准备 Gist
不需要预先创建 Gist:第一次执行 Push 时,如果本机尚未配置 Gist,系统会询问是否自动创建新的 Secret Gist。
已有 Gist 时,也可以登录 GitHub、打开 <https://gist.github.com/>,复制完整 Gist URL 或末尾的 Gist ID,再通过 `Configure GitHub Gist...` 关联。
Gist ID 不是访问令牌。身份认证由 GitHub CLI `gh` 管理。
Secret Gist 只是不出现在公开搜索中,获得链接的人仍然可以读取其内容。同步 JSON 不包含 API Key,但仍应妥善保管 Gist URL。
## 3. 主机器首次上传
### 3.1 安装和认证 GitHub CLI
当前 Windows 机器已经安装 `gh`。其他机器可以使用系统包管理器安装:
```powershell ```powershell
# Windows
winget install GitHub.cli winget install GitHub.cli
``` ```
macOS
```sh ```sh
# macOS
brew install gh brew install gh
``` ```
安装后,在 Zed 中按 `Mod+R` 打开 Task Picker,运行: Linux 安装方式见 <https://github.com/cli/cli/blob/trunk/docs/install_linux.md>。
```text 无需提前创建 Gist,也无需手工生成 Personal Access Token。设置向导会调用 GitHub CLI 的浏览器登录流程,并请求 Gist 权限。
Zed Settings: Authenticate GitHub...
```
也可以在终端执行: ## 第一台机器
```sh ### 1. 安装配置
gh auth login
```
### 3.2 创建或关联 Gist
1.`Mod+R`
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 中的其他文件。
Gist ID 和上次同步哈希保存在本机:
```text
Windows: %APPDATA%\Zed\scripts\settings-sync.json
macOS/Linux: ~/.config/zed/scripts/settings-sync.json
```
此文件不参与导入导出,所以其他机器不会覆盖当前机器记录。
## 4. 新机器首次接入
第一次接入只需要运行仓库里的独立 bootstrap 脚本。它会询问 Gist URL/ID,直接读取 Gist 单 JSON,再安装全部设置和同步工具;不需要下载或解压便携目录。
### 4.1 安装前置程序
新机器需要:
- Zed 或 ZedG。
- Node.js。
- GitHub CLI `gh`
bootstrap 仓库本身不保存设置或凭据,只需要包含:
```text
bootstrap-from-gist.ps1
bootstrap-from-gist.sh
```
因此这个仓库可以公开;真正的设置仍只存放在指定 Gist 中。
### 4.2 一行安装
Windows PowerShell Windows PowerShell
```powershell ```powershell
irm https://git.okk.cool/purp1e/zed-sync/raw/branch/master/bootstrap-from-gist.ps1 | iex irm https://git.okk.cool/purp1e/zed-sync/raw/branch/master/bootstrap.ps1 | iex
``` ```
macOS/Linux macOS / Linux
```sh ```sh
curl -fsSL https://git.okk.cool/purp1e/zed-sync/raw/branch/master/bootstrap-from-gist.sh | sh curl -fsSL https://git.okk.cool/purp1e/zed-sync/raw/branch/master/bootstrap.sh | sh
``` ```
迁移到其他 Gitea 仓库时,Raw URL 的常见形式是 安装器先完成以下工作
```powershell 1. 从公开 Gitea 仓库下载默认 `zed-settings-sync.json`
irm https://<Gitea域名>/<用户>/<仓库>/raw/branch/main/bootstrap-from-gist.ps1 | iex 2. 把当前 Zed 配置备份到 `backups/bootstrap-<时间>`
``` 3. 安装设置、键位、Tasks 和同步脚本。
4. 创建空的机器本地同步状态。
```sh 这四步不访问 GitHub。没有 `gh`、没有登录或没有 Gist 都不会造成安装失败。
curl -fsSL https://<Gitea域名>/<用户>/<仓库>/raw/branch/main/bootstrap-from-gist.sh | sh
```
执行过程中脚本会: ### 2. 连接 GitHub
1. 检查 Node.js 和 `gh` 安装结束时可以选择立即运行向导。稍后操作时:
2. 在尚未登录时运行 `gh auth login`
3. 询问 Gist URL 或 ID;留空时自动创建新的 Secret Gist。
4. 输入已有 Gist 时从 GitHub API 读取配置;留空时使用本仓库的 `zed-settings-sync.json` 种子配置。
5. 验证顶层文件路径,防止写出 Zed 配置目录。
6. 备份新机器已有配置。
7. 安装设置、键位、Tasks、snippets、themes 和辅助脚本。
8. 保存 Gist ID 和远端整文件 SHA-256 作为首次同步基线。
安装完成后重启 Zed,运行 `Zed Settings: Show GitHub Gist Status`,状态应为 `Synchronized` 1. 重启或 Reload Zed
2.`Mod+R` 打开 Task Picker。
3. 运行 `Zed Settings: Set Up / Reconfigure Sync...`
4. 如果尚未登录,终端会显示一次性代码并打开 GitHub 浏览器授权页面。
为了降低执行远程脚本的供应链风险,可以把 URL 中的 `main` 换成审核过的 commit SHA。bootstrap 仓库不应加入访问令牌或其他秘密。 脚本按以下顺序选择凭据:
### 4.3 便携目录备用安装 1. 验证当前 `GH_TOKEN` / `GITHUB_TOKEN`,包括 Gist 权限。
2. 环境 token 无效时,移除它们后验证 GitHub CLI 凭据存储。
3. 都不可用时启动浏览器登录。
不能访问 Raw URL 时,仍可复制完整 `zed-config-portable` 目录并运行: 同步脚本本身不会读取、打印或保存 token。
```powershell ### 3. 创建首个 Gist
.\install.ps1
```
```sh 登录后向导会搜索当前账号下包含 `zed-settings-sync.json` 的 Gist。第一台机器通常没有结果:
sh ./install.sh
```
然后在 Task Picker 中认证 GitHub、配置 Gist 并执行首次 Pull 1. 选择 `N. Create a new Secret Gist from this machine`
2. 向导创建 Secret Gist 并上传当前 Zed 设置。
3. Gist ID 和当前 SHA-256 被记录到本机 `scripts/settings-sync.json`
4. 自动同步保持默认关闭,除非明确选择开启。
## 5. 之后的日常使用 Secret Gist 不会出现在公开列表或搜索中,但知道链接的人仍可读取它。
开始在一台机器工作前: ## 新机器
1. `Mod+R` 1. 安装 Node.js;需要同步时再安装 `gh`
2. 运行 `Zed Settings: Show GitHub Gist Status` 2. 执行与第一台机器相同的一行安装命令
3. 如果显示 `Remote changes available`,执行 Pull 3. 运行 `Set Up / Reconfigure Sync...`
4. 使用同一个 GitHub 账号完成浏览器登录。
5. 向导会自动列出已有的 Zed 设置 Gist;通常只有一个,直接选择即可。
6. 初始同步方向选择 `P. Pull remote settings onto this machine`
7. Pull 前默认安装产生的本地配置会被再次备份,然后替换为云端设置。
8. Reload Zed。
修改设置、键位、Tasks、排除规则或 snippets 后: 只有在使用其他账号、别人共享的 Gist 或自动发现失败时,才需要选择 `M` 并粘贴 Gist URL/ID。
1. 执行 Push。 ## 日常手动同步
2. 确认状态变为 `Synchronized`
3. 再到其他机器执行 Pull。
常用快捷键 推荐默认使用手动同步,方向明确
| 快捷键 | 操作 | - 修改设置、键位、Tasks、代码片段或主题后,在当前机器运行 `Push Now`
- 切换到另一台机器后运行 `Pull Now`
- 不确定状态时先运行 `Show Sync Status`
快捷键:
| 键位 | 操作 |
|---|---| |---|---|
| `Mod+; U` | Push Now |
| `Mod+; D` | Pull Now |
| `Mod+R` | 打开 Task Picker | | `Mod+R` | 打开 Task Picker |
| `Mod+; U` | Push 到 Gist |
| `Mod+; D` | 从 Gist Pull |
| `Mod+; K` | Reload Zed |
`Mod` 在 Windows/Linux 是 Ctrl,在 macOS 是 Command Push 发现远端已经变化时会显示本地、远端和同步基线哈希,并在覆盖前确认。Pull 发现本地已经变化时同样确认,并始终先创建备份
## 6. 哈希和冲突处理 ## 自动同步
系统比较三个 SHA-256 运行 `Zed Settings: Enable Automatic Sync`
- 当前机器实时生成的单 JSON 哈希 1. 验证已经配置 Gist
- Gist 中单 JSON 的哈希 2. 验证 GitHub CLI 的系统凭据存储,而不是依赖临时环境 token
- 当前机器上次成功 Push/Pull 时保存的哈希 3. 安装当前用户级的 15 分钟定时任务
4.`auto_sync` 写为 `true`
Status 可能显示: 关闭时运行 `Disable Automatic Sync`,它会删除对应的系统调度项并保留 Gist 关联和同步基线。
| 状态 | 含义 | 建议操作 | 系统实现:
- WindowsTask Scheduler,任务名 `ZedSettingsSync`
- macOSLaunchAgent `cool.okk.zed-settings-sync.plist`
- Linuxsystemd user timer;没有 systemd 时使用带 `# zed-settings-sync` 标记的 crontab。
自动同步只处理方向明确的单边变化:
| 本地 | 远端 | 基线 | 结果 |
|---|---|---|---|
| 相同 | 相同 | 任意 | 无操作 |
| 已变 | 等于基线 | 存在 | 自动 Push |
| 等于基线 | 已变 | 存在 | 备份后自动 Pull |
| 已变 | 已变 | 存在 | 冲突,停止 |
| 不同 | 不同 | 不存在 | 无基线,停止 |
冲突、无基线、认证失效和网络错误不会触发覆盖。相同错误只通知一次,详细记录写入:
```text
Windows: %APPDATA%\Zed\logs\settings-sync.log
macOS/Linux: ~/.config/zed/logs/settings-sync.log
```
日志超过 1 MB 后保留最多三份历史文件。
## 冲突恢复
`Show Sync Status` 可能显示:
| 状态 | 含义 | 建议 |
|---|---|---| |---|---|---|
| `Synchronized` | 本地和 Gist 一致 | 无需操作 | | `synchronized` | 本地和远端一致 | 无需操作 |
| `Local changes pending upload` | 只有本机有变化 | Push | | `local-changed` | 只有本变化 | Push Now |
| `Remote changes available` | 只有 Gist 有变化 | Pull | | `remote-changed` | 只有远端变化 | Pull Now |
| `Conflict` | 本机和 Gist 都有变化 | 先导出本地备份,再决定 Push 或 Pull | | `conflict` | 两边都变化 | 先 Export JSON,再决定保留哪一边 |
| `Not synchronized on this machine` | 这台机器尚无同步基线 | 首次接入通常选择 Pull | | `no-baseline` | 机器没有可靠同步基线 | 检查内容后手动 Push 或 Pull |
| `remote-missing` | Gist 中缺少同步文件 | 重新配置或手动 Push |
Push 覆盖远端前、Pull 覆盖本地前都会显示哈希并要求确认。Pull 会自动备份本地配置;系统不会尝试逐字段自动合并 发生冲突时不要连续试 Push/Pull。先运行 `Export JSON...` 保存本机单文件;需要保留远端时执行 Pull,需要保留本机时执行 Push。两个方向在覆盖前都会再次确认
## 7. 离线 JSON 导入导出 ## 导入导出与备份
不使用 Gist 时,也可以使用完全相同的单 JSON 格式 `Export JSON...``Import JSON...` 会打开系统文件选择器。导出的 JSON 与 Gist 中的格式完全相同,顶层键直接是文件路径,例如
```text ```json
Zed Settings: Export JSON... {
Zed Settings: Import JSON... "settings.json": "...",
"keymap.json": "...",
"tasks.json": "..."
}
``` ```
两个命令都会弹出系统文件选择窗口。直接 Import 不会修改已保存的 Gist ID 和上次同步哈希;导入内容会被为新的本地变化,可以随后 Push 到 Gist 没有 `files` 外壳,也没有内置哈希。Import 不会覆盖本机保存的 Gist ID、自动同步设置或上次同步哈希,但导入后的内容会被识别为新的本地变化。
## 8. 修改文件排除规则 Import 和 Pull 的备份目录:
只编辑本机配置中的 `custom` 数组:
```text
Windows: %APPDATA%\Zed\scripts\file-exclusions.json
macOS/Linux: ~/.config/zed/scripts/file-exclusions.json
```
修改后按 `Alt+Shift+-` 测试显隐,再 Push 到 Gist。其他机器 Pull 后会取得相同规则。
## 9. 备份位置
每次 Import 或 Pull 前的备份位于:
```text ```text
Windows: %APPDATA%\Zed\backups\sync-import-<时间> Windows: %APPDATA%\Zed\backups\sync-import-<时间>
macOS/Linux: ~/.config/zed/backups/sync-import-<时间> macOS/Linux: ~/.config/zed/backups/sync-import-<时间>
``` ```
便携安装器产生的备份使用 `portable-install-<时间>` 名称。 ## 修改文件排除规则
## 10. 常见问题 编辑:
### Task Picker 中没有 Zed Settings ```text
Windows: %APPDATA%\Zed\scripts\file-exclusions.json
macOS/Linux: ~/.config/zed/scripts/file-exclusions.json
```
重新运行一行 bootstrap 命令,或运行便携目录中的 `install.ps1` / `install.sh`,然后 Reload Zed - `defaults`:始终排除的 Zed 默认规则
- `custom`:按 `Alt+Shift+-` 切换的自定义规则。
### 提示找不到 gh 修改后先测试显隐,再运行 Push Now。其他机器 Pull 后会取得相同规则。
安装 GitHub CLI 后完全退出并重启 Zed,使编辑器读取更新后的 PATH。 ## 故障排查
### Gist 返回 404 ### Task 没有反应
检查 Gist URL/ID、当前 GitHub 账号以及 Gist 是否仍然存在。重新运行 `Configure GitHub Gist...` 可以更换记录 新版 Setup、Status、Push、Pull、启用和禁用自动同步都会打开并保留终端。先查看终端中的完整错误;确认 Zed 已 Reload,且 Task 名称不是旧版的 `Configure GitHub Gist`
### Gist 中没有 zed-settings-sync.json ### 环境 token 导致认证异常
先在已经配置好的主机器上执行一次 Push 脚本会先验证环境 token。无效时会自动回退到凭据存储,并在终端说明。若希望清理环境变量,可检查系统、Shell Profile 和 Zed 的 terminal env 设置
### Pull 后键位或界面没有立即变化 ### GitHub 已登录但不能访问 Gist
`Mod+; K` Reload Zed;如果扩展仍在安装,等待完成后再重启一次 `Set Up / Reconfigure Sync...`。向导会调用 `gh auth refresh --scopes gist` 补充权限
### 换了另一个 Gist ### 自动同步一直认证失败
运行 `Configure GitHub Gist...` 并输入新 ID。系统会保留新 ID,同时清空旧 Gist 对应的上次同步哈希,下一次 Push/Pull 会重新确认覆盖方向 后台任务不能依赖只存在于某个终端进程中的 `GH_TOKEN`。重新运行设置向导并完成浏览器登录,让 `gh` 把凭据保存到系统凭据存储,然后重新启用自动同步
### Pull 后没有立即生效
先运行 `Show Sync Status` 确认哈希一致,再执行 `Mod+; K` Reload Zed。某些扩展和 UI 状态仍可能需要完整重启。
### 更换或删除 Gist
运行 `Set Up / Reconfigure Sync...` 重新选择、输入或创建 Gist。切换成功后会建立新的同步基线。删除远端 Gist 前建议先 Export JSON。
-150
View File
@@ -1,150 +0,0 @@
[CmdletBinding()]
param()
$ErrorActionPreference = "Stop"
$bundleName = "zed-settings-sync.json"
$utf8 = New-Object System.Text.UTF8Encoding($false)
function Require-Command([string]$Name, [string]$InstallHint) {
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "$Name is required. Install it first: $InstallHint"
}
}
function Get-GistId([string]$Value) {
$matches = [regex]::Matches($Value.Trim(), "[a-fA-F0-9]{8,}")
if ($matches.Count -eq 0) {
throw "Enter a valid GitHub Gist ID or URL."
}
return $matches[$matches.Count - 1].Value.ToLowerInvariant()
}
function Test-AllowedPath([string]$Path) {
$fixed = @(
"settings.json",
"keymap.json",
"tasks.json",
"debug.json",
"scripts/toggle-file-scan-exclusions.mjs",
"scripts/file-exclusions.json",
"scripts/zed-settings-sync.mjs"
)
if ($Path.StartsWith("/") -or $Path.Contains("\") -or $Path.Split("/") -contains "..") {
return $false
}
return $fixed -contains $Path -or $Path.StartsWith("snippets/") -or $Path.StartsWith("themes/")
}
function Write-Atomic([string]$Path, [string]$Contents) {
$directory = Split-Path $Path -Parent
New-Item -ItemType Directory -Force -Path $directory | Out-Null
$temporary = Join-Path $directory ".$([IO.Path]::GetFileName($Path)).$PID.tmp"
try {
[IO.File]::WriteAllText($temporary, $Contents, $utf8)
Move-Item -LiteralPath $temporary -Destination $Path -Force
} finally {
Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue
}
}
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 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()
if ($LASTEXITCODE -ne 0 -or -not $token) { throw "Could not obtain the GitHub authentication token from gh." }
$headers = @{
Accept = "application/vnd.github+json"
Authorization = "Bearer $token"
"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
} catch {
throw "The Gist contains invalid JSON: $($_.Exception.Message)"
}
$properties = @($bundle.PSObject.Properties)
foreach ($required in @("settings.json", "keymap.json", "tasks.json")) {
if (-not ($properties.Name -contains $required)) { throw "The bundle is missing $required." }
}
foreach ($property in $properties) {
if (-not (Test-AllowedPath $property.Name) -or $property.Value -isnot [string]) {
throw "Invalid bundle file: $($property.Name)"
}
}
$target = Join-Path $env:APPDATA "Zed"
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$backup = Join-Path $target "backups\gist-bootstrap-$timestamp"
New-Item -ItemType Directory -Force -Path $target, $backup, (Join-Path $target "scripts") | Out-Null
foreach ($property in $properties) {
$existing = Join-Path $target ($property.Name.Replace("/", "\"))
if (Test-Path -LiteralPath $existing) {
$backupPath = Join-Path $backup ($property.Name.Replace("/", "\"))
New-Item -ItemType Directory -Force -Path (Split-Path $backupPath -Parent) | Out-Null
Copy-Item -LiteralPath $existing -Destination $backupPath -Force
}
}
$toggleScript = (Join-Path $target "scripts\toggle-file-scan-exclusions.mjs").Replace("\", "/")
$syncScript = (Join-Path $target "scripts\zed-settings-sync.mjs").Replace("\", "/")
foreach ($property in $properties) {
$destination = Join-Path $target ($property.Name.Replace("/", "\"))
$contents = [string]$property.Value
if ($property.Name -eq "tasks.json") {
$contents = $contents.Replace("__TOGGLE_SCRIPT__", $toggleScript).Replace("__SYNC_SCRIPT__", $syncScript)
}
Write-Atomic $destination $contents
}
$bytes = $utf8.GetBytes($bundleSource)
$sha = [Security.Cryptography.SHA256]::Create()
try {
$hash = -join ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString("x2") })
} finally {
$sha.Dispose()
}
$syncState = [ordered]@{ gist_id = $gistId; last_synced_hash = $hash } | ConvertTo-Json
Write-Atomic (Join-Path $target "scripts\settings-sync.json") "$syncState`n"
Write-Host "Installed Zed settings from Gist $gistId"
Write-Host "SHA-256: $hash"
Write-Host "Backup: $backup"
Write-Host "Restart Zed to load the synchronized configuration."
-153
View File
@@ -1,153 +0,0 @@
#!/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
+140
View File
@@ -0,0 +1,140 @@
[CmdletBinding()]
param()
$ErrorActionPreference = "Stop"
$bundleUrl = "https://git.okk.cool/purp1e/zed-sync/raw/branch/master/zed-settings-sync.json"
$utf8 = New-Object System.Text.UTF8Encoding($false)
function Require-Command([string]$Name, [string]$InstallHint) {
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "$Name is required. Install it first: $InstallHint"
}
}
function Test-AllowedPath([string]$Path) {
$fixed = @(
"settings.json",
"keymap.json",
"tasks.json",
"debug.json",
"scripts/toggle-file-scan-exclusions.mjs",
"scripts/file-exclusions.json",
"scripts/zed-settings-sync.mjs"
)
$segments = $Path.Split("/")
if (
$Path.StartsWith("/") -or
$Path.Contains("\") -or
$segments -contains "" -or
$segments -contains "." -or
$segments -contains ".."
) {
return $false
}
return $fixed -contains $Path -or $Path.StartsWith("snippets/") -or $Path.StartsWith("themes/")
}
function Write-Atomic([string]$Path, [string]$Contents) {
$directory = Split-Path $Path -Parent
New-Item -ItemType Directory -Force -Path $directory | Out-Null
$temporary = Join-Path $directory ".$([IO.Path]::GetFileName($Path)).$PID.tmp"
try {
[IO.File]::WriteAllText($temporary, $Contents, $utf8)
Move-Item -LiteralPath $temporary -Destination $Path -Force
} finally {
Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue
}
}
Require-Command "node" "winget install OpenJS.NodeJS.LTS"
if ($env:ZED_SETTINGS_BUNDLE_PATH) {
$bundleSource = [IO.File]::ReadAllText($env:ZED_SETTINGS_BUNDLE_PATH, [Text.Encoding]::UTF8)
} else {
Write-Host "Downloading the public Zed configuration bundle..."
$bundleSource = (Invoke-WebRequest -UseBasicParsing -Uri $bundleUrl).Content
}
try {
$bundle = $bundleSource | ConvertFrom-Json
} catch {
throw "The downloaded bundle contains invalid JSON: $($_.Exception.Message)"
}
$properties = @($bundle.PSObject.Properties)
foreach ($required in @("settings.json", "keymap.json", "tasks.json")) {
if (-not ($properties.Name -contains $required)) { throw "The bundle is missing $required." }
}
foreach ($property in $properties) {
if (-not (Test-AllowedPath $property.Name) -or $property.Value -isnot [string]) {
throw "Invalid bundle file: $($property.Name)"
}
}
$target = Join-Path $env:APPDATA "Zed"
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$backup = Join-Path $target "backups\bootstrap-$timestamp"
New-Item -ItemType Directory -Force -Path $target, $backup, (Join-Path $target "scripts") | Out-Null
foreach ($property in $properties) {
$existing = Join-Path $target ($property.Name.Replace("/", "\"))
if (Test-Path -LiteralPath $existing) {
$backupPath = Join-Path $backup ($property.Name.Replace("/", "\"))
New-Item -ItemType Directory -Force -Path (Split-Path $backupPath -Parent) | Out-Null
Copy-Item -LiteralPath $existing -Destination $backupPath -Force
}
}
$toggleScript = (Join-Path $target "scripts\toggle-file-scan-exclusions.mjs").Replace("\", "/")
$syncScript = (Join-Path $target "scripts\zed-settings-sync.mjs").Replace("\", "/")
foreach ($property in $properties) {
$destination = Join-Path $target ($property.Name.Replace("/", "\"))
$contents = [string]$property.Value
if ($property.Name -eq "tasks.json") {
$contents = $contents.Replace("__TOGGLE_SCRIPT__", $toggleScript).Replace("__SYNC_SCRIPT__", $syncScript)
}
Write-Atomic $destination $contents
}
$statePath = Join-Path $target "scripts\settings-sync.json"
if (-not (Test-Path -LiteralPath $statePath)) {
$state = [ordered]@{
gist_id = ""
last_synced_hash = ""
auto_sync = $false
interval_minutes = 15
last_run_at = ""
last_result = ""
} | ConvertTo-Json
Write-Atomic $statePath "$state`n"
} else {
$existingState = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json
$state = [ordered]@{
gist_id = if ($existingState.gist_id -is [string]) { $existingState.gist_id } else { "" }
last_synced_hash = if ($existingState.last_synced_hash -is [string]) { $existingState.last_synced_hash } else { "" }
auto_sync = $existingState.auto_sync -eq $true
interval_minutes = if ($existingState.interval_minutes -is [int] -and $existingState.interval_minutes -ge 5) { $existingState.interval_minutes } else { 15 }
last_run_at = if ($existingState.last_run_at -is [string]) { $existingState.last_run_at } else { "" }
last_result = if ($existingState.last_result -is [string]) { $existingState.last_result } else { "" }
} | ConvertTo-Json
Write-Atomic $statePath "$state`n"
}
Write-Host ""
Write-Host "Installed Zed settings to $target"
Write-Host "Previous settings were backed up to $backup"
Write-Host "GitHub and Gist setup are optional and did not block installation."
if ($env:ZED_SYNC_SKIP_SETUP -ne "1") {
try {
$answer = Read-Host "Run the GitHub sync setup now? [Y/n]"
if ([string]::IsNullOrWhiteSpace($answer) -or $answer -match "^[Yy]") {
& node $syncScript setup
if ($LASTEXITCODE -ne 0) {
Write-Warning "Sync setup did not complete. Local installation is still valid; run the setup Task from Zed later."
}
}
} catch {
Write-Warning "Could not start interactive sync setup. Run 'Zed Settings: Set Up / Reconfigure Sync...' from Zed later."
}
}
Write-Host "Restart or reload Zed to use the installed configuration."
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env sh
set -eu
bundle_url='https://git.okk.cool/purp1e/zed-sync/raw/branch/master/zed-settings-sync.json'
command -v node >/dev/null 2>&1 || {
printf '%s\n' 'Node.js is required. Install it with your system package manager.' >&2
exit 1
}
command -v curl >/dev/null 2>&1 || {
printf '%s\n' 'curl is required. Install it with your system package manager.' >&2
exit 1
}
temporary_dir=$(mktemp -d)
trap 'rm -rf "$temporary_dir"' EXIT INT TERM
bundle_path="$temporary_dir/zed-settings-sync.json"
if [ -n "${ZED_SETTINGS_BUNDLE_PATH:-}" ]; then
bundle_path=$ZED_SETTINGS_BUNDLE_PATH
else
printf '%s\n' 'Downloading the public Zed configuration bundle...'
curl -fsSL "$bundle_url" -o "$bundle_path"
fi
node - "$bundle_path" <<'NODE'
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const bundlePath = process.argv[2];
const source = fs.readFileSync(bundlePath, "utf8");
const bundle = JSON.parse(source);
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", `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("/"));
const contents = relativePath === "tasks.json"
? sourceContents.replaceAll("__TOGGLE_SCRIPT__", toggleScript).replaceAll("__SYNC_SCRIPT__", syncScript)
: sourceContents;
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 statePath = path.join(target, "scripts", "settings-sync.json");
const previousState = fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, "utf8")) : {};
const state = {
gist_id: typeof previousState.gist_id === "string" ? previousState.gist_id : "",
last_synced_hash: typeof previousState.last_synced_hash === "string" ? previousState.last_synced_hash : "",
auto_sync: previousState.auto_sync === true,
interval_minutes: Number.isInteger(previousState.interval_minutes) && previousState.interval_minutes >= 5 ? previousState.interval_minutes : 15,
last_run_at: typeof previousState.last_run_at === "string" ? previousState.last_run_at : "",
last_result: typeof previousState.last_result === "string" ? previousState.last_result : "",
};
fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
console.log(`Installed Zed settings to ${target}`);
console.log(`Previous settings were backed up to ${backup}`);
console.log("GitHub and Gist setup are optional and did not block installation.");
NODE
target="${XDG_CONFIG_HOME:-$HOME/.config}/zed"
if [ "${ZED_SYNC_SKIP_SETUP:-0}" != "1" ] && [ -r /dev/tty ]; then
printf 'Run the GitHub sync setup now? [Y/n] ' >/dev/tty
IFS= read -r answer </dev/tty || answer=n
case "$answer" in
""|y|Y|yes|YES)
if ! node "$target/scripts/zed-settings-sync.mjs" setup </dev/tty >/dev/tty; then
printf '%s\n' 'Sync setup did not complete. Local installation is still valid; run the setup Task from Zed later.' >&2
fi
;;
esac
fi
printf '%s\n' 'Restart or reload Zed to use the installed configuration.'
+11
View File
@@ -25,6 +25,17 @@ Copy-Item -LiteralPath (Join-Path $source "scripts\zed-settings-sync.mjs") -Dest
$syncConfig = Join-Path $target "scripts\settings-sync.json" $syncConfig = Join-Path $target "scripts\settings-sync.json"
if (-not (Test-Path -LiteralPath $syncConfig)) { if (-not (Test-Path -LiteralPath $syncConfig)) {
Copy-Item -LiteralPath (Join-Path $source "scripts\settings-sync.json") -Destination $syncConfig Copy-Item -LiteralPath (Join-Path $source "scripts\settings-sync.json") -Destination $syncConfig
} else {
$existingState = Get-Content -LiteralPath $syncConfig -Raw | ConvertFrom-Json
$migratedState = [ordered]@{
gist_id = if ($existingState.gist_id -is [string]) { $existingState.gist_id } else { "" }
last_synced_hash = if ($existingState.last_synced_hash -is [string]) { $existingState.last_synced_hash } else { "" }
auto_sync = $existingState.auto_sync -eq $true
interval_minutes = if ($existingState.interval_minutes -is [int] -and $existingState.interval_minutes -ge 5) { $existingState.interval_minutes } else { 15 }
last_run_at = if ($existingState.last_run_at -is [string]) { $existingState.last_run_at } else { "" }
last_result = if ($existingState.last_result -is [string]) { $existingState.last_result } else { "" }
} | ConvertTo-Json
[IO.File]::WriteAllText($syncConfig, "$migratedState`n", $utf8)
} }
$toggleScriptPath = (Join-Path $target "scripts\toggle-file-scan-exclusions.mjs").Replace("\", "/") $toggleScriptPath = (Join-Path $target "scripts\toggle-file-scan-exclusions.mjs").Replace("\", "/")
+15
View File
@@ -22,6 +22,21 @@ if [ ! -f "$target/scripts/settings-sync.json" ]; then
cp "$source_dir/scripts/settings-sync.json" "$target/scripts/settings-sync.json" cp "$source_dir/scripts/settings-sync.json" "$target/scripts/settings-sync.json"
fi fi
node - "$target/scripts/settings-sync.json" <<'NODE_STATE'
const fs = require("node:fs");
const statePath = process.argv[2];
const source = JSON.parse(fs.readFileSync(statePath, "utf8"));
const state = {
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 : 15,
last_run_at: typeof source.last_run_at === "string" ? source.last_run_at : "",
last_result: typeof source.last_result === "string" ? source.last_result : "",
};
fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
NODE_STATE
node - "$source_dir/tasks.json" "$target/tasks.json" "$target/scripts/toggle-file-scan-exclusions.mjs" "$target/scripts/zed-settings-sync.mjs" <<'NODE' node - "$source_dir/tasks.json" "$target/tasks.json" "$target/scripts/toggle-file-scan-exclusions.mjs" "$target/scripts/zed-settings-sync.mjs" <<'NODE'
const fs = require("node:fs"); const fs = require("node:fs");
const [source, target, toggleScript, syncScript] = process.argv.slice(2); const [source, target, toggleScript, syncScript] = process.argv.slice(2);
+2 -2
View File
@@ -32,11 +32,11 @@
"secondary-; a": "git::StageAll", "secondary-; a": "git::StageAll",
"secondary-; u": [ "secondary-; u": [
"task::Spawn", "task::Spawn",
{ "task_name": "Zed Settings: Push to GitHub Gist" } { "task_name": "Zed Settings: Push Now" }
], ],
"secondary-; d": [ "secondary-; d": [
"task::Spawn", "task::Spawn",
{ "task_name": "Zed Settings: Pull from GitHub Gist" } { "task_name": "Zed Settings: Pull Now" }
], ],
"shift-alt--": [ "shift-alt--": [
"task::Spawn", "task::Spawn",
+5 -1
View File
@@ -1,4 +1,8 @@
{ {
"gist_id": "", "gist_id": "",
"last_synced_hash": "" "last_synced_hash": "",
"auto_sync": false,
"interval_minutes": 15,
"last_run_at": "",
"last_result": ""
} }
+526 -189
View File
@@ -1,11 +1,23 @@
import { createHash } from "node:crypto"; 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 { existsSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path"; import { basename, dirname, join, resolve } from "node:path";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { createInterface } from "node:readline/promises";
const BUNDLE_NAME = "zed-settings-sync.json"; const BUNDLE_NAME = "zed-settings-sync.json";
const GIST_DESCRIPTION = "Zed settings sync";
const REQUIRED_FILES = ["settings.json", "keymap.json", "tasks.json"]; const REQUIRED_FILES = ["settings.json", "keymap.json", "tasks.json"];
const FIXED_FILES = [ const FIXED_FILES = [
...REQUIRED_FILES, ...REQUIRED_FILES,
@@ -16,8 +28,20 @@ const FIXED_FILES = [
]; ];
const OPTIONAL_DIRECTORIES = ["snippets", "themes"]; const OPTIONAL_DIRECTORIES = ["snippets", "themes"];
const LOCAL_SYNC_CONFIG = "scripts/settings-sync.json"; 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() { function zedConfigDirectory() {
if (process.env.ZED_CONFIG_DIR) return resolve(process.env.ZED_CONFIG_DIR);
if (process.platform === "win32") { if (process.platform === "win32") {
if (!process.env.APPDATA) throw new Error("APPDATA is not set"); if (!process.env.APPDATA) throw new Error("APPDATA is not set");
return join(process.env.APPDATA, "Zed"); return join(process.env.APPDATA, "Zed");
@@ -29,6 +53,10 @@ function timestamp() {
return new Date().toISOString().replace(/[:.]/g, "-"); return new Date().toISOString().replace(/[:.]/g, "-");
} }
function now() {
return new Date().toISOString();
}
async function writeAtomically(path, contents) { async function writeAtomically(path, contents) {
await mkdir(dirname(path), { recursive: true }); await mkdir(dirname(path), { recursive: true });
const temporaryPath = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`); 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}`); throw new Error(`Invalid Zed settings bundle: ${sourceName}`);
} }
for (const required of REQUIRED_FILES) { for (const required of REQUIRED_FILES) {
if (typeof files[required] !== "string") { if (typeof files[required] !== "string") throw new Error(`Invalid bundle: ${required} is missing`);
throw new Error(`Invalid bundle: ${required} is missing`);
}
} }
for (const [path, value] of Object.entries(files)) { for (const [path, value] of Object.entries(files)) {
if (!isAllowedBundlePath(path) || typeof value !== "string") { if (!isAllowedBundlePath(path) || typeof value !== "string") {
@@ -170,7 +196,6 @@ async function installBundleContents(contents, sourceName) {
const configDirectory = zedConfigDirectory(); const configDirectory = zedConfigDirectory();
const backupDirectory = await backupCurrentConfiguration(configDirectory); const backupDirectory = await backupCurrentConfiguration(configDirectory);
for (const [relativePath, sourceContents] of Object.entries(parsed.files)) { 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; if (relativePath === LOCAL_SYNC_CONFIG) continue;
const destination = join(configDirectory, ...relativePath.split("/")); const destination = join(configDirectory, ...relativePath.split("/"));
const installedContents = relativePath === "tasks.json" const installedContents = relativePath === "tasks.json"
@@ -198,6 +223,7 @@ function runPowerShell(command, extraEnv = {}) {
} }
function nativeFileDialog(mode) { function nativeFileDialog(mode) {
if (process.env.ZED_SYNC_NO_DIALOGS === "1") return "";
if (process.platform === "win32") { 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 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" const command = mode === "save"
@@ -218,91 +244,6 @@ function nativeFileDialog(mode) {
return result.status === 0 ? result.stdout.trim() : ""; 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() { async function readSyncConfiguration() {
const path = join(zedConfigDirectory(), ...LOCAL_SYNC_CONFIG.split("/")); const path = join(zedConfigDirectory(), ...LOCAL_SYNC_CONFIG.split("/"));
let source = {}; let source = {};
@@ -314,8 +255,15 @@ async function readSyncConfiguration() {
return { return {
path, path,
config: { config: {
...DEFAULT_SYNC_CONFIG,
gist_id: typeof source.gist_id === "string" ? source.gist_id : "", gist_id: typeof source.gist_id === "string" ? source.gist_id : "",
last_synced_hash: typeof source.last_synced_hash === "string" ? source.last_synced_hash : "", 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(); return matches.at(-1).toLowerCase();
} }
async function promptForGist() { function sanitizedEnvironment() {
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) {
const env = { ...process.env }; const env = { ...process.env };
delete env.GH_TOKEN; delete env.GH_TOKEN;
delete env.GITHUB_TOKEN; delete env.GITHUB_TOKEN;
const result = spawnSync("gh", args, { encoding: "utf8", env, input, windowsHide: true }); return env;
if (result.error?.code === "ENOENT") { }
throw new Error("GitHub CLI (gh) is not installed. Install it, then run: gh auth login");
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(); return result.stdout.trim();
} }
function readRemoteGist(gistId) { function readRemoteGist(gistId, auth) {
const gist = JSON.parse(runGh(["api", `gists/${gistId}`])); const gist = JSON.parse(runGh(["api", `gists/${gistId}`], auth));
const file = gist.files?.[BUNDLE_NAME]; const file = gist.files?.[BUNDLE_NAME];
if (!file) return { gist, parsed: null, contents: null }; 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`); 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 }; 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 } } }); 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({ const payload = JSON.stringify({
description: "Zed settings sync", description: GIST_DESCRIPTION,
public: false, public: false,
files: { [BUNDLE_NAME]: { content: contents } }, 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) { function shortHash(hash) {
return hash ? hash.slice(0, 12) : "none"; return hash ? hash.slice(0, 12) : "none";
} }
async function configureGist() { function classifySync(localHash, remoteHash, lastHash) {
const state = await promptForGist(); if (!remoteHash) return "remote-missing";
if (!state) return; if (localHash === remoteHash) return "synchronized";
nativeMessage(`Saved Gist ID:\n${state.config.gist_id}\n\nAuthentication is managed by GitHub CLI (gh).`); 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() { async function pushGist() {
const state = await readSyncConfiguration(); return withSyncLock(async () => {
const localBundle = await createBundle(); const state = await requireConfiguredState();
if (!state.config.gist_id) { if (!state) return;
console.log("No Gist is configured. Asking to create one..."); const auth = await resolveGhAuthentication({ interactive: true });
const proceed = nativeConfirm( const local = await createBundle();
"No GitHub Gist is configured. Create a new Secret Gist from this machine's current Zed settings?", const remote = readRemoteGist(state.config.gist_id, auth);
);
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 remoteHash = remote.parsed?.hash || "";
const lastHash = state.config.last_synced_hash; const status = classifySync(local.hash, remoteHash, state.config.last_synced_hash);
if (remoteHash && remoteHash !== localBundle.hash) { if (["remote-changed", "conflict", "no-baseline"].includes(status)) {
const remoteChanged = !lastHash || remoteHash !== lastHash; console.log(`Remote settings differ (${status}).`);
if (remoteChanged) { console.log(`Local: ${shortHash(local.hash)} Remote: ${shortHash(remoteHash)} Last: ${shortHash(state.config.last_synced_hash)}`);
const proceed = nativeConfirm( if (!await askYesNo("Overwrite the remote Gist with this machine's settings?", false)) return;
`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;
} }
} updateRemoteGist(state.config.gist_id, local.contents, auth);
const updated = updateRemoteGist(state.config.gist_id, localBundle.contents); state.config.last_synced_hash = local.hash;
state.config.last_synced_hash = localBundle.hash; await recordResult(state, `Uploaded settings to Gist ${state.config.gist_id}`);
await saveSyncConfiguration(state.path, state.config); console.log(`Uploaded to ${remote.gist.html_url || state.config.gist_id}`);
nativeMessage(`Uploaded to Gist ${state.config.gist_id}\n\nSHA-256: ${localBundle.hash}\nUpdated: ${updated.updated_at || "unknown"}`); console.log(`SHA-256: ${local.hash}`);
});
} }
async function pullGist() { async function pullGist() {
const state = await promptForGist(); return withSyncLock(async () => {
const state = await requireConfiguredState();
if (!state) return; if (!state) return;
const localBundle = await createBundle(); const auth = await resolveGhAuthentication({ interactive: true });
const remote = readRemoteGist(state.config.gist_id); 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}`); if (!remote.parsed) throw new Error(`Gist ${state.config.gist_id} does not contain ${BUNDLE_NAME}`);
if (localBundle.hash !== remote.parsed.hash) { const status = classifySync(local.hash, remote.parsed.hash, state.config.last_synced_hash);
const localChanged = !state.config.last_synced_hash || localBundle.hash !== state.config.last_synced_hash; if (["local-changed", "conflict", "no-baseline"].includes(status)) {
if (localChanged) { console.log(`Local settings differ (${status}).`);
const proceed = nativeConfirm( console.log(`Local: ${shortHash(local.hash)} Remote: ${shortHash(remote.parsed.hash)} Last: ${shortHash(state.config.last_synced_hash)}`);
`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 (!await askYesNo("Replace local settings with the Gist version? A backup will be created first.", false)) return;
);
if (!proceed) return;
}
await installBundleContents(remote.contents, `Gist ${state.config.gist_id}`);
} }
if (local.hash !== remote.parsed.hash) await installBundleContents(remote.contents, `Gist ${state.config.gist_id}`);
state.config.last_synced_hash = remote.parsed.hash; state.config.last_synced_hash = remote.parsed.hash;
await saveSyncConfiguration(state.path, state.config); await recordResult(state, `Pulled settings from Gist ${state.config.gist_id}`);
nativeMessage(`Downloaded from Gist ${state.config.gist_id}\n\nSHA-256: ${remote.parsed.hash}\nRemote updated: ${remote.gist.updated_at || "unknown"}`); console.log(`Downloaded from ${remote.gist.html_url || state.config.gist_id}`);
console.log(`SHA-256: ${remote.parsed.hash}`);
});
} }
async function showGistStatus() { async function showStatus() {
const state = await promptForGist(); const state = await readSyncConfiguration();
if (!state) return; if (!state.config.gist_id) {
const localBundle = await createBundle(); console.log("Sync is not configured. Run 'Zed Settings: Set Up / Reconfigure Sync...'.");
const remote = readRemoteGist(state.config.gist_id); 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 remoteHash = remote.parsed?.hash || "";
const lastHash = state.config.last_synced_hash; const status = classifySync(local.hash, remoteHash, state.config.last_synced_hash);
let status = "The Gist does not contain a Zed settings file."; if (status === "synchronized") {
if (remoteHash === localBundle.hash) status = "Synchronized"; await recordResult(state, "Automatic sync: already synchronized");
else if (!lastHash) status = "Not synchronized on this machine"; } else if (status === "local-changed") {
else if (localBundle.hash === lastHash) status = "Remote changes available"; updateRemoteGist(state.config.gist_id, local.contents, auth);
else if (remoteHash === lastHash) status = "Local changes pending upload"; state.config.last_synced_hash = local.hash;
else status = "Conflict: local and remote settings both changed"; await recordResult(state, "Automatic sync: uploaded local changes");
nativeMessage( } else if (status === "remote-changed") {
`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"}`, await installBundleContents(remote.contents, `Gist ${state.config.gist_id}`);
"Zed Settings Gist Status", state.config.last_synced_hash = remote.parsed.hash;
); await recordResult(state, "Automatic sync: downloaded remote changes");
} else {
const result = `Automatic sync stopped: ${status}`;
const shouldNotify = state.config.last_result !== result;
await recordResult(state, result);
if (shouldNotify) notify(`${result}. Open the Zed Task Picker to resolve it.`);
}
} catch (error) {
const result = `Automatic sync failed: ${error.message}`;
const shouldNotify = state.config.last_result !== result;
await recordResult(state, result);
if (shouldNotify) notify(result);
throw error;
}
});
}
function shellQuote(value) {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
function xmlEscape(value) {
return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
function schedulerPlatform() {
return process.env.ZED_SYNC_PLATFORM || process.platform;
}
async function configureScheduler(enable, intervalMinutes) {
const platform = schedulerPlatform();
const scriptPath = resolve(process.argv[1]);
const dryRun = process.env.ZED_SYNC_SCHEDULER_DRY_RUN === "1";
const actions = [];
const run = (command, args, options = {}) => {
actions.push([command, ...args].join(" "));
if (dryRun) return { status: 0, stdout: "", stderr: "" };
return execute(command, args, options);
};
if (platform === "win32") {
if (enable) {
const taskCommand = `\"${process.execPath}\" \"${scriptPath}\" auto-sync`;
const result = run("schtasks.exe", ["/Create", "/SC", "MINUTE", "/MO", String(intervalMinutes), "/TN", SCHEDULER_NAME, "/TR", taskCommand, "/F"]);
if (result.status !== 0) throw new Error((result.stderr || result.stdout || "Could not create Windows scheduled task").trim());
} else {
run("schtasks.exe", ["/Delete", "/TN", SCHEDULER_NAME, "/F"]);
}
} else if (platform === "darwin") {
const plistPath = join(homedir(), "Library", "LaunchAgents", "cool.okk.zed-settings-sync.plist");
run("launchctl", ["unload", plistPath]);
if (enable) {
const plist = `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n <key>Label</key><string>cool.okk.zed-settings-sync</string>\n <key>ProgramArguments</key><array><string>${xmlEscape(process.execPath)}</string><string>${xmlEscape(scriptPath)}</string><string>auto-sync</string></array>\n <key>StartInterval</key><integer>${intervalMinutes * 60}</integer>\n <key>RunAtLoad</key><true/>\n</dict></plist>\n`;
if (!dryRun) await writeAtomically(plistPath, plist);
actions.push(`write ${plistPath}`);
const result = run("launchctl", ["load", plistPath]);
if (result.status !== 0) throw new Error((result.stderr || "Could not load LaunchAgent").trim());
} else if (!dryRun) {
await rm(plistPath, { force: true });
}
} else {
const systemdDirectory = join(homedir(), ".config", "systemd", "user");
const servicePath = join(systemdDirectory, "zed-settings-sync.service");
const timerPath = join(systemdDirectory, "zed-settings-sync.timer");
const systemd = run("systemctl", ["--user", "--version"]);
const systemdSession = systemd.status === 0
? run("systemctl", ["--user", "show-environment"])
: { status: 1 };
if (enable && systemdSession.status === 0) {
const service = `[Unit]\nDescription=Synchronize Zed settings\n\n[Service]\nType=oneshot\nExecStart=\"${process.execPath.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}\" \"${scriptPath.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}\" auto-sync\n`;
const timer = `[Unit]\nDescription=Synchronize Zed settings every ${intervalMinutes} minutes\n\n[Timer]\nOnBootSec=2min\nOnUnitActiveSec=${intervalMinutes}min\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n`;
if (!dryRun) {
await writeAtomically(servicePath, service);
await writeAtomically(timerPath, timer);
}
actions.push(`write ${servicePath}`, `write ${timerPath}`);
run("systemctl", ["--user", "daemon-reload"]);
const result = run("systemctl", ["--user", "enable", "--now", "zed-settings-sync.timer"]);
if (result.status !== 0) throw new Error((result.stderr || "Could not enable systemd user timer").trim());
} else if (!enable) {
if (systemd.status === 0) run("systemctl", ["--user", "disable", "--now", "zed-settings-sync.timer"]);
if (!dryRun) {
await rm(servicePath, { force: true });
await rm(timerPath, { force: true });
}
if (systemdSession.status === 0) run("systemctl", ["--user", "daemon-reload"]);
const current = run("crontab", ["-l"]);
if (current.status === 0) {
const lines = current.stdout.split(/\r?\n/).filter((line) => line && !line.includes("# zed-settings-sync"));
run("crontab", ["-"], { input: `${lines.join("\n")}\n` });
}
} else {
const current = run("crontab", ["-l"]);
const marker = "# zed-settings-sync";
const lines = (current.status === 0 ? current.stdout : "").split(/\r?\n/).filter((line) => line && !line.includes(marker));
lines.push(`*/${intervalMinutes} * * * * ${shellQuote(process.execPath)} ${shellQuote(scriptPath)} auto-sync ${marker}`);
const updated = `${lines.join("\n")}\n`;
const result = run("crontab", ["-"], { input: updated });
if (result.status !== 0) throw new Error((result.stderr || "Could not update crontab").trim());
}
}
if (dryRun) console.log(actions.join("\n"));
}
async function enableAutomaticSync() {
const state = await readSyncConfiguration();
if (!state.config.gist_id) {
console.log("Sync must be configured before automatic synchronization can be enabled.");
await setupSync({ offerAutomaticSync: false });
}
const configured = await readSyncConfiguration();
if (!configured.config.gist_id) return;
const auth = await resolveGhAuthentication({ interactive: true, requireStored: true });
console.log(`Stored GitHub credentials verified for ${auth.login}.`);
await configureScheduler(true, configured.config.interval_minutes);
configured.config.auto_sync = true;
await recordResult(configured, `Automatic sync enabled every ${configured.config.interval_minutes} minutes`);
console.log(`Automatic synchronization is enabled every ${configured.config.interval_minutes} minutes.`);
}
async function disableAutomaticSync() {
const state = await readSyncConfiguration();
await configureScheduler(false, state.config.interval_minutes);
state.config.auto_sync = false;
await recordResult(state, "Automatic sync disabled");
console.log("Automatic synchronization is disabled.");
} }
async function main() { async function main() {
@@ -480,10 +814,13 @@ async function main() {
if (path) await importBundle(path); if (path) await importBundle(path);
return; return;
} }
if (mode === "gist-configure") return configureGist(); if (mode === "setup") return setupSync();
if (mode === "gist-status") return showGistStatus(); if (mode === "status") return showStatus();
if (mode === "gist-push") return pushGist(); if (mode === "push") return pushGist();
if (mode === "gist-pull") return pullGist(); 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>"}`); throw new Error(`Unknown mode: ${mode || "<missing>"}`);
} }
@@ -492,9 +829,9 @@ try {
} catch (error) { } catch (error) {
console.error(error.stack || error.message); console.error(error.stack || error.message);
try { try {
nativeMessage(error.message, "Zed Settings Sync Error", true); await appendLog(`ERROR ${error.message}`);
} catch { } catch {
// The terminal output remains available when the native dialog backend is unavailable. // The terminal remains the primary error channel.
} }
process.exitCode = 1; process.exitCode = 1;
} }
+66 -53
View File
@@ -79,8 +79,8 @@
"export-dialog" "export-dialog"
], ],
"allow_concurrent_runs": false, "allow_concurrent_runs": false,
"reveal": "never", "reveal": "always",
"hide": "always", "hide": "never",
"show_summary": false, "show_summary": false,
"show_command": false, "show_command": false,
"save": "all" "save": "all"
@@ -93,81 +93,94 @@
"import-dialog" "import-dialog"
], ],
"allow_concurrent_runs": false, "allow_concurrent_runs": false,
"reveal": "never", "reveal": "always",
"hide": "always",
"show_summary": false,
"show_command": false,
"save": "all"
},
{
"label": "Zed Settings: Configure GitHub Gist...",
"command": "node",
"args": [
"__SYNC_SCRIPT__",
"gist-configure"
],
"allow_concurrent_runs": false,
"reveal": "no_focus",
"hide": "never", "hide": "never",
"show_summary": false, "show_summary": false,
"show_command": false, "show_command": false,
"save": "all" "save": "all"
}, },
{ {
"label": "Zed Settings: Show GitHub Gist Status", "label": "Zed Settings: Set Up / Reconfigure Sync...",
"command": "node", "command": "node",
"args": [ "args": [
"__SYNC_SCRIPT__", "__SYNC_SCRIPT__",
"gist-status" "setup"
], ],
"allow_concurrent_runs": false, "allow_concurrent_runs": false,
"reveal": "no_focus", "reveal": "always",
"hide": "never", "hide": "never",
"show_summary": false, "show_summary": true,
"show_command": false, "show_command": false,
"save": "all" "save": "all"
}, },
{ {
"label": "Zed Settings: Push to GitHub Gist", "label": "Zed Settings: Show Sync Status",
"command": "node", "command": "node",
"args": [ "args": [
"__SYNC_SCRIPT__", "__SYNC_SCRIPT__",
"gist-push" "status"
],
"allow_concurrent_runs": false,
"reveal": "no_focus",
"hide": "never",
"show_summary": false,
"show_command": false,
"save": "all"
},
{
"label": "Zed Settings: Pull from GitHub Gist",
"command": "node",
"args": [
"__SYNC_SCRIPT__",
"gist-pull"
],
"allow_concurrent_runs": false,
"reveal": "no_focus",
"hide": "never",
"show_summary": false,
"show_command": false,
"save": "all"
},
{
"label": "Zed Settings: Authenticate GitHub...",
"command": "powershell",
"args": [
"-NoExit",
"-Command",
"Remove-Item Env:GH_TOKEN,Env:GITHUB_TOKEN -ErrorAction SilentlyContinue; gh auth login"
], ],
"allow_concurrent_runs": false, "allow_concurrent_runs": false,
"reveal": "always", "reveal": "always",
"hide": "never", "hide": "never",
"show_summary": false, "show_summary": false,
"show_command": true, "show_command": false,
"save": "all"
},
{
"label": "Zed Settings: Push Now",
"command": "node",
"args": [
"__SYNC_SCRIPT__",
"push"
],
"allow_concurrent_runs": false,
"reveal": "always",
"hide": "never",
"show_summary": false,
"show_command": false,
"save": "all"
},
{
"label": "Zed Settings: Pull Now",
"command": "node",
"args": [
"__SYNC_SCRIPT__",
"pull"
],
"allow_concurrent_runs": false,
"reveal": "always",
"hide": "never",
"show_summary": false,
"show_command": false,
"save": "all"
},
{
"label": "Zed Settings: Enable Automatic Sync",
"command": "node",
"args": [
"__SYNC_SCRIPT__",
"auto-enable"
],
"allow_concurrent_runs": false,
"reveal": "always",
"hide": "never",
"show_summary": false,
"show_command": false,
"save": "all"
},
{
"label": "Zed Settings: Disable Automatic Sync",
"command": "node",
"args": [
"__SYNC_SCRIPT__",
"auto-disable"
],
"allow_concurrent_runs": false,
"reveal": "always",
"hide": "never",
"show_summary": false,
"show_command": false,
"save": "none" "save": "none"
}, },
] ]
+388
View File
@@ -0,0 +1,388 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { chmod, cp, mkdir, mkdtemp, readFile, readdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const repository = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const syncScript = join(repository, "scripts", "zed-settings-sync.mjs");
async function makeFixture() {
const root = await mkdtemp(join(tmpdir(), "zed-sync-test-"));
const config = join(root, "zed");
const bin = join(root, "bin");
await mkdir(join(config, "scripts"), { recursive: true });
await mkdir(bin, { recursive: true });
for (const name of ["settings.json", "keymap.json", "tasks.json"]) {
await cp(join(repository, name), join(config, name));
}
for (const name of ["zed-settings-sync.mjs", "toggle-file-scan-exclusions.mjs", "file-exclusions.json", "settings-sync.json"]) {
await cp(join(repository, "scripts", name), join(config, "scripts", name));
}
const fakeGh = join(root, "fake-gh.mjs");
await writeFile(fakeGh, `
import { readFileSync, writeFileSync } from "node:fs";
const args = process.argv.slice(2);
const statePath = process.env.FAKE_GH_STATE;
const state = JSON.parse(readFileSync(statePath, "utf8"));
const save = () => writeFileSync(statePath, JSON.stringify(state), "utf8");
if (args[0] === "auth" && ["login", "refresh"].includes(args[1])) process.exit(0);
if (args[0] !== "api") process.exit(2);
if (args.includes("user")) {
if (process.env.FAKE_GH_REJECT_TOKEN === "1" && process.env.GH_TOKEN) {
process.stderr.write("bad token");
process.exit(1);
}
process.stdout.write("test-user\\n");
process.exit(0);
}
if (args.includes("gists?per_page=1")) {
process.exit(0);
}
if (args.includes("gists?per_page=100")) {
process.stdout.write(JSON.stringify([[state.gist].filter(Boolean)]));
process.exit(0);
}
const methodIndex = args.indexOf("--method");
const method = methodIndex >= 0 ? args[methodIndex + 1] : "GET";
if (process.env.FAKE_GH_NETWORK_FAIL === "1") {
process.stderr.write("network unavailable");
process.exit(1);
}
if (method === "POST") {
const payload = JSON.parse(readFileSync(0, "utf8"));
state.gist = { id: "abcde12345", description: payload.description, public: false, html_url: "https://gist.github.com/abcde12345", updated_at: new Date().toISOString(), files: payload.files };
save();
process.stdout.write(JSON.stringify(state.gist));
process.exit(0);
}
const endpoint = args.find((arg) => arg.startsWith("gists/"));
if (endpoint && method === "PATCH") {
const payload = JSON.parse(readFileSync(0, "utf8"));
state.gist.files = { ...state.gist.files, ...payload.files };
state.gist.updated_at = new Date().toISOString();
state.patches = (state.patches || 0) + 1;
save();
process.stdout.write(JSON.stringify(state.gist));
process.exit(0);
}
if (endpoint && state.gist) {
process.stdout.write(JSON.stringify(state.gist));
process.exit(0);
}
process.stderr.write("not found");
process.exit(1);
`, "utf8");
if (process.platform === "win32") {
await writeFile(join(bin, "gh.cmd"), `@echo off\r\n"${process.execPath}" "${fakeGh}" %*\r\n`, "utf8");
} else {
const shim = join(bin, "gh");
await writeFile(shim, `#!/bin/sh\nexec "${process.execPath}" "${fakeGh}" "$@"\n`, "utf8");
await chmod(shim, 0o755);
}
const ghState = join(root, "gh-state.json");
await writeFile(ghState, JSON.stringify({ gist: null }), "utf8");
return { root, config, bin, fakeGh, ghState };
}
function environment(fixture, extra = {}) {
const env = {
...process.env,
PATH: `${fixture.bin}${process.platform === "win32" ? ";" : ":"}${process.env.PATH}`,
ZED_CONFIG_DIR: fixture.config,
ZED_SYNC_GH_SCRIPT: fixture.fakeGh,
FAKE_GH_STATE: fixture.ghState,
ZED_SYNC_NO_DIALOGS: "1",
ZED_SYNC_NO_NOTIFICATIONS: "1",
...extra,
};
delete env.GH_TOKEN;
delete env.GITHUB_TOKEN;
if (extra.GH_TOKEN) env.GH_TOKEN = extra.GH_TOKEN;
return env;
}
function run(fixture, args, extra = {}) {
return spawnSync(process.execPath, [syncScript, ...args], {
cwd: repository,
env: environment(fixture, extra),
encoding: "utf8",
});
}
function parseJsonc(source) {
let output = "";
let inString = false;
let escaped = false;
let lineComment = false;
let blockComment = false;
for (let index = 0; index < source.length; index += 1) {
const character = source[index];
const next = source[index + 1];
if (lineComment) {
if (character === "\n") {
lineComment = false;
output += character;
} else output += " ";
continue;
}
if (blockComment) {
if (character === "*" && next === "/") {
blockComment = false;
output += " ";
index += 1;
} else output += character === "\n" ? "\n" : " ";
continue;
}
if (inString) {
output += character;
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === '"') inString = false;
continue;
}
if (character === '"') {
inString = true;
output += character;
} else if (character === "/" && next === "/") {
lineComment = true;
output += " ";
index += 1;
} else if (character === "/" && next === "*") {
blockComment = true;
output += " ";
index += 1;
} else {
output += character;
}
}
return JSON.parse(output.replace(/,(\s*[}\]])/g, "$1"));
}
test("Zed JSONC files parse and expose the new sync Tasks", async () => {
const settings = parseJsonc(await readFile(join(repository, "settings.json"), "utf8"));
const keymap = parseJsonc(await readFile(join(repository, "keymap.json"), "utf8"));
const tasks = parseJsonc(await readFile(join(repository, "tasks.json"), "utf8"));
assert.equal(typeof settings.base_keymap, "string");
assert.equal(Array.isArray(keymap), true);
const labels = tasks.map((task) => task.label);
for (const label of [
"Zed Settings: Set Up / Reconfigure Sync...",
"Zed Settings: Show Sync Status",
"Zed Settings: Push Now",
"Zed Settings: Pull Now",
"Zed Settings: Enable Automatic Sync",
"Zed Settings: Disable Automatic Sync",
]) {
assert.equal(labels.includes(label), true, `Missing Task: ${label}`);
}
});
test("export remains a flat JSON bundle without local state or hash metadata", async () => {
const fixture = await makeFixture();
const output = join(fixture.root, "bundle.json");
const result = run(fixture, ["export", output]);
assert.equal(result.status, 0, result.stderr);
const bundle = JSON.parse(await readFile(output, "utf8"));
assert.equal(typeof bundle["settings.json"], "string");
assert.equal(typeof bundle["keymap.json"], "string");
assert.equal(bundle.files, undefined);
assert.equal(bundle.hash, undefined);
assert.equal(bundle["scripts/settings-sync.json"], undefined);
});
test("setup creates a Secret Gist and stores only machine-local sync state", async () => {
const fixture = await makeFixture();
const result = run(fixture, ["setup"], { ZED_SYNC_ANSWERS: JSON.stringify(["n", "n"]) });
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /Created Secret Gist/);
const state = JSON.parse(await readFile(join(fixture.config, "scripts", "settings-sync.json"), "utf8"));
assert.equal(state.gist_id, "abcde12345");
assert.equal(state.auto_sync, false);
assert.equal(state.last_synced_hash.length, 64);
const remote = JSON.parse(await readFile(fixture.ghState, "utf8"));
assert.equal(remote.gist.public, false);
});
test("setup initializes a configured Gist that is missing the settings file", async () => {
const fixture = await makeFixture();
const gist = {
id: "missing1234",
description: "Empty sync Gist",
public: false,
html_url: "https://gist.github.com/missing1234",
updated_at: new Date().toISOString(),
files: { "notes.txt": { content: "empty" } },
};
await writeFile(fixture.ghState, JSON.stringify({ gist }), "utf8");
const statePath = join(fixture.config, "scripts", "settings-sync.json");
const state = JSON.parse(await readFile(statePath, "utf8"));
state.gist_id = gist.id;
await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
const result = run(fixture, ["setup"], { ZED_SYNC_ANSWERS: JSON.stringify(["1", "y", "n"]) });
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /does not contain zed-settings-sync.json/);
const remote = JSON.parse(await readFile(fixture.ghState, "utf8"));
assert.equal(typeof remote.gist.files[BUNDLE_NAME].content, "string");
const updated = JSON.parse(await readFile(statePath, "utf8"));
assert.equal(updated.gist_id, gist.id);
assert.equal(updated.last_synced_hash.length, 64);
});
test("an invalid inherited token falls back to stored gh credentials", async () => {
const fixture = await makeFixture();
let result = run(fixture, ["setup"], { ZED_SYNC_ANSWERS: JSON.stringify(["n", "n"]) });
assert.equal(result.status, 0, result.stderr);
result = run(fixture, ["status"], { GH_TOKEN: "invalid", FAKE_GH_REJECT_TOKEN: "1" });
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /inherited GitHub token is invalid/);
assert.match(result.stdout, /Status: synchronized/);
});
test("automatic sync uploads one-sided local changes and stops on conflicts", async () => {
const fixture = await makeFixture();
let result = run(fixture, ["setup"], { ZED_SYNC_ANSWERS: JSON.stringify(["n", "n"]) });
assert.equal(result.status, 0, result.stderr);
const statePath = join(fixture.config, "scripts", "settings-sync.json");
const state = JSON.parse(await readFile(statePath, "utf8"));
state.auto_sync = true;
await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
await writeFile(join(fixture.config, "settings.json"), `${await readFile(join(fixture.config, "settings.json"), "utf8")}\n`, "utf8");
result = run(fixture, ["auto-sync"]);
assert.equal(result.status, 0, result.stderr);
let remote = JSON.parse(await readFile(fixture.ghState, "utf8"));
assert.equal(remote.patches, 1);
const baseline = JSON.parse(await readFile(statePath, "utf8")).last_synced_hash;
await writeFile(join(fixture.config, "keymap.json"), `${await readFile(join(fixture.config, "keymap.json"), "utf8")}\n`, "utf8");
const remoteBundle = JSON.parse(remote.gist.files[BUNDLE_NAME].content);
remoteBundle["settings.json"] += "\n// remote change\n";
remote.gist.files[BUNDLE_NAME] = { content: `${JSON.stringify(remoteBundle, null, 2)}\n` };
await writeFile(fixture.ghState, JSON.stringify(remote), "utf8");
result = run(fixture, ["auto-sync"]);
assert.equal(result.status, 0, result.stderr);
const conflicted = JSON.parse(await readFile(statePath, "utf8"));
assert.equal(conflicted.last_synced_hash, baseline);
assert.match(conflicted.last_result, /conflict/);
remote = JSON.parse(await readFile(fixture.ghState, "utf8"));
assert.equal(remote.patches, 1);
});
test("automatic sync downloads one-sided remote changes with a backup", async () => {
const fixture = await makeFixture();
let result = run(fixture, ["setup"], { ZED_SYNC_ANSWERS: JSON.stringify(["n", "n"]) });
assert.equal(result.status, 0, result.stderr);
const statePath = join(fixture.config, "scripts", "settings-sync.json");
const state = JSON.parse(await readFile(statePath, "utf8"));
state.auto_sync = true;
await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
const remote = JSON.parse(await readFile(fixture.ghState, "utf8"));
const remoteBundle = JSON.parse(remote.gist.files[BUNDLE_NAME].content);
remoteBundle["settings.json"] += "\n// downloaded remote change\n";
remote.gist.files[BUNDLE_NAME].content = `${JSON.stringify(remoteBundle, null, 2)}\n`;
await writeFile(fixture.ghState, JSON.stringify(remote), "utf8");
result = run(fixture, ["auto-sync"]);
assert.equal(result.status, 0, result.stderr);
assert.match(await readFile(join(fixture.config, "settings.json"), "utf8"), /downloaded remote change/);
assert.match(await readFile(statePath, "utf8"), /downloaded remote changes/);
assert.equal((await readdir(join(fixture.config, "backups"))).length, 1);
});
test("automatic sync does not overwrite without a baseline and records network errors", async () => {
const fixture = await makeFixture();
let result = run(fixture, ["setup"], { ZED_SYNC_ANSWERS: JSON.stringify(["n", "n"]) });
assert.equal(result.status, 0, result.stderr);
const statePath = join(fixture.config, "scripts", "settings-sync.json");
const state = JSON.parse(await readFile(statePath, "utf8"));
state.auto_sync = true;
state.last_synced_hash = "";
await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
await writeFile(join(fixture.config, "settings.json"), `${await readFile(join(fixture.config, "settings.json"), "utf8")}\n`, "utf8");
result = run(fixture, ["auto-sync"]);
assert.equal(result.status, 0, result.stderr);
let updated = JSON.parse(await readFile(statePath, "utf8"));
assert.match(updated.last_result, /no-baseline/);
let remote = JSON.parse(await readFile(fixture.ghState, "utf8"));
assert.equal(remote.patches, undefined);
result = run(fixture, ["auto-sync"], { FAKE_GH_NETWORK_FAIL: "1" });
assert.equal(result.status, 1);
updated = JSON.parse(await readFile(statePath, "utf8"));
assert.match(updated.last_result, /network unavailable/);
remote = JSON.parse(await readFile(fixture.ghState, "utf8"));
assert.equal(remote.patches, undefined);
});
test("scheduler generation supports Windows, macOS, and Linux without side effects", async () => {
for (const platform of ["win32", "darwin", "linux"]) {
const fixture = await makeFixture();
let result = run(fixture, ["setup"], { ZED_SYNC_ANSWERS: JSON.stringify(["n", "n"]) });
assert.equal(result.status, 0, result.stderr);
result = run(fixture, ["auto-enable"], {
ZED_SYNC_PLATFORM: platform,
ZED_SYNC_SCHEDULER_DRY_RUN: "1",
});
assert.equal(result.status, 0, `${platform}: ${result.stderr}`);
assert.match(result.stdout, platform === "win32" ? /schtasks/ : platform === "darwin" ? /launchctl/ : /systemctl/);
}
});
test("Windows bootstrap installs without gh, authentication, or a Gist", { skip: process.platform !== "win32" }, async () => {
const fixture = await makeFixture();
const bundle = join(fixture.root, "bundle.json");
let result = run(fixture, ["export", bundle]);
assert.equal(result.status, 0, result.stderr);
const appData = join(fixture.root, "appdata");
await mkdir(join(appData, "Zed", "scripts"), { recursive: true });
await writeFile(join(appData, "Zed", "scripts", "settings-sync.json"), JSON.stringify({
gist_id: "legacy1234",
last_synced_hash: "abc",
}), "utf8");
const powershell = spawnSync("powershell.exe", ["-NoProfile", "-File", join(repository, "bootstrap.ps1")], {
cwd: repository,
encoding: "utf8",
env: {
...process.env,
APPDATA: appData,
ZED_SETTINGS_BUNDLE_PATH: bundle,
ZED_SYNC_SKIP_SETUP: "1",
},
});
assert.equal(powershell.status, 0, powershell.stderr);
assert.equal(existsSync(join(appData, "Zed", "settings.json")), true);
const installedState = JSON.parse(await readFile(join(appData, "Zed", "scripts", "settings-sync.json"), "utf8"));
assert.equal(installedState.gist_id, "legacy1234");
assert.equal(installedState.last_synced_hash, "abc");
assert.equal(installedState.auto_sync, false);
assert.equal(installedState.interval_minutes, 15);
});
test("Windows repository installer migrates old local state without losing Gist identity", { skip: process.platform !== "win32" }, async () => {
const fixture = await makeFixture();
const appData = join(fixture.root, "appdata-install");
const statePath = join(appData, "Zed", "scripts", "settings-sync.json");
await mkdir(dirname(statePath), { recursive: true });
await writeFile(statePath, JSON.stringify({ gist_id: "keep56789", last_synced_hash: "def" }), "utf8");
const powershell = spawnSync("powershell.exe", ["-NoProfile", "-File", join(repository, "install.ps1")], {
cwd: repository,
encoding: "utf8",
env: { ...process.env, APPDATA: appData },
});
assert.equal(powershell.status, 0, powershell.stderr);
const state = JSON.parse(await readFile(statePath, "utf8"));
assert.equal(state.gist_id, "keep56789");
assert.equal(state.last_synced_hash, "def");
assert.equal(state.auto_sync, false);
assert.equal(state.interval_minutes, 15);
});
const BUNDLE_NAME = "zed-settings-sync.json";
File diff suppressed because one or more lines are too long