From d3ccded86f6db824cb3cccb5ccfa22cd1defcc63 Mon Sep 17 00:00:00 2001 From: Simon Nordberg Date: Tue, 8 Sep 2026 09:25:16 +0200 Subject: [PATCH 1/5] feat: add Windows support (amd64 + arm64) Build and distribute the CLI for Windows alongside Linux and macOS. - Add windows to goreleaser build matrix - Extract shared internal/browser package (deduplicate openBrowser), add Windows case (cmd /c start) - Self-update: append .exe to download binary name on Windows, handle locked-exe replacement by renaming the running binary aside first, clean up .old file on next run - Switch config/cache dir fallbacks from hardcoded ~/.config and ~/.cache to os.UserConfigDir() / os.UserCacheDir() (returns %AppData% / %LocalAppData% on Windows, unchanged on Unix) - Add PowerShell install script (install.ps1) mirroring install.sh - Update upload-release.sh to handle .exe binaries and upload install.ps1 --- .goreleaser.yaml | 1 + cmd/codebahn/main.go | 16 +----- internal/browser/browser.go | 20 +++++++ internal/browser/browser_test.go | 18 +++++++ internal/config/config.go | 4 +- internal/config/config_test.go | 29 ++++++++++ internal/migrate/device_flow.go | 13 ----- internal/migrate/github_session.go | 3 +- internal/migrate/githubapp.go | 3 +- internal/update/cache.go | 4 +- internal/update/cache_test.go | 4 +- internal/update/selfupdate.go | 25 +++++++-- internal/update/selfupdate_test.go | 41 ++++++++++++++ scripts/install.ps1 | 86 ++++++++++++++++++++++++++++++ scripts/upload-release.sh | 12 ++++- 15 files changed, 238 insertions(+), 41 deletions(-) create mode 100644 internal/browser/browser.go create mode 100644 internal/browser/browser_test.go create mode 100644 internal/config/config_test.go create mode 100644 scripts/install.ps1 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 0227d14..a1cc8a9 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -13,6 +13,7 @@ builds: goos: - linux - darwin + - windows goarch: - amd64 - arm64 diff --git a/cmd/codebahn/main.go b/cmd/codebahn/main.go index 4e6b1d7..3df6e16 100644 --- a/cmd/codebahn/main.go +++ b/cmd/codebahn/main.go @@ -5,14 +5,13 @@ import ( "encoding/json" "fmt" "os" - "os/exec" - "runtime" "runtime/debug" "time" "github.com/spf13/cobra" "github.com/codebahn/codebahn-cli/client" + "github.com/codebahn/codebahn-cli/internal/browser" "github.com/codebahn/codebahn-cli/internal/config" "github.com/codebahn/codebahn-cli/internal/gen" "github.com/codebahn/codebahn-cli/internal/migrate" @@ -132,7 +131,7 @@ func authLoginCmd() *cobra.Command { Use: "login", Short: "Authenticate via browser (OAuth2 + PKCE)", RunE: func(cmd *cobra.Command, _ []string) error { - tokenResp, err := oauth.Login(cmd.Context(), loginURL, openBrowser) + tokenResp, err := oauth.Login(cmd.Context(), loginURL, browser.Open) if err != nil { return err } @@ -252,14 +251,3 @@ func checkUpdateInBackground(rootCmd *cobra.Command) func() string { } } } - -func openBrowser(url string) error { - switch runtime.GOOS { - case "linux": - return exec.Command("xdg-open", url).Start() - case "darwin": - return exec.Command("open", url).Start() - default: - return nil - } -} diff --git a/internal/browser/browser.go b/internal/browser/browser.go new file mode 100644 index 0000000..bbdf83e --- /dev/null +++ b/internal/browser/browser.go @@ -0,0 +1,20 @@ +package browser + +import ( + "os/exec" + "runtime" +) + +// Open opens the given URL in the user's default browser. +func Open(url string) error { + switch runtime.GOOS { + case "linux": + return exec.Command("xdg-open", url).Start() + case "darwin": + return exec.Command("open", url).Start() + case "windows": + return exec.Command("cmd", "/c", "start", url).Start() + default: + return nil + } +} diff --git a/internal/browser/browser_test.go b/internal/browser/browser_test.go new file mode 100644 index 0000000..038bc67 --- /dev/null +++ b/internal/browser/browser_test.go @@ -0,0 +1,18 @@ +package browser + +import ( + "runtime" + "testing" +) + +func TestOpen_NoError(t *testing.T) { + // We can't verify the browser actually opens, but we can verify the + // function exists with the expected signature and returns no error on + // the current platform. On CI / headless environments, the underlying + // command may fail to start; that is acceptable. + err := Open("https://example.com") + if err != nil && runtime.GOOS != "linux" { + // On Linux (CI), xdg-open may not be installed. + t.Fatalf("Open returned unexpected error: %v", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index b032bbb..e5acde9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,8 +26,8 @@ func ConfigDir() string { if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { return filepath.Join(dir, "codebahn") } - home, _ := os.UserHomeDir() - return filepath.Join(home, ".config", "codebahn") + dir, _ := os.UserConfigDir() + return filepath.Join(dir, "codebahn") } func ConfigPath() string { diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..94e2532 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,29 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestConfigDir_XDGOverride(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + + got := ConfigDir() + want := filepath.Join(dir, "codebahn") + if got != want { + t.Errorf("ConfigDir() = %q, want %q", got, want) + } +} + +func TestConfigDir_Default(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + + got := ConfigDir() + base, _ := os.UserConfigDir() + want := filepath.Join(base, "codebahn") + if got != want { + t.Errorf("ConfigDir() = %q, want %q", got, want) + } +} diff --git a/internal/migrate/device_flow.go b/internal/migrate/device_flow.go index e83df95..568dd5f 100644 --- a/internal/migrate/device_flow.go +++ b/internal/migrate/device_flow.go @@ -7,8 +7,6 @@ import ( "io" "net/http" "net/url" - "os/exec" - "runtime" "strings" "time" ) @@ -142,14 +140,3 @@ func PollForToken(ctx context.Context, clientID, deviceCode string, interval int } } } - -func openBrowser(u string) error { - switch runtime.GOOS { - case "linux": - return exec.Command("xdg-open", u).Start() - case "darwin": - return exec.Command("open", u).Start() - default: - return nil - } -} diff --git a/internal/migrate/github_session.go b/internal/migrate/github_session.go index 1c1d5d1..0a4d2be 100644 --- a/internal/migrate/github_session.go +++ b/internal/migrate/github_session.go @@ -17,8 +17,7 @@ type githubSession struct { var githubSessionPath = func() string { dir := os.Getenv("XDG_CONFIG_HOME") if dir == "" { - home, _ := os.UserHomeDir() - dir = filepath.Join(home, ".config") + dir, _ = os.UserConfigDir() } return filepath.Join(dir, "codebahn", "github-session.json") } diff --git a/internal/migrate/githubapp.go b/internal/migrate/githubapp.go index a21c3dc..8b30230 100644 --- a/internal/migrate/githubapp.go +++ b/internal/migrate/githubapp.go @@ -8,6 +8,7 @@ import ( "net/url" "github.com/codebahn/codebahn-cli/client" + "github.com/codebahn/codebahn-cli/internal/browser" "github.com/codebahn/codebahn-cli/internal/output" ) @@ -155,7 +156,7 @@ func authenticateGitHubApp(ctx context.Context, c *client.Client, account string fmt.Printf("\nEnter the code at %s\n", code.VerificationURI) fmt.Printf("Code: %s\n\n", output.Bold(code.UserCode)) - _ = openBrowser(code.VerificationURI) + _ = browser.Open(code.VerificationURI) fmt.Print("Waiting for authorization...") tokenResp, err := PollForToken(ctx, cfg.ClientID, code.DeviceCode, code.Interval) diff --git a/internal/update/cache.go b/internal/update/cache.go index 2eec81f..652065a 100644 --- a/internal/update/cache.go +++ b/internal/update/cache.go @@ -9,6 +9,6 @@ func CacheDir() string { if dir := os.Getenv("XDG_CACHE_HOME"); dir != "" { return filepath.Join(dir, "codebahn") } - home, _ := os.UserHomeDir() - return filepath.Join(home, ".cache", "codebahn") + dir, _ := os.UserCacheDir() + return filepath.Join(dir, "codebahn") } diff --git a/internal/update/cache_test.go b/internal/update/cache_test.go index 858691f..fe3dd1e 100644 --- a/internal/update/cache_test.go +++ b/internal/update/cache_test.go @@ -21,8 +21,8 @@ func TestCacheDir_Default(t *testing.T) { t.Setenv("XDG_CACHE_HOME", "") got := CacheDir() - home, _ := os.UserHomeDir() - want := filepath.Join(home, ".cache", "codebahn") + base, _ := os.UserCacheDir() + want := filepath.Join(base, "codebahn") if got != want { t.Errorf("CacheDir() = %q, want %q", got, want) } diff --git a/internal/update/selfupdate.go b/internal/update/selfupdate.go index 6ed7519..c09f8f4 100644 --- a/internal/update/selfupdate.go +++ b/internal/update/selfupdate.go @@ -36,6 +36,9 @@ func Update(rel *Release, execPath string) error { return ErrHomebrew } + // Clean up leftover .old file from a previous Windows update. + os.Remove(execPath + ".old") + tag := "v" + rel.Version base := releasesURL() @@ -54,6 +57,9 @@ func Update(rel *Release, execPath string) error { } binaryName := fmt.Sprintf("codebahn-%s-%s", runtime.GOOS, runtime.GOARCH) + if runtime.GOOS == "windows" { + binaryName += ".exe" + } expectedHash, err := findChecksum(checksumData, binaryName) if err != nil { return err @@ -143,9 +149,22 @@ func replaceBinary(execPath string, data []byte) error { } tmp.Close() - if err := os.Chmod(tmpPath, info.Mode()); err != nil { - os.Remove(tmpPath) - return fmt.Errorf("setting permissions: %w", err) + if runtime.GOOS != "windows" { + if err := os.Chmod(tmpPath, info.Mode()); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("setting permissions: %w", err) + } + } + + // On Windows, a running .exe is locked and cannot be overwritten. + // Rename it aside first (Windows allows renaming a locked file). + if runtime.GOOS == "windows" { + oldPath := execPath + ".old" + os.Remove(oldPath) // clean up from previous update + if err := os.Rename(execPath, oldPath); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("moving old binary aside: %w", err) + } } if err := os.Rename(tmpPath, execPath); err != nil { diff --git a/internal/update/selfupdate_test.go b/internal/update/selfupdate_test.go index 8aae8fe..bb45c41 100644 --- a/internal/update/selfupdate_test.go +++ b/internal/update/selfupdate_test.go @@ -124,6 +124,47 @@ func TestUpdate_BadChecksum(t *testing.T) { } } +func TestFindChecksum_WithExeSuffix(t *testing.T) { + checksumData := []byte( + "aaa111 codebahn-linux-amd64\n" + + "bbb222 codebahn-windows-amd64.exe\n" + + "ccc333 codebahn-darwin-arm64\n", + ) + + hash, err := findChecksum(checksumData, "codebahn-windows-amd64.exe") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if hash != "bbb222" { + t.Errorf("expected bbb222, got %s", hash) + } +} + +func TestReplaceBinary_CleansUpOldFile(t *testing.T) { + dir := t.TempDir() + execPath := filepath.Join(dir, "codebahn") + oldPath := execPath + ".old" + + // Create the current binary and a leftover .old file + os.WriteFile(execPath, []byte("current"), 0755) + os.WriteFile(oldPath, []byte("stale"), 0644) + + // Simulate a full update which should clean up the .old file + srv := setupReleaseServer(t, "3.0.0", "new binary content") + defer srv.Close() + t.Setenv("CODEBAHN_RELEASES_URL", srv.URL+"/cli") + + rel := &Release{Version: "3.0.0", Newer: true} + err := Update(rel, execPath) + if err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(oldPath); err == nil { + t.Error(".old file should have been cleaned up") + } +} + func TestUpdate_HomebrewDetection(t *testing.T) { srv := setupReleaseServer(t, "2.0.0", "new binary") defer srv.Close() diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..2d2afc5 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,86 @@ +#Requires -Version 5.1 +$ErrorActionPreference = 'Stop' + +$BaseUrl = 'https://releases.codebahn.net/cli' +$InstallDir = if ($env:INSTALL_DIR) { $env:INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'Programs\codebahn' } + +function Get-Arch { + switch ($env:PROCESSOR_ARCHITECTURE) { + 'AMD64' { return 'amd64' } + 'ARM64' { return 'arm64' } + default { throw "Unsupported architecture: $env:PROCESSOR_ARCHITECTURE" } + } +} + +function Get-LatestVersion { + $json = Invoke-RestMethod -Uri "$BaseUrl/latest.json" + return $json.version +} + +function Test-Checksum { + param([string]$File, [string]$ChecksumFile, [string]$Name) + + $lines = Get-Content $ChecksumFile + $expected = $null + foreach ($line in $lines) { + $parts = $line -split '\s+' + if ($parts.Length -ge 2 -and ($parts[1] -eq $Name -or $parts[1] -eq "*$Name")) { + $expected = $parts[0] + break + } + } + if (-not $expected) { + throw "No checksum found for $Name" + } + + $actual = (Get-FileHash -Path $File -Algorithm SHA256).Hash.ToLower() + if ($actual -ne $expected) { + throw "Checksum mismatch: expected $expected, got $actual" + } +} + +function Install-Codebahn { + $arch = Get-Arch + $binary = "codebahn-windows-${arch}.exe" + + Write-Host "Fetching latest version... " -NoNewline + $version = Get-LatestVersion + Write-Host "v$version" + + $tag = "v$version" + $tagUrl = "$BaseUrl/$tag" + $tempDir = Join-Path $env:TEMP 'codebahn-install' + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + + $tempBinary = Join-Path $tempDir 'codebahn.exe' + $tempChecksums = Join-Path $tempDir 'checksums.txt' + + try { + Write-Host "Downloading $binary... " -NoNewline + Invoke-WebRequest -Uri "$tagUrl/$binary" -OutFile $tempBinary -UseBasicParsing + Write-Host 'done' + + Write-Host 'Verifying checksum... ' -NoNewline + Invoke-WebRequest -Uri "$tagUrl/checksums.txt" -OutFile $tempChecksums -UseBasicParsing + Test-Checksum -File $tempBinary -ChecksumFile $tempChecksums -Name $binary + Write-Host 'ok' + + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + Move-Item -Path $tempBinary -Destination (Join-Path $InstallDir 'codebahn.exe') -Force + + Write-Host "Installed codebahn v$version to $InstallDir\codebahn.exe" + + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + if ($userPath -notlike "*$InstallDir*") { + Write-Host '' + Write-Host "Warning: $InstallDir is not in your PATH." + Write-Host "Add it with: `$env:Path = `"$InstallDir;`$env:Path`"" + Write-Host "Or permanently: [Environment]::SetEnvironmentVariable('Path', `"$InstallDir;`$([Environment]::GetEnvironmentVariable('Path', 'User'))`", 'User')" + } + } + finally { + Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Install-Codebahn diff --git a/scripts/upload-release.sh b/scripts/upload-release.sh index df8e479..5faba19 100755 --- a/scripts/upload-release.sh +++ b/scripts/upload-release.sh @@ -18,13 +18,16 @@ echo "Uploading binaries..." for dir in dist/codebahn-cli_*; do [ -d "$dir" ] || continue binary="$dir/codebahn" + [ -f "$binary" ] || binary="$dir/codebahn.exe" [ -f "$binary" ] || continue # Extract os and arch from directory name (codebahn-cli_linux_amd64_v1) name=$(basename "$dir" | sed 's/codebahn-cli_//; s/_v[0-9.]*$//') os=$(echo "$name" | cut -d_ -f1) arch=$(echo "$name" | cut -d_ -f2) - upload "$binary" "codebahn-${os}-${arch}" + ext="" + case "$binary" in *.exe) ext=".exe" ;; esac + upload "$binary" "codebahn-${os}-${arch}${ext}" done echo "Uploading archives..." @@ -37,13 +40,18 @@ echo "Uploading checksums..." upload dist/checksums.txt checksums.txt [ -f dist/checksums.txt.asc ] && upload dist/checksums.txt.asc checksums.txt.asc -echo "Uploading install script..." +echo "Uploading install scripts..." SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" aws s3 cp "$SCRIPT_DIR/install.sh" "s3://${BUCKET}/cli/install.sh" \ --endpoint-url "$ENDPOINT" \ --content-type text/plain \ --acl public-read \ --quiet +aws s3 cp "$SCRIPT_DIR/install.ps1" "s3://${BUCKET}/cli/install.ps1" \ + --endpoint-url "$ENDPOINT" \ + --content-type text/plain \ + --acl public-read \ + --quiet echo "Updating latest.json..." echo "{\"version\":\"${VERSION}\"}" | aws s3 cp - \ From 6d6e533a5cb298d7b6e8a563809e9be06b9f28a9 Mon Sep 17 00:00:00 2001 From: Simon Nordberg Date: Tue, 8 Sep 2026 10:12:46 +0200 Subject: [PATCH 2/5] fix: address code review findings for Windows support - browser.Open: add empty title arg to cmd /c start to prevent & in OAuth URLs from being interpreted as command separators - config/cache dirs: only use os.UserConfigDir() / os.UserCacheDir() on Windows; keep ~/.config and ~/.cache on Linux/macOS to avoid orphaning existing config files (macOS would move to ~/Library) - selfupdate: rollback the .old rename if the final rename fails, and clean up .old on success --- internal/browser/browser.go | 2 +- internal/config/config.go | 9 +++++++-- internal/migrate/github_session.go | 8 +++++++- internal/update/cache.go | 9 +++++++-- internal/update/selfupdate.go | 7 +++++++ 5 files changed, 29 insertions(+), 6 deletions(-) diff --git a/internal/browser/browser.go b/internal/browser/browser.go index bbdf83e..513eec9 100644 --- a/internal/browser/browser.go +++ b/internal/browser/browser.go @@ -13,7 +13,7 @@ func Open(url string) error { case "darwin": return exec.Command("open", url).Start() case "windows": - return exec.Command("cmd", "/c", "start", url).Start() + return exec.Command("cmd", "/c", "start", "", url).Start() default: return nil } diff --git a/internal/config/config.go b/internal/config/config.go index e5acde9..feaf0e6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "runtime" ) type Config struct { @@ -26,8 +27,12 @@ func ConfigDir() string { if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { return filepath.Join(dir, "codebahn") } - dir, _ := os.UserConfigDir() - return filepath.Join(dir, "codebahn") + if runtime.GOOS == "windows" { + dir, _ := os.UserConfigDir() + return filepath.Join(dir, "codebahn") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "codebahn") } func ConfigPath() string { diff --git a/internal/migrate/github_session.go b/internal/migrate/github_session.go index 0a4d2be..72d1957 100644 --- a/internal/migrate/github_session.go +++ b/internal/migrate/github_session.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "runtime" "time" ) @@ -17,7 +18,12 @@ type githubSession struct { var githubSessionPath = func() string { dir := os.Getenv("XDG_CONFIG_HOME") if dir == "" { - dir, _ = os.UserConfigDir() + if runtime.GOOS == "windows" { + dir, _ = os.UserConfigDir() + } else { + home, _ := os.UserHomeDir() + dir = filepath.Join(home, ".config") + } } return filepath.Join(dir, "codebahn", "github-session.json") } diff --git a/internal/update/cache.go b/internal/update/cache.go index 652065a..43ba673 100644 --- a/internal/update/cache.go +++ b/internal/update/cache.go @@ -3,12 +3,17 @@ package update import ( "os" "path/filepath" + "runtime" ) func CacheDir() string { if dir := os.Getenv("XDG_CACHE_HOME"); dir != "" { return filepath.Join(dir, "codebahn") } - dir, _ := os.UserCacheDir() - return filepath.Join(dir, "codebahn") + if runtime.GOOS == "windows" { + dir, _ := os.UserCacheDir() + return filepath.Join(dir, "codebahn") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".cache", "codebahn") } diff --git a/internal/update/selfupdate.go b/internal/update/selfupdate.go index c09f8f4..7ea50ff 100644 --- a/internal/update/selfupdate.go +++ b/internal/update/selfupdate.go @@ -169,8 +169,15 @@ func replaceBinary(execPath string, data []byte) error { if err := os.Rename(tmpPath, execPath); err != nil { os.Remove(tmpPath) + if runtime.GOOS == "windows" { + os.Rename(execPath+".old", execPath) + } return fmt.Errorf("replacing binary: %w", err) } + if runtime.GOOS == "windows" { + os.Remove(execPath + ".old") + } + return nil } From 2fcbf489fba82f52c2810bd5a9ffdcd80d90c404 Mon Sep 17 00:00:00 2001 From: Simon Nordberg Date: Thu, 17 Sep 2026 08:54:15 +0200 Subject: [PATCH 3/5] fix: Windows browser open and macOS test failures Use rundll32 instead of cmd /c start to avoid URL truncation on &. Match test expectations to actual code paths (hardcoded .config/.cache, not os.UserConfigDir/os.UserCacheDir which differ on macOS). --- internal/browser/browser.go | 2 +- internal/config/config_test.go | 4 ++-- internal/update/cache_test.go | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/browser/browser.go b/internal/browser/browser.go index 513eec9..04228d7 100644 --- a/internal/browser/browser.go +++ b/internal/browser/browser.go @@ -13,7 +13,7 @@ func Open(url string) error { case "darwin": return exec.Command("open", url).Start() case "windows": - return exec.Command("cmd", "/c", "start", "", url).Start() + return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() default: return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 94e2532..a8bb71e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -21,8 +21,8 @@ func TestConfigDir_Default(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", "") got := ConfigDir() - base, _ := os.UserConfigDir() - want := filepath.Join(base, "codebahn") + home, _ := os.UserHomeDir() + want := filepath.Join(home, ".config", "codebahn") if got != want { t.Errorf("ConfigDir() = %q, want %q", got, want) } diff --git a/internal/update/cache_test.go b/internal/update/cache_test.go index fe3dd1e..858691f 100644 --- a/internal/update/cache_test.go +++ b/internal/update/cache_test.go @@ -21,8 +21,8 @@ func TestCacheDir_Default(t *testing.T) { t.Setenv("XDG_CACHE_HOME", "") got := CacheDir() - base, _ := os.UserCacheDir() - want := filepath.Join(base, "codebahn") + home, _ := os.UserHomeDir() + want := filepath.Join(home, ".cache", "codebahn") if got != want { t.Errorf("CacheDir() = %q, want %q", got, want) } From 8ae0e9d737fc8c088be50482e872972bf5abf6d1 Mon Sep 17 00:00:00 2001 From: Simon Nordberg Date: Thu, 17 Sep 2026 09:04:09 +0200 Subject: [PATCH 4/5] fix: TLS 1.2 enforcement and Windows test compatibility Add [Net.ServicePointManager]::SecurityProtocol = Tls12 before HTTPS calls in install.ps1 for PS 5.1 on older Windows. Append .exe to binary name in test helpers so Update tests resolve the correct path on Windows (matching production selfupdate.go logic). --- internal/update/selfupdate_test.go | 6 ++++++ scripts/install.ps1 | 1 + 2 files changed, 7 insertions(+) diff --git a/internal/update/selfupdate_test.go b/internal/update/selfupdate_test.go index bb45c41..f40e928 100644 --- a/internal/update/selfupdate_test.go +++ b/internal/update/selfupdate_test.go @@ -48,6 +48,9 @@ func setupReleaseServer(t *testing.T, version, binaryContent string) *httptest.S t.Helper() binaryName := fmt.Sprintf("codebahn-%s-%s", runtime.GOOS, runtime.GOARCH) + if runtime.GOOS == "windows" { + binaryName += ".exe" + } h := sha256.Sum256([]byte(binaryContent)) checksumLine := fmt.Sprintf("%x %s\n", h, binaryName) @@ -93,6 +96,9 @@ func TestUpdate_Success(t *testing.T) { func TestUpdate_BadChecksum(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { binaryName := fmt.Sprintf("codebahn-%s-%s", runtime.GOOS, runtime.GOARCH) + if runtime.GOOS == "windows" { + binaryName += ".exe" + } switch { case r.URL.Path == "/cli/latest.json": fmt.Fprint(w, `{"version":"2.0.0"}`) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 2d2afc5..3a6c4b3 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,5 +1,6 @@ #Requires -Version 5.1 $ErrorActionPreference = 'Stop' +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $BaseUrl = 'https://releases.codebahn.net/cli' $InstallDir = if ($env:INSTALL_DIR) { $env:INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'Programs\codebahn' } From 83556fb44e21ab7457977d9a4a1d03ee0126137b Mon Sep 17 00:00:00 2001 From: Simon Nordberg Date: Thu, 17 Sep 2026 09:30:20 +0200 Subject: [PATCH 5/5] fix: address review findings for Windows support - Add PGP signature verification to PowerShell installer (uses gpg when available, warns when not) - Surface rollback error with recovery path in selfupdate on Windows - Make TestConfigDir_Default platform-aware for Windows --- internal/config/config_test.go | 11 +++++++++-- internal/update/selfupdate.go | 4 +++- scripts/install.ps1 | 28 ++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a8bb71e..8aaabe2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "runtime" "testing" ) @@ -21,8 +22,14 @@ func TestConfigDir_Default(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", "") got := ConfigDir() - home, _ := os.UserHomeDir() - want := filepath.Join(home, ".config", "codebahn") + var want string + if runtime.GOOS == "windows" { + dir, _ := os.UserConfigDir() + want = filepath.Join(dir, "codebahn") + } else { + home, _ := os.UserHomeDir() + want = filepath.Join(home, ".config", "codebahn") + } if got != want { t.Errorf("ConfigDir() = %q, want %q", got, want) } diff --git a/internal/update/selfupdate.go b/internal/update/selfupdate.go index 7ea50ff..524e314 100644 --- a/internal/update/selfupdate.go +++ b/internal/update/selfupdate.go @@ -170,7 +170,9 @@ func replaceBinary(execPath string, data []byte) error { if err := os.Rename(tmpPath, execPath); err != nil { os.Remove(tmpPath) if runtime.GOOS == "windows" { - os.Rename(execPath+".old", execPath) + if rbErr := os.Rename(execPath+".old", execPath); rbErr != nil { + return fmt.Errorf("replacing binary: %w (rollback failed: %v; recover manually: rename %s.old to %s)", err, rbErr, execPath, execPath) + } } return fmt.Errorf("replacing binary: %w", err) } diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 3a6c4b3..fd3aeef 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -40,6 +40,31 @@ function Test-Checksum { } } +function Test-Signature { + param([string]$ChecksumFile, [string]$TagUrl) + + $sigFile = Join-Path (Split-Path $ChecksumFile) 'checksums.txt.asc' + Invoke-WebRequest -Uri "$TagUrl/checksums.txt.asc" -OutFile $sigFile -UseBasicParsing + + $gpg = Get-Command gpg -ErrorAction SilentlyContinue + if (-not $gpg) { + Write-Host 'WARNING: gpg not found; PGP signature verification skipped.' + Write-Host ' Install GPG for Windows and re-run, or verify manually:' + Write-Host " gpg --verify `"$sigFile`" `"$ChecksumFile`"" + return + } + + $keyFile = Join-Path (Split-Path $ChecksumFile) 'release-key.asc' + Invoke-WebRequest -Uri "$BaseUrl/release-key.asc" -OutFile $keyFile -UseBasicParsing + & gpg --batch --import $keyFile 2>$null + + $null = & gpg --batch --verify $sigFile $ChecksumFile 2>&1 + if ($LASTEXITCODE -ne 0) { + throw 'PGP signature verification failed. The checksums file may have been tampered with.' + } + Write-Host 'ok' +} + function Install-Codebahn { $arch = Get-Arch $binary = "codebahn-windows-${arch}.exe" @@ -66,6 +91,9 @@ function Install-Codebahn { Test-Checksum -File $tempBinary -ChecksumFile $tempChecksums -Name $binary Write-Host 'ok' + Write-Host 'Verifying PGP signature... ' -NoNewline + Test-Signature -ChecksumFile $tempChecksums -TagUrl $tagUrl + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null Move-Item -Path $tempBinary -Destination (Join-Path $InstallDir 'codebahn.exe') -Force