From f51a4bfa77ffc1ee62457567d07c9b59c41fabfc Mon Sep 17 00:00:00 2001 From: Sven Greb Date: Mon, 7 Dec 2020 15:53:53 +0100 Subject: [PATCH] "Task" API: Simplified usage and "normalized" naming scheme (#51) With GH-14 [1] the "abstract" wand API was introduced with a naming scheme is inspired by the fantasy novel "Harry Potter" [2] that was used to to define interfaces. The main motivation was to create a matching naming to the overall "magic" topic and the actual target project Mage [3], but in retrospect this is way too abstract and confusing. The goal of this commit is to... - rewrite the API to make it way easier to use. - use a "normal" naming scheme. - improve all documentations to be more user-scoped and provide guides and examples. >>> New API Concept The basic mindset of the API remains partially the same, but it is designed around the concept of tasks and the ways to run them. >>>> Command Runner `task.Runner` [4] is a new base interface that runs a command with parameters in a specific environment. It can be compared to the current `cast.Caster` [5] interface, but provides a cleaner method set accepting the new `task.Task` [6] interface. - `Handles() task.Kind` - returns the supported task kind [7]. - `Run(task.Task) error` - runs a command. - `Validate() error` - validates the runner. The new `task.RunnerExec` [8] interface is a specialized `task.Runner` and serves as an abstract representation for a command or action, in most cases a (binary) [executable][9] of external commands or Go module `main` packages, that provides corresponding information like the path to the executable. It can be compared to the previous `BinaryCaster` [10] interface, but also comes with a cleaner method set and a more appropriate name. - `FilePath() string` - returns the path to the (binary) command executable. >>>> Tasks `task.Task` [6] is the new interface that is scoped for Mage "target" [11] usage. It can be compared to the previous `spell.Incantation` [12] interface, but provides a smaller method set without `Formula() []string`. - `Kind() task.Kind` - returns the task kind [7]. - `Options() task.Options` - returns the task options [13]. The new `task.Exec` [14] interface is a specialized `task.Task` and serves as an abstract task for an executable command. It can be compared to the previous `Binary` [15] interface, but also comes with the new `BuildParams() []string` method that enables a more flexible usage by exposing the parameters for command runner like `task.RunnerExec` and also allows to compose with other tasks. See the Wikipedia page about the "anatomy of a shell CLI" [16] for more details about parameters. - `BuildParams() []string` - builds the parameters for a command runner where parameters can consist of options, flags and arguments. - `Env() map[string]string` - returns the task specific environment. The new `task.GoModule` [17] interface is a specialized `task.Exec` for a executable Go module command. It can be compared to the previous `spell.GoModule` [18] interface and the method set has not changed except a renaming of the `GoModuleID() *project.GoModuleID` to the more appropriate name `ID() *project.GoModuleID`. See the official "Go module reference documentation" [19] for more details about Go modules. - `ID() *project.GoModuleID` - returns the identifier of a Go module. >>> New API Naming Scheme The following listing shows the new name concept and how the previous API components can be mapped to the upcoming changes: 1. Runner - A component that runs a command with parameters in a specific environment, in most cases a (binary) executable [9] of external commands or Go module `main` packages. The previous API component that can be compared to runners is `cast.Caster` [5] and its specialized interfaces. 2. Tasks - A component that is scoped for Mage "target" [11] usage in order to run a action. The previous API component that can be compared to tasks is`spell.Incantation` [12] and its specialized interfaces. >>> API Usage Even though the API has been changed quite heavily, the basic usage has almost not changed. -> A `task.Task` can only be run through a `task.Runner`! Before a `spell.Incantation` was passed to a `cast.Caster` in order to run it, in most cases a (binary) executable of a command that uses the `Formula() []string` method of `spell.Incantation` to pass the result a parameters. The new API works the same: A `task.Task` is passed to a `task.Runner` that calls the `BuildParams() []string` method when the runner is specialized for (binary) executable of commands. >>> Improved Documentations Before the documentation was mainly scoped on the technical details, but lacked more user-friendly sections about topics like the way how to implement own API components, how to compose the "elder" reference implementation [20] or usage examples for single or monorepo [21] project layouts. >>>> User Guide Most of the current sections have been rewritten or removed entirely while new sections now provide more user-friendly guides about how to... - use or compose the "elder" reference implementation [20]. - build own tasks and runners using the new API. - structure repositories independent of the layout, single or "monorepo". >>>> Usage Examples Some examples have been added, that are linked and documented in the user guides described above, to show how to... - use or compose the "elder" reference implementation [20]. - build own tasks and runners using the new API. - structure repositories independent of the layout, single or "monorepo". [1]: https://github.com/svengreb/wand/issues/14 [2]: https://en.wikipedia.org/wiki/Harry_Potter [3]: https://magefile.org [4]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task#Runner [5]: https://pkg.go.dev/github.com/svengreb/wand/pkg/cast#Caster [6]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task#Task [7]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task#Kind [8]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task#RunnerExec [9]: https://en.wikipedia.org/wiki/Executable [10]: https://pkg.go.dev/github.com/svengreb/wand/pkg/cast#BinaryCaster [11]: https://magefile.org/targets [12]: https://pkg.go.dev/github.com/svengreb/wand/pkg/spell#Incantation [13]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task#Options [14]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task#Exec [15]: https://pkg.go.dev/github.com/svengreb/wand/pkg/spell#Binary [16]: https://en.wikipedia.org/wiki/Command-line_interface#Anatomy_of_a_shell_CLI [17]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task#GoModule [18]: https://pkg.go.dev/github.com/svengreb/wand/pkg/spell#GoModule [19]: https://golang.org/ref/mod [20]: https://pkg.go.dev/github.com/svengreb/wand/pkg/elder [21]: https://trunkbaseddevelopment.com/monorepos Closes GH-49 --- README.md | 280 +++++++++------ examples/custom_runner/runners.go | 141 ++++++++ examples/custom_task/tasks.go | 104 ++++++ examples/monorepo/apps/cli/cli.go | 9 + examples/monorepo/apps/daemon/daemon.go | 9 + examples/monorepo/apps/promexp/promexp.go | 9 + examples/monorepo/magefile.go | 99 ++++++ examples/monorepo/pkg/cmd/cli/cli.go | 35 ++ examples/monorepo/pkg/cmd/cli/config.go | 6 + examples/monorepo/pkg/cmd/daemon/config.go | 6 + examples/monorepo/pkg/cmd/daemon/daemon.go | 36 ++ examples/monorepo/pkg/cmd/promexp/config.go | 6 + examples/monorepo/pkg/cmd/promexp/promexp.go | 38 +++ examples/simple/magefile.go | 70 ++++ examples/simple/main.go | 9 + examples/simple/pkg/cmd/cli/cli.go | 35 ++ examples/simple/pkg/cmd/cli/config.go | 6 + internal/support/os/env.go | 4 +- pkg/app/config.go | 4 +- pkg/app/store.go | 5 +- pkg/cast/cast.go | 37 -- pkg/cast/error.go | 53 --- pkg/cast/gobin/gobin.go | 251 -------------- pkg/cast/gobin/options.go | 96 ------ pkg/cast/golang/toolchain/options.go | 71 ---- pkg/cast/golang/toolchain/toolchain.go | 92 ----- pkg/elder/elder.go | 194 ++++++----- pkg/elder/options.go | 52 +-- pkg/project/options.go | 19 +- pkg/spell/error.go | 44 --- pkg/spell/fs/clean/clean.go | 103 ------ pkg/spell/goimports/goimports.go | 101 ------ pkg/spell/golang/build/build.go | 78 ----- pkg/spell/golang/golang.go | 58 ---- pkg/spell/golang/test/test.go | 128 ------- pkg/spell/golangcilint/golangcilint.go | 60 ---- pkg/spell/gox/gox.go | 113 ------ pkg/spell/spell.go | 73 ---- pkg/task/error.go | 90 +++++ pkg/task/fs/clean/clean.go | 91 +++++ pkg/{spell => task}/fs/clean/options.go | 26 +- pkg/task/gobin/gobin.go | 342 +++++++++++++++++++ pkg/task/gobin/options.go | 114 +++++++ pkg/task/goimports/goimports.go | 104 ++++++ pkg/{spell => task}/goimports/options.go | 68 ++-- pkg/task/golang/build/build.go | 77 +++++ pkg/{spell => task}/golang/build/options.go | 48 +-- pkg/task/golang/golang.go | 87 +++++ pkg/{spell => task}/golang/mixins.go | 63 ++-- pkg/{spell => task}/golang/options.go | 241 +++++++++---- pkg/{spell => task}/golang/test/options.go | 127 +++---- pkg/task/golang/test/test.go | 128 +++++++ pkg/task/golangcilint/golangcilint.go | 60 ++++ pkg/{spell => task}/golangcilint/options.go | 88 +++-- pkg/task/gox/gox.go | 112 ++++++ pkg/{spell => task}/gox/options.go | 144 ++++---- pkg/{spell => task}/kind.go | 42 +-- pkg/{spell => task}/mixin.go | 12 +- pkg/task/runner.go | 24 ++ pkg/task/task.go | 48 +++ 60 files changed, 2712 insertions(+), 1958 deletions(-) create mode 100644 examples/custom_runner/runners.go create mode 100644 examples/custom_task/tasks.go create mode 100644 examples/monorepo/apps/cli/cli.go create mode 100644 examples/monorepo/apps/daemon/daemon.go create mode 100644 examples/monorepo/apps/promexp/promexp.go create mode 100644 examples/monorepo/magefile.go create mode 100644 examples/monorepo/pkg/cmd/cli/cli.go create mode 100644 examples/monorepo/pkg/cmd/cli/config.go create mode 100644 examples/monorepo/pkg/cmd/daemon/config.go create mode 100644 examples/monorepo/pkg/cmd/daemon/daemon.go create mode 100644 examples/monorepo/pkg/cmd/promexp/config.go create mode 100644 examples/monorepo/pkg/cmd/promexp/promexp.go create mode 100644 examples/simple/magefile.go create mode 100644 examples/simple/main.go create mode 100644 examples/simple/pkg/cmd/cli/cli.go create mode 100644 examples/simple/pkg/cmd/cli/config.go delete mode 100644 pkg/cast/cast.go delete mode 100644 pkg/cast/error.go delete mode 100644 pkg/cast/gobin/gobin.go delete mode 100644 pkg/cast/gobin/options.go delete mode 100644 pkg/cast/golang/toolchain/options.go delete mode 100644 pkg/cast/golang/toolchain/toolchain.go delete mode 100644 pkg/spell/error.go delete mode 100644 pkg/spell/fs/clean/clean.go delete mode 100644 pkg/spell/goimports/goimports.go delete mode 100644 pkg/spell/golang/build/build.go delete mode 100644 pkg/spell/golang/golang.go delete mode 100644 pkg/spell/golang/test/test.go delete mode 100644 pkg/spell/golangcilint/golangcilint.go delete mode 100644 pkg/spell/gox/gox.go delete mode 100644 pkg/spell/spell.go create mode 100644 pkg/task/error.go create mode 100644 pkg/task/fs/clean/clean.go rename pkg/{spell => task}/fs/clean/options.go (91%) create mode 100644 pkg/task/gobin/gobin.go create mode 100644 pkg/task/gobin/options.go create mode 100644 pkg/task/goimports/goimports.go rename pkg/{spell => task}/goimports/options.go (72%) create mode 100644 pkg/task/golang/build/build.go rename pkg/{spell => task}/golang/build/options.go (72%) create mode 100644 pkg/task/golang/golang.go rename pkg/{spell => task}/golang/mixins.go (69%) rename pkg/{spell => task}/golang/options.go (60%) rename pkg/{spell => task}/golang/test/options.go (91%) create mode 100644 pkg/task/golang/test/test.go create mode 100644 pkg/task/golangcilint/golangcilint.go rename pkg/{spell => task}/golangcilint/options.go (60%) create mode 100644 pkg/task/gox/gox.go rename pkg/{spell => task}/gox/options.go (64%) rename pkg/{spell => task}/kind.go (61%) rename pkg/{spell => task}/mixin.go (50%) create mode 100644 pkg/task/runner.go create mode 100644 pkg/task/task.go diff --git a/README.md b/README.md index 934bbf0..0425bc5 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ The provided [API packages][go-pkg-pkg] allow users to compose their own, reusab - **Runs any `main` package of a [Go module][go-docs-ref-mod] without the requirement for the user to install it beforehand** — thanks to the awesome [gobin][] project, there is no need for the user to `go get` the `main` package of a Go module in order to run its compiled executable. - **Comes with support for basic [Go toolchain][go-pkg-cmd/go] commands and popular modules from the Go ecosystem** — run common commands like `go build` and `go test` or great tools like [goimports][go-pkg-golang.org/x/tools/cmd/goimports], [golangci-lint][go-pkg-github.com/golangci/golangci-lint/cmd/golangci-lint] and [gox][go-pkg-github.com/mitchellh/gox] in no time. -Please see the [“Design & Usage“](#design--usage) section for more details about the [API](#api) packages and [“Elder Wand“](#elder-wand) reference implementation. +See the [API](#api) and [“Elder Wand“](#elder-wand) sections for more details. The [user guides](#user-guides) for more information about how to build your own tasks and runners and the [examples](#examples) for different repositories layouts (single or [“monorepo“][trunkbasedev-monorepos]) and use cases. ## Motivation @@ -31,7 +31,7 @@ Please see the [“Design & Usage“](#design--usage) section for more details a ### Why Mage? Every project involves processes that are often recurring. These can mostly be done with the tools supplied with the respective programming language, which in turn, in many cases, involve more time and the memorizing of longer commands with their flags and parameters. -In order to reduce this effort or to avoid it completely, project task automation tools are used which often establish a defined standard to enable the widest possible use and unify tasks. They offer a user-friendly and comfortable interface to handle the processes consistently with time savings and without the need for developers to remember many and/or complex commands. +In order to significantly reduce this effort or to avoid it completely, project task automation tools are used which often establish a defined standard to enable the widest possible use and unify tasks. They offer a user-friendly and comfortable interface to handle the processes consistently with time savings and without the need for developers to remember many and/or complex commands. But these tools come with a cost: the introduction of standards and the restriction to predefined ways how to handle tasks is also usually the biggest disadvantage when it comes to adaptability for use cases that are individual for a single project, tasks that deviate from the standard or not covered by it at all. [Mage][] is a project task automation tool which gives the user complete freedom by **not specifying how tasks are solved, but only how they are started and connected with each other**. This is an absolute advantage over tools that force how how a task has to be solved while leaving out individual and project specific preferences. @@ -46,118 +46,105 @@ This is where _Mage_ comes in: - Written in [pure Go without any external dependencies][gh-blob-magefile/mage/go.mod] for fully native compatibility and easy expansion. - [No installation][mage-zero_install] required. -- Allows to [declare dependencies between tasks][mage-deps] in a _makefile_-style tree and optionally [runs them in parallel][mage-deps#paral]. -- Tasks can be defined in shared packages and [imported in any _Magefile_][mage-importing]. No mechanics like plugins or extensions required, just use any Go module and the whole Go ecosystem. +- Allows to [declare dependencies between targets][mage-deps] in a _makefile_-style tree and optionally [runs them in parallel][mage-deps#paral]. +- Targets can be defined in shared packages and [imported][mage-importing] in any [_Magefile_][mage-files]. No mechanics like plugins or extensions required, just use any Go module and the whole Go ecosystem. ### Why _wand_? -While _Mage_ is often already sufficient on its own, I‘ve noticed that I had to implement almost identical tasks over and over again when starting a new project or migrating an existing one to _Mage_. Even though the actual [task functions][mage-files] could be moved into their own Go module to allow to simply [import them][mage-importing] in different projects, it was often required to copy & paste code across projects that can not be exported that easily. That was the moment where I decided to create a way that simplifies the integration and usage of _Mage_ without loosing any of its flexibility and dynamic structure. - -significantly reduced effort. +While _Mage_ is often already sufficient on its own, I‘ve noticed that I had to implement almost identical tasks over and over again when starting a new project or migrating an existing one to _Mage_. Even though the actual [target functions][mage-targets] could be moved into their own Go module to allow to simply [import them][mage-importing] in different projects, it was often required to copy & paste code across projects that can not be exported that easily. That was the moment where I decided to create a way that simplifies the integration and usage of _Mage_ without loosing any of its flexibility and dynamic structure. Please note that this package has mainly been created for my personal use in mind to avoid copying source code between my projects. The default configurations or reference implementation might not fit your needs, but the [API](#api) packages have been designed so that they can be flexibly adapted to different use cases and environments or used to create and compose your own [`wand.Wand`][go-pkg-if-wand#wand]. -Please see the [“Design & Usage“](#design--usage) section below to learn about the API and how to adapt or extend _wand_ for your project. +See the [API](#api) and [“Elder Wand“](#elder-wand) sections to learn how to adapt or extend _wand_ for your project. -## Design & Usage - -_wand_ has been designed as a toolkit for [Mage][] and tries to follow its goal to provide a good developer experience through simple and small interfaces. - -### Wording +## Wording -Since _wand_ is a toolkit for [Mage][], the API has been designed with an abstract naming scheme in mind that matches the fantasy of magic which in case of _wand_ have been derived from the fantasy novel [“Harry Potter“][wikip-hp]. -As this might be a bit confusing for some users this section provides a mapping to the actual functionality and code logic of the different API components: +Since _wand_ is a toolkit for [Mage][], is partly makes use of an abstract naming scheme that matches the fantasy of magic which in case of _wand_ has been derived from the fantasy novel [“Harry Potter“][wikip-hp]. This is mainly limited to the [main “Wand“ interface][go-pkg-if-wand#wand] and the [“Elder Wand“](#elder-wand) reference implementation. +The basic mindset of the API is designed around the concept of **tasks** and the ways to **run** them. -- **Spell Incantation** — An abstract representation of flags and parameters for a command or action, in most cases a (binary) [executable][wikip-exec]. - The naming is inspired by the fact that it is almost only possible to [cast a magic spell][wikip-hp_magic#cast] through a [incantation][wikip-inc]. The parameters can be seen as the _formula_. -- **Caster** — An abstract representation for a command or action, in most cases a (binary) [executable][wikip-exec], that provides corresponding information like the path to the executable. - The naming is inspired by the fact that a caster can [cast a magic spell][wikip-hp_magic#cast] through a [incantation][wikip-inc]. Command can be seen as the [magicians][wikip-magic#magicians] that cast a magic spell. +- **Runner** — Components that run a command with parameters in a specific environment, in most cases a (binary) [executable][wikip-exec] of external commands or Go module `main` packages. +- **Tasks** — Components that are scoped for Mage [“target“][mage-targets] usage in order to run an action. -### API +## API The public _wand_ API is located in the [`pkg`][go-pkg-pkg] package while the main interface [`wand.Wand`][go-pkg-if-wand#wand], that manages a project and its applications and stores their metadata, is defined in the [`wand`][go-pkg-wand] package. -Please also see the individual documentations of each package for more details. - -#### Application Configurations - -The [`pkg/app`][go-pkg-app] package provides the functionality for application configurations. The [`Config` struct type][go-pkg-stc-app#config] holds information and metadata of an application which are stored in defined by [the `Store` interface][go-pkg-if-app#store]. The [`NewStore() app.Store`][go-pkg-func-app#newstore] function returns a reference implementation of the `Store` interface. - -#### Spell Incantation Casters - -The [`pkg/cast`][go-pkg-cast] package provides caster for spell incantations. The [`BinaryCaster` interface][go-pkg-if-cast#binarycaster] is a specialized [`Caster`][go-pkg-if-cast#caster] to run commands using a (binary) executable. - -##### Go Toolchain Caster - -The [`pkg/cast/golang/toolchain`][go-pkg-cast/golang/toolchain] package provides a caster to interact with the [Go toolchain][go-pkg-cmd/go], in most cases the `go` executable. - -##### "gobin" Go Module Caster - -The [`pkg/cast/gobin`][go-pkg-cast/gobin] package provides a caster to install and run [Go module][go-docs-ref-mod] executables using the [`github.com/myitcv/gobin`][go-pkg-github.com/myitcv/gobin] module command. +Please see the individual documentations of each package for more details. -1. **Go Executable Installation** — When installing a Go executable from within a [Go module][go-docs-ref-mod] directory using the [`go install` command][go-pkg-cmd/go#install], it is installed into the Go executable search path that is defined through the [`GOBIN` environment variable][go-pkg-cmd/go#env_vars] and can also be shown and modified using the [`go env` command][go-pkg-cmd/go#print_env]. - Even though the executable gets installed globally, the [`go.mod` file][go-docs-cmd/go#go.mod] will be updated to include the installed packages since this is the default behavior of the [`go get` command][go-pkg-cmd/go#add_deps] when running in [“module“ mode][go-docs-cmd/go#mod_cmds]. - Next to this problem, the installed executable will also overwrite any executable of the same module/package that was installed already, but maybe from a different version. Therefore only one version of a executable can be installed at a time which makes it impossible to work on different projects that use the same tool but with different versions. -2. **History and Future** — The local installation of executables built from [Go modules][go-docs-ref-mod]/`main` packages has always been a somewhat controversial point which unfortunately, partly for historical reasons, does not offer an optimal and user-friendly solution up to now. The [`go` command][go-pkg-cmd/go] is a fantastic toolchain that provides many great features one would expect to be provided out-of-the-box from a modern and well designed programming language without the requirement to use a third-party solution: from compiling code, running unit/integration/benchmark tests, quality and error analysis, debugging utilities and many more. Unfortunately the way the [`go install` command][go-pkg-cmd/go#install] of Go versions less or equal to 1.15 handles the installation is still not optimal. - The general problem of tool dependencies is a long-time known issue/weak point of the current Go toolchain and is a highly rated change request from the Go community with discussions like [golang/go#30515][gh-golang/go#30515], [golang/go#25922][gh-golang/go#25922] and [golang/go#27653][gh-golang/go#27653] to improve this essential feature, but they‘ve been around for quite a long time without a solution that works without introducing breaking changes and most users and the Go team agree on. - Luckily, this topic was finally picked up for the next [upcoming Go release version 1.16][gh-golang/go-ms-145] and [golang/go#40276][gh-golang/go#40276] introduces a way to install executables in module mode outside a module. The [release note preview also already includes details about this change][go-docs-tip-rln-1.16#mods] and how installation of executables from Go modules will be handled in the future. -3. **The Workaround** — Beside the great news and anticipation about an official solution for the problem the usage of a workaround is almost inevitable until Go 1.16 is finally released. - The [official Go wiki][gh-golang/go-wiki] provides a section on [“How can I track tool dependencies for a module?“][gh-golang/go-wiki-mods#tool_deps] that describes a workaround that tracks tool dependencies. It allows to use the Go module logic by using a file like `tools.go` with a dedicated `tools` build tag that prevents the included module dependencies to be picked up included for normal executable builds. This approach works fine for non-main packages, but CLI tools that are only implemented in the `main` package can not be imported in such a file. - In order to tackle this problem, a user from the community created [gobin][], an experimental, module-aware command to install/run main packages. It allows to install or run main-package commands without “polluting“ the `go.mod` file by default. It downloads modules in version-aware mode into a binary cache path within the [systems cache directory][go-pkg-func-os#usercachedir]. It prevents problems due to already globally installed executables by placing each version in its own directory. The decision to use a cache directory instead of sub-directories within the `GOBIN` path keeps the system clean. - `gobin` is still in an early development state, but has already received a lot of positive feedback and is used in many projects. There are also members of the core Go team that have contributed to the project and the chance is high that the changes for Go 1.16 were influenced or partially ported from it. See [gobin‘s FAQ page in the repository wiki][gobin-wiki-faq] for more details about the project. - It is currently the best workaround to… - 1. prevent the Go toolchain to pick up the [`GOMOD` environment variable][go-pkg-cmd/go#add_deps] (see [`go env GOMOD`][go-pkg-cmd/go#add_deps]) that is initialized automatically with the path to the [`go.mod` file][go-docs-cmd/go#go.mod] in the current working directory. - 2. install module/package executables globally without “polluting“ the [`go.mod` file][go-docs-cmd/go#go.mod]. - 3. install module/package executables globally without overriding already installed executables of different versions. -4. **The Go Module `Caster`** — To allow to manage the tool dependency problem, this caster uses `gobin` through to prevent the “pollution“ of the project [`go.mod` file][go-docs-cmd/go#go.mod] and allows to... - 1. install `gobin` itself into `GOBIN` (see [`go env GOBIN`][go-pkg-cmd/go#print_env]). - 2. cast any [spell incantation][go-pkg-if-spell#incantation] of kind [`KindGoModule`][go-pkg-const-spell#kindgomodule] by installing the executable globally into the dedicated `gobin` cache. +### Application Configurations -#### Project Metadata +The [`app`][go-pkg-app] package provides the functionality for application configurations. A [`Config`][go-pkg-stc-app#config] holds information and metadata of an application that is stored by types that implement the [`Store` interface][go-pkg-if-app#store]. The [`NewStore() app.Store`][go-pkg-func-app#newstore] function returns a reference implementation of this interface. -The [`pkg/project`][go-pkg-project] package provides metadata and [VCS][wikip-vcs] information of a project. +### Command Runners -##### VCS "Git" +The [`task`][go-pkg-task] package defines the API for runner of commands. [`Runner`][go-pkg-if-task#runner] is the base interface while [`RunnerExec` interface][go-pkg-if-task#runnerexec] is a specialized for (binary) executables of a command. -The [`pkg/project/vcs/git`][go-pkg-project/vcs/git] package provides [VCS][wikip-vcs] utility functions to interact with [Git][] repositories. +The package already provides runners for the [Go toolchain][go-pkg-cmd/go] and the [gobin][] Go module: -#### Spell Incantations +- **Go Toolchain** — to interact with the [Go toolchain][go-pkg-cmd/go], also known as the `go` executable, the [`golang.Runner`][go-pkg-stc-task/golang#runner] can be used. +- **`gobin` Go Module** — to install and run [Go module][go-docs-ref-mod] `main` the [`Runner`][go-pkg-stc-task/gobin#runner] makes use of the [`github.com/myitcv/gobin`][go-pkg-github.com/myitcv/gobin] command. + 1. **Go Executable Installation** — Using the [`go install`][go-pkg-cmd/go#install] or [`go get`][go-pkg-cmd/go#print_env] command for a [Go module][go-ref-mod] `main` package, the resulting executables are placed in the Go executable search path that is defined by the [`GOBIN` environment variable][go-pkg-cmd/go#env_vars] (see the [`go env` command][go-pkg-cmd/go#print_env] to show or modify the Go toolchain environment). + Even though executables are installed “globally“ for the current user, any [`go.mod` file][go-ref-mod#go.mod] in the current working directory will be updated to include the Go module. This is the default behavior of the [`go get` command][go-pkg-cmd/go#print_env] when running in [“module mode“][go-pkg-cmd/go#mod_cmds] (see [`GO111MODULE` environment variable). + Next to this problem, installed executables will also overwrite any previously installed executable of the same module/package regardless of the version. Therefore only one version of a executable can be installed at a time which makes it impossible to work on different projects that make use of the same executable but require different versions. + 2. **History and Future** — The installation concept for `main` package executables has always been a somewhat controversial point which unfortunately, partly for historical reasons, does not offer an optimal and user-friendly solution up to now. + The [`go` command][go-pkg-cmd/go] is a fantastic toolchain that provides many great features one would expect to be provided out-of-the-box from a modern and well designed programming language without the requirement to use a third-party solution: from compiling code, running unit/integration/benchmark tests, quality and error analysis, debugging utilities and many more. + Unfortunately this does not apply for the [`go install` command][go-pkg-cmd/go#install] of Go versions less or equal to 1.15. + The general problem of tool dependencies is a long-time known issue/weak point of the current Go toolchain and is a highly rated change request from the Go community with discussions like [golang/go#30515][gh-golang/go#30515], [golang/go#25922][gh-golang/go#25922] and [golang/go#27653][gh-golang/go#27653] to improve this essential feature, but they've been around for quite a long time without a solution that works without introducing breaking changes and most users and the Go team agree on. + Luckily, this topic was finally picked up for the next [upcoming Go release version 1.16][gh-golang/go-ms-145] and [golang/go#40276][gh-golang/go#40276] introduces a way to install executables in module mode outside a module. + The [release note preview also already includes details about this change][go-docs-tip-rln-1.16#mods] and how installation of executables from Go modules will be handled in the future. + 3. **The Workaround** — Beside the great news and anticipation about an official solution for the problem the usage of a workaround is almost inevitable until Go 1.16 is finally released. + The [official Go wiki][gh-golang/go-wiki] provides a section on [“How can I track tool dependencies for a module?“][gh-golang/go-wiki-mods#tool_deps] that describes a workaround that tracks tool dependencies. It allows to use the Go module logic by using a file like `tools.go` with a dedicated `tools` build tag that prevents the included module dependencies to be picked up for “normal“ executable builds. This approach works fine for non-main packages, but CLI tools that are only implemented in the `main` package can not be imported in such a file. + In order to tackle this problem, a well-known user from the community created [gobin][], an experimental, module-aware command to install and run `main` packages. + It allows to install or run `main` package commands without “polluting“ the `go.mod` file. Modules are downloaded in version-aware mode into a cache path within the [users local cache directory][go-pkg-func-os#usercachedir]. This way it prevents problems due to already installed executables by placing each version of an executable in its own directory. + The decision to use a cache directory instead of sub-directories within the `GOBIN` path doesn't require to mess with the users setup and keep the Go toolchain specific paths clean and unchanged. + `gobin` is still in an early development state, but has already received a lot of positive feedback and is used in many projects. There are also members of the core Go team that have contributed to the project and the chance is high that the changes for Go 1.16 were influenced or partially ported from it. See [gobin‘s FAQ page][gobin-wiki-faq] in the repository wiki for more details about the project. + It is currently the best workaround to… + 1. prevent the Go toolchain to pick up the [`GOMOD` (`go env GOMOD`) environment variable][go-pkg-cmd/go#print_env] that is initialized automatically with the path to the [`go.mod` file][go-ref-mod#go.mod] in the current working directory. + 2. install `main` package executables locally for the current user without “polluting“ the `go.mod` file. + 3. install `main` package executables locally for the current user without overriding already installed executables of different versions. + 4. **The Go Module `Runner`** — To allow to manage the tool dependency problem, this package provides a command runner that uses `gobin` in order to prevent the problems described in the sections above like the “pollution“ of the "go.mod" file and allows to… + 1. install `gobin` itself into `GOBIN` ([`go env GOBIN`][go-pkg-cmd/go#print_env]). + 2. run any Go module command by installing `main` package executables locally for the current user into the dedicated `gobin` cache. -The [`pkg/spell`][go-pkg-spell] package provides spell incantations for different kinds. +### Project Metadata -##### Filesystem Cleaning Spell Incantation +The [`project`][go-pkg-project] package defines the API for metadata and [VCS][wikip-vcs] information of a project. The [`New(opts ...project.Option) (*project.Metadata, error)`][go-pkg-fn-project#new] function can be used to create a new [project metadata][go-pkg-stc-project#metadata]. -The [`pkg/spell/fs/clean`][go-pkg-spell/fs/clean] package provides a spell incantation to remove directories in a filesystem. It implements [`spell.GoCode`][go-pkg-if-spell#gocode] and can be used without a [`cast.Caster`][go-pkg-if-cast#caster]. +The package also already provides a [VCS `Repository` interface reference implementation][go-pkg-if-project/vcs#repository] for [Git][]: -##### "goimports" Go Module Spell Incantation +- **VCS “Git“** — the [`git`][go-pkg-project/vcs/git] package provides VCS utility functions to interact with [Git][] repositories. -The [`pkg/spell/goimports`][go-pkg-spell/goimports] package provides a spell incantation for the [`golang.org/x/tools/cmd/goimports`][go-pkg-golang.org/x/tools/cmd/goimports] Go module command that allows to update Go import lines, add missing ones and remove unreferenced ones. It also formats code in the same style as [`gofmt`][go-pkg-cmd/gofmt] so it can be used as a replacement. The source code of `goimports` is [available in the GitHub repository][gh-golang/tools-tree-cmd/goimports]. +### Tasks -##### Go Toolchain Spell Incantations +The [`task`][go-pkg-task] package defines the API for tasks. [`Task`][go-pkg-if-task#task] is the base interface while [`Exec`][go-pkg-if-task#exec] and [`GoModule`][go-pkg-if-task#gomodule] are a specialized to represent the (binary) executable of either an “external“ or Go module based command. -The [`pkg/spell/golang`][go-pkg-spell/golang] package provides spell incantations for [Go toolchain][go-pkg-cmd/go] commands. +The package also already provides tasks for basic [Go toolchain][go-pkg-cmd/go] commands and popular modules from the Go ecosystem: -###### "build" Go Toolchain Spell Incantation +- **`goimports`** — the [`goimports`][go-pkg-task/goimports] package provides a task for the [`golang.org/x/tools/cmd/goimports`][go-pkg-golang.org/x/tools/cmd/goimports] Go module command. `goimports` allows to update Go import lines, add missing ones and remove unreferenced ones. It also formats code in the same style as [`gofmt`][go-pkg-cmd/gofmt] so it can be used as a replacement. The source code of `goimports` is [available in the GitHub repository][gh-golang/tools-tree-cmd/goimports]. +- **Go** — The [`golang`][go-pkg-task/golang] package provides tasks for [Go toolchain][go-pkg-cmd/go] commands. + - **`build`** — to run the [`build` command of the Go toolchain][go-pkg-cmd/go#build] the task of the [`build`][go-pkg-task/golang/build] package can be used. + - **`test`** — to run the [`test` command of the Go toolchain][go-pkg-cmd/go#test] the task of the [`test`][go-pkg-task/golang/test] package can be used. +- **`golangci-lint`** — the [`golangcilint`][go-pkg-task/golangcilint] package provides a task for the [`github.com/golangci/golangci-lint/cmd/golangci-lint`][go-pkg-github.com/golangci/golangci-lint/cmd/golangci-lint] Go module command. `golangci-lint` is a fast, parallel runner for dozens of Go linters that uses caching, supports YAML configurations and has integrations with all major IDEs. The source code of `golangci-lint` is [available in the GitHub repository][gh-golangci/golangci-lint]. +- **`gox`** — the [`gox`][go-pkg-task/gox] package provides a task for the [`github.com/mitchellh/gox`][go-pkg-github.com/mitchellh/gox] Go module command. `gox` is a dead simple, no frills Go cross compile tool that behaves a lot like the standard [Go toolchain `build` command][go-pkg-cmd/go#build]. The source code of `gox` is [available in the GitHub repository][gh-mitchellh/gox]. -The [`pkg/spell/golang`][go-pkg-spell/golang/build] package provides a spell incantation for the [`build` command of the Go toolchain][go-pkg-cmd/go#build]. +There are also tasks that don‘t need to implement the task API but make use of some “loose“ features like information about a project application are shared as well as the dynamic option system. They can be used without a `task.Runner`, just like a “normal“ package, and provide Go functions/methods that can be called directly: -###### "test" Go Toolchain Spell Incantation +- **Filesystem Cleaning** — The [`clean`][go-pkg-task/fs/clean] package provides a task to remove directories in a filesystem. -The [`pkg/spell/golang/test`][go-pkg-spell/golang/test] package provides a spell incantation for the [`test` command of the Go toolchain][go-pkg-cmd/go#test]. +## Usage Guides -##### "golangci-lint" Go Module Spell Incantation +In the following sections you can learn how to use the _wand_ reference implementation [“elder wand“](#elder-wand), compose/extend it or simply implement your own tasks and runners. -The [`pkg/spell/golangcilint`][go-pkg-spell/golangcilint] package provides a spell incantation for the [`github.com/golangci/golangci-lint/cmd/golangci-lint`][go-pkg-github.com/golangci/golangci-lint/cmd/golangci-lint] Go module command, a fast, parallel runner for dozens of Go linters that uses caching, supports YAML configurations and has integrations with all major IDEs. The source code of `golangci-lint` is [available in the GitHub repository][gh-golangci/golangci-lint]. - -##### "gox" Go Module Spell Incantation +### Elder Wand -The [`pkg/spell/gox`][go-pkg-spell/gox] package provides a spell incantation for the [`github.com/mitchellh/gox`][go-pkg-github.com/mitchellh/gox] Go module command, a dead simple, no frills Go cross compile tool that behaves a lot like the standard [Go toolchain `build` command][go-pkg-cmd/go#build]. The source code of `golangci-lint` is [available in the GitHub repository][gh-mitchellh/gox]. +The [`elder`][go-pkg-elder] package is the reference implementation of the main [`wand.Wand`][go-pkg-if-wand#wand] interface that provides common Mage tasks and stores configurations and metadata for applications of a project. Next to task methods for the Go toolchain and Go module commands, it comes with additional methods like `Bootstrap` to run initialization actions or `Validate` to ensure that the _wand_ is initialized properly. -### Elder Wand +Create your [_Magefile_][mage-files], e.g `magefile.go`, and use the [`New`][go-pkg-fn-elder#new] function to initialize a new wand and register any amount of applications. +Create a global variable of type `*elder.Elder` and assign the created “elder wand“ to make it available to all functions in your _Magefile_. Even though global variables are a bad practice and should be avoid at all, it‘s totally fine for your task automation since it is non-production code. -The [`elder`][go-pkg-elder] package contains a reference implementation of the main [`wand.Wand`][go-pkg-if-wand#wand] interface that provides common Mage tasks and stores configurations and metadata for applications of a project. Next to task methods for the Go toolchain and Go module commands, it comes with additional methods like `Bootstrap`, that runs initialization tasks to ensure the _wand_ is operational, or `Validate`, that ensures that all casters are properly initialized and available. +Note that the _Mage_ specific **`// +build mage` [build constraint][go-pkg-pkg/go/build#constraints], also known as a build tag, is important** in order to mark the file as _Magefile_. See the [official _Mage_ documentation][mage-files] for more details. @@ -167,13 +154,22 @@ The [`elder`][go-pkg-elder] package contains a reference implementation of the m package main import ( - "fmt" + "context" + "fmt" "os" + "github.com/svengreb/nib" "github.com/svengreb/nib/inkpen" + "github.com/svengreb/wand/pkg/elder" wandProj "github.com/svengreb/wand/pkg/project" + wandProjVCS "github.com/svengreb/wand/pkg/project/vcs" + taskGo "github.com/svengreb/wand/pkg/task/golang" + taskGoBuild "github.com/svengreb/wand/pkg/task/golang/build" ) + +var elderWand *elder.Elder + func init() { // Create a new "elder wand". ew, ewErr := elder.New( @@ -195,18 +191,81 @@ func init() { apps := []struct { name, displayName, pathRel string }{ - {"cli", "Fruit Mixer CLI", "apps/cli"}, - {"daemon", "Fruit Mixer Daemon", "apps/daemon"}, - {"prometheus-exporter", "Fruit Mixer Prometheus Exporter", "apps/promexp"}, + {"fruitctl", "Fruit CLI", "apps/cli"}, + {"fruitd", "Fruit Daemon", "apps/daemon"}, + {"fruitpromexp", "Fruit Prometheus Exporter", "apps/promexp"}, } for _, app := range apps { if regErr := ew.RegisterApp(app.name, app.displayName, app.pathRel); regErr != nil { ew.ExitPrintf(1, nib.ErrorVerbosity, "Failed to register application %q: %v", app.name, regErr) } } + + elderWand = ew } ``` +Now you can create [_Mage_ target functions][mage-targets] using the task methods of the “elder wand“. + +```go +func Build(mageCtx context.Context) { + buildErr := elderWand.GoBuild( + cliAppName, + taskGoBuild.WithBinaryArtifactName(cliAppName), + taskGoBuild.WithGoOptions( + taskGo.WithTrimmedPath(true), + ), + ) + if buildErr != nil { + fmt.Printf("Build incomplete: %v\n", buildErr) + } +} +``` + + + +See the [examples](#examples) to learn about more uses cases and way how to structure your _Mage_ setup. + +### Build It Yourself + +_wand_ comes with tasks and runners for common [Go toolchain][go-pkg-cmd/go] commands, the [gobin][] and other popular modules from the Go ecosystem, but the chance is high that you want to build your own for your specific use cases. + +#### Custom Tasks + +To create your own task that is compatible with the _wand_ API, implement the [`Task`][go-pkg-if-task#task] base interface or any of its specialized interfaces. The `Kind() task.Kind` method must return [`KindBase`][go-pkg-al-task#kindbase] while `Options() task.Options` can return anything since [`task.Options`][go-pkg-al-task#options] is just an alias for `interface{}`. + +1. If your task is **intended for an executable command** you need to implement the [`Exec`][go-pkg-if-task#exec] interface where… + - the `Kind() task.Kind` method must return [`KindExec`][go-pkg-al-task#kindexec]. + - the `BuildParams() []string` method must return all the parameters that should be passed to the executable. +2. If your task is **intended for the `main` package of a Go module**, so basically also an executable command, you need to implement the [`GoModule`][go-pkg-if-task#gomodule] interface where… + - the `Kind() task.Kind` method must return [`KindGoModule`][go-pkg-al-task#kindgomodule]. + - the `BuildParams() []string` method must return all the parameters that should be passed to the executable that was compiled from the `main` package of the Go module. + - the returned type of the `ID() *project.GoModuleID` method must provide the import path and module version of the target `main` package. + +For sample code of a custom task please see the [examples](#examples) section. +Based on your task kind, you can also either use one of the [already provided command runners](#command-runners), like for the [Go toolchain][go-pkg-task/golang] and [gobin][], or [implement your own custom runner](#custom-runners). + +#### Custom Runners + +To create your own command runner that is compatible with the _wand_ API, implement the [`Runner`][go-pkg-if-task#runner] base interface or any of its specialized interfaces. The `Handles() Kind` method must return the [`Kind`][go-pkg-al-task#kind] that can be handled while the actual business logic of `Validate() errors` is not bound to any constraint, but like the method names states, should ensure that the runner is configured properly and is operational. The `Run(task.Task) error` method represents the main functionality of the interface and is responsible for running the given [`task.Task`][go-pkg-if-task#task] by passing all task parameters, obtained through the `BuildParams() []string` method, and finally execute the configured file. Optionally you can also inspect and use its [`task.Options`][go-pkg-al-task#options] by casting the type returned from the `Options() task.Options` method. + +1. If your runner is **intended for an executable command** you need to implement the [`RunnerExec`][go-pkg-if-task#runnerexec] interface where… + - the `Handles() Kind` method can return kinds like [`KindExec`][go-pkg-al-task#kindexec] or [`KindGoModule`][go-pkg-al-task#kindgomodule]. + - the `Run(task.Task) error` method runs the given [`task.Task`][go-pkg-if-task#task] by passing all task parameters, obtained through the `BuildParams() []string` method, and finally execute the configured file. + - it is recommended that the `Validate() error` method tests if the executable file of the command exists at the configured path in the target filesystem or maybe also check other (default) paths if this is not the case. It is also often a good preventative measure to prevent problems to check that the current process actually has permissions to read and execute the file. + +For a sample code of a custom command runner please see the [examples](#examples) section. +Based on the kind your command runner can handle, you can also either use one of the [already provided tasks](#tasks) or [implement your own custom task](#custom-task). + +## Examples + +To learn how to use the _wand_ API and its packages, the [`examples` repository directory][gh-tree-examples] contains code samples for multiple use cases: + +- **Create your own command runner** — The [`custom_runner`][gh-tree-examples/custom_runner] directory contains code samples to demonstrate [how to create a custom command runner](#custom-runners). The `FruitMixerRunner` struct implements the [`RunnerExec`][go-pkg-if-task#runnerexec] interface for the imaginary `fruitctl` application. +- **Create your own task** — The [`custom_task`][gh-tree-examples/custom_task] directory contains code samples to demonstrate [how to create a custom task](#custom-tasks). The `MixTask` struct implements the [`Exec`][go-pkg-if-task#exec] interface for the imaginary `fruitctl` application. +- **Usage in a [monorepo][trunkbasedev-monorepos] layout** — The [`monorepo`][gh-tree-examples/monorepo] directory contains code samples to demonstrate the usage in a _monorepo_ layout for three example applications `cli`, `daemon` and `promexp`. The _Magefile_ provides a `build` target to build all applications. Each application also has a dedicated `:build` target using the [`mg.Namespace`][go-pkg-al-github.com/magefile/mage/mg#namespace] to only build it individually. +- **Usage with a simple, single command repository layout** — The [`simple`][gh-tree-examples/simple] directory contains code samples to demonstrate the usage in a “simple“ repository that only provides a single command. The _Magefile_ provides a `build` target to build the `fruitctl` application. + ## Contributing _wand_ is an open source project and contributions are always welcome! @@ -243,28 +302,35 @@ The guide also includes information about [minimal, complete, and verifiable exa [gh-golang/tools-tree-cmd/goimports]: https://github.com/golang/tools/tree/master/cmd/goimports [gh-golangci/golangci-lint]: https://github.com/golangci/golangci-lint/tree/master/cmd/golangci-lint [gh-mitchellh/gox]: https://github.com/mitchellh/gox +[gh-tree-examples]: https://github.com/svengreb/wand/tree/main/examples +[gh-tree-examples/custom_runner]: https://github.com/svengreb/wand/tree/main/examples/custom_runner +[gh-tree-examples/custom_task]: https://github.com/svengreb/wand/tree/main/examples/custom_task +[gh-tree-examples/monorepo]: https://github.com/svengreb/wand/tree/main/examples/monorepo +[gh-tree-examples/simple]: https://github.com/svengreb/wand/tree/main/examples/simple [gh-tree-golang/go/src]: https://github.com/golang/go/tree/926994fd/src [git]: https://git-scm.com [gnu-make-docs-shell]: https://www.gnu.org/software/make/manual/html_node/Choosing-the-Shell.html [gnu-make-repo]: https://savannah.gnu.org/git/?group=make -[go-docs-cmd/go#go.mod]: https://golang.org/ref/mod#go-mod-file -[go-docs-cmd/go#mod_cmds]: https://golang.org/ref/mod#mod-commands [go-docs-ref-mod]: https://golang.org/ref/mod [go-docs-tip-rln-1.16#mods]: https://tip.golang.org/doc/go1.16#modules +[go-pkg-al-github.com/magefile/mage/mg#namespace]: https://pkg.go.dev/github.com/magefile/mage/mg#Namespace +[go-pkg-al-task#kind]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task#Kind +[go-pkg-al-task#kindbase]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task#KindBase +[go-pkg-al-task#kindexec]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task#KindExec +[go-pkg-al-task#kindgomodule]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task#KindGoModule +[go-pkg-al-task#options]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task#Options [go-pkg-app]: https://pkg.go.dev/github.com/svengreb/wand/pkg/app -[go-pkg-cast]: https://pkg.go.dev/github.com/svengreb/wand/pkg/cast -[go-pkg-cast/gobin]: https://pkg.go.dev/github.com/svengreb/wand/pkg/cast/gobin -[go-pkg-cast/golang/toolchain]: https://pkg.go.dev/github.com/svengreb/wand/pkg/cast/golang/toolchain [go-pkg-cmd/go]: https://pkg.go.dev/cmd/go -[go-pkg-cmd/go#add_deps]: https://pkg.go.dev/cmd/go/#hdr-PrintGoenvironment_information [go-pkg-cmd/go#build]: https://pkg.go.dev/cmd/go#hdr-Compile_packages_and_dependencies [go-pkg-cmd/go#env_vars]: https://pkg.go.dev/cmd/go/#hdr-Environment_variables [go-pkg-cmd/go#install]: https://pkg.go.dev/cmd/go#hdr-Compile_and_install_packages_and_dependencies -[go-pkg-cmd/go#print_env]: https://pkg.go.dev/cmd/go#hdr-PrintGoenvironment_information +[go-pkg-cmd/go#mod_cmds]: https://golang.org/ref/mod#mod-commands +[go-pkg-cmd/go#print_env]: https://pkg.go.dev/cmd/go/#hdr-Print_Go_environment_information [go-pkg-cmd/go#test]: https://pkg.go.dev/cmd/go#hdr-Test_packages [go-pkg-cmd/gofmt]: https://pkg.go.dev/cmd/gofmt -[go-pkg-const-spell#kindgomodule]: https://pkg.go.dev/github.com/svengreb/wand/pkg/spell#KindGoModule [go-pkg-elder]: https://pkg.go.dev/github.com/svengreb/wand/pkg/elder +[go-pkg-fn-elder#new]: https://pkg.go.dev/github.com/svengreb/wand/pkg/elder#New +[go-pkg-fn-project#new]: https://pkg.go.dev/github.com/svengreb/wand/pkg/project#New [go-pkg-func-app#newstore]: https://pkg.go.dev/github.com/svengreb/wand/pkg/app#NewStore [go-pkg-func-os#usercachedir]: https://pkg.go.dev/os/#UserCacheDir [go-pkg-github.com/golangci/golangci-lint/cmd/golangci-lint]: https://pkg.go.dev/github.com/golangci/golangci-lint/cmd/golangci-lint @@ -272,24 +338,32 @@ The guide also includes information about [minimal, complete, and verifiable exa [go-pkg-github.com/myitcv/gobin]: https://pkg.go.dev/github.com/myitcv/gobin [go-pkg-golang.org/x/tools/cmd/goimports]: https://pkg.go.dev/golang.org/x/tools/cmd/goimports [go-pkg-if-app#store]: https://pkg.go.dev/github.com/svengreb/wand/pkg/app#Store -[go-pkg-if-cast#binarycaster]: https://pkg.go.dev/github.com/svengreb/wand/pkg/cast#BinaryCaster -[go-pkg-if-cast#caster]: https://pkg.go.dev/github.com/svengreb/wand/pkg/cast#Caster -[go-pkg-if-spell#gocode]: https://pkg.go.dev/github.com/svengreb/wand/pkg/spell#GoCode -[go-pkg-if-spell#incantation]: https://pkg.go.dev/github.com/svengreb/wand/pkg/spell#Incantation +[go-pkg-if-project/vcs#repository]: https://pkg.go.dev/github.com/svengreb/wand/pkg/project/vcs#Repository +[go-pkg-if-task#exec]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task#Exec +[go-pkg-if-task#gomodule]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task#GoModule +[go-pkg-if-task#runner]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task#Runner +[go-pkg-if-task#runnerexec]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task#RunnerExec +[go-pkg-if-task#task]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task#Task [go-pkg-if-wand#wand]: https://pkg.go.dev/github.com/svengreb/wand#Wand [go-pkg-pkg]: https://pkg.go.dev/github.com/svengreb/wand/pkg +[go-pkg-pkg/go/build#constraints]: https://pkg.go.dev/pkg/go/build/#hdr-Build_Constraints [go-pkg-project]: https://pkg.go.dev/github.com/svengreb/wand/pkg/project [go-pkg-project/vcs/git]: https://pkg.go.dev/github.com/svengreb/wand/pkg/project/vcs/git -[go-pkg-spell]: https://pkg.go.dev/github.com/svengreb/wand/pkg/spell -[go-pkg-spell/fs/clean]: https://pkg.go.dev/github.com/svengreb/wand/pkg/spell/fs/clean -[go-pkg-spell/goimports]: https://pkg.go.dev/github.com/svengreb/wand/pkg/spell/goimports -[go-pkg-spell/golang]: https://pkg.go.dev/github.com/svengreb/wand/pkg/spell/golang -[go-pkg-spell/golang/build]: https://pkg.go.dev/github.com/svengreb/wand/pkg/spell/golang/build -[go-pkg-spell/golang/test]: https://pkg.go.dev/github.com/svengreb/wand/pkg/spell/golang/test -[go-pkg-spell/golangcilint]: https://pkg.go.dev/github.com/svengreb/wand/pkg/spell/golangcilint -[go-pkg-spell/gox]: https://pkg.go.dev/github.com/svengreb/wand/pkg/spell/gox [go-pkg-stc-app#config]: https://pkg.go.dev/github.com/svengreb/wand/pkg/app#Config +[go-pkg-stc-project#metadata]: https://pkg.go.dev/github.com/svengreb/wand/pkg/project#Metadata +[go-pkg-stc-task/gobin#runner]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task/gobin#Runner +[go-pkg-stc-task/golang#runner]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task/golang#Runner +[go-pkg-task]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task +[go-pkg-task/fs/clean]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task/fs/clean +[go-pkg-task/goimports]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task/goimports +[go-pkg-task/golang]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task/golang +[go-pkg-task/golang/build]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task/golang/build +[go-pkg-task/golang/test]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task/golang/test +[go-pkg-task/golangcilint]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task/golangcilint +[go-pkg-task/gox]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task/gox [go-pkg-wand]: https://pkg.go.dev/github.com/svengreb/wand +[go-ref-mod]: https://golang.org/ref/mod +[go-ref-mod#go.mod]: https://golang.org/ref/mod#go-mod-file [gobin-wiki-faq]: https://github.com/myitcv/gobin/wiki/FAQ [gobin]: https://github.com/myitcv/gobin [gradle]: https://gradle.org @@ -298,6 +372,7 @@ The guide also includes information about [minimal, complete, and verifiable exa [mage-deps#paral]: https://magefile.org/dependencies/#parallelism [mage-files]: https://magefile.org/magefiles [mage-importing]: https://magefile.org/importing +[mage-targets]: https://magefile.org/targets [mage-zero_install]: https://magefile.org/zeroinstall [mage]: https://magefile.org [make]: https://www.gnu.org/software/make @@ -307,10 +382,7 @@ The guide also includes information about [minimal, complete, and verifiable exa [trunkbasedev-monorepos]: https://trunkbaseddevelopment.com/monorepos [wikip-dsl]: https://en.wikipedia.org/wiki/Domain-specific_language [wikip-exec]: https://en.wikipedia.org/wiki/Executable -[wikip-hp_magic#cast]: https://en.wikipedia.org/wiki/Magic_in_Harry_Potter#Spellcasting [wikip-hp]: https://en.wikipedia.org/wiki/Harry_Potter -[wikip-inc]: https://en.wikipedia.org/wiki/Incantation -[wikip-magic#magicians]: https://en.wikipedia.org/wiki/Magic_(supernatural)#Magicians [wikip-path_var]: https://en.wikipedia.org/wiki/PATH_(variable) [wikip-shell_builtin]: https://en.wikipedia.org/wiki/Shell_builtin [wikip-vcs]: https://en.wikipedia.org/wiki/Version_control diff --git a/examples/custom_runner/runners.go b/examples/custom_runner/runners.go new file mode 100644 index 0000000..dc4a957 --- /dev/null +++ b/examples/custom_runner/runners.go @@ -0,0 +1,141 @@ +// +build mage + +package main + +import ( + "fmt" + "os/exec" + + "github.com/magefile/mage/sh" + glFS "github.com/svengreb/golib/pkg/io/fs" + + "github.com/svengreb/wand/pkg/task" +) + +const ( + // DefaultRunnerExec is the default name of the runner executable. + DefaultRunnerExec = "fruitctl" + + // RunnerName is the name of the runner. + RunnerName = "fruit_mixer" +) + +// FruitMixerOption is a fruit mixer runner option. +type FruitMixerOption func(*FruitMixerOptions) + +// FruitMixerOptions are fruit mixer runner options. +type FruitMixerOptions struct { + // Env is the runner specific environment. + Env map[string]string + + // Exec is the name or path of the runner command executable. + Exec string + + // Quiet indicates whether the runner output should be minimal. + Quiet bool +} + +// FruitMixerRunner is a task runner for the fruit mixer. +type FruitMixerRunner struct { + opts *FruitMixerOptions +} + +// FilePath returns the path to the runner executable. +func (r *FruitMixerRunner) FilePath() string { + return r.opts.Exec +} + +// Handles returns the supported task kind. +func (r *FruitMixerRunner) Handles() task.Kind { + return task.KindExec +} + +// Run runs the command. +// It returns an error of type *task.ErrRunner when any error occurs during the command execution. +func (r *FruitMixerRunner) Run(t task.Task) error { + tExec, ok := t.(task.Exec) + if t.Kind() != task.KindExec || !ok { + return &task.ErrRunner{ + Err: fmt.Errorf("expected %q but got %q", r.Handles(), t.Kind()), + Kind: task.ErrUnsupportedTaskKind, + } + } + + runFn := sh.RunWithV + if r.opts.Quiet { + runFn = sh.RunWith + } + + for k, v := range tExec.Env() { + r.opts.Env[k] = v + } + + return runFn(r.opts.Env, r.opts.Exec, tExec.BuildParams()...) +} + +// Validate validates the command executable. +// It returns an error of type *task.ErrRunner when the executable does not exist and when it is also not available in +// the executable search path(s) of the current environment. +func (r *FruitMixerRunner) Validate() error { + // Check if the executable exists,... + execExits, fsErr := glFS.RegularFileExists(r.opts.Exec) + if fsErr != nil { + return &task.ErrRunner{ + Err: fmt.Errorf("command runner %q: %w", RunnerName, fsErr), + Kind: task.ErrRunnerValidation, + } + } + // ...otherwise try to look up the executable search path(s). + if !execExits { + path, pathErr := exec.LookPath(r.opts.Exec) + if pathErr != nil { + return &task.ErrRunner{ + Err: fmt.Errorf("command runner %q: %q not found in PATH: %w", RunnerName, r.opts.Exec, pathErr), + Kind: task.ErrRunnerValidation, + } + } + r.opts.Exec = path + } + + return nil +} + +// NewFruitMixerRunner creates a new fruit mixer command runner. +func NewFruitMixerRunner(opts ...FruitMixerOption) *FruitMixerRunner { + return &FruitMixerRunner{opts: NewFruitMixerRunnerOptions(opts...)} +} + +// NewFruitMixerRunnerOptions creates new fruit mixer runner options. +func NewFruitMixerRunnerOptions(opts ...FruitMixerOption) *FruitMixerOptions { + opt := &FruitMixerOptions{ + Env: make(map[string]string), + Exec: DefaultRunnerExec, + } + for _, o := range opts { + o(opt) + } + + return opt +} + +// WithEnv sets the runner specific environment. +func WithEnv(env map[string]string) FruitMixerOption { + return func(o *FruitMixerOptions) { + o.Env = env + } +} + +// WithExec sets the name or path of the runner command executable. +// Defaults to DefaultRunnerExec. +func WithExec(nameOrPath string) FruitMixerOption { + return func(o *FruitMixerOptions) { + o.Exec = nameOrPath + } +} + +// WithQuiet indicates whether the runner output should be minimal. +func WithQuiet(quiet bool) FruitMixerOption { + return func(o *FruitMixerOptions) { + o.Quiet = quiet + } +} diff --git a/examples/custom_task/tasks.go b/examples/custom_task/tasks.go new file mode 100644 index 0000000..ec1802b --- /dev/null +++ b/examples/custom_task/tasks.go @@ -0,0 +1,104 @@ +// +build mage + +package main + +import ( + "github.com/svengreb/wand" + "github.com/svengreb/wand/pkg/app" + "github.com/svengreb/wand/pkg/task" +) + +// MixOption is a mix task option. +type MixOption func(*MixOptions) + +// MixOptions are mix task options. +type MixOptions struct { + // env is the task specific environment. + env map[string]string + + // extraArgs are additional arguments passed to the command. + extraArgs []string + + // fruits are the fruits. + fruits []string + + // verbose indicates whether the output should be verbose. + verbose bool +} + +// MixTask is a mix task for the fruit CLI. +type MixTask struct { + ac app.Config + opts *MixOptions +} + +// BuildParams builds the parameters. +func (t *MixTask) BuildParams() []string { + var params []string + + // Toggle verbose output. + if t.opts.verbose { + params = append(params, "-v") + } + + // Include additionally configured arguments. + params = append(params, t.opts.extraArgs...) + + // Append all fruits. + params = append(params, t.opts.fruits...) + + return params +} + +// Env returns the task specific environment. +func (t *MixTask) Env() map[string]string { + return t.opts.env +} + +// Kind returns the task kind. +func (t *MixTask) Kind() task.Kind { + return task.KindExec +} + +// Options returns the task options. +func (t *MixTask) Options() task.Options { + return *t.opts +} + +// NewMixTask creates a new mix task for the fruit CLI. +func NewMixTask(wand wand.Wand, ac app.Config, opts ...MixOption) (*MixTask, error) { + return &MixTask{ac: ac, opts: NewMixOptions(opts...)}, nil +} + +// NewMixOptions creates new mix task options. +func NewMixOptions(opts ...MixOption) *MixOptions { + opt := &MixOptions{ + env: make(map[string]string), + } + for _, o := range opts { + o(opt) + } + + return opt +} + +// WithEnv sets the mix task specific environment. +func WithEnv(env map[string]string) MixOption { + return func(o *MixOptions) { + o.env = env + } +} + +// WithFruits adds fruits. +func WithFruits(fruits ...string) MixOption { + return func(o *MixOptions) { + o.fruits = append(o.fruits, fruits...) + } +} + +// WithVerboseOutput indicates whether the output should be verbose. +func WithVerboseOutput(verbose bool) MixOption { + return func(o *MixOptions) { + o.verbose = verbose + } +} diff --git a/examples/monorepo/apps/cli/cli.go b/examples/monorepo/apps/cli/cli.go new file mode 100644 index 0000000..6ef693a --- /dev/null +++ b/examples/monorepo/apps/cli/cli.go @@ -0,0 +1,9 @@ +package main + +import ( + "github.com/svengreb/wand/examples/monorepo/pkg/cmd/cli" +) + +func main() { + cli.Main() +} diff --git a/examples/monorepo/apps/daemon/daemon.go b/examples/monorepo/apps/daemon/daemon.go new file mode 100644 index 0000000..d083254 --- /dev/null +++ b/examples/monorepo/apps/daemon/daemon.go @@ -0,0 +1,9 @@ +package main + +import ( + "github.com/svengreb/wand/examples/monorepo/pkg/cmd/daemon" +) + +func main() { + daemon.Main() +} diff --git a/examples/monorepo/apps/promexp/promexp.go b/examples/monorepo/apps/promexp/promexp.go new file mode 100644 index 0000000..ac53c11 --- /dev/null +++ b/examples/monorepo/apps/promexp/promexp.go @@ -0,0 +1,9 @@ +package main + +import ( + "github.com/svengreb/wand/examples/monorepo/pkg/cmd/promexp" +) + +func main() { + promexp.Main() +} diff --git a/examples/monorepo/magefile.go b/examples/monorepo/magefile.go new file mode 100644 index 0000000..3274c00 --- /dev/null +++ b/examples/monorepo/magefile.go @@ -0,0 +1,99 @@ +// +build mage + +package main + +import ( + "context" + "fmt" + "os" + + "github.com/magefile/mage/mg" + "github.com/svengreb/nib" + "github.com/svengreb/nib/inkpen" + + appCLI "github.com/svengreb/wand/examples/monorepo/pkg/cmd/cli" + appDaemon "github.com/svengreb/wand/examples/monorepo/pkg/cmd/daemon" + appPromExp "github.com/svengreb/wand/examples/monorepo/pkg/cmd/promexp" + "github.com/svengreb/wand/pkg/elder" + wandProj "github.com/svengreb/wand/pkg/project" + wandProjVCS "github.com/svengreb/wand/pkg/project/vcs" + taskGo "github.com/svengreb/wand/pkg/task/golang" + taskGoBuild "github.com/svengreb/wand/pkg/task/golang/build" +) + +const ( + projectDisplayName = "Fruit Mixer" + projectName = "fruit-mixer" +) + +var elderWand *elder.Elder + +func init() { + // Create a new "elder wand". + ew, ewErr := elder.New( + // Provide information about the project. + elder.WithProjectOptions( + wandProj.WithName(projectName), + wandProj.WithDisplayName(projectDisplayName), + wandProj.WithVCSKind(wandProjVCS.KindNone), + wandProj.WithModulePath("github.com/svengreb/wand/examples/monorepo"), + ), + // Use "github.com/svengreb/nib/inkpen" module as line printer for human-facing messages. + elder.WithNib(inkpen.New()), + ) + if ewErr != nil { + fmt.Printf("Failed to initialize elder wand: %v\n", ewErr) + os.Exit(1) + } + + // Register any amount of project applications (monorepo layout). + apps := []struct { + name, displayName, pathRel string + }{ + {appCLI.Name, appCLI.DisplayName, "apps/cli"}, + {appDaemon.Name, appDaemon.DisplayName, "apps/daemon"}, + {appPromExp.Name, appPromExp.DisplayName, "apps/promexp"}, + } + for _, app := range apps { + if regErr := ew.RegisterApp(app.name, app.displayName, app.pathRel); regErr != nil { + ew.ExitPrintf(1, nib.ErrorVerbosity, "Failed to register application %q: %v", app.name, regErr) + } + } + + elderWand = ew +} + +func baseGoBuild(appName string) { + buildErr := elderWand.GoBuild( + appName, + taskGoBuild.WithBinaryArtifactName(appName), + taskGoBuild.WithOutputDir("out"), + taskGoBuild.WithGoOptions( + taskGo.WithTrimmedPath(true), + ), + ) + if buildErr != nil { + fmt.Printf("Build incomplete: %v\n", buildErr) + } + elderWand.Successf("Build completed") +} + +func Build(mageCtx context.Context) { + mg.SerialDeps( + CLI.Build, + Daemon.Build, + PrometheusExporter.Build, + ) +} + +type CLI mg.Namespace + +func (CLI) Build(mageCtx context.Context) { baseGoBuild(appCLI.Name) } + +type Daemon mg.Namespace + +func (Daemon) Build(mageCtx context.Context) { baseGoBuild(appDaemon.Name) } + +type PrometheusExporter mg.Namespace + +func (PrometheusExporter) Build(mageCtx context.Context) { baseGoBuild(appPromExp.Name) } diff --git a/examples/monorepo/pkg/cmd/cli/cli.go b/examples/monorepo/pkg/cmd/cli/cli.go new file mode 100644 index 0000000..39a09d4 --- /dev/null +++ b/examples/monorepo/pkg/cmd/cli/cli.go @@ -0,0 +1,35 @@ +package cli + +import ( + "flag" + "fmt" + "os" + "strings" +) + +var flagVerbose = flag.Bool("v", false, "enable verbose output") + +func usage() { + fmt.Fprintf(os.Stderr, "usage: fruitctl [flags] \n") + flag.PrintDefaults() +} + +func tasty(fruits ...string) { + msg := fmt.Sprintf("Washing %d tasty fruits", len(fruits)) + if *flagVerbose { + msg = fmt.Sprintf("Tasty fruits: %s!", strings.Join(fruits, ", ")) + } + fmt.Println(msg) +} + +func Main() { + flag.Usage = usage + flag.Parse() + + if flag.NArg() == 0 { + fmt.Fprintln(os.Stderr, "error: at least one fruit is required") + os.Exit(2) + } + + tasty(flag.Args()...) +} diff --git a/examples/monorepo/pkg/cmd/cli/config.go b/examples/monorepo/pkg/cmd/cli/config.go new file mode 100644 index 0000000..574b73d --- /dev/null +++ b/examples/monorepo/pkg/cmd/cli/config.go @@ -0,0 +1,6 @@ +package cli + +const ( + DisplayName = "Fruit CLI" + Name = "fruitctl" +) diff --git a/examples/monorepo/pkg/cmd/daemon/config.go b/examples/monorepo/pkg/cmd/daemon/config.go new file mode 100644 index 0000000..573a0f5 --- /dev/null +++ b/examples/monorepo/pkg/cmd/daemon/config.go @@ -0,0 +1,6 @@ +package daemon + +const ( + DisplayName = "Fruit Daemon" + Name = "fruitd" +) diff --git a/examples/monorepo/pkg/cmd/daemon/daemon.go b/examples/monorepo/pkg/cmd/daemon/daemon.go new file mode 100644 index 0000000..fd44f82 --- /dev/null +++ b/examples/monorepo/pkg/cmd/daemon/daemon.go @@ -0,0 +1,36 @@ +package daemon + +import ( + "flag" + "fmt" + "log" + "os" + "strings" +) + +var flagBgJob = flag.Bool("b", false, "run as background job") + +func usage() { + fmt.Fprintf(os.Stderr, "usage: fruitd [flags] \n") + flag.PrintDefaults() +} + +func run(fruits ...string) { + msg := "Starting fruit daemon" + if *flagBgJob { + msg += " as background job" + } + log.Printf("%s for %s...\n", msg, strings.Join(fruits, ", ")) +} + +func Main() { + flag.Usage = usage + flag.Parse() + + if flag.NArg() == 0 { + fmt.Fprintln(os.Stderr, "error: at least one fruit is required") + os.Exit(2) + } + + run(flag.Args()...) +} diff --git a/examples/monorepo/pkg/cmd/promexp/config.go b/examples/monorepo/pkg/cmd/promexp/config.go new file mode 100644 index 0000000..5433552 --- /dev/null +++ b/examples/monorepo/pkg/cmd/promexp/config.go @@ -0,0 +1,6 @@ +package promexp + +const ( + DisplayName = "Fruit Prometheus Exporter" + Name = "fruitpromexp" +) diff --git a/examples/monorepo/pkg/cmd/promexp/promexp.go b/examples/monorepo/pkg/cmd/promexp/promexp.go new file mode 100644 index 0000000..6f242d9 --- /dev/null +++ b/examples/monorepo/pkg/cmd/promexp/promexp.go @@ -0,0 +1,38 @@ +package promexp + +import ( + "flag" + "fmt" + "log" + "os" +) + +var ( + flagScrapeEndpoint = flag.String("s", "", "the endpoint for scraping") + flagPromURL = flag.String("p", "", "the Prometheus connection URL") +) + +func usage() { + fmt.Fprintf(os.Stderr, "usage: fruitpromexp [flags]\n") + flag.PrintDefaults() +} + +func export() { + log.Printf("Exporting data from %q to %q...\n", *flagScrapeEndpoint, *flagPromURL) +} + +func Main() { + flag.Usage = usage + flag.Parse() + + if flag.NFlag() != 2 { + fmt.Fprintln(os.Stderr, "error: scrape endpoint and Prometheus connection URL are required") + os.Exit(1) + } + if *flagPromURL == "" || *flagScrapeEndpoint == "" { + fmt.Fprintln(os.Stderr, "error: scrape endpoint or Prometheus connection URL must not be empty") + os.Exit(1) + } + + export() +} diff --git a/examples/simple/magefile.go b/examples/simple/magefile.go new file mode 100644 index 0000000..5851f72 --- /dev/null +++ b/examples/simple/magefile.go @@ -0,0 +1,70 @@ +// +build mage + +// Copyright (c) 2019-present Sven Greb +// This source code is licensed under the MIT license found in the LICENSE file. + +package main + +import ( + "context" + "fmt" + "os" + + "github.com/svengreb/nib" + "github.com/svengreb/nib/inkpen" + + "github.com/svengreb/wand/examples/simple/pkg/cmd/cli" + "github.com/svengreb/wand/pkg/elder" + wandProj "github.com/svengreb/wand/pkg/project" + wandProjVCS "github.com/svengreb/wand/pkg/project/vcs" + taskGo "github.com/svengreb/wand/pkg/task/golang" + taskGoBuild "github.com/svengreb/wand/pkg/task/golang/build" +) + +const ( + projectDisplayName = "Fruit Mixer" + projectName = "fruit-mixer" +) + +var elderWand *elder.Elder + +func init() { + // Create a new "elder wand". + ew, ewErr := elder.New( + // Provide information about the project. + elder.WithProjectOptions( + wandProj.WithName(projectName), + wandProj.WithDisplayName(projectDisplayName), + wandProj.WithVCSKind(wandProjVCS.KindNone), + wandProj.WithModulePath("github.com/svengreb/wand/examples/single_cmd"), + ), + // Use "github.com/svengreb/nib/inkpen" module as line printer for human-facing messages. + elder.WithNib(inkpen.New()), + ) + if ewErr != nil { + fmt.Printf("Failed to initialize elder wand: %v\n", ewErr) + os.Exit(1) + } + + // Register the CLI application. + if err := ew.RegisterApp(cli.Name, cli.DisplayName, "."); err != nil { + ew.ExitPrintf(1, nib.ErrorVerbosity, "Failed to register application: %v", err) + } + + elderWand = ew +} + +func Build(mageCtx context.Context) { + buildErr := elderWand.GoBuild( + cli.Name, + taskGoBuild.WithBinaryArtifactName(cli.Name), + taskGoBuild.WithOutputDir("out"), + taskGoBuild.WithGoOptions( + taskGo.WithTrimmedPath(true), + ), + ) + if buildErr != nil { + fmt.Printf("Build incomplete: %v\n", buildErr) + } + elderWand.Successf("Build completed") +} diff --git a/examples/simple/main.go b/examples/simple/main.go new file mode 100644 index 0000000..a7d1a4c --- /dev/null +++ b/examples/simple/main.go @@ -0,0 +1,9 @@ +package main + +import ( + "github.com/svengreb/wand/examples/simple/pkg/cmd/cli" +) + +func main() { + cli.Main() +} diff --git a/examples/simple/pkg/cmd/cli/cli.go b/examples/simple/pkg/cmd/cli/cli.go new file mode 100644 index 0000000..39a09d4 --- /dev/null +++ b/examples/simple/pkg/cmd/cli/cli.go @@ -0,0 +1,35 @@ +package cli + +import ( + "flag" + "fmt" + "os" + "strings" +) + +var flagVerbose = flag.Bool("v", false, "enable verbose output") + +func usage() { + fmt.Fprintf(os.Stderr, "usage: fruitctl [flags] \n") + flag.PrintDefaults() +} + +func tasty(fruits ...string) { + msg := fmt.Sprintf("Washing %d tasty fruits", len(fruits)) + if *flagVerbose { + msg = fmt.Sprintf("Tasty fruits: %s!", strings.Join(fruits, ", ")) + } + fmt.Println(msg) +} + +func Main() { + flag.Usage = usage + flag.Parse() + + if flag.NArg() == 0 { + fmt.Fprintln(os.Stderr, "error: at least one fruit is required") + os.Exit(2) + } + + tasty(flag.Args()...) +} diff --git a/examples/simple/pkg/cmd/cli/config.go b/examples/simple/pkg/cmd/cli/config.go new file mode 100644 index 0000000..574b73d --- /dev/null +++ b/examples/simple/pkg/cmd/cli/config.go @@ -0,0 +1,6 @@ +package cli + +const ( + DisplayName = "Fruit CLI" + Name = "fruitctl" +) diff --git a/internal/support/os/env.go b/internal/support/os/env.go index 416a94d..b796c3e 100644 --- a/internal/support/os/env.go +++ b/internal/support/os/env.go @@ -19,7 +19,8 @@ func EnvMapToSlice(src map[string]string) []string { } // EnvSliceToMap transforms a slice of environment variables separated by an equal sign into a map. -func EnvSliceToMap(src []string, dst map[string]string) { +func EnvSliceToMap(src []string) map[string]string { + dst := make(map[string]string, len(src)) for _, envVar := range src { kv := strings.Split(envVar, "=") if len(kv) == 1 { @@ -28,4 +29,5 @@ func EnvSliceToMap(src []string, dst map[string]string) { dst[kv[0]] = kv[1] } } + return dst } diff --git a/pkg/app/config.go b/pkg/app/config.go index 931c86c..63e9cc3 100644 --- a/pkg/app/config.go +++ b/pkg/app/config.go @@ -17,6 +17,6 @@ type Config struct { // PathRel is the relative path to an application root directory. PathRel string - // PkgPath is the name of an application package. - PkgPath string + // PkgImportPath is the import path of an application package. + PkgImportPath string } diff --git a/pkg/app/store.go b/pkg/app/store.go index f5e8597..dab7ad3 100644 --- a/pkg/app/store.go +++ b/pkg/app/store.go @@ -37,10 +37,7 @@ func (s *appStore) Get(appName string) (*Config, error) { Kind: ErrNoSuchConfig, } } - return ac, &ErrApp{ - Err: fmt.Errorf("application name %q", appName), - Kind: ErrNoSuchConfig, - } + return ac, nil } // NewStore creates a new store for application configurations. diff --git a/pkg/cast/cast.go b/pkg/cast/cast.go deleted file mode 100644 index 062b197..0000000 --- a/pkg/cast/cast.go +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) 2019-present Sven Greb -// This source code is licensed under the MIT license found in the LICENSE file. - -// Package cast provides caster for spell incantations. -package cast - -import ( - "github.com/svengreb/wand/pkg/spell" -) - -// Caster casts a spell.Incantation using a command for a specific spell.Kind. -// -// The abstract view and naming is inspired by the fantasy novel "Harry Potter" in which a caster can cast a magic spell -// through a incantation. -// -// References -// -// (1) https://en.wikipedia.org/wiki/Magic_in_Harry_Potter#Spellcasting -// (2) https://en.wikipedia.org/wiki/Incantation -type Caster interface { - // Cast casts a spell incantation. - Cast(spell.Incantation) error - - // Handles returns the spell kind that can be casted. - Handles() spell.Kind - - // Validate validates the caster command. - Validate() error -} - -// BinaryCaster is a Caster to run commands using a binary executable. -type BinaryCaster interface { - Caster - - // GetExec returns the path to the binary executable of the command. - GetExec() string -} diff --git a/pkg/cast/error.go b/pkg/cast/error.go deleted file mode 100644 index 1f65cd0..0000000 --- a/pkg/cast/error.go +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) 2019-present Sven Greb -// This source code is licensed under the MIT license found in the LICENSE file. - -package cast - -import ( - "errors" - "fmt" - - wErr "github.com/svengreb/wand/pkg/error" -) - -const ( - // ErrCasterCasting indicates that a caster failed to cast. - ErrCasterCasting = wErr.ErrString("failed to cast") - - // ErrCasterInvalidOpts indicates invalid caster options. - ErrCasterInvalidOpts = wErr.ErrString("invalid caster options") - - // ErrCasterValidation indicates that a caster validation failed. - ErrCasterValidation = wErr.ErrString("caster validation failed") - - // ErrCasterSpellIncantationKindUnsupported indicates that a spell incantation kind is not supported by a caster. - ErrCasterSpellIncantationKindUnsupported = wErr.ErrString("unsupported spell incantation kind") -) - -// ErrCast represents a cast error. -type ErrCast struct { - // Err is a wrapped error. - Err error - // Kind is the error kind. - Kind error -} - -func (e *ErrCast) Error() string { - msg := "cast error" - if e.Kind != nil { - msg = fmt.Sprintf("%s: %v", msg, e.Kind) - } - if e.Err != nil { - msg = fmt.Sprintf("%s: %v", msg, e.Err) - } - - return msg -} - -// Is enables usage of errors.Is() to determine the kind of error that occurred. -func (e *ErrCast) Is(err error) bool { - return errors.Is(err, e.Kind) -} - -// Unwrap returns the underlying error for usage with errors.Unwrap(). -func (e *ErrCast) Unwrap() error { return e.Err } diff --git a/pkg/cast/gobin/gobin.go b/pkg/cast/gobin/gobin.go deleted file mode 100644 index 41faaa9..0000000 --- a/pkg/cast/gobin/gobin.go +++ /dev/null @@ -1,251 +0,0 @@ -// Copyright (c) 2019-present Sven Greb -// This source code is licensed under the MIT license found in the LICENSE file. - -// Package gobin provides a caster to install and run Go module executables using the "github.com/myitcv/gobin" module -// command. -// -// See https://pkg.go.dev/github.com/myitcv/gobin for more details about "gobin". -// The source code of the "gobin" is available at https://github.com/myitcv/gobin. -// -// Go Executable Installation -// -// When installing a Go executable from within a Go module (1) directory using the "go install" command (2), it is -// installed into the Go executable search path that is defined through the "GOBIN" environment variable (3) and can -// also be shown and modified using the "go env" command (4). -// Even though the executable gets installed globally, the "go.mod" file (5) will be updated to include the installed -// packages since this is the default behavior of the "go get" command (6) when running in "module" mode (7). -// -// Next to this problem, the installed executable will also overwrite any executable of the same module/package that was -// installed already, but maybe from a different version. Therefore only one version of a executable can be installed at -// a time which makes it impossible to work on different projects that use the same tool but with different versions. -// -// History and Future -// -// The local installation of executables built from Go modules/"main" packages has always been a somewhat controversial -// point which unfortunately, partly for historical reasons, does not offer an optimal and user-friendly solution up to -// now. -// The "go" command (8) is a fantastic toolchain that provides many great features one would expect to be provided -// out-of-the-box from a modern and well designed programming language without the requirement to use a third-party -// solution: from compiling code, running unit/integration/benchmark tests, quality and error analysis, debugging -// utilities and many more. -// Unfortunately the way the "go install" command (2) of Go versions less or equal to 1.15 handles the installation is -// still not optimal. -// -// The general problem of tool dependencies is a long-time known issue/weak point of the current Go toolchain and is a -// highly rated change request from the Go community with discussions like https://github.com/golang/go/issues/30515, -// https://github.com/golang/go/issues/25922 and https://github.com/golang/go/issues/27653 to improve this essential -// feature, but they've been around for quite a long time without a solution that works without introducing breaking -// changes and most users and the Go team agree on. -// Luckily, this topic was finally picked up for the next upcoming Go release version 1.16 (9) and -// https://github.com/golang/go/issues/40276 introduces a way to install executables in module mode outside a module. -// The release note preview also already includes details about this change (10) and how installation of executables -// from Go modules will be handled in the future. -// -// The Workaround -// -// Beside the great news and anticipation about an official solution for the problem the usage of a workaround is almost -// inevitable until Go 1.16 is finally released. -// -// The official Go wiki (11) provides a section on "How can I track tool dependencies for a module?" (12) that describes -// a workaround that tracks tool dependencies. It allows to use the Go module logic by using a file like "tools.go" with -// a dedicated "tools" build tag that prevents the included module dependencies to be picked up included for normal -// executable builds. This approach works fine for non-main packages, but CLI tools that are only implemented in the -// "main" package can not be imported in such a file. -// -// In order to tackle this problem, a user from the community created "gobin" (13), an experimental, module-aware -// command to install/run main packages. -// It allows to install or run main-package commands without "polluting" the "go.mod" file by default. It downloads -// modules in version-aware mode into a binary cache path within the systems cache directory (14). -// It prevents problems due to already globally installed executables by placing each version in its own directory. -// The decision to use a cache directory instead of sub-directories within the "GOBIN" path keeps the system clean. -// -// "gobin" is still in an early development state, but has already received a lot of positive feedback and is used in -// many projects. There are also members of the core Go team that have contributed to the project and the chance is high -// that the changes for Go 1.16 were influenced or partially ported from it. -// It is currently the best workaround to... -// 1. prevent the Go toolchain to pick up the "GOMOD" environment variable (4) (see "go env GOMOD" (4)) that is -// initialized automatically with the path to the "go.mod" file (5) in the current working directory. -// 2. install module/package executables globally without "polluting" the "go.mod" file. -// 3. install module/package executables globally without overriding already installed executables of different -// versions. -// -// See gobin's FAQ page (15) in the repository wiki for more details about the project. -// -// The Go Module Caster -// -// To allow to manage the tool dependency problem, this caster uses "gobin" through to prevent the "pollution" of the -// project "go.mod" file and allows to... -// 1. install "gobin" itself into "GOBIN" (`go env GOBIN` (4)). -// 2. cast any spell incantation (16) of kind "KindGoModule" (17) by installing the executable globally into the -// dedicated "gobin" cache. -// -// References -// -// (1) https://golang.org/ref/mod -// (2) https://pkg.go.dev/cmd/go#hdr-Compile_and_install_packages_and_dependencies -// (3) https://pkg.go.dev/cmd/go/#hdr-Environment_variables -// (4) https://pkg.go.dev/cmd/go/#hdr-Print_Go_environment_information -// (5) https://golang.org/ref/mod#go-mod-file -// (6) https://pkg.go.dev/cmd/go/#hdr-Print_Go_environment_information -// (7) https://golang.org/ref/mod#mod-commands -// (8) https://golang.org/cmd/go -// (9) https://github.com/golang/go/milestone/145 -// (10) https://tip.golang.org/doc/go1.16#modules -// (11) https://github.com/golang/go/wiki -// (12) https://github.com/golang/go/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module -// (13) https://github.com/myitcv/gobin -// (14) https://pkg.go.dev/os/#UserCacheDir -// (16) https://github.com/myitcv/gobin/wiki/FAQ -// (16) https://pkg.go.dev/github.com/svengreb/wand/pkg/spell#Incantation -// (17) https://pkg.go.dev/github.com/svengreb/wand/pkg/spell#KindGoModule -package gobin - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - - "github.com/magefile/mage/sh" - glFS "github.com/svengreb/golib/pkg/io/fs" - - osSupport "github.com/svengreb/wand/internal/support/os" - "github.com/svengreb/wand/pkg/cast" - castGoToolchain "github.com/svengreb/wand/pkg/cast/golang/toolchain" - "github.com/svengreb/wand/pkg/project" - "github.com/svengreb/wand/pkg/spell" -) - -// Caster is a "github.com/myitcv/gobin" module caster. -type Caster struct { - opts *Options -} - -// GoModule returns partial Go module identifier information for the "github.com/myitcv/gobin" module. -func (c *Caster) GoModule() project.GoModuleID { - return *c.opts.goModule -} - -// GetExec returns the path to the installed executable of the "github.com/myitcv/gobin" module. -func (c *Caster) GetExec() string { - return c.opts.Exec -} - -// Cast casts a spell incantation. -// It returns an error of type *cast.ErrCast when the spell is not a spell.KindGoModule and any other error that occurs -// during the command execution. -func (c *Caster) Cast(si spell.Incantation) error { - if si.Kind() != spell.KindGoModule { - return &cast.ErrCast{ - Err: fmt.Errorf("%q", si.Kind()), - Kind: cast.ErrCasterSpellIncantationKindUnsupported, - } - } - - s, ok := si.(spell.GoModule) - if !ok { - return &cast.ErrCast{ - Err: fmt.Errorf("expected %q but got %q", s.Kind(), si.Kind()), - Kind: cast.ErrCasterSpellIncantationKindUnsupported, - } - } - - args := append([]string{"-run", s.GoModuleID().String()}, si.Formula()...) - for k, v := range s.Env() { - c.opts.Env[k] = v - } - - return sh.RunWithV(c.opts.Env, c.opts.Exec, args...) -} - -// Install installs the executable of the "github.com/myitcv/gobin" module. -// It does not "pollute" the "go.mod" file of the project the installation outside of the project root directory but -// using a the systems temporary directory instead. -// -// See the package documentation for details: https://pkg.go.dev/github.com/svengreb/wand/pkg/cast/gobin -func (c *Caster) Install(goCaster *castGoToolchain.Caster) error { - goToolchainExec := goCaster.GetExec() - cmd := exec.Command(goToolchainExec, "get", "-v", c.opts.goModule.String()) - cmd.Dir = os.TempDir() - cmd.Env = os.Environ() - - // Explicitly enable "module" mode to install a pinned "github.com/myitcv/gobin" module version. - c.opts.Env[castGoToolchain.DefaultEnvVarGO111MODULE] = "on" - cmd.Env = osSupport.EnvMapToSlice(c.opts.Env) - - if err := cmd.Run(); err != nil { - return &cast.ErrCast{ - Err: err, - Kind: cast.ErrCasterCasting, - } - } - return nil -} - -// Handles returns the supported spell.Kind. -func (c *Caster) Handles() spell.Kind { - return spell.KindGoModule -} - -// Validate validates the "github.com/myitcv/gobin" module caster. -// It returns an error of type *cast.ErrCast when the binary executable does not exists at the configured path and when -// it is also not available in the executable search paths of the current environment. -func (c *Caster) Validate() error { - // Check if the "gobin" executable exists at the configured path,... - execExits, fsErr := glFS.RegularFileExists(c.opts.Exec) - if fsErr != nil { - return &cast.ErrCast{ - Err: fmt.Errorf("caster %q: %w", CasterName, fsErr), - Kind: cast.ErrCasterValidation, - } - } - - // ...otherwise try to look up the system-wide executable search paths of the current environment... - if !execExits { - execPath, pathErr := exec.LookPath(c.opts.Exec) - - // ...and the local Go binary installation path. - if pathErr != nil { - var execDirGoEnv string - - if execDirGoEnv = os.Getenv(castGoToolchain.DefaultEnvVarGOBIN); execDirGoEnv == "" { - if execDirGoEnv = os.Getenv(castGoToolchain.DefaultEnvVarGOPATH); execDirGoEnv != "" { - execDirGoEnv = filepath.Join(execDirGoEnv, castGoToolchain.DefaultGOBINSubDirName) - } - } - - execPath = filepath.Join(execDirGoEnv, c.opts.Exec) - execExits, fsErr = glFS.RegularFileExists(execPath) - if fsErr != nil { - return &cast.ErrCast{ - Err: fmt.Errorf("caster %q: %w", CasterName, fsErr), - Kind: cast.ErrCasterValidation, - } - } - - if !execExits { - return &cast.ErrCast{ - Err: fmt.Errorf("caster %q: %w", CasterName, fsErr), - Kind: cast.ErrCasterValidation, - } - } - } - - c.opts.Exec = execPath - } - - return nil -} - -// NewCaster creates a new "github.com/myitcv/gobin" module caster. -func NewCaster(opts ...Option) (*Caster, error) { - opt, optErr := NewOptions(opts...) - if optErr != nil { - return nil, &cast.ErrCast{ - Err: optErr, - Kind: cast.ErrCasterInvalidOpts, - } - } - - return &Caster{opts: opt}, nil -} diff --git a/pkg/cast/gobin/options.go b/pkg/cast/gobin/options.go deleted file mode 100644 index 021264b..0000000 --- a/pkg/cast/gobin/options.go +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright (c) 2019-present Sven Greb -// This source code is licensed under the MIT license found in the LICENSE file. - -package gobin - -import ( - "fmt" - - "github.com/Masterminds/semver/v3" - - "github.com/svengreb/wand/pkg/cast" - "github.com/svengreb/wand/pkg/project" -) - -const ( - // CasterName is the name of the Go toolchain command caster. - CasterName = "gobin" - - // DefaultExec is the default name of the "github.com/myitcv/gobin" module executable. - DefaultExec = "gobin" - - // DefaultGoModulePath is the default "gobin" module import path. - DefaultGoModulePath = "github.com/myitcv/gobin" - - // DefaultGoModuleVersion is the default "gobin" module version. - DefaultGoModuleVersion = "v0.0.14" -) - -// Options stores "github.com/myitcv/gobin" module caster options. -type Options struct { - // Env are caster specific environment variables. - Env map[string]string - - // Exec ist the name or path of the "gobin" module executable. - Exec string - - goModule *project.GoModuleID -} - -// Option is a "github.com/myitcv/gobin" module caster option. -type Option func(*Options) - -// WithExec sets the name or path to the "github.com/myitcv/gobin" module executable. -// Defaults to DefaultExec. -func WithExec(nameOrPath string) Option { - return func(o *Options) { - if nameOrPath != "" { - o.Exec = nameOrPath - } - } -} - -// WithModulePath sets the "gobin" module import path. -// Defaults to DefaultGoModulePath. -func WithModulePath(path string) Option { - return func(o *Options) { - if path != "" { - o.goModule.Path = path - } - } -} - -// WithModuleVersion sets the "gobin" module version. -// Defaults to DefaultGoModuleVersion. -func WithModuleVersion(version *semver.Version) Option { - return func(o *Options) { - if version != nil { - o.goModule.Version = version - } - } -} - -// NewOptions creates new "github.com/myitcv/gobin" module caster options. -func NewOptions(opts ...Option) (*Options, error) { - version, versionErr := semver.NewVersion(DefaultGoModuleVersion) - if versionErr != nil { - return nil, &cast.ErrCast{ - Err: fmt.Errorf("parsing default module version %q: %w", DefaultGoModulePath, versionErr), - Kind: cast.ErrCasterInvalidOpts, - } - } - - opt := &Options{ - Env: make(map[string]string), - Exec: DefaultExec, - goModule: &project.GoModuleID{ - Path: DefaultGoModulePath, - Version: version, - }, - } - for _, o := range opts { - o(opt) - } - - return opt, nil -} diff --git a/pkg/cast/golang/toolchain/options.go b/pkg/cast/golang/toolchain/options.go deleted file mode 100644 index 4021b1a..0000000 --- a/pkg/cast/golang/toolchain/options.go +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) 2019-present Sven Greb -// This source code is licensed under the MIT license found in the LICENSE file. - -package toolchain - -import ( - "github.com/magefile/mage/mg" -) - -const ( - // CasterName is the name of the Go toolchain command caster. - CasterName = "golang" - - // DefaultEnvVarGO111MODULE is the default environment variable name to toggle the Go 1.11 module mode. - DefaultEnvVarGO111MODULE = "GO111MODULE" - - // DefaultEnvVarGOBIN is the default environment variable name for the Go binary executable path. - DefaultEnvVarGOBIN = "GOBIN" - - // DefaultEnvVarGOFLAGS is the default environment variable name for Go tool flags. - DefaultEnvVarGOFLAGS = "GOFLAGS" - - // DefaultEnvVarGOPATH is the default environment variable name for the Go path. - DefaultEnvVarGOPATH = "GOPATH" - - // DefaultGOBINSubDirName is the default name of the sub-directory for the Go executables within DefaultEnvVarGOBIN. - DefaultGOBINSubDirName = "bin" -) - -// DefaultExec is the default path to the Go executable. -var DefaultExec = mg.GoCmd() - -// Options stores Go toolchain command caster options. -type Options struct { - // Env are caster specific environment variables. - Env map[string]string - - // Exec is the name or path of the Go toolchain command executable. - Exec string -} - -// Option is a Go toolchain command caster option. -type Option func(*Options) - -// WithEnv sets the caster environment. -func WithEnv(env map[string]string) Option { - return func(o *Options) { - o.Env = env - } -} - -// WithExec sets the name or path to the Go executable. -// Defaults to DefaultExec. -func WithExec(nameOrPath string) Option { - return func(o *Options) { - o.Exec = nameOrPath - } -} - -// NewOptions creates new Go toolchain command caster options. -func NewOptions(opts ...Option) *Options { - opt := &Options{ - Env: make(map[string]string), - Exec: DefaultExec, - } - for _, o := range opts { - o(opt) - } - - return opt -} diff --git a/pkg/cast/golang/toolchain/toolchain.go b/pkg/cast/golang/toolchain/toolchain.go deleted file mode 100644 index 5fb3ebc..0000000 --- a/pkg/cast/golang/toolchain/toolchain.go +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) 2019-present Sven Greb -// This source code is licensed under the MIT license found in the LICENSE file. - -// Package toolchain provides a caster to interact with the Go toolchain. -// -// See https://golang.org/cmd/go for more details. -package toolchain - -import ( - "fmt" - "os/exec" - - "github.com/magefile/mage/sh" - glFS "github.com/svengreb/golib/pkg/io/fs" - - "github.com/svengreb/wand/pkg/cast" - "github.com/svengreb/wand/pkg/spell" -) - -// Caster is a Go toolchain command caster. -type Caster struct { - opts *Options -} - -// GetExec returns the path to the binary executable. -func (c *Caster) GetExec() string { - return c.opts.Exec -} - -// Cast casts a spell incantation. -// It returns an error of type *cast.ErrCast when the spell is not a spell.KindBinary and any other error that occurs -// during the command execution. -func (c *Caster) Cast(si spell.Incantation) error { - if si.Kind() != spell.KindBinary { - return &cast.ErrCast{ - Err: fmt.Errorf("%q", si.Kind()), - Kind: cast.ErrCasterSpellIncantationKindUnsupported, - } - } - - s, ok := si.(spell.Binary) - if !ok { - return &cast.ErrCast{ - Err: fmt.Errorf("expected %q but got %q", s.Kind(), si.Kind()), - Kind: cast.ErrCasterSpellIncantationKindUnsupported, - } - } - - args := si.Formula() - for k, v := range s.Env() { - c.opts.Env[k] = v - } - - return sh.RunWithV(c.opts.Env, c.opts.Exec, args...) -} - -// Handles returns the supported spell.Kind. -func (c *Caster) Handles() spell.Kind { - return spell.KindBinary -} - -// Validate validates the Go toolchain command caster. -// It returns an error of type *cast.ErrCast when the binary executable does not exists at the configured path and when -// it is also not available in the executable search paths of the current environment. -func (c *Caster) Validate() error { - // Check if the Go executable exists,... - execExits, fsErr := glFS.RegularFileExists(c.opts.Exec) - if fsErr != nil { - return &cast.ErrCast{ - Err: fmt.Errorf("caster %q: %w", CasterName, fsErr), - Kind: cast.ErrCasterValidation, - } - } - // ...otherwise try to look up the system-wide executable paths. - if !execExits { - path, pathErr := exec.LookPath(c.opts.Exec) - if pathErr != nil { - return &cast.ErrCast{ - Err: fmt.Errorf("caster %q: %q not found or does not exist: %w", CasterName, c.opts.Exec, pathErr), - Kind: cast.ErrCasterValidation, - } - } - c.opts.Exec = path - } - - return nil -} - -// NewCaster creates a new Go toolchain command caster. -func NewCaster(opts ...Option) *Caster { - return &Caster{opts: NewOptions(opts...)} -} diff --git a/pkg/elder/elder.go b/pkg/elder/elder.go index 85879af..7d4f8d9 100644 --- a/pkg/elder/elder.go +++ b/pkg/elder/elder.go @@ -1,7 +1,7 @@ // Copyright (c) 2019-present Sven Greb // This source code is licensed under the MIT license found in the LICENSE file. -// Package elder is a wand.Wand reference implementation that provides common Mage tasks and stores application +// Package elder is a wand reference implementation that provides common Mage tasks and stores application // configurations and metadata of a project. // // The naming is inspired by the "Elder Wand", an extremely powerful wand made of elder wood, from the fantasy novel @@ -17,16 +17,16 @@ import ( "github.com/svengreb/nib" "github.com/svengreb/wand/pkg/app" - "github.com/svengreb/wand/pkg/cast" - castGobin "github.com/svengreb/wand/pkg/cast/gobin" - castGoToolchain "github.com/svengreb/wand/pkg/cast/golang/toolchain" "github.com/svengreb/wand/pkg/project" - spellFSClean "github.com/svengreb/wand/pkg/spell/fs/clean" - spellGoimports "github.com/svengreb/wand/pkg/spell/goimports" - spellGoBuild "github.com/svengreb/wand/pkg/spell/golang/build" - spellGoTest "github.com/svengreb/wand/pkg/spell/golang/test" - spellGolangCILint "github.com/svengreb/wand/pkg/spell/golangcilint" - spellGox "github.com/svengreb/wand/pkg/spell/gox" + "github.com/svengreb/wand/pkg/task" + taskFSClean "github.com/svengreb/wand/pkg/task/fs/clean" + taskGobin "github.com/svengreb/wand/pkg/task/gobin" + taskGoimports "github.com/svengreb/wand/pkg/task/goimports" + taskGo "github.com/svengreb/wand/pkg/task/golang" + taskGoBuild "github.com/svengreb/wand/pkg/task/golang/build" + taskGoTest "github.com/svengreb/wand/pkg/task/golang/test" + taskGolangCILint "github.com/svengreb/wand/pkg/task/golangcilint" + taskGox "github.com/svengreb/wand/pkg/task/gox" ) // Elder is a wand.Wand reference implementation that provides common Mage tasks and stores configurations and metadata @@ -35,49 +35,45 @@ type Elder struct { nib.Nib as app.Store - gobinCaster *castGobin.Caster - goCaster *castGoToolchain.Caster + gobinRunner *taskGobin.Runner + goRunner *taskGo.Runner opts *Options project *project.Metadata } // Bootstrap runs initialization tasks to ensure the wand is operational. -// This includes the installation of configured caster like cast.BinaryCaster that can handle spell incantations of -// kind spell.KindGoModule. -// When any error occurs it will be of type *cast.ErrCast. +// If an error occurs it will be of type *task.ErrRunner. func (e *Elder) Bootstrap() error { - if valErr := e.gobinCaster.Validate(); valErr != nil { - e.Infof("Installing %q", e.gobinCaster.GoModule()) - if installErr := e.gobinCaster.Install(e.goCaster); installErr != nil { - e.Errorf("Failed to install %q: %v", e.gobinCaster.GoModule(), installErr) - return fmt.Errorf("failed to install %q: %w", e.gobinCaster.GoModule(), installErr) + if valErr := e.gobinRunner.Validate(); valErr != nil { + e.Infof("Installing %q", e.gobinRunner.GoMod()) + if installErr := e.gobinRunner.Install(e.goRunner); installErr != nil { + e.Errorf("Failed to install %q: %v", e.gobinRunner.GoMod(), installErr) + return fmt.Errorf("failed to install %q: %w", e.gobinRunner.GoMod(), installErr) } } return nil } -// Clean is a spell.GoCode to remove configured filesystem paths, e.g. output data like artifacts and reports from -// previous development, test, production and distribution builds. -// It returns paths that have been cleaned along with an error of type *spell.ErrGoCode when an error occurred during -// the execution of the Go code. -// When any error occurs it will be of type *app.ErrApp or *cast.ErrCast. +// Clean is a task to remove filesystem paths, e.g. output data like artifacts and reports from previous development, +// test, production and distribution builds. +// It returns paths that have been cleaned along with an error when the task execution fails. // -// See the "github.com/svengreb/wand/pkg/spell/fs/clean" package for all available options. -func (e *Elder) Clean(appName string, opts ...spellFSClean.Option) ([]string, error) { +// See the "github.com/svengreb/wand/pkg/task/fs/clean" package for all available options. +func (e *Elder) Clean(appName string, opts ...taskFSClean.Option) ([]string, error) { ac, acErr := e.GetAppConfig(appName) if acErr != nil { return []string{}, fmt.Errorf("failed to get %q application configuration: %w", appName, acErr) } - si, siErr := spellFSClean.New(e.GetProjectMetadata(), ac, opts...) - if siErr != nil { - return []string{}, fmt.Errorf("failed to create %q spell incantation: %w", "golangci-lint", siErr) + t, tErr := taskFSClean.New(e.GetProjectMetadata(), ac, opts...) + if tErr != nil { + return []string{}, fmt.Errorf("failed to create %q task: %w", "fs/clean", tErr) } - return si.Clean() + return t.Clean() } -// ExitPrintf simplifies the logging for process exits with a suitable nib.Verbosity. +// ExitPrintf simplifies the logging for process exits with a suitable verbosity. // // References // @@ -107,13 +103,14 @@ func (e *Elder) ExitPrintf(code int, verb nib.Verbosity, format string, args ... } // GetAppConfig returns an application configuration. -// An empty application configuration is returned along with an error of type *app.ErrApp when there is no -// configuration in the store for the given name. +// An empty application configuration is returned along with an error of type *app.ErrApp when there is no configuration +// in the store for the given name. func (e *Elder) GetAppConfig(name string) (app.Config, error) { ac, acErr := e.as.Get(name) if acErr != nil { return app.Config{}, fmt.Errorf("failed to get %q application configuration: %w", name, acErr) } + return *ac, nil } @@ -122,98 +119,113 @@ func (e *Elder) GetProjectMetadata() project.Metadata { return *e.project } -// GoBuild casts the spell incantation for the "build" command of the Go toolchain. -// When any error occurs it will be of type *app.ErrApp or *cast.ErrCast. +// GoBuild is a task for the Go toolchain "build" command. +// When any error occurs it will be of type *app.ErrApp or *task.ErrRunner. // -// See the "github.com/svengreb/wand/pkg/spell/golang/build" package for all available options. -func (e *Elder) GoBuild(appName string, opts ...spellGoBuild.Option) error { +// See the "github.com/svengreb/wand/pkg/task/golang/build" package for all available options. +func (e *Elder) GoBuild(appName string, opts ...taskGoBuild.Option) error { ac, acErr := e.GetAppConfig(appName) if acErr != nil { return fmt.Errorf("failed to get %q application configuration: %w", appName, acErr) } - si := spellGoBuild.New(e, ac, opts...) - return e.goCaster.Cast(si) + t := taskGoBuild.New(e, ac, opts...) + return e.goRunner.Run(t) } -// Goimports casts the spell incantation for the "golang.org/x/tools/cmd/goimports" Go module command that allows to -// update Go import lines, add missing ones and remove unreferenced ones. It also formats code in the same style as -// "https://pkg.go.dev/cmd/gofmt" so it can be used as a replacement. -// When any error occurs it will be of type *app.ErrApp or *cast.ErrCast. +// Goimports is a task for the "golang.org/x/tools/cmd/goimports" Go module command. +// "goimports" allows to update Go import lines, add missing ones and remove unreferenced ones. It also formats code in +// the same style as "https://pkg.go.dev/cmd/gofmt" so it can be used as a replacement. // -// See the "github.com/svengreb/wand/pkg/spell/goimports" package for all available options. +// See the "github.com/svengreb/wand/pkg/task/goimports" package for all available options. // // See https://pkg.go.dev/golang.org/x/tools/cmd/goimports for more details about "goimports". // The source code of "goimports" is available at https://github.com/golang/tools/tree/master/cmd/goimports. -func (e *Elder) Goimports(appName string, opts ...spellGoimports.Option) error { +func (e *Elder) Goimports(appName string, opts ...taskGoimports.Option) error { ac, acErr := e.GetAppConfig(appName) if acErr != nil { return fmt.Errorf("failed to get %q application configuration: %w", appName, acErr) } - si, siErr := spellGoimports.New(e, ac, opts...) - if siErr != nil { - return fmt.Errorf("failed to create %q spell incantation: %w", "goimports", siErr) + t, tErr := taskGoimports.New(e, ac, opts...) + if tErr != nil { + return fmt.Errorf("failed to create %q task: %w", "goimports", tErr) } - return e.gobinCaster.Cast(si) + + return e.gobinRunner.Run(t) } -// GolangCILint casts the spell incantation for the "github.com/golangci/golangci-lint/cmd/golangci-lint" Go module -// command, a fast, parallel runner for dozens of Go linters Go that uses caching, supports YAML configurations and has -// integrations with all major IDEs. -// When any error occurs it will be of type *app.ErrApp or *cast.ErrCast. +// GolangCILint is a task to run the "github.com/golangci/golangci-lint/cmd/golangci-lint" Go module +// command. +// "golangci-lint" is a fast, parallel runner for dozens of Go linters Go that uses caching, supports YAML +// configurations and has integrations with all major IDEs. +// When any error occurs it will be of type *app.ErrApp or *task.ErrRunner. // -// See the "github.com/svengreb/wand/pkg/spell/golangcilint" package for all available options. +// See the "github.com/svengreb/wand/pkg/task/golangcilint" package for all available options. // // See https://pkg.go.dev/github.com/golangci/golangci-lint and the official website at https://golangci-lint.run for // more details about "golangci-lint". // The source code of "golangci-lint" is available at https://github.com/golangci/golangci-lint. -func (e *Elder) GolangCILint(appName string, opts ...spellGolangCILint.Option) error { +func (e *Elder) GolangCILint(appName string, opts ...taskGolangCILint.Option) error { ac, acErr := e.GetAppConfig(appName) if acErr != nil { return fmt.Errorf("failed to get %q application configuration: %w", appName, acErr) } - si, siErr := spellGolangCILint.New(e, ac, opts...) - if siErr != nil { - return fmt.Errorf("failed to create %q spell incantation: %w", "golangci-lint", siErr) + t, tErr := taskGolangCILint.New(e, ac, opts...) + if tErr != nil { + return fmt.Errorf("failed to create %q task: %w", "golangci-lint", tErr) } - return e.gobinCaster.Cast(si) + + return e.gobinRunner.Run(t) } -// GoTest casts the spell incantation for the "test" command of the Go toolchain. -// When any error occurs it will be of type *app.ErrApp or *cast.ErrCast. +// GoTest is a task to run the Go toolchain "test" command. +// The configured output directory for reports like coverage or benchmark profiles will be created recursively when it +// does not exist yet. +// When any error occurs it will be of type *app.ErrApp, *task.ErrRunner or os.PathError. // -// See the "github.com/svengreb/wand/pkg/spell/golang/test" package for all available options. -func (e *Elder) GoTest(appName string, opts ...spellGoTest.Option) error { +// See the "github.com/svengreb/wand/pkg/task/param/golang/test" package for all available options. +func (e *Elder) GoTest(appName string, opts ...taskGoTest.Option) error { ac, acErr := e.GetAppConfig(appName) if acErr != nil { return fmt.Errorf("failed to get %q application configuration: %w", appName, acErr) } - return e.goCaster.Cast(spellGoTest.New(e, ac, opts...)) + t := taskGoTest.New(e, ac, opts...) + tOpts, ok := t.Options().(taskGoTest.Options) + if !ok { + return fmt.Errorf(`failed to convert task options to "%T"`, taskGoTest.Options{}) + } + + if err := os.MkdirAll(tOpts.OutputDir, os.ModePerm); err != nil { + return fmt.Errorf("failed to create output directory %q: %w", tOpts.OutputDir, err) + } + + return e.goRunner.Run(t) } -// Gox casts the spell incantation for the "github.com/mitchellh/gox" Go module command, a dead simple, no frills Go -// cross compile tool that behaves a lot like the standard Go toolchain "build" command. -// When any error occurs it will be of type *app.ErrApp or *cast.ErrCast. +// Gox is a task to run the "github.com/mitchellh/gox" Go module command. +// "gox" is a dead simple, no frills Go cross compile tool that behaves a lot like the standard Go toolchain "build" +// command. +// When any error occurs it will be of type *app.ErrApp or *task.ErrRunner. // -// See the "github.com/svengreb/wand/pkg/spell/gox" package for all available options. +// See the "github.com/svengreb/wand/pkg/task/gox" package for all available options. // // See https://pkg.go.dev/github.com/mitchellh/gox for more details about "gox". // The source code of the "gox" is available at https://github.com/mitchellh/gox. -func (e *Elder) Gox(appName string, opts ...spellGox.Option) error { +func (e *Elder) Gox(appName string, opts ...taskGox.Option) error { ac, acErr := e.GetAppConfig(appName) if acErr != nil { return fmt.Errorf("failed to get %q application configuration: %w", appName, acErr) } - si, siErr := spellGox.New(e, ac, opts...) - if siErr != nil { - return fmt.Errorf("failed to create %q spell incantation: %w", "gox", siErr) + t, tErr := taskGox.New(e, ac, opts...) + if tErr != nil { + return fmt.Errorf("failed to create %q task: %w", "gox", tErr) } - return e.gobinCaster.Cast(si) + return e.gobinRunner.Run(t) } // RegisterApp creates and stores a new application configuration. @@ -259,19 +271,19 @@ func (e *Elder) RegisterApp(name, displayName, pathRel string) error { DisplayName: displayName, Name: name, PathRel: pathRel, - PkgPath: fmt.Sprintf("%s/%s", e.project.Options().GoModule.Path, pathRel), + PkgImportPath: filepath.Clean(fmt.Sprintf("%s/%s", e.project.Options().GoModule.Path, pathRel)), } e.as.Add(ac) return nil } -// Validate ensures that all casters are properly initialized and available. -// It returns an error of type *cast.ErrCast when the validation of any of the supported casters fails. +// Validate ensures that all tasks are properly initialized and operational. +// It returns an error of type *task.ErrRunner when the validation of any of the supported task fails. func (e *Elder) Validate() error { - for _, caster := range []cast.Caster{e.goCaster, e.gobinCaster} { - if err := caster.Validate(); err != nil { - return fmt.Errorf("failed to validate caster: %w", err) + for _, t := range []task.Runner{e.goRunner, e.gobinRunner} { + if err := t.Validate(); err != nil { + return fmt.Errorf("failed to validate runner: %w", err) } } @@ -287,14 +299,14 @@ func (e *Elder) Validate() error { // - "-d " option to set the directory from which "magefiles" are read (defaults to "."). // - "-w " option to set the working directory where "magefiles" will run (defaults to value of "-d" flag). // -// If any error occurs it will be of type *cast.ErrCast or *project.ErrProject. +// If any error occurs it will be of type *cmd.ErrCmd or *project.ErrProject. // // References // -// - https://magefile.org/#usage -// - https://golang.org/pkg/os/#Getwd -// - https://golang.org/pkg/runtime/debug/#ReadBuildInfo -// - https://pkg.go.dev/runtime/debug +// (1) https://magefile.org/#usage +// (2) https://golang.org/pkg/os/#Getwd +// (3) https://golang.org/pkg/runtime/debug/#ReadBuildInfo +// (4) https://pkg.go.dev/runtime/debug func New(opts ...Option) (*Elder, error) { opt := NewOptions(opts...) @@ -310,15 +322,15 @@ func New(opts ...Option) (*Elder, error) { } e.project = proj - e.goCaster = castGoToolchain.NewCaster(e.opts.goToolchainCasterOpts...) + e.goRunner = taskGo.NewRunner(e.opts.goRunnerOpts...) - gobinCaster, gobinCasterErr := castGobin.NewCaster(e.opts.gobinCasterOpts...) - if gobinCasterErr != nil { - return nil, fmt.Errorf("failed to create %q caster: %w", "gobin", gobinCasterErr) + gobinRunner, gobinRunnerErr := taskGobin.NewRunner(e.opts.gobinRunnerOpts...) + if gobinRunnerErr != nil { + return nil, fmt.Errorf("failed to create %q runner: %w", "gobin", gobinRunnerErr) } - e.gobinCaster = gobinCaster + e.gobinRunner = gobinRunner - if err := e.RegisterApp(e.project.Options().Name, e.project.Options().DisplayName, "."); err != nil { + if err := e.RegisterApp(e.project.Options().Name, e.project.Options().DisplayName, ""); err != nil { e.ExitPrintf(1, nib.ErrorVerbosity, "registering application %q: %v", e.project.Options().Name, err) } diff --git a/pkg/elder/options.go b/pkg/elder/options.go index aedaf12..2d6a64c 100644 --- a/pkg/elder/options.go +++ b/pkg/elder/options.go @@ -7,40 +7,50 @@ import ( "github.com/svengreb/nib" "github.com/svengreb/nib/inkpen" - castGobin "github.com/svengreb/wand/pkg/cast/gobin" - castGoToolchain "github.com/svengreb/wand/pkg/cast/golang/toolchain" "github.com/svengreb/wand/pkg/project" + taskGobin "github.com/svengreb/wand/pkg/task/gobin" + taskGo "github.com/svengreb/wand/pkg/task/golang" ) -// Options are options for the wand.Wand reference implementation "elder". +// Option is a wand option. +type Option func(*Options) + +// Options are wand options. type Options struct { - // gobinCasterOpts are "gobin" caster options. - gobinCasterOpts []castGobin.Option + // gobinRunnerOpts are "gobin" runner options. + gobinRunnerOpts []taskGobin.RunnerOption - // goToolchainCasterOpts are Go toolchain caster options. - goToolchainCasterOpts []castGoToolchain.Option + // goRunnerOpts are Go toolchain runner options. + goRunnerOpts []taskGo.RunnerOption // nib is the log-level based line printer for human-facing messages. nib nib.Nib - // goToolchainCasterOpts are project options. + // projectOpts are project options. projectOpts []project.Option } -// Option is a option for the wand.Wand reference implementation "elder". -type Option func(*Options) +// NewOptions creates new wand options. +func NewOptions(opts ...Option) *Options { + opt := &Options{nib: inkpen.New()} + for _, o := range opts { + o(opt) + } + + return opt +} -// WithGobinCasterOptions sets "gobin" caster options. -func WithGobinCasterOptions(opts ...castGobin.Option) Option { +// WithGobinRunnerOptions sets "gobin" runner options. +func WithGobinRunnerOptions(opts ...taskGobin.RunnerOption) Option { return func(o *Options) { - o.gobinCasterOpts = append(o.gobinCasterOpts, opts...) + o.gobinRunnerOpts = append(o.gobinRunnerOpts, opts...) } } -// WithGoToolchainCasterOptions sets Go toolchain caster options. -func WithGoToolchainCasterOptions(opts ...castGoToolchain.Option) Option { +// WithGoRunnerOptions sets Go toolchain runner options. +func WithGoRunnerOptions(opts ...taskGo.RunnerOption) Option { return func(o *Options) { - o.goToolchainCasterOpts = append(o.goToolchainCasterOpts, opts...) + o.goRunnerOpts = append(o.goRunnerOpts, opts...) } } @@ -59,13 +69,3 @@ func WithProjectOptions(opts ...project.Option) Option { o.projectOpts = append(o.projectOpts, opts...) } } - -// NewOptions creates new options for the wand.Wand reference implementation "elder". -func NewOptions(opts ...Option) *Options { - opt := &Options{nib: inkpen.New()} - for _, o := range opts { - o(opt) - } - - return opt -} diff --git a/pkg/project/options.go b/pkg/project/options.go index 0dbeada..8cd1c43 100644 --- a/pkg/project/options.go +++ b/pkg/project/options.go @@ -61,7 +61,7 @@ func WithBaseOutputDir(dir string) Option { } } -// WithDefaultVersion set the project default verion. +// WithDefaultVersion set the project default version. func WithDefaultVersion(defaultVersion string) Option { return func(o *Options) { o.DefaultVersion = defaultVersion @@ -75,10 +75,21 @@ func WithDisplayName(name string) Option { } } -// WithGoModule sets the project Go module. -func WithGoModule(module *GoModuleID) Option { +// WithModulePath sets the module import path. +func WithModulePath(path string) Option { return func(o *Options) { - o.GoModule = module + if path != "" { + o.GoModule.Path = path + } + } +} + +// WithModuleVersion sets the module version. +func WithModuleVersion(version *semver.Version) Option { + return func(o *Options) { + if version != nil { + o.GoModule.Version = version + } } } diff --git a/pkg/spell/error.go b/pkg/spell/error.go deleted file mode 100644 index a699a4a..0000000 --- a/pkg/spell/error.go +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) 2019-present Sven Greb -// This source code is licensed under the MIT license found in the LICENSE file. - -package spell - -import ( - "errors" - "fmt" - - wErr "github.com/svengreb/wand/pkg/error" -) - -const ( - // ErrExec indicates that a GoCode spell incantation returned an error during the code execution. - ErrExec = wErr.ErrString("error returned during execution") -) - -// ErrGoCode represents a GoCode error. -type ErrGoCode struct { - // Err is a wrapped error. - Err error - // Kind is the error kind. - Kind error -} - -func (e *ErrGoCode) Error() string { - msg := "Go code" - if e.Kind != nil { - msg = fmt.Sprintf("%s: %v", msg, e.Kind) - } - if e.Err != nil { - msg = fmt.Sprintf("%s: %v", msg, e.Err) - } - - return msg -} - -// Is enables usage of errors.Is() to determine the kind of error that occurred. -func (e *ErrGoCode) Is(err error) bool { - return errors.Is(err, e.Kind) -} - -// Unwrap returns the underlying error for usage with errors.Unwrap(). -func (e *ErrGoCode) Unwrap() error { return e.Err } diff --git a/pkg/spell/fs/clean/clean.go b/pkg/spell/fs/clean/clean.go deleted file mode 100644 index 777ffd5..0000000 --- a/pkg/spell/fs/clean/clean.go +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (c) 2019-present Sven Greb -// This source code is licensed under the MIT license found in the LICENSE file. - -// Package clean provides a spell incantation to remove directories in a filesystem. -// It implements spell.GoCode and can be used without a cast.Caster. -package clean - -import ( - "fmt" - "os" - "path/filepath" - - glFS "github.com/svengreb/golib/pkg/io/fs" - glFilePath "github.com/svengreb/golib/pkg/io/fs/filepath" - - "github.com/svengreb/wand/pkg/app" - "github.com/svengreb/wand/pkg/project" - "github.com/svengreb/wand/pkg/spell" -) - -// Spell is a spell incantation to remove directories in a filesystem. -// It is of kind spell.KindGoCode and can be used without a cast.Caster. -type Spell struct { - ac app.Config - proj project.Metadata - opts *Options -} - -// Formula returns the spell incantation formula. -// Note that Spell implements spell.GoCode so this method always returns an empty slice! -func (s *Spell) Formula() []string { - return []string{} -} - -// Kind returns the spell incantation kind. -func (s *Spell) Kind() spell.Kind { - return spell.KindGoCode -} - -// Options returns the spell incantation options. -func (s *Spell) Options() interface{} { - return *s.opts -} - -// Clean removes the configured paths. -// It returns an error of type *spell.ErrGoCode for any error that occurs during the execution of the Go code. -func (s *Spell) Clean() ([]string, error) { - var cleaned []string - - for _, p := range s.opts.paths { - pAbs := filepath.Join(s.proj.Options().RootDirPathAbs, p) - - if s.opts.limitToAppOutputDir { - appDir := filepath.Join(s.proj.Options().RootDirPathAbs, s.ac.BaseOutputDir) - pAbs = filepath.Join(s.proj.Options().RootDirPathAbs, p) - - isSubDir, subDirErr := glFilePath.IsSubDir(appDir, pAbs, false) - if subDirErr != nil { - return cleaned, &spell.ErrGoCode{ - Err: fmt.Errorf("check if %q is a subdirectory of %q: %w", pAbs, appDir, subDirErr), - Kind: spell.ErrExec, - } - } - if !isSubDir { - return cleaned, &spell.ErrGoCode{ - Err: fmt.Errorf("%q is not a subdirectory of %q", pAbs, appDir), - Kind: spell.ErrExec, - } - } - } - - nodeExists, fsErr := glFS.FileExists(pAbs) - if fsErr != nil { - return cleaned, &spell.ErrGoCode{ - Err: fmt.Errorf("check if %q exists: %w", pAbs, fsErr), - Kind: spell.ErrExec, - } - } - if nodeExists { - if err := os.RemoveAll(pAbs); err != nil { - return cleaned, &spell.ErrGoCode{ - Err: fmt.Errorf("remove path %q: %w", pAbs, err), - Kind: spell.ErrExec, - } - } - cleaned = append(cleaned, p) - } - } - - return cleaned, nil -} - -// New creates a new spell incantation to remove the configured filesystem paths, e.g. output data like artifacts and -// reports from previous development, test, production and distribution builds. -//nolint:gocritic // The app.Config struct is passed as value by design to ensure immutability. -func New(proj project.Metadata, ac app.Config, opts ...Option) (*Spell, error) { - opt, optErr := NewOptions(opts...) - if optErr != nil { - return nil, optErr - } - - return &Spell{ac: ac, proj: proj, opts: opt}, nil -} diff --git a/pkg/spell/goimports/goimports.go b/pkg/spell/goimports/goimports.go deleted file mode 100644 index e8e12d4..0000000 --- a/pkg/spell/goimports/goimports.go +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c) 2019-present Sven Greb -// This source code is licensed under the MIT license found in the LICENSE file. - -// Package goimports provides a spell incantation for the "golang.org/x/tools/cmd/goimports" Go module command that -// allows to update Go import lines, add missing ones and remove unreferenced ones. It also formats code in the same -// style as "https://pkg.go.dev/cmd/gofmt" so it can be used as a replacement. -// -// See https://pkg.go.dev/golang.org/x/tools/cmd/goimports for more details about "goimports". -// The source code of "goimports" is available at https://github.com/golang/tools/tree/master/cmd/goimports. -package goimports - -import ( - "fmt" - "strings" - - "github.com/svengreb/wand" - "github.com/svengreb/wand/pkg/app" - "github.com/svengreb/wand/pkg/project" - "github.com/svengreb/wand/pkg/spell" -) - -// Spell is a spell incantation for the "golang.org/x/tools/cmd/goimports" Go module command. -type Spell struct { - ac app.Config - opts *Options -} - -// Formula returns the spell incantation formula. -func (s *Spell) Formula() []string { - var args []string - - // List files whose formatting are non-compliant to the style guide. - if s.opts.listNonCompliantFiles { - args = append(args, "-l") - } - - // A comma-separated list of prefixes for local package imports to be put after 3rd-party packages. - if len(s.opts.localPkgs) > 0 { - args = append(args, "-local", fmt.Sprintf("'%s'", strings.Join(s.opts.localPkgs, ","))) - } - - // Report all errors and not just the first 10 on different lines. - if s.opts.reportAllErrors { - args = append(args, "-e") - } - - // Write result to source files instead of stdout. - if s.opts.persistChanges { - args = append(args, "-w") - } - - // Enable verbose output. - if s.opts.verbose { - args = append(args, "-v") - } - - // Include additionally configured arguments. - args = append(args, s.opts.extraArgs...) - - // Only search in specified paths for Go source files... - if len(s.opts.paths) > 0 { - args = append(args, s.opts.paths...) - } else { - // ...or otherwise search recursively starting from the current working directory. - args = append(args, ".") - } - - return args -} - -// Kind returns the spell incantation kind. -func (s *Spell) Kind() spell.Kind { - return spell.KindGoModule -} - -// Options returns the spell incantation options. -func (s *Spell) Options() interface{} { - return *s.opts -} - -// GoModuleID returns partial Go module identifier information. -func (s *Spell) GoModuleID() *project.GoModuleID { - return s.opts.goModule -} - -// Env returns spell incantation specific environment variables. -func (s *Spell) Env() map[string]string { - return s.opts.env -} - -// New creates a new spell incantation for the "golang.org/x/tools/cmd/goimports" Go module command that allows to -// update Go import lines, add missing ones and remove unreferenced ones. It also formats code in the same style as -// "https://pkg.go.dev/cmd/gofmt" so it can be used as a replacement. -//nolint:gocritic // The app.Config struct is passed as value by design to ensure immutability. -func New(wand wand.Wand, ac app.Config, opts ...Option) (*Spell, error) { - opt, optErr := NewOptions(opts...) - if optErr != nil { - return nil, optErr - } - return &Spell{ac: ac, opts: opt}, nil -} diff --git a/pkg/spell/golang/build/build.go b/pkg/spell/golang/build/build.go deleted file mode 100644 index 972fb15..0000000 --- a/pkg/spell/golang/build/build.go +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (c) 2019-present Sven Greb -// This source code is licensed under the MIT license found in the LICENSE file. - -// Package build provides a spell incantation for the "build" command of the Go toolchain. -package build - -import ( - "path/filepath" - - "github.com/svengreb/wand/pkg/app" - - "github.com/svengreb/wand" - "github.com/svengreb/wand/pkg/spell" - spellGo "github.com/svengreb/wand/pkg/spell/golang" -) - -// Spell is a spell incantation for the "build" command of the Go toolchain. -type Spell struct { - ac app.Config - opts *Options -} - -// Formula returns the spell incantation formula. -// Note that configured flags are applied after the "GOFLAGS" environment variable and could overwrite already defined -// flags. -// -// See `go help environment`, `go help env` and the `go` command documentations for more details: -// - https://golang.org/cmd/go/#hdr-Environment_variables -func (s *Spell) Formula() []string { - args := []string{"build"} - - args = append(args, spellGo.CompileFormula(s.opts.spellGoOpts...)...) - - if len(s.opts.Flags) > 0 { - args = append(args, s.opts.Flags...) - } - - args = append( - args, - "-o", - filepath.Join(s.opts.OutputDir, s.opts.BinaryArtifactName), - s.ac.PkgPath, - ) - - return args -} - -// Kind returns the spell incantation kind. -func (s *Spell) Kind() spell.Kind { - return spell.KindBinary -} - -// Options returns the spell incantation options. -func (s *Spell) Options() interface{} { - return *s.opts -} - -// Env returns spell incantation specific environment variables. -func (s *Spell) Env() map[string]string { - return s.opts.Env -} - -// New creates a new spell incantation for the "build" command of the Go toolchain. -//nolint:gocritic // The app.Config struct is passed as value by design to ensure immutability. -func New(wand wand.Wand, ac app.Config, opts ...Option) *Spell { - opt := NewOptions(opts...) - - if opt.BinaryArtifactName == "" { - opt.BinaryArtifactName = ac.Name - } - - // Store build artifacts in the application specific subdirectory. - if opt.OutputDir == "" { - opt.OutputDir = ac.BaseOutputDir - } - - return &Spell{ac: ac, opts: opt} -} diff --git a/pkg/spell/golang/golang.go b/pkg/spell/golang/golang.go deleted file mode 100644 index 94aab3d..0000000 --- a/pkg/spell/golang/golang.go +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) 2019-present Sven Greb -// This source code is licensed under the MIT license found in the LICENSE file. - -// Package golang provides spell incantations for Go toolchain commands. -package golang - -import ( - "fmt" - "strings" -) - -// CompileFormula compiles the formula for shared Go toolchain command options. -func CompileFormula(opts ...Option) []string { - opt := NewOptions(opts...) - var args []string - - if len(opt.Tags) > 0 { - args = append(args, fmt.Sprintf("-tags='%s'", strings.Join(opt.Tags, " "))) - } - - if opt.EnableRaceDetector { - args = append(args, "-race") - } - - if opt.EnableTrimPath { - args = append(args, "-trimpath") - } - - if len(opt.AsmFlags) > 0 { - flag := "-asmflags" - if opt.FlagsPrefixAll { - flag = fmt.Sprintf("%s=all", flag) - } - args = append(args, fmt.Sprintf("%s=%s", flag, strings.Join(opt.AsmFlags, " "))) - } - - if len(opt.GcFlags) > 0 { - flag := "-gcflags" - if opt.FlagsPrefixAll { - flag = fmt.Sprintf("%s=all", flag) - } - args = append(args, fmt.Sprintf("%s=%s", flag, strings.Join(opt.GcFlags, " "))) - } - - if len(opt.LdFlags) > 0 { - flag := "-ldflags" - if opt.FlagsPrefixAll { - flag = fmt.Sprintf("%s=all", flag) - } - args = append(args, fmt.Sprintf("%s=%s", flag, strings.Join(opt.LdFlags, " "))) - } - - if len(opt.Flags) > 0 { - args = append(args, opt.Flags...) - } - - return args -} diff --git a/pkg/spell/golang/test/test.go b/pkg/spell/golang/test/test.go deleted file mode 100644 index 054a50b..0000000 --- a/pkg/spell/golang/test/test.go +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright (c) 2019-present Sven Greb -// This source code is licensed under the MIT license found in the LICENSE file. - -// Package test provide a spell incantation for the "test" command of the Go toolchain. -package test - -import ( - "fmt" - "path/filepath" - - "github.com/svengreb/wand" - "github.com/svengreb/wand/pkg/app" - "github.com/svengreb/wand/pkg/spell" - spellGo "github.com/svengreb/wand/pkg/spell/golang" -) - -// Spell is a spell incantation for the "test" command of the Go toolchain. -type Spell struct { - ac app.Config - opts *Options -} - -// Formula returns the spell incantation formula. -// Note that configured flags are applied after the "GOFLAGS" environment variable and could overwrite already defined -// flags. In addition, the output directory for test artifacts like profiles and reports must exist or must be be -// created before, otherwise the "test" Go toolchain command will fail to run. -// -// See `go help environment`, `go help env` and the `go` command documentations for more details: -// - https://golang.org/cmd/go/#hdr-Environment_variables -func (s *Spell) Formula() []string { - args := []string{"test"} - - args = append(args, spellGo.CompileFormula(s.opts.spellGoOpts...)...) - - if s.opts.EnableVerboseOutput { - args = append(args, "-v") - } - - if s.opts.DisableCache { - args = append(args, "-count=1") - } - - if s.opts.EnableBlockProfile { - args = append(args, - fmt.Sprintf( - "-blockprofile=%s", - filepath.Join(s.opts.OutputDir, s.opts.BlockProfileOutputFileName), - ), - ) - } - - if s.opts.EnableCoverageProfile { - args = append(args, - fmt.Sprintf( - "-coverprofile=%s", - filepath.Join(s.opts.OutputDir, s.opts.CoverageProfileOutputFileName), - ), - ) - } - - if s.opts.EnableCPUProfile { - args = append(args, - fmt.Sprintf("-cpuprofile=%s", - filepath.Join(s.opts.OutputDir, s.opts.CPUProfileOutputFileName), - ), - ) - } - - if s.opts.EnableMemProfile { - args = append(args, - fmt.Sprintf("-memprofile=%s", - filepath.Join(s.opts.OutputDir, s.opts.MemoryProfileOutputFileName), - ), - ) - } - - if s.opts.EnableMutexProfile { - args = append(args, - fmt.Sprintf("-mutexprofile=%s", - filepath.Join(s.opts.OutputDir, s.opts.MutexProfileOutputFileName), - ), - ) - } - - if s.opts.EnableTraceProfile { - args = append(args, - fmt.Sprintf("-trace=%s", - filepath.Join(s.opts.OutputDir, s.opts.TraceProfileOutputFileName), - ), - ) - } - - if len(s.opts.Flags) > 0 { - args = append(args, s.opts.Flags...) - } - - args = append(args, s.opts.Pkgs...) - - return args -} - -// Kind returns the spell incantation kind. -func (s *Spell) Kind() spell.Kind { - return spell.KindBinary -} - -// Options returns the spell incantation options. -func (s *Spell) Options() interface{} { - return *s.opts -} - -// Env returns spell incantation specific environment variables. -func (s *Spell) Env() map[string]string { - return s.opts.Env -} - -// New creates a new spell incantation for the "test" command of the Go toolchain. -//nolint:gocritic // The app.Config struct is passed as value by design to ensure immutability. -func New(wand wand.Wand, ac app.Config, opts ...Option) *Spell { - opt := NewOptions(opts...) - - // Store test profiles and reports within the application specific subdirectory. - if opt.OutputDir == "" { - opt.OutputDir = filepath.Join(ac.BaseOutputDir, DefaultOutputDirName) - } - - return &Spell{ac: ac, opts: opt} -} diff --git a/pkg/spell/golangcilint/golangcilint.go b/pkg/spell/golangcilint/golangcilint.go deleted file mode 100644 index 16c88d1..0000000 --- a/pkg/spell/golangcilint/golangcilint.go +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) 2019-present Sven Greb -// This source code is licensed under the MIT license found in the LICENSE file. - -// Package golangcilint provides a spell incantation for the "github.com/golangci/golangci-lint/cmd/golangci-lint" Go -// module command, a fast, parallel runner for dozens of Go linters that uses caching, supports YAML configurations -// and has integrations with all major IDEs. -// -// See https://pkg.go.dev/github.com/golangci/golangci-lint for more details about "golangci-lint". -// The source code of "golangci-lint" is available at -// https://github.com/golangci/golangci-lint/tree/master/cmd/golangci-lint. -package golangcilint - -import ( - "github.com/svengreb/wand" - "github.com/svengreb/wand/pkg/app" - "github.com/svengreb/wand/pkg/project" - "github.com/svengreb/wand/pkg/spell" -) - -// Spell is a spell incantation for the "github.com/golangci/golangci-lint/cmd/golangci-lint" Go module command. -// If not extra arguments are configured, DefaultArgs are passed to the executable. -type Spell struct { - ac app.Config - opts *Options -} - -// Formula returns the spell incantation formula. -func (s *Spell) Formula() []string { - return s.opts.args -} - -// Kind returns the spell incantation kind. -func (s *Spell) Kind() spell.Kind { - return spell.KindGoModule -} - -// Options returns the spell incantation options. -func (s *Spell) Options() interface{} { - return *s.opts -} - -// GoModuleID returns partial Go module identifier information. -func (s *Spell) GoModuleID() *project.GoModuleID { - return s.opts.goModule -} - -// Env returns spell incantation specific environment variables. -func (s *Spell) Env() map[string]string { - return s.opts.env -} - -// New creates a new spell incantation for the "github.com/golangci/golangci-lint/cmd/golangci-lint" Go module command. -//nolint:gocritic // The app.Config struct is passed as value by design to ensure immutability. -func New(wand wand.Wand, ac app.Config, opts ...Option) (*Spell, error) { - opt, optErr := NewOptions(opts...) - if optErr != nil { - return nil, optErr - } - return &Spell{ac: ac, opts: opt}, nil -} diff --git a/pkg/spell/gox/gox.go b/pkg/spell/gox/gox.go deleted file mode 100644 index 0543fe0..0000000 --- a/pkg/spell/gox/gox.go +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright (c) 2019-present Sven Greb -// This source code is licensed under the MIT license found in the LICENSE file. - -// Package gox provides a spell incantation for the "github.com/mitchellh/gox" Go module command, a dead simple, -// no frills Go cross compile tool that behaves a lot like the standard Go toolchain "build" command. -// -// See https://pkg.go.dev/github.com/mitchellh/gox for more details about "gox". -// The source code of the "gox" is available at https://github.com/mitchellh/gox. -package gox - -import ( - "fmt" - "strings" - - "github.com/svengreb/wand" - "github.com/svengreb/wand/pkg/app" - castGoToolchain "github.com/svengreb/wand/pkg/cast/golang/toolchain" - "github.com/svengreb/wand/pkg/project" - "github.com/svengreb/wand/pkg/spell" - spellGo "github.com/svengreb/wand/pkg/spell/golang" -) - -// Spell is a spell incantation for the "github.com/mitchellh/gox" Go module command. -type Spell struct { - ac app.Config - opts *Options -} - -// Formula returns the spell incantation formula. -func (s *Spell) Formula() []string { - args := spellGo.CompileFormula(s.opts.spellGoOpts...) - - // Workaround to allow the usage of the "-trimpath" flag that has been introduced in Go 1.13.0. - // The currently latest version of "gox" does not support the flag yet. - // - // See https://github.com/mitchellh/gox/pull/138 for more details. - for idx, arg := range args { - if arg == "-trimpath" { - args = append(args[:idx], args[idx+1:]...) - // Set the flag via the GOFLAGS environment variable instead. - s.opts.env[castGoToolchain.DefaultEnvVarGOFLAGS] = fmt.Sprintf( - "%s %s -trimpath", - s.opts.Env[castGoToolchain.DefaultEnvVarGOFLAGS], - s.opts.env[castGoToolchain.DefaultEnvVarGOFLAGS], - ) - } - } - - if s.opts.verbose { - args = append(args, "-verbose") - } - - if s.opts.goCmd != "" { - args = append(args, fmt.Sprintf("-gocmd=%s", s.opts.goCmd)) - } - - if len(s.opts.CrossCompileTargetPlatforms) > 0 { - args = append(args, fmt.Sprintf("-osarch=%s", strings.Join(s.opts.CrossCompileTargetPlatforms, " "))) - } - - args = append(args, fmt.Sprintf("--output=%s/%s", s.opts.OutputDir, s.opts.outputTemplate)) - - if len(s.opts.Flags) > 0 { - args = append(args, s.opts.Flags...) - } - - return append(args, s.ac.PkgPath) -} - -// Kind returns the spell incantation kind. -func (s *Spell) Kind() spell.Kind { - return spell.KindGoModule -} - -// Options returns the spell incantation options. -func (s *Spell) Options() interface{} { - return *s.opts -} - -// GoModuleID returns partial Go module identifier information. -func (s *Spell) GoModuleID() *project.GoModuleID { - return s.opts.goModule -} - -// Env returns spell incantation specific environment variables. -func (s *Spell) Env() map[string]string { - return s.opts.env -} - -// New creates a new spell incantation for the "github.com/mitchellh/gox" Go module command, a dead simple, no frills -// Go cross compile tool that behaves a lot like the standard Go toolchain "build" command. -//nolint:gocritic // The app.Config struct is passed as value by design to ensure immutability. -func New(wand wand.Wand, ac app.Config, opts ...Option) (*Spell, error) { - opt, optErr := NewOptions(opts...) - if optErr != nil { - return nil, optErr - } - - if opt.BinaryArtifactName == "" { - opt.BinaryArtifactName = ac.Name - } - - // Store builds artifacts in the application specific sub-folder. - if opt.OutputDir == "" { - opt.OutputDir = ac.BaseOutputDir - } - - if opt.outputTemplate == "" { - opt.outputTemplate = DefaultCrossCompileBinaryNameTemplate(opt.BinaryArtifactName) - } - - return &Spell{ac: ac, opts: opt}, nil -} diff --git a/pkg/spell/spell.go b/pkg/spell/spell.go deleted file mode 100644 index 1e7b0a6..0000000 --- a/pkg/spell/spell.go +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) 2019-present Sven Greb -// This source code is licensed under the MIT license found in the LICENSE file. - -// Package spell provides incantations for different kinds. -package spell - -import "github.com/svengreb/wand/pkg/project" - -// Incantation is the abstract representation of flags and parameters for a command or action. -// It is mainly handled by a cast.Caster that provides the corresponding information about the command like the path -// to the executable. -// -// The separation of parameters from commands enables a flexible usage, e.g. when the parameters can be reused for a -// different command. -// -// The abstract view and naming is inspired by the fantasy novel "Harry Potter" in which it is almost only possible to -// cast a magic spell through a incantation. -// -// References -// -// (1) https://en.wikipedia.org/wiki/Incantation -// (2) https://en.wikipedia.org/wiki/Magic_in_Harry_Potter#Spellcasting -// (3) https://scifi.stackexchange.com/a/33234 -// (4) https://harrypotter.fandom.com/wiki/Spell -// (5) https://diffsense.com/diff/incantation/spell -type Incantation interface { - // Formula returns all parameters of a spell. - Formula() []string - - // Kind returns the Kind of a spell. - Kind() Kind - - // Options returns the options of a spell. - Options() interface{} -} - -// Binary is a Incantation for commands which are using a binary executable. -type Binary interface { - Incantation - - // Env returns additional environment variables. - Env() map[string]string -} - -// GoCode is a Incantation for actions that can be casted without a cast.Caster. -// It is a special incantations in that it allows to use Go code as spell while still being compatible to the -// incantation API. -// Note that the Incantation.Formula of a GoCode must always return an empty slice, -// otherwise it is a "normal" Incantation that requires a cast.Caster. -// -// Seen from the abstract "Harry Potter" view this is equal to a "non-verbal" spell that is a special technique that can -// be used for spells that have been specially designed to be used non-verbally. -// -// References -// -// (1) https://en.wikipedia.org/wiki/Magic_in_Harry_Potter#Spellcasting -// (2) https://www.reddit.com/r/harrypotter/comments/4z9rwl/what_is_the_difference_between_a_spell_charm -type GoCode interface { - Incantation - - // Cast casts itself. - Cast() (interface{}, error) -} - -// GoModule is a Binary for binary command executables managed by a Go module. -// -// See https://golang.org/ref/mod for more details. -type GoModule interface { - Binary - - // GoModuleID returns the identifier of a Go module. - GoModuleID() *project.GoModuleID -} diff --git a/pkg/task/error.go b/pkg/task/error.go new file mode 100644 index 0000000..3c76805 --- /dev/null +++ b/pkg/task/error.go @@ -0,0 +1,90 @@ +// Copyright (c) 2019-present Sven Greb +// This source code is licensed under the MIT license found in the LICENSE file. + +package task + +import ( + "errors" + "fmt" + + wErr "github.com/svengreb/wand/pkg/error" +) + +const ( + // ErrIncompatibleRunner indicates that a command runner is not compatible for a task. + ErrIncompatibleRunner = wErr.ErrString("incompatible command runner") + + // ErrInvalidRunnerOpts indicates invalid command runner options. + ErrInvalidRunnerOpts = wErr.ErrString("invalid options") + + // ErrInvalidTaskOpts indicates invalid task options. + ErrInvalidTaskOpts = wErr.ErrString("invalid options") + + // ErrRun indicates that a runner failed to run. + ErrRun = wErr.ErrString("failed to run") + + // ErrRunnerValidation indicates that a command runner validation failed. + ErrRunnerValidation = wErr.ErrString("validation failed") + + // ErrUnsupportedTaskKind indicates that a task kind is not supported. + ErrUnsupportedTaskKind = wErr.ErrString("unsupported task kind") + + // ErrUnsupportedTaskOptions indicates that the task options are not supported. + ErrUnsupportedTaskOptions = wErr.ErrString("unsupported task kind") +) + +// ErrRunner represents a runner error. +type ErrRunner struct { + // Err is a wrapped error. + Err error + // Kind is the error kind. + Kind error +} + +func (e *ErrRunner) Error() string { + msg := "runner error" + if e.Kind != nil { + msg = fmt.Sprintf("%s: %v", msg, e.Kind) + } + if e.Err != nil { + msg = fmt.Sprintf("%s: %v", msg, e.Err) + } + + return msg +} + +// Is enables usage of errors.Is() to determine the kind of error that occurred. +func (e *ErrRunner) Is(err error) bool { + return errors.Is(err, e.Kind) +} + +// Unwrap returns the underlying error for usage with errors.Unwrap(). +func (e *ErrRunner) Unwrap() error { return e.Err } + +// ErrTask represents a task error. +type ErrTask struct { + // Err is a wrapped error. + Err error + // Kind is the error kind. + Kind error +} + +func (e *ErrTask) Error() string { + msg := "task error" + if e.Kind != nil { + msg = fmt.Sprintf("%s: %v", msg, e.Kind) + } + if e.Err != nil { + msg = fmt.Sprintf("%s: %v", msg, e.Err) + } + + return msg +} + +// Is enables usage of errors.Is() to determine the kind of error that occurred. +func (e *ErrTask) Is(err error) bool { + return errors.Is(err, e.Kind) +} + +// Unwrap returns the underlying error for usage with errors.Unwrap(). +func (e *ErrTask) Unwrap() error { return e.Err } diff --git a/pkg/task/fs/clean/clean.go b/pkg/task/fs/clean/clean.go new file mode 100644 index 0000000..6c449e8 --- /dev/null +++ b/pkg/task/fs/clean/clean.go @@ -0,0 +1,91 @@ +// Copyright (c) 2019-present Sven Greb +// This source code is licensed under the MIT license found in the LICENSE file. + +// Package clean provides a task to remove filesystem paths, e.g. output data like artifacts and reports from previous +// development, test, production and distribution builds. +package clean + +import ( + "fmt" + "os" + "path/filepath" + + glFS "github.com/svengreb/golib/pkg/io/fs" + glFilePath "github.com/svengreb/golib/pkg/io/fs/filepath" + + "github.com/svengreb/wand/pkg/app" + "github.com/svengreb/wand/pkg/project" + "github.com/svengreb/wand/pkg/task" +) + +// Task is a task to remove filesystem paths, e.g. output data like artifacts and reports from previous development, +// test, production and distribution builds. +type Task struct { + ac app.Config + opts *Options + proj project.Metadata +} + +// Clean removes the configured paths. +// It returns an error of type *param.ErrGoCode for any error that occurs during the execution of the Go code. +func (t *Task) Clean() ([]string, error) { + var cleaned []string + + for _, p := range t.opts.paths { + pAbs := filepath.Join(t.proj.Options().RootDirPathAbs, p) + + if t.opts.limitToAppOutputDir { + appDir := filepath.Join(t.proj.Options().RootDirPathAbs, t.ac.BaseOutputDir) + pAbs = filepath.Join(t.proj.Options().RootDirPathAbs, p) + + isSubDir, subDirErr := glFilePath.IsSubDir(appDir, pAbs, false) + if subDirErr != nil { + return cleaned, &task.ErrTask{ + Err: fmt.Errorf("check if %q is a subdirectory of %q: %w", pAbs, appDir, subDirErr), + Kind: task.ErrInvalidTaskOpts, + } + } + if !isSubDir { + return cleaned, &task.ErrTask{ + Err: fmt.Errorf("%q is not a subdirectory of %q", pAbs, appDir), + Kind: task.ErrInvalidTaskOpts, + } + } + } + + nodeExists, fsErr := glFS.FileExists(pAbs) + if fsErr != nil { + return cleaned, &task.ErrTask{ + Err: fmt.Errorf("check if %q exists: %w", pAbs, fsErr), + Kind: task.ErrInvalidTaskOpts, + } + } + if nodeExists { + if err := os.RemoveAll(pAbs); err != nil { + return cleaned, &task.ErrRunner{ + Err: fmt.Errorf("remove path %q: %w", pAbs, err), + Kind: task.ErrRun, + } + } + cleaned = append(cleaned, p) + } + } + + return cleaned, nil +} + +// Options returns the task options. +func (t *Task) Options() task.Options { + return *t.opts +} + +// New creates a new task. +//nolint:gocritic // The app.Config struct is passed as value by design to ensure immutability. +func New(proj project.Metadata, ac app.Config, opts ...Option) (*Task, error) { + opt, optErr := NewOptions(opts...) + if optErr != nil { + return nil, optErr + } + + return &Task{ac: ac, proj: proj, opts: opt}, nil +} diff --git a/pkg/spell/fs/clean/options.go b/pkg/task/fs/clean/options.go similarity index 91% rename from pkg/spell/fs/clean/options.go rename to pkg/task/fs/clean/options.go index d62ec20..f3feef4 100644 --- a/pkg/spell/fs/clean/options.go +++ b/pkg/task/fs/clean/options.go @@ -3,7 +3,10 @@ package clean -// Options are clean.Spell options. +// Option is a task option. +type Option func(*Options) + +// Options are task options. type Options struct { // limitToAppOutputDir indicates whether only paths within the configured application output directory should be // allowed. @@ -15,8 +18,15 @@ type Options struct { paths []string } -// Option is a clean.Spell option. -type Option func(*Options) +// NewOptions creates new task options. +func NewOptions(opts ...Option) (*Options, error) { + opt := &Options{} + for _, o := range opts { + o(opt) + } + + return opt, nil +} // WithLimitToAppOutputDir indicates whether only paths within the configured application output directory should be // allowed. @@ -34,13 +44,3 @@ func WithPaths(paths ...string) Option { o.paths = append(o.paths, paths...) } } - -// NewOptions creates new clean.Spell options. -func NewOptions(opts ...Option) (*Options, error) { - opt := &Options{} - for _, o := range opts { - o(opt) - } - - return opt, nil -} diff --git a/pkg/task/gobin/gobin.go b/pkg/task/gobin/gobin.go new file mode 100644 index 0000000..2fb2637 --- /dev/null +++ b/pkg/task/gobin/gobin.go @@ -0,0 +1,342 @@ +// Copyright (c) 2019-present Sven Greb +// This source code is licensed under the MIT license found in the LICENSE file. + +// Package gobin provides a runner for the "github.com/myitcv/gobin" Go module command. +// "gobin" is an experimental, module-aware command to install and run "main" packages. +// +// See https://pkg.go.dev/github.com/myitcv/gobin for more details about "gobin". +// The source code of the "gobin" is available at https://github.com/myitcv/gobin. +// +// Go Executable Installation +// +// Using the "go install" (2) or "go get" (6) command for a Go module (1) "main" package, the resulting executables are +// placed in the Go executable search path that is defined by the "GOBIN" environment variable (3) (see the "go env" +// command (4) to show or modify the Go toolchain environment). +// Even though executables are installed "globally" for the current user, any "go.mod" file (5) in the current working +// directory will be updated to include the Go module. This is the default behavior of the "go get" command (6) when +// running in "module mode" (7) (see "GO111MODULE" environment variable). +// +// Next to this problem, installed executables will also overwrite any previously installed executable of the same +// module/package regardless of the version. Therefore only one version of a executable can be installed at a time which +// makes it impossible to work on different projects that make use of the same executable but require different +// versions. +// +// History and Future +// +// The installation concept for "main" package executables has always been a somewhat controversial point which +// unfortunately, partly for historical reasons, does not offer an optimal and user-friendly solution up to now. +// The "go" command (8) is a fantastic toolchain that provides many great features one would expect to be provided +// out-of-the-box from a modern and well designed programming language without the requirement to use a third-party +// solution: from compiling code, running unit/integration/benchmark tests, quality and error analysis, debugging +// utilities and many more. +// Unfortunately this doesn't apply for the "go install" command (2) of Go versions less or equal to 1.15. +// +// The general problem of tool dependencies is a long-time known issue/weak point of the current Go toolchain and is a +// highly rated change request from the Go community with discussions like https://github.com/golang/go/issues/30515, +// https://github.com/golang/go/issues/25922 and https://github.com/golang/go/issues/27653 to improve this essential +// feature, but they've been around for quite a long time without a solution that works without introducing breaking +// changes and most users and the Go team agree on. +// Luckily, this topic was finally picked up for the next upcoming Go release version 1.16 (9) and +// https://github.com/golang/go/issues/40276 introduces a way to install executables in module mode outside a module. +// The release note preview also already includes details about this change (10) and how installation of executables +// from Go modules will be handled in the future. +// +// The Workaround +// +// Beside the great news and anticipation about an official solution for the problem the usage of a workaround is almost +// inevitable until Go 1.16 is finally released. +// +// The official Go wiki (11) provides a section on "How can I track tool dependencies for a module?" (12) that describes +// a workaround that tracks tool dependencies. It allows to use the Go module logic by using a file like "tools.go" with +// a dedicated "tools" build tag that prevents the included module dependencies to be picked up for "normal" executable +// builds. This approach works fine for non-main packages, but CLI tools that are only implemented in the +// "main" package can not be imported in such a file. +// +// In order to tackle this problem, a well-known user from the community created "gobin" (13), an experimental, +// module-aware command to install and run "main" packages. +// It allows to install or run "main" package commands without "polluting" the "go.mod" file. Modules are downloaded in +// version-aware mode into a cache path within the users local cache directory (14). This way it prevents problems due +// to already installed executables by placing each version of an executable in its own directory. +// The decision to use a cache directory instead of sub-directories within the "GOBIN" path doesn't require to mess with +// the users setup and keep the Go toolchain specific paths clean and unchanged. +// +// "gobin" is still in an early development state, but has already received a lot of positive feedback and is used in +// many projects. There are also members of the core Go team that have contributed to the project and the chance is high +// that the changes for Go 1.16 were influenced or partially ported from it. +// It is currently the best workaround to... +// 1. prevent the Go toolchain to pick up the "GOMOD" ("go env GOMOD") environment variable (4) that is initialized +// automatically with the path to the "go.mod" file (5) in the current working directory. +// 2. install "main" package executables locally for the current user without "polluting" the "go.mod" file. +// 3. install "main" package executables locally for the current user without overriding already installed executables +// of different versions. +// +// See gobin's FAQ page (15) in the repository wiki for more details about the project. +// +// The Go Module Command Runner +// +// To allow to manage the tool dependency problem, this package provides a command runner that uses "gobin" in order to +// prevent the problems described in the sections above like the "pollution" of the "go.mod" file and allows to... +// 1. install "gobin" itself into "GOBIN" (`go env GOBIN` (4)). +// 2. run any Go module command by installing "main" package executables locally for the current user into the +// dedicated "gobin" cache. +// +// References +// +// (1) https://golang.org/ref/mod +// (2) https://pkg.go.dev/cmd/go#hdr-Compile_and_install_packages_and_dependencies +// (3) https://pkg.go.dev/cmd/go/#hdr-Environment_variables +// (4) https://pkg.go.dev/cmd/go/#hdr-Print_Go_environment_information +// (5) https://golang.org/ref/mod#go-mod-file +// (6) https://pkg.go.dev/cmd/go/#hdr-Print_Go_environment_information +// (7) https://golang.org/ref/mod#mod-commands +// (8) https://golang.org/cmd/go +// (9) https://github.com/golang/go/milestone/145 +// (10) https://tip.golang.org/doc/go1.16#modules +// (11) https://github.com/golang/go/wiki +// (12) https://github.com/golang/go/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module +// (13) https://github.com/myitcv/gobin +// (14) https://pkg.go.dev/os/#UserCacheDir +// (15) https://github.com/myitcv/gobin/wiki/FAQ +package gobin + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/magefile/mage/sh" + glFS "github.com/svengreb/golib/pkg/io/fs" + + osSupport "github.com/svengreb/wand/internal/support/os" + "github.com/svengreb/wand/pkg/project" + "github.com/svengreb/wand/pkg/task" + taskGo "github.com/svengreb/wand/pkg/task/golang" +) + +// Runner is a runner for the "github.com/myitcv/gobin" Go module command. +// "gobin" is an experimental, module-aware command to install and run "main" packages. +// +// See https://pkg.go.dev/github.com/myitcv/gobin for more details about "gobin". +// The source code of the "gobin" is available at https://github.com/myitcv/gobin. +// +// Go Executable Installation +// +// Using the "go install" (2) or "go get" (6) command for a Go module (1) "main" package, the resulting executables are +// placed in the Go executable search path that is defined by the "GOBIN" environment variable (3) (see the "go env" +// command (4) to show or modify the Go toolchain environment). +// Even though executables are installed "globally" for the current user, any "go.mod" file (5) in the current working +// directory will be updated to include the Go module. This is the default behavior of the "go get" command (6) when +// running in "module mode" (7) (see "GO111MODULE" environment variable). +// +// Next to this problem, installed executables will also overwrite any previously installed executable of the same +// module/package regardless of the version. Therefore only one version of a executable can be installed at a time which +// makes it impossible to work on different projects that make use of the same executable but require different +// versions. +// +// History and Future +// +// The installation concept for "main" package executables has always been a somewhat controversial point which +// unfortunately, partly for historical reasons, does not offer an optimal and user-friendly solution up to now. +// The "go" command (8) is a fantastic toolchain that provides many great features one would expect to be provided +// out-of-the-box from a modern and well designed programming language without the requirement to use a third-party +// solution: from compiling code, running unit/integration/benchmark tests, quality and error analysis, debugging +// utilities and many more. +// Unfortunately this doesn't apply for the "go install" command (2) of Go versions less or equal to 1.15. +// +// The general problem of tool dependencies is a long-time known issue/weak point of the current Go toolchain and is a +// highly rated change request from the Go community with discussions like https://github.com/golang/go/issues/30515, +// https://github.com/golang/go/issues/25922 and https://github.com/golang/go/issues/27653 to improve this essential +// feature, but they've been around for quite a long time without a solution that works without introducing breaking +// changes and most users and the Go team agree on. +// Luckily, this topic was finally picked up for the next upcoming Go release version 1.16 (9) and +// https://github.com/golang/go/issues/40276 introduces a way to install executables in module mode outside a module. +// The release note preview also already includes details about this change (10) and how installation of executables +// from Go modules will be handled in the future. +// +// The Workaround +// +// Beside the great news and anticipation about an official solution for the problem the usage of a workaround is almost +// inevitable until Go 1.16 is finally released. +// +// The official Go wiki (11) provides a section on "How can I track tool dependencies for a module?" (12) that describes +// a workaround that tracks tool dependencies. It allows to use the Go module logic by using a file like "tools.go" with +// a dedicated "tools" build tag that prevents the included module dependencies to be picked up for "normal" executable +// builds. This approach works fine for non-main packages, but CLI tools that are only implemented in the +// "main" package can not be imported in such a file. +// +// In order to tackle this problem, a well-known user from the community created "gobin" (13), an experimental, +// module-aware command to install and run "main" packages. +// It allows to install or run "main" package commands without "polluting" the "go.mod" file. Modules are downloaded in +// version-aware mode into a cache path within the users local cache directory (14). This way it prevents problems due +// to already installed executables by placing each version of an executable in its own directory. +// The decision to use a cache directory instead of sub-directories within the "GOBIN" path doesn't require to mess with +// the users setup and keep the Go toolchain specific paths clean and unchanged. +// +// "gobin" is still in an early development state, but has already received a lot of positive feedback and is used in +// many projects. There are also members of the core Go team that have contributed to the project and the chance is high +// that the changes for Go 1.16 were influenced or partially ported from it. +// It is currently the best workaround to... +// 1. prevent the Go toolchain to pick up the "GOMOD" ("go env GOMOD") environment variable (4) that is initialized +// automatically with the path to the "go.mod" file (5) in the current working directory. +// 2. install "main" package executables locally for the current user without "polluting" the "go.mod" file. +// 3. install "main" package executables locally for the current user without overriding already installed executables +// of different versions. +// +// See gobin's FAQ page (15) in the repository wiki for more details about the project. +// +// The Go Module Command Runner +// +// To allow to manage the tool dependency problem, this package provides a command runner that uses "gobin" in order to +// prevent the problems described in the sections above like the "pollution" of the "go.mod" file and allows to... +// 1. install "gobin" itself into "GOBIN" (`go env GOBIN` (4)). +// 2. run any Go module command by installing "main" package executables locally for the current user into the +// dedicated "gobin" cache. +// +// References +// +// (1) https://golang.org/ref/mod +// (2) https://pkg.go.dev/cmd/go#hdr-Compile_and_install_packages_and_dependencies +// (3) https://pkg.go.dev/cmd/go/#hdr-Environment_variables +// (4) https://pkg.go.dev/cmd/go/#hdr-Print_Go_environment_information +// (5) https://golang.org/ref/mod#go-mod-file +// (6) https://pkg.go.dev/cmd/go/#hdr-Print_Go_environment_information +// (7) https://golang.org/ref/mod#mod-commands +// (8) https://golang.org/cmd/go +// (9) https://github.com/golang/go/milestone/145 +// (10) https://tip.golang.org/doc/go1.16#modules +// (11) https://github.com/golang/go/wiki +// (12) https://github.com/golang/go/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module +// (13) https://github.com/myitcv/gobin +// (14) https://pkg.go.dev/os/#UserCacheDir +// (15) https://github.com/myitcv/gobin/wiki/FAQ +type Runner struct { + opts *RunnerOptions +} + +// FilePath returns the path to the runner executable. +func (r *Runner) FilePath() string { + return r.opts.Exec +} + +// GoMod returns the Go module identifier. +func (r *Runner) GoMod() *project.GoModuleID { + return r.opts.goModule +} + +// Handles returns the supported task kind. +func (r *Runner) Handles() task.Kind { + return task.KindGoModule +} + +// Install installs the runner executable. +// It does not "pollute" the "go.mod" file of the project by running the installation outside of the project root +// directory using a temporary path instead. +// +// See the package documentation for details: https://pkg.go.dev/github.com/svengreb/wand/pkg/task/gobin +func (r *Runner) Install(goRunner *taskGo.Runner) error { + goRunnerExec := goRunner.FilePath() + executor := exec.Command(goRunnerExec, "get", "-v", r.opts.goModule.String()) + executor.Dir = os.TempDir() + executor.Env = os.Environ() + + // Explicitly enable "module" mode to install a pinned version. + r.opts.Env[taskGo.DefaultEnvVarGO111MODULE] = "on" + executor.Env = osSupport.EnvMapToSlice(r.opts.Env) + + if err := executor.Run(); err != nil { + return &task.ErrRunner{ + Err: err, + Kind: task.ErrRun, + } + } + return nil +} + +// Run runs the command. +// It returns an error of type *task.ErrRunner when any error occurs during the command execution. +func (r *Runner) Run(t task.Task) error { + tGM, ok := t.(task.GoModule) + if t.Kind() != task.KindGoModule || !ok { + return &task.ErrRunner{ + Err: fmt.Errorf("expected %q but got %q", r.Handles(), t.Kind()), + Kind: task.ErrUnsupportedTaskKind, + } + } + + runFn := sh.RunWithV + if r.opts.Quiet { + runFn = sh.RunWith + } + + params := append([]string{"-run", tGM.ID().String()}, tGM.BuildParams()...) + + for k, v := range tGM.Env() { + r.opts.Env[k] = v + } + + return runFn(r.opts.Env, r.opts.Exec, params...) +} + +// Validate validates the command executable. +// It returns an error of type *task.ErrRunner when the executable does not exist and when it is also not available in +// the executable search path(s) of the current environment. +func (r *Runner) Validate() error { + // Check if the executable exists,... + execExits, fsErr := glFS.RegularFileExists(r.opts.Exec) + if fsErr != nil { + return &task.ErrRunner{ + Err: fmt.Errorf("runner %q: %w", RunnerName, fsErr), + Kind: task.ErrRunnerValidation, + } + } + + // ...otherwise try to look up the executable search path(s)... + if !execExits { + execPath, pathErr := exec.LookPath(r.opts.Exec) + + // ...and the Go specific executable search path. + if pathErr != nil { + var execDirGoEnv string + + if execDirGoEnv = os.Getenv(taskGo.DefaultEnvVarGOBIN); execDirGoEnv == "" { + if execDirGoEnv = os.Getenv(taskGo.DefaultEnvVarGOPATH); execDirGoEnv != "" { + execDirGoEnv = filepath.Join(execDirGoEnv, taskGo.DefaultGOBINSubDirName) + } + } + + execPath = filepath.Join(execDirGoEnv, r.opts.Exec) + execExits, fsErr = glFS.RegularFileExists(execPath) + if fsErr != nil { + return &task.ErrRunner{ + Err: fmt.Errorf("runner %q: %w", RunnerName, fsErr), + Kind: task.ErrRunnerValidation, + } + } + + if !execExits { + return &task.ErrRunner{ + Err: fmt.Errorf("runner %q: %q not found or does not exist", RunnerName, execPath), + Kind: task.ErrRunnerValidation, + } + } + } + + r.opts.Exec = execPath + } + + return nil +} + +// NewRunner creates a new command runner for the "github.com/myitcv/gobin" Go module. +func NewRunner(opts ...RunnerOption) (*Runner, error) { + opt, optErr := NewRunnerOptions(opts...) + if optErr != nil { + return nil, &task.ErrRunner{ + Err: optErr, + Kind: task.ErrInvalidRunnerOpts, + } + } + + return &Runner{opts: opt}, nil +} diff --git a/pkg/task/gobin/options.go b/pkg/task/gobin/options.go new file mode 100644 index 0000000..8702fd7 --- /dev/null +++ b/pkg/task/gobin/options.go @@ -0,0 +1,114 @@ +// Copyright (c) 2019-present Sven Greb +// This source code is licensed under the MIT license found in the LICENSE file. + +package gobin + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" + + "github.com/svengreb/wand/pkg/project" + "github.com/svengreb/wand/pkg/task" +) + +const ( + // DefaultGoModulePath is the default Go module import path of the runner command. + DefaultGoModulePath = "github.com/myitcv/gobin" + + // DefaultGoModuleVersion is the default Go module version of the runner command. + DefaultGoModuleVersion = "v0.0.14" + + // DefaultRunnerExec is the default path to the runner command executable. + DefaultRunnerExec = "gobin" + + // RunnerName is the name of the runner. + RunnerName = "gobin" +) + +// RunnerOption is a runner option. +type RunnerOption func(*RunnerOptions) + +// RunnerOptions are runner options. +type RunnerOptions struct { + // Env is the runner specific environment. + Env map[string]string + + // Exec is the name or path of the runner command executable. + Exec string + + // goModule is the Go module identifier. + goModule *project.GoModuleID + + // Quiet indicates whether the runner output should be minimal. + Quiet bool +} + +// NewRunnerOptions creates new runner options. +func NewRunnerOptions(opts ...RunnerOption) (*RunnerOptions, error) { + version, versionErr := semver.NewVersion(DefaultGoModuleVersion) + if versionErr != nil { + return nil, &task.ErrRunner{ + Err: fmt.Errorf("parsing default module version %q: %w", DefaultGoModulePath, versionErr), + Kind: task.ErrInvalidRunnerOpts, + } + } + + opt := &RunnerOptions{ + Env: make(map[string]string), + Exec: DefaultRunnerExec, + goModule: &project.GoModuleID{ + Path: DefaultGoModulePath, + Version: version, + }, + } + for _, o := range opts { + o(opt) + } + + return opt, nil +} + +// WithEnv sets the runner specific environment. +func WithEnv(env map[string]string) RunnerOption { + return func(o *RunnerOptions) { + o.Env = env + } +} + +// WithExec sets the name or path of the runner command executable. +// Defaults to DefaultExecFileName. +func WithExec(nameOrPath string) RunnerOption { + return func(o *RunnerOptions) { + if nameOrPath != "" { + o.Exec = nameOrPath + } + } +} + +// WithModulePath sets the Go module import path of the runner command. +// Defaults to DefaultGoModulePath. +func WithModulePath(path string) RunnerOption { + return func(o *RunnerOptions) { + if path != "" { + o.goModule.Path = path + } + } +} + +// WithModuleVersion sets the Go module version of the runner command. +// Defaults to DefaultGoModuleVersion. +func WithModuleVersion(version *semver.Version) RunnerOption { + return func(o *RunnerOptions) { + if version != nil { + o.goModule.Version = version + } + } +} + +// WithQuiet indicates whether the runner output should be minimal. +func WithQuiet(quiet bool) RunnerOption { + return func(o *RunnerOptions) { + o.Quiet = quiet + } +} diff --git a/pkg/task/goimports/goimports.go b/pkg/task/goimports/goimports.go new file mode 100644 index 0000000..396e07d --- /dev/null +++ b/pkg/task/goimports/goimports.go @@ -0,0 +1,104 @@ +// Copyright (c) 2019-present Sven Greb +// This source code is licensed under the MIT license found in the LICENSE file. + +// Package goimports provides a task for the "golang.org/x/tools/cmd/goimports" Go module command. +// "goimports" allows to update Go import lines, add missing ones and remove unreferenced ones. It also formats code in +// the same style as "https://pkg.go.dev/cmd/gofmt" so it can be used as a replacement. +// +// See https://pkg.go.dev/golang.org/x/tools/cmd/goimports for more details about "goimports". +// The source code of "goimports" is available at https://github.com/golang/tools/tree/master/cmd/goimports. +package goimports + +import ( + "fmt" + "strings" + + "github.com/svengreb/wand" + "github.com/svengreb/wand/pkg/app" + "github.com/svengreb/wand/pkg/project" + "github.com/svengreb/wand/pkg/task" +) + +// Task is a task for the "golang.org/x/tools/cmd/goimports" Go module command. +// "goimports" allows to update Go import lines, add missing ones and remove unreferenced ones. It also formats code in +// the same style as "https://pkg.go.dev/cmd/gofmt" so it can be used as a replacement. +// +// See https://pkg.go.dev/golang.org/x/tools/cmd/goimports for more details about "goimports". +// The source code of "goimports" is available at https://github.com/golang/tools/tree/master/cmd/goimports. +type Task struct { + ac app.Config + opts *Options +} + +// BuildParams builds the parameters. +func (t *Task) BuildParams() []string { + var params []string + + // List files whose formatting are non-compliant to the style guide. + if t.opts.listNonCompliantFiles { + params = append(params, "-l") + } + + // A comma-separated list of prefixes for local package imports to be put after 3rd-party packages. + if len(t.opts.localPkgs) > 0 { + params = append(params, "-local", fmt.Sprintf("'%s'", strings.Join(t.opts.localPkgs, ","))) + } + + // Report all errors and not just the first 10 on different lines. + if t.opts.reportAllErrors { + params = append(params, "-e") + } + + // Write result to source files instead of stdout. + if t.opts.persistChanges { + params = append(params, "-w") + } + + // Toggle verbose output. + if t.opts.verbose { + params = append(params, "-v") + } + + // Include additionally configured arguments. + params = append(params, t.opts.extraArgs...) + + // Only search in specified paths for Go source files... + if len(t.opts.paths) > 0 { + params = append(params, t.opts.paths...) + } else { + // ...or otherwise search recursively starting from the current working directory. + params = append(params, ".") + } + + return params +} + +// Env returns the task specific environment. +func (t *Task) Env() map[string]string { + return t.opts.env +} + +// ID returns the identifier of the Go module. +func (t *Task) ID() *project.GoModuleID { + return t.opts.goModule +} + +// Kind returns the task kind. +func (t *Task) Kind() task.Kind { + return task.KindGoModule +} + +// Options returns the task options. +func (t *Task) Options() task.Options { + return *t.opts +} + +// New creates a new task for the "golang.org/x/tools/cmd/goimports" Go module command. +//nolint:gocritic // The app.Config struct is passed as value by design to ensure immutability. +func New(wand wand.Wand, ac app.Config, opts ...Option) (*Task, error) { + opt, optErr := NewOptions(opts...) + if optErr != nil { + return nil, optErr + } + return &Task{ac: ac, opts: opt}, nil +} diff --git a/pkg/spell/goimports/options.go b/pkg/task/goimports/options.go similarity index 72% rename from pkg/spell/goimports/options.go rename to pkg/task/goimports/options.go index 50b3e63..e3cc81f 100644 --- a/pkg/spell/goimports/options.go +++ b/pkg/task/goimports/options.go @@ -4,31 +4,28 @@ package goimports import ( - "fmt" - "github.com/Masterminds/semver/v3" - "github.com/svengreb/wand/pkg/cast" "github.com/svengreb/wand/pkg/project" ) const ( - // DefaultGoModulePath is the default "goimports" module command import path. + // DefaultGoModulePath is the default module import path. DefaultGoModulePath = "golang.org/x/tools/cmd/goimports" - - // DefaultGoModuleVersion is the default "goimports" module version. - DefaultGoModuleVersion = "latest" ) -// Options are spell incantation options for the "golang.org/x/tools/cmd/goimports" Go module command. +// Option is a task option. +type Option func(*Options) + +// Options are task options. type Options struct { - // env are spell incantation specific environment variables. + // env is the task specific environment. env map[string]string - // extraArgs are additional arguments to pass to the "goimports" command. + // extraArgs are additional arguments passed to the command. extraArgs []string - // goModule are partial Go module identifier information. + // goModule is the Go module identifier. goModule *project.GoModuleID // listNonCompliantFiles indicates whether files, whose formatting are not conform to the style guide, should be @@ -52,17 +49,30 @@ type Options struct { verbose bool } -// Option is a spell incantation option for the "golang.org/x/tools/cmd/goimports" Go module command. -type Option func(*Options) +// NewOptions creates new task options. +func NewOptions(opts ...Option) (*Options, error) { + opt := &Options{ + env: make(map[string]string), + goModule: &project.GoModuleID{ + Path: DefaultGoModulePath, + IsLatest: true, + }, + } + for _, o := range opts { + o(opt) + } + + return opt, nil +} -// WithEnv sets the spell incantation specific environment. +// WithEnv sets the task specific environment. func WithEnv(env map[string]string) Option { return func(o *Options) { o.env = env } } -// WithExtraArgs sets additional arguments to pass to the "goimports" module command. +// WithExtraArgs sets additional arguments to pass to the command. func WithExtraArgs(extraArgs ...string) Option { return func(o *Options) { o.extraArgs = append(o.extraArgs, extraArgs...) @@ -83,7 +93,7 @@ func WithLocalPkgs(localPkgs ...string) Option { } } -// WithModulePath sets the "goimports" module import path. +// WithModulePath sets the module import path. // Defaults to DefaultGoModulePath. func WithModulePath(path string) Option { return func(o *Options) { @@ -93,7 +103,7 @@ func WithModulePath(path string) Option { } } -// WithModuleVersion sets the "goimports" module version. +// WithModuleVersion sets the module version. // Defaults to DefaultGoModuleVersion. func WithModuleVersion(version *semver.Version) Option { return func(o *Options) { @@ -131,27 +141,3 @@ func WithVerboseOutput(verbose bool) Option { o.verbose = verbose } } - -// NewOptions creates new spell incantation options for the "golang.org/x/tools/cmd/goimports" Go module command. -func NewOptions(opts ...Option) (*Options, error) { - version, versionErr := semver.NewVersion(DefaultGoModuleVersion) - if versionErr != nil { - return nil, &cast.ErrCast{ - Err: fmt.Errorf("parsing default module version %q: %w", DefaultGoModulePath, versionErr), - Kind: cast.ErrCasterInvalidOpts, - } - } - opt := &Options{ - env: make(map[string]string), - goModule: &project.GoModuleID{ - Path: DefaultGoModulePath, - Version: version, - IsLatest: true, - }, - } - for _, o := range opts { - o(opt) - } - - return opt, nil -} diff --git a/pkg/task/golang/build/build.go b/pkg/task/golang/build/build.go new file mode 100644 index 0000000..5d58287 --- /dev/null +++ b/pkg/task/golang/build/build.go @@ -0,0 +1,77 @@ +// Copyright (c) 2019-present Sven Greb +// This source code is licensed under the MIT license found in the LICENSE file. + +// Package build provides a task for the Go toolchain "build" command. +package build + +import ( + "path/filepath" + + "github.com/svengreb/wand" + "github.com/svengreb/wand/pkg/app" + "github.com/svengreb/wand/pkg/task" + taskGo "github.com/svengreb/wand/pkg/task/golang" +) + +// Task is a task for the Go toolchain "build" command. +type Task struct { + ac app.Config + opts *Options +} + +// BuildParams builds the parameters. +// Note that configured flags are applied after the "GOFLAGS" environment variable and could overwrite already defined +// flags. +// +// See `go help environment`, `go help env` and the `go` command documentations for more details: +// - https://golang.org/cmd/go/#hdr-Environment_variables +func (t *Task) BuildParams() []string { + params := []string{"build"} + + params = append(params, taskGo.BuildGoOptions(t.opts.taskGoOpts...)...) + + if len(t.opts.Flags) > 0 { + params = append(params, t.opts.Flags...) + } + + params = append( + params, + "-o", + filepath.Join(t.opts.OutputDir, t.opts.BinaryArtifactName), + t.ac.PkgImportPath, + ) + + return params +} + +// Env returns the task specific environment. +func (t *Task) Env() map[string]string { + return t.opts.Env +} + +// Kind returns the task kind. +func (t *Task) Kind() task.Kind { + return task.KindExec +} + +// Options returns the task options. +func (t *Task) Options() task.Options { + return *t.opts +} + +// New creates a new task for the Go toolchain "build" command. +//nolint:gocritic // The app.Config struct is passed as value by design to ensure immutability. +func New(wand wand.Wand, ac app.Config, opts ...Option) *Task { + opt := NewOptions(opts...) + + if opt.BinaryArtifactName == "" { + opt.BinaryArtifactName = ac.Name + } + + // Store build artifacts in the application specific subdirectory. + if opt.OutputDir == "" { + opt.OutputDir = ac.BaseOutputDir + } + + return &Task{ac: ac, opts: opt} +} diff --git a/pkg/spell/golang/build/options.go b/pkg/task/golang/build/options.go similarity index 72% rename from pkg/spell/golang/build/options.go rename to pkg/task/golang/build/options.go index 2936a32..06880dc 100644 --- a/pkg/spell/golang/build/options.go +++ b/pkg/task/golang/build/options.go @@ -4,7 +4,7 @@ package build import ( - spellGo "github.com/svengreb/wand/pkg/spell/golang" + taskGo "github.com/svengreb/wand/pkg/task/golang" ) const ( @@ -12,9 +12,12 @@ const ( DefaultDistOutputDirName = "dist" ) -// Options are spell incantation options for the Go toolchain "build" command. +// Option is a task option. +type Option func(*Options) + +// Options are task options. type Options struct { - *spellGo.Options + *taskGo.Options // BinaryArtifactName is the name for the binary build artifact. BinaryArtifactName string @@ -22,24 +25,33 @@ type Options struct { // CrossCompileTargetPlatforms are the names of cross-compile platform targets. // // See `go tool dist list` and the `go` command documentations for more details: - // - https://github.com/golang/go/blob/master/src/cmd/dist/build.go + // - https://github.com/golang/go/blob/master/src/cmd/dist/build.go CrossCompileTargetPlatforms []string // Flags are additional flags to pass to the Go `build` command along with the base Go flags. // // See `go help build` and the Go command documentation for more details: - // - https://golang.org/cmd/go/#hdr-Compile_packages_and_dependencies + // - https://golang.org/cmd/go/#hdr-Compile_packages_and_dependencies Flags []string // OutputDir is the output directory, relative to the project root, for compilation artifacts. OutputDir string - // spellGoOpts are shared Go toolchain commands options. - spellGoOpts []spellGo.Option + // taskGoOpts are shared Go toolchain task options. + taskGoOpts []taskGo.Option } -// Option is a spell incantation option for the Go toolchain "build" command. -type Option func(*Options) +// NewOptions creates new task options. +func NewOptions(opts ...Option) *Options { + opt := &Options{} + for _, o := range opts { + o(opt) + } + + opt.Options = taskGo.NewOptions(opt.taskGoOpts...) + + return opt +} // WithBinaryArtifactName sets the name for the binary build artifact. func WithBinaryArtifactName(name string) Option { @@ -62,10 +74,10 @@ func WithFlags(flags ...string) Option { } } -// WithGoOptions sets shared Go toolchain commands options. -func WithGoOptions(goOpts ...spellGo.Option) Option { +// WithGoOptions sets shared Go toolchain task options. +func WithGoOptions(goOpts ...taskGo.Option) Option { return func(o *Options) { - o.spellGoOpts = append(o.spellGoOpts, goOpts...) + o.taskGoOpts = append(o.taskGoOpts, goOpts...) } } @@ -75,15 +87,3 @@ func WithOutputDir(dir string) Option { o.OutputDir = dir } } - -// NewOptions creates new spell incantation options for the Go toolchain "build" command. -func NewOptions(opts ...Option) *Options { - opt := &Options{} - for _, o := range opts { - o(opt) - } - - opt.Options = spellGo.NewOptions(opt.spellGoOpts...) - - return opt -} diff --git a/pkg/task/golang/golang.go b/pkg/task/golang/golang.go new file mode 100644 index 0000000..d13e660 --- /dev/null +++ b/pkg/task/golang/golang.go @@ -0,0 +1,87 @@ +// Copyright (c) 2019-present Sven Greb +// This source code is licensed under the MIT license found in the LICENSE file. + +// Package golang provides Go toolchain tasks and runner. +// +// See https://golang.org/cmd/go for more details. +package golang + +import ( + "fmt" + "os/exec" + + "github.com/magefile/mage/sh" + glFS "github.com/svengreb/golib/pkg/io/fs" + + "github.com/svengreb/wand/pkg/task" +) + +// Runner is a task runner for the Go toolchain. +type Runner struct { + opts *RunnerOptions +} + +// FilePath returns the path to the runner executable. +func (r *Runner) FilePath() string { + return r.opts.Exec +} + +// Handles returns the supported task kind. +func (r *Runner) Handles() task.Kind { + return task.KindExec +} + +// Run runs the command. +// It returns an error of type *task.ErrRunner when any error occurs during the command execution. +func (r *Runner) Run(t task.Task) error { + tExec, ok := t.(task.Exec) + if t.Kind() != task.KindExec || !ok { + return &task.ErrRunner{ + Err: fmt.Errorf("expected %q but got %q", r.Handles(), t.Kind()), + Kind: task.ErrUnsupportedTaskKind, + } + } + + runFn := sh.RunWithV + if r.opts.Quiet { + runFn = sh.RunWith + } + + for k, v := range tExec.Env() { + r.opts.Env[k] = v + } + + return runFn(r.opts.Env, r.opts.Exec, tExec.BuildParams()...) +} + +// Validate validates the command executable. +// It returns an error of type *task.ErrRunner when the executable does not exist and when it is also not available in +// the executable search path(s) of the current environment. +func (r *Runner) Validate() error { + // Check if the executable exists,... + execExits, fsErr := glFS.RegularFileExists(r.opts.Exec) + if fsErr != nil { + return &task.ErrRunner{ + Err: fmt.Errorf("command runner %q: %w", RunnerName, fsErr), + Kind: task.ErrRunnerValidation, + } + } + // ...otherwise try to look up the executable search path(s). + if !execExits { + path, pathErr := exec.LookPath(r.opts.Exec) + if pathErr != nil { + return &task.ErrRunner{ + Err: fmt.Errorf("command runner %q: %q not found in PATH: %w", RunnerName, r.opts.Exec, pathErr), + Kind: task.ErrRunnerValidation, + } + } + r.opts.Exec = path + } + + return nil +} + +// NewRunner creates a new Go toolchain command runner. +func NewRunner(opts ...RunnerOption) *Runner { + return &Runner{opts: NewRunnerOptions(opts...)} +} diff --git a/pkg/spell/golang/mixins.go b/pkg/task/golang/mixins.go similarity index 69% rename from pkg/spell/golang/mixins.go rename to pkg/task/golang/mixins.go index 8e312d2..d3ecd11 100644 --- a/pkg/spell/golang/mixins.go +++ b/pkg/task/golang/mixins.go @@ -8,29 +8,31 @@ import ( "fmt" "github.com/svengreb/wand/pkg/project" - "github.com/svengreb/wand/pkg/spell" + "github.com/svengreb/wand/pkg/task" ) -// MixinImproveDebugging is a spell.Mixin for golang.Options to add linker flags to improve the debugging of binary +// MixinImproveDebugging is a task.Mixin for golang.Options to add linker flags to improve the debugging of binary // artifacts. -// This includes the disabling of inlining and all compiler optimizations tp improve the compatibility for debuggers. +// This includes the disabling of inlining and all compiler optimizations to improve the compatibility for debuggers. // // Note that this mixin adds the "all" prefix for "-gcflags" parameters to make sure all packages are affected. // If you disabled the "all" prefix on purpose you need to handle this conflict on your own, e.g. by creating more than // one binary artifact each with different build options. // -// Run `go help build`, `go tool compile -help` for the documentation of supported flags. -// See the official Go documentations and other resources for more details: +// See `go help build` and `go tool compile -help` for the documentation of supported flags. +// +// References +// // - https://golang.org/cmd/link // - https://golang.org/cmd/go/#hdr-Compile_packages_and_dependencies // - https://golang.org/doc/go1.10#build type MixinImproveDebugging struct{} -// Apply applies the mixin to the given spell.Options that must be of type golang.Options. -func (m MixinImproveDebugging) Apply(so spell.Options) (spell.Options, error) { +// Apply applies the mixin to the task options. +func (m MixinImproveDebugging) Apply(so task.Options) (task.Options, error) { goOpts, ok := so.(*Options) if !ok { - return nil, errors.New("unsupported spell options type") + return nil, &task.ErrTask{Kind: task.ErrUnsupportedTaskOptions} } // Make sure the flags are applied to all packages. @@ -45,7 +47,7 @@ func (m MixinImproveDebugging) Apply(so spell.Options) (spell.Options, error) { return goOpts, nil } -// MixinImproveEscapeAnalysis is a spell.Mixin for golang.Options to add linker flags to improve the escape analysis of +// MixinImproveEscapeAnalysis is a task.Mixin for golang.Options to add linker flags to improve the escape analysis of // binary artifacts. // Enables 2/4 level for reporting verbosity, higher levels are too noisy and rarely necessary. // @@ -54,18 +56,20 @@ func (m MixinImproveDebugging) Apply(so spell.Options) (spell.Options, error) { // on purpose you need to handle this conflict on your own, e.g. by creating more than one binary artifact each with // different build options. // -// Run `go help build`, `go tool compile -help` for the documentation of supported flags. -// See the official Go documentations and other resources for more details: +// See `go help build` and `go tool compile -help` for the documentation of supported flags. +// +// References +// // - https://golang.org/cmd/link // - https://golang.org/cmd/go/#hdr-Compile_packages_and_dependencies // - https://golang.org/doc/go1.10#build type MixinImproveEscapeAnalysis struct{} -// Apply applies the mixin to the given spell.Options that must be of type golang.Options. -func (m MixinImproveEscapeAnalysis) Apply(so spell.Options) (spell.Options, error) { +// Apply applies the mixin to the task options. +func (m MixinImproveEscapeAnalysis) Apply(so task.Options) (task.Options, error) { goOpts, ok := so.(*Options) if !ok { - return nil, errors.New("unsupported spell options type") + return nil, &task.ErrTask{Kind: task.ErrUnsupportedTaskOptions} } // Only enable for the target package, otherwise includes reports for (traverse) dependencies as well. @@ -79,7 +83,7 @@ func (m MixinImproveEscapeAnalysis) Apply(so spell.Options) (spell.Options, erro return goOpts, nil } -// MixinInjectBuildTimeVariableValues is a spell.Mixin for golang.Options to inject build-time values through the `-X` +// MixinInjectBuildTimeVariableValues is a task.Mixin for golang.Options to inject build-time values through the `-X` // linker flags to populate e.g. application metadata variables. // // See `go help build`, `go tool compile -help` and the `go` command documentations for more details: @@ -93,36 +97,39 @@ type MixinInjectBuildTimeVariableValues struct { // The value is the actual value that will be assigned to the variable, e.g. the application version. Data map[string]string - // GoModuleID is the ID of the target Go module to inject the given key/value pairs into. - GoModuleID *project.GoModuleID + // GoModule is the identifier of the target Go module to inject the given key/value pairs into. + GoModule *project.GoModuleID } -// Apply applies the mixin to the given spell.Options that must be of type golang.Options. -func (m MixinInjectBuildTimeVariableValues) Apply(so spell.Options) (spell.Options, error) { - if m.GoModuleID == nil { - return nil, errors.New("module path is required") +// Apply applies the mixin to the task options. +func (m MixinInjectBuildTimeVariableValues) Apply(so task.Options) (task.Options, error) { + if m.GoModule == nil { + return nil, &task.ErrTask{ + Err: errors.New("module path is required"), + Kind: task.ErrInvalidTaskOpts, + } } goOpts, ok := so.(*Options) if !ok { - return nil, errors.New("unsupported spell options type") + return nil, &task.ErrTask{Kind: task.ErrUnsupportedTaskOptions} } if m.Data != nil { for k, v := range m.Data { - goOpts.LdFlags = append(goOpts.LdFlags, fmt.Sprintf("-X %s/%s=%s", m.GoModuleID.Path, k, v)) + goOpts.LdFlags = append(goOpts.LdFlags, fmt.Sprintf("-X %s/%s=%s", m.GoModule.Path, k, v)) } } return goOpts, nil } -// MixinStripDebugMetadata is a spell.Mixin for golang.Options to add linker flags to strip debug information from +// MixinStripDebugMetadata is a task.Mixin for golang.Options to add linker flags to strip debug information from // binary artifacts. // This includes DWARF tables needed for debuggers, but keeps annotations needed for stack traces so panics are still // readable. It also shrinks the file size and memory overhead as well as reducing the chance for possible security // related problems due to enabled development features or debug information leaks. // -// Run `go tool compile -help`, `go doc cmd/link` for the documentation of supported flags. +// See `go tool compile -help` and `go doc cmd/link` for the documentation of supported flags. // // See the official Go documentations and other resources for more details: // - https://golang.org/cmd/link @@ -143,11 +150,11 @@ func (m MixinInjectBuildTimeVariableValues) Apply(so spell.Options) (spell.Optio // afterwards. type MixinStripDebugMetadata struct{} -// Apply applies the mixin to the given spell.Options that must be of type golang.Options. -func (m MixinStripDebugMetadata) Apply(so spell.Options) (spell.Options, error) { +// Apply applies the mixin to the task options. +func (m MixinStripDebugMetadata) Apply(so task.Options) (task.Options, error) { goOpts, ok := so.(*Options) if !ok { - return nil, errors.New("unsupported spell options type") + return nil, &task.ErrTask{Kind: task.ErrUnsupportedTaskOptions} } // Make sure the flags are applied to all packages. diff --git a/pkg/spell/golang/options.go b/pkg/task/golang/options.go similarity index 60% rename from pkg/spell/golang/options.go rename to pkg/task/golang/options.go index 783d31d..82bc0b6 100644 --- a/pkg/spell/golang/options.go +++ b/pkg/task/golang/options.go @@ -4,18 +4,48 @@ package golang import ( + "fmt" + "strings" + "github.com/imdario/mergo" + "github.com/magefile/mage/mg" - "github.com/svengreb/wand/pkg/spell" + "github.com/svengreb/wand/pkg/task" ) -// Options are shared Go toolchain commands options. +const ( + // DefaultEnvVarGO111MODULE is the default environment variable name to toggle the Go 1.11 module mode. + DefaultEnvVarGO111MODULE = "GO111MODULE" + + // DefaultEnvVarGOBIN is the default environment variable name for the Go binary executable search path. + DefaultEnvVarGOBIN = "GOBIN" + + // DefaultEnvVarGOFLAGS is the default environment variable name for Go tool flags. + DefaultEnvVarGOFLAGS = "GOFLAGS" + + // DefaultEnvVarGOPATH is the default environment variable name for the Go path. + DefaultEnvVarGOPATH = "GOPATH" + + // DefaultGOBINSubDirName is the default name of the subdirectory for the Go executables within DefaultEnvVarGOBIN. + DefaultGOBINSubDirName = "bin" + + // RunnerName is the name of the runner. + RunnerName = "golang" +) + +// DefaultRunnerExec is the default path to the runner executable. +var DefaultRunnerExec = mg.GoCmd() + +// Option is a shared Go toolchain task option. +type Option func(*Options) + +// Options are shared Go toolchain task options. // // References // -// - https://golang.org/cmd/go/#hdr-Compile_packages_and_dependencies +// - https://golang.org/cmd/go/#hdr-Compile_packages_and_dependencies type Options struct { - // AsmFlags are the arguments for the `-asmflags` flag that are passed to each `go tool asm` invocation. + // AsmFlags are arguments for the `-asmflags` flag that are passed to each `go tool asm` invocation. // // See `go help buildmode`, `go help build` and the `go` command documentations for more details: // - https://golang.org/cmd/go/#hdr-Compile_packages_and_dependencies @@ -37,7 +67,7 @@ type Options struct { // - https://golang.org/doc/go1.13#go-command EnableTrimPath bool - // Env are Go toolchain command specific environment variables. + // Env is the Go toolchain specific environment. Env map[string]string // Flags are additional flags passed to the `go` command. @@ -46,7 +76,7 @@ type Options struct { // - https://golang.org/cmd/go/#hdr-Compile_packages_and_dependencies Flags []string - // FlagsPrefixAll indicates whether the values of `-asmflags` and `-gcflags` should be prefixed with the `all=` + // FlagsPrefixAll indicates whether values of `-asmflags` and `-gcflags` should be prefixed with the `all=` // pattern in order to apply to all packages. // As of Go 1.10 (https://golang.org/doc/go1.10#build), the value specified to `-asmflags` and `-gcflags` are only // applied to the current package, therefore the `all=` pattern is used to apply the flag to all packages. @@ -56,69 +86,138 @@ type Options struct { // - https://golang.org/doc/go1.10#build FlagsPrefixAll bool - // GcFlags are the arguments for the `-gcflags` flag that are passed to each `go tool compile` invocation. + // GcFlags are arguments for the `-gcflags` flag that are passed to each `go tool compile` invocation. // // See `go help buildmode`, `go help build` and the `go` command documentations for more details: // - https://golang.org/cmd/go/#hdr-Build_modes // - https://golang.org/cmd/go/#hdr-Compile_packages_and_dependencies GcFlags []string - // LdFlags are the arguments for the `-ldflags` flag that are passed to each `go tool link` invocation. + // LdFlags are arguments for the `-ldflags` flag that are passed to each `go tool link` invocation. // // See `go help buildmode`, `go help build` and the `go` command documentations for more details: // - https://golang.org/cmd/go/#hdr-Build_modes // - https://golang.org/cmd/go/#hdr-Compile_packages_and_dependencies LdFlags []string - // mixins are spell mixins that can be applied by option consumers. - mixins []spell.Mixin + // mixins are parameter mixins that can be applied by option consumers. + mixins []task.Mixin - // Tags are the Go tags. + // Tags are Go tags. // // See `go help build` and the `go` command documentations for more details: // - https://golang.org/cmd/go/#hdr-Compile_packages_and_dependencies Tags []string } -// Option is a pencil option. -type Option func(*Options) +// RunnerOption is a runner option. +type RunnerOption func(*RunnerOptions) -// WithAsmFlags sets flags to pass on each `go tool asm` invocation. -// -// See `go help buildmode`, `go help build` and the `go` command documentations for more details: -// - https://golang.org/cmd/go/#hdr-Build_modes -// - https://golang.org/cmd/go/#hdr-Compile_packages_and_dependencies -func WithAsmFlags(asmFlags ...string) Option { - return func(o *Options) { - o.AsmFlags = append(o.AsmFlags, asmFlags...) +// RunnerOptions are runner options. +type RunnerOptions struct { + // Env is the runner specific environment. + Env map[string]string + + // Exec is the name or path of the runner command executable. + Exec string + + // Quiet indicates whether the runner output should be minimal. + Quiet bool +} + +// BuildGoOptions builds shared Go toolchain options. +func BuildGoOptions(opts ...Option) []string { + opt := NewOptions(opts...) + var args []string + + if len(opt.Tags) > 0 { + args = append(args, fmt.Sprintf("-tags='%s'", strings.Join(opt.Tags, " "))) + } + + if opt.EnableRaceDetector { + args = append(args, "-race") + } + + if opt.EnableTrimPath { + args = append(args, "-trimpath") + } + + if len(opt.AsmFlags) > 0 { + flag := "-asmflags" + if opt.FlagsPrefixAll { + flag = fmt.Sprintf("%s=all", flag) + } + args = append(args, fmt.Sprintf("%s=%s", flag, strings.Join(opt.AsmFlags, " "))) + } + + if len(opt.GcFlags) > 0 { + flag := "-gcflags" + if opt.FlagsPrefixAll { + flag = fmt.Sprintf("%s=all", flag) + } + args = append(args, fmt.Sprintf("%s=%s", flag, strings.Join(opt.GcFlags, " "))) + } + + if len(opt.LdFlags) > 0 { + flag := "-ldflags" + if opt.FlagsPrefixAll { + flag = fmt.Sprintf("%s=all", flag) + } + args = append(args, fmt.Sprintf("%s=%s", flag, strings.Join(opt.LdFlags, " "))) + } + + if len(opt.Flags) > 0 { + args = append(args, opt.Flags...) } + + return args } -// WithRaceDetector indicates whether the race detector should be enabled. -// -// See `go help build` and the `go` command documentations for more details: -// - https://golang.org/cmd/go/#hdr-Compile_packages_and_dependencies -// - https://golang.org/cmd/go/#hdr-Testing_flags -func WithRaceDetector(enableRaceDetector bool) Option { - return func(o *Options) { - o.EnableRaceDetector = enableRaceDetector +// NewOptions creates new shared Go toolchain options. +func NewOptions(opts ...Option) *Options { + opt := &Options{ + Env: make(map[string]string), + } + for _, o := range opts { + o(opt) + } + + for _, m := range opt.mixins { + mixedOpt, mixErr := m.Apply(opt) + if mixErr != nil { + continue + } + _ = mergo.Merge(opt, mixedOpt) } + + return opt } -// WithTrimmedPath indicates whether all file system paths should be removed from the resulting executable. -// This is done by adding compiler and linker flags to remove the absolute path to the project root directory from -// binary artifacts. +// NewRunnerOptions creates new runner options. +func NewRunnerOptions(opts ...RunnerOption) *RunnerOptions { + opt := &RunnerOptions{ + Env: make(map[string]string), + Exec: DefaultRunnerExec, + } + for _, o := range opts { + o(opt) + } + + return opt +} + +// WithAsmFlags sets flags to pass on each `go tool asm` invocation. // -// See `go help build` and the `go` command documentations for more details: +// See `go help buildmode`, `go help build` and the `go` command documentations for more details: +// - https://golang.org/cmd/go/#hdr-Build_modes // - https://golang.org/cmd/go/#hdr-Compile_packages_and_dependencies -// - https://golang.org/doc/go1.13#go-command -func WithTrimmedPath(enableTrimPath bool) Option { +func WithAsmFlags(asmFlags ...string) Option { return func(o *Options) { - o.EnableTrimPath = enableTrimPath + o.AsmFlags = append(o.AsmFlags, asmFlags...) } } -// WithEnv adds or overrides Go toolchain command specific environment variables. +// WithEnv sets the runner specific environment. func WithEnv(env map[string]string) Option { return func(o *Options) { for k, v := range env { @@ -127,7 +226,7 @@ func WithEnv(env map[string]string) Option { } } -// WithFlags sets additional Go toolchain command flags. +// WithFlags sets additional Go toolchain flags. // // See `go help build` and the `go` command documentations for more details: // - https://golang.org/cmd/go/#hdr-Compile_packages_and_dependencies @@ -137,8 +236,8 @@ func WithFlags(flags ...string) Option { } } -// WithFlagsPrefixAll indicates whether the values of `-asmflags` and `-gcflags` should be prefixed with the `all=` pattern -// in order to apply to all packages. +// WithFlagsPrefixAll indicates whether the values of `-asmflags` and `-gcflags` should be prefixed with the `all=` +// pattern in order to apply to all packages. // As of Go 1.10 (https://golang.org/doc/go1.10#build), the value specified to `-asmflags` and `-gcflags` are only // applied to the current package, therefore the `all=` pattern is used to apply the flag to all packages. // @@ -173,39 +272,65 @@ func WithLdFlags(ldFlags ...string) Option { } } -// WithMixins sets spell mixins that can be applied by option consumers. -func WithMixins(mixins ...spell.Mixin) Option { +// WithMixins sets parameter mixins that can be applied by option consumers. +func WithMixins(mixins ...task.Mixin) Option { return func(o *Options) { o.mixins = append(o.mixins, mixins...) } } -// WithTags sets Go toolchain tags. +// WithRaceDetector indicates whether the race detector should be enabled. // // See `go help build` and the `go` command documentations for more details: // - https://golang.org/cmd/go/#hdr-Compile_packages_and_dependencies -func WithTags(tags ...string) Option { +// - https://golang.org/cmd/go/#hdr-Testing_flags +func WithRaceDetector(enableRaceDetector bool) Option { return func(o *Options) { - o.Tags = append(o.Tags, tags...) + o.EnableRaceDetector = enableRaceDetector } } -// NewOptions creates new shared Go toolchain command options. -func NewOptions(opts ...Option) *Options { - opt := &Options{ - Env: make(map[string]string), +// WithRunnerEnv sets the runner specific environment. +func WithRunnerEnv(env map[string]string) RunnerOption { + return func(o *RunnerOptions) { + o.Env = env } - for _, o := range opts { - o(opt) +} + +// WithRunnerExec sets the name or path of the runner command executable. +// Defaults to DefaultRunnerExec. +func WithRunnerExec(nameOrPath string) RunnerOption { + return func(o *RunnerOptions) { + o.Exec = nameOrPath } +} - for _, m := range opt.mixins { - mixedOpt, mixErr := m.Apply(opt) - if mixErr != nil { - continue - } - _ = mergo.Merge(opt, mixedOpt) +// WithRunnerQuiet indicates whether the runner output should be minimal. +func WithRunnerQuiet(quiet bool) RunnerOption { + return func(o *RunnerOptions) { + o.Quiet = quiet } +} - return opt +// WithTags sets Go toolchain tags. +// +// See `go help build` and the `go` command documentations for more details: +// - https://golang.org/cmd/go/#hdr-Compile_packages_and_dependencies +func WithTags(tags ...string) Option { + return func(o *Options) { + o.Tags = append(o.Tags, tags...) + } +} + +// WithTrimmedPath indicates whether all file system paths should be removed from the resulting executable. +// This is done by adding compiler and linker flags to remove the absolute path to the project root directory from +// binary artifacts. +// +// See `go help build` and the `go` command documentations for more details: +// - https://golang.org/cmd/go/#hdr-Compile_packages_and_dependencies +// - https://golang.org/doc/go1.13#go-command +func WithTrimmedPath(enableTrimPath bool) Option { + return func(o *Options) { + o.EnableTrimPath = enableTrimPath + } } diff --git a/pkg/spell/golang/test/options.go b/pkg/task/golang/test/options.go similarity index 91% rename from pkg/spell/golang/test/options.go rename to pkg/task/golang/test/options.go index 73c1f4f..98d52eb 100644 --- a/pkg/spell/golang/test/options.go +++ b/pkg/task/golang/test/options.go @@ -4,11 +4,11 @@ package test import ( - spellGo "github.com/svengreb/wand/pkg/spell/golang" + taskGo "github.com/svengreb/wand/pkg/task/golang" ) const ( - // DefaultIntegrationTestTag is the default name of tag for integration tests. + // DefaultIntegrationTestTag is the default tag name for integration tests. DefaultIntegrationTestTag = "integration" // DefaultBlockProfileOutputFileName is the default file name for the Goroutine blocking profile file. @@ -33,9 +33,12 @@ const ( DefaultTraceProfileOutputFileName = "trace_profile.out" ) -// Options are spell incantation options for the Go toolchain "test" command. +// Option is a task option. +type Option func(*Options) + +// Options are parameter build options. type Options struct { - *spellGo.Options + *taskGo.Options // BlockProfileOutputFileName is the file name for the Goroutine blocking profile file. // @@ -79,11 +82,11 @@ type Options struct { // - https://golang.org/cmd/go/#hdr-Testing_flags EnableCPUProfile bool - // EnableMemProfile indicates whether the tests should be run with memory profiling. + // EnableMemoryProfile indicates whether the tests should be run with memory profiling. // // See `go help test` and the `go` command documentations for more details: // - https://golang.org/cmd/go/#hdr-Testing_flags - EnableMemProfile bool + EnableMemoryProfile bool // EnableMutexProfile indicates whether the tests should be run with mutex profiling. // @@ -135,8 +138,8 @@ type Options struct { // - https://golang.org/cmd/go/#hdr-Testing_flags Pkgs []string - // spellGoOpts are shared Go toolchain command options. - spellGoOpts []spellGo.Option + // taskGoOpts are shared Go toolchain task options. + taskGoOpts []taskGo.Option // TraceProfileOutputFileName is the file name for the execution trace profile file. // @@ -145,66 +148,85 @@ type Options struct { TraceProfileOutputFileName string } -// Option is a spell incantation option for the Go toolchain "test" command. -type Option func(*Options) +// NewOptions creates new task options. +func NewOptions(opts ...Option) *Options { + opt := &Options{ + BlockProfileOutputFileName: DefaultBlockProfileOutputFileName, + CoverageProfileOutputFileName: DefaultCoverageOutputFileName, + CPUProfileOutputFileName: DefaultCPUProfileOutputFileName, + MemoryProfileOutputFileName: DefaultMemoryProfileOutputFileName, + MutexProfileOutputFileName: DefaultMutexProfileOutputFileName, + TraceProfileOutputFileName: DefaultTraceProfileOutputFileName, + } + for _, o := range opts { + o(opt) + } -// WithBlockProfileOutputFileName sets the file name for the Goroutine blocking profile file. + opt.Options = taskGo.NewOptions(opt.taskGoOpts...) + + return opt +} + +// WithBlockProfile indicates whether the tests should be run with a Goroutine blocking profiling. // // See `go help test` and the `go` command documentations for more details: // - https://golang.org/cmd/go/#hdr-Testing_flags -func WithBlockProfileOutputFileName(blockProfileOutputFileName string) Option { +func WithBlockProfile(withBlockProfile bool) Option { return func(o *Options) { - o.BlockProfileOutputFileName = blockProfileOutputFileName + o.EnableBlockProfile = withBlockProfile } } -// WithCoverageProfileOutputFileName sets the file name for the test coverage profile file. +// WithBlockProfileOutputFileName sets the file name for the Goroutine blocking profile file. +// Defaults to DefaultBlockProfileOutputFileName // // See `go help test` and the `go` command documentations for more details: // - https://golang.org/cmd/go/#hdr-Testing_flags -func WithCoverageProfileOutputFileName(coverageProfileOutputFileName string) Option { +func WithBlockProfileOutputFileName(blockProfileOutputFileName string) Option { return func(o *Options) { - o.CoverageProfileOutputFileName = coverageProfileOutputFileName + o.BlockProfileOutputFileName = blockProfileOutputFileName } } -// WithCPUProfileOutputFileName sets the file name for the CPU profile file. +// WithCoverageProfile indicates whether the tests should be run with coverage profiling. // // See `go help test` and the `go` command documentations for more details: // - https://golang.org/cmd/go/#hdr-Testing_flags -func WithCPUProfileOutputFileName(cpuProfileOutputFileName string) Option { +func WithCoverageProfile(withCoverageProfile bool) Option { return func(o *Options) { - o.CPUProfileOutputFileName = cpuProfileOutputFileName + o.EnableCoverageProfile = withCoverageProfile } } -// WithBlockProfile indicates whether the tests should be run with a Goroutine blocking profiling. +// WithCoverageProfileOutputFileName sets the file name for the test coverage profile file. +// Defaults to DefaultCoverageOutputFileName. // // See `go help test` and the `go` command documentations for more details: // - https://golang.org/cmd/go/#hdr-Testing_flags -func WithBlockProfile(withBlockProfile bool) Option { +func WithCoverageProfileOutputFileName(coverageProfileOutputFileName string) Option { return func(o *Options) { - o.EnableBlockProfile = withBlockProfile + o.CoverageProfileOutputFileName = coverageProfileOutputFileName } } -// WithCoverageProfile indicates whether the tests should be run with coverage profiling. +// WithCPUProfile indicates whether the tests should be run with CPU profiling. // // See `go help test` and the `go` command documentations for more details: // - https://golang.org/cmd/go/#hdr-Testing_flags -func WithCoverageProfile(withCoverageProfile bool) Option { +func WithCPUProfile(withCPUProfile bool) Option { return func(o *Options) { - o.EnableCoverageProfile = withCoverageProfile + o.EnableCPUProfile = withCPUProfile } } -// WithCPUProfile indicates whether the tests should be run with CPU profiling. +// WithCPUProfileOutputFileName sets the file name for the CPU profile file. +// Defaults to DefaultCPUProfileOutputFileName. // // See `go help test` and the `go` command documentations for more details: // - https://golang.org/cmd/go/#hdr-Testing_flags -func WithCPUProfile(withCPUProfile bool) Option { +func WithCPUProfileOutputFileName(cpuProfileOutputFileName string) Option { return func(o *Options) { - o.EnableCPUProfile = withCPUProfile + o.CPUProfileOutputFileName = cpuProfileOutputFileName } } @@ -219,24 +241,25 @@ func WithFlags(flags ...string) Option { } } -// WithGoOptions sets shared Go toolchain command options. -func WithGoOptions(goOpts ...spellGo.Option) Option { +// WithGoOptions sets shared Go toolchain task options. +func WithGoOptions(goOpts ...taskGo.Option) Option { return func(o *Options) { - o.spellGoOpts = append(o.spellGoOpts, goOpts...) + o.taskGoOpts = append(o.taskGoOpts, goOpts...) } } -// WithMemProfile indicates whether the tests should be run with memory profiling. +// WithMemoryProfile indicates whether the tests should be run with memory profiling. // // See `go help test` and the `go` command documentations for more details: // - https://golang.org/cmd/go/#hdr-Testing_flags -func WithMemProfile(withMemProfile bool) Option { +func WithMemoryProfile(withMemoryProfile bool) Option { return func(o *Options) { - o.EnableMemProfile = withMemProfile + o.EnableMemoryProfile = withMemoryProfile } } // WithMemoryProfileOutputFileName sets the file name for the memory profile file. +// Defaults to DefaultMemoryProfileOutputFileName. // // See `go help test` and the `go` command documentations for more details: // - https://golang.org/cmd/go/#hdr-Testing_flags @@ -257,6 +280,7 @@ func WithMutexProfile(withMutexProfile bool) Option { } // WithMutexProfileOutputFileName sets the file name for the mutex profile file. +// Defaults to DefaultMutexProfileOutputFileName. // // See `go help test` and the `go` command documentations for more details: // - https://golang.org/cmd/go/#hdr-Testing_flags @@ -266,24 +290,25 @@ func WithMutexProfileOutputFileName(mutexProfileOutputFileName string) Option { } } -// WithOutputDir sets the output directory, relative to the project root, for reports like coverage or benchmark -// profiles. +// WithoutCache indicates whether the tests should be run without test caching that is enabled by Go by default. // // See `go help test` and the `go` command documentations for more details: // - https://golang.org/cmd/go/#hdr-Testing_flags -func WithOutputDir(outputDir string) Option { +func WithoutCache(withoutCache bool) Option { return func(o *Options) { - o.OutputDir = outputDir + o.DisableCache = withoutCache } } -// WithoutCache indicates whether the tests should be run without test caching that is enabled by Go by default. +// WithOutputDir sets the output directory, relative to the project root, for reports like coverage or benchmark +// profiles. +// Defaults to DefaultOutputDirName. // // See `go help test` and the `go` command documentations for more details: // - https://golang.org/cmd/go/#hdr-Testing_flags -func WithoutCache(withoutCache bool) Option { +func WithOutputDir(outputDir string) Option { return func(o *Options) { - o.DisableCache = withoutCache + o.OutputDir = outputDir } } @@ -308,6 +333,7 @@ func WithTraceProfile(withTraceProfile bool) Option { } // WithTraceProfileOutputFileName sets the file name for the execution trace profile file. +// Defaults to DefaultTraceProfileOutputFileName. // // See `go help test` and the `go` command documentations for more details: // - https://golang.org/cmd/go/#hdr-Testing_flags @@ -326,22 +352,3 @@ func WithVerboseOutput(withVerboseOutput bool) Option { o.EnableVerboseOutput = withVerboseOutput } } - -// NewOptions creates new spell incantation options for the Go toolchain "test" command. -func NewOptions(opts ...Option) *Options { - opt := &Options{ - BlockProfileOutputFileName: DefaultBlockProfileOutputFileName, - CoverageProfileOutputFileName: DefaultCoverageOutputFileName, - CPUProfileOutputFileName: DefaultCPUProfileOutputFileName, - MemoryProfileOutputFileName: DefaultMemoryProfileOutputFileName, - MutexProfileOutputFileName: DefaultMutexProfileOutputFileName, - TraceProfileOutputFileName: DefaultTraceProfileOutputFileName, - } - for _, o := range opts { - o(opt) - } - - opt.Options = spellGo.NewOptions(opt.spellGoOpts...) - - return opt -} diff --git a/pkg/task/golang/test/test.go b/pkg/task/golang/test/test.go new file mode 100644 index 0000000..e0687ee --- /dev/null +++ b/pkg/task/golang/test/test.go @@ -0,0 +1,128 @@ +// Copyright (c) 2019-present Sven Greb +// This source code is licensed under the MIT license found in the LICENSE file. + +// Package test provides a task for the Go toolchain "test" command. +package test + +import ( + "fmt" + "path/filepath" + + "github.com/svengreb/wand" + "github.com/svengreb/wand/pkg/app" + "github.com/svengreb/wand/pkg/task" + taskGo "github.com/svengreb/wand/pkg/task/golang" +) + +// Task is a task for the Go toolchain "test" command. +type Task struct { + ac app.Config + opts *Options +} + +// BuildParams builds the parameters. +// Note that configured flags are applied after the "GOFLAGS" environment variable and could overwrite already defined +// flags. In addition, the output directory for test artifacts like profiles and reports must exist or must be be +// created before, otherwise the "test" Go toolchain command will fail to run. +// +// See `go help environment`, `go help env` and the `go` command documentations for more details: +// - https://golang.org/cmd/go/#hdr-Environment_variables +func (t *Task) BuildParams() []string { + params := []string{"test"} + + params = append(params, taskGo.BuildGoOptions(t.opts.taskGoOpts...)...) + + if t.opts.EnableVerboseOutput { + params = append(params, "-v") + } + + if t.opts.DisableCache { + params = append(params, "-count=1") + } + + if t.opts.EnableBlockProfile { + params = append(params, + fmt.Sprintf( + "-blockprofile=%s", + filepath.Join(t.opts.OutputDir, t.opts.BlockProfileOutputFileName), + ), + ) + } + + if t.opts.EnableCoverageProfile { + params = append(params, + fmt.Sprintf( + "-coverprofile=%s", + filepath.Join(t.opts.OutputDir, t.opts.CoverageProfileOutputFileName), + ), + ) + } + + if t.opts.EnableCPUProfile { + params = append(params, + fmt.Sprintf("-cpuprofile=%s", + filepath.Join(t.opts.OutputDir, t.opts.CPUProfileOutputFileName), + ), + ) + } + + if t.opts.EnableMemoryProfile { + params = append(params, + fmt.Sprintf("-memprofile=%s", + filepath.Join(t.opts.OutputDir, t.opts.MemoryProfileOutputFileName), + ), + ) + } + + if t.opts.EnableMutexProfile { + params = append(params, + fmt.Sprintf("-mutexprofile=%s", + filepath.Join(t.opts.OutputDir, t.opts.MutexProfileOutputFileName), + ), + ) + } + + if t.opts.EnableTraceProfile { + params = append(params, + fmt.Sprintf("-trace=%s", + filepath.Join(t.opts.OutputDir, t.opts.TraceProfileOutputFileName), + ), + ) + } + + if len(t.opts.Flags) > 0 { + params = append(params, t.opts.Flags...) + } + + params = append(params, t.opts.Pkgs...) + + return params +} + +// Env returns the task specific environment. +func (t *Task) Env() map[string]string { + return t.opts.Env +} + +// Kind returns the task kind. +func (t *Task) Kind() task.Kind { + return task.KindExec +} + +// Options returns the task options. +func (t *Task) Options() task.Options { + return *t.opts +} + +// New creates a new task for the Go toolchain "test" command. +//nolint:gocritic // The app.Config struct is passed as value by design to ensure immutability. +func New(wand wand.Wand, ac app.Config, opts ...Option) *Task { + opt := NewOptions(opts...) + + // Store test profiles and reports within the application specific subdirectory. + if opt.OutputDir == "" { + opt.OutputDir = filepath.Join(ac.BaseOutputDir, DefaultOutputDirName) + } + + return &Task{ac: ac, opts: opt} +} diff --git a/pkg/task/golangcilint/golangcilint.go b/pkg/task/golangcilint/golangcilint.go new file mode 100644 index 0000000..bf720de --- /dev/null +++ b/pkg/task/golangcilint/golangcilint.go @@ -0,0 +1,60 @@ +// Copyright (c) 2019-present Sven Greb +// This source code is licensed under the MIT license found in the LICENSE file. + +// Package golangcilint provides a task for the "github.com/golangci/golangci-lint/cmd/golangci-lint" Go module command. +// "golangci-lint" a fast, parallel runner for dozens of Go linters that uses caching, supports YAML configurations and +// has integrations with all major IDEs. +// +// See https://pkg.go.dev/github.com/golangci/golangci-lint for more details about "golangci-lint". +// The source code of "golangci-lint" is available at +// https://github.com/golangci/golangci-lint/tree/master/cmd/golangci-lint. +package golangcilint + +import ( + "github.com/svengreb/wand" + "github.com/svengreb/wand/pkg/app" + "github.com/svengreb/wand/pkg/project" + "github.com/svengreb/wand/pkg/task" +) + +// Task is a task for the "github.com/golangci/golangci-lint/cmd/golangci-lint" Go module command. +type Task struct { + ac app.Config + opts *Options +} + +// BuildParams builds the parameters. +func (t *Task) BuildParams() []string { + return t.opts.args +} + +// Env returns the task specific environment. +func (t *Task) Env() map[string]string { + return t.opts.env +} + +// ID returns the identifier of the Go module. +func (t *Task) ID() *project.GoModuleID { + return t.opts.goModule +} + +// Kind returns the task kind. +func (t *Task) Kind() task.Kind { + return task.KindGoModule +} + +// Options returns the task options. +func (t *Task) Options() task.Options { + return *t.opts +} + +// New creates a new task for the "github.com/golangci/golangci-lint/cmd/golangci-lint" Go module command. +// If no extra arguments are configured, DefaultArgs are passed to the command. +//nolint:gocritic // The app.Config struct is passed as value by design to ensure immutability. +func New(wand wand.Wand, ac app.Config, opts ...Option) (*Task, error) { + opt, optErr := NewOptions(opts...) + if optErr != nil { + return nil, optErr + } + return &Task{ac: ac, opts: opt}, nil +} diff --git a/pkg/spell/golangcilint/options.go b/pkg/task/golangcilint/options.go similarity index 60% rename from pkg/spell/golangcilint/options.go rename to pkg/task/golangcilint/options.go index 867d290..ae4669d 100644 --- a/pkg/spell/golangcilint/options.go +++ b/pkg/task/golangcilint/options.go @@ -8,56 +8,83 @@ import ( "github.com/Masterminds/semver/v3" - "github.com/svengreb/wand/pkg/cast" "github.com/svengreb/wand/pkg/project" + "github.com/svengreb/wand/pkg/task" ) const ( - // DefaultGoModulePath is the default "goimports" module command import path. + // DefaultGoModulePath is the default module import path. DefaultGoModulePath = "github.com/golangci/golangci-lint/cmd/golangci-lint" - // DefaultGoModuleVersion is the default "goimports" module version. + // DefaultGoModuleVersion is the default module version. DefaultGoModuleVersion = "v1.32.0" ) -// DefaultArgs are the default arguments to pass to the "golangci-lint" command. +// DefaultArgs are default arguments passed to the command. var DefaultArgs = []string{"run"} -// Options are spell incantation options for the "github.com/golangci/golangci-lint/cmd/golangci-lint" Go module -// command. +// Option is a task option. +type Option func(*Options) + +// Options are task options. type Options struct { - // args are arguments to pass to the "golangci-lint" command. + // args are arguments passed to the command. args []string - // env are spell incantation specific environment variables. + // env is the task specific environment. env map[string]string - // goModule are partial Go module identifier information. + // goModule is the Go module identifier. goModule *project.GoModuleID // verbose indicates whether the output should be verbose. verbose bool } -// Option is a spell incantation option for the "github.com/golangci/golangci-lint/cmd/golangci-lint" Go module command. -type Option func(*Options) +// NewOptions creates new task options. +func NewOptions(opts ...Option) (*Options, error) { + version, versionErr := semver.NewVersion(DefaultGoModuleVersion) + if versionErr != nil { + return nil, &task.ErrTask{ + Err: fmt.Errorf("parsing default module version %q: %w", DefaultGoModulePath, versionErr), + Kind: task.ErrInvalidTaskOpts, + } + } + + opt := &Options{ + env: make(map[string]string), + goModule: &project.GoModuleID{ + Path: DefaultGoModulePath, + Version: version, + }, + } + for _, o := range opts { + o(opt) + } + + if len(opt.args) == 0 { + opt.args = append(opt.args, DefaultArgs...) + } + + return opt, nil +} -// WithArgs sets additional arguments to pass to the "golangci-lint" module command. -// By default DefaultArgs are passed. +// WithArgs sets additional arguments to pass to the command. +// Defaults to DefaultArgs. func WithArgs(args ...string) Option { return func(o *Options) { o.args = append(o.args, args...) } } -// WithEnv sets the spell incantation specific environment. +// WithEnv sets the task specific environment. func WithEnv(env map[string]string) Option { return func(o *Options) { o.env = env } } -// WithModulePath sets the "golangci-lint" module command import path. +// WithModulePath sets the module import path. // Defaults to DefaultGoModulePath. func WithModulePath(path string) Option { return func(o *Options) { @@ -67,7 +94,7 @@ func WithModulePath(path string) Option { } } -// WithModuleVersion sets the "golangci-lint" module version. +// WithModuleVersion sets the module version. // Defaults to DefaultGoModuleVersion. func WithModuleVersion(version *semver.Version) Option { return func(o *Options) { @@ -83,32 +110,3 @@ func WithVerboseOutput(verbose bool) Option { o.verbose = verbose } } - -// NewOptions creates new spell incantation options for the "github.com/golangci/golangci-lint/cmd/golangci-lint" Go -// module command. -func NewOptions(opts ...Option) (*Options, error) { - version, versionErr := semver.NewVersion(DefaultGoModuleVersion) - if versionErr != nil { - return nil, &cast.ErrCast{ - Err: fmt.Errorf("parsing default module version %q: %w", DefaultGoModulePath, versionErr), - Kind: cast.ErrCasterInvalidOpts, - } - } - - opt := &Options{ - env: make(map[string]string), - goModule: &project.GoModuleID{ - Path: DefaultGoModulePath, - Version: version, - }, - } - for _, o := range opts { - o(opt) - } - - if len(opt.args) == 0 { - opt.args = append(opt.args, DefaultArgs...) - } - - return opt, nil -} diff --git a/pkg/task/gox/gox.go b/pkg/task/gox/gox.go new file mode 100644 index 0000000..dd65dac --- /dev/null +++ b/pkg/task/gox/gox.go @@ -0,0 +1,112 @@ +// Copyright (c) 2019-present Sven Greb +// This source code is licensed under the MIT license found in the LICENSE file. + +// Package gox provides a task for the github.com/mitchellh/gox Go module command. +// "gox" is a dead simple, no frills Go cross compile tool that behaves a lot like the standard Go toolchain "build" +// command. +// +// See https://pkg.go.dev/github.com/mitchellh/gox for more details about "gox". +// The source code of the "gox" is available at https://github.com/mitchellh/gox. +package gox + +import ( + "fmt" + "strings" + + "github.com/svengreb/wand" + "github.com/svengreb/wand/pkg/app" + "github.com/svengreb/wand/pkg/project" + "github.com/svengreb/wand/pkg/task" + taskGo "github.com/svengreb/wand/pkg/task/golang" +) + +// Task is a task for the "github.com/mitchellh/gox" Go module command. +type Task struct { + ac app.Config + opts *Options +} + +// BuildParams builds the parameters. +func (t *Task) BuildParams() []string { + params := taskGo.BuildGoOptions(t.opts.taskGoOpts...) + + // Workaround to allow the usage of the "-trimpath" flag that has been introduced in Go 1.13.0. + // The currently latest version of "gox" does not support the flag yet. + // + // See https://github.com/mitchellh/gox/pull/138 for more details. + for idx, arg := range params { + if arg == "-trimpath" { + params = append(params[:idx], params[idx+1:]...) + // Set the flag via the GOFLAGS environment variable instead. + t.opts.env[taskGo.DefaultEnvVarGOFLAGS] = fmt.Sprintf( + "%s %s -trimpath", + t.opts.Env[taskGo.DefaultEnvVarGOFLAGS], + t.opts.env[taskGo.DefaultEnvVarGOFLAGS], + ) + } + } + + if t.opts.verbose { + params = append(params, "-verbose") + } + + if t.opts.goCmd != "" { + params = append(params, fmt.Sprintf("-gocmd=%s", t.opts.goCmd)) + } + + if len(t.opts.CrossCompileTargetPlatforms) > 0 { + params = append(params, fmt.Sprintf("-osarch=%s", strings.Join(t.opts.CrossCompileTargetPlatforms, " "))) + } + + params = append(params, fmt.Sprintf("--output=%s/%s", t.opts.OutputDir, t.opts.outputTemplate)) + + if len(t.opts.Flags) > 0 { + params = append(params, t.opts.Flags...) + } + + return append(params, t.ac.PkgImportPath) +} + +// Env returns the task specific environment. +func (t *Task) Env() map[string]string { + return t.opts.env +} + +// ID returns the identifier of the Go module. +func (t *Task) ID() *project.GoModuleID { + return t.opts.goModule +} + +// Kind returns the task kind. +func (t *Task) Kind() task.Kind { + return task.KindGoModule +} + +// Options returns the task options. +func (t *Task) Options() task.Options { + return *t.opts +} + +// New creates a new task for the "github.com/mitchellh/gox" Go module command. +//nolint:gocritic // The app.Config struct is passed as value by design to ensure immutability. +func New(wand wand.Wand, ac app.Config, opts ...Option) (*Task, error) { + opt, optErr := NewOptions(opts...) + if optErr != nil { + return nil, optErr + } + + if opt.BinaryArtifactName == "" { + opt.BinaryArtifactName = ac.Name + } + + // Store build artifacts in the application specific subdirectory. + if opt.OutputDir == "" { + opt.OutputDir = ac.BaseOutputDir + } + + if opt.outputTemplate == "" { + opt.outputTemplate = DefaultCrossCompileBinaryNameTemplate(opt.BinaryArtifactName) + } + + return &Task{ac: ac, opts: opt}, nil +} diff --git a/pkg/spell/gox/options.go b/pkg/task/gox/options.go similarity index 64% rename from pkg/spell/gox/options.go rename to pkg/task/gox/options.go index 4cac410..9052c4f 100644 --- a/pkg/spell/gox/options.go +++ b/pkg/task/gox/options.go @@ -8,17 +8,17 @@ import ( "github.com/Masterminds/semver/v3" - "github.com/svengreb/wand/pkg/cast" "github.com/svengreb/wand/pkg/project" - spellGo "github.com/svengreb/wand/pkg/spell/golang" - spellGoBuild "github.com/svengreb/wand/pkg/spell/golang/build" + "github.com/svengreb/wand/pkg/task" + taskGo "github.com/svengreb/wand/pkg/task/golang" + taskGoBuild "github.com/svengreb/wand/pkg/task/golang/build" ) const ( - // DefaultGoModulePath is the default "gox" module command import path. + // DefaultGoModulePath is the default module import path. DefaultGoModulePath = "github.com/mitchellh/gox" - // DefaultGoModuleVersion is the default "gox" module version. + // DefaultGoModuleVersion is the default module version. DefaultGoModuleVersion = "v1.0.1" ) @@ -39,72 +39,102 @@ var ( } ) -// Options are spell incantation options for the "github.com/mitchellh/gox" Go module command. +// Option is a task option. +type Option func(*Options) + +// Options are task options. type Options struct { - *spellGoBuild.Options + *taskGoBuild.Options - // env are spell incantation specific environment variables. + // env is the task specific environment. env map[string]string // goCmd is the path to the Go toolchain executable. goCmd string - // goModule are partial Go module identifier information. + // goModule is the Go module identifier. goModule *project.GoModuleID // outputTemplate is the name template for cross-compile platform targets. outputTemplate string - // spellGoOpts are shared Go toolchain command options. - spellGoOpts []spellGo.Option + // taskGoBuildOpts are Go toolchain "build" command task options. + taskGoBuildOpts []taskGoBuild.Option - // spellGoOpts are options for the Go toolchain "build" command. - spellGoBuildOpts []spellGoBuild.Option + // taskGoOpts are shared Go toolchain task options. + taskGoOpts []taskGo.Option // verbose indicates whether the output should be verbose. verbose bool } -// Option is a spell incantation option for the "github.com/mitchellh/gox" Go module command. -type Option func(*Options) +// NewOptions creates new task options. +func NewOptions(opts ...Option) (*Options, error) { + version, versionErr := semver.NewVersion(DefaultGoModuleVersion) + if versionErr != nil { + return nil, &task.ErrTask{ + Err: fmt.Errorf("parsing default module version %q: %w", DefaultGoModulePath, versionErr), + Kind: task.ErrInvalidTaskOpts, + } + } -// WithEnv sets the spell incantation specific environment. -func WithEnv(env map[string]string) Option { - return func(o *Options) { - o.env = env + opt := &Options{ + env: make(map[string]string), + goModule: &project.GoModuleID{ + Path: DefaultGoModulePath, + Version: version, + }, } + for _, o := range opts { + o(opt) + } + + goBuildOpts := append( + []taskGoBuild.Option{taskGoBuild.WithGoOptions(opt.taskGoOpts...)}, + opt.taskGoBuildOpts..., + ) + opt.Options = taskGoBuild.NewOptions(goBuildOpts...) + + if opt.outputTemplate == "" && opt.BinaryArtifactName != "" { + opt.outputTemplate = DefaultCrossCompileBinaryNameTemplate(opt.BinaryArtifactName) + } + + if len(opt.CrossCompileTargetPlatforms) == 0 { + opt.CrossCompileTargetPlatforms = DefaultCrossCompileTargetPlatforms + } + + return opt, nil } -// WithGoCmd sets the path to the Go toolchain executable. -func WithGoCmd(goCmd string) Option { +// WithEnv sets the task specific environment. +func WithEnv(env map[string]string) Option { return func(o *Options) { - o.goCmd = goCmd + o.env = env } } -// WithOutputTemplate sets the name template for cross-compile platform targets. -// Defaults to DefaultCrossCompileBinaryNameTemplate. -func WithOutputTemplate(outputTemplate string) Option { +// WithGoBuildOptions sets Go toolchain "build" command task options. +func WithGoBuildOptions(goBuildOpts ...taskGoBuild.Option) Option { return func(o *Options) { - o.outputTemplate = outputTemplate + o.taskGoBuildOpts = append(o.taskGoBuildOpts, goBuildOpts...) } } -// WithGoOptions sets shared Go toolchain command options. -func WithGoOptions(goOpts ...spellGo.Option) Option { +// WithGoCmd sets the path to the Go toolchain executable. +func WithGoCmd(goCmd string) Option { return func(o *Options) { - o.spellGoOpts = append(o.spellGoOpts, goOpts...) + o.goCmd = goCmd } } -// WithGoBuildOptions sets options for the Go toolchain "build" command. -func WithGoBuildOptions(goBuildOpts ...spellGoBuild.Option) Option { +// WithGoOptions sets shared Go toolchain task options. +func WithGoOptions(goOpts ...taskGo.Option) Option { return func(o *Options) { - o.spellGoBuildOpts = append(o.spellGoBuildOpts, goBuildOpts...) + o.taskGoOpts = append(o.taskGoOpts, goOpts...) } } -// WithModulePath sets the "gox" module command import path. +// WithModulePath sets the module import path. // Defaults to DefaultGoModulePath. func WithModulePath(path string) Option { return func(o *Options) { @@ -114,7 +144,7 @@ func WithModulePath(path string) Option { } } -// WithModuleVersion sets the "gox" module version. +// WithModuleVersion sets the module version. // Defaults to DefaultGoModuleVersion. func WithModuleVersion(version *semver.Version) Option { return func(o *Options) { @@ -124,47 +154,17 @@ func WithModuleVersion(version *semver.Version) Option { } } -// WithVerboseOutput indicates whether the output should be verbose. -func WithVerboseOutput(verbose bool) Option { +// WithOutputTemplate sets the name template for cross-compile platform targets. +// Defaults to DefaultCrossCompileBinaryNameTemplate. +func WithOutputTemplate(outputTemplate string) Option { return func(o *Options) { - o.verbose = verbose + o.outputTemplate = outputTemplate } } -// NewOptions creates new spell incantation options for the "github.com/mitchellh/gox" Go module command. -func NewOptions(opts ...Option) (*Options, error) { - version, versionErr := semver.NewVersion(DefaultGoModuleVersion) - if versionErr != nil { - return nil, &cast.ErrCast{ - Err: fmt.Errorf("parsing default module version %q: %w", DefaultGoModulePath, versionErr), - Kind: cast.ErrCasterInvalidOpts, - } - } - - opt := &Options{ - env: make(map[string]string), - goModule: &project.GoModuleID{ - Path: DefaultGoModulePath, - Version: version, - }, - } - for _, o := range opts { - o(opt) - } - - goBuildOpts := append( - []spellGoBuild.Option{spellGoBuild.WithGoOptions(opt.spellGoOpts...)}, - opt.spellGoBuildOpts..., - ) - opt.Options = spellGoBuild.NewOptions(goBuildOpts...) - - if opt.outputTemplate == "" && opt.BinaryArtifactName != "" { - opt.outputTemplate = DefaultCrossCompileBinaryNameTemplate(opt.BinaryArtifactName) - } - - if len(opt.CrossCompileTargetPlatforms) == 0 { - opt.CrossCompileTargetPlatforms = DefaultCrossCompileTargetPlatforms +// WithVerboseOutput indicates whether the output should be verbose. +func WithVerboseOutput(verbose bool) Option { + return func(o *Options) { + o.verbose = verbose } - - return opt, nil } diff --git a/pkg/spell/kind.go b/pkg/task/kind.go similarity index 61% rename from pkg/spell/kind.go rename to pkg/task/kind.go index 713d41c..54bf200 100644 --- a/pkg/spell/kind.go +++ b/pkg/task/kind.go @@ -1,7 +1,7 @@ // Copyright (c) 2019-present Sven Greb // This source code is licensed under the MIT license found in the LICENSE file. -package spell +package task import ( "fmt" @@ -9,35 +9,35 @@ import ( ) const ( - // KindNameBinary is the Kind name for binary spells. - KindNameBinary = "binary" - // KindNameGoCode is the Kind name for Go code spells. - KindNameGoCode = "go.code" - // KindNameGoModule is the Kind name for Go module spells. + // KindNameBase is the kind name for base tasks. + KindNameBase = "base" + // KindNameExec is the kind name for executable file tasks. + KindNameExec = "executable" + // KindNameGoModule is the kind name for Go module tasks. KindNameGoModule = "go.module" - // KindNameUnknown is the name for a unknown spell Kind. + // KindNameUnknown is the name for a unknown task kind. KindNameUnknown = "unknown" ) const ( - // KindBinary is the Kind for binary spells. - KindBinary Kind = iota - // KindGoCode is the Kind for Go code spells. - KindGoCode - // KindGoModule is the Kind for Go module spells. + // KindBase is the kind for base tasks. + KindBase Kind = iota + // KindExec is the kind for executable file tasks. + KindExec + // KindGoModule is the kind for Go module tasks. KindGoModule ) -// Kind defines the kind of a spell. +// Kind defines the kind of tasks. type Kind uint32 // MarshalText returns the textual representation of itself. func (k Kind) MarshalText() ([]byte, error) { switch k { - case KindBinary: - return []byte(KindNameBinary), nil - case KindGoCode: - return []byte(KindNameGoCode), nil + case KindBase: + return []byte(KindNameBase), nil + case KindExec: + return []byte(KindNameExec), nil case KindGoModule: return []byte(KindNameGoModule), nil } @@ -66,10 +66,10 @@ func (k *Kind) UnmarshalText(text []byte) error { // ParseKind takes a kind name and returns the Kind constant. func ParseKind(name string) (Kind, error) { switch strings.ToLower(name) { - case KindNameBinary: - return KindBinary, nil - case KindNameGoCode: - return KindGoCode, nil + case KindNameBase: + return KindBase, nil + case KindNameExec: + return KindExec, nil case KindNameGoModule: return KindGoModule, nil } diff --git a/pkg/spell/mixin.go b/pkg/task/mixin.go similarity index 50% rename from pkg/spell/mixin.go rename to pkg/task/mixin.go index 981f35f..6cd6842 100644 --- a/pkg/spell/mixin.go +++ b/pkg/task/mixin.go @@ -1,13 +1,13 @@ // Copyright (c) 2019-present Sven Greb // This source code is licensed under the MIT license found in the LICENSE file. -package spell +package task -// Options is a generic representation for spell incantation options. -type Options interface{} - -// Mixin allows to compose functions that process Options of spell incantations. +// Mixin allows to compose functions that process task options. type Mixin interface { - // Apply applies generic Options to spell incantation options. + // Apply applies the mixin to task options. Apply(Options) (Options, error) } + +// Options is a generic representation for task options. +type Options interface{} diff --git a/pkg/task/runner.go b/pkg/task/runner.go new file mode 100644 index 0000000..dba585c --- /dev/null +++ b/pkg/task/runner.go @@ -0,0 +1,24 @@ +// Copyright (c) 2019-present Sven Greb +// This source code is licensed under the MIT license found in the LICENSE file. + +package task + +// Runner runs a command with parameters in a specific environment. +type Runner interface { + // Handles returns the supported task kind. + Handles() Kind + + // Run runs a command. + Run(Task) error + + // Validate validates the runner. + Validate() error +} + +// RunnerExec is a runner for a (binary) command executable. +type RunnerExec interface { + Runner + + // FilePath returns the path to the (binary) command executable. + FilePath() string +} diff --git a/pkg/task/task.go b/pkg/task/task.go new file mode 100644 index 0000000..427cbf3 --- /dev/null +++ b/pkg/task/task.go @@ -0,0 +1,48 @@ +// Copyright (c) 2019-present Sven Greb +// This source code is licensed under the MIT license found in the LICENSE file. + +// Package task provides tasks and runner for Mage. +package task + +import ( + "github.com/svengreb/wand/pkg/project" +) + +// Exec is a task for a executable command. +type Exec interface { + Task + + // BuildParams builds the parameters for a command runner. + // Parameters consist of options, flags and arguments. + // The separation of parameters from commands enables a flexible usage, e.g. when parameters can be reused for + // different tasks. + // + // References + // + // (1) https://en.wikipedia.org/wiki/Command-line_interface#Anatomy_of_a_shell_CLI + BuildParams() []string + + // Env returns the task specific environment. + Env() map[string]string +} + +// GoModule is a task for a Go module command. +// +// See https://golang.org/ref/mod for more details about Go modules. +type GoModule interface { + Exec + + // ID returns the identifier of a Go module. + ID() *project.GoModuleID +} + +// Task is a wand task for Mage. +// +// See https://magefile.org/targets for more details about Mage targets. +type Task interface { + // Kind returns the task kind. + Kind() Kind + + // Options returns the task options. + Options() Options +}