Files
keyfunc/internal/cli/cli.go
T
sneak a6a9c7b18e
check / check (push) Waiting to run
Put the terminal back when a signal ends the mnemonic prompt (closes #91)
A SIGINT, SIGTERM or SIGHUP at the mnemonic prompt killed the tool
with echo still off. The prompt now catches them with signals.Notify
from before echo goes off, and reads the line in a goroutine. When one
arrives it puts the terminal settings back, stops catching, and sends
the tool the same signal again, so the tool is still ended by it (130
for SIGINT in a shell). A signal the tool was started with set to be
ignored stays ignored. A test sends SIGINT at the prompt on a
pseudo-terminal.

Unchanged: the Windows prompt, which uses term.ReadPassword.

Model: opus-5-5
2026-10-14 21:47:30 +00:00

107 lines
3.3 KiB
Go

// Package cli builds the command tree and runs it.
package cli
import (
"errors"
"fmt"
"os"
"runtime"
"runtime/debug"
"github.com/spf13/cobra"
"sneak.berlin/go/keyfunc/internal/cli/age"
"sneak.berlin/go/keyfunc/internal/cli/mnemonic"
"sneak.berlin/go/keyfunc/internal/cli/options"
"sneak.berlin/go/keyfunc/internal/cli/ssh"
)
// devVersion is what Version holds until a build stamps a real one.
const devVersion = "dev"
// Version is what --version prints. make build stamps it with -ldflags.
//
//nolint:gochecknoglobals // set at build time with -ldflags
var Version = devVersion
// resolveVersion chooses what --version reports. A value stamped at
// build time wins. Otherwise, for a binary from go install, the module
// version recorded in the build info is used, unless that is empty or
// the "(devel)" of a local build. When neither names a version, the
// "dev" fallback stays.
func resolveVersion(stamped string, info *debug.BuildInfo) string {
if stamped != devVersion {
return stamped
}
if info != nil && info.Main.Version != "" &&
info.Main.Version != "(devel)" {
return info.Main.Version
}
return devVersion
}
// Root returns the whole command tree.
func Root() *cobra.Command {
info, _ := debug.ReadBuildInfo()
root := &cobra.Command{
Use: "keyfunc",
Short: "derive key pairs from a BIP-39 mnemonic",
Long: "keyfunc turns a BIP-39 mnemonic into key pairs that can " +
"be recreated from that mnemonic at any time. The same " +
"mnemonic, key type and index always give the same key.",
Version: resolveVersion(Version, info),
SilenceUsage: true,
SilenceErrors: true,
}
options.Add(root)
root.AddCommand(ssh.Command(), age.Command(), mnemonic.Command())
return root
}
// init keeps the command on the main thread. Linux hands a signal sent
// to the tool to that thread first, and a thread runs a pending signal
// handler before its own code, so when "age encrypt -o" or "age
// decrypt -o" checks for a signal as its input ends, one sent before
// then, as by Ctrl-C on a pipeline, has been received.
//
//nolint:gochecknoinits // only an init can keep main on the main thread
func init() {
runtime.LockOSThread()
}
// Main runs the tool and returns the status the process should exit
// with. An error ends the tool with status 1, except when it carries a
// status of its own, which "ssh to" uses to hand on the status ssh
// ended with. ssh has already said whatever it had to say in that
// case, so nothing more is printed.
//
// SIGINT, SIGTERM and SIGHUP end the tool at once, as they end any Go
// program, so a command waiting at the mnemonic prompt or reading what
// it encrypts or decrypts goes no further. The exceptions catch the
// signals to clean up first: "ssh to" and "ssh install" while they
// have ssh or sftp running, so the child ends and their own cleanup
// still runs, and "age encrypt -o" and "age decrypt -o" while they
// write a new file to rename over the named one, so the unfinished file
// is removed.
//
// At the mnemonic prompt the signal is caught only to put the terminal
// back, and then ends the tool as above.
func Main() int {
err := Root().Execute()
if err == nil {
return 0
}
if passed, ok := errors.AsType[ssh.StatusError](err); ok {
return passed.Status
}
fmt.Fprintln(os.Stderr, "keyfunc: "+err.Error())
return 1
}