Files
winauth-go/cmd/winauth/cmd_get.go
T
iceking2nd 727be3557a 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、不持有单实例锁
2026-06-12 09:50:07 +08:00

121 lines
3.3 KiB
Go

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
}