From be9fa9e5af17d9c93633e319da6c4c8e94313dcc Mon Sep 17 00:00:00 2001 From: Naman Date: Wed, 1 Apr 2026 01:05:36 -0700 Subject: [PATCH 1/5] ci: auto-update Homebrew formula on new releases Adds an `update-homebrew` job to the release workflow that: - Downloads SHA256 checksums from the just-published release - Generates an updated Formula/createos.rb with new version + hashes - Pushes to NodeOps-app/homebrew-createos Requires HOMEBREW_TAP_TOKEN secret (PAT with repo scope for the tap repo). Runs after binaries are uploaded, so SHA256 files are always available. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/release.yaml | 81 ++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index d699c4a..ac14170 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -76,3 +76,84 @@ jobs: - name: Upload release assets run: gh release upload "${{ github.ref_name }}" createos-* --clobber + + update-homebrew: + needs: release + runs-on: ubuntu-latest + steps: + - name: Download SHA256 files + env: + GH_TOKEN: ${{ github.token }} + run: | + VERSION="${{ github.ref_name }}" + VERSION_NUM="${VERSION#v}" + BASE="https://github.com/NodeOps-app/createos-cli/releases/download/${VERSION}" + + for target in darwin-arm64 darwin-amd64 linux-arm64 linux-amd64; do + curl -sL "${BASE}/createos-${target}.sha256" > "sha256-${target}.txt" + done + + SHA_DARWIN_ARM64=$(cat sha256-darwin-arm64.txt) + SHA_DARWIN_AMD64=$(cat sha256-darwin-amd64.txt) + SHA_LINUX_ARM64=$(cat sha256-linux-arm64.txt) + SHA_LINUX_AMD64=$(cat sha256-linux-amd64.txt) + + cat > createos.rb << FORMULA + class Createos < Formula + desc "CreateOS CLI - Deploy APIs, manage infrastructure, and monetize Skills" + homepage "https://github.com/NodeOps-app/createos-cli" + version "${VERSION_NUM}" + license "MIT" + + on_macos do + on_arm do + url "https://github.com/NodeOps-app/createos-cli/releases/download/v#{version}/createos-darwin-arm64" + sha256 "${SHA_DARWIN_ARM64}" + end + + on_intel do + url "https://github.com/NodeOps-app/createos-cli/releases/download/v#{version}/createos-darwin-amd64" + sha256 "${SHA_DARWIN_AMD64}" + end + end + + on_linux do + on_arm do + url "https://github.com/NodeOps-app/createos-cli/releases/download/v#{version}/createos-linux-arm64" + sha256 "${SHA_LINUX_ARM64}" + end + + on_intel do + url "https://github.com/NodeOps-app/createos-cli/releases/download/v#{version}/createos-linux-amd64" + sha256 "${SHA_LINUX_AMD64}" + end + end + + def install + binary = Dir["createos-*"].first || "createos" + bin.install binary => "createos" + end + + test do + assert_match "createos", shell_output("#{bin}/createos version 2>&1") + end + end + FORMULA + + # Remove leading whitespace from heredoc + sed -i 's/^ //' createos.rb + + - name: Push to Homebrew tap + env: + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + VERSION="${{ github.ref_name }}" + git clone https://x-access-token:${GH_TOKEN}@github.com/NodeOps-app/homebrew-createos.git tap + cp createos.rb tap/Formula/createos.rb + cd tap + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Formula/createos.rb + git diff --cached --quiet && echo "No changes" && exit 0 + git commit -m "Update createos to ${VERSION}" + git push From 31c1485a81814022758dc4d7e903305923cdf555 Mon Sep 17 00:00:00 2001 From: Naman Date: Wed, 1 Apr 2026 01:20:13 -0700 Subject: [PATCH 2/5] feat: add `createos deploy` command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cmd/deploy/deploy.go | 326 ++++++++++++++++++++++++++++++++++++++++ cmd/root/root.go | 3 + internal/api/methods.go | 56 ++++++- 3 files changed, 384 insertions(+), 1 deletion(-) create mode 100644 cmd/deploy/deploy.go diff --git a/cmd/deploy/deploy.go b/cmd/deploy/deploy.go new file mode 100644 index 0000000..ca905d8 --- /dev/null +++ b/cmd/deploy/deploy.go @@ -0,0 +1,326 @@ +// Package deploy provides the deploy command for creating new deployments. +package deploy + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "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" +) + +const maxZipSize = 50 * 1024 * 1024 // 50 MB + +// defaultIgnorePatterns are files/dirs excluded when zipping for upload. +var defaultIgnorePatterns = []string{ + ".git", + ".gitignore", + ".createos.json", + "node_modules", + ".env", + ".env.*", + "__pycache__", + ".venv", + "venv", + ".DS_Store", + "Thumbs.db", + ".idea", + ".vscode", + "*.swp", + "*.swo", + "target", // Rust + "vendor", // Go (optional, but common to exclude) + "dist", // built output — may need to include for some projects + "coverage", + ".nyc_output", +} + +// NewDeployCommand returns the deploy command. +func NewDeployCommand() *cli.Command { + return &cli.Command{ + Name: "deploy", + Usage: "Deploy your project to CreateOS", + Description: "Creates a new deployment for the current project.\n\n" + + " The deploy method is chosen automatically based on your project type:\n" + + " VCS (GitHub) projects → triggers from the latest commit\n" + + " Upload projects → zips and uploads the current directory\n" + + " Image projects → deploys the specified Docker image\n\n" + + " Link your project first with 'createos init' if you haven't already.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "project", + Usage: "Project ID (auto-detected from .createos.json)", + }, + &cli.StringFlag{ + Name: "branch", + Usage: "Branch to deploy from (VCS projects only, defaults to repo default branch)", + }, + &cli.StringFlag{ + Name: "image", + Usage: "Docker image to deploy (image projects only, e.g. nginx:latest)", + }, + &cli.StringFlag{ + Name: "dir", + Value: ".", + Usage: "Directory to deploy (upload projects only)", + }, + }, + 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") + if projectID == "" { + cfg, err := config.FindProjectConfig() + if err != nil { + return err + } + if cfg == nil { + 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 ") + } + projectID = cfg.ProjectID + } + + project, err := client.GetProject(projectID) + if err != nil { + return err + } + + // Route based on project type + switch { + case c.IsSet("image") || project.Type == "image": + return deployImage(c, client, project) + case project.Type == "upload": + return deployUpload(c, client, project) + default: + // VCS (GitHub) projects and anything else + return deployVCS(c, client, project) + } + }, + } +} + +// deployVCS triggers a deployment from the latest commit on a branch. +func deployVCS(c *cli.Context, client *api.APIClient, project *api.Project) error { + branch := c.String("branch") + + branchLabel := "default branch" + if branch != "" { + branchLabel = branch + } + + pterm.Info.Printf("Deploying %s from %s...\n", project.DisplayName, branchLabel) + + deployment, err := client.TriggerLatestDeployment(project.ID, branch) + if err != nil { + return err + } + + return waitForDeployment(client, project.ID, deployment) +} + +// deployUpload zips the local directory and uploads it. +func deployUpload(c *cli.Context, client *api.APIClient, project *api.Project) error { + dir := c.String("dir") + absDir, err := filepath.Abs(dir) + if err != nil { + return err + } + + info, err := os.Stat(absDir) + if err != nil || !info.IsDir() { + return fmt.Errorf("directory %q not found", dir) + } + + pterm.Info.Printf("Deploying %s from %s...\n", project.DisplayName, absDir) + + // Create temporary zip + zipFile, err := os.CreateTemp("", "createos-deploy-*.zip") + if err != nil { + return fmt.Errorf("could not create temp file: %w", err) + } + defer os.Remove(zipFile.Name()) //nolint:errcheck + defer zipFile.Close() //nolint:errcheck + + spinner, _ := pterm.DefaultSpinner.Start("Packaging files...") + + if err := createZip(zipFile, absDir); err != nil { + spinner.Fail("Packaging failed") + return err + } + + stat, _ := zipFile.Stat() + if stat != nil && stat.Size() > maxZipSize { + spinner.Fail("Package too large") + return fmt.Errorf("deployment package is %d MB (max %d MB)\n\n Tip: check that node_modules, .git, and build artifacts are excluded", + stat.Size()/(1024*1024), maxZipSize/(1024*1024)) + } + + spinner.UpdateText("Uploading...") + + // Close before uploading so the file is flushed + zipFile.Close() //nolint:errcheck + + deployment, err := client.UploadDeploymentZip(project.ID, zipFile.Name()) + if err != nil { + spinner.Fail("Upload failed") + return err + } + + spinner.Success("Uploaded") + + return waitForDeployment(client, project.ID, deployment) +} + +// deployImage deploys a Docker image. +func deployImage(c *cli.Context, client *api.APIClient, project *api.Project) error { + image := c.String("image") + if image == "" { + if !terminal.IsInteractive() { + return fmt.Errorf("please provide a Docker image with --image\n\n Example:\n createos deploy --image nginx:latest") + } + result, err := pterm.DefaultInteractiveTextInput. + WithDefaultText("Docker image (e.g. nginx:latest)"). + Show() + if err != nil || result == "" { + return fmt.Errorf("no image provided") + } + image = result + } + + pterm.Info.Printf("Deploying %s with image %s...\n", project.DisplayName, image) + + deployment, err := client.CreateDeployment(project.ID, map[string]any{ + "image": image, + }) + if err != nil { + return err + } + + return waitForDeployment(client, project.ID, deployment) +} + +// waitForDeployment polls until the deployment succeeds, fails, or times out. +func waitForDeployment(client *api.APIClient, projectID string, deployment *api.Deployment) error { + spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Deploying (v%d)...", deployment.VersionNumber)) + + timeout := time.After(5 * time.Minute) + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + + for { + select { + case <-timeout: + spinner.Warning("Deployment is still in progress — check back with: createos deployments logs") + return nil + case <-ticker.C: + d, err := client.GetDeployment(projectID, deployment.ID) + if err != nil { + continue // transient error, keep polling + } + + switch d.Status { + case "successful", "running", "active", "deployed": + spinner.Success(fmt.Sprintf("Deployed (v%d)", d.VersionNumber)) + fmt.Println() + if d.Extra.Endpoint != "" { + url := d.Extra.Endpoint + if !strings.HasPrefix(url, "http") { + url = "https://" + url + } + pterm.Info.Printf("Live at: %s\n", url) + } + fmt.Println() + pterm.Println(pterm.Gray(" View logs: createos deployments logs")) + pterm.Println(pterm.Gray(" Redeploy: createos deploy")) + return nil + case "failed", "error", "cancelled": + spinner.Fail(fmt.Sprintf("Deployment failed (v%d)", d.VersionNumber)) + fmt.Println() + pterm.Println(pterm.Gray(" View build logs: createos deployments build-logs")) + return fmt.Errorf("deployment %s failed with status: %s", d.ID, d.Status) + default: + spinner.UpdateText(fmt.Sprintf("Deploying (v%d) — %s...", d.VersionNumber, d.Status)) + } + } + } +} + +// createZip creates a zip archive of the directory, excluding default ignore patterns. +func createZip(w io.Writer, srcDir string) error { + zw := zip.NewWriter(w) + defer zw.Close() //nolint:errcheck + + return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + + // Skip root + if relPath == "." { + return nil + } + + // Check ignore patterns + baseName := filepath.Base(relPath) + for _, pattern := range defaultIgnorePatterns { + if matched, _ := filepath.Match(pattern, baseName); matched { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + } + + // Skip symlinks + if info.Mode()&os.ModeSymlink != 0 { + return nil + } + + if info.IsDir() { + return nil + } + + // Skip files larger than 10MB individually + if info.Size() > 10*1024*1024 { + return nil + } + + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + header.Name = filepath.ToSlash(relPath) + header.Method = zip.Deflate + + writer, err := zw.CreateHeader(header) + if err != nil { + return err + } + + f, err := os.Open(path) //nolint:gosec + if err != nil { + return err + } + defer f.Close() //nolint:errcheck + + _, err = io.Copy(writer, f) + return err + }) +} diff --git a/cmd/root/root.go b/cmd/root/root.go index 7c4cdae..28ff673 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -11,6 +11,7 @@ import ( "github.com/NodeOps-app/createos-cli/cmd/ask" "github.com/NodeOps-app/createos-cli/cmd/auth" "github.com/NodeOps-app/createos-cli/cmd/cronjobs" + "github.com/NodeOps-app/createos-cli/cmd/deploy" "github.com/NodeOps-app/createos-cli/cmd/deployments" "github.com/NodeOps-app/createos-cli/cmd/domains" "github.com/NodeOps-app/createos-cli/cmd/env" @@ -139,6 +140,7 @@ func NewApp() *cli.App { fmt.Println("Available Commands:") if config.IsLoggedIn() { fmt.Println(" cronjobs Manage cron jobs for a project") + fmt.Println(" deploy Deploy your project to CreateOS") fmt.Println(" deployments Manage project deployments") fmt.Println(" domains Manage custom domains") fmt.Println(" env Manage environment variables") @@ -174,6 +176,7 @@ func NewApp() *cli.App { auth.NewLoginCommand(), auth.NewLogoutCommand(), cronjobs.NewCronjobsCommand(), + deploy.NewDeployCommand(), deployments.NewDeploymentsCommand(), ask.NewAskCommand(), domains.NewDomainsCommand(), diff --git a/internal/api/methods.go b/internal/api/methods.go index 3a33516..c6a8220 100644 --- a/internal/api/methods.go +++ b/internal/api/methods.go @@ -660,7 +660,7 @@ func (c *APIClient) GetTemplatePurchaseDownloadURL(purchaseID string) (string, e return result.Data.DownloadURI, nil } -// CreateDeployment creates a new deployment for a project. +// CreateDeployment creates a new deployment for an image-type project. func (c *APIClient) CreateDeployment(projectID string, body map[string]any) (*Deployment, error) { var result Response[Deployment] resp, err := c.Client.R(). @@ -675,3 +675,57 @@ func (c *APIClient) CreateDeployment(projectID string, body map[string]any) (*De } return &result.Data, nil } + +// TriggerLatestDeployment triggers a new deployment from the latest commit on +// the given branch. If branch is empty the repository's default branch is used. +// Only available for VCS projects. +func (c *APIClient) TriggerLatestDeployment(projectID, branch string) (*Deployment, error) { + body := map[string]any{} + if branch != "" { + body["branch"] = branch + } + var result Response[Deployment] + resp, err := c.Client.R(). + SetResult(&result). + SetBody(body). + Post("/v1/projects/" + projectID + "/deployments/trigger-latest") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &result.Data, nil +} + +// UploadDeploymentZip creates a new deployment by uploading a ZIP file. +// Only available for upload-type projects. +func (c *APIClient) UploadDeploymentZip(projectID, zipPath string) (*Deployment, error) { + var result Response[Deployment] + resp, err := c.Client.R(). + SetResult(&result). + SetFile("file", zipPath). + Post("/v1/projects/" + projectID + "/deployments/upload/zip") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &result.Data, nil +} + +// GetDeployment returns a single deployment by ID. +func (c *APIClient) GetDeployment(projectID, deploymentID string) (*Deployment, error) { + var result Response[Deployment] + resp, err := c.Client.R(). + SetResult(&result). + Get("/v1/projects/" + projectID + "/deployments/" + deploymentID) + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &result.Data, nil +} From 78a2202020bc0e1be50171ac1f4474e78572694b Mon Sep 17 00:00:00 2001 From: Bhautik Date: Wed, 1 Apr 2026 14:12:14 +0530 Subject: [PATCH 3/5] Update release.yaml --- .github/workflows/release.yaml | 82 +--------------------------------- 1 file changed, 1 insertion(+), 81 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ac14170..317ae1c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -76,84 +76,4 @@ jobs: - name: Upload release assets run: gh release upload "${{ github.ref_name }}" createos-* --clobber - - update-homebrew: - needs: release - runs-on: ubuntu-latest - steps: - - name: Download SHA256 files - env: - GH_TOKEN: ${{ github.token }} - run: | - VERSION="${{ github.ref_name }}" - VERSION_NUM="${VERSION#v}" - BASE="https://github.com/NodeOps-app/createos-cli/releases/download/${VERSION}" - - for target in darwin-arm64 darwin-amd64 linux-arm64 linux-amd64; do - curl -sL "${BASE}/createos-${target}.sha256" > "sha256-${target}.txt" - done - - SHA_DARWIN_ARM64=$(cat sha256-darwin-arm64.txt) - SHA_DARWIN_AMD64=$(cat sha256-darwin-amd64.txt) - SHA_LINUX_ARM64=$(cat sha256-linux-arm64.txt) - SHA_LINUX_AMD64=$(cat sha256-linux-amd64.txt) - - cat > createos.rb << FORMULA - class Createos < Formula - desc "CreateOS CLI - Deploy APIs, manage infrastructure, and monetize Skills" - homepage "https://github.com/NodeOps-app/createos-cli" - version "${VERSION_NUM}" - license "MIT" - - on_macos do - on_arm do - url "https://github.com/NodeOps-app/createos-cli/releases/download/v#{version}/createos-darwin-arm64" - sha256 "${SHA_DARWIN_ARM64}" - end - - on_intel do - url "https://github.com/NodeOps-app/createos-cli/releases/download/v#{version}/createos-darwin-amd64" - sha256 "${SHA_DARWIN_AMD64}" - end - end - - on_linux do - on_arm do - url "https://github.com/NodeOps-app/createos-cli/releases/download/v#{version}/createos-linux-arm64" - sha256 "${SHA_LINUX_ARM64}" - end - - on_intel do - url "https://github.com/NodeOps-app/createos-cli/releases/download/v#{version}/createos-linux-amd64" - sha256 "${SHA_LINUX_AMD64}" - end - end - - def install - binary = Dir["createos-*"].first || "createos" - bin.install binary => "createos" - end - - test do - assert_match "createos", shell_output("#{bin}/createos version 2>&1") - end - end - FORMULA - - # Remove leading whitespace from heredoc - sed -i 's/^ //' createos.rb - - - name: Push to Homebrew tap - env: - GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} - run: | - VERSION="${{ github.ref_name }}" - git clone https://x-access-token:${GH_TOKEN}@github.com/NodeOps-app/homebrew-createos.git tap - cp createos.rb tap/Formula/createos.rb - cd tap - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add Formula/createos.rb - git diff --cached --quiet && echo "No changes" && exit 0 - git commit -m "Update createos to ${VERSION}" - git push + From 1412d6358a47f0ba2407213a600799b8145e1eec Mon Sep 17 00:00:00 2001 From: Bhautik Date: Wed, 1 Apr 2026 21:30:31 +0530 Subject: [PATCH 4/5] feat: addressed suggested changes --- .gitignore | 2 + cmd/deploy/deploy.go | 182 +++++++++++++++++++++++++++++------ cmd/deployments/retrigger.go | 2 +- cmd/root/root.go | 2 +- internal/api/methods.go | 63 ++++++------ 5 files changed, 190 insertions(+), 61 deletions(-) diff --git a/.gitignore b/.gitignore index 3d322d8..4d03132 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .claude .createos.json .env.* +cos +build.sh diff --git a/cmd/deploy/deploy.go b/cmd/deploy/deploy.go index ca905d8..0094ae8 100644 --- a/cmd/deploy/deploy.go +++ b/cmd/deploy/deploy.go @@ -22,26 +22,60 @@ const maxZipSize = 50 * 1024 * 1024 // 50 MB // defaultIgnorePatterns are files/dirs excluded when zipping for upload. var defaultIgnorePatterns = []string{ + // Version control ".git", - ".gitignore", + + // CreateOS config ".createos.json", - "node_modules", + + // Secrets and credentials ".env", ".env.*", - "__pycache__", + "*.pem", + "*.key", + "*.p12", + "*.pfx", + "*.crt", + "*.cer", + "*.jks", + ".npmrc", + ".pypirc", + "credentials.json", + "service-account*.json", + + // Dependencies + "node_modules", ".venv", "venv", + "vendor", // Go + + // Build artifacts + "target", // Rust + "coverage", + ".nyc_output", + ".pytest_cache", + "__pycache__", + + // Database files + "*.sqlite", + "*.sqlite3", + "*.db", + + // Log files + "*.log", + + // Terraform state + ".terraform", + "terraform.tfstate", + "terraform.tfstate.*", + + // OS/editor noise ".DS_Store", "Thumbs.db", ".idea", ".vscode", "*.swp", "*.swo", - "target", // Rust - "vendor", // Go (optional, but common to exclude) - "dist", // built output — may need to include for some projects - "coverage", - ".nyc_output", } // NewDeployCommand returns the deploy command. @@ -97,24 +131,44 @@ func NewDeployCommand() *cli.Command { return err } + // Validate flag/type combinations + isVCS := project.Type == "vcs" || project.Type == "githubImport" + if c.IsSet("branch") && !isVCS { + return fmt.Errorf("--branch is only supported for Git-connected projects (this project uses %q deployment)", project.Type) + } + if c.IsSet("dir") && project.Type != "upload" { + return fmt.Errorf("--dir is only supported for upload projects (this project uses %q deployment)", project.Type) + } + // Route based on project type switch { case c.IsSet("image") || project.Type == "image": return deployImage(c, client, project) case project.Type == "upload": return deployUpload(c, client, project) - default: - // VCS (GitHub) projects and anything else + case project.Type == "vcs" || project.Type == "githubImport": return deployVCS(c, client, project) + default: + return fmt.Errorf("unsupported project type %q — please deploy from the dashboard", project.Type) } }, } } -// deployVCS triggers a deployment from the latest commit on a branch. +// deployVCS triggers a new deployment from the latest commit, optionally on a specific branch. func deployVCS(c *cli.Context, client *api.APIClient, project *api.Project) error { branch := c.String("branch") + if branch == "" && terminal.IsInteractive() { + result, err := pterm.DefaultInteractiveTextInput. + WithDefaultText("Branch to deploy (leave empty for default branch)"). + Show() + if err != nil { + return err + } + branch = strings.TrimSpace(result) + } + branchLabel := "default branch" if branch != "" { branchLabel = branch @@ -170,7 +224,9 @@ func deployUpload(c *cli.Context, client *api.APIClient, project *api.Project) e spinner.UpdateText("Uploading...") // Close before uploading so the file is flushed - zipFile.Close() //nolint:errcheck + if err := zipFile.Close(); err != nil { //nolint:govet + return fmt.Errorf("could not flush deployment package: %w", err) + } deployment, err := client.UploadDeploymentZip(project.ID, zipFile.Name()) if err != nil { @@ -211,18 +267,22 @@ func deployImage(c *cli.Context, client *api.APIClient, project *api.Project) er return waitForDeployment(client, project.ID, deployment) } -// waitForDeployment polls until the deployment succeeds, fails, or times out. +// waitForDeployment streams build logs while building, then runtime logs on success. func waitForDeployment(client *api.APIClient, projectID string, deployment *api.Deployment) error { - spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Deploying (v%d)...", deployment.VersionNumber)) + fmt.Println() - timeout := time.After(5 * time.Minute) - ticker := time.NewTicker(3 * time.Second) + timeout := time.After(10 * time.Minute) + ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() + lastBuildLine := 0 + headerPrinted := false + for { select { case <-timeout: - spinner.Warning("Deployment is still in progress — check back with: createos deployments logs") + fmt.Println() + pterm.Warning.Println("Deployment is still in progress — check back with: createos deployments build-logs") return nil case <-ticker.C: d, err := client.GetDeployment(projectID, deployment.ID) @@ -230,9 +290,26 @@ func waitForDeployment(client *api.APIClient, projectID string, deployment *api. continue // transient error, keep polling } + if !headerPrinted && d.VersionNumber > 0 { + fmt.Printf(" Building v%d...\n\n", d.VersionNumber) + headerPrinted = true + } + + // Stream new build log lines + buildLogs, err := client.GetDeploymentBuildLogs(projectID, deployment.ID) + if err == nil { + for _, e := range buildLogs { + if e.LineNumber > lastBuildLine { + fmt.Println(e.Log) + lastBuildLine = e.LineNumber + } + } + } + switch d.Status { case "successful", "running", "active", "deployed": - spinner.Success(fmt.Sprintf("Deployed (v%d)", d.VersionNumber)) + fmt.Println() + pterm.Success.Printf("Deployed (v%d)\n", d.VersionNumber) fmt.Println() if d.Extra.Endpoint != "" { url := d.Extra.Endpoint @@ -240,28 +317,73 @@ func waitForDeployment(client *api.APIClient, projectID string, deployment *api. url = "https://" + url } pterm.Info.Printf("Live at: %s\n", url) + fmt.Println() } - fmt.Println() - pterm.Println(pterm.Gray(" View logs: createos deployments logs")) - pterm.Println(pterm.Gray(" Redeploy: createos deploy")) + // Stream initial runtime logs + streamRuntimeLogs(client, projectID, deployment.ID) return nil case "failed", "error", "cancelled": - spinner.Fail(fmt.Sprintf("Deployment failed (v%d)", d.VersionNumber)) fmt.Println() - pterm.Println(pterm.Gray(" View build logs: createos deployments build-logs")) + pterm.Error.Printf("Deployment failed (v%d)\n", d.VersionNumber) return fmt.Errorf("deployment %s failed with status: %s", d.ID, d.Status) - default: - spinner.UpdateText(fmt.Sprintf("Deploying (v%d) — %s...", d.VersionNumber, d.Status)) } } } } -// createZip creates a zip archive of the directory, excluding default ignore patterns. +// streamRuntimeLogs fetches and prints runtime logs after a successful deployment. +func streamRuntimeLogs(client *api.APIClient, projectID, deploymentID string) { + logs, err := client.GetDeploymentLogs(projectID, deploymentID) + if err != nil || logs == "" { + pterm.Println(pterm.Gray(" View logs: createos deployments logs")) + pterm.Println(pterm.Gray(" Redeploy: createos deploy")) + return + } + fmt.Println(" Runtime logs:") + fmt.Println() + for _, line := range strings.Split(strings.TrimRight(logs, "\n"), "\n") { + fmt.Println(" " + line) + } + fmt.Println() + pterm.Println(pterm.Gray(" Follow logs: createos deployments logs --follow")) + pterm.Println(pterm.Gray(" Redeploy: createos deploy")) +} + +// loadGitignorePatterns reads .gitignore from srcDir and returns usable patterns. +func loadGitignorePatterns(srcDir string) []string { + data, err := os.ReadFile(filepath.Join(srcDir, ".gitignore")) //nolint:gosec + if err != nil { + return nil + } + var patterns []string + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + // Skip comments and empty lines + if line == "" || strings.HasPrefix(line, "#") { + continue + } + // Skip negations — we don't support re-including files + if strings.HasPrefix(line, "!") { + continue + } + // Strip trailing slash (directory marker) — we handle dirs via SkipDir + line = strings.TrimSuffix(line, "/") + // Strip leading slash (root-anchored) — use basename matching + line = strings.TrimPrefix(line, "/") + if line != "" { + patterns = append(patterns, line) + } + } + return patterns +} + +// createZip creates a zip archive of the directory, excluding default ignore patterns and .gitignore rules. func createZip(w io.Writer, srcDir string) error { zw := zip.NewWriter(w) defer zw.Close() //nolint:errcheck + ignorePatterns := append(defaultIgnorePatterns, loadGitignorePatterns(srcDir)...) //nolint:gocritic + return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err @@ -277,10 +399,12 @@ func createZip(w io.Writer, srcDir string) error { return nil } - // Check ignore patterns + // Check ignore patterns against basename and full relative path baseName := filepath.Base(relPath) - for _, pattern := range defaultIgnorePatterns { - if matched, _ := filepath.Match(pattern, baseName); matched { + for _, pattern := range ignorePatterns { + matchedBase, _ := filepath.Match(pattern, baseName) + matchedRel, _ := filepath.Match(pattern, filepath.ToSlash(relPath)) + if matchedBase || matchedRel { if info.IsDir() { return filepath.SkipDir } diff --git a/cmd/deployments/retrigger.go b/cmd/deployments/retrigger.go index fbd65a2..97c0cc7 100644 --- a/cmd/deployments/retrigger.go +++ b/cmd/deployments/retrigger.go @@ -28,7 +28,7 @@ func newDeploymentRetriggerCommand() *cli.Command { return err } - if err := client.RetriggerDeployment(projectID, deploymentID); err != nil { + if _, err := client.RetriggerDeployment(projectID, deploymentID, ""); err != nil { return err } diff --git a/cmd/root/root.go b/cmd/root/root.go index 28ff673..a937bcc 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -83,7 +83,7 @@ func NewApp() *cli.App { } cmd := c.Args().First() - if cmd == "" || cmd == "login" || cmd == "logout" || cmd == "version" || cmd == "ask" || cmd == "init" || cmd == "upgrade" { + if cmd == "" || cmd == "login" || cmd == "logout" || cmd == "version" || cmd == "ask" || cmd == "upgrade" { return nil } diff --git a/internal/api/methods.go b/internal/api/methods.go index c6a8220..2120b1a 100644 --- a/internal/api/methods.go +++ b/internal/api/methods.go @@ -258,17 +258,42 @@ func (c *APIClient) GetDeploymentBuildLogs(projectID, deploymentID string) ([]Bu return result.Data, nil } -// RetriggerDeployment triggers a new deployment run. -func (c *APIClient) RetriggerDeployment(projectID, deploymentID string) error { - resp, err := c.Client.R(). - Post("/v1/projects/" + projectID + "/deployments/" + deploymentID + "/retrigger") +// RetriggerDeployment triggers a new deployment run and returns the new deployment. +// branch is optional — pass empty string to use the branch from the existing deployment. +func (c *APIClient) RetriggerDeployment(projectID, deploymentID, branch string) (*Deployment, error) { + var result Response[Deployment] + req := c.Client.R().SetResult(&result) + if branch != "" { + req = req.SetQueryParam("branch", branch) + } + resp, err := req.Post("/v1/projects/" + projectID + "/deployments/" + deploymentID + "/retrigger") if err != nil { - return err + return nil, err } if resp.IsError() { - return ParseAPIError(resp.StatusCode(), resp.Body()) + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) } - return nil + return &result.Data, nil +} + +// TriggerLatestDeployment triggers a new deployment from the latest commit. +// branch is optional — passed as a query param; omit to use the project's default branch. +func (c *APIClient) TriggerLatestDeployment(projectID, branch string) (*Deployment, error) { + var result Response[struct { + ID string `json:"id"` + }] + req := c.Client.R().SetResult(&result) + if branch != "" { + req = req.SetQueryParam("branch", branch) + } + resp, err := req.Post("/v1/projects/" + projectID + "/trigger-latest") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return c.GetDeployment(projectID, result.Data.ID) } // CancelDeployment cancels a running deployment. @@ -676,28 +701,6 @@ func (c *APIClient) CreateDeployment(projectID string, body map[string]any) (*De return &result.Data, nil } -// TriggerLatestDeployment triggers a new deployment from the latest commit on -// the given branch. If branch is empty the repository's default branch is used. -// Only available for VCS projects. -func (c *APIClient) TriggerLatestDeployment(projectID, branch string) (*Deployment, error) { - body := map[string]any{} - if branch != "" { - body["branch"] = branch - } - var result Response[Deployment] - resp, err := c.Client.R(). - SetResult(&result). - SetBody(body). - Post("/v1/projects/" + projectID + "/deployments/trigger-latest") - if err != nil { - return nil, err - } - if resp.IsError() { - return nil, ParseAPIError(resp.StatusCode(), resp.Body()) - } - return &result.Data, nil -} - // UploadDeploymentZip creates a new deployment by uploading a ZIP file. // Only available for upload-type projects. func (c *APIClient) UploadDeploymentZip(projectID, zipPath string) (*Deployment, error) { @@ -705,7 +708,7 @@ func (c *APIClient) UploadDeploymentZip(projectID, zipPath string) (*Deployment, resp, err := c.Client.R(). SetResult(&result). SetFile("file", zipPath). - Post("/v1/projects/" + projectID + "/deployments/upload/zip") + Put("/v1/projects/" + projectID + "/deployments/upload-zip") if err != nil { return nil, err } From 1e7b30f3118712a17bea247fa1e480e5e1ba55e6 Mon Sep 17 00:00:00 2001 From: Bhautik Date: Wed, 1 Apr 2026 21:32:25 +0530 Subject: [PATCH 5/5] feat: addressed suggested changes --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.md b/README.md index 67a0d01..05746a3 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,33 @@ createos --help | `createos projects get` | Get project details | | `createos projects delete` | Delete a project | +### Deploy + +| Command | Description | +| ----------------------- | -------------------------------------------------------- | +| `createos deploy` | Deploy your project (auto-detects type) | + +**Deploy flags:** + +| Flag | Description | +| ------------ | ------------------------------------------------------------------ | +| `--project` | Project ID (auto-detected from `.createos.json`) | +| `--branch` | Branch to deploy from (VCS/GitHub projects only) | +| `--image` | Docker image to deploy (image projects only, e.g. `nginx:latest`) | +| `--dir` | Directory to zip and upload (upload projects only, default: `.`) | + +**Deploy behaviour by project type:** + +| Project type | What happens | +| -------------- | ------------------------------------------------------------------------------------- | +| VCS / GitHub | Triggers from the latest commit. Prompts for branch interactively if not provided. | +| Upload | Zips the local directory (respects `.gitignore`), uploads, and streams build logs. | +| Image | Deploys the specified Docker image. | + +**Files excluded from upload zip:** + +Sensitive and noisy files are always excluded: `.env`, `.env.*`, secrets/keys (`*.pem`, `*.key`, `*.p12`, etc.), `node_modules`, build artifacts (`target`, `coverage`, etc.), OS/editor files, and anything listed in your project's `.gitignore`. + ### Deployments | Command | Description | @@ -235,6 +262,12 @@ createos --help All commands accept flags so they work in CI and non-interactive environments. Destructive commands require `--force` to skip the confirmation prompt. ```bash +# Deploy +createos deploy # upload project — zips current dir +createos deploy --dir ./dist # upload project — zip a specific dir +createos deploy --branch main # VCS project — deploy from main +createos deploy --image nginx:latest # image project + # Projects createos projects get --project createos projects delete --project --force