Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ oc-go-cc
dist/
brag-output-**
.DS_Store
.kimchi
21 changes: 21 additions & 0 deletions INSTALLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,24 @@ docker run -d --restart unless-stopped --name routatic-proxy --env-file .env -p
- An [OpenCode Go](https://opencode.ai/auth) subscription and API key
- Go 1.21+ (only needed if building from source)
- Docker (only needed for Docker setup)

## Updating

If you installed via `go install` or downloaded a release binary directly, you can self-update with the built-in command:

```bash
# See whether a newer release is available without changing anything
routatic-proxy update --check

# Download, verify checksum, and replace the running binary in place
routatic-proxy update

# Skip the confirmation prompt (useful in scripts)
routatic-proxy update --yes
```

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.

A `dev` build (e.g. when compiled from source without a version tag) refuses to update unless you pass `--force`.

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.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ OpenCode Go gives you access to powerful open coding models for **$5/month** (th
- **Hot Reload** — Watch config file for changes and reload automatically (off by default)
- **Background Mode** — Run as daemon detached from terminal
- **Auto-start on Login** — Launch on system startup via launchd (macOS)
- **Self-Update** — Check and install the latest release with one command

## Supported Models

Expand Down Expand Up @@ -168,6 +169,9 @@ routatic-proxy models List all available models (Go, Zen, Bedrock)
routatic-proxy autostart enable Enable auto-start on login
routatic-proxy autostart disable Disable auto-start on login
routatic-proxy autostart status Check autostart status
routatic-proxy update Update to the latest release
routatic-proxy update --check Show if an update is available
routatic-proxy update --yes Update without prompting
routatic-proxy --version Show version
```

Expand Down
1 change: 1 addition & 0 deletions cmd/routatic-proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Legacy ~/.config/oc-go-cc/config.json and OC_GO_CC_* environment variables are s
rootCmd.AddCommand(checkCmd())
rootCmd.AddCommand(modelsCmd())
rootCmd.AddCommand(autostartCmd())
rootCmd.AddCommand(updateCmd())
addPlatformCommands(rootCmd)

if err := rootCmd.Execute(); err != nil {
Expand Down
113 changes: 113 additions & 0 deletions cmd/routatic-proxy/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package main

import (
"fmt"
"strings"

"github.com/routatic/proxy/internal/daemon"
"github.com/routatic/proxy/internal/updater"
"github.com/spf13/cobra"
)

// updateCmd returns the Cobra command that updates routatic-proxy to the
// latest GitHub release.
func updateCmd() *cobra.Command {
var (
checkOnly bool
yes bool
force bool
skipChecksum bool
)

cmd := &cobra.Command{
Use: "update",
Short: "Update routatic-proxy to the latest release",
Long: `Check GitHub for the latest routatic-proxy release and, if a newer
version is available, download the matching asset for this OS/arch,
verify its SHA256 checksum, and replace the running binary in place.

A .old backup of the previous binary is written next to the running
executable on every platform. On Windows the backup is scheduled for
deletion after the process exits because the running executable is
locked until then.

If the current binary reports its version as "dev" (e.g. when built
from source without a version tag) the command refuses to update
unless --force is passed.`,
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()

info, err := updater.Check(ctx)
if err != nil {
return err
}

if checkOnly {
needs, err := updater.NeedsUpdate(version, info.TagName, false)
if err != nil {
return err
}
if needs {
fmt.Printf("Update available: %s -> %s\n", version, info.TagName)
} else {
fmt.Printf("Already up to date (%s)\n", version)
}
return nil
}

needs, err := updater.NeedsUpdate(version, info.TagName, force)
if err != nil {
return err
}
if !needs {
fmt.Printf("Already up to date (%s)\n", version)
return nil
}

if !yes {
fmt.Printf("Update %s -> %s? [y/N] ", version, info.TagName)
var resp string
if _, err := fmt.Scanln(&resp); err != nil {
return fmt.Errorf("aborted")
}
if strings.ToLower(strings.TrimSpace(resp)) != "y" {
return fmt.Errorf("update cancelled")
}
}

currentPath, err := daemon.FindBinary()
if err != nil {
return fmt.Errorf("cannot locate current binary: %w", err)
}

result, err := updater.Apply(ctx, updater.Options{
CurrentVersion: version,
CurrentBinaryPath: currentPath,
Force: force,
SkipChecksum: skipChecksum,
})
if err != nil {
return err
}

if !result.Updated {
fmt.Printf("Already up to date (%s)\n", version)
return nil
}

fmt.Printf("Updated %s -> %s\n", result.OldVersion, result.NewVersion)
fmt.Printf("New binary: %s\n", result.NewPath)
if result.BackupPath != "" {
fmt.Printf("Backup: %s\n", result.BackupPath)
}
return nil
},
}

cmd.Flags().BoolVarP(&checkOnly, "check", "c", false, "Only check for updates; do not install")
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip the confirmation prompt")
cmd.Flags().BoolVarP(&force, "force", "f", false, "Update even if already on the latest version (required when current version is 'dev')")
cmd.Flags().BoolVar(&skipChecksum, "skip-checksum", false, "Skip SHA256 checksum verification of the downloaded asset")

return cmd
}
63 changes: 59 additions & 4 deletions internal/daemon/autostart_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ package daemon
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"

"golang.org/x/sys/windows/registry"
)
Expand All @@ -15,16 +18,27 @@ const (
)

func buildAutostartArgs(configPath string, port int) string {
args := `"serve" "--background"`
args := "serve --background"
if configPath != "" {
args += ` "--config" "` + configPath + `"`
args += ` --config "` + configPath + `"`
}
if port != 0 {
args += ` "--port" "` + fmt.Sprintf("%d", port) + `"`
args += fmt.Sprintf(" --port %d", port)
}
return args
}

// registryBinaryRef returns the string to use as the binary token in the
// registry Run value. If binaryPath lives inside a WindowsApps alias directory,
// the bare executable name is returned so Windows resolves it via PATH at
// login. Otherwise the quoted absolute path is returned.
func registryBinaryRef(binaryPath string) string {
if strings.Contains(strings.ToLower(binaryPath), strings.ToLower(`\WindowsApps\`)) {
return filepath.Base(binaryPath)
}
return `"` + binaryPath + `"`
}

// EnableAutostart adds a registry Run key so routatic-proxy starts on login.
func EnableAutostart(configPath string, port int) error {
paths, err := DefaultPaths()
Expand All @@ -41,7 +55,12 @@ func EnableAutostart(configPath string, port int) error {
}
defer func() { _ = key.Close() }()

value := `"` + paths.BinaryPath + `" ` + buildAutostartArgs(configPath, port)
binRef := registryBinaryRef(paths.BinaryPath)
if err := validateBinaryRef(binRef); err != nil {
return err
}

value := binRef + " " + buildAutostartArgs(configPath, port)
if err := key.SetStringValue(registryValue, value); err != nil {
return fmt.Errorf("cannot set registry value: %w", err)
}
Expand All @@ -51,6 +70,23 @@ func EnableAutostart(configPath string, port int) error {
return nil
}

func validateBinaryRef(binRef string) error {
// Bare executable name: verify it can be found on PATH.
if !strings.ContainsAny(binRef, `\/`) {
if _, err := exec.LookPath(binRef); err != nil {
return fmt.Errorf("cannot find %s on PATH: %w", binRef, err)
}
return nil
}

// Quoted absolute path: strip quotes and verify the file exists.
path := strings.Trim(binRef, `"`)
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("cannot access binary at %s: %w", path, err)
}
return nil
}

