├── .github ├── ISSUE_TEMPLATE.md └── workflows │ ├── check-release.yaml │ ├── docs.yaml │ ├── go.yaml │ └── release.yaml ├── .gitignore ├── .goreleaser.yaml ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── check.go ├── completion.go ├── init.go ├── install.go ├── meta.go ├── root.go ├── self-update.go ├── show.go ├── state.go ├── uninstall.go └── update.go ├── docs ├── configuration │ ├── command.md │ ├── package │ │ ├── gist.md │ │ ├── github.md │ │ ├── http.md │ │ └── local.md │ └── plugin.md ├── faq.md ├── getting-started.md ├── how-it-works.md ├── images │ ├── config-dir.svg │ ├── cover.png │ ├── dir-map.png │ ├── install-drop.png │ ├── install.png │ ├── installation.png │ ├── state-json.svg │ ├── state.png │ ├── state.svg │ ├── struct.png │ └── struct.svg ├── index.md ├── links.md └── requirements.txt ├── go.mod ├── go.sum ├── hack ├── README.md └── install ├── main.go ├── mkdocs.yml └── pkg ├── config ├── command.go ├── config.go ├── gist.go ├── github.go ├── http.go ├── local.go ├── package.go ├── plugin.go ├── status.go └── util.go ├── data └── data.go ├── dependency └── dependency.go ├── env └── config.go ├── errors ├── errors.go └── wrap.go ├── github ├── client.go ├── github.go └── github_test.go ├── helpers ├── shell │ └── shell.go ├── spin │ ├── spin.go │ └── symbol.go └── templates │ ├── README.md │ ├── markdown.go │ └── normalizer.go ├── logging ├── logging.go └── transport.go ├── printers ├── printers.go └── terminal.go ├── state ├── state.go ├── state_test.go └── testing.go ├── templates └── templates.go └── update ├── update.go └── update_test.go /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## WHAT 2 | 3 | (Write what you need) 4 | 5 | ## WHY 6 | 7 | (Write the background of this issue) 8 | -------------------------------------------------------------------------------- /.github/workflows/check-release.yaml: -------------------------------------------------------------------------------- 1 | name: Check Release 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - labeled 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | 14 | - uses: actions-ecosystem/action-release-label@v1 15 | id: release-label 16 | if: ${{ startsWith(github.event.label.name, 'release/') }} 17 | 18 | - uses: actions-ecosystem/action-get-latest-tag@v1 19 | id: get-latest-tag 20 | if: ${{ steps.release-label.outputs.level != null }} 21 | with: 22 | semver_only: true 23 | 24 | - uses: actions-ecosystem/action-bump-semver@v1 25 | id: bump-semver 26 | if: ${{ steps.release-label.outputs.level != null }} 27 | with: 28 | current_version: ${{ steps.get-latest-tag.outputs.tag }} 29 | level: ${{ steps.release-label.outputs.level }} 30 | 31 | - uses: actions-ecosystem/action-create-comment@v1 32 | if: ${{ steps.bump-semver.outputs.new_version != null }} 33 | with: 34 | github_token: ${{ secrets.GITHUB_TOKEN }} 35 | body: | 36 | This PR will update [${{ github.repository }}](https://github.com/${{ github.repository }}) from [${{ steps.get-latest-tag.outputs.tag }}](https://github.com/${{ github.repository }}/releases/tag/${{ steps.get-latest-tag.outputs.tag }}) to **${{ steps.bump-semver.outputs.new_version }}** :rocket: 37 | 38 | Changes: https://github.com/${{ github.repository }}/compare/${{ steps.get-latest-tag.outputs.tag }}...${{ github.event.pull_request.head.ref }} 39 | 40 | If this update isn't as you expected, you may want to change or remove the *release label*. 41 | -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | name: Make docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | paths: 9 | - 'mkdocs.yml' 10 | - 'docs/**' 11 | - '.github/workflows/docs.yaml' 12 | 13 | jobs: 14 | deploy: 15 | runs-on: ubuntu-20.04 16 | concurrency: 17 | group: ${{ github.workflow }}-${{ github.ref }} 18 | steps: 19 | - uses: actions/checkout@v3 20 | with: 21 | submodules: true # Fetch Hugo themes (true OR recursive) 22 | fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod 23 | 24 | - name: Setup Python 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: '3.6' 28 | architecture: 'x64' 29 | 30 | - name: Cache dependencies 31 | uses: actions/cache@v3 32 | with: 33 | path: ~/.cache/pip 34 | key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} 35 | restore-keys: | 36 | ${{ runner.os }}-pip- 37 | 38 | - name: Install dependencies 39 | run: | 40 | python3 -m pip install --upgrade pip 41 | python3 -m pip install -r ./docs/requirements.txt 42 | 43 | - run: mkdocs build 44 | 45 | - name: Deploy 46 | uses: peaceiris/actions-gh-pages@v3 47 | if: ${{ github.ref == 'refs/heads/main' }} 48 | with: 49 | github_token: ${{ secrets.GITHUB_TOKEN }} 50 | publish_dir: ./site 51 | -------------------------------------------------------------------------------- /.github/workflows/go.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | strategy: 6 | fail-fast: false 7 | matrix: 8 | os: [ubuntu-latest, windows-latest, macos-latest] 9 | runs-on: ${{ matrix.os }} 10 | 11 | steps: 12 | - name: Set up Go 1.23 13 | uses: actions/setup-go@v3 14 | with: 15 | go-version: 1.23.x 16 | 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | 20 | - name: Cache Go modules 21 | uses: actions/cache@v3 22 | with: 23 | path: ~/go 24 | key: ${{ runner.os }}-build-${{ hashFiles('go.mod') }} 25 | restore-keys: | 26 | ${{ runner.os }}-build- 27 | ${{ runner.os }}- 28 | - name: Download dependencies 29 | run: go mod download 30 | 31 | - name: Run tests 32 | run: go test -race ./... 33 | 34 | - name: Build 35 | run: go build -v 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | 9 | jobs: 10 | release: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v3 20 | with: 21 | go-version: 1.20.x 22 | 23 | - name: Cache Go modules 24 | uses: actions/cache@v3 25 | with: 26 | path: ~/go/pkg/mod 27 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 28 | restore-keys: | 29 | ${{ runner.os }}-go- 30 | 31 | - name: Get pull request 32 | uses: actions-ecosystem/action-get-merged-pull-request@v1 33 | id: get-merged-pull-request 34 | with: 35 | github_token: ${{ secrets.GITHUB_TOKEN }} 36 | 37 | - name: Get release label 38 | uses: actions-ecosystem/action-release-label@v1 39 | id: release-label 40 | if: ${{ steps.get-merged-pull-request.outputs.title != null }} 41 | with: 42 | labels: ${{ steps.get-merged-pull-request.outputs.labels }} 43 | 44 | - name: Get latest Git tag 45 | uses: actions-ecosystem/action-get-latest-tag@v1 46 | id: get-latest-tag 47 | if: ${{ steps.release-label.outputs.level != null }} 48 | with: 49 | semver_only: true 50 | 51 | - name: Bump up version 52 | uses: actions-ecosystem/action-bump-semver@v1 53 | id: bump-semver 54 | if: ${{ steps.release-label.outputs.level != null }} 55 | with: 56 | current_version: ${{ steps.get-latest-tag.outputs.tag }} 57 | level: ${{ steps.release-label.outputs.level }} 58 | 59 | - name: Push new Git tag 60 | uses: actions-ecosystem/action-push-tag@v1 61 | if: ${{ steps.bump-semver.outputs.new_version != null }} 62 | with: 63 | tag: ${{ steps.bump-semver.outputs.new_version }} 64 | message: "${{ steps.bump-semver.outputs.new_version }}: PR #${{ steps.get-merged-pull-request.outputs.number }} ${{ steps.get-merged-pull-request.outputs.title }}" 65 | 66 | - name: Release binaries with GoReleaser 67 | uses: goreleaser/goreleaser-action@v4 68 | if: ${{ steps.release-label.outputs.level == 'major' || steps.release-label.outputs.level == 'minor' || steps.release-label.outputs.level == 'patch' }} 69 | with: 70 | distribution: goreleaser 71 | version: latest 72 | args: release --clean 73 | env: 74 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 75 | 76 | - name: Post release comment 77 | uses: actions-ecosystem/action-create-comment@v1 78 | if: ${{ steps.bump-semver.outputs.new_version != null }} 79 | with: 80 | github_token: ${{ secrets.GITHUB_TOKEN }} 81 | number: ${{ steps.get-merged-pull-request.outputs.number }} 82 | body: | 83 | The new version [${{ steps.bump-semver.outputs.new_version }}](https://github.com/${{ github.repository }}/releases/tag/${{ steps.bump-semver.outputs.new_version }}) has been released :tada: 84 | 85 | Changes: https://github.com/${{ github.repository }}/compare/${{ steps.get-latest-tag.outputs.tag }}...${{ steps.bump-semver.outputs.new_version }} 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/* 2 | afx 3 | *.swp 4 | vendor 5 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | project_name: afx 2 | env: 3 | - GO111MODULE=on 4 | before: 5 | hooks: 6 | - go mod tidy 7 | builds: 8 | - main: . 9 | binary: afx 10 | ldflags: 11 | - -s -w 12 | - -X github.com/babarot/afx/cmd.Version={{ .Version }} 13 | - -X github.com/babarot/afx/cmd.BuildTag={{ .Tag }} 14 | - -X github.com/babarot/afx/cmd.BuildSHA={{ .ShortCommit }} 15 | env: 16 | - CGO_ENABLED=0 17 | archives: 18 | - name_template: '{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' 19 | replacements: 20 | darwin: darwin 21 | linux: linux 22 | windows: windows 23 | 386: i386 24 | amd64: x86_64 25 | format_overrides: 26 | - goos: windows 27 | format: zip 28 | release: 29 | prerelease: auto 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-2022 Masaki ISHIYAMA 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ifdef DEBUG 2 | GOFLAGS := -gcflags="-N -l" 3 | else 4 | GOFLAGS := 5 | endif 6 | 7 | GO ?= go 8 | TAGS := 9 | LDFLAGS := 10 | 11 | GIT_COMMIT = $(shell git rev-parse HEAD) 12 | GIT_SHA = $(shell git rev-parse --short HEAD) 13 | GIT_TAG = $(shell git describe --tags --abbrev=0 --exact-match 2>/dev/null || echo "canary") 14 | GIT_DIRTY = $(shell test -n "`git status --porcelain`" && echo "dirty" || echo "clean") 15 | 16 | LDFLAGS += -X github.com/babarot/afx/cmd.BuildSHA=${GIT_SHA} 17 | LDFLAGS += -X github.com/babarot/afx/cmd.GitTreeState=${GIT_DIRTY} 18 | 19 | ifneq ($(GIT_TAG),) 20 | LDFLAGS += -X github.com/babarot/afx/cmd.BuildTag=${GIT_TAG} 21 | endif 22 | 23 | all: build 24 | 25 | .PHONY: build 26 | build: 27 | $(GO) install $(GOFLAGS) -ldflags '$(LDFLAGS)' 28 | 29 | .PHONY: test 30 | test: 31 | $(GO) test -v ./... 32 | 33 | .PHONY: docs 34 | docs: 35 | @python3 -m pip install --upgrade pip 36 | @python3 -m pip install -r ./docs/requirements.txt 37 | @python3 -m mkdocs serve 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | AFX - Package manager for CLI 2 | --- 3 | 4 | AFX is a package manager for command-line tools and shell plugins. afx can allow us to manage almost all things available on GitHub, Gist and so on. Before, we needed to trawl web pages to download each package one by one. It's very annoying every time we set up new machine and also it's difficult to get how many commands/plugins we installed. So afx's motivation is coming from that and to manage them with YAML files (as a code). 5 | 6 | [![][afx-mark]][afx-link] [![][test-mark]][test-link] [![][release-mark]][release-link] 7 | 8 | [afx-mark]: https://img.shields.io/github/v/release/babarot/afx?color=EF2D5E&display_name=release&label=AFX&logo=alchemy&logoColor=EF2D5E&sort=semver 9 | [afx-link]: https://github.com/babarot/afx/releases 10 | 11 | [test-mark]: https://github.com/babarot/afx/actions/workflows/go.yaml/badge.svg 12 | [test-link]: https://github.com/babarot/afx/actions/workflows/go.yaml 13 | 14 | [release-mark]: https://github.com/babarot/afx/actions/workflows/release.yaml/badge.svg 15 | [release-link]: https://github.com/babarot/afx/actions/workflows/release.yaml 16 | 17 | Full document is here: [AFX](https://babarot.me/afx/) 18 | 19 | 20 | 21 | 26 | 27 | ## Features 28 | 29 | - Allows to manage various packages types: 30 | - GitHub / GitHub Release / Gist / HTTP (web) / Local 31 | - [gh extensions](https://github.com/topics/gh-extension) 32 | - Manages as CLI commands, shell plugins or both 33 | - Easy to install/update/uninstall 34 | - Easy to configure with YAML 35 | - Environment variables for each packages 36 | - Aliases for each packges 37 | - Conditional branches 38 | - Build steps 39 | - Run snippet code 40 | - Dependency between packages 41 | - etc... 42 | - Works on bash, zsh and fish 43 | 44 | ## Quick Start [plus!](https://babarot.me/afx/getting-started/) 45 | 46 | - [1. Install packages](#1-install-packages) 47 | - [2. Load packages](#2-load-packages) 48 | - [3. Update packages](#3-update-packages) 49 | - [4. Uninstall packages](#4-uninstall-packages) 50 | 51 | ### 1. Install packages 52 | 53 | Write YAML file with name as you like in `~/.config/afx/`. Let's say you write this code and then put it into `github.yaml`. After than you can install packages with `install` command. 54 | ```diff 55 | + github: 56 | + - name: stedolan/jq 57 | + description: Command-line JSON processor 58 | + owner: stedolan 59 | + repo: jq 60 | + release: 61 | + name: jq 62 | + tag: jq-1.5 63 | + command: 64 | + link: 65 | + - from: '*jq*' 66 | + to: jq 67 | ``` 68 | 69 | ```console 70 | $ afx install 71 | ``` 72 | 73 | ### 2. Load packages 74 | 75 | You can enable installed packages to your current shell with this command: 76 | 77 | ```console 78 | $ source <(afx init) 79 | ``` 80 | 81 | Take it easy to run `afx init` because it just shows what to apply in your shell to Stdout. 82 | 83 | If you want to automatically load packages when you start new shell, you need to add above to your shell-rc file. 84 | 85 | ### 3. Update packages 86 | 87 | All you have to do for updating is just to update version part (release.tag) to next version then run `update` command. 88 | 89 | ```diff 90 | github: 91 | - name: stedolan/jq 92 | description: Command-line JSON processor 93 | owner: stedolan 94 | repo: jq 95 | release: 96 | name: jq 97 | - tag: jq-1.5 98 | + tag: jq-1.6 99 | command: 100 | link: 101 | - from: '*jq*' 102 | to: jq 103 | ``` 104 | 105 | ```console 106 | $ afx update 107 | ``` 108 | 109 | ### 4. Uninstall packages 110 | 111 | Uninstalling is also almost same as `install`. Just to remove unneeded part from YAML file then run `uninstall` command. 112 | 113 | ```diff 114 | - github: 115 | - - name: stedolan/jq 116 | - description: Command-line JSON processor 117 | - owner: stedolan 118 | - repo: jq 119 | - release: 120 | - name: jq 121 | - tag: jq-1.6 122 | - command: 123 | - link: 124 | - - from: '*jq*' 125 | - to: jq 126 | ``` 127 | 128 | ```console 129 | $ afx uninstall 130 | ``` 131 | 132 | ## Advanced tips 133 | 134 | ### Shell completion 135 | 136 | For zsh user, you can enable shell completion for afx: 137 | 138 | ```bash 139 | # .zshrc 140 | source <(afx completion zsh) 141 | ``` 142 | 143 | bash and fish users are also available. 144 | 145 | ## Installation 146 | 147 | Download the binary from [GitHub Release][release] and drop it in your `$PATH`. 148 | 149 | - [Darwin / Mac][release] 150 | - [Linux][release] 151 | 152 | Or, bash installer has been provided so you can install afx by running this one command at your own risk ([detail](./hack/README.md)). 153 | 154 | ```bash 155 | curl -sL https://raw.githubusercontent.com/babarot/afx/HEAD/hack/install | bash 156 | ``` 157 | 158 | [release]: https://github.com/babarot/afx/releases/latest 159 | [website]: https://babarot.me/afx/ 160 | 161 | ## License 162 | 163 | MIT 164 | -------------------------------------------------------------------------------- /cmd/check.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/signal" 9 | 10 | "github.com/babarot/afx/pkg/config" 11 | "github.com/babarot/afx/pkg/errors" 12 | "github.com/babarot/afx/pkg/helpers/templates" 13 | "github.com/babarot/afx/pkg/state" 14 | "github.com/spf13/cobra" 15 | "golang.org/x/sync/errgroup" 16 | ) 17 | 18 | type checkCmd struct { 19 | metaCmd 20 | } 21 | 22 | var ( 23 | // checkLong is long description of fmt command 24 | checkLong = templates.LongDesc(``) 25 | 26 | // checkExample is examples for fmt command 27 | checkExample = templates.Examples(` 28 | afx check [args...] 29 | 30 | By default, it tries to check packages if new version is 31 | available or not. 32 | If any args are given, it tries to check only them. 33 | `) 34 | ) 35 | 36 | // newCheckCmd creates a new fmt command 37 | func (m metaCmd) newCheckCmd() *cobra.Command { 38 | c := &checkCmd{metaCmd: m} 39 | 40 | checkCmd := &cobra.Command{ 41 | Use: "check", 42 | Short: "Check new updates on each package", 43 | Long: checkLong, 44 | Example: checkExample, 45 | Aliases: []string{"c"}, 46 | DisableFlagsInUseLine: true, 47 | SilenceUsage: true, 48 | SilenceErrors: true, 49 | Args: cobra.MinimumNArgs(0), 50 | ValidArgs: state.Keys(m.state.NoChanges), 51 | RunE: func(cmd *cobra.Command, args []string) error { 52 | resources := m.state.NoChanges 53 | if len(resources) == 0 { 54 | fmt.Println("No packages to check") 55 | return nil 56 | } 57 | 58 | var tmp []state.Resource 59 | for _, arg := range args { 60 | resource, ok := state.Map(resources)[arg] 61 | if !ok { 62 | return fmt.Errorf("%s: not installed yet", arg) 63 | } 64 | tmp = append(tmp, resource) 65 | } 66 | if len(tmp) > 0 { 67 | resources = tmp 68 | } 69 | 70 | yes, _ := m.askRunCommand(*c, state.Keys(resources)) 71 | if !yes { 72 | fmt.Println("Cancelled") 73 | return nil 74 | } 75 | 76 | pkgs := m.GetPackages(resources) 77 | m.env.AskWhen(map[string]bool{ 78 | "GITHUB_TOKEN": config.HasGitHubReleaseBlock(pkgs), 79 | }) 80 | 81 | return c.run(pkgs) 82 | }, 83 | PostRunE: func(cmd *cobra.Command, args []string) error { 84 | return m.printForUpdate() 85 | }, 86 | } 87 | 88 | return checkCmd 89 | } 90 | 91 | type checkResult struct { 92 | Package config.Package 93 | Error error 94 | } 95 | 96 | func (c *checkCmd) run(pkgs []config.Package) error { 97 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 98 | defer stop() 99 | 100 | progress := config.NewProgress(pkgs) 101 | completion := make(chan config.Status) 102 | limit := make(chan struct{}, 16) 103 | results := make(chan checkResult) 104 | 105 | go func() { 106 | progress.Print(completion) 107 | }() 108 | 109 | log.Printf("[DEBUG] (check): start to run each pkg.Check()") 110 | eg := errgroup.Group{} 111 | for _, pkg := range pkgs { 112 | pkg := pkg 113 | eg.Go(func() error { 114 | limit <- struct{}{} 115 | defer func() { <-limit }() 116 | err := pkg.Check(ctx, completion) 117 | select { 118 | case results <- checkResult{Package: pkg, Error: err}: 119 | return nil 120 | case <-ctx.Done(): 121 | return errors.Wrapf(ctx.Err(), "%s: cancelled checking", pkg.GetName()) 122 | } 123 | }) 124 | } 125 | 126 | go func() { 127 | eg.Wait() 128 | close(results) 129 | }() 130 | 131 | var exit errors.Errors 132 | for result := range results { 133 | exit.Append(result.Error) 134 | } 135 | if err := eg.Wait(); err != nil { 136 | log.Printf("[ERROR] failed to check: %s", err) 137 | exit.Append(err) 138 | } 139 | 140 | defer func(err error) { 141 | if err != nil { 142 | c.env.Refresh() 143 | } 144 | }(exit.ErrorOrNil()) 145 | 146 | return exit.ErrorOrNil() 147 | } 148 | -------------------------------------------------------------------------------- /cmd/completion.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/babarot/afx/pkg/helpers/templates" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var ( 11 | // completionLong is long description of completion command 12 | completionLong = templates.LongDesc(``) 13 | 14 | // completionExample is examples for completion command 15 | completionExample = templates.Raw(` 16 | To load completions: 17 | 18 | Bash: 19 | $ source <(afx completion bash) 20 | 21 | # To load completions for each session, execute once: 22 | # Linux: 23 | $ afx completion bash > /etc/bash_completion.d/afx 24 | # macOS: 25 | $ afx completion bash > /usr/local/etc/bash_completion.d/afx 26 | 27 | Zsh: 28 | $ source <(afx completion zsh) 29 | 30 | # If shell completion is not already enabled in your environment, 31 | # you will need to enable it. You can execute the following once: 32 | 33 | $ echo "autoload -U compinit; compinit" >> ~/.zshrc 34 | 35 | # To load completions for each session, execute once: 36 | $ afx completion zsh > "${fpath[1]}/_afx" 37 | 38 | # You will need to start a new shell for this setup to take effect. 39 | 40 | Fish: 41 | $ afx completion fish | source 42 | 43 | # To load completions for each session, execute once: 44 | $ afx completion fish > ~/.config/fish/completions/afx.fish 45 | `) 46 | ) 47 | 48 | // newCompletionCmd creates a new completion command 49 | func (m metaCmd) newCompletionCmd() *cobra.Command { 50 | return &cobra.Command{ 51 | Use: "completion [bash|zsh|fish]", 52 | Short: "Generate completion script", 53 | Long: completionLong, 54 | Example: completionExample, 55 | DisableFlagsInUseLine: true, 56 | SilenceUsage: true, 57 | SilenceErrors: true, 58 | ValidArgs: []string{"bash", "zsh", "fish"}, 59 | Args: cobra.ExactValidArgs(1), 60 | RunE: func(cmd *cobra.Command, args []string) error { 61 | switch args[0] { 62 | case "bash": 63 | newRootCmd(m).GenBashCompletion(os.Stdout) 64 | case "zsh": 65 | newRootCmd(m).GenZshCompletion(os.Stdout) 66 | case "fish": 67 | newRootCmd(m).GenFishCompletion(os.Stdout, true) 68 | } 69 | return nil 70 | }, 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /cmd/init.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/babarot/afx/pkg/helpers/templates" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | var ( 12 | // initLong is long description of init command 13 | initLong = templates.LongDesc(``) 14 | 15 | // initExample is examples for init command 16 | initExample = templates.Examples(` 17 | # show a source file to start packages installed by afx 18 | afx init 19 | 20 | # enable plugins/commands in current shell 21 | source <(afx init) 22 | 23 | # automatically load configurations 24 | Bash: 25 | echo 'source <(afx init)' ~/.bashrc 26 | Zsh: 27 | echo 'source <(afx init)' ~/.zshrc 28 | Fish: 29 | echo 'afx init | source' ~/.config/fish/config.fish 30 | `) 31 | ) 32 | 33 | // newInitCmd creates a new init command 34 | func (m metaCmd) newInitCmd() *cobra.Command { 35 | return &cobra.Command{ 36 | Use: "init", 37 | Short: "Initialize installed packages", 38 | Long: initLong, 39 | Example: initExample, 40 | DisableFlagsInUseLine: true, 41 | SilenceUsage: true, 42 | SilenceErrors: true, 43 | Args: cobra.MaximumNArgs(0), 44 | Run: func(cmd *cobra.Command, args []string) { 45 | for _, pkg := range m.packages { 46 | if err := pkg.Init(); err != nil { 47 | log.Printf("[ERROR] %s: failed to init package: %v\n", pkg.GetName(), err) 48 | // do not return err to continue to load even if failed 49 | continue 50 | } 51 | } 52 | for k, v := range m.main.Env { 53 | fmt.Printf("export %s=%q\n", k, v) 54 | } 55 | }, 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /cmd/install.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/signal" 9 | 10 | "github.com/babarot/afx/pkg/config" 11 | "github.com/babarot/afx/pkg/errors" 12 | "github.com/babarot/afx/pkg/helpers/templates" 13 | "github.com/babarot/afx/pkg/logging" 14 | "github.com/babarot/afx/pkg/state" 15 | "github.com/spf13/cobra" 16 | "golang.org/x/sync/errgroup" 17 | ) 18 | 19 | type installCmd struct { 20 | metaCmd 21 | } 22 | 23 | var ( 24 | // installLong is long description of fmt command 25 | installLong = templates.LongDesc(``) 26 | 27 | // installExample is examples for fmt command 28 | installExample = templates.Examples(` 29 | afx install [args...] 30 | 31 | By default, it tries to install all packages which are newly 32 | added to config file. 33 | If any args are given, it tries to install only them. 34 | `) 35 | ) 36 | 37 | // newInstallCmd creates a new fmt command 38 | func (m metaCmd) newInstallCmd() *cobra.Command { 39 | c := &installCmd{metaCmd: m} 40 | 41 | installCmd := &cobra.Command{ 42 | Use: "install", 43 | Short: "Resume installation from paused part (idempotency)", 44 | Long: installLong, 45 | Example: installExample, 46 | Aliases: []string{"i"}, 47 | DisableFlagsInUseLine: true, 48 | SilenceUsage: true, 49 | SilenceErrors: true, 50 | Args: cobra.MinimumNArgs(0), 51 | ValidArgs: state.Keys(m.state.Additions), 52 | RunE: func(cmd *cobra.Command, args []string) error { 53 | resources := m.state.Additions 54 | if len(resources) == 0 { 55 | fmt.Println("No packages to install") 56 | return nil 57 | } 58 | 59 | var tmp []state.Resource 60 | for _, arg := range args { 61 | resource, ok := state.Map(resources)[arg] 62 | if !ok { 63 | return fmt.Errorf("%s: no such package in config", arg) 64 | } 65 | tmp = append(tmp, resource) 66 | } 67 | if len(tmp) > 0 { 68 | resources = tmp 69 | } 70 | 71 | yes, _ := m.askRunCommand(*c, state.Keys(resources)) 72 | if !yes { 73 | fmt.Println("Cancelled") 74 | return nil 75 | } 76 | 77 | pkgs := m.GetPackages(resources) 78 | m.env.AskWhen(map[string]bool{ 79 | "GITHUB_TOKEN": config.HasGitHubReleaseBlock(pkgs), 80 | "AFX_SUDO_PASSWORD": config.HasSudoInCommandBuildSteps(pkgs), 81 | }) 82 | 83 | return c.run(pkgs) 84 | }, 85 | PostRunE: func(cmd *cobra.Command, args []string) error { 86 | return m.printForUpdate() 87 | }, 88 | } 89 | 90 | return installCmd 91 | } 92 | 93 | type installResult struct { 94 | Package config.Package 95 | Error error 96 | } 97 | 98 | func (c *installCmd) run(pkgs []config.Package) error { 99 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 100 | defer stop() 101 | 102 | progress := config.NewProgress(pkgs) 103 | completion := make(chan config.Status) 104 | limit := make(chan struct{}, 16) 105 | results := make(chan installResult) 106 | 107 | go func() { 108 | progress.Print(completion) 109 | }() 110 | 111 | log.Printf("[DEBUG] (install): start to run each pkg.Install()") 112 | eg := errgroup.Group{} 113 | for _, pkg := range pkgs { 114 | pkg := pkg 115 | eg.Go(func() error { 116 | limit <- struct{}{} 117 | defer func() { <-limit }() 118 | err := pkg.Install(ctx, completion) 119 | switch err { 120 | case nil: 121 | c.state.Add(pkg) 122 | default: 123 | if !logging.IsSet() { 124 | log.Printf("[DEBUG] uninstall %q because installation failed", pkg.GetName()) 125 | pkg.Uninstall(ctx) 126 | } 127 | } 128 | select { 129 | case results <- installResult{Package: pkg, Error: err}: 130 | return nil 131 | case <-ctx.Done(): 132 | return errors.Wrapf(ctx.Err(), "%s: cancelled installation", pkg.GetName()) 133 | } 134 | }) 135 | } 136 | 137 | go func() { 138 | eg.Wait() 139 | close(results) 140 | }() 141 | 142 | var exit errors.Errors 143 | for result := range results { 144 | exit.Append(result.Error) 145 | } 146 | 147 | if err := eg.Wait(); err != nil { 148 | log.Printf("[ERROR] failed to install: %s", err) 149 | exit.Append(err) 150 | } 151 | 152 | defer func(err error) { 153 | if err != nil { 154 | c.env.Refresh() 155 | } 156 | }(exit.ErrorOrNil()) 157 | 158 | return exit.ErrorOrNil() 159 | } 160 | -------------------------------------------------------------------------------- /cmd/meta.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | "sort" 11 | "strings" 12 | 13 | "github.com/AlecAivazis/survey/v2" 14 | "github.com/babarot/afx/pkg/config" 15 | "github.com/babarot/afx/pkg/env" 16 | "github.com/babarot/afx/pkg/errors" 17 | "github.com/babarot/afx/pkg/github" 18 | "github.com/babarot/afx/pkg/helpers/shell" 19 | "github.com/babarot/afx/pkg/printers" 20 | "github.com/babarot/afx/pkg/state" 21 | "github.com/babarot/afx/pkg/update" 22 | "github.com/fatih/color" 23 | "github.com/mattn/go-shellwords" 24 | ) 25 | 26 | type metaCmd struct { 27 | env *env.Config 28 | packages []config.Package 29 | main *config.Main 30 | state *state.State 31 | configs map[string]config.Config 32 | 33 | updateMessageChan chan *update.ReleaseInfo 34 | } 35 | 36 | func (m *metaCmd) init() error { 37 | m.updateMessageChan = make(chan *update.ReleaseInfo) 38 | go func() { 39 | log.Printf("[DEBUG] (goroutine): checking new updates...") 40 | release, err := checkForUpdate(Version) 41 | if err != nil { 42 | log.Printf("[ERROR] (goroutine): cannot check for new updates: %s", err) 43 | } 44 | m.updateMessageChan <- release 45 | }() 46 | 47 | root := filepath.Join(os.Getenv("HOME"), ".afx") 48 | cfgRoot := filepath.Join(os.Getenv("HOME"), ".config", "afx") 49 | cache := filepath.Join(root, "cache.json") 50 | 51 | err := config.CreateDirIfNotExist(cfgRoot) 52 | if err != nil { 53 | return errors.Wrapf(err, "%s: failed to create dir", cfgRoot) 54 | } 55 | files, err := config.WalkDir(cfgRoot) 56 | if err != nil { 57 | return errors.Wrapf(err, "%s: failed to walk dir", cfgRoot) 58 | } 59 | 60 | var pkgs []config.Package 61 | app := &config.DefaultMain 62 | m.configs = map[string]config.Config{} 63 | for _, file := range files { 64 | cfg, err := config.Read(file) 65 | if err != nil { 66 | return errors.Wrapf(err, "%s: failed to read config", file) 67 | } 68 | parsed, err := cfg.Parse() 69 | if err != nil { 70 | return errors.Wrapf(err, "%s: failed to parse config", file) 71 | } 72 | pkgs = append(pkgs, parsed...) 73 | 74 | // Append config to one struct 75 | m.configs[file] = cfg 76 | 77 | if cfg.Main != nil { 78 | app = cfg.Main 79 | } 80 | } 81 | 82 | m.main = app 83 | 84 | if err := config.Validate(pkgs); err != nil { 85 | return errors.Wrap(err, "failed to validate packages") 86 | } 87 | 88 | pkgs, err = config.Sort(pkgs) 89 | if err != nil { 90 | return errors.Wrap(err, "failed to resolve dependencies between packages") 91 | } 92 | 93 | m.packages = pkgs 94 | 95 | m.env = env.New(cache) 96 | m.env.Add(env.Variables{ 97 | "AFX_CONFIG_PATH": env.Variable{Value: cfgRoot}, 98 | "AFX_LOG": env.Variable{}, 99 | "AFX_LOG_PATH": env.Variable{}, 100 | "AFX_COMMAND_PATH": env.Variable{Default: filepath.Join(os.Getenv("HOME"), "bin")}, 101 | "AFX_SHELL": env.Variable{Default: m.main.Shell}, 102 | "AFX_SUDO_PASSWORD": env.Variable{ 103 | Input: env.Input{ 104 | When: config.HasSudoInCommandBuildSteps(m.packages), 105 | Message: "Please enter sudo command password", 106 | Help: "Some packages build steps requires sudo command", 107 | }, 108 | }, 109 | "GITHUB_TOKEN": env.Variable{ 110 | Input: env.Input{ 111 | When: config.HasGitHubReleaseBlock(m.packages), 112 | Message: "Please type your GITHUB_TOKEN", 113 | Help: "To fetch GitHub Releases, GitHub token is required", 114 | }, 115 | }, 116 | "AFX_NO_UPDATE_NOTIFIER": env.Variable{}, 117 | }) 118 | 119 | for k, v := range m.main.Env { 120 | log.Printf("[DEBUG] main: set env: %s=%s", k, v) 121 | os.Setenv(k, v) 122 | } 123 | 124 | log.Printf("[DEBUG] mkdir %s\n", root) 125 | os.MkdirAll(root, os.ModePerm) 126 | 127 | log.Printf("[DEBUG] mkdir %s\n", os.Getenv("AFX_COMMAND_PATH")) 128 | os.MkdirAll(os.Getenv("AFX_COMMAND_PATH"), os.ModePerm) 129 | 130 | resourcers := make([]state.Resourcer, len(m.packages)) 131 | for i, pkg := range m.packages { 132 | resourcers[i] = pkg 133 | } 134 | 135 | s, err := state.Open(filepath.Join(root, "state.json"), resourcers) 136 | if err != nil { 137 | return errors.Wrap(err, "failed to open state file") 138 | } 139 | m.state = s 140 | 141 | log.Printf("[INFO] state additions: (%d) %#v", len(s.Additions), state.Keys(s.Additions)) 142 | log.Printf("[INFO] state deletions: (%d) %#v", len(s.Deletions), state.Keys(s.Deletions)) 143 | log.Printf("[INFO] state changes: (%d) %#v", len(s.Changes), state.Keys(s.Changes)) 144 | log.Printf("[INFO] state unchanges: (%d) []string{...skip...}", len(s.NoChanges)) 145 | 146 | return nil 147 | } 148 | 149 | func printForUpdate(uriCh chan *update.ReleaseInfo) { 150 | switch Version { 151 | case "unset": 152 | return 153 | } 154 | log.Printf("[DEBUG] checking updates on afx repo...") 155 | newRelease := <-uriCh 156 | if newRelease != nil { 157 | fmt.Fprintf(os.Stdout, "\n\n%s %s -> %s\n", 158 | color.YellowString("A new release of afx is available:"), 159 | color.CyanString("v"+Version), 160 | color.CyanString(newRelease.Version)) 161 | fmt.Fprintf(os.Stdout, "%s\n\n", color.YellowString(newRelease.URL)) 162 | fmt.Fprintf(os.Stdout, "To upgrade, run: afx self-update\n") 163 | } 164 | } 165 | 166 | func (m *metaCmd) printForUpdate() error { 167 | if m.updateMessageChan == nil { 168 | return errors.New("update message chan is not set") 169 | } 170 | printForUpdate(m.updateMessageChan) 171 | return nil 172 | } 173 | 174 | func (m *metaCmd) prompt() (config.Package, error) { 175 | if m.main.FilterCmd == "" { 176 | return nil, errors.New("filter_command is not set") 177 | } 178 | 179 | var stdin, stdout bytes.Buffer 180 | 181 | p := shellwords.NewParser() 182 | p.ParseEnv = true 183 | p.ParseBacktick = true 184 | 185 | args, err := p.Parse(m.main.FilterCmd) 186 | if err != nil { 187 | return nil, errors.New("failed to parse filter command in main config") 188 | } 189 | 190 | cmd := shell.Shell{ 191 | Stdin: &stdin, 192 | Stdout: &stdout, 193 | Stderr: os.Stderr, 194 | Command: args[0], 195 | Args: args[1:], 196 | } 197 | 198 | for _, pkg := range m.packages { 199 | fmt.Fprintln(&stdin, pkg.GetName()) 200 | } 201 | 202 | if err := cmd.Run(context.Background()); err != nil { 203 | return nil, err 204 | } 205 | 206 | search := func(name string) config.Package { 207 | for _, pkg := range m.packages { 208 | if pkg.GetName() == name { 209 | return pkg 210 | } 211 | } 212 | return nil 213 | } 214 | 215 | for _, line := range strings.Split(stdout.String(), "\n") { 216 | if pkg := search(line); pkg != nil { 217 | return pkg, nil 218 | } 219 | } 220 | 221 | return nil, errors.New("pkg not found") 222 | } 223 | 224 | func (m *metaCmd) askRunCommand(op interface{}, pkgs []string) (bool, error) { 225 | var do string 226 | switch op.(type) { 227 | case installCmd: 228 | do = "install" 229 | case uninstallCmd: 230 | do = "uninstall" 231 | case updateCmd: 232 | do = "update" 233 | case checkCmd: 234 | do = "check" 235 | default: 236 | return false, errors.New("unsupported command type") 237 | } 238 | 239 | length := 3 240 | target := strings.Join(pkgs, ", ") 241 | if len(pkgs) > length { 242 | target = fmt.Sprintf("%s, ... (%d packages)", strings.Join(pkgs[:length], ", "), len(pkgs)) 243 | } 244 | 245 | yes := false 246 | confirm := survey.Confirm{ 247 | Message: fmt.Sprintf("OK to %s these packages? %s", do, color.YellowString(target)), 248 | } 249 | 250 | if len(pkgs) > length { 251 | helpMessage := "\n" 252 | sort.Strings(pkgs) 253 | for _, pkg := range pkgs { 254 | helpMessage += fmt.Sprintf("- %s\n", pkg) 255 | } 256 | confirm.Help = helpMessage 257 | } 258 | 259 | if err := survey.AskOne(&confirm, &yes); err != nil { 260 | return false, errors.Wrap(err, "failed to get input from console") 261 | } 262 | return yes, nil 263 | } 264 | 265 | func shouldCheckForUpdate() bool { 266 | if os.Getenv("AFX_NO_UPDATE_NOTIFIER") != "" { 267 | return false 268 | } 269 | return !isCI() && printers.IsTerminal(os.Stdout) && printers.IsTerminal(os.Stderr) 270 | } 271 | 272 | // based on https://github.com/watson/ci-info/blob/HEAD/index.js 273 | func isCI() bool { 274 | return os.Getenv("CI") != "" || // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari 275 | os.Getenv("BUILD_NUMBER") != "" || // Jenkins, TeamCity 276 | os.Getenv("RUN_ID") != "" // TaskCluster, dsari 277 | } 278 | 279 | func checkForUpdate(currentVersion string) (*update.ReleaseInfo, error) { 280 | if !shouldCheckForUpdate() { 281 | return nil, nil 282 | } 283 | client := github.NewClient() 284 | stateFilePath := filepath.Join(os.Getenv("HOME"), ".afx", "version.json") 285 | return update.CheckForUpdate(client, stateFilePath, Repository, Version) 286 | } 287 | 288 | func (m metaCmd) GetPackage(resource state.Resource) config.Package { 289 | for _, pkg := range m.packages { 290 | if pkg.GetName() == resource.Name { 291 | return pkg 292 | } 293 | } 294 | return nil 295 | } 296 | 297 | func (m metaCmd) GetPackages(resources []state.Resource) []config.Package { 298 | var pkgs []config.Package 299 | for _, resource := range resources { 300 | pkgs = append(pkgs, m.GetPackage(resource)) 301 | } 302 | return pkgs 303 | } 304 | 305 | func (m metaCmd) GetConfig() config.Config { 306 | var all config.Config 307 | for _, config := range m.configs { 308 | if config.Main != nil { 309 | all.Main = config.Main 310 | } 311 | all.GitHub = append(all.GitHub, config.GitHub...) 312 | all.Gist = append(all.Gist, config.Gist...) 313 | all.HTTP = append(all.HTTP, config.HTTP...) 314 | all.Local = append(all.Local, config.Local...) 315 | } 316 | return all 317 | } 318 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "runtime" 8 | 9 | "github.com/babarot/afx/pkg/errors" 10 | "github.com/babarot/afx/pkg/helpers/templates" 11 | "github.com/babarot/afx/pkg/logging" 12 | "github.com/babarot/afx/pkg/update" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | var Repository string = "babarot/afx" 17 | 18 | var ( 19 | rootLong = templates.LongDesc(`Package manager for CLI`) 20 | ) 21 | 22 | var ( 23 | // Version is the version number 24 | Version = "unset" 25 | 26 | // BuildTag set during build to git tag, if any 27 | BuildTag = "unset" 28 | 29 | // BuildSHA is the git sha set during build 30 | BuildSHA = "unset" 31 | ) 32 | 33 | // newRootCmd returns the root command 34 | func newRootCmd(m metaCmd) *cobra.Command { 35 | rootCmd := &cobra.Command{ 36 | Use: "afx", 37 | Short: "Package manager for CLI", 38 | Long: rootLong, 39 | SilenceErrors: true, 40 | DisableSuggestions: false, 41 | Version: fmt.Sprintf("%s (%s/%s)", Version, BuildTag, BuildSHA), 42 | PreRun: func(cmd *cobra.Command, args []string) { 43 | uriCh := make(chan *update.ReleaseInfo) 44 | go func() { 45 | log.Printf("[DEBUG] (goroutine): checking new updates...") 46 | release, err := checkForUpdate(Version) 47 | if err != nil { 48 | log.Printf("[ERROR] (goroutine): cannot check for new updates: %s", err) 49 | } 50 | uriCh <- release 51 | }() 52 | 53 | if cmd.Runnable() { 54 | cmd.Help() 55 | } 56 | 57 | printForUpdate(uriCh) 58 | }, 59 | RunE: func(cmd *cobra.Command, args []string) error { 60 | // Just define this function to prevent c.Runnable() from becoming false. 61 | // if c.Runnable() is true, just c.Help() is called and then stopped. 62 | return nil 63 | }, 64 | } 65 | 66 | rootCmd.AddCommand( 67 | m.newInitCmd(), 68 | m.newInstallCmd(), 69 | m.newUninstallCmd(), 70 | m.newUpdateCmd(), 71 | m.newCheckCmd(), 72 | m.newSelfUpdateCmd(), 73 | m.newShowCmd(), 74 | m.newCompletionCmd(), 75 | m.newStateCmd(), 76 | ) 77 | 78 | return rootCmd 79 | } 80 | 81 | func Execute() error { 82 | logWriter, err := logging.LogOutput() 83 | if err != nil { 84 | return errors.Wrap(err, "%s: failed to set logger") 85 | } 86 | log.SetOutput(logWriter) 87 | 88 | log.Printf("[INFO] afx version: %s", Version) 89 | log.Printf("[INFO] Go runtime version: %s", runtime.Version()) 90 | log.Printf("[INFO] Build tag/SHA: %s/%s", BuildTag, BuildSHA) 91 | log.Printf("[INFO] CLI args: %#v", os.Args) 92 | 93 | meta := metaCmd{} 94 | if err := meta.init(); err != nil { 95 | return errors.Wrap(err, "failed to initialize afx") 96 | } 97 | 98 | defer log.Printf("[INFO] root command execution finished") 99 | return newRootCmd(meta).Execute() 100 | } 101 | -------------------------------------------------------------------------------- /cmd/self-update.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "runtime" 9 | 10 | "github.com/AlecAivazis/survey/v2" 11 | "github.com/babarot/afx/pkg/errors" 12 | "github.com/babarot/afx/pkg/github" 13 | "github.com/babarot/afx/pkg/helpers/templates" 14 | "github.com/creativeprojects/go-selfupdate" 15 | "github.com/fatih/color" 16 | "github.com/spf13/cobra" 17 | ) 18 | 19 | type selfUpdateCmd struct { 20 | metaCmd 21 | 22 | opt selfUpdateOpt 23 | 24 | annotation map[string]string 25 | } 26 | 27 | type selfUpdateOpt struct { 28 | tag bool 29 | } 30 | 31 | var ( 32 | // selfUpdateLong is long description of self-update command 33 | selfUpdateLong = templates.LongDesc(` 34 | self-update requires afx is pre-compiled one to upgrade. 35 | 36 | Those who built afx by go install etc cannot use this feature. 37 | (afx --version returns unset/unset) 38 | `) 39 | 40 | // selfUpdateExample is examples for selfUpdate command 41 | selfUpdateExample = templates.Examples(` 42 | # upgrade afx to latest version 43 | $ afx self-update 44 | `) 45 | ) 46 | 47 | // newSelfUpdateCmd creates a new selfUpdate command 48 | func (m metaCmd) newSelfUpdateCmd() *cobra.Command { 49 | info := color.New(color.FgGreen).SprintFunc() 50 | c := &selfUpdateCmd{ 51 | metaCmd: m, 52 | annotation: map[string]string{ 53 | "0.1.11": info(`Run "afx state refresh --force" at first!`), 54 | }, 55 | } 56 | 57 | selfUpdateCmd := &cobra.Command{ 58 | Use: "self-update", 59 | Short: "Update afx itself to latest version", 60 | Long: selfUpdateLong, 61 | Example: selfUpdateExample, 62 | DisableFlagsInUseLine: true, 63 | SilenceUsage: true, 64 | SilenceErrors: true, 65 | Args: cobra.MaximumNArgs(0), 66 | RunE: func(cmd *cobra.Command, args []string) error { 67 | m.env.AskWhen(map[string]bool{ 68 | "GITHUB_TOKEN": true, 69 | }) 70 | return c.run(args) 71 | }, 72 | } 73 | 74 | return selfUpdateCmd 75 | } 76 | 77 | func (c *selfUpdateCmd) run(args []string) error { 78 | switch Version { 79 | case "unset": 80 | fmt.Fprintf(os.Stderr, "%s\n\n %s\n %s\n\n", 81 | "Failed to update to new version!", 82 | "You need to get precompiled version from GitHub releases.", 83 | fmt.Sprintf("This version (%s/%s) is compiled from source code.", 84 | Version, runtime.Version()), 85 | ) 86 | return errors.New("failed to run self-update") 87 | } 88 | 89 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 90 | defer stop() 91 | 92 | latest, found, err := selfupdate.DetectLatest(ctx, selfupdate.ParseSlug(Repository)) 93 | if err != nil { 94 | return errors.Wrap(err, "error occurred while detecting version") 95 | } 96 | 97 | if !found { 98 | return fmt.Errorf("latest version for %s/%s could not be found from GitHub repository", 99 | runtime.GOOS, runtime.GOARCH) 100 | } 101 | 102 | if latest.LessOrEqual(Version) { 103 | fmt.Printf("Current version (%s) is the latest\n", Version) 104 | return nil 105 | } 106 | 107 | yes := false 108 | if err := survey.AskOne(&survey.Confirm{ 109 | Message: fmt.Sprintf("Do you update to %s? (current version: %s)", 110 | latest.Version(), Version), 111 | }, &yes); err != nil { 112 | return errors.Wrap(err, "cannot get answer from console") 113 | } 114 | if !yes { 115 | return nil 116 | } 117 | 118 | release, err := github.NewRelease(ctx, "babarot", "afx", "v"+latest.Version(), github.WithVerbose()) 119 | if err != nil { 120 | return err 121 | } 122 | 123 | asset, err := release.Download(ctx) 124 | if err != nil { 125 | return err 126 | } 127 | 128 | if err := release.Unarchive(asset); err != nil { 129 | return err 130 | } 131 | 132 | exe, err := os.Executable() 133 | if err != nil { 134 | return errors.New("could not locate executable path") 135 | } 136 | 137 | if err := release.Install(exe); err != nil { 138 | return err 139 | } 140 | 141 | color.New(color.FgWhite).Printf("Successfully updated to version %s\n", latest.Version()) 142 | return nil 143 | } 144 | -------------------------------------------------------------------------------- /cmd/show.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "sort" 7 | "strings" 8 | 9 | "github.com/babarot/afx/pkg/helpers/templates" 10 | "github.com/babarot/afx/pkg/printers" 11 | "github.com/babarot/afx/pkg/state" 12 | "github.com/goccy/go-yaml" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | type showCmd struct { 17 | metaCmd 18 | 19 | opt showOpt 20 | } 21 | 22 | type showOpt struct { 23 | output string 24 | } 25 | 26 | var ( 27 | // showLong is long description of show command 28 | showLong = templates.LongDesc(``) 29 | 30 | // showExample is examples for show command 31 | showExample = templates.Examples(` 32 | $ afx show 33 | $ afx show -o json | jq .github 34 | `) 35 | ) 36 | 37 | // newShowCmd creates a new show command 38 | func (m metaCmd) newShowCmd() *cobra.Command { 39 | c := &showCmd{metaCmd: m} 40 | 41 | showCmd := &cobra.Command{ 42 | Use: "show", 43 | Short: "Show packages managed by afx", 44 | Long: showLong, 45 | Example: showExample, 46 | DisableFlagsInUseLine: true, 47 | SilenceUsage: true, 48 | SilenceErrors: true, 49 | ValidArgs: state.Keys(m.state.NoChanges), 50 | Args: cobra.MinimumNArgs(0), 51 | RunE: func(cmd *cobra.Command, args []string) error { 52 | cfg := m.GetConfig() 53 | if len(args) > 0 { 54 | cfg = cfg.Contains(args...) 55 | } 56 | b, err := yaml.Marshal(cfg) 57 | if err != nil { 58 | return err 59 | } 60 | switch c.opt.output { 61 | case "default": 62 | return c.run(args) 63 | case "json": 64 | b, err := yaml.YAMLToJSON(b) 65 | if err != nil { 66 | return err 67 | } 68 | fmt.Println(string(b)) 69 | case "yaml": 70 | fmt.Println(string(b)) 71 | case "path": 72 | for _, pkg := range c.GetPackages(c.state.NoChanges) { 73 | fmt.Println(pkg.GetHome()) 74 | } 75 | case "name": 76 | for _, pkg := range c.GetPackages(c.state.NoChanges) { 77 | fmt.Println(pkg.GetName()) 78 | } 79 | default: 80 | return fmt.Errorf("%s: not supported output style", c.opt.output) 81 | } 82 | return nil 83 | }, 84 | } 85 | 86 | flag := showCmd.Flags() 87 | flag.StringVarP(&c.opt.output, "output", "o", "default", "Output style [default,json,yaml,path,name]") 88 | 89 | showCmd.RegisterFlagCompletionFunc("output", 90 | func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 91 | out := []string{"default", "json", "yaml", "path", "name"} 92 | return out, cobra.ShellCompDirectiveNoFileComp 93 | }) 94 | 95 | return showCmd 96 | } 97 | 98 | func (c *showCmd) run(args []string) error { 99 | w := printers.GetNewTabWriter(os.Stdout) 100 | headers := []string{"NAME", "TYPE", "STATUS"} 101 | 102 | type Item struct { 103 | Name string 104 | Type string 105 | Status string 106 | } 107 | type Items []Item 108 | 109 | filter := func(items []Item, input string) []Item { 110 | var tmp []Item 111 | for _, item := range items { 112 | if strings.Contains(item.Name, input) { 113 | tmp = append(tmp, item) 114 | } 115 | } 116 | return tmp 117 | } 118 | 119 | var items []Item 120 | for _, pkg := range c.state.Additions { 121 | items = append(items, Item{ 122 | Name: pkg.Name, 123 | Type: pkg.Type, 124 | Status: "WaitingInstall", 125 | }) 126 | } 127 | for _, pkg := range c.state.Changes { 128 | items = append(items, Item{ 129 | Name: pkg.Name, 130 | Type: pkg.Type, 131 | Status: "WaitingUpdate", 132 | }) 133 | } 134 | for _, pkg := range c.state.Deletions { 135 | items = append(items, Item{ 136 | Name: pkg.Name, 137 | Type: pkg.Type, 138 | Status: "WaitingUninstall", 139 | }) 140 | } 141 | for _, pkg := range c.state.NoChanges { 142 | items = append(items, Item{ 143 | Name: pkg.Name, 144 | Type: pkg.Type, 145 | Status: "Installed", 146 | }) 147 | } 148 | 149 | if len(args) > 0 { 150 | var tmp []Item 151 | for _, arg := range args { 152 | tmp = append(tmp, filter(items, arg)...) 153 | } 154 | items = tmp 155 | } 156 | 157 | sort.Slice(items, func(i, j int) bool { 158 | return items[i].Name < items[j].Name 159 | }) 160 | 161 | fmt.Fprintf(w, strings.Join(headers, "\t")+"\n") 162 | for _, item := range items { 163 | fields := []string{ 164 | item.Name, item.Type, item.Status, 165 | } 166 | fmt.Fprintf(w, strings.Join(fields, "\t")+"\n") 167 | } 168 | 169 | return w.Flush() 170 | } 171 | -------------------------------------------------------------------------------- /cmd/state.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/AlecAivazis/survey/v2" 7 | "github.com/babarot/afx/pkg/errors" 8 | "github.com/babarot/afx/pkg/helpers/templates" 9 | "github.com/babarot/afx/pkg/state" 10 | "github.com/fatih/color" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | type stateCmd struct { 15 | metaCmd 16 | 17 | opt stateOpt 18 | } 19 | 20 | type stateOpt struct { 21 | force bool 22 | } 23 | 24 | var ( 25 | // stateLong is long description of state command 26 | stateLong = templates.LongDesc(``) 27 | 28 | // stateExample is examples for state command 29 | stateExample = templates.Examples(``) 30 | ) 31 | 32 | // newStateCmd creates a new state command 33 | func (m metaCmd) newStateCmd() *cobra.Command { 34 | c := &stateCmd{metaCmd: m} 35 | 36 | stateCmd := &cobra.Command{ 37 | Use: "state [list|refresh|remove]", 38 | Short: "Advanced state management", 39 | Long: stateLong, 40 | Example: stateExample, 41 | DisableFlagsInUseLine: true, 42 | SilenceUsage: true, 43 | SilenceErrors: true, 44 | Args: cobra.MaximumNArgs(1), 45 | Hidden: true, 46 | } 47 | 48 | stateCmd.AddCommand( 49 | c.newStateListCmd(), 50 | c.newStateRefreshCmd(), 51 | c.newStateRemoveCmd(), 52 | ) 53 | 54 | return stateCmd 55 | } 56 | 57 | func (c stateCmd) newStateListCmd() *cobra.Command { 58 | return &cobra.Command{ 59 | Use: "list", 60 | Short: "List your state items", 61 | DisableFlagsInUseLine: true, 62 | SilenceUsage: true, 63 | SilenceErrors: true, 64 | Args: cobra.ExactArgs(0), 65 | RunE: func(cmd *cobra.Command, args []string) error { 66 | resources, err := c.state.List() 67 | if err != nil { 68 | return err 69 | } 70 | for _, resource := range resources { 71 | fmt.Println(resource.Name) 72 | } 73 | return nil 74 | }, 75 | } 76 | } 77 | 78 | func (c stateCmd) newStateRefreshCmd() *cobra.Command { 79 | cmd := &cobra.Command{ 80 | Use: "refresh", 81 | Short: "Refresh your state file", 82 | DisableFlagsInUseLine: true, 83 | SilenceUsage: true, 84 | SilenceErrors: true, 85 | Args: cobra.ExactArgs(0), 86 | RunE: func(cmd *cobra.Command, args []string) error { 87 | if c.opt.force { 88 | return c.state.New() 89 | } 90 | if err := c.state.Refresh(); err != nil { 91 | return errors.Wrap(err, "failed to refresh state") 92 | } 93 | fmt.Println(color.WhiteString("Successfully refreshed")) 94 | return nil 95 | }, 96 | } 97 | cmd.Flags().BoolVarP(&c.opt.force, "force", "", false, "force update") 98 | return cmd 99 | } 100 | 101 | func (c stateCmd) newStateRemoveCmd() *cobra.Command { 102 | return &cobra.Command{ 103 | Use: "remove", 104 | Short: "Remove selected packages from state file", 105 | DisableFlagsInUseLine: true, 106 | SilenceUsage: true, 107 | SilenceErrors: true, 108 | Aliases: []string{"rm"}, 109 | Args: cobra.MinimumNArgs(0), 110 | ValidArgs: state.Keys(c.state.NoChanges), 111 | RunE: func(cmd *cobra.Command, args []string) error { 112 | var resources []state.Resource 113 | switch len(cmd.Flags().Args()) { 114 | case 0: 115 | rs, err := c.state.List() 116 | if err != nil { 117 | return errors.Wrap(err, "failed to list state items") 118 | } 119 | var items []string 120 | for _, r := range rs { 121 | items = append(items, r.Name) 122 | } 123 | var selected string 124 | if err := survey.AskOne(&survey.Select{ 125 | Message: "Choose a package:", 126 | Options: items, 127 | }, &selected); err != nil { 128 | return errors.Wrap(err, "failed to get input from console") 129 | } 130 | resource, err := c.state.Get(selected) 131 | if err != nil { 132 | return errors.Wrapf(err, "%s: failed to get state file", selected) 133 | } 134 | resources = append(resources, resource) 135 | default: 136 | for _, arg := range cmd.Flags().Args() { 137 | resource, err := c.state.Get(arg) 138 | if err != nil { 139 | return errors.Wrapf(err, "%s: failed to get state file", arg) 140 | } 141 | resources = append(resources, resource) 142 | } 143 | } 144 | for _, resource := range resources { 145 | c.state.Remove(resource) 146 | } 147 | return nil 148 | }, 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /cmd/uninstall.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/babarot/afx/pkg/errors" 8 | "github.com/babarot/afx/pkg/helpers/templates" 9 | "github.com/babarot/afx/pkg/state" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | type uninstallCmd struct { 14 | metaCmd 15 | } 16 | 17 | var ( 18 | // uninstallLong is long description of uninstall command 19 | uninstallLong = templates.LongDesc(``) 20 | 21 | // uninstallExample is examples for uninstall command 22 | uninstallExample = templates.Examples(` 23 | afx uninstall [args...] 24 | 25 | By default, it tries to uninstall all packages deleted from config file. 26 | If any args are given, it tries to uninstall only them. 27 | But it's needed also to be deleted from config file. 28 | `) 29 | ) 30 | 31 | // newUninstallCmd creates a new uninstall command 32 | func (m metaCmd) newUninstallCmd() *cobra.Command { 33 | c := &uninstallCmd{metaCmd: m} 34 | 35 | uninstallCmd := &cobra.Command{ 36 | Use: "uninstall", 37 | Short: "Uninstall installed packages", 38 | Long: uninstallLong, 39 | Example: uninstallExample, 40 | Aliases: []string{"rm", "un"}, 41 | SuggestFor: []string{"delete"}, 42 | DisableFlagsInUseLine: true, 43 | SilenceUsage: true, 44 | SilenceErrors: true, 45 | Args: cobra.MinimumNArgs(0), 46 | ValidArgs: state.Keys(m.state.Deletions), 47 | RunE: func(cmd *cobra.Command, args []string) error { 48 | resources := m.state.Deletions 49 | if len(resources) == 0 { 50 | fmt.Println("No packages to uninstall") 51 | return nil 52 | } 53 | 54 | var tmp []state.Resource 55 | for _, arg := range args { 56 | resource, ok := state.Map(resources)[arg] 57 | if !ok { 58 | return fmt.Errorf("%s: no such package to be uninstalled", arg) 59 | } 60 | tmp = append(tmp, resource) 61 | } 62 | if len(tmp) > 0 { 63 | resources = tmp 64 | } 65 | 66 | yes, _ := m.askRunCommand(*c, state.Keys(resources)) 67 | if !yes { 68 | fmt.Println("Cancelled") 69 | return nil 70 | } 71 | 72 | return c.run(resources) 73 | }, 74 | PostRunE: func(cmd *cobra.Command, args []string) error { 75 | return m.printForUpdate() 76 | }, 77 | } 78 | 79 | return uninstallCmd 80 | } 81 | 82 | func (c *uninstallCmd) run(resources []state.Resource) error { 83 | var errs errors.Errors 84 | 85 | delete := func(paths ...string) error { 86 | var errs errors.Errors 87 | for _, path := range paths { 88 | errs.Append(os.RemoveAll(path)) 89 | } 90 | return errs.ErrorOrNil() 91 | } 92 | 93 | for _, resource := range resources { 94 | err := delete(append(resource.Paths, resource.Home)...) 95 | if err != nil { 96 | errs.Append(err) 97 | continue 98 | } 99 | c.state.Remove(resource) 100 | fmt.Printf("deleted %s\n", resource.Home) 101 | } 102 | 103 | return errs.ErrorOrNil() 104 | } 105 | -------------------------------------------------------------------------------- /cmd/update.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/signal" 9 | 10 | "github.com/babarot/afx/pkg/config" 11 | "github.com/babarot/afx/pkg/errors" 12 | "github.com/babarot/afx/pkg/helpers/templates" 13 | "github.com/babarot/afx/pkg/state" 14 | "github.com/spf13/cobra" 15 | "golang.org/x/sync/errgroup" 16 | ) 17 | 18 | type updateCmd struct { 19 | metaCmd 20 | } 21 | 22 | var ( 23 | // updateLong is long description of fmt command 24 | updateLong = templates.LongDesc(``) 25 | 26 | // updateExample is examples for fmt command 27 | updateExample = templates.Examples(` 28 | afx update [args...] 29 | 30 | By default, it tries to update packages only if something are 31 | changed in config file. 32 | If any args are given, it tries to update only them. 33 | `) 34 | ) 35 | 36 | // newUpdateCmd creates a new fmt command 37 | func (m metaCmd) newUpdateCmd() *cobra.Command { 38 | c := &updateCmd{metaCmd: m} 39 | 40 | updateCmd := &cobra.Command{ 41 | Use: "update", 42 | Short: "Update installed package if version etc is changed", 43 | Long: updateLong, 44 | Example: updateExample, 45 | Aliases: []string{"u"}, 46 | DisableFlagsInUseLine: true, 47 | SilenceUsage: true, 48 | SilenceErrors: true, 49 | Args: cobra.MinimumNArgs(0), 50 | ValidArgs: state.Keys(m.state.Changes), 51 | RunE: func(cmd *cobra.Command, args []string) error { 52 | resources := m.state.Changes 53 | if len(resources) == 0 { 54 | fmt.Println("No packages to update") 55 | return nil 56 | } 57 | 58 | var tmp []state.Resource 59 | for _, arg := range args { 60 | resource, ok := state.Map(resources)[arg] 61 | if !ok { 62 | return fmt.Errorf("%s: no such package in config", arg) 63 | } 64 | tmp = append(tmp, resource) 65 | } 66 | if len(tmp) > 0 { 67 | resources = tmp 68 | } 69 | 70 | yes, _ := m.askRunCommand(*c, state.Keys(resources)) 71 | if !yes { 72 | fmt.Println("Cancelled") 73 | return nil 74 | } 75 | 76 | pkgs := m.GetPackages(resources) 77 | m.env.AskWhen(map[string]bool{ 78 | "GITHUB_TOKEN": config.HasGitHubReleaseBlock(pkgs), 79 | "AFX_SUDO_PASSWORD": config.HasSudoInCommandBuildSteps(pkgs), 80 | }) 81 | 82 | return c.run(pkgs) 83 | }, 84 | PostRunE: func(cmd *cobra.Command, args []string) error { 85 | return m.printForUpdate() 86 | }, 87 | } 88 | 89 | return updateCmd 90 | } 91 | 92 | type updateResult struct { 93 | Package config.Package 94 | Error error 95 | } 96 | 97 | func (c *updateCmd) run(pkgs []config.Package) error { 98 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 99 | defer stop() 100 | 101 | progress := config.NewProgress(pkgs) 102 | completion := make(chan config.Status) 103 | limit := make(chan struct{}, 16) 104 | results := make(chan updateResult) 105 | 106 | go func() { 107 | progress.Print(completion) 108 | }() 109 | 110 | log.Printf("[DEBUG] (update): start to run each pkg.Install()") 111 | eg := errgroup.Group{} 112 | for _, pkg := range pkgs { 113 | pkg := pkg 114 | eg.Go(func() error { 115 | limit <- struct{}{} 116 | defer func() { <-limit }() 117 | os.RemoveAll(pkg.GetHome()) // delete before updating 118 | err := pkg.Install(ctx, completion) 119 | switch err { 120 | case nil: 121 | c.state.Update(pkg) 122 | default: 123 | log.Printf("[DEBUG] uninstall %q because updating failed", pkg.GetName()) 124 | pkg.Uninstall(ctx) 125 | } 126 | select { 127 | case results <- updateResult{Package: pkg, Error: err}: 128 | return nil 129 | case <-ctx.Done(): 130 | return errors.Wrapf(ctx.Err(), "%s: cancelled updating", pkg.GetName()) 131 | } 132 | }) 133 | } 134 | 135 | go func() { 136 | eg.Wait() 137 | close(results) 138 | }() 139 | 140 | var exit errors.Errors 141 | for result := range results { 142 | exit.Append(result.Error) 143 | } 144 | if err := eg.Wait(); err != nil { 145 | log.Printf("[ERROR] failed to update: %s", err) 146 | exit.Append(err) 147 | } 148 | 149 | defer func(err error) { 150 | if err != nil { 151 | c.env.Refresh() 152 | } 153 | }(exit.ErrorOrNil()) 154 | 155 | return exit.ErrorOrNil() 156 | } 157 | -------------------------------------------------------------------------------- /docs/configuration/package/gist.md: -------------------------------------------------------------------------------- 1 | # Gist 2 | 3 | Gist type allows you to manage [Gist](https://gist.github.com/) pages as a plugin or command. 4 | 5 | ```yaml 6 | gist: 7 | - name: context-scripts 8 | description: Get current GCP/Kubernetes context which you are on. 9 | owner: babarot 10 | id: bb820b99fdba605ea4bd4fb29046ce58 11 | command: 12 | link: 13 | - from: gcp-context 14 | - from: kube-context 15 | ``` 16 | 17 | ## Parameters 18 | 19 | ### name 20 | 21 | Type | Default 22 | ---|--- 23 | string | (required) 24 | 25 | Package name. Name it as you like. In Gist, there's a case that several files are attached in one Gist. So may be better to name considering it. 26 | 27 | ### description 28 | 29 | Type | Default 30 | ---|--- 31 | string | `""` 32 | 33 | Package description. 34 | 35 | ### owner 36 | 37 | Type | Default 38 | ---|--- 39 | string | (required) 40 | 41 | Gist owner. 42 | 43 | ### id 44 | 45 | Type | Default 46 | ---|--- 47 | string | (required) 48 | 49 | Gist page id. 50 | 51 | ### depends-on 52 | 53 | See [GitHub#depends-on](github.md#depends-on) page. Same as that. 54 | 55 | ### command 56 | 57 | See [Command](../command.md) page 58 | 59 | ### plugin 60 | 61 | See [Plugin](../plugin.md) page 62 | -------------------------------------------------------------------------------- /docs/configuration/package/github.md: -------------------------------------------------------------------------------- 1 | # GitHub 2 | 3 | GitHub type allows you to get GitHub repository or GitHub Release. To get releases, you need to specify `release` field. 4 | 5 | In GitHub type, there are mainly two type of package style. One is a "repository" and the other is "release". In afx configuration, a `release` field is optional so basically all of GitHub packages are regard as "repository". It's a same reason why actual GitHub Release pages exists on its GitHub Repository. But if `release` field is specified, in afx, it's regard as also "release". 6 | 7 | It may be good to think about whether to configure `release` field depending on where you install it from. 8 | 9 | === "Repository" 10 | ```yaml 11 | github: 12 | - name: ahmetb/kubectx 13 | description: Switch faster between clusters and namespaces in kubectl 14 | owner: ahmetb 15 | repo: kubectx 16 | command: 17 | link: 18 | - from: kubectx 19 | to: kubectl-ctx 20 | - from: kubens 21 | to: kubectl-ns 22 | ``` 23 | 24 | === "Release" 25 | ```yaml 26 | github: 27 | - name: stedolan/jq 28 | description: Command-line JSON processor 29 | owner: stedolan 30 | repo: jq 31 | release: 32 | name: jq 33 | tag: jq-1.6 34 | command: 35 | link: 36 | - from: '*jq*' 37 | to: jq 38 | ``` 39 | 40 | ## Parameters 41 | 42 | ### name 43 | 44 | Type | Default 45 | ---|--- 46 | string | (required) 47 | 48 | Package name. Basically you can name it as you like. However, in GitHub package, "owner/repo" style may be suitable. 49 | 50 | ### description 51 | 52 | Type | Default 53 | ---|--- 54 | string | `""` 55 | 56 | Package description. 57 | 58 | ### owner 59 | 60 | Type | Default 61 | ---|--- 62 | string | (required) 63 | 64 | Repository owner. 65 | 66 | ### repo 67 | 68 | Type | Default 69 | ---|--- 70 | string | (required) 71 | 72 | Repository name. 73 | 74 | ### branch 75 | 76 | Type | Default 77 | ---|--- 78 | string | `master` 79 | 80 | Remote branch name. 81 | 82 | ### with.depth 83 | 84 | Type | Default 85 | ---|--- 86 | number | `0` (all commits) 87 | 88 | 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. 89 | 90 | ### as 91 | 92 | Key | Type | Default 93 | ---|---|--- 94 | gh-extension | Object | `null` 95 | 96 | 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. 97 | 98 | === "gh-extension" 99 | 100 | 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. 101 | 102 | Key | Type | Default 103 | ---|---|--- 104 | name | string | (required) 105 | tag | string | `latest` 106 | rename-to | string | `""` 107 | 108 | ```yaml 109 | - name: yusukebe/gh-markdown-preview 110 | description: GitHub CLI extension to preview Markdown looks like GitHub. 111 | owner: yusukebe 112 | repo: gh-markdown-preview 113 | as: 114 | gh-extension: 115 | name: gh-markdown-preview 116 | tag: v1.4.0 117 | rename-to: gh-md # markdown-preview is long so rename it to shorten. 118 | ``` 119 | 120 | ### release.name 121 | 122 | Type | Default 123 | ---|--- 124 | string | `""` 125 | 126 | Allows you to specify a package name managed in GitHub Release. You can find this by visiting release page of packages you want to install. 127 | 128 | ### relase.tag 129 | 130 | Type | Default 131 | ---|--- 132 | string | `""` 133 | 134 | Allows you to specify a tag version of GitHub Release. You can find this by visiting release page of packages you want to install. 135 | 136 | ### release.asset.filename 137 | 138 | Type | Default 139 | ---|--- 140 | string | `""` 141 | 142 | Allows you to specify a filename of GitHub Release asset you want to install. Moreover, this field in afx config file supports templating. 143 | 144 | !!! tip "(Basically) NO NEED TO SET THIS" 145 | 146 | Basically in afx, it's no problem even if you don't specify this field when downloading a package from GitHub Release. Because afx automatically filters release assets that can work on your system (OS/Architecture, etc) even if several release assets are uploaded. 147 | 148 | But the filename of the package uploaded to GitHub Release can be named by its author freely. So there are cases that afx cannot filter the package which is suitable on your system when too much special wording is included in a filename. 149 | 150 | For example, the following case is, 151 | 152 | - a filename has "steve-jobs" instead of "mac" or "darwin": `some-package-v1.0.0-steve-jobs-amd64.tar.gz` 153 | 154 | === "Case 1" 155 | 156 | ```yaml hl_lines="9 10" title="Specify asset filename directly" 157 | github: 158 | - name: direnv/direnv 159 | description: Unclutter your .profile 160 | owner: direnv 161 | repo: direnv 162 | release: 163 | name: direnv 164 | tag: v2.30.3 165 | asset: 166 | filename: direnv.darwin-amd64 167 | command: 168 | link: 169 | - from: direnv 170 | ``` 171 | 172 | === "Case 2" 173 | 174 | ```yaml hl_lines="9 10" title="Specify asset filename with templating" 175 | github: 176 | - name: direnv/direnv 177 | description: Unclutter your .profile 178 | owner: direnv 179 | repo: direnv 180 | release: 181 | name: direnv 182 | tag: v2.30.3 183 | asset: 184 | filename: '{{ .Release.Name }}.{{ .OS }}-{{ .Arch }}' 185 | command: 186 | link: 187 | - from: direnv 188 | ``` 189 | 190 | You can specify a filename from asset list on GitHub Release page. It allows to specify a filename directly and also to use name templating feature by using these variables provided by afx: 191 | 192 | Key | Description 193 | ---|--- 194 | `.Release.Name` | Same as `release.name` 195 | `.Release.Tag` | Same as `release.tag` 196 | `.OS` | [GOOS](https://go.dev/doc/install/source#environment)[^1] (e.g. `darwin` etc) 197 | `.Arch` | [GOARCH](https://go.dev/doc/install/source#environment)[^1] (e.g. `amd64` etc) 198 | 199 | [^1]: This can be overwritten by `replace.asset.replacements`. 200 | 201 | ### release.asset.replacements 202 | 203 | Type | Default 204 | ---|--- 205 | map | `{}` 206 | 207 | Allows you to replace pre-defined OS/Architecture wording with yours. In afx, the templating variables of `.OS` and `.Arch` are coming from `runtime.GOOS` and `runtime.GOARCH` (The Go Programming Language). For example, your system is Mac: In this case, `GOOS` returns `darwin` string, but let's say the filename of the assets on GitHub Release you want has `mac` instead of `darwin`. In this case, you can replace it with `darwin` by defining this `replacements` map. 208 | 209 | ```yaml hl_lines="4" 210 | asset: 211 | filename: '{{ .Release.Name }}-{{ .Release.Tag }}-{{ .Arch }}-{{ .OS }}.tar.gz' 212 | replacements: 213 | darwin: mac 214 | ``` 215 | 216 | Keys should be valid `GOOS`s or `GOARCH`s. Valid name is below (full is are on [Environment - The Go Programming Language](https://go.dev/doc/install/source#environment)). Values are the respective replacements. 217 | 218 | `GOOS` | `GOARCH` 219 | ---|--- 220 | darwin|amd64 221 | darwin|arm64 222 | linux|386 223 | linux|amd64 224 | linux|arm64 225 | windows|386 226 | windows|amd64 227 | windows|arm64 228 | 229 | === "Case 1" 230 | 231 | ```yaml hl_lines="9 10 11 12 13" title="" 232 | github: 233 | - name: sharkdp/bat 234 | description: A cat(1) clone with wings. 235 | owner: sharkdp 236 | repo: bat 237 | release: 238 | name: bat 239 | tag: v0.11.0 240 | asset: 241 | filename: '{{ .Release.Name }}-{{ .Release.Tag }}-{{ .Arch }}-{{ .OS }}.tar.gz' 242 | replacements: 243 | darwin: apple-darwin 244 | amd64: x86_64 245 | command: 246 | link: 247 | - from: '**/bat' 248 | ``` 249 | 250 | Due to specifying `release.asset.filename` field, you can choose what you install explicitly. It's not only but also you can replace these `.OS` and `.Arch` with what you like. 251 | 252 | Above example will be templated from: 253 | 254 | ``` 255 | '{{ .Release.Name }}-{{ .Release.Tag }}-{{ .Arch }}-{{ .OS }}.tar.gz' 256 | ``` 257 | to: 258 | ``` 259 | bat-v0.11.0-x86_64-apple-darwin.tar.gz 260 | ``` 261 | 262 | 263 | ### depends-on 264 | 265 | Type | Default 266 | ---|--- 267 | list | `[]` 268 | 269 | Allows you to specify dependency list between packages to handle hidden dependency that afx can't automatically infer. 270 | 271 | Explicitly specifying a dependency is helpful when a package relies on some other package's behavior. Concretely it's good for handling the order of loading files listed on `plugin.sources` when running `afx init`. 272 | 273 | Let's say you want to manage `pkg-a` and `pkg-b` with afx. Also let's say `pkg-a` needs to be loaded after `pkg-b` (This means `pkg-a` depends on `pkg-b`). 274 | 275 | In this case you can specify dependencies: 276 | 277 | === "Case 1" 278 | 279 | ```yaml hl_lines="9 10" 280 | local: 281 | - name: zsh 282 | directory: ~/.zsh 283 | plugin: 284 | if: | 285 | [[ $SHELL == *zsh* ]] 286 | sources: 287 | - '[0-9]*.zsh' 288 | depends-on: 289 | - google-cloud-sdk 290 | - name: google-cloud-sdk 291 | directory: ~/Downloads/google-cloud-sdk 292 | plugin: 293 | env: 294 | PATH: ~/Downloads/google-cloud-sdk/bin 295 | sources: 296 | - '*.zsh.inc' 297 | ``` 298 | 299 | Thanks to `depends-on`, the order of loading sources are: 300 | 301 | ```console 302 | * zsh -> google-cloud-sdk 303 | ``` 304 | 305 | Let's see the actual output with `afx init` in case we added `depends-on` like above config: 306 | 307 | ```console 308 | $ afx init 309 | ... 310 | source /Users/babarot/Downloads/google-cloud-sdk/completion.zsh.inc 311 | source /Users/babarot/Downloads/google-cloud-sdk/path.zsh.inc 312 | export PATH="$PATH:/Users/babarot/Downloads/google-cloud-sdk/bin" 313 | ... 314 | source /Users/babarot/.zsh/10_utils.zsh 315 | source /Users/babarot/.zsh/20_keybinds.zsh 316 | source /Users/babarot/.zsh/30_aliases.zsh 317 | source /Users/babarot/.zsh/50_setopt.zsh 318 | source /Users/babarot/.zsh/70_misc.zsh 319 | ... 320 | ... 321 | ``` 322 | 323 | ### command 324 | 325 | See [Command](../command.md) page 326 | 327 | ### plugin 328 | 329 | See [Plugin](../plugin.md) page 330 | -------------------------------------------------------------------------------- /docs/configuration/package/http.md: -------------------------------------------------------------------------------- 1 | # HTTP 2 | 3 | HTTP type allows you to manage a plugin or command hosted on any websites except for a source code hosting site such as GitHub etc. 4 | 5 | ```yaml 6 | http: 7 | - name: gcping 8 | description: Like gcping.com but a command line tool 9 | url: https://storage.googleapis.com/gcping-release/gcping_darwin_arm64_latest 10 | command: 11 | link: 12 | - from: gcping_* 13 | to: gcping 14 | ``` 15 | 16 | ## Parameters 17 | 18 | ### name 19 | 20 | Type | Default 21 | ---|--- 22 | string | (required) 23 | 24 | Package name. 25 | 26 | ### description 27 | 28 | Type | Default 29 | ---|--- 30 | string | `""` 31 | 32 | Package description. 33 | 34 | ### url 35 | 36 | Type | Default 37 | ---|--- 38 | string | (required) 39 | 40 | Specify a URL that a command or plugin you want to install are hosted. 41 | 42 | In this field, you can use template variables: 43 | 44 | === "Case 1" 45 | 46 | ```yaml hl_lines="4 5 6 7" 47 | http: 48 | - name: gcping 49 | description: Like gcping.com but a command line tool 50 | url: 'https://storage.googleapis.com/gcping-release/{{ .Name }}_{{ .OS }}_{{ .Arch }}_latest' 51 | templates: 52 | replacements: 53 | darwin: darwin # can replace "darwin" as you like! 54 | command: 55 | link: 56 | - from: gcping_* 57 | to: gcping 58 | ``` 59 | 60 | Key | Description 61 | ---|--- 62 | `.Name` | Same as `.name` (Package name) 63 | `.OS` | [GOOS](https://go.dev/doc/install/source#environment)[^1] (e.g. `darwin` etc) 64 | `.Arch` | [GOARCH](https://go.dev/doc/install/source#environment)[^1] (e.g. `amd64` etc) 65 | 66 | [^1]: This can be overwritten by `templates.replacements`. 67 | 68 | ### templates.replacements 69 | 70 | Type | Default 71 | ---|--- 72 | list | `[]` 73 | 74 | In `.url` field, the template variables can be used. Also you can replace it with your own values. For more details, see also below page. 75 | 76 | See [GitHub#release.asset.replacements](github.md#releaseassetreplacements) 77 | 78 | ### depends-on 79 | 80 | See [GitHub#depends-on](github.md#depends-on) page. Same as that. 81 | 82 | ### command 83 | 84 | See [Command](../command.md) page 85 | 86 | ### plugin 87 | 88 | See [Plugin](../plugin.md) page 89 | -------------------------------------------------------------------------------- /docs/configuration/package/local.md: -------------------------------------------------------------------------------- 1 | # Local 2 | 3 | Local type allows you to manage a plugin or command located locally in your system. You can also just run `source` command on your rc files (e.g. zshrc) to load your settings divided into other shell scripts without using this Local type. But by using this package type, you can manage them as same like other packages on afx ecosystem. 4 | 5 | ```yaml 6 | local: 7 | - name: zsh 8 | description: My zsh scripts 9 | directory: ~/.zsh 10 | plugin: 11 | sources: 12 | - '[0-9]*.zsh' 13 | - name: google-cloud-sdk 14 | description: Google Cloud SDK 15 | directory: ~/Downloads/google-cloud-sdk 16 | plugin: 17 | sources: 18 | - '*.zsh.inc' 19 | ``` 20 | 21 | ## Parameters 22 | 23 | ### name 24 | 25 | Type | Default 26 | ---|--- 27 | string | (required) 28 | 29 | Package name. 30 | 31 | ### description 32 | 33 | Type | Default 34 | ---|--- 35 | string | `""` 36 | 37 | Package description. 38 | 39 | ### directory 40 | 41 | Type | Default 42 | ---|--- 43 | string | (required) 44 | 45 | Specify a directory path that files you want to load are put. Allow to use `~` (tilda) and environment variables (e.g. `$HOME`) here. Of course, specifying full path is also acceptable. 46 | 47 | ### depends-on 48 | 49 | See [GitHub#depends-on](github.md#depends-on) page. Same as that. 50 | 51 | ### command 52 | 53 | See [Command](../command.md) page 54 | 55 | ### plugin 56 | 57 | See [Plugin](../plugin.md) page 58 | -------------------------------------------------------------------------------- /docs/configuration/plugin.md: -------------------------------------------------------------------------------- 1 | # Plugin 2 | 3 | afx's goal is to finally support to install packages as `command`, `plugin` or both. In afx, several pacakge types (e.g. `github`) are supported but you can specify `command` and `plugin` field in all of sources. 4 | 5 | ## Parameters 6 | 7 | ### sources 8 | 9 | Type | Default 10 | ---|--- 11 | list | (required) 12 | 13 | `sources` allows you to select what to load files when starting shell. 14 | 15 | === "Case 1" 16 | 17 | ```yaml hl_lines="9 10" title="Simple case, just register to init.sh as load scripts" 18 | github: 19 | - name: babarot/enhancd 20 | description: A next-generation cd command with your interactive filter 21 | owner: babarot 22 | repo: enhancd 23 | plugin: 24 | env: 25 | ENHANCD_FILTER: fzf --height 25% --reverse --ansi:fzy 26 | sources: 27 | - init.sh 28 | ``` 29 | 30 | === "Case 2" 31 | 32 | ```yaml hl_lines="10 11" title="Using wildcards to register multiple files" 33 | github: 34 | - name: babarot/zsh-prompt-minimal 35 | description: Super super super minimal prompt for zsh 36 | owner: babarot 37 | repo: zsh-prompt-minimal 38 | plugin: 39 | env: 40 | PROMPT_PATH_STYLE: minimal 41 | PROMPT_USE_VIM_MODE: true 42 | sources: 43 | - '*.zsh-theme' 44 | ``` 45 | 46 | === "Case 3" 47 | 48 | ```yaml hl_lines="5 6" title="Filenames starting with numbers" 49 | local: 50 | - name: zsh 51 | directory: ~/.zsh 52 | plugin: 53 | sources: 54 | - '[0-9]*.zsh' 55 | ``` 56 | 57 | ### env 58 | 59 | Type | Default 60 | ---|--- 61 | map | `{}` 62 | 63 | `env` allows you to set environment variables. By having this section in same YAML file of package declaration, you can manage it with same file. When we don't have afx, we should have environment variables in shell config (e.g. zshrc) even if not installed it yet or failed to install it. But thanks to afx, afx users can keep it with same files and enable it only while a package is installed. 64 | 65 | !!! notes "Needs to login new shell" 66 | 67 | To enable environment variables to your shell, you need to run this command or start new shell after adding this command to your shel config (e.g. .zshrc): 68 | 69 | ```bash 70 | source <(afx init) 71 | ``` 72 | 73 | === "Case 1" 74 | 75 | ```yaml hl_lines="7 8 9" 76 | github: 77 | - name: babarot/zsh-prompt-minimal 78 | description: Super super super minimal prompt for zsh 79 | owner: babarot 80 | repo: zsh-prompt-minimal 81 | plugin: 82 | env: 83 | PROMPT_PATH_STYLE: minimal 84 | PROMPT_USE_VIM_MODE: true 85 | sources: 86 | - '*.zsh-theme' 87 | ``` 88 | 89 | ### snippet 90 | 91 | Type | Default 92 | ---|--- 93 | string | `""` 94 | 95 | `snippet` allows you to specify the command which are runned when starting new shell. 96 | 97 | === "Case 1" 98 | 99 | ```yaml hl_lines="11 12 13" title="Login message if tpm is installed" 100 | github: 101 | - name: babarot/enhancd 102 | description: A next-generation cd command with your interactive filter 103 | owner: babarot 104 | repo: enhancd 105 | plugin: 106 | env: 107 | ENHANCD_FILTER: fzf --height 25% --reverse --ansi:fzy 108 | sources: 109 | - init.sh 110 | snippet: | 111 | echo "enhancd is enabled, cd command is overrided by enhancd" 112 | echo "see github.com/babarot/enhancd" 113 | ``` 114 | 115 | ### snippet-prepare (beta) 116 | 117 | Type | Default 118 | ---|--- 119 | string | `""` 120 | 121 | `snippet-prepare` allows you to specify the command which are runned when starting new shell. Unlike `snippet`, this `snippet-prepare` is run before `source` command. 122 | 123 | 1. Run `snippet-prepare` 124 | 2. Load `sources` 125 | 3. Run `snippet` 126 | 127 | This option comes from https://github.com/babarot/afx/issues/6. 128 | 129 | === "Case 1" 130 | 131 | ```yaml hl_lines="7 8 9 10 11 12" title="Run snippet before sources" 132 | github: 133 | - name: sindresorhus/pure 134 | description: Pretty, minimal and fast ZSH prompt 135 | owner: sindresorhus 136 | repo: pure 137 | plugin: 138 | snippet-prepare: | 139 | zstyle :prompt:pure:git:branch color magenta 140 | zstyle :prompt:pure:git:branch:cached color yellow 141 | zstyle :prompt:pure:git:dirty color 091 142 | zstyle :prompt:pure:user color blue 143 | zstyle :prompt:pure:host color blue 144 | sources: 145 | - pure.zsh 146 | ``` 147 | 148 | ### if 149 | 150 | Type | Default 151 | ---|--- 152 | string | `""` 153 | 154 | `if` allows you to specify the condition to load packages. If it returns true, then the plugin will be loaded. But if it returns false, the plugin will not be loaded. 155 | 156 | In `if` field, you can write shell scripts[^1]. The exit code finally returned from that shell script is used to determine whether it loads plugin or not. 157 | 158 | === "Case 1" 159 | 160 | ```yaml hl_lines="5 6" title="if login shell is zsh, plugin will be loaded" 161 | local: 162 | - name: zsh 163 | directory: ~/.zsh 164 | plugin: 165 | if: | 166 | [[ $SHELL == *zsh* ]] 167 | sources: 168 | - '[0-9]*.zsh' 169 | ``` 170 | 171 | [^1]: You can configure your favorite shell to evaluate `if` field by setting `AFX_SHELL`. 172 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | FAQ 2 | === 3 | 4 | ## Debugging 5 | 6 | In `afx`, it provides debugging feature by default. You can specify environment variables like: 7 | 8 | ```console 9 | $ export AFX_LOG=debug 10 | $ afx 11 | ``` 12 | 13 | It always shows debug messages while running afx, so it's ok to specify only oneline: 14 | 15 | ```console 16 | $ AFX_LOG=debug afx ... 17 | ``` 18 | 19 | Currently log levels we can use are here: 20 | 21 | Level | Message 22 | ---|--- 23 | INFO | Only show info message 24 | WARN | Previous level, plus warn message 25 | ERROR | Previous level, plus error message 26 | DEBUG | Previous level, plug more detail log messages 27 | TRACE | Previous level, plus all of log messages 28 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## Install the pre-compiled binary 4 | 5 | You can install the pre-compiled binary (in several different ways), compile from source. 6 | 7 | Below you can find the steps for each of them. 8 | 9 | ### bash script 10 | 11 | bash installer has been provided so you can install afx by running this one command at your own risk. 12 | 13 | === "Latest" 14 | 15 | ```bash 16 | curl -sL https://raw.githubusercontent.com/babarot/afx/HEAD/hack/install | bash 17 | ``` 18 | 19 | === "Version" 20 | 21 | ```bash 22 | curl -sL https://raw.githubusercontent.com/babarot/afx/HEAD/hack/install | AFX_VERSION=v0.1.24 bash 23 | ``` 24 | 25 | env | description | default 26 | ---|---|--- 27 | `AFX_VERSION` | afx version, available versions are on [releases](https://github.com/babarot/afx/releases) | `latest` 28 | `AFX_BIN_DIR` | Path to install | `~/bin` 29 | 30 | ### go install 31 | 32 | For Go developers. 33 | 34 | ```bash 35 | go install github.com/babarot/afx@latest 36 | ``` 37 | 38 | ### manually 39 | 40 | Download the pre-compiled binaries from the [OSS releases page][releases] and copy them to the desired location. 41 | 42 | [releases]: https://github.com/babarot/afx/releases 43 | 44 | ## Write YAML 45 | 46 | Let's say you want to install `jq` and `enhancd` with afx. So please write YAML file like this: 47 | 48 | ```yaml 49 | github: 50 | - name: stedolan/jq 51 | description: Command-line JSON processor 52 | owner: stedolan 53 | repo: jq 54 | release: 55 | name: jq 56 | tag: jq-1.6 57 | command: 58 | link: 59 | - from: '*jq*' 60 | to: jq 61 | - name: babarot/enhancd 62 | description: A next-generation cd command with your interactive filter 63 | owner: babarot 64 | repo: enhancd 65 | plugin: 66 | env: 67 | ENHANCD_FILTER: fzf --height 25% --reverse --ansi:fzy 68 | sources: 69 | - init.sh 70 | ``` 71 | 72 | This declaration means afx gets `jq` v1.6 from GitHub release and install it into PATH as a command. 73 | 74 | Okay, then let's save this file in `~/.config/afx/main.yaml`. 75 | 76 | ## Install packages 77 | 78 | After preparing YAML files, you become able to run `install` command: 79 | 80 | ```sh 81 | $ afx install 82 | ``` 83 | 84 | This command runs install based on what were declared in YAML files. 85 | 86 | ## Initialize packages 87 | 88 | After installed, you need to run this command to enable commands/plugins you installed. 89 | 90 | ```sh 91 | $ source <(afx init) 92 | ``` 93 | 94 | `afx init` is just showing what needed to run commands/plugins. As a test, try to run. 95 | 96 | ```sh 97 | $ afx init 98 | source /Users/babarot/.afx/github.com/babarot/enhancd/init.sh 99 | export ENHANCD_FILTER="fzf --height 25% --reverse --ansi:fzy" 100 | ``` 101 | 102 | As long as you don't run it with `source` command, it doesn't effect your current shell. 103 | 104 | ## Initialize when starting shell 105 | 106 | Add this command to your shell config (e.g. .zshrc) enable plugins and commands you installed when starting shell. 107 | 108 | ```bash 109 | # enable packages 110 | source <(afx init) 111 | ``` 112 | 113 | ## Update packages 114 | 115 | If you want to update package to new version etc, all you have to do is just to modify YAML file and then run `afx update`: 116 | 117 | ```diff 118 | github: 119 | - name: stedolan/jq 120 | description: Command-line JSON processor 121 | owner: stedolan 122 | repo: jq 123 | release: 124 | name: jq 125 | - tag: jq-1.5 126 | + tag: jq-1.6 127 | command: 128 | link: 129 | - from: '*jq*' 130 | to: jq 131 | ``` 132 | 133 | ```sh 134 | $ afx update 135 | ✔ stedolan/jq 136 | ``` 137 | 138 | ## Configure shell completions 139 | 140 | You can also use shell completion with afx. To enable completion at starting a shell, you need to add below to your each shell "rc" files. 141 | 142 | === "Bash" 143 | 144 | ```console 145 | $ source <(afx completion bash) 146 | ``` 147 | 148 | === "Zsh" 149 | 150 | ```console 151 | $ source <(afx completion zsh) 152 | ``` 153 | 154 | === "Fish" 155 | 156 | ```console 157 | $ afx completion fish | source 158 | ``` 159 | -------------------------------------------------------------------------------- /docs/how-it-works.md: -------------------------------------------------------------------------------- 1 | # How it works 2 | 3 | ## Where to put YAML 4 | 5 |
6 | 7 |
Example of config directory structure.
8 |
9 | 10 | In afx, all installation declaration can be kept in YAML files. All configurations should be basically saved in `$AFX_CONFIG_PATH`. It defaults to `~/.config/afx`. You can create YAML files that your package declarations are described and save them in this config directory. 11 | 12 | In AFX\_CONFIG\_PATH, you can keep files with these rules: 13 | 14 | - Naming files as you like is ok 15 | - Having single file can be ok 16 | - Deviding into multiple files is also ok 17 | - Creating sub dir can be also ok 18 | 19 | Let's describe each one below. 20 | 21 | ### Single file 22 | 23 | You can create files with any name you like. In above case, declaration of GitHub packages are saved in `github.yaml` and packages of GitHub Releases are saved in `release.yaml`. Others (e.g. `local` etc) are saved in `main.yaml`. 24 | 25 | ```sh 26 | ~/.config/afx 27 | └── afx.yaml 28 | ``` 29 | 30 | It's ok to keep them in one single file. You can choose which one to suit your style. 31 | 32 | ### Multiple files 33 | 34 | You can divide them into each files to make file name and its contents clear and also put it into one YAML file. 35 | 36 | ```sh 37 | ~/.config/afx 38 | ├── github.yaml 39 | ├── main.yaml 40 | └── release.yaml 41 | ``` 42 | 43 | ### Sub directories 44 | 45 | Keeping files in sub directories is also ok. afx tries to walk all directories and find files ending with `.yaml` or `.yml`. 46 | 47 | ```sh 48 | ~/.config/afx 49 | ├── subdir 50 | │ ├── github-1.yaml 51 | │ └── github-2.yaml 52 | ├── local.yaml 53 | └── http.yaml 54 | ``` 55 | 56 | ## State feature 57 | 58 | afx have a state feature like [Terraform](https://www.terraform.io/). In afx, due to this state feature, what was written in the YAML files means always packages list of what a user desired to install. In short, adding a package declaration to YAML files is to install them to your system and also deleting a package declaration from YAML files is to uninstall from your system. 59 | 60 | !!! hint "State in afx" 61 | 62 | All of package declarations are saved in the state file. Install and uninstall will be run by using the difference between YAML files and records in the state file 63 | 64 | === "Install" 65 | 66 | ```diff 67 | github: 68 | - name: babarot/enhancd 69 | description: A next-generation cd command with your interactive filter 70 | owner: babarot 71 | repo: enhancd 72 | plugin: 73 | env: 74 | ENHANCD_FILTER: fzf --height 25% --reverse --ansi:fzy 75 | sources: 76 | - init.sh 77 | + - name: jhawthorn/fzy 78 | + description: A better fuzzy finder 79 | + owner: jhawthorn 80 | + repo: fzy 81 | + command: 82 | + build: 83 | + steps: 84 | + - make 85 | + - sudo make install 86 | ``` 87 | 88 | After adding package declaration to your YAML, then run this command: 89 | 90 | ```sh 91 | $ afx install 92 | ``` 93 | 94 | === "Uninstall" 95 | 96 | ```diff 97 | github: 98 | - name: babarot/enhancd 99 | description: A next-generation cd command with your interactive filter 100 | owner: babarot 101 | repo: enhancd 102 | plugin: 103 | env: 104 | ENHANCD_FILTER: fzf --height 25% --reverse --ansi:fzy 105 | sources: 106 | - init.sh 107 | - - name: jhawthorn/fzy 108 | - description: A better fuzzy finder 109 | - owner: jhawthorn 110 | - repo: fzy 111 | - command: 112 | - build: 113 | - steps: 114 | - - make 115 | - - sudo make install 116 | ``` 117 | 118 | After deleting package declaration from your YAML, then run this command: 119 | 120 | ```sh 121 | $ afx uninstall 122 | ``` 123 | 124 | !!! danger "Localtion of a state file" 125 | 126 | Location of state file defaults to `~/.afx/state.json`. Currently afx does not provide the way to change this path and basically user should not touch this file because it's used internally by afx to keep equivalence between YAML files and its state file. It's likely to be happened unexpected install/uninstall by changing a state file. 127 | 128 |
129 | 130 |
Workflow to install packages.
131 |
132 | 133 | The packages which need to be installed will be calculated from the state file. Then afx installs packages based on the demand of package declarations. 134 | 135 | ## Initialize your commands/plugins 136 | 137 | After installed, basically you need to run `afx init` command and run `source` command with the output of that command in order to become able to use commands and plugins you installed. 138 | 139 | ```sh 140 | $ source <(afx init) 141 | ``` 142 | 143 | This is just an example of `afx init`. Running `source` command with this output means these statements are evaluate in current shell. So we can use plugins in current shell and also aliases, variables and so on. 144 | 145 | ```bash 146 | $ afx init 147 | source /Users/babarot/.afx/github.com/babarot/enhancd/init.sh 148 | export ENHANCD_FILTER="fzf --height 25% --reverse --ansi:fzy" 149 | source /Users/babarot/.afx/github.com/zdharma-continuum/history-search-multi-word/history-search-multi-word.plugin.zsh 150 | source /Users/babarot/.afx/github.com/babarot/zsh-vimode-visual/zsh-vimode-visual.zsh 151 | alias diff="colordiff -u" 152 | source /Users/babarot/.afx/github.com/zdharma-continuum/fast-syntax-highlighting/fast-syntax-highlighting.plugin.zsh 153 | source /Users/babarot/.afx/github.com/babarot/zsh-prompt-minimal/minimal.zsh-theme 154 | export PROMPT_PATH_STYLE="minimal" 155 | export PROMPT_USE_VIM_MODE="true" 156 | ## package shlide is not installed, so skip to init 157 | source /Users/babarot/.zsh/10_utils.zsh 158 | source /Users/babarot/.zsh/20_keybinds.zsh 159 | source /Users/babarot/.zsh/30_aliases.zsh 160 | source /Users/babarot/.zsh/50_setopt.zsh 161 | source /Users/babarot/.zsh/70_misc.zsh 162 | ``` 163 | 164 | afx initialize step just only generates these statements based on your YAML files. 165 | -------------------------------------------------------------------------------- /docs/images/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/babarot/afx/0d1903d6b516ffb3b028344f8f50d41712b87e7a/docs/images/cover.png -------------------------------------------------------------------------------- /docs/images/dir-map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/babarot/afx/0d1903d6b516ffb3b028344f8f50d41712b87e7a/docs/images/dir-map.png -------------------------------------------------------------------------------- /docs/images/install-drop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/babarot/afx/0d1903d6b516ffb3b028344f8f50d41712b87e7a/docs/images/install-drop.png -------------------------------------------------------------------------------- /docs/images/install.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/babarot/afx/0d1903d6b516ffb3b028344f8f50d41712b87e7a/docs/images/install.png -------------------------------------------------------------------------------- /docs/images/installation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/babarot/afx/0d1903d6b516ffb3b028344f8f50d41712b87e7a/docs/images/installation.png -------------------------------------------------------------------------------- /docs/images/state.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/babarot/afx/0d1903d6b516ffb3b028344f8f50d41712b87e7a/docs/images/state.png -------------------------------------------------------------------------------- /docs/images/struct.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/babarot/afx/0d1903d6b516ffb3b028344f8f50d41712b87e7a/docs/images/struct.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # AFX Concepts 2 | 3 | AFX is a command-line package manager. afx can allow us to manage almost all things available on GitHub, Gist and so on. Before, we needed to trawl web pages to download each package one by one. It's very annoying every time we set up new machine and also it's difficult to get how many commands/plugins we installed. 4 | 5 | So afx's motivation is coming from that and to manage them with YAML files (as a code). 6 | 7 | ```console 8 | $ afx help 9 | Package manager for CLI 10 | 11 | Usage: 12 | afx [flags] 13 | afx [command] 14 | 15 | Available Commands: 16 | check Check new updates on each package 17 | completion Generate completion script 18 | help Help about any command 19 | init Initialize installed packages 20 | install Resume installation from paused part (idempotency) 21 | self-update Update afx itself to latest version 22 | show Show packages managed by afx 23 | uninstall Uninstall installed packages 24 | update Update installed package if version etc is changed 25 | 26 | Flags: 27 | -h, --help help for afx 28 | -v, --version version for afx 29 | 30 | Use "afx [command] --help" for more information about a command. 31 | ``` 32 | 33 | ![](https://user-images.githubusercontent.com/4442708/224565945-2c09b729-82b7-4829-9cbc-e247b401b689.gif) 34 | -------------------------------------------------------------------------------- /docs/links.md: -------------------------------------------------------------------------------- 1 | # Links 2 | 3 | Examples on afx config: 4 | 5 | - [babarot/dotfiles](https://github.com/babarot/dotfiles/tree/HEAD/.config/afx) 6 | 7 | Similar projects: 8 | 9 | - [zplug/zplug](https://github.com/zplug/zplug) 10 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | click==8.0.3 2 | ghp-import==2.0.2 3 | Jinja2==3.0.3 4 | Markdown==3.3.6 5 | MarkupSafe==2.0.1 6 | mergedeep==1.3.4 7 | mkdocs==1.2.3 8 | mkdocs-material==8.1.9 9 | mkdocs-material-extensions==1.0.3 10 | packaging==21.3 11 | Pygments==2.11.2 12 | pymdown-extensions==9.1 13 | pyparsing==3.0.7 14 | python-dateutil==2.8.2 15 | PyYAML==6.0 16 | pyyaml_env_tag==0.1 17 | watchdog==2.1.6 18 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/babarot/afx 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/AlecAivazis/survey/v2 v2.3.6 7 | github.com/MakeNowJust/heredoc v1.0.0 8 | github.com/Masterminds/semver v1.5.0 9 | github.com/cli/cli/v2 v2.24.3 10 | github.com/creativeprojects/go-selfupdate v1.0.1 11 | github.com/deckarep/golang-set v1.8.0 12 | github.com/fatih/color v1.14.1 13 | github.com/go-playground/validator/v10 v10.11.2 14 | github.com/goccy/go-yaml v1.9.8 15 | github.com/google/go-cmp v0.5.9 16 | github.com/h2non/filetype v1.1.3 17 | github.com/hashicorp/go-multierror v1.1.1 18 | github.com/hashicorp/go-version v1.6.0 19 | github.com/hashicorp/logutils v1.0.0 20 | github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf 21 | github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de 22 | github.com/mattn/go-isatty v0.0.17 23 | github.com/mattn/go-shellwords v1.0.12 24 | github.com/mattn/go-zglob v0.0.4 25 | github.com/mholt/archiver v3.1.1+incompatible 26 | github.com/pkg/errors v0.9.1 27 | github.com/russross/blackfriday v1.6.0 28 | github.com/schollz/progressbar/v3 v3.13.0 29 | github.com/spf13/cobra v1.6.1 30 | golang.org/x/sync v0.1.0 31 | golang.org/x/term v0.6.0 32 | gopkg.in/src-d/go-git.v4 v4.13.1 33 | gopkg.in/yaml.v2 v2.2.2 34 | ) 35 | 36 | require ( 37 | code.gitea.io/sdk/gitea v0.15.1 // indirect 38 | github.com/Masterminds/semver/v3 v3.2.0 // indirect 39 | github.com/dsnet/compress v0.0.1 // indirect 40 | github.com/emirpasic/gods v1.12.0 // indirect 41 | github.com/frankban/quicktest v1.14.4 // indirect 42 | github.com/go-playground/locales v0.14.1 // indirect 43 | github.com/go-playground/universal-translator v0.18.1 // indirect 44 | github.com/golang/protobuf v1.5.2 // indirect 45 | github.com/golang/snappy v0.0.4 // indirect 46 | github.com/google/go-github/v30 v30.1.0 // indirect 47 | github.com/google/go-querystring v1.1.0 // indirect 48 | github.com/hashicorp/errwrap v1.0.0 // indirect 49 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 50 | github.com/hashicorp/go-retryablehttp v0.7.2 // indirect 51 | github.com/inconshreveable/mousetrap v1.0.1 // indirect 52 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 53 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 54 | github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd // indirect 55 | github.com/leodido/go-urn v1.2.1 // indirect 56 | github.com/mattn/go-colorable v0.1.13 // indirect 57 | github.com/mattn/go-runewidth v0.0.14 // indirect 58 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect 59 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect 60 | github.com/mitchellh/go-homedir v1.1.0 // indirect 61 | github.com/nwaples/rardecode v1.1.3 // indirect 62 | github.com/pierrec/lz4 v2.6.1+incompatible // indirect 63 | github.com/rivo/uniseg v0.4.3 // indirect 64 | github.com/sergi/go-diff v1.0.0 // indirect 65 | github.com/spf13/pflag v1.0.5 // indirect 66 | github.com/src-d/gcfg v1.4.0 // indirect 67 | github.com/ulikunitz/xz v0.5.11 // indirect 68 | github.com/xanzy/go-gitlab v0.80.2 // indirect 69 | github.com/xanzy/ssh-agent v0.2.1 // indirect 70 | github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect 71 | golang.org/x/crypto v0.6.0 // indirect 72 | golang.org/x/net v0.8.0 // indirect 73 | golang.org/x/oauth2 v0.5.0 // indirect 74 | golang.org/x/sys v0.6.0 // indirect 75 | golang.org/x/text v0.8.0 // indirect 76 | golang.org/x/time v0.3.0 // indirect 77 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect 78 | google.golang.org/appengine v1.6.7 // indirect 79 | google.golang.org/protobuf v1.28.1 // indirect 80 | gopkg.in/src-d/go-billy.v4 v4.3.2 // indirect 81 | gopkg.in/warnings.v0 v0.1.2 // indirect 82 | ) 83 | -------------------------------------------------------------------------------- /hack/README.md: -------------------------------------------------------------------------------- 1 | ## Installation script 2 | 3 | Run script from local: 4 | 5 | ```console 6 | $ cat hack/install | bash 7 | ``` 8 | 9 | Run script via curl (when not cloning repo): 10 | 11 | ```console 12 | $ curl -sL https://raw.githubusercontent.com/babarot/afx/HEAD/hack/install | AFX_VERSION=v0.1.24 bash 13 | ``` 14 | 15 | env | description | default 16 | ---|---|--- 17 | `AFX_VERSION` | afx version, available versions are on [releases](https://github.com/babarot/afx/releases) | `latest` 18 | `AFX_BIN_DIR` | Path to install | `~/bin` 19 | 20 | -------------------------------------------------------------------------------- /hack/install: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | afx_bin_dir=${AFX_BIN_DIR:-~/bin} 4 | afx_version=${AFX_VERSION:-latest} 5 | afx_tmp_dir=${TMPDIR:-/tmp}/afx-${afx_version} 6 | 7 | main() { 8 | # Try to download binary executable 9 | local arch 10 | local notfound=false 11 | local tarball 12 | 13 | if [[ -x ${afx_bin_dir}/afx ]]; then 14 | echo "already installed: ${afx_bin_dir}/afx" 15 | return 0 16 | fi 17 | 18 | arch="$(uname -sm)" 19 | case "${arch}" in 20 | "Darwin arm64") tarball="afx_darwin_arm64.tar.gz" ;; 21 | "Darwin x86_64") tarball="afx_darwin_x86_64.tar.gz" ;; 22 | "Linux aarch64") tarball="afx_linux_arm64.tar.gz" ;; 23 | "Linux "*64) tarball="afx_linux_x86_64.tar.gz" ;; 24 | *) notfound=true ;; 25 | esac 26 | 27 | if ! { download ${tarball} && install -v -m 0755 "${afx_tmp_dir}/afx" "${afx_bin_dir}/afx"; } || ${notfound}; then 28 | echo "afx available on your system is not found. So trying to make afx from Go!" 29 | if command -v go >/dev/null; then 30 | try_go 31 | else 32 | echo "go executable not found. Installation failed." >&2 33 | return 1 34 | fi 35 | fi 36 | 37 | command -v afx &>/dev/null && afx --version 38 | 39 | echo 'For more information, see: https://github.com/babarot/afx' 40 | } 41 | 42 | try_curl() { 43 | local file=${1} 44 | command -v curl > /dev/null && 45 | if [[ ${file} =~ tar.gz$ ]]; then 46 | curl --progress-bar -fL "${file}" | tar -xzf - -C "${afx_tmp_dir}" 47 | else 48 | local tmp=${afx_tmp_dir}/afx.zip 49 | curl --progress-bar -fLo "${tmp}" "${file}" && unzip -o "${tmp}" && rm -f "${tmp}" 50 | fi 51 | } 52 | 53 | try_wget() { 54 | local file=${1} 55 | command -v wget > /dev/null && 56 | if [[ ${file} =~ tar.gz$ ]]; then 57 | wget -O - "${file}" | tar -xzf - -C "${afx_tmp_dir}" 58 | else 59 | local tmp=${afx_tmp_dir}/afx.zip 60 | wget -O "${tmp}" "${file}" && unzip -o "${tmp}" && rm -f "${tmp}" 61 | fi 62 | } 63 | 64 | download() { 65 | local tarball="${1}" 66 | local url 67 | 68 | if [[ -z ${tarball} ]]; then 69 | # when not found what to download 70 | return 1 71 | fi 72 | 73 | mkdir -p "${afx_bin_dir}" || { 74 | echo "Failed to create directory" >&2 75 | return 1 76 | } 77 | 78 | mkdir -p "${afx_tmp_dir}" || { 79 | echo "Failed to create directory" >&2 80 | return 1 81 | } 82 | 83 | if [[ ${afx_version} == latest ]]; then 84 | url="https://github.com/babarot/afx/releases/latest/download/${tarball}" 85 | else 86 | url="https://github.com/babarot/afx/releases/download/${afx_version}/${tarball}" 87 | fi 88 | 89 | echo "Downloading afx ..." 90 | if ! (try_curl "${url}" || try_wget "${url}"); then 91 | echo "Failed to download with curl and wget" >&2 92 | return 1 93 | fi 94 | 95 | if [[ ! -f ${afx_tmp_dir}/afx ]]; then 96 | echo "Failed to download ${tarball}" >&2 97 | return 1 98 | fi 99 | } 100 | 101 | try_go() { 102 | local do_cp=false 103 | local path="github.com/babarot/afx" 104 | local cmd="${path}/cmd" 105 | 106 | echo -n "Building binary (go get -u ${path}) ... " 107 | if [[ -z ${GOPATH} ]]; then 108 | do_cp=true 109 | export GOPATH="${TMPDIR:-/tmp}/afx-gopath" 110 | mkdir -p "${GOPATH}" 111 | fi 112 | 113 | local ts 114 | ts=$(date "+%Y-%m-%d") 115 | 116 | if go install -ldflags "-s -w -X ${cmd}.Version=${afx_version} -X ${cmd}.BuildTag=built-by-go -X ${cmd}.BuildSHA=${ts}" ${path}; then 117 | echo "OK" 118 | ${do_cp} && cp -v "${GOPATH}/bin/afx" "${afx_bin_dir}/afx" 119 | else 120 | echo "Failed to build binary. Installation failed." >&2 121 | return 1 122 | fi 123 | } 124 | 125 | main "${@}" 126 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/babarot/afx/cmd" 8 | ) 9 | 10 | func main() { 11 | if err := cmd.Execute(); err != nil { 12 | fmt.Fprintf(os.Stderr, "[ERROR]: %v\n", err) 13 | os.Exit(1) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | # Example: https://github.com/squidfunk/mkdocs-material/blob/master/mkdocs.yml 2 | 3 | site_name: AFX 4 | site_url: https://babarot.me/afx/ 5 | site_description: Package manager for CLI 6 | repo_name: babarot/afx 7 | repo_url: https://github.com/babarot/afx 8 | edit_uri: 9 | copyright: Copyright © 2022 babarot 10 | 11 | theme: 12 | name: material 13 | language: en 14 | favicon: static/favicon.ico 15 | icon: 16 | logo: material/package-variant-closed 17 | include_search_page: false 18 | search_index_only: true 19 | palette: 20 | - media: "(prefers-color-scheme: light)" 21 | scheme: default 22 | toggle: 23 | icon: material/toggle-switch-off-outline 24 | name: Switch to dark mode 25 | primary: deep orange 26 | accent: red 27 | - media: "(prefers-color-scheme: dark)" 28 | scheme: slate 29 | toggle: 30 | icon: material/toggle-switch 31 | name: Switch to light mode 32 | primary: teal 33 | accent: green 34 | features: 35 | - search.suggest 36 | - search.highlight 37 | - search.share 38 | 39 | plugins: 40 | - search 41 | 42 | markdown_extensions: 43 | - meta 44 | - codehilite 45 | - admonition 46 | - toc: 47 | permalink: "#" 48 | - pymdownx.arithmatex 49 | - pymdownx.betterem: 50 | smart_enable: all 51 | - pymdownx.caret 52 | - pymdownx.critic 53 | - pymdownx.details 54 | - pymdownx.emoji: 55 | emoji_generator: !!python/name:pymdownx.emoji.to_svg 56 | - pymdownx.inlinehilite 57 | - pymdownx.magiclink: 58 | repo_url_shortener: true 59 | repo_url_shorthand: true 60 | social_url_shorthand: true 61 | user: babarot 62 | repo: afx 63 | - pymdownx.mark 64 | - pymdownx.smartsymbols 65 | - pymdownx.superfences 66 | - pymdownx.tasklist: 67 | custom_checkbox: true 68 | - pymdownx.tabbed: 69 | alternate_style: true 70 | - pymdownx.tilde 71 | - pymdownx.superfences 72 | - footnotes 73 | 74 | extra: 75 | social: 76 | - icon: fontawesome/solid/blog 77 | link: https://tellme.tokyo 78 | - icon: fontawesome/brands/github 79 | link: https://github.com/babarot 80 | - icon: fontawesome/brands/twitter 81 | link: https://twitter.com/babarot 82 | - icon: fontawesome/brands/docker 83 | link: https://hub.docker.com/u/babarot 84 | 85 | nav: 86 | - Home: index.md 87 | - Getting Started: getting-started.md 88 | - How it works: how-it-works.md 89 | - Configuration: 90 | - Package Type: 91 | - GitHub: configuration/package/github.md 92 | - Gist: configuration/package/gist.md 93 | - Local: configuration/package/local.md 94 | - HTTP: configuration/package/http.md 95 | - Command: configuration/command.md 96 | - Plugin: configuration/plugin.md 97 | - Links: links.md 98 | - FAQ: faq.md 99 | -------------------------------------------------------------------------------- /pkg/config/command.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "log" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "strings" 13 | 14 | "github.com/babarot/afx/pkg/errors" 15 | "github.com/goccy/go-yaml" 16 | "github.com/mattn/go-shellwords" 17 | "github.com/mattn/go-zglob" 18 | ) 19 | 20 | // Command is 21 | type Command struct { 22 | Build *Build `yaml:"build"` 23 | Link []*Link `yaml:"link" validate:"required"` 24 | Env map[string]string `yaml:"env"` 25 | Alias map[string]string `yaml:"alias"` 26 | Snippet string `yaml:"snippet"` 27 | If string `yaml:"if"` 28 | } 29 | 30 | // Build is 31 | type Build struct { 32 | Steps []string `yaml:"steps" validate:"required"` 33 | Env map[string]string `yaml:"env"` 34 | Directory string `yaml:"directory"` 35 | } 36 | 37 | // Link is 38 | type Link struct { 39 | From string `yaml:"from" validate:"required"` 40 | To string `yaml:"to"` 41 | } 42 | 43 | func (l *Link) UnmarshalYAML(b []byte) error { 44 | type alias Link 45 | 46 | tmp := struct { 47 | *alias 48 | From string `yaml:"from"` 49 | To string `yaml:"to"` 50 | }{ 51 | alias: (*alias)(l), 52 | } 53 | 54 | if err := yaml.Unmarshal(b, &tmp); err != nil { 55 | return errors.Wrap(err, "failed to unmarshal YAML") 56 | } 57 | 58 | l.From = tmp.From 59 | l.To = expandTilda(os.ExpandEnv(tmp.To)) 60 | 61 | return nil 62 | } 63 | 64 | func (c Command) GetLink(pkg Package) ([]Link, error) { 65 | var links []Link 66 | 67 | if _, err := os.Stat(pkg.GetHome()); err != nil { 68 | return links, fmt.Errorf( 69 | "%s: still not exists. this method should have been called after install was done", 70 | pkg.GetHome(), 71 | ) 72 | } 73 | 74 | getTo := func(link *Link) string { 75 | dest := link.To 76 | if link.To == "" { 77 | dest = filepath.Base(link.From) 78 | } 79 | if !filepath.IsAbs(link.To) { 80 | dest = filepath.Join(os.Getenv("AFX_COMMAND_PATH"), dest) 81 | } 82 | return dest 83 | } 84 | 85 | for _, link := range c.Link { 86 | if link.From == "." { 87 | links = append(links, Link{ 88 | From: pkg.GetHome(), 89 | To: getTo(link), 90 | }) 91 | continue 92 | } 93 | file := filepath.Join(pkg.GetHome(), link.From) 94 | // zglob can search file path even if file path doesn't includ asterisk at all. 95 | matches, err := zglob.Glob(file) 96 | if err != nil { 97 | return links, errors.Wrapf(err, "%s: failed to get links", pkg.GetName()) 98 | } 99 | 100 | log.Printf("[TRACE] Run zglob.Glob() to search files: %s", file) 101 | var src string 102 | switch len(matches) { 103 | case 0: 104 | return links, fmt.Errorf("%s: %q no matches", pkg.GetName(), link.From) 105 | case 1: 106 | // OK pattern: matches should be only one 107 | src = matches[0] 108 | case 2: 109 | // TODO: Update this with more flexiblities 110 | return links, fmt.Errorf("%s: %d files matched: %#v", pkg.GetName(), len(matches), matches) 111 | default: 112 | log.Printf("[ERROR] matched files: %#v", matches) 113 | return links, errors.New("too many files are matched in file glob") 114 | } 115 | links = append(links, Link{ 116 | From: src, 117 | To: getTo(link), 118 | }) 119 | } 120 | 121 | return links, nil 122 | } 123 | 124 | // Installed returns true ... 125 | func (c Command) Installed(pkg Package) bool { 126 | links, err := c.GetLink(pkg) 127 | if err != nil { 128 | log.Printf("[ERROR] %s: cannot get link: %v", pkg.GetName(), err) 129 | return false 130 | } 131 | 132 | if len(links) == 0 { 133 | // regard as installed if home dir exists 134 | // even if link section is not specified 135 | _, err := os.Stat(pkg.GetHome()) 136 | return err == nil 137 | } 138 | 139 | for _, link := range links { 140 | fi, err := os.Lstat(link.To) 141 | if err != nil { 142 | return false 143 | } 144 | if fi.Mode()&os.ModeSymlink != os.ModeSymlink { 145 | return false 146 | } 147 | orig, err := os.Readlink(link.To) 148 | if err != nil { 149 | return false 150 | } 151 | if _, err := os.Stat(orig); err != nil { 152 | log.Printf("[DEBUG] %v does no longer exist (%s)", orig, link.To) 153 | return false 154 | } 155 | } 156 | 157 | return true 158 | } 159 | 160 | // buildRequired is 161 | func (c Command) buildRequired() bool { 162 | return c.Build != nil && len(c.Build.Steps) > 0 163 | } 164 | 165 | func (c Command) build(pkg Package) error { 166 | wd, _ := os.Getwd() 167 | log.Printf("[DEBUG] Current working directory: %s", wd) 168 | 169 | dir := filepath.Join(pkg.GetHome(), c.Build.Directory) 170 | log.Printf("[DEBUG] Change working directory to %s", dir) 171 | 172 | p := shellwords.NewParser() 173 | p.ParseEnv = true 174 | p.ParseBacktick = true 175 | p.Dir = dir 176 | 177 | var errs errors.Errors 178 | for _, step := range c.Build.Steps { 179 | args, err := p.Parse(step) 180 | if err != nil { 181 | errs.Append(err) 182 | continue 183 | } 184 | var stdin io.Reader = os.Stdin 185 | var stdout, stderr bytes.Buffer 186 | switch args[0] { 187 | case "sudo": 188 | sudo := []string{"sudo", "-S"} 189 | args = append(sudo, args[1:]...) 190 | stdin = strings.NewReader(os.Getenv("AFX_SUDO_PASSWORD") + "\n") 191 | } 192 | log.Printf("[DEBUG] run command: %#v\n", args) 193 | cmd := exec.Command(args[0], args[1:]...) 194 | for k, v := range c.Build.Env { 195 | cmd.Env = append(os.Environ(), fmt.Sprintf("%s=%s", k, v)) 196 | } 197 | cmd.Stdin = stdin 198 | cmd.Stdout = &stdout 199 | cmd.Stdout = os.Stdout // TODO: remove 200 | cmd.Stderr = &stderr 201 | log.Printf("[DEBUG] change dir to: %s\n", dir) 202 | cmd.Dir = dir 203 | if err := cmd.Run(); err != nil { 204 | errs.Append(err) 205 | if stderr.String() != "" { 206 | errs.Append(errors.New(stderr.String())) 207 | } 208 | } 209 | } 210 | return errs.ErrorOrNil() 211 | } 212 | 213 | // Install is 214 | func (c Command) Install(pkg Package) error { 215 | if c.buildRequired() { 216 | err := c.build(pkg) 217 | if err != nil { 218 | return errors.Wrapf(err, "%s: failed to build", pkg.GetName()) 219 | } 220 | } 221 | 222 | links, err := c.GetLink(pkg) 223 | if err != nil { 224 | return errors.Wrapf(err, "%s: failed to get command.link", pkg.GetName()) 225 | } 226 | 227 | if len(links) == 0 { 228 | return fmt.Errorf("%s: failed to install command due to no links specified", pkg.GetName()) 229 | } 230 | 231 | var errs errors.Errors 232 | for _, link := range links { 233 | // Create base dir if not exists when creating symlink 234 | pdir := filepath.Dir(link.To) 235 | if _, err := os.Stat(pdir); os.IsNotExist(err) { 236 | log.Printf("[DEBUG] %s: created directory to install path", pdir) 237 | os.MkdirAll(pdir, 0755) 238 | } 239 | 240 | fi, err := os.Stat(link.From) 241 | if err != nil { 242 | log.Printf("[WARN] %s: no such file or directory", link.From) 243 | continue 244 | } 245 | switch fi.Mode() { 246 | case 0755: 247 | // ok 248 | default: 249 | os.Chmod(link.From, 0755) 250 | } 251 | 252 | if _, err := os.Lstat(link.To); err == nil { 253 | log.Printf("[DEBUG] %s: removed because already exists before linking", link.To) 254 | os.Remove(link.To) 255 | } 256 | 257 | log.Printf("[DEBUG] created symlink %s to %s", link.From, link.To) 258 | if err := os.Symlink(link.From, link.To); err != nil { 259 | log.Printf("[ERROR] failed to create symlink: %v", err) 260 | errs.Append(err) 261 | } 262 | } 263 | 264 | return errs.ErrorOrNil() 265 | } 266 | 267 | func (c Command) Unlink(pkg Package) error { 268 | links, err := c.GetLink(pkg) 269 | if err != nil { 270 | return errors.Wrapf(err, "%s: failed to get command.link", pkg.GetName()) 271 | } 272 | 273 | var errs errors.Errors 274 | for _, link := range links { 275 | log.Printf("[DEBUG] %s: unlinked %s", pkg.GetName(), link.To) 276 | errs.Append(os.Remove(link.To)) 277 | } 278 | return errs.ErrorOrNil() 279 | } 280 | 281 | // Init returns necessary things which should be loaded when executing commands 282 | func (c Command) Init(pkg Package) error { 283 | if !pkg.Installed() { 284 | fmt.Printf("## package %q is not installed\n", pkg.GetName()) 285 | return fmt.Errorf("%s: not installed", pkg.GetName()) 286 | } 287 | 288 | shell := os.Getenv("AFX_SHELL") 289 | if shell == "" { 290 | shell = "bash" 291 | } 292 | 293 | if len(c.If) > 0 { 294 | cmd := exec.CommandContext(context.Background(), shell, "-c", c.If) 295 | err := cmd.Run() 296 | switch cmd.ProcessState.ExitCode() { 297 | case 0: 298 | default: 299 | log.Printf("[ERROR] %s: command.if returns not zero so unlink package", pkg.GetName()) 300 | c.Unlink(pkg) 301 | return fmt.Errorf("%s: failed to run command.if: %w", pkg.GetName(), err) 302 | } 303 | } 304 | 305 | for k, v := range c.Env { 306 | switch k { 307 | case "PATH": 308 | // avoid overwriting PATH 309 | v = fmt.Sprintf("$PATH:%s", expandTilda(v)) 310 | default: 311 | // through 312 | } 313 | fmt.Printf("export %s=%q\n", k, v) 314 | } 315 | 316 | for k, v := range c.Alias { 317 | fmt.Printf("alias %s=%q\n", k, v) 318 | } 319 | 320 | if s := c.Snippet; s != "" { 321 | fmt.Printf("%s", s) 322 | } 323 | 324 | return nil 325 | } 326 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/babarot/afx/pkg/dependency" 12 | "github.com/babarot/afx/pkg/state" 13 | "github.com/go-playground/validator/v10" 14 | "github.com/goccy/go-yaml" 15 | "github.com/hashicorp/go-multierror" 16 | ) 17 | 18 | // Config structure for file describing deployment. This includes the module source, inputs 19 | // dependencies, backend etc. One config element is connected to a single deployment 20 | type Config struct { 21 | GitHub []*GitHub `yaml:"github,omitempty"` 22 | Gist []*Gist `yaml:"gist,omitempty"` 23 | Local []*Local `yaml:"local,omitempty"` 24 | HTTP []*HTTP `yaml:"http,omitempty"` 25 | 26 | Main *Main `yaml:"main,omitempty"` 27 | } 28 | 29 | // Main represents configurations of this application itself 30 | type Main struct { 31 | Shell string `yaml:"shell"` 32 | FilterCmd string `yaml:"filter_command"` 33 | Env map[string]string `yaml:"env"` 34 | } 35 | 36 | // DefaultMain is default settings of Main 37 | // Basically this will be overridden by user config if given 38 | var DefaultMain Main = Main{ 39 | Shell: "bash", 40 | FilterCmd: "fzf --ansi --no-preview --height=50% --reverse", 41 | Env: map[string]string{}, 42 | } 43 | 44 | // Read reads yaml file based on given path 45 | func Read(path string) (Config, error) { 46 | log.Printf("[INFO] Reading config %s...", path) 47 | 48 | var cfg Config 49 | 50 | f, err := os.Open(path) 51 | if err != nil { 52 | return cfg, err 53 | } 54 | defer f.Close() 55 | 56 | validate := validator.New() 57 | validate.RegisterValidation("startswith-gh-if-not-empty", ValidateGHExtension) 58 | d := yaml.NewDecoder( 59 | bufio.NewReader(f), 60 | yaml.DisallowUnknownField(), 61 | yaml.DisallowDuplicateKey(), 62 | yaml.Validator(validate), 63 | ) 64 | if err := d.Decode(&cfg); err != nil { 65 | return cfg, err 66 | } 67 | 68 | return cfg, err 69 | } 70 | 71 | func parse(cfg Config) []Package { 72 | var pkgs []Package 73 | 74 | for _, pkg := range cfg.GitHub { 75 | pkgs = append(pkgs, pkg) 76 | } 77 | for _, pkg := range cfg.Gist { 78 | pkgs = append(pkgs, pkg) 79 | } 80 | for _, pkg := range cfg.Local { 81 | pkgs = append(pkgs, pkg) 82 | } 83 | for _, pkg := range cfg.HTTP { 84 | pkg.ParseURL() 85 | pkgs = append(pkgs, pkg) 86 | } 87 | 88 | return pkgs 89 | } 90 | 91 | // Parse parses a config given via yaml files and converts it into package interface 92 | func (c Config) Parse() ([]Package, error) { 93 | log.Printf("[INFO] Parsing config...") 94 | // TODO: divide from parse() 95 | return parse(c), nil 96 | } 97 | 98 | func visitYAML(files *[]string) filepath.WalkFunc { 99 | return func(path string, info os.FileInfo, err error) error { 100 | if err != nil { 101 | return fmt.Errorf("%w: %s: failed to visit", err, path) 102 | } 103 | switch filepath.Ext(path) { 104 | case ".yaml", ".yml": 105 | *files = append(*files, path) 106 | } 107 | return nil 108 | } 109 | } 110 | 111 | func CreateDirIfNotExist(path string) error { 112 | _, err := os.Stat(path) 113 | if os.IsNotExist(err) { 114 | return os.MkdirAll(path, 0755) 115 | } else if err != nil { 116 | return err 117 | } 118 | return nil 119 | } 120 | 121 | func resolvePath(path string) (string, bool, error) { 122 | fi, err := os.Lstat(path) 123 | if err != nil { 124 | return path, false, err 125 | } 126 | 127 | if fi.Mode()&os.ModeSymlink == os.ModeSymlink { 128 | path, err = os.Readlink(path) 129 | if err != nil { 130 | return path, false, err 131 | } 132 | fi, err = os.Lstat(path) 133 | if err != nil { 134 | return path, false, err 135 | } 136 | } 137 | 138 | isDir := fi.IsDir() 139 | 140 | if filepath.IsAbs(path) { 141 | return path, isDir, nil 142 | } 143 | 144 | return path, isDir, err 145 | } 146 | 147 | // WalkDir walks given directory path and returns full-path of all yaml files 148 | func WalkDir(path string) ([]string, error) { 149 | var files []string 150 | path, isDir, err := resolvePath(path) 151 | if err != nil { 152 | return files, err 153 | } 154 | if isDir { 155 | return files, filepath.Walk(path, visitYAML(&files)) 156 | } 157 | switch filepath.Ext(path) { 158 | case ".yaml", ".yml": 159 | files = append(files, path) 160 | default: 161 | log.Printf("[WARN] %s: found but cannot be loaded. yaml is only allowed\n", path) 162 | } 163 | return files, nil 164 | } 165 | 166 | func Sort(given []Package) ([]Package, error) { 167 | var pkgs []Package 168 | var graph dependency.Graph 169 | 170 | table := map[string]Package{} 171 | 172 | for _, pkg := range given { 173 | table[pkg.GetName()] = pkg 174 | } 175 | 176 | var err error 177 | for name, pkg := range table { 178 | dependencies := pkg.GetDependsOn() 179 | for _, dep := range pkg.GetDependsOn() { 180 | if _, ok := table[dep]; !ok { 181 | err = multierror.Append(err, 182 | fmt.Errorf("%s: not valid package name in depends-on: %q", pkg.GetName(), dep), 183 | ) 184 | } 185 | } 186 | graph = append(graph, dependency.NewNode(name, dependencies...)) 187 | } 188 | if err != nil { 189 | return pkgs, err 190 | } 191 | 192 | if dependency.Has(graph) { 193 | log.Printf("[DEBUG] dependency graph is here: \n%s", graph) 194 | } 195 | 196 | resolved, err := dependency.Resolve(graph) 197 | if err != nil { 198 | return pkgs, fmt.Errorf("%w: failed to resolve dependency graph", err) 199 | } 200 | 201 | for _, node := range resolved { 202 | pkgs = append(pkgs, table[node.Name]) 203 | } 204 | 205 | return pkgs, nil 206 | } 207 | 208 | // Validate validates if packages are not violated some rules 209 | func Validate(pkgs []Package) error { 210 | m := make(map[string]bool) 211 | var list []string 212 | 213 | for _, pkg := range pkgs { 214 | name := pkg.GetName() 215 | _, exist := m[name] 216 | if exist { 217 | list = append(list, name) 218 | continue 219 | } 220 | m[name] = true 221 | } 222 | 223 | if len(list) > 0 { 224 | return fmt.Errorf("duplicated packages: [%s]", strings.Join(list, ",")) 225 | } 226 | 227 | return nil 228 | } 229 | 230 | func getResource(pkg Package) state.Resource { 231 | var paths []string 232 | 233 | // repository existence is also one of the path resource 234 | paths = append(paths, pkg.GetHome()) 235 | 236 | if pkg.HasPluginBlock() { 237 | plugin := pkg.GetPluginBlock() 238 | paths = append(paths, plugin.GetSources(pkg)...) 239 | } 240 | 241 | if pkg.HasCommandBlock() { 242 | command := pkg.GetCommandBlock() 243 | links, _ := command.GetLink(pkg) 244 | for _, link := range links { 245 | paths = append(paths, link.From) 246 | paths = append(paths, link.To) 247 | } 248 | } 249 | 250 | var ty string 251 | var version string 252 | var id string 253 | 254 | switch pkg := pkg.(type) { 255 | case GitHub: 256 | ty = "GitHub" 257 | if pkg.HasReleaseBlock() { 258 | ty = "GitHub Release" 259 | version = pkg.Release.Tag 260 | } 261 | id = fmt.Sprintf("github.com/%s/%s", pkg.Owner, pkg.Repo) 262 | if pkg.HasReleaseBlock() { 263 | id = fmt.Sprintf("github.com/release/%s/%s", pkg.Owner, pkg.Repo) 264 | } 265 | if pkg.IsGHExtension() { 266 | ty = "GitHub (gh extension)" 267 | gh := pkg.As.GHExtension 268 | paths = append(paths, gh.GetHome()) 269 | } 270 | case Gist: 271 | ty = "Gist" 272 | id = fmt.Sprintf("gist.github.com/%s/%s", pkg.Owner, pkg.ID) 273 | case Local: 274 | ty = "Local" 275 | id = fmt.Sprintf("local/%s", pkg.Directory) 276 | case HTTP: 277 | ty = "HTTP" 278 | id = pkg.URL 279 | default: 280 | ty = "Unknown" 281 | } 282 | 283 | return state.Resource{ 284 | ID: id, 285 | Name: pkg.GetName(), 286 | Home: pkg.GetHome(), 287 | Type: ty, 288 | Version: version, 289 | Paths: paths, 290 | } 291 | } 292 | 293 | func (c Config) Get(args ...string) Config { 294 | var part Config 295 | for _, arg := range args { 296 | for _, github := range c.GitHub { 297 | if github.Name == arg { 298 | part.GitHub = append(part.GitHub, github) 299 | } 300 | } 301 | for _, gist := range c.Gist { 302 | if gist.Name == arg { 303 | part.Gist = append(part.Gist, gist) 304 | } 305 | } 306 | for _, local := range c.Local { 307 | if local.Name == arg { 308 | part.Local = append(part.Local, local) 309 | } 310 | } 311 | for _, http := range c.HTTP { 312 | if http.Name == arg { 313 | part.HTTP = append(part.HTTP, http) 314 | } 315 | } 316 | } 317 | return part 318 | } 319 | 320 | func (c Config) Contains(args ...string) Config { 321 | var part Config 322 | for _, arg := range args { 323 | for _, github := range c.GitHub { 324 | if strings.Contains(github.Name, arg) { 325 | part.GitHub = append(part.GitHub, github) 326 | } 327 | } 328 | for _, gist := range c.Gist { 329 | if strings.Contains(gist.Name, arg) { 330 | part.Gist = append(part.Gist, gist) 331 | } 332 | } 333 | for _, local := range c.Local { 334 | if strings.Contains(local.Name, arg) { 335 | part.Local = append(part.Local, local) 336 | } 337 | } 338 | for _, http := range c.HTTP { 339 | if strings.Contains(http.Name, arg) { 340 | part.HTTP = append(part.HTTP, http) 341 | } 342 | } 343 | } 344 | return part 345 | } 346 | -------------------------------------------------------------------------------- /pkg/config/gist.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/babarot/afx/pkg/errors" 11 | "github.com/babarot/afx/pkg/state" 12 | git "gopkg.in/src-d/go-git.v4" 13 | ) 14 | 15 | // Gist represents 16 | type Gist struct { 17 | Name string `yaml:"name" validate:"required"` 18 | 19 | Owner string `yaml:"owner" validate:"required"` 20 | ID string `yaml:"id" validate:"required"` 21 | Description string `yaml:"description"` 22 | 23 | Plugin *Plugin `yaml:"plugin"` 24 | Command *Command `yaml:"command"` 25 | 26 | DependsOn []string `yaml:"depends-on"` 27 | } 28 | 29 | // Init is 30 | func (c Gist) Init() error { 31 | var errs errors.Errors 32 | if c.HasPluginBlock() { 33 | errs.Append(c.Plugin.Init(c)) 34 | } 35 | if c.HasCommandBlock() { 36 | errs.Append(c.Command.Init(c)) 37 | } 38 | return errs.ErrorOrNil() 39 | } 40 | 41 | // Install is 42 | func (c Gist) Install(ctx context.Context, status chan<- Status) error { 43 | ctx, cancel := context.WithCancel(ctx) 44 | defer cancel() 45 | 46 | select { 47 | case <-ctx.Done(): 48 | log.Println("[DEBUG] canceled") 49 | return nil 50 | default: 51 | // Go installing step! 52 | } 53 | 54 | if _, err := os.Stat(c.GetHome()); err == nil { 55 | log.Printf("[DEBUG] %s: removed because already exists before clone gist: %s", 56 | c.GetName(), c.GetHome()) 57 | os.RemoveAll(c.GetHome()) 58 | } 59 | 60 | _, err := git.PlainCloneContext(ctx, c.GetHome(), false, &git.CloneOptions{ 61 | URL: fmt.Sprintf("https://gist.github.com/%s/%s", c.Owner, c.ID), 62 | Tags: git.NoTags, 63 | }) 64 | if err != nil { 65 | status <- Status{Name: c.GetName(), Done: true, Err: true} 66 | return errors.Wrapf(err, "%s: failed to clone gist repo", c.Name) 67 | } 68 | 69 | var errs errors.Errors 70 | if c.HasPluginBlock() { 71 | errs.Append(c.Plugin.Install(c)) 72 | } 73 | if c.HasCommandBlock() { 74 | errs.Append(c.Command.Install(c)) 75 | } 76 | 77 | status <- Status{Name: c.GetName(), Done: true, Err: errs.ErrorOrNil() != nil} 78 | return errs.ErrorOrNil() 79 | } 80 | 81 | // Installed is 82 | func (c Gist) Installed() bool { 83 | var list []bool 84 | 85 | if c.HasPluginBlock() { 86 | list = append(list, c.Plugin.Installed(c)) 87 | } 88 | 89 | if c.HasCommandBlock() { 90 | list = append(list, c.Command.Installed(c)) 91 | } 92 | 93 | switch { 94 | case c.HasPluginBlock(): 95 | case c.HasCommandBlock(): 96 | default: 97 | _, err := os.Stat(c.GetHome()) 98 | list = append(list, err == nil) 99 | } 100 | 101 | return allTrue(list) 102 | } 103 | 104 | // HasPluginBlock is 105 | func (c Gist) HasPluginBlock() bool { 106 | return c.Plugin != nil 107 | } 108 | 109 | // HasCommandBlock is 110 | func (c Gist) HasCommandBlock() bool { 111 | return c.Command != nil 112 | } 113 | 114 | // GetPluginBlock is 115 | func (c Gist) GetPluginBlock() Plugin { 116 | if c.HasPluginBlock() { 117 | return *c.Plugin 118 | } 119 | return Plugin{} 120 | } 121 | 122 | // GetCommandBlock is 123 | func (c Gist) GetCommandBlock() Command { 124 | if c.HasCommandBlock() { 125 | return *c.Command 126 | } 127 | return Command{} 128 | } 129 | 130 | // Uninstall is 131 | func (c Gist) Uninstall(ctx context.Context) error { 132 | var errs errors.Errors 133 | 134 | delete := func(f string, errs *errors.Errors) { 135 | err := os.RemoveAll(f) 136 | if err != nil { 137 | errs.Append(err) 138 | return 139 | } 140 | log.Printf("[INFO] Delete %s\n", f) 141 | } 142 | 143 | if c.HasCommandBlock() { 144 | links, err := c.Command.GetLink(c) 145 | if err != nil { 146 | return errors.Wrapf(err, "%s: failed to get command.link", c.Name) 147 | } 148 | for _, link := range links { 149 | delete(link.From, &errs) 150 | delete(link.To, &errs) 151 | } 152 | } 153 | 154 | if c.HasPluginBlock() { 155 | // TODO 156 | } 157 | 158 | delete(c.GetHome(), &errs) 159 | 160 | return errs.ErrorOrNil() 161 | } 162 | 163 | // GetName returns a name 164 | func (c Gist) GetName() string { 165 | return c.Name 166 | } 167 | 168 | // GetHome returns a path 169 | func (c Gist) GetHome() string { 170 | return filepath.Join(os.Getenv("HOME"), ".afx", "gist.github.com", c.Owner, c.ID) 171 | } 172 | 173 | func (c Gist) GetDependsOn() []string { 174 | return c.DependsOn 175 | } 176 | 177 | func (c Gist) GetResource() state.Resource { 178 | return getResource(c) 179 | } 180 | 181 | func (c Gist) Check(ctx context.Context, status chan<- Status) error { 182 | status <- Status{Name: c.GetName(), Done: true, Err: false, Message: "(gist)", NoColor: true} 183 | return nil 184 | } 185 | -------------------------------------------------------------------------------- /pkg/config/http.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | "path/filepath" 13 | 14 | "github.com/babarot/afx/pkg/data" 15 | "github.com/babarot/afx/pkg/errors" 16 | "github.com/babarot/afx/pkg/state" 17 | "github.com/babarot/afx/pkg/templates" 18 | "github.com/h2non/filetype" 19 | "github.com/mholt/archiver" 20 | ) 21 | 22 | // HTTP represents 23 | type HTTP struct { 24 | Name string `yaml:"name" validate:"required"` 25 | 26 | URL string `yaml:"url" validate:"required,url"` 27 | Description string `yaml:"description"` 28 | 29 | Plugin *Plugin `yaml:"plugin"` 30 | Command *Command `yaml:"command"` 31 | 32 | DependsOn []string `yaml:"depends-on"` 33 | Templates Templates `yaml:"templates"` 34 | } 35 | 36 | type Templates struct { 37 | Replacements map[string]string `yaml:"replacements"` 38 | } 39 | 40 | // Init is 41 | func (c HTTP) Init() error { 42 | var errs errors.Errors 43 | if c.HasPluginBlock() { 44 | errs.Append(c.Plugin.Init(c)) 45 | } 46 | if c.HasCommandBlock() { 47 | errs.Append(c.Command.Init(c)) 48 | } 49 | return errs.ErrorOrNil() 50 | } 51 | 52 | func (c HTTP) call(ctx context.Context) error { 53 | log.Printf("[TRACE] Get %s\n", c.URL) 54 | req, err := http.NewRequest(http.MethodGet, c.URL, nil) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | client := new(http.Client) 60 | resp, err := client.Do(req.WithContext(ctx)) 61 | if err != nil { 62 | return err 63 | } 64 | defer resp.Body.Close() 65 | 66 | log.Printf("[DEBUG] response code: %d", resp.StatusCode) 67 | switch resp.StatusCode { 68 | case 200, 301, 302: 69 | // go 70 | case 404: 71 | return fmt.Errorf("%s: %d Not Found in %s", c.GetName(), resp.StatusCode, c.URL) 72 | default: 73 | return fmt.Errorf("%s: %d %s", c.GetName(), resp.StatusCode, http.StatusText(resp.StatusCode)) 74 | } 75 | 76 | os.MkdirAll(c.GetHome(), os.ModePerm) 77 | dest := filepath.Join(c.GetHome(), filepath.Base(c.URL)) 78 | 79 | log.Printf("[DEBUG] http: %s: copying %q to %q", c.GetName(), c.URL, dest) 80 | file, err := os.Create(dest) 81 | if err != nil { 82 | return err 83 | } 84 | defer file.Close() 85 | _, err = io.Copy(file, resp.Body) 86 | if err != nil { 87 | return err 88 | } 89 | 90 | if err := unarchiveV2(dest); err != nil { 91 | return errors.Wrapf(err, "failed to unarchive: %s", dest) 92 | } 93 | 94 | return nil 95 | } 96 | 97 | // Install is 98 | func (c HTTP) Install(ctx context.Context, status chan<- Status) error { 99 | select { 100 | case <-ctx.Done(): 101 | log.Println("[DEBUG] canceled") 102 | return nil 103 | default: 104 | // Go installing step! 105 | } 106 | 107 | ctx, cancel := context.WithCancel(ctx) 108 | defer cancel() 109 | 110 | if err := c.call(ctx); err != nil { 111 | err = errors.Wrapf(err, "%s: failed to make HTTP request", c.Name) 112 | status <- Status{Name: c.GetName(), Done: true, Err: true} 113 | return err 114 | } 115 | 116 | var errs errors.Errors 117 | if c.HasPluginBlock() { 118 | errs.Append(c.Plugin.Install(c)) 119 | } 120 | if c.HasCommandBlock() { 121 | errs.Append(c.Command.Install(c)) 122 | } 123 | 124 | status <- Status{Name: c.GetName(), Done: true, Err: errs.ErrorOrNil() != nil} 125 | return errs.ErrorOrNil() 126 | } 127 | 128 | func unarchiveV2(path string) error { 129 | _, err := archiver.ByExtension(path) 130 | if err != nil { 131 | log.Printf("[DEBUG] unarchiveV2: no need to unarchive. finished with nil") 132 | return nil 133 | } 134 | return archiver.Unarchive(path, filepath.Dir(path)) 135 | } 136 | 137 | func unarchive(f string) error { 138 | buf, err := ioutil.ReadFile(f) 139 | if err != nil { 140 | return err 141 | } 142 | 143 | switch { 144 | case filetype.IsArchive(buf): 145 | if err := archiver.Unarchive(f, filepath.Dir(f)); err != nil { 146 | return errors.Wrapf(err, "%s: failed to unarhive", f) 147 | } 148 | default: 149 | log.Printf("[DEBUG] %s: no need to unarchive", f) 150 | } 151 | 152 | return nil 153 | } 154 | 155 | // Installed is 156 | func (c HTTP) Installed() bool { 157 | var list []bool 158 | 159 | if c.HasPluginBlock() { 160 | list = append(list, c.Plugin.Installed(c)) 161 | } 162 | 163 | if c.HasCommandBlock() { 164 | list = append(list, c.Command.Installed(c)) 165 | } 166 | 167 | switch { 168 | case c.HasPluginBlock(): 169 | case c.HasCommandBlock(): 170 | default: 171 | _, err := os.Stat(c.GetHome()) 172 | list = append(list, err == nil) 173 | } 174 | 175 | return allTrue(list) 176 | } 177 | 178 | // HasPluginBlock is 179 | func (c HTTP) HasPluginBlock() bool { 180 | return c.Plugin != nil 181 | } 182 | 183 | // HasCommandBlock is 184 | func (c HTTP) HasCommandBlock() bool { 185 | return c.Command != nil 186 | } 187 | 188 | // GetPluginBlock is 189 | func (c HTTP) GetPluginBlock() Plugin { 190 | if c.HasPluginBlock() { 191 | return *c.Plugin 192 | } 193 | return Plugin{} 194 | } 195 | 196 | // GetCommandBlock is 197 | func (c HTTP) GetCommandBlock() Command { 198 | if c.HasCommandBlock() { 199 | return *c.Command 200 | } 201 | return Command{} 202 | } 203 | 204 | // Uninstall is 205 | func (c HTTP) Uninstall(ctx context.Context) error { 206 | var errs errors.Errors 207 | 208 | delete := func(f string, errs *errors.Errors) { 209 | err := os.RemoveAll(f) 210 | if err != nil { 211 | errs.Append(err) 212 | return 213 | } 214 | log.Printf("[INFO] Delete %s", f) 215 | } 216 | 217 | if c.HasCommandBlock() { 218 | links, err := c.Command.GetLink(c) 219 | if err != nil { 220 | // no problem to handle error even if this links returns no value 221 | // because base dir itself will be deleted below 222 | } 223 | for _, link := range links { 224 | delete(link.From, &errs) 225 | delete(link.To, &errs) 226 | } 227 | } 228 | 229 | if c.HasPluginBlock() { 230 | } 231 | 232 | delete(c.GetHome(), &errs) 233 | 234 | return errs.ErrorOrNil() 235 | } 236 | 237 | // GetName returns a name 238 | func (c HTTP) GetName() string { 239 | return c.Name 240 | } 241 | 242 | // GetHome returns a path 243 | func (c HTTP) GetHome() string { 244 | u, _ := url.Parse(c.URL) 245 | return filepath.Join(os.Getenv("HOME"), ".afx", u.Host, filepath.Dir(u.Path)) 246 | } 247 | 248 | func (c HTTP) GetDependsOn() []string { 249 | return c.DependsOn 250 | } 251 | 252 | func (c HTTP) GetResource() state.Resource { 253 | return getResource(c) 254 | } 255 | 256 | func (c *HTTP) ParseURL() { 257 | templated, err := templates.New(data.New(data.WithPackage(c))). 258 | Replace(c.Templates.Replacements). 259 | Apply(c.URL) 260 | if err != nil { 261 | log.Printf("[ERROR] %s: failed to parse URL", c.GetName()) 262 | return 263 | } 264 | if templated != c.URL { 265 | log.Printf("[TRACE] %s: templating URL %q to %q", c.GetName(), c.URL, templated) 266 | c.URL = templated 267 | } 268 | return 269 | } 270 | 271 | func (c HTTP) Check(ctx context.Context, status chan<- Status) error { 272 | status <- Status{Name: c.GetName(), Done: true, Err: false, Message: "(http)", NoColor: true} 273 | return nil 274 | } 275 | -------------------------------------------------------------------------------- /pkg/config/local.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | "github.com/babarot/afx/pkg/errors" 8 | "github.com/babarot/afx/pkg/state" 9 | ) 10 | 11 | // Local represents 12 | type Local struct { 13 | Name string `yaml:"name" validate:"required"` 14 | 15 | Directory string `yaml:"directory" validate:"required"` 16 | Description string `yaml:"description"` 17 | 18 | Plugin *Plugin `yaml:"plugin"` 19 | Command *Command `yaml:"command"` 20 | 21 | DependsOn []string `yaml:"depends-on"` 22 | } 23 | 24 | // Init is 25 | func (c Local) Init() error { 26 | var errs errors.Errors 27 | if c.HasPluginBlock() { 28 | errs.Append(c.Plugin.Init(c)) 29 | } 30 | if c.HasCommandBlock() { 31 | errs.Append(c.Command.Init(c)) 32 | } 33 | return errs.ErrorOrNil() 34 | } 35 | 36 | // Install is 37 | func (c Local) Install(ctx context.Context, status chan<- Status) error { 38 | return nil 39 | } 40 | 41 | // Installed is 42 | func (c Local) Installed() bool { 43 | return true 44 | } 45 | 46 | // HasPluginBlock is 47 | func (c Local) HasPluginBlock() bool { 48 | return c.Plugin != nil 49 | } 50 | 51 | // HasCommandBlock is 52 | func (c Local) HasCommandBlock() bool { 53 | return c.Command != nil 54 | } 55 | 56 | // GetPluginBlock is 57 | func (c Local) GetPluginBlock() Plugin { 58 | if c.HasPluginBlock() { 59 | return *c.Plugin 60 | } 61 | return Plugin{} 62 | } 63 | 64 | // GetCommandBlock is 65 | func (c Local) GetCommandBlock() Command { 66 | if c.HasCommandBlock() { 67 | return *c.Command 68 | } 69 | return Command{} 70 | } 71 | 72 | // Uninstall is 73 | func (c Local) Uninstall(ctx context.Context) error { 74 | return nil 75 | } 76 | 77 | // GetName returns a name 78 | func (c Local) GetName() string { 79 | return c.Name 80 | } 81 | 82 | // GetHome returns a path 83 | func (c Local) GetHome() string { 84 | return expandTilda(os.ExpandEnv(c.Directory)) 85 | } 86 | 87 | func (c Local) GetDependsOn() []string { 88 | return c.DependsOn 89 | } 90 | 91 | func (c Local) GetResource() state.Resource { 92 | return getResource(c) 93 | } 94 | 95 | func (c Local) Check(ctx context.Context, status chan<- Status) error { 96 | status <- Status{Name: c.GetName(), Done: true, Err: false, Message: "(local)", NoColor: true} 97 | return nil 98 | } 99 | -------------------------------------------------------------------------------- /pkg/config/package.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/babarot/afx/pkg/state" 7 | "github.com/mattn/go-shellwords" 8 | ) 9 | 10 | // Installer is an interface related to installation of a package 11 | type Installer interface { 12 | Install(context.Context, chan<- Status) error 13 | Uninstall(context.Context) error 14 | Installed() bool 15 | Check(context.Context, chan<- Status) error 16 | } 17 | 18 | // Loader is an interface related to initialize a package 19 | type Loader interface { 20 | Init() error 21 | } 22 | 23 | // Handler is an interface of package handler 24 | type Handler interface { 25 | GetHome() string 26 | GetName() string 27 | 28 | HasPluginBlock() bool 29 | HasCommandBlock() bool 30 | GetPluginBlock() Plugin 31 | GetCommandBlock() Command 32 | 33 | GetDependsOn() []string 34 | GetResource() state.Resource 35 | } 36 | 37 | // Package is an interface related to package itself 38 | type Package interface { 39 | Loader 40 | Handler 41 | Installer 42 | } 43 | 44 | // HasGitHubReleaseBlock returns true if release block is included in one package at least 45 | func HasGitHubReleaseBlock(pkgs []Package) bool { 46 | for _, pkg := range pkgs { 47 | github, ok := pkg.(*GitHub) 48 | if !ok { 49 | continue 50 | } 51 | if github.Release != nil { 52 | return true 53 | } 54 | } 55 | return false 56 | } 57 | 58 | // HasSudoInCommandBuildSteps returns true if sudo command is 59 | // included in one build step of given package at least 60 | func HasSudoInCommandBuildSteps(pkgs []Package) bool { 61 | for _, pkg := range pkgs { 62 | if !pkg.HasCommandBlock() { 63 | continue 64 | } 65 | command := pkg.GetCommandBlock() 66 | if !command.buildRequired() { 67 | continue 68 | } 69 | p := shellwords.NewParser() 70 | p.ParseEnv = true 71 | p.ParseBacktick = true 72 | for _, step := range command.Build.Steps { 73 | args, err := p.Parse(step) 74 | if err != nil { 75 | continue 76 | } 77 | switch args[0] { 78 | case "sudo": 79 | return true 80 | default: 81 | continue 82 | } 83 | } 84 | } 85 | return false 86 | } 87 | -------------------------------------------------------------------------------- /pkg/config/plugin.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | 12 | "github.com/goccy/go-yaml" 13 | "github.com/mattn/go-zglob" 14 | ) 15 | 16 | // Plugin is 17 | type Plugin struct { 18 | Sources []string `yaml:"sources" validate:"required"` 19 | Env map[string]string `yaml:"env"` 20 | Snippet string `yaml:"snippet"` 21 | SnippetPrepare string `yaml:"snippet-prepare"` 22 | If string `yaml:"if"` 23 | } 24 | 25 | func (p *Plugin) UnmarshalYAML(b []byte) error { 26 | type alias Plugin 27 | 28 | // Unlike UnmarshalJSON, all of fields in struct should be listed here... 29 | // http://choly.ca/post/go-json-marshalling/ 30 | // https://go.dev/play/p/rozEOsAYHPe // JSON works but replacing json with yaml then not working 31 | // https://stackoverflow.com/questions/48674624/unmarshal-a-yaml-to-a-struct-with-unexpected-fields-in-go 32 | // https://go.dev/play/p/XZg7tEPGXna // other YAML case 33 | tmp := struct { 34 | *alias 35 | Sources []string `yaml:"sources" validate:"required"` 36 | Env map[string]string `yaml:"env"` 37 | Snippet string `yaml:"snippet"` 38 | SnippetPrepare string `yaml:"snippet-prepare"` 39 | If string `yaml:"if"` 40 | }{ 41 | alias: (*alias)(p), 42 | } 43 | 44 | if err := yaml.Unmarshal(b, &tmp); err != nil { 45 | return err 46 | } 47 | 48 | var sources []string 49 | for _, source := range tmp.Sources { 50 | sources = append(sources, expandTilda(os.ExpandEnv(source))) 51 | } 52 | 53 | p.Sources = sources 54 | p.Env = tmp.Env 55 | p.Snippet = tmp.Snippet 56 | p.SnippetPrepare = tmp.SnippetPrepare 57 | p.If = tmp.If 58 | 59 | return nil 60 | } 61 | 62 | // Installed returns true if sources exist at least one or more 63 | func (p Plugin) Installed(pkg Package) bool { 64 | return len(p.GetSources(pkg)) > 0 65 | } 66 | 67 | // Install runs nothing on plugin installation 68 | func (p Plugin) Install(pkg Package) error { 69 | return nil 70 | } 71 | 72 | func (p Plugin) GetSources(pkg Package) []string { 73 | var sources []string 74 | for _, src := range p.Sources { 75 | path := src 76 | if !filepath.IsAbs(src) { 77 | // basically almost all of sources are not abs path 78 | path = filepath.Join(pkg.GetHome(), src) 79 | } 80 | for _, src := range glob(path) { 81 | if _, err := os.Stat(src); errors.Is(err, os.ErrNotExist) { 82 | continue 83 | } 84 | sources = append(sources, src) 85 | } 86 | } 87 | return sources 88 | } 89 | 90 | // Init returns the file list which should be loaded as shell plugins 91 | func (p Plugin) Init(pkg Package) error { 92 | if !pkg.Installed() { 93 | fmt.Printf("## package %q is not installed\n", pkg.GetName()) 94 | return fmt.Errorf("%s: not installed", pkg.GetName()) 95 | } 96 | 97 | shell := os.Getenv("AFX_SHELL") 98 | if shell == "" { 99 | shell = "bash" 100 | } 101 | 102 | if len(p.If) > 0 { 103 | cmd := exec.CommandContext(context.Background(), shell, "-c", p.If) 104 | err := cmd.Run() 105 | switch cmd.ProcessState.ExitCode() { 106 | case 0: 107 | default: 108 | log.Printf("[ERROR] %s: plugin.if exit code is not zero, so stopped to init package", pkg.GetName()) 109 | return fmt.Errorf("%s: returned non-zero value with evaluation of `if` field: %w", pkg.GetName(), err) 110 | } 111 | } 112 | 113 | if s := p.SnippetPrepare; s != "" { 114 | fmt.Printf("%s\n", s) 115 | } 116 | 117 | for _, src := range p.GetSources(pkg) { 118 | fmt.Printf("source %s\n", src) 119 | } 120 | 121 | for k, v := range p.Env { 122 | switch k { 123 | case "PATH": 124 | // avoid overwriting PATH 125 | v = fmt.Sprintf("$PATH:%s", expandTilda(v)) 126 | default: 127 | // through 128 | } 129 | fmt.Printf("export %s=%q\n", k, v) 130 | } 131 | 132 | if s := p.Snippet; s != "" { 133 | fmt.Printf("%s\n", s) 134 | } 135 | 136 | return nil 137 | } 138 | 139 | func glob(path string) []string { 140 | var matches, sources []string 141 | var err error 142 | 143 | matches, err = filepath.Glob(path) 144 | if err == nil { 145 | sources = append(sources, matches...) 146 | } 147 | matches, err = zglob.Glob(path) 148 | if err == nil { 149 | sources = append(sources, matches...) 150 | } 151 | 152 | m := make(map[string]bool) 153 | unique := []string{} 154 | 155 | for _, source := range sources { 156 | if !m[source] { 157 | m[source] = true 158 | unique = append(unique, source) 159 | } 160 | } 161 | 162 | return unique 163 | } 164 | -------------------------------------------------------------------------------- /pkg/config/status.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "math" 7 | "os" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/fatih/color" 12 | "golang.org/x/term" 13 | ) 14 | 15 | type Progress struct { 16 | Status map[string]Status 17 | } 18 | 19 | type Status struct { 20 | Name string 21 | Done bool 22 | Err bool 23 | Message string 24 | NoColor bool 25 | } 26 | 27 | func NewProgress(pkgs []Package) Progress { 28 | status := make(map[string]Status) 29 | for _, pkg := range pkgs { 30 | status[pkg.GetName()] = Status{ 31 | Name: pkg.GetName(), 32 | Done: false, 33 | Err: false, 34 | Message: "", 35 | } 36 | } 37 | return Progress{Status: status} 38 | } 39 | 40 | func (p Progress) Print(completion chan Status) { 41 | green := color.New(color.FgGreen).SprintFunc() 42 | red := color.New(color.FgRed).SprintFunc() 43 | white := color.New(color.FgWhite).SprintFunc() 44 | 45 | fadedOutput := color.New(color.FgCyan) 46 | for { 47 | s := <-completion 48 | fmt.Printf("\x1b[2K") 49 | 50 | name := white(s.Name) 51 | if s.NoColor { 52 | name = s.Name 53 | } 54 | 55 | sign := green("✔") 56 | if s.Err { 57 | sign = red("✖") 58 | } 59 | 60 | fmt.Println(sign, name, s.Message) 61 | 62 | p.Status[s.Name] = s 63 | count, repos := countRemaining(p.Status) 64 | if count == len(p.Status) { 65 | break 66 | } 67 | 68 | _, width := getTerminalSize() 69 | width = int(math.Min(float64(width), 100)) 70 | 71 | finalOutput := strconv.Itoa(len(p.Status)-count) + "| " + strings.Join(repos, ", ") 72 | if width < 5 { 73 | finalOutput = "" 74 | } else if len(finalOutput) > width { 75 | finalOutput = finalOutput[:width-4] + "..." 76 | } 77 | fadedOutput.Printf(finalOutput + "\r") 78 | } 79 | } 80 | 81 | func countRemaining(status map[string]Status) (int, []string) { 82 | count := 0 83 | var repos []string 84 | for _, s := range status { 85 | if s.Done { 86 | count++ 87 | } else { 88 | repos = append(repos, s.Name) 89 | } 90 | } 91 | return count, repos 92 | } 93 | 94 | func getTerminalSize() (int, int) { 95 | id := int(os.Stdout.Fd()) 96 | width, height, err := term.GetSize(id) 97 | if err != nil { 98 | log.Printf("[ERROR]: getTerminalSize(): %s", err) 99 | } 100 | return height, width 101 | } 102 | -------------------------------------------------------------------------------- /pkg/config/util.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "runtime" 7 | "strings" 8 | 9 | "github.com/babarot/afx/pkg/errors" 10 | ) 11 | 12 | // const errors 13 | var ( 14 | ErrPermission = errors.New("permission denied") 15 | ) 16 | 17 | // isExecutable returns an error if a given file is not an executable. 18 | // https://golang.org/src/os/executable_path.go 19 | func isExecutable(path string) error { 20 | stat, err := os.Stat(path) 21 | if err != nil { 22 | return err 23 | } 24 | mode := stat.Mode() 25 | if !mode.IsRegular() { 26 | return ErrPermission 27 | } 28 | if (mode & 0111) == 0 { 29 | return ErrPermission 30 | } 31 | return nil 32 | } 33 | 34 | func allTrue(list []bool) bool { 35 | if len(list) == 0 { 36 | return false 37 | } 38 | for _, item := range list { 39 | if !item { 40 | return false 41 | } 42 | } 43 | return true 44 | } 45 | 46 | func expandTilda(path string) string { 47 | if !strings.HasPrefix(path, "~") { 48 | return path 49 | } 50 | 51 | home := "" 52 | switch runtime.GOOS { 53 | case "windows": 54 | home = filepath.Join(os.Getenv("HomeDrive"), os.Getenv("HomePath")) 55 | if home == "" { 56 | home = os.Getenv("UserProfile") 57 | } 58 | default: 59 | home = os.Getenv("HOME") 60 | } 61 | 62 | if home == "" { 63 | return path 64 | } 65 | 66 | return home + path[1:] 67 | } 68 | -------------------------------------------------------------------------------- /pkg/data/data.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "os" 5 | "runtime" 6 | "strings" 7 | ) 8 | 9 | type Data struct { 10 | Env Env 11 | Runtime Runtime 12 | Package Package 13 | Release Release 14 | } 15 | 16 | type Env map[string]string 17 | 18 | type Runtime struct { 19 | Goos string 20 | Goarch string 21 | } 22 | 23 | type PackageInterface interface { 24 | GetHome() string 25 | GetName() string 26 | } 27 | 28 | type Package struct { 29 | Name string 30 | Home string 31 | } 32 | 33 | type Release struct { 34 | Name string 35 | Tag string 36 | } 37 | 38 | func New(fields ...func(*Data)) *Data { 39 | d := &Data{ 40 | Package: Package{}, 41 | Release: Release{}, 42 | Env: ToEnv(os.Environ()), 43 | Runtime: Runtime{ 44 | Goos: runtime.GOOS, 45 | Goarch: runtime.GOARCH, 46 | }, 47 | } 48 | for _, f := range fields { 49 | f(d) 50 | } 51 | return d 52 | } 53 | 54 | func WithPackage(pkg PackageInterface) func(*Data) { 55 | return func(d *Data) { 56 | d.Package = Package{ 57 | Home: pkg.GetHome(), 58 | Name: pkg.GetName(), 59 | } 60 | } 61 | } 62 | 63 | func WithRelease(r Release) func(*Data) { 64 | return func(d *Data) { 65 | d.Release = r 66 | } 67 | } 68 | 69 | // ToEnv converts a list of strings to an Env (aka a map[string]string). 70 | func ToEnv(env []string) Env { 71 | r := Env{} 72 | for _, e := range env { 73 | p := strings.SplitN(e, "=", 2) 74 | if len(p) != 2 || p[0] == "" { 75 | continue 76 | } 77 | r[p[0]] = p[1] 78 | } 79 | return r 80 | } 81 | -------------------------------------------------------------------------------- /pkg/dependency/dependency.go: -------------------------------------------------------------------------------- 1 | package dependency 2 | 3 | // This package is heavily inspired from 4 | // https://github.com/dnaeon/go-dependency-graph-algorithm 5 | // E.g. let's say these dependencies are defined 6 | // C -> A 7 | // D -> A 8 | // A -> B 9 | // B -> X 10 | // then this package allows to resolve this dependency to this chain(order). 11 | // X 12 | // B 13 | // A 14 | // C 15 | // D 16 | // Read more about it and the motivation behind it at 17 | // http://dnaeon.github.io/dependency-graph-resolution-algorithm-in-go/ 18 | 19 | import ( 20 | "bytes" 21 | "errors" 22 | "fmt" 23 | 24 | mapset "github.com/deckarep/golang-set" 25 | ) 26 | 27 | // Node represents a single node in the graph with it's dependencies 28 | type Node struct { 29 | // Name of the node 30 | Name string 31 | 32 | // Dependencies of the node 33 | Deps []string 34 | } 35 | 36 | // NewNode creates a new node 37 | func NewNode(name string, deps ...string) *Node { 38 | n := &Node{ 39 | Name: name, 40 | Deps: deps, 41 | } 42 | 43 | return n 44 | } 45 | 46 | type Graph []*Node 47 | 48 | // Displays the dependency graph 49 | func Display(graph Graph) { 50 | fmt.Printf("%s", graph.String()) 51 | } 52 | 53 | func (g Graph) String() string { 54 | var buf bytes.Buffer 55 | for _, node := range g { 56 | for _, dep := range node.Deps { 57 | fmt.Fprintf(&buf, "* %s -> %s\n", node.Name, dep) 58 | } 59 | } 60 | return buf.String() 61 | } 62 | 63 | func Has(graph Graph) bool { 64 | for _, node := range graph { 65 | if len(node.Deps) > 0 { 66 | return true 67 | } 68 | } 69 | return false 70 | } 71 | 72 | // Resolves the dependency graph 73 | func Resolve(graph Graph) (Graph, error) { 74 | // A map containing the node names and the actual node object 75 | nodeNames := make(map[string]*Node) 76 | 77 | // A map containing the nodes and their dependencies 78 | nodeDependencies := make(map[string]mapset.Set) 79 | 80 | // Populate the maps 81 | for _, node := range graph { 82 | nodeNames[node.Name] = node 83 | 84 | dependencySet := mapset.NewSet() 85 | for _, dep := range node.Deps { 86 | dependencySet.Add(dep) 87 | } 88 | nodeDependencies[node.Name] = dependencySet 89 | } 90 | 91 | // Iteratively find and remove nodes from the graph which have no dependencies. 92 | // If at some point there are still nodes in the graph and we cannot find 93 | // nodes without dependencies, that means we have a circular dependency 94 | var resolved Graph 95 | for len(nodeDependencies) != 0 { 96 | // Get all nodes from the graph which have no dependencies 97 | readySet := mapset.NewSet() 98 | for name, deps := range nodeDependencies { 99 | if deps.Cardinality() == 0 { 100 | readySet.Add(name) 101 | } 102 | } 103 | 104 | // If there aren't any ready nodes, then we have a cicular dependency 105 | if readySet.Cardinality() == 0 { 106 | var g Graph 107 | for name := range nodeDependencies { 108 | g = append(g, nodeNames[name]) 109 | } 110 | 111 | return g, errors.New("circular dependency found") 112 | } 113 | 114 | // Remove the ready nodes and add them to the resolved graph 115 | for name := range readySet.Iter() { 116 | delete(nodeDependencies, name.(string)) 117 | resolved = append(resolved, nodeNames[name.(string)]) 118 | } 119 | 120 | // Also make sure to remove the ready nodes from the 121 | // remaining node dependencies as well 122 | for name, deps := range nodeDependencies { 123 | diff := deps.Difference(readySet) 124 | nodeDependencies[name] = diff 125 | } 126 | } 127 | 128 | return resolved, nil 129 | } 130 | -------------------------------------------------------------------------------- /pkg/env/config.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io/ioutil" 7 | "os" 8 | 9 | "github.com/AlecAivazis/survey/v2" 10 | ) 11 | 12 | // Config represents data of environment variables and cache file path 13 | type Config struct { 14 | Path string `json:"path"` 15 | Env map[string]Variable `json:"env"` 16 | } 17 | 18 | // Variables is a collection of Variable and its name 19 | type Variables map[string]Variable 20 | 21 | // Variable represents environment variable 22 | type Variable struct { 23 | Value string `json:"value,omitempty"` 24 | Default string `json:"default,omitempty"` 25 | Input Input `json:"input,omitempty"` 26 | } 27 | 28 | // Input represents value input from terminal 29 | type Input struct { 30 | When bool `json:"when,omitempty"` 31 | Message string `json:"message,omitempty"` 32 | Help string `json:"help,omitempty"` 33 | } 34 | 35 | // New creates Config instance 36 | func New(path string) *Config { 37 | cfg := &Config{ 38 | Path: path, 39 | Env: map[string]Variable{}, 40 | } 41 | if _, err := os.Stat(path); err == nil { 42 | // already exist 43 | cfg.read() 44 | } 45 | return cfg 46 | } 47 | 48 | // Add adds environment variable with given key and given value 49 | func (c *Config) Add(args ...interface{}) error { 50 | switch len(args) { 51 | case 0: 52 | return errors.New("one or two args required") 53 | case 1: 54 | switch args[0].(type) { 55 | case Variables: 56 | variables := args[0].(Variables) 57 | for name, v := range variables { 58 | c.add(name, v) 59 | } 60 | return nil 61 | default: 62 | return errors.New("args type should be Variables") 63 | } 64 | case 2: 65 | name, ok := args[0].(string) 66 | if !ok { 67 | return errors.New("args[0] type should be string") 68 | } 69 | v, ok := args[1].(Variable) 70 | if !ok { 71 | return errors.New("args[1] type should be Variable") 72 | } 73 | c.add(name, v) 74 | return nil 75 | default: 76 | return errors.New("too many arguments") 77 | } 78 | } 79 | 80 | func (c *Config) add(name string, v Variable) { 81 | defer c.save() 82 | 83 | existing, exist := c.Env[name] 84 | if exist { 85 | v.Value = existing.Value 86 | } 87 | 88 | if v.Value != os.Getenv(name) && os.Getenv(name) != "" { 89 | v.Value = os.Getenv(name) 90 | } 91 | if v.Value == "" { 92 | v.Value = os.Getenv(name) 93 | } 94 | if v.Value == "" { 95 | v.Value = v.Default 96 | } 97 | 98 | os.Setenv(name, v.Value) 99 | c.Env[name] = v 100 | } 101 | 102 | // Refresh deletes existing file cache 103 | func (c *Config) Refresh() error { 104 | return c.delete() 105 | } 106 | 107 | // Ask asks the user for input using the given query 108 | func (c *Config) Ask(keys ...string) { 109 | var update bool 110 | for _, key := range keys { 111 | v, found := c.Env[key] 112 | if !found { 113 | continue 114 | } 115 | if len(v.Value) > 0 { 116 | continue 117 | } 118 | if !v.Input.When { 119 | continue 120 | } 121 | var opts []survey.AskOpt 122 | opts = append(opts, survey.WithValidator(survey.Required)) 123 | survey.AskOne(&survey.Password{ 124 | Message: v.Input.Message, 125 | Help: v.Input.Help, 126 | }, &v.Value, opts...) 127 | c.Env[key] = v 128 | os.Setenv(key, v.Value) 129 | update = true 130 | } 131 | if update { 132 | c.save() 133 | } 134 | } 135 | 136 | func (c *Config) AskWhen(env map[string]bool) { 137 | var update bool 138 | for key, when := range env { 139 | v, found := c.Env[key] 140 | if !found { 141 | continue 142 | } 143 | if len(v.Value) > 0 { 144 | continue 145 | } 146 | if !when { 147 | continue 148 | } 149 | var opts []survey.AskOpt 150 | opts = append(opts, survey.WithValidator(survey.Required)) 151 | survey.AskOne(&survey.Password{ 152 | Message: v.Input.Message, 153 | Help: v.Input.Help, 154 | }, &v.Value, opts...) 155 | c.Env[key] = v 156 | os.Setenv(key, v.Value) 157 | update = true 158 | } 159 | if update { 160 | c.save() 161 | } 162 | } 163 | 164 | func (c *Config) read() error { 165 | _, err := os.Stat(c.Path) 166 | if err != nil { 167 | return err 168 | } 169 | 170 | content, err := ioutil.ReadFile(c.Path) 171 | if err != nil { 172 | return err 173 | } 174 | 175 | return json.Unmarshal(content, &c) 176 | } 177 | 178 | func (c *Config) save() error { 179 | cfg := Config{Path: c.Path, Env: make(map[string]Variable)} 180 | // Remove empty variable from c.Env 181 | // to avoid adding empty item to cache 182 | for name, v := range c.Env { 183 | if v.Value == "" && v.Default == "" { 184 | continue 185 | } 186 | cfg.Env[name] = v 187 | } 188 | 189 | f, err := os.Create(c.Path) 190 | if err != nil { 191 | return err 192 | } 193 | return json.NewEncoder(f).Encode(cfg) 194 | } 195 | 196 | func (c *Config) delete() error { 197 | return os.Remove(c.Path) 198 | } 199 | -------------------------------------------------------------------------------- /pkg/errors/errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // Errors is an error type to track multiple errors. This is used to 9 | // accumulate errors in cases and return them as a single "error". 10 | type Errors []error 11 | 12 | func (e *Errors) Error() string { 13 | format := func(text string) string { 14 | var s string 15 | lines := strings.Split(text, "\n") 16 | switch len(lines) { 17 | default: 18 | s += lines[0] 19 | for _, line := range lines[1:] { 20 | s += fmt.Sprintf("\n\t %s", line) 21 | } 22 | case 0: 23 | s = (*e)[0].Error() 24 | } 25 | return s 26 | } 27 | 28 | if len(*e) == 1 { 29 | if (*e)[0] == nil { 30 | return "" 31 | } 32 | return fmt.Sprintf("1 error occurred:\n\t* %s\n\n", format((*e)[0].Error())) 33 | } 34 | 35 | var points []string 36 | for _, err := range *e { 37 | if err == nil { 38 | continue 39 | } 40 | points = append(points, fmt.Sprintf("* %s", format(err.Error()))) 41 | } 42 | 43 | return fmt.Sprintf( 44 | "%d errors occurred:\n\t%s\n\n", 45 | len(points), strings.Join(points, "\n\t")) 46 | } 47 | 48 | func (e *Errors) Append(errs ...error) { 49 | if e == nil { 50 | e = new(Errors) 51 | } 52 | for _, err := range errs { 53 | if err != nil { 54 | *e = append(*e, err) 55 | } 56 | } 57 | } 58 | 59 | // ErrorOrNil returns an error interface if this Error represents 60 | // a list of errors, or returns nil if the list of errors is empty. This 61 | // function is useful at the end of accumulation to make sure that the value 62 | // returned represents the existence of errors. 63 | func (e *Errors) ErrorOrNil() error { 64 | if e == nil { 65 | return nil 66 | } 67 | if len(*e) == 0 { 68 | return nil 69 | } 70 | 71 | return e 72 | } 73 | -------------------------------------------------------------------------------- /pkg/errors/wrap.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | ) 6 | 7 | func New(messages ...string) error { 8 | var e Errors 9 | for _, message := range messages { 10 | e.Append(errors.New(message)) 11 | } 12 | return e.ErrorOrNil() 13 | } 14 | 15 | func Wrap(err error, message string) error { 16 | return errors.Wrap(err, message) 17 | } 18 | 19 | func Wrapf(err error, format string, args ...interface{}) error { 20 | return errors.Wrapf(err, format, args...) 21 | } 22 | -------------------------------------------------------------------------------- /pkg/github/client.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "io/ioutil" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/babarot/afx/pkg/errors" 11 | ) 12 | 13 | // ClientOption represents an argument to NewClient 14 | type ClientOption = func(http.RoundTripper) http.RoundTripper 15 | 16 | // NewHTTPClient initializes an http.Client 17 | func NewHTTPClient(opts ...ClientOption) *http.Client { 18 | tr := http.DefaultTransport 19 | for _, opt := range opts { 20 | tr = opt(tr) 21 | } 22 | return &http.Client{Transport: tr} 23 | } 24 | 25 | // NewClient initializes a Client 26 | func NewClient(opts ...ClientOption) *Client { 27 | client := &Client{http: NewHTTPClient(opts...)} 28 | return client 29 | } 30 | 31 | // ReplaceTripper substitutes the underlying RoundTripper with a custom one 32 | func ReplaceTripper(tr http.RoundTripper) ClientOption { 33 | return func(http.RoundTripper) http.RoundTripper { 34 | return tr 35 | } 36 | } 37 | 38 | // Client facilitates making HTTP requests to the GitHub API 39 | type Client struct { 40 | http *http.Client 41 | } 42 | 43 | func (c Client) REST(method string, url string, body io.Reader, data interface{}) error { 44 | req, err := http.NewRequest(method, url, body) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | // to avoid hitting rate limit 50 | // https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting 51 | token := os.Getenv("GITHUB_TOKEN") 52 | if token != "" { 53 | // currently optional 54 | req.Header.Set("Authorization", "token "+token) 55 | } 56 | req.Header.Set("Content-Type", "application/json; charset=utf-8") 57 | 58 | resp, err := c.http.Do(req) 59 | if err != nil { 60 | return err 61 | } 62 | defer resp.Body.Close() 63 | 64 | success := resp.StatusCode >= 200 && resp.StatusCode < 300 65 | if !success { 66 | body, err := ioutil.ReadAll(resp.Body) 67 | if err != nil { 68 | return err 69 | } 70 | return errors.New(string(body)) 71 | } 72 | 73 | if resp.StatusCode == http.StatusNoContent { 74 | return nil 75 | } 76 | 77 | b, err := ioutil.ReadAll(resp.Body) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | err = json.Unmarshal(b, &data) 83 | if err != nil { 84 | return err 85 | } 86 | 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /pkg/github/github.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | "os" 11 | "path/filepath" 12 | "regexp" 13 | "runtime" 14 | "strings" 15 | 16 | "github.com/babarot/afx/pkg/errors" 17 | "github.com/babarot/afx/pkg/logging" 18 | "github.com/inconshreveable/go-update" 19 | "github.com/mholt/archiver" 20 | "github.com/schollz/progressbar/v3" 21 | ) 22 | 23 | // Release represents a GitHub release and its client 24 | // A difference from Release is whether a client or not 25 | type Release struct { 26 | Name string 27 | Tag string 28 | Assets Assets 29 | 30 | client *Client 31 | workdir string 32 | verbose bool 33 | overwrite bool 34 | filter func(Assets) *Asset 35 | } 36 | 37 | // Asset represents GitHub release's asset. 38 | // Basically this means one archive file attached in a release 39 | type Asset struct { 40 | Name string 41 | URL string 42 | } 43 | 44 | // ReleaseResponse is a response of github release structure 45 | // TODO: This may be better to become same one strucure as above 46 | type ReleaseResponse struct { 47 | TagName string `json:"tag_name"` 48 | Assets []AssetsResponse `json:"assets"` 49 | } 50 | 51 | type AssetsResponse struct { 52 | Name string `json:"name"` 53 | BrowserDownloadURL string `json:"browser_download_url"` 54 | } 55 | 56 | type Assets []Asset 57 | 58 | func (as *Assets) filter(fn func(Asset) bool) *Assets { 59 | var assets Assets 60 | if len(*as) < 2 { 61 | // no more need to filter 62 | log.Printf("[DEBUG] assets.filter: finished filtering because length of assets is less than two") 63 | return as 64 | } 65 | 66 | for _, asset := range *as { 67 | if fn(asset) { 68 | assets = append(assets, asset) 69 | } 70 | } 71 | 72 | // logging if assets are changed by filter 73 | if len(*as) != len(assets) { 74 | log.Printf("[DEBUG] assets.filter: filtered: %#v", getAssetKeys(assets)) 75 | } 76 | 77 | *as = assets 78 | return as 79 | } 80 | 81 | func getAssetKeys(assets []Asset) []string { 82 | var names []string 83 | for _, asset := range assets { 84 | names = append(names, asset.Name) 85 | } 86 | return names 87 | } 88 | 89 | type Option func(r *Release) 90 | 91 | type FilterFunc func(assets Assets) *Asset 92 | 93 | func WithOverwrite() Option { 94 | return func(r *Release) { 95 | r.overwrite = true 96 | } 97 | } 98 | 99 | func WithWorkdir(workdir string) Option { 100 | return func(r *Release) { 101 | r.workdir = workdir 102 | } 103 | } 104 | 105 | func WithVerbose() Option { 106 | return func(r *Release) { 107 | r.verbose = true 108 | } 109 | } 110 | 111 | func WithFilter(filter func(Assets) *Asset) Option { 112 | return func(r *Release) { 113 | r.filter = filter 114 | } 115 | } 116 | 117 | func NewRelease(ctx context.Context, owner, repo, tag string, opts ...Option) (*Release, error) { 118 | if owner == "" || repo == "" { 119 | return nil, errors.New("owner and repo are required") 120 | } 121 | 122 | releaseURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", owner, repo) 123 | switch tag { 124 | case "latest", "": 125 | releaseURL += "/latest" 126 | default: 127 | releaseURL += fmt.Sprintf("/tags/%s", tag) 128 | } 129 | log.Printf("[DEBUG] getting asset data from %s", releaseURL) 130 | 131 | var resp ReleaseResponse 132 | client := NewClient( 133 | ReplaceTripper(logging.NewTransport("GitHub", http.DefaultTransport)), 134 | ) 135 | err := client.REST(http.MethodGet, releaseURL, nil, &resp) 136 | if err != nil { 137 | return nil, err 138 | } 139 | 140 | var assets []Asset 141 | for _, asset := range resp.Assets { 142 | assets = append(assets, Asset{ 143 | Name: asset.Name, 144 | URL: asset.BrowserDownloadURL, 145 | }) 146 | } 147 | 148 | tmp, err := ioutil.TempDir("", repo) 149 | if err != nil { 150 | return nil, err 151 | } 152 | 153 | release := &Release{ 154 | Name: repo, 155 | Tag: resp.TagName, 156 | Assets: assets, 157 | client: client, 158 | workdir: tmp, 159 | verbose: false, 160 | filter: nil, 161 | } 162 | 163 | for _, o := range opts { 164 | o(release) 165 | } 166 | 167 | return release, nil 168 | } 169 | 170 | func (r *Release) filterAssets() (Asset, error) { 171 | log.Printf("[DEBUG] assets: %#v\n", getAssetKeys(r.Assets)) 172 | 173 | if len(r.Assets) == 0 { 174 | return Asset{}, errors.New("no assets") 175 | } 176 | 177 | if r.filter != nil { 178 | log.Printf("[DEBUG] asset: filterfunc: started running") 179 | asset := r.filter(r.Assets) 180 | if asset != nil { 181 | log.Printf("[DEBUG] asset: filterfunc: matched in assets") 182 | return *asset, nil 183 | } 184 | log.Printf("[DEBUG] asset: filterfunc: not matched in assets") 185 | return Asset{}, errors.New("could not find assets with given name") 186 | } 187 | 188 | log.Printf("[DEBUG] asset: %s: using default assets filter", r.Name) 189 | assets := *r.Assets. 190 | filter(func(asset Asset) bool { 191 | expr := `.*\.sbom` 192 | // filter out 193 | return !regexp.MustCompile(expr).MatchString(asset.Name) 194 | }). 195 | filter(func(asset Asset) bool { 196 | expr := ".*(sha256sum|checksum).*" 197 | // filter out 198 | return !regexp.MustCompile(expr).MatchString(asset.Name) 199 | }). 200 | filter(func(asset Asset) bool { 201 | expr := "" 202 | switch runtime.GOOS { 203 | case "darwin": 204 | expr += ".*(apple|darwin|Darwin|osx|mac|macos|macOS).*" 205 | case "linux": 206 | expr += ".*(linux|hoe).*" 207 | } 208 | return regexp.MustCompile(expr).MatchString(asset.Name) 209 | }). 210 | filter(func(asset Asset) bool { 211 | expr := "" 212 | switch runtime.GOARCH { 213 | case "amd64": 214 | expr += ".*(amd64|x86.64).*" 215 | case "386": 216 | expr += ".*(386|86).*" 217 | } 218 | return regexp.MustCompile(expr).MatchString(asset.Name) 219 | }) 220 | 221 | switch len(assets) { 222 | case 0: 223 | return Asset{}, errors.New("asset not found after filtered") 224 | case 1: 225 | return assets[0], nil 226 | default: 227 | log.Printf("[WARN] %d assets found: %#v", len(assets), getAssetKeys(assets)) 228 | log.Printf("[WARN] first one %q will be used", assets[0].Name) 229 | return assets[0], nil 230 | } 231 | } 232 | 233 | // Download downloads GitHub Release from given page 234 | func (r *Release) Download(ctx context.Context) (Asset, error) { 235 | ctx, cancel := context.WithCancel(ctx) 236 | defer cancel() 237 | 238 | asset, err := r.filterAssets() 239 | if err != nil { 240 | log.Printf("[ERROR] %s: could not find assets available on your system", r.Name) 241 | return asset, err 242 | } 243 | 244 | log.Printf("[DEBUG] asset: %#v", asset) 245 | 246 | req, err := http.NewRequest(http.MethodGet, asset.URL, nil) 247 | if err != nil { 248 | return asset, err 249 | } 250 | 251 | httpClient := http.DefaultClient 252 | resp, err := httpClient.Do(req.WithContext(ctx)) 253 | if err != nil { 254 | return asset, err 255 | } 256 | defer resp.Body.Close() 257 | 258 | os.MkdirAll(r.workdir, os.ModePerm) 259 | archive := filepath.Join(r.workdir, asset.Name) 260 | 261 | file, err := os.Create(archive) 262 | if err != nil { 263 | return asset, errors.Wrapf(err, "%s: failed to create file", archive) 264 | } 265 | defer file.Close() 266 | 267 | var w io.Writer 268 | if r.verbose { 269 | w = io.MultiWriter(file, progressbar.DefaultBytes( 270 | resp.ContentLength, 271 | "Downloading", 272 | )) 273 | } else { 274 | w = file 275 | } 276 | 277 | _, err = io.Copy(w, resp.Body) 278 | return asset, err 279 | } 280 | 281 | // Unarchive extracts downloaded asset 282 | func (r *Release) Unarchive(asset Asset) error { 283 | archive := filepath.Join(r.workdir, asset.Name) 284 | 285 | uaIface, err := archiver.ByExtension(archive) 286 | if err != nil { 287 | // err: this will be an error of format unrecognized by filename 288 | // but in this case, maybe not archived file: e.g. tigrawap/slit 289 | // 290 | log.Printf("[WARN] archiver.ByExtension(): %v", err) 291 | 292 | // TODO: remove this logic? 293 | // thanks to this logic, we don't need to specify this statement to link.from 294 | // 295 | // command: 296 | // link: 297 | // - from: '*jq*' 298 | // 299 | // because this logic renames a binary of 'jq-1.6' to 'jq' 300 | // 301 | target := filepath.Join(r.workdir, r.Name) 302 | if _, err := os.Stat(target); err == nil { 303 | if r.overwrite { 304 | log.Printf("[WARN] %s: already exist. but overwrite", target) 305 | } else { 306 | log.Printf("[WARN] %s: already exist. so skip to unarchive", target) 307 | return nil 308 | } 309 | } 310 | log.Printf("[DEBUG] renamed from %s to %s", archive, target) 311 | os.Rename(archive, target) 312 | os.Chmod(target, 0755) 313 | return nil 314 | } 315 | 316 | tar := &archiver.Tar{ 317 | OverwriteExisting: true, 318 | MkdirAll: false, 319 | ImplicitTopLevelFolder: false, 320 | ContinueOnError: false, 321 | } 322 | switch v := uaIface.(type) { 323 | case *archiver.Rar: 324 | v.OverwriteExisting = true 325 | case *archiver.Zip: 326 | v.OverwriteExisting = true 327 | case *archiver.TarBz2: 328 | v.Tar = tar 329 | case *archiver.TarGz: 330 | v.Tar = tar 331 | case *archiver.TarLz4: 332 | v.Tar = tar 333 | case *archiver.TarSz: 334 | v.Tar = tar 335 | case *archiver.TarXz: 336 | v.Tar = tar 337 | case *archiver.Gz, 338 | *archiver.Bz2, 339 | *archiver.Lz4, 340 | *archiver.Snappy, 341 | *archiver.Xz: 342 | // nothing to customise 343 | } 344 | log.Printf("[DEBUG] uaIface: %#v (%T)", uaIface, uaIface) 345 | 346 | var done bool 347 | 348 | u, ok := uaIface.(archiver.Unarchiver) 349 | if ok { 350 | if err := u.Unarchive(archive, r.workdir); err != nil { 351 | return errors.Wrapf(err, "%s: failed to unarchive", r.Name) 352 | } 353 | done = true 354 | } 355 | 356 | d, ok := uaIface.(archiver.Decompressor) 357 | if ok { 358 | fc := archiver.FileCompressor{Decompressor: d} 359 | name := strings.TrimSuffix(asset.Name, filepath.Ext(asset.Name)) 360 | if err := fc.DecompressFile(archive, filepath.Join(r.workdir, name)); err != nil { 361 | return errors.Wrapf(err, "%s: failed to decompress", r.Name) 362 | } 363 | done = true 364 | } 365 | 366 | if !done { 367 | return errors.New("archiver cannot unarchive/decompress file") 368 | } 369 | 370 | log.Printf("[DEBUG] removed archive file: %s", archive) 371 | os.Remove(archive) 372 | 373 | return nil 374 | } 375 | 376 | // Install instals unarchived packages to given path 377 | func (r *Release) Install(to string) error { 378 | bin := filepath.Join(r.workdir, r.Name) 379 | log.Printf("[DEBUG] release install: %#v", bin) 380 | 381 | fp, err := os.Open(bin) 382 | if err != nil { 383 | return errors.Wrap(err, "failed to open file") 384 | } 385 | defer fp.Close() 386 | 387 | log.Printf("[DEBUG] installing: from %s to %s", bin, to) 388 | return update.Apply(fp, update.Options{ 389 | TargetPath: to, 390 | }) 391 | } 392 | 393 | func HasRelease(httpClient *http.Client, owner, repo, tag string) (bool, error) { 394 | // https://github.com/cli/cli/blob/9596fd5368cdbd30d08555266890a2312e22eba9/pkg/cmd/extension/http.go#L110 395 | releaseURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", owner, repo) 396 | switch tag { 397 | case "latest", "": 398 | releaseURL += "/latest" 399 | default: 400 | releaseURL += fmt.Sprintf("/tags/%s", tag) 401 | } 402 | req, err := http.NewRequest("GET", releaseURL, nil) 403 | if err != nil { 404 | return false, err 405 | } 406 | 407 | resp, err := httpClient.Do(req) 408 | if err != nil { 409 | return false, err 410 | } 411 | defer resp.Body.Close() 412 | return resp.StatusCode < 299, nil 413 | } 414 | -------------------------------------------------------------------------------- /pkg/github/github_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | -------------------------------------------------------------------------------- /pkg/helpers/shell/shell.go: -------------------------------------------------------------------------------- 1 | package shell 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | "os/exec" 9 | "runtime" 10 | ) 11 | 12 | // Shell represents shell command 13 | type Shell struct { 14 | Stdin io.Reader 15 | Stdout io.Writer 16 | Stderr io.Writer 17 | Env map[string]string 18 | Command string 19 | Args []string 20 | Dir string 21 | } 22 | 23 | // New returns Shell instance 24 | func New(command string, args ...string) Shell { 25 | return Shell{ 26 | Stdin: os.Stdin, 27 | Stdout: os.Stdout, 28 | Stderr: os.Stderr, 29 | Env: map[string]string{}, 30 | Command: command, 31 | Args: args, 32 | } 33 | } 34 | 35 | // Run runs shell command based on given command and args 36 | func (s Shell) Run(ctx context.Context) error { 37 | command := s.Command 38 | if _, err := exec.LookPath(command); err != nil { 39 | return err 40 | } 41 | for _, arg := range s.Args { 42 | command += " " + arg 43 | } 44 | var cmd *exec.Cmd 45 | if runtime.GOOS == "windows" { 46 | cmd = exec.CommandContext(ctx, "cmd", "/c", command) 47 | } else { 48 | cmd = exec.CommandContext(ctx, "sh", "-c", command) 49 | } 50 | cmd.Stderr = s.Stderr 51 | cmd.Stdout = s.Stdout 52 | cmd.Stdin = s.Stdin 53 | cmd.Dir = s.Dir 54 | for k, v := range s.Env { 55 | cmd.Env = append(os.Environ(), fmt.Sprintf("%s=%s", k, v)) 56 | } 57 | return cmd.Run() 58 | } 59 | 60 | // RunCommand runs command with given arguments 61 | func RunCommand(command string, args ...string) error { 62 | return New(command, args...).Run(context.Background()) 63 | } 64 | -------------------------------------------------------------------------------- /pkg/helpers/spin/spin.go: -------------------------------------------------------------------------------- 1 | package spin 2 | 3 | import ( 4 | "fmt" 5 | "sync/atomic" 6 | "time" 7 | ) 8 | 9 | // ClearLine go to the beginning of the line and clear it 10 | const ClearLine = "\r\033[K" 11 | 12 | // Spinner main type 13 | type Spinner struct { 14 | frames []rune 15 | pos int 16 | active uint64 17 | text string 18 | done string 19 | tpf time.Duration 20 | } 21 | 22 | // Option describes an option to override a default 23 | // when creating a new Spinner. 24 | type Option func(s *Spinner) 25 | 26 | // New creates a Spinner object with the provided 27 | // text. By default, the Default spinner frames are 28 | // used, and new frames are rendered every 100 milliseconds. 29 | // Options can be provided to override these default 30 | // settings. 31 | func New(text string, opts ...Option) *Spinner { 32 | s := &Spinner{ 33 | text: ClearLine + text, 34 | frames: []rune(Default), 35 | tpf: 100 * time.Millisecond, 36 | } 37 | for _, o := range opts { 38 | o(s) 39 | } 40 | return s 41 | } 42 | 43 | // WithFrames sets the frames string. 44 | func WithFrames(frames string) Option { 45 | return func(s *Spinner) { 46 | s.Set(frames) 47 | } 48 | } 49 | 50 | // WithTimePerFrame sets how long each frame shall 51 | // be shown. 52 | func WithTimePerFrame(d time.Duration) Option { 53 | return func(s *Spinner) { 54 | s.tpf = d 55 | } 56 | } 57 | 58 | // WithDoneMessage sets the final message as done. 59 | func WithDoneMessage(text string) Option { 60 | return func(s *Spinner) { 61 | s.done = text 62 | } 63 | } 64 | 65 | // Set frames to the given string which must not use spaces. 66 | func (s *Spinner) Set(frames string) { 67 | s.frames = []rune(frames) 68 | } 69 | 70 | // Start shows the spinner. 71 | func (s *Spinner) Start() *Spinner { 72 | if atomic.LoadUint64(&s.active) > 0 { 73 | return s 74 | } 75 | atomic.StoreUint64(&s.active, 1) 76 | go func() { 77 | for atomic.LoadUint64(&s.active) > 0 { 78 | fmt.Printf(s.text, s.next()) 79 | time.Sleep(s.tpf) 80 | } 81 | }() 82 | return s 83 | } 84 | 85 | // Stop hides the spinner. 86 | func (s *Spinner) Stop() bool { 87 | if x := atomic.SwapUint64(&s.active, 0); x > 0 { 88 | fmt.Printf(ClearLine) 89 | if s.done != "" { 90 | fmt.Printf(s.done) 91 | } 92 | return true 93 | } 94 | return false 95 | } 96 | 97 | func (s *Spinner) next() string { 98 | r := s.frames[s.pos%len(s.frames)] 99 | s.pos++ 100 | return string(r) 101 | } 102 | -------------------------------------------------------------------------------- /pkg/helpers/spin/symbol.go: -------------------------------------------------------------------------------- 1 | package spin 2 | 3 | // Spinner types. 4 | var ( 5 | Box1 = `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏` 6 | Box2 = `⠋⠙⠚⠞⠖⠦⠴⠲⠳⠓` 7 | Box3 = `⠄⠆⠇⠋⠙⠸⠰⠠⠰⠸⠙⠋⠇⠆` 8 | Box4 = `⠋⠙⠚⠒⠂⠂⠒⠲⠴⠦⠖⠒⠐⠐⠒⠓⠋` 9 | Box5 = `⠁⠉⠙⠚⠒⠂⠂⠒⠲⠴⠤⠄⠄⠤⠴⠲⠒⠂⠂⠒⠚⠙⠉⠁` 10 | Box6 = `⠈⠉⠋⠓⠒⠐⠐⠒⠖⠦⠤⠠⠠⠤⠦⠖⠒⠐⠐⠒⠓⠋⠉⠈` 11 | Box7 = `⠁⠁⠉⠙⠚⠒⠂⠂⠒⠲⠴⠤⠄⠄⠤⠠⠠⠤⠦⠖⠒⠐⠐⠒⠓⠋⠉⠈⠈` 12 | Spin1 = `|/-\` 13 | Spin2 = `◴◷◶◵` 14 | Spin3 = `◰◳◲◱` 15 | Spin4 = `◐◓◑◒` 16 | Spin5 = `▉▊▋▌▍▎▏▎▍▌▋▊▉` 17 | Spin6 = `▌▄▐▀` 18 | Spin7 = `╫╪` 19 | Spin8 = `■□▪▫` 20 | Spin9 = `←↑→↓` 21 | Spin10 = `⦾⦿` 22 | Spin11 = `⌜⌝⌟⌞` 23 | Spin12 = `┤┘┴└├┌┬┐` 24 | Spin13 = `⇑⇗⇒⇘⇓⇙⇐⇖` 25 | Spin14 = `☰☱☳☷☶☴` 26 | Spin15 = `䷀䷪䷡䷊䷒䷗䷁䷖䷓䷋䷠䷫` 27 | Default = Box1 28 | ) 29 | -------------------------------------------------------------------------------- /pkg/helpers/templates/README.md: -------------------------------------------------------------------------------- 1 | # Kubernetes template function 2 | 3 | These files are copied from https://github.com/kubernetes/kubectl/tree/master/pkg/util/templates to make cobra output prettier. 4 | 5 | If not copying them it would pull in entire kubernetes and increase binary by 15mb. So it is easier to just include those files directly in code. 6 | 7 | No modifications have been done 8 | -------------------------------------------------------------------------------- /pkg/helpers/templates/markdown.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2016 The Kubernetes Authors. 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and 11 | limitations under the License. 12 | */ 13 | 14 | package templates 15 | 16 | import ( 17 | "bytes" 18 | "fmt" 19 | "io" 20 | "strings" 21 | 22 | "github.com/russross/blackfriday" 23 | ) 24 | 25 | const linebreak = "\n" 26 | 27 | // ASCIIRenderer implements blackfriday.Renderer 28 | var _ blackfriday.Renderer = &ASCIIRenderer{} 29 | 30 | // ASCIIRenderer is a blackfriday.Renderer intended for rendering markdown 31 | // documents as plain text, well suited for human reading on terminals. 32 | type ASCIIRenderer struct { 33 | Indentation string 34 | 35 | listItemCount uint 36 | listLevel uint 37 | } 38 | 39 | // NormalText gets a text chunk *after* the markdown syntax was already 40 | // processed and does a final cleanup on things we don't expect here, like 41 | // removing linebreaks on things that are not a paragraph break (auto unwrap). 42 | func (r *ASCIIRenderer) NormalText(out *bytes.Buffer, text []byte) { 43 | raw := string(text) 44 | lines := strings.Split(raw, linebreak) 45 | for _, line := range lines { 46 | trimmed := strings.Trim(line, " \n\t") 47 | if len(trimmed) > 0 && trimmed[0] != '_' { 48 | out.WriteString(" ") 49 | } 50 | out.WriteString(trimmed) 51 | } 52 | } 53 | 54 | // List renders the start and end of a list. 55 | func (r *ASCIIRenderer) List(out *bytes.Buffer, text func() bool, flags int) { 56 | r.listLevel++ 57 | out.WriteString(linebreak) 58 | text() 59 | r.listLevel-- 60 | } 61 | 62 | // ListItem renders list items and supports both ordered and unordered lists. 63 | func (r *ASCIIRenderer) ListItem(out *bytes.Buffer, text []byte, flags int) { 64 | if flags&blackfriday.LIST_ITEM_BEGINNING_OF_LIST != 0 { 65 | r.listItemCount = 1 66 | } else { 67 | r.listItemCount++ 68 | } 69 | indent := strings.Repeat(r.Indentation, int(r.listLevel)) 70 | var bullet string 71 | if flags&blackfriday.LIST_TYPE_ORDERED != 0 { 72 | bullet += fmt.Sprintf("%d.", r.listItemCount) 73 | } else { 74 | bullet += "*" 75 | } 76 | out.WriteString(indent + bullet + " ") 77 | r.fw(out, text) 78 | out.WriteString(linebreak) 79 | } 80 | 81 | // Paragraph renders the start and end of a paragraph. 82 | func (r *ASCIIRenderer) Paragraph(out *bytes.Buffer, text func() bool) { 83 | out.WriteString(linebreak) 84 | text() 85 | out.WriteString(linebreak) 86 | } 87 | 88 | // BlockCode renders a chunk of text that represents source code. 89 | func (r *ASCIIRenderer) BlockCode(out *bytes.Buffer, text []byte, lang string) { 90 | out.WriteString(linebreak) 91 | lines := []string{} 92 | for _, line := range strings.Split(string(text), linebreak) { 93 | indented := r.Indentation + line 94 | lines = append(lines, indented) 95 | } 96 | out.WriteString(strings.Join(lines, linebreak)) 97 | } 98 | 99 | // GetFlags always returns 0 100 | func (r *ASCIIRenderer) GetFlags() int { return 0 } 101 | 102 | // HRule returns horizontal line 103 | func (r *ASCIIRenderer) HRule(out *bytes.Buffer) { 104 | out.WriteString(linebreak + "----------" + linebreak) 105 | } 106 | 107 | // LineBreak returns a line break 108 | func (r *ASCIIRenderer) LineBreak(out *bytes.Buffer) { out.WriteString(linebreak) } 109 | 110 | // TitleBlock writes title block 111 | func (r *ASCIIRenderer) TitleBlock(out *bytes.Buffer, text []byte) { r.fw(out, text) } 112 | 113 | // Header writes header 114 | func (r *ASCIIRenderer) Header(out *bytes.Buffer, text func() bool, level int, id string) { text() } 115 | 116 | // BlockHtml writes htlm 117 | func (r *ASCIIRenderer) BlockHtml(out *bytes.Buffer, text []byte) { r.fw(out, text) } 118 | 119 | // BlockQuote writes block 120 | func (r *ASCIIRenderer) BlockQuote(out *bytes.Buffer, text []byte) { r.fw(out, text) } 121 | 122 | // TableRow writes table row 123 | func (r *ASCIIRenderer) TableRow(out *bytes.Buffer, text []byte) { r.fw(out, text) } 124 | 125 | // TableHeaderCell writes table header cell 126 | func (r *ASCIIRenderer) TableHeaderCell(out *bytes.Buffer, text []byte, align int) { r.fw(out, text) } 127 | 128 | // TableCell writes table cell 129 | func (r *ASCIIRenderer) TableCell(out *bytes.Buffer, text []byte, align int) { r.fw(out, text) } 130 | 131 | // Footnotes writes footnotes 132 | func (r *ASCIIRenderer) Footnotes(out *bytes.Buffer, text func() bool) { text() } 133 | 134 | // FootnoteItem writes footnote item 135 | func (r *ASCIIRenderer) FootnoteItem(out *bytes.Buffer, name, text []byte, flags int) { r.fw(out, text) } 136 | 137 | // AutoLink writes autolink 138 | func (r *ASCIIRenderer) AutoLink(out *bytes.Buffer, link []byte, kind int) { r.fw(out, link) } 139 | 140 | // CodeSpan writes code span 141 | func (r *ASCIIRenderer) CodeSpan(out *bytes.Buffer, text []byte) { r.fw(out, text) } 142 | 143 | // DoubleEmphasis writes double emphasis 144 | func (r *ASCIIRenderer) DoubleEmphasis(out *bytes.Buffer, text []byte) { r.fw(out, text) } 145 | 146 | // Emphasis writes emphasis 147 | func (r *ASCIIRenderer) Emphasis(out *bytes.Buffer, text []byte) { r.fw(out, text) } 148 | 149 | // RawHtmlTag writes raw htlm tag 150 | func (r *ASCIIRenderer) RawHtmlTag(out *bytes.Buffer, text []byte) { r.fw(out, text) } 151 | 152 | // TripleEmphasis writes triple emphasis 153 | func (r *ASCIIRenderer) TripleEmphasis(out *bytes.Buffer, text []byte) { r.fw(out, text) } 154 | 155 | // StrikeThrough writes strike through 156 | func (r *ASCIIRenderer) StrikeThrough(out *bytes.Buffer, text []byte) { r.fw(out, text) } 157 | 158 | // FootnoteRef writes footnote ref 159 | func (r *ASCIIRenderer) FootnoteRef(out *bytes.Buffer, ref []byte, id int) { r.fw(out, ref) } 160 | 161 | // Entity writes entity 162 | func (r *ASCIIRenderer) Entity(out *bytes.Buffer, entity []byte) { r.fw(out, entity) } 163 | 164 | // Smartypants writes smartypants 165 | func (r *ASCIIRenderer) Smartypants(out *bytes.Buffer, text []byte) { r.fw(out, text) } 166 | 167 | // DocumentHeader does nothing 168 | func (r *ASCIIRenderer) DocumentHeader(out *bytes.Buffer) {} 169 | 170 | // DocumentFooter does nothing 171 | func (r *ASCIIRenderer) DocumentFooter(out *bytes.Buffer) {} 172 | 173 | // TocHeaderWithAnchor does nothing 174 | func (r *ASCIIRenderer) TocHeaderWithAnchor(text []byte, level int, anchor string) {} 175 | 176 | // TocHeader does nothing 177 | func (r *ASCIIRenderer) TocHeader(text []byte, level int) {} 178 | 179 | // TocFinalize does nothing 180 | func (r *ASCIIRenderer) TocFinalize() {} 181 | 182 | // Table writes a table 183 | func (r *ASCIIRenderer) Table(out *bytes.Buffer, header []byte, body []byte, columnData []int) { 184 | r.fw(out, header, body) 185 | } 186 | 187 | // Link writes a link 188 | func (r *ASCIIRenderer) Link(out *bytes.Buffer, link []byte, title []byte, content []byte) { 189 | r.fw(out, link) 190 | } 191 | 192 | // Image writes image 193 | func (r *ASCIIRenderer) Image(out *bytes.Buffer, link []byte, title []byte, alt []byte) { 194 | r.fw(out, link) 195 | } 196 | 197 | func (r *ASCIIRenderer) fw(out io.Writer, text ...[]byte) { 198 | for _, t := range text { 199 | out.Write(t) 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /pkg/helpers/templates/normalizer.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2016 The Kubernetes Authors. 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and 11 | limitations under the License. 12 | */ 13 | 14 | package templates 15 | 16 | import ( 17 | "strings" 18 | 19 | "github.com/MakeNowJust/heredoc" 20 | "github.com/russross/blackfriday" 21 | "github.com/spf13/cobra" 22 | ) 23 | 24 | // Indentation string to use for indents 25 | const Indentation = ` ` 26 | 27 | // LongDesc normalizes a command's long description to follow the conventions. 28 | func LongDesc(s string) string { 29 | if len(s) == 0 { 30 | return s 31 | } 32 | return normalizer{s}.heredoc().markdown().trim().string 33 | } 34 | 35 | // Examples normalizes a command's examples to follow the conventions. 36 | func Examples(s string) string { 37 | if len(s) == 0 { 38 | return s 39 | } 40 | return normalizer{s}.trim().indent().string 41 | } 42 | 43 | // Normalize perform all required normalizations on a given command. 44 | func Normalize(cmd *cobra.Command) *cobra.Command { 45 | if len(cmd.Long) > 0 { 46 | cmd.Long = LongDesc(cmd.Long) 47 | } 48 | if len(cmd.Example) > 0 { 49 | cmd.Example = Examples(cmd.Example) 50 | } 51 | return cmd 52 | } 53 | 54 | // NormalizeAll perform all required normalizations in the entire command tree. 55 | func NormalizeAll(cmd *cobra.Command) *cobra.Command { 56 | if cmd.HasSubCommands() { 57 | for _, subCmd := range cmd.Commands() { 58 | NormalizeAll(subCmd) 59 | } 60 | } 61 | Normalize(cmd) 62 | return cmd 63 | } 64 | 65 | type normalizer struct { 66 | string 67 | } 68 | 69 | func (s normalizer) markdown() normalizer { 70 | bytes := []byte(s.string) 71 | formatted := blackfriday.Markdown(bytes, &ASCIIRenderer{Indentation: Indentation}, blackfriday.EXTENSION_NO_INTRA_EMPHASIS) 72 | s.string = string(formatted) 73 | return s 74 | } 75 | 76 | func (s normalizer) heredoc() normalizer { 77 | s.string = heredoc.Doc(s.string) 78 | return s 79 | } 80 | 81 | func (s normalizer) trim() normalizer { 82 | s.string = strings.TrimSpace(s.string) 83 | return s 84 | } 85 | 86 | func (s normalizer) indent() normalizer { 87 | indentedLines := []string{} 88 | for _, line := range strings.Split(s.string, "\n") { 89 | trimmed := strings.TrimSpace(line) 90 | indented := Indentation + trimmed 91 | indentedLines = append(indentedLines, indented) 92 | } 93 | s.string = strings.Join(indentedLines, "\n") 94 | return s 95 | } 96 | 97 | // Added by me 98 | func (s normalizer) space() normalizer { 99 | indentedLines := []string{} 100 | for _, line := range strings.Split(s.string, "\n") { 101 | indented := Indentation + line 102 | indentedLines = append(indentedLines, indented) 103 | } 104 | s.string = strings.Join(indentedLines, "\n") 105 | return s 106 | } 107 | 108 | func Raw(s string) string { 109 | if len(s) == 0 { 110 | return s 111 | } 112 | return normalizer{s}.heredoc().space().string 113 | } 114 | -------------------------------------------------------------------------------- /pkg/logging/logging.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "io" 5 | "io/ioutil" 6 | "log" 7 | "os" 8 | "strings" 9 | "syscall" 10 | 11 | "github.com/hashicorp/logutils" 12 | ) 13 | 14 | // These are the environmental variables that determine if we log, and if 15 | // we log whether or not the log should go to a file. 16 | const ( 17 | EnvLog = "AFX_LOG" 18 | EnvLogFile = "AFX_LOG_PATH" 19 | ) 20 | 21 | // ValidLevels is a list of valid log levels 22 | var ValidLevels = []logutils.LogLevel{"TRACE", "DEBUG", "INFO", "WARN", "ERROR"} 23 | 24 | // LogOutput determines where we should send logs (if anywhere) and the log level. 25 | func LogOutput() (logOutput io.Writer, err error) { 26 | logOutput = ioutil.Discard 27 | 28 | logLevel := LogLevel() 29 | if logLevel == "" { 30 | return 31 | } 32 | 33 | logOutput = os.Stderr 34 | if logPath := os.Getenv(EnvLogFile); logPath != "" { 35 | var err error 36 | logOutput, err = os.OpenFile(logPath, syscall.O_CREAT|syscall.O_RDWR|syscall.O_APPEND, 0666) 37 | if err != nil { 38 | return nil, err 39 | } 40 | } 41 | 42 | // This was the default since the beginning 43 | logOutput = &logutils.LevelFilter{ 44 | Levels: ValidLevels, 45 | MinLevel: logutils.LogLevel(logLevel), 46 | Writer: logOutput, 47 | } 48 | 49 | return 50 | } 51 | 52 | // SetOutput checks for a log destination with LogOutput, and calls 53 | // log.SetOutput with the result. If LogOutput returns nil, SetOutput uses 54 | // ioutil.Discard. Any error from LogOutout is fatal. 55 | func SetOutput() { 56 | out, err := LogOutput() 57 | if err != nil { 58 | log.Fatal(err) 59 | } 60 | 61 | if out == nil { 62 | out = ioutil.Discard 63 | } 64 | 65 | log.SetOutput(out) 66 | } 67 | 68 | // LogLevel returns the current log level string based the environment vars 69 | func LogLevel() string { 70 | envLevel := os.Getenv(EnvLog) 71 | if envLevel == "" { 72 | return "" 73 | } 74 | 75 | logLevel := "TRACE" 76 | if isValidLogLevel(envLevel) { 77 | // allow following for better ux: info, Info or INFO 78 | logLevel = strings.ToUpper(envLevel) 79 | } else { 80 | log.Printf("[WARN] Invalid log level: %q. Defaulting to level: TRACE. Valid levels are: %+v", 81 | envLevel, ValidLevels) 82 | } 83 | 84 | return logLevel 85 | } 86 | 87 | // IsDebugOrHigher returns whether or not the current log level is debug or trace 88 | func IsDebugOrHigher() bool { 89 | level := string(LogLevel()) 90 | return level == "DEBUG" || level == "TRACE" 91 | } 92 | 93 | func IsTrace() bool { 94 | level := string(LogLevel()) 95 | return level == "TRACE" 96 | } 97 | 98 | // IsSet returns true if AFX_LOG is set 99 | func IsSet() bool { 100 | return string(LogLevel()) != "" 101 | } 102 | 103 | func isValidLogLevel(level string) bool { 104 | for _, l := range ValidLevels { 105 | if strings.ToUpper(level) == string(l) { 106 | return true 107 | } 108 | } 109 | 110 | return false 111 | } 112 | -------------------------------------------------------------------------------- /pkg/logging/transport.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "log" 7 | "net/http" 8 | "net/http/httputil" 9 | "strings" 10 | ) 11 | 12 | type transport struct { 13 | name string 14 | transport http.RoundTripper 15 | } 16 | 17 | func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) { 18 | if IsTrace() { 19 | reqData, err := httputil.DumpRequestOut(req, true) 20 | if err == nil { 21 | log.Printf("[DEBUG] "+logReqMsg, t.name, prettyPrintJsonLines(reqData)) 22 | } else { 23 | log.Printf("[ERROR] %s API Request error: %#v", t.name, err) 24 | } 25 | } 26 | 27 | resp, err := t.transport.RoundTrip(req) 28 | if err != nil { 29 | return resp, err 30 | } 31 | 32 | if IsTrace() { 33 | respData, err := httputil.DumpResponse(resp, true) 34 | if err == nil { 35 | log.Printf("[DEBUG] "+logRespMsg, t.name, prettyPrintJsonLines(respData)) 36 | } else { 37 | log.Printf("[ERROR] %s API Response error: %#v", t.name, err) 38 | } 39 | } 40 | 41 | return resp, nil 42 | } 43 | 44 | func NewTransport(name string, t http.RoundTripper) *transport { 45 | return &transport{name, t} 46 | } 47 | 48 | // prettyPrintJsonLines iterates through a []byte line-by-line, 49 | // transforming any lines that are complete json into pretty-printed json. 50 | func prettyPrintJsonLines(b []byte) string { 51 | parts := strings.Split(string(b), "\n") 52 | for i, p := range parts { 53 | if b := []byte(p); json.Valid(b) { 54 | var out bytes.Buffer 55 | json.Indent(&out, b, "", " ") 56 | parts[i] = out.String() 57 | } 58 | } 59 | return strings.Join(parts, "\n") 60 | } 61 | 62 | const logReqMsg = `%s API Request Details: 63 | ---[ REQUEST ]--------------------------------------- 64 | %s 65 | -----------------------------------------------------` 66 | 67 | const logRespMsg = `%s API Response Details: 68 | ---[ RESPONSE ]-------------------------------------- 69 | %s 70 | -----------------------------------------------------` 71 | -------------------------------------------------------------------------------- /pkg/printers/printers.go: -------------------------------------------------------------------------------- 1 | package printers 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/liggitt/tabwriter" 7 | ) 8 | 9 | const ( 10 | tabwriterMinWidth = 6 11 | tabwriterWidth = 4 12 | tabwriterPadding = 3 13 | tabwriterPadChar = ' ' 14 | tabwriterFlags = tabwriter.RememberWidths 15 | ) 16 | 17 | // GetNewTabWriter returns a tabwriter that translates tabbed columns in input into properly aligned text. 18 | func GetNewTabWriter(output io.Writer) *tabwriter.Writer { 19 | return tabwriter.NewWriter(output, tabwriterMinWidth, tabwriterWidth, tabwriterPadding, tabwriterPadChar, tabwriterFlags) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/printers/terminal.go: -------------------------------------------------------------------------------- 1 | package printers 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/mattn/go-isatty" 8 | "golang.org/x/term" 9 | ) 10 | 11 | var IsTerminal = func(f *os.File) bool { 12 | return isatty.IsTerminal(f.Fd()) || IsCygwinTerminal(f) 13 | } 14 | 15 | func IsCygwinTerminal(f *os.File) bool { 16 | return isatty.IsCygwinTerminal(f.Fd()) 17 | } 18 | 19 | var TerminalSize = func(w interface{}) (int, int, error) { 20 | if f, isFile := w.(*os.File); isFile { 21 | return term.GetSize(int(f.Fd())) 22 | } 23 | 24 | return 0, 0, fmt.Errorf("%v is not a file", w) 25 | } 26 | -------------------------------------------------------------------------------- /pkg/state/state.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "log" 10 | "os" 11 | "path/filepath" 12 | "sync" 13 | 14 | "github.com/google/go-cmp/cmp" 15 | ) 16 | 17 | // ID is to prevent from detecting state changes unexpected by package name changing 18 | // By using fixed string instead of package name, we can forcus on detecting the 19 | // changes of only package contents itself. 20 | type ID = string 21 | 22 | type Self struct { 23 | Resources map[ID]Resource `json:"resources"` 24 | } 25 | 26 | type State struct { 27 | // State itself of state file 28 | Self 29 | 30 | packages map[ID]Resource 31 | path string 32 | mu sync.RWMutex 33 | 34 | // No record in state file 35 | Additions []Resource 36 | 37 | // Exists in state file but no in config file 38 | // so maybe users had deleted the package from config file 39 | Deletions []Resource 40 | 41 | // Something changes happened between config file and state file 42 | // Currently only version (github.release.tag) is detected as changes 43 | Changes []Resource 44 | 45 | // All items recorded in state file. It means no changes between state file 46 | // and config file 47 | NoChanges []Resource 48 | } 49 | 50 | type Resourcer interface { 51 | GetResource() Resource 52 | } 53 | 54 | type Resource struct { 55 | ID ID `json:"id"` 56 | Name string `json:"name"` 57 | Home string `json:"home"` 58 | Type string `json:"type"` 59 | Version string `json:"version"` 60 | Paths []string `json:"paths"` 61 | } 62 | 63 | func (e Resource) GetResource() Resource { 64 | return e 65 | } 66 | 67 | func (e Resource) exists() bool { 68 | if len(e.Paths) == 0 { 69 | return false 70 | } 71 | for _, path := range e.Paths { 72 | if !exists(path) { 73 | return false 74 | } 75 | } 76 | return true 77 | } 78 | 79 | var exists = func(path string) bool { 80 | if _, err := os.Stat(path); os.IsNotExist(err) { 81 | return false 82 | } 83 | return true 84 | } 85 | 86 | var ReadStateFile = func(filename string) ([]byte, error) { 87 | f, err := os.Open(filename) 88 | if err != nil { 89 | // return empty json contents if state.json does not exist 90 | return []byte(`{"resources":{}}`), nil 91 | } 92 | defer f.Close() 93 | 94 | data, err := ioutil.ReadAll(f) 95 | if err != nil { 96 | return nil, err 97 | } 98 | 99 | return data, nil 100 | } 101 | 102 | var SaveStateFile = func(filename string) (io.Writer, error) { 103 | return os.Create(filename) 104 | } 105 | 106 | func findRegularFile(p string) string { 107 | for { 108 | if s, err := os.Stat(p); err == nil && s.Mode().IsRegular() { 109 | return p 110 | } 111 | newPath := filepath.Dir(p) 112 | if newPath == p || newPath == "/" || newPath == "." { 113 | break 114 | } 115 | p = newPath 116 | } 117 | return "" 118 | } 119 | 120 | func add(r Resource, s *State) { 121 | log.Printf("[DEBUG] %s: added to state", r.Name) 122 | s.Resources[r.ID] = r 123 | } 124 | 125 | func remove(r Resource, s *State) { 126 | resources := map[ID]Resource{} 127 | for _, resource := range s.Resources { 128 | if resource.ID == r.ID { 129 | log.Printf("[DEBUG] %s: removed from state", r.Name) 130 | continue 131 | } 132 | resources[resource.ID] = resource 133 | } 134 | if len(s.Resources) == len(resources) { 135 | log.Printf("[WARN] %s: failed to remove from state", r.Name) 136 | return 137 | } 138 | s.Resources = resources 139 | } 140 | 141 | func update(r Resource, s *State) { 142 | _, ok := s.Resources[r.ID] 143 | if !ok { 144 | return 145 | } 146 | log.Printf("[DEBUG] %s: updated in state", r.Name) 147 | s.Resources[r.ID] = r 148 | } 149 | 150 | func (s *State) save() error { 151 | f, err := SaveStateFile(s.path) 152 | if err != nil { 153 | return err 154 | } 155 | return json.NewEncoder(f).Encode(s.Self) 156 | } 157 | 158 | func contains(resources []Resource, name string) bool { 159 | for _, resource := range resources { 160 | if resource.Name == name { 161 | return true 162 | } 163 | } 164 | return false 165 | } 166 | 167 | func (s *State) listChanges() []Resource { 168 | var resources []Resource 169 | for _, resource := range s.Resources { 170 | if resource.Version == "" { 171 | log.Printf("[TRACE] skip; version of %s is not set", resource.Name) 172 | continue 173 | } 174 | r, ok := s.packages[resource.ID] 175 | if !ok { 176 | log.Printf("[TRACE] skip; %s is not found in packages", resource.Name) 177 | continue 178 | } 179 | if resource.Version != r.Version { 180 | resources = append(resources, resource) 181 | } 182 | } 183 | return resources 184 | } 185 | 186 | func (s *State) listNoChanges() []Resource { 187 | var resources []Resource 188 | for _, resource := range s.packages { 189 | if contains(append(s.listAdditions(), s.listReadditions()...), resource.Name) { 190 | continue 191 | } 192 | if contains(s.listChanges(), resource.Name) { 193 | continue 194 | } 195 | resources = append(resources, resource) 196 | } 197 | return resources 198 | } 199 | 200 | func (s *State) listAdditions() []Resource { 201 | var resources []Resource 202 | for _, resource := range s.packages { 203 | if _, ok := s.Resources[resource.ID]; !ok { 204 | resources = append(resources, resource) 205 | } 206 | } 207 | return resources 208 | } 209 | 210 | func (s *State) listReadditions() []Resource { 211 | var resources []Resource 212 | for _, resource := range s.packages { 213 | resource, ok := s.Resources[resource.ID] 214 | if !ok { 215 | // if it's not in state file, 216 | // it means we need to install not reinstall 217 | continue 218 | } 219 | if !resource.exists() { 220 | resources = append(resources, resource) 221 | continue 222 | } 223 | } 224 | return resources 225 | } 226 | 227 | func (s *State) listDeletions() []Resource { 228 | var resources []Resource 229 | for _, resource := range s.Resources { 230 | if _, ok := s.packages[resource.ID]; !ok { 231 | resources = append(resources, resource) 232 | } 233 | } 234 | return resources 235 | } 236 | 237 | func Keys(resources []Resource) []string { 238 | var keys []string 239 | for _, resource := range resources { 240 | keys = append(keys, resource.Name) 241 | } 242 | return keys 243 | } 244 | 245 | func Open(path string, resourcers []Resourcer) (*State, error) { 246 | s := State{ 247 | Self: Self{Resources: map[ID]Resource{}}, 248 | path: path, 249 | packages: map[ID]Resource{}, 250 | mu: sync.RWMutex{}, 251 | } 252 | 253 | for _, resourcer := range resourcers { 254 | resource := resourcer.GetResource() 255 | if resource.Type == "Local" { 256 | // local package should not manage in state 257 | continue 258 | } 259 | s.packages[resource.ID] = resource 260 | } 261 | 262 | content, err := ReadStateFile(path) 263 | if err != nil { 264 | return &s, err 265 | } 266 | 267 | if err := json.Unmarshal(content, &s.Self); err != nil { 268 | return &s, err 269 | } 270 | 271 | s.Additions = append(s.listAdditions(), s.listReadditions()...) 272 | s.Deletions = s.listDeletions() 273 | s.Changes = s.listChanges() 274 | s.NoChanges = s.listNoChanges() 275 | 276 | // TODO: maybe better to separate to dedicated command etc? 277 | // this is needed to update state schema (e.g. adding new field) 278 | // but maybe it's danger a bit 279 | // so may be better to separate to dedicated command like `afx state refresh` etc 280 | // to run state operation explicitly 281 | if err := s.Refresh(); err != nil { 282 | log.Printf("[ERROR] there're some states or packages which needs operations: %v", err) 283 | } 284 | 285 | return &s, s.save() 286 | } 287 | 288 | func (s *State) Add(resourcer Resourcer) { 289 | s.mu.Lock() 290 | defer s.mu.Unlock() 291 | 292 | add(resourcer.GetResource(), s) 293 | s.save() 294 | } 295 | 296 | func (s *State) Remove(resourcer Resourcer) { 297 | s.mu.Lock() 298 | defer s.mu.Unlock() 299 | 300 | remove(resourcer.GetResource(), s) 301 | s.save() 302 | } 303 | 304 | func (s *State) Update(resourcer Resourcer) { 305 | s.mu.Lock() 306 | defer s.mu.Unlock() 307 | 308 | update(resourcer.GetResource(), s) 309 | s.save() 310 | } 311 | 312 | func (s *State) List() ([]Resource, error) { 313 | content, err := ReadStateFile(s.path) 314 | if err != nil { 315 | return []Resource{}, err 316 | } 317 | var state Self 318 | if err := json.Unmarshal(content, &state); err != nil { 319 | return []Resource{}, err 320 | } 321 | var resources []Resource 322 | for _, resource := range state.Resources { 323 | resources = append(resources, resource) 324 | } 325 | return resources, nil 326 | } 327 | 328 | func (s *State) New() error { 329 | s.Resources = map[ID]Resource{} 330 | for _, resource := range s.packages { 331 | add(resource, s) 332 | } 333 | return s.save() 334 | } 335 | 336 | func (s *State) Refresh() error { 337 | s.mu.Lock() 338 | defer s.mu.Unlock() 339 | 340 | someChanges := len(s.Additions) > 0 || 341 | len(s.Changes) > 0 || 342 | len(s.Deletions) > 0 343 | 344 | if someChanges { 345 | return errors.New("cannot refresh state") 346 | } 347 | 348 | done := false 349 | for _, resource := range s.packages { 350 | v1 := s.Resources[resource.ID] 351 | v2 := resource 352 | if diff := cmp.Diff(v1, v2); diff != "" { 353 | log.Printf("[DEBUG] refresh state to %s", diff) 354 | update(resource, s) 355 | done = true 356 | } 357 | } 358 | 359 | if done { 360 | log.Printf("[DEBUG] refreshed state to update latest state schema") 361 | } 362 | 363 | return nil 364 | } 365 | 366 | func Map(resources []Resource) map[ID]Resource { 367 | m := map[ID]Resource{} 368 | for _, resource := range resources { 369 | m[resource.Name] = resource 370 | } 371 | return m 372 | } 373 | 374 | func Slice(m map[ID]Resource) []Resource { 375 | var resources []Resource 376 | for _, resource := range m { 377 | resources = append(resources, resource) 378 | } 379 | return resources 380 | } 381 | 382 | func (s *State) Get(name string) (Resource, error) { 383 | for _, resource := range s.Resources { 384 | if resource.Name == name { 385 | return resource, nil 386 | } 387 | } 388 | return Resource{}, fmt.Errorf("%s: not found in state file", name) 389 | } 390 | -------------------------------------------------------------------------------- /pkg/state/testing.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | ) 8 | 9 | func stubState(m map[string]string) func() { 10 | origRead := ReadStateFile 11 | origSave := SaveStateFile 12 | ReadStateFile = func(filename string) ([]byte, error) { 13 | content, ok := m[filename] 14 | if !ok { 15 | return []byte(nil), os.ErrNotExist 16 | } 17 | return []byte(content), nil 18 | } 19 | SaveStateFile = func(fn string) (io.Writer, error) { 20 | // override with this buffer to prevent creating 21 | // actual files in testing 22 | var b bytes.Buffer 23 | return &b, nil 24 | } 25 | return func() { 26 | ReadStateFile = origRead 27 | SaveStateFile = origSave 28 | } 29 | } 30 | 31 | type testConfig struct { 32 | pkgs []testPackage 33 | } 34 | 35 | type testPackage struct { 36 | r Resource 37 | } 38 | 39 | func (p testPackage) GetResource() Resource { 40 | return p.r 41 | } 42 | 43 | func stubPackages(resources []Resource) []Resourcer { 44 | var cfg testConfig 45 | for _, resource := range resources { 46 | cfg.pkgs = append(cfg.pkgs, testPackage{r: resource}) 47 | } 48 | 49 | resourcers := make([]Resourcer, len(cfg.pkgs)) 50 | for i, pkg := range cfg.pkgs { 51 | resourcers[i] = pkg 52 | } 53 | 54 | return resourcers 55 | } 56 | -------------------------------------------------------------------------------- /pkg/templates/templates.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "bytes" 5 | "path/filepath" 6 | "strings" 7 | "text/template" 8 | "time" 9 | 10 | "github.com/babarot/afx/pkg/data" 11 | ) 12 | 13 | // Template holds data that can be applied to a template string. 14 | type Template struct { 15 | data *data.Data 16 | fields Fields 17 | } 18 | 19 | // Fields that will be available to the template engine. 20 | type Fields map[string]interface{} 21 | 22 | const ( 23 | pkgName = "Name" 24 | pkgHome = "Home" 25 | dir = "Dir" 26 | goos = "OS" 27 | goarch = "Arch" 28 | env = "Env" 29 | release = "Release" 30 | 31 | // release 32 | releaseName = "Name" 33 | releaseTag = "Tag" 34 | ) 35 | 36 | // New Template. 37 | func New(d *data.Data) *Template { 38 | return &Template{ 39 | data: d, 40 | fields: Fields{ 41 | env: d.Env, 42 | pkgName: d.Package.Name, 43 | pkgHome: d.Package.Home, 44 | dir: d.Package.Home, 45 | goos: d.Runtime.Goos, 46 | goarch: d.Runtime.Goarch, 47 | release: map[string]string{ 48 | releaseName: d.Release.Name, 49 | releaseTag: d.Release.Tag, 50 | }, 51 | }, 52 | } 53 | } 54 | 55 | // Apply applies the given string against the Fields stored in the template. 56 | func (t *Template) Apply(s string) (string, error) { 57 | var out bytes.Buffer 58 | tmpl, err := template.New("tmpl"). 59 | Option("missingkey=error"). 60 | Funcs(template.FuncMap{ 61 | "replace": strings.ReplaceAll, 62 | "time": func(s string) string { 63 | return time.Now().UTC().Format(s) 64 | }, 65 | "tolower": strings.ToLower, 66 | "toupper": strings.ToUpper, 67 | "trim": strings.TrimSpace, 68 | "trimprefix": strings.TrimPrefix, 69 | "trimsuffix": strings.TrimSuffix, 70 | "dir": filepath.Dir, 71 | "abs": filepath.Abs, 72 | }). 73 | Parse(s) 74 | if err != nil { 75 | return "", err 76 | } 77 | 78 | err = tmpl.Execute(&out, t.fields) 79 | return out.String(), err 80 | } 81 | 82 | // Replace populates Fields from the artifact and replacements. 83 | func (t *Template) Replace(replacements map[string]string) *Template { 84 | t.fields[goos] = replace(replacements, t.data.Runtime.Goos) 85 | t.fields[goarch] = replace(replacements, t.data.Runtime.Goarch) 86 | return t 87 | } 88 | 89 | func replace(replacements map[string]string, original string) string { 90 | result := replacements[original] 91 | if result == "" { 92 | return original 93 | } 94 | return result 95 | } 96 | -------------------------------------------------------------------------------- /pkg/update/update.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "net/http" 9 | "os" 10 | "path/filepath" 11 | "regexp" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | "github.com/babarot/afx/pkg/github" 17 | "github.com/hashicorp/go-version" 18 | ) 19 | 20 | // refer: github.com/cli/cli/tree//internal/update 21 | // hash: bf83c660a1ae486d582117e0a174f8e109b64775 22 | var gitDescribeSuffixRE = regexp.MustCompile(`\d+-\d+-g[a-f0-9]{8}$`) 23 | 24 | // ReleaseInfo stores information about a release 25 | type ReleaseInfo struct { 26 | Version string `json:"tag_name"` 27 | URL string `json:"html_url"` 28 | PublishedAt time.Time `json:"published_at"` 29 | } 30 | 31 | type StateEntry struct { 32 | CheckedForUpdateAt time.Time `json:"checked_for_update_at"` 33 | LatestRelease ReleaseInfo `json:"latest_release"` 34 | } 35 | 36 | // CheckForUpdate checks whether this software has had a newer release on GitHub 37 | func CheckForUpdate(client *github.Client, stateFilePath, repo, currentVersion string) (*ReleaseInfo, error) { 38 | stateEntry, _ := getStateEntry(stateFilePath) 39 | if stateEntry != nil && time.Since(stateEntry.CheckedForUpdateAt).Hours() < 24 { 40 | return nil, nil 41 | } 42 | 43 | releaseInfo, err := getLatestReleaseInfo(client, repo) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | err = setStateEntry(stateFilePath, time.Now(), *releaseInfo) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | if versionGreaterThan(releaseInfo.Version, currentVersion) { 54 | return releaseInfo, nil 55 | } 56 | 57 | return nil, nil 58 | } 59 | 60 | func getLatestReleaseInfo(client *github.Client, repo string) (*ReleaseInfo, error) { 61 | var latestRelease ReleaseInfo 62 | 63 | log.Printf("[DEBUG] call GitHub Release API to get release info") 64 | 65 | api := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo) 66 | err := client.REST(http.MethodGet, api, nil, &latestRelease) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | return &latestRelease, nil 72 | } 73 | 74 | func getStateEntry(stateFilePath string) (*StateEntry, error) { 75 | content, err := ioutil.ReadFile(stateFilePath) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | var stateEntry StateEntry 81 | err = json.Unmarshal(content, &stateEntry) 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | return &stateEntry, nil 87 | } 88 | 89 | func setStateEntry(stateFilePath string, t time.Time, r ReleaseInfo) error { 90 | data := StateEntry{CheckedForUpdateAt: t, LatestRelease: r} 91 | content, err := json.Marshal(data) 92 | if err != nil { 93 | return err 94 | } 95 | 96 | err = os.MkdirAll(filepath.Dir(stateFilePath), 0755) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | err = ioutil.WriteFile(stateFilePath, content, 0600) 102 | return err 103 | } 104 | 105 | func versionGreaterThan(v, w string) bool { 106 | w = gitDescribeSuffixRE.ReplaceAllStringFunc(w, func(m string) string { 107 | idx := strings.IndexRune(m, '-') 108 | n, _ := strconv.Atoi(m[0:idx]) 109 | return fmt.Sprintf("%d-pre.0", n+1) 110 | }) 111 | 112 | vv, ve := version.NewVersion(v) 113 | vw, we := version.NewVersion(w) 114 | 115 | return ve == nil && we == nil && vv.GreaterThan(vw) 116 | } 117 | -------------------------------------------------------------------------------- /pkg/update/update_test.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "net/http" 8 | "os" 9 | "testing" 10 | 11 | "github.com/babarot/afx/pkg/github" 12 | "github.com/cli/cli/v2/pkg/httpmock" 13 | ) 14 | 15 | func TestCheckForUpdate(t *testing.T) { 16 | orig_GITHUB_TOKEN := os.Getenv("GITHUB_TOKEN") 17 | t.Cleanup(func() { 18 | os.Setenv("GITHUB_TOKEN", orig_GITHUB_TOKEN) 19 | }) 20 | 21 | scenarios := []struct { 22 | Name string 23 | CurrentVersion string 24 | LatestVersion string 25 | LatestURL string 26 | ExpectsResult bool 27 | }{ 28 | { 29 | Name: "latest is newer", 30 | CurrentVersion: "v0.0.1", 31 | LatestVersion: "v1.0.0", 32 | LatestURL: "https://www.spacejam.com/archive/spacejam/movie/jam.htm", 33 | ExpectsResult: true, 34 | }, 35 | { 36 | Name: "current is prerelease", 37 | CurrentVersion: "v1.0.0-pre.1", 38 | LatestVersion: "v1.0.0", 39 | LatestURL: "https://www.spacejam.com/archive/spacejam/movie/jam.htm", 40 | ExpectsResult: true, 41 | }, 42 | { 43 | Name: "current is built from source", 44 | CurrentVersion: "v1.2.3-123-gdeadbeef", 45 | LatestVersion: "v1.2.3", 46 | LatestURL: "https://www.spacejam.com/archive/spacejam/movie/jam.htm", 47 | ExpectsResult: false, 48 | }, 49 | { 50 | Name: "current is built from source after a prerelease", 51 | CurrentVersion: "v1.2.3-rc.1-123-gdeadbeef", 52 | LatestVersion: "v1.2.3", 53 | LatestURL: "https://www.spacejam.com/archive/spacejam/movie/jam.htm", 54 | ExpectsResult: true, 55 | }, 56 | { 57 | Name: "latest is newer than version build from source", 58 | CurrentVersion: "v1.2.3-123-gdeadbeef", 59 | LatestVersion: "v1.2.4", 60 | LatestURL: "https://www.spacejam.com/archive/spacejam/movie/jam.htm", 61 | ExpectsResult: true, 62 | }, 63 | { 64 | Name: "latest is current", 65 | CurrentVersion: "v1.0.0", 66 | LatestVersion: "v1.0.0", 67 | LatestURL: "https://www.spacejam.com/archive/spacejam/movie/jam.htm", 68 | ExpectsResult: false, 69 | }, 70 | { 71 | Name: "latest is older", 72 | CurrentVersion: "v0.10.0-pre.1", 73 | LatestVersion: "v0.9.0", 74 | LatestURL: "https://www.spacejam.com/archive/spacejam/movie/jam.htm", 75 | ExpectsResult: false, 76 | }, 77 | } 78 | 79 | log.SetOutput(ioutil.Discard) 80 | 81 | for _, s := range scenarios { 82 | t.Run(s.Name, func(t *testing.T) { 83 | os.Setenv("GITHUB_TOKEN", "TOKEN") 84 | mock := &httpmock.Registry{} 85 | client := github.NewClient(github.ReplaceTripper(mock)) 86 | 87 | mock.Register( 88 | httpmock.REST(http.MethodGet, "repos/OWNER/REPO/releases/latest"), 89 | httpmock.StringResponse(fmt.Sprintf(`{ 90 | "tag_name": "%s", 91 | "html_url": "%s" 92 | }`, s.LatestVersion, s.LatestURL)), 93 | ) 94 | 95 | rel, err := CheckForUpdate(client, tempFilePath(), "OWNER/REPO", s.CurrentVersion) 96 | if err != nil { 97 | t.Fatal(err) 98 | } 99 | 100 | if len(mock.Requests) != 1 { 101 | t.Fatalf("expected 1 HTTP request, got %d", len(mock.Requests)) 102 | } 103 | requestPath := mock.Requests[0].URL.Path 104 | if requestPath != "/repos/OWNER/REPO/releases/latest" { 105 | t.Errorf("HTTP path: %q", requestPath) 106 | } 107 | 108 | if !s.ExpectsResult { 109 | if rel != nil { 110 | t.Fatal("expected no new release") 111 | } 112 | return 113 | } 114 | if rel == nil { 115 | t.Fatal("expected to report new release") 116 | } 117 | 118 | if rel.Version != s.LatestVersion { 119 | t.Errorf("Version: %q", rel.Version) 120 | } 121 | if rel.URL != s.LatestURL { 122 | t.Errorf("URL: %q", rel.URL) 123 | } 124 | }) 125 | } 126 | } 127 | 128 | func tempFilePath() string { 129 | file, err := ioutil.TempFile("", "") 130 | if err != nil { 131 | log.Fatal(err) 132 | } 133 | os.Remove(file.Name()) 134 | return file.Name() 135 | } 136 | --------------------------------------------------------------------------------