Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"name": "major",
"source": "./plugins/major",
"description": "Use the Major platform agentically via Claude Code",
"version": "1.0.3"
"version": "1.0.4"
}
]
}
11 changes: 11 additions & 0 deletions clients/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,17 @@ func (c *Client) SetApplicationEnvironment(applicationID, environmentID string)
return &resp, nil
}

// GetApplicationInfo retrieves application info including deploy status and URL
func (c *Client) GetApplicationInfo(applicationID string) (*GetApplicationInfoResponse, error) {
var resp GetApplicationInfoResponse
path := fmt.Sprintf("/applications/%s/info", applicationID)
err := c.doRequest("GET", path, nil, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}

// GetApplicationForLink retrieves application info needed for the link command
func (c *Client) GetApplicationForLink(applicationID string) (*GetApplicationForLinkResponse, error) {
var resp GetApplicationForLinkResponse
Expand Down
9 changes: 9 additions & 0 deletions clients/api/structs.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,15 @@ type SetEnvironmentChoiceResponse struct {
EnvironmentName string `json:"environmentName,omitempty"`
}

// GetApplicationInfoResponse represents the response from GET /applications/:applicationId/info
type GetApplicationInfoResponse struct {
Error *AppErrorDetail `json:"error,omitempty"`
ApplicationID string `json:"applicationId,omitempty"`
Name string `json:"name,omitempty"`
AppURL *string `json:"appUrl,omitempty"`
DeployStatus string `json:"deployStatus,omitempty"`
}

// GetApplicationForLinkResponse represents the response from GET /application/:applicationId/link-info
type GetApplicationForLinkResponse struct {
Error *AppErrorDetail `json:"error,omitempty"`
Expand Down
31 changes: 31 additions & 0 deletions clients/git/client.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package git

import (
"context"
"errors"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"time"

clierrors "github.com/major-technology/cli/errors"
)
Expand Down Expand Up @@ -225,6 +228,34 @@ func Pull(repoDir string) error {
return nil
}

// IsBehindRemote checks if the local branch is behind origin/main.
// Returns whether it's behind, how many commits behind, and any error.
// Uses a 5-second timeout to avoid blocking if the network is unavailable.
func IsBehindRemote() (bool, int, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

// Fetch latest from origin
fetchCmd := exec.CommandContext(ctx, "git", "fetch", "origin", "main", "--quiet")
if err := fetchCmd.Run(); err != nil {
return false, 0, err
}

// Count commits local is behind
revListCmd := exec.Command("git", "rev-list", "--count", "HEAD..origin/main")
output, err := revListCmd.Output()
if err != nil {
return false, 0, err
}

count, err := strconv.Atoi(strings.TrimSpace(string(output)))
if err != nil {
return false, 0, err
}

return count > 0, count, nil
}

// GetCurrentGithubUser attempts to retrieve the GitHub username of the current user
// by checking SSH authentication and git configuration.
func GetCurrentGithubUser() (string, error) {
Expand Down
1 change: 1 addition & 0 deletions cmd/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ func init() {
Cmd.AddCommand(configureCmd)
Cmd.AddCommand(createCmd)
Cmd.AddCommand(deployCmd)
Cmd.AddCommand(deployStatusCmd)
Cmd.AddCommand(infoCmd)
Cmd.AddCommand(listCmd)
Cmd.AddCommand(startCmd)
Expand Down
54 changes: 47 additions & 7 deletions cmd/app/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package app

import (
"fmt"
"os"
"regexp"
"strings"
"time"
Expand All @@ -10,6 +11,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
xt "github.com/charmbracelet/x/term"
"github.com/major-technology/cli/clients/git"
"github.com/major-technology/cli/errors"
"github.com/major-technology/cli/singletons"
Expand All @@ -20,11 +22,13 @@ import (
var (
flagDeployMessage string
flagDeploySlug string
flagDeployNoWait bool
)

func init() {
deployCmd.Flags().StringVarP(&flagDeployMessage, "message", "m", "", "Commit message for uncommitted changes (skips interactive prompt)")
deployCmd.Flags().StringVar(&flagDeploySlug, "slug", "", "URL slug for first deploy (skips interactive prompt)")
deployCmd.Flags().BoolVar(&flagDeployNoWait, "no-wait", false, "Don't wait for deployment to complete (returns immediately after triggering)")
}

// deployCmd represents the deploy command
Expand Down Expand Up @@ -110,15 +114,15 @@ func runDeploy(cobraCmd *cobra.Command) error {
}

// Prompt for deploy URL slug on first deploy
appURL := urlSlug
if appURL == "" {
deploySlug := urlSlug
if deploySlug == "" {
if flagDeploySlug != "" {
if err := validateSlug(flagDeploySlug); err != nil {
return fmt.Errorf("invalid slug: %w", err)
}
appURL = flagDeploySlug
deploySlug = flagDeploySlug
} else {
appURL, err = promptForDeployURL(cobraCmd)
deploySlug, err = promptForDeployURL(cobraCmd)
if err != nil {
return errors.WrapError("failed to collect deploy URL", err)
}
Expand All @@ -127,15 +131,26 @@ func runDeploy(cobraCmd *cobra.Command) error {

// Call API to create new version
apiClient := singletons.GetAPIClient()
resp, err := apiClient.CreateApplicationVersion(applicationID, appURL)
resp, err := apiClient.CreateApplicationVersion(applicationID, deploySlug)
if err != nil {
return err
}

cobraCmd.Printf("\n✓ Version created: %s\n", resp.VersionID)

// Poll deployment status with beautiful UI
finalStatus, deploymentError, appURL, err := pollDeploymentStatus(applicationID, organizationID, resp.VersionID)
// If --no-wait, return immediately
if flagDeployNoWait {
cobraCmd.Printf("Deployment started. Use 'major app deploy-status --version-id %s' to check status.\n", resp.VersionID)
return nil
}

// Poll deployment status -- use simple polling if not a TTY, Bubble Tea otherwise
var finalStatus, deploymentError, appURL string
if xt.IsTerminal(os.Stdout.Fd()) {
finalStatus, deploymentError, appURL, err = pollDeploymentStatus(applicationID, organizationID, resp.VersionID)
} else {
finalStatus, deploymentError, appURL, err = pollDeploymentStatusSimple(cobraCmd, applicationID, organizationID, resp.VersionID)
}
if err != nil {
return errors.WrapError("failed to track deployment status", err)
}
Expand Down Expand Up @@ -411,6 +426,31 @@ func validateSlug(s string) error {
return nil
}

// pollDeploymentStatusSimple polls deployment status using simple text output (for non-TTY environments).
func pollDeploymentStatusSimple(cobraCmd *cobra.Command, applicationID, organizationID, versionID string) (string, string, string, error) {
apiClient := singletons.GetAPIClient()
lastStatus := ""

for {
resp, err := apiClient.GetVersionStatus(applicationID, organizationID, versionID)
if err != nil {
return "", "", "", err
}

if resp.Status != lastStatus {
statusText, _ := getStatusDisplay(resp.Status)
cobraCmd.Printf("Status: %s\n", statusText)
lastStatus = resp.Status
}

if isTerminalStatus(resp.Status) {
return resp.Status, resp.DeploymentError, resp.AppURL, nil
}

time.Sleep(2 * time.Second)
}
}

// promptForDeployURL prompts the user for a deploy URL slug on first deploy.
func promptForDeployURL(cobraCmd *cobra.Command) (string, error) {
cfg := singletons.GetConfig()
Expand Down
56 changes: 56 additions & 0 deletions cmd/app/deploy_status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package app

import (
"encoding/json"
"fmt"

"github.com/major-technology/cli/errors"
"github.com/major-technology/cli/singletons"
"github.com/spf13/cobra"
)

var flagDeployStatusVersionID string

var deployStatusCmd = &cobra.Command{
Use: "deploy-status",
Short: "Check the status of a deployment",
Long: `Returns the current deployment status for a given version ID.`,
RunE: func(cobraCmd *cobra.Command, args []string) error {
return runDeployStatus(cobraCmd)
},
}

func init() {
deployStatusCmd.Flags().StringVar(&flagDeployStatusVersionID, "version-id", "", "Version ID to check")
deployStatusCmd.MarkFlagRequired("version-id")
}

func runDeployStatus(cobraCmd *cobra.Command) error {
applicationID, organizationID, _, err := getApplicationAndOrgID()
if err != nil {
return errors.WrapError("failed to get application ID", err)
}

apiClient := singletons.GetAPIClient()
resp, err := apiClient.GetVersionStatus(applicationID, organizationID, flagDeployStatusVersionID)
if err != nil {
return errors.WrapError("failed to get deployment status", err)
}

type statusJSON struct {
Status string `json:"status"`
DeploymentError string `json:"deploymentError,omitempty"`
AppURL string `json:"appUrl,omitempty"`
}

data, err := json.Marshal(statusJSON{
Status: resp.Status,
DeploymentError: resp.DeploymentError,
AppURL: resp.AppURL,
})
if err != nil {
return err
}
fmt.Fprintln(cobraCmd.OutOrStdout(), string(data))
return nil
}
50 changes: 47 additions & 3 deletions cmd/app/info.go
Original file line number Diff line number Diff line change
@@ -1,28 +1,72 @@
package app

import (
"encoding/json"
"fmt"

"github.com/major-technology/cli/singletons"
"github.com/spf13/cobra"
)

var flagInfoJSON bool

// infoCmd represents the info command
var infoCmd = &cobra.Command{
Use: "info",
Short: "Display information about the current application",
Long: `Display information about the application in the current directory, including the application ID.`,
Long: `Display information about the application in the current directory, including the application ID, deploy status, and URL.`,
RunE: func(cmd *cobra.Command, args []string) error {
return runInfo(cmd)
},
}

func init() {
infoCmd.Flags().BoolVar(&flagInfoJSON, "json", false, "Output in JSON format")
}

func runInfo(cmd *cobra.Command) error {
// Get application ID
applicationID, err := getApplicationID()
if err != nil {
return err
}

// Print only the application ID
cmd.Printf("Application ID: %s\n", applicationID)
// Try to get extended info from the new endpoint
apiClient := singletons.GetAPIClient()
appInfo, err := apiClient.GetApplicationInfo(applicationID)
if err != nil {
// Graceful fallback: if endpoint doesn't exist yet, just show app ID
cmd.Printf("Application ID: %s\n", applicationID)
return nil
}

if flagInfoJSON {
type infoJSON struct {
ApplicationID string `json:"applicationId"`
Name string `json:"name"`
DeployStatus string `json:"deployStatus"`
AppURL *string `json:"appUrl"`
}

data, err := json.Marshal(infoJSON{
ApplicationID: appInfo.ApplicationID,
Name: appInfo.Name,
DeployStatus: appInfo.DeployStatus,
AppURL: appInfo.AppURL,
})
if err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), string(data))
return nil
}

cmd.Printf("Application ID: %s\n", appInfo.ApplicationID)
cmd.Printf("Name: %s\n", appInfo.Name)
cmd.Printf("Deploy Status: %s\n", appInfo.DeployStatus)
if appInfo.AppURL != nil {
cmd.Printf("URL: %s\n", *appInfo.AppURL)
}

return nil
}
9 changes: 9 additions & 0 deletions cmd/app/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os/exec"
"path/filepath"

"github.com/major-technology/cli/clients/git"
"github.com/major-technology/cli/errors"
"github.com/major-technology/cli/utils"
"github.com/spf13/cobra"
Expand All @@ -21,6 +22,14 @@ var startCmd = &cobra.Command{
}

func runStart(cobraCmd *cobra.Command) error {
// Check if local branch is behind origin/main
isBehind, count, err := git.IsBehindRemote()
if err != nil {
cobraCmd.Printf("Warning: Could not check remote status: %v\n", err)
} else if isBehind {
cobraCmd.Printf("Warning: Your local branch is %d commit(s) behind origin/main. Consider running 'git pull' first.\n", count)
}

// Generate .env file
_, envVars, err := generateEnvFile("")
if err != nil {
Expand Down
Loading