Skip to content

Commit 31c1485

Browse files
Namanclaude
authored andcommitted
feat: add createos deploy command
Adds the most-requested missing command — `createos deploy` — which creates new deployments directly from the CLI. Automatically detects project type and routes accordingly: - VCS (GitHub) projects → triggers deploy from latest commit on branch - Upload projects → zips local directory and uploads (respects .gitignore-style patterns) - Image projects → deploys a Docker image reference Features: - Auto-detects project from .createos.json (or --project flag) - Real-time deployment status polling with spinner - Prints live URL on success - Shows build-log hint on failure - Upload path: excludes node_modules, .git, .env, __pycache__, etc. - Upload path: 50MB max, 10MB per-file limit - 5-minute deployment timeout with graceful fallback New API methods: - TriggerLatestDeployment — POST /v1/projects/{id}/deployments/trigger-latest - UploadDeploymentZip — POST /v1/projects/{id}/deployments/upload/zip - GetDeployment — GET /v1/projects/{id}/deployments/{id} Closes #3 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent be9fa9e commit 31c1485

3 files changed

Lines changed: 384 additions & 1 deletion

File tree

cmd/deploy/deploy.go

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
// Package deploy provides the deploy command for creating new deployments.
2+
package deploy
3+
4+
import (
5+
"archive/zip"
6+
"fmt"
7+
"io"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
"time"
12+
13+
"github.com/pterm/pterm"
14+
"github.com/urfave/cli/v2"
15+
16+
"github.com/NodeOps-app/createos-cli/internal/api"
17+
"github.com/NodeOps-app/createos-cli/internal/config"
18+
"github.com/NodeOps-app/createos-cli/internal/terminal"
19+
)
20+
21+
const maxZipSize = 50 * 1024 * 1024 // 50 MB
22+
23+
// defaultIgnorePatterns are files/dirs excluded when zipping for upload.
24+
var defaultIgnorePatterns = []string{
25+
".git",
26+
".gitignore",
27+
".createos.json",
28+
"node_modules",
29+
".env",
30+
".env.*",
31+
"__pycache__",
32+
".venv",
33+
"venv",
34+
".DS_Store",
35+
"Thumbs.db",
36+
".idea",
37+
".vscode",
38+
"*.swp",
39+
"*.swo",
40+
"target", // Rust
41+
"vendor", // Go (optional, but common to exclude)
42+
"dist", // built output — may need to include for some projects
43+
"coverage",
44+
".nyc_output",
45+
}
46+
47+
// NewDeployCommand returns the deploy command.
48+
func NewDeployCommand() *cli.Command {
49+
return &cli.Command{
50+
Name: "deploy",
51+
Usage: "Deploy your project to CreateOS",
52+
Description: "Creates a new deployment for the current project.\n\n" +
53+
" The deploy method is chosen automatically based on your project type:\n" +
54+
" VCS (GitHub) projects → triggers from the latest commit\n" +
55+
" Upload projects → zips and uploads the current directory\n" +
56+
" Image projects → deploys the specified Docker image\n\n" +
57+
" Link your project first with 'createos init' if you haven't already.",
58+
Flags: []cli.Flag{
59+
&cli.StringFlag{
60+
Name: "project",
61+
Usage: "Project ID (auto-detected from .createos.json)",
62+
},
63+
&cli.StringFlag{
64+
Name: "branch",
65+
Usage: "Branch to deploy from (VCS projects only, defaults to repo default branch)",
66+
},
67+
&cli.StringFlag{
68+
Name: "image",
69+
Usage: "Docker image to deploy (image projects only, e.g. nginx:latest)",
70+
},
71+
&cli.StringFlag{
72+
Name: "dir",
73+
Value: ".",
74+
Usage: "Directory to deploy (upload projects only)",
75+
},
76+
},
77+
Action: func(c *cli.Context) error {
78+
client, ok := c.App.Metadata[api.ClientKey].(*api.APIClient)
79+
if !ok {
80+
return fmt.Errorf("you're not signed in — run 'createos login' to get started")
81+
}
82+
83+
projectID := c.String("project")
84+
if projectID == "" {
85+
cfg, err := config.FindProjectConfig()
86+
if err != nil {
87+
return err
88+
}
89+
if cfg == nil {
90+
return fmt.Errorf("no project linked to this directory\n\n Link a project first:\n createos init\n\n Or specify one:\n createos deploy --project <id>")
91+
}
92+
projectID = cfg.ProjectID
93+
}
94+
95+
project, err := client.GetProject(projectID)
96+
if err != nil {
97+
return err
98+
}
99+
100+
// Route based on project type
101+
switch {
102+
case c.IsSet("image") || project.Type == "image":
103+
return deployImage(c, client, project)
104+
case project.Type == "upload":
105+
return deployUpload(c, client, project)
106+
default:
107+
// VCS (GitHub) projects and anything else
108+
return deployVCS(c, client, project)
109+
}
110+
},
111+
}
112+
}
113+
114+
// deployVCS triggers a deployment from the latest commit on a branch.
115+
func deployVCS(c *cli.Context, client *api.APIClient, project *api.Project) error {
116+
branch := c.String("branch")
117+
118+
branchLabel := "default branch"
119+
if branch != "" {
120+
branchLabel = branch
121+
}
122+
123+
pterm.Info.Printf("Deploying %s from %s...\n", project.DisplayName, branchLabel)
124+
125+
deployment, err := client.TriggerLatestDeployment(project.ID, branch)
126+
if err != nil {
127+
return err
128+
}
129+
130+
return waitForDeployment(client, project.ID, deployment)
131+
}
132+
133+
// deployUpload zips the local directory and uploads it.
134+
func deployUpload(c *cli.Context, client *api.APIClient, project *api.Project) error {
135+
dir := c.String("dir")
136+
absDir, err := filepath.Abs(dir)
137+
if err != nil {
138+
return err
139+
}
140+
141+
info, err := os.Stat(absDir)
142+
if err != nil || !info.IsDir() {
143+
return fmt.Errorf("directory %q not found", dir)
144+
}
145+
146+
pterm.Info.Printf("Deploying %s from %s...\n", project.DisplayName, absDir)
147+
148+
// Create temporary zip
149+
zipFile, err := os.CreateTemp("", "createos-deploy-*.zip")
150+
if err != nil {
151+
return fmt.Errorf("could not create temp file: %w", err)
152+
}
153+
defer os.Remove(zipFile.Name()) //nolint:errcheck
154+
defer zipFile.Close() //nolint:errcheck
155+
156+
spinner, _ := pterm.DefaultSpinner.Start("Packaging files...")
157+
158+
if err := createZip(zipFile, absDir); err != nil {
159+
spinner.Fail("Packaging failed")
160+
return err
161+
}
162+
163+
stat, _ := zipFile.Stat()
164+
if stat != nil && stat.Size() > maxZipSize {
165+
spinner.Fail("Package too large")
166+
return fmt.Errorf("deployment package is %d MB (max %d MB)\n\n Tip: check that node_modules, .git, and build artifacts are excluded",
167+
stat.Size()/(1024*1024), maxZipSize/(1024*1024))
168+
}
169+
170+
spinner.UpdateText("Uploading...")
171+
172+
// Close before uploading so the file is flushed
173+
zipFile.Close() //nolint:errcheck
174+
175+
deployment, err := client.UploadDeploymentZip(project.ID, zipFile.Name())
176+
if err != nil {
177+
spinner.Fail("Upload failed")
178+
return err
179+
}
180+
181+
spinner.Success("Uploaded")
182+
183+
return waitForDeployment(client, project.ID, deployment)
184+
}
185+
186+
// deployImage deploys a Docker image.
187+
func deployImage(c *cli.Context, client *api.APIClient, project *api.Project) error {
188+
image := c.String("image")
189+
if image == "" {
190+
if !terminal.IsInteractive() {
191+
return fmt.Errorf("please provide a Docker image with --image\n\n Example:\n createos deploy --image nginx:latest")
192+
}
193+
result, err := pterm.DefaultInteractiveTextInput.
194+
WithDefaultText("Docker image (e.g. nginx:latest)").
195+
Show()
196+
if err != nil || result == "" {
197+
return fmt.Errorf("no image provided")
198+
}
199+
image = result
200+
}
201+
202+
pterm.Info.Printf("Deploying %s with image %s...\n", project.DisplayName, image)
203+
204+
deployment, err := client.CreateDeployment(project.ID, map[string]any{
205+
"image": image,
206+
})
207+
if err != nil {
208+
return err
209+
}
210+
211+
return waitForDeployment(client, project.ID, deployment)
212+
}
213+
214+
// waitForDeployment polls until the deployment succeeds, fails, or times out.
215+
func waitForDeployment(client *api.APIClient, projectID string, deployment *api.Deployment) error {
216+
spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Deploying (v%d)...", deployment.VersionNumber))
217+
218+
timeout := time.After(5 * time.Minute)
219+
ticker := time.NewTicker(3 * time.Second)
220+
defer ticker.Stop()
221+
222+
for {
223+
select {
224+
case <-timeout:
225+
spinner.Warning("Deployment is still in progress — check back with: createos deployments logs")
226+
return nil
227+
case <-ticker.C:
228+
d, err := client.GetDeployment(projectID, deployment.ID)
229+
if err != nil {
230+
continue // transient error, keep polling
231+
}
232+
233+
switch d.Status {
234+
case "successful", "running", "active", "deployed":
235+
spinner.Success(fmt.Sprintf("Deployed (v%d)", d.VersionNumber))
236+
fmt.Println()
237+
if d.Extra.Endpoint != "" {
238+
url := d.Extra.Endpoint
239+
if !strings.HasPrefix(url, "http") {
240+
url = "https://" + url
241+
}
242+
pterm.Info.Printf("Live at: %s\n", url)
243+
}
244+
fmt.Println()
245+
pterm.Println(pterm.Gray(" View logs: createos deployments logs"))
246+
pterm.Println(pterm.Gray(" Redeploy: createos deploy"))
247+
return nil
248+
case "failed", "error", "cancelled":
249+
spinner.Fail(fmt.Sprintf("Deployment failed (v%d)", d.VersionNumber))
250+
fmt.Println()
251+
pterm.Println(pterm.Gray(" View build logs: createos deployments build-logs"))
252+
return fmt.Errorf("deployment %s failed with status: %s", d.ID, d.Status)
253+
default:
254+
spinner.UpdateText(fmt.Sprintf("Deploying (v%d) — %s...", d.VersionNumber, d.Status))
255+
}
256+
}
257+
}
258+
}
259+
260+
// createZip creates a zip archive of the directory, excluding default ignore patterns.
261+
func createZip(w io.Writer, srcDir string) error {
262+
zw := zip.NewWriter(w)
263+
defer zw.Close() //nolint:errcheck
264+
265+
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
266+
if err != nil {
267+
return err
268+
}
269+
270+
relPath, err := filepath.Rel(srcDir, path)
271+
if err != nil {
272+
return err
273+
}
274+
275+
// Skip root
276+
if relPath == "." {
277+
return nil
278+
}
279+
280+
// Check ignore patterns
281+
baseName := filepath.Base(relPath)
282+
for _, pattern := range defaultIgnorePatterns {
283+
if matched, _ := filepath.Match(pattern, baseName); matched {
284+
if info.IsDir() {
285+
return filepath.SkipDir
286+
}
287+
return nil
288+
}
289+
}
290+
291+
// Skip symlinks
292+
if info.Mode()&os.ModeSymlink != 0 {
293+
return nil
294+
}
295+
296+
if info.IsDir() {
297+
return nil
298+
}
299+
300+
// Skip files larger than 10MB individually
301+
if info.Size() > 10*1024*1024 {
302+
return nil
303+
}
304+
305+
header, err := zip.FileInfoHeader(info)
306+
if err != nil {
307+
return err
308+
}
309+
header.Name = filepath.ToSlash(relPath)
310+
header.Method = zip.Deflate
311+
312+
writer, err := zw.CreateHeader(header)
313+
if err != nil {
314+
return err
315+
}
316+
317+
f, err := os.Open(path) //nolint:gosec
318+
if err != nil {
319+
return err
320+
}
321+
defer f.Close() //nolint:errcheck
322+
323+
_, err = io.Copy(writer, f)
324+
return err
325+
})
326+
}

