diff --git a/Earthfile b/Earthfile index 6bd3e59..3ad4128 100644 --- a/Earthfile +++ b/Earthfile @@ -1,10 +1,14 @@ # To lint, install Earthly and run `earth +lint` # This ensures the usage of the same version of golangci-lint -FROM golangci/golangci-lint:v1.31 +FROM golangci/golangci-lint:v1.37 WORKDIR /gpm -lint: +lint-cli: COPY . . + RUN golangci-lint run + +lint-lib: + COPY ./go-gpm . RUN golangci-lint run \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..433f7db --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2021 John Olheiser + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile index 64cc957..5cdd052 100644 --- a/Makefile +++ b/Makefile @@ -3,12 +3,39 @@ VERSION ?= $(shell git describe --tags --always | sed 's/-/+/' | sed 's/^v//') .PHONY: build build: - $(GO) build -ldflags '-s -w -X "go.jolheiser.com/gpm/config.Version=$(VERSION)"' + $(GO) build -ldflags '-s -w -X "go.jolheiser.com/gpm/router.Version=$(VERSION)"' + +.PHONY: lint +lint: + earth +lint-cli + earth +lint-lib .PHONY: fmt -fmt: - $(GO) fmt ./... +fmt: fmt-cli fmt-lib .PHONY: test -test: +test: test-cli test-lib + +.PHONY: fmt-cli +fmt-cli: + $(GO) fmt ./... + +.PHONY: test-cli +test-cli: $(GO) test -race ./... + +.PHONY: fmt-lib +fmt-lib: + @cd go-gpm && $(GO) fmt ./... + +.PHONY: test-lib +test-lib: + @cd go-gpm && $(GO) test -race ./... + +.PHONY: docker-build +docker-build: + docker build -f docker/Dockerfile -t jolheiser/gpm . + +.PHONY: docker-push +docker-push: docker-build + docker push jolheiser/gpm \ No newline at end of file diff --git a/README.md b/README.md index 3b27702..926d6fc 100644 --- a/README.md +++ b/README.md @@ -17,18 +17,21 @@ Using either a GPM server or local config, I can instead `gpm get cli` which fin * `remove` - Remove a local package * `list` - List local packages * `config` - Change local configuration -* `export` - Export local packages to JSON -* `import` - Import JSON to local packages. Either give a path to a `.json` file, or a URL to a GPM server export endpoint - * e.g. `https://gpm.jolheiser.com/export` * `get` - Get a list of packages - * e.g. `gpm get beaver survey toml homedir cli` to get all the modules needed for gpm itself (assuming the map resolves to the same packages) + * e.g. `gpm get beaver survey bbolt cli chi` to get all the modules needed for gpm itself (assuming the map resolves to the same packages) * `server` - Start a gpm server ### Server -If GPM doesn't find a package locally, it can call out to a configurable gpm server to find a package there instead. +gpm will call out to a gpm server to find a package. This makes it much simpler to have a central library of packages rather than exporting and importing between environments. Want to run your own server? It's very easy! This CLI comes packaged with the server inside, simply run `gpm server` to start up a GPM server. -Put it behind your favorite reverse proxy and it's ready to go! +Remember to set a `--token`! +Put it behind your favorite reverse proxy, and it's ready to go! + + +## License + +[MIT](LICENSE) \ No newline at end of file diff --git a/cmd/add.go b/cmd/add.go index 2ee2e71..8e6cd0a 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -1,10 +1,13 @@ package cmd import ( + "context" "regexp" "strings" - "go.jolheiser.com/gpm/config" + "go.jolheiser.com/gpm/cmd/flags" + "go.jolheiser.com/gpm/database" + "go.jolheiser.com/gpm/go-gpm" "github.com/AlecAivazis/survey/v2" "github.com/urfave/cli/v2" @@ -12,26 +15,30 @@ import ( ) var Add = cli.Command{ - Name: "add", - Usage: "Add a package", + Name: "add", + Aliases: []string{"a"}, + Usage: "Add a package", Flags: []cli.Flag{ &cli.BoolFlag{ - Name: "force", - Aliases: []string{"f"}, - Usage: "Overwrite existing package without prompt", + Name: "force", + Aliases: []string{"f"}, + Usage: "Overwrite existing package without prompt", + Destination: &flags.Force, + }, + &cli.BoolFlag{ + Name: "local", + Aliases: []string{"l"}, + Usage: "local mode", + Destination: &flags.Local, }, }, + Before: localOrToken, Action: doAdd, } var vPattern = regexp.MustCompile(`v\d+$`) -func doAdd(ctx *cli.Context) error { - cfg, err := config.Load() - if err != nil { - return err - } - +func doAdd(_ *cli.Context) error { goGetQuestion := &survey.Input{ Message: "Package go-get import", } @@ -58,16 +65,26 @@ func doAdd(ctx *cli.Context) error { return err } - pkg := config.Package{ + pkg := gpm.Package{ Name: nameAnswer, Import: goGetAnswer, } - cfg.AddPackages(ctx.Bool("force"), pkg) - if err := cfg.Save(); err != nil { - return err + if flags.Local { + db, err := database.Load(flags.Database) + if err != nil { + return err + } + if err := db.PutPackage(pkg); err != nil { + return err + } + } else { + client := gpm.New(flags.Token, gpm.WithServer(flags.Server)) + if err := client.Add(context.Background(), pkg); err != nil { + return err + } } - beaver.Infof("Added `%s` to local gpm.", nameAnswer) + beaver.Infof("Added %s", yellow.Format(nameAnswer)) return nil } diff --git a/cmd/cmd.go b/cmd/cmd.go index b574758..6c99021 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -1,18 +1,104 @@ package cmd import ( - "go.jolheiser.com/gpm/config" + "context" + "errors" + "os" + "path/filepath" + + "go.jolheiser.com/gpm/cmd/flags" + "go.jolheiser.com/gpm/database" + "go.jolheiser.com/gpm/go-gpm" + "go.jolheiser.com/gpm/router" "github.com/urfave/cli/v2" + "go.jolheiser.com/beaver/color" ) -func NewFlags(cfg *config.Config) []cli.Flag { - return []cli.Flag{ +var yellow = color.FgYellow + +func New() *cli.App { + app := cli.NewApp() + app.Name = "gpm" + app.Usage = "Go Package Manager" + app.Version = router.Version + app.Commands = []*cli.Command{ + &Add, + &Get, + &List, + &Remove, + &Search, + &Server, + &Update, + } + app.Flags = []cli.Flag{ &cli.StringFlag{ - Name: "url", - Aliases: []string{"u"}, - Usage: "gpm server to use", - Value: cfg.GPMURL, + Name: "server", + Aliases: []string{"s"}, + Usage: "gpm server to use", + Value: gpm.DefaultServer, + EnvVars: []string{"GPM_SERVER"}, + Destination: &flags.Server, + }, + &cli.StringFlag{ + Name: "token", + Aliases: []string{"t"}, + Usage: "gpm auth token to use", + DefaultText: "${GPM_TOKEN}", + EnvVars: []string{"GPM_TOKEN"}, + Destination: &flags.Token, + }, + &cli.StringFlag{ + Name: "database", + Aliases: []string{"d"}, + Usage: "path to gpm database for server", + Value: dbPath(), + DefaultText: "`${HOME}/gpm.db` or `${BINPATH}/gpm.db`", + EnvVars: []string{"GPM_DATABASE"}, + Destination: &flags.Database, }, } + return app +} + +func dbPath() string { + fn := "gpm.db" + home, err := os.UserHomeDir() + if err != nil { + bin, err := os.Executable() + if err != nil { + return fn + } + return filepath.Join(filepath.Dir(bin), fn) + } + return filepath.Join(home, fn) +} + +func localOrToken(_ *cli.Context) error { + if flags.Local && flags.Token == "" { + return errors.New("server interaaction requires --token") + } + return nil +} + +func listPackages() ([]gpm.Package, error) { + var pkgs []gpm.Package + if flags.Local { + db, err := database.Load(flags.Database) + if err != nil { + return pkgs, err + } + pkgs, err = db.Packages() + if err != nil { + return pkgs, err + } + } else { + client := gpm.New(flags.Token, gpm.WithServer(flags.Server)) + info, err := client.Info(context.Background()) + if err != nil { + return pkgs, err + } + pkgs = info.Packages + } + return pkgs, nil } diff --git a/cmd/config.go b/cmd/config.go deleted file mode 100644 index 04be274..0000000 --- a/cmd/config.go +++ /dev/null @@ -1,41 +0,0 @@ -package cmd - -import ( - "go.jolheiser.com/gpm/config" - - "github.com/AlecAivazis/survey/v2" - "github.com/urfave/cli/v2" - "go.jolheiser.com/beaver" -) - -var Config = cli.Command{ - Name: "config", - Aliases: []string{"cfg"}, - Usage: "Configure local gpm", - Action: doConfig, -} - -func doConfig(_ *cli.Context) error { - cfg, err := config.Load() - if err != nil { - return err - } - - urlQuestion := &survey.Input{ - Message: "gpm URL", - Default: cfg.GPMURL, - } - var urlAnswer string - - if err := survey.AskOne(urlQuestion, &urlAnswer); err != nil { - return err - } - - cfg.GPMURL = urlAnswer - if err := cfg.Save(); err != nil { - return err - } - - beaver.Info("gpm URL saved!") - return nil -} diff --git a/cmd/export.go b/cmd/export.go deleted file mode 100644 index c699a63..0000000 --- a/cmd/export.go +++ /dev/null @@ -1,30 +0,0 @@ -package cmd - -import ( - "fmt" - - "go.jolheiser.com/gpm/config" - - "github.com/urfave/cli/v2" -) - -var Export = cli.Command{ - Name: "export", - Usage: "Export JSON for local packages", - Action: doExport, -} - -func doExport(_ *cli.Context) error { - cfg, err := config.Load() - if err != nil { - return err - } - - export, err := cfg.Export() - if err != nil { - return err - } - - fmt.Println(export) - return nil -} diff --git a/cmd/flags/flags.go b/cmd/flags/flags.go new file mode 100644 index 0000000..e4a4f67 --- /dev/null +++ b/cmd/flags/flags.go @@ -0,0 +1,11 @@ +package flags + +var ( + Server string + Token string + Database string + + Local bool + Force bool + Port int +) diff --git a/cmd/get.go b/cmd/get.go index 95e9ee6..ba6e39e 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -1,15 +1,13 @@ package cmd import ( - "encoding/json" - "fmt" - "io" - "net/http" + "context" "os" "os/exec" "strings" - "go.jolheiser.com/gpm/config" + "go.jolheiser.com/gpm/cmd/flags" + "go.jolheiser.com/gpm/go-gpm" "github.com/AlecAivazis/survey/v2" "github.com/urfave/cli/v2" @@ -17,27 +15,13 @@ import ( ) var Get = cli.Command{ - Name: "get", - Usage: "Get package(s)", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "ignore-local", - Usage: "Ignore local packages", - }, - &cli.BoolFlag{ - Name: "offline", - Usage: "Offline mode, return error instead of querying server", - }, - }, - Action: doGet, + Name: "get", + Aliases: []string{"g"}, + Usage: "Get package(s)", + Action: doGet, } func doGet(ctx *cli.Context) error { - cfg, err := config.Load() - if err != nil { - return err - } - pkgs := ctx.Args().Slice() if len(pkgs) == 0 { pkgsQuestion := &survey.Multiline{ @@ -52,27 +36,16 @@ func doGet(ctx *cli.Context) error { pkgs = strings.Split(pkgsAnswer, "\n") } - local := cfg.Packages.Map() - for _, pkg := range pkgs { - var url string - if u, ok := local[pkg]; ok && !ctx.Bool("ignore-local") { - url = u.Import - } else if !ctx.Bool("offline") { - u, err := queryServer(ctx.String("url"), pkg) - if err != nil { - beaver.Error(err) - continue - } - url = u - } - - if url == "" { - beaver.Errorf("no package found for `%s`", pkg) + client := gpm.New(flags.Token, gpm.WithServer(flags.Server)) + for _, p := range pkgs { + pkg, err := client.Get(context.Background(), p) + if err != nil { + beaver.Error(err) continue } beaver.Infof("getting `%s`...", pkg) - if err := goGet(url); err != nil { + if err := goGet(pkg.Import); err != nil { beaver.Error(err) } } @@ -80,31 +53,6 @@ func doGet(ctx *cli.Context) error { return nil } -func queryServer(server, name string) (string, error) { - endpoint := fmt.Sprintf("%s/package/%s", server, name) - resp, err := http.Get(endpoint) - if err != nil { - return "", fmt.Errorf("could not query server at `%s`", endpoint) - } - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("could not find server package for `%s`", name) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - defer resp.Body.Close() - - var pkg config.Package - if err := json.Unmarshal(body, &pkg); err != nil { - return "", err - } - - return pkg.Import, nil -} - func goGet(url string) error { cmd := exec.Command("go", "get", url) cmd.Stdout = os.Stdout diff --git a/cmd/import.go b/cmd/import.go deleted file mode 100644 index 5a40e9b..0000000 --- a/cmd/import.go +++ /dev/null @@ -1,79 +0,0 @@ -package cmd - -import ( - "encoding/json" - "errors" - "io" - "net/http" - "os" - "strings" - - "go.jolheiser.com/gpm/config" - - "github.com/urfave/cli/v2" - "go.jolheiser.com/beaver" -) - -var Import = cli.Command{ - Name: "import", - Usage: "Import JSON for local packages", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "force", - Aliases: []string{"f"}, - Usage: "Overwrite any existing packages without prompt", - }, - }, - Action: doImport, -} - -func doImport(ctx *cli.Context) error { - cfg, err := config.Load() - if err != nil { - return err - } - - if ctx.NArg() == 0 { - return errors.New("must point to either a JSON file or gpm server export endpoint") - } - - arg := ctx.Args().First() - isJSON := strings.HasSuffix(arg, ".json") - isHTTP := strings.HasPrefix(arg, "http") - - if !isJSON && !isHTTP { - return errors.New("must point to either a JSON file or gpm server export endpoint") - } - - var data []byte - if isJSON { - data, err = os.ReadFile(arg) - if err != nil { - return err - } - } else if isHTTP { - resp, err := http.Get(arg) - if err != nil { - return err - } - - data, err = io.ReadAll(resp.Body) - if err != nil { - return err - } - defer resp.Body.Close() - } - - var importPkgs []config.Package - if err := json.Unmarshal(data, &importPkgs); err != nil { - return err - } - cfg.AddPackages(ctx.Bool("force"), importPkgs...) - - if err := cfg.Save(); err != nil { - return err - } - - beaver.Info("Import complete") - return nil -} diff --git a/cmd/list.go b/cmd/list.go index bdf825b..9d53d85 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -1,27 +1,29 @@ package cmd import ( - "go.jolheiser.com/gpm/config" + "fmt" + "os" + "text/tabwriter" "github.com/urfave/cli/v2" - "go.jolheiser.com/beaver" ) var List = cli.Command{ Name: "list", - Aliases: []string{"l"}, + Aliases: []string{"ls", "l"}, Usage: "List local packages", Action: doList, } func doList(_ *cli.Context) error { - cfg, err := config.Load() + pkgs, err := listPackages() if err != nil { return err } - - for _, pkg := range cfg.Packages { - beaver.Infof("%s -> %s", pkg.Name, pkg.Import) + w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', 0) + for _, pkg := range pkgs { + s := fmt.Sprintf("%s\t%s\n", pkg.Name, pkg.Import) + _, _ = w.Write([]byte(s)) } - return nil + return w.Flush() } diff --git a/cmd/remove.go b/cmd/remove.go index ea38461..ad15807 100644 --- a/cmd/remove.go +++ b/cmd/remove.go @@ -1,10 +1,11 @@ package cmd import ( - "fmt" - "strings" + "context" - "go.jolheiser.com/gpm/config" + "go.jolheiser.com/gpm/cmd/flags" + "go.jolheiser.com/gpm/database" + "go.jolheiser.com/gpm/go-gpm" "github.com/AlecAivazis/survey/v2" "github.com/urfave/cli/v2" @@ -15,49 +16,53 @@ var Remove = cli.Command{ Name: "remove", Aliases: []string{"rm"}, Usage: "Remove package(s)", + Before: localOrToken, Action: doRemove, } func doRemove(_ *cli.Context) error { - cfg, err := config.Load() + pkgs, err := listPackages() if err != nil { return err } - pkgQuestion := &survey.Input{ - Message: "Package name", + pkgSlice := make([]string, len(pkgs)) + pkgMap := make(map[string]gpm.Package) + for idx, pkg := range pkgs { + pkgSlice[idx] = pkg.Name + pkgMap[pkg.Name] = pkg } - var pkgAnswer string - if err := survey.AskOne(pkgQuestion, &pkgAnswer); err != nil { + pkgQuestion := &survey.Select{ + Message: "Select package to remove", + Options: pkgSlice, + } + + var pkgName string + if err := survey.AskOne(pkgQuestion, &pkgName); err != nil { return err } - for idx, p := range cfg.Packages { - if strings.EqualFold(p.Name, pkgAnswer) { - confirm := &survey.Confirm{ - Message: fmt.Sprintf("Are you sure you want to remove %s (%s) ?", p.Name, p.Import), - Default: false, - } - var answer bool + pkg := gpm.Package{ + Name: pkgName, + Import: pkgMap[pkgName].Import, + } - if err := survey.AskOne(confirm, &answer); err != nil { - return err - } - - if answer { - cfg.Packages = append(cfg.Packages[:idx], cfg.Packages[idx+1:]...) - if err := cfg.Save(); err != nil { - return err - } - beaver.Infof("Removed `%s` from local gpm.", p.Name) - break - } - - beaver.Infof("Did not remove `%s` from local gpm.", p.Name) - break + if flags.Local { + db, err := database.Load(flags.Database) + if err != nil { + return err + } + if err := db.RemovePackage(pkg.Name); err != nil { + return err + } + } else { + client := gpm.New(flags.Token, gpm.WithServer(flags.Server)) + if err := client.Remove(context.Background(), pkg); err != nil { + return err } } + beaver.Infof("Removed %s", yellow.Format(pkgName)) return nil } diff --git a/cmd/search.go b/cmd/search.go index b07d490..7aef35d 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -1,12 +1,7 @@ package cmd import ( - "encoding/json" - "fmt" - "io" - "net/http" - - "go.jolheiser.com/gpm/config" + "go.jolheiser.com/gpm/go-gpm" "github.com/AlecAivazis/survey/v2" "github.com/urfave/cli/v2" @@ -17,35 +12,25 @@ var Search = cli.Command{ Name: "search", Aliases: []string{"s"}, Usage: "Search packages", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "local", - Usage: "Search locally", - }, - }, - Action: doSearch, + Action: doSearch, } -func doSearch(ctx *cli.Context) error { - cfg, err := config.Load() +func doSearch(_ *cli.Context) error { + pkgs, err := listPackages() if err != nil { return err } - packageMap := cfg.Packages.Map() - packageSlice := cfg.Packages.Slice() - if !ctx.Bool("local") { - export, err := queryExport(ctx.String("url")) - if err != nil { - return err - } - packageMap = export.Map() - packageSlice = export.Slice() + pkgSlice := make([]string, len(pkgs)) + pkgMap := make(map[string]gpm.Package) + for idx, pkg := range pkgs { + pkgSlice[idx] = pkg.Name + pkgMap[pkg.Name] = pkg } q := &survey.MultiSelect{ Message: "Select packages", - Options: packageSlice, + Options: pkgSlice, } var a []string @@ -54,7 +39,7 @@ func doSearch(ctx *cli.Context) error { } for _, name := range a { - pkg, ok := packageMap[name] + pkg, ok := pkgMap[name] if !ok { beaver.Errorf("could not find package for `%s`", name) continue @@ -67,23 +52,3 @@ func doSearch(ctx *cli.Context) error { return nil } - -func queryExport(server string) (config.Packages, error) { - resp, err := http.Get(fmt.Sprintf("%s/export", server)) - if err != nil { - return nil, err - } - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var importPkgs config.Packages - if err := json.Unmarshal(data, &importPkgs); err != nil { - return nil, err - } - - return importPkgs, nil -} diff --git a/cmd/server.go b/cmd/server.go index f592e32..9931fbc 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -1,10 +1,12 @@ package cmd import ( + "errors" "fmt" "net/http" - "go.jolheiser.com/gpm/config" + "go.jolheiser.com/gpm/cmd/flags" + "go.jolheiser.com/gpm/database" "go.jolheiser.com/gpm/router" "github.com/urfave/cli/v2" @@ -12,27 +14,34 @@ import ( ) var Server = cli.Command{ - Name: "server", - Usage: "Start the gpm server", + Name: "server", + Aliases: []string{"web"}, + Usage: "Start the gpm server", Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "port", - Aliases: []string{"p"}, - Usage: "Port to run the gpm server on", - Value: "3333", + &cli.IntFlag{ + Name: "port", + Aliases: []string{"p"}, + Usage: "Port to run the gpm server on", + Value: 3333, + EnvVars: []string{"GPM_PORT"}, + Destination: &flags.Port, }, }, Action: doServer, } -func doServer(ctx *cli.Context) error { - cfg, err := config.Load() - if err != nil { - return err +func doServer(_ *cli.Context) error { + if flags.Token == "" { + return errors.New("gpm server requires --token") } - beaver.Infof("Running gpm server at http://localhost:%s", ctx.String("port")) - if err := http.ListenAndServe(fmt.Sprintf(":%s", ctx.String("port")), router.New(cfg)); err != nil { + db, err := database.Load(flags.Database) + if err != nil { + beaver.Fatalf("could not load database at %s: %v", flags.Database, err) + } + + beaver.Infof("Running gpm server at http://localhost:%d", flags.Port) + if err := http.ListenAndServe(fmt.Sprintf(":%d", flags.Port), router.New(flags.Token, db)); err != nil { return err } return nil diff --git a/cmd/update.go b/cmd/update.go new file mode 100644 index 0000000..690e235 --- /dev/null +++ b/cmd/update.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "context" + + "go.jolheiser.com/gpm/cmd/flags" + "go.jolheiser.com/gpm/database" + "go.jolheiser.com/gpm/go-gpm" + + "github.com/AlecAivazis/survey/v2" + "github.com/urfave/cli/v2" + "go.jolheiser.com/beaver" +) + +var Update = cli.Command{ + Name: "update", + Aliases: []string{"u"}, + Usage: "Update a package", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "local", + Aliases: []string{"l"}, + Usage: "local mode", + Destination: &flags.Local, + }, + }, + Before: localOrToken, + Action: doUpdate, +} + +func doUpdate(_ *cli.Context) error { + pkgs, err := listPackages() + if err != nil { + return err + } + + pkgSlice := make([]string, len(pkgs)) + pkgMap := make(map[string]gpm.Package) + for idx, pkg := range pkgs { + pkgSlice[idx] = pkg.Name + pkgMap[pkg.Name] = pkg + } + + pkgQuestion := &survey.Select{ + Message: "Select package to update", + Options: pkgSlice, + } + + var pkgName string + if err := survey.AskOne(pkgQuestion, &pkgName); err != nil { + return err + } + + importQuestion := &survey.Input{ + Message: "New import path", + Default: pkgMap[pkgName].Import, + } + + var importPath string + if err := survey.AskOne(importQuestion, &importPath); err != nil { + return err + } + + pkg := gpm.Package{ + Name: pkgName, + Import: importPath, + } + + if flags.Local { + db, err := database.Load(flags.Database) + if err != nil { + return err + } + if err := db.PutPackage(pkg); err != nil { + return err + } + } else { + client := gpm.New(flags.Token, gpm.WithServer(flags.Server)) + if err := client.Update(context.Background(), pkg); err != nil { + return err + } + } + + beaver.Infof("Updated %s", yellow.Format(pkgName)) + return nil +} diff --git a/config/config.go b/config/config.go deleted file mode 100644 index a3d6ef5..0000000 --- a/config/config.go +++ /dev/null @@ -1,146 +0,0 @@ -package config - -import ( - "encoding/json" - "fmt" - "os" - "path" - "strings" - - "github.com/AlecAivazis/survey/v2" - "github.com/pelletier/go-toml" - "go.jolheiser.com/beaver" -) - -var Version = "develop" - -type Config struct { - path string - GPMURL string `toml:"gpm-url" json:"gpm_url"` - Packages Packages `toml:"package" json:"packages"` -} - -type Package struct { - Name string `toml:"name" json:"name"` - Import string `toml:"import" json:"import"` -} - -type Packages []Package - -func Load() (*Config, error) { - home, err := os.UserHomeDir() - if err != nil { - return nil, fmt.Errorf("could not get user home dir: %v", err) - } - - home = path.Join(home, ".gpm") - homeEnv := os.Getenv("GPM_HOME") - if homeEnv != "" { - home = homeEnv - } - - configPath := path.Join(home, "gpm.toml") - configEnv := os.Getenv("GPM_CONFIG") - if configEnv != "" { - configPath = configEnv - } - - if _, err := os.Stat(configPath); os.IsNotExist(err) { - if err := os.MkdirAll(path.Dir(configPath), os.ModePerm); err != nil { - return nil, fmt.Errorf("could not create gpm home: %v", err) - } - - if _, err := os.Create(configPath); err != nil { - return nil, fmt.Errorf("could not create gpm config: %v", err) - } - } - - var cfg Config - tree, err := toml.LoadFile(configPath) - if err != nil { - return nil, fmt.Errorf("could not decode gpm config: %v", err) - } - if err = tree.Unmarshal(&cfg); err != nil { - return nil, fmt.Errorf("could not unmarshal config: %v", err) - } - - dupe := make(map[string]bool) - for _, pkg := range cfg.Packages { - name := strings.ToLower(pkg.Name) - if ok := dupe[name]; ok { - return nil, fmt.Errorf("duplicate package for %s", pkg.Name) - } - dupe[name] = true - } - - cfg.path = configPath - return &cfg, nil -} - -func (c *Config) Save() error { - fi, err := os.Create(c.path) - if err != nil { - return err - } - defer fi.Close() - - if err := toml.NewEncoder(fi).Encode(c); err != nil { - return err - } - - return nil -} - -func (c *Config) Export() (string, error) { - data, err := json.Marshal(c.Packages) - return string(data), err -} - -func (p Packages) Slice() []string { - pkgs := make([]string, len(p)) - for idx, pkg := range p { - pkgs[idx] = fmt.Sprintf("%s (%s)", pkg.Name, pkg.Import) - } - return pkgs -} - -func (p Packages) Map() map[string]Package { - pkgs := make(map[string]Package) - for _, pkg := range p { - pkgs[pkg.Name] = pkg - } - return pkgs -} - -func (c *Config) AddPackages(force bool, pkgs ...Package) { - for _, pkg := range pkgs { - for idx, p := range c.Packages { - if strings.EqualFold(p.Name, pkg.Name) { - if force { - c.Packages[idx] = pkg - break - } - - forceQuestion := &survey.Confirm{ - Message: fmt.Sprintf("Package `%s` (%s) already exists. Overwrite with `%s`?", p.Name, p.Import, p.Import), - Default: false, - } - var forceAnswer bool - - if err := survey.AskOne(forceQuestion, &forceAnswer); err != nil { - beaver.Error(err) - break - } - - if !forceAnswer { - beaver.Errorf("leaving package `%s` as-is", pkg.Name) - break - } - - c.Packages[idx] = pkg - break - } - } - c.Packages = append(c.Packages, pkg) - } -} diff --git a/database/database.go b/database/database.go new file mode 100644 index 0000000..d7827d0 --- /dev/null +++ b/database/database.go @@ -0,0 +1,79 @@ +package database + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + + "go.jolheiser.com/gpm/go-gpm" + + "go.etcd.io/bbolt" +) + +var packageBucket = []byte("packages") + +type Database struct { + db *bbolt.DB +} + +func Load(dbPath string) (*Database, error) { + if err := os.MkdirAll(filepath.Dir(dbPath), os.ModePerm); err != nil { + return nil, err + } + db, err := bbolt.Open(dbPath, os.ModePerm, nil) + if err != nil { + return nil, err + } + return &Database{ + db: db, + }, db.Update(func(tx *bbolt.Tx) error { + _, err := tx.CreateBucketIfNotExists(packageBucket) + return err + }) +} + +func (d *Database) Package(name string) (gpm.Package, error) { + var pkg gpm.Package + data, err := d.PackageJSON(name) + if err != nil { + return pkg, err + } + return pkg, json.NewDecoder(bytes.NewReader(data)).Decode(&pkg) +} + +func (d *Database) PackageJSON(name string) (pkg []byte, err error) { + return pkg, d.db.View(func(tx *bbolt.Tx) error { + pkg = tx.Bucket(packageBucket).Get([]byte(name)) + return nil + }) +} + +func (d *Database) Packages() (pkgs []gpm.Package, err error) { + return pkgs, d.db.View(func(tx *bbolt.Tx) error { + return tx.Bucket(packageBucket).ForEach(func(key, val []byte) error { + var pkg gpm.Package + if err := json.NewDecoder(bytes.NewReader(val)).Decode(&pkg); err != nil { + return err + } + pkgs = append(pkgs, pkg) + return nil + }) + }) +} + +func (d *Database) PutPackage(pkg gpm.Package) error { + return d.db.Update(func(tx *bbolt.Tx) error { + data, err := json.Marshal(pkg) + if err != nil { + return err + } + return tx.Bucket(packageBucket).Put([]byte(pkg.Name), data) + }) +} + +func (d *Database) RemovePackage(name string) error { + return d.db.Update(func(tx *bbolt.Tx) error { + return tx.Bucket(packageBucket).Delete([]byte(name)) + }) +} diff --git a/database/database_test.go b/database/database_test.go new file mode 100644 index 0000000..b6e1f92 --- /dev/null +++ b/database/database_test.go @@ -0,0 +1,87 @@ +package database + +import ( + "os" + "path/filepath" + "testing" + + "go.jolheiser.com/gpm/go-gpm" +) + +var db *Database + +func TestMain(m *testing.M) { + tmp, err := os.MkdirTemp(os.TempDir(), "gpm") + if err != nil { + panic(err) + } + dbPath := filepath.Join(tmp, "gpm.db") + + db, err = Load(dbPath) + if err != nil { + panic(err) + } + + code := m.Run() + + // Cleanup + if err := os.RemoveAll(tmp); err != nil { + panic(err) + } + + os.Exit(code) +} + +func TestPackage(t *testing.T) { + + // Does not exist + _, err := db.Package("test") + if err == nil { + t.Log("test package should not exist") + t.FailNow() + } + + // Add + pkg := gpm.Package{ + Name: "test", + Import: "gitea.com/test/testing", + } + err = db.PutPackage(pkg) + if err != nil { + t.Logf("could not put test package: %v\n", err) + t.FailNow() + } + + // Update + pkg.Import = "gitea.com/testing/test" + err = db.PutPackage(pkg) + if err != nil { + t.Logf("could not put test package: %v\n", err) + t.FailNow() + } + + // Check + p, err := db.Package("test") + if err != nil { + t.Logf("should find test package: %v\n", err) + t.FailNow() + } + if p.Import != pkg.Import { + t.Logf("test package did not match update:\n\texpected: %s\n\t got: %s\n", pkg.Import, p.Import) + t.FailNow() + } + + // Remove + err = db.RemovePackage("test") + if err != nil { + t.Log("could not remove test package") + t.FailNow() + } + + // Check + _, err = db.Package("test") + if err == nil { + t.Log("test package should not exist after being removed") + t.FailNow() + } +} diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..5431a7b --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.16-alpine as builder +RUN apk --no-cache add build-base git +COPY . /app +WORKDIR /app +RUN make build + +FROM alpine:latest +LABEL maintainer="john.olheiser@gmail.com" +COPY --from=builder /app/gpm gpm +EXPOSE 3333 +ENV GPM_TOKEN="" +ENTRYPOINT exec gpm --token $GPM_TOKEN server \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..394e21f --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,9 @@ +version: "2" +services: + vanity: + image: jolheiser/gpm:latest + environment: + - GPM_TOKEN= + restart: always + ports: + - "80:3333" \ No newline at end of file diff --git a/docs.go b/docs.go new file mode 100644 index 0000000..a2a6fd8 --- /dev/null +++ b/docs.go @@ -0,0 +1,33 @@ +//+build docs + +package main + +import ( + "os" + "strings" + + "go.jolheiser.com/gpm/cmd" +) + +func main() { + app := cmd.New() + + md, err := app.ToMarkdown() + if err != nil { + panic(err) + } + + // FIXME Why is this not fixed yet?? + md = md[strings.Index(md, "#"):] + + fi, err := os.Create("DOCS.md") + if err != nil { + panic(err) + } + if _, err := fi.WriteString(md); err != nil { + panic(err) + } + if err := fi.Close(); err != nil { + panic(err) + } +} diff --git a/go-gpm/client.go b/go-gpm/client.go new file mode 100644 index 0000000..bfcc334 --- /dev/null +++ b/go-gpm/client.go @@ -0,0 +1,59 @@ +package gpm + +import ( + "context" + "io" + "net/http" + "strings" +) + +const ( + DefaultServer = "https://gpm.jolheiser.com" + TokenHeader = "X-GPM-Token" +) + +// Client is a gpm client +type Client struct { + token string + server string + http *http.Client +} + +// New returns a new Client +func New(token string, opts ...ClientOption) *Client { + c := &Client{ + token: token, + server: DefaultServer, + http: http.DefaultClient, + } + for _, opt := range opts { + opt(c) + } + return c +} + +// ClientOption is an option for a Client +type ClientOption func(*Client) + +// WithHTTP sets the http.Client for a Client +func WithHTTP(client *http.Client) ClientOption { + return func(c *Client) { + c.http = client + } +} + +// WithServer sets the gpm server for a Client +func WithServer(server string) ClientOption { + return func(c *Client) { + c.server = strings.TrimSuffix(server, "/") + } +} + +func (c *Client) newRequest(ctx context.Context, method, url string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, err + } + req.Header.Set(TokenHeader, c.token) + return req, nil +} diff --git a/go-gpm/go.mod b/go-gpm/go.mod new file mode 100644 index 0000000..a2558d4 --- /dev/null +++ b/go-gpm/go.mod @@ -0,0 +1,3 @@ +module go.jolheiser.com/gpm/go-gpm + +go 1.16 diff --git a/go-gpm/gpm_test.go b/go-gpm/gpm_test.go new file mode 100644 index 0000000..925a0bc --- /dev/null +++ b/go-gpm/gpm_test.go @@ -0,0 +1,207 @@ +package gpm + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +var ( + server *httptest.Server + token = "TestingLibrary" + version = "GPMTest" + + packages = []Package{ + { + Name: "test1", + Import: "gitea.com/test/testing", + }, + } +) + +func TestMain(m *testing.M) { + server = httptest.NewServer(http.HandlerFunc(testServer)) + os.Exit(m.Run()) +} + +func TestClient(t *testing.T) { + ctx := context.Background() + client := New("", WithServer(server.URL)) + + // Info + checkInfo(t, client, 1) + + pkg1 := Package{ + Name: "test1", + Import: "gitea.com/test/testing", + } + pkg2 := Package{ + Name: "test2", + Import: "gitea.com/testing/test", + } + + // Add (without token) + if err := client.Add(ctx, pkg1); err == nil { + t.Log("adding without token should fail") + t.Fail() + } + + // Add (with token) + client = New(token, WithServer(server.URL)) + checkAdd(t, client, pkg1, pkg2) + + // Info (after second package) + checkInfo(t, client, 2) + + // Check package + checkGet(t, client, pkg2) + + // Update package + checkUpdate(t, client, pkg1) + + // Remove + checkRemove(t, client, pkg1) + + // Info (final) + checkInfo(t, client, 1) +} + +func checkInfo(t *testing.T, client *Client, numPackages int) { + info, err := client.Info(context.Background()) + if err != nil { + t.Logf("info should not return error: %v\n", err) + t.Fail() + } + if info.Version != version || info.NumPackages != numPackages { + t.Log("info did not match expected") + t.Fail() + } +} + +func checkGet(t *testing.T, client *Client, pkg Package) { + ctx := context.Background() + _, err := client.Get(ctx, "test3") + if err == nil { + t.Log("should not be able to get invalid package") + t.Fail() + } + + // Check valid package + p, err := client.Get(ctx, "test2") + if err != nil { + t.Logf("should not be able to get invalid package: %v\n", err) + t.Fail() + } + if p != pkg { + t.Log("valid package should match pkg") + t.Fail() + } +} + +func checkAdd(t *testing.T, client *Client, pkg1, pkg2 Package) { + ctx := context.Background() + if err := client.Add(ctx, pkg2); err != nil { + t.Logf("pkg2 should be added: %v\n", err) + t.Fail() + } + // Duplicate package + if err := client.Add(ctx, pkg1); err == nil { + t.Log("pkg1 should already exist") + t.Fail() + } +} + +func checkUpdate(t *testing.T, client *Client, pkg Package) { + ctx := context.Background() + // Update invalid package + if err := client.Update(ctx, Package{Name: "test4", Import: "gitea.com/invalid"}); err == nil { + t.Log("should not be able to update invalid package") + t.Fail() + } + + // Update valid package + pkg.Import = "gitea.com/tester/testing" + if err := client.Update(ctx, pkg); err != nil { + t.Logf("should be able to update valid package: %v\n", err) + t.Fail() + } +} + +func checkRemove(t *testing.T, client *Client, pkg Package) { + ctx := context.Background() + if err := client.Remove(ctx, pkg); err != nil { + t.Logf("should be able to remove package: %v\n", err) + t.Fail() + } + + // Remove (idempotent) + if err := client.Remove(ctx, pkg); err != nil { + t.Logf("should be able to remove package idempotently: %v\n", err) + t.Fail() + } +} + +func testServer(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/": + switch r.Method { + case http.MethodGet: + resp := Info{ + Version: version, + NumPackages: len(packages), + Packages: packages, + } + _ = json.NewEncoder(w).Encode(resp) + case http.MethodPost, http.MethodPatch, http.MethodDelete: + if r.Header.Get(TokenHeader) != token { + w.WriteHeader(http.StatusUnauthorized) + return + } + var pkg Package + if err := json.NewDecoder(r.Body).Decode(&pkg); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + switch r.Method { + case http.MethodPost: + for _, p := range packages { + if p.Name == pkg.Name { + w.WriteHeader(http.StatusConflict) + return + } + } + packages = append(packages, pkg) + w.WriteHeader(http.StatusCreated) + case http.MethodPatch: + for idx, p := range packages { + if p.Name == pkg.Name { + packages[idx] = pkg + return + } + } + w.WriteHeader(http.StatusNotFound) + case http.MethodDelete: + for idx, p := range packages { + if p.Name == pkg.Name { + packages = append(packages[:idx], packages[idx+1:]...) + } + } + } + return + } + return + default: + name := strings.TrimPrefix(r.URL.Path, "/") + for _, pkg := range packages { + if pkg.Name == name { + _ = json.NewEncoder(w).Encode(pkg) + return + } + } + } + w.WriteHeader(http.StatusNotImplemented) +} diff --git a/go-gpm/package.go b/go-gpm/package.go new file mode 100644 index 0000000..d93df2e --- /dev/null +++ b/go-gpm/package.go @@ -0,0 +1,124 @@ +package gpm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" +) + +// Package is a gpm package +type Package struct { + Name string `json:"name"` + Import string `json:"import"` +} + +// Info is gpm information, such as version and list of packages +type Info struct { + Version string `json:"version"` + NumPackages int `json:"num_packages"` + Packages []Package `json:"packages"` +} + +// Info gets Info from a gpm server +func (c *Client) Info(ctx context.Context) (Info, error) { + var info Info + resp, err := c.crud(ctx, Package{}, http.MethodGet) + if err != nil { + return info, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return info, fmt.Errorf("could not get info: %s", resp.Status) + } + + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return info, err + } + + return info, nil +} + +// Add adds a new Package to a gpm server +func (c *Client) Add(ctx context.Context, pkg Package) error { + resp, err := c.crud(ctx, pkg, http.MethodPost) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusCreated { + return fmt.Errorf("could not add package: %s", resp.Status) + } + return nil +} + +// Update updates a Package on a gpm server +func (c *Client) Update(ctx context.Context, pkg Package) error { + resp, err := c.crud(ctx, pkg, http.MethodPatch) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("could not update package: %s", resp.Status) + } + + return nil +} + +// Remove removes a Package from a gpm server +func (c *Client) Remove(ctx context.Context, pkg Package) error { + resp, err := c.crud(ctx, pkg, http.MethodDelete) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("could not remove package: %s", resp.Status) + } + + return nil +} + +// Get gets a Package from a server +func (c *Client) Get(ctx context.Context, name string) (Package, error) { + var pkg Package + uri := fmt.Sprintf("%s/%s", c.server, name) + + req, err := c.newRequest(ctx, http.MethodGet, uri, nil) + if err != nil { + return pkg, err + } + + resp, err := c.http.Do(req) + if err != nil { + return pkg, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return pkg, fmt.Errorf("package not found for %s", name) + } + + if err := json.NewDecoder(resp.Body).Decode(&pkg); err != nil { + return pkg, err + } + + return pkg, nil +} + +func (c *Client) crud(ctx context.Context, pkg Package, method string) (*http.Response, error) { + payload, err := json.Marshal(pkg) + if err != nil { + return nil, err + } + + req, err := c.newRequest(ctx, method, c.server, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + + return c.http.Do(req) +} diff --git a/go.mod b/go.mod index 48d4374..0715a09 100644 --- a/go.mod +++ b/go.mod @@ -2,18 +2,21 @@ module go.jolheiser.com/gpm go 1.15 +replace go.jolheiser.com/gpm/go-gpm => ./go-gpm + require ( github.com/AlecAivazis/survey/v2 v2.2.7 github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect github.com/go-chi/chi v1.5.2 github.com/mattn/go-colorable v0.1.8 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect - github.com/pelletier/go-toml v1.8.1 github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/urfave/cli/v2 v2.3.0 - go.jolheiser.com/beaver v1.1.0 + go.etcd.io/bbolt v1.3.5 + go.jolheiser.com/beaver v1.1.1 + go.jolheiser.com/gpm/go-gpm v0.0.0-00010101000000-000000000000 golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad // indirect - golang.org/x/sys v0.0.0-20210216224549-f992740a1bac // indirect + golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073 // indirect golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf // indirect golang.org/x/text v0.3.5 // indirect ) diff --git a/go.sum b/go.sum index 7b557ae..40a724e 100644 --- a/go.sum +++ b/go.sum @@ -25,8 +25,6 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= -github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNCRM= -github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -37,8 +35,10 @@ github.com/stretchr/testify v1.2.1 h1:52QO5WkIUcHGIR7EnGagH88x1bUzqGXTC5/1bDTUQ7 github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= -go.jolheiser.com/beaver v1.1.0 h1:Igz73y+jJQoe8Uteewf14mOMnozGAo2vxjzyqU8v9kA= -go.jolheiser.com/beaver v1.1.0/go.mod h1:7X4F5+XOGSC3LejTShoBdqtRCnPWcnRgmYGmG3EKW8g= +go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0= +go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= +go.jolheiser.com/beaver v1.1.1 h1:py8Zj3tjT52dUzsvnu97aiLj1fBJjDJiK6kHjKJejMQ= +go.jolheiser.com/beaver v1.1.1/go.mod h1:7X4F5+XOGSC3LejTShoBdqtRCnPWcnRgmYGmG3EKW8g= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY= @@ -51,10 +51,11 @@ golang.org/x/sys v0.0.0-20190530182044-ad28b68e88f1/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210216224549-f992740a1bac h1:9glrpwtNjBYgRpb67AZJKHfzj1stG/8BL5H7In2oTC4= -golang.org/x/sys v0.0.0-20210216224549-f992740a1bac/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073 h1:8qxJSnu+7dRq6upnbntrmriWByIakBuct5OM/MdQC1M= +golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlpnxz5GSOhOmtHo3iPU6M= golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/main.go b/main.go index 4225082..b0166e6 100644 --- a/main.go +++ b/main.go @@ -4,35 +4,12 @@ import ( "os" "go.jolheiser.com/gpm/cmd" - "go.jolheiser.com/gpm/config" - "github.com/urfave/cli/v2" "go.jolheiser.com/beaver" ) func main() { - cfg, err := config.Load() - if err != nil { - beaver.Fatal(err) - } - - app := cli.NewApp() - app.Name = "gpm" - app.Usage = "Go Package Manager" - app.Version = config.Version - app.Commands = []*cli.Command{ - &cmd.Add, - &cmd.Remove, - &cmd.List, - &cmd.Get, - &cmd.Import, - &cmd.Export, - &cmd.Config, - &cmd.Server, - &cmd.Search, - } - app.Flags = cmd.NewFlags(cfg) - if err := app.Run(os.Args); err != nil { + if err := cmd.New().Run(os.Args); err != nil { beaver.Error(err) } } diff --git a/router/router.go b/router/router.go index 3697034..a6de33f 100644 --- a/router/router.go +++ b/router/router.go @@ -2,38 +2,47 @@ package router import ( "encoding/json" + "io" "net/http" "time" - "go.jolheiser.com/gpm/config" + "go.jolheiser.com/gpm/database" + "go.jolheiser.com/gpm/go-gpm" "github.com/go-chi/chi" "github.com/go-chi/chi/middleware" "go.jolheiser.com/beaver" ) -var cache map[string]config.Package +var Version = "develop" -func New(cfg *config.Config) *chi.Mux { +func New(token string, db *database.Database) *chi.Mux { r := chi.NewRouter() r.Use(middleware.RedirectSlashes) r.Use(middleware.Recoverer) r.Use(middleware.Timeout(30 * time.Second)) - r.Get("/", handleHome(cfg)) - r.Get("/export", handleExport(cfg)) - r.Get("/package/{name}", handlePackage) - - cache = cfg.Packages.Map() + r.Get("/", handleHome(db)) + r.Post("/", addUpdatePackage(db, token)) + r.Patch("/", addUpdatePackage(db, token)) + r.Delete("/", removePackage(db, token)) + r.Get("/{name}", getPackage(db)) return r } -func handleHome(cfg *config.Config) func(res http.ResponseWriter, _ *http.Request) { +func handleHome(db *database.Database) func(res http.ResponseWriter, _ *http.Request) { return func(res http.ResponseWriter, _ *http.Request) { - status, err := json.Marshal(map[string]interface{}{ - "version": config.Version, - "packages": len(cfg.Packages), + pkgs, err := db.Packages() + if err != nil { + beaver.Error(err) + return + } + + status, err := json.Marshal(gpm.Info{ + Version: Version, + NumPackages: len(pkgs), + Packages: pkgs, }) if err != nil { res.WriteHeader(http.StatusInternalServerError) @@ -45,32 +54,98 @@ func handleHome(cfg *config.Config) func(res http.ResponseWriter, _ *http.Reques } } -func handleExport(cfg *config.Config) func(res http.ResponseWriter, _ *http.Request) { - return func(res http.ResponseWriter, _ *http.Request) { - export, err := cfg.Export() +func getPackage(db *database.Database) func(http.ResponseWriter, *http.Request) { + return func(res http.ResponseWriter, req *http.Request) { + name := chi.URLParam(req, "name") + + pkg, err := db.PackageJSON(name) if err != nil { - beaver.Error(err) - return - } - - _, _ = res.Write([]byte(export)) - } -} - -func handlePackage(res http.ResponseWriter, req *http.Request) { - name := chi.URLParam(req, "name") - - if pkg, ok := cache[name]; ok { - data, err := json.Marshal(pkg) - if err != nil { - res.WriteHeader(http.StatusInternalServerError) + res.WriteHeader(http.StatusNotFound) _, _ = res.Write([]byte("{}")) return } - _, _ = res.Write(data) - return - } - res.WriteHeader(http.StatusNotFound) - _, _ = res.Write([]byte("{}")) + _, _ = res.Write(pkg) + } +} + +func addUpdatePackage(db *database.Database, token string) func(http.ResponseWriter, *http.Request) { + return func(res http.ResponseWriter, req *http.Request) { + if req.Header.Get(gpm.TokenHeader) != token { + res.WriteHeader(http.StatusUnauthorized) + return + } + + data, err := io.ReadAll(req.Body) + if err != nil { + res.WriteHeader(http.StatusBadRequest) + return + } + defer req.Body.Close() + + var pkg gpm.Package + if err := json.Unmarshal(data, &pkg); err != nil { + res.WriteHeader(http.StatusBadRequest) + return + } + + exists, err := db.PackageJSON(pkg.Name) + if err != nil { + res.WriteHeader(http.StatusInternalServerError) + return + } + switch req.Method { + case http.MethodPost: + if exists != nil { + res.WriteHeader(http.StatusConflict) + return + } + case http.MethodPatch: + if exists == nil { + res.WriteHeader(http.StatusNotFound) + return + } + } + + if err := db.PutPackage(pkg); err != nil { + res.WriteHeader(http.StatusInternalServerError) + return + } + + switch req.Method { + case http.MethodPost: + res.WriteHeader(http.StatusCreated) + case http.MethodPatch: + res.WriteHeader(http.StatusOK) + } + } +} + +func removePackage(db *database.Database, token string) func(http.ResponseWriter, *http.Request) { + return func(res http.ResponseWriter, req *http.Request) { + if req.Header.Get(gpm.TokenHeader) != token { + res.WriteHeader(http.StatusUnauthorized) + return + } + + data, err := io.ReadAll(req.Body) + if err != nil { + res.WriteHeader(http.StatusBadRequest) + return + } + defer req.Body.Close() + + var pkg gpm.Package + if err := json.Unmarshal(data, &pkg); err != nil { + res.WriteHeader(http.StatusBadRequest) + return + } + + if err := db.RemovePackage(pkg.Name); err != nil { + res.WriteHeader(http.StatusInternalServerError) + return + } + + res.WriteHeader(http.StatusOK) + } } diff --git a/router/router_test.go b/router/router_test.go new file mode 100644 index 0000000..ff34fb0 --- /dev/null +++ b/router/router_test.go @@ -0,0 +1,166 @@ +package router + +import ( + "context" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "go.jolheiser.com/gpm/database" + "go.jolheiser.com/gpm/go-gpm" + + "go.jolheiser.com/beaver" +) + +var ( + server *httptest.Server + token = "TestingRouter" +) + +// NOTE: The router test is more or less a copy/paste from go-gpm +// However, this ensures that testing is the same with the "real" router and DB +func TestMain(m *testing.M) { + tmp, err := os.MkdirTemp(os.TempDir(), "gpm") + if err != nil { + panic(err) + } + dbPath := filepath.Join(tmp, "gpm.db") + + db, err := database.Load(dbPath) + if err != nil { + beaver.Fatalf("could not load database at %s: %v", dbPath, err) + } + + server = httptest.NewServer(New(token, db)) + + code := m.Run() + + // Cleanup + if err := os.RemoveAll(tmp); err != nil { + panic(err) + } + + os.Exit(code) +} + +func TestRouter(t *testing.T) { + ctx := context.Background() + client := gpm.New("", gpm.WithServer(server.URL)) + + // Info + checkInfo(t, client, 0) + + pkg1 := gpm.Package{ + Name: "test1", + Import: "gitea.com/test/testing", + } + pkg2 := gpm.Package{ + Name: "test2", + Import: "gitea.com/testing/test", + } + + // Add (without token) + if err := client.Add(ctx, pkg1); err == nil { + t.Log("adding without token should fail") + t.Fail() + } + + // Add (with token) + client = gpm.New(token, gpm.WithServer(server.URL)) + checkAdd(t, client, pkg1, pkg2) + + // Info (after second package) + checkInfo(t, client, 2) + + // Check package + checkGet(t, client, pkg2) + + // Update package + checkUpdate(t, client, pkg1) + + // Remove + checkRemove(t, client, pkg1) + + // Info (final) + checkInfo(t, client, 1) +} + +func checkInfo(t *testing.T, client *gpm.Client, numPackages int) { + info, err := client.Info(context.Background()) + if err != nil { + t.Logf("info should not return error: %v\n", err) + t.Fail() + } + if info.Version != Version || info.NumPackages != numPackages { + t.Log("info did not match expected") + t.Fail() + } +} + +func checkGet(t *testing.T, client *gpm.Client, pkg gpm.Package) { + ctx := context.Background() + _, err := client.Get(ctx, "test3") + if err == nil { + t.Log("should not be able to get invalid package") + t.Fail() + } + + // Check valid package + p, err := client.Get(ctx, "test2") + if err != nil { + t.Logf("should not be able to get invalid package: %v\n", err) + t.Fail() + } + if p != pkg { + t.Log("valid package should match pkg") + t.Fail() + } +} + +func checkAdd(t *testing.T, client *gpm.Client, pkg1, pkg2 gpm.Package) { + ctx := context.Background() + if err := client.Add(ctx, pkg1); err != nil { + t.Logf("pkg1 should be added: %v\n", err) + t.Fail() + } + if err := client.Add(ctx, pkg2); err != nil { + t.Logf("pkg2 should be added: %v\n", err) + t.Fail() + } + // Duplicate package + if err := client.Add(ctx, pkg1); err == nil { + t.Log("pkg1 should already exist") + t.Fail() + } +} + +func checkUpdate(t *testing.T, client *gpm.Client, pkg gpm.Package) { + ctx := context.Background() + // Update invalid package + if err := client.Update(ctx, gpm.Package{Name: "test4", Import: "gitea.com/invalid"}); err == nil { + t.Log("should not be able to update invalid package") + t.Fail() + } + + // Update valid package + pkg.Import = "gitea.com/tester/testing" + if err := client.Update(ctx, pkg); err != nil { + t.Logf("should be able to update valid package: %v\n", err) + t.Fail() + } +} + +func checkRemove(t *testing.T, client *gpm.Client, pkg gpm.Package) { + ctx := context.Background() + if err := client.Remove(ctx, pkg); err != nil { + t.Logf("should be able to remove package: %v\n", err) + t.Fail() + } + + // Remove (idempotent) + if err := client.Remove(ctx, pkg); err != nil { + t.Logf("should be able to remove package idempotently: %v\n", err) + t.Fail() + } +}