diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8127cb0..ec630fc 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -68,6 +68,9 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - uses: actions/checkout@v4 + with: + repository: NodeOps-app/homebrew-tap + token: ${{ secrets.TAP_GITHUB_TOKEN }} - uses: actions/download-artifact@v4 with: @@ -77,16 +80,7 @@ jobs: - name: Upload release assets run: gh release upload "${{ github.ref_name }}" createos-* --clobber - update-tap: - needs: release - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - repository: NodeOps-app/homebrew-tap - token: ${{ secrets.TAP_GITHUB_TOKEN }} - - - name: Update formula + - name: Update homebrew formula run: | VERSION="${{ github.ref_name }}" VERSION_NUM="${VERSION#v}" 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/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 diff --git a/cmd/deploy/deploy.go b/cmd/deploy/deploy.go new file mode 100644 index 0000000..0094ae8 --- /dev/null +++ b/cmd/deploy/deploy.go @@ -0,0 +1,450 @@ +// 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{ + // Version control + ".git", + + // CreateOS config + ".createos.json", + + // Secrets and credentials + ".env", + ".env.*", + "*.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", +} + +// 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 + } + + // 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) + 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 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 + } + + 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 + 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 { + 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 streams build logs while building, then runtime logs on success. +func waitForDeployment(client *api.APIClient, projectID string, deployment *api.Deployment) error { + fmt.Println() + + timeout := time.After(10 * time.Minute) + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + lastBuildLine := 0 + headerPrinted := false + + for { + select { + case <-timeout: + 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) + if err != nil { + 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": + fmt.Println() + pterm.Success.Printf("Deployed (v%d)\n", 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() + } + // Stream initial runtime logs + streamRuntimeLogs(client, projectID, deployment.ID) + return nil + case "failed", "error", "cancelled": + fmt.Println() + pterm.Error.Printf("Deployment failed (v%d)\n", d.VersionNumber) + return fmt.Errorf("deployment %s failed with status: %s", d.ID, d.Status) + } + } + } +} + +// 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 + } + + relPath, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + + // Skip root + if relPath == "." { + return nil + } + + // Check ignore patterns against basename and full relative path + baseName := filepath.Base(relPath) + 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 + } + 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/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 7c4cdae..a937bcc 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" @@ -82,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 } @@ -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..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. @@ -660,7 +685,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 +700,35 @@ func (c *APIClient) CreateDeployment(projectID string, body map[string]any) (*De } 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). + Put("/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 +}