From 44f14ef9ae81c2e2aee0349bdb9febbfddd67bfa Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Sun, 28 Jun 2026 16:08:32 +0200 Subject: [PATCH 1/5] 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. --- .kimchi/docs/update-command-spec.md | 236 ++++++++++++ .kimchi/docs/windows-autostart-fix.md | 73 ++++ INSTALLATION.md | 21 ++ README.md | 4 + cmd/routatic-proxy/main.go | 1 + internal/daemon/autostart_windows.go | 63 +++- internal/daemon/autostart_windows_test.go | 163 ++++++++ internal/daemon/background.go | 2 + internal/updater/replace_windows.go | 33 ++ internal/updater/updater.go | 434 ++++++++++++++++++++++ internal/updater/updater_test.go | 293 +++++++++++++++ 11 files changed, 1319 insertions(+), 4 deletions(-) create mode 100644 .kimchi/docs/update-command-spec.md create mode 100644 .kimchi/docs/windows-autostart-fix.md create mode 100644 internal/daemon/autostart_windows_test.go create mode 100644 internal/updater/replace_windows.go create mode 100644 internal/updater/updater.go create mode 100644 internal/updater/updater_test.go diff --git a/.kimchi/docs/update-command-spec.md b/.kimchi/docs/update-command-spec.md new file mode 100644 index 0000000..455cd3f --- /dev/null +++ b/.kimchi/docs/update-command-spec.md @@ -0,0 +1,236 @@ +# Update command spec + +## Goal +Add a `routatic-proxy update` command that: +1. Checks the latest GitHub release of `routatic/proxy`. +2. Compares it with the currently running binary's version. +3. Downloads the correct release asset for the current OS/arch. +4. Verifies the SHA256 checksum if `checksums.txt` is available. +5. Replaces the current binary in-place (with a `.old` backup on all platforms). +6. Updates README/INSTALLATION docs to list the new command. + +## Release asset naming + +Repository: `routatic/proxy` +Latest release API: `https://api.github.com/repos/routatic/proxy/releases/latest` +Asset base URL pattern: `https://github.com/routatic/proxy/releases/download//` + +Assets follow the pattern `routatic-proxy_-` with `.exe` suffix on Windows: + +| GOOS | GOARCH | Asset name | +|--------|--------|----------------------------------------------| +| darwin | amd64 | `routatic-proxy_darwin-amd64` | +| darwin | arm64 | `routatic-proxy_darwin-arm64` | +| linux | amd64 | `routatic-proxy_linux-amd64` | +| linux | arm64 | `routatic-proxy_linux-arm64` | +| windows| amd64 | `routatic-proxy_windows-amd64.exe` | +| windows| arm64 | `routatic-proxy_windows-arm64.exe` | + +`checksums.txt` contains one `sha256 filename` line per asset. + +## Files + +### `internal/updater/updater.go` (complex) + +Pure-Go self-update logic. No Cobra/CLI code. + +```go +package updater + +const ( + Owner = "routatic" + Repo = "proxy" +) + +// ReleaseInfo holds the data we need from the GitHub release API. +type ReleaseInfo struct { + TagName string + Name string + Published time.Time + AssetName string + AssetURL string + ChecksumURL string // URL of checksums.txt if present, else empty +} + +// Result is returned by Apply. +type Result struct { + Updated bool + OldVersion string + NewVersion string + NewPath string + BackupPath string +} + +// Options controls update behavior. +type Options struct { + CurrentVersion string + Force bool // install even if versions compare equal + SkipChecksum bool // skip SHA256 verification + HTTPClient *http.Client // optional; default 30s timeout +} + +// Check fetches the latest release and returns its info. +func Check(ctx context.Context) (*ReleaseInfo, error) + +// NeedsUpdate compares the current version with the release tag. +// Returns true if the release is newer, or if Force is set. +// Versions are normalized by stripping a leading "v" and comparing +// major.minor.patch numerically. Pre-release segments are compared +// lexicographically. "dev" is treated as older than any real release. +func NeedsUpdate(current, latest string, force bool) (bool, error) + +// AssetName returns the expected asset name for the running platform. +func AssetName() (string, error) + +// Download fetches the asset and returns a path to the temporary file. +func Download(ctx context.Context, info *ReleaseInfo, dir string) (tempPath string, err error) + +// VerifyChecksum verifies the downloaded file against checksums.txt. +func VerifyChecksum(ctx context.Context, info *ReleaseInfo, assetPath string) error + +// Replace swaps the running binary with the downloaded one. +// On Unix it renames current -> .old and temp -> current. +// On Windows it renames current -> .old, moves temp -> current, and +// schedules deletion of the .old file after a short delay because the +// running executable is locked until exit. +func Replace(currentPath, tempPath string) (backupPath string, err error) + +// Apply orchestrates Check, NeedsUpdate, Download, VerifyChecksum, Replace. +func Apply(ctx context.Context, opts Options) (*Result, error) +``` + +Implementation details: +- Use `net/http` with a default 30-second timeout. Set `Accept: application/vnd.github+json` and a `User-Agent: routatic-proxy/`. +- Parse the JSON release response manually into a small struct (do not add a GitHub SDK dependency). +- Find the asset by matching `info.AssetName()` against `asset.name`. If missing, error. +- Checksum parsing: read `checksums.txt`, split lines, find line ending with the asset name, compare lower-case hex SHA256. +- Download to `os.CreateTemp(dir, "routatic-proxy-update-*")`. On Windows the temp file should have a `.exe` extension so `os.Rename` behaves predictably. Use `filepath.Join(dir, base+".tmp")` when on Windows if the generic temp name is not `.exe`. +- After download, `os.Chmod(tempPath, 0755)` on Unix. +- `Replace`: + - `backupPath = currentPath + ".old"` + - Remove any existing `.old` first if possible. + - `os.Rename(currentPath, backupPath)`. + - `os.Rename(tempPath, currentPath)`. + - On Windows, schedule deletion of `backupPath` with a short detached delay. + +### `internal/updater/updater_test.go` + +Tests: +- `TestNormalizeVersion` — strip leading `v`, keep `dev`. +- `TestCompareVersions` — equal, newer, older, pre-release ordering, `dev` older than release. +- `TestAssetName` — for each supported platform via `runtime.GOOS/GOARCH` override table. +- `TestParseChecksums` — parses `sha256 filename` lines and ignores others. +- `TestFindAsset` — picks the correct asset from a mocked list. +- `TestDownload` — uses `httptest` to serve a small asset and checksum, verifies temp file. + +### `cmd/routatic-proxy/update.go` (simple) + +Cobra command. Add `updateCmd()` and register it in `main.go` with `rootCmd.AddCommand(updateCmd())`. + +```go +func updateCmd() *cobra.Command { + var check, yes, force, skipChecksum bool + cmd := &cobra.Command{ + Use: "update", + Short: "Update routatic-proxy to the latest release", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + if check { + info, err := updater.Check(ctx) + if err != nil { return err } + 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 + } + + if version == "dev" && !force { + return fmt.Errorf("current binary has version 'dev'; use --force to update anyway") + } + + info, err := updater.Check(ctx) + if err != nil { return err } + 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 := os.Executable() + if err != nil { return err } + currentPath = resolveExecutablePath(currentPath) + + result, err := updater.Apply(ctx, updater.Options{ + CurrentVersion: version, + Force: force, + SkipChecksum: skipChecksum, + }) + if err != nil { return err } + + 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(&check, "check", "c", false, "Only check for updates") + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt") + cmd.Flags().BoolVarP(&force, "force", "f", false, "Update even if already on the latest version") + cmd.Flags().BoolVar(&skipChecksum, "skip-checksum", false, "Skip SHA256 checksum verification") + return cmd +} +``` + +Notes: +- Use existing `resolveExecutablePath` from `internal/daemon`? It is unexported. `cmd/routatic-proxy` already has its own `resolveExecutablePath`? No, that's in `internal/daemon/paths.go`. The command package can implement its own small helper or import `internal/daemon`. For consistency, import `internal/daemon` and use `daemon.DefaultPaths().BinaryPath` or `daemon.FindBinary()`. The simplest: use `daemon.FindBinary()` to get the canonical path. +- The command must import `internal/updater`. + +### `cmd/routatic-proxy/main.go` + +Add `rootCmd.AddCommand(updateCmd())` next to the other `AddCommand` calls. + +### Documentation updates + +#### `README.md` +- Add to the command reference block (~line 132): + ``` + 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 + ``` +- Add a bullet under **Features** (optional): "Self-Update — check and install the latest release with one command". + +#### `INSTALLATION.md` +- Add an "Updating" section near the top or bottom with the `routatic-proxy update` command and notes about Homebrew/Scoop users using their package manager instead. + +## Verification + +- `go test ./internal/updater/ -v` +- `go test ./...` +- `make lint` +- Build: `make build` and run `./bin/routatic-proxy update --check` (will report "dev" unless built with a version tag). + +## Acceptance criteria +- `routatic-proxy update --check` prints whether an update is available. +- `routatic-proxy update` downloads the correct asset, verifies checksum, and replaces the binary (with confirmation). +- `routatic-proxy update --yes --force` works without interaction. +- README and INSTALLATION mention the new command. +- All tests and lint pass. diff --git a/.kimchi/docs/windows-autostart-fix.md b/.kimchi/docs/windows-autostart-fix.md new file mode 100644 index 0000000..ca03e38 --- /dev/null +++ b/.kimchi/docs/windows-autostart-fix.md @@ -0,0 +1,73 @@ +# Windows autostart fix spec + +## Problem +`routatic-proxy autostart enable` writes an `HKCU\...\Run` value so the proxy starts on Windows login. Users report the registry entry is created and `autostart status` says "enabled", but after reboot the server is not running. + +The current value looks like: + +```text +"C:\Users\\AppData\Local\Microsoft\WindowsApps\routatic-proxy.exe" "serve" "--background" +``` + +Two likely failure modes: + +1. **AppExecLink / WindowsApps alias problem.** The recorded binary path can be an AppExecLink reparse point inside `WindowsApps`. When launched via the registry `Run` key with an absolute path, `CreateProcess` sometimes fails to resolve the alias, so the process never starts. The same alias works from a terminal because the shell resolves it through `PATH`. +2. **Crash on startup is invisible.** If the forked background process crashes (missing env var, bad config, port in use), the error goes to the log file, but `autostart status` only checks the registry entry and binary existence — it does not verify the server process is alive. + +## Goals +1. Make the registry command robust against WindowsApps aliases. +2. Use conventional Windows command-line quoting (quote only the executable path and arguments that contain spaces, not every token). +3. Improve `autostart status` so it reports whether the server process is actually running. +4. Surface fork/start errors in the log file instead of losing them. +5. Add unit tests for the Windows-specific command-line building and path-extraction helpers. + +## Changes + +### `internal/daemon/autostart_windows.go` + +1. `buildAutostartArgs(configPath string, port int) string` + - Return a plain command-line string, not a collection of individually quoted tokens. + - Format: `serve --background` optionally followed by ` --config ""` and/or ` --port `. + - Quote the config path only if it contains spaces; on Windows the simplest safe form is to always quote the config path. + +2. `registryBinaryRef(binaryPath string) string` + - New helper. + - If `binaryPath` is inside a `WindowsApps` directory (case-insensitive), return `filepath.Base(binaryPath)` (e.g. `routatic-proxy.exe`). This lets Windows resolve the alias via `PATH` at login instead of relying on a direct absolute-path launch of a reparse point. + - Otherwise return the absolute path wrapped in double quotes. + +3. `EnableAutostart` + - Build the registry value as: ` `. + - Validate the chosen binary reference by checking `exec.LookPath` when the bare name is used, or `os.Stat` when an absolute path is used, and return a clear error if the binary cannot be found. + +4. `AutostartStatus` + - Keep the existing registry-entry and binary-existence checks. + - Add a process-running check: read the PID file via `daemon.GetPID(paths.PIDFile)` and `daemon.IsProcessRunning(pid)`. If the PID file is missing or the process is not running, print a warning (the registry entry is still enabled, but the server is not currently alive). + +5. `extractBinaryPath` + - Keep the existing parser but add a unit test. + +### `internal/daemon/background.go` + +1. In `ForkIntoBackground`, if `cmd.Start()` fails, write the error to `paths.LogFile` before returning it. This ensures that a startup failure at login leaves evidence in the log. + +### `internal/daemon/autostart_windows_test.go` + +New file, `//go:build windows`. Tests: + +- `TestBuildAutostartArgs` — verifies the command-line string for empty, config-only, port-only, and config+port cases. Assert no quotes around bare flags; config path is quoted. +- `TestRegistryBinaryRef` — verifies that a path under `WindowsApps` resolves to the base name, while a normal path resolves to the quoted absolute path. +- `TestExtractBinaryPath` — verifies parsing of quoted paths, unquoted paths, paths with spaces, and missing trailing quote. + +## Verification + +- Compile the Windows daemon package: `GOOS=windows go vet ./internal/daemon/` +- Run daemon tests with Windows build tags: `GOOS=windows go test ./internal/daemon/` +- Run the repo-wide test suite on the host: `go test ./...` +- Run lint: `make lint` + +## Acceptance criteria +- `routatic-proxy autostart status` on Windows reports whether the server process is running. +- `buildAutostartArgs` produces a conventional Windows command line without over-quoting flags. +- A binary path inside `WindowsApps` is recorded as the bare executable name in the registry value. +- Fork failures in `ForkIntoBackground` are appended to `routatic-proxy.log`. +- All new and existing tests pass. diff --git a/INSTALLATION.md b/INSTALLATION.md index ea3c056..0d8469d 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -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. diff --git a/README.md b/README.md index 0b4aaf5..cb2d8a0 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,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 @@ -140,6 +141,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 ``` diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index e33f09e..10d5880 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -48,6 +48,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()) if err := rootCmd.Execute(); err != nil { os.Exit(1) diff --git a/internal/daemon/autostart_windows.go b/internal/daemon/autostart_windows.go index 61d8c0b..5e0340a 100644 --- a/internal/daemon/autostart_windows.go +++ b/internal/daemon/autostart_windows.go @@ -5,6 +5,9 @@ package daemon import ( "fmt" "os" + "os/exec" + "path/filepath" + "strings" "golang.org/x/sys/windows/registry" ) @@ -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() @@ -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) } @@ -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) @@ -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)") @@ -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 @@ -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 } diff --git a/internal/daemon/autostart_windows_test.go b/internal/daemon/autostart_windows_test.go new file mode 100644 index 0000000..6daca31 --- /dev/null +++ b/internal/daemon/autostart_windows_test.go @@ -0,0 +1,163 @@ +//go:build windows + +package daemon + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestBuildAutostartArgs(t *testing.T) { + tests := []struct { + name string + configPath string + port int + want string + }{ + { + name: "no options", + want: "serve --background", + }, + { + name: "config only", + configPath: `C:\Users\Me\.config\routatic-proxy\config.json`, + want: `serve --background --config "C:\Users\Me\.config\routatic-proxy\config.json"`, + }, + { + name: "port only", + port: 8080, + want: "serve --background --port 8080", + }, + { + name: "config and port", + configPath: `C:\Program Files\routatic-proxy\config.json`, + port: 9090, + want: `serve --background --config "C:\Program Files\routatic-proxy\config.json" --port 9090`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildAutostartArgs(tt.configPath, tt.port) + if got != tt.want { + t.Errorf("buildAutostartArgs() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestRegistryBinaryRef(t *testing.T) { + tests := []struct { + name string + binaryPath string + want string + }{ + { + name: "normal absolute path with spaces", + binaryPath: `C:\Program Files\routatic-proxy\routatic-proxy.exe`, + want: `"C:\Program Files\routatic-proxy\routatic-proxy.exe"`, + }, + { + name: "windows apps alias lower case", + binaryPath: `C:\Users\Me\AppData\Local\Microsoft\WindowsApps\routatic-proxy.exe`, + want: `routatic-proxy.exe`, + }, + { + name: "windows apps alias mixed case", + binaryPath: `C:\Users\Me\AppData\Local\Microsoft\windowsapps\routatic-proxy.exe`, + want: `routatic-proxy.exe`, + }, + { + name: "path without spaces", + binaryPath: `C:\tools\routatic-proxy.exe`, + want: `"C:\tools\routatic-proxy.exe"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := registryBinaryRef(tt.binaryPath) + if got != tt.want { + t.Errorf("registryBinaryRef(%q) = %q, want %q", tt.binaryPath, got, tt.want) + } + }) + } +} + +func TestExtractBinaryPath(t *testing.T) { + tests := []struct { + name string + val string + want string + }{ + { + name: "quoted path with args", + val: `"C:\tools\routatic-proxy.exe" serve --background`, + want: `C:\tools\routatic-proxy.exe`, + }, + { + name: "quoted path with spaces", + val: `"C:\Program Files\routatic-proxy\routatic-proxy.exe" serve --background`, + want: `C:\Program Files\routatic-proxy\routatic-proxy.exe`, + }, + { + name: "unquoted path with args", + val: `C:\tools\routatic-proxy.exe serve --background`, + want: `C:\tools\routatic-proxy.exe`, + }, + { + name: "bare name with args", + val: `routatic-proxy.exe serve --background`, + want: `routatic-proxy.exe`, + }, + { + name: "only path quoted", + val: `"C:\tools\routatic-proxy.exe"`, + want: `C:\tools\routatic-proxy.exe`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractBinaryPath(tt.val) + if got != tt.want { + t.Errorf("extractBinaryPath(%q) = %q, want %q", tt.val, got, tt.want) + } + }) + } +} + +func TestValidateBinaryRef(t *testing.T) { + execPath, err := os.Executable() + if err != nil { + t.Skipf("cannot determine executable: %v", err) + } + execPath = resolveExecutablePath(execPath) + + if runtime.GOOS != "windows" { + t.Skip("Windows-only test") + } + + // Absolute/quoted path should pass. + if err := validateBinaryRef(`"` + execPath + `"`); err != nil { + t.Errorf("validateBinaryRef(absolute) unexpected error: %v", err) + } + + // Bare name of the current binary is usually not on PATH, so expect a lookup error. + base := filepath.Base(execPath) + if err := validateBinaryRef(base); err == nil { + t.Logf("validateBinaryRef(%q) succeeded; test binary is on PATH", base) + } else if !strings.Contains(err.Error(), "cannot find") { + t.Errorf("validateBinaryRef(bare) unexpected error: %v", err) + } + + // Non-existent quoted path should fail. + if err := validateBinaryRef(`"C:\nonexistent\routatic-proxy.exe"`); err == nil { + t.Error("validateBinaryRef(non-existent) expected error, got nil") + } else if !strings.Contains(err.Error(), "cannot access binary") { + t.Errorf("validateBinaryRef(non-existent) unexpected error: %v", err) + } +} diff --git a/internal/daemon/background.go b/internal/daemon/background.go index 1cd977c..c9b1ac2 100644 --- a/internal/daemon/background.go +++ b/internal/daemon/background.go @@ -57,6 +57,8 @@ func ForkIntoBackground(opts BackgroundOpts) error { cmd.Dir = paths.ConfigDir // Run from a stable directory to avoid caller cwd issues if err := cmd.Start(); err != nil { + // Write the error to the log file so login-time failures are not lost. + _, _ = fmt.Fprintf(logFile, "failed to start background process: %v\n", err) return fmt.Errorf("failed to start background process: %w", err) } diff --git a/internal/updater/replace_windows.go b/internal/updater/replace_windows.go new file mode 100644 index 0000000..bc2e67e --- /dev/null +++ b/internal/updater/replace_windows.go @@ -0,0 +1,33 @@ +//go:build windows + +package updater + +import ( + "fmt" + "os/exec" + "syscall" +) + +func init() { + cleanupBackup = scheduleDeleteWindows +} + +// scheduleDeleteWindows removes a file after a short delay. +// This is needed because Windows keeps the running executable locked +// until the process exits, so the backup cannot be deleted immediately. +func scheduleDeleteWindows(path string) { + // Ping gives the current process time to exit before attempting deletion. + cmd := exec.Command("cmd", "/c", fmt.Sprintf("ping 127.0.0.1 -n 3 > nul & del %s", windowsQuote(path))) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP, + } + _ = cmd.Start() +} + +func windowsQuote(s string) string { + if s == "" { + return `""` + } + return `"` + s + `"` +} diff --git a/internal/updater/updater.go b/internal/updater/updater.go new file mode 100644 index 0000000..82f8815 --- /dev/null +++ b/internal/updater/updater.go @@ -0,0 +1,434 @@ +package updater + +import ( + "bufio" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "runtime" + "strconv" + "strings" + "time" +) + +const ( + Owner = "routatic" + Repo = "proxy" +) + +var defaultClient = &http.Client{Timeout: 30 * time.Second} + +// ReleaseInfo holds the data we need from the GitHub release API. +type ReleaseInfo struct { + TagName string + Name string + Published time.Time + AssetName string + AssetURL string + ChecksumURL string +} + +// Result is returned by Apply. +type Result struct { + Updated bool + OldVersion string + NewVersion string + NewPath string + BackupPath string +} + +// Options controls update behavior. +type Options struct { + CurrentVersion string + CurrentBinaryPath string + Force bool + SkipChecksum bool + HTTPClient *http.Client +} + +// Check fetches the latest release from GitHub. +func Check(ctx context.Context) (*ReleaseInfo, error) { + return checkWithClient(ctx, defaultClient) +} + +func checkWithClient(ctx context.Context, client *http.Client) (*ReleaseInfo, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", Owner, Repo) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "routatic-proxy") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot fetch latest release: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("release API returned %s", resp.Status) + } + + var payload struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + PublishedAt string `json:"published_at"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, fmt.Errorf("cannot decode release response: %w", err) + } + + published, err := time.Parse(time.RFC3339, payload.PublishedAt) + if err != nil { + published = time.Time{} + } + + assetName, err := AssetName() + if err != nil { + return nil, err + } + + info := &ReleaseInfo{ + TagName: payload.TagName, + Name: payload.Name, + Published: published, + AssetName: assetName, + } + + for _, asset := range payload.Assets { + switch asset.Name { + case assetName: + info.AssetURL = asset.BrowserDownloadURL + case "checksums.txt": + info.ChecksumURL = asset.BrowserDownloadURL + } + } + + if info.AssetURL == "" { + return nil, fmt.Errorf("release does not contain asset %q", assetName) + } + return info, nil +} + +// NeedsUpdate compares the current version with the release tag. +// Versions are normalized by stripping a leading "v" and comparing +// major.minor.patch numerically. Pre-release segments are compared +// lexicographically. "dev" is treated as older than any real release +// unless Force is true. +func NeedsUpdate(current, latest string, force bool) (bool, error) { + current = normalizeVersion(current) + latest = normalizeVersion(latest) + + if current == "dev" { + if force { + return true, nil + } + return false, fmt.Errorf("current version is %q; use --force to update anyway", current) + } + + if current == "" { + if force { + return true, nil + } + return false, fmt.Errorf("current version is empty; use --force to update anyway") + } + + cmp, err := compareSemver(current, latest) + if err != nil { + return false, fmt.Errorf("cannot compare versions: %w", err) + } + return cmp < 0 || force, nil +} + +func normalizeVersion(v string) string { + v = strings.TrimSpace(v) + v = strings.TrimPrefix(v, "v") + v = strings.TrimPrefix(v, "V") + return strings.TrimSpace(v) +} + +// compareSemver returns -1 if a < b, 0 if equal, 1 if a > b. +// It expects inputs like "0.3.9" or "0.3.9-beta.1". +func compareSemver(a, b string) (int, error) { + aPre, bPre := "", "" + if i := strings.Index(a, "-"); i >= 0 { + aPre = a[i+1:] + a = a[:i] + } + if i := strings.Index(b, "-"); i >= 0 { + bPre = b[i+1:] + b = b[:i] + } + + aParts := strings.Split(a, ".") + bParts := strings.Split(b, ".") + for i := 0; i < len(aParts) || i < len(bParts); i++ { + ai, bi := 0, 0 + if i < len(aParts) { + n, err := strconv.Atoi(aParts[i]) + if err != nil { + return 0, fmt.Errorf("invalid version component %q", aParts[i]) + } + ai = n + } + if i < len(bParts) { + n, err := strconv.Atoi(bParts[i]) + if err != nil { + return 0, fmt.Errorf("invalid version component %q", bParts[i]) + } + bi = n + } + if ai < bi { + return -1, nil + } + if ai > bi { + return 1, nil + } + } + + // A version without a pre-release is newer than one with a pre-release. + if aPre == "" && bPre != "" { + return 1, nil + } + if aPre != "" && bPre == "" { + return -1, nil + } + if aPre == bPre { + return 0, nil + } + if aPre < bPre { + return -1, nil + } + return 1, nil +} + +// AssetName returns the expected release asset name for the running platform. +func AssetName() (string, error) { + key := runtime.GOOS + "-" + runtime.GOARCH + switch key { + case "darwin-amd64": + return "routatic-proxy_darwin-amd64", nil + case "darwin-arm64": + return "routatic-proxy_darwin-arm64", nil + case "linux-amd64": + return "routatic-proxy_linux-amd64", nil + case "linux-arm64": + return "routatic-proxy_linux-arm64", nil + case "windows-amd64": + return "routatic-proxy_windows-amd64.exe", nil + case "windows-arm64": + return "routatic-proxy_windows-arm64.exe", nil + default: + return "", fmt.Errorf("unsupported platform %s/%s", runtime.GOOS, runtime.GOARCH) + } +} + +// Download fetches the release asset and returns the path to the temporary file. +func Download(ctx context.Context, info *ReleaseInfo, dir string) (string, error) { + return downloadWithClient(ctx, defaultClient, info, dir) +} + +func downloadWithClient(ctx context.Context, client *http.Client, info *ReleaseInfo, dir string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, info.AssetURL, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "routatic-proxy") + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("cannot download asset: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("asset download returned %s", resp.Status) + } + + pattern := "routatic-proxy-update-*" + if strings.HasSuffix(info.AssetName, ".exe") { + pattern += ".exe" + } + out, err := os.CreateTemp(dir, pattern) + if err != nil { + return "", fmt.Errorf("cannot create temporary file: %w", err) + } + defer func() { + if err != nil { + _ = out.Close() + _ = os.Remove(out.Name()) + } + }() + + if _, err := io.Copy(out, resp.Body); err != nil { + return "", fmt.Errorf("cannot write asset: %w", err) + } + if err := out.Close(); err != nil { + return "", fmt.Errorf("cannot close asset file: %w", err) + } + + if runtime.GOOS != "windows" { + if err := os.Chmod(out.Name(), 0755); err != nil { + return "", fmt.Errorf("cannot make asset executable: %w", err) + } + } + + return out.Name(), nil +} + +// VerifyChecksum verifies the downloaded file against checksums.txt. +func VerifyChecksum(ctx context.Context, info *ReleaseInfo, assetPath string) error { + return verifyChecksumWithClient(ctx, defaultClient, info, assetPath) +} + +func verifyChecksumWithClient(ctx context.Context, client *http.Client, info *ReleaseInfo, assetPath string) error { + if info.ChecksumURL == "" { + return fmt.Errorf("no checksums.txt available") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, info.ChecksumURL, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "routatic-proxy") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("cannot download checksums: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("checksum download returned %s", resp.Status) + } + + expected := "" + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) >= 2 && fields[1] == info.AssetName { + expected = fields[0] + break + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("cannot read checksums: %w", err) + } + if expected == "" { + return fmt.Errorf("checksum not found for %s", info.AssetName) + } + + f, err := os.Open(assetPath) + if err != nil { + return fmt.Errorf("cannot open downloaded asset: %w", err) + } + defer func() { _ = f.Close() }() + + hash := sha256.New() + if _, err := io.Copy(hash, f); err != nil { + return fmt.Errorf("cannot hash asset: %w", err) + } + got := hex.EncodeToString(hash.Sum(nil)) + + if !strings.EqualFold(got, expected) { + return fmt.Errorf("checksum mismatch for %s: expected %s, got %s", info.AssetName, expected, got) + } + return nil +} + +// Replace swaps the running binary with the downloaded one. +// It returns the path to the backup file. +func Replace(currentPath, tempPath string) (string, error) { + backupPath := currentPath + ".old" + + // Remove any stale backup from a previous update. + _ = os.Remove(backupPath) + + if err := os.Rename(currentPath, backupPath); err != nil { + return "", fmt.Errorf("cannot back up current binary: %w", err) + } + + if err := os.Rename(tempPath, currentPath); err != nil { + // Best-effort rollback. + _ = os.Rename(backupPath, currentPath) + return "", fmt.Errorf("cannot install new binary: %w", err) + } + + cleanupBackup(backupPath) + return backupPath, nil +} + +// cleanupBackup is replaced on Windows where the running binary is locked. +var cleanupBackup = func(path string) { _ = os.Remove(path) } + +// Apply orchestrates the full update. +func Apply(ctx context.Context, opts Options) (*Result, error) { + client := opts.HTTPClient + if client == nil { + client = defaultClient + } + + info, err := checkWithClient(ctx, client) + if err != nil { + return nil, err + } + + needs, err := NeedsUpdate(opts.CurrentVersion, info.TagName, opts.Force) + if err != nil { + return nil, err + } + + if !needs { + return &Result{ + Updated: false, + OldVersion: opts.CurrentVersion, + NewVersion: info.TagName, + }, nil + } + + tempPath, err := downloadWithClient(ctx, client, info, "") + if err != nil { + return nil, err + } + + if !opts.SkipChecksum && info.ChecksumURL != "" { + if err := verifyChecksumWithClient(ctx, client, info, tempPath); err != nil { + _ = os.Remove(tempPath) + return nil, err + } + } + + currentPath := opts.CurrentBinaryPath + if currentPath == "" { + p, err := os.Executable() + if err != nil { + _ = os.Remove(tempPath) + return nil, fmt.Errorf("cannot locate current binary: %w", err) + } + currentPath = p + } + + backupPath, err := Replace(currentPath, tempPath) + if err != nil { + _ = os.Remove(tempPath) + return nil, err + } + + return &Result{ + Updated: true, + OldVersion: opts.CurrentVersion, + NewVersion: info.TagName, + NewPath: currentPath, + BackupPath: backupPath, + }, nil +} diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go new file mode 100644 index 0000000..fd1d94e --- /dev/null +++ b/internal/updater/updater_test.go @@ -0,0 +1,293 @@ +package updater + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// rewriteTransport redirects all requests to the test server URL while +// preserving the original request method, headers, body, and path. This lets +// tests exercise code that hardcodes a production URL (like the GitHub API). +type rewriteTransport struct { + target string +} + +func (t *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + target, err := url.Parse(t.target) + if err != nil { + return nil, err + } + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + return http.DefaultTransport.RoundTrip(req) +} + +// newTestClient returns an *http.Client whose requests are routed to the +// given test server, regardless of the URL the caller specifies. +func newTestClient(srv *httptest.Server) *http.Client { + return &http.Client{ + Timeout: 10 * 1000_000_000, // 10s + Transport: &rewriteTransport{target: srv.URL}, + } +} + +func TestNormalizeVersion(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"v0.3.9", "0.3.9"}, + {"V0.3.9", "0.3.9"}, + {"0.3.9", "0.3.9"}, + {" v1.0.0 ", "1.0.0"}, + {"dev", "dev"}, + } + for _, tt := range tests { + if got := normalizeVersion(tt.input); got != tt.want { + t.Errorf("normalizeVersion(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestCompareSemver(t *testing.T) { + tests := []struct { + a, b string + want int + }{ + {"0.3.9", "0.3.9", 0}, + {"0.3.9", "0.3.10", -1}, + {"0.3.10", "0.3.9", 1}, + {"0.3.9", "0.4.0", -1}, + {"1.0.0", "0.9.9", 1}, + {"0.3.9-beta.1", "0.3.9", -1}, + {"0.3.9", "0.3.9-beta.1", 1}, + {"0.3.9-beta.1", "0.3.9-beta.2", -1}, + {"0.3", "0.3.0", 0}, + } + for _, tt := range tests { + got, err := compareSemver(tt.a, tt.b) + if err != nil { + t.Fatalf("compareSemver(%q, %q) error: %v", tt.a, tt.b, err) + } + if got != tt.want { + t.Errorf("compareSemver(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) + } + } +} + +func TestCompareSemverInvalid(t *testing.T) { + if _, err := compareSemver("abc", "0.3.9"); err == nil { + t.Error("expected error for invalid version component") + } +} + +func TestNeedsUpdate(t *testing.T) { + need, err := NeedsUpdate("v0.3.9", "v0.3.10", false) + if err != nil || !need { + t.Errorf("NeedsUpdate(v0.3.9, v0.3.10) = %v, %v; want true, nil", need, err) + } + + need, err = NeedsUpdate("v0.3.10", "v0.3.9", false) + if err != nil || need { + t.Errorf("NeedsUpdate(v0.3.10, v0.3.9) = %v, %v; want false, nil", need, err) + } + + need, err = NeedsUpdate("v0.3.9", "v0.3.9", false) + if err != nil || need { + t.Errorf("NeedsUpdate(v0.3.9, v0.3.9) = %v, %v; want false, nil", need, err) + } + + need, err = NeedsUpdate("v0.3.9", "v0.3.9", true) + if err != nil || !need { + t.Errorf("NeedsUpdate(v0.3.9, v0.3.9, force) = %v, %v; want true, nil", need, err) + } + + _, err = NeedsUpdate("dev", "v0.3.10", false) + if err == nil { + t.Error("NeedsUpdate(dev) expected error without force") + } + + need, err = NeedsUpdate("dev", "v0.3.10", true) + if err != nil || !need { + t.Errorf("NeedsUpdate(dev, force) = %v, %v; want true, nil", need, err) + } +} + +func TestAssetName(t *testing.T) { + asset, err := AssetName() + if err != nil { + t.Fatalf("AssetName() error: %v", err) + } + + expected := "routatic-proxy_" + runtime.GOOS + "-" + runtime.GOARCH + if runtime.GOOS == "windows" { + expected += ".exe" + } + if asset != expected { + t.Errorf("AssetName() = %q, want %q", asset, expected) + } +} + +func TestParseChecksums(t *testing.T) { + body := "abc123 routatic-proxy_linux-amd64\ndef456 routatic-proxy_darwin-arm64\n" + expected := "abc123" + + var got string + for _, line := range strings.Split(body, "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 && fields[1] == "routatic-proxy_linux-amd64" { + got = fields[0] + break + } + } + if got != expected { + t.Errorf("checksum parse = %q, want %q", got, expected) + } +} + +func TestCheckAndDownload(t *testing.T) { + assetContent := []byte("fake binary content") + assetHash := sha256.Sum256(assetContent) + expectedAsset, err := AssetName() + if err != nil { + t.Fatalf("AssetName() error: %v", err) + } + checksums := fmt.Sprintf("%s %s\n", hex.EncodeToString(assetHash[:]), expectedAsset) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/releases/latest"): + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{ + "tag_name": "v0.4.0", + "name": "v0.4.0", + "published_at": "2026-06-22T12:00:00Z", + "assets": [ + {"name": %q, "browser_download_url": "/download/asset"}, + {"name": "checksums.txt", "browser_download_url": "/download/checksums"} + ] + }`, expectedAsset) + case strings.HasSuffix(r.URL.Path, "/download/asset"): + _, _ = w.Write(assetContent) + case strings.HasSuffix(r.URL.Path, "/download/checksums"): + _, _ = w.Write([]byte(checksums)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + // Override the default client so all requests (including the + // hardcoded GitHub API URL) are routed to the test server. + client := newTestClient(server) + origClient := defaultClient + defaultClient = client + defer func() { defaultClient = origClient }() + + ctx := context.Background() + info, err := Check(ctx) + if err != nil { + t.Fatalf("Check() error: %v", err) + } + if info.TagName != "v0.4.0" { + t.Errorf("TagName = %q, want v0.4.0", info.TagName) + } + if info.AssetURL == "" { + t.Error("AssetURL is empty") + } + if info.ChecksumURL == "" { + t.Error("ChecksumURL is empty") + } + + tempPath, err := Download(ctx, info, "") + if err != nil { + t.Fatalf("Download() error: %v", err) + } + defer func() { _ = os.Remove(tempPath) }() + + data, err := os.ReadFile(tempPath) + if err != nil { + t.Fatalf("cannot read downloaded file: %v", err) + } + if string(data) != string(assetContent) { + t.Errorf("downloaded content mismatch") + } + + if err := VerifyChecksum(ctx, info, tempPath); err != nil { + t.Errorf("VerifyChecksum() error: %v", err) + } +} + +func TestReplace(t *testing.T) { + dir := t.TempDir() + current := filepath.Join(dir, "routatic-proxy") + newBin := filepath.Join(dir, "routatic-proxy-new") + + if err := os.WriteFile(current, []byte("old"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(newBin, []byte("new"), 0644); err != nil { + t.Fatal(err) + } + + backup, err := Replace(current, newBin) + if err != nil { + t.Fatalf("Replace() error: %v", err) + } + + if _, err := os.Stat(current); err != nil { + t.Errorf("new binary missing: %v", err) + } + got, err := os.ReadFile(current) + if err != nil || string(got) != "new" { + t.Errorf("new binary content = %q, want new", got) + } + + if runtime.GOOS != "windows" { + // On Unix the backup is removed immediately. + if _, err := os.Stat(backup); !os.IsNotExist(err) { + t.Errorf("expected backup %s to be removed on Unix", backup) + } + } else { + // On Windows the backup is scheduled for later deletion. + if _, err := os.Stat(backup); err != nil { + t.Errorf("backup missing on Windows: %v", err) + } + } +} + +func TestApplyNoUpdate(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{ + "tag_name": "v0.3.9", + "name": "v0.3.9", + "published_at": "2026-06-22T12:00:00Z", + "assets": [{"name": "routatic-proxy_`+runtime.GOOS+`-`+runtime.GOARCH+`", "browser_download_url": "/asset"}] + }`) + })) + defer server.Close() + + client := newTestClient(server) + origClient := defaultClient + defaultClient = client + defer func() { defaultClient = origClient }() + + res, err := Apply(context.Background(), Options{CurrentVersion: "v0.3.9", CurrentBinaryPath: "/dev/null"}) + if err != nil { + t.Fatalf("Apply() error: %v", err) + } + if res.Updated { + t.Error("expected no update") + } +} From 88db5ce04cf977f79ee92239c80c38064ce13c5c Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Sun, 28 Jun 2026 16:22:26 +0200 Subject: [PATCH 2/5] fix: update .gitignore to include .kimchi and remove obsolete documentation files --- .gitignore | 1 + .kimchi/docs/update-command-spec.md | 236 -------------------------- .kimchi/docs/windows-autostart-fix.md | 73 -------- 3 files changed, 1 insertion(+), 309 deletions(-) delete mode 100644 .kimchi/docs/update-command-spec.md delete mode 100644 .kimchi/docs/windows-autostart-fix.md diff --git a/.gitignore b/.gitignore index a3b03c0..6179d9d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ oc-go-cc dist/ brag-output-** .DS_Store +.kimchi diff --git a/.kimchi/docs/update-command-spec.md b/.kimchi/docs/update-command-spec.md deleted file mode 100644 index 455cd3f..0000000 --- a/.kimchi/docs/update-command-spec.md +++ /dev/null @@ -1,236 +0,0 @@ -# Update command spec - -## Goal -Add a `routatic-proxy update` command that: -1. Checks the latest GitHub release of `routatic/proxy`. -2. Compares it with the currently running binary's version. -3. Downloads the correct release asset for the current OS/arch. -4. Verifies the SHA256 checksum if `checksums.txt` is available. -5. Replaces the current binary in-place (with a `.old` backup on all platforms). -6. Updates README/INSTALLATION docs to list the new command. - -## Release asset naming - -Repository: `routatic/proxy` -Latest release API: `https://api.github.com/repos/routatic/proxy/releases/latest` -Asset base URL pattern: `https://github.com/routatic/proxy/releases/download//` - -Assets follow the pattern `routatic-proxy_-` with `.exe` suffix on Windows: - -| GOOS | GOARCH | Asset name | -|--------|--------|----------------------------------------------| -| darwin | amd64 | `routatic-proxy_darwin-amd64` | -| darwin | arm64 | `routatic-proxy_darwin-arm64` | -| linux | amd64 | `routatic-proxy_linux-amd64` | -| linux | arm64 | `routatic-proxy_linux-arm64` | -| windows| amd64 | `routatic-proxy_windows-amd64.exe` | -| windows| arm64 | `routatic-proxy_windows-arm64.exe` | - -`checksums.txt` contains one `sha256 filename` line per asset. - -## Files - -### `internal/updater/updater.go` (complex) - -Pure-Go self-update logic. No Cobra/CLI code. - -```go -package updater - -const ( - Owner = "routatic" - Repo = "proxy" -) - -// ReleaseInfo holds the data we need from the GitHub release API. -type ReleaseInfo struct { - TagName string - Name string - Published time.Time - AssetName string - AssetURL string - ChecksumURL string // URL of checksums.txt if present, else empty -} - -// Result is returned by Apply. -type Result struct { - Updated bool - OldVersion string - NewVersion string - NewPath string - BackupPath string -} - -// Options controls update behavior. -type Options struct { - CurrentVersion string - Force bool // install even if versions compare equal - SkipChecksum bool // skip SHA256 verification - HTTPClient *http.Client // optional; default 30s timeout -} - -// Check fetches the latest release and returns its info. -func Check(ctx context.Context) (*ReleaseInfo, error) - -// NeedsUpdate compares the current version with the release tag. -// Returns true if the release is newer, or if Force is set. -// Versions are normalized by stripping a leading "v" and comparing -// major.minor.patch numerically. Pre-release segments are compared -// lexicographically. "dev" is treated as older than any real release. -func NeedsUpdate(current, latest string, force bool) (bool, error) - -// AssetName returns the expected asset name for the running platform. -func AssetName() (string, error) - -// Download fetches the asset and returns a path to the temporary file. -func Download(ctx context.Context, info *ReleaseInfo, dir string) (tempPath string, err error) - -// VerifyChecksum verifies the downloaded file against checksums.txt. -func VerifyChecksum(ctx context.Context, info *ReleaseInfo, assetPath string) error - -// Replace swaps the running binary with the downloaded one. -// On Unix it renames current -> .old and temp -> current. -// On Windows it renames current -> .old, moves temp -> current, and -// schedules deletion of the .old file after a short delay because the -// running executable is locked until exit. -func Replace(currentPath, tempPath string) (backupPath string, err error) - -// Apply orchestrates Check, NeedsUpdate, Download, VerifyChecksum, Replace. -func Apply(ctx context.Context, opts Options) (*Result, error) -``` - -Implementation details: -- Use `net/http` with a default 30-second timeout. Set `Accept: application/vnd.github+json` and a `User-Agent: routatic-proxy/`. -- Parse the JSON release response manually into a small struct (do not add a GitHub SDK dependency). -- Find the asset by matching `info.AssetName()` against `asset.name`. If missing, error. -- Checksum parsing: read `checksums.txt`, split lines, find line ending with the asset name, compare lower-case hex SHA256. -- Download to `os.CreateTemp(dir, "routatic-proxy-update-*")`. On Windows the temp file should have a `.exe` extension so `os.Rename` behaves predictably. Use `filepath.Join(dir, base+".tmp")` when on Windows if the generic temp name is not `.exe`. -- After download, `os.Chmod(tempPath, 0755)` on Unix. -- `Replace`: - - `backupPath = currentPath + ".old"` - - Remove any existing `.old` first if possible. - - `os.Rename(currentPath, backupPath)`. - - `os.Rename(tempPath, currentPath)`. - - On Windows, schedule deletion of `backupPath` with a short detached delay. - -### `internal/updater/updater_test.go` - -Tests: -- `TestNormalizeVersion` — strip leading `v`, keep `dev`. -- `TestCompareVersions` — equal, newer, older, pre-release ordering, `dev` older than release. -- `TestAssetName` — for each supported platform via `runtime.GOOS/GOARCH` override table. -- `TestParseChecksums` — parses `sha256 filename` lines and ignores others. -- `TestFindAsset` — picks the correct asset from a mocked list. -- `TestDownload` — uses `httptest` to serve a small asset and checksum, verifies temp file. - -### `cmd/routatic-proxy/update.go` (simple) - -Cobra command. Add `updateCmd()` and register it in `main.go` with `rootCmd.AddCommand(updateCmd())`. - -```go -func updateCmd() *cobra.Command { - var check, yes, force, skipChecksum bool - cmd := &cobra.Command{ - Use: "update", - Short: "Update routatic-proxy to the latest release", - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - if check { - info, err := updater.Check(ctx) - if err != nil { return err } - 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 - } - - if version == "dev" && !force { - return fmt.Errorf("current binary has version 'dev'; use --force to update anyway") - } - - info, err := updater.Check(ctx) - if err != nil { return err } - 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 := os.Executable() - if err != nil { return err } - currentPath = resolveExecutablePath(currentPath) - - result, err := updater.Apply(ctx, updater.Options{ - CurrentVersion: version, - Force: force, - SkipChecksum: skipChecksum, - }) - if err != nil { return err } - - 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(&check, "check", "c", false, "Only check for updates") - cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt") - cmd.Flags().BoolVarP(&force, "force", "f", false, "Update even if already on the latest version") - cmd.Flags().BoolVar(&skipChecksum, "skip-checksum", false, "Skip SHA256 checksum verification") - return cmd -} -``` - -Notes: -- Use existing `resolveExecutablePath` from `internal/daemon`? It is unexported. `cmd/routatic-proxy` already has its own `resolveExecutablePath`? No, that's in `internal/daemon/paths.go`. The command package can implement its own small helper or import `internal/daemon`. For consistency, import `internal/daemon` and use `daemon.DefaultPaths().BinaryPath` or `daemon.FindBinary()`. The simplest: use `daemon.FindBinary()` to get the canonical path. -- The command must import `internal/updater`. - -### `cmd/routatic-proxy/main.go` - -Add `rootCmd.AddCommand(updateCmd())` next to the other `AddCommand` calls. - -### Documentation updates - -#### `README.md` -- Add to the command reference block (~line 132): - ``` - 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 - ``` -- Add a bullet under **Features** (optional): "Self-Update — check and install the latest release with one command". - -#### `INSTALLATION.md` -- Add an "Updating" section near the top or bottom with the `routatic-proxy update` command and notes about Homebrew/Scoop users using their package manager instead. - -## Verification - -- `go test ./internal/updater/ -v` -- `go test ./...` -- `make lint` -- Build: `make build` and run `./bin/routatic-proxy update --check` (will report "dev" unless built with a version tag). - -## Acceptance criteria -- `routatic-proxy update --check` prints whether an update is available. -- `routatic-proxy update` downloads the correct asset, verifies checksum, and replaces the binary (with confirmation). -- `routatic-proxy update --yes --force` works without interaction. -- README and INSTALLATION mention the new command. -- All tests and lint pass. diff --git a/.kimchi/docs/windows-autostart-fix.md b/.kimchi/docs/windows-autostart-fix.md deleted file mode 100644 index ca03e38..0000000 --- a/.kimchi/docs/windows-autostart-fix.md +++ /dev/null @@ -1,73 +0,0 @@ -# Windows autostart fix spec - -## Problem -`routatic-proxy autostart enable` writes an `HKCU\...\Run` value so the proxy starts on Windows login. Users report the registry entry is created and `autostart status` says "enabled", but after reboot the server is not running. - -The current value looks like: - -```text -"C:\Users\\AppData\Local\Microsoft\WindowsApps\routatic-proxy.exe" "serve" "--background" -``` - -Two likely failure modes: - -1. **AppExecLink / WindowsApps alias problem.** The recorded binary path can be an AppExecLink reparse point inside `WindowsApps`. When launched via the registry `Run` key with an absolute path, `CreateProcess` sometimes fails to resolve the alias, so the process never starts. The same alias works from a terminal because the shell resolves it through `PATH`. -2. **Crash on startup is invisible.** If the forked background process crashes (missing env var, bad config, port in use), the error goes to the log file, but `autostart status` only checks the registry entry and binary existence — it does not verify the server process is alive. - -## Goals -1. Make the registry command robust against WindowsApps aliases. -2. Use conventional Windows command-line quoting (quote only the executable path and arguments that contain spaces, not every token). -3. Improve `autostart status` so it reports whether the server process is actually running. -4. Surface fork/start errors in the log file instead of losing them. -5. Add unit tests for the Windows-specific command-line building and path-extraction helpers. - -## Changes - -### `internal/daemon/autostart_windows.go` - -1. `buildAutostartArgs(configPath string, port int) string` - - Return a plain command-line string, not a collection of individually quoted tokens. - - Format: `serve --background` optionally followed by ` --config ""` and/or ` --port `. - - Quote the config path only if it contains spaces; on Windows the simplest safe form is to always quote the config path. - -2. `registryBinaryRef(binaryPath string) string` - - New helper. - - If `binaryPath` is inside a `WindowsApps` directory (case-insensitive), return `filepath.Base(binaryPath)` (e.g. `routatic-proxy.exe`). This lets Windows resolve the alias via `PATH` at login instead of relying on a direct absolute-path launch of a reparse point. - - Otherwise return the absolute path wrapped in double quotes. - -3. `EnableAutostart` - - Build the registry value as: ` `. - - Validate the chosen binary reference by checking `exec.LookPath` when the bare name is used, or `os.Stat` when an absolute path is used, and return a clear error if the binary cannot be found. - -4. `AutostartStatus` - - Keep the existing registry-entry and binary-existence checks. - - Add a process-running check: read the PID file via `daemon.GetPID(paths.PIDFile)` and `daemon.IsProcessRunning(pid)`. If the PID file is missing or the process is not running, print a warning (the registry entry is still enabled, but the server is not currently alive). - -5. `extractBinaryPath` - - Keep the existing parser but add a unit test. - -### `internal/daemon/background.go` - -1. In `ForkIntoBackground`, if `cmd.Start()` fails, write the error to `paths.LogFile` before returning it. This ensures that a startup failure at login leaves evidence in the log. - -### `internal/daemon/autostart_windows_test.go` - -New file, `//go:build windows`. Tests: - -- `TestBuildAutostartArgs` — verifies the command-line string for empty, config-only, port-only, and config+port cases. Assert no quotes around bare flags; config path is quoted. -- `TestRegistryBinaryRef` — verifies that a path under `WindowsApps` resolves to the base name, while a normal path resolves to the quoted absolute path. -- `TestExtractBinaryPath` — verifies parsing of quoted paths, unquoted paths, paths with spaces, and missing trailing quote. - -## Verification - -- Compile the Windows daemon package: `GOOS=windows go vet ./internal/daemon/` -- Run daemon tests with Windows build tags: `GOOS=windows go test ./internal/daemon/` -- Run the repo-wide test suite on the host: `go test ./...` -- Run lint: `make lint` - -## Acceptance criteria -- `routatic-proxy autostart status` on Windows reports whether the server process is running. -- `buildAutostartArgs` produces a conventional Windows command line without over-quoting flags. -- A binary path inside `WindowsApps` is recorded as the bare executable name in the registry value. -- Fork failures in `ForkIntoBackground` are appended to `routatic-proxy.log`. -- All new and existing tests pass. From dbb5095910ee7146b51c36c1a1c8bf93ca7d4461 Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Sun, 28 Jun 2026 16:23:10 +0200 Subject: [PATCH 3/5] feat: add update command to routatic-proxy for automatic version management --- cmd/routatic-proxy/update.go | 113 +++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 cmd/routatic-proxy/update.go diff --git a/cmd/routatic-proxy/update.go b/cmd/routatic-proxy/update.go new file mode 100644 index 0000000..0d0a136 --- /dev/null +++ b/cmd/routatic-proxy/update.go @@ -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 +} From 74dc534014d4c0bbf83b8de6632a745bc297ce19 Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Sun, 28 Jun 2026 16:52:45 +0200 Subject: [PATCH 4/5] feat: enhance User-Agent handling in updater for GitHub API requests --- internal/updater/updater.go | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 82f8815..8f9a5c1 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -23,6 +23,15 @@ const ( var defaultClient = &http.Client{Timeout: 30 * time.Second} +// userAgent builds a User-Agent header value. If version is non-empty it is +// appended to the product name as required by the GitHub API spec. +func userAgent(version string) string { + if version == "" { + return "routatic-proxy" + } + return "routatic-proxy/" + version +} + // ReleaseInfo holds the data we need from the GitHub release API. type ReleaseInfo struct { TagName string @@ -53,17 +62,17 @@ type Options struct { // Check fetches the latest release from GitHub. func Check(ctx context.Context) (*ReleaseInfo, error) { - return checkWithClient(ctx, defaultClient) + return checkWithClient(ctx, defaultClient, "") } -func checkWithClient(ctx context.Context, client *http.Client) (*ReleaseInfo, error) { +func checkWithClient(ctx context.Context, client *http.Client, version string) (*ReleaseInfo, error) { url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", Owner, Repo) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("User-Agent", "routatic-proxy") + req.Header.Set("User-Agent", userAgent(version)) resp, err := client.Do(req) if err != nil { @@ -235,15 +244,15 @@ func AssetName() (string, error) { // Download fetches the release asset and returns the path to the temporary file. func Download(ctx context.Context, info *ReleaseInfo, dir string) (string, error) { - return downloadWithClient(ctx, defaultClient, info, dir) + return downloadWithClient(ctx, defaultClient, "", info, dir) } -func downloadWithClient(ctx context.Context, client *http.Client, info *ReleaseInfo, dir string) (string, error) { +func downloadWithClient(ctx context.Context, client *http.Client, version string, info *ReleaseInfo, dir string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, info.AssetURL, nil) if err != nil { return "", err } - req.Header.Set("User-Agent", "routatic-proxy") + req.Header.Set("User-Agent", userAgent(version)) resp, err := client.Do(req) if err != nil { @@ -288,10 +297,10 @@ func downloadWithClient(ctx context.Context, client *http.Client, info *ReleaseI // VerifyChecksum verifies the downloaded file against checksums.txt. func VerifyChecksum(ctx context.Context, info *ReleaseInfo, assetPath string) error { - return verifyChecksumWithClient(ctx, defaultClient, info, assetPath) + return verifyChecksumWithClient(ctx, defaultClient, "", info, assetPath) } -func verifyChecksumWithClient(ctx context.Context, client *http.Client, info *ReleaseInfo, assetPath string) error { +func verifyChecksumWithClient(ctx context.Context, client *http.Client, version string, info *ReleaseInfo, assetPath string) error { if info.ChecksumURL == "" { return fmt.Errorf("no checksums.txt available") } @@ -300,7 +309,7 @@ func verifyChecksumWithClient(ctx context.Context, client *http.Client, info *Re if err != nil { return err } - req.Header.Set("User-Agent", "routatic-proxy") + req.Header.Set("User-Agent", userAgent(version)) resp, err := client.Do(req) if err != nil { @@ -378,7 +387,7 @@ func Apply(ctx context.Context, opts Options) (*Result, error) { client = defaultClient } - info, err := checkWithClient(ctx, client) + info, err := checkWithClient(ctx, client, opts.CurrentVersion) if err != nil { return nil, err } @@ -396,13 +405,13 @@ func Apply(ctx context.Context, opts Options) (*Result, error) { }, nil } - tempPath, err := downloadWithClient(ctx, client, info, "") + tempPath, err := downloadWithClient(ctx, client, opts.CurrentVersion, info, "") if err != nil { return nil, err } if !opts.SkipChecksum && info.ChecksumURL != "" { - if err := verifyChecksumWithClient(ctx, client, info, tempPath); err != nil { + if err := verifyChecksumWithClient(ctx, client, opts.CurrentVersion, info, tempPath); err != nil { _ = os.Remove(tempPath) return nil, err } From ed83b76914373c208a03d9417bfc4e8f6f6a60d1 Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Sun, 28 Jun 2026 16:57:58 +0200 Subject: [PATCH 5/5] fix: replace ping with timeout for backup deletion in Windows --- internal/updater/replace_windows.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/updater/replace_windows.go b/internal/updater/replace_windows.go index bc2e67e..75e9076 100644 --- a/internal/updater/replace_windows.go +++ b/internal/updater/replace_windows.go @@ -16,8 +16,11 @@ func init() { // This is needed because Windows keeps the running executable locked // until the process exits, so the backup cannot be deleted immediately. func scheduleDeleteWindows(path string) { - // Ping gives the current process time to exit before attempting deletion. - cmd := exec.Command("cmd", "/c", fmt.Sprintf("ping 127.0.0.1 -n 3 > nul & del %s", windowsQuote(path))) + // Wait briefly to give the current process time to exit and release its + // lock on the running executable, then force-delete the backup. timeout.exe + // is used instead of ping so the deletion still works on networks where ICMP + // is blocked or filtered. + cmd := exec.Command("cmd", "/c", fmt.Sprintf("timeout /t 3 /nobreak > nul && del /f %s", windowsQuote(path))) cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP,