From 340dd241c90d3cce16ff989458be8a958a565772 Mon Sep 17 00:00:00 2001 From: Xlxinxi Date: Sat, 1 Aug 2026 02:39:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E7=9A=84=E6=9C=AC=E5=9C=B0=E9=85=8D=E7=BD=AE=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E4=B8=8ECLI=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次重构实现了标准化的本地配置系统,替换原有的硬编码环境变量读取逻辑: 1. 新增跨平台的原子化配置文件读写,支持Unix和Windows系统 2. 新增init命令用于安全初始化和更新本地凭据 3. 替换原有错误提示文案为更友好的中文提示 4. 更新文档说明新的配置流程和安全规范 5. 新增完整的配置相关测试用例 6. 添加必要的依赖包支持 --- AGENTS.md | 8 +- README.md | 26 ++- SKILL.md | 13 +- go.mod | 3 + go.sum | 6 + internal/client/client.go | 4 +- internal/command/commands.go | 9 +- internal/command/commands_test.go | 15 +- internal/command/init.go | 62 ++++++ internal/command/init_test.go | 60 +++++ internal/config/config.go | 234 ++++++++++++++++++++ internal/config/config_test.go | 343 +++++++++++++++++++++++++++++ internal/config/replace_unix.go | 9 + internal/config/replace_windows.go | 21 ++ 14 files changed, 793 insertions(+), 20 deletions(-) create mode 100644 internal/command/init.go create mode 100644 internal/command/init_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/replace_unix.go create mode 100644 internal/config/replace_windows.go diff --git a/AGENTS.md b/AGENTS.md index a1b88dd..518f49d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,8 +17,12 @@ - 写命令必须使用稳定且唯一的 `--request-id`;同一操作重试时复用原 request ID 和原参数。 - 报价编辑固定使用 `begin -> command -> preview -> commit`,放弃时使用 `discard`。 - 项目和报价删除必须先执行对应 `delete-preview`,不得绕过 revision 与 `changeSetId`。 -- CLI 只读取 `LIGHTCORE_API_BASE_URL` 和 `LIGHTCORE_SHOP_CRM_AGENT_TOKEN`;缺失时明确失败,不回退 localhost。 -- Token 不得写入源码、文件、日志或输出;测试只能使用明显虚构的占位值。 +- API 默认连接 `https://api.zhecent.com`;`LIGHTCORE_API_BASE_URL` 只用于显式覆盖,不回退 localhost。 +- CLI 只以仓库内 `bin/shop-crm-agent[.exe]` 运行;不得用 `go install` 安装该 CLI,不得将其二进制复制或软链接到仓库外,也不得为该 CLI 修改 `PATH`、Shell 启动文件或系统级配置。 +- 本地配置只通过 `shop-crm-agent init` 隐藏读取 Token,并在仓库根写入 Git 已忽略的 `.env`;该命令可重复执行且每次原子覆盖原文件,除 `init` 外不得修改该文件。 +- 二进制从 `bin/` 定位仓库根 `.env`,`go run` 只允许从仓库根使用。`.env` 只包含 `LIGHTCORE_SHOP_CRM_AGENT_TOKEN`;`init` 在 Unix 系统写入 `0600`,加载时拒绝组或其他用户权限。 +- 进程环境中的 `LIGHTCORE_SHOP_CRM_AGENT_TOKEN` 优先于 `.env`;覆盖 API 地址时必须同时显式提供环境 Token,禁止把 `.env` 的生产凭据发送到其他地址。 +- CLI 不创建、更新或撤销服务端凭据。Agent 不得读取、显示、记录或提交 `.env` 内容;Token 不得进入源码、命令参数、日志或输出,测试只能使用明显虚构的占位值。 ## 开发流程 diff --git a/README.md b/README.md index 9135ca7..50923bf 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ - Git - Go 1.26 或更高版本 -- 可访问目标 LightCore API 的网络环境 -- 运行环境提供 `LIGHTCORE_API_BASE_URL` 和 `LIGHTCORE_SHOP_CRM_AGENT_TOKEN` +- 可访问生产 LightCore API `https://api.zhecent.com` 的网络环境 +- 当前员工可登录 Shop 管理后台创建 CRM Agent 凭据 仓库在开发工作区中固定与 `LightCore` 同级。例如: @@ -24,19 +24,31 @@ ```bash git clone https://code.zhecent.com/open/shop-crm-agent.git cd shop-crm-agent +go version go run ./scripts/build.go ./bin/shop-crm-agent --version +./bin/shop-crm-agent init ``` -Windows 构建产物为 `bin/shop-crm-agent.exe`。构建脚本执行测试后,只编译当前操作系统和 CPU 架构,不安装系统服务,也不修改全局 PATH。 +缺少 Go 或版本低于 `go.mod` 要求时,先安装官方 Go 工具链,再重新执行构建。Windows 构建产物为 `bin/shop-crm-agent.exe`。构建脚本执行测试后,只编译当前操作系统和 CPU 架构,不安装系统服务,也不修改全局 PATH。 -运行业务命令前,由当前 Shell 或 Agent 的受控运行环境提供配置: +## 运行配置 -```bash -export LIGHTCORE_API_BASE_URL=https://api.example.com +CLI 默认连接生产 API `https://api.zhecent.com`,无需设置 `LIGHTCORE_API_BASE_URL`,也不会回退 localhost。 + +首次执行 `init` 时,终端会隐藏输入内容并提示粘贴 CRM Agent Token。Token 由员工登录 Shop 管理后台后,在 `/shop/crm/settings/agent-credentials` 创建;凭据绑定当前管理员及所选 `crm.read`、`crm.write`、`crm.delete` scope,明文只在创建成功时显示一次。 + +初始化成功后,CLI 在仓库根创建 Git 已忽略的 `.env`: + +```dotenv +LIGHTCORE_SHOP_CRM_AGENT_TOKEN=<由 init 隐藏写入> ``` -`LIGHTCORE_SHOP_CRM_AGENT_TOKEN` 必须通过密码管理器、受控 Secret 注入或其他不会回显和记录明文的机制提供。不要在交互式命令中直接输入真实 Token,也不得把它写入仓库、Skill、`.env`、命令历史、普通日志或聊天内容。 +`.env` 只保存这一项;`init` 在 Unix 系统写入 `0600` 权限,CLI 会拒绝允许组或其他用户访问的文件。二进制从自身所在的 `bin/` 定位仓库根,因此从其他目录调用同一二进制时仍读取该文件;使用 `go run` 时必须位于仓库根。Agent 不得读取、回显、记录或提交 `.env` 内容。 + +凭据撤销或更换后重新执行 `./bin/shop-crm-agent init`。该命令可重复执行,每次都会通过同目录临时文件原子覆盖仓库根 `.env`;`init` 只管理本地配置文件,不创建、更新或撤销服务端凭据。 + +CI 或测试环境可以通过 `LIGHTCORE_SHOP_CRM_AGENT_TOKEN` 临时覆盖 `.env`。`LIGHTCORE_API_BASE_URL` 仅用于覆盖生产地址;覆盖时必须同时显式提供同一环境的 `LIGHTCORE_SHOP_CRM_AGENT_TOKEN`,避免把 `.env` 中的生产凭据发送到其他地址。真实 Token 不得进入命令参数、Shell 历史、普通日志或聊天内容。 ## 更新 diff --git a/SKILL.md b/SKILL.md index 2765ee8..762301b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -15,13 +15,18 @@ description: 安装、更新并安全使用 shop-crm-agent 查询和维护 Shop 2. 缺少仓库时,从 `https://code.zhecent.com/open/shop-crm-agent.git` 匿名 clone,不使用来源不明的压缩包或二进制。 3. 检查 `go version` 满足 `go.mod`;缺少 Go 时安装官方 Go 工具链,再继续构建。 4. 首次安装在仓库根目录执行 `go run ./scripts/build.go`,使用 `bin/shop-crm-agent`;Windows 使用 `bin/shop-crm-agent.exe`。 -5. 收到更新通知后先确认 `git status --short` 为空,再执行 `git pull --ff-only`;拉取后重新读取本文件并重新构建。 -6. 本地存在修改、拉取失败、测试失败、构建失败或版本探针失败时停止,不得强制重置或继续使用半完成产物。 +5. 只运行仓库内 `bin/shop-crm-agent[.exe]`;不得用 `go install` 安装该 CLI,不得将其二进制复制或软链接到仓库外,也不得为该 CLI 修改 `PATH`、Shell 启动文件或系统级配置。 +6. 构建成功后执行 `bin/shop-crm-agent init`;让员工在隐藏提示中粘贴 Shop 管理后台只显示一次的 Token,不得要求员工把 Token 发到聊天中。 +7. 收到更新通知后先确认 `git status --short` 为空,再执行 `git pull --ff-only`;拉取后重新读取本文件并重新构建。`.env` 被 Git 忽略,正常更新不会改动凭据。 +8. 本地存在修改、拉取失败、测试失败、构建失败或版本探针失败时停止,不得强制重置或继续使用半完成产物。 ## 运行配置 -- 确认 `LIGHTCORE_API_BASE_URL` 和 `LIGHTCORE_SHOP_CRM_AGENT_TOKEN` 已由运行环境提供;不得显示、记录或提交 Token。 -- 凭据只通过 Shop 管理后台创建和撤销;CLI 不管理凭据。 +- 默认生产 API 为 `https://api.zhecent.com`,无需设置 `LIGHTCORE_API_BASE_URL`,且不会回退 localhost。 +- Token 只通过 Shop 管理后台 `/shop/crm/settings/agent-credentials` 创建和撤销,绑定当前管理员与所选 scope,明文仅在创建时显示一次;CLI 的 `init` 只把已有 Token 写入本地 `.env`,不管理服务端凭据。 +- `.env` 固定在仓库根,由 `init` 创建且已被 Git 忽略;`init` 可重复执行并且每次原子覆盖旧文件,除此之外 Agent 不得读取或修改其内容。 +- 业务命令默认从 `.env` 读取 Token;CI 可使用 `LIGHTCORE_SHOP_CRM_AGENT_TOKEN` 覆盖。覆盖 `LIGHTCORE_API_BASE_URL` 时必须同时显式提供对应环境的 Token,不得读取 `.env` 中的生产 Token。 +- 不得显示、记录、提交 Token,也不得把真实 Token 写入命令参数、会保留历史的 Shell 命令、普通日志或聊天内容。 - 业务命令使用仓库 `bin/` 下的当前构建产物,精确参数以 ` --help` 为准。 ## 操作流程 diff --git a/go.mod b/go.mod index 50d2b03..00c5469 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,9 @@ go 1.26 require ( github.com/spf13/cobra v1.10.2 + golang.org/x/mod v0.38.0 + golang.org/x/sys v0.47.0 + golang.org/x/term v0.45.0 google.golang.org/protobuf v1.36.11 ) diff --git a/go.sum b/go.sum index d56c5f7..6cb9113 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,12 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/client/client.go b/internal/client/client.go index 31932be..dd26f4c 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -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} diff --git a/internal/command/commands.go b/internal/command/commands.go index 5c92ace..c97598d 100644 --- a/internal/command/commands.go +++ b/internal/command/commands.go @@ -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 { diff --git a/internal/command/commands_test.go b/internal/command/commands_test.go index cfaa0c6..027e0c1 100644 --- a/internal/command/commands_test.go +++ b/internal/command/commands_test.go @@ -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) } } diff --git a/internal/command/init.go b/internal/command/init.go new file mode 100644 index 0000000..a8e9ea9 --- /dev/null +++ b/internal/command/init.go @@ -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 +} diff --git a/internal/command/init_test.go b/internal/command/init_test.go new file mode 100644 index 0000000..80dede8 --- /dev/null +++ b/internal/command/init_test.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..ce5cee7 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..28a77b8 --- /dev/null +++ b/internal/config/config_test.go @@ -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 +} diff --git a/internal/config/replace_unix.go b/internal/config/replace_unix.go new file mode 100644 index 0000000..4c5d8c1 --- /dev/null +++ b/internal/config/replace_unix.go @@ -0,0 +1,9 @@ +//go:build !windows + +package config + +import "os" + +func replaceFile(source, target string) error { + return os.Rename(source, target) +} diff --git a/internal/config/replace_windows.go b/internal/config/replace_windows.go new file mode 100644 index 0000000..0ba7794 --- /dev/null +++ b/internal/config/replace_windows.go @@ -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, + ) +}