refactor: extract core logic to a plain Go package

This commit is contained in:
2022-02-26 18:38:37 +00:00
parent b67da4accb
commit 5fcb2b52ab
21 changed files with 1126 additions and 763 deletions

276
manager/config.go Normal file
View File

@@ -0,0 +1,276 @@
package manager
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/sethvargo/go-envconfig"
"gopkg.in/yaml.v3"
)
var ErrConfig = fmt.Errorf("%w", Err)
type Mode string
const (
User Mode = "user"
System Mode = "system"
)
type ConfigFile struct {
Paths ConfigFilePaths `yaml:"paths" json:"paths"`
}
type ConfigFilePaths struct {
Shims string `yaml:"shims" json:"shims" env:"EVM_SHIMS,overwrite"`
Sources string `yaml:"sources" json:"sources" env:"EVM_SOURCES,overwrite"`
Versions string `yaml:"versions" json:"versions" env:"EVM_VERSIONS,overwrite"`
}
type Config struct {
Mode Mode `yaml:"mode" json:"mode"`
Current CurrentConfig `yaml:"current" json:"current"`
Paths PathsConfig `yaml:"paths" json:"paths"`
}
type CurrentConfig struct {
Version string `yaml:"version" json:"version"`
SetBy string `yaml:"set_by,omitempty" json:"set_by,omitempty"`
}
type PathsConfig struct {
Binary string `yaml:"binary" json:"binary"`
Root string `yaml:"root" json:"root"`
Shims string `yaml:"shims" json:"shims"`
Sources string `yaml:"sources" json:"sources"`
Versions string `yaml:"versions" json:"versions"`
}
func NewConfig() (*Config, error) {
mode := Mode(os.Getenv("EVM_MODE"))
if mode != System {
mode = User
}
defaultRoot := filepath.Join(string(os.PathSeparator), "opt", "evm")
if mode == User {
defaultRoot = filepath.Join("$HOME", ".evm")
}
if v := os.Getenv("EVM_ROOT"); v != "" {
defaultRoot = v
}
conf := &Config{
Mode: mode,
Paths: PathsConfig{
Root: defaultRoot,
Shims: "$EVM_ROOT/shims",
Sources: "$EVM_ROOT/sources",
Versions: "$EVM_ROOT/versions",
},
}
var err error
conf.Paths.Root, err = conf.normalizePath(conf.Paths.Root)
if err != nil {
return nil, err
}
err = conf.load()
if err != nil {
return nil, err
}
conf.Paths.Shims, err = conf.normalizePath(conf.Paths.Shims)
if err != nil {
return nil, err
}
conf.Paths.Sources, err = conf.normalizePath(conf.Paths.Sources)
if err != nil {
return nil, err
}
conf.Paths.Versions, err = conf.normalizePath(conf.Paths.Versions)
if err != nil {
return nil, err
}
conf.Paths.Binary, err = os.Executable()
if err != nil {
return nil, err
}
err = conf.PopulateCurrent()
if err != nil {
return nil, err
}
return conf, nil
}
const currentFileName = "current"
func (conf *Config) PopulateCurrent() error {
if v := os.Getenv("EVM_VERSION"); v != "" {
conf.Current.Version = strings.TrimSpace(v)
conf.Current.SetBy = "EVM_VERSION environment variable"
return nil
}
currentFile := filepath.Join(conf.Paths.Root, currentFileName)
b, err := os.ReadFile(currentFile)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil
}
return err
}
if len(b) > 0 {
conf.Current.Version = strings.TrimSpace(string(b))
conf.Current.SetBy = currentFile
}
return nil
}
var configFileNames = []string{
"config.yaml",
"config.yml",
"config.json",
"evm.yaml",
"evm.yml",
"evm.json",
}
func (c *Config) load() error {
var path string
for _, name := range configFileNames {
f := filepath.Join(c.Paths.Root, name)
_, err := os.Stat(f)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
continue
}
return err
}
path = f
break
}
cf := &ConfigFile{}
if path != "" {
var err error
cf, err = c.loadConfigFile(path)
if err != nil {
return err
}
}
err := envconfig.Process(context.Background(), cf)
if err != nil {
return err
}
if cf.Paths.Shims != "" {
c.Paths.Shims = cf.Paths.Shims
}
if cf.Paths.Sources != "" {
c.Paths.Sources = cf.Paths.Sources
}
if cf.Paths.Versions != "" {
c.Paths.Versions = cf.Paths.Versions
}
return nil
}
func (c *Config) loadConfigFile(path string) (*ConfigFile, error) {
if path == "" {
return nil, nil
}
content, err := os.ReadFile(path)
if err != nil {
return nil, err
}
cf := &ConfigFile{}
buf := bytes.NewBuffer(content)
switch filepath.Ext(path) {
case ".yaml", ".yml":
dec := yaml.NewDecoder(buf)
dec.KnownFields(true)
err = dec.Decode(cf)
case ".json":
dec := json.NewDecoder(buf)
dec.DisallowUnknownFields()
err = dec.Decode(cf)
default:
return nil, fmt.Errorf(
`%w"%s" does not have a ".yaml", ".yml", `+
`or ".json" file extension`,
ErrConfig, path,
)
}
if err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
return cf, nil
}
func (c *Config) normalizePath(path string) (string, error) {
path = strings.TrimSpace(path)
var homePrefix string
switch {
case strings.HasPrefix(path, "$HOME") ||
strings.HasPrefix(path, "$home"):
homePrefix = path[0:5]
case strings.HasPrefix(path, "~"):
homePrefix = path[0:1]
}
if homePrefix != "" {
if c.Mode == System {
return "", fmt.Errorf(
`%wEVM_MODE is set to "%s" which prohibits `+
`using "$HOME" or "~" in EVM_ROOT`,
ErrConfig, string(System),
)
}
var home string
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
path = filepath.Join(
home, strings.TrimPrefix(path, homePrefix))
}
if c.Paths.Root == "" {
return path, nil
}
if strings.HasPrefix(path, "$EVM_ROOT") {
path = filepath.Join(c.Paths.Root, path[9:])
} else if !filepath.IsAbs(path) {
path = filepath.Join(c.Paths.Root, path)
}
return path, nil
}

