Skip to content

Commit

Permalink
Mature CLI and help, add pre-flight check for PATH, add getting-start…
Browse files Browse the repository at this point in the history
…ed help to the root command, fix sorting/finding the latest version of a tool, name tools after the download, support Github releases that do not use archives or store the binary in a sub-directory, misc. refactoring

* Update install logic to process `provider:source:[version]`.
* Fix `install` command to be helpful when no tool is specified.
* Add `list` command to list all tools, or installed versions of a particular tool.
* Update `RunShim` to default to the only installed  version of a tool if no desired version is specified.
* Update `createShim` to check for an existing shim first.
* Renamed some functions to refer to "command" as "tool." for clarity.
* Fix copying of Github releases that are not archived (where the release is a single binary) by updating `ExtractFile` to return whether anything was extracted
* The base Github asset name (download file minus version / OS / architecture details) is used as the tool name, instead of the Github repository name.
* `MatchAssetByOsAndArch` now uses OS aliases, such as `macOS` for `Darwin`.
* `ExtractFiles` now flattens directory-structures, useful for tools that place the binary in a sub-directory.
* Use `github.com/hashicorp/go-version` to properly sort semver versions RE: finding the latest installed one.
* Update Github `findTagForVersion` to use the latest release tag when `latest` or an empty version is specified. This ends up simplifying the Github entrpoint to a single function: DownloadReleaseForVersion.
* Bump the default HTTP client seconds to 30.
  • Loading branch information
ivanfetch committed Jul 22, 2022
1 parent 5e34dd2 commit c523a04
Show file tree
Hide file tree
Showing 15 changed files with 529 additions and 222 deletions.
11 changes: 4 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,11 @@ JKL is a version manager for other command-line tools. It installs tools quickly
* Defaults can be set by configuration files in higher-level parent directories. Child configuration files can specify only a tool's version, with parent configuration files specifying where that tool can be downloaded.
* Install multiple tools in parallel - useful when bootstrapping a new workstation or standard versions of tooling used by a project.

## JKL Installation
## Installation

This process is mostly incomplete as I experiment for the best user experience. The intent is:

* Download a Github release or build JKL on your own if desired.
* Put the `jkl` binary in your `$PATH`, ideally the same location where you would like JKL to create shims for JKL-managed tools.
* Optionally override the directory where JKL manages tools that it installs. This defaults to `~/.jkl/installs`
* Use JKL to install your first tool by running `jkl -i github:User/Repo` (replacing `User` and `Repo` with a Github user and repository).
* Download [a jkl release](https://github.com/ivanfetch/jkl/releases) or build jkl from a clone of this repository by running `go build cmd/jkl`
* Run `jkl` to performa pre-fight check. This will instruct you to add the `~/.jkl` directory to your path, and install your first jkl-managed tool from a Github release using a command like: `jkl install github:<github user>/<github repository>`
* Ideally put the `jkl` binary in a directory that is part of your `PATH`, to make it easier to run jkl.

##Features Under Consideration

Expand Down
44 changes: 27 additions & 17 deletions archives.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,22 +54,22 @@ func (f *fileTypeReader) Type() string {
}

// ExtractFile uncompresses and unarchives a file of type gzip, bzip2, tar,
// and zip. If the file is not one of these types, this function silently
// returns.
func ExtractFile(filePath string) error {
// and zip. If the file is not one of these types, wasExtracted returns
// false.
func ExtractFile(filePath string) (wasExtracted bool, err error) {
oldCWD, err := os.Getwd()
if err != nil {
return err
return false, err
}
absFilePath, err := filepath.Abs(filePath)
if err != nil {
return err
return false, err
}
destDirName := filepath.Dir(filePath)
debugLog.Printf("extracting file %q into directory %q", absFilePath, destDirName)
err = os.Chdir(destDirName)
if err != nil {
return err
return false, err
}
defer func() {
dErr := os.Chdir(oldCWD)
Expand All @@ -79,34 +79,34 @@ func ExtractFile(filePath string) error {
}()
f, err := os.Open(absFilePath)
if err != nil {
return err
return false, err
}
fileStat, err := f.Stat()
if err != nil {
return err
return false, err
}
fileSize := fileStat.Size()
ftr, fileType, err := NewFileTypeReader(f)
if err != nil {
return err
return false, err
}
debugLog.Printf("file type %v\n", fileType)
fileName := filepath.Base(filePath)
switch fileType {
case "gz":
err := gunzipFile(ftr)
if err != nil {
return err
return false, err
}
case "bz2":
err := bunzip2File(ftr, fileName)
if err != nil {
return err
return false, err
}
case "tar":
err = extractTarFile(ftr)
if err != nil {
return err
return false, err
}
case "zip":
// archive/zip requires io.ReaderAt, satisfied by os.File instead of
Expand All @@ -115,13 +115,13 @@ func ExtractFile(filePath string) error {
// impacted by the fileTypeReader having read the first 512 bytes above.
err = extractZipFile(f, fileSize)
if err != nil {
return err
return false, err
}
default:
debugLog.Printf("nothing to extract from file %s, unknown file type %q", fileName, fileType)
return nil
return false, nil
}
return nil
return true, nil
}

// saveAs writes the content of an io.Reader to the specified file. If the
Expand Down Expand Up @@ -211,6 +211,7 @@ func bunzip2File(r io.Reader, filePath string) error {

// extractTarFile uses tar to extract the specified io.Reader into the current
// directory.
// Files are extracted in a flat hierarchy, without their sub-directories.
func extractTarFile(r io.Reader) error {
debugLog.Println("extracting tar")
tarReader := tar.NewReader(r)
Expand All @@ -225,12 +226,16 @@ func extractTarFile(r io.Reader) error {
}
switch header.Typeflag {
case tar.TypeDir:
debugLog.Printf("skipping directory %q", header.Name)
continue
/* This code kept for future `retainDirStructure` option.
err = os.Mkdir(header.Name, 0700)
if err != nil {
return err
}
*/
case tar.TypeReg:
err = saveAs(tarReader, header.Name)
err = saveAs(tarReader, filepath.Base(header.Name))
if err != nil {
return err
}
Expand All @@ -243,18 +248,23 @@ func extractTarFile(r io.Reader) error {

// extractZipFile uses zip to extract the specified os.File into the
// current directory.
// Files are extracted in a flat hierarchy, without their sub-directories.
func extractZipFile(f *os.File, size int64) error {
debugLog.Println("extracting zip")
zipReader, err := zip.NewReader(f, size)
if err != nil {
return err
}
for _, zrf := range zipReader.File {
if strings.HasSuffix(zrf.Name, "/") {
debugLog.Printf("Skipping directory %q", zrf.Name)
continue
}
zf, err := zrf.Open()
if err != nil {
return fmt.Errorf("cannot open %s in zip file: %v", zrf.Name, err)
}
err = saveAs(zf, zrf.Name)
err = saveAs(zf, filepath.Base(zrf.Name))
if err != nil {
zf.Close()
return fmt.Errorf("Cannot write to %s: %v", f.Name(), err)
Expand Down
35 changes: 27 additions & 8 deletions archives_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,37 +20,50 @@ func TestExtractFile(t *testing.T) {
description string
archiveFilePath string
extractedFiles []string
wasExtracted bool
expectError bool
}{
{
description: "Single file gzip compressed",
archiveFilePath: "file.gz",
extractedFiles: []string{"file"},
wasExtracted: true,
},
{
description: "Single file bzip2 compressed",
archiveFilePath: "file.bz2",
extractedFiles: []string{"file"},
wasExtracted: true,
},
{
description: "tar gzip compressed",
archiveFilePath: "file.tar.gz",
extractedFiles: []string{"file", "subdir/file"},
extractedFiles: []string{"file", "file2"},
wasExtracted: true,
},
{
description: "tar bzip2 compressed",
archiveFilePath: "file.tar.bz2",
extractedFiles: []string{"file", "subdir/file"},
extractedFiles: []string{"file", "file2"},
wasExtracted: true,
},
{
description: "uncompressed tar",
archiveFilePath: "file.tar",
extractedFiles: []string{"file", "subdir/file"},
extractedFiles: []string{"file", "file2"},
wasExtracted: true,
},
{
description: "zip",
archiveFilePath: "file.zip",
extractedFiles: []string{"file", "subdir/file"},
extractedFiles: []string{"file", "file2"},
wasExtracted: true,
},
{
description: "A plain file not in an archive",
archiveFilePath: "plain-file",
extractedFiles: []string{"plain-file"},
wasExtracted: false,
},
{
description: "Truncated gzip which will return an error",
Expand Down Expand Up @@ -86,15 +99,21 @@ func TestExtractFile(t *testing.T) {
t.Fatal(err)
}
tempArchiveFilePath := tempDir + "/" + filepath.Base(tc.archiveFilePath)
err = jkl.ExtractFile(tempArchiveFilePath)
wasExtracted, err := jkl.ExtractFile(tempArchiveFilePath)
if err != nil && !tc.expectError {
t.Fatal(err)
}
// Include the archive file in the list of expected files, which was
if tc.wasExtracted != wasExtracted {
t.Errorf("want wasExtracted to be %v, got %v", tc.wasExtracted, wasExtracted)
}
// IF files are expected to be extracted, include the archive file in the
// list of expected files, which was
// also copied into tempDir.
wantExtractedFiles := make([]string, len(tc.extractedFiles)+1)
wantExtractedFiles := make([]string, len(tc.extractedFiles))
copy(wantExtractedFiles, tc.extractedFiles)
wantExtractedFiles[len(wantExtractedFiles)-1] = tc.archiveFilePath
if tc.wasExtracted || tc.expectError {
wantExtractedFiles = append(wantExtractedFiles, tc.archiveFilePath)
}
sort.Strings(wantExtractedFiles)
gotExtractedFiles, err := filesInDir(tempDir)
if err != nil {
Expand Down
148 changes: 148 additions & 0 deletions cli.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package jkl

import (
"fmt"
"io"
"os"
"path/filepath"

"github.com/spf13/cobra"
)

// RunCLI determines how this binary was run, and either calls RunShim() or
// processes JKL commands and arguments.
func RunCLI(args []string, output, errOutput io.Writer) error {
j, err := NewJKL()
if err != nil {
return err
}
calledProgName := filepath.Base(args[0])
if calledProgName != callMeProgName { // Running as a shim
return j.RunShim(args)
}

// Cobra commands are defined here to inharit the JKL instance.
var debugFlagEnabled bool
var rootCmd = &cobra.Command{
Use: "jkl",
Short: "A command-line tool version manager",
Long: `JKL is a version manager for other command-line tools. It installs tools quickly with minimal input, and helps you switch versions of tools while you work.`,
SilenceErrors: true, // will be bubbled up and output elsewhere
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if os.Getenv("JKL_DEBUG") != "" || debugFlagEnabled {
EnableDebugOutput()
}
err := j.displayPreFlightCheck(cmd.OutOrStdout())
return err
},
RunE: func(cmd *cobra.Command, args []string) error {
err := j.displayGettingStarted(cmd.OutOrStdout())
return err
},
}
rootCmd.PersistentFlags().BoolVarP(&debugFlagEnabled, "debug", "D", false, "Enable debug output (also enabled by setting the JKL_DEBUG environment variable to any value).")

var versionCmd = &cobra.Command{
Use: "version",
Short: "Display the jkl version",
Long: "Display the jkl version and git commit",
Aliases: []string{"ver", "v"},
Run: func(cmd *cobra.Command, args []string) {
fmt.Fprintf(cmd.OutOrStdout(), "%s version %s, git commit %s\n", callMeProgName, Version, GitCommit)
},
}
rootCmd.AddCommand(versionCmd)
var installCmd = &cobra.Command{
Use: "install <provider>:<source>[:version]",
Short: "Install a command-line tool",
Long: `Install a command-line tool.
If no version is specified, the latest version will be installed (not including pre-release versions). A partial major version will match the latest minor one.
Available providers are:
github|gh - install a Github release`,
Example: ` jkl install github:fairwindsops/rbac-lookup
jkl install github:fairwindsops/rbac-lookup:0.9.0
jkl install github:fairwindsops/rbac-lookup:0.8`,
Aliases: []string{"add", "inst", "i"},
Args: func(cmd *cobra.Command, args []string) error {
if len(args) != 1 {
return fmt.Errorf("Please specify what you would like to install, using a colon-separated provider, source, and optional version. Run %s install -h for more information about installation providers, and matching tool versions.", callMeProgName)
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
_, err := j.Install(args[0])
if err != nil {
return err
}
return nil
},
}
rootCmd.AddCommand(installCmd)

var listCmd = &cobra.Command{
Use: "list [<tool name>]",
Short: "List installed command-line tools or installed versions for a specific tool",
Long: `List command-line tools that jkl has installed.
With no arguments, all tools that jkl has installed are shown. With a tool name, jkl lists installed versions of that tool.`,
Example: ` jkl list
jkl list rbac-lookup`,
Aliases: []string{"ls", "lis", "l"},
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 1 {
j.displayInstalledVersionsOfTool(cmd.OutOrStdout(), args[0])
return err
}
j.displayInstalledTools(cmd.OutOrStdout())
return err
},
}
rootCmd.AddCommand(listCmd)

cobra.CheckErr(rootCmd.Execute())
return nil
}

// RunShim executes the desired version of the tool which JKL was called as a
// shim, passing the remaining command-line arguments to the actual tool being
// executed.
func (j JKL) RunShim(args []string) error {
if os.Getenv("JKL_DEBUG") != "" {
EnableDebugOutput()
}
calledProgName := filepath.Base(args[0])
desiredVersion, ok, err := j.getDesiredVersionOfTool(calledProgName)
if err != nil {
return err
}
if !ok {
availableVersions, foundVersions, err := j.listInstalledVersionsOfTool(calledProgName)
if err != nil {
return err
}
if foundVersions && len(availableVersions) > 1 {
return fmt.Errorf(`please specify which version of %s you would like to run, by setting the %s environment variable to a valid version, or to "latest" to use the latest version already installed.`, calledProgName, j.getEnvVarNameForToolDesiredVersion(calledProgName))
}
if foundVersions {
desiredVersion = availableVersions[0]
debugLog.Printf("selecting only available version %s for tool %s", desiredVersion, calledProgName)
}
}
installedCommandPath, err := j.getPathForToolDesiredVersion(calledProgName, desiredVersion)
if err != nil {
return err
}
if installedCommandPath == "" {
// Can we install the command version at this point? We don't know where the
// command came from. LOL
return fmt.Errorf("Version %s of %s is not installed", desiredVersion, calledProgName)
}
err = RunCommand(append([]string{installedCommandPath}, args[1:]...))
if err != nil {
return err
}
return nil
}
Loading

0 comments on commit c523a04

Please sign in to comment.