Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

- 自动清理旧版 `providers` / `provider_overrides`
- 自动兼容 `workdir`、`default_workdir` 等旧字段
- 自动加载 `.env`

## 配置文件位置

Expand Down
5 changes: 2 additions & 3 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import (
"github.com/spf13/viper"

"neo-code/internal/app"
"neo-code/internal/config"
"neo-code/internal/updater"
"neo-code/internal/version"
)
Expand Down Expand Up @@ -115,12 +114,12 @@ func defaultRootProgramLauncher(ctx context.Context, opts app.BootstrapOptions)
return err
}

// defaultGlobalPreload runs lightweight startup preloads such as persisted env.
// defaultGlobalPreload 负责执行启动前预检查,避免在命令执行前遗漏上下文取消信号。
func defaultGlobalPreload(ctx context.Context) error {
if err := ctx.Err(); err != nil {
return err
}
return config.LoadPersistedEnv("")
return nil
}

// defaultSilentUpdateCheck 在后台异步检查新版本并缓存退出后提示文案。
Expand Down
43 changes: 2 additions & 41 deletions internal/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1425,46 +1425,7 @@ func TestDefaultSilentUpdateCheckSetsSanitizedNotice(t *testing.T) {
t.Fatal("expected update notice to be set")
}

func TestDefaultGlobalPreloadLoadsPersistedEnv(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)

