Skip to content

Commit f2347bd

Browse files
Implement Windows autostart fix and self-update feature (#105)
* Implement Windows autostart fix and self-update feature - Added a new documentation file for the Windows autostart fix specification. - Enhanced the autostart functionality to handle WindowsApps aliases and improve command-line argument formatting. - Updated the autostart status command to check if the server process is running and log any startup errors. - Introduced unit tests for Windows-specific command-line building and path extraction. - Implemented a self-update command that allows users to check for and apply updates directly from the command line. - Added error handling and logging improvements during the background process startup. - Created a new updater package to manage downloading and verifying updates from GitHub. - Added tests for the updater functionality, including version comparison and checksum verification. * fix: update .gitignore to include .kimchi and remove obsolete documentation files * feat: add update command to routatic-proxy for automatic version management * feat: enhance User-Agent handling in updater for GitHub API requests * fix: replace ping with timeout for backup deletion in Windows --------- Signed-off-by: TUYIZERE Samuel <tuyizeres0@gmail.com>
1 parent 142b836 commit f2347bd

11 files changed

Lines changed: 1136 additions & 4 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ oc-go-cc
1111
dist/
1212
brag-output-**
1313
.DS_Store
14+
.kimchi

INSTALLATION.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,24 @@ docker run -d --restart unless-stopped --name routatic-proxy --env-file .env -p
9191
- An [OpenCode Go](https://opencode.ai/auth) subscription and API key
9292
- Go 1.21+ (only needed if building from source)
9393
- Docker (only needed for Docker setup)
94+
95+
## Updating
96+
97+
If you installed via `go install` or downloaded a release binary directly, you can self-update with the built-in command:
98+
99+
```bash
100+
# See whether a newer release is available without changing anything
101+
routatic-proxy update --check
102+
103+
# Download, verify checksum, and replace the running binary in place
104+
routatic-proxy update
105+
106+
# Skip the confirmation prompt (useful in scripts)
107+
routatic-proxy update --yes
108+
```
109+
110+
The updater queries the [routatic/proxy releases on GitHub](https://github.com/routatic/proxy/releases), picks the asset that matches your OS/arch, verifies its SHA256 against `checksums.txt` when available, and writes a `.old` backup of the previous binary next to the running executable before replacing it. On Windows the `.old` backup is scheduled for deletion after the process exits because the running executable is locked until then.
111+
112+
A `dev` build (e.g. when compiled from source without a version tag) refuses to update unless you pass `--force`.
113+
114+
If you installed via **Homebrew** (`brew upgrade routatic-proxy`) or **Scoop** (`scoop update routatic-proxy`), prefer your package manager — it tracks the same releases and handles uninstall/reinstall cleanly.

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ OpenCode Go gives you access to powerful open coding models for **$5/month** (th
5252
- **Hot Reload** — Watch config file for changes and reload automatically (off by default)
5353
- **Background Mode** — Run as daemon detached from terminal
5454
- **Auto-start on Login** — Launch on system startup via launchd (macOS)
55+
- **Self-Update** — Check and install the latest release with one command
5556

5657
## Supported Models
5758

@@ -168,6 +169,9 @@ routatic-proxy models List all available models (Go, Zen, Bedrock)
168169
routatic-proxy autostart enable Enable auto-start on login
169170
routatic-proxy autostart disable Disable auto-start on login
170171
routatic-proxy autostart status Check autostart status
172+
routatic-proxy update Update to the latest release
173+
routatic-proxy update --check Show if an update is available
174+
routatic-proxy update --yes Update without prompting
171175
routatic-proxy --version Show version
172176
```
173177

cmd/routatic-proxy/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ Legacy ~/.config/oc-go-cc/config.json and OC_GO_CC_* environment variables are s
4949
rootCmd.AddCommand(checkCmd())
5050
rootCmd.AddCommand(modelsCmd())
5151
rootCmd.AddCommand(autostartCmd())
52+
rootCmd.AddCommand(updateCmd())
5253
addPlatformCommands(rootCmd)
5354

5455
if err := rootCmd.Execute(); err != nil {

cmd/routatic-proxy/update.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/routatic/proxy/internal/daemon"
8+
"github.com/routatic/proxy/internal/updater"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
// updateCmd returns the Cobra command that updates routatic-proxy to the
13+
// latest GitHub release.
14+
func updateCmd() *cobra.Command {
15+
var (
16+
checkOnly bool
17+
yes bool
18+
force bool
19+
skipChecksum bool
20+
)
21+
22+
cmd := &cobra.Command{
23+
Use: "update",
24+
Short: "Update routatic-proxy to the latest release",
25+
Long: `Check GitHub for the latest routatic-proxy release and, if a newer
26+
version is available, download the matching asset for this OS/arch,
27+
verify its SHA256 checksum, and replace the running binary in place.
28+
29+
A .old backup of the previous binary is written next to the running
30+
executable on every platform. On Windows the backup is scheduled for
31+
deletion after the process exits because the running executable is
32+
locked until then.
33+
34+
If the current binary reports its version as "dev" (e.g. when built
35+
from source without a version tag) the command refuses to update
36+
unless --force is passed.`,
37+
RunE: func(cmd *cobra.Command, args []string) error {
38+
ctx := cmd.Context()
39+
40+
info, err := updater.Check(ctx)
41+
if err != nil {
42+
return err
43+
}
44+
45+
if checkOnly {
46+
needs, err := updater.NeedsUpdate(version, info.TagName, false)
47+
if err != nil {
48+
return err
49+
}
50+
if needs {
51+
fmt.Printf("Update available: %s -> %s\n", version, info.TagName)
52+
} else {
53+
fmt.Printf("Already up to date (%s)\n", version)
54+
}
55+
return nil
56+
}
57+
58+
needs, err := updater.NeedsUpdate(version, info.TagName, force)
59+
if err != nil {
60+
return err
61+
}
62+
if !needs {
63+
fmt.Printf("Already up to date (%s)\n", version)
64+
return nil
65+
}
66+
67+
if !yes {
68+
fmt.Printf("Update %s -> %s? [y/N] ", version, info.TagName)
69+
var resp string
70+
if _, err := fmt.Scanln(&resp); err != nil {
71+
return fmt.Errorf("aborted")
72+
}
73+
if strings.ToLower(strings.TrimSpace(resp)) != "y" {
74+
return fmt.Errorf("update cancelled")
75+
}
76+
}
77+
78+
currentPath, err := daemon.FindBinary()
79+
if err != nil {
80+
return fmt.Errorf("cannot locate current binary: %w", err)
81+
}
82+
83+
result, err := updater.Apply(ctx, updater.Options{
84+
CurrentVersion: version,
85+
CurrentBinaryPath: currentPath,
86+
Force: force,
87+
SkipChecksum: skipChecksum,
88+
})
89+
if err != nil {
90+
return err
91+
}
92+
93+
if !result.Updated {
94+
fmt.Printf("Already up to date (%s)\n", version)
95+
return nil
96+
}
97+
98+
fmt.Printf("Updated %s -> %s\n", result.OldVersion, result.NewVersion)
99+
fmt.Printf("New binary: %s\n", result.NewPath)
100+
if result.BackupPath != "" {
101+
fmt.Printf("Backup: %s\n", result.BackupPath)
102+
}
103+
return nil
104+
},
105+
}
106+
107+
cmd.Flags().BoolVarP(&checkOnly, "check", "c", false, "Only check for updates; do not install")
108+
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip the confirmation prompt")
109+
cmd.Flags().BoolVarP(&force, "force", "f", false, "Update even if already on the latest version (required when current version is 'dev')")
110+
cmd.Flags().BoolVar(&skipChecksum, "skip-checksum", false, "Skip SHA256 checksum verification of the downloaded asset")
111+
112+
return cmd
113+
}

internal/daemon/autostart_windows.go

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ package daemon
55
import (
66
"fmt"
77
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"strings"
811

912
"golang.org/x/sys/windows/registry"
1013
)
@@ -15,16 +18,27 @@ const (
1518
)
1619

1720
func buildAutostartArgs(configPath string, port int) string {
18-
args := `"serve" "--background"`
21+
args := "serve --background"
1922
if configPath != "" {
20-
args += ` "--config" "` + configPath + `"`
23+
args += ` --config "` + configPath + `"`
2124
}
2225
if port != 0 {
23-
args += ` "--port" "` + fmt.Sprintf("%d", port) + `"`
26+
args += fmt.Sprintf(" --port %d", port)
2427
}
2528
return args
2629
}
2730

31+
// registryBinaryRef returns the string to use as the binary token in the
32+
// registry Run value. If binaryPath lives inside a WindowsApps alias directory,
33+
// the bare executable name is returned so Windows resolves it via PATH at
34+
// login. Otherwise the quoted absolute path is returned.
35+
func registryBinaryRef(binaryPath string) string {
36+
if strings.Contains(strings.ToLower(binaryPath), strings.ToLower(`\WindowsApps\`)) {
37+
return filepath.Base(binaryPath)
38+
}
39+
return `"` + binaryPath + `"`
40+
}
41+
2842
// EnableAutostart adds a registry Run key so routatic-proxy starts on login.
2943
func EnableAutostart(configPath string, port int) error {
3044
paths, err := DefaultPaths()
@@ -41,7 +55,12 @@ func EnableAutostart(configPath string, port int) error {
4155
}
4256
defer func() { _ = key.Close() }()
4357

44-
value := `"` + paths.BinaryPath + `" ` + buildAutostartArgs(configPath, port)
58+
binRef := registryBinaryRef(paths.BinaryPath)
59+
if err := validateBinaryRef(binRef); err != nil {
60+
return err
61+
}
62+
63+
value := binRef + " " + buildAutostartArgs(configPath, port)
4564
if err := key.SetStringValue(registryValue, value); err != nil {
4665
return fmt.Errorf("cannot set registry value: %w", err)
4766
}
@@ -51,6 +70,23 @@ func EnableAutostart(configPath string, port int) error {
5170
return nil
5271
}
5372

73+
func validateBinaryRef(binRef string) error {
74+
// Bare executable name: verify it can be found on PATH.
75+
if !strings.ContainsAny(binRef, `\/`) {
76+
if _, err := exec.LookPath(binRef); err != nil {
77+
return fmt.Errorf("cannot find %s on PATH: %w", binRef, err)
78+
}
79+
return nil
80+
}
81+
82+
// Quoted absolute path: strip quotes and verify the file exists.
83+
path := strings.Trim(binRef, `"`)
84+
if _, err := os.Stat(path); err != nil {
85+
return fmt.Errorf("cannot access binary at %s: %w", path, err)
86+
}
87+
return nil
88+
}
89+
5490
// DisableAutostart removes the registry Run key.
5591
func DisableAutostart() error {
5692
key, err := registry.OpenKey(registry.CURRENT_USER, registryRunKey, registry.SET_VALUE|registry.QUERY_VALUE)
@@ -76,6 +112,11 @@ func DisableAutostart() error {
76112

77113
// AutostartStatus reports whether autostart is enabled.
78114
func AutostartStatus() error {
115+
paths, err := DefaultPaths()
116+
if err != nil {
117+
return err
118+
}
119+
79120
key, err := registry.OpenKey(registry.CURRENT_USER, registryRunKey, registry.READ)
80121
if err != nil {
81122
fmt.Println("Autostart: disabled (cannot read registry)")
@@ -91,6 +132,12 @@ func AutostartStatus() error {
91132

92133
// Verify the binary still exists at the recorded path
93134
binPath := extractBinaryPath(val)
135+
if !strings.ContainsAny(binPath, `\/`) {
136+
// Bare executable name (used for WindowsApps aliases) — resolve via PATH.
137+
if resolved, err := exec.LookPath(binPath); err == nil {
138+
binPath = resolved
139+
}
140+
}
94141
if _, err := os.Stat(binPath); os.IsNotExist(err) {
95142
fmt.Printf("Autostart: disabled (binary not found at %s)\n", binPath)
96143
return nil
@@ -99,6 +146,14 @@ func AutostartStatus() error {
99146
fmt.Println("Autostart: enabled (registry Run key set)")
100147
fmt.Printf(" Registry: HKCU\\%s\\%s\n", registryRunKey, registryValue)
101148
fmt.Printf(" Value: %s\n", val)
149+
150+
// Surface whether the server process is currently alive.
151+
if pid, err := GetPID(paths.PIDFile); err == nil && IsProcessRunning(pid) {
152+
fmt.Printf(" Server process: running (PID %d)\n", pid)
153+
} else {
154+
fmt.Println(" Server process: not running")
155+
}
156+
102157
return nil
103158
}
104159

0 commit comments

Comments
 (0)