core/mise/install.go
core/mise/install.goBrowse 1970 files
2,275 tokens
8,380 bytes
Token encoding: o200k_base
Snapshot 21a254f
← Back to SKILL.md
1package mise2 3import (4 "archive/tar"5 "archive/zip"6 "compress/gzip"7 _ "embed"8 "fmt"9 "io"10 "net/http"11 "os"12 "os/exec"13 "path/filepath"14 "runtime"15 "strings"16 "time"17 18 "github.com/charmbracelet/log"19)20 21//go:embed version.txt22var Version string23 24// a var so tests can point the download at a local server25var githubReleaseBase = "https://github.com/jdx/mise/releases/download"26 27// http.DefaultClient has no timeout of any kind, so a stalled connection would28// hang the build indefinitely. The ceiling is generous to tolerate slow builders.29var downloadClient = &http.Client{Timeout: 5 * time.Minute}30 31// returns name of the mise binary based on the operating system32func getBinaryName() string {33 if runtime.GOOS == "windows" {34 return fmt.Sprintf("mise-%s.exe", Version)35 }36 return fmt.Sprintf("mise-%s", Version)37}38 39// returns platform-specific mise github asset download name40func getAssetName(goos, goarch string) (string, error) {41 var platform string42 43 switch {44 case goos == "linux" && goarch == "amd64":45 platform = "linux-x64-musl"46 case goos == "linux" && goarch == "arm64":47 platform = "linux-arm64-musl"48 case goos == "linux" && goarch == "arm":49 platform = "linux-armv7-musl"50 case goos == "darwin" && goarch == "amd64":51 platform = "macos-x64"52 case goos == "darwin" && goarch == "arm64":53 platform = "macos-arm64"54 case goos == "windows" && goarch == "amd64":55 platform = "windows-x64"56 case goos == "windows" && goarch == "arm64":57 platform = "windows-arm64"58 default:59 return "", fmt.Errorf("unsupported platform: %s %s", goos, goarch)60 }61 62 extension := "tar.gz"63 if goos == "windows" {64 extension = "zip"65 }66 67 return fmt.Sprintf("mise-v%s-%s.%s", Version, platform, extension), nil68}69 70// getBinaryPath returns the full path to the binary71func getBinaryPath(cacheDir string) string {72 return filepath.Join(cacheDir, getBinaryName())73}74 75// ensures the mise binary (at the pinned version) is installed and returns its path76func ensureInstalled(cacheDir string) (string, error) {77 binaryPath := getBinaryPath(cacheDir)78 79 if _, err := os.Stat(binaryPath); err == nil {80 log.Debugf("Mise executable exists at %s", binaryPath)81 return binaryPath, nil82 }83 84 log.Debugf("Mise %s not found, installing", Version)85 86 if err := os.MkdirAll(cacheDir, 0755); err != nil {87 return "", fmt.Errorf("failed to create cache directory: %w", err)88 }89 90 if err := downloadAndInstall(cacheDir); err != nil {91 return "", fmt.Errorf("failed to download and install: %w", err)92 }93 94 if err := validateInstallation(cacheDir); err != nil {95 return "", fmt.Errorf("failed to validate installation: %w", err)96 }97 98 log.Debugf("Installed mise version: %s to %s", Version, binaryPath)99 100 return binaryPath, nil101}102 103func downloadAndInstall(cacheDir string) error {104 assetName, err := getAssetName(runtime.GOOS, runtime.GOARCH)105 if err != nil {106 return err107 }108 109 url := fmt.Sprintf("%s/v%s/%s", githubReleaseBase, Version, assetName)110 binaryPath := getBinaryPath(cacheDir)111 112 log.Debugf("Downloading mise from %s", url)113 114 // Create temporary directory115 tempDir, err := os.MkdirTemp("", "mise-install")116 if err != nil {117 return fmt.Errorf("failed to create temp directory: %w", err)118 }119 defer func() { _ = os.RemoveAll(tempDir) }()120 121 archivePath := filepath.Join(tempDir, assetName)122 if err := downloadArchive(url, archivePath); err != nil {123 return err124 }125 126 if runtime.GOOS == "windows" {127 err = extractZip(archivePath, binaryPath)128 } else {129 err = extractTarGz(archivePath, binaryPath)130 }131 if err != nil {132 return fmt.Errorf("failed to extract archive: %w", err)133 }134 135 if runtime.GOOS != "windows" {136 if err := os.Chmod(binaryPath, 0755); err != nil {137 return fmt.Errorf("failed to set executable permissions: %w", err)138 }139 }140 141 return nil142}143 144// downloads url to archivePath. Transient failures are typed so the caller can145// decide whether to retry; this deliberately does not retry on its own.146func downloadArchive(url, archivePath string) error {147 resp, err := downloadClient.Get(url)148 if err != nil {149 // transport failures (dial timeouts, resets) are never the app's fault150 return &TemporaryError{URL: url, Err: err}151 }152 defer func() { _ = resp.Body.Close() }()153 154 if err := checkDownloadStatus(url, resp); err != nil {155 return err156 }157 158 f, err := os.Create(archivePath)159 if err != nil {160 return fmt.Errorf("failed to create archive file: %w", err)161 }162 defer func() { _ = f.Close() }()163 164 if _, err := io.Copy(f, resp.Body); err != nil {165 // the connection dropped mid-body, leaving a truncated archive166 return &TemporaryError{URL: url, Err: fmt.Errorf("failed to save archive: %w", err)}167 }168 169 return nil170}171 172// classifies the response status. Rate limits and server errors are transient;173// anything else (notably a 404 for a mise version that does not exist) is not.174func checkDownloadStatus(url string, resp *http.Response) error {175 if resp.StatusCode >= 200 && resp.StatusCode < 300 {176 return nil177 }178 179 statusErr := fmt.Errorf("unexpected status %s", resp.Status)180 181 if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {182 return &TemporaryError{URL: url, Err: statusErr}183 }184 185 return fmt.Errorf("failed to download mise from %s: %w", url, statusErr)186}187 188func extractTarGz(archivePath, binaryPath string) error {189 f, err := os.Open(archivePath)190 if err != nil {191 return err192 }193 defer func() { _ = f.Close() }()194 195 gzr, err := gzip.NewReader(f)196 if err != nil {197 return err198 }199 defer func() { _ = gzr.Close() }()200 201 tr := tar.NewReader(gzr)202 binaryPathInArchive := "mise/bin/mise"203 found := false204 205 writeAndMove, cleanup, err := createAtomicWriter(binaryPath)206 if err != nil {207 return err208 }209 defer cleanup()210 211 return writeAndMove(func(tempFile *os.File) error {212 for {213 header, err := tr.Next()214 if err == io.EOF {215 break216 }217 if err != nil {218 return err219 }220 221 if header.Name == binaryPathInArchive {222 if _, err := io.Copy(tempFile, tr); err != nil {223 return err224 }225 found = true226 break227 }228 }229 230 if !found {231 return fmt.Errorf("binary not found in archive at %s", binaryPathInArchive)232 }233 234 return nil235 })236}237 238func extractZip(archivePath, binaryPath string) error {239 r, err := zip.OpenReader(archivePath)240 if err != nil {241 return err242 }243 defer func() { _ = r.Close() }()244 245 writeAndMove, cleanup, err := createAtomicWriter(binaryPath)246 if err != nil {247 return err248 }249 defer cleanup()250 251 binaryName := getBinaryName()252 for _, f := range r.File {253 if strings.HasSuffix(f.Name, binaryName) {254 rc, err := f.Open()255 if err != nil {256 return err257 }258 259 err = writeAndMove(func(tempFile *os.File) error {260 _, err := io.Copy(tempFile, rc)261 _ = rc.Close()262 return err263 })264 265 return err266 }267 }268 269 return fmt.Errorf("binary not found in archive")270}271 272func validateInstallation(cacheDir string) error {273 binaryPath := getBinaryPath(cacheDir)274 cmd := exec.Command(binaryPath, "--version")275 output, err := cmd.Output()276 if err != nil {277 return fmt.Errorf("failed to run version check: %w", err)278 }279 280 versionOutput := string(output)281 if !strings.Contains(versionOutput, Version) {282 return fmt.Errorf("mise version mismatch: expected %s, got %s", Version, strings.TrimSpace(versionOutput))283 }284 285 return nil286}287 288// creates a temporary file and returns a function to atomically write content to the final destination289func createAtomicWriter(targetPath string) (writeAndMove func(write func(tempFile *os.File) error) error, cleanup func(), err error) {290 tempFile, err := os.CreateTemp(filepath.Dir(targetPath), "mise-temp-*")291 if err != nil {292 return nil, nil, fmt.Errorf("failed to create temp file: %w", err)293 }294 tempPath := tempFile.Name()295 296 success := false297 cleanup = func() {298 _ = tempFile.Close()299 if !success {300 _ = os.Remove(tempPath)301 }302 }303 304 writeAndMove = func(write func(tempFile *os.File) error) error {305 if err := write(tempFile); err != nil {306 return err307 }308 309 if err := tempFile.Close(); err != nil {310 return fmt.Errorf("failed to close temp file: %w", err)311 }312 313 if runtime.GOOS != "windows" {314 if err := os.Chmod(tempPath, 0755); err != nil {315 return fmt.Errorf("failed to set executable permissions: %w", err)316 }317 }318 319 if err := os.Rename(tempPath, targetPath); err != nil {320 return fmt.Errorf("failed to move temp file to target: %w", err)321 }322 323 success = true324 return nil325 }326 327 return writeAndMove, cleanup, nil328}329