diff --git a/README.md b/README.md index f4f450a..2b78f46 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Full document is here: [AFX](https://babarot.me/afx/) - Allows to manage various packages types: - GitHub / GitHub Release / Gist / HTTP (web) / Local + - [gh extensions](https://github.com/topics/gh-extension) - Manages as CLI commands, shell plugins or both - Easy to install/update/uninstall - Easy to configure with YAML diff --git a/docs/configuration/package/github.md b/docs/configuration/package/github.md index 1894053..2936de4 100644 --- a/docs/configuration/package/github.md +++ b/docs/configuration/package/github.md @@ -87,6 +87,36 @@ number | `0` (all commits) Limit fetching to the specified number of commits from the tip of each remote branch history. If fetching to a shallow repository, specify 1 or more number, deepen or shorten the history to the specified number of commits. +### as + +Key | Type | Default +---|---|--- +gh-extension | Object | `null` + +Change the installation behavior of the packages based on specified package type. In current afx, all packages are based on where it's hosted e.g. `github`. Almost all cases are not problem on that but some package types (such as "brew" or "gh extension") will be able to do more easily if there is a dedicated parameters to install the packages. In this `as` section, it expands more this installation method. Some of package types (especially "brew") will be coming soon in near future. + +=== "gh-extension" + + Install a package as [gh extension](https://github.blog/2023-01-13-new-github-cli-extension-tools/). Officially gh extensions can be installed with `gh extension install owern/repo` command ([guide](https://cli.github.com/manual/gh_extension_install)) but it's difficult to manage what we downloaded as code. In afx, by handling them as the same as other packages, it allows us to codenize them. + + Key | Type | Default + ---|---|--- + name | string | (required) + tag | string | `latest` + rename-to | string | `""` + + ```yaml + - name: yusukebe/gh-markdown-preview + description: GitHub CLI extension to preview Markdown looks like GitHub. + owner: yusukebe + repo: gh-markdown-preview + as: + gh-extension: + name: gh-markdown-preview + tag: v1.4.0 + rename-to: gh-md # markdown-preview is long so rename it to shorten. + ``` + ### release.name Type | Default diff --git a/docs/index.md b/docs/index.md index 6e67319..fef01e3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,3 +29,5 @@ Flags: Use "afx [command] --help" for more information about a command. ``` + +![](https://user-images.githubusercontent.com/4442708/224565945-2c09b729-82b7-4829-9cbc-e247b401b689.gif) diff --git a/go.mod b/go.mod index fa56bdc..1b44c55 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( golang.org/x/sync v0.1.0 golang.org/x/term v0.6.0 gopkg.in/src-d/go-git.v4 v4.13.1 + gopkg.in/yaml.v2 v2.2.2 ) require ( diff --git a/go.sum b/go.sum index eedb474..6c0b027 100644 --- a/go.sum +++ b/go.sum @@ -265,6 +265,7 @@ gopkg.in/src-d/go-git.v4 v4.13.1 h1:SRtFyV8Kxc0UP7aCHcijOMQGPxHSmMOPrzulQWolkYE= gopkg.in/src-d/go-git.v4 v4.13.1/go.mod h1:nx5NYcxdKxq5fpltdHnPa2Exj4Sx0EclMWZQbYDu2z8= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/pkg/config/config.go b/pkg/config/config.go index 4bb83c4..ceef989 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -9,7 +9,6 @@ import ( "strings" "github.com/b4b4r07/afx/pkg/dependency" - "github.com/b4b4r07/afx/pkg/errors" "github.com/b4b4r07/afx/pkg/state" "github.com/go-playground/validator/v10" "github.com/goccy/go-yaml" @@ -55,6 +54,7 @@ func Read(path string) (Config, error) { defer f.Close() validate := validator.New() + validate.RegisterValidation("gh-extension", ValidateGHExtension) d := yaml.NewDecoder( bufio.NewReader(f), yaml.DisallowUnknownField(), @@ -98,7 +98,7 @@ func (c Config) Parse() ([]Package, error) { func visitYAML(files *[]string) filepath.WalkFunc { return func(path string, info os.FileInfo, err error) error { if err != nil { - return errors.Wrapf(err, "%s: failed to visit", path) + return fmt.Errorf("%w: %s: failed to visit", err, path) } switch filepath.Ext(path) { case ".yaml", ".yml": @@ -178,7 +178,7 @@ func Sort(given []Package) ([]Package, error) { resolved, err := dependency.Resolve(graph) if err != nil { - return pkgs, errors.Wrap(err, "failed to resolve dependency graph") + return pkgs, fmt.Errorf("%w: failed to resolve dependency graph", err) } for _, node := range resolved { @@ -245,6 +245,11 @@ func getResource(pkg Package) state.Resource { if pkg.HasReleaseBlock() { id = fmt.Sprintf("github.com/release/%s/%s", pkg.Owner, pkg.Repo) } + if pkg.IsGHExtension() { + ty = "GitHub (gh extension)" + gh := pkg.As.GHExtension + paths = append(paths, gh.GetHome()) + } case Gist: ty = "Gist" id = fmt.Sprintf("gist.github.com/%s/%s", pkg.Owner, pkg.ID) diff --git a/pkg/config/github.go b/pkg/config/github.go index 09a2256..5012651 100644 --- a/pkg/config/github.go +++ b/pkg/config/github.go @@ -5,12 +5,15 @@ import ( "fmt" "io/ioutil" "log" + "net/http" "os" "path/filepath" + "strings" git "gopkg.in/src-d/go-git.v4" "gopkg.in/src-d/go-git.v4/config" "gopkg.in/src-d/go-git.v4/plumbing" + "gopkg.in/yaml.v2" "github.com/Masterminds/semver" "github.com/b4b4r07/afx/pkg/data" @@ -20,14 +23,15 @@ import ( "github.com/b4b4r07/afx/pkg/state" "github.com/b4b4r07/afx/pkg/templates" "github.com/fatih/color" + "github.com/go-playground/validator/v10" ) // GitHub represents GitHub repository type GitHub struct { Name string `yaml:"name" validate:"required"` - Owner string `yaml:"owner" validate:"required"` - Repo string `yaml:"repo" validate:"required"` + Owner string `yaml:"owner" validate:"required"` + Repo string `yaml:"repo" validate:"required"` Description string `yaml:"description"` Branch string `yaml:"branch"` @@ -35,12 +39,23 @@ type GitHub struct { Release *GitHubRelease `yaml:"release"` - Plugin *Plugin `yaml:"plugin"` - Command *Command `yaml:"command" validate:"required_with=Release"` // TODO: (not required Release) + Plugin *Plugin `yaml:"plugin"` + Command *Command `yaml:"command" validate:"required_with=Release"` // TODO: (not required Release) + As *GitHubAs `yaml:"as"` DependsOn []string `yaml:"depends-on"` } +type GitHubAs struct { + GHExtension *GHExtension `yaml:"gh-extension"` +} + +type GHExtension struct { + Name string `yaml:"name" validate:"required,startswith=gh-"` + Tag string `yaml:"tag"` + RenameTo string `yaml:"rename-to" validate:"gh-extension,excludesall=/"` +} + type GitHubOption struct { Depth int `yaml:"depth"` } @@ -168,6 +183,17 @@ func (c GitHub) Install(ctx context.Context, status chan<- Status) error { } var errs errors.Errors + + if c.IsGHExtension() { + gh := c.As.GHExtension + err := gh.Install(ctx, c.Owner, c.Repo, gh.GetTag()) + if err != nil { + err = errors.Wrapf(err, "%s: failed to get from release", c.Name) + status <- Status{Name: c.GetName(), Done: true, Err: true} + return err + } + } + if c.HasPluginBlock() { errs.Append(c.Plugin.Install(c)) } @@ -202,17 +228,27 @@ func (c GitHub) Installed() bool { return check(list) } +func (c GitHub) GetReleaseTag() string { + if c.Release != nil { + return c.Release.Tag + } + return "latest" +} + // InstallFromRelease runs install from GitHub release, from not repository func (c GitHub) InstallFromRelease(ctx context.Context) error { ctx, cancel := context.WithCancel(ctx) defer cancel() + owner, repo, tag := c.Owner, c.Repo, c.GetReleaseTag() + log.Printf("[DEBUG] install from release: %s/%s (%s)", owner, repo, tag) + release, err := github.NewRelease( - ctx, c.Owner, c.Repo, c.Release.Tag, + ctx, owner, repo, tag, github.WithWorkdir(c.GetHome()), github.WithFilter(func(filename string) github.FilterFunc { if filename == "" { - // do not use filterfunc + // cancel filtering return nil } return func(assets github.Assets) *github.Asset { @@ -276,22 +312,18 @@ func (c GitHub) templateFilename() string { return filename } -// HasPluginBlock is func (c GitHub) HasPluginBlock() bool { return c.Plugin != nil } -// HasCommandBlock is func (c GitHub) HasCommandBlock() bool { return c.Command != nil } -// HasReleaseBlock is func (c GitHub) HasReleaseBlock() bool { return c.Release != nil } -// GetPluginBlock is func (c GitHub) GetPluginBlock() Plugin { if c.HasPluginBlock() { return *c.Plugin @@ -299,7 +331,6 @@ func (c GitHub) GetPluginBlock() Plugin { return Plugin{} } -// GetCommandBlock is func (c GitHub) GetCommandBlock() Command { if c.HasCommandBlock() { return *c.Command @@ -307,7 +338,6 @@ func (c GitHub) GetCommandBlock() Command { return Command{} } -// Uninstall is func (c GitHub) Uninstall(ctx context.Context) error { var errs errors.Errors @@ -332,7 +362,6 @@ func (c GitHub) Uninstall(ctx context.Context) error { } delete(c.GetHome(), &errs) - return errs.ErrorOrNil() } @@ -343,6 +372,9 @@ func (c GitHub) GetName() string { // GetHome returns a path func (c GitHub) GetHome() string { + if c.IsGHExtension() { + return c.As.GHExtension.GetHome() + } return filepath.Join(os.Getenv("HOME"), ".afx", "github.com", c.Owner, c.Repo) } @@ -437,3 +469,124 @@ func (c GitHub) checkUpdates(ctx context.Context) (report, error) { return report{}, errors.New("invalid version comparison") } } + +func ValidateGHExtension(fl validator.FieldLevel) bool { + return fl.Field().String() == "" || strings.HasPrefix(fl.Field().String(), "gh-") +} + +func (c GitHub) IsGHExtension() bool { + return c.As != nil && c.As.GHExtension != nil && c.As.GHExtension.Name != "" +} + +type ghManifest struct { + Owner string `yaml:"owner"` + Name string `yaml:"name"` + Host string `yaml:"host"` + Tag string `yaml:"tag"` + IsPinned bool `yaml:"ispinned"` + Path string `yaml:"path"` +} + +func (gh GHExtension) GetHome() string { + base := filepath.Join(os.Getenv("HOME"), ".local", "share", "gh", "extensions") + var ext string + if gh.RenameTo == "" { + ext = filepath.Join(base, gh.Name) + } else { + ext = filepath.Join(base, gh.RenameTo) + } + return ext +} + +func (gh GHExtension) GetTag() string { + if gh.Tag != "" { + return gh.Tag + } + return "latest" +} + +func (gh GHExtension) Install(ctx context.Context, owner, repo, tag string) error { + available, _ := github.HasRelease(http.DefaultClient, owner, repo, tag) + if available { + err := gh.InstallFromRelease(ctx, owner, repo, tag) + if err != nil { + return fmt.Errorf("%w: %s: failed to get gh extension", err, gh.Name) + } + } + + ghHome := gh.GetHome() + // ensure to create the parent dir of each gh extension's path + _ = os.MkdirAll(filepath.Dir(ghHome), os.ModePerm) + + // make alias + if gh.RenameTo != "" { + if err := os.Symlink( + filepath.Join(ghHome, gh.Name), + filepath.Join(ghHome, gh.RenameTo), + ); err != nil { + return fmt.Errorf("%w: failed to symlink as alise", err) + } + } + + if gh.GetTag() == "latest" { + // in case of not making manifest yaml + return nil + } + + return gh.makeManifest(owner) +} + +func (gh GHExtension) InstallFromRelease(ctx context.Context, owner, repo, tag string) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + log.Printf("[DEBUG] install from release: %s/%s (%s)", owner, repo, tag) + release, err := github.NewRelease( + ctx, owner, repo, tag, + github.WithOverwrite(), + github.WithWorkdir(gh.GetHome()), + ) + if err != nil { + return err + } + + asset, err := release.Download(ctx) + if err != nil { + return errors.Wrapf(err, "%s: failed to download", release.Name) + } + + if err := release.Unarchive(asset); err != nil { + return errors.Wrapf(err, "%s: failed to unarchive", release.Name) + } + + return nil +} + +func (gh GHExtension) makeManifest(owner string) error { + // https://github.com/cli/cli/blob/c9a2d85793c4cef026d5bb941b3ac4121c81ae10/pkg/cmd/extension/manager.go#L424-L451 + manifest := ghManifest{ + Name: gh.Name, + Owner: owner, + Host: "github.com", + Path: gh.GetHome(), + Tag: gh.GetTag(), + IsPinned: false, + } + bs, err := yaml.Marshal(manifest) + if err != nil { + return fmt.Errorf("failed to serialize manifest: %w", err) + } + + manifestPath := filepath.Join(gh.GetHome(), "manifest.yml") + f, err := os.OpenFile(manifestPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("failed to open manifest for writing: %w", err) + } + defer f.Close() + + _, err = f.Write(bs) + if err != nil { + return fmt.Errorf("failed write manifest file: %w", err) + } + return nil +} diff --git a/pkg/github/github.go b/pkg/github/github.go index ef1108d..f295408 100644 --- a/pkg/github/github.go +++ b/pkg/github/github.go @@ -27,10 +27,11 @@ type Release struct { Tag string Assets Assets - client *Client - workdir string - verbose bool - filter func(Assets) *Asset + client *Client + workdir string + verbose bool + overwrite bool + filter func(Assets) *Asset } // Asset represents GitHub release's asset. @@ -89,6 +90,12 @@ type Option func(r *Release) type FilterFunc func(assets Assets) *Asset +func WithOverwrite() Option { + return func(r *Release) { + r.overwrite = true + } +} + func WithWorkdir(workdir string) Option { return func(r *Release) { r.workdir = workdir @@ -292,11 +299,17 @@ func (r *Release) Unarchive(asset Asset) error { // because this logic renames a binary of 'jq-1.6' to 'jq' // target := filepath.Join(r.workdir, r.Name) - if _, err := os.Stat(target); err != nil { - log.Printf("[DEBUG] renamed from %s to %s", archive, target) - os.Rename(archive, target) - os.Chmod(target, 0755) + if _, err := os.Stat(target); err == nil { + if r.overwrite { + log.Printf("[WARN] %s: already exist. but overwrite", target) + } else { + log.Printf("[WARN] %s: already exist. so skip to unarchive", target) + return nil + } } + log.Printf("[DEBUG] renamed from %s to %s", archive, target) + os.Rename(archive, target) + os.Chmod(target, 0755) return nil } @@ -376,3 +389,25 @@ func (r *Release) Install(to string) error { TargetPath: to, }) } + +func HasRelease(httpClient *http.Client, owner, repo, tag string) (bool, error) { + // https://github.com/cli/cli/blob/9596fd5368cdbd30d08555266890a2312e22eba9/pkg/cmd/extension/http.go#L110 + releaseURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", owner, repo) + switch tag { + case "latest", "": + releaseURL += "/latest" + default: + releaseURL += fmt.Sprintf("/tags/%s", tag) + } + req, err := http.NewRequest("GET", releaseURL, nil) + if err != nil { + return false, err + } + + resp, err := httpClient.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + return resp.StatusCode < 299, nil +}