Skip to content

Commit a61c547

Browse files
authored
Add hidden link command for 1 click runs (#74)
1 parent d5b015e commit a61c547

File tree

5 files changed

+206
-5
lines changed

5 files changed

+206
-5
lines changed

clients/api/client.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,3 +427,14 @@ func (c *Client) SetApplicationEnvironment(applicationID, environmentID string)
427427
}
428428
return &resp, nil
429429
}
430+
431+
// GetApplicationForLink retrieves application info needed for the link command
432+
func (c *Client) GetApplicationForLink(applicationID string) (*GetApplicationForLinkResponse, error) {
433+
var resp GetApplicationForLinkResponse
434+
path := fmt.Sprintf("/application/%s/link-info", applicationID)
435+
err := c.doRequest("GET", path, nil, &resp)
436+
if err != nil {
437+
return nil, err
438+
}
439+
return &resp, nil
440+
}

clients/api/structs.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,3 +289,12 @@ type SetEnvironmentChoiceResponse struct {
289289
EnvironmentID string `json:"environmentId,omitempty"`
290290
EnvironmentName string `json:"environmentName,omitempty"`
291291
}
292+
293+
// GetApplicationForLinkResponse represents the response from GET /application/:applicationId/link-info
294+
type GetApplicationForLinkResponse struct {
295+
Error *AppErrorDetail `json:"error,omitempty"`
296+
ApplicationID string `json:"applicationId,omitempty"`
297+
Name string `json:"name,omitempty"`
298+
CloneURLSSH string `json:"cloneUrlSsh,omitempty"`
299+
CloneURLHTTPS string `json:"cloneUrlHttps,omitempty"`
300+
}

cmd/app/link.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package app
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/charmbracelet/lipgloss"
8+
"github.com/major-technology/cli/clients/git"
9+
mjrToken "github.com/major-technology/cli/clients/token"
10+
"github.com/major-technology/cli/cmd/user"
11+
"github.com/major-technology/cli/errors"
12+
"github.com/major-technology/cli/singletons"
13+
"github.com/major-technology/cli/utils"
14+
"github.com/spf13/cobra"
15+
)
16+
17+
// linkCmd represents the link command (hidden - used by install script)
18+
var linkCmd = &cobra.Command{
19+
Use: "link [application-id]",
20+
Short: "Link and run an application locally",
21+
Long: `Links an application by ID - logs in if needed, clones the repository, and starts the development server.`,
22+
Args: cobra.ExactArgs(1),
23+
Hidden: true,
24+
RunE: func(cmd *cobra.Command, args []string) error {
25+
return runLink(cmd, args[0])
26+
},
27+
}
28+
29+
func init() {
30+
Cmd.AddCommand(linkCmd)
31+
}
32+
33+
func runLink(cmd *cobra.Command, applicationID string) error {
34+
// Step 1: Check if user is logged in, login if not
35+
_, err := mjrToken.GetToken()
36+
if err != nil {
37+
if err := user.RunLoginForLink(cmd); err != nil {
38+
return errors.WrapError("failed to login", err)
39+
}
40+
}
41+
42+
// Step 2: Fetch application info using the application ID
43+
cmd.Println("Fetching application info...")
44+
apiClient := singletons.GetAPIClient()
45+
46+
appInfo, err := apiClient.GetApplicationForLink(applicationID)
47+
if err != nil {
48+
return errors.WrapError("failed to get application info", err)
49+
}
50+
51+
cmd.Printf("Found application: %s\n", appInfo.Name)
52+
53+
// Step 3: Clone the repository
54+
desiredDir := sanitizeDirName(appInfo.Name)
55+
workingDir := desiredDir
56+
57+
// Check if directory exists
58+
if _, err := os.Stat(desiredDir); err == nil {
59+
cmd.Printf("Directory '%s' already exists. Pulling latest changes...\n", workingDir)
60+
if gitErr := git.Pull(workingDir); gitErr != nil {
61+
if isGitAuthError(gitErr) {
62+
// Ensure repository access
63+
if err := utils.EnsureRepositoryAccess(cmd, applicationID, appInfo.CloneURLSSH, appInfo.CloneURLHTTPS); err != nil {
64+
return errors.WrapError("failed to ensure repository access", err)
65+
}
66+
// Retry
67+
if err := git.Pull(workingDir); err != nil {
68+
return errors.ErrorGitRepositoryAccessFailed
69+
}
70+
} else {
71+
return errors.ErrorGitCloneFailed
72+
}
73+
}
74+
} else {
75+
cmd.Printf("Cloning repository to '%s'...\n", workingDir)
76+
_, gitErr := cloneRepository(appInfo.CloneURLSSH, appInfo.CloneURLHTTPS, workingDir)
77+
if gitErr != nil {
78+
if isGitAuthError(gitErr) {
79+
// Ensure repository access
80+
if err := utils.EnsureRepositoryAccess(cmd, applicationID, appInfo.CloneURLSSH, appInfo.CloneURLHTTPS); err != nil {
81+
return errors.WrapError("failed to ensure repository access", err)
82+
}
83+
// Retry with retries
84+
gitErr = pullOrCloneWithRetries(cmd, workingDir, appInfo.CloneURLSSH, appInfo.CloneURLHTTPS)
85+
if gitErr != nil {
86+
return errors.ErrorGitRepositoryAccessFailed
87+
}
88+
} else {
89+
return errors.ErrorGitCloneFailed
90+
}
91+
}
92+
}
93+
94+
cmd.Println("✓ Repository ready")
95+
96+
// Step 4: Generate .env file
97+
cmd.Println("Generating .env file...")
98+
envFilePath, _, err := generateEnvFile(workingDir)
99+
if err != nil {
100+
return errors.WrapError("failed to generate .env file", err)
101+
}
102+
cmd.Printf("✓ Generated .env file at: %s\n", envFilePath)
103+
104+
// Step 5: Print success and run start
105+
printLinkSuccessMessage(cmd, workingDir, appInfo.Name)
106+
107+
// Step 6: Run pnpm install and pnpm dev in the target directory
108+
return RunStartInDir(cmd, workingDir)
109+
}
110+
111+
func printLinkSuccessMessage(cmd *cobra.Command, dir, appName string) {
112+
// Define styles
113+
successStyle := lipgloss.NewStyle().
114+
Bold(true).
115+
Foreground(lipgloss.Color("10")). // Green
116+
MarginTop(1).
117+
MarginBottom(1)
118+
119+
boxStyle := lipgloss.NewStyle().
120+
Border(lipgloss.RoundedBorder()).
121+
BorderForeground(lipgloss.Color("12")). // Blue
122+
Padding(1, 2).
123+
MarginTop(1).
124+
MarginBottom(1)
125+
126+
pathStyle := lipgloss.NewStyle().
127+
Bold(true).
128+
Foreground(lipgloss.Color("14")) // Cyan
129+
130+
// Build the message
131+
successMsg := successStyle.Render(fmt.Sprintf("🎉 Successfully linked %s!", appName))
132+
133+
content := fmt.Sprintf("Application cloned to: %s", pathStyle.Render(dir))
134+
135+
box := boxStyle.Render(content)
136+
137+
// Print everything
138+
cmd.Println(successMsg)
139+
cmd.Println(box)
140+
}

