feat: 实现完整的本地配置管理与CLI重构
本次重构实现了标准化的本地配置系统,替换原有的硬编码环境变量读取逻辑: 1. 新增跨平台的原子化配置文件读写,支持Unix和Windows系统 2. 新增init命令用于安全初始化和更新本地凭据 3. 替换原有错误提示文案为更友好的中文提示 4. 更新文档说明新的配置流程和安全规范 5. 新增完整的配置相关测试用例 6. 添加必要的依赖包支持
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
dto "code.zhecent.com/open/shop-crm-agent/internal/api"
|
||||
crmClient "code.zhecent.com/open/shop-crm-agent/internal/client"
|
||||
runtimeConfig "code.zhecent.com/open/shop-crm-agent/internal/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
@@ -26,12 +27,16 @@ func New(version string) *cobra.Command {
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
|
||||
}
|
||||
cmd.AddCommand(newCrmAgentCustomerListCmd(), newCrmAgentProjectCmd(), newCrmAgentQuotationCmd(), newCrmAgentCatalogCmd(), newCrmAgentEditCmd())
|
||||
cmd.AddCommand(newInitCmd(), newCrmAgentCustomerListCmd(), newCrmAgentProjectCmd(), newCrmAgentQuotationCmd(), newCrmAgentCatalogCmd(), newCrmAgentEditCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCrmAgentHTTPClient() (*crmClient.Client, error) {
|
||||
return crmClient.New(os.Getenv("LIGHTCORE_API_BASE_URL"), os.Getenv("LIGHTCORE_SHOP_CRM_AGENT_TOKEN"), nil)
|
||||
configuration, err := runtimeConfig.Load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return crmClient.New(configuration.APIBaseURL, configuration.Token, nil)
|
||||
}
|
||||
|
||||
func writeCrmAgentProto(writer io.Writer, message proto.Message) error {
|
||||
|
||||
@@ -3,6 +3,9 @@ package command
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
dto "code.zhecent.com/open/shop-crm-agent/internal/api"
|
||||
@@ -13,6 +16,7 @@ import (
|
||||
func TestRootCommandExposesOnlyCrmOperations(t *testing.T) {
|
||||
cmd := New("test-version")
|
||||
want := map[string]bool{
|
||||
"init": true,
|
||||
"customer-list": true, "project": true,
|
||||
"quotation": true, "catalog-search": true, "edit": true,
|
||||
}
|
||||
@@ -55,13 +59,18 @@ func TestProjectCommandsExcludeProgressAndTemplateFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteCommandRequiresEnvironmentConfiguration(t *testing.T) {
|
||||
func TestRemoteCommandRequiresCredentialConfiguration(t *testing.T) {
|
||||
t.Setenv("LIGHTCORE_API_BASE_URL", "")
|
||||
t.Setenv("LIGHTCORE_SHOP_CRM_AGENT_TOKEN", "")
|
||||
repoRoot := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(repoRoot, "go.mod"), []byte("module code.zhecent.com/open/shop-crm-agent\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(repoRoot)
|
||||
cmd := New("test")
|
||||
cmd.SetArgs([]string{"customer-list"})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("missing environment configuration was accepted")
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "shop-crm-agent init") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
runtimeConfig "code.zhecent.com/open/shop-crm-agent/internal/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
type initializeConfigFunc func(token string) (string, error)
|
||||
type readTokenFunc func(input io.Reader, output io.Writer) (string, error)
|
||||
|
||||
type initResult struct {
|
||||
Initialized bool `json:"initialized"`
|
||||
EnvFile string `json:"envFile"`
|
||||
}
|
||||
|
||||
func newInitCmd() *cobra.Command {
|
||||
return newInitCommand(runtimeConfig.Initialize, readTokenFromTerminal)
|
||||
}
|
||||
|
||||
func newInitCommand(initialize initializeConfigFunc, readToken readTokenFunc) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "初始化本地 CRM Agent 凭证",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
token, err := readToken(cmd.InOrStdin(), cmd.ErrOrStderr())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
envFile, err := initialize(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(initResult{Initialized: true, EnvFile: envFile})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func readTokenFromTerminal(input io.Reader, output io.Writer) (string, error) {
|
||||
inputFile, ok := input.(*os.File)
|
||||
if !ok || !term.IsTerminal(int(inputFile.Fd())) {
|
||||
return "", errors.New("shop-crm-agent init 必须在交互式终端运行")
|
||||
}
|
||||
if _, err := fmt.Fprint(output, "请输入 Shop 管理后台创建后仅显示一次的 CRM Agent Token: "); err != nil {
|
||||
return "", err
|
||||
}
|
||||
value, err := term.ReadPassword(int(inputFile.Fd()))
|
||||
_, _ = fmt.Fprintln(output)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取 CRM Agent Token 失败: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(value)), nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInitCommandReadsTokenWithoutPrintingIt(t *testing.T) {
|
||||
const token = "test-private-token"
|
||||
var receivedToken string
|
||||
cmd := newInitCommand(
|
||||
func(value string) (string, error) {
|
||||
receivedToken = value
|
||||
return "/test/repository/.env", nil
|
||||
},
|
||||
func(input io.Reader, output io.Writer) (string, error) {
|
||||
_, _ = io.WriteString(output, "credential prompt")
|
||||
return token, nil
|
||||
},
|
||||
)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if receivedToken != token {
|
||||
t.Fatalf("initializer received token=%q", receivedToken)
|
||||
}
|
||||
if strings.Contains(stdout.String(), token) || strings.Contains(stderr.String(), token) {
|
||||
t.Fatal("init output exposed the token")
|
||||
}
|
||||
result := initResult{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
|
||||
t.Fatalf("decode init result: %v", err)
|
||||
}
|
||||
if !result.Initialized || result.EnvFile != "/test/repository/.env" {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitCommandDoesNotExposeForceFlag(t *testing.T) {
|
||||
cmd := newInitCommand(func(string) (string, error) {
|
||||
return "", nil
|
||||
}, func(io.Reader, io.Writer) (string, error) {
|
||||
return "token", nil
|
||||
})
|
||||
if cmd.Flags().Lookup("force") != nil {
|
||||
t.Fatal("init still exposes the retired --force flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitTerminalReaderRejectsNonTTY(t *testing.T) {
|
||||
if _, err := readTokenFromTerminal(bytes.NewBufferString("token\n"), io.Discard); err == nil || !strings.Contains(err.Error(), "交互式终端") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user