├── .gitignore ├── gtcore ├── platform_others.go ├── platform_windows.go ├── test.go ├── run.go ├── uninstall.go ├── install.go ├── tool.go ├── catalog.go ├── update.go ├── list.go └── default_catalog.go ├── go.mod ├── staticcheck.conf ├── go.sum ├── main.go ├── .circleci └── config.yml ├── .github ├── dependabot.yml └── workflows │ ├── go.yml │ └── release.yml ├── LICENSE ├── Makefile ├── README.md └── goenv └── goenv.go /.gitignore: -------------------------------------------------------------------------------- 1 | /gtc 2 | /gtc.exe 3 | tags 4 | tmp/ 5 | -------------------------------------------------------------------------------- /gtcore/platform_others.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package gtcore 5 | 6 | var platformTools = []Tool{} 7 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/koron/gtc 2 | 3 | go 1.21.12 4 | 5 | require ( 6 | github.com/koron-go/jsonhttpc v0.9.0 7 | github.com/koron/go-subcmd v1.0.0 8 | ) 9 | -------------------------------------------------------------------------------- /gtcore/platform_windows.go: -------------------------------------------------------------------------------- 1 | package gtcore 2 | 3 | var platformTools = []Tool{ 4 | { 5 | Path: "github.com/mattn/sudo", 6 | Desc: "sudo for windows", 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /staticcheck.conf: -------------------------------------------------------------------------------- 1 | # vim:set ft=toml: 2 | 3 | checks = ["all"] 4 | 5 | # based on: github.com/koron-go/_skeleton/staticcheck.conf 6 | # $Hash:cd6871e83e780f6a2ce05386c0551034badf78b2ad40a76a8f6f5510$ 7 | -------------------------------------------------------------------------------- /gtcore/test.go: -------------------------------------------------------------------------------- 1 | package gtcore 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | 7 | "github.com/koron/gtc/goenv" 8 | ) 9 | 10 | func test(fs *flag.FlagSet, args []string) error { 11 | if err := fs.Parse(args); err != nil { 12 | return err 13 | } 14 | fmt.Printf("goenv.Default=%+v\n", goenv.Default) 15 | return nil 16 | } 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/koron-go/jsonhttpc v0.9.0 h1:JSwzgYWXXH4k1mwxx90LHCS+ZdxoTYwcn0Y/Eu4tdN0= 2 | github.com/koron-go/jsonhttpc v0.9.0/go.mod h1:RxfHCdAmAIdddrrIPie7cO1Le0Q73tWiPVM9WR10SCQ= 3 | github.com/koron/go-subcmd v1.0.0 h1:AVCdeWSfY9LaiuVHAumEKV3OYIIKG7pHw/+vvxhlJdk= 4 | github.com/koron/go-subcmd v1.0.0/go.mod h1:D+hefRR744UrPtYBpn9xhuXNdJK4NliVntPEPTZajjA= 5 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/koron/gtc/gtcore" 8 | ) 9 | 10 | func run() error { 11 | if custom := os.Getenv("GTC_CATALOG_FILE"); custom != "" { 12 | err := gtcore.SetupDefaultCatalog(custom) 13 | if err != nil { 14 | return err 15 | } 16 | } 17 | return gtcore.Run(os.Args) 18 | } 19 | 20 | func main() { 21 | err := run() 22 | if err != nil { 23 | fmt.Println(err.Error()) 24 | os.Exit(1) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/golang:latest 6 | working_directory: /go/src/github.com/koron/gtc 7 | steps: 8 | - checkout 9 | - run: 10 | name: Install dependencies 11 | command: go get -v -t -d ./... 12 | - run: 13 | name: Build 14 | command: go build -v -i . 15 | - run: 16 | name: Test all 17 | command: go test -v ./... 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | 13 | # $Hash:c5d3212bc9191230f44684f4d30f030b6c70d515e68cbc9c3467c4d9$ 14 | -------------------------------------------------------------------------------- /gtcore/run.go: -------------------------------------------------------------------------------- 1 | package gtcore 2 | 3 | import ( 4 | "errors" 5 | "path/filepath" 6 | 7 | subcmd "github.com/koron/go-subcmd" 8 | ) 9 | 10 | var cmds = subcmd.Subcmds{ 11 | "install": subcmd.Main2(install), 12 | "uninstall": subcmd.Main2(uninstall), 13 | "list": subcmd.Main2(list), 14 | "update": subcmd.Main2(update), 15 | "test": subcmd.Main2(test), 16 | } 17 | 18 | var currentCatalog Catalog 19 | 20 | func catalogFind(name string) (Tool, bool) { 21 | t, ok := currentCatalog[name] 22 | return t, ok 23 | } 24 | 25 | func catalogNames() []string { 26 | return currentCatalog.Names() 27 | } 28 | 29 | func run(c Catalog, args []string) error { 30 | if len(args) < 1 { 31 | return errors.New("one ore more argurements required") 32 | } 33 | name := filepath.Base(args[0]) 34 | name = name[:len(name)-len(filepath.Ext(name))] 35 | currentCatalog = c 36 | return cmds.RunWithName(name, args[1:]) 37 | } 38 | 39 | // Run runs the core of "gtc" command. 40 | func Run(args []string) error { 41 | return run(DefaultCatalog, args) 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 MURAOKA Taro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /gtcore/uninstall.go: -------------------------------------------------------------------------------- 1 | package gtcore 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "log" 8 | 9 | "github.com/koron/gtc/goenv" 10 | ) 11 | 12 | func uninstall(fs *flag.FlagSet, args []string) error { 13 | env := goenv.Default 14 | fs.BoolVar(&env.Verbose, "verbose", false, "verbose message") 15 | if err := fs.Parse(args); err != nil { 16 | return err 17 | } 18 | if fs.NArg() == 0 { 19 | return errors.New("no tools to uninstall") 20 | } 21 | 22 | failed := false 23 | for _, prog := range fs.Args() { 24 | err := uninstallOne(env, prog) 25 | if err != nil { 26 | failed = true 27 | log.Printf("failed to uninstall %s: %s", prog, err) 28 | continue 29 | } 30 | } 31 | if failed { 32 | return errors.New("failed to uninstall one or more tools") 33 | } 34 | return nil 35 | } 36 | 37 | func uninstallOne(env goenv.Env, prog string) error { 38 | tool, ok := catalogFind(prog) 39 | if !ok { 40 | return fmt.Errorf("unknown catalog %q", prog) 41 | } 42 | if !env.HasTool(prog) { 43 | log.Printf("not installed: %s", prog) 44 | return nil 45 | } 46 | err := env.Uninstall(tool.CmdName()) 47 | if err != nil { 48 | return err 49 | } 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: [push] 4 | 5 | env: 6 | GO_VERSION: '>=1.24.0' 7 | 8 | jobs: 9 | 10 | build: 11 | name: Build 12 | runs-on: ${{ matrix.os }} 13 | 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: 18 | - ubuntu-latest 19 | - ubuntu-24.04-arm 20 | - macos-latest 21 | - windows-latest 22 | 23 | steps: 24 | 25 | - uses: actions/checkout@v6 26 | 27 | - uses: actions/setup-go@v6 28 | with: 29 | go-version: ${{ env.GO_VERSION }} 30 | 31 | - run: go test ./... 32 | 33 | - name: Build all "main" packages 34 | shell: bash 35 | run: | 36 | go list -f '{{if (eq .Name "main")}}{{.ImportPath}} .{{slice .ImportPath (len .Module.Path)}}{{end}}' ./... | while IFS= read -r line ; do 37 | read -a e <<< "$line" 38 | path="${e[0]}" 39 | dir="${e[1]}" 40 | name=$(basename $path) 41 | printf "building %s\t(%s)\n" $name $dir 42 | ( cd "$dir" && go build ) 43 | done 44 | 45 | # based on: github.com/koron-go/_skeleton/.github/workflows/go.yml 46 | # $Hash:5c453adb7ee86afc50e6bbb626326f86d1e9b7ebc92cc12d74227105$ 47 | -------------------------------------------------------------------------------- /gtcore/install.go: -------------------------------------------------------------------------------- 1 | package gtcore 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "log" 8 | 9 | "github.com/koron/gtc/goenv" 10 | ) 11 | 12 | func install(fs *flag.FlagSet, args []string) error { 13 | env := goenv.Default 14 | fs.BoolVar(&env.EnableModule, "module", false, "use module to install") 15 | fs.BoolVar(&env.Verbose, "verbose", false, "verbose message") 16 | if err := fs.Parse(args); err != nil { 17 | return err 18 | } 19 | if fs.NArg() == 0 { 20 | return errors.New("no tools to install") 21 | } 22 | failed := false 23 | for _, prog := range fs.Args() { 24 | err := installOne(env, prog) 25 | if err != nil { 26 | failed = true 27 | log.Printf("failed to install %s: %s", prog, err) 28 | continue 29 | } 30 | } 31 | if failed { 32 | return errors.New("failed to install one or more tools") 33 | } 34 | return nil 35 | } 36 | 37 | func installOne(env goenv.Env, prog string) error { 38 | tool, ok := catalogFind(prog) 39 | if !ok { 40 | return fmt.Errorf("unknown catalog %q", prog) 41 | } 42 | if env.HasTool(prog) { 43 | log.Printf("already installed: %s", prog) 44 | return nil 45 | } 46 | log.Printf("installing: %s", prog) 47 | err := env.Install(tool.Path) 48 | if err != nil { 49 | return err 50 | } 51 | return nil 52 | } 53 | -------------------------------------------------------------------------------- /gtcore/tool.go: -------------------------------------------------------------------------------- 1 | package gtcore 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/url" 7 | "path" 8 | "strings" 9 | "time" 10 | 11 | "github.com/koron-go/jsonhttpc" 12 | ) 13 | 14 | // Tool represents a well known tool. 15 | type Tool struct { 16 | Path string `json:"path"` 17 | Desc string `json:"desc"` 18 | 19 | // Name is command name (OPTION). If empty, extract from Path. 20 | Name string `json:"name,omitempty"` 21 | 22 | Module string `json:"module,omitempty"` 23 | } 24 | 25 | // CmdName returns name of a tool. 26 | func (tool Tool) CmdName() string { 27 | if tool.Name != "" { 28 | return tool.Name 29 | } 30 | return path.Base(tool.Path) 31 | } 32 | 33 | // ModulePath returns module path 34 | func (tool Tool) ModulePath() string { 35 | if tool.Module != "" { 36 | return tool.Module 37 | } 38 | x := strings.SplitN(tool.Path, "/", 4) 39 | if len(x) < 3 { 40 | log.Printf("[WARN] short module name, may be wrong: %s", tool.Path) 41 | return tool.Path 42 | } 43 | return strings.Join(x[:3], "/") 44 | } 45 | 46 | type Info struct { 47 | Version string 48 | Time time.Time 49 | } 50 | 51 | var proxyClient *jsonhttpc.Client 52 | 53 | func init() { 54 | u, err := url.Parse("https://proxy.golang.org/") 55 | if err != nil { 56 | panic(err) 57 | } 58 | proxyClient = jsonhttpc.New(u) 59 | } 60 | 61 | func (tool Tool) Latest(ctx context.Context) (*Info, error) { 62 | info := new(Info) 63 | err := proxyClient.Do(ctx, "GET", tool.ModulePath()+"/@latest", nil, info) 64 | if err != nil { 65 | return nil, err 66 | } 67 | return info, nil 68 | } 69 | -------------------------------------------------------------------------------- /gtcore/catalog.go: -------------------------------------------------------------------------------- 1 | package gtcore 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "sort" 7 | ) 8 | 9 | // Catalog is a collection of tools. 10 | type Catalog map[string]Tool 11 | 12 | // NewCatalog creates a Catalog from Tool array. 13 | func NewCatalog(tools ...Tool) Catalog { 14 | c := Catalog{} 15 | c.Merge(tools...) 16 | return c 17 | } 18 | 19 | // Merge merges tools to a Catalog. 20 | func (c Catalog) Merge(tools ...Tool) Catalog { 21 | for _, tool := range tools { 22 | c[tool.CmdName()] = tool 23 | } 24 | return c 25 | } 26 | 27 | // Names returns all name of tools. 28 | func (c Catalog) Names() []string { 29 | names := make([]string, 0, len(c)) 30 | for k := range c { 31 | names = append(names, k) 32 | } 33 | sort.Strings(names) 34 | return names 35 | } 36 | 37 | // Run runs an operation on a Catalog. 38 | func (c Catalog) Run(args []string) error { 39 | return run(c, args) 40 | } 41 | 42 | // DefaultCatalog provides a catalog of default tools. 43 | var DefaultCatalog Catalog 44 | 45 | func init() { 46 | SetupDefaultCatalog() 47 | } 48 | 49 | // SetupDefaultCatalog loads/setups DefaultCatalog with tools from JSON files. 50 | func SetupDefaultCatalog(names ...string) error { 51 | cc := Catalog{} 52 | for _, name := range names { 53 | f, err := os.Open(name) 54 | if err != nil { 55 | return err 56 | } 57 | var tools []Tool 58 | err = json.NewDecoder(f).Decode(&tools) 59 | if err != nil { 60 | return err 61 | } 62 | cc.Merge(tools...) 63 | } 64 | DefaultCatalog = cc. 65 | Merge(defaultTools...). 66 | Merge(platformTools...) 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Get relative paths of all "main" packages 2 | MAIN_PACKAGE ?= $(shell go list -f '{{if (eq .Name "main")}}.{{slice .ImportPath (len .Module.Path)}}{{end}}' ./...) 3 | 4 | TEST_PACKAGE ?= ./... 5 | 6 | .PHONY: build 7 | build: 8 | go build -gcflags '-e' ./... 9 | 10 | .PHONY: test 11 | test: 12 | go test $(TEST_PACKAGE) 13 | 14 | .PHONY: race 15 | race: 16 | go test -race $(TEST_PACKAGE) 17 | 18 | .PHONY: bench 19 | bench: 20 | go test -bench $(TEST_PACKAGE) 21 | 22 | .PHONY: tags 23 | tags: 24 | gotags -f tags -R . 25 | 26 | .PHONY: cover 27 | cover: 28 | mkdir -p tmp 29 | go test -coverprofile tmp/_cover.out $(TEST_PACKAGE) 30 | go tool cover -html tmp/_cover.out -o tmp/cover.html 31 | 32 | .PHONY: checkall 33 | checkall: vet staticcheck 34 | 35 | .PHONY: vet 36 | vet: 37 | go vet $(TEST_PACKAGE) 38 | 39 | .PHONY: staticcheck 40 | staticcheck: 41 | staticcheck $(TEST_PACKAGE) 42 | 43 | .PHONY: clean 44 | clean: 45 | go clean 46 | rm -f tags 47 | rm -f tmp/_cover.out tmp/cover.html 48 | 49 | .PHONY: upgradable 50 | upgradable: 51 | @go list -m -mod=readonly -u -f='{{if and (not .Indirect) (not .Main)}}{{if .Update}}{{.Path}}@{{.Update.Version}} [{{.Version}}]{{else if .Replace}}{{if .Replace.Update}}{{.Path}}@{{.Replace.Update.Version}} [replaced:{{.Replace.Version}} {{.Version}}]{{end}}{{end}}{{end}}' all 52 | 53 | .PHONY: upgradable-all 54 | upgradable-all: 55 | @go list -m -u -f '{{if .Update}}{{.Path}} {{.Version}} [{{.Update.Version}}]{{end}}' all 56 | 57 | # Build all "main" packages 58 | .PHONY: main-build 59 | main-build: 60 | @for d in $(MAIN_PACKAGE) ; do \ 61 | echo "cd $$d && go build -gcflags '-e'" ; \ 62 | ( cd $$d && go build -gcflags '-e' ) ; \ 63 | done 64 | 65 | # Clean all "main" packages 66 | .PHONY: main-clean 67 | main-clean: 68 | @for d in $(MAIN_PACKAGE) ; do \ 69 | echo "cd $$d && go clean" ; \ 70 | ( cd $$d && go clean ) ; \ 71 | done 72 | 73 | # based on: github.com/koron-go/_skeleton/Makefile 74 | # $Hash:93a5966a0297543bcdd82a4dd9c2d60232a1b02c49cfa0b4341fdb71$ 75 | -------------------------------------------------------------------------------- /gtcore/update.go: -------------------------------------------------------------------------------- 1 | package gtcore 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "time" 9 | 10 | "github.com/koron/gtc/goenv" 11 | ) 12 | 13 | var ( 14 | updateDryrun bool 15 | updateFlagAll bool 16 | updateDuration time.Duration 17 | ) 18 | 19 | func update(fs *flag.FlagSet, args []string) error { 20 | env := goenv.Default 21 | fs.BoolVar(&updateDryrun, "dryrun", false, "dry run to update") 22 | fs.BoolVar(&updateFlagAll, "all", false, "update all installed tools") 23 | fs.DurationVar(&updateDuration, "duration", time.Hour*24*5, 24 | "threshold to update with \"-all\"") 25 | fs.BoolVar(&env.EnableModule, "module", false, "use module to install") 26 | fs.BoolVar(&env.Verbose, "verbose", false, "verbose message") 27 | if err := fs.Parse(args); err != nil { 28 | return err 29 | } 30 | 31 | if updateFlagAll { 32 | return updateAll(&env, updateDuration) 33 | } 34 | return updateTools(&env, fs.Args()) 35 | } 36 | 37 | func updateAll(env *goenv.Env, dur time.Duration) error { 38 | tools, err := env.OldTools(time.Now().Add(-dur)) 39 | if err != nil { 40 | return err 41 | } 42 | var all []string 43 | for _, t := range tools { 44 | if _, ok := catalogFind(t); ok { 45 | all = append(all, t) 46 | } 47 | } 48 | if len(all) == 0 { 49 | log.Printf("no tools to update") 50 | return nil 51 | } 52 | return updateTools(env, all) 53 | } 54 | 55 | func updateTools(env *goenv.Env, tools []string) error { 56 | if len(tools) == 0 { 57 | return errors.New("no tools to update") 58 | } 59 | var failed bool 60 | for _, prog := range tools { 61 | err := updateOne(env, prog) 62 | if err != nil { 63 | failed = true 64 | log.Printf("failed to update %s: %s", prog, err) 65 | continue 66 | } 67 | } 68 | if failed { 69 | return errors.New("failed to update one or more tools") 70 | } 71 | return nil 72 | } 73 | 74 | func updateOne(env *goenv.Env, prog string) error { 75 | tool, ok := catalogFind(prog) 76 | if !ok { 77 | return fmt.Errorf("unknown catalog %q", prog) 78 | } 79 | if !env.HasTool(prog) { 80 | log.Printf("not installed: %s", prog) 81 | return nil 82 | } 83 | if updateDryrun { 84 | log.Printf("updating (dryrun): %s", prog) 85 | return nil 86 | } 87 | log.Printf("updating: %s", prog) 88 | err := env.Update(tool.Path, prog) 89 | if err != nil { 90 | return err 91 | } 92 | return nil 93 | } 94 | -------------------------------------------------------------------------------- /gtcore/list.go: -------------------------------------------------------------------------------- 1 | package gtcore 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "os" 9 | 10 | "github.com/koron/gtc/goenv" 11 | ) 12 | 13 | var ( 14 | listFilter string 15 | listShowPath bool 16 | listShowDesc bool 17 | listShowModule bool 18 | listShowLatest bool 19 | ) 20 | 21 | func list(fs *flag.FlagSet, args []string) error { 22 | fs.StringVar(&listFilter, "filter", "installed", 23 | `filter by status: "installed", "notinstalled" or "unknown"`) 24 | fs.BoolVar(&listShowPath, "path", false, `show path of catalogs`) 25 | fs.BoolVar(&listShowDesc, "desc", false, `show desc of catalogs`) 26 | fs.BoolVar(&listShowModule, "mod", false, `show module path of catalogs`) 27 | fs.BoolVar(&listShowLatest, "latest", false, `show latest version`) 28 | if err := fs.Parse(args); err != nil { 29 | return err 30 | } 31 | 32 | env := goenv.Default 33 | switch listFilter { 34 | case "installed": 35 | return listInstalled(env) 36 | case "notinstalled": 37 | return listNotInstalled(env) 38 | case "unknown": 39 | return listUnknown(env) 40 | default: 41 | return fmt.Errorf("unsupported filter: %s", listFilter) 42 | } 43 | } 44 | 45 | func listPrint(w io.Writer, tool Tool) { 46 | fmt.Fprintf(w, "%s\n", tool.CmdName()) 47 | if listShowPath { 48 | fmt.Fprintf(w, " Path: %s\n", tool.Path) 49 | } 50 | if listShowDesc { 51 | fmt.Fprintf(w, " Desc: %s\n", tool.Desc) 52 | } 53 | if listShowModule { 54 | fmt.Fprintf(w, " Module: %s\n", tool.ModulePath()) 55 | } 56 | if listShowLatest { 57 | info, err := tool.Latest(context.Background()) 58 | var latest string 59 | if err != nil { 60 | latest = fmt.Sprintf("(error: %s)", err) 61 | } else { 62 | latest = info.Version 63 | } 64 | fmt.Fprintf(w, " Latest: %s\n", latest) 65 | } 66 | } 67 | 68 | func listNotInstalled(env goenv.Env) error { 69 | for _, prog := range catalogNames() { 70 | if env.HasTool(prog) { 71 | continue 72 | } 73 | tool, _ := catalogFind(prog) 74 | listPrint(os.Stdout, tool) 75 | } 76 | return nil 77 | } 78 | 79 | func listInstalled(env goenv.Env) error { 80 | for _, prog := range catalogNames() { 81 | if !env.HasTool(prog) { 82 | continue 83 | } 84 | tool, _ := catalogFind(prog) 85 | listPrint(os.Stdout, tool) 86 | } 87 | return nil 88 | } 89 | 90 | func listUnknown(env goenv.Env) error { 91 | tools, err := env.Tools() 92 | if err != nil { 93 | return err 94 | } 95 | for _, t := range tools { 96 | _, ok := catalogFind(t) 97 | if ok { 98 | continue 99 | } 100 | fmt.Println(t) 101 | } 102 | return nil 103 | } 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Go Tool Catalog 2 | 3 | [![GoDoc](https://godoc.org/github.com/koron/gtc?status.svg)](https://godoc.org/github.com/koron/gtc) 4 | [![Actions/Go](https://github.com/koron/gtc/workflows/Go/badge.svg)](https://github.com/koron/gtc/actions?query=workflow%3AGo) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/koron/gtc)](https://goreportcard.com/report/github.com/koron/gtc) 6 | 7 | Gtc installs and updates well known tools written by golang. 8 | 9 | ## Install gtc 10 | 11 | ```console 12 | $ go get -u github.com/koron/gtc 13 | ``` 14 | 15 | ## Usages 16 | 17 | 18 | ```console 19 | # List tools installed 20 | $ go list 21 | 22 | # List tools not-installed 23 | $ go list -filter notinstalled 24 | 25 | # List tools unknown (for gtc) 26 | $ go list -filter unknown 27 | 28 | # Install a tool 29 | $ go install jvgrep 30 | 31 | # Install multiple tools 32 | $ go install goimports golint jvgrep 33 | 34 | # Update a tool 35 | $ go update jvgrep 36 | 37 | # Update multiple tools 38 | $ go update goimports golint jvgrep 39 | 40 | # Update all tools which has been installed and not touched over 5 days 41 | $ go update -all 42 | ``` 43 | 44 | ## Customize with your own catalog 45 | 46 | 47 | Create a new repository `github.com/{YOURNAME}/mygtc` with main.go like this: 48 | 49 | ```go 50 | package main 51 | 52 | import ( 53 | "fmt" 54 | "os" 55 | 56 | "github.com/koron/gtc/gtcore" 57 | ) 58 | 59 | func main() { 60 | err := gtcore.DefaultCatalog.Merge([]gtcore.Tool{ 61 | { 62 | Path: "github.com/{YOURNAME}/mygtc", 63 | Desc: "My own go tools catalog", 64 | }, 65 | // FIXME: add your favorite tools at here! 66 | }...).Run(os.Args) 67 | if err != nil { 68 | fmt.Println(err.Error()) 69 | os.Exit(1) 70 | } 71 | } 72 | ``` 73 | 74 | And push it to github, then install it: 75 | 76 | ```console 77 | $ go get -u github.com/{YOURNAME}/mygtc 78 | ``` 79 | 80 | Now you can run `mygtc` instead of `gtc`. 81 | 82 | ## Customize with JSON 83 | 84 | To load your own catalog from a file, prepare a JSON file like below. 85 | And set its filename to `GTC_CATALOG_FILE` environment variable. 86 | It will be merged with the default catalog. 87 | But tools in the default catalog overrides same name tools in your catalog. 88 | 89 | If you consider to manage your own catalog with git, 90 | you should manage it in [golang - main.go](#customize-with-your-own-catalog) 91 | instead of JSON. 92 | It can override the default catalog entirely. 93 | 94 | ```json 95 | [ 96 | { 97 | "path": "github.com/foo/foo", 98 | "desc": "your favorite foo" 99 | }, 100 | { 101 | "path": "github.com/foo/bar", 102 | "desc": "your favorite bar" 103 | }, 104 | ...(other your favorite tools)... 105 | ] 106 | ``` 107 | -------------------------------------------------------------------------------- /gtcore/default_catalog.go: -------------------------------------------------------------------------------- 1 | package gtcore 2 | 3 | var defaultTools = []Tool{ 4 | { 5 | Path: "github.com/achiku/planter", 6 | Desc: "Generate PlantUML ER diagram textual description from PostgreSQL tables", 7 | }, 8 | { 9 | Path: "github.com/client9/misspell/cmd/misspell", 10 | Desc: "Correct commonly misspelled English words in source files", 11 | }, 12 | { 13 | Path: "github.com/derekparker/delve/cmd/dlv", 14 | Desc: "Delve is a debugger for the Go programming language.", 15 | }, 16 | { 17 | Path: "github.com/fzipp/gocyclo", 18 | Desc: "Calculate cyclomatic complexities of functions in Go source code.", 19 | }, 20 | { 21 | Path: "github.com/go-swagger/go-swagger/cmd/swagger", 22 | Desc: "Swagger 2.0 implementation for go https://goswagger.io", 23 | }, 24 | { 25 | Path: "github.com/golang/protobuf/protoc-gen-go", 26 | Desc: "Go support for Google's protocol buffers", 27 | }, 28 | { 29 | Path: "github.com/grpc-ecosystem/grpc-gateway/protoc-gen-grpc-gateway", 30 | Desc: "gRPC to JSON proxy generator following the gRPC HTTP spec - gateway", 31 | }, 32 | { 33 | Path: "github.com/grpc-ecosystem/grpc-gateway/protoc-gen-swagger", 34 | Desc: "gRPC to JSON proxy generator following the gRPC HTTP spec - swagger", 35 | }, 36 | { 37 | Path: "github.com/jstemmer/gotags", 38 | Desc: "ctags-compatible tag generator for Go", 39 | }, 40 | { 41 | Path: "github.com/kisielk/errcheck", 42 | Desc: "errcheck checks that you checked errors.", 43 | }, 44 | { 45 | Path: "github.com/koron/gtc", 46 | Desc: "Go tools catalog", 47 | }, 48 | { 49 | Path: "github.com/koron/tmpl", 50 | Desc: "simple template expander", 51 | }, 52 | { 53 | Path: "github.com/mattn/jvgrep", 54 | Desc: "grep for japanese vimmer", 55 | }, 56 | { 57 | Path: "github.com/nsf/gocode", 58 | Desc: "An autocompletion daemon for the Go programming language", 59 | }, 60 | { 61 | Path: "golang.org/x/lint/golint", 62 | Desc: "a linter for Go source code", 63 | }, 64 | { 65 | Path: "golang.org/x/tools/cmd/goimports", 66 | Desc: "updates your Go import lines, adding missing ones and removing unreferenced ones.", 67 | }, 68 | { 69 | Path: "golang.org/x/tools/cmd/gorename", 70 | Desc: "The gorename command performs precise type-safe renaming of identifiers in Go source code.", 71 | }, 72 | { 73 | Path: "golang.org/x/tools/cmd/goyacc", 74 | Desc: "Goyacc is a version of yacc for Go.", 75 | }, 76 | { 77 | Path: "golang.org/x/tools/gopls", 78 | Desc: "The gols command is an LSP server for Go.", 79 | }, 80 | { 81 | Path: "honnef.co/go/tools/cmd/staticcheck", 82 | Desc: "staticcheck offers extensive analysis of Go code, covering a myriad of categories.", 83 | }, 84 | { 85 | Path: "honnef.co/go/tools/cmd/unused", 86 | Desc: "unused checks Go code for unused constants, variables, functions and types (deprecated)", 87 | }, 88 | } 89 | -------------------------------------------------------------------------------- /goenv/goenv.go: -------------------------------------------------------------------------------- 1 | package goenv 2 | 3 | import ( 4 | "go/build" 5 | "io/ioutil" 6 | "log" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | // Env is information of gtc running environment. 15 | type Env struct { 16 | // RootDir is first element of GOPATH 17 | RootDir string 18 | 19 | // ExeSuffix is extension of executable file name for the OS. 20 | // ".exe" for Windows, "" for other OS. 21 | ExeSuffix string 22 | 23 | // IsWindows is true for Windows. 24 | IsWindows bool 25 | 26 | EnableModule bool 27 | 28 | Verbose bool 29 | } 30 | 31 | // New creates `*Env` 32 | func New(root string) *Env { 33 | return &Env{ 34 | RootDir: root, 35 | } 36 | } 37 | 38 | func (env *Env) toolName(tool string) string { 39 | return filepath.Join(env.RootDir, "bin", tool+env.ExeSuffix) 40 | } 41 | 42 | // Uninstall uninstalls a tool from "$GOPATH/bin". 43 | func (env *Env) Uninstall(tool string) error { 44 | n := env.toolName(tool) 45 | _, err := os.Stat(n) 46 | if err != nil { 47 | if os.IsNotExist(err) { 48 | return nil 49 | } 50 | return err 51 | } 52 | err = os.Remove(n) 53 | if err != nil { 54 | // on Windows, running command can't be removed. So try to rename with 55 | // "~" suffix instead. 56 | if env.IsWindows && os.IsPermission(err) { 57 | err := os.Rename(n, n+"~") 58 | if err != nil { 59 | return err 60 | } 61 | if env.Verbose { 62 | log.Printf("uninstalled with rename: %s", tool) 63 | } 64 | return nil 65 | } 66 | return err 67 | } 68 | log.Printf("uninstalled: %s", tool) 69 | return nil 70 | } 71 | 72 | func (env *Env) touchTool(tool string) error { 73 | n := env.toolName(tool) 74 | now := time.Now() 75 | return os.Chtimes(n, now, now) 76 | } 77 | 78 | // HasTool checks a specified tool is installed or not. 79 | func (env *Env) HasTool(tool string) bool { 80 | name := env.toolName(tool) 81 | fi, err := os.Stat(name) 82 | if err != nil { 83 | return false 84 | } 85 | return fi.Mode().IsRegular() 86 | } 87 | 88 | func (env *Env) moduleEnvstr() string { 89 | if env.EnableModule { 90 | return "GO111MODULE=on" 91 | } 92 | return "GO111MODULE=off" 93 | } 94 | 95 | // Install installs a tool. 96 | func (env *Env) Install(path string) error { 97 | args := make([]string, 0, 3) 98 | args = append(args, "get") 99 | if env.Verbose { 100 | args = append(args, "-v") 101 | } 102 | args = append(args, path) 103 | c := exec.Command("go", args...) 104 | c.Env = os.Environ() 105 | c.Env = append(c.Env, env.moduleEnvstr()) 106 | 107 | b, err := c.CombinedOutput() 108 | if err != nil { 109 | os.Stderr.Write(b) 110 | return err 111 | } 112 | return nil 113 | } 114 | 115 | // Update update a tool. 116 | func (env *Env) Update(path, tool string) error { 117 | args := make([]string, 0, 4) 118 | args = append(args, "get", "-u") 119 | if env.Verbose { 120 | args = append(args, "-v") 121 | } 122 | args = append(args, path) 123 | c := exec.Command("go", args...) 124 | c.Env = os.Environ() 125 | c.Env = append(c.Env, env.moduleEnvstr()) 126 | 127 | b, err := c.CombinedOutput() 128 | if err != nil { 129 | os.Stderr.Write(b) 130 | return err 131 | } 132 | err = env.touchTool(tool) 133 | if err != nil { 134 | return err 135 | } 136 | return nil 137 | } 138 | 139 | func (env *Env) tools(filter func(os.FileInfo) bool) ([]string, error) { 140 | files, err := ioutil.ReadDir(filepath.Join(env.RootDir, "bin")) 141 | if err != nil { 142 | return nil, err 143 | } 144 | var tools []string 145 | for _, fi := range files { 146 | if !fi.Mode().IsRegular() || !filter(fi) { 147 | continue 148 | } 149 | n := fi.Name() 150 | if env.ExeSuffix != "" && strings.HasSuffix(n, env.ExeSuffix) { 151 | n = n[:len(n)-len(env.ExeSuffix)] 152 | } 153 | tools = append(tools, n) 154 | } 155 | return tools, nil 156 | } 157 | 158 | // Tools returns all installed tools. 159 | func (env *Env) Tools() ([]string, error) { 160 | return env.tools(func(os.FileInfo) bool { return true }) 161 | } 162 | 163 | // OldTools returns list of tools, which are not updated recently. 164 | func (env *Env) OldTools(pivot time.Time) ([]string, error) { 165 | return env.tools(func(fi os.FileInfo) bool { 166 | return fi.ModTime().Before(pivot) 167 | }) 168 | } 169 | 170 | func defaultEnv(bc build.Context) Env { 171 | gopath := filepath.SplitList(bc.GOPATH) 172 | if len(gopath) == 0 { 173 | panic("GOPATH isn't set") 174 | } 175 | var ( 176 | exeSuffix string 177 | isWindows bool 178 | ) 179 | if bc.GOOS == "windows" { 180 | exeSuffix = ".exe" 181 | isWindows = true 182 | } 183 | return Env{ 184 | RootDir: gopath[0], 185 | ExeSuffix: exeSuffix, 186 | IsWindows: isWindows, 187 | } 188 | } 189 | 190 | func init() { 191 | Default = defaultEnv(build.Default) 192 | } 193 | 194 | // Default is default `Env` for current running environment. 195 | var Default Env 196 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: [push] 4 | 5 | permissions: 6 | contents: write 7 | 8 | env: 9 | GO_VERSION: '>=1.24.0' 10 | 11 | jobs: 12 | 13 | check: 14 | name: Check if the main package exists 15 | outputs: 16 | targets: ${{ steps.list.outputs.targets }} 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | 22 | - uses: actions/checkout@v6 23 | - uses: actions/setup-go@v6 24 | with: 25 | go-version: ${{ env.GO_VERSION }} 26 | 27 | - name: List "main" packages to be released 28 | id: list 29 | run: | 30 | found=0 31 | echo "targets<<__END__" >> $GITHUB_OUTPUT 32 | go list -f '{{if (eq .Name "main")}}{{.ImportPath}} .{{slice .ImportPath (len .Module.Path)}}{{end}}' ./... | while IFS= read -r line ; do 33 | read -a e <<< "$line" 34 | path="${e[0]}" 35 | dir="${e[1]}" 36 | name=$(basename $path) 37 | if [ -f "$dir/.norelease" ] ; then 38 | echo -e "Skipped $name\t($dir), due to $dir/.norelease found" 39 | else 40 | found=1 41 | echo "$name $dir $path" >> $GITHUB_OUTPUT 42 | echo -e "Added $name\t($dir) to the release" 43 | fi 44 | done 45 | echo "__END__" >> $GITHUB_OUTPUT 46 | if [[ $found == 0 ]] ; then 47 | echo "⛔ No packages found to release" 48 | fi 49 | 50 | build: 51 | name: Build releases 52 | 53 | needs: check 54 | if: needs.check.outputs.targets != '' 55 | 56 | env: 57 | RELEASE_TARGETS: ${{needs.check.outputs.targets}} 58 | 59 | strategy: 60 | fail-fast: false 61 | matrix: 62 | os: 63 | - ubuntu-latest 64 | - ubuntu-24.04-arm 65 | - macos-latest 66 | - windows-latest 67 | arch: 68 | - amd64 69 | - arm64 70 | exclude: 71 | - os: windows-latest 72 | arch: arm64 73 | - os: ubuntu-latest 74 | arch: arm64 75 | - os: ubuntu-24.04-arm 76 | arch: amd64 77 | 78 | runs-on: ${{ matrix.os }} 79 | 80 | steps: 81 | 82 | - uses: actions/checkout@v6 83 | - uses: actions/setup-go@v6 84 | with: 85 | go-version: ${{ env.GO_VERSION }} 86 | 87 | - name: Setup env 88 | id: setup 89 | shell: bash 90 | run: | 91 | export NAME="${GITHUB_REPOSITORY#${GITHUB_REPOSITORY_OWNER}/}" 92 | if [[ ${GITHUB_REF} =~ ^refs/tags/v[0-9]+\.[0-9]+ ]] ; then 93 | export VERSION=${GITHUB_REF_NAME} 94 | else 95 | export VERSION=SNAPSHOT 96 | fi 97 | echo "VERSION=${VERSION}" >> $GITHUB_ENV 98 | case ${{ matrix.os }} in 99 | ubuntu-*) 100 | export GOOS=linux 101 | export PKGEXT=.tar.gz 102 | ;; 103 | macos-*) 104 | export GOOS=darwin 105 | export PKGEXT=.zip 106 | ;; 107 | windows-*) 108 | choco install zip 109 | export GOOS=windows 110 | export PKGEXT=.zip 111 | ;; 112 | esac 113 | export GOARCH=${{ matrix.arch }} 114 | echo "GOOS=${GOOS}" >> $GITHUB_ENV 115 | echo "GOARCH=${GOARCH}" >> $GITHUB_ENV 116 | echo "CGO_ENABLED=1" >> $GITHUB_ENV 117 | echo "PKGNAME=${NAME}_${VERSION}_${GOOS}_${GOARCH}" >> $GITHUB_ENV 118 | echo "PKGEXT=${PKGEXT}" >> $GITHUB_ENV 119 | 120 | - name: Build all "main" packages 121 | shell: bash 122 | run: | 123 | echo "$RELEASE_TARGETS" | while IFS= read -r line ; do 124 | read -a entry <<< "$line" 125 | printf "building %s\t(%s)\n" "${entry[0]}" "${entry[1]}" 126 | ( cd "${entry[1]}" && go build ) 127 | done 128 | 129 | - name: Archive 130 | shell: bash 131 | run: | 132 | mkdir -p _build/${PKGNAME} 133 | 134 | echo "$RELEASE_TARGETS" | while IFS= read -r line ; do 135 | read -a entry <<< "$line" 136 | cp "${entry[1]}/${entry[0]}" _build/${PKGNAME} 137 | done 138 | 139 | cp -p LICENSE _build/${PKGNAME} 140 | cp -p README.md _build/${PKGNAME} 141 | 142 | case "${PKGEXT}" in 143 | ".tar.gz") 144 | tar caf _build/${PKGNAME}${PKGEXT} -C _build ${PKGNAME} 145 | ;; 146 | ".zip") 147 | (cd _build && zip -r9q ${PKGNAME}${PKGEXT} ${PKGNAME}) 148 | ;; 149 | esac 150 | ls -laFR _build 151 | 152 | - name: Artifact upload 153 | uses: actions/upload-artifact@v5 154 | with: 155 | name: ${{ env.GOOS }}_${{ env.GOARCH }} 156 | path: _build/${{ env.PKGNAME }}${{ env.PKGEXT }} 157 | 158 | create-release: 159 | name: Create release 160 | runs-on: ubuntu-latest 161 | if: startsWith(github.ref, 'refs/tags/') 162 | needs: 163 | - build 164 | steps: 165 | - uses: actions/download-artifact@v6 166 | with: { name: darwin_amd64 } 167 | - uses: actions/download-artifact@v6 168 | with: { name: darwin_arm64 } 169 | - uses: actions/download-artifact@v6 170 | with: { name: linux_amd64 } 171 | - uses: actions/download-artifact@v6 172 | with: { name: linux_arm64 } 173 | - uses: actions/download-artifact@v6 174 | with: { name: windows_amd64 } 175 | - run: ls -lafR 176 | - name: Release 177 | uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 178 | with: 179 | draft: true 180 | prerelease: ${{ contains(github.ref_name, '-alpha.') || contains(github.ref_name, '-beta.') }} 181 | files: | 182 | *.tar.gz 183 | *.zip 184 | fail_on_unmatched_files: true 185 | generate_release_notes: true 186 | append_body: true 187 | 188 | # based on: github.com/koron-go/_skeleton/.github/workflows/release.yml 189 | # $Hash:a9354833cf3f815f347887d0b11c95c61a362262f9715036ff827975$ 190 | --------------------------------------------------------------------------------