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
This commit is contained in:
parent
55e652d484
commit
7a1912c02a
18 changed files with 579 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -7,6 +7,7 @@
|
|||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
v
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
|
33
README.md
33
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 <version>` to install new versions and `v use <installed version>` 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.
|
||||
|
|
45
cmd/v.go
Normal file
45
cmd/v.go
Normal file
|
@ -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 <hello@karnov.club>"
|
||||
)
|
||||
|
||||
// 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 <version>", "Downloads, builds and installs a new version of Python.",
|
||||
).AddCommand(
|
||||
"uninstall", subcommands.UninstallPython, "v uninstall <version>", "Uninstalls the given Python version.",
|
||||
).AddCommand(
|
||||
"use", subcommands.Use, "v use <version>", "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)
|
||||
}
|
||||
}
|
3
go.mod
Normal file
3
go.mod
Normal file
|
@ -0,0 +1,3 @@
|
|||
module v
|
||||
|
||||
go 1.21.1
|
78
internal/argparse/cli.go
Normal file
78
internal/argparse/cli.go
Normal file
|
@ -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)
|
||||
}
|
25
internal/argparse/util.go
Normal file
25
internal/argparse/util.go
Normal file
|
@ -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
|
||||
}
|
61
internal/state/state.go
Normal file
61
internal/state/state.go
Normal file
|
@ -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
|
||||
}
|
47
internal/subcommands/initialize.go
Normal file
47
internal/subcommands/initialize.go
Normal file
|
@ -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
|
||||
}
|
122
internal/subcommands/install.go
Normal file
122
internal/subcommands/install.go
Normal file
|
@ -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 <version> from python.org
|
||||
// and stores it at <destination>.
|
||||
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
|
||||
}
|
29
internal/subcommands/ls.go
Normal file
29
internal/subcommands/ls.go
Normal file
|
@ -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
|
||||
}
|
15
internal/subcommands/uninstall.go
Normal file
15
internal/subcommands/uninstall.go
Normal file
|
@ -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
|
||||
}
|
34
internal/subcommands/use.go
Normal file
34
internal/subcommands/use.go
Normal file
|
@ -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
|
||||
}
|
33
internal/subcommands/util.go
Normal file
33
internal/subcommands/util.go
Normal file
|
@ -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
|
||||
}
|
22
internal/subcommands/where.go
Normal file
22
internal/subcommands/where.go
Normal file
|
@ -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
|
||||
}
|
12
internal/subcommands/which.go
Normal file
12
internal/subcommands/which.go
Normal file
|
@ -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
|
||||
}
|
13
internal/util/fmtgroup.go
Normal file
13
internal/util/fmtgroup.go
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
3
scripts/build
Normal file
3
scripts/build
Normal file
|
@ -0,0 +1,3 @@
|
|||
#!/usr/bin/bash
|
||||
|
||||
go build cmd/v.go
|
3
scripts/format
Normal file
3
scripts/format
Normal file
|
@ -0,0 +1,3 @@
|
|||
#!/usr/bin/bash
|
||||
|
||||
go fmt ./...
|
Loading…
Reference in a new issue