286
manager/manager.go Normal file
View File

@@ -0,0 +1,286 @@
package manager
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"io/fs"
"os"
"path/filepath"
"strings"
"syscall"
)
var (
Err = errors.New("")
ErrVersion = fmt.Errorf("%w", Err)
ErrNoCurrentVersion = fmt.Errorf("%w", ErrVersion)
ErrVersionNotFound = fmt.Errorf("%w", ErrVersion)
ErrBinNotFound = fmt.Errorf("%w", ErrVersion)
)
type Manager struct {
Config *Config
}
func New(config *Config) (*Manager, error) {
if config == nil {
var err error
config, err = NewConfig()
if err != nil {
return nil, err
}
}
return &Manager{Config: config}, nil
}
func (m *Manager) CurrentVersion() string {
return m.Config.Current.Version
}
func (m *Manager) CurrentSetBy() string {
return m.Config.Current.SetBy
}
func (m *Manager) List(ctx context.Context) ([]*Version, error) {
return newVersions(ctx, m.Config)
}
func (m *Manager) Get(ctx context.Context, version string) (*Version, error) {
return newVersion(ctx, m.Config, version)
}
func (m *Manager) Use(ctx context.Context, version string) error {
ver, err := m.Get(ctx, version)
if err != nil {
return err
}
currentFile := filepath.Join(m.Config.Paths.Root, currentFileName)
err = os.WriteFile(currentFile, []byte(ver.Version), 0o644)
if err != nil {
return err
}
err = m.rehashVersions(ctx, false, []*Version{ver})
if err != nil {
return err
}
return nil
}
func (m *Manager) RehashAll(ctx context.Context) error {
versions, err := m.List(ctx)
if err != nil {
return err
}
return m.rehashVersions(ctx, true, versions)
}
func (m *Manager) RehashVersions(
ctx context.Context,
versions []string,
) error {
var vers []*Version
for _, s := range versions {
v, err := m.Get(ctx, s)
if err != nil {
return err
}
vers = append(vers, v)
}
return m.rehashVersions(ctx, false, vers)
}
func (m *Manager) rehashVersions(
ctx context.Context,
tidy bool,
versions []*Version,
) error {
programs := map[string]bool{}
for _, ver := range versions {
for _, bin := range ver.Binaries {
base := filepath.Base(bin)
programs[base] = true
}
}
shims, err := m.ListShims(ctx)
if err != nil {
return err
}
shimMap := map[string]bool{}
for _, s := range shims {
base := filepath.Base(s)
shimMap[base] = true
}
shim, err := m.shim()
if err != nil {
return err
}
err = os.MkdirAll(m.Config.Paths.Shims, 0o755)
if err != nil {
return err
}
for name := range programs {
shimFile := filepath.Join(m.Config.Paths.Shims, name)
err = os.WriteFile(shimFile, shim, 0o755)
if err != nil {
return err
}
var f fs.FileInfo
f, err = os.Stat(shimFile)
if err != nil {
return err
}
if f.Mode().Perm() != 0o755 {
err = os.Chmod(shimFile, 0o755)
if err != nil {
return err
}
}
delete(shimMap, name)
}
if tidy {
for name := range shimMap {
shimFile := filepath.Join(m.Config.Paths.Shims, name)
err := os.Remove(shimFile)
if err != nil {
return err
}
}
}
return nil
}
var shimTemplate = template.Must(template.New("other").Parse(
`#!/usr/bin/env bash
set -e
[ -n "$EVM_DEBUG" ] && set -x
program="${0##*/}"
export EVM_ROOT="{{.Paths.Root}}"
exec "{{.Paths.Binary}}" exec "$program" "$@"
`))
func (m *Manager) shim() ([]byte, error) {
var buf bytes.Buffer
err := shimTemplate.Execute(&buf, m.Config)
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func (m *Manager) ListShims(ctx context.Context) ([]string, error) {
entries, err := os.ReadDir(m.Config.Paths.Shims)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return []string{}, nil
}
return nil, err
}
r := []string{}
for _, entry := range entries {
if ctx.Err() != nil {
return nil, ctx.Err()
}
shimPath := filepath.Join(m.Config.Paths.Shims, entry.Name())
f, err := os.Stat(shimPath)
if err != nil {
return nil, err
}
if f.Mode().IsRegular() {
r = append(r, shimPath)
}
}
return r, nil
}
func (m *Manager) Exec(
ctx context.Context,
program string,
args []string,
) error {
current := m.CurrentVersion()
if current == "" {
return ErrNoCurrentVersion
}
return m.ExecVersion(ctx, current, program, args)
}
func (m *Manager) ExecVersion(
ctx context.Context,
version string,
program string,
args []string,
) error {
ver, err := m.Get(ctx, version)
if err != nil {
return err
}
bin, err := ver.FindBin(program)
if err != nil {
return err
}
if ctx.Err() != nil {
return ctx.Err()
}
execArgs := append([]string{bin}, args...)
execEnv := os.Environ()
// Prepend selected version's bin directory to PATH.
for i := 0; i < len(execEnv); i++ {
if strings.HasPrefix(execEnv[i], "PATH=") {
execEnv[i] = "PATH=" + ver.BinDir + ":" + execEnv[i][5:]
}
}
return syscall.Exec(bin, execArgs, execEnv)
}
func (m *Manager) FindBin(
ctx context.Context,
name string,
) ([]*Version, error) {
versions, err := m.List(ctx)
if err != nil {
return nil, err
}
var availableIn []*Version
for _, ver := range versions {
if _, err := ver.FindBin(name); err == nil {
availableIn = append(availableIn, ver)
}
}
return availableIn, nil
}

