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,166 @@
|
||||
package profilecmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
type AddOptions struct {
|
||||
Host string
|
||||
User string
|
||||
Use bool // --use: switch to this profile after adding
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// profileAddFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `profile add`. The result describes the newly-registered profile.
|
||||
var profileAddFields = []string{
|
||||
"name", "host", "user", "current",
|
||||
}
|
||||
|
||||
// addResult is the typed payload emitted under data on success.
|
||||
type addResult struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
User string `json:"user,omitempty"`
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
// NewCmdAdd builds `weknora profile add`. Registers a *credentialless*
|
||||
// connection target - host + optional user only. Credentials for the new
|
||||
// profile are attached separately by making it active and running
|
||||
// `weknora auth login`, separating "where" the CLI talks to (the host) and
|
||||
// "how" it authenticates (the credential). Pass --use to switch to the new
|
||||
// profile immediately, then run `weknora auth login`.
|
||||
func NewCmdAdd(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &AddOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "add <name>",
|
||||
Short: "Register a new profile (host without credentials)",
|
||||
Long: `Add a new profile entry to config.yaml. Stores host (and optionally
|
||||
user) but does NOT prompt for credentials. To attach credentials, make the
|
||||
profile active (` + "`weknora profile use <n>`" + `, or ` + "`profile add <n> --use`" + `) and run
|
||||
` + "`weknora auth login`" + `.
|
||||
|
||||
The first profile added is auto-selected as the current profile. Subsequent
|
||||
adds leave the current profile untouched unless --use is passed.`,
|
||||
Example: ` weknora profile add staging --host https://staging.example.com
|
||||
weknora profile add prod --host https://prod.example.com --user alice@example.com
|
||||
weknora profile add prod --host https://prod.example.com --use # switch to it now`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
// Pure-local validation runs before the dry-run gate so --dry-run
|
||||
// rejects identically to the live path. Same typed errors as runAdd
|
||||
// (kept there for direct-call callers).
|
||||
name := args[0]
|
||||
if err := cmdutil.ValidateProfileName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
host, err := cmdutil.NormalizeHost(opts.Host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := cfg.Profiles[name]; exists {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeResourceAlreadyExists,
|
||||
Message: fmt.Sprintf("profile %q already exists", name),
|
||||
Hint: fmt.Sprintf("use a different name, or run `weknora profile remove %s` first", name),
|
||||
}
|
||||
}
|
||||
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
|
||||
Action: "profile.add",
|
||||
Args: map[string]any{
|
||||
"name": name,
|
||||
"host": host,
|
||||
},
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
return runAddWithConfig(opts, fopts, name, host, cfg)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Host, "host", "", "Server base URL, e.g. https://kb.example.com (required)")
|
||||
cmd.Flags().StringVar(&opts.User, "user", "", "Account email shown in 'profile list' (optional, cosmetic only)")
|
||||
cmd.Flags().BoolVar(&opts.Use, "use", false, "Switch to this profile after adding it")
|
||||
cmdutil.AddFormatFlag(cmd, profileAddFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
_ = cmd.MarkFlagRequired("host")
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "Register a new profile (connection target) with a name and host URL. Does not store credentials; make it active (--use, or `profile use <n>`) and run `auth login` afterwards to authenticate.",
|
||||
RequiredFlags: []string{"<name> (positional)", "--host"},
|
||||
Output: "envelope.data has name, host, user, current",
|
||||
Examples: []string{
|
||||
"weknora profile add prod --host https://kb.example.com --use",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runAdd is the legacy direct-call entrypoint: revalidates inputs and loads
|
||||
// config. Preserved for tests / external callers that bypass the cobra layer.
|
||||
// The cobra RunE path validates earlier and delegates to runAddWithConfig.
|
||||
func runAdd(opts *AddOptions, fopts *cmdutil.FormatOptions, name string) error {
|
||||
if err := cmdutil.ValidateProfileName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
host, err := cmdutil.NormalizeHost(opts.Host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := cfg.Profiles[name]; exists {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeResourceAlreadyExists,
|
||||
Message: fmt.Sprintf("profile %q already exists", name),
|
||||
Hint: fmt.Sprintf("use a different name, or run `weknora profile remove %s` first", name),
|
||||
}
|
||||
}
|
||||
return runAddWithConfig(opts, fopts, name, host, cfg)
|
||||
}
|
||||
|
||||
// runAddWithConfig performs the side-effectful write. Inputs are assumed to be
|
||||
// pre-validated (ValidateProfileName, NormalizeHost, dup-check) by the caller.
|
||||
func runAddWithConfig(opts *AddOptions, fopts *cmdutil.FormatOptions, name, host string, cfg *config.Config) error {
|
||||
if cfg.Profiles == nil {
|
||||
cfg.Profiles = map[string]config.Profile{}
|
||||
}
|
||||
cfg.Profiles[name] = config.Profile{Host: host, User: opts.User}
|
||||
wasFirst := cfg.CurrentProfile == ""
|
||||
// The first profile is auto-selected; --use forces selection even when
|
||||
// another profile is already current.
|
||||
current := wasFirst || opts.Use
|
||||
if current {
|
||||
cfg.CurrentProfile = name
|
||||
}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config")
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, addResult{Name: name, Host: host, User: opts.User, Current: current}, nil)
|
||||
}
|
||||
if current {
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Added profile %s (now current). Run `weknora auth login` to attach credentials.\n", name)
|
||||
} else {
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Added profile %s. Run `weknora profile use %s` then `weknora auth login` to attach credentials.\n", name, name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package profilecmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/config"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
)
|
||||
|
||||
func TestAdd_HappyPath(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
|
||||
if err := runAdd(&AddOptions{Host: "https://my.example.com", User: "alice@example.com"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, "staging"); err != nil {
|
||||
t.Fatalf("runAdd: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
c, ok := cfg.Profiles["staging"]
|
||||
if !ok {
|
||||
t.Fatalf("staging not in Profiles; got keys=%v", profileKeys(cfg.Profiles))
|
||||
}
|
||||
if c.Host != "https://my.example.com" {
|
||||
t.Errorf("Host=%q, want https://my.example.com", c.Host)
|
||||
}
|
||||
if c.User != "alice@example.com" {
|
||||
t.Errorf("User=%q, want alice@example.com", c.User)
|
||||
}
|
||||
// First profile auto-becomes current.
|
||||
if cfg.CurrentProfile != "staging" {
|
||||
t.Errorf("first profile should auto-become current, got CurrentProfile=%q", cfg.CurrentProfile)
|
||||
}
|
||||
if !strings.Contains(out.String(), "staging") {
|
||||
t.Errorf("output should mention added name, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdd_DuplicateName(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "staging",
|
||||
Profiles: map[string]config.Profile{"staging": {Host: "https://old.example.com"}},
|
||||
}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
err := runAdd(&AddOptions{Host: "https://new.example.com"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, "staging")
|
||||
if err == nil {
|
||||
t.Fatal("expected error on duplicate name")
|
||||
}
|
||||
cm, ok := err.(*cmdutil.Error)
|
||||
if !ok {
|
||||
t.Fatalf("expected *cmdutil.Error, got %T", err)
|
||||
}
|
||||
if cm.Code != cmdutil.CodeResourceAlreadyExists {
|
||||
t.Errorf("code=%q, want %q", cm.Code, cmdutil.CodeResourceAlreadyExists)
|
||||
}
|
||||
// Existing entry must NOT be overwritten.
|
||||
got, _ := config.Load()
|
||||
if got.Profiles["staging"].Host != "https://old.example.com" {
|
||||
t.Errorf("existing profile overwritten; Host=%q", got.Profiles["staging"].Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdd_BadHost(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
|
||||
bad := []string{
|
||||
"", // empty
|
||||
"my.example.com", // missing scheme
|
||||
"ftp://my.example.com", // wrong scheme
|
||||
"http://", // missing host
|
||||
}
|
||||
for _, h := range bad {
|
||||
err := runAdd(&AddOptions{Host: h}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, "staging")
|
||||
if err == nil {
|
||||
t.Errorf("host=%q: expected error", h)
|
||||
continue
|
||||
}
|
||||
cm, ok := err.(*cmdutil.Error)
|
||||
if !ok {
|
||||
t.Errorf("host=%q: expected *cmdutil.Error, got %T", h, err)
|
||||
continue
|
||||
}
|
||||
if cm.Code != cmdutil.CodeInputInvalidArgument {
|
||||
t.Errorf("host=%q: code=%q, want %q", h, cm.Code, cmdutil.CodeInputInvalidArgument)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdd_SecondProfileDoesNotChangeCurrent(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "production",
|
||||
Profiles: map[string]config.Profile{"production": {Host: "https://prod.example.com"}},
|
||||
}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
if err := runAdd(&AddOptions{Host: "https://stg.example.com"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, "staging"); err != nil {
|
||||
t.Fatalf("runAdd: %v", err)
|
||||
}
|
||||
got, _ := config.Load()
|
||||
if got.CurrentProfile != "production" {
|
||||
t.Errorf("adding a second profile must not switch current; got %q", got.CurrentProfile)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdd_UseSwitchesCurrent asserts --use switches the current profile to the
|
||||
// newly-added one even when another profile is already current.
|
||||
func TestAdd_UseSwitchesCurrent(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "production",
|
||||
Profiles: map[string]config.Profile{"production": {Host: "https://prod.example.com"}},
|
||||
}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
host, err := cmdutil.NormalizeHost("https://h")
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeHost: %v", err)
|
||||
}
|
||||
loaded, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if err := runAddWithConfig(&AddOptions{Host: host, Use: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, "new", host, loaded); err != nil {
|
||||
t.Fatalf("runAddWithConfig: %v", err)
|
||||
}
|
||||
|
||||
got, _ := config.Load()
|
||||
if got.CurrentProfile != "new" {
|
||||
t.Errorf("--use must switch current to the new profile; got %q", got.CurrentProfile)
|
||||
}
|
||||
if _, ok := got.Profiles["new"]; !ok {
|
||||
t.Errorf("new profile not registered; keys=%v", profileKeys(got.Profiles))
|
||||
}
|
||||
if !strings.Contains(out.String(), "now current") {
|
||||
t.Errorf("output should note the profile is now current, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdd_JSON(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
|
||||
if err := runAdd(&AddOptions{Host: "https://my.example.com"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, "staging"); err != nil {
|
||||
t.Fatalf("runAdd: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\noutput=%q", err, out.String())
|
||||
}
|
||||
got := env.Data
|
||||
if got["name"] != "staging" {
|
||||
t.Errorf("name should be staging, got %v", got)
|
||||
}
|
||||
if got["host"] != "https://my.example.com" {
|
||||
t.Errorf("host wrong: %v", got)
|
||||
}
|
||||
if got["current"] != true {
|
||||
t.Errorf("first added profile must be current=true, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Package profilecmd — dryrun_validation_test.go asserts that --dry-run on
|
||||
// profile subcommands rejects identically to the live path. Before the
|
||||
// surrounding fix, validation lived in runX() and was reached only after
|
||||
// HandleDryRun short-circuited, so --dry-run accepted invocations the live
|
||||
// path would reject.
|
||||
package profilecmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"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/prompt"
|
||||
"github.com/Tencent/WeKnora/cli/internal/secrets"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// profileDryRunFactory builds a Factory whose Client closure panics if
|
||||
// invoked — dry-run must early-exit before any SDK call.
|
||||
func profileDryRunFactory(t *testing.T) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
return &cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) {
|
||||
t.Fatal("dry-run path must not call Factory.Client(); SDK side effect leaked")
|
||||
return nil, nil
|
||||
},
|
||||
Prompter: func() prompt.Prompter {
|
||||
t.Fatal("dry-run path must not call Factory.Prompter(); confirm-prompt side effect leaked")
|
||||
return nil
|
||||
},
|
||||
Secrets: func() (secrets.Store, error) { return secrets.NewMemStore(), nil },
|
||||
}
|
||||
}
|
||||
|
||||
// withRootHarnessProfile wraps a profile subcommand under a synthetic root
|
||||
// cmd that registers the global persistent flags.
|
||||
func withRootHarnessProfile(sub *cobra.Command, args ...string) *cobra.Command {
|
||||
root := &cobra.Command{Use: "weknora"}
|
||||
pf := root.PersistentFlags()
|
||||
pf.BoolP("yes", "y", false, "")
|
||||
pf.String("format", "", "")
|
||||
pf.StringP("jq", "q", "", "")
|
||||
root.AddCommand(sub)
|
||||
root.SetArgs(append([]string{sub.Name()}, args...))
|
||||
root.SetContext(context.Background())
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
return root
|
||||
}
|
||||
|
||||
// TestProfileAdd_DryRun_RejectsDuplicate: profile already exists → live path
|
||||
// returns resource.already_exists; --dry-run must do the same.
|
||||
func TestProfileAdd_DryRun_RejectsDuplicate(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
iostreams.SetForTest(t)
|
||||
require.NoError(t, config.Save(&config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{"prod": {Host: "https://prod"}},
|
||||
}))
|
||||
|
||||
root := withRootHarnessProfile(NewCmdAdd(profileDryRunFactory(t)),
|
||||
"prod", "--host", "https://other.example.com", "--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err, "dry-run must reject duplicate profile name")
|
||||
var typed *cmdutil.Error
|
||||
require.True(t, errors.As(err, &typed), "expected *cmdutil.Error, got %T %v", err, err)
|
||||
assert.Equal(t, cmdutil.CodeResourceAlreadyExists, typed.Code)
|
||||
}
|
||||
|
||||
// TestProfileAdd_DryRun_RejectsInvalidName: shell-unsafe profile name → live
|
||||
// path returns input.invalid_argument; --dry-run must do the same.
|
||||
func TestProfileAdd_DryRun_RejectsInvalidName(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
iostreams.SetForTest(t)
|
||||
|
||||
root := withRootHarnessProfile(NewCmdAdd(profileDryRunFactory(t)),
|
||||
"bad name", "--host", "https://x.example.com", "--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err, "dry-run must reject invalid profile name")
|
||||
}
|
||||
|
||||
// TestProfileRemove_DryRun_RejectsUnknownName: removing a nonexistent profile
|
||||
// returns local.profile_not_found on the live path; --dry-run must too.
|
||||
func TestProfileRemove_DryRun_RejectsUnknownName(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
iostreams.SetForTest(t)
|
||||
require.NoError(t, config.Save(&config.Config{
|
||||
Profiles: map[string]config.Profile{"prod": {Host: "https://prod"}},
|
||||
}))
|
||||
|
||||
root := withRootHarnessProfile(NewCmdRemove(profileDryRunFactory(t)),
|
||||
"ghost", "--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err, "dry-run must reject unknown profile name")
|
||||
var typed *cmdutil.Error
|
||||
require.True(t, errors.As(err, &typed), "expected *cmdutil.Error, got %T %v", err, err)
|
||||
assert.Equal(t, cmdutil.CodeLocalProfileNotFound, typed.Code)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package profilecmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/config"
|
||||
"github.com/Tencent/WeKnora/cli/internal/format"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/output"
|
||||
)
|
||||
|
||||
type ListOptions struct{}
|
||||
|
||||
// profileListFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `profile list`. Each entry is a per-profile summary row.
|
||||
var profileListFields = []string{
|
||||
"name", "host", "user", "current",
|
||||
}
|
||||
|
||||
type listEntry struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
User string `json:"user,omitempty"`
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
// NewCmdList builds `weknora profile list`. Per-host enumeration with an
|
||||
// active marker. Reads only config.yaml - no network, no keyring touch.
|
||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List configured profiles",
|
||||
Long: `Show every profile registered in ~/.config/weknora/config.yaml. The
|
||||
active profile (used by subsequent commands when --profile is unset) is
|
||||
marked with a leading "*". No network requests are issued.
|
||||
|
||||
The credential mode (api-key vs password) is intentionally not shown here -
|
||||
run "weknora auth list" for that. "profile list" is the catalog of *where*
|
||||
the CLI can talk to; "auth list" is the catalog of *how*.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
return runList(fopts)
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, profileListFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "list configured connection profiles (name, host, which is active)",
|
||||
Examples: []string{"weknora profile list", "weknora profile list --jq '.data[].name'"},
|
||||
Output: "envelope.data is an array of {name, host, user, current}",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runList(fopts *cmdutil.FormatOptions) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries := make([]listEntry, 0, len(cfg.Profiles))
|
||||
for name, c := range cfg.Profiles {
|
||||
entries = append(entries, listEntry{
|
||||
Name: name,
|
||||
Host: c.Host,
|
||||
User: c.User,
|
||||
Current: name == cfg.CurrentProfile,
|
||||
})
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
meta := &output.Meta{Count: output.IntPtr(len(entries))}
|
||||
return fopts.Emit(iostreams.IO.Out, entries, meta)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
fmt.Fprintln(iostreams.IO.Out, "No profiles configured. Run `weknora auth login` (or `weknora profile add`) to create one.")
|
||||
return nil
|
||||
}
|
||||
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, " NAME\tHOST\tUSER")
|
||||
for _, e := range entries {
|
||||
marker := " "
|
||||
if e.Current {
|
||||
marker = "* "
|
||||
}
|
||||
fmt.Fprintf(tw, "%s%s\t%s\t%s\n", marker, e.Name, e.Host, format.DashIfEmpty(e.User))
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package profilecmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/config"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
)
|
||||
|
||||
func TestList_Empty(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
|
||||
if err := runList(&cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "No profiles") {
|
||||
t.Errorf("empty output should mention `No profiles`, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_MultipleSorted(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "staging",
|
||||
Profiles: map[string]config.Profile{
|
||||
"production": {Host: "https://prod.example.com", User: "alice@example.com"},
|
||||
"staging": {Host: "https://staging.example.com"},
|
||||
"alpha": {Host: "https://alpha.example.com"},
|
||||
},
|
||||
}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
if err := runList(&cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
// header
|
||||
if !strings.Contains(got, "NAME") || !strings.Contains(got, "HOST") {
|
||||
t.Errorf("missing header NAME/HOST in %q", got)
|
||||
}
|
||||
// row ordering: alpha < production < staging
|
||||
iAlpha := strings.Index(got, "alpha")
|
||||
iProd := strings.Index(got, "production")
|
||||
iStg := strings.Index(got, "staging")
|
||||
if !(iAlpha < iProd && iProd < iStg) {
|
||||
t.Errorf("rows must be alphabetical, got order alpha=%d prod=%d staging=%d in %q", iAlpha, iProd, iStg, got)
|
||||
}
|
||||
// active marker on staging row
|
||||
stgLine := lineContaining(got, "staging")
|
||||
if !strings.HasPrefix(stgLine, "*") {
|
||||
t.Errorf("active profile row must start with `*`, got %q", stgLine)
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_JSON(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "staging",
|
||||
Profiles: map[string]config.Profile{
|
||||
"staging": {Host: "https://staging.example.com", User: "bob@example.com"},
|
||||
"production": {Host: "https://prod.example.com"},
|
||||
},
|
||||
}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
if err := runList(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\noutput=%q", err, out.String())
|
||||
}
|
||||
rows := env.Data
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(rows))
|
||||
}
|
||||
// alphabetical: production before staging
|
||||
if rows[0]["name"] != "production" {
|
||||
t.Errorf("first row should be production, got %v", rows[0])
|
||||
}
|
||||
if rows[1]["name"] != "staging" || rows[1]["current"] != true {
|
||||
t.Errorf("second row should be staging with current=true, got %v", rows[1])
|
||||
}
|
||||
}
|
||||
|
||||
// lineContaining returns the first line of s that contains needle (trimmed of
|
||||
// the trailing newline) or "" if no line matches.
|
||||
func lineContaining(s, needle string) string {
|
||||
for l := range strings.SplitSeq(s, "\n") {
|
||||
if strings.Contains(l, needle) {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Package profilecmd holds the `weknora profile` command tree
|
||||
// (list / add / remove / use).
|
||||
//
|
||||
// Package name `profilecmd` (not `profile`) to keep the pattern consistent
|
||||
// with other cmd subpackages.
|
||||
// The cobra Use: string is "profile" - this is what users type.
|
||||
package profilecmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// NewCmd builds the `weknora profile` parent command.
|
||||
func NewCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "profile",
|
||||
Short: "Manage CLI profiles (named connection targets)",
|
||||
}
|
||||
cmd.AddCommand(NewCmdList(f))
|
||||
cmd.AddCommand(NewCmdAdd(f))
|
||||
cmd.AddCommand(NewCmdRemove(f))
|
||||
cmd.AddCommand(NewCmdUse(f))
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package profilecmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"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/prompt"
|
||||
"github.com/Tencent/WeKnora/cli/internal/secrets"
|
||||
)
|
||||
|
||||
type RemoveOptions struct {
|
||||
Yes bool // sourced from the global -y/--yes persistent flag (matches `kb delete`)
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// profileRemoveFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `profile remove`. The result reports the disposition of the removed entry.
|
||||
var profileRemoveFields = []string{
|
||||
"name", "removed", "was_current",
|
||||
}
|
||||
|
||||
// removeResult is the typed payload emitted under data on success.
|
||||
type removeResult struct {
|
||||
Name string `json:"name"`
|
||||
Removed bool `json:"removed"`
|
||||
WasCurrent bool `json:"was_current"`
|
||||
}
|
||||
|
||||
// NewCmdRemove builds `weknora profile remove`. Drops the entry from
|
||||
// config.yaml and best-effort clears keyring references. Removing a
|
||||
// non-current profile is low-friction (no prompt). Removing the *current*
|
||||
// profile triggers the destructive-write confirmation protocol (exit 10),
|
||||
// because subsequent commands will have no default connection target.
|
||||
func NewCmdRemove(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &RemoveOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove <name>",
|
||||
Short: "Remove a profile (drops entry, clears keyring refs)",
|
||||
Long: `Deletes the named profile from config.yaml and best-effort clears any
|
||||
keyring references it owned (matches ` + "`weknora auth logout`" + `).
|
||||
|
||||
Removing the current profile also clears CurrentProfile - subsequent commands
|
||||
will error until you select another with ` + "`weknora profile use <name>`" + ` or pick
|
||||
one up via the global ` + "`--profile`" + ` flag. Because that change is observable in
|
||||
every later command, removing the current profile requires explicit -y/--yes
|
||||
in scripted / --format json invocations (exit code 10; see cli/README.md).`,
|
||||
Example: ` weknora profile remove staging # remove non-current → no prompt
|
||||
weknora profile remove production -y # remove current → confirm`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
opts.Yes, _ = c.Flags().GetBool("yes")
|
||||
// Pure-local existence check runs before the dry-run gate so
|
||||
// --dry-run rejects unknown profile names identically to the live
|
||||
// path. Same notFoundError as runRemove (kept there for direct
|
||||
// callers).
|
||||
cfg, cfgErr := config.Load()
|
||||
if cfgErr != nil {
|
||||
return cfgErr
|
||||
}
|
||||
if _, exists := cfg.Profiles[args[0]]; !exists {
|
||||
return notFoundError(args[0], cfg)
|
||||
}
|
||||
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
|
||||
Action: "profile.remove",
|
||||
Args: map[string]any{
|
||||
"name": args[0],
|
||||
},
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
store, err := f.Secrets()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runRemove(opts, fopts, args[0], store, f.Prompter())
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, profileRemoveFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetRisk(cmd, "profile.remove")
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "remove a named profile and its stored credentials",
|
||||
RequiredFlags: []string{"<name> (positional)"},
|
||||
Output: "envelope.data is {name, removed:true, was_current}",
|
||||
Examples: []string{
|
||||
"weknora profile remove staging",
|
||||
"weknora profile remove production -y",
|
||||
},
|
||||
Warnings: []string{
|
||||
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
|
||||
"profile remove deletes local credentials + config; the server-side token is not revoked (use 'auth logout' on server instead).",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runRemove(opts *RemoveOptions, fopts *cmdutil.FormatOptions, name string, store secrets.Store, p prompt.Prompter) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, exists := cfg.Profiles[name]
|
||||
if !exists {
|
||||
return notFoundError(name, cfg)
|
||||
}
|
||||
wasCurrent := name == cfg.CurrentProfile
|
||||
|
||||
jsonOut := fopts.WantsJSON()
|
||||
// Confirmation only fires for removing the current profile - non-current
|
||||
// remove uses the same low-friction policy as `auth logout`.
|
||||
if wasCurrent {
|
||||
if err := cmdutil.ConfirmDestructive(p, opts.Yes, jsonOut, "remove", "current profile", name, "profile.remove", []string{"weknora", "profile", "remove", name, "-y"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Config first, secrets after: a crash in between leaves an orphan
|
||||
// keyring entry but no dangling config ref (same ordering as auth logout).
|
||||
delete(cfg.Profiles, name)
|
||||
if wasCurrent {
|
||||
cfg.CurrentProfile = ""
|
||||
}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config")
|
||||
}
|
||||
clearProfileSecrets(store, ctx, name)
|
||||
|
||||
result := removeResult{Name: name, Removed: true, WasCurrent: wasCurrent}
|
||||
if jsonOut {
|
||||
return fopts.Emit(iostreams.IO.Out, result, nil)
|
||||
}
|
||||
if wasCurrent {
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Removed profile %s (current profile cleared - run `weknora profile use <name>` to pick another)\n", name)
|
||||
} else {
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Removed profile %s\n", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearProfileSecrets mirrors auth/logout.go: best-effort delete every secret
|
||||
// slot the profile references. Errors are swallowed so a missing keyring
|
||||
// entry doesn't block remove (same policy as `auth logout`).
|
||||
func clearProfileSecrets(store secrets.Store, c config.Profile, name string) {
|
||||
if c.TokenRef != "" {
|
||||
_ = store.Delete(name, "access")
|
||||
}
|
||||
if c.RefreshRef != "" {
|
||||
_ = store.Delete(name, "refresh")
|
||||
}
|
||||
if c.APIKeyRef != "" {
|
||||
_ = store.Delete(name, "api_key")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package profilecmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
"github.com/Tencent/WeKnora/cli/internal/testutil"
|
||||
)
|
||||
|
||||
// seedStore returns a MemStore pre-loaded with sentinel values for every
|
||||
// secret slot a profile might reference. Tests assert deletion by checking
|
||||
// `secrets.ErrNotFound` post-runRemove.
|
||||
func seedStore(t *testing.T, name string, slots ...string) *secrets.MemStore {
|
||||
t.Helper()
|
||||
s := secrets.NewMemStore()
|
||||
for _, slot := range slots {
|
||||
if err := s.Set(name, slot, "sentinel-"+name+"-"+slot); err != nil {
|
||||
t.Fatalf("seed %s/%s: %v", name, slot, err)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func assertDeleted(t *testing.T, s *secrets.MemStore, name, slot string) {
|
||||
t.Helper()
|
||||
if _, err := s.Get(name, slot); !errors.Is(err, secrets.ErrNotFound) {
|
||||
t.Errorf("expected %s/%s removed, got err=%v", name, slot, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemove_NonCurrent_NoPromptNeeded(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "production",
|
||||
Profiles: map[string]config.Profile{
|
||||
"production": {Host: "https://prod.example.com", TokenRef: "mem://production/access"},
|
||||
"staging": {Host: "https://staging.example.com", APIKeyRef: "mem://staging/api_key"},
|
||||
},
|
||||
}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
store := seedStore(t, "staging", "api_key")
|
||||
p := &testutil.ConfirmPrompter{}
|
||||
if err := runRemove(&RemoveOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, "staging", store, p); err != nil {
|
||||
t.Fatalf("runRemove: %v", err)
|
||||
}
|
||||
if p.Asked {
|
||||
t.Errorf("non-current remove must not prompt")
|
||||
}
|
||||
|
||||
got, _ := config.Load()
|
||||
if _, exists := got.Profiles["staging"]; exists {
|
||||
t.Errorf("staging should have been removed; Profiles=%v", got.Profiles)
|
||||
}
|
||||
if got.CurrentProfile != "production" {
|
||||
t.Errorf("CurrentProfile must be unchanged, got %q", got.CurrentProfile)
|
||||
}
|
||||
assertDeleted(t, store, "staging", "api_key")
|
||||
if !strings.Contains(out.String(), "staging") {
|
||||
t.Errorf("output should mention removed profile, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemove_NotFound_WithDidYouMean(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{Profiles: map[string]config.Profile{
|
||||
"production": {Host: "https://prod"},
|
||||
"staging": {Host: "https://staging"},
|
||||
}}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
err := runRemove(&RemoveOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, "prodution", secrets.NewMemStore(), &testutil.ConfirmPrompter{})
|
||||
if err == nil {
|
||||
t.Fatal("expected not-found error")
|
||||
}
|
||||
cm, ok := err.(*cmdutil.Error)
|
||||
if !ok {
|
||||
t.Fatalf("expected *cmdutil.Error, got %T", err)
|
||||
}
|
||||
if cm.Code != cmdutil.CodeLocalProfileNotFound {
|
||||
t.Errorf("code=%q, want %q", cm.Code, cmdutil.CodeLocalProfileNotFound)
|
||||
}
|
||||
if !strings.Contains(cm.Hint, "production") {
|
||||
t.Errorf("hint should suggest 'production', got %q", cm.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemove_Current_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "production",
|
||||
Profiles: map[string]config.Profile{
|
||||
"production": {Host: "https://prod", TokenRef: "mem://production/access"},
|
||||
"staging": {Host: "https://staging"},
|
||||
},
|
||||
}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
store := seedStore(t, "production", "access")
|
||||
err := runRemove(&RemoveOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, "production", store, &testutil.ConfirmPrompter{})
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation-required error")
|
||||
}
|
||||
cm, ok := err.(*cmdutil.Error)
|
||||
if !ok {
|
||||
t.Fatalf("expected *cmdutil.Error, got %T", err)
|
||||
}
|
||||
if cm.Code != cmdutil.CodeInputConfirmationRequired {
|
||||
t.Errorf("code=%q, want %q", cm.Code, cmdutil.CodeInputConfirmationRequired)
|
||||
}
|
||||
if cmdutil.ExitCode(err) != 10 {
|
||||
t.Errorf("expected exit-10, got %d", cmdutil.ExitCode(err))
|
||||
}
|
||||
// Must not have mutated config or keyring.
|
||||
if got, _ := config.Load(); got.CurrentProfile != "production" {
|
||||
t.Errorf("config mutated despite confirmation gate: CurrentProfile=%q", got.CurrentProfile)
|
||||
}
|
||||
if v, err := store.Get("production", "access"); err != nil || v == "" {
|
||||
t.Errorf("keyring touched before confirmation, get=%q err=%v", v, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemove_Current_WithYes_ClearsCurrent(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "production",
|
||||
Profiles: map[string]config.Profile{
|
||||
"production": {Host: "https://prod", TokenRef: "mem://production/access"},
|
||||
"staging": {Host: "https://staging"},
|
||||
},
|
||||
}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
store := seedStore(t, "production", "access")
|
||||
if err := runRemove(&RemoveOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, "production", store, &testutil.ConfirmPrompter{}); err != nil {
|
||||
t.Fatalf("runRemove: %v", err)
|
||||
}
|
||||
got, _ := config.Load()
|
||||
if _, exists := got.Profiles["production"]; exists {
|
||||
t.Errorf("production should be removed")
|
||||
}
|
||||
if got.CurrentProfile != "" {
|
||||
t.Errorf("removing current must clear CurrentProfile, got %q", got.CurrentProfile)
|
||||
}
|
||||
assertDeleted(t, store, "production", "access")
|
||||
if !strings.Contains(out.String(), "current profile cleared") {
|
||||
t.Errorf("output should warn about cleared current, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemove_Current_TTY_PromptNo(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
_, errBuf := iostreams.SetForTestWithTTY(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "production",
|
||||
Profiles: map[string]config.Profile{"production": {Host: "https://prod"}},
|
||||
}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
p := &testutil.ConfirmPrompter{Answer: false}
|
||||
err := runRemove(&RemoveOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, "production", secrets.NewMemStore(), p)
|
||||
if err == nil {
|
||||
t.Fatal("expected user-aborted error")
|
||||
}
|
||||
cm, ok := err.(*cmdutil.Error)
|
||||
if !ok {
|
||||
t.Fatalf("expected *cmdutil.Error, got %T", err)
|
||||
}
|
||||
if cm.Code != cmdutil.CodeUserAborted {
|
||||
t.Errorf("code=%q, want %q", cm.Code, cmdutil.CodeUserAborted)
|
||||
}
|
||||
if !p.Asked {
|
||||
t.Errorf("prompt should have been asked on TTY")
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "Aborted") {
|
||||
t.Errorf("stderr should contain Aborted, got %q", errBuf.String())
|
||||
}
|
||||
if got, _ := config.Load(); got.CurrentProfile != "production" {
|
||||
t.Errorf("aborted remove must not mutate config")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package profilecmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// profileUseFields enumerates fields surfaced for `--format json` discovery on
|
||||
// `profile use`.
|
||||
var profileUseFields = []string{"current_profile", "previous_profile"}
|
||||
|
||||
// NewCmdUse builds the `weknora profile use <name>` command.
|
||||
func NewCmdUse(f *cmdutil.Factory) *cobra.Command {
|
||||
var dryRun bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "use <name>",
|
||||
Short: "Switch the default profile for subsequent commands",
|
||||
Long: `Switches the default profile written in config.yaml. Names are case-sensitive.
|
||||
|
||||
The active profile is what every subsequent command uses for auth + host. The
|
||||
global --profile flag (e.g. weknora --profile staging kb list) overrides for
|
||||
one command without writing to disk.
|
||||
|
||||
AI agents: Do NOT switch the active profile unless the user explicitly asked
|
||||
you to. Profile selection is a user preference; one-shot overrides should use
|
||||
the global --profile flag instead, which writes nothing to disk.`,
|
||||
Example: ` weknora profile use staging # persist switch
|
||||
weknora --profile staging kb list # one-shot override (no disk write)
|
||||
weknora profile use staging --format json # {current_profile, previous_profile}`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
if handled, err := cmdutil.HandleDryRun(c, dryRun, cmdutil.DryRunPlan{
|
||||
Action: "profile.use",
|
||||
Args: map[string]any{"name": args[0]},
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
return runUse(args[0], fopts)
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, profileUseFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &dryRun)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "switch the default profile for subsequent commands (persists to config)",
|
||||
RequiredFlags: []string{"<name> (positional)"},
|
||||
Examples: []string{"weknora profile use staging"},
|
||||
Output: "envelope.data confirms the now-active profile",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
type useResult struct {
|
||||
CurrentProfile string `json:"current_profile"`
|
||||
PreviousProfile string `json:"previous_profile,omitempty"`
|
||||
}
|
||||
|
||||
func runUse(name string, fopts *cmdutil.FormatOptions) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := cfg.Profiles[name]; !ok {
|
||||
return notFoundError(name, cfg)
|
||||
}
|
||||
prev := cfg.CurrentProfile
|
||||
cfg.CurrentProfile = name
|
||||
if err := config.Save(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
result := useResult{CurrentProfile: name, PreviousProfile: prev}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, result, nil)
|
||||
}
|
||||
if prev != "" && prev != name {
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Switched profile to %s (was %s)\n", name, prev)
|
||||
} else {
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Active profile: %s\n", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func notFoundError(name string, cfg *config.Config) error {
|
||||
if len(cfg.Profiles) == 0 {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeLocalProfileNotFound,
|
||||
Message: fmt.Sprintf("profile not found: %s", name),
|
||||
Hint: "no profiles registered - run `weknora auth login` first",
|
||||
}
|
||||
}
|
||||
keys := profileKeys(cfg.Profiles)
|
||||
candidate := cmdutil.SuggestOne(name, keys)
|
||||
var hint string
|
||||
if candidate != "" && candidate != name {
|
||||
hint = fmt.Sprintf("did you mean: %q?", candidate)
|
||||
} else {
|
||||
hint = fmt.Sprintf("available profiles: %v", keys)
|
||||
}
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeLocalProfileNotFound,
|
||||
Message: fmt.Sprintf("profile not found: %s", name),
|
||||
Hint: hint,
|
||||
}
|
||||
}
|
||||
|
||||
func profileKeys(m map[string]config.Profile) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package profilecmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/config"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
)
|
||||
|
||||
func TestUse_OK(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "staging",
|
||||
Profiles: map[string]config.Profile{
|
||||
"staging": {Host: "https://staging.example.com"},
|
||||
"production": {Host: "https://prod.example.com"},
|
||||
},
|
||||
}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
t.Fatalf("Save initial config: %v", err)
|
||||
}
|
||||
|
||||
if err := runUse("production", &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil {
|
||||
t.Fatalf("runUse: %v", err)
|
||||
}
|
||||
|
||||
got, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if got.CurrentProfile != "production" {
|
||||
t.Errorf("CurrentProfile = %q, want production", got.CurrentProfile)
|
||||
}
|
||||
if !strings.Contains(out.String(), "production") {
|
||||
t.Errorf("output should mention switched-to profile, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUse_NotFound_WithDidYouMean(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{Profiles: map[string]config.Profile{
|
||||
"production": {Host: "https://prod"},
|
||||
"staging": {Host: "https://staging"},
|
||||
}}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
err := runUse("prodution", &cmdutil.FormatOptions{Mode: cmdutil.FormatText}) // typo: missing 'c'
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
cm, ok := err.(*cmdutil.Error)
|
||||
if !ok {
|
||||
t.Fatalf("expected *cmdutil.Error, got %T", err)
|
||||
}
|
||||
if cm.Code != cmdutil.CodeLocalProfileNotFound {
|
||||
t.Errorf("code = %q, want %q", cm.Code, cmdutil.CodeLocalProfileNotFound)
|
||||
}
|
||||
if cm.Hint != `did you mean: "production"?` {
|
||||
t.Errorf("hint should be exact `did you mean: \"production\"?`, got %q", cm.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUse_NotFound_DeterministicTieBreak guards against map-iteration-order
|
||||
// flake: when two candidates have equal levenshtein distance, the suggestion
|
||||
// must be reproducibly the lexicographically-smaller one.
|
||||
func TestUse_NotFound_DeterministicTieBreak(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
cfg := &config.Config{Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://a"},
|
||||
"prom": {Host: "https://b"},
|
||||
"prog": {Host: "https://c"},
|
||||
}}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
// "prox" is distance 1 from prod / prom (both win); lex tie-break → prod.
|
||||
for i := 0; i < 5; i++ {
|
||||
err := runUse("prox", &cmdutil.FormatOptions{Mode: cmdutil.FormatText})
|
||||
if err == nil {
|
||||
t.Fatalf("iter %d: expected error", i)
|
||||
}
|
||||
cm := err.(*cmdutil.Error)
|
||||
if cm.Hint != `did you mean: "prod"?` {
|
||||
t.Fatalf("iter %d: tie-break must pick lex-smallest 'prod', got %q", i, cm.Hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUse_NotFound_EmptyProfiles(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
|
||||
err := runUse("anything", &cmdutil.FormatOptions{Mode: cmdutil.FormatText})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
cm := err.(*cmdutil.Error)
|
||||
if !strings.Contains(cm.Hint, "auth login") {
|
||||
t.Errorf("hint should mention `auth login` for empty profiles, got %q", cm.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUse_CaseSensitive(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{Profiles: map[string]config.Profile{
|
||||
"Production": {Host: "https://prod"},
|
||||
}}
|
||||
_ = config.Save(cfg)
|
||||
|
||||
err := runUse("production", &cmdutil.FormatOptions{Mode: cmdutil.FormatText}) // lowercase - must NOT match "Production"
|
||||
if err == nil {
|
||||
t.Fatal("expected case-sensitive miss")
|
||||
}
|
||||
cm := err.(*cmdutil.Error)
|
||||
// did-you-mean kicks in (distance 1 - "P"→"p")
|
||||
if !strings.Contains(cm.Hint, "Production") {
|
||||
t.Errorf("hint should suggest 'Production' (case-different), got %q", cm.Hint)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user