Skip to content

Commit

Permalink
Publicize WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
ivanfetch committed May 22, 2022
0 parents commit f96c74a
Show file tree
Hide file tree
Showing 27 changed files with 1,882 additions and 0 deletions.
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# JKL - A Tool Version Manager

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.

**JKL is a public work in progress - not all functionality is complete and there are plenty of rough edges.**

* Install a new command-line tool from its Github release or direct download URL.
* Target a specific version (`v1.2.3`), `latest`, or the latest partial version (`v1.2` or `v1`).
* Versions can match Github release tags with or without a leading `v`.
* A Github asset is matched to your operating system and architecture.
* It's ok if the tool is contained in a tar or zip archive.
* JKL creates a "shim" to intercept the execution of the just-installed tool, so that whenyou attempt to run the tool JKL can determine which version to run.
* Specify which version of a given tool to run via an an environment variable, configuration file, or your shell current directory.
* Specifying `latest` runs the latest installed version.
* 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

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).

##Features Under Consideration

These are features or user experience that need more consideration.

* JKL configuration files will specify the "provider" and desired version of a tool. The provider represents where / how to download the tool (`github`, `URLTemplate`, `CurlBash`).
* A provider may not need to be specified in all config files. Config files can be read from parent directories to find a tool's provider. This could allow a project/environment to specify desired tool versions without needing to care about the provider.
* A JKL setup / init command that uses JKL to manage itself.
* A central "for all users" operating mode to support shared environments like jump-boxes:
* Avoid each user needing to install their own copies of common tools.
* Allow users to install new tools or versions not already present in a shared location.
* Try hard to not become a full-fledged package manager. :)
* Support additional features via "plugins" - such as:
* Some tools will require post-install action, like managing a shell initialization file.
* Some tools will have multiple binaries, like Go, Python or other runtimes.
* Some logic may be required depending on architecture or to generate default configuration for a tool.
* Use user-installed tools, instead of JKL-managed ones.
* The user-installed tools would follow a configurable naming convention such as `tool.x.y.z` or `tool-x.y.z`.
* The first binary found in the PATH matching the naming convention would be used.
* A `cleanup` option that uninstalls versions of tools that aren't referenced in config files within a directory tree.
* A `nuke` option that uninstalls everything JKL manages.
* A bulk purge option to remove all tools from a particular provider, or Github user.
265 changes: 265 additions & 0 deletions archives.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
package jkl

import (
"archive/tar"
"archive/zip"
"bytes"
"compress/bzip2"
"compress/gzip"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"

"github.com/h2non/filetype"
)

// fileTypeReader extends io.Reader by providing the file type, determined by
// reading the first 512 bytes.
type fileTypeReader struct {
io.Reader
fileType string
}

// NewFileTypeReader returns a fileTypeReader and the file type of the
// supplied io.Reader.
func NewFileTypeReader(f io.Reader) (ftr *fileTypeReader, fileType string, err error) {
ftr = &fileTypeReader{}
buffer := make([]byte, 512)
n, err := f.Read(buffer)
// Restore; rewind the original os.File before potentially returning from a
// Read error above.
resetReader := io.MultiReader(bytes.NewBuffer(buffer[:n]), f)
ftr.Reader = resetReader
if errors.Is(err, io.EOF) {
ftr.fileType = "unknown"
return ftr, ftr.fileType, nil
}
if err != nil {
return nil, "", err
}
contentType, err := filetype.Match(buffer)
if err != nil {
return nil, "", err
}
ftr.fileType = contentType.Extension
return ftr, ftr.fileType, nil
}

// Return the file type of the io.Reader.
func (f *fileTypeReader) Type() string {
return f.fileType
}