114
manager/version.go Normal file
View File

@@ -0,0 +1,114 @@
package manager
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
)
type Version struct {
Version string `yaml:"version" json:"version"`
Current bool `yaml:"current" json:"current"`
Path string `yaml:"path" json:"path"`
BinDir string `yaml:"bin_dir" json:"bin_dir"`
Binaries []string `yaml:"binaries" json:"binaries"`
}
func (ver *Version) FindBin(name string) (string, error) {
for _, b := range ver.Binaries {
if filepath.Base(b) == name {
return b, nil
}
}
return "", fmt.Errorf(
`%wExecutable "%s" not found in Emacs version %s`,
ErrBinNotFound, name, ver.Version,
)
}
func newVersion(
ctx context.Context,
conf *Config,
version string,
) (*Version, error) {
if version == "" {
return nil, fmt.Errorf("%wversion cannot be empty", ErrVersion)
}
path := filepath.Join(conf.Paths.Versions, version)
_, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf(
"%wVersion %s is not available in %s",
ErrVersionNotFound, version, conf.Paths.Versions,
)
}
ver := &Version{
Version: version,
Path: path,
BinDir: filepath.Join(path, "bin"),
Current: version == conf.Current.Version,
}
entries, err := os.ReadDir(ver.BinDir)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, err
}
for _, entry := range entries {
if ctx.Err() != nil {
return nil, ctx.Err()
}
binPath := filepath.Join(ver.BinDir, entry.Name())
f, err := os.Stat(binPath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
continue
}
return nil, err
}
// Ensure f is Regular file and executable.
if f.Mode().IsRegular() && f.Mode().Perm()&0111 == 0111 {
ver.Binaries = append(ver.Binaries, binPath)
}
}
return ver, nil
}
func newVersions(ctx context.Context, conf *Config) ([]*Version, error) {
results := []*Version{}
entries, err := os.ReadDir(conf.Paths.Versions)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, err
}
for _, entry := range entries {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if !entry.IsDir() {
continue
}
ver, err := newVersion(ctx, conf, entry.Name())
if err != nil {
return nil, err
}
results = append(results, ver)
}
return results, nil
}