diff --git a/go.mod b/go.mod index f10b7b0..ddda73e 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,9 @@ toolchain go1.24.12 require ( filippo.io/age v1.3.1 github.com/charmbracelet/huh v0.6.0 + github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3 github.com/charmbracelet/lipgloss v1.1.0 + github.com/fsnotify/fsnotify v1.9.0 github.com/go-git/go-git/v5 v5.16.4 github.com/spf13/cobra v1.8.0 ) @@ -23,7 +25,6 @@ require ( github.com/charmbracelet/bubbles v0.21.0 // indirect github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect @@ -56,7 +57,6 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect diff --git a/go.sum b/go.sum index bfd9a80..ae803ed 100644 --- a/go.sum +++ b/go.sum @@ -21,14 +21,12 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= -github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= -github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c= -github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= @@ -37,20 +35,16 @@ github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWma github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU= github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3 h1:KUeWGoKnmyrLaDIa0smE6pK5eFMZWNIxPGweQR12iLg= github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3/go.mod h1:OMqKat/mm9a/qOnpuNOPyYO9bPzRNnmzLnRZT5KYltg= -github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= -github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk= -github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= -github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= -github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= @@ -69,6 +63,8 @@ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= @@ -110,8 +106,6 @@ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg= -github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= @@ -154,8 +148,6 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbR golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/cli/commands.go b/internal/cli/commands.go index f6b7471..766d69a 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -298,6 +298,65 @@ Useful after many sync operations to keep storage optimized.`, }, } +// watchCmd starts the autosync daemon +var watchCmd = &cobra.Command{ + Use: "watch", + Short: "Start autosync daemon (file watch + periodic sync)", + Long: `Run as a background daemon that syncs automatically. + +Triggers a sync on: + - Startup + - File changes in OpenCode config directory (debounced) + - Periodic interval (default: 5 minutes) + +Typically run via: opencode-sync watch & +Or install as a launchd service: opencode-sync autosync install`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil || cfg == nil { + return fmt.Errorf("no configuration found. Run 'opencode-sync setup' first") + } + + p, err := paths.Get() + if err != nil { + return fmt.Errorf("failed to get paths: %w", err) + } + + watcher := sync.NewWatcher(cfg, runSync, p) + return watcher.Run() + }, +} + +// autosyncCmd manages the autosync launchd service +var autosyncCmd = &cobra.Command{ + Use: "autosync", + Short: "Manage the autosync launchd service (macOS)", +} + +var autosyncInstallCmd = &cobra.Command{ + Use: "install", + Short: "Install autosync as a launchd service", + RunE: func(cmd *cobra.Command, args []string) error { + return runAutosyncInstall() + }, +} + +var autosyncUninstallCmd = &cobra.Command{ + Use: "uninstall", + Short: "Uninstall the autosync launchd service", + RunE: func(cmd *cobra.Command, args []string) error { + return runAutosyncUninstall() + }, +} + +var autosyncStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show autosync launchd service status", + RunE: func(cmd *cobra.Command, args []string) error { + return runAutosyncStatus() + }, +} + func init() { // Add config subcommands configCmd.AddCommand(configShowCmd) @@ -309,6 +368,11 @@ func init() { keyCmd.AddCommand(keyExportCmd) keyCmd.AddCommand(keyImportCmd) keyCmd.AddCommand(keyRegenCmd) + + // Add autosync subcommands + autosyncCmd.AddCommand(autosyncInstallCmd) + autosyncCmd.AddCommand(autosyncUninstallCmd) + autosyncCmd.AddCommand(autosyncStatusCmd) } // Command implementations @@ -1405,3 +1469,95 @@ func runGC() error { ui.Success("Repository optimized!") return nil } + +func runAutosyncInstall() error { + plistName := "com.opencode-sync.autosync" + plistPath := filepath.Join(os.Getenv("HOME"), "Library/LaunchAgents", plistName+".plist") + logDir := filepath.Join(os.Getenv("HOME"), "Library/Logs/opencode-sync") + + binaryPath, err := os.Executable() + if err != nil { + binaryPath = filepath.Join(os.Getenv("HOME"), ".local/bin/opencode-sync") + } + + if err := os.MkdirAll(logDir, 0755); err != nil { + return fmt.Errorf("failed to create log dir: %w", err) + } + + plist := fmt.Sprintf(` + + + + Label + %s + ProgramArguments + + %s + watch + + RunAtLoad + + KeepAlive + + StandardOutPath + %s/autosync.log + StandardErrorPath + %s/autosync.err + + +`, plistName, binaryPath, logDir, logDir) + + if err := os.WriteFile(plistPath, []byte(plist), 0644); err != nil { + return fmt.Errorf("failed to write plist: %w", err) + } + + if err := exec.Command("launchctl", "load", plistPath).Run(); err != nil { + return fmt.Errorf("failed to load launchd service: %w", err) + } + + ui.Success("Autosync service installed and started!") + fmt.Printf(" Plist: %s\n", plistPath) + fmt.Printf(" Logs: %s/autosync.log\n", logDir) + return nil +} + +func runAutosyncUninstall() error { + plistName := "com.opencode-sync.autosync" + plistPath := filepath.Join(os.Getenv("HOME"), "Library/LaunchAgents", plistName+".plist") + + exec.Command("launchctl", "unload", plistPath).Run() + os.Remove(plistPath) + + ui.Success("Autosync service uninstalled") + return nil +} + +func runAutosyncStatus() error { + plistName := "com.opencode-sync.autosync" + plistPath := filepath.Join(os.Getenv("HOME"), "Library/LaunchAgents", plistName+".plist") + logPath := filepath.Join(os.Getenv("HOME"), "Library/Logs/opencode-sync/autosync.log") + + if _, err := os.Stat(plistPath); os.IsNotExist(err) { + ui.Warn("Autosync service is not installed") + return nil + } + + fmt.Printf("Plist: %s\n", plistPath) + + if out, err := exec.Command("launchctl", "list", plistName).CombinedOutput(); err == nil { + fmt.Printf("Status: running\n%s\n", string(out)) + } else { + fmt.Printf("Status: not running\n") + } + + if data, err := os.ReadFile(logPath); err == nil && len(data) > 0 { + lines := string(data) + if idx := len(lines) - 500; idx > 0 { + lines = lines[idx:] + } + fmt.Println("\nRecent logs:") + fmt.Println(lines) + } + + return nil +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 2028303..d756208 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -82,6 +82,8 @@ func init() { rootCmd.AddCommand(rebindCmd) rootCmd.AddCommand(gcCmd) rootCmd.AddCommand(uninstallCmd) + rootCmd.AddCommand(watchCmd) + rootCmd.AddCommand(autosyncCmd) } // runSetupWizard runs the first-time setup wizard diff --git a/internal/config/config.go b/internal/config/config.go index 151c1f7..4eb8d66 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,15 @@ type Config struct { Repo RepoConfig `json:"repo"` Encryption EncryptionConfig `json:"encryption"` Sync SyncConfig `json:"sync"` + Autosync AutosyncConfig `json:"autosync,omitempty"` +} + +// AutosyncConfig holds autosync daemon settings +type AutosyncConfig struct { + Enabled bool `json:"enabled"` + PollInterval string `json:"pollInterval,omitempty"` + DebounceDelay string `json:"debounceDelay,omitempty"` + WatchFiles bool `json:"watchFiles,omitempty"` } // RepoConfig holds Git repository configuration @@ -56,6 +65,12 @@ func Default() *Config { IncludeMcpAuth: false, Exclude: []string{"node_modules", "*.log", "bun.lock"}, }, + Autosync: AutosyncConfig{ + Enabled: false, + PollInterval: "15m", + DebounceDelay: "3s", + WatchFiles: true, + }, } } diff --git a/internal/paths/paths.go b/internal/paths/paths.go index 757d3e2..3790668 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -19,6 +19,9 @@ type Paths struct { // OpenCodeDataDir is where OpenCode stores its data (auth.json, etc.) OpenCodeDataDir string + // OpenCodeSkillsDir is where OpenCode CLI stores skills (~/.opencode/skills/) + OpenCodeSkillsDir string + // ClaudeSkillsDir is where Claude Code stores skills (~/.claude/skills/) ClaudeSkillsDir string } @@ -94,8 +97,11 @@ func (p *Paths) SyncableOpenCodePaths() []string { filepath.Join(p.OpenCodeConfigDir, "mode"), filepath.Join(p.OpenCodeConfigDir, "themes"), filepath.Join(p.OpenCodeConfigDir, "plugin"), + filepath.Join(p.OpenCodeConfigDir, "tools"), + filepath.Join(p.OpenCodeConfigDir, "package.json"), } + paths = append(paths, p.OpenCodeSkillsDir) paths = append(paths, p.ClaudeSkillsDir) return paths diff --git a/internal/paths/paths_unix.go b/internal/paths/paths_unix.go index 71cdcad..526030f 100644 --- a/internal/paths/paths_unix.go +++ b/internal/paths/paths_unix.go @@ -29,6 +29,7 @@ func getPlatformPaths() (*Paths, error) { DataDir: filepath.Join(dataHome, "opencode-sync"), OpenCodeConfigDir: filepath.Join(configHome, "opencode"), OpenCodeDataDir: filepath.Join(dataHome, "opencode"), + OpenCodeSkillsDir: filepath.Join(home, ".opencode", "skills"), ClaudeSkillsDir: filepath.Join(home, ".claude", "skills"), }, nil } diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 49878cb..4918d45 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -110,6 +110,8 @@ func (s *Syncer) CopyToRepo() error { if srcPath == s.paths.ClaudeSkillsDir { dstPath = filepath.Join(s.paths.SyncRepoDir(), "claude-skills") + } else if srcPath == s.paths.OpenCodeSkillsDir { + dstPath = filepath.Join(s.paths.SyncRepoDir(), "opencode-skills") } else { relPath, err = filepath.Rel(s.paths.OpenCodeConfigDir, srcPath) if err != nil { @@ -204,6 +206,12 @@ func (s *Syncer) CopyFromRepo() error { return nil } dstPath = filepath.Join(s.paths.ClaudeSkillsDir, relToClaudeSkills) + } else if strings.HasPrefix(relPath, "opencode-skills"+string(filepath.Separator)) || relPath == "opencode-skills" { + relToOpenCodeSkills, _ := filepath.Rel("opencode-skills", relPath) + if relToOpenCodeSkills == "." { + return nil + } + dstPath = filepath.Join(s.paths.OpenCodeSkillsDir, relToOpenCodeSkills) } else { dstPath = filepath.Join(s.paths.OpenCodeConfigDir, relPath) } diff --git a/internal/sync/watcher.go b/internal/sync/watcher.go new file mode 100644 index 0000000..8e18f73 --- /dev/null +++ b/internal/sync/watcher.go @@ -0,0 +1,136 @@ +package sync + +import ( + "log" + "os" + "os/signal" + "syscall" + "time" + + "github.com/GareArc/opencode-sync/internal/config" + "github.com/GareArc/opencode-sync/internal/paths" + "github.com/fsnotify/fsnotify" +) + +// Watcher runs the autosync daemon +type Watcher struct { + cfg *config.Config + syncFunc func() error + paths *paths.Paths +} + +// NewWatcher creates a new autosync watcher +func NewWatcher(cfg *config.Config, syncFn func() error, p *paths.Paths) *Watcher { + return &Watcher{ + cfg: cfg, + syncFunc: syncFn, + paths: p, + } +} + +// Run starts the autosync daemon. Blocks until signal received. +func (w *Watcher) Run() error { + pollInterval, err := time.ParseDuration(w.cfg.Autosync.PollInterval) + if err != nil || pollInterval <= 0 { + pollInterval = 5 * time.Minute + } + + debounceDelay, err := time.ParseDuration(w.cfg.Autosync.DebounceDelay) + if err != nil || debounceDelay <= 0 { + debounceDelay = 3 * time.Second + } + + watchFiles := w.cfg.Autosync.WatchFiles + + log.Printf("[autosync] starting: poll=%v debounce=%v watchFiles=%v", + pollInterval, debounceDelay, watchFiles) + + // Signal handling + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + + triggerCh := make(chan string, 10) + doneCh := make(chan struct{}) + + // Goroutine 1: periodic ticker + go func() { + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + triggerCh <- "startup" + for { + select { + case <-ticker.C: + triggerCh <- "poll" + case <-doneCh: + return + } + } + }() + + // Goroutine 2: file watcher + if watchFiles { + go func() { + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Printf("[autosync] fsnotify unavailable: %v, falling back to poll-only", err) + return + } + defer watcher.Close() + + opencodeDir := w.paths.OpenCodeConfigDir + if _, err := os.Stat(opencodeDir); err == nil { + if err := watcher.Add(opencodeDir); err != nil { + log.Printf("[autosync] failed to watch %s: %v", opencodeDir, err) + } else { + log.Printf("[autosync] watching %s", opencodeDir) + } + } + + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) || event.Has(fsnotify.Rename) { + triggerCh <- "file" + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + log.Printf("[autosync] watcher error: %v", err) + case <-doneCh: + return + } + } + }() + } + + // Main loop: debounce and execute + var debounceTimer *time.Timer + for { + select { + case reason := <-triggerCh: + log.Printf("[autosync] triggered: %s", reason) + if debounceTimer != nil { + debounceTimer.Stop() + } + debounceTimer = time.AfterFunc(debounceDelay, func() { + log.Println("[autosync] running sync...") + if err := w.syncFunc(); err != nil { + log.Printf("[autosync] sync error: %v", err) + } else { + log.Println("[autosync] sync complete") + } + }) + case <-sigCh: + log.Println("[autosync] received signal, shutting down") + close(doneCh) + if debounceTimer != nil { + debounceTimer.Stop() + } + return nil + } + } +}