├── .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 | 
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 |
--------------------------------------------------------------------------------