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..04228d7 --- /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("rundll32", "url.dll,FileProtocolHandler", 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..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,6 +27,10 @@ func ConfigDir() string { if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { 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") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..8aaabe2 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,36 @@ +package config + +import ( + "os" + "path/filepath" + "runtime" + "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() + 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/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..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,8 +18,12 @@ type githubSession struct { var githubSessionPath = func() string { dir := os.Getenv("XDG_CONFIG_HOME") if dir == "" { - home, _ := os.UserHomeDir() - dir = filepath.Join(home, ".config") + 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/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..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") } + 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 6ed7519..524e314 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,15 +149,37 @@ 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 { os.Remove(tmpPath) + if runtime.GOOS == "windows" { + 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) } + if runtime.GOOS == "windows" { + os.Remove(execPath + ".old") + } + return nil } diff --git a/internal/update/selfupdate_test.go b/internal/update/selfupdate_test.go index 8aae8fe..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"}`) @@ -124,6 +130,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..fd3aeef --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,115 @@ +#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' } + +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 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" + + 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' + + 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 + + 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 - \