Skip to content

Commit 4ddd343

Browse files
feat: suspend - unsuspend project (#38)
* feat: suspend - unsuspend project * feat: updated ask command instruction
1 parent 227743f commit 4ddd343

5 files changed

Lines changed: 273 additions & 1 deletion

File tree

cmd/ask/ask.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,13 @@ func NewAskCommand() *cli.Command {
5757
opencodeBin, err := exec.LookPath("opencode")
5858
if err != nil {
5959
if !terminal.IsInteractive() {
60-
return fmt.Errorf("opencode is not installed\n\n Install it with:\n curl -fsSL https://opencode.ai/install | bash")
60+
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")
6161
}
6262

63+
fmt.Println()
64+
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.")
65+
fmt.Println()
66+
6367
install, _ := pterm.DefaultInteractiveConfirm.
6468
WithDefaultText("opencode is not installed. Install it now?").
6569
WithDefaultValue(true).

cmd/projects/projects.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ func NewProjectsCommand() *cli.Command {
1414
newDeleteCommand(),
1515
newGetCommand(),
1616
newListCommand(),
17+
newSuspendCommand(),
18+
newUnsuspendCommand(),
1719
},
1820
}
1921
}

cmd/projects/suspend.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package projects
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/pterm/pterm"
7+
"github.com/urfave/cli/v2"
8+
9+
"github.com/NodeOps-app/createos-cli/internal/api"
10+
"github.com/NodeOps-app/createos-cli/internal/config"
11+
"github.com/NodeOps-app/createos-cli/internal/terminal"
12+
)
13+
14+
func newSuspendCommand() *cli.Command {
15+
return &cli.Command{
16+
Name: "suspend",
17+
Usage: "Pause a running project",
18+
Flags: []cli.Flag{
19+
&cli.StringFlag{Name: "project", Usage: "Project ID"},
20+
},
21+
Action: func(c *cli.Context) error {
22+
client, ok := c.App.Metadata[api.ClientKey].(*api.APIClient)
23+
if !ok {
24+
return fmt.Errorf("you're not signed in — run 'createos login' to get started")
25+
}
26+
27+
projectID := c.String("project")
28+
29+
// Try linked project config
30+
if projectID == "" {
31+
cfg, _ := config.FindProjectConfig()
32+
if cfg != nil {
33+
projectID = cfg.ProjectID
34+
}
35+
}
36+
37+
// Interactive picker filtered to active projects
38+
if projectID == "" && terminal.IsInteractive() {
39+
projects, err := client.ListProjects()
40+
if err != nil {
41+
return err
42+
}
43+
44+
active := make([]api.Project, 0, len(projects))
45+
for _, p := range projects {
46+
if p.Status == "active" {
47+
active = append(active, p)
48+
}
49+
}
50+
51+
if len(active) == 0 {
52+
return fmt.Errorf("you don't have any active projects to suspend")
53+
}
54+
55+
options := make([]string, len(active))
56+
for i, p := range active {
57+
options[i] = fmt.Sprintf("%s (%s)", p.DisplayName, p.ID)
58+
}
59+
60+
selected, err := pterm.DefaultInteractiveSelect.
61+
WithDefaultText("Select a project to suspend").
62+
WithOptions(options).
63+
WithFilter(true).
64+
Show()
65+
if err != nil {
66+
return fmt.Errorf("selection cancelled")
67+
}
68+
69+
for i, opt := range options {
70+
if opt == selected {
71+
projectID = active[i].ID
72+
break
73+
}
74+
}
75+
}
76+
77+
if projectID == "" {
78+
return fmt.Errorf("please provide a project ID\n\n To see your projects, run:\n createos projects list")
79+
}
80+
81+
// Validate status when project was explicitly provided (not from picker)
82+
project, err := client.GetProject(projectID)
83+
if err != nil {
84+
return err
85+
}
86+
87+
if project.Status != "active" {
88+
switch project.Status {
89+
case "suspended":
90+
return fmt.Errorf("this project is already suspended")
91+
case "suspending":
92+
return fmt.Errorf("this project is already being suspended — please wait for it to complete")
93+
default:
94+
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)
95+
}
96+
}
97+
98+
if terminal.IsInteractive() {
99+
confirm, err := pterm.DefaultInteractiveConfirm.
100+
WithDefaultText(fmt.Sprintf("Are you sure you want to suspend project %q?", project.DisplayName)).
101+
WithDefaultValue(false).
102+
Show()
103+
if err != nil {
104+
return fmt.Errorf("could not read confirmation: %w", err)
105+
}
106+
if !confirm {
107+
fmt.Println("Cancelled. Your project was not suspended.")
108+
return nil
109+
}
110+
}
111+
112+
if err := client.SuspendProject(projectID); err != nil {
113+
return err
114+
}
115+
116+
pterm.Success.Println("Project is being suspended.")
117+
return nil
118+
},
119+
}
120+
}

