diff --git a/cmd/root.go b/cmd/root.go index 5c0739a7..40e0b291 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,11 +22,11 @@ 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" - symbolscmd "github.com/launchdarkly/ldcli/cmd/symbols" whoamicmd "github.com/launchdarkly/ldcli/cmd/whoami" "github.com/launchdarkly/ldcli/internal/analytics" "github.com/launchdarkly/ldcli/internal/config" @@ -37,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 { @@ -46,6 +47,8 @@ type APIClients struct { MembersClient members.Client ProjectsClient projects.Client ResourcesClient resources.Client + Detector setup.Detector + Installer setup.Installer } type Command interface { @@ -126,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. @@ -254,13 +260,31 @@ 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()) cmd.AddCommand(devcmd.NewDevServerCmd(clients.ResourcesClient, analyticsTrackerFn, clients.DevClient)) cmd.AddCommand(sourcemapscmd.NewSourcemapsCmd(clients.ResourcesClient, analyticsTrackerFn)) - cmd.AddCommand(symbolscmd.NewSymbolsCmd(clients.ResourcesClient, analyticsTrackerFn)) cmd.AddCommand(whoamicmd.NewWhoAmICmd(clients.ResourcesClient)) resourcecmd.AddAllResourceCmds(cmd, clients.ResourcesClient, analyticsTrackerFn) 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 b806ed7b..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 @@ -26,7 +27,6 @@ Common resource commands: {{rpad "members" 29}} Invite new members to an account {{rpad "segments" 29}} List, create, modify, and delete segments {{rpad "sourcemaps" 29}} Manage sourcemaps for error monitoring - {{rpad "symbols" 29}} Manage symbol files for error monitoring {{rpad "..." 29}} To see more resource commands, run 'ldcli resources' Flags: