Skip to content

Commit babfea3

Browse files
Namanclaude
authored andcommitted
feat: add init, env, status, open, scale, templates commands + --output json + logs --follow + domains improvements
New commands: - `createos init` — link local directory to a CreateOS project via .createos.json - `createos env set/list/rm/pull/push` — full environment variable management - `createos status` — consolidated project health dashboard - `createos open` — open project URL or dashboard in browser - `createos scale` — adjust replicas, CPU, and memory - `createos templates list/info/use` — browse and scaffold from project templates Enhancements: - `--output json` / `-o json` global flag for machine-readable output (auto-detects non-TTY) - `createos deployments logs --follow` / `-f` for real-time log tailing - `createos domains add` now shows DNS setup instructions - `createos domains list` now shows status icons (✓/⏳/✗) - `createos domains verify` new subcommand with DNS polling Infrastructure: - internal/config/project.go — .createos.json project linking with directory walk-up - internal/output/render.go — JSON/table output format helper - internal/browser/browser.go — cross-platform browser opening (extracted from oauth) - 8 new API methods in internal/api/methods.go Bug fixes: - Fixed wrong command paths in domains delete/refresh error messages - Signal handlers properly cleaned up in follow/verify polling loops Closes #3, #4, #5, #6, #7, #8, #9, #10, #11, #12 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 49c8f13 commit babfea3

27 files changed

Lines changed: 1917 additions & 22 deletions

cmd/deployments/deployments_logs.go

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ package deployments
22

33
import (
44
"fmt"
5+
"os"
6+
"os/signal"
7+
"strings"
8+
"syscall"
9+
"time"
510

611
"github.com/pterm/pterm"
712
"github.com/urfave/cli/v2"
@@ -16,10 +21,22 @@ func newDeploymentLogsCommand() *cli.Command {
1621
ArgsUsage: "<project-id> <deployment-id>",
1722
Description: "Fetches the latest logs for a running deployment.\n\n" +
1823
" To find your deployment ID, run:\n" +
19-
" createos projects deployments list <project-id>",
24+
" createos deployments list <project-id>",
25+
Flags: []cli.Flag{
26+
&cli.BoolFlag{
27+
Name: "follow",
28+
Aliases: []string{"f"},
29+
Usage: "Continuously poll for new logs",
30+
},
31+
&cli.DurationFlag{
32+
Name: "interval",
33+
Value: 2 * time.Second,
34+
Usage: "Polling interval when using --follow",
35+
},
36+
},
2037
Action: func(c *cli.Context) error {
2138
if c.NArg() < 2 {
22-
return fmt.Errorf("please provide a project ID and deployment ID\n\n Example:\n createos projects deployments logs <project-id> <deployment-id>")
39+
return fmt.Errorf("please provide a project ID and deployment ID\n\n Example:\n createos deployments logs <project-id> <deployment-id>")
2340
}
2441

2542
client, ok := c.App.Metadata[api.ClientKey].(*api.APIClient)
@@ -37,14 +54,50 @@ func newDeploymentLogsCommand() *cli.Command {
3754

3855
if logs == "" {
3956
fmt.Println("No logs available yet. The deployment may still be starting up.")
57+
} else {
58+
fmt.Print(logs)
59+
if !strings.HasSuffix(logs, "\n") {
60+
fmt.Println()
61+
}
62+
}
63+
64+
if !c.Bool("follow") {
65+
fmt.Println()
66+
pterm.Println(pterm.Gray(" Tip: Use --follow (-f) to tail logs in real-time."))
4067
return nil
4168
}
4269

43-
fmt.Println(logs)
70+
// Follow mode: poll for new logs
71+
pterm.Println(pterm.Gray(" Tailing logs (Ctrl+C to stop)..."))
4472
fmt.Println()
45-
pterm.Println(pterm.Gray(" Tip: To redeploy, run:"))
46-
pterm.Println(pterm.Gray(" createos projects deployments retrigger " + projectID + " " + deploymentID))
47-
return nil
73+
74+
previousLen := len(logs)
75+
interval := c.Duration("interval")
76+
ticker := time.NewTicker(interval)
77+
defer ticker.Stop()
78+
79+
sigCh := make(chan os.Signal, 1)
80+
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
81+
defer signal.Stop(sigCh)
82+
83+
for {
84+
select {
85+
case <-sigCh:
86+
fmt.Println()
87+
pterm.Info.Println("Log streaming stopped.")
88+
return nil
89+
case <-ticker.C:
90+
newLogs, err := client.GetDeploymentLogs(projectID, deploymentID)
91+
if err != nil {
92+
continue // transient error, keep trying
93+
}
94+
if len(newLogs) > previousLen {
95+
// Print only the new portion
96+
fmt.Print(newLogs[previousLen:])
97+
previousLen = len(newLogs)
98+
}
99+
}
100+
}
48101
},
49102
}
50103
}

cmd/domains/domains.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ func NewDomainsCommand() *cli.Command {
1414
newDomainsListCommand(),
1515
newDomainsAddCommand(),
1616
newDomainsRefreshCommand(),
17+
newDomainsVerifyCommand(),
1718
newDomainsDeleteCommand(),
1819
},
1920
}

