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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions clients/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
9 changes: 9 additions & 0 deletions clients/api/structs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
140 changes: 140 additions & 0 deletions cmd/app/link.go
Original file line number Diff line number Diff line change
@@ -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)
}
30 changes: 26 additions & 4 deletions cmd/app/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package app
import (
"os"
"os/exec"
"path/filepath"

"github.com/major-technology/cli/errors"
"github.com/spf13/cobra"
Expand All @@ -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
Expand Down
21 changes: 20 additions & 1 deletion cmd/user/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -70,7 +80,6 @@ func runLogin(cobraCmd *cobra.Command) error {
return clierrors.ErrorNoOrganizationsAvailable
}

printSuccessMessage(cobraCmd)
return nil
}

Expand Down Expand Up @@ -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
Expand Down