From ebdf953ac8fd2ff19fbfb71baa0b1e0036ae6fa2 Mon Sep 17 00:00:00 2001 From: egerev Date: Thu, 9 Apr 2026 02:45:22 +0800 Subject: [PATCH 1/2] fix: CLI exit codes, config.yaml reading, setup agent key creation (RUN4.06, RUN4.07, RUN4.08) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add apierror package with structured APIError type and exit code constants - Map HTTP 401/403 → exit 2, 404 → exit 3, 429 → exit 5, timeout → exit 4 - Update client.go to return *apierror.APIError instead of fmt.Errorf - Update main.go to call apierror.ExitCodeFrom(err) for correct exit codes - Add LoadFromPath to config.go; Load now reads ~/.alaya/config.yaml via yaml.v3, env vars override - Add createAPIKeyViaBootstrap to setup.go; setup agent creates key via ALAYA_BOOTSTRAP_KEY when none stored Co-Authored-By: Claude Sonnet 4.6 --- packages/cli-go/cmd/alaya/main.go | 3 +- packages/cli-go/go.mod | 1 + packages/cli-go/internal/apierror/apierror.go | 60 +++++++++++++ .../cli-go/internal/apierror/apierror_test.go | 86 +++++++++++++++++++ packages/cli-go/internal/client/client.go | 8 +- .../cli-go/internal/client/client_test.go | 60 +++++++++++++ packages/cli-go/internal/cmd/setup.go | 41 ++++++++- packages/cli-go/internal/cmd/setup_test.go | 63 ++++++++++++++ packages/cli-go/internal/config/config.go | 25 +++++- .../cli-go/internal/config/config_test.go | 76 ++++++++++++++++ 10 files changed, 417 insertions(+), 6 deletions(-) create mode 100644 packages/cli-go/internal/apierror/apierror.go create mode 100644 packages/cli-go/internal/apierror/apierror_test.go create mode 100644 packages/cli-go/internal/cmd/setup_test.go diff --git a/packages/cli-go/cmd/alaya/main.go b/packages/cli-go/cmd/alaya/main.go index e3cdf29..dbe1250 100644 --- a/packages/cli-go/cmd/alaya/main.go +++ b/packages/cli-go/cmd/alaya/main.go @@ -3,11 +3,12 @@ package main import ( "os" + "github.com/GoSync-Inc/alaya/packages/cli-go/internal/apierror" "github.com/GoSync-Inc/alaya/packages/cli-go/internal/cmd" ) func main() { if err := cmd.Execute(); err != nil { - os.Exit(1) + os.Exit(apierror.ExitCodeFrom(err)) } } diff --git a/packages/cli-go/go.mod b/packages/cli-go/go.mod index 43cff8b..f7c7aca 100644 --- a/packages/cli-go/go.mod +++ b/packages/cli-go/go.mod @@ -13,4 +13,5 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect golang.org/x/sys v0.27.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/packages/cli-go/internal/apierror/apierror.go b/packages/cli-go/internal/apierror/apierror.go new file mode 100644 index 0000000..22dc071 --- /dev/null +++ b/packages/cli-go/internal/apierror/apierror.go @@ -0,0 +1,60 @@ +package apierror + +import ( + "errors" + "fmt" +) + +const ( + ExitGeneric = 1 + ExitAuth = 2 + ExitNotFound = 3 + ExitTimeout = 4 + ExitRateLimit = 5 +) + +// APIError is a structured HTTP API error carrying an exit code. +type APIError struct { + StatusCode int + ExitCode int + Body string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("API error (%d): %s", e.StatusCode, e.Body) +} + +// New creates an APIError with the appropriate exit code for the given HTTP status. +func New(statusCode int, body string) *APIError { + exitCode := ExitGeneric + switch { + case statusCode == 401 || statusCode == 403: + exitCode = ExitAuth + case statusCode == 404: + exitCode = ExitNotFound + case statusCode == 429: + exitCode = ExitRateLimit + } + return &APIError{StatusCode: statusCode, ExitCode: exitCode, Body: body} +} + +// ExitCodeFrom extracts the appropriate CLI exit code from an error. +// Returns ExitGeneric for unknown errors and nil. +func ExitCodeFrom(err error) int { + if err == nil { + return ExitGeneric + } + var apiErr *APIError + if errors.As(err, &apiErr) { + return apiErr.ExitCode + } + if isTimeout(err) { + return ExitTimeout + } + return ExitGeneric +} + +func isTimeout(err error) bool { + var netErr interface{ Timeout() bool } + return errors.As(err, &netErr) && netErr.Timeout() +} diff --git a/packages/cli-go/internal/apierror/apierror_test.go b/packages/cli-go/internal/apierror/apierror_test.go new file mode 100644 index 0000000..43fe9e0 --- /dev/null +++ b/packages/cli-go/internal/apierror/apierror_test.go @@ -0,0 +1,86 @@ +package apierror + +import ( + "errors" + "fmt" + "net" + "testing" +) + +func TestNew_MessageFormat(t *testing.T) { + err := New(404, "not found body") + if err.Error() != "API error (404): not found body" { + t.Errorf("unexpected error message: %q", err.Error()) + } +} + +func TestNew_ExitCodes(t *testing.T) { + cases := []struct { + status int + wantCode int + }{ + {200, ExitGeneric}, // shouldn't normally be used but safe + {400, ExitGeneric}, + {401, ExitAuth}, + {403, ExitAuth}, + {404, ExitNotFound}, + {429, ExitRateLimit}, + {500, ExitGeneric}, + } + for _, tc := range cases { + err := New(tc.status, "body") + if err.ExitCode != tc.wantCode { + t.Errorf("status %d: expected exit code %d, got %d", tc.status, tc.wantCode, err.ExitCode) + } + } +} + +func TestExitCodeFrom_APIError(t *testing.T) { + err := New(401, "unauthorized") + code := ExitCodeFrom(err) + if code != ExitAuth { + t.Errorf("expected %d, got %d", ExitAuth, code) + } +} + +func TestExitCodeFrom_WrappedAPIError(t *testing.T) { + apiErr := New(404, "not found") + wrapped := fmt.Errorf("operation failed: %w", apiErr) + code := ExitCodeFrom(wrapped) + if code != ExitNotFound { + t.Errorf("expected %d, got %d", ExitNotFound, code) + } +} + +func TestExitCodeFrom_TimeoutError(t *testing.T) { + // net.Error with Timeout() = true + timeoutErr := &net.OpError{ + Op: "dial", + Err: &timeoutSentinel{}, + } + code := ExitCodeFrom(timeoutErr) + if code != ExitTimeout { + t.Errorf("expected %d, got %d", ExitTimeout, code) + } +} + +func TestExitCodeFrom_GenericError(t *testing.T) { + code := ExitCodeFrom(errors.New("some error")) + if code != ExitGeneric { + t.Errorf("expected %d, got %d", ExitGeneric, code) + } +} + +func TestExitCodeFrom_Nil(t *testing.T) { + code := ExitCodeFrom(nil) + if code != ExitGeneric { + t.Errorf("expected %d for nil, got %d", ExitGeneric, code) + } +} + +// timeoutSentinel is a net.Error that reports Timeout() = true. +type timeoutSentinel struct{} + +func (t *timeoutSentinel) Error() string { return "timeout" } +func (t *timeoutSentinel) Timeout() bool { return true } +func (t *timeoutSentinel) Temporary() bool { return true } diff --git a/packages/cli-go/internal/client/client.go b/packages/cli-go/internal/client/client.go index 455d3fc..7b9f94b 100644 --- a/packages/cli-go/internal/client/client.go +++ b/packages/cli-go/internal/client/client.go @@ -7,6 +7,8 @@ import ( "io" "net/http" "time" + + "github.com/GoSync-Inc/alaya/packages/cli-go/internal/apierror" ) type Client struct { @@ -42,7 +44,7 @@ func (c *Client) Get(path string) ([]byte, error) { } if resp.StatusCode >= 400 { - return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(data)) + return nil, apierror.New(resp.StatusCode, string(data)) } return data, nil @@ -73,7 +75,7 @@ func (c *Client) Post(path string, body interface{}) ([]byte, error) { } if resp.StatusCode >= 400 { - return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(data)) + return nil, apierror.New(resp.StatusCode, string(data)) } return data, nil @@ -98,7 +100,7 @@ func (c *Client) Delete(path string) ([]byte, error) { } if resp.StatusCode >= 400 { - return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(data)) + return nil, apierror.New(resp.StatusCode, string(data)) } return data, nil diff --git a/packages/cli-go/internal/client/client_test.go b/packages/cli-go/internal/client/client_test.go index 6f581c1..6d074a0 100644 --- a/packages/cli-go/internal/client/client_test.go +++ b/packages/cli-go/internal/client/client_test.go @@ -2,9 +2,12 @@ package client import ( "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" + + "github.com/GoSync-Inc/alaya/packages/cli-go/internal/apierror" ) func TestNew(t *testing.T) { @@ -142,6 +145,63 @@ func TestGet_ErrorStatus(t *testing.T) { } } +func TestGet_Returns_APIError_404(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error":"not found"}`)) + })) + defer ts.Close() + + c := New(ts.URL, "ak_test") + _, err := c.Get("/entities/missing") + var apiErr *apierror.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *apierror.APIError, got %T: %v", err, err) + } + if apiErr.StatusCode != 404 { + t.Errorf("expected status 404, got %d", apiErr.StatusCode) + } + if apiErr.ExitCode != apierror.ExitNotFound { + t.Errorf("expected ExitNotFound (%d), got %d", apierror.ExitNotFound, apiErr.ExitCode) + } +} + +func TestPost_Returns_APIError_401(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"unauthorized"}`)) + })) + defer ts.Close() + + c := New(ts.URL, "bad_key") + _, err := c.Post("/search", map[string]string{}) + var apiErr *apierror.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *apierror.APIError, got %T: %v", err, err) + } + if apiErr.ExitCode != apierror.ExitAuth { + t.Errorf("expected ExitAuth (%d), got %d", apierror.ExitAuth, apiErr.ExitCode) + } +} + +func TestDelete_Returns_APIError_429(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + w.Write([]byte(`{"error":"rate limited"}`)) + })) + defer ts.Close() + + c := New(ts.URL, "ak_test") + _, err := c.Delete("/api-keys/ak_prefix") + var apiErr *apierror.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *apierror.APIError, got %T: %v", err, err) + } + if apiErr.ExitCode != apierror.ExitRateLimit { + t.Errorf("expected ExitRateLimit (%d), got %d", apierror.ExitRateLimit, apiErr.ExitCode) + } +} + func TestAsk(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/packages/cli-go/internal/cmd/setup.go b/packages/cli-go/internal/cmd/setup.go index eaea52f..be8d200 100644 --- a/packages/cli-go/internal/cmd/setup.go +++ b/packages/cli-go/internal/cmd/setup.go @@ -1,10 +1,13 @@ package cmd import ( + "encoding/json" "fmt" + "os" "strings" "github.com/GoSync-Inc/alaya/packages/cli-go/internal/auth" + "github.com/GoSync-Inc/alaya/packages/cli-go/internal/client" "github.com/GoSync-Inc/alaya/packages/cli-go/internal/config" "github.com/spf13/cobra" ) @@ -30,7 +33,21 @@ var setupAgentCmd = &cobra.Command{ } apiKey, err := auth.GetAPIKey() if err != nil { - return fmt.Errorf("authenticate first: alaya auth login") + // No stored key — try creating one via bootstrap key + bootstrapKey := os.Getenv("ALAYA_BOOTSTRAP_KEY") + if bootstrapKey == "" { + return fmt.Errorf("no API key found. Run 'alaya auth login' or set ALAYA_BOOTSTRAP_KEY to create one automatically") + } + fmt.Println("No API key found. Creating one via bootstrap key...") + newKey, createErr := createAPIKeyViaBootstrap(baseURL, bootstrapKey) + if createErr != nil { + return fmt.Errorf("create API key: %w", createErr) + } + if storeErr := auth.SetAPIKey(newKey); storeErr != nil { + return fmt.Errorf("store API key: %w", storeErr) + } + fmt.Println("API key created and stored.") + apiKey = newKey } switch strings.ToLower(setupProfile) { case "claude-code": @@ -55,3 +72,25 @@ func init() { setupCmd.AddCommand(setupAgentCmd) setupAgentCmd.Flags().StringVar(&setupProfile, "profile", "generic", "Agent profile (claude-code|codex|cursor|generic)") } + +// createAPIKeyViaBootstrap calls POST /api-keys using the bootstrap key and returns the raw key. +func createAPIKeyViaBootstrap(baseURL, bootstrapKey string) (string, error) { + c := client.New(baseURL, bootstrapKey) + data, err := c.Post("/api-keys", map[string]interface{}{ + "name": "cli-agent", + "scopes": []string{"read", "write"}, + }) + if err != nil { + return "", err + } + var resp struct { + RawKey string `json:"raw_key"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return "", fmt.Errorf("parse key response: %w", err) + } + if resp.RawKey == "" { + return "", fmt.Errorf("server did not return raw_key in response") + } + return resp.RawKey, nil +} diff --git a/packages/cli-go/internal/cmd/setup_test.go b/packages/cli-go/internal/cmd/setup_test.go new file mode 100644 index 0000000..0c50f2b --- /dev/null +++ b/packages/cli-go/internal/cmd/setup_test.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestCreateAPIKeyViaBootstrap_Success(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + if r.Header.Get("X-Api-Key") != "bootstrap_key_123" { + t.Errorf("expected bootstrap key in X-Api-Key header, got %q", r.Header.Get("X-Api-Key")) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "raw_key": "ak_newly_created_key", + }) + })) + defer ts.Close() + + key, err := createAPIKeyViaBootstrap(ts.URL, "bootstrap_key_123") + if err != nil { + t.Fatalf("createAPIKeyViaBootstrap() error: %v", err) + } + if key != "ak_newly_created_key" { + t.Errorf("expected raw_key, got %q", key) + } +} + +func TestCreateAPIKeyViaBootstrap_APIError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"invalid bootstrap key"}`)) + })) + defer ts.Close() + + _, err := createAPIKeyViaBootstrap(ts.URL, "wrong_bootstrap") + if err == nil { + t.Fatal("expected error for 401 response") + } +} + +func TestCreateAPIKeyViaBootstrap_MissingRawKey(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + // Response missing raw_key field + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "key-uuid", + }) + })) + defer ts.Close() + + _, err := createAPIKeyViaBootstrap(ts.URL, "bootstrap_key_123") + if err == nil { + t.Fatal("expected error when raw_key is missing from response") + } +} diff --git a/packages/cli-go/internal/config/config.go b/packages/cli-go/internal/config/config.go index 89545b3..5a61e15 100644 --- a/packages/cli-go/internal/config/config.go +++ b/packages/cli-go/internal/config/config.go @@ -1,8 +1,11 @@ package config import ( + "fmt" "os" "path/filepath" + + "gopkg.in/yaml.v3" ) type Config struct { @@ -15,13 +18,33 @@ func DefaultConfigPath() string { return filepath.Join(home, ".alaya", "config.yaml") } +// Load reads config from the default path (~/.alaya/config.yaml) then applies env overrides. func Load() (*Config, error) { + return LoadFromPath(DefaultConfigPath()) +} + +// LoadFromPath reads config from the given path then applies env overrides. +// A missing file is not an error — defaults are used instead. +func LoadFromPath(path string) (*Config, error) { cfg := &Config{ ServerURL: "http://localhost:8000", } - // Env override + + data, err := os.ReadFile(path) + if err == nil { + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + } + // file not found is acceptable — continue with defaults + + // Env vars take priority over file settings if url := os.Getenv("ALAYA_SERVER_URL"); url != "" { cfg.ServerURL = url } + if ws := os.Getenv("ALAYA_WORKSPACE"); ws != "" { + cfg.Workspace = ws + } + return cfg, nil } diff --git a/packages/cli-go/internal/config/config_test.go b/packages/cli-go/internal/config/config_test.go index 8a9b0b0..4b57fb6 100644 --- a/packages/cli-go/internal/config/config_test.go +++ b/packages/cli-go/internal/config/config_test.go @@ -2,6 +2,7 @@ package config import ( "os" + "path/filepath" "testing" ) @@ -37,3 +38,78 @@ func TestDefaultConfigPath(t *testing.T) { t.Errorf("path too short: %q", path) } } + +func TestLoad_FromYAMLFile(t *testing.T) { + dir := t.TempDir() + cfgFile := filepath.Join(dir, "config.yaml") + content := []byte("server_url: http://yaml-server:9999\nworkspace: ws-from-file\n") + if err := os.WriteFile(cfgFile, content, 0o600); err != nil { + t.Fatalf("write config file: %v", err) + } + + // Override DefaultConfigPath by swapping the env; Load uses DefaultConfigPath internally, + // but we'll test via the exported LoadFromPath helper. + os.Unsetenv("ALAYA_SERVER_URL") + os.Unsetenv("ALAYA_WORKSPACE") + + cfg, err := LoadFromPath(cfgFile) + if err != nil { + t.Fatalf("LoadFromPath() error: %v", err) + } + if cfg.ServerURL != "http://yaml-server:9999" { + t.Errorf("expected ServerURL from file, got %q", cfg.ServerURL) + } + if cfg.Workspace != "ws-from-file" { + t.Errorf("expected Workspace from file, got %q", cfg.Workspace) + } +} + +func TestLoad_EnvOverridesFile(t *testing.T) { + dir := t.TempDir() + cfgFile := filepath.Join(dir, "config.yaml") + content := []byte("server_url: http://yaml-server:9999\nworkspace: ws-from-file\n") + if err := os.WriteFile(cfgFile, content, 0o600); err != nil { + t.Fatalf("write config file: %v", err) + } + + t.Setenv("ALAYA_SERVER_URL", "http://env-server:1111") + t.Setenv("ALAYA_WORKSPACE", "ws-from-env") + + cfg, err := LoadFromPath(cfgFile) + if err != nil { + t.Fatalf("LoadFromPath() error: %v", err) + } + if cfg.ServerURL != "http://env-server:1111" { + t.Errorf("env should override file, got %q", cfg.ServerURL) + } + if cfg.Workspace != "ws-from-env" { + t.Errorf("env should override workspace, got %q", cfg.Workspace) + } +} + +func TestLoad_MissingFileUsesDefaults(t *testing.T) { + os.Unsetenv("ALAYA_SERVER_URL") + os.Unsetenv("ALAYA_WORKSPACE") + + cfg, err := LoadFromPath("/nonexistent/path/config.yaml") + if err != nil { + t.Fatalf("LoadFromPath() should not error on missing file, got: %v", err) + } + if cfg.ServerURL != "http://localhost:8000" { + t.Errorf("expected default ServerURL, got %q", cfg.ServerURL) + } +} + +func TestLoad_InvalidYAMLReturnsError(t *testing.T) { + dir := t.TempDir() + cfgFile := filepath.Join(dir, "config.yaml") + // Write invalid YAML + if err := os.WriteFile(cfgFile, []byte(":\ninvalid: [unclosed\n"), 0o600); err != nil { + t.Fatalf("write config file: %v", err) + } + + _, err := LoadFromPath(cfgFile) + if err == nil { + t.Fatal("expected error for invalid YAML") + } +} From 1f5eb4686bad199278f4dd0715c59e5c81b30a58 Mon Sep 17 00:00:00 2001 From: egerev Date: Thu, 9 Apr 2026 02:48:48 +0800 Subject: [PATCH 2/2] fix: config error handling, ExitCodeFrom nil, context deadline timeout - LoadFromPath propagates non-ErrNotExist errors (permission denied, I/O) - ExitCodeFrom(nil) returns 0 instead of ExitGeneric - isTimeout checks context.DeadlineExceeded in addition to net.Error.Timeout() - Added tests: permission denied config, nil error, context deadline exceeded Co-Authored-By: Claude Sonnet 4.6 --- packages/cli-go/internal/apierror/apierror.go | 6 +++++- .../cli-go/internal/apierror/apierror_test.go | 12 ++++++++++-- packages/cli-go/internal/config/config.go | 4 ++++ packages/cli-go/internal/config/config_test.go | 17 +++++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/cli-go/internal/apierror/apierror.go b/packages/cli-go/internal/apierror/apierror.go index 22dc071..d95641f 100644 --- a/packages/cli-go/internal/apierror/apierror.go +++ b/packages/cli-go/internal/apierror/apierror.go @@ -1,6 +1,7 @@ package apierror import ( + "context" "errors" "fmt" ) @@ -42,7 +43,7 @@ func New(statusCode int, body string) *APIError { // Returns ExitGeneric for unknown errors and nil. func ExitCodeFrom(err error) int { if err == nil { - return ExitGeneric + return 0 } var apiErr *APIError if errors.As(err, &apiErr) { @@ -55,6 +56,9 @@ func ExitCodeFrom(err error) int { } func isTimeout(err error) bool { + if errors.Is(err, context.DeadlineExceeded) { + return true + } var netErr interface{ Timeout() bool } return errors.As(err, &netErr) && netErr.Timeout() } diff --git a/packages/cli-go/internal/apierror/apierror_test.go b/packages/cli-go/internal/apierror/apierror_test.go index 43fe9e0..72ace1c 100644 --- a/packages/cli-go/internal/apierror/apierror_test.go +++ b/packages/cli-go/internal/apierror/apierror_test.go @@ -1,6 +1,7 @@ package apierror import ( + "context" "errors" "fmt" "net" @@ -73,8 +74,15 @@ func TestExitCodeFrom_GenericError(t *testing.T) { func TestExitCodeFrom_Nil(t *testing.T) { code := ExitCodeFrom(nil) - if code != ExitGeneric { - t.Errorf("expected %d for nil, got %d", ExitGeneric, code) + if code != 0 { + t.Errorf("expected 0 for nil, got %d", code) + } +} + +func TestExitCodeFrom_ContextDeadlineExceeded(t *testing.T) { + code := ExitCodeFrom(context.DeadlineExceeded) + if code != ExitTimeout { + t.Errorf("expected %d for context.DeadlineExceeded, got %d", ExitTimeout, code) } } diff --git a/packages/cli-go/internal/config/config.go b/packages/cli-go/internal/config/config.go index 5a61e15..2be92e7 100644 --- a/packages/cli-go/internal/config/config.go +++ b/packages/cli-go/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "errors" "fmt" "os" "path/filepath" @@ -31,6 +32,9 @@ func LoadFromPath(path string) (*Config, error) { } data, err := os.ReadFile(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("read config: %w", err) + } if err == nil { if err := yaml.Unmarshal(data, cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) diff --git a/packages/cli-go/internal/config/config_test.go b/packages/cli-go/internal/config/config_test.go index 4b57fb6..ccf323d 100644 --- a/packages/cli-go/internal/config/config_test.go +++ b/packages/cli-go/internal/config/config_test.go @@ -100,6 +100,23 @@ func TestLoad_MissingFileUsesDefaults(t *testing.T) { } } +func TestLoad_PermissionDeniedReturnsError(t *testing.T) { + dir := t.TempDir() + cfgFile := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgFile, []byte("server_url: http://test\n"), 0o000); err != nil { + t.Fatalf("write config file: %v", err) + } + // Skip on systems where root can read any file + if os.Getuid() == 0 { + t.Skip("running as root; permission check not applicable") + } + + _, err := LoadFromPath(cfgFile) + if err == nil { + t.Fatal("expected error for permission denied, got nil") + } +} + func TestLoad_InvalidYAMLReturnsError(t *testing.T) { dir := t.TempDir() cfgFile := filepath.Join(dir, "config.yaml")