// DisableAutostart removes the registry Run key.
func DisableAutostart() error {
key, err := registry.OpenKey(registry.CURRENT_USER, registryRunKey, registry.SET_VALUE|registry.QUERY_VALUE)
Expand All @@ -76,6 +112,11 @@ func DisableAutostart() error {

// AutostartStatus reports whether autostart is enabled.
func AutostartStatus() error {
paths, err := DefaultPaths()
if err != nil {
return err
}

key, err := registry.OpenKey(registry.CURRENT_USER, registryRunKey, registry.READ)
if err != nil {
fmt.Println("Autostart: disabled (cannot read registry)")
Expand All @@ -91,6 +132,12 @@ func AutostartStatus() error {

// Verify the binary still exists at the recorded path
binPath := extractBinaryPath(val)
if !strings.ContainsAny(binPath, `\/`) {
// Bare executable name (used for WindowsApps aliases) — resolve via PATH.
if resolved, err := exec.LookPath(binPath); err == nil {
binPath = resolved
}
}
if _, err := os.Stat(binPath); os.IsNotExist(err) {
fmt.Printf("Autostart: disabled (binary not found at %s)\n", binPath)
return nil
Expand All @@ -99,6 +146,14 @@ func AutostartStatus() error {
fmt.Println("Autostart: enabled (registry Run key set)")
fmt.Printf(" Registry: HKCU\\%s\\%s\n", registryRunKey, registryValue)
fmt.Printf(" Value: %s\n", val)

// Surface whether the server process is currently alive.
if pid, err := GetPID(paths.PIDFile); err == nil && IsProcessRunning(pid) {
fmt.Printf(" Server process: running (PID %d)\n", pid)
} else {
fmt.Println(" Server process: not running")
}

return nil
}

Expand Down
Loading
Loading