core/mise/mise.go
core/mise/mise.goBrowse 1970 files
2,696 tokens
10,203 bytes
Token encoding: o200k_base
Snapshot 21a254f
← Back to SKILL.md
1// helper utilities to run the mise tool on the host2// this is distinct from the mise step builder which generates mise commands to be run inside the container3// for this reason, the commands here are heavily sandboxed from the host environment to avoid picking up host configs4 5package mise6 7import (8 "bytes"9 "fmt"10 "os"11 "os/exec"12 "path/filepath"13 "strings"14 15 "github.com/BurntSushi/toml"16 "github.com/alexflint/go-filemutex"17 "github.com/charmbracelet/log"18 "github.com/railwayapp/railpack/internal/utils"19)20 21const (22 InstallDir = "/tmp/railpack/mise"23 TestInstallDir = "/tmp/railpack/mise-test"24 IdiomaticVersionFileTools = "python,node,ruby,elixir,go,java,yarn,pnpm,bun,deno,dotnet,rust"25 // applied only to the first GetLatestVersion check to skip very recent releases26 MinimumReleaseAge = "14d"27)28 29type Mise struct {30 binaryPath string31 cacheDir string32 githubToken string33}34 35const (36 ErrMiseGetLatestVersion = "failed to resolve version %s of %s"37)38 39func New(cacheDir string) (*Mise, error) {40 binaryPath, err := ensureInstalled(cacheDir)41 if err != nil {42 return nil, fmt.Errorf("failed to ensure mise is installed: %w", err)43 }44 45 // without the GITHUB_TOKEN, mise will 403 us46 githubToken := os.Getenv("GITHUB_TOKEN")47 48 return &Mise{49 binaryPath: binaryPath,50 cacheDir: cacheDir,51 githubToken: githubToken,52 }, nil53}54 55// gets the latest version of a package matching the version constraint56func (m *Mise) GetLatestVersion(pkg, version string) (string, error) {57 _, unlock, err := m.createAndLock(pkg)58 if err != nil {59 return "", err60 }61 defer unlock()62 63 baseEnv := []string{"MISE_NO_CONFIG=1", "MISE_PARANOID=1"}64 65 // a user could eliminate the min release age in their config, or pin a version to a release66 // if they do, we want to make sure they can still install that specific version they want, so we fallback to a env67 // *without* the min age requirement after we've tried to query mise with this requirement first.68 minAgeEnv := append([]string{fmt.Sprintf("MISE_MINIMUM_RELEASE_AGE=%s", MinimumReleaseAge)}, baseEnv...)69 70 noAgeEnv := append([]string{"MISE_MINIMUM_RELEASE_AGE=0s"}, baseEnv...)71 72 var output string73 for i, queryVersion := range versionQueryCandidates(version) {74 // i.e. node@lts75 query := fmt.Sprintf("%s@%s", pkg, queryVersion)76 77 if i == 0 {78 // Prefer versions old enough to avoid newly released regressions.79 output, err = m.runCmdWithEnv(minAgeEnv, "latest", query)80 if err == nil && strings.TrimSpace(output) != "" {81 break82 }83 84 // Fall back without the age filter when a pinned version is newer than85 // MinimumReleaseAge. As of 2026-06-01, mise's uv backend also applies86 // this setting inconsistently between macOS and Linux.87 }88 89 output, err = m.runCmdWithEnv(noAgeEnv, "latest", query)90 if err == nil && strings.TrimSpace(output) != "" {91 break92 }93 }94 95 // TODO should create an error docs entry for this96 if err != nil {97 triedVersions := strings.Join(versionQueryCandidates(version), ", ")98 if strings.Contains(err.Error(), "not found in mise tool registry") {99 return "", fmt.Errorf("package `%s` not available in Mise after trying versions: %s. Try installing as apt package instead", pkg, triedVersions)100 }101 102 return "", fmt.Errorf("failed to get latest version for package `%s` after trying versions: %s: %w", pkg, triedVersions, err)103 }104 105 // TODO seems like an odd case, we should write error docs for it and try to get reports on this error106 latestVersion := strings.TrimSpace(output)107 if latestVersion == "" {108 return "", fmt.Errorf(ErrMiseGetLatestVersion, version, pkg)109 }110 111 return latestVersion, nil112}113 114func (m *Mise) GetAllVersions(pkg, version string) ([]string, error) {115 _, unlock, err := m.createAndLock(pkg)116 if err != nil {117 return nil, err118 }119 defer unlock()120 121 var output string122 for _, queryVersion := range versionQueryCandidates(version) {123 query := fmt.Sprintf("%s@%s", pkg, queryVersion)124 output, err = m.runCmdWithEnv([]string{"MISE_NO_CONFIG=1", "MISE_PARANOID=1"}, "ls-remote", query)125 if err == nil && strings.TrimSpace(output) != "" {126 break127 }128 }129 130 if err != nil {131 return nil, err132 }133 134 lines := strings.Split(strings.TrimSpace(output), "\n")135 var versions []string136 for _, line := range lines {137 version := strings.TrimSpace(line)138 if version == "" || strings.Contains(version, "RC") {139 continue140 }141 versions = append(versions, version)142 }143 144 if len(versions) == 0 {145 return nil, fmt.Errorf(ErrMiseGetLatestVersion, version, pkg)146 }147 148 return versions, nil149}150 151// versionQueryCandidates returns a slice of possible version strings to query with mise,152// normalizing semver versions but preserving special aliases (like "lts"). Semver normalization is153// attempted first for version resolution, then the original input is retried for cases like aliases.154//155// Examples:156//157// versionQueryCandidates("20.10.2") => []string{"20.10.2"}158// versionQueryCandidates("^20.10.2") => []string{"20.10.2", "^20.10.2"}159// versionQueryCandidates("lts") => []string{"lts"}160func versionQueryCandidates(version string) []string {161 semverVersion := utils.ExtractSemverVersion(version)162 if semverVersion == "" {163 // Preserve mise aliases like `lts` instead of querying an empty version.164 return []string{version}165 }166 if semverVersion == version {167 return []string{version}168 }169 170 // Prefer the normalized semver, then retry idiomatic strings that mise accepts directly.171 // https://github.com/railwayapp/railpack/issues/203172 return []string{semverVersion, version}173}174 175// returns the JSON output of 'mise list --current --json' for the app176func (m *Mise) GetCurrentList(appDir string) (string, error) {177 // MISE_TRUSTED_CONFIG_PATHS allows mise to use configs in the app directory without a trust warning178 trustedConfigEnv := fmt.Sprintf("MISE_TRUSTED_CONFIG_PATHS=%s", appDir)179 180 // MISE_CEILING_PATHS prevents mise from searching parent directories, isolating it to the app directory181 // This eliminates the risk of local configuration (when running on a dev machine, for instance) polluting the mise182 // configuration (and therefore packages) that are bundled into the image.183 184 // We set the ceiling to the parent dir so mise can still read configs in appDir itself185 // since MISE_CEILING_PATHS prevents reading the root mise.toml settings186 ceilingPathsEnv := fmt.Sprintf("MISE_CEILING_PATHS=%s", filepath.Dir(appDir))187 188 // eliminates the need to have custom .python-version, etc parsing logic for each provider189 enabledIdiomaticEnv := fmt.Sprintf("MISE_IDIOMATIC_VERSION_FILE_ENABLE_TOOLS=%s", IdiomaticVersionFileTools)190 191 return m.runCmdWithEnv([]string{192 trustedConfigEnv,193 ceilingPathsEnv,194 enabledIdiomaticEnv,195 // MISE_PARANOID enables stricter security validation196 "MISE_PARANOID=1",197 // Safe mode keeps the app's own mise config inert (no code execution or host env mutation) while still reporting versions198 "MISE_SAFE=1",199 }, "--cd", appDir, "list", "--current", "--json")200}201 202// runCmdWithEnv runs a mise command with additional environment variables203func (m *Mise) runCmdWithEnv(extraEnv []string, args ...string) (string, error) {204 cacheDir := filepath.Join(m.cacheDir, "cache")205 dataDir := filepath.Join(m.cacheDir, "data")206 stateDir := filepath.Join(m.cacheDir, "state")207 systemDir := filepath.Join(m.cacheDir, "system")208 209 cmd := exec.Command(m.binaryPath, args...)210 var stdout, stderr bytes.Buffer211 cmd.Stdout = &stdout212 cmd.Stderr = &stderr213 214 // Mise also discovers configs from process CWD, not only --cd. Run outside the monorepo so215 // host mise.toml (e.g. locked=true) does not pollute app version resolution.216 // cacheDir is the install root for the host mise binary (e.g. /tmp/railpack/mise).217 cmd.Dir = m.cacheDir218 219 // https://github.com/jdx/mise/blob/main/src/dirs.rs220 // MISE_SYSTEM_CONFIG_DIR ensures any local config on the host does not interfere with mise commands221 cmd.Env = append(cmd.Env,222 fmt.Sprintf("HOME=%s", m.cacheDir),223 fmt.Sprintf("MISE_CACHE_DIR=%s", cacheDir),224 fmt.Sprintf("MISE_DATA_DIR=%s", dataDir),225 fmt.Sprintf("MISE_STATE_DIR=%s", stateDir),226 fmt.Sprintf("MISE_SYSTEM_CONFIG_DIR=%s", systemDir),227 // TODO doesn't HTTP timeout apply to fetch remote versions too?228 "MISE_HTTP_TIMEOUT=60s",229 "MISE_FETCH_REMOTE_VERSIONS_TIMEOUT=60s",230 // allows for a 2m outage on mise (10ms base backoff retry)231 "MISE_HTTP_RETRIES=5",232 fmt.Sprintf("PATH=%s", os.Getenv("PATH")),233 )234 235 if m.githubToken != "" {236 cmd.Env = append(cmd.Env, fmt.Sprintf("GITHUB_TOKEN=%s", m.githubToken))237 }238 239 if len(extraEnv) > 0 {240 cmd.Env = append(cmd.Env, extraEnv...)241 }242 243 cmdStr := strings.Join(append([]string{m.binaryPath}, args...), " ")244 log.Debugf("Running mise command %s with env: %v", cmdStr, cmd.Env)245 246 if err := cmd.Run(); err != nil {247 return "", fmt.Errorf("failed to run mise command '%s': %w\n%s\n\n%s",248 cmdStr,249 err,250 stdout.String(),251 stderr.String())252 }253 254 log.Debugf("Mise stdout: %s", stdout.String())255 log.Debugf("Mise stderr: %s", stderr.String())256 257 return stdout.String(), nil258}259 260// MiseConfig represents the overall mise configuration261type MiseConfig struct {262 Tools map[string]string `toml:"tools"`263 Settings map[string]any `toml:"settings,omitempty"`264}265 266// used by the container mise logic, but uses the package structs defined in this file267func GenerateMiseToml(packages map[string]string, settings map[string]any) (string, error) {268 config := MiseConfig{269 Tools: packages,270 Settings: settings,271 }272 273 buf := bytes.NewBuffer(nil)274 if err := toml.NewEncoder(buf).Encode(config); err != nil {275 return "", err276 }277 278 return buf.String(), nil279}280 281// lock ensuring mise does not work on the same package concurrently282func (m *Mise) createAndLock(pkg string) (*filemutex.FileMutex, func(), error) {283 fileLockPath := filepath.Join(m.cacheDir, fmt.Sprintf("lock-%s", strings.ReplaceAll(pkg, "/", "-")))284 mu, err := filemutex.New(fileLockPath)285 if err != nil {286 return nil, nil, fmt.Errorf("failed to create mutex: %w", err)287 }288 289 if err := mu.Lock(); err != nil {290 return nil, nil, fmt.Errorf("failed to acquire lock: %w", err)291 }292 293 unlock := func() {294 if err := mu.Unlock(); err != nil {295 log.Printf("failed to release lock: %v", err)296 }297 }298 299 return mu, unlock, nil300}301