diff --git a/cmd/root.go b/cmd/root.go
index 0bb93cce..40e0b291 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -22,8 +22,9 @@ 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"
whoamicmd "github.com/launchdarkly/ldcli/cmd/whoami"
@@ -36,6 +37,7 @@ import (
"github.com/launchdarkly/ldcli/internal/members"
"github.com/launchdarkly/ldcli/internal/projects"
"github.com/launchdarkly/ldcli/internal/resources"
+ "github.com/launchdarkly/ldcli/internal/setup"
)
type APIClients struct {
@@ -45,6 +47,8 @@ type APIClients struct {
MembersClient members.Client
ProjectsClient projects.Client
ResourcesClient resources.Client
+ Detector setup.Detector
+ Installer setup.Installer
}
type Command interface {
@@ -125,23 +129,26 @@ func NewRootCommand(
Long: "LaunchDarkly CLI to control your feature flags",
Version: version,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
- // disable required flags when running certain commands
+ // Skip the global --access-token required-flag check for commands
+ // that don't need an API token. We clear the annotation on the
+ // specific flag instead of setting DisableFlagParsing, which would
+ // also suppress validation for the subcommand's own required flags.
for _, name := range []string{
"completion",
"config",
"help",
"login",
+ "setup",
"signup",
"whoami",
} {
- if cmd.HasParent() && cmd.Parent().Name() == name {
- cmd.DisableFlagParsing = true
- }
- if cmd.Name() == name {
- cmd.DisableFlagParsing = true
+ if cmd.Name() == name || (cmd.HasParent() && cmd.Parent().Name() == name) {
+ if f := cmd.Flags().Lookup(cliflags.AccessTokenFlag); f != nil {
+ delete(f.Annotations, cobra.BashCompOneRequiredFlag)
+ }
+ break
}
}
-
},
Annotations: make(map[string]string),
// Handle errors differently based on type.
@@ -253,7 +260,26 @@ func NewRootCommand(
configCmd := configcmd.NewConfigCmd(configService, analyticsTrackerFn)
cmd.AddCommand(configCmd.Cmd())
- cmd.AddCommand(NewQuickStartCmd(analyticsTrackerFn, clients.EnvironmentsClient, clients.FlagsClient))
+ 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,
+ detector,
+ installer,
+ ))
+ quickStartCmd := NewQuickStartCmd(analyticsTrackerFn, clients.EnvironmentsClient, clients.FlagsClient)
+ quickStartCmd.Use = "quickstart"
+ quickStartCmd.Hidden = true
+ quickStartCmd.Deprecated = "use 'ldcli setup' for the new guided setup experience"
+ cmd.AddCommand(quickStartCmd)
cmd.AddCommand(logincmd.NewLoginCmd(clients.ResourcesClient))
cmd.AddCommand(signupcmd.NewSignupCmd(analyticsTrackerFn))
cmd.AddCommand(resourcecmd.NewResourcesCmd())
diff --git a/cmd/setup/detect.go b/cmd/setup/detect.go
new file mode 100644
index 00000000..1ee33123
--- /dev/null
+++ b/cmd/setup/detect.go
@@ -0,0 +1,62 @@
+package setup
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/launchdarkly/ldcli/cmd/cliflags"
+ "github.com/launchdarkly/ldcli/internal/setup"
+)
+
+const pathFlag = "path"
+
+func newDetectCmd(detector setup.Detector) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "detect",
+ Short: "Detect language, framework, and recommended SDK for a project",
+ Hidden: true,
+ RunE: runDetect(detector),
+ }
+
+ cmd.Flags().String(pathFlag, "", "Path to the project directory (defaults to current directory)")
+
+ return cmd
+}
+
+func runDetect(detector setup.Detector) func(*cobra.Command, []string) error {
+ return func(cmd *cobra.Command, args []string) error {
+ dir, _ := cmd.Flags().GetString(pathFlag)
+ if dir == "" {
+ var err error
+ dir, err = os.Getwd()
+ if err != nil {
+ return err
+ }
+ }
+
+ result, err := detector.Detect(dir)
+ if err != nil {
+ return err
+ }
+
+ outputKind := cliflags.GetOutputKind(cmd)
+ if outputKind == "json" {
+ data, _ := json.Marshal(result)
+ fmt.Fprintln(cmd.OutOrStdout(), string(data))
+ return nil
+ }
+
+ fmt.Fprintf(cmd.OutOrStdout(), "Language: %s\n", result.Language)
+ if result.Framework != "" {
+ fmt.Fprintf(cmd.OutOrStdout(), "Framework: %s\n", result.Framework)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "Package Manager: %s\n", result.PackageManager)
+ fmt.Fprintf(cmd.OutOrStdout(), "Recommended SDK: %s\n", result.SDKID)
+ fmt.Fprintf(cmd.OutOrStdout(), "Entry Point: %s\n", result.EntryPoint)
+
+ return nil
+ }
+}
diff --git a/cmd/setup/init.go b/cmd/setup/init.go
new file mode 100644
index 00000000..1a07d2f2
--- /dev/null
+++ b/cmd/setup/init.go
@@ -0,0 +1,86 @@
+package setup
+
+import (
+ "encoding/json"
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/launchdarkly/ldcli/cmd/cliflags"
+ "github.com/launchdarkly/ldcli/internal/setup"
+)
+
+func getFlag(cmd *cobra.Command, name string) string {
+ v, _ := cmd.Flags().GetString(name)
+ return v
+}
+
+const (
+ fileFlag = "file"
+ sdkKeyFlag = "sdk-key"
+ clientIDFlag = "client-side-id"
+ mobileFlag = "mobile-key"
+ flagKeyFlag = "flag-key"
+)
+
+func newInitCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "init",
+ Short: "Inject LaunchDarkly SDK initialization code into a file",
+ Hidden: true,
+ RunE: runInit(),
+ }
+
+ cmd.Flags().String(sdkIDFlag, "", "SDK identifier (e.g. node-server, react-client-sdk)")
+ _ = cmd.MarkFlagRequired(sdkIDFlag)
+
+ cmd.Flags().String(fileFlag, "", "Target file to inject initialization code into")
+ _ = cmd.MarkFlagRequired(fileFlag)
+
+ cmd.Flags().String(sdkKeyFlag, "", "Server-side SDK key")
+ cmd.Flags().String(clientIDFlag, "", "Client-side environment ID")
+ cmd.Flags().String(mobileFlag, "", "Mobile SDK key")
+ cmd.Flags().String(flagKeyFlag, "", "Feature flag key to use in the initialization example")
+
+ return cmd
+}
+
+func runInit() func(*cobra.Command, []string) error {
+ return func(cmd *cobra.Command, args []string) error {
+ sdkID, _ := cmd.Flags().GetString(sdkIDFlag)
+ filePath, _ := cmd.Flags().GetString(fileFlag)
+ cfg := setup.InitConfig{
+ SDKKey: getFlag(cmd, sdkKeyFlag),
+ ClientSideID: getFlag(cmd, clientIDFlag),
+ MobileKey: getFlag(cmd, mobileFlag),
+ FlagKey: getFlag(cmd, flagKeyFlag),
+ }
+
+ initializer := setup.Initializer{}
+ result, err := initializer.InjectIntoFile(sdkID, filePath, cfg)
+ if err != nil {
+ return err
+ }
+
+ outputKind := cliflags.GetOutputKind(cmd)
+ if outputKind == "json" {
+ data, _ := json.Marshal(result)
+ fmt.Fprintln(cmd.OutOrStdout(), string(data))
+ return nil
+ }
+
+ if !result.Success {
+ if result.Snippet != "" {
+ fmt.Fprintf(cmd.OutOrStdout(), "Manual setup required for %s — add the following to %s:\n\n%s\n\n", result.SDKID, result.FilePath, result.Snippet)
+ fmt.Fprintf(cmd.OutOrStdout(), "Follow the setup guide at: %s\n", result.DocsURL)
+ return nil
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "No initialization template available for %s\n", result.SDKID)
+ fmt.Fprintf(cmd.OutOrStdout(), "Follow the setup guide at: %s\n", result.DocsURL)
+ return nil
+ }
+
+ fmt.Fprintf(cmd.OutOrStdout(), "Injected %s initialization into %s\n", result.SDKID, result.FilePath)
+ return nil
+ }
+}
diff --git a/cmd/setup/install.go b/cmd/setup/install.go
new file mode 100644
index 00000000..12ad67c5
--- /dev/null
+++ b/cmd/setup/install.go
@@ -0,0 +1,99 @@
+package setup
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/launchdarkly/ldcli/cmd/cliflags"
+ "github.com/launchdarkly/ldcli/internal/setup"
+)
+
+const (
+ sdkIDFlag = "sdk-id"
+ dryRunFlag = "dry-run"
+)
+
+func newInstallCmd(installer setup.Installer) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "install",
+ Short: "Install the LaunchDarkly SDK package for the detected project",
+ Hidden: true,
+ RunE: runInstall(installer),
+ }
+
+ cmd.Flags().String(pathFlag, "", "Path to the project directory (defaults to current directory)")
+ cmd.Flags().String(sdkIDFlag, "", "SDK identifier to install (e.g. node-server, react-client-sdk)")
+ _ = cmd.MarkFlagRequired(sdkIDFlag)
+ cmd.Flags().String("package-manager", "", "Package manager to use (e.g. npm, pip, go)")
+ cmd.Flags().Bool(dryRunFlag, false, "Print the install command that would run without executing it")
+
+ return cmd
+}
+
+func runInstall(installer setup.Installer) func(*cobra.Command, []string) error {
+ return func(cmd *cobra.Command, args []string) error {
+ dir, _ := cmd.Flags().GetString(pathFlag)
+ if dir == "" {
+ var err error
+ dir, err = os.Getwd()
+ if err != nil {
+ return err
+ }
+ }
+
+ sdkID, _ := cmd.Flags().GetString(sdkIDFlag)
+ pkgMgr, _ := cmd.Flags().GetString("package-manager")
+ dryRun, _ := cmd.Flags().GetBool(dryRunFlag)
+ detection := &setup.DetectResult{
+ SDKID: sdkID,
+ PackageManager: pkgMgr,
+ }
+
+ var result *setup.InstallResult
+ if dryRun {
+ args, pkg := setup.InstallArgs(sdkID, pkgMgr)
+ result = &setup.InstallResult{
+ SDKID: sdkID,
+ Package: pkg,
+ Command: strings.Join(args, " "),
+ DryRun: true,
+ }
+ } else {
+ var err error
+ result, err = installer.Install(dir, detection)
+ if err != nil {
+ return err
+ }
+ }
+
+ outputKind := cliflags.GetOutputKind(cmd)
+ if outputKind == "json" {
+ data, _ := json.Marshal(result)
+ fmt.Fprintln(cmd.OutOrStdout(), string(data))
+ return nil
+ }
+
+ fmt.Fprintf(cmd.OutOrStdout(), "SDK: %s\n", result.SDKID)
+ 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)
+ }
+ if result.AlreadyInstalled {
+ fmt.Fprintln(cmd.OutOrStdout(), "Already installed — skipping install.")
+ return nil
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "Command: %s\n", result.Command)
+ if result.DryRun {
+ fmt.Fprintln(cmd.OutOrStdout(), "Dry run: command not executed")
+ } else {
+ fmt.Fprintf(cmd.OutOrStdout(), "Success: %t\n", result.Success)
+ }
+
+ return nil
+ }
+}
diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go
new file mode 100644
index 00000000..ed59797e
--- /dev/null
+++ b/cmd/setup/setup.go
@@ -0,0 +1,54 @@
+package setup
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+ "github.com/spf13/viper"
+
+ cmdAnalytics "github.com/launchdarkly/ldcli/cmd/analytics"
+ "github.com/launchdarkly/ldcli/cmd/cliflags"
+ "github.com/launchdarkly/ldcli/internal/analytics"
+ "github.com/launchdarkly/ldcli/internal/flags"
+ "github.com/launchdarkly/ldcli/internal/resources"
+ "github.com/launchdarkly/ldcli/internal/setup"
+)
+
+// NewSetupCmd creates the top-level setup command and registers its hidden subcommands.
+func NewSetupCmd(
+ analyticsTrackerFn analytics.TrackerFn,
+ resourcesClient resources.Client,
+ flagsClient flags.Client,
+ detector setup.Detector,
+ installer setup.Installer,
+) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "setup",
+ Short: "Set up LaunchDarkly in your project",
+ Long: `Guided setup to integrate LaunchDarkly into your codebase.
+
+Detects your project's language and framework, installs the correct SDK,
+initializes it with your environment's SDK key, creates a feature flag,
+and verifies the connection.`,
+ PreRun: func(cmd *cobra.Command, args []string) {
+ // Dim the notice and set it off with a blank line so it reads as a
+ // transitional notice, visually distinct from command output.
+ notice := mutedStyle.Render(
+ "Notice: 'ldcli setup' now runs the new guided setup wizard (project detection, SDK installation, and initialization).\n" +
+ "The previous quickstart wizard is still available via 'ldcli quickstart' during the transition period.")
+ fmt.Fprintf(cmd.ErrOrStderr(), "%s\n\n", notice)
+ analyticsTrackerFn(
+ viper.GetString(cliflags.AccessTokenFlag),
+ viper.GetString(cliflags.BaseURIFlag),
+ viper.GetBool(cliflags.AnalyticsOptOut),
+ ).SendCommandRunEvent(cmdAnalytics.CmdRunEventProperties(cmd, "setup", nil))
+ },
+ RunE: runSetupWizard(analyticsTrackerFn, resourcesClient, flagsClient, detector, installer),
+ }
+
+ cmd.AddCommand(newDetectCmd(detector))
+ cmd.AddCommand(newInstallCmd(installer))
+ cmd.AddCommand(newInitCmd())
+
+ return cmd
+}
diff --git a/cmd/setup/setup_test.go b/cmd/setup/setup_test.go
new file mode 100644
index 00000000..2d7e4cbe
--- /dev/null
+++ b/cmd/setup/setup_test.go
@@ -0,0 +1,362 @@
+package setup_test
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/launchdarkly/ldcli/cmd"
+ "github.com/launchdarkly/ldcli/internal/analytics"
+ "github.com/launchdarkly/ldcli/internal/resources"
+ "github.com/launchdarkly/ldcli/internal/setup"
+)
+
+func TestSetup_NoAuth_ReturnsLoginGuidance(t *testing.T) {
+ // No --access-token and no LD_ACCESS_TOKEN: the wizard must bail before the
+ // TUI with clear guidance rather than dumping a raw 401.
+ args := []string{"setup"}
+ _, err := cmd.CallCmd(
+ t,
+ cmd.APIClients{ResourcesClient: &resources.MockClient{}},
+ analytics.NoopClientFn{}.Tracker(),
+ args,
+ )
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "ldcli login")
+}
+
+func TestInit(t *testing.T) {
+ tmpDir := t.TempDir()
+ filePath := filepath.Join(tmpDir, "index.js")
+
+ args := []string{
+ "setup", "init",
+ "--access-token", "test-token",
+ "--sdk-id", "node-server",
+ "--file", filePath,
+ "--sdk-key", "test-sdk-key",
+ "--flag-key", "test-flag",
+ }
+ output, err := cmd.CallCmd(
+ t,
+ cmd.APIClients{
+ ResourcesClient: &resources.MockClient{},
+ },
+ analytics.NoopClientFn{}.Tracker(),
+ args,
+ )
+
+ require.NoError(t, err)
+ assert.Contains(t, string(output), "Injected node-server")
+}
+
+func TestInitJSON(t *testing.T) {
+ tmpDir := t.TempDir()
+ filePath := filepath.Join(tmpDir, "index.js")
+
+ args := []string{
+ "setup", "init",
+ "--access-token", "test-token",
+ "--sdk-id", "node-server",
+ "--file", filePath,
+ "--sdk-key", "test-sdk-key",
+ "--flag-key", "test-flag",
+ "--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), `"success":true`)
+}
+
+func TestInitUnsupportedSDKPlaintext(t *testing.T) {
+ tmpDir := t.TempDir()
+ filePath := tmpDir + "/main.rs"
+
+ args := []string{
+ "setup", "init",
+ "--access-token", "test-token",
+ "--sdk-id", "rust-server-sdk",
+ "--file", filePath,
+ "--sdk-key", "test-sdk-key",
+ }
+ output, err := cmd.CallCmd(
+ t,
+ cmd.APIClients{
+ ResourcesClient: &resources.MockClient{},
+ },
+ analytics.NoopClientFn{}.Tracker(),
+ args,
+ )
+
+ require.NoError(t, err)
+ assert.Contains(t, string(output), "No initialization template available for rust-server-sdk")
+ assert.Contains(t, string(output), "setup guide at:")
+ assert.NotContains(t, string(output), "Injected")
+}
+
+func TestInitUnsupportedSDKJSON(t *testing.T) {
+ tmpDir := t.TempDir()
+ filePath := tmpDir + "/main.rs"
+
+ args := []string{
+ "setup", "init",
+ "--access-token", "test-token",
+ "--sdk-id", "rust-server-sdk",
+ "--file", filePath,
+ "--sdk-key", "test-sdk-key",
+ "--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), `"success":false`)
+ assert.Contains(t, string(output), `"docs_url"`)
+}
+
+func TestDetect_UnknownProject_ReturnsError(t *testing.T) {
+ emptyDir := t.TempDir()
+ args := []string{
+ "setup", "detect",
+ "--access-token", "test-token",
+ "--path", emptyDir,
+ }
+ _, err := cmd.CallCmd(
+ t,
+ cmd.APIClients{
+ ResourcesClient: &resources.MockClient{},
+ },
+ analytics.NoopClientFn{}.Tracker(),
+ args,
+ )
+
+ require.Error(t, err)
+ 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_DryRun(t *testing.T) {
+ args := []string{
+ "setup", "install",
+ "--access-token", "test-token",
+ "--sdk-id", "node-server",
+ "--dry-run",
+ }
+ // No Installer provided: dry-run must not invoke it or shell out.
+ output, err := cmd.CallCmd(
+ t,
+ cmd.APIClients{
+ ResourcesClient: &resources.MockClient{},
+ },
+ analytics.NoopClientFn{}.Tracker(),
+ args,
+ )
+
+ require.NoError(t, err)
+ assert.Contains(t, string(output), "npm install @launchdarkly/node-server-sdk")
+ assert.Contains(t, string(output), "Dry run")
+}
+
+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) {
+ args := []string{
+ "setup", "install",
+ "--access-token", "test-token",
+ "--sdk-id", "node-server",
+ }
+ _, err := cmd.CallCmd(
+ t,
+ cmd.APIClients{
+ ResourcesClient: &resources.MockClient{},
+ Installer: setup.StubInstaller{},
+ },
+ analytics.NoopClientFn{}.Tracker(),
+ args,
+ )
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "not yet implemented")
+}
+
+func TestInstallMissingRequiredFlag(t *testing.T) {
+ args := []string{
+ "setup", "install",
+ "--access-token", "test-token",
+ }
+ _, err := cmd.CallCmd(
+ t,
+ cmd.APIClients{
+ ResourcesClient: &resources.MockClient{},
+ },
+ analytics.NoopClientFn{}.Tracker(),
+ args,
+ )
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "required flag")
+}
+
+func TestInitMissingRequiredFlags(t *testing.T) {
+ args := []string{
+ "setup", "init",
+ "--access-token", "test-token",
+ }
+ _, err := cmd.CallCmd(
+ t,
+ cmd.APIClients{
+ ResourcesClient: &resources.MockClient{},
+ },
+ analytics.NoopClientFn{}.Tracker(),
+ args,
+ )
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "required flag")
+}
diff --git a/cmd/setup/styles.go b/cmd/setup/styles.go
new file mode 100644
index 00000000..4ace0717
--- /dev/null
+++ b/cmd/setup/styles.go
@@ -0,0 +1,52 @@
+package setup
+
+import "github.com/charmbracelet/lipgloss"
+
+// Shared visual tokens for the setup wizard, aligned with ldcli's existing
+// quickstart TUI: selected items use color 170, bordered panels use 62.
+var (
+ colorSelected = lipgloss.Color("170") // active selection / pointer
+ colorBorder = lipgloss.Color("62") // focused panel border
+ colorBlur = lipgloss.Color("240") // unfocused panel border
+
+ titleStyle = lipgloss.NewStyle().Bold(true).MarginBottom(1)
+ headerStyle = lipgloss.NewStyle().Bold(true)
+ selectedStyle = lipgloss.NewStyle().Foreground(colorSelected).Bold(true)
+ mutedStyle = lipgloss.NewStyle().Faint(true)
+
+ // codeStyle marks copy-me code (snippets, commands) with a left gutter bar
+ // and a distinct foreground, so the user can tell what to copy versus read.
+ codeStyle = lipgloss.NewStyle().
+ Border(lipgloss.NormalBorder(), false, false, false, true).
+ BorderForeground(colorBorder).
+ Foreground(lipgloss.Color("252")).
+ PaddingLeft(1)
+)
+
+// code renders a snippet or command as a distinct code block.
+func code(s string) string { return codeStyle.Render(s) }
+
+// wrapText reflows prose to the given width so it doesn't overflow narrow
+// terminals. Returns the input unchanged when width is unknown (<=0).
+func wrapText(s string, width int) string {
+ if width <= 0 {
+ return s
+ }
+ if width > 100 {
+ width = 100
+ }
+ return lipgloss.NewStyle().Width(width).Render(s)
+}
+
+// box returns the panel style used on the SDK screen, highlighted when focused.
+func box(focused bool, width int) lipgloss.Style {
+ border := colorBlur
+ if focused {
+ border = colorBorder
+ }
+ return lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(border).
+ Padding(0, 1).
+ Width(width)
+}
diff --git a/cmd/setup/wizard.go b/cmd/setup/wizard.go
new file mode 100644
index 00000000..4eec0f85
--- /dev/null
+++ b/cmd/setup/wizard.go
@@ -0,0 +1,871 @@
+package setup
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "os"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/list"
+ "github.com/charmbracelet/bubbles/spinner"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/spf13/cobra"
+ "github.com/spf13/viper"
+
+ "github.com/launchdarkly/ldcli/cmd/cliflags"
+ "github.com/launchdarkly/ldcli/internal/analytics"
+ "github.com/launchdarkly/ldcli/internal/errors"
+ "github.com/launchdarkly/ldcli/internal/flags"
+ "github.com/launchdarkly/ldcli/internal/resources"
+ "github.com/launchdarkly/ldcli/internal/setup"
+)
+
+type wizardStep int
+
+const (
+ stepSelectProject wizardStep = iota
+ stepSelectEnvironment
+ stepDetect
+ stepSelectSDK
+ stepPlan
+ stepInstall
+ stepCreateFlag
+ stepInit
+ stepWaitForApp
+ stepVerify
+ stepDone
+)
+
+type wizardModel struct {
+ analyticsTrackerFn analytics.TrackerFn
+ resourcesClient resources.Client
+ flagsClient flags.Client
+ detector setup.Detector
+ installer setup.Installer
+
+ step wizardStep
+ spinner spinner.Model
+ err error
+ width int
+ height int
+
+ // data gathered through the flow
+ projects []projectItem
+ environments []envItem
+ projectList list.Model
+ envList list.Model
+ sdkList list.Model
+
+ selectedProject string
+ selectedEnv string
+ sdkKey string
+ clientSideID string
+ mobileKey string
+
+ detectComplete bool // detection (run once at launch) has finished
+ detectedSDKID string // detected SDK id, cached from the one-time detection ("" if none)
+ detectedEntryPoint string
+ detectResult *setup.DetectResult
+ detectedSDK *sdkItem // the auto-detected SDK, shown in its own panel; nil if detection failed
+ sdkFocus int // on the SDK screen: 0 = detected panel, 1 = the list of other SDKs
+ planInstallCmd string // install command previewed on the plan screen
+ planAlready bool // whether the SDK is already installed (previewed on the plan screen)
+ installResult *setup.InstallResult
+ flagKey string
+ initResult *setup.InitResult
+ verifyResult *setup.VerifyResult
+
+ quitting bool
+}
+
+type sdkItem struct {
+ id string
+ language string
+ name string
+}
+
+func (s sdkItem) Title() string {
+ if setup.RequiresManualInstall(s.id) {
+ return s.name + " (manual install)"
+ }
+ 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
+}
+
+func (p projectItem) Title() string { return p.name }
+func (p projectItem) Description() string { return p.key }
+func (p projectItem) FilterValue() string { return p.name }
+
+type envItem struct {
+ key string
+ name string
+}
+
+func (e envItem) Title() string { return e.name }
+func (e envItem) Description() string { return e.key }
+func (e envItem) FilterValue() string { return e.name }
+
+// messages
+type projectsFetchedMsg struct{ projects []projectItem }
+type envsFetchedMsg struct{ environments []envItem }
+type envDetailsFetchedMsg struct {
+ sdkKey string
+ clientSideID string
+ 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 }
+type verifyDoneMsg struct{ result *setup.VerifyResult }
+type wizardErrMsg struct{ err error }
+
+func runSetupWizard(
+ analyticsTrackerFn analytics.TrackerFn,
+ resourcesClient resources.Client,
+ flagsClient flags.Client,
+ detector setup.Detector,
+ installer setup.Installer,
+) func(*cobra.Command, []string) error {
+ return func(cmd *cobra.Command, args []string) error {
+ // Pre-flight: the wizard's first action is an authenticated API call, so
+ // bail early with clear guidance rather than dumping a raw 401 mid-TUI.
+ if viper.GetString(cliflags.AccessTokenFlag) == "" {
+ return errors.NewError("It looks like you're not logged in yet.\n\nRun `ldcli login` to authenticate, then run `ldcli setup` again.\n(Or pass --access-token, or set LD_ACCESS_TOKEN.)")
+ }
+
+ s := spinner.New()
+ s.Spinner = spinner.Dot
+
+ m := wizardModel{
+ analyticsTrackerFn: analyticsTrackerFn,
+ resourcesClient: resourcesClient,
+ flagsClient: flagsClient,
+ detector: detector,
+ installer: installer,
+ step: stepSelectProject,
+ spinner: s,
+ }
+
+ p := tea.NewProgram(m, tea.WithAltScreen())
+ _, err := p.Run()
+ return err
+ }
+}
+
+func (m wizardModel) Init() tea.Cmd {
+ // Detect the project once, up front, so navigating the flow never re-runs it.
+ return tea.Batch(m.spinner.Tick, m.fetchProjects(), m.runDetect())
+}
+
+func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+
+ case tea.KeyMsg:
+ switch msg.String() {
+ case "ctrl+c":
+ m.quitting = true
+ return m, tea.Quit
+ case "q", "esc":
+ if m.isFiltering() {
+ break // let the list receive 'q' / clear its filter
+ }
+ m.quitting = true
+ return m, tea.Quit
+ case "left", "h":
+ if m.isFiltering() {
+ break // let the list receive the key as filter input
+ }
+ return m.handleBack()
+ case "enter":
+ return m.handleEnter()
+ }
+
+ case projectsFetchedMsg:
+ m.projects = msg.projects
+ items := make([]list.Item, len(msg.projects))
+ for i, p := range msg.projects {
+ items[i] = p
+ }
+ delegate := list.NewDefaultDelegate()
+ m.projectList = list.New(items, delegate, m.width, m.height-4)
+ m.projectList.Title = "Select a project:"
+ m.projectList.SetShowStatusBar(false)
+ return m, nil
+
+ case envsFetchedMsg:
+ m.environments = msg.environments
+ items := make([]list.Item, len(msg.environments))
+ for i, e := range msg.environments {
+ items[i] = e
+ }
+ delegate := list.NewDefaultDelegate()
+ m.envList = list.New(items, delegate, m.width, m.height-4)
+ m.envList.Title = "Select an environment:"
+ m.envList.SetShowStatusBar(false)
+ return m, nil
+
+ case envDetailsFetchedMsg:
+ m.sdkKey = msg.sdkKey
+ m.clientSideID = msg.clientSideID
+ m.mobileKey = msg.mobileKey
+ // Detection was kicked off at launch; go straight to the SDK screen if
+ // it's already done, otherwise show a brief wait until it lands.
+ if m.detectComplete {
+ m.enterSDKStep()
+ } else {
+ m.step = stepDetect
+ }
+ return m, nil
+
+ case detectFailedMsg:
+ m.detectComplete = true
+ m.detectedSDKID = ""
+ if m.step == stepDetect {
+ m.enterSDKStep()
+ }
+ return m, nil
+
+ case detectDoneMsg:
+ m.detectComplete = true
+ m.detectedSDKID = msg.result.SDKID
+ m.detectedEntryPoint = msg.result.EntryPoint
+ if m.step == stepDetect {
+ m.enterSDKStep()
+ }
+ return m, nil
+
+ case installDoneMsg:
+ m.installResult = msg.result
+ m.step = stepCreateFlag
+ return m, m.runCreateFlag()
+
+ case flagCreatedMsg:
+ m.flagKey = msg.key
+ m.step = stepInit
+ return m, m.runInit()
+
+ case initDoneMsg:
+ m.initResult = msg.result
+ // Skip the live verify if init didn't inject runnable code, or if the SDK
+ // wasn't actually installed (auto-install failed) — the app can't connect.
+ if !msg.result.Success || (m.installResult != nil && m.installResult.Failed) {
+ m.step = stepDone
+ return m, nil
+ }
+ m.step = stepWaitForApp
+ return m, nil
+
+ case verifyDoneMsg:
+ m.verifyResult = msg.result
+ m.step = stepDone
+ return m, nil
+
+ case wizardErrMsg:
+ m.err = msg.err
+ return m, nil
+
+ case spinner.TickMsg:
+ var cmd tea.Cmd
+ m.spinner, cmd = m.spinner.Update(msg)
+ return m, cmd
+ }
+
+ // delegate to list models
+ var cmd tea.Cmd
+ switch m.step {
+ case stepSelectProject:
+ if len(m.projects) > 0 {
+ m.projectList, cmd = m.projectList.Update(msg)
+ }
+ case stepSelectEnvironment:
+ if len(m.environments) > 0 {
+ m.envList, cmd = m.envList.Update(msg)
+ }
+ case stepSelectSDK:
+ // Two panels when a detected SDK is shown: the detected panel (focus 0)
+ // and the list of other SDKs (focus 1). Arrows move focus between them.
+ if m.detectedSDK != nil {
+ if km, ok := msg.(tea.KeyMsg); ok {
+ switch km.String() {
+ case "down", "tab", "j":
+ if m.sdkFocus == 0 {
+ m.sdkFocus = 1
+ m.sdkList.SetDelegate(sdkDelegate(true))
+ return m, nil
+ }
+ case "up", "shift+tab", "k":
+ if m.sdkFocus == 1 && m.sdkList.Index() == 0 {
+ m.sdkFocus = 0
+ m.sdkList.SetDelegate(sdkDelegate(false))
+ return m, nil
+ }
+ }
+ }
+ if m.sdkFocus == 1 && m.sdkList.Items() != nil {
+ m.sdkList, cmd = m.sdkList.Update(msg)
+ }
+ } else if m.sdkList.Items() != nil {
+ m.sdkList, cmd = m.sdkList.Update(msg)
+ }
+ }
+ return m, cmd
+}
+
+// isFiltering reports whether the current step's list is in filter-typing mode,
+// so keys like esc/q are left for the list instead of triggering back/quit.
+func (m wizardModel) isFiltering() bool {
+ switch m.step {
+ case stepSelectProject:
+ return m.projectList.FilterState() == list.Filtering
+ case stepSelectEnvironment:
+ return m.envList.FilterState() == list.Filtering
+ case stepSelectSDK:
+ return m.sdkList.FilterState() == list.Filtering
+ }
+ return false
+}
+
+// enterSDKStep builds the SDK-selection screen from the cached one-time
+// detection result and switches to it. Rebuilding the list is cheap and uses
+// the current width; detection itself is never re-run.
+func (m *wizardModel) enterSDKStep() {
+ if id := m.detectedSDKID; id != "" {
+ if det, ok := findKnownSDK(id); ok {
+ m.detectedSDK = &det
+ m.sdkFocus = 0
+ m.sdkList = m.newSDKList(sdkItemsExcept(det.id), "Other SDKs:", false)
+ m.step = stepSelectSDK
+ return
+ }
+ }
+ m.detectedSDK = nil
+ m.sdkFocus = 1
+ m.sdkList = m.newSDKList(sdkItemsExcept(""), "Select your SDK:", true)
+ m.step = stepSelectSDK
+}
+
+// handleBack returns to the previous selection so the user can change the
+// project, environment, or SDK.
+func (m wizardModel) handleBack() (tea.Model, tea.Cmd) {
+ switch m.step {
+ case stepSelectEnvironment:
+ m.step = stepSelectProject
+ case stepSelectSDK:
+ m.step = stepSelectEnvironment
+ case stepPlan:
+ m.step = stepSelectSDK
+ }
+ return m, nil
+}
+
+func (m wizardModel) handleEnter() (tea.Model, tea.Cmd) {
+ switch m.step {
+ case stepSelectProject:
+ if len(m.projects) == 0 {
+ return m, nil
+ }
+ selected, ok := m.projectList.SelectedItem().(projectItem)
+ if !ok {
+ return m, nil
+ }
+ m.selectedProject = selected.key
+ m.step = stepSelectEnvironment
+ return m, m.fetchEnvironments()
+
+ case stepSelectEnvironment:
+ if len(m.environments) == 0 {
+ return m, nil
+ }
+ selected, ok := m.envList.SelectedItem().(envItem)
+ if !ok {
+ return m, nil
+ }
+ m.selectedEnv = selected.key
+ return m, m.fetchEnvDetails()
+
+ case stepSelectSDK:
+ var chosen sdkItem
+ if m.detectedSDK != nil && m.sdkFocus == 0 {
+ chosen = *m.detectedSDK
+ } else {
+ selected, ok := m.sdkList.SelectedItem().(sdkItem)
+ if !ok {
+ return m, nil
+ }
+ chosen = selected
+ }
+ m.detectResult = &setup.DetectResult{
+ SDKID: chosen.id,
+ Language: chosen.language,
+ EntryPoint: m.detectedEntryPoint,
+ }
+ // Compute the plan preview shown before any action is taken.
+ args, _ := setup.InstallArgs(chosen.id, "")
+ m.planInstallCmd = strings.Join(args, " ")
+ if dir, err := os.Getwd(); err == nil {
+ m.planAlready = setup.IsInstalled(dir, chosen.id)
+ }
+ m.step = stepPlan
+ return m, nil
+
+ case stepPlan:
+ m.step = stepInstall
+ return m, m.runInstall()
+
+ case stepWaitForApp:
+ m.step = stepVerify
+ return m, m.runVerify()
+ }
+ return m, nil
+}
+
+// quitHint is appended to terminal (done) screens so the user knows how to exit.
+var quitHint = "\n" + mutedStyle.Render("Press q to quit.") + "\n"
+
+func (m wizardModel) View() string {
+ if m.quitting {
+ return ""
+ }
+
+ if m.err != nil {
+ return titleStyle.Render("Error") + "\n\n" + m.err.Error() + "\n\nPress ctrl+c to quit."
+ }
+
+ switch m.step {
+ case stepSelectProject:
+ if len(m.projects) == 0 {
+ return m.spinner.View() + " Loading projects..."
+ }
+ return m.projectList.View() + "\n" + mutedStyle.Render("esc quit")
+
+ case stepSelectEnvironment:
+ if len(m.environments) == 0 {
+ return m.spinner.View() + " Loading environments..."
+ }
+ return m.envList.View() + "\n" + mutedStyle.Render("← back · esc quit")
+
+ case stepDetect:
+ return m.spinner.View() + " Detecting project type..."
+
+ case stepSelectSDK:
+ return m.sdkSelectView()
+
+ case stepPlan:
+ return m.planView()
+
+ case stepInstall:
+ return m.spinner.View() + " Installing SDK..."
+
+ case stepCreateFlag:
+ return m.spinner.View() + " Creating feature flag..."
+
+ case stepInit:
+ return m.spinner.View() + " Injecting initialization code..."
+
+ case stepWaitForApp:
+ return titleStyle.Render("Start your application") + "\n\n" +
+ "SDK initialization code has been injected into:\n" +
+ " " + m.initResult.FilePath + "\n\n" +
+ "Please start your application now, then press Enter to verify the connection.\n"
+
+ case stepVerify:
+ return m.spinner.View() + " Waiting for SDK to connect..."
+
+ case stepDone:
+ if m.installResult != nil && m.installResult.Failed {
+ body := titleStyle.Render("Manual install needed") + "\n\n" +
+ m.wrap("The SDK couldn't be installed automatically. Install it yourself with:") + "\n\n" +
+ code(m.installResult.Command) + "\n\n"
+ if m.initResult != nil && m.initResult.Success {
+ body += m.wrap(fmt.Sprintf("Initialization code was added to %s.", m.initResult.FilePath)) + "\n"
+ } else if m.initResult != nil && m.initResult.Snippet != "" {
+ body += m.wrap(fmt.Sprintf("Then add this initialization code to %s:", m.initResult.FilePath)) +
+ "\n\n" + code(m.initResult.Snippet) + "\n"
+ }
+ body += "\n" + m.wrap(fmt.Sprintf("Flag %q was created in project %q.", m.flagKey, m.selectedProject)) + "\n"
+ return body + quitHint
+ }
+ if m.initResult != nil && !m.initResult.Success {
+ body := titleStyle.Render("Manual SDK setup required") + "\n\n"
+ if m.initResult.Snippet != "" {
+ body += m.wrap(fmt.Sprintf("Add the following %s initialization code to %s:", m.initResult.SDKID, m.initResult.FilePath)) +
+ "\n\n" + code(m.initResult.Snippet) + "\n\n"
+ } else {
+ body += fmt.Sprintf("No initialization template is available for %s.\n", m.initResult.SDKID)
+ }
+ return body +
+ fmt.Sprintf("Follow the setup guide at: %s\n\n", m.initResult.DocsURL) +
+ 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" +
+ quitHint
+ }
+ 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) +
+ 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) +
+ quitHint
+ }
+ return titleStyle.Render("Verification timed out") + "\n\n" +
+ "The SDK did not report as active within the timeout period.\n" +
+ "Make sure your application is running and try again.\n" +
+ quitHint
+ }
+
+ return ""
+}
+
+// findKnownSDK returns the sdkItem for the given SDK id, if it is one we know.
+func findKnownSDK(id string) (sdkItem, bool) {
+ for _, sdk := range setup.KnownSDKs {
+ if sdk.ID == id {
+ return sdkItem{id: sdk.ID, language: sdk.Language, name: sdk.Name}, true
+ }
+ }
+ return sdkItem{}, false
+}
+
+// sdkItemsExcept returns all known SDKs as list items, omitting the given id.
+func sdkItemsExcept(exclude string) []list.Item {
+ items := make([]list.Item, 0, len(setup.KnownSDKs))
+ for _, sdk := range setup.KnownSDKs {
+ if sdk.ID == exclude {
+ continue
+ }
+ items = append(items, sdkItem{id: sdk.ID, language: sdk.Language, name: sdk.Name})
+ }
+ return items
+}
+
+// sdkBoxWidth is the shared width for the detected panel and the SDK list box,
+// so both areas line up.
+func (m wizardModel) sdkBoxWidth() int {
+ w := m.width - 4
+ if w > 72 {
+ w = 72
+ }
+ if w < 20 { // never wider than a very narrow terminal can show
+ w = 20
+ }
+ return w
+}
+
+// wrap reflows prose to the terminal width so it doesn't overflow narrow
+// terminals. Code snippets are rendered raw (not passed through here).
+func (m wizardModel) wrap(s string) string {
+ return wrapText(s, m.width)
+}
+
+// sdkDelegate returns the list row renderer. When the list isn't the focused
+// area, the selected row is styled like a normal row so it doesn't look active
+// while the detected-SDK panel holds focus.
+func sdkDelegate(focused bool) list.DefaultDelegate {
+ d := list.NewDefaultDelegate()
+ if !focused {
+ d.Styles.SelectedTitle = d.Styles.NormalTitle
+ d.Styles.SelectedDesc = d.Styles.NormalDesc
+ }
+ return d
+}
+
+// newSDKList builds the list model for the SDK selection screen.
+func (m wizardModel) newSDKList(items []list.Item, title string, focused bool) list.Model {
+ h := m.height - 12
+ if h < 3 {
+ h = 3
+ }
+ l := list.New(items, sdkDelegate(focused), m.sdkBoxWidth()-2, h)
+ l.Title = title
+ l.Styles.Title = headerStyle // match the detected-SDK panel header, not the default title bar
+ l.SetShowStatusBar(false)
+ l.SetShowHelp(false) // we render a single key hint inside the box instead
+ return l
+}
+
+// sdkSelectView renders the SDK selection screen. When an SDK was auto-detected
+// it shows two areas: an "identified" panel on top and the list of other SDKs
+// below; the focused area is highlighted. When detection failed, only the list
+// is shown.
+func (m wizardModel) sdkSelectView() string {
+ hint := mutedStyle.Render("↑/↓ move · enter select · ← back · esc quit")
+ catalog := mutedStyle.Render("Don't see your language? All LaunchDarkly SDKs: https://launchdarkly.com/docs/sdk")
+
+ if m.detectedSDK == nil {
+ listBox := box(true, m.sdkBoxWidth()).Render(m.sdkList.View() + "\n" + hint)
+ return listBox + "\n" + catalog
+ }
+
+ boxW := m.sdkBoxWidth()
+ panelStyle := box(m.sdkFocus == 0, boxW)
+ listStyle := box(m.sdkFocus == 1, boxW)
+
+ // Point to the detected SDK when its panel is focused, matching the list's cursor.
+ label := fmt.Sprintf("%s (%s)", m.detectedSDK.name, m.detectedSDK.language)
+ if setup.RequiresManualInstall(m.detectedSDK.id) {
+ label += " — manual install"
+ }
+ pointer := " "
+ if m.sdkFocus == 0 {
+ pointer, label = selectedStyle.Render("❯ "), selectedStyle.Render(label)
+ }
+ panel := panelStyle.Render(
+ headerStyle.Render("We identified this as your SDK") + "\n" +
+ pointer + label + "\n" +
+ mutedStyle.Render("Press Enter to use it"))
+
+ listBox := listStyle.Render(m.sdkList.View() + "\n" + hint)
+
+ return panel + "\n\n" + listBox + "\n" + catalog
+}
+
+// planView lists the steps setup will take, before any of them run, so the user
+// knows what's about to happen and can confirm.
+func (m wizardModel) planView() string {
+ if m.detectResult == nil {
+ return ""
+ }
+ name := m.detectResult.SDKID
+ if nm, ok := findKnownSDK(m.detectResult.SDKID); ok {
+ name = nm.name
+ }
+
+ var steps []string
+ add := func(s string) {
+ steps = append(steps, selectedStyle.Render(fmt.Sprintf("%d.", len(steps)+1))+" "+s)
+ }
+
+ switch {
+ case m.planAlready:
+ add(fmt.Sprintf("Install the %s SDK — %s", name, mutedStyle.Render("already installed, will skip")))
+ case m.planInstallCmd != "":
+ add(fmt.Sprintf("Install the %s SDK — %s", name, mutedStyle.Render(m.planInstallCmd)))
+ default:
+ add(fmt.Sprintf("Add the %s SDK %s", name, mutedStyle.Render("(manual install)")))
+ }
+ add(fmt.Sprintf("Create a feature flag in %s / %s", m.selectedProject, m.selectedEnv))
+ if setup.InjectsInPlace(m.detectResult.SDKID) {
+ add(fmt.Sprintf("Add initialization code to %s", m.detectResult.EntryPoint))
+ add("Verify the SDK connects to LaunchDarkly")
+ } else {
+ add("Show initialization code for you to add")
+ }
+
+ return headerStyle.Render("Here's what setup will do:") + "\n\n" +
+ strings.Join(steps, "\n") + "\n\n" +
+ mutedStyle.Render("Enter continue · ← back · esc quit")
+}
+
+// Commands that perform async work
+
+func (m wizardModel) fetchProjects() tea.Cmd {
+ return func() tea.Msg {
+ path, _ := url.JoinPath(
+ viper.GetString(cliflags.BaseURIFlag),
+ "api/v2/projects",
+ )
+ res, err := m.resourcesClient.MakeRequest(
+ viper.GetString(cliflags.AccessTokenFlag),
+ "GET", path, "application/json", nil, nil, false,
+ )
+ if err != nil {
+ return wizardErrMsg{err: err}
+ }
+
+ var resp struct {
+ Items []struct {
+ Key string `json:"key"`
+ Name string `json:"name"`
+ } `json:"items"`
+ }
+ if err := json.Unmarshal(res, &resp); err != nil {
+ return wizardErrMsg{err: fmt.Errorf("parsing projects: %w", err)}
+ }
+
+ projects := make([]projectItem, len(resp.Items))
+ for i, item := range resp.Items {
+ projects[i] = projectItem{key: item.Key, name: item.Name}
+ }
+ return projectsFetchedMsg{projects: projects}
+ }
+}
+
+func (m wizardModel) fetchEnvironments() tea.Cmd {
+ return func() tea.Msg {
+ path, _ := url.JoinPath(
+ viper.GetString(cliflags.BaseURIFlag),
+ "api/v2/projects", m.selectedProject, "environments",
+ )
+ res, err := m.resourcesClient.MakeRequest(
+ viper.GetString(cliflags.AccessTokenFlag),
+ "GET", path, "application/json", nil, nil, false,
+ )
+ if err != nil {
+ return wizardErrMsg{err: err}
+ }
+
+ var resp struct {
+ Items []struct {
+ Key string `json:"key"`
+ Name string `json:"name"`
+ } `json:"items"`
+ }
+ if err := json.Unmarshal(res, &resp); err != nil {
+ return wizardErrMsg{err: fmt.Errorf("parsing environments: %w", err)}
+ }
+
+ envs := make([]envItem, len(resp.Items))
+ for i, item := range resp.Items {
+ envs[i] = envItem{key: item.Key, name: item.Name}
+ }
+ return envsFetchedMsg{environments: envs}
+ }
+}
+
+func (m wizardModel) fetchEnvDetails() tea.Cmd {
+ return func() tea.Msg {
+ path, _ := url.JoinPath(
+ viper.GetString(cliflags.BaseURIFlag),
+ "api/v2/projects", m.selectedProject, "environments", m.selectedEnv,
+ )
+ res, err := m.resourcesClient.MakeRequest(
+ viper.GetString(cliflags.AccessTokenFlag),
+ "GET", path, "application/json", nil, nil, false,
+ )
+ if err != nil {
+ return wizardErrMsg{err: err}
+ }
+
+ var resp struct {
+ SDKKey string `json:"apiKey"`
+ ClientSideId string `json:"_id"`
+ MobileKey string `json:"mobileKey"`
+ }
+ if err := json.Unmarshal(res, &resp); err != nil {
+ return wizardErrMsg{err: fmt.Errorf("parsing environment details: %w", err)}
+ }
+
+ return envDetailsFetchedMsg{
+ sdkKey: resp.SDKKey,
+ clientSideID: resp.ClientSideId,
+ mobileKey: resp.MobileKey,
+ }
+ }
+}
+
+func (m wizardModel) runDetect() tea.Cmd {
+ return func() tea.Msg {
+ dir, err := os.Getwd()
+ if err != nil {
+ return wizardErrMsg{err: err}
+ }
+ result, err := m.detector.Detect(dir)
+ if err != nil {
+ return detectFailedMsg{}
+ }
+ return detectDoneMsg{result: result}
+ }
+}
+
+func (m wizardModel) runInstall() tea.Cmd {
+ return func() tea.Msg {
+ dir, err := os.Getwd()
+ if err != nil {
+ return wizardErrMsg{err: err}
+ }
+ result, err := m.installer.Install(dir, m.detectResult)
+ if err != nil {
+ // Don't dead-end on a failed auto-install (e.g. Ruby gem perms, no
+ // network): continue and surface the command to run by hand.
+ args, _ := setup.InstallArgs(m.detectResult.SDKID, m.detectResult.PackageManager)
+ return installDoneMsg{result: &setup.InstallResult{
+ SDKID: m.detectResult.SDKID,
+ Command: strings.Join(args, " "),
+ Failed: true,
+ }}
+ }
+ return installDoneMsg{result: result}
+ }
+}
+
+func (m wizardModel) runCreateFlag() tea.Cmd {
+ return func() tea.Msg {
+ flagKey := "my-new-flag"
+ flagName := "My New Flag"
+
+ _, err := m.flagsClient.Create(
+ context.Background(),
+ viper.GetString(cliflags.AccessTokenFlag),
+ viper.GetString(cliflags.BaseURIFlag),
+ flagName,
+ flagKey,
+ m.selectedProject,
+ )
+ if err != nil {
+ // If flag already exists (conflict), continue using it
+ if jsonErr, parseErr := parseJSONError(err); parseErr == nil && jsonErr.Code == "conflict" {
+ return flagCreatedMsg{key: flagKey}
+ }
+ return wizardErrMsg{err: err}
+ }
+ return flagCreatedMsg{key: flagKey}
+ }
+}
+
+func (m wizardModel) runInit() tea.Cmd {
+ return func() tea.Msg {
+ cfg := setup.InitConfig{
+ SDKKey: m.sdkKey,
+ ClientSideID: m.clientSideID,
+ MobileKey: m.mobileKey,
+ FlagKey: m.flagKey,
+ }
+ initializer := setup.Initializer{}
+ result, err := initializer.InjectIntoFile(m.detectResult.SDKID, m.detectResult.EntryPoint, cfg)
+ if err != nil {
+ return wizardErrMsg{err: err}
+ }
+ return initDoneMsg{result: result}
+ }
+}
+
+func (m wizardModel) runVerify() tea.Cmd {
+ return func() tea.Msg {
+ verifier := setup.DefaultVerifier(m.resourcesClient)
+ result, err := verifier.Verify(
+ viper.GetString(cliflags.AccessTokenFlag),
+ viper.GetString(cliflags.BaseURIFlag),
+ m.selectedProject,
+ m.selectedEnv,
+ )
+ if err != nil {
+ return wizardErrMsg{err: err}
+ }
+ return verifyDoneMsg{result: result}
+ }
+}
+
+type jsonError struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+}
+
+func parseJSONError(err error) (*jsonError, error) {
+ var je jsonError
+ if parseErr := json.Unmarshal([]byte(err.Error()), &je); parseErr != nil {
+ return nil, parseErr
+ }
+ return &je, nil
+}
diff --git a/cmd/setup/wizard_test.go b/cmd/setup/wizard_test.go
new file mode 100644
index 00000000..344272f5
--- /dev/null
+++ b/cmd/setup/wizard_test.go
@@ -0,0 +1,260 @@
+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 goes to stepSelectSDK: detected SDK in its own panel, the rest
+// in a separate list, focus defaulting to the detected panel.
+
+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)
+ // detected SDK lives in the panel, not the list, so the list has the rest.
+ assert.Equal(t, len(setup.KnownSDKs)-1, len(updated.sdkList.Items()))
+}
+
+func TestWizard_DetectDone_DetectedSDKInOwnPanel_FocusedFirst(t *testing.T) {
+ m := wizardModel{step: stepDetect}
+
+ next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{SDKID: "go-server-sdk", Language: "Go"}})
+ updated := next.(wizardModel)
+
+ require.NotNil(t, updated.detectedSDK)
+ assert.Equal(t, "go-server-sdk", updated.detectedSDK.id)
+ assert.Equal(t, 0, updated.sdkFocus) // detected panel focused by default
+}
+
+func TestWizard_DetectDone_ListExcludesDetectedSDK(t *testing.T) {
+ m := wizardModel{step: stepDetect}
+
+ next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{SDKID: "go-server-sdk"}})
+ updated := next.(wizardModel)
+
+ for _, item := range updated.sdkList.Items() {
+ assert.NotEqual(t, "go-server-sdk", item.(sdkItem).id)
+ }
+}
+
+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)
+}
+
+func TestWizard_DetectDone_ShowsIdentifiedPanel(t *testing.T) {
+ m := wizardModel{step: stepDetect, width: 80, height: 30}
+
+ next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{SDKID: "go-server-sdk", Language: "Go"}})
+ updated := next.(wizardModel)
+
+ view := updated.View()
+ assert.Contains(t, view, "We identified this as your SDK")
+ assert.Contains(t, view, "❯") // detected choice is pointed to while its panel is focused
+}
+
+// detectFailedMsg goes to stepSelectSDK in default KnownSDKs order.
+
+func TestWizard_DetectFailed_UsesGenericSDKTitle(t *testing.T) {
+ m := wizardModel{step: stepDetect}
+
+ next, _ := m.Update(detectFailedMsg{})
+ updated := next.(wizardModel)
+
+ assert.Equal(t, "Select your SDK:", updated.sdkList.Title)
+}
+
+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_ProceedsToPlanThenInstall(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)
+
+ // Enter accepts the detected SDK and shows the plan (no action taken yet).
+ next, _ = updated.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ planned := next.(wizardModel)
+ assert.Equal(t, stepPlan, planned.step)
+ require.NotNil(t, planned.detectResult)
+ assert.Equal(t, "go-server-sdk", planned.detectResult.SDKID)
+
+ // Enter on the plan proceeds to install.
+ next, cmd := planned.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ installing := next.(wizardModel)
+ assert.Equal(t, stepInstall, installing.step)
+ assert.NotNil(t, cmd)
+}
+
+func TestWizard_Plan_ListsSteps(t *testing.T) {
+ m := wizardModel{
+ step: stepPlan,
+ selectedProject: "default",
+ selectedEnv: "test",
+ detectResult: &setup.DetectResult{SDKID: "node-server", EntryPoint: "src/index.js"},
+ planInstallCmd: "npm install @launchdarkly/node-server-sdk",
+ width: 80,
+ height: 30,
+ }
+
+ view := m.planView()
+ assert.Contains(t, view, "Here's what setup will do:")
+ assert.Contains(t, view, "npm install @launchdarkly/node-server-sdk")
+ assert.Contains(t, view, "Create a feature flag")
+ assert.Contains(t, view, "Verify") // node-server injects in place -> verify step listed
+}
+
+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_Back_ReturnsToPreviousStep(t *testing.T) {
+ cases := []struct{ from, want wizardStep }{
+ {stepPlan, stepSelectSDK},
+ {stepSelectSDK, stepSelectEnvironment},
+ {stepSelectEnvironment, stepSelectProject},
+ }
+ for _, c := range cases {
+ m := wizardModel{step: c.from}
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft})
+ assert.Equal(t, c.want, next.(wizardModel).step)
+ }
+}
+
+func TestWizard_Esc_Quits(t *testing.T) {
+ m := wizardModel{step: stepSelectSDK}
+ next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
+ assert.True(t, next.(wizardModel).quitting)
+ assert.NotNil(t, cmd)
+}
+
+func TestSDKItem_Title_MarksManualInstall(t *testing.T) {
+ assert.Contains(t, sdkItem{id: "java-server-sdk", name: "Java"}.Title(), "manual install")
+ assert.Equal(t, "Node.js", sdkItem{id: "node-server", name: "Node.js"}.Title())
+}
+
+func TestWizard_Done_InstallFailed_ShowsManualCommand(t *testing.T) {
+ m := wizardModel{
+ step: stepDone,
+ width: 80,
+ height: 30,
+ flagKey: "my-new-flag",
+ selectedProject: "default",
+ installResult: &setup.InstallResult{SDKID: "ruby-server-sdk", Command: "gem install launchdarkly-server-sdk", Failed: true},
+ initResult: &setup.InitResult{SDKID: "ruby-server-sdk", FilePath: "app.rb", Success: true},
+ }
+
+ v := m.View()
+ assert.Contains(t, v, "Manual install needed")
+ assert.Contains(t, v, "gem install launchdarkly-server-sdk")
+}
+
+func TestWizard_Done_Success_ShowsQuitHint(t *testing.T) {
+ m := wizardModel{
+ step: stepDone,
+ detectResult: &setup.DetectResult{SDKID: "node-server"},
+ verifyResult: &setup.VerifyResult{Active: true},
+ flagKey: "my-new-flag",
+ width: 80,
+ height: 30,
+ }
+
+ assert.Contains(t, m.View(), "Press q to quit")
+}
+
+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/cmd/templates.go b/cmd/templates.go
index 6fe0d68a..1d9c9a1e 100644
--- a/cmd/templates.go
+++ b/cmd/templates.go
@@ -12,7 +12,8 @@ func getUsageTemplate() string {
{{.CommandPath}} [command]{{end}}
{{if not .HasParent}}
Commands:
- {{rpad "setup" 29}} Create your first feature flag using a step-by-step guide
+ {{rpad "setup" 29}} Set up LaunchDarkly in your project (detect, install, initialize)
+ {{rpad "quickstart" 29}} Create your first feature flag using a step-by-step guide (deprecated: use setup)
{{rpad "config" 29}} View and modify specific configuration values
{{rpad "completion" 29}} Enable command autocompletion within supported shells
{{rpad "login" 29}} Log in to your LaunchDarkly account
diff --git a/internal/setup/detector.go b/internal/setup/detector.go
new file mode 100644
index 00000000..cf201c8a
--- /dev/null
+++ b/internal/setup/detector.go
@@ -0,0 +1,351 @@
+package setup
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+)
+
+// DetectResult contains information about the user's project detected from the working directory.
+type DetectResult struct {
+ Language string `json:"language"`
+ Framework string `json:"framework,omitempty"`
+ PackageManager string `json:"package_manager"`
+ SDKID string `json:"sdk_id"`
+ EntryPoint string `json:"entry_point"`
+}
+
+// Detector inspects a directory to determine the language, framework, package manager,
+// recommended SDK, and entry point file.
+type Detector interface {
+ Detect(dir string) (*DetectResult, error)
+}
+
+// StubDetector is a placeholder implementation. Replace with real detection logic.
+type StubDetector struct{}
+
+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 := detectRuby(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)
+
+ // Next.js apps run a Node server (SSR and API routes), so server-side flag
+ // evaluation uses the Node server SDK rather than a browser client SDK.
+ 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 detectRuby(dir string) *DetectResult {
+ found := false
+ for _, indicator := range []string{"Gemfile", "Gemfile.lock", "config.ru"} {
+ if _, err := os.Stat(filepath.Join(dir, indicator)); err == nil {
+ found = true
+ break
+ }
+ }
+ if !found {
+ if matches, _ := filepath.Glob(filepath.Join(dir, "*.gemspec")); len(matches) == 0 {
+ return nil
+ }
+ }
+ return &DetectResult{
+ Language: "Ruby",
+ PackageManager: "gem",
+ SDKID: "ruby-server-sdk",
+ EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{
+ "config.ru", "app.rb", "main.rb",
+ })),
+ }
+}
+
+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_ruby_test.go b/internal/setup/detector_ruby_test.go
new file mode 100644
index 00000000..9dadbbc4
--- /dev/null
+++ b/internal/setup/detector_ruby_test.go
@@ -0,0 +1,33 @@
+package setup
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFileDetector_DetectsRuby_Gemfile(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "Gemfile", "source 'https://rubygems.org'\n")
+ writeDetectFile(t, dir, "app.rb", "# app\n")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "ruby-server-sdk", result.SDKID)
+ assert.Equal(t, "Ruby", result.Language)
+ assert.Equal(t, "gem", result.PackageManager)
+ assert.Equal(t, filepath.Join(dir, "app.rb"), result.EntryPoint)
+}
+
+func TestFileDetector_DetectsRuby_Gemspec(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "mygem.gemspec", "Gem::Specification.new\n")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "ruby-server-sdk", result.SDKID)
+}
diff --git a/internal/setup/detector_test.go b/internal/setup/detector_test.go
new file mode 100644
index 00000000..ce8945ea
--- /dev/null
+++ b/internal/setup/detector_test.go
@@ -0,0 +1,357 @@
+package setup
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// 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.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 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.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
new file mode 100644
index 00000000..48d8ad7d
--- /dev/null
+++ b/internal/setup/initializer.go
@@ -0,0 +1,264 @@
+package setup
+
+import (
+ "bytes"
+ "embed"
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+ "text/template"
+)
+
+//go:embed sdk_init_templates/*.tmpl
+var initTemplateFiles embed.FS
+
+// InitConfig holds the values to interpolate into SDK initialization templates.
+type InitConfig struct {
+ SDKKey string
+ ClientSideID string
+ MobileKey string
+ FlagKey string
+}
+
+// InitResult describes the outcome of injecting SDK initialization code.
+//
+// Success is true only when initialization code was actually written to a file
+// as valid, ready-to-run code. When Success is false, Snippet (if set) holds the
+// rendered code the user must place manually, and DocsURL points at the setup
+// guide.
+type InitResult struct {
+ SDKID string `json:"sdk_id"`
+ FilePath string `json:"file_path,omitempty"`
+ DocsURL string `json:"docs_url,omitempty"`
+ Snippet string `json:"snippet,omitempty"`
+ Success bool `json:"success"`
+}
+
+// appendSafeSDKs lists SDKs whose entry file is an interpreted script executed
+// top-to-bottom, so initialization statements can be appended at file scope and
+// still run. For every other SDK — compiled/scoped languages (Go, Java, C#,
+// Swift, Android) whose statements are illegal at file scope, and framework SDKs
+// (React, React Native) that must be wired into a component tree — appending
+// produces code that does not compile or does not run, so we return the snippet
+// as guidance instead of writing a broken file.
+var appendSafeSDKs = map[string]bool{
+ "node-server": true,
+ "python-server-sdk": true,
+ "ruby-server-sdk": true,
+}
+
+// Initializer injects SDK initialization code into a target file.
+type Initializer struct{}
+
+// sdkTemplateInfo maps an SDK ID to the template filename.
+type sdkTemplateInfo struct {
+ TemplateFile string
+}
+
+var sdkTemplates = map[string]sdkTemplateInfo{
+ "react-client-sdk": {TemplateFile: "react-client-sdk.tmpl"},
+ "react-native": {TemplateFile: "react-native.tmpl"},
+ "js-client-sdk": {TemplateFile: "js-client-sdk.tmpl"},
+ "swift-client-sdk": {TemplateFile: "swift-client-sdk.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"},
+ "python-server-sdk": {TemplateFile: "python-server-sdk.tmpl"},
+ "dotnet-server-sdk": {TemplateFile: "dotnet-server-sdk.tmpl"},
+ "node-server": {TemplateFile: "node-server.tmpl"},
+}
+
+// sdkDocsPaths maps SDK IDs to their documentation path on launchdarkly.com/docs.
+// Covers all SDKs, including those without init templates.
+var sdkDocsPaths = map[string]string{
+ "akamai-server-edgekv-sdk": "sdk/edge/akamai",
+ "android": "sdk/client-side/android",
+ "android-client-sdk": "sdk/client-side/android",
+ "apex-server-sdk": "sdk/server-side/apex",
+ "cpp-client-sdk": "sdk/client-side/c-c--",
+ "cpp-server-sdk": "sdk/server-side/c-c--",
+ "cloudflare-server-sdk": "sdk/edge/cloudflare",
+ "dotnet-client-sdk": "sdk/client-side/dotnet",
+ "dotnet-server-sdk": "sdk/server-side/dotnet",
+ "electron-client-sdk": "sdk/client-side/electron",
+ "erlang-server-sdk": "sdk/server-side/erlang",
+ "flutter-client-sdk": "sdk/client-side/flutter",
+ "go-server-sdk": "sdk/server-side/go",
+ "haskell-server-sdk": "sdk/server-side/haskell",
+ "ios-client-sdk": "sdk/client-side/ios",
+ "swift-client-sdk": "sdk/client-side/ios",
+ "java-server-sdk": "sdk/server-side/java",
+ "js-client-sdk": "sdk/client-side/javascript",
+ "lua-server-sdk": "sdk/server-side/lua",
+ "node-client-sdk": "sdk/client-side/node-js",
+ "node-server": "sdk/server-side/node-js",
+ "node-server-sdk": "sdk/server-side/node-js",
+ "php-server-sdk": "sdk/server-side/php",
+ "python-server-sdk": "sdk/server-side/python",
+ "react-client-sdk": "sdk/client-side/react",
+ "react-native": "sdk/client-side/react-native",
+ "react-native-client-sdk": "sdk/client-side/react-native",
+ "roku-client-sdk": "sdk/client-side/roku",
+ "ruby-server-sdk": "sdk/server-side/ruby",
+ "rust-server-sdk": "sdk/server-side/rust",
+ "vercel-server-sdk": "sdk/edge/vercel",
+ "vue-client-sdk": "sdk/client-side/vue",
+}
+
+const docsBaseURL = "https://launchdarkly.com/docs"
+
+// GetDocsURL returns the full documentation URL for the given SDK ID.
+// Falls back to the top-level SDK docs page if the ID is unknown.
+func GetDocsURL(sdkID string) string {
+ if path, ok := sdkDocsPaths[sdkID]; ok {
+ return docsBaseURL + "/" + path
+ }
+ return docsBaseURL + "/sdk"
+}
+
+// SupportedSDKIDs returns the list of SDK IDs that have initialization templates.
+func SupportedSDKIDs() []string {
+ ids := make([]string, 0, len(sdkTemplates))
+ for id := range sdkTemplates {
+ ids = append(ids, id)
+ }
+ return ids
+}
+
+// HasTemplate returns true if the given SDK ID has an initialization template.
+func HasTemplate(sdkID string) bool {
+ _, ok := sdkTemplates[sdkID]
+ return ok
+}
+
+// InjectsInPlace reports whether `init` writes runnable code directly into the
+// entry file (true) versus returning a snippet for the user to place manually
+// (false). Also indicates whether a live verify step is meaningful afterward.
+func InjectsInPlace(sdkID string) bool {
+ return HasTemplate(sdkID) && appendSafeSDKs[sdkID]
+}
+
+// RenderTemplate renders the initialization code for the given SDK.
+func RenderTemplate(sdkID string, cfg InitConfig) (string, error) {
+ info, ok := sdkTemplates[sdkID]
+ if !ok {
+ return "", fmt.Errorf("no initialization template for SDK %q; see docs: %s", sdkID, GetDocsURL(sdkID))
+ }
+
+ content, err := initTemplateFiles.ReadFile("sdk_init_templates/" + info.TemplateFile)
+ if err != nil {
+ return "", fmt.Errorf("reading template for %s: %w", sdkID, err)
+ }
+
+ tmpl, err := template.New(sdkID).Parse(string(content))
+ if err != nil {
+ return "", fmt.Errorf("parsing template for %s: %w", sdkID, err)
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, cfg); err != nil {
+ return "", fmt.Errorf("executing template for %s: %w", sdkID, err)
+ }
+
+ return buf.String(), nil
+}
+
+// InjectIntoFile renders the SDK initialization code and, for SDKs whose entry
+// file is an interpreted script (see appendSafeSDKs), writes it into filePath:
+// imports are placed at the top and init code appended after existing content.
+//
+// For SDKs that are not append-safe — because file-scope statements would not
+// compile (Go, Java, C#, Swift, Android) or because the code must be wired into
+// a component tree (React, React Native) — the file is left untouched and the
+// result carries the rendered Snippet plus DocsURL as guidance, with
+// Success=false so callers do not report a broken file as ready.
+//
+// If no template exists for the SDK at all, the result carries only the
+// documentation URL.
+//
+// The template output is split into an IMPORTS section and an INIT section by a
+// separator line ("// --- init ---" or "# --- init ---" depending on language).
+func (i Initializer) InjectIntoFile(sdkID, filePath string, cfg InitConfig) (*InitResult, error) {
+ if !HasTemplate(sdkID) {
+ return &InitResult{
+ SDKID: sdkID,
+ DocsURL: GetDocsURL(sdkID),
+ Success: false,
+ }, nil
+ }
+
+ rendered, err := RenderTemplate(sdkID, cfg)
+ if err != nil {
+ return nil, err
+ }
+
+ importSection, initSection := splitInitSections(rendered)
+
+ if !appendSafeSDKs[sdkID] {
+ return &InitResult{
+ SDKID: sdkID,
+ FilePath: filePath,
+ DocsURL: GetDocsURL(sdkID),
+ Snippet: joinSnippet(importSection, initSection),
+ Success: false,
+ }, nil
+ }
+
+ existing, err := os.ReadFile(filePath)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ var content string
+ if importSection != "" {
+ content = importSection + "\n\n" + initSection + "\n"
+ } else {
+ content = initSection + "\n"
+ }
+ if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
+ return nil, fmt.Errorf("creating %s: %w", filePath, err)
+ }
+ return &InitResult{SDKID: sdkID, FilePath: filePath, Success: true}, nil
+ }
+ return nil, fmt.Errorf("reading %s: %w", filePath, err)
+ }
+
+ content := string(existing)
+ if importSection != "" {
+ content = importSection + "\n" + content
+ }
+ 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)
+ }
+
+ return &InitResult{SDKID: sdkID, FilePath: filePath, Success: true}, nil
+}
+
+// joinSnippet recombines the import and init sections into a single human-readable
+// snippet the user can copy into the correct place in their code.
+func joinSnippet(importSection, initSection string) string {
+ if importSection == "" {
+ return initSection
+ }
+ return importSection + "\n\n" + initSection
+}
+
+// initSeparators lists the markers that divide import and init sections in templates.
+var initSeparators = []string{
+ "// --- init ---",
+ "# --- init ---",
+}
+
+// splitInitSections splits rendered template output into an import section and an
+// init section. It recognises comment-style-appropriate separators so that templates
+// for languages like Python and Ruby can use `#` comments.
+func splitInitSections(rendered string) (importSection, initSection string) {
+ for _, sep := range initSeparators {
+ if parts := strings.SplitN(rendered, sep, 2); len(parts) == 2 {
+ return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
+ }
+ }
+ return "", rendered
+}
diff --git a/internal/setup/initializer_test.go b/internal/setup/initializer_test.go
new file mode 100644
index 00000000..70c2eb04
--- /dev/null
+++ b/internal/setup/initializer_test.go
@@ -0,0 +1,244 @@
+package setup
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRenderTemplate(t *testing.T) {
+ cfg := InitConfig{
+ SDKKey: "sdk-test-key-123",
+ ClientSideID: "client-id-456",
+ MobileKey: "mob-key-789",
+ FlagKey: "my-test-flag",
+ }
+
+ tests := []struct {
+ name string
+ sdkID string
+ wantSubstr string
+ }{
+ {"node-server", "node-server", "sdk-test-key-123"},
+ {"react-client-sdk", "react-client-sdk", "client-id-456"},
+ {"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-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"},
+ {"python-server-sdk", "python-server-sdk", "sdk-test-key-123"},
+ {"dotnet-server-sdk", "dotnet-server-sdk", "sdk-test-key-123"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, err := RenderTemplate(tt.sdkID, cfg)
+ require.NoError(t, err)
+ assert.Contains(t, result, tt.wantSubstr)
+ })
+ }
+}
+
+func TestRenderTemplateUnknownSDK(t *testing.T) {
+ _, err := RenderTemplate("nonexistent-sdk", InitConfig{})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "no initialization template")
+ assert.Contains(t, err.Error(), "see docs")
+}
+
+func TestRenderTemplateUnknownSDK_KnownDocsPath(t *testing.T) {
+ _, err := RenderTemplate("php-server-sdk", InitConfig{})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "https://launchdarkly.com/docs/sdk/server-side/php")
+}
+
+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"))
+}
+
+func TestSupportedSDKIDs(t *testing.T) {
+ ids := SupportedSDKIDs()
+ assert.Len(t, ids, 11)
+ assert.Contains(t, ids, "node-server")
+ assert.Contains(t, ids, "react-client-sdk")
+ assert.Contains(t, ids, "go-server-sdk")
+}
+
+func TestInjectIntoFile_NewFile(t *testing.T) {
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, "index.js")
+
+ initializer := Initializer{}
+ result, err := initializer.InjectIntoFile("node-server", filePath, InitConfig{
+ SDKKey: "test-key",
+ FlagKey: "test-flag",
+ })
+
+ require.NoError(t, err)
+ assert.True(t, result.Success)
+ assert.Equal(t, "node-server", result.SDKID)
+
+ content, err := os.ReadFile(filePath)
+ require.NoError(t, err)
+ assert.Contains(t, string(content), "test-key")
+ assert.Contains(t, string(content), "test-flag")
+}
+
+func TestInjectIntoFile_ExistingFile(t *testing.T) {
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, "app.js")
+
+ err := os.WriteFile(filePath, []byte("// existing code\nconsole.log('hello');\n"), 0644)
+ require.NoError(t, err)
+
+ initializer := Initializer{}
+ result, err := initializer.InjectIntoFile("node-server", filePath, InitConfig{
+ SDKKey: "test-key",
+ FlagKey: "test-flag",
+ })
+
+ require.NoError(t, err)
+ assert.True(t, result.Success)
+
+ content, err := os.ReadFile(filePath)
+ require.NoError(t, err)
+ assert.Contains(t, string(content), "existing code")
+ assert.Contains(t, string(content), "test-key")
+}
+
+func TestInjectIntoFile_NewFile_OmitsSeparator(t *testing.T) {
+ sdks := []struct {
+ sdkID string
+ filename string
+ }{
+ {"python-server-sdk", "init_ld.py"},
+ {"ruby-server-sdk", "init_ld.rb"},
+ {"node-server", "index.js"},
+ }
+
+ for _, tt := range sdks {
+ t.Run(tt.sdkID, func(t *testing.T) {
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, tt.filename)
+
+ initializer := Initializer{}
+ result, err := initializer.InjectIntoFile(tt.sdkID, filePath, InitConfig{
+ SDKKey: "test-key",
+ FlagKey: "test-flag",
+ })
+
+ require.NoError(t, err)
+ assert.True(t, result.Success)
+
+ content, err := os.ReadFile(filePath)
+ require.NoError(t, err)
+ assert.NotContains(t, string(content), "// --- init ---")
+ })
+ }
+}
+
+func TestInjectIntoFile_AndroidClientSdk_ReturnsGuidance(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)
+ // Android is a scoped language: statements can't live at file scope, so we
+ // return guidance rather than write a broken file.
+ assert.False(t, result.Success)
+ assert.Equal(t, "android-client-sdk", result.SDKID)
+ assert.Contains(t, result.Snippet, "mob-test-key")
+ assert.NotEmpty(t, result.DocsURL)
+
+ // The file must not have been created.
+ _, statErr := os.Stat(filePath)
+ assert.True(t, os.IsNotExist(statErr), "guidance-only SDK must not create the file")
+}
+
+func TestInjectIntoFile_Go_ReturnsGuidanceDoesNotModifyFile(t *testing.T) {
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, "main.go")
+
+ existing := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"hello\")\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)
+ // Go statements are illegal at file scope, so appending would not compile.
+ // We return the snippet as guidance and leave the file untouched.
+ assert.False(t, result.Success)
+ assert.Contains(t, result.Snippet, "sdk-test-key")
+ assert.Contains(t, result.Snippet, "github.com/launchdarkly/go-server-sdk/v7")
+
+ content, err := os.ReadFile(filePath)
+ require.NoError(t, err)
+ assert.Equal(t, existing, string(content), "existing file must not be modified")
+}
+
+func TestInjectIntoFile_React_ReturnsGuidanceDoesNotModifyFile(t *testing.T) {
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, "App.tsx")
+
+ existing := "export default function App() { return null }\n"
+ err := os.WriteFile(filePath, []byte(existing), 0644)
+ require.NoError(t, err)
+
+ initializer := Initializer{}
+ result, err := initializer.InjectIntoFile("react-client-sdk", filePath, InitConfig{
+ ClientSideID: "client-id-456",
+ FlagKey: "test-flag",
+ })
+
+ require.NoError(t, err)
+ // React init must be wired into the component tree, not appended, so we
+ // return guidance rather than corrupt the file.
+ assert.False(t, result.Success)
+ assert.Contains(t, result.Snippet, "asyncWithLDProvider")
+
+ content, err := os.ReadFile(filePath)
+ require.NoError(t, err)
+ assert.Equal(t, existing, string(content), "existing file must not be modified")
+}
+
+func TestInjectIntoFile_UnsupportedSDK_ReturnsDocsURL(t *testing.T) {
+ initializer := Initializer{}
+ result, err := initializer.InjectIntoFile("php-server-sdk", "/tmp/fake.php", InitConfig{})
+ require.NoError(t, err)
+ assert.False(t, result.Success)
+ assert.Equal(t, "https://launchdarkly.com/docs/sdk/server-side/php", result.DocsURL)
+}
+
+func TestInjectIntoFile_CompletelyUnknownSDK_ReturnsFallbackDocsURL(t *testing.T) {
+ initializer := Initializer{}
+ result, err := initializer.InjectIntoFile("nonexistent-sdk", "/tmp/fake.txt", InitConfig{})
+ require.NoError(t, err)
+ assert.False(t, result.Success)
+ assert.Equal(t, "https://launchdarkly.com/docs/sdk", result.DocsURL)
+}
+
+func TestGetDocsURL(t *testing.T) {
+ assert.Equal(t, "https://launchdarkly.com/docs/sdk/server-side/go", GetDocsURL("go-server-sdk"))
+ assert.Equal(t, "https://launchdarkly.com/docs/sdk/client-side/react", GetDocsURL("react-client-sdk"))
+ assert.Equal(t, "https://launchdarkly.com/docs/sdk/server-side/python", GetDocsURL("python-server-sdk"))
+ assert.Equal(t, "https://launchdarkly.com/docs/sdk", GetDocsURL("totally-unknown"))
+}
diff --git a/internal/setup/installer.go b/internal/setup/installer.go
new file mode 100644
index 00000000..48039a65
--- /dev/null
+++ b/internal/setup/installer.go
@@ -0,0 +1,229 @@
+package setup
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+// InstallResult contains the outcome of installing an SDK package.
+type InstallResult struct {
+ SDKID string `json:"sdk_id"`
+ Package string `json:"package"`
+ Version string `json:"version"`
+ Command string `json:"command"`
+ DryRun bool `json:"dry_run,omitempty"`
+ AlreadyInstalled bool `json:"already_installed,omitempty"`
+ Failed bool `json:"failed,omitempty"`
+ Success bool `json:"success"`
+}
+
+// RequiresManualInstall reports whether the SDK has no automated package-manager
+// command and must be added by hand (e.g. Java, Android, Swift).
+func RequiresManualInstall(sdkID string) bool {
+ return manualInstallSDKs[sdkID]
+}
+
+// Installer runs the appropriate package manager command to add an SDK dependency.
+type Installer interface {
+ Install(dir string, detection *DetectResult) (*InstallResult, error)
+}
+
+// StubInstaller is a placeholder implementation. Replace with real install logic.
+type StubInstaller struct{}
+
+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{}
+
+// manualInstallSDKs lists SDKs that have no automated package-manager command
+// (Java, Android, Swift) but ARE recognised. For these, Install returns
+// Success=false without an error so the wizard can proceed and show the package
+// identifier. An SDK ID that is neither installable nor in this set is unknown
+// and is treated as an error rather than a silent no-op.
+var manualInstallSDKs = map[string]bool{
+ "java-server-sdk": true,
+ "android": true,
+ "android-client-sdk": true,
+ "swift-client-sdk": true,
+ "ios-client-sdk": true,
+}
+
+// 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. An unknown SDK
+// ID returns an error.
+func (p PackageInstaller) Install(dir string, detection *DetectResult) (*InstallResult, error) {
+ args, pkg := InstallArgs(detection.SDKID, detection.PackageManager)
+ if len(args) == 0 {
+ if !manualInstallSDKs[detection.SDKID] {
+ return nil, fmt.Errorf("unknown SDK %q: no install command available; specify a supported --sdk-id", detection.SDKID)
+ }
+ return &InstallResult{
+ SDKID: detection.SDKID,
+ Package: pkg,
+ Success: false,
+ }, nil
+ }
+
+ // Skip the install if the SDK is already a dependency of the project.
+ if IsInstalled(dir, detection.SDKID) {
+ return &InstallResult{
+ SDKID: detection.SDKID,
+ Package: pkg,
+ AlreadyInstalled: true,
+ Success: true,
+ }, 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"
+ }
+}
+
+// IsInstalled reports whether the SDK is already a dependency of the project in
+// dir, by looking for its package identifier in the relevant manifest(s). Only
+// covers SDKs with an automated install command; returns false for manual SDKs
+// and unknowns.
+func IsInstalled(dir, sdkID string) bool {
+ _, pkg := InstallArgs(sdkID, "")
+ if pkg == "" {
+ return false
+ }
+
+ var manifests []string
+ switch sdkID {
+ case "react-client-sdk", "react-native", "node-server", "js-client-sdk":
+ manifests = []string{"package.json"}
+ case "go-server-sdk":
+ manifests = []string{"go.mod", "go.sum"}
+ case "python-server-sdk":
+ manifests = []string{"requirements.txt", "pyproject.toml", "setup.py"}
+ case "ruby-server-sdk":
+ manifests = []string{"Gemfile", "Gemfile.lock"}
+ case "dotnet-server-sdk":
+ matches, _ := filepath.Glob(filepath.Join(dir, "*.csproj"))
+ for _, f := range matches {
+ if fileContains(f, pkg) {
+ return true
+ }
+ }
+ return false
+ default:
+ return false
+ }
+
+ for _, mf := range manifests {
+ if fileContains(filepath.Join(dir, mf), pkg) {
+ return true
+ }
+ }
+ return false
+}
+
+func fileContains(path, substr string) bool {
+ b, err := os.ReadFile(path)
+ return err == nil && strings.Contains(string(b), substr)
+}
diff --git a/internal/setup/installer_test.go b/internal/setup/installer_test.go
new file mode 100644
index 00000000..7fa9ccb6
--- /dev/null
+++ b/internal/setup/installer_test.go
@@ -0,0 +1,201 @@
+package setup
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "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_AlreadyInstalled_SkipsCommand(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(
+ filepath.Join(dir, "package.json"),
+ []byte(`{"dependencies":{"@launchdarkly/node-server-sdk":"^9.0.0"}}`), 0644))
+
+ installer := PackageInstaller{
+ run: func(_ string, _ []string) ([]byte, error) {
+ t.Fatal("package manager must not run when the SDK is already installed")
+ return nil, nil
+ },
+ }
+
+ result, err := installer.Install(dir, &DetectResult{SDKID: "node-server", PackageManager: "npm"})
+
+ require.NoError(t, err)
+ assert.True(t, result.AlreadyInstalled)
+ assert.True(t, result.Success)
+ assert.Empty(t, result.Command)
+}
+
+func TestRequiresManualInstall(t *testing.T) {
+ assert.True(t, RequiresManualInstall("java-server-sdk"))
+ assert.True(t, RequiresManualInstall("swift-client-sdk"))
+ assert.False(t, RequiresManualInstall("node-server"))
+ assert.False(t, RequiresManualInstall("ruby-server-sdk"))
+}
+
+func TestPackageInstaller_Install_UnknownSDK_ReturnsError(t *testing.T) {
+ installer := PackageInstaller{}
+
+ _, err := installer.Install("/tmp", &DetectResult{SDKID: "totally-unknown-sdk"})
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unknown SDK")
+}
+
+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/sdk_init_templates/android.tmpl b/internal/setup/sdk_init_templates/android.tmpl
new file mode 100644
index 00000000..d1672e82
--- /dev/null
+++ b/internal/setup/sdk_init_templates/android.tmpl
@@ -0,0 +1,10 @@
+import com.launchdarkly.sdk.android.*;
+import com.launchdarkly.sdk.*;
+// --- init ---
+LDConfig ldConfig = new LDConfig.Builder()
+ .mobileKey("{{.MobileKey}}")
+ .build();
+LDContext ldContext = LDContext.builder(ContextKind.DEFAULT, "example-user-key")
+ .name("Example User")
+ .build();
+LDClient ldClient = LDClient.init(this.getApplication(), ldConfig, ldContext, 5);
diff --git a/internal/setup/sdk_init_templates/dotnet-server-sdk.tmpl b/internal/setup/sdk_init_templates/dotnet-server-sdk.tmpl
new file mode 100644
index 00000000..da54e9d4
--- /dev/null
+++ b/internal/setup/sdk_init_templates/dotnet-server-sdk.tmpl
@@ -0,0 +1,11 @@
+using LaunchDarkly.Sdk;
+using LaunchDarkly.Sdk.Server;
+// --- init ---
+var ldClient = new LdClient("{{.SDKKey}}");
+
+var context = Context.Builder("example-user-key")
+ .Name("Example User")
+ .Build();
+
+var flagValue = ldClient.BoolVariation("{{.FlagKey}}", context, false);
+Console.WriteLine($"Flag '{{.FlagKey}}' is {flagValue}");
diff --git a/internal/setup/sdk_init_templates/go-server-sdk.tmpl b/internal/setup/sdk_init_templates/go-server-sdk.tmpl
new file mode 100644
index 00000000..a1a92ba4
--- /dev/null
+++ b/internal/setup/sdk_init_templates/go-server-sdk.tmpl
@@ -0,0 +1,16 @@
+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").
+ Name("Example User").
+ Build()
+
+flagValue, _ := ldClient.BoolVariation("{{.FlagKey}}", context, false)
+fmt.Printf("Flag '{{.FlagKey}}' is %t\n", flagValue)
diff --git a/internal/setup/sdk_init_templates/java-server-sdk.tmpl b/internal/setup/sdk_init_templates/java-server-sdk.tmpl
new file mode 100644
index 00000000..88a0e3a7
--- /dev/null
+++ b/internal/setup/sdk_init_templates/java-server-sdk.tmpl
@@ -0,0 +1,11 @@
+import com.launchdarkly.sdk.*;
+import com.launchdarkly.sdk.server.*;
+// --- init ---
+LDClient ldClient = new LDClient("{{.SDKKey}}");
+
+LDContext context = LDContext.builder("example-user-key")
+ .name("Example User")
+ .build();
+
+boolean flagValue = ldClient.boolVariation("{{.FlagKey}}", context, false);
+System.out.println("Flag '{{.FlagKey}}' is " + flagValue);
diff --git a/internal/setup/sdk_init_templates/js-client-sdk.tmpl b/internal/setup/sdk_init_templates/js-client-sdk.tmpl
new file mode 100644
index 00000000..f78f1f30
--- /dev/null
+++ b/internal/setup/sdk_init_templates/js-client-sdk.tmpl
@@ -0,0 +1,12 @@
+import * as LDClient from 'launchdarkly-js-client-sdk';
+// --- init ---
+const ldClient = LDClient.initialize('{{.ClientSideID}}', {
+ kind: 'user',
+ key: 'example-user-key',
+ name: 'Example User',
+});
+
+ldClient.on('ready', () => {
+ const flagValue = ldClient.variation('{{.FlagKey}}', false);
+ console.log(`Flag '{{.FlagKey}}' is ${flagValue}`);
+});
diff --git a/internal/setup/sdk_init_templates/node-server.tmpl b/internal/setup/sdk_init_templates/node-server.tmpl
new file mode 100644
index 00000000..321c0c67
--- /dev/null
+++ b/internal/setup/sdk_init_templates/node-server.tmpl
@@ -0,0 +1,15 @@
+const LaunchDarkly = require('@launchdarkly/node-server-sdk');
+// --- init ---
+const ldClient = LaunchDarkly.init('{{.SDKKey}}');
+
+const context = {
+ kind: 'user',
+ key: 'example-user-key',
+ name: 'Example User',
+};
+
+ldClient.on('ready', () => {
+ ldClient.variation('{{.FlagKey}}', context, false, (err, flagValue) => {
+ console.log(`Flag '{{.FlagKey}}' is ${flagValue}`);
+ });
+});
diff --git a/internal/setup/sdk_init_templates/python-server-sdk.tmpl b/internal/setup/sdk_init_templates/python-server-sdk.tmpl
new file mode 100644
index 00000000..4960a98f
--- /dev/null
+++ b/internal/setup/sdk_init_templates/python-server-sdk.tmpl
@@ -0,0 +1,11 @@
+import ldclient
+from ldclient import Context
+from ldclient.config import Config
+# --- init ---
+ldclient.set_config(Config("{{.SDKKey}}"))
+ld_client = ldclient.get()
+
+context = Context.builder("example-user-key").name("Example User").build()
+
+flag_value = ld_client.variation("{{.FlagKey}}", context, False)
+print(f"Flag '{{.FlagKey}}' is {flag_value}")
diff --git a/internal/setup/sdk_init_templates/react-client-sdk.tmpl b/internal/setup/sdk_init_templates/react-client-sdk.tmpl
new file mode 100644
index 00000000..de155630
--- /dev/null
+++ b/internal/setup/sdk_init_templates/react-client-sdk.tmpl
@@ -0,0 +1,10 @@
+import { asyncWithLDProvider } from 'launchdarkly-react-client-sdk';
+// --- init ---
+const LDProvider = await asyncWithLDProvider({
+ clientSideID: '{{.ClientSideID}}',
+ context: {
+ kind: 'user',
+ key: 'example-user-key',
+ name: 'Example User',
+ },
+});
diff --git a/internal/setup/sdk_init_templates/react-native.tmpl b/internal/setup/sdk_init_templates/react-native.tmpl
new file mode 100644
index 00000000..0b137bc6
--- /dev/null
+++ b/internal/setup/sdk_init_templates/react-native.tmpl
@@ -0,0 +1,5 @@
+import LDClient from '@launchdarkly/react-native-client-sdk';
+// --- init ---
+const ldClient = new LDClient();
+const context = { kind: 'user', key: 'example-user-key', name: 'Example User' };
+await ldClient.configure({ mobileKey: '{{.MobileKey}}' }, context);
diff --git a/internal/setup/sdk_init_templates/ruby-server-sdk.tmpl b/internal/setup/sdk_init_templates/ruby-server-sdk.tmpl
new file mode 100644
index 00000000..38306dbb
--- /dev/null
+++ b/internal/setup/sdk_init_templates/ruby-server-sdk.tmpl
@@ -0,0 +1,12 @@
+require 'ldclient-rb'
+# --- init ---
+ld_client = LaunchDarkly::LDClient.new("{{.SDKKey}}")
+
+context = LaunchDarkly::LDContext.create({
+ key: "example-user-key",
+ kind: "user",
+ name: "Example User"
+})
+
+flag_value = ld_client.variation("{{.FlagKey}}", context, false)
+puts "Flag '{{.FlagKey}}' is #{flag_value}"
diff --git a/internal/setup/sdk_init_templates/swift-client-sdk.tmpl b/internal/setup/sdk_init_templates/swift-client-sdk.tmpl
new file mode 100644
index 00000000..8fb3c12b
--- /dev/null
+++ b/internal/setup/sdk_init_templates/swift-client-sdk.tmpl
@@ -0,0 +1,5 @@
+import LaunchDarkly
+// --- init ---
+var ldConfig = LDConfig(mobileKey: "{{.MobileKey}}")
+let ldContext = try LDContextBuilder(key: "example-user-key").build().get()
+LDClient.start(config: ldConfig, context: ldContext)
diff --git a/internal/setup/verifier.go b/internal/setup/verifier.go
new file mode 100644
index 00000000..7b265dbf
--- /dev/null
+++ b/internal/setup/verifier.go
@@ -0,0 +1,83 @@
+package setup
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "time"
+
+ "github.com/launchdarkly/ldcli/internal/resources"
+)
+
+// VerifyResult describes the outcome of verifying SDK connectivity.
+type VerifyResult struct {
+ Active bool `json:"active"`
+ Attempts int `json:"attempts"`
+ Elapsed string `json:"elapsed"`
+}
+
+// Verifier polls the sdk-active endpoint until the SDK reports as active or a timeout is reached.
+type Verifier struct {
+ Client resources.Client
+ Interval time.Duration
+ Timeout time.Duration
+}
+
+// DefaultVerifier returns a Verifier with sensible defaults.
+func DefaultVerifier(client resources.Client) *Verifier {
+ return &Verifier{
+ Client: client,
+ Interval: 5 * time.Second,
+ Timeout: 120 * time.Second,
+ }
+}
+
+// Verify polls GET /api/v2/projects/{project}/environments/{env}/sdk-active until active=true.
+func (v *Verifier) Verify(accessToken, baseURI, projectKey, envKey string) (*VerifyResult, error) {
+ start := time.Now()
+ deadline := start.Add(v.Timeout)
+ attempts := 0
+
+ for {
+ attempts++
+ active, err := v.checkOnce(accessToken, baseURI, projectKey, envKey)
+ if err != nil {
+ return nil, err
+ }
+ if active {
+ return &VerifyResult{
+ Active: true,
+ Attempts: attempts,
+ Elapsed: time.Since(start).Round(time.Millisecond).String(),
+ }, nil
+ }
+
+ if time.Now().After(deadline) {
+ return &VerifyResult{
+ Active: false,
+ Attempts: attempts,
+ Elapsed: time.Since(start).Round(time.Millisecond).String(),
+ }, nil
+ }
+
+ time.Sleep(v.Interval)
+ }
+}
+
+func (v *Verifier) checkOnce(accessToken, baseURI, projectKey, envKey string) (bool, error) {
+ path, _ := url.JoinPath(baseURI, "api/v2/projects", projectKey, "environments", envKey, "sdk-active")
+
+ res, err := v.Client.MakeRequest(accessToken, "GET", path, "application/json", nil, nil, false)
+ if err != nil {
+ return false, fmt.Errorf("checking sdk-active: %w", err)
+ }
+
+ var resp struct {
+ Active bool `json:"active"`
+ }
+ if err := json.Unmarshal(res, &resp); err != nil {
+ return false, fmt.Errorf("parsing sdk-active response: %w", err)
+ }
+
+ return resp.Active, nil
+}
diff --git a/internal/setup/verifier_test.go b/internal/setup/verifier_test.go
new file mode 100644
index 00000000..8ce339af
--- /dev/null
+++ b/internal/setup/verifier_test.go
@@ -0,0 +1,43 @@
+package setup
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/launchdarkly/ldcli/internal/resources"
+)
+
+func TestVerify_Active(t *testing.T) {
+ client := &resources.MockClient{
+ Response: []byte(`{"active": true}`),
+ }
+ verifier := &Verifier{
+ Client: client,
+ Interval: 10 * time.Millisecond,
+ Timeout: 1 * time.Second,
+ }
+
+ result, err := verifier.Verify("token", "https://app.launchdarkly.com", "proj", "env")
+ require.NoError(t, err)
+ assert.True(t, result.Active)
+ assert.Equal(t, 1, result.Attempts)
+}
+
+func TestVerify_InactiveTimesOut(t *testing.T) {
+ client := &resources.MockClient{
+ Response: []byte(`{"active": false}`),
+ }
+ verifier := &Verifier{
+ Client: client,
+ Interval: 10 * time.Millisecond,
+ Timeout: 50 * time.Millisecond,
+ }
+
+ result, err := verifier.Verify("token", "https://app.launchdarkly.com", "proj", "env")
+ require.NoError(t, err)
+ assert.False(t, result.Active)
+ assert.Greater(t, result.Attempts, 1)
+}