package main import ( "bufio" "context" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "runtime" "strings" "time" "github.com/spf13/cobra" "git.warky.dev/wdevs/relspecgo/pkg/buildinfo" "git.warky.dev/wdevs/relspecgo/pkg/updatecheck" ) // windowsInstallerAsset is the release asset built by the NSIS installer step. const windowsInstallerAsset = "relspec-setup-windows-amd64.exe" var ( updateCheckOnly bool updateAssumeYes bool updateAPIURL = updatecheck.DefaultAPIURL ) var updateCmd = &cobra.Command{ Use: "update", Short: "Check for a newer release and offer to install it", Long: `Check the project releases for a version newer than this binary. If one exists you are prompted to update. On Windows the installer is downloaded and started; on other platforms the release page URL is shown. Use --check to only report, and --yes to skip the prompt.`, RunE: func(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute) defer cancel() return runUpdate(ctx, updateOptions{ current: buildinfo.Version, apiURL: updateAPIURL, goos: runtime.GOOS, checkOnly: updateCheckOnly, assumeYes: updateAssumeYes, in: cmd.InOrStdin(), out: cmd.OutOrStdout(), install: downloadAndRunInstaller, }) }, } func init() { updateCmd.Flags().BoolVar(&updateCheckOnly, "check", false, "Only report whether an update is available") updateCmd.Flags().BoolVarP(&updateAssumeYes, "yes", "y", false, "Update without prompting") } type updateOptions struct { current string apiURL string goos string checkOnly bool assumeYes bool in io.Reader out io.Writer // install downloads and starts the installer found at url. install func(ctx context.Context, url string, out io.Writer) error } func runUpdate(ctx context.Context, o updateOptions) error { rel, err := updatecheck.Latest(ctx, nil, o.apiURL) if err != nil { return err } if !updatecheck.IsNewer(o.current, rel.Tag) { _, _ = fmt.Fprintf(o.out, "RelSpec %s is up to date (latest release: %s)\n", o.current, rel.Tag) return nil } _, _ = fmt.Fprintf(o.out, "A newer RelSpec is available: %s (installed: %s)\n", rel.Tag, o.current) if o.checkOnly { _, _ = fmt.Fprintf(o.out, "Release: %s\n", rel.URL) return nil } installer, canInstall := rel.FindAsset(windowsInstallerAsset) if o.goos != "windows" || !canInstall { _, _ = fmt.Fprintf(o.out, "Download it from: %s\n", rel.URL) return nil } if !o.assumeYes && !confirm(o.in, o.out, "Download and run the installer now?") { _, _ = fmt.Fprintf(o.out, "Skipped. Release: %s\n", rel.URL) return nil } return o.install(ctx, installer.URL, o.out) } // confirm asks a yes/no question, defaulting to no. func confirm(in io.Reader, out io.Writer, question string) bool { _, _ = fmt.Fprintf(out, "%s [y/N]: ", question) line, _ := bufio.NewReader(in).ReadString('\n') switch strings.ToLower(strings.TrimSpace(line)) { case "y", "yes": return true } return false } // downloadAndRunInstaller saves the installer to a temp directory and starts // it detached so this process can exit and release relspec.exe. func downloadAndRunInstaller(ctx context.Context, url string, out io.Writer) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return err } resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("downloading installer: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("downloading installer: unexpected status %s", resp.Status) } dir, err := os.MkdirTemp("", "relspec-update-") if err != nil { return err } path := filepath.Join(dir, windowsInstallerAsset) f, err := os.Create(path) if err != nil { return err } if _, err := io.Copy(f, resp.Body); err != nil { _ = f.Close() return fmt.Errorf("downloading installer: %w", err) } if err := f.Close(); err != nil { return err } _, _ = fmt.Fprintf(out, "Starting installer: %s\n", path) return exec.Command(path).Start() }