feat(cli): list/get 子命令 — 脚本集成 TOTP 获取

- winauth list: tabwriter 列出所有条目(名称/vendor/icon/hotkey)
- winauth get NAME: 输出当前 OTP,-n 不换行,--advance 允许 HOTP
- 加密配置自动 tty 隐藏密码提示(golang.org/x/term)
- --config 改为 PersistentFlag,子命令可继承
- HOTP get 默认拒绝,需 --advance 显式确认并保存配置
- CLI 不运行 GUI、不持有单实例锁
This commit is contained in:
2026-06-12 09:50:07 +08:00
parent 969600b6ee
commit 727be3557a
7 changed files with 265 additions and 7 deletions
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"errors"
"fmt"
"github.com/spf13/cobra"
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
func newGetCmd() *cobra.Command {
var noNewline, advance bool
cmd := &cobra.Command{
Use: "get NAME",
Short: "Print the current OTP for an entry",
Long: `Print the current one-time password for the entry named NAME.
The name must match exactly (case-sensitive).
For HOTP (counter-based) entries, this command refuses by default
because generating a code advances the counter. Use --advance to
confirm that you want to consume a counter value; the config file
will be updated automatically.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runGet(cmd, args[0], noNewline, advance)
},
}
cmd.Flags().BoolVarP(&noNewline, "no-newline", "n", false, "Do not append a newline after the code")
cmd.Flags().BoolVar(&advance, "advance", false, "Advance HOTP counter (required for counter-based entries)")
return cmd
}
func runGet(cmd *cobra.Command, name string, noNewline, advance bool) error {
path := resolveConfigPath(cmd)
lang := preferredLanguage(path)
_ = i18n.Init(lang)
cfg, passphrase, err := cliLoadConfig(path)
if err != nil {
if errors.Is(err, config.ErrPasswordWrong) {
return fmt.Errorf("wrong password")
}
return fmt.Errorf("load config: %w", err)
}
// Find entry by exact name.
var target *config.Entry
var matches []string
for i := range cfg.Entries {
if cfg.Entries[i].Name == name {
target = &cfg.Entries[i]
break
}
matches = append(matches, cfg.Entries[i].Name)
}
if target == nil {
return fmt.Errorf("entry %q not found", name)
}
a, err := buildAuth(*target)
if err != nil {
return fmt.Errorf("build authenticator: %w", err)
}
// HOTP safety gate.
if a.Name() == "hotp" && !advance {
return fmt.Errorf("entry %q is counter-based (HOTP); re-run with --advance to consume a counter value", name)
}
code, err := a.CurrentCode()
if err != nil {
return fmt.Errorf("compute OTP: %w", err)
}
if noNewline {
fmt.Print(code)
} else {
fmt.Println(code)
}
// Persist the updated counter for HOTP entries.
if a.Name() == "hotp" {
target.SecretRaw = a.SecretData()
if err := config.SaveYAML(cfg, path, passphrase); err != nil {
return fmt.Errorf("save config after HOTP advance: %w", err)
}
}
return nil
}
// buildAuth mirrors the switch in internal/ui/convert.go. Duplicated here
// so the CLI binary does not depend on the UI package.
func buildAuth(e config.Entry) (authenticator.Authenticator, error) {
var a authenticator.Authenticator
switch e.Vendor {
case "google", "":
a = authenticator.NewGoogleAuthenticator()
case "microsoft":
a = authenticator.NewMicrosoftAuthenticator()
case "okta":
a = authenticator.NewOktaVerifyAuthenticator()
case "hotp":
a = authenticator.NewHOTPAuthenticator()
case "battlenet":
a = authenticator.NewBattleNetAuthenticator()
case "steam":
a = authenticator.NewSteamAuthenticator()
default:
return nil, fmt.Errorf("unknown vendor %q", e.Vendor)
}
if err := a.SetSecretData(e.SecretRaw); err != nil {
return nil, fmt.Errorf("decode entry %q: %w", e.Name, err)
}
return a, nil
}
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"errors"
"fmt"
"os"
"text/tabwriter"
"github.com/spf13/cobra"
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
func newListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all authenticator entries",
Long: "Prints a table of all entries in the config file. For encrypted configs, a password prompt will appear.",
Args: cobra.NoArgs,
RunE: runList,
}
}
func runList(cmd *cobra.Command, args []string) error {
const fn = "cmd.winauth.runList"
path := resolveConfigPath(cmd)
lang := preferredLanguage(path)
_ = i18n.Init(lang) // best-effort; list output is English anyway
cfg, _, err := cliLoadConfig(path)
if err != nil {
if errors.Is(err, config.ErrPasswordWrong) {
return fmt.Errorf("wrong password")
}
return fmt.Errorf("load config: %w", err)
}
if len(cfg.Entries) == 0 {
fmt.Fprintln(os.Stderr, "No entries found.")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "NAME\tVENDOR\tICON\tHOTKEY")
for _, e := range cfg.Entries {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", e.Name, e.Vendor, e.IconName, e.Hotkey)
}
return tw.Flush()
}
+50
View File
@@ -0,0 +1,50 @@
package main
import (
"errors"
"fmt"
"os"
"github.com/spf13/cobra"
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
)
// resolveConfigPath returns the config file path from the --config
// flag or the default location.
func resolveConfigPath(cmd *cobra.Command) string {
if p, _ := cmd.Root().PersistentFlags().GetString("config"); p != "" {
return p
}
return config.DefaultPath()
}
// cliLoadConfig loads the config at path, prompting for a password if
// the file is encrypted. Returns the config and the passphrase (empty
// for plaintext configs). Up to 3 password attempts are allowed.
func cliLoadConfig(path string) (*config.Config, []byte, error) {
cfg, err := config.LoadYAML(path, nil)
if err == nil {
return cfg, nil, nil
}
if !errors.Is(err, config.ErrPasswordRequired) {
return nil, nil, err
}
for attempt := 0; attempt < 3; attempt++ {
pw, err := readPassword("Password: ")
if err != nil {
return nil, nil, fmt.Errorf("read password: %w", err)
}
cfg, err = config.LoadYAML(path, pw)
if err == nil {
return cfg, pw, nil
}
if errors.Is(err, config.ErrPasswordWrong) {
fmt.Fprintln(os.Stderr, "Wrong password.")
continue
}
return nil, nil, err
}
return nil, nil, fmt.Errorf("too many failed password attempts")
}
+4 -1
View File
@@ -91,9 +91,12 @@ func main() {
"Log level: panic|fatal|error|warn|info|debug|trace (or 0..6)")
root.Flags().StringVar(&logFile, "log-file", "",
"If set, also write logs to this file in addition to the console")
root.Flags().StringVar(&configPath, "config", "",
root.PersistentFlags().StringVar(&configPath, "config", "",
"Path to the YAML config file (default: %APPDATA%\\winauth-go\\config.yaml or $XDG_CONFIG_HOME/winauth-go/config.yaml)")
root.AddCommand(newListCmd())
root.AddCommand(newGetCmd())
if err := root.Execute(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"bufio"
"fmt"
"os"
"golang.org/x/term"
)
// readPassword prompts the user for a password on the given file
// descriptor. If the fd is a terminal the input is hidden; otherwise
// (piped stdin) it falls back to a plain read.
func readPassword(prompt string) ([]byte, error) {
fd := int(os.Stdin.Fd())
if term.IsTerminal(fd) {
fmt.Fprint(os.Stderr, prompt)
pw, err := term.ReadPassword(fd)
fmt.Fprintln(os.Stderr)
return pw, err
}
// Non-tty (piped): read a line without echo hiding.
scanner := bufio.NewScanner(os.Stdin)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return nil, err
}
return nil, fmt.Errorf("no input on stdin")
}
return []byte(scanner.Text()), nil
}
+5 -4
View File
@@ -1,14 +1,18 @@
module git.wxccs.org/iceking2nd/winauth-go
go 1.23
go 1.25.0
require (
gioui.org v0.7.1
github.com/BurntSushi/toml v1.4.0
github.com/makiuchi-d/gozxing v0.1.1
github.com/nicksnyder/go-i18n/v2 v2.4.0
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.8.1
golang.org/x/crypto v0.28.0
golang.org/x/image v0.18.0
golang.org/x/sys v0.46.0
golang.org/x/term v0.44.0
golang.org/x/text v0.19.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -18,11 +22,8 @@ require (
gioui.org/shader v1.0.8 // indirect
github.com/go-text/typesetting v0.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/makiuchi-d/gozxing v0.1.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
)
+4 -2
View File
@@ -44,8 +44,10 @@ golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37/go.mod h1:3F+MieQB7dRY
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=