From e2550ed089197ccf5704e96ba79ac9a8624ec350 Mon Sep 17 00:00:00 2001 From: Dakota Sanchez Date: Tue, 12 May 2026 15:09:05 -0700 Subject: [PATCH 01/10] [REL-13574] sdk detection and installation --- cmd/root.go | 16 ++- cmd/setup/install.go | 6 +- cmd/setup/setup_test.go | 141 ++++++++++++++++++++++- internal/setup/detector.go | 167 ++++++++++++++++++++++++++- internal/setup/detector_test.go | 186 +++++++++++++++++++++++++++++++ internal/setup/installer.go | 120 +++++++++++++++++++- internal/setup/installer_test.go | 158 ++++++++++++++++++++++++++ 7 files changed, 786 insertions(+), 8 deletions(-) create mode 100644 internal/setup/installer_test.go 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..3a19cb2b 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,6 +11,7 @@ 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) { @@ -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/internal/setup/detector.go b/internal/setup/detector.go index b70964f2..32bcabe5 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,163 @@ 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 + } + 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"]; 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", + })), + } + } + + 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" + } + 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{"main.go", "cmd/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{ + "main.py", "app.py", "manage.py", "src/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" + } + return &DetectResult{ + Language: "Java", + PackageManager: pm, + SDKID: "java-server-sdk", + EntryPoint: filepath.Join(dir, "src/main/java/Main.java"), + } + } + } + return nil +} + +// 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..d1bfaf3f 100644 --- a/internal/setup/detector_test.go +++ b/internal/setup/detector_test.go @@ -1,6 +1,8 @@ package setup import ( + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -22,3 +24,187 @@ func TestStubInstaller_ReturnsError(t *testing.T) { assert.Nil(t, result) assert.Contains(t, err.Error(), "not yet implemented") } + +// 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_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_UnknownProject_ReturnsError(t *testing.T) { + dir := t.TempDir() + + _, err := FileDetector{}.Detect(dir) + + require.Error(t, err) + 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_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.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/installer.go b/internal/setup/installer.go index 2f5b258c..9c3cee64 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,116 @@ 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} + 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": + 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..66a085dc --- /dev/null +++ b/internal/setup/installer_test.go @@ -0,0 +1,158 @@ +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", "", "npm", "launchdarkly-react-client-sdk"}, + {"react-native", "npm", "npm", "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", "", "npm", "@launchdarkly/node-server-sdk"}, + {"js-client-sdk", "npm", "npm", "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) +} From 2c3530a9189e4cfb9d7c86a76f329e2f7da7b315 Mon Sep 17 00:00:00 2001 From: Dakota Sanchez Date: Wed, 13 May 2026 10:43:27 -0700 Subject: [PATCH 02/10] Update internal/setup/detector.go Co-authored-by: ari-launchdarkly --- internal/setup/detector.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/setup/detector.go b/internal/setup/detector.go index 32bcabe5..569975f1 100644 --- a/internal/setup/detector.go +++ b/internal/setup/detector.go @@ -127,6 +127,9 @@ func detectNodePM(dir string) string { 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" } From 4a751324dee3652cbba275941933299d8ab75e95 Mon Sep 17 00:00:00 2001 From: Dakota Sanchez Date: Wed, 13 May 2026 10:43:42 -0700 Subject: [PATCH 03/10] Update internal/setup/detector.go Co-authored-by: ari-launchdarkly --- internal/setup/detector.go | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/internal/setup/detector.go b/internal/setup/detector.go index 569975f1..3255da84 100644 --- a/internal/setup/detector.go +++ b/internal/setup/detector.go @@ -106,6 +106,42 @@ func detectNode(dir string) *DetectResult { })), } } + if _, ok := allDeps["react-native"]; ok { + return &DetectResult{ + Language: "JavaScript", + Framework: "React Native", + PackageManager: pm, + SDKID: "react-native-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 := map[string]string{ + "backbone": "Backbone", + "svelte": "Svelte", + "vue": "Vue", + "@angular/core": "Angular", + "ember-source": "Ember", + } + for dep, framework := range jsClientFrameworks { + if _, ok := allDeps[dep]; ok { + return &DetectResult{ + Language: "JavaScript", + Framework: 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", From e7eda6f35c949c4f3de0e5cd82bf65466bb2e70d Mon Sep 17 00:00:00 2001 From: Dakota Sanchez Date: Wed, 13 May 2026 10:43:57 -0700 Subject: [PATCH 04/10] Update internal/setup/installer.go Co-authored-by: ari-launchdarkly --- internal/setup/installer.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/setup/installer.go b/internal/setup/installer.go index 9c3cee64..6fd3e486 100644 --- a/internal/setup/installer.go +++ b/internal/setup/installer.go @@ -128,6 +128,8 @@ func nodeInstallCmd(pm, pkg string) []string { 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} } From 0382c5149dbea1cba50b3fe594f4624738ecdf6b Mon Sep 17 00:00:00 2001 From: Dakota Sanchez Date: Wed, 13 May 2026 10:44:09 -0700 Subject: [PATCH 05/10] Update internal/setup/installer.go Co-authored-by: ari-launchdarkly --- internal/setup/installer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/setup/installer.go b/internal/setup/installer.go index 6fd3e486..80feac88 100644 --- a/internal/setup/installer.go +++ b/internal/setup/installer.go @@ -138,7 +138,7 @@ func nodeInstallCmd(pm, pkg string) []string { // resolveNodePM normalises the package manager name, defaulting to "npm". func resolveNodePM(pm string) string { switch pm { - case "yarn", "pnpm": + case "yarn", "pnpm", "bun": return pm default: return "npm" From 718810b165aa4d70db873a8d66f25d4f37db1b16 Mon Sep 17 00:00:00 2001 From: Dakota Sanchez Date: Wed, 13 May 2026 12:06:38 -0700 Subject: [PATCH 06/10] [REL-13574] feedback --- cmd/setup/wizard.go | 44 ++++++++- cmd/setup/wizard_test.go | 68 +++++++++++++ internal/setup/detector.go | 153 +++++++++++++++++++++++------ internal/setup/detector_test.go | 163 +++++++++++++++++++++++++++++++ internal/setup/installer.go | 2 +- internal/setup/installer_test.go | 6 +- 6 files changed, 403 insertions(+), 33 deletions(-) create mode 100644 cmd/setup/wizard_test.go diff --git a/cmd/setup/wizard.go b/cmd/setup/wizard.go index 3f2929fb..49c8fa96 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 @@ -69,6 +71,16 @@ type wizardModel struct { 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,6 +192,18 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.step = stepDetect return m, m.runDetect() + case detectFailedMsg: + items := make([]list.Item, len(setup.KnownSDKs)) + for i, sdk := range setup.KnownSDKs { + items[i] = sdkItem{id: sdk.ID, language: sdk.Language, name: sdk.Name} + } + delegate := list.NewDefaultDelegate() + m.sdkList = list.New(items, delegate, m.width, m.height-4) + m.sdkList.Title = "Select your SDK:" + m.sdkList.SetShowStatusBar(false) + m.step = stepSelectSDK + return m, nil + case detectDoneMsg: m.detectResult = msg.result m.step = stepInstall @@ -225,6 +250,8 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.projectList, cmd = m.projectList.Update(msg) case stepSelectEnvironment: m.envList, cmd = m.envList.Update(msg) + case stepSelectSDK: + m.sdkList, cmd = m.sdkList.Update(msg) } return m, cmd } @@ -254,6 +281,18 @@ 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, + } + 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..." @@ -433,7 +475,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..06fe3afc --- /dev/null +++ b/cmd/setup/wizard_test.go @@ -0,0 +1,68 @@ +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" +) + +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_SDKListTitles(t *testing.T) { + m := wizardModel{step: stepDetect} + + next, _ := m.Update(detectFailedMsg{}) + updated := next.(wizardModel) + + // Verify the list items match KnownSDKs in order + for i, item := range updated.sdkList.Items() { + sdk := item.(sdkItem) + assert.Equal(t, setup.KnownSDKs[i].ID, sdk.id) + assert.Equal(t, setup.KnownSDKs[i].Name, sdk.name) + assert.Equal(t, setup.KnownSDKs[i].Language, sdk.language) + } +} + +func TestWizard_SelectSDK_SetsDetectResultAndProceedsToInstall(t *testing.T) { + m := wizardModel{step: stepDetect} + + // Transition to stepSelectSDK + next, _ := m.Update(detectFailedMsg{}) + updated := next.(wizardModel) + require.Equal(t, stepSelectSDK, updated.step) + + // Press enter — selects the first SDK in the list + 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, setup.KnownSDKs[0].ID, selected.detectResult.SDKID) + assert.Equal(t, setup.KnownSDKs[0].Language, selected.detectResult.Language) + // cmd is the runInstall tea.Cmd — should be non-nil + assert.NotNil(t, cmd) +} + +func TestWizard_SelectSDK_EmptyList_DoesNotPanic(t *testing.T) { + m := wizardModel{step: stepSelectSDK} + // sdkList not initialized — SelectedItem returns nil + + next, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated := next.(wizardModel) + + // Should stay on stepSelectSDK without panicking + assert.Equal(t, stepSelectSDK, updated.step) + assert.Nil(t, updated.detectResult) +} diff --git a/internal/setup/detector.go b/internal/setup/detector.go index 3255da84..484c14e4 100644 --- a/internal/setup/detector.go +++ b/internal/setup/detector.go @@ -52,6 +52,12 @@ func (FileDetector) Detect(dir string) (*DetectResult, error) { 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") } @@ -93,12 +99,12 @@ func detectNode(dir string) *DetectResult { } } - if _, ok := allDeps["react"]; ok { + if _, ok := allDeps["react-native"]; ok { return &DetectResult{ Language: "JavaScript", - Framework: "React", + Framework: "React Native", PackageManager: pm, - SDKID: "react-client-sdk", + 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", @@ -106,12 +112,12 @@ func detectNode(dir string) *DetectResult { })), } } - if _, ok := allDeps["react-native"]; ok { + if _, ok := allDeps["react"]; ok { return &DetectResult{ Language: "JavaScript", - Framework: "React Native", + Framework: "React", PackageManager: pm, - SDKID: "react-native-client-sdk", + 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", @@ -119,28 +125,29 @@ func detectNode(dir string) *DetectResult { })), } } - jsClientFrameworks := map[string]string{ - "backbone": "Backbone", - "svelte": "Svelte", - "vue": "Vue", - "@angular/core": "Angular", - "ember-source": "Ember", - } - for dep, framework := range jsClientFrameworks { - if _, ok := allDeps[dep]; ok { - return &DetectResult{ - Language: "JavaScript", - Framework: 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", - })), - } - } - } + 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{ @@ -177,7 +184,7 @@ func detectGo(dir string) *DetectResult { Language: "Go", PackageManager: "go", SDKID: "go-server-sdk", - EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{"main.go", "cmd/main.go"})), + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{"cmd/main.go", "main.go"})), } } @@ -189,7 +196,7 @@ func detectPython(dir string) *DetectResult { PackageManager: "pip", SDKID: "python-server-sdk", EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ - "main.py", "app.py", "manage.py", "src/main.py", + "src/main.py", "manage.py", "app.py", "main.py", })), } } @@ -204,6 +211,20 @@ func detectJava(dir string) *DetectResult { 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, @@ -215,6 +236,78 @@ func detectJava(dir string) *DetectResult { 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. diff --git a/internal/setup/detector_test.go b/internal/setup/detector_test.go index d1bfaf3f..76455837 100644 --- a/internal/setup/detector_test.go +++ b/internal/setup/detector_test.go @@ -48,6 +48,20 @@ func TestFileDetector_DetectsReact(t *testing.T) { 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"}}`) @@ -135,6 +149,42 @@ func TestFileDetector_DetectsJava_BuildGradle(t *testing.T) { 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() @@ -166,6 +216,119 @@ func TestFileDetector_DetectsNodePM_Yarn(t *testing.T) { 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 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"}}`) diff --git a/internal/setup/installer.go b/internal/setup/installer.go index 80feac88..fccb3c5b 100644 --- a/internal/setup/installer.go +++ b/internal/setup/installer.go @@ -90,7 +90,7 @@ func InstallArgs(sdkID, packageManager string) (args []string, pkg string) { pkg = "@launchdarkly/node-server-sdk" return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg case "js-client-sdk": - pkg = "launchdarkly-js-client-sdk" + pkg = "@launchdarkly/js-client-sdk" return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg case "python-server-sdk": pm := packageManager diff --git a/internal/setup/installer_test.go b/internal/setup/installer_test.go index 66a085dc..b18de929 100644 --- a/internal/setup/installer_test.go +++ b/internal/setup/installer_test.go @@ -18,13 +18,17 @@ func TestInstallArgs_NodeSDKs(t *testing.T) { {"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", "npm", "npm", "@launchdarkly/js-client-sdk"}, + {"js-client-sdk", "bun", "bun", "@launchdarkly/js-client-sdk"}, } for _, tt := range tests { From 0f11bed4335e5e2684f6051dc3baa286228a8d2e Mon Sep 17 00:00:00 2001 From: Dakota Sanchez Date: Wed, 13 May 2026 12:19:14 -0700 Subject: [PATCH 07/10] [REL-13574] change to always show (prioritized) SDK selector --- cmd/setup/wizard.go | 43 +++++++++++++-------- cmd/setup/wizard_test.go | 81 ++++++++++++++++++++++++++++++++++------ 2 files changed, 96 insertions(+), 28 deletions(-) diff --git a/cmd/setup/wizard.go b/cmd/setup/wizard.go index 49c8fa96..65ef937f 100644 --- a/cmd/setup/wizard.go +++ b/cmd/setup/wizard.go @@ -62,9 +62,8 @@ type wizardModel struct { clientSideID string mobileKey string - detectResult *setup.DetectResult - installResult *setup.InstallResult - flagKey string + detectResult *setup.DetectResult + flagKey string initResult *setup.InitResult verifyResult *setup.VerifyResult @@ -193,24 +192,16 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.runDetect() case detectFailedMsg: - items := make([]list.Item, len(setup.KnownSDKs)) - for i, sdk := range setup.KnownSDKs { - items[i] = sdkItem{id: sdk.ID, language: sdk.Language, name: sdk.Name} - } - delegate := list.NewDefaultDelegate() - m.sdkList = list.New(items, delegate, m.width, m.height-4) - m.sdkList.Title = "Select your SDK:" - m.sdkList.SetShowStatusBar(false) + m.sdkList = m.buildSDKList("") m.step = stepSelectSDK return m, nil case detectDoneMsg: - m.detectResult = msg.result - m.step = stepInstall - return m, m.runInstall() + 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() @@ -356,7 +347,7 @@ 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) + @@ -370,6 +361,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 { diff --git a/cmd/setup/wizard_test.go b/cmd/setup/wizard_test.go index 06fe3afc..36d1c17b 100644 --- a/cmd/setup/wizard_test.go +++ b/cmd/setup/wizard_test.go @@ -10,6 +10,48 @@ import ( "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} @@ -20,49 +62,64 @@ func TestWizard_DetectFailed_TransitionsToSDKSelection(t *testing.T) { assert.Equal(t, len(setup.KnownSDKs), len(updated.sdkList.Items())) } -func TestWizard_DetectFailed_SDKListTitles(t *testing.T) { +func TestWizard_DetectFailed_ListInDefaultOrder(t *testing.T) { m := wizardModel{step: stepDetect} next, _ := m.Update(detectFailedMsg{}) updated := next.(wizardModel) - // Verify the list items match KnownSDKs in order for i, item := range updated.sdkList.Items() { sdk := item.(sdkItem) assert.Equal(t, setup.KnownSDKs[i].ID, sdk.id) - assert.Equal(t, setup.KnownSDKs[i].Name, sdk.name) - assert.Equal(t, setup.KnownSDKs[i].Language, sdk.language) } } +// Selecting an SDK always sets detectResult and proceeds to install. + func TestWizard_SelectSDK_SetsDetectResultAndProceedsToInstall(t *testing.T) { m := wizardModel{step: stepDetect} - // Transition to stepSelectSDK - next, _ := m.Update(detectFailedMsg{}) + 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 SDK in the list + // 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, setup.KnownSDKs[0].ID, selected.detectResult.SDKID) - assert.Equal(t, setup.KnownSDKs[0].Language, selected.detectResult.Language) - // cmd is the runInstall tea.Cmd — should be non-nil + 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_SelectSDK_EmptyList_DoesNotPanic(t *testing.T) { m := wizardModel{step: stepSelectSDK} - // sdkList not initialized — SelectedItem returns nil next, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) updated := next.(wizardModel) - // Should stay on stepSelectSDK without panicking assert.Equal(t, stepSelectSDK, updated.step) assert.Nil(t, updated.detectResult) } From 3a9b9dce9d5928f886dbf678d38541f91c17e5c7 Mon Sep 17 00:00:00 2001 From: Dakota Sanchez Date: Wed, 13 May 2026 12:45:54 -0700 Subject: [PATCH 08/10] [REL-13574] lingering bugs --- cmd/setup/setup_test.go | 4 +- cmd/setup/wizard.go | 9 ++-- cmd/setup/wizard_test.go | 34 ++++++++++++++ internal/setup/detector.go | 2 +- internal/setup/detector_test.go | 16 ------- internal/setup/initializer.go | 2 +- internal/setup/initializer_test.go | 47 ++++++++++++++++++- .../sdk_init_templates/go-server-sdk.tmpl | 11 ++--- internal/setup/verifier_test.go | 6 --- 9 files changed, 94 insertions(+), 37 deletions(-) diff --git a/cmd/setup/setup_test.go b/cmd/setup/setup_test.go index 3a19cb2b..e3b0a442 100644 --- a/cmd/setup/setup_test.go +++ b/cmd/setup/setup_test.go @@ -16,7 +16,7 @@ import ( func TestInit(t *testing.T) { tmpDir := t.TempDir() - filePath := tmpDir + "/index.js" + filePath := filepath.Join(tmpDir, "index.js") args := []string{ "setup", "init", @@ -41,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", diff --git a/cmd/setup/wizard.go b/cmd/setup/wizard.go index 65ef937f..657f5173 100644 --- a/cmd/setup/wizard.go +++ b/cmd/setup/wizard.go @@ -62,7 +62,8 @@ type wizardModel struct { clientSideID string mobileKey string - detectResult *setup.DetectResult + detectedEntryPoint string + detectResult *setup.DetectResult flagKey string initResult *setup.InitResult verifyResult *setup.VerifyResult @@ -197,6 +198,7 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case detectDoneMsg: + m.detectedEntryPoint = msg.result.EntryPoint m.sdkList = m.buildSDKList(msg.result.SDKID) m.step = stepSelectSDK return m, nil @@ -278,8 +280,9 @@ func (m wizardModel) handleEnter() (tea.Model, tea.Cmd) { return m, nil } m.detectResult = &setup.DetectResult{ - SDKID: selected.id, - Language: selected.language, + SDKID: selected.id, + Language: selected.language, + EntryPoint: m.detectedEntryPoint, } m.step = stepInstall return m, m.runInstall() diff --git a/cmd/setup/wizard_test.go b/cmd/setup/wizard_test.go index 36d1c17b..a0eb55bd 100644 --- a/cmd/setup/wizard_test.go +++ b/cmd/setup/wizard_test.go @@ -114,6 +114,40 @@ func TestWizard_SelectSDK_UserCanOverrideDetection(t *testing.T) { 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} diff --git a/internal/setup/detector.go b/internal/setup/detector.go index 484c14e4..1af31a16 100644 --- a/internal/setup/detector.go +++ b/internal/setup/detector.go @@ -170,7 +170,7 @@ func detectNodePM(dir string) string { if _, err := os.Stat(filepath.Join(dir, "yarn.lock")); err == nil { return "yarn" } - if _, err := os.Stat(filepath.Join(dir, "bun.lock")); err == nil { + if _, err := os.Stat(filepath.Join(dir, "bun.lock")); err == nil { return "bun" } return "npm" diff --git a/internal/setup/detector_test.go b/internal/setup/detector_test.go index 76455837..ce8945ea 100644 --- a/internal/setup/detector_test.go +++ b/internal/setup/detector_test.go @@ -9,22 +9,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestStubDetector_ReturnsError(t *testing.T) { - d := StubDetector{} - result, err := d.Detect("/tmp") - require.Error(t, err) - assert.Nil(t, result) - assert.Contains(t, err.Error(), "not yet implemented") -} - -func TestStubInstaller_ReturnsError(t *testing.T) { - i := StubInstaller{} - result, err := i.Install("/tmp", &DetectResult{}) - require.Error(t, err) - assert.Nil(t, result) - assert.Contains(t, err.Error(), "not yet implemented") -} - // writeDetectFile writes content to a file in dir, creating parent directories as needed. func writeDetectFile(t *testing.T, dir, name, content string) { t.Helper() diff --git a/internal/setup/initializer.go b/internal/setup/initializer.go index 75dc72a1..27cc7fa7 100644 --- a/internal/setup/initializer.go +++ b/internal/setup/initializer.go @@ -42,7 +42,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"}, diff --git a/internal/setup/initializer_test.go b/internal/setup/initializer_test.go index afb93a98..2b6d7aa6 100644 --- a/internal/setup/initializer_test.go +++ b/internal/setup/initializer_test.go @@ -27,7 +27,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 +60,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 +146,49 @@ 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_UnsupportedSDK_ReturnsDocsURL(t *testing.T) { initializer := Initializer{} result, err := initializer.InjectIntoFile("php-server-sdk", "/tmp/fake.php", InitConfig{}) diff --git a/internal/setup/sdk_init_templates/go-server-sdk.tmpl b/internal/setup/sdk_init_templates/go-server-sdk.tmpl index a1a92ba4..bd812ee4 100644 --- a/internal/setup/sdk_init_templates/go-server-sdk.tmpl +++ b/internal/setup/sdk_init_templates/go-server-sdk.tmpl @@ -1,11 +1,8 @@ -import ( - "fmt" - "time" +// Add these to your import block: +// "github.com/launchdarkly/go-sdk-common/v3/ldcontext" +// ld "github.com/launchdarkly/go-server-sdk/v7" + - "github.com/launchdarkly/go-sdk-common/v3/ldcontext" - ld "github.com/launchdarkly/go-server-sdk/v7" -) -// --- init --- ldClient, _ := ld.MakeClient("{{.SDKKey}}", 5*time.Second) context := ldcontext.NewBuilder("example-user-key"). 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) -} From d3d26dc294e73d3fe6849b84db17b7e2714363b8 Mon Sep 17 00:00:00 2001 From: Dakota Sanchez Date: Thu, 14 May 2026 11:23:34 -0700 Subject: [PATCH 09/10] [REL-13574] merge resolution and new go code injection --- internal/setup/initializer.go | 64 +++++++++++++++++-- internal/setup/initializer_test.go | 39 +++++++++++ .../sdk_init_templates/go-server-sdk.tmpl | 11 ++-- 3 files changed, 105 insertions(+), 9 deletions(-) diff --git a/internal/setup/initializer.go b/internal/setup/initializer.go index 27cc7fa7..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 @@ -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 2b6d7aa6..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" @@ -189,6 +190,44 @@ func TestInjectIntoFile_ExistingFile_Go_DoesNotBreakPackageDeclaration(t *testin 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/sdk_init_templates/go-server-sdk.tmpl b/internal/setup/sdk_init_templates/go-server-sdk.tmpl index bd812ee4..a1a92ba4 100644 --- a/internal/setup/sdk_init_templates/go-server-sdk.tmpl +++ b/internal/setup/sdk_init_templates/go-server-sdk.tmpl @@ -1,8 +1,11 @@ -// Add these to your import block: -// "github.com/launchdarkly/go-sdk-common/v3/ldcontext" -// ld "github.com/launchdarkly/go-server-sdk/v7" - +import ( + "fmt" + "time" + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" + ld "github.com/launchdarkly/go-server-sdk/v7" +) +// --- init --- ldClient, _ := ld.MakeClient("{{.SDKKey}}", 5*time.Second) context := ldcontext.NewBuilder("example-user-key"). From 32f1834a5ba6492e342442af611757aab5f8611e Mon Sep 17 00:00:00 2001 From: Dakota Sanchez Date: Thu, 14 May 2026 14:46:26 -0700 Subject: [PATCH 10/10] [REL-13574] fix panic and flag link --- cmd/setup/wizard.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/cmd/setup/wizard.go b/cmd/setup/wizard.go index 657f5173..c83e9237 100644 --- a/cmd/setup/wizard.go +++ b/cmd/setup/wizard.go @@ -240,11 +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: - m.sdkList, cmd = m.sdkList.Update(msg) + if m.sdkList.Items() != nil { + m.sdkList, cmd = m.sdkList.Update(msg) + } } return m, cmd } @@ -354,7 +360,7 @@ func (m wizardModel) View() string { 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" +