diff --git a/README.md b/README.md index 602f5de..5677808 100644 --- a/README.md +++ b/README.md @@ -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:/` +* Ideally put the `jkl` binary in a directory that is part of your `PATH`, to make it easier to run jkl. ##Features Under Consideration diff --git a/archives.go b/archives.go index 5551c3d..1663dd4 100644 --- a/archives.go +++ b/archives.go @@ -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) @@ -79,16 +79,16 @@ 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) @@ -96,17 +96,17 @@ func ExtractFile(filePath string) error { 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 @@ -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 @@ -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) @@ -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 } @@ -243,6 +248,7 @@ 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) @@ -250,11 +256,15 @@ func extractZipFile(f *os.File, size int64) error { 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) diff --git a/archives_test.go b/archives_test.go index 5775638..910000e 100644 --- a/archives_test.go +++ b/archives_test.go @@ -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", @@ -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 { diff --git a/cli.go b/cli.go new file mode 100644 index 0000000..843f7d5 --- /dev/null +++ b/cli.go @@ -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 :[: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 []", + 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 +} diff --git a/cmd/jkl/main.go b/cmd/jkl/main.go index 51150a2..63dfc63 100644 --- a/cmd/jkl/main.go +++ b/cmd/jkl/main.go @@ -7,13 +7,9 @@ import ( ) 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) + err := jkl.RunCLI(os.Args, os.Stdout, os.Stderr) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) } } diff --git a/github.go b/github.go index 5b0baaf..4bcc30f 100644 --- a/github.go +++ b/github.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "os" + "regexp" "runtime" "sort" "strings" @@ -41,7 +42,7 @@ func NewGithubClient(options ...githubClientOption) (*GithubClient, error) { c := &GithubClient{ apiHost: "https://api.github.com", token: os.Getenv("GH_TOKEN"), - httpClient: &http.Client{Timeout: time.Second * 10}, + httpClient: &http.Client{Timeout: time.Second * 30}, } for _, o := range options { err := o(c) @@ -57,6 +58,19 @@ type GithubAsset struct { URL string `json:"url"` } +var assetBaseNameRE *regexp.Regexp = regexp.MustCompile(`(?is)^(.+?)[-_]v?\d+.*`) + +// GetBaseName returns the asset name after attempting to strip version, +// architecture, operating system, and file extension. +func (g GithubAsset) GetBaseName() string { + matches := assetBaseNameRE.FindStringSubmatch(g.Name) + if matches == nil || len(matches) < 2 { + debugLog.Printf("unable to match a base asset name from %q, returning the full asset name", g.Name) + } + debugLog.Printf("matched base name %q for asset name %q", matches[1], g.Name) + return matches[1] +} + type GithubReleases []struct { ReleaseName string `json:"name"` TagName string `json:"tag_name"` @@ -265,24 +279,33 @@ func (g GithubRepo) Download(asset GithubAsset) (filePath string, err error) { } // DownloadReleaseForVersion matches a Github release tag for the -// specified version, then calls DownloadReleaseForTag(). The release tag -// is matched from the specified version using +// specified version, then calls DownloadReleaseForTag(). +// The release tag is matched from the specified version using // findGithubReleaseTagForVersion(). -func (g GithubRepo) DownloadReleaseForVersion(version string) (binaryPath, matchedTag string, err error) { +// An empty version causes the latest release to be installed. +func (g GithubRepo) DownloadReleaseForVersion(version string) (binaryPath, matchedTag, assetBaseName string, err error) { tag, ok, err := g.findTagForVersion(version) if err != nil { - return "", "", err + return "", "", "", err } if !ok { - return "", "", fmt.Errorf("no tag found matching version %q", version) + return "", "", "", fmt.Errorf("no tag found matching version %q", version) } - binaryPath, err = g.DownloadReleaseForTag(tag) - matchedTag = tag - return + binaryPath, assetBaseName, err = g.DownloadReleaseForTag(tag) + return binaryPath, tag, assetBaseName, err } +// findTagForVersion matches a release tag to the specified version. An empty +// version or "latest" will return the latest release tag. func (g GithubRepo) findTagForVersion(version string) (tag string, found bool, err error) { debugLog.Printf("finding Github tag matching version %q of %q\n", version, g.GetOwnerAndRepo()) + if version == "" || strings.EqualFold(version, "latest") { + tag, err = g.GetTagForLatestRelease() + if err != nil { + return "", false, err + } + return tag, true, nil + } URI := "/repos/" + g.ownerAndRepo + "/releases" resp, err := g.githubAPIRequest(http.MethodGet, URI) if err != nil { @@ -323,46 +346,49 @@ func (g GithubRepo) findTagForVersion(version string) (tag string, found bool, e return "", false, nil } -func (g GithubRepo) DownloadReleaseForLatest() (binaryPath, latestVersionTag string, err error) { +func (g GithubRepo) DownloadReleaseForLatest() (binaryPath, latestVersionTag, assetBaseName string, err error) { latestVersionTag, err = g.GetTagForLatestRelease() if err != nil { - return "", "", err + return "", "", "", err } - binaryPath, err = g.DownloadReleaseForTag(latestVersionTag) - return binaryPath, latestVersionTag, err + binaryPath, assetBaseName, err = g.DownloadReleaseForTag(latestVersionTag) + return binaryPath, latestVersionTag, assetBaseName, err } -func (g GithubRepo) DownloadReleaseForTagOSAndArch(tag, OS, arch string) (filePath string, err error) { +func (g GithubRepo) DownloadReleaseForTagOSAndArch(tag, OS, arch string) (filePath, baseAssetName string, err error) { assets, err := g.AssetsForTag(tag) if err != nil { - return "", err + return "", "", err } asset, ok := MatchAssetByOsAndArch(assets, OS, arch) if !ok { - return "", fmt.Errorf("no asset found matching Github owner/repository %s, tag %s, OS %s, and architecture %s", g.ownerAndRepo, tag, OS, arch) + return "", "", fmt.Errorf("no asset found matching Github owner/repository %s, tag %s, OS %s, and architecture %s", g.ownerAndRepo, tag, OS, arch) } filePath, err = g.Download(asset) - return filePath, err + return filePath, asset.GetBaseName(), err } -func (g GithubRepo) DownloadReleaseForTag(tag string) (binaryPath string, err error) { +func (g GithubRepo) DownloadReleaseForTag(tag string) (binaryPath, assetBaseName string, err error) { debugLog.Printf("downloading Github release %q for tag %q\n", tag, g.ownerAndRepo) - downloadedFile, err := g.DownloadReleaseForTagOSAndArch(tag, runtime.GOOS, runtime.GOARCH) + downloadedFile, assetBaseName, err := g.DownloadReleaseForTagOSAndArch(tag, runtime.GOOS, runtime.GOARCH) if err != nil { - return "", err + return "", "", err } - return downloadedFile, nil + return downloadedFile, assetBaseName, nil } func MatchAssetByOsAndArch(assets []GithubAsset, OS, arch string) (GithubAsset, bool) { archAliases := map[string][]string{ "amd64": {"x86_64"}, } + OSAliases := map[string][]string{ + "darwin": {"macos"}, + } 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]...) { + if stringContainsOneOf(LCAssetName, LCOS, OSAliases[LCOS]...) && stringContainsOneOf(LCAssetName, LCArch, archAliases[LCArch]...) { debugLog.Printf("matched this asset for OS %q and arch %q: %#v", OS, arch, asset) return asset, true } diff --git a/go.mod b/go.mod index 97a933c..13becad 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,9 @@ 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/hashicorp/go-version v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/ivanfetch/jkl v0.0.0-00010101000000-000000000000 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/spf13/cobra v1.5.0 // indirect ) diff --git a/go.sum b/go.sum index 76c44a4..42fcba9 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,19 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 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/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= +github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= +github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= 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= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/jkl.go b/jkl.go index 00a46cf..6ea4975 100644 --- a/jkl.go +++ b/jkl.go @@ -11,8 +11,8 @@ import ( "sort" "strings" + hashicorpversion "github.com/hashicorp/go-version" homedir "github.com/mitchellh/go-homedir" - flag "github.com/spf13/pflag" ) var debugLog *log.Logger = log.New(io.Discard, "", 0) @@ -69,25 +69,27 @@ func WithShimsDir(d string) JKLOption { } } -// New constructs a new JKL, accepting optional parameters via With*() +// NewJKL constructs a new JKL instance, accepting optional parameters via With*() // functional options. -func New(options ...JKLOption) (*JKL, error) { +func NewJKL(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, } + // Use functional options to set default values. + setDefaultInstallsDir := WithInstallsDir("~/.jkl/installs") + err = setDefaultInstallsDir(j) + if err != nil { + return nil, err + } + setDefaultShimsDir := WithShimsDir("~/.jkl/bin") + err = setDefaultShimsDir(j) + if err != nil { + return nil, err + } for _, option := range options { err := option(j) if err != nil { @@ -102,126 +104,101 @@ 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:]) +func (j JKL) displayPreFlightCheck(output io.Writer) error { + debugLog.Println("starting pre-flight check") + shimsDirInPath, err := directoryInPath(j.shimsDir) if err != nil { - return err - } + fmt.Printf("Unable to verify whether the directory %q is in your PATH: %v\n", j.shimsDir, err) + } + if !shimsDirInPath { + fmt.Fprintf(output, `WARNING: Please add the directory %[2]q to your PATH environment variable, so that %[1]s-managed tools can be run automatically. +Be sure the updated path takes effect by restarting your shell or sourcing the shell initialization file. +For example, you might add the following line to one of your shell initialization files: +PATH=%[2]q:$PATH +export PATH - 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 - } +`, callMeProgName, j.shimsDir) + return err // potentially set by directoryInPath } + debugLog.Println("pre-flight check done") 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) +func (j JKL) displayGettingStarted(output io.Writer) error { + managedTools, err := j.listInstalledTools() 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 + var numToolsPhrase string + switch len(managedTools) { + case 0: + numToolsPhrase = fmt.Sprintf("not yet managing any tools, to install your first tool using a Github release, run: %s install github:{user name}/{repository name}", callMeProgName) + case 1: + numToolsPhrase = fmt.Sprintf("already managing 1 tool. Run %[1]s list to see a list of managed tools, or use %[1]s install to install more tools.", callMeProgName) + default: + numToolsPhrase = fmt.Sprintf("already managing %d tools. Run %[2]s list to see a list of managed tools, or %[2]s install to install more tools.", len(managedTools), callMeProgName) } + fmt.Fprintf(output, "%[1]s is %[2]s\n", callMeProgName, numToolsPhrase) 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. +// returning the version that was installed. The tool-specification represents +// the tool provider and an optional version. func (j JKL) Install(toolSpec string) (installedVersion string, err error) { - debugLog.Printf("Installing %s\n", toolSpec) - var downloadPath, binaryPath, toolVersion string + debugLog.Printf("Installing tool specification %q\n", toolSpec) + var toolProvider, toolSource, toolVersion string toolSpecFields := strings.Split(toolSpec, ":") - g, err := NewGithubRepo(toolSpecFields[0]) - if err != nil { - return "", err - } - if len(toolSpecFields) == 2 && strings.ToLower(toolSpecFields[1]) != "latest" { - downloadPath, toolVersion, err = g.DownloadReleaseForVersion(toolSpecFields[1]) - } else { - downloadPath, toolVersion, err = g.DownloadReleaseForLatest() + if len(toolSpecFields) > 3 { + return "", fmt.Errorf("The tool specification %q has too many components - please supply a colon-separated provider, source, and optional version.", toolSpec) } - if err != nil { - return "", err + if len(toolSpecFields) < 2 { + return "", fmt.Errorf("the tool specification %q does not have enough components - please supply a colon-separated provider, source, and optional version", toolSpec) } - err = ExtractFile(downloadPath) - if err != nil { - return "", err - } - toolName := filepath.Base(g.GetOwnerAndRepo()) - extractedToolBinary := fmt.Sprintf("%s/%s", filepath.Dir(downloadPath), toolName) - installDest := fmt.Sprintf("%s/%s/%s", j.installsDir, toolName, toolVersion) - err = CopyExecutableToCreatedDir(extractedToolBinary, installDest) - if err != nil { - return "", err + if len(toolSpecFields) == 3 { + toolVersion = toolSpecFields[2] } - binaryPath = fmt.Sprintf("%s/%s", installDest, toolName) - err = j.createShim(filepath.Base(binaryPath)) - if err != nil { - return "", err + toolProvider = strings.ToLower(toolSpecFields[0]) + toolSource = toolSpecFields[1] + switch toolProvider { + case "github", "gh": + g, err := NewGithubRepo(toolSource) + if err != nil { + return "", err + } + downloadPath, actualToolVersion, assetBaseName, err := g.DownloadReleaseForVersion(toolVersion) + if err != nil { + return "", err + } + wasExtracted, err := ExtractFile(downloadPath) + if err != nil { + return "", err + } + toolName := assetBaseName + var extractedToolBinary string = downloadPath // non-archived binary + if wasExtracted { + extractedToolBinary = fmt.Sprintf("%s/%s", filepath.Dir(downloadPath), toolName) + } + installDest := fmt.Sprintf("%s/%s/%s/%s", j.installsDir, toolName, actualToolVersion, toolName) + err = CopyExecutableToCreatedDir(extractedToolBinary, installDest) + if err != nil { + return "", err + } + err = j.createShim(toolName) + if err != nil { + return "", err + } + debugLog.Printf("Installed version %q", actualToolVersion) + return actualToolVersion, nil + default: + return "", fmt.Errorf("unknown tool provider %q", toolProvider) } - debugLog.Printf("Installed version %q", toolVersion) - 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) + debugLog.Printf("Assessing shim %s\n", binaryName) _, err := os.Stat(j.shimsDir) if err != nil && !errors.Is(err, fs.ErrNotExist) { return err @@ -233,89 +210,186 @@ func (j JKL) createShim(binaryName string) error { 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)) + shimPath := filepath.Join(j.shimsDir, binaryName) + shimStat, err := os.Lstat(shimPath) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("while looking for existing shim %s: %v", shimPath, err) + } + if errors.Is(err, fs.ErrNotExist) { + debugLog.Printf("Creating shim %s -> %s\n", binaryName, j.executable) + err = os.Symlink(j.executable, shimPath) + if err != nil { + return err + } + return nil + } + if shimStat.Mode()&fs.ModeSymlink == 0 { + return fmt.Errorf("not overwriting existing incorrect shim %s which should be a symlink (%v), but is instead mode %v", shimPath, fs.ModeSymlink, shimStat.Mode()) + } + shimDest, err := filepath.EvalSymlinks(shimPath) + if err != nil { + return fmt.Errorf("while dereferencing shim symlink %s: %v", shimPath, err) + } + if shimDest == j.executable { + debugLog.Printf("shim for %s already exists", shimPath) + return nil + } + return fmt.Errorf("shim %s already exists but points to %q", shimPath, shimDest) +} + +// listInstalledTools returns a list of tools with at least one version +// installed. +// A missing JKL.installsDir is not an error and will return 0 tools +// installed. +func (j JKL) listInstalledTools() (toolNames []string, err error) { + fileSystem := os.DirFS(j.installsDir) + toolNames = make([]string, 0) + err = fs.WalkDir(fileSystem, ".", func(path string, d fs.DirEntry, err error) error { + if !errors.Is(err, fs.ErrNotExist) && err != nil { + return err + } + if path != "." && d.IsDir() { + _, hasVersions, err := j.listInstalledVersionsOfTool(path) + if err != nil { + return err + } + if hasVersions { + toolNames = append(toolNames, path) + return nil + } + } + return nil + }) + if err != nil { + return nil, err + } + if len(toolNames) == 0 { + return nil, nil + } + sort.Strings(toolNames) + return toolNames, nil +} + +func (j JKL) displayInstalledTools(output io.Writer) error { + toolNames, err := j.listInstalledTools() if err != nil { return err } + for _, v := range toolNames { + fmt.Fprintln(output, v) + } return nil } -// getInstalledCommandPath returns the full path to the desired version of the +// getPathForToolDesiredVersion 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) +func (j JKL) getPathForToolDesiredVersion(toolName, toolVersion string) (installedPath string, err error) { + installedPath = fmt.Sprintf("%[1]s/%[2]s/%[3]s/%[2]s", j.installsDir, toolName, toolVersion) + _, err = os.Stat(installedPath) 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) + debugLog.Printf("desired installed tool %q not found", installedPath) return "", nil } - debugLog.Printf("installed command path for %s is %q\n", commandName, installedCommandPath) - return installedCommandPath, nil + debugLog.Printf("installed path for %s is %q\n", toolName, installedPath) + return installedPath, nil +} + +// listInstalledVersionsOfTool returns a list of installed versions for +// the specified tool. +func (j JKL) listInstalledVersionsOfTool(toolName string) (versions []string, found bool, err error) { + fileSystem := os.DirFS(filepath.Join(j.installsDir, toolName)) + 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) + found = true + return nil + } + return nil + }) + if err != nil { + return nil, false, err + } + if !found { + return nil, false, nil + } + return versions, true, nil +} + +func (j JKL) displayInstalledVersionsOfTool(output io.Writer, toolName string) error { + toolVersions, ok, err := j.listInstalledVersionsOfTool(toolName) + if err != nil { + return fmt.Errorf("cannot list installed versions of %s: %v", toolName, err) + } + if !ok { + fmt.Fprintf(output, "%s is not installed\n", toolName) + return nil + } + sort.Strings(toolVersions) + for _, v := range toolVersions { + fmt.Fprintln(output, v) + } + return nil } -// getDesiredVersionForCommand returns the version of the specified command +// getDesiredVersionOfTool 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) +func (j JKL) getDesiredVersionOfTool(toolName string) (desiredVersion string, found bool, err error) { + envVarName := j.getEnvVarNameForToolDesiredVersion(toolName) + desiredVersion = os.Getenv(envVarName) + if desiredVersion == "" { + debugLog.Printf("environment variable %q is not set, looking in config files for the %s version", envVarName, toolName) var ok bool // ToDo: Our own config file is not yet implemented. - commandVersion, ok, err = FindASDFToolVersion(commandName) + desiredVersion, ok, err = FindASDFToolVersion(toolName) if err != nil { return "", false, err } if !ok { - debugLog.Printf("No version specified for command %q", commandName) + debugLog.Printf("No desired version specified for %q", toolName) return "", false, nil } } - debugLog.Printf("version %s specified for %q\n", commandVersion, commandName) - if strings.ToLower(commandVersion) == "latest" { - return j.getLatestInstalledVersionForCommand(commandName) + debugLog.Printf("version %s specified for %q\n", desiredVersion, toolName) + if strings.ToLower(desiredVersion) == "latest" { + return j.getLatestInstalledVersionOfTool(toolName) } - return commandVersion, true, nil + return desiredVersion, true, nil } -// getDesiredCommandVersionEnvVarName returns the name of the environment +// getEnvVarNameForToolDesiredVersion 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 { +func (j JKL) getEnvVarNameForToolDesiredVersion(toolName string) string { // ToDo: Make this env var format configurable in the constructor? - return fmt.Sprintf("JKL_%s", strings.ToUpper(strings.ReplaceAll(commandName, "-", "_"))) + return fmt.Sprintf("JKL_%s", strings.ToUpper(strings.ReplaceAll(toolName, "-", "_"))) } -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 - }) +func (j JKL) getLatestInstalledVersionOfTool(toolName string) (latestVersion string, found bool, err error) { + versions, ok, err := j.listInstalledVersionsOfTool(toolName) 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) + if !ok { + debugLog.Printf("no versions found for %q while looking for the latest installed version", toolName) return "", false, nil } - commandVersion = versions[len(versions)-1] - debugLog.Printf("the latest installed version of %s is %s", commandName, commandVersion) - return commandVersion, true, nil + sortedVersions := make([]*hashicorpversion.Version, len(versions)) + for i, v := range versions { + hv, _ := hashicorpversion.NewVersion(v) + sortedVersions[i] = hv + } + sort.Sort(hashicorpversion.Collection(sortedVersions)) + latestVersion = sortedVersions[len(sortedVersions)-1].Original() + debugLog.Printf("the latest installed version of %s is %s", toolName, latestVersion) + return latestVersion, true, nil } diff --git a/testdata/archives/file.tar b/testdata/archives/file.tar index a61b60c..8b6cd86 100644 Binary files a/testdata/archives/file.tar and b/testdata/archives/file.tar differ diff --git a/testdata/archives/file.tar.bz2 b/testdata/archives/file.tar.bz2 index ef1e347..cdd0d26 100644 Binary files a/testdata/archives/file.tar.bz2 and b/testdata/archives/file.tar.bz2 differ diff --git a/testdata/archives/file.tar.gz b/testdata/archives/file.tar.gz index fb2cd87..78a0b57 100644 Binary files a/testdata/archives/file.tar.gz and b/testdata/archives/file.tar.gz differ diff --git a/testdata/archives/file.zip b/testdata/archives/file.zip index d44d1e4..e60e182 100644 Binary files a/testdata/archives/file.zip and b/testdata/archives/file.zip differ diff --git a/testdata/archives/plain-file b/testdata/archives/plain-file new file mode 100644 index 0000000..e69de29 diff --git a/util.go b/util.go index 959ef16..de0a634 100644 --- a/util.go +++ b/util.go @@ -64,15 +64,17 @@ func CopyFile(filePath, destDir string) error { return nil } -// CopyExecutableToCreatedDir copies the specified file to destDir, and sets -// permissions to 0755 so the resulting file is executable. IF destDir does -// not exist it wil be created. -func CopyExecutableToCreatedDir(filePath, destDir string) error { - _, err := os.Stat(filePath) +// CopyExecutableToCreatedDir copies the specified file to destFilePath, and sets +// permissions to 0755 so the resulting file is executable. IF destFilePath +// minus the file name does +// not exist, the directory wil be created. +func CopyExecutableToCreatedDir(sourceFilePath, destFilePath string) error { + _, err := os.Stat(sourceFilePath) if err != nil { return err } - debugLog.Printf("copying file %q to directory %q", filePath, destDir) + debugLog.Printf("copying file %q to %q", sourceFilePath, destFilePath) + destDir := filepath.Dir(destFilePath) _, err = os.Stat(destDir) if err != nil && !errors.Is(err, fs.ErrNotExist) { return err @@ -84,24 +86,23 @@ func CopyExecutableToCreatedDir(filePath, destDir string) error { return err } } - fileName := filepath.Base(filePath) - s, err := os.Open(filePath) + s, err := os.Open(sourceFilePath) if err != nil { return err } defer s.Close() - d, err := os.Create(destDir + "/" + fileName) + d, err := os.Create(destFilePath) if err != nil { return err } err = d.Chmod(0755) if err != nil { - return fmt.Errorf("cannot set mode on %s: %v", destDir+fileName, err) + return fmt.Errorf("cannot set mode on %s: %v", destFilePath, err) } defer d.Close() _, err = io.Copy(d, s) if err != nil { - return fmt.Errorf("Cannot write to %s: %v", fileName, err) + return fmt.Errorf("Cannot write to %s: %v", destFilePath, err) } return nil } @@ -165,3 +166,26 @@ func listPathsByParent(fileName string, options ...pathOption) (paths []string, debugLog.Printf("File %s was found in these paths: %v\n", fileName, paths) return paths, nil } + +// directoryInPath returns true if the specified directory is among those in +// the PATH environment variable. +func directoryInPath(dirName string) (bool, error) { + if dirName == "" { + return false, nil + } + absDirName, err := filepath.Abs(dirName) + if err != nil { + return false, fmt.Errorf("cannot make %q absolute: %v", dirName, err) + } + pathComponents := filepath.SplitList(os.Getenv("PATH")) + for _, component := range pathComponents { + absComponent, err := filepath.Abs(component) + if err != nil { + return false, fmt.Errorf("cannot make path component %q absolute: %v", component, err) + } + if absComponent == absDirName { + return true, nil + } + } + return false, nil +}