Redesign Zed Gist sync workflow
This commit is contained in:
@@ -1,102 +1,118 @@
|
||||
# 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:
|
||||
|
||||
```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
|
||||
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.
|
||||
- `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.
|
||||
## Zed Tasks
|
||||
|
||||
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+; 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` 在 macOS 是 Command,在 Windows/Linux 是 Ctrl;配置通过 Zed 的 `secondary-` 语法跨平台复用。
|
||||
|
||||
`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:
|
||||
- Windows:Task 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
|
||||
.\install.ps1
|
||||
```
|
||||
|
||||
Export later changes from Zed back into this directory:
|
||||
从当前 Zed 配置重新生成仓库配置和单文件:
|
||||
|
||||
```powershell
|
||||
.\export.ps1
|
||||
```
|
||||
|
||||
## macOS and Linux
|
||||
|
||||
Node.js is required for the exclusion toggle and task-template rendering.
|
||||
运行隔离测试:
|
||||
|
||||
```sh
|
||||
sh ./install.sh
|
||||
sh ./export.sh
|
||||
node --test tests/settings-sync.test.mjs
|
||||
```
|
||||
|
||||
## Private Git synchronization
|
||||
|
||||
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.
|
||||
测试使用模拟 `gh` 和临时 Zed 目录,不会访问或创建真实 Gist。
|
||||
|
||||
+142
-175
@@ -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`:界面、字体、主题、编辑器行为及扩展自动安装清单。
|
||||
- `keymap.json`:自定义键位。
|
||||
- `tasks.json`:文件显隐和设置同步任务。
|
||||
- `scripts/` 中的文件显隐及同步脚本。
|
||||
- 非空的 `snippets/` 和 `themes/` 目录。
|
||||
- Node.js
|
||||
- Windows PowerShell,或 macOS/Linux 的 `curl`
|
||||
|
||||
不会同步:
|
||||
|
||||
- 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`。其他机器可以使用系统包管理器安装:
|
||||
启用 GitHub Gist 同步还需要 GitHub CLI `gh`:
|
||||
|
||||
```powershell
|
||||
# Windows
|
||||
winget install GitHub.cli
|
||||
```
|
||||
|
||||
macOS:
|
||||
|
||||
```sh
|
||||
# macOS
|
||||
brew install gh
|
||||
```
|
||||
|
||||
安装后,在 Zed 中按 `Mod+R` 打开 Task Picker,运行:
|
||||
Linux 安装方式见 <https://github.com/cli/cli/blob/trunk/docs/install_linux.md>。
|
||||
|
||||
```text
|
||||
Zed Settings: Authenticate GitHub...
|
||||
```
|
||||
无需提前创建 Gist,也无需手工生成 Personal Access Token。设置向导会调用 GitHub CLI 的浏览器登录流程,并请求 Gist 权限。
|
||||
|
||||
也可以在终端执行:
|
||||
## 第一台机器
|
||||
|
||||
```sh
|
||||
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 一行安装
|
||||
### 1. 安装配置
|
||||
|
||||
Windows 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
|
||||
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
|
||||
irm https://<Gitea域名>/<用户>/<仓库>/raw/branch/main/bootstrap-from-gist.ps1 | iex
|
||||
```
|
||||
1. 从公开 Gitea 仓库下载默认 `zed-settings-sync.json`。
|
||||
2. 把当前 Zed 配置备份到 `backups/bootstrap-<时间>`。
|
||||
3. 安装设置、键位、Tasks 和同步脚本。
|
||||
4. 创建空的机器本地同步状态。
|
||||
|
||||
```sh
|
||||
curl -fsSL https://<Gitea域名>/<用户>/<仓库>/raw/branch/main/bootstrap-from-gist.sh | sh
|
||||
```
|
||||
这四步不访问 GitHub。没有 `gh`、没有登录或没有 Gist 都不会造成安装失败。
|
||||
|
||||
执行过程中脚本会:
|
||||
### 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
|
||||
.\install.ps1
|
||||
```
|
||||
### 3. 创建首个 Gist
|
||||
|
||||
```sh
|
||||
sh ./install.sh
|
||||
```
|
||||
登录后向导会搜索当前账号下包含 `zed-settings-sync.json` 的 Gist。第一台机器通常没有结果:
|
||||
|
||||
然后在 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`。
|
||||
2. 运行 `Zed Settings: Show GitHub Gist Status`。
|
||||
3. 如果显示 `Remote changes available`,执行 Pull。
|
||||
1. 安装 Node.js;需要同步时再安装 `gh`。
|
||||
2. 执行与第一台机器相同的一行安装命令。
|
||||
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+; 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 哈希。
|
||||
- Gist 中单 JSON 的哈希。
|
||||
- 当前机器上次成功 Push/Pull 时保存的哈希。
|
||||
1. 验证已经配置 Gist。
|
||||
2. 验证 GitHub CLI 的系统凭据存储,而不是依赖临时环境 token。
|
||||
3. 安装当前用户级的 15 分钟定时任务。
|
||||
4. 把 `auto_sync` 写为 `true`。
|
||||
|
||||
Status 可能显示:
|
||||
关闭时运行 `Disable Automatic Sync`,它会删除对应的系统调度项并保留 Gist 关联和同步基线。
|
||||
|
||||
| 状态 | 含义 | 建议操作 |
|
||||
系统实现:
|
||||
|
||||
- Windows:Task Scheduler,任务名 `ZedSettingsSync`。
|
||||
- macOS:LaunchAgent `cool.okk.zed-settings-sync.plist`。
|
||||
- Linux:systemd 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 一致 | 无需操作 |
|
||||
| `Local changes pending upload` | 只有本机有变化 | Push |
|
||||
| `Remote changes available` | 只有 Gist 有变化 | Pull |
|
||||
| `Conflict` | 本机和 Gist 都有变化 | 先导出本地备份,再决定 Push 或 Pull |
|
||||
| `Not synchronized on this machine` | 这台机器尚无同步基线 | 首次接入通常选择 Pull |
|
||||
| `synchronized` | 本地和远端一致 | 无需操作 |
|
||||
| `local-changed` | 只有本地变化 | Push Now |
|
||||
| `remote-changed` | 只有远端变化 | Pull Now |
|
||||
| `conflict` | 两边都变化 | 先 Export JSON,再决定保留哪一边 |
|
||||
| `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
|
||||
Zed Settings: Export JSON...
|
||||
Zed Settings: Import JSON...
|
||||
```json
|
||||
{
|
||||
"settings.json": "...",
|
||||
"keymap.json": "...",
|
||||
"tasks.json": "..."
|
||||
}
|
||||
```
|
||||
|
||||
两个命令都会弹出系统文件选择窗口。直接 Import 不会修改已保存的 Gist ID 和上次同步哈希;导入内容会被视为新的本地变化,可以随后 Push 到 Gist。
|
||||
没有 `files` 外壳,也没有内置哈希。Import 不会覆盖本机保存的 Gist ID、自动同步设置或上次同步哈希,但导入后的内容会被识别为新的本地变化。
|
||||
|
||||
## 8. 修改文件排除规则
|
||||
|
||||
只编辑本机配置中的 `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 前的备份位于:
|
||||
Import 和 Pull 的备份目录:
|
||||
|
||||
```text
|
||||
Windows: %APPDATA%\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。
|
||||
|
||||
@@ -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."
|
||||
@@ -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
@@ -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
@@ -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
@@ -25,6 +25,17 @@ Copy-Item -LiteralPath (Join-Path $source "scripts\zed-settings-sync.mjs") -Dest
|
||||
$syncConfig = Join-Path $target "scripts\settings-sync.json"
|
||||
if (-not (Test-Path -LiteralPath $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("\", "/")
|
||||
|
||||
+15
@@ -22,6 +22,21 @@ if [ ! -f "$target/scripts/settings-sync.json" ]; then
|
||||
cp "$source_dir/scripts/settings-sync.json" "$target/scripts/settings-sync.json"
|
||||
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'
|
||||
const fs = require("node:fs");
|
||||
const [source, target, toggleScript, syncScript] = process.argv.slice(2);
|
||||
|
||||
+2
-2
@@ -32,11 +32,11 @@
|
||||
"secondary-; a": "git::StageAll",
|
||||
"secondary-; u": [
|
||||
"task::Spawn",
|
||||
{ "task_name": "Zed Settings: Push to GitHub Gist" }
|
||||
{ "task_name": "Zed Settings: Push Now" }
|
||||
],
|
||||
"secondary-; d": [
|
||||
"task::Spawn",
|
||||
{ "task_name": "Zed Settings: Pull from GitHub Gist" }
|
||||
{ "task_name": "Zed Settings: Pull Now" }
|
||||
],
|
||||
"shift-alt--": [
|
||||
"task::Spawn",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"gist_id": "",
|
||||
"last_synced_hash": ""
|
||||
"last_synced_hash": "",
|
||||
"auto_sync": false,
|
||||
"interval_minutes": 15,
|
||||
"last_run_at": "",
|
||||
"last_result": ""
|
||||
}
|
||||
|
||||
+526
-189
@@ -1,11 +1,23 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { copyFile, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
appendFile,
|
||||
copyFile,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
|
||||
const BUNDLE_NAME = "zed-settings-sync.json";
|
||||
const GIST_DESCRIPTION = "Zed settings sync";
|
||||
const REQUIRED_FILES = ["settings.json", "keymap.json", "tasks.json"];
|
||||
const FIXED_FILES = [
|
||||
...REQUIRED_FILES,
|
||||
@@ -16,8 +28,20 @@ const FIXED_FILES = [
|
||||
];
|
||||
const OPTIONAL_DIRECTORIES = ["snippets", "themes"];
|
||||
const LOCAL_SYNC_CONFIG = "scripts/settings-sync.json";
|
||||
const DEFAULT_SYNC_CONFIG = {
|
||||
gist_id: "",
|
||||
last_synced_hash: "",
|
||||
auto_sync: false,
|
||||
interval_minutes: 15,
|
||||
last_run_at: "",
|
||||
last_result: "",
|
||||
};
|
||||
const LOCK_MAX_AGE_MS = 10 * 60 * 1000;
|
||||
const LOG_MAX_BYTES = 1024 * 1024;
|
||||
const SCHEDULER_NAME = "ZedSettingsSync";
|
||||
|
||||
function zedConfigDirectory() {
|
||||
if (process.env.ZED_CONFIG_DIR) return resolve(process.env.ZED_CONFIG_DIR);
|
||||
if (process.platform === "win32") {
|
||||
if (!process.env.APPDATA) throw new Error("APPDATA is not set");
|
||||
return join(process.env.APPDATA, "Zed");
|
||||
@@ -29,6 +53,10 @@ function timestamp() {
|
||||
return new Date().toISOString().replace(/[:.]/g, "-");
|
||||
}
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
async function writeAtomically(path, contents) {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const temporaryPath = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`);
|
||||
@@ -133,9 +161,7 @@ function parseBundle(contents, sourceName) {
|
||||
throw new Error(`Invalid Zed settings bundle: ${sourceName}`);
|
||||
}
|
||||
for (const required of REQUIRED_FILES) {
|
||||
if (typeof files[required] !== "string") {
|
||||
throw new Error(`Invalid bundle: ${required} is missing`);
|
||||
}
|
||||
if (typeof files[required] !== "string") throw new Error(`Invalid bundle: ${required} is missing`);
|
||||
}
|
||||
for (const [path, value] of Object.entries(files)) {
|
||||
if (!isAllowedBundlePath(path) || typeof value !== "string") {
|
||||
@@ -170,7 +196,6 @@ async function installBundleContents(contents, sourceName) {
|
||||
const configDirectory = zedConfigDirectory();
|
||||
const backupDirectory = await backupCurrentConfiguration(configDirectory);
|
||||
for (const [relativePath, sourceContents] of Object.entries(parsed.files)) {
|
||||
// Gist identity and sync history belong to each machine and are never imported.
|
||||
if (relativePath === LOCAL_SYNC_CONFIG) continue;
|
||||
const destination = join(configDirectory, ...relativePath.split("/"));
|
||||
const installedContents = relativePath === "tasks.json"
|
||||
@@ -198,6 +223,7 @@ function runPowerShell(command, extraEnv = {}) {
|
||||
}
|
||||
|
||||
function nativeFileDialog(mode) {
|
||||
if (process.env.ZED_SYNC_NO_DIALOGS === "1") return "";
|
||||
if (process.platform === "win32") {
|
||||
const common = "[Console]::OutputEncoding=[Text.Encoding]::UTF8; Add-Type -AssemblyName System.Windows.Forms; $o=New-Object System.Windows.Forms.Form; $o.TopMost=$true; $o.ShowInTaskbar=$false; $o.Opacity=0; $o.Show(); $o.Activate(); ";
|
||||
const command = mode === "save"
|
||||
@@ -218,91 +244,6 @@ function nativeFileDialog(mode) {
|
||||
return result.status === 0 ? result.stdout.trim() : "";
|
||||
}
|
||||
|
||||
function nativeInput(prompt, title, defaultValue = "") {
|
||||
if (process.platform === "win32") {
|
||||
const command = `
|
||||
[Console]::OutputEncoding=[Text.Encoding]::UTF8
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$f=New-Object System.Windows.Forms.Form
|
||||
$f.Text=$env:ZED_SYNC_TITLE
|
||||
$f.Size=New-Object System.Drawing.Size(620,190)
|
||||
$f.StartPosition='CenterScreen'
|
||||
$f.TopMost=$true
|
||||
$f.ShowInTaskbar=$true
|
||||
$f.FormBorderStyle='FixedDialog'
|
||||
$f.MaximizeBox=$false
|
||||
$f.MinimizeBox=$false
|
||||
$l=New-Object System.Windows.Forms.Label
|
||||
$l.Text=$env:ZED_SYNC_PROMPT
|
||||
$l.AutoSize=$false
|
||||
$l.Location=New-Object System.Drawing.Point(14,14)
|
||||
$l.Size=New-Object System.Drawing.Size(575,42)
|
||||
$t=New-Object System.Windows.Forms.TextBox
|
||||
$t.Text=$env:ZED_SYNC_DEFAULT
|
||||
$t.Location=New-Object System.Drawing.Point(14,62)
|
||||
$t.Size=New-Object System.Drawing.Size(575,24)
|
||||
$ok=New-Object System.Windows.Forms.Button
|
||||
$ok.Text='OK'
|
||||
$ok.DialogResult=[System.Windows.Forms.DialogResult]::OK
|
||||
$ok.Location=New-Object System.Drawing.Point(427,105)
|
||||
$cancel=New-Object System.Windows.Forms.Button
|
||||
$cancel.Text='Cancel'
|
||||
$cancel.DialogResult=[System.Windows.Forms.DialogResult]::Cancel
|
||||
$cancel.Location=New-Object System.Drawing.Point(514,105)
|
||||
$f.Controls.AddRange(@($l,$t,$ok,$cancel))
|
||||
$f.AcceptButton=$ok
|
||||
$f.CancelButton=$cancel
|
||||
$f.Add_Shown({$f.Activate();$t.Focus();$t.SelectAll()})
|
||||
if($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK){[Console]::Write($t.Text)}
|
||||
`;
|
||||
return runPowerShell(command, {
|
||||
ZED_SYNC_PROMPT: prompt,
|
||||
ZED_SYNC_TITLE: title,
|
||||
ZED_SYNC_DEFAULT: defaultValue,
|
||||
});
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
const escaped = (value) => value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
||||
const script = `text returned of (display dialog "${escaped(prompt)}" with title "${escaped(title)}" default answer "${escaped(defaultValue)}")`;
|
||||
const result = spawnSync("osascript", ["-e", script], { encoding: "utf8" });
|
||||
return result.status === 0 ? result.stdout.trim() : "";
|
||||
}
|
||||
const result = spawnSync("zenity", ["--entry", `--title=${title}`, `--text=${prompt}`, `--entry-text=${defaultValue}`], { encoding: "utf8" });
|
||||
return result.status === 0 ? result.stdout.trim() : "";
|
||||
}
|
||||
|
||||
function nativeMessage(message, title = "Zed Settings Sync", error = false) {
|
||||
if (process.platform === "win32") {
|
||||
const command = "Add-Type -AssemblyName System.Windows.Forms; $o=New-Object System.Windows.Forms.Form; $o.TopMost=$true; $o.ShowInTaskbar=$false; $o.Opacity=0; $o.Show(); $o.Activate(); [void][System.Windows.Forms.MessageBox]::Show($o,$env:ZED_SYNC_MESSAGE,$env:ZED_SYNC_TITLE,'OK',$env:ZED_SYNC_ICON); $o.Close()";
|
||||
runPowerShell(command, {
|
||||
ZED_SYNC_MESSAGE: message,
|
||||
ZED_SYNC_TITLE: title,
|
||||
ZED_SYNC_ICON: error ? "Error" : "Information",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
const escaped = message.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
||||
spawnSync("osascript", ["-e", `display dialog "${escaped}" with title "${title}" buttons {"OK"}`]);
|
||||
return;
|
||||
}
|
||||
spawnSync("zenity", [error ? "--error" : "--info", `--title=${title}`, `--text=${message}`]);
|
||||
}
|
||||
|
||||
function nativeConfirm(message, title = "Zed Settings Sync") {
|
||||
if (process.platform === "win32") {
|
||||
const command = "Add-Type -AssemblyName System.Windows.Forms; $o=New-Object System.Windows.Forms.Form; $o.TopMost=$true; $o.ShowInTaskbar=$false; $o.Opacity=0; $o.Show(); $o.Activate(); [Console]::Write([System.Windows.Forms.MessageBox]::Show($o,$env:ZED_SYNC_MESSAGE,$env:ZED_SYNC_TITLE,'YesNo','Warning')); $o.Close()";
|
||||
return runPowerShell(command, { ZED_SYNC_MESSAGE: message, ZED_SYNC_TITLE: title }) === "Yes";
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
const escaped = message.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
||||
const result = spawnSync("osascript", ["-e", `button returned of (display dialog "${escaped}" with title "${title}" buttons {"Cancel", "Continue"} default button "Continue" cancel button "Cancel")`], { encoding: "utf8" });
|
||||
return result.status === 0 && result.stdout.trim() === "Continue";
|
||||
}
|
||||
return spawnSync("zenity", ["--question", `--title=${title}`, `--text=${message}`]).status === 0;
|
||||
}
|
||||
|
||||
async function readSyncConfiguration() {
|
||||
const path = join(zedConfigDirectory(), ...LOCAL_SYNC_CONFIG.split("/"));
|
||||
let source = {};
|
||||
@@ -314,8 +255,15 @@ async function readSyncConfiguration() {
|
||||
return {
|
||||
path,
|
||||
config: {
|
||||
...DEFAULT_SYNC_CONFIG,
|
||||
gist_id: typeof source.gist_id === "string" ? source.gist_id : "",
|
||||
last_synced_hash: typeof source.last_synced_hash === "string" ? source.last_synced_hash : "",
|
||||
auto_sync: source.auto_sync === true,
|
||||
interval_minutes: Number.isInteger(source.interval_minutes) && source.interval_minutes >= 5
|
||||
? source.interval_minutes
|
||||
: DEFAULT_SYNC_CONFIG.interval_minutes,
|
||||
last_run_at: typeof source.last_run_at === "string" ? source.last_run_at : "",
|
||||
last_result: typeof source.last_result === "string" ? source.last_result : "",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -330,140 +278,526 @@ function normalizeGistId(value) {
|
||||
return matches.at(-1).toLowerCase();
|
||||
}
|
||||
|
||||
async function promptForGist() {
|
||||
const state = await readSyncConfiguration();
|
||||
console.log("Opening Gist configuration dialog...");
|
||||
const entered = nativeInput(
|
||||
"Paste the GitHub Gist ID or full Gist URL. The previous value is remembered on this machine.",
|
||||
"Zed Settings Gist",
|
||||
state.config.gist_id,
|
||||
);
|
||||
if (!entered) return null;
|
||||
const gistId = normalizeGistId(entered);
|
||||
if (gistId !== state.config.gist_id) {
|
||||
state.config.gist_id = gistId;
|
||||
state.config.last_synced_hash = "";
|
||||
await saveSyncConfiguration(state.path, state.config);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function runGh(args, input) {
|
||||
function sanitizedEnvironment() {
|
||||
const env = { ...process.env };
|
||||
delete env.GH_TOKEN;
|
||||
delete env.GITHUB_TOKEN;
|
||||
const result = spawnSync("gh", args, { encoding: "utf8", env, input, windowsHide: true });
|
||||
if (result.error?.code === "ENOENT") {
|
||||
throw new Error("GitHub CLI (gh) is not installed. Install it, then run: gh auth login");
|
||||
return env;
|
||||
}
|
||||
|
||||
function execute(command, args, options = {}) {
|
||||
return spawnSync(command, args, {
|
||||
encoding: options.stdio === "inherit" ? undefined : "utf8",
|
||||
env: options.env || process.env,
|
||||
input: options.input,
|
||||
stdio: options.stdio || "pipe",
|
||||
windowsHide: options.windowsHide ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
function ghMissingMessage() {
|
||||
if (process.platform === "win32") return "GitHub CLI is required. Install it with: winget install GitHub.cli";
|
||||
if (process.platform === "darwin") return "GitHub CLI is required. Install it with: brew install gh";
|
||||
return "GitHub CLI is required. Install it from https://cli.github.com/";
|
||||
}
|
||||
|
||||
function executeGh(args, options = {}) {
|
||||
if (process.env.ZED_SYNC_GH_SCRIPT) {
|
||||
return execute(process.execPath, [process.env.ZED_SYNC_GH_SCRIPT, ...args], options);
|
||||
}
|
||||
if (result.status !== 0) throw new Error(result.stderr.trim() || `gh ${args[0]} failed`);
|
||||
return execute("gh", args, options);
|
||||
}
|
||||
|
||||
function testGhAuthentication(env) {
|
||||
const user = executeGh(["api", "user", "--jq", ".login"], { env });
|
||||
if (user.error?.code === "ENOENT") return { ok: false, missing: true, message: ghMissingMessage() };
|
||||
if (user.status !== 0) {
|
||||
return { ok: false, login: "", message: (user.stderr || user.stdout || "GitHub authentication failed").trim() };
|
||||
}
|
||||
const gists = executeGh(["api", "gists?per_page=1", "--silent"], { env });
|
||||
return {
|
||||
ok: gists.status === 0,
|
||||
login: user.stdout.trim(),
|
||||
message: gists.status === 0 ? "" : (gists.stderr || gists.stdout || "The GitHub credential cannot access Gists").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveGhAuthentication({ interactive = false, requireStored = false } = {}) {
|
||||
const hasEnvironmentToken = Boolean(process.env.GH_TOKEN || process.env.GITHUB_TOKEN);
|
||||
if (!requireStored) {
|
||||
const current = testGhAuthentication(process.env);
|
||||
if (current.missing) throw new Error(current.message);
|
||||
if (current.ok) {
|
||||
return { env: process.env, login: current.login, source: hasEnvironmentToken ? "environment" : "credential-store" };
|
||||
}
|
||||
if (hasEnvironmentToken) console.log("The inherited GitHub token is invalid; trying stored GitHub CLI credentials.");
|
||||
}
|
||||
|
||||
const cleanEnv = sanitizedEnvironment();
|
||||
const stored = testGhAuthentication(cleanEnv);
|
||||
if (stored.missing) throw new Error(stored.message);
|
||||
if (stored.ok) return { env: cleanEnv, login: stored.login, source: "credential-store" };
|
||||
if (!interactive) throw new Error("GitHub is not authenticated. Run 'Zed Settings: Set Up / Reconfigure Sync...'.");
|
||||
|
||||
console.log("Opening GitHub browser authentication. No personal access token needs to be pasted.");
|
||||
const authenticationArgs = stored.login
|
||||
? ["auth", "refresh", "--hostname", "github.com", "--scopes", "gist"]
|
||||
: ["auth", "login", "--hostname", "github.com", "--web", "--clipboard", "--scopes", "gist"];
|
||||
const login = executeGh(
|
||||
authenticationArgs,
|
||||
{ env: cleanEnv, stdio: "inherit", windowsHide: false },
|
||||
);
|
||||
if (login.status !== 0) throw new Error("GitHub browser authentication was cancelled or failed.");
|
||||
const verified = testGhAuthentication(cleanEnv);
|
||||
if (!verified.ok) throw new Error(verified.message || "GitHub authentication could not be verified.");
|
||||
return { env: cleanEnv, login: verified.login, source: "credential-store" };
|
||||
}
|
||||
|
||||
function runGh(args, auth, input) {
|
||||
const result = executeGh(args, { env: auth.env, input });
|
||||
if (result.error?.code === "ENOENT") throw new Error(ghMissingMessage());
|
||||
if (result.status !== 0) throw new Error((result.stderr || result.stdout || `gh ${args[0]} failed`).trim());
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function readRemoteGist(gistId) {
|
||||
const gist = JSON.parse(runGh(["api", `gists/${gistId}`]));
|
||||
function readRemoteGist(gistId, auth) {
|
||||
const gist = JSON.parse(runGh(["api", `gists/${gistId}`], auth));
|
||||
const file = gist.files?.[BUNDLE_NAME];
|
||||
if (!file) return { gist, parsed: null, contents: null };
|
||||
if (file.truncated) throw new Error(`${BUNDLE_NAME} is too large to read through the Gist API`);
|
||||
return { gist, parsed: parseBundle(file.content, `Gist ${gistId}`), contents: file.content };
|
||||
}
|
||||
|
||||
function updateRemoteGist(gistId, contents) {
|
||||
function updateRemoteGist(gistId, contents, auth) {
|
||||
const payload = JSON.stringify({ files: { [BUNDLE_NAME]: { content: contents } } });
|
||||
return JSON.parse(runGh(["api", "--method", "PATCH", `gists/${gistId}`, "--input", "-"], payload));
|
||||
return JSON.parse(runGh(["api", "--method", "PATCH", `gists/${gistId}`, "--input", "-"], auth, payload));
|
||||
}
|
||||
|
||||
function createRemoteGist(contents) {
|
||||
function createRemoteGist(contents, auth) {
|
||||
const payload = JSON.stringify({
|
||||
description: "Zed settings sync",
|
||||
description: GIST_DESCRIPTION,
|
||||
public: false,
|
||||
files: { [BUNDLE_NAME]: { content: contents } },
|
||||
});
|
||||
return JSON.parse(runGh(["api", "--method", "POST", "gists", "--input", "-"], payload));
|
||||
return JSON.parse(runGh(["api", "--method", "POST", "gists", "--input", "-"], auth, payload));
|
||||
}
|
||||
|
||||
function discoverGists(auth) {
|
||||
const raw = runGh(["api", "--paginate", "--slurp", "gists?per_page=100"], auth);
|
||||
const pages = JSON.parse(raw);
|
||||
const gists = (Array.isArray(pages[0]) ? pages.flat() : pages)
|
||||
.filter((gist) => gist.files?.[BUNDLE_NAME])
|
||||
.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)));
|
||||
return gists;
|
||||
}
|
||||
|
||||
function shortHash(hash) {
|
||||
return hash ? hash.slice(0, 12) : "none";
|
||||
}
|
||||
|
||||
async function configureGist() {
|
||||
const state = await promptForGist();
|
||||
if (!state) return;
|
||||
nativeMessage(`Saved Gist ID:\n${state.config.gist_id}\n\nAuthentication is managed by GitHub CLI (gh).`);
|
||||
function classifySync(localHash, remoteHash, lastHash) {
|
||||
if (!remoteHash) return "remote-missing";
|
||||
if (localHash === remoteHash) return "synchronized";
|
||||
if (!lastHash) return "no-baseline";
|
||||
if (localHash === lastHash) return "remote-changed";
|
||||
if (remoteHash === lastHash) return "local-changed";
|
||||
return "conflict";
|
||||
}
|
||||
|
||||
let scriptedAnswers;
|
||||
async function ask(question, defaultValue = "") {
|
||||
if (scriptedAnswers === undefined) {
|
||||
try {
|
||||
scriptedAnswers = JSON.parse(process.env.ZED_SYNC_ANSWERS || "[]");
|
||||
} catch {
|
||||
scriptedAnswers = [];
|
||||
}
|
||||
}
|
||||
if (scriptedAnswers.length) {
|
||||
const answer = String(scriptedAnswers.shift());
|
||||
console.log(`${question}${answer}`);
|
||||
return answer;
|
||||
}
|
||||
if (!process.stdin.isTTY) throw new Error("Interactive input is required. Run this command from the Zed Task Picker.");
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
return (await rl.question(question)).trim() || defaultValue;
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function askYesNo(question, defaultYes = false) {
|
||||
const answer = (await ask(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"} `, defaultYes ? "y" : "n")).toLowerCase();
|
||||
return answer === "y" || answer === "yes";
|
||||
}
|
||||
|
||||
async function appendLog(message) {
|
||||
const logDirectory = join(zedConfigDirectory(), "logs");
|
||||
const logPath = join(logDirectory, "settings-sync.log");
|
||||
await mkdir(logDirectory, { recursive: true });
|
||||
try {
|
||||
if ((await stat(logPath)).size > LOG_MAX_BYTES) {
|
||||
await rm(`${logPath}.3`, { force: true });
|
||||
if (existsSync(`${logPath}.2`)) await rename(`${logPath}.2`, `${logPath}.3`);
|
||||
if (existsSync(`${logPath}.1`)) await rename(`${logPath}.1`, `${logPath}.2`);
|
||||
await rename(logPath, `${logPath}.1`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
}
|
||||
await appendFile(logPath, `${now()} ${message}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function recordResult(state, result) {
|
||||
state.config.last_run_at = now();
|
||||
state.config.last_result = result;
|
||||
await saveSyncConfiguration(state.path, state.config);
|
||||
await appendLog(result);
|
||||
}
|
||||
|
||||
function notify(message) {
|
||||
if (process.env.ZED_SYNC_NO_NOTIFICATIONS === "1") return;
|
||||
if (process.platform === "win32") {
|
||||
execute("msg.exe", [process.env.USERNAME || "*", message]);
|
||||
} else if (process.platform === "darwin") {
|
||||
const escaped = message.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
||||
execute("osascript", ["-e", `display notification "${escaped}" with title "Zed Settings Sync"`]);
|
||||
} else {
|
||||
execute("notify-send", ["Zed Settings Sync", message]);
|
||||
}
|
||||
}
|
||||
|
||||
async function withSyncLock(action) {
|
||||
const lockPath = join(zedConfigDirectory(), "scripts", ".settings-sync.lock");
|
||||
try {
|
||||
await mkdir(lockPath, { recursive: false });
|
||||
} catch (error) {
|
||||
if (error.code !== "EEXIST") throw error;
|
||||
const age = Date.now() - (await stat(lockPath)).mtimeMs;
|
||||
if (age <= LOCK_MAX_AGE_MS) {
|
||||
console.log("Another settings sync is already running.");
|
||||
return null;
|
||||
}
|
||||
await rm(lockPath, { recursive: true, force: true });
|
||||
await mkdir(lockPath);
|
||||
}
|
||||
try {
|
||||
await writeFile(join(lockPath, "owner.json"), JSON.stringify({ pid: process.pid, started_at: now() }));
|
||||
return await action();
|
||||
} finally {
|
||||
await rm(lockPath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function selectSetupGist(gists, state) {
|
||||
console.log("\nAvailable Zed settings Gists:");
|
||||
gists.forEach((gist, index) => {
|
||||
const current = gist.id === state.config.gist_id ? " (current)" : "";
|
||||
console.log(` ${index + 1}. ${gist.description || "Untitled"} - ${gist.id}${current} - ${gist.updated_at}`);
|
||||
});
|
||||
console.log(" N. Create a new Secret Gist from this machine");
|
||||
console.log(" M. Enter another Gist URL or ID");
|
||||
console.log(" Q. Cancel");
|
||||
const defaultChoice = gists.length === 1 ? "1" : (gists.length === 0 ? "n" : "q");
|
||||
const choice = (await ask(`Choose [${defaultChoice.toUpperCase()}]: `, defaultChoice)).toLowerCase();
|
||||
if (choice === "q") return { action: "cancel" };
|
||||
if (choice === "n") return { action: "create" };
|
||||
if (choice === "m") return { action: "existing", gistId: normalizeGistId(await ask("Gist URL or ID: ")) };
|
||||
const index = Number.parseInt(choice, 10) - 1;
|
||||
if (!Number.isInteger(index) || !gists[index]) throw new Error("Invalid Gist selection");
|
||||
return { action: "existing", gistId: gists[index].id };
|
||||
}
|
||||
|
||||
async function setupSync({ offerAutomaticSync = true } = {}) {
|
||||
console.log("Zed Settings Sync Setup\n");
|
||||
const auth = await resolveGhAuthentication({ interactive: true });
|
||||
console.log(`Connected to GitHub as ${auth.login} (${auth.source}).`);
|
||||
const state = await readSyncConfiguration();
|
||||
const gists = discoverGists(auth);
|
||||
if (state.config.gist_id && !gists.some((gist) => gist.id === state.config.gist_id)) {
|
||||
try {
|
||||
gists.unshift(readRemoteGist(state.config.gist_id, auth).gist);
|
||||
} catch (error) {
|
||||
console.log(`The currently configured Gist could not be loaded: ${error.message}`);
|
||||
}
|
||||
}
|
||||
const selection = await selectSetupGist(gists, state);
|
||||
if (selection.action === "cancel") {
|
||||
console.log("Setup cancelled. Local Zed settings remain installed and unchanged.");
|
||||
return;
|
||||
}
|
||||
|
||||
const local = await createBundle();
|
||||
if (selection.action === "create") {
|
||||
const created = createRemoteGist(local.contents, auth);
|
||||
state.config.gist_id = created.id;
|
||||
state.config.last_synced_hash = local.hash;
|
||||
await recordResult(state, `Created and uploaded Secret Gist ${created.id}`);
|
||||
console.log(`\nCreated Secret Gist: ${created.html_url || created.id}`);
|
||||
console.log(`SHA-256: ${local.hash}`);
|
||||
} else {
|
||||
const remote = readRemoteGist(selection.gistId, auth);
|
||||
console.log(`\nRemote: ${remote.gist.html_url || selection.gistId}`);
|
||||
state.config.gist_id = selection.gistId;
|
||||
if (!remote.parsed) {
|
||||
console.log(`This Gist does not contain ${BUNDLE_NAME}.`);
|
||||
if (!await askYesNo("Upload this machine's current Zed settings to it?", true)) return;
|
||||
updateRemoteGist(selection.gistId, local.contents, auth);
|
||||
state.config.last_synced_hash = local.hash;
|
||||
await recordResult(state, `Initialized Gist ${selection.gistId} with this machine's settings`);
|
||||
} else {
|
||||
console.log(`Local SHA-256: ${shortHash(local.hash)}`);
|
||||
console.log(`Remote SHA-256: ${shortHash(remote.parsed.hash)}`);
|
||||
console.log(" P. Pull remote settings onto this machine (backup first)");
|
||||
console.log(" U. Upload this machine's settings to the Gist");
|
||||
console.log(" Q. Cancel");
|
||||
const direction = (await ask("Initial sync direction [P]: ", "p")).toLowerCase();
|
||||
if (direction === "q") return;
|
||||
if (direction === "p") {
|
||||
if (local.hash !== remote.parsed.hash) await installBundleContents(remote.contents, `Gist ${selection.gistId}`);
|
||||
state.config.last_synced_hash = remote.parsed.hash;
|
||||
await recordResult(state, `Pulled settings from Gist ${selection.gistId}`);
|
||||
} else if (direction === "u") {
|
||||
updateRemoteGist(selection.gistId, local.contents, auth);
|
||||
state.config.last_synced_hash = local.hash;
|
||||
await recordResult(state, `Uploaded settings to Gist ${selection.gistId}`);
|
||||
} else {
|
||||
throw new Error("Invalid initial sync direction");
|
||||
}
|
||||
}
|
||||
await saveSyncConfiguration(state.path, state.config);
|
||||
console.log(`Configured Gist ${selection.gistId}.`);
|
||||
}
|
||||
|
||||
if (offerAutomaticSync) {
|
||||
if (await askYesNo("Enable automatic synchronization every 15 minutes?", false)) {
|
||||
await enableAutomaticSync();
|
||||
} else {
|
||||
console.log("Automatic synchronization remains disabled. Use Push Now and Pull Now from the Task Picker.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function requireConfiguredState() {
|
||||
const state = await readSyncConfiguration();
|
||||
if (state.config.gist_id) return state;
|
||||
console.log("No Gist is configured. Starting setup...\n");
|
||||
await setupSync();
|
||||
const configured = await readSyncConfiguration();
|
||||
return configured.config.gist_id ? configured : null;
|
||||
}
|
||||
|
||||
async function pushGist() {
|
||||
const state = await readSyncConfiguration();
|
||||
const localBundle = await createBundle();
|
||||
if (!state.config.gist_id) {
|
||||
console.log("No Gist is configured. Asking to create one...");
|
||||
const proceed = nativeConfirm(
|
||||
"No GitHub Gist is configured. Create a new Secret Gist from this machine's current Zed settings?",
|
||||
);
|
||||
if (!proceed) return;
|
||||
const created = createRemoteGist(localBundle.contents);
|
||||
state.config.gist_id = created.id;
|
||||
state.config.last_synced_hash = localBundle.hash;
|
||||
await saveSyncConfiguration(state.path, state.config);
|
||||
nativeMessage(`Created and uploaded Secret Gist:\n${created.html_url || created.id}\n\nSHA-256: ${localBundle.hash}`);
|
||||
return;
|
||||
}
|
||||
const remote = readRemoteGist(state.config.gist_id);
|
||||
return withSyncLock(async () => {
|
||||
const state = await requireConfiguredState();
|
||||
if (!state) return;
|
||||
const auth = await resolveGhAuthentication({ interactive: true });
|
||||
const local = await createBundle();
|
||||
const remote = readRemoteGist(state.config.gist_id, auth);
|
||||
const remoteHash = remote.parsed?.hash || "";
|
||||
const lastHash = state.config.last_synced_hash;
|
||||
if (remoteHash && remoteHash !== localBundle.hash) {
|
||||
const remoteChanged = !lastHash || remoteHash !== lastHash;
|
||||
if (remoteChanged) {
|
||||
const proceed = nativeConfirm(
|
||||
`The Gist contains different settings.\n\nLocal: ${shortHash(localBundle.hash)}\nRemote: ${shortHash(remoteHash)}\nLast sync: ${shortHash(lastHash)}\n\nOverwrite the Gist with this machine's settings?`,
|
||||
);
|
||||
if (!proceed) return;
|
||||
const status = classifySync(local.hash, remoteHash, state.config.last_synced_hash);
|
||||
if (["remote-changed", "conflict", "no-baseline"].includes(status)) {
|
||||
console.log(`Remote settings differ (${status}).`);
|
||||
console.log(`Local: ${shortHash(local.hash)} Remote: ${shortHash(remoteHash)} Last: ${shortHash(state.config.last_synced_hash)}`);
|
||||
if (!await askYesNo("Overwrite the remote Gist with this machine's settings?", false)) return;
|
||||
}
|
||||
}
|
||||
const updated = updateRemoteGist(state.config.gist_id, localBundle.contents);
|
||||
state.config.last_synced_hash = localBundle.hash;
|
||||
await saveSyncConfiguration(state.path, state.config);
|
||||
nativeMessage(`Uploaded to Gist ${state.config.gist_id}\n\nSHA-256: ${localBundle.hash}\nUpdated: ${updated.updated_at || "unknown"}`);
|
||||
updateRemoteGist(state.config.gist_id, local.contents, auth);
|
||||
state.config.last_synced_hash = local.hash;
|
||||
await recordResult(state, `Uploaded settings to Gist ${state.config.gist_id}`);
|
||||
console.log(`Uploaded to ${remote.gist.html_url || state.config.gist_id}`);
|
||||
console.log(`SHA-256: ${local.hash}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function pullGist() {
|
||||
const state = await promptForGist();
|
||||
return withSyncLock(async () => {
|
||||
const state = await requireConfiguredState();
|
||||
if (!state) return;
|
||||
const localBundle = await createBundle();
|
||||
const remote = readRemoteGist(state.config.gist_id);
|
||||
const auth = await resolveGhAuthentication({ interactive: true });
|
||||
const local = await createBundle();
|
||||
const remote = readRemoteGist(state.config.gist_id, auth);
|
||||
if (!remote.parsed) throw new Error(`Gist ${state.config.gist_id} does not contain ${BUNDLE_NAME}`);
|
||||
if (localBundle.hash !== remote.parsed.hash) {
|
||||
const localChanged = !state.config.last_synced_hash || localBundle.hash !== state.config.last_synced_hash;
|
||||
if (localChanged) {
|
||||
const proceed = nativeConfirm(
|
||||
`This machine contains different settings.\n\nLocal: ${shortHash(localBundle.hash)}\nRemote: ${shortHash(remote.parsed.hash)}\nLast sync: ${shortHash(state.config.last_synced_hash)}\n\nReplace local settings with the Gist version? A backup will be created first.`,
|
||||
);
|
||||
if (!proceed) return;
|
||||
}
|
||||
await installBundleContents(remote.contents, `Gist ${state.config.gist_id}`);
|
||||
const status = classifySync(local.hash, remote.parsed.hash, state.config.last_synced_hash);
|
||||
if (["local-changed", "conflict", "no-baseline"].includes(status)) {
|
||||
console.log(`Local settings differ (${status}).`);
|
||||
console.log(`Local: ${shortHash(local.hash)} Remote: ${shortHash(remote.parsed.hash)} Last: ${shortHash(state.config.last_synced_hash)}`);
|
||||
if (!await askYesNo("Replace local settings with the Gist version? A backup will be created first.", false)) return;
|
||||
}
|
||||
if (local.hash !== remote.parsed.hash) await installBundleContents(remote.contents, `Gist ${state.config.gist_id}`);
|
||||
state.config.last_synced_hash = remote.parsed.hash;
|
||||
await saveSyncConfiguration(state.path, state.config);
|
||||
nativeMessage(`Downloaded from Gist ${state.config.gist_id}\n\nSHA-256: ${remote.parsed.hash}\nRemote updated: ${remote.gist.updated_at || "unknown"}`);
|
||||
await recordResult(state, `Pulled settings from Gist ${state.config.gist_id}`);
|
||||
console.log(`Downloaded from ${remote.gist.html_url || state.config.gist_id}`);
|
||||
console.log(`SHA-256: ${remote.parsed.hash}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function showGistStatus() {
|
||||
const state = await promptForGist();
|
||||
if (!state) return;
|
||||
const localBundle = await createBundle();
|
||||
const remote = readRemoteGist(state.config.gist_id);
|
||||
async function showStatus() {
|
||||
const state = await readSyncConfiguration();
|
||||
if (!state.config.gist_id) {
|
||||
console.log("Sync is not configured. Run 'Zed Settings: Set Up / Reconfigure Sync...'.");
|
||||
return;
|
||||
}
|
||||
const auth = await resolveGhAuthentication();
|
||||
const local = await createBundle();
|
||||
const remote = readRemoteGist(state.config.gist_id, auth);
|
||||
const status = classifySync(local.hash, remote.parsed?.hash || "", state.config.last_synced_hash);
|
||||
console.log(`Gist: ${remote.gist.html_url || state.config.gist_id}`);
|
||||
console.log(`Status: ${status}`);
|
||||
console.log(`Automatic sync: ${state.config.auto_sync ? `enabled (${state.config.interval_minutes} minutes)` : "disabled"}`);
|
||||
console.log(`Local: ${shortHash(local.hash)}`);
|
||||
console.log(`Remote: ${shortHash(remote.parsed?.hash || "")}`);
|
||||
console.log(`Last: ${shortHash(state.config.last_synced_hash)}`);
|
||||
console.log(`Last run: ${state.config.last_run_at || "never"}`);
|
||||
console.log(`Last result: ${state.config.last_result || "none"}`);
|
||||
}
|
||||
|
||||
async function automaticSync() {
|
||||
return withSyncLock(async () => {
|
||||
const state = await readSyncConfiguration();
|
||||
if (!state.config.auto_sync) return;
|
||||
try {
|
||||
if (!state.config.gist_id) throw new Error("Automatic sync requires setup");
|
||||
const auth = await resolveGhAuthentication({ requireStored: true });
|
||||
const local = await createBundle();
|
||||
const remote = readRemoteGist(state.config.gist_id, auth);
|
||||
const remoteHash = remote.parsed?.hash || "";
|
||||
const lastHash = state.config.last_synced_hash;
|
||||
let status = "The Gist does not contain a Zed settings file.";
|
||||
if (remoteHash === localBundle.hash) status = "Synchronized";
|
||||
else if (!lastHash) status = "Not synchronized on this machine";
|
||||
else if (localBundle.hash === lastHash) status = "Remote changes available";
|
||||
else if (remoteHash === lastHash) status = "Local changes pending upload";
|
||||
else status = "Conflict: local and remote settings both changed";
|
||||
nativeMessage(
|
||||
`Gist: ${remote.gist.html_url || state.config.gist_id}\nStatus: ${status}\n\nLocal: ${shortHash(localBundle.hash)}\nRemote: ${shortHash(remoteHash)}\nLast sync: ${shortHash(lastHash)}\nRemote updated: ${remote.gist.updated_at || "unknown"}`,
|
||||
"Zed Settings Gist Status",
|
||||
);
|
||||
const status = classifySync(local.hash, remoteHash, state.config.last_synced_hash);
|
||||
if (status === "synchronized") {
|
||||
await recordResult(state, "Automatic sync: already synchronized");
|
||||
} else if (status === "local-changed") {
|
||||
updateRemoteGist(state.config.gist_id, local.contents, auth);
|
||||
state.config.last_synced_hash = local.hash;
|
||||
await recordResult(state, "Automatic sync: uploaded local changes");
|
||||
} else if (status === "remote-changed") {
|
||||
await installBundleContents(remote.contents, `Gist ${state.config.gist_id}`);
|
||||
state.config.last_synced_hash = remote.parsed.hash;
|
||||
await recordResult(state, "Automatic sync: downloaded remote changes");
|
||||
} else {
|
||||
const result = `Automatic sync stopped: ${status}`;
|
||||
const shouldNotify = state.config.last_result !== result;
|
||||
await recordResult(state, result);
|
||||
if (shouldNotify) notify(`${result}. Open the Zed Task Picker to resolve it.`);
|
||||
}
|
||||
} catch (error) {
|
||||
const result = `Automatic sync failed: ${error.message}`;
|
||||
const shouldNotify = state.config.last_result !== result;
|
||||
await recordResult(state, result);
|
||||
if (shouldNotify) notify(result);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function xmlEscape(value) {
|
||||
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
|
||||
function schedulerPlatform() {
|
||||
return process.env.ZED_SYNC_PLATFORM || process.platform;
|
||||
}
|
||||
|
||||
async function configureScheduler(enable, intervalMinutes) {
|
||||
const platform = schedulerPlatform();
|
||||
const scriptPath = resolve(process.argv[1]);
|
||||
const dryRun = process.env.ZED_SYNC_SCHEDULER_DRY_RUN === "1";
|
||||
const actions = [];
|
||||
const run = (command, args, options = {}) => {
|
||||
actions.push([command, ...args].join(" "));
|
||||
if (dryRun) return { status: 0, stdout: "", stderr: "" };
|
||||
return execute(command, args, options);
|
||||
};
|
||||
|
||||
if (platform === "win32") {
|
||||
if (enable) {
|
||||
const taskCommand = `\"${process.execPath}\" \"${scriptPath}\" auto-sync`;
|
||||
const result = run("schtasks.exe", ["/Create", "/SC", "MINUTE", "/MO", String(intervalMinutes), "/TN", SCHEDULER_NAME, "/TR", taskCommand, "/F"]);
|
||||
if (result.status !== 0) throw new Error((result.stderr || result.stdout || "Could not create Windows scheduled task").trim());
|
||||
} else {
|
||||
run("schtasks.exe", ["/Delete", "/TN", SCHEDULER_NAME, "/F"]);
|
||||
}
|
||||
} else if (platform === "darwin") {
|
||||
const plistPath = join(homedir(), "Library", "LaunchAgents", "cool.okk.zed-settings-sync.plist");
|
||||
run("launchctl", ["unload", plistPath]);
|
||||
if (enable) {
|
||||
const plist = `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n <key>Label</key><string>cool.okk.zed-settings-sync</string>\n <key>ProgramArguments</key><array><string>${xmlEscape(process.execPath)}</string><string>${xmlEscape(scriptPath)}</string><string>auto-sync</string></array>\n <key>StartInterval</key><integer>${intervalMinutes * 60}</integer>\n <key>RunAtLoad</key><true/>\n</dict></plist>\n`;
|
||||
if (!dryRun) await writeAtomically(plistPath, plist);
|
||||
actions.push(`write ${plistPath}`);
|
||||
const result = run("launchctl", ["load", plistPath]);
|
||||
if (result.status !== 0) throw new Error((result.stderr || "Could not load LaunchAgent").trim());
|
||||
} else if (!dryRun) {
|
||||
await rm(plistPath, { force: true });
|
||||
}
|
||||
} else {
|
||||
const systemdDirectory = join(homedir(), ".config", "systemd", "user");
|
||||
const servicePath = join(systemdDirectory, "zed-settings-sync.service");
|
||||
const timerPath = join(systemdDirectory, "zed-settings-sync.timer");
|
||||
const systemd = run("systemctl", ["--user", "--version"]);
|
||||
const systemdSession = systemd.status === 0
|
||||
? run("systemctl", ["--user", "show-environment"])
|
||||
: { status: 1 };
|
||||
if (enable && systemdSession.status === 0) {
|
||||
const service = `[Unit]\nDescription=Synchronize Zed settings\n\n[Service]\nType=oneshot\nExecStart=\"${process.execPath.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}\" \"${scriptPath.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}\" auto-sync\n`;
|
||||
const timer = `[Unit]\nDescription=Synchronize Zed settings every ${intervalMinutes} minutes\n\n[Timer]\nOnBootSec=2min\nOnUnitActiveSec=${intervalMinutes}min\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n`;
|
||||
if (!dryRun) {
|
||||
await writeAtomically(servicePath, service);
|
||||
await writeAtomically(timerPath, timer);
|
||||
}
|
||||
actions.push(`write ${servicePath}`, `write ${timerPath}`);
|
||||
run("systemctl", ["--user", "daemon-reload"]);
|
||||
const result = run("systemctl", ["--user", "enable", "--now", "zed-settings-sync.timer"]);
|
||||
if (result.status !== 0) throw new Error((result.stderr || "Could not enable systemd user timer").trim());
|
||||
} else if (!enable) {
|
||||
if (systemd.status === 0) run("systemctl", ["--user", "disable", "--now", "zed-settings-sync.timer"]);
|
||||
if (!dryRun) {
|
||||
await rm(servicePath, { force: true });
|
||||
await rm(timerPath, { force: true });
|
||||
}
|
||||
if (systemdSession.status === 0) run("systemctl", ["--user", "daemon-reload"]);
|
||||
const current = run("crontab", ["-l"]);
|
||||
if (current.status === 0) {
|
||||
const lines = current.stdout.split(/\r?\n/).filter((line) => line && !line.includes("# zed-settings-sync"));
|
||||
run("crontab", ["-"], { input: `${lines.join("\n")}\n` });
|
||||
}
|
||||
} else {
|
||||
const current = run("crontab", ["-l"]);
|
||||
const marker = "# zed-settings-sync";
|
||||
const lines = (current.status === 0 ? current.stdout : "").split(/\r?\n/).filter((line) => line && !line.includes(marker));
|
||||
lines.push(`*/${intervalMinutes} * * * * ${shellQuote(process.execPath)} ${shellQuote(scriptPath)} auto-sync ${marker}`);
|
||||
const updated = `${lines.join("\n")}\n`;
|
||||
const result = run("crontab", ["-"], { input: updated });
|
||||
if (result.status !== 0) throw new Error((result.stderr || "Could not update crontab").trim());
|
||||
}
|
||||
}
|
||||
if (dryRun) console.log(actions.join("\n"));
|
||||
}
|
||||
|
||||
async function enableAutomaticSync() {
|
||||
const state = await readSyncConfiguration();
|
||||
if (!state.config.gist_id) {
|
||||
console.log("Sync must be configured before automatic synchronization can be enabled.");
|
||||
await setupSync({ offerAutomaticSync: false });
|
||||
}
|
||||
const configured = await readSyncConfiguration();
|
||||
if (!configured.config.gist_id) return;
|
||||
const auth = await resolveGhAuthentication({ interactive: true, requireStored: true });
|
||||
console.log(`Stored GitHub credentials verified for ${auth.login}.`);
|
||||
await configureScheduler(true, configured.config.interval_minutes);
|
||||
configured.config.auto_sync = true;
|
||||
await recordResult(configured, `Automatic sync enabled every ${configured.config.interval_minutes} minutes`);
|
||||
console.log(`Automatic synchronization is enabled every ${configured.config.interval_minutes} minutes.`);
|
||||
}
|
||||
|
||||
async function disableAutomaticSync() {
|
||||
const state = await readSyncConfiguration();
|
||||
await configureScheduler(false, state.config.interval_minutes);
|
||||
state.config.auto_sync = false;
|
||||
await recordResult(state, "Automatic sync disabled");
|
||||
console.log("Automatic synchronization is disabled.");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
@@ -480,10 +814,13 @@ async function main() {
|
||||
if (path) await importBundle(path);
|
||||
return;
|
||||
}
|
||||
if (mode === "gist-configure") return configureGist();
|
||||
if (mode === "gist-status") return showGistStatus();
|
||||
if (mode === "gist-push") return pushGist();
|
||||
if (mode === "gist-pull") return pullGist();
|
||||
if (mode === "setup") return setupSync();
|
||||
if (mode === "status") return showStatus();
|
||||
if (mode === "push") return pushGist();
|
||||
if (mode === "pull") return pullGist();
|
||||
if (mode === "auto-sync") return automaticSync();
|
||||
if (mode === "auto-enable") return enableAutomaticSync();
|
||||
if (mode === "auto-disable") return disableAutomaticSync();
|
||||
throw new Error(`Unknown mode: ${mode || "<missing>"}`);
|
||||
}
|
||||
|
||||
@@ -492,9 +829,9 @@ try {
|
||||
} catch (error) {
|
||||
console.error(error.stack || error.message);
|
||||
try {
|
||||
nativeMessage(error.message, "Zed Settings Sync Error", true);
|
||||
await appendLog(`ERROR ${error.message}`);
|
||||
} catch {
|
||||
// The terminal output remains available when the native dialog backend is unavailable.
|
||||
// The terminal remains the primary error channel.
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
+66
-53
@@ -79,8 +79,8 @@
|
||||
"export-dialog"
|
||||
],
|
||||
"allow_concurrent_runs": false,
|
||||
"reveal": "never",
|
||||
"hide": "always",
|
||||
"reveal": "always",
|
||||
"hide": "never",
|
||||
"show_summary": false,
|
||||
"show_command": false,
|
||||
"save": "all"
|
||||
@@ -93,81 +93,94 @@
|
||||
"import-dialog"
|
||||
],
|
||||
"allow_concurrent_runs": false,
|
||||
"reveal": "never",
|
||||
"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",
|
||||
"reveal": "always",
|
||||
"hide": "never",
|
||||
"show_summary": false,
|
||||
"show_command": false,
|
||||
"save": "all"
|
||||
},
|
||||
{
|
||||
"label": "Zed Settings: Show GitHub Gist Status",
|
||||
"label": "Zed Settings: Set Up / Reconfigure Sync...",
|
||||
"command": "node",
|
||||
"args": [
|
||||
"__SYNC_SCRIPT__",
|
||||
"gist-status"
|
||||
"setup"
|
||||
],
|
||||
"allow_concurrent_runs": false,
|
||||
"reveal": "no_focus",
|
||||
"reveal": "always",
|
||||
"hide": "never",
|
||||
"show_summary": false,
|
||||
"show_summary": true,
|
||||
"show_command": false,
|
||||
"save": "all"
|
||||
},
|
||||
{
|
||||
"label": "Zed Settings: Push to GitHub Gist",
|
||||
"label": "Zed Settings: Show Sync Status",
|
||||
"command": "node",
|
||||
"args": [
|
||||
"__SYNC_SCRIPT__",
|
||||
"gist-push"
|
||||
],
|
||||
"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"
|
||||
"status"
|
||||
],
|
||||
"allow_concurrent_runs": false,
|
||||
"reveal": "always",
|
||||
"hide": "never",
|
||||
"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"
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user