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.
This commit is contained in:
2026-10-31 10:11:47 -07:00
parent f53b6aad42
commit 4d45aa4ca3
9 changed files with 243 additions and 25 deletions
+30 -4
View File
@@ -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(),
)
+67 -3
View File
@@ -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
+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.
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 {