diff --git a/cmd/root.go b/cmd/root.go index d498e07e..7b95b592 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -24,6 +24,7 @@ import ( memberscmd "github.com/launchdarkly/ldcli/cmd/members" sdkactivecmd "github.com/launchdarkly/ldcli/cmd/sdk_active" resourcecmd "github.com/launchdarkly/ldcli/cmd/resources" + setupcmd "github.com/launchdarkly/ldcli/cmd/setup" signupcmd "github.com/launchdarkly/ldcli/cmd/signup" sourcemapscmd "github.com/launchdarkly/ldcli/cmd/sourcemaps" "github.com/launchdarkly/ldcli/internal/analytics" @@ -35,6 +36,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 { @@ -124,22 +126,25 @@ 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", } { - 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. @@ -251,7 +256,18 @@ func NewRootCommand( configCmd := configcmd.NewConfigCmd(configService, analyticsTrackerFn) cmd.AddCommand(configCmd.Cmd()) - cmd.AddCommand(NewQuickStartCmd(analyticsTrackerFn, clients.EnvironmentsClient, clients.FlagsClient)) + cmd.AddCommand(setupcmd.NewSetupCmd( + analyticsTrackerFn, + clients.ResourcesClient, + clients.FlagsClient, + setup.StubDetector{}, + setup.StubInstaller{}, + )) + 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..0afe87cd --- /dev/null +++ b/cmd/setup/init.go @@ -0,0 +1,81 @@ +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 { + 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..02df6b42 --- /dev/null +++ b/cmd/setup/install.go @@ -0,0 +1,69 @@ +package setup + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/launchdarkly/ldcli/cmd/cliflags" + "github.com/launchdarkly/ldcli/internal/setup" +) + +const sdkIDFlag = "sdk-id" + +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)") + + 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") + detection := &setup.DetectResult{ + SDKID: sdkID, + PackageManager: pkgMgr, + } + + 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) + fmt.Fprintf(cmd.OutOrStdout(), "Package: %s@%s\n", result.Package, result.Version) + fmt.Fprintf(cmd.OutOrStdout(), "Command: %s\n", result.Command) + 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..80ca89c0 --- /dev/null +++ b/cmd/setup/setup.go @@ -0,0 +1,52 @@ +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) { + fmt.Fprintln(cmd.ErrOrStderr(), + "Notice: 'ldcli setup' now runs the new guided setup wizard (project detection, SDK installation, and initialization).", + "\nThe previous quickstart wizard is still available via 'ldcli quickstart' during the transition period.", + ) + 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..13e39627 --- /dev/null +++ b/cmd/setup/setup_test.go @@ -0,0 +1,188 @@ +package setup_test + +import ( + "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" +) + +func TestInit(t *testing.T) { + tmpDir := t.TempDir() + filePath := 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 := 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 TestDetectStubReturnsError(t *testing.T) { + args := []string{ + "setup", "detect", + "--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(), "not yet implemented") +} + +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{}, + }, + 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/wizard.go b/cmd/setup/wizard.go new file mode 100644 index 00000000..3f2929fb --- /dev/null +++ b/cmd/setup/wizard.go @@ -0,0 +1,524 @@ +package setup + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "os" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "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/flags" + "github.com/launchdarkly/ldcli/internal/resources" + "github.com/launchdarkly/ldcli/internal/setup" +) + +type wizardStep int + +const ( + stepSelectProject wizardStep = iota + stepSelectEnvironment + stepDetect + 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 + + selectedProject string + selectedEnv string + sdkKey string + clientSideID string + mobileKey string + + detectResult *setup.DetectResult + installResult *setup.InstallResult + flagKey string + initResult *setup.InitResult + verifyResult *setup.VerifyResult + + quitting bool +} + +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 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 { + 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 { + return tea.Batch(m.spinner.Tick, m.fetchProjects()) +} + +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", "q": + m.quitting = true + return m, tea.Quit + 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 + m.step = stepDetect + return m, m.runDetect() + + case detectDoneMsg: + m.detectResult = msg.result + m.step = stepInstall + return m, m.runInstall() + + 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 + if !msg.result.Success { + 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: + m.projectList, cmd = m.projectList.Update(msg) + case stepSelectEnvironment: + m.envList, cmd = m.envList.Update(msg) + } + return m, cmd +} + +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 stepWaitForApp: + m.step = stepVerify + return m, m.runVerify() + } + return m, nil +} + +func (m wizardModel) View() string { + if m.quitting { + return "" + } + + titleStyle := lipgloss.NewStyle().Bold(true).MarginBottom(1) + + 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() + + case stepSelectEnvironment: + if len(m.environments) == 0 { + return m.spinner.View() + " Loading environments..." + } + return m.envList.View() + + case stepDetect: + return m.spinner.View() + " Detecting project type..." + + 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.initResult != nil && !m.initResult.Success { + return titleStyle.Render("Manual SDK setup required") + "\n\n" + + fmt.Sprintf("No initialization template is available for %s.\n", m.initResult.SDKID) + + 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" + } + if m.verifyResult != nil && m.verifyResult.Active { + return titleStyle.Render("Setup complete!") + "\n\n" + + fmt.Sprintf("Your %s SDK is connected to LaunchDarkly.\n", m.detectResult.SDKID) + + fmt.Sprintf("Flag %q is ready to use.\n\n", m.flagKey) + + "You can now toggle your flag at https://app.launchdarkly.com\n" + } + 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" + } + + return "" +} + +// 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 wizardErrMsg{err: err} + } + 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 { + return wizardErrMsg{err: err} + } + 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/templates.go b/cmd/templates.go index 3a96b533..b32e93bd 100644 --- a/cmd/templates.go +++ b/cmd/templates.go @@ -11,7 +11,8 @@ func getUsageTemplate() string { {{.CommandPath}} [command] 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..b70964f2 --- /dev/null +++ b/internal/setup/detector.go @@ -0,0 +1,27 @@ +package setup + +import "errors" + +// 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") +} diff --git a/internal/setup/detector_test.go b/internal/setup/detector_test.go new file mode 100644 index 00000000..473ff675 --- /dev/null +++ b/internal/setup/detector_test.go @@ -0,0 +1,24 @@ +package setup + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStubDetector_ReturnsError(t *testing.T) { + d := StubDetector{} + result, err := d.Detect("/tmp") + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "not yet implemented") +} + +func TestStubInstaller_ReturnsError(t *testing.T) { + i := StubInstaller{} + result, err := i.Install("/tmp", &DetectResult{}) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "not yet implemented") +} diff --git a/internal/setup/initializer.go b/internal/setup/initializer.go new file mode 100644 index 00000000..75dc72a1 --- /dev/null +++ b/internal/setup/initializer.go @@ -0,0 +1,213 @@ +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. +type InitResult struct { + SDKID string `json:"sdk_id"` + FilePath string `json:"file_path,omitempty"` + DocsURL string `json:"docs_url,omitempty"` + Success bool `json:"success"` +} + +// 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": {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 +} + +// 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 reads the file at filePath, injects the rendered SDK initialization code, +// and writes the result back. If no template exists for the SDK, it returns a result with +// the documentation URL instead of an error. +// +// The template output is split into an IMPORTS section and an INIT section by a +// separator line ("// --- init ---" or "# --- init ---" depending on language). +// Imports are placed at the top of the file, and init code is appended after the +// existing content. +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) + + 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 +} + +// 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..afb93a98 --- /dev/null +++ b/internal/setup/initializer_test.go @@ -0,0 +1,168 @@ +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", "android", "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.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_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..2f5b258c --- /dev/null +++ b/internal/setup/installer.go @@ -0,0 +1,26 @@ +package setup + +import "errors" + +// 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"` + Success bool `json:"success"` +} + +// 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") +} 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..fe394477 --- /dev/null +++ b/internal/setup/verifier_test.go @@ -0,0 +1,49 @@ +package setup + +import ( + "net/url" + "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) +} + +func TestVerify_URLConstruction(t *testing.T) { + expected, _ := url.JoinPath("https://app.launchdarkly.com", "api/v2/projects", "my-proj", "environments", "my-env", "sdk-active") + assert.Equal(t, "https://app.launchdarkly.com/api/v2/projects/my-proj/environments/my-env/sdk-active", expected) +}