From ea3d1e1225e77bb746a87b729751088365295995 Mon Sep 17 00:00:00 2001 From: Dipen Dave Date: Sun, 29 Mar 2026 00:33:52 -0700 Subject: [PATCH] feat: portable settings sync across machines via GitHub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a complete settings sync system that keeps AgentPlex preferences and Claude CLI configuration in sync across machines using a private GitHub repo as the backend. ## Settings System - Expand `settings-manager.ts` from a single `defaultShell` field to a full extensible `AppPreferences` with `getAllSettings()`, `updateSettings()`, and `invalidateCache()` - All user-configurable values now live in `~/.agentplex/settings.json` - Settings are edited via a Monaco JSON editor in the Settings panel - Font size, font family, theme (dark/light), and all other preferences are reactive — changes apply immediately to the terminal and UI ## Sync Engine (`sync-engine.ts`) - Git-based sync using a private GitHub repo (`agentplex-sync`) - One-click setup: detects GitHub auth via `gh` CLI, auto-creates the repo if it doesn't exist, works with GitHub.com and GHE - GitHub login flow built into the UI with device code display - **Auto-sync**: `fs.watch` on `~/.claude/` dirs triggers debounced push (30s); lightweight GitHub API poll with ETags every 5 min for remote changes (304 Not Modified = zero cost) - Conflict resolution via Monaco diff editor (`SyncConflictDialog.tsx`) - Profile support in the backend (folders in sync repo: `default/`, `work/`, etc.) with create/switch/rename/delete — UI hidden for now - Automatic migration from flat repo layout to profile-based folders - `syncClaudeIncludes` setting controls what gets synced from `~/.claude/` (allowlist: CLAUDE.md, settings.json, agents, commands, plugins by default) - Sync config fields preserved during pull (machine-specific fields like `syncRepoUrl` are never overwritten by synced settings) ## UI - Settings panel opens as a floating overlay (no layout shift) - Sync status icon at bottom of ActivityBar with colored dot indicator: green (synced), spinning (syncing), yellow (conflict), red (error) - Sync controls: "Sync Now" button + "Disconnect" - Tooltip on Settings Sync header explaining what gets synced - Terminal font size/family reactive to preferences — Ctrl+/- also persists to settings.json for sync - Theme toggle now persists to settings.json for cross-machine sync ## Testing - Set up vitest with `pnpm test` / `pnpm test:watch` - 56 tests across 2 test files: - `settings-manager.test.ts` (11): load, save, merge, cache, invalidate, extensibility, backward compat, sync config fields - `sync-engine.test.ts` (45): file walking with allowlist, 1MB guard, copy to/from repo, setup (empty/existing), push, pull, disconnect, status, auto-sync, GitHub user parsing (GH + GHE), profiles (list/create/switch/rename/delete/protection), custom syncClaudeIncludes, flat-to-profile migration, sync config preservation - Tests use real git repos in temp directories (no git mocking) - CLAUDE.md updated with TDD guidelines and settings conventions ## New Files - `src/main/sync-engine.ts` — sync engine - `src/main/settings-manager.test.ts` — settings tests - `src/main/sync-engine.test.ts` — sync tests - `src/renderer/components/SettingsPanel.tsx` — JSON editor + sync UI - `src/renderer/components/SyncConflictDialog.tsx` — conflict resolver - `vitest.config.mts` — test configuration Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 35 +- package.json | 5 +- pnpm-lock.yaml | 230 +++++ src/main/ipc-handlers.ts | 78 +- src/main/main.ts | 7 + src/main/settings-manager.test.ts | 180 ++++ src/main/settings-manager.ts | 34 +- src/main/sync-engine.test.ts | 901 ++++++++++++++++++ src/main/sync-engine.ts | 768 +++++++++++++++ src/preload/preload.ts | 88 +- src/renderer/App.tsx | 22 +- src/renderer/components/ActivityBar.tsx | 58 +- src/renderer/components/SettingsPanel.tsx | 316 ++++++ src/renderer/components/SidePanel.tsx | 2 +- .../components/SyncConflictDialog.tsx | 147 +++ src/renderer/hooks/useTerminal.ts | 51 +- src/renderer/store.ts | 15 +- src/renderer/types.ts | 25 +- src/shared/ipc-channels.ts | 41 + vitest.config.mts | 10 + 20 files changed, 2993 insertions(+), 20 deletions(-) create mode 100644 src/main/settings-manager.test.ts create mode 100644 src/main/sync-engine.test.ts create mode 100644 src/main/sync-engine.ts create mode 100644 src/renderer/components/SettingsPanel.tsx create mode 100644 src/renderer/components/SyncConflictDialog.tsx create mode 100644 vitest.config.mts diff --git a/CLAUDE.md b/CLAUDE.md index dfba76e..dc14e69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,8 @@ src/ jsonl-session-watcher.ts # Polls JSONL to detect sub-agent spawns plan-task-detector.ts # Regex-based plan/task extraction from terminal output claude-session-scanner.ts # Discovers sessions from ~/.claude - settings-manager.ts # Persistent user preferences + settings-manager.ts # Persistent user preferences (AppPreferences type) + sync-engine.ts # Git-based settings sync (GitHub repo, profiles) preload/ preload.ts # Context bridge (window.agentPlex API) renderer/ # React app @@ -34,6 +35,8 @@ src/ TerminalPanel.tsx # xterm.js terminal view SendDialog.tsx # Cross-session messaging with optional AI summarization ProjectLauncher.tsx # Modal for discovering & resuming sessions + SettingsPanel.tsx # JSON editor for settings, sync controls, profiles + SyncConflictDialog.tsx # Monaco diff for sync conflict resolution Toolbar.tsx # Top menu bar shared/ # Shared between main & renderer ipc-channels.ts # IPC channel constants & types @@ -54,6 +57,8 @@ src/ ```bash pnpm install # Install dependencies pnpm start # Dev mode with HMR +pnpm test # Run tests (vitest) +pnpm test:watch # Run tests in watch mode pnpm lint # ESLint pnpm package # Package standalone app pnpm make # Build installer (.exe/.dmg/.deb) @@ -66,3 +71,31 @@ pnpm make # Build installer (.exe/.dmg/.deb) - **Plan/Task**: Claude CLI plan mode with individual tasks, parsed from terminal output - **Group**: Container node created by dragging sessions together on the canvas - **External Session**: Claude CLI sessions running outside AgentPlex, discoverable and adoptable +- **Settings Sync**: Git-based sync of preferences + Claude config across machines via a private GitHub repo, with profile support +- **Profile**: A named set of preferences + Claude config (default, work, personal, etc.) stored as folders in the sync repo + +## Development Guidelines + +### Testing (TDD) +- **All new features and bug fixes must have tests written first** (red-green-refactor). +- Test framework: **vitest** (`pnpm test` / `pnpm test:watch`). +- Test files live next to the module they test: `foo.ts` -> `foo.test.ts`. +- Main process code (settings-manager, sync-engine, etc.) is tested with temp directories and mocked `os.homedir()` / `electron`. +- Tests that interact with git use real bare repos in temp dirs — no mocking git itself. +- Run `pnpm test` and `npx tsc --noEmit` before considering any change complete. + +### User-Configurable Settings +- **All user preferences and configurable values must be stored in `~/.agentplex/settings.json`** via `settings-manager.ts` (`updateSettings` / `getAllSettings`). +- Never hardcode user-facing defaults without a settings.json fallback. Read from `loadSettings()` first, fall back to a `DEFAULT_*` constant. +- The `AppPreferences` interface in `settings-manager.ts` uses `[key: string]: unknown` for extensibility — new settings do not require a migration. +- Settings are exposed to the renderer via the JSON editor in the Settings panel. Any new setting automatically appears there. +- Sync-related config (repo URL, active profile, claude include list) also lives in settings.json under `sync*` keys. +- The `syncClaudeIncludes` array controls which files/dirs from `~/.claude/` get synced. Default: `["CLAUDE.md", "settings.json", "agents", "commands", "plugins"]`. + +### IPC Pattern +When adding a new feature that spans main + renderer: +1. Add types to `shared/ipc-channels.ts` +2. Add IPC channel constant to the `IPC` object +3. Add handler in `main/ipc-handlers.ts` +4. Add preload bridge method in `preload/preload.ts` +5. Add type to `AgentPlexAPI` in `renderer/types.ts` diff --git a/package.json b/package.json index 0ea34f5..1cdb6ff 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "scripts": { "generate-icons": "node scripts/generate-icons.mjs", "postinstall": "node -e \"try{require('fs').chmodSync(require('path').join('node_modules','node-pty','prebuilds',process.platform+'-'+process.arch,'spawn-helper'),0o755)}catch{}\"", + "test": "vitest run", + "test:watch": "vitest", "lint": "eslint src/", "start": "electron-forge start", "package": "pnpm generate-icons && electron-forge package", @@ -66,6 +68,7 @@ "sharp": "^0.34.5", "typescript": "^6.0.2", "typescript-eslint": "^8.57.2", - "vite": "^8.0.3" + "vite": "^8.0.3", + "vitest": "^4.1.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 290644f..08623b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,9 @@ importers: vite: specifier: ^8.0.3 version: 8.0.3(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1) + vitest: + specifier: ^4.1.2 + version: 4.1.2(@types/node@25.5.0)(vite@8.0.3(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1)) packages: @@ -757,6 +760,9 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@szmarczak/http-timer@4.0.6': resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} @@ -868,6 +874,9 @@ packages: '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} @@ -886,6 +895,9 @@ packages: '@types/d3-zoom@3.0.8': resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -1011,6 +1023,35 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/expect@4.1.2': + resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==} + + '@vitest/mocker@4.1.2': + resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.2': + resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==} + + '@vitest/runner@4.1.2': + resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==} + + '@vitest/snapshot@4.1.2': + resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==} + + '@vitest/spy@4.1.2': + resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==} + + '@vitest/utils@4.1.2': + resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} + '@vscode/sudo-prompt@9.3.2': resolution: {integrity: sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==} @@ -1165,6 +1206,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types@0.13.4: resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} engines: {node: '>=4'} @@ -1302,6 +1347,10 @@ packages: caniuse-lite@1.0.30001781: resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1652,6 +1701,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -1670,6 +1722,10 @@ packages: resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==} engines: {node: '>=6'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} @@ -2398,6 +2454,9 @@ packages: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2508,6 +2567,9 @@ packages: resolution: {integrity: sha512-dUnb5dXUf+kzhC/W/F4e5/SkluXIFf5VUHolW1Eg1irn1hGWjPGdsRcvYJ1nD6lhk8Ir7VM0bHJKsYTx8Jx9OQ==} engines: {node: '>=4'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pe-library@1.0.1: resolution: {integrity: sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg==} engines: {node: '>=14', npm: '>=7'} @@ -2752,6 +2814,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -2809,9 +2874,15 @@ packages: resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + state-local@1.0.7: resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + streamx@2.25.0: resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} @@ -2911,10 +2982,21 @@ packages: text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -3070,6 +3152,41 @@ packages: yaml: optional: true + vitest@4.1.2: + resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.2 + '@vitest/browser-preview': 4.1.2 + '@vitest/browser-webdriverio': 4.1.2 + '@vitest/ui': 4.1.2 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + watchpack@2.5.1: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} @@ -3109,6 +3226,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -4083,6 +4205,8 @@ snapshots: '@sindresorhus/is@4.6.0': {} + '@standard-schema/spec@1.1.0': {} + '@szmarczak/http-timer@4.0.6': dependencies: defer-to-connect: 2.0.1 @@ -4171,6 +4295,11 @@ snapshots: '@types/node': 25.5.0 '@types/responselike': 1.0.3 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-color@3.1.3': {} '@types/d3-drag@3.0.7': @@ -4192,6 +4321,8 @@ snapshots: '@types/d3-interpolate': 3.0.4 '@types/d3-selection': 3.0.11 + '@types/deep-eql@4.0.2': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -4348,6 +4479,47 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: 8.0.3(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1) + '@vitest/expect@4.1.2': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.2(vite@8.0.3(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1))': + dependencies: + '@vitest/spy': 4.1.2 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.3(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1) + + '@vitest/pretty-format@4.1.2': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.2': + dependencies: + '@vitest/utils': 4.1.2 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.2': + dependencies: + '@vitest/pretty-format': 4.1.2 + '@vitest/utils': 4.1.2 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.2': {} + + '@vitest/utils@4.1.2': + dependencies: + '@vitest/pretty-format': 4.1.2 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@vscode/sudo-prompt@9.3.2': {} '@webassemblyjs/ast@1.14.1': @@ -4531,6 +4703,8 @@ snapshots: argparse@2.0.1: {} + assertion-error@2.0.1: {} + ast-types@0.13.4: dependencies: tslib: 2.8.1 @@ -4667,6 +4841,8 @@ snapshots: caniuse-lite@1.0.30001781: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -5022,6 +5198,10 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} eventemitter3@5.0.4: {} @@ -5044,6 +5224,8 @@ snapshots: signal-exit: 3.0.7 strip-eof: 1.0.0 + expect-type@1.3.0: {} + exponential-backoff@3.1.3: {} external-editor@3.1.0: @@ -5762,6 +5944,8 @@ snapshots: object-keys@1.1.1: optional: true + obug@2.1.1: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -5876,6 +6060,8 @@ snapshots: dependencies: pify: 2.3.0 + pathe@2.0.3: {} + pe-library@1.0.1: {} pend@1.2.0: {} @@ -6165,6 +6351,8 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -6227,8 +6415,12 @@ snapshots: dependencies: minipass: 3.3.6 + stackback@0.0.2: {} + state-local@1.0.7: {} + std-env@4.0.0: {} + streamx@2.25.0: dependencies: events-universal: 1.0.1 @@ -6356,11 +6548,17 @@ snapshots: transitivePeerDependencies: - react-native-b4a + tinybench@2.9.0: {} + + tinyexec@1.0.4: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -6468,6 +6666,33 @@ snapshots: jiti: 2.6.1 terser: 5.46.1 + vitest@4.1.2(@types/node@25.5.0)(vite@8.0.3(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1)): + dependencies: + '@vitest/expect': 4.1.2 + '@vitest/mocker': 4.1.2(vite@8.0.3(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1)) + '@vitest/pretty-format': 4.1.2 + '@vitest/runner': 4.1.2 + '@vitest/snapshot': 4.1.2 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 8.0.3(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.5.0 + transitivePeerDependencies: + - msw + watchpack@2.5.1: dependencies: glob-to-regexp: 0.4.1 @@ -6528,6 +6753,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrap-ansi@6.2.0: diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 9401a2d..f9d45f4 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -4,7 +4,8 @@ import * as path from 'path'; import { IPC, CLI_TOOLS, RESUME_TOOL, type CliTool, type PinnedProject, type DrawingData } from '../shared/ipc-channels'; import { sessionManager } from './session-manager'; import { detectShells, getCachedShells } from './shell-detector'; -import { getDefaultShellId, setDefaultShellId } from './settings-manager'; +import { getDefaultShellId, setDefaultShellId, getAllSettings, updateSettings } from './settings-manager'; +import { setupSync, setupSyncAuto, pushSync, pullSync, disconnectSync, getSyncStatus, getGitHubUser, ghLogin, listProfiles, createProfile, switchProfile, renameProfile, deleteProfile, getActiveProfile } from './sync-engine'; import { scanProjects, scanSessionsForProject, getPinnedProjects, updatePinnedProjects, resolveProjectPath } from './claude-session-scanner'; import { getGitStatus, getFileDiff, saveFile, stageFile, unstageFile, gitCommit, gitPush, gitPull, gitLog, gitBranchInfo } from './git-operations'; @@ -307,6 +308,81 @@ ${safeContext} return gitBranchInfo(status.repoRoot); }); + // ── Settings sync ────────────────────────────────────────────────────────── + + ipcMain.handle(IPC.SYNC_SETUP_AUTO, async () => { + return setupSyncAuto(); + }); + + ipcMain.handle(IPC.SYNC_SETUP, async (_event, { repoUrl }: { repoUrl: string }) => { + if (typeof repoUrl !== 'string' || !repoUrl.trim()) throw new Error('Invalid repo URL'); + return setupSync(repoUrl.trim()); + }); + + ipcMain.handle(IPC.SYNC_GET_GITHUB_USER, async () => { + return getGitHubUser(); + }); + + ipcMain.handle(IPC.SYNC_GH_LOGIN, async (_event, { host }: { host?: string } = {}) => { + return ghLogin(host); + }); + + ipcMain.handle(IPC.SYNC_PUSH, async () => { + return pushSync(); + }); + + ipcMain.handle(IPC.SYNC_PULL, async () => { + return pullSync(); + }); + + ipcMain.handle(IPC.SYNC_DISCONNECT, () => { + return disconnectSync(); + }); + + ipcMain.handle(IPC.SYNC_STATUS, () => { + return getSyncStatus(); + }); + + ipcMain.handle(IPC.SYNC_LIST_PROFILES, () => { + return listProfiles(); + }); + + ipcMain.handle(IPC.SYNC_CREATE_PROFILE, async (_event, { name }: { name: string }) => { + if (typeof name !== 'string' || !name.trim()) throw new Error('Invalid profile name'); + return createProfile(name.trim()); + }); + + ipcMain.handle(IPC.SYNC_SWITCH_PROFILE, async (_event, { name }: { name: string }) => { + if (typeof name !== 'string') throw new Error('Invalid profile name'); + return switchProfile(name); + }); + + ipcMain.handle(IPC.SYNC_RENAME_PROFILE, async (_event, { oldName, newName }: { oldName: string; newName: string }) => { + if (typeof oldName !== 'string' || typeof newName !== 'string' || !newName.trim()) throw new Error('Invalid parameters'); + return renameProfile(oldName, newName.trim()); + }); + + ipcMain.handle(IPC.SYNC_DELETE_PROFILE, async (_event, { name }: { name: string }) => { + if (typeof name !== 'string') throw new Error('Invalid profile name'); + return deleteProfile(name); + }); + + ipcMain.handle(IPC.SYNC_ACTIVE_PROFILE, () => { + return getActiveProfile(); + }); + + ipcMain.handle(IPC.SETTINGS_GET_ALL, () => { + return getAllSettings(); + }); + + ipcMain.handle(IPC.SETTINGS_UPDATE, async (_event, { settings }: { settings: Record }) => { + if (!settings || typeof settings !== 'object') return; + updateSettings(settings); + BrowserWindow.getAllWindows().forEach((w) => { + w.webContents.send(IPC.SETTINGS_CHANGED, getAllSettings()); + }); + }); + // ── Drawing canvas persistence ───────────────────────────────────────────── const canvasDir = path.join(app.getPath('home'), '.agentplex'); const canvasPath = path.join(canvasDir, 'canvas.json'); diff --git a/src/main/main.ts b/src/main/main.ts index 844ffdf..d899778 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import { sessionManager } from './session-manager'; import { registerIpcHandlers } from './ipc-handlers'; import { detectShells } from './shell-detector'; +import { getSyncConfig, startAutoSync, pullSync } from './sync-engine'; declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string; declare const MAIN_WINDOW_VITE_NAME: string; @@ -163,6 +164,12 @@ app.whenReady().then(() => { sessionManager.start(); createWindow(); + // Settings sync: pull on startup + start auto-sync if configured + if (getSyncConfig()) { + pullSync().catch((err) => console.error('[sync] Startup pull failed:', err)); + startAutoSync(); + } + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); diff --git a/src/main/settings-manager.test.ts b/src/main/settings-manager.test.ts new file mode 100644 index 0000000..4fb999b --- /dev/null +++ b/src/main/settings-manager.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// We'll test the settings-manager by pointing it at a temp directory. +// The module uses homedir() internally, so we mock it. +import { vi } from 'vitest'; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentplex-test-')); + vi.doMock('os', async (importOriginal) => { + const orig = await importOriginal(); + return { ...orig, homedir: () => tmpDir }; + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +async function loadModule() { + return await import('./settings-manager'); +} + +describe('settings-manager', () => { + describe('loadSettings', () => { + it('returns empty object when settings file does not exist', async () => { + const mod = await loadModule(); + expect(mod.loadSettings()).toEqual({}); + }); + + it('loads existing settings from disk', async () => { + const dir = path.join(tmpDir, '.agentplex'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'settings.json'), + JSON.stringify({ defaultShell: 'bash', fontSize: 14 }), + ); + + const mod = await loadModule(); + const settings = mod.loadSettings(); + expect(settings.defaultShell).toBe('bash'); + expect(settings.fontSize).toBe(14); + }); + + it('caches settings after first load', async () => { + const dir = path.join(tmpDir, '.agentplex'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'settings.json'), + JSON.stringify({ defaultShell: 'pwsh' }), + ); + + const mod = await loadModule(); + mod.loadSettings(); + + // Mutate file on disk — cached value should still be returned + fs.writeFileSync( + path.join(dir, 'settings.json'), + JSON.stringify({ defaultShell: 'changed' }), + ); + expect(mod.loadSettings().defaultShell).toBe('pwsh'); + }); + }); + + describe('getAllSettings', () => { + it('returns a copy of settings (not the cached reference)', async () => { + const mod = await loadModule(); + const a = mod.getAllSettings(); + const b = mod.getAllSettings(); + expect(a).toEqual(b); + expect(a).not.toBe(b); // different object references + }); + }); + + describe('updateSettings', () => { + it('merges partial settings into existing', async () => { + const dir = path.join(tmpDir, '.agentplex'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'settings.json'), + JSON.stringify({ defaultShell: 'bash', fontSize: 12 }), + ); + + const mod = await loadModule(); + mod.updateSettings({ fontSize: 16, fontFamily: 'Fira Code' }); + + const result = mod.getAllSettings(); + expect(result.defaultShell).toBe('bash'); // preserved + expect(result.fontSize).toBe(16); // updated + expect(result.fontFamily).toBe('Fira Code'); // added + }); + + it('persists to disk', async () => { + const mod = await loadModule(); + mod.updateSettings({ theme: 'light' }); + + const raw = JSON.parse( + fs.readFileSync(path.join(tmpDir, '.agentplex', 'settings.json'), 'utf-8'), + ); + expect(raw.theme).toBe('light'); + }); + + it('creates the directory if it does not exist', async () => { + const mod = await loadModule(); + mod.updateSettings({ fontSize: 14 }); + expect(fs.existsSync(path.join(tmpDir, '.agentplex', 'settings.json'))).toBe(true); + }); + }); + + describe('invalidateCache', () => { + it('forces next loadSettings to read from disk', async () => { + const dir = path.join(tmpDir, '.agentplex'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'settings.json'), + JSON.stringify({ defaultShell: 'bash' }), + ); + + const mod = await loadModule(); + mod.loadSettings(); // populate cache + + // Write new content to disk + fs.writeFileSync( + path.join(dir, 'settings.json'), + JSON.stringify({ defaultShell: 'zsh' }), + ); + + // Without invalidate, cache still returns old value + expect(mod.loadSettings().defaultShell).toBe('bash'); + + // After invalidate, reads fresh from disk + mod.invalidateCache(); + expect(mod.loadSettings().defaultShell).toBe('zsh'); + }); + }); + + describe('sync config fields in settings', () => { + it('stores and retrieves sync repo URL', async () => { + const mod = await loadModule(); + mod.updateSettings({ + syncRepoUrl: 'https://github.com/user/my-settings.git', + syncLastSyncedAt: '2026-03-28T10:00:00Z', + syncAutoSync: true, + }); + + const result = mod.getAllSettings(); + expect(result.syncRepoUrl).toBe('https://github.com/user/my-settings.git'); + expect(result.syncLastSyncedAt).toBe('2026-03-28T10:00:00Z'); + expect(result.syncAutoSync).toBe(true); + }); + + it('handles arbitrary extension keys', async () => { + const mod = await loadModule(); + mod.updateSettings({ customKey: 'customValue', nested: { a: 1 } }); + + const result = mod.getAllSettings(); + expect(result.customKey).toBe('customValue'); + expect(result.nested).toEqual({ a: 1 }); + }); + }); + + describe('backward compat', () => { + it('getDefaultShellId and setDefaultShellId still work', async () => { + const mod = await loadModule(); + expect(mod.getDefaultShellId()).toBeUndefined(); + + mod.setDefaultShellId('gitbash'); + expect(mod.getDefaultShellId()).toBe('gitbash'); + + // Should also be visible via getAllSettings + expect(mod.getAllSettings().defaultShell).toBe('gitbash'); + }); + }); +}); diff --git a/src/main/settings-manager.ts b/src/main/settings-manager.ts index 109ecaa..e4209fb 100644 --- a/src/main/settings-manager.ts +++ b/src/main/settings-manager.ts @@ -2,15 +2,24 @@ import * as fs from 'fs'; import * as path from 'path'; import { homedir } from 'os'; -const SETTINGS_PATH = path.join(homedir(), '.agentplex', 'settings.json'); - -interface AppSettings { +export interface AppPreferences { defaultShell?: string; + fontSize?: number; + fontFamily?: string; + theme?: 'dark' | 'light'; + terminalBellEnabled?: boolean; + editorWordWrap?: 'off' | 'on'; + syncRepoUrl?: string; + syncLastSyncedAt?: string | null; + syncAutoSync?: boolean; + [key: string]: unknown; } -let cached: AppSettings | null = null; +const SETTINGS_PATH = path.join(homedir(), '.agentplex', 'settings.json'); -export function loadSettings(): AppSettings { +let cached: AppPreferences | null = null; + +export function loadSettings(): AppPreferences { if (cached) return cached; try { cached = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8')); @@ -21,7 +30,7 @@ export function loadSettings(): AppSettings { } } -function saveSettings(settings: AppSettings): void { +function saveSettings(settings: AppPreferences): void { cached = settings; try { fs.mkdirSync(path.dirname(SETTINGS_PATH), { recursive: true }); @@ -40,3 +49,16 @@ export function setDefaultShellId(id: string): void { settings.defaultShell = id; saveSettings(settings); } + +export function getAllSettings(): AppPreferences { + return { ...loadSettings() }; +} + +export function updateSettings(partial: Partial): void { + const current = loadSettings(); + saveSettings({ ...current, ...partial }); +} + +export function invalidateCache(): void { + cached = null; +} diff --git a/src/main/sync-engine.test.ts b/src/main/sync-engine.test.ts new file mode 100644 index 0000000..1533764 --- /dev/null +++ b/src/main/sync-engine.test.ts @@ -0,0 +1,901 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +let tmpDir: string; +let agentplexHome: string; +let claudeHome: string; +let syncRepoPath: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentplex-sync-test-')); + agentplexHome = path.join(tmpDir, '.agentplex'); + claudeHome = path.join(tmpDir, '.claude'); + syncRepoPath = path.join(agentplexHome, 'sync-repo'); + + fs.mkdirSync(agentplexHome, { recursive: true }); + fs.mkdirSync(claudeHome, { recursive: true }); + + vi.doMock('os', async (importOriginal) => { + const orig = await importOriginal(); + return { ...orig, homedir: () => tmpDir }; + }); + + // Mock electron BrowserWindow (not available in test) + vi.doMock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, + })); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +async function loadModule() { + return await import('./sync-engine'); +} + +// ── Helpers to set up fixture files ───────────────────────────────────────── + +function writeClaudeFiles() { + // Files that SHOULD be synced (allowlist: CLAUDE.md, settings.json, agents, commands, plugins) + fs.mkdirSync(path.join(claudeHome, 'commands'), { recursive: true }); + fs.writeFileSync(path.join(claudeHome, 'commands', 'deploy.md'), '# Deploy command'); + fs.writeFileSync(path.join(claudeHome, 'commands', 'test.md'), '# Test command'); + + fs.mkdirSync(path.join(claudeHome, 'agents'), { recursive: true }); + fs.writeFileSync(path.join(claudeHome, 'agents', 'reviewer.yml'), 'name: reviewer'); + + fs.mkdirSync(path.join(claudeHome, 'plugins'), { recursive: true }); + fs.writeFileSync(path.join(claudeHome, 'plugins', 'my-plugin.json'), '{}'); + + fs.writeFileSync(path.join(claudeHome, 'CLAUDE.md'), '# My global instructions'); + fs.writeFileSync(path.join(claudeHome, 'settings.json'), '{"model":"opus"}'); + + // Files that should NOT be synced (not in allowlist) + fs.writeFileSync(path.join(claudeHome, '.clauderc'), 'export CLAUDE_CODE=1'); + fs.writeFileSync(path.join(claudeHome, '.credentials'), 'secret-token'); + + fs.mkdirSync(path.join(claudeHome, 'projects', 'some-project'), { recursive: true }); + fs.writeFileSync(path.join(claudeHome, 'projects', 'some-project', 'abc.jsonl'), '{}'); + + fs.mkdirSync(path.join(claudeHome, 'sessions'), { recursive: true }); + fs.writeFileSync(path.join(claudeHome, 'sessions', 'active.json'), '{}'); + + fs.mkdirSync(path.join(claudeHome, 'todos'), { recursive: true }); + fs.writeFileSync(path.join(claudeHome, 'todos', 'tasks.json'), '[]'); + + fs.mkdirSync(path.join(claudeHome, 'sub-agents'), { recursive: true }); + fs.writeFileSync(path.join(claudeHome, 'sub-agents', 'old.yml'), 'name: old'); +} + +function writeAgentplexSettings(settings: Record) { + fs.writeFileSync( + path.join(agentplexHome, 'settings.json'), + JSON.stringify(settings, null, 2), + ); +} + +function initBareGitRepo(): string { + // Create a bare repo to act as our "GitHub remote" + const { execFileSync } = require('child_process'); + const bareDir = path.join(tmpDir, 'remote-repo.git'); + execFileSync('git', ['init', '--bare', bareDir], { windowsHide: true }); + return bareDir; +} + +function cloneRepoAt(remoteUrl: string, destDir: string) { + const { execFileSync } = require('child_process'); + execFileSync('git', ['clone', remoteUrl, destDir], { windowsHide: true }); +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe('sync-engine', () => { + describe('getClaudeFilesToSync', () => { + it('includes CLAUDE.md, settings.json, agents, commands, plugins', async () => { + writeClaudeFiles(); + const mod = await loadModule(); + const files = mod.getClaudeFilesToSync(); + + expect(files).toContain(path.join('commands', 'deploy.md')); + expect(files).toContain(path.join('commands', 'test.md')); + expect(files).toContain(path.join('agents', 'reviewer.yml')); + expect(files).toContain(path.join('plugins', 'my-plugin.json')); + expect(files).toContain('CLAUDE.md'); + expect(files).toContain('settings.json'); + }); + + it('excludes everything not in the allowlist', async () => { + writeClaudeFiles(); + const mod = await loadModule(); + const files = mod.getClaudeFilesToSync(); + + for (const f of files) { + expect(f).not.toMatch(/^projects/); + expect(f).not.toMatch(/^sessions/); + expect(f).not.toMatch(/^todos/); + expect(f).not.toMatch(/^sub-agents/); + expect(f).not.toBe('.credentials'); + expect(f).not.toBe('.clauderc'); + } + }); + + it('excludes files larger than 1MB', async () => { + // CLAUDE.md is in the allowlist, so test with that name + fs.writeFileSync(path.join(claudeHome, 'CLAUDE.md'), 'x'.repeat(1024 * 1024 + 1)); + fs.writeFileSync(path.join(claudeHome, 'settings.json'), '{}'); + + const mod = await loadModule(); + const files = mod.getClaudeFilesToSync(); + + expect(files).toContain('settings.json'); + expect(files).not.toContain('CLAUDE.md'); + }); + + it('returns empty array when ~/.claude does not exist', async () => { + fs.rmSync(claudeHome, { recursive: true, force: true }); + const mod = await loadModule(); + expect(mod.getClaudeFilesToSync()).toEqual([]); + }); + }); + + describe('copyLocalToSyncRepo', () => { + it('copies agentplex settings into active profile folder', async () => { + writeAgentplexSettings({ defaultShell: 'bash', fontSize: 14 }); + fs.mkdirSync(syncRepoPath, { recursive: true }); + + const mod = await loadModule(); + mod.copyLocalToSyncRepo(); + + const dest = path.join(syncRepoPath, 'default', 'agentplex-settings.json'); + expect(fs.existsSync(dest)).toBe(true); + const content = JSON.parse(fs.readFileSync(dest, 'utf-8')); + expect(content.defaultShell).toBe('bash'); + expect(content.fontSize).toBe(14); + }); + + it('copies claude files into profile claude/ subdirectory', async () => { + writeClaudeFiles(); + fs.mkdirSync(syncRepoPath, { recursive: true }); + + const mod = await loadModule(); + mod.copyLocalToSyncRepo(); + + const profileDir = path.join(syncRepoPath, 'default'); + expect(fs.existsSync(path.join(profileDir, 'claude', 'CLAUDE.md'))).toBe(true); + expect(fs.existsSync(path.join(profileDir, 'claude', 'commands', 'deploy.md'))).toBe(true); + expect(fs.existsSync(path.join(profileDir, 'claude', 'agents', 'reviewer.yml'))).toBe(true); + expect(fs.existsSync(path.join(profileDir, 'claude', 'plugins', 'my-plugin.json'))).toBe(true); + + // Non-allowlisted dirs should not appear + expect(fs.existsSync(path.join(profileDir, 'claude', 'projects'))).toBe(false); + expect(fs.existsSync(path.join(profileDir, 'claude', 'sessions'))).toBe(false); + expect(fs.existsSync(path.join(profileDir, 'claude', 'sub-agents'))).toBe(false); + }); + }); + + describe('applySyncRepoToLocal', () => { + it('copies agentplex-settings.json back to ~/.agentplex/settings.json', async () => { + const profileDir = path.join(syncRepoPath, 'default'); + fs.mkdirSync(profileDir, { recursive: true }); + fs.writeFileSync( + path.join(profileDir, 'agentplex-settings.json'), + JSON.stringify({ theme: 'light', fontSize: 16 }), + ); + + const mod = await loadModule(); + mod.applySyncRepoToLocal(); + + const settings = JSON.parse( + fs.readFileSync(path.join(agentplexHome, 'settings.json'), 'utf-8'), + ); + expect(settings.theme).toBe('light'); + expect(settings.fontSize).toBe(16); + }); + + it('copies claude/ files back to ~/.claude without deleting local-only files', async () => { + // Existing local file that is NOT in sync repo + fs.writeFileSync(path.join(claudeHome, 'local-only.txt'), 'keep me'); + + // Sync repo has a command in the profile folder + const profileDir = path.join(syncRepoPath, 'default'); + fs.mkdirSync(path.join(profileDir, 'claude', 'commands'), { recursive: true }); + fs.writeFileSync( + path.join(profileDir, 'claude', 'commands', 'new-cmd.md'), + '# New command', + ); + + const mod = await loadModule(); + mod.applySyncRepoToLocal(); + + // New file should appear + expect(fs.existsSync(path.join(claudeHome, 'commands', 'new-cmd.md'))).toBe(true); + // Local-only file should still exist (additive merge) + expect(fs.existsSync(path.join(claudeHome, 'local-only.txt'))).toBe(true); + }); + }); + + describe('setupSync', () => { + it('clones the repo and performs initial push when repo is empty', async () => { + const bareRepo = initBareGitRepo(); + writeClaudeFiles(); + writeAgentplexSettings({ defaultShell: 'bash' }); + + const mod = await loadModule(); + const result = await mod.setupSync(bareRepo); + + expect(result.status).toBe('idle'); + expect(result.lastSyncedAt).toBeTruthy(); + expect(fs.existsSync(syncRepoPath)).toBe(true); + + // Settings should be persisted with sync config + const config = mod.getSyncConfig(); + expect(config).not.toBeNull(); + expect(config!.syncRepoUrl).toBe(bareRepo); + }); + + it('pulls content when repo already has data', async () => { + const bareRepo = initBareGitRepo(); + const { execFileSync } = require('child_process'); + + // Seed the remote with some data + const seedDir = path.join(tmpDir, 'seed'); + cloneRepoAt(bareRepo, seedDir); + fs.mkdirSync(path.join(seedDir, 'claude', 'commands'), { recursive: true }); + fs.writeFileSync( + path.join(seedDir, 'claude', 'commands', 'remote-cmd.md'), + '# Remote command', + ); + fs.writeFileSync( + path.join(seedDir, 'agentplex-settings.json'), + JSON.stringify({ fontSize: 18 }), + ); + execFileSync('git', ['add', '-A'], { cwd: seedDir, windowsHide: true }); + execFileSync('git', ['commit', '-m', 'seed'], { cwd: seedDir, windowsHide: true }); + execFileSync('git', ['push', 'origin', 'HEAD'], { cwd: seedDir, windowsHide: true }); + + const mod = await loadModule(); + const result = await mod.setupSync(bareRepo); + + expect(result.status).toBe('idle'); + + // Remote command should now exist locally + expect( + fs.existsSync(path.join(claudeHome, 'commands', 'remote-cmd.md')), + ).toBe(true); + }); + }); + + describe('pushSync', () => { + it('returns not-configured when no sync config exists', async () => { + const mod = await loadModule(); + const result = await mod.pushSync(); + expect(result.status).toBe('not-configured'); + }); + + it('commits and pushes local changes', async () => { + const bareRepo = initBareGitRepo(); + writeClaudeFiles(); + writeAgentplexSettings({ defaultShell: 'bash' }); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + + // Add a new file locally + fs.writeFileSync(path.join(claudeHome, 'commands', 'new.md'), '# New'); + + const result = await mod.pushSync(); + expect(result.status).toBe('idle'); + expect(result.lastSyncedAt).toBeTruthy(); + + // Verify the new file is in the remote by cloning fresh + const verifyDir = path.join(tmpDir, 'verify'); + cloneRepoAt(bareRepo, verifyDir); + expect( + fs.existsSync(path.join(verifyDir, 'default', 'claude', 'commands', 'new.md')), + ).toBe(true); + }); + + it('returns idle with no commit when nothing changed', async () => { + const bareRepo = initBareGitRepo(); + writeClaudeFiles(); + writeAgentplexSettings({ defaultShell: 'bash' }); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + + // Push again with no changes + const result = await mod.pushSync(); + expect(result.status).toBe('idle'); + }); + }); + + describe('pullSync', () => { + it('returns not-configured when no sync config exists', async () => { + const mod = await loadModule(); + const result = await mod.pullSync(); + expect(result.status).toBe('not-configured'); + }); + + it('pulls remote changes and applies to local', async () => { + const bareRepo = initBareGitRepo(); + writeClaudeFiles(); + writeAgentplexSettings({ defaultShell: 'bash' }); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + + // Simulate a push from another machine (using profile folder structure) + const { execFileSync } = require('child_process'); + const otherDir = path.join(tmpDir, 'other-machine'); + cloneRepoAt(bareRepo, otherDir); + fs.mkdirSync(path.join(otherDir, 'default', 'claude', 'commands'), { recursive: true }); + fs.writeFileSync( + path.join(otherDir, 'default', 'claude', 'commands', 'from-laptop.md'), + '# From laptop', + ); + execFileSync('git', ['add', '-A'], { cwd: otherDir, windowsHide: true }); + execFileSync('git', ['commit', '-m', 'from laptop'], { cwd: otherDir, windowsHide: true }); + execFileSync('git', ['push', 'origin', 'HEAD'], { cwd: otherDir, windowsHide: true }); + + // Now pull + const result = await mod.pullSync(); + expect(result.status).toBe('idle'); + + // File from "laptop" should now exist locally + expect( + fs.existsSync(path.join(claudeHome, 'commands', 'from-laptop.md')), + ).toBe(true); + }); + }); + + describe('disconnectSync', () => { + it('removes sync config and repo directory', async () => { + const bareRepo = initBareGitRepo(); + writeAgentplexSettings({ defaultShell: 'bash' }); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + + expect(mod.getSyncConfig()).not.toBeNull(); + expect(fs.existsSync(syncRepoPath)).toBe(true); + + mod.disconnectSync(); + + expect(mod.getSyncConfig()).toBeNull(); + expect(fs.existsSync(syncRepoPath)).toBe(false); + }); + }); + + describe('getSyncStatus', () => { + it('returns not-configured when no config', async () => { + const mod = await loadModule(); + expect(mod.getSyncStatus().status).toBe('not-configured'); + }); + + it('returns idle after successful setup', async () => { + const bareRepo = initBareGitRepo(); + writeAgentplexSettings({}); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + + expect(mod.getSyncStatus().status).toBe('idle'); + }); + }); + + describe('auto-sync', () => { + it('startAutoSync returns a stop function', async () => { + const bareRepo = initBareGitRepo(); + writeAgentplexSettings({}); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + + const stop = mod.startAutoSync(); + expect(typeof stop).toBe('function'); + stop(); // should not throw + }); + }); + + describe('getGitHubUser', () => { + it('parses username and host from gh auth status output', async () => { + // Mock child_process to simulate gh auth status output + vi.doMock('child_process', async (importOriginal) => { + const orig = await importOriginal(); + return { + ...orig, + execFile: (cmd: string, args: string[], opts: any, cb: any) => { + if (cmd === 'gh' && args[0] === 'auth' && args[1] === 'status') { + // gh auth status writes to stderr + const proc = { stdout: '', stderr: '' }; + cb(null, { + stdout: '', + stderr: 'github.com\n ✓ Logged in to github.com account testuser (keyring)\n - Active account: true\n', + }); + return proc; + } + return orig.execFile(cmd, args, opts, cb); + }, + }; + }); + + vi.resetModules(); + // Re-mock os and electron after resetModules + vi.doMock('os', async (importOriginal) => { + const orig = await importOriginal(); + return { ...orig, homedir: () => tmpDir }; + }); + vi.doMock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, + })); + + const mod = await import('./sync-engine'); + const user = await mod.getGitHubUser(); + expect(user).not.toBeNull(); + expect(user!.username).toBe('testuser'); + expect(user!.host).toBe('github.com'); + }); + + it('returns null when gh is not authenticated', async () => { + vi.doMock('child_process', async (importOriginal) => { + const orig = await importOriginal(); + return { + ...orig, + execFile: (cmd: string, args: string[], opts: any, cb: any) => { + if (cmd === 'gh') { + cb(new Error('not authenticated'), { stdout: '', stderr: '' }); + return {}; + } + return orig.execFile(cmd, args, opts, cb); + }, + }; + }); + + vi.resetModules(); + vi.doMock('os', async (importOriginal) => { + const orig = await importOriginal(); + return { ...orig, homedir: () => tmpDir }; + }); + vi.doMock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, + })); + + const mod = await import('./sync-engine'); + const user = await mod.getGitHubUser(); + expect(user).toBeNull(); + }); + + it('handles GHE hosts', async () => { + vi.doMock('child_process', async (importOriginal) => { + const orig = await importOriginal(); + return { + ...orig, + execFile: (cmd: string, args: string[], opts: any, cb: any) => { + if (cmd === 'gh' && args[0] === 'auth' && args[1] === 'status') { + cb(null, { + stdout: '', + stderr: 'enterprise.github.com\n ✓ Logged in to enterprise.github.com account jdoe (keyring)\n', + }); + return {}; + } + return orig.execFile(cmd, args, opts, cb); + }, + }; + }); + + vi.resetModules(); + vi.doMock('os', async (importOriginal) => { + const orig = await importOriginal(); + return { ...orig, homedir: () => tmpDir }; + }); + vi.doMock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, + })); + + const mod = await import('./sync-engine'); + const user = await mod.getGitHubUser(); + expect(user).not.toBeNull(); + expect(user!.username).toBe('jdoe'); + expect(user!.host).toBe('enterprise.github.com'); + }); + }); + + describe('SYNC_REPO_NAME', () => { + it('is a fixed constant', async () => { + const mod = await loadModule(); + expect(mod.SYNC_REPO_NAME).toBe('agentplex-sync'); + }); + }); + + describe('profiles', () => { + describe('listProfiles', () => { + it('returns ["default"] when sync repo has no profile folders yet', async () => { + const bareRepo = initBareGitRepo(); + writeAgentplexSettings({}); + writeClaudeFiles(); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + + const profiles = mod.listProfiles(); + expect(profiles).toEqual(['default']); + }); + + it('returns all profile folder names sorted alphabetically', async () => { + const bareRepo = initBareGitRepo(); + writeAgentplexSettings({}); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + + // Create extra profiles by making folders in the sync repo + const repo = path.join(agentplexHome, 'sync-repo'); + fs.mkdirSync(path.join(repo, 'work'), { recursive: true }); + fs.writeFileSync(path.join(repo, 'work', 'agentplex-settings.json'), '{}'); + fs.mkdirSync(path.join(repo, 'personal'), { recursive: true }); + fs.writeFileSync(path.join(repo, 'personal', 'agentplex-settings.json'), '{}'); + + const profiles = mod.listProfiles(); + expect(profiles).toEqual(['default', 'personal', 'work']); + }); + }); + + describe('createProfile', () => { + it('creates a new profile folder by copying from current profile', async () => { + const bareRepo = initBareGitRepo(); + writeAgentplexSettings({ fontSize: 14 }); + writeClaudeFiles(); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + + await mod.createProfile('work'); + + const profiles = mod.listProfiles(); + expect(profiles).toContain('work'); + + // Should have copied settings from default + const repo = path.join(agentplexHome, 'sync-repo'); + const workSettings = JSON.parse( + fs.readFileSync(path.join(repo, 'work', 'agentplex-settings.json'), 'utf-8'), + ); + expect(workSettings.fontSize).toBe(14); + }); + + it('rejects creating a profile named "default"', async () => { + const bareRepo = initBareGitRepo(); + writeAgentplexSettings({}); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + + await expect(mod.createProfile('default')).rejects.toThrow(); + }); + + it('rejects creating a duplicate profile', async () => { + const bareRepo = initBareGitRepo(); + writeAgentplexSettings({}); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + await mod.createProfile('work'); + + await expect(mod.createProfile('work')).rejects.toThrow(); + }); + }); + + describe('switchProfile', () => { + it('auto-pushes current profile before switching', async () => { + const bareRepo = initBareGitRepo(); + writeAgentplexSettings({ fontSize: 12 }); + writeClaudeFiles(); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + await mod.createProfile('work'); + + // Change a local setting + fs.writeFileSync( + path.join(agentplexHome, 'settings.json'), + JSON.stringify({ fontSize: 20 }), + ); + + // Switch to work — should auto-push default first + await mod.switchProfile('work'); + + // Verify default profile in repo has the updated settings + const repo = path.join(agentplexHome, 'sync-repo'); + const defaultSettings = JSON.parse( + fs.readFileSync(path.join(repo, 'default', 'agentplex-settings.json'), 'utf-8'), + ); + expect(defaultSettings.fontSize).toBe(20); + }); + + it('applies the target profile files to local', async () => { + const bareRepo = initBareGitRepo(); + writeAgentplexSettings({ fontSize: 12 }); + writeClaudeFiles(); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + await mod.createProfile('work'); + + // Modify the work profile's settings directly in repo + const repo = path.join(agentplexHome, 'sync-repo'); + fs.writeFileSync( + path.join(repo, 'work', 'agentplex-settings.json'), + JSON.stringify({ fontSize: 18, theme: 'light' }), + ); + + await mod.switchProfile('work'); + + // Local settings should now reflect work profile + const local = JSON.parse( + fs.readFileSync(path.join(agentplexHome, 'settings.json'), 'utf-8'), + ); + expect(local.fontSize).toBe(18); + expect(local.theme).toBe('light'); + }); + + it('updates syncActiveProfile in settings', async () => { + const bareRepo = initBareGitRepo(); + writeAgentplexSettings({}); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + await mod.createProfile('personal'); + await mod.switchProfile('personal'); + + const settings = mod.getActiveProfile(); + expect(settings).toBe('personal'); + }); + + it('rejects switching to a non-existent profile', async () => { + const bareRepo = initBareGitRepo(); + writeAgentplexSettings({}); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + + await expect(mod.switchProfile('nonexistent')).rejects.toThrow(); + }); + }); + + describe('renameProfile', () => { + it('renames a profile folder in the sync repo', async () => { + const bareRepo = initBareGitRepo(); + writeAgentplexSettings({}); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + await mod.createProfile('work'); + + await mod.renameProfile('work', 'office'); + + const profiles = mod.listProfiles(); + expect(profiles).toContain('office'); + expect(profiles).not.toContain('work'); + }); + + it('rejects renaming the default profile', async () => { + const bareRepo = initBareGitRepo(); + writeAgentplexSettings({}); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + + await expect(mod.renameProfile('default', 'main')).rejects.toThrow(); + }); + + it('updates syncActiveProfile if renaming the active profile', async () => { + const bareRepo = initBareGitRepo(); + writeAgentplexSettings({}); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + await mod.createProfile('work'); + await mod.switchProfile('work'); + + await mod.renameProfile('work', 'office'); + + expect(mod.getActiveProfile()).toBe('office'); + }); + }); + + describe('deleteProfile', () => { + it('removes a profile folder from the sync repo', async () => { + const bareRepo = initBareGitRepo(); + writeAgentplexSettings({}); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + await mod.createProfile('temp'); + + await mod.deleteProfile('temp'); + + const profiles = mod.listProfiles(); + expect(profiles).not.toContain('temp'); + }); + + it('rejects deleting the default profile', async () => { + const bareRepo = initBareGitRepo(); + writeAgentplexSettings({}); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + + await expect(mod.deleteProfile('default')).rejects.toThrow(); + }); + + it('switches to default if deleting the active profile', async () => { + const bareRepo = initBareGitRepo(); + writeAgentplexSettings({}); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + await mod.createProfile('work'); + await mod.switchProfile('work'); + + await mod.deleteProfile('work'); + + expect(mod.getActiveProfile()).toBe('default'); + }); + }); + + describe('profile-aware sync', () => { + it('copyLocalToSyncRepo writes into the active profile folder', async () => { + const bareRepo = initBareGitRepo(); + writeAgentplexSettings({ fontSize: 14 }); + writeClaudeFiles(); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + + // Default profile — files should be under default/ + const repo = path.join(agentplexHome, 'sync-repo'); + expect( + fs.existsSync(path.join(repo, 'default', 'agentplex-settings.json')), + ).toBe(true); + expect( + fs.existsSync(path.join(repo, 'default', 'claude', 'CLAUDE.md')), + ).toBe(true); + }); + + it('applySyncRepoToLocal reads from the active profile folder', async () => { + const bareRepo = initBareGitRepo(); + writeAgentplexSettings({}); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + await mod.createProfile('work'); + + // Put specific content in work profile + const repo = path.join(agentplexHome, 'sync-repo'); + fs.mkdirSync(path.join(repo, 'work', 'claude', 'commands'), { recursive: true }); + fs.writeFileSync( + path.join(repo, 'work', 'claude', 'commands', 'work-deploy.md'), + '# Work deploy', + ); + + await mod.switchProfile('work'); + + expect( + fs.existsSync(path.join(claudeHome, 'commands', 'work-deploy.md')), + ).toBe(true); + }); + }); + }); + + describe('configurable syncClaudeIncludes', () => { + it('uses custom includes from settings when set', async () => { + // Write a custom include list that only syncs CLAUDE.md + writeAgentplexSettings({ syncClaudeIncludes: ['CLAUDE.md'] }); + writeClaudeFiles(); + + const mod = await loadModule(); + const files = mod.getClaudeFilesToSync(); + + expect(files).toContain('CLAUDE.md'); + expect(files).not.toContain('settings.json'); + expect(files).not.toContain(path.join('commands', 'deploy.md')); + }); + + it('falls back to defaults when syncClaudeIncludes is empty', async () => { + writeAgentplexSettings({ syncClaudeIncludes: [] }); + writeClaudeFiles(); + + const mod = await loadModule(); + const files = mod.getClaudeFilesToSync(); + + // Should use defaults + expect(files).toContain('CLAUDE.md'); + expect(files).toContain(path.join('commands', 'deploy.md')); + }); + + it('seeds syncClaudeIncludes into settings on first sync setup', async () => { + const bareRepo = initBareGitRepo(); + writeAgentplexSettings({}); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + + // Read settings from disk + const settings = JSON.parse( + fs.readFileSync(path.join(agentplexHome, 'settings.json'), 'utf-8'), + ); + expect(settings.syncClaudeIncludes).toEqual( + ['CLAUDE.md', 'settings.json', 'agents', 'commands', 'plugins'], + ); + }); + }); + + describe('migrateToProfileLayout', () => { + it('moves flat layout files into default/ folder', async () => { + const bareRepo = initBareGitRepo(); + const { execFileSync } = require('child_process'); + + // Seed the remote with flat (legacy) layout + const seedDir = path.join(tmpDir, 'seed'); + cloneRepoAt(bareRepo, seedDir); + fs.writeFileSync( + path.join(seedDir, 'agentplex-settings.json'), + JSON.stringify({ fontSize: 14 }), + ); + fs.mkdirSync(path.join(seedDir, 'claude', 'commands'), { recursive: true }); + fs.writeFileSync(path.join(seedDir, 'claude', 'commands', 'old.md'), '# Old'); + execFileSync('git', ['add', '-A'], { cwd: seedDir, windowsHide: true }); + execFileSync('git', ['commit', '-m', 'flat layout'], { cwd: seedDir, windowsHide: true }); + execFileSync('git', ['push', 'origin', 'HEAD'], { cwd: seedDir, windowsHide: true }); + + // Now setupSync should detect flat layout and migrate + writeAgentplexSettings({}); + const mod = await loadModule(); + const result = await mod.setupSync(bareRepo); + + expect(result.status).toBe('idle'); + + // Verify migration happened — files should be under default/ + const repo = path.join(agentplexHome, 'sync-repo'); + expect( + fs.existsSync(path.join(repo, 'default', 'agentplex-settings.json')), + ).toBe(true); + expect( + fs.existsSync(path.join(repo, 'default', 'claude', 'commands', 'old.md')), + ).toBe(true); + // Flat files should no longer exist at root + expect( + fs.existsSync(path.join(repo, 'agentplex-settings.json')), + ).toBe(false); + + // And the migrated settings should have been applied locally + expect( + fs.existsSync(path.join(claudeHome, 'commands', 'old.md')), + ).toBe(true); + }); + }); + + describe('applySyncRepoToLocal preserves sync config', () => { + it('does not overwrite syncRepoUrl and syncActiveProfile from synced settings', async () => { + const bareRepo = initBareGitRepo(); + writeAgentplexSettings({}); + writeClaudeFiles(); + + const mod = await loadModule(); + await mod.setupSync(bareRepo); + + // Verify sync config fields survive a pull + // The synced agentplex-settings.json won't have syncRepoUrl + // but the local one should retain it after applySyncRepoToLocal + const settingsAfter = JSON.parse( + fs.readFileSync(path.join(agentplexHome, 'settings.json'), 'utf-8'), + ); + expect(settingsAfter.syncRepoUrl).toBe(bareRepo); + expect(settingsAfter.syncActiveProfile).toBe('default'); + }); + }); +}); diff --git a/src/main/sync-engine.ts b/src/main/sync-engine.ts new file mode 100644 index 0000000..7a77632 --- /dev/null +++ b/src/main/sync-engine.ts @@ -0,0 +1,768 @@ +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import * as fs from 'fs'; +import * as path from 'path'; +import { homedir, hostname } from 'os'; +import { BrowserWindow } from 'electron'; +import { invalidateCache, loadSettings, updateSettings } from './settings-manager'; + +const execFileAsync = promisify(execFile); + +export const SYNC_REPO_NAME = 'agentplex-sync'; +const DEFAULT_PROFILE = 'default'; + +// ── Path helpers (all derived from homedir so mocking works in tests) ─────── + +function agentplexHome(): string { return path.join(homedir(), '.agentplex'); } +function syncRepoPath(): string { return path.join(agentplexHome(), 'sync-repo'); } +function claudeHome(): string { return path.join(homedir(), '.claude'); } +function settingsPath(): string { return path.join(agentplexHome(), 'settings.json'); } + +// Default allowlist for ~/.claude/ sync +const DEFAULT_CLAUDE_SYNC_INCLUDES = ['CLAUDE.md', 'settings.json', 'agents', 'commands', 'plugins']; + +function getClaudeSyncIncludes(): Set { + const settings = loadSettings(); + const custom = settings.syncClaudeIncludes; + if (Array.isArray(custom) && custom.length > 0) return new Set(custom as string[]); + return new Set(DEFAULT_CLAUDE_SYNC_INCLUDES); +} + +const MAX_FILE_SIZE = 1024 * 1024; // 1MB + +export interface SyncStatusInfo { + status: 'idle' | 'syncing' | 'conflict' | 'error' | 'not-configured'; + lastSyncedAt: string | null; + error?: string; +} + +let currentStatus: SyncStatusInfo = { status: 'not-configured', lastSyncedAt: null }; +let syncing = false; + +// ── Git helpers ───────────────────────────────────────────────────────────── + +async function git(args: string[], cwd: string): Promise { + const { stdout } = await execFileAsync('git', args, { + cwd, + maxBuffer: 10 * 1024 * 1024, + windowsHide: true, + }); + return stdout; +} + +async function gitMayFail(args: string[], cwd: string): Promise<{ stdout: string; stderr: string; code: number }> { + try { + const { stdout, stderr } = await execFileAsync('git', args, { + cwd, + maxBuffer: 10 * 1024 * 1024, + windowsHide: true, + }); + return { stdout, stderr, code: 0 }; + } catch (err: any) { + return { stdout: err.stdout || '', stderr: err.stderr || '', code: err.code ?? 1 }; + } +} + +// ── Status broadcasting ───────────────────────────────────────────────────── + +function setStatus(status: SyncStatusInfo): void { + currentStatus = status; + try { + BrowserWindow.getAllWindows().forEach((w) => { + w.webContents.send('sync:statusChanged', status); + }); + } catch { /* ok in tests */ } +} + +// ── File walking ──────────────────────────────────────────────────────────── + +function walkDir(root: string, excludes: Set, relativeTo: string = root): string[] { + const results: string[] = []; + if (!fs.existsSync(root)) return results; + + const entries = fs.readdirSync(root, { withFileTypes: true }); + for (const entry of entries) { + const rel = path.relative(relativeTo, path.join(root, entry.name)); + const topLevel = rel.split(path.sep)[0]; + + if (excludes.has(topLevel) || excludes.has(entry.name)) continue; + if (entry.name.startsWith('.git') && entry.name !== '.gitignore') continue; + + if (entry.isDirectory()) { + results.push(...walkDir(path.join(root, entry.name), excludes, relativeTo)); + } else if (entry.isFile()) { + try { + const stat = fs.statSync(path.join(root, entry.name)); + if (stat.size <= MAX_FILE_SIZE) { + results.push(rel); + } + } catch { /* skip unreadable */ } + } + } + return results; +} + +function copyFiles(files: string[], sourceRoot: string, destRoot: string): void { + for (const rel of files) { + const src = path.join(sourceRoot, rel); + const dst = path.join(destRoot, rel); + try { + fs.mkdirSync(path.dirname(dst), { recursive: true }); + fs.copyFileSync(src, dst); + } catch (err: any) { + console.warn(`[sync] Failed to copy ${rel}:`, err.message); + } + } +} + +// ── Profile helpers ───────────────────────────────────────────────────────── + +export function getActiveProfile(): string { + const settings = loadSettings(); + return (settings.syncActiveProfile as string) || DEFAULT_PROFILE; +} + +function profileDir(profile?: string): string { + return path.join(syncRepoPath(), profile ?? getActiveProfile()); +} + +// ── Public API: file helpers ──────────────────────────────────────────────── + +export function getClaudeFilesToSync(): string[] { + const root = claudeHome(); + if (!fs.existsSync(root)) return []; + + const results: string[] = []; + for (const name of getClaudeSyncIncludes()) { + const full = path.join(root, name); + if (!fs.existsSync(full)) continue; + + const stat = fs.statSync(full); + if (stat.isDirectory()) { + // Recursively collect files from this allowed directory + results.push(...walkDir(full, new Set(), root)); + } else if (stat.isFile() && stat.size <= MAX_FILE_SIZE) { + results.push(name); + } + } + return results; +} + +export function copyLocalToSyncRepo(): void { + const dest = profileDir(); + fs.mkdirSync(dest, { recursive: true }); + + // Copy AgentPlex settings + const src = settingsPath(); + if (fs.existsSync(src)) { + fs.copyFileSync(src, path.join(dest, 'agentplex-settings.json')); + } + + // Copy Claude folder selectively + const claudeFiles = getClaudeFilesToSync(); + copyFiles(claudeFiles, claudeHome(), path.join(dest, 'claude')); +} + +export function applySyncRepoToLocal(): void { + const src = profileDir(); + + // Apply AgentPlex settings (preserving sync-config fields) + const syncedSettings = path.join(src, 'agentplex-settings.json'); + if (fs.existsSync(syncedSettings)) { + fs.mkdirSync(agentplexHome(), { recursive: true }); + const synced = JSON.parse(fs.readFileSync(syncedSettings, 'utf-8')); + // Preserve local sync config — these are machine-specific + const current = loadSettings(); + const SYNC_FIELDS = ['syncRepoUrl', 'syncLastSyncedAt', 'syncAutoSync', 'syncActiveProfile']; + for (const key of SYNC_FIELDS) { + if (current[key] !== undefined) synced[key] = current[key]; + } + fs.writeFileSync(settingsPath(), JSON.stringify(synced, null, 2)); + invalidateCache(); + + try { + const newSettings = JSON.parse(fs.readFileSync(settingsPath(), 'utf-8')); + BrowserWindow.getAllWindows().forEach((w) => { + w.webContents.send('settings:changed', newSettings); + }); + } catch { /* ok */ } + } + + // Apply Claude folder + const claudeSrcDir = path.join(src, 'claude'); + if (fs.existsSync(claudeSrcDir)) { + const claudeFiles = walkDir(claudeSrcDir, new Set(), claudeSrcDir); + fs.mkdirSync(claudeHome(), { recursive: true }); + copyFiles(claudeFiles, claudeSrcDir, claudeHome()); + } +} + +// ── GitHub CLI helpers ────────────────────────────────────────────────────── + +export interface GitHubUser { + username: string; + host: string; +} + +export async function getGitHubUser(): Promise { + try { + const { stdout, stderr } = await execFileAsync('gh', ['auth', 'status'], { + windowsHide: true, + timeout: 10_000, + }); + // gh may write to stdout or stderr depending on version + const output = (stdout || '') + (stderr || ''); + const match = output.match(/Logged in to (\S+) account (\S+)/); + if (match) { + return { host: match[1], username: match[2] }; + } + return null; + } catch { + return null; + } +} + +export interface GhLoginProgress { + status: 'code' | 'waiting' | 'success' | 'error'; + code?: string; + error?: string; +} + +export async function ghLogin(host: string = 'github.com'): Promise { + return new Promise((resolve) => { + const child = execFile( + 'gh', + ['auth', 'login', '-h', host, '-p', 'https', '-w'], + { windowsHide: true, timeout: 120_000 }, + (err) => { + if (err) { + resolve({ status: 'error', error: err.message }); + } else { + resolve({ status: 'success' }); + } + }, + ); + + // Capture output for the one-time code + let output = ''; + child.stdout?.on('data', (chunk: string) => { output += chunk; }); + child.stderr?.on('data', (chunk: string) => { output += chunk; }); + + // Broadcast the code once we detect it + const codeInterval = setInterval(() => { + const match = output.match(/one-time code:\s*(\S+-\S+)/i) + || output.match(/code:\s*([A-Z0-9]{4}-[A-Z0-9]{4})/); + if (match) { + clearInterval(codeInterval); + BrowserWindow.getAllWindows().forEach((w) => { + w.webContents.send('sync:ghLoginProgress', { status: 'code', code: match[1] } satisfies GhLoginProgress); + }); + } + }, 200); + + // Cleanup interval when process exits + child.on('exit', () => clearInterval(codeInterval)); + }); +} + +export async function ensureSyncRepo(): Promise { + const user = await getGitHubUser(); + if (!user) throw new Error('Not authenticated with GitHub CLI. Run `gh auth login` first.'); + + const repoFullName = `${user.username}/${SYNC_REPO_NAME}`; + + const { code } = await gitMayFail( + ['ls-remote', `https://${user.host}/${repoFullName}.git`], + process.cwd(), + ); + + if (code !== 0) { + console.log(`[sync] Creating private repo ${repoFullName} on ${user.host}`); + await execFileAsync('gh', [ + 'repo', 'create', SYNC_REPO_NAME, + '--private', + '--description', 'AgentPlex settings sync (auto-created)', + ], { windowsHide: true, timeout: 30_000 }); + } + + return `https://${user.host}/${repoFullName}.git`; +} + +// ── Public API: sync config ───────────────────────────────────────────────── + +export interface SyncConfig { + syncRepoUrl: string; + syncLastSyncedAt: string | null; + syncAutoSync: boolean; +} + +export function getSyncConfig(): SyncConfig | null { + const settings = loadSettings(); + if (!settings.syncRepoUrl) return null; + return { + syncRepoUrl: settings.syncRepoUrl as string, + syncLastSyncedAt: (settings.syncLastSyncedAt as string | null) ?? null, + syncAutoSync: (settings.syncAutoSync as boolean) ?? false, + }; +} + +function saveSyncConfig(repoUrl: string, lastSyncedAt: string | null): void { + const patch: Record = { syncRepoUrl: repoUrl, syncLastSyncedAt: lastSyncedAt }; + // Seed syncClaudeIncludes so it's visible in the JSON editor + if (!loadSettings().syncClaudeIncludes) { + patch.syncClaudeIncludes = DEFAULT_CLAUDE_SYNC_INCLUDES; + } + updateSettings(patch); +} + +// ── Detect default branch ─────────────────────────────────────────────────── + +async function detectDefaultBranch(cwd: string): Promise { + try { + const head = (await git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd)).trim(); + if (head && head !== 'HEAD') return head; + } catch { /* fallback */ } + return 'master'; +} + +// ── Public API: profiles ──────────────────────────────────────────────────── + +export function listProfiles(): string[] { + const repo = syncRepoPath(); + if (!fs.existsSync(repo)) return [DEFAULT_PROFILE]; + + const entries = fs.readdirSync(repo, { withFileTypes: true }); + const profiles = entries + .filter((e) => e.isDirectory() && !e.name.startsWith('.')) + .map((e) => e.name) + .sort(); + + return profiles.length > 0 ? profiles : [DEFAULT_PROFILE]; +} + +export async function createProfile(name: string): Promise { + if (name === DEFAULT_PROFILE) throw new Error('Cannot create a profile named "default"'); + + const dest = profileDir(name); + if (fs.existsSync(dest)) throw new Error(`Profile "${name}" already exists`); + + // Copy current profile's content as the starting point + const currentDir = profileDir(); + if (fs.existsSync(currentDir)) { + const files = walkDir(currentDir, new Set(), currentDir); + copyFiles(files, currentDir, dest); + } else { + fs.mkdirSync(dest, { recursive: true }); + } + + // Commit + const repo = syncRepoPath(); + await git(['add', '-A'], repo); + await git(['commit', '-m', `profile: create "${name}" from "${getActiveProfile()}"`], repo); + await gitMayFail(['push', 'origin', 'HEAD'], repo); +} + +export async function switchProfile(name: string): Promise { + const profiles = listProfiles(); + if (!profiles.includes(name)) throw new Error(`Profile "${name}" does not exist`); + + // Auto-push current profile before switching + copyLocalToSyncRepo(); + const repo = syncRepoPath(); + await git(['add', '-A'], repo); + const { code: diffCode } = await gitMayFail(['diff', '--cached', '--quiet'], repo); + if (diffCode !== 0) { + await git(['commit', '-m', `sync: save "${getActiveProfile()}" before switch to "${name}"`], repo); + await gitMayFail(['push', 'origin', 'HEAD'], repo); + } + + // Update active profile setting + updateSettings({ syncActiveProfile: name }); + + // Apply the target profile's files to local + applySyncRepoToLocal(); +} + +export async function renameProfile(oldName: string, newName: string): Promise { + if (oldName === DEFAULT_PROFILE) throw new Error('Cannot rename the default profile'); + + const repo = syncRepoPath(); + const oldDir = path.join(repo, oldName); + const newDir = path.join(repo, newName); + + if (!fs.existsSync(oldDir)) throw new Error(`Profile "${oldName}" does not exist`); + if (fs.existsSync(newDir)) throw new Error(`Profile "${newName}" already exists`); + + fs.renameSync(oldDir, newDir); + await git(['add', '-A'], repo); + await git(['commit', '-m', `profile: rename "${oldName}" to "${newName}"`], repo); + await gitMayFail(['push', 'origin', 'HEAD'], repo); + + // Update active profile if we renamed the active one + if (getActiveProfile() === oldName) { + updateSettings({ syncActiveProfile: newName }); + } +} + +export async function deleteProfile(name: string): Promise { + if (name === DEFAULT_PROFILE) throw new Error('Cannot delete the default profile'); + + const repo = syncRepoPath(); + const dir = path.join(repo, name); + if (!fs.existsSync(dir)) throw new Error(`Profile "${name}" does not exist`); + + fs.rmSync(dir, { recursive: true, force: true }); + await git(['add', '-A'], repo); + await git(['commit', '-m', `profile: delete "${name}"`], repo); + await gitMayFail(['push', 'origin', 'HEAD'], repo); + + // Switch to default if we deleted the active profile + if (getActiveProfile() === name) { + updateSettings({ syncActiveProfile: DEFAULT_PROFILE }); + applySyncRepoToLocal(); + } +} + +// ── Public API: sync operations ───────────────────────────────────────────── + +export async function setupSyncAuto(): Promise { + try { + const repoUrl = await ensureSyncRepo(); + return setupSync(repoUrl); + } catch (err: any) { + const status: SyncStatusInfo = { status: 'error', lastSyncedAt: null, error: err.message }; + setStatus(status); + return status; + } +} + +export async function setupSync(repoUrl: string): Promise { + if (syncing) return { status: 'error', lastSyncedAt: null, error: 'Sync already in progress' }; + syncing = true; + + try { + const repo = syncRepoPath(); + + // Clean up existing sync-repo if remote differs + if (fs.existsSync(repo)) { + try { + const remote = (await git(['remote', 'get-url', 'origin'], repo)).trim(); + if (remote !== repoUrl) { + fs.rmSync(repo, { recursive: true, force: true }); + } + } catch { + fs.rmSync(repo, { recursive: true, force: true }); + } + } + + if (!fs.existsSync(repo)) { + await execFileAsync('git', ['clone', repoUrl, repo], { + maxBuffer: 10 * 1024 * 1024, + windowsHide: true, + }); + } + + // Ensure active profile is set + if (!loadSettings().syncActiveProfile) { + updateSettings({ syncActiveProfile: DEFAULT_PROFILE }); + } + + // Check if repo has any commits + const { code: logCode } = await gitMayFail(['log', '--oneline', '-1'], repo); + const isEmpty = logCode !== 0; + + if (isEmpty) { + // Empty repo — initial push into default profile folder + copyLocalToSyncRepo(); + await git(['add', '-A'], repo); + const { code: diffCode } = await gitMayFail(['diff', '--cached', '--quiet'], repo); + if (diffCode !== 0) { + await git(['commit', '-m', `sync: initial from ${hostname()}`], repo); + await gitMayFail(['push', 'origin', 'HEAD'], repo); + } + } else { + // Repo has content — check if it uses the profile folder structure + const defaultDir = path.join(repo, DEFAULT_PROFILE); + if (!fs.existsSync(defaultDir)) { + // Legacy flat layout — migrate to profile structure + await migrateToProfileLayout(repo); + } + applySyncRepoToLocal(); + } + + const now = new Date().toISOString(); + saveSyncConfig(repoUrl, now); + const status: SyncStatusInfo = { status: 'idle', lastSyncedAt: now }; + setStatus(status); + return status; + } catch (err: any) { + console.error('[sync] Setup failed:', err.message); + const status: SyncStatusInfo = { status: 'error', lastSyncedAt: null, error: err.message || 'Setup failed' }; + setStatus(status); + return status; + } finally { + syncing = false; + suppressWatcher = true; + setTimeout(() => { suppressWatcher = false; }, 2000); + } +} + +async function migrateToProfileLayout(repo: string): Promise { + console.log('[sync] Migrating flat layout to profile folders'); + const defaultDir = path.join(repo, DEFAULT_PROFILE); + fs.mkdirSync(defaultDir, { recursive: true }); + + // Move agentplex-settings.json and claude/ into default/ + const settingsFile = path.join(repo, 'agentplex-settings.json'); + if (fs.existsSync(settingsFile)) { + fs.renameSync(settingsFile, path.join(defaultDir, 'agentplex-settings.json')); + } + const claudeDir = path.join(repo, 'claude'); + if (fs.existsSync(claudeDir)) { + fs.renameSync(claudeDir, path.join(defaultDir, 'claude')); + } + + await git(['add', '-A'], repo); + const { code } = await gitMayFail(['diff', '--cached', '--quiet'], repo); + if (code !== 0) { + await git(['commit', '-m', 'sync: migrate to profile folder structure'], repo); + await gitMayFail(['push', 'origin', 'HEAD'], repo); + } +} + +export async function pushSync(): Promise { + if (syncing) return { ...currentStatus, error: 'Sync already in progress' }; + const config = getSyncConfig(); + if (!config) return { status: 'not-configured', lastSyncedAt: null }; + + syncing = true; + setStatus({ status: 'syncing', lastSyncedAt: config.syncLastSyncedAt }); + + try { + const repo = syncRepoPath(); + + copyLocalToSyncRepo(); + await git(['add', '-A'], repo); + + const { code: diffCode } = await gitMayFail(['diff', '--cached', '--quiet'], repo); + if (diffCode === 0) { + const status: SyncStatusInfo = { status: 'idle', lastSyncedAt: config.syncLastSyncedAt }; + setStatus(status); + return status; + } + + const timestamp = new Date().toISOString(); + await git(['commit', '-m', `sync: ${timestamp} from ${hostname()}`], repo); + + const branch = await detectDefaultBranch(repo); + const { code: pullCode, stderr: pullErr } = await gitMayFail( + ['pull', '--rebase', 'origin', branch], repo, + ); + + if (pullCode !== 0 && pullErr.includes('CONFLICT')) { + await gitMayFail(['rebase', '--abort'], repo); + const status: SyncStatusInfo = { status: 'conflict', lastSyncedAt: config.syncLastSyncedAt }; + setStatus(status); + return status; + } + + const { code: pushCode, stderr: pushErr } = await gitMayFail(['push', 'origin', 'HEAD'], repo); + if (pushCode !== 0 && !pushErr.includes('->')) { + throw new Error(pushErr || 'Push failed'); + } + + const now = new Date().toISOString(); + saveSyncConfig(config.syncRepoUrl, now); + const status: SyncStatusInfo = { status: 'idle', lastSyncedAt: now }; + setStatus(status); + return status; + } catch (err: any) { + console.error('[sync] Push failed:', err.message); + const status: SyncStatusInfo = { status: 'error', lastSyncedAt: config.syncLastSyncedAt, error: err.message }; + setStatus(status); + return status; + } finally { + syncing = false; + suppressWatcher = true; + setTimeout(() => { suppressWatcher = false; }, 2000); + } +} + +export async function pullSync(): Promise { + if (syncing) return { ...currentStatus, error: 'Sync already in progress' }; + const config = getSyncConfig(); + if (!config) return { status: 'not-configured', lastSyncedAt: null }; + + syncing = true; + setStatus({ status: 'syncing', lastSyncedAt: config.syncLastSyncedAt }); + + try { + const repo = syncRepoPath(); + const branch = await detectDefaultBranch(repo); + + const { code: stashCode } = await gitMayFail(['stash'], repo); + + const { code: pullCode, stderr: pullErr } = await gitMayFail( + ['pull', 'origin', branch], repo, + ); + + if (pullCode !== 0 && pullErr.includes('CONFLICT')) { + const status: SyncStatusInfo = { status: 'conflict', lastSyncedAt: config.syncLastSyncedAt }; + setStatus(status); + return status; + } + + if (pullCode !== 0) { + throw new Error(pullErr || 'Pull failed'); + } + + if (stashCode === 0) { + await gitMayFail(['stash', 'pop'], repo); + } + + applySyncRepoToLocal(); + + const now = new Date().toISOString(); + saveSyncConfig(config.syncRepoUrl, now); + const status: SyncStatusInfo = { status: 'idle', lastSyncedAt: now }; + setStatus(status); + return status; + } catch (err: any) { + console.error('[sync] Pull failed:', err.message); + const status: SyncStatusInfo = { status: 'error', lastSyncedAt: config.syncLastSyncedAt, error: err.message }; + setStatus(status); + return status; + } finally { + syncing = false; + suppressWatcher = true; + setTimeout(() => { suppressWatcher = false; }, 2000); + } +} + +export function getSyncStatus(): SyncStatusInfo { + if (!getSyncConfig()) return { status: 'not-configured', lastSyncedAt: null }; + return currentStatus; +} + +export function disconnectSync(): void { + updateSettings({ syncRepoUrl: undefined, syncLastSyncedAt: undefined, syncAutoSync: undefined, syncActiveProfile: undefined }); + try { fs.rmSync(syncRepoPath(), { recursive: true, force: true }); } catch { /* ok */ } + setStatus({ status: 'not-configured', lastSyncedAt: null }); +} + +// ── Auto-sync ─────────────────────────────────────────────────────────────── + +const AUTO_SYNC_POLL_MS = 5 * 60 * 1000; // 5 minutes +const DEBOUNCE_MS = 30_000; // 30 seconds after last local change + +// Suppress file-watcher triggers while a sync operation is running +let suppressWatcher = false; + +export function startAutoSync(): () => void { + // Ensure syncClaudeIncludes is seeded so it's visible in the JSON editor + if (!loadSettings().syncClaudeIncludes) { + updateSettings({ syncClaudeIncludes: DEFAULT_CLAUDE_SYNC_INCLUDES }); + } + + let pollTimer: ReturnType | null = null; + let debounceTimer: ReturnType | null = null; + const watchers: fs.FSWatcher[] = []; + + function schedulePush() { + // Ignore events triggered by our own sync writes + if (suppressWatcher || syncing) return; + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + pushSync().catch((err) => console.error('[auto-sync] push error:', err)); + }, DEBOUNCE_MS); + } + + // Only watch ~/.claude dirs/files — NOT settings.json (it changes on every sync config update) + try { + const claude = claudeHome(); + if (fs.existsSync(claude)) { + for (const dir of getClaudeSyncIncludes()) { + const full = path.join(claude, dir); + if (!fs.existsSync(full)) continue; + try { + const stat = fs.statSync(full); + if (stat.isDirectory()) { + watchers.push(fs.watch(full, { recursive: true }, schedulePush)); + } + } catch { /* skip */ } + } + watchers.push(fs.watch(claude, (_event, filename) => { + if (filename && getClaudeSyncIncludes().has(filename)) { + schedulePush(); + } + })); + } + } catch { /* ok */ } + + // Poll remote using GitHub API with ETags (304 = no change, nearly free) + pollTimer = setInterval(() => { + checkRemoteAndPull().catch((err) => console.error('[auto-sync] poll error:', err)); + }, AUTO_SYNC_POLL_MS); + + return () => { + if (pollTimer) clearInterval(pollTimer); + if (debounceTimer) clearTimeout(debounceTimer); + for (const w of watchers) { + try { w.close(); } catch { /* ok */ } + } + }; +} + +// ── Lightweight GitHub API poll ───────────────────────────────────────────── + +let lastEtag: string | null = null; + +async function checkRemoteAndPull(): Promise { + if (syncing) return; + + const config = getSyncConfig(); + if (!config) return; + + // Parse owner/repo from the sync URL + const match = config.syncRepoUrl.match(/([^/]+)\/([^/.]+?)(?:\.git)?$/); + if (!match) { + // Fallback to git fetch if URL can't be parsed + await pullSync(); + return; + } + + const [, owner, repo] = match; + + try { + // Use gh api which handles auth automatically, pass ETag for conditional request + const args = ['api', `repos/${owner}/${repo}/commits?per_page=1`, '--include']; + if (lastEtag) { + args.push('-H', `If-None-Match: ${lastEtag}`); + } + + const result = await execFileAsync('gh', args, { + windowsHide: true, + timeout: 15_000, + }).catch((err: any) => ({ stdout: err.stdout || '', stderr: err.stderr || '' })); + + const output = (result as any).stdout || ''; + + // Check for 304 Not Modified + if (output.includes('304 Not Modified') || output.includes('HTTP/2 304')) { + return; // Nothing changed remotely + } + + // Extract ETag from response headers + const etagMatch = output.match(/etag:\s*"?([^"\r\n]+)"?/i); + if (etagMatch) { + lastEtag = etagMatch[1]; + } + + // Remote has new commits — do a pull + console.log('[auto-sync] Remote changed, pulling...'); + await pullSync(); + } catch { + // If gh api fails (not installed, no auth, etc.), fall back to git fetch poll + await pullSync(); + } +} diff --git a/src/preload/preload.ts b/src/preload/preload.ts index d5dab76..d37558c 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -1,6 +1,6 @@ import { contextBridge, ipcRenderer, clipboard } from 'electron'; import { IPC, SessionStatus } from '../shared/ipc-channels'; -import type { CliTool, DetectedShell, SessionInfo, SubagentInfo, PlanInfo, TaskInfo, TaskUpdateInfo, TaskListInfo, ExternalSession, DiscoveredProject, DiscoveredSession, PinnedProject, GitStatusResult, GitFileDiffResult, GitLogEntry, GitBranchInfo, GitCommandResult, DrawingData } from '../shared/ipc-channels'; +import type { CliTool, DetectedShell, SessionInfo, SubagentInfo, PlanInfo, TaskInfo, TaskUpdateInfo, TaskListInfo, ExternalSession, DiscoveredProject, DiscoveredSession, PinnedProject, GitStatusResult, GitFileDiffResult, GitLogEntry, GitBranchInfo, GitCommandResult, DrawingData, AppPreferences, SyncStatusInfo } from '../shared/ipc-channels'; const api = { platform: process.platform, @@ -232,6 +232,92 @@ const api = { canvasSave: (data: DrawingData): Promise => { return ipcRenderer.invoke(IPC.CANVAS_SAVE, data); }, + + // ── Settings sync ────────────────────────────────────────────────────────── + + syncSetupAuto: (): Promise => { + return ipcRenderer.invoke(IPC.SYNC_SETUP_AUTO); + }, + + syncSetup: (repoUrl: string): Promise => { + return ipcRenderer.invoke(IPC.SYNC_SETUP, { repoUrl }); + }, + + syncGetGitHubUser: (): Promise<{ username: string; host: string } | null> => { + return ipcRenderer.invoke(IPC.SYNC_GET_GITHUB_USER); + }, + + syncGhLogin: (host?: string): Promise<{ status: string; code?: string; error?: string }> => { + return ipcRenderer.invoke(IPC.SYNC_GH_LOGIN, { host }); + }, + + onGhLoginProgress: (callback: (progress: { status: string; code?: string }) => void): (() => void) => { + const handler = (_event: Electron.IpcRendererEvent, payload: { status: string; code?: string }) => callback(payload); + ipcRenderer.on(IPC.SYNC_GH_LOGIN_PROGRESS, handler); + return () => ipcRenderer.removeListener(IPC.SYNC_GH_LOGIN_PROGRESS, handler); + }, + + syncPush: (): Promise => { + return ipcRenderer.invoke(IPC.SYNC_PUSH); + }, + + syncPull: (): Promise => { + return ipcRenderer.invoke(IPC.SYNC_PULL); + }, + + syncDisconnect: (): Promise => { + return ipcRenderer.invoke(IPC.SYNC_DISCONNECT); + }, + + syncStatus: (): Promise => { + return ipcRenderer.invoke(IPC.SYNC_STATUS); + }, + + syncListProfiles: (): Promise => { + return ipcRenderer.invoke(IPC.SYNC_LIST_PROFILES); + }, + + syncCreateProfile: (name: string): Promise => { + return ipcRenderer.invoke(IPC.SYNC_CREATE_PROFILE, { name }); + }, + + syncSwitchProfile: (name: string): Promise => { + return ipcRenderer.invoke(IPC.SYNC_SWITCH_PROFILE, { name }); + }, + + syncRenameProfile: (oldName: string, newName: string): Promise => { + return ipcRenderer.invoke(IPC.SYNC_RENAME_PROFILE, { oldName, newName }); + }, + + syncDeleteProfile: (name: string): Promise => { + return ipcRenderer.invoke(IPC.SYNC_DELETE_PROFILE, { name }); + }, + + syncActiveProfile: (): Promise => { + return ipcRenderer.invoke(IPC.SYNC_ACTIVE_PROFILE); + }, + + onSyncStatusChanged: (callback: (status: SyncStatusInfo) => void): (() => void) => { + const handler = (_event: Electron.IpcRendererEvent, payload: SyncStatusInfo) => callback(payload); + ipcRenderer.on(IPC.SYNC_STATUS_CHANGED, handler); + return () => ipcRenderer.removeListener(IPC.SYNC_STATUS_CHANGED, handler); + }, + + // ── Expanded settings ────────────────────────────────────────────────────── + + getAllSettings: (): Promise => { + return ipcRenderer.invoke(IPC.SETTINGS_GET_ALL); + }, + + updateSettings: (settings: Partial): Promise => { + return ipcRenderer.invoke(IPC.SETTINGS_UPDATE, { settings }); + }, + + onSettingsChanged: (callback: (settings: AppPreferences) => void): (() => void) => { + const handler = (_event: Electron.IpcRendererEvent, payload: AppPreferences) => callback(payload); + ipcRenderer.on(IPC.SETTINGS_CHANGED, handler); + return () => ipcRenderer.removeListener(IPC.SETTINGS_CHANGED, handler); + }, }; contextBridge.exposeInMainWorld('agentPlex', api); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3f94836..2808423 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -7,6 +7,7 @@ import { SendDialog } from './components/SendDialog'; import { ProjectLauncher } from './components/ProjectLauncher'; import { ActivityBar } from './components/ActivityBar'; import { SidePanel } from './components/SidePanel'; +import { SettingsPanel } from './components/SettingsPanel'; import { useAppStore } from './store'; import { SessionStatus } from '../shared/ipc-channels'; import './types'; @@ -40,6 +41,8 @@ export function App() { const updateTask = useAppStore((s) => s.updateTask); const reconcileTasks = useAppStore((s) => s.reconcileTasks); const prevStatuses = useRef>(new Map()); + const updateSyncStatus = useAppStore((s) => s.updateSyncStatus); + const updatePreferences = useAppStore((s) => s.updatePreferences); const renameSession = useAppStore((s) => s.renameSession); @@ -104,6 +107,15 @@ export function App() { window.addEventListener('mouseup', onUp); }, [setSidePanelWidth]); + // Load initial settings and sync status + useEffect(() => { + window.agentPlex.getAllSettings().then(updatePreferences); + window.agentPlex.syncStatus().then(updateSyncStatus); + const cleanupSync = window.agentPlex.onSyncStatusChanged(updateSyncStatus); + const cleanupSettings = window.agentPlex.onSettingsChanged(updatePreferences); + return () => { cleanupSync(); cleanupSettings(); }; + }, [updateSyncStatus, updatePreferences]); + // Reconnect to existing sessions on mount (e.g. after renderer reload/crash) // and restore persisted Claude sessions from state.json useEffect(() => { @@ -222,7 +234,7 @@ export function App() {
- {activePanelId && ( + {activePanelId && activePanelId !== 'settings' && ( <>
+ {activePanelId === 'settings' && ( +
+
+ +
+
useAppStore.getState().togglePanel('settings')} /> +
+ )} {sendDialogSourceId && } {launcherOpen && }
diff --git a/src/renderer/components/ActivityBar.tsx b/src/renderer/components/ActivityBar.tsx index e602abd..3fcc447 100644 --- a/src/renderer/components/ActivityBar.tsx +++ b/src/renderer/components/ActivityBar.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { FolderOpen, Search, Pencil, Eraser, Square, Type, Undo2, Redo2, Trash2, Palette, Sun, Moon } from 'lucide-react'; +import { FolderOpen, Search, RefreshCw, Pencil, Eraser, Square, Type, Undo2, Redo2, Trash2, Palette, Sun, Moon } from 'lucide-react'; import { useAppStore, type PanelId } from '../store'; const PANELS: { id: PanelId; icon: typeof FolderOpen }[] = [ @@ -13,11 +13,49 @@ const PRESET_COLORS = [ ]; function getInitialTheme(): 'dark' | 'light' { + // Prefer synced preference, fall back to localStorage + const prefs = useAppStore.getState().preferences; + if (prefs.theme === 'dark' || prefs.theme === 'light') return prefs.theme; const saved = localStorage.getItem('agentplex-theme'); if (saved === 'dark' || saved === 'light') return saved; return 'dark'; } +function SyncButton({ btnBase, btnInactive }: { btnBase: string; btnInactive: string }) { + const syncStatus = useAppStore((s) => s.syncStatus); + const activePanelId = useAppStore((s) => s.activePanelId); + const togglePanel = useAppStore((s) => s.togglePanel); + const isActive = activePanelId === 'settings'; + + const status = syncStatus.status; + const isSyncing = status === 'syncing'; + + let dotColor = ''; + let title = 'Settings'; + if (status === 'idle') { dotColor = 'bg-green-400'; title = 'Synced'; } + else if (status === 'syncing') { dotColor = ''; title = 'Syncing...'; } + else if (status === 'conflict') { dotColor = 'bg-yellow-400'; title = 'Sync conflict'; } + else if (status === 'error') { dotColor = 'bg-red-400'; title = 'Sync error'; } + + return ( +
+ +
+ ); +} + export function ActivityBar() { const activePanelId = useAppStore((s) => s.activePanelId); const togglePanel = useAppStore((s) => s.togglePanel); @@ -30,10 +68,18 @@ export function ActivityBar() { const canUndo = useAppStore((s) => s._drawCanUndo); const canRedo = useAppStore((s) => s._drawCanRedo); const hasElements = useAppStore((s) => s._drawHasElements); + const prefsTheme = useAppStore((s) => s.preferences.theme); const [theme, setTheme] = useState<'dark' | 'light'>(getInitialTheme); const [showColorPicker, setShowColorPicker] = useState(false); const colorPickerRef = useRef(null); + // Sync theme from preferences (e.g. after pull from another machine) + useEffect(() => { + if (prefsTheme && prefsTheme !== theme) { + setTheme(prefsTheme as 'dark' | 'light'); + } + }, [prefsTheme]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('agentplex-theme', theme); @@ -41,7 +87,12 @@ export function ActivityBar() { }, [theme]); const toggleTheme = useCallback(() => { - setTheme((t) => (t === 'dark' ? 'light' : 'dark')); + setTheme((prev) => { + const next = prev === 'dark' ? 'light' : 'dark'; + // Persist to synced preferences + window.agentPlex.updateSettings({ theme: next }); + return next; + }); }, []); // Close color picker on outside click @@ -217,7 +268,7 @@ export function ActivityBar() { )}
-
+
+
); } diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx new file mode 100644 index 0000000..605c587 --- /dev/null +++ b/src/renderer/components/SettingsPanel.tsx @@ -0,0 +1,316 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import Editor, { loader } from '@monaco-editor/react'; +import { GitBranchPlus, RefreshCw, Unplug, Check, AlertTriangle, Loader2, HelpCircle } from 'lucide-react'; +import { useAppStore } from '../store'; +import type { SyncStatusInfo } from '../../shared/ipc-channels'; + +loader.config({ paths: { vs: 'node_modules/monaco-editor/min/vs' } }); + +function SyncStatusBadge({ status }: { status: SyncStatusInfo['status'] }) { + switch (status) { + case 'idle': + return Synced; + case 'syncing': + return Syncing...; + case 'conflict': + return Conflict; + case 'error': + return Error; + case 'not-configured': + return Not configured; + } +} + +export function SettingsPanel() { + const syncStatus = useAppStore((s) => s.syncStatus); + const updatePreferences = useAppStore((s) => s.updatePreferences); + const updateSyncStatus = useAppStore((s) => s.updateSyncStatus); + + const [ghUser, setGhUser] = useState<{ username: string; host: string } | null>(null); + const [ghChecking, setGhChecking] = useState(true); + const [connecting, setConnecting] = useState(false); + const [loggingIn, setLoggingIn] = useState(false); + const [loginCode, setLoginCode] = useState(null); + const [loginHost, setLoginHost] = useState('github.com'); + + // JSON editor state + const [jsonText, setJsonText] = useState('{}'); + const [jsonError, setJsonError] = useState(null); + const saveTimerRef = useRef | null>(null); + const localEditRef = useRef(false); // true while the editor has a pending save + + const refreshSettings = useCallback(async () => { + const s = await window.agentPlex.getAllSettings(); + updatePreferences(s); + setJsonText(JSON.stringify(s, null, 2)); + setJsonError(null); + }, [updatePreferences]); + + // Load on mount + useEffect(() => { + refreshSettings(); + window.agentPlex.syncStatus().then(updateSyncStatus); + window.agentPlex.syncGetGitHubUser().then((u) => { + setGhUser(u); + setGhChecking(false); + }); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + return window.agentPlex.onSyncStatusChanged(updateSyncStatus); + }, [updateSyncStatus]); + + useEffect(() => { + return window.agentPlex.onSettingsChanged((s) => { + updatePreferences(s); + // Only update editor text if WE didn't initiate the change (avoids feedback loop) + if (!localEditRef.current) { + setJsonText(JSON.stringify(s, null, 2)); + setJsonError(null); + } + }); + }, [updatePreferences]); + + // Auto-save JSON edits with debounce + const handleJsonChange = useCallback((value: string | undefined) => { + if (!value) return; + setJsonText(value); + localEditRef.current = true; + + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(() => { + try { + const parsed = JSON.parse(value); + setJsonError(null); + window.agentPlex.updateSettings(parsed); + updatePreferences(parsed); + } catch { + setJsonError('Invalid JSON'); + } + localEditRef.current = false; + }, 800); + }, [updatePreferences]); + + const handleConnect = useCallback(async () => { + setConnecting(true); + try { + const result = await window.agentPlex.syncSetupAuto(); + updateSyncStatus(result); + if (result.status === 'idle') { + await refreshSettings(); + } + } finally { + setConnecting(false); + } + }, [updateSyncStatus, refreshSettings]); + + const handleDisconnect = useCallback(async () => { + await window.agentPlex.syncDisconnect(); + updateSyncStatus({ status: 'not-configured', lastSyncedAt: null }); + }, [updateSyncStatus]); + + const handleSync = useCallback(async () => { + let result = await window.agentPlex.syncPull(); + updateSyncStatus(result); + if (result.status === 'idle') { + await refreshSettings(); + result = await window.agentPlex.syncPush(); + updateSyncStatus(result); + } + }, [updateSyncStatus, refreshSettings]); + + const isConfigured = syncStatus.status !== 'not-configured'; + const isSyncing = syncStatus.status === 'syncing'; + + const inputCls = 'w-full px-2 py-1 bg-inset border border-border rounded text-sm text-fg focus:outline-none focus:border-accent'; + const btnCls = 'px-3 py-1.5 rounded text-xs font-medium transition-colors cursor-pointer'; + const btnPrimary = `${btnCls} bg-accent text-fg hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed`; + const btnDanger = `${btnCls} bg-red-900/30 text-red-400 hover:bg-red-900/50 disabled:opacity-40`; + + return ( +
+
+

Settings

+
+ +
+ {/* ── JSON Editor ─────────────────────────────────────────── */} +
+
+

+ settings.json +

+ {jsonError && ( + {jsonError} + )} +
+
+ +
+
+ +
+ {/* ── Settings Sync ───────────────────────────────────────── */} +
+
+

+ Settings Sync +

+
+ +
+

Syncs across machines:

+
    +
  • AgentPlex preferences
  • +
  • ~/.claude/ files configured in syncClaudeIncludes
  • +
+

Session history, projects, and credentials are never synced. Edit syncClaudeIncludes above to customize.

+
+
+
+ + {!isConfigured ? ( +
+

+ Sync preferences and Claude config across machines via a private GitHub repo. + A repo named agentplex-sync will be + auto-created on your GitHub account. +

+ + {ghChecking ? ( +
+ Checking GitHub CLI... +
+ ) : ghUser ? ( +
+
+ + {ghUser.username} + on {ghUser.host} +
+ +
+ ) : loggingIn ? ( +
+ {loginCode ? ( + <> +

+ A browser window should have opened. Enter this code: +

+
+ + {loginCode} + +
+

+ Waiting for browser authentication... +

+ + ) : ( +

+ Opening browser... +

+ )} +
+ ) : ( +
+
+ + setLoginHost(e.target.value)} + className={inputCls} + /> +
+ +
+ )} + + {syncStatus.error && ( +

+ {syncStatus.error} +

+ )} +
+ ) : ( +
+
+ + {syncStatus.lastSyncedAt && ( + + {new Date(syncStatus.lastSyncedAt).toLocaleString()} + + )} +
+ + {syncStatus.error && ( +

+ {syncStatus.error} +

+ )} + +
+ + +
+
+ )} +
+
+
+
+ ); +} diff --git a/src/renderer/components/SidePanel.tsx b/src/renderer/components/SidePanel.tsx index d4ad7b4..c8b1d2f 100644 --- a/src/renderer/components/SidePanel.tsx +++ b/src/renderer/components/SidePanel.tsx @@ -11,7 +11,7 @@ export function SidePanel() { const activePanelId = useAppStore((s) => s.activePanelId); const sidePanelWidth = useAppStore((s) => s.sidePanelWidth); - if (!activePanelId) return null; + if (!activePanelId || activePanelId === 'settings') return null; const title = PANEL_TITLES[activePanelId] || activePanelId; diff --git a/src/renderer/components/SyncConflictDialog.tsx b/src/renderer/components/SyncConflictDialog.tsx new file mode 100644 index 0000000..f4a895c --- /dev/null +++ b/src/renderer/components/SyncConflictDialog.tsx @@ -0,0 +1,147 @@ +import { useState, useCallback } from 'react'; +import { DiffEditor, loader } from '@monaco-editor/react'; +import { X, ChevronLeft, ChevronRight, Check } from 'lucide-react'; +import { useAppStore } from '../store'; +import type { SyncConflictFile } from '../../shared/ipc-channels'; + +loader.config({ paths: { vs: 'node_modules/monaco-editor/min/vs' } }); + +interface Props { + conflicts: SyncConflictFile[]; + onClose: () => void; +} + +export function SyncConflictDialog({ conflicts, onClose }: Props) { + const updateSyncStatus = useAppStore((s) => s.updateSyncStatus); + const [index, setIndex] = useState(0); + const [resolutions, setResolutions] = useState>({}); + const [resolving, setResolving] = useState(false); + + const current = conflicts[index]; + const allResolved = conflicts.every((c) => resolutions[c.path]); + + const handleKeepMine = useCallback(() => { + setResolutions((r) => ({ ...r, [current.path]: { resolution: 'ours' } })); + }, [current]); + + const handleKeepTheirs = useCallback(() => { + setResolutions((r) => ({ ...r, [current.path]: { resolution: 'theirs' } })); + }, [current]); + + const handleResolveAll = useCallback(async () => { + setResolving(true); + try { + for (const conflict of conflicts) { + const res = resolutions[conflict.path]; + if (!res) continue; + // For now, resolve via individual calls. Could batch in future. + // The sync engine auto-commits+pushes after all conflicts are resolved. + await (window as any).agentPlex.syncResolveConflict?.({ + path: conflict.path, + resolution: res.resolution, + manualContent: res.content, + }); + } + const status = await window.agentPlex.syncStatus(); + updateSyncStatus(status); + onClose(); + } catch (err: any) { + console.error('[sync-conflict] resolve error:', err); + } finally { + setResolving(false); + } + }, [conflicts, resolutions, updateSyncStatus, onClose]); + + if (!current) return null; + + const resolved = resolutions[current.path]; + + return ( +
+
+ {/* Header */} +
+
+

Sync Conflicts

+ + {index + 1} / {conflicts.length} + +
+ +
+ + {/* File name */} +
+
+ + {current.path} + +
+ {resolved && ( + + + {resolved.resolution === 'ours' ? 'Keeping yours' : resolved.resolution === 'theirs' ? 'Keeping theirs' : 'Manual edit'} + + )} +
+ + {/* Diff editor */} +
+ +
+ + {/* Actions */} +
+
+ Local (left) vs Remote (right) + + +
+ +
+
+
+ ); +} diff --git a/src/renderer/hooks/useTerminal.ts b/src/renderer/hooks/useTerminal.ts index baf3399..67d960d 100644 --- a/src/renderer/hooks/useTerminal.ts +++ b/src/renderer/hooks/useTerminal.ts @@ -29,23 +29,67 @@ const TERMINAL_THEME = { }; const DEFAULT_FONT_SIZE = 14; +const DEFAULT_FONT_FAMILY = 'MesloLGS Nerd Font Mono, Menlo, Monaco, Cascadia Code, Consolas, monospace'; const MIN_FONT_SIZE = 8; const MAX_FONT_SIZE = 32; let terminalFontSize = DEFAULT_FONT_SIZE; +function getPreferredFontSize(): number { + const prefs = useAppStore.getState().preferences; + return (prefs.fontSize as number) || DEFAULT_FONT_SIZE; +} + +function getPreferredFontFamily(): string { + const prefs = useAppStore.getState().preferences; + return (prefs.fontFamily as string) || DEFAULT_FONT_FAMILY; +} + export function useTerminal(containerRef: React.RefObject) { const selectedSessionId = useAppStore((s) => s.selectedSessionId); + const prefsFontSize = useAppStore((s) => s.preferences.fontSize as number | undefined); + const prefsFontFamily = useAppStore((s) => s.preferences.fontFamily as string | undefined); const termRef = useRef(null); const fitAddonRef = useRef(null); + // Apply preference changes to existing terminal + useEffect(() => { + const term = termRef.current; + const fitAddon = fitAddonRef.current; + if (!term || !fitAddon) return; + + const newSize = getPreferredFontSize(); + const newFamily = getPreferredFontFamily(); + let changed = false; + + if (term.options.fontSize !== newSize) { + term.options.fontSize = newSize; + terminalFontSize = newSize; + changed = true; + } + if (term.options.fontFamily !== newFamily) { + term.options.fontFamily = newFamily; + changed = true; + } + if (changed) { + try { + fitAddon.fit(); + if (selectedSessionId) { + window.agentPlex.resizeSession(selectedSessionId, term.cols, term.rows); + } + } catch { /* ignore */ } + } + }, [prefsFontSize, prefsFontFamily, selectedSessionId]); + useEffect(() => { if (!containerRef.current || !selectedSessionId) return; + terminalFontSize = getPreferredFontSize(); + // Create terminal const term = new Terminal({ theme: TERMINAL_THEME, fontSize: terminalFontSize, - fontFamily: 'MesloLGS Nerd Font Mono, Menlo, Monaco, Cascadia Code, Consolas, monospace', + fontFamily: getPreferredFontFamily(), cursorBlink: true, convertEol: true, }); @@ -107,13 +151,16 @@ export function useTerminal(containerRef: React.RefObject } else if (e.key === '-') { newSize = Math.max(terminalFontSize - 2, MIN_FONT_SIZE); } else if (e.key === '0') { - newSize = DEFAULT_FONT_SIZE; + newSize = getPreferredFontSize(); } else { return true; } if (newSize !== terminalFontSize) { terminalFontSize = newSize; term.options.fontSize = newSize; + // Persist to settings so the panel and sync stay in sync + window.agentPlex.updateSettings({ fontSize: newSize }); + useAppStore.setState((s) => ({ preferences: { ...s.preferences, fontSize: newSize } })); try { fitAddon.fit(); window.agentPlex.resizeSession(sessionId_, term.cols, term.rows); diff --git a/src/renderer/store.ts b/src/renderer/store.ts index 24f7dbd..06c3b22 100644 --- a/src/renderer/store.ts +++ b/src/renderer/store.ts @@ -7,7 +7,7 @@ import { applyNodeChanges, applyEdgeChanges, } from '@xyflow/react'; -import { SessionStatus, type SessionInfo, type CliTool } from '../shared/ipc-channels'; +import { SessionStatus, type SessionInfo, type CliTool, type SyncStatusInfo, type AppPreferences } from '../shared/ipc-channels'; import type { SubAgentNodeData } from './components/SubAgentNode'; function getAccentColor(): string { @@ -55,7 +55,7 @@ interface SubagentEntry { spawnedAt: number; } -export type PanelId = 'explorer' | 'search'; +export type PanelId = 'explorer' | 'search' | 'settings'; export interface AppState { nodes: Node[]; @@ -132,6 +132,12 @@ export interface AppState { setDrawTool: (tool: 'pen' | 'eraser' | 'rect' | 'text') => void; setDrawColor: (color: string) => void; + // Settings sync + syncStatus: SyncStatusInfo; + preferences: AppPreferences; + updateSyncStatus: (status: SyncStatusInfo) => void; + updatePreferences: (prefs: AppPreferences) => void; + // Imperative handles set by DrawingOverlay (not part of public API) _drawUndo?: () => void; _drawRedo?: () => void; @@ -183,6 +189,11 @@ export const useAppStore = create((set, get) => ({ set({ sidePanelWidth: Math.max(160, Math.min(400, width)) }); }, + syncStatus: { status: 'not-configured', lastSyncedAt: null } as SyncStatusInfo, + preferences: {} as AppPreferences, + updateSyncStatus: (status: SyncStatusInfo) => set({ syncStatus: status }), + updatePreferences: (prefs: AppPreferences) => set({ preferences: prefs }), + drawingMode: false, drawTool: 'pen' as const, drawColor: '#d18a7a', diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 587fae8..a334c01 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -1,4 +1,4 @@ -import type { CliTool, DetectedShell, SessionInfo, SessionStatus, SubagentInfo, PlanInfo, TaskInfo, TaskUpdateInfo, TaskListInfo, ExternalSession, DiscoveredProject, DiscoveredSession, PinnedProject, GitStatusResult, GitFileDiffResult, GitLogEntry, GitBranchInfo, GitCommandResult, DrawingData } from '../shared/ipc-channels'; +import type { CliTool, DetectedShell, SessionInfo, SessionStatus, SubagentInfo, PlanInfo, TaskInfo, TaskUpdateInfo, TaskListInfo, ExternalSession, DiscoveredProject, DiscoveredSession, PinnedProject, GitStatusResult, GitFileDiffResult, GitLogEntry, GitBranchInfo, GitCommandResult, DrawingData, AppPreferences, SyncStatusInfo } from '../shared/ipc-channels'; export interface AgentPlexAPI { platform: string; @@ -49,6 +49,29 @@ export interface AgentPlexAPI { gitBranchInfo: (sessionId: string) => Promise; canvasLoad: () => Promise; canvasSave: (data: DrawingData) => Promise; + + // Settings sync + syncSetupAuto: () => Promise; + syncSetup: (repoUrl: string) => Promise; + syncGetGitHubUser: () => Promise<{ username: string; host: string } | null>; + syncGhLogin: (host?: string) => Promise<{ status: string; code?: string; error?: string }>; + onGhLoginProgress: (callback: (progress: { status: string; code?: string }) => void) => () => void; + syncPush: () => Promise; + syncPull: () => Promise; + syncDisconnect: () => Promise; + syncStatus: () => Promise; + syncListProfiles: () => Promise; + syncCreateProfile: (name: string) => Promise; + syncSwitchProfile: (name: string) => Promise; + syncRenameProfile: (oldName: string, newName: string) => Promise; + syncDeleteProfile: (name: string) => Promise; + syncActiveProfile: () => Promise; + onSyncStatusChanged: (callback: (status: SyncStatusInfo) => void) => () => void; + + // Expanded settings + getAllSettings: () => Promise; + updateSettings: (settings: Partial) => Promise; + onSettingsChanged: (callback: (settings: AppPreferences) => void) => () => void; } declare global { diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 1a599a4..6c9f64c 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -130,6 +130,24 @@ export interface GitCommandResult { output: string; } +// ── Settings sync types ───────────────────────────────────────────────────── + +export type { AppPreferences } from '../main/settings-manager'; +export type { SyncConfig, SyncStatusInfo } from '../main/sync-engine'; + +export interface SyncConflictFile { + path: string; + ours: string; + theirs: string; + language: string; +} + +export interface SyncConflictResolution { + path: string; + resolution: 'ours' | 'theirs' | 'manual'; + manualContent?: string; +} + // ── Drawing canvas types ───────────────────────────────────────────────────── export interface DrawingElement { @@ -205,4 +223,27 @@ export const IPC = { GIT_BRANCH_INFO: 'git:branchInfo', CANVAS_LOAD: 'canvas:load', CANVAS_SAVE: 'canvas:save', + + // Settings sync + SYNC_SETUP_AUTO: 'sync:setupAuto', + SYNC_SETUP: 'sync:setup', + SYNC_GET_GITHUB_USER: 'sync:getGitHubUser', + SYNC_GH_LOGIN: 'sync:ghLogin', + SYNC_GH_LOGIN_PROGRESS: 'sync:ghLoginProgress', + SYNC_DISCONNECT: 'sync:disconnect', + SYNC_PUSH: 'sync:push', + SYNC_PULL: 'sync:pull', + SYNC_STATUS: 'sync:status', + SYNC_LIST_PROFILES: 'sync:listProfiles', + SYNC_CREATE_PROFILE: 'sync:createProfile', + SYNC_SWITCH_PROFILE: 'sync:switchProfile', + SYNC_RENAME_PROFILE: 'sync:renameProfile', + SYNC_DELETE_PROFILE: 'sync:deleteProfile', + SYNC_ACTIVE_PROFILE: 'sync:activeProfile', + SYNC_STATUS_CHANGED: 'sync:statusChanged', + + // Expanded settings + SETTINGS_GET_ALL: 'settings:getAll', + SETTINGS_UPDATE: 'settings:update', + SETTINGS_CHANGED: 'settings:changed', } as const; diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..4b373f0 --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + testTimeout: 10_000, + }, +});