From ba27024adf420a7071f582bd15c12a72b787de7a Mon Sep 17 00:00:00 2001 From: Bhautik Date: Mon, 13 Apr 2026 15:59:41 +0530 Subject: [PATCH 1/2] feat: suspend - unsuspend project --- cmd/projects/projects.go | 2 + cmd/projects/suspend.go | 120 ++++++++++++++++++++++++++++++++++++++ cmd/projects/unsuspend.go | 120 ++++++++++++++++++++++++++++++++++++++ internal/api/methods.go | 26 +++++++++ 4 files changed, 268 insertions(+) create mode 100644 cmd/projects/suspend.go create mode 100644 cmd/projects/unsuspend.go diff --git a/cmd/projects/projects.go b/cmd/projects/projects.go index 1c95c17..b950182 100644 --- a/cmd/projects/projects.go +++ b/cmd/projects/projects.go @@ -14,6 +14,8 @@ func NewProjectsCommand() *cli.Command { newDeleteCommand(), newGetCommand(), newListCommand(), + newSuspendCommand(), + newUnsuspendCommand(), }, } } diff --git a/cmd/projects/suspend.go b/cmd/projects/suspend.go new file mode 100644 index 0000000..01cca58 --- /dev/null +++ b/cmd/projects/suspend.go @@ -0,0 +1,120 @@ +package projects + +import ( + "fmt" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/config" + "github.com/NodeOps-app/createos-cli/internal/terminal" +) + +func newSuspendCommand() *cli.Command { + return &cli.Command{ + Name: "suspend", + Usage: "Pause a running project", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "project", Usage: "Project ID"}, + }, + Action: func(c *cli.Context) error { + client, ok := c.App.Metadata[api.ClientKey].(*api.APIClient) + if !ok { + return fmt.Errorf("you're not signed in — run 'createos login' to get started") + } + + projectID := c.String("project") + + // Try linked project config + if projectID == "" { + cfg, _ := config.FindProjectConfig() + if cfg != nil { + projectID = cfg.ProjectID + } + } + + // Interactive picker filtered to active projects + if projectID == "" && terminal.IsInteractive() { + projects, err := client.ListProjects() + if err != nil { + return err + } + + active := make([]api.Project, 0, len(projects)) + for _, p := range projects { + if p.Status == "active" { + active = append(active, p) + } + } + + if len(active) == 0 { + return fmt.Errorf("you don't have any active projects to suspend") + } + + options := make([]string, len(active)) + for i, p := range active { + options[i] = fmt.Sprintf("%s (%s)", p.DisplayName, p.ID) + } + + selected, err := pterm.DefaultInteractiveSelect. + WithDefaultText("Select a project to suspend"). + WithOptions(options). + WithFilter(true). + Show() + if err != nil { + return fmt.Errorf("selection cancelled") + } + + for i, opt := range options { + if opt == selected { + projectID = active[i].ID + break + } + } + } + + if projectID == "" { + return fmt.Errorf("please provide a project ID\n\n To see your projects, run:\n createos projects list") + } + + // Validate status when project was explicitly provided (not from picker) + project, err := client.GetProject(projectID) + if err != nil { + return err + } + + if project.Status != "active" { + switch project.Status { + case "suspended": + return fmt.Errorf("this project is already suspended") + case "suspending": + return fmt.Errorf("this project is already being suspended — please wait for it to complete") + default: + return fmt.Errorf("this project can't be suspended right now because it is %s\n\n Only active projects can be suspended. Run 'createos projects get %s' to check its status", project.Status, projectID) + } + } + + if terminal.IsInteractive() { + confirm, err := pterm.DefaultInteractiveConfirm. + WithDefaultText(fmt.Sprintf("Are you sure you want to suspend project %q?", project.DisplayName)). + WithDefaultValue(false). + Show() + if err != nil { + return fmt.Errorf("could not read confirmation: %w", err) + } + if !confirm { + fmt.Println("Cancelled. Your project was not suspended.") + return nil + } + } + + if err := client.SuspendProject(projectID); err != nil { + return err + } + + pterm.Success.Println("Project is being suspended.") + return nil + }, + } +} diff --git a/cmd/projects/unsuspend.go b/cmd/projects/unsuspend.go new file mode 100644 index 0000000..dc03203 --- /dev/null +++ b/cmd/projects/unsuspend.go @@ -0,0 +1,120 @@ +package projects + +import ( + "fmt" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/config" + "github.com/NodeOps-app/createos-cli/internal/terminal" +) + +func newUnsuspendCommand() *cli.Command { + return &cli.Command{ + Name: "unsuspend", + Usage: "Resume a suspended project", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "project", Usage: "Project ID"}, + }, + Action: func(c *cli.Context) error { + client, ok := c.App.Metadata[api.ClientKey].(*api.APIClient) + if !ok { + return fmt.Errorf("you're not signed in — run 'createos login' to get started") + } + + projectID := c.String("project") + + // Try linked project config + if projectID == "" { + cfg, _ := config.FindProjectConfig() + if cfg != nil { + projectID = cfg.ProjectID + } + } + + // Interactive picker filtered to suspended projects + if projectID == "" && terminal.IsInteractive() { + projects, err := client.ListProjects() + if err != nil { + return err + } + + suspended := make([]api.Project, 0, len(projects)) + for _, p := range projects { + if p.Status == "suspended" { + suspended = append(suspended, p) + } + } + + if len(suspended) == 0 { + return fmt.Errorf("you don't have any suspended projects to resume") + } + + options := make([]string, len(suspended)) + for i, p := range suspended { + options[i] = fmt.Sprintf("%s (%s)", p.DisplayName, p.ID) + } + + selected, err := pterm.DefaultInteractiveSelect. + WithDefaultText("Select a project to resume"). + WithOptions(options). + WithFilter(true). + Show() + if err != nil { + return fmt.Errorf("selection cancelled") + } + + for i, opt := range options { + if opt == selected { + projectID = suspended[i].ID + break + } + } + } + + if projectID == "" { + return fmt.Errorf("please provide a project ID\n\n To see your projects, run:\n createos projects list") + } + + // Validate status when project was explicitly provided (not from picker) + project, err := client.GetProject(projectID) + if err != nil { + return err + } + + if project.Status != "suspended" { + switch project.Status { + case "active": + return fmt.Errorf("this project is already running") + case "suspending": + return fmt.Errorf("this project is currently being suspended — wait for it to finish before unsuspending") + default: + return fmt.Errorf("this project can't be unsuspended right now because it is %s\n\n Only suspended projects can be resumed. Run 'createos projects get %s' to check its status", project.Status, projectID) + } + } + + if terminal.IsInteractive() { + confirm, err := pterm.DefaultInteractiveConfirm. + WithDefaultText(fmt.Sprintf("Are you sure you want to resume project %q?", project.DisplayName)). + WithDefaultValue(false). + Show() + if err != nil { + return fmt.Errorf("could not read confirmation: %w", err) + } + if !confirm { + fmt.Println("Cancelled. Your project was not resumed.") + return nil + } + } + + if err := client.UnsuspendProject(projectID); err != nil { + return err + } + + pterm.Success.Println("Project resumed.") + return nil + }, + } +} diff --git a/internal/api/methods.go b/internal/api/methods.go index 5bdeb8f..09239ab 100644 --- a/internal/api/methods.go +++ b/internal/api/methods.go @@ -192,6 +192,32 @@ func (c *APIClient) DeleteProject(id string) error { return nil } +// SuspendProject suspends a running project. +func (c *APIClient) SuspendProject(id string) error { + resp, err := c.Client.R(). + Put("/v1/projects/" + id + "/suspend") + if err != nil { + return err + } + if resp.IsError() { + return ParseAPIError(resp.StatusCode(), resp.Body()) + } + return nil +} + +// UnsuspendProject resumes a suspended project. +func (c *APIClient) UnsuspendProject(id string) error { + resp, err := c.Client.R(). + Put("/v1/projects/" + id + "/unsuspend") + if err != nil { + return err + } + if resp.IsError() { + return ParseAPIError(resp.StatusCode(), resp.Body()) + } + return nil +} + // GetUser returns the currently authenticated user. func (c *APIClient) GetUser() (*User, error) { var result Response[User] From bd728d73de622932b13925109e30c16a037d580d Mon Sep 17 00:00:00 2001 From: Bhautik Date: Mon, 13 Apr 2026 16:03:01 +0530 Subject: [PATCH 2/2] feat: updated ask command instruction --- cmd/ask/ask.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd/ask/ask.go b/cmd/ask/ask.go index 2815e6f..4c6828c 100644 --- a/cmd/ask/ask.go +++ b/cmd/ask/ask.go @@ -57,9 +57,13 @@ func NewAskCommand() *cli.Command { opencodeBin, err := exec.LookPath("opencode") if err != nil { if !terminal.IsInteractive() { - return fmt.Errorf("opencode is not installed\n\n Install it with:\n curl -fsSL https://opencode.ai/install | bash") + return fmt.Errorf("opencode is not installed\n\n The 'ask' command uses OpenCode (https://opencode.ai), an open-source AI coding\n assistant, to power the CreateOS AI agent. It lets you manage your infrastructure\n using natural language right from the terminal.\n\n Install it with:\n curl -fsSL https://opencode.ai/install | bash") } + fmt.Println() + pterm.Info.Println("The 'ask' command uses OpenCode (https://opencode.ai), an open-source AI coding\nassistant, to power the CreateOS AI agent. It lets you manage your infrastructure\nusing natural language right from the terminal.") + fmt.Println() + install, _ := pterm.DefaultInteractiveConfirm. WithDefaultText("opencode is not installed. Install it now?"). WithDefaultValue(true).