diff --git a/cmd/root.go b/cmd/root.go index 7b95b592..992ca911 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,8 +22,8 @@ import ( flagscmd "github.com/launchdarkly/ldcli/cmd/flags" logincmd "github.com/launchdarkly/ldcli/cmd/login" memberscmd "github.com/launchdarkly/ldcli/cmd/members" - sdkactivecmd "github.com/launchdarkly/ldcli/cmd/sdk_active" resourcecmd "github.com/launchdarkly/ldcli/cmd/resources" + sdkactivecmd "github.com/launchdarkly/ldcli/cmd/sdk_active" setupcmd "github.com/launchdarkly/ldcli/cmd/setup" signupcmd "github.com/launchdarkly/ldcli/cmd/signup" sourcemapscmd "github.com/launchdarkly/ldcli/cmd/sourcemaps" @@ -46,6 +46,8 @@ type APIClients struct { MembersClient members.Client ProjectsClient projects.Client ResourcesClient resources.Client + Detector setup.Detector + Installer setup.Installer } type Command interface { @@ -256,12 +258,20 @@ func NewRootCommand( configCmd := configcmd.NewConfigCmd(configService, analyticsTrackerFn) cmd.AddCommand(configCmd.Cmd()) + detector := clients.Detector + if detector == nil { + detector = setup.FileDetector{} + } + installer := clients.Installer + if installer == nil { + installer = setup.PackageInstaller{} + } cmd.AddCommand(setupcmd.NewSetupCmd( analyticsTrackerFn, clients.ResourcesClient, clients.FlagsClient, - setup.StubDetector{}, - setup.StubInstaller{}, + detector, + installer, )) quickStartCmd := NewQuickStartCmd(analyticsTrackerFn, clients.EnvironmentsClient, clients.FlagsClient) quickStartCmd.Use = "quickstart" diff --git a/cmd/setup/install.go b/cmd/setup/install.go index 02df6b42..bc0ad313 100644 --- a/cmd/setup/install.go +++ b/cmd/setup/install.go @@ -60,7 +60,11 @@ func runInstall(installer setup.Installer) func(*cobra.Command, []string) error } fmt.Fprintf(cmd.OutOrStdout(), "SDK: %s\n", result.SDKID) - fmt.Fprintf(cmd.OutOrStdout(), "Package: %s@%s\n", result.Package, result.Version) + if result.Version != "" { + fmt.Fprintf(cmd.OutOrStdout(), "Package: %s@%s\n", result.Package, result.Version) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "Package: %s\n", result.Package) + } fmt.Fprintf(cmd.OutOrStdout(), "Command: %s\n", result.Command) fmt.Fprintf(cmd.OutOrStdout(), "Success: %t\n", result.Success) diff --git a/cmd/setup/setup_test.go b/cmd/setup/setup_test.go index 13e39627..e3b0a442 100644 --- a/cmd/setup/setup_test.go +++ b/cmd/setup/setup_test.go @@ -1,6 +1,8 @@ package setup_test import ( + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -9,11 +11,12 @@ import ( "github.com/launchdarkly/ldcli/cmd" "github.com/launchdarkly/ldcli/internal/analytics" "github.com/launchdarkly/ldcli/internal/resources" + "github.com/launchdarkly/ldcli/internal/setup" ) func TestInit(t *testing.T) { tmpDir := t.TempDir() - filePath := tmpDir + "/index.js" + filePath := filepath.Join(tmpDir, "index.js") args := []string{ "setup", "init", @@ -38,7 +41,7 @@ func TestInit(t *testing.T) { func TestInitJSON(t *testing.T) { tmpDir := t.TempDir() - filePath := tmpDir + "/index.js" + filePath := filepath.Join(tmpDir, "index.js") args := []string{ "setup", "init", @@ -114,10 +117,12 @@ func TestInitUnsupportedSDKJSON(t *testing.T) { assert.Contains(t, string(output), `"docs_url"`) } -func TestDetectStubReturnsError(t *testing.T) { +func TestDetect_UnknownProject_ReturnsError(t *testing.T) { + emptyDir := t.TempDir() args := []string{ "setup", "detect", "--access-token", "test-token", + "--path", emptyDir, } _, err := cmd.CallCmd( t, @@ -129,7 +134,138 @@ func TestDetectStubReturnsError(t *testing.T) { ) require.Error(t, err) - assert.Contains(t, err.Error(), "not yet implemented") + assert.Contains(t, err.Error(), "could not detect") +} + +func TestDetect_GoProject_ReturnsResult(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/app\n\ngo 1.21\n"), 0600) + require.NoError(t, err) + + args := []string{ + "setup", "detect", + "--access-token", "test-token", + "--path", dir, + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: &resources.MockClient{}, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), "go-server-sdk") +} + +func TestDetect_JSON(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/app\n\ngo 1.21\n"), 0600) + require.NoError(t, err) + + args := []string{ + "setup", "detect", + "--access-token", "test-token", + "--path", dir, + "--output", "json", + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ResourcesClient: &resources.MockClient{}}, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), `"sdk_id":"go-server-sdk"`) +} + +// mockInstaller is a simple Installer that returns a canned result, used to exercise +// runInstall output paths without executing real package manager commands. +type mockInstaller struct { + result *setup.InstallResult +} + +func (m mockInstaller) Install(_ string, detection *setup.DetectResult) (*setup.InstallResult, error) { + if m.result != nil { + return m.result, nil + } + return &setup.InstallResult{ + SDKID: detection.SDKID, + Package: "@launchdarkly/node-server-sdk", + Command: "npm install @launchdarkly/node-server-sdk", + Success: true, + }, nil +} + +func TestInstall_Plaintext(t *testing.T) { + args := []string{ + "setup", "install", + "--access-token", "test-token", + "--sdk-id", "node-server", + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: &resources.MockClient{}, + Installer: mockInstaller{}, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), "node-server") + assert.Contains(t, string(output), "@launchdarkly/node-server-sdk") +} + +func TestInstall_Plaintext_WithVersion(t *testing.T) { + args := []string{ + "setup", "install", + "--access-token", "test-token", + "--sdk-id", "node-server", + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: &resources.MockClient{}, + Installer: mockInstaller{result: &setup.InstallResult{ + SDKID: "node-server", + Package: "@launchdarkly/node-server-sdk", + Version: "9.7.0", + Command: "npm install @launchdarkly/node-server-sdk", + Success: true, + }}, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), "@launchdarkly/node-server-sdk@9.7.0") +} + +func TestInstall_JSON(t *testing.T) { + args := []string{ + "setup", "install", + "--access-token", "test-token", + "--sdk-id", "node-server", + "--output", "json", + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: &resources.MockClient{}, + Installer: mockInstaller{}, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), `"success":true`) } func TestInstallStubReturnsError(t *testing.T) { @@ -142,6 +278,7 @@ func TestInstallStubReturnsError(t *testing.T) { t, cmd.APIClients{ ResourcesClient: &resources.MockClient{}, + Installer: setup.StubInstaller{}, }, analytics.NoopClientFn{}.Tracker(), args, diff --git a/cmd/setup/wizard.go b/cmd/setup/wizard.go index 3f2929fb..c83e9237 100644 --- a/cmd/setup/wizard.go +++ b/cmd/setup/wizard.go @@ -27,6 +27,7 @@ const ( stepSelectProject wizardStep = iota stepSelectEnvironment stepDetect + stepSelectSDK stepInstall stepCreateFlag stepInit @@ -53,6 +54,7 @@ type wizardModel struct { environments []envItem projectList list.Model envList list.Model + sdkList list.Model selectedProject string selectedEnv string @@ -60,15 +62,25 @@ type wizardModel struct { clientSideID string mobileKey string - detectResult *setup.DetectResult - installResult *setup.InstallResult - flagKey string + detectedEntryPoint string + detectResult *setup.DetectResult + flagKey string initResult *setup.InitResult verifyResult *setup.VerifyResult quitting bool } +type sdkItem struct { + id string + language string + name string +} + +func (s sdkItem) Title() string { return s.name } +func (s sdkItem) Description() string { return s.language } +func (s sdkItem) FilterValue() string { return s.name } + type projectItem struct { key string name string @@ -96,6 +108,7 @@ type envDetailsFetchedMsg struct { mobileKey string } type detectDoneMsg struct{ result *setup.DetectResult } +type detectFailedMsg struct{} type installDoneMsg struct{ result *setup.InstallResult } type flagCreatedMsg struct{ key string } type initDoneMsg struct{ result *setup.InitResult } @@ -179,13 +192,18 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.step = stepDetect return m, m.runDetect() + case detectFailedMsg: + m.sdkList = m.buildSDKList("") + m.step = stepSelectSDK + return m, nil + case detectDoneMsg: - m.detectResult = msg.result - m.step = stepInstall - return m, m.runInstall() + m.detectedEntryPoint = msg.result.EntryPoint + m.sdkList = m.buildSDKList(msg.result.SDKID) + m.step = stepSelectSDK + return m, nil case installDoneMsg: - m.installResult = msg.result m.step = stepCreateFlag return m, m.runCreateFlag() @@ -222,9 +240,17 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch m.step { case stepSelectProject: - m.projectList, cmd = m.projectList.Update(msg) + if len(m.projects) > 0 { + m.projectList, cmd = m.projectList.Update(msg) + } case stepSelectEnvironment: - m.envList, cmd = m.envList.Update(msg) + if len(m.environments) > 0 { + m.envList, cmd = m.envList.Update(msg) + } + case stepSelectSDK: + if m.sdkList.Items() != nil { + m.sdkList, cmd = m.sdkList.Update(msg) + } } return m, cmd } @@ -254,6 +280,19 @@ func (m wizardModel) handleEnter() (tea.Model, tea.Cmd) { m.selectedEnv = selected.key return m, m.fetchEnvDetails() + case stepSelectSDK: + selected, ok := m.sdkList.SelectedItem().(sdkItem) + if !ok { + return m, nil + } + m.detectResult = &setup.DetectResult{ + SDKID: selected.id, + Language: selected.language, + EntryPoint: m.detectedEntryPoint, + } + m.step = stepInstall + return m, m.runInstall() + case stepWaitForApp: m.step = stepVerify return m, m.runVerify() @@ -288,6 +327,9 @@ func (m wizardModel) View() string { case stepDetect: return m.spinner.View() + " Detecting project type..." + case stepSelectSDK: + return m.sdkList.View() + case stepInstall: return m.spinner.View() + " Installing SDK..." @@ -314,11 +356,11 @@ func (m wizardModel) View() string { fmt.Sprintf("Flag %q has been created in project %q.\n", m.flagKey, m.selectedProject) + "Once you've initialized the SDK manually, your flag will be ready to use.\n" } - if m.verifyResult != nil && m.verifyResult.Active { + if m.verifyResult != nil && m.verifyResult.Active && m.detectResult != nil { return titleStyle.Render("Setup complete!") + "\n\n" + fmt.Sprintf("Your %s SDK is connected to LaunchDarkly.\n", m.detectResult.SDKID) + fmt.Sprintf("Flag %q is ready to use.\n\n", m.flagKey) + - "You can now toggle your flag at https://app.launchdarkly.com\n" + fmt.Sprintf("You can now toggle your flag at https://app.launchdarkly.com/projects/%s/flags/%s/targeting?env=%s\n", m.selectedProject, m.flagKey, m.selectedEnv) } return titleStyle.Render("Verification timed out") + "\n\n" + "The SDK did not report as active within the timeout period.\n" + @@ -328,6 +370,26 @@ func (m wizardModel) View() string { return "" } +// buildSDKList constructs the SDK selection list. If prioritizedID is non-empty +// the matching SDK is placed first; all others follow in their default order. +func (m wizardModel) buildSDKList(prioritizedID string) list.Model { + var first, rest []list.Item + for _, sdk := range setup.KnownSDKs { + item := sdkItem{id: sdk.ID, language: sdk.Language, name: sdk.Name} + if sdk.ID == prioritizedID { + first = append(first, item) + } else { + rest = append(rest, item) + } + } + items := append(first, rest...) + delegate := list.NewDefaultDelegate() + l := list.New(items, delegate, m.width, m.height-4) + l.Title = "Select your SDK:" + l.SetShowStatusBar(false) + return l +} + // Commands that perform async work func (m wizardModel) fetchProjects() tea.Cmd { @@ -433,7 +495,7 @@ func (m wizardModel) runDetect() tea.Cmd { } result, err := m.detector.Detect(dir) if err != nil { - return wizardErrMsg{err: err} + return detectFailedMsg{} } return detectDoneMsg{result: result} } diff --git a/cmd/setup/wizard_test.go b/cmd/setup/wizard_test.go new file mode 100644 index 00000000..a0eb55bd --- /dev/null +++ b/cmd/setup/wizard_test.go @@ -0,0 +1,159 @@ +package setup + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/setup" +) + +// detectDoneMsg always goes to stepSelectSDK with the detected SDK prioritized. + +func TestWizard_DetectDone_TransitionsToSDKSelection(t *testing.T) { + m := wizardModel{step: stepDetect} + + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{SDKID: "go-server-sdk", Language: "Go"}}) + updated := next.(wizardModel) + + assert.Equal(t, stepSelectSDK, updated.step) + assert.Equal(t, len(setup.KnownSDKs), len(updated.sdkList.Items())) +} + +func TestWizard_DetectDone_PrioritizesDetectedSDK(t *testing.T) { + m := wizardModel{step: stepDetect} + + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{SDKID: "go-server-sdk", Language: "Go"}}) + updated := next.(wizardModel) + + first := updated.sdkList.Items()[0].(sdkItem) + assert.Equal(t, "go-server-sdk", first.id) +} + +func TestWizard_DetectDone_DoesNotDuplicateDetectedSDK(t *testing.T) { + m := wizardModel{step: stepDetect} + + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{SDKID: "go-server-sdk"}}) + updated := next.(wizardModel) + + assert.Equal(t, len(setup.KnownSDKs), len(updated.sdkList.Items())) +} + +func TestWizard_DetectDone_DetectResultNotSetUntilUserConfirms(t *testing.T) { + m := wizardModel{step: stepDetect} + + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{SDKID: "go-server-sdk"}}) + updated := next.(wizardModel) + + assert.Nil(t, updated.detectResult) +} + +// detectFailedMsg goes to stepSelectSDK in default KnownSDKs order. + +func TestWizard_DetectFailed_TransitionsToSDKSelection(t *testing.T) { + m := wizardModel{step: stepDetect} + + next, _ := m.Update(detectFailedMsg{}) + updated := next.(wizardModel) + + assert.Equal(t, stepSelectSDK, updated.step) + assert.Equal(t, len(setup.KnownSDKs), len(updated.sdkList.Items())) +} + +func TestWizard_DetectFailed_ListInDefaultOrder(t *testing.T) { + m := wizardModel{step: stepDetect} + + next, _ := m.Update(detectFailedMsg{}) + updated := next.(wizardModel) + + for i, item := range updated.sdkList.Items() { + sdk := item.(sdkItem) + assert.Equal(t, setup.KnownSDKs[i].ID, sdk.id) + } +} + +// Selecting an SDK always sets detectResult and proceeds to install. + +func TestWizard_SelectSDK_SetsDetectResultAndProceedsToInstall(t *testing.T) { + m := wizardModel{step: stepDetect} + + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{SDKID: "go-server-sdk", Language: "Go"}}) + updated := next.(wizardModel) + require.Equal(t, stepSelectSDK, updated.step) + + // Press enter — selects the first (prioritized) SDK + next, cmd := updated.Update(tea.KeyMsg{Type: tea.KeyEnter}) + selected := next.(wizardModel) + + assert.Equal(t, stepInstall, selected.step) + require.NotNil(t, selected.detectResult) + assert.Equal(t, "go-server-sdk", selected.detectResult.SDKID) + assert.NotNil(t, cmd) +} + +func TestWizard_SelectSDK_UserCanOverrideDetection(t *testing.T) { + // Detection said go-server-sdk, but we'll navigate down and pick something else. + // Here we just verify that whatever is selected (not necessarily the detected SDK) + // becomes the detectResult. + m := wizardModel{step: stepDetect} + + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{SDKID: "go-server-sdk"}}) + updated := next.(wizardModel) + + // Move down to the second item + next, _ = updated.Update(tea.KeyMsg{Type: tea.KeyDown}) + updated = next.(wizardModel) + + next, _ = updated.Update(tea.KeyMsg{Type: tea.KeyEnter}) + selected := next.(wizardModel) + + require.NotNil(t, selected.detectResult) + // Second item should not be go-server-sdk + assert.NotEqual(t, "go-server-sdk", selected.detectResult.SDKID) +} + +func TestWizard_DetectDone_EntryPointStoredForLaterUse(t *testing.T) { + m := wizardModel{step: stepDetect} + + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{ + SDKID: "go-server-sdk", + Language: "Go", + EntryPoint: "/my/project/main.go", + }}) + updated := next.(wizardModel) + + // Entry point is not exposed on detectResult yet (user hasn't confirmed) + assert.Nil(t, updated.detectResult) + + // Confirm SDK selection — entry point should now be on detectResult + next, _ = updated.Update(tea.KeyMsg{Type: tea.KeyEnter}) + selected := next.(wizardModel) + + require.NotNil(t, selected.detectResult) + assert.Equal(t, "/my/project/main.go", selected.detectResult.EntryPoint) +} + +func TestWizard_WaitForApp_EnterTriggersVerify(t *testing.T) { + m := wizardModel{ + step: stepWaitForApp, + initResult: &setup.InitResult{SDKID: "go-server-sdk", FilePath: "/tmp/main.go", Success: true}, + } + + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated := next.(wizardModel) + + assert.Equal(t, stepVerify, updated.step) + assert.NotNil(t, cmd) +} + +func TestWizard_SelectSDK_EmptyList_DoesNotPanic(t *testing.T) { + m := wizardModel{step: stepSelectSDK} + + next, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated := next.(wizardModel) + + assert.Equal(t, stepSelectSDK, updated.step) + assert.Nil(t, updated.detectResult) +} diff --git a/internal/setup/detector.go b/internal/setup/detector.go index b70964f2..1af31a16 100644 --- a/internal/setup/detector.go +++ b/internal/setup/detector.go @@ -1,6 +1,11 @@ package setup -import "errors" +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) // DetectResult contains information about the user's project detected from the working directory. type DetectResult struct { @@ -25,3 +30,295 @@ var _ Detector = StubDetector{} func (StubDetector) Detect(_ string) (*DetectResult, error) { return nil, errors.New("detect is not yet implemented: a real Detector must be provided") } + +// FileDetector implements Detector by scanning the filesystem for known project indicators. +type FileDetector struct{} + +var _ Detector = FileDetector{} + +// Detect scans dir for known project files and returns a DetectResult with language, +// framework, SDK ID, package manager, and a suggested entry point file. +// Returns an error if the project type cannot be determined. +func (FileDetector) Detect(dir string) (*DetectResult, error) { + if result := detectNode(dir); result != nil { + return result, nil + } + if result := detectGo(dir); result != nil { + return result, nil + } + if result := detectPython(dir); result != nil { + return result, nil + } + if result := detectJava(dir); result != nil { + return result, nil + } + if result := detectSwift(dir); result != nil { + return result, nil + } + if result := detectDotnet(dir); result != nil { + return result, nil + } + return nil, errors.New("could not detect project language from directory; try specifying --sdk-id manually") +} + +func detectNode(dir string) *DetectResult { + pkgBytes, err := os.ReadFile(filepath.Join(dir, "package.json")) + if err != nil { + return nil + } + + var pkg struct { + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` + } + if json.Unmarshal(pkgBytes, &pkg) != nil { + return nil + } + + allDeps := make(map[string]string, len(pkg.Dependencies)+len(pkg.DevDependencies)) + for k, v := range pkg.Dependencies { + allDeps[k] = v + } + for k, v := range pkg.DevDependencies { + allDeps[k] = v + } + + pm := detectNodePM(dir) + + if _, ok := allDeps["next"]; ok { + return &DetectResult{ + Language: "JavaScript", + Framework: "Next.js", + PackageManager: pm, + SDKID: "node-server", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "src/index.ts", "src/index.js", + "pages/index.tsx", "pages/index.ts", "pages/index.js", + "index.js", + })), + } + } + + if _, ok := allDeps["react-native"]; ok { + return &DetectResult{ + Language: "JavaScript", + Framework: "React Native", + PackageManager: pm, + SDKID: "react-native", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "src/App.tsx", "src/App.jsx", "src/App.js", + "src/index.tsx", "src/index.jsx", "src/index.js", + "index.js", + })), + } + } + if _, ok := allDeps["react"]; ok { + return &DetectResult{ + Language: "JavaScript", + Framework: "React", + PackageManager: pm, + SDKID: "react-client-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "src/App.tsx", "src/App.jsx", "src/App.js", + "src/index.tsx", "src/index.jsx", "src/index.js", + "index.js", + })), + } + } + jsClientFrameworks := []struct{ dep, framework string }{ + {"backbone", "Backbone"}, + {"svelte", "Svelte"}, + {"vue", "Vue"}, + {"@angular/core", "Angular"}, + {"ember-source", "Ember"}, + {"preact", "Preact"}, + } + for _, fw := range jsClientFrameworks { + if _, ok := allDeps[fw.dep]; ok { + return &DetectResult{ + Language: "JavaScript", + Framework: fw.framework, + PackageManager: pm, + SDKID: "js-client-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "src/App.tsx", "src/App.jsx", "src/App.js", + "src/index.tsx", "src/index.jsx", "src/index.js", + "src/main.ts", "src/main.js", "index.js", + })), + } + } + } + + + return &DetectResult{ + Language: "JavaScript", + PackageManager: pm, + SDKID: "node-server", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "src/index.ts", "src/index.js", + "index.ts", "index.js", + "server.ts", "server.js", + "app.ts", "app.js", + })), + } +} + +func detectNodePM(dir string) string { + if _, err := os.Stat(filepath.Join(dir, "pnpm-lock.yaml")); err == nil { + return "pnpm" + } + if _, err := os.Stat(filepath.Join(dir, "yarn.lock")); err == nil { + return "yarn" + } + if _, err := os.Stat(filepath.Join(dir, "bun.lock")); err == nil { + return "bun" + } + return "npm" +} + +func detectGo(dir string) *DetectResult { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err != nil { + return nil + } + return &DetectResult{ + Language: "Go", + PackageManager: "go", + SDKID: "go-server-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{"cmd/main.go", "main.go"})), + } +} + +func detectPython(dir string) *DetectResult { + for _, indicator := range []string{"requirements.txt", "pyproject.toml", "setup.py"} { + if _, err := os.Stat(filepath.Join(dir, indicator)); err == nil { + return &DetectResult{ + Language: "Python", + PackageManager: "pip", + SDKID: "python-server-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "src/main.py", "manage.py", "app.py", "main.py", + })), + } + } + } + return nil +} + +func detectJava(dir string) *DetectResult { + for _, indicator := range []string{"pom.xml", "build.gradle", "build.gradle.kts"} { + if _, err := os.Stat(filepath.Join(dir, indicator)); err == nil { + pm := "gradle" + if indicator == "pom.xml" { + pm = "mvn" + } + // Android projects use Gradle but are distinguished by AndroidManifest.xml. + for _, manifest := range []string{ + "app/src/main/AndroidManifest.xml", + "src/main/AndroidManifest.xml", + } { + if _, err := os.Stat(filepath.Join(dir, manifest)); err == nil { + return &DetectResult{ + Language: "Java", + PackageManager: "gradle", + SDKID: "android-client-sdk", + EntryPoint: filepath.Join(dir, "app/src/main/java/MainActivity.java"), + } + } + } + return &DetectResult{ + Language: "Java", + PackageManager: pm, + SDKID: "java-server-sdk", + EntryPoint: filepath.Join(dir, "src/main/java/Main.java"), + } + } + } + return nil +} + +func detectSwift(dir string) *DetectResult { + pm := "spm" + if _, err := os.Stat(filepath.Join(dir, "Podfile")); err == nil { + pm = "cocoapods" + } + indicators := []string{"Package.swift", "Podfile"} + for _, f := range indicators { + if _, err := os.Stat(filepath.Join(dir, f)); err == nil { + return &DetectResult{ + Language: "Swift", + PackageManager: pm, + SDKID: "swift-client-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "Sources/main.swift", "App.swift", "ContentView.swift", "AppDelegate.swift", + })), + } + } + } + matches, _ := filepath.Glob(filepath.Join(dir, "*.xcodeproj")) + if len(matches) > 0 { + return &DetectResult{ + Language: "Swift", + PackageManager: pm, + SDKID: "swift-client-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "Sources/main.swift", "App.swift", "ContentView.swift", "AppDelegate.swift", + })), + } + } + return nil +} + +func detectDotnet(dir string) *DetectResult { + for _, pattern := range []string{"*.csproj", "*.sln"} { + matches, _ := filepath.Glob(filepath.Join(dir, pattern)) + if len(matches) > 0 { + return &DetectResult{ + Language: "C#", + PackageManager: "dotnet", + SDKID: "dotnet-server-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "Program.cs", "Startup.cs", "src/Program.cs", + })), + } + } + } + return nil +} + +// SDKOption describes a LaunchDarkly SDK available for use with ldcli setup. +type SDKOption struct { + ID string + Language string + Name string +} + +// KnownSDKs is the ordered list of SDKs available for manual selection when +// auto-detection fails or the user wants to override the detected SDK. +var KnownSDKs = []SDKOption{ + {ID: "node-server", Language: "JavaScript", Name: "Node.js"}, + {ID: "react-client-sdk", Language: "JavaScript", Name: "React"}, + {ID: "react-native", Language: "JavaScript", Name: "React Native"}, + {ID: "js-client-sdk", Language: "JavaScript", Name: "JavaScript (Browser)"}, + {ID: "python-server-sdk", Language: "Python", Name: "Python"}, + {ID: "go-server-sdk", Language: "Go", Name: "Go"}, + {ID: "java-server-sdk", Language: "Java", Name: "Java"}, + {ID: "android-client-sdk", Language: "Java", Name: "Android"}, + {ID: "dotnet-server-sdk", Language: "C#", Name: ".NET"}, + {ID: "swift-client-sdk", Language: "Swift", Name: "iOS/Swift"}, + {ID: "ruby-server-sdk", Language: "Ruby", Name: "Ruby"}, +} + +// firstExistingIn returns the first candidate that exists as a file in dir, +// or the last candidate if none exist (as a suggested path). +// Returns an empty string if candidates is empty. +func firstExistingIn(dir string, candidates []string) string { + if len(candidates) == 0 { + return "" + } + for _, c := range candidates { + if _, err := os.Stat(filepath.Join(dir, c)); err == nil { + return c + } + } + return candidates[len(candidates)-1] +} diff --git a/internal/setup/detector_test.go b/internal/setup/detector_test.go index 473ff675..ce8945ea 100644 --- a/internal/setup/detector_test.go +++ b/internal/setup/detector_test.go @@ -1,24 +1,357 @@ package setup import ( + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestStubDetector_ReturnsError(t *testing.T) { - d := StubDetector{} - result, err := d.Detect("/tmp") +// writeDetectFile writes content to a file in dir, creating parent directories as needed. +func writeDetectFile(t *testing.T, dir, name, content string) { + t.Helper() + path := filepath.Join(dir, name) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0600)) +} + +func TestFileDetector_DetectsReact(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0"}}`) + writeDetectFile(t, dir, "src/App.tsx", "// App") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "react-client-sdk", result.SDKID) + assert.Equal(t, "JavaScript", result.Language) + assert.Equal(t, "React", result.Framework) + assert.Equal(t, "npm", result.PackageManager) + assert.Equal(t, filepath.Join(dir, "src/App.tsx"), result.EntryPoint) +} + +func TestFileDetector_DetectsReactNative(t *testing.T) { + dir := t.TempDir() + // React Native projects always list both "react" and "react-native" as deps; + // react-native must be checked first so it takes priority over react. + writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0","react-native":"^0.73.0"}}`) + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "react-native", result.SDKID) + assert.Equal(t, "JavaScript", result.Language) + assert.Equal(t, "React Native", result.Framework) +} + +func TestFileDetector_DetectsNextJs(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0","next":"^14.0.0"}}`) + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "node-server", result.SDKID) + assert.Equal(t, "JavaScript", result.Language) + assert.Equal(t, "Next.js", result.Framework) +} + +func TestFileDetector_DetectsNodeJs(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"express":"^4.0.0"}}`) + writeDetectFile(t, dir, "index.js", "// entry") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "node-server", result.SDKID) + assert.Equal(t, "JavaScript", result.Language) + assert.Empty(t, result.Framework) + assert.Equal(t, filepath.Join(dir, "index.js"), result.EntryPoint) +} + +func TestFileDetector_DetectsGo(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "go.mod", "module example.com/myapp\n\ngo 1.21\n") + writeDetectFile(t, dir, "main.go", "package main\n") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "go-server-sdk", result.SDKID) + assert.Equal(t, "Go", result.Language) + assert.Equal(t, "go", result.PackageManager) + assert.Equal(t, filepath.Join(dir, "main.go"), result.EntryPoint) +} + +func TestFileDetector_DetectsPython_RequirementsTxt(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "requirements.txt", "flask==3.0.0\n") + writeDetectFile(t, dir, "app.py", "# app") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "python-server-sdk", result.SDKID) + assert.Equal(t, "Python", result.Language) + assert.Equal(t, "pip", result.PackageManager) + assert.Equal(t, filepath.Join(dir, "app.py"), result.EntryPoint) +} + +func TestFileDetector_DetectsPython_Pyproject(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "pyproject.toml", "[tool.poetry]\nname = \"myapp\"\n") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "python-server-sdk", result.SDKID) +} + +func TestFileDetector_DetectsJava_PomXml(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "pom.xml", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "java-server-sdk", result.SDKID) + assert.Equal(t, "Java", result.Language) + assert.Equal(t, "mvn", result.PackageManager) +} + +func TestFileDetector_DetectsJava_BuildGradle(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "build.gradle", "plugins { id 'java' }") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "java-server-sdk", result.SDKID) + assert.Equal(t, "gradle", result.PackageManager) +} + +func TestFileDetector_DetectsAndroid_BuildGradle(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "build.gradle", "plugins { id 'com.android.application' }") + writeDetectFile(t, dir, "app/src/main/AndroidManifest.xml", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "android-client-sdk", result.SDKID) + assert.Equal(t, "Java", result.Language) + assert.Equal(t, "gradle", result.PackageManager) +} + +func TestFileDetector_DetectsAndroid_KotlinDsl(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "build.gradle.kts", "plugins { id(\"com.android.application\") }") + writeDetectFile(t, dir, "app/src/main/AndroidManifest.xml", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "android-client-sdk", result.SDKID) + assert.Equal(t, "gradle", result.PackageManager) +} + +func TestFileDetector_DetectsJava_NotAndroid(t *testing.T) { + // build.gradle without AndroidManifest.xml should still return java-server-sdk + dir := t.TempDir() + writeDetectFile(t, dir, "build.gradle", "plugins { id 'java' }") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "java-server-sdk", result.SDKID) +} + +func TestFileDetector_UnknownProject_ReturnsError(t *testing.T) { + dir := t.TempDir() + + _, err := FileDetector{}.Detect(dir) + require.Error(t, err) - assert.Nil(t, result) - assert.Contains(t, err.Error(), "not yet implemented") + assert.Contains(t, err.Error(), "could not detect") +} + +func TestFileDetector_DetectsNodePM_Pnpm(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{}`) + writeDetectFile(t, dir, "pnpm-lock.yaml", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "pnpm", result.PackageManager) +} + +func TestFileDetector_DetectsNodePM_Yarn(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{}`) + writeDetectFile(t, dir, "yarn.lock", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "yarn", result.PackageManager) +} + +func TestFileDetector_DetectsNodePM_Bun(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{}`) + writeDetectFile(t, dir, "bun.lock", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "bun", result.PackageManager) } -func TestStubInstaller_ReturnsError(t *testing.T) { - i := StubInstaller{} - result, err := i.Install("/tmp", &DetectResult{}) +func TestFileDetector_DetectsJsClientFramework(t *testing.T) { + tests := []struct { + dep string + framework string + }{ + {"vue", "Vue"}, + {"svelte", "Svelte"}, + {"backbone", "Backbone"}, + {"@angular/core", "Angular"}, + {"ember-source", "Ember"}, + {"preact", "Preact"}, + } + for _, tt := range tests { + t.Run(tt.framework, func(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"`+tt.dep+`":"^1.0.0"}}`) + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "js-client-sdk", result.SDKID) + assert.Equal(t, tt.framework, result.Framework) + }) + } +} + +func TestFileDetector_DetectsSwift_PackageSwift(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "swift-client-sdk", result.SDKID) + assert.Equal(t, "Swift", result.Language) + assert.Equal(t, "spm", result.PackageManager) +} + +func TestFileDetector_DetectsSwift_Podfile(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Podfile", "platform :ios, '14.0'") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "swift-client-sdk", result.SDKID) + assert.Equal(t, "cocoapods", result.PackageManager) +} + +func TestFileDetector_DetectsSwift_XcodeProj(t *testing.T) { + dir := t.TempDir() + // .xcodeproj is a directory in practice, but we use Glob so creating the dir is enough + require.NoError(t, os.MkdirAll(filepath.Join(dir, "MyApp.xcodeproj"), 0755)) + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "swift-client-sdk", result.SDKID) + assert.Equal(t, "Swift", result.Language) +} + +func TestFileDetector_DetectsDotnet_Csproj(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "MyApp.csproj", "") + writeDetectFile(t, dir, "Program.cs", "// entry") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "dotnet-server-sdk", result.SDKID) + assert.Equal(t, "C#", result.Language) + assert.Equal(t, "dotnet", result.PackageManager) + assert.Equal(t, filepath.Join(dir, "Program.cs"), result.EntryPoint) +} + +func TestFileDetector_DetectsDotnet_Sln(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "MyApp.sln", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "dotnet-server-sdk", result.SDKID) + assert.Equal(t, "dotnet", result.PackageManager) +} + +func TestKnownSDKs_ContainsExpectedSDKs(t *testing.T) { + ids := make([]string, len(KnownSDKs)) + for i, sdk := range KnownSDKs { + ids[i] = sdk.ID + } + assert.Contains(t, ids, "node-server") + assert.Contains(t, ids, "react-client-sdk") + assert.Contains(t, ids, "react-native") + assert.Contains(t, ids, "python-server-sdk") + assert.Contains(t, ids, "go-server-sdk") + assert.Contains(t, ids, "java-server-sdk") + assert.Contains(t, ids, "dotnet-server-sdk") + assert.Contains(t, ids, "swift-client-sdk") + assert.Contains(t, ids, "ruby-server-sdk") +} + +func TestFileDetector_EntryPointFallback_WhenNoneExist(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0"}}`) + // No src/App.tsx or other entry point files + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + // Falls back to last candidate + assert.NotEmpty(t, result.EntryPoint) +} + +func TestFileDetector_MalformedPackageJSON_FallsThrough(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `not valid json {{{`) + // No other project indicators + + _, err := FileDetector{}.Detect(dir) + + // detectNode skips invalid JSON; no other indicators → error require.Error(t, err) - assert.Nil(t, result) - assert.Contains(t, err.Error(), "not yet implemented") + assert.Contains(t, err.Error(), "could not detect") +} + +func TestFirstExistingIn_EmptySlice_ReturnsEmpty(t *testing.T) { + result := firstExistingIn(t.TempDir(), []string{}) + assert.Empty(t, result) +} + +func TestFirstExistingIn_NoMatch_ReturnLastCandidate(t *testing.T) { + dir := t.TempDir() + result := firstExistingIn(dir, []string{"nonexistent.go", "also-nonexistent.go"}) + assert.Equal(t, "also-nonexistent.go", result) +} + +func TestFirstExistingIn_MatchesFirst(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "second.go", "") + writeDetectFile(t, dir, "first.go", "") + result := firstExistingIn(dir, []string{"first.go", "second.go"}) + assert.Equal(t, "first.go", result) } diff --git a/internal/setup/initializer.go b/internal/setup/initializer.go index 75dc72a1..6945f474 100644 --- a/internal/setup/initializer.go +++ b/internal/setup/initializer.go @@ -5,9 +5,15 @@ import ( "embed" "errors" "fmt" + "go/format" + "go/parser" + "go/token" "os" + "path/filepath" "strings" "text/template" + + "golang.org/x/tools/go/ast/astutil" ) //go:embed sdk_init_templates/*.tmpl @@ -42,7 +48,7 @@ var sdkTemplates = map[string]sdkTemplateInfo{ "react-native": {TemplateFile: "react-native.tmpl"}, "js-client-sdk": {TemplateFile: "js-client-sdk.tmpl"}, "swift-client-sdk": {TemplateFile: "swift-client-sdk.tmpl"}, - "android": {TemplateFile: "android.tmpl"}, + "android-client-sdk": {TemplateFile: "android.tmpl"}, "java-server-sdk": {TemplateFile: "java-server-sdk.tmpl"}, "ruby-server-sdk": {TemplateFile: "ruby-server-sdk.tmpl"}, "go-server-sdk": {TemplateFile: "go-server-sdk.tmpl"}, @@ -180,12 +186,20 @@ func (i Initializer) InjectIntoFile(sdkID, filePath string, cfg InitConfig) (*In return nil, fmt.Errorf("reading %s: %w", filePath, err) } - content := string(existing) - - if importSection != "" { - content = importSection + "\n" + content + var content string + if importSection != "" && filepath.Ext(filePath) == ".go" { + merged, err := injectGoImports(filePath, existing, importSection) + if err != nil { + return nil, fmt.Errorf("injecting imports into %s: %w", filePath, err) + } + content = merged + "\n\n" + initSection + "\n" + } else { + content = string(existing) + if importSection != "" { + content = importSection + "\n" + content + } + content = content + "\n\n" + initSection + "\n" } - content = content + "\n\n" + initSection + "\n" if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { return nil, fmt.Errorf("writing %s: %w", filePath, err) @@ -194,6 +208,46 @@ func (i Initializer) InjectIntoFile(sdkID, filePath string, cfg InitConfig) (*In return &InitResult{SDKID: sdkID, FilePath: filePath, Success: true}, nil } +// injectGoImports parses importSection as a Go import block, then uses astutil to +// add each import into the existing file's AST, preserving all existing imports. +// Returns the formatted Go source with the new imports merged in. +func injectGoImports(filePath string, existing []byte, importSection string) (string, error) { + // Wrap importSection in a minimal Go file so go/parser can parse it. + wrapped := "package p\n\n" + strings.TrimSpace(importSection) + "\n" + fset := token.NewFileSet() + tmplFile, err := parser.ParseFile(fset, "", wrapped, 0) + if err != nil { + return "", fmt.Errorf("parsing import section: %w", err) + } + + // Parse the target file. + targetFset := token.NewFileSet() + targetFile, err := parser.ParseFile(targetFset, filePath, existing, parser.ParseComments) + if err != nil { + return "", fmt.Errorf("parsing %s: %w", filePath, err) + } + + // Add each import from the template into the target file's AST. + for _, spec := range tmplFile.Imports { + path := strings.Trim(spec.Path.Value, `"`) + name := "" + if spec.Name != nil { + name = spec.Name.Name + } + if name != "" { + astutil.AddNamedImport(targetFset, targetFile, name, path) + } else { + astutil.AddImport(targetFset, targetFile, path) + } + } + + var buf bytes.Buffer + if err := format.Node(&buf, targetFset, targetFile); err != nil { + return "", fmt.Errorf("formatting %s: %w", filePath, err) + } + return buf.String(), nil +} + // initSeparators lists the markers that divide import and init sections in templates. var initSeparators = []string{ "// --- init ---", diff --git a/internal/setup/initializer_test.go b/internal/setup/initializer_test.go index afb93a98..51365b35 100644 --- a/internal/setup/initializer_test.go +++ b/internal/setup/initializer_test.go @@ -3,6 +3,7 @@ package setup import ( "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -27,7 +28,7 @@ func TestRenderTemplate(t *testing.T) { {"react-native", "react-native", "mob-key-789"}, {"js-client-sdk", "js-client-sdk", "my-test-flag"}, {"swift-client-sdk", "swift-client-sdk", "mob-key-789"}, - {"android", "android", "mob-key-789"}, + {"android-client-sdk", "android-client-sdk", "mob-key-789"}, {"java-server-sdk", "java-server-sdk", "sdk-test-key-123"}, {"ruby-server-sdk", "ruby-server-sdk", "sdk-test-key-123"}, {"go-server-sdk", "go-server-sdk", "sdk-test-key-123"}, @@ -60,6 +61,8 @@ func TestRenderTemplateUnknownSDK_KnownDocsPath(t *testing.T) { func TestHasTemplate(t *testing.T) { assert.True(t, HasTemplate("node-server")) assert.True(t, HasTemplate("react-client-sdk")) + assert.True(t, HasTemplate("android-client-sdk")) + assert.False(t, HasTemplate("android")) assert.False(t, HasTemplate("nonexistent-sdk")) } @@ -144,6 +147,87 @@ func TestInjectIntoFile_NewFile_OmitsSeparator(t *testing.T) { } } +func TestInjectIntoFile_NewFile_AndroidClientSdk(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "MainActivity.java") + + initializer := Initializer{} + result, err := initializer.InjectIntoFile("android-client-sdk", filePath, InitConfig{ + MobileKey: "mob-test-key", + FlagKey: "test-flag", + }) + + require.NoError(t, err) + assert.True(t, result.Success) + assert.Equal(t, "android-client-sdk", result.SDKID) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Contains(t, string(content), "mob-test-key") +} + +func TestInjectIntoFile_ExistingFile_Go_DoesNotBreakPackageDeclaration(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "main.go") + + existing := "package main\n\nfunc main() {\n}\n" + err := os.WriteFile(filePath, []byte(existing), 0644) + require.NoError(t, err) + + initializer := Initializer{} + result, err := initializer.InjectIntoFile("go-server-sdk", filePath, InitConfig{ + SDKKey: "sdk-test-key", + FlagKey: "test-flag", + }) + + require.NoError(t, err) + assert.True(t, result.Success) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + // package declaration must remain first + assert.True(t, len(content) > 0 && string(content[:12]) == "package main") + assert.Contains(t, string(content), "sdk-test-key") +} + +func TestInjectIntoFile_ExistingFile_Go_MergesImports(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "main.go") + + existing := `package main + +import "fmt" + +func main() { + fmt.Println("hello") +} +` + err := os.WriteFile(filePath, []byte(existing), 0644) + require.NoError(t, err) + + initializer := Initializer{} + result, err := initializer.InjectIntoFile("go-server-sdk", filePath, InitConfig{ + SDKKey: "sdk-test-key", + FlagKey: "test-flag", + }) + + require.NoError(t, err) + assert.True(t, result.Success) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + s := string(content) + // package declaration must remain first + assert.True(t, strings.HasPrefix(s, "package main")) + // existing import preserved + assert.Contains(t, s, `"fmt"`) + // new imports added + assert.Contains(t, s, `"github.com/launchdarkly/go-sdk-common/v3/ldcontext"`) + assert.Contains(t, s, `ld "github.com/launchdarkly/go-server-sdk/v7"`) + // init code appended + assert.Contains(t, s, "sdk-test-key") +} + func TestInjectIntoFile_UnsupportedSDK_ReturnsDocsURL(t *testing.T) { initializer := Initializer{} result, err := initializer.InjectIntoFile("php-server-sdk", "/tmp/fake.php", InitConfig{}) diff --git a/internal/setup/installer.go b/internal/setup/installer.go index 2f5b258c..fccb3c5b 100644 --- a/internal/setup/installer.go +++ b/internal/setup/installer.go @@ -1,6 +1,11 @@ package setup -import "errors" +import ( + "errors" + "fmt" + "os/exec" + "strings" +) // InstallResult contains the outcome of installing an SDK package. type InstallResult struct { @@ -24,3 +29,118 @@ var _ Installer = StubInstaller{} func (StubInstaller) Install(_ string, _ *DetectResult) (*InstallResult, error) { return nil, errors.New("install is not yet implemented: a real Installer must be provided") } + +// PackageInstaller implements Installer using the system package manager. +// Its run field can be replaced in tests to avoid executing real commands. +type PackageInstaller struct { + run func(dir string, args []string) ([]byte, error) +} + +var _ Installer = PackageInstaller{} + +// Install runs the appropriate package manager command to add the SDK dependency. +// For SDKs that require manual installation (e.g. Java, Android, Swift), Install +// returns a result with Success=false without returning an error. +func (p PackageInstaller) Install(dir string, detection *DetectResult) (*InstallResult, error) { + args, pkg := InstallArgs(detection.SDKID, detection.PackageManager) + if len(args) == 0 { + return &InstallResult{ + SDKID: detection.SDKID, + Package: pkg, + Success: false, + }, nil + } + + runner := p.run + if runner == nil { + runner = execRun + } + + out, err := runner(dir, args) + command := strings.Join(args, " ") + if err != nil { + return nil, fmt.Errorf("%s: %w\n%s", command, err, strings.TrimSpace(string(out))) + } + return &InstallResult{ + SDKID: detection.SDKID, + Package: pkg, + Command: command, + Success: true, + }, nil +} + +func execRun(dir string, args []string) ([]byte, error) { + cmd := exec.Command(args[0], args[1:]...) //nolint:gosec + cmd.Dir = dir + return cmd.CombinedOutput() +} + +// InstallArgs returns the command-line arguments and package name for installing the given SDK. +// Returns nil args for SDKs that require manual installation (e.g. Java, Android, Swift). +// packageManager is used for Node.js SDKs; for other runtimes the appropriate tool is chosen automatically. +func InstallArgs(sdkID, packageManager string) (args []string, pkg string) { + switch sdkID { + case "react-client-sdk": + pkg = "launchdarkly-react-client-sdk" + return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg + case "react-native": + pkg = "launchdarkly-react-native-client-sdk" + return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg + case "node-server": + pkg = "@launchdarkly/node-server-sdk" + return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg + case "js-client-sdk": + pkg = "@launchdarkly/js-client-sdk" + return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg + case "python-server-sdk": + pm := packageManager + if pm == "" { + pm = "pip" + } + pkg = "launchdarkly-server-sdk" + return []string{pm, "install", pkg}, pkg + case "go-server-sdk": + pkg = "github.com/launchdarkly/go-server-sdk/v7" + return []string{"go", "get", pkg}, pkg + case "ruby-server-sdk": + pkg = "launchdarkly-server-sdk" + return []string{"gem", "install", pkg}, pkg + case "dotnet-server-sdk": + pkg = "LaunchDarkly.ServerSdk" + return []string{"dotnet", "add", "package", pkg}, pkg + // SDKs requiring manual installation — return a meaningful package identifier + // so callers can display what the user needs to add. + case "java-server-sdk": + return nil, "com.launchdarkly:launchdarkly-java-server-sdk" + case "android", "android-client-sdk": + return nil, "com.launchdarkly:launchdarkly-android-client-sdk" + case "swift-client-sdk", "ios-client-sdk": + return nil, "LaunchDarkly" // Swift Package Manager / CocoaPods + default: + return nil, sdkID + } +} + +// nodeInstallCmd returns the install command arguments for a Node.js package manager. +func nodeInstallCmd(pm, pkg string) []string { + switch pm { + case "yarn": + return []string{"yarn", "add", pkg} + case "pnpm": + return []string{"pnpm", "add", pkg} + case "bun": + return []string{"bun", "add", pkg} + default: + return []string{"npm", "install", pkg} + } +} + +// resolveNodePM normalises the package manager name, defaulting to "npm". +func resolveNodePM(pm string) string { + switch pm { + case "yarn", "pnpm", "bun": + return pm + default: + return "npm" + } +} diff --git a/internal/setup/installer_test.go b/internal/setup/installer_test.go new file mode 100644 index 00000000..b18de929 --- /dev/null +++ b/internal/setup/installer_test.go @@ -0,0 +1,162 @@ +package setup + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstallArgs_NodeSDKs(t *testing.T) { + tests := []struct { + sdkID string + pm string + wantCmd string + wantPkg string + }{ + {"react-client-sdk", "npm", "npm", "launchdarkly-react-client-sdk"}, + {"react-client-sdk", "yarn", "yarn", "launchdarkly-react-client-sdk"}, + {"react-client-sdk", "pnpm", "pnpm", "launchdarkly-react-client-sdk"}, + {"react-client-sdk", "bun", "bun", "launchdarkly-react-client-sdk"}, + {"react-client-sdk", "", "npm", "launchdarkly-react-client-sdk"}, + {"react-native", "npm", "npm", "launchdarkly-react-native-client-sdk"}, + {"react-native", "bun", "bun", "launchdarkly-react-native-client-sdk"}, + {"node-server", "npm", "npm", "@launchdarkly/node-server-sdk"}, + {"node-server", "yarn", "yarn", "@launchdarkly/node-server-sdk"}, + {"node-server", "pnpm", "pnpm", "@launchdarkly/node-server-sdk"}, + {"node-server", "bun", "bun", "@launchdarkly/node-server-sdk"}, + {"node-server", "", "npm", "@launchdarkly/node-server-sdk"}, + {"js-client-sdk", "npm", "npm", "@launchdarkly/js-client-sdk"}, + {"js-client-sdk", "bun", "bun", "@launchdarkly/js-client-sdk"}, + } + + for _, tt := range tests { + t.Run(tt.sdkID+"/"+tt.pm, func(t *testing.T) { + args, pkg := InstallArgs(tt.sdkID, tt.pm) + require.NotEmpty(t, args) + assert.Equal(t, tt.wantCmd, args[0]) + assert.Equal(t, tt.wantPkg, pkg) + assert.Contains(t, args, pkg) + }) + } +} + +func TestInstallArgs_Python(t *testing.T) { + args, pkg := InstallArgs("python-server-sdk", "") + require.NotEmpty(t, args) + assert.Equal(t, "pip", args[0]) + assert.Equal(t, "launchdarkly-server-sdk", pkg) + + args2, _ := InstallArgs("python-server-sdk", "pip3") + require.NotEmpty(t, args2) + assert.Equal(t, "pip3", args2[0]) +} + +func TestInstallArgs_Go(t *testing.T) { + args, pkg := InstallArgs("go-server-sdk", "") + require.NotEmpty(t, args) + assert.Equal(t, "go", args[0]) + assert.Equal(t, "get", args[1]) + assert.Equal(t, "github.com/launchdarkly/go-server-sdk/v7", pkg) +} + +func TestInstallArgs_Ruby(t *testing.T) { + args, pkg := InstallArgs("ruby-server-sdk", "") + require.NotEmpty(t, args) + assert.Equal(t, "gem", args[0]) + assert.Equal(t, "launchdarkly-server-sdk", pkg) +} + +func TestInstallArgs_Dotnet(t *testing.T) { + args, pkg := InstallArgs("dotnet-server-sdk", "") + require.NotEmpty(t, args) + assert.Equal(t, "dotnet", args[0]) + assert.Equal(t, "LaunchDarkly.ServerSdk", pkg) +} + +func TestInstallArgs_ManualSDKs(t *testing.T) { + tests := []struct { + sdkID string + wantPkg string + }{ + {"java-server-sdk", "com.launchdarkly:launchdarkly-java-server-sdk"}, + {"android", "com.launchdarkly:launchdarkly-android-client-sdk"}, + {"android-client-sdk", "com.launchdarkly:launchdarkly-android-client-sdk"}, + {"swift-client-sdk", "LaunchDarkly"}, + {"ios-client-sdk", "LaunchDarkly"}, + {"unknown-sdk-xyz", "unknown-sdk-xyz"}, // unknown falls back to SDK ID + } + for _, tt := range tests { + t.Run(tt.sdkID, func(t *testing.T) { + args, pkg := InstallArgs(tt.sdkID, "") + assert.Nil(t, args, "expected nil args for manual SDK %s", tt.sdkID) + assert.Equal(t, tt.wantPkg, pkg) + }) + } +} + +func TestPackageInstaller_Install_Success(t *testing.T) { + var capturedDir string + var capturedArgs []string + + installer := PackageInstaller{ + run: func(dir string, args []string) ([]byte, error) { + capturedDir = dir + capturedArgs = args + return []byte("added 1 package"), nil + }, + } + + result, err := installer.Install("/my/project", &DetectResult{ + SDKID: "node-server", + PackageManager: "npm", + }) + + require.NoError(t, err) + assert.True(t, result.Success) + assert.Equal(t, "node-server", result.SDKID) + assert.Equal(t, "@launchdarkly/node-server-sdk", result.Package) + assert.Equal(t, "npm install @launchdarkly/node-server-sdk", result.Command) + assert.Equal(t, "/my/project", capturedDir) + assert.Equal(t, []string{"npm", "install", "@launchdarkly/node-server-sdk"}, capturedArgs) +} + +func TestPackageInstaller_Install_CommandFailure(t *testing.T) { + installer := PackageInstaller{ + run: func(dir string, args []string) ([]byte, error) { + return []byte("npm ERR! not found"), errors.New("exit status 1") + }, + } + + _, err := installer.Install("/tmp", &DetectResult{ + SDKID: "node-server", + PackageManager: "npm", + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "npm install @launchdarkly/node-server-sdk") + assert.Contains(t, err.Error(), "npm ERR! not found") +} + +func TestPackageInstaller_Install_ManualSDK_ReturnsNoError(t *testing.T) { + installer := PackageInstaller{} + + result, err := installer.Install("/tmp", &DetectResult{SDKID: "java-server-sdk"}) + + require.NoError(t, err) + assert.False(t, result.Success) + assert.Equal(t, "java-server-sdk", result.SDKID) + assert.Empty(t, result.Command) +} + +func TestPackageInstaller_Install_DefaultRunner_UsedWhenNil(t *testing.T) { + // PackageInstaller{} (zero value) should not panic — it uses execRun. + // We test this by using a manual SDK so no real command is executed. + installer := PackageInstaller{} + + result, err := installer.Install("/tmp", &DetectResult{SDKID: "android"}) + + require.NoError(t, err) + assert.False(t, result.Success) +} diff --git a/internal/setup/verifier_test.go b/internal/setup/verifier_test.go index fe394477..8ce339af 100644 --- a/internal/setup/verifier_test.go +++ b/internal/setup/verifier_test.go @@ -1,7 +1,6 @@ package setup import ( - "net/url" "testing" "time" @@ -42,8 +41,3 @@ func TestVerify_InactiveTimesOut(t *testing.T) { assert.False(t, result.Active) assert.Greater(t, result.Attempts, 1) } - -func TestVerify_URLConstruction(t *testing.T) { - expected, _ := url.JoinPath("https://app.launchdarkly.com", "api/v2/projects", "my-proj", "environments", "my-env", "sdk-active") - assert.Equal(t, "https://app.launchdarkly.com/api/v2/projects/my-proj/environments/my-env/sdk-active", expected) -}