From 7bd2cdc8c2405f8ac0987a34588f05a34cfec4b6 Mon Sep 17 00:00:00 2001 From: tsunamik <1573238+tsunamik@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:22:26 +0200 Subject: [PATCH 1/3] feat(sync): add hardened portable state sync --- .github/workflows/opencode-smoke.yml | 38 +- .github/workflows/pr-title.yml | 2 +- .github/workflows/pr.yml | 3 +- .github/workflows/publish.yml | 20 +- .github/workflows/release.yml | 6 +- .release-please-manifest.json | 2 +- README.md | 361 ++++++----------- bun.lock | 2 +- docs/extended-sync-v1.md | 76 ++++ package.json | 18 +- src/command/sync-enable-secrets.md | 7 - src/command/sync-init.md | 8 +- src/command/sync-link.md | 4 +- src/command/sync-resolve.md | 5 - src/index.ts | 59 ++- src/sync/apply.test.ts | 403 ++++++++++++++++++ src/sync/apply.ts | 583 +++++++++++++++++++-------- src/sync/config.test.ts | 122 +++++- src/sync/config.ts | 102 ++++- src/sync/mcp-secrets.test.ts | 52 ++- src/sync/mcp-secrets.ts | 35 +- src/sync/paths.test.ts | 140 ++++++- src/sync/paths.ts | 169 +++----- src/sync/reconcile.test.ts | 197 +++++++++ src/sync/reconcile.ts | 264 ++++++++++++ src/sync/repo.test.ts | 102 ++++- src/sync/repo.ts | 359 ++++++++++++++++- src/sync/service.test.ts | 220 ++++++++++ src/sync/service.ts | 451 ++++++++------------- 29 files changed, 2852 insertions(+), 958 deletions(-) create mode 100644 docs/extended-sync-v1.md delete mode 100644 src/command/sync-enable-secrets.md delete mode 100644 src/command/sync-resolve.md create mode 100644 src/sync/apply.test.ts create mode 100644 src/sync/reconcile.test.ts create mode 100644 src/sync/reconcile.ts create mode 100644 src/sync/service.test.ts diff --git a/.github/workflows/opencode-smoke.yml b/.github/workflows/opencode-smoke.yml index e6e3809..f26677a 100644 --- a/.github/workflows/opencode-smoke.yml +++ b/.github/workflows/opencode-smoke.yml @@ -31,24 +31,26 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - name: Resolve plugin version id: version + env: + DIST_TAG: ${{ inputs.tag }} run: | - VERSION=$(npm view opencode-synced@${{ inputs.tag }} version) + case "$DIST_TAG" in + latest|next) ;; + *) echo "Unsupported npm dist-tag"; exit 1 ;; + esac + VERSION=$(npm view "@tsunamik/opencode-synced@$DIST_TAG" version) if [ -z "$VERSION" ]; then - echo "No version found for tag: ${{ inputs.tag }}" + echo "No version found for requested tag" exit 1 fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Install opencode - env: - opencode_install_dir: ${{ runner.temp }}/opencode/bin - run: | - curl -fsSL https://opencode.ai/install | bash - echo "${opencode_install_dir}" >> "$GITHUB_PATH" + run: npm install --global opencode-ai@1.18.15 - name: Configure clean opencode home env: @@ -65,23 +67,7 @@ jobs: cat > "$XDG_CONFIG_HOME/opencode/opencode.json" < "$XDG_CONFIG_HOME/opencode/opencode-synced.jsonc" <> "$GITHUB_OUTPUT" @@ -58,7 +64,7 @@ jobs: - name: Resolve published version id: version run: | - VERSION=$(npm view opencode-synced@next version) + VERSION=$(npm view @tsunamik/opencode-synced@next version) if [ -z "$VERSION" ]; then echo "Failed to resolve version from npm tag: next" exit 1 @@ -80,7 +86,7 @@ jobs: id-token: write contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - uses: jdx/mise-action@d6e32c1796099e0f1f3ac741c220a8b7eae9e5dd with: @@ -96,5 +102,5 @@ jobs: exit 1 fi - echo "Promoting opencode-synced@$VERSION to latest" - npm dist-tag add "opencode-synced@$VERSION" latest + echo "Promoting @tsunamik/opencode-synced@$VERSION to latest" + npm dist-tag add "@tsunamik/opencode-synced@$VERSION" latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e3c2c90..20a473c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: releases_created: ${{ steps.release-please.outputs.releases_created }} prs_created: ${{ steps.release-please.outputs.prs_created }} steps: - - uses: google-github-actions/release-please-action@v4 + - uses: google-github-actions/release-please-action@8b8fd2cc23b2e18957157a9d923d75aa0c6f6ad5 id: release-please with: token: ${{ secrets.GITHUB_TOKEN }} @@ -33,7 +33,7 @@ jobs: steps: - name: Dispatch publish for releases if: needs.process.outputs.releases_created == 'true' - uses: peter-evans/repository-dispatch@v2 + uses: peter-evans/repository-dispatch@bf47d102fdb849e755b0b0023ea3e81a44b6f570 with: token: ${{ secrets.GITHUB_TOKEN }} event-type: publish-package @@ -41,7 +41,7 @@ jobs: - name: Dispatch publish for prerelease if: needs.process.outputs.prs_created == 'true' - uses: peter-evans/repository-dispatch@v2 + uses: peter-evans/repository-dispatch@bf47d102fdb849e755b0b0023ea3e81a44b6f570 with: token: ${{ secrets.GITHUB_TOKEN }} event-type: publish-package diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 76d5538..827b4ed 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.9.0" + ".": "0.10.0-beta.1" } diff --git a/README.md b/README.md index e296e79..35cf50d 100644 --- a/README.md +++ b/README.md @@ -1,291 +1,170 @@ -# opencode-synced +# @tsunamik/opencode-synced -Sync global opencode configuration across machines via a GitHub repo, with optional secrets support for private repos. +Hardened, narrow OpenCode configuration and state synchronization for trusted machines. +This project is a fork of [iHildy/opencode-synced](https://github.com/iHildy/opencode-synced) +based on the upstream `v0.9.0` release. -## Features +## Scope -- Syncs global opencode config (`~/.config/opencode`) and related directories -- Optional secrets sync when the repo is private -- Optional session sync to share conversation history across machines -- Optional prompt stash sync to share stashed prompts and history across machines -- Startup auto-sync with restart toast -- Per-machine overrides via `opencode-synced.overrides.jsonc` -- Custom `/sync-*` commands and `opencode_sync` tool +Supported synchronized data: -## Requirements - -- GitHub CLI (`gh`) installed and authenticated (`gh auth login`) -- Git installed and available on PATH - -## Setup +- global `opencode.json` or `opencode.jsonc` and `AGENTS.md` +- `agent/`, `command/`, `mode/`, `tool/`, `themes/`, and legacy singular `plugin/` +- global `skills/` +- plaintext prompt history and prompt stash in a private repository +- only the `favorite` projection from `state/opencode/model.json` +- `main-model.txt` and `cheap-model.txt` -Enable the plugin in your global opencode config (opencode will install it on next run): +Always local and rejected by configuration validation: -```jsonc -{ - "$schema": "https://opencode.ai/config.json", - "plugin": ["opencode-synced"], -} -``` +- OpenCode authentication and MCP authentication files +- sessions, messages, parts, diffs, and `opencode.db` +- the global `secrets/` directory +- `opencode-synced.jsonc` and `opencode-synced.overrides.jsonc` +- arbitrary extra paths and custom local repository paths -opencode does not auto-update plugins. To update, modify the version number in your config file. +## Important Security Properties -## Configure +- Prompt synchronization requires a private GitHub repository and the explicit + `acknowledgePlaintextPromptRisk` setting. +- Private GitHub repositories provide access control, not end-to-end encryption. Prompt + content remains recoverable from Git history and every clone. +- Remote and local symlinks and unsupported filesystem entries are rejected. +- Skills exclude `__pycache__`, `*.pyc`, `*.pyo`, `.DS_Store`, and + `*:Zone.Identifier` files. +- Local generated configuration, state, and rollback bundles use owner-only permissions. +- Secrets, sessions, local overrides, and sync configuration cannot be enabled by tool flags. +- The AI-based destructive `/sync-resolve` command is removed. -### First machine (create new sync repo) +The sync repository contains active content. A trusted machine can push agents, commands, +skills, MCP commands, or plugin configuration that executes on another linked machine. -Run `/sync-init` to create a new sync repo: +## Conflict Policy -1. Detects your GitHub username -2. Creates a private repo (`my-opencode-config` by default) -3. Clones the repo and pushes your current config +The last successful synchronization wins. -### Additional machines (link to existing repo) +Before fetch, the plugin projects the current local managed state into a temporary owner-only +directory. After fetching remote changes, only items that were changed locally are applied on +top of the fetched branch. This preserves unrelated changes from both machines. If both machines +changed the same managed item, the currently synchronizing machine wins. -Run `/sync-link` to connect to your existing sync repo: +The displaced remote item remains in Git history and is copied to an owner-only rollback bundle +under the OpenCode state directory. Wall-clock timestamps are never used to choose a winner. -1. Searches your GitHub for common sync repo names (prioritizes `my-opencode-config`) -2. Clones and applies the synced config -3. **Overwrites local config** with synced content (preserves your local overrides file) +## Requirements -If auto-detection fails, specify the repo name: `/sync-link my-opencode-config` +- Git +- GitHub CLI (`gh`) installed and authenticated +- a private GitHub data repository when prompt synchronization is enabled -After linking, restart opencode to apply the synced settings. +## Pilot Installation -### Custom repo name or org +Build and pack one artifact, then install the exact same artifact checksum on both machines: -You can specify a custom repo name or use an organization: +```bash +npm install --ignore-scripts --no-package-lock +npm test +npm run build +npm pack --ignore-scripts +``` -- `/sync-init` - Uses `{your-username}/my-opencode-config` -- `/sync-init my-config` - Uses `{your-username}/my-config` -- `/sync-init my-org/team-config` - Uses `my-org/team-config` +During the pilot, load the built `dist/index.js` from a local clone or install the packed +artifact into OpenCode's package cache. Do not use a mutable branch reference. -
-Manual configuration +## Configuration -Create `~/.config/opencode/opencode-synced.jsonc`: +`~/.config/opencode/opencode-synced.jsonc` is local-only: ```jsonc { "repo": { - "owner": "your-org", - "name": "opencode-config", - "branch": "main", + "owner": "your-github-user", + "name": "my-opencode-config", + "branch": "main" }, - "includeSecrets": false, - "includeMcpSecrets": false, - "includeSessions": false, - "includePromptStash": false, + "includeSkills": true, + "includePromptHistory": true, + "includePromptStash": true, + "acknowledgePlaintextPromptRisk": true, "includeModelFavorites": true, - "extraSecretPaths": [], - "extraConfigPaths": [], + "includeModelSelectors": true } ``` -
+Prompt flags fail unless the risk acknowledgement is exactly `true`. The repository visibility +is checked before prompt data is read or written. -### Synced paths (default) - -- `~/.config/opencode/opencode.json` and `opencode.jsonc` -- `~/.config/opencode/AGENTS.md` -- `~/.config/opencode/agent/`, `command/`, `mode/`, `tool/`, `themes/`, `plugin/` -- `~/.local/state/opencode/model.json` (model favorites) -- Any extra paths in `extraConfigPaths` (allowlist, files or folders) - -### Secrets (private repos only) - -Enable secrets with `/sync-enable-secrets` or set `"includeSecrets": true`: - -- `~/.local/share/opencode/auth.json` -- `~/.local/share/opencode/mcp-auth.json` -- Any extra paths in `extraSecretPaths` (allowlist, files or folders) - -MCP API keys stored inside `opencode.json(c)` are **not** committed by default. To allow them -in a private repo, set `"includeMcpSecrets": true` (requires `includeSecrets`). - -### Sessions (private repos only) - -Sync your opencode sessions (conversation history from `/sessions`) across machines by setting `"includeSessions": true`. This requires `includeSecrets` to also be enabled since sessions may contain sensitive data. - -```jsonc -{ - "repo": { ... }, - "includeSecrets": true, - "includeSessions": true -} -``` - -Synced session data: - -- `~/.local/share/opencode/storage/session/` - Session files -- `~/.local/share/opencode/storage/message/` - Message history -- `~/.local/share/opencode/storage/part/` - Message parts -- `~/.local/share/opencode/storage/session_diff/` - Session diffs - -### Prompt Stash (private repos only) - -Sync your stashed prompts and prompt history across machines by setting `"includePromptStash": true`. This requires `includeSecrets` to also be enabled since prompts may contain sensitive data. - -```jsonc -{ - "repo": { ... }, - "includeSecrets": true, - "includePromptStash": true -} -``` - -Synced prompt data: - -- `~/.local/state/opencode/prompt-stash.jsonl` - Stashed prompts -- `~/.local/state/opencode/prompt-history.jsonl` - Prompt history - -## Overrides - -Create a local-only overrides file at: - -``` -~/.config/opencode/opencode-synced.overrides.jsonc -``` - -Overrides are merged into the runtime config and re-applied to `opencode.json(c)` after pull. - -### MCP secret scrubbing - -If your `opencode.json(c)` contains MCP secrets (for example `mcp.*.headers` or `mcp.*.oauth.clientSecret`), opencode-synced will automatically: - -1. Move the secret values into `opencode-synced.overrides.jsonc` (local-only). -2. Replace the values in the synced config with `{env:...}` placeholders. - -This keeps secrets out of the repo while preserving local behavior. On other machines, set the matching environment variables (or add local overrides). -If you want MCP secrets committed (private repos only), set `"includeMcpSecrets": true` alongside `"includeSecrets": true`. - -Env var naming rules: - -- If the header name already looks like an env var (e.g. `CONTEXT7_API_KEY`), it is used directly. -- Otherwise: `opencode_mcp__
` (non-alphanumerics become `_`). -- OAuth client secrets use `opencode_mcp__OAUTH_CLIENT_SECRET`. - -## Usage +## Commands | Command | Description | -|---------|-------------| -| `/sync-init` | Create a new sync repo (first machine) | -| `/sync-link` | Link to existing sync repo (additional machines) | -| `/sync-status` | Show repo status and last sync times | -| `/sync-pull` | Fetch and apply remote config | -| `/sync-push` | Commit and push local changes | -| `/sync-enable-secrets` | Enable secrets sync (private repos only) | -| `/sync-resolve` | Auto-resolve uncommitted changes using AI | - -
-Manual sync (without slash commands) - -### Trigger a sync - -Restart opencode to run the startup sync flow (pull remote, apply if changed, push local changes if needed). - -### Check status - -Inspect the local repo directly: - -```bash -cd ~/.local/share/opencode/opencode-synced/repo -git status -git log --oneline -5 +| --- | --- | +| `/sync-init` | Create or initialize the first private sync repository | +| `/sync-link` | Link another machine after making a local backup | +| `/sync-status` | Show repository and operation state | +| `/sync-pull` | Apply the remote state; remote wins for an explicit pull | +| `/sync-push` | Reconcile and push; the current machine wins same-item conflicts | + +Linking applies the remote managed scope and may overwrite local files. Back up every machine +before the first link. + +## Repository Layout + +```text +config/ + opencode.json + AGENTS.md + agent/ + command/ + skills/ +state/ + model-favorites.json + model-selectors/ + main-model.txt + cheap-model.txt + prompts/ + prompt-history.jsonl + prompt-stash.jsonl ``` -
- -## Recovery - -If the sync repo has uncommitted changes, you can: +Legacy `data/`, `secrets/`, raw `state/model.json`, direct prompt files, extra manifests, and +tracked sync configuration cause synchronization to stop with a migration error. -1. **Auto-resolve using AI**: Run `/sync-resolve` to let AI analyze and decide whether to commit or discard the changes -2. **Manual resolution**: Navigate to the repo and resolve manually: +## Model State -```bash -cd ~/.local/share/opencode/opencode-synced/repo -git status -git pull --rebase -``` +Only `model.json.favorite` is stored in Git. Pulling favorites preserves each machine's local +`recent` and `variant` data. -Then re-run `/sync-pull` or `/sync-push`. +## Prompt State -## Removal +Prompt files must be valid JSONL and are limited to 16 MiB each. They are synchronized as whole +snapshots. Concurrent same-file changes use the last-successful-sync-wins policy; the losing +snapshot remains recoverable from Git history and the local rollback bundle. -
-How to completely remove and delete opencode-synced +Prompt/model state is synchronized before OpenCode registers the plugin hooks so in-memory TUI +stores do not overwrite freshly imported state during startup. Manual pulls should still be +followed by an OpenCode restart. -Run this one-liner to remove the plugin from your config, delete local sync files, and delete the GitHub repository: - -```bash -bun -e ' - const fs = require("node:fs"), path = require("node:path"), os = require("node:os"), { spawnSync } = require("node:child_process"); - const isWin = os.platform() === "win32", home = os.homedir(); - const configDir = isWin ? path.join(process.env.APPDATA, "opencode") : path.join(home, ".config", "opencode"); - const dataDir = isWin ? path.join(process.env.LOCALAPPDATA, "opencode") : path.join(home, ".local", "share", "opencode"); - ["opencode.json", "opencode.jsonc"].forEach(f => { - const p = path.join(configDir, f); - if (fs.existsSync(p)) { - const c = fs.readFileSync(p, "utf8"), u = c.replace(/"opencode-synced"\s*,?\s*/g, "").replace(/,\s*\]/g, "]"); - if (c !== u) fs.writeFileSync(p, u); - } - }); - const scp = path.join(configDir, "opencode-synced.jsonc"); - if (fs.existsSync(scp)) { - try { - const c = JSON.parse(fs.readFileSync(scp, "utf8").replace(/\/\/.*/g, "")); - if (c.repo?.owner && c.repo?.name) { - const res = spawnSync("gh", ["repo", "delete", `${c.repo.owner}/${c.repo.name}`, "--yes"], { stdio: "inherit" }); - if (res.status !== 0) console.log("\nNote: Repository delete failed. If it is a permission error, run: gh auth refresh -s delete_repo\n"); - } - } catch (e) {} - } - [scp, path.join(configDir, "opencode-synced.overrides.jsonc"), path.join(dataDir, "sync-state.json"), path.join(dataDir, "opencode-synced")].forEach(p => { - if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true }); - }); - console.log("opencode-synced removed."); -' -``` - -### Manual steps -1. Remove `"opencode-synced"` from the `plugin` array in `~/.config/opencode/opencode.json` (or `.jsonc`). -2. Delete the local configuration and state: - ```bash - rm ~/.config/opencode/opencode-synced.jsonc - rm ~/.local/share/opencode/sync-state.json - rm -rf ~/.local/share/opencode/opencode-synced - ``` -3. (Optional) Delete the backup repository on GitHub via the web UI or `gh repo delete`. +## Recovery -
+- Never force-push the data repository. +- Resolve an unexpected dirty sync clone manually after preserving a copy. +- Revert bad remote changes with a normal Git revert commit. +- Local conflict backups live under the OpenCode state directory in + `opencode-synced/rollbacks/`. +- Restore the previous pinned plugin artifact to roll back plugin behavior. ## Development -- `bun run build` -- `bun run test` -- `bun run lint` - -### Local testing (production-like) - -To test the same artifact that would be published, install from a packed tarball -into opencode's cache: - ```bash -mise run local-pack-test +npm install --ignore-scripts --no-package-lock +npm test +npm run build +npm run lint ``` -Then set `~/.config/opencode/opencode.json` to use: - -```jsonc -{ - "plugin": ["opencode-synced"] -} -``` - -Restart opencode to pick up the cached install. - +Architecture and threat-model decisions are documented in +[`docs/extended-sync-v1.md`](docs/extended-sync-v1.md). -## Prefer a CLI version? +## License -I stumbled upon [opencodesync](https://www.npmjs.com/package/opencodesync) while publishing this plugin. +MIT, preserving the original upstream copyright and license. diff --git a/bun.lock b/bun.lock index 8110e08..fe02693 100644 --- a/bun.lock +++ b/bun.lock @@ -3,7 +3,7 @@ "configVersion": 1, "workspaces": { "": { - "name": "opencode-sync", + "name": "@tsunamik/opencode-synced", "dependencies": { "@opencode-ai/plugin": "1.0.85", }, diff --git a/docs/extended-sync-v1.md b/docs/extended-sync-v1.md new file mode 100644 index 0000000..06692d6 --- /dev/null +++ b/docs/extended-sync-v1.md @@ -0,0 +1,76 @@ +# Extended Sync v1 + +## Context + +The released `opencode-synced` v0.9.0 does not cover the full state needed on two +OpenCode machines. This fork adds a narrow, portable scope without adopting the +unreleased Turso, 1Password, or session-sync changes from upstream `main`. + +## Managed data + +The fork may synchronize only these paths: + +- `opencode.json` or `opencode.jsonc`, `AGENTS.md`, agents, commands, modes, tools, + themes, and legacy singular plugins +- `skills/`, excluding generated cache and platform metadata +- prompt history and prompt stash as plaintext in a private repository +- the `favorite` projection from `model.json` +- `main-model.txt` and `cheap-model.txt` + +The following remain local and are rejected when explicitly enabled: + +- authentication files, MCP authentication, and the `secrets/` directory +- sessions and the OpenCode database +- `opencode-synced.jsonc` and `opencode-synced.overrides.jsonc` +- arbitrary extra paths and a custom local repository path + +## Conflict policy + +The last successful synchronization wins. The operation snapshots local managed +items before fetching. If both local and remote changed since the last applied +commit, the currently synchronizing machine writes its local item in a new commit +whose parent is the fetched remote tip. The displaced remote item is retained in +Git history and an owner-only local rollback bundle. + +This policy uses operation order, never wall-clock timestamps. + +## Security invariants + +- The data repository must be private when prompt synchronization is enabled. +- Remote and local symlinks, gitlinks, devices, sockets, and FIFOs are rejected. +- Every repository path must remain inside the configured repository root. +- Skill cache files (`__pycache__`, `*.pyc`, `*.pyo`) and platform metadata + (`*:Zone.Identifier`, `.DS_Store`) are excluded. +- Files are staged and atomically renamed instead of overwriting live files in + place. Directory replacement must preserve a rollback copy until completion. +- Generated local state and overrides use mode `0600`; their parent directory uses + mode `0700`. +- Secrets, sessions, sync configuration, and overrides are never part of a plan. +- `/sync-resolve` must not run AI over diffs or invoke destructive Git cleanup. + +## Model state + +Only `model.json.favorite` is portable. Pulling favorites merges that projection +into the local file while preserving local `recent` and `variant` fields. + +## Prompt state + +Prompt history and stash are synchronized as complete plaintext snapshots in the +private data repository. They are active only after an explicit risk acknowledgement. +Concurrent changes use the general last-successful-sync-wins policy. Git history and +rollback bundles retain prior snapshots. + +## Delivery + +Development starts from tag `v0.9.0` on `feature/extended-sync-v1`. The pilot uses +one reproducible packed artifact with the same checksum on both machines. Publishing +an npm package is deferred until the two-machine test passes. + +## Deferred + +- session or database synchronization +- encrypted prompts +- semantic prompt-event merging +- signed commits and review gates +- arbitrary extra paths +- Turso and external secret backends diff --git a/package.json b/package.json index 4bf337d..cb60855 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "opencode-synced", - "version": "0.9.0", - "description": "Sync global opencode config across machines via GitHub.", + "name": "@tsunamik/opencode-synced", + "version": "0.10.0-beta.1", + "description": "Hardened OpenCode config and state sync across trusted machines.", "author": { "name": "Ian Hildebrand" }, @@ -17,14 +17,12 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/iHildy/opencode-synced.git" + "url": "git+https://github.com/tsunamik/opencode-synced.git" }, "publishConfig": { "access": "public" }, - "files": [ - "dist" - ], + "files": ["dist", "docs"], "dependencies": { "@opencode-ai/plugin": "1.0.85" }, @@ -41,7 +39,7 @@ }, "scripts": { "build": "rm -rf dist && tsc -p tsconfig.build.json && cp -r src/command dist/command", - "prepack": "bun run build", + "prepack": "npm run build", "test": "vitest run", "test:watch": "vitest", "lint": "biome lint .", @@ -51,8 +49,6 @@ "prepare": "husky" }, "lint-staged": { - "*.{js,ts,json}": [ - "biome check --write --no-errors-on-unmatched" - ] + "*.{js,ts,json}": ["biome check --write --no-errors-on-unmatched"] } } diff --git a/src/command/sync-enable-secrets.md b/src/command/sync-enable-secrets.md deleted file mode 100644 index 2a8b228..0000000 --- a/src/command/sync-enable-secrets.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -description: Enable secrets sync (private repo required) ---- - -Use the opencode_sync tool with command "enable-secrets". -If the user supplies extra secret paths, pass them via extraSecretPaths. -If they want MCP secrets committed in a private repo, pass includeMcpSecrets: true. diff --git a/src/command/sync-init.md b/src/command/sync-init.md index 18eb40e..5c81722 100644 --- a/src/command/sync-init.md +++ b/src/command/sync-init.md @@ -7,8 +7,6 @@ The repo will be created automatically if it doesn't exist (private by default). Default repo name is "my-opencode-config" with owner auto-detected from GitHub CLI. If the user wants a custom repo name, pass name="custom-name". If the user wants an org-owned repo, pass owner="org-name". -If the user wants a public repo, pass private=false. -Include includeSecrets if the user explicitly opts in. -Include includeMcpSecrets only if they want MCP secrets committed to a private repo. -If the user supplies extra config paths, pass extraConfigPaths. -Model favorites sync is enabled by default; set includeModelFavorites=false to disable. +Pass includeSkills, includeModelFavorites, and includeModelSelectors for portable state. +Prompt history and stash require a private repo plus acknowledgePlaintextPromptRisk=true. +Secrets, sessions, arbitrary extra paths, overrides, and sync configuration are never synced. diff --git a/src/command/sync-link.md b/src/command/sync-link.md index 9344d43..bab93d8 100644 --- a/src/command/sync-link.md +++ b/src/command/sync-link.md @@ -12,4 +12,6 @@ If no repo name is provided, the tool will automatically search for common sync After linking: - Remind the user to restart opencode to apply the synced config -- If they want to enable secrets sync, they should run /sync-enable-secrets +- Use the same includeSkills, prompt, favorites, and model-selector flags as the first machine +- Prompt flags require acknowledgePlaintextPromptRisk=true and a private repository +- Secrets, sessions, arbitrary extra paths, overrides, and sync configuration stay local diff --git a/src/command/sync-resolve.md b/src/command/sync-resolve.md deleted file mode 100644 index 0f8470a..0000000 --- a/src/command/sync-resolve.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -description: Resolve uncommitted changes in sync repo ---- - -Use the opencode_sync tool with command "resolve" to automatically resolve uncommitted changes in the local sync repository using AI. diff --git a/src/index.ts b/src/index.ts index c6d5d2b..5344d2e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -117,35 +117,36 @@ export const opencodeConfigSync: Plugin = async (ctx) => { description: 'Manage opencode config sync with a GitHub repo', args: { command: tool.schema - .enum(['status', 'init', 'link', 'pull', 'push', 'enable-secrets', 'resolve']) + .enum(['status', 'init', 'link', 'pull', 'push']) .describe('Sync command to execute'), repo: tool.schema.string().optional().describe('Repo owner/name or URL'), owner: tool.schema.string().optional().describe('Repo owner'), name: tool.schema.string().optional().describe('Repo name'), url: tool.schema.string().optional().describe('Repo URL'), branch: tool.schema.string().optional().describe('Repo branch'), - includeSecrets: tool.schema.boolean().optional().describe('Enable secrets sync'), - includeMcpSecrets: tool.schema + includeSkills: tool.schema.boolean().optional().describe('Sync global OpenCode skills'), + includePromptHistory: tool.schema .boolean() .optional() - .describe('Allow MCP secrets to be committed (requires includeSecrets)'), - includeSessions: tool.schema - .boolean() - .optional() - .describe('Enable session sync (requires includeSecrets)'), + .describe('Sync plaintext prompt history in a private repository'), includePromptStash: tool.schema .boolean() .optional() - .describe('Enable prompt stash/history sync (requires includeSecrets)'), + .describe('Sync plaintext prompt stash in a private repository'), includeModelFavorites: tool.schema .boolean() .optional() - .describe('Sync model favorites (state/model.json)'), + .describe('Sync only the favorite projection from state/model.json'), + includeModelSelectors: tool.schema + .boolean() + .optional() + .describe('Sync main-model.txt and cheap-model.txt'), + acknowledgePlaintextPromptRisk: tool.schema + .boolean() + .optional() + .describe('Required acknowledgement when prompt synchronization is enabled'), create: tool.schema.boolean().optional().describe('Create repo if missing'), private: tool.schema.boolean().optional().describe('Create repo as private'), - extraSecretPaths: tool.schema.array(tool.schema.string()).optional(), - extraConfigPaths: tool.schema.array(tool.schema.string()).optional(), - localRepoPath: tool.schema.string().optional().describe('Override local repo path'), }, async execute(args) { try { @@ -159,21 +160,25 @@ export const opencodeConfigSync: Plugin = async (ctx) => { name: args.name, url: args.url, branch: args.branch, - includeSecrets: args.includeSecrets, - includeMcpSecrets: args.includeMcpSecrets, - includeSessions: args.includeSessions, + includeSkills: args.includeSkills, + includePromptHistory: args.includePromptHistory, includePromptStash: args.includePromptStash, includeModelFavorites: args.includeModelFavorites, + includeModelSelectors: args.includeModelSelectors, + acknowledgePlaintextPromptRisk: args.acknowledgePlaintextPromptRisk, create: args.create, private: args.private, - extraSecretPaths: args.extraSecretPaths, - extraConfigPaths: args.extraConfigPaths, - localRepoPath: args.localRepoPath, }); } if (args.command === 'link') { return await service.link({ repo: args.repo ?? args.name, + includeSkills: args.includeSkills, + includePromptHistory: args.includePromptHistory, + includePromptStash: args.includePromptStash, + includeModelFavorites: args.includeModelFavorites, + includeModelSelectors: args.includeModelSelectors, + acknowledgePlaintextPromptRisk: args.acknowledgePlaintextPromptRisk, }); } if (args.command === 'pull') { @@ -182,16 +187,6 @@ export const opencodeConfigSync: Plugin = async (ctx) => { if (args.command === 'push') { return await service.push(); } - if (args.command === 'enable-secrets') { - return await service.enableSecrets({ - extraSecretPaths: args.extraSecretPaths, - includeMcpSecrets: args.includeMcpSecrets, - }); - } - if (args.command === 'resolve') { - return await service.resolve(); - } - return 'Unknown command.'; } catch (error) { if (error instanceof SyncConfigMissingError || error instanceof SyncCommandError) { @@ -202,10 +197,8 @@ export const opencodeConfigSync: Plugin = async (ctx) => { }, }); - // Delay startup sync slightly to ensure TUI is connected - setTimeout(() => { - void service.startupSync(); - }, 1000); + // Import prompt/model state before OpenCode mounts in-memory TUI stores. + await service.startupSync(); return { tool: { diff --git a/src/sync/apply.test.ts b/src/sync/apply.test.ts new file mode 100644 index 0000000..fbb7618 --- /dev/null +++ b/src/sync/apply.test.ts @@ -0,0 +1,403 @@ +import { chmod, lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { syncLocalToRepo, syncRepoToLocal } from './apply.js'; +import type { SyncItem, SyncPlan } from './paths.js'; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function makeRoots(): Promise<{ home: string; repo: string }> { + const root = await mkdtemp(path.join(os.tmpdir(), 'opencode-synced-apply-')); + tempDirs.push(root); + const home = path.join(root, 'home'); + const repo = path.join(root, 'repo'); + await mkdir(home, { recursive: true }); + await mkdir(repo, { recursive: true }); + return { home, repo }; +} + +function makePlan(home: string, repo: string, items: SyncItem[]): SyncPlan { + return { + items, + repoRoot: repo, + homeDir: home, + platform: 'linux', + }; +} + +describe('safe synchronization', () => { + it('rejects a symlinked source file', async () => { + const { home, repo } = await makeRoots(); + const outside = path.join(home, 'outside.txt'); + const source = path.join(home, 'config.txt'); + await writeFile(outside, 'private'); + await symlink(outside, source); + + const plan = makePlan(home, repo, [ + { + localPath: source, + repoPath: path.join(repo, 'config', 'config.txt'), + type: 'file', + isSecret: false, + isConfigFile: false, + }, + ]); + + await expect(syncLocalToRepo(plan, null)).rejects.toThrow('symlink'); + }); + + it('rejects repository paths outside the repository root', async () => { + const { home, repo } = await makeRoots(); + const source = path.join(home, 'config.txt'); + await writeFile(source, 'safe'); + + const plan = makePlan(home, repo, [ + { + localPath: source, + repoPath: `${repo}-evil/config.txt`, + type: 'file', + isSecret: false, + isConfigFile: false, + }, + ]); + + await expect(syncLocalToRepo(plan, null)).rejects.toThrow('outside the allowed root'); + }); + + it('does not delete through a symlinked repository ancestor', async () => { + const { home, repo } = await makeRoots(); + const outside = path.join(home, 'outside'); + await mkdir(outside, { recursive: true }); + await writeFile(path.join(outside, 'AGENTS.md'), 'keep'); + await symlink(outside, path.join(repo, 'config')); + const plan = makePlan(home, repo, [ + { + localPath: path.join(home, 'missing-AGENTS.md'), + repoPath: path.join(repo, 'config', 'AGENTS.md'), + type: 'file', + isSecret: false, + isConfigFile: false, + }, + ]); + + await expect(syncLocalToRepo(plan, null)).rejects.toThrow('symlink'); + expect(await readFile(path.join(outside, 'AGENTS.md'), 'utf8')).toBe('keep'); + }); + + it('filters generated skill files and preserves executable mode', async () => { + const { home, repo } = await makeRoots(); + const skills = path.join(home, 'skills'); + await mkdir(path.join(skills, 'demo', '__pycache__'), { recursive: true }); + await writeFile(path.join(skills, 'demo', 'SKILL.md'), '# Demo\n'); + await writeFile(path.join(skills, 'demo', 'run.sh'), '#!/bin/sh\nexit 0\n'); + await chmod(path.join(skills, 'demo', 'run.sh'), 0o755); + await writeFile(path.join(skills, 'demo', '__pycache__', 'cache.pyc'), 'cache'); + await writeFile(path.join(skills, 'demo', 'SKILL.md:Zone.Identifier'), 'metadata'); + await writeFile(path.join(skills, '.DS_Store'), 'metadata'); + + const destination = path.join(repo, 'config', 'skills'); + const plan = makePlan(home, repo, [ + { + localPath: skills, + repoPath: destination, + type: 'dir', + isSecret: false, + isConfigFile: false, + strategy: 'skills', + }, + ]); + + await syncLocalToRepo(plan, null); + + expect(await readFile(path.join(destination, 'demo', 'SKILL.md'), 'utf8')).toBe('# Demo\n'); + await expect(lstat(path.join(destination, 'demo', '__pycache__'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + await expect( + lstat(path.join(destination, 'demo', 'SKILL.md:Zone.Identifier')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(lstat(path.join(destination, '.DS_Store'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + expect((await lstat(path.join(destination, 'demo', 'run.sh'))).mode & 0o777).toBe(0o755); + }); + + it('rejects sensitive files in skills', async () => { + const { home, repo } = await makeRoots(); + const skills = path.join(home, 'skills'); + await mkdir(path.join(skills, 'demo'), { recursive: true }); + await writeFile(path.join(skills, 'demo', '.env'), 'TOKEN=private\n'); + const plan = makePlan(home, repo, [ + { + localPath: skills, + repoPath: path.join(repo, 'config', 'skills'), + type: 'dir', + isSecret: false, + isConfigFile: false, + strategy: 'skills', + }, + ]); + + await expect(syncLocalToRepo(plan, null)).rejects.toThrow('sensitive skill path'); + }); + + it('scans skill files larger than one MiB for secret-like content', async () => { + const { home, repo } = await makeRoots(); + const skills = path.join(home, 'skills'); + await mkdir(path.join(skills, 'demo'), { recursive: true }); + const leakedKey = `ghp_${'a'.repeat(36)}`; + await writeFile( + path.join(skills, 'demo', 'large.md'), + `${'x'.repeat(1024 * 1024)}${leakedKey}` + ); + const plan = makePlan(home, repo, [ + { + localPath: skills, + repoPath: path.join(repo, 'config', 'skills'), + type: 'dir', + isSecret: false, + isConfigFile: false, + strategy: 'skills', + }, + ]); + + await expect(syncLocalToRepo(plan, null)).rejects.toThrow('secret-like skill content'); + }); + + it('writes only the favorite projection to the repository', async () => { + const { home, repo } = await makeRoots(); + const source = path.join(home, 'model.json'); + const destination = path.join(repo, 'state', 'model-favorites.json'); + await writeFile( + source, + JSON.stringify({ + favorite: [{ providerID: 'provider', modelID: 'model' }], + recent: [{ providerID: 'local', modelID: 'recent' }], + variant: { local: 'high' }, + }) + ); + + const plan = makePlan(home, repo, [ + { + localPath: source, + repoPath: destination, + type: 'file', + isSecret: false, + isConfigFile: false, + strategy: 'model-favorites', + }, + ]); + + await syncLocalToRepo(plan, null); + + expect(JSON.parse(await readFile(destination, 'utf8'))).toEqual({ + favorite: [{ providerID: 'provider', modelID: 'model' }], + }); + }); + + it('applies favorites while preserving local recent and variant state', async () => { + const { home, repo } = await makeRoots(); + const destination = path.join(home, 'model.json'); + const source = path.join(repo, 'state', 'model-favorites.json'); + await mkdir(path.dirname(source), { recursive: true }); + await writeFile( + source, + JSON.stringify({ favorite: [{ providerID: 'remote', modelID: 'model' }] }) + ); + await writeFile( + destination, + JSON.stringify({ + favorite: [{ providerID: 'old', modelID: 'model' }], + recent: [{ providerID: 'local', modelID: 'recent' }], + variant: { local: 'high' }, + }) + ); + + const plan = makePlan(home, repo, [ + { + localPath: destination, + repoPath: source, + type: 'file', + isSecret: false, + isConfigFile: false, + strategy: 'model-favorites', + }, + ]); + + await syncRepoToLocal(plan, null); + + expect(JSON.parse(await readFile(destination, 'utf8'))).toEqual({ + favorite: [{ providerID: 'remote', modelID: 'model' }], + recent: [{ providerID: 'local', modelID: 'recent' }], + variant: { local: 'high' }, + }); + }); + + it('applies a remote favorites deletion without deleting local recent state', async () => { + const { home, repo } = await makeRoots(); + const destination = path.join(home, 'model.json'); + await writeFile( + destination, + JSON.stringify({ + favorite: [{ providerID: 'old', modelID: 'model' }], + recent: [{ providerID: 'local', modelID: 'recent' }], + }) + ); + const plan = makePlan(home, repo, [ + { + localPath: destination, + repoPath: path.join(repo, 'state', 'model-favorites.json'), + type: 'file', + isSecret: false, + isConfigFile: false, + strategy: 'model-favorites', + }, + ]); + + await syncRepoToLocal(plan, null); + + expect(JSON.parse(await readFile(destination, 'utf8'))).toEqual({ + favorite: [], + recent: [{ providerID: 'local', modelID: 'recent' }], + }); + }); + + it('rejects a symlinked local destination during pull', async () => { + const { home, repo } = await makeRoots(); + const outside = path.join(home, 'outside.txt'); + const destination = path.join(home, 'config.txt'); + const source = path.join(repo, 'config', 'config.txt'); + await mkdir(path.dirname(source), { recursive: true }); + await writeFile(outside, 'keep'); + await symlink(outside, destination); + await writeFile(source, 'remote'); + + const plan = makePlan(home, repo, [ + { + localPath: destination, + repoPath: source, + type: 'file', + isSecret: false, + isConfigFile: false, + }, + ]); + + await expect(syncRepoToLocal(plan, null)).rejects.toThrow('symlink'); + expect(await readFile(outside, 'utf8')).toBe('keep'); + }); + + it('applies a remote deletion to the local managed file', async () => { + const { home, repo } = await makeRoots(); + const destination = path.join(home, 'obsolete.txt'); + await writeFile(destination, 'obsolete'); + const plan = makePlan(home, repo, [ + { + localPath: destination, + repoPath: path.join(repo, 'config', 'obsolete.txt'), + type: 'file', + isSecret: false, + isConfigFile: false, + }, + ]); + + await syncRepoToLocal(plan, null); + + await expect(readFile(destination, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('rolls back earlier local items when a later remote item is invalid', async () => { + const { home, repo } = await makeRoots(); + const localA = path.join(home, 'a.txt'); + const localB = path.join(home, 'b.txt'); + const remoteA = path.join(repo, 'config', 'a.txt'); + const remoteB = path.join(repo, 'config', 'b.txt'); + const outside = path.join(repo, 'outside.txt'); + await mkdir(path.dirname(remoteA), { recursive: true }); + await writeFile(localA, 'local-a'); + await writeFile(localB, 'local-b'); + await writeFile(remoteA, 'remote-a'); + await writeFile(outside, 'outside'); + await symlink(outside, remoteB); + const plan = makePlan(home, repo, [ + { localPath: localA, repoPath: remoteA, type: 'file', isSecret: false, isConfigFile: false }, + { localPath: localB, repoPath: remoteB, type: 'file', isSecret: false, isConfigFile: false }, + ]); + + await expect(syncRepoToLocal(plan, null)).rejects.toThrow('symlink'); + expect(await readFile(localA, 'utf8')).toBe('local-a'); + expect(await readFile(localB, 'utf8')).toBe('local-b'); + }); + + it('rejects malformed remote config before replacing the local config', async () => { + const { home, repo } = await makeRoots(); + const localConfig = path.join(home, 'opencode.json'); + const remoteConfig = path.join(repo, 'config', 'opencode.json'); + await mkdir(path.dirname(remoteConfig), { recursive: true }); + await writeFile(localConfig, '{"theme":"local"}\n'); + await writeFile(remoteConfig, '{"theme":'); + const plan = makePlan(home, repo, [ + { + localPath: localConfig, + repoPath: remoteConfig, + type: 'file', + isSecret: false, + isConfigFile: true, + }, + ]); + + await expect(syncRepoToLocal(plan, null)).rejects.toThrow(); + expect(await readFile(localConfig, 'utf8')).toBe('{"theme":"local"}\n'); + }); + + it('copies valid prompt JSONL snapshots byte-for-byte', async () => { + const { home, repo } = await makeRoots(); + const source = path.join(home, 'prompt-history.jsonl'); + const destination = path.join(repo, 'state', 'prompts', 'prompt-history.jsonl'); + const content = + '{"input":"one","mode":"build","parts":[]}\n{"input":"two","mode":"plan","parts":[]}\n'; + await writeFile(source, content); + const plan = makePlan(home, repo, [ + { + localPath: source, + repoPath: destination, + type: 'file', + isSecret: true, + isConfigFile: false, + strategy: 'prompt-snapshot', + }, + ]); + + await syncLocalToRepo(plan, null); + + expect(await readFile(destination, 'utf8')).toBe(content); + expect((await lstat(destination)).mode & 0o777).toBe(0o600); + expect((await lstat(path.dirname(destination))).mode & 0o777).toBe(0o700); + }); + + it('rejects malformed prompt JSONL before it reaches the repository', async () => { + const { home, repo } = await makeRoots(); + const source = path.join(home, 'prompt-history.jsonl'); + const destination = path.join(repo, 'state', 'prompts', 'prompt-history.jsonl'); + await writeFile(source, '{"input":"valid"}\n{"input":\n'); + const plan = makePlan(home, repo, [ + { + localPath: source, + repoPath: destination, + type: 'file', + isSecret: true, + isConfigFile: false, + strategy: 'prompt-snapshot', + }, + ]); + + await expect(syncLocalToRepo(plan, null)).rejects.toThrow('Invalid prompt JSONL'); + await expect(readFile(destination, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + }); +}); diff --git a/src/sync/apply.ts b/src/sync/apply.ts index 98bc65e..39210d6 100644 --- a/src/sync/apply.ts +++ b/src/sync/apply.ts @@ -1,4 +1,6 @@ +import { randomUUID } from 'node:crypto'; import { promises as fs } from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { @@ -11,50 +13,113 @@ import { writeJsonFile, } from './config.js'; import { + assertNoLiteralSecrets, extractMcpSecrets, hasOverrides, mergeOverrides, stripOverrideKeys, } from './mcp-secrets.js'; -import type { ExtraPathPlan, SyncItem, SyncPlan } from './paths.js'; -import { normalizePath } from './paths.js'; +import type { SyncItem, SyncPlan } from './paths.js'; -type ExtraPathType = 'file' | 'dir'; - -interface ExtraPathManifestItem { - relativePath: string; - type: ExtraPathType; - mode?: number; -} - -interface ExtraPathManifestEntry { - sourcePath: string; - repoPath: string; - type?: ExtraPathType; - mode?: number; - items?: ExtraPathManifestItem[]; -} - -interface ExtraPathManifest { - entries: ExtraPathManifestEntry[]; +export async function syncRepoToLocal( + plan: SyncPlan, + overrides: Record | null +): Promise { + const rollbackRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-synced-apply-')); + await fs.chmod(rollbackRoot, 0o700); + let retainRollback = false; + try { + await backupLocalItems(plan, rollbackRoot); + try { + await applyRepoToLocal(plan, overrides); + } catch (error) { + const restoreErrors = await restoreLocalItems(plan, rollbackRoot); + if (restoreErrors.length > 0) { + retainRollback = true; + throw new AggregateError( + [error, ...restoreErrors], + `Remote apply failed and rollback data was retained at ${rollbackRoot}` + ); + } + throw error; + } + } finally { + if (!retainRollback) await fs.rm(rollbackRoot, { recursive: true, force: true }); + } } -export async function syncRepoToLocal( +async function applyRepoToLocal( plan: SyncPlan, overrides: Record | null ): Promise { for (const item of plan.items) { - await copyItem(item.repoPath, item.localPath, item.type); + assertPathInside(plan.repoRoot, item.repoPath); + const localRoot = findAllowedLocalRoot(plan, item.localPath); + await assertSafeDestination(localRoot, item.localPath); + if (item.isConfigFile) { + await validateRemoteConfig(item.repoPath); + } + if (item.strategy === 'model-favorites') { + await applyModelFavorites(item, localRoot); + continue; + } + await copyItem(item.repoPath, item.localPath, item.type, true, { + sourceRoot: plan.repoRoot, + destinationRoot: localRoot, + strategy: item.strategy, + }); } - await applyExtraPaths(plan, plan.extraConfigs); - await applyExtraPaths(plan, plan.extraSecrets); - if (overrides && Object.keys(overrides).length > 0) { await applyOverridesToLocalConfig(plan, overrides); } } +async function validateRemoteConfig(configPath: string): Promise { + const stat = await lstatOrNull(configPath); + if (!stat) return; + if (stat.isSymbolicLink()) throw new Error(`Refusing to read symlink: ${configPath}`); + if (!stat.isFile()) throw new Error(`Expected regular file: ${configPath}`); + const parsed = parseJsonc>(await fs.readFile(configPath, 'utf8')); + assertNoLiteralSecrets(parsed); +} + +async function backupLocalItems(plan: SyncPlan, rollbackRoot: string): Promise { + for (let index = 0; index < plan.items.length; index += 1) { + const item = plan.items[index]; + const localRoot = findAllowedLocalRoot(plan, item.localPath); + const rollbackPath = localRollbackPath(rollbackRoot, item, index); + await copyItem(item.localPath, rollbackPath, item.type, true, { + sourceRoot: localRoot, + destinationRoot: rollbackRoot, + strategy: 'copy', + }); + } +} + +async function restoreLocalItems(plan: SyncPlan, rollbackRoot: string): Promise { + const errors: unknown[] = []; + for (let index = plan.items.length - 1; index >= 0; index -= 1) { + const item = plan.items[index]; + const localRoot = findAllowedLocalRoot(plan, item.localPath); + const rollbackPath = localRollbackPath(rollbackRoot, item, index); + try { + await copyItem(rollbackPath, item.localPath, item.type, true, { + sourceRoot: rollbackRoot, + destinationRoot: localRoot, + strategy: 'copy', + }); + } catch (error) { + errors.push(error); + } + } + return errors; +} + +function localRollbackPath(rollbackRoot: string, item: SyncItem, index: number): string { + return path.join(rollbackRoot, String(index), path.basename(item.localPath) || 'item'); +} + export async function syncLocalToRepo( plan: SyncPlan, overrides: Record | null, @@ -71,6 +136,7 @@ export async function syncLocalToRepo( const content = await fs.readFile(item.localPath, 'utf8'); const parsed = parseJsonc>(content); const { sanitizedConfig, secretOverrides: extracted } = extractMcpSecrets(parsed); + assertNoLiteralSecrets(sanitizedConfig); if (!allowMcpSecrets) { sanitizedConfigs.set(item.localPath, sanitizedConfig); } @@ -85,46 +151,94 @@ export async function syncLocalToRepo( const baseOverrides = overrides ?? {}; const mergedOverrides = mergeOverrides(baseOverrides, secretOverrides); if (options.overridesPath && !isDeepEqual(baseOverrides, mergedOverrides)) { - await writeJsonFile(options.overridesPath, mergedOverrides, { jsonc: true }); + const overridesParent = path.dirname(options.overridesPath); + await fs.mkdir(overridesParent, { recursive: true, mode: 0o700 }); + await fs.chmod(overridesParent, 0o700); + await writeJsonFile(options.overridesPath, mergedOverrides, { jsonc: true, mode: 0o600 }); } } overridesForStrip = overrides ? stripOverrideKeys(overrides, secretOverrides) : overrides; } for (const item of plan.items) { + assertPathInside(plan.repoRoot, item.repoPath); + const localRoot = findAllowedLocalRoot(plan, item.localPath); + await assertSafeDestination(localRoot, item.localPath); if (item.isConfigFile) { const sanitized = sanitizedConfigs.get(item.localPath); await copyConfigForRepo(item, overridesForStrip, plan.repoRoot, sanitized); continue; } - await copyItem(item.localPath, item.repoPath, item.type, true); - } + if (item.strategy === 'model-favorites') { + await writeModelFavorites(item, plan); + continue; + } - await writeExtraPathManifest(plan, plan.extraConfigs); - await writeExtraPathManifest(plan, plan.extraSecrets); + await copyItem(item.localPath, item.repoPath, item.type, true, { + sourceRoot: localRoot, + destinationRoot: plan.repoRoot, + strategy: item.strategy, + }); + } } async function copyItem( sourcePath: string, destinationPath: string, type: SyncItem['type'], - removeWhenMissing = false + removeWhenMissing = false, + options: { + sourceRoot?: string; + destinationRoot?: string; + strategy?: SyncItem['strategy']; + } = {} ): Promise { - if (!(await pathExists(sourcePath))) { + if (options.sourceRoot) assertPathInside(options.sourceRoot, sourcePath); + if (options.destinationRoot) assertPathInside(options.destinationRoot, destinationPath); + if (options.sourceRoot) await assertSafeDestination(options.sourceRoot, sourcePath); + if (options.destinationRoot) + await assertSafeDestination(options.destinationRoot, destinationPath); + + const sourceStat = await lstatOrNull(sourcePath); + if (!sourceStat) { if (removeWhenMissing) { await removePath(destinationPath); } return; } + if (sourceStat.isSymbolicLink()) { + throw new Error(`Refusing to copy symlink: ${sourcePath}`); + } + if (type === 'file') { - await copyFileWithMode(sourcePath, destinationPath); + if (!sourceStat.isFile()) { + throw new Error(`Expected regular file: ${sourcePath}`); + } + if (options.strategy === 'prompt-snapshot') { + await validatePromptSnapshot(sourcePath, sourceStat.size); + } + const isPrivateSnapshot = options.strategy === 'prompt-snapshot'; + await copyFileWithMode( + sourcePath, + destinationPath, + options.destinationRoot, + isPrivateSnapshot ? 0o600 : undefined, + isPrivateSnapshot ? 0o700 : undefined + ); return; } - await removePath(destinationPath); - await copyDirRecursive(sourcePath, destinationPath); + if (!sourceStat.isDirectory()) { + throw new Error(`Expected directory: ${sourcePath}`); + } + await replaceDirectoryAtomic( + sourcePath, + destinationPath, + options.strategy === 'skills', + options.destinationRoot + ); } async function copyConfigForRepo( @@ -133,6 +247,7 @@ async function copyConfigForRepo( repoRoot: string, configOverride?: Record ): Promise { + await assertSafeDestination(repoRoot, item.repoPath); if (!(await pathExists(item.localPath))) { await removePath(item.repoPath); return; @@ -150,7 +265,9 @@ async function copyConfigForRepo( } } const stripped = stripOverrides(localConfig, effectiveOverrides, baseConfig); - const stat = await fs.stat(item.localPath); + assertPathInside(repoRoot, item.repoPath); + const stat = await safeRegularFileStat(item.localPath); + await assertSafeDestination(repoRoot, item.repoPath); await fs.mkdir(path.dirname(item.repoPath), { recursive: true }); await writeJsonFile(item.repoPath, stripped, { jsonc: item.localPath.endsWith('.jsonc'), @@ -162,9 +279,7 @@ async function readRepoConfig( item: SyncItem, repoRoot: string ): Promise | null> { - if (!item.repoPath.startsWith(repoRoot)) { - return null; - } + assertPathInside(repoRoot, item.repoPath); if (!(await pathExists(item.repoPath))) { return null; } @@ -183,7 +298,7 @@ async function applyOverridesToLocalConfig( const content = await fs.readFile(item.localPath, 'utf8'); const parsed = parseJsonc>(content); const merged = deepMerge(parsed, overrides) as Record; - const stat = await fs.stat(item.localPath); + const stat = await safeRegularFileStat(item.localPath); await writeJsonFile(item.localPath, merged, { jsonc: item.localPath.endsWith('.jsonc'), mode: stat.mode & 0o777, @@ -191,30 +306,67 @@ async function applyOverridesToLocalConfig( } } -async function copyFileWithMode(sourcePath: string, destinationPath: string): Promise { - const stat = await fs.stat(sourcePath); - await fs.mkdir(path.dirname(destinationPath), { recursive: true }); - await fs.copyFile(sourcePath, destinationPath); - await chmodIfExists(destinationPath, stat.mode & 0o777); +async function copyFileWithMode( + sourcePath: string, + destinationPath: string, + destinationRoot?: string, + modeOverride?: number, + parentMode?: number +): Promise { + const stat = await safeRegularFileStat(sourcePath); + if (destinationRoot) await assertSafeDestination(destinationRoot, destinationPath); + const destinationParent = path.dirname(destinationPath); + await fs.mkdir(destinationParent, { recursive: true, mode: parentMode }); + if (parentMode !== undefined) await fs.chmod(destinationParent, parentMode); + const tempPath = `${destinationPath}.sync-tmp-${randomUUID()}`; + try { + await fs.copyFile(sourcePath, tempPath); + await fs.chmod(tempPath, modeOverride ?? stat.mode & 0o777); + await fs.rename(tempPath, destinationPath); + } finally { + await fs.rm(tempPath, { force: true }); + } } -async function copyDirRecursive(sourcePath: string, destinationPath: string): Promise { - const stat = await fs.stat(sourcePath); +async function copyDirRecursive( + sourcePath: string, + destinationPath: string, + skillsOnly = false, + relativeRoot = sourcePath +): Promise { + const stat = await fs.lstat(sourcePath); + if (stat.isSymbolicLink()) throw new Error(`Refusing to copy symlink: ${sourcePath}`); + if (!stat.isDirectory()) throw new Error(`Expected directory: ${sourcePath}`); await fs.mkdir(destinationPath, { recursive: true }); const entries = await fs.readdir(sourcePath, { withFileTypes: true }); for (const entry of entries) { const entrySource = path.join(sourcePath, entry.name); const entryDest = path.join(destinationPath, entry.name); + const relativePath = path.relative(relativeRoot, entrySource); + + if (entry.isSymbolicLink()) { + throw new Error(`Refusing to copy symlink: ${entrySource}`); + } + if (skillsOnly) { + const skillPathPolicy = classifySkillPath(relativePath, entry.isDirectory()); + if (skillPathPolicy === 'ignore') continue; + if (skillPathPolicy === 'reject') { + throw new Error(`Refusing to sync sensitive skill path: ${relativePath}`); + } + } if (entry.isDirectory()) { - await copyDirRecursive(entrySource, entryDest); + await copyDirRecursive(entrySource, entryDest, skillsOnly, relativeRoot); continue; } if (entry.isFile()) { + if (skillsOnly) await assertSkillFileContentSafe(entrySource, relativePath); await copyFileWithMode(entrySource, entryDest); + continue; } + throw new Error(`Refusing unsupported filesystem entry: ${entrySource}`); } await chmodIfExists(destinationPath, stat.mode & 0o777); @@ -224,154 +376,245 @@ async function removePath(targetPath: string): Promise { await fs.rm(targetPath, { recursive: true, force: true }); } -async function applyExtraPaths(plan: SyncPlan, extra: ExtraPathPlan): Promise { - const allowlist = extra.allowlist; - if (allowlist.length === 0) return; - - if (!(await pathExists(extra.manifestPath))) return; - - const manifestContent = await fs.readFile(extra.manifestPath, 'utf8'); - const manifest = parseJsonc(manifestContent); - - for (const entry of manifest.entries) { - const normalized = normalizePath(entry.sourcePath, plan.homeDir, plan.platform); - const isAllowed = allowlist.includes(normalized); - if (!isAllowed) continue; - - const repoPath = path.isAbsolute(entry.repoPath) - ? entry.repoPath - : path.join(plan.repoRoot, entry.repoPath); - const localPath = entry.sourcePath; - const entryType: ExtraPathType = entry.type ?? 'file'; - - if (!(await pathExists(repoPath))) continue; - - await copyItem(repoPath, localPath, entryType); - await applyExtraPathModes(localPath, entry); +async function replaceDirectoryAtomic( + sourcePath: string, + destinationPath: string, + skillsOnly: boolean, + destinationRoot?: string +): Promise { + if (destinationRoot) await assertSafeDestination(destinationRoot, destinationPath); + await fs.mkdir(path.dirname(destinationPath), { recursive: true }); + const stagePath = `${destinationPath}.sync-tmp-${randomUUID()}`; + const backupPath = `${destinationPath}.sync-backup-${randomUUID()}`; + let hasBackup = false; + + try { + await copyDirRecursive(sourcePath, stagePath, skillsOnly); + const destinationStat = await lstatOrNull(destinationPath); + if (destinationStat?.isSymbolicLink()) { + throw new Error(`Refusing to replace symlink: ${destinationPath}`); + } + if (destinationStat) { + if (!destinationStat.isDirectory()) throw new Error(`Expected directory: ${destinationPath}`); + await fs.rename(destinationPath, backupPath); + hasBackup = true; + } + await fs.rename(stagePath, destinationPath); + if (hasBackup) { + await fs.rm(backupPath, { recursive: true, force: true }); + hasBackup = false; + } + } catch (error) { + const destinationExists = Boolean(await lstatOrNull(destinationPath)); + if (hasBackup && !destinationExists) { + try { + await fs.rename(backupPath, destinationPath); + hasBackup = false; + } catch (restoreError) { + throw new AggregateError( + [error, restoreError], + `Directory replacement failed and backup was retained at ${backupPath}` + ); + } + } + throw error; + } finally { + await fs.rm(stagePath, { recursive: true, force: true }); } } -async function writeExtraPathManifest(plan: SyncPlan, extra: ExtraPathPlan): Promise { - const allowlist = extra.allowlist; - const extraDir = path.join(path.dirname(extra.manifestPath), 'extra'); - if (allowlist.length === 0) { - await removePath(extra.manifestPath); - await removePath(extraDir); +async function writeModelFavorites(item: SyncItem, plan: SyncPlan): Promise { + await assertSafeDestination(plan.repoRoot, item.repoPath); + const sourceStat = await lstatOrNull(item.localPath); + if (!sourceStat) { + await removePath(item.repoPath); return; } + const stat = await safeRegularFileStat(item.localPath); + const modelState = parseJsonc>(await fs.readFile(item.localPath, 'utf8')); + const favorite = modelState.favorite; + if (!Array.isArray(favorite)) throw new Error(`Invalid model favorites file: ${item.localPath}`); + await assertSafeDestination(plan.repoRoot, item.repoPath); + await fs.mkdir(path.dirname(item.repoPath), { recursive: true }); + await writeJsonFile(item.repoPath, { favorite }, { jsonc: false, mode: stat.mode & 0o777 }); +} - await removePath(extraDir); - - const entries: ExtraPathManifestEntry[] = []; - - for (const entry of extra.entries) { - const sourcePath = entry.sourcePath; - if (!(await pathExists(sourcePath))) { - continue; - } - const stat = await fs.stat(sourcePath); - if (stat.isDirectory()) { - await copyDirRecursive(sourcePath, entry.repoPath); - const items = await collectExtraPathItems(sourcePath, sourcePath); - entries.push({ - sourcePath, - repoPath: path.relative(plan.repoRoot, entry.repoPath), - type: 'dir', - mode: stat.mode & 0o777, - items, - }); - continue; - } - if (stat.isFile()) { - await copyFileWithMode(sourcePath, entry.repoPath); - entries.push({ - sourcePath, - repoPath: path.relative(plan.repoRoot, entry.repoPath), - type: 'file', - mode: stat.mode & 0o777, - }); - } +async function applyModelFavorites(item: SyncItem, localRoot: string): Promise { + const remoteStat = await lstatOrNull(item.repoPath); + if (!remoteStat) { + const localStat = await lstatOrNull(item.localPath); + if (!localStat) return; + if (localStat.isSymbolicLink()) + throw new Error(`Refusing to replace symlink: ${item.localPath}`); + if (!localStat.isFile()) throw new Error(`Expected regular file: ${item.localPath}`); + const local = parseJsonc>(await fs.readFile(item.localPath, 'utf8')); + await assertSafeDestination(localRoot, item.localPath); + await writeJsonFile( + item.localPath, + { ...local, favorite: [] }, + { jsonc: false, mode: localStat.mode & 0o777 } + ); + return; + } + if (remoteStat.isSymbolicLink()) throw new Error(`Refusing to read symlink: ${item.repoPath}`); + if (!remoteStat.isFile()) throw new Error(`Expected regular file: ${item.repoPath}`); + const remote = parseJsonc>(await fs.readFile(item.repoPath, 'utf8')); + if (!Array.isArray(remote.favorite)) { + throw new Error(`Invalid model favorites projection: ${item.repoPath}`); } - await fs.mkdir(path.dirname(extra.manifestPath), { recursive: true }); - await writeJsonFile(extra.manifestPath, { entries }, { jsonc: false }); + const localStat = await lstatOrNull(item.localPath); + if (localStat?.isSymbolicLink()) + throw new Error(`Refusing to replace symlink: ${item.localPath}`); + const local = localStat + ? parseJsonc>(await fs.readFile(item.localPath, 'utf8')) + : {}; + await assertSafeDestination(localRoot, item.localPath); + await fs.mkdir(path.dirname(item.localPath), { recursive: true }); + await writeJsonFile( + item.localPath, + { ...local, favorite: remote.favorite }, + { jsonc: false, mode: localStat ? localStat.mode & 0o777 : 0o600 } + ); } -async function collectExtraPathItems( - sourcePath: string, - basePath: string -): Promise { - const items: ExtraPathManifestItem[] = []; - const entries = await fs.readdir(sourcePath, { withFileTypes: true }); - - for (const entry of entries) { - const entrySource = path.join(sourcePath, entry.name); - const relativePath = path.relative(basePath, entrySource); - - if (entry.isDirectory()) { - const stat = await fs.stat(entrySource); - items.push({ - relativePath, - type: 'dir', - mode: stat.mode & 0o777, - }); - const nested = await collectExtraPathItems(entrySource, basePath); - items.push(...nested); - continue; - } +function classifySkillPath( + relativePath: string, + isDirectory: boolean +): 'include' | 'ignore' | 'reject' { + const segments = relativePath.split(path.sep); + const lowerSegments = segments.map((segment) => segment.toLowerCase()); + const name = lowerSegments.at(-1) ?? ''; + const ignoredDirectories = new Set([ + '.cache', + '.git', + '.mypy_cache', + '.pytest_cache', + '.venv', + '__pycache__', + 'build', + 'coverage', + 'dist', + 'node_modules', + 'target', + 'venv', + ]); + if (isDirectory && ignoredDirectories.has(name)) return 'ignore'; + if (name.endsWith('.pyc') || name.endsWith('.pyo')) return 'ignore'; + if (name.endsWith(':zone.identifier') || name === '.ds_store') return 'ignore'; + + const sensitiveDirectories = new Set(['.gnupg', '.ssh', 'private', 'secrets']); + if (lowerSegments.some((segment) => sensitiveDirectories.has(segment))) return 'reject'; + const sensitiveNames = new Set([ + '.netrc', + '.npmrc', + 'auth.json', + 'credentials.json', + 'id_ed25519', + 'id_rsa', + 'token.json', + ]); + if (name === '.env' || name.startsWith('.env.')) return 'reject'; + if (sensitiveNames.has(name)) return 'reject'; + if (/\.(?:db|key|kdbx|p12|pem|pfx|sqlite|sqlite3)$/i.test(name)) return 'reject'; + return 'include'; +} - if (entry.isFile()) { - const stat = await fs.stat(entrySource); - items.push({ - relativePath, - type: 'file', - mode: stat.mode & 0o777, - }); - } +async function assertSkillFileContentSafe(filePath: string, relativePath: string): Promise { + const stat = await safeRegularFileStat(filePath); + const maxBytes = 16 * 1024 * 1024; + if (stat.size > maxBytes) { + throw new Error(`Refusing oversized skill file (${stat.size} bytes): ${relativePath}`); + } + const content = await fs.readFile(filePath); + if (content.includes(0)) return; + const text = content.toString('utf8'); + const secretPatterns = [ + /-----BEGIN [A-Z ]*PRIVATE KEY-----/, + /ghp_[A-Za-z0-9]{36,}/, + /github_pat_[A-Za-z0-9_]{50,}/, + /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/, + ]; + if (secretPatterns.some((pattern) => pattern.test(text))) { + throw new Error(`Refusing to sync secret-like skill content: ${relativePath}`); } - - return items; } -async function applyExtraPathModes( - targetPath: string, - entry: ExtraPathManifestEntry -): Promise { - if (entry.mode !== undefined) { - await chmodIfExists(targetPath, entry.mode); +async function validatePromptSnapshot(filePath: string, size: number): Promise { + const maxBytes = 16 * 1024 * 1024; + if (size > maxBytes) throw new Error(`Prompt snapshot exceeds ${maxBytes} bytes: ${filePath}`); + const content = await fs.readFile(filePath, 'utf8'); + const lines = content.split('\n'); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].trim(); + if (!line) continue; + try { + const value = JSON.parse(line) as unknown; + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error('not object'); + } catch { + throw new Error(`Invalid prompt JSONL at ${filePath}:${index + 1}`); + } } +} - if (entry.type !== 'dir') { +function assertPathInside(rootPath: string, candidatePath: string): void { + const root = path.resolve(rootPath); + const candidate = path.resolve(candidatePath); + const relative = path.relative(root, candidate); + if ( + relative === '' || + (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)) + ) { return; } + throw new Error(`Path is outside the allowed root: ${candidatePath}`); +} - if (!entry.items || entry.items.length === 0) { - return; +function findAllowedLocalRoot(plan: SyncPlan, candidatePath: string): string { + for (const root of plan.localRoots ?? [plan.homeDir]) { + try { + assertPathInside(root, candidatePath); + return root; + } catch {} } + throw new Error(`Path is outside the allowed local roots: ${candidatePath}`); +} - for (const item of entry.items) { - if (item.mode === undefined) continue; - const itemPath = resolveExtraPathItem(targetPath, item.relativePath); - if (!itemPath) continue; - await chmodIfExists(itemPath, item.mode); +export async function assertSafeDestination( + rootPath: string, + destinationPath: string +): Promise { + assertPathInside(rootPath, destinationPath); + const root = path.resolve(rootPath); + const rootStat = await lstatOrNull(root); + if (rootStat?.isSymbolicLink()) throw new Error(`Refusing to traverse symlink: ${root}`); + if (rootStat && !rootStat.isDirectory()) throw new Error(`Expected directory root: ${root}`); + const relative = path.relative(root, path.resolve(destinationPath)); + let current = root; + for (const segment of relative.split(path.sep).filter(Boolean)) { + current = path.join(current, segment); + const stat = await lstatOrNull(current); + if (!stat) break; + if (stat.isSymbolicLink()) throw new Error(`Refusing to traverse symlink: ${current}`); } } -function resolveExtraPathItem(basePath: string, relativePath: string): string | null { - if (!relativePath) return null; - if (path.isAbsolute(relativePath)) return null; +async function safeRegularFileStat(filePath: string) { + const stat = await fs.lstat(filePath); + if (stat.isSymbolicLink()) throw new Error(`Refusing to copy symlink: ${filePath}`); + if (!stat.isFile()) throw new Error(`Expected regular file: ${filePath}`); + return stat; +} - const resolvedBase = path.resolve(basePath); - const resolvedPath = path.resolve(basePath, relativePath); - const relative = path.relative(resolvedBase, resolvedPath); - if (relative === '..' || relative.startsWith(`..${path.sep}`)) { - return null; +async function lstatOrNull(filePath: string) { + try { + return await fs.lstat(filePath); + } catch (error) { + const maybeErrno = error as NodeJS.ErrnoException; + if (maybeErrno.code === 'ENOENT') return null; + throw error; } - if (path.isAbsolute(relative)) { - return null; - } - - return resolvedPath; } function isDeepEqual(left: unknown, right: unknown): boolean { diff --git a/src/sync/config.test.ts b/src/sync/config.test.ts index e8b925c..e310aef 100644 --- a/src/sync/config.test.ts +++ b/src/sync/config.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -8,10 +8,15 @@ import { canCommitMcpSecrets, chmodIfExists, deepMerge, + loadState, normalizeSyncConfig, parseJsonc, stripOverrides, + writeJsonFile, + writeState, + writeSyncConfig, } from './config.js'; +import { resolveSyncLocations } from './paths.js'; describe('deepMerge', () => { it('merges nested objects and replaces arrays', () => { @@ -57,33 +62,75 @@ describe('stripOverrides', () => { }); describe('normalizeSyncConfig', () => { - it('disables MCP secrets when secrets are disabled', () => { + it('keeps all secret sync disabled', () => { const normalized = normalizeSyncConfig({ includeSecrets: false, - includeMcpSecrets: true, + includeMcpSecrets: false, }); + expect(normalized.includeSecrets).toBe(false); expect(normalized.includeMcpSecrets).toBe(false); }); - it('allows MCP secrets when secrets are enabled', () => { - const normalized = normalizeSyncConfig({ - includeSecrets: true, - includeMcpSecrets: true, - }); - expect(normalized.includeMcpSecrets).toBe(true); + it('rejects MCP secrets even when secrets are enabled', () => { + expect(() => + normalizeSyncConfig({ + includeSecrets: true, + includeMcpSecrets: true, + }) + ).toThrow('not supported by this fork'); }); it('enables model favorites by default', () => { const normalized = normalizeSyncConfig({}); expect(normalized.includeModelFavorites).toBe(true); }); + + it('keeps extended sync features opt-in', () => { + const normalized = normalizeSyncConfig({}); + + expect(normalized.includeSkills).toBe(false); + expect(normalized.includePromptHistory).toBe(false); + expect(normalized.includePromptStash).toBe(false); + expect(normalized.includeModelSelectors).toBe(false); + }); + + it('allows acknowledged plaintext prompt snapshots', () => { + const normalized = normalizeSyncConfig({ + includePromptHistory: true, + includePromptStash: true, + acknowledgePlaintextPromptRisk: true, + }); + + expect(normalized.includePromptHistory).toBe(true); + expect(normalized.includePromptStash).toBe(true); + expect(normalized.acknowledgePlaintextPromptRisk).toBe(true); + }); + + it('rejects prompt sync without plaintext risk acknowledgement', () => { + expect(() => + normalizeSyncConfig({ + includePromptHistory: true, + }) + ).toThrow('acknowledgePlaintextPromptRisk'); + }); + + it.each([ + { includeSecrets: true }, + { includeMcpSecrets: true }, + { includeSessions: true }, + { extraSecretPaths: ['/tmp/secret'] }, + { extraConfigPaths: ['/tmp/config'] }, + { localRepoPath: '/tmp/repo' }, + ])('rejects unsupported dangerous scope: %j', (input) => { + expect(() => normalizeSyncConfig(input)).toThrow('not supported by this fork'); + }); }); describe('canCommitMcpSecrets', () => { - it('requires includeSecrets and includeMcpSecrets', () => { + it('never allows MCP secrets in the hardened fork', () => { expect(canCommitMcpSecrets({ includeSecrets: false, includeMcpSecrets: true })).toBe(false); expect(canCommitMcpSecrets({ includeSecrets: true, includeMcpSecrets: false })).toBe(false); - expect(canCommitMcpSecrets({ includeSecrets: true, includeMcpSecrets: true })).toBe(true); + expect(canCommitMcpSecrets({ includeSecrets: true, includeMcpSecrets: true })).toBe(false); }); }); @@ -124,3 +171,56 @@ describe('chmodIfExists', () => { } }); }); + +describe('secure local files', () => { + it('writes sync config with owner-only permissions', async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'opencode-sync-')); + try { + const locations = resolveSyncLocations({ HOME: tempDir } as NodeJS.ProcessEnv, 'linux'); + await writeSyncConfig(locations, { + repo: { owner: 'me', name: 'config' }, + includeSkills: true, + }); + + expect((await lstat(locations.syncConfigPath)).mode & 0o777).toBe(0o600); + expect((await lstat(path.dirname(locations.syncConfigPath))).mode & 0o777).toBe(0o700); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('merges state updates instead of erasing prior timestamps', async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'opencode-sync-')); + try { + const locations = resolveSyncLocations({ HOME: tempDir } as NodeJS.ProcessEnv, 'linux'); + await writeState(locations, { lastPull: 'pull-time' }); + await writeState(locations, { lastPush: 'push-time' }); + + expect(await loadState(locations)).toMatchObject({ + lastPull: 'pull-time', + lastPush: 'push-time', + }); + expect((await lstat(locations.statePath)).mode & 0o777).toBe(0o600); + expect((await lstat(path.dirname(locations.statePath))).mode & 0o777).toBe(0o700); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('refuses to replace a symlink with generated JSON', async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'opencode-sync-')); + try { + const outside = path.join(tempDir, 'outside.json'); + const targetDir = path.join(tempDir, 'config'); + const target = path.join(targetDir, 'state.json'); + await mkdir(targetDir, { recursive: true }); + await writeFile(outside, '{"keep":true}\n'); + await symlink(outside, target); + + await expect(writeJsonFile(target, { replace: true })).rejects.toThrow('symlink'); + expect(await readFile(outside, 'utf8')).toBe('{"keep":true}\n'); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/sync/config.ts b/src/sync/config.ts index cdc6b2d..1f80798 100644 --- a/src/sync/config.ts +++ b/src/sync/config.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { promises as fs } from 'node:fs'; import path from 'node:path'; @@ -16,16 +17,27 @@ export interface SyncConfig { includeSecrets?: boolean; includeMcpSecrets?: boolean; includeSessions?: boolean; + includeSkills?: boolean; + includePromptHistory?: boolean; includePromptStash?: boolean; includeModelFavorites?: boolean; + includeModelSelectors?: boolean; + acknowledgePlaintextPromptRisk?: boolean; extraSecretPaths?: string[]; extraConfigPaths?: string[]; } export interface SyncState { + lastAttempt?: string; + lastFetch?: string; + lastNoop?: string; + lastApplied?: string; + lastCommit?: string; lastPull?: string; lastPush?: string; lastRemoteUpdate?: string; + lastError?: string; + lastOutcome?: 'noop' | 'pulled' | 'pushed' | 'failed'; } export async function pathExists(filePath: string): Promise { @@ -48,23 +60,53 @@ export async function chmodIfExists(filePath: string, mode: number): Promise 0 || + (config.extraConfigPaths?.length ?? 0) > 0 || + Boolean(config.localRepoPath); + + if (unsupported) { + throw new Error( + 'Secrets, sessions, arbitrary extra paths, and localRepoPath are not supported by this fork.' + ); + } } export async function loadSyncConfig(locations: SyncLocations): Promise { @@ -78,9 +120,11 @@ export async function loadSyncConfig(locations: SyncLocations): Promise { - await fs.mkdir(path.dirname(locations.syncConfigPath), { recursive: true }); + const parent = path.dirname(locations.syncConfigPath); + await fs.mkdir(parent, { recursive: true, mode: 0o700 }); + await fs.chmod(parent, 0o700); const payload = normalizeSyncConfig(config); - await writeJsonFile(locations.syncConfigPath, payload, { jsonc: true }); + await writeJsonFile(locations.syncConfigPath, payload, { jsonc: true, mode: 0o600 }); } export async function loadOverrides( @@ -105,8 +149,11 @@ export async function loadState(locations: SyncLocations): Promise { } export async function writeState(locations: SyncLocations, state: SyncState): Promise { - await fs.mkdir(path.dirname(locations.statePath), { recursive: true }); - await writeJsonFile(locations.statePath, state, { jsonc: false }); + const parent = path.dirname(locations.statePath); + await fs.mkdir(parent, { recursive: true, mode: 0o700 }); + await fs.chmod(parent, 0o700); + const current = await loadState(locations); + await writeJsonFile(locations.statePath, { ...current, ...state }, { jsonc: false, mode: 0o600 }); } export function applyOverridesToRuntimeConfig( @@ -260,9 +307,32 @@ export async function writeJsonFile( ): Promise { const json = JSON.stringify(data, null, 2); const content = options.jsonc ? `// Generated by opencode-synced\n${json}\n` : `${json}\n`; - await fs.writeFile(filePath, content, 'utf8'); - if (options.mode !== undefined) { - await chmodIfExists(filePath, options.mode); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + + const current = await lstatOrNull(filePath); + if (current?.isSymbolicLink()) { + throw new Error(`Refusing to replace symlink: ${filePath}`); + } + if (current && !current.isFile()) throw new Error(`Expected regular file: ${filePath}`); + + const mode = options.mode ?? (current ? current.mode & 0o777 : 0o600); + const tempPath = `${filePath}.sync-tmp-${randomUUID()}`; + try { + await fs.writeFile(tempPath, content, { encoding: 'utf8', mode }); + await fs.chmod(tempPath, mode); + await fs.rename(tempPath, filePath); + } finally { + await fs.rm(tempPath, { force: true }); + } +} + +async function lstatOrNull(filePath: string) { + try { + return await fs.lstat(filePath); + } catch (error) { + const maybeErrno = error as NodeJS.ErrnoException; + if (maybeErrno.code === 'ENOENT') return null; + throw error; } } diff --git a/src/sync/mcp-secrets.test.ts b/src/sync/mcp-secrets.test.ts index 72215d0..2b0b36c 100644 --- a/src/sync/mcp-secrets.test.ts +++ b/src/sync/mcp-secrets.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { extractMcpSecrets } from './mcp-secrets.js'; +import { assertNoLiteralSecrets, extractMcpSecrets } from './mcp-secrets.js'; describe('extractMcpSecrets', () => { it('moves MCP header secrets into overrides and adds env placeholders', () => { @@ -58,6 +58,23 @@ describe('extractMcpSecrets', () => { expect(sanitizedConfig).toEqual(input); }); + it('leaves file placeholders intact and skips overrides', () => { + const input = { + mcp: { + context7: { + headers: { + CONTEXT7_API_KEY: '{file:~/.config/opencode/secrets/context7}', + }, + }, + }, + }; + + const { sanitizedConfig, secretOverrides } = extractMcpSecrets(input); + + expect(secretOverrides).toEqual({}); + expect(sanitizedConfig).toEqual(input); + }); + it('handles bearer authorization and oauth client secrets', () => { const input = { mcp: { @@ -127,3 +144,36 @@ describe('extractMcpSecrets', () => { }); }); }); + +describe('assertNoLiteralSecrets', () => { + it('allows environment and file references', () => { + expect(() => + assertNoLiteralSecrets({ + provider: { demo: { options: { apiKey: '{env:DEMO_API_KEY}' } } }, + mcp: { demo: { environment: { DEMO_TOKEN: '{file:/private/demo-token}' } } }, + }) + ).not.toThrow(); + }); + + it('rejects literal provider and MCP environment credentials without exposing values', () => { + expect(() => + assertNoLiteralSecrets({ + provider: { demo: { options: { apiKey: 'literal-provider-key' } } }, + }) + ).toThrow('provider.demo.options.apiKey'); + expect(() => + assertNoLiteralSecrets({ mcp: { demo: { environment: { DEMO_TOKEN: 'literal-token' } } } }) + ).toThrow('mcp.demo.environment.DEMO_TOKEN'); + }); + + it('rejects literal text surrounding an embedded reference', () => { + expect(() => + assertNoLiteralSecrets({ provider: { demo: { options: { apiKey: '{env:DECOY} leaked' } } } }) + ).toThrow('provider.demo.options.apiKey'); + expect(() => + assertNoLiteralSecrets({ + mcp: { demo: { headers: { Authorization: 'Bearer {env:TOKEN} leaked' } } }, + }) + ).toThrow('mcp.demo.headers.Authorization'); + }); +}); diff --git a/src/sync/mcp-secrets.ts b/src/sync/mcp-secrets.ts index 28bdf74..d729032 100644 --- a/src/sync/mcp-secrets.ts +++ b/src/sync/mcp-secrets.ts @@ -5,7 +5,9 @@ export interface McpSecretExtraction { secretOverrides: Record; } -const ENV_PLACEHOLDER_PATTERN = /\{env:[^}]+\}/i; +const SAFE_REFERENCE_PATTERN = /^(?:[A-Za-z][A-Za-z0-9+.-]*\s+)?\{(?:env|file):[^{}]+\}$/i; +const SENSITIVE_KEY_PATTERN = + /(?:api[_-]?key|token|access[_-]?token|secret|client[_-]?secret|password|authorization|cookie)$/i; export function extractMcpSecrets(config: Record): McpSecretExtraction { const sanitizedConfig = cloneConfig(config); @@ -46,7 +48,36 @@ export function extractMcpSecrets(config: Record): McpSecretExt } function isSecretString(value: unknown): value is string { - return typeof value === 'string' && value.length > 0 && !ENV_PLACEHOLDER_PATTERN.test(value); + return typeof value === 'string' && value.length > 0 && !SAFE_REFERENCE_PATTERN.test(value); +} + +export function assertNoLiteralSecrets(config: Record): void { + visitSecretCandidates(config, []); +} + +function visitSecretCandidates(value: unknown, pathTokens: string[]): void { + if (Array.isArray(value)) { + value.forEach((entry, index) => { + visitSecretCandidates(entry, [...pathTokens, String(index)]); + }); + return; + } + if (!isPlainObject(value)) return; + + for (const [key, entry] of Object.entries(value)) { + const nextPath = [...pathTokens, key]; + if ( + typeof entry === 'string' && + entry.length > 0 && + SENSITIVE_KEY_PATTERN.test(key) && + !SAFE_REFERENCE_PATTERN.test(entry) + ) { + throw new Error( + `Literal secret-like value must use an env/file reference: ${nextPath.join('.')}` + ); + } + visitSecretCandidates(entry, nextPath); + } } function buildHeaderEnvVar(serverName: string, headerName: string): string { diff --git a/src/sync/paths.test.ts b/src/sync/paths.test.ts index 06764f4..bd7de12 100644 --- a/src/sync/paths.test.ts +++ b/src/sync/paths.test.ts @@ -26,6 +26,16 @@ describe('resolveXdgPaths', () => { }); describe('resolveSyncLocations', () => { + it('respects the OpenCode uppercase config directory flag', () => { + const env = { + HOME: '/home/test', + OPENCODE_CONFIG_DIR: '/official/opencode', + } as NodeJS.ProcessEnv; + const locations = resolveSyncLocations(env, 'linux'); + + expect(locations.configRoot).toBe('/official/opencode'); + }); + it('respects opencode_config_dir', () => { const env = { HOME: '/home/test', @@ -40,25 +50,40 @@ describe('resolveSyncLocations', () => { }); describe('buildSyncPlan', () => { - it('excludes secrets when includeSecrets is false', () => { + it('tracks redirected XDG roots instead of assuming everything is under HOME', () => { + const env = { + HOME: '/home/test', + XDG_CONFIG_HOME: '/mnt/config', + XDG_STATE_HOME: '/mnt/state', + } as NodeJS.ProcessEnv; + const locations = resolveSyncLocations(env, 'linux'); + const plan = buildSyncPlan( + { repo: { owner: 'acme', name: 'config' }, includeModelFavorites: true }, + locations, + '/repo', + 'linux' + ); + + expect(plan.localRoots).toEqual(['/mnt/config/opencode', '/mnt/state/opencode']); + }); + + it('excludes secrets and arbitrary extra paths', () => { const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; const locations = resolveSyncLocations(env, 'linux'); const config: SyncConfig = { repo: { owner: 'acme', name: 'config' }, includeSecrets: false, - extraSecretPaths: ['/home/test/.ssh/id_rsa'], - extraConfigPaths: ['/home/test/.config/opencode/custom.json'], + extraSecretPaths: [], + extraConfigPaths: [], }; const plan = buildSyncPlan(config, locations, '/repo', 'linux'); const secretItems = plan.items.filter((item) => item.isSecret); expect(secretItems.length).toBe(0); - expect(plan.extraSecrets.allowlist.length).toBe(0); - expect(plan.extraConfigs.allowlist.length).toBe(1); }); - it('includes secrets when includeSecrets is true', () => { + it('rejects secrets and arbitrary extra paths', () => { const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; const locations = resolveSyncLocations(env, 'linux'); const config: SyncConfig = { @@ -68,12 +93,9 @@ describe('buildSyncPlan', () => { extraConfigPaths: ['/home/test/.config/opencode/custom.json'], }; - const plan = buildSyncPlan(config, locations, '/repo', 'linux'); - const secretItems = plan.items.filter((item) => item.isSecret); - - expect(secretItems.length).toBe(2); - expect(plan.extraSecrets.allowlist.length).toBe(1); - expect(plan.extraConfigs.allowlist.length).toBe(1); + expect(() => buildSyncPlan(config, locations, '/repo', 'linux')).toThrow( + 'not supported by this fork' + ); }); it('includes model favorites by default and allows disabling', () => { @@ -103,4 +125,98 @@ describe('buildSyncPlan', () => { expect(disabledItem).toBeUndefined(); }); + + it('adds skills only when explicitly enabled', () => { + const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; + const locations = resolveSyncLocations(env, 'linux'); + const base: SyncConfig = { + repo: { owner: 'acme', name: 'config' }, + includeSecrets: false, + }; + + const disabled = buildSyncPlan(base, locations, '/repo', 'linux'); + expect(disabled.items.some((item) => item.localPath.endsWith('/skills'))).toBe(false); + + const enabled = buildSyncPlan({ ...base, includeSkills: true }, locations, '/repo', 'linux'); + const skills = enabled.items.find((item) => item.localPath.endsWith('/skills')); + + expect(skills).toMatchObject({ + repoPath: '/repo/config/skills', + type: 'dir', + strategy: 'skills', + }); + }); + + it('adds prompt snapshots independently of auth secrets', () => { + const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; + const locations = resolveSyncLocations(env, 'linux'); + const config: SyncConfig = { + repo: { owner: 'acme', name: 'config' }, + includeSecrets: false, + includePromptHistory: true, + includePromptStash: true, + acknowledgePlaintextPromptRisk: true, + }; + + const plan = buildSyncPlan(config, locations, '/repo', 'linux'); + const promptItems = plan.items.filter((item) => item.strategy === 'prompt-snapshot'); + + expect(promptItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + localPath: '/home/test/.local/state/opencode/prompt-history.jsonl', + repoPath: '/repo/state/prompts/prompt-history.jsonl', + isSecret: true, + }), + expect.objectContaining({ + localPath: '/home/test/.local/state/opencode/prompt-stash.jsonl', + repoPath: '/repo/state/prompts/prompt-stash.jsonl', + isSecret: true, + }), + ]) + ); + + expect(plan.items.some((item) => item.localPath.endsWith('/auth.json'))).toBe(false); + expect(plan.items.some((item) => item.localPath.endsWith('/mcp-auth.json'))).toBe(false); + }); + + it('adds portable model selector files when enabled', () => { + const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; + const locations = resolveSyncLocations(env, 'linux'); + const config: SyncConfig = { + repo: { owner: 'acme', name: 'config' }, + includeModelSelectors: true, + }; + + const plan = buildSyncPlan(config, locations, '/repo', 'linux'); + const selectors = plan.items.filter((item) => item.strategy === 'model-selector'); + + expect(selectors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + localPath: '/home/test/.config/opencode/main-model.txt', + repoPath: '/repo/state/model-selectors/main-model.txt', + }), + expect.objectContaining({ + localPath: '/home/test/.config/opencode/cheap-model.txt', + repoPath: '/repo/state/model-selectors/cheap-model.txt', + }), + ]) + ); + }); + + it('marks model favorites for projection instead of raw file replacement', () => { + const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; + const locations = resolveSyncLocations(env, 'linux'); + const plan = buildSyncPlan( + { repo: { owner: 'acme', name: 'config' }, includeModelFavorites: true }, + locations, + '/repo', + 'linux' + ); + + const favorites = plan.items.find((item) => item.localPath.endsWith('/model.json')); + expect(favorites?.strategy).toBe('model-favorites'); + expect(favorites?.repoPath).toBe('/repo/state/model-favorites.json'); + }); }); diff --git a/src/sync/paths.ts b/src/sync/paths.ts index 6440394..a9e5dea 100644 --- a/src/sync/paths.ts +++ b/src/sync/paths.ts @@ -1,7 +1,6 @@ -import crypto from 'node:crypto'; import path from 'node:path'; -import type { SyncConfig } from './config.js'; +import { assertSupportedSyncScope, type SyncConfig } from './config.js'; export interface XdgPaths { homeDir: string; @@ -20,6 +19,12 @@ export interface SyncLocations { } export type SyncItemType = 'file' | 'dir'; +export type SyncItemStrategy = + | 'copy' + | 'skills' + | 'prompt-snapshot' + | 'model-favorites' + | 'model-selector'; export interface SyncItem { localPath: string; @@ -27,20 +32,14 @@ export interface SyncItem { type: SyncItemType; isSecret: boolean; isConfigFile: boolean; -} - -export interface ExtraPathPlan { - allowlist: string[]; - manifestPath: string; - entries: Array<{ sourcePath: string; repoPath: string }>; + strategy?: SyncItemStrategy; } export interface SyncPlan { items: SyncItem[]; - extraSecrets: ExtraPathPlan; - extraConfigs: ExtraPathPlan; repoRoot: string; homeDir: string; + localRoots?: string[]; platform: NodeJS.Platform; } @@ -52,9 +51,10 @@ const DEFAULT_OVERRIDES_NAME = 'opencode-synced.overrides.jsonc'; const DEFAULT_STATE_NAME = 'sync-state.json'; const CONFIG_DIRS = ['agent', 'command', 'mode', 'tool', 'themes', 'plugin']; -const SESSION_DIRS = ['storage/session', 'storage/message', 'storage/part', 'storage/session_diff']; -const PROMPT_STASH_FILES = ['prompt-stash.jsonl', 'prompt-history.jsonl']; const MODEL_FAVORITES_FILE = 'model.json'; +const PROMPT_HISTORY_FILE = 'prompt-history.jsonl'; +const PROMPT_STASH_FILE = 'prompt-stash.jsonl'; +const MODEL_SELECTOR_FILES = ['main-model.txt', 'cheap-model.txt']; export function resolveHomeDir( env: NodeJS.ProcessEnv = process.env, @@ -102,7 +102,7 @@ export function resolveSyncLocations( platform: NodeJS.Platform = process.platform ): SyncLocations { const xdg = resolveXdgPaths(env, platform); - const customConfigDir = env.opencode_config_dir; + const customConfigDir = env.OPENCODE_CONFIG_DIR ?? env.opencode_config_dir; const configRoot = customConfigDir ? path.resolve(expandHome(customConfigDir, xdg.homeDir)) : path.join(xdg.configDir, 'opencode'); @@ -148,16 +148,6 @@ export function isSamePath( return normalizePath(left, homeDir, platform) === normalizePath(right, homeDir, platform); } -export function encodeExtraPath(inputPath: string): string { - const normalized = inputPath.replace(/\\/g, '/'); - const safeBase = normalized.replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+/, ''); - const hash = crypto.createHash('sha1').update(normalized).digest('hex').slice(0, 8); - const base = safeBase ? safeBase.slice(-80) : 'path'; - return `${base}-${hash}`; -} - -export const encodeSecretPath = encodeExtraPath; - export function resolveRepoRoot(config: SyncConfig | null, locations: SyncLocations): string { if (config?.localRepoPath) { return expandHome(config.localRepoPath, locations.xdg.homeDir); @@ -172,17 +162,12 @@ export function buildSyncPlan( repoRoot: string, platform: NodeJS.Platform = process.platform ): SyncPlan { + assertSupportedSyncScope(config); + const configRoot = locations.configRoot; - const dataRoot = path.join(locations.xdg.dataDir, 'opencode'); const stateRoot = path.join(locations.xdg.stateDir, 'opencode'); const repoConfigRoot = path.join(repoRoot, 'config'); - const repoDataRoot = path.join(repoRoot, 'data'); - const repoSecretsRoot = path.join(repoRoot, 'secrets'); const repoStateRoot = path.join(repoRoot, 'state'); - const repoExtraDir = path.join(repoSecretsRoot, 'extra'); - const manifestPath = path.join(repoSecretsRoot, 'extra-manifest.json'); - const repoConfigExtraDir = path.join(repoConfigRoot, 'extra'); - const configManifestPath = path.join(repoConfigRoot, 'extra-manifest.json'); const items: SyncItem[] = []; @@ -210,104 +195,68 @@ export function buildSyncPlan( }); } + if (config.includeSkills) { + items.push({ + localPath: path.join(configRoot, 'skills'), + repoPath: path.join(repoConfigRoot, 'skills'), + type: 'dir', + isSecret: false, + isConfigFile: false, + strategy: 'skills', + }); + } + if (config.includeModelFavorites !== false) { items.push({ localPath: path.join(stateRoot, MODEL_FAVORITES_FILE), - repoPath: path.join(repoStateRoot, MODEL_FAVORITES_FILE), + repoPath: path.join(repoStateRoot, 'model-favorites.json'), type: 'file', isSecret: false, isConfigFile: false, + strategy: 'model-favorites', }); } - if (config.includeSecrets) { - items.push( - { - localPath: path.join(dataRoot, 'auth.json'), - repoPath: path.join(repoDataRoot, 'auth.json'), - type: 'file', - isSecret: true, - isConfigFile: false, - }, - { - localPath: path.join(dataRoot, 'mcp-auth.json'), - repoPath: path.join(repoDataRoot, 'mcp-auth.json'), + if (config.includePromptHistory) { + items.push({ + localPath: path.join(stateRoot, PROMPT_HISTORY_FILE), + repoPath: path.join(repoStateRoot, 'prompts', PROMPT_HISTORY_FILE), + type: 'file', + isSecret: true, + isConfigFile: false, + strategy: 'prompt-snapshot', + }); + } + + if (config.includePromptStash) { + items.push({ + localPath: path.join(stateRoot, PROMPT_STASH_FILE), + repoPath: path.join(repoStateRoot, 'prompts', PROMPT_STASH_FILE), + type: 'file', + isSecret: true, + isConfigFile: false, + strategy: 'prompt-snapshot', + }); + } + + if (config.includeModelSelectors) { + for (const fileName of MODEL_SELECTOR_FILES) { + items.push({ + localPath: path.join(configRoot, fileName), + repoPath: path.join(repoStateRoot, 'model-selectors', fileName), type: 'file', - isSecret: true, + isSecret: false, isConfigFile: false, - } - ); - - if (config.includeSessions) { - for (const dirName of SESSION_DIRS) { - items.push({ - localPath: path.join(dataRoot, dirName), - repoPath: path.join(repoDataRoot, dirName), - type: 'dir', - isSecret: true, - isConfigFile: false, - }); - } - } - - if (config.includePromptStash) { - for (const fileName of PROMPT_STASH_FILES) { - items.push({ - localPath: path.join(stateRoot, fileName), - repoPath: path.join(repoStateRoot, fileName), - type: 'file', - isSecret: true, - isConfigFile: false, - }); - } + strategy: 'model-selector', + }); } } - const extraSecrets = buildExtraPathPlan( - config.includeSecrets ? config.extraSecretPaths : [], - locations, - repoExtraDir, - manifestPath, - platform - ); - - const extraConfigs = buildExtraPathPlan( - config.extraConfigPaths, - locations, - repoConfigExtraDir, - configManifestPath, - platform - ); - return { items, - extraSecrets, - extraConfigs, repoRoot, homeDir: locations.xdg.homeDir, + localRoots: [configRoot, stateRoot], platform, }; } - -function buildExtraPathPlan( - inputPaths: string[] | undefined, - locations: SyncLocations, - repoExtraDir: string, - manifestPath: string, - platform: NodeJS.Platform -): ExtraPathPlan { - const allowlist = (inputPaths ?? []).map((entry) => - normalizePath(entry, locations.xdg.homeDir, platform) - ); - - const entries = allowlist.map((sourcePath) => ({ - sourcePath, - repoPath: path.join(repoExtraDir, encodeExtraPath(sourcePath)), - })); - - return { - allowlist, - manifestPath, - entries, - }; -} diff --git a/src/sync/reconcile.test.ts b/src/sync/reconcile.test.ts new file mode 100644 index 0000000..1f62ef7 --- /dev/null +++ b/src/sync/reconcile.test.ts @@ -0,0 +1,197 @@ +import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import type { SyncItem, SyncPlan } from './paths.js'; +import { applyLocalProjection, createLocalProjection } from './reconcile.js'; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function setup(): Promise<{ + root: string; + home: string; + repo: string; + stage: string; + rollback: string; + plan: SyncPlan; +}> { + const root = await mkdtemp(path.join(os.tmpdir(), 'opencode-synced-reconcile-')); + tempDirs.push(root); + const home = path.join(root, 'home'); + const repo = path.join(root, 'repo'); + const stage = path.join(root, 'stage'); + const rollback = path.join(root, 'rollback'); + await mkdir(path.join(home, 'config'), { recursive: true }); + await mkdir(path.join(repo, 'config'), { recursive: true }); + const items: SyncItem[] = ['a.txt', 'b.txt'].map((name) => ({ + localPath: path.join(home, 'config', name), + repoPath: path.join(repo, 'config', name), + type: 'file', + isSecret: false, + isConfigFile: false, + })); + return { + root, + home, + repo, + stage, + rollback, + plan: { + items, + repoRoot: repo, + homeDir: home, + platform: 'linux', + }, + }; +} + +describe('local projection reconciliation', () => { + it('keeps unrelated remote changes while applying changed local items', async () => { + const { home, repo, stage, rollback, plan } = await setup(); + await writeFile(path.join(home, 'config', 'a.txt'), 'local-a'); + await writeFile(path.join(home, 'config', 'b.txt'), 'base-b'); + await writeFile(path.join(repo, 'config', 'a.txt'), 'base-a'); + await writeFile(path.join(repo, 'config', 'b.txt'), 'base-b'); + + const projection = await createLocalProjection(plan, null, stage); + expect(projection.changedItemIndexes).toEqual([0]); + + await writeFile(path.join(repo, 'config', 'b.txt'), 'remote-b'); + await applyLocalProjection(plan, projection, rollback); + + expect(await readFile(path.join(repo, 'config', 'a.txt'), 'utf8')).toBe('local-a'); + expect(await readFile(path.join(repo, 'config', 'b.txt'), 'utf8')).toBe('remote-b'); + }); + + it('lets the current local sync win and backs up a concurrent remote version', async () => { + const { home, repo, stage, rollback, plan } = await setup(); + await writeFile(path.join(home, 'config', 'a.txt'), 'local-a'); + await writeFile(path.join(home, 'config', 'b.txt'), 'base-b'); + await writeFile(path.join(repo, 'config', 'a.txt'), 'base-a'); + await writeFile(path.join(repo, 'config', 'b.txt'), 'base-b'); + + const projection = await createLocalProjection(plan, null, stage); + await writeFile(path.join(repo, 'config', 'a.txt'), 'remote-a'); + await applyLocalProjection(plan, projection, rollback); + + expect(await readFile(path.join(repo, 'config', 'a.txt'), 'utf8')).toBe('local-a'); + expect(await readFile(path.join(rollback, 'config', 'a.txt'), 'utf8')).toBe('remote-a'); + }); + + it('propagates a local deletion while retaining the displaced remote file', async () => { + const { home, repo, stage, rollback, plan } = await setup(); + await writeFile(path.join(home, 'config', 'b.txt'), 'base-b'); + await writeFile(path.join(repo, 'config', 'a.txt'), 'base-a'); + await writeFile(path.join(repo, 'config', 'b.txt'), 'base-b'); + + const projection = await createLocalProjection(plan, null, stage); + expect(projection.changedItemIndexes).toEqual([0]); + await applyLocalProjection(plan, projection, rollback); + + await expect(readFile(path.join(repo, 'config', 'a.txt'), 'utf8')).rejects.toMatchObject({ + code: 'ENOENT', + }); + expect(await readFile(path.join(rollback, 'config', 'a.txt'), 'utf8')).toBe('base-a'); + }); + + it('restores earlier items when a later projection replacement fails', async () => { + const { root, repo, stage, rollback, plan } = await setup(); + await writeFile(path.join(repo, 'config', 'a.txt'), 'base-a'); + await writeFile(path.join(repo, 'config', 'b.txt'), 'base-b'); + await mkdir(path.join(stage, 'config'), { recursive: true }); + await writeFile(path.join(stage, 'config', 'a.txt'), 'local-a'); + const outside = path.join(root, 'outside.txt'); + await writeFile(outside, 'outside'); + await symlink(outside, path.join(stage, 'config', 'b.txt')); + const projectionPlan: SyncPlan = { + ...plan, + repoRoot: stage, + items: plan.items.map((item) => ({ + ...item, + repoPath: path.join(stage, 'config', path.basename(item.repoPath)), + })), + }; + + await expect( + applyLocalProjection(plan, { plan: projectionPlan, changedItemIndexes: [0, 1] }, rollback) + ).rejects.toThrow('symlink'); + + expect(await readFile(path.join(repo, 'config', 'a.txt'), 'utf8')).toBe('base-a'); + expect(await readFile(path.join(repo, 'config', 'b.txt'), 'utf8')).toBe('base-b'); + }); + + it('persists newly extracted MCP secrets while creating the projection', async () => { + const { home, repo, stage, plan } = await setup(); + const configPath = path.join(home, 'config', 'opencode.json'); + const repoConfigPath = path.join(repo, 'config', 'opencode.json'); + const overridesPath = path.join(home, 'config', 'opencode-synced.overrides.jsonc'); + plan.items = [ + { + localPath: configPath, + repoPath: repoConfigPath, + type: 'file', + isSecret: false, + isConfigFile: true, + }, + ]; + await writeFile( + configPath, + JSON.stringify({ mcp: { demo: { headers: { Authorization: 'Bearer local-secret' } } } }) + ); + await writeFile(repoConfigPath, '{}\n'); + + const projection = await createLocalProjection(plan, null, stage, { overridesPath }); + + expect(projection.changedItemIndexes).toEqual([0]); + expect(await readFile(overridesPath, 'utf8')).toContain('Bearer local-secret'); + expect(await readFile(projection.plan.items[0].repoPath, 'utf8')).not.toContain( + 'Bearer local-secret' + ); + }); + + it('does not treat unchanged override keys as shared config deletions', async () => { + const { home, repo, stage, plan } = await setup(); + const configPath = path.join(home, 'config', 'opencode.json'); + const repoConfigPath = path.join(repo, 'config', 'opencode.json'); + plan.items = [ + { + localPath: configPath, + repoPath: repoConfigPath, + type: 'file', + isSecret: false, + isConfigFile: true, + }, + ]; + await writeFile(configPath, '{"shared":"base","theme":"local-only"}\n'); + await writeFile(repoConfigPath, '{"shared":"base","theme":"remote-base"}\n'); + + const projection = await createLocalProjection(plan, { theme: 'local-only' }, stage); + + expect(projection.changedItemIndexes).toEqual([]); + expect(JSON.parse(await readFile(projection.plan.items[0].repoPath, 'utf8'))).toEqual({ + shared: 'base', + theme: 'remote-base', + }); + }); + + it('ignores non-executable permission differences that Git cannot represent', async () => { + const { home, repo, stage, plan } = await setup(); + const local = path.join(home, 'config', 'a.txt'); + const tracked = path.join(repo, 'config', 'a.txt'); + await writeFile(local, 'same'); + await writeFile(tracked, 'same'); + await chmod(local, 0o600); + await chmod(tracked, 0o644); + plan.items = [plan.items[0]]; + + const projection = await createLocalProjection(plan, null, stage); + + expect(projection.changedItemIndexes).toEqual([]); + }); +}); diff --git a/src/sync/reconcile.ts b/src/sync/reconcile.ts new file mode 100644 index 0000000..6283073 --- /dev/null +++ b/src/sync/reconcile.ts @@ -0,0 +1,264 @@ +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { syncLocalToRepo, syncRepoToLocal } from './apply.js'; +import { parseJsonc } from './config.js'; +import type { SyncItem, SyncPlan } from './paths.js'; + +export interface LocalProjection { + plan: SyncPlan; + changedItemIndexes: number[]; +} + +export async function createLocalProjection( + plan: SyncPlan, + overrides: Record | null, + projectionRoot: string, + options: { overridesPath?: string } = {} +): Promise { + await fs.rm(projectionRoot, { recursive: true, force: true }); + await fs.mkdir(projectionRoot, { recursive: true, mode: 0o700 }); + const projectionPlan = remapRepoRoot(plan, projectionRoot); + await seedProjectionFromRepo(plan, projectionPlan); + await syncLocalToRepo(projectionPlan, overrides, { + overridesPath: options.overridesPath, + allowMcpSecrets: false, + }); + + const changedItemIndexes: number[] = []; + for (let index = 0; index < plan.items.length; index += 1) { + const localDigest = await digestManagedItem( + projectionPlan.items[index], + projectionPlan.items[index].repoPath + ); + const baseDigest = await digestManagedItem(plan.items[index], plan.items[index].repoPath); + if (localDigest !== baseDigest) changedItemIndexes.push(index); + } + + return { plan: projectionPlan, changedItemIndexes }; +} + +async function seedProjectionFromRepo( + sourcePlan: SyncPlan, + projectionPlan: SyncPlan +): Promise { + const seedItems = sourcePlan.items.map( + (item, index): SyncItem => ({ + ...item, + localPath: projectionPlan.items[index].repoPath, + isConfigFile: false, + strategy: 'copy', + }) + ); + await syncRepoToLocal( + { + ...sourcePlan, + homeDir: projectionPlan.repoRoot, + localRoots: [projectionPlan.repoRoot], + items: seedItems, + }, + null + ); +} + +export async function applyLocalProjection( + targetPlan: SyncPlan, + projection: LocalProjection, + rollbackRoot: string +): Promise { + await fs.rm(rollbackRoot, { recursive: true, force: true }); + await fs.mkdir(rollbackRoot, { recursive: true, mode: 0o700 }); + + const replacements: Array<{ index: number; targetItem: SyncItem; projectedItem: SyncItem }> = []; + for (const index of projection.changedItemIndexes) { + const targetItem = targetPlan.items[index]; + const projectedItem = projection.plan.items[index]; + if (!targetItem || !projectedItem) throw new Error(`Invalid projected item index: ${index}`); + const relativePath = relativeRepoPath(targetPlan.repoRoot, targetItem.repoPath); + const rollbackPath = path.join(rollbackRoot, relativePath); + + await backupRepoItem(targetPlan, targetItem, rollbackRoot, rollbackPath); + replacements.push({ index, targetItem, projectedItem }); + } + + const applied: typeof replacements = []; + try { + for (const replacement of replacements) { + await replaceRepoItem( + targetPlan, + projection.plan, + replacement.targetItem, + replacement.projectedItem + ); + applied.push(replacement); + } + } catch (error) { + const rollbackPlan = remapRepoRoot(targetPlan, rollbackRoot); + const restoreErrors: unknown[] = []; + for (const replacement of applied.reverse()) { + try { + await replaceRepoItem( + targetPlan, + rollbackPlan, + replacement.targetItem, + rollbackPlan.items[replacement.index] + ); + } catch (restoreError) { + restoreErrors.push(restoreError); + } + } + await makePrivateRecursive(rollbackRoot); + if (restoreErrors.length > 0) { + throw new AggregateError( + [error, ...restoreErrors], + `Projection failed and rollback data was retained at ${rollbackRoot}` + ); + } + throw error; + } + + await makePrivateRecursive(rollbackRoot); +} + +function remapRepoRoot(plan: SyncPlan, nextRepoRoot: string): SyncPlan { + const remapItem = (item: SyncItem): SyncItem => ({ + ...item, + repoPath: path.join(nextRepoRoot, relativeRepoPath(plan.repoRoot, item.repoPath)), + }); + return { + ...plan, + repoRoot: nextRepoRoot, + items: plan.items.map(remapItem), + }; +} + +async function backupRepoItem( + plan: SyncPlan, + item: SyncItem, + rollbackRoot: string, + rollbackPath: string +): Promise { + const rawItem: SyncItem = { + ...item, + localPath: rollbackPath, + isConfigFile: false, + strategy: 'copy', + }; + await syncRepoToLocal( + { + ...plan, + homeDir: rollbackRoot, + localRoots: [rollbackRoot], + items: [rawItem], + }, + null + ); +} + +async function replaceRepoItem( + targetPlan: SyncPlan, + projectionPlan: SyncPlan, + targetItem: SyncItem, + projectedItem: SyncItem +): Promise { + const rawItem: SyncItem = { + ...targetItem, + localPath: projectedItem.repoPath, + isConfigFile: false, + strategy: 'copy', + }; + await syncLocalToRepo( + { + ...targetPlan, + homeDir: projectionPlan.repoRoot, + localRoots: [projectionPlan.repoRoot], + items: [rawItem], + }, + null + ); +} + +function relativeRepoPath(repoRoot: string, itemPath: string): string { + const relative = path.relative(path.resolve(repoRoot), path.resolve(itemPath)); + if ( + !relative || + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new Error(`Managed item is outside repository root: ${itemPath}`); + } + return relative; +} + +async function digestPath(targetPath: string): Promise { + const stat = await lstatOrNull(targetPath); + if (!stat) return 'missing'; + if (stat.isSymbolicLink()) throw new Error(`Refusing to digest symlink: ${targetPath}`); + const hash = createHash('sha256'); + if (stat.isFile()) { + hash.update('file\0'); + hash.update(stat.mode & 0o111 ? 'executable\0' : 'regular\0'); + hash.update(await fs.readFile(targetPath)); + return hash.digest('hex'); + } + if (!stat.isDirectory()) throw new Error(`Unsupported managed path type: ${targetPath}`); + hash.update('dir\0'); + const entries = await fs.readdir(targetPath, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + hash.update(entry.name); + hash.update('\0'); + hash.update(await digestPath(path.join(targetPath, entry.name))); + } + return hash.digest('hex'); +} + +async function digestManagedItem(item: SyncItem, targetPath: string): Promise { + const stat = await lstatOrNull(targetPath); + if (!stat) return 'missing'; + if (stat.isSymbolicLink()) throw new Error(`Refusing to digest symlink: ${targetPath}`); + if (item.isConfigFile || item.strategy === 'model-favorites') { + if (!stat.isFile()) throw new Error(`Expected JSON file: ${targetPath}`); + const parsed = parseJsonc(await fs.readFile(targetPath, 'utf8')); + return createHash('sha256').update(stableJson(parsed)).digest('hex'); + } + return digestPath(targetPath); +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; + if (value && typeof value === 'object') { + const entries = Object.entries(value as Record).sort(([left], [right]) => + left.localeCompare(right) + ); + return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`).join(',')}}`; + } + return JSON.stringify(value) ?? 'null'; +} + +async function makePrivateRecursive(targetPath: string): Promise { + const stat = await lstatOrNull(targetPath); + if (!stat) return; + if (stat.isSymbolicLink()) throw new Error(`Refusing to chmod symlink: ${targetPath}`); + if (stat.isFile()) { + await fs.chmod(targetPath, 0o600); + return; + } + if (!stat.isDirectory()) throw new Error(`Unsupported rollback path type: ${targetPath}`); + await fs.chmod(targetPath, 0o700); + for (const entry of await fs.readdir(targetPath)) { + await makePrivateRecursive(path.join(targetPath, entry)); + } +} + +async function lstatOrNull(targetPath: string) { + try { + return await fs.lstat(targetPath); + } catch (error) { + const maybeErrno = error as NodeJS.ErrnoException; + if (maybeErrno.code === 'ENOENT') return null; + throw error; + } +} diff --git a/src/sync/repo.test.ts b/src/sync/repo.test.ts index 9efee54..270bd23 100644 --- a/src/sync/repo.test.ts +++ b/src/sync/repo.test.ts @@ -1,6 +1,38 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + import { describe, expect, it } from 'vitest'; -import { parseRepoVisibility } from './repo.js'; +import { + assertRestrictedRepoLayout, + deriveGitIdentity, + normalizeRepoRemote, + parseAheadBehind, + parseRepoVisibility, + shouldPushBranch, +} from './repo.js'; + +describe('normalizeRepoRemote', () => { + it('matches GitHub owner/name, HTTPS, and SSH forms', () => { + expect(normalizeRepoRemote('Owner/Repo')).toBe('github.com/owner/repo'); + expect(normalizeRepoRemote('https://github.com/Owner/Repo.git')).toBe('github.com/owner/repo'); + expect(normalizeRepoRemote('git@github.com:Owner/Repo.git')).toBe('github.com/owner/repo'); + }); + + it('normalizes local repository paths', () => { + expect(normalizeRepoRemote('/tmp/example.git')).toBe('local:/tmp/example.git'); + }); + + it('does not equate alternate protocols or ports with canonical GitHub', () => { + expect(normalizeRepoRemote('http://github.com/Owner/Repo.git')).not.toBe( + normalizeRepoRemote('Owner/Repo') + ); + expect(normalizeRepoRemote('https://github.com:8443/Owner/Repo.git')).not.toBe( + normalizeRepoRemote('Owner/Repo') + ); + }); +}); describe('parseRepoVisibility', () => { it('parses private status', () => { @@ -12,3 +44,71 @@ describe('parseRepoVisibility', () => { expect(() => parseRepoVisibility('{"private": true}')).toThrow(); }); }); + +describe('parseAheadBehind', () => { + it('parses git rev-list counts', () => { + expect(parseAheadBehind('2\t3\n')).toEqual({ ahead: 2, behind: 3 }); + }); + + it('rejects malformed counts instead of pretending the repo is current', () => { + expect(() => parseAheadBehind('not-a-count')).toThrow('Invalid ahead/behind response'); + }); +}); + +describe('deriveGitIdentity', () => { + it('uses the account name and GitHub noreply address', () => { + expect(deriveGitIdentity({ login: 'octocat', id: 123, name: 'The Octocat' })).toEqual({ + name: 'The Octocat', + email: '123+octocat@users.noreply.github.com', + }); + }); + + it('falls back to login when the account has no display name', () => { + expect(deriveGitIdentity({ login: 'octocat', id: 123, name: null })).toEqual({ + name: 'octocat', + email: '123+octocat@users.noreply.github.com', + }); + }); +}); + +describe('shouldPushBranch', () => { + it('pushes an initial branch and a clean branch that is ahead', () => { + expect(shouldPushBranch(false, 0)).toBe(true); + expect(shouldPushBranch(true, 1)).toBe(true); + }); + + it('does not push a branch already equal to its remote', () => { + expect(shouldPushBranch(true, 0)).toBe(false); + }); + + it('does not recreate a previously tracked branch deleted from the remote', () => { + expect(shouldPushBranch(false, 0, true)).toBe(false); + }); +}); + +describe('assertRestrictedRepoLayout', () => { + it('rejects legacy sensitive or local-only tracked paths', async () => { + const repo = await mkdtemp(path.join(os.tmpdir(), 'opencode-synced-layout-')); + try { + await mkdir(path.join(repo, 'data'), { recursive: true }); + await writeFile(path.join(repo, 'data', 'auth.json'), '{}\n'); + + await expect(assertRestrictedRepoLayout(repo)).rejects.toThrow('data'); + } finally { + await rm(repo, { recursive: true, force: true }); + } + }); + + it('accepts the narrow portable layout', async () => { + const repo = await mkdtemp(path.join(os.tmpdir(), 'opencode-synced-layout-')); + try { + await mkdir(path.join(repo, 'config', 'skills'), { recursive: true }); + await mkdir(path.join(repo, 'state', 'prompts'), { recursive: true }); + await writeFile(path.join(repo, 'state', 'model-favorites.json'), '{"favorite":[]}\n'); + + await expect(assertRestrictedRepoLayout(repo)).resolves.toBeUndefined(); + } finally { + await rm(repo, { recursive: true, force: true }); + } + }); +}); diff --git a/src/sync/repo.ts b/src/sync/repo.ts index 86c71d2..114e1fe 100644 --- a/src/sync/repo.ts +++ b/src/sync/repo.ts @@ -21,6 +21,17 @@ export interface RepoUpdateResult { branch: string; } +export interface GitHubUserIdentity { + login: string; + id: number; + name?: string | null; +} + +export interface GitIdentity { + name: string; + email: string; +} + type Shell = PluginInput['$']; export async function isRepoCloned(repoDir: string): Promise { @@ -28,6 +39,36 @@ export async function isRepoCloned(repoDir: string): Promise { return pathExists(gitDir); } +const RESTRICTED_REPO_PATHS = [ + 'data', + 'secrets', + 'config/opencode-synced.jsonc', + 'config/opencode-synced.overrides.jsonc', + 'config/extra', + 'config/extra-manifest.json', + 'state/model.json', + 'state/prompt-history.jsonl', + 'state/prompt-stash.jsonl', +]; + +export async function assertRestrictedRepoLayout(repoDir: string): Promise { + const found: string[] = []; + for (const relativePath of RESTRICTED_REPO_PATHS) { + try { + await fs.lstat(path.join(repoDir, relativePath)); + found.push(relativePath); + } catch (error) { + const maybeErrno = error as NodeJS.ErrnoException; + if (maybeErrno.code !== 'ENOENT') throw error; + } + } + if (found.length > 0) { + throw new SyncCommandError( + `Repository contains paths forbidden by this fork: ${found.join(', ')}. Migrate or remove them before syncing.` + ); + } +} + export function resolveRepoIdentifier(config: SyncConfig): string { const repo = config.repo; if (!repo) { @@ -52,6 +93,7 @@ export async function ensureRepoCloned( repoDir: string ): Promise { if (await isRepoCloned(repoDir)) { + await assertRepoOriginMatches($, config, repoDir); return; } @@ -63,6 +105,60 @@ export async function ensureRepoCloned( } catch (error) { throw new SyncCommandError(`Failed to clone repo: ${formatError(error)}`); } + await assertRepoOriginMatches($, config, repoDir); +} + +export function normalizeRepoRemote(input: string, baseDir = process.cwd()): string { + const trimmed = input.trim().replace(/\/$/, ''); + const scpLike = trimmed.match(/^git@([^:]+):(.+)$/i); + if (scpLike) { + return `${scpLike[1].toLowerCase()}/${stripGitSuffix(scpLike[2]).toLowerCase()}`; + } + + if (/^[^/:]+\/[^/]+$/.test(trimmed)) { + return `github.com/${stripGitSuffix(trimmed).toLowerCase()}`; + } + + try { + const remoteUrl = new URL(trimmed); + if (remoteUrl.protocol === 'file:') { + return `local:${path.resolve(remoteUrl.pathname)}`; + } + const host = remoteUrl.hostname.toLowerCase(); + const repoPath = stripGitSuffix(remoteUrl.pathname).toLowerCase(); + const isCanonicalGithub = + host === 'github.com' && + ((remoteUrl.protocol === 'https:' && (!remoteUrl.port || remoteUrl.port === '443')) || + (remoteUrl.protocol === 'ssh:' && (!remoteUrl.port || remoteUrl.port === '22'))); + if (isCanonicalGithub) return `${host}/${repoPath}`; + const port = remoteUrl.port ? `:${remoteUrl.port}` : ''; + return `${remoteUrl.protocol}//${host}${port}/${repoPath}`; + } catch { + return `local:${path.resolve(baseDir, trimmed)}`; + } +} + +async function assertRepoOriginMatches( + $: Shell, + config: SyncConfig, + repoDir: string +): Promise { + let origin: string; + try { + origin = await $`git -C ${repoDir} remote get-url origin`.quiet().text(); + } catch (error) { + throw new SyncCommandError(`Unable to verify sync repo origin: ${formatError(error)}`); + } + const configured = resolveRepoIdentifier(config); + if (normalizeRepoRemote(origin, repoDir) === normalizeRepoRemote(configured, process.cwd())) + return; + throw new SyncCommandError( + `Existing sync repo origin does not match configured repo. Refusing to use ${repoDir}.` + ); +} + +function stripGitSuffix(input: string): string { + return input.replace(/^\/+|\/+$/g, '').replace(/\.git$/i, ''); } export async function ensureRepoPrivate($: Shell, config: SyncConfig): Promise { @@ -111,6 +207,7 @@ export async function fetchAndFastForward( const remoteRef = `origin/${branch}`; const remoteExists = await hasRemoteRef($, repoDir, branch); if (!remoteExists) { + await assertRemoteBranchNotDeleted($, repoDir, branch); return { updated: false, branch }; } @@ -133,6 +230,103 @@ export async function fetchAndFastForward( return { updated: false, branch }; } +export async function fetchAndRebaseLocalWins( + $: Shell, + repoDir: string, + branch: string +): Promise { + try { + await $`git -C ${repoDir} fetch --prune`.quiet(); + } catch (error) { + throw new SyncCommandError(`Failed to fetch repo: ${formatError(error)}`); + } + + await checkoutBranch($, repoDir, branch); + const remoteRef = `origin/${branch}`; + const remoteExists = await hasRemoteRef($, repoDir, branch); + if (!remoteExists) { + await assertRemoteBranchNotDeleted($, repoDir, branch); + return { updated: false, branch }; + } + + const { ahead, behind } = await getAheadBehind($, repoDir, remoteRef); + if (ahead > 0 && behind > 0) { + const recoveryRef = `refs/opencode-synced/pending/${Date.now()}`; + try { + await $`git -C ${repoDir} update-ref ${recoveryRef} HEAD`.quiet(); + await $`git -C ${repoDir} rebase ${remoteRef}`.quiet(); + return { updated: true, branch }; + } catch (error) { + try { + await resolveRebaseConflictsLocalWins($, repoDir, recoveryRef); + return { updated: true, branch }; + } catch (recoveryError) { + try { + await $`git -C ${repoDir} rebase --abort`.quiet(); + } catch {} + throw new SyncCommandError( + `Failed to rebase pending local commits safely: ${formatError(error)}; ${formatError(recoveryError)}. Pending commits remain at ${recoveryRef}.` + ); + } + } + } + + if (behind > 0) { + try { + await $`git -C ${repoDir} merge --ff-only ${remoteRef}`.quiet(); + return { updated: true, branch }; + } catch (error) { + throw new SyncCommandError(`Failed to fast-forward: ${formatError(error)}`); + } + } + + return { updated: false, branch }; +} + +async function resolveRebaseConflictsLocalWins( + $: Shell, + repoDir: string, + pendingRef: string +): Promise { + for (let attempt = 0; attempt < 50; attempt += 1) { + const output = await $`git -C ${repoDir} diff --name-only --diff-filter=U -z`.quiet().text(); + const conflictedPaths = output.split('\0').filter(Boolean); + if (conflictedPaths.length === 0) { + throw new Error('Rebase failed without resolvable unmerged paths.'); + } + + for (const conflictedPath of conflictedPaths) { + const pendingObject = `${pendingRef}:${conflictedPath}`; + if (await gitObjectExists($, repoDir, pendingObject)) { + await $`git -C ${repoDir} checkout ${pendingRef} -- ${conflictedPath}`.quiet(); + await $`git -C ${repoDir} add -- ${conflictedPath}`.quiet(); + } else { + await $`git -C ${repoDir} rm -f --ignore-unmatch -- ${conflictedPath}`.quiet(); + } + } + + try { + await $`env GIT_EDITOR=true git -C ${repoDir} rebase --continue`.quiet(); + return; + } catch { + const remaining = await $`git -C ${repoDir} diff --name-only --diff-filter=U -z` + .quiet() + .text(); + if (remaining.length === 0) throw new Error('Unable to continue resolved rebase.'); + } + } + throw new Error('Exceeded the maximum number of automatic rebase conflict resolutions.'); +} + +async function gitObjectExists($: Shell, repoDir: string, objectName: string): Promise { + try { + await $`git -C ${repoDir} cat-file -e ${objectName}`.quiet(); + return true; + } catch { + return false; + } +} + export async function getRepoStatus($: Shell, repoDir: string): Promise { const branch = await getCurrentBranch($, repoDir); const changes = await getStatusLines($, repoDir); @@ -146,6 +340,7 @@ export async function hasLocalChanges($: Shell, repoDir: string): Promise { try { + await ensureGitIdentity($, repoDir); await $`git -C ${repoDir} add -A`.quiet(); await $`git -C ${repoDir} commit -m ${message}`.quiet(); } catch (error) { @@ -153,14 +348,94 @@ export async function commitAll($: Shell, repoDir: string, message: string): Pro } } -export async function pushBranch($: Shell, repoDir: string, branch: string): Promise { +export async function pushBranch( + $: Shell, + repoDir: string, + branch: string, + expectedRemoteCommit?: string +): Promise { try { + if (expectedRemoteCommit) { + const lease = `refs/heads/${branch}:${expectedRemoteCommit}`; + await $`git -C ${repoDir} push -u --force-with-lease=${lease} origin ${branch}`.quiet(); + return; + } await $`git -C ${repoDir} push -u origin ${branch}`.quiet(); } catch (error) { throw new SyncCommandError(`Failed to push changes: ${formatError(error)}`); } } +export async function pushPendingCommits( + $: Shell, + repoDir: string, + branch: string +): Promise { + const remoteExists = await hasRemoteRef($, repoDir, branch); + const hadUpstream = await hasConfiguredUpstream($, repoDir, branch); + let ahead = 0; + let expectedRemoteCommit: string | undefined; + if (remoteExists) { + ahead = (await getAheadBehind($, repoDir, `origin/${branch}`)).ahead; + expectedRemoteCommit = await resolveGitCommit($, repoDir, `origin/${branch}`); + } + if (!shouldPushBranch(remoteExists, ahead, hadUpstream)) return false; + await pushBranch($, repoDir, branch, expectedRemoteCommit); + return true; +} + +async function resolveGitCommit($: Shell, repoDir: string, ref: string): Promise { + try { + return (await $`git -C ${repoDir} rev-parse ${ref}`.quiet().text()).trim(); + } catch (error) { + throw new SyncCommandError(`Failed to resolve Git ref ${ref}: ${formatError(error)}`); + } +} + +export function shouldPushBranch( + remoteExists: boolean, + ahead: number, + hadUpstream = false +): boolean { + if (!remoteExists) return !hadUpstream; + return ahead > 0; +} + +export function deriveGitIdentity(user: GitHubUserIdentity): GitIdentity { + if (!user.login || !Number.isInteger(user.id) || user.id <= 0) { + throw new Error('Invalid GitHub user identity response.'); + } + return { + name: user.name?.trim() || user.login, + email: `${user.id}+${user.login}@users.noreply.github.com`, + }; +} + +export async function ensureGitIdentity($: Shell, repoDir: string): Promise { + const name = await readLocalGitConfig($, repoDir, 'user.name'); + const email = await readLocalGitConfig($, repoDir, 'user.email'); + if (name && email) return; + + let user: GitHubUserIdentity; + try { + const output = await $`gh api user --jq ${'{login: .login, id: .id, name: .name}'}` + .quiet() + .text(); + user = JSON.parse(output) as GitHubUserIdentity; + } catch (error) { + throw new SyncCommandError(`Failed to derive Git identity: ${formatError(error)}`); + } + const derived = deriveGitIdentity(user); + try { + if (!name) await $`git -C ${repoDir} config user.name ${derived.name}`.quiet(); + if (!email) await $`git -C ${repoDir} config user.email ${derived.email}`.quiet(); + } catch (error) { + throw new SyncCommandError( + `Failed to configure repository Git identity: ${formatError(error)}` + ); + } +} + async function getCurrentBranch($: Shell, repoDir: string): Promise { try { const output = await $`git -C ${repoDir} rev-parse --abbrev-ref HEAD`.quiet().text(); @@ -179,7 +454,16 @@ async function checkoutBranch($: Shell, repoDir: string, branch: string): Promis await $`git -C ${repoDir} checkout ${branch}`.quiet(); return; } - await $`git -C ${repoDir} checkout -b ${branch}`.quiet(); + if (await hasRemoteRef($, repoDir, branch)) { + await $`git -C ${repoDir} checkout -b ${branch} --track origin/${branch}`.quiet(); + return; + } + if (await hasHeadCommit($, repoDir)) { + throw new SyncCommandError( + `Configured branch ${branch} does not exist locally or on origin. Refusing to create it from the current branch.` + ); + } + await $`git -C ${repoDir} checkout --orphan ${branch}`.quiet(); } catch (error) { throw new SyncCommandError(`Failed to checkout branch: ${formatError(error)}`); } @@ -203,7 +487,36 @@ async function hasRemoteRef($: Shell, repoDir: string, branch: string): Promise< } } -async function getAheadBehind( +async function hasConfiguredUpstream($: Shell, repoDir: string, branch: string): Promise { + try { + const remote = await $`git -C ${repoDir} config --get branch.${branch}.remote`.quiet().text(); + return remote.trim().length > 0; + } catch { + return false; + } +} + +async function assertRemoteBranchNotDeleted( + $: Shell, + repoDir: string, + branch: string +): Promise { + if (!(await hasConfiguredUpstream($, repoDir, branch))) return; + throw new SyncCommandError( + `Remote branch origin/${branch} no longer exists. Refusing to recreate a deleted branch.` + ); +} + +async function hasHeadCommit($: Shell, repoDir: string): Promise { + try { + await $`git -C ${repoDir} rev-parse --verify HEAD`.quiet(); + return true; + } catch { + return false; + } +} + +export async function getAheadBehind( $: Shell, repoDir: string, remoteRef: string @@ -212,13 +525,26 @@ async function getAheadBehind( const output = await $`git -C ${repoDir} rev-list --left-right --count HEAD...${remoteRef}` .quiet() .text(); - const [aheadRaw, behindRaw] = output.trim().split(/\s+/); - const ahead = Number(aheadRaw ?? 0); - const behind = Number(behindRaw ?? 0); - return { ahead, behind }; - } catch { - return { ahead: 0, behind: 0 }; + return parseAheadBehind(output); + } catch (error) { + throw new SyncCommandError(`Failed to determine ahead/behind state: ${formatError(error)}`); + } +} + +export function parseAheadBehind(output: string): { ahead: number; behind: number } { + const [aheadRaw, behindRaw, ...rest] = output.trim().split(/\s+/); + const ahead = Number(aheadRaw); + const behind = Number(behindRaw); + if ( + rest.length > 0 || + !Number.isInteger(ahead) || + ahead < 0 || + !Number.isInteger(behind) || + behind < 0 + ) { + throw new Error('Invalid ahead/behind response.'); } + return { ahead, behind }; } async function getStatusLines($: Shell, repoDir: string): Promise { @@ -228,8 +554,21 @@ async function getStatusLines($: Shell, repoDir: string): Promise { .split('\n') .map((line) => line.trim()) .filter(Boolean); + } catch (error) { + throw new SyncCommandError(`Failed to read Git status: ${formatError(error)}`); + } +} + +async function readLocalGitConfig( + $: Shell, + repoDir: string, + key: 'user.name' | 'user.email' +): Promise { + try { + const output = await $`git -C ${repoDir} config --local --get ${key}`.quiet().text(); + return output.trim() || null; } catch { - return []; + return null; } } diff --git a/src/sync/service.test.ts b/src/sync/service.test.ts new file mode 100644 index 0000000..44c0233 --- /dev/null +++ b/src/sync/service.test.ts @@ -0,0 +1,220 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +import type { PluginInput } from '@opencode-ai/plugin'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { loadState, writeSyncConfig } from './config.js'; +import { resolveSyncLocations } from './paths.js'; +import { createSyncService } from './service.js'; + +const execFile = promisify(execFileCallback); +const tempDirs: string[] = []; +const originalEnv = { ...process.env }; + +afterEach(async () => { + process.env = { ...originalEnv }; + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('SyncService local-wins integration', () => { + it('does not wait for TUI to exist when startup sync is not configured', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'opencode-synced-startup-')); + tempDirs.push(root); + process.env.HOME = path.join(root, 'home'); + delete process.env.XDG_CONFIG_HOME; + delete process.env.XDG_DATA_HOME; + delete process.env.XDG_STATE_HOME; + delete process.env.opencode_config_dir; + await mkdir(process.env.HOME, { recursive: true }); + const client = { + app: { log: async () => ({}) }, + tui: { showToast: () => new Promise(() => {}) }, + } as unknown as PluginInput['client']; + const service = createSyncService({ client, $: createShell() }); + + const result = await Promise.race([ + service.startupSync().then(() => 'completed'), + new Promise((resolve) => setTimeout(() => resolve('timed-out'), 500)), + ]); + + expect(result).toBe('completed'); + }); + + it('preserves unrelated remote changes and backs up a displaced same-file change', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'opencode-synced-service-')); + tempDirs.push(root); + const home = path.join(root, 'home'); + const origin = path.join(root, 'origin.git'); + const seed = path.join(root, 'seed'); + const remoteWriter = path.join(root, 'remote-writer'); + await mkdir(home, { recursive: true }); + process.env.HOME = home; + delete process.env.XDG_CONFIG_HOME; + delete process.env.XDG_DATA_HOME; + delete process.env.XDG_STATE_HOME; + delete process.env.opencode_config_dir; + + await run('git', ['init', '--bare', origin]); + await run('git', ['clone', origin, seed]); + await configureGit(seed); + await mkdir(path.join(seed, 'config'), { recursive: true }); + await writeFile(path.join(seed, 'config', 'AGENTS.md'), 'base-agents\n'); + await writeFile(path.join(seed, 'config', 'opencode.json'), '{"theme":"base"}\n'); + await run('git', ['-C', seed, 'add', '--all']); + await run('git', ['-C', seed, 'commit', '-m', 'base']); + await run('git', ['-C', seed, 'branch', '-M', 'main']); + await run('git', ['-C', seed, 'push', '--set-upstream', 'origin', 'main']); + await run('git', ['--git-dir', origin, 'symbolic-ref', 'HEAD', 'refs/heads/main']); + + const locations = resolveSyncLocations(); + await mkdir(path.dirname(locations.defaultRepoDir), { recursive: true }); + await run('git', ['clone', origin, locations.defaultRepoDir]); + await configureGit(locations.defaultRepoDir); + await mkdir(locations.configRoot, { recursive: true }); + await writeFile(path.join(locations.configRoot, 'AGENTS.md'), 'base-agents\n'); + await writeFile(path.join(locations.configRoot, 'opencode.json'), '{"theme":"base"}\n'); + await writeSyncConfig(locations, { + repo: { url: origin, branch: 'main' }, + includeModelFavorites: false, + }); + + await writeFile(path.join(locations.configRoot, 'AGENTS.md'), 'local-agents\n'); + + await run('git', ['clone', origin, remoteWriter]); + await configureGit(remoteWriter); + await writeFile(path.join(remoteWriter, 'config', 'AGENTS.md'), 'remote-agents\n'); + await writeFile(path.join(remoteWriter, 'config', 'opencode.json'), '{"theme":"remote"}\n'); + await run('git', ['-C', remoteWriter, 'add', '--all']); + await run('git', ['-C', remoteWriter, 'commit', '-m', 'remote update']); + await run('git', ['-C', remoteWriter, 'push']); + + const service = createSyncService({ client: createClient(), $: createShell() }); + await expect(service.push()).resolves.toContain('Pushed changes'); + + expect(await readFile(path.join(locations.configRoot, 'AGENTS.md'), 'utf8')).toBe( + 'local-agents\n' + ); + expect(await readFile(path.join(locations.configRoot, 'opencode.json'), 'utf8')).toContain( + 'remote' + ); + expect(await gitShow(origin, 'main:config/AGENTS.md')).toBe('local-agents\n'); + expect(await gitShow(origin, 'main:config/opencode.json')).toContain('remote'); + + const rollbackBase = path.join( + path.dirname(locations.statePath), + 'opencode-synced', + 'rollbacks' + ); + const rollbackDirs = await readdir(rollbackBase); + expect(rollbackDirs).toHaveLength(1); + expect( + await readFile(path.join(rollbackBase, rollbackDirs[0], 'config', 'AGENTS.md'), 'utf8') + ).toBe('remote-agents\n'); + + expect(await loadState(locations)).toMatchObject({ lastOutcome: 'pushed' }); + expect( + (await run('git', ['-C', locations.defaultRepoDir, 'status', '--porcelain'])).stdout + ).toBe(''); + + await writeFile(path.join(locations.configRoot, 'AGENTS.md'), 'pending-local-agents\n'); + await writeFile( + path.join(locations.defaultRepoDir, 'config', 'AGENTS.md'), + 'pending-local-agents\n' + ); + await run('git', ['-C', locations.defaultRepoDir, 'add', '--all']); + await run('git', ['-C', locations.defaultRepoDir, 'commit', '-m', 'pending local commit']); + + await run('git', ['-C', remoteWriter, 'pull', '--ff-only']); + await rm(path.join(remoteWriter, 'config', 'AGENTS.md')); + await writeFile(path.join(remoteWriter, 'config', 'opencode.json'), '{"theme":"remote-two"}\n'); + await run('git', ['-C', remoteWriter, 'add', '--all']); + await run('git', ['-C', remoteWriter, 'commit', '-m', 'second remote update']); + await run('git', ['-C', remoteWriter, 'push']); + + await expect(service.push()).resolves.toContain('Pushed'); + expect(await gitShow(origin, 'main:config/AGENTS.md')).toBe('pending-local-agents\n'); + expect(await gitShow(origin, 'main:config/opencode.json')).toContain('remote-two'); + + await writeFile(path.join(locations.configRoot, 'AGENTS.md'), 'stale-local-value\n'); + await expect(service.pull()).resolves.toContain('Remote config applied'); + expect(await readFile(path.join(locations.configRoot, 'AGENTS.md'), 'utf8')).toBe( + 'pending-local-agents\n' + ); + }); + + it('rejects an existing clone with a different origin', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'opencode-synced-origin-')); + tempDirs.push(root); + const home = path.join(root, 'home'); + const oldOrigin = path.join(root, 'old-origin.git'); + const configuredOrigin = path.join(root, 'configured-origin.git'); + await mkdir(home, { recursive: true }); + process.env.HOME = home; + delete process.env.XDG_CONFIG_HOME; + delete process.env.XDG_DATA_HOME; + delete process.env.XDG_STATE_HOME; + delete process.env.opencode_config_dir; + await run('git', ['init', '--bare', oldOrigin]); + await run('git', ['init', '--bare', configuredOrigin]); + + const locations = resolveSyncLocations(); + await mkdir(path.dirname(locations.defaultRepoDir), { recursive: true }); + await run('git', ['clone', oldOrigin, locations.defaultRepoDir]); + await writeSyncConfig(locations, { + repo: { url: configuredOrigin, branch: 'main' }, + includeModelFavorites: false, + }); + + const service = createSyncService({ client: createClient(), $: createShell() }); + await expect(service.push()).rejects.toThrow('origin does not match configured repo'); + }); +}); + +function createClient(): PluginInput['client'] { + return { + app: { log: async () => ({}) }, + tui: { showToast: async () => ({}) }, + } as unknown as PluginInput['client']; +} + +function createShell(): PluginInput['$'] { + return ((strings: TemplateStringsArray, ...values: unknown[]) => { + let commandTemplate = strings[0]; + for (let index = 0; index < values.length; index += 1) { + commandTemplate += `__OCSYNC_ARG_${index}__${strings[index + 1]}`; + } + const args = (commandTemplate.match(/\S+/g) ?? []).map((token) => + token.replace(/__OCSYNC_ARG_(\d+)__/g, (_match, index) => String(values[Number(index)])) + ); + const [command, ...commandArgs] = args; + let promise: Promise<{ stdout: string; stderr: string }> | null = null; + const execute = () => { + promise ??= run(command, commandArgs); + return promise; + }; + const text = async () => (await execute()).stdout; + const result = { + quiet: () => Object.assign(execute(), { text }), + text, + }; + return result; + }) as unknown as PluginInput['$']; +} + +async function configureGit(repo: string): Promise { + await run('git', ['-C', repo, 'config', 'user.name', 'Test User']); + await run('git', ['-C', repo, 'config', 'user.email', 'test@example.invalid']); +} + +async function gitShow(repo: string, object: string): Promise { + return (await run('git', ['--git-dir', repo, 'show', object])).stdout; +} + +async function run(command: string, args: string[]): Promise<{ stdout: string; stderr: string }> { + const result = await execFile(command, args, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }); + return { stdout: result.stdout, stderr: result.stderr }; +} diff --git a/src/sync/service.ts b/src/sync/service.ts index df7071b..4981e1b 100644 --- a/src/sync/service.ts +++ b/src/sync/service.ts @@ -1,8 +1,8 @@ +import { promises as fs } from 'node:fs'; import path from 'node:path'; - import type { PluginInput } from '@opencode-ai/plugin'; -import { syncLocalToRepo, syncRepoToLocal } from './apply.js'; -import { generateCommitMessage } from './commit.js'; + +import { assertSafeDestination, syncLocalToRepo, syncRepoToLocal } from './apply.js'; import { canCommitMcpSecrets, loadOverrides, @@ -16,28 +16,26 @@ import { SyncCommandError, SyncConfigMissingError } from './errors.js'; import type { SyncLockInfo } from './lock.js'; import { withSyncLock } from './lock.js'; import { buildSyncPlan, resolveRepoRoot, resolveSyncLocations } from './paths.js'; +import { applyLocalProjection, createLocalProjection } from './reconcile.js'; import { + assertRestrictedRepoLayout, commitAll, ensureRepoCloned, ensureRepoPrivate, fetchAndFastForward, + fetchAndRebaseLocalWins, findSyncRepo, getAuthenticatedUser, getRepoStatus, hasLocalChanges, isRepoCloned, pushBranch, + pushPendingCommits, repoExists, resolveRepoBranch, resolveRepoIdentifier, } from './repo.js'; -import { - createLogger, - extractTextFromResponse, - resolveSmallModel, - showToast, - unwrapData, -} from './utils.js'; +import { createLogger, showToast } from './utils.js'; type SyncServiceContext = Pick; type Logger = ReturnType; @@ -52,8 +50,12 @@ interface InitOptions { includeSecrets?: boolean; includeMcpSecrets?: boolean; includeSessions?: boolean; + includeSkills?: boolean; + includePromptHistory?: boolean; includePromptStash?: boolean; includeModelFavorites?: boolean; + includeModelSelectors?: boolean; + acknowledgePlaintextPromptRisk?: boolean; create?: boolean; private?: boolean; extraSecretPaths?: string[]; @@ -63,6 +65,12 @@ interface InitOptions { interface LinkOptions { repo?: string; + includeSkills?: boolean; + includePromptHistory?: boolean; + includePromptStash?: boolean; + includeModelFavorites?: boolean; + includeModelSelectors?: boolean; + acknowledgePlaintextPromptRisk?: boolean; } export interface SyncService { @@ -72,11 +80,6 @@ export interface SyncService { link: (_options: LinkOptions) => Promise; pull: () => Promise; push: () => Promise; - enableSecrets: (_options?: { - extraSecretPaths?: string[]; - includeMcpSecrets?: boolean; - }) => Promise; - resolve: () => Promise; } export function createSyncService(ctx: SyncServiceContext): SyncService { @@ -125,26 +128,16 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { } catch (error) { const message = `Failed to load opencode-synced config: ${formatError(error)}`; log.error(message, { path: locations.syncConfigPath }); - await showToast( - ctx.client, - `Failed to load opencode-synced config. Check ${locations.syncConfigPath} for JSON errors.`, - 'error' - ); return; } if (!config) { - await showToast( - ctx.client, - 'Configure opencode-synced with /sync-init or link to an existing repo with /sync-link', - 'info' - ); + log.info('Sync is not configured; skipping startup sync'); return; } try { await runStartup(ctx, locations, config, log); } catch (error) { log.error('Startup sync failed', { error: formatError(error) }); - await showToast(ctx.client, formatError(error), 'error'); } }), status: async () => { @@ -175,10 +168,14 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { const includeSecrets = config.includeSecrets ? 'enabled' : 'disabled'; const includeMcpSecrets = config.includeMcpSecrets ? 'enabled' : 'disabled'; const includeSessions = config.includeSessions ? 'enabled' : 'disabled'; + const includeSkills = config.includeSkills ? 'enabled' : 'disabled'; + const includePromptHistory = config.includePromptHistory ? 'enabled' : 'disabled'; const includePromptStash = config.includePromptStash ? 'enabled' : 'disabled'; const includeModelFavorites = config.includeModelFavorites ? 'enabled' : 'disabled'; + const includeModelSelectors = config.includeModelSelectors ? 'enabled' : 'disabled'; const lastPull = state.lastPull ?? 'never'; const lastPush = state.lastPush ?? 'never'; + const lastOutcome = state.lastOutcome ?? 'never'; let changesLabel = 'clean'; if (!cloned) { @@ -196,10 +193,14 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { `Secrets: ${includeSecrets}`, `MCP secrets: ${includeMcpSecrets}`, `Sessions: ${includeSessions}`, + `Skills: ${includeSkills}`, + `Prompt history: ${includePromptHistory}`, `Prompt stash: ${includePromptStash}`, `Model favorites: ${includeModelFavorites}`, + `Model selectors: ${includeModelSelectors}`, `Last pull: ${lastPull}`, `Last push: ${lastPush}`, + `Last outcome: ${lastOutcome}`, `Working tree: ${changesLabel}`, ]; @@ -211,6 +212,9 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { const repoIdentifier = resolveRepoIdentifier(config); const isPrivate = options.private ?? true; + if (!isPrivate && (config.includePromptHistory || config.includePromptStash)) { + throw new SyncCommandError('Prompt synchronization requires a private repository.'); + } const exists = await repoExists(ctx.$, repoIdentifier); let created = false; @@ -222,7 +226,8 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { await writeSyncConfig(locations, config); const repoRoot = resolveRepoRoot(config, locations); await ensureRepoCloned(ctx.$, config, repoRoot); - await ensureSecretsPolicy(ctx, config); + await assertRestrictedRepoLayout(repoRoot); + await ensurePrivateDataPolicy(ctx, config); if (created) { const overrides = await loadOverrides(locations); @@ -237,7 +242,13 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { const branch = resolveRepoBranch(config); await commitAll(ctx.$, repoRoot, 'Initial sync from opencode-synced'); await pushBranch(ctx.$, repoRoot, branch); - await writeState(locations, { lastPush: new Date().toISOString() }); + const completedAt = new Date().toISOString(); + await writeState(locations, { + lastAttempt: completedAt, + lastCommit: completedAt, + lastPush: completedAt, + lastOutcome: 'pushed', + }); } } @@ -276,7 +287,12 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { includeSecrets: false, includeMcpSecrets: false, includeSessions: false, - includePromptStash: false, + includeSkills: options.includeSkills ?? false, + includePromptHistory: options.includePromptHistory ?? false, + includePromptStash: options.includePromptStash ?? false, + includeModelFavorites: options.includeModelFavorites ?? true, + includeModelSelectors: options.includeModelSelectors ?? false, + acknowledgePlaintextPromptRisk: options.acknowledgePlaintextPromptRisk ?? false, extraSecretPaths: [], extraConfigPaths: [], }); @@ -284,10 +300,13 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { await writeSyncConfig(locations, config); const repoRoot = resolveRepoRoot(config, locations); await ensureRepoCloned(ctx.$, config, repoRoot); + await ensurePrivateDataPolicy(ctx, config); + await assertRestrictedRepoLayout(repoRoot); const branch = await resolveBranch(ctx, config, repoRoot); await fetchAndFastForward(ctx.$, repoRoot, branch); + await assertRestrictedRepoLayout(repoRoot); const overrides = await loadOverrides(locations); const plan = buildSyncPlan(config, locations, repoRoot); @@ -307,8 +326,8 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { 'Restart opencode to apply the new settings.', '', found.isPrivate - ? 'To enable secrets sync, run: /sync-enable-secrets' - : 'Note: Repo is public. Secrets sync is disabled.', + ? 'Private repository verified. Secret files remain local-only.' + : 'Public repository detected. Prompt snapshots remain disabled.', ]; await showToast(ctx.client, 'Config synced. Restart opencode to apply.', 'info'); @@ -319,7 +338,10 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { const config = await getConfigOrThrow(locations); const repoRoot = resolveRepoRoot(config, locations); await ensureRepoCloned(ctx.$, config, repoRoot); - await ensureSecretsPolicy(ctx, config); + await ensurePrivateDataPolicy(ctx, config); + await assertRestrictedRepoLayout(repoRoot); + const attemptedAt = new Date().toISOString(); + await writeState(locations, { lastAttempt: attemptedAt }); const branch = await resolveBranch(ctx, config, repoRoot); @@ -331,17 +353,19 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { } const update = await fetchAndFastForward(ctx.$, repoRoot, branch); - if (!update.updated) { - return 'Already up to date.'; - } + await assertRestrictedRepoLayout(repoRoot); const overrides = await loadOverrides(locations); const plan = buildSyncPlan(config, locations, repoRoot); await syncRepoToLocal(plan, overrides); + const completedAt = new Date().toISOString(); await writeState(locations, { - lastPull: new Date().toISOString(), - lastRemoteUpdate: new Date().toISOString(), + lastFetch: completedAt, + lastApplied: completedAt, + lastPull: completedAt, + ...(update.updated ? { lastRemoteUpdate: completedAt } : {}), + lastOutcome: 'pulled', }); await showToast(ctx.client, 'Config updated. Restart opencode to apply.', 'info'); @@ -350,91 +374,7 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { push: () => runExclusive(async () => { const config = await getConfigOrThrow(locations); - const repoRoot = resolveRepoRoot(config, locations); - await ensureRepoCloned(ctx.$, config, repoRoot); - await ensureSecretsPolicy(ctx, config); - const branch = await resolveBranch(ctx, config, repoRoot); - - const preDirty = await hasLocalChanges(ctx.$, repoRoot); - if (preDirty) { - throw new SyncCommandError( - `Local sync repo has uncommitted changes. Resolve in ${repoRoot} before pushing.` - ); - } - - const overrides = await loadOverrides(locations); - const plan = buildSyncPlan(config, locations, repoRoot); - await syncLocalToRepo(plan, overrides, { - overridesPath: locations.overridesPath, - allowMcpSecrets: canCommitMcpSecrets(config), - }); - - const dirty = await hasLocalChanges(ctx.$, repoRoot); - if (!dirty) { - return 'No local changes to push.'; - } - - const message = await generateCommitMessage({ client: ctx.client, $: ctx.$ }, repoRoot); - await commitAll(ctx.$, repoRoot, message); - await pushBranch(ctx.$, repoRoot, branch); - - await writeState(locations, { - lastPush: new Date().toISOString(), - }); - - return `Pushed changes: ${message}`; - }), - enableSecrets: (options?: { extraSecretPaths?: string[]; includeMcpSecrets?: boolean }) => - runExclusive(async () => { - const config = await getConfigOrThrow(locations); - config.includeSecrets = true; - if (options?.extraSecretPaths) { - config.extraSecretPaths = options.extraSecretPaths; - } - if (options?.includeMcpSecrets !== undefined) { - config.includeMcpSecrets = options.includeMcpSecrets; - } - - await ensureRepoPrivate(ctx.$, config); - await writeSyncConfig(locations, config); - - return 'Secrets sync enabled for this repo.'; - }), - resolve: () => - runExclusive(async () => { - const config = await getConfigOrThrow(locations); - const repoRoot = resolveRepoRoot(config, locations); - await ensureRepoCloned(ctx.$, config, repoRoot); - - const dirty = await hasLocalChanges(ctx.$, repoRoot); - if (!dirty) { - return 'No uncommitted changes to resolve.'; - } - - const status = await getRepoStatus(ctx.$, repoRoot); - const decision = await analyzeAndDecideResolution( - { client: ctx.client, $: ctx.$ }, - repoRoot, - status.changes - ); - - if (decision.action === 'commit') { - const message = decision.message ?? 'Sync: Auto-resolved uncommitted changes'; - await commitAll(ctx.$, repoRoot, message); - return `Resolved by committing changes: ${message}`; - } - - if (decision.action === 'reset') { - try { - await ctx.$`git -C ${repoRoot} reset --hard HEAD`.quiet(); - await ctx.$`git -C ${repoRoot} clean -fd`.quiet(); - return 'Resolved by discarding all uncommitted changes.'; - } catch (error) { - throw new SyncCommandError(`Failed to reset changes: ${formatError(error)}`); - } - } - - return `Unable to automatically resolve. Please manually resolve in: ${repoRoot}`; + return runLocalWinsSync(ctx, locations, config, log); }), }; } @@ -445,58 +385,113 @@ async function runStartup( config: ReturnType, log: Logger ): Promise { - const repoRoot = resolveRepoRoot(config, locations); - log.debug('Starting sync', { repoRoot }); - - await ensureRepoCloned(ctx.$, config, repoRoot); - await ensureSecretsPolicy(ctx, config); - const branch = await resolveBranch(ctx, config, repoRoot); - log.debug('Resolved branch', { branch }); - - const dirty = await hasLocalChanges(ctx.$, repoRoot); - if (dirty) { - log.warn('Uncommitted changes detected', { repoRoot }); - await showToast( - ctx.client, - `Uncommitted changes detected. Run /sync-resolve to auto-fix, or manually resolve in: ${repoRoot}`, - 'warning' - ); - return; + const result = await runLocalWinsSync(ctx, locations, config, log); + if (result === 'pulled') { + await showToast(ctx.client, 'Config updated. Restart opencode to apply.', 'info'); } +} + +async function runLocalWinsSync( + ctx: SyncServiceContext, + locations: ReturnType, + config: ReturnType, + log: Logger +): Promise { + const attemptedAt = new Date().toISOString(); + await writeState(locations, { lastAttempt: attemptedAt }); + const repoRoot = resolveRepoRoot(config, locations); + const workspaceParent = path.dirname(locations.statePath); + const projectionRoot = path.join(workspaceParent, 'opencode-synced', 'projection'); + const rollbackBase = path.join(workspaceParent, 'opencode-synced', 'rollbacks'); + + try { + await assertSafeDestination(workspaceParent, projectionRoot); + await ensureRepoCloned(ctx.$, config, repoRoot); + await ensurePrivateDataPolicy(ctx, config); + await assertRestrictedRepoLayout(repoRoot); + const branch = await resolveBranch(ctx, config, repoRoot); + const dirty = await hasLocalChanges(ctx.$, repoRoot); + if (dirty) { + throw new SyncCommandError( + `Local sync repo has uncommitted changes. Resolve them manually in ${repoRoot}.` + ); + } - const update = await fetchAndFastForward(ctx.$, repoRoot, branch); - if (update.updated) { - log.info('Pulled remote changes', { branch }); const overrides = await loadOverrides(locations); const plan = buildSyncPlan(config, locations, repoRoot); - await syncRepoToLocal(plan, overrides); - await writeState(locations, { - lastPull: new Date().toISOString(), - lastRemoteUpdate: new Date().toISOString(), + const projection = await createLocalProjection(plan, overrides, projectionRoot, { + overridesPath: locations.overridesPath, }); - await showToast(ctx.client, 'Config updated. Restart opencode to apply.', 'info'); - return; - } + const update = await fetchAndRebaseLocalWins(ctx.$, repoRoot, branch); + await assertRestrictedRepoLayout(repoRoot); + const fetchedAt = new Date().toISOString(); + await writeState(locations, { lastFetch: fetchedAt }); + + if (update.updated && projection.changedItemIndexes.length > 0) { + const rollbackRoot = path.join(rollbackBase, safeTimestamp()); + await assertSafeDestination(workspaceParent, rollbackRoot); + await applyLocalProjection(plan, projection, rollbackRoot); + log.warn('Concurrent remote changes reconciled with local-wins policy', { + changedItems: projection.changedItemIndexes.length, + rollbackRoot, + }); + } else if (!update.updated) { + await syncLocalToRepo(plan, overrides, { + overridesPath: locations.overridesPath, + allowMcpSecrets: canCommitMcpSecrets(config), + }); + } - const overrides = await loadOverrides(locations); - const plan = buildSyncPlan(config, locations, repoRoot); - await syncLocalToRepo(plan, overrides, { - overridesPath: locations.overridesPath, - allowMcpSecrets: canCommitMcpSecrets(config), - }); - const changes = await hasLocalChanges(ctx.$, repoRoot); - if (!changes) { - log.debug('No local changes to push'); - return; + let message: string | null = null; + if (await hasLocalChanges(ctx.$, repoRoot)) { + message = 'sync: update OpenCode configuration'; + await commitAll(ctx.$, repoRoot, message); + await writeState(locations, { lastCommit: new Date().toISOString() }); + } + + const pushed = await pushPendingCommits(ctx.$, repoRoot, branch); + if (pushed) { + const completedAt = new Date().toISOString(); + await writeState(locations, { + lastPush: completedAt, + lastOutcome: 'pushed', + }); + await syncRepoToLocal(plan, overrides); + return message ? `Pushed changes: ${message}` : 'Pushed pending commits.'; + } + + if (update.updated) { + await syncRepoToLocal(plan, overrides); + const completedAt = new Date().toISOString(); + await writeState(locations, { + lastApplied: completedAt, + lastPull: completedAt, + lastRemoteUpdate: completedAt, + lastOutcome: 'pulled', + }); + return 'pulled'; + } + + const completedAt = new Date().toISOString(); + await writeState(locations, { + lastNoop: completedAt, + lastOutcome: 'noop', + }); + return 'No local or remote changes.'; + } catch (error) { + await writeState(locations, { + lastError: new Date().toISOString(), + lastOutcome: 'failed', + }); + throw error; + } finally { + await assertSafeDestination(workspaceParent, projectionRoot); + await fs.rm(projectionRoot, { recursive: true, force: true }); } +} - const message = await generateCommitMessage({ client: ctx.client, $: ctx.$ }, repoRoot); - log.info('Pushing local changes', { message }); - await commitAll(ctx.$, repoRoot, message); - await pushBranch(ctx.$, repoRoot, branch); - await writeState(locations, { - lastPush: new Date().toISOString(), - }); +function safeTimestamp(): string { + return new Date().toISOString().replace(/[:.]/g, '-'); } async function getConfigOrThrow( @@ -511,25 +506,20 @@ async function getConfigOrThrow( return config; } -async function ensureSecretsPolicy( +async function ensurePrivateDataPolicy( ctx: SyncServiceContext, config: ReturnType ) { - if (!config.includeSecrets) return; + if (!config.includePromptHistory && !config.includePromptStash) return; await ensureRepoPrivate(ctx.$, config); } async function resolveBranch( - ctx: SyncServiceContext, + _ctx: SyncServiceContext, config: ReturnType, - repoRoot: string + _repoRoot: string ): Promise { - try { - const status = await getRepoStatus(ctx.$, repoRoot); - return resolveRepoBranch(config, status.branch); - } catch { - return resolveRepoBranch(config); - } + return resolveRepoBranch(config); } const DEFAULT_REPO_NAME = 'my-opencode-config'; @@ -541,8 +531,12 @@ async function buildConfigFromInit($: Shell, options: InitOptions) { includeSecrets: options.includeSecrets ?? false, includeMcpSecrets: options.includeMcpSecrets ?? false, includeSessions: options.includeSessions ?? false, + includeSkills: options.includeSkills ?? false, + includePromptHistory: options.includePromptHistory ?? false, includePromptStash: options.includePromptStash ?? false, includeModelFavorites: options.includeModelFavorites ?? true, + includeModelSelectors: options.includeModelSelectors ?? false, + acknowledgePlaintextPromptRisk: options.acknowledgePlaintextPromptRisk ?? false, extraSecretPaths: options.extraSecretPaths ?? [], extraConfigPaths: options.extraConfigPaths ?? [], localRepoPath: options.localRepoPath, @@ -600,108 +594,3 @@ function formatError(error: unknown): string { if (error instanceof Error) return error.message; return String(error); } - -interface ResolutionDecision { - action: 'commit' | 'reset' | 'manual'; - message?: string; - reason?: string; -} - -async function analyzeAndDecideResolution( - ctx: { client: SyncServiceContext['client']; $: Shell }, - repoRoot: string, - changes: string[] -): Promise { - try { - const diff = await ctx.$`git -C ${repoRoot} diff HEAD`.quiet().text(); - const statusOutput = changes.join('\n'); - - const prompt = [ - 'You are analyzing uncommitted changes in an opencode-synced repository.', - 'Decide whether to commit these changes or discard them.', - '', - 'IMPORTANT: Only choose "commit" if the changes appear to be legitimate config updates.', - 'Choose "discard" if the changes look like temporary files, cache, or corruption.', - '', - 'Respond with ONLY a JSON object in this exact format:', - '{"action": "commit", "message": "your commit message here"}', - 'OR', - '{"action": "discard", "reason": "explanation why discarding"}', - '', - 'Status:', - statusOutput, - '', - 'Diff preview (first 2000 chars):', - diff.slice(0, 2000), - ].join('\n'); - - const model = await resolveSmallModel(ctx.client); - if (!model) { - return { action: 'manual', reason: 'No AI model available' }; - } - - let sessionId: string | null = null; - try { - const sessionResult = await ctx.client.session.create({ - body: { title: 'sync-resolve' }, - }); - const session = unwrapData<{ id: string }>(sessionResult); - sessionId = session?.id ?? null; - if (!sessionId) { - return { action: 'manual', reason: 'Failed to create session' }; - } - - const response = await ctx.client.session.prompt({ - path: { id: sessionId }, - body: { - model, - parts: [{ type: 'text', text: prompt }], - }, - }); - - const messageText = extractTextFromResponse(unwrapData(response) ?? response); - if (!messageText) { - return { action: 'manual', reason: 'No response from AI' }; - } - - const decision = parseResolutionDecision(messageText); - return decision; - } finally { - if (sessionId) { - try { - await ctx.client.session.delete({ path: { id: sessionId } }); - } catch {} - } - } - } catch (error) { - console.error('[ERROR] AI resolution analysis failed:', error); - return { action: 'manual', reason: `Error analyzing changes: ${formatError(error)}` }; - } -} - -function parseResolutionDecision(text: string): ResolutionDecision { - try { - const jsonMatch = text.match(/\{[\s\S]*\}/); - if (!jsonMatch) { - return { action: 'manual', reason: 'Could not parse AI response' }; - } - - const parsed = JSON.parse(jsonMatch[0]) as { - action?: string; - message?: string; - reason?: string; - }; - - if (parsed.action === 'commit' && parsed.message) { - return { action: 'commit', message: parsed.message }; - } - - if (parsed.action === 'discard') { - return { action: 'reset', reason: parsed.reason }; - } - - return { action: 'manual', reason: 'Unexpected AI response format' }; - } catch { - return { action: 'manual', reason: 'Failed to parse AI decision' }; - } -} From a513c7a220dd9470034a6053b048b8c197e4be91 Mon Sep 17 00:00:00 2001 From: Tsunami Date: Fri, 21 Aug 2026 14:24:05 +0200 Subject: [PATCH 2/3] fix: support Windows XDG paths and frontier model selector --- README.md | 7 ++++- docs/extended-sync-v1.md | 5 ++- package.json | 2 +- scripts/build.mjs | 15 +++++++++ src/index.ts | 2 +- src/sync/apply.test.ts | 29 +++++++++++++----- src/sync/apply.ts | 3 +- src/sync/config.test.ts | 12 +++++--- src/sync/paths.test.ts | 66 ++++++++++++++++++++++++---------------- src/sync/paths.ts | 10 +++--- src/sync/repo.test.ts | 2 +- src/sync/repo.ts | 2 +- src/sync/service.test.ts | 42 ++++++++++++++----------- 13 files changed, 131 insertions(+), 66 deletions(-) create mode 100644 scripts/build.mjs diff --git a/README.md b/README.md index 35cf50d..c6bbf4a 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Supported synchronized data: - global `skills/` - plaintext prompt history and prompt stash in a private repository - only the `favorite` projection from `state/opencode/model.json` -- `main-model.txt` and `cheap-model.txt` +- `main-model.txt`, `cheap-model.txt`, and `frontier-model.txt` Always local and rejected by configuration validation: @@ -94,6 +94,10 @@ artifact into OpenCode's package cache. Do not use a mutable branch reference. Prompt flags fail unless the risk acknowledgement is exactly `true`. The repository visibility is checked before prompt data is read or written. +On Windows, sync locations follow OpenCode's XDG-compatible layout: `%USERPROFILE%\.config`, +`%USERPROFILE%\.local\share`, and `%USERPROFILE%\.local\state`. The native `APPDATA` and +`LOCALAPPDATA` roots are not used for sync locations. + ## Commands | Command | Description | @@ -121,6 +125,7 @@ state/ model-selectors/ main-model.txt cheap-model.txt + frontier-model.txt prompts/ prompt-history.jsonl prompt-stash.jsonl diff --git a/docs/extended-sync-v1.md b/docs/extended-sync-v1.md index 06692d6..3aec8b5 100644 --- a/docs/extended-sync-v1.md +++ b/docs/extended-sync-v1.md @@ -15,7 +15,7 @@ The fork may synchronize only these paths: - `skills/`, excluding generated cache and platform metadata - prompt history and prompt stash as plaintext in a private repository - the `favorite` projection from `model.json` -- `main-model.txt` and `cheap-model.txt` +- `main-model.txt`, `cheap-model.txt`, and `frontier-model.txt` The following remain local and are rejected when explicitly enabled: @@ -43,6 +43,9 @@ This policy uses operation order, never wall-clock timestamps. (`*:Zone.Identifier`, `.DS_Store`) are excluded. - Files are staged and atomically renamed instead of overwriting live files in place. Directory replacement must preserve a rollback copy until completion. +- On Windows, sync locations follow OpenCode's XDG-compatible `%USERPROFILE%\.config`, + `%USERPROFILE%\.local\share`, and `%USERPROFILE%\.local\state` roots instead of + native `APPDATA` and `LOCALAPPDATA` roots. - Generated local state and overrides use mode `0600`; their parent directory uses mode `0700`. - Secrets, sessions, sync configuration, and overrides are never part of a plan. diff --git a/package.json b/package.json index cb60855..2d00b66 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "vitest": "^3.2.4" }, "scripts": { - "build": "rm -rf dist && tsc -p tsconfig.build.json && cp -r src/command dist/command", + "build": "node scripts/build.mjs", "prepack": "npm run build", "test": "vitest run", "test:watch": "vitest", diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..e8838a6 --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,15 @@ +import { spawnSync } from 'node:child_process'; +import { cp, rm } from 'node:fs/promises'; + +await rm('dist', { recursive: true, force: true }); + +const result = spawnSync( + process.execPath, + ['node_modules/typescript/bin/tsc', '-p', 'tsconfig.build.json'], + { stdio: 'inherit' } +); + +if (result.error) throw result.error; +if (result.status !== 0) process.exit(result.status ?? 1); + +await cp('src/command', 'dist/command', { recursive: true }); diff --git a/src/index.ts b/src/index.ts index 5344d2e..0240195 100644 --- a/src/index.ts +++ b/src/index.ts @@ -140,7 +140,7 @@ export const opencodeConfigSync: Plugin = async (ctx) => { includeModelSelectors: tool.schema .boolean() .optional() - .describe('Sync main-model.txt and cheap-model.txt'), + .describe('Sync main-model.txt, cheap-model.txt, and frontier-model.txt'), acknowledgePlaintextPromptRisk: tool.schema .boolean() .optional() diff --git a/src/sync/apply.test.ts b/src/sync/apply.test.ts index fbb7618..d3cc9f8 100644 --- a/src/sync/apply.test.ts +++ b/src/sync/apply.test.ts @@ -99,7 +99,16 @@ describe('safe synchronization', () => { await writeFile(path.join(skills, 'demo', 'run.sh'), '#!/bin/sh\nexit 0\n'); await chmod(path.join(skills, 'demo', 'run.sh'), 0o755); await writeFile(path.join(skills, 'demo', '__pycache__', 'cache.pyc'), 'cache'); - await writeFile(path.join(skills, 'demo', 'SKILL.md:Zone.Identifier'), 'metadata'); + const zoneIdentifierNames = + process.platform === 'win32' + ? [ + `SKILL.md${String.fromCharCode(0xf03a)}Zone.Identifier`, + `SKILL.md${String.fromCharCode(0xff1a)}Zone.Identifier`, + ] + : ['SKILL.md:Zone.Identifier']; + for (const zoneIdentifierName of zoneIdentifierNames) { + await writeFile(path.join(skills, 'demo', zoneIdentifierName), 'metadata'); + } await writeFile(path.join(skills, '.DS_Store'), 'metadata'); const destination = path.join(repo, 'config', 'skills'); @@ -120,13 +129,17 @@ describe('safe synchronization', () => { await expect(lstat(path.join(destination, 'demo', '__pycache__'))).rejects.toMatchObject({ code: 'ENOENT', }); - await expect( - lstat(path.join(destination, 'demo', 'SKILL.md:Zone.Identifier')) - ).rejects.toMatchObject({ code: 'ENOENT' }); + for (const zoneIdentifierName of zoneIdentifierNames) { + await expect( + lstat(path.join(destination, 'demo', zoneIdentifierName)) + ).rejects.toMatchObject({ code: 'ENOENT' }); + } await expect(lstat(path.join(destination, '.DS_Store'))).rejects.toMatchObject({ code: 'ENOENT', }); - expect((await lstat(path.join(destination, 'demo', 'run.sh'))).mode & 0o777).toBe(0o755); + if (process.platform !== 'win32') { + expect((await lstat(path.join(destination, 'demo', 'run.sh'))).mode & 0o777).toBe(0o755); + } }); it('rejects sensitive files in skills', async () => { @@ -377,8 +390,10 @@ describe('safe synchronization', () => { await syncLocalToRepo(plan, null); expect(await readFile(destination, 'utf8')).toBe(content); - expect((await lstat(destination)).mode & 0o777).toBe(0o600); - expect((await lstat(path.dirname(destination))).mode & 0o777).toBe(0o700); + if (process.platform !== 'win32') { + expect((await lstat(destination)).mode & 0o777).toBe(0o600); + expect((await lstat(path.dirname(destination))).mode & 0o777).toBe(0o700); + } }); it('rejects malformed prompt JSONL before it reaches the repository', async () => { diff --git a/src/sync/apply.ts b/src/sync/apply.ts index 39210d6..2838105 100644 --- a/src/sync/apply.ts +++ b/src/sync/apply.ts @@ -501,7 +501,8 @@ function classifySkillPath( ]); if (isDirectory && ignoredDirectories.has(name)) return 'ignore'; if (name.endsWith('.pyc') || name.endsWith('.pyo')) return 'ignore'; - if (name.endsWith(':zone.identifier') || name === '.ds_store') return 'ignore'; + const normalizedMetadataName = name.replace(/[\uF03A\uFF1A]/g, ':'); + if (normalizedMetadataName.endsWith(':zone.identifier') || name === '.ds_store') return 'ignore'; const sensitiveDirectories = new Set(['.gnupg', '.ssh', 'private', 'secrets']); if (lowerSegments.some((segment) => sensitiveDirectories.has(segment))) return 'reject'; diff --git a/src/sync/config.test.ts b/src/sync/config.test.ts index e310aef..0724b4c 100644 --- a/src/sync/config.test.ts +++ b/src/sync/config.test.ts @@ -182,8 +182,10 @@ describe('secure local files', () => { includeSkills: true, }); - expect((await lstat(locations.syncConfigPath)).mode & 0o777).toBe(0o600); - expect((await lstat(path.dirname(locations.syncConfigPath))).mode & 0o777).toBe(0o700); + if (process.platform !== 'win32') { + expect((await lstat(locations.syncConfigPath)).mode & 0o777).toBe(0o600); + expect((await lstat(path.dirname(locations.syncConfigPath))).mode & 0o777).toBe(0o700); + } } finally { await rm(tempDir, { recursive: true, force: true }); } @@ -200,8 +202,10 @@ describe('secure local files', () => { lastPull: 'pull-time', lastPush: 'push-time', }); - expect((await lstat(locations.statePath)).mode & 0o777).toBe(0o600); - expect((await lstat(path.dirname(locations.statePath))).mode & 0o777).toBe(0o700); + if (process.platform !== 'win32') { + expect((await lstat(locations.statePath)).mode & 0o777).toBe(0o600); + expect((await lstat(path.dirname(locations.statePath))).mode & 0o777).toBe(0o700); + } } finally { await rm(tempDir, { recursive: true, force: true }); } diff --git a/src/sync/paths.test.ts b/src/sync/paths.test.ts index bd7de12..ae8b1f5 100644 --- a/src/sync/paths.test.ts +++ b/src/sync/paths.test.ts @@ -1,3 +1,5 @@ +import path from 'node:path'; + import { describe, expect, it } from 'vitest'; import type { SyncConfig } from './config.js'; @@ -8,8 +10,8 @@ describe('resolveXdgPaths', () => { const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; const paths = resolveXdgPaths(env, 'linux'); - expect(paths.configDir).toBe('/home/test/.config'); - expect(paths.dataDir).toBe('/home/test/.local/share'); + expect(paths.configDir).toBe(path.join('/home/test', '.config')); + expect(paths.dataDir).toBe(path.join('/home/test', '.local', 'share')); }); it('resolves windows defaults', () => { @@ -20,8 +22,9 @@ describe('resolveXdgPaths', () => { } as NodeJS.ProcessEnv; const paths = resolveXdgPaths(env, 'win32'); - expect(paths.configDir).toBe('C:\\Users\\Test\\AppData\\Roaming'); - expect(paths.dataDir).toBe('C:\\Users\\Test\\AppData\\Local'); + expect(paths.configDir).toBe('C:\\Users\\Test\\.config'); + expect(paths.dataDir).toBe('C:\\Users\\Test\\.local\\share'); + expect(paths.stateDir).toBe('C:\\Users\\Test\\.local\\state'); }); }); @@ -33,7 +36,7 @@ describe('resolveSyncLocations', () => { } as NodeJS.ProcessEnv; const locations = resolveSyncLocations(env, 'linux'); - expect(locations.configRoot).toBe('/official/opencode'); + expect(locations.configRoot).toBe(path.resolve('/official/opencode')); }); it('respects opencode_config_dir', () => { @@ -43,9 +46,13 @@ describe('resolveSyncLocations', () => { } as NodeJS.ProcessEnv; const locations = resolveSyncLocations(env, 'linux'); - expect(locations.configRoot).toBe('/custom/opencode'); - expect(locations.syncConfigPath).toBe('/custom/opencode/opencode-synced.jsonc'); - expect(locations.overridesPath).toBe('/custom/opencode/opencode-synced.overrides.jsonc'); + expect(locations.configRoot).toBe(path.resolve('/custom/opencode')); + expect(locations.syncConfigPath).toBe( + path.join(path.resolve('/custom/opencode'), 'opencode-synced.jsonc') + ); + expect(locations.overridesPath).toBe( + path.join(path.resolve('/custom/opencode'), 'opencode-synced.overrides.jsonc') + ); }); }); @@ -64,7 +71,10 @@ describe('buildSyncPlan', () => { 'linux' ); - expect(plan.localRoots).toEqual(['/mnt/config/opencode', '/mnt/state/opencode']); + expect(plan.localRoots).toEqual([ + path.join('/mnt/config', 'opencode'), + path.join('/mnt/state', 'opencode'), + ]); }); it('excludes secrets and arbitrary extra paths', () => { @@ -108,7 +118,7 @@ describe('buildSyncPlan', () => { const plan = buildSyncPlan(config, locations, '/repo', 'linux'); const favoritesItem = plan.items.find((item) => - item.localPath.endsWith('/.local/state/opencode/model.json') + item.localPath.endsWith(path.join('.local', 'state', 'opencode', 'model.json')) ); expect(favoritesItem).toBeTruthy(); @@ -120,7 +130,7 @@ describe('buildSyncPlan', () => { 'linux' ); const disabledItem = disabledPlan.items.find((item) => - item.localPath.endsWith('/.local/state/opencode/model.json') + item.localPath.endsWith(path.join('.local', 'state', 'opencode', 'model.json')) ); expect(disabledItem).toBeUndefined(); @@ -135,13 +145,13 @@ describe('buildSyncPlan', () => { }; const disabled = buildSyncPlan(base, locations, '/repo', 'linux'); - expect(disabled.items.some((item) => item.localPath.endsWith('/skills'))).toBe(false); + expect(disabled.items.some((item) => item.localPath.endsWith('skills'))).toBe(false); const enabled = buildSyncPlan({ ...base, includeSkills: true }, locations, '/repo', 'linux'); - const skills = enabled.items.find((item) => item.localPath.endsWith('/skills')); + const skills = enabled.items.find((item) => item.localPath.endsWith('skills')); expect(skills).toMatchObject({ - repoPath: '/repo/config/skills', + repoPath: path.join('/repo', 'config', 'skills'), type: 'dir', strategy: 'skills', }); @@ -164,20 +174,20 @@ describe('buildSyncPlan', () => { expect(promptItems).toEqual( expect.arrayContaining([ expect.objectContaining({ - localPath: '/home/test/.local/state/opencode/prompt-history.jsonl', - repoPath: '/repo/state/prompts/prompt-history.jsonl', + localPath: path.join('/home/test', '.local', 'state', 'opencode', 'prompt-history.jsonl'), + repoPath: path.join('/repo', 'state', 'prompts', 'prompt-history.jsonl'), isSecret: true, }), expect.objectContaining({ - localPath: '/home/test/.local/state/opencode/prompt-stash.jsonl', - repoPath: '/repo/state/prompts/prompt-stash.jsonl', + localPath: path.join('/home/test', '.local', 'state', 'opencode', 'prompt-stash.jsonl'), + repoPath: path.join('/repo', 'state', 'prompts', 'prompt-stash.jsonl'), isSecret: true, }), ]) ); - expect(plan.items.some((item) => item.localPath.endsWith('/auth.json'))).toBe(false); - expect(plan.items.some((item) => item.localPath.endsWith('/mcp-auth.json'))).toBe(false); + expect(plan.items.some((item) => item.localPath.endsWith('auth.json'))).toBe(false); + expect(plan.items.some((item) => item.localPath.endsWith('mcp-auth.json'))).toBe(false); }); it('adds portable model selector files when enabled', () => { @@ -194,12 +204,16 @@ describe('buildSyncPlan', () => { expect(selectors).toEqual( expect.arrayContaining([ expect.objectContaining({ - localPath: '/home/test/.config/opencode/main-model.txt', - repoPath: '/repo/state/model-selectors/main-model.txt', + localPath: path.join('/home/test', '.config', 'opencode', 'main-model.txt'), + repoPath: path.join('/repo', 'state', 'model-selectors', 'main-model.txt'), + }), + expect.objectContaining({ + localPath: path.join('/home/test', '.config', 'opencode', 'cheap-model.txt'), + repoPath: path.join('/repo', 'state', 'model-selectors', 'cheap-model.txt'), }), expect.objectContaining({ - localPath: '/home/test/.config/opencode/cheap-model.txt', - repoPath: '/repo/state/model-selectors/cheap-model.txt', + localPath: path.join('/home/test', '.config', 'opencode', 'frontier-model.txt'), + repoPath: path.join('/repo', 'state', 'model-selectors', 'frontier-model.txt'), }), ]) ); @@ -215,8 +229,8 @@ describe('buildSyncPlan', () => { 'linux' ); - const favorites = plan.items.find((item) => item.localPath.endsWith('/model.json')); + const favorites = plan.items.find((item) => item.localPath.endsWith('model.json')); expect(favorites?.strategy).toBe('model-favorites'); - expect(favorites?.repoPath).toBe('/repo/state/model-favorites.json'); + expect(favorites?.repoPath).toBe(path.join('/repo', 'state', 'model-favorites.json')); }); }); diff --git a/src/sync/paths.ts b/src/sync/paths.ts index a9e5dea..6b3efad 100644 --- a/src/sync/paths.ts +++ b/src/sync/paths.ts @@ -54,7 +54,7 @@ const CONFIG_DIRS = ['agent', 'command', 'mode', 'tool', 'themes', 'plugin']; const MODEL_FAVORITES_FILE = 'model.json'; const PROMPT_HISTORY_FILE = 'prompt-history.jsonl'; const PROMPT_STASH_FILE = 'prompt-stash.jsonl'; -const MODEL_SELECTOR_FILES = ['main-model.txt', 'cheap-model.txt']; +const MODEL_SELECTOR_FILES = ['main-model.txt', 'cheap-model.txt', 'frontier-model.txt']; export function resolveHomeDir( env: NodeJS.ProcessEnv = process.env, @@ -83,10 +83,10 @@ export function resolveXdgPaths( } if (platform === 'win32') { - const configDir = env.APPDATA ?? path.join(homeDir, 'AppData', 'Roaming'); - const dataDir = env.LOCALAPPDATA ?? path.join(homeDir, 'AppData', 'Local'); - // Windows doesn't have XDG_STATE_HOME equivalent, use LOCALAPPDATA - const stateDir = env.LOCALAPPDATA ?? path.join(homeDir, 'AppData', 'Local'); + // This OpenCode installation uses XDG-compatible roots on Windows. + const configDir = env.XDG_CONFIG_HOME ?? path.join(homeDir, '.config'); + const dataDir = env.XDG_DATA_HOME ?? path.join(homeDir, '.local', 'share'); + const stateDir = env.XDG_STATE_HOME ?? path.join(homeDir, '.local', 'state'); return { homeDir, configDir, dataDir, stateDir }; } diff --git a/src/sync/repo.test.ts b/src/sync/repo.test.ts index 270bd23..57e8c19 100644 --- a/src/sync/repo.test.ts +++ b/src/sync/repo.test.ts @@ -21,7 +21,7 @@ describe('normalizeRepoRemote', () => { }); it('normalizes local repository paths', () => { - expect(normalizeRepoRemote('/tmp/example.git')).toBe('local:/tmp/example.git'); + expect(normalizeRepoRemote('/tmp/example.git')).toBe(`local:${path.resolve('/tmp/example.git')}`); }); it('does not equate alternate protocols or ports with canonical GitHub', () => { diff --git a/src/sync/repo.ts b/src/sync/repo.ts index 114e1fe..6808b41 100644 --- a/src/sync/repo.ts +++ b/src/sync/repo.ts @@ -306,7 +306,7 @@ async function resolveRebaseConflictsLocalWins( } try { - await $`env GIT_EDITOR=true git -C ${repoDir} rebase --continue`.quiet(); + await $`git -C ${repoDir} -c core.editor=true rebase --continue`.quiet(); return; } catch { const remaining = await $`git -C ${repoDir} diff --name-only --diff-filter=U -z` diff --git a/src/sync/service.test.ts b/src/sync/service.test.ts index 44c0233..1ad8910 100644 --- a/src/sync/service.test.ts +++ b/src/sync/service.test.ts @@ -24,12 +24,14 @@ describe('SyncService local-wins integration', () => { it('does not wait for TUI to exist when startup sync is not configured', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'opencode-synced-startup-')); tempDirs.push(root); - process.env.HOME = path.join(root, 'home'); - delete process.env.XDG_CONFIG_HOME; - delete process.env.XDG_DATA_HOME; - delete process.env.XDG_STATE_HOME; + const home = path.join(root, 'home'); + process.env.HOME = home; + process.env.USERPROFILE = home; + process.env.XDG_CONFIG_HOME = path.join(root, 'xdg-config'); + process.env.XDG_DATA_HOME = path.join(root, 'xdg-data'); + process.env.XDG_STATE_HOME = path.join(root, 'xdg-state'); delete process.env.opencode_config_dir; - await mkdir(process.env.HOME, { recursive: true }); + await mkdir(home, { recursive: true }); const client = { app: { log: async () => ({}) }, tui: { showToast: () => new Promise(() => {}) }, @@ -53,13 +55,14 @@ describe('SyncService local-wins integration', () => { const remoteWriter = path.join(root, 'remote-writer'); await mkdir(home, { recursive: true }); process.env.HOME = home; - delete process.env.XDG_CONFIG_HOME; - delete process.env.XDG_DATA_HOME; - delete process.env.XDG_STATE_HOME; + process.env.USERPROFILE = home; + process.env.XDG_CONFIG_HOME = path.join(root, 'xdg-config'); + process.env.XDG_DATA_HOME = path.join(root, 'xdg-data'); + process.env.XDG_STATE_HOME = path.join(root, 'xdg-state'); delete process.env.opencode_config_dir; await run('git', ['init', '--bare', origin]); - await run('git', ['clone', origin, seed]); + await run('git', ['-c', 'core.autocrlf=false', 'clone', origin, seed]); await configureGit(seed); await mkdir(path.join(seed, 'config'), { recursive: true }); await writeFile(path.join(seed, 'config', 'AGENTS.md'), 'base-agents\n'); @@ -72,7 +75,7 @@ describe('SyncService local-wins integration', () => { const locations = resolveSyncLocations(); await mkdir(path.dirname(locations.defaultRepoDir), { recursive: true }); - await run('git', ['clone', origin, locations.defaultRepoDir]); + await run('git', ['-c', 'core.autocrlf=false', 'clone', origin, locations.defaultRepoDir]); await configureGit(locations.defaultRepoDir); await mkdir(locations.configRoot, { recursive: true }); await writeFile(path.join(locations.configRoot, 'AGENTS.md'), 'base-agents\n'); @@ -84,7 +87,7 @@ describe('SyncService local-wins integration', () => { await writeFile(path.join(locations.configRoot, 'AGENTS.md'), 'local-agents\n'); - await run('git', ['clone', origin, remoteWriter]); + await run('git', ['-c', 'core.autocrlf=false', 'clone', origin, remoteWriter]); await configureGit(remoteWriter); await writeFile(path.join(remoteWriter, 'config', 'AGENTS.md'), 'remote-agents\n'); await writeFile(path.join(remoteWriter, 'config', 'opencode.json'), '{"theme":"remote"}\n'); @@ -112,7 +115,10 @@ describe('SyncService local-wins integration', () => { const rollbackDirs = await readdir(rollbackBase); expect(rollbackDirs).toHaveLength(1); expect( - await readFile(path.join(rollbackBase, rollbackDirs[0], 'config', 'AGENTS.md'), 'utf8') + (await readFile(path.join(rollbackBase, rollbackDirs[0], 'config', 'AGENTS.md'), 'utf8')).replace( + /\r\n/g, + '\n' + ) ).toBe('remote-agents\n'); expect(await loadState(locations)).toMatchObject({ lastOutcome: 'pushed' }); @@ -144,7 +150,7 @@ describe('SyncService local-wins integration', () => { expect(await readFile(path.join(locations.configRoot, 'AGENTS.md'), 'utf8')).toBe( 'pending-local-agents\n' ); - }); + }, 30_000); it('rejects an existing clone with a different origin', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'opencode-synced-origin-')); @@ -154,16 +160,17 @@ describe('SyncService local-wins integration', () => { const configuredOrigin = path.join(root, 'configured-origin.git'); await mkdir(home, { recursive: true }); process.env.HOME = home; - delete process.env.XDG_CONFIG_HOME; - delete process.env.XDG_DATA_HOME; - delete process.env.XDG_STATE_HOME; + process.env.USERPROFILE = home; + process.env.XDG_CONFIG_HOME = path.join(root, 'xdg-config'); + process.env.XDG_DATA_HOME = path.join(root, 'xdg-data'); + process.env.XDG_STATE_HOME = path.join(root, 'xdg-state'); delete process.env.opencode_config_dir; await run('git', ['init', '--bare', oldOrigin]); await run('git', ['init', '--bare', configuredOrigin]); const locations = resolveSyncLocations(); await mkdir(path.dirname(locations.defaultRepoDir), { recursive: true }); - await run('git', ['clone', oldOrigin, locations.defaultRepoDir]); + await run('git', ['-c', 'core.autocrlf=false', 'clone', oldOrigin, locations.defaultRepoDir]); await writeSyncConfig(locations, { repo: { url: configuredOrigin, branch: 'main' }, includeModelFavorites: false, @@ -208,6 +215,7 @@ function createShell(): PluginInput['$'] { async function configureGit(repo: string): Promise { await run('git', ['-C', repo, 'config', 'user.name', 'Test User']); await run('git', ['-C', repo, 'config', 'user.email', 'test@example.invalid']); + await run('git', ['-C', repo, 'config', 'core.autocrlf', 'false']); } async function gitShow(repo: string, object: string): Promise { From 463fabfab1f2356364c8aa9fa97188940d66f208 Mon Sep 17 00:00:00 2001 From: Tsunami Date: Fri, 21 Aug 2026 14:58:14 +0200 Subject: [PATCH 3/3] test: normalize simulated Windows paths --- src/sync/paths.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sync/paths.test.ts b/src/sync/paths.test.ts index ae8b1f5..8ebf364 100644 --- a/src/sync/paths.test.ts +++ b/src/sync/paths.test.ts @@ -22,9 +22,9 @@ describe('resolveXdgPaths', () => { } as NodeJS.ProcessEnv; const paths = resolveXdgPaths(env, 'win32'); - expect(paths.configDir).toBe('C:\\Users\\Test\\.config'); - expect(paths.dataDir).toBe('C:\\Users\\Test\\.local\\share'); - expect(paths.stateDir).toBe('C:\\Users\\Test\\.local\\state'); + expect(paths.configDir).toBe(path.join('C:\\Users\\Test', '.config')); + expect(paths.dataDir).toBe(path.join('C:\\Users\\Test', '.local', 'share')); + expect(paths.stateDir).toBe(path.join('C:\\Users\\Test', '.local', 'state')); }); });