cmd/root/root.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/NodeOps-app/createos-cli/cmd/ask"
1212
"github.com/NodeOps-app/createos-cli/cmd/auth"
1313
"github.com/NodeOps-app/createos-cli/cmd/cronjobs"
14+
"github.com/NodeOps-app/createos-cli/cmd/deploy"
1415
"github.com/NodeOps-app/createos-cli/cmd/deployments"
1516
"github.com/NodeOps-app/createos-cli/cmd/domains"
1617
"github.com/NodeOps-app/createos-cli/cmd/env"
@@ -139,6 +140,7 @@ func NewApp() *cli.App {
139140
fmt.Println("Available Commands:")
140141
if config.IsLoggedIn() {
141142
fmt.Println(" cronjobs Manage cron jobs for a project")
143+
fmt.Println(" deploy Deploy your project to CreateOS")
142144
fmt.Println(" deployments Manage project deployments")
143145
fmt.Println(" domains Manage custom domains")
144146
fmt.Println(" env Manage environment variables")
@@ -174,6 +176,7 @@ func NewApp() *cli.App {
174176
auth.NewLoginCommand(),
175177
auth.NewLogoutCommand(),
176178
cronjobs.NewCronjobsCommand(),
179+
deploy.NewDeployCommand(),
177180
deployments.NewDeploymentsCommand(),
178181
ask.NewAskCommand(),
179182
domains.NewDomainsCommand(),

0 commit comments

Comments
 (0)