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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ command reports the bare version (e.g. `0.1.0`).
- Docs: `Recipes` is now a parent overview page (with `has_children`); the Connect Opencode Desktop recipe lives on a nested child page, ready for more recipes to be added.
- Docs: Sidebar submenus are expanded by default via a small script in `docs/_includes/head_custom.html`.
- Configurable host-directory bind mounts (`mounts`), including `~/` source expansion and read-only mounts. Mount changes are tracked via a persisted fingerprint and recreate the project VM.
- Self-upgrade: `run`/`shell` check GitHub for a newer opencode-sandbox release (throttled to `upgrade.interval`, default `1d`, minimum `1h`). `upgrade.mode` controls behavior: `prompt` (default; continue / don't-ask-again / upgrade & continue / upgrade & exit, falling back to a notice when non-interactive), `notify`, `auto`, or `auto-exit`. A new `upgrade` command installs the latest release on demand. Checks are skipped for `dev` builds and offline failures are ignored.

### Changed

Expand Down
6 changes: 5 additions & 1 deletion cmd/opencode-sandbox/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import (
launcherconfig "github.com/inoio/opencode-sandbox/internal/viperconfig"
)

var version = "dev"
// devVersion is the version baked into locally built binaries via ldflags
// only for releases.
const devVersion = "dev"

var version = devVersion

// execute runs the CLI with the given arguments and UI.
//
Expand Down
193 changes: 193 additions & 0 deletions cmd/opencode-sandbox/cli_upgrade_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package main

import (
"context"
"errors"
"strings"
"testing"

"github.com/spf13/cobra"

sandboxmsb "github.com/inoio/opencode-sandbox/internal/sandbox/msb"
"github.com/inoio/opencode-sandbox/internal/termio"
"github.com/inoio/opencode-sandbox/internal/upgrade"
launcherconfig "github.com/inoio/opencode-sandbox/internal/viperconfig"
)

func TestUpgrade(t *testing.T) {
origVersion := version
origLatest := upgrade.LatestVersion
origUpdate := upgrade.Update
t.Cleanup(func() {
version = origVersion
upgrade.LatestVersion = origLatest
upgrade.Update = origUpdate
})

t.Run("dev build is rejected", func(t *testing.T) {
version = devVersion
updateCmd, _ := setupUpgradeTestFixtures(t)
err := updateCmd.RunE(updateCmd, nil)
if err == nil {
t.Fatal("expected error for dev build")
}
})

t.Run("up to date", func(t *testing.T) {
version = "1.0.0"
upgrade.LatestVersion = func(context.Context) (string, error) { return "1.0.0", nil }
upgrade.Update = func(context.Context, string) error { t.Fatal("Update should not be called"); return nil }

updateCmd, testUI := setupUpgradeTestFixtures(t)
if err := updateCmd.RunE(updateCmd, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(strings.Join(testUI.InfoCalls, " "), "up to date") {
t.Fatalf("expected 'up to date' info, got %v", testUI.InfoCalls)
}
})

t.Run("update available", func(t *testing.T) {
version = "1.0.0"
upgrade.LatestVersion = func(context.Context) (string, error) { return "2.0.0", nil }
var installed string
upgrade.Update = func(_ context.Context, latest string) error {
installed = latest
return nil
}

updateCmd, testUI := setupUpgradeTestFixtures(t)
if err := updateCmd.RunE(updateCmd, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if installed != "2.0.0" {
t.Fatalf("installed version = %q, want 2.0.0", installed)
}
if !strings.Contains(strings.Join(testUI.InfoCalls, " "), "upgraded") {
t.Fatalf("expected 'upgraded' info, got %v", testUI.InfoCalls)
}
})

t.Run("latest lookup failure", func(t *testing.T) {
version = "1.0.0"
upgrade.LatestVersion = func(context.Context) (string, error) { return "", errors.New("boom") }
upgrade.Update = func(context.Context, string) error { t.Fatal("Update should not be called"); return nil }

updateCmd, _ := setupUpgradeTestFixtures(t)
if err := updateCmd.RunE(updateCmd, nil); err == nil {
t.Fatal("expected error when latest lookup fails")
}
})
}

func setupUpgradeTestFixtures(t *testing.T) (*cobra.Command, *termio.Mock) {
t.Helper()
testUI := termio.NewTestMock(t)
root := buildRootCmd(&testUI)
upgradeCmd, _, _ := root.Find([]string{"upgrade"})
return upgradeCmd, &testUI
}

func TestCheckForUpgrade(t *testing.T) {
origVersion := version
origCheck := upgradeCheck
t.Cleanup(func() {
version = origVersion
upgradeCheck = origCheck
})

t.Run("nil resolver no-ops", func(t *testing.T) {
exit, err := checkForUpgrade(context.Background(), nil, &termio.Mock{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if exit {
t.Fatal("expected exit=false for a nil resolver")
}
})

t.Run("dev version no-ops", func(t *testing.T) {
version = devVersion
r := launcherconfig.NewResolverWithConfig(launcherconfig.Config{})
exit, err := checkForUpgrade(context.Background(), r, &termio.Mock{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if exit {
t.Fatal("expected exit=false for a dev version")
}
})

t.Run("propagates check error", func(t *testing.T) {
upgradeCheck = func(context.Context, upgrade.Options) (upgrade.Result, error) {
return upgrade.Result{}, errors.New("boom")
}
r := launcherconfig.NewResolverWithConfig(launcherconfig.Config{})
if _, err := checkForUpgrade(context.Background(), r, &termio.Mock{}); err == nil {
t.Fatal("expected the upgrade-check error to propagate")
}
})

t.Run("reports exit", func(t *testing.T) {
upgradeCheck = func(context.Context, upgrade.Options) (upgrade.Result, error) {
return upgrade.Result{Exit: true}, nil
}
r := launcherconfig.NewResolverWithConfig(launcherconfig.Config{})
exit, err := checkForUpgrade(context.Background(), r, &termio.Mock{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !exit {
t.Fatal("expected exit=true when the upgrade-and-exit mode ran")
}
})
}

func TestRunShellUpgradeExit(t *testing.T) {
for _, cmdName := range []string{"run", "shell"} {
t.Run(cmdName, func(t *testing.T) {
initTestRepo(t)
origCheck := upgradeCheck
t.Cleanup(func() { upgradeCheck = origCheck })
upgradeCheck = func(context.Context, upgrade.Options) (upgrade.Result, error) {
return upgrade.Result{Exit: true}, nil
}

mock := &sandboxmsb.MockMsbClient{}
root, _ := setupRunMocks(t, mock, &sandboxmsb.MockSandbox{}, cmdName)

// The upgrade-and-exit path must terminate before starting a session,
// so the command succeeds without ever creating a sandbox.
if err := root.Execute(); err != nil {
t.Fatalf("expected clean exit after upgrade-and-exit, got: %v", err)
}
if len(mock.CreatedSandboxCalls) != 0 {
t.Errorf("expected no sandbox creation, got %d calls", len(mock.CreatedSandboxCalls))
}
})
}
}

func TestRunShellUpgradeError(t *testing.T) {
for _, cmdName := range []string{"run", "shell"} {
t.Run(cmdName, func(t *testing.T) {
initTestRepo(t)
origCheck := upgradeCheck
t.Cleanup(func() { upgradeCheck = origCheck })
upgradeCheck = func(context.Context, upgrade.Options) (upgrade.Result, error) {
return upgrade.Result{}, errors.New("upgrade failed")
}

mock := &sandboxmsb.MockMsbClient{}
root, _ := setupRunMocks(t, mock, &sandboxmsb.MockSandbox{}, cmdName)

err := root.Execute()
if err == nil {
t.Fatal("expected the upgrade-check error to abort the command")
}
if !strings.Contains(err.Error(), "upgrade failed") {
t.Errorf("expected error containing 'upgrade failed', got: %v", err)
}
})
}
}
2 changes: 1 addition & 1 deletion cmd/opencode-sandbox/cli_version_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func TestVersion(t *testing.T) {
t.Run("default version is dev", func(t *testing.T) {
orig := version
t.Cleanup(func() { version = orig })
version = "dev"
version = devVersion

versionCmd, testUI := setupVersionTestFixtures(t)

Expand Down
13 changes: 13 additions & 0 deletions cmd/opencode-sandbox/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/inoio/opencode-sandbox/internal/sandbox/options"
sandbox "github.com/inoio/opencode-sandbox/internal/sandbox/vm"
"github.com/inoio/opencode-sandbox/internal/termio"
"github.com/inoio/opencode-sandbox/internal/upgrade"
launcherconfig "github.com/inoio/opencode-sandbox/internal/viperconfig"
)

Expand Down Expand Up @@ -174,6 +175,7 @@ func buildRootCmd(ui termio.UI) *cobra.Command {
rootCmd.AddCommand(buildRunCmd(ui))
rootCmd.AddCommand(buildTreeCmd(rootCmd, ui))
rootCmd.AddCommand(buildVersionCmd(rootCmd, ui))
rootCmd.AddCommand(buildUpgradeCmd(ui))
rootCmd.AddCommand(buildDoctorCmd(ui))
rootCmd.AddCommand(buildBuildCmd(ui))
rootCmd.AddCommand(buildListCmd(ui))
Expand Down Expand Up @@ -215,3 +217,14 @@ func buildVersionCmd(rootCmd *cobra.Command, ui termio.UI) *cobra.Command {
}
return cmd
}

func buildUpgradeCmd(ui termio.UI) *cobra.Command {
return &cobra.Command{
Use: cmdUpgrade,
Args: cobra.NoArgs,
Short: "Check for and install the latest release",
RunE: func(cmd *cobra.Command, _ []string) error {
return upgrade.Upgrade(cmd.Context(), ui, version)
},
}
}
39 changes: 39 additions & 0 deletions cmd/opencode-sandbox/commands_cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"github.com/inoio/opencode-sandbox/internal/git"
"github.com/inoio/opencode-sandbox/internal/sandbox/pruning"
"github.com/inoio/opencode-sandbox/internal/upgrade"
launcherconfig "github.com/inoio/opencode-sandbox/internal/viperconfig"

"github.com/inoio/opencode-sandbox/internal/sandbox/doctor"
Expand Down Expand Up @@ -136,12 +137,40 @@ func runFunc(ui termio.UI) func(cmd *cobra.Command, args []string) error {
if !doctor.CheckAll(cmd.Context(), ui) {
return errors.New("preflight failed")
}
exit, err := checkForUpgrade(ctx, r, ui)
if err != nil {
return err
}
if exit {
return nil
}
}
pruning.AutoPrune(cmd.Context(), r.AutoPruneAge(), isDryRun, &autoPruneOutToVerboseRedirect{UI: ui})
return session.Run(ctx, opts, ui)
}
}

//nolint:gochecknoglobals // test seam for the otherwise hard-to-reach upgrade check
var upgradeCheck = upgrade.Check

// checkForUpgrade runs the self-upgrade check for the current version and
// returns whether the caller should exit (an upgrade-and-exit was performed).
func checkForUpgrade(ctx context.Context, r *launcherconfig.Resolver, ui termio.UI) (bool, error) {
if r == nil {
return false, nil
}
res, err := upgradeCheck(ctx, upgrade.Options{ //nolint:exhaustruct // StatePath/UpdateFunc use their defaults
CurrentVersion: version,
Mode: r.UpgradeMode(),
Interval: r.UpgradeInterval(),
UI: ui,
})
if err != nil {
return false, err
}
return res.Exit, nil
}

// serveOnlyContext builds a cancellable context for the serve-only path.
// It wires SIGINT/SIGTERM and stdin EOF (Ctrl-D) to cancel the context,
// so runServeOnly can exit cleanly and trigger proper teardown
Expand Down Expand Up @@ -171,6 +200,16 @@ func buildShellCmd(ui termio.UI) *cobra.Command {
if !doctor.CheckAll(cmd.Context(), ui) {
return errors.New("preflight failed")
}
isDryRun, _ := cmd.Flags().GetBool(flagDryRun)
if !isDryRun {
exit, err := checkForUpgrade(cmd.Context(), resolverFromContext(cmd.Context()), ui)
if err != nil {
return err
}
if exit {
return nil
}
}
opts, err := extractRunOptions(cmd, ui)
if err != nil {
return err
Expand Down
1 change: 1 addition & 0 deletions cmd/opencode-sandbox/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const (
cmdTree = "tree"
cmdVersion = "version"
cmdConfig = "config"
cmdUpgrade = "upgrade"
cmdShow = "show"
cmdHome = "home"
cmdImage = "image"
Expand Down
12 changes: 12 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,18 @@ opencode-sandbox version

---

### upgrade

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

update vs. upgrade naming clash. upgrade is better IMHO

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Renamed update->upgrade across the package, config keys (upgrade.mode/upgrade.interval), env vars, state file, and docs for consistent naming.


Check for and install the latest release, independent of the `upgrade.mode`/`upgrade.interval` settings that govern the
automatic check on `run`/`shell`. Replaces the running executable with the release binary for your platform; the new
version takes effect on the next invocation.

```console
opencode-sandbox upgrade
```

---

### `opencode-sandbox volume <subcommand>`

The volume group provides manual home volume management.
Expand Down
Loading