diff --git a/pkg/cmd/ls/ls.go b/pkg/cmd/ls/ls.go index bed0cf1a..996897cf 100644 --- a/pkg/cmd/ls/ls.go +++ b/pkg/cmd/ls/ls.go @@ -24,7 +24,6 @@ import ( "github.com/brevdev/brev-cli/pkg/cmdcontext" "github.com/brevdev/brev-cli/pkg/config" "github.com/brevdev/brev-cli/pkg/entity" - "github.com/brevdev/brev-cli/pkg/entity/virtualproject" breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/featureflag" "github.com/brevdev/brev-cli/pkg/store" @@ -383,27 +382,14 @@ func (ls Ls) RunUser(_ bool) error { return nil } -func (ls Ls) ShowAllWorkspaces(org *entity.Organization, otherOrgs []entity.Organization, user *entity.User, allWorkspaces []entity.Workspace, gpuLookup map[string]string) { - userWorkspaces := store.FilterForUserWorkspaces(allWorkspaces, user.ID) - ls.displayWorkspacesAndHelp(org, otherOrgs, userWorkspaces, allWorkspaces, gpuLookup) - - projects := virtualproject.NewVirtualProjects(allWorkspaces) - - var unjoinedProjects []virtualproject.VirtualProject - for _, p := range projects { - wks := p.GetUserWorkspaces(user.ID) - if len(wks) == 0 { - unjoinedProjects = append(unjoinedProjects, p) - } - } - - displayProjects(ls.terminal, org.Name, unjoinedProjects) +func (ls Ls) ShowAllWorkspaces(org *entity.Organization, otherOrgs []entity.Organization, workspacesToShow []entity.Workspace, gpuLookup map[string]string) { + ls.displayWorkspacesAndHelp(org, otherOrgs, workspacesToShow, workspacesToShow, true, gpuLookup) } func (ls Ls) ShowUserWorkspaces(org *entity.Organization, otherOrgs []entity.Organization, user *entity.User, allWorkspaces []entity.Workspace, gpuLookup map[string]string) { userWorkspaces := store.FilterForUserWorkspaces(allWorkspaces, user.ID) - ls.displayWorkspacesAndHelp(org, otherOrgs, userWorkspaces, allWorkspaces, gpuLookup) + ls.displayWorkspacesAndHelp(org, otherOrgs, userWorkspaces, allWorkspaces, false, gpuLookup) } func (ls Ls) ShowOrgWorkspaces(org *entity.Organization, workspaces []entity.Workspace, gpuLookup map[string]string) { @@ -417,10 +403,10 @@ func (ls Ls) ShowOrgWorkspaces(org *entity.Organization, workspaces []entity.Wor fmt.Print("\n") } -func (ls Ls) displayWorkspacesAndHelp(org *entity.Organization, otherOrgs []entity.Organization, userWorkspaces []entity.Workspace, allWorkspaces []entity.Workspace, gpuLookup map[string]string) { - if len(userWorkspaces) == 0 { +func (ls Ls) displayWorkspacesAndHelp(org *entity.Organization, otherOrgs []entity.Organization, workspacesToDisplay []entity.Workspace, allWorkspaces []entity.Workspace, showAll bool, gpuLookup map[string]string) { + if len(workspacesToDisplay) == 0 { ls.terminal.Vprint(ls.terminal.Yellow("No instances in org %s\n", org.Name)) - if len(allWorkspaces) > 0 { + if !showAll && len(allWorkspaces) > 0 { ls.terminal.Vprintf("%s", ls.terminal.Green("See teammates' instances:\n")) ls.terminal.Vprintf("%s", ls.terminal.Yellow("\tbrev ls --all\n")) } else { @@ -432,8 +418,12 @@ func (ls Ls) displayWorkspacesAndHelp(org *entity.Organization, otherOrgs []enti ls.terminal.Vprintf("%s", ls.terminal.Yellow(fmt.Sprintf("\tbrev set %s\n", getOtherOrg(otherOrgs, *org).Name))) } } else { - ls.terminal.Vprintf("You have %d instances in Org %s\n", len(userWorkspaces), ls.terminal.Yellow(org.Name)) - displayWorkspacesTable(ls.terminal, userWorkspaces, gpuLookup) + if showAll { + ls.terminal.Vprintf("%d instances in Org %s\n", len(workspacesToDisplay), ls.terminal.Yellow(org.Name)) + } else { + ls.terminal.Vprintf("You have %d instances in Org %s\n", len(workspacesToDisplay), ls.terminal.Yellow(org.Name)) + } + displayWorkspacesTable(ls.terminal, workspacesToDisplay, gpuLookup) fmt.Print("\n") } @@ -530,7 +520,7 @@ func (ls Ls) RunWorkspaces(cliAuth auth.CLIAuth, org *entity.Organization, showA return breverrors.WrapAndTrace(err) } if showAll { - ls.ShowAllWorkspaces(org, orgs, user, allWorkspaces, gpuLookup) + ls.ShowAllWorkspaces(org, orgs, workspacesToShow, gpuLookup) if len(nodes) > 0 { ls.terminal.Vprintf("\nYou have %d external node(s) in Org %s\n", len(nodes), ls.terminal.Yellow(org.Name)) displayNodesTable(ls.terminal, nodes, ls.piped) @@ -662,23 +652,6 @@ func (ls Ls) RunHosts(org *entity.Organization) error { return nil } -func displayProjects(t *terminal.Terminal, orgName string, projects []virtualproject.VirtualProject) { - if len(projects) > 0 { - fmt.Print("\n") - t.Vprintf("%d other projects in Org %s\n", len(projects), t.Yellow(orgName)) - displayProjectsTable(projects) - - fmt.Print("\n") - t.Vprintf("%s", t.Green("Join a project:\n")+ - t.Yellow(fmt.Sprintf("\tbrev start %s\n", projects[0].Name))) - } else { - t.Vprintf("no other projects in Org %s\n", t.Yellow(orgName)) - fmt.Print("\n") - t.Vprintf("%s", t.Green("Invite a teamate:\n")+ - t.Yellow("\tbrev invite")) - } -} - func getBrevTableOptions() table.Options { options := table.OptionsDefault options.DrawBorder = false @@ -775,19 +748,6 @@ func displayOrgTable(t *terminal.Terminal, orgs []entity.Organization, currentOr ta.Render() } -func displayProjectsTable(projects []virtualproject.VirtualProject) { - ta := table.NewWriter() - ta.SetOutputMirror(os.Stdout) - ta.Style().Options = getBrevTableOptions() - header := table.Row{"NAME", "MEMBERS"} - ta.AppendHeader(header) - for _, p := range projects { - workspaceRow := []table.Row{{p.Name, p.GetUniqueUserCount()}} - ta.AppendRows(workspaceRow) - } - ta.Render() -} - func getStatusColoredText(t *terminal.Terminal, status string) string { switch status { case entity.Running, entity.Ready, string(entity.Completed): diff --git a/pkg/cmd/ls/ls_test.go b/pkg/cmd/ls/ls_test.go index 0e176f8c..0574e4e0 100644 --- a/pkg/cmd/ls/ls_test.go +++ b/pkg/cmd/ls/ls_test.go @@ -1,12 +1,16 @@ package ls import ( + "context" "encoding/json" + "net/http/httptest" "os" "strings" "testing" + nodev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" + "connectrpc.com/connect" authpkg "github.com/brevdev/brev-cli/pkg/auth" "github.com/brevdev/brev-cli/pkg/cmd/gpusearch" @@ -401,6 +405,41 @@ func TestRunLs_OrgsJSON(t *testing.T) { } } +// TestRunLs_ShowAllTable verifies that --all lists every org instance in table output. +func TestRunLs_ShowAllTable(t *testing.T) { + s := newTestStore() + s.workspaces = []entity.Workspace{ + { + ID: "ws-mine", + Name: "my-ws", + Status: entity.Running, + VerbBuildStatus: entity.Completed, + CreatedByUserID: "u1", + }, + { + ID: "ws-other", + Name: "other-ws", + Status: entity.Running, + VerbBuildStatus: entity.Completed, + CreatedByUserID: "u2", + }, + } + term := terminal.New() + + out := captureStdout(t, func() { + err := RunLs(term, resolveTestCLIAuth(t, s), s, nil, "", true, false) + if err != nil { + t.Fatalf("RunLs --all returned error: %v", err) + } + }) + if !strings.Contains(out, "my-ws") || !strings.Contains(out, "other-ws") { + t.Fatalf("expected both workspaces in table output, got:\n%s", out) + } + if strings.Contains(out, "brev ls --all") { + t.Fatal("should not suggest brev ls --all when already using --all") + } +} + // TestRunLs_ShowAll verifies that --all includes workspaces from other users. func TestRunLs_ShowAllJSON(t *testing.T) { s := newTestStore() @@ -673,3 +712,83 @@ func TestOutputWorkspacesJSON_NoNodes(t *testing.T) { t.Error("nil nodes should not produce a nodes key in the output") } } + +type lsFakeNodeService struct { + nodev1connect.UnimplementedExternalNodeServiceHandler +} + +func (f *lsFakeNodeService) ListNodes(_ context.Context, req *connect.Request[nodev1.ListNodesRequest]) (*connect.Response[nodev1.ListNodesResponse], error) { + if req.Msg.GetOrganizationId() != "org1" { + return connect.NewResponse(&nodev1.ListNodesResponse{}), nil + } + return connect.NewResponse(&nodev1.ListNodesResponse{ + Items: []*nodev1.ExternalNode{ + {Name: "gpu-node-1", ExternalNodeId: "en-123", OrganizationId: "org1"}, + {Name: "gpu-node-2", ExternalNodeId: "en-456", OrganizationId: "org1"}, + }, + }), nil +} + +func withFakeNodeAPI(t *testing.T, fn func()) { + t.Helper() + _, handler := nodev1connect.NewExternalNodeServiceHandler(&lsFakeNodeService{}) + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + t.Setenv("BREV_PUBLIC_API_URL", server.URL) + fn() +} + +// TestRunLs_ShowAllJSON_WithNodes verifies --all includes nodes from listNodes. +func TestRunLs_ShowAllJSON_WithNodes(t *testing.T) { + term := terminal.New() + + t.Run("matching org returns nodes", func(t *testing.T) { + s := newTestStore() + s.workspaces = []entity.Workspace{ + {ID: "ws1", Name: "my-ws", Status: entity.Running, VerbBuildStatus: entity.Completed, CreatedByUserID: "u1"}, + } + withFakeNodeAPI(t, func() { + out := captureStdout(t, func() { + if err := runLs(t, term, s, nil, true); err != nil { + t.Fatalf("RunLs --all: %v", err) + } + }) + var res struct { + Workspaces []WorkspaceInfo `json:"workspaces"` + Nodes []NodeInfo `json:"nodes"` + } + if err := json.Unmarshal([]byte(out), &res); err != nil { + t.Fatalf("parse JSON: %v\n%s", err, out) + } + if len(res.Nodes) != 2 || res.Nodes[0].Name != "gpu-node-1" { + t.Fatalf("expected 2 nodes from listNodes, got %+v", res.Nodes) + } + }) + }) + + t.Run("other org returns no nodes", func(t *testing.T) { + s := newTestStore() + s.org = &entity.Organization{ID: "other-org", Name: "other-org"} + s.orgs = []entity.Organization{*s.org} + s.workspaces = []entity.Workspace{ + {ID: "ws1", Name: "my-ws", Status: entity.Running, VerbBuildStatus: entity.Completed, CreatedByUserID: "u1"}, + } + withFakeNodeAPI(t, func() { + out := captureStdout(t, func() { + if err := runLs(t, term, s, nil, true); err != nil { + t.Fatalf("RunLs --all: %v", err) + } + }) + var res struct { + Workspaces []WorkspaceInfo `json:"workspaces"` + Nodes []NodeInfo `json:"nodes"` + } + if err := json.Unmarshal([]byte(out), &res); err != nil { + t.Fatalf("parse JSON: %v\n%s", err, out) + } + if len(res.Nodes) != 0 { + t.Fatalf("expected 0 nodes for non-org1 org, got %+v", res.Nodes) + } + }) + }) +}