cmd/domains/domains_add.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ func newDomainsAddCommand() *cli.Command {
1616
ArgsUsage: "<project-id> <domain>",
1717
Description: "Adds a custom domain to your project.\n\n" +
1818
" After adding, point your DNS to the provided records, then run:\n" +
19-
" createos projects domains refresh <project-id> <domain-id>",
19+
" createos domains refresh <project-id> <domain-id>",
2020
Action: func(c *cli.Context) error {
2121
if c.NArg() < 2 {
22-
return fmt.Errorf("please provide a project ID and domain name\n\n Example:\n createos projects domains add <project-id> myapp.com")
22+
return fmt.Errorf("please provide a project ID and domain name\n\n Example:\n createos domains add <project-id> myapp.com")
2323
}
2424

2525
client, ok := c.App.Metadata[api.ClientKey].(*api.APIClient)
@@ -37,8 +37,20 @@ func newDomainsAddCommand() *cli.Command {
3737

3838
pterm.Success.Printf("Domain %q added successfully.\n", name)
3939
fmt.Println()
40-
pterm.Println(pterm.Gray(" Next step: point your DNS records to CreateOS, then verify with:"))
41-
pterm.Println(pterm.Gray(" createos projects domains refresh " + projectID + " " + id))
40+
41+
// Show DNS setup instructions
42+
fmt.Println(" Configure your DNS with the following record:")
43+
fmt.Println()
44+
tableData := pterm.TableData{
45+
{"Type", "Name", "Value"},
46+
{"CNAME", name, projectID + ".nodeops.app"},
47+
}
48+
if err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Render(); err != nil {
49+
return err
50+
}
51+
fmt.Println()
52+
pterm.Println(pterm.Gray(" After updating DNS, verify with:"))
53+
pterm.Println(pterm.Gray(" createos domains refresh " + projectID + " " + id))
4254
return nil
4355
},
4456
}

cmd/domains/domains_delete.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ func newDomainsDeleteCommand() *cli.Command {
1616
ArgsUsage: "<project-id> <domain-id>",
1717
Description: "Permanently removes a custom domain from your project.\n\n" +
1818
" To find your domain ID, run:\n" +
19-
" createos projects domains list <project-id>",
19+
" createos domains list <project-id>",
2020
Action: func(c *cli.Context) error {
2121
if c.NArg() < 2 {
22-
return fmt.Errorf("please provide a project ID and domain ID\n\n Example:\n createos projects domains delete <project-id> <domain-id>")
22+
return fmt.Errorf("please provide a project ID and domain ID\n\n Example:\n createos domains delete <project-id> <domain-id>")
2323
}
2424

2525
client, ok := c.App.Metadata[api.ClientKey].(*api.APIClient)
@@ -50,7 +50,7 @@ func newDomainsDeleteCommand() *cli.Command {
5050
pterm.Success.Println("Domain is being removed.")
5151
fmt.Println()
5252
pterm.Println(pterm.Gray(" Tip: To see your remaining domains, run:"))
53-
pterm.Println(pterm.Gray(" createos projects domains list " + projectID))
53+
pterm.Println(pterm.Gray(" createos domains list " + projectID))
5454
return nil
5555
},
5656
}

cmd/domains/domains_list.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,28 +34,40 @@ func newDomainsListCommand() *cli.Command {
3434
fmt.Println("No custom domains added yet.")
3535
fmt.Println()
3636
pterm.Println(pterm.Gray(" Tip: To add a domain, run:"))
37-
pterm.Println(pterm.Gray(" createos projects domains add " + projectID + " <your-domain.com>"))
37+
pterm.Println(pterm.Gray(" createos domains add " + projectID + " <your-domain.com>"))
3838
return nil
3939
}
4040

4141
tableData := pterm.TableData{
4242
{"ID", "Domain", "Status", "Message"},
4343
}
4444
for _, d := range domains {
45+
icon := domainIcon(d.Status)
4546
msg := ""
4647
if d.Message != nil {
4748
msg = *d.Message
4849
}
49-
tableData = append(tableData, []string{d.ID, d.Name, d.Status, msg})
50+
tableData = append(tableData, []string{d.ID, d.Name, icon + " " + d.Status, msg})
5051
}
5152

5253
if err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Render(); err != nil {
5354
return err
5455
}
5556
fmt.Println()
5657
pterm.Println(pterm.Gray(" Tip: To add a new domain, run:"))
57-
pterm.Println(pterm.Gray(" createos projects domains add " + projectID + " <your-domain.com>"))
58+
pterm.Println(pterm.Gray(" createos domains add " + projectID + " <your-domain.com>"))
5859
return nil
5960
},
6061
}
6162
}
63+
64+
func domainIcon(status string) string {
65+
switch status {
66+
case "verified", "active":
67+
return pterm.Green("✓")
68+
case "pending":
69+
return pterm.Yellow("⏳")
70+
default:
71+
return pterm.Red("✗")
72+
}
73+
}

