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