diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 678bec5..e9cf10d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -49,6 +49,149 @@ jobs:
- run: npm run build
+ installer:
+ # The only place a real scheduled job is ever registered: disposable
+ # runners. Installs the packed checkout through the real installer, links
+ # a local bare repository with every root redirected into the runner's
+ # temp directory, then installs, runs and removes the scheduler entry.
+ name: installer · ${{ matrix.os }}
+ runs-on: ${{ matrix.os }}
+ needs: check
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, windows-latest, macos-latest]
+ env:
+ OPENCODE_SYNC_NO_GH: "1"
+ OPENCODE_SYNC_NO_STAGGER: "1"
+ OPENCODE_SYNC_LANG: en
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+
+ - name: Configure git identity
+ shell: bash
+ run: |
+ git config --global user.name "CI"
+ git config --global user.email "ci@example.com"
+ git config --global init.defaultBranch main
+
+ - name: Build and pack the checkout
+ shell: bash
+ run: |
+ npm ci
+ npm run build
+ npm pack
+ pkg="$(pwd)/$(ls opencode-github-sync-*.tgz)"
+ if [ "$RUNNER_OS" = "Windows" ]; then pkg=$(cygpath -w "$pkg"); fi
+ echo "PKG=$pkg" >> "$GITHUB_ENV"
+
+ - name: Isolated roots and a local bare repository
+ shell: bash
+ run: |
+ base="$RUNNER_TEMP/ogs"
+ mkdir -p "$base/home" "$base/config"
+ git init -q --bare -b main "$base/remote.git"
+ printf '{ "model": "ci" }\n' > "$base/config/opencode.jsonc"
+ {
+ echo "SYNC_CONFIG_ROOT=$base/config"
+ echo "SYNC_DATA_ROOT=$base/data"
+ echo "SYNC_STATE_ROOT=$base/state"
+ echo "SYNC_AGENTS_ROOT=$base/agents/skills"
+ echo "SYNC_CACHE_ROOT=$base/cache"
+ echo "SYNC_HOME=$base/home"
+ echo "OGS_REMOTE=$base/remote.git"
+ echo "OGS_LOG=$base/config/logs/opencode-sync-autosync.log"
+ } >> "$GITHUB_ENV"
+
+ - name: Run install.sh non-interactively
+ if: runner.os != 'Windows'
+ shell: bash
+ run: sh install.sh --mode link --repo "$OGS_REMOTE" --interval 5 --lang en --package "$PKG" --yes /dev/null \
+ || crontab -l | grep -q "opencode-github-sync autosync" ;;
+ esac
+
+ - name: The scheduler actually starts the job
+ shell: bash
+ run: |
+ if [ "$RUNNER_OS" = "Windows" ]; then schtasks //Run //TN opencode-github-sync; fi
+ if [ "$RUNNER_OS" = "Linux" ]; then
+ if systemctl --user is-enabled opencode-github-sync.timer >/dev/null 2>&1; then
+ systemctl --user start opencode-github-sync.service || true
+ else
+ echo "cron fallback: not waiting for the next minute boundary"; exit 0
+ fi
+ fi
+ for _ in $(seq 1 45); do
+ if grep -q "scheduled:" "$OGS_LOG" 2>/dev/null; then cat "$OGS_LOG"; exit 0; fi
+ sleep 2
+ done
+ echo "the scheduled job did not write to $OGS_LOG"
+ cat "$OGS_LOG" 2>/dev/null || true
+ cat "$SYNC_CONFIG_ROOT/logs/autosync-launchd.log" 2>/dev/null || true
+ if [ "$RUNNER_OS" = "Windows" ]; then
+ # An InteractiveToken task needs a signed-in desktop session, which
+ # a hosted runner may not provide. Report what Task Scheduler saw.
+ powershell -NoProfile -Command "Get-ScheduledTaskInfo -TaskName opencode-github-sync | Format-List"
+ echo "::warning::Task Scheduler did not start the job in this runner session"
+ exit 0
+ fi
+ exit 1
+
+ - name: autosync run
+ shell: bash
+ run: |
+ opencode-sync autosync run --trigger ci
+ grep "ci:" "$OGS_LOG"
+
+ - name: autosync uninstall removes the scheduler entry
+ shell: bash
+ run: |
+ opencode-sync autosync uninstall
+ case "$RUNNER_OS" in
+ Windows) ! schtasks //Query //TN opencode-github-sync >/dev/null 2>&1 ;;
+ macOS) test ! -f "$HOME/Library/LaunchAgents/com.opencode-github-sync.autosync.plist"
+ ! launchctl list | grep -q com.opencode-github-sync.autosync ;;
+ Linux) ! systemctl --user is-enabled opencode-github-sync.timer >/dev/null 2>&1
+ ! crontab -l 2>/dev/null | grep -q "opencode-github-sync autosync" ;;
+ esac
+
+ shellcheck:
+ name: shellcheck install.sh
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - run: shellcheck -s sh install.sh
+
package:
name: package contents
runs-on: ubuntu-latest
@@ -68,3 +211,6 @@ jobs:
test -f dist/plugin/index.js
test -f dist/cli/index.js
test -f dist/core/index.js
+ # Installers and README images are served from GitHub, not npm.
+ files=$(npm pack --dry-run --json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s)[0].files.map(f=>f.path).join("\n")))')
+ ! echo "$files" | grep -E '^(install\.(sh|ps1)|docs/)'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 07506e1..09fad75 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,84 @@
[简体中文](./CHANGELOG.zh-CN.md)
+## 3.2.0
+
+### Added
+- **One-line installers.** `install.ps1` (Windows PowerShell 5.1 and
+ PowerShell 7) and `install.sh` (macOS, Linux) check for Node.js 18+ and git,
+ offer to install a missing one through winget, Homebrew or the system
+ package manager (they ask first and never install silently), run
+ `npm install -g opencode-github-sync@latest`, and open the menu. `--mode`,
+ `--repo`, `--interval`, `--lang`, `--proxy` and `--yes` run the whole setup
+ non-interactively. Under `curl | sh` questions are read from the terminal,
+ and on Windows the menu runs as its own process so it gets the keyboard.
+- **Interactive menu.** `opencode-sync` with no arguments (or `opencode-sync
+ menu`) opens a menu with arrow keys, hotkeys and checkboxes, and falls back
+ to numbered prompts whenever input is not a terminal. Every existing
+ subcommand still works and stays scriptable. First run offers: install the
+ command only, link a repository, or link and make this the primary device
+ with autosync. When the machine and the repository differ, it shows the
+ differences and lets you pick a side; the losing side's files go to a
+ recovery folder.
+- **English and Simplified Chinese** for every menu and command string,
+ detected from `OPENCODE_SYNC_LANG`, `LC_ALL`/`LC_MESSAGES`/`LANG` or the
+ system locale, and switchable with `--lang` or from the menu.
+- **Autosync driven by the operating system.** `opencode-sync autosync
+ install|uninstall|enable|disable|status|run|reset|pause|resume`. Task
+ Scheduler on Windows (logon, unlock, remote connect and a repeating timer;
+ hidden through `conhost --headless` or a UTF-16 VBScript launcher), launchd
+ on macOS, a systemd user timer on Linux with a crontab fallback. Each run
+ pushes only when the synced trees have been quiet for a settle period, and
+ otherwise receives only when this machine has nothing of its own in flight.
+ Machines are staggered so they do not collide, failures back off from 30 to
+ 60 minutes without ever giving up, a lost push race is retried rather than
+ counted, and a desktop notification appears only when backing off starts
+ and when it recovers.
+- **What to sync** as checkboxes: main config, instruction files, agents,
+ commands, plugins, skills, credentials and sessions. A switched-off category
+ stays local in both directions.
+- **Large-change guard.** A push that adds more than 300 files, more than
+ 10 MB, or any file over 2 MB waits for confirmation (thresholds are
+ adjustable). Autosync never confirms: it records a failure and notifies.
+ The first push into an empty repository carries the whole existing setup,
+ so only the single-file limit applies there.
+ Generated directories (`node_modules`, `.venv`, `venv`, `__pycache__`,
+ `dist`, `.cache`) are never staged.
+- **Restore a file from history**, with the replaced version kept in the
+ recovery folder.
+- **Device list** built from commit history, and renaming this machine.
+- **Network proxy** for git, gh and npm, applied to child processes only.
+- **Preview** of what a sync would change before running it.
+- **Support bundle** export with tokens, passwords and URL credentials removed.
+- **Update** menu: check npm, upgrade, and an optional daily auto-update run
+ by the autosync job.
+
+### Changed
+- When autosync is on, the plugin no longer pushes on idle (it still pulls at
+ startup), so it never races the scheduled job.
+- Sync commits carry an `Opencode-Sync-Device` trailer naming the machine.
+- `link` accepts any git URL or local path in addition to `owner/name`.
+- A forced pull, and the first pull onto a machine that already has files, copy
+ the files they replace into `opencode-sync-recovery` under the data
+ directory first. This covers new folders, non-ASCII file names, and local
+ skills and extra-path files that the forced pull removes or overwrites.
+
+### Fixed
+- A push that loses a race with another machine rebases and retries up to three
+ times instead of failing. A protected branch or a declined hook is reported
+ as a failure rather than retried.
+- The first push from a machine whose config directory is not yet a
+ repository now writes the remote's skills out before staging, so it can no
+ longer publish every skill it lacks as a deletion.
+- Git runs with `core.quotepath=off` and English messages, so non-ASCII file
+ names count toward the large-change guard and a lost push race is
+ recognised on a localized git.
+
+### Contributors
+- @vitaly74 — OpenCode 2 plugin entrypoint (#6, 3.1.0)
+- @aquelejota — deterministic shard export and working session tests (#5), analysis of #2, #3 and #4 (3.1.0)
+- @logser13 — reported #2, #3 and #4 with reproductions and tested fixes (3.1.x)
+
## 3.1.1
### Fixed
diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md
index 24f0d8b..49cab2d 100644
--- a/CHANGELOG.zh-CN.md
+++ b/CHANGELOG.zh-CN.md
@@ -2,6 +2,38 @@
[English](./CHANGELOG.md)
+## 3.2.0
+
+### 新增
+- **一行命令安装。** `install.ps1`(Windows PowerShell 5.1 和 PowerShell 7 都能用)和 `install.sh`(macOS、Linux)会检查 Node.js 18+ 和 git,缺了就提示用 winget、Homebrew 或系统包管理器安装(一定先问,绝不偷偷装),然后执行 `npm install -g opencode-github-sync@latest` 并打开菜单。带上 `--mode`、`--repo`、`--interval`、`--lang`、`--proxy`、`--yes` 可以全程无人值守完成设置。用 `curl | sh` 时问题从终端读取;在 Windows 上菜单作为独立进程启动,键盘输入不会被管道吃掉。
+- **交互菜单。** 直接运行 `opencode-sync`(或 `opencode-sync menu`)会打开菜单,支持方向键、快捷键和勾选框;输入不是终端时自动改用编号提问。原有子命令全部保留,照样可以写进脚本。第一次运行时可以选:只装命令、关联仓库、关联并把本机设为开启自动同步的主力设备。本机和仓库内容不一样时会先列出差异让你选以哪边为准,被替换的一方会先存进恢复目录。
+- **英文和简体中文界面**,覆盖所有菜单和命令输出。语言按 `OPENCODE_SYNC_LANG`、`LC_ALL`/`LC_MESSAGES`/`LANG`、系统区域设置依次判断,也可以用 `--lang` 或在菜单里切换。
+- **由操作系统驱动的自动同步。** 新增 `opencode-sync autosync install|uninstall|enable|disable|status|run|reset|pause|resume`。Windows 用任务计划程序(登录、解锁、远程连接时触发,外加定时重复;通过 `conhost --headless` 或 UTF-16 编码的 VBScript 隐藏窗口),macOS 用 launchd,Linux 用 systemd 用户定时器,不可用时退回 crontab。每次运行只在同步目录安静一段时间后才推送;还有文件在变化时,只有本机没有待处理的改动才会接收。多台机器会错开时间避免撞车;连续失败后退避间隔从 30 分钟加到 60 分钟,但永不放弃;推送被别的机器抢先不算失败,会自动重试;桌面通知只在开始退避和恢复正常时各弹一次。
+- **同步内容勾选**:主配置、指令文件、智能体、自定义命令、插件、技能、登录凭据、会话。关掉的类别双向都只留在本机。
+- **大改动保护。** 一次推送新增超过 300 个文件、总量超过 10 MB,或有单个文件超过 2 MB 时,要先确认(阈值可调)。自动同步从不自动确认,只记一次失败并发通知。往空仓库的第一次推送本来就要带上全部现有配置,所以只检查单个文件大小。`node_modules`、`.venv`、`venv`、`__pycache__`、`dist`、`.cache` 这类生成目录永远不会提交。
+- **从历史版本恢复单个文件**,被替换的版本保存在恢复目录里。
+- **设备列表**,根据提交历史显示每台机器上次同步的时间,并可以给本机改名。
+- **网络代理**,只作用于 opencode-sync 启动的 git、gh 和 npm。
+- **同步前预览**会改动哪些文件。
+- **导出排障包**,自动去掉令牌、密码和 URL 里的账号密码。
+- **更新**菜单:检查 npm 新版本、立即升级,以及可选的每日自动更新(由自动同步任务执行)。
+
+### 变更
+- 开启自动同步后,插件不再在会话空闲时推送(启动时仍然拉取),避免和定时任务抢着同步。
+- 同步提交带上 `Opencode-Sync-Device` 尾注,记录是哪台机器提交的。
+- `link` 除了 `用户名/仓库名`,也接受任意 git 地址或本地路径。
+- 强制拉取,以及本机已有文件时的第一次拉取,会先把将被替换的文件复制到数据目录下的 `opencode-sync-recovery`。新建的整个文件夹、中文等非 ASCII 文件名,以及强制拉取会删除或覆盖的本机技能和额外路径文件,也都包括在内。
+
+### 修复
+- 推送时被别的机器抢先,会自动变基并最多重试三次,而不是直接失败。分支受保护或被钩子拒绝时会如实报错,不会反复重试。
+- 配置目录还不是仓库的机器第一次推送时,会先把仓库里的技能写回本机再打包,不会再把本机缺少的技能当成删除推送出去。
+- git 改为以 `core.quotepath=off` 和英文提示运行:中文文件名会正确计入大改动保护,非英文系统上也能识别推送被抢先的情况。
+
+### Contributors
+- @vitaly74 — OpenCode 2 plugin entrypoint (#6, 3.1.0)
+- @aquelejota — deterministic shard export and working session tests (#5), analysis of #2, #3 and #4 (3.1.0)
+- @logser13 — reported #2, #3 and #4 with reproductions and tested fixes (3.1.x)
+
## 3.1.1
### 修复
diff --git a/README.md b/README.md
index 49c1b5a..db18ad1 100644
--- a/README.md
+++ b/README.md
@@ -1,175 +1,227 @@
-
+
-
+
- 简体中文
+ 简体中文
·
- Quick start
+ Install
·
- Overrides
+ Setup
·
- Sessions
+ Autosync
·
Commands
+ ·
+ Troubleshooting
---
-You use OpenCode on more than one machine. Your config, agents, commands, skills
-and MCP servers live on whichever one you touched last.
-
-**opencode-github-sync** keeps them all in step through a private GitHub
-repository — as an OpenCode plugin, as a CLI, or both.
-
-```bash
-npm install -g opencode-github-sync
-opencode-sync init # creates the private repo and configures this machine
-opencode-sync push
-```
-
-
-
-
-
----
-
-## Why another sync tool
-
-Most config-sync tools stop being safe the moment real use hits them. These are
-the three places that happens, and what this one does instead.
-
-| | Typical approach | Here |
-| ------------------ | ----------------------------------------------- | -------------------------------------------------------------------------- |
-| **Machine differences** | Every machine forced identical | [Per-machine overrides](#per-machine-overrides) that sync can never overwrite |
-| **Sessions** | Commit `opencode.db` — gigabytes, unmergeable | [Selective per-session shards](#session-sync), a few MB, conflict-free |
-| **When it breaks** | Plugin-only: bad config means no way back | The CLI runs outside OpenCode and can always recover you |
+You use OpenCode on more than one computer. Your config, agents, commands,
+skills and MCP servers end up wherever you last touched them.
-Plus the boring things that turn out to matter:
+**opencode-github-sync** keeps every machine on the same setup through a
+private GitHub repository. Run it by hand, or let one machine sync itself in
+the background.
-- **Symlinks are refused, not followed.** No copying files out of the machine by accident, no junction turning a delete into the wrong delete.
-- **Every replacement is atomic.** A crash mid-sync cannot leave a half-written config that stops OpenCode from starting.
-- **A push is verified against the remote.** `git push` can exit `0` without the remote moving; that is checked, not assumed.
-- **Nothing runs twice at once.** A cross-process lock covers the plugin, the CLI and every OpenCode window.
-- **Credentials are opt-in** and refused outright on a public repository.
-- **Your hostname never leaves the machine.** Commits are signed with a stable pseudonym, so a corporate asset tag never lands in a repo that might not stay private.
+## Install
----
+**macOS / Linux**
-## Install
+```bash
+curl -fsSL https://raw.githubusercontent.com/doomsday616/opencode-github-sync/main/install.sh | sh
+```
-### As an OpenCode plugin
+**Windows** (PowerShell 5.1 or 7)
-```jsonc
-// ~/.config/opencode/opencode.json
-{
- "$schema": "https://opencode.ai/config.json",
- "plugin": ["opencode-github-sync"]
-}
+```powershell
+irm https://raw.githubusercontent.com/doomsday616/opencode-github-sync/main/install.ps1 | iex
```
-This `"plugin"` form works on both OpenCode 1.x (1.3.4 and later) and
-OpenCode 2.x: 1.x reads only `"plugin"`, and 2.x still accepts it alongside its
-native `"plugins"` key. If you run only OpenCode 2, you can list it under
-`"plugins"` instead. Do not list the plugin under both keys — OpenCode 2 loads
-entries from both lists.
+The installer checks for Node.js 18+ and git. If one is missing it shows the
+command that would install it and asks before running anything. Then it runs
+`npm install -g opencode-github-sync@latest` and opens the menu.
-OpenCode 1.0–1.3.3 cannot load 3.1.0 and later; stay on
-`opencode-github-sync@3.0.1` there.
+Already have Node.js? `npm install -g opencode-github-sync` and then
+`opencode-sync` does the same thing.
-OpenCode installs it on the next start. The plugin adds a pull on startup, an
-optional push when a session goes idle, and an `opencode_sync` tool so you can
-just ask for a sync in plain language.
+### Set up a machine in one line
-### As a CLI
+Pass options to skip the questions, for example when provisioning a new
+machine:
```bash
-npm install -g opencode-github-sync
+curl -fsSL https://raw.githubusercontent.com/doomsday616/opencode-github-sync/main/install.sh | sh -s -- --mode auto --repo you/my-opencode-config
```
-Keep the CLI even if you use the plugin. A plugin cannot fix the configuration
-that stops OpenCode from loading the plugin.
+```powershell
+& ([scriptblock]::Create((irm https://raw.githubusercontent.com/doomsday616/opencode-github-sync/main/install.ps1))) -Mode auto -Repo you/my-opencode-config
+```
-**Requirements** — git, and [`gh`](https://cli.github.com) for automatic setup.
-Node 18+ for config sync; Node 22.5+ or Bun for [session sync](#session-sync).
+| Option | Meaning |
+| --- | --- |
+| `--mode cli` / `-Mode cli` | Install the command only |
+| `--mode link` | Link `--repo` and sync once |
+| `--mode auto` | Link, sync once and turn on autosync |
+| `--repo owner/name` | The repository (a git URL works too); created as private if it does not exist |
+| `--interval N` | Autosync every N minutes (minimum 2, default 5) |
+| `--lang en` / `zh-CN` | Interface language |
+| `--proxy URL` | Proxy for npm and git, during install and for later syncs |
+| `--yes` | Install missing Node.js or git without asking |
+| `--package SPEC` | npm package spec to install (default `opencode-github-sync@latest`) |
+| `--dry-run` | Print what would run, change nothing |
+
+On Windows each option is the same word with a leading dash: `-Repo`,
+`-Interval`, `-Lang`, `-Proxy`, `-Yes`, `-Package`, `-DryRun`.
+
+When the repository already has content and this machine also has its own
+files, setup takes the repository's version and keeps the replaced files in a
+[recovery folder](#safety-features).
---
-## Quick start
+## Pick your setup
-### First machine
+
+
+
-```bash
-opencode-sync init # → /my-opencode-config
-opencode-sync init team-config # custom name
-opencode-sync init my-org/shared # inside an organisation
-opencode-sync push
-```
+The first time you run `opencode-sync` it asks how this machine should work.
-`init` creates a **private** repository, writes
-`~/.config/opencode/opencode-sync.jsonc`, and leaves an empty overrides file
-ready for you.
+| | Command only | Primary device + autosync |
+| --- | --- | --- |
+| How it syncs | You run `opencode-sync push` / `pull`, or the plugin pulls at startup | The operating system runs a sync every few minutes, even when OpenCode is closed |
+| Good for | A machine you use now and then, a shared or locked-down machine | The machines you work on every day |
+| Background job | None | Task Scheduler, launchd or a systemd timer |
-### Every other machine
+You can change your mind any time from the **Autosync** menu. Autosync is
+opt-in per machine: turning it on here never turns it on anywhere else.
-```bash
-opencode-sync link doomsday616/my-opencode-config
-opencode-sync pull
-```
+---
-Then restart OpenCode.
+## The menu
-### Day to day
+Run `opencode-sync` with no arguments.
-```bash
-opencode-sync push # share what changed here
-opencode-sync pull # take what changed elsewhere
-opencode-sync status # what is in sync, what is not
-```
+
+
+
+
+Arrow keys or the shown hotkey pick an item, space ticks a checkbox, Enter
+confirms and Esc goes back. When the input is not a terminal (a pipe, a script,
+CI) every prompt becomes a numbered question, so the menu can be driven from a
+file.
+
+| Item | What you can do there |
+| --- | --- |
+| **Sync now** | Push and receive, receive only, push only, or preview first |
+| **What to sync** | Tick main config, instruction files, agents, commands, plugins, skills, credentials, sessions; set session limits; add extra paths |
+| **Autosync** | Turn it on or off, choose 5/10/15/30/60 minutes or your own interval, pause it, run while signed out (Windows), notifications |
+| **Update** | Check npm for a newer version, upgrade, update automatically once a day |
+| **This machine's overrides** | Open the overrides file in your editor, see what it changes |
+| **Diagnostics** | git, node, gh, repository access, the scheduled job, the last log lines; export a support bundle |
+| **Danger zone** | Make this machine match the repository, or the other way round (you type `overwrite` to confirm) |
+| **Uninstall** | Remove the scheduled job, optionally unlink and remove the command. Your configuration is never deleted |
+| **Network proxy** | A proxy for git, gh and npm, used only by opencode-sync |
+| **Restore a file from history** | Pick a file, pick a version, put it back |
+| **Devices** | Every machine that has synced and when, and renaming this one |
+| **Large-change guard** | The limits that make a big push stop and ask |
+| **Language** | English, 简体中文 or automatic |
+
+Every command from earlier versions still works and is still scriptable.
---
## What gets synced
-
+
-| Source | In the repo | Contents |
-| -------------------------- | ----------- | -------------------------------------------- |
-| `~/.config/opencode/` | repo root | config, agents, commands, skills, themes, MCP |
-| `~/.local/share/opencode/` | `_data/` | project metadata, optional credentials |
-| `~/.local/state/opencode/` | `_state/` | frecency, model cache, prompt history |
-| `~/.agents/skills/` | `_agents/` | skills installed by the `skills` CLI |
-| selected sessions | `_sessions/`| one gzipped shard per session |
+Your `~/.config/opencode` folder becomes a working copy of the repository.
+Everything else is mirrored into a folder inside it.
-**Never synced:** `opencode.db` and its journals, tool output, snapshots, logs,
-and your local settings and overrides files.
+| On your machine | In the repository | What it holds |
+| --- | --- | --- |
+| `~/.config/opencode/` | the root | config, agents, commands, plugins, themes, instruction files |
+| `~/.local/share/opencode/` | `_data/` | project metadata, and credentials if you opt in |
+| `~/.local/state/opencode/` | `_state/` | model cache, recent files, prompt history |
+| `~/.agents/skills/` | `_agents/` | skills installed with the `skills` CLI |
+| selected sessions | `_sessions/` | one small compressed file per session |
+| your extra paths | `_extra/` | anything else you add |
-Add anything else with `extraPaths`:
+Each part can be switched off under **What to sync**. A part you switch off
+stays local in both directions: your edits are not sent, and other machines'
+versions do not replace yours.
-```jsonc
-{
- "extraPaths": [".tavily", ".config/gh/config.yml"]
-}
-```
+**Never synced:** the `opencode.db` database, tool output, snapshots, logs,
+`node_modules` and other generated folders, and opencode-sync's own settings
+and overrides files.
---
-## Per-machine overrides
+## How autosync works
-A synced repository makes every machine identical. That is exactly wrong for the
-settings that are genuinely machine-specific: a corporate proxy, a local
-toolchain path, an MCP server that only exists on one box.
+
+
+
+
+When autosync is on, the operating system starts `opencode-sync autosync run`
+every few minutes and at sign-in (on Windows also when you unlock or reconnect
+over Remote Desktop). Each run makes one decision:
+
+1. **Is it quiet?** If any file in your config or skills folders changed in the
+ last minute, something is still being written. Sending now could publish a
+ half-saved file, so the push waits.
+2. **Can it at least receive?** While busy, it still takes other machines'
+ changes, but only when this machine has nothing of its own waiting.
+ Otherwise it leaves everything for the next run.
+3. **Push and receive.** Your changes go up, other machines' changes come down,
+ in one step.
+
+The rules that keep this safe:
+
+- **Machines take turns.** Each machine waits a fixed few seconds based on its
+ name, so two machines on the same schedule do not collide every time. If
+ another machine still gets there first, this one picks up the new commit and
+ tries again; that does not count as an error.
+- **It backs off, but never gives up.** After three failures in a row
+ (typically no network) it waits 30 minutes, then an hour between attempts.
+ The first success puts it back on schedule.
+- **It only interrupts you twice.** One notification when it starts backing
+ off, one when it recovers. Nothing for ordinary syncs.
+- **It never runs twice at once.** A lock is shared by the scheduled job, the
+ CLI, the menu and every OpenCode window; if one is busy, the others skip.
+- **It never confirms a large change.** Anything that trips the
+ [large-change guard](#safety-features) is held back and you get a
+ notification.
+
+| Platform | Scheduler | Notes |
+| --- | --- | --- |
+| Windows | Task Scheduler | Runs hidden. To run while signed out, turn that on in the menu and install from an administrator terminal |
+| macOS | launchd agent | `~/Library/LaunchAgents/com.opencode-github-sync.autosync.plist` |
+| Linux | systemd user timer | Falls back to a crontab line. On a server, run `loginctl enable-linger` so it runs while you are logged out |
+
+`opencode-sync autosync status` shows the last run, the last error and when it
+will try next. With autosync on, the OpenCode plugin stops pushing on idle so
+the two never compete; it still pulls when OpenCode starts.
-Put those in `~/.config/opencode/opencode-sync.overrides.jsonc`:
+---
+
+## Per-machine overrides
+
+Some settings belong to one machine only: a work proxy, a local tool path, an
+MCP server that only exists on one box. Put them in
+`~/.config/opencode/opencode-sync.overrides.jsonc`, or use **This machine's
+overrides** in the menu:
```jsonc
{
@@ -181,175 +233,244 @@ Put those in `~/.config/opencode/opencode-sync.overrides.jsonc`:
```
```
- repo opencode.jsonc shared baseline, committed
+ repo opencode.jsonc shared, committed
+ overrides.jsonc this machine only, never committed
- ───────────────────────── deep merge
- = effective opencode.jsonc what OpenCode actually reads
+ ───────────────────────── merged
+ = effective opencode.jsonc what OpenCode reads
```
-Merge rules: objects merge key by key, arrays and scalars replace, `null`
-deletes a key.
-
-The half that other tools get wrong is **push**. Because the effective file is
-what sits on disk, a naive push uploads your overrides to everyone. Here, any
-key the overrides file claims is restored to the value the repository already
-had before committing — so an overridden key is invisible to sync in both
-directions, and your machine keeps its own settings the whole time.
-
-> With no overrides file the config is copied byte for byte and your comments
-> and formatting are untouched. The structural rewrite only happens once you
-> actually opt in.
+Objects merge key by key, lists and plain values replace, and `null` removes a
+key. Before every push, any key the overrides file claims is put back to the
+repository's value, so your machine-only values never reach other machines.
+Without an overrides file, the config is copied exactly as written, comments
+and all.
---
-## Session sync
+## Sessions
-Off by default. Turn it on only for a private repository — conversations are
-private data.
+Off by default. Only turn it on for a private repository: conversations are
+private.
```bash
opencode-sync sessions enable
opencode-sync sessions list
-opencode-sync sessions include ses_065cad1caffeN3lf1RgLZno30 # pin one forever
+opencode-sync sessions include ses_065cad1caffeN3lf1RgLZno30 # always sync this one
opencode-sync sessions exclude ses_0ba19b4e7ffeMbSmWYi3Nj6xfZ # never sync this one
```
-### Why not just commit the database
+Sessions are not synced by committing the database. A real OpenCode database
+grows to several gigabytes, mostly tool output, and git cannot merge it. Each
+session is exported on its own as a small compressed file instead:
+
+- **Small.** Only recent sessions (7 days and 50 sessions by default), and any
+ single session over 5 MB is skipped.
+- **No conflicts in practice.** Two machines working on different sessions
+ touch different files. The same session edited in two places keeps the newer
+ version.
+- **Safe to import.** Importing runs in one transaction, so a failure leaves
+ your database as it was.
+
+Window, count and size limits are under **What to sync → Session settings**.
+Session sync needs Node.js 22.5 or newer.
-A real OpenCode database reaches several gigabytes, almost all of it tool output
-inside `part.data`. Git cannot delta-compress it, GitHub's LFS free tier is one
-gigabyte, and copying a live database alongside its write-ahead log can capture
-a torn state that only shows up much later.
+---
-So sessions are exported **one at a time**. Each becomes an independent gzipped
-JSON shard holding its own rows plus the project and workspace rows it needs:
+## Safety features
-- **No large files.** Shards are small and compress well.
-- **Conflicts isolate themselves.** Two machines working on different sessions
- touch different files, so git merges them with no special handling. The only
- real conflict is one session edited in two places, and there the newer
- `time_updated` wins — an in-progress conversation is never clobbered by a
- stale copy.
-- **You choose what travels.** A time window, an explicit include list, a
- project filter and a per-session size cap. Ancient sessions stay where they
- are.
+
+
+
-Import is an upsert inside a transaction, so a failure leaves your database
-exactly as it was.
+- **Atomic replace.** Files are written beside their target and then swapped
+ in, so a crash mid-sync cannot leave a half-written config that stops
+ OpenCode from starting.
+- **Push verification.** After pushing, the remote is read back. `git push`
+ can report success without the remote changing; that is checked, not
+ assumed.
+- **One sync at a time.** A lock shared by the plugin, the CLI, the menu and
+ the scheduled job.
+- **Large-change guard.** A push that adds more than 300 files, more than
+ 10 MB in total, or any single file over 2 MB stops and asks. The limits are
+ adjustable. Generated folders such as `node_modules`, `.venv`, `venv`,
+ `__pycache__`, `dist` and `.cache` are never sent.
+- **Recovery folder.** Before a forced pull, a "repository wins" first link,
+ or a restore from history, the files about to be replaced are copied to
+ `~/.local/share/opencode/opencode-sync-recovery/-/`.
+- **Credentials are opt-in** and refused outright when the repository is
+ public.
+- **Your hostname stays private.** Commits name the machine by a stable
+ pseudonym (or a name you choose), never by its real hostname.
+- **Symlinks are refused, not followed**, so a sync cannot copy files from
+ outside the folders it owns.
+
+---
+
+## OpenCode plugin
+
+The plugin is optional. It pulls when OpenCode starts, can push when a session
+goes idle, and adds an `opencode_sync` tool so you can ask for a sync in plain
+language.
```jsonc
+// ~/.config/opencode/opencode.json
{
- "sessions": {
- "enabled": true,
- "days": 7, // only sessions touched this recently
- "maxSessions": 50, // hard cap per push, newest first
- "maxSessionBytes": 5242880, // skip any single runaway session
- "include": [], // always sync these, whatever the window says
- "exclude": [], // never sync these
- "directories": [] // only sessions under these project directories
- }
+ "$schema": "https://opencode.ai/config.json",
+ "plugin": ["opencode-github-sync"]
}
```
-> The internal `event` log is deliberately excluded. It is the largest table by
-> row count and nothing about resuming a conversation depends on it.
-
----
+This `"plugin"` form works on both OpenCode 1.x (1.3.4 and later) and
+OpenCode 2.x: 1.x reads only `"plugin"`, and 2.x still accepts it alongside its
+native `"plugins"` key. If you run only OpenCode 2, you can list it under
+`"plugins"` instead. Do not list the plugin under both keys — OpenCode 2 loads
+entries from both lists.
-## Command reference
+OpenCode 1.0–1.3.3 cannot load 3.1.0 and later; stay on
+`opencode-github-sync@3.0.1` there.
-| Command | What it does |
-| ---------------------------------- | ------------------------------------------------ |
-| `opencode-sync init [name]` | Create a private sync repository |
-| `opencode-sync link ` | Point this machine at an existing one |
-| `opencode-sync push` | Share this machine's configuration |
-| `opencode-sync pull` | Apply the shared configuration |
-| `opencode-sync status` | Show what is in sync and what is not |
-| `opencode-sync sessions list` | Recent sessions and their ids |
-| `opencode-sync sessions enable` | Turn selective session sync on |
-| `opencode-sync sessions include` | Pin a session, ignoring the time window |
-| `opencode-sync overrides` | Create/locate the per-machine patch |
-| `opencode-sync config` | Print the current settings |
-
-| Flag | Effect |
-| ----------- | ----------------------------------------------- |
-| `--force` | Overwrite the other side on conflict (confirmed) |
-| `--dry-run` | Report what would change, write nothing |
-
-| Environment variable | Effect |
-| ---------------------------- | ---------------------------------------- |
-| `OPENCODE_SYNC_HOST_ALIAS` | Name this machine in commit messages |
-| `OPENCODE_SYNC_VERBOSE=1` | Full file list and stack traces |
-| `NO_COLOR` | Disable colour |
-| `SYNC_REMOTE_URL` | Override the repository URL |
+Keep the command installed even if you use the plugin. A plugin cannot repair
+the configuration that stops OpenCode from loading it; `opencode-sync pull
+--force` can.
---
-## Settings
+## Command reference
-`~/.config/opencode/opencode-sync.jsonc` — never committed.
+| Command | What it does |
+| --- | --- |
+| `opencode-sync` | Open the menu (same as `opencode-sync menu`) |
+| `opencode-sync init [name]` | Create a private sync repository with `gh` |
+| `opencode-sync link ` | Use an existing repository |
+| `opencode-sync setup --mode cli\|link\|auto` | Non-interactive setup, used by the installers |
+| `opencode-sync push` | Send this machine's changes |
+| `opencode-sync pull` | Take the shared configuration |
+| `opencode-sync status` | What is in sync and what is not |
+| `opencode-sync autosync enable` / `disable` | Turn autosync on or off for this machine |
+| `opencode-sync autosync install` / `uninstall` | Register or remove the scheduled job only |
+| `opencode-sync autosync status` | Last run, last error, next attempt |
+| `opencode-sync autosync run` | One scheduled pass, now |
+| `opencode-sync autosync pause \|tomorrow` / `resume` | Pause or resume |
+| `opencode-sync autosync reset` | Clear the failure counter |
+| `opencode-sync sessions list\|enable\|disable` | Session sync |
+| `opencode-sync sessions include\|exclude ` | Always or never sync one session |
+| `opencode-sync overrides` | Create or locate the overrides file |
+| `opencode-sync config` | Print the settings |
+
+| Flag | Effect |
+| --- | --- |
+| `--force` | Overwrite the other side (asks you to type `yes`; `--yes` skips) |
+| `--dry-run` | Show what would change, change nothing |
+| `--allow-large` | Push even when the large-change guard objects |
+| `--lang en\|zh-CN` | Interface language for this run |
+
+| Environment variable | Effect |
+| --- | --- |
+| `OPENCODE_SYNC_LANG` | Interface language |
+| `OPENCODE_SYNC_HOST_ALIAS` | Machine name used in commits |
+| `OPENCODE_SYNC_PROXY` | Proxy for git, gh and npm |
+| `OPENCODE_SYNC_VERBOSE=1` | Full file lists and stack traces |
+| `SYNC_REMOTE_URL` | Override the repository URL |
+| `NO_COLOR` | Plain output |
+
+### Settings file
+
+`~/.config/opencode/opencode-sync.jsonc` stays on this machine and is never
+committed. The menu edits it for you; every key is optional.
```jsonc
{
"repo": { "owner": "you", "name": "my-opencode-config", "branch": "main" },
-
- "machineAlias": "laptop", // optional; otherwise a stable pseudonym
-
- "includeCredentials": false, // auth.json / account.json — private repos only
+ "machineAlias": "laptop",
+ "includeCredentials": false,
"includeSkills": true,
"includeState": true,
- "extraPaths": [],
-
- "sessions": { "enabled": false },
-
- "autoPullOnStartup": true, // plugin only
- "autoPushOnIdle": false // plugin only
+ "extraPaths": [".config/gh/config.yml"],
+ "categories": { "config": true, "instructions": true, "agents": true, "commands": true, "plugins": true },
+ "sessions": { "enabled": false, "days": 7, "maxSessions": 50, "maxSessionBytes": 5242880 },
+ "autosync": { "enabled": false, "intervalMinutes": 5, "settleSeconds": 60, "runWhenLoggedOff": false, "autoUpdate": false },
+ "guard": { "maxFiles": 300, "maxBytes": 10485760, "maxFileBytes": 2097152 },
+ "proxy": "",
+ "language": "",
+ "notifications": true,
+ "autoPullOnStartup": true,
+ "autoPushOnIdle": false
}
```
---
-## When something goes wrong
+## Troubleshooting
-**A pull conflicted with local edits.** The worktree is rolled back to a clean
-state and your changes stay in the stash. The exact recovery commands are
-printed. Nothing is lost.
+**I am behind a proxy.** Pass `--proxy http://host:port` (or `-Proxy` on
+Windows) to the installer. It is used for npm and git during the install and
+saved for later syncs. You can change it any time under **Network proxy**.
-```bash
-cd ~/.config/opencode
-git stash list
-git stash show -p stash@{0}
-```
+**`curl | sh` does not ask me anything.** The script itself arrives through the
+pipe, so it reads your answers from the terminal instead. In a session with no
+terminal (CI, a Dockerfile) there is nobody to ask: pass `--mode` and `--repo`,
+plus `--yes` if Node.js or git may need installing.
+
+**Windows says running scripts is disabled.** `irm ... | iex` does not run a
+script file, so the execution policy does not apply to it. If you downloaded
+`install.ps1` and want to run it as a file, use
+`powershell -ExecutionPolicy Bypass -File install.ps1`.
+
+**`opencode-sync` is not found after installing.** npm installed it into a
+folder that is not on your `PATH`. `npm prefix -g` shows the folder; add its
+`bin` (or the folder itself on Windows) to `PATH`.
-**"This machine has N commits that were never pushed."** Deliberate — pulling
-would destroy them. Run `opencode-sync push`, or `pull --force` to discard.
+**Autosync is on but nothing happens.** Run `opencode-sync autosync status`. It
+shows whether it is paused, backing off after errors, or waiting because files
+are still changing. **Diagnostics** in the menu shows whether the scheduled job
+is registered.
-**Authentication failed.** `gh auth login -h github.com`. The token needs the
-`repo` scope for private repositories.
+**A pull conflicted with my local edits.** Your edits are kept in a git stash
+and the exact commands to get them back are printed. Nothing is lost.
-**Session sync says no SQLite driver.** Node 22.5+ or Bun. Config sync is
-unaffected.
+**"This machine has N commits that were never pushed."** Pulling would throw
+them away, so it stops. Run `opencode-sync push`, or `pull --force` to discard
+them.
-**Everything is broken and OpenCode will not start.** This is why the CLI
-exists:
+**Authentication failed.** Run `gh auth login -h github.com`. Private
+repositories need the `repo` scope.
+
+**OpenCode will not start after a sync.** This is what the command is for:
```bash
opencode-sync pull --force
```
+The files it replaces go to the recovery folder first.
+
+---
+
+## Upgrading from 3.1.x
+
+```bash
+npm install -g opencode-github-sync@latest
+```
+
+Nothing else is required: your settings, overrides and repository keep
+working, and autosync stays off until you turn it on. If you were using a
+script of your own to sync on a timer, remove it before turning autosync on so
+the two do not compete. If you used `autoPushOnIdle` in the plugin, autosync
+replaces it on machines where you turn it on.
+
---
## Development
```bash
npm install
-npm run check # lint + typecheck + tests
+npm run check # lint, typecheck and tests
npm run build
```
-The test suite runs against a real bare git repository on disk and simulates two
-separate machines, so push, pull, force, rebase, stashing and the override
-round-trip are all exercised end to end.
+The tests run against real git repositories on disk and simulate several
+machines, so pushing, pulling, rebasing, autosync scheduling and the menu are
+exercised end to end. Real scheduled jobs are only ever registered on
+disposable CI runners.
---
@@ -367,7 +488,7 @@ round-trip are all exercised end to end.
## License
-[MIT](./LICENSE)
+[MIT](https://github.com/doomsday616/opencode-github-sync/blob/main/LICENSE)
If this saved you some time, a ⭐ is appreciated.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 7ae3f69..29fc4f5 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -1,168 +1,187 @@
-
+
-
-
+
+
- English
+ English
·
- 快速开始
+ 安装
·
- 单机覆盖
+ 使用方式
·
- 会话同步
+ 自动同步
·
命令
+ ·
+ 常见问题
---
-你在多台机器上用 OpenCode。配置、agent、命令、skill、MCP 全都留在最后碰过的那台上。
+你在好几台电脑上用 OpenCode,配置、智能体、自定义命令、技能和 MCP 服务器却总是停留在"上次改的那台"上。
+
+**opencode-github-sync** 借助一个 GitHub 私有仓库,让每台电脑用上同一套配置。可以手动同步,也可以让一台电脑在后台自己同步。
+
+## 安装
-**opencode-github-sync** 通过一个私有 GitHub 仓库把它们对齐——可以当 OpenCode
-插件用,可以当命令行工具用,也可以两个都用。
+**macOS / Linux**
```bash
-npm install -g opencode-github-sync
-opencode-sync init # 建好私有仓库并配置好这台机器
-opencode-sync push
+curl -fsSL https://raw.githubusercontent.com/doomsday616/opencode-github-sync/main/install.sh | sh
```
-
-
-
+**Windows**(PowerShell 5.1 或 7 都可以)
----
-
-## 为什么再造一个
+```powershell
+irm https://raw.githubusercontent.com/doomsday616/opencode-github-sync/main/install.ps1 | iex
+```
-大多数配置同步工具,一旦真正投入使用就不再安全了。问题集中在三个地方。
+安装脚本会先检查 Node.js 18+ 和 git。缺了哪个,就把安装命令列出来,问过你才执行。之后运行 `npm install -g opencode-github-sync@latest`,并打开菜单。
-| | 常见做法 | 这里的做法 |
-| ------------ | --------------------------------- | ----------------------------------------------------- |
-| **机器差异** | 强制所有机器完全一致 | [单机覆盖](#单机覆盖),同步永远盖不掉 |
-| **会话** | 直接提交 `opencode.db`,几个 GB,无法合并 | [按会话分片](#会话同步),几 MB,天然不冲突 |
-| **出问题时** | 纯插件:配置写坏了就没有退路 | CLI 在 OpenCode 之外运行,永远能救回来 |
+已经装了 Node.js?直接 `npm install -g opencode-github-sync`,再运行 `opencode-sync`,效果一样。
-还有一些听起来无聊、但真出事的时候很关键的事:
+### 一行命令配好一台电脑
-- **符号链接直接拒绝,不跟随。** 不会顺着链接把机器上别的文件抄进仓库,Windows 上的目录联接也不会让一次删除删错地方。
-- **所有替换都是原子的。** 同步中途崩了,也不会留下一个写了一半、让 OpenCode 起不来的配置文件。
-- **push 会回读远端校验。** `git push` 可能返回 0 但远端根本没动,这一点是验证过的,不是假设的。
-- **不会有两个同步同时跑。** 跨进程锁覆盖插件、CLI 和每一个 OpenCode 窗口。
-- **凭据默认不同步**,且在公开仓库上会被直接拒绝。
-- **你的主机名不会离开这台机器。** 提交署名用的是稳定的化名,公司资产编号不会落进一个未必永远私有的仓库。
+带上参数就能跳过所有提问,适合给新电脑批量部署:
----
+```bash
+curl -fsSL https://raw.githubusercontent.com/doomsday616/opencode-github-sync/main/install.sh | sh -s -- --mode auto --repo 你的用户名/my-opencode-config
+```
-## 安装
+```powershell
+& ([scriptblock]::Create((irm https://raw.githubusercontent.com/doomsday616/opencode-github-sync/main/install.ps1))) -Mode auto -Repo 你的用户名/my-opencode-config
+```
-### 作为 OpenCode 插件
+| 参数 | 作用 |
+| --- | --- |
+| `--mode cli` / `-Mode cli` | 只装命令 |
+| `--mode link` | 关联 `--repo` 并同步一次 |
+| `--mode auto` | 关联、同步一次,并开启自动同步 |
+| `--repo 用户名/仓库名` | 要用的仓库(也可以是 git 地址);不存在时会新建为私有仓库 |
+| `--interval N` | 每 N 分钟自动同步一次(最少 2,默认 5) |
+| `--lang en` / `zh-CN` | 界面语言 |
+| `--proxy 地址` | npm 和 git 用的代理,安装时和之后同步时都会用 |
+| `--yes` | 缺 Node.js 或 git 时不再询问,直接安装 |
+| `--package 包名` | 要安装的 npm 包(默认 `opencode-github-sync@latest`) |
+| `--dry-run` | 只打印要执行的步骤,什么都不改 |
-```jsonc
-// ~/.config/opencode/opencode.json
-{
- "$schema": "https://opencode.ai/config.json",
- "plugin": ["opencode-github-sync"]
-}
-```
+Windows 上参数名相同,前面换成一个短横线:`-Repo`、`-Interval`、`-Lang`、`-Proxy`、`-Yes`、`-Package`、`-DryRun`。
-这种 `"plugin"` 写法在 OpenCode 1.x(1.3.4 及以上)和 OpenCode 2.x 上都能用:
-1.x 只认 `"plugin"`,2.x 除了自己的 `"plugins"` 之外也仍然接受 `"plugin"`。
-如果你只用 OpenCode 2,也可以改写在 `"plugins"` 里。不要两个键里都写上这个插件——
-OpenCode 2 会把两个列表里的条目都加载。
+如果仓库里已经有内容、本机也有自己的文件,设置时以仓库为准,被替换的本机文件会存进[恢复目录](#安全机制)。
-OpenCode 1.0–1.3.3 无法加载 3.1.0 及以后的版本,请在这些版本上继续使用
-`opencode-github-sync@3.0.1`。
+---
-OpenCode 下次启动时会自动安装。插件提供启动时自动 pull、会话空闲时可选自动
-push,以及一个 `opencode_sync` 工具——直接用自然语言说一句"同步一下"就行。
+## 选择使用方式
-### 作为命令行工具
+
+
+
-```bash
-npm install -g opencode-github-sync
-```
+第一次运行 `opencode-sync` 时,它会问你这台电脑打算怎么用。
-**即使你用插件,也请保留 CLI。** 插件救不了那个"导致插件加载不了"的配置。
+| | 只装命令 | 主力设备 + 自动同步 |
+| --- | --- | --- |
+| 怎么同步 | 你自己运行 `opencode-sync push` / `pull`,或者插件在 OpenCode 启动时拉取 | 操作系统每隔几分钟同步一次,OpenCode 没开也照样同步 |
+| 适合 | 偶尔用一下的电脑、共用或受管控的电脑 | 每天干活用的电脑 |
+| 后台任务 | 无 | 任务计划程序、launchd 或 systemd 定时器 |
-**依赖** — git,以及用于自动初始化的 [`gh`](https://cli.github.com)。
-配置同步需要 Node 18+;[会话同步](#会话同步)需要 Node 22.5+ 或 Bun。
+之后随时可以在**自动同步**菜单里改。自动同步按电脑单独开启:在这台打开,不会影响其他任何一台。
---
-## 快速开始
+## 菜单一览
-### 第一台机器
+不带参数运行 `opencode-sync`:
-```bash
-opencode-sync init # → <你的用户名>/my-opencode-config
-opencode-sync init team-config # 自定义仓库名
-opencode-sync init my-org/shared # 建在组织下
-opencode-sync push
-```
+
+
+
+
+方向键或括号里的快捷键选择,空格勾选,回车确认,Esc 返回。如果输入不是终端(管道、脚本、CI),每个问题都会变成编号提问,所以也能用文件驱动整个菜单。
+
+| 菜单项 | 能做什么 |
+| --- | --- |
+| **立即同步** | 推送并接收、只接收、只推送,或者先预览 |
+| **同步哪些内容** | 勾选主配置、指令文件、智能体、自定义命令、插件、技能、登录凭据、会话;设置会话范围;添加额外路径 |
+| **自动同步** | 开关;选 5/10/15/30/60 分钟或自定义间隔;暂停;注销后也运行(Windows);桌面通知 |
+| **更新** | 检查 npm 上的新版本、立即升级、每天自动更新 |
+| **本机专属设置** | 用编辑器打开专属设置文件,查看它改了哪些值 |
+| **诊断** | git、node、gh、仓库访问、定时任务、最近的日志;导出排障包 |
+| **危险操作** | 让本机和仓库完全一致,或者反过来(要输入 `overwrite` 确认) |
+| **卸载** | 删除定时任务,可选解除关联、删除命令。你的配置文件永远不会被删 |
+| **网络代理** | git、gh、npm 用的代理,只对 opencode-sync 生效 |
+| **从历史版本恢复文件** | 选文件、选版本、恢复 |
+| **设备列表** | 哪些电脑同步过、上次是什么时候,还能给本机改名 |
+| **大改动保护** | 设置推送多大的改动时要先停下来问你 |
+| **界面语言** | English、简体中文或自动 |
+
+以前版本的所有命令都还在,照样可以写进脚本。
-`init` 会建一个**私有**仓库,写好 `~/.config/opencode/opencode-sync.jsonc`,
-并留一个空的覆盖文件给你。
+---
-### 其他机器
+## 同步哪些内容
-```bash
-opencode-sync link doomsday616/my-opencode-config
-opencode-sync pull
-```
+
+
+
-然后重启 OpenCode。
+`~/.config/opencode` 文件夹本身就是仓库的工作目录,其他位置的内容会镜像到仓库里的子文件夹。
-### 日常
+| 本机位置 | 仓库里的位置 | 内容 |
+| --- | --- | --- |
+| `~/.config/opencode/` | 根目录 | 配置、智能体、自定义命令、插件、主题、指令文件 |
+| `~/.local/share/opencode/` | `_data/` | 项目信息;开启后还有登录凭据 |
+| `~/.local/state/opencode/` | `_state/` | 模型缓存、最近文件、输入历史 |
+| `~/.agents/skills/` | `_agents/` | 用 `skills` 命令安装的技能 |
+| 选中的会话 | `_sessions/` | 每个会话一个压缩小文件 |
+| 额外路径 | `_extra/` | 你自己加的任何东西 |
-```bash
-opencode-sync push # 把这台的改动发出去
-opencode-sync pull # 把别处的改动拿回来
-opencode-sync status # 看什么同步了、什么没同步
-```
+每一项都能在**同步哪些内容**里关掉。关掉的项双向都只留在本机:你的改动不会推上去,别的电脑的版本也不会覆盖你的。
+
+**永远不同步:** `opencode.db` 数据库、工具输出、快照、日志、`node_modules` 之类的生成目录,以及 opencode-sync 自己的设置文件和专属设置文件。
---
-## 同步哪些东西
+## 自动同步是怎么工作的
-
+
-| 本地位置 | 仓库里 | 内容 |
-| -------------------------- | ----------- | ---------------------------------------- |
-| `~/.config/opencode/` | 仓库根目录 | 配置、agent、命令、skill、主题、MCP |
-| `~/.local/share/opencode/` | `_data/` | 项目元数据、可选的凭据 |
-| `~/.local/state/opencode/` | `_state/` | frecency、模型缓存、提示词历史 |
-| `~/.agents/skills/` | `_agents/` | 用 `skills` CLI 安装的技能 |
-| 选中的会话 | `_sessions/`| 每个会话一个 gzip 分片 |
+开启后,操作系统每隔几分钟、以及登录时(Windows 上还包括解锁和远程桌面重连时)运行一次 `opencode-sync autosync run`。每次只做一个判断:
-**永远不同步:** `opencode.db` 及其日志文件、工具输出、快照、日志,以及你的本地
-设置和覆盖文件。
+1. **现在安静吗?** 只要配置或技能文件夹里有文件在最近一分钟内改过,就说明还有东西在写。这时推送可能会把写了一半的文件发出去,所以先等。
+2. **至少能接收吗?** 忙的时候它仍然会接收别的电脑的改动,但前提是本机没有自己待推送的东西;否则留到下一次。
+3. **推送并接收。** 你的改动上去,别人的改动下来,一步完成。
-其他路径用 `extraPaths` 加:
+保证安全的几条规则:
-```jsonc
-{
- "extraPaths": [".tavily", ".config/gh/config.yml"]
-}
-```
+- **多台电脑错峰。** 每台电脑会根据自己的名字固定等几秒,几台电脑排在同一个时刻也不会每次都撞车。万一还是被别人抢先,这台会把新提交合进来再试一次,不算出错。
+- **会退避,但永不放弃。** 连续失败三次(通常是没网)后,改成 30 分钟、再到每小时试一次。一旦成功就恢复正常节奏。
+- **只打扰你两次。** 开始退避时弹一次通知,恢复时再弹一次。平时同步不弹。
+- **绝不同时跑两个。** 定时任务、命令行、菜单和每个 OpenCode 窗口共用一把锁,有一个在跑,其他的就跳过。
+- **绝不替你确认大改动。** 触发[大改动保护](#安全机制)的推送会被拦下,并通知你。
----
+| 平台 | 调度方式 | 说明 |
+| --- | --- | --- |
+| Windows | 任务计划程序 | 后台运行不弹窗。想在注销后也运行,先在菜单里打开这一项,再从管理员终端安装 |
+| macOS | launchd | `~/Library/LaunchAgents/com.opencode-github-sync.autosync.plist` |
+| Linux | systemd 用户定时器 | 不可用时退回 crontab。服务器上要运行 `loginctl enable-linger`,注销后才会继续跑 |
-## 单机覆盖
+`opencode-sync autosync status` 会显示上次运行、最后的错误和下次尝试的时间。开启自动同步后,OpenCode 插件不再在空闲时推送,免得两边抢;启动时照常拉取。
-同步会让所有机器变得一模一样。但有些设置本来就该因机器而异:公司代理、本地工具链
-路径、只有某一台才有的 MCP 服务。
+---
+
+## 本机专属设置
-把这些写进 `~/.config/opencode/opencode-sync.overrides.jsonc`:
+有些设置只属于一台电脑:公司代理、本地工具路径、只有这台才有的 MCP 服务器。把它们写进 `~/.config/opencode/opencode-sync.overrides.jsonc`,或者在菜单的**本机专属设置**里打开:
```jsonc
{
@@ -174,160 +193,185 @@ opencode-sync status # 看什么同步了、什么没同步
```
```
- 仓库里的 opencode.jsonc 所有机器共享的基线,进 Git
- + overrides.jsonc 只属于这台机器,永不进 Git
- ───────────────────────── 深合并
- = 实际的 opencode.jsonc OpenCode 真正读到的
+ 仓库里的 opencode.jsonc 共享,会提交
+ + overrides.jsonc 只属于本机,不提交
+ ─────────────────────────── 合并
+ = 实际生效的 opencode.jsonc OpenCode 读到的
```
-合并规则:对象逐键合并,数组和标量整体替换,`null` 删除该键。
-
-**别的工具做错的是 push 这一半。** 因为磁盘上那份就是合并后的结果,直接推就会把你
-的私有设置推给所有人。这里的做法是:凡是覆盖文件声明过的键,提交前一律还原成仓库
-里原本的值——所以被覆盖的键在两个方向上对同步都是隐形的,而你这台机器全程保持自己
-的设置。
-
-> 没有覆盖文件时,配置是逐字节复制的,注释和格式原样保留。只有你真的启用了覆盖,
-> 才会发生结构化重写。
+对象按键逐个合并,数组和普通值直接替换,`null` 表示删掉这个键。每次推送前,专属设置里出现过的键都会还原成仓库里的值,所以这台电脑专用的值永远不会跑到别的电脑上。没有专属设置文件时,配置原样复制,连注释都不动。
---
## 会话同步
-默认关闭。**只在私有仓库上开** —— 聊天记录是隐私数据。
+默认关闭。只在私有仓库里开启:对话内容是隐私。
```bash
opencode-sync sessions enable
opencode-sync sessions list
-opencode-sync sessions include ses_065cad1caffeN3lf1RgLZno30 # 永久固定这个会话
-opencode-sync sessions exclude ses_0ba19b4e7ffeMbSmWYi3Nj6xfZ # 这个永远不同步
+opencode-sync sessions include ses_065cad1caffeN3lf1RgLZno30 # 这个会话始终同步
+opencode-sync sessions exclude ses_0ba19b4e7ffeMbSmWYi3Nj6xfZ # 这个会话永不同步
```
-### 为什么不直接提交数据库
+会话不是靠提交整个数据库来同步的。真实的 OpenCode 数据库会涨到好几 GB,大部分是工具输出,git 也没法合并它。所以每个会话单独导出成一个压缩小文件:
+
+- **体积小。** 只同步最近的会话(默认 7 天、最多 50 个),单个超过 5 MB 的会话直接跳过。
+- **基本不冲突。** 两台电脑各自处理不同的会话,改的是不同的文件。同一个会话在两边都改了,保留较新的那份。
+- **导入安全。** 导入在一个事务里完成,失败了数据库保持原样。
-一个真实使用的 OpenCode 数据库会长到几个 GB,绝大部分是 `part.data` 里的工具输出。
-Git 无法对它做增量压缩,GitHub LFS 免费额度只有 1 GB,而且把一个正在被写入的数据库
-连同它的 WAL 一起拷走,可能拿到一份撕裂的数据——这种问题往往过很久才暴露。
+时间范围、数量和大小上限在**同步哪些内容 → 会话同步设置**里调。会话同步需要 Node.js 22.5 或更新版本。
-所以会话是**一个一个导出**的。每个会话变成一个独立的 gzip JSON 分片,里面装着它自己
-的数据行,外加它依赖的 project 和 workspace 行:
+---
-- **没有大文件。** 分片体积小,压缩率高。
-- **冲突自动隔离。** 两台机器改不同会话就是改不同文件,Git 不需要任何特殊处理就能
- 合并。唯一的真冲突是同一个会话在两边都改了,这时 `time_updated` 新的一方胜出——
- 正在进行的对话不会被一份过期的副本覆盖。
-- **你决定同步什么。** 时间窗口、显式固定列表、项目目录过滤、单会话体积上限。老会话
- 就留在原来那台机器上。
+## 安全机制
-导入是事务里的 upsert,失败了你的数据库原封不动。
+
+
+
+
+- **原子替换。** 文件先写在目标旁边,再一次性换过去。同步到一半崩溃,也不会留下写了一半、导致 OpenCode 启动不了的配置。
+- **推送后核实。** 推送完会回读远端确认。`git push` 有时报成功但远端根本没变,这一点会检查,不会想当然。
+- **同一时间只跑一个。** 插件、命令行、菜单和定时任务共用一把锁。
+- **大改动保护。** 一次推送新增超过 300 个文件、总量超过 10 MB,或有单个文件超过 2 MB,会先停下来问你。上限可调。`node_modules`、`.venv`、`venv`、`__pycache__`、`dist`、`.cache` 这类生成目录永远不会推送。
+- **恢复目录。** 强制拉取、首次关联时以仓库为准、从历史恢复文件之前,即将被替换的文件会先复制到 `~/.local/share/opencode/opencode-sync-recovery/<时间>-<原因>/`。
+- **登录凭据默认不同步**,仓库是公开的时候直接拒绝。
+- **不泄露主机名。** 提交里用固定的代号(或你起的名字)标记电脑,不用真实主机名。
+- **拒绝符号链接,不跟随**,同步不会顺着链接把别处的文件复制出去。
+
+---
+
+## OpenCode 插件
+
+插件可装可不装。它会在 OpenCode 启动时拉取,可以在会话空闲时推送,还提供一个 `opencode_sync` 工具,直接说一句"同步一下"就行。
```jsonc
+// ~/.config/opencode/opencode.json
{
- "sessions": {
- "enabled": true,
- "days": 7, // 只同步这么多天内动过的会话
- "maxSessions": 50, // 单次 push 上限,按时间从新到旧
- "maxSessionBytes": 5242880, // 跳过体积失控的单个会话
- "include": [], // 无视时间窗口,永远同步
- "exclude": [], // 永远不同步
- "directories": [] // 只同步这些项目目录下的会话
- }
+ "$schema": "https://opencode.ai/config.json",
+ "plugin": ["opencode-github-sync"]
}
```
-> 内部的 `event` 日志表被刻意排除了。它是行数最多的表,而恢复一段对话完全不依赖它。
+这种 `"plugin"` 写法在 OpenCode 1.x(1.3.4 及以上)和 OpenCode 2.x 上都能用:1.x 只认 `"plugin"`,2.x 除了自己的 `"plugins"` 之外也仍然接受 `"plugin"`。如果你只用 OpenCode 2,也可以改写在 `"plugins"` 里。不要两个键里都写上这个插件——OpenCode 2 会把两个列表里的条目都加载。
----
+OpenCode 1.0–1.3.3 无法加载 3.1.0 及以后的版本,请在这些版本上继续使用 `opencode-github-sync@3.0.1`。
-## 命令速查
-
-| 命令 | 作用 |
-| ---------------------------------- | --------------------------------- |
-| `opencode-sync init [名称]` | 创建私有同步仓库 |
-| `opencode-sync link ` | 让这台机器连上已有仓库 |
-| `opencode-sync push` | 把本机配置发出去 |
-| `opencode-sync pull` | 应用共享配置 |
-| `opencode-sync status` | 查看同步状态 |
-| `opencode-sync sessions list` | 列出最近会话和它们的 id |
-| `opencode-sync sessions enable` | 开启选择性会话同步 |
-| `opencode-sync sessions include` | 固定某个会话,无视时间窗口 |
-| `opencode-sync overrides` | 创建/定位单机覆盖文件 |
-| `opencode-sync config` | 打印当前设置 |
-
-| 参数 | 效果 |
-| ----------- | ------------------------------------- |
-| `--force` | 冲突时覆盖另一边(会二次确认) |
-| `--dry-run` | 只报告会改什么,不写入任何东西 |
-
-| 环境变量 | 效果 |
-| ---------------------------- | -------------------------- |
-| `OPENCODE_SYNC_HOST_ALIAS` | 指定提交署名用的机器名 |
-| `OPENCODE_SYNC_VERBOSE=1` | 输出完整文件列表和堆栈 |
-| `NO_COLOR` | 关闭彩色输出 |
-| `SYNC_REMOTE_URL` | 覆盖仓库地址 |
+就算用了插件,也请保留命令行。插件修不了"导致插件加载不了"的那份配置,`opencode-sync pull --force` 可以。
---
-## 设置
+## 命令速查
-`~/.config/opencode/opencode-sync.jsonc` —— 永远不会被提交。
+| 命令 | 作用 |
+| --- | --- |
+| `opencode-sync` | 打开菜单(同 `opencode-sync menu`) |
+| `opencode-sync init [名字]` | 用 `gh` 新建私有同步仓库 |
+| `opencode-sync link <用户名/仓库名 或 地址>` | 使用已有仓库 |
+| `opencode-sync setup --mode cli\|link\|auto` | 无交互设置,安装脚本用的就是它 |
+| `opencode-sync push` | 推送本机的改动 |
+| `opencode-sync pull` | 拉取共享配置 |
+| `opencode-sync status` | 查看哪些已同步、哪些没有 |
+| `opencode-sync autosync enable` / `disable` | 在本机开启或关闭自动同步 |
+| `opencode-sync autosync install` / `uninstall` | 只注册或删除定时任务 |
+| `opencode-sync autosync status` | 上次运行、最后的错误、下次尝试 |
+| `opencode-sync autosync run` | 马上执行一次定时同步 |
+| `opencode-sync autosync pause <小时数>\|tomorrow` / `resume` | 暂停或恢复 |
+| `opencode-sync autosync reset` | 清零失败次数 |
+| `opencode-sync sessions list\|enable\|disable` | 会话同步 |
+| `opencode-sync sessions include\|exclude ` | 某个会话始终同步或永不同步 |
+| `opencode-sync overrides` | 创建或找到专属设置文件 |
+| `opencode-sync config` | 打印设置 |
+
+| 参数 | 作用 |
+| --- | --- |
+| `--force` | 覆盖另一端(需要输入 `yes` 确认;加 `--yes` 跳过) |
+| `--dry-run` | 只显示会改什么,不实际改 |
+| `--allow-large` | 大改动保护反对时也照样推送 |
+| `--lang en\|zh-CN` | 本次运行的界面语言 |
+
+| 环境变量 | 作用 |
+| --- | --- |
+| `OPENCODE_SYNC_LANG` | 界面语言 |
+| `OPENCODE_SYNC_HOST_ALIAS` | 提交里用的电脑名字 |
+| `OPENCODE_SYNC_PROXY` | git、gh、npm 用的代理 |
+| `OPENCODE_SYNC_VERBOSE=1` | 显示完整文件列表和错误堆栈 |
+| `SYNC_REMOTE_URL` | 覆盖仓库地址 |
+| `NO_COLOR` | 不输出颜色 |
+
+### 设置文件
+
+`~/.config/opencode/opencode-sync.jsonc` 只留在本机,永远不会提交。菜单会帮你改,所有键都可以省略。
```jsonc
{
- "repo": { "owner": "你", "name": "my-opencode-config", "branch": "main" },
-
- "machineAlias": "laptop", // 可选;不填则自动生成稳定化名
-
- "includeCredentials": false, // auth.json / account.json,仅限私有仓库
+ "repo": { "owner": "you", "name": "my-opencode-config", "branch": "main" },
+ "machineAlias": "laptop",
+ "includeCredentials": false,
"includeSkills": true,
"includeState": true,
- "extraPaths": [],
-
- "sessions": { "enabled": false },
-
- "autoPullOnStartup": true, // 仅插件
- "autoPushOnIdle": false // 仅插件
+ "extraPaths": [".config/gh/config.yml"],
+ "categories": { "config": true, "instructions": true, "agents": true, "commands": true, "plugins": true },
+ "sessions": { "enabled": false, "days": 7, "maxSessions": 50, "maxSessionBytes": 5242880 },
+ "autosync": { "enabled": false, "intervalMinutes": 5, "settleSeconds": 60, "runWhenLoggedOff": false, "autoUpdate": false },
+ "guard": { "maxFiles": 300, "maxBytes": 10485760, "maxFileBytes": 2097152 },
+ "proxy": "",
+ "language": "",
+ "notifications": true,
+ "autoPullOnStartup": true,
+ "autoPushOnIdle": false
}
```
---
-## 出问题了怎么办
+## 常见问题
-**pull 和本地修改冲突了。** 工作区已经回滚到干净状态,你的改动还在 stash 里,恢复
-命令会直接打印出来。什么都没丢。
+**我得走代理。** 给安装脚本加 `--proxy http://主机:端口`(Windows 上是 `-Proxy`)。安装时 npm 和 git 都会用它,之后同步也会继续用。随时可以在**网络代理**里改。
-```bash
-cd ~/.config/opencode
-git stash list
-git stash show -p stash@{0}
-```
+**`curl | sh` 怎么不问我问题?** 脚本本身是从管道传进来的,所以它改从终端读你的回答。如果根本没有终端(CI、Dockerfile),就没人可问了:加上 `--mode` 和 `--repo`;可能需要装 Node.js 或 git 的话再加 `--yes`。
+
+**Windows 提示禁止运行脚本。** `irm ... | iex` 不是运行脚本文件,执行策略管不到它。如果你把 `install.ps1` 下载下来当文件运行,请用 `powershell -ExecutionPolicy Bypass -File install.ps1`。
-**提示"本机有 N 个未推送的提交"。** 这是故意拦住的——直接 pull 会毁掉它们。先
-`opencode-sync push`,或者用 `pull --force` 明确放弃。
+**装完找不到 `opencode-sync`。** npm 把它装到了不在 `PATH` 里的文件夹。`npm prefix -g` 能看到是哪个文件夹,把它下面的 `bin`(Windows 上就是这个文件夹本身)加进 `PATH`。
-**认证失败。** 跑 `gh auth login -h github.com`。私有仓库需要 `repo` 权限。
+**开了自动同步却没动静。** 运行 `opencode-sync autosync status`,看看是不是被暂停了、出错后在退避,还是因为文件还在变化而在等。菜单里的**诊断**可以看定时任务有没有注册上。
-**会话同步说没有 SQLite 驱动。** 需要 Node 22.5+ 或 Bun。配置同步不受影响。
+**拉取时和本地改动冲突了。** 你的改动保存在 git stash 里,恢复用的命令会直接打印出来。不会丢东西。
-**全乱了,OpenCode 起不来了。** CLI 存在的意义就在这里:
+**提示"这台电脑有 N 个提交从没推送过"。** 这时拉取会把它们丢掉,所以先停下。运行 `opencode-sync push`,或者用 `pull --force` 放弃它们。
+
+**认证失败。** 运行 `gh auth login -h github.com`。私有仓库需要 `repo` 权限。
+
+**同步之后 OpenCode 启动不了了。** 命令行就是为这种时候准备的:
```bash
opencode-sync pull --force
```
+被替换的文件会先存进恢复目录。
+
+---
+
+## 从 3.1.x 升级
+
+```bash
+npm install -g opencode-github-sync@latest
+```
+
+不需要别的操作:设置、专属设置和仓库都照常工作,自动同步在你打开之前一直是关的。如果你之前用自己的脚本定时同步,开启自动同步前先把它停掉,免得两边抢。如果你在插件里开了 `autoPushOnIdle`,在开启了自动同步的电脑上,由自动同步接替它。
+
---
## 开发
```bash
npm install
-npm run check # lint + 类型检查 + 测试
+npm run check # 代码检查、类型检查和测试
npm run build
```
-测试跑在磁盘上一个真实的裸 Git 仓库上,并模拟两台独立机器,所以 push、pull、force、
-rebase、stash 以及覆盖机制的往返全都是端到端验证过的。
+测试直接在磁盘上的真实 git 仓库里跑,并模拟多台电脑,推送、拉取、变基、自动同步调度和菜单都端到端覆盖。真正的定时任务只会在用完即弃的 CI 机器上注册。
---
@@ -345,8 +389,8 @@ rebase、stash 以及覆盖机制的往返全都是端到端验证过的。
## 许可证
-[MIT](./LICENSE)
+[MIT](https://github.com/doomsday616/opencode-github-sync/blob/main/LICENSE)
- 如果它帮你省了时间,点个 ⭐ 就很好。
+ 如果它帮你省了时间,欢迎点个 ⭐。
diff --git a/docs/architecture.svg b/docs/architecture.svg
deleted file mode 100644
index 6ec1ed7..0000000
--- a/docs/architecture.svg
+++ /dev/null
@@ -1,103 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- How it fits together
- Two entry points, one core, one repository.
-
-
-
-
- Entry points
- OpenCode plugin
- startup pull · idle push · opencode_sync tool
- opencode-sync CLI
-
-
-
-
-
- Core
- staging · atomic replace · advisory lock
- overrides · session shards
- verified push · auto-stash pull
-
-
-
-
-
- Private GitHub repo
- plain git — no server, no service
- inspectable, revertible history
-
-
-
-
-
-
-
- What travels
-
-
-
-
- ~/.config/opencode/
- →
- repo root
- config, agents, commands, skills, themes
-
- ~/.local/share/opencode/
- →
- _data/
- project metadata, optional credentials
-
- ~/.local/state/opencode/
- →
- _state/
- frecency, model cache, prompt history
-
- ~/.agents/skills/
- →
- _agents/
- skills installed by the skills CLI
-
-
-
- What stays put
-
-
-
- opencode-sync.overrides.jsonc
- this machine's own settings, merged after every pull
- never committed, never pushed, never overwritten
-
-
-
-
- opencode.db
- multi-gigabyte, unmergeable, machine-local
- selected sessions travel as shards in _sessions/
-
-
diff --git a/docs/banner.svg b/docs/banner.svg
deleted file mode 100644
index d3131e6..0000000
--- a/docs/banner.svg
+++ /dev/null
@@ -1,71 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- private repo
- one source of truth
-
-
- opencode-sync
- One config. Every machine. Nothing leaked.
-
-
-
-
- plugin + CLI
-
-
-
- per-machine overrides
-
-
-
- selective session sync
-
-
-
diff --git a/docs/demo.svg b/docs/demo.svg
deleted file mode 100644
index 1184c95..0000000
--- a/docs/demo.svg
+++ /dev/null
@@ -1,50 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- opencode-sync
-
-
- ❯ opencode-sync push
-
- opencode -sync · push to GitHub
-
- › Staging local files
- › Exporting sessions
- Exported 6 session(s)
-
- ✓ +3 added ~2 modified -1 deleted
-
- + skills/homelab/SKILL.md
- + _sessions/ses_065cad1c.json.gz
- ~ opencode.jsonc
- ~ _agents/tavily/SKILL.md
- - command/retired.md
-
- ✦ Pushed 6 change(s)
-
- ❯ ▊
-
-
diff --git a/docs/images/en/01-hero.png b/docs/images/en/01-hero.png
new file mode 100644
index 0000000..9d67f72
Binary files /dev/null and b/docs/images/en/01-hero.png differ
diff --git a/docs/images/en/02-how-it-works.png b/docs/images/en/02-how-it-works.png
new file mode 100644
index 0000000..827c437
Binary files /dev/null and b/docs/images/en/02-how-it-works.png differ
diff --git a/docs/images/en/03-pick-your-setup.png b/docs/images/en/03-pick-your-setup.png
new file mode 100644
index 0000000..b2ab56a
Binary files /dev/null and b/docs/images/en/03-pick-your-setup.png differ
diff --git a/docs/images/en/04-autosync.png b/docs/images/en/04-autosync.png
new file mode 100644
index 0000000..d04228c
Binary files /dev/null and b/docs/images/en/04-autosync.png differ
diff --git a/docs/images/en/05-safety-net.png b/docs/images/en/05-safety-net.png
new file mode 100644
index 0000000..c810902
Binary files /dev/null and b/docs/images/en/05-safety-net.png differ
diff --git a/docs/images/zh-CN/01-hero.png b/docs/images/zh-CN/01-hero.png
new file mode 100644
index 0000000..57d0837
Binary files /dev/null and b/docs/images/zh-CN/01-hero.png differ
diff --git a/docs/images/zh-CN/02-how-it-works.png b/docs/images/zh-CN/02-how-it-works.png
new file mode 100644
index 0000000..81437e6
Binary files /dev/null and b/docs/images/zh-CN/02-how-it-works.png differ
diff --git a/docs/images/zh-CN/03-pick-your-setup.png b/docs/images/zh-CN/03-pick-your-setup.png
new file mode 100644
index 0000000..d52d1c0
Binary files /dev/null and b/docs/images/zh-CN/03-pick-your-setup.png differ
diff --git a/docs/images/zh-CN/04-autosync.png b/docs/images/zh-CN/04-autosync.png
new file mode 100644
index 0000000..c52ccef
Binary files /dev/null and b/docs/images/zh-CN/04-autosync.png differ
diff --git a/docs/images/zh-CN/05-safety-net.png b/docs/images/zh-CN/05-safety-net.png
new file mode 100644
index 0000000..eac0014
Binary files /dev/null and b/docs/images/zh-CN/05-safety-net.png differ
diff --git a/docs/menu.svg b/docs/menu.svg
new file mode 100644
index 0000000..1e380b4
--- /dev/null
+++ b/docs/menu.svg
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ opencode-sync
+ opencode -sync
+
+ repository you/my-opencode-config
+ this machine work-laptop
+ last sync 2026-10-26 17:28 2 min ago
+ autosync on · every 5 min · next in about 3 min
+
+ [1] Sync now
+ [2] What to sync
+ [3] Autosync
+ [4] Update
+ [5] This machine's overrides
+ [6] Diagnostics
+ [7] Danger zone
+ [8] Uninstall
+ [p] Network proxy
+ [r] Restore a file from history
+ [d] Devices
+ [g] Large-change guard
+ [l] Language
+ [q] Quit
+
diff --git a/docs/menu.zh-CN.svg b/docs/menu.zh-CN.svg
new file mode 100644
index 0000000..19823d4
--- /dev/null
+++ b/docs/menu.zh-CN.svg
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ opencode-sync
+ opencode -sync
+
+ 仓库 you/my-opencode-config
+ 本机 work-laptop
+ 上次同步 2026-10-26 17:28 2 分钟前
+ 自动同步 已开启 · 每 5 分钟 · 下次 约 3 分钟后
+
+ [1] 立即同步
+ [2] 同步哪些内容
+ [3] 自动同步
+ [4] 更新
+ [5] 本机专属设置
+ [6] 诊断
+ [7] 危险操作
+ [8] 卸载
+ [p] 网络代理
+ [r] 从历史版本恢复文件
+ [d] 设备列表
+ [g] 大改动保护
+ [l] 界面语言
+ [q] 退出
+
diff --git a/install.ps1 b/install.ps1
new file mode 100644
index 0000000..c4753c5
--- /dev/null
+++ b/install.ps1
@@ -0,0 +1,200 @@
+<#
+.SYNOPSIS
+Install opencode-github-sync on Windows and open its menu.
+
+.DESCRIPTION
+ irm https://raw.githubusercontent.com/doomsday616/opencode-github-sync/main/install.ps1 | iex
+
+ & ([scriptblock]::Create((irm https://raw.githubusercontent.com/doomsday616/opencode-github-sync/main/install.ps1))) -Mode auto -Repo owner/name
+
+What it does, and nothing else:
+ 1. checks for Node.js 18+ and git, and offers to install a missing one with
+ winget (it asks first; it never installs anything silently)
+ 2. npm install -g opencode-github-sync@latest
+ 3. starts opencode-sync: the interactive menu, or a non-interactive setup
+ when -Mode is given
+
+Works in Windows PowerShell 5.1 and PowerShell 7. Running through `irm | iex`
+does not touch the execution policy, because no script file is executed.
+
+.PARAMETER Mode
+cli: install only. link: link -Repo. auto: link and make this the primary
+device with autosync.
+
+.PARAMETER Repo
+Repository to link, as owner/name or any git URL.
+
+.PARAMETER Interval
+Autosync interval in minutes (minimum 2).
+
+.PARAMETER Lang
+Interface language: en or zh-CN.
+
+.PARAMETER Proxy
+HTTP(S) proxy for npm and git during the install and for later syncs.
+
+.PARAMETER Yes
+Do not ask before installing missing prerequisites.
+
+.PARAMETER Package
+npm package spec to install. Default: opencode-github-sync@latest.
+
+.PARAMETER DryRun
+Print what would run and exit.
+#>
+[CmdletBinding()]
+param(
+ [ValidateSet('', 'cli', 'link', 'auto')]
+ [string]$Mode = '',
+ [string]$Repo = '',
+ [int]$Interval = 0,
+ [ValidateSet('', 'en', 'zh-CN', 'zh')]
+ [string]$Lang = '',
+ [string]$Proxy = '',
+ [switch]$Yes,
+ [string]$Package = 'opencode-github-sync@latest',
+ [switch]$DryRun
+)
+
+$ErrorActionPreference = 'Stop'
+
+function Write-Step([string]$Text) { Write-Host $Text }
+function Stop-Install([string]$Text) {
+ Write-Host "error: $Text" -ForegroundColor Red
+ # `exit` would close the window when this runs through `irm | iex`.
+ throw $Text
+}
+
+if (($Mode -eq 'link' -or $Mode -eq 'auto') -and -not $Repo) {
+ Stop-Install "-Mode $Mode needs -Repo"
+}
+if ($Interval -ne 0 -and $Interval -lt 2) {
+ Stop-Install '-Interval must be at least 2'
+}
+
+# The proxy applies to everything this script starts: npm, git and the CLI.
+if ($Proxy) {
+ foreach ($name in 'HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'npm_config_proxy', 'npm_config_https_proxy') {
+ Set-Item -Path "Env:$name" -Value $Proxy
+ }
+}
+
+# Arguments handed to `opencode-sync setup`.
+$cliArgs = @()
+if ($Mode) { $cliArgs += @('setup', '--mode', $Mode) }
+if ($Repo) { $cliArgs += @('--repo', $Repo) }
+if ($Interval -gt 0) { $cliArgs += @('--interval', [string]$Interval) }
+if ($Lang) { $cliArgs += @('--lang', $Lang) }
+if ($Proxy) { $cliArgs += @('--proxy', $Proxy) }
+
+function ConvertTo-ArgumentString([string[]]$Items) {
+ # Windows command-line quoting, so paths and URLs with spaces survive
+ # Start-Process in both PowerShell editions. The target is a .cmd shim, so
+ # cmd.exe metacharacters (a proxy or repo URL with `&`) are quoted too.
+ $quoted = foreach ($item in $Items) {
+ if ($item -notmatch '[\s"&|<>^%]' -and $item -ne '') { $item; continue }
+ $out = '"'
+ $slashes = 0
+ foreach ($ch in $item.ToCharArray()) {
+ if ($ch -eq '\') { $slashes++; continue }
+ if ($ch -eq '"') { $out += ('\' * ($slashes * 2 + 1)) + '"' }
+ else { $out += ('\' * $slashes) + $ch }
+ $slashes = 0
+ }
+ $out + ('\' * ($slashes * 2)) + '"'
+ }
+ return ($quoted -join ' ')
+}
+
+if ($DryRun) {
+ Write-Output "npm install -g $Package"
+ if ($cliArgs.Count -gt 0) { Write-Output ("opencode-sync " + (ConvertTo-ArgumentString $cliArgs)) }
+ else { Write-Output 'opencode-sync' }
+ if ($Proxy) { Write-Output "proxy $Proxy" }
+ return
+}
+
+# --- Prerequisites ---
+
+function Update-SessionPath {
+ # A fresh winget install updates the registry, not this session. New
+ # entries are prepended; session-only entries (nvm, fnm shims) are kept.
+ $current = @($env:Path -split ';' | Where-Object { $_ })
+ $registry = @(
+ [Environment]::GetEnvironmentVariable('Path', 'Machine'),
+ [Environment]::GetEnvironmentVariable('Path', 'User')
+ ) -join ';' -split ';' | Where-Object { $_ }
+ $missing = @($registry | Where-Object { $current -notcontains $_ })
+ $env:Path = (@($missing) + $current) -join ';'
+}
+
+function Test-Command([string]$Name) {
+ return [bool](Get-Command $Name -ErrorAction SilentlyContinue)
+}
+
+function Request-Install([string]$What, [string]$WingetId, [string]$Why, [string]$Manual) {
+ Write-Step $Why
+ if (-not (Test-Command 'winget')) {
+ Stop-Install "Install $What from $Manual and run this again."
+ }
+ $command = "winget install --id $WingetId -e --source winget"
+ Write-Step 'It can be installed with:'
+ Write-Step " $command"
+ $go = [bool]$Yes
+ if (-not $go) {
+ $answer = Read-Host 'Run that now? [y/N]'
+ $go = $answer -match '^(y|yes)$'
+ }
+ if (-not $go) {
+ Stop-Install 'Not installed. Run the command above, then this installer again.'
+ }
+ & winget install --id $WingetId -e --source winget --accept-package-agreements --accept-source-agreements
+ Update-SessionPath
+}
+
+function Get-NodeVersion {
+ if (-not (Test-Command 'node')) { return $null }
+ try { return [version]((& node -p 'process.versions.node') -replace '[^0-9.]', '') } catch { return $null }
+}
+
+$nodeVersion = Get-NodeVersion
+if (-not $nodeVersion -or -not (Test-Command 'npm.cmd')) {
+ Request-Install 'Node.js' 'OpenJS.NodeJS.LTS' 'Node.js (18 or newer) and npm are needed and were not found.' 'https://nodejs.org'
+ $nodeVersion = Get-NodeVersion
+}
+if (-not $nodeVersion -or $nodeVersion.Major -lt 18) {
+ Request-Install 'Node.js' 'OpenJS.NodeJS.LTS' "Node.js $nodeVersion is too old; 18 or newer is needed." 'https://nodejs.org'
+ $nodeVersion = Get-NodeVersion
+ if (-not $nodeVersion -or $nodeVersion.Major -lt 18) { Stop-Install 'Node.js 18 or newer is still not available on PATH.' }
+}
+if ($nodeVersion -lt [version]'22.5.0') {
+ Write-Step "Note: Node.js $nodeVersion syncs configuration fine; session sync needs 22.5 or newer."
+}
+if (-not (Test-Command 'git')) {
+ Request-Install 'git' 'Git.Git' 'git is needed and was not found.' 'https://git-scm.com'
+ if (-not (Test-Command 'git')) { Stop-Install 'git is still not available on PATH.' }
+}
+
+# --- Install ---
+
+Write-Step "Installing $Package ..."
+# npm.cmd, not npm: plain `npm` resolves to npm.ps1, which a Restricted
+# execution policy blocks even though this script itself runs fine via iex.
+& npm.cmd install -g $Package
+if ($LASTEXITCODE -ne 0) { Stop-Install 'npm install -g failed.' }
+
+$prefix = (& npm.cmd prefix -g).Trim()
+$bin = Join-Path $prefix 'opencode-sync.cmd'
+if (-not (Test-Path -LiteralPath $bin)) {
+ $found = Get-Command 'opencode-sync.cmd' -ErrorAction SilentlyContinue
+ if ($found) { $bin = $found.Source } else { Stop-Install "opencode-sync was installed but cannot be found. Add $prefix to PATH." }
+}
+
+# Started as its own process in this console, so the menu owns the keyboard
+# even when this script itself arrived through a pipe (`irm | iex`).
+$startArgs = @{ FilePath = $bin; NoNewWindow = $true; Wait = $true; PassThru = $true }
+if ($cliArgs.Count -gt 0) { $startArgs.ArgumentList = ConvertTo-ArgumentString $cliArgs }
+$process = Start-Process @startArgs
+if ($process.ExitCode -ne 0) {
+ Stop-Install "opencode-sync exited with code $($process.ExitCode)."
+}
diff --git a/install.sh b/install.sh
new file mode 100644
index 0000000..71d7c51
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,227 @@
+#!/bin/sh
+# opencode-github-sync installer for macOS and Linux.
+#
+# curl -fsSL https://raw.githubusercontent.com/doomsday616/opencode-github-sync/main/install.sh | sh
+# curl -fsSL https://raw.githubusercontent.com/doomsday616/opencode-github-sync/main/install.sh | sh -s -- --mode auto --repo owner/name
+#
+# What it does, and nothing else:
+# 1. checks for Node.js 18+ and git, and offers to install a missing one
+# (it asks first; it never installs anything silently)
+# 2. npm install -g opencode-github-sync@latest
+# 3. starts opencode-sync: the interactive menu, or a non-interactive setup
+# when --mode is given
+#
+# Options (all optional):
+# --mode cli|link|auto cli: install only; link: link --repo; auto: link and
+# make this the primary device with autosync
+# --repo owner/name repository to link (or any git URL)
+# --interval N autosync interval in minutes (minimum 2)
+# --lang en|zh-CN interface language
+# --proxy URL HTTP(S) proxy for npm and git during install and sync
+# --yes do not ask before installing missing prerequisites
+# --package SPEC npm package spec to install (default: opencode-github-sync@latest)
+# --dry-run print what would run and exit
+#
+# Under `curl | sh` the script itself arrives on standard input, so every
+# question is read from /dev/tty instead.
+
+set -eu
+
+MODE=""
+REPO=""
+INTERVAL=""
+LANG_OPT=""
+PROXY=""
+YES=0
+DRY_RUN=0
+PACKAGE="opencode-github-sync@latest"
+
+say() { printf '%s\n' "$*"; }
+err() { printf 'error: %s\n' "$*" >&2; }
+
+need_value() {
+ if [ $# -lt 2 ] || [ -z "$2" ]; then
+ err "$1 needs a value"
+ exit 2
+ fi
+}
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --mode) need_value "$@"; MODE="$2"; shift 2 ;;
+ --mode=*) MODE="${1#*=}"; shift ;;
+ --repo) need_value "$@"; REPO="$2"; shift 2 ;;
+ --repo=*) REPO="${1#*=}"; shift ;;
+ --interval) need_value "$@"; INTERVAL="$2"; shift 2 ;;
+ --interval=*) INTERVAL="${1#*=}"; shift ;;
+ --lang) need_value "$@"; LANG_OPT="$2"; shift 2 ;;
+ --lang=*) LANG_OPT="${1#*=}"; shift ;;
+ --proxy) need_value "$@"; PROXY="$2"; shift 2 ;;
+ --proxy=*) PROXY="${1#*=}"; shift ;;
+ --package) need_value "$@"; PACKAGE="$2"; shift 2 ;;
+ --package=*) PACKAGE="${1#*=}"; shift ;;
+ --yes|-y) YES=1; shift ;;
+ --dry-run) DRY_RUN=1; shift ;;
+ -h|--help)
+ sed -n '2,30p' "$0" 2>/dev/null || say "See https://github.com/doomsday616/opencode-github-sync"
+ exit 0 ;;
+ *) err "unknown option: $1"; exit 2 ;;
+ esac
+done
+
+case "$MODE" in
+ ""|cli|link|auto) ;;
+ *) err "--mode must be cli, link or auto"; exit 2 ;;
+esac
+if [ "$MODE" = "link" ] || [ "$MODE" = "auto" ]; then
+ if [ -z "$REPO" ]; then err "--mode $MODE needs --repo"; exit 2; fi
+fi
+if [ -n "$INTERVAL" ]; then
+ case "$INTERVAL" in
+ *[!0-9]*) err "--interval must be a whole number of minutes"; exit 2 ;;
+ esac
+ if [ "$INTERVAL" -lt 2 ]; then err "--interval must be at least 2"; exit 2; fi
+fi
+case "$LANG_OPT" in
+ ""|en|zh-CN|zh) ;;
+ *) err "--lang must be en or zh-CN"; exit 2 ;;
+esac
+
+# The proxy applies to everything this script starts: npm, git and the CLI.
+if [ -n "$PROXY" ]; then
+ export HTTP_PROXY="$PROXY" HTTPS_PROXY="$PROXY" http_proxy="$PROXY" https_proxy="$PROXY"
+ export npm_config_proxy="$PROXY" npm_config_https_proxy="$PROXY"
+fi
+
+# Arguments handed to `opencode-sync setup`.
+set --
+if [ -n "$MODE" ]; then set -- "$@" setup --mode "$MODE"; fi
+if [ -n "$REPO" ]; then set -- "$@" --repo "$REPO"; fi
+if [ -n "$INTERVAL" ]; then set -- "$@" --interval "$INTERVAL"; fi
+if [ -n "$LANG_OPT" ]; then set -- "$@" --lang "$LANG_OPT"; fi
+if [ -n "$PROXY" ]; then set -- "$@" --proxy "$PROXY"; fi
+
+if [ "$DRY_RUN" -eq 1 ]; then
+ say "npm install -g $PACKAGE"
+ if [ $# -gt 0 ]; then say "opencode-sync $*"; else say "opencode-sync"; fi
+ if [ -n "$PROXY" ]; then say "proxy $PROXY"; fi
+ exit 0
+fi
+
+# --- Questions go to the terminal, not to the pipe ---
+
+HAVE_TTY=0
+# Probe in a subshell: in a strict POSIX shell (dash) a failed redirection on
+# a special builtin such as `:` exits the whole script, silently, with status 2.
+if (exec /dev/null; then HAVE_TTY=1; fi
+
+ask_yes_no() {
+ # $1 = question. Default is No.
+ if [ "$HAVE_TTY" -ne 1 ]; then return 1; fi
+ printf '%s [y/N] ' "$1" >/dev/tty
+ answer=""
+ read -r answer /dev/null 2>&1; then
+ if [ "$what" = node ]; then echo "brew install node"; else echo "brew install git"; fi
+ elif command -v apt-get >/dev/null 2>&1; then
+ if [ "$what" = node ]; then echo "sudo apt-get install -y nodejs npm"; else echo "sudo apt-get install -y git"; fi
+ elif command -v dnf >/dev/null 2>&1; then
+ if [ "$what" = node ]; then echo "sudo dnf install -y nodejs npm"; else echo "sudo dnf install -y git"; fi
+ elif command -v pacman >/dev/null 2>&1; then
+ if [ "$what" = node ]; then echo "sudo pacman -S --noconfirm nodejs npm"; else echo "sudo pacman -S --noconfirm git"; fi
+ elif command -v apk >/dev/null 2>&1; then
+ if [ "$what" = node ]; then echo "sudo apk add nodejs npm"; else echo "sudo apk add git"; fi
+ elif command -v zypper >/dev/null 2>&1; then
+ if [ "$what" = node ]; then echo "sudo zypper install -y nodejs npm"; else echo "sudo zypper install -y git"; fi
+ fi
+}
+
+offer_install() {
+ # $1 = node | git, $2 = why it is needed
+ what="$1"
+ say "$2"
+ cmd=$(package_manager_command "$what")
+ if [ -z "$cmd" ]; then
+ if [ "$what" = node ]; then say "Install Node.js 18 or newer from https://nodejs.org and run this again."
+ else say "Install git from https://git-scm.com and run this again."; fi
+ exit 1
+ fi
+ say "It can be installed with:"
+ say " $cmd"
+ if [ "$YES" -eq 1 ] || ask_yes_no "Run that now?"; then
+ case "$cmd" in
+ # A fresh container has no package index yet; install would fail.
+ *apt-get*) cmd="sudo apt-get update && $cmd" ;;
+ esac
+ if [ "$HAVE_TTY" -eq 1 ]; then sh -c "$cmd" /dev/null || echo 0
+}
+
+node_ok() {
+ command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1 && [ "$(node_major)" -ge 18 ]
+}
+
+if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then
+ offer_install node "Node.js (18 or newer) and npm are needed and were not found."
+elif [ "$(node_major)" -lt 18 ]; then
+ offer_install node "Node.js $(node -v) is too old; 18 or newer is needed."
+fi
+# A distribution package can still be older than 18, so check the result.
+if ! node_ok; then
+ err "Node.js 18 or newer with npm is still not available."
+ say "Install it from https://nodejs.org and run this again."
+ exit 1
+fi
+if ! node -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit(a>22||(a===22&&b>=5)?0:1)' 2>/dev/null; then
+ say "Note: Node.js $(node -v) syncs configuration fine; session sync needs 22.5 or newer."
+fi
+if ! command -v git >/dev/null 2>&1; then
+ offer_install git "git is needed and was not found."
+ if ! command -v git >/dev/null 2>&1; then
+ err "git is still not available."
+ exit 1
+ fi
+fi
+
+# --- Install ---
+
+say "Installing $PACKAGE ..."
+if ! npm install -g "$PACKAGE"; then
+ err "npm install -g failed."
+ say "If this is a permissions error, point npm at a folder you own:"
+ say " npm config set prefix \"\$HOME/.local\" && export PATH=\"\$HOME/.local/bin:\$PATH\""
+ exit 1
+fi
+
+BIN="$(npm prefix -g 2>/dev/null)/bin/opencode-sync"
+if [ ! -x "$BIN" ]; then BIN="$(command -v opencode-sync 2>/dev/null || true)"; fi
+if [ -z "$BIN" ] || [ ! -x "$BIN" ]; then
+ err "opencode-sync was installed but cannot be found on PATH."
+ say "Add \"$(npm prefix -g)/bin\" to PATH, then run: opencode-sync"
+ exit 1
+fi
+
+if [ $# -gt 0 ]; then
+ if [ "$HAVE_TTY" -eq 1 ]; then exec "$BIN" "$@" pathToFileURL(path.join(root, "dist", p)).href;
+
+process.env.NO_COLOR = "1";
+process.env.OPENCODE_SYNC_NO_GH = "1";
+process.env.OPENCODE_SYNC_HOST_ALIAS = "work-laptop";
+
+const { runMenu } = await import(dist("cli/menu.js"));
+const { setOutput } = await import(dist("cli/render.js"));
+const { setLang } = await import(dist("cli/i18n.js"));
+const { Ui } = await import(dist("cli/ui.js"));
+const { defaultSettings, saveSettings } = await import(dist("core/settings.js"));
+const { writeState } = await import(dist("core/autosync.js"));
+
+const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "ogs-svg-"));
+const remote = path.join(sandbox, "remote.git");
+execFileSync("git", ["init", "-q", "--bare", "-b", "main", remote]);
+const roots = Object.fromEntries(
+ ["config", "data", "state", "agents", "cache"].map((k) => [k, path.join(sandbox, k)]),
+);
+for (const dir of Object.values(roots)) fs.mkdirSync(dir, { recursive: true });
+
+const settings = defaultSettings();
+// The URL is what syncs; owner/name is what the header shows.
+settings.repo = { url: remote, owner: "you", name: "my-opencode-config", branch: "main" };
+settings.autosync.enabled = true;
+settings.autosync.intervalMinutes = 5;
+saveSettings(settings, roots.config);
+const now = Date.UTC(2026, 9, 26, 9, 30, 0);
+writeState(roots, { lastRunAt: now - 2 * 60_000, lastSuccessAt: now - 2 * 60_000 });
+
+// One real sync, dated two minutes before `now`, so "last sync" is genuine.
+fs.writeFileSync(path.join(roots.config, "opencode.jsonc"), "{}\n");
+const stamp = new Date(now - 2 * 60_000).toISOString();
+process.env.GIT_AUTHOR_DATE = stamp;
+process.env.GIT_COMMITTER_DATE = stamp;
+const { push } = await import(dist("core/sync.js"));
+await push({ roots });
+delete process.env.GIT_AUTHOR_DATE;
+delete process.env.GIT_COMMITTER_DATE;
+
+const scheduler = {
+ install: () => ({ ok: true, kind: "schtasks", detail: [], warnings: [] }),
+ uninstall: () => ({ removed: true }),
+ status: () => ({ installed: true, kind: "schtasks" }),
+ elevated: () => false,
+};
+
+async function capture(lang) {
+ setLang(lang);
+ let text = "";
+ setOutput({
+ write: (s) => {
+ text += s;
+ return true;
+ },
+ });
+ const ui = new Ui({
+ input: Readable.from(["q\n"]),
+ output: new Writable({
+ write(chunk, _e, cb) {
+ text += chunk.toString();
+ cb();
+ },
+ }),
+ interactive: false,
+ });
+ await runMenu({ ui, roots, scheduler, now: () => now, discover: () => [], cwd: sandbox });
+ return text
+ .split("\n")
+ .map((l) => l.replace(/\s+$/, ""))
+ .filter((l, i, all) => !(l === "" && all[i - 1] === ""))
+ .join("\n")
+ .trim()
+ .split("\n")
+ .filter((l) => !/›\s*q$/.test(l));
+}
+
+const esc = (s) => s.replace(/&/g, "&").replace(//g, ">");
+
+function svg(lines, lang) {
+ const lineHeight = 22;
+ const top = 76;
+ const height = top + lines.length * lineHeight + 40;
+ const width = 900;
+ const body = lines
+ .map((line, i) => {
+ const y = top + i * lineHeight;
+ let cls = "text";
+ if (/^opencode-sync/.test(line.trim())) {
+ return ` opencode -sync `;
+ }
+ // Columns are placed at fixed x positions rather than padded with
+ // spaces: CJK glyphs are not exactly two monospace cells wide in every
+ // font, so space padding drifts.
+ const tag = /^(\s*)\[(\w)\](\s+)(.*)$/.exec(line);
+ if (tag) {
+ const [, , key, , label] = tag;
+ return ` [${key}] ${esc(label)} `;
+ }
+ const kv = /^(\s*)(\S.*?)(\s{2,})(.*)$/.exec(line);
+ if (kv) {
+ const [, , key, , value] = kv;
+ const valueClass = /autosync|自动同步/.test(key) ? "green" : "text";
+ return ` ${esc(key)} ${esc(value)} `;
+ }
+ if (/›/.test(line)) cls = "brand";
+ return ` ${esc(line.trim())} `;
+ })
+ .join("\n");
+ const font =
+ lang === "zh-CN"
+ ? `ui-monospace, SFMono-Regular, Menlo, Consolas, "Sarasa Mono SC", "Noto Sans Mono CJK SC", "Microsoft YaHei", monospace`
+ : `ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace`;
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ opencode-sync
+${body}
+
+`;
+}
+
+try {
+ for (const [lang, file] of [
+ ["en", "menu.svg"],
+ ["zh-CN", "menu.zh-CN.svg"],
+ ]) {
+ const lines = await capture(lang);
+ fs.writeFileSync(path.join(root, "docs", file), svg(lines, lang));
+ console.log(`docs/${file}: ${lines.length} lines`);
+ }
+} finally {
+ setOutput(process.stdout);
+ fs.rmSync(sandbox, { recursive: true, force: true });
+}
diff --git a/src/cli/app.ts b/src/cli/app.ts
new file mode 100644
index 0000000..a110793
--- /dev/null
+++ b/src/cli/app.ts
@@ -0,0 +1,232 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+ type AutosyncDeps,
+ type AutosyncMessages,
+ defaultDeps,
+ readState,
+} from "../core/autosync.js";
+import { setChildProxy } from "../core/env.js";
+import { git } from "../core/git.js";
+import { notify } from "../core/notify.js";
+import type { Roots } from "../core/paths.js";
+import {
+ type InstallContext,
+ type InstallResult,
+ type Platform,
+ type ScheduleSpec,
+ type SchedulerStatus,
+ installScheduler,
+ isElevated,
+ realRunner,
+ resolveNode,
+ schedulerStatus,
+ uninstallScheduler,
+} from "../core/scheduler.js";
+import { type Settings, loadSettings } from "../core/settings.js";
+import { checkForUpdate, installLatest, uninstallGlobal } from "../core/update.js";
+import { type Lang, detectLang, setLang, t } from "./i18n.js";
+
+/** Directory of the compiled CLI module (`dist/cli`). */
+export const CLI_DIR = path.dirname(fileURLToPath(import.meta.url));
+
+export function homeDir(): string {
+ return process.env.SYNC_HOME || os.homedir();
+}
+
+/** Apply the language and proxy from settings (and flags) to this process. */
+export function applyLocalSettings(settings: Settings | undefined, langFlag?: string): Lang {
+ const lang = detectLang(langFlag, settings?.language);
+ setLang(lang);
+ setChildProxy(process.env.OPENCODE_SYNC_PROXY || settings?.proxy || undefined);
+ return lang;
+}
+
+export function safeLoadSettings(roots: Roots): Settings | undefined {
+ try {
+ return loadSettings(roots.config);
+ } catch {
+ return undefined;
+ }
+}
+
+// ── Scheduler wiring ────────────────────────────────────────────────────────
+
+/** Environment variables carried into the scheduled job when set at install time. */
+const PASSTHROUGH_ENV = [
+ "SYNC_CONFIG_ROOT",
+ "SYNC_DATA_ROOT",
+ "SYNC_STATE_ROOT",
+ "SYNC_AGENTS_ROOT",
+ "SYNC_CACHE_ROOT",
+ "SYNC_HOME",
+ "SYNC_REMOTE_URL",
+ "XDG_CONFIG_HOME",
+ "XDG_DATA_HOME",
+ "XDG_STATE_HOME",
+ "XDG_CACHE_HOME",
+ "OPENCODE_SYNC_LANG",
+ "OPENCODE_SYNC_HOST_ALIAS",
+ "OPENCODE_SYNC_PROXY",
+];
+
+function gitDirectory(): string | undefined {
+ const probe =
+ process.platform === "win32"
+ ? realRunner.run("where.exe", ["git"])
+ : realRunner.run("/bin/sh", ["-c", "command -v git"]);
+ const first = probe.stdout.split(/\r?\n/).find(Boolean)?.trim();
+ return first ? path.dirname(first) : undefined;
+}
+
+export function scheduleSpec(roots: Roots, settings: Settings): ScheduleSpec {
+ const node = resolveNode();
+ const env: Record = {};
+ for (const key of PASSTHROUGH_ENV) {
+ const value = process.env[key];
+ if (value) env[key] = value;
+ }
+ if (process.platform !== "win32") {
+ // launchd, systemd and cron start with a bare PATH; git and node must be
+ // reachable from it.
+ const dirs = [
+ path.dirname(node),
+ gitDirectory(),
+ "/opt/homebrew/bin",
+ "/usr/local/bin",
+ "/usr/bin",
+ "/bin",
+ ];
+ env.PATH = [...new Set(dirs.filter(Boolean) as string[])].join(":");
+ }
+ return {
+ node,
+ cli: path.join(CLI_DIR, "index.js"),
+ intervalMinutes: settings.autosync.intervalMinutes,
+ env,
+ logDir: path.join(roots.config, "logs"),
+ };
+}
+
+export interface SchedulerApi {
+ install: (roots: Roots, settings: Settings) => InstallResult;
+ uninstall: (roots: Roots) => { removed: boolean };
+ status: () => SchedulerStatus;
+ elevated: () => boolean;
+}
+
+function installContext(roots: Roots, settings: Settings): InstallContext {
+ return {
+ platform: process.platform as Platform,
+ spec: scheduleSpec(roots, settings),
+ home: os.homedir(),
+ runner: realRunner,
+ // The data root is never mirrored as a whole, so the launcher stays local.
+ launcherDir: roots.data,
+ runWhenLoggedOff: settings.autosync.runWhenLoggedOff,
+ elevated: isElevated(),
+ };
+}
+
+export const realScheduler: SchedulerApi = {
+ install: (roots, settings) => installScheduler(installContext(roots, settings)),
+ // Needs no settings, so a corrupt settings file cannot block removal.
+ uninstall: (roots) =>
+ uninstallScheduler({
+ platform: process.platform as Platform,
+ runner: realRunner,
+ home: os.homedir(),
+ launcherDir: roots.data,
+ }),
+ status: () =>
+ schedulerStatus({
+ platform: process.platform as Platform,
+ runner: realRunner,
+ home: os.homedir(),
+ }),
+ elevated: () => isElevated(),
+};
+
+// ── Update wiring ───────────────────────────────────────────────────────────
+
+export interface UpdateApi {
+ check: () => ReturnType;
+ install: () => void;
+ uninstall: () => void;
+}
+
+export const realUpdater: UpdateApi = {
+ check: () => checkForUpdate(path.resolve(CLI_DIR, "..", "core")),
+ install: () => installLatest(),
+ uninstall: () => uninstallGlobal(),
+};
+
+// ── Autosync wiring ─────────────────────────────────────────────────────────
+
+export function localizedMessages(): AutosyncMessages {
+ return {
+ backoffTitle: t("ntBackoffTitle"),
+ backoffBody: (failures, minutes) => t("ntBackoffBody", { n: failures, m: minutes }),
+ recoveredTitle: t("ntRecoveredTitle"),
+ recoveredBody: t("ntRecoveredBody"),
+ largeTitle: t("ntLargeTitle"),
+ largeBody: t("ntLargeBody"),
+ };
+}
+
+export function autosyncDeps(roots: Roots, updater: UpdateApi = realUpdater): AutosyncDeps {
+ return defaultDeps(roots, {
+ home: homeDir(),
+ notify: (title, body) => notify(title, body),
+ messages: localizedMessages(),
+ selfUpdate: () => {
+ const check = updater.check();
+ if (check.error) return `check failed: ${check.error}`;
+ if (!check.newer) return `up to date (${check.current})`;
+ updater.install();
+ return `upgraded ${check.current} -> ${check.latest}`;
+ },
+ });
+}
+
+// ── Display helpers ─────────────────────────────────────────────────────────
+
+/** ISO date of the newest commit in the sync repository, if any. */
+export function lastCommitTime(roots: Roots): number | undefined {
+ if (!fs.existsSync(path.join(roots.config, ".git"))) return undefined;
+ const log = git(["log", "-1", "--format=%cI"], { cwd: roots.config });
+ const time = log.ok ? Date.parse(log.stdout) : Number.NaN;
+ return Number.isFinite(time) ? time : undefined;
+}
+
+/** Best estimate of the next scheduled run, from the last run and the interval. */
+export function nextRunEstimate(
+ roots: Roots,
+ settings: Settings,
+ now = Date.now(),
+): number | undefined {
+ const state = readState(roots);
+ if (!state.lastRunAt) return undefined;
+ const next = state.lastRunAt + settings.autosync.intervalMinutes * 60000;
+ return next > now ? next : undefined;
+}
+
+export function formatTime(ms: number): string {
+ const d = new Date(ms);
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
+
+/** Tomorrow at 08:00 local time. */
+export function tomorrowMorning(now = Date.now()): number {
+ const d = new Date(now);
+ d.setDate(d.getDate() + 1);
+ d.setHours(8, 0, 0, 0);
+ return d.getTime();
+}
+
+export function isValidProxy(value: string): boolean {
+ return /^(https?|socks5h?|socks4):\/\/[^\s]+$/i.test(value.trim());
+}
diff --git a/src/cli/args.ts b/src/cli/args.ts
new file mode 100644
index 0000000..1ab31db
--- /dev/null
+++ b/src/cli/args.ts
@@ -0,0 +1,45 @@
+/** Flags that take a value, so `--repo o/r` works as well as `--repo=o/r`. */
+export const VALUE_FLAGS = new Set([
+ "mode",
+ "repo",
+ "interval",
+ "lang",
+ "proxy",
+ "limit",
+ "prefer",
+ "trigger",
+]);
+
+export interface Args {
+ command: string;
+ positional: string[];
+ flags: Set;
+ values: Map;
+}
+
+export function parseArgs(argv: string[]): Args {
+ const positional: string[] = [];
+ const flags = new Set();
+ const values = new Map();
+
+ for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i]!;
+ if (!arg.startsWith("-") || arg === "-") {
+ positional.push(arg);
+ continue;
+ }
+ const clean = arg.replace(/^--?/, "");
+ const eq = clean.indexOf("=");
+ if (eq !== -1) {
+ values.set(clean.slice(0, eq), clean.slice(eq + 1));
+ } else if (VALUE_FLAGS.has(clean) && i + 1 < argv.length && !argv[i + 1]!.startsWith("--")) {
+ values.set(clean, argv[++i]!);
+ } else {
+ flags.add(clean);
+ }
+ }
+
+ let command = positional.shift() ?? "menu";
+ if (command === "menu" && (flags.has("help") || flags.has("h"))) command = "help";
+ return { command, positional, flags, values };
+}
diff --git a/src/cli/i18n.ts b/src/cli/i18n.ts
new file mode 100644
index 0000000..4075d7e
--- /dev/null
+++ b/src/cli/i18n.ts
@@ -0,0 +1,848 @@
+/**
+ * User-visible strings, English and Simplified Chinese.
+ *
+ * The Chinese catalogue is typed against the English keys, so a missing or
+ * extra key fails the build; a test also asserts the key sets are identical.
+ * Placeholders are `{name}` and are filled by `t()`.
+ */
+
+const en = {
+ // common
+ back: "Back",
+ quit: "Quit",
+ cancelled: "Cancelled.",
+ saved: "Saved.",
+ on: "on",
+ off: "off",
+ never: "never",
+ none: "none",
+ unknown: "unknown",
+ pressKey: "Press any key to continue",
+ invalidValue: "Not a valid value: {value}",
+ recommended: "recommended",
+ failed: "Failed: {error}",
+ justNow: "just now",
+ minutesAgo: "{n} min ago",
+ hoursAgo: "{n} h ago",
+ daysAgo: "{n} d ago",
+ inMinutes: "in about {n} min",
+ soon: "soon",
+
+ // header
+ hdrRepo: "repository",
+ hdrMachine: "this machine",
+ hdrLastSync: "last sync",
+ hdrAutosync: "autosync",
+ hdrAutosyncOn: "on · every {n} min · next {next}",
+ hdrAutosyncPaused: "paused until {time}",
+ hdrLastFailure: "last failure",
+ hdrFailures: "{n} in a row · {error}",
+
+ // first run
+ firstTitle: "This machine is not linked to a sync repository yet.",
+ firstInstallOnly: "Just install the command (set up later)",
+ firstLink: "Link a repository",
+ firstPrimary: "Link and make this the primary device with autosync",
+ installOnlyDone: "The command is installed. Run `opencode-sync` whenever you are ready.",
+ linkTitle: "Which repository?",
+ linkCreate: "Create a new private repository on GitHub",
+ linkExisting: "Use an existing repository",
+ linkAskName: "Repository name or owner/name",
+ linkAskExisting: "owner/name, or a git URL",
+ linkFound: "Your private repositories that look like a sync repository:",
+ linkOther: "Something else",
+ linkDone: "Linked {repo}",
+ linkNeedGh:
+ "Creating a repository needs the GitHub CLI (gh), signed in. You can also paste a git URL.",
+ resolveTitle: "This machine's files differ from the repository in {n} place(s).",
+ resolveRepo: "Use the repository's version (replaced files go to recovery)",
+ resolveLocal: "Keep this machine's version and push it",
+ resolveDiff: "Show the differences first",
+ resolveEmpty:
+ "The repository is empty, so this machine's configuration is pushed as the first version.",
+ resolveSame: "This machine already matches the repository.",
+ diffOnlyHere: "only here",
+ diffDifferent: "different",
+ diffOnlyRepo: "only in the repository",
+ diffMore: "… and {n} more",
+ recoverySaved: "Replaced files were saved to {dir}",
+
+ // main menu
+ menuSync: "Sync now",
+ menuWhat: "What to sync",
+ menuAutosync: "Autosync",
+ menuUpdate: "Update",
+ menuOverrides: "This machine's overrides",
+ menuDiagnostics: "Diagnostics",
+ menuDanger: "Danger zone",
+ menuUninstall: "Uninstall",
+ menuProxy: "Network proxy",
+ menuRestore: "Restore a file from history",
+ menuDevices: "Devices",
+ menuLanguage: "Language",
+ menuGuard: "Large-change guard",
+
+ // sync
+ syncTitle: "Sync",
+ syncBoth: "Push and receive",
+ syncReceive: "Receive only",
+ syncPush: "Push only",
+ syncPreview: "Preview (change nothing)",
+ previewNothing: "Nothing would change.",
+ previewOutgoing: "Would push {n} change(s):",
+ previewIncoming: "Would receive {n} change(s):",
+ previewConfirm: "Go ahead?",
+ restartHint: "Restart OpenCode for the new configuration to take effect.",
+ largeTitle: "This push is unusually large:",
+ largeFiles: "{n} new files (limit {limit})",
+ largeBytes: "{size} in total (limit {limit})",
+ largeFile: "{path} is {size} (limit {limit})",
+ largeConfirm: "Push it anyway?",
+ largeRefused: "Held back by the large-change guard. Nothing was pushed.",
+
+ // what to sync
+ whatTitle: "What to sync",
+ whatCategories: "Choose what to sync",
+ whatSessions: "Session settings",
+ whatExtra: "Extra paths",
+ catConfig: "Main config (opencode.json)",
+ catInstructions: "Instruction files (*.md)",
+ catAgents: "Agents",
+ catCommands: "Commands",
+ catPlugins: "Plugins",
+ catSkills: "Skills (~/.agents/skills)",
+ catCredentials: "Credentials (auth.json)",
+ catSessions: "Sessions",
+ checkboxKeys: "Space toggles · Enter saves · Esc cancels",
+ checkboxLines: "Type numbers to toggle, an empty line saves, q cancels",
+ credPublic: "Refused: credentials are only synced to a private repository.",
+ credUnverified: "Could not confirm that the repository is private.",
+ credConfirm: "Sync credentials anyway?",
+ sessionsWarn: "Only use this with a private repository: conversations are private data.",
+ sessionsDays: "Sessions from the last N days",
+ sessionsMax: "At most N sessions per push",
+ sessionsMb: "Skip any session above N MB",
+ sessionsNeedNode: "Session sync needs Node 22.5 or newer.",
+ extraTitle: "Extra paths (relative to your home folder)",
+ extraAdd: "Add a path",
+ extraRemove: "Remove {path}",
+ extraAsk: "Path to add",
+ extraEmpty: "No extra paths yet.",
+
+ // autosync
+ autoTitle: "Autosync",
+ autoTurnOn: "Make this the primary device (turn autosync on)",
+ autoTurnOff: "Turn autosync off on this machine",
+ autoInterval: "Interval: every {n} min",
+ autoLoggedOff: "Run while signed out: {state}",
+ autoPause: "Pause",
+ autoNotifications: "Notifications: {state}",
+ autoRunNow: "Run once now",
+ autoReset: "Clear the failure counter",
+ autoOn: "Autosync is on: every {n} min.",
+ autoOff: "Autosync is off on this machine.",
+ autoInstallFailed: "Could not register the scheduled job: {error}",
+ autoWarning: "Note: {text}",
+ intervalAsk: "Minutes between runs (minimum 2)",
+ intervalCustom: "Custom",
+ intervalMinutes: "{n} min",
+ loggedOffExplain:
+ "Windows only registers a job that runs while you are signed out from an administrator terminal. Without that it runs while you are signed in. While signed out, saved HTTPS logins are not available to git, so use an SSH remote for this mode.",
+ loggedOffNeedAdmin:
+ "This terminal is not elevated. The setting is saved and applies the next time autosync is installed from an administrator terminal.",
+ notWindows: "This setting only applies on Windows.",
+ pause1h: "1 hour",
+ pause4h: "4 hours",
+ pauseTomorrow: "Until tomorrow morning",
+ pauseResume: "Resume now",
+ pausedUntil: "Paused until {time}.",
+ resumed: "Resumed.",
+ resetDone: "Cleared {n} failure(s).",
+
+ // update
+ updTitle: "Update",
+ updCheck: "Check for a newer version",
+ updUpgrade: "Upgrade now",
+ updAuto: "Update automatically once a day: {state}",
+ updVersions: "Installed {current} · latest {latest}",
+ updNewer: "A newer version is available.",
+ updLatest: "You have the latest version.",
+ updFailed: "Could not reach npm: {error}",
+ updDone: "Upgraded. Restart OpenCode and reopen this menu.",
+ updRunning: "Upgrading through npm…",
+
+ // overrides
+ ovTitle: "This machine's overrides",
+ ovOpen: "Open in an editor",
+ ovShow: "Show what the overrides change",
+ ovEditorFailed: "Could not start an editor. The file is: {file}",
+ ovNone: "No overrides: this machine uses the shared configuration as it is.",
+ ovFile: "File: {file}",
+ ovRemoved: "(removed)",
+
+ // diagnostics
+ diagTitle: "Diagnostics",
+ diagGit: "git",
+ diagNode: "node",
+ diagGh: "gh",
+ diagNetwork: "repository access",
+ diagScheduler: "scheduled job",
+ diagSettings: "settings file",
+ diagLog: "Last log lines",
+ diagOk: "ok",
+ diagMissing: "not found",
+ diagFail: "failed: {error}",
+ diagSessionsNode: "below 22.5, session sync unavailable",
+ diagRegistered: "registered ({kind})",
+ diagNotRegistered: "not registered",
+ diagIgnored: "excluded from the repository",
+ diagNotIgnored: "NOT excluded from the repository",
+ diagBundle: "Export a support bundle",
+ bundleWritten: "Support bundle written: {file}",
+
+ // danger
+ dangerTitle: "Danger zone",
+ dangerPull: "Force pull: make this machine match the repository",
+ dangerPush: "Force push: make the repository match this machine",
+ dangerWarnPull:
+ "Local changes that are not in the repository are replaced. Replaced files are saved to recovery first.",
+ dangerWarnPush: "Changes from other machines that this one has not received are overwritten.",
+ dangerType: "Type",
+
+ // uninstall
+ unTitle: "Uninstall",
+ unSchedulerRemoved: "Removed the scheduled job.",
+ unSchedulerNone: "No scheduled job was registered.",
+ unUnlink: "Also unlink this machine from the repository?",
+ unUnlinked: "Unlinked. No configuration file was touched.",
+ unNpm: "Also remove the opencode-sync command (npm uninstall -g)?",
+ unNpmDone: "Removed the command.",
+ unKeep: "Your configuration files are never deleted.",
+
+ // proxy
+ proxyTitle: "Network proxy",
+ proxyCurrent: "Current proxy: {proxy}",
+ proxyAsk: "Proxy URL, e.g. http://127.0.0.1:7890 (empty clears it)",
+ proxySet: "Saved. git, gh and npm started by opencode-sync now use it.",
+ proxyCleared: "Proxy cleared.",
+ proxyInvalid: "Not an http(s) or socks URL: {value}",
+
+ // restore
+ rsAsk: "Part of the file name (empty lists everything)",
+ rsNoMatch: "No synced file matches.",
+ rsPickFile: "Which file?",
+ rsPickVersion: "Which version?",
+ rsNoHistory: "This file has no history yet.",
+ rsConfirm: "Restore {file} to the version from {date}?",
+ rsDone: "Restored {file}.",
+ rsPushNow: "Push this change now?",
+
+ // devices
+ devTitle: "Devices",
+ devRow: "last sync {when} · {n} sync(s)",
+ devThis: "this machine",
+ devNone: "No sync history yet.",
+ devRename: "Rename this machine",
+ devAsk: "New name for this machine",
+ devRenamed: "This machine is now called {alias}.",
+
+ // language
+ langTitle: "Language",
+ langAuto: "Automatic (follow the system)",
+ langSet: "Language saved.",
+
+ // guard
+ guardTitle: "Large-change guard",
+ guardFiles: "New files per push: {n}",
+ guardBytes: "Total size per push: {size}",
+ guardFile: "Single file: {size}",
+ guardSkip: "Never copied: {dirs}",
+ guardAskNumber: "New limit",
+ guardAskMb: "New limit in MB",
+ guardAskSkip: "Directory names, comma separated",
+
+ // command line
+ cliUnknown: "Unknown command: {cmd}",
+ cliHelpHint: "Run `opencode-sync help` to see everything.",
+ cliNoRepo: "No sync repository configured.",
+ cliRunInit: "Run `opencode-sync` to set one up.",
+ cliFoundRepos: "Private repositories that look like a match: {list}",
+ cliLinkHint: "Link one with: opencode-sync link ",
+ cliNotInitialised: "The local repository is not initialised yet. Run `opencode-sync pull`.",
+ cliForceTitle: "Force {action}?",
+ cliForcePushWarn: "Any change made on another machine and not pulled here will be overwritten.",
+ cliForcePullWarn: "Uncommitted local changes are replaced; replaced files go to recovery.",
+ cliTypeYes: "Type yes to continue",
+ cliPushTitle: "push to GitHub",
+ cliPullTitle: "pull from GitHub",
+ cliPreparingPush: "Preparing push",
+ cliPreparingPull: "Preparing pull",
+ cliDryRun: "{message} (dry run)",
+ cliSessionsLine: "Sessions: {text}",
+ cliExported: "{n} exported",
+ cliImported: "{n} imported",
+ cliSkipped: "{n} skipped",
+ cliNeedTty:
+ "This needs an interactive terminal; pass --yes to accept, or run it from a terminal.",
+
+ stTitle: "status",
+ stRepository: "repository",
+ stBranch: "branch",
+ stMachine: "machine",
+ stState: "state",
+ stInSync: "in sync",
+ stToPush: "{n} to push",
+ stToPull: "{n} to pull",
+ stUncommitted: "uncommitted",
+ stClean: "clean",
+ stFiles: "{n} file(s)",
+ stOverrides: "overrides",
+ stActive: "active",
+ stSessions: "sessions",
+ stSessionsOn: "enabled · {n} shard(s)",
+ stDisabled: "disabled",
+ stLastCommit: "Last commit",
+
+ initTitle: "create a sync repository",
+ linkTitleCli: "link an existing repository",
+ initCreated: "Created {repo}",
+ initLinked: "Linked {repo}",
+ initSettings: "settings {file}",
+ initOverrides: "overrides {file}",
+ initNext: "Next",
+ initRestart: "Restart OpenCode",
+
+ sesTitle: "sessions",
+ sesNoSqlite: "No SQLite driver available. Use Node 22.5+ or run under Bun.",
+ sesNone: "No sessions found.",
+ sesUntitled: "(untitled)",
+ sesPinHint: "Pin a session with: opencode-sync sessions include ",
+ sesUsage: "Usage: opencode-sync sessions {sub} ",
+ sesPinned: "Pinned {id}",
+ sesExcluded: "Excluded {id}",
+ sesEnabled: "Session sync enabled",
+ sesDisabled: "Session sync disabled",
+ sesWindow: "Window: {days} day(s), up to {max} sessions",
+ sesCap: "Per-session cap: {size}",
+ sesUnknown: "Unknown sessions subcommand: {sub}",
+
+ ovCliTitle: "per-machine overrides",
+ ovCreated: "Created the overrides file",
+ ovCliInfo: "Anything in this file stays on this machine and is re-applied after every pull.",
+ cfgTitle: "settings",
+ cfgMissing: "No settings file yet. Run `opencode-sync`.",
+
+ asTitle: "autosync",
+ asInstalled: "Scheduled job registered ({kind}).",
+ asUninstalled: "Scheduled job removed.",
+ asNotInstalled: "No scheduled job was registered.",
+ asEnabled: "enabled",
+ asInterval: "interval",
+ asScheduler: "scheduled job",
+ asLastRun: "last run",
+ asLastSuccess: "last success",
+ asLastError: "last error",
+ asFailures: "failures in a row",
+ asNextAttempt: "next attempt",
+ asPaused: "paused until",
+ asOutcome: "last outcome",
+ asUsage:
+ "Usage: opencode-sync autosync install|uninstall|enable|disable|status|run|reset|pause|resume",
+ asPauseUsage: "Usage: opencode-sync autosync pause |tomorrow",
+ asRunSkipped: "Skipped: {reason}",
+ asRunDone: "{action}: {message}",
+
+ setupNeedRepo: "--repo is required with --mode {mode}.",
+ setupCliOnly: "opencode-sync is installed. Run `opencode-sync` to open the menu.",
+ setupDone: "Setup complete.",
+
+ helpTitle: "sync OpenCode across machines",
+ helpSetup: "Setup",
+ helpSync: "Sync",
+ helpSessions: "Sessions",
+ helpAutosync: "Autosync",
+ helpLocal: "Machine-local",
+ helpFlags: "Flags",
+ helpMenu: "Open the interactive menu (also: no arguments)",
+ helpInit: "Create a private sync repository",
+ helpLink: "Use an existing sync repository",
+ helpSetupCmd: "Non-interactive setup used by the installers",
+ helpPush: "Send this machine's configuration",
+ helpPull: "Apply the shared configuration",
+ helpStatus: "Show what is in sync and what is not",
+ helpSesList: "Show recent sessions and their ids",
+ helpSesToggle: "Turn selective session sync on or off",
+ helpSesInclude: "Always sync a session, ignoring the time window",
+ helpSesExclude: "Never sync a session",
+ helpAsInstall: "Register or remove the scheduled job",
+ helpAsToggle: "Turn autosync on or off for this machine",
+ helpAsStatus: "Show autosync state and the last error",
+ helpAsRun: "Run one scheduled pass now",
+ helpAsPause: "Pause or resume autosync",
+ helpAsReset: "Clear the failure counter",
+ helpOverrides: "Open the per-machine configuration patch",
+ helpConfig: "Show the current settings",
+ helpForce: "Overwrite the other side on conflict",
+ helpDryRun: "Report what would change without doing it",
+ helpLang: "Interface language: en or zh-CN",
+ helpAllowLarge: "Push even when the large-change guard objects",
+ helpYes: "Answer yes to confirmations (needed without a terminal)",
+ helpPrefer:
+ "setup: on a first sync with an existing repository, keep the repository's files (repo, default) or this machine's (local)",
+
+ // notifications from the scheduled job
+ ntBackoffTitle: "OpenCode sync is failing",
+ ntBackoffBody:
+ "{n} attempts in a row failed. Retrying every {m} min. Details: opencode-sync autosync status",
+ ntRecoveredTitle: "OpenCode sync recovered",
+ ntRecoveredBody: "Automatic sync is working again.",
+ ntLargeTitle: "OpenCode sync needs you",
+ ntLargeBody: "A very large change is waiting. Open opencode-sync to review it.",
+};
+
+export type MessageKey = keyof typeof en;
+type Catalog = Record;
+
+const zhCN: Catalog = {
+ back: "返回",
+ quit: "退出",
+ cancelled: "已取消。",
+ saved: "已保存。",
+ on: "开",
+ off: "关",
+ never: "从未",
+ none: "无",
+ unknown: "未知",
+ pressKey: "按任意键继续",
+ invalidValue: "无效的值:{value}",
+ recommended: "推荐",
+ failed: "失败:{error}",
+ justNow: "刚刚",
+ minutesAgo: "{n} 分钟前",
+ hoursAgo: "{n} 小时前",
+ daysAgo: "{n} 天前",
+ inMinutes: "约 {n} 分钟后",
+ soon: "很快",
+
+ hdrRepo: "仓库",
+ hdrMachine: "本机",
+ hdrLastSync: "上次同步",
+ hdrAutosync: "自动同步",
+ hdrAutosyncOn: "已开启 · 每 {n} 分钟 · 下次 {next}",
+ hdrAutosyncPaused: "暂停到 {time}",
+ hdrLastFailure: "上次失败",
+ hdrFailures: "连续 {n} 次 · {error}",
+
+ firstTitle: "这台电脑还没有关联同步仓库。",
+ firstInstallOnly: "只装好命令,以后再设置",
+ firstLink: "关联一个仓库",
+ firstPrimary: "关联仓库,并把本机设为自动同步的主力设备",
+ installOnlyDone: "命令已经装好。准备好了随时运行 `opencode-sync`。",
+ linkTitle: "用哪个仓库?",
+ linkCreate: "在 GitHub 上新建一个私有仓库",
+ linkExisting: "用已有的仓库",
+ linkAskName: "仓库名,或 用户名/仓库名",
+ linkAskExisting: "用户名/仓库名,或者 git 地址",
+ linkFound: "你的私有仓库里,像是同步仓库的有:",
+ linkOther: "都不是,我自己输入",
+ linkDone: "已关联 {repo}",
+ linkNeedGh: "新建仓库需要已登录的 GitHub CLI(gh)。也可以直接粘贴 git 地址。",
+ resolveTitle: "本机文件和仓库有 {n} 处不一样。",
+ resolveRepo: "以仓库为准(被替换的文件先放进恢复目录)",
+ resolveLocal: "以本机为准,推送上去",
+ resolveDiff: "先看看哪里不一样",
+ resolveEmpty: "仓库是空的,本机的配置会作为第一个版本推上去。",
+ resolveSame: "本机和仓库已经一致。",
+ diffOnlyHere: "只在本机",
+ diffDifferent: "内容不同",
+ diffOnlyRepo: "只在仓库",
+ diffMore: "……还有 {n} 个",
+ recoverySaved: "被替换的文件已保存到 {dir}",
+
+ menuSync: "立即同步",
+ menuWhat: "同步哪些内容",
+ menuAutosync: "自动同步",
+ menuUpdate: "更新",
+ menuOverrides: "本机专属设置",
+ menuDiagnostics: "诊断",
+ menuDanger: "危险操作",
+ menuUninstall: "卸载",
+ menuProxy: "网络代理",
+ menuRestore: "从历史版本恢复文件",
+ menuDevices: "设备列表",
+ menuLanguage: "界面语言",
+ menuGuard: "大改动保护",
+
+ syncTitle: "同步",
+ syncBoth: "推送并接收",
+ syncReceive: "只接收",
+ syncPush: "只推送",
+ syncPreview: "预览(不做任何改动)",
+ previewNothing: "没有需要同步的改动。",
+ previewOutgoing: "将推送 {n} 处改动:",
+ previewIncoming: "将接收 {n} 处改动:",
+ previewConfirm: "继续吗?",
+ restartHint: "重启 OpenCode 后新配置才会生效。",
+ largeTitle: "这次推送的改动特别大:",
+ largeFiles: "新增 {n} 个文件(上限 {limit})",
+ largeBytes: "总共 {size}(上限 {limit})",
+ largeFile: "{path} 有 {size}(上限 {limit})",
+ largeConfirm: "仍然推送吗?",
+ largeRefused: "被大改动保护拦下,什么都没推送。",
+
+ whatTitle: "同步哪些内容",
+ whatCategories: "勾选要同步的内容",
+ whatSessions: "会话同步设置",
+ whatExtra: "额外路径",
+ catConfig: "主配置(opencode.json)",
+ catInstructions: "指令文件(*.md)",
+ catAgents: "智能体(agents)",
+ catCommands: "自定义命令(commands)",
+ catPlugins: "插件(plugins)",
+ catSkills: "技能(~/.agents/skills)",
+ catCredentials: "登录凭据(auth.json)",
+ catSessions: "会话记录",
+ checkboxKeys: "空格勾选 · 回车保存 · Esc 取消",
+ checkboxLines: "输入编号切换勾选,直接回车保存,q 取消",
+ credPublic: "已拒绝:登录凭据只能同步到私有仓库。",
+ credUnverified: "没法确认这个仓库是私有的。",
+ credConfirm: "仍然同步登录凭据吗?",
+ sessionsWarn: "只在私有仓库里开启:对话内容属于隐私。",
+ sessionsDays: "同步最近几天的会话",
+ sessionsMax: "每次最多推送几个会话",
+ sessionsMb: "单个会话超过多少 MB 就跳过",
+ sessionsNeedNode: "会话同步需要 Node 22.5 或更新版本。",
+ extraTitle: "额外路径(相对于用户主目录)",
+ extraAdd: "添加路径",
+ extraRemove: "移除 {path}",
+ extraAsk: "要添加的路径",
+ extraEmpty: "还没有额外路径。",
+
+ autoTitle: "自动同步",
+ autoTurnOn: "设为主力设备(开启自动同步)",
+ autoTurnOff: "在本机关闭自动同步",
+ autoInterval: "间隔:每 {n} 分钟",
+ autoLoggedOff: "注销后也运行:{state}",
+ autoPause: "暂停",
+ autoNotifications: "桌面通知:{state}",
+ autoRunNow: "马上跑一次",
+ autoReset: "清零失败次数",
+ autoOn: "自动同步已开启:每 {n} 分钟一次。",
+ autoOff: "本机的自动同步已关闭。",
+ autoInstallFailed: "定时任务注册失败:{error}",
+ autoWarning: "提示:{text}",
+ intervalAsk: "每隔几分钟运行一次(最少 2)",
+ intervalCustom: "自定义",
+ intervalMinutes: "{n} 分钟",
+ loggedOffExplain:
+ "Windows 只有在管理员终端里才能注册“注销后也运行”的任务。否则只在你登录时运行。注销后 git 读不到你保存的 HTTPS 登录信息,这种模式请用 SSH 地址连接仓库。",
+ loggedOffNeedAdmin: "当前终端没有管理员权限。设置已保存,下次在管理员终端里安装自动同步时生效。",
+ notWindows: "这项设置只对 Windows 有效。",
+ pause1h: "1 小时",
+ pause4h: "4 小时",
+ pauseTomorrow: "到明天早上",
+ pauseResume: "现在恢复",
+ pausedUntil: "已暂停到 {time}。",
+ resumed: "已恢复。",
+ resetDone: "已清零 {n} 次失败。",
+
+ updTitle: "更新",
+ updCheck: "检查新版本",
+ updUpgrade: "立即升级",
+ updAuto: "每天自动更新:{state}",
+ updVersions: "已安装 {current} · 最新 {latest}",
+ updNewer: "有新版本可以升级。",
+ updLatest: "已经是最新版本。",
+ updFailed: "连不上 npm:{error}",
+ updDone: "升级完成。请重启 OpenCode,再重新打开这个菜单。",
+ updRunning: "正在通过 npm 升级……",
+
+ ovTitle: "本机专属设置",
+ ovOpen: "用编辑器打开",
+ ovShow: "看看专属设置改了什么",
+ ovEditorFailed: "打不开编辑器。文件在:{file}",
+ ovNone: "没有专属设置:本机直接使用共享配置。",
+ ovFile: "文件:{file}",
+ ovRemoved: "(已删除)",
+
+ diagTitle: "诊断",
+ diagGit: "git",
+ diagNode: "node",
+ diagGh: "gh",
+ diagNetwork: "仓库访问",
+ diagScheduler: "定时任务",
+ diagSettings: "设置文件",
+ diagLog: "最近的日志",
+ diagOk: "正常",
+ diagMissing: "未找到",
+ diagFail: "失败:{error}",
+ diagSessionsNode: "低于 22.5,无法同步会话",
+ diagRegistered: "已注册({kind})",
+ diagNotRegistered: "未注册",
+ diagIgnored: "不会提交到仓库",
+ diagNotIgnored: "没有排除,会被提交到仓库",
+ diagBundle: "导出排障包",
+ bundleWritten: "排障包已生成:{file}",
+
+ dangerTitle: "危险操作",
+ dangerPull: "强制拉取:让本机和仓库完全一致",
+ dangerPush: "强制推送:让仓库和本机完全一致",
+ dangerWarnPull: "本机上仓库里没有的改动会被替换。被替换的文件会先保存到恢复目录。",
+ dangerWarnPush: "其他电脑推上去、本机还没收到的改动会被覆盖。",
+ dangerType: "请输入",
+
+ unTitle: "卸载",
+ unSchedulerRemoved: "已删除定时任务。",
+ unSchedulerNone: "没有注册过定时任务。",
+ unUnlink: "同时解除本机和仓库的关联吗?",
+ unUnlinked: "已解除关联,没有动任何配置文件。",
+ unNpm: "同时删除 opencode-sync 命令吗(npm uninstall -g)?",
+ unNpmDone: "命令已删除。",
+ unKeep: "你的配置文件永远不会被删除。",
+
+ proxyTitle: "网络代理",
+ proxyCurrent: "当前代理:{proxy}",
+ proxyAsk: "代理地址,例如 http://127.0.0.1:7890(留空则清除)",
+ proxySet: "已保存。opencode-sync 调用的 git、gh、npm 都会走这个代理。",
+ proxyCleared: "已清除代理。",
+ proxyInvalid: "不是 http(s) 或 socks 地址:{value}",
+
+ rsAsk: "文件名里的关键字(留空列出全部)",
+ rsNoMatch: "没有匹配的同步文件。",
+ rsPickFile: "恢复哪个文件?",
+ rsPickVersion: "恢复到哪个版本?",
+ rsNoHistory: "这个文件还没有历史版本。",
+ rsConfirm: "把 {file} 恢复到 {date} 的版本吗?",
+ rsDone: "已恢复 {file}。",
+ rsPushNow: "现在就推送这个改动吗?",
+
+ devTitle: "设备列表",
+ devRow: "上次同步 {when} · 共 {n} 次",
+ devThis: "本机",
+ devNone: "还没有同步记录。",
+ devRename: "给本机改名",
+ devAsk: "本机的新名字",
+ devRenamed: "本机现在叫 {alias}。",
+
+ langTitle: "界面语言",
+ langAuto: "自动(跟随系统)",
+ langSet: "语言已保存。",
+
+ guardTitle: "大改动保护",
+ guardFiles: "每次推送的新文件数:{n}",
+ guardBytes: "每次推送的总大小:{size}",
+ guardFile: "单个文件大小:{size}",
+ guardSkip: "永不复制的目录:{dirs}",
+ guardAskNumber: "新的上限",
+ guardAskMb: "新的上限(MB)",
+ guardAskSkip: "目录名,用逗号分隔",
+
+ cliUnknown: "未知命令:{cmd}",
+ cliHelpHint: "运行 `opencode-sync help` 查看全部命令。",
+ cliNoRepo: "还没有配置同步仓库。",
+ cliRunInit: "运行 `opencode-sync` 进行设置。",
+ cliFoundRepos: "可能匹配的私有仓库:{list}",
+ cliLinkHint: "关联方法:opencode-sync link <用户名/仓库名>",
+ cliNotInitialised: "本地仓库还没初始化。请运行 `opencode-sync pull`。",
+ cliForceTitle: "确定强制{action}吗?",
+ cliForcePushWarn: "其他电脑上做了、本机还没拉取的改动会被覆盖。",
+ cliForcePullWarn: "本机未提交的改动会被替换;被替换的文件会放进恢复目录。",
+ cliTypeYes: "输入 yes 继续",
+ cliPushTitle: "推送到 GitHub",
+ cliPullTitle: "从 GitHub 拉取",
+ cliPreparingPush: "正在准备推送",
+ cliPreparingPull: "正在准备拉取",
+ cliDryRun: "{message}(演练,未实际执行)",
+ cliSessionsLine: "会话:{text}",
+ cliExported: "导出 {n} 个",
+ cliImported: "导入 {n} 个",
+ cliSkipped: "跳过 {n} 个",
+ cliNeedTty: "这一步需要交互式终端;可以加 --yes 直接同意,或者在终端里运行。",
+
+ stTitle: "状态",
+ stRepository: "仓库",
+ stBranch: "分支",
+ stMachine: "本机",
+ stState: "同步状态",
+ stInSync: "已同步",
+ stToPush: "{n} 个待推送",
+ stToPull: "{n} 个待拉取",
+ stUncommitted: "未提交",
+ stClean: "无",
+ stFiles: "{n} 个文件",
+ stOverrides: "专属设置",
+ stActive: "已启用",
+ stSessions: "会话",
+ stSessionsOn: "已开启 · {n} 个分片",
+ stDisabled: "未开启",
+ stLastCommit: "最近一次提交",
+
+ initTitle: "新建同步仓库",
+ linkTitleCli: "关联已有仓库",
+ initCreated: "已新建 {repo}",
+ initLinked: "已关联 {repo}",
+ initSettings: "设置文件 {file}",
+ initOverrides: "专属设置 {file}",
+ initNext: "下一步",
+ initRestart: "重启 OpenCode",
+
+ sesTitle: "会话",
+ sesNoSqlite: "没有可用的 SQLite 驱动。请使用 Node 22.5+ 或 Bun。",
+ sesNone: "没有找到会话。",
+ sesUntitled: "(无标题)",
+ sesPinHint: "固定同步某个会话:opencode-sync sessions include ",
+ sesUsage: "用法:opencode-sync sessions {sub} <会话 id>",
+ sesPinned: "已固定 {id}",
+ sesExcluded: "已排除 {id}",
+ sesEnabled: "会话同步已开启",
+ sesDisabled: "会话同步已关闭",
+ sesWindow: "范围:最近 {days} 天,最多 {max} 个会话",
+ sesCap: "单个会话上限:{size}",
+ sesUnknown: "未知的 sessions 子命令:{sub}",
+
+ ovCliTitle: "本机专属设置",
+ ovCreated: "已创建专属设置文件",
+ ovCliInfo: "这个文件里的内容只留在本机,每次拉取后都会重新套用。",
+ cfgTitle: "设置",
+ cfgMissing: "还没有设置文件。请运行 `opencode-sync`。",
+
+ asTitle: "自动同步",
+ asInstalled: "定时任务已注册({kind})。",
+ asUninstalled: "定时任务已删除。",
+ asNotInstalled: "没有注册过定时任务。",
+ asEnabled: "已开启",
+ asInterval: "间隔",
+ asScheduler: "定时任务",
+ asLastRun: "上次运行",
+ asLastSuccess: "上次成功",
+ asLastError: "最后的错误",
+ asFailures: "连续失败",
+ asNextAttempt: "下次尝试",
+ asPaused: "暂停到",
+ asOutcome: "上次结果",
+ asUsage:
+ "用法:opencode-sync autosync install|uninstall|enable|disable|status|run|reset|pause|resume",
+ asPauseUsage: "用法:opencode-sync autosync pause <小时数>|tomorrow",
+ asRunSkipped: "跳过:{reason}",
+ asRunDone: "{action}:{message}",
+
+ setupNeedRepo: "--mode {mode} 需要同时提供 --repo。",
+ setupCliOnly: "opencode-sync 已安装。运行 `opencode-sync` 打开菜单。",
+ setupDone: "设置完成。",
+
+ helpTitle: "在多台电脑之间同步 OpenCode",
+ helpSetup: "设置",
+ helpSync: "同步",
+ helpSessions: "会话",
+ helpAutosync: "自动同步",
+ helpLocal: "仅限本机",
+ helpFlags: "参数",
+ helpMenu: "打开交互菜单(不带参数也可以)",
+ helpInit: "新建一个私有同步仓库",
+ helpLink: "使用已有的同步仓库",
+ helpSetupCmd: "安装脚本使用的非交互设置",
+ helpPush: "把本机配置推送上去",
+ helpPull: "把共享配置拉到本机",
+ helpStatus: "查看哪些已同步、哪些没有",
+ helpSesList: "列出最近的会话和 id",
+ helpSesToggle: "开启或关闭会话同步",
+ helpSesInclude: "始终同步某个会话,不受时间范围限制",
+ helpSesExclude: "永不同步某个会话",
+ helpAsInstall: "注册或删除定时任务",
+ helpAsToggle: "在本机开启或关闭自动同步",
+ helpAsStatus: "查看自动同步状态和最后的错误",
+ helpAsRun: "马上执行一次定时同步",
+ helpAsPause: "暂停或恢复自动同步",
+ helpAsReset: "清零失败次数",
+ helpOverrides: "打开本机专属配置文件",
+ helpConfig: "显示当前设置",
+ helpForce: "冲突时以本端为准覆盖另一端",
+ helpDryRun: "只报告会改什么,不实际执行",
+ helpLang: "界面语言:en 或 zh-CN",
+ helpAllowLarge: "即使大改动保护反对也照样推送",
+ helpYes: "确认提示一律回答“是”(没有终端时需要)",
+ helpPrefer: "setup:仓库里已有内容时,第一次同步保留仓库的文件(repo,默认)还是本机的(local)",
+
+ ntBackoffTitle: "OpenCode 配置同步出错",
+ ntBackoffBody: "已连续失败 {n} 次,现在每 {m} 分钟重试一次。详情:opencode-sync autosync status",
+ ntRecoveredTitle: "OpenCode 配置同步已恢复",
+ ntRecoveredBody: "自动同步又正常了。",
+ ntLargeTitle: "OpenCode 同步需要你确认",
+ ntLargeBody: "有一个特别大的改动在等待。打开 opencode-sync 看一下。",
+};
+
+export type Lang = "en" | "zh-CN";
+
+export const CATALOGS: Record = { en, "zh-CN": zhCN };
+
+/** Map any locale string to a supported language, or undefined. */
+export function normalizeLang(value: string | undefined | null): Lang | undefined {
+ const v = String(value ?? "")
+ .trim()
+ .toLowerCase();
+ if (!v || v === "c" || v === "posix" || v === "auto") return undefined;
+ if (v.startsWith("zh")) return "zh-CN";
+ if (v.startsWith("en")) return "en";
+ return undefined;
+}
+
+/**
+ * Resolve the language.
+ *
+ * Order: explicit `--lang`, then OPENCODE_SYNC_LANG, then the menu setting,
+ * then LC_ALL / LC_MESSAGES / LANG, then the runtime's Intl locale.
+ */
+export function detectLang(
+ explicit?: string,
+ setting?: string,
+ env: NodeJS.ProcessEnv = process.env,
+ intlLocale: () => string = () => Intl.DateTimeFormat().resolvedOptions().locale,
+): Lang {
+ const candidates = [
+ explicit,
+ env.OPENCODE_SYNC_LANG,
+ setting,
+ env.LC_ALL,
+ env.LC_MESSAGES,
+ env.LANG,
+ ];
+ for (const c of candidates) {
+ const lang = normalizeLang(c);
+ if (lang) return lang;
+ }
+ try {
+ return normalizeLang(intlLocale()) ?? "en";
+ } catch {
+ return "en";
+ }
+}
+
+let current: Lang = "en";
+
+export function setLang(lang: Lang): void {
+ current = lang;
+}
+
+export function getLang(): Lang {
+ return current;
+}
+
+export type Vars = Record;
+
+export function t(key: MessageKey, vars: Vars = {}, lang: Lang = current): string {
+ const template = CATALOGS[lang][key] ?? CATALOGS.en[key] ?? key;
+ return template.replace(/\{(\w+)\}/g, (match, name: string) =>
+ name in vars ? String(vars[name]) : match,
+ );
+}
+
+/** Relative time in the current language. */
+export function ago(timestamp: number | undefined, now = Date.now()): string {
+ if (!timestamp) return t("never");
+ const minutes = Math.round((now - timestamp) / 60000);
+ if (minutes < 1) return t("justNow");
+ if (minutes < 60) return t("minutesAgo", { n: minutes });
+ const hours = Math.round(minutes / 60);
+ if (hours < 48) return t("hoursAgo", { n: hours });
+ return t("daysAgo", { n: Math.round(hours / 24) });
+}
diff --git a/src/cli/index.ts b/src/cli/index.ts
index 51f184a..efcf7c2 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -1,7 +1,15 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
-import readline from "node:readline";
+import {
+ appendLog,
+ backoffRemainingMs,
+ failureCount,
+ readState,
+ runAutosync,
+ writeState,
+} from "../core/autosync.js";
+import { LargeChangeError } from "../core/guard.js";
import { withSyncLock } from "../core/lock.js";
import { getDatabasePath, getRoots } from "../core/paths.js";
import { listSessions } from "../core/sessions.js";
@@ -12,13 +20,30 @@ import {
saveSettings,
settingsPath,
} from "../core/settings.js";
-import { SetupError, discoverSyncRepos, initSync, linkSync } from "../core/setup.js";
+import {
+ SetupError,
+ discoverSyncRepos,
+ initSync,
+ isRepoUrl,
+ linkByUrl,
+ linkSync,
+} from "../core/setup.js";
import { sqliteAvailable } from "../core/sqlite.js";
import { SyncError, formatBytes, pull, push, status } from "../core/sync.js";
+import {
+ applyLocalSettings,
+ autosyncDeps,
+ formatTime,
+ realScheduler,
+ safeLoadSettings,
+ tomorrowMorning,
+} from "./app.js";
+import { type Args, parseArgs } from "./args.js";
+import { ago, t } from "./i18n.js";
+import { type SetupFlags, disableAutosync, enableAutosync, runMenu, runSetup } from "./menu.js";
import {
Spinner,
banner,
- changeList,
createCliReporter,
detail,
fail,
@@ -28,57 +53,38 @@ import {
ok,
outcome,
rule,
- summaryLine,
warn,
write,
} from "./render.js";
import { glyph, paint, style } from "./theme.js";
+import { Ui } from "./ui.js";
const LOCK_WAIT_MS = 5 * 60 * 1000;
-interface Args {
- command: string;
- positional: string[];
- flags: Set;
- values: Map;
+function newUi(): Ui {
+ return new Ui({
+ input: process.stdin,
+ output: process.stdout,
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
+ });
}
-function parseArgs(argv: string[]): Args {
- const positional: string[] = [];
- const flags = new Set();
- const values = new Map();
-
- for (const arg of argv) {
- if (!arg.startsWith("-")) {
- positional.push(arg);
- continue;
- }
- const clean = arg.replace(/^--?/, "");
- const eq = clean.indexOf("=");
- if (eq !== -1) values.set(clean.slice(0, eq), clean.slice(eq + 1));
- else flags.add(clean);
+async function confirmForce(action: string, lines: string[], args: Args): Promise {
+ if (args.flags.has("yes") || args.flags.has("y")) return true;
+ if (!process.stdin.isTTY) {
+ fail(t("cliNeedTty"));
+ return false;
}
-
- return { command: positional.shift() ?? "help", positional, flags, values };
-}
-
-async function confirm(question: string, lines: string[]): Promise {
- if (!process.stdin.isTTY) return false;
write();
- write(` ${paint(" FORCE ", style.bold, style.red)} ${paint(question, style.bold, style.text)}`);
+ write(
+ ` ${paint(" FORCE ", style.bold, style.red)} ${paint(t("cliForceTitle", { action }), style.bold, style.text)}`,
+ );
for (const line of lines) write(` ${paint(`${glyph.bullet} ${line}`, style.muted)}`);
write();
-
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
- const answer = await new Promise((resolve) => {
- rl.question(
- ` ${paint("Type", style.muted)} ${paint("yes", style.bold, style.red)} ${paint("to continue: ", style.muted)}`,
- resolve,
- );
- });
- rl.close();
- write();
- return answer.trim().toLowerCase() === "yes";
+ const ui = newUi();
+ const answer = await ui.text(t("cliTypeYes"));
+ ui.close();
+ return answer?.trim().toLowerCase() === "yes";
}
// ── Commands ────────────────────────────────────────────────────────────────
@@ -86,113 +92,122 @@ async function confirm(question: string, lines: string[]): Promise {
async function runSync(action: "push" | "pull", args: Args): Promise {
const force = args.flags.has("force") || args.flags.has("f");
const dryRun = args.flags.has("dry-run") || args.flags.has("n");
+ const allowLarge = args.flags.has("allow-large");
const roots = getRoots();
- banner(action === "push" ? "push to GitHub" : "pull from GitHub", force ? "FORCE" : undefined);
+ banner(action === "push" ? t("cliPushTitle") : t("cliPullTitle"), force ? "FORCE" : undefined);
if (force && !dryRun) {
- const consequences =
- action === "push"
- ? ["Any change made on another machine and not pulled here will be overwritten."]
- : [
- "Uncommitted local changes will be discarded.",
- "Skills and state directories will be rewritten from the repository.",
- ];
- if (!(await confirm(`Force ${action}?`, consequences))) {
- info("Cancelled.");
+ const consequences = action === "push" ? [t("cliForcePushWarn")] : [t("cliForcePullWarn")];
+ if (!(await confirmForce(action, consequences, args))) {
+ info(t("cancelled"));
return;
}
}
- const spinner = new Spinner(action === "push" ? "Preparing push" : "Preparing pull");
+ const spinner = new Spinner(action === "push" ? t("cliPreparingPush") : t("cliPreparingPull"));
spinner.start();
const reporter = createCliReporter(spinner);
try {
const result = await withSyncLock(roots.config, { waitMs: LOCK_WAIT_MS }, () =>
action === "push"
- ? push({ force, dryRun, reporter, roots })
+ ? push({ force, dryRun, reporter, roots, allowLarge })
: pull({ force, dryRun, reporter, roots }),
);
spinner.stop();
if (result.sessions) {
const bits: string[] = [];
- if (result.sessions.exported !== undefined) bits.push(`${result.sessions.exported} exported`);
- if (result.sessions.imported !== undefined) bits.push(`${result.sessions.imported} imported`);
- if (result.sessions.skipped) bits.push(`${result.sessions.skipped} skipped`);
- if (bits.length > 0) detail(`Sessions: ${bits.join(", ")}`);
+ if (result.sessions.exported !== undefined)
+ bits.push(t("cliExported", { n: result.sessions.exported }));
+ if (result.sessions.imported !== undefined)
+ bits.push(t("cliImported", { n: result.sessions.imported }));
+ if (result.sessions.skipped) bits.push(t("cliSkipped", { n: result.sessions.skipped }));
+ if (bits.length > 0) detail(t("cliSessionsLine", { text: bits.join(", ") }));
+ }
+ if (result.guard) {
+ warn(t("largeTitle"));
+ detail(`${result.guard.addedFiles} / ${formatBytes(result.guard.totalBytes)}`);
}
- outcome(dryRun ? `${result.message} (dry run)` : result.message, result.changed);
+ outcome(dryRun ? t("cliDryRun", { message: result.message }) : result.message, result.changed);
if (result.restartRequired && result.changed) {
- info("Restart OpenCode for the new configuration to take effect.");
+ info(t("restartHint"));
write();
}
+ } catch (error) {
+ if (error instanceof LargeChangeError) {
+ spinner.stop();
+ fail(`${t("largeRefused")}\n${error.message}`);
+ process.exitCode = 1;
+ return;
+ }
+ throw error;
} finally {
spinner.stop();
}
}
function runStatus(): void {
- banner("status");
+ banner(t("stTitle"));
const roots = getRoots();
const state = status({ roots });
if (!state.configured) {
- fail("No sync repository configured.");
+ fail(t("cliNoRepo"));
write();
- info("Run `opencode-sync init` to create one.");
+ info(t("cliRunInit"));
const found = discoverSyncRepos();
if (found.length > 0) {
write();
- detail(
- `Existing private repositories that look like a match: ${found.slice(0, 5).join(", ")}`,
- );
- detail("Link one with: opencode-sync link ");
+ detail(t("cliFoundRepos", { list: found.slice(0, 5).join(", ") }));
+ detail(t("cliLinkHint"));
}
write();
return;
}
- keyValue("repository", paint(state.remote ?? "-", style.text));
- keyValue("branch", paint(state.branch, style.text));
- keyValue("machine", paint(state.machineAlias, style.text));
+ keyValue(t("stRepository"), paint(state.remote ?? "-", style.text));
+ keyValue(t("stBranch"), paint(state.branch, style.text));
+ keyValue(t("stMachine"), paint(state.machineAlias, style.text));
if (!state.initialized) {
write();
- warn("The local repository is not initialised yet. Run `opencode-sync pull`.");
+ warn(t("cliNotInitialised"));
write();
return;
}
const sync =
state.ahead === 0 && state.behind === 0
- ? paint("in sync", style.green)
+ ? paint(t("stInSync"), style.green)
: [
- state.ahead > 0 ? paint(`${state.ahead} to push`, style.yellow) : "",
- state.behind > 0 ? paint(`${state.behind} to pull`, style.cyan) : "",
+ state.ahead > 0 ? paint(t("stToPush", { n: state.ahead }), style.yellow) : "",
+ state.behind > 0 ? paint(t("stToPull", { n: state.behind }), style.cyan) : "",
]
.filter(Boolean)
.join(paint(" ", style.faint));
- keyValue("state", sync);
+ keyValue(t("stState"), sync);
keyValue(
- "uncommitted",
- state.dirty === 0 ? paint("clean", style.green) : paint(`${state.dirty} file(s)`, style.yellow),
+ t("stUncommitted"),
+ state.dirty === 0
+ ? paint(t("stClean"), style.green)
+ : paint(t("stFiles", { n: state.dirty }), style.yellow),
);
keyValue(
- "overrides",
- state.overridesActive ? paint("active", style.accent) : paint("none", style.muted),
+ t("stOverrides"),
+ state.overridesActive ? paint(t("stActive"), style.accent) : paint(t("none"), style.muted),
);
keyValue(
- "sessions",
+ t("stSessions"),
state.sessionsEnabled
- ? paint(`enabled · ${state.sessionShards} shard(s)`, style.text)
- : paint("disabled", style.muted),
+ ? paint(t("stSessionsOn", { n: state.sessionShards }), style.text)
+ : paint(t("stDisabled"), style.muted),
);
if (state.lastCommit) {
- heading("Last commit");
+ heading(t("stLastCommit"));
detail(state.lastCommit);
if (state.lastCommitDate) detail(state.lastCommitDate);
}
@@ -200,7 +215,7 @@ function runStatus(): void {
}
function runInit(args: Args, mode: "init" | "link"): void {
- banner(mode === "init" ? "create a sync repository" : "link an existing repository");
+ banner(mode === "init" ? t("initTitle") : t("linkTitleCli"));
const reporter = createCliReporter();
const options = {
repo: args.positional[0],
@@ -209,21 +224,29 @@ function runInit(args: Args, mode: "init" | "link"): void {
includeSessions: args.flags.has("sessions"),
};
- const result = mode === "init" ? initSync(options) : linkSync(options);
+ const result =
+ mode === "link" && isRepoUrl(options.repo)
+ ? linkByUrl({ ...options, url: options.repo! })
+ : mode === "init"
+ ? initSync(options)
+ : linkSync(options);
+ const label = result.target.owner
+ ? `${result.target.owner}/${result.target.name}`
+ : result.target.name;
ok(
result.created
- ? `Created ${paint(`${result.target.owner}/${result.target.name}`, style.bold, style.text)}`
- : `Linked ${paint(`${result.target.owner}/${result.target.name}`, style.bold, style.text)}`,
+ ? t("initCreated", { repo: paint(label, style.bold, style.text) })
+ : t("initLinked", { repo: paint(label, style.bold, style.text) }),
);
- detail(`settings ${result.settingsFile}`);
- detail(`overrides ${result.overridesFile}`);
+ detail(t("initSettings", { file: result.settingsFile }));
+ detail(t("initOverrides", { file: result.overridesFile }));
- heading("Next");
+ heading(t("initNext"));
write(
` ${paint("1.", style.faint)} ${mode === "init" ? "opencode-sync push" : "opencode-sync pull"}`,
);
- write(` ${paint("2.", style.faint)} Restart OpenCode`);
+ write(` ${paint("2.", style.faint)} ${t("initRestart")}`);
write();
}
@@ -233,39 +256,43 @@ async function runSessions(args: Args): Promise {
const settings = loadSettings(roots.config);
if (sub === "list") {
- banner("sessions");
+ banner(t("sesTitle"));
if (!(await sqliteAvailable())) {
- fail("No SQLite driver available. Use Node 22.5+ or run under Bun.");
+ fail(t("sesNoSqlite"));
write();
return;
}
const limit = Number(args.values.get("limit") ?? 20);
const sessions = await listSessions(getDatabasePath(roots), limit);
if (sessions.length === 0) {
- info("No sessions found.");
+ info(t("sesNone"));
write();
return;
}
for (const session of sessions) {
const when = new Date(session.timeUpdated).toISOString().slice(0, 16).replace("T", " ");
- const title = session.title || paint("(untitled)", style.faint);
+ const title = session.title || paint(t("sesUntitled"), style.faint);
write(` ${paint(when, style.faint)} ${title}`);
write(` ${paint(" ".repeat(16), style.faint)} ${paint(session.id, style.faint)}`);
}
write();
- detail(`Pin a session with: opencode-sync sessions include `);
+ detail(t("sesPinHint"));
write();
return;
}
if (sub === "include" || sub === "exclude") {
const id = args.positional[1];
- if (!id) throw new SyncError(`Usage: opencode-sync sessions ${sub} `);
+ if (!id) throw new SyncError(t("sesUsage", { sub }));
const list = sub === "include" ? settings.sessions.include : settings.sessions.exclude;
if (!list.includes(id)) list.push(id);
saveSettings(settings, roots.config);
- banner("sessions");
- ok(`${sub === "include" ? "Pinned" : "Excluded"} ${paint(id, style.text)}`);
+ banner(t("sesTitle"));
+ ok(
+ sub === "include"
+ ? t("sesPinned", { id: paint(id, style.text) })
+ : t("sesExcluded", { id: paint(id, style.text) }),
+ );
write();
return;
}
@@ -273,43 +300,41 @@ async function runSessions(args: Args): Promise {
if (sub === "enable" || sub === "disable") {
settings.sessions.enabled = sub === "enable";
saveSettings(settings, roots.config);
- banner("sessions");
- ok(`Session sync ${sub}d`);
+ banner(t("sesTitle"));
+ ok(sub === "enable" ? t("sesEnabled") : t("sesDisabled"));
if (sub === "enable") {
- detail(
- `Window: ${settings.sessions.days} day(s), up to ${settings.sessions.maxSessions} sessions`,
- );
- detail(`Per-session cap: ${formatBytes(settings.sessions.maxSessionBytes)}`);
- warn("Only enable this for a private repository — conversations are private data.");
+ detail(t("sesWindow", { days: settings.sessions.days, max: settings.sessions.maxSessions }));
+ detail(t("sesCap", { size: formatBytes(settings.sessions.maxSessionBytes) }));
+ warn(t("sessionsWarn"));
}
write();
return;
}
- throw new SyncError(`Unknown sessions subcommand: ${sub}`);
+ throw new SyncError(t("sesUnknown", { sub }));
}
function runOverrides(): void {
- banner("per-machine overrides");
+ banner(t("ovCliTitle"));
const roots = getRoots();
const file = overridesPath(roots.config);
if (!fs.existsSync(file)) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, OVERRIDES_TEMPLATE);
- ok("Created the overrides file");
+ ok(t("ovCreated"));
}
detail(file);
write();
- info("Anything in this file stays on this machine and is re-applied after every pull.");
+ info(t("ovCliInfo"));
write();
}
function runConfig(): void {
- banner("settings");
+ banner(t("cfgTitle"));
const roots = getRoots();
const file = settingsPath(roots.config);
if (!fs.existsSync(file)) {
- fail("No settings file yet. Run `opencode-sync init`.");
+ fail(t("cfgMissing"));
write();
return;
}
@@ -319,31 +344,192 @@ function runConfig(): void {
write();
}
-function runHelp(): void {
- banner("sync OpenCode across machines");
-
- heading("Setup");
- command("init [name|owner/repo]", "Create a private sync repository");
- command("link ", "Use an existing sync repository");
-
- heading("Sync");
- command("push", "Send this machine's configuration");
- command("pull", "Apply the shared configuration");
- command("status", "Show what is in sync and what is not");
-
- heading("Sessions");
- command("sessions list", "Show recent sessions and their ids");
- command("sessions enable | disable", "Turn selective session sync on or off");
- command("sessions include ", "Always sync a session, ignoring the time window");
- command("sessions exclude ", "Never sync a session");
+async function runAutosyncCommand(args: Args): Promise {
+ const sub = args.positional[0] ?? "status";
+ const roots = getRoots();
+ const ctx = { roots, scheduler: realScheduler };
+
+ switch (sub) {
+ case "run": {
+ // Called by the scheduler: no banner, no colour noise, one log line.
+ const trigger = args.values.get("trigger") ?? "scheduled";
+ const outcomeResult = await runAutosync(autosyncDeps(roots), trigger);
+ write(
+ outcomeResult.action === "skip"
+ ? t("asRunSkipped", { reason: outcomeResult.reason })
+ : t("asRunDone", {
+ action: outcomeResult.action,
+ message: outcomeResult.message ?? outcomeResult.reason,
+ }),
+ );
+ if (!outcomeResult.ok) process.exitCode = 1;
+ return;
+ }
+ case "install": {
+ banner(t("asTitle"));
+ const settings = loadSettings(roots.config);
+ const result = realScheduler.install(roots, settings);
+ if (!result.ok) {
+ fail(t("autoInstallFailed", { error: result.detail.join("; ") }));
+ process.exitCode = 1;
+ return;
+ }
+ ok(t("asInstalled", { kind: result.kind }));
+ for (const d of result.detail) detail(d);
+ for (const w of result.warnings) warn(t("autoWarning", { text: w }));
+ if (!settings.autosync.enabled) warn(`${t("autoOff")} opencode-sync autosync enable`);
+ write();
+ return;
+ }
+ case "uninstall": {
+ banner(t("asTitle"));
+ const result = realScheduler.uninstall(roots);
+ ok(result.removed ? t("asUninstalled") : t("asNotInstalled"));
+ write();
+ return;
+ }
+ case "enable": {
+ banner(t("asTitle"));
+ const interval = Number(args.values.get("interval"));
+ if (Number.isFinite(interval) && interval >= 2) {
+ const s = loadSettings(roots.config);
+ s.autosync.intervalMinutes = Math.floor(interval);
+ saveSettings(s, roots.config);
+ }
+ if (!(await enableAutosync(ctx))) process.exitCode = 1;
+ write();
+ return;
+ }
+ case "disable":
+ banner(t("asTitle"));
+ disableAutosync(ctx);
+ write();
+ return;
+ case "reset": {
+ banner(t("asTitle"));
+ const before = failureCount(readState(roots));
+ writeState(roots, {
+ consecutiveFailures: 0,
+ lastError: null,
+ failureNotifiedAt: null,
+ lastAttemptAt: 0,
+ });
+ appendLog(roots, `reset: cleared ${before} failure(s)`);
+ ok(t("resetDone", { n: before }));
+ write();
+ return;
+ }
+ case "pause":
+ case "resume": {
+ banner(t("asTitle"));
+ const s = loadSettings(roots.config);
+ if (sub === "resume") {
+ s.autosync.pausedUntil = 0;
+ saveSettings(s, roots.config);
+ ok(t("resumed"));
+ } else {
+ const arg = args.positional[1] ?? "1";
+ const until = arg === "tomorrow" ? tomorrowMorning() : Date.now() + Number(arg) * 3600_000;
+ if (!Number.isFinite(until) || until <= Date.now()) throw new SyncError(t("asPauseUsage"));
+ s.autosync.pausedUntil = until;
+ saveSettings(s, roots.config);
+ ok(t("pausedUntil", { time: formatTime(until) }));
+ }
+ write();
+ return;
+ }
+ case "status": {
+ banner(t("asTitle"));
+ const s = loadSettings(roots.config);
+ const state = readState(roots);
+ const sched = realScheduler.status();
+ keyValue(
+ t("asEnabled"),
+ s.autosync.enabled ? paint(t("on"), style.green) : paint(t("off"), style.muted),
+ 20,
+ );
+ keyValue(t("asInterval"), t("intervalMinutes", { n: s.autosync.intervalMinutes }), 20);
+ keyValue(
+ t("asScheduler"),
+ sched.installed
+ ? `${t("diagRegistered", { kind: sched.kind })}${sched.nextRun ? ` · ${sched.nextRun}` : ""}`
+ : t("diagNotRegistered"),
+ 20,
+ );
+ if (s.autosync.pausedUntil > Date.now())
+ keyValue(t("asPaused"), formatTime(s.autosync.pausedUntil), 20);
+ keyValue(t("asLastRun"), ago(state.lastRunAt), 20);
+ keyValue(t("asLastSuccess"), ago(state.lastSuccessAt), 20);
+ if (state.lastOutcome) keyValue(t("asOutcome"), state.lastOutcome, 20);
+ const failures = failureCount(state);
+ if (failures) keyValue(t("asFailures"), String(failures), 20);
+ const wait = backoffRemainingMs(state, Date.now());
+ if (wait > 0)
+ keyValue(t("asNextAttempt"), t("inMinutes", { n: Math.ceil(wait / 60000) }), 20);
+ if (state.lastError) keyValue(t("asLastError"), paint(state.lastError, style.red), 20);
+ write();
+ return;
+ }
+ default:
+ throw new SyncError(t("asUsage"));
+ }
+}
- heading("Machine-local");
- command("overrides", "Open the per-machine configuration patch");
- command("config", "Show the current settings");
+function setupFlags(args: Args): SetupFlags {
+ const mode = (args.values.get("mode") ?? "cli") as SetupFlags["mode"];
+ if (!["cli", "link", "auto"].includes(mode))
+ throw new SetupError(t("invalidValue", { value: mode }));
+ const interval = args.values.get("interval");
+ const prefer = args.values.get("prefer");
+ return {
+ mode,
+ repo: args.values.get("repo") ?? args.positional[0],
+ interval: interval ? Number(interval) : undefined,
+ lang: args.values.get("lang"),
+ proxy: args.values.get("proxy"),
+ prefer: prefer === "local" ? "local" : prefer === "repo" ? "repo" : undefined,
+ };
+}
- heading("Flags");
- command("--force", "Overwrite the other side on conflict");
- command("--dry-run", "Report what would change without doing it");
+function runHelp(): void {
+ banner(t("helpTitle"));
+
+ heading(t("helpSetup"));
+ command("(no arguments) | menu", t("helpMenu"));
+ command("init [name|owner/repo]", t("helpInit"));
+ command("link ", t("helpLink"));
+ command("setup --mode cli|link|auto", t("helpSetupCmd"));
+
+ heading(t("helpSync"));
+ command("push", t("helpPush"));
+ command("pull", t("helpPull"));
+ command("status", t("helpStatus"));
+
+ heading(t("helpAutosync"));
+ command("autosync install | uninstall", t("helpAsInstall"));
+ command("autosync enable | disable", t("helpAsToggle"));
+ command("autosync status", t("helpAsStatus"));
+ command("autosync run", t("helpAsRun"));
+ command("autosync pause | resume", t("helpAsPause"));
+ command("autosync reset", t("helpAsReset"));
+
+ heading(t("helpSessions"));
+ command("sessions list", t("helpSesList"));
+ command("sessions enable | disable", t("helpSesToggle"));
+ command("sessions include ", t("helpSesInclude"));
+ command("sessions exclude ", t("helpSesExclude"));
+
+ heading(t("helpLocal"));
+ command("overrides", t("helpOverrides"));
+ command("config", t("helpConfig"));
+
+ heading(t("helpFlags"));
+ command("--force", t("helpForce"));
+ command("--dry-run", t("helpDryRun"));
+ command("--allow-large", t("helpAllowLarge"));
+ command("--yes", t("helpYes"));
+ command("--prefer repo|local", t("helpPrefer"));
+ command("--lang en|zh-CN", t("helpLang"));
write();
rule();
@@ -354,7 +540,7 @@ function runHelp(): void {
}
function command(name: string, description: string): void {
- const padded = name.padEnd(30);
+ const padded = name.padEnd(32);
write(` ${paint(padded, style.cyan)}${paint(description, style.muted)}`);
}
@@ -362,8 +548,28 @@ function command(name: string, description: string): void {
async function main(): Promise {
const args = parseArgs(process.argv.slice(2));
+ const roots = getRoots();
+ applyLocalSettings(safeLoadSettings(roots), args.values.get("lang"));
switch (args.command) {
+ case "menu": {
+ const ui = newUi();
+ try {
+ await runMenu({ ui, roots });
+ } finally {
+ ui.close();
+ }
+ return;
+ }
+ case "setup": {
+ const ui = newUi();
+ try {
+ await runSetup(setupFlags(args), { ui, roots });
+ } finally {
+ ui.close();
+ }
+ return;
+ }
case "push":
case "pull":
await runSync(args.command, args);
@@ -380,6 +586,9 @@ async function main(): Promise {
case "sessions":
await runSessions(args);
return;
+ case "autosync":
+ await runAutosyncCommand(args);
+ return;
case "overrides":
runOverrides();
return;
@@ -393,9 +602,9 @@ async function main(): Promise {
return;
default:
banner();
- fail(`Unknown command: ${args.command}`);
+ fail(t("cliUnknown", { cmd: args.command }));
write();
- info("Run `opencode-sync help` to see everything.");
+ info(t("cliHelpHint"));
write();
process.exitCode = 1;
}
diff --git a/src/cli/menu.ts b/src/cli/menu.ts
new file mode 100644
index 0000000..84e5cd8
--- /dev/null
+++ b/src/cli/menu.ts
@@ -0,0 +1,1281 @@
+import { spawnSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import { appendLog, readState, tailLog, writeState } from "../core/autosync.js";
+import { redactValue, writeBundle } from "../core/bundle.js";
+import { compareWithRemote } from "../core/compare.js";
+import { setChildProxy } from "../core/env.js";
+import { git } from "../core/git.js";
+import type { GuardReport } from "../core/guard.js";
+import { fileHistory, listDevices, restoreFile, trackedFiles } from "../core/history.js";
+import { hostAlias, sanitizeAlias } from "../core/host.js";
+import { parseJsonc } from "../core/jsonc.js";
+import { withSyncLock } from "../core/lock.js";
+import { findConfigFile, loadOverrides } from "../core/overrides.js";
+import { type Roots, getRoots } from "../core/paths.js";
+import { listRecovery } from "../core/recovery.js";
+import { CollectingReporter } from "../core/reporter.js";
+import {
+ MIN_AUTOSYNC_INTERVAL,
+ OVERRIDES_TEMPLATE,
+ type Settings,
+ loadSettings,
+ overridesPath,
+ repoBranch,
+ repoUrl,
+ saveSettings,
+ settingsPath,
+} from "../core/settings.js";
+import {
+ SetupError,
+ discoverSyncRepos,
+ ghAvailable,
+ githubTargetFromUrl,
+ initSync,
+ isRepoUrl,
+ linkByUrl,
+ linkSync,
+ repoIsPrivate,
+ repoLabel,
+ unlinkSync,
+} from "../core/setup.js";
+import { sqliteAvailable } from "../core/sqlite.js";
+import { type SyncResult, formatBytes, pull, push } from "../core/sync.js";
+import {
+ type SchedulerApi,
+ type UpdateApi,
+ applyLocalSettings,
+ formatTime,
+ isValidProxy,
+ lastCommitTime,
+ nextRunEstimate,
+ realScheduler,
+ realUpdater,
+ tomorrowMorning,
+} from "./app.js";
+import { type Lang, type MessageKey, ago, getLang, setLang, t } from "./i18n.js";
+import {
+ banner,
+ changeList,
+ createCliReporter,
+ detail,
+ fail,
+ heading,
+ info,
+ keyValue,
+ ok,
+ outcome,
+ summaryLine,
+ warn,
+ write,
+} from "./render.js";
+import { paint, style } from "./theme.js";
+import type { Choice, Ui } from "./ui.js";
+
+export interface MenuDeps {
+ ui: Ui;
+ roots?: Roots;
+ scheduler?: SchedulerApi;
+ updater?: UpdateApi;
+ /** Opens a file for editing; returns false when no editor could start. */
+ openEditor?: (file: string) => boolean;
+ now?: () => number;
+ /** Directory the support bundle is written to. */
+ cwd?: string;
+ /** Lists candidate repositories; defaults to the GitHub CLI. */
+ discover?: () => string[];
+}
+
+interface Ctx {
+ ui: Ui;
+ roots: Roots;
+ scheduler: SchedulerApi;
+ updater: UpdateApi;
+ openEditor: (file: string) => boolean;
+ now: () => number;
+ cwd: string;
+ discover: () => string[];
+}
+
+type Next = "stay" | "exit";
+
+// ── Entry ───────────────────────────────────────────────────────────────────
+
+export async function runMenu(deps: MenuDeps): Promise {
+ const ctx: Ctx = {
+ ui: deps.ui,
+ roots: deps.roots ?? getRoots(),
+ scheduler: deps.scheduler ?? realScheduler,
+ updater: deps.updater ?? realUpdater,
+ openEditor: deps.openEditor ?? defaultEditor,
+ now: deps.now ?? Date.now,
+ cwd: deps.cwd ?? process.cwd(),
+ discover: deps.discover ?? defaultDiscover,
+ };
+
+ if (!repoUrl(settings(ctx))) {
+ const next = await firstRun(ctx);
+ if (next === "exit") return;
+ }
+
+ for (;;) {
+ const s = settings(ctx);
+ header(ctx, s);
+ const choice = await ctx.ui.select("", [
+ { value: "sync", label: t("menuSync"), key: "1" },
+ { value: "what", label: t("menuWhat"), key: "2" },
+ { value: "auto", label: t("menuAutosync"), key: "3" },
+ { value: "update", label: t("menuUpdate"), key: "4" },
+ { value: "overrides", label: t("menuOverrides"), key: "5" },
+ { value: "diag", label: t("menuDiagnostics"), key: "6" },
+ { value: "danger", label: t("menuDanger"), key: "7" },
+ { value: "uninstall", label: t("menuUninstall"), key: "8" },
+ { value: "proxy", label: t("menuProxy"), key: "p" },
+ { value: "restore", label: t("menuRestore"), key: "r" },
+ { value: "devices", label: t("menuDevices"), key: "d" },
+ { value: "guard", label: t("menuGuard"), key: "g" },
+ { value: "lang", label: t("menuLanguage"), key: "l" },
+ { value: "quit", label: t("quit"), key: "q" },
+ ]);
+ if (choice === undefined || choice === "quit") return;
+ const next = await dispatch(ctx, choice);
+ if (next === "exit") return;
+ }
+}
+
+async function dispatch(ctx: Ctx, choice: string): Promise {
+ try {
+ switch (choice) {
+ case "sync":
+ return await syncMenu(ctx);
+ case "what":
+ return await whatMenu(ctx);
+ case "auto":
+ return await autosyncMenu(ctx);
+ case "update":
+ return await updateMenu(ctx);
+ case "overrides":
+ return await overridesMenu(ctx);
+ case "diag":
+ return await diagnosticsMenu(ctx);
+ case "danger":
+ return await dangerMenu(ctx);
+ case "uninstall":
+ return await uninstallMenu(ctx);
+ case "proxy":
+ return await proxyMenu(ctx);
+ case "restore":
+ return await restoreMenu(ctx);
+ case "devices":
+ return await devicesMenu(ctx);
+ case "guard":
+ return await guardMenu(ctx);
+ case "lang":
+ return await languageMenu(ctx);
+ }
+ } catch (error) {
+ write();
+ fail((error as Error).message);
+ write();
+ await ctx.ui.pause(t("pressKey"));
+ }
+ return "stay";
+}
+
+function settings(ctx: Ctx): Settings {
+ return loadSettings(ctx.roots.config);
+}
+
+function save(ctx: Ctx, mutate: (s: Settings) => void): Settings {
+ const s = settings(ctx);
+ mutate(s);
+ saveSettings(s, ctx.roots.config);
+ return s;
+}
+
+function onOff(value: boolean): string {
+ return value ? paint(t("on"), style.green) : paint(t("off"), style.muted);
+}
+
+// ── Header ──────────────────────────────────────────────────────────────────
+
+function header(ctx: Ctx, s: Settings): void {
+ banner();
+ keyValue(t("hdrRepo"), paint(repoLabel(s) ?? "-", style.text));
+ keyValue(t("hdrMachine"), paint(hostAlias(s.machineAlias), style.text));
+ const last = lastCommitTime(ctx.roots);
+ keyValue(
+ t("hdrLastSync"),
+ last ? `${formatTime(last)} ${paint(ago(last, ctx.now()), style.faint)}` : t("never"),
+ );
+
+ const now = ctx.now();
+ let auto = paint(t("off"), style.muted);
+ if (s.autosync.enabled) {
+ if (s.autosync.pausedUntil > now) {
+ auto = paint(
+ t("hdrAutosyncPaused", { time: formatTime(s.autosync.pausedUntil) }),
+ style.yellow,
+ );
+ } else {
+ const next = nextRunEstimate(ctx.roots, s, now);
+ const nextText = next
+ ? t("inMinutes", { n: Math.max(1, Math.round((next - now) / 60000)) })
+ : t("soon");
+ auto = paint(
+ t("hdrAutosyncOn", { n: s.autosync.intervalMinutes, next: nextText }),
+ style.green,
+ );
+ }
+ }
+ keyValue(t("hdrAutosync"), auto);
+
+ const state = readState(ctx.roots);
+ if (state.lastError && state.lastFailureAt) {
+ const failures = state.consecutiveFailures ?? 0;
+ keyValue(
+ t("hdrLastFailure"),
+ paint(
+ `${ago(state.lastFailureAt, now)} · ${t("hdrFailures", { n: failures, error: truncate(state.lastError, 60) })}`,
+ style.red,
+ ),
+ );
+ }
+ write();
+}
+
+function truncate(text: string, max: number): string {
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
+}
+
+// ── First run ───────────────────────────────────────────────────────────────
+
+async function firstRun(ctx: Ctx): Promise {
+ banner();
+ info(t("firstTitle"));
+ write();
+ const mode = await ctx.ui.select("", [
+ { value: "cli", label: t("firstInstallOnly"), key: "1" },
+ { value: "link", label: t("firstLink"), key: "2" },
+ { value: "primary", label: t("firstPrimary"), key: "3", hint: t("recommended") },
+ { value: "quit", label: t("quit"), key: "q" },
+ ]);
+ if (mode === undefined || mode === "quit") return "exit";
+ if (mode === "cli") {
+ ok(t("installOnlyDone"));
+ write();
+ return "exit";
+ }
+ const linked = await linkFlow(ctx);
+ if (!linked) return "exit";
+ const resolved = await resolveFirstSync(ctx);
+ if (!resolved) return "exit";
+ if (mode === "primary") await enableAutosync(ctx);
+ return "stay";
+}
+
+async function linkFlow(ctx: Ctx): Promise {
+ const how = await ctx.ui.select(t("linkTitle"), [
+ { value: "create", label: t("linkCreate"), key: "1" },
+ { value: "existing", label: t("linkExisting"), key: "2" },
+ { value: "back", label: t("back"), key: "b" },
+ ]);
+ if (how === undefined || how === "back") return false;
+ const reporter = createCliReporter();
+
+ if (how === "create") {
+ if (!ghAvailable()) {
+ warn(t("linkNeedGh"));
+ return false;
+ }
+ const name = await ctx.ui.text(t("linkAskName"), "my-opencode-config");
+ if (name === undefined) return false;
+ const result = initSync({ repo: name, reporter, roots: ctx.roots });
+ ok(t("linkDone", { repo: `${result.target.owner}/${result.target.name}` }));
+ return true;
+ }
+
+ let target: string | undefined;
+ const found = safe(() => ctx.discover(), [] as string[]);
+ if (found.length > 0) {
+ const choices: Choice[] = found.slice(0, 9).map((r, i) => ({
+ value: r,
+ label: r,
+ key: String(i + 1),
+ }));
+ choices.push({ value: "", label: t("linkOther"), key: "o" });
+ target = await ctx.ui.select(t("linkFound"), choices);
+ if (target === undefined) return false;
+ }
+ if (!target) {
+ target = await ctx.ui.text(t("linkAskExisting"));
+ if (!target) return false;
+ }
+ const result = isRepoUrl(target)
+ ? linkByUrl({ url: target, reporter, roots: ctx.roots })
+ : linkSync({ repo: target, reporter, roots: ctx.roots });
+ ok(
+ t("linkDone", {
+ repo: result.target.owner ? `${result.target.owner}/${result.target.name}` : target,
+ }),
+ );
+ return true;
+}
+
+function safe(fn: () => T, fallback: T): T {
+ try {
+ return fn();
+ } catch {
+ return fallback;
+ }
+}
+
+function defaultDiscover(): string[] {
+ return ghAvailable() ? discoverSyncRepos() : [];
+}
+
+/**
+ * The first sync on a newly linked machine. When the repository and the
+ * machine differ the user picks a side; nothing is replaced silently.
+ */
+async function resolveFirstSync(ctx: Ctx, prefer?: "repo" | "local"): Promise {
+ const s = settings(ctx);
+ const remote = repoUrl(s)!;
+ if (fs.existsSync(path.join(ctx.roots.config, ".git"))) {
+ // Already a working copy (re-link): an ordinary sync handles it.
+ await runSync(ctx, "both");
+ return true;
+ }
+ const comparison = compareWithRemote(ctx.roots.config, remote, repoBranch(s), s.guard.skipDirs);
+ if (comparison.empty) {
+ info(t("resolveEmpty"));
+ await runSync(ctx, "push");
+ return true;
+ }
+ if (comparison.differences.length === 0) {
+ info(t("resolveSame"));
+ await runSync(ctx, "receive");
+ return true;
+ }
+
+ for (;;) {
+ let choice: string | undefined = prefer;
+ if (!choice) {
+ write();
+ warn(t("resolveTitle", { n: comparison.differences.length }));
+ choice = await ctx.ui.select("", [
+ { value: "repo", label: t("resolveRepo"), key: "1" },
+ { value: "local", label: t("resolveLocal"), key: "2" },
+ { value: "diff", label: t("resolveDiff"), key: "3" },
+ { value: "back", label: t("back"), key: "b" },
+ ]);
+ }
+ if (choice === undefined || choice === "back") return false;
+ if (choice === "diff") {
+ const labels: Record = {
+ onlyLocal: "diffOnlyHere",
+ different: "diffDifferent",
+ onlyRemote: "diffOnlyRepo",
+ };
+ for (const d of comparison.differences.slice(0, 40)) {
+ detail(`${paint(t(labels[d.kind]!).padEnd(12), style.yellow)} ${d.path}`);
+ }
+ if (comparison.differences.length > 40) {
+ detail(t("diffMore", { n: comparison.differences.length - 40 }));
+ }
+ continue;
+ }
+ if (choice === "repo") {
+ await runSync(ctx, "receive");
+ const latest = listRecovery(ctx.roots)[0];
+ if (latest) info(t("recoverySaved", { dir: latest }));
+ return true;
+ }
+ await runSync(ctx, "push", { adopt: "local" });
+ return true;
+ }
+}
+
+// ── Sync ────────────────────────────────────────────────────────────────────
+
+async function syncMenu(ctx: Ctx): Promise {
+ const choice = await ctx.ui.select(t("syncTitle"), [
+ { value: "both", label: t("syncBoth"), key: "1" },
+ { value: "receive", label: t("syncReceive"), key: "2" },
+ { value: "push", label: t("syncPush"), key: "3" },
+ { value: "preview", label: t("syncPreview"), key: "4" },
+ { value: "back", label: t("back"), key: "b" },
+ ]);
+ if (choice === undefined || choice === "back") return "stay";
+ if (choice === "preview") {
+ const go = await preview(ctx);
+ if (go) await runSync(ctx, "both");
+ } else {
+ await runSync(ctx, choice as "both" | "receive" | "push");
+ }
+ await ctx.ui.pause(t("pressKey"));
+ return "stay";
+}
+
+async function confirmLarge(ctx: Ctx, report: GuardReport): Promise {
+ const limits = settings(ctx).guard;
+ write();
+ warn(t("largeTitle"));
+ if (report.reasons.includes("files"))
+ detail(t("largeFiles", { n: report.addedFiles, limit: limits.maxFiles }));
+ if (report.reasons.includes("bytes")) {
+ detail(
+ t("largeBytes", {
+ size: formatBytes(report.totalBytes),
+ limit: formatBytes(limits.maxBytes),
+ }),
+ );
+ }
+ for (const f of report.oversized.slice(0, 5)) {
+ detail(
+ t("largeFile", {
+ path: f.path,
+ size: formatBytes(f.bytes),
+ limit: formatBytes(limits.maxFileBytes),
+ }),
+ );
+ }
+ return ctx.ui.confirm(t("largeConfirm"), false);
+}
+
+async function runSync(
+ ctx: Ctx,
+ mode: "both" | "receive" | "push",
+ extra: { adopt?: "local" | "remote"; force?: boolean } = {},
+): Promise {
+ const results: SyncResult[] = [];
+ const reporter = createCliReporter();
+ await withSyncLock(ctx.roots.config, { waitMs: 60_000 }, async () => {
+ if (mode !== "receive") {
+ try {
+ results.push(
+ await push({
+ roots: ctx.roots,
+ reporter,
+ force: extra.force,
+ adopt: extra.adopt,
+ confirmLarge: (r) => confirmLarge(ctx, r),
+ }),
+ );
+ } catch (error) {
+ if ((error as Error).name === "LargeChangeError") {
+ warn(t("largeRefused"));
+ return;
+ }
+ throw error;
+ }
+ }
+ if (mode !== "push") {
+ results.push(
+ await pull({ roots: ctx.roots, reporter, force: mode === "receive" && extra.force }),
+ );
+ }
+ });
+ for (const r of results) outcome(r.message, r.changed);
+ if (results.some((r) => r.restartRequired && r.changed)) info(t("restartHint"));
+ return results;
+}
+
+async function preview(ctx: Ctx): Promise {
+ const reporter = new CollectingReporter();
+ const out = await withSyncLock(ctx.roots.config, { waitMs: 60_000 }, () =>
+ push({ roots: ctx.roots, reporter, dryRun: true }),
+ );
+ let incoming: SyncResult | undefined;
+ try {
+ incoming = await withSyncLock(ctx.roots.config, { waitMs: 60_000 }, () =>
+ pull({ roots: ctx.roots, reporter, dryRun: true }),
+ );
+ } catch {
+ incoming = undefined;
+ }
+ const outFiles = out.files;
+ const inFiles = incoming?.files ?? [];
+ if (outFiles.length === 0 && inFiles.length === 0) {
+ ok(t("previewNothing"));
+ return false;
+ }
+ if (outFiles.length > 0) {
+ heading(t("previewOutgoing", { n: outFiles.length }));
+ write(` ${summaryLine(out.summary)}`);
+ changeList(outFiles);
+ }
+ if (inFiles.length > 0 && incoming) {
+ heading(t("previewIncoming", { n: inFiles.length }));
+ write(` ${summaryLine(incoming.summary)}`);
+ changeList(inFiles);
+ }
+ write();
+ return ctx.ui.confirm(t("previewConfirm"), true);
+}
+
+// ── What to sync ────────────────────────────────────────────────────────────
+
+type CatValue =
+ | "config"
+ | "instructions"
+ | "agents"
+ | "commands"
+ | "plugins"
+ | "skills"
+ | "credentials"
+ | "sessions";
+
+async function whatMenu(ctx: Ctx): Promise {
+ for (;;) {
+ const choice = await ctx.ui.select(t("whatTitle"), [
+ { value: "cats", label: t("whatCategories"), key: "1" },
+ { value: "sessions", label: t("whatSessions"), key: "2" },
+ { value: "extra", label: t("whatExtra"), key: "3" },
+ { value: "back", label: t("back"), key: "b" },
+ ]);
+ if (choice === undefined || choice === "back") return "stay";
+ if (choice === "cats") await categoriesMenu(ctx);
+ if (choice === "sessions") await sessionSettings(ctx);
+ if (choice === "extra") await extraPathsMenu(ctx);
+ }
+}
+
+/** Whether the linked repository is confirmed private, public, or unknown. */
+function repoVisibility(s: Settings): "private" | "public" | "unknown" {
+ const target =
+ s.repo.owner && s.repo.name
+ ? { owner: s.repo.owner, name: s.repo.name }
+ : s.repo.url
+ ? githubTargetFromUrl(s.repo.url)
+ : undefined;
+ if (!target || !ghAvailable()) return "unknown";
+ return repoIsPrivate(target) ? "private" : "public";
+}
+
+async function categoriesMenu(ctx: Ctx): Promise {
+ const s = settings(ctx);
+ const choices: Choice[] = [
+ { value: "config", label: t("catConfig") },
+ { value: "instructions", label: t("catInstructions") },
+ { value: "agents", label: t("catAgents") },
+ { value: "commands", label: t("catCommands") },
+ { value: "plugins", label: t("catPlugins") },
+ { value: "skills", label: t("catSkills") },
+ { value: "credentials", label: t("catCredentials") },
+ { value: "sessions", label: t("catSessions") },
+ ];
+ const selected = new Set();
+ for (const key of ["config", "instructions", "agents", "commands", "plugins"] as const) {
+ if (s.categories[key]) selected.add(key);
+ }
+ if (s.includeSkills) selected.add("skills");
+ if (s.includeCredentials) selected.add("credentials");
+ if (s.sessions.enabled) selected.add("sessions");
+
+ const footer = ctx.ui.io.interactive ? t("checkboxKeys") : t("checkboxLines");
+ const result = await ctx.ui.checkbox(t("whatCategories"), choices, selected, footer);
+ if (!result) {
+ info(t("cancelled"));
+ return;
+ }
+
+ // Credentials and sessions are private data: never to a public repository.
+ for (const sensitive of ["credentials", "sessions"] as const) {
+ if (!result.has(sensitive) || selected.has(sensitive)) continue;
+ const visibility = repoVisibility(s);
+ if (visibility === "public") {
+ fail(t("credPublic"));
+ result.delete(sensitive);
+ continue;
+ }
+ if (visibility === "unknown") {
+ warn(t("credUnverified"));
+ if (!(await ctx.ui.confirm(t("credConfirm"), false))) result.delete(sensitive);
+ }
+ if (sensitive === "sessions" && result.has("sessions")) {
+ warn(t("sessionsWarn"));
+ if (!(await sqliteAvailable())) warn(t("sessionsNeedNode"));
+ }
+ }
+
+ save(ctx, (n) => {
+ for (const key of ["config", "instructions", "agents", "commands", "plugins"] as const) {
+ n.categories[key] = result.has(key);
+ }
+ n.includeSkills = result.has("skills");
+ n.includeCredentials = result.has("credentials");
+ n.sessions.enabled = result.has("sessions");
+ });
+ ok(t("saved"));
+}
+
+async function askNumber(
+ ctx: Ctx,
+ question: string,
+ current: number,
+ min = 1,
+): Promise {
+ const answer = await ctx.ui.text(question, String(current));
+ if (answer === undefined) return undefined;
+ const value = Number(answer);
+ if (!Number.isFinite(value) || value < min) {
+ warn(t("invalidValue", { value: answer }));
+ return undefined;
+ }
+ return value;
+}
+
+async function sessionSettings(ctx: Ctx): Promise {
+ const s = settings(ctx);
+ warn(t("sessionsWarn"));
+ const days = await askNumber(ctx, t("sessionsDays"), s.sessions.days);
+ if (days === undefined) return;
+ const max = await askNumber(ctx, t("sessionsMax"), s.sessions.maxSessions);
+ if (max === undefined) return;
+ const mb = await askNumber(
+ ctx,
+ t("sessionsMb"),
+ Math.round(s.sessions.maxSessionBytes / 1048576),
+ );
+ if (mb === undefined) return;
+ save(ctx, (n) => {
+ n.sessions.days = Math.floor(days);
+ n.sessions.maxSessions = Math.floor(max);
+ n.sessions.maxSessionBytes = Math.floor(mb * 1048576);
+ });
+ ok(t("saved"));
+}
+
+async function extraPathsMenu(ctx: Ctx): Promise {
+ for (;;) {
+ const s = settings(ctx);
+ if (s.extraPaths.length === 0) detail(t("extraEmpty"));
+ const choices: Choice[] = [{ value: "+add", label: t("extraAdd"), key: "a" }];
+ s.extraPaths.forEach((p, i) =>
+ choices.push({ value: p, label: t("extraRemove", { path: p }), key: String(i + 1) }),
+ );
+ choices.push({ value: "+back", label: t("back"), key: "b" });
+ const choice = await ctx.ui.select(t("extraTitle"), choices);
+ if (choice === undefined || choice === "+back") return;
+ if (choice === "+add") {
+ const value = await ctx.ui.text(t("extraAsk"));
+ if (!value) continue;
+ save(ctx, (n) => {
+ if (!n.extraPaths.includes(value)) n.extraPaths.push(value);
+ });
+ ok(t("saved"));
+ } else {
+ save(ctx, (n) => {
+ n.extraPaths = n.extraPaths.filter((p) => p !== choice);
+ });
+ ok(t("saved"));
+ }
+ }
+}
+
+// ── Autosync ────────────────────────────────────────────────────────────────
+
+export async function enableAutosync(ctx: Pick): Promise {
+ const s = loadSettings(ctx.roots.config);
+ s.autosync.enabled = true;
+ saveSettings(s, ctx.roots.config);
+ const result = ctx.scheduler.install(ctx.roots, s);
+ if (!result.ok) {
+ fail(t("autoInstallFailed", { error: result.detail.join("; ") }));
+ return false;
+ }
+ for (const w of result.warnings) warn(t("autoWarning", { text: w }));
+ ok(t("autoOn", { n: s.autosync.intervalMinutes }));
+ return true;
+}
+
+export function disableAutosync(ctx: Pick): void {
+ const s = loadSettings(ctx.roots.config);
+ s.autosync.enabled = false;
+ saveSettings(s, ctx.roots.config);
+ ctx.scheduler.uninstall(ctx.roots);
+ ok(t("autoOff"));
+}
+
+async function autosyncMenu(ctx: Ctx): Promise {
+ for (;;) {
+ const s = settings(ctx);
+ const choice = await ctx.ui.select(t("autoTitle"), [
+ s.autosync.enabled
+ ? { value: "off", label: t("autoTurnOff"), key: "1" }
+ : { value: "on", label: t("autoTurnOn"), key: "1" },
+ { value: "interval", label: t("autoInterval", { n: s.autosync.intervalMinutes }), key: "2" },
+ {
+ value: "loggedoff",
+ label: t("autoLoggedOff", { state: onOff(s.autosync.runWhenLoggedOff) }),
+ key: "3",
+ },
+ { value: "pause", label: t("autoPause"), key: "4" },
+ {
+ value: "notify",
+ label: t("autoNotifications", { state: onOff(s.notifications) }),
+ key: "5",
+ },
+ { value: "reset", label: t("autoReset"), key: "6" },
+ { value: "back", label: t("back"), key: "b" },
+ ]);
+ if (choice === undefined || choice === "back") return "stay";
+ if (choice === "on") await enableAutosync(ctx);
+ if (choice === "off") disableAutosync(ctx);
+ if (choice === "interval") await intervalMenu(ctx);
+ if (choice === "loggedoff") await loggedOffMenu(ctx);
+ if (choice === "pause") await pauseMenu(ctx);
+ if (choice === "notify") {
+ save(ctx, (n) => {
+ n.notifications = !n.notifications;
+ });
+ ok(t("saved"));
+ }
+ if (choice === "reset") {
+ const before = readState(ctx.roots).consecutiveFailures ?? 0;
+ writeState(ctx.roots, {
+ consecutiveFailures: 0,
+ lastError: null,
+ failureNotifiedAt: null,
+ lastAttemptAt: 0,
+ });
+ ok(t("resetDone", { n: before }));
+ }
+ }
+}
+
+async function intervalMenu(ctx: Ctx): Promise {
+ const presets = [5, 10, 15, 30, 60];
+ const choices: Choice[] = presets.map((n, i) => ({
+ value: n,
+ label: t("intervalMinutes", { n }),
+ key: String(i + 1),
+ }));
+ choices.push({ value: -1, label: t("intervalCustom"), key: "c" });
+ let minutes = await ctx.ui.select(t("intervalAsk"), choices);
+ if (minutes === undefined) return;
+ if (minutes === -1) {
+ const custom = await askNumber(
+ ctx,
+ t("intervalAsk"),
+ settings(ctx).autosync.intervalMinutes,
+ MIN_AUTOSYNC_INTERVAL,
+ );
+ if (custom === undefined) return;
+ minutes = Math.floor(custom);
+ }
+ const s = save(ctx, (n) => {
+ n.autosync.intervalMinutes = minutes!;
+ });
+ if (s.autosync.enabled) await enableAutosync(ctx);
+ else ok(t("saved"));
+}
+
+async function loggedOffMenu(ctx: Ctx): Promise {
+ if (process.platform !== "win32") {
+ info(t("notWindows"));
+ }
+ info(t("loggedOffExplain"));
+ const s = save(ctx, (n) => {
+ n.autosync.runWhenLoggedOff = !n.autosync.runWhenLoggedOff;
+ });
+ ok(t("autoLoggedOff", { state: onOff(s.autosync.runWhenLoggedOff) }));
+ if (s.autosync.runWhenLoggedOff && process.platform === "win32" && !ctx.scheduler.elevated()) {
+ warn(t("loggedOffNeedAdmin"));
+ return;
+ }
+ if (s.autosync.enabled) await enableAutosync(ctx);
+}
+
+async function pauseMenu(ctx: Ctx): Promise {
+ const now = ctx.now();
+ const choice = await ctx.ui.select(t("autoPause"), [
+ { value: now + 3600_000, label: t("pause1h"), key: "1" },
+ { value: now + 4 * 3600_000, label: t("pause4h"), key: "2" },
+ { value: tomorrowMorning(now), label: t("pauseTomorrow"), key: "3" },
+ { value: 0, label: t("pauseResume"), key: "4" },
+ ]);
+ if (choice === undefined) return;
+ save(ctx, (n) => {
+ n.autosync.pausedUntil = choice;
+ });
+ ok(choice ? t("pausedUntil", { time: formatTime(choice) }) : t("resumed"));
+}
+
+// ── Update ──────────────────────────────────────────────────────────────────
+
+async function updateMenu(ctx: Ctx): Promise {
+ for (;;) {
+ const s = settings(ctx);
+ const choice = await ctx.ui.select(t("updTitle"), [
+ { value: "check", label: t("updCheck"), key: "1" },
+ { value: "upgrade", label: t("updUpgrade"), key: "2" },
+ { value: "auto", label: t("updAuto", { state: onOff(s.autosync.autoUpdate) }), key: "3" },
+ { value: "back", label: t("back"), key: "b" },
+ ]);
+ if (choice === undefined || choice === "back") return "stay";
+ if (choice === "check") {
+ const result = ctx.updater.check();
+ if (result.error) fail(t("updFailed", { error: result.error }));
+ else {
+ info(t("updVersions", { current: result.current, latest: result.latest ?? "?" }));
+ ok(result.newer ? t("updNewer") : t("updLatest"));
+ }
+ }
+ if (choice === "upgrade") {
+ info(t("updRunning"));
+ ctx.updater.install();
+ ok(t("updDone"));
+ return "exit";
+ }
+ if (choice === "auto") {
+ save(ctx, (n) => {
+ n.autosync.autoUpdate = !n.autosync.autoUpdate;
+ });
+ ok(t("saved"));
+ }
+ }
+}
+
+// ── Overrides ───────────────────────────────────────────────────────────────
+
+function defaultEditor(file: string): boolean {
+ const editor =
+ process.env.VISUAL || process.env.EDITOR || (process.platform === "win32" ? "notepad.exe" : "");
+ if (editor) {
+ const result = spawnSync(editor, [file], {
+ stdio: "inherit",
+ shell: !path.isAbsolute(editor) && /\s/.test(editor),
+ });
+ return !result.error;
+ }
+ const opener = process.platform === "darwin" ? "open" : "xdg-open";
+ return !spawnSync(opener, [file], { stdio: "ignore" }).error;
+}
+
+function flatten(value: unknown, prefix = ""): [string, unknown][] {
+ if (value && typeof value === "object" && !Array.isArray(value)) {
+ const entries = Object.entries(value as Record);
+ if (entries.length === 0) return [[prefix, value]];
+ return entries.flatMap(([k, v]) => flatten(v, prefix ? `${prefix}.${k}` : k));
+ }
+ return [[prefix, value]];
+}
+
+function lookup(obj: unknown, dotted: string): unknown {
+ return dotted
+ .split(".")
+ .reduce(
+ (acc, k) =>
+ acc && typeof acc === "object" ? (acc as Record)[k] : undefined,
+ obj,
+ );
+}
+
+async function overridesMenu(ctx: Ctx): Promise {
+ const file = overridesPath(ctx.roots.config);
+ for (;;) {
+ const choice = await ctx.ui.select(t("ovTitle"), [
+ { value: "open", label: t("ovOpen"), key: "1" },
+ { value: "show", label: t("ovShow"), key: "2" },
+ { value: "back", label: t("back"), key: "b" },
+ ]);
+ if (choice === undefined || choice === "back") return "stay";
+ if (choice === "open") {
+ if (!fs.existsSync(file)) {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, OVERRIDES_TEMPLATE);
+ }
+ if (!ctx.openEditor(file)) warn(t("ovEditorFailed", { file }));
+ }
+ if (choice === "show") showOverrides(ctx, file);
+ }
+}
+
+function showOverrides(ctx: Ctx, file: string): void {
+ detail(t("ovFile", { file }));
+ const overrides = loadOverrides(ctx.roots.config);
+ if (Object.keys(overrides).length === 0) {
+ info(t("ovNone"));
+ return;
+ }
+ let shared: unknown;
+ const configFile = findConfigFile(ctx.roots.config);
+ if (configFile) {
+ const rel = path.relative(ctx.roots.config, configFile).split(path.sep).join("/");
+ const head = git(["show", `HEAD:${rel}`], { cwd: ctx.roots.config });
+ if (head.ok) shared = safe(() => parseJsonc(head.stdout), undefined);
+ }
+ for (const [key, value] of flatten(overrides)) {
+ const before = lookup(shared, key);
+ const after =
+ value === null ? paint(t("ovRemoved"), style.red) : paint(JSON.stringify(value), style.green);
+ write(
+ ` ${paint(key, style.cyan)} ${paint(JSON.stringify(before) ?? "-", style.faint)} → ${after}`,
+ );
+ }
+}
+
+// ── Diagnostics ─────────────────────────────────────────────────────────────
+
+function toolVersion(cmd: string, args: string[]): string | undefined {
+ const result = spawnSync(cmd, args, { encoding: "utf8", timeout: 20_000, windowsHide: true });
+ if (result.error || result.status !== 0) return undefined;
+ return String(result.stdout).split(/\r?\n/)[0]?.trim();
+}
+
+export function collectDiagnostics(ctx: Pick): [string, string][] {
+ const rows: [string, string][] = [];
+ rows.push([t("diagNode"), process.version]);
+ const major = Number(process.versions.node.split(".")[0]);
+ const minor = Number(process.versions.node.split(".")[1]);
+ if (major < 22 || (major === 22 && minor < 5)) rows.push(["", t("diagSessionsNode")]);
+ rows.push([t("diagGit"), toolVersion("git", ["--version"]) ?? t("diagMissing")]);
+ rows.push([t("diagGh"), toolVersion("gh", ["--version"]) ?? t("diagMissing")]);
+ const s = safe(() => loadSettings(ctx.roots.config), undefined);
+ const remote = s ? repoUrl(s) : undefined;
+ if (remote) {
+ const probe = git(["ls-remote", "--heads", remote], {
+ cwd: ctx.roots.config,
+ timeoutMs: 60_000,
+ });
+ rows.push([
+ t("diagNetwork"),
+ probe.ok
+ ? t("diagOk")
+ : t("diagFail", { error: (probe.stderr.split("\n")[0] ?? "").slice(0, 120) }),
+ ]);
+ }
+ const sched = safe(() => ctx.scheduler.status(), { installed: false, kind: "none" as const });
+ rows.push([
+ t("diagScheduler"),
+ sched.installed
+ ? `${t("diagRegistered", { kind: sched.kind })}${sched.nextRun ? ` · ${sched.nextRun}` : ""}`
+ : t("diagNotRegistered"),
+ ]);
+ if (fs.existsSync(path.join(ctx.roots.config, ".git"))) {
+ const ignored = git(["check-ignore", "-q", "opencode-sync.jsonc"], { cwd: ctx.roots.config });
+ rows.push([t("diagSettings"), ignored.ok ? t("diagIgnored") : t("diagNotIgnored")]);
+ }
+ return rows;
+}
+
+async function diagnosticsMenu(ctx: Ctx): Promise {
+ heading(t("diagTitle"));
+ for (const [k, v] of collectDiagnostics(ctx)) keyValue(k, v, 20);
+ heading(t("diagLog"));
+ for (const line of tailLog(ctx.roots, 15)) detail(line);
+ write();
+ const choice = await ctx.ui.select("", [
+ { value: "bundle", label: t("diagBundle"), key: "1" },
+ { value: "back", label: t("back"), key: "b" },
+ ]);
+ if (choice === "bundle") {
+ const file = exportBundle(ctx);
+ ok(t("bundleWritten", { file }));
+ }
+ return "stay";
+}
+
+export function exportBundle(ctx: Pick): string {
+ const s = safe(() => loadSettings(ctx.roots.config), undefined);
+ const entries = [
+ {
+ name: "diagnostics.txt",
+ content: collectDiagnostics(ctx)
+ .map(([k, v]) => `${k.padEnd(20)} ${v}`)
+ .concat([
+ `platform ${process.platform} ${process.arch}`,
+ `lang ${getLang()}`,
+ ])
+ .join("\n"),
+ },
+ { name: "settings.json", content: JSON.stringify(redactValue(s ?? {}), null, 2) },
+ {
+ name: "autosync-state.json",
+ content: JSON.stringify(redactValue(readState(ctx.roots)), null, 2),
+ },
+ { name: "autosync.log", content: tailLog(ctx.roots, 400).join("\n") },
+ ];
+ const gitLog = git(["log", "-20", "--format=%h %cI %s"], { cwd: ctx.roots.config });
+ if (gitLog.ok) entries.push({ name: "git-log.txt", content: gitLog.stdout });
+ const status = git(["status", "--short"], { cwd: ctx.roots.config });
+ if (status.ok) entries.push({ name: "git-status.txt", content: status.stdout });
+ return writeBundle(ctx.cwd, entries);
+}
+
+// ── Danger zone ─────────────────────────────────────────────────────────────
+
+const CONFIRM_WORD = "overwrite";
+
+async function dangerMenu(ctx: Ctx): Promise {
+ const choice = await ctx.ui.select(t("dangerTitle"), [
+ { value: "pull", label: t("dangerPull"), key: "1" },
+ { value: "push", label: t("dangerPush"), key: "2" },
+ { value: "back", label: t("back"), key: "b" },
+ ]);
+ if (choice === undefined || choice === "back") return "stay";
+ warn(choice === "pull" ? t("dangerWarnPull") : t("dangerWarnPush"));
+ if (!(await ctx.ui.confirmWord(t("dangerType"), CONFIRM_WORD))) {
+ info(t("cancelled"));
+ return "stay";
+ }
+ await runSync(ctx, choice === "pull" ? "receive" : "push", { force: true });
+ await ctx.ui.pause(t("pressKey"));
+ return "stay";
+}
+
+// ── Uninstall ───────────────────────────────────────────────────────────────
+
+async function uninstallMenu(ctx: Ctx): Promise {
+ heading(t("unTitle"));
+ info(t("unKeep"));
+ const removed = ctx.scheduler.uninstall(ctx.roots);
+ ok(removed.removed ? t("unSchedulerRemoved") : t("unSchedulerNone"));
+ save(ctx, (n) => {
+ n.autosync.enabled = false;
+ });
+ if (await ctx.ui.confirm(t("unUnlink"), false)) {
+ unlinkSync(ctx.roots);
+ ok(t("unUnlinked"));
+ }
+ if (await ctx.ui.confirm(t("unNpm"), false)) {
+ ctx.updater.uninstall();
+ ok(t("unNpmDone"));
+ return "exit";
+ }
+ return repoUrl(settings(ctx)) ? "stay" : "exit";
+}
+
+// ── Extras ──────────────────────────────────────────────────────────────────
+
+async function proxyMenu(ctx: Ctx): Promise {
+ const s = settings(ctx);
+ info(t("proxyCurrent", { proxy: s.proxy || t("none") }));
+ const value = await ctx.ui.text(t("proxyAsk"));
+ if (value === undefined) return "stay";
+ if (value && !isValidProxy(value)) {
+ fail(t("proxyInvalid", { value }));
+ return "stay";
+ }
+ const saved = save(ctx, (n) => {
+ n.proxy = value;
+ });
+ applyLocalSettings(saved);
+ ok(value ? t("proxySet") : t("proxyCleared"));
+ return "stay";
+}
+
+async function restoreMenu(ctx: Ctx): Promise {
+ const filter = await ctx.ui.text(t("rsAsk"));
+ if (filter === undefined) return "stay";
+ const files = trackedFiles(ctx.roots.config, filter).slice(0, 30);
+ if (files.length === 0) {
+ warn(t("rsNoMatch"));
+ return "stay";
+ }
+ const file = await ctx.ui.select(
+ t("rsPickFile"),
+ files.map((f, i) => ({ value: f, label: f, key: String(i + 1) })),
+ );
+ if (!file) return "stay";
+ const versions = fileHistory(ctx.roots.config, file, 20);
+ if (versions.length === 0) {
+ warn(t("rsNoHistory"));
+ return "stay";
+ }
+ const commit = await ctx.ui.select(
+ t("rsPickVersion"),
+ versions.map((v, i) => ({
+ value: v.commit,
+ label: `${formatTime(Date.parse(v.date))} ${v.commit.slice(0, 8)}`,
+ hint: v.subject,
+ key: String(i + 1),
+ })),
+ );
+ if (!commit) return "stay";
+ const version = versions.find((v) => v.commit === commit)!;
+ if (
+ !(await ctx.ui.confirm(
+ t("rsConfirm", { file, date: formatTime(Date.parse(version.date)) }),
+ false,
+ ))
+ ) {
+ info(t("cancelled"));
+ return "stay";
+ }
+ const result = restoreFile(ctx.roots, file, commit);
+ ok(t("rsDone", { file }));
+ if (result.recovery) info(t("recoverySaved", { dir: result.recovery }));
+ if (await ctx.ui.confirm(t("rsPushNow"), false)) await runSync(ctx, "push");
+ return "stay";
+}
+
+async function devicesMenu(ctx: Ctx): Promise {
+ const s = settings(ctx);
+ const me = hostAlias(s.machineAlias);
+ const devices = listDevices(ctx.roots.config);
+ heading(t("devTitle"));
+ if (devices.length === 0) detail(t("devNone"));
+ for (const d of devices) {
+ const when = ago(Date.parse(d.lastSync), ctx.now());
+ const tag = d.alias === me ? paint(` (${t("devThis")})`, style.accent) : "";
+ write(
+ ` ${paint(d.alias.padEnd(24), style.text)} ${paint(t("devRow", { when, n: d.commits }), style.muted)}${tag}`,
+ );
+ }
+ write();
+ const choice = await ctx.ui.select("", [
+ { value: "rename", label: t("devRename"), key: "1" },
+ { value: "back", label: t("back"), key: "b" },
+ ]);
+ if (choice === "rename") {
+ const name = await ctx.ui.text(t("devAsk"), me);
+ if (!name) return "stay";
+ const alias = sanitizeAlias(name);
+ save(ctx, (n) => {
+ n.machineAlias = alias;
+ });
+ ok(t("devRenamed", { alias }));
+ }
+ return "stay";
+}
+
+async function guardMenu(ctx: Ctx): Promise {
+ for (;;) {
+ const g = settings(ctx).guard;
+ const choice = await ctx.ui.select(t("guardTitle"), [
+ { value: "files", label: t("guardFiles", { n: g.maxFiles }), key: "1" },
+ { value: "bytes", label: t("guardBytes", { size: formatBytes(g.maxBytes) }), key: "2" },
+ { value: "file", label: t("guardFile", { size: formatBytes(g.maxFileBytes) }), key: "3" },
+ { value: "skip", label: t("guardSkip", { dirs: g.skipDirs.join(", ") }), key: "4" },
+ { value: "back", label: t("back"), key: "b" },
+ ]);
+ if (choice === undefined || choice === "back") return "stay";
+ if (choice === "files") {
+ const n = await askNumber(ctx, t("guardAskNumber"), g.maxFiles);
+ if (n !== undefined) {
+ save(ctx, (s) => {
+ s.guard.maxFiles = Math.floor(n);
+ });
+ }
+ }
+ if (choice === "bytes" || choice === "file") {
+ const key = choice === "bytes" ? "maxBytes" : "maxFileBytes";
+ const n = await askNumber(ctx, t("guardAskMb"), Math.round(g[key] / 1048576));
+ if (n !== undefined) {
+ save(ctx, (s) => {
+ s.guard[key] = Math.floor(n * 1048576);
+ });
+ }
+ }
+ if (choice === "skip") {
+ const value = await ctx.ui.text(t("guardAskSkip"), g.skipDirs.join(", "));
+ if (value !== undefined) {
+ const dirs = value
+ .split(",")
+ .map((d) => d.trim())
+ .filter(Boolean);
+ save(ctx, (s) => {
+ s.guard.skipDirs = dirs;
+ });
+ }
+ }
+ }
+}
+
+async function languageMenu(ctx: Ctx): Promise {
+ const choice = await ctx.ui.select(t("langTitle"), [
+ { value: "", label: t("langAuto"), key: "1" },
+ { value: "en", label: "English", key: "2" },
+ { value: "zh-CN", label: "简体中文", key: "3" },
+ ]);
+ if (choice === undefined) return "stay";
+ const s = save(ctx, (n) => {
+ n.language = choice;
+ });
+ applyLocalSettings(s);
+ if (choice) setLang(choice as Lang);
+ ok(t("langSet"));
+ return "stay";
+}
+
+// ── Non-interactive setup (installers) ──────────────────────────────────────
+
+export interface SetupFlags {
+ mode: "cli" | "link" | "auto";
+ repo?: string;
+ interval?: number;
+ lang?: string;
+ proxy?: string;
+ prefer?: "repo" | "local";
+}
+
+/**
+ * The non-interactive path the installers call with `--mode`. Mirrors the
+ * first-run menu: link (creating the repository when `gh` can and it does not
+ * exist), resolve the first sync by `prefer`, and with `auto` turn autosync on.
+ */
+export async function runSetup(
+ flags: SetupFlags,
+ deps: { ui: Ui; roots?: Roots; scheduler?: SchedulerApi; updater?: UpdateApi },
+): Promise {
+ const ctx: Ctx = {
+ ui: deps.ui,
+ roots: deps.roots ?? getRoots(),
+ scheduler: deps.scheduler ?? realScheduler,
+ updater: deps.updater ?? realUpdater,
+ openEditor: defaultEditor,
+ now: Date.now,
+ cwd: process.cwd(),
+ discover: () => [],
+ };
+ banner();
+ // Local-only preferences go into the settings file once it exists (after
+ // linking); before that they only apply to this process.
+ const applyFlags = () => {
+ if (!fs.existsSync(settingsPath(ctx.roots.config))) return;
+ save(ctx, (s) => {
+ if (flags.lang) s.language = flags.lang;
+ if (flags.proxy !== undefined) s.proxy = flags.proxy;
+ if (flags.interval) {
+ s.autosync.intervalMinutes = Math.max(MIN_AUTOSYNC_INTERVAL, Math.floor(flags.interval));
+ }
+ });
+ };
+ const current = safe(() => settings(ctx), undefined);
+ applyLocalSettings(
+ current && flags.proxy !== undefined ? { ...current, proxy: flags.proxy } : current,
+ flags.lang,
+ );
+ if (flags.proxy) setChildProxy(flags.proxy);
+
+ if (flags.mode === "cli") {
+ applyFlags();
+ ok(t("setupCliOnly"));
+ return;
+ }
+ if (!flags.repo) throw new SetupError(t("setupNeedRepo", { mode: flags.mode }));
+
+ const reporter = createCliReporter();
+ if (isRepoUrl(flags.repo)) linkByUrl({ url: flags.repo, reporter, roots: ctx.roots });
+ else {
+ try {
+ linkSync({ repo: flags.repo, reporter, roots: ctx.roots });
+ } catch (error) {
+ if (!/was not found/.test((error as Error).message)) throw error;
+ initSync({ repo: flags.repo, reporter, roots: ctx.roots });
+ }
+ }
+ applyFlags();
+ ok(t("linkDone", { repo: repoLabel(settings(ctx)) ?? flags.repo }));
+
+ await resolveFirstSync(ctx, flags.prefer ?? "repo");
+ if (flags.mode === "auto") {
+ const enabled = await enableAutosync(ctx);
+ if (!enabled) process.exitCode = 1;
+ }
+ appendLog(ctx.roots, `setup: mode=${flags.mode}`);
+ ok(t("setupDone"));
+}
diff --git a/src/cli/render.ts b/src/cli/render.ts
index 34d15a1..fea1929 100644
--- a/src/cli/render.ts
+++ b/src/cli/render.ts
@@ -3,8 +3,15 @@ import { SPINNER, canAnimate, displayWidth, glyph, paint, style } from "./theme.
const INDENT = " ";
+/** Where rendered output goes. Swapped by tests that drive the menu. */
+let sink: { write: (text: string) => unknown } = process.stdout;
+
+export function setOutput(target: { write: (text: string) => unknown }): void {
+ sink = target;
+}
+
export function write(line = ""): void {
- process.stdout.write(`${line}\n`);
+ sink.write(`${line}\n`);
}
/**
diff --git a/src/cli/ui.ts b/src/cli/ui.ts
new file mode 100644
index 0000000..936a3b3
--- /dev/null
+++ b/src/cli/ui.ts
@@ -0,0 +1,409 @@
+import readline from "node:readline";
+import type { Readable, Writable } from "node:stream";
+import { displayWidth, paint, stripAnsi, style } from "./theme.js";
+
+/**
+ * Interactive terminal primitives with no dependencies.
+ *
+ * Two input modes share one interface:
+ *
+ * - raw mode, when the input is a TTY: arrow keys move, space toggles,
+ * enter confirms, a hotkey jumps straight to an item;
+ * - line mode otherwise: each prompt prints numbered choices and reads one
+ * line. This is what runs under `curl | sh`, in CI and in the tests, where
+ * the input is a pipe.
+ *
+ * Every prompt resolves to `undefined` at end of input, so a caller that runs
+ * out of answers backs out instead of hanging.
+ */
+
+export interface Io {
+ input: Readable;
+ output: Writable;
+ /** Use raw-mode key handling. */
+ interactive: boolean;
+}
+
+export interface Choice {
+ value: T;
+ label: string;
+ /** Single character typed to pick this item directly. */
+ key?: string;
+ hint?: string;
+ disabled?: boolean;
+}
+
+interface Key {
+ name?: string;
+ sequence?: string;
+ ctrl?: boolean;
+}
+
+/** Queued reader over a stream: lines in line mode, keys in raw mode. */
+class InputQueue {
+ private lines: string[] = [];
+ private keys: Key[] = [];
+ private waiters: (() => void)[] = [];
+ private ended = false;
+ private buffer = "";
+ private rawDepth = 0;
+
+ constructor(private readonly io: Io) {
+ if (io.interactive) {
+ readline.emitKeypressEvents(io.input);
+ io.input.on("keypress", (str: string, key: Key) => {
+ this.keys.push(key ?? { sequence: str, name: str });
+ this.wake();
+ });
+ }
+ io.input.on("data", (chunk: Buffer | string) => {
+ if (io.interactive) return;
+ this.buffer += chunk.toString();
+ let index = this.buffer.indexOf("\n");
+ while (index !== -1) {
+ this.lines.push(this.buffer.slice(0, index).replace(/\r$/, ""));
+ this.buffer = this.buffer.slice(index + 1);
+ index = this.buffer.indexOf("\n");
+ }
+ this.wake();
+ });
+ io.input.on("end", () => {
+ if (this.buffer) this.lines.push(this.buffer.replace(/\r$/, ""));
+ this.buffer = "";
+ this.ended = true;
+ this.wake();
+ });
+ io.input.on("close", () => {
+ this.ended = true;
+ this.wake();
+ });
+ }
+
+ private wake(): void {
+ const waiters = this.waiters;
+ this.waiters = [];
+ for (const w of waiters) w();
+ }
+
+ private wait(): Promise {
+ return new Promise((resolve) => this.waiters.push(resolve));
+ }
+
+ async line(): Promise {
+ for (;;) {
+ if (this.lines.length > 0) return this.lines.shift();
+ if (this.ended) return undefined;
+ this.io.input.resume?.();
+ await this.wait();
+ }
+ }
+
+ async key(): Promise {
+ for (;;) {
+ if (this.keys.length > 0) return this.keys.shift();
+ if (this.ended) return undefined;
+ this.io.input.resume?.();
+ await this.wait();
+ }
+ }
+
+ setRaw(on: boolean): void {
+ const input = this.io.input as Readable & { setRawMode?: (mode: boolean) => void };
+ if (typeof input.setRawMode !== "function") return;
+ this.rawDepth += on ? 1 : -1;
+ input.setRawMode(this.rawDepth > 0);
+ }
+
+ pause(): void {
+ this.io.input.pause?.();
+ }
+}
+
+export class Ui {
+ private readonly queue: InputQueue;
+ private renderedLines = 0;
+
+ constructor(readonly io: Io) {
+ this.queue = new InputQueue(io);
+ }
+
+ write(text = ""): void {
+ this.io.output.write(`${text}\n`);
+ }
+
+ /** Release the input so the process can exit. */
+ close(): void {
+ this.queue.pause();
+ }
+
+ /**
+ * Read one line in line mode. A terminal echoes what was typed; a pipe does
+ * not, so the answer is echoed here to keep transcripts readable.
+ */
+ private async readLine(): Promise {
+ const answer = await this.queue.line();
+ const echoed = Boolean((this.io.input as { isTTY?: boolean }).isTTY);
+ if (!echoed) this.io.output.write(`${answer ?? ""}\n`);
+ return answer;
+ }
+
+ private clearRendered(): void {
+ if (this.renderedLines > 0) {
+ this.io.output.write(`\x1b[${this.renderedLines}A\x1b[0J`);
+ this.renderedLines = 0;
+ }
+ }
+
+ private render(lines: string[]): void {
+ this.clearRendered();
+ this.io.output.write(`${lines.join("\n")}\n`);
+ this.renderedLines = lines.length;
+ }
+
+ /** Pick one item. Returns `undefined` on Escape, `q` or end of input. */
+ async select(title: string, choices: Choice[], initial = 0): Promise {
+ const enabled = choices.map((c, i) => (c.disabled ? -1 : i)).filter((i) => i >= 0);
+ if (enabled.length === 0) return undefined;
+ const keyOf = (c: Choice, i: number) => (c.key ?? String(i + 1)).toLowerCase();
+
+ if (!this.io.interactive) {
+ if (title) this.write(` ${paint(title, style.bold, style.text)}`);
+ choices.forEach((c, i) => {
+ const tag = `[${keyOf(c, i)}]`.padEnd(5);
+ const line = ` ${paint(tag, style.cyan)}${c.label}${c.hint ? paint(` ${c.hint}`, style.faint) : ""}`;
+ this.write(c.disabled ? paint(stripAnsi(line), style.faint) : line);
+ });
+ for (;;) {
+ this.io.output.write(` ${paint("›", style.brand)} `);
+ const answer = await this.readLine();
+ if (answer === undefined) return undefined;
+ const text = answer.trim().toLowerCase();
+ if (text === "") continue;
+ const index = choices.findIndex((c, i) => !c.disabled && keyOf(c, i) === text);
+ if (index >= 0) return choices[index]!.value;
+ if (text === "q") return undefined;
+ this.write(` ${paint("?", style.yellow)} ${text}`);
+ }
+ }
+
+ let cursor = enabled.includes(initial) ? initial : enabled[0]!;
+ const draw = () => {
+ const lines = [title ? ` ${paint(title, style.bold, style.text)}` : ""];
+ choices.forEach((c, i) => {
+ const active = i === cursor;
+ const pointer = active ? paint("❯", style.brand) : " ";
+ const tag = paint(`${keyOf(c, i)}`.padEnd(2), style.faint);
+ const label = c.disabled
+ ? paint(c.label, style.faint)
+ : active
+ ? paint(c.label, style.bold, style.text)
+ : c.label;
+ lines.push(
+ ` ${pointer} ${tag} ${label}${c.hint ? paint(` ${c.hint}`, style.faint) : ""}`,
+ );
+ });
+ this.render(lines);
+ };
+ this.queue.setRaw(true);
+ try {
+ draw();
+ for (;;) {
+ const key = await this.queue.key();
+ if (!key) return undefined;
+ if (key.ctrl && key.name === "c") {
+ this.queue.setRaw(false);
+ process.exit(130);
+ }
+ const pos = enabled.indexOf(cursor);
+ if (key.name === "up" || key.name === "k") {
+ cursor = enabled[(pos - 1 + enabled.length) % enabled.length]!;
+ } else if (key.name === "down" || key.name === "j" || key.name === "tab") {
+ cursor = enabled[(pos + 1) % enabled.length]!;
+ } else if (key.name === "return" || key.name === "enter") {
+ this.renderedLines = 0;
+ return choices[cursor]!.value;
+ } else if (key.name === "escape" || key.sequence === "q") {
+ this.renderedLines = 0;
+ return undefined;
+ } else if (key.sequence) {
+ const typed = key.sequence.toLowerCase();
+ const index = choices.findIndex((c, i) => !c.disabled && keyOf(c, i) === typed);
+ if (index >= 0) {
+ cursor = index;
+ draw();
+ this.renderedLines = 0;
+ return choices[index]!.value;
+ }
+ }
+ draw();
+ }
+ } finally {
+ this.queue.setRaw(false);
+ }
+ }
+
+ /**
+ * Toggle several items. Space toggles, enter saves. In line mode the user
+ * types the numbers to toggle (space or comma separated) and an empty line
+ * to save. Returns `undefined` when cancelled.
+ */
+ async checkbox(
+ title: string,
+ choices: Choice[],
+ selected: Set,
+ footer = "",
+ ): Promise | undefined> {
+ const current = new Set(selected);
+ const mark = (on: boolean) => (on ? paint("[x]", style.green) : paint("[ ]", style.faint));
+
+ if (!this.io.interactive) {
+ for (;;) {
+ this.write(` ${paint(title, style.bold, style.text)}`);
+ choices.forEach((c, i) => {
+ this.write(
+ ` ${paint(String(i + 1).padEnd(3), style.cyan)}${mark(current.has(c.value))} ${c.label}${c.hint ? paint(` ${c.hint}`, style.faint) : ""}`,
+ );
+ });
+ if (footer) this.write(` ${paint(footer, style.faint)}`);
+ this.io.output.write(` ${paint("›", style.brand)} `);
+ const answer = await this.readLine();
+ if (answer === undefined) return undefined;
+ const text = answer.trim().toLowerCase();
+ if (text === "" || text === "s") return current;
+ if (text === "q") return undefined;
+ for (const token of text.split(/[\s,]+/)) {
+ const index = Number(token) - 1;
+ const choice = choices[index];
+ if (!choice || choice.disabled) continue;
+ if (current.has(choice.value)) current.delete(choice.value);
+ else current.add(choice.value);
+ }
+ }
+ }
+
+ let cursor = 0;
+ const draw = () => {
+ const lines = [` ${paint(title, style.bold, style.text)}`];
+ choices.forEach((c, i) => {
+ const pointer = i === cursor ? paint("❯", style.brand) : " ";
+ const label = c.disabled ? paint(c.label, style.faint) : c.label;
+ lines.push(
+ ` ${pointer} ${mark(current.has(c.value))} ${label}${c.hint ? paint(` ${c.hint}`, style.faint) : ""}`,
+ );
+ });
+ if (footer) lines.push(` ${paint(footer, style.faint)}`);
+ this.render(lines);
+ };
+ this.queue.setRaw(true);
+ try {
+ draw();
+ for (;;) {
+ const key = await this.queue.key();
+ if (!key) return undefined;
+ if (key.ctrl && key.name === "c") {
+ this.queue.setRaw(false);
+ process.exit(130);
+ }
+ if (key.name === "up" || key.name === "k") {
+ cursor = (cursor - 1 + choices.length) % choices.length;
+ } else if (key.name === "down" || key.name === "j" || key.name === "tab") {
+ cursor = (cursor + 1) % choices.length;
+ } else if (key.name === "space") {
+ const choice = choices[cursor]!;
+ if (!choice.disabled) {
+ if (current.has(choice.value)) current.delete(choice.value);
+ else current.add(choice.value);
+ }
+ } else if (key.name === "return" || key.name === "enter") {
+ this.renderedLines = 0;
+ return current;
+ } else if (key.name === "escape" || key.sequence === "q") {
+ this.renderedLines = 0;
+ return undefined;
+ }
+ draw();
+ }
+ } finally {
+ this.queue.setRaw(false);
+ }
+ }
+
+ /** Read one line of text. Returns `undefined` at end of input. */
+ async text(question: string, initial = ""): Promise {
+ const suffix = initial ? paint(` (${initial})`, style.faint) : "";
+ if (!this.io.interactive) {
+ this.io.output.write(` ${question}${suffix} ${paint("›", style.brand)} `);
+ const answer = await this.readLine();
+ if (answer === undefined) return undefined;
+ return answer.trim() === "" ? initial : answer.trim();
+ }
+
+ this.io.output.write(` ${question}${suffix} ${paint("›", style.brand)} `);
+ let value = "";
+ this.queue.setRaw(true);
+ try {
+ for (;;) {
+ const key = await this.queue.key();
+ if (!key) return undefined;
+ if (key.ctrl && key.name === "c") {
+ this.queue.setRaw(false);
+ process.exit(130);
+ }
+ if (key.name === "return" || key.name === "enter") {
+ this.write();
+ return value.trim() === "" ? initial : value.trim();
+ }
+ if (key.name === "escape") {
+ this.write();
+ return undefined;
+ }
+ if (key.name === "backspace") {
+ if (value.length > 0) {
+ const last = [...value].pop() ?? "";
+ value = [...value].slice(0, -1).join("");
+ const width = displayWidth(last);
+ this.io.output.write("\b \b".repeat(width));
+ }
+ continue;
+ }
+ const seq = key.sequence ?? "";
+ const control = seq.length > 0 && (seq.charCodeAt(0) < 0x20 || seq.charCodeAt(0) === 0x7f);
+ if (seq && !key.ctrl && !control) {
+ value += seq;
+ this.io.output.write(seq);
+ }
+ }
+ } finally {
+ this.queue.setRaw(false);
+ }
+ }
+
+ /** Yes/no question. Enter takes the default. */
+ async confirm(question: string, defaultYes = false, yesWords = ["y", "yes"]): Promise {
+ const hint = defaultYes ? "Y/n" : "y/N";
+ const answer = await this.text(`${question} ${paint(`[${hint}]`, style.faint)}`);
+ if (answer === undefined) return false;
+ const text = answer.trim().toLowerCase();
+ if (text === "") return defaultYes;
+ return yesWords.includes(text);
+ }
+
+ /** Require the user to type an exact word. */
+ async confirmWord(question: string, word: string): Promise {
+ const answer = await this.text(`${question} ${paint(word, style.bold, style.red)}`);
+ return answer !== undefined && answer.trim() === word;
+ }
+
+ /** Wait for enter (or any key) before returning to a menu. */
+ async pause(message: string): Promise {
+ if (!this.io.interactive) return;
+ this.io.output.write(` ${paint(message, style.faint)}`);
+ this.queue.setRaw(true);
+ try {
+ await this.queue.key();
+ } finally {
+ this.queue.setRaw(false);
+ this.write();
+ }
+ }
+}
diff --git a/src/core/autosync.ts b/src/core/autosync.ts
new file mode 100644
index 0000000..a806029
--- /dev/null
+++ b/src/core/autosync.ts
@@ -0,0 +1,598 @@
+import { createHash } from "node:crypto";
+import fs from "node:fs";
+import path from "node:path";
+import { git, parsePorcelainPaths } from "./git.js";
+import { LargeChangeError } from "./guard.js";
+import { hostAlias } from "./host.js";
+import { acquireSyncLock, releaseSyncLock } from "./lock.js";
+import { configMatchesRepo, findConfigFile } from "./overrides.js";
+import type { Roots } from "./paths.js";
+import { excludePathspecs, readStageOutMarkTime } from "./repo.js";
+import { CollectingReporter } from "./reporter.js";
+import { type Settings, loadSettings, repoBranch } from "./settings.js";
+import { PushRaceError, type SyncOptions, type SyncResult, pull, push } from "./sync.js";
+
+/**
+ * Unattended sync, run by the operating system's scheduler.
+ *
+ * The rules below exist because an unattended writer is dangerous in ways a
+ * person at the keyboard is not:
+ *
+ * - Opt-in per machine. The flag lives in the local-only settings file, so
+ * enabling it here can never enable it on another machine.
+ * - Never write while something else is writing. A sync rewrites the skills
+ * directory; doing that while an editor or another OpenCode window is
+ * mid-save can revert the edit. What matters is whether files are moving,
+ * so the gate is the newest modification time in the synced trees.
+ * - Receiving is not read-only either: it mirrors the repository out over
+ * untracked directories. It runs only when this machine has nothing of its
+ * own in flight — no uncommitted edits, no unpushed commits, and no file in
+ * the untracked trees newer than the last time they matched the repository.
+ * - Back off, but never give up. After repeated failures the gap between
+ * attempts widens to an hour, and the next success resets it.
+ * - Machines that fire on the same minute collide on every run, so each one
+ * waits a stable per-machine offset before touching the network.
+ */
+
+export const STATE_FILE = "opencode-sync-autosync.json";
+export const LOG_FILE = "opencode-sync-autosync.log";
+export const LOG_ROTATE_BYTES = 512 * 1024;
+
+export const MAX_CONSECUTIVE_FAILURES = 3;
+export const BACKOFF_BASE_MS = 30 * 60 * 1000;
+export const BACKOFF_MAX_MS = 60 * 60 * 1000;
+export const STAGGER_MAX_S = 45;
+export const JITTER_MAX_MS = 5000;
+/** Scheduler start times drift by the stagger; allow that much early. */
+export const INTERVAL_TOLERANCE_MS = 60 * 1000;
+export const UPDATE_CHECK_MS = 24 * 60 * 60 * 1000;
+
+export interface AutosyncState {
+ lastRunAt?: number;
+ lastAttemptAt?: number;
+ lastReceiveAt?: number;
+ lastSuccessAt?: number;
+ lastFailureAt?: number;
+ consecutiveFailures?: number;
+ lastError?: string | null;
+ lastOutcome?: string;
+ failureNotifiedAt?: number | null;
+ largeChangeNotifiedAt?: number | null;
+ lastUpdateCheckAt?: number;
+}
+
+// ── State and log files ─────────────────────────────────────────────────────
+
+export function statePath(roots: Roots): string {
+ return path.join(roots.state, STATE_FILE);
+}
+
+export function logPath(roots: Roots): string {
+ return path.join(roots.config, "logs", LOG_FILE);
+}
+
+export function readState(roots: Roots): AutosyncState {
+ try {
+ const parsed = JSON.parse(fs.readFileSync(statePath(roots), "utf8"));
+ return parsed && typeof parsed === "object" ? parsed : {};
+ } catch {
+ return {};
+ }
+}
+
+export function writeState(roots: Roots, patch: Partial): AutosyncState {
+ const next = { ...readState(roots), ...patch };
+ try {
+ const file = statePath(roots);
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ // Write-then-rename, so a concurrent reader never sees half a file and
+ // silently resets the failure counter.
+ const tmp = `${file}.tmp`;
+ fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`);
+ fs.renameSync(tmp, file);
+ } catch {
+ // Best effort: losing bookkeeping costs one extra attempt, nothing more.
+ }
+ return next;
+}
+
+export function appendLog(roots: Roots, line: string, now = Date.now()): void {
+ const file = logPath(roots);
+ try {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ if (fs.existsSync(file) && fs.statSync(file).size > LOG_ROTATE_BYTES) {
+ fs.renameSync(file, `${file}.1`);
+ }
+ fs.appendFileSync(file, `[${new Date(now).toISOString()}] ${line}\n`, "utf8");
+ } catch {
+ // Logging must never be what breaks a sync.
+ }
+}
+
+export function tailLog(roots: Roots, lines = 20): string[] {
+ try {
+ return fs.readFileSync(logPath(roots), "utf8").split(/\r?\n/).filter(Boolean).slice(-lines);
+ } catch {
+ return [];
+ }
+}
+
+// ── Pure decision helpers ───────────────────────────────────────────────────
+
+/** The failure counter as a sane integer, whatever the file holds. */
+export function failureCount(state: AutosyncState): number {
+ const value = state.consecutiveFailures;
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
+}
+
+/** Delay before the next attempt once backing off: 30 min doubling to 60. */
+export function backoffDelayMs(failures: number): number {
+ const extra = Math.max(0, failures - MAX_CONSECUTIVE_FAILURES);
+ return Math.min(BACKOFF_BASE_MS * 2 ** extra, BACKOFF_MAX_MS);
+}
+
+/** Milliseconds still to wait because of backoff; 0 when free to try. */
+export function backoffRemainingMs(state: AutosyncState, now: number): number {
+ const failures = failureCount(state);
+ if (failures < MAX_CONSECUTIVE_FAILURES) return 0;
+ const last = typeof state.lastFailureAt === "number" ? state.lastFailureAt : 0;
+ return Math.max(0, backoffDelayMs(failures) - (now - last));
+}
+
+/** A stable 0–45 s offset per machine plus up to 5 s of noise. */
+export function staggerMs(alias: string, random: () => number): number {
+ const digest = createHash("sha256").update(alias).digest();
+ const offset = (digest.readUInt32BE(0) % (STAGGER_MAX_S + 1)) * 1000;
+ return offset + Math.floor(random() * JITTER_MAX_MS);
+}
+
+/** Directory names skipped by the settle check: churn that says nothing. */
+export const SETTLE_SKIP_DIRS = new Set([
+ ".git",
+ "logs",
+ "log",
+ ".opencode-sync.lock",
+ "node_modules",
+ ".venv",
+ "venv",
+ "__pycache__",
+]);
+
+/** File names skipped by the settle check. */
+export const SETTLE_SKIP_FILES = new Set([
+ "session.json",
+ "prompt-history.jsonl",
+ "frecency.jsonl",
+ "model.json",
+ "bun.lock",
+ "package-lock.json",
+ "opencode-sync.jsonc",
+ STATE_FILE,
+ `${STATE_FILE}.tmp`,
+]);
+
+function isNoisyFile(name: string): boolean {
+ return (
+ SETTLE_SKIP_FILES.has(name) ||
+ name.endsWith(".log") ||
+ name.endsWith(".lock") ||
+ name.endsWith(".tmp")
+ );
+}
+
+/** Newest modification time below `dirs`, ignoring noise. 0 when nothing found. */
+export function newestWrite(dirs: string[], skipDirs: Iterable = []): number {
+ const skip = new Set([...SETTLE_SKIP_DIRS, ...skipDirs]);
+ let newest = 0;
+ const walk = (dir: string, depth: number): void => {
+ if (depth > 10) return;
+ let entries: fs.Dirent[];
+ try {
+ entries = fs.readdirSync(dir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ if (entry.isSymbolicLink()) continue;
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (!skip.has(entry.name)) walk(full, depth + 1);
+ continue;
+ }
+ if (isNoisyFile(entry.name)) continue;
+ try {
+ const mtime = fs.statSync(full).mtimeMs;
+ if (mtime > newest) newest = mtime;
+ } catch {
+ // Vanished mid-walk.
+ }
+ }
+ };
+ for (const dir of dirs) {
+ try {
+ if (fs.statSync(dir).isFile()) {
+ newest = Math.max(newest, fs.statSync(dir).mtimeMs);
+ continue;
+ }
+ } catch {
+ continue;
+ }
+ walk(dir, 0);
+ }
+ return newest;
+}
+
+// ── Dependencies ────────────────────────────────────────────────────────────
+
+export interface RepoSnapshot {
+ ok: boolean;
+ dirty: boolean;
+ ahead: number;
+ error?: string;
+}
+
+export interface AutosyncDeps {
+ roots: Roots;
+ now: () => number;
+ sleep: (ms: number) => Promise;
+ random: () => number;
+ loadSettings: () => Settings;
+ push: (options: SyncOptions) => Promise;
+ pull: (options: SyncOptions) => Promise;
+ /** Fetch and count incoming commits. */
+ fetchBehind: () => { ok: boolean; behind: number; error?: string };
+ repoSnapshot: () => RepoSnapshot;
+ /** Newest write in the trees the settle rule watches. */
+ newestWrite: (settings: Settings) => number;
+ /** Newest write in the untracked trees a receive would overwrite. */
+ newestUntrackedWrite: (settings: Settings) => number;
+ /** When the untracked trees last matched the repository; 0 if unknown. */
+ stageOutTime: () => number;
+ tryLock: () => { release: () => void } | null;
+ notify: (title: string, body: string) => boolean;
+ log: (line: string) => void;
+ /** Daily self-update hook; returns a one-line summary for the log. */
+ selfUpdate?: () => string;
+ messages: AutosyncMessages;
+}
+
+export interface AutosyncMessages {
+ backoffTitle: string;
+ backoffBody: (failures: number, minutes: number) => string;
+ recoveredTitle: string;
+ recoveredBody: string;
+ largeTitle: string;
+ largeBody: string;
+}
+
+export const ENGLISH_MESSAGES: AutosyncMessages = {
+ backoffTitle: "OpenCode sync is failing",
+ backoffBody: (failures, minutes) =>
+ `${failures} attempts in a row failed. Retrying every ${minutes} min. Run: opencode-sync autosync status`,
+ recoveredTitle: "OpenCode sync recovered",
+ recoveredBody: "Automatic sync is working again.",
+ largeTitle: "OpenCode sync needs you",
+ largeBody: "A very large change is waiting. Open opencode-sync to review and confirm it.",
+};
+
+// ── The run ─────────────────────────────────────────────────────────────────
+
+export type RunAction = "push" | "receive" | "skip";
+
+export interface RunOutcome {
+ action: RunAction;
+ ok: boolean;
+ reason: string;
+ message?: string;
+}
+
+/** Why a push should not run now, or null when it may. */
+export function pushBlockedReason(
+ settings: Settings,
+ state: AutosyncState,
+ now: number,
+): string | null {
+ if (!settings.autosync.enabled) return "autosync is not enabled on this machine";
+ if (settings.autosync.pausedUntil > now) {
+ return `paused until ${new Date(settings.autosync.pausedUntil).toISOString()}`;
+ }
+ const backoff = backoffRemainingMs(state, now);
+ if (backoff > 0) {
+ return `backing off after ${failureCount(state)} failures, retry in ${Math.ceil(backoff / 60000)} min`;
+ }
+ const intervalMs = Math.max(2, settings.autosync.intervalMinutes) * 60000;
+ const since = now - (state.lastAttemptAt ?? 0);
+ if (since < intervalMs - INTERVAL_TOLERANCE_MS) {
+ return `throttled, ${Math.ceil((intervalMs - since) / 60000)} min to go`;
+ }
+ return null;
+}
+
+/** Run one scheduled pass. Never throws. */
+export async function runAutosync(deps: AutosyncDeps, trigger = "scheduled"): Promise {
+ const now = deps.now();
+ let settings: Settings;
+ try {
+ settings = deps.loadSettings();
+ } catch (error) {
+ const reason = `cannot read settings: ${(error as Error).message}`;
+ deps.log(`${trigger}: skipped (${reason})`);
+ return { action: "skip", ok: false, reason };
+ }
+ let state = writeState(deps.roots, { lastRunAt: now });
+
+ const blocked = pushBlockedReason(settings, state, now);
+ if (blocked) {
+ deps.log(`${trigger}: skipped (${blocked})`);
+ return { action: "skip", ok: true, reason: blocked };
+ }
+
+ const settleMs = Math.max(0, settings.autosync.settleSeconds) * 1000;
+ const newest = deps.newestWrite(settings);
+ const busyFor = newest > 0 ? Math.max(0, now - newest) : null;
+ const busy = busyFor !== null && busyFor < settleMs;
+
+ const alias = hostAlias(settings.machineAlias);
+ const delay = process.env.OPENCODE_SYNC_NO_STAGGER === "1" ? 0 : staggerMs(alias, deps.random);
+
+ if (busy) {
+ const why = `files still changing (${Math.round((busyFor ?? 0) / 1000)}s ago)`;
+ const receiveBlocked = receiveBlockedReason(deps, settings);
+ if (receiveBlocked) {
+ deps.log(`${trigger}: skipped (${why}; receive: ${receiveBlocked})`);
+ return { action: "skip", ok: true, reason: `${why}; ${receiveBlocked}` };
+ }
+ if (delay > 0) await deps.sleep(delay);
+ return receive(deps, settings, trigger, why);
+ }
+
+ if (delay > 0) await deps.sleep(delay);
+ const lock = deps.tryLock();
+ if (!lock) {
+ deps.log(`${trigger}: skipped (another sync is in progress)`);
+ return { action: "skip", ok: true, reason: "another sync is in progress" };
+ }
+
+ state = writeState(deps.roots, { lastAttemptAt: deps.now() });
+ try {
+ const result = await deps.push({
+ settings,
+ roots: deps.roots,
+ reporter: new CollectingReporter(),
+ });
+ recordSuccess(deps, `push: ${result.message}`);
+ deps.log(`${trigger}: push ${result.message}`);
+ maybeSelfUpdate(deps, settings);
+ return { action: "push", ok: true, reason: "", message: result.message };
+ } catch (error) {
+ recordFailure(deps, settings, error as Error);
+ deps.log(`${trigger}: push FAILED (${oneLine((error as Error).message)})`);
+ return { action: "push", ok: false, reason: oneLine((error as Error).message) };
+ } finally {
+ lock.release();
+ }
+}
+
+function receiveBlockedReason(deps: AutosyncDeps, settings: Settings): string | null {
+ const repo = deps.repoSnapshot();
+ if (!repo.ok) return `cannot read the repository (${repo.error ?? "unknown"})`;
+ if (repo.dirty) return "local edits are not committed yet";
+ if (repo.ahead > 0) return `${repo.ahead} local commit(s) waiting`;
+ // Untracked trees (skills, extra paths) cannot be seen by git status. Any
+ // file there newer than the last write-out is an edit a receive would revert.
+ const mark = deps.stageOutTime();
+ if (mark <= 0) return "no record of the last sync on this machine";
+ if (deps.newestUntrackedWrite(settings) > mark) return "skills or extra paths have local edits";
+ return null;
+}
+
+async function receive(
+ deps: AutosyncDeps,
+ settings: Settings,
+ trigger: string,
+ why: string,
+): Promise {
+ const lock = deps.tryLock();
+ if (!lock) {
+ deps.log(`${trigger}: skipped (${why}; another sync is in progress)`);
+ return { action: "skip", ok: true, reason: "another sync is in progress" };
+ }
+ try {
+ const fetched = deps.fetchBehind();
+ if (!fetched.ok) throw new Error(`fetch failed: ${fetched.error ?? "unknown"}`);
+ if (fetched.behind === 0) {
+ deps.log(`${trigger}: skipped (${why}; nothing waiting to receive)`);
+ return { action: "skip", ok: true, reason: `${why}; nothing to receive` };
+ }
+ writeState(deps.roots, { lastReceiveAt: deps.now() });
+ const result = await deps.pull({
+ settings,
+ roots: deps.roots,
+ reporter: new CollectingReporter(),
+ });
+ recordSuccess(deps, `receive: ${result.message}`);
+ deps.log(`${trigger}: receive ${result.message} (push held back: ${why})`);
+ return { action: "receive", ok: true, reason: why, message: result.message };
+ } catch (error) {
+ recordFailure(deps, settings, error as Error);
+ deps.log(`${trigger}: receive FAILED (${oneLine((error as Error).message)})`);
+ return { action: "receive", ok: false, reason: oneLine((error as Error).message) };
+ } finally {
+ lock.release();
+ }
+}
+
+function oneLine(text: string): string {
+ return String(text ?? "")
+ .split(/\r?\n/)
+ .map((l) => l.trim())
+ .filter(Boolean)
+ .join(" ")
+ .slice(0, 400);
+}
+
+function recordSuccess(deps: AutosyncDeps, outcome: string): void {
+ const state = readState(deps.roots);
+ const patch: Partial = {
+ consecutiveFailures: 0,
+ lastSuccessAt: deps.now(),
+ lastError: null,
+ lastOutcome: outcome,
+ largeChangeNotifiedAt: null,
+ };
+ if (state.failureNotifiedAt) {
+ const settings = safeSettings(deps);
+ if (settings?.notifications !== false) {
+ deps.notify(deps.messages.recoveredTitle, deps.messages.recoveredBody);
+ }
+ patch.failureNotifiedAt = null;
+ }
+ writeState(deps.roots, patch);
+}
+
+function recordFailure(deps: AutosyncDeps, settings: Settings, error: Error): void {
+ const state = readState(deps.roots);
+ const lastError = oneLine(error.message);
+ if (error instanceof PushRaceError || error.name === "PushRaceError") {
+ // Contention with another machine is not an outage; it must not push this
+ // machine into the backoff meant for a dead network.
+ writeState(deps.roots, { lastError, lastOutcome: "lost a push race" });
+ return;
+ }
+ const failures = failureCount(state) + 1;
+ const patch: Partial = {
+ consecutiveFailures: failures,
+ lastError,
+ lastFailureAt: deps.now(),
+ lastOutcome: "failed",
+ };
+ const notifications = settings.notifications !== false;
+ if (
+ (error instanceof LargeChangeError || error.name === "LargeChangeError") &&
+ !state.largeChangeNotifiedAt
+ ) {
+ const shown = notifications && deps.notify(deps.messages.largeTitle, deps.messages.largeBody);
+ patch.largeChangeNotifiedAt = deps.now();
+ if (shown) patch.failureNotifiedAt = deps.now();
+ }
+ if (
+ failures >= MAX_CONSECUTIVE_FAILURES &&
+ !state.failureNotifiedAt &&
+ !patch.failureNotifiedAt
+ ) {
+ const minutes = Math.round(backoffDelayMs(failures) / 60000);
+ const shown =
+ notifications &&
+ deps.notify(deps.messages.backoffTitle, deps.messages.backoffBody(failures, minutes));
+ // Recorded even when notifications are off, so recovery is announced
+ // consistently if they are switched back on.
+ patch.failureNotifiedAt = deps.now();
+ deps.log(`backing off after ${failures} failures (notified: ${Boolean(shown)})`);
+ }
+ writeState(deps.roots, patch);
+}
+
+function safeSettings(deps: AutosyncDeps): Settings | undefined {
+ try {
+ return deps.loadSettings();
+ } catch {
+ return undefined;
+ }
+}
+
+function maybeSelfUpdate(deps: AutosyncDeps, settings: Settings): void {
+ if (!settings.autosync.autoUpdate || !deps.selfUpdate) return;
+ const state = readState(deps.roots);
+ if (deps.now() - (state.lastUpdateCheckAt ?? 0) < UPDATE_CHECK_MS) return;
+ writeState(deps.roots, { lastUpdateCheckAt: deps.now() });
+ try {
+ deps.log(`update: ${deps.selfUpdate()}`);
+ } catch (error) {
+ deps.log(`update FAILED (${oneLine((error as Error).message)})`);
+ }
+}
+
+// ── Default wiring ──────────────────────────────────────────────────────────
+
+/** Directories whose writes mean "someone is working right now". */
+export function watchedTrees(roots: Roots, settings: Settings): string[] {
+ const dirs = [roots.config];
+ if (settings.includeSkills) dirs.push(roots.agents);
+ return dirs;
+}
+
+/** Untracked trees that a receive writes over. */
+export function untrackedTrees(roots: Roots, settings: Settings, home: string): string[] {
+ const dirs: string[] = [];
+ if (settings.includeSkills) dirs.push(roots.agents);
+ for (const entry of settings.extraPaths) {
+ dirs.push(path.isAbsolute(entry) ? entry : path.join(home, entry));
+ }
+ return dirs;
+}
+
+export function defaultDeps(
+ roots: Roots,
+ overrides: Partial & { home: string },
+): AutosyncDeps {
+ const settingsLoader = () => loadSettings(roots.config);
+ return {
+ roots,
+ now: () => Date.now(),
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
+ random: Math.random,
+ loadSettings: settingsLoader,
+ push: (options) => push(options),
+ pull: (options) => pull(options),
+ fetchBehind: () => {
+ const settings = settingsLoader();
+ const branch = repoBranch(settings);
+ const fetched = git(["fetch", "origin", branch], { cwd: roots.config, timeoutMs: 120_000 });
+ if (!fetched.ok) return { ok: false, behind: 0, error: oneLine(fetched.stderr) };
+ const count = git(["rev-list", "--count", `HEAD..origin/${branch}`], { cwd: roots.config });
+ return { ok: true, behind: Number(count.stdout) || 0 };
+ },
+ repoSnapshot: () => snapshotRepo(roots.config, repoBranch(settingsLoader())),
+ newestWrite: (settings) =>
+ newestWrite(watchedTrees(roots, settings), settings.guard?.skipDirs ?? []),
+ newestUntrackedWrite: (settings) =>
+ newestWrite(untrackedTrees(roots, settings, overrides.home), settings.guard?.skipDirs ?? []),
+ stageOutTime: () => readStageOutMarkTime(roots.config),
+ tryLock: () => {
+ const lock = acquireSyncLock(roots.config, { waitMs: 0 });
+ return lock ? { release: () => releaseSyncLock(lock) } : null;
+ },
+ notify: () => false,
+ log: (line) => appendLog(roots, line),
+ messages: ENGLISH_MESSAGES,
+ ...overrides,
+ };
+}
+
+/** Local view of the repository: no network. */
+export function snapshotRepo(root: string, branch: string): RepoSnapshot {
+ const status = git(["status", "--porcelain", "--", ".", ...excludePathspecs()], { cwd: root });
+ if (!status.ok) return { ok: false, dirty: false, ahead: 0, error: oneLine(status.stderr) };
+ const ahead = git(["rev-list", "--count", `origin/${branch}..HEAD`], { cwd: root });
+ return {
+ ok: true,
+ dirty: realChanges(root, status.stdout) > 0,
+ ahead: ahead.ok ? Number(ahead.stdout) || 0 : 0,
+ };
+}
+
+/**
+ * Uncommitted files, not counting the config file when its only difference
+ * is this machine's overrides — the expected, permanent difference.
+ */
+function realChanges(root: string, porcelain: string): number {
+ const paths = parsePorcelainPaths(porcelain);
+ if (paths.length === 0) return 0;
+ const configFile = findConfigFile(root);
+ if (!configFile) return paths.length;
+ const rel = path.relative(root, configFile).split(path.sep).join("/");
+ if (!paths.includes(rel)) return paths.length;
+ const head = git(["show", `HEAD:${rel}`], { cwd: root });
+ if (!configMatchesRepo(root, head.ok ? head.stdout : undefined)) return paths.length;
+ return paths.length - 1;
+}
diff --git a/src/core/bundle.ts b/src/core/bundle.ts
new file mode 100644
index 0000000..f1eb90b
--- /dev/null
+++ b/src/core/bundle.ts
@@ -0,0 +1,101 @@
+import fs from "node:fs";
+import path from "node:path";
+import { gzipSync } from "node:zlib";
+
+/**
+ * Support bundle: diagnostics, logs and settings with secrets removed, packed
+ * as a .tar.gz using only the standard library.
+ */
+
+const SECRET_KEY =
+ /(token|secret|password|passwd|apikey|api_key|api-key|auth|credential|cookie|key)$/i;
+
+/** Remove anything that looks like a credential from free text. */
+export function redactText(text: string): string {
+ return (
+ String(text)
+ // user:pass@ and token@ in URLs
+ .replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+(?::[^\s/@]*)?@/gi, "$1[REDACTED]@")
+ // GitHub, npm, OpenAI-style and generic bearer tokens
+ .replace(/\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/g, "[REDACTED]")
+ .replace(/\bnpm_[A-Za-z0-9]{20,}\b/g, "[REDACTED]")
+ .replace(/\bsk-[A-Za-z0-9_-]{16,}\b/g, "[REDACTED]")
+ .replace(/((?:bearer|basic)\s+)[A-Za-z0-9._~+/-]+=*/gi, "$1[REDACTED]")
+ .replace(/(authorization:\s*token\s+)[A-Za-z0-9._~+/-]+=*/gi, "$1[REDACTED]")
+ // key = value / "key": "value" pairs whose key names a secret
+ .replace(
+ /("?[A-Za-z0-9_-]*(?:token|secret|password|passwd|api[_-]?key)[A-Za-z0-9_-]*"?\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,}]+)/gi,
+ '$1"[REDACTED]"',
+ )
+ );
+}
+
+/** Deep-copy a JSON value with secret-looking keys blanked and strings scrubbed. */
+export function redactValue(value: unknown, key = ""): unknown {
+ if (typeof value === "string") {
+ return key && SECRET_KEY.test(key) && value ? "[REDACTED]" : redactText(value);
+ }
+ if (Array.isArray(value)) return value.map((v) => redactValue(v));
+ if (value && typeof value === "object") {
+ const out: Record = {};
+ for (const [k, v] of Object.entries(value)) out[k] = redactValue(v, k);
+ return out;
+ }
+ return value;
+}
+
+// ── Minimal ustar writer ────────────────────────────────────────────────────
+
+function octal(value: number, width: number): string {
+ return `${value.toString(8).padStart(width - 1, "0")}\0`;
+}
+
+function header(name: string, size: number, mtime: number): Buffer {
+ const buf = Buffer.alloc(512, 0);
+ const nameBytes = Buffer.from(name, "utf8");
+ if (nameBytes.length > 100) throw new Error(`tar entry name too long: ${name}`);
+ nameBytes.copy(buf, 0);
+ buf.write(octal(0o644, 8), 100, "ascii");
+ buf.write(octal(0, 8), 108, "ascii");
+ buf.write(octal(0, 8), 116, "ascii");
+ buf.write(octal(size, 12), 124, "ascii");
+ buf.write(octal(Math.floor(mtime / 1000), 12), 136, "ascii");
+ buf.write(" ", 148, "ascii");
+ buf.write("0", 156, "ascii");
+ buf.write("ustar\0", 257, "ascii");
+ buf.write("00", 263, "ascii");
+ let sum = 0;
+ for (const byte of buf) sum += byte;
+ buf.write(`${sum.toString(8).padStart(6, "0")}\0 `, 148, "ascii");
+ return buf;
+}
+
+export interface BundleEntry {
+ name: string;
+ content: string | Buffer;
+}
+
+export function tarGz(entries: BundleEntry[], mtime = Date.now()): Buffer {
+ const parts: Buffer[] = [];
+ for (const entry of entries) {
+ const data = Buffer.isBuffer(entry.content)
+ ? entry.content
+ : Buffer.from(entry.content, "utf8");
+ parts.push(header(entry.name, data.length, mtime), data);
+ const pad = (512 - (data.length % 512)) % 512;
+ if (pad) parts.push(Buffer.alloc(pad, 0));
+ }
+ parts.push(Buffer.alloc(1024, 0));
+ return gzipSync(Buffer.concat(parts));
+}
+
+export function writeBundle(dir: string, entries: BundleEntry[], now = new Date()): string {
+ const stamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 19);
+ const file = path.join(dir, `opencode-sync-support-${stamp}.tar.gz`);
+ const redacted = entries.map((e) => ({
+ name: e.name,
+ content: Buffer.isBuffer(e.content) ? e.content : redactText(e.content),
+ }));
+ fs.writeFileSync(file, tarGz(redacted, now.getTime()));
+ return file;
+}
diff --git a/src/core/categories.ts b/src/core/categories.ts
new file mode 100644
index 0000000..bcba600
--- /dev/null
+++ b/src/core/categories.ts
@@ -0,0 +1,136 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import type { SyncCategories } from "./settings.js";
+
+/**
+ * Optional parts of the config directory.
+ *
+ * The repository worktree is `~/.config/opencode` itself, so a category is a
+ * set of paths at its root. Turning a category off makes those paths local to
+ * this machine in both directions: they are never committed from here, and an
+ * incoming version never replaces them.
+ */
+
+export type CategoryKey = keyof SyncCategories;
+
+interface CategoryPaths {
+ files?: string[];
+ dirs?: string[];
+ /** Match root-level files by extension. */
+ rootExtensions?: string[];
+}
+
+export const CATEGORY_PATHS: Record = {
+ config: { files: ["opencode.json", "opencode.jsonc", "tui.json", "tui.jsonc"] },
+ instructions: { rootExtensions: [".md"] },
+ agents: { dirs: ["agent", "agents"] },
+ commands: { dirs: ["command", "commands"] },
+ plugins: { dirs: ["plugin", "plugins"] },
+};
+
+export function disabledCategories(categories: SyncCategories | undefined): CategoryKey[] {
+ if (!categories) return [];
+ return (Object.keys(CATEGORY_PATHS) as CategoryKey[]).filter((key) => categories[key] === false);
+}
+
+/** True when a repository-relative path belongs to a disabled category. */
+export function isDisabledPath(rel: string, disabled: CategoryKey[]): boolean {
+ const clean = rel.replace(/\\/g, "/").replace(/^\.\//, "");
+ for (const key of disabled) {
+ const spec = CATEGORY_PATHS[key];
+ if (spec.files?.includes(clean)) return true;
+ if (spec.dirs?.some((d) => clean === d || clean.startsWith(`${d}/`))) return true;
+ if (
+ spec.rootExtensions &&
+ !clean.includes("/") &&
+ spec.rootExtensions.some((ext) => clean.toLowerCase().endsWith(ext))
+ ) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/** Git pathspecs (top-level, literal) covering the disabled categories. */
+export function disabledPathspecs(disabled: CategoryKey[]): string[] {
+ const specs: string[] = [];
+ for (const key of disabled) {
+ const spec = CATEGORY_PATHS[key];
+ for (const f of spec.files ?? []) specs.push(`:(top,literal)${f}`);
+ for (const d of spec.dirs ?? []) specs.push(`:(top,literal)${d}`);
+ for (const ext of spec.rootExtensions ?? []) specs.push(`:(top,glob)*${ext}`);
+ }
+ return specs;
+}
+
+/** Every existing file under `root` that belongs to a disabled category. */
+export function listDisabledFiles(root: string, disabled: CategoryKey[]): string[] {
+ if (disabled.length === 0 || !fs.existsSync(root)) return [];
+ const out: string[] = [];
+ const walk = (rel: string): void => {
+ const abs = path.join(root, rel);
+ let entries: fs.Dirent[];
+ try {
+ entries = fs.readdirSync(abs, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
+ if (entry.isSymbolicLink()) continue;
+ if (entry.isDirectory()) walk(childRel);
+ else out.push(childRel);
+ }
+ };
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
+ if (entry.name === ".git" || entry.isSymbolicLink()) continue;
+ if (!isDisabledPath(entry.name, disabled)) continue;
+ if (entry.isDirectory()) walk(entry.name);
+ else out.push(entry.name);
+ }
+ return out;
+}
+
+/**
+ * Copy the disabled categories aside, so they can be put back exactly as they
+ * were after the worktree is reset to the remote.
+ */
+export interface CategorySnapshot {
+ dir: string;
+ files: string[];
+}
+
+export function snapshotDisabled(root: string, disabled: CategoryKey[]): CategorySnapshot | null {
+ if (disabled.length === 0) return null;
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ogs-keep-"));
+ const files = listDisabledFiles(root, disabled);
+ for (const rel of files) {
+ const target = path.join(dir, rel);
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.copyFileSync(path.join(root, rel), target);
+ }
+ return { dir, files };
+}
+
+/** Put the snapshot back: incoming files in those categories are removed. */
+export function restoreDisabled(
+ root: string,
+ disabled: CategoryKey[],
+ snapshot: CategorySnapshot | null,
+): void {
+ if (!snapshot) return;
+ try {
+ const keep = new Set(snapshot.files);
+ for (const rel of listDisabledFiles(root, disabled)) {
+ if (!keep.has(rel)) fs.rmSync(path.join(root, rel), { force: true });
+ }
+ for (const rel of snapshot.files) {
+ const target = path.join(root, rel);
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.copyFileSync(path.join(snapshot.dir, rel), target);
+ }
+ } finally {
+ fs.rmSync(snapshot.dir, { recursive: true, force: true });
+ }
+}
diff --git a/src/core/compare.ts b/src/core/compare.ts
new file mode 100644
index 0000000..480e7f7
--- /dev/null
+++ b/src/core/compare.ts
@@ -0,0 +1,138 @@
+import { createHash } from "node:crypto";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { git } from "./git.js";
+import { AGENTS_DIR, DATA_DIR, SESSIONS_DIR, STATE_DIR } from "./paths.js";
+import { OVERRIDES_FILE, SETTINGS_FILE } from "./settings.js";
+
+/**
+ * Compare this machine's config directory with a remote branch without
+ * touching either: the remote is fetched into a throwaway bare repository and
+ * local files are hashed in-process the way git would store them.
+ *
+ * Used before the first link, so the user can choose which side wins knowing
+ * what would change.
+ */
+
+export interface FileDifference {
+ path: string;
+ kind: "onlyLocal" | "different" | "onlyRemote";
+}
+
+export interface CompareResult {
+ /** The remote branch has no commits yet. */
+ empty: boolean;
+ differences: FileDifference[];
+}
+
+const SKIP_TOP = new Set([
+ ".git",
+ DATA_DIR,
+ STATE_DIR,
+ AGENTS_DIR,
+ SESSIONS_DIR,
+ "_extra",
+ SETTINGS_FILE,
+ OVERRIDES_FILE,
+ ".opencode-sync.lock",
+ // Written by the tool itself on the first sync.
+ ".gitignore",
+ ".gitattributes",
+]);
+const SKIP_ANY = new Set(["node_modules", "logs", "log", "__pycache__", ".venv", "venv"]);
+const SKIP_FILES = new Set([".DS_Store", "Thumbs.db", "desktop.ini", "bun.lock"]);
+
+/** The blob id git would assign to this content once normalised to LF. */
+export function gitBlobId(content: Buffer): string {
+ const binary = content.includes(0);
+ const data = binary ? content : Buffer.from(content.toString("utf8").replace(/\r\n/g, "\n"));
+ return createHash("sha1").update(`blob ${data.length}\0`).update(data).digest("hex");
+}
+
+function localFiles(root: string, skipDirs: string[]): Map {
+ const files = new Map();
+ const skipAny = new Set([...SKIP_ANY, ...skipDirs]);
+ const walk = (rel: string, depth: number): void => {
+ if (depth > 12) return;
+ let entries: fs.Dirent[];
+ try {
+ entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ if (entry.isSymbolicLink()) continue;
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
+ if (!rel && SKIP_TOP.has(entry.name)) continue;
+ if (entry.isDirectory()) {
+ if (!skipAny.has(entry.name)) walk(childRel, depth + 1);
+ continue;
+ }
+ if (SKIP_FILES.has(entry.name) || entry.name.endsWith(".pyc")) continue;
+ try {
+ files.set(childRel, gitBlobId(fs.readFileSync(path.join(root, childRel))));
+ } catch {
+ // Unreadable files cannot be compared; skip them.
+ }
+ }
+ };
+ if (fs.existsSync(root)) walk("", 0);
+ return files;
+}
+
+function remoteFiles(tree: string): Map {
+ const files = new Map();
+ for (const line of tree.split("\n")) {
+ const match = /^\d+ blob ([0-9a-f]{40})\t(.+)$/.exec(line);
+ if (!match) continue;
+ const [, id, rel] = match;
+ const top = rel!.split("/")[0]!;
+ if (SKIP_TOP.has(top)) continue;
+ files.set(rel!.replace(/^"(.*)"$/, "$1"), id!);
+ }
+ return files;
+}
+
+export function compareWithRemote(
+ root: string,
+ remote: string,
+ branch: string,
+ skipDirs: string[] = [],
+): CompareResult {
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "ogs-compare-"));
+ try {
+ git(["init", "--bare", "-q", scratch], { cwd: os.tmpdir() });
+ const fetched = git(["fetch", "-q", "--depth=1", remote, branch], {
+ cwd: scratch,
+ timeoutMs: 120_000,
+ });
+ if (!fetched.ok) {
+ if (/couldn't find remote ref|not found|does not appear/i.test(fetched.stderr)) {
+ return { empty: true, differences: [] };
+ }
+ // An empty repository answers the fetch with a missing ref as well.
+ const listed = git(["ls-remote", remote], { cwd: scratch, timeoutMs: 60_000 });
+ if (listed.ok && listed.stdout.trim() === "") return { empty: true, differences: [] };
+ throw new Error(`Cannot read ${remote}: ${fetched.stderr.split("\n")[0]}`);
+ }
+ const tree = git(["-c", "core.quotepath=off", "ls-tree", "-r", "FETCH_HEAD"], {
+ cwd: scratch,
+ });
+ const theirs = remoteFiles(tree.ok ? tree.stdout : "");
+ const ours = localFiles(root, skipDirs);
+ const differences: FileDifference[] = [];
+ for (const [rel, id] of ours) {
+ const other = theirs.get(rel);
+ if (other === undefined) differences.push({ path: rel, kind: "onlyLocal" });
+ else if (other !== id) differences.push({ path: rel, kind: "different" });
+ }
+ for (const rel of theirs.keys()) {
+ if (!ours.has(rel)) differences.push({ path: rel, kind: "onlyRemote" });
+ }
+ differences.sort((a, b) => a.path.localeCompare(b.path));
+ return { empty: false, differences };
+ } finally {
+ fs.rmSync(scratch, { recursive: true, force: true });
+ }
+}
diff --git a/src/core/env.ts b/src/core/env.ts
new file mode 100644
index 0000000..40849c4
--- /dev/null
+++ b/src/core/env.ts
@@ -0,0 +1,52 @@
+/**
+ * Environment handed to child processes (git, gh, npm).
+ *
+ * A proxy configured in the local settings must reach every network-touching
+ * child, but must never leak into the parent process or into OpenCode itself,
+ * so it is kept here and merged in only when a child is spawned.
+ */
+
+let extra: Record = {};
+
+/** Environment variables that express a proxy to git, gh, curl and npm alike. */
+export function proxyEnv(proxy: string | undefined): Record {
+ const value = (proxy ?? "").trim();
+ if (!value) return {};
+ return {
+ HTTP_PROXY: value,
+ HTTPS_PROXY: value,
+ http_proxy: value,
+ https_proxy: value,
+ npm_config_proxy: value,
+ npm_config_https_proxy: value,
+ };
+}
+
+/** Set (or clear, with `undefined`) the proxy used by every child process. */
+export function setChildProxy(proxy: string | undefined): void {
+ extra = proxyEnv(proxy);
+}
+
+/** `process.env` plus the child-only additions plus caller-specific overrides. */
+export function childEnv(overrides: Record = {}): NodeJS.ProcessEnv {
+ return { ...process.env, ...extra, ...overrides };
+}
+
+/** Prepend directories to PATH (whatever its casing is on this platform). */
+export function prependPath(env: NodeJS.ProcessEnv, dirs: string[]): NodeJS.ProcessEnv {
+ if (dirs.length === 0) return env;
+ const key = Object.keys(env).find((k) => k.toLowerCase() === "path") ?? "PATH";
+ const sep = process.platform === "win32" ? ";" : ":";
+ const current = String(env[key] ?? "");
+ const present = new Set(
+ current
+ .split(sep)
+ .filter(Boolean)
+ .map((p) => (process.platform === "win32" ? p.toLowerCase() : p)),
+ );
+ const missing = dirs.filter(
+ (d) => d && !present.has(process.platform === "win32" ? d.toLowerCase() : d),
+ );
+ if (missing.length === 0) return env;
+ return { ...env, [key]: [...missing, current].filter(Boolean).join(sep) };
+}
diff --git a/src/core/git.ts b/src/core/git.ts
index b09032d..8c92d21 100644
--- a/src/core/git.ts
+++ b/src/core/git.ts
@@ -1,5 +1,6 @@
import { execFile, execFileSync } from "node:child_process";
import { promisify } from "node:util";
+import { childEnv } from "./env.js";
const execFileAsync = promisify(execFile);
@@ -21,8 +22,19 @@ const NO_PROMPT_ENV = {
GIT_TERMINAL_PROMPT: "0",
GIT_ASKPASS: "",
GCM_INTERACTIVE: "never",
+ // Failures are classified by matching git's English messages; a localized
+ // git would make a lost push race look like a hard failure.
+ LC_ALL: "C",
+ LANGUAGE: "C",
};
+/**
+ * Paths are parsed from git's output and then opened on disk. With the
+ * default `core.quotepath`, a non-ASCII name such as `技能.md` comes back as
+ * an octal-escaped string that names no real file.
+ */
+const BASE_ARGS = ["-c", "core.quotepath=off"];
+
/**
* Run git and never throw.
*
@@ -33,12 +45,13 @@ const NO_PROMPT_ENV = {
*/
export function git(args: string[], options: GitOptions): GitResult {
try {
- const stdout = execFileSync("git", args, {
+ const stdout = execFileSync("git", [...BASE_ARGS, ...args], {
cwd: options.cwd,
encoding: "utf8",
stdio: "pipe",
timeout: options.timeoutMs ?? 0,
- env: options.interactive ? process.env : { ...process.env, ...NO_PROMPT_ENV },
+ windowsHide: true,
+ env: options.interactive ? childEnv() : childEnv(NO_PROMPT_ENV),
});
return { ok: true, stdout: stdout.trim(), stderr: "", code: 0 };
} catch (e: any) {
@@ -53,11 +66,12 @@ export function git(args: string[], options: GitOptions): GitResult {
export async function gitAsync(args: string[], options: GitOptions): Promise {
try {
- const { stdout, stderr } = await execFileAsync("git", args, {
+ const { stdout, stderr } = await execFileAsync("git", [...BASE_ARGS, ...args], {
cwd: options.cwd,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
- env: options.interactive ? process.env : { ...process.env, ...NO_PROMPT_ENV },
+ windowsHide: true,
+ env: options.interactive ? childEnv() : childEnv(NO_PROMPT_ENV),
});
return { ok: true, stdout: stdout.trim(), stderr: stderr.trim(), code: 0 };
} catch (e: any) {
@@ -161,6 +175,31 @@ export function parsePorcelainPaths(output: string): string[] {
return paths;
}
+/**
+ * Parse `git status -z --porcelain`: `XY path` records separated by NUL, with
+ * a rename's original path in the following record. Every path is returned,
+ * the original of a rename included, since both are local bytes.
+ */
+export function parsePorcelainZ(output: string): string[] {
+ const records = String(output ?? "").split("\0");
+ const paths: string[] = [];
+ for (let i = 0; i < records.length; i++) {
+ const record = records[i] ?? "";
+ if (record.length < 3) continue;
+ // Each record is `XY path`, so the third character is always a space.
+ // `git()` trims its output, which drops the leading space of a first
+ // record such as ` M file`; that shows as a non-space third character.
+ const start = record[2] === " " ? 3 : record[1] === " " ? 2 : -1;
+ if (start === -1) continue;
+ paths.push(record.slice(start));
+ if (/^[RC]/.test(record)) {
+ const original = records[++i];
+ if (original) paths.push(original);
+ }
+ }
+ return paths;
+}
+
export interface NameStatusEntry {
kind: "added" | "modified" | "deleted" | "renamed";
path: string;
diff --git a/src/core/guard.ts b/src/core/guard.ts
new file mode 100644
index 0000000..22e6198
--- /dev/null
+++ b/src/core/guard.ts
@@ -0,0 +1,89 @@
+import fs from "node:fs";
+import path from "node:path";
+import type { ChangedFile } from "./reporter.js";
+import type { GuardSettings } from "./settings.js";
+
+/**
+ * Large-change guard.
+ *
+ * The usual way a sync repository gets ruined is not a conflict but an
+ * accident: a virtualenv created inside a skill, a build output dropped into
+ * the config directory, a log that grew to a gigabyte. Each is one `git add`
+ * away from being in the history of every machine for ever. A push that adds
+ * an unusual amount of data is therefore held back until a person confirms it.
+ * The unattended scheduler never confirms; it records a failure instead.
+ */
+
+export interface GuardReport {
+ addedFiles: number;
+ totalBytes: number;
+ largest?: { path: string; bytes: number };
+ /** Files above the single-file limit, largest first. */
+ oversized: { path: string; bytes: number }[];
+ /** Human-readable reasons; empty when the push is within limits. */
+ reasons: ("files" | "bytes" | "file")[];
+}
+
+export function evaluateGuard(
+ root: string,
+ files: ChangedFile[],
+ limits: GuardSettings,
+): GuardReport {
+ let addedFiles = 0;
+ let totalBytes = 0;
+ const sizes: { path: string; bytes: number }[] = [];
+
+ for (const file of files) {
+ if (file.kind === "deleted") continue;
+ if (file.kind === "added") addedFiles++;
+ let bytes = 0;
+ try {
+ bytes = fs.statSync(path.join(root, file.path)).size;
+ } catch {
+ // Vanished or unreadable: it cannot add weight to the push.
+ }
+ totalBytes += bytes;
+ sizes.push({ path: file.path, bytes });
+ }
+
+ sizes.sort((a, b) => b.bytes - a.bytes);
+ const oversized = sizes.filter((s) => s.bytes > limits.maxFileBytes);
+ const reasons: GuardReport["reasons"] = [];
+ if (addedFiles > limits.maxFiles) reasons.push("files");
+ if (totalBytes > limits.maxBytes) reasons.push("bytes");
+ if (oversized.length > 0) reasons.push("file");
+
+ return { addedFiles, totalBytes, largest: sizes[0], oversized, reasons };
+}
+
+export class LargeChangeError extends Error {
+ constructor(readonly report: GuardReport) {
+ super(
+ `This push is unusually large (${report.addedFiles} new file(s), ${report.totalBytes} bytes).\n` +
+ "Confirm it interactively, raise the limits in the menu, or add the directory to the skip list.",
+ );
+ this.name = "LargeChangeError";
+ }
+}
+
+/** Patterns written to `.git/info/exclude` so generated trees are never staged. */
+export function excludeFileContent(skipDirs: string[]): string {
+ const lines = [
+ "# Managed by opencode-github-sync: generated directories never synced.",
+ ...skipDirs.filter((d) => /^[^\\/*?[\]]+$/.test(d)).map((d) => `${d}/`),
+ ];
+ return `${lines.join("\n")}\n`;
+}
+
+export function writeLocalExcludes(root: string, skipDirs: string[]): void {
+ const file = path.join(root, ".git", "info", "exclude");
+ if (!fs.existsSync(path.join(root, ".git"))) return;
+ const content = excludeFileContent(skipDirs);
+ try {
+ if (fs.existsSync(file) && fs.readFileSync(file, "utf8") === content) return;
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, content);
+ } catch {
+ // Best effort: the large-change guard still catches what slips through.
+ }
+}
diff --git a/src/core/history.ts b/src/core/history.ts
new file mode 100644
index 0000000..6dfa6dc
--- /dev/null
+++ b/src/core/history.ts
@@ -0,0 +1,119 @@
+import { execFileSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import { git } from "./git.js";
+import type { Roots } from "./paths.js";
+import { saveToRecovery } from "./recovery.js";
+import { DEVICE_TRAILER } from "./sync.js";
+
+/**
+ * Read-only views of the sync repository's history: which machines sync, when
+ * each last did, and earlier versions of a file.
+ */
+
+export interface DeviceInfo {
+ alias: string;
+ lastSync: string;
+ commits: number;
+}
+
+const SUBJECT_ALIAS = / from ([A-Za-z0-9._-]+)\s*$/;
+
+/**
+ * Machines seen in the history, newest first.
+ *
+ * The alias is taken from the commit trailer written since 3.2.0, falling back
+ * to the `... from ` subject that every earlier release used.
+ */
+export function listDevices(root: string, limit = 2000): DeviceInfo[] {
+ const sep = "\x1f";
+ const end = "\x1e";
+ const log = git(
+ [
+ "log",
+ `-n${limit}`,
+ `--format=%cI${sep}%s${sep}%(trailers:key=${DEVICE_TRAILER},valueonly,separator=%x2C)${end}`,
+ ],
+ { cwd: root },
+ );
+ if (!log.ok) return [];
+ const seen = new Map();
+ for (const record of log.stdout.split(end)) {
+ const [date, subject, trailer] = record.trim().split(sep);
+ if (!date) continue;
+ const alias = (trailer ?? "").trim() || SUBJECT_ALIAS.exec(subject ?? "")?.[1];
+ if (!alias) continue;
+ const existing = seen.get(alias);
+ if (existing) existing.commits++;
+ else seen.set(alias, { alias, lastSync: date, commits: 1 });
+ }
+ return [...seen.values()];
+}
+
+export interface FileVersion {
+ commit: string;
+ date: string;
+ subject: string;
+}
+
+/** Commits that touched a repository-relative file, newest first. */
+export function fileHistory(root: string, rel: string, limit = 30): FileVersion[] {
+ const sep = "\x1f";
+ const log = git(["log", `-n${limit}`, `--format=%H${sep}%cI${sep}%s`, "--", rel], {
+ cwd: root,
+ });
+ if (!log.ok) return [];
+ return log.stdout
+ .split("\n")
+ .filter(Boolean)
+ .map((line) => {
+ const [commit = "", date = "", subject = ""] = line.split(sep);
+ return { commit, date, subject };
+ });
+}
+
+/** Tracked files, optionally filtered by a case-insensitive substring. */
+export function trackedFiles(root: string, filter = ""): string[] {
+ const result = git(["ls-files"], { cwd: root });
+ if (!result.ok) return [];
+ const needle = filter.toLowerCase();
+ return result.stdout
+ .split("\n")
+ .filter(Boolean)
+ .filter((f) => !needle || f.toLowerCase().includes(needle));
+}
+
+/**
+ * Put the content a file had at `commit` back into the worktree.
+ *
+ * The current bytes are copied to the recovery folder first, so a restore can
+ * itself be undone. The file is left as an ordinary local edit; the next push
+ * shares it. Returns the recovery folder, if anything was saved.
+ */
+export function restoreFile(
+ roots: Roots,
+ rel: string,
+ commit: string,
+): { recovery?: string; target: string } {
+ const root = roots.config;
+ const target = path.join(root, rel);
+ const show = git(["cat-file", "-e", `${commit}:${rel}`], { cwd: root });
+ if (!show.ok) throw new Error(`${rel} does not exist in ${commit.slice(0, 8)}`);
+ const recovery = saveToRecovery(roots, "restore", [{ source: target, rel }]);
+
+ // A string pipe would decode as UTF-8 and corrupt binary files, so the blob
+ // is read as raw bytes and renamed into place atomically.
+ const blob = git(["rev-parse", `${commit}:${rel}`], { cwd: root });
+ if (!blob.ok) throw new Error(`Cannot read ${rel} at ${commit.slice(0, 8)}`);
+ const raw = execFileSync("git", ["cat-file", "blob", blob.stdout], {
+ cwd: root,
+ stdio: ["ignore", "pipe", "pipe"],
+ windowsHide: true,
+ maxBuffer: 256 * 1024 * 1024,
+ });
+ const tmp = `${target}.restore-${process.pid}`;
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.writeFileSync(tmp, raw);
+ fs.renameSync(tmp, target);
+ return { recovery, target };
+}
diff --git a/src/core/index.ts b/src/core/index.ts
index d1b7e7d..70b3243 100644
--- a/src/core/index.ts
+++ b/src/core/index.ts
@@ -13,3 +13,14 @@ export * from "./sqlite.js";
export * from "./stage.js";
export * from "./sync.js";
export * from "./setup.js";
+export * from "./autosync.js";
+export * from "./bundle.js";
+export * from "./categories.js";
+export * from "./env.js";
+export * from "./guard.js";
+export * from "./history.js";
+export * from "./notify.js";
+export * from "./recovery.js";
+export * from "./scheduler.js";
+export * from "./update.js";
+export * from "./compare.js";
diff --git a/src/core/notify.ts b/src/core/notify.ts
new file mode 100644
index 0000000..6eeeaf9
--- /dev/null
+++ b/src/core/notify.ts
@@ -0,0 +1,78 @@
+import { execFileSync } from "node:child_process";
+import { windowsPowerShell } from "./scheduler.js";
+
+/**
+ * Desktop notifications from the unattended job. Never throws: a machine that
+ * cannot show a notification must still sync.
+ */
+
+function xmlText(text: string): string {
+ return (
+ String(text)
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: XML rejects control characters, so they are stripped
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'")
+ );
+}
+
+/**
+ * The PowerShell script that shows a Windows toast. Windows PowerShell 5.1 is
+ * used because it projects the WinRT types implicitly; the script travels as
+ * base64 UTF-16LE so non-ASCII text never crosses an ANSI code page.
+ */
+export function windowsToastScript(title: string, body: string): string {
+ const xml = `${xmlText(title)} ${xmlText(body)} `;
+ // Windows PowerShell's own AppUserModelID is always registered; an
+ // unregistered id makes Windows drop the toast silently.
+ const appId = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe";
+ return [
+ "[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime]",
+ "[void][Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom, ContentType=WindowsRuntime]",
+ "$d = New-Object Windows.Data.Xml.Dom.XmlDocument",
+ `$d.LoadXml('${xml.replace(/'/g, "''")}')`,
+ "$t = New-Object Windows.UI.Notifications.ToastNotification $d",
+ `[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('${appId}').Show($t)`,
+ ].join("\n");
+}
+
+export function appleScriptNotification(title: string, body: string): string {
+ const flat = (s: string) => String(s).replace(/[\n\r\u2028\u2029]+/g, " · ");
+ const quote = (s: string) => `"${flat(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
+ return `display notification ${quote(body)} with title ${quote(title)}`;
+}
+
+export function notify(title: string, body: string, platform = process.platform): boolean {
+ try {
+ if (platform === "win32") {
+ const encoded = Buffer.from(windowsToastScript(title, body), "utf16le").toString("base64");
+ execFileSync(
+ windowsPowerShell(),
+ ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded],
+ {
+ stdio: "ignore",
+ windowsHide: true,
+ timeout: 20_000,
+ },
+ );
+ return true;
+ }
+ if (platform === "darwin") {
+ execFileSync("/usr/bin/osascript", ["-e", appleScriptNotification(title, body)], {
+ stdio: "ignore",
+ timeout: 20_000,
+ });
+ return true;
+ }
+ execFileSync("notify-send", ["--app-name=opencode-sync", title, body], {
+ stdio: "ignore",
+ timeout: 20_000,
+ });
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/src/core/recovery.ts b/src/core/recovery.ts
new file mode 100644
index 0000000..e11f554
--- /dev/null
+++ b/src/core/recovery.ts
@@ -0,0 +1,71 @@
+import fs from "node:fs";
+import path from "node:path";
+import type { Roots } from "./paths.js";
+
+/**
+ * Recovery copies.
+ *
+ * Whenever this tool is about to replace bytes the user may still want — a
+ * forced pull, a "repository wins" first link, a file restored from history —
+ * the current version is copied here first. The directory lives under the data
+ * root, which is never mirrored into the repository as a whole, so recovery
+ * copies stay on this machine.
+ */
+
+export const RECOVERY_DIR = "opencode-sync-recovery";
+
+export function recoveryRoot(roots: Roots): string {
+ return path.join(roots.data, RECOVERY_DIR);
+}
+
+export interface RecoveryEntry {
+ /** Absolute path of the file being replaced. */
+ source: string;
+ /** Relative path used inside the recovery folder. */
+ rel: string;
+}
+
+function stamp(now: Date): string {
+ return now.toISOString().replace(/[:.]/g, "-").replace("Z", "");
+}
+
+/**
+ * Copy files into a fresh recovery folder. Missing sources are skipped.
+ * Returns the folder, or `undefined` when nothing needed saving.
+ */
+export function saveToRecovery(
+ roots: Roots,
+ label: string,
+ entries: RecoveryEntry[],
+ now = new Date(),
+): string | undefined {
+ const existing = entries.filter((e) => {
+ try {
+ return fs.statSync(e.source).isFile();
+ } catch {
+ return false;
+ }
+ });
+ if (existing.length === 0) return undefined;
+ const safeLabel = label.replace(/[^A-Za-z0-9._-]+/g, "-");
+ const dir = path.join(recoveryRoot(roots), `${stamp(now)}-${safeLabel}`);
+ for (const entry of existing) {
+ const rel = entry.rel.replace(/^[A-Za-z]:/, "").replace(/^[\\/]+/, "");
+ const target = path.join(dir, rel);
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.copyFileSync(entry.source, target);
+ }
+ return dir;
+}
+
+/** Recovery folders, newest first. */
+export function listRecovery(roots: Roots): string[] {
+ const root = recoveryRoot(roots);
+ if (!fs.existsSync(root)) return [];
+ return fs
+ .readdirSync(root, { withFileTypes: true })
+ .filter((d) => d.isDirectory())
+ .map((d) => path.join(root, d.name))
+ .sort()
+ .reverse();
+}
diff --git a/src/core/repo.ts b/src/core/repo.ts
index 67f9280..56b5005 100644
--- a/src/core/repo.ts
+++ b/src/core/repo.ts
@@ -38,8 +38,10 @@ ${DATA_DIR}/snapshot/
${DATA_DIR}/storage/session
${DATA_DIR}/storage/session_diff
-# Machine-local UI state
+# Machine-local UI state and the autosync runtime state
${STATE_DIR}/session.json
+${STATE_DIR}/opencode-sync-autosync.json
+${STATE_DIR}/opencode-sync-autosync.json.tmp
# Empty directories recreated by OpenCode at runtime
plugin/
@@ -101,6 +103,15 @@ export interface EnsureRepoOptions {
root: string;
remote: string;
branch: string;
+ /**
+ * How an existing remote history is adopted by a directory that is not yet
+ * a repository. `remote` (default) checks the remote files out over the
+ * local ones; `local` keeps every local file and only moves the branch, so
+ * the next commit records this machine's state on top of the remote.
+ */
+ adopt?: "remote" | "local";
+ /** Called before `remote` adoption overwrites the worktree. */
+ beforeAdopt?: (root: string, ref: string) => void;
}
export type RepoState = "ready" | "adopted" | "fresh";
@@ -147,7 +158,12 @@ export function initRepo(options: EnsureRepoOptions): RepoState {
const fetched = git(["fetch", "origin", branch], { cwd: root });
if (fetched.ok && git(["rev-parse", `origin/${branch}`], { cwd: root }).ok) {
- git(["reset", "--hard", `origin/${branch}`], { cwd: root });
+ if (options.adopt === "local") {
+ git(["reset", "-q", `origin/${branch}`], { cwd: root });
+ } else {
+ options.beforeAdopt?.(root, `origin/${branch}`);
+ git(["reset", "--hard", `origin/${branch}`], { cwd: root });
+ }
git(["branch", `--set-upstream-to=origin/${branch}`, branch], { cwd: root });
return "adopted";
}
@@ -236,6 +252,18 @@ export function readStageOutMark(root: string): string | undefined {
}
}
+/**
+ * When the real directories last matched the repository, in epoch ms; 0 when
+ * unknown. Any untracked file newer than this is a local edit.
+ */
+export function readStageOutMarkTime(root: string): number {
+ try {
+ return fs.statSync(stageOutMarkPath(root)).mtimeMs;
+ } catch {
+ return 0;
+ }
+}
+
/** Record that the real directories now match HEAD. */
export function writeStageOutMark(root: string): void {
const head = headCommit(root);
diff --git a/src/core/scheduler.ts b/src/core/scheduler.ts
new file mode 100644
index 0000000..6462db1
--- /dev/null
+++ b/src/core/scheduler.ts
@@ -0,0 +1,651 @@
+import { execFileSync } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+/**
+ * Operating-system scheduler definitions.
+ *
+ * Every definition is produced by a pure function from a `ScheduleSpec`, so the
+ * exact XML, plist and unit text can be snapshot-tested on any platform. The
+ * installers below are thin wrappers that write those strings and call the
+ * platform tool.
+ *
+ * Paths are resolved once, at install time, from the running Node binary and
+ * this package's own location. They are never rebuilt from a user name: the
+ * account name and the profile folder name differ on many machines.
+ */
+
+export const TASK_NAME = "opencode-github-sync";
+export const LAUNCHD_LABEL = "com.opencode-github-sync.autosync";
+export const SYSTEMD_UNIT = "opencode-github-sync";
+export const VBS_NAME = "opencode-sync-autosync.vbs";
+
+export interface ScheduleSpec {
+ /** Absolute path of the Node binary. */
+ node: string;
+ /** Absolute path of the CLI entry script. */
+ cli: string;
+ intervalMinutes: number;
+ /** Extra environment for the job (root overrides used by tests and CI). */
+ env?: Record;
+ /** Directory for scheduler-level stdout/stderr logs. */
+ logDir: string;
+}
+
+export function runArgs(spec: ScheduleSpec): string[] {
+ return [spec.cli, "autosync", "run"];
+}
+
+// ── Windows ─────────────────────────────────────────────────────────────────
+
+function xmlEscape(text: string): string {
+ return text
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+/** Quote one argument for the Windows command line (CommandLineToArgvW rules). */
+export function quoteWindowsArg(arg: string): string {
+ if (arg !== "" && !/[\s"]/.test(arg)) return arg;
+ let out = '"';
+ let backslashes = 0;
+ for (const ch of arg) {
+ if (ch === "\\") {
+ backslashes++;
+ continue;
+ }
+ if (ch === '"') {
+ out += "\\".repeat(backslashes * 2 + 1) + '"';
+ } else {
+ out += "\\".repeat(backslashes) + ch;
+ }
+ backslashes = 0;
+ }
+ return `${out}${"\\".repeat(backslashes * 2)}"`;
+}
+
+export type WindowsLauncher =
+ | { kind: "conhost"; conhost: string }
+ | { kind: "wscript"; wscript: string; vbs: string };
+
+export interface WindowsTaskOptions {
+ /** `DOMAIN\user` or `MACHINE\user` of the account the task runs as. */
+ userId: string;
+ /** S4U runs while logged off; requires an elevated registration. */
+ logonType: "InteractiveToken" | "S4U";
+ launcher: WindowsLauncher;
+ /** Start boundary for the time trigger, local ISO without offset is fine. */
+ startBoundary: string;
+}
+
+/** The command and arguments the task's Exec action runs. */
+export function windowsAction(
+ spec: ScheduleSpec,
+ launcher: WindowsLauncher,
+): { command: string; args: string } {
+ if (launcher.kind === "conhost") {
+ return {
+ command: launcher.conhost,
+ args: ["--headless", spec.node, ...runArgs(spec)].map(quoteWindowsArg).join(" "),
+ };
+ }
+ return { command: launcher.wscript, args: `//B //Nologo ${quoteWindowsArg(launcher.vbs)}` };
+}
+
+/**
+ * Task Scheduler XML.
+ *
+ * - A time trigger repeating every N minutes carries the steady cadence. A
+ * logon trigger's repetition only starts counting after a real logon, so a
+ * task registered mid-session would otherwise never fire on schedule.
+ * - Logon (+30 s), session unlock and remote connect (+15 s) cover returning
+ * to the machine.
+ * - IgnoreNew, a 10-minute limit and no network gate: a roaming laptop is
+ * offline often, and the job handles an unreachable remote itself.
+ */
+export function windowsTaskXml(spec: ScheduleSpec, options: WindowsTaskOptions): string {
+ const action = windowsAction(spec, options.launcher);
+ const user = xmlEscape(options.userId);
+ const interval = `PT${Math.max(2, Math.floor(spec.intervalMinutes))}M`;
+ return `
+
+
+ Keeps OpenCode configuration in sync with its GitHub repository (opencode-github-sync).
+ \\${TASK_NAME}
+
+
+
+ ${xmlEscape(options.startBoundary)}
+ true
+
+ ${interval}
+ false
+
+
+
+ true
+ ${user}
+ PT30S
+
+
+ true
+ ${user}
+ PT15S
+ SessionUnlock
+
+
+ true
+ ${user}
+ PT15S
+ RemoteConnect
+
+
+
+
+ ${user}
+ ${options.logonType}
+ LeastPrivilege
+
+
+
+ IgnoreNew
+ false
+ false
+ true
+ true
+ false
+
+ false
+ false
+
+ true
+ true
+ false
+ false
+ false
+ PT10M
+ 7
+
+
+
+ ${xmlEscape(action.command)}
+ ${xmlEscape(action.args)}
+
+
+
+`;
+}
+
+/**
+ * The VBScript fallback launcher, used where `conhost --headless` is not
+ * available. `Run ..., 0, True` starts Node with no window and waits, so the
+ * task's own run time and exit code are meaningful.
+ */
+export function windowsVbs(spec: ScheduleSpec): string {
+ const vbQuote = (s: string) => `""${s.replace(/"/g, '""""')}""`;
+ const command = [spec.node, ...runArgs(spec)].map(vbQuote).join(" ");
+ const envLines = Object.entries(spec.env ?? {}).map(
+ ([k, v]) => `env("${k.replace(/"/g, "")}") = "${v.replace(/"/g, '""')}"`,
+ );
+ return [
+ "' Generated by opencode-github-sync. Starts the autosync job with no window.",
+ "' Rewritten on every `opencode-sync autosync install`.",
+ "Dim shell, env, code",
+ 'Set shell = CreateObject("WScript.Shell")',
+ 'Set env = shell.Environment("PROCESS")',
+ ...envLines,
+ `code = shell.Run("${command}", 0, True)`,
+ "WScript.Quit code",
+ "",
+ ].join("\r\n");
+}
+
+/** UTF-16LE with BOM: wscript reads anything else in the ANSI code page. */
+export function encodeVbs(text: string): Buffer {
+ return Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(text, "utf16le")]);
+}
+
+/** schtasks expects the same encoding for an XML that declares UTF-16. */
+export const encodeTaskXml = encodeVbs;
+
+// ── macOS ───────────────────────────────────────────────────────────────────
+
+export function launchdPlist(spec: ScheduleSpec): string {
+ const esc = xmlEscape;
+ const args = [spec.node, ...runArgs(spec)].map((a) => ` ${esc(a)} `);
+ const envEntries = Object.entries(spec.env ?? {});
+ const env =
+ envEntries.length === 0
+ ? ""
+ : ` EnvironmentVariables
+
+${envEntries.map(([k, v]) => ` ${esc(k)} \n ${esc(v)} `).join("\n")}
+
+`;
+ return `
+
+
+
+ Label
+ ${LAUNCHD_LABEL}
+ ProgramArguments
+
+${args.join("\n")}
+
+${env} RunAtLoad
+
+ StartInterval
+ ${Math.max(2, Math.floor(spec.intervalMinutes)) * 60}
+ ProcessType
+ Background
+ StandardOutPath
+ ${esc(path.posix.join(toPosix(spec.logDir), "autosync-launchd.log"))}
+ StandardErrorPath
+ ${esc(path.posix.join(toPosix(spec.logDir), "autosync-launchd.log"))}
+
+
+`;
+}
+
+function toPosix(p: string): string {
+ return p.replace(/\\/g, "/");
+}
+
+// ── Linux ───────────────────────────────────────────────────────────────────
+
+/**
+ * Quote one word for a systemd `ExecStart=` / `Environment=` line. `%` is a
+ * specifier and `$` an environment expansion there, so both are doubled.
+ */
+export function systemdQuote(arg: string): string {
+ if (/^[A-Za-z0-9_@+=:,./-]+$/.test(arg)) return arg;
+ return `"${arg
+ .replace(/\\/g, "\\\\")
+ .replace(/"/g, '\\"')
+ .replace(/%/g, "%%")
+ .replace(/\$/g, "$$$$")}"`;
+}
+
+export function systemdService(spec: ScheduleSpec): string {
+ const exec = [spec.node, ...runArgs(spec)].map(systemdQuote).join(" ");
+ const env = Object.entries(spec.env ?? {}).map(
+ ([k, v]) => `Environment=${systemdQuote(`${k}=${v}`)}`,
+ );
+ return [
+ "[Unit]",
+ "Description=opencode-github-sync autosync",
+ "",
+ "[Service]",
+ "Type=oneshot",
+ `ExecStart=${exec}`,
+ ...env,
+ "TimeoutStartSec=600",
+ "",
+ ].join("\n");
+}
+
+export function systemdTimer(spec: ScheduleSpec): string {
+ const minutes = Math.max(2, Math.floor(spec.intervalMinutes));
+ return [
+ "[Unit]",
+ "Description=Run opencode-github-sync autosync periodically",
+ "",
+ "[Timer]",
+ "OnBootSec=1min",
+ `OnUnitActiveSec=${minutes}min`,
+ "Persistent=true",
+ `Unit=${SYSTEMD_UNIT}.service`,
+ "",
+ "[Install]",
+ "WantedBy=timers.target",
+ "",
+ ].join("\n");
+}
+
+/** Single-quote for POSIX sh. */
+export function shQuote(arg: string): string {
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
+}
+
+export const CRON_MARKER = "# opencode-github-sync autosync";
+
+export function crontabLine(spec: ScheduleSpec): string {
+ // The minute field cannot express more than 59; longer intervals become
+ // hourly steps (rounded down), which `*/N` would silently turn into hourly.
+ const minutes = Math.max(2, Math.floor(spec.intervalMinutes));
+ const schedule =
+ minutes < 60 ? `*/${minutes} * * * *` : `0 */${Math.min(23, Math.floor(minutes / 60))} * * *`;
+ // cron turns an unescaped `%` into a newline.
+ const cronQuote = (v: string) => shQuote(v).replace(/%/g, "\\%");
+ const env = Object.entries(spec.env ?? {})
+ .map(([k, v]) => `${k}=${cronQuote(v)}`)
+ .join(" ");
+ const cmd = [spec.node, ...runArgs(spec)].map(cronQuote).join(" ");
+ const log = cronQuote(path.posix.join(toPosix(spec.logDir), "autosync-cron.log"));
+ return `${schedule} ${env ? `${env} ` : ""}${cmd} >> ${log} 2>&1 ${CRON_MARKER}`;
+}
+
+/** Replace (or remove, with `line` undefined) our entry in a crontab text. */
+export function editCrontab(current: string, line: string | undefined): string {
+ const kept = current.split("\n").filter((l) => l.trim() && !l.includes(CRON_MARKER));
+ if (line) kept.push(line);
+ return kept.length > 0 ? `${kept.join("\n")}\n` : "";
+}
+
+// ── Resolution of absolute paths ────────────────────────────────────────────
+
+/** The Node binary to schedule: the one running this CLI, unless it is Bun or OpenCode. */
+export function resolveNode(execPath = process.execPath): string {
+ if (process.env.OPENCODE_SYNC_NODE) return process.env.OPENCODE_SYNC_NODE;
+ const base = path.basename(execPath).toLowerCase();
+ if (base === "node" || base === "node.exe") return execPath;
+ const candidates =
+ process.platform === "win32"
+ ? [path.join(process.env.ProgramFiles || "C:\\Program Files", "nodejs", "node.exe")]
+ : ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node"];
+ return candidates.find((c) => fs.existsSync(c)) ?? execPath;
+}
+
+/** The CLI entry script of this installation. */
+export function resolveCli(moduleDir: string): string {
+ return path.resolve(moduleDir, "..", "cli", "index.js");
+}
+
+// ── Installation (thin wrappers) ────────────────────────────────────────────
+
+export type Platform = "win32" | "darwin" | "linux";
+
+export interface Runner {
+ run: (
+ cmd: string,
+ args: string[],
+ input?: string,
+ ) => { ok: boolean; stdout: string; stderr: string };
+}
+
+export const realRunner: Runner = {
+ run(cmd, args, input) {
+ try {
+ const stdout = execFileSync(cmd, args, {
+ encoding: "utf8",
+ stdio: ["pipe", "pipe", "pipe"],
+ input,
+ windowsHide: true,
+ timeout: 60_000,
+ });
+ return { ok: true, stdout: String(stdout), stderr: "" };
+ } catch (e: any) {
+ return {
+ ok: false,
+ stdout: String(e.stdout ?? ""),
+ stderr: String(e.stderr ?? e.message ?? ""),
+ };
+ }
+ },
+};
+
+export interface InstallContext {
+ platform: Platform;
+ spec: ScheduleSpec;
+ home: string;
+ runner: Runner;
+ /**
+ * Directory for generated launcher files. Must be outside the sync
+ * worktree: a launcher there would be committed, overwritten by other
+ * machines and deleted by a forced pull's `git clean`.
+ */
+ launcherDir: string;
+ runWhenLoggedOff: boolean;
+ elevated: boolean;
+}
+
+export interface InstallResult {
+ ok: boolean;
+ kind: "schtasks" | "launchd" | "systemd" | "cron";
+ detail: string[];
+ warnings: string[];
+}
+
+function windowsUser(runner: Runner): string {
+ const who = runner.run("whoami", []);
+ return who.ok && who.stdout.trim()
+ ? who.stdout.trim()
+ : `${os.hostname()}\\${os.userInfo().username}`;
+}
+
+function windowsLauncher(ctx: InstallContext): WindowsLauncher {
+ const systemRoot = process.env.SystemRoot || "C:\\Windows";
+ const conhost = path.join(systemRoot, "System32", "conhost.exe");
+ // `--headless` exists from Windows 10 1809 (build 17763). It cannot carry
+ // environment variables, so a job that needs any uses the VBScript launcher.
+ const build = Number(os.release().split(".")[2] ?? 0);
+ const needsEnv = Object.keys(ctx.spec.env ?? {}).length > 0;
+ if (
+ !needsEnv &&
+ process.env.OPENCODE_SYNC_LAUNCHER !== "wscript" &&
+ fs.existsSync(conhost) &&
+ build >= 17763
+ ) {
+ return { kind: "conhost", conhost };
+ }
+ const vbs = path.join(ctx.launcherDir, VBS_NAME);
+ fs.mkdirSync(path.dirname(vbs), { recursive: true });
+ fs.writeFileSync(vbs, encodeVbs(windowsVbs(ctx.spec)));
+ return { kind: "wscript", wscript: path.join(systemRoot, "System32", "wscript.exe"), vbs };
+}
+
+function localIsoNow(): string {
+ const d = new Date(Date.now() - 60_000);
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
+}
+
+export function installScheduler(ctx: InstallContext): InstallResult {
+ const warnings: string[] = [];
+ fs.mkdirSync(ctx.spec.logDir, { recursive: true });
+
+ if (ctx.platform === "win32") {
+ let logonType: WindowsTaskOptions["logonType"] = "InteractiveToken";
+ if (ctx.runWhenLoggedOff) {
+ if (ctx.elevated) {
+ logonType = "S4U";
+ // S4U has no network credentials and cannot unlock DPAPI, so Git
+ // Credential Manager cannot hand out a saved HTTPS login.
+ warnings.push(
+ "while signed out, saved HTTPS logins from Windows Credential Manager are unavailable; use an SSH remote for unattended runs",
+ );
+ } else warnings.push("not elevated: registered to run only while you are signed in");
+ }
+ const launcher = windowsLauncher(ctx);
+ const xml = windowsTaskXml(ctx.spec, {
+ userId: windowsUser(ctx.runner),
+ logonType,
+ launcher,
+ startBoundary: localIsoNow(),
+ });
+ const xmlFile = path.join(os.tmpdir(), `ogs-task-${process.pid}.xml`);
+ fs.writeFileSync(xmlFile, encodeTaskXml(xml));
+ try {
+ const created = ctx.runner.run("schtasks", [
+ "/Create",
+ "/TN",
+ TASK_NAME,
+ "/XML",
+ xmlFile,
+ "/F",
+ ]);
+ if (!created.ok) {
+ return { ok: false, kind: "schtasks", detail: [created.stderr.trim()], warnings };
+ }
+ } finally {
+ fs.rmSync(xmlFile, { force: true });
+ }
+ const check = schedulerStatus(ctx);
+ if (!check.installed) {
+ return { ok: false, kind: "schtasks", detail: ["task missing after registration"], warnings };
+ }
+ if (!check.nextRun) {
+ return { ok: false, kind: "schtasks", detail: ["task has no next run time"], warnings };
+ }
+ return {
+ ok: true,
+ kind: "schtasks",
+ detail: [`launcher ${launcher.kind}`, `logon ${logonType}`, `next run ${check.nextRun}`],
+ warnings,
+ };
+ }
+
+ if (ctx.platform === "darwin") {
+ const plistPath = path.join(ctx.home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
+ fs.mkdirSync(path.dirname(plistPath), { recursive: true });
+ fs.writeFileSync(plistPath, launchdPlist(ctx.spec));
+ const uid = String(process.getuid?.() ?? "");
+ ctx.runner.run("launchctl", ["bootout", `gui/${uid}/${LAUNCHD_LABEL}`]);
+ const boot = ctx.runner.run("launchctl", ["bootstrap", `gui/${uid}`, plistPath]);
+ if (!boot.ok) {
+ // A headless CI session has no GUI domain; the per-user domain still works.
+ const alt = ctx.runner.run("launchctl", ["bootstrap", `user/${uid}`, plistPath]);
+ if (!alt.ok) return { ok: false, kind: "launchd", detail: [boot.stderr.trim()], warnings };
+ warnings.push("loaded into the user domain (no GUI session)");
+ }
+ return { ok: true, kind: "launchd", detail: [plistPath], warnings };
+ }
+
+ // Linux: systemd --user when it answers, otherwise cron. Whichever is
+ // chosen, the other one is cleared, or switching would run the job twice.
+ const probe = ctx.runner.run("systemctl", ["--user", "show-environment"]);
+ const unitDir = path.join(
+ process.env.XDG_CONFIG_HOME || path.join(ctx.home, ".config"),
+ "systemd",
+ "user",
+ );
+ if (probe.ok) {
+ const dir = unitDir;
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(path.join(dir, `${SYSTEMD_UNIT}.service`), systemdService(ctx.spec));
+ fs.writeFileSync(path.join(dir, `${SYSTEMD_UNIT}.timer`), systemdTimer(ctx.spec));
+ ctx.runner.run("systemctl", ["--user", "daemon-reload"]);
+ const enabled = ctx.runner.run("systemctl", [
+ "--user",
+ "enable",
+ "--now",
+ `${SYSTEMD_UNIT}.timer`,
+ ]);
+ if (!enabled.ok)
+ return { ok: false, kind: "systemd", detail: [enabled.stderr.trim()], warnings };
+ const cron = ctx.runner.run("crontab", ["-l"]);
+ if (cron.ok && cron.stdout.includes(CRON_MARKER)) {
+ ctx.runner.run("crontab", ["-"], editCrontab(cron.stdout, undefined));
+ }
+ warnings.push("headless host: run `loginctl enable-linger` so the timer runs while logged out");
+ return { ok: true, kind: "systemd", detail: [dir], warnings };
+ }
+
+ const current = ctx.runner.run("crontab", ["-l"]);
+ const next = editCrontab(current.ok ? current.stdout : "", crontabLine(ctx.spec));
+ const written = ctx.runner.run("crontab", ["-"], next);
+ if (!written.ok) return { ok: false, kind: "cron", detail: [written.stderr.trim()], warnings };
+ for (const unit of [`${SYSTEMD_UNIT}.timer`, `${SYSTEMD_UNIT}.service`]) {
+ fs.rmSync(path.join(unitDir, unit), { force: true });
+ }
+ return { ok: true, kind: "cron", detail: ["crontab entry added"], warnings };
+}
+
+export interface SchedulerStatus {
+ installed: boolean;
+ kind: InstallResult["kind"] | "none";
+ nextRun?: string;
+ detail?: string;
+}
+
+export function schedulerStatus(
+ ctx: Pick,
+): SchedulerStatus {
+ if (ctx.platform === "win32") {
+ const q = ctx.runner.run("schtasks", ["/Query", "/TN", TASK_NAME]);
+ if (!q.ok) return { installed: false, kind: "none" };
+ // schtasks' own output is localized; the cmdlet returns a real DateTime.
+ const info = ctx.runner.run(windowsPowerShell(), [
+ "-NoProfile",
+ "-NonInteractive",
+ "-Command",
+ `$i = Get-ScheduledTaskInfo -TaskName '${TASK_NAME}'; if ($i.NextRunTime) { $i.NextRunTime.ToString('s') }`,
+ ]);
+ const next = info.ok ? info.stdout.trim() : "";
+ return { installed: true, kind: "schtasks", nextRun: next || undefined };
+ }
+ if (ctx.platform === "darwin") {
+ const plistPath = path.join(ctx.home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
+ if (!fs.existsSync(plistPath)) return { installed: false, kind: "none" };
+ const listed = ctx.runner.run("launchctl", ["list", LAUNCHD_LABEL]);
+ return { installed: true, kind: "launchd", detail: listed.ok ? "loaded" : "not loaded" };
+ }
+ const timer = ctx.runner.run("systemctl", ["--user", "is-enabled", `${SYSTEMD_UNIT}.timer`]);
+ if (timer.ok && /enabled/.test(timer.stdout)) {
+ return { installed: true, kind: "systemd" };
+ }
+ const cron = ctx.runner.run("crontab", ["-l"]);
+ if (cron.ok && cron.stdout.includes(CRON_MARKER)) return { installed: true, kind: "cron" };
+ return { installed: false, kind: "none" };
+}
+
+export type UninstallContext = Pick;
+
+export function uninstallScheduler(ctx: UninstallContext): { removed: boolean; detail: string[] } {
+ const detail: string[] = [];
+ let removed = false;
+ if (ctx.platform === "win32") {
+ const del = ctx.runner.run("schtasks", ["/Delete", "/TN", TASK_NAME, "/F"]);
+ removed = del.ok;
+ fs.rmSync(path.join(ctx.launcherDir, VBS_NAME), { force: true });
+ return { removed, detail };
+ }
+ if (ctx.platform === "darwin") {
+ const uid = String(process.getuid?.() ?? "");
+ ctx.runner.run("launchctl", ["bootout", `gui/${uid}/${LAUNCHD_LABEL}`]);
+ ctx.runner.run("launchctl", ["bootout", `user/${uid}/${LAUNCHD_LABEL}`]);
+ const plistPath = path.join(ctx.home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
+ removed = fs.existsSync(plistPath);
+ fs.rmSync(plistPath, { force: true });
+ return { removed, detail };
+ }
+ const dir = path.join(
+ process.env.XDG_CONFIG_HOME || path.join(ctx.home, ".config"),
+ "systemd",
+ "user",
+ );
+ const timer = path.join(dir, `${SYSTEMD_UNIT}.timer`);
+ if (fs.existsSync(timer)) {
+ ctx.runner.run("systemctl", ["--user", "disable", "--now", `${SYSTEMD_UNIT}.timer`]);
+ fs.rmSync(timer, { force: true });
+ fs.rmSync(path.join(dir, `${SYSTEMD_UNIT}.service`), { force: true });
+ ctx.runner.run("systemctl", ["--user", "daemon-reload"]);
+ removed = true;
+ }
+ const cron = ctx.runner.run("crontab", ["-l"]);
+ if (cron.ok && cron.stdout.includes(CRON_MARKER)) {
+ ctx.runner.run("crontab", ["-"], editCrontab(cron.stdout, undefined));
+ removed = true;
+ }
+ return { removed, detail };
+}
+
+/** True when this process runs with administrator rights (Windows only). */
+export function isElevated(runner: Runner = realRunner): boolean {
+ if (process.platform !== "win32") return process.getuid?.() === 0;
+ return runner.run("net", ["session"]).ok;
+}
+
+export function windowsPowerShell(): string {
+ return path.join(
+ process.env.SystemRoot || "C:\\Windows",
+ "System32",
+ "WindowsPowerShell",
+ "v1.0",
+ "powershell.exe",
+ );
+}
diff --git a/src/core/settings.ts b/src/core/settings.ts
index e03ff03..c41869c 100644
--- a/src/core/settings.ts
+++ b/src/core/settings.ts
@@ -37,6 +37,48 @@ export interface SessionSettings {
directories: string[];
}
+/** Background sync driven by the operating system's scheduler. Machine-local. */
+export interface AutosyncSettings {
+ /** Opt-in. Only a machine that turned this on ever syncs unattended. */
+ enabled: boolean;
+ /** Minutes between scheduled runs. Minimum 2. */
+ intervalMinutes: number;
+ /** Seconds the synced trees must be quiet before a push. */
+ settleSeconds: number;
+ /** Windows only: register with S4U so it runs while logged off (needs elevation). */
+ runWhenLoggedOff: boolean;
+ /** Epoch milliseconds; no unattended sync before this moment. 0 = not paused. */
+ pausedUntil: number;
+ /** Check npm once a day from the scheduled job and upgrade when newer. */
+ autoUpdate: boolean;
+}
+
+/** Thresholds above which a push needs explicit confirmation. */
+export interface GuardSettings {
+ /** Maximum number of newly added files in one push. */
+ maxFiles: number;
+ /** Maximum total size of added and modified files in one push. */
+ maxBytes: number;
+ /** Maximum size of any single added or modified file. */
+ maxFileBytes: number;
+ /** Directory names never copied out of synced trees (generated content). */
+ skipDirs: string[];
+}
+
+/**
+ * Which parts of the config directory this machine shares.
+ *
+ * A category switched off is local to this machine in both directions: its
+ * local changes are never committed and incoming versions never replace it.
+ */
+export interface SyncCategories {
+ config: boolean;
+ instructions: boolean;
+ agents: boolean;
+ commands: boolean;
+ plugins: boolean;
+}
+
export interface Settings {
repo: RepoSettings;
/** Stable alias for this machine used in commit messages. */
@@ -54,8 +96,20 @@ export interface Settings {
autoPullOnStartup: boolean;
/** Push automatically when a session goes idle (plugin only). */
autoPushOnIdle: boolean;
+ /** Parts of the config directory to share. */
+ categories: SyncCategories;
+ autosync: AutosyncSettings;
+ guard: GuardSettings;
+ /** HTTP(S) proxy for git, gh and npm child processes. Empty = none. */
+ proxy: string;
+ /** Interface language; empty = auto-detect. */
+ language: string;
+ /** Desktop notifications from the scheduled job. */
+ notifications: boolean;
}
+export const DEFAULT_SKIP_DIRS = ["node_modules", ".venv", "venv", "__pycache__", "dist", ".cache"];
+
export const DEFAULT_SETTINGS: Settings = {
repo: { branch: "main" },
includeCredentials: false,
@@ -73,8 +127,28 @@ export const DEFAULT_SETTINGS: Settings = {
},
autoPullOnStartup: true,
autoPushOnIdle: false,
+ categories: { config: true, instructions: true, agents: true, commands: true, plugins: true },
+ autosync: {
+ enabled: false,
+ intervalMinutes: 5,
+ settleSeconds: 60,
+ runWhenLoggedOff: false,
+ pausedUntil: 0,
+ autoUpdate: false,
+ },
+ guard: {
+ maxFiles: 300,
+ maxBytes: 10 * 1024 * 1024,
+ maxFileBytes: 2 * 1024 * 1024,
+ skipDirs: [...DEFAULT_SKIP_DIRS],
+ },
+ proxy: "",
+ language: "",
+ notifications: true,
};
+export const MIN_AUTOSYNC_INTERVAL = 2;
+
export function settingsPath(configRoot = getConfigRoot()): string {
return path.join(configRoot, SETTINGS_FILE);
}
@@ -89,19 +163,38 @@ function mergeSettings(base: Settings, patch: Partial): Settings {
...patch,
repo: { ...base.repo, ...(patch.repo ?? {}) },
sessions: { ...base.sessions, ...(patch.sessions ?? {}) },
+ categories: { ...base.categories, ...(patch.categories ?? {}) },
+ autosync: { ...base.autosync, ...(patch.autosync ?? {}) },
+ guard: { ...base.guard, ...(patch.guard ?? {}) },
};
}
+/** A deep copy of the defaults, so callers can mutate the result freely. */
+export function defaultSettings(): Settings {
+ return JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
+}
+
export function loadSettings(configRoot = getConfigRoot()): Settings {
const file = settingsPath(configRoot);
- if (!fs.existsSync(file)) return { ...DEFAULT_SETTINGS };
+ if (!fs.existsSync(file)) return defaultSettings();
let parsed: Partial;
try {
parsed = parseJsonc>(fs.readFileSync(file, "utf8"));
} catch (e) {
throw new Error(`Cannot read ${SETTINGS_FILE}: ${(e as Error).message}`);
}
- return mergeSettings(DEFAULT_SETTINGS, parsed ?? {});
+ return mergeSettings(defaultSettings(), parsed ?? {});
+}
+
+/** Load, change and save in one step. Returns the saved settings. */
+export function updateSettings(
+ mutate: (settings: Settings) => void,
+ configRoot = getConfigRoot(),
+): Settings {
+ const settings = loadSettings(configRoot);
+ mutate(settings);
+ saveSettings(settings, configRoot);
+ return settings;
}
export function saveSettings(settings: Settings, configRoot = getConfigRoot()): void {
@@ -122,6 +215,15 @@ export function repoBranch(settings: Settings): string {
return settings.repo.branch || "main";
}
+/**
+ * Idle pushes from inside OpenCode race the scheduled autosync job for the
+ * same files, so once autosync owns a machine the plugin keeps only its
+ * startup pull.
+ */
+export function idlePushEnabled(settings: Settings): boolean {
+ return settings.autoPushOnIdle && !settings.autosync?.enabled;
+}
+
export const SETTINGS_TEMPLATE = `{
// opencode-github-sync settings. This file is never committed.
"repo": {
diff --git a/src/core/setup.ts b/src/core/setup.ts
index 5e56ed4..c59056e 100644
--- a/src/core/setup.ts
+++ b/src/core/setup.ts
@@ -1,13 +1,14 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
+import { childEnv } from "./env.js";
import { ensureFile } from "./fsx.js";
import { type Roots, getRoots } from "./paths.js";
import type { Reporter } from "./reporter.js";
import {
- DEFAULT_SETTINGS,
OVERRIDES_TEMPLATE,
SETTINGS_TEMPLATE,
type Settings,
+ defaultSettings,
loadSettings,
overridesPath,
saveSettings,
@@ -33,7 +34,12 @@ export class SetupError extends Error {
function gh(args: string[]): { ok: boolean; stdout: string; stderr: string } {
try {
- const stdout = execFileSync("gh", args, { encoding: "utf8", stdio: "pipe" });
+ const stdout = execFileSync("gh", args, {
+ encoding: "utf8",
+ stdio: "pipe",
+ windowsHide: true,
+ env: childEnv(),
+ });
return { ok: true, stdout: stdout.trim(), stderr: "" };
} catch (e: any) {
return {
@@ -45,6 +51,8 @@ function gh(args: string[]): { ok: boolean; stdout: string; stderr: string } {
}
export function ghAvailable(): boolean {
+ // Lets tests and CI run without touching whatever account `gh` is signed in to.
+ if (process.env.OPENCODE_SYNC_NO_GH === "1") return false;
return gh(["--version"]).ok;
}
@@ -192,12 +200,12 @@ function writeSetupFiles(
const existing = fs.existsSync(settingsPath(roots.config))
? loadSettings(roots.config)
- : { ...DEFAULT_SETTINGS };
+ : defaultSettings();
const settings: Settings = {
...existing,
repo: {
- ...existing.repo,
+ // owner/name replaces any URL from an earlier link; the URL would win.
owner: target.owner,
name: target.name,
branch: existing.repo.branch || "main",
@@ -234,6 +242,88 @@ function writeSetupFiles(
};
}
+/**
+ * True when the input is a git URL or a local path rather than `owner/name`.
+ * Such a repository is linked as given, without the GitHub CLI.
+ */
+export function isRepoUrl(input: string | undefined): boolean {
+ const value = (input ?? "").trim();
+ return (
+ /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ||
+ /^[^@\s]+@[^:\s]+:/.test(value) ||
+ /^[A-Za-z]:[\\/]/.test(value) ||
+ value.startsWith("/") ||
+ value.startsWith("./") ||
+ value.startsWith("..") ||
+ value.endsWith(".git")
+ );
+}
+
+/** Extract `owner/name` from a GitHub URL, if it is one. */
+export function githubTargetFromUrl(url: string): RepoTarget | undefined {
+ const match = /github\.com[/:]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(url.trim());
+ return match ? { owner: match[1]!, name: match[2]! } : undefined;
+}
+
+/**
+ * Link by URL. Visibility cannot be checked without the GitHub API, so
+ * credential and session syncing are refused unless the URL is a GitHub
+ * repository that `gh` confirms is private.
+ */
+export function linkByUrl(options: SetupOptions & { url: string }): SetupResult {
+ const roots = options.roots ?? getRoots();
+ const url = options.url.trim();
+ const github = githubTargetFromUrl(url);
+ const isPrivate = github && ghAvailable() ? repoIsPrivate(github) : false;
+ if ((options.includeCredentials || options.includeSessions) && !isPrivate) {
+ throw new SetupError(
+ "Credential and session syncing need a repository confirmed to be private.\n" +
+ "Link it as owner/name with the GitHub CLI signed in, or leave those options off.",
+ );
+ }
+ fs.mkdirSync(roots.config, { recursive: true });
+ const existing = fs.existsSync(settingsPath(roots.config))
+ ? loadSettings(roots.config)
+ : defaultSettings();
+ const settings: Settings = {
+ ...existing,
+ repo: { url, branch: existing.repo.branch || "main" },
+ includeCredentials: options.includeCredentials ?? existing.includeCredentials,
+ sessions: {
+ ...existing.sessions,
+ enabled: options.includeSessions ?? existing.sessions.enabled,
+ },
+ };
+ saveSettings(settings, roots.config);
+ ensureFile(overridesPath(roots.config), OVERRIDES_TEMPLATE);
+ return {
+ target: github ?? { owner: "", name: url },
+ created: false,
+ settingsFile: settingsPath(roots.config),
+ overridesFile: overridesPath(roots.config),
+ };
+}
+
+/** Human-readable name of the configured repository. */
+export function repoLabel(settings: Settings): string | undefined {
+ if (settings.repo.owner && settings.repo.name)
+ return `${settings.repo.owner}/${settings.repo.name}`;
+ if (settings.repo.url) {
+ const gh = githubTargetFromUrl(settings.repo.url);
+ return gh ? `${gh.owner}/${gh.name}` : settings.repo.url.replace(/\/\/[^@/]+@/, "//");
+ }
+ return undefined;
+}
+
+/** Remove the repository from the local settings. No file is deleted. */
+export function unlinkSync(roots: Roots = getRoots()): void {
+ if (!fs.existsSync(settingsPath(roots.config))) return;
+ const settings = loadSettings(roots.config);
+ settings.repo = { branch: settings.repo.branch || "main" };
+ settings.autosync.enabled = false;
+ saveSettings(settings, roots.config);
+}
+
/** Guess which of the user's repositories is a sync repository. */
export function discoverSyncRepos(): string[] {
const result = gh([
diff --git a/src/core/stage.ts b/src/core/stage.ts
index 418ec45..4beff05 100644
--- a/src/core/stage.ts
+++ b/src/core/stage.ts
@@ -17,6 +17,7 @@ import { git } from "./git.js";
import { parseJsonc } from "./jsonc.js";
import { configTextForRepo, findConfigFile } from "./overrides.js";
import { AGENTS_DIR, DATA_DIR, type Roots, STATE_DIR, getRoots } from "./paths.js";
+import { saveToRecovery } from "./recovery.js";
import type { Reporter } from "./reporter.js";
import type { Settings } from "./settings.js";
@@ -46,8 +47,15 @@ const DATA_FILES = ["sync-state.json"];
* and `storage/migration` is a one-byte file — `copyPath` resolves which.
*/
const DATA_PATHS = ["storage/project", "storage/migration"];
-/** State files that describe this machine's current UI, not its configuration. */
-const MACHINE_STATE_FILES = new Set(["session.json"]);
+/**
+ * State files that describe this machine, not its configuration: the current
+ * UI session and the autosync scheduler's own bookkeeping.
+ */
+export const MACHINE_STATE_FILES = new Set([
+ "session.json",
+ "opencode-sync-autosync.json",
+ "opencode-sync-autosync.json.tmp",
+]);
const SKILL_LOCK = ".skill-lock.json";
@@ -231,6 +239,8 @@ export function stageOut(ctx: StageContext): void {
const repoState = path.join(repoRoot, STATE_DIR);
const repoAgents = path.join(repoRoot, AGENTS_DIR);
+ if (force) saveReplacedDirs(ctx, repoAgents);
+
ensureDir(roots.data);
for (const file of dataFilesFor(settings)) {
// `preserveMissing` matters for credentials: a machine that syncs without
@@ -266,6 +276,61 @@ export function stageOut(ctx: StageContext): void {
stageExtraPathsOut(ctx);
}
+/**
+ * A forced stage-out makes the skills folder and directory extra paths exact
+ * copies of the repository. Every local file that is about to disappear or
+ * change is copied to the recovery folder first. Nested `.git` folders are
+ * not copied: they are clones the `skills` CLI can recreate.
+ */
+function saveReplacedDirs(ctx: StageContext, repoAgents: string): void {
+ const pairs: { local: string; repo: string; label: string }[] = [];
+ if (ctx.settings.includeSkills && fs.existsSync(repoAgents)) {
+ pairs.push({ local: ctx.roots.agents, repo: repoAgents, label: "skills" });
+ }
+ for (const { entry, source, repo } of extraPathEntries(ctx)) {
+ if (fs.existsSync(repo) && fs.statSync(repo).isDirectory()) {
+ pairs.push({ local: source, repo, label: path.join("extra", sanitizeRelative(entry)) });
+ }
+ }
+ const entries: { source: string; rel: string }[] = [];
+ for (const { local, repo, label } of pairs) {
+ for (const rel of listFiles(local)) {
+ if (rel === SKILL_LOCK) continue;
+ const incoming = path.join(repo, rel);
+ const current = path.join(local, rel);
+ if (fs.existsSync(incoming) && sameBytes(current, incoming)) continue;
+ entries.push({ source: current, rel: path.join(label, rel) });
+ }
+ }
+ const dir = saveToRecovery(ctx.roots, "force-pull", entries);
+ if (dir) ctx.reporter.detail(`Saved the replaced local files to ${dir}`);
+}
+
+function listFiles(root: string, rel = ""): string[] {
+ let dirents: fs.Dirent[];
+ try {
+ dirents = fs.readdirSync(path.join(root, rel), { withFileTypes: true });
+ } catch {
+ return [];
+ }
+ return dirents.flatMap((d) => {
+ const child = rel ? path.join(rel, d.name) : d.name;
+ if (d.isDirectory()) return d.name === ".git" ? [] : listFiles(root, child);
+ return d.isFile() ? [child] : [];
+ });
+}
+
+function sameBytes(a: string, b: string): boolean {
+ try {
+ const sa = fs.statSync(a);
+ const sb = fs.statSync(b);
+ if (!sb.isFile() || sa.size !== sb.size) return false;
+ return fs.readFileSync(a).equals(fs.readFileSync(b));
+ } catch {
+ return false;
+ }
+}
+
function stageSkillLockOut(ctx: StageContext, repoAgents: string): void {
const repoLock = path.join(repoAgents, SKILL_LOCK);
const localLock = path.join(path.dirname(ctx.roots.agents), SKILL_LOCK);
diff --git a/src/core/sync.ts b/src/core/sync.ts
index 46c81e0..5fdf986 100644
--- a/src/core/sync.ts
+++ b/src/core/sync.ts
@@ -1,18 +1,31 @@
import fs from "node:fs";
import path from "node:path";
+import {
+ type CategoryKey,
+ type CategorySnapshot,
+ disabledCategories,
+ disabledPathspecs,
+ isDisabledPath,
+ restoreDisabled,
+ snapshotDisabled,
+} from "./categories.js";
import {
type GitResult,
git,
isGitRepo,
parseNameStatus,
parsePorcelainPaths,
+ parsePorcelainZ,
toGitError,
} from "./git.js";
+import { type GuardReport, LargeChangeError, evaluateGuard, writeLocalExcludes } from "./guard.js";
import { commitMessage, hostAlias } from "./host.js";
import { applyOverrides, configMatchesRepo, findConfigFile } from "./overrides.js";
import { type Roots, getDatabasePath, getRoots } from "./paths.js";
+import { saveToRecovery } from "./recovery.js";
import {
LOCAL_RUNTIME_PATHS,
+ type RepoState,
assertPushLanded,
assertRemoteReachable,
ensureRepo,
@@ -33,7 +46,7 @@ import {
totalChanges,
} from "./reporter.js";
import { exportSessions, importSessions } from "./sessions.js";
-import { type Settings, loadSettings, repoBranch, repoUrl } from "./settings.js";
+import { DEFAULT_SETTINGS, type Settings, loadSettings, repoBranch, repoUrl } from "./settings.js";
import { sqliteAvailable } from "./sqlite.js";
import {
createStageContext,
@@ -51,6 +64,19 @@ export interface SyncOptions {
force?: boolean;
/** Compute the result without writing anything to the remote. */
dryRun?: boolean;
+ /**
+ * Called when a push exceeds the large-change limits. Return true to send it
+ * anyway. Without a callback an oversized push is refused.
+ */
+ confirmLarge?: (report: GuardReport) => boolean | Promise;
+ /** Skip the large-change guard entirely. */
+ allowLarge?: boolean;
+ /**
+ * First push from a directory that is not a repository yet, against a
+ * remote that already has history: `remote` (default) takes the remote
+ * files first, `local` keeps every local file and records it on top.
+ */
+ adopt?: "remote" | "local";
}
export interface SyncResult {
@@ -62,6 +88,8 @@ export interface SyncResult {
message: string;
/** True when OpenCode must restart for the pulled config to take effect. */
restartRequired: boolean;
+ /** Present on a dry-run push that would trip the large-change guard. */
+ guard?: GuardReport;
}
class SyncError extends Error {
@@ -71,6 +99,22 @@ class SyncError extends Error {
}
}
+/**
+ * Another machine published between our rebase and our push, every time we
+ * retried. That is contention, not an outage: the next run simply tries again.
+ */
+export class PushRaceError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "PushRaceError";
+ }
+}
+
+/** Commit trailer naming the machine that produced a sync commit. */
+export const DEVICE_TRAILER = "Opencode-Sync-Device";
+
+const PUSH_RACE_RETRIES = 3;
+
interface Context {
root: string;
branch: string;
@@ -80,6 +124,9 @@ interface Context {
reporter: Reporter;
force: boolean;
dryRun: boolean;
+ confirmLarge?: SyncOptions["confirmLarge"];
+ allowLarge: boolean;
+ disabled: CategoryKey[];
}
function prepare(options: SyncOptions): Context {
@@ -101,6 +148,9 @@ function prepare(options: SyncOptions): Context {
reporter: options.reporter ?? silentReporter,
force: options.force ?? false,
dryRun: options.dryRun ?? false,
+ confirmLarge: options.confirmLarge,
+ allowLarge: options.allowLarge ?? false,
+ disabled: disabledCategories(settings.categories),
};
}
@@ -118,9 +168,18 @@ export async function push(options: SyncOptions = {}): Promise {
if (!fs.existsSync(root)) throw new SyncError(`Config directory not found: ${root}`);
- ensureRepo({ root, remote: ctx.remote, branch });
+ const state = adoptRepo(ctx, options.adopt ?? "remote", "first-push");
+ if (state === "adopted" && (options.adopt ?? "remote") === "remote" && !ctx.dryRun) {
+ // The remote's files are now in the worktree, but the untracked trees
+ // (skills, state, extra paths) still hold this machine's old contents.
+ // Write the remote's versions out first; staging the stale trees in would
+ // otherwise publish every skill this machine lacks as a deletion.
+ stageOut(createStageContext(root, ctx.settings, reporter, false, ctx.roots));
+ writeStageOutMark(root);
+ }
assertRemoteReachable(root, branch, "Push", true);
writeRepoMetadata(root);
+ writeLocalExcludes(root, ctx.settings.guard?.skipDirs ?? []);
try {
return await runPush(ctx);
@@ -154,6 +213,7 @@ async function runPush(ctx: Context): Promise {
git(["add", "-A"], { cwd: root });
unstageLocalRuntimePaths(root);
+ unstageDisabled(ctx);
const staged = git(["diff", "--cached", "--name-status"], { cwd: root });
const files = toChangedFiles(staged, true);
@@ -176,7 +236,7 @@ async function runPush(ctx: Context): Promise {
);
}
reporter.step(`Pushing ${ahead} pending commit(s)`);
- const integrated = pushBranch(ctx, true);
+ const integrated = pushBranch(ctx);
await finishPush(ctx, integrated);
return done(
"push",
@@ -194,7 +254,13 @@ async function runPush(ctx: Context): Promise {
// anything — and the result must reach the real directories, or the next
// push would read everything new as deleted on this machine.
reporter.step(`Fast-forwarding ${behind} remote commit(s)`);
- const ff = git(["merge", "--ff-only", `origin/${branch}`], { cwd: root });
+ const aside = setAsideDisabled(ctx);
+ let ff: GitResult;
+ try {
+ ff = git(["merge", "--ff-only", `origin/${branch}`], { cwd: root });
+ } finally {
+ restoreDisabled(root, ctx.disabled, aside);
+ }
if (!ff.ok) throw toGitError("Fast-forward", ff);
await finishPush(ctx, true);
return done(
@@ -212,8 +278,15 @@ async function runPush(ctx: Context): Promise {
}
reporter.changes(summary, files);
+ const guard = ctx.allowLarge
+ ? undefined
+ : firstPushGuard(
+ root,
+ branch,
+ evaluateGuard(root, files, ctx.settings.guard ?? DEFAULT_SETTINGS.guard),
+ );
if (ctx.dryRun) {
- return done(
+ const result = done(
"push",
true,
summary,
@@ -221,17 +294,31 @@ async function runPush(ctx: Context): Promise {
`${totalChanges(summary)} change(s) would be pushed`,
sessions,
);
+ if (guard && guard.reasons.length > 0) result.guard = guard;
+ return result;
+ }
+
+ if (guard && guard.reasons.length > 0) {
+ const allowed = ctx.confirmLarge ? await ctx.confirmLarge(guard) : false;
+ if (!allowed) {
+ // Leave nothing staged: the next attempt must re-evaluate from scratch.
+ git(["reset", "-q"], { cwd: root });
+ throw new LargeChangeError(guard);
+ }
}
const alias = hostAlias(ctx.settings.machineAlias);
- const commit = git(["commit", "-m", commitMessage("sync", alias)], { cwd: root });
+ const commit = git(
+ ["commit", "-m", commitMessage("sync", alias), "-m", `${DEVICE_TRAILER}: ${alias}`],
+ { cwd: root },
+ );
if (!commit.ok) throw toGitError("Commit", commit);
// The commit was built from this machine's directories, so they match it.
// If the rebase or the network push below fails, the next run sees the mark
// trailing HEAD and writes the integrated tree out before staging again.
writeStageOutMark(root);
- const integrated = pushBranch(ctx, false);
+ const integrated = pushBranch(ctx);
await finishPush(ctx, integrated);
return done(
"push",
@@ -262,13 +349,20 @@ async function finishPush(ctx: Context, integrated: boolean): Promise {
}
/** Push the branch. Returns true when a rebase integrated remote commits. */
-function pushBranch(ctx: Context, alreadyCommitted: boolean): boolean {
+function pushBranch(ctx: Context): boolean {
const { root, branch, force, reporter } = ctx;
const hasRemoteBranch = git(["rev-parse", `origin/${branch}`], { cwd: root }).ok;
if (!hasRemoteBranch) {
const result = git(["push", "-u", "origin", branch], { cwd: root });
- if (!result.ok) throw toGitError("Push", result);
+ if (!result.ok) {
+ if (isRejected(result)) {
+ throw new PushRaceError(
+ "Another machine created the branch at the same moment. The next sync will retry.",
+ );
+ }
+ throw toGitError("Push", result);
+ }
assertPushLanded(root, branch, "Push");
return false;
}
@@ -289,26 +383,91 @@ function pushBranch(ctx: Context, alreadyCommitted: boolean): boolean {
return false;
}
- const before = headCommit(root);
- const rebase = git(["pull", "--rebase", "origin", branch], { cwd: root });
- if (!rebase.ok) {
- git(["rebase", "--abort"], { cwd: root });
- throw new SyncError(
- "The remote has changes that conflict with this machine.\n" +
- "Run `opencode-sync pull` to take the remote version, or `opencode-sync push --force` to overwrite it.",
- );
+ // Another machine can publish between our rebase and our push. Losing that
+ // race is contention, not failure: rebase onto the newcomer and try again.
+ // Files in disabled categories are set aside so local-only edits never
+ // block the rebase.
+ const aside = setAsideDisabled(ctx);
+ let integrated = false;
+ try {
+ for (let attempt = 1; ; attempt++) {
+ const before = headCommit(root);
+ const rebase = git(["pull", "--rebase", "origin", branch], { cwd: root });
+ if (!rebase.ok) {
+ git(["rebase", "--abort"], { cwd: root });
+ throw new SyncError(
+ "The remote has changes that conflict with this machine.\n" +
+ "Run `opencode-sync pull` to take the remote version, or `opencode-sync push --force` to overwrite it.",
+ );
+ }
+ // The rebase moves HEAD only when it replayed local commits onto new
+ // remote ones; an unchanged HEAD means nothing arrived.
+ if (headCommit(root) !== before) integrated = true;
+
+ const result = git(["push", "origin", branch], { cwd: root });
+ if (result.ok) break;
+ if (!isRejected(result)) throw toGitError("Push", result);
+ if (attempt >= PUSH_RACE_RETRIES) {
+ throw new PushRaceError(
+ `Another machine kept publishing first (${attempt} attempts). The next sync will retry.`,
+ );
+ }
+ reporter.step("Another machine pushed first; rebasing and retrying");
+ }
+ } finally {
+ restoreDisabled(root, ctx.disabled, aside);
}
- // The rebase moves HEAD only when it replayed local commits onto new remote
- // ones; an unchanged HEAD means nothing arrived.
- const integrated = headCommit(root) !== before;
-
- const result = git(["push", "origin", branch], { cwd: root });
- if (!result.ok) throw toGitError("Push", result);
assertPushLanded(root, branch, "Push");
- void alreadyCommitted;
return integrated;
}
+/**
+ * The first push into an empty repository sends the whole existing setup, so
+ * a high file count or total size is expected there. Only the single-file
+ * limit still applies: a multi-megabyte file is suspicious on any push.
+ */
+function firstPushGuard(root: string, branch: string, report: GuardReport): GuardReport {
+ if (git(["rev-parse", "--verify", `origin/${branch}`], { cwd: root }).ok) return report;
+ return { ...report, reasons: report.reasons.filter((r) => r === "file") };
+}
+
+/**
+ * True only when the push lost a race: the remote moved under us, either
+ * before the push (`fetch first`, `non-fast-forward`) or during the ref update
+ * itself (`cannot lock ref ... but expected`). A protected branch or a
+ * declined hook also prints "failed to push some refs", and must surface as a
+ * real failure rather than be retried for ever.
+ */
+function isRejected(result: GitResult): boolean {
+ return /\((fetch first|non-fast-forward)\)|cannot lock ref .* but expected/i.test(
+ `${result.stderr}\n${result.stdout}`,
+ );
+}
+
+// ── Disabled categories ─────────────────────────────────────────────────────
+
+function unstageDisabled(ctx: Context): void {
+ if (ctx.disabled.length === 0) return;
+ git(["reset", "-q", "--", ...disabledPathspecs(ctx.disabled)], { cwd: ctx.root });
+}
+
+/**
+ * Copy the disabled categories aside and bring their tracked files back to
+ * HEAD, so a rebase or reset can run on a clean tree. `restoreDisabled` puts
+ * the local versions back afterwards.
+ */
+function setAsideDisabled(ctx: Context): CategorySnapshot | null {
+ if (ctx.disabled.length === 0) return null;
+ const snapshot = snapshotDisabled(ctx.root, ctx.disabled);
+ if (!snapshot) return null;
+ // Untracked local-only files would block an incoming file at the same path.
+ for (const rel of snapshot.files) fs.rmSync(path.join(ctx.root, rel), { force: true });
+ const tracked = git(["ls-files", "--", ...disabledPathspecs(ctx.disabled)], { cwd: ctx.root });
+ const files = tracked.ok ? tracked.stdout.split("\n").filter(Boolean) : [];
+ if (files.length > 0) git(["checkout", "HEAD", "--", ...files], { cwd: ctx.root });
+ return snapshot;
+}
+
// ── Pull ────────────────────────────────────────────────────────────────────
export async function pull(options: SyncOptions = {}): Promise {
@@ -318,7 +477,7 @@ export async function pull(options: SyncOptions = {}): Promise {
fs.mkdirSync(root, { recursive: true });
if (!isGitRepo(root)) {
- const state = ensureRepo({ root, remote: ctx.remote, branch });
+ const state = adoptRepo(ctx, "remote", "first-pull");
if (state === "fresh") {
return done(
"pull",
@@ -392,28 +551,37 @@ export async function pull(options: SyncOptions = {}): Promise {
// that would conflict on every single run.
normalizeConfigForRepo(stageCtx);
- const stashed = prepareWorktree(ctx);
- const nameStatus = git(["diff", "--name-status", "HEAD", `origin/${branch}`], { cwd: root });
- const files = toChangedFiles(nameStatus, true);
- const summary = summarize(files);
+ if (force && !ctx.dryRun) saveOverwritten(ctx, `origin/${branch}`, "force-pull");
+ const aside = ctx.dryRun ? null : setAsideDisabled(ctx);
+ let stashed = false;
+ let files: ChangedFile[] = [];
+ let summary = emptySummary();
+ try {
+ stashed = prepareWorktree(ctx);
+ const nameStatus = git(["diff", "--name-status", "HEAD", `origin/${branch}`], { cwd: root });
+ files = toChangedFiles(nameStatus, true).filter((f) => !isDisabledPath(f.path, ctx.disabled));
+ summary = summarize(files);
- if (ctx.dryRun) {
- if (stashed) restoreStash(ctx);
- return done(
- "pull",
- files.length > 0,
- summary,
- files,
- `${totalChanges(summary)} change(s) would be pulled`,
- );
- }
+ if (ctx.dryRun) {
+ if (stashed) restoreStash(ctx);
+ return done(
+ "pull",
+ files.length > 0,
+ summary,
+ files,
+ `${totalChanges(summary)} change(s) would be pulled`,
+ );
+ }
- const reset = git(["reset", "--hard", `origin/${branch}`], { cwd: root });
- if (!reset.ok) {
+ const reset = git(["reset", "--hard", `origin/${branch}`], { cwd: root });
+ if (!reset.ok) {
+ if (stashed) restoreStash(ctx);
+ throw toGitError("Reset", reset);
+ }
if (stashed) restoreStash(ctx);
- throw toGitError("Reset", reset);
+ } finally {
+ restoreDisabled(root, ctx.disabled, aside);
}
- if (stashed) restoreStash(ctx);
stageOut(createStageContext(root, ctx.settings, reporter, force, ctx.roots));
const sessions = await maybeImportSessions(ctx);
@@ -643,6 +811,63 @@ function countRealChanges(root: string, porcelain: string): number {
// ── Helpers ─────────────────────────────────────────────────────────────────
+/**
+ * Make the config directory a working copy of the remote.
+ *
+ * When the remote already has history and `mode` is `remote`, the files it
+ * is about to overwrite are copied to recovery first, and files in disabled
+ * categories are set aside and put back, so they stay this machine's own.
+ */
+function adoptRepo(ctx: Context, mode: "remote" | "local", label: string): RepoState {
+ let aside: CategorySnapshot | null = null;
+ try {
+ return ensureRepo({
+ root: ctx.root,
+ remote: ctx.remote,
+ branch: ctx.branch,
+ adopt: mode,
+ beforeAdopt: (_root, ref) => {
+ saveOverwritten(ctx, ref, label);
+ aside = snapshotDisabled(ctx.root, ctx.disabled);
+ },
+ });
+ } finally {
+ restoreDisabled(ctx.root, ctx.disabled, aside);
+ }
+}
+
+/**
+ * Copy every local file that a move to `ref` would overwrite or delete into
+ * the recovery folder. Called before a forced pull and before a first link
+ * adopts the remote, the two moments local bytes are replaced wholesale.
+ */
+function saveOverwritten(ctx: Context, ref: string, label: string): string | undefined {
+ const { root } = ctx;
+ const candidates = new Set();
+ const hasHead = git(["rev-parse", "--verify", "HEAD"], { cwd: root }).ok;
+ if (hasHead) {
+ const diff = git(["diff", "-z", "--name-only", "HEAD", ref], { cwd: root });
+ for (const p of diff.ok ? diff.stdout.split("\0") : []) if (p) candidates.add(p);
+ // `--untracked-files=all` lists each file in a new folder; the default
+ // reports the folder alone, which is not a file and would be skipped
+ // right before `git clean` deletes it.
+ const dirty = git(
+ ["status", "-z", "--porcelain", "--untracked-files=all", "--", ".", ...excludePathspecs()],
+ { cwd: root },
+ );
+ for (const p of parsePorcelainZ(dirty.ok ? dirty.stdout : "")) candidates.add(p);
+ } else {
+ const files = git(["ls-tree", "-z", "-r", "--name-only", ref], { cwd: root });
+ for (const p of files.ok ? files.stdout.split("\0") : []) if (p) candidates.add(p);
+ }
+ const entries = [...candidates]
+ .filter((rel) => !isLocalRuntimePath(rel))
+ .map((rel) => ({ source: path.join(root, rel), rel }));
+ const dir = saveToRecovery(ctx.roots, label, entries);
+ if (dir) ctx.reporter.detail(`Saved the replaced local files to ${dir}`);
+ return dir;
+}
+
function countCommits(root: string, range: string): number {
const result = git(["rev-list", "--count", range], { cwd: root });
return result.ok ? Number(result.stdout) || 0 : 0;
diff --git a/src/core/update.ts b/src/core/update.ts
new file mode 100644
index 0000000..01a1d22
--- /dev/null
+++ b/src/core/update.ts
@@ -0,0 +1,87 @@
+import { execFileSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import { childEnv } from "./env.js";
+
+/**
+ * Self-update through npm.
+ *
+ * Only ever invoked by an explicit menu choice, or by the scheduled job when
+ * the user switched auto-update on. Never runs on its own otherwise.
+ */
+
+export const PACKAGE_NAME = "opencode-github-sync";
+
+export function currentVersion(moduleDir: string): string {
+ try {
+ const pkg = JSON.parse(
+ fs.readFileSync(path.resolve(moduleDir, "..", "..", "package.json"), "utf8"),
+ );
+ return String(pkg.version ?? "0.0.0");
+ } catch {
+ return "0.0.0";
+ }
+}
+
+/** Compare dotted numeric versions; pre-release suffixes sort before release. */
+export function compareVersions(a: string, b: string): number {
+ const parse = (v: string) => {
+ const [main = "", pre = ""] = v.replace(/^v/, "").split("-", 2);
+ return { nums: main.split(".").map((n) => Number(n) || 0), pre };
+ };
+ const x = parse(a);
+ const y = parse(b);
+ for (let i = 0; i < Math.max(x.nums.length, y.nums.length); i++) {
+ const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0);
+ if (d !== 0) return d > 0 ? 1 : -1;
+ }
+ if (x.pre === y.pre) return 0;
+ if (!x.pre) return 1;
+ if (!y.pre) return -1;
+ return x.pre > y.pre ? 1 : -1;
+}
+
+function npm(args: string[], timeout = 120_000): string {
+ // npm is a batch file on Windows and Node refuses to spawn .cmd directly;
+ // going through cmd.exe with fixed arguments avoids a shell-string build.
+ const [command, prefix] =
+ process.platform === "win32"
+ ? [process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "npm"]]
+ : ["npm", []];
+ return execFileSync(command, [...prefix, ...args], {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ env: childEnv(),
+ timeout,
+ windowsHide: true,
+ }).trim();
+}
+
+export function latestVersion(): string {
+ return npm(["view", PACKAGE_NAME, "version"], 60_000);
+}
+
+export interface UpdateCheck {
+ current: string;
+ latest?: string;
+ newer: boolean;
+ error?: string;
+}
+
+export function checkForUpdate(moduleDir: string): UpdateCheck {
+ const current = currentVersion(moduleDir);
+ try {
+ const latest = latestVersion();
+ return { current, latest, newer: compareVersions(latest, current) > 0 };
+ } catch (error) {
+ return { current, newer: false, error: (error as Error).message.split("\n")[0] };
+ }
+}
+
+export function installLatest(): void {
+ npm(["install", "-g", `${PACKAGE_NAME}@latest`], 300_000);
+}
+
+export function uninstallGlobal(): void {
+ npm(["uninstall", "-g", PACKAGE_NAME], 300_000);
+}
diff --git a/src/plugin/index.ts b/src/plugin/index.ts
index 76592e6..c0cce3b 100644
--- a/src/plugin/index.ts
+++ b/src/plugin/index.ts
@@ -2,7 +2,7 @@ import fs from "node:fs";
import { withSyncLock } from "../core/lock.js";
import { getRoots } from "../core/paths.js";
import { CollectingReporter } from "../core/reporter.js";
-import { loadSettings, repoUrl, settingsPath } from "../core/settings.js";
+import { idlePushEnabled, loadSettings, repoUrl, settingsPath } from "../core/settings.js";
import { pull, push, status } from "../core/sync.js";
/**
@@ -239,6 +239,7 @@ export const OpencodeGithubSync = async (ctx: PluginContext) => {
}
const settings = loadSettings(roots.config);
+ const idlePush = idlePushEnabled(settings);
if (settings.autoPullOnStartup) {
// Deliberately not awaited: OpenCode should finish starting even when the
@@ -248,7 +249,7 @@ export const OpencodeGithubSync = async (ctx: PluginContext) => {
return {
event: async ({ event }: { event: { type: string } }) => {
- if (event.type === "session.idle" && settings.autoPushOnIdle) {
+ if (event.type === "session.idle" && idlePush) {
void runPush(client, false);
}
},
@@ -302,7 +303,7 @@ async function setup(ctx: V2Context): Promise<() => void> {
});
});
- if (settings.autoPushOnIdle) {
+ if (idlePushEnabled(settings)) {
void (async () => {
try {
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
diff --git a/tests/__snapshots__/scheduler.test.ts.snap b/tests/__snapshots__/scheduler.test.ts.snap
new file mode 100644
index 0000000..f6c3d41
--- /dev/null
+++ b/tests/__snapshots__/scheduler.test.ts.snap
@@ -0,0 +1,216 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Linux systemd and cron > renders a crontab line and edits only its own entry 1`] = `"*/10 * * * * PATH='/Users/李 雷/.nvm/versions/node/v22.11.0/bin:/usr/bin:/bin' '/Users/李 雷/.nvm/versions/node/v22.11.0/bin/node' '/Users/李 雷/.nvm/versions/node/v22.11.0/lib/node_modules/opencode-github-sync/dist/cli/index.js' 'autosync' 'run' >> '/Users/李 雷/.config/opencode/logs/autosync-cron.log' 2>&1 # opencode-github-sync autosync"`;
+
+exports[`Linux systemd and cron > renders the user service and timer 1`] = `
+"[Unit]
+Description=opencode-github-sync autosync
+
+[Service]
+Type=oneshot
+ExecStart="/Users/李 雷/.nvm/versions/node/v22.11.0/bin/node" "/Users/李 雷/.nvm/versions/node/v22.11.0/lib/node_modules/opencode-github-sync/dist/cli/index.js" autosync run
+Environment="PATH=/Users/李 雷/.nvm/versions/node/v22.11.0/bin:/usr/bin:/bin"
+TimeoutStartSec=600
+"
+`;
+
+exports[`Linux systemd and cron > renders the user service and timer 2`] = `
+"[Unit]
+Description=Run opencode-github-sync autosync periodically
+
+[Timer]
+OnBootSec=1min
+OnUnitActiveSec=10min
+Persistent=true
+Unit=opencode-github-sync.service
+
+[Install]
+WantedBy=timers.target
+"
+`;
+
+exports[`Windows Task Scheduler > renders S4U with the wscript launcher 1`] = `
+"
+
+
+ Keeps OpenCode configuration in sync with its GitHub repository (opencode-github-sync).
+ \\opencode-github-sync
+
+
+
+ 2026-10-26T08:00:00
+ true
+
+ PT5M
+ false
+
+
+
+ true
+ CORP\\user
+ PT30S
+
+
+ true
+ CORP\\user
+ PT15S
+ SessionUnlock
+
+
+ true
+ CORP\\user
+ PT15S
+ RemoteConnect
+
+
+
+
+ CORP\\user
+ S4U
+ LeastPrivilege
+
+
+
+ IgnoreNew
+ false
+ false
+ true
+ true
+ false
+
+ false
+ false
+
+ true
+ true
+ false
+ false
+ false
+ PT10M
+ 7
+
+
+
+ C:\\Windows\\System32\\wscript.exe
+ //B //Nologo "C:\\Users\\王小明 Wang\\.config\\opencode\\opencode-sync-autosync.vbs"
+
+
+
+"
+`;
+
+exports[`Windows Task Scheduler > renders the task XML with conhost --headless 1`] = `
+"
+
+
+ Keeps OpenCode configuration in sync with its GitHub repository (opencode-github-sync).
+ \\opencode-github-sync
+
+
+
+ 2026-10-26T08:00:00
+ true
+
+ PT5M
+ false
+
+
+
+ true
+ DESKTOP-1\\王小明
+ PT30S
+
+
+ true
+ DESKTOP-1\\王小明
+ PT15S
+ SessionUnlock
+
+
+ true
+ DESKTOP-1\\王小明
+ PT15S
+ RemoteConnect
+
+
+
+
+ DESKTOP-1\\王小明
+ InteractiveToken
+ LeastPrivilege
+
+
+
+ IgnoreNew
+ false
+ false
+ true
+ true
+ false
+
+ false
+ false
+
+ true
+ true
+ false
+ false
+ false
+ PT10M
+ 7
+
+
+
+ C:\\Windows\\System32\\conhost.exe
+ --headless "C:\\Program Files\\nodejs\\node.exe" "C:\\Users\\王小明 Wang\\AppData\\Roaming\\npm\\node_modules\\opencode-github-sync\\dist\\cli\\index.js" autosync run
+
+
+
+"
+`;
+
+exports[`Windows Task Scheduler > writes the VBScript launcher as UTF-16LE with a BOM 1`] = `
+"' Generated by opencode-github-sync. Starts the autosync job with no window.
+' Rewritten on every \`opencode-sync autosync install\`.
+Dim shell, env, code
+Set shell = CreateObject("WScript.Shell")
+Set env = shell.Environment("PROCESS")
+env("SYNC_HOME") = "C:\\Users\\王小明 Wang"
+code = shell.Run("""C:\\Program Files\\nodejs\\node.exe"" ""C:\\Users\\王小明 Wang\\AppData\\Roaming\\npm\\node_modules\\opencode-github-sync\\dist\\cli\\index.js"" ""autosync"" ""run""", 0, True)
+WScript.Quit code
+"
+`;
+
+exports[`macOS launchd > renders the agent plist 1`] = `
+"
+
+
+
+ Label
+ com.opencode-github-sync.autosync
+ ProgramArguments
+
+ /Users/李 雷/.nvm/versions/node/v22.11.0/bin/node
+ /Users/李 雷/.nvm/versions/node/v22.11.0/lib/node_modules/opencode-github-sync/dist/cli/index.js
+ autosync
+ run
+
+ EnvironmentVariables
+
+ PATH
+ /Users/李 雷/.nvm/versions/node/v22.11.0/bin:/usr/bin:/bin
+
+ RunAtLoad
+
+ StartInterval
+ 600
+ ProcessType
+ Background
+ StandardOutPath
+ /Users/李 雷/.config/opencode/logs/autosync-launchd.log
+ StandardErrorPath
+ /Users/李 雷/.config/opencode/logs/autosync-launchd.log
+
+
+"
+`;
diff --git a/tests/autosync.test.ts b/tests/autosync.test.ts
new file mode 100644
index 0000000..2034e79
--- /dev/null
+++ b/tests/autosync.test.ts
@@ -0,0 +1,356 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+ type AutosyncDeps,
+ BACKOFF_MAX_MS,
+ ENGLISH_MESSAGES,
+ backoffDelayMs,
+ backoffRemainingMs,
+ newestWrite,
+ readState,
+ runAutosync,
+ staggerMs,
+ writeState,
+} from "../src/core/autosync.js";
+import { LargeChangeError } from "../src/core/guard.js";
+import type { Roots } from "../src/core/paths.js";
+import { emptySummary } from "../src/core/reporter.js";
+import { type Settings, defaultSettings } from "../src/core/settings.js";
+import { PushRaceError, type SyncResult } from "../src/core/sync.js";
+
+/**
+ * The scheduling rules, exercised with a fake clock and fake sync functions.
+ * End-to-end runs against a real bare repository live in two-machines.test.ts.
+ */
+
+let sandbox: string;
+let roots: Roots;
+
+beforeEach(() => {
+ sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "ogs-auto-"));
+ roots = {
+ config: path.join(sandbox, "config"),
+ data: path.join(sandbox, "data"),
+ state: path.join(sandbox, "state"),
+ agents: path.join(sandbox, "agents"),
+ cache: path.join(sandbox, "cache"),
+ };
+ for (const dir of Object.values(roots)) fs.mkdirSync(dir, { recursive: true });
+});
+
+afterEach(() => {
+ fs.rmSync(sandbox, { recursive: true, force: true });
+});
+
+const MIN = 60_000;
+const T0 = Date.UTC(2026, 9, 26, 8, 0, 0);
+
+function result(message: string): SyncResult {
+ return {
+ action: "push",
+ changed: true,
+ summary: emptySummary(),
+ files: [],
+ message,
+ restartRequired: false,
+ };
+}
+
+interface Harness {
+ deps: AutosyncDeps;
+ clock: { now: number };
+ calls: string[];
+ notes: string[];
+ settings: Settings;
+ logs: string[];
+}
+
+function harness(patch: Partial = {}): Harness {
+ const clock = { now: T0 };
+ const calls: string[] = [];
+ const notes: string[] = [];
+ const logs: string[] = [];
+ const settings = defaultSettings();
+ settings.autosync.enabled = true;
+ const deps: AutosyncDeps = {
+ roots,
+ now: () => clock.now,
+ sleep: async (ms) => {
+ calls.push(`sleep:${ms}`);
+ },
+ random: () => 0,
+ loadSettings: () => settings,
+ push: async () => {
+ calls.push("push");
+ return result("Pushed 1 change(s)");
+ },
+ pull: async () => {
+ calls.push("pull");
+ return result("Pulled 1 change(s)");
+ },
+ fetchBehind: () => {
+ calls.push("fetch");
+ return { ok: true, behind: 1 };
+ },
+ repoSnapshot: () => ({ ok: true, dirty: false, ahead: 0 }),
+ newestWrite: () => 0,
+ newestUntrackedWrite: () => 0,
+ stageOutTime: () => T0 - 10 * MIN,
+ tryLock: () => ({ release: () => calls.push("release") }),
+ notify: (title) => {
+ notes.push(title);
+ return true;
+ },
+ log: (line) => logs.push(line),
+ messages: ENGLISH_MESSAGES,
+ ...patch,
+ };
+ return { deps, clock, calls, notes, settings, logs };
+}
+
+describe("autosync gate", () => {
+ it("does nothing unless enabled on this machine", async () => {
+ const h = harness();
+ h.settings.autosync.enabled = false;
+ const outcome = await runAutosync(h.deps);
+ expect(outcome.action).toBe("skip");
+ expect(h.calls).not.toContain("push");
+ });
+
+ it("pushes when idle, enabled and due", async () => {
+ const h = harness();
+ const outcome = await runAutosync(h.deps);
+ expect(outcome).toMatchObject({ action: "push", ok: true });
+ expect(h.calls).toContain("push");
+ expect(h.calls).toContain("release");
+ expect(readState(roots).lastSuccessAt).toBe(T0);
+ });
+
+ it("respects the interval since the last attempt", async () => {
+ const h = harness();
+ writeState(roots, { lastAttemptAt: T0 - 2 * MIN });
+ const outcome = await runAutosync(h.deps);
+ expect(outcome.action).toBe("skip");
+ expect(outcome.reason).toMatch(/throttled/);
+ });
+
+ it("skips while paused and resumes after", async () => {
+ const h = harness();
+ h.settings.autosync.pausedUntil = T0 + 60 * MIN;
+ expect((await runAutosync(h.deps)).reason).toMatch(/paused/);
+ h.clock.now = T0 + 61 * MIN;
+ expect((await runAutosync(h.deps)).action).toBe("push");
+ });
+
+ it("skips when another sync holds the lock", async () => {
+ const h = harness({ tryLock: () => null });
+ const outcome = await runAutosync(h.deps);
+ expect(outcome).toMatchObject({ action: "skip", reason: "another sync is in progress" });
+ expect(h.calls).not.toContain("push");
+ });
+
+ it("staggers by a stable per-machine offset", async () => {
+ const h = harness();
+ delete process.env.OPENCODE_SYNC_NO_STAGGER;
+ await runAutosync(h.deps);
+ const sleep = h.calls.find((c) => c.startsWith("sleep:"));
+ expect(sleep).toBeDefined();
+ const ms = Number(sleep!.slice(6));
+ expect(ms).toBeGreaterThanOrEqual(0);
+ expect(ms).toBeLessThanOrEqual(45_000);
+ expect(staggerMs("laptop", () => 0)).toBe(staggerMs("laptop", () => 0));
+ expect(staggerMs("laptop", () => 0.999)).toBeLessThan(staggerMs("laptop", () => 0) + 5000);
+ });
+});
+
+describe("settle window", () => {
+ it("holds the push back while files are still changing and receives instead", async () => {
+ const h = harness({ newestWrite: () => T0 - 10_000 });
+ const outcome = await runAutosync(h.deps);
+ expect(outcome.action).toBe("receive");
+ expect(h.calls).toEqual(expect.arrayContaining(["fetch", "pull"]));
+ expect(h.calls).not.toContain("push");
+ });
+
+ it("does not receive when the repository has local work", async () => {
+ const h = harness({
+ newestWrite: () => T0 - 10_000,
+ repoSnapshot: () => ({ ok: true, dirty: true, ahead: 0 }),
+ });
+ const outcome = await runAutosync(h.deps);
+ expect(outcome.action).toBe("skip");
+ expect(h.calls).not.toContain("pull");
+ });
+
+ it("does not receive when commits are waiting to be pushed", async () => {
+ const h = harness({
+ newestWrite: () => T0 - 10_000,
+ repoSnapshot: () => ({ ok: true, dirty: false, ahead: 2 }),
+ });
+ expect((await runAutosync(h.deps)).reason).toMatch(/2 local commit/);
+ });
+
+ it("does not receive over skills edited since the last write-out", async () => {
+ const h = harness({
+ newestWrite: () => T0 - 10_000,
+ newestUntrackedWrite: () => T0 - 20_000,
+ stageOutTime: () => T0 - 60_000,
+ });
+ const outcome = await runAutosync(h.deps);
+ expect(outcome.action).toBe("skip");
+ expect(outcome.reason).toMatch(/skills/);
+ });
+
+ it("does not pull when nothing is waiting", async () => {
+ const h = harness({
+ newestWrite: () => T0 - 10_000,
+ fetchBehind: () => ({ ok: true, behind: 0 }),
+ });
+ const outcome = await runAutosync(h.deps);
+ expect(outcome.action).toBe("skip");
+ expect(h.calls).not.toContain("pull");
+ });
+
+ it("pushes once the tree has been quiet for the settle time", async () => {
+ const h = harness({ newestWrite: () => T0 - 61_000 });
+ expect((await runAutosync(h.deps)).action).toBe("push");
+ });
+
+ it("ignores noisy files when measuring the newest write", () => {
+ const dir = path.join(sandbox, "tree");
+ fs.mkdirSync(path.join(dir, "logs"), { recursive: true });
+ fs.mkdirSync(path.join(dir, "node_modules", "x"), { recursive: true });
+ fs.writeFileSync(path.join(dir, "real.md"), "x");
+ const old = new Date(T0 - 3600_000);
+ fs.utimesSync(path.join(dir, "real.md"), old, old);
+ for (const noisy of ["logs/a.log", "node_modules/x/i.js", "bun.lock", "prompt-history.jsonl"]) {
+ fs.writeFileSync(path.join(dir, noisy), "n");
+ }
+ expect(Math.round(newestWrite([dir]))).toBe(Math.round(old.getTime()));
+ });
+});
+
+describe("backoff", () => {
+ it("doubles from 30 to a 60 minute cap", () => {
+ expect(backoffDelayMs(3)).toBe(30 * MIN);
+ expect(backoffDelayMs(4)).toBe(60 * MIN);
+ expect(backoffDelayMs(10)).toBe(BACKOFF_MAX_MS);
+ });
+
+ it("enters backoff after three failures, notifies once, and recovers", async () => {
+ const h = harness({
+ push: async () => {
+ throw new Error("Push failed: could not resolve host");
+ },
+ });
+
+ for (let i = 1; i <= 3; i++) {
+ h.clock.now = T0 + i * 10 * MIN;
+ const outcome = await runAutosync(h.deps);
+ expect(outcome).toMatchObject({ action: "push", ok: false });
+ }
+ let state = readState(roots);
+ expect(state.consecutiveFailures).toBe(3);
+ expect(h.notes).toEqual([ENGLISH_MESSAGES.backoffTitle]);
+ expect(backoffRemainingMs(state, h.clock.now)).toBe(30 * MIN);
+
+ // Inside the backoff window nothing is attempted.
+ h.clock.now += 10 * MIN;
+ expect((await runAutosync(h.deps)).reason).toMatch(/backing off/);
+
+ // A fourth failure widens the gap and does not notify again.
+ h.clock.now += 21 * MIN;
+ await runAutosync(h.deps);
+ state = readState(roots);
+ expect(state.consecutiveFailures).toBe(4);
+ expect(backoffRemainingMs(state, h.clock.now)).toBe(60 * MIN);
+ expect(h.notes).toHaveLength(1);
+
+ // Never gives up: after the cap it tries again, and success resets.
+ h.deps.push = async () => result("Pushed 1 change(s)");
+ h.clock.now += 61 * MIN;
+ expect((await runAutosync(h.deps)).ok).toBe(true);
+ state = readState(roots);
+ expect(state.consecutiveFailures).toBe(0);
+ expect(h.notes).toEqual([ENGLISH_MESSAGES.backoffTitle, ENGLISH_MESSAGES.recoveredTitle]);
+ });
+
+ it("treats a lost push race as contention, not failure", async () => {
+ const h = harness({
+ push: async () => {
+ throw new PushRaceError("Another machine kept publishing first");
+ },
+ });
+ for (let i = 0; i < 5; i++) {
+ h.clock.now = T0 + i * 10 * MIN;
+ await runAutosync(h.deps);
+ }
+ expect(readState(roots).consecutiveFailures ?? 0).toBe(0);
+ expect(h.notes).toHaveLength(0);
+ });
+
+ it("respects the notification toggle", async () => {
+ const h = harness({
+ push: async () => {
+ throw new Error("network down");
+ },
+ });
+ h.settings.notifications = false;
+ for (let i = 1; i <= 4; i++) {
+ h.clock.now = T0 + i * 40 * MIN;
+ await runAutosync(h.deps);
+ }
+ expect(h.notes).toHaveLength(0);
+ });
+
+ it("never confirms a large change: records a failure and notifies", async () => {
+ const h = harness({
+ push: async () => {
+ throw new LargeChangeError({
+ addedFiles: 999,
+ totalBytes: 1,
+ oversized: [],
+ reasons: ["files"],
+ });
+ },
+ });
+ const outcome = await runAutosync(h.deps);
+ expect(outcome.ok).toBe(false);
+ expect(readState(roots).consecutiveFailures).toBe(1);
+ expect(h.notes).toEqual([ENGLISH_MESSAGES.largeTitle]);
+ });
+
+ it("tolerates a corrupted state file", async () => {
+ fs.writeFileSync(
+ path.join(roots.state, "opencode-sync-autosync.json"),
+ '{"consecutiveFailures":"41"',
+ );
+ const h = harness();
+ expect((await runAutosync(h.deps)).ok).toBe(true);
+ });
+});
+
+describe("self-update", () => {
+ it("checks at most once a day and only when switched on", async () => {
+ let checks = 0;
+ const h = harness({
+ selfUpdate: () => {
+ checks++;
+ return "up to date";
+ },
+ });
+ await runAutosync(h.deps);
+ expect(checks).toBe(0);
+ h.settings.autosync.autoUpdate = true;
+ h.clock.now += 10 * MIN;
+ await runAutosync(h.deps);
+ h.clock.now += 10 * MIN;
+ await runAutosync(h.deps);
+ expect(checks).toBe(1);
+ h.clock.now += 25 * 60 * MIN;
+ await runAutosync(h.deps);
+ expect(checks).toBe(2);
+ });
+});
diff --git a/tests/config.test.ts b/tests/config.test.ts
index 48f17ef..6de3eba 100644
--- a/tests/config.test.ts
+++ b/tests/config.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { parseNameStatus, parsePorcelainPaths } from "../src/core/git.js";
+import { parseNameStatus, parsePorcelainPaths, parsePorcelainZ } from "../src/core/git.js";
import { commitMessage, hostAlias, sanitizeAlias } from "../src/core/host.js";
import { deepMerge, parseJsonc } from "../src/core/jsonc.js";
import { stripOverrides } from "../src/core/overrides.js";
@@ -197,6 +197,13 @@ describe("parsePorcelainPaths", () => {
});
});
+describe("parsePorcelainZ", () => {
+ it("reads NUL-separated records, a trimmed first record and both sides of a rename", () => {
+ const out = "M a b.md\0?? 技能.md\0R new.md\0old.md\0 D gone.md";
+ expect(parsePorcelainZ(out)).toEqual(["a b.md", "技能.md", "new.md", "old.md", "gone.md"]);
+ });
+});
+
describe("parseNameStatus", () => {
it("maps status characters to change kinds", () => {
const entries = parseNameStatus("A\tnew.txt\nM\tmod.txt\nD\tgone.txt");
diff --git a/tests/installers.test.ts b/tests/installers.test.ts
new file mode 100644
index 0000000..75c324f
--- /dev/null
+++ b/tests/installers.test.ts
@@ -0,0 +1,157 @@
+import { execFileSync, spawnSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+
+/**
+ * The installers are checked for syntax and argument handling only. `--dry-run`
+ * prints the plan and exits before anything is installed; the real install
+ * runs in the CI `installer` job on disposable runners.
+ */
+
+const root = path.resolve(__dirname, "..");
+const installSh = path.join(root, "install.sh");
+const installPs1 = path.join(root, "install.ps1");
+
+function findSh(): string | undefined {
+ const candidates =
+ process.platform === "win32"
+ ? ["C:\\Program Files\\Git\\bin\\sh.exe", "C:\\Program Files\\Git\\usr\\bin\\sh.exe"]
+ : ["/bin/sh"];
+ return candidates.find((c) => fs.existsSync(c));
+}
+
+function hasCommand(cmd: string): boolean {
+ return !spawnSync(cmd, ["--version"], { stdio: "ignore" }).error;
+}
+
+const sh = findSh();
+const pwsh = hasCommand("pwsh") ? "pwsh" : undefined;
+
+describe("install.sh", () => {
+ it.skipIf(!sh)("parses as POSIX sh", () => {
+ expect(spawnSync(sh!, ["-n", installSh]).status).toBe(0);
+ });
+
+ it.skipIf(!hasCommand("dash"))("parses and plans under dash, the strictest common sh", () => {
+ expect(spawnSync("dash", ["-n", installSh]).status).toBe(0);
+ const out = execFileSync("dash", [installSh, "--dry-run", "--mode", "auto", "--repo", "o/r"], {
+ encoding: "utf8",
+ });
+ expect(out).toContain("opencode-sync setup --mode auto --repo o/r");
+ });
+
+ it.skipIf(!hasCommand("shellcheck"))("passes shellcheck", () => {
+ const result = spawnSync("shellcheck", ["-s", "sh", installSh], { encoding: "utf8" });
+ expect(result.stdout + result.stderr).toBe("");
+ });
+
+ it("is plain ASCII with LF line endings", () => {
+ const bytes = fs.readFileSync(installSh);
+ expect(bytes.includes(13)).toBe(false);
+ expect([...bytes].every((b) => b < 128)).toBe(true);
+ });
+
+ it.skipIf(!sh)("passes every option through to the CLI", () => {
+ const out = execFileSync(
+ sh!,
+ [
+ installSh,
+ "--dry-run",
+ "--mode",
+ "auto",
+ "--repo=o/r",
+ "--interval",
+ "15",
+ "--lang",
+ "zh-CN",
+ "--proxy",
+ "http://127.0.0.1:7890",
+ ],
+ { encoding: "utf8" },
+ );
+ expect(out.split("\n")).toEqual([
+ "npm install -g opencode-github-sync@latest",
+ "opencode-sync setup --mode auto --repo o/r --interval 15 --lang zh-CN --proxy http://127.0.0.1:7890",
+ "proxy http://127.0.0.1:7890",
+ "",
+ ]);
+ });
+
+ it.skipIf(!sh)("opens the menu when no mode is given", () => {
+ const out = execFileSync(sh!, [installSh, "--dry-run"], { encoding: "utf8" });
+ expect(out).toBe("npm install -g opencode-github-sync@latest\nopencode-sync\n");
+ });
+
+ it.skipIf(!sh)("rejects invalid options", () => {
+ for (const args of [
+ ["--mode", "link"],
+ ["--mode", "bogus"],
+ ["--interval", "1"],
+ ["--interval", "abc"],
+ ["--lang", "fr"],
+ ["--unknown"],
+ ]) {
+ const result = spawnSync(sh!, [installSh, "--dry-run", ...args], { encoding: "utf8" });
+ expect(result.status, args.join(" ")).toBe(2);
+ }
+ });
+});
+
+describe("install.ps1", () => {
+ it("is plain ASCII", () => {
+ const bytes = fs.readFileSync(installPs1);
+ expect([...bytes].every((b) => b < 128)).toBe(true);
+ });
+
+ it.skipIf(!pwsh)("parses with the PowerShell parser", () => {
+ const script = `$e=$null; [void][System.Management.Automation.Language.Parser]::ParseFile('${installPs1.replace(/'/g, "''")}',[ref]$null,[ref]$e); $e.Count`;
+ const out = execFileSync(pwsh!, ["-NoProfile", "-NonInteractive", "-Command", script], {
+ encoding: "utf8",
+ });
+ expect(out.trim()).toBe("0");
+ });
+
+ it.skipIf(!pwsh)("passes parameters through, quoting paths with spaces", () => {
+ const out = execFileSync(
+ pwsh!,
+ [
+ "-NoProfile",
+ "-NonInteractive",
+ "-File",
+ installPs1,
+ "-DryRun",
+ "-Mode",
+ "link",
+ "-Repo",
+ "C:\\my repos\\sync.git",
+ "-Interval",
+ "10",
+ "-Lang",
+ "en",
+ ],
+ { encoding: "utf8" },
+ );
+ expect(out.trim().split(/\r?\n/)).toEqual([
+ "npm install -g opencode-github-sync@latest",
+ 'opencode-sync setup --mode link --repo "C:\\my repos\\sync.git" --interval 10 --lang en',
+ ]);
+ });
+
+ it.skipIf(!pwsh)("works as a script block, the documented way to pass parameters", () => {
+ const script = `$s=[IO.File]::ReadAllText('${installPs1.replace(/'/g, "''")}'); & ([scriptblock]::Create($s)) -Mode auto -Repo o/r -DryRun`;
+ const out = execFileSync(pwsh!, ["-NoProfile", "-NonInteractive", "-Command", script], {
+ encoding: "utf8",
+ });
+ expect(out).toContain("opencode-sync setup --mode auto --repo o/r");
+ });
+
+ it.skipIf(!pwsh)("refuses -Mode link without -Repo", () => {
+ const result = spawnSync(
+ pwsh!,
+ ["-NoProfile", "-NonInteractive", "-File", installPs1, "-DryRun", "-Mode", "link"],
+ { encoding: "utf8" },
+ );
+ expect(result.status).not.toBe(0);
+ });
+});
diff --git a/tests/menu.test.ts b/tests/menu.test.ts
new file mode 100644
index 0000000..e8569b9
--- /dev/null
+++ b/tests/menu.test.ts
@@ -0,0 +1,333 @@
+import { execFileSync } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { Readable, Writable } from "node:stream";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import type { SchedulerApi, UpdateApi } from "../src/cli/app.js";
+import { type Lang, setLang } from "../src/cli/i18n.js";
+import { runMenu, runSetup } from "../src/cli/menu.js";
+import { setOutput } from "../src/cli/render.js";
+import { Ui } from "../src/cli/ui.js";
+import { git } from "../src/core/git.js";
+import type { Roots } from "../src/core/paths.js";
+import { CollectingReporter } from "../src/core/reporter.js";
+import { defaultSettings, loadSettings, saveSettings } from "../src/core/settings.js";
+import { push } from "../src/core/sync.js";
+
+/**
+ * The menu, driven end to end with an injected input stream. Line mode is what
+ * runs whenever stdin is not a TTY, so every prompt is answerable from a pipe.
+ */
+
+let sandbox: string;
+let remote: string;
+let roots: Roots;
+let output: string;
+
+beforeEach(() => {
+ sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "ogs-menu-"));
+ remote = path.join(sandbox, "remote.git");
+ execFileSync("git", ["init", "--bare", "-b", "main", remote], { stdio: "pipe" });
+ roots = makeRoots("me");
+ output = "";
+ setOutput({
+ write: (text: string) => {
+ output += text;
+ return true;
+ },
+ });
+ process.env.OPENCODE_SYNC_NO_GH = "1";
+});
+
+afterEach(() => {
+ setOutput(process.stdout);
+ setLang("en");
+ delete process.env.OPENCODE_SYNC_NO_GH;
+ fs.rmSync(sandbox, { recursive: true, force: true });
+});
+
+function makeRoots(name: string): Roots {
+ const base = path.join(sandbox, name);
+ const r: Roots = {
+ config: path.join(base, "config"),
+ data: path.join(base, "data"),
+ state: path.join(base, "state"),
+ agents: path.join(base, "agents", "skills"),
+ cache: path.join(base, "cache"),
+ };
+ for (const dir of Object.values(r)) fs.mkdirSync(dir, { recursive: true });
+ return r;
+}
+
+function fakeScheduler(): SchedulerApi & { installed: number; removed: number } {
+ const api = {
+ installed: 0,
+ removed: 0,
+ install: () => {
+ api.installed++;
+ return { ok: true, kind: "schtasks" as const, detail: [], warnings: [] };
+ },
+ uninstall: () => {
+ api.removed++;
+ return { removed: true };
+ },
+ status: () => ({ installed: api.installed > api.removed, kind: "schtasks" as const }),
+ elevated: () => false,
+ };
+ return api;
+}
+
+const fakeUpdater = (): UpdateApi & { installs: number } => {
+ const api = {
+ installs: 0,
+ check: () => ({ current: "3.2.0", latest: "3.3.0", newer: true }),
+ install: () => {
+ api.installs++;
+ },
+ uninstall: () => {},
+ };
+ return api;
+};
+
+async function drive(
+ lines: string[],
+ lang: Lang,
+ extra: { scheduler?: SchedulerApi; updater?: UpdateApi } = {},
+) {
+ setLang(lang);
+ let echo = "";
+ const ui = new Ui({
+ input: Readable.from([`${lines.join("\n")}\n`]),
+ output: new Writable({
+ write(chunk, _enc, cb) {
+ echo += chunk.toString();
+ cb();
+ },
+ }),
+ interactive: false,
+ });
+ const scheduler = extra.scheduler ?? fakeScheduler();
+ await runMenu({
+ ui,
+ roots,
+ scheduler,
+ updater: extra.updater ?? fakeUpdater(),
+ openEditor: () => true,
+ discover: () => [],
+ cwd: sandbox,
+ now: () => Date.UTC(2026, 9, 26, 8, 0, 0),
+ });
+ return `${output}\n${echo}`;
+}
+
+function linkSettings(): void {
+ const s = defaultSettings();
+ s.repo = { url: remote, branch: "main" };
+ s.machineAlias = "me";
+ saveSettings(s, roots.config);
+}
+
+describe("first run", () => {
+ it("install-only exits without writing settings (English)", async () => {
+ const text = await drive(["1"], "en");
+ expect(text).toContain("This machine is not linked to a sync repository yet.");
+ expect(text).toContain("Just install the command");
+ expect(fs.existsSync(path.join(roots.config, "opencode-sync.jsonc"))).toBe(false);
+ });
+
+ it("links an empty repository, pushes, and turns on autosync (Chinese)", async () => {
+ fs.writeFileSync(path.join(roots.config, "opencode.jsonc"), '{ "model": "x" }\n');
+ const scheduler = fakeScheduler();
+ const text = await drive(["3", "2", remote, "q"], "zh-CN", { scheduler });
+ expect(text).toContain("这台电脑还没有关联同步仓库。");
+ expect(text).toContain("仓库是空的");
+ expect(text).toContain("自动同步已开启");
+ expect(scheduler.installed).toBe(1);
+ const settings = loadSettings(roots.config);
+ expect(settings.autosync.enabled).toBe(true);
+ expect(git(["ls-remote", remote], { cwd: sandbox }).stdout).toContain("refs/heads/main");
+ });
+
+ it("shows the differences, then lets the repository win", async () => {
+ const other = makeRoots("other");
+ fs.writeFileSync(path.join(other.config, "opencode.jsonc"), '{ "model": "shared" }\n');
+ const s = defaultSettings();
+ s.repo = { url: remote, branch: "main" };
+ await push({ settings: s, roots: other, reporter: new CollectingReporter() });
+
+ fs.writeFileSync(path.join(roots.config, "opencode.jsonc"), '{ "model": "mine" }\n');
+ const text = await drive(["2", "2", remote, "3", "1", "q"], "en");
+ expect(text).toContain("differ from the repository in 1 place");
+ expect(text).toContain("different");
+ expect(fs.readFileSync(path.join(roots.config, "opencode.jsonc"), "utf8")).toContain("shared");
+ expect(text).toContain("Replaced files were saved to");
+ });
+});
+
+describe("main menu", () => {
+ it("shows the header and every item in both languages", async () => {
+ linkSettings();
+ const en = await drive(["q"], "en");
+ for (const label of [
+ "repository",
+ "this machine",
+ "autosync",
+ "Sync now",
+ "What to sync",
+ "Autosync",
+ "Update",
+ "This machine's overrides",
+ "Diagnostics",
+ "Danger zone",
+ "Uninstall",
+ "Network proxy",
+ "Restore a file from history",
+ "Devices",
+ "Large-change guard",
+ "Language",
+ ]) {
+ expect(en).toContain(label);
+ }
+ output = "";
+ const zh = await drive(["q"], "zh-CN");
+ for (const label of [
+ "立即同步",
+ "同步哪些内容",
+ "自动同步",
+ "危险操作",
+ "从历史版本恢复文件",
+ "大改动保护",
+ ]) {
+ expect(zh).toContain(label);
+ }
+ });
+
+ it("toggles categories with the checkbox and persists them", async () => {
+ linkSettings();
+ // 2 What to sync -> 1 categories -> toggle 2 (instructions) and 6 (skills) -> save
+ await drive(["2", "1", "2 6", "", "b", "q"], "en");
+ const s = loadSettings(roots.config);
+ expect(s.categories.instructions).toBe(false);
+ expect(s.includeSkills).toBe(false);
+ expect(s.categories.config).toBe(true);
+ });
+
+ it("asks before syncing credentials when visibility cannot be checked", async () => {
+ linkSettings();
+ await drive(["2", "1", "7", "", "n", "b", "q"], "en");
+ expect(loadSettings(roots.config).includeCredentials).toBe(false);
+ });
+
+ it("changes the interval, pauses and resumes autosync", async () => {
+ linkSettings();
+ const scheduler = fakeScheduler();
+ await drive(["3", "1", "2", "2", "4", "2", "b", "q"], "en", { scheduler });
+ let s = loadSettings(roots.config);
+ expect(s.autosync.enabled).toBe(true);
+ expect(s.autosync.intervalMinutes).toBe(10);
+ expect(s.autosync.pausedUntil).toBe(Date.UTC(2026, 9, 26, 12, 0, 0));
+ expect(scheduler.installed).toBe(2);
+
+ await drive(["3", "4", "4", "1", "b", "q"], "en", { scheduler });
+ s = loadSettings(roots.config);
+ expect(s.autosync.pausedUntil).toBe(0);
+ expect(s.autosync.enabled).toBe(false);
+ expect(scheduler.removed).toBe(1);
+ });
+
+ it("rejects an interval below two minutes", async () => {
+ linkSettings();
+ await drive(["3", "2", "c", "1", "b", "q"], "en");
+ expect(loadSettings(roots.config).autosync.intervalMinutes).toBe(5);
+ });
+
+ it("saves a proxy and refuses a malformed one", async () => {
+ linkSettings();
+ await drive(["p", "not a url", "q"], "en");
+ expect(loadSettings(roots.config).proxy).toBe("");
+ await drive(["p", "http://127.0.0.1:7890", "q"], "en");
+ expect(loadSettings(roots.config).proxy).toBe("http://127.0.0.1:7890");
+ });
+
+ it("switches the language and persists it", async () => {
+ linkSettings();
+ const text = await drive(["l", "3", "q"], "en");
+ expect(loadSettings(roots.config).language).toBe("zh-CN");
+ expect(text).toContain("语言已保存。");
+ });
+
+ it("requires the confirmation word in the danger zone", async () => {
+ linkSettings();
+ fs.writeFileSync(path.join(roots.config, "opencode.jsonc"), "{}\n");
+ const text = await drive(["7", "2", "nope", "q"], "en");
+ expect(text).toContain("Cancelled.");
+ expect(git(["ls-remote", remote], { cwd: sandbox }).stdout).toBe("");
+ });
+
+ it("renames this machine from the device list", async () => {
+ linkSettings();
+ await drive(["d", "1", "Work Laptop", "q"], "en");
+ expect(loadSettings(roots.config).machineAlias).toBe("Work-Laptop");
+ });
+
+ it("checks for updates and upgrades on request", async () => {
+ linkSettings();
+ const updater = fakeUpdater();
+ const text = await drive(["4", "1", "2"], "en", { updater });
+ expect(text).toContain("Installed 3.2.0 · latest 3.3.0");
+ expect(updater.installs).toBe(1);
+ });
+
+ it("writes a redacted support bundle", async () => {
+ linkSettings();
+ const s = loadSettings(roots.config);
+ s.proxy = "http://user:secret@proxy:8080";
+ saveSettings(s, roots.config);
+ await drive(["6", "1", "q"], "en");
+ const bundle = fs.readdirSync(sandbox).find((f) => f.startsWith("opencode-sync-support-"));
+ expect(bundle).toBeDefined();
+ const tar = execFileSync("tar", ["-xzOf", path.join(sandbox, bundle!), "settings.json"], {
+ encoding: "utf8",
+ });
+ expect(tar).not.toContain("secret");
+ });
+
+ it("uninstall removes the job and keeps configuration files", async () => {
+ linkSettings();
+ fs.writeFileSync(path.join(roots.config, "opencode.jsonc"), "{}\n");
+ const scheduler = fakeScheduler();
+ await drive(["8", "y", "n"], "en", { scheduler });
+ expect(scheduler.removed).toBe(1);
+ expect(loadSettings(roots.config).repo.url).toBeUndefined();
+ expect(fs.existsSync(path.join(roots.config, "opencode.jsonc"))).toBe(true);
+ });
+});
+
+describe("non-interactive setup", () => {
+ const ui = () =>
+ new Ui({
+ input: Readable.from([""]),
+ output: new Writable({ write: (_c, _e, cb) => cb() }),
+ interactive: false,
+ });
+
+ it("--mode auto links, pushes and enables autosync", async () => {
+ fs.writeFileSync(path.join(roots.config, "opencode.jsonc"), "{}\n");
+ const scheduler = fakeScheduler();
+ await runSetup(
+ { mode: "auto", repo: remote, interval: 15, lang: "zh-CN", proxy: "http://127.0.0.1:1" },
+ { ui: ui(), roots, scheduler },
+ );
+ const s = loadSettings(roots.config);
+ expect(s.autosync.enabled).toBe(true);
+ expect(s.autosync.intervalMinutes).toBe(15);
+ expect(s.language).toBe("zh-CN");
+ expect(s.proxy).toBe("http://127.0.0.1:1");
+ expect(scheduler.installed).toBe(1);
+ });
+
+ it("--mode link without --repo fails clearly", async () => {
+ await expect(runSetup({ mode: "link" }, { ui: ui(), roots })).rejects.toThrow(/--repo/);
+ });
+});
diff --git a/tests/scheduler.test.ts b/tests/scheduler.test.ts
new file mode 100644
index 0000000..5a04252
--- /dev/null
+++ b/tests/scheduler.test.ts
@@ -0,0 +1,285 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+import {
+ CRON_MARKER,
+ type InstallContext,
+ type Runner,
+ type ScheduleSpec,
+ crontabLine,
+ editCrontab,
+ encodeVbs,
+ installScheduler,
+ launchdPlist,
+ quoteWindowsArg,
+ systemdService,
+ systemdTimer,
+ uninstallScheduler,
+ windowsTaskXml,
+ windowsVbs,
+} from "../src/core/scheduler.js";
+
+const windowsSpec: ScheduleSpec = {
+ node: "C:\\Program Files\\nodejs\\node.exe",
+ cli: "C:\\Users\\王小明 Wang\\AppData\\Roaming\\npm\\node_modules\\opencode-github-sync\\dist\\cli\\index.js",
+ intervalMinutes: 5,
+ env: {},
+ logDir: "C:\\Users\\王小明 Wang\\.config\\opencode\\logs",
+};
+
+const posixSpec: ScheduleSpec = {
+ node: "/Users/李 雷/.nvm/versions/node/v22.11.0/bin/node",
+ cli: "/Users/李 雷/.nvm/versions/node/v22.11.0/lib/node_modules/opencode-github-sync/dist/cli/index.js",
+ intervalMinutes: 10,
+ env: { PATH: "/Users/李 雷/.nvm/versions/node/v22.11.0/bin:/usr/bin:/bin" },
+ logDir: "/Users/李 雷/.config/opencode/logs",
+};
+
+describe("Windows Task Scheduler", () => {
+ it("renders the task XML with conhost --headless", () => {
+ const xml = windowsTaskXml(windowsSpec, {
+ userId: "DESKTOP-1\\王小明",
+ logonType: "InteractiveToken",
+ launcher: { kind: "conhost", conhost: "C:\\Windows\\System32\\conhost.exe" },
+ startBoundary: "2026-10-26T08:00:00",
+ });
+ expect(xml).toMatchSnapshot();
+ expect(xml).toContain("IgnoreNew ");
+ expect(xml).toContain("PT10M ");
+ expect(xml).toContain("false ");
+ expect(xml).toContain("PT5M ");
+ expect(xml).toContain("SessionUnlock ");
+ expect(xml).toContain("RemoteConnect ");
+ expect(xml).toContain("PT30S ");
+ expect(xml).toContain("InteractiveToken ");
+ expect(xml).toContain("LeastPrivilege ");
+ });
+
+ it("renders S4U with the wscript launcher", () => {
+ const xml = windowsTaskXml(windowsSpec, {
+ userId: "CORP\\user",
+ logonType: "S4U",
+ launcher: {
+ kind: "wscript",
+ wscript: "C:\\Windows\\System32\\wscript.exe",
+ vbs: "C:\\Users\\王小明 Wang\\.config\\opencode\\opencode-sync-autosync.vbs",
+ },
+ startBoundary: "2026-10-26T08:00:00",
+ });
+ expect(xml).toMatchSnapshot();
+ expect(xml).toContain("S4U ");
+ });
+
+ it("escapes XML metacharacters in paths", () => {
+ const xml = windowsTaskXml(
+ { ...windowsSpec, cli: "C:\\A&B \\index.js" },
+ {
+ userId: "PC\\a&b",
+ logonType: "InteractiveToken",
+ launcher: { kind: "conhost", conhost: "C:\\Windows\\System32\\conhost.exe" },
+ startBoundary: "2026-10-26T08:00:00",
+ },
+ );
+ expect(xml).toContain("A&B <x>");
+ expect(xml).toContain("PC\\a&b");
+ });
+
+ it("quotes Windows command-line arguments", () => {
+ expect(quoteWindowsArg("plain")).toBe("plain");
+ expect(quoteWindowsArg("C:\\Program Files\\x")).toBe('"C:\\Program Files\\x"');
+ expect(quoteWindowsArg('a"b')).toBe('"a\\"b"');
+ expect(quoteWindowsArg("C:\\dir with space\\")).toBe('"C:\\dir with space\\\\"');
+ expect(quoteWindowsArg("")).toBe('""');
+ });
+
+ it("writes the VBScript launcher as UTF-16LE with a BOM", () => {
+ const text = windowsVbs({ ...windowsSpec, env: { SYNC_HOME: "C:\\Users\\王小明 Wang" } });
+ expect(text).toMatchSnapshot();
+ const bytes = encodeVbs(text);
+ expect(bytes[0]).toBe(0xff);
+ expect(bytes[1]).toBe(0xfe);
+ expect(bytes.subarray(2).toString("utf16le")).toBe(text);
+ expect(text).toContain("王小明 Wang");
+ expect(text).toContain(", 0, True)");
+ });
+});
+
+describe("macOS launchd", () => {
+ it("renders the agent plist", () => {
+ const plist = launchdPlist(posixSpec);
+ expect(plist).toMatchSnapshot();
+ expect(plist).toContain("RunAtLoad \n ");
+ expect(plist).toContain("600 ");
+ expect(plist).toContain("/Users/李 雷/.nvm/versions/node/v22.11.0/bin/node ");
+ });
+});
+
+describe("Linux systemd and cron", () => {
+ it("renders the user service and timer", () => {
+ expect(systemdService(posixSpec)).toMatchSnapshot();
+ const timer = systemdTimer(posixSpec);
+ expect(timer).toMatchSnapshot();
+ expect(timer).toContain("OnBootSec=1min");
+ expect(timer).toContain("OnUnitActiveSec=10min");
+ expect(timer).toContain("Persistent=true");
+ expect(systemdService(posixSpec)).toContain('ExecStart="/Users/李 雷/');
+ });
+
+ it("renders a crontab line and edits only its own entry", () => {
+ const line = crontabLine(posixSpec);
+ expect(line).toMatchSnapshot();
+ expect(line.startsWith("*/10 * * * * ")).toBe(true);
+ const existing = "0 3 * * * /usr/bin/backup\n";
+ const added = editCrontab(existing, line);
+ expect(added).toBe(`0 3 * * * /usr/bin/backup\n${line}\n`);
+ expect(editCrontab(added, line)).toBe(added);
+ expect(editCrontab(added, undefined)).toBe(existing);
+ expect(line).toContain(CRON_MARKER);
+ });
+
+ it("escapes cron and systemd specials and keeps long intervals honest", () => {
+ const odd = { ...posixSpec, cli: "/opt/50%/$HOME/cli.js", intervalMinutes: 120 };
+ const line = crontabLine(odd);
+ expect(line.startsWith("0 */2 * * * ")).toBe(true);
+ expect(line).toContain("50\\%");
+ expect(systemdService(odd)).toContain("50%%/$$HOME");
+ });
+
+ it("clears the cron entry when systemd takes over", () => {
+ const runner = fakeRunner({
+ "crontab -l": {
+ ok: true,
+ stdout: `0 3 * * * /usr/bin/backup\n*/5 * * * * x ${CRON_MARKER}\n`,
+ },
+ });
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "ogs-sched-"));
+ const saved = process.env.XDG_CONFIG_HOME;
+ process.env.XDG_CONFIG_HOME = path.join(home, ".config");
+ try {
+ const result = installScheduler({
+ platform: "linux",
+ spec: { ...posixSpec, logDir: path.join(home, "logs") },
+ home,
+ runner,
+ launcherDir: path.join(home, "data"),
+ runWhenLoggedOff: false,
+ elevated: false,
+ });
+ expect(result.kind).toBe("systemd");
+ expect(runner.calls).toContain("crontab - <<0 3 * * * /usr/bin/backup");
+ } finally {
+ if (saved === undefined) delete process.env.XDG_CONFIG_HOME;
+ else process.env.XDG_CONFIG_HOME = saved;
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+ });
+});
+
+/** A fake platform tool runner, so installation logic runs without registering anything. */
+function fakeRunner(responses: Record): Runner & {
+ calls: string[];
+} {
+ const calls: string[] = [];
+ return {
+ calls,
+ run(cmd, args, input) {
+ const key = `${path.basename(cmd).toLowerCase()} ${args.join(" ")}`;
+ calls.push(input ? `${key} <<${input.trim()}` : key);
+ const match = Object.entries(responses).find(([prefix]) => key.startsWith(prefix));
+ const r = match?.[1] ?? { ok: true };
+ return { ok: r.ok, stdout: r.stdout ?? "", stderr: r.ok ? "" : "failed" };
+ },
+ };
+}
+
+describe("installation wrappers (no real registration)", () => {
+ const base = (platform: InstallContext["platform"], runner: Runner): InstallContext => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "ogs-sched-"));
+ return {
+ platform,
+ spec: { ...posixSpec, logDir: path.join(home, "logs") },
+ home,
+ runner,
+ launcherDir: path.join(home, "data"),
+ runWhenLoggedOff: false,
+ elevated: false,
+ };
+ };
+
+ it("falls back to cron when systemd --user is unavailable", () => {
+ const runner = fakeRunner({
+ "systemctl --user show-environment": { ok: false },
+ "crontab -l": { ok: false },
+ });
+ const ctx = base("linux", runner);
+ const result = installScheduler(ctx);
+ expect(result).toMatchObject({ ok: true, kind: "cron" });
+ expect(runner.calls.some((c) => c.startsWith("crontab - <<") && c.includes(CRON_MARKER))).toBe(
+ true,
+ );
+ fs.rmSync(ctx.home, { recursive: true, force: true });
+ });
+
+ it("writes and enables the systemd timer when available", () => {
+ const runner = fakeRunner({ "systemctl --user is-enabled": { ok: true, stdout: "enabled" } });
+ const ctx = base("linux", runner);
+ const saved = process.env.XDG_CONFIG_HOME;
+ process.env.XDG_CONFIG_HOME = path.join(ctx.home, ".config");
+ try {
+ const result = installScheduler(ctx);
+ expect(result).toMatchObject({ ok: true, kind: "systemd" });
+ const unitDir = path.join(ctx.home, ".config", "systemd", "user");
+ expect(fs.existsSync(path.join(unitDir, "opencode-github-sync.timer"))).toBe(true);
+ expect(runner.calls).toContain("systemctl --user enable --now opencode-github-sync.timer");
+ expect(uninstallScheduler(ctx).removed).toBe(true);
+ expect(fs.existsSync(path.join(unitDir, "opencode-github-sync.timer"))).toBe(false);
+ } finally {
+ if (saved === undefined) delete process.env.XDG_CONFIG_HOME;
+ else process.env.XDG_CONFIG_HOME = saved;
+ fs.rmSync(ctx.home, { recursive: true, force: true });
+ }
+ });
+
+ it("writes the plist and bootstraps it on macOS", () => {
+ const runner = fakeRunner({});
+ const ctx = base("darwin", runner);
+ expect(installScheduler(ctx)).toMatchObject({ ok: true, kind: "launchd" });
+ const plist = path.join(
+ ctx.home,
+ "Library",
+ "LaunchAgents",
+ "com.opencode-github-sync.autosync.plist",
+ );
+ expect(fs.existsSync(plist)).toBe(true);
+ expect(runner.calls.some((c) => c.startsWith("launchctl bootstrap gui/"))).toBe(true);
+ uninstallScheduler(ctx);
+ expect(fs.existsSync(plist)).toBe(false);
+ fs.rmSync(ctx.home, { recursive: true, force: true });
+ });
+
+ it("registers through schtasks and verifies the next run time on Windows", () => {
+ const runner = fakeRunner({
+ "whoami ": { ok: true, stdout: "pc\\user\r\n" },
+ "powershell.exe": { ok: true, stdout: "2026-10-26T08:05:00\r\n" },
+ });
+ const ctx = base("win32", runner);
+ ctx.runWhenLoggedOff = true;
+ ctx.elevated = false;
+ const result = installScheduler(ctx);
+ expect(result.ok).toBe(true);
+ expect(result.warnings.join(" ")).toMatch(/not elevated/);
+ expect(result.detail.join(" ")).toContain("logon InteractiveToken");
+ expect(
+ runner.calls.some((c) => c.startsWith("schtasks /Create /TN opencode-github-sync /XML")),
+ ).toBe(true);
+ fs.rmSync(ctx.home, { recursive: true, force: true });
+ });
+
+ it("fails the Windows install when no next run is scheduled", () => {
+ const runner = fakeRunner({ "powershell.exe": { ok: true, stdout: "" } });
+ const ctx = base("win32", runner);
+ expect(installScheduler(ctx)).toMatchObject({ ok: false });
+ fs.rmSync(ctx.home, { recursive: true, force: true });
+ });
+});
diff --git a/tests/two-machines.test.ts b/tests/two-machines.test.ts
new file mode 100644
index 0000000..62c1504
--- /dev/null
+++ b/tests/two-machines.test.ts
@@ -0,0 +1,420 @@
+import { execFileSync } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { ENGLISH_MESSAGES, defaultDeps, readState, runAutosync } from "../src/core/autosync.js";
+import { compareWithRemote } from "../src/core/compare.js";
+import { git } from "../src/core/git.js";
+import { LargeChangeError } from "../src/core/guard.js";
+import { fileHistory, listDevices, restoreFile } from "../src/core/history.js";
+import type { Roots } from "../src/core/paths.js";
+import { listRecovery } from "../src/core/recovery.js";
+import { CollectingReporter } from "../src/core/reporter.js";
+import { type Settings, defaultSettings, saveSettings } from "../src/core/settings.js";
+import { pull, push } from "../src/core/sync.js";
+
+/**
+ * Two simulated machines, each with its own set of roots, syncing through a
+ * bare repository on disk. Nothing touches the real home directory.
+ */
+
+let sandbox: string;
+let remote: string;
+
+beforeEach(() => {
+ sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "ogs-2m-"));
+ remote = path.join(sandbox, "remote.git");
+ execFileSync("git", ["init", "--bare", "-b", "main", remote], { stdio: "pipe" });
+ process.env.OPENCODE_SYNC_NO_STAGGER = "1";
+});
+
+afterEach(() => {
+ delete process.env.OPENCODE_SYNC_NO_STAGGER;
+ delete process.env.OPENCODE_SYNC_HOST_ALIAS;
+ fs.rmSync(sandbox, { recursive: true, force: true });
+});
+
+interface Machine {
+ name: string;
+ roots: Roots;
+ settings: Settings;
+}
+
+function machine(name: string, patch: (s: Settings) => void = () => {}): Machine {
+ const base = path.join(sandbox, name);
+ const roots: Roots = {
+ config: path.join(base, "config"),
+ data: path.join(base, "data"),
+ state: path.join(base, "state"),
+ agents: path.join(base, "agents", "skills"),
+ cache: path.join(base, "cache"),
+ };
+ for (const dir of Object.values(roots)) fs.mkdirSync(dir, { recursive: true });
+ const settings = defaultSettings();
+ settings.repo = { url: remote, branch: "main" };
+ settings.machineAlias = name;
+ settings.autosync.enabled = true;
+ settings.autosync.settleSeconds = 60;
+ patch(settings);
+ saveSettings(settings, roots.config);
+ return { name, roots, settings };
+}
+
+const write = (file: string, content: string, mtime?: number): void => {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, content);
+ if (mtime) fs.utimesSync(file, new Date(mtime), new Date(mtime));
+};
+const read = (file: string): string => fs.readFileSync(file, "utf8");
+
+/** Push with the machine's own settings. */
+const pushFrom = (m: Machine, extra = {}) =>
+ push({ settings: m.settings, roots: m.roots, reporter: new CollectingReporter(), ...extra });
+const pullTo = (m: Machine, extra = {}) =>
+ pull({ settings: m.settings, roots: m.roots, reporter: new CollectingReporter(), ...extra });
+
+/** Age every file under the machine's trees so the settle rule sees quiet. */
+function settle(m: Machine, ageMs = 10 * 60_000): void {
+ const stamp = new Date(Date.now() - ageMs);
+ const walk = (dir: string) => {
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ const p = path.join(dir, e.name);
+ if (e.name === ".git") continue;
+ if (e.isDirectory()) walk(p);
+ else fs.utimesSync(p, stamp, stamp);
+ }
+ };
+ for (const dir of [m.roots.config, m.roots.agents]) if (fs.existsSync(dir)) walk(dir);
+}
+
+function autosync(m: Machine) {
+ const deps = defaultDeps(m.roots, {
+ home: path.join(sandbox, m.name, "home"),
+ notify: () => false,
+ messages: ENGLISH_MESSAGES,
+ sleep: async () => {},
+ });
+ return runAutosync(deps, "test");
+}
+
+describe("autosync between two machines", () => {
+ it("propagates an edit from A to B through scheduled runs", async () => {
+ const a = machine("alpha");
+ const b = machine("beta");
+ write(path.join(a.roots.config, "opencode.jsonc"), '{ "model": "one" }\n');
+ write(path.join(a.roots.agents, "s1", "SKILL.md"), "skill one\n");
+ settle(a);
+ expect((await autosync(a)).action).toBe("push");
+
+ // B starts empty: its first scheduled run sends nothing and receives.
+ settle(b);
+ const first = await autosync(b);
+ expect(first.ok).toBe(true);
+ expect(read(path.join(b.roots.config, "opencode.jsonc"))).toContain("one");
+ expect(read(path.join(b.roots.agents, "s1", "SKILL.md"))).toBe("skill one\n");
+
+ // A edits again; B picks it up on its next due run. The new content has a
+ // different size and age: `settle` backdates mtimes, and a same-size file
+ // with an identical backdated mtime would look unchanged to git's stat cache.
+ write(path.join(a.roots.config, "opencode.jsonc"), '{ "model": "second" }\n');
+ settle(a, 5 * 60_000);
+ fs.rmSync(path.join(a.roots.state, "opencode-sync-autosync.json"), { force: true });
+ expect((await autosync(a)).action).toBe("push");
+ settle(b);
+ fs.rmSync(path.join(b.roots.state, "opencode-sync-autosync.json"), { force: true });
+ expect((await autosync(b)).ok).toBe(true);
+ expect(read(path.join(b.roots.config, "opencode.jsonc"))).toContain("second");
+ });
+
+ it("receives only while this machine's files are still changing", async () => {
+ const a = machine("alpha");
+ const b = machine("beta");
+ write(path.join(a.roots.config, "opencode.jsonc"), '{ "model": "one" }\n');
+ await pushFrom(a);
+ await pullTo(b);
+
+ write(path.join(a.roots.config, "AGENTS.md"), "from alpha\n");
+ await pushFrom(a);
+
+ // B is busy: a file just changed in its config directory, but the edit is
+ // in a tracked file, so the repository is dirty and nothing is received.
+ write(path.join(b.roots.config, "notes.md"), "typing...\n");
+ let outcome = await autosync(b);
+ expect(outcome.action).toBe("skip");
+ expect(fs.existsSync(path.join(b.roots.config, "AGENTS.md"))).toBe(false);
+
+ // Once the edit is gone but the tree was just touched, receive-only runs.
+ fs.rmSync(path.join(b.roots.config, "notes.md"));
+ write(path.join(b.roots.cache, "touch"), "x");
+ const busy = path.join(b.roots.config, "opencode.jsonc");
+ fs.utimesSync(busy, new Date(), new Date());
+ outcome = await autosync(b);
+ expect(outcome.action).toBe("receive");
+ expect(read(path.join(b.roots.config, "AGENTS.md"))).toBe("from alpha\n");
+ expect(git(["log", "-1", "--format=%s"], { cwd: b.roots.config }).stdout).toContain("alpha");
+ });
+
+ it("skips while another process holds the lock", async () => {
+ const a = machine("alpha");
+ write(path.join(a.roots.config, "opencode.jsonc"), "{}\n");
+ settle(a);
+ const lockDir = path.join(a.roots.config, ".opencode-sync.lock");
+ fs.mkdirSync(lockDir);
+ fs.writeFileSync(
+ path.join(lockDir, "owner.json"),
+ JSON.stringify({ id: "x", pid: process.pid, startedAt: new Date().toISOString() }),
+ );
+ const outcome = await autosync(a);
+ expect(outcome).toMatchObject({ action: "skip", reason: "another sync is in progress" });
+ fs.rmSync(lockDir, { recursive: true });
+ });
+
+ it("records failures and backs off when the remote is unreachable", async () => {
+ const a = machine("alpha", (s) => {
+ s.repo.url = path.join(sandbox, "missing.git");
+ });
+ write(path.join(a.roots.config, "opencode.jsonc"), "{}\n");
+ settle(a);
+ const outcome = await autosync(a);
+ expect(outcome.ok).toBe(false);
+ expect(readState(a.roots).consecutiveFailures).toBe(1);
+ });
+});
+
+describe("push race", () => {
+ it("rebases and retries when another machine published in between", async () => {
+ const a = machine("alpha");
+ const b = machine("beta");
+ write(path.join(a.roots.config, "opencode.jsonc"), "{}\n");
+ await pushFrom(a);
+ await pullTo(b);
+
+ write(path.join(a.roots.config, "a.md"), "a\n");
+ write(path.join(b.roots.config, "b.md"), "b\n");
+
+ // Install a pre-push hook on B that lets A publish first, once.
+ const hook = path.join(b.roots.config, ".git", "hooks", "pre-push");
+ const marker = path.join(sandbox, "raced");
+ const script = [
+ "#!/bin/sh",
+ `if [ ! -f '${marker.replace(/\\/g, "/")}' ]; then`,
+ ` touch '${marker.replace(/\\/g, "/")}'`,
+ ` cd '${a.roots.config.replace(/\\/g, "/")}' && git add -A && git commit -qm 'race: from alpha' && git push -q origin main`,
+ "fi",
+ "exit 0",
+ "",
+ ].join("\n");
+ fs.writeFileSync(hook, script, { mode: 0o755 });
+
+ const result = await pushFrom(b);
+ expect(result.changed).toBe(true);
+ const log = git(["log", "--format=%s", "origin/main"], { cwd: b.roots.config }).stdout;
+ expect(log).toContain("race: from alpha");
+ expect(log.split("\n")[0]).toContain("beta");
+ expect(read(path.join(b.roots.config, "a.md"))).toBe("a\n");
+ });
+});
+
+describe("large-change guard", () => {
+ it("refuses an oversized push unless confirmed", async () => {
+ const a = machine("alpha", (s) => {
+ s.guard.maxFiles = 5;
+ });
+ write(path.join(a.roots.config, "opencode.jsonc"), "{}\n");
+ await pushFrom(a);
+ const before = git(["ls-remote", remote], { cwd: sandbox }).stdout;
+ for (let i = 0; i < 8; i++) write(path.join(a.roots.config, "command", `c${i}.md`), `${i}\n`);
+ await expect(pushFrom(a)).rejects.toBeInstanceOf(LargeChangeError);
+ expect(git(["ls-remote", remote], { cwd: sandbox }).stdout).toBe(before);
+
+ let asked = 0;
+ const confirmed = await pushFrom(a, {
+ confirmLarge: () => {
+ asked++;
+ return true;
+ },
+ });
+ expect(asked).toBe(1);
+ expect(confirmed.changed).toBe(true);
+ });
+
+ it("lets the first push into an empty repository carry the whole setup", async () => {
+ const a = machine("alpha", (s) => {
+ s.guard.maxFiles = 5;
+ });
+ for (let i = 0; i < 8; i++) write(path.join(a.roots.config, "command", `c${i}.md`), `${i}\n`);
+ expect((await pushFrom(a)).changed).toBe(true);
+ });
+
+ it("flags a single oversized file", async () => {
+ const a = machine("alpha", (s) => {
+ s.guard.maxFileBytes = 1024;
+ });
+ write(path.join(a.roots.config, "big.bin"), "x".repeat(4096));
+ const dry = await pushFrom(a, { dryRun: true });
+ expect(dry.guard?.reasons).toContain("file");
+ expect(dry.guard?.oversized[0]?.path).toBe("big.bin");
+ });
+
+ it("never stages generated directories inside synced trees", async () => {
+ const a = machine("alpha");
+ write(path.join(a.roots.config, "opencode.jsonc"), "{}\n");
+ write(path.join(a.roots.config, "tools", ".venv", "lib", "x.py"), "x\n");
+ write(path.join(a.roots.config, "tools", "dist", "bundle.js"), "x\n");
+ write(path.join(a.roots.config, "tools", "main.py"), "x\n");
+ await pushFrom(a);
+ const files = git(["ls-files"], { cwd: a.roots.config }).stdout;
+ expect(files).toContain("tools/main.py");
+ expect(files).not.toContain(".venv");
+ expect(files).not.toContain("dist/bundle.js");
+ });
+});
+
+describe("category toggles", () => {
+ it("keeps a disabled category local in both directions", async () => {
+ const a = machine("alpha");
+ const b = machine("beta", (s) => {
+ s.categories.instructions = false;
+ });
+ write(path.join(a.roots.config, "opencode.jsonc"), "{}\n");
+ write(path.join(a.roots.config, "AGENTS.md"), "shared\n");
+ await pushFrom(a);
+
+ write(path.join(b.roots.config, "AGENTS.md"), "local only\n");
+ await pullTo(b);
+ expect(read(path.join(b.roots.config, "AGENTS.md"))).toBe("local only\n");
+ expect(fs.existsSync(path.join(b.roots.config, "opencode.jsonc"))).toBe(true);
+
+ write(path.join(b.roots.config, "AGENTS.md"), "local edit\n");
+ write(path.join(b.roots.config, "command", "x.md"), "x\n");
+ await pushFrom(b);
+ const shared = git(["show", "origin/main:AGENTS.md"], { cwd: b.roots.config }).stdout;
+ expect(shared).toBe("shared");
+ expect(read(path.join(b.roots.config, "AGENTS.md"))).toBe("local edit\n");
+
+ await pullTo(a);
+ expect(read(path.join(a.roots.config, "command", "x.md"))).toBe("x\n");
+ expect(read(path.join(a.roots.config, "AGENTS.md"))).toBe("shared\n");
+ });
+});
+
+describe("history", () => {
+ it("restores an earlier version and keeps the replaced bytes", async () => {
+ const a = machine("alpha");
+ const file = path.join(a.roots.config, "AGENTS.md");
+ write(file, "version 1\n");
+ await pushFrom(a);
+ write(file, "version 2\n");
+ await pushFrom(a);
+
+ const history = fileHistory(a.roots.config, "AGENTS.md");
+ expect(history).toHaveLength(2);
+ const result = restoreFile(a.roots, "AGENTS.md", history[1]!.commit);
+ expect(read(file)).toBe("version 1\n");
+ expect(result.recovery).toBeDefined();
+ expect(read(path.join(result.recovery!, "AGENTS.md"))).toBe("version 2\n");
+ });
+
+ it("lists devices from trailers and legacy subjects", async () => {
+ const a = machine("alpha");
+ const b = machine("beta");
+ write(path.join(a.roots.config, "opencode.jsonc"), "{}\n");
+ await pushFrom(a);
+ await pullTo(b);
+ write(path.join(b.roots.config, "x.md"), "x\n");
+ await pushFrom(b);
+ // A commit from an older release has no trailer, only the subject.
+ write(path.join(b.roots.config, "y.md"), "y\n");
+ git(["add", "-A"], { cwd: b.roots.config });
+ git(["commit", "-qm", "sync: 2026-01-01 00:00:00 from gamma-old"], { cwd: b.roots.config });
+
+ const devices = listDevices(b.roots.config).map((d) => d.alias);
+ expect(devices).toEqual(["gamma-old", "beta", "alpha"]);
+ });
+});
+
+describe("forced pull recovery", () => {
+ it("saves new folders, non-ASCII names and local-only skills before replacing them", async () => {
+ const a = machine("alpha");
+ write(path.join(a.roots.config, "opencode.jsonc"), "{}\n");
+ write(path.join(a.roots.agents, "shared", "SKILL.md"), "shared\n");
+ await pushFrom(a);
+
+ const b = machine("beta");
+ await pullTo(b);
+ write(path.join(a.roots.config, "AGENTS.md"), "from alpha\n");
+ await pushFrom(a);
+
+ write(path.join(b.roots.config, "notes", "draft.md"), "draft\n");
+ write(path.join(b.roots.config, "技能.md"), "中文\n");
+ write(path.join(b.roots.agents, "mine", "SKILL.md"), "mine\n");
+ await pullTo(b, { force: true });
+
+ expect(fs.existsSync(path.join(b.roots.config, "notes"))).toBe(false);
+ expect(fs.existsSync(path.join(b.roots.agents, "mine"))).toBe(false);
+ const saved = listRecovery(b.roots).map((dir) => dir);
+ const find = (rel: string) => saved.find((dir) => fs.existsSync(path.join(dir, rel)));
+ expect(read(path.join(find(path.join("notes", "draft.md"))!, "notes", "draft.md"))).toBe(
+ "draft\n",
+ );
+ expect(read(path.join(find("技能.md")!, "技能.md"))).toBe("中文\n");
+ const skill = path.join("skills", "mine", "SKILL.md");
+ expect(read(path.join(find(skill)!, skill))).toBe("mine\n");
+ expect(find(path.join("skills", "shared", "SKILL.md"))).toBeUndefined();
+ });
+});
+
+describe("first link", () => {
+ it("compares local files with the remote without touching either", async () => {
+ const a = machine("alpha");
+ write(path.join(a.roots.config, "opencode.jsonc"), '{ "model": "a" }\n');
+ write(path.join(a.roots.config, "AGENTS.md"), "same\n");
+ await pushFrom(a);
+
+ const b = machine("beta");
+ write(path.join(b.roots.config, "opencode.jsonc"), '{ "model": "b" }\r\n');
+ write(path.join(b.roots.config, "AGENTS.md"), "same\r\n");
+ write(path.join(b.roots.config, "mine.md"), "mine\n");
+
+ const diff = compareWithRemote(b.roots.config, remote, "main");
+ expect(diff.empty).toBe(false);
+ expect(diff.differences).toEqual([
+ { path: "mine.md", kind: "onlyLocal" },
+ { path: "opencode.jsonc", kind: "different" },
+ ]);
+ expect(fs.existsSync(path.join(b.roots.config, ".git"))).toBe(false);
+
+ const empty = path.join(sandbox, "empty.git");
+ execFileSync("git", ["init", "--bare", "-b", "main", empty], { stdio: "pipe" });
+ expect(compareWithRemote(b.roots.config, empty, "main").empty).toBe(true);
+ });
+
+ it("repository wins: local files go to recovery first", async () => {
+ const a = machine("alpha");
+ write(path.join(a.roots.config, "opencode.jsonc"), '{ "model": "a" }\n');
+ await pushFrom(a);
+
+ const b = machine("beta");
+ write(path.join(b.roots.config, "opencode.jsonc"), '{ "model": "b" }\n');
+ await pullTo(b);
+ expect(read(path.join(b.roots.config, "opencode.jsonc"))).toContain('"a"');
+ const saved = listRecovery(b.roots)[0];
+ expect(saved).toBeDefined();
+ expect(read(path.join(saved!, "opencode.jsonc"))).toContain('"b"');
+ });
+
+ it("local wins: this machine's files are pushed on top", async () => {
+ const a = machine("alpha");
+ write(path.join(a.roots.config, "opencode.jsonc"), '{ "model": "a" }\n');
+ write(path.join(a.roots.config, "theirs.md"), "theirs\n");
+ await pushFrom(a);
+
+ const b = machine("beta");
+ write(path.join(b.roots.config, "opencode.jsonc"), '{ "model": "b" }\n');
+ await pushFrom(b, { adopt: "local" });
+ const shared = git(["show", "origin/main:opencode.jsonc"], { cwd: b.roots.config }).stdout;
+ expect(shared).toContain('"b"');
+ expect(read(path.join(b.roots.config, "opencode.jsonc"))).toContain('"b"');
+ });
+});
diff --git a/tests/units.test.ts b/tests/units.test.ts
new file mode 100644
index 0000000..f42233d
--- /dev/null
+++ b/tests/units.test.ts
@@ -0,0 +1,228 @@
+import { execFileSync } from "node:child_process";
+import { Readable, Writable } from "node:stream";
+import { gunzipSync } from "node:zlib";
+import { describe, expect, it } from "vitest";
+import { parseArgs } from "../src/cli/args.js";
+import { CATALOGS, detectLang, normalizeLang, t } from "../src/cli/i18n.js";
+import { Ui } from "../src/cli/ui.js";
+import { redactText, redactValue, tarGz } from "../src/core/bundle.js";
+import { disabledCategories, disabledPathspecs, isDisabledPath } from "../src/core/categories.js";
+import { gitBlobId } from "../src/core/compare.js";
+import { proxyEnv } from "../src/core/env.js";
+import { excludeFileContent } from "../src/core/guard.js";
+import { appleScriptNotification, windowsToastScript } from "../src/core/notify.js";
+import { defaultSettings, idlePushEnabled } from "../src/core/settings.js";
+import { githubTargetFromUrl, isRepoUrl } from "../src/core/setup.js";
+import { compareVersions } from "../src/core/update.js";
+
+describe("i18n", () => {
+ it("has identical keys in every catalogue", () => {
+ const en = Object.keys(CATALOGS.en).sort();
+ const zh = Object.keys(CATALOGS["zh-CN"]).sort();
+ expect(zh).toEqual(en);
+ });
+
+ it("uses the same placeholders in both languages", () => {
+ for (const key of Object.keys(CATALOGS.en) as (keyof typeof CATALOGS.en)[]) {
+ const names = (s: string) => [...s.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort();
+ expect(names(CATALOGS["zh-CN"][key]), key).toEqual(names(CATALOGS.en[key]));
+ }
+ });
+
+ it("has no empty strings", () => {
+ for (const catalog of Object.values(CATALOGS)) {
+ for (const [key, value] of Object.entries(catalog)) expect(value.trim(), key).not.toBe("");
+ }
+ });
+
+ it("detects the language in the documented order", () => {
+ const intl = () => "fr-FR";
+ expect(detectLang("zh-CN", "en", {}, intl)).toBe("zh-CN");
+ expect(detectLang(undefined, "en", { OPENCODE_SYNC_LANG: "zh" }, intl)).toBe("zh-CN");
+ expect(detectLang(undefined, "zh-CN", { LANG: "en_US.UTF-8" }, intl)).toBe("zh-CN");
+ expect(detectLang(undefined, "", { LC_ALL: "zh_CN.UTF-8", LANG: "en_US" }, intl)).toBe("zh-CN");
+ expect(detectLang(undefined, "", { LANG: "C" }, () => "zh-Hans-CN")).toBe("zh-CN");
+ expect(detectLang(undefined, "", {}, intl)).toBe("en");
+ expect(normalizeLang("POSIX")).toBeUndefined();
+ });
+
+ it("fills placeholders", () => {
+ expect(t("autoOn", { n: 5 }, "en")).toBe("Autosync is on: every 5 min.");
+ expect(t("autoOn", { n: 5 }, "zh-CN")).toBe("自动同步已开启:每 5 分钟一次。");
+ });
+});
+
+describe("argument parsing", () => {
+ it("defaults to the menu", () => {
+ expect(parseArgs([]).command).toBe("menu");
+ expect(parseArgs(["--help"]).command).toBe("help");
+ });
+
+ it("accepts both --key value and --key=value", () => {
+ const args = parseArgs(["setup", "--mode", "auto", "--repo=o/r", "--interval", "10", "--yes"]);
+ expect(args.command).toBe("setup");
+ expect(args.values.get("mode")).toBe("auto");
+ expect(args.values.get("repo")).toBe("o/r");
+ expect(args.values.get("interval")).toBe("10");
+ expect(args.flags.has("yes")).toBe(true);
+ });
+
+ it("keeps boolean flags boolean", () => {
+ const args = parseArgs(["push", "--force", "--dry-run"]);
+ expect(args.flags.has("force")).toBe(true);
+ expect(args.flags.has("dry-run")).toBe(true);
+ expect(args.positional).toEqual([]);
+ });
+});
+
+describe("categories", () => {
+ it("maps disabled categories to repository paths", () => {
+ const s = defaultSettings();
+ s.categories.instructions = false;
+ s.categories.plugins = false;
+ const disabled = disabledCategories(s.categories);
+ expect(disabled).toEqual(["instructions", "plugins"]);
+ expect(isDisabledPath("AGENTS.md", disabled)).toBe(true);
+ expect(isDisabledPath("skills/x/SKILL.md", disabled)).toBe(false);
+ expect(isDisabledPath("plugin/x.ts", disabled)).toBe(true);
+ expect(isDisabledPath("opencode.jsonc", disabled)).toBe(false);
+ expect(disabledPathspecs(disabled)).toContain(":(top,glob)*.md");
+ });
+});
+
+describe("support bundle redaction", () => {
+ it("removes tokens, passwords and credentials in URLs", () => {
+ const text = [
+ "remote https://user:hunter2@github.com/o/r.git",
+ "token ghp_abcdefghijklmnopqrstuvwxyz0123456789",
+ '"apiKey": "sk-1234567890abcdefghij"',
+ "password=letmein",
+ "Authorization: Bearer abc.def.ghi",
+ ].join("\n");
+ const out = redactText(text);
+ for (const secret of ["hunter2", "ghp_abcdef", "sk-1234567890", "letmein", "abc.def.ghi"]) {
+ expect(out).not.toContain(secret);
+ }
+ expect(out).toContain("github.com/o/r.git");
+ });
+
+ it("blanks secret-looking keys in structured settings", () => {
+ const value = redactValue({
+ proxy: "http://u:p@proxy:8080",
+ repo: { url: "x" },
+ token: "abc",
+ }) as any;
+ expect(value.token).toBe("[REDACTED]");
+ expect(value.proxy).not.toContain("u:p@");
+ });
+
+ it("writes a tar archive that tar can read", () => {
+ const archive = tarGz([{ name: "a.txt", content: "hello" }]);
+ const tar = gunzipSync(archive);
+ expect(tar.subarray(0, 5).toString()).toBe("a.txt");
+ expect(tar.subarray(512, 517).toString()).toBe("hello");
+ expect(tar.length % 512).toBe(0);
+ });
+});
+
+describe("helpers", () => {
+ it("builds proxy variables for git, gh and npm", () => {
+ expect(proxyEnv("")).toEqual({});
+ const env = proxyEnv("http://127.0.0.1:7890");
+ expect(env.HTTPS_PROXY).toBe("http://127.0.0.1:7890");
+ expect(env.npm_config_https_proxy).toBe("http://127.0.0.1:7890");
+ });
+
+ it("recognises URLs and local paths as repositories", () => {
+ expect(isRepoUrl("https://github.com/o/r.git")).toBe(true);
+ expect(isRepoUrl("git@github.com:o/r.git")).toBe(true);
+ expect(isRepoUrl("C:\\repos\\sync.git")).toBe(true);
+ expect(isRepoUrl("/srv/sync.git")).toBe(true);
+ expect(isRepoUrl("o/r")).toBe(false);
+ expect(githubTargetFromUrl("https://github.com/o/r.git")).toEqual({ owner: "o", name: "r" });
+ expect(githubTargetFromUrl("/srv/sync.git")).toBeUndefined();
+ });
+
+ it("compares versions numerically", () => {
+ expect(compareVersions("3.10.0", "3.9.9")).toBe(1);
+ expect(compareVersions("3.2.0", "3.2.0")).toBe(0);
+ expect(compareVersions("3.2.0-beta.1", "3.2.0")).toBe(-1);
+ });
+
+ it("hashes files the way git does", () => {
+ const content = Buffer.from("hello\r\nworld\n");
+ const expected = execFileSync("git", ["hash-object", "--stdin"], {
+ input: "hello\nworld\n",
+ encoding: "utf8",
+ }).trim();
+ expect(gitBlobId(content)).toBe(expected);
+ });
+
+ it("keeps the plugin's idle push off while autosync owns the machine", () => {
+ const s = defaultSettings();
+ s.autoPushOnIdle = true;
+ expect(idlePushEnabled(s)).toBe(true);
+ s.autosync.enabled = true;
+ expect(idlePushEnabled(s)).toBe(false);
+ });
+
+ it("writes generated-directory excludes", () => {
+ expect(excludeFileContent(["node_modules", ".venv", "bad/*"])).toBe(
+ "# Managed by opencode-github-sync: generated directories never synced.\nnode_modules/\n.venv/\n",
+ );
+ });
+
+ it("escapes notification text for XML and AppleScript", () => {
+ expect(windowsToastScript("a {
+ function ui(lines: string) {
+ let out = "";
+ const output = new Writable({
+ write(chunk, _enc, cb) {
+ out += chunk.toString();
+ cb();
+ },
+ });
+ return {
+ ui: new Ui({ input: Readable.from([lines]), output, interactive: false }),
+ out: () => out,
+ };
+ }
+
+ it("selects by key and returns undefined at end of input", async () => {
+ const { ui: u } = ui("x\n2\n");
+ const choices = [
+ { value: "a", label: "A", key: "1" },
+ { value: "b", label: "B", key: "2" },
+ ];
+ expect(await u.select("pick", choices)).toBe("b");
+ expect(await u.select("pick", choices)).toBeUndefined();
+ });
+
+ it("toggles checkboxes by number and saves on an empty line", async () => {
+ const { ui: u } = ui("1 3\n\n");
+ const result = await u.checkbox(
+ "t",
+ [
+ { value: "a", label: "A" },
+ { value: "b", label: "B" },
+ { value: "c", label: "C" },
+ ],
+ new Set(["a", "b"]),
+ );
+ expect([...(result ?? [])].sort()).toEqual(["b", "c"]);
+ });
+
+ it("requires the exact confirmation word", async () => {
+ const { ui: u } = ui("Overwrite\noverwrite\n");
+ expect(await u.confirmWord("type", "overwrite")).toBe(false);
+ expect(await u.confirmWord("type", "overwrite")).toBe(true);
+ });
+});