Skip to content
Merged
10 changes: 7 additions & 3 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/spf13/viper"

"neo-code/internal/app"
"neo-code/internal/config"
)

var launchRootProgram = defaultRootProgramLauncher
Expand Down Expand Up @@ -83,9 +84,12 @@ func defaultRootProgramLauncher(ctx context.Context, opts app.BootstrapOptions)
return err
}

// defaultGlobalPreload 预留给根命令全局预加载逻辑,默认不执行重度配置加载。
func defaultGlobalPreload(_ context.Context) error {
return nil
// defaultGlobalPreload runs lightweight startup preloads such as persisted env.
func defaultGlobalPreload(ctx context.Context) error {
if err := ctx.Err(); err != nil {
return err
}
return config.LoadPersistedEnv("")
}

// shouldSkipGlobalPreload 判断当前命令是否应跳过全局预加载逻辑。
Expand Down
78 changes: 78 additions & 0 deletions internal/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"

tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"

"neo-code/internal/app"
"neo-code/internal/config"
"neo-code/internal/gateway"
"neo-code/internal/gateway/adapters/urlscheme"
)
Expand Down Expand Up @@ -950,6 +952,70 @@ func TestShouldSkipGlobalPreload(t *testing.T) {
}
}

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)
}

restore := captureEnvForRootTest(t, "NEOCODE_PRELOAD_KEEP")
defer restore()
if err := os.Setenv("NEOCODE_PRELOAD_KEEP", "process-value"); err != nil {
t.Fatalf("Setenv() error = %v", err)
}

if err := defaultGlobalPreload(context.Background()); err != nil {
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)
}
}

func TestDefaultGlobalPreloadReturnsContextError(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()

err := defaultGlobalPreload(ctx)
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context canceled, got %v", err)
}
}

func TestWriteURLDispatchSuccessOutput(t *testing.T) {
var buffer bytes.Buffer
err := writeURLDispatchSuccessOutput(&buffer, urlscheme.DispatchResult{
Expand Down Expand Up @@ -1038,3 +1104,15 @@ func (quitModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (quitModel) View() string {
return ""
}

func captureEnvForRootTest(t *testing.T, key string) func() {
t.Helper()
value, exists := os.LookupEnv(key)
return func() {
if exists {
_ = os.Setenv(key, value)
return
}
_ = os.Unsetenv(key)
}
}
19 changes: 19 additions & 0 deletions internal/config/env_platform.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//go:build !windows

package config

// PersistUserEnvVar persists a key/value pair into user-level environment storage.
// On non-Windows platforms, NeoCode currently relies on .env persistence and process env.
func PersistUserEnvVar(key string, value string) error {
return nil
}

// DeleteUserEnvVar 删除用户级环境变量;非 Windows 平台当前无需额外处理。
func DeleteUserEnvVar(key string) error {
return nil
}

// LookupUserEnvVar 查询用户级环境变量;非 Windows 平台当前无单独存储,统一返回不存在。
func LookupUserEnvVar(key string) (string, bool, error) {
return "", false, nil
}
30 changes: 30 additions & 0 deletions internal/config/env_platform_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//go:build !windows

package config

import "testing"

func TestPersistUserEnvVarNoopOnNonWindows(t *testing.T) {
if err := PersistUserEnvVar("NEOCODE_TEST_KEY", "value"); err != nil {
t.Fatalf("PersistUserEnvVar() error = %v", err)
}
}

func TestDeleteUserEnvVarNoopOnNonWindows(t *testing.T) {
if err := DeleteUserEnvVar("NEOCODE_TEST_KEY"); err != nil {
t.Fatalf("DeleteUserEnvVar() error = %v", err)
}
}

func TestLookupUserEnvVarNoopOnNonWindows(t *testing.T) {
value, exists, err := LookupUserEnvVar("NEOCODE_TEST_KEY")
if err != nil {
t.Fatalf("LookupUserEnvVar() error = %v", err)
}
if exists {
t.Fatalf("LookupUserEnvVar() exists = true, want false")
}
if value != "" {
t.Fatalf("LookupUserEnvVar() value = %q, want empty", value)
}
}
95 changes: 95 additions & 0 deletions internal/config/env_platform_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//go:build windows

package config

import (
"errors"
"fmt"
"strings"

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

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 strings.ContainsAny(value, "\r\n") {
return errors.New("config: env value contains newline")
}

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

if err := envKey.SetStringValue(normalizedKey, value); err != nil {
return fmt.Errorf("config: set windows user env %q: %w", normalizedKey, err)
}
return nil
}

// 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)
}

envKey, err := registry.OpenKey(registry.CURRENT_USER, windowsUserEnvironmentKey, registry.SET_VALUE)
if err != nil {
if errors.Is(err, registry.ErrNotExist) {
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) {
return nil
}
return fmt.Errorf("config: delete windows user env %q: %w", normalizedKey, err)
}
return nil
}

// 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)
}

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

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