diff --git a/pkg/cmd/delete/delete.go b/pkg/cmd/delete/delete.go index 2465ca0a..198d7c32 100644 --- a/pkg/cmd/delete/delete.go +++ b/pkg/cmd/delete/delete.go @@ -18,7 +18,7 @@ import ( var ( //go:embed doc.md deleteLong string - deleteExample = "brev delete " + deleteExample = "brev delete ...\necho instance-name | brev delete" ) type DeleteStore interface { @@ -37,11 +37,25 @@ func NewCmdDelete(t *terminal.Terminal, loginDeleteStore DeleteStore, noLoginDel Example: deleteExample, ValidArgsFunction: completions.GetAllWorkspaceNameCompletionHandler(noLoginDeleteStore, t), RunE: func(cmd *cobra.Command, args []string) error { + piped := util.IsStdoutPiped() + names, err := util.GetInstanceNames(args) + if err != nil { + return breverrors.WrapAndTrace(err) + } var allError error - for _, workspace := range args { - err := deleteWorkspace(workspace, t, loginDeleteStore) + var deletedNames []string + for _, workspace := range names { + err := deleteWorkspace(workspace, t, loginDeleteStore, piped) if err != nil { allError = multierror.Append(allError, err) + } else { + deletedNames = append(deletedNames, workspace) + } + } + // Output names for piping to next command + if piped { + for _, name := range deletedNames { + fmt.Println(name) } } if allError != nil { @@ -54,10 +68,10 @@ func NewCmdDelete(t *terminal.Terminal, loginDeleteStore DeleteStore, noLoginDel return cmd } -func deleteWorkspace(workspaceName string, t *terminal.Terminal, deleteStore DeleteStore) error { +func deleteWorkspace(workspaceName string, t *terminal.Terminal, deleteStore DeleteStore, piped bool) error { workspace, err := util.GetUserWorkspaceByNameOrIDErr(deleteStore, workspaceName) if err != nil { - err1 := handleAdminUser(err, deleteStore) + err1 := handleAdminUser(err, deleteStore, piped) if err1 != nil { return breverrors.WrapAndTrace(err1) } @@ -75,12 +89,14 @@ func deleteWorkspace(workspaceName string, t *terminal.Terminal, deleteStore Del return breverrors.WrapAndTrace(err) } - t.Vprintf("Deleting instance %s. This can take a few minutes. Run 'brev ls' to check status\n", deletedWorkspace.Name) + if !piped { + t.Vprintf("Deleting instance %s. This can take a few minutes. Run 'brev ls' to check status\n", deletedWorkspace.Name) + } return nil } -func handleAdminUser(err error, deleteStore DeleteStore) error { +func handleAdminUser(err error, deleteStore DeleteStore, piped bool) error { if strings.Contains(err.Error(), "not found") { user, err1 := deleteStore.GetCurrentUser() if err1 != nil { @@ -89,7 +105,9 @@ func handleAdminUser(err error, deleteStore DeleteStore) error { if user.GlobalUserType != "Admin" { return breverrors.WrapAndTrace(err) } - fmt.Println("attempting to delete an instance you don't own as admin") + if !piped { + fmt.Println("attempting to delete an instance you don't own as admin") + } return nil } diff --git a/pkg/cmd/ls/ls.go b/pkg/cmd/ls/ls.go index 67ff24df..01280159 100644 --- a/pkg/cmd/ls/ls.go +++ b/pkg/cmd/ls/ls.go @@ -2,6 +2,7 @@ package ls import ( + "encoding/json" "fmt" "os" @@ -9,7 +10,7 @@ import ( "github.com/brevdev/brev-cli/pkg/cmd/cmderrors" "github.com/brevdev/brev-cli/pkg/cmd/completions" "github.com/brevdev/brev-cli/pkg/cmd/hello" - utilities "github.com/brevdev/brev-cli/pkg/cmd/util" + cmdutil "github.com/brevdev/brev-cli/pkg/cmd/util" "github.com/brevdev/brev-cli/pkg/cmdcontext" "github.com/brevdev/brev-cli/pkg/config" "github.com/brevdev/brev-cli/pkg/entity" @@ -37,17 +38,23 @@ type LsStore interface { func NewCmdLs(t *terminal.Terminal, loginLsStore LsStore, noLoginLsStore LsStore) *cobra.Command { var showAll bool var org string + var jsonOutput bool cmd := &cobra.Command{ Annotations: map[string]string{"workspace": ""}, Use: "ls", Aliases: []string{"list"}, Short: "List instances within active org", - Long: "List instances within your active org. List all instances if no active org is set.", + Long: `List instances within your active org. List all instances if no active org is set. + +When stdout is piped, outputs instance names only (one per line) for easy chaining +with other commands like stop, start, or delete.`, Example: ` brev ls + brev ls --json + brev ls | grep running | brev stop brev ls orgs - brev ls --org + brev ls orgs --json `, PersistentPostRunE: func(cmd *cobra.Command, args []string) error { if hello.ShouldWeRunOnboardingLSStep(noLoginLsStore) && hello.ShouldWeRunOnboarding(noLoginLsStore) { @@ -94,23 +101,17 @@ func NewCmdLs(t *terminal.Terminal, loginLsStore LsStore, noLoginLsStore LsStore Args: cmderrors.TransformToValidationError(cobra.MinimumNArgs(0)), ValidArgs: []string{"orgs", "workspaces"}, RunE: func(cmd *cobra.Command, args []string) error { - err := RunLs(t, loginLsStore, args, org, showAll) + // Auto-switch to names-only output when piped (for chaining with stop/start/delete) + piped := cmdutil.IsStdoutPiped() + + err := RunLs(t, loginLsStore, args, org, showAll, jsonOutput, piped) if err != nil { return breverrors.WrapAndTrace(err) } - // Call analytics for ls - userID := "" - user, err := loginLsStore.GetCurrentUser() - if err != nil { - userID = "" - } else { - userID = user.ID + // Call analytics for ls (skip when piped to avoid polluting output) + if !piped && !jsonOutput { + trackLsAnalytics(loginLsStore) } - data := analytics.EventData{ - EventName: "Brev ls", - UserID: userID, - } - _ = analytics.TrackEvent(data) return nil }, } @@ -123,10 +124,25 @@ func NewCmdLs(t *terminal.Terminal, loginLsStore LsStore, noLoginLsStore LsStore } cmd.Flags().BoolVar(&showAll, "all", false, "show all workspaces in org") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON") return cmd } +// trackLsAnalytics sends analytics event for ls command +func trackLsAnalytics(store LsStore) { + userID := "" + user, err := store.GetCurrentUser() + if err == nil { + userID = user.ID + } + data := analytics.EventData{ + EventName: "Brev ls", + UserID: userID, + } + _ = analytics.TrackEvent(data) +} + func getOrgForRunLs(lsStore LsStore, orgflag string) (*entity.Organization, error) { var org *entity.Organization if orgflag != "" { @@ -156,8 +172,8 @@ func getOrgForRunLs(lsStore LsStore, orgflag string) (*entity.Organization, erro return org, nil } -func RunLs(t *terminal.Terminal, lsStore LsStore, args []string, orgflag string, showAll bool) error { - ls := NewLs(lsStore, t) +func RunLs(t *terminal.Terminal, lsStore LsStore, args []string, orgflag string, showAll bool, jsonOutput bool, piped bool) error { + ls := NewLs(lsStore, t, jsonOutput, piped) user, err := lsStore.GetCurrentUser() if err != nil { return breverrors.WrapAndTrace(err) @@ -219,23 +235,41 @@ func handleLsArg(ls *Ls, arg string, user *entity.User, org *entity.Organization } type Ls struct { - lsStore LsStore - terminal *terminal.Terminal + lsStore LsStore + terminal *terminal.Terminal + jsonOutput bool + piped bool } -func NewLs(lsStore LsStore, terminal *terminal.Terminal) *Ls { +func NewLs(lsStore LsStore, terminal *terminal.Terminal, jsonOutput bool, piped bool) *Ls { return &Ls{ - lsStore: lsStore, - terminal: terminal, + lsStore: lsStore, + terminal: terminal, + jsonOutput: jsonOutput, + piped: piped, } } +// OrgInfo represents organization data for JSON output +type OrgInfo struct { + Name string `json:"name"` + ID string `json:"id"` + IsActive bool `json:"is_active"` +} + func (ls Ls) RunOrgs() error { orgs, err := ls.lsStore.GetOrganizations(nil) if err != nil { return breverrors.WrapAndTrace(err) } if len(orgs) == 0 { + if ls.jsonOutput { + fmt.Println("[]") + return nil + } + if ls.piped { + return nil + } ls.terminal.Vprint(ls.terminal.Yellow(fmt.Sprintf("You don't have any orgs. Create one! %s", config.GlobalConfig.GetConsoleURL()))) return nil } @@ -244,6 +278,19 @@ func (ls Ls) RunOrgs() error { if err != nil { return breverrors.WrapAndTrace(err) } + + // Handle JSON output + if ls.jsonOutput { + return ls.outputOrgsJSON(orgs, defaultOrg) + } + + // Handle piped output - clean table without colors + if ls.piped { + displayOrgTablePlain(orgs, defaultOrg) + return nil + } + + // Standard table output ls.terminal.Vprint(ls.terminal.Yellow("Your organizations:")) displayOrgTable(ls.terminal, orgs, defaultOrg) if len(orgs) > 1 { @@ -257,6 +304,23 @@ func (ls Ls) RunOrgs() error { return nil } +func (ls Ls) outputOrgsJSON(orgs []entity.Organization, defaultOrg *entity.Organization) error { + var infos []OrgInfo + for _, o := range orgs { + infos = append(infos, OrgInfo{ + Name: o.Name, + ID: o.ID, + IsActive: defaultOrg != nil && o.ID == defaultOrg.ID, + }) + } + output, err := json.MarshalIndent(infos, "", " ") + if err != nil { + return breverrors.WrapAndTrace(err) + } + fmt.Println(string(output)) + return nil +} + func getOtherOrg(orgs []entity.Organization, org entity.Organization) *entity.Organization { for _, o := range orgs { if org.ID != o.ID { @@ -350,6 +414,27 @@ func (ls Ls) RunWorkspaces(org *entity.Organization, user *entity.User, showAll return breverrors.WrapAndTrace(err) } + // Determine which workspaces to show + var workspacesToShow []entity.Workspace + if showAll { + workspacesToShow = allWorkspaces + } else { + workspacesToShow = store.FilterForUserWorkspaces(allWorkspaces, user.ID) + } + + // Handle JSON output + if ls.jsonOutput { + return ls.outputWorkspacesJSON(workspacesToShow) + } + + // Handle piped output - clean table without colors or extra text + // Enables: brev ls | grep RUNNING | awk '{print $1}' | brev stop + if ls.piped { + displayWorkspacesTablePlain(workspacesToShow) + return nil + } + + // Standard table output with colors and help text orgs, err := ls.lsStore.GetOrganizations(nil) if err != nil { return breverrors.WrapAndTrace(err) @@ -362,6 +447,52 @@ func (ls Ls) RunWorkspaces(org *entity.Organization, user *entity.User, showAll return nil } +// WorkspaceInfo represents workspace data for JSON output +type WorkspaceInfo struct { + Name string `json:"name"` + ID string `json:"id"` + Status string `json:"status"` + BuildStatus string `json:"build_status"` + ShellStatus string `json:"shell_status"` + HealthStatus string `json:"health_status"` + InstanceType string `json:"instance_type"` + InstanceKind string `json:"instance_kind"` +} + +// getInstanceTypeAndKind returns the instance type and kind (gpu/cpu) +func getInstanceTypeAndKind(w entity.Workspace) (string, string) { + if w.InstanceType != "" { + return w.InstanceType, "gpu" + } + if w.WorkspaceClassID != "" { + return w.WorkspaceClassID, "cpu" + } + return "", "" +} + +func (ls Ls) outputWorkspacesJSON(workspaces []entity.Workspace) error { + var infos []WorkspaceInfo + for _, w := range workspaces { + instanceType, instanceKind := getInstanceTypeAndKind(w) + infos = append(infos, WorkspaceInfo{ + Name: w.Name, + ID: w.ID, + Status: getWorkspaceDisplayStatus(w), + BuildStatus: string(w.VerbBuildStatus), + ShellStatus: getShellDisplayStatus(w), + HealthStatus: w.HealthStatus, + InstanceType: instanceType, + InstanceKind: instanceKind, + }) + } + output, err := json.MarshalIndent(infos, "", " ") + if err != nil { + return breverrors.WrapAndTrace(err) + } + fmt.Println(string(output)) + return nil +} + func (ls Ls) RunHosts(org *entity.Organization) error { user, err := ls.lsStore.GetCurrentUser() if err != nil { @@ -413,13 +544,30 @@ func displayWorkspacesTable(t *terminal.Terminal, workspaces []entity.Workspace) ta.AppendHeader(header) for _, w := range workspaces { status := getWorkspaceDisplayStatus(w) - instanceString := utilities.GetInstanceString(w) + instanceString := cmdutil.GetInstanceString(w) workspaceRow := []table.Row{{w.Name, getStatusColoredText(t, status), getStatusColoredText(t, string(w.VerbBuildStatus)), getStatusColoredText(t, getShellDisplayStatus(w)), w.ID, instanceString}} ta.AppendRows(workspaceRow) } ta.Render() } +// displayWorkspacesTablePlain outputs a clean table without colors for piping +// Enables: brev ls | grep RUNNING | awk '{print $1}' | brev stop +func displayWorkspacesTablePlain(workspaces []entity.Workspace) { + ta := table.NewWriter() + ta.SetOutputMirror(os.Stdout) + ta.Style().Options = getBrevTableOptions() + header := table.Row{"NAME", "STATUS", "BUILD", "SHELL", "ID", "MACHINE"} + ta.AppendHeader(header) + for _, w := range workspaces { + status := getWorkspaceDisplayStatus(w) + instanceString := cmdutil.GetInstanceString(w) + workspaceRow := []table.Row{{w.Name, status, string(w.VerbBuildStatus), getShellDisplayStatus(w), w.ID, instanceString}} + ta.AppendRows(workspaceRow) + } + ta.Render() +} + func getShellDisplayStatus(w entity.Workspace) string { status := entity.NotReady if w.Status == entity.Running && w.VerbBuildStatus == entity.Completed { @@ -452,6 +600,24 @@ func displayOrgTable(t *terminal.Terminal, orgs []entity.Organization, currentOr ta.Render() } +// displayOrgTablePlain outputs a clean table without colors for piping +// Enables: brev ls orgs | grep myorg | awk '{print $1}' +func displayOrgTablePlain(orgs []entity.Organization, currentOrg *entity.Organization) { + ta := table.NewWriter() + ta.SetOutputMirror(os.Stdout) + ta.Style().Options = getBrevTableOptions() + header := table.Row{"NAME", "ID"} + ta.AppendHeader(header) + for _, o := range orgs { + activeMarker := "" + if currentOrg != nil && o.ID == currentOrg.ID { + activeMarker = "* " + } + ta.AppendRows([]table.Row{{activeMarker + o.Name, o.ID}}) + } + ta.Render() +} + func displayProjectsTable(projects []virtualproject.VirtualProject) { ta := table.NewWriter() ta.SetOutputMirror(os.Stdout) diff --git a/pkg/cmd/start/start.go b/pkg/cmd/start/start.go index 9f082e31..ecb53d66 100644 --- a/pkg/cmd/start/start.go +++ b/pkg/cmd/start/start.go @@ -9,18 +9,17 @@ import ( "time" "github.com/brevdev/brev-cli/pkg/cmd/completions" - "github.com/brevdev/brev-cli/pkg/cmd/util" + cmdutil "github.com/brevdev/brev-cli/pkg/cmd/util" "github.com/brevdev/brev-cli/pkg/config" "github.com/brevdev/brev-cli/pkg/entity" + breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/featureflag" "github.com/brevdev/brev-cli/pkg/instancetypes" "github.com/brevdev/brev-cli/pkg/mergeshells" "github.com/brevdev/brev-cli/pkg/store" "github.com/brevdev/brev-cli/pkg/terminal" - allutil "github.com/brevdev/brev-cli/pkg/util" + "github.com/brevdev/brev-cli/pkg/util" "github.com/spf13/cobra" - - breverrors "github.com/brevdev/brev-cli/pkg/errors" ) var ( @@ -29,11 +28,12 @@ var ( brev start brev start brev start --org myFancyOrg + echo instance-name | brev start ` ) type StartStore interface { - util.GetWorkspaceByNameOrIDErrStore + cmdutil.GetWorkspaceByNameOrIDErrStore GetWorkspaces(organizationID string, options *store.GetWorkspacesOptions) ([]entity.Workspace, error) GetActiveOrganizationOrDefault() (*entity.Organization, error) GetCurrentUser() (*entity.User, error) @@ -65,10 +65,8 @@ func NewCmdStart(t *terminal.Terminal, startStore StartStore, noLoginStartStore Example: startExample, ValidArgsFunction: completions.GetAllWorkspaceNameCompletionHandler(noLoginStartStore, t), RunE: func(cmd *cobra.Command, args []string) error { - repoOrPathOrNameOrID := "" - if len(args) > 0 { - repoOrPathOrNameOrID = args[0] - } + piped := cmdutil.IsStdoutPiped() + names, stdinPiped := cmdutil.GetInstanceNamesWithPipeInfo(args) if gpu != "" { isValid := instancetypes.ValidateInstanceType(gpu) @@ -78,26 +76,13 @@ func NewCmdStart(t *terminal.Terminal, startStore StartStore, noLoginStartStore } } - err := runStartWorkspace(t, StartOptions{ - RepoOrPathOrNameOrID: repoOrPathOrNameOrID, - Name: name, - OrgName: org, - SetupScript: setupScript, - SetupRepo: setupRepo, - SetupPath: setupPath, - WorkspaceClass: cpu, - Detached: detached, - InstanceType: gpu, - }, startStore) - if err != nil { - if strings.Contains(err.Error(), "duplicate instance with name") { - t.Vprint(t.Yellow("try running:")) - t.Vprint(t.Yellow("\tbrev start --name [different name] [repo] # or")) - t.Vprint(t.Yellow("\tbrev delete [name]")) - } - return breverrors.WrapAndTrace(err) + // If stdin is piped, handle multiple instances (only start existing stopped instances) + if stdinPiped && len(names) > 0 { + return runBatchStart(t, names, org, setupScript, setupRepo, setupPath, cpu, gpu, piped, startStore) } - return nil + + // Single instance mode (original behavior) + return runSingleStart(t, names, name, org, setupScript, setupRepo, setupPath, cpu, gpu, detached, piped, startStore) }, } cmd.Flags().BoolVarP(&detached, "detached", "d", false, "run the command in the background instead of blocking the shell") @@ -129,6 +114,7 @@ type StartOptions struct { WorkspaceClass string Detached bool InstanceType string + Piped bool // true when stdout is piped to another command } func runStartWorkspace(t *terminal.Terminal, options StartOptions, startStore StartStore) error { @@ -173,7 +159,7 @@ func runStartWorkspace(t *terminal.Terminal, options StartOptions, startStore St } func maybeStartWithLocalPath(options StartOptions, user *entity.User, t *terminal.Terminal, startStore StartStore) (bool, error) { - if allutil.DoesPathExist(options.RepoOrPathOrNameOrID) { + if util.DoesPathExist(options.RepoOrPathOrNameOrID) { err := startWorkspaceFromPath(user, t, options, startStore) if err != nil { return false, breverrors.WrapAndTrace(err) @@ -205,7 +191,7 @@ func maybeStartStoppedOrJoin(t *terminal.Terminal, user *entity.User, options St return false, breverrors.NewValidationError(fmt.Sprintf("workspace with id/name %s is a failed workspace", options.RepoOrPathOrNameOrID)) } } - if allutil.DoesPathExist(options.RepoOrPathOrNameOrID) { + if util.DoesPathExist(options.RepoOrPathOrNameOrID) { t.Print(t.Yellow(fmt.Sprintf("Warning: local path found and instance name/id found %s. Using instance name/id. If you meant to specify a local path change directory and try again.", options.RepoOrPathOrNameOrID))) } errr := startStopppedWorkspace(&userWorkspaces[0], startStore, t, options) @@ -226,7 +212,7 @@ func maybeStartStoppedOrJoin(t *terminal.Terminal, user *entity.User, options St } func maybeStartFromGitURL(t *terminal.Terminal, user *entity.User, options StartOptions, startStore StartStore) (bool, error) { - if allutil.IsGitURL(options.RepoOrPathOrNameOrID) { // todo this is function is not complete, some cloneable urls are not identified + if util.IsGitURL(options.RepoOrPathOrNameOrID) { // todo this is function is not complete, some cloneable urls are not identified err := createNewWorkspaceFromGit(user, t, options.SetupScript, options, startStore) if err != nil { return true, breverrors.WrapAndTrace(err) @@ -248,7 +234,7 @@ func maybeStartEmpty(t *terminal.Terminal, user *entity.User, options StartOptio } func startWorkspaceFromPath(user *entity.User, t *terminal.Terminal, options StartOptions, startStore StartStore) error { - pathExists := allutil.DoesPathExist(options.RepoOrPathOrNameOrID) + pathExists := util.DoesPathExist(options.RepoOrPathOrNameOrID) if !pathExists { return fmt.Errorf("Path: %s does not exist", options.RepoOrPathOrNameOrID) } @@ -278,7 +264,7 @@ func startWorkspaceFromPath(user *entity.User, t *terminal.Terminal, options Sta if options.RepoOrPathOrNameOrID == "." { localSetupPath = filepath.Join(".brev", "setup.sh") } - if !allutil.DoesPathExist(localSetupPath) { + if !util.DoesPathExist(localSetupPath) { fmt.Println(strings.Join([]string{"Generating setup script at", localSetupPath}, "\n")) mergeshells.ImportPath(t, options.RepoOrPathOrNameOrID, startStore) fmt.Println("setup script generated.") @@ -689,3 +675,70 @@ func pollUntil(t *terminal.Terminal, wsid string, state string, startStore Start } return nil } + +// runBatchStart handles starting multiple instances when stdin is piped +func runBatchStart(t *terminal.Terminal, names []string, org, setupScript, setupRepo, setupPath, cpu, gpu string, piped bool, startStore StartStore) error { + var startedNames []string + for _, instanceName := range names { + err := runStartWorkspace(t, StartOptions{ + RepoOrPathOrNameOrID: instanceName, + Name: "", + OrgName: org, + SetupScript: setupScript, + SetupRepo: setupRepo, + SetupPath: setupPath, + WorkspaceClass: cpu, + Detached: true, // Always detached when piping multiple + InstanceType: gpu, + Piped: piped, + }, startStore) + if err != nil { + if !piped { + t.Vprintf("Error starting %s: %s\n", instanceName, err.Error()) + } + } else { + startedNames = append(startedNames, instanceName) + } + } + // Output names for piping to next command + if piped { + for _, n := range startedNames { + fmt.Println(n) + } + } + return nil +} + +// runSingleStart handles starting a single instance (original behavior) +func runSingleStart(t *terminal.Terminal, names []string, name, org, setupScript, setupRepo, setupPath, cpu, gpu string, detached, piped bool, startStore StartStore) error { + repoOrPathOrNameOrID := "" + if len(names) > 0 { + repoOrPathOrNameOrID = names[0] + } + + err := runStartWorkspace(t, StartOptions{ + RepoOrPathOrNameOrID: repoOrPathOrNameOrID, + Name: name, + OrgName: org, + SetupScript: setupScript, + SetupRepo: setupRepo, + SetupPath: setupPath, + WorkspaceClass: cpu, + Detached: detached, + InstanceType: gpu, + Piped: piped, + }, startStore) + if err != nil { + if strings.Contains(err.Error(), "duplicate instance with name") { + t.Vprint(t.Yellow("try running:")) + t.Vprint(t.Yellow("\tbrev start --name [different name] [repo] # or")) + t.Vprint(t.Yellow("\tbrev delete [name]")) + } + return breverrors.WrapAndTrace(err) + } + // Output name for piping to next command + if piped && repoOrPathOrNameOrID != "" { + fmt.Println(repoOrPathOrNameOrID) + } + return nil +} diff --git a/pkg/cmd/stop/stop.go b/pkg/cmd/stop/stop.go index 8f76eb7a..f7572cc4 100644 --- a/pkg/cmd/stop/stop.go +++ b/pkg/cmd/stop/stop.go @@ -17,7 +17,7 @@ import ( var ( stopLong = "Stop a Brev machine that's in a running state" - stopExample = "brev stop ... \nbrev stop --all" + stopExample = "brev stop ...\nbrev stop --all\necho instance-name | brev stop" ) type StopStore interface { @@ -43,17 +43,28 @@ func NewCmdStop(t *terminal.Terminal, loginStopStore StopStore, noLoginStopStore // Args: cmderrors.TransformToValidationError(cobra.ExactArgs()), ValidArgsFunction: completions.GetAllWorkspaceNameCompletionHandler(noLoginStopStore, t), RunE: func(cmd *cobra.Command, args []string) error { + piped := util.IsStdoutPiped() if all { - return stopAllWorkspaces(t, loginStopStore) + return stopAllWorkspaces(t, loginStopStore, piped) } else { - if len(args) == 0 { - return breverrors.NewValidationError("please provide an instance to stop") + names, err := util.GetInstanceNames(args) + if err != nil { + return breverrors.WrapAndTrace(err) } var allErr error - for _, arg := range args { - err := stopWorkspace(arg, t, loginStopStore) + var stoppedNames []string + for _, name := range names { + err := stopWorkspace(name, t, loginStopStore, piped) if err != nil { allErr = multierror.Append(allErr, err) + } else { + stoppedNames = append(stoppedNames, name) + } + } + // Output names for piping to next command + if piped { + for _, name := range stoppedNames { + fmt.Println(name) } } if allErr != nil { @@ -68,7 +79,7 @@ func NewCmdStop(t *terminal.Terminal, loginStopStore StopStore, noLoginStopStore return cmd } -func stopAllWorkspaces(t *terminal.Terminal, stopStore StopStore) error { +func stopAllWorkspaces(t *terminal.Terminal, stopStore StopStore, piped bool) error { user, err := stopStore.GetCurrentUser() if err != nil { return breverrors.WrapAndTrace(err) @@ -81,21 +92,33 @@ func stopAllWorkspaces(t *terminal.Terminal, stopStore StopStore) error { if err != nil { return breverrors.WrapAndTrace(err) } - t.Vprintf("Turning off all of your instances") + if !piped { + t.Vprintf("Turning off all of your instances") + } + var stoppedNames []string for _, v := range workspaces { if v.Status == entity.Running { _, err = stopStore.StopWorkspace(v.ID) if err != nil { return breverrors.WrapAndTrace(err) } else { - t.Vprintf("%s", t.Green("\n%s stopped ✓", v.Name)) + stoppedNames = append(stoppedNames, v.Name) + if !piped { + t.Vprintf("%s", t.Green("\n%s stopped ✓", v.Name)) + } } } } + // Output names for piping to next command + if piped { + for _, name := range stoppedNames { + fmt.Println(name) + } + } return nil } -func stopWorkspace(workspaceName string, t *terminal.Terminal, stopStore StopStore) error { +func stopWorkspace(workspaceName string, t *terminal.Terminal, stopStore StopStore, piped bool) error { user, err := stopStore.GetCurrentUser() if err != nil { return breverrors.WrapAndTrace(err) @@ -106,7 +129,9 @@ func stopWorkspace(workspaceName string, t *terminal.Terminal, stopStore StopSto if workspaceName == "self" { wsID, err2 := stopStore.GetCurrentWorkspaceID() if err2 != nil { - t.Vprintf("\n Error: %s", t.Red(err2.Error())) + if !piped { + t.Vprintf("\n Error: %s", t.Red(err2.Error())) + } return breverrors.WrapAndTrace(err2) } workspaceID = wsID @@ -117,7 +142,9 @@ func stopWorkspace(workspaceName string, t *terminal.Terminal, stopStore StopSto return breverrors.WrapAndTrace(err3) } else { if user.GlobalUserType == entity.Admin { - fmt.Println("admin trying to stop any instance") + if !piped { + fmt.Println("admin trying to stop any instance") + } workspace, err = util.GetAnyWorkspaceByIDOrNameInActiveOrgErr(stopStore, workspaceName) if err != nil { return breverrors.WrapAndTrace(err) @@ -133,7 +160,7 @@ func stopWorkspace(workspaceName string, t *terminal.Terminal, stopStore StopSto _, err = stopStore.StopWorkspace(workspaceID) if err != nil { return breverrors.WrapAndTrace(err) - } else { + } else if !piped { if workspaceName == "self" { t.Vprintf("%s", t.Green("Stopping this instance\n")+ "Note: this can take a few seconds. Run 'brev ls' to check status\n") @@ -145,7 +172,3 @@ func stopWorkspace(workspaceName string, t *terminal.Terminal, stopStore StopSto return nil } - -// get current workspace -// stopWorkspace("") -// stop the workspace diff --git a/pkg/cmd/util/piping.go b/pkg/cmd/util/piping.go new file mode 100644 index 00000000..fbabb7d0 --- /dev/null +++ b/pkg/cmd/util/piping.go @@ -0,0 +1,53 @@ +package util + +import ( + "bufio" + "os" + "strings" + + breverrors "github.com/brevdev/brev-cli/pkg/errors" +) + +// IsStdoutPiped returns true if stdout is being piped to another command +// Enables command chaining like: brev ls | grep RUNNING | brev stop +func IsStdoutPiped() bool { + stat, _ := os.Stdout.Stat() + return (stat.Mode() & os.ModeCharDevice) == 0 +} + +// IsStdinPiped returns true if stdin is being piped from another command +func IsStdinPiped() bool { + stat, _ := os.Stdin.Stat() + return (stat.Mode() & os.ModeCharDevice) == 0 +} + +// GetInstanceNames gets instance names from args or stdin (supports piping) +// Returns error if no names are provided +func GetInstanceNames(args []string) ([]string, error) { + names, _ := GetInstanceNamesWithPipeInfo(args) + if len(names) == 0 { + return nil, breverrors.NewValidationError("instance name required: provide as argument or pipe from another command") + } + return names, nil +} + +// GetInstanceNamesWithPipeInfo gets instance names from args or stdin and +// returns whether stdin was piped. Useful when you need to know if input came +// from a pipe vs args. +func GetInstanceNamesWithPipeInfo(args []string) ([]string, bool) { + var names []string + names = append(names, args...) + + stdinPiped := IsStdinPiped() + if stdinPiped { + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + name := strings.TrimSpace(scanner.Text()) + if name != "" { + names = append(names, name) + } + } + } + + return names, stdinPiped +}