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
2 changes: 2 additions & 0 deletions pkg/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import (
"github.com/brevdev/brev-cli/pkg/cmd/tasks"
"github.com/brevdev/brev-cli/pkg/cmd/test"
"github.com/brevdev/brev-cli/pkg/cmd/updatemodel"
"github.com/brevdev/brev-cli/pkg/cmd/upgrade"
"github.com/brevdev/brev-cli/pkg/cmd/version"
"github.com/brevdev/brev-cli/pkg/cmd/workspacegroups"
"github.com/brevdev/brev-cli/pkg/cmd/writeconnectionevent"
Expand Down Expand Up @@ -345,6 +346,7 @@ func createCmdTree(cmd *cobra.Command, t *terminal.Terminal, loginCmdStore *stor
cmd.AddCommand(refresh.NewCmdRefresh(t, loginCmdStore))
cmd.AddCommand(register.NewCmdRegister(t, externalNodeCmdStore))
cmd.AddCommand(deregister.NewCmdDeregister(t, externalNodeCmdStore))
cmd.AddCommand(upgrade.NewCmdUpgrade(t, noLoginCmdStore))
cmd.AddCommand(enablessh.NewCmdEnableSSH(t, externalNodeCmdStore))
cmd.AddCommand(grantssh.NewCmdGrantSSH(t, externalNodeCmdStore))
cmd.AddCommand(revokessh.NewCmdRevokeSSH(t, externalNodeCmdStore))
Expand Down
7 changes: 7 additions & 0 deletions pkg/cmd/deregister/deregister.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/brevdev/brev-cli/pkg/config"
"github.com/brevdev/brev-cli/pkg/entity"
"github.com/brevdev/brev-cli/pkg/externalnode"
"github.com/brevdev/brev-cli/pkg/sudo"
"github.com/brevdev/brev-cli/pkg/terminal"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -46,6 +47,7 @@ func (brevSSHKeyRemover) RemoveBrevKeys(u *user.User) ([]string, error) {
type deregisterDeps struct {
platform externalnode.PlatformChecker
prompter terminal.Selector
confirmer terminal.Confirmer
netbird register.NetBirdManager
nodeClients externalnode.NodeClientFactory
registrationStore register.RegistrationStore
Expand All @@ -56,6 +58,7 @@ func defaultDeregisterDeps() deregisterDeps {
return deregisterDeps{
platform: register.LinuxPlatform{},
prompter: register.TerminalPrompter{},
confirmer: register.TerminalPrompter{},
netbird: register.Netbird{},
nodeClients: register.DefaultNodeClientFactory{},
registrationStore: register.NewFileRegistrationStore(),
Expand Down Expand Up @@ -93,6 +96,10 @@ func runDeregister(ctx context.Context, t *terminal.Terminal, s DeregisterStore,
return fmt.Errorf("brev deregister is only supported on Linux")
}

if err := sudo.Gate(t, deps.confirmer, "Device deregistration"); err != nil {
return fmt.Errorf("sudo issue: %w", err)
}

reg, err := deps.registrationStore.Load()
if err != nil {
return breverrors.WrapAndTrace(err)
Expand Down
5 changes: 5 additions & 0 deletions pkg/cmd/deregister/deregister_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ func (m mockSelector) Select(label string, items []string) string {
return m.fn(label, items)
}

type mockConfirmer struct{ confirm bool }

func (m mockConfirmer) ConfirmYesNo(_ string) bool { return m.confirm }

type mockNetBirdManager struct {
called bool
err error
Expand Down Expand Up @@ -131,6 +135,7 @@ func testDeregisterDeps(t *testing.T, svc *fakeNodeService, regStore register.Re
}
return ""
}},
confirmer: mockConfirmer{confirm: true},
netbird: &mockNetBirdManager{},
nodeClients: mockNodeClientFactory{serverURL: server.URL},
registrationStore: regStore,
Expand Down
12 changes: 1 addition & 11 deletions pkg/cmd/open/open.go
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ func runOpenCommand(t *terminal.Terminal, tstore OpenStore, wsIDOrName string, s
return handlePathError(tstore, workspace, errMsg)
}
if strings.Contains(err.Error(), `tmux: command not found`) {
errMsg := "tmux not found on remote instance. This will be installed automatically."
errMsg := "tmux not found on remote instance. Please install it and try again."
return handlePathError(tstore, workspace, errMsg)
}
return breverrors.WrapAndTrace(err)
Expand Down Expand Up @@ -809,16 +809,6 @@ func ensureTmuxInstalled(sshAlias string) error {
checkCmd := fmt.Sprintf("ssh %s 'which tmux >/dev/null 2>&1'", sshAlias)
checkExec := exec.Command("bash", "-c", checkCmd) // #nosec G204
err := checkExec.Run()
if err == nil {
return nil
}

installCmd := fmt.Sprintf("ssh %s 'sudo apt-get update && sudo apt-get install -y tmux'", sshAlias)
installExec := exec.Command("bash", "-c", installCmd) // #nosec G204
installExec.Stderr = os.Stderr
installExec.Stdout = os.Stdout

err = installExec.Run()
if err != nil {
return breverrors.WrapAndTrace(err)
}
Expand Down
5 changes: 5 additions & 0 deletions pkg/cmd/register/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
breverrors "github.com/brevdev/brev-cli/pkg/errors"
"github.com/brevdev/brev-cli/pkg/externalnode"
"github.com/brevdev/brev-cli/pkg/names"
"github.com/brevdev/brev-cli/pkg/sudo"
"github.com/brevdev/brev-cli/pkg/terminal"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -107,6 +108,10 @@ func runRegister(ctx context.Context, t *terminal.Terminal, s RegisterStore, nam
return breverrors.New("brev register is only supported on Linux")
}

if err := sudo.Gate(t, deps.prompter, "Device registration"); err != nil {
return fmt.Errorf("sudo issue: %w", err)
}

alreadyRegistered, err := deps.registrationStore.Exists()
if err != nil {
return breverrors.WrapAndTrace(err)
Expand Down
8 changes: 6 additions & 2 deletions pkg/cmd/register/register_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,8 +246,12 @@ func Test_runRegister_UserCancels(t *testing.T) {

term := terminal.New()
err := runRegister(context.Background(), term, store, "my-spark", "", deps)
if err != nil {
t.Fatalf("expected nil error on cancel, got: %v", err)
// sudo.Gate returns a wrapped error when the user declines
if err == nil {
t.Fatal("expected error when user declines sudo gate")
}
if !strings.Contains(err.Error(), "canceled by user") {
t.Errorf("expected 'canceled by user' error, got: %v", err)
}

// Registration should not exist
Expand Down
32 changes: 32 additions & 0 deletions pkg/cmd/upgrade/detector.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package upgrade

import "os/exec"

// InstallMethod represents how brev was installed on the system.
type InstallMethod int

const (
// InstallMethodDirect means brev was installed via direct binary download.
InstallMethodDirect InstallMethod = 0
// InstallMethodBrew means brev was installed via Homebrew.
InstallMethodBrew InstallMethod = 1
)

// Detector determines how brev was installed.
type Detector interface {
Detect() InstallMethod
}

// SystemDetector checks the actual system for install method.
type SystemDetector struct{}

// Detect checks whether brev was installed via Homebrew or direct download.
func (SystemDetector) Detect() InstallMethod {
if _, err := exec.LookPath("brew"); err != nil {
return InstallMethodDirect
}
if exec.Command("brew", "list", "brev").Run() == nil { //nolint:gosec // intentional brew probe
return InstallMethodBrew
}
return InstallMethodDirect
}
48 changes: 48 additions & 0 deletions pkg/cmd/upgrade/runner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package upgrade

import (
"fmt"
"os"
"os/exec"

"github.com/brevdev/brev-cli/pkg/terminal"
)

const installScriptURL = "https://raw.githubusercontent.com/brevdev/brev-cli/main/bin/install-latest.sh"

// Upgrader executes the actual upgrade process.
type Upgrader interface {
UpgradeViaBrew(t *terminal.Terminal) error
UpgradeViaInstallScript(t *terminal.Terminal) error
}

// SystemUpgrader executes upgrade commands on the real system.
type SystemUpgrader struct{}

// UpgradeViaBrew runs "brew upgrade brev" with output connected to the terminal.
func (SystemUpgrader) UpgradeViaBrew(t *terminal.Terminal) error {
t.Vprint("Running: brew upgrade brev")
t.Vprint("")

cmd := exec.Command("brew", "upgrade", "brev")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("brew upgrade failed: %w", err)
}
return nil
}

// UpgradeViaInstallScript runs the upstream install-latest.sh script via sudo.
func (SystemUpgrader) UpgradeViaInstallScript(t *terminal.Terminal) error {
t.Vprintf("Running: bash -c \"$(curl -fsSL %s)\"\n", installScriptURL)
t.Vprint("")

cmd := exec.Command("bash", "-c", fmt.Sprintf(`curl -fsSL "%s" | bash`, installScriptURL)) //nolint:gosec // URL is a compile-time constant
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("install script failed: %w", err)
}
return nil
}
162 changes: 162 additions & 0 deletions pkg/cmd/upgrade/upgrade.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// Package upgrade provides the brev upgrade command.
package upgrade

import (
"fmt"
"os"

"github.com/brevdev/brev-cli/pkg/cmd/agentskill"
"github.com/brevdev/brev-cli/pkg/cmd/register"
"github.com/brevdev/brev-cli/pkg/cmd/version"
"github.com/brevdev/brev-cli/pkg/store"
"github.com/brevdev/brev-cli/pkg/sudo"
"github.com/brevdev/brev-cli/pkg/terminal"

"github.com/spf13/cobra"
)

// VersionStore fetches the latest release metadata from GitHub.
type VersionStore interface {
GetLatestReleaseMetadata() (*store.GithubReleaseMetadata, error)
}

// SkillInstaller updates agent skill files after a binary upgrade.
type SkillInstaller interface {
InstallSkill(t *terminal.Terminal) error
}

// defaultSkillInstaller calls agentskill.InstallSkill using the real home directory.
type defaultSkillInstaller struct{}

func (defaultSkillInstaller) InstallSkill(t *terminal.Terminal) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("could not determine home directory: %w", err)
}
if err := agentskill.InstallSkill(t, homeDir, false); err != nil {
return fmt.Errorf("install skill: %w", err)
}
return nil
}

type upgradeDeps struct {
detector Detector
upgrader Upgrader
confirmer terminal.Confirmer
skillInstaller SkillInstaller
}

func defaultUpgradeDeps() upgradeDeps {
return upgradeDeps{
detector: SystemDetector{},
upgrader: SystemUpgrader{},
confirmer: register.TerminalPrompter{},
skillInstaller: defaultSkillInstaller{},
}
}

var (
upgradeLong = "Upgrade brev to the latest version."
upgradeExample = " brev upgrade"
)

// NewCmdUpgrade creates the brev upgrade command.
func NewCmdUpgrade(t *terminal.Terminal, versionStore VersionStore) *cobra.Command {
cmd := &cobra.Command{
Annotations: map[string]string{"configuration": ""},
Use: "upgrade",
DisableFlagsInUseLine: true,
Short: "Upgrade brev to the latest version",
Long: upgradeLong,
Example: upgradeExample,
RunE: func(cmd *cobra.Command, args []string) error {
return runUpgrade(t, versionStore, defaultUpgradeDeps())
},
}
return cmd
}

func runUpgrade(t *terminal.Terminal, vs VersionStore, deps upgradeDeps) error {
t.Vprint("")
t.Vprintf("Current version: %s\n", version.Version)

release, err := vs.GetLatestReleaseMetadata()
if err != nil {
return fmt.Errorf("failed to check latest version: %w", err)
}

if release.TagName == version.Version {
t.Vprint(t.Green("Already up to date."))
return nil
}

t.Vprintf("New version available: %s\n", release.TagName)
t.Vprint("")

method := deps.detector.Detect()

var (
upgraded bool
upgradeErr error
)
switch method {
case InstallMethodBrew:
upgraded, upgradeErr = upgradeViaBrew(t, deps)
case InstallMethodDirect:
upgraded, upgradeErr = upgradeViaDirect(t, deps)
default:
return fmt.Errorf("unknown install method")
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also install the latest skill?


if upgradeErr != nil {
return upgradeErr
}

if upgraded {
if err := deps.skillInstaller.InstallSkill(t); err != nil {
t.Vprintf(" Warning: skill update failed: %v\n", err)
t.Vprintf(" You can retry with: brev agent-skill install\n")
}
}

return nil
}

func upgradeViaBrew(t *terminal.Terminal, deps upgradeDeps) (bool, error) {
t.Vprint("Detected install method: Homebrew")
t.Vprint("This will run: brew upgrade brev")
t.Vprint("")

if !deps.confirmer.ConfirmYesNo("Proceed with upgrade?") {
t.Vprint("Upgrade canceled.")
return false, nil
}

t.Vprint("")
if err := deps.upgrader.UpgradeViaBrew(t); err != nil {
return false, fmt.Errorf("brew upgrade: %w", err)
}

t.Vprint("")
t.Vprint(t.Green("Upgrade complete."))
return true, nil
}

func upgradeViaDirect(t *terminal.Terminal, deps upgradeDeps) (bool, error) {
t.Vprint("Detected install method: direct binary install")
t.Vprint("This will download the latest release and install it to /usr/local/bin/brev")
t.Vprint("")

if err := sudo.Gate(t, deps.confirmer, "Upgrade"); err != nil {
return false, fmt.Errorf("sudo issue: %w", err)
}

t.Vprint("")
if err := deps.upgrader.UpgradeViaInstallScript(t); err != nil {
return false, fmt.Errorf("direct upgrade: %w", err)
}

t.Vprint("")
t.Vprint(t.Green("Upgrade complete."))
return true, nil
}
Loading
Loading