diff --git a/README.md b/README.md index 66cc048..3db24c1 100644 --- a/README.md +++ b/README.md @@ -473,6 +473,11 @@ Per-category updates are partial — only categories you name are changed; other ### Projects +- `kernel projects list` - List projects (up to 100 by default) + - `--limit ` - Maximum number of projects to return (1-100, default 100) + - `--offset ` - Number of projects to skip; table indexes match this offset + - `--output json`, `-o json` - Output `{ "projects": [...], "next_offset": }`; `next_offset` is omitted on the last page + - When more projects are available, the CLI prints the exact command to fetch the next page - `kernel projects update ` - Update a project's name or status - `--name ` - New project name (1-255 characters) - `--status ` - New project status: `active` or `archived` diff --git a/cmd/projects.go b/cmd/projects.go index 28cedeb..68d05fd 100644 --- a/cmd/projects.go +++ b/cmd/projects.go @@ -2,7 +2,10 @@ package cmd import ( "context" + "encoding/json" "fmt" + "net/http" + "strconv" "strings" "github.com/kernel/cli/pkg/util" @@ -37,7 +40,11 @@ type ProjectsCmd struct { limits ProjectLimitsService } -type ProjectsListInput struct{} +type ProjectsListInput struct { + Limit int + Offset int + Output string +} type ProjectsCreateInput struct { Name string @@ -87,24 +94,112 @@ func resolveProjectArg(ctx context.Context, projects ProjectListService, val str } func (c ProjectsCmd) List(ctx context.Context, in ProjectsListInput) error { - projects, err := c.projects.List(ctx, kernel.ProjectListParams{}) + if err := validateJSONOutput(in.Output); err != nil { + return err + } + if in.Limit < 1 || in.Limit > 100 { + return fmt.Errorf("--limit must be between 1 and 100") + } + if in.Offset < 0 { + return fmt.Errorf("--offset must be non-negative") + } + + var response *http.Response + projects, err := c.projects.List(ctx, kernel.ProjectListParams{ + Limit: param.NewOpt(int64(in.Limit)), + Offset: param.NewOpt(int64(in.Offset)), + }, option.WithResponseInto(&response)) if err != nil { return util.CleanedUpSdkError{Err: err} } - if projects == nil || len(projects.Items) == 0 { + items := make([]kernel.Project, 0) + if projects != nil { + items = projects.Items + } + pagination, err := parseProjectListPagination(response) + if err != nil { + return err + } + + if in.Output == "json" { + data, err := marshalProjectsListJSON(projects, pagination.NextOffset) + if err != nil { + return err + } + fmt.Println(string(data)) + return nil + } + + if len(items) == 0 { pterm.Info.Println("No projects found") return nil } - table := pterm.TableData{{"ID", "Name", "Status", "Created At"}} - for _, p := range projects.Items { - table = append(table, []string{p.ID, p.Name, string(p.Status), util.FormatLocal(p.CreatedAt)}) + table := pterm.TableData{{"ID", "Name", "Status", "Created At", "idx"}} + for i, p := range items { + table = append(table, []string{ + p.ID, + p.Name, + string(p.Status), + util.FormatLocal(p.CreatedAt), + strconv.Itoa(in.Offset + i), + }) } PrintTableNoPad(table, true) + + if pagination.HasMore { + pterm.Warning.Printfln( + "Output truncated after index %d. Continue with: kernel projects list --limit %d --offset %d", + in.Offset+len(items)-1, in.Limit, pagination.NextOffset, + ) + } return nil } +type projectListPagination struct { + HasMore bool + NextOffset int +} + +func parseProjectListPagination(response *http.Response) (projectListPagination, error) { + if response == nil { + return projectListPagination{}, fmt.Errorf("project list response is missing pagination headers") + } + + hasMoreValue := response.Header.Get("X-Has-More") + hasMore, err := strconv.ParseBool(hasMoreValue) + if err != nil { + return projectListPagination{}, fmt.Errorf("invalid X-Has-More header %q", hasMoreValue) + } + + nextOffsetValue := response.Header.Get("X-Next-Offset") + nextOffset, err := strconv.Atoi(nextOffsetValue) + if err != nil || nextOffset < 0 { + return projectListPagination{}, fmt.Errorf("invalid X-Next-Offset header %q", nextOffsetValue) + } + if hasMore && nextOffset == 0 { + return projectListPagination{}, fmt.Errorf("X-Has-More is true but X-Next-Offset is not positive") + } + if !hasMore && nextOffset != 0 { + return projectListPagination{}, fmt.Errorf("X-Has-More is false but X-Next-Offset is %d", nextOffset) + } + + return projectListPagination{HasMore: hasMore, NextOffset: nextOffset}, nil +} + +func marshalProjectsListJSON(projects *pagination.OffsetPagination[kernel.Project], nextOffset int) ([]byte, error) { + rawProjects := json.RawMessage("[]") + if projects != nil && len(projects.Items) > 0 { + rawProjects = json.RawMessage(projects.RawJSON()) + } + payload := struct { + Projects json.RawMessage `json:"projects"` + NextOffset int `json:"next_offset,omitempty"` + }{Projects: rawProjects, NextOffset: nextOffset} + return json.MarshalIndent(payload, "", " ") +} + func (c ProjectsCmd) Create(ctx context.Context, in ProjectsCreateInput) error { project, err := c.projects.New(ctx, kernel.ProjectNewParams{ CreateProjectRequest: kernel.CreateProjectRequestParam{ @@ -323,7 +418,10 @@ func getProjectsHandler(cmd *cobra.Command) ProjectsCmd { func runProjectsList(cmd *cobra.Command, args []string) error { c := getProjectsHandler(cmd) - return c.List(cmd.Context(), ProjectsListInput{}) + limit, _ := cmd.Flags().GetInt("limit") + offset, _ := cmd.Flags().GetInt("offset") + output, _ := cmd.Flags().GetString("output") + return c.List(cmd.Context(), ProjectsListInput{Limit: limit, Offset: offset, Output: output}) } func runProjectsCreate(cmd *cobra.Command, args []string) error { @@ -477,6 +575,10 @@ var projectsSetLimitsCompatCmd = &cobra.Command{ } func init() { + projectsListCmd.Flags().Int("limit", 100, "Maximum number of projects to return (1-100)") + projectsListCmd.Flags().Int("offset", 0, "Number of projects to skip (for pagination)") + addJSONOutputFlag(projectsListCmd) + projectsUpdateCmd.Flags().String("name", "", "New project name (1-255 characters)") projectsUpdateCmd.Flags().String("status", "", "New project status: active or archived") addJSONOutputFlag(projectsUpdateCmd) diff --git a/cmd/projects_test.go b/cmd/projects_test.go index 8cc2de5..bce3d9c 100644 --- a/cmd/projects_test.go +++ b/cmd/projects_test.go @@ -3,13 +3,17 @@ package cmd import ( "context" "errors" + "net/http" + "net/http/httptest" "testing" "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" "github.com/kernel/kernel-go-sdk/packages/pagination" "github.com/kernel/kernel-go-sdk/packages/respjson" + "github.com/pterm/pterm" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type FakeProjectsService struct { @@ -74,6 +78,124 @@ func (f *FakeProjectLimitsService) Update(ctx context.Context, id string, body k return &kernel.ProjectLimits{}, nil } +func TestProjectsList_UsesSDKResponsePaginationMetadata(t *testing.T) { + const responseBody = `[ + {"id":"project-alpha","name":"alpha","status":"active","created_at":"2026-08-08T12:00:00Z","updated_at":"2026-08-08T12:00:00Z"}, + {"id":"project-beta","name":"beta","status":"archived","created_at":"2026-08-08T12:01:00Z","updated_at":"2026-08-08T12:01:00Z"} + ]` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/org/projects", r.URL.Path) + assert.Equal(t, "2", r.URL.Query().Get("limit")) + assert.Equal(t, "20", r.URL.Query().Get("offset")) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Has-More", "true") + w.Header().Set("X-Next-Offset", "22") + _, _ = w.Write([]byte(responseBody)) + })) + defer server.Close() + + client := kernel.NewClient(option.WithBaseURL(server.URL), option.WithAPIKey("test")) + c := ProjectsCmd{projects: &client.Projects, limits: &client.Projects.Limits} + + buf := capturePtermOutput(t) + err := c.List(context.Background(), ProjectsListInput{Limit: 2, Offset: 20}) + require.NoError(t, err) + out := pterm.RemoveColorFromString(buf.String()) + assert.Contains(t, out, "idx") + assert.Regexp(t, `(?m)^project-alpha\s+\| alpha\s+\| active\s+\| [^|]+\| 20\s*$`, out) + assert.Regexp(t, `(?m)^project-beta\s+\| beta\s+\| archived\s+\| [^|]+\| 21\s*$`, out) + assert.Contains(t, out, "kernel projects list --limit 2 --offset 22") + + jsonOutput := captureStdout(t, func() { + err = c.List(context.Background(), ProjectsListInput{Limit: 2, Offset: 20, Output: "json"}) + }) + require.NoError(t, err) + assert.JSONEq(t, `{"projects":`+responseBody+`,"next_offset":22}`, jsonOutput) +} + +func TestProjectsList_RejectsInvalidPagination(t *testing.T) { + fakeProjects := &FakeProjectsService{ + ListFunc: func(ctx context.Context, query kernel.ProjectListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Project], error) { + t.Fatal("List should not be called") + return nil, nil + }, + } + c := ProjectsCmd{projects: fakeProjects, limits: &FakeProjectLimitsService{}} + + for _, in := range []ProjectsListInput{ + {Limit: 0}, + {Limit: 101}, + {Limit: 100, Offset: -1}, + {Limit: 100, Output: "yaml"}, + } { + assert.Error(t, c.List(context.Background(), in)) + } +} + +func TestParseProjectListPagination(t *testing.T) { + tests := []struct { + name string + response *http.Response + want projectListPagination + wantErr string + }{ + { + name: "more results", + response: &http.Response{Header: http.Header{"X-Has-More": []string{"true"}, "X-Next-Offset": []string{"120"}}}, + want: projectListPagination{HasMore: true, NextOffset: 120}, + }, + { + name: "terminal page", + response: &http.Response{Header: http.Header{"X-Has-More": []string{"false"}, "X-Next-Offset": []string{"0"}}}, + want: projectListPagination{}, + }, + {name: "missing response", wantErr: "missing pagination headers"}, + { + name: "missing has more", + response: &http.Response{Header: http.Header{"X-Next-Offset": []string{"120"}}}, + wantErr: "invalid X-Has-More", + }, + { + name: "has more with missing cursor", + response: &http.Response{Header: http.Header{"X-Has-More": []string{"true"}}}, + wantErr: "invalid X-Next-Offset", + }, + { + name: "has more with malformed cursor", + response: &http.Response{Header: http.Header{"X-Has-More": []string{"true"}, "X-Next-Offset": []string{"next"}}}, + wantErr: "invalid X-Next-Offset", + }, + { + name: "has more with terminal cursor", + response: &http.Response{Header: http.Header{"X-Has-More": []string{"true"}, "X-Next-Offset": []string{"0"}}}, + wantErr: "X-Next-Offset is not positive", + }, + { + name: "terminal page with cursor", + response: &http.Response{Header: http.Header{"X-Has-More": []string{"false"}, "X-Next-Offset": []string{"120"}}}, + wantErr: "X-Has-More is false", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseProjectListPagination(tt.response) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestMarshalProjectsListJSON_EmptyPage(t *testing.T) { + data, err := marshalProjectsListJSON(nil, 0) + require.NoError(t, err) + assert.JSONEq(t, `{"projects":[]}`, string(data)) +} + func TestProjectsLimitsGet_DefaultOutput(t *testing.T) { buf := capturePtermOutput(t) limits := &kernel.ProjectLimits{