diff --git a/clients/api/client.go b/clients/api/client.go index ce6b718..b9145ab 100644 --- a/clients/api/client.go +++ b/clients/api/client.go @@ -427,3 +427,14 @@ func (c *Client) SetApplicationEnvironment(applicationID, environmentID string) } return &resp, nil } + +// GetApplicationForLink retrieves application info needed for the link command +func (c *Client) GetApplicationForLink(applicationID string) (*GetApplicationForLinkResponse, error) { + var resp GetApplicationForLinkResponse + path := fmt.Sprintf("/application/%s/link-info", applicationID) + err := c.doRequest("GET", path, nil, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} diff --git a/clients/api/structs.go b/clients/api/structs.go index 33b26ae..27688ca 100644 --- a/clients/api/structs.go +++ b/clients/api/structs.go @@ -289,3 +289,12 @@ type SetEnvironmentChoiceResponse struct { EnvironmentID string `json:"environmentId,omitempty"` EnvironmentName string `json:"environmentName,omitempty"` } + +// GetApplicationForLinkResponse represents the response from GET /application/:applicationId/link-info +type GetApplicationForLinkResponse struct { + Error *AppErrorDetail `json:"error,omitempty"` + ApplicationID string `json:"applicationId,omitempty"` + Name string `json:"name,omitempty"` + CloneURLSSH string `json:"cloneUrlSsh,omitempty"` + CloneURLHTTPS string `json:"cloneUrlHttps,omitempty"` +} diff --git a/cmd/app/link.go b/cmd/app/link.go new file mode 100644 index 0000000..e1889f5 --- /dev/null +++ b/cmd/app/link.go @@ -0,0 +1,140 @@ +package app + +import ( + "fmt" + "os" + + "github.com/charmbracelet/lipgloss" + "github.com/major-technology/cli/clients/git" + mjrToken "github.com/major-technology/cli/clients/token" + "github.com/major-technology/cli/cmd/user" + "github.com/major-technology/cli/errors" + "github.com/major-technology/cli/singletons" + "github.com/major-technology/cli/utils" + "github.com/spf13/cobra" +) + +// linkCmd represents the link command (hidden - used by install script) +var linkCmd = &cobra.Command{ + Use: "link [application-id]", + Short: "Link and run an application locally", + Long: `Links an application by ID - logs in if needed, clones the repository, and starts the development server.`, + Args: cobra.ExactArgs(1), + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runLink(cmd, args[0]) + }, +} + +func init() { + Cmd.AddCommand(linkCmd) +} + +func runLink(cmd *cobra.Command, applicationID string) error { + // Step 1: Check if user is logged in, login if not + _, err := mjrToken.GetToken() + if err != nil { + if err := user.RunLoginForLink(cmd); err != nil { + return errors.WrapError("failed to login", err) + } + } + + // Step 2: Fetch application info using the application ID + cmd.Println("Fetching application info...") + apiClient := singletons.GetAPIClient() + + appInfo, err := apiClient.GetApplicationForLink(applicationID) + if err != nil { + return errors.WrapError("failed to get application info", err) + } + + cmd.Printf("Found application: %s\n", appInfo.Name) + + // Step 3: Clone the repository + desiredDir := sanitizeDirName(appInfo.Name) + workingDir := desiredDir + + // Check if directory exists + if _, err := os.Stat(desiredDir); err == nil { + cmd.Printf("Directory '%s' already exists. Pulling latest changes...\n", workingDir) + if gitErr := git.Pull(workingDir); gitErr != nil { + if isGitAuthError(gitErr) { + // Ensure repository access + if err := utils.EnsureRepositoryAccess(cmd, applicationID, appInfo.CloneURLSSH, appInfo.CloneURLHTTPS); err != nil { + return errors.WrapError("failed to ensure repository access", err) + } + // Retry + if err := git.Pull(workingDir); err != nil { + return errors.ErrorGitRepositoryAccessFailed + } + } else { + return errors.ErrorGitCloneFailed + } + } + } else { + cmd.Printf("Cloning repository to '%s'...\n", workingDir) + _, gitErr := cloneRepository(appInfo.CloneURLSSH, appInfo.CloneURLHTTPS, workingDir) + if gitErr != nil { + if isGitAuthError(gitErr) { + // Ensure repository access + if err := utils.EnsureRepositoryAccess(cmd, applicationID, appInfo.CloneURLSSH, appInfo.CloneURLHTTPS); err != nil { + return errors.WrapError("failed to ensure repository access", err) + } + // Retry with retries + gitErr = pullOrCloneWithRetries(cmd, workingDir, appInfo.CloneURLSSH, appInfo.CloneURLHTTPS) + if gitErr != nil { + return errors.ErrorGitRepositoryAccessFailed + } + } else { + return errors.ErrorGitCloneFailed + } + } + } + + cmd.Println("✓ Repository ready") + + // Step 4: Generate .env file + cmd.Println("Generating .env file...") + envFilePath, _, err := generateEnvFile(workingDir) + if err != nil { + return errors.WrapError("failed to generate .env file", err) + } + cmd.Printf("✓ Generated .env file at: %s\n", envFilePath) + + // Step 5: Print success and run start + printLinkSuccessMessage(cmd, workingDir, appInfo.Name) + + // Step 6: Run pnpm install and pnpm dev in the target directory + return RunStartInDir(cmd, workingDir) +} + +func printLinkSuccessMessage(cmd *cobra.Command, dir, appName string) { + // Define styles + successStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("10")). // Green + MarginTop(1). + MarginBottom(1) + + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("12")). // Blue + Padding(1, 2). + MarginTop(1). + MarginBottom(1) + + pathStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("14")) // Cyan + + // Build the message + successMsg := successStyle.Render(fmt.Sprintf("🎉 Successfully linked %s!", appName)) + + content := fmt.Sprintf("Application cloned to: %s", pathStyle.Render(dir)) + + box := boxStyle.Render(content) + + // Print everything + cmd.Println(successMsg) + cmd.Println(box) +} diff --git a/cmd/app/start.go b/cmd/app/start.go index af03270..eb1034b 100644 --- a/cmd/app/start.go +++ b/cmd/app/start.go @@ -3,6 +3,7 @@ package app import ( "os" "os/exec" + "path/filepath" "github.com/major-technology/cli/errors" "github.com/spf13/cobra" @@ -25,22 +26,43 @@ func runStart(cobraCmd *cobra.Command) error { return errors.WrapError("failed to generate .env file", err) } + // Run start in current directory + return RunStartInDir(cobraCmd, "") +} + +// RunStartInDir runs pnpm install and pnpm dev in the specified directory. +// If dir is empty, it uses the current directory. +func RunStartInDir(cmd *cobra.Command, dir string) error { + var absDir string + var err error + + if dir == "" { + absDir, err = os.Getwd() + } else { + absDir, err = filepath.Abs(dir) + } + if err != nil { + return errors.WrapError("failed to get directory path", err) + } + // Run pnpm install - cobraCmd.Println("Running pnpm install...") + cmd.Println("Running pnpm install...") installCmd := exec.Command("pnpm", "install") + installCmd.Dir = absDir installCmd.Stdout = os.Stdout installCmd.Stderr = os.Stderr installCmd.Stdin = os.Stdin if err := installCmd.Run(); err != nil { - return errors.WrapError("failed to run pnpm install: %w", err) + return errors.WrapError("failed to run pnpm install", err) } - cobraCmd.Println("✓ Dependencies installed") + cmd.Println("✓ Dependencies installed") // Run pnpm dev - cobraCmd.Println("\nStarting development server...") + cmd.Println("\nStarting development server...") devCmd := exec.Command("pnpm", "dev") + devCmd.Dir = absDir devCmd.Stdout = os.Stdout devCmd.Stderr = os.Stderr devCmd.Stdin = os.Stdin diff --git a/cmd/user/login.go b/cmd/user/login.go index 0523c35..e8ed3c0 100644 --- a/cmd/user/login.go +++ b/cmd/user/login.go @@ -26,6 +26,16 @@ var loginCmd = &cobra.Command{ } func runLogin(cobraCmd *cobra.Command) error { + if err := doLogin(cobraCmd); err != nil { + return err + } + printSuccessMessage(cobraCmd) + return nil +} + +// doLogin performs the core login flow: browser auth, token storage, and org selection. +// Used by both runLogin and RunLoginForLink. +func doLogin(cobraCmd *cobra.Command) error { // Get the API client (no token yet for login flow) apiClient := singletons.GetAPIClient() startResp, err := apiClient.StartLogin() @@ -70,7 +80,6 @@ func runLogin(cobraCmd *cobra.Command) error { return clierrors.ErrorNoOrganizationsAvailable } - printSuccessMessage(cobraCmd) return nil } @@ -153,6 +162,16 @@ func SelectOrganization(cobraCmd *cobra.Command, orgs []apiClient.Organization) return nil, fmt.Errorf("selected organization not found") } +// RunLoginForLink is an exported function that runs the login flow for the link command. +// It's a simplified version that doesn't print the full success message. +func RunLoginForLink(cobraCmd *cobra.Command) error { + if err := doLogin(cobraCmd); err != nil { + return err + } + cobraCmd.Println("✓ Successfully authenticated!") + return nil +} + // printSuccessMessage displays a nicely formatted success message with next steps func printSuccessMessage(cobraCmd *cobra.Command) { // Define styles