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
6 changes: 5 additions & 1 deletion cmd/ask/ask.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 2 additions & 0 deletions cmd/projects/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ func NewProjectsCommand() *cli.Command {
newDeleteCommand(),
newGetCommand(),
newListCommand(),
newSuspendCommand(),
newUnsuspendCommand(),
},
}
}
120 changes: 120 additions & 0 deletions cmd/projects/suspend.go
Original file line number Diff line number Diff line change
@@ -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
},
}
}
120 changes: 120 additions & 0 deletions cmd/projects/unsuspend.go
Original file line number Diff line number Diff line change
@@ -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
},
}
}
26 changes: 26 additions & 0 deletions internal/api/methods.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down