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..d95641f --- /dev/null +++ b/packages/cli-go/internal/apierror/apierror.go @@ -0,0 +1,64 @@ +package apierror + +import ( + "context" + "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 0 + } + var apiErr *APIError + if errors.As(err, &apiErr) { + return apiErr.ExitCode + } + if isTimeout(err) { + return ExitTimeout + } + return ExitGeneric +} + +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 new file mode 100644 index 0000000..72ace1c --- /dev/null +++ b/packages/cli-go/internal/apierror/apierror_test.go @@ -0,0 +1,94 @@ +package apierror + +import ( + "context" + "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 != 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) + } +} + +// 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..2be92e7 100644 --- a/packages/cli-go/internal/config/config.go +++ b/packages/cli-go/internal/config/config.go @@ -1,8 +1,12 @@ package config import ( + "errors" + "fmt" "os" "path/filepath" + + "gopkg.in/yaml.v3" ) type Config struct { @@ -15,13 +19,36 @@ 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 && !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) + } + } + // 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..ccf323d 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,95 @@ 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_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") + // 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") + } +}