From 411623d234ded8215b8421c9e631146ddeba3b0b Mon Sep 17 00:00:00 2001 From: josegironn <30703536+josegironn@users.noreply.github.com> Date: Thu, 2 Apr 2026 18:12:33 -0700 Subject: [PATCH 1/4] feat: improve CLI for Claude plugin integration - Add --no-wait flag and non-TTY fallback to deploy command - Add --id flag to org select and resource env for non-interactive use - Add --json flag to org list and new resource env-list command - Add resource list, add, and remove commands for programmatic management - Enhance app info to show name, deploy status, and URL - Add origin/HEAD behind check to app start - Update SKILL.md and docs to mention Next.js, document new commands - Add Read permission for skill docs in allowed-tools Co-Authored-By: Claude Opus 4.6 (1M context) --- clients/api/client.go | 11 +++ clients/api/structs.go | 9 ++ clients/git/client.go | 31 +++++++ cmd/app/deploy.go | 54 +++++++++-- cmd/app/info.go | 50 +++++++++- cmd/app/start.go | 9 ++ cmd/org/list.go | 33 +++++++ cmd/org/select.go | 24 ++++- cmd/resource/add.go | 93 +++++++++++++++++++ cmd/resource/env.go | 21 +++++ cmd/resource/env_list.go | 87 +++++++++++++++++ cmd/resource/list.go | 77 +++++++++++++++ cmd/resource/remove.go | 90 ++++++++++++++++++ cmd/resource/resource.go | 4 + plugins/major/skills/major/SKILL.md | 52 +++++++---- .../major/skills/major/docs/app-workflows.md | 36 ++++--- .../skills/major/docs/getting-started.md | 18 +++- .../major/skills/major/docs/org-management.md | 23 ++++- .../skills/major/docs/resource-workflows.md | 67 ++++++++++++- .../skills/major/docs/troubleshooting.md | 35 ++++++- utils/resources.go | 6 +- 21 files changed, 769 insertions(+), 61 deletions(-) create mode 100644 cmd/resource/add.go create mode 100644 cmd/resource/env_list.go create mode 100644 cmd/resource/list.go create mode 100644 cmd/resource/remove.go 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/deploy.go b/cmd/app/deploy.go index a0232c9..0d8b942 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.Println("Deployment started. Use 'major app info' to check status.") + 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/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..623aff3 --- /dev/null +++ b/cmd/resource/add.go @@ -0,0 +1,93 @@ +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() + + // Verify the resource exists in the org + orgResources, err := apiClient.GetResources(appInfo.OrganizationID) + if err != nil { + return errors.WrapError("failed to get resources", err) + } + + 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) + } + + // Get current app resources + appResources, err := apiClient.GetApplicationResources(appInfo.ApplicationID) + if err != nil { + return errors.WrapError("failed to get application resources", err) + } + + // Check if already attached + resourceIDs := make([]string, 0, len(appResources.Resources)+1) + for _, r := range appResources.Resources { + if r.ID == flagAddResourceID { + cobraCmd.Printf("Resource %q is already attached to this application.\n", targetResource.Name) + return nil + } + resourceIDs = append(resourceIDs, r.ID) + } + + // Add the new resource + resourceIDs = append(resourceIDs, flagAddResourceID) + + _, err = apiClient.SaveApplicationResources(appInfo.OrganizationID, appInfo.ApplicationID, resourceIDs) + if err != nil { + return errors.WrapError("failed to save resources", err) + } + + // Generate local client code + if err := utils.AddResourcesToProject(cobraCmd, ".", []api.ResourceItem{*targetResource}, appInfo.ApplicationID); err != nil { + return errors.WrapError("failed to add resource to project", err) + } + + cobraCmd.Printf("Resource %q added successfully.\n", targetResource.Name) + 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..1ef7e20 --- /dev/null +++ b/cmd/resource/remove.go @@ -0,0 +1,90 @@ +package resource + +import ( + "fmt" + "os" + "os/exec" + + "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() + + // Get current app resources + appResources, err := apiClient.GetApplicationResources(appInfo.ApplicationID) + if err != nil { + return errors.WrapError("failed to get application resources", err) + } + + // Find the resource and build filtered list + var removedName string + var removedType string + resourceIDs := make([]string, 0, len(appResources.Resources)) + for _, r := range appResources.Resources { + if r.ID == flagRemoveResourceID { + removedName = r.Name + removedType = r.Type + continue + } + resourceIDs = append(resourceIDs, r.ID) + } + + if removedName == "" { + return fmt.Errorf("resource with ID %q is not attached to this application", flagRemoveResourceID) + } + + // Save the updated resource list + _, err = apiClient.SaveApplicationResources(appInfo.OrganizationID, appInfo.ApplicationID, resourceIDs) + if err != nil { + return errors.WrapError("failed to save resources", err) + } + + // Remove local client code + cobraCmd.Printf("Removing resource: %s (%s)...\n", removedName, removedType) + framework := utils.DetectFramework(".") + args := []string{"exec", "major-client", "remove", removedName} + if framework != "" { + args = append(args, "--framework", framework) + } + pnpmCmd := exec.Command("pnpm", args...) + pnpmCmd.Stdout = os.Stdout + pnpmCmd.Stderr = os.Stderr + if err := pnpmCmd.Run(); err != nil { + cobraCmd.Printf("Warning: Failed to remove local resource files: %v\n", err) + } + + cobraCmd.Printf("Resource %q removed successfully.\n", removedName) + 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..a64b766 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,23 @@ 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 | 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 +54,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 +69,19 @@ 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: ```bash - major app deploy --message "Add search feature" --slug "my-app" + major app deploy --message "Add search feature" --no-wait + ``` + 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 +89,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..b6134ba 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,31 +51,37 @@ 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 +5. Without `--no-wait`: shows deployment progress (bundling -> building -> deploying -> deployed) 6. Returns the live app URL on success ## Checking App Info @@ -84,7 +90,13 @@ major app deploy --message "Initial deployment" --slug "my-app" 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..409806f 100644 --- a/utils/resources.go +++ b/utils/resources.go @@ -148,8 +148,8 @@ func SelectApplicationResources(cmd *cobra.Command, apiClient *api.Client, orgID return selectedResources, nil } -// 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 +227,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 { From 6449ac0631c844969697966d0b20b173c0f25f62 Mon Sep 17 00:00:00 2001 From: josegironn <30703536+josegironn@users.noreply.github.com> Date: Thu, 2 Apr 2026 18:53:50 -0700 Subject: [PATCH 2/4] feat: add deploy-status command for checking deployment progress - Add `major app deploy-status --version-id ` returning JSON with status, appUrl, and deploymentError - Update --no-wait output to show the version ID and suggest deploy-status command - Update SKILL.md and docs with deploy-status workflow Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/app/app.go | 1 + cmd/app/deploy.go | 2 +- cmd/app/deploy_status.go | 56 +++++++++++++++++++ plugins/major/skills/major/SKILL.md | 7 ++- .../major/skills/major/docs/app-workflows.md | 15 ++++- 5 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 cmd/app/deploy_status.go 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 0d8b942..c0f3bbd 100644 --- a/cmd/app/deploy.go +++ b/cmd/app/deploy.go @@ -140,7 +140,7 @@ func runDeploy(cobraCmd *cobra.Command) error { // If --no-wait, return immediately if flagDeployNoWait { - cobraCmd.Println("Deployment started. Use 'major app info' to check status.") + cobraCmd.Printf("Deployment started. Use 'major app deploy-status --version-id %s' to check status.\n", resp.VersionID) return nil } 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/plugins/major/skills/major/SKILL.md b/plugins/major/skills/major/SKILL.md index a64b766..6c7d2b4 100644 --- a/plugins/major/skills/major/SKILL.md +++ b/plugins/major/skills/major/SKILL.md @@ -21,7 +21,8 @@ Major is a platform for building and deploying Next.js web applications. It crea | `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 (warns if behind origin) | Direct | -| `major app deploy --message "description" --no-wait` | Deploy to production | 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 app ID, name, deploy status, URL | Direct | | `major app info --json` | App info as JSON | Direct | @@ -75,9 +76,11 @@ Major is a platform for building and deploying Next.js web applications. It crea 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` and `--no-wait` with deploy** to skip the interactive commit prompt and avoid TUI issues: +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" --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 diff --git a/plugins/major/skills/major/docs/app-workflows.md b/plugins/major/skills/major/docs/app-workflows.md index b6134ba..cdeecd8 100644 --- a/plugins/major/skills/major/docs/app-workflows.md +++ b/plugins/major/skills/major/docs/app-workflows.md @@ -80,10 +80,23 @@ major app deploy --message "Initial deployment" --slug "my-app" --no-wait 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 +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 From 0fc300a026e349960b8e5d5af49b724b722aa08d Mon Sep 17 00:00:00 2001 From: josegironn <30703536+josegironn@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:28:08 -0700 Subject: [PATCH 3/4] fix: pass full resource list to AddResourcesToProject in resource add AddResourcesToProject diffs the passed list against resources.json. Passing only the new resource caused it to treat all existing resources as removals. Now passes existing + new. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/resource/add.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/resource/add.go b/cmd/resource/add.go index 623aff3..3dd6c08 100644 --- a/cmd/resource/add.go +++ b/cmd/resource/add.go @@ -83,8 +83,9 @@ func runAdd(cobraCmd *cobra.Command) error { return errors.WrapError("failed to save resources", err) } - // Generate local client code - if err := utils.AddResourcesToProject(cobraCmd, ".", []api.ResourceItem{*targetResource}, appInfo.ApplicationID); err != nil { + // Generate local client code — pass full resource list so the diff works correctly + allResources := append(appResources.Resources, *targetResource) + if err := utils.AddResourcesToProject(cobraCmd, ".", allResources, appInfo.ApplicationID); err != nil { return errors.WrapError("failed to add resource to project", err) } From 91c17382700bfedeb0f63f80ac72c0bc54ce1642 Mon Sep 17 00:00:00 2001 From: josegironn <30703536+josegironn@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:50:16 -0700 Subject: [PATCH 4/4] fix: rewrite resource add/remove to match manage pattern - Use ReadLocalResources (resources.json) as source of truth instead of GetApplicationResources (which returns all org resources) - Extract ResolveResourceItems helper, reuse in manage/add/remove - Remove now uses AddResourcesToProject for consistent client cleanup - Bump plugin version to 1.0.4 Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude-plugin/marketplace.json | 2 +- cmd/resource/add.go | 38 +++++++++++------------ cmd/resource/remove.go | 53 +++++++++++++++------------------ utils/resources.go | 26 +++++++++------- 4 files changed, 59 insertions(+), 60 deletions(-) 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/cmd/resource/add.go b/cmd/resource/add.go index 3dd6c08..4dd144e 100644 --- a/cmd/resource/add.go +++ b/cmd/resource/add.go @@ -41,12 +41,13 @@ func runAdd(cobraCmd *cobra.Command) error { apiClient := singletons.GetAPIClient() - // Verify the resource exists in the org + // 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 { @@ -59,36 +60,33 @@ func runAdd(cobraCmd *cobra.Command) error { return fmt.Errorf("resource with ID %q not found in organization", flagAddResourceID) } - // Get current app resources - appResources, err := apiClient.GetApplicationResources(appInfo.ApplicationID) + // Read local resources.json (same as manage does) + existingResources, err := utils.ReadLocalResources(".") if err != nil { - return errors.WrapError("failed to get application resources", err) + cobraCmd.Printf("Warning: Could not read existing resources: %v\n", err) + existingResources = []utils.LocalResource{} } - // Check if already attached - resourceIDs := make([]string, 0, len(appResources.Resources)+1) - for _, r := range appResources.Resources { - if r.ID == flagAddResourceID { - cobraCmd.Printf("Resource %q is already attached to this application.\n", targetResource.Name) - return nil - } - resourceIDs = append(resourceIDs, r.ID) + // 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) - // Add the new resource - resourceIDs = append(resourceIDs, flagAddResourceID) - - _, err = apiClient.SaveApplicationResources(appInfo.OrganizationID, appInfo.ApplicationID, resourceIDs) + // Save to server + _, err = apiClient.SaveApplicationResources(appInfo.OrganizationID, appInfo.ApplicationID, selectedIDs) if err != nil { return errors.WrapError("failed to save resources", err) } - // Generate local client code — pass full resource list so the diff works correctly - allResources := append(appResources.Resources, *targetResource) - if err := utils.AddResourcesToProject(cobraCmd, ".", allResources, appInfo.ApplicationID); err != nil { + // 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) } - cobraCmd.Printf("Resource %q added successfully.\n", targetResource.Name) return nil } diff --git a/cmd/resource/remove.go b/cmd/resource/remove.go index 1ef7e20..171327c 100644 --- a/cmd/resource/remove.go +++ b/cmd/resource/remove.go @@ -2,8 +2,6 @@ package resource import ( "fmt" - "os" - "os/exec" "github.com/major-technology/cli/errors" "github.com/major-technology/cli/middleware" @@ -42,49 +40,46 @@ func runRemove(cobraCmd *cobra.Command) error { apiClient := singletons.GetAPIClient() - // Get current app resources - appResources, err := apiClient.GetApplicationResources(appInfo.ApplicationID) + // Fetch org resources (source of truth for resource metadata) + orgResources, err := apiClient.GetResources(appInfo.OrganizationID) if err != nil { - return errors.WrapError("failed to get application resources", err) + return errors.WrapError("failed to get resources", err) } - // Find the resource and build filtered list - var removedName string - var removedType string - resourceIDs := make([]string, 0, len(appResources.Resources)) - for _, r := range appResources.Resources { + // 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 { - removedName = r.Name - removedType = r.Type + found = true continue } - resourceIDs = append(resourceIDs, r.ID) + selectedIDs = append(selectedIDs, r.ID) } - if removedName == "" { + if !found { return fmt.Errorf("resource with ID %q is not attached to this application", flagRemoveResourceID) } - // Save the updated resource list - _, err = apiClient.SaveApplicationResources(appInfo.OrganizationID, appInfo.ApplicationID, resourceIDs) + // Save to server + _, err = apiClient.SaveApplicationResources(appInfo.OrganizationID, appInfo.ApplicationID, selectedIDs) if err != nil { return errors.WrapError("failed to save resources", err) } - // Remove local client code - cobraCmd.Printf("Removing resource: %s (%s)...\n", removedName, removedType) - framework := utils.DetectFramework(".") - args := []string{"exec", "major-client", "remove", removedName} - if framework != "" { - args = append(args, "--framework", framework) - } - pnpmCmd := exec.Command("pnpm", args...) - pnpmCmd.Stdout = os.Stdout - pnpmCmd.Stderr = os.Stderr - if err := pnpmCmd.Run(); err != nil { - cobraCmd.Printf("Warning: Failed to remove local resource files: %v\n", 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) } - cobraCmd.Printf("Resource %q removed successfully.\n", removedName) return nil } diff --git a/utils/resources.go b/utils/resources.go index 409806f..2e4385c 100644 --- a/utils/resources.go +++ b/utils/resources.go @@ -134,18 +134,24 @@ 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