From 3b239474d42d7a5354d58f5c66aad8dee5628a5c Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 4 May 2026 14:09:29 -0700 Subject: [PATCH] feat(cli): add foundational command surface --- .github/workflows/ci.yml | 1 + .golangci.yml | 473 +++++++++++++++++++++++++++++++++ .moon/proto/golangci-lint.toml | 28 ++ .moon/toolchains.yml | 6 +- .prototools | 4 + README.md | 10 +- cmd/imgcli/main.go | 31 +++ docs/docs/design.md | 18 +- docs/docs/index.md | 4 +- docs/docusaurus.config.ts | 2 +- go.mod | 46 ++++ go.sum | 90 +++++++ internal/cli/config.go | 129 +++++++++ internal/cli/config_test.go | 107 ++++++++ internal/cli/doc.go | 8 + internal/cli/logging.go | 63 +++++ internal/cli/options.go | 59 ++++ internal/cli/root.go | 91 +++++++ internal/cli/root_test.go | 128 +++++++++ internal/cli/version.go | 19 ++ moon.yml | 84 ++++-- 21 files changed, 1365 insertions(+), 36 deletions(-) create mode 100644 .golangci.yml create mode 100644 .moon/proto/golangci-lint.toml create mode 100644 .prototools create mode 100644 cmd/imgcli/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/cli/config.go create mode 100644 internal/cli/config_test.go create mode 100644 internal/cli/doc.go create mode 100644 internal/cli/logging.go create mode 100644 internal/cli/options.go create mode 100644 internal/cli/root.go create mode 100644 internal/cli/root_test.go create mode 100644 internal/cli/version.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03a123a..c831219 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,7 @@ jobs: - name: Setup Moon Toolchain uses: moonrepo/setup-toolchain@261c62cb5b0f580c7be7c8cd0f023a2e96756095 # v0.6.4 with: + auto-install: true auto-setup: true cache-base: ${{ github.event.repository.default_branch }} diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..cbf07f7 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,473 @@ +# This file is licensed under the terms of the MIT license https://opensource.org/license/mit +# Copyright (c) 2021-2025 Marat Reimers + +## Golden config for golangci-lint v2.11.4 +# +# This is the best config for golangci-lint based on my experience and opinion. +# It is very strict, but not extremely strict. +# Feel free to adapt it to suit your needs. +# If this config helps you, please consider keeping a link to this repo (see the next comment). + +# Based on https://github.com/maratori/golangci-lint-config + +version: "2" + +issues: + # Maximum count of issues with the same text. + # Set to 0 to disable. + # Default: 3 + max-same-issues: 50 + +formatters: + enable: + - goimports # checks if the code and import statements are formatted according to the 'goimports' command + - golines # checks if code is formatted, and fixes long lines + + ## you may want to enable + #- gci # checks if code and import statements are formatted, with additional rules + #- gofmt # checks if the code is formatted according to 'gofmt' command + #- gofumpt # enforces a stricter format than 'gofmt', while being backwards compatible + #- swaggo # formats swaggo comments + + # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml + settings: + goimports: + # A list of prefixes, which, if set, checks import paths + # with the given prefixes are grouped after 3rd-party packages. + # Default: [] + local-prefixes: + - github.com/meigma/imgcli + + golines: + # Target maximum line length. + # Default: 100 + max-len: 120 + +linters: + enable: + - asasalint # checks for pass []any as any in variadic func(...any) + - asciicheck # checks that your code does not contain non-ASCII identifiers + - bidichk # checks for dangerous unicode character sequences + - bodyclose # checks whether HTTP response body is closed successfully + - canonicalheader # checks whether net/http.Header uses canonical header + - copyloopvar # detects places where loop variables are copied (Go 1.22+) + - cyclop # checks function and package cyclomatic complexity + - depguard # checks if package imports are in a list of acceptable packages + - dupl # tool for code clone detection + - durationcheck # checks for two durations multiplied together + - embeddedstructfieldcheck # checks embedded types in structs + - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases + - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error + - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13 + - exhaustive # checks exhaustiveness of enum switch statements + - exptostd # detects functions from golang.org/x/exp/ that can be replaced by std functions + - fatcontext # detects nested contexts in loops + - forbidigo # forbids identifiers + - funcorder # checks the order of functions, methods, and constructors + - funlen # tool for detection of long functions + - gocheckcompilerdirectives # validates go compiler directive comments (//go:) + - gochecknoglobals # checks that no global variables exist + - gochecknoinits # checks that no init functions are present in Go code + - gochecksumtype # checks exhaustiveness on Go "sum types" + - gocognit # computes and checks the cognitive complexity of functions + - goconst # finds repeated strings that could be replaced by a constant + - gocritic # provides diagnostics that check for bugs, performance and style issues + - gocyclo # computes and checks the cyclomatic complexity of functions + - godoclint # checks Golang's documentation practice + - godot # checks if comments end in a period + - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod + - goprintffuncname # checks that printf-like functions are named with f at the end + - gosec # inspects source code for security problems + - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string + - iface # checks the incorrect use of interfaces, helping developers avoid interface pollution + - ineffassign # detects when assignments to existing variables are not used + - intrange # finds places where for loops could make use of an integer range + - iotamixing # checks if iotas are being used in const blocks with other non-iota declarations + - loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap) + - makezero # finds slice declarations with non-zero initial length + - mirror # reports wrong mirror patterns of bytes/strings usage + - mnd # detects magic numbers + - modernize # suggests simplifications to Go code, using modern language and library features + - musttag # enforces field tags in (un)marshaled structs + - nakedret # finds naked returns in functions greater than a specified function length + - nestif # reports deeply nested if statements + - nilerr # finds the code that returns nil even if it checks that the error is not nil + - nilnesserr # reports that it checks for err != nil, but it returns a different nil value error (powered by nilness and nilerr) + - nilnil # checks that there is no simultaneous return of nil error and an invalid value + - noctx # finds sending http request without context.Context + - nolintlint # reports ill-formed or insufficient nolint directives + - nonamedreturns # reports all named returns + - nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL + - perfsprint # checks that fmt.Sprintf can be replaced with a faster alternative + - predeclared # finds code that shadows one of Go's predeclared identifiers + - promlinter # checks Prometheus metrics naming via promlint + - protogetter # reports direct reads from proto message fields when getters should be used + - reassign # checks that package variables are not reassigned + - recvcheck # checks for receiver type consistency + - revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint + - rowserrcheck # checks whether Err of rows is checked successfully + - sloglint # ensure consistent code style when using log/slog + - spancheck # checks for mistakes with OpenTelemetry/Census spans + - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed + - staticcheck # is a go vet on steroids, applying a ton of static analysis checks + - testableexamples # checks if examples are testable (have an expected output) + - testifylint # checks usage of github.com/stretchr/testify + # testpackage disabled: tests intentionally use whitebox style to exercise + # unexported helpers, fake implementations of unexported interfaces + # (e.g. tsnetServer), and shared internal fixtures across multiple test + # files. Re-enable if/when the test layout grows large enough that + # blackbox-only is worth the export pollution. + #- testpackage + - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes + - unconvert # removes unnecessary type conversions + - unparam # reports unused function parameters + - unqueryvet # detects SELECT * in SQL queries and SQL builders, encouraging explicit column selection + - unused # checks for unused constants, variables, functions and types + - usestdlibvars # detects the possibility to use variables/constants from the Go standard library + - usetesting # reports uses of functions with replacement inside the testing package + - wastedassign # finds wasted assignment statements + - whitespace # detects leading and trailing whitespace + + ## you may want to enable + #- arangolint # opinionated best practices for arangodb client + #- decorder # checks declaration order and count of types, constants, variables and functions + #- exhaustruct # [highly recommend to enable] checks if all structure fields are initialized + #- ginkgolinter # [if you use ginkgo/gomega] enforces standards of using ginkgo and gomega + #- godox # detects usage of FIXME, TODO and other keywords inside comments + #- goheader # checks is file header matches to pattern + #- inamedparam # [great idea, but too strict, need to ignore a lot of cases by default] reports interfaces with unnamed method parameters + #- interfacebloat # checks the number of methods inside an interface + #- ireturn # accept interfaces, return concrete types + #- noinlineerr # disallows inline error handling `if err := ...; err != nil {` + #- prealloc # [premature optimization, but can be used in some cases] finds slice declarations that could potentially be preallocated + #- tagalign # checks that struct tags are well aligned + #- varnamelen # [great idea, but too many false positives] checks that the length of a variable's name matches its scope + #- wrapcheck # checks that errors returned from external packages are wrapped + #- zerologlint # detects the wrong usage of zerolog that a user forgets to dispatch zerolog.Event + + ## disabled + #- containedctx # detects struct contained context.Context field + #- contextcheck # [too many false positives] checks the function whether use a non-inherited context + #- dogsled # checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) + #- dupword # [useless without config] checks for duplicate words in the source code + #- err113 # [too strict] checks the errors handling expressions + #- errchkjson # [don't see profit + I'm against of omitting errors like in the first example https://github.com/breml/errchkjson] checks types passed to the json encoding functions. Reports unsupported types and optionally reports occasions, where the check for the returned error can be omitted + #- forcetypeassert # [replaced by errcheck] finds forced type assertions + #- gomodguard # [use more powerful depguard] allow and block lists linter for direct Go module dependencies + #- gosmopolitan # reports certain i18n/l10n anti-patterns in your Go codebase + #- grouper # analyzes expression groups + #- importas # enforces consistent import aliases + #- lll # [replaced by golines] reports long lines + #- maintidx # measures the maintainability index of each function + #- misspell # [useless] finds commonly misspelled English words in comments + #- nlreturn # [too strict and mostly code is not more readable] checks for a new line before return and branch statements to increase code clarity + #- paralleltest # [too many false positives] detects missing usage of t.Parallel() method in your Go test + #- tagliatelle # checks the struct tags + #- thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers + #- wsl # [too strict and mostly code is not more readable] whitespace linter forces you to use empty lines + #- wsl_v5 # [too strict and mostly code is not more readable] add or remove empty lines + + # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml + settings: + cyclop: + # The maximal code complexity to report. + # Default: 10 + max-complexity: 30 + # The maximal average package complexity. + # If it's higher than 0.0 (float) the check is enabled. + # Default: 0.0 + package-average: 10.0 + + depguard: + # Rules to apply. + # + # Variables: + # - File Variables + # Use an exclamation mark `!` to negate a variable. + # Example: `!$test` matches any file that is not a go test file. + # + # `$all` - matches all go files + # `$test` - matches all go test files + # + # - Package Variables + # + # `$gostd` - matches all of go's standard library (Pulled from `GOROOT`) + # + # Default (applies if no custom rules are defined): Only allow $gostd in all files. + rules: + "deprecated": + # List of file globs that will match this list of settings to compare against. + # By default, if a path is relative, it is relative to the directory where the golangci-lint command is executed. + # The placeholder '${base-path}' is substituted with a path relative to the mode defined with `run.relative-path-mode`. + # The placeholder '${config-path}' is substituted with a path relative to the configuration file. + # Default: $all + files: + - "$all" + # List of packages that are not allowed. + # Entries can be a variable (starting with $), a string prefix, or an exact match (if ending with $). + # Default: [] + deny: + - pkg: github.com/golang/protobuf + desc: Use google.golang.org/protobuf instead, see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules + - pkg: github.com/satori/go.uuid + desc: Use github.com/google/uuid instead, satori's package is not maintained + - pkg: github.com/gofrs/uuid$ + desc: Use github.com/gofrs/uuid/v5 or later, it was not a go module before v5 + "non-test files": + files: + - "!$test" + deny: + - pkg: math/rand$ + desc: Use math/rand/v2 instead, see https://go.dev/blog/randv2 + "non-main files": + files: + - "!**/main.go" + deny: + - pkg: log$ + desc: Use log/slog instead, see https://go.dev/blog/slog + + embeddedstructfieldcheck: + # Checks that sync.Mutex and sync.RWMutex are not used as embedded fields. + # Default: false + forbid-mutex: true + + errcheck: + # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. + # Such cases aren't reported by default. + # Default: false + check-type-assertions: true + + exhaustive: + # Program elements to check for exhaustiveness. + # Default: [ switch ] + check: + - switch + - map + + exhaustruct: + # List of regular expressions to match type names that should be excluded from processing. + # Anonymous structs can be matched by '' alias. + # Has precedence over `include`. + # Each regular expression must match the full type name, including package path. + # For example, to match type `net/http.Cookie` regular expression should be `.*/http\.Cookie`, + # but not `http\.Cookie`. + # Default: [] + exclude: + # std libs + - ^net/http.Client$ + - ^net/http.Cookie$ + - ^net/http.Request$ + - ^net/http.Response$ + - ^net/http.Server$ + - ^net/http.Transport$ + - ^net/url.URL$ + - ^os/exec.Cmd$ + - ^reflect.StructField$ + # public libs + - ^github.com/Shopify/sarama.Config$ + - ^github.com/Shopify/sarama.ProducerMessage$ + - ^github.com/mitchellh/mapstructure.DecoderConfig$ + - ^github.com/prometheus/client_golang/.+Opts$ + - ^github.com/spf13/cobra.Command$ + - ^github.com/spf13/cobra.CompletionOptions$ + - ^github.com/stretchr/testify/mock.Mock$ + - ^github.com/testcontainers/testcontainers-go.+Request$ + - ^github.com/testcontainers/testcontainers-go.FromDockerfile$ + - ^golang.org/x/tools/go/analysis.Analyzer$ + - ^google.golang.org/protobuf/.+Options$ + - ^gopkg.in/yaml.v3.Node$ + # Allows empty structures in return statements. + # Default: false + allow-empty-returns: true + + funcorder: + # Checks if the exported methods of a structure are placed before the non-exported ones. + # Default: true + struct-method: false + + funlen: + # Checks the number of lines in a function. + # If lower than 0, disable the check. + # Default: 60 + lines: 100 + # Checks the number of statements in a function. + # If lower than 0, disable the check. + # Default: 40 + statements: 50 + + gochecksumtype: + # Presence of `default` case in switch statements satisfies exhaustiveness, if all members are not listed. + # Default: true + default-signifies-exhaustive: false + + gocognit: + # Minimal code complexity to report. + # Default: 30 (but we recommend 10-20) + min-complexity: 20 + + gocritic: + # Settings passed to gocritic. + # The settings key is the name of a supported gocritic checker. + # The list of supported checkers can be found at https://go-critic.com/overview. + settings: + captLocal: + # Whether to restrict checker to params only. + # Default: true + paramsOnly: false + underef: + # Whether to skip (*x).method() calls where x is a pointer receiver. + # Default: true + skipRecvDeref: false + + godoclint: + # List of rules to enable in addition to the default set. + # Default: empty + enable: + # Assert no unused link in godocs. + # https://github.com/godoc-lint/godoc-lint?tab=readme-ov-file#no-unused-link + - no-unused-link + # Require proper doc links to standard library declarations where applicable. + # https://github.com/godoc-lint/godoc-lint?tab=readme-ov-file#require-stdlib-doclink + - require-stdlib-doclink + + govet: + # Enable all analyzers. + # Default: false + enable-all: true + # Disable analyzers by name. + # Run `GL_DEBUG=govet golangci-lint run --enable=govet` to see default, all available analyzers, and enabled analyzers. + # Default: [] + disable: + - fieldalignment # too strict + + inamedparam: + # Skips check for interface methods with only a single parameter. + # Default: false + skip-single-param: true + + mnd: + # List of function patterns to exclude from analysis. + # Values always ignored: `time.Date`, + # `strconv.FormatInt`, `strconv.FormatUint`, `strconv.FormatFloat`, + # `strconv.ParseInt`, `strconv.ParseUint`, `strconv.ParseFloat`. + # Default: [] + ignored-functions: + - args.Error + - flag.Arg + - flag.Duration.* + - flag.Float.* + - flag.Int.* + - flag.Uint.* + - os.Chmod + - os.Mkdir.* + - os.OpenFile + - os.WriteFile + - prometheus.ExponentialBuckets.* + - prometheus.LinearBuckets + + nakedret: + # Make an issue if func has more lines of code than this setting, and it has naked returns. + # Default: 30 + max-func-lines: 0 + + nolintlint: + # Exclude following linters from requiring an explanation. + # Default: [] + allow-no-explanation: [ funlen, gocognit, golines ] + # Enable to require an explanation of nonzero length after each nolint directive. + # Default: false + require-explanation: true + # Enable to require nolint directives to mention the specific linter being suppressed. + # Default: false + require-specific: true + + perfsprint: + # Optimizes into strings concatenation. + # Default: true + strconcat: false + + reassign: + # Patterns for global variable names that are checked for reassignment. + # See https://github.com/curioswitch/go-reassign#usage + # Default: ["EOF", "Err.*"] + patterns: + - ".*" + + rowserrcheck: + # database/sql is always checked. + # Default: [] + packages: + - github.com/jmoiron/sqlx + + sloglint: + # Enforce not using global loggers. + # Values: + # - "": disabled + # - "all": report all global loggers + # - "default": report only the default slog logger + # https://github.com/go-simpler/sloglint?tab=readme-ov-file#no-global + # Default: "" + no-global: all + # Enforce using methods that accept a context. + # Values: + # - "": disabled + # - "all": report all contextless calls + # - "scope": report only if a context exists in the scope of the outermost function + # https://github.com/go-simpler/sloglint?tab=readme-ov-file#context-only + # Default: "" + context: scope + + staticcheck: + # SAxxxx checks in https://staticcheck.dev/docs/configuration/options/#checks + # Example (to disable some checks): [ "all", "-SA1000", "-SA1001"] + # Default: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022"] + checks: + - all + # Incorrect or missing package comment. + # https://staticcheck.dev/docs/checks/#ST1000 + - -ST1000 + # Use consistent method receiver names. + # https://staticcheck.dev/docs/checks/#ST1016 + - -ST1016 + # Omit embedded fields from selector expression. + # https://staticcheck.dev/docs/checks/#QF1008 + - -QF1008 + + usetesting: + # Enable/disable `os.TempDir()` detections. + # Default: false + os-temp-dir: true + + exclusions: + # Log a warning if an exclusion rule is unused. + # Default: false + warn-unused: false + # Predefined exclusion rules. + # Default: [] + presets: + - std-error-handling + - common-false-positives + # Excluding configuration per-path, per-linter, per-text and per-source. + rules: + - source: 'TODO' + linters: [ godot ] + - text: 'should have a package comment' + linters: [ revive ] + - text: 'exported \S+ \S+ should have comment( \(or a comment on this block\))? or be unexported' + linters: [ revive ] + - text: 'package comment should be of the form ".+"' + source: '// ?(nolint|TODO)' + linters: [ revive ] + - text: 'comment on exported \S+ \S+ should be of the form ".+"' + source: '// ?(nolint|TODO)' + linters: [ revive, staticcheck ] + - path: '_test\.go' + linters: + - bodyclose + - dupl + - errcheck + - funlen + - goconst + - gosec + - noctx + - wrapcheck diff --git a/.moon/proto/golangci-lint.toml b/.moon/proto/golangci-lint.toml new file mode 100644 index 0000000..2bd5101 --- /dev/null +++ b/.moon/proto/golangci-lint.toml @@ -0,0 +1,28 @@ +name = "golangci-lint" +type = "cli" + +[platform.macos] +download-file = "golangci-lint-{version}-darwin-{arch}.tar.gz" +checksum-file = "golangci-lint-{version}-checksums.txt" +exe-path = "golangci-lint-{version}-darwin-{arch}/golangci-lint" + +[platform.linux] +download-file = "golangci-lint-{version}-linux-{arch}.tar.gz" +checksum-file = "golangci-lint-{version}-checksums.txt" +exe-path = "golangci-lint-{version}-linux-{arch}/golangci-lint" + +[platform.windows] +download-file = "golangci-lint-{version}-windows-{arch}.zip" +checksum-file = "golangci-lint-{version}-checksums.txt" +exe-path = "golangci-lint-{version}-windows-{arch}/golangci-lint.exe" + +[install] +download-url = "https://github.com/golangci/golangci-lint/releases/download/v{version}/{download_file}" +checksum-url = "https://github.com/golangci/golangci-lint/releases/download/v{version}/{checksum_file}" + +[install.arch] +aarch64 = "arm64" +x86_64 = "amd64" + +[resolve] +git-url = "https://github.com/golangci/golangci-lint" diff --git a/.moon/toolchains.yml b/.moon/toolchains.yml index 726c0b4..4212555 100644 --- a/.moon/toolchains.yml +++ b/.moon/toolchains.yml @@ -7,8 +7,12 @@ javascript: npm: {} +proto: + version: '0.56.4' + node: version: '22.22.2' syncVersionManagerConfig: 'nvm' -# Add the Go toolchain when the CLI implementation lands. +go: + version: '1.26.2' diff --git a/.prototools b/.prototools new file mode 100644 index 0000000..ed4dc3f --- /dev/null +++ b/.prototools @@ -0,0 +1,4 @@ +golangci-lint = "=2.11.4" + +[plugins.tools] +golangci-lint = "file://.moon/proto/golangci-lint.toml" diff --git a/README.md b/README.md index e03f0c3..d7a3a65 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # imgcli -`imgcli` is a prototype CLI for building disk image artifacts from Git-tracked -CUE configuration and, later, publishing those artifacts to `imgsrv`. +`imgcli` is a prototype CLI for building disk image artifacts from CUE +configuration and, later, publishing those artifacts to `imgsrv`. The repository is still in the design and scaffolding stage. The current working design is intentionally lightweight and lives in [docs/docs/design.md](docs/docs/design.md). @@ -32,7 +32,7 @@ npm --prefix docs run start ## Usage -The intended CLI workflow is Git-as-truth image publishing: +One intended release-pipeline workflow is image publishing: ```sh imgcli publish --version 1.0.0 --alias latest --alias prod config.cue @@ -48,8 +48,8 @@ publishing once the `imgsrv` API is stable enough to target. artifact work, and write deterministic local outputs under `dist/` by default. Release versions and aliases are publish-time inputs. They should come from the -release pipeline, usually a Git tag plus explicit CLI flags, rather than being -hard-coded into the CUE file. +release pipeline, usually a release tag plus explicit CLI flags, rather than +being hard-coded into the CUE file. ## Documentation diff --git a/cmd/imgcli/main.go b/cmd/imgcli/main.go new file mode 100644 index 0000000..9717578 --- /dev/null +++ b/cmd/imgcli/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/meigma/imgcli/internal/cli" +) + +const exitFailure = 1 + +var version = "dev" + +func main() { + if err := run(); err != nil { + if _, writeErr := fmt.Fprintln(os.Stderr, err); writeErr != nil { + os.Exit(exitFailure) + } + os.Exit(exitFailure) + } +} + +func run() error { + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + return cli.ExecuteContext(ctx, cli.Options{Version: version}) +} diff --git a/docs/docs/design.md b/docs/docs/design.md index 7fab206..8c09d90 100644 --- a/docs/docs/design.md +++ b/docs/docs/design.md @@ -5,8 +5,8 @@ description: Temporary working design for the imgcli prototype. # imgcli Initial Design -`imgcli` is a CLI for building disk image artifacts from Git-tracked -configuration and, eventually, publishing those artifacts to `imgsrv`. +`imgcli` is a CLI for building disk image artifacts from CUE configuration and, +eventually, publishing those artifacts to `imgsrv`. This document is intentionally a starting point for prototype work. It captures the shared product shape and the first schema direction, but it does not attempt @@ -35,7 +35,7 @@ image versions, uploaded blobs, release artifacts, artifact attachments, publishing, aliases, downloads, and future materializations. The CLI should not know about downstream tools such as Tinkerbell, CAPI, CAPN, -Incus, or GitOps controllers. Those tools are consumers of the artifacts and +Incus, or deployment automation. Those tools are consumers of the artifacts and server APIs, not concepts in `imgcli`. ## V0 Prototype Scope @@ -58,13 +58,13 @@ The initial prototype should not implement Incus/simplestreams materialization, generated Incus metadata attachments, or provider schemas for Talos, Flatcar, or distrobuilder. -## GitOps Workflow +## Release Pipeline Workflow -The intended workflow is Git-as-truth image publishing: +One intended workflow is release-pipeline image publishing: 1. Create one image configuration file. -2. Commit and push it. -3. Create a Git tag. +2. Commit and push it if the configuration is managed in source control. +3. Create a release tag or otherwise select an explicit release version. 4. Run `imgcli` in a release pipeline with an explicit version. For example: @@ -75,7 +75,7 @@ imgcli publish --version 1.0.0 --alias latest --alias prod config.cue The configuration file should encode the desired image state, but it should not hard-code the release version. Version identity comes from the release pipeline, -usually through the Git tag and an explicit `--version` flag. +usually through an explicit `--version` flag. Aliases are also publish-time intent. An alias is a mutable relationship from a text label to a published version, so it should be supplied during publish @@ -381,7 +381,7 @@ For `imgsrv` integration, `imgcli` should need only client connection details: - API token Those values belong in CLI flags, environment variables, or CI secrets, not in -the Git-tracked image config. +the image config. The concrete publish protocol belongs to `imgsrv`. `imgcli` should follow the server API once it exists rather than designing a separate publishing workflow. diff --git a/docs/docs/index.md b/docs/docs/index.md index d1a4a3b..1ab7198 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -6,8 +6,8 @@ description: Documentation for imgcli. # imgcli Docs -`imgcli` is a prototype CLI for building disk image artifacts from Git-tracked -CUE configuration and, later, publishing those artifacts to `imgsrv`. +`imgcli` is a prototype CLI for building disk image artifacts from CUE +configuration and, later, publishing those artifacts to `imgsrv`. The project is intentionally early. Start with the [initial design](./design.md), which captures the current product boundary, diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index 60b93e5..3a83795 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -4,7 +4,7 @@ import type * as Preset from '@docusaurus/preset-classic'; const config: Config = { title: 'imgcli', - tagline: 'Build disk image artifacts from Git-tracked configuration', + tagline: 'Build disk image artifacts from configuration', future: { v4: true, }, diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a2e8694 --- /dev/null +++ b/go.mod @@ -0,0 +1,46 @@ +module github.com/meigma/imgcli + +go 1.26 + +require ( + charm.land/log/v2 v2.0.0 + github.com/charmbracelet/colorprofile v0.4.2 + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 + github.com/spf13/viper v1.21.0 + github.com/stretchr/testify v1.11.1 +) + +require ( + charm.land/lipgloss/v2 v2.0.1 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.28.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a027818 --- /dev/null +++ b/go.sum @@ -0,0 +1,90 @@ +charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= +charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s= +charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI= +github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/config.go b/internal/cli/config.go new file mode 100644 index 0000000..c3312d2 --- /dev/null +++ b/internal/cli/config.go @@ -0,0 +1,129 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +const ( + envPrefix = "IMGCLI" + + // KeyConfig is the Viper key for the optional config file path. + KeyConfig = "config" + // KeyLogLevel is the Viper key for the minimum log level. + KeyLogLevel = "log-level" + // KeyLogFormat is the Viper key for the log formatter. + KeyLogFormat = "log-format" + // KeyNoColor is the Viper key for disabling styled terminal output. + KeyNoColor = "no-color" +) + +const ( + flagConfig = "config" + flagLogLevel = "log-level" + flagLogFormat = "log-format" + flagNoColor = "no-color" +) + +const ( + defaultLogLevel = "info" + defaultLogFormat = "text" +) + +// Config is the CLI edge configuration resolved from flags, environment, config file, and defaults. +type Config struct { + // ConfigFile is the optional config file path used for this invocation. + ConfigFile string + + // LogLevel is the minimum log level emitted to stderr. + LogLevel string + + // LogFormat is the log formatter emitted to stderr. + LogFormat string + + // NoColor disables styled terminal output when true. + NoColor bool +} + +func configureViper(vp *viper.Viper) { + vp.SetEnvPrefix(envPrefix) + vp.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_")) + vp.AutomaticEnv() + + vp.SetDefault(KeyLogLevel, defaultLogLevel) + vp.SetDefault(KeyLogFormat, defaultLogFormat) + vp.SetDefault(KeyNoColor, false) +} + +func (rt *runtime) registerGlobalFlags(root *cobra.Command) error { + flags := root.PersistentFlags() + flags.String(flagConfig, "", "Path to an imgcli config file") + flags.String(flagLogLevel, defaultLogLevel, "Minimum log level: debug, info, warn, or error") + flags.String(flagLogFormat, defaultLogFormat, "Log format: text, json, or logfmt") + flags.Bool(flagNoColor, false, "Disable styled terminal output") + + if err := bindConfigFlag(rt.viper, flags, KeyConfig, flagConfig); err != nil { + return err + } + if err := bindConfigFlag(rt.viper, flags, KeyLogLevel, flagLogLevel); err != nil { + return err + } + if err := bindConfigFlag(rt.viper, flags, KeyLogFormat, flagLogFormat); err != nil { + return err + } + if err := bindConfigFlag(rt.viper, flags, KeyNoColor, flagNoColor); err != nil { + return err + } + + return nil +} + +func bindConfigFlag(vp *viper.Viper, flags *pflag.FlagSet, key string, flagName string) error { + flag := flags.Lookup(flagName) + if flag == nil { + return fmt.Errorf("bind config flag %q: flag not found", flagName) + } + if err := vp.BindPFlag(key, flag); err != nil { + return fmt.Errorf("bind flag %q to key %q: %w", flagName, key, err) + } + if err := vp.BindEnv(key); err != nil { + return fmt.Errorf("bind env for key %q: %w", key, err) + } + return nil +} + +func loadConfig(vp *viper.Viper) (Config, error) { + if configFile := vp.GetString(KeyConfig); configFile != "" { + vp.SetConfigFile(configFile) + if err := vp.ReadInConfig(); err != nil { + return Config{}, fmt.Errorf("read config file %q: %w", configFile, err) + } + } + + cfg := Config{ + ConfigFile: vp.GetString(KeyConfig), + LogLevel: strings.ToLower(strings.TrimSpace(vp.GetString(KeyLogLevel))), + LogFormat: strings.ToLower(strings.TrimSpace(vp.GetString(KeyLogFormat))), + NoColor: vp.GetBool(KeyNoColor), + } + + if err := validateConfig(cfg); err != nil { + return Config{}, err + } + + return cfg, nil +} + +func validateConfig(cfg Config) error { + if _, err := parseLogLevel(cfg.LogLevel); err != nil { + return err + } + if _, err := parseLogFormat(cfg.LogFormat); err != nil { + return err + } + return nil +} diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go new file mode 100644 index 0000000..e3c5e23 --- /dev/null +++ b/internal/cli/config_test.go @@ -0,0 +1,107 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLogConfigPrecedence(t *testing.T) { + tests := []struct { + name string + configKey string + envName string + flagName string + validValue string + invalidValue string + wantErr string + }{ + { + name: "log level", + configKey: KeyLogLevel, + envName: "IMGCLI_LOG_LEVEL", + flagName: flagLogLevel, + validValue: "debug", + invalidValue: "verbose", + wantErr: `invalid log level "verbose"`, + }, + { + name: "log format", + configKey: KeyLogFormat, + envName: "IMGCLI_LOG_FORMAT", + flagName: flagLogFormat, + validValue: "json", + invalidValue: "yaml", + wantErr: `invalid log format "yaml"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name+" invalid config file fails", func(t *testing.T) { + clearIMGCLIEnv(t) + configPath := writeConfig(t, tt.configKey, tt.invalidValue) + + result := executeCommand(t, Options{}, "--config", configPath, "version") + + require.Error(t, result.err) + assert.ErrorContains(t, result.err, tt.wantErr) + }) + + t.Run(tt.name+" env overrides config file", func(t *testing.T) { + clearIMGCLIEnv(t) + configPath := writeConfig(t, tt.configKey, tt.invalidValue) + t.Setenv(tt.envName, tt.validValue) + + result := executeCommand(t, Options{}, "--config", configPath, "version") + + require.NoError(t, result.err) + assert.Equal(t, "dev\n", result.stdout) + }) + + t.Run(tt.name+" flag overrides env", func(t *testing.T) { + clearIMGCLIEnv(t) + t.Setenv(tt.envName, tt.invalidValue) + + result := executeCommand(t, Options{}, "--"+tt.flagName, tt.validValue, "version") + + require.NoError(t, result.err) + assert.Equal(t, "dev\n", result.stdout) + }) + } +} + +func TestConfigFileCanComeFromEnvironment(t *testing.T) { + clearIMGCLIEnv(t) + configPath := writeConfig(t, KeyLogFormat, "yaml") + t.Setenv("IMGCLI_CONFIG", configPath) + + result := executeCommand(t, Options{}, "version") + + require.Error(t, result.err) + assert.ErrorContains(t, result.err, `invalid log format "yaml"`) +} + +func TestConfigFlagOverridesConfigEnvironment(t *testing.T) { + clearIMGCLIEnv(t) + envConfigPath := writeConfig(t, KeyLogFormat, "yaml") + flagConfigPath := writeConfig(t, KeyLogFormat, "logfmt") + t.Setenv("IMGCLI_CONFIG", envConfigPath) + + result := executeCommand(t, Options{}, "--config", flagConfigPath, "version") + + require.NoError(t, result.err) + assert.Equal(t, "dev\n", result.stdout) +} + +func writeConfig(t *testing.T, key string, value string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "imgcli.yaml") + content := fmt.Sprintf("%s: %q\n", key, value) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path +} diff --git a/internal/cli/doc.go b/internal/cli/doc.go new file mode 100644 index 0000000..acfc49e --- /dev/null +++ b/internal/cli/doc.go @@ -0,0 +1,8 @@ +// Package cli adapts Cobra, Viper, and terminal IO to imgcli commands. +// +// New commands should be built as constructor functions that receive the shared +// runtime, then registered from NewRootCommand. Flags that participate in +// configuration precedence should define a key constant and use bindConfigFlag +// so flags, IMGCLI_* environment variables, config files, and defaults resolve +// consistently. +package cli diff --git a/internal/cli/logging.go b/internal/cli/logging.go new file mode 100644 index 0000000..3a7688f --- /dev/null +++ b/internal/cli/logging.go @@ -0,0 +1,63 @@ +package cli + +import ( + "fmt" + "io" + "log/slog" + + charmLog "charm.land/log/v2" + "github.com/charmbracelet/colorprofile" +) + +func newLogger(cfg Config, stderr io.Writer, environ []string) (*slog.Logger, error) { + level, err := parseLogLevel(cfg.LogLevel) + if err != nil { + return nil, err + } + + formatter, err := parseLogFormat(cfg.LogFormat) + if err != nil { + return nil, err + } + + handler := charmLog.NewWithOptions(stderr, charmLog.Options{ + Level: level, + Formatter: formatter, + ReportTimestamp: false, + }) + if cfg.NoColor { + handler.SetColorProfile(colorprofile.NoTTY) + } else { + handler.SetColorProfile(colorprofile.Detect(stderr, environ)) + } + + return slog.New(handler), nil +} + +func parseLogLevel(value string) (charmLog.Level, error) { + switch value { + case "debug": + return charmLog.DebugLevel, nil + case "info": + return charmLog.InfoLevel, nil + case "warn": + return charmLog.WarnLevel, nil + case "error": + return charmLog.ErrorLevel, nil + default: + return 0, fmt.Errorf("invalid log level %q: expected debug, info, warn, or error", value) + } +} + +func parseLogFormat(value string) (charmLog.Formatter, error) { + switch value { + case "text": + return charmLog.TextFormatter, nil + case "json": + return charmLog.JSONFormatter, nil + case "logfmt": + return charmLog.LogfmtFormatter, nil + default: + return 0, fmt.Errorf("invalid log format %q: expected text, json, or logfmt", value) + } +} diff --git a/internal/cli/options.go b/internal/cli/options.go new file mode 100644 index 0000000..ff7b2b6 --- /dev/null +++ b/internal/cli/options.go @@ -0,0 +1,59 @@ +package cli + +import ( + "io" + "os" +) + +// Options configures the root imgcli command. +type Options struct { + // Version is the build version printed by the version command. + Version string + + // Stdin receives interactive input. Nil selects os.Stdin. + Stdin io.Reader + + // Stdout receives command results. Nil selects os.Stdout. + Stdout io.Writer + + // Stderr receives logs and diagnostics. Nil selects os.Stderr. + Stderr io.Writer + + // Environ provides terminal environment values for output adapters. Nil selects os.Environ(). + Environ []string +} + +func (o Options) version() string { + if o.Version == "" { + return defaultVersion + } + return o.Version +} + +func (o Options) stdin() io.Reader { + if o.Stdin == nil { + return os.Stdin + } + return o.Stdin +} + +func (o Options) stdout() io.Writer { + if o.Stdout == nil { + return os.Stdout + } + return o.Stdout +} + +func (o Options) stderr() io.Writer { + if o.Stderr == nil { + return os.Stderr + } + return o.Stderr +} + +func (o Options) environ() []string { + if o.Environ == nil { + return os.Environ() + } + return o.Environ +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..531d66f --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,91 @@ +package cli + +import ( + "context" + "fmt" + "log/slog" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +const defaultVersion = "dev" + +type runtime struct { + opts Options + viper *viper.Viper + config Config + logger *slog.Logger +} + +// ExecuteContext constructs and executes the imgcli root command. +func ExecuteContext(ctx context.Context, opts Options) error { + cmd, err := NewRootCommand(opts) + if err != nil { + return err + } + + return cmd.ExecuteContext(ctx) +} + +// NewRootCommand constructs the imgcli Cobra command tree. +func NewRootCommand(opts Options) (*cobra.Command, error) { + rt := newRuntime(opts) + + root := &cobra.Command{ + Use: "imgcli", + Short: "Build disk image artifacts from configuration", + Version: rt.opts.version(), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + return rt.initialize(cmd) + }, + } + root.SetVersionTemplate("{{.Version}}\n") + root.SetIn(rt.opts.stdin()) + root.SetOut(rt.opts.stdout()) + root.SetErr(rt.opts.stderr()) + + if err := rt.registerGlobalFlags(root); err != nil { + return nil, err + } + + addCommands(root, newVersionCommand(rt)) + + return root, nil +} + +func newRuntime(opts Options) *runtime { + vp := viper.New() + configureViper(vp) + + return &runtime{ + opts: opts, + viper: vp, + logger: slog.New(slog.DiscardHandler), + } +} + +func addCommands(root *cobra.Command, commands ...*cobra.Command) { + root.AddCommand(commands...) +} + +func (rt *runtime) initialize(_ *cobra.Command) error { + cfg, err := loadConfig(rt.viper) + if err != nil { + return err + } + + logger, err := newLogger(cfg, rt.opts.stderr(), rt.opts.environ()) + if err != nil { + return fmt.Errorf("configure logger: %w", err) + } + + rt.config = cfg + rt.logger = logger + return nil +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go new file mode 100644 index 0000000..166d67c --- /dev/null +++ b/internal/cli/root_test.go @@ -0,0 +1,128 @@ +package cli + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type commandResult struct { + stdout string + stderr string + err error +} + +func TestVersionOutput(t *testing.T) { + tests := []struct { + name string + version string + args []string + want string + }{ + { + name: "version command uses dev by default", + args: []string{"version"}, + want: "dev\n", + }, + { + name: "root version flag uses dev by default", + args: []string{"--version"}, + want: "dev\n", + }, + { + name: "version command prints injected version", + version: "1.2.3", + args: []string{"version"}, + want: "1.2.3\n", + }, + { + name: "root version flag prints injected version", + version: "1.2.3", + args: []string{"--version"}, + want: "1.2.3\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearIMGCLIEnv(t) + + result := executeCommand(t, Options{Version: tt.version}, tt.args...) + + require.NoError(t, result.err) + assert.Equal(t, tt.want, result.stdout) + assert.Empty(t, result.stderr) + }) + } +} + +func TestInvalidLogSettings(t *testing.T) { + tests := []struct { + name string + args []string + wantErr string + }{ + { + name: "invalid log level", + args: []string{"--log-level", "verbose", "version"}, + wantErr: `invalid log level "verbose"`, + }, + { + name: "invalid log format", + args: []string{"--log-format", "yaml", "version"}, + wantErr: `invalid log format "yaml"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearIMGCLIEnv(t) + + result := executeCommand(t, Options{}, tt.args...) + + require.Error(t, result.err) + require.ErrorContains(t, result.err, tt.wantErr) + assert.Empty(t, result.stdout) + assert.Empty(t, result.stderr) + }) + } +} + +func executeCommand(t *testing.T, opts Options, args ...string) commandResult { + t.Helper() + + var stdout bytes.Buffer + var stderr bytes.Buffer + opts.Stdin = strings.NewReader("") + opts.Stdout = &stdout + opts.Stderr = &stderr + opts.Environ = []string{"TERM=dumb"} + + cmd, err := NewRootCommand(opts) + require.NoError(t, err) + cmd.SetArgs(args) + err = cmd.ExecuteContext(context.Background()) + + return commandResult{ + stdout: stdout.String(), + stderr: stderr.String(), + err: err, + } +} + +func clearIMGCLIEnv(t *testing.T) { + t.Helper() + + for _, key := range []string{ + "IMGCLI_CONFIG", + "IMGCLI_LOG_LEVEL", + "IMGCLI_LOG_FORMAT", + "IMGCLI_NO_COLOR", + } { + t.Setenv(key, "") + } +} diff --git a/internal/cli/version.go b/internal/cli/version.go new file mode 100644 index 0000000..57e5924 --- /dev/null +++ b/internal/cli/version.go @@ -0,0 +1,19 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newVersionCommand(rt *runtime) *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the imgcli version", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + _, err := fmt.Fprintln(cmd.OutOrStdout(), rt.opts.version()) + return err + }, + } +} diff --git a/moon.yml b/moon.yml index 43b6c1c..0863c71 100644 --- a/moon.yml +++ b/moon.yml @@ -1,8 +1,12 @@ +language: 'go' layer: 'application' +tags: + - 'cli' + - 'go' project: title: 'imgcli' - description: 'CLI for building disk image artifacts from Git-tracked configuration.' + description: 'CLI for building disk image artifacts from configuration.' owner: 'meigma' maintainers: - 'meigma' @@ -11,20 +15,64 @@ workspace: inheritedTasks: include: [] -# Add repository-wide maintenance tasks here as imgcli grows. -# Examples: -# -# tasks: -# format: -# command: 'prettier --check .' -# toolchains: ['javascript', 'node', 'npm'] -# inputs: [] -# options: -# cache: false -# -# docs-build: -# command: 'moon run docs:build' -# toolchains: 'system' -# inputs: [] -# options: -# cache: false +toolchains: + default: 'go' + +fileGroups: + go-config: + - 'go.mod' + - 'go.sum' + go-sources: + - 'cmd/**/*.go' + - 'internal/**/*.go' + lint-config: + - '.golangci.yml' + - '.prototools' + - '.moon/proto/golangci-lint.toml' + +tasks: + build: + command: 'go build -o bin/imgcli ./cmd/imgcli' + toolchains: ['go'] + inputs: + - '@group(go-config)' + - '@group(go-sources)' + outputs: + - 'bin/imgcli' + + format: + command: 'proto run golangci-lint -- fmt --config .golangci.yml --diff' + toolchains: ['go'] + inputs: + - '@group(go-config)' + - '@group(go-sources)' + - '@group(lint-config)' + options: + cache: false + + lint: + command: 'proto run golangci-lint -- run --config .golangci.yml ./... --show-stats=false' + toolchains: ['go'] + inputs: + - '@group(go-config)' + - '@group(go-sources)' + - '@group(lint-config)' + options: + cache: false + + test: + command: 'go test ./...' + toolchains: ['go'] + inputs: + - '@group(go-config)' + - '@group(go-sources)' + + check: + deps: + - 'root:format' + - 'root:lint' + - 'root:build' + - 'root:test' + options: + cache: false + runInCI: true