diff --git a/README.md b/README.md index ca1111b..316df30 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,23 @@ export PATH="$PATH:$(go env GOPATH)/bin" +### Updating + +```sh +dedalus update +``` + +To check the latest available release without installing it: + +```sh +dedalus update --check +``` + +The updater respects how the CLI was installed. Homebrew installs delegate to +`brew upgrade`, macOS/Linux curl installs rerun the install script for the +current executable directory, and Windows installs print the PowerShell installer +command because Windows cannot replace the running `dedalus.exe` process. + ### Running Locally After cloning the git repository for this project, you can use the diff --git a/pkg/cmd/update.go b/pkg/cmd/update.go new file mode 100644 index 0000000..a3088fe --- /dev/null +++ b/pkg/cmd/update.go @@ -0,0 +1,343 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/urfave/cli/v3" +) + +const ( + installScriptURL = "https://raw.githubusercontent.com/dedalus-labs/dedalus-cli/main/scripts/install.sh" + installPS1URL = "https://raw.githubusercontent.com/dedalus-labs/dedalus-cli/main/scripts/install.ps1" + latestReleaseURL = "https://api.github.com/repos/dedalus-labs/dedalus-cli/releases/latest" + defaultUnixBinDir = ".local/bin" + installMarkerFile = ".dedalus-cli-install" +) + +var updateCommand = cli.Command{ + Name: "update", + Usage: "Update the Dedalus CLI", + UsageText: "dedalus update [--check]", + Category: "CLI", + Suggest: true, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "check", + Usage: "Check for the latest release without installing it.", + }, + }, + Action: handleUpdate, + HideHelpCommand: true, +} + +func init() { + Command.Commands = append(Command.Commands, &updateCommand) +} + +type updateOptions struct { + checkOnly bool +} + +type installMethod int + +const ( + installMethodCurl installMethod = iota + installMethodHomebrewCask + installMethodWindows + installMethodUnknown +) + +type updater struct { + goos string + stdout io.Writer + stderr io.Writer + executable func() (string, error) + lookPath func(string) (string, error) + commandOutput func(context.Context, string, ...string) (string, error) + runCommand func(context.Context, []string, string, ...string) error + httpClient *http.Client + latestURL string +} + +type detectedInstall struct { + method installMethod + exe string +} + +type latestRelease struct { + TagName string `json:"tag_name"` +} + +func handleUpdate(ctx context.Context, c *cli.Command) error { + return newUpdater(c.Root().Writer, c.Root().ErrWriter).update(ctx, updateOptions{ + checkOnly: c.Bool("check"), + }) +} + +func newUpdater(stdout, stderr io.Writer) *updater { + if stdout == nil { + stdout = os.Stdout + } + if stderr == nil { + stderr = os.Stderr + } + u := &updater{ + goos: runtime.GOOS, + stdout: stdout, + stderr: stderr, + executable: os.Executable, + lookPath: exec.LookPath, + httpClient: http.DefaultClient, + latestURL: latestReleaseURL, + } + u.commandOutput = u.defaultCommandOutput + u.runCommand = u.defaultRunCommand + return u +} + +func (u *updater) update(ctx context.Context, opts updateOptions) error { + latest, err := u.latestVersion(ctx) + if err != nil { + return err + } + + current := versionTag(Version) + fmt.Fprintf(u.stdout, "Current version: %s\n", current) + fmt.Fprintf(u.stdout, "Latest version: %s\n", latest) + + if sameVersion(current, latest) { + fmt.Fprintf(u.stdout, "dedalus is already at %s\n", current) + return nil + } + if opts.checkOnly { + return nil + } + + install := u.detectInstall(ctx) + + switch install.method { + case installMethodHomebrewCask: + return u.updateWithHomebrew(ctx) + case installMethodWindows: + return u.printWindowsUpdateCommand(install.exe) + case installMethodCurl: + return u.updateWithInstallScript(ctx, install.exe) + case installMethodUnknown: + return u.printManualUpdate() + default: + return fmt.Errorf("unknown install method %d", install.method) + } +} + +func (u *updater) latestVersion(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.latestURL, nil) + if err != nil { + return "", fmt.Errorf("build latest release request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", fmt.Sprintf("Dedalus/CLI %s", Version)) + + resp, err := u.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("fetch latest release: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("fetch latest release: got %s", resp.Status) + } + + var release latestRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", fmt.Errorf("decode latest release: %w", err) + } + tag := strings.TrimSpace(release.TagName) + if tag == "" { + return "", fmt.Errorf("fetch latest release: missing tag_name") + } + return versionTag(tag), nil +} + +func (u *updater) detectInstall(ctx context.Context) detectedInstall { + exe, err := u.executablePath() + if err != nil { + if u.goos == "windows" { + return detectedInstall{method: installMethodWindows} + } + return detectedInstall{method: installMethodUnknown} + } + + if u.goos == "windows" { + return detectedInstall{method: installMethodWindows, exe: exe} + } + if u.isHomebrewCask(ctx, exe) { + return detectedInstall{method: installMethodHomebrewCask, exe: exe} + } + if u.isLikelyCurlInstall(exe) { + return detectedInstall{method: installMethodCurl, exe: exe} + } + return detectedInstall{method: installMethodUnknown, exe: exe} +} + +func (u *updater) isHomebrewCask(ctx context.Context, exe string) bool { + if u.goos != "darwin" { + return false + } + brew, err := u.lookPath("brew") + if err != nil { + return false + } + prefix, err := u.commandOutput(ctx, brew, "--prefix") + if err != nil || !pathWithin(exe, strings.TrimSpace(prefix)) { + return false + } + return u.commandSucceeds(ctx, brew, "list", "--cask", "--versions", "dedalus") +} + +func (u *updater) isLikelyCurlInstall(exe string) bool { + if u.goos == "windows" { + return false + } + if filepath.Base(exe) != "dedalus" { + return false + } + home, err := os.UserHomeDir() + if err == nil && pathWithin(exe, filepath.Join(home, defaultUnixBinDir)) { + return true + } + if hasInstallScriptMarker(exe) { + return true + } + installDir := os.Getenv("DEDALUS_INSTALL_DIR") + return installDir != "" && pathWithin(exe, installDir) +} + +func (u *updater) updateWithHomebrew(ctx context.Context) error { + brew, err := u.lookPath("brew") + if err != nil { + return fmt.Errorf("find brew: %w", err) + } + args := []string{"upgrade", "--cask", "dedalus"} + fmt.Fprintf(u.stdout, "Running: brew %s\n", strings.Join(args, " ")) + return u.runCommand(ctx, nil, brew, args...) +} + +func (u *updater) updateWithInstallScript(ctx context.Context, exe string) error { + installDir := filepath.Dir(exe) + fmt.Fprintf(u.stdout, "Running installer with DEDALUS_INSTALL_DIR=%s\n", installDir) + return u.runCommand(ctx, []string{"DEDALUS_INSTALL_DIR=" + installDir}, "bash", "-c", "curl -fsSL "+installScriptURL+" | bash") +} + +func (u *updater) printWindowsUpdateCommand(exe string) error { + installDir := "" + if exe != "" { + installDir = filepath.Dir(exe) + } + fmt.Fprintln(u.stdout, "Windows does not allow replacing the running dedalus.exe process.") + fmt.Fprintln(u.stdout, "Run this from a new PowerShell session:") + if installDir != "" { + fmt.Fprintf(u.stdout, " $env:DEDALUS_INSTALL_DIR = '%s'; irm %s | iex\n", powerShellSingleQuoted(installDir), installPS1URL) + return nil + } + fmt.Fprintf(u.stdout, " irm %s | iex\n", installPS1URL) + return nil +} + +func (u *updater) printManualUpdate() error { + fmt.Fprintln(u.stdout, "Could not determine how this dedalus binary was installed.") + fmt.Fprintln(u.stdout, "Update with the package manager or installer you originally used.") + return nil +} + +func (u *updater) executablePath() (string, error) { + exe, err := u.executable() + if err != nil { + return "", fmt.Errorf("locate current executable: %w", err) + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + return exe, nil +} + +func (u *updater) commandSucceeds(ctx context.Context, name string, args ...string) bool { + _, err := u.commandOutput(ctx, name, args...) + return err == nil +} + +func (u *updater) defaultCommandOutput(ctx context.Context, name string, args ...string) (string, error) { + c := exec.CommandContext(ctx, name, args...) + out, err := c.Output() + return string(out), err +} + +func (u *updater) defaultRunCommand(ctx context.Context, env []string, name string, args ...string) error { + c := exec.CommandContext(ctx, name, args...) + if len(env) > 0 { + c.Env = append(os.Environ(), env...) + } + c.Stdin = os.Stdin + c.Stdout = u.stdout + c.Stderr = u.stderr + return c.Run() +} + +func versionTag(version string) string { + version = strings.TrimSpace(version) + if version == "" || strings.HasPrefix(version, "v") { + return version + } + return "v" + version +} + +func sameVersion(a, b string) bool { + return strings.TrimPrefix(versionTag(a), "v") == strings.TrimPrefix(versionTag(b), "v") +} + +func pathWithin(path, dir string) bool { + if strings.TrimSpace(dir) == "" { + return false + } + absPath, ok := comparablePath(path) + if !ok { + return false + } + absDir, ok := comparablePath(dir) + if !ok { + return false + } + rel, err := filepath.Rel(absDir, absPath) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))) +} + +func comparablePath(path string) (string, bool) { + abs, err := filepath.Abs(path) + if err != nil { + return "", false + } + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + abs = resolved + } + return abs, true +} + +func hasInstallScriptMarker(exe string) bool { + info, err := os.Stat(filepath.Join(filepath.Dir(exe), installMarkerFile)) + return err == nil && !info.IsDir() +} + +func powerShellSingleQuoted(value string) string { + return strings.ReplaceAll(value, "'", "''") +} diff --git a/pkg/cmd/update_test.go b/pkg/cmd/update_test.go new file mode 100644 index 0000000..6ff4ee8 --- /dev/null +++ b/pkg/cmd/update_test.go @@ -0,0 +1,323 @@ +package cmd + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func TestLatestVersion(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("latestVersion request method = %q, want GET", r.Method) + } + if r.URL.Path != "/latest" { + t.Errorf("latestVersion request path = %q, want /latest", r.URL.Path) + } + if got := r.Header.Get("Accept"); got != "application/vnd.github+json" { + t.Errorf("latestVersion Accept header = %q, want application/vnd.github+json", got) + } + if got := r.Header.Get("User-Agent"); got == "" { + t.Error("latestVersion User-Agent header is empty") + } + io.WriteString(w, `{"tag_name":"v9.8.7"}`) + })) + defer server.Close() + + updater := newTestUpdater(t) + updater.latestURL = server.URL + "/latest" + + got, err := updater.latestVersion(context.Background()) + if err != nil { + t.Fatalf("latestVersion() returned unexpected error: %v", err) + } + if want := "v9.8.7"; got != want { + t.Errorf("latestVersion() = %q, want %q", got, want) + } +} + +func TestUpdateCheckOnly(t *testing.T) { + t.Parallel() + + server := latestVersionServer(t, "v9.9.9") + defer server.Close() + + var stdout bytes.Buffer + updater := newTestUpdater(t) + updater.stdout = &stdout + updater.latestURL = server.URL + "/latest" + updater.runCommand = func(context.Context, []string, string, ...string) error { + t.Fatal("update --check should not run commands") + return nil + } + + if err := updater.update(context.Background(), updateOptions{checkOnly: true}); err != nil { + t.Fatalf("update(checkOnly: true) returned unexpected error: %v", err) + } + + got := stdout.String() + for _, want := range []string{"Current version: v" + Version, "Latest version: v9.9.9"} { + if !strings.Contains(got, want) { + t.Errorf("update(checkOnly: true) output = %q, want substring %q", got, want) + } + } +} + +func TestUpdateSkipsCurrentVersion(t *testing.T) { + t.Parallel() + + server := latestVersionServer(t, "v"+Version) + defer server.Close() + + var stdout bytes.Buffer + updater := newTestUpdater(t) + updater.stdout = &stdout + updater.latestURL = server.URL + "/latest" + updater.runCommand = func(context.Context, []string, string, ...string) error { + t.Fatal("update should not run commands when current version is latest") + return nil + } + + if err := updater.update(context.Background(), updateOptions{}); err != nil { + t.Fatalf("update() returned unexpected error: %v", err) + } + if got, want := stdout.String(), "dedalus is already at v"+Version; !strings.Contains(got, want) { + t.Errorf("update() output = %q, want substring %q", got, want) + } +} + +func TestUpdateHomebrewCask(t *testing.T) { + t.Parallel() + + prefix := t.TempDir() + exe := filepath.Join(prefix, "bin", "dedalus") + writeExecutable(t, exe) + + server := latestVersionServer(t, "v9.9.9") + defer server.Close() + + var ranName string + var ranArgs []string + updater := newTestUpdater(t) + updater.goos = "darwin" + updater.executable = func() (string, error) { return exe, nil } + updater.latestURL = server.URL + "/latest" + updater.lookPath = func(name string) (string, error) { + if name == "brew" { + return "/opt/homebrew/bin/brew", nil + } + return "", errors.New("not found") + } + updater.commandOutput = func(_ context.Context, name string, args ...string) (string, error) { + switch { + case name == "/opt/homebrew/bin/brew" && slices.Equal(args, []string{"--prefix"}): + return prefix + "\n", nil + case name == "/opt/homebrew/bin/brew" && slices.Equal(args, []string{"list", "--cask", "--versions", "dedalus"}): + return "dedalus 9.8.0\n", nil + default: + return "", errors.New("unexpected command") + } + } + updater.runCommand = func(_ context.Context, _ []string, name string, args ...string) error { + ranName = name + ranArgs = slices.Clone(args) + return nil + } + + if err := updater.update(context.Background(), updateOptions{}); err != nil { + t.Fatalf("update() returned unexpected error: %v", err) + } + if ranName != "/opt/homebrew/bin/brew" { + t.Errorf("update() command name = %q, want /opt/homebrew/bin/brew", ranName) + } + if want := []string{"upgrade", "--cask", "dedalus"}; !slices.Equal(ranArgs, want) { + t.Errorf("update() command args = %v, want %v", ranArgs, want) + } +} + +func TestUpdateCurlInstall(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + exe := filepath.Join(home, ".local", "bin", "dedalus") + writeExecutable(t, exe) + + server := latestVersionServer(t, "v9.9.9") + defer server.Close() + + var ranEnv []string + var ranName string + var ranArgs []string + updater := newTestUpdater(t) + updater.executable = func() (string, error) { return exe, nil } + updater.latestURL = server.URL + "/latest" + updater.runCommand = func(_ context.Context, env []string, name string, args ...string) error { + ranEnv = slices.Clone(env) + ranName = name + ranArgs = slices.Clone(args) + return nil + } + + if err := updater.update(context.Background(), updateOptions{}); err != nil { + t.Fatalf("update() returned unexpected error: %v", err) + } + resolvedExe, err := filepath.EvalSymlinks(exe) + if err != nil { + t.Fatalf("EvalSymlinks(%q) returned unexpected error: %v", exe, err) + } + if want := []string{"DEDALUS_INSTALL_DIR=" + filepath.Dir(resolvedExe)}; !slices.Equal(ranEnv, want) { + t.Errorf("update() env = %v, want %v", ranEnv, want) + } + if ranName != "bash" { + t.Errorf("update() command name = %q, want bash", ranName) + } + if want := []string{"-c", "curl -fsSL " + installScriptURL + " | bash"}; !slices.Equal(ranArgs, want) { + t.Errorf("update() command args = %v, want %v", ranArgs, want) + } +} + +func TestUpdateCustomCurlInstallWithMarker(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + exe := filepath.Join(t.TempDir(), "bin", "dedalus") + writeExecutable(t, exe) + writeInstallMarker(t, filepath.Dir(exe)) + + server := latestVersionServer(t, "v9.9.9") + defer server.Close() + + var ranEnv []string + var ranName string + updater := newTestUpdater(t) + updater.executable = func() (string, error) { return exe, nil } + updater.latestURL = server.URL + "/latest" + updater.runCommand = func(_ context.Context, env []string, name string, args ...string) error { + ranEnv = slices.Clone(env) + ranName = name + return nil + } + + if err := updater.update(context.Background(), updateOptions{}); err != nil { + t.Fatalf("update() returned unexpected error: %v", err) + } + resolvedExe, err := filepath.EvalSymlinks(exe) + if err != nil { + t.Fatalf("EvalSymlinks(%q) returned unexpected error: %v", exe, err) + } + if want := []string{"DEDALUS_INSTALL_DIR=" + filepath.Dir(resolvedExe)}; !slices.Equal(ranEnv, want) { + t.Errorf("update() env = %v, want %v", ranEnv, want) + } + if ranName != "bash" { + t.Errorf("update() command name = %q, want bash", ranName) + } +} + +func TestWindowsUpdatePrintsInstallerCommand(t *testing.T) { + t.Parallel() + + server := latestVersionServer(t, "v9.9.9") + defer server.Close() + + var stdout bytes.Buffer + updater := newTestUpdater(t) + updater.goos = "windows" + updater.stdout = &stdout + updater.latestURL = server.URL + "/latest" + updater.executable = func() (string, error) { + return filepath.Join("C:", "Users", "me", ".local", "bin", "dedalus.exe"), nil + } + + if err := updater.update(context.Background(), updateOptions{}); err != nil { + t.Fatalf("update() returned unexpected error: %v", err) + } + got := stdout.String() + for _, want := range []string{"Windows does not allow replacing the running dedalus.exe process.", "install.ps1", "DEDALUS_INSTALL_DIR"} { + if !strings.Contains(got, want) { + t.Errorf("windows update output = %q, want substring %q", got, want) + } + } +} + +func TestUnknownInstallPrintsManualInstructions(t *testing.T) { + t.Parallel() + + exe := filepath.Join(t.TempDir(), "dedalus") + server := latestVersionServer(t, "v9.9.9") + defer server.Close() + + var stdout bytes.Buffer + updater := newTestUpdater(t) + updater.stdout = &stdout + updater.executable = func() (string, error) { return exe, nil } + updater.latestURL = server.URL + "/latest" + + if err := updater.update(context.Background(), updateOptions{}); err != nil { + t.Fatalf("update() returned unexpected error: %v", err) + } + if got, want := stdout.String(), "Could not determine how this dedalus binary was installed."; !strings.Contains(got, want) { + t.Errorf("unknown install output = %q, want substring %q", got, want) + } +} + +func newTestUpdater(t *testing.T) *updater { + t.Helper() + + updater := newUpdater(io.Discard, io.Discard) + updater.goos = "linux" + updater.executable = func() (string, error) { + return filepath.Join(t.TempDir(), "dedalus"), nil + } + updater.lookPath = func(string) (string, error) { + return "", errors.New("not found") + } + updater.commandOutput = func(context.Context, string, ...string) (string, error) { + return "", errors.New("not found") + } + updater.runCommand = func(context.Context, []string, string, ...string) error { + return nil + } + return updater +} + +func latestVersionServer(t *testing.T, tag string) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/latest" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"tag_name":"`+tag+`"}`) + })) +} + +func writeExecutable(t *testing.T, path string) { + t.Helper() + + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("MkdirAll(%q) returned unexpected error: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte("old"), 0755); err != nil { + t.Fatalf("WriteFile(%q) returned unexpected error: %v", path, err) + } +} + +func writeInstallMarker(t *testing.T, dir string) { + t.Helper() + + if err := os.WriteFile(filepath.Join(dir, installMarkerFile), []byte("method=install-script\n"), 0644); err != nil { + t.Fatalf("WriteFile(%q) returned unexpected error: %v", installMarkerFile, err) + } +} diff --git a/scripts/install.sh b/scripts/install.sh index c00ff36..0d348d2 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4,6 +4,7 @@ set -euo pipefail REPO="dedalus-labs/dedalus-cli" BINARY="dedalus" INSTALL_DIR="${DEDALUS_INSTALL_DIR:-$HOME/.local/bin}" +INSTALL_MARKER=".dedalus-cli-install" TMPDIR_CLEANUP="" RED='\033[0;31m' @@ -52,13 +53,14 @@ get_latest_version() { exit 1 fi - VERSION=$(curl -sI "https://github.com/${REPO}/releases/latest" \ - | grep -i '^location:' \ - | sed 's|.*/tag/||' \ - | tr -d '\r\n') + VERSION=$(curl -fsSL \ + -H 'Accept: application/vnd.github+json' \ + -H 'User-Agent: dedalus-cli-installer' \ + "https://api.github.com/repos/${REPO}/releases/latest" \ + | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') if [[ -z "$VERSION" ]]; then - error "Could not determine latest version" + error "Could not determine latest version from GitHub API" exit 1 fi @@ -96,6 +98,7 @@ download_and_install() { mkdir -p "$INSTALL_DIR" mv "${tmpdir}/${BINARY}" "${INSTALL_DIR}/${BINARY}" chmod +x "${INSTALL_DIR}/${BINARY}" + printf 'method=install-script\nversion=%s\n' "$VERSION" > "${INSTALL_DIR}/${INSTALL_MARKER}" success "Installed ${BINARY} to ${INSTALL_DIR}/${BINARY}" }