Compare commits

...
2 Commits
Author SHA1 Message Date
sneak 9def7ba586 Document config loading, cgo policy, and tagging policy in README 2026-10-31 10:11:49 -07:00
sneak 4d45aa4ca3 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.
2026-10-31 10:11:47 -07:00
10 changed files with 266 additions and 34 deletions

No files matched your search

+2 -4
View File
@@ -33,9 +33,7 @@ RUN make fmt-check-md
# Build stage # Build stage
# golang:1.26-alpine, 2026-10-27 # golang:1.26-alpine, 2026-10-27
FROM golang@sha256:a2012b4a1d1f9f7e9f44e0d39b805a901e856b1dd80b4e6ee4338db547050308 AS builder FROM golang@sha256:a2012b4a1d1f9f7e9f44e0d39b805a901e856b1dd80b4e6ee4338db547050308 AS builder
# gcc and musl-dev are needed only because `make test` uses -race, which RUN apk add --no-cache make
# requires cgo; the release binary below is built with CGO_ENABLED=0.
RUN apk add --no-cache make gcc musl-dev
WORKDIR /src WORKDIR /src
# Force BuildKit to run the lint and docs stages before proceeding # Force BuildKit to run the lint and docs stages before proceeding
@@ -49,7 +47,7 @@ RUN make test
ARG VERSION=dev ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \ 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 -o /sutil ./cmd/sutil
# Runtime stage: the binary plus the full source it was built from # Runtime stage: the binary plus the full source it was built from
+7 -3
View File
@@ -9,7 +9,7 @@
BINARY := sutil BINARY := sutil
VERSION := $(shell git describe --always --dirty 2>/dev/null || echo dev) 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 GO_DIRS := cmd internal pkg
# golangci/golangci-lint:v2.12.2, 2026-05-06 # golangci/golangci-lint:v2.12.2, 2026-05-06
@@ -25,6 +25,10 @@ MARKDOWN_GLOB := '**/*.md'
all: build 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 the sutil binary in the repo root with the git version embedded.
build: build:
go build -trimpath -ldflags "$(GOLDFLAGS)" -o $(BINARY) ./cmd/$(BINARY) 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 # Run the tests quietly; on failure rerun verbosely for diagnostics and
# still fail. # still fail.
test: test:
@go test -timeout 30s -race -cover ./... || \ @go test -timeout 30s -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { 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 # 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 # results match CI exactly. Inside the Docker build (where there is no
+23 -9
View File
@@ -48,9 +48,13 @@ sutil generate mnemonic > mnemonic.txt
sutil cbk derive < mnemonic.txt sutil cbk derive < mnemonic.txt
sutil cbk derive --public < mnemonic.txt sutil cbk derive --public < mnemonic.txt
# Build metadata. # Build metadata, including which config file (if any) was loaded.
sutil version sutil version
sutil info sutil info
# Every command first loads ~/.sneak.yaml (or .json, .toml, ...) if it
# exists, or the file named with --config.
sutil --config ./sneak.yaml info
``` ```
### Use the packages ### Use the packages
@@ -138,7 +142,13 @@ of the module stays small and intentional.
The command tree holds no global state: `cli.New` builds every command from a The command tree holds no global state: `cli.New` builds every command from a
`Params` struct, so tests construct it repeatedly with captured input and `Params` struct, so tests construct it repeatedly with captured input and
output, and nothing happens in `init` functions. output, and nothing happens in `init` functions. Before any command runs, the
root command's persistent pre-run loads the optional configuration file (the one
named by `--config`, or else `.sneak.*` in the home directory found with
`go-homedir`) into a viper instance that also exposes environment variables. No
command consumes settings from it yet; `sutil info` reports which file was
loaded. Tests point `Params.ConfigDir` at a temporary directory so the real home
directory is never read.
### `pkg/cbk` ### `pkg/cbk`
@@ -226,11 +236,11 @@ sutil
### Build, test, and CI ### Build, test, and CI
The `Makefile` is the single entry point: `make test` (30-second timeout, race The `Makefile` is the single entry point: `make test` (30-second timeout, quiet
detector, quiet unless something fails), `make lint`, `make fmt` and unless something fails), `make lint`, `make fmt` and `make fmt-check` (gofmt for
`make fmt-check` (gofmt for Go, prettier for markdown), `make check` for all Go, prettier for markdown), `make check` for all three, `make build`,
three, `make build`, `make install`, `make docker`, `make hooks`, `make tidy`, `make install`, `make docker`, `make hooks`, `make tidy`, and `make setup` to
and `make setup` to install the pinned prettier. install the pinned prettier.
`make lint` runs golangci-lint inside the same hash-pinned Docker image the CI `make lint` runs golangci-lint inside the same hash-pinned Docker image the CI
build uses, so local results match CI exactly. The `Dockerfile` splits build uses, so local results match CI exactly. The `Dockerfile` splits
@@ -240,7 +250,12 @@ on both. The runtime image carries the binary and the source it was built from.
Every external reference, from base images to the GitHub Action in Every external reference, from base images to the GitHub Action in
`.gitea/workflows/check.yml`, is pinned by content hash with a version and date `.gitea/workflows/check.yml`, is pinned by content hash with a version and date
comment. `.golangci.yml` is the standard configuration and is never edited by comment. `.golangci.yml` is the standard configuration and is never edited by
hand here. hand here. cgo stays off: the Makefile exports `CGO_ENABLED=0` for every build
and test, so binaries are static and no C toolchain is needed anywhere.
Version tags are created by the author alone, when the author decides. Tooling,
contributors, and agents working in this repository do not propose, schedule, or
create tags.
## Development ## Development
@@ -254,7 +269,6 @@ make docker # the full CI build
## TODO ## TODO
- [ ] Tag `v0.1.0` once `main` is green in CI.
- [ ] Bump the pinned `golangci/golangci-lint` image (in `Makefile` and - [ ] Bump the pinned `golangci/golangci-lint` image (in `Makefile` and
`Dockerfile`) past v2.12.2 once the canonical `.golangci.yml` disables `Dockerfile`) past v2.12.2 once the canonical `.golangci.yml` disables
`exhaustruct_v5`, which v2.13.0 added alongside the already-disabled `exhaustruct_v5`, which v2.13.0 added alongside the already-disabled
+6 -4
View File
@@ -8,12 +8,14 @@ import (
"sneak.berlin/go/x/internal/cli" "sneak.berlin/go/x/internal/cli"
) )
// version is the build version. The Makefile and Dockerfile set it at link // Version is the build version. The Makefile and Dockerfile set it at link
// time with -ldflags "-X main.version=...". // time with -ldflags "-X main.Version=...".
var version = "dev" //
//nolint:gochecknoglobals // set by the linker; there is no other way
var Version = "dev"
func main() { func main() {
err := cli.New(cli.Params{Version: version}).Execute() err := cli.New(cli.Params{Version: Version, ConfigDir: ""}).Execute()
if err != nil { if err != nil {
os.Exit(1) os.Exit(1)
} }
+11 -1
View File
@@ -4,15 +4,25 @@ go 1.26.0
require ( require (
github.com/kavfixnel/words v1.0.0 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/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.12.1 github.com/stretchr/testify v1.12.1
github.com/tyler-smith/go-bip39 v1.1.0 github.com/tyler-smith/go-bip39 v1.1.0
golang.org/x/crypto v0.58.0 golang.org/x/crypto v0.58.0
) )
require ( 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/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 go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/sys v0.49.0 // indirect golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect golang.org/x/text v0.43.0 // indirect
+32 -1
View File
@@ -1,15 +1,46 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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 h1:k7/KXUnLIVNCAlBLXhdq1GunnxXmQqglZooHA+NRzuQ=
github.com/kavfixnel/words v1.0.0/go.mod h1:i2LITxcnsJnKfT+ruhPGVwzdcRDnsgQTL9TIooX9XAU= 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/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 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 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.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 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= 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 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= 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= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+30 -4
View File
@@ -7,6 +7,7 @@ package cli
import ( import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper"
) )
// Params configures New. // Params configures New.
@@ -14,23 +15,48 @@ type Params struct {
// Version is the version string embedded at build time. It is shown // Version is the version string embedded at build time. It is shown
// by `sutil version`, `sutil --version`, and `sutil info`. // by `sutil version`, `sutil --version`, and `sutil info`.
Version string 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. // New builds the root sutil command with every subcommand attached.
func New(p Params) *cobra.Command { func New(p Params) *cobra.Command {
a := &app{params: p, configFile: "", config: nil}
root := &cobra.Command{ root := &cobra.Command{
Use: "sutil", Use: "sutil",
Short: "sneak's shell utility", Short: "sneak's shell utility",
Long: `sutil is a small collection of shell utilities built on the packages in Long: `sutil is a small collection of shell utilities built on the packages in
sneak.berlin/go/x: fast pseudorandom byte streams, memorable passwords, sneak.berlin/go/x: fast pseudorandom byte streams, memorable passwords,
BIP39 mnemonics, and crypto_box keypairs derived from them.`, BIP39 mnemonics, and crypto_box keypairs derived from them.
Version: p.Version,
SilenceUsage: true, 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( root.AddCommand(
newVersionCommand(p), newVersionCommand(p),
newInfoCommand(p), newInfoCommand(a),
newGenerateCommand(), newGenerateCommand(),
newCBKCommand(), newCBKCommand(),
) )
+67 -3
View File
@@ -3,6 +3,8 @@ package cli_test
import ( import (
"bytes" "bytes"
"errors" "errors"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
@@ -54,6 +56,56 @@ func TestInfoCommand(t *testing.T) {
assert.Contains(t, output, "version: "+testVersion+"\n") assert.Contains(t, output, "version: "+testVersion+"\n")
assert.Contains(t, output, "go: go") assert.Contains(t, output, "go: go")
assert.Contains(t, output, "module: sneak.berlin/go/x\n") 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) { func TestGenerateRandomSeededIsDeterministic(t *testing.T) {
@@ -157,12 +209,24 @@ func TestCBKDeriveRejectsInvalidMnemonic(t *testing.T) {
require.ErrorIs(t, err, bip39wordlist.ErrInvalidWordCount) require.ErrorIs(t, err, bip39wordlist.ErrInvalidWordCount)
} }
// execute runs sutil with the given stdin and arguments and returns // execute runs sutil with the given stdin and arguments, looking for config
// everything it wrote to stdout and stderr. // 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) { func execute(t *testing.T, stdin string, args ...string) (string, error) {
t.Helper() 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 var output bytes.Buffer
+73
View File
@@ -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, &notFound) {
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()
}
+15 -5
View File
@@ -15,31 +15,41 @@ import (
// module support. // module support.
var ErrNoBuildInfo = errors.New("sutil: no build information in this binary") 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 // newInfoCommand returns `sutil info`, which prints the build version, the
// Go toolchain version, the module path, and the VCS metadata recorded by // Go toolchain version, the module path, the configuration file in use,
// the Go toolchain at build time. // and the VCS metadata recorded by the Go toolchain at build time.
func newInfoCommand(p Params) *cobra.Command { func newInfoCommand(a *app) *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "info", Use: "info",
Short: "Print build information", Short: "Print build information",
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { 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. // 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() info, ok := debug.ReadBuildInfo()
if !ok { if !ok {
return ErrNoBuildInfo return ErrNoBuildInfo
} }
if configFile == "" {
configFile = noConfigFile
}
lines := []string{ lines := []string{
"version: " + version, "version: " + version,
"go: " + info.GoVersion, "go: " + info.GoVersion,
"module: " + info.Main.Path, "module: " + info.Main.Path,
"config: " + configFile,
} }
for _, setting := range info.Settings { for _, setting := range info.Settings {