feat: 实现完整的本地配置管理与CLI重构
本次重构实现了标准化的本地配置系统,替换原有的硬编码环境变量读取逻辑: 1. 新增跨平台的原子化配置文件读写,支持Unix和Windows系统 2. 新增init命令用于安全初始化和更新本地凭据 3. 替换原有错误提示文案为更友好的中文提示 4. 更新文档说明新的配置流程和安全规范 5. 新增完整的配置相关测试用例 6. 添加必要的依赖包支持
This commit is contained in:
@@ -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 不得进入源码、命令参数、日志或输出,测试只能使用明显虚构的占位值。
|
||||
|
||||
## 开发流程
|
||||
|
||||
|
||||
@@ -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 历史、普通日志或聊天内容。
|
||||
|
||||
## 更新
|
||||
|
||||
|
||||
@@ -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/` 下的当前构建产物,精确参数以 `<binary> <subcommand> --help` 为准。
|
||||
|
||||
## 操作流程
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package config
|
||||
|
||||
import "os"
|
||||
|
||||
func replaceFile(source, target string) error {
|
||||
return os.Rename(source, target)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user