feat: 实现完整的本地配置管理与CLI重构

本次重构实现了标准化的本地配置系统,替换原有的硬编码环境变量读取逻辑:
1. 新增跨平台的原子化配置文件读写,支持Unix和Windows系统
2. 新增init命令用于安全初始化和更新本地凭据
3. 替换原有错误提示文案为更友好的中文提示
4. 更新文档说明新的配置流程和安全规范
5. 新增完整的配置相关测试用例
6. 添加必要的依赖包支持
This commit is contained in:
2026-08-01 02:39:49 +08:00
parent 3466e19d13
commit 340dd241c9
14 changed files with 793 additions and 20 deletions
+2 -2
View File
@@ -31,10 +31,10 @@ func New(baseURL, token string, httpClient *http.Client) (*Client, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return nil, errors.New("LIGHTCORE_API_BASE_URL 必须是有效的 http/https URL")
return nil, errors.New("CRM Agent API Base URL 必须是有效的 http/https URL")
}
if strings.TrimSpace(token) == "" {
return nil, errors.New("LIGHTCORE_SHOP_CRM_AGENT_TOKEN 不能为空")
return nil, errors.New("CRM Agent Token 不能为空")
}
if httpClient == nil {
httpClient = &http.Client{Timeout: 30 * time.Second}
+7 -2
View File
@@ -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 {
+12 -3
View File
@@ -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)
}
}
+62
View File
@@ -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
}
+60
View File
@@ -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)
}
}
+234
View File
@@ -0,0 +1,234 @@
package config
import (
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"golang.org/x/mod/modfile"
)
const (
DefaultAPIBaseURL = "https://api.zhecent.com"
APIBaseURLEnv = "LIGHTCORE_API_BASE_URL"
TokenEnv = "LIGHTCORE_SHOP_CRM_AGENT_TOKEN"
EnvFileName = ".env"
maxEnvFileSize = 16 << 10
modulePath = "code.zhecent.com/open/shop-crm-agent"
)
type Config struct {
APIBaseURL string
Token string
}
func Load() (Config, error) {
envFile, err := DefaultEnvFile()
if err != nil {
return Config{}, err
}
return load(envFile, os.LookupEnv)
}
func Initialize(token string) (string, error) {
envFile, err := DefaultEnvFile()
if err != nil {
return "", err
}
if err := writeEnvFile(envFile, token); err != nil {
return "", err
}
return envFile, nil
}
func DefaultEnvFile() (string, error) {
executable, executableErr := os.Executable()
if executableErr == nil {
if resolved, err := filepath.EvalSymlinks(executable); err == nil {
executable = resolved
}
}
workingDir, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("定位 shop-crm-agent 仓库失败: %w", err)
}
return resolveEnvFile(executable, workingDir)
}
func resolveEnvFile(executable, workingDir string) (string, error) {
binaryDir := filepath.Dir(executable)
if filepath.Base(binaryDir) == "bin" {
repoRoot := filepath.Dir(binaryDir)
if isRepositoryRoot(repoRoot) {
return filepath.Join(repoRoot, EnvFileName), nil
}
}
if !isRepositoryRoot(workingDir) {
return "", errors.New("无法定位 shop-crm-agent 仓库根目录,请从仓库根运行 CLI")
}
return filepath.Join(workingDir, EnvFileName), nil
}
func load(envFile string, lookupEnv func(string) (string, bool)) (Config, error) {
apiBaseURL := DefaultAPIBaseURL
if rawAPIBaseURL, exists := lookupEnv(APIBaseURLEnv); exists && strings.TrimSpace(rawAPIBaseURL) != "" {
apiBaseURL = normalizeBaseURL(rawAPIBaseURL)
if err := validateAPIBaseURL(apiBaseURL); err != nil {
return Config{}, err
}
}
if rawToken, exists := lookupEnv(TokenEnv); exists && strings.TrimSpace(rawToken) != "" {
token, err := normalizeToken(rawToken)
if err != nil {
return Config{}, err
}
return Config{APIBaseURL: apiBaseURL, Token: token}, nil
}
if apiBaseURL != DefaultAPIBaseURL {
return Config{}, fmt.Errorf("覆盖 %s 时必须同时设置 %s", APIBaseURLEnv, TokenEnv)
}
token, err := readEnvFile(envFile)
if err != nil {
return Config{}, err
}
return Config{APIBaseURL: apiBaseURL, Token: token}, nil
}
func readEnvFile(path string) (string, error) {
pathInfo, err := os.Lstat(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", errors.New("CRM Agent 尚未初始化,请运行 shop-crm-agent init")
}
return "", fmt.Errorf("读取 CRM Agent .env 信息失败: %w", err)
}
if !pathInfo.Mode().IsRegular() {
return "", errors.New("CRM Agent .env 必须是普通文件")
}
file, err := os.Open(path)
if err != nil {
return "", fmt.Errorf("打开 CRM Agent .env 失败: %w", err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return "", fmt.Errorf("读取 CRM Agent .env 信息失败: %w", err)
}
if !info.Mode().IsRegular() || !os.SameFile(pathInfo, info) {
return "", errors.New("CRM Agent .env 必须是普通文件")
}
if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
return "", errors.New("CRM Agent .env 不得允许组或其他用户访问,请设置为 0600 权限")
}
data, err := io.ReadAll(io.LimitReader(file, maxEnvFileSize+1))
if err != nil {
return "", fmt.Errorf("读取 CRM Agent .env 失败: %w", err)
}
if len(data) > maxEnvFileSize {
return "", errors.New("CRM Agent .env 超过 16 KiB 限制")
}
contents := strings.TrimSuffix(string(data), "\n")
contents = strings.TrimSuffix(contents, "\r")
if strings.ContainsAny(contents, "\r\n\x00") {
return "", errors.New("CRM Agent .env 格式无效,请重新运行 shop-crm-agent init")
}
prefix := TokenEnv + "="
if !strings.HasPrefix(contents, prefix) {
return "", errors.New("CRM Agent .env 缺少凭证,请重新运行 shop-crm-agent init")
}
return normalizeToken(strings.TrimPrefix(contents, prefix))
}
func writeEnvFile(path, token string) error {
return writeEnvFileWithReplace(path, token, replaceFile)
}
func writeEnvFileWithReplace(path, token string, replace func(string, string) error) error {
token, err := normalizeToken(token)
if err != nil {
return err
}
contents := TokenEnv + "=" + token + "\n"
if len(contents) > maxEnvFileSize {
return errors.New("CRM Agent Token 超过 .env 大小限制")
}
if info, err := os.Lstat(path); err == nil {
if !info.Mode().IsRegular() {
return errors.New("CRM Agent .env 已存在且不是普通文件,拒绝覆盖")
}
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("检查 CRM Agent .env 失败: %w", err)
}
tempFile, err := os.CreateTemp(filepath.Dir(path), EnvFileName+".tmp-*")
if err != nil {
return fmt.Errorf("创建 CRM Agent .env 临时文件失败: %w", err)
}
tempPath := tempFile.Name()
defer func() {
_ = tempFile.Close()
_ = os.Remove(tempPath)
}()
if runtime.GOOS != "windows" {
if err := tempFile.Chmod(0o600); err != nil {
return fmt.Errorf("设置 CRM Agent .env 权限失败: %w", err)
}
}
if _, err := io.WriteString(tempFile, contents); err != nil {
return fmt.Errorf("写入 CRM Agent .env 失败: %w", err)
}
if err := tempFile.Sync(); err != nil {
return fmt.Errorf("同步 CRM Agent .env 失败: %w", err)
}
if err := tempFile.Close(); err != nil {
return fmt.Errorf("关闭 CRM Agent .env 失败: %w", err)
}
if err := replace(tempPath, path); err != nil {
return fmt.Errorf("替换 CRM Agent .env 失败: %w", err)
}
return nil
}
func normalizeToken(value string) (string, error) {
token := strings.TrimSpace(value)
if token == "" {
return "", errors.New("CRM Agent Token 不能为空")
}
if len(token) > maxEnvFileSize || strings.ContainsAny(token, "\r\n\x00") {
return "", errors.New("CRM Agent Token 格式无效")
}
return token, nil
}
func normalizeBaseURL(value string) string {
return strings.TrimRight(strings.TrimSpace(value), "/")
}
func validateAPIBaseURL(value string) error {
parsed, err := url.Parse(value)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return errors.New("CRM Agent API Base URL 必须是有效的 http/https URL")
}
return nil
}
func isRepositoryRoot(path string) bool {
data, err := os.ReadFile(filepath.Join(path, "go.mod"))
if err != nil {
return false
}
return modfile.ModulePath(data) == modulePath
}
+343
View File
@@ -0,0 +1,343 @@
package config
import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestLoadUsesProductionDefaultAndRepositoryEnvFile(t *testing.T) {
envFile := filepath.Join(t.TempDir(), EnvFileName)
writeTestEnvFile(t, envFile, TokenEnv+"=file-token\n", 0o600)
value, err := load(envFile, emptyEnvironment)
if err != nil {
t.Fatalf("load config: %v", err)
}
if value.APIBaseURL != DefaultAPIBaseURL || value.Token != "file-token" {
t.Fatalf("config = %#v", value)
}
}
func TestInitializeWritesAndReplacesRepositoryRootEnvFile(t *testing.T) {
repositoryRoot := makeTestRepository(t)
t.Chdir(repositoryRoot)
wantPath := filepath.Join(repositoryRoot, EnvFileName)
path, err := Initialize("first-token")
if err != nil {
t.Fatalf("initialize first token: %v", err)
}
if path != wantPath {
t.Fatalf("env file = %q, want %q", path, wantPath)
}
path, err = Initialize("second-token")
if err != nil {
t.Fatalf("initialize second token: %v", err)
}
if path != wantPath {
t.Fatalf("replacement env file = %q, want %q", path, wantPath)
}
data, err := os.ReadFile(wantPath)
if err != nil {
t.Fatal(err)
}
if string(data) != TokenEnv+"=second-token\n" {
t.Fatalf("replaced env file = %q", data)
}
}
func TestLoadEnvironmentTokenTakesPriority(t *testing.T) {
envFile := filepath.Join(t.TempDir(), EnvFileName)
writeTestEnvFile(t, envFile, TokenEnv+"=file-token\n", 0o600)
environment := map[string]string{
APIBaseURLEnv: "https://staging.example.com/",
TokenEnv: " environment-token ",
}
value, err := load(envFile, mapEnvironment(environment))
if err != nil {
t.Fatalf("load config: %v", err)
}
if value.APIBaseURL != "https://staging.example.com" || value.Token != "environment-token" {
t.Fatalf("config = %#v", value)
}
}
func TestLoadAPIOverrideRequiresEnvironmentToken(t *testing.T) {
envFile := filepath.Join(t.TempDir(), EnvFileName)
writeTestEnvFile(t, envFile, TokenEnv+"=production-token\n", 0o600)
_, err := load(envFile, mapEnvironment(map[string]string{APIBaseURLEnv: "https://staging.example.com"}))
if err == nil || !strings.Contains(err.Error(), TokenEnv) {
t.Fatalf("error = %v", err)
}
}
func TestLoadRejectsInvalidExplicitAPIWithoutProductionFallback(t *testing.T) {
for _, value := range []string{"/", "////", "not-a-url"} {
t.Run(value, func(t *testing.T) {
_, err := load(filepath.Join(t.TempDir(), EnvFileName), mapEnvironment(map[string]string{
APIBaseURLEnv: value,
TokenEnv: "test-token",
}))
if err == nil || !strings.Contains(err.Error(), "有效的 http/https URL") {
t.Fatalf("error = %v", err)
}
})
}
}
func TestReadEnvFileRejectsMissingUnsafeOrMalformedFiles(t *testing.T) {
t.Run("missing", func(t *testing.T) {
_, err := readEnvFile(filepath.Join(t.TempDir(), EnvFileName))
if err == nil || !strings.Contains(err.Error(), "shop-crm-agent init") {
t.Fatalf("error = %v", err)
}
})
t.Run("directory", func(t *testing.T) {
_, err := readEnvFile(t.TempDir())
if err == nil || !strings.Contains(err.Error(), "普通文件") {
t.Fatalf("error = %v", err)
}
})
if runtime.GOOS != "windows" {
t.Run("symlink", func(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target")
writeTestEnvFile(t, target, TokenEnv+"=token\n", 0o600)
path := filepath.Join(dir, EnvFileName)
if err := os.Symlink(target, path); err != nil {
t.Fatal(err)
}
_, err := readEnvFile(path)
if err == nil || !strings.Contains(err.Error(), "普通文件") {
t.Fatalf("error = %v", err)
}
})
}
t.Run("missing key", func(t *testing.T) {
path := filepath.Join(t.TempDir(), EnvFileName)
writeTestEnvFile(t, path, "OTHER=value\n", 0o600)
_, err := readEnvFile(path)
if err == nil || !strings.Contains(err.Error(), "缺少凭证") {
t.Fatalf("error = %v", err)
}
})
t.Run("multiple lines", func(t *testing.T) {
path := filepath.Join(t.TempDir(), EnvFileName)
writeTestEnvFile(t, path, TokenEnv+"=token\nOTHER=value\n", 0o600)
_, err := readEnvFile(path)
if err == nil || !strings.Contains(err.Error(), "格式无效") {
t.Fatalf("error = %v", err)
}
})
t.Run("too large", func(t *testing.T) {
path := filepath.Join(t.TempDir(), EnvFileName)
writeTestEnvFile(t, path, strings.Repeat("x", maxEnvFileSize+1), 0o600)
_, err := readEnvFile(path)
if err == nil || !strings.Contains(err.Error(), "16 KiB") {
t.Fatalf("error = %v", err)
}
})
if runtime.GOOS != "windows" {
t.Run("permissions", func(t *testing.T) {
path := filepath.Join(t.TempDir(), EnvFileName)
writeTestEnvFile(t, path, TokenEnv+"=token\n", 0o644)
_, err := readEnvFile(path)
if err == nil || !strings.Contains(err.Error(), "0600") {
t.Fatalf("error = %v", err)
}
})
}
}
func TestWriteEnvFileCreatesPrivateFileAndReplacesItOnEveryRun(t *testing.T) {
path := filepath.Join(t.TempDir(), EnvFileName)
if err := writeEnvFile(path, "first-token"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(data) != TokenEnv+"=first-token\n" {
t.Fatalf("env file = %q", data)
}
if runtime.GOOS != "windows" {
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("mode = %o", info.Mode().Perm())
}
}
if err := writeEnvFile(path, "second-token"); err != nil {
t.Fatal(err)
}
data, _ = os.ReadFile(path)
if string(data) != TokenEnv+"=second-token\n" {
t.Fatalf("replaced env file = %q", data)
}
matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), EnvFileName+".tmp-*"))
if err != nil {
t.Fatal(err)
}
if len(matches) != 0 {
t.Fatalf("temporary files remain: %v", matches)
}
}
func TestWriteEnvFileKeepsExistingCredentialsWhenAtomicReplaceFails(t *testing.T) {
path := filepath.Join(t.TempDir(), EnvFileName)
writeTestEnvFile(t, path, TokenEnv+"=existing-token\n", 0o600)
err := writeEnvFileWithReplace(path, "replacement-token", func(source, target string) error {
return errors.New("injected replace failure")
})
if err == nil || !strings.Contains(err.Error(), "替换") {
t.Fatalf("error = %v", err)
}
data, readErr := os.ReadFile(path)
if readErr != nil {
t.Fatal(readErr)
}
if string(data) != TokenEnv+"=existing-token\n" {
t.Fatalf("existing credentials changed: %q", data)
}
matches, globErr := filepath.Glob(filepath.Join(filepath.Dir(path), EnvFileName+".tmp-*"))
if globErr != nil {
t.Fatal(globErr)
}
if len(matches) != 0 {
t.Fatalf("temporary files remain: %v", matches)
}
}
func TestWriteEnvFileDoesNotPublishWhenInitialAtomicReplaceFails(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, EnvFileName)
err := writeEnvFileWithReplace(path, "initial-token", func(source, target string) error {
return errors.New("injected replace failure")
})
if err == nil || !strings.Contains(err.Error(), "替换") {
t.Fatalf("error = %v", err)
}
if _, statErr := os.Lstat(path); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("unexpected env file after failed initialization: %v", statErr)
}
matches, globErr := filepath.Glob(filepath.Join(dir, EnvFileName+".tmp-*"))
if globErr != nil {
t.Fatal(globErr)
}
if len(matches) != 0 {
t.Fatalf("temporary files remain: %v", matches)
}
}
func TestWriteEnvFileRejectsTokenThatCannotFitEnvFile(t *testing.T) {
path := filepath.Join(t.TempDir(), EnvFileName)
err := writeEnvFile(path, strings.Repeat("x", maxEnvFileSize))
if err == nil || !strings.Contains(err.Error(), "大小限制") {
t.Fatalf("error = %v", err)
}
if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("unexpected env file: %v", statErr)
}
}
func TestWriteEnvFileRejectsSymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows symlink creation requires privileges on some environments")
}
dir := t.TempDir()
target := filepath.Join(dir, "target")
writeTestEnvFile(t, target, "unchanged", 0o600)
path := filepath.Join(dir, EnvFileName)
if err := os.Symlink(target, path); err != nil {
t.Fatal(err)
}
if err := writeEnvFile(path, "token"); err == nil {
t.Fatal("symlink env file was overwritten")
}
data, _ := os.ReadFile(target)
if string(data) != "unchanged" {
t.Fatalf("symlink target changed: %q", data)
}
}
func TestResolveEnvFileUsesBuiltBinaryRepositoryThenWorkingDirectory(t *testing.T) {
builtRepo := makeTestRepository(t)
builtBinary := filepath.Join(builtRepo, "bin", "shop-crm-agent")
if err := os.MkdirAll(filepath.Dir(builtBinary), 0o755); err != nil {
t.Fatal(err)
}
workingRepo := makeTestRepository(t)
path, err := resolveEnvFile(builtBinary, workingRepo)
if err != nil {
t.Fatal(err)
}
if path != filepath.Join(builtRepo, EnvFileName) {
t.Fatalf("built binary env file = %q", path)
}
path, err = resolveEnvFile(filepath.Join(t.TempDir(), "shop-crm-agent"), workingRepo)
if err != nil {
t.Fatal(err)
}
if path != filepath.Join(workingRepo, EnvFileName) {
t.Fatalf("go run env file = %q", path)
}
}
func TestResolveEnvFileRejectsModulePathMentionedOnlyInComment(t *testing.T) {
dir := t.TempDir()
writeTestEnvFile(t, filepath.Join(dir, "go.mod"), "module example.com/other\n// module "+modulePath+"\n", 0o644)
_, err := resolveEnvFile(filepath.Join(t.TempDir(), "shop-crm-agent"), dir)
if err == nil || !strings.Contains(err.Error(), "无法定位") {
t.Fatalf("error = %v", err)
}
}
func emptyEnvironment(string) (string, bool) { return "", false }
func mapEnvironment(values map[string]string) func(string) (string, bool) {
return func(key string) (string, bool) {
value, exists := values[key]
return value, exists
}
}
func writeTestEnvFile(t *testing.T, path, contents string, mode os.FileMode) {
t.Helper()
if err := os.WriteFile(path, []byte(contents), mode); err != nil {
t.Fatal(err)
}
if runtime.GOOS != "windows" {
if err := os.Chmod(path, mode); err != nil {
t.Fatal(err)
}
}
}
func makeTestRepository(t *testing.T) string {
t.Helper()
dir := t.TempDir()
writeTestEnvFile(t, filepath.Join(dir, "go.mod"), "module "+modulePath+"\n", 0o644)
return dir
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !windows
package config
import "os"
func replaceFile(source, target string) error {
return os.Rename(source, target)
}
+21
View File
@@ -0,0 +1,21 @@
//go:build windows
package config
import "golang.org/x/sys/windows"
func replaceFile(source, target string) error {
sourcePath, err := windows.UTF16PtrFromString(source)
if err != nil {
return err
}
targetPath, err := windows.UTF16PtrFromString(target)
if err != nil {
return err
}
return windows.MoveFileEx(
sourcePath,
targetPath,
windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH,
)
}