envPath := filepath.Join(home, ".neocode", ".env")
if err := os.MkdirAll(filepath.Dir(envPath), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(envPath, []byte("NEOCODE_PRELOAD_KEY=loaded-value\n"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}

restore := captureEnvForRootTest(t, "NEOCODE_PRELOAD_KEY")
defer restore()
if err := os.Unsetenv("NEOCODE_PRELOAD_KEY"); err != nil {
t.Fatalf("Unsetenv() error = %v", err)
}

if err := defaultGlobalPreload(context.Background()); err != nil {
t.Fatalf("defaultGlobalPreload() error = %v", err)
}
if got := os.Getenv("NEOCODE_PRELOAD_KEY"); got != "loaded-value" {
t.Fatalf("expected loaded env value, got %q", got)
}
}

func TestDefaultGlobalPreloadKeepsExistingProcessEnv(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)

envPath := config.EnvFilePath("")
if err := os.MkdirAll(filepath.Dir(envPath), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(envPath, []byte("NEOCODE_PRELOAD_KEEP=file-value\n"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}

func TestDefaultGlobalPreloadNoop(t *testing.T) {
restore := captureEnvForRootTest(t, "NEOCODE_PRELOAD_KEEP")
defer restore()
if err := os.Setenv("NEOCODE_PRELOAD_KEEP", "process-value"); err != nil {
Expand All @@ -1475,7 +1436,7 @@ func TestDefaultGlobalPreloadKeepsExistingProcessEnv(t *testing.T) {
t.Fatalf("defaultGlobalPreload() error = %v", err)
}
if got := os.Getenv("NEOCODE_PRELOAD_KEEP"); got != "process-value" {
t.Fatalf("expected existing process env value, got %q", got)
t.Fatalf("defaultGlobalPreload should not mutate process env, got %q", got)
}
}

Expand Down
24 changes: 23 additions & 1 deletion internal/config/env_platform.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,40 @@

package config

import (
"errors"
"strings"
)

// PersistUserEnvVar persists a key/value pair into user-level environment storage.
// On non-Windows platforms, NeoCode currently relies on .env persistence and process env.
// Windows 平台当前不做系统级持久化,但会统一执行输入校验。
func PersistUserEnvVar(key string, value string) error {
if err := ValidateEnvVarName(strings.TrimSpace(key)); err != nil {
return err
}
if strings.ContainsAny(value, "\r\n") {
return errors.New("config: env value contains newline")
}
return nil
}

// DeleteUserEnvVar 删除用户级环境变量;非 Windows 平台当前无需额外处理。
func DeleteUserEnvVar(key string) error {
if err := ValidateEnvVarName(strings.TrimSpace(key)); err != nil {
return err
}
return nil
}

// LookupUserEnvVar 查询用户级环境变量;非 Windows 平台当前无单独存储,统一返回不存在。
func LookupUserEnvVar(key string) (string, bool, error) {
if err := ValidateEnvVarName(strings.TrimSpace(key)); err != nil {
return "", false, err
}
return "", false, nil
}

// SupportsUserEnvPersistence 返回当前平台是否支持用户级环境变量持久化。
func SupportsUserEnvPersistence() bool {
return false
}
6 changes: 6 additions & 0 deletions internal/config/env_platform_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,9 @@ func TestLookupUserEnvVarNoopOnNonWindows(t *testing.T) {
t.Fatalf("LookupUserEnvVar() value = %q, want empty", value)
}
}

func TestSupportsUserEnvPersistenceOnNonWindows(t *testing.T) {
if SupportsUserEnvPersistence() {
t.Fatal("SupportsUserEnvPersistence() = true, want false")
}
}
41 changes: 21 additions & 20 deletions internal/config/env_platform_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,11 @@ const windowsUserEnvironmentKey = `Environment`
// PersistUserEnvVar persists key/value into Windows user environment variables.
func PersistUserEnvVar(key string, value string) error {
normalizedKey := strings.TrimSpace(key)
if normalizedKey == "" {
return errors.New("config: env key is empty")
}
if strings.ContainsAny(normalizedKey, " \t\r\n=") {
return fmt.Errorf("config: env key %q is invalid", normalizedKey)
if err := ValidateEnvVarName(normalizedKey); err != nil {
return err
}
if strings.ContainsAny(value, "\r\n") {
return errors.New("config: env value contains newline")
return fmt.Errorf("config: env value contains newline")
}

envKey, _, err := registry.CreateKey(registry.CURRENT_USER, windowsUserEnvironmentKey, registry.SET_VALUE)
Expand All @@ -40,24 +37,21 @@ func PersistUserEnvVar(key string, value string) error {
// DeleteUserEnvVar 删除 Windows 用户级环境变量,不存在时视为成功。
func DeleteUserEnvVar(key string) error {
normalizedKey := strings.TrimSpace(key)
if normalizedKey == "" {
return errors.New("config: env key is empty")
}
if strings.ContainsAny(normalizedKey, " \t\r\n=") {
return fmt.Errorf("config: env key %q is invalid", normalizedKey)
if err := ValidateEnvVarName(normalizedKey); err != nil {
return err
}

envKey, err := registry.OpenKey(registry.CURRENT_USER, windowsUserEnvironmentKey, registry.SET_VALUE)
if err != nil {
if errors.Is(err, registry.ErrNotExist) {
if isWindowsRegistryNotExist(err) {
return nil
}
return fmt.Errorf("config: open windows user env: %w", err)
}
defer envKey.Close()

if err := envKey.DeleteValue(normalizedKey); err != nil {
if errors.Is(err, registry.ErrNotExist) {
if isWindowsRegistryNotExist(err) {
return nil
}
return fmt.Errorf("config: delete windows user env %q: %w", normalizedKey, err)
Expand All @@ -68,16 +62,13 @@ func DeleteUserEnvVar(key string) error {
// LookupUserEnvVar 查询 Windows 用户级环境变量,不存在时返回 exists=false。
func LookupUserEnvVar(key string) (string, bool, error) {
normalizedKey := strings.TrimSpace(key)
if normalizedKey == "" {
return "", false, errors.New("config: env key is empty")
}
if strings.ContainsAny(normalizedKey, " \t\r\n=") {
return "", false, fmt.Errorf("config: env key %q is invalid", normalizedKey)
if err := ValidateEnvVarName(normalizedKey); err != nil {
return "", false, err
}

envKey, err := registry.OpenKey(registry.CURRENT_USER, windowsUserEnvironmentKey, registry.QUERY_VALUE)
if err != nil {
if errors.Is(err, registry.ErrNotExist) {
if isWindowsRegistryNotExist(err) {
return "", false, nil
}
return "", false, fmt.Errorf("config: open windows user env: %w", err)
Expand All @@ -86,10 +77,20 @@ func LookupUserEnvVar(key string) (string, bool, error) {

value, _, err := envKey.GetStringValue(normalizedKey)
if err != nil {
if errors.Is(err, registry.ErrNotExist) {
if isWindowsRegistryNotExist(err) {
return "", false, nil
}
return "", false, fmt.Errorf("config: read windows user env %q: %w", normalizedKey, err)
}
return value, true, nil
}

// isWindowsRegistryNotExist 统一判断注册表“键/值不存在”错误,兼容包装错误场景。
func isWindowsRegistryNotExist(err error) bool {
return errors.Is(err, registry.ErrNotExist)
}

// SupportsUserEnvPersistence 返回当前平台是否支持用户级环境变量持久化。
func SupportsUserEnvPersistence() bool {
return true
}
24 changes: 24 additions & 0 deletions internal/config/env_platform_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//go:build windows

package config

import (
"errors"
"fmt"
"testing"

"golang.org/x/sys/windows/registry"
)

func TestIsWindowsRegistryNotExistWrapped(t *testing.T) {
wrapped := fmt.Errorf("wrapped: %w", registry.ErrNotExist)
if !isWindowsRegistryNotExist(wrapped) {
t.Fatal("expected wrapped ErrNotExist to be treated as not-exist")
}
}

func TestIsWindowsRegistryNotExistMismatch(t *testing.T) {
if isWindowsRegistryNotExist(errors.New("other error")) {
t.Fatal("expected non-ErrNotExist error to be false")
}
}
Loading
Loading