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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user