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
3 changes: 2 additions & 1 deletion packages/cli-go/cmd/alaya/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
1 change: 1 addition & 0 deletions packages/cli-go/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
64 changes: 64 additions & 0 deletions packages/cli-go/internal/apierror/apierror.go
Original file line number Diff line number Diff line change
@@ -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()
}
94 changes: 94 additions & 0 deletions packages/cli-go/internal/apierror/apierror_test.go
Original file line number Diff line number Diff line change
@@ -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 }
8 changes: 5 additions & 3 deletions packages/cli-go/internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"io"
"net/http"
"time"

"github.com/GoSync-Inc/alaya/packages/cli-go/internal/apierror"
)

type Client struct {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
60 changes: 60 additions & 0 deletions packages/cli-go/internal/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
Expand Down
41 changes: 40 additions & 1 deletion packages/cli-go/internal/cmd/setup.go
Original file line number Diff line number Diff line change
@@ -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"
)
Expand All @@ -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":
Expand All @@ -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
}
Loading
Loading