From 7a1912c02a74295cb83ea0f992836a43740807a9 Mon Sep 17 00:00:00 2001 From: Marc Cataford Date: Fri, 3 Nov 2023 00:41:05 -0400 Subject: [PATCH] feat: basic use,install,list,help,init functionality (#1) * feat: basic use,install,list,help,init functionality * docs: README notes * refactor: argument parser documentation + removing redundant comments * feat: add uninstall command * feat: command ordering * build: shortcut tooling to build, format * feat: improve messaging around install progress * refactor: rename argparse utils * docs: more README notes --- .gitignore | 1 + README.md | 33 ++++++++ cmd/v.go | 45 +++++++++++ go.mod | 3 + internal/argparse/cli.go | 78 ++++++++++++++++++ internal/argparse/util.go | 25 ++++++ internal/state/state.go | 61 +++++++++++++++ internal/subcommands/initialize.go | 47 +++++++++++ internal/subcommands/install.go | 122 +++++++++++++++++++++++++++++ internal/subcommands/ls.go | 29 +++++++ internal/subcommands/uninstall.go | 15 ++++ internal/subcommands/use.go | 34 ++++++++ internal/subcommands/util.go | 33 ++++++++ internal/subcommands/where.go | 22 ++++++ internal/subcommands/which.go | 12 +++ internal/util/fmtgroup.go | 13 +++ scripts/build | 3 + scripts/format | 3 + 18 files changed, 579 insertions(+) create mode 100644 cmd/v.go create mode 100644 go.mod create mode 100644 internal/argparse/cli.go create mode 100644 internal/argparse/util.go create mode 100644 internal/state/state.go create mode 100644 internal/subcommands/initialize.go create mode 100644 internal/subcommands/install.go create mode 100644 internal/subcommands/ls.go create mode 100644 internal/subcommands/uninstall.go create mode 100644 internal/subcommands/use.go create mode 100644 internal/subcommands/util.go create mode 100644 internal/subcommands/where.go create mode 100644 internal/subcommands/which.go create mode 100644 internal/util/fmtgroup.go create mode 100644 scripts/build create mode 100644 scripts/format diff --git a/.gitignore b/.gitignore index 3b735ec..541af9e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ *.dll *.so *.dylib +v # Test binary, built with `go test -c` *.test diff --git a/README.md b/README.md index 6041fc6..be8bbbb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,35 @@ # v A version manager you might not want to use. + +## Overview + +`v` is a simple version manager inspired from other tools like [asdf](https://github.com/asdf-vm/asdf), [pyenv](https://github.com/pyenv/pyenv), [n](https://github.com/tj/n) and [nvm](https://github.com/nvm-sh/nvm). At it's core, it's a reinvention of the wheel with some extras. + +- First and foremost, while the first version is about Python version management, the plan is to expand to support a bunch more runtime (with an emphasis on simplifying adding more runtimes to manage); +- A lot of those tools are written as shellscript, which I find somewhat inscrutable. Go is a bit easier to read; +- ...? It's a reason to write some Go. :) + +## Roadmap + +While the plan for the first release is to only support Python runtimes, expanding to others will be next so that `v` can just handle all/most version management needs. + +## Usage + +### Building your own and setting up + +Pre-built binaries are not currently available. You can clone the repository and build your own via `. scripts/build`. + +You should find a suitable place for the binary (`/usr/local/bin` is a good location) and if not already included, add its location to `$PATH`. + +Finally, run `v init` to create directories to store artifacts and state (under `~/.v` unless override using the +`V_ROOT` environment variable) and add `~/.v/shims` to your `$PATH` as well. + +### Usage + +`v` will print a helpful list of available commands. + +The most important things to know include `v install ` to install new versions and `v use ` to use a specific version of Python. + +## Contributing + +The project isn't currently accepting contributions because it's not yet set up to do so. Stay tuned. diff --git a/cmd/v.go b/cmd/v.go new file mode 100644 index 0000000..f47a255 --- /dev/null +++ b/cmd/v.go @@ -0,0 +1,45 @@ +package main + +import ( + "os" + argparse "v/internal/argparse" + stateManager "v/internal/state" + subcommands "v/internal/subcommands" +) + +const ( + Version = "0.0.1" + Author = "mcataford " +) + +// Main entrypoint. +func main() { + args := os.Args[1:] + currentState := stateManager.ReadState() + + cli := argparse.CLI{ + Metadata: map[string]string{ + "Version": Version, + }, + } + + err := cli.AddCommand( + "install", subcommands.InstallPython, "v install ", "Downloads, builds and installs a new version of Python.", + ).AddCommand( + "uninstall", subcommands.UninstallPython, "v uninstall ", "Uninstalls the given Python version.", + ).AddCommand( + "use", subcommands.Use, "v use ", "Selects which Python version to use.", + ).AddCommand( + "ls", subcommands.ListVersions, "v ls", "Lists the installed Python versions.", + ).AddCommand( + "where", subcommands.Where, "v where", "Prints the path to the current Python version.", + ).AddCommand( + "which", subcommands.Which, "v which", "Prints the current Python version.", + ).AddCommand( + "init", subcommands.Initialize, "v init", "Initializes the v state.", + ).Run(args, currentState) + + if err != nil { + panic(err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..40572b4 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module v + +go 1.21.1 diff --git a/internal/argparse/cli.go b/internal/argparse/cli.go new file mode 100644 index 0000000..c9f3304 --- /dev/null +++ b/internal/argparse/cli.go @@ -0,0 +1,78 @@ +package argparse + +import ( + "fmt" + "strings" + stateManager "v/internal/state" +) + +// Command definition for CLI subcommands. +type Command struct { + Label string + Handler func([]string, Flags, stateManager.State) error + Usage string + Description string +} + +// Represents a CLI invocation. +// Must be initialized with commands via AddCommand before running +// with Run. +type CLI struct { + // Commands in enumeration order. + OrderedCommands []string + // Command metadata entries. + Commands map[string]Command + Metadata map[string]string +} + +// Registers a command. +// This specifies a label that is used to route the user input to +// the right command, a handler that is called when the label is used, +// and usage/description details that get included in autogenerated help messaging. +func (c CLI) AddCommand(label string, handler func([]string, Flags, stateManager.State) error, usage string, description string) CLI { + if c.Commands == nil { + c.Commands = map[string]Command{} + c.OrderedCommands = []string{} + } + + c.OrderedCommands = append(c.OrderedCommands, label) + c.Commands[label] = Command{Label: label, Handler: handler, Usage: usage, Description: description} + + return c +} + +// Executes one of the registered commands if any match the provided +// user arguments. +func (c CLI) Run(args []string, currentState stateManager.State) error { + if len(args) == 0 { + c.Help() + return nil + } + + command := args[0] + + if command == "help" { + c.Help() + return nil + } + + flags := CollectFlags(args) + return c.Commands[command].Handler(args, flags, currentState) +} + +// Prints autogenerated help documentation specifying command usage +// and descriptions based on registered commands (see: AddCommand). +func (c CLI) Help() { + usageStrings := []string{} + + for _, commandLabel := range c.OrderedCommands { + command := c.Commands[commandLabel] + usageStrings = append(usageStrings, fmt.Sprintf("\033[1m%-30s\033[0m%s", command.Usage, command.Description)) + } + + helpString := fmt.Sprintf(`v: A simple version manager. (v%s) +--- +%s`, c.Metadata["Version"], strings.Join(usageStrings, "\n")) + + fmt.Println(helpString) +} diff --git a/internal/argparse/util.go b/internal/argparse/util.go new file mode 100644 index 0000000..47726d3 --- /dev/null +++ b/internal/argparse/util.go @@ -0,0 +1,25 @@ +package argparse + +import ( + "strings" +) + +type Flags struct { + Verbose bool +} + +func CollectFlags(args []string) Flags { + collected := Flags{} + + for _, arg := range args { + if !strings.HasPrefix(arg, "--") { + continue + } + + if arg == "--verbose" { + collected.Verbose = true + } + } + + return collected +} diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..53df1b5 --- /dev/null +++ b/internal/state/state.go @@ -0,0 +1,61 @@ +package state + +import ( + "encoding/json" + "io/ioutil" + "os" + "path" + "strings" +) + +// Persistent state used by the CLI to track runtime information +// between calls. +type State struct { + GlobalVersion string `json:"globalVersion"` +} + +func GetStateDirectory() string { + home, _ := os.UserHomeDir() + userDefinedRoot, found := os.LookupEnv("V_ROOT") + + root := path.Join(home, ".v") + + if found { + root = userDefinedRoot + } + + return root +} + +func GetPathFromStateDirectory(suffix string) string { + return path.Join(GetStateDirectory(), suffix) +} + +func ReadState() State { + c, _ := ioutil.ReadFile(GetPathFromStateDirectory("state.json")) + + state := State{} + + json.Unmarshal(c, &state) + + return state +} + +func WriteState(version string) { + state := State{GlobalVersion: version} + + d, _ := json.Marshal(state) + ioutil.WriteFile(GetPathFromStateDirectory("state.json"), d, 0750) +} + +func GetAvailableVersions() []string { + entries, _ := os.ReadDir(GetPathFromStateDirectory("runtimes")) + + versions := []string{} + + for _, d := range entries { + versions = append(versions, strings.TrimPrefix(d.Name(), "py-")) + } + + return versions +} diff --git a/internal/subcommands/initialize.go b/internal/subcommands/initialize.go new file mode 100644 index 0000000..0e385c9 --- /dev/null +++ b/internal/subcommands/initialize.go @@ -0,0 +1,47 @@ +package subcommands + +import ( + "os" + "path" + argparse "v/internal/argparse" + stateManager "v/internal/state" +) + +var DIRECTORIES = []string{ + "cache", + "runtimes", + "shims", +} + +var SHIMS = []string{ + "python", + "python3", +} + +const DEFAULT_PERMISSION = 0775 + +func writeShim(shimPath string) error { + shimContent := []byte("#!/bin/bash\n$(vm where) $@") + if err := os.WriteFile(shimPath, shimContent, DEFAULT_PERMISSION); err != nil { + return err + } + + return nil +} + +// Sets up directories and files used to store downloaded archives, +// installed runtimes and metadata. +func Initialize(args []string, flags argparse.Flags, currentState stateManager.State) error { + stateDirectory := stateManager.GetStateDirectory() + + os.Mkdir(stateDirectory, DEFAULT_PERMISSION) + for _, dir := range DIRECTORIES { + os.Mkdir(stateManager.GetPathFromStateDirectory(dir), DEFAULT_PERMISSION) + } + + for _, shim := range SHIMS { + writeShim(stateManager.GetPathFromStateDirectory(path.Join("shims", shim))) + } + + return nil +} diff --git a/internal/subcommands/install.go b/internal/subcommands/install.go new file mode 100644 index 0000000..ee43dd2 --- /dev/null +++ b/internal/subcommands/install.go @@ -0,0 +1,122 @@ +package subcommands + +import ( + "fmt" + "io" + "net/http" + "os" + "path" + "strings" + "time" + argparse "v/internal/argparse" + stateManager "v/internal/state" + util "v/internal/util" +) + +var pythonReleasesBaseURL = "https://www.python.org/ftp/python" + +type PackageMetadata struct { + ArchivePath string + InstallPath string + Version string +} + +type VersionTag struct { + Major string + Minor string + Patch string +} + +func InstallPython(args []string, flags argparse.Flags, currentState stateManager.State) error { + verbose := flags.Verbose + version := args[1] + + if err := validateVersion(version); err != nil { + return err + } + + packageMetadata, dlerr := downloadSource(version, "") + + if dlerr != nil { + return dlerr + } + _, err := buildFromSource(packageMetadata, verbose) + + if err != nil { + return err + } + + return nil +} + +// Fetches the Python tarball for version from python.org +// and stores it at . +func downloadSource(version string, destination string) (PackageMetadata, error) { + archiveName := fmt.Sprintf("Python-%s.tgz", version) + archivePath := stateManager.GetPathFromStateDirectory(path.Join("cache", archiveName)) + sourceUrl := fmt.Sprintf("%s/%s/%s", pythonReleasesBaseURL, version, archiveName) + file, _ := os.Create(archivePath) + + client := http.Client{} + + dlPrint := util.StartFmtGroup(fmt.Sprintf("Downloading source for Python %s", version)) + + dlPrint(fmt.Sprintf("Fetching from %s", sourceUrl)) + start := time.Now() + resp, err := client.Get(sourceUrl) + + if err != nil { + return PackageMetadata{}, err + } + + defer resp.Body.Close() + + io.Copy(file, resp.Body) + + defer file.Close() + + dlPrint(fmt.Sprintf("✅ Done (%s)", time.Since(start))) + return PackageMetadata{ArchivePath: archivePath, Version: version}, nil +} + +func buildFromSource(pkgMeta PackageMetadata, verbose bool) (PackageMetadata, error) { + buildPrint := util.StartFmtGroup(fmt.Sprintf("Building from source")) + start := time.Now() + + buildPrint(fmt.Sprintf("Unpacking source for %s", pkgMeta.ArchivePath)) + + _, untarErr := RunCommand([]string{"tar", "zxvf", pkgMeta.ArchivePath}, stateManager.GetPathFromStateDirectory("cache"), !verbose) + + if untarErr != nil { + return pkgMeta, untarErr + } + + unzippedRoot := strings.TrimSuffix(pkgMeta.ArchivePath, path.Ext(pkgMeta.ArchivePath)) + + buildPrint("Configuring installer") + + targetDirectory := stateManager.GetPathFromStateDirectory(path.Join("runtimes", fmt.Sprintf("py-%s", pkgMeta.Version))) + + _, configureErr := RunCommand([]string{"./configure", fmt.Sprintf("--prefix=%s", targetDirectory), "--enable-optimizations"}, unzippedRoot, !verbose) + + if configureErr != nil { + return pkgMeta, configureErr + } + + buildPrint("Building") + _, buildErr := RunCommand([]string{"make", "altinstall"}, unzippedRoot, !verbose) + + if buildErr != nil { + return pkgMeta, buildErr + } + + if cleanupErr := os.RemoveAll(unzippedRoot); cleanupErr != nil { + return pkgMeta, cleanupErr + } + + pkgMeta.InstallPath = targetDirectory + + buildPrint(fmt.Sprintf("Installed Python %s at %s", pkgMeta.Version, pkgMeta.InstallPath)) + buildPrint(fmt.Sprintf("✅ Done (%s)", time.Since(start))) + return pkgMeta, nil +} diff --git a/internal/subcommands/ls.go b/internal/subcommands/ls.go new file mode 100644 index 0000000..a9c331b --- /dev/null +++ b/internal/subcommands/ls.go @@ -0,0 +1,29 @@ +package subcommands + +import ( + "fmt" + "os" + "strings" + argparse "v/internal/argparse" + stateManager "v/internal/state" +) + +func ListVersions(args []string, flags argparse.Flags, currentState stateManager.State) error { + runtimesDir := stateManager.GetPathFromStateDirectory("runtimes") + entries, err := os.ReadDir(runtimesDir) + + if err != nil { + return err + } + + if len(entries) == 0 { + fmt.Println("No versions installed!") + return nil + } + + for _, d := range entries { + fmt.Println(strings.TrimPrefix(d.Name(), "py-")) + } + + return nil +} diff --git a/internal/subcommands/uninstall.go b/internal/subcommands/uninstall.go new file mode 100644 index 0000000..cecd21c --- /dev/null +++ b/internal/subcommands/uninstall.go @@ -0,0 +1,15 @@ +package subcommands + +import ( + "fmt" + "os" + "path" + argparse "v/internal/argparse" + stateManager "v/internal/state" +) + +func UninstallPython(args []string, flags argparse.Flags, currentState stateManager.State) error { + runtimePath := stateManager.GetPathFromStateDirectory(path.Join("runtimes", fmt.Sprintf("py-%s", args[1]))) + err := os.RemoveAll(runtimePath) + return err +} diff --git a/internal/subcommands/use.go b/internal/subcommands/use.go new file mode 100644 index 0000000..0412882 --- /dev/null +++ b/internal/subcommands/use.go @@ -0,0 +1,34 @@ +package subcommands + +import ( + "errors" + "fmt" + argparse "v/internal/argparse" + stateManager "v/internal/state" +) + +func Use(args []string, flags argparse.Flags, currentState stateManager.State) error { + version := args[1] + if err := validateVersion(version); err != nil { + return err + } + + availableVersions := stateManager.GetAvailableVersions() + + found := false + for _, v := range availableVersions { + if v == version { + found = true + break + } + } + + if !found { + return errors.New("Version not installed.") + } + + stateManager.WriteState(version) + fmt.Printf("Now using Python %s\n", version) + + return nil +} diff --git a/internal/subcommands/util.go b/internal/subcommands/util.go new file mode 100644 index 0000000..00d0f29 --- /dev/null +++ b/internal/subcommands/util.go @@ -0,0 +1,33 @@ +package subcommands + +import ( + "errors" + "os" + "os/exec" + "strings" +) + +func validateVersion(version string) error { + if splitVersion := strings.Split(version, "."); len(splitVersion) != 3 { + return errors.New("Invalid version string. Expected format 'a.b.c'.") + } + + return nil +} + +func RunCommand(command []string, cwd string, quiet bool) (string, error) { + cmd := exec.Command(command[0], command[1:]...) + + cmd.Dir = cwd + + if !quiet { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } + + if err := cmd.Run(); err != nil { + return "", err + } + + return "", nil +} diff --git a/internal/subcommands/where.go b/internal/subcommands/where.go new file mode 100644 index 0000000..dd79f4f --- /dev/null +++ b/internal/subcommands/where.go @@ -0,0 +1,22 @@ +package subcommands + +import ( + "fmt" + "strings" + argparse "v/internal/argparse" + stateManager "v/internal/state" +) + +func versionStringToStruct(version string) VersionTag { + splitVersion := strings.Split(version, ".") + + return VersionTag{Major: splitVersion[0], Minor: splitVersion[1], Patch: splitVersion[2]} +} + +func Where(args []string, flags argparse.Flags, currentState stateManager.State) error { + version := currentState.GlobalVersion + tag := versionStringToStruct(version) + withoutPatch := fmt.Sprintf("%s.%s", tag.Major, tag.Minor) + fmt.Printf("%s/runtimes/py-%s/bin/python%s\n", stateManager.GetStateDirectory(), currentState.GlobalVersion, withoutPatch) + return nil +} diff --git a/internal/subcommands/which.go b/internal/subcommands/which.go new file mode 100644 index 0000000..ca0747e --- /dev/null +++ b/internal/subcommands/which.go @@ -0,0 +1,12 @@ +package subcommands + +import ( + "fmt" + argparse "v/internal/argparse" + stateManager "v/internal/state" +) + +func Which(args []string, flags argparse.Flags, currentState stateManager.State) error { + fmt.Println(currentState.GlobalVersion) + return nil +} diff --git a/internal/util/fmtgroup.go b/internal/util/fmtgroup.go new file mode 100644 index 0000000..56286a7 --- /dev/null +++ b/internal/util/fmtgroup.go @@ -0,0 +1,13 @@ +package util + +import ( + "fmt" +) + +func StartFmtGroup(label string) func(string) { + fmt.Printf("\033[1m%s\033[0m\n", label) + + return func(message string) { + fmt.Printf(" %s\n", message) + } +} diff --git a/scripts/build b/scripts/build new file mode 100644 index 0000000..96c2d25 --- /dev/null +++ b/scripts/build @@ -0,0 +1,3 @@ +#!/usr/bin/bash + +go build cmd/v.go diff --git a/scripts/format b/scripts/format new file mode 100644 index 0000000..bc2d5e5 --- /dev/null +++ b/scripts/format @@ -0,0 +1,3 @@ +#!/usr/bin/bash + +go fmt ./...