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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ builds:
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
Expand Down
16 changes: 2 additions & 14 deletions cmd/codebahn/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
}
20 changes: 20 additions & 0 deletions internal/browser/browser.go
Original file line number Diff line number Diff line change
@@ -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
}
}
18 changes: 18 additions & 0 deletions internal/browser/browser_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 5 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"os"
"path/filepath"
"runtime"
)

type Config struct {
Expand All @@ -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")
}
Expand Down
36 changes: 36 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
13 changes: 0 additions & 13 deletions internal/migrate/device_flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import (
"io"
"net/http"
"net/url"
"os/exec"
"runtime"
"strings"
"time"
)
Expand Down Expand Up @@ -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
}
}
9 changes: 7 additions & 2 deletions internal/migrate/github_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"time"
)

Expand All @@ -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")
}
Expand Down
3 changes: 2 additions & 1 deletion internal/migrate/githubapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions internal/update/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
34 changes: 31 additions & 3 deletions internal/update/selfupdate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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
Expand Down Expand Up @@ -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
}
47 changes: 47 additions & 0 deletions internal/update/selfupdate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"}`)
Expand Down Expand Up @@ -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()
Expand Down
Loading