From 4d45aa4ca34235d03c5513b40227863e60307452 Mon Sep 17 00:00:00 2001 From: sneak Date: Sat, 31 Oct 2026 10:11:47 -0700 Subject: [PATCH] Restore config loading, keep exported Version, keep cgo off Bring back the in-progress viper and go-homedir configuration loading from the original root command as a persistent pre-run on the root command, with a --config flag and a config line in sutil info. Restore the exported main.Version with a nolint directive instead of renaming it. Export CGO_ENABLED=0 from the Makefile, drop -race from make test, and remove the C toolchain from the Docker builder stage. --- Dockerfile | 6 ++-- Makefile | 10 ++++-- cmd/sutil/main.go | 10 +++--- go.mod | 12 ++++++- go.sum | 33 +++++++++++++++++- internal/cli/cli.go | 34 ++++++++++++++++--- internal/cli/cli_test.go | 70 ++++++++++++++++++++++++++++++++++++-- internal/cli/config.go | 73 ++++++++++++++++++++++++++++++++++++++++ internal/cli/info.go | 20 ++++++++--- 9 files changed, 243 insertions(+), 25 deletions(-) create mode 100644 internal/cli/config.go diff --git a/Dockerfile b/Dockerfile index efb6295..9fc066d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,9 +33,7 @@ RUN make fmt-check-md # Build stage # golang:1.26-alpine, 2026-10-27 FROM golang@sha256:a2012b4a1d1f9f7e9f44e0d39b805a901e856b1dd80b4e6ee4338db547050308 AS builder -# gcc and musl-dev are needed only because `make test` uses -race, which -# requires cgo; the release binary below is built with CGO_ENABLED=0. -RUN apk add --no-cache make gcc musl-dev +RUN apk add --no-cache make WORKDIR /src # Force BuildKit to run the lint and docs stages before proceeding @@ -49,7 +47,7 @@ RUN make test ARG VERSION=dev RUN CGO_ENABLED=0 go build -trimpath \ - -ldflags="-s -w -X main.version=${VERSION}" \ + -ldflags="-s -w -X main.Version=${VERSION}" \ -o /sutil ./cmd/sutil # Runtime stage: the binary plus the full source it was built from diff --git a/Makefile b/Makefile index 9c7e9b3..e731973 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ BINARY := sutil VERSION := $(shell git describe --always --dirty 2>/dev/null || echo dev) -GOLDFLAGS := -X main.version=$(VERSION) +GOLDFLAGS := -X main.Version=$(VERSION) GO_DIRS := cmd internal pkg # golangci/golangci-lint:v2.12.2, 2026-05-06 @@ -25,6 +25,10 @@ MARKDOWN_GLOB := '**/*.md' all: build +# cgo stays off everywhere: builds are static and the tests do not use +# -race, which would require a C toolchain. +export CGO_ENABLED := 0 + # Build the sutil binary in the repo root with the git version embedded. build: go build -trimpath -ldflags "$(GOLDFLAGS)" -o $(BINARY) ./cmd/$(BINARY) @@ -36,9 +40,9 @@ install: # Run the tests quietly; on failure rerun verbosely for diagnostics and # still fail. test: - @go test -timeout 30s -race -cover ./... || \ + @go test -timeout 30s -cover ./... || \ { echo "--- Rerunning with -v for details ---"; \ - go test -timeout 30s -race -v ./...; exit 1; } + go test -timeout 30s -v ./...; exit 1; } # Lint with the pinned golangci-lint image when docker is available so local # results match CI exactly. Inside the Docker build (where there is no diff --git a/cmd/sutil/main.go b/cmd/sutil/main.go index 185e43c..6825c67 100644 --- a/cmd/sutil/main.go +++ b/cmd/sutil/main.go @@ -8,12 +8,14 @@ import ( "sneak.berlin/go/x/internal/cli" ) -// version is the build version. The Makefile and Dockerfile set it at link -// time with -ldflags "-X main.version=...". -var version = "dev" +// Version is the build version. The Makefile and Dockerfile set it at link +// time with -ldflags "-X main.Version=...". +// +//nolint:gochecknoglobals // set by the linker; there is no other way +var Version = "dev" func main() { - err := cli.New(cli.Params{Version: version}).Execute() + err := cli.New(cli.Params{Version: Version, ConfigDir: ""}).Execute() if err != nil { os.Exit(1) } diff --git a/go.mod b/go.mod index b08638d..37c8646 100644 --- a/go.mod +++ b/go.mod @@ -4,15 +4,25 @@ go 1.26.0 require ( github.com/kavfixnel/words v1.0.0 + github.com/mitchellh/go-homedir v1.1.0 github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.12.1 github.com/tyler-smith/go-bip39 v1.1.0 golang.org/x/crypto v0.58.0 ) require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/pflag v1.0.9 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // 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/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.43.0 // indirect diff --git a/go.sum b/go.sum index 1d7aab9..900db4e 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,46 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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-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/kavfixnel/words v1.0.0 h1:k7/KXUnLIVNCAlBLXhdq1GunnxXmQqglZooHA+NRzuQ= github.com/kavfixnel/words v1.0.0/go.mod h1:i2LITxcnsJnKfT+ruhPGVwzdcRDnsgQTL9TIooX9XAU= +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/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +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/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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 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.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index f7bf717..c168cc4 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -7,6 +7,7 @@ package cli import ( "github.com/spf13/cobra" + "github.com/spf13/viper" ) // Params configures New. @@ -14,23 +15,48 @@ type Params struct { // Version is the version string embedded at build time. It is shown // by `sutil version`, `sutil --version`, and `sutil info`. Version string + + // ConfigDir is the directory searched for the optional .sneak + // configuration file. Empty means the user's home directory; tests set + // it to a temporary directory so the real home is never consulted. + ConfigDir string +} + +// app carries the state shared by every command: the build parameters, +// the --config flag, and the configuration loaded before any command runs. +type app struct { + params Params + configFile string + config *viper.Viper } // New builds the root sutil command with every subcommand attached. func New(p Params) *cobra.Command { + a := &app{params: p, configFile: "", config: nil} + root := &cobra.Command{ Use: "sutil", Short: "sneak's shell utility", Long: `sutil is a small collection of shell utilities built on the packages in sneak.berlin/go/x: fast pseudorandom byte streams, memorable passwords, -BIP39 mnemonics, and crypto_box keypairs derived from them.`, - Version: p.Version, - SilenceUsage: true, +BIP39 mnemonics, and crypto_box keypairs derived from them. + +Before any command runs, sutil loads the optional configuration file named +by --config, or else ~/.sneak.yaml (or .json, .toml, ...) if it exists, and +makes environment variables available alongside it.`, + Version: p.Version, + SilenceUsage: true, + PersistentPreRunE: a.loadConfig, } + root.PersistentFlags().StringVar( + &a.configFile, "config", "", + "config file (default is "+configName+".* in the home directory)", + ) + root.AddCommand( newVersionCommand(p), - newInfoCommand(p), + newInfoCommand(a), newGenerateCommand(), newCBKCommand(), ) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index c900fbc..b2f95d3 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -3,6 +3,8 @@ package cli_test import ( "bytes" "errors" + "os" + "path/filepath" "strings" "testing" @@ -54,6 +56,56 @@ func TestInfoCommand(t *testing.T) { assert.Contains(t, output, "version: "+testVersion+"\n") assert.Contains(t, output, "go: go") assert.Contains(t, output, "module: sneak.berlin/go/x\n") + assert.Contains(t, output, "config: (none)\n") +} + +func TestConfigFileDiscoveredInConfigDir(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + path := filepath.Join(configDir, ".sneak.yaml") + require.NoError(t, os.WriteFile(path, []byte("example: 1\n"), 0o600)) + + params := cli.Params{Version: testVersion, ConfigDir: configDir} + + output, err := executeWithParams(t, params, "", "info") + require.NoError(t, err) + assert.Contains(t, output, "Using config file: "+path+"\n") + assert.Contains(t, output, "config: "+path+"\n") +} + +func TestConfigFlagLoadsNamedFile(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "elsewhere.yaml") + require.NoError(t, os.WriteFile(path, []byte("example: 1\n"), 0o600)) + + output, err := execute(t, "", "--config", path, "info") + require.NoError(t, err) + assert.Contains(t, output, "Using config file: "+path+"\n") + assert.Contains(t, output, "config: "+path+"\n") +} + +func TestConfigFlagRejectsMissingFile(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "missing.yaml") + + _, err := execute(t, "", "--config", path, "version") + require.Error(t, err) +} + +func TestConfigRejectsMalformedFile(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + path := filepath.Join(configDir, ".sneak.yaml") + require.NoError(t, os.WriteFile(path, []byte("not: [valid\n"), 0o600)) + + params := cli.Params{Version: testVersion, ConfigDir: configDir} + + _, err := executeWithParams(t, params, "", "version") + require.Error(t, err) } func TestGenerateRandomSeededIsDeterministic(t *testing.T) { @@ -157,12 +209,24 @@ func TestCBKDeriveRejectsInvalidMnemonic(t *testing.T) { require.ErrorIs(t, err, bip39wordlist.ErrInvalidWordCount) } -// execute runs sutil with the given stdin and arguments and returns -// everything it wrote to stdout and stderr. +// execute runs sutil with the given stdin and arguments, looking for config +// in an empty temporary directory, and returns everything it wrote to +// stdout and stderr. func execute(t *testing.T, stdin string, args ...string) (string, error) { t.Helper() - root := cli.New(cli.Params{Version: testVersion}) + params := cli.Params{Version: testVersion, ConfigDir: t.TempDir()} + + return executeWithParams(t, params, stdin, args...) +} + +// executeWithParams is execute with explicit Params. +func executeWithParams( + t *testing.T, params cli.Params, stdin string, args ...string, +) (string, error) { + t.Helper() + + root := cli.New(params) var output bytes.Buffer diff --git a/internal/cli/config.go b/internal/cli/config.go new file mode 100644 index 0000000..3326558 --- /dev/null +++ b/internal/cli/config.go @@ -0,0 +1,73 @@ +package cli + +import ( + "errors" + "fmt" + + "github.com/mitchellh/go-homedir" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// configName is the base name of the optional configuration file looked +// up in the config directory; viper tries every extension it supports, so +// ~/.sneak.yaml, ~/.sneak.json, ~/.sneak.toml, and so on all work. +const configName = ".sneak" + +// loadConfig runs before every command. It reads the optional +// configuration file into a.config and makes environment variables +// available through it. The file is the one named by --config, or else +// configName in Params.ConfigDir, which defaults to the user's home +// directory. A missing default file is not an error; a file that is named +// explicitly but missing, or one that cannot be parsed, is. +func (a *app) loadConfig(cmd *cobra.Command, _ []string) error { + configDir := a.params.ConfigDir + if configDir == "" { + home, err := homedir.Dir() + if err != nil { + return fmt.Errorf("finding home directory: %w", err) + } + + configDir = home + } + + config := viper.New() + config.AddConfigPath(configDir) + config.SetConfigName(configName) + config.AutomaticEnv() + + if a.configFile != "" { + config.SetConfigFile(a.configFile) + } + + err := config.ReadInConfig() + + var notFound viper.ConfigFileNotFoundError + if errors.As(err, ¬Found) { + a.config = config + + return nil + } + + if err != nil { + return fmt.Errorf("reading config file: %w", err) + } + + a.config = config + + _, err = fmt.Fprintln( + cmd.ErrOrStderr(), "Using config file:", config.ConfigFileUsed(), + ) + + return err +} + +// configFileUsed returns the path of the configuration file that +// loadConfig read, or an empty string if there was none. +func (a *app) configFileUsed() string { + if a.config == nil { + return "" + } + + return a.config.ConfigFileUsed() +} diff --git a/internal/cli/info.go b/internal/cli/info.go index f95ac29..e810e77 100644 --- a/internal/cli/info.go +++ b/internal/cli/info.go @@ -15,31 +15,41 @@ import ( // module support. var ErrNoBuildInfo = errors.New("sutil: no build information in this binary") +// noConfigFile is what `sutil info` prints when no config file was loaded. +const noConfigFile = "(none)" + // newInfoCommand returns `sutil info`, which prints the build version, the -// Go toolchain version, the module path, and the VCS metadata recorded by -// the Go toolchain at build time. -func newInfoCommand(p Params) *cobra.Command { +// Go toolchain version, the module path, the configuration file in use, +// and the VCS metadata recorded by the Go toolchain at build time. +func newInfoCommand(a *app) *cobra.Command { return &cobra.Command{ Use: "info", Short: "Print build information", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - return writeBuildInfo(cmd.OutOrStdout(), p.Version) + return writeBuildInfo( + cmd.OutOrStdout(), a.params.Version, a.configFileUsed(), + ) }, } } // writeBuildInfo writes one "key: value" line per fact to w. -func writeBuildInfo(w io.Writer, version string) error { +func writeBuildInfo(w io.Writer, version, configFile string) error { info, ok := debug.ReadBuildInfo() if !ok { return ErrNoBuildInfo } + if configFile == "" { + configFile = noConfigFile + } + lines := []string{ "version: " + version, "go: " + info.GoVersion, "module: " + info.Main.Path, + "config: " + configFile, } for _, setting := range info.Settings {