WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/config"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/secrets"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// authLoginFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `auth login`. The post-login summary has no token values - they stay in the
|
||||
// keyring; agents who need to verify the credential should re-run
|
||||
// `auth status`.
|
||||
var authLoginFields = []string{
|
||||
"profile", "host", "mode", "email", "tenant_id",
|
||||
}
|
||||
|
||||
// LoginOptions is the configuration captured from flags + prompts. Host and
|
||||
// Profile are resolved from the active profile in config (not from flags) -
|
||||
// `auth login` authenticates the already-existing active profile, created
|
||||
// beforehand with `weknora profile add <name> --host <h> --use`.
|
||||
type LoginOptions struct {
|
||||
Host string // resolved from the active profile's Host in config
|
||||
Profile string // resolved active profile name (honors global --profile)
|
||||
WithToken bool // --with-token: read api key from stdin instead of prompting password
|
||||
APIKey string // populated by --with-token from stdin
|
||||
Email string
|
||||
Password string
|
||||
StdinReader io.Reader // override for tests
|
||||
}
|
||||
|
||||
// LoginService is the narrow SDK surface auth login depends on.
|
||||
// *sdk.Client satisfies it implicitly via the new Login(ctx, LoginRequest)
|
||||
// signature added in client/auth.go.
|
||||
type LoginService interface {
|
||||
Login(ctx context.Context, req sdk.LoginRequest) (*sdk.LoginResponse, error)
|
||||
}
|
||||
|
||||
// apiKeyValidator probes /auth/me with the supplied API key so a bad key
|
||||
// fails fast at `auth login --with-token` time rather than on the next
|
||||
// authenticated call.
|
||||
//
|
||||
// Returns the resolved user (used to populate Profile.User / TenantID at
|
||||
// rest, so later `auth list` reflects who owns the key).
|
||||
type apiKeyValidator func(ctx context.Context, host, apiKey string) (*sdk.AuthUser, error)
|
||||
|
||||
// defaultAPIKeyValidator builds a one-shot SDK client with the supplied key
|
||||
// and calls /auth/me. Side-effect-free; no persistence.
|
||||
var defaultAPIKeyValidator apiKeyValidator = func(ctx context.Context, host, apiKey string) (*sdk.AuthUser, error) {
|
||||
resp, err := sdk.NewClient(host, sdk.WithAPIKey(apiKey)).GetCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !resp.Success || resp.Data.User == nil {
|
||||
return nil, fmt.Errorf("server rejected the API key (no user returned)")
|
||||
}
|
||||
return resp.Data.User, nil
|
||||
}
|
||||
|
||||
// NewCmdLogin builds the `weknora auth login` command. runF is the testable
|
||||
// entrypoint (left nil for production; see cli/cmd/auth/login_test.go).
|
||||
func NewCmdLogin(f *cmdutil.Factory, runF func(context.Context, *LoginOptions, *cmdutil.FormatOptions, *cmdutil.Factory, LoginService) error) *cobra.Command {
|
||||
opts := &LoginOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Authenticate the active profile against its WeKnora server",
|
||||
Long: `Authenticate the active profile by email + password (interactive prompt) or
|
||||
pipe an API key with --with-token.
|
||||
|
||||
` + "`auth login`" + ` operates on the active profile (override with the global
|
||||
--profile flag). Create the profile first with
|
||||
` + "`weknora profile add <name> --host <h> --use`" + `, then run ` + "`weknora auth login`" + `.
|
||||
|
||||
Credentials are persisted to the OS keyring when available; otherwise to a
|
||||
0600 file under $XDG_CONFIG_HOME/weknora/secrets.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
if opts.StdinReader == nil {
|
||||
opts.StdinReader = iostreams.IO.In
|
||||
}
|
||||
if runF != nil {
|
||||
// Test seam: the injected runF supplies its own service and
|
||||
// (where needed) seeds opts.Host / opts.Profile itself.
|
||||
return runF(c.Context(), opts, fopts, f, nil)
|
||||
}
|
||||
return runLogin(c.Context(), opts, fopts, f, nil)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.WithToken, "with-token", false, "Read an API key from stdin instead of prompting for password")
|
||||
cmdutil.AddFormatFlag(cmd, authLoginFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "authenticate the active profile; --with-token reads an API key from stdin (non-interactive)",
|
||||
Examples: []string{`echo "$WEKNORA_API_KEY" | weknora auth login --with-token`},
|
||||
Output: "envelope.data is {profile, host, mode, email, tenant_id} on success (email matches `auth status`)",
|
||||
Warnings: []string{
|
||||
"a profile must exist and be active first (`weknora profile add <n> --host <url> --use`)",
|
||||
"password login is interactive-only (no flags) — agents must use --with-token with the key piped to stdin",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// resolveActiveProfile loads config and resolves the active profile (honoring
|
||||
// the global --profile override) plus its persisted Host. `auth login`
|
||||
// authenticates an already-existing profile; profile creation is `profile
|
||||
// add`'s job. Returns typed errors when no active profile is configured or
|
||||
// the profile lacks a host.
|
||||
func resolveActiveProfile(f *cmdutil.Factory) (name, host string, err error) {
|
||||
// f.Config() already folds the global --profile / WEKNORA_PROFILE override
|
||||
// into cfg.CurrentProfile (same source f.ActiveProfile reads), so one load
|
||||
// resolves the active profile — matches logout/refresh.
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
active := cfg.CurrentProfile
|
||||
if active == "" {
|
||||
msg := "no active profile; run `weknora profile add <name> --host <h> --use` first"
|
||||
if envActive, kind := cmdutil.EnvCredential(); envActive {
|
||||
msg = "no active profile to log in — you are already authenticated this session via " + kind +
|
||||
"; `auth login` only persists a named profile, so run `weknora profile add <name> --host <h> --use` first if you want one"
|
||||
}
|
||||
return "", "", cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, msg)
|
||||
}
|
||||
prof, ok := cfg.Profiles[active]
|
||||
if !ok {
|
||||
return "", "", cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
|
||||
fmt.Sprintf("active profile %q not found in config", active))
|
||||
}
|
||||
if prof.Host == "" {
|
||||
return "", "", cmdutil.NewError(cmdutil.CodeLocalConfigCorrupt,
|
||||
fmt.Sprintf("profile %q has no host", active))
|
||||
}
|
||||
return active, prof.Host, nil
|
||||
}
|
||||
|
||||
// loginServiceFor returns a fresh SDK client targeting host. login.go cannot
|
||||
// reuse Factory.Client because that closure requires an existing profile.
|
||||
func loginServiceFor(host string) LoginService {
|
||||
if host == "" {
|
||||
return nil
|
||||
}
|
||||
return sdk.NewClient(host)
|
||||
}
|
||||
|
||||
func runLogin(ctx context.Context, opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, svc LoginService) error {
|
||||
// Resolve the target profile + host from the active profile in config.
|
||||
// `auth login` no longer takes --host / --name; it authenticates the
|
||||
// already-existing active profile (override via the global --profile).
|
||||
name, host, err := resolveActiveProfile(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Profile = name
|
||||
opts.Host = host
|
||||
if svc == nil {
|
||||
svc = loginServiceFor(host)
|
||||
}
|
||||
|
||||
if err := validateHost(opts.Host); err != nil {
|
||||
return err
|
||||
}
|
||||
// Reject shell-metacharacter / path-like names up-front so opts.Profile
|
||||
// stays safe to interpolate into the keyring namespace, config.yaml
|
||||
// keys, and envelope.error.retry_argv. Matches `profile add`.
|
||||
if err := cmdutil.ValidateProfileName(opts.Profile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.WithToken {
|
||||
key, err := readStdinTrimmed(opts.StdinReader)
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "read stdin")
|
||||
}
|
||||
if key == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeInputMissingFlag, "--with-token requires an API key piped to stdin")
|
||||
}
|
||||
opts.APIKey = key
|
||||
// Validate against the server before persisting so a typo'd /
|
||||
// expired / wrong-host key fails fast at login time. The probe
|
||||
// is /auth/me - read-only, side-effect-free.
|
||||
user, err := defaultAPIKeyValidator(ctx, opts.Host, key)
|
||||
if err != nil {
|
||||
// Transport errors (connection refused, DNS failure) must not be
|
||||
// surfaced as auth.bad_credential — the key may be fine but the
|
||||
// host is unreachable. Classify via WrapHTTP so network errors
|
||||
// get CodeNetworkError and the hint points at `weknora doctor`.
|
||||
if cmdutil.ClassifyHTTPError(err) == cmdutil.CodeNetworkError {
|
||||
return cmdutil.Wrapf(cmdutil.CodeNetworkError, err, "validate API key: check host reachability")
|
||||
}
|
||||
return cmdutil.Wrapf(cmdutil.CodeAuthBadCredential, err, "validate API key")
|
||||
}
|
||||
return persistAPIKey(opts, fopts, f, user)
|
||||
}
|
||||
|
||||
// Interactive: prompt for email + password. svc is always set by now
|
||||
// (loginServiceFor returns non-nil because resolveActiveProfile guarantees
|
||||
// a non-empty host).
|
||||
if opts.Email == "" || opts.Password == "" {
|
||||
p := f.Prompter()
|
||||
if opts.Email == "" {
|
||||
email, err := p.Input("Email", "")
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeInputMissingFlag, err, "email prompt")
|
||||
}
|
||||
opts.Email = email
|
||||
}
|
||||
if opts.Password == "" {
|
||||
pw, err := p.Password("Password")
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeInputMissingFlag, err, "password prompt")
|
||||
}
|
||||
opts.Password = pw
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := svc.Login(ctx, sdk.LoginRequest{Email: opts.Email, Password: opts.Password})
|
||||
if err != nil {
|
||||
// Transport errors (connection refused, DNS failure) must not be
|
||||
// surfaced as auth.bad_credential — credentials may be fine but the
|
||||
// host is unreachable. Classify so network errors get CodeNetworkError.
|
||||
if cmdutil.ClassifyHTTPError(err) == cmdutil.CodeNetworkError {
|
||||
return cmdutil.Wrapf(cmdutil.CodeNetworkError, err, "login: check host reachability")
|
||||
}
|
||||
return cmdutil.Wrapf(cmdutil.CodeAuthBadCredential, err, "login")
|
||||
}
|
||||
if !resp.Success || resp.Token == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeAuthBadCredential, fmt.Sprintf("login refused: %s", resp.Message))
|
||||
}
|
||||
|
||||
return persistJWT(opts, fopts, f, resp)
|
||||
}
|
||||
|
||||
// persistAPIKey saves the --with-token API key and writes the profile.
|
||||
// user is the principal returned by /auth/me during pre-persist validation,
|
||||
// used to populate Profile.User / TenantID so `auth list` reflects who
|
||||
// owns the key.
|
||||
func persistAPIKey(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, user *sdk.AuthUser) error {
|
||||
store, err := f.Secrets()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
warnOnFileFallback(store)
|
||||
if err := store.Set(opts.Profile, "api_key", opts.APIKey); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "save api key")
|
||||
}
|
||||
// Switching credential mode: this profile is now an API-key profile, so
|
||||
// any leftover JWT refs (from a prior password login) are stale. Set the
|
||||
// api-key ref and clear the token/refresh refs; keep Host + existing
|
||||
// User/TenantID (merged in saveProfileRef via applyUser).
|
||||
mutate := func(prof *config.Profile) {
|
||||
prof.APIKeyRef = store.Ref(opts.Profile, "api_key")
|
||||
prof.TokenRef = ""
|
||||
prof.RefreshRef = ""
|
||||
applyUser(prof, user)
|
||||
}
|
||||
return saveProfileRef(opts, fopts, f, mutate, ModeAPIKey, user)
|
||||
}
|
||||
|
||||
// persistJWT saves access + refresh tokens and writes the profile.
|
||||
func persistJWT(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, resp *sdk.LoginResponse) error {
|
||||
store, err := f.Secrets()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
warnOnFileFallback(store)
|
||||
if err := store.Set(opts.Profile, "access", resp.Token); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "save access token")
|
||||
}
|
||||
if resp.RefreshToken != "" {
|
||||
if err := store.Set(opts.Profile, "refresh", resp.RefreshToken); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "save refresh token")
|
||||
}
|
||||
}
|
||||
// Switching to a JWT profile: set token refs and drop any stale api-key
|
||||
// ref; keep Host + existing User/TenantID (merged in saveProfileRef via applyUser).
|
||||
mutate := func(prof *config.Profile) {
|
||||
prof.TokenRef = store.Ref(opts.Profile, "access")
|
||||
prof.RefreshRef = store.Ref(opts.Profile, "refresh")
|
||||
prof.APIKeyRef = ""
|
||||
applyUser(prof, resp.User)
|
||||
}
|
||||
return saveProfileRef(opts, fopts, f, mutate, ModeBearer, resp.User)
|
||||
}
|
||||
|
||||
// applyUser overwrites prof.User / prof.TenantID only when the server actually
|
||||
// returned them. A nil user (or empty email) must NOT wipe an existing User
|
||||
// that was set during `profile add` or a prior login.
|
||||
func applyUser(prof *config.Profile, user *sdk.AuthUser) {
|
||||
if user == nil {
|
||||
return
|
||||
}
|
||||
if user.Email != "" {
|
||||
prof.User = user.Email
|
||||
}
|
||||
if user.TenantID != 0 {
|
||||
prof.TenantID = user.TenantID
|
||||
}
|
||||
}
|
||||
|
||||
// loginResult is the typed payload emitted by `--format json`. mode is derived from
|
||||
// whether the server returned a user (password flow) vs API-key flow.
|
||||
type loginResult struct {
|
||||
Profile string `json:"profile"`
|
||||
Host string `json:"host"`
|
||||
Mode string `json:"mode"` // ModeBearer or ModeAPIKey
|
||||
// Email is the authenticated principal's email. Named "email" (not
|
||||
// "user") so the identity field matches `auth status`, which also
|
||||
// exposes it as `email` — one key for one concept across both commands.
|
||||
Email string `json:"email,omitempty"`
|
||||
TenantID uint64 `json:"tenant_id,omitempty"`
|
||||
}
|
||||
|
||||
// saveProfileRef MERGES the new credential refs into the EXISTING profile
|
||||
// record and prints success. The active profile already exists (created via
|
||||
// `profile add`, carrying Host and possibly User/TenantID); mutate sets only
|
||||
// the credential refs + any server-returned user, so re-login never clobbers
|
||||
// the host or wipes an existing user. cfg.CurrentProfile is left untouched -
|
||||
// `auth login` authenticates the already-active profile, it doesn't switch.
|
||||
func saveProfileRef(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, mutate func(*config.Profile), mode string, user *sdk.AuthUser) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Profiles == nil {
|
||||
cfg.Profiles = map[string]config.Profile{}
|
||||
}
|
||||
prof := cfg.Profiles[opts.Profile] // existing record (Host, User, TenantID)
|
||||
mutate(&prof)
|
||||
cfg.Profiles[opts.Profile] = prof
|
||||
if err := config.Save(cfg); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config")
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
// mode reflects the credential actually stored (passed by the caller),
|
||||
// not whether the server happened to return a user — an API-key login
|
||||
// validates via /auth/me and DOES get a user, so deriving mode from
|
||||
// user != nil would mislabel it as bearer.
|
||||
result := loginResult{Profile: opts.Profile, Host: opts.Host, Mode: mode}
|
||||
if user != nil {
|
||||
result.Email = user.Email
|
||||
result.TenantID = user.TenantID
|
||||
}
|
||||
return fopts.Emit(iostreams.IO.Out, result, nil)
|
||||
}
|
||||
who := opts.Profile
|
||||
if user != nil {
|
||||
who = user.Email
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Logged in to %s as %s (profile=%s)\n", opts.Host, who, opts.Profile)
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateHost rejects empty / non-http URLs early so we surface a clean
|
||||
// flag error instead of failing inside the SDK transport.
|
||||
func validateHost(host string) error {
|
||||
_, err := cmdutil.NormalizeHost(host)
|
||||
return err
|
||||
}
|
||||
|
||||
// warnOnFileFallback prints a one-shot stderr advisory when the secrets
|
||||
// store fell back to the plaintext 0600 file backend (keychain unavailable
|
||||
// - typical on headless CI, WSL without DBus, agent containers). Helps
|
||||
// users notice that credentials are NOT in the OS keychain before they're
|
||||
// surprised by it later. doctor's credential_storage check carries the
|
||||
// same info but agents that bypass doctor would otherwise miss it.
|
||||
func warnOnFileFallback(store secrets.Store) {
|
||||
if _, isFile := store.(*secrets.FileStore); !isFile {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(iostreams.IO.Err, "warning: OS keychain unavailable - credentials will be saved to a 0600 file under $XDG_CONFIG_HOME/weknora/secrets/.")
|
||||
fmt.Fprintln(iostreams.IO.Err, " install / unlock the keyring (or use `weknora doctor` to inspect) for OS-backed storage.")
|
||||
}
|
||||
|
||||
// readStdinTrimmed reads all of r and returns the result with surrounding
|
||||
// whitespace stripped. Empty result is returned as-is for the caller to
|
||||
// validate.
|
||||
func readStdinTrimmed(r io.Reader) (string, error) {
|
||||
if r == nil {
|
||||
return "", nil
|
||||
}
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(b)), nil
|
||||
}
|
||||
Reference in New Issue
Block a user