cmd/projects/unsuspend.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package projects
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/pterm/pterm"
7+
"github.com/urfave/cli/v2"
8+
9+
"github.com/NodeOps-app/createos-cli/internal/api"
10+
"github.com/NodeOps-app/createos-cli/internal/config"
11+
"github.com/NodeOps-app/createos-cli/internal/terminal"
12+
)
13+
14+
func newUnsuspendCommand() *cli.Command {
15+
return &cli.Command{
16+
Name: "unsuspend",
17+
Usage: "Resume a suspended project",
18+
Flags: []cli.Flag{
19+
&cli.StringFlag{Name: "project", Usage: "Project ID"},
20+
},
21+
Action: func(c *cli.Context) error {
22+
client, ok := c.App.Metadata[api.ClientKey].(*api.APIClient)
23+
if !ok {
24+
return fmt.Errorf("you're not signed in — run 'createos login' to get started")
25+
}
26+
27+
projectID := c.String("project")
28+
29+
// Try linked project config
30+
if projectID == "" {
31+
cfg, _ := config.FindProjectConfig()
32+
if cfg != nil {
33+
projectID = cfg.ProjectID
34+
}
35+
}
36+
37+
// Interactive picker filtered to suspended projects
38+
if projectID == "" && terminal.IsInteractive() {
39+
projects, err := client.ListProjects()
40+
if err != nil {
41+
return err
42+
}
43+
44+
suspended := make([]api.Project, 0, len(projects))
45+
for _, p := range projects {
46+
if p.Status == "suspended" {
47+
suspended = append(suspended, p)
48+
}
49+
}
50+
51+
if len(suspended) == 0 {
52+
return fmt.Errorf("you don't have any suspended projects to resume")
53+
}
54+
55+
options := make([]string, len(suspended))
56+
for i, p := range suspended {
57+
options[i] = fmt.Sprintf("%s (%s)", p.DisplayName, p.ID)
58+
}
59+
60+
selected, err := pterm.DefaultInteractiveSelect.
61+
WithDefaultText("Select a project to resume").
62+
WithOptions(options).
63+
WithFilter(true).
64+
Show()
65+
if err != nil {
66+
return fmt.Errorf("selection cancelled")
67+
}
68+
69+
for i, opt := range options {
70+
if opt == selected {
71+
projectID = suspended[i].ID
72+
break
73+
}
74+
}
75+
}
76+
77+
if projectID == "" {
78+
return fmt.Errorf("please provide a project ID\n\n To see your projects, run:\n createos projects list")
79+
}
80+
81+
// Validate status when project was explicitly provided (not from picker)
82+
project, err := client.GetProject(projectID)
83+
if err != nil {
84+
return err
85+
}
86+
87+
if project.Status != "suspended" {
88+
switch project.Status {
89+
case "active":
90+
return fmt.Errorf("this project is already running")
91+
case "suspending":
92+
return fmt.Errorf("this project is currently being suspended — wait for it to finish before unsuspending")
93+
default:
94+
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)
95+
}
96+
}
97+
98+
if terminal.IsInteractive() {
99+
confirm, err := pterm.DefaultInteractiveConfirm.
100+
WithDefaultText(fmt.Sprintf("Are you sure you want to resume project %q?", project.DisplayName)).
101+
WithDefaultValue(false).
102+
Show()
103+
if err != nil {
104+
return fmt.Errorf("could not read confirmation: %w", err)
105+
}
106+
if !confirm {
107+
fmt.Println("Cancelled. Your project was not resumed.")
108+
return nil
109+
}
110+
}
111+
112+
if err := client.UnsuspendProject(projectID); err != nil {
113+
return err
114+
}
115+
116+
pterm.Success.Println("Project resumed.")
117+
return nil
118+
},
119+
}
120+
}

internal/api/methods.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,32 @@ func (c *APIClient) DeleteProject(id string) error {
192192
return nil
193193
}
194194

195+
// SuspendProject suspends a running project.
196+
func (c *APIClient) SuspendProject(id string) error {
197+
resp, err := c.Client.R().
198+
Put("/v1/projects/" + id + "/suspend")
199+
if err != nil {
200+
return err
201+
}
202+
if resp.IsError() {
203+
return ParseAPIError(resp.StatusCode(), resp.Body())
204+
}
205+
return nil
206+
}
207+
208+
// UnsuspendProject resumes a suspended project.
209+
func (c *APIClient) UnsuspendProject(id string) error {
210+
resp, err := c.Client.R().
211+
Put("/v1/projects/" + id + "/unsuspend")
212+
if err != nil {
213+
return err
214+
}
215+
if resp.IsError() {
216+
return ParseAPIError(resp.StatusCode(), resp.Body())
217+
}
218+
return nil
219+
}
220+
195221
// GetUser returns the currently authenticated user.
196222
func (c *APIClient) GetUser() (*User, error) {
197223
var result Response[User]

0 commit comments

Comments
 (0)