cmd/domains/domains_refresh.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ func newDomainsRefreshCommand() *cli.Command {
1616
ArgsUsage: "<project-id> <domain-id>",
1717
Description: "Triggers a DNS verification and certificate refresh for your domain.\n\n" +
1818
" To find your domain ID, run:\n" +
19-
" createos projects domains list <project-id>",
19+
" createos domains list <project-id>",
2020
Action: func(c *cli.Context) error {
2121
if c.NArg() < 2 {
22-
return fmt.Errorf("please provide a project ID and domain ID\n\n Example:\n createos projects domains refresh <project-id> <domain-id>")
22+
return fmt.Errorf("please provide a project ID and domain ID\n\n Example:\n createos domains refresh <project-id> <domain-id>")
2323
}
2424

2525
client, ok := c.App.Metadata[api.ClientKey].(*api.APIClient)
@@ -37,7 +37,7 @@ func newDomainsRefreshCommand() *cli.Command {
3737
pterm.Success.Println("Domain refresh started. This may take a few minutes.")
3838
fmt.Println()
3939
pterm.Println(pterm.Gray(" Tip: To check the domain status, run:"))
40-
pterm.Println(pterm.Gray(" createos projects domains list " + projectID))
40+
pterm.Println(pterm.Gray(" createos domains list " + projectID))
4141
return nil
4242
},
4343
}

cmd/domains/domains_verify.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package domains
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/signal"
7+
"syscall"
8+
"time"
9+
10+
"github.com/pterm/pterm"
11+
"github.com/urfave/cli/v2"
12+
13+
"github.com/NodeOps-app/createos-cli/internal/api"
14+
)
15+
16+
func newDomainsVerifyCommand() *cli.Command {
17+
return &cli.Command{
18+
Name: "verify",
19+
Usage: "Check DNS propagation and wait for domain verification",
20+
ArgsUsage: "<project-id> <domain-id>",
21+
Flags: []cli.Flag{
22+
&cli.BoolFlag{
23+
Name: "no-wait",
24+
Usage: "Check once and exit instead of polling",
25+
},
26+
},
27+
Action: func(c *cli.Context) error {
28+
if c.NArg() < 2 {
29+
return fmt.Errorf("please provide a project ID and domain ID\n\n Example:\n createos domains verify <project-id> <domain-id>")
30+
}
31+
32+
client, ok := c.App.Metadata[api.ClientKey].(*api.APIClient)
33+
if !ok {
34+
return fmt.Errorf("you're not signed in — run 'createos login' to get started")
35+
}
36+
37+
projectID := c.Args().Get(0)
38+
domainID := c.Args().Get(1)
39+
40+
// Trigger a refresh first
41+
if err := client.RefreshDomain(projectID, domainID); err != nil {
42+
return err
43+
}
44+
45+
// Check status
46+
domains, err := client.ListDomains(projectID)
47+
if err != nil {
48+
return err
49+
}
50+
51+
var domain *api.Domain
52+
for i := range domains {
53+
if domains[i].ID == domainID {
54+
domain = &domains[i]
55+
break
56+
}
57+
}
58+
if domain == nil {
59+
return fmt.Errorf("domain %s not found", domainID)
60+
}
61+
62+
if domain.Status == "verified" || domain.Status == "active" {
63+
pterm.Success.Printf("Domain %s is verified!\n", domain.Name)
64+
return nil
65+
}
66+
67+
if c.Bool("no-wait") {
68+
pterm.Warning.Printf("Domain %s status: %s\n", domain.Name, domain.Status)
69+
return nil
70+
}
71+
72+
// Poll until verified
73+
pterm.Info.Printf("Waiting for DNS verification of %s...\n", domain.Name)
74+
75+
ticker := time.NewTicker(10 * time.Second)
76+
defer ticker.Stop()
77+
78+
sigCh := make(chan os.Signal, 1)
79+
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
80+
defer signal.Stop(sigCh)
81+
82+
attempts := 0
83+
maxAttempts := 30 // 5 minutes
84+
85+
for {
86+
select {
87+
case <-sigCh:
88+
fmt.Println()
89+
pterm.Info.Println("Verification stopped. You can check again later with:")
90+
pterm.Println(pterm.Gray(" createos domains verify " + projectID + " " + domainID))
91+
return nil
92+
case <-ticker.C:
93+
attempts++
94+
if attempts > maxAttempts {
95+
pterm.Warning.Println("DNS propagation is taking longer than expected.")
96+
pterm.Println(pterm.Gray(" DNS changes can take up to 48 hours. Try again later:"))
97+
pterm.Println(pterm.Gray(" createos domains verify " + projectID + " " + domainID))
98+
return nil
99+
}
100+
101+
_ = client.RefreshDomain(projectID, domainID)
102+
domains, err := client.ListDomains(projectID)
103+
if err != nil {
104+
continue
105+
}
106+
107+
for _, d := range domains {
108+
if d.ID == domainID {
109+
if d.Status == "verified" || d.Status == "active" {
110+
pterm.Success.Printf("Domain %s is verified!\n", d.Name)
111+
return nil
112+
}
113+
fmt.Printf(" ⏳ Checking DNS... %s\n", d.Status)
114+
break
115+
}
116+
}
117+
}
118+
}
119+
},
120+
}
121+
}

cmd/env/env.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Package env provides environment variable management commands.
2+
package env
3+
4+
import (
5+
"github.com/urfave/cli/v2"
6+
)
7+
8+
// NewEnvCommand returns the env command group.
9+
func NewEnvCommand() *cli.Command {
10+
return &cli.Command{
11+
Name: "env",
12+
Usage: "Manage environment variables for a project",
13+
Subcommands: []*cli.Command{
14+
newEnvListCommand(),
15+
newEnvSetCommand(),
16+
newEnvRmCommand(),
17+
newEnvPullCommand(),
18+
newEnvPushCommand(),
19+
},
20+
}
21+
}

0 commit comments

Comments
 (0)