From 941b1d86c36f460e93989ec7f12d4841a3ce213d Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sat, 23 May 2026 12:42:44 +0100 Subject: [PATCH] feat(cli): add update subcommand for in-place self-update --- cmd/harness-sync/main.go | 1 + internal/cli/update.go | 54 +++++++++++++++++++++++++++++++++++++ internal/cli/update_test.go | 39 +++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 internal/cli/update.go create mode 100644 internal/cli/update_test.go diff --git a/cmd/harness-sync/main.go b/cmd/harness-sync/main.go index 11f6e37..0c5a49c 100644 --- a/cmd/harness-sync/main.go +++ b/cmd/harness-sync/main.go @@ -36,6 +36,7 @@ func main() { root.AddCommand(cli.NewProfile(reg)) root.AddCommand(cli.NewRollback()) root.AddCommand(cli.NewAdapter(reg)) + root.AddCommand(cli.NewUpdate()) if err := root.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) diff --git a/internal/cli/update.go b/internal/cli/update.go new file mode 100644 index 0000000..94c2763 --- /dev/null +++ b/internal/cli/update.go @@ -0,0 +1,54 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/spf13/cobra" +) + +const installURL = "https://raw.githubusercontent.com/lukaszraczylo/harness-sync/main/install.sh" + +// NewUpdate returns the `update` subcommand. It downloads and installs the +// latest harness-sync release into the same directory the running binary +// lives in. +func NewUpdate() *cobra.Command { + var ( + dryRun bool + dir string + ) + cmd := &cobra.Command{ + Use: "update", + Short: "Download and install the latest harness-sync release in place", + RunE: func(cmd *cobra.Command, _ []string) error { + if dir == "" { + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("resolve current binary path: %w", err) + } + resolved, err := filepath.EvalSymlinks(exe) + if err == nil { + exe = resolved + } + dir = filepath.Dir(exe) + } + pipeline := fmt.Sprintf("curl -fsSL %q | INSTALL_DIR=%q bash", installURL, dir) + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Installing latest harness-sync into %s\n", dir); err != nil { + return err + } + if dryRun { + _, err := fmt.Fprintln(cmd.OutOrStdout(), pipeline) + return err + } + sh := exec.Command("/bin/bash", "-c", pipeline) + sh.Stdout = cmd.OutOrStdout() + sh.Stderr = cmd.ErrOrStderr() + return sh.Run() + }, + } + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print the install command without executing it") + cmd.Flags().StringVar(&dir, "install-dir", "", "override install directory (default: dir of running binary)") + return cmd +} diff --git a/internal/cli/update_test.go b/internal/cli/update_test.go new file mode 100644 index 0000000..113b5a9 --- /dev/null +++ b/internal/cli/update_test.go @@ -0,0 +1,39 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUpdateDryRunPrintsCommand(t *testing.T) { + cmd := NewUpdate() + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetErr(&buf) + cmd.SetArgs([]string{"--dry-run", "--install-dir", "/tmp/test-dir"}) + require.NoError(t, cmd.Execute()) + + out := buf.String() + assert.Contains(t, out, "Installing latest harness-sync into /tmp/test-dir") + assert.Contains(t, out, "curl -fsSL") + assert.Contains(t, out, "install.sh") + assert.Contains(t, out, "INSTALL_DIR=") + // Should NOT have run anything + assert.False(t, strings.Contains(out, "[INFO]"), "dry run must not invoke install.sh") +} + +func TestUpdateResolvesCurrentBinaryDirByDefault(t *testing.T) { + cmd := NewUpdate() + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetArgs([]string{"--dry-run"}) + require.NoError(t, cmd.Execute()) + + // Should mention SOME absolute path (the test binary's directory) + out := buf.String() + assert.Contains(t, out, "Installing latest harness-sync into /") +}