From f96c74a730224d3ac4c4ed1a4a2629873cfd10bc Mon Sep 17 00:00:00 2001 From: Ivan Fetch Date: Sat, 21 May 2022 19:27:00 -0600 Subject: [PATCH] Publicize WIP --- README.md | 47 +++++ archives.go | 265 +++++++++++++++++++++++++ archives_test.go | 130 ++++++++++++ asdfconfig.go | 69 +++++++ asdfconfig_test.go | 128 ++++++++++++ cmd/jkl/main.go | 19 ++ command.go | 45 +++++ command_test.go | 75 +++++++ github.go | 340 ++++++++++++++++++++++++++++++++ github_test.go | 93 +++++++++ go.mod | 14 ++ go.sum | 9 + jkl.go | 308 +++++++++++++++++++++++++++++ jkl_integration_test.go | 111 +++++++++++ jkl_test.go | 56 ++++++ testdata/archives/file.bz2 | Bin 0 -> 52 bytes testdata/archives/file.gz | Bin 0 -> 38 bytes testdata/archives/file.tar | Bin 0 -> 3072 bytes testdata/archives/file.tar.bz2 | Bin 0 -> 178 bytes testdata/archives/file.tar.gz | Bin 0 -> 154 bytes testdata/archives/file.zip | Bin 0 -> 338 bytes testdata/archives/truncated.bz2 | Bin 0 -> 40 bytes testdata/archives/truncated.gz | Bin 0 -> 15 bytes testdata/archives/truncated.tar | Bin 0 -> 1500 bytes testdata/archives/truncated.zip | Bin 0 -> 200 bytes util.go | 167 ++++++++++++++++ version.go | 6 + 27 files changed, 1882 insertions(+) create mode 100644 README.md create mode 100644 archives.go create mode 100644 archives_test.go create mode 100644 asdfconfig.go create mode 100644 asdfconfig_test.go create mode 100644 cmd/jkl/main.go create mode 100644 command.go create mode 100644 command_test.go create mode 100644 github.go create mode 100644 github_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 jkl.go create mode 100644 jkl_integration_test.go create mode 100644 jkl_test.go create mode 100644 testdata/archives/file.bz2 create mode 100644 testdata/archives/file.gz create mode 100644 testdata/archives/file.tar create mode 100644 testdata/archives/file.tar.bz2 create mode 100644 testdata/archives/file.tar.gz create mode 100644 testdata/archives/file.zip create mode 100644 testdata/archives/truncated.bz2 create mode 100644 testdata/archives/truncated.gz create mode 100644 testdata/archives/truncated.tar create mode 100644 testdata/archives/truncated.zip create mode 100644 util.go create mode 100644 version.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..602f5de --- /dev/null +++ b/README.md @@ -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. diff --git a/archives.go b/archives.go new file mode 100644 index 0000000..5551c3d --- /dev/null +++ b/archives.go @@ -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 +} diff --git a/archives_test.go b/archives_test.go new file mode 100644 index 0000000..5775638 --- /dev/null +++ b/archives_test.go @@ -0,0 +1,130 @@ +package jkl_test + +import ( + "io/fs" + "jkl" + "os" + "path/filepath" + "sort" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestExtractFile(t *testing.T) { + // These are non-parallel tests because they change the current working + // directory. + // Eventually code will use all absolute filesystem locations and these + // tests can be parallelized. + testCases := []struct { + description string + archiveFilePath string + extractedFiles []string + expectError bool + }{ + { + description: "Single file gzip compressed", + archiveFilePath: "file.gz", + extractedFiles: []string{"file"}, + }, + { + description: "Single file bzip2 compressed", + archiveFilePath: "file.bz2", + extractedFiles: []string{"file"}, + }, + { + description: "tar gzip compressed", + archiveFilePath: "file.tar.gz", + extractedFiles: []string{"file", "subdir/file"}, + }, + { + description: "tar bzip2 compressed", + archiveFilePath: "file.tar.bz2", + extractedFiles: []string{"file", "subdir/file"}, + }, + { + description: "uncompressed tar", + archiveFilePath: "file.tar", + extractedFiles: []string{"file", "subdir/file"}, + }, + { + description: "zip", + archiveFilePath: "file.zip", + extractedFiles: []string{"file", "subdir/file"}, + }, + { + description: "Truncated gzip which will return an error", + archiveFilePath: "truncated.gz", + extractedFiles: []string{}, + expectError: true, + }, + { + description: "Truncated bzip2 which will return an error", + archiveFilePath: "truncated.bz2", + extractedFiles: []string{}, + expectError: true, + }, + { + description: "Truncated tar which will return an error", + archiveFilePath: "truncated.tar", + extractedFiles: []string{"file"}, // This will partially extract. + expectError: true, + }, + { + description: "Truncated zip which will return an error", + archiveFilePath: "truncated.zip", + extractedFiles: []string{}, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + tempDir := t.TempDir() + err := jkl.CopyFile("testdata/archives/"+tc.archiveFilePath, tempDir) + if err != nil { + t.Fatal(err) + } + tempArchiveFilePath := tempDir + "/" + filepath.Base(tc.archiveFilePath) + err = jkl.ExtractFile(tempArchiveFilePath) + if err != nil && !tc.expectError { + t.Fatal(err) + } + // Include the archive file in the list of expected files, which was + // also copied into tempDir. + wantExtractedFiles := make([]string, len(tc.extractedFiles)+1) + copy(wantExtractedFiles, tc.extractedFiles) + wantExtractedFiles[len(wantExtractedFiles)-1] = tc.archiveFilePath + sort.Strings(wantExtractedFiles) + gotExtractedFiles, err := filesInDir(tempDir) + if err != nil { + t.Fatalf("listing files that were extracted: %v", err) + } + if !cmp.Equal(wantExtractedFiles, gotExtractedFiles) { + t.Fatalf("want vs. got files extracted: %s", cmp.Diff(wantExtractedFiles, gotExtractedFiles)) + } + }) + } +} + +// filesInDir returns the sorted list of recursive files contained in the +// specified directory. +func filesInDir(dir string) ([]string, error) { + fileSystem := os.DirFS(dir) + files := make([]string, 0) + err := fs.WalkDir(fileSystem, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if path == "." || d.IsDir() { + return nil + } + files = append(files, path) + return nil + }) + if err != nil { + return nil, err + } + sort.Strings(files) + return files, nil +} diff --git a/asdfconfig.go b/asdfconfig.go new file mode 100644 index 0000000..1db1ac5 --- /dev/null +++ b/asdfconfig.go @@ -0,0 +1,69 @@ +package jkl + +import ( + "bufio" + "os" + "strings" +) + +const ( + ASDFConfigFileName = ".tool-versions" +) + +// findASDFToolVersion traverses parent directories to find the desired +// version for the specified tool, in the ASDF configuration file. +func FindASDFToolVersion(toolName string, locationOptions ...pathOption) (toolVersion string, foundTool bool, err error) { + locations, err := listPathsByParent(ASDFConfigFileName, locationOptions...) + if err != nil { + return "", false, err + } + for _, location := range locations { + v, ok, err := getToolVersionFromASDFConfigFile(location+"/"+ASDFConfigFileName, toolName) + if err != nil { + return "", false, err + } + if ok { + return v, true, nil + } + } + return "", false, nil +} + +// getToolVersionFromASDFConfigFile parses an ASDF tool-versions configuration +// file, returning the version for the specified tool, if found. +func getToolVersionFromASDFConfigFile(filePath, toolName string) (toolVersion string, foundTool bool, err error) { + debugLog.Printf("Reading ASDF config file %s for %s version", filePath, toolName) + f, err := os.Open(filePath) + if err != nil { + return + } + defer f.Close() + s := bufio.NewScanner(f) + s.Split(bufio.ScanLines) + for s.Scan() { + fields := strings.Fields(s.Text()) + if len(fields) == 0 { + continue + } + if fields[0] == "" { + continue + } + if string(fields[0][0]) == "#" { + continue + } + if len(fields) < 2 { + debugLog.Printf("No version found in line: %q\n", s.Text()) + continue + } + if len(fields) > 2 { + debugLog.Printf("Too many tokens found in line: %q\n", s.Text()) + continue + } + if fields[0] == toolName { + toolVersion = fields[1] + debugLog.Printf("Found version %s for %s in ASDF config file %s", toolVersion, toolName, filePath) + return toolVersion, true, nil + } + } + return "", false, nil +} diff --git a/asdfconfig_test.go b/asdfconfig_test.go new file mode 100644 index 0000000..b85ee2b --- /dev/null +++ b/asdfconfig_test.go @@ -0,0 +1,128 @@ +package jkl_test + +import ( + "errors" + "fmt" + "io/fs" + "jkl" + "os" + "path/filepath" + "testing" +) + +func TestFindASDFToolVersion(t *testing.T) { + // These are non-parallel tests because they change the current working + // directory. + testCases := []struct { + description string + testCWD string // within test tempDir + toolName string + toolVersionFilesContent map[string]string // paths to .tool-versions files and content + wantVersion string + expectFound bool + expectError bool + }{ + { + description: "app 1.2.3 in the current directory", + testCWD: ".", + toolName: "app", + toolVersionFilesContent: map[string]string{".": "app 1.2.3"}, + wantVersion: "1.2.3", + expectFound: true, + }, + { + description: "app 1.2.3 2 sub-dirs deep", + testCWD: "./dir2/dir3", + toolName: "app", + toolVersionFilesContent: map[string]string{ + ".": "app 1.1.1", + "./dir2": "app 1.2.2", + "./dir2/dir3": "app 1.2.3"}, + wantVersion: "1.2.3", + expectFound: true, + }, + { + description: "app 1.2.3 in sub-dir with child different app", + testCWD: "./dir2/dir3", + toolName: "app", + toolVersionFilesContent: map[string]string{ + ".": "app 1.1.1", + "./dir2": "app 1.2.3", + "./dir2/dir3": "differentapp 1.2.3"}, + wantVersion: "1.2.3", + expectFound: true, + }, + { + description: "app not listed in any tools-versions files", + testCWD: "./dir2/dir3/dir4", + toolName: "app", + toolVersionFilesContent: map[string]string{ + ".": "dummy 1.1.1", + "./dir2": "anotherDummy 1.5.0", + "./dir2/dir3": "yetAnotherDummy 1.0.0", + "./dir2/dir3/dir4": "aFinalDummy 0.4.0", + }, + expectFound: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + tempDir := t.TempDir() + testDir := tempDir + "/" + tc.testCWD + oldCWD, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer func() { + dErr := os.Chdir(oldCWD) + if dErr != nil { + err = dErr + } + }() + for subDir, fileContent := range tc.toolVersionFilesContent { + fileName := fmt.Sprintf("%s/%s/.tool-versions", tempDir, subDir) + err := writeDirsAndFile(fileName, fileContent) + if err != nil { + t.Fatalf("writing test tool-versions file %s: %v", fileName, err) + } + } + err = os.Chdir(testDir) + if err != nil { + t.Fatalf("unable to change to test-case directory %q: %v", testDir, err) + } + gotToolVersion, gotOK, err := jkl.FindASDFToolVersion(tc.toolName, jkl.WithAlternateRootDir(tempDir)) + if err != nil && !tc.expectError { + t.Fatal(err) + } + if err == nil && tc.expectError { + t.Fatal("an error is expected") + } + if tc.expectFound != gotOK { + t.Fatalf("expected tool version to be found, but it was not found") + } + if tc.wantVersion != gotToolVersion { + t.Fatalf("want tool version %q, got %q", tc.wantVersion, gotToolVersion) + } + }) + } +} + +func writeDirsAndFile(filePath, fileContent string) error { + dir := filepath.Dir(filePath) + _, err := os.Stat(dir) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("varifying directory %s exists: %v", dir, err) + } + if errors.Is(err, fs.ErrNotExist) { + err := os.MkdirAll(dir, 0700) + if err != nil { + return fmt.Errorf("while creating directory %s: %v", dir, err) + } + } + err = os.WriteFile(filePath, []byte(fileContent), 0600) + if err != nil { + return err + } + return nil +} diff --git a/cmd/jkl/main.go b/cmd/jkl/main.go new file mode 100644 index 0000000..51150a2 --- /dev/null +++ b/cmd/jkl/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "fmt" + "jkl" + "os" +) + +func main() { + j, err := jkl.New(jkl.WithInstallsDir("~/.jkl/installs"), jkl.WithShimsDir("~/.jkl/bin")) + + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + } + err = j.RunCLI(os.Args) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + } +} diff --git a/command.go b/command.go new file mode 100644 index 0000000..7184f2e --- /dev/null +++ b/command.go @@ -0,0 +1,45 @@ +package jkl + +import ( + "os" + "os/exec" + "syscall" +) + +// RunCommand execs the specified command, the command will replace the +// current (Go program) process. If commandAndArgs[0] is not an absolute path, +// the PATH environment variable will be searched for the executable. +func RunCommand(commandAndArgs []string) error { + cmd, err := exec.LookPath(commandAndArgs[0]) + if err != nil { + return err + } + debugLog.Printf("Going to exec %s which was in path for command %v\n", cmd, commandAndArgs[0]) + err = syscall.Exec(cmd, commandAndArgs, os.Environ()) + if err != nil { + return err + } + return nil +} + +// FindCommandVersion accepts a command name and desired x.y.z version, +// returning the path to the command, otherwise +// returning an empty string if the command is not found in the PATH +// environment variable. +// This is used to find user-installed binaries. JKL-installed binaries are +// instead located within the JKL `installs` directory, using the function +// getInstalledCommandPath(). +func FindCommandVersion(command, version string) (string, error) { + commandWithVersion := command + "." + version + found, err := exec.LookPath(commandWithVersion) + execErr, ok := err.(*exec.Error) + if ok && execErr.Err == exec.ErrNotFound { + debugLog.Printf("did not find command %s version %s\n", command, version) + return "", nil + } + if err != nil { + return "", err + } + debugLog.Printf("found %q for command %s version %s\n", found, command, version) + return found, nil +} diff --git a/command_test.go b/command_test.go new file mode 100644 index 0000000..cdd6101 --- /dev/null +++ b/command_test.go @@ -0,0 +1,75 @@ +package jkl_test + +import ( + "fmt" + "jkl" + "os" + "os/exec" + "strings" + "testing" +) + +// wrapRunCommand is a helper function that calls jkl.RunCommand() without +// allowing its syscall.Exec() to replace the go test binary. +// It accomplishes this by forking a new process running the same go test +// binary, targeting an explicit test that will call jkl.RunCommand(). +func wrapRunCommand(command []string) error { + cmd := exec.Command(os.Args[0], "-test.run=TestExecHelper") + cmd.Env = append(os.Environ(), "test_exec_helper_command="+strings.Join(command, " "), "test_exec_helper_explicit=true") + o, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%w\n%s", err, o) + } + return nil +} + +// TestExecHelper is run by wrapRunCommand() to facilitate calling +// jkl.RunCommand() in a sub-process. +func TestExecHelper(t *testing.T) { + calledExplicitly := os.Getenv("test_exec_helper_explicit") + if calledExplicitly != "true" { // Avoid this test running on its own + return + } + commandString := os.Getenv("test_exec_helper_command") + err := jkl.RunCommand(strings.Split(commandString, " ")) + if err != nil { + t.Fatal(err) + } + t.Errorf("RunCommand() did not return an error, but also did not syscall.Exec() for command %q", commandString) +} + +func TestRunCommandToTouchAFile(t *testing.T) { + // t.Parallel() + path := t.TempDir() + "/" + t.Name() + command := []string{"/usr/bin/touch", path} + err := wrapRunCommand(command) + if err != nil { + t.Fatal(err) + } + _, err = os.Stat(path) + if err != nil { + t.Fatal(err) + } +} + +func TestFindCommandVersion(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("PATH", tempDir) + want := tempDir + "/testcommand.1.0" + f, err := os.Create(want) + if err != nil { + t.Fatal(err) + } + defer f.Close() + err = f.Chmod(0755) + if err != nil { + t.Fatal(err) + } + got, err := jkl.FindCommandVersion("testcommand", "1.0") + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("want %q, got %q", want, got) + } +} diff --git a/github.go b/github.go new file mode 100644 index 0000000..19200c6 --- /dev/null +++ b/github.go @@ -0,0 +1,340 @@ +package jkl + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "time" +) + +type GithubAsset struct { + Name string `json:"name"` + URL string `json:"url"` +} + +type GithubReleases []struct { + ReleaseName string `json:"name"` + TagName string `json:"tag_name"` +} + +func (g GithubReleases) tagExists(wantTag string) (tag string, found bool) { + debugLog.Printf("Looking for tag %q in %d releases\n", wantTag, len(g)) + for _, r := range g { + if strings.EqualFold(r.TagName, wantTag) { + debugLog.Printf("found tag %q for release %s\n", r.TagName, r.ReleaseName) + return r.TagName, true + } + } + debugLog.Printf("tag %q not found\n", wantTag) + return "", false +} + +func (g GithubReleases) tagForReleaseName(wantName string) (tag string, found bool) { + debugLog.Printf("Looking for name %q in %d releases\n", wantName, len(g)) + for _, r := range g { + if strings.EqualFold(r.ReleaseName, wantName) { + debugLog.Printf("found release name %s which has tag %q\n", r.ReleaseName, r.TagName) + return r.TagName, true + } + } + debugLog.Printf("name %q not found\n", wantName) + return "", false +} + +func (gr GithubReleases) MatchTagFromPartialVersion(pv string) (tag string, found bool) { + debugLog.Printf("matching tag from partial version %q\n", pv) + tags := make([]string, len(gr)) + for i, j := range gr { + tags[i] = j.TagName + } + sort.Strings(tags) + // Iterate the Github release tags backwards. + for i := len(tags) - 1; i >= 0; i-- { + LCPV := strings.ToLower(pv) + LCThisTag := strings.ToLower(tags[i]) + if stringContainsOneOf(LCThisTag, "-rc", "-alpha", "-beta") { + debugLog.Printf("skipping pre-release tag %q\n", tags[i]) + continue + } + if strings.HasPrefix(LCThisTag, LCPV) || strings.HasPrefix(LCThisTag, "v"+LCPV) { + debugLog.Printf("matched tag %q for partial version %s\n", tags[i], pv) + return tags[i], true + } + } + debugLog.Printf("no partial match for %s\n", pv) + return "", false +} + +type Downloader struct { + githubToken, githubAPIHost string + httpClient *http.Client +} + +func NewDownloader() *Downloader { + return &Downloader{ + githubAPIHost: "https://api.github.com", + githubToken: os.Getenv("GH_TOKEN"), + httpClient: &http.Client{Timeout: time.Second * 30}, + } +} + +func (d *Downloader) githubAPIRequest(method, URI string) (*http.Response, error) { + if !strings.HasPrefix(URI, "/") { + URI = "/" + URI + } + URL := d.githubAPIHost + URI + req, err := http.NewRequest(method, URL, nil) + if err != nil { + return nil, err + } + if d.githubToken != "" { + req.Header.Add("Authorization", fmt.Sprintf("token %s", d.githubToken)) + } + resp, err := d.httpClient.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} + +func (d Downloader) ListGithubAssetsForTag(ownerAndRepo, tag string) ([]GithubAsset, error) { + ok, err := d.GithubRepoExists(ownerAndRepo) + if err != nil { + return nil, err + } + if !ok { + return nil, errors.New("no such repository") + } + URI := "/repos/" + ownerAndRepo + "/releases/tags/" + tag + resp, err := d.githubAPIRequest(http.MethodGet, URI) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d for %s", resp.StatusCode, URI) + } + var APIResp struct { + Assets []GithubAsset `json:"assets"` + } + err = json.NewDecoder(resp.Body).Decode(&APIResp) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if len(APIResp.Assets) == 0 { + return nil, errors.New("the Github API did not return the expected fields") + } + return APIResp.Assets, nil +} + +func (d Downloader) GetGithubLatestReleaseTag(ownerAndRepo string) (tagName string, err error) { + ok, err := d.GithubRepoExists(ownerAndRepo) + if err != nil { + return "", err + } + if !ok { + return "", errors.New("no such repository") + } + URI := "/repos/" + ownerAndRepo + "/releases/latest" + resp, err := d.githubAPIRequest(http.MethodGet, URI) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HTTP %d for %s", resp.StatusCode, URI) + } + var APIResp struct { + TagName *string `json:"tag_name"` + } + err = json.NewDecoder(resp.Body).Decode(&APIResp) + if err != nil { + return "", err + } + defer resp.Body.Close() + if APIResp.TagName == nil { + return "", errors.New("the Github API did not return tag_name") + } + return *APIResp.TagName, nil +} + +func (d Downloader) GithubRepoExists(ownerAndRepo string) (bool, error) { + URI := "/repos/" + ownerAndRepo + resp, err := d.githubAPIRequest(http.MethodGet, URI) + if err != nil { + return false, err + } + if resp.StatusCode == http.StatusOK { + return true, nil + } + if resp.StatusCode == http.StatusNotFound { + return false, nil + } + return false, fmt.Errorf("HTTP %d for %s", resp.StatusCode, URI) +} + +func (d Downloader) Download(asset GithubAsset) (filePath string, err error) { + req, err := http.NewRequest(http.MethodGet, asset.URL, nil) + if err != nil { + return "", err + } + if d.githubToken != "" { + req.Header.Add("Authorization", fmt.Sprintf("token %s", d.githubToken)) + } + req.Header.Add("Accept", "application/octet-stream") + resp, err := d.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HTTP %d for %s", resp.StatusCode, asset.URL) + } + tempDir, err := os.MkdirTemp(os.TempDir(), callMeProgName+"-") + if err != nil { + return "", err + } + filePath = fmt.Sprintf("%s/%s", tempDir, asset.Name) + f, err := os.Create(filePath) + if err != nil { + return "", err + } + defer f.Close() + if _, err := io.Copy(f, resp.Body); err != nil { + return "", err + } + return filePath, nil +} + +// InstallGithubReleaseForVersion matches a Github release tag for the +// specified version, then calls InstallGithubReleaseForTag(). The release tag +// is matched from the specified version using +// findGithubReleaseTagForVersion(). +func (j JKL) InstallGithubReleaseForVersion(ownerAndRepo, version string) (binaryPath string, err error) { + ownerAndRepo = strings.Replace(ownerAndRepo, "github.com/", "", 1) + d := NewDownloader() + tag, ok, err := d.findGithubReleaseTagForVersion(ownerAndRepo, version) + if err != nil { + return "", err + } + if !ok { + return "", fmt.Errorf("no tag found matching version %q", version) + } + return j.InstallGithubReleaseForTag(ownerAndRepo, tag) +} + +func (j JKL) InstallGithubReleaseForTag(ownerAndRepo, tag string) (binaryPath string, err error) { + debugLog.Printf("installing Github release %q for tag %q\n", tag, ownerAndRepo) + d := NewDownloader() + downloadedFile, err := d.DownloadGithubReleaseForTag(ownerAndRepo, tag, runtime.GOOS, runtime.GOARCH) + if err != nil { + return "", err + } + err = ExtractFile(downloadedFile) + if err != nil { + return "", err + } + toolName := filepath.Base(ownerAndRepo) // name of the repository + extractedToolBinary := fmt.Sprintf("%s/%s", filepath.Dir(downloadedFile), toolName) + installDest := fmt.Sprintf("%s/%s/%s", j.installsDir, toolName, tag) + err = CopyExecutableToCreatedDir(extractedToolBinary, installDest) + if err != nil { + return "", err + } + binaryPath = fmt.Sprintf("%s/%s", installDest, toolName) + return binaryPath, nil + +} + +func (j JKL) InstallGithubReleaseForLatest(ownerAndRepo string) (binaryPath, latestVersionTag string, err error) { + d := NewDownloader() + latestVersionTag, err = d.GetGithubLatestReleaseTag(ownerAndRepo) + if err != nil { + return "", "", err + } + binaryPath, err = j.InstallGithubReleaseForTag(ownerAndRepo, latestVersionTag) + return binaryPath, latestVersionTag, err +} + +func (d Downloader) findGithubReleaseTagForVersion(ownerAndRepo, version string) (tag string, found bool, err error) { + debugLog.Printf("finding Github tag matching version %q of %q\n", version, ownerAndRepo) + URI := "/repos/" + ownerAndRepo + "/releases" + resp, err := d.githubAPIRequest(http.MethodGet, URI) + if err != nil { + return "", false, err + } + if resp.StatusCode != http.StatusOK { + return "", false, fmt.Errorf("HTTP %d for %s", resp.StatusCode, URI) + } + var APIResp GithubReleases + err = json.NewDecoder(resp.Body).Decode(&APIResp) + if err != nil { + return "", false, err + } + defer resp.Body.Close() + if len(APIResp) == 0 { + return "", false, errors.New("there are no releases") + } + tag, found = APIResp.tagExists(version) + if found { + return tag, true, nil + } + tag, found = APIResp.tagExists(toggleVPrefix(version)) + if found { + return tag, true, nil + } + tag, found = APIResp.tagForReleaseName(version) + if found { + return tag, true, nil + } + tag, found = APIResp.tagForReleaseName(toggleVPrefix(version)) + if found { + return tag, true, nil + } + tag, found = APIResp.MatchTagFromPartialVersion(version) + if found { + return tag, true, nil + } + return "", false, nil +} + +func (d Downloader) DownloadGithubReleaseForTag(ownerAndRepo, tag, OS, arch string) (filePath string, err error) { + assets, err := d.ListGithubAssetsForTag(ownerAndRepo, tag) + if err != nil { + return "", err + } + asset, ok := MatchGithubAsset(assets, OS, arch) + if !ok { + return "", fmt.Errorf("no asset found matching Github owner/repository %s, tag %s, OS %s, and architecture %s", ownerAndRepo, tag, OS, arch) + } + filePath, err = d.Download(asset) + return filePath, err +} + +func MatchGithubAsset(assets []GithubAsset, OS, arch string) (GithubAsset, bool) { + archAliases := map[string][]string{ + "amd64": {"x86_64"}, + } + LCOS := strings.ToLower(OS) + LCArch := strings.ToLower(arch) + for _, asset := range assets { + LCAssetName := strings.ToLower(asset.Name) + if strings.Contains(LCAssetName, LCOS) && stringContainsOneOf(LCAssetName, LCArch, archAliases[LCArch]...) { + debugLog.Printf("matched this asset for OS %q and arch %q: %#v", OS, arch, asset) + return asset, true + } + } + if LCOS == "darwin" && LCArch == "arm64" { + // If no Darwin/ARM64 asset is available, try AMD64 which can run under Mac OS + // Rosetta. + debugLog.Println("trying to match Github asset for Darwin/AMD64 as none were found for ARM64") + return MatchGithubAsset(assets, OS, "amd64") + } + return GithubAsset{}, false +} diff --git a/github_test.go b/github_test.go new file mode 100644 index 0000000..d9f3dc0 --- /dev/null +++ b/github_test.go @@ -0,0 +1,93 @@ +package jkl_test + +import ( + "jkl" + "testing" +) + +func TestGithubMatchTagFromPartialVersion(t *testing.T) { + t.Parallel() + fakeGithubReleases := jkl.GithubReleases{ + { + ReleaseName: "0.8", + TagName: "0.8", + }, + { + ReleaseName: "0.9", + TagName: "0.9", + }, + { + ReleaseName: "1.0.0", + TagName: "1.0.0", + }, + { + ReleaseName: "1.0.2", + TagName: "1.0.2", + }, + { + ReleaseName: "1.0.3-rc1", + TagName: "1.0.3-rc1", + }, + { + ReleaseName: "2.0.1", // skipped 2.0.0 + TagName: "2.0.1", + }, + { + ReleaseName: "3.0.0", + TagName: "3.0.0", + }, + { + ReleaseName: "3.0.1", + TagName: "3.0.1", + }, + { + ReleaseName: "3.0.2", + TagName: "3.0.2", + }, + { + ReleaseName: "3.0.3", + TagName: "3.0.3", + }, + } + + testCases := []struct { + description string + version string + wantTag string + expectMatch bool + }{ + { + description: "match tag 3.0.3 from partial version 3.0", + version: "3.0", + wantTag: "3.0.3", + expectMatch: true, + }, + { + description: "match tag 1.0.2 from partial version 1", + version: "1", + wantTag: "1.0.2", + expectMatch: true, + }, + { + description: "match tag 2.0.1 from partial version 2.0", + version: "2.0", + wantTag: "2.0.1", + expectMatch: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + gotTag, gotMatch := fakeGithubReleases.MatchTagFromPartialVersion(tc.version) + if tc.expectMatch && !gotMatch { + t.Fatal("expected version to match a tag") + } + if !tc.expectMatch && gotMatch { + t.Fatalf("unexpectedly matched tag %q to version %q\n", gotTag, tc.version) + } + if tc.wantTag != gotTag { + t.Fatalf("Want tag %q, got %q\n", tc.wantTag, gotTag) + } + }) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..97a933c --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module jkl + +replace github.com/ivanfetch/jkl => ./ + +go 1.17 + +require github.com/spf13/pflag v1.0.5 + +require ( + github.com/google/go-cmp v0.5.7 // indirect + github.com/h2non/filetype v1.1.3 // indirect + github.com/ivanfetch/jkl v0.0.0-00010101000000-000000000000 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..76c44a4 --- /dev/null +++ b/go.sum @@ -0,0 +1,9 @@ +github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= +github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/jkl.go b/jkl.go new file mode 100644 index 0000000..9fdec74 --- /dev/null +++ b/jkl.go @@ -0,0 +1,308 @@ +package jkl + +import ( + "errors" + "fmt" + "io" + "io/fs" + "log" + "os" + "path/filepath" + "sort" + "strings" + + homedir "github.com/mitchellh/go-homedir" + flag "github.com/spf13/pflag" +) + +var debugLog *log.Logger = log.New(io.Discard, "", 0) + +const ( + callMeProgName = "jkl" +) + +// JKL holds configuration. +type JKL struct { + installsDir string // where downloaded tools are installed + shimsDir string // where shim symlinks are created + executable string // path to the jkl binary +} + +func EnableDebugOutput() { + debugLog.SetOutput(os.Stdout) + debugLog.SetPrefix(callMeProgName + ": ") +} + +// JKLOption uses a function to set fields on a type JKL by operating on +// that type as an argument. +// This provides optional configuration and minimizes required parameters for +// the constructor. +type JKLOption func(*JKL) error + +// WithInstallsDir sets the corresponding field in a JKL type. +func WithInstallsDir(d string) JKLOption { + return func(j *JKL) error { + if d == "" { + return errors.New("the installs directory cannot be empty") + } + expandedD, err := homedir.Expand(d) + if err != nil { + return err + } + j.installsDir = expandedD + return nil + } +} + +// WithShimsDir sets the corresponding field in a JKL type. +func WithShimsDir(d string) JKLOption { + return func(j *JKL) error { + if d == "" { + return errors.New("the shims directory cannot be empty") + } + expandedD, err := homedir.Expand(d) + if err != nil { + return err + } + j.shimsDir = expandedD + return nil + } +} + +// New constructs a new JKL, accepting optional parameters via With*() +// functional options. +func New(options ...JKLOption) (*JKL, error) { + executable, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("cannot get executable to determine its parent directory: %v", err) + } + CWD := filepath.Dir(executable) + absInstallsDir, err := filepath.Abs(CWD + "/../jkl-installs") + if err != nil { + return nil, fmt.Errorf("cannot get absolute path for installs location %q: %v", CWD+"/../jkl-installs", err) + } + j := &JKL{ + installsDir: absInstallsDir, + // the default is to manage shims in the same directory jkl has been + // installed. + shimsDir: CWD, + executable: executable, + } + for _, option := range options { + err := option(j) + if err != nil { + return nil, err + } + } + return j, nil +} + +// GetExecutable returns the executable field from a type JKL. +func (j JKL) GetExecutable() string { + return j.executable +} + +// RunCLI determines how this binary was run, and either calls RunShim() or +// processes JKL commands and arguments. +func (j JKL) RunCLI(args []string) error { + calledProgName := filepath.Base(args[0]) + if calledProgName != callMeProgName { // Running as a shim + return j.RunShim(args) + } + // Otherwise, running as jkl (not as a shim). + errOutput := os.Stderr + fs := flag.NewFlagSet(callMeProgName, flag.ExitOnError) + fs.SetOutput(errOutput) + fs.Usage = func() { + fmt.Fprintf(errOutput, `%s manages command-line tools and their versions. + +Usage: %s [flags] + +Available command-line flags: +`, + callMeProgName, callMeProgName) + fs.PrintDefaults() + } + + CLIVersion := fs.BoolP("version", "v", false, "Display the version and git commit.") + CLIDebug := fs.BoolP("debug", "D", false, "Enable debug output") + CLIInstall := fs.StringP("install", "i", "", "Download and install a tool. E.G. github.com/Owner/Repo or github.com/Owner/Repo:VersionTag") + err := fs.Parse(args[1:]) + if err != nil { + return err + } + + if *CLIVersion { + fmt.Fprintf(errOutput, "%s version %s, git commit %s\n", callMeProgName, Version, GitCommit) + os.Exit(0) + } + if *CLIDebug || os.Getenv("JKL_DEBUG") != "" { + EnableDebugOutput() + } + if *CLIInstall != "" { + _, err := j.Install(*CLIInstall) + if err != nil { + return err + } + } + 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.getDesiredVersionForCommand(calledProgName) + if err != nil { + return err + } + if !ok { + 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.getDesiredCommandVersionEnvVarName(calledProgName)) + } + installedCommandPath, err := j.getInstalledCommandPath(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 +} + +// Install installs the specified tool-specification and creates a shim, +// returning the version that was installed. The tool-specification represents where a tool can be +// downloaded, and an optional version. +func (j JKL) Install(toolSpec string) (installedVersion string, err error) { + debugLog.Printf("Installing %s\n", toolSpec) + // Eventually the below will be broken into a InstallGithub func, and THIS + // func will determine which install-provider to use. + var binaryPath, toolVersion string + toolSpecFields := strings.Split(toolSpec, ":") + ownerAndRepo := toolSpecFields[0] + if len(toolSpecFields) == 2 && strings.ToLower(toolSpecFields[1]) != "latest" { + toolVersion := toolSpecFields[1] + binaryPath, err = j.InstallGithubReleaseForVersion(ownerAndRepo, toolVersion) + } else { + binaryPath, toolVersion, err = j.InstallGithubReleaseForLatest(ownerAndRepo) + } + if err != nil { + return "", err + } + err = j.createShim(filepath.Base(binaryPath)) + if err != nil { + return "", err + } + return toolVersion, nil +} + +// CreateShim creates a symbolic link for the specified tool name, pointing to +// the JKL binary. +func (j JKL) createShim(binaryName string) error { + debugLog.Printf("Creating shim %s -> %s\n", binaryName, j.executable) + _, err := os.Stat(j.shimsDir) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + if errors.Is(err, fs.ErrNotExist) { + debugLog.Printf("creating directory %q", j.shimsDir) + err := os.MkdirAll(j.shimsDir, 0700) + if err != nil { + return err + } + } + // ToDo: Check whether this exists and that it's correct, before attempting + // to create it. + err = os.Symlink(j.executable, fmt.Sprintf("%s/%s", j.shimsDir, binaryName)) + if err != nil { + return err + } + return nil +} + +// getInstalledCommandPath returns the full path to the desired version of the +// specified command. The version is obtained from configuration files or the +// command-specific environment variable. +func (j JKL) getInstalledCommandPath(commandName, commandVersion string) (installedCommandPath string, err error) { + installedCommandPath = fmt.Sprintf("%s/%s/%s/%s", j.installsDir, commandName, commandVersion, commandName) + _, err = os.Stat(installedCommandPath) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return "", err + } + if errors.Is(err, fs.ErrNotExist) { + debugLog.Printf("desired installed command %q not found", installedCommandPath) + return "", nil + } + debugLog.Printf("installed command path for %s is %q\n", commandName, installedCommandPath) + return installedCommandPath, nil +} + +// getDesiredVersionForCommand returns the version of the specified command +// that is defined in configuration files or the command-specific environment +// variable. IF the specified version is `latest`, the latest installed +// version will be returned. +func (j JKL) getDesiredVersionForCommand(commandName string) (commandVersion string, found bool, err error) { + envVarName := j.getDesiredCommandVersionEnvVarName(commandName) + commandVersion = os.Getenv(envVarName) + if commandVersion == "" { + debugLog.Printf("environment variable %q is not set, looking in config files for the %s version", envVarName, commandName) + var ok bool + // ToDo: Our own config file is not yet implemented. + commandVersion, ok, err = FindASDFToolVersion(commandName) + if err != nil { + return "", false, err + } + if !ok { + debugLog.Printf("No version specified for command %q", commandName) + return "", false, nil + } + } + debugLog.Printf("version %s specified for %q\n", commandVersion, commandName) + if strings.ToLower(commandVersion) == "latest" { + return j.getLatestInstalledVersionForCommand(commandName) + } + return commandVersion, true, nil +} + +// getDesiredCommandVersionEnvVarName returns the name of the environment +// variable that JKL will use to determine the desired version for a specified +// command. +func (j JKL) getDesiredCommandVersionEnvVarName(commandName string) string { + // ToDo: Make this env var format configurable in the constructor? + return fmt.Sprintf("JKL_%s", strings.ToUpper(strings.ReplaceAll(commandName, "-", "_"))) +} + +func (j JKL) getLatestInstalledVersionForCommand(commandName string) (commandVersion string, found bool, err error) { + fileSystem := os.DirFS(filepath.Join(j.installsDir, commandName)) + versions := make([]string, 0) + err = fs.WalkDir(fileSystem, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if path != "." && d.IsDir() { + versions = append(versions, path) + return nil + } + return nil + }) + if err != nil { + return "", false, err + } + sort.Strings(versions) + if len(versions) == 0 { + debugLog.Printf("no versions found for command %q while looking for the latest installed version", commandName) + return "", false, nil + } + commandVersion = versions[len(versions)-1] + debugLog.Printf("the latest installed version of %s is %s", commandName, commandVersion) + return commandVersion, true, nil +} diff --git a/jkl_integration_test.go b/jkl_integration_test.go new file mode 100644 index 0000000..7b61aea --- /dev/null +++ b/jkl_integration_test.go @@ -0,0 +1,111 @@ +//go:build integration + +package jkl_test + +import ( + "fmt" + "io/fs" + "jkl" + "os" + "sort" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestListGithubAssetsForTag(t *testing.T) { + // NOTE for additional test-cases, octocat/hello-world has 0 releases. + t.Parallel() + d := jkl.NewDownloader() + got, err := d.ListGithubAssetsForTag("ivanfetch/prme", "v0.0.6") + if err != nil { + t.Fatal(err) + } + want := make([]jkl.GithubAsset, 0) + want = append(want, + jkl.GithubAsset{ + Name: "checksums.txt", + URL: "https://api.github.com/repos/ivanfetch/PRMe/releases/assets/47905347", + }, + jkl.GithubAsset{ + Name: "prme_0.0.6_Darwin_x86_64.tar.gz", + URL: "https://api.github.com/repos/ivanfetch/PRMe/releases/assets/47905345", + }, + jkl.GithubAsset{ + Name: "prme_0.0.6_Linux_arm64.tar.gz", + URL: "https://api.github.com/repos/ivanfetch/PRMe/releases/assets/47905348", + }, + jkl.GithubAsset{ + Name: "prme_0.0.6_Linux_x86_64.tar.gz", + URL: "https://api.github.com/repos/ivanfetch/PRMe/releases/assets/47905353", + }, + jkl.GithubAsset{ + Name: "prme_0.0.6_Windows_x86_64.tar.gz", + URL: "https://api.github.com/repos/ivanfetch/PRMe/releases/assets/47905349", + }, + ) + if !cmp.Equal(want, got) { + t.Fatalf("want vs. got: %s", cmp.Diff(want, got)) + } +} + +func TestInstall(t *testing.T) { + t.Parallel() + testCases := []struct { + description string + toolSpec string + wantInstalledFiles []string + wantShims []string + wantVersion string + expectError bool + }{ + { + description: "latest version of ivanfetch/prme", + toolSpec: "ivanfetch/prme", + wantVersion: "v0.0.6", + wantInstalledFiles: []string{"prme/v0.0.6/prme"}, + wantShims: []string{"prme"}, + }, + } + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + tempDir := t.TempDir() + j, err := jkl.New(jkl.WithInstallsDir(tempDir+"/installs"), jkl.WithShimsDir(tempDir+"/shims")) + if err != nil { + t.Fatal(err) + } + gotVersion, err := j.Install(tc.toolSpec) + if err != nil { + t.Fatal(err) + } + if tc.wantVersion != gotVersion { + t.Fatalf("want version %q, got %q", tc.wantVersion, gotVersion) + } + gotInstalledFiles, err := filesInDir(tempDir + "/installs") + if err != nil { + t.Fatalf("listing installed files: %v", err) + } + sort.Strings(gotInstalledFiles) + if !cmp.Equal(tc.wantInstalledFiles, gotInstalledFiles) { + t.Fatalf("want vs. got installed files: %s", cmp.Diff(tc.wantInstalledFiles, gotInstalledFiles)) + } + gotShims, err := filesInDir(tempDir + "/shims") + if err != nil { + t.Fatalf("listing shims: %v", err) + } + sort.Strings(gotShims) + if !cmp.Equal(tc.wantShims, gotShims) { + t.Fatalf("want vs. got shims %s", cmp.Diff(tc.wantShims, gotShims)) + } + for _, shim := range gotShims { + shimStat, err := os.Lstat(fmt.Sprintf("%s/shims/%s", tempDir, shim)) + if err != nil { + t.Fatalf("getting file info for shim %s in %s: %v", shim, tempDir, err) + } + if shimStat.Mode()&fs.ModeSymlink == 0 { + t.Fatalf("want shim %s to be a symlink (%v), but got mode %v", shim, fs.ModeSymlink, shimStat.Mode()) + } + } + }) + } +} diff --git a/jkl_test.go b/jkl_test.go new file mode 100644 index 0000000..cfae675 --- /dev/null +++ b/jkl_test.go @@ -0,0 +1,56 @@ +package jkl_test + +import ( + "jkl" + "os" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func init() { + // Enable debugging for all tests, via the same environment variable the jkl + // binary uses. + if os.Getenv("JKL_DEBUG") != "" { + jkl.EnableDebugOutput() + } +} + +func TestMatchGithubAsset(t *testing.T) { + t.Parallel() + + testAssets := []jkl.GithubAsset{ + { + Name: "checksums.txt", + URL: "https://api.github.com/repos/ivanfetch/PRMe/releases/assets/47905347", + }, + { + Name: "prme_0.0.6_Darwin_x86_64.tar.gz", + URL: "https://api.github.com/repos/ivanfetch/PRMe/releases/assets/47905345", + }, + { + Name: "prme_0.0.6_Linux_arm64.tar.gz", + URL: "https://api.github.com/repos/ivanfetch/PRMe/releases/assets/47905348", + }, + { + Name: "prme_0.0.6_Linux_x86_64.tar.gz", + URL: "https://api.github.com/repos/ivanfetch/PRMe/releases/assets/47905353", + }, + { + Name: "prme_0.0.6_Windows_x86_64.tar.gz", + URL: "https://api.github.com/repos/ivanfetch/PRMe/releases/assets/47905349", + }, + } + + got, ok := jkl.MatchGithubAsset(testAssets, "darwin", "amd64") + want := jkl.GithubAsset{ + Name: "prme_0.0.6_Darwin_x86_64.tar.gz", + URL: "https://api.github.com/repos/ivanfetch/PRMe/releases/assets/47905345", + } + if !ok { + t.Fatal("no asset matched") + } + if !cmp.Equal(want, got) { + t.Fatalf("want vs. got: %s", cmp.Diff(want, got)) + } +} diff --git a/testdata/archives/file.bz2 b/testdata/archives/file.bz2 new file mode 100644 index 0000000000000000000000000000000000000000..c43c7ea8c30d4ecd72373f8556581166e31fde4e GIT binary patch literal 52 zcmZ>Y%CIzaj8qGb)amEeVPIenY+w*@U{FfnVNhUDTyQx{;6Q`#s~t+lNAFDZ6H(z- IK6IJ^0BE@nr~m)} literal 0 HcmV?d00001 diff --git a/testdata/archives/file.gz b/testdata/archives/file.gz new file mode 100644 index 0000000000000000000000000000000000000000..afb77da0e59783dab7e7c0de6cbeae837d4e0418 GIT binary patch literal 38 ucmb2|=HRgWs+7dQoR*oB%AkGngiaX4BA3=lR~`xJF$f8>7V$GMFaQAOcna(Q literal 0 HcmV?d00001 diff --git a/testdata/archives/file.tar b/testdata/archives/file.tar new file mode 100644 index 0000000000000000000000000000000000000000..a61b60ca957aac8bdadc0e9c99391907b039f4fb GIT binary patch literal 3072 zcmeHHNe;sx4CLHb_y@IF-;=hWAoUcS|JO;*iE;>sR30H2Ff!voL#f|vIs*}-_h2NZ z7}kh(Tg#S!5|FnfGaIY}BzsLO>7_Zr*4z?r0v>Av z83S-`{J05grFoSCZGL^~^ejEU0rACo|GOmqcM*g7|Ef1Ws-yltQb%*DfGVI0{I3E( DcMmrr literal 0 HcmV?d00001 diff --git a/testdata/archives/file.tar.bz2 b/testdata/archives/file.tar.bz2 new file mode 100644 index 0000000000000000000000000000000000000000..ef1e347f74371117bd92e6218f6b03cc37f71c37 GIT binary patch literal 178 zcmV;j08RfwT4*^jL0KkKS*i+g0{{Sidy&!*0f0aN|9}Dne`Fp21ONa55C|{;xC}5% z34n%-nqXmqU`zxwWYYkuK>$&vnW%oKY2_ZJmRX}WETl^T3iY^nQB*{cx@L>gb|~$% zZhW)RwxTgRq@vcMFliwFRX1t4Mo1MSpH3Iibw2H;uu6uChYpXZsjh7r0NGxx7n=xz#KIHfcn zLQsOl#V_Z3ng&C};2g9XgMU^uecw?8a)#QmHx`hLC7b|957ul&=CzKJImva|@2hRh zZ|>D%L|gO%-a%j=m(eghxn-^QAM8_;Y1kD&QiN~!9*0WckVn zlokQ1DaptzRsf4hK;0|1_> BI6?pb literal 0 HcmV?d00001 diff --git a/testdata/archives/truncated.bz2 b/testdata/archives/truncated.bz2 new file mode 100644 index 0000000000000000000000000000000000000000..e21782a307719e46382b867397f0338cbe878bbe GIT binary patch literal 40 vcmZ>Y%CIzaj8qGb)amEeVPIenY+w*@U{FfnVNhUDTyQx{;6Q`#s~t)J!;=dF literal 0 HcmV?d00001 diff --git a/testdata/archives/truncated.gz b/testdata/archives/truncated.gz new file mode 100644 index 0000000000000000000000000000000000000000..95160d92ea85781851640d22dd2a579043397bf1 GIT binary patch literal 15 Wcmb2|=HRgWs+7dQoR*oB$^ZZ$O9T)A literal 0 HcmV?d00001 diff --git a/testdata/archives/truncated.tar b/testdata/archives/truncated.tar new file mode 100644 index 0000000000000000000000000000000000000000..7d2a62496c0cfed5abfcbdcfa1be4d01229804ec GIT binary patch literal 1500 zcmeHHOA5pw40YX8HV9w>%zc{i$Wj5GaAf`@|5~1t}_rZg#gYt zr?@ln5!;f1GEiW$8AtYj#GcRrcGk5Yzcd|ZlUAkZYdWuJX(H#_TXTk^xs5LZooWLc nBWP(lTtxLP^Itd6mgl?959#?0m@mHfzt84>pD^D3|MiVmuwpU; literal 0 HcmV?d00001 diff --git a/testdata/archives/truncated.zip b/testdata/archives/truncated.zip new file mode 100644 index 0000000000000000000000000000000000000000..2a8fa001d350f1e3505bd4aa8902d7108175dbe6 GIT binary patch literal 200 zcmWIWW@h1H00EsDNg+bQtVR4lHVCsY$S|a3=A?#(a569lvnnJla!^Sst>9*0WckVn zlokQ1DaptzRsf