Add sutil CLI in cmd/sutil with internal/cli command tree
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
// Command sutil is sneak's shell utility: small tools backed by the
|
||||||
|
// packages in sneak.berlin/go/x. Run `sutil --help` for the command list.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
err := cli.New(cli.Params{Version: version}).Execute()
|
||||||
|
if err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"sneak.berlin/go/x/pkg/cbk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxMnemonicInputBytes bounds how much of stdin `cbk derive` will read; a
|
||||||
|
// 24-word mnemonic is well under 300 bytes.
|
||||||
|
const maxMnemonicInputBytes = 64 << 10
|
||||||
|
|
||||||
|
// newCBKCommand returns `sutil cbk`, the parent of the crypto_box
|
||||||
|
// keypair commands.
|
||||||
|
func newCBKCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "cbk",
|
||||||
|
Short: "crypto_box keypairs derived from BIP39 mnemonics",
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(newCBKDeriveCommand())
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// newCBKDeriveCommand returns `sutil cbk derive`.
|
||||||
|
func newCBKDeriveCommand() *cobra.Command {
|
||||||
|
var publicOnly bool
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "derive",
|
||||||
|
Short: "Derive a crypto_box keypair from a mnemonic read on stdin",
|
||||||
|
Long: `Reads a BIP39 mnemonic from stdin, in any letter case and with any
|
||||||
|
whitespace between words, and prints the crypto_box keypair derived from it
|
||||||
|
as hex. The same mnemonic always yields the same keypair.
|
||||||
|
|
||||||
|
The mnemonic is read from stdin rather than taken as an argument so that it
|
||||||
|
does not end up in shell history or process listings:
|
||||||
|
|
||||||
|
sutil generate mnemonic > mnemonic.txt
|
||||||
|
sutil cbk derive < mnemonic.txt`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
input := io.LimitReader(cmd.InOrStdin(), maxMnemonicInputBytes)
|
||||||
|
|
||||||
|
mnemonic, err := io.ReadAll(input)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading mnemonic from stdin: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keypair, err := cbk.New(cbk.Params{Mnemonic: string(mnemonic)})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return writeKeypair(cmd.OutOrStdout(), keypair, publicOnly)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVar(&publicOnly, "public", false, "print only the public key")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeKeypair prints the keypair as labeled hex lines, or just the bare
|
||||||
|
// public key when publicOnly is set.
|
||||||
|
func writeKeypair(w io.Writer, keypair cbk.CryptoBoxKeypair, publicOnly bool) error {
|
||||||
|
if publicOnly {
|
||||||
|
_, err := fmt.Fprintln(w, keypair.PublicKeyHex())
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := fmt.Fprintf(
|
||||||
|
w, "public: %s\nprivate: %s\n", keypair.PublicKeyHex(), keypair.PrivateKeyHex(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Package cli implements the sutil command tree.
|
||||||
|
//
|
||||||
|
// The tree is built by New rather than assembled in init functions, so
|
||||||
|
// the package holds no global state and tests can construct it repeatedly
|
||||||
|
// with captured input and output.
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Params configures New.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds the root sutil command with every subcommand attached.
|
||||||
|
func New(p Params) *cobra.Command {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
root.AddCommand(
|
||||||
|
newVersionCommand(p),
|
||||||
|
newInfoCommand(p),
|
||||||
|
newGenerateCommand(),
|
||||||
|
newCBKCommand(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return root
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package cli_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"sneak.berlin/go/x/internal/cli"
|
||||||
|
"sneak.berlin/go/x/internal/pwgen"
|
||||||
|
"sneak.berlin/go/x/pkg/bip39wordlist"
|
||||||
|
"sneak.berlin/go/x/pkg/cbk"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testVersion = "v1.2.3-test"
|
||||||
|
|
||||||
|
// vectorRepeats is how many times "abandon" appears in the standard
|
||||||
|
// BIP39 test vector for all-zero 128-bit entropy, which ends with
|
||||||
|
// "about".
|
||||||
|
vectorRepeats = bip39wordlist.MinWordCount - 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// vectorMnemonic returns the standard BIP39 test vector for all-zero
|
||||||
|
// 128-bit entropy.
|
||||||
|
func vectorMnemonic() string {
|
||||||
|
return strings.Repeat("abandon ", vectorRepeats) + "about"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersionCommand(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
output, err := execute(t, "", "version")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testVersion+"\n", output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersionFlag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
output, err := execute(t, "", "--version")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, output, testVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInfoCommand(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
output, err := execute(t, "", "info")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, output, "version: "+testVersion+"\n")
|
||||||
|
assert.Contains(t, output, "go: go")
|
||||||
|
assert.Contains(t, output, "module: sneak.berlin/go/x\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateRandomSeededIsDeterministic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
first, err := execute(t, "", "generate", "random", "--count", "4096", "seed")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, first, 4096)
|
||||||
|
|
||||||
|
second, err := execute(t, "", "generate", "random", "--count", "4096", "seed")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, first, second)
|
||||||
|
|
||||||
|
other, err := execute(t, "", "gen", "random", "-c", "4096", "other seed")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEqual(t, first, other)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateRandomUnseeded(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
first, err := execute(t, "", "generate", "random", "--count", "1024")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, first, 1024)
|
||||||
|
|
||||||
|
second, err := execute(t, "", "generate", "random", "--count", "1024")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEqual(t, first, second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateRandomRejectsExtraArguments(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := execute(t, "", "generate", "random", "one", "two")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateMnemonic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
output, err := execute(t, "", "generate", "mnemonic")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mnemonic := strings.TrimSuffix(output, "\n")
|
||||||
|
require.NoError(t, bip39wordlist.ValidateWordlist(mnemonic))
|
||||||
|
assert.Len(t, strings.Fields(mnemonic), bip39wordlist.MaxWordCount)
|
||||||
|
|
||||||
|
output, err = execute(t, "", "generate", "mnemonic", "--words", "12")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, strings.Fields(output), bip39wordlist.MinWordCount)
|
||||||
|
|
||||||
|
_, err = execute(t, "", "generate", "mnemonic", "--words", "13")
|
||||||
|
require.ErrorIs(t, err, bip39wordlist.ErrInvalidWordCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratePassword(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := pwgen.New(pwgen.Params{Words: nil, MinWordLength: 0, MaxWordLength: 0})
|
||||||
|
if errors.Is(err, pwgen.ErrNoUsableWords) {
|
||||||
|
t.Skip("this system has no word list")
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
output, err := execute(t, "", "generate", "password")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
password := strings.TrimSuffix(output, "\n")
|
||||||
|
assert.NotContains(t, password, "\n")
|
||||||
|
assert.GreaterOrEqual(t, len(password), 3*(pwgen.DefaultMinWordLength+1)+2)
|
||||||
|
assert.True(t, strings.ContainsAny(password, "0123456789"), password)
|
||||||
|
assert.True(t, strings.ContainsAny(password, pwgen.Delimiters), password)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCBKDerive(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
expected, err := cbk.FromMnemonic(vectorMnemonic())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
stdin := " " + strings.ToUpper(vectorMnemonic()) + "\n"
|
||||||
|
|
||||||
|
output, err := execute(t, stdin, "cbk", "derive")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t,
|
||||||
|
"public: "+expected.PublicKeyHex()+"\n"+
|
||||||
|
"private: "+expected.PrivateKeyHex()+"\n",
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
output, err = execute(t, vectorMnemonic(), "cbk", "derive", "--public")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, expected.PublicKeyHex()+"\n", output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCBKDeriveRejectsInvalidMnemonic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := execute(t, "not a mnemonic\n", "cbk", "derive")
|
||||||
|
require.ErrorIs(t, err, bip39wordlist.ErrInvalidWordCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// execute runs sutil with the given stdin and arguments 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})
|
||||||
|
|
||||||
|
var output bytes.Buffer
|
||||||
|
|
||||||
|
root.SetOut(&output)
|
||||||
|
root.SetErr(&output)
|
||||||
|
root.SetIn(strings.NewReader(stdin))
|
||||||
|
root.SetArgs(args)
|
||||||
|
|
||||||
|
err := root.Execute()
|
||||||
|
|
||||||
|
return output.String(), err
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"sneak.berlin/go/x/internal/pwgen"
|
||||||
|
"sneak.berlin/go/x/internal/randstream"
|
||||||
|
"sneak.berlin/go/x/pkg/bip39wordlist"
|
||||||
|
)
|
||||||
|
|
||||||
|
// streamChunkSize is the write size used by `generate random`. Large
|
||||||
|
// writes keep the per-syscall overhead negligible.
|
||||||
|
const streamChunkSize = 1 << 20
|
||||||
|
|
||||||
|
// newGenerateCommand returns `sutil generate`, the parent of everything
|
||||||
|
// that produces random output.
|
||||||
|
func newGenerateCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "generate",
|
||||||
|
Aliases: []string{"gen"},
|
||||||
|
Short: "Generate random bytes, passwords, and mnemonics",
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(
|
||||||
|
newGenerateRandomCommand(),
|
||||||
|
newGeneratePasswordCommand(),
|
||||||
|
newGenerateMnemonicCommand(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// newGenerateRandomCommand returns `sutil generate random [seedstr]`.
|
||||||
|
func newGenerateRandomCommand() *cobra.Command {
|
||||||
|
var byteCount int64
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "random [seedstr]",
|
||||||
|
Short: "Write a fast stream of pseudorandom bytes to stdout",
|
||||||
|
Long: `Writes pseudorandom bytes to stdout until stopped, or until --count
|
||||||
|
bytes have been written.
|
||||||
|
|
||||||
|
With seedstr the stream is deterministic: the same seed always produces
|
||||||
|
the same bytes. Without it the stream is keyed from the operating
|
||||||
|
system's secure random source and is different every run.
|
||||||
|
|
||||||
|
This exists because /dev/urandom is slow.`,
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
seed := ""
|
||||||
|
if len(args) == 1 {
|
||||||
|
seed = args[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
stream, err := randstream.New(randstream.Params{Seed: seed})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return streamBytes(cmd.OutOrStdout(), stream, byteCount)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().Int64VarP(
|
||||||
|
&byteCount, "count", "c", 0, "stop after this many bytes (0 = never)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// newGeneratePasswordCommand returns `sutil generate password`.
|
||||||
|
func newGeneratePasswordCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "password",
|
||||||
|
Aliases: []string{"pw"},
|
||||||
|
Short: "Print a memorable password built from system dictionary words",
|
||||||
|
Long: `Prints a password of the form
|
||||||
|
|
||||||
|
<word><digit><delim><word><digit><delim><word><digit>
|
||||||
|
|
||||||
|
for example marble7-sprout2-velvet9. The words come from the system word
|
||||||
|
list (/usr/share/dict/words), the delimiter is chosen at random from
|
||||||
|
|
||||||
|
` + pwgen.Delimiters + `
|
||||||
|
|
||||||
|
and is the same in both positions. The command panics if the system has
|
||||||
|
no usable word list.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
generator, err := pwgen.New(pwgen.Params{})
|
||||||
|
if err != nil {
|
||||||
|
panic("sutil: cannot load the system word list: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
password, err := generator.Generate()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = fmt.Fprintln(cmd.OutOrStdout(), password)
|
||||||
|
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newGenerateMnemonicCommand returns `sutil generate mnemonic`.
|
||||||
|
func newGenerateMnemonicCommand() *cobra.Command {
|
||||||
|
var wordCount int
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "mnemonic",
|
||||||
|
Short: "Print a new random BIP39 mnemonic phrase",
|
||||||
|
Long: `Prints a new BIP39 mnemonic phrase generated from crypto/rand. The
|
||||||
|
default of 24 words encodes 256 bits of entropy; 12 words encodes 128.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
mnemonic, err := bip39wordlist.GenerateMnemonic(wordCount)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = fmt.Fprintln(cmd.OutOrStdout(), mnemonic)
|
||||||
|
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().IntVarP(
|
||||||
|
&wordCount, "words", "w", bip39wordlist.MaxWordCount,
|
||||||
|
"number of words: 12, 15, 18, 21, or 24",
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamBytes copies limit bytes (or everything, when limit is zero) from
|
||||||
|
// src to dst in streamChunkSize chunks.
|
||||||
|
func streamBytes(dst io.Writer, src io.Reader, limit int64) error {
|
||||||
|
if limit > 0 {
|
||||||
|
src = io.LimitReader(src, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapping dst hides any ReadFrom method it has, so io.CopyBuffer
|
||||||
|
// cannot bypass the large buffer.
|
||||||
|
_, err := io.CopyBuffer(writerOnly{dst}, src, make([]byte, streamChunkSize))
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// writerOnly exposes only the Write method of the wrapped writer.
|
||||||
|
type writerOnly struct {
|
||||||
|
io.Writer
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"runtime/debug"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNoBuildInfo is returned by `sutil info` when the binary carries no
|
||||||
|
// Go build information, which only happens for binaries not built with
|
||||||
|
// module support.
|
||||||
|
var ErrNoBuildInfo = errors.New("sutil: no build information in this binary")
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeBuildInfo writes one "key: value" line per fact to w.
|
||||||
|
func writeBuildInfo(w io.Writer, version string) error {
|
||||||
|
info, ok := debug.ReadBuildInfo()
|
||||||
|
if !ok {
|
||||||
|
return ErrNoBuildInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := []string{
|
||||||
|
"version: " + version,
|
||||||
|
"go: " + info.GoVersion,
|
||||||
|
"module: " + info.Main.Path,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, setting := range info.Settings {
|
||||||
|
if strings.HasPrefix(setting.Key, "vcs.") {
|
||||||
|
lines = append(lines, setting.Key+": "+setting.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := fmt.Fprintln(w, strings.Join(lines, "\n"))
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newVersionCommand returns `sutil version`, which prints the build
|
||||||
|
// version on its own line.
|
||||||
|
func newVersionCommand(p Params) *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "version",
|
||||||
|
Short: "Print the sutil version",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
_, err := fmt.Fprintln(cmd.OutOrStdout(), p.Version)
|
||||||
|
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user