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)
-}