// 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 {
oldCWD, err := os.Getwd()
if err != nil {
return err
}
absFilePath, err := filepath.Abs(filePath)
if err != nil {
return err
}
destDirName := filepath.Dir(filePath)
debugLog.Printf("extracting file %q into directory %q", absFilePath, destDirName)
err = os.Chdir(destDirName)
if err != nil {
return err
}
defer func() {
dErr := os.Chdir(oldCWD)
if dErr != nil { // avoid setting upstream err to nil
err = dErr
}
}()
f, err := os.Open(absFilePath)
if err != nil {
return err
}
fileStat, err := f.Stat()
if err != nil {
return err
}
fileSize := fileStat.Size()
ftr, fileType, err := NewFileTypeReader(f)
if err != nil {
return err
}
debugLog.Printf("file type %v\n", fileType)
fileName := filepath.Base(filePath)
switch fileType {
case "gz":
err := gunzipFile(ftr)
if err != nil {
return err
}
case "bz2":
err := bunzip2File(ftr, fileName)
if err != nil {
return err
}
case "tar":
err = extractTarFile(ftr)
if err != nil {
return err
}
case "zip":
// archive/zip requires io.ReaderAt, satisfied by os.File instead of
// io.Reader.
// The unzip pkg explicitly positions the ReaderAt, therefore is not
// impacted by the fileTypeReader having read the first 512 bytes above.
err = extractZipFile(f, fileSize)
if err != nil {
return err
}
default:
debugLog.Printf("nothing to extract from file %s, unknown file type %q", fileName, fileType)
return nil
}
return nil
}

// saveAs writes the content of an io.Reader to the specified file. If the
// base directory does not exist, it will be created.
func saveAs(r io.Reader, filePath string) error {
baseDir := filepath.Dir(filePath)
_, err := os.Stat(baseDir)
if os.IsNotExist(err) {
debugLog.Printf("creating directory %q", baseDir)
err := os.MkdirAll(baseDir, 0700)
if err != nil {
return err
}
}
if err != nil && !os.IsNotExist(err) {
return err
}
f, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("Cannot open %s: %v", filePath, err)
}
defer f.Close()
debugLog.Printf("saving to file %s\n", filePath)
_, err = io.Copy(f, r)
if err != nil {
return fmt.Errorf("Cannot write to %s: %v", filePath, err)
}
return nil
}

// gunzipFile uses gunzip to decompress the specified io.Reader. If the result
// is a tar file, it will be extracted, otherwise the io.Reader is written to
// a file using saveAs().
func gunzipFile(r io.Reader) error {
gzipReader, err := gzip.NewReader(r)
if err != nil {
return err
}
defer gzipReader.Close()
fileName := gzipReader.Header.Name
debugLog.Printf("decompressing gzip, optional file name is %q\n", fileName)
ftr, fileType, err := NewFileTypeReader(gzipReader)
if err != nil {
return err
}
if fileType == "tar" {
err := extractTarFile(ftr)
if err != nil {
return fmt.Errorf("while extracting ungzipped tar: %v", err)
}
return nil
}
debugLog.Println("nothing to unarchive, saving direct file.")
err = saveAs(ftr, fileName)
if err != nil {
return err
}
return nil
}

// bunzip2File uses bzip2 to decompress the specified io.Reader. If the result
// is a tar file, it will be extracted, otherwise the io.Reader is written to
// a file using saveAs() and the original file name minus the .bz2 extension.
func bunzip2File(r io.Reader, filePath string) error {
debugLog.Println("decompressing bzip2")
bzip2Reader := bzip2.NewReader(r)
baseFileName := strings.TrimSuffix(filepath.Base(filePath), ".bz2")
baseFileName = strings.TrimSuffix(baseFileName, ".BZ2")
ftr, fileType, err := NewFileTypeReader(bzip2Reader)
if err != nil {
return err
}
if fileType == "tar" {
err := extractTarFile(ftr)
if err != nil {
return fmt.Errorf("while extracting bunzip2ed tar: %v", err)
}
return nil
}
debugLog.Println("nothing to unarchive, saving direct file.")
err = saveAs(ftr, baseFileName)
if err != nil {
return err
}
return nil
}

// extractTarFile uses tar to extract the specified io.Reader into the current
// directory.
func extractTarFile(r io.Reader) error {
debugLog.Println("extracting tar")
tarReader := tar.NewReader(r)
for {
header, err := tarReader.Next()
if errors.Is(err, io.EOF) {
debugLog.Println("end of tar file")
break
}
if err != nil {
return err
}
switch header.Typeflag {
case tar.TypeDir:
err = os.Mkdir(header.Name, 0700)
if err != nil {
return err
}
case tar.TypeReg:
err = saveAs(tarReader, header.Name)
if err != nil {
return err
}
default:
return fmt.Errorf("unknown file type %q for file %q in tar file", header.Typeflag, header.Name)
}
}
return nil
}

// extractZipFile uses zip to extract the specified os.File into the
// current directory.
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 {
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)
if err != nil {
zf.Close()
return fmt.Errorf("Cannot write to %s: %v", f.Name(), err)
}
zf.Close()
}
return nil
}
Loading

0 comments on commit f96c74a

Please sign in to comment.