-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit f96c74a
Showing
27 changed files
with
1,882 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
Oops, something went wrong.