diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a279088..42f695a 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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" } ] } diff --git a/clients/api/client.go b/clients/api/client.go index 6a43d62..7c36a07 100644 --- a/clients/api/client.go +++ b/clients/api/client.go @@ -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 diff --git a/clients/api/structs.go b/clients/api/structs.go index 82155c3..7cd09f4 100644 --- a/clients/api/structs.go +++ b/clients/api/structs.go @@ -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"` diff --git a/clients/git/client.go b/clients/git/client.go index 61ca62f..e747600 100644 --- a/clients/git/client.go +++ b/clients/git/client.go @@ -1,11 +1,14 @@ package git import ( + "context" "errors" "os" "os/exec" "regexp" + "strconv" "strings" + "time" clierrors "github.com/major-technology/cli/errors" ) @@ -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) { diff --git a/cmd/app/app.go b/cmd/app/app.go index 4332d0d..7bbd0e4 100644 --- a/cmd/app/app.go +++ b/cmd/app/app.go @@ -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) diff --git a/cmd/app/deploy.go b/cmd/app/deploy.go index a0232c9..c0f3bbd 100644 --- a/cmd/app/deploy.go +++ b/cmd/app/deploy.go @@ -2,6 +2,7 @@ package app import ( "fmt" + "os" "regexp" "strings" "time" @@ -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" @@ -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 @@ -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) } @@ -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) } @@ -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() diff --git a/cmd/app/deploy_status.go b/cmd/app/deploy_status.go new file mode 100644 index 0000000..8e82aa9 --- /dev/null +++ b/cmd/app/deploy_status.go @@ -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 +} diff --git a/cmd/app/info.go b/cmd/app/info.go index 6ef4228..f898b01 100644 --- a/cmd/app/info.go +++ b/cmd/app/info.go @@ -1,19 +1,29 @@ 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() @@ -21,8 +31,42 @@ func runInfo(cmd *cobra.Command) error { 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 } diff --git a/cmd/app/start.go b/cmd/app/start.go index a874e7f..ec65384 100644 --- a/cmd/app/start.go +++ b/cmd/app/start.go @@ -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" @@ -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 { diff --git a/cmd/org/list.go b/cmd/org/list.go index 4980af0..1a79f9a 100644 --- a/cmd/org/list.go +++ b/cmd/org/list.go @@ -1,12 +1,17 @@ package org import ( + "encoding/json" + "fmt" + mjrToken "github.com/major-technology/cli/clients/token" "github.com/major-technology/cli/errors" "github.com/major-technology/cli/singletons" "github.com/spf13/cobra" ) +var flagListJSON bool + var listCmd = &cobra.Command{ Use: "list", Short: "List all organizations", @@ -16,6 +21,10 @@ var listCmd = &cobra.Command{ }, } +func init() { + listCmd.Flags().BoolVar(&flagListJSON, "json", false, "Output in JSON format") +} + func runList(cobraCmd *cobra.Command) error { apiClient := singletons.GetAPIClient() @@ -31,6 +40,30 @@ func runList(cobraCmd *cobra.Command) error { // Get the default org to mark it defaultOrgID, _, _ := mjrToken.GetDefaultOrg() + if flagListJSON { + type orgJSON struct { + ID string `json:"id"` + Name string `json:"name"` + IsSelected bool `json:"isSelected"` + } + + orgs := make([]orgJSON, len(orgsResp.Organizations)) + for i, org := range orgsResp.Organizations { + orgs[i] = orgJSON{ + ID: org.ID, + Name: org.Name, + IsSelected: org.ID == defaultOrgID, + } + } + + data, err := json.Marshal(orgs) + if err != nil { + return errors.WrapError("failed to marshal JSON", err) + } + fmt.Fprintln(cobraCmd.OutOrStdout(), string(data)) + return nil + } + // Print header cobraCmd.Println("\nYour Organizations:") cobraCmd.Println("-------------------") diff --git a/cmd/org/select.go b/cmd/org/select.go index c10b744..6ff06a6 100644 --- a/cmd/org/select.go +++ b/cmd/org/select.go @@ -1,6 +1,8 @@ package org import ( + "fmt" + mjrToken "github.com/major-technology/cli/clients/token" "github.com/major-technology/cli/cmd/user" "github.com/major-technology/cli/errors" @@ -8,6 +10,8 @@ import ( "github.com/spf13/cobra" ) +var flagSelectOrgID string + // selectCmd represents the org select command var selectCmd = &cobra.Command{ Use: "select", @@ -18,6 +22,10 @@ var selectCmd = &cobra.Command{ }, } +func init() { + selectCmd.Flags().StringVar(&flagSelectOrgID, "id", "", "Organization ID to select non-interactively") +} + func runSelect(cobraCmd *cobra.Command) error { // Get the API client apiClient := singletons.GetAPIClient() @@ -32,7 +40,21 @@ func runSelect(cobraCmd *cobra.Command) error { return errors.ErrorNoOrganizationsAvailable } - // Let user select organization + // Non-interactive mode: select by ID + if flagSelectOrgID != "" { + for _, org := range orgsResp.Organizations { + if org.ID == flagSelectOrgID { + if err := mjrToken.StoreDefaultOrg(org.ID, org.Name); err != nil { + return errors.WrapError("failed to store default organization", err) + } + cobraCmd.Printf("Default organization set to: %s\n", org.Name) + return nil + } + } + return fmt.Errorf("organization with ID %q not found", flagSelectOrgID) + } + + // Interactive mode: let user select organization selectedOrg, err := user.SelectOrganization(cobraCmd, orgsResp.Organizations) if err != nil { return errors.WrapError("failed to select organization", err) diff --git a/cmd/resource/add.go b/cmd/resource/add.go new file mode 100644 index 0000000..4dd144e --- /dev/null +++ b/cmd/resource/add.go @@ -0,0 +1,92 @@ +package resource + +import ( + "fmt" + + "github.com/major-technology/cli/clients/api" + "github.com/major-technology/cli/errors" + "github.com/major-technology/cli/middleware" + "github.com/major-technology/cli/singletons" + "github.com/major-technology/cli/utils" + "github.com/spf13/cobra" +) + +var flagAddResourceID string + +var addCmd = &cobra.Command{ + Use: "add", + Short: "Add a resource to the current application", + Long: `Add a resource by ID to the current application. Use 'major resource list' to see available resources.`, + PreRunE: middleware.ChainParent( + middleware.CheckLogin, + middleware.CheckNodeInstalled, + middleware.CheckNodeVersion("22.12"), + middleware.CheckPnpmInstalled, + ), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return runAdd(cobraCmd) + }, +} + +func init() { + addCmd.Flags().StringVar(&flagAddResourceID, "id", "", "Resource ID to add") + addCmd.MarkFlagRequired("id") +} + +func runAdd(cobraCmd *cobra.Command) error { + appInfo, err := utils.GetApplicationInfo("") + if err != nil { + return errors.WrapError("failed to identify application", err) + } + + apiClient := singletons.GetAPIClient() + + // Fetch org resources (source of truth for resource metadata) + orgResources, err := apiClient.GetResources(appInfo.OrganizationID) + if err != nil { + return errors.WrapError("failed to get resources", err) + } + + // Find the target resource in org resources + var targetResource *api.ResourceItem + for i, r := range orgResources.Resources { + if r.ID == flagAddResourceID { + targetResource = &orgResources.Resources[i] + break + } + } + + if targetResource == nil { + return fmt.Errorf("resource with ID %q not found in organization", flagAddResourceID) + } + + // Read local resources.json (same as manage does) + existingResources, err := utils.ReadLocalResources(".") + if err != nil { + cobraCmd.Printf("Warning: Could not read existing resources: %v\n", err) + existingResources = []utils.LocalResource{} + } + + // Build desired resource ID list: existing + new + selectedIDs := make([]string, 0, len(existingResources)+1) + for _, r := range existingResources { + selectedIDs = append(selectedIDs, r.ID) + } + selectedIDs = append(selectedIDs, flagAddResourceID) + + // Save to server + _, err = apiClient.SaveApplicationResources(appInfo.OrganizationID, appInfo.ApplicationID, selectedIDs) + if err != nil { + return errors.WrapError("failed to save resources", err) + } + + // Build full resource list for AddResourcesToProject (needs ResourceItem details) + selectedResources := utils.ResolveResourceItems(selectedIDs, orgResources.Resources) + + // Generate local client code (diffs against resources.json) + if err := utils.AddResourcesToProject(cobraCmd, ".", selectedResources, appInfo.ApplicationID); err != nil { + return errors.WrapError("failed to add resource to project", err) + } + + return nil +} diff --git a/cmd/resource/env.go b/cmd/resource/env.go index f369073..e7b96da 100644 --- a/cmd/resource/env.go +++ b/cmd/resource/env.go @@ -13,6 +13,8 @@ import ( "github.com/spf13/cobra" ) +var flagEnvID string + // envCmd represents the env command var envCmd = &cobra.Command{ Use: "env", @@ -26,6 +28,10 @@ var envCmd = &cobra.Command{ }, } +func init() { + envCmd.Flags().StringVar(&flagEnvID, "id", "", "Environment ID to select non-interactively") +} + func runEnv(cobraCmd *cobra.Command) error { // Get application info from current directory appInfo, err := utils.GetApplicationInfo("") @@ -55,6 +61,21 @@ func runEnv(cobraCmd *cobra.Command) error { } } + // Non-interactive mode: select by ID + if flagEnvID != "" { + for i, env := range envListResp.Environments { + if env.ID == flagEnvID { + setResp, err := apiClient.SetApplicationEnvironment(appInfo.ApplicationID, envListResp.Environments[i].ID) + if err != nil { + return errors.WrapError("failed to set environment", err) + } + cobraCmd.Printf("Environment set to: %s\n", setResp.EnvironmentName) + return nil + } + } + return fmt.Errorf("environment with ID %q not found", flagEnvID) + } + // If only one environment, just show current and exit if len(envListResp.Environments) == 1 { printCurrentEnvironment(cobraCmd, currentEnvResp) diff --git a/cmd/resource/env_list.go b/cmd/resource/env_list.go new file mode 100644 index 0000000..00924a5 --- /dev/null +++ b/cmd/resource/env_list.go @@ -0,0 +1,87 @@ +package resource + +import ( + "encoding/json" + "fmt" + + "github.com/major-technology/cli/errors" + "github.com/major-technology/cli/middleware" + "github.com/major-technology/cli/singletons" + "github.com/major-technology/cli/utils" + "github.com/spf13/cobra" +) + +var flagEnvListJSON bool + +var envListCmd = &cobra.Command{ + Use: "env-list", + Short: "List available environments for this application", + Long: `List all available environments and show which one is currently selected.`, + PreRunE: middleware.Compose( + middleware.CheckLogin, + ), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return runEnvList(cobraCmd) + }, +} + +func init() { + envListCmd.Flags().BoolVar(&flagEnvListJSON, "json", false, "Output in JSON format") +} + +func runEnvList(cobraCmd *cobra.Command) error { + appInfo, err := utils.GetApplicationInfo("") + if err != nil { + return errors.WrapError("failed to identify application", err) + } + + apiClient := singletons.GetAPIClient() + + currentEnvResp, err := apiClient.GetApplicationEnvironment(appInfo.ApplicationID) + if err != nil { + return errors.WrapError("failed to get current environment", err) + } + + envListResp, err := apiClient.ListApplicationEnvironments(appInfo.ApplicationID) + if err != nil { + return errors.WrapError("failed to list environments", err) + } + + if flagEnvListJSON { + type envJSON struct { + ID string `json:"id"` + Name string `json:"name"` + IsCurrent bool `json:"isCurrent"` + } + + envs := make([]envJSON, len(envListResp.Environments)) + for i, env := range envListResp.Environments { + isCurrent := currentEnvResp.EnvironmentID != nil && env.ID == *currentEnvResp.EnvironmentID + envs[i] = envJSON{ + ID: env.ID, + Name: env.Name, + IsCurrent: isCurrent, + } + } + + data, err := json.Marshal(envs) + if err != nil { + return errors.WrapError("failed to marshal JSON", err) + } + fmt.Fprintln(cobraCmd.OutOrStdout(), string(data)) + return nil + } + + // Human-readable output + cobraCmd.Println("\nEnvironments:") + cobraCmd.Println("-------------") + for _, env := range envListResp.Environments { + if currentEnvResp.EnvironmentID != nil && env.ID == *currentEnvResp.EnvironmentID { + cobraCmd.Printf("• %s (current)\n", env.Name) + } else { + cobraCmd.Printf("• %s\n", env.Name) + } + } + cobraCmd.Println() + return nil +} diff --git a/cmd/resource/list.go b/cmd/resource/list.go new file mode 100644 index 0000000..cf29bbb --- /dev/null +++ b/cmd/resource/list.go @@ -0,0 +1,77 @@ +package resource + +import ( + "encoding/json" + "fmt" + + "github.com/major-technology/cli/errors" + "github.com/major-technology/cli/middleware" + "github.com/major-technology/cli/singletons" + "github.com/major-technology/cli/utils" + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List available resources", + Long: `List all resources in the organization, showing which are attached to the current app.`, + PreRunE: middleware.Compose( + middleware.CheckLogin, + ), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return runList(cobraCmd) + }, +} + +func runList(cobraCmd *cobra.Command) error { + appInfo, err := utils.GetApplicationInfo("") + if err != nil { + return errors.WrapError("failed to identify application", err) + } + + apiClient := singletons.GetAPIClient() + + // Get all org resources + orgResources, err := apiClient.GetResources(appInfo.OrganizationID) + if err != nil { + return errors.WrapError("failed to get resources", err) + } + + // Get app-attached resources + appResources, err := apiClient.GetApplicationResources(appInfo.ApplicationID) + if err != nil { + return errors.WrapError("failed to get application resources", err) + } + + // Build set of attached resource IDs + attached := make(map[string]bool) + for _, r := range appResources.Resources { + attached[r.ID] = true + } + + type resourceJSON struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` + IsAttached bool `json:"isAttached"` + } + + resources := make([]resourceJSON, len(orgResources.Resources)) + for i, r := range orgResources.Resources { + resources[i] = resourceJSON{ + ID: r.ID, + Name: r.Name, + Type: r.Type, + Description: r.Description, + IsAttached: attached[r.ID], + } + } + + data, err := json.Marshal(resources) + if err != nil { + return errors.WrapError("failed to marshal JSON", err) + } + fmt.Fprintln(cobraCmd.OutOrStdout(), string(data)) + return nil +} diff --git a/cmd/resource/remove.go b/cmd/resource/remove.go new file mode 100644 index 0000000..171327c --- /dev/null +++ b/cmd/resource/remove.go @@ -0,0 +1,85 @@ +package resource + +import ( + "fmt" + + "github.com/major-technology/cli/errors" + "github.com/major-technology/cli/middleware" + "github.com/major-technology/cli/singletons" + "github.com/major-technology/cli/utils" + "github.com/spf13/cobra" +) + +var flagRemoveResourceID string + +var removeCmd = &cobra.Command{ + Use: "remove", + Short: "Remove a resource from the current application", + Long: `Remove a resource by ID from the current application. Use 'major resource list' to see attached resources.`, + PreRunE: middleware.ChainParent( + middleware.CheckLogin, + middleware.CheckNodeInstalled, + middleware.CheckNodeVersion("22.12"), + middleware.CheckPnpmInstalled, + ), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return runRemove(cobraCmd) + }, +} + +func init() { + removeCmd.Flags().StringVar(&flagRemoveResourceID, "id", "", "Resource ID to remove") + removeCmd.MarkFlagRequired("id") +} + +func runRemove(cobraCmd *cobra.Command) error { + appInfo, err := utils.GetApplicationInfo("") + if err != nil { + return errors.WrapError("failed to identify application", err) + } + + apiClient := singletons.GetAPIClient() + + // Fetch org resources (source of truth for resource metadata) + orgResources, err := apiClient.GetResources(appInfo.OrganizationID) + if err != nil { + return errors.WrapError("failed to get resources", err) + } + + // Read local resources.json (same as manage does) + existingResources, err := utils.ReadLocalResources(".") + if err != nil { + return errors.WrapError("failed to read existing resources", err) + } + + // Verify the resource is currently attached + found := false + selectedIDs := make([]string, 0, len(existingResources)) + for _, r := range existingResources { + if r.ID == flagRemoveResourceID { + found = true + continue + } + selectedIDs = append(selectedIDs, r.ID) + } + + if !found { + return fmt.Errorf("resource with ID %q is not attached to this application", flagRemoveResourceID) + } + + // Save to server + _, err = apiClient.SaveApplicationResources(appInfo.OrganizationID, appInfo.ApplicationID, selectedIDs) + if err != nil { + return errors.WrapError("failed to save resources", err) + } + + // Build resource list for AddResourcesToProject (needs ResourceItem details) + selectedResources := utils.ResolveResourceItems(selectedIDs, orgResources.Resources) + + // Generate local client code (diffs against resources.json, will remove the target) + if err := utils.AddResourcesToProject(cobraCmd, ".", selectedResources, appInfo.ApplicationID); err != nil { + return errors.WrapError("failed to update project resources", err) + } + + return nil +} diff --git a/cmd/resource/resource.go b/cmd/resource/resource.go index 7e237e8..1f6fe3b 100644 --- a/cmd/resource/resource.go +++ b/cmd/resource/resource.go @@ -21,4 +21,8 @@ func init() { Cmd.AddCommand(createCmd) Cmd.AddCommand(manageCmd) Cmd.AddCommand(envCmd) + Cmd.AddCommand(envListCmd) + Cmd.AddCommand(listCmd) + Cmd.AddCommand(addCmd) + Cmd.AddCommand(removeCmd) } diff --git a/plugins/major/skills/major/SKILL.md b/plugins/major/skills/major/SKILL.md index c178d06..6c7d2b4 100644 --- a/plugins/major/skills/major/SKILL.md +++ b/plugins/major/skills/major/SKILL.md @@ -1,16 +1,16 @@ --- name: major description: > - Use the Major platform to create, develop, and deploy web applications. + Use the Major platform to create, develop, and deploy Next.js web applications. Triggers when user mentions Major apps, deploying, creating apps, managing resources, or working with the Major CLI. disable-model-invocation: false -allowed-tools: Bash(major *) +allowed-tools: Bash(major *), Read(**/plugins/major/skills/major/docs/*) --- # Major Platform -Major is a platform for building and deploying web applications. It creates GitHub-backed apps with local development, connected resources (databases, APIs), and production deployments. +Major is a platform for building and deploying Next.js web applications. It creates GitHub-backed Next.js apps with local development, connected resources (databases, APIs), and production deployments. ## Command Reference @@ -20,17 +20,24 @@ Major is a platform for building and deploying web applications. It creates GitH |---------|-------------|------| | `major app create --name "X" --description "Y"` | Create a new app (skips resource selection in non-interactive mode) | Direct | | `major app clone --app-id "UUID"` | Clone an existing app | Direct | -| `major app start` | Start local dev server | Direct | -| `major app deploy --message "description"` | Deploy to production | Direct | +| `major app start` | Start local dev server (warns if behind origin) | Direct | +| `major app deploy --message "description" --no-wait` | Deploy to production (returns version ID) | Direct | +| `major app deploy-status --version-id "ID"` | Check deployment status (JSON: status, appUrl, error) | Direct | | `major app list` | List all apps in org (JSON: id, name) | Direct | -| `major app info` | Show current app ID | Direct | +| `major app info` | Show app ID, name, deploy status, URL | Direct | +| `major app info --json` | App info as JSON | Direct | | `major app configure` | Open app settings in browser | Direct | ### Resource Commands | Command | Description | Mode | |---------|-------------|------| -| `major resource env` | View/switch environments | Direct | +| `major resource list` | List org resources as JSON (shows which are attached to app) | Direct | +| `major resource add --id "UUID"` | Add a resource to current app | Direct | +| `major resource remove --id "UUID"` | Remove a resource from current app | Direct | +| `major resource env` | View/switch environments (interactive, or `--id` for non-interactive) | Direct | +| `major resource env-list` | List available environments | Direct | +| `major resource env-list --json` | List environments as JSON | Direct | | `major resource create` | Open resource creation in browser | Direct | | `major resource manage` | Interactive resource menu | Interactive | @@ -48,8 +55,10 @@ Major is a platform for building and deploying web applications. It creates GitH | Command | Description | Mode | |---------|-------------|------| | `major org list` | List all organizations | Direct | +| `major org list --json` | List organizations as JSON (includes IDs) | Direct | | `major org whoami` | Show current default org | Direct | | `major org select` | Select default organization | Interactive | +| `major org select --id "UUID"` | Select organization non-interactively | Direct | ### Other Commands @@ -61,15 +70,21 @@ Major is a platform for building and deploying web applications. It creates GitH ## Rules **Direct** commands: Run these yourself via Bash. -**Interactive** commands: Tell the user to run these in their terminal — they require browser or TUI interaction. +**Interactive** commands: Tell the user to run these in their terminal -- they require browser or TUI interaction. ### Critical Rules -1. **NEVER use raw git commands** (`git clone`, `git push`) — always use Major CLI commands. `major app clone` handles GitHub auth, permissions, and `.env` generation. +1. **NEVER use raw git commands** (`git clone`, `git push`) -- always use Major CLI commands. `major app clone` handles GitHub auth, permissions, and `.env` generation. -2. **Always use `--message` with deploy** to skip the interactive commit prompt. On first deploy, also pass `--slug` to set the URL non-interactively: +2. **Always use `--message` and `--no-wait` with deploy** to skip the interactive commit prompt and avoid TUI issues. The command returns a version ID you can use to check status: ```bash - major app deploy --message "Add search feature" --slug "my-app" + major app deploy --message "Add search feature" --no-wait + # Returns version ID, then check status: + major app deploy-status --version-id "" + ``` + On first deploy, also pass `--slug` to set the URL non-interactively: + ```bash + major app deploy --message "Initial deploy" --slug "my-app" --no-wait ``` 3. **Always check auth first** before running commands: @@ -77,20 +92,24 @@ Major is a platform for building and deploying web applications. It creates GitH major user whoami ``` -4. **GitHub Invitation Flow** — When you see "Action Required: Accept GitHub Invitation": +4. **GitHub Invitation Flow** -- When you see "Action Required: Accept GitHub Invitation": - STOP and tell the user to accept the invitation at the URL shown - Tell them a browser window should have opened automatically - After they accept, re-run the same command - Do NOT try `git clone` directly or retry without user action -5. **App type**: Creates a NextJS application by default +5. **App type**: Creates a Next.js application by default + +6. **Resource management**: Use `major resource list` to see available resources, then `major resource add --id ` or `major resource remove --id ` to manage them programmatically. Use `major resource env-list --json` to see environments and `major resource env --id ` to switch. + +7. **Organization selection**: Use `major org list --json` to get org IDs, then `major org select --id ` to switch orgs programmatically. ## Workflow Reference For detailed workflows, see the docs below: -- [Getting Started](docs/getting-started.md) — Install, auth, first app -- [App Workflows](docs/app-workflows.md) — Create, clone, start, deploy -- [Resource Workflows](docs/resource-workflows.md) — Create, manage, environments -- [Org Management](docs/org-management.md) — Organizations and teams -- [Troubleshooting](docs/troubleshooting.md) — Common issues and fixes +- [Getting Started](docs/getting-started.md) -- Install, auth, first app +- [App Workflows](docs/app-workflows.md) -- Create, clone, start, deploy +- [Resource Workflows](docs/resource-workflows.md) -- Create, manage, environments +- [Org Management](docs/org-management.md) -- Organizations and teams +- [Troubleshooting](docs/troubleshooting.md) -- Common issues and fixes diff --git a/plugins/major/skills/major/docs/app-workflows.md b/plugins/major/skills/major/docs/app-workflows.md index 52cfe3e..cdeecd8 100644 --- a/plugins/major/skills/major/docs/app-workflows.md +++ b/plugins/major/skills/major/docs/app-workflows.md @@ -7,12 +7,12 @@ major app create --name "app-name" --description "What this app does" ``` **Flags:** -- `--name` — App name (required for non-interactive use) -- `--description` — App description (required for non-interactive use) +- `--name` -- App name (required for non-interactive use) +- `--description` -- App description (required for non-interactive use) **What happens:** 1. API creates the app and a GitHub repository -2. CLI checks GitHub repo access — may trigger invitation flow +2. CLI checks GitHub repo access -- may trigger invitation flow 3. Prompts to select resources for the app 4. Clones the repository locally 5. Adds selected resources via major-client @@ -51,40 +51,65 @@ major app start ``` Must be run from the app directory. This: -1. Generates/refreshes the `.env` file -2. Runs `pnpm install` -3. Runs `pnpm dev` -4. Streams output to the terminal +1. Checks if your branch is behind `origin/main` (warns if so) +2. Generates/refreshes the `.env` file +3. Runs `pnpm install` +4. Runs `pnpm dev` +5. Streams output to the terminal The dev server has access to connected resources via environment variables. ## Deploying to Production ```bash -major app deploy --message "Add search feature" +major app deploy --message "Add search feature" --no-wait ``` -Always use `--message` (or `-m`) to skip the interactive commit prompt. On first deploy, also pass `--slug` to set the URL non-interactively: +Always use `--message` (or `-m`) to skip the interactive commit prompt. Use `--no-wait` to skip the TUI deployment progress tracker (recommended for AI-driven deploys). On first deploy, also pass `--slug` to set the URL non-interactively: ```bash -major app deploy --message "Initial deployment" --slug "my-app" +major app deploy --message "Initial deployment" --slug "my-app" --no-wait ``` +**Flags:** +- `--message` / `-m` -- Commit message (skips interactive prompt) +- `--slug` -- URL slug for first deploy (skips interactive prompt) +- `--no-wait` -- Return immediately after triggering deploy (don't wait for completion) + **What happens:** 1. Checks for uncommitted git changes 2. Stages, commits, and pushes to main branch 3. On first deploy, uses `--slug` or prompts for URL slug -4. Creates application version via API -5. Shows deployment progress: bundling → building → deploying → deployed +4. Creates application version via API and returns a version ID +5. Without `--no-wait`: shows deployment progress (bundling -> building -> deploying -> deployed) 6. Returns the live app URL on success +## Checking Deployment Status + +```bash +major app deploy-status --version-id "version-uuid" +``` + +Returns JSON with the current status of a specific deployment version: +```json +{"status":"DEPLOYED","appUrl":"https://my-app.apps2.prod.major.build"} +``` + +Possible statuses: `BUNDLING`, `BUNDLE_FAILED`, `BUILDING`, `BUILD_FAILED`, `DEPLOYING`, `DEPLOY_FAILED`, `DEPLOYED`. + ## Checking App Info ```bash major app info ``` -Displays the Application ID of the current directory. Must be run from within an app directory. +Displays application ID, name, deploy status, and URL (if deployed). Must be run from within an app directory. + +```bash +major app info --json +``` + +Returns the same info as JSON for programmatic use. ## Configuring an App diff --git a/plugins/major/skills/major/docs/getting-started.md b/plugins/major/skills/major/docs/getting-started.md index f450ea3..50463fd 100644 --- a/plugins/major/skills/major/docs/getting-started.md +++ b/plugins/major/skills/major/docs/getting-started.md @@ -13,7 +13,7 @@ curl -fsSL https://install.major.build | bash ## Authenticate -Authentication is interactive — the user must run this in their terminal: +Authentication is interactive -- the user must run this in their terminal: ```bash major user login @@ -44,7 +44,7 @@ major app create --name "my-app" --description "My first Major app" ``` This will: -1. Create the app and GitHub repository (NextJS by default) +1. Create the app and GitHub repository (Next.js by default) 2. Ensure GitHub repo access (may trigger invitation flow) 3. Clone the repository locally 4. Generate `.env` file with environment variables @@ -57,12 +57,20 @@ cd my-app major app start ``` -This runs `pnpm install` followed by `pnpm dev`, starting a local dev server with access to connected resources via environment variables. +This checks for upstream changes, runs `pnpm install` followed by `pnpm dev`, starting a local dev server with access to connected resources via environment variables. ## Deploy to Production ```bash -major app deploy --message "Initial deployment" +major app deploy --message "Initial deployment" --no-wait ``` -Always include `--message` to skip the interactive commit prompt. The CLI stages, commits, pushes, and deploys automatically. +Always include `--message` to skip the interactive commit prompt. Use `--no-wait` to skip the TUI progress tracker. The CLI stages, commits, pushes, and deploys automatically. + +## Check App Status + +```bash +major app info +``` + +Shows app ID, name, deploy status, and URL (if deployed). diff --git a/plugins/major/skills/major/docs/org-management.md b/plugins/major/skills/major/docs/org-management.md index 963d63e..452f57b 100644 --- a/plugins/major/skills/major/docs/org-management.md +++ b/plugins/major/skills/major/docs/org-management.md @@ -10,6 +10,15 @@ major org list Lists all organizations the user belongs to. The default org is marked. +```bash +major org list --json +``` + +Returns JSON with org IDs for programmatic use: +```json +[{"id":"uuid","name":"My Org","isSelected":true}] +``` + ## Show Current Organization ```bash @@ -20,10 +29,20 @@ Displays the currently selected default organization. ## Select Default Organization +### Non-interactive (recommended for AI) + +```bash +major org select --id "organization-uuid" +``` + +Selects the default organization by ID. Use `major org list --json` to get the ID. + +### Interactive + ```bash major org select ``` -This is **interactive** — opens a TUI picker for selecting the default organization. The selection is stored in the system keychain. +Opens a TUI picker for selecting the default organization. The user must run this in their terminal. -The default organization determines which org's apps and resources are shown when running other commands. +The default organization determines which org's apps and resources are shown when running other commands. The selection is stored in the system keychain. diff --git a/plugins/major/skills/major/docs/resource-workflows.md b/plugins/major/skills/major/docs/resource-workflows.md index db55880..8acb4d7 100644 --- a/plugins/major/skills/major/docs/resource-workflows.md +++ b/plugins/major/skills/major/docs/resource-workflows.md @@ -17,24 +17,81 @@ major resource create Opens the resource creation page in the browser. Resources are created at the organization level. -## Managing Resources +## Listing Resources + +```bash +major resource list +``` + +Lists all resources in the organization as JSON. Each resource includes `isAttached` to show if it's connected to the current app. Example output: + +```json +[{"id":"uuid","name":"My DB","type":"postgresql","description":"Production database","isAttached":true}] +``` + +## Adding a Resource to an App + +```bash +major resource add --id "resource-uuid" +``` + +Adds a resource to the current application by ID. This: +1. Updates the app's resource list on the server +2. Generates the local client code via `major-client` +3. Installs dependencies + +Use `major resource list` to find the resource ID. + +## Removing a Resource from an App + +```bash +major resource remove --id "resource-uuid" +``` + +Removes a resource from the current application. This: +1. Updates the app's resource list on the server +2. Removes the local client code + +## Managing Resources (Interactive) ```bash major resource manage ``` -This is **interactive** — the user must run it in their terminal. It opens a TUI menu to: +This is **interactive** -- the user must run it in their terminal. It opens a TUI menu to: - View connected resources - Connect new resources to the app - Disconnect resources ## Environment Switching +### List Environments + +```bash +major resource env-list +major resource env-list --json +``` + +Lists available environments. With `--json`, outputs: +```json +[{"id":"uuid","name":"production","isCurrent":true}] +``` + +### Switch Environment + +```bash +major resource env --id "environment-uuid" +``` + +Switches to a specific environment non-interactively. + ```bash major resource env ``` -View or switch between available environments for the app. Resources can be configured differently per environment (dev, staging, production). +Without `--id`, opens an interactive TUI picker. + +Resources can be configured differently per environment (dev, staging, production). ## How Resources Work in Code @@ -53,6 +110,6 @@ When creating an app with `major app create`, the CLI prompts to select resource ## Adding Resources After Creation -1. Run `major resource manage` (interactive — user must run in terminal) -2. Select resources to connect +1. List available resources: `major resource list` +2. Add by ID: `major resource add --id "resource-uuid"` 3. Restart the dev server with `major app start` to pick up new environment variables diff --git a/plugins/major/skills/major/docs/troubleshooting.md b/plugins/major/skills/major/docs/troubleshooting.md index 787197f..25508fb 100644 --- a/plugins/major/skills/major/docs/troubleshooting.md +++ b/plugins/major/skills/major/docs/troubleshooting.md @@ -6,7 +6,7 @@ ```bash major user login ``` -User must run this in their terminal — it opens a browser for OAuth. +User must run this in their terminal -- it opens a browser for OAuth. **Session expired** Run `major user login` again to refresh the token. @@ -25,23 +25,40 @@ Navigate to the app directory. Verify with `major app info`. Use `major app clone` to list and clone available apps. **Missing `.env` file** -Run `major app start` — it regenerates the `.env` file automatically. +Run `major app start` -- it regenerates the `.env` file automatically. + +**Branch behind origin** +`major app start` warns if your branch is behind `origin/main`. Run `git pull` to update. ## Resource Issues **Resources not available locally** -1. Check connected resources: `major resource manage` (user must run) -2. Verify environment: `major resource env` +1. Check connected resources: `major resource list` +2. Verify environment: `major resource env-list` 3. Restart dev server: `major app start` (regenerates `.env`) **Wrong environment** ```bash -major resource env +major resource env-list --json +major resource env --id "correct-env-id" ``` Switch to the correct environment, then restart the dev server. +**Adding/removing resources programmatically** +```bash +major resource list # See available resources and their IDs +major resource add --id "resource-id" # Add to current app +major resource remove --id "resource-id" # Remove from current app +``` + ## Deployment Issues +**Deploy crashes or hangs** +Use `--no-wait` to skip the TUI progress tracker: +```bash +major app deploy --message "description" --no-wait +``` + **Deploy fails** 1. Check authentication: `major user whoami` 2. Verify app directory: `major app info` @@ -51,6 +68,14 @@ Switch to the correct environment, then restart the dev server. **"Not in a git repository"** Navigate to the app directory. Major apps are git repositories. +## Organization Issues + +**Switch organizations programmatically** +```bash +major org list --json # Get org IDs +major org select --id "org-id" # Switch to a specific org +``` + ## CLI Issues **CLI out of date** diff --git a/utils/resources.go b/utils/resources.go index 87a3cb7..2e4385c 100644 --- a/utils/resources.go +++ b/utils/resources.go @@ -134,22 +134,28 @@ func SelectApplicationResources(cmd *cobra.Command, apiClient *api.Client, orgID return nil, err } - // Build and return the list of selected resources with full details - var selectedResources []api.ResourceItem - for _, selectedID := range selectedResourceIDs { - for _, resource := range resourcesResp.Resources { - if resource.ID == selectedID { - selectedResources = append(selectedResources, resource) - break - } - } + return ResolveResourceItems(selectedResourceIDs, resourcesResp.Resources), nil +} + +// ResolveResourceItems maps a list of resource IDs to their full ResourceItem details +// from the org resource list. IDs not found in orgResources are skipped. +func ResolveResourceItems(ids []string, orgResources []api.ResourceItem) []api.ResourceItem { + resourceMap := make(map[string]api.ResourceItem, len(orgResources)) + for _, r := range orgResources { + resourceMap[r.ID] = r } - return selectedResources, nil + var result []api.ResourceItem + for _, id := range ids { + if r, ok := resourceMap[id]; ok { + result = append(result, r) + } + } + return result } -// detectFramework detects the framework used in the project by checking package.json dependencies -func detectFramework(projectDir string) string { +// DetectFramework detects the framework used in the project by checking package.json dependencies +func DetectFramework(projectDir string) string { packageJsonPath := filepath.Join(projectDir, "package.json") data, err := os.ReadFile(packageJsonPath) if err != nil { @@ -227,7 +233,7 @@ func AddResourcesToProject(cmd *cobra.Command, projectDir string, resources []ap return errors.WrapError("failed to install dependencies", err) } - framework := detectFramework(projectDir) + framework := DetectFramework(projectDir) removeSuccessCount := 0 for _, resource := range resourcesToRemove {