cmd/app/start.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package app
33
import (
44
"os"
55
"os/exec"
6+
"path/filepath"
67

78
"github.com/major-technology/cli/errors"
89
"github.com/spf13/cobra"
@@ -25,22 +26,43 @@ func runStart(cobraCmd *cobra.Command) error {
2526
return errors.WrapError("failed to generate .env file", err)
2627
}
2728

29+
// Run start in current directory
30+
return RunStartInDir(cobraCmd, "")
31+
}
32+
33+
// RunStartInDir runs pnpm install and pnpm dev in the specified directory.
34+
// If dir is empty, it uses the current directory.
35+
func RunStartInDir(cmd *cobra.Command, dir string) error {
36+
var absDir string
37+
var err error
38+
39+
if dir == "" {
40+
absDir, err = os.Getwd()
41+
} else {
42+
absDir, err = filepath.Abs(dir)
43+
}
44+
if err != nil {
45+
return errors.WrapError("failed to get directory path", err)
46+
}
47+
2848
// Run pnpm install
29-
cobraCmd.Println("Running pnpm install...")
49+
cmd.Println("Running pnpm install...")
3050
installCmd := exec.Command("pnpm", "install")
51+
installCmd.Dir = absDir
3152
installCmd.Stdout = os.Stdout
3253
installCmd.Stderr = os.Stderr
3354
installCmd.Stdin = os.Stdin
3455

3556
if err := installCmd.Run(); err != nil {
36-
return errors.WrapError("failed to run pnpm install: %w", err)
57+
return errors.WrapError("failed to run pnpm install", err)
3758
}
3859

39-
cobraCmd.Println("✓ Dependencies installed")
60+
cmd.Println("✓ Dependencies installed")
4061

4162
// Run pnpm dev
42-
cobraCmd.Println("\nStarting development server...")
63+
cmd.Println("\nStarting development server...")
4364
devCmd := exec.Command("pnpm", "dev")
65+
devCmd.Dir = absDir
4466
devCmd.Stdout = os.Stdout
4567
devCmd.Stderr = os.Stderr
4668
devCmd.Stdin = os.Stdin

cmd/user/login.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ var loginCmd = &cobra.Command{
2626
}
2727

2828
func runLogin(cobraCmd *cobra.Command) error {
29+
if err := doLogin(cobraCmd); err != nil {
30+
return err
31+
}
32+
printSuccessMessage(cobraCmd)
33+
return nil
34+
}
35+
36+
// doLogin performs the core login flow: browser auth, token storage, and org selection.
37+
// Used by both runLogin and RunLoginForLink.
38+
func doLogin(cobraCmd *cobra.Command) error {
2939
// Get the API client (no token yet for login flow)
3040
apiClient := singletons.GetAPIClient()
3141
startResp, err := apiClient.StartLogin()
@@ -70,7 +80,6 @@ func runLogin(cobraCmd *cobra.Command) error {
7080
return clierrors.ErrorNoOrganizationsAvailable
7181
}
7282

73-
printSuccessMessage(cobraCmd)
7483
return nil
7584
}
7685

@@ -153,6 +162,16 @@ func SelectOrganization(cobraCmd *cobra.Command, orgs []apiClient.Organization)
153162
return nil, fmt.Errorf("selected organization not found")
154163
}
155164

165+
// RunLoginForLink is an exported function that runs the login flow for the link command.
166+
// It's a simplified version that doesn't print the full success message.
167+
func RunLoginForLink(cobraCmd *cobra.Command) error {
168+
if err := doLogin(cobraCmd); err != nil {
169+
return err
170+
}
171+
cobraCmd.Println("✓ Successfully authenticated!")
172+
return nil
173+
}
174+
156175
// printSuccessMessage displays a nicely formatted success message with next steps
157176
func printSuccessMessage(cobraCmd *cobra.Command) {
158177
// Define styles

0 commit comments

Comments
 (0)