From 1b0e695963842f42cbf0833e3a1de3cddd86ea3e Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Mon, 3 Aug 2026 14:55:23 -0400 Subject: [PATCH 1/8] feat: add guided setup command ldcli setup walks a project through installing a LaunchDarkly SDK: detect the language and package manager, pick a project and environment, install the SDK with the project's own tool, create a flag, write or show initialization code, then poll until the SDK connects. Orchestration lives in internal/setup.Service so the wizard UI and the detect/install/init subcommands share one path. The wizard is split into model, update, view, and commands rather than one file. Environments gains List so the wizard can offer a choice of environments. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/root.go | 76 +++-- cmd/root_test.go | 34 ++ cmd/setup/commands.go | 124 +++++++ cmd/setup/detect.go | 66 ++++ cmd/setup/init.go | 85 +++++ cmd/setup/install.go | 99 ++++++ cmd/setup/model.go | 159 +++++++++ cmd/setup/setup.go | 57 ++++ cmd/setup/setup_test.go | 362 +++++++++++++++++++++ cmd/setup/styles.go | 52 +++ cmd/setup/update.go | 299 +++++++++++++++++ cmd/setup/view.go | 263 +++++++++++++++ cmd/setup/wizard_test.go | 469 +++++++++++++++++++++++++++ cmd/templates.go | 3 +- cmd/templates_test.go | 32 ++ internal/environments/client.go | 23 ++ internal/environments/mock_client.go | 11 + internal/setup/service.go | 177 ++++++++++ internal/setup/service_test.go | 157 +++++++++ 19 files changed, 2528 insertions(+), 20 deletions(-) create mode 100644 cmd/setup/commands.go create mode 100644 cmd/setup/detect.go create mode 100644 cmd/setup/init.go create mode 100644 cmd/setup/install.go create mode 100644 cmd/setup/model.go create mode 100644 cmd/setup/setup.go create mode 100644 cmd/setup/setup_test.go create mode 100644 cmd/setup/styles.go create mode 100644 cmd/setup/update.go create mode 100644 cmd/setup/view.go create mode 100644 cmd/setup/wizard_test.go create mode 100644 cmd/templates_test.go create mode 100644 internal/setup/service.go create mode 100644 internal/setup/service_test.go diff --git a/cmd/root.go b/cmd/root.go index 5c0739a7d..a8f2110e3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,8 +22,9 @@ import ( flagscmd "github.com/launchdarkly/ldcli/cmd/flags" logincmd "github.com/launchdarkly/ldcli/cmd/login" memberscmd "github.com/launchdarkly/ldcli/cmd/members" - sdkactivecmd "github.com/launchdarkly/ldcli/cmd/sdk_active" resourcecmd "github.com/launchdarkly/ldcli/cmd/resources" + sdkactivecmd "github.com/launchdarkly/ldcli/cmd/sdk_active" + setupcmd "github.com/launchdarkly/ldcli/cmd/setup" signupcmd "github.com/launchdarkly/ldcli/cmd/signup" sourcemapscmd "github.com/launchdarkly/ldcli/cmd/sourcemaps" symbolscmd "github.com/launchdarkly/ldcli/cmd/symbols" @@ -37,6 +38,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 +48,8 @@ type APIClients struct { MembersClient members.Client ProjectsClient projects.Client ResourcesClient resources.Client + Detector setup.Detector + Installer setup.Installer } type Command interface { @@ -100,6 +104,33 @@ func forceTTYDefaultOutput(getenv func(string) string) bool { return lookup("FORCE_TTY") != "" || lookup("LD_FORCE_TTY") != "" } +// authExemptCommands are commands (and their subcommands) that don't call the +// LaunchDarkly API and so don't require --access-token. +var authExemptCommands = map[string]bool{ + "completion": true, + "config": true, + "help": true, + "login": true, + "setup": true, + "signup": true, + "whoami": true, +} + +// clearAccessTokenRequirement drops the "required" annotation on --access-token +// for auth-exempt commands, so cobra's required-flag check doesn't reject them. +// We clear the annotation rather than setting DisableFlagParsing, which would +// also suppress validation of the subcommand's own required flags. +func clearAccessTokenRequirement(cmd *cobra.Command) { + for c := cmd; c != nil; c = c.Parent() { + if authExemptCommands[c.Name()] { + if f := cmd.Flags().Lookup(cliflags.AccessTokenFlag); f != nil { + delete(f.Annotations, cobra.BashCompOneRequiredFlag) + } + return + } + } +} + // NewRootCommand constructs the ldcli root command tree. // // isTerminal must be non-nil; it should reflect whether stdout is a TTY (see Execute). When it @@ -126,23 +157,7 @@ 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 - for _, name := range []string{ - "completion", - "config", - "help", - "login", - "signup", - "whoami", - } { - if cmd.HasParent() && cmd.Parent().Name() == name { - cmd.DisableFlagParsing = true - } - if cmd.Name() == name { - cmd.DisableFlagParsing = true - } - } - + clearAccessTokenRequirement(cmd) }, Annotations: make(map[string]string), // Handle errors differently based on type. @@ -254,7 +269,30 @@ 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, + setup.Clients{ + Projects: clients.ProjectsClient, + Environments: clients.EnvironmentsClient, + Flags: clients.FlagsClient, + Resources: clients.ResourcesClient, + }, + detector, + installer, + )) + quickStartCmd := NewQuickStartCmd(analyticsTrackerFn, clients.EnvironmentsClient, clients.FlagsClient) + quickStartCmd.Use = "quickstart" + quickStartCmd.Hidden = true + quickStartCmd.Deprecated = "use 'ldcli setup' for the new guided setup experience" + cmd.AddCommand(quickStartCmd) cmd.AddCommand(logincmd.NewLoginCmd(clients.ResourcesClient)) cmd.AddCommand(signupcmd.NewSignupCmd(analyticsTrackerFn)) cmd.AddCommand(resourcecmd.NewResourcesCmd()) diff --git a/cmd/root_test.go b/cmd/root_test.go index 5d3f6ef09..2b34ee65f 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -338,3 +338,37 @@ func TestConfigOutputPrecedenceNonTTY(t *testing.T) { assert.Contains(t, string(out), "Key:") assert.Contains(t, string(out), "test-key") } + +// A rebase silently dropped the symbols registration once, so every top-level +// command the CLI ships is asserted here. +func TestNewRootCommand_RegistersTopLevelCommands(t *testing.T) { + rootCmd := newRootCmdWithTerminal(t, func() bool { return false }, nil) + c := rootCmd.Cmd() + // Execute wires this up; NewRootCommand does not. + c.InitDefaultCompletionCmd() + + registered := make(map[string]bool, len(c.Commands())) + for _, sub := range c.Commands() { + registered[sub.Name()] = true + } + + for _, name := range []string{ + "completion", + "config", + "dev-server", + "flags", + "login", + "members", + "projects", + "quickstart", + "resources", + "segments", + "setup", + "signup", + "sourcemaps", + "symbols", + "whoami", + } { + assert.True(t, registered[name], "%s is not registered on the root command", name) + } +} diff --git a/cmd/setup/commands.go b/cmd/setup/commands.go new file mode 100644 index 000000000..470600cbc --- /dev/null +++ b/cmd/setup/commands.go @@ -0,0 +1,124 @@ +package setup + +import ( + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/launchdarkly/ldcli/internal/setup" +) + +func (m wizardModel) fetchProjects() tea.Cmd { + return func() tea.Msg { + ps, err := m.svc.ListProjects(m.auth) + if err != nil { + return wizardErrMsg{err: err} + } + projects := make([]projectItem, len(ps)) + for i, p := range ps { + projects[i] = projectItem{key: p.Key, name: p.Name} + } + return projectsFetchedMsg{projects: projects} + } +} + +func (m wizardModel) fetchEnvironments() tea.Cmd { + return func() tea.Msg { + es, err := m.svc.ListEnvironments(m.auth, m.selectedProject) + if err != nil { + return wizardErrMsg{err: err} + } + envs := make([]envItem, len(es)) + for i, e := range es { + envs[i] = envItem{key: e.Key, name: e.Name} + } + return envsFetchedMsg{environments: envs} + } +} + +func (m wizardModel) fetchEnvDetails() tea.Cmd { + return func() tea.Msg { + keys, err := m.svc.EnvKeys(m.auth, m.selectedProject, m.selectedEnv) + if err != nil { + return wizardErrMsg{err: err} + } + return envDetailsFetchedMsg{ + sdkKey: keys.SDKKey, + clientSideID: keys.ClientSideID, + mobileKey: keys.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.svc.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.svc.Install(dir, m.detectResult) + if err != nil { + // Don't dead-end the interactive flow on a failed auto-install (e.g. + // Ruby gem perms, no network): 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, + FailureReason: err.Error(), + }} + } + return installDoneMsg{result: result} + } +} + +func (m wizardModel) runCreateFlag() tea.Cmd { + return func() tea.Msg { + key, err := m.svc.CreateFlag(m.auth, m.selectedProject, "my-new-flag", "My New Flag") + if err != nil { + return wizardErrMsg{err: err} + } + return flagCreatedMsg{key: key} + } +} + +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, + } + result, err := m.svc.Inject(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 { + result, err := m.svc.Verify(m.auth, m.selectedProject, m.selectedEnv, m.detectResult.SDKID) + if err != nil { + return wizardErrMsg{err: err} + } + return verifyDoneMsg{result: result} + } +} diff --git a/cmd/setup/detect.go b/cmd/setup/detect.go new file mode 100644 index 000000000..b7d0ced2a --- /dev/null +++ b/cmd/setup/detect.go @@ -0,0 +1,66 @@ +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(svc setup.Service) *cobra.Command { + cmd := &cobra.Command{ + Use: "detect", + Short: "Detect language, framework, and recommended SDK for a project", + Hidden: true, + RunE: runDetect(svc), + } + + cmd.Flags().String(pathFlag, "", "Path to the project directory (defaults to current directory)") + + return cmd +} + +func runDetect(svc setup.Service) 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 := svc.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) + if result.EntryPointExists { + fmt.Fprintf(cmd.OutOrStdout(), "Entry Point: %s\n", result.EntryPoint) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "Entry Point: %s (suggested, does not exist)\n", result.EntryPoint) + } + + return nil + } +} diff --git a/cmd/setup/init.go b/cmd/setup/init.go new file mode 100644 index 000000000..ef96b1ed7 --- /dev/null +++ b/cmd/setup/init.go @@ -0,0 +1,85 @@ +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(svc setup.Service) *cobra.Command { + cmd := &cobra.Command{ + Use: "init", + Short: "Inject LaunchDarkly SDK initialization code into a file", + Hidden: true, + RunE: runInit(svc), + } + + 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(svc setup.Service) 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), + } + + result, err := svc.Inject(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 000000000..455ec7169 --- /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(svc setup.Service) *cobra.Command { + cmd := &cobra.Command{ + Use: "install", + Short: "Install the LaunchDarkly SDK package for the detected project", + Hidden: true, + RunE: runInstall(svc), + } + + 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(svc setup.Service) 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 = svc.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/model.go b/cmd/setup/model.go new file mode 100644 index 000000000..48e02fc28 --- /dev/null +++ b/cmd/setup/model.go @@ -0,0 +1,159 @@ +package setup + +import ( + "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/setup" +) + +type wizardStep int + +const ( + stepSelectProject wizardStep = iota + stepSelectEnvironment + stepDetect + stepSelectSDK + stepPlan + stepInstall + stepCreateFlag + stepInit + stepWaitForApp + stepVerify + stepDone +) + +type wizardModel struct { + analyticsTrackerFn analytics.TrackerFn + svc setup.Service + auth setup.Auth + + 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) + // detected is the unmodified result of the one-time detection. detectResult is + // what the rest of the flow acts on: the same values with the SDK the user + // actually chose. Keeping the whole struct means added fields reach the later + // steps without every one having to be copied by hand. + detected *setup.DetectResult + 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, + svc setup.Service, +) 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, + svc: svc, + auth: setup.Auth{ + AccessToken: viper.GetString(cliflags.AccessTokenFlag), + BaseURI: viper.GetString(cliflags.BaseURIFlag), + }, + 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()) +} diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go new file mode 100644 index 000000000..b0f32f95c --- /dev/null +++ b/cmd/setup/setup.go @@ -0,0 +1,57 @@ +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/setup" +) + +// NewSetupCmd creates the top-level setup command and registers its hidden subcommands. +func NewSetupCmd( + analyticsTrackerFn analytics.TrackerFn, + clients setup.Clients, + detector setup.Detector, + installer setup.Installer, +) *cobra.Command { + svc := setup.Service{ + Clients: clients, + Detector: detector, + Installer: installer, + Initializer: setup.Initializer{}, + } + 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, svc), + } + + cmd.AddCommand(newDetectCmd(svc)) + cmd.AddCommand(newInstallCmd(svc)) + cmd.AddCommand(newInitCmd(svc)) + + return cmd +} diff --git a/cmd/setup/setup_test.go b/cmd/setup/setup_test.go new file mode 100644 index 000000000..2d7e4cbe6 --- /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 000000000..4ace07175 --- /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/update.go b/cmd/setup/update.go new file mode 100644 index 000000000..0aa90d5a5 --- /dev/null +++ b/cmd/setup/update.go @@ -0,0 +1,299 @@ +package setup + +import ( + "os" + "path/filepath" + "strings" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + + "github.com/launchdarkly/ldcli/internal/setup" +) + +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.detected = msg.result + 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 + } + result := setup.DetectResult{} + if m.detected != nil { + result = *m.detected + } + result.SDKID = chosen.id + result.Language = chosen.language + if chosen.id != m.detectedSDKID { + // The detected entry point belongs to the language we detected, not the + // one the user picked. An append-safe SDK would otherwise write Ruby into + // a Node project's index.js, so start over from that SDK's own default. + result.Framework = "" + result.EntryPoint = setup.DefaultEntryPoint(chosen.id) + result.EntryPointExists = false + if dir, err := os.Getwd(); err == nil && result.EntryPoint != "" { + result.EntryPoint = filepath.Join(dir, result.EntryPoint) + // Injection appends to a file that is already there, so report the + // default as found when it exists. Claiming otherwise would promise + // to create a file and then quietly append to the user's. + if info, err := os.Stat(result.EntryPoint); err == nil && !info.IsDir() { + result.EntryPointExists = true + } + } + } + m.detectResult = &result + // Compute the plan preview shown before any action is taken. + args, _ := setup.InstallArgs(chosen.id, result.PackageManager) + 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. diff --git a/cmd/setup/view.go b/cmd/setup/view.go new file mode 100644 index 000000000..b939d1812 --- /dev/null +++ b/cmd/setup/view.go @@ -0,0 +1,263 @@ +package setup + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/list" + + "github.com/launchdarkly/ldcli/internal/setup" +) + +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.installResult.FailureReason != "" { + body += m.wrap("Reason: "+m.installResult.FailureReason) + "\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 { + appHost := strings.TrimRight(m.auth.BaseURI, "/") + 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 %s/projects/%s/flags/%s/targeting?env=%s\n", appHost, 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) { + // Say when the entry file does not exist yet: a file we create is not loaded + // by the project, so the user needs the chance to back out and point us at + // the real entry point. + if m.detectResult.EntryPointExists { + add(fmt.Sprintf("Add initialization code to %s", m.detectResult.EntryPoint)) + } else { + add(fmt.Sprintf("Create %s with initialization code %s", + m.detectResult.EntryPoint, + mutedStyle.Render("(no entry file found — check this is where your app starts)"))) + } + 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. Each is a thin tea.Cmd adapter over the +// orchestration service: it calls a step method and maps the result or error +// onto a wizard message. All API/filesystem work and business rules live in +// internal/setup.Service. diff --git a/cmd/setup/wizard_test.go b/cmd/setup/wizard_test.go new file mode 100644 index 000000000..2b1a885cf --- /dev/null +++ b/cmd/setup/wizard_test.go @@ -0,0 +1,469 @@ +package setup + +import ( + "os" + "path/filepath" + "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) +} + +func TestWizard_Plan_ExistingEntryPoint_SaysAdd(t *testing.T) { + m := wizardModel{ + step: stepPlan, + selectedProject: "default", + selectedEnv: "test", + detectResult: &setup.DetectResult{ + SDKID: "node-server", + EntryPoint: "src/index.js", + EntryPointExists: true, + }, + width: 80, + height: 30, + } + + view := m.planView() + assert.Contains(t, view, "Add initialization code to src/index.js") + assert.NotContains(t, view, "Create src/index.js") +} + +// A guessed entry point means we would write a file the project does not load, so +// the plan has to say so while the user can still back out. +func TestWizard_Plan_MissingEntryPoint_SaysCreate(t *testing.T) { + m := wizardModel{ + step: stepPlan, + selectedProject: "default", + selectedEnv: "test", + detectResult: &setup.DetectResult{ + SDKID: "node-server", + EntryPoint: "instrumentation.ts", + EntryPointExists: false, + }, + width: 80, + height: 30, + } + + view := m.planView() + assert.Contains(t, view, "Create instrumentation.ts") + assert.Contains(t, view, "no entry file found") + assert.NotContains(t, view, "Add initialization code to") +} + +// The SDK screen rebuilds detectResult, and the plan and install steps read it, so +// every detected value has to survive that step — not just the SDK. +func TestWizard_SelectSDK_CarriesDetectionThrough(t *testing.T) { + m := wizardModel{step: stepDetect, width: 80, height: 30} + + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{ + SDKID: "ruby-server-sdk", + Language: "Ruby", + Framework: "Rails", + PackageManager: "bundle", + EntryPoint: "config.ru", + EntryPointExists: true, + }}) + m2 := next.(wizardModel) + require.Equal(t, stepSelectSDK, m2.step) + + next2, _ := m2.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m3 := next2.(wizardModel) + require.Equal(t, stepPlan, m3.step) + + assert.Equal(t, "bundle", m3.detectResult.PackageManager, "install would fall back to gem install") + assert.True(t, m3.detectResult.EntryPointExists, "plan would claim it will create an existing file") + assert.Equal(t, "Rails", m3.detectResult.Framework) + assert.Equal(t, "config.ru", m3.detectResult.EntryPoint) +} + +func TestWizard_SelectSDK_PlanUsesDetectedPackageManager(t *testing.T) { + m := wizardModel{step: stepDetect, width: 80, height: 30} + + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{ + SDKID: "ruby-server-sdk", Language: "Ruby", PackageManager: "bundle", + }}) + next2, _ := next.(wizardModel).Update(tea.KeyMsg{Type: tea.KeyEnter}) + m3 := next2.(wizardModel) + + assert.Equal(t, "bundle add launchdarkly-server-sdk", m3.planInstallCmd) +} + +// selectOtherSDK moves focus to the list of non-detected SDKs and highlights id. +func selectOtherSDK(t *testing.T, m wizardModel, id string) wizardModel { + t.Helper() + next, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab}) + m = next.(wizardModel) + require.Equal(t, 1, m.sdkFocus) + for i, item := range m.sdkList.Items() { + if sdk, ok := item.(sdkItem); ok && sdk.id == id { + m.sdkList.Select(i) + return m + } + } + t.Fatalf("%s is not in the list of other SDKs", id) + return m +} + +// The detected entry point belongs to the detected language. ruby-server-sdk is +// append-safe, so reusing it would append Ruby to a Node project's index.js. +func TestWizard_OverrideSDK_DoesNotReuseDetectedEntryPoint(t *testing.T) { + m := wizardModel{step: stepDetect, width: 80, height: 30} + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{ + SDKID: "node-server", + Language: "JavaScript", + Framework: "Next.js", + PackageManager: "pnpm", + EntryPoint: "/proj/index.js", + EntryPointExists: true, + }}) + + m2 := selectOtherSDK(t, next.(wizardModel), "ruby-server-sdk") + next2, _ := m2.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m3 := next2.(wizardModel) + + require.Equal(t, stepPlan, m3.step) + assert.Equal(t, "ruby-server-sdk", m3.detectResult.SDKID) + assert.NotEqual(t, "/proj/index.js", m3.detectResult.EntryPoint, + "setup would append Ruby to the Node entry file") + assert.False(t, m3.detectResult.EntryPointExists, + "a file we have not found must not be reported as found") + assert.Contains(t, m3.detectResult.EntryPoint, "main.rb") + assert.Empty(t, m3.detectResult.Framework, "Next.js does not describe a Ruby project") + // The package manager describes the project, not the SDK, so it survives. + assert.Equal(t, "pnpm", m3.detectResult.PackageManager) +} + +// SDKs that only ever return a snippet have no file to name. +func TestWizard_OverrideSDK_SnippetOnlySDKHasNoEntryPoint(t *testing.T) { + m := wizardModel{step: stepDetect, width: 80, height: 30} + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{ + SDKID: "node-server", + Language: "JavaScript", + EntryPoint: "/proj/index.js", + EntryPointExists: true, + }}) + + m2 := selectOtherSDK(t, next.(wizardModel), "go-server-sdk") + next2, _ := m2.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m3 := next2.(wizardModel) + + assert.Empty(t, m3.detectResult.EntryPoint) + assert.False(t, setup.InjectsInPlace(m3.detectResult.SDKID)) + assert.Contains(t, m3.View(), "Show initialization code for you to add") +} + +// Confirming the detected SDK is not an override, so its entry point stands. +func TestWizard_KeepDetectedSDK_KeepsEntryPoint(t *testing.T) { + m := wizardModel{step: stepDetect, width: 80, height: 30} + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{ + SDKID: "node-server", + Language: "JavaScript", + Framework: "Next.js", + EntryPoint: "/proj/instrumentation.ts", + EntryPointExists: true, + }}) + + next2, _ := next.(wizardModel).Update(tea.KeyMsg{Type: tea.KeyEnter}) + m3 := next2.(wizardModel) + + assert.Equal(t, "/proj/instrumentation.ts", m3.detectResult.EntryPoint) + assert.True(t, m3.detectResult.EntryPointExists) + assert.Equal(t, "Next.js", m3.detectResult.Framework) +} + +// overrideToSDK runs detection, switches to id, and returns the model on the plan +// screen. +func overrideToSDK(t *testing.T, detected *setup.DetectResult, id string) wizardModel { + t.Helper() + m := wizardModel{step: stepDetect, width: 80, height: 30} + next, _ := m.Update(detectDoneMsg{result: detected}) + m2 := selectOtherSDK(t, next.(wizardModel), id) + next2, _ := m2.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m3 := next2.(wizardModel) + require.Equal(t, stepPlan, m3.step) + return m3 +} + +// Injection appends to a file that already exists, so the plan must not offer to +// create one. Promising to create and then appending edits a file the user did not +// agree to have touched. +func TestWizard_OverrideSDK_DefaultEntryPointAlreadyPresent(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.rb"), []byte("puts 1\n"), 0600)) + t.Chdir(dir) + + m := overrideToSDK(t, &setup.DetectResult{ + SDKID: "node-server", Language: "JavaScript", + EntryPoint: filepath.Join(dir, "index.js"), EntryPointExists: true, + }, "ruby-server-sdk") + + assert.Equal(t, filepath.Join(dir, "main.rb"), m.detectResult.EntryPoint) + assert.True(t, m.detectResult.EntryPointExists) + view := m.View() + assert.Contains(t, view, "Add initialization code to") + assert.NotContains(t, view, "no entry file found") +} + +func TestWizard_OverrideSDK_DefaultEntryPointMissing(t *testing.T) { + t.Chdir(t.TempDir()) + + m := overrideToSDK(t, &setup.DetectResult{ + SDKID: "node-server", Language: "JavaScript", + EntryPoint: "index.js", EntryPointExists: true, + }, "ruby-server-sdk") + + assert.False(t, m.detectResult.EntryPointExists) + assert.Contains(t, m.View(), "no entry file found") +} diff --git a/cmd/templates.go b/cmd/templates.go index b806ed7ba..46a5c1f2d 100644 --- a/cmd/templates.go +++ b/cmd/templates.go @@ -12,7 +12,8 @@ func getUsageTemplate() string { {{.CommandPath}} [command]{{end}} {{if not .HasParent}} Commands: - {{rpad "setup" 29}} Create your first feature flag using a step-by-step guide + {{rpad "setup" 29}} Set up LaunchDarkly in your project (detect, install, initialize) + {{rpad "quickstart" 29}} Create your first feature flag using a step-by-step guide (deprecated: use setup) {{rpad "config" 29}} View and modify specific configuration values {{rpad "completion" 29}} Enable command autocompletion within supported shells {{rpad "login" 29}} Log in to your LaunchDarkly account diff --git a/cmd/templates_test.go b/cmd/templates_test.go new file mode 100644 index 000000000..a4717eb33 --- /dev/null +++ b/cmd/templates_test.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// The root usage listing is hand-maintained, so a command added to or removed from +// the tree does not update it. The symbols entry was dropped from both at once. +func TestGetUsageTemplate_ListsTopLevelCommands(t *testing.T) { + template := getUsageTemplate() + + for _, name := range []string{ + "setup", + "quickstart", + "config", + "completion", + "login", + "signup", + "dev-server", + "flags", + "environments", + "projects", + "members", + "segments", + "sourcemaps", + "symbols", + } { + assert.Contains(t, template, `"`+name+`"`, "%s is missing from the root usage listing", name) + } +} diff --git a/internal/environments/client.go b/internal/environments/client.go index 6abbc7578..46add672d 100644 --- a/internal/environments/client.go +++ b/internal/environments/client.go @@ -10,6 +10,7 @@ import ( type Client interface { Get(ctx context.Context, accessToken, baseURI, key, projKey string) ([]byte, error) + List(ctx context.Context, accessToken, baseURI, projKey string) ([]byte, error) } type EnvironmentsClient struct { @@ -46,3 +47,25 @@ func (c EnvironmentsClient) Get( return output, nil } + +func (c EnvironmentsClient) List( + ctx context.Context, + accessToken, + baseURI, + projectKey string, +) ([]byte, error) { + client := client.New(accessToken, baseURI, c.cliVersion) + environments, _, err := client.EnvironmentsApi.GetEnvironmentsByProject(ctx, projectKey).Execute() + if err != nil { + return nil, errors.NewLDAPIError(err) + + } + + output, err := json.Marshal(environments) + if err != nil { + return nil, errors.NewLDAPIError(err) + + } + + return output, nil +} diff --git a/internal/environments/mock_client.go b/internal/environments/mock_client.go index c53ff20cd..e6da5bef3 100644 --- a/internal/environments/mock_client.go +++ b/internal/environments/mock_client.go @@ -23,3 +23,14 @@ func (c *MockClient) Get( return args.Get(0).([]byte), args.Error(1) } + +func (c *MockClient) List( + ctx context.Context, + accessToken, + baseURI, + projKey string, +) ([]byte, error) { + args := c.Called(accessToken, baseURI, projKey) + + return args.Get(0).([]byte), args.Error(1) +} diff --git a/internal/setup/service.go b/internal/setup/service.go new file mode 100644 index 000000000..d1636caa7 --- /dev/null +++ b/internal/setup/service.go @@ -0,0 +1,177 @@ +package setup + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/launchdarkly/ldcli/internal/environments" + "github.com/launchdarkly/ldcli/internal/flags" + "github.com/launchdarkly/ldcli/internal/projects" + "github.com/launchdarkly/ldcli/internal/resources" +) + +// Auth carries resolved credentials so the service never reads global config. +type Auth struct { + AccessToken string + BaseURI string +} + +// Clients groups the LaunchDarkly API clients the service depends on. Projects, +// Environments, and Flags use the shared typed clients; Resources backs Verify, +// whose sdk-active endpoint has no typed-client wrapper. +type Clients struct { + Projects projects.Client + Environments environments.Client + Flags flags.Client + Resources resources.Client +} + +// Service orchestrates the setup steps over the LaunchDarkly API and the local +// project. It holds no UI or CLI state; callers resolve credentials into Auth +// and pass them in. +type Service struct { + Clients Clients + Detector Detector + Installer Installer + Initializer Initializer +} + +// ProjectSummary is a project as the setup flow needs it. +type ProjectSummary struct { + Key string + Name string +} + +// EnvSummary is an environment as the setup flow needs it. +type EnvSummary struct { + Key string + Name string +} + +// EnvKeys are the SDK credentials for an environment. +type EnvKeys struct { + SDKKey string + ClientSideID string + MobileKey string +} + +// ListProjects returns the account's projects. +func (s Service) ListProjects(a Auth) ([]ProjectSummary, error) { + res, err := s.Clients.Projects.List(context.Background(), a.AccessToken, a.BaseURI) + if err != nil { + return nil, 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 nil, fmt.Errorf("parsing projects: %w", err) + } + + projects := make([]ProjectSummary, len(resp.Items)) + for i, item := range resp.Items { + projects[i] = ProjectSummary{Key: item.Key, Name: item.Name} + } + return projects, nil +} + +// ListEnvironments returns the environments in a project. +func (s Service) ListEnvironments(a Auth, projectKey string) ([]EnvSummary, error) { + res, err := s.Clients.Environments.List(context.Background(), a.AccessToken, a.BaseURI, projectKey) + if err != nil { + return nil, 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 nil, fmt.Errorf("parsing environments: %w", err) + } + + envs := make([]EnvSummary, len(resp.Items)) + for i, item := range resp.Items { + envs[i] = EnvSummary{Key: item.Key, Name: item.Name} + } + return envs, nil +} + +// EnvKeys returns the SDK credentials for an environment. +func (s Service) EnvKeys(a Auth, projectKey, envKey string) (EnvKeys, error) { + res, err := s.Clients.Environments.Get(context.Background(), a.AccessToken, a.BaseURI, envKey, projectKey) + if err != nil { + return EnvKeys{}, 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 EnvKeys{}, fmt.Errorf("parsing environment details: %w", err) + } + + return EnvKeys{ + SDKKey: resp.SDKKey, + ClientSideID: resp.ClientSideID, + MobileKey: resp.MobileKey, + }, nil +} + +// Detect inspects the project directory for language, framework, and SDK. +func (s Service) Detect(dir string) (*DetectResult, error) { + return s.Detector.Detect(dir) +} + +// Install installs the SDK package for the project. It returns the installer's +// error unchanged; callers that must not dead-end (the interactive wizard) apply +// their own fallback, while non-interactive callers surface the error. +func (s Service) Install(dir string, detection *DetectResult) (*InstallResult, error) { + return s.Installer.Install(dir, detection) +} + +// CreateFlag creates a feature flag, treating an existing flag (conflict) as +// success and returning its key. +func (s Service) CreateFlag(a Auth, projectKey, key, name string) (string, error) { + _, err := s.Clients.Flags.Create(context.Background(), a.AccessToken, a.BaseURI, name, key, projectKey) + if err != nil { + if je, parseErr := parseJSONError(err); parseErr == nil && je.Code == "conflict" { + return key, nil + } + return "", err + } + return key, nil +} + +// Inject writes SDK initialization code into filePath. +func (s Service) Inject(sdkID, filePath string, cfg InitConfig) (*InitResult, error) { + return s.Initializer.InjectIntoFile(sdkID, filePath, cfg) +} + +// Verify polls until the SDK reports as active or a timeout is reached. +func (s Service) Verify(a Auth, projectKey, envKey, sdkID string) (*VerifyResult, error) { + return DefaultVerifier(s.Clients.Resources).Verify(a.AccessToken, a.BaseURI, projectKey, envKey, sdkID) +} + +type jsonError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// parseJSONError decodes a LaunchDarkly API error whose message is a JSON body. +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/internal/setup/service_test.go b/internal/setup/service_test.go new file mode 100644 index 000000000..7bc8ed17a --- /dev/null +++ b/internal/setup/service_test.go @@ -0,0 +1,157 @@ +package setup + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/environments" + "github.com/launchdarkly/ldcli/internal/errors" + "github.com/launchdarkly/ldcli/internal/flags" + "github.com/launchdarkly/ldcli/internal/projects" + "github.com/launchdarkly/ldcli/internal/resources" +) + +var testAuth = Auth{AccessToken: "token", BaseURI: "https://example.com"} + +// fakeDetector / fakeInstaller let us drive the service's passthrough steps +// without the filesystem or shelling out. +type fakeDetector struct { + result *DetectResult + err error +} + +func (f fakeDetector) Detect(string) (*DetectResult, error) { return f.result, f.err } + +type fakeInstaller struct { + result *InstallResult + err error +} + +func (f fakeInstaller) Install(string, *DetectResult) (*InstallResult, error) { + return f.result, f.err +} + +func TestService_ListProjects(t *testing.T) { + mockProjects := &projects.MockClient{} + mockProjects.On("List", testAuth.AccessToken, testAuth.BaseURI). + Return([]byte(`{"items":[{"key":"p1","name":"Project One"},{"key":"p2","name":"Project Two"}]}`), nil) + svc := Service{Clients: Clients{Projects: mockProjects}} + + got, err := svc.ListProjects(testAuth) + + require.NoError(t, err) + assert.Equal(t, []ProjectSummary{{Key: "p1", Name: "Project One"}, {Key: "p2", Name: "Project Two"}}, got) +} + +func TestService_ListEnvironments(t *testing.T) { + mockEnvs := &environments.MockClient{} + mockEnvs.On("List", testAuth.AccessToken, testAuth.BaseURI, "p1"). + Return([]byte(`{"items":[{"key":"production","name":"Production"}]}`), nil) + svc := Service{Clients: Clients{Environments: mockEnvs}} + + got, err := svc.ListEnvironments(testAuth, "p1") + + require.NoError(t, err) + assert.Equal(t, []EnvSummary{{Key: "production", Name: "Production"}}, got) +} + +func TestService_EnvKeys(t *testing.T) { + mockEnvs := &environments.MockClient{} + mockEnvs.On("Get", testAuth.AccessToken, testAuth.BaseURI, "production", "p1"). + Return([]byte(`{"apiKey":"sdk-123","_id":"client-456","mobileKey":"mob-789"}`), nil) + svc := Service{Clients: Clients{Environments: mockEnvs}} + + got, err := svc.EnvKeys(testAuth, "p1", "production") + + require.NoError(t, err) + assert.Equal(t, EnvKeys{SDKKey: "sdk-123", ClientSideID: "client-456", MobileKey: "mob-789"}, got) +} + +func TestService_CreateFlag_Success(t *testing.T) { + mockFlags := &flags.MockClient{} + mockFlags.On("Create", testAuth.AccessToken, testAuth.BaseURI, "My New Flag", "my-new-flag", "p1"). + Return([]byte(`{"key":"my-new-flag"}`), nil) + svc := Service{Clients: Clients{Flags: mockFlags}} + + key, err := svc.CreateFlag(testAuth, "p1", "my-new-flag", "My New Flag") + + require.NoError(t, err) + assert.Equal(t, "my-new-flag", key) +} + +func TestService_CreateFlag_ConflictIsSuccess(t *testing.T) { + mockFlags := &flags.MockClient{} + mockFlags.On("Create", testAuth.AccessToken, testAuth.BaseURI, "My New Flag", "my-new-flag", "p1"). + Return([]byte(nil), errors.NewError(`{"code":"conflict","message":"already exists"}`)) + svc := Service{Clients: Clients{Flags: mockFlags}} + + key, err := svc.CreateFlag(testAuth, "p1", "my-new-flag", "My New Flag") + + require.NoError(t, err) + assert.Equal(t, "my-new-flag", key) +} + +func TestService_CreateFlag_OtherErrorPropagates(t *testing.T) { + mockFlags := &flags.MockClient{} + mockFlags.On("Create", testAuth.AccessToken, testAuth.BaseURI, "My New Flag", "my-new-flag", "p1"). + Return([]byte(nil), errors.NewError(`{"code":"internal_error"}`)) + svc := Service{Clients: Clients{Flags: mockFlags}} + + _, err := svc.CreateFlag(testAuth, "p1", "my-new-flag", "My New Flag") + + assert.Error(t, err) +} + +func TestService_Detect(t *testing.T) { + want := &DetectResult{Language: "go", SDKID: "go-server-sdk"} + svc := Service{Detector: fakeDetector{result: want}} + + got, err := svc.Detect("/some/dir") + + require.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestService_Install_Success(t *testing.T) { + want := &InstallResult{SDKID: "node-server", Success: true} + svc := Service{Installer: fakeInstaller{result: want}} + + got, err := svc.Install("/some/dir", &DetectResult{SDKID: "node-server"}) + + require.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestService_Install_ErrorPropagates(t *testing.T) { + // The service returns the installer's error unchanged; the wizard, not the + // service, decides whether to continue past a failed install. + svc := Service{Installer: fakeInstaller{err: errors.NewError("boom")}} + + _, err := svc.Install("/some/dir", &DetectResult{SDKID: "node-server"}) + + assert.Error(t, err) +} + +func TestService_Inject(t *testing.T) { + svc := Service{Initializer: Initializer{}} + filePath := filepath.Join(t.TempDir(), "index.js") + + result, err := svc.Inject("node-server", filePath, InitConfig{SDKKey: "sdk-123"}) + + require.NoError(t, err) + assert.Equal(t, "node-server", result.SDKID) + assert.True(t, result.Success) +} + +func TestService_Verify_Active(t *testing.T) { + svc := Service{Clients: Clients{Resources: &resources.MockClient{Response: []byte(`{"active":true}`)}}} + + result, err := svc.Verify(testAuth, "p1", "production", "node-server") + + require.NoError(t, err) + assert.True(t, result.Active) + assert.Equal(t, 1, result.Attempts) +} From f73e51ca9f82ab45a8bacd805e82813b4dac543a Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Wed, 5 Aug 2026 14:14:35 -0400 Subject: [PATCH 2/8] feat(setup): copy wizard code blocks with c (#771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * REL-15243: add a copy key for wizard code blocks Code blocks are drawn with a left gutter bar, so selecting one by hand copies the gutter characters and the padding lipgloss squares the block off with. The wizard also owns the alternate screen, so the snippet is not in scrollback once it exits. Pressing c writes the raw content to the system clipboard with OSC 52, preferring the snippet over the install command when a screen shows both. Co-Authored-By: Claude Opus 5 (1M context) * REL-15243: copy through the OS clipboard before the terminal OSC 52 alone left the key unreliable: terminals are not required to implement it, Apple Terminal does not, and support cannot be queried, so the confirmation claimed a copy that may never have happened. The OS clipboard works in any terminal and returns an error, so try it first and keep OSC 52 for when it fails — which is the SSH case, where the OS clipboard belongs to the wrong machine. Word the two outcomes apart, since only the first can be confirmed. Co-Authored-By: Claude Opus 5 (1M context) * REL-15243: skip the OS clipboard over SSH A remote host can have a working clipboard, so writing to it succeeds while putting the snippet on a machine the user is not pasting into — and the confirmation then claimed the copy was done. Detect an SSH session from the environment sshd sets and go straight to the terminal, which is the end the user is actually at. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- cmd/setup/commands.go | 52 ++++++++ cmd/setup/copy_test.go | 279 +++++++++++++++++++++++++++++++++++++++++ cmd/setup/model.go | 32 ++++- cmd/setup/update.go | 16 +++ cmd/setup/view.go | 22 +++- go.mod | 4 +- 6 files changed, 399 insertions(+), 6 deletions(-) create mode 100644 cmd/setup/copy_test.go diff --git a/cmd/setup/commands.go b/cmd/setup/commands.go index 470600cbc..1e97ea2eb 100644 --- a/cmd/setup/commands.go +++ b/cmd/setup/commands.go @@ -1,10 +1,12 @@ package setup import ( + "fmt" "os" "strings" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" "github.com/launchdarkly/ldcli/internal/setup" ) @@ -122,3 +124,53 @@ func (m wizardModel) runVerify() tea.Cmd { return verifyDoneMsg{result: result} } } + +// copyableContent returns the code the current screen is asking the user to copy, +// along with the word the hint uses for it. A screen can show both an install command +// and a snippet; the snippet is the one that has to be pasted verbatim, so it wins. +// Returns false when the screen has nothing to copy. +func (m wizardModel) copyableContent() (content, label string, ok bool) { + if m.step != stepDone { + return "", "", false + } + if m.initResult != nil && !m.initResult.Success && m.initResult.Snippet != "" { + return m.initResult.Snippet, "snippet", true + } + if m.installResult != nil && m.installResult.Failed && m.installResult.Command != "" { + return m.installResult.Command, "command", true + } + return "", "", false +} + +// copyToClipboard puts the content on the clipboard, preferring the operating +// system's own clipboard because it works in every terminal and reports whether it +// succeeded. OSC 52 is the fallback: it asks the terminal to do the copying, which is +// what works over SSH, where the OS clipboard belongs to the wrong machine. Not every +// terminal implements OSC 52 and support cannot be queried, so a copy that goes that +// route is reported as a request rather than a result. +func (m wizardModel) copyToClipboard(content string) tea.Cmd { + return func() tea.Msg { + // Over SSH the OS clipboard is the one on the machine running the code, not + // the one the user pastes into, and it can succeed there — so a remote + // session has to go to the terminal even though the local path would work. + if !m.remoteSession { + if err := m.nativeCopy(content); err == nil { + return copiedMsg{viaTerminal: false} + } + } + fmt.Fprint(m.clipboard, ansi.SetSystemClipboard(content)) + return copiedMsg{viaTerminal: true} + } +} + +// isRemoteSession reports whether the CLI is running over SSH. sshd sets these for +// the session it owns, so they distinguish "the clipboard here is the user's" from +// "the user's clipboard is on the other end of the connection". +func isRemoteSession() bool { + for _, name := range []string{"SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"} { + if os.Getenv(name) != "" { + return true + } + } + return false +} diff --git a/cmd/setup/copy_test.go b/cmd/setup/copy_test.go new file mode 100644 index 000000000..e93621e66 --- /dev/null +++ b/cmd/setup/copy_test.go @@ -0,0 +1,279 @@ +package setup + +import ( + "bytes" + "encoding/base64" + "errors" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/setup" +) + +const snippet = "const LaunchDarkly = require('@launchdarkly/node-server-sdk');\nconst ldClient = LaunchDarkly.init('sdk-key');" + +// copyKey sends "c" with a working OS clipboard, and returns the updated model +// alongside what each path received. +func copyKey(t *testing.T, m wizardModel) (wizardModel, string) { + t.Helper() + var native string + updated, terminal := copyKeyWith(t, m, func(s string) error { + native = s + return nil + }) + return updated, native + terminal +} + +// copyKeyWith sends "c" with the given OS clipboard behaviour, and returns the +// updated model and whatever was written to the terminal as an OSC 52 sequence. +func copyKeyWith(t *testing.T, m wizardModel, native func(string) error) (wizardModel, string) { + t.Helper() + var out bytes.Buffer + m.clipboard = &out + m.nativeCopy = native + + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) + m = next.(wizardModel) + if cmd != nil { + if msg := cmd(); msg != nil { + next, _ = m.Update(msg) + m = next.(wizardModel) + } + } + return m, out.String() +} + +// The snippet has to arrive on the clipboard exactly as the user needs to paste it: +// the gutter bar the code block is drawn with, and the padding lipgloss adds to square +// it off, are display only and must not be copied. +func TestWizard_CopySnippet_CopiesRawContent(t *testing.T) { + m := wizardModel{ + step: stepDone, + width: 80, + initResult: &setup.InitResult{ + SDKID: "go-server-sdk", + FilePath: "/proj/main.go", + Snippet: snippet, + Success: false, + }, + } + + // The rendered block carries the decoration the raw copy must not. + require.Contains(t, m.View(), "│", "the code block is drawn with a gutter bar") + + var native string + updated, _ := copyKeyWith(t, m, func(s string) error { + native = s + return nil + }) + + assert.Equal(t, snippet, native) + assert.Equal(t, copyDone, updated.copyState) + assert.NotContains(t, native, "│", "the gutter bar must not be copied") + assert.NotContains(t, native, " \n", "trailing padding must not be copied") +} + +// A screen can show both an install command and a snippet. The snippet is the one +// that has to be pasted verbatim, so that is what c copies. +func TestWizard_CopySnippet_PrefersSnippetOverInstallCommand(t *testing.T) { + m := wizardModel{ + step: stepDone, + width: 80, + installResult: &setup.InstallResult{ + Command: "npm install @launchdarkly/node-server-sdk", + Failed: true, + }, + initResult: &setup.InitResult{ + SDKID: "node-server", + FilePath: "/proj/index.js", + Snippet: snippet, + Success: false, + }, + } + + _, copied := copyKey(t, m) + assert.Equal(t, snippet, copied) + assert.Contains(t, m.View(), "Press c to copy the snippet.") +} + +// With no snippet to paste, the thing the user still has to carry out of the wizard +// is the install command. +func TestWizard_CopySnippet_FallsBackToInstallCommand(t *testing.T) { + m := wizardModel{ + step: stepDone, + width: 80, + installResult: &setup.InstallResult{ + Command: "npm install @launchdarkly/node-server-sdk", + Failed: true, + }, + initResult: &setup.InitResult{SDKID: "node-server", FilePath: "/proj/index.js", Success: true}, + } + + _, copied := copyKey(t, m) + assert.Equal(t, "npm install @launchdarkly/node-server-sdk", copied) + assert.Contains(t, m.View(), "Press c to copy the command.") +} + +// Offering a copy on a screen with nothing to copy, or writing to the terminal on a +// key the screen does not handle, would both be wrong. +func TestWizard_CopySnippet_NothingToCopy(t *testing.T) { + tests := []struct { + name string + m wizardModel + }{ + { + name: "verification succeeded, no manual step left", + m: wizardModel{ + step: stepDone, + width: 80, + initResult: &setup.InitResult{SDKID: "node-server", Success: true}, + verifyResult: &setup.VerifyResult{Active: true}, + detectResult: &setup.DetectResult{SDKID: "node-server"}, + }, + }, + { + name: "mid-flow screen shows no code", + m: wizardModel{step: stepSelectSDK, width: 80}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + updated, written := copyKey(t, tt.m) + + assert.Empty(t, written, "must not copy anything with nothing to copy") + assert.Equal(t, copyNone, updated.copyState) + assert.NotContains(t, tt.m.View(), "Press c to copy") + }) + } +} + +// The hint has to confirm the copy, otherwise the user has no way to tell whether the +// key did anything. +func TestWizard_CopySnippet_HintConfirmsAfterCopying(t *testing.T) { + m := wizardModel{ + step: stepDone, + width: 80, + initResult: &setup.InitResult{ + SDKID: "go-server-sdk", + FilePath: "/proj/main.go", + Snippet: snippet, + Success: false, + }, + } + + assert.Contains(t, m.View(), "Press c to copy the snippet.") + + updated, _ := copyKey(t, m) + view := updated.View() + assert.Contains(t, view, "Copied the snippet to your clipboard.") + assert.NotContains(t, view, "Press c to copy") +} + +// 'c' is a legal character in a filter query, so the list has to keep receiving it. +func TestWizard_CopySnippet_DoesNotStealCFromFiltering(t *testing.T) { + m := wizardModel{step: stepDetect, width: 80, height: 30} + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{ + SDKID: "node-server", + Language: "JavaScript", + }}) + m2 := next.(wizardModel) + m2.sdkFocus = 1 + + // Open the list filter, then type "c". + filtering, _ := m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + m3 := filtering.(wizardModel) + require.True(t, m3.isFiltering(), "expected the SDK list to be filtering") + + typed, written := copyKey(t, m3) + assert.Empty(t, written, "c must reach the filter, not the clipboard") + assert.Equal(t, copyNone, typed.copyState) +} + +// Over SSH the OS clipboard belongs to the wrong machine, so a failure there falls +// back to asking the terminal. That path cannot be confirmed, so the hint must not +// claim the content is on the clipboard. +func TestWizard_CopySnippet_FallsBackToTerminalWhenOSClipboardFails(t *testing.T) { + m := wizardModel{ + step: stepDone, + width: 80, + initResult: &setup.InitResult{ + SDKID: "go-server-sdk", + FilePath: "/proj/main.go", + Snippet: snippet, + Success: false, + }, + } + + updated, written := copyKeyWith(t, m, func(string) error { + return errors.New("no clipboard on this machine") + }) + + require.NotEmpty(t, written, "a failed OS copy must fall back to OSC 52") + assert.Equal(t, "\x1b]52;c;"+base64.StdEncoding.EncodeToString([]byte(snippet))+"\x07", written) + assert.Equal(t, snippet, decodeOSC52(t, written)) + assert.Equal(t, copyRequested, updated.copyState) + + view := updated.View() + assert.Contains(t, view, "Asked your terminal to copy the snippet.") + assert.NotContains(t, view, "Copied the snippet to your clipboard.", + "OSC 52 support cannot be detected, so the copy must not be claimed as done") +} + +// A remote host can have a perfectly working clipboard — a Mac with pbcopy, a Linux +// box with a display — and writing to it still puts the snippet on the wrong machine. +// Success there is not evidence the user can paste, so it must not be preferred or +// reported as done. +func TestWizard_CopySnippet_RemoteSessionSkipsTheOSClipboard(t *testing.T) { + m := wizardModel{ + step: stepDone, + width: 80, + remoteSession: true, + initResult: &setup.InitResult{ + SDKID: "go-server-sdk", + FilePath: "/proj/main.go", + Snippet: snippet, + Success: false, + }, + } + + nativeCalled := false + updated, written := copyKeyWith(t, m, func(string) error { + nativeCalled = true + return nil // the remote clipboard would accept it + }) + + assert.False(t, nativeCalled, "must not write to the clipboard of the remote machine") + assert.Equal(t, snippet, decodeOSC52(t, written)) + assert.Equal(t, copyRequested, updated.copyState) + assert.Contains(t, updated.View(), "Asked your terminal to copy the snippet.") +} + +// The environment sshd sets for its session is what separates the two cases. +func TestIsRemoteSession(t *testing.T) { + for _, name := range []string{"SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"} { + t.Run(name, func(t *testing.T) { + t.Setenv(name, "10.0.0.1 51234 10.0.0.2 22") + assert.True(t, isRemoteSession()) + }) + } + + t.Run("no ssh variables", func(t *testing.T) { + t.Setenv("SSH_CONNECTION", "") + t.Setenv("SSH_CLIENT", "") + t.Setenv("SSH_TTY", "") + assert.False(t, isRemoteSession()) + }) +} + +func decodeOSC52(t *testing.T, seq string) string { + t.Helper() + require.True(t, len(seq) > len("\x1b]52;c;")+1, "not an OSC 52 sequence: %q", seq) + payload := seq[len("\x1b]52;c;") : len(seq)-1] + decoded, err := base64.StdEncoding.DecodeString(payload) + require.NoError(t, err) + return string(decoded) +} diff --git a/cmd/setup/model.go b/cmd/setup/model.go index 48e02fc28..318545430 100644 --- a/cmd/setup/model.go +++ b/cmd/setup/model.go @@ -1,6 +1,10 @@ package setup import ( + "io" + "os" + + "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" @@ -13,6 +17,16 @@ import ( "github.com/launchdarkly/ldcli/internal/setup" ) +// copyState records how the visible snippet was copied, so the view can confirm a +// clipboard write outright but only claim to have asked when the terminal did it. +type copyState int + +const ( + copyNone copyState = iota + copyDone // written to the OS clipboard + copyRequested // handed to the terminal over OSC 52, which cannot confirm +) + type wizardStep int const ( @@ -70,6 +84,16 @@ type wizardModel struct { initResult *setup.InitResult verifyResult *setup.VerifyResult + // nativeCopy puts content on the operating system's clipboard, and clipboard + // receives the OSC 52 sequence used when that is not available. Both are fields + // so tests can drive either path without a real clipboard or terminal. + nativeCopy func(string) error + clipboard io.Writer + copyState copyState + // remoteSession suppresses the OS clipboard, because over SSH it is not the one + // the user pastes into even when writing to it succeeds. + remoteSession bool + quitting bool } @@ -119,6 +143,7 @@ type detectFailedMsg struct{} type installDoneMsg struct{ result *setup.InstallResult } type flagCreatedMsg struct{ key string } type initDoneMsg struct{ result *setup.InitResult } +type copiedMsg struct{ viaTerminal bool } type verifyDoneMsg struct{ result *setup.VerifyResult } type wizardErrMsg struct{ err error } @@ -143,8 +168,11 @@ func runSetupWizard( AccessToken: viper.GetString(cliflags.AccessTokenFlag), BaseURI: viper.GetString(cliflags.BaseURIFlag), }, - step: stepSelectProject, - spinner: s, + step: stepSelectProject, + spinner: s, + clipboard: os.Stdout, + nativeCopy: clipboard.WriteAll, + remoteSession: isRemoteSession(), } p := tea.NewProgram(m, tea.WithAltScreen()) diff --git a/cmd/setup/update.go b/cmd/setup/update.go index 0aa90d5a5..79cf18f8e 100644 --- a/cmd/setup/update.go +++ b/cmd/setup/update.go @@ -34,10 +34,26 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break // let the list receive the key as filter input } return m.handleBack() + case "c": + if m.isFiltering() { + break // let the list receive the key as filter input + } + content, _, ok := m.copyableContent() + if !ok { + break + } + return m, m.copyToClipboard(content) case "enter": return m.handleEnter() } + case copiedMsg: + m.copyState = copyDone + if msg.viaTerminal { + m.copyState = copyRequested + } + return m, nil + case projectsFetchedMsg: m.projects = msg.projects items := make([]list.Item, len(msg.projects)) diff --git a/cmd/setup/view.go b/cmd/setup/view.go index b939d1812..dc6f83449 100644 --- a/cmd/setup/view.go +++ b/cmd/setup/view.go @@ -11,6 +11,23 @@ import ( var quitHint = "\n" + mutedStyle.Render("Press q to quit.") + "\n" +// copyHint labels the copy action next to a code block, or confirms the copy once +// it has happened. The block is drawn with a left gutter bar and the wizard owns the +// alternate screen, so selecting the code by hand picks up the gutter characters. +func (m wizardModel) copyHint() string { + _, label, ok := m.copyableContent() + if !ok { + return "" + } + switch m.copyState { + case copyDone: + return mutedStyle.Render(fmt.Sprintf("Copied the %s to your clipboard.", label)) + "\n" + case copyRequested: + return mutedStyle.Render(fmt.Sprintf("Asked your terminal to copy the %s.", label)) + "\n" + } + return mutedStyle.Render(fmt.Sprintf("Press c to copy the %s.", label)) + "\n" +} + func (m wizardModel) View() string { if m.quitting { return "" @@ -75,7 +92,7 @@ func (m wizardModel) View() string { "\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 + return body + "\n" + m.copyHint() + quitHint } if m.initResult != nil && !m.initResult.Success { body := titleStyle.Render("Manual SDK setup required") + "\n\n" @@ -88,7 +105,8 @@ func (m wizardModel) View() string { 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" + + "Once you've initialized the SDK manually, your flag will be ready to use.\n\n" + + m.copyHint() + quitHint } if m.verifyResult != nil && m.verifyResult.Active && m.detectResult != nil { diff --git a/go.mod b/go.mod index 468347667..8ca60f0cf 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,12 @@ go 1.24.3 require ( github.com/adrg/xdg v0.5.3 + github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/charmbracelet/x/ansi v0.9.3 github.com/getkin/kin-openapi v0.135.0 github.com/google/uuid v1.6.0 github.com/gorilla/handlers v1.5.2 @@ -38,11 +40,9 @@ require ( require ( github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.1 // indirect From 718053d2de92207c83b9764f722dd182f1d33f34 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Wed, 5 Aug 2026 17:44:27 -0400 Subject: [PATCH 3/8] fix(setup): say why an automatic install did not run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plaintext `setup install` output only reported `Success: false`, and the wizard's manual-install screen printed an empty code block whenever the installer declined to run without erroring, so neither told the user what to do next. Print the installer's FailureReason, explain SDKs that have no automated command at all, and lead the wizard screen with the reason — offering a command only when one exists, since the reason already carries the right command when the installer declined up front. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/setup/install.go | 14 ++++++++--- cmd/setup/setup_test.go | 52 ++++++++++++++++++++++++++++++++++++++++ cmd/setup/view.go | 10 ++++++-- cmd/setup/wizard_test.go | 42 ++++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 5 deletions(-) diff --git a/cmd/setup/install.go b/cmd/setup/install.go index 455ec7169..866843225 100644 --- a/cmd/setup/install.go +++ b/cmd/setup/install.go @@ -87,11 +87,19 @@ func runInstall(svc setup.Service) func(*cobra.Command, []string) error { fmt.Fprintln(cmd.OutOrStdout(), "Already installed — skipping install.") return nil } - fmt.Fprintf(cmd.OutOrStdout(), "Command: %s\n", result.Command) + if result.Command != "" { + 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 + } + fmt.Fprintf(cmd.OutOrStdout(), "Success: %t\n", result.Success) + switch { + case result.FailureReason != "": + fmt.Fprintf(cmd.OutOrStdout(), "Reason: %s\n", result.FailureReason) + case !result.Success && setup.RequiresManualInstall(result.SDKID): + fmt.Fprintf(cmd.OutOrStdout(), "Reason: %s has no automated install command; add %s to your build configuration by hand.\n", result.SDKID, result.Package) } return nil diff --git a/cmd/setup/setup_test.go b/cmd/setup/setup_test.go index 2d7e4cbe6..17130c2f6 100644 --- a/cmd/setup/setup_test.go +++ b/cmd/setup/setup_test.go @@ -262,6 +262,58 @@ func TestInstall_Plaintext_WithVersion(t *testing.T) { assert.Contains(t, string(output), "@launchdarkly/node-server-sdk@9.7.0") } +func TestInstall_Plaintext_PrintsFailureReason(t *testing.T) { + args := []string{ + "setup", "install", + "--access-token", "test-token", + "--sdk-id", "dotnet-server-sdk", + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: &resources.MockClient{}, + Installer: mockInstaller{result: &setup.InstallResult{ + SDKID: "dotnet-server-sdk", + Package: "LaunchDarkly.ServerSdk", + Failed: true, + FailureReason: "no .csproj found; rerun with --project", + }}, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), "Success: false") + assert.Contains(t, string(output), "no .csproj found; rerun with --project") + assert.NotContains(t, string(output), "Command: \n") +} + +func TestInstall_Plaintext_ExplainsManualInstall(t *testing.T) { + args := []string{ + "setup", "install", + "--access-token", "test-token", + "--sdk-id", "java-server-sdk", + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: &resources.MockClient{}, + Installer: mockInstaller{result: &setup.InstallResult{ + SDKID: "java-server-sdk", + Package: "com.launchdarkly:launchdarkly-java-server-sdk", + Success: false, + }}, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), "Success: false") + assert.Contains(t, string(output), "no automated install command") +} + func TestInstall_DryRun(t *testing.T) { args := []string{ "setup", "install", diff --git a/cmd/setup/view.go b/cmd/setup/view.go index dc6f83449..c6faa2580 100644 --- a/cmd/setup/view.go +++ b/cmd/setup/view.go @@ -80,11 +80,17 @@ func (m wizardModel) View() string { 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" + m.wrap("The SDK couldn't be installed automatically.") + "\n\n" if m.installResult.FailureReason != "" { body += m.wrap("Reason: "+m.installResult.FailureReason) + "\n\n" } + // The installer only supplies a command when one exists and failed to + // run. When it declines up front, its reason above carries the command + // to use instead, so don't contradict it with a broken one. + if m.installResult.Command != "" { + body += m.wrap("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 != "" { diff --git a/cmd/setup/wizard_test.go b/cmd/setup/wizard_test.go index 2b1a885cf..ea8ce782a 100644 --- a/cmd/setup/wizard_test.go +++ b/cmd/setup/wizard_test.go @@ -467,3 +467,45 @@ func TestWizard_OverrideSDK_DefaultEntryPointMissing(t *testing.T) { assert.False(t, m.detectResult.EntryPointExists) assert.Contains(t, m.View(), "no entry file found") } + +func TestWizard_Done_DeclinedInstall_ShowsReasonWithoutCommand(t *testing.T) { + m := wizardModel{ + step: stepDone, + width: 78, + selectedProject: "default", + flagKey: "my-new-flag", + installResult: &setup.InstallResult{ + SDKID: "dotnet-server-sdk", + Package: "LaunchDarkly.ServerSdk", + Failed: true, + FailureReason: "found 2 projects in this solution", + }, + } + + v := m.View() + assert.Contains(t, v, "Manual install needed") + assert.Contains(t, v, "found 2 projects in this solution") + // No command to offer, so the screen must not render an empty code block or + // promise one. + assert.NotContains(t, v, "Install it yourself with") +} + +func TestWizard_Done_FailedInstall_ShowsCommand(t *testing.T) { + m := wizardModel{ + step: stepDone, + width: 78, + selectedProject: "default", + flagKey: "my-new-flag", + installResult: &setup.InstallResult{ + SDKID: "ruby-server-sdk", + Command: "gem install launchdarkly-server-sdk", + Failed: true, + FailureReason: "permission denied", + }, + } + + v := m.View() + assert.Contains(t, v, "Install it yourself with") + assert.Contains(t, v, "gem install launchdarkly-server-sdk") + assert.Contains(t, v, "permission denied") +} From 3b8c231b4ff518e7aadb230851872f0e07d5a5a4 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Wed, 5 Aug 2026 18:00:53 -0400 Subject: [PATCH 4/8] fix(setup): page through lists and handle empty ones Four review findings in the wizard: Projects and environments were read from the first API page only, hiding everything past the first 20 from accounts and projects larger than that. Both list calls now take a limit and offset and page until a short page. An empty list was indistinguishable from a list still loading, so a token scoped to no projects parked on the spinner with no way out. Fetch completion is now tracked separately from item count, with an empty state for each. Terminal resizes never reached the lists, which were sized once at construction. WindowSizeMsg now pushes the new size into every list that has been built; SetSize panics on a zero-value list.Model, so the guards matter. List height is clamped so it cannot go negative before the first size message. OSC 52 was written to stdout, which Bubble Tea owns for frame rendering while the wizard runs, so the sequence could land mid-frame on exactly the SSH path that depends on it. It now goes to stderr, or to the controlling terminal when stderr is not one. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/setup/commands.go | 19 +++++++ cmd/setup/model.go | 14 +++-- cmd/setup/update.go | 20 ++++++- cmd/setup/view.go | 25 +++++++- cmd/setup/wizard_test.go | 48 ++++++++++++++++ internal/environments/client.go | 13 ++++- internal/environments/mock_client.go | 4 +- internal/projects/mock.go | 4 +- internal/projects/projects.go | 14 ++++- internal/setup/service.go | 85 ++++++++++++++++------------ internal/setup/service_test.go | 45 ++++++++++++++- 11 files changed, 238 insertions(+), 53 deletions(-) diff --git a/cmd/setup/commands.go b/cmd/setup/commands.go index 1e97ea2eb..acba81490 100644 --- a/cmd/setup/commands.go +++ b/cmd/setup/commands.go @@ -2,11 +2,13 @@ package setup import ( "fmt" + "io" "os" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" + "golang.org/x/term" "github.com/launchdarkly/ldcli/internal/setup" ) @@ -163,6 +165,23 @@ func (m wizardModel) copyToClipboard(content string) tea.Cmd { } } +// terminalWriter returns the writer to send OSC 52 to. It must not be stdout: +// Bubble Tea owns stdout for frame rendering while the wizard runs, so a +// sequence written there from a command goroutine can land in the middle of a +// frame. Stderr is preferred because it reaches the same terminal without that +// contention, but it may be redirected to a file or pipe, in which case the +// sequence would be swallowed instead of reaching the terminal — so fall back to +// the controlling terminal itself. +func terminalWriter() io.Writer { + if term.IsTerminal(int(os.Stderr.Fd())) { + return os.Stderr + } + if tty, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0); err == nil { + return tty + } + return os.Stderr +} + // isRemoteSession reports whether the CLI is running over SSH. sshd sets these for // the session it owns, so they distinguish "the clipboard here is the user's" from // "the user's clipboard is on the other end of the connection". diff --git a/cmd/setup/model.go b/cmd/setup/model.go index 318545430..cf7bb5c0c 100644 --- a/cmd/setup/model.go +++ b/cmd/setup/model.go @@ -2,7 +2,6 @@ package setup import ( "io" - "os" "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/list" @@ -57,8 +56,15 @@ type wizardModel struct { // data gathered through the flow projects []projectItem environments []envItem - projectList list.Model - envList list.Model + // projectsLoaded and envsLoaded record that a fetch came back, so a list that + // is legitimately empty is not mistaken for one that is still loading. + projectsLoaded bool + envsLoaded bool + projectList list.Model + envList list.Model + // sdkListBuilt records that sdkList was constructed, since SetSize panics on a + // zero-value list.Model. + sdkListBuilt bool sdkList list.Model selectedProject string @@ -170,7 +176,7 @@ func runSetupWizard( }, step: stepSelectProject, spinner: s, - clipboard: os.Stdout, + clipboard: terminalWriter(), nativeCopy: clipboard.WriteAll, remoteSession: isRemoteSession(), } diff --git a/cmd/setup/update.go b/cmd/setup/update.go index 79cf18f8e..c3bb62de7 100644 --- a/cmd/setup/update.go +++ b/cmd/setup/update.go @@ -17,6 +17,18 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + // The lists are built when their data arrives, which can be before or + // after this message, so push the new size into whichever already exist. + // SetSize panics on a zero-value list.Model, hence the built guards. + if m.projectsLoaded { + m.projectList.SetSize(m.width, m.listHeight()) + } + if m.envsLoaded { + m.envList.SetSize(m.width, m.listHeight()) + } + if m.sdkListBuilt { + m.sdkList.SetSize(m.sdkBoxWidth()-2, m.sdkList.Height()) + } case tea.KeyMsg: switch msg.String() { @@ -56,24 +68,26 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case projectsFetchedMsg: m.projects = msg.projects + m.projectsLoaded = true 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 = list.New(items, delegate, m.width, m.listHeight()) m.projectList.Title = "Select a project:" m.projectList.SetShowStatusBar(false) return m, nil case envsFetchedMsg: m.environments = msg.environments + m.envsLoaded = true 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 = list.New(items, delegate, m.width, m.listHeight()) m.envList.Title = "Select an environment:" m.envList.SetShowStatusBar(false) return m, nil @@ -208,6 +222,7 @@ func (m *wizardModel) enterSDKStep() { m.detectedSDK = &det m.sdkFocus = 0 m.sdkList = m.newSDKList(sdkItemsExcept(det.id), "Other SDKs:", false) + m.sdkListBuilt = true m.step = stepSelectSDK return } @@ -215,6 +230,7 @@ func (m *wizardModel) enterSDKStep() { m.detectedSDK = nil m.sdkFocus = 1 m.sdkList = m.newSDKList(sdkItemsExcept(""), "Select your SDK:", true) + m.sdkListBuilt = true m.step = stepSelectSDK } diff --git a/cmd/setup/view.go b/cmd/setup/view.go index c6faa2580..0dcdb813f 100644 --- a/cmd/setup/view.go +++ b/cmd/setup/view.go @@ -39,15 +39,25 @@ func (m wizardModel) View() string { switch m.step { case stepSelectProject: - if len(m.projects) == 0 { + if !m.projectsLoaded { return m.spinner.View() + " Loading projects..." } + if len(m.projects) == 0 { + return titleStyle.Render("No projects available") + "\n\n" + + m.wrap("This access token can't see any projects. Create a project in LaunchDarkly, or use a token with access to one, then run this command again.") + "\n" + + quitHint + } return m.projectList.View() + "\n" + mutedStyle.Render("esc quit") case stepSelectEnvironment: - if len(m.environments) == 0 { + if !m.envsLoaded { return m.spinner.View() + " Loading environments..." } + if len(m.environments) == 0 { + return titleStyle.Render("No environments available") + "\n\n" + + m.wrap(fmt.Sprintf("Project %q has no environments this access token can see. Press ← to pick another project.", m.selectedProject)) + "\n" + + mutedStyle.Render("← back · q quit") + "\n" + } return m.envList.View() + "\n" + mutedStyle.Render("← back · esc quit") case stepDetect: @@ -167,6 +177,17 @@ func (m wizardModel) sdkBoxWidth() int { return w } +// listHeight is the height available to a full-screen list. It never returns a +// value below a usable minimum, because a WindowSizeMsg may not have arrived yet +// and m.height-4 would then be negative. +func (m wizardModel) listHeight() int { + h := m.height - 4 + if h < 3 { + h = 3 + } + return h +} + // 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 { diff --git a/cmd/setup/wizard_test.go b/cmd/setup/wizard_test.go index ea8ce782a..18eb22e0d 100644 --- a/cmd/setup/wizard_test.go +++ b/cmd/setup/wizard_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -509,3 +510,50 @@ func TestWizard_Done_FailedInstall_ShowsCommand(t *testing.T) { assert.Contains(t, v, "gem install launchdarkly-server-sdk") assert.Contains(t, v, "permission denied") } + +func TestWizard_NoProjects_ShowsEmptyStateNotSpinner(t *testing.T) { + m := wizardModel{step: stepSelectProject, width: 78, height: 24, spinner: spinner.New()} + + // Before the fetch lands, the spinner is right. + assert.Contains(t, m.View(), "Loading projects") + + updated, _ := m.Update(projectsFetchedMsg{projects: nil}) + v := updated.(wizardModel).View() + + assert.NotContains(t, v, "Loading projects") + assert.Contains(t, v, "No projects available") +} + +func TestWizard_NoEnvironments_ShowsEmptyStateNotSpinner(t *testing.T) { + m := wizardModel{step: stepSelectEnvironment, width: 78, height: 24, spinner: spinner.New(), selectedProject: "my-proj"} + + assert.Contains(t, m.View(), "Loading environments") + + updated, _ := m.Update(envsFetchedMsg{environments: nil}) + v := updated.(wizardModel).View() + + assert.NotContains(t, v, "Loading environments") + assert.Contains(t, v, "No environments available") + assert.Contains(t, v, "my-proj") +} + +func TestWizard_WindowSize_ResizesExistingLists(t *testing.T) { + m := wizardModel{step: stepSelectProject, width: 40, height: 10, spinner: spinner.New()} + withList, _ := m.Update(projectsFetchedMsg{projects: []projectItem{{key: "p1", name: "One"}}}) + + resized, _ := withList.(wizardModel).Update(tea.WindowSizeMsg{Width: 120, Height: 50}) + got := resized.(wizardModel) + + assert.Equal(t, 120, got.projectList.Width()) + assert.Equal(t, got.listHeight(), got.projectList.Height()) +} + +func TestWizard_ListHeight_NeverNegativeBeforeWindowSize(t *testing.T) { + // No WindowSizeMsg yet, so height is still zero and height-4 would be negative. + m := wizardModel{step: stepSelectProject, spinner: spinner.New()} + + assert.GreaterOrEqual(t, m.listHeight(), 3) + + withList, _ := m.Update(projectsFetchedMsg{projects: []projectItem{{key: "p1", name: "One"}}}) + assert.GreaterOrEqual(t, withList.(wizardModel).projectList.Height(), 3) +} diff --git a/internal/environments/client.go b/internal/environments/client.go index 46add672d..734c0a9b1 100644 --- a/internal/environments/client.go +++ b/internal/environments/client.go @@ -10,7 +10,7 @@ import ( type Client interface { Get(ctx context.Context, accessToken, baseURI, key, projKey string) ([]byte, error) - List(ctx context.Context, accessToken, baseURI, projKey string) ([]byte, error) + List(ctx context.Context, accessToken, baseURI, projKey string, limit, offset int64) ([]byte, error) } type EnvironmentsClient struct { @@ -53,9 +53,18 @@ func (c EnvironmentsClient) List( accessToken, baseURI, projectKey string, + limit, + offset int64, ) ([]byte, error) { client := client.New(accessToken, baseURI, c.cliVersion) - environments, _, err := client.EnvironmentsApi.GetEnvironmentsByProject(ctx, projectKey).Execute() + req := client.EnvironmentsApi.GetEnvironmentsByProject(ctx, projectKey) + if limit > 0 { + req = req.Limit(limit) + } + if offset > 0 { + req = req.Offset(offset) + } + environments, _, err := req.Execute() if err != nil { return nil, errors.NewLDAPIError(err) diff --git a/internal/environments/mock_client.go b/internal/environments/mock_client.go index e6da5bef3..f4862e6ce 100644 --- a/internal/environments/mock_client.go +++ b/internal/environments/mock_client.go @@ -29,8 +29,10 @@ func (c *MockClient) List( accessToken, baseURI, projKey string, + limit, + offset int64, ) ([]byte, error) { - args := c.Called(accessToken, baseURI, projKey) + args := c.Called(accessToken, baseURI, projKey, limit, offset) return args.Get(0).([]byte), args.Error(1) } diff --git a/internal/projects/mock.go b/internal/projects/mock.go index c8fa59a51..c9452ec88 100644 --- a/internal/projects/mock.go +++ b/internal/projects/mock.go @@ -28,8 +28,10 @@ func (c *MockClient) List( ctx context.Context, accessToken, baseURI string, + limit, + offset int64, ) ([]byte, error) { - args := c.Called(accessToken, baseURI) + args := c.Called(accessToken, baseURI, limit, offset) return args.Get(0).([]byte), args.Error(1) } diff --git a/internal/projects/projects.go b/internal/projects/projects.go index d4e4151b2..8a192a457 100644 --- a/internal/projects/projects.go +++ b/internal/projects/projects.go @@ -12,7 +12,7 @@ import ( type Client interface { Create(ctx context.Context, accessToken, baseURI, name, key string) ([]byte, error) - List(ctx context.Context, accessToken, baseURI string) ([]byte, error) + List(ctx context.Context, accessToken, baseURI string, limit, offset int64) ([]byte, error) } type ProjectsClient struct { @@ -52,10 +52,18 @@ func (c ProjectsClient) List( ctx context.Context, accessToken, baseURI string, + limit, + offset int64, ) ([]byte, error) { client := client.New(accessToken, baseURI, c.cliVersion) - projects, _, err := client.ProjectsApi. - GetProjects(ctx).Execute() + req := client.ProjectsApi.GetProjects(ctx) + if limit > 0 { + req = req.Limit(limit) + } + if offset > 0 { + req = req.Offset(offset) + } + projects, _, err := req.Execute() if err != nil { return nil, errors.NewLDAPIError(err) } diff --git a/internal/setup/service.go b/internal/setup/service.go index d1636caa7..5f381e030 100644 --- a/internal/setup/service.go +++ b/internal/setup/service.go @@ -56,52 +56,65 @@ type EnvKeys struct { MobileKey string } -// ListProjects returns the account's projects. +// listPageSize is how many items each list request asks for. The wizard needs +// every project and environment, so the requests page through until a short page +// says there are no more. +const listPageSize = 100 + +// keyedItems is the shape both list endpoints return. +type keyedItems struct { + Items []struct { + Key string `json:"key"` + Name string `json:"name"` + } `json:"items"` +} + +// ListProjects returns the account's projects, following pagination so accounts +// with more projects than a single page are listed in full. func (s Service) ListProjects(a Auth) ([]ProjectSummary, error) { - res, err := s.Clients.Projects.List(context.Background(), a.AccessToken, a.BaseURI) - if err != nil { - return nil, err - } + var projects []ProjectSummary + for offset := int64(0); ; offset += listPageSize { + res, err := s.Clients.Projects.List(context.Background(), a.AccessToken, a.BaseURI, listPageSize, offset) + if err != nil { + return nil, 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 nil, fmt.Errorf("parsing projects: %w", err) - } + var resp keyedItems + if err := json.Unmarshal(res, &resp); err != nil { + return nil, fmt.Errorf("parsing projects: %w", err) + } - projects := make([]ProjectSummary, len(resp.Items)) - for i, item := range resp.Items { - projects[i] = ProjectSummary{Key: item.Key, Name: item.Name} + for _, item := range resp.Items { + projects = append(projects, ProjectSummary{Key: item.Key, Name: item.Name}) + } + if len(resp.Items) < listPageSize { + return projects, nil + } } - return projects, nil } -// ListEnvironments returns the environments in a project. +// ListEnvironments returns the environments in a project, following pagination so +// projects with more environments than a single page are listed in full. func (s Service) ListEnvironments(a Auth, projectKey string) ([]EnvSummary, error) { - res, err := s.Clients.Environments.List(context.Background(), a.AccessToken, a.BaseURI, projectKey) - if err != nil { - return nil, err - } + var envs []EnvSummary + for offset := int64(0); ; offset += listPageSize { + res, err := s.Clients.Environments.List(context.Background(), a.AccessToken, a.BaseURI, projectKey, listPageSize, offset) + if err != nil { + return nil, 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 nil, fmt.Errorf("parsing environments: %w", err) - } + var resp keyedItems + if err := json.Unmarshal(res, &resp); err != nil { + return nil, fmt.Errorf("parsing environments: %w", err) + } - envs := make([]EnvSummary, len(resp.Items)) - for i, item := range resp.Items { - envs[i] = EnvSummary{Key: item.Key, Name: item.Name} + for _, item := range resp.Items { + envs = append(envs, EnvSummary{Key: item.Key, Name: item.Name}) + } + if len(resp.Items) < listPageSize { + return envs, nil + } } - return envs, nil } // EnvKeys returns the SDK credentials for an environment. diff --git a/internal/setup/service_test.go b/internal/setup/service_test.go index 7bc8ed17a..f5ec648eb 100644 --- a/internal/setup/service_test.go +++ b/internal/setup/service_test.go @@ -1,7 +1,9 @@ package setup import ( + "fmt" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -36,7 +38,7 @@ func (f fakeInstaller) Install(string, *DetectResult) (*InstallResult, error) { func TestService_ListProjects(t *testing.T) { mockProjects := &projects.MockClient{} - mockProjects.On("List", testAuth.AccessToken, testAuth.BaseURI). + mockProjects.On("List", testAuth.AccessToken, testAuth.BaseURI, int64(listPageSize), int64(0)). Return([]byte(`{"items":[{"key":"p1","name":"Project One"},{"key":"p2","name":"Project Two"}]}`), nil) svc := Service{Clients: Clients{Projects: mockProjects}} @@ -48,7 +50,7 @@ func TestService_ListProjects(t *testing.T) { func TestService_ListEnvironments(t *testing.T) { mockEnvs := &environments.MockClient{} - mockEnvs.On("List", testAuth.AccessToken, testAuth.BaseURI, "p1"). + mockEnvs.On("List", testAuth.AccessToken, testAuth.BaseURI, "p1", int64(listPageSize), int64(0)). Return([]byte(`{"items":[{"key":"production","name":"Production"}]}`), nil) svc := Service{Clients: Clients{Environments: mockEnvs}} @@ -155,3 +157,42 @@ func TestService_Verify_Active(t *testing.T) { assert.True(t, result.Active) assert.Equal(t, 1, result.Attempts) } + +func TestService_ListProjects_FollowsPagination(t *testing.T) { + first := make([]string, listPageSize) + for i := range first { + first[i] = fmt.Sprintf(`{"key":"p%d","name":"Project %d"}`, i, i) + } + mockProjects := &projects.MockClient{} + mockProjects.On("List", testAuth.AccessToken, testAuth.BaseURI, int64(listPageSize), int64(0)). + Return([]byte(`{"items":[`+strings.Join(first, ",")+`]}`), nil) + mockProjects.On("List", testAuth.AccessToken, testAuth.BaseURI, int64(listPageSize), int64(listPageSize)). + Return([]byte(`{"items":[{"key":"last","name":"Last"}]}`), nil) + svc := Service{Clients: Clients{Projects: mockProjects}} + + got, err := svc.ListProjects(testAuth) + + require.NoError(t, err) + assert.Len(t, got, listPageSize+1) + assert.Equal(t, ProjectSummary{Key: "last", Name: "Last"}, got[len(got)-1]) + mockProjects.AssertExpectations(t) +} + +func TestService_ListEnvironments_FollowsPagination(t *testing.T) { + first := make([]string, listPageSize) + for i := range first { + first[i] = fmt.Sprintf(`{"key":"e%d","name":"Env %d"}`, i, i) + } + mockEnvs := &environments.MockClient{} + mockEnvs.On("List", testAuth.AccessToken, testAuth.BaseURI, "p1", int64(listPageSize), int64(0)). + Return([]byte(`{"items":[`+strings.Join(first, ",")+`]}`), nil) + mockEnvs.On("List", testAuth.AccessToken, testAuth.BaseURI, "p1", int64(listPageSize), int64(listPageSize)). + Return([]byte(`{"items":[{"key":"last","name":"Last"}]}`), nil) + svc := Service{Clients: Clients{Environments: mockEnvs}} + + got, err := svc.ListEnvironments(testAuth, "p1") + + require.NoError(t, err) + assert.Len(t, got, listPageSize+1) + mockEnvs.AssertExpectations(t) +} From 7de1108c87eb24f166f063130b58340e65587f67 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Thu, 6 Aug 2026 11:38:00 -0400 Subject: [PATCH 5/8] fix(setup): clear environment state when the project changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting a second project left the first project's environments in the model while the new fetch was in flight, so the list rendered immediately and Enter committed an environment key the new project does not have — the wizard then ran against a mismatched project and environment. A previously empty result had the milder version of the same fault, showing the empty state and making a non-empty project look empty. Back from the SDK step deliberately keeps the list, since that transition does not re-fetch and clearing it would strand the user on a spinner. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/setup/update.go | 16 +++++++ cmd/setup/wizard_test.go | 97 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/cmd/setup/update.go b/cmd/setup/update.go index c3bb62de7..eb205f86a 100644 --- a/cmd/setup/update.go +++ b/cmd/setup/update.go @@ -234,12 +234,27 @@ func (m *wizardModel) enterSDKStep() { m.step = stepSelectSDK } +// resetEnvSelection drops the environments belonging to the previously selected +// project, so the pending fetch shows the loading spinner rather than a list +// Enter would pick a key from that the new project doesn't have (or an empty +// state that makes a non-empty project look empty). envList is zeroed instead of +// left in place because envsLoaded already guards the SetSize call that would +// panic on a zero-value list, and envsFetchedMsg rebuilds it at the width a +// WindowSizeMsg has meanwhile recorded. +func (m *wizardModel) resetEnvSelection() { + m.environments = nil + m.envsLoaded = false + m.envList = list.Model{} + m.selectedEnv = "" +} + // 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 + m.resetEnvSelection() case stepSelectSDK: m.step = stepSelectEnvironment case stepPlan: @@ -259,6 +274,7 @@ func (m wizardModel) handleEnter() (tea.Model, tea.Cmd) { return m, nil } m.selectedProject = selected.key + m.resetEnvSelection() m.step = stepSelectEnvironment return m, m.fetchEnvironments() diff --git a/cmd/setup/wizard_test.go b/cmd/setup/wizard_test.go index 18eb22e0d..e25156c9f 100644 --- a/cmd/setup/wizard_test.go +++ b/cmd/setup/wizard_test.go @@ -537,6 +537,103 @@ func TestWizard_NoEnvironments_ShowsEmptyStateNotSpinner(t *testing.T) { assert.Contains(t, v, "my-proj") } +// selectProjectAtIndex drives the project list to the given row and presses +// Enter, returning the model with the environment fetch in flight. +func selectProjectAtIndex(t *testing.T, m wizardModel, i int) wizardModel { + t.Helper() + m.projectList.Select(i) + next, _ := m.handleEnter() + return next.(wizardModel) +} + +// wizardWithTwoProjectsAndEnvsFor returns a model that has already selected the +// first project and received its environments. +func wizardWithTwoProjectsAndEnvsFor(t *testing.T, envs []envItem) wizardModel { + t.Helper() + m := wizardModel{step: stepSelectProject, width: 78, height: 24, spinner: spinner.New()} + loaded, _ := m.Update(projectsFetchedMsg{projects: []projectItem{ + {key: "proj-a", name: "A"}, + {key: "proj-b", name: "B"}, + }}) + first := selectProjectAtIndex(t, loaded.(wizardModel), 0) + require.Equal(t, "proj-a", first.selectedProject) + + withEnvs, _ := first.Update(envsFetchedMsg{environments: envs}) + return withEnvs.(wizardModel) +} + +func TestWizard_ReselectProject_CannotSelectPreviousProjectsEnvironment(t *testing.T) { + m := wizardWithTwoProjectsAndEnvsFor(t, []envItem{{key: "a-production", name: "A Production"}}) + + back, _ := m.handleBack() + second := selectProjectAtIndex(t, back.(wizardModel), 1) + require.Equal(t, "proj-b", second.selectedProject) + + assert.Empty(t, second.environments) + assert.Empty(t, second.selectedEnv) + + // Enter while the new fetch is in flight must not commit a key from proj-a. + pressed, _ := second.handleEnter() + got := pressed.(wizardModel) + assert.Empty(t, got.selectedEnv) + assert.Equal(t, stepSelectEnvironment, got.step) +} + +func TestWizard_ReselectProject_ShowsSpinnerNotStaleList(t *testing.T) { + m := wizardWithTwoProjectsAndEnvsFor(t, []envItem{{key: "a-production", name: "A Production"}}) + require.Contains(t, m.View(), "A Production") + + back, _ := m.handleBack() + second := selectProjectAtIndex(t, back.(wizardModel), 1) + + v := second.View() + assert.Contains(t, v, "Loading environments") + assert.NotContains(t, v, "A Production") + assert.NotContains(t, v, "No environments available") +} + +func TestWizard_ReselectProject_AfterEmptyList_ShowsSpinnerNotEmptyState(t *testing.T) { + m := wizardWithTwoProjectsAndEnvsFor(t, nil) + require.Contains(t, m.View(), "No environments available") + + back, _ := m.handleBack() + second := selectProjectAtIndex(t, back.(wizardModel), 1) + + assert.Contains(t, second.View(), "Loading environments") + + // The new project's environments still land normally. + withEnvs, _ := second.Update(envsFetchedMsg{environments: []envItem{{key: "b-production", name: "B Production"}}}) + assert.Contains(t, withEnvs.(wizardModel).View(), "B Production") +} + +func TestWizard_ReselectProject_WindowSizeDoesNotPanic(t *testing.T) { + m := wizardWithTwoProjectsAndEnvsFor(t, []envItem{{key: "a-production", name: "A Production"}}) + back, _ := m.handleBack() + second := selectProjectAtIndex(t, back.(wizardModel), 1) + + // Resizing with the env list cleared, then again once the fetch lands. + resized, _ := second.Update(tea.WindowSizeMsg{Width: 120, Height: 50}) + withEnvs, _ := resized.(wizardModel).Update(envsFetchedMsg{environments: []envItem{{key: "b-production", name: "B Production"}}}) + got := withEnvs.(wizardModel) + assert.Equal(t, 120, got.envList.Width()) + + again, _ := got.Update(tea.WindowSizeMsg{Width: 60, Height: 30}) + assert.Equal(t, 60, again.(wizardModel).envList.Width()) +} + +func TestWizard_BackFromSDK_KeepsEnvironmentList(t *testing.T) { + // Back from the SDK step does not re-fetch, so the env list must survive it. + m := wizardWithTwoProjectsAndEnvsFor(t, []envItem{{key: "a-production", name: "A Production"}}) + m.step = stepSelectSDK + + back, _ := m.handleBack() + got := back.(wizardModel) + + assert.Equal(t, stepSelectEnvironment, got.step) + assert.True(t, got.envsLoaded) + assert.Contains(t, got.View(), "A Production") +} + func TestWizard_WindowSize_ResizesExistingLists(t *testing.T) { m := wizardModel{step: stepSelectProject, width: 40, height: 10, spinner: spinner.New()} withList, _ := m.Update(projectsFetchedMsg{projects: []projectItem{{key: "p1", name: "One"}}}) From 19fd1f2118dc114145fed4d1be37b6e86d93f4c9 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Thu, 6 Aug 2026 12:51:22 -0400 Subject: [PATCH 6/8] fix(setup): drop fetch responses the user has moved past Nothing cancels an in-flight fetch, so its response was applied whatever the model had done meanwhile. Pressing enter on an environment and then going back let the arriving keys pull the wizard into SDK selection with no environment selected, which sent verification to an empty environment path; a response for a project the user had already left rebuilt the list under the new project, letting enter commit an environment key that project does not have. Both messages now name the project and environment they were fetched for, and are dropped unless that still matches the selection and the wizard is on a step that is waiting for them. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/setup/commands.go | 12 +++-- cmd/setup/model.go | 12 ++++- cmd/setup/update.go | 31 ++++++++++++ cmd/setup/wizard_test.go | 103 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 150 insertions(+), 8 deletions(-) diff --git a/cmd/setup/commands.go b/cmd/setup/commands.go index acba81490..994c07099 100644 --- a/cmd/setup/commands.go +++ b/cmd/setup/commands.go @@ -28,8 +28,11 @@ func (m wizardModel) fetchProjects() tea.Cmd { } func (m wizardModel) fetchEnvironments() tea.Cmd { + // Read the selection here rather than in the goroutine, so the message reports + // what was asked for even after the model has moved on. + project := m.selectedProject return func() tea.Msg { - es, err := m.svc.ListEnvironments(m.auth, m.selectedProject) + es, err := m.svc.ListEnvironments(m.auth, project) if err != nil { return wizardErrMsg{err: err} } @@ -37,17 +40,20 @@ func (m wizardModel) fetchEnvironments() tea.Cmd { for i, e := range es { envs[i] = envItem{key: e.Key, name: e.Name} } - return envsFetchedMsg{environments: envs} + return envsFetchedMsg{project: project, environments: envs} } } func (m wizardModel) fetchEnvDetails() tea.Cmd { + project, env := m.selectedProject, m.selectedEnv return func() tea.Msg { - keys, err := m.svc.EnvKeys(m.auth, m.selectedProject, m.selectedEnv) + keys, err := m.svc.EnvKeys(m.auth, project, env) if err != nil { return wizardErrMsg{err: err} } return envDetailsFetchedMsg{ + project: project, + env: env, sdkKey: keys.SDKKey, clientSideID: keys.ClientSideID, mobileKey: keys.MobileKey, diff --git a/cmd/setup/model.go b/cmd/setup/model.go index cf7bb5c0c..15563393d 100644 --- a/cmd/setup/model.go +++ b/cmd/setup/model.go @@ -138,8 +138,18 @@ func (e envItem) FilterValue() string { return e.name } // messages type projectsFetchedMsg struct{ projects []projectItem } -type envsFetchedMsg struct{ environments []envItem } + +// envsFetchedMsg and envDetailsFetchedMsg name the selection their fetch was +// issued for. Nothing cancels a fetch the user has navigated away from, so the +// response has to say what it answers for the model to tell a current reply from +// a superseded one it must drop. +type envsFetchedMsg struct { + project string + environments []envItem +} type envDetailsFetchedMsg struct { + project string + env string sdkKey string clientSideID string mobileKey string diff --git a/cmd/setup/update.go b/cmd/setup/update.go index eb205f86a..a54349a9b 100644 --- a/cmd/setup/update.go +++ b/cmd/setup/update.go @@ -80,6 +80,9 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case envsFetchedMsg: + if !m.acceptsEnvs(msg) { + return m, nil + } m.environments = msg.environments m.envsLoaded = true items := make([]list.Item, len(msg.environments)) @@ -93,6 +96,9 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case envDetailsFetchedMsg: + if !m.acceptsEnvDetails(msg) { + return m, nil + } m.sdkKey = msg.sdkKey m.clientSideID = msg.clientSideID m.mobileKey = msg.mobileKey @@ -234,6 +240,31 @@ func (m *wizardModel) enterSDKStep() { m.step = stepSelectSDK } +// acceptsEnvs reports whether an environment list still describes the project the +// user has selected, and whether the wizard is still choosing one. A list fetched +// for a project the user has since left would otherwise be shown under the new +// project, letting Enter commit an environment key the new project doesn't have. +func (m wizardModel) acceptsEnvs(msg envsFetchedMsg) bool { + if msg.project != m.selectedProject { + return false + } + // Past the environment step the list is only a leftover of a choice already + // made, so rebuilding it would drop the user's place for nothing. + return m.step == stepSelectProject || m.step == stepSelectEnvironment +} + +// acceptsEnvDetails reports whether SDK keys belong to the project and +// environment currently selected, and whether the wizard is still waiting for +// them. Without both checks a response the user has navigated away from — or a +// duplicate arriving after the flow finished — would write another environment's +// keys and yank the flow back to SDK selection. +func (m wizardModel) acceptsEnvDetails(msg envDetailsFetchedMsg) bool { + if msg.project != m.selectedProject || msg.env != m.selectedEnv { + return false + } + return m.step == stepSelectEnvironment || m.step == stepDetect +} + // resetEnvSelection drops the environments belonging to the previously selected // project, so the pending fetch shows the loading spinner rather than a list // Enter would pick a key from that the new project doesn't have (or an empty diff --git a/cmd/setup/wizard_test.go b/cmd/setup/wizard_test.go index e25156c9f..52101d383 100644 --- a/cmd/setup/wizard_test.go +++ b/cmd/setup/wizard_test.go @@ -529,7 +529,7 @@ func TestWizard_NoEnvironments_ShowsEmptyStateNotSpinner(t *testing.T) { assert.Contains(t, m.View(), "Loading environments") - updated, _ := m.Update(envsFetchedMsg{environments: nil}) + updated, _ := m.Update(envsFetchedMsg{project: "my-proj", environments: nil}) v := updated.(wizardModel).View() assert.NotContains(t, v, "Loading environments") @@ -558,7 +558,7 @@ func wizardWithTwoProjectsAndEnvsFor(t *testing.T, envs []envItem) wizardModel { first := selectProjectAtIndex(t, loaded.(wizardModel), 0) require.Equal(t, "proj-a", first.selectedProject) - withEnvs, _ := first.Update(envsFetchedMsg{environments: envs}) + withEnvs, _ := first.Update(envsFetchedMsg{project: "proj-a", environments: envs}) return withEnvs.(wizardModel) } @@ -602,7 +602,7 @@ func TestWizard_ReselectProject_AfterEmptyList_ShowsSpinnerNotEmptyState(t *test assert.Contains(t, second.View(), "Loading environments") // The new project's environments still land normally. - withEnvs, _ := second.Update(envsFetchedMsg{environments: []envItem{{key: "b-production", name: "B Production"}}}) + withEnvs, _ := second.Update(envsFetchedMsg{project: "proj-b", environments: []envItem{{key: "b-production", name: "B Production"}}}) assert.Contains(t, withEnvs.(wizardModel).View(), "B Production") } @@ -613,7 +613,7 @@ func TestWizard_ReselectProject_WindowSizeDoesNotPanic(t *testing.T) { // Resizing with the env list cleared, then again once the fetch lands. resized, _ := second.Update(tea.WindowSizeMsg{Width: 120, Height: 50}) - withEnvs, _ := resized.(wizardModel).Update(envsFetchedMsg{environments: []envItem{{key: "b-production", name: "B Production"}}}) + withEnvs, _ := resized.(wizardModel).Update(envsFetchedMsg{project: "proj-b", environments: []envItem{{key: "b-production", name: "B Production"}}}) got := withEnvs.(wizardModel) assert.Equal(t, 120, got.envList.Width()) @@ -654,3 +654,98 @@ func TestWizard_ListHeight_NeverNegativeBeforeWindowSize(t *testing.T) { withList, _ := m.Update(projectsFetchedMsg{projects: []projectItem{{key: "p1", name: "One"}}}) assert.GreaterOrEqual(t, withList.(wizardModel).projectList.Height(), 3) } + +// envDetailsInFlight returns a model that has selected proj-a/production and is +// waiting on the SDK keys for it. +func envDetailsInFlight(t *testing.T) wizardModel { + t.Helper() + m := wizardWithTwoProjectsAndEnvsFor(t, []envItem{{key: "production", name: "Prod"}}) + next, _ := m.handleEnter() + got := next.(wizardModel) + require.Equal(t, "production", got.selectedEnv) + require.Equal(t, stepSelectEnvironment, got.step) + return got +} + +func TestWizard_EnvDetails_LandingAfterBack_IsIgnored(t *testing.T) { + m := envDetailsInFlight(t) + + // User presses ← before the keys arrive. + back, _ := m.handleBack() + m = back.(wizardModel) + require.Equal(t, stepSelectProject, m.step) + + late, _ := m.Update(envDetailsFetchedMsg{ + project: "proj-a", env: "production", + sdkKey: "sdk-A", clientSideID: "cs-A", mobileKey: "mob-A", + }) + got := late.(wizardModel) + + // Must not yank the user into SDK selection with no environment selected. + assert.Equal(t, stepSelectProject, got.step) + assert.Empty(t, got.sdkKey) + assert.Empty(t, got.selectedEnv) +} + +func TestWizard_EnvDetails_OutOfOrder_KeepsSelectedEnvsKeys(t *testing.T) { + m := envDetailsInFlight(t) // production selected, its fetch in flight + m.detectComplete = true + + // User goes back and selects a different environment before the first lands. + back, _ := m.handleBack() + m = back.(wizardModel) + m = selectProjectAtIndex(t, m, 0) + withEnvs, _ := m.Update(envsFetchedMsg{project: "proj-a", environments: []envItem{ + {key: "production", name: "Prod"}, {key: "test", name: "Test"}, + }}) + m = withEnvs.(wizardModel) + m.envList.Select(1) // test + next, _ := m.handleEnter() + m = next.(wizardModel) + require.Equal(t, "test", m.selectedEnv) + + // The superseded production response lands last and must be dropped. + stale, _ := m.Update(envDetailsFetchedMsg{ + project: "proj-a", env: "production", sdkKey: "sdk-PROD", + }) + m = stale.(wizardModel) + assert.Empty(t, m.sdkKey, "production's key must not be adopted while test is selected") + + // test's own response is still accepted. + fresh, _ := m.Update(envDetailsFetchedMsg{ + project: "proj-a", env: "test", sdkKey: "sdk-TEST", + }) + assert.Equal(t, "sdk-TEST", fresh.(wizardModel).sdkKey) +} + +func TestWizard_EnvDetails_DuplicateOnDoneScreen_IsIgnored(t *testing.T) { + m := wizardModel{ + step: stepDone, width: 78, spinner: spinner.New(), + selectedProject: "proj-a", selectedEnv: "production", + verifyResult: &setup.VerifyResult{Active: true}, + detectResult: &setup.DetectResult{SDKID: "node-server"}, + } + + dup, _ := m.Update(envDetailsFetchedMsg{project: "proj-a", env: "production", sdkKey: "sdk-A"}) + + assert.Equal(t, stepDone, dup.(wizardModel).step, "a duplicate must not reopen SDK selection") +} + +func TestWizard_EnvsFetched_ForSupersededProject_IsIgnored(t *testing.T) { + m := wizardWithTwoProjectsAndEnvsFor(t, []envItem{{key: "only-in-a", name: "Only In A"}}) + + back, _ := m.handleBack() + m = selectProjectAtIndex(t, back.(wizardModel), 1) + require.Equal(t, "proj-b", m.selectedProject) + + // proj-a's in-flight list lands after proj-b was chosen. + stale, _ := m.Update(envsFetchedMsg{project: "proj-a", environments: []envItem{{key: "only-in-a", name: "Only In A"}}}) + m = stale.(wizardModel) + assert.Empty(t, m.environments) + assert.False(t, m.envsLoaded) + assert.Contains(t, m.View(), "Loading environments") + + // proj-b's own list is accepted. + fresh, _ := m.Update(envsFetchedMsg{project: "proj-b", environments: []envItem{{key: "b-prod", name: "B Prod"}}}) + assert.Contains(t, fresh.(wizardModel).View(), "B Prod") +} From 05de128548f070e07292f0b074161441488719e8 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Thu, 6 Aug 2026 12:54:20 -0400 Subject: [PATCH 7/8] fix(setup): leave an already-initialized entry file alone Injection appended unconditionally, so running setup a second time wrote a second copy of the init code. In Node that redeclares const bindings and the app stops starting with a SyntaxError, while both runs reported success. The second run is an ordinary path: install is skipped as already installed and a flag conflict counts as success, so nothing else stops it. The entry file is now checked for the template's own import lines before anything is written, and the wizard says the file was left as it is rather than claiming to have injected code. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/setup/view.go | 10 ++++- internal/setup/initializer.go | 39 +++++++++++++++++++- internal/setup/initializer_test.go | 59 ++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 3 deletions(-) diff --git a/cmd/setup/view.go b/cmd/setup/view.go index 0dcdb813f..440ce5be1 100644 --- a/cmd/setup/view.go +++ b/cmd/setup/view.go @@ -79,8 +79,12 @@ func (m wizardModel) View() string { return m.spinner.View() + " Injecting initialization code..." case stepWaitForApp: + lead := "SDK initialization code has been injected into:\n" + if m.initResult.AlreadyInitialized { + lead = "This file already initializes the LaunchDarkly SDK, so it was left as it is:\n" + } return titleStyle.Render("Start your application") + "\n\n" + - "SDK initialization code has been injected into:\n" + + lead + " " + m.initResult.FilePath + "\n\n" + "Please start your application now, then press Enter to verify the connection.\n" @@ -101,7 +105,9 @@ func (m wizardModel) View() string { body += m.wrap("Install it yourself with:") + "\n\n" + code(m.installResult.Command) + "\n\n" } - if m.initResult != nil && m.initResult.Success { + if m.initResult != nil && m.initResult.AlreadyInitialized { + body += m.wrap(fmt.Sprintf("%s already initializes the SDK, so it was left unchanged.", m.initResult.FilePath)) + "\n" + } else 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)) + diff --git a/internal/setup/initializer.go b/internal/setup/initializer.go index 8ce42e2d4..9ce25bb8c 100644 --- a/internal/setup/initializer.go +++ b/internal/setup/initializer.go @@ -35,7 +35,11 @@ type InitResult struct { FilePath string `json:"file_path,omitempty"` DocsURL string `json:"docs_url,omitempty"` Snippet string `json:"snippet,omitempty"` - Success bool `json:"success"` + // AlreadyInitialized reports that the entry file initialized the SDK before + // this run, so nothing was written. Setup is complete either way, which is why + // it accompanies Success. + AlreadyInitialized bool `json:"already_initialized,omitempty"` + Success bool `json:"success"` } // appendSafeSDKs lists SDKs whose entry file is an interpreted script executed @@ -265,6 +269,15 @@ func (i Initializer) InjectIntoFile(sdkID, filePath string, cfg InitConfig) (*In } content := string(existing) + if alreadyInitialized(content, importSection, initSection) { + return &InitResult{ + SDKID: sdkID, + FilePath: filePath, + AlreadyInitialized: true, + Success: true, + }, nil + } + if importSection != "" { prologue, body := splitPrologue(sdkID, content) content = prologue + importSection + "\n" + body @@ -278,6 +291,30 @@ func (i Initializer) InjectIntoFile(sdkID, filePath string, cfg InitConfig) (*In return &InitResult{SDKID: sdkID, FilePath: filePath, Success: true}, nil } +// alreadyInitialized reports whether the file already contains the initialization +// this template would add. Injection appends at file scope, so a second copy +// redeclares the same names: in Node that is a SyntaxError that stops the app from +// starting, and in Python and Ruby it silently rebinds the client. Matching the +// template's own import lines keeps the test in whatever language the file is +// written in, and matching any one of them errs toward leaving a half-configured +// file alone rather than appending into it. +func alreadyInitialized(content, importSection, initSection string) bool { + section := importSection + if strings.TrimSpace(section) == "" { + section = initSection + } + for _, line := range strings.Split(section, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "#") { + continue + } + if strings.Contains(content, line) { + return true + } + } + return false +} + // entryNeedsESM reports whether code written into entryPath has to use ESM import // syntax. The extension decides it outright for the explicit cases; a plain .js // entry depends on the enclosing package's "type" field. Detection points Node diff --git a/internal/setup/initializer_test.go b/internal/setup/initializer_test.go index a99c571c9..364d88e42 100644 --- a/internal/setup/initializer_test.go +++ b/internal/setup/initializer_test.go @@ -495,3 +495,62 @@ func TestRenderTemplate_MobileConfigCarriesRequiredArguments(t *testing.T) { }) } } + +// Running setup twice must not append a second copy. In Node the injected code +// declares const bindings, so a duplicate is a SyntaxError that stops the app. +func TestInjectIntoFile_SecondRunLeavesFileUnchanged(t *testing.T) { + tests := []struct { + sdkID string + name string + initial string + }{ + {"node-server", "index.js", "'use strict'\nconst express = require('express')\n\nexpress()\n"}, + {"node-server", "src/main.ts", "import express from 'express'\n\nexpress()\n"}, + {"python-server-sdk", "app.py", "\"\"\"docstring.\"\"\"\nimport os\n\nprint(os.getcwd())\n"}, + {"ruby-server-sdk", "config.ru", "# frozen_string_literal: true\nrequire 'rack'\n"}, + } + for _, tt := range tests { + t.Run(tt.sdkID+"/"+tt.name, func(t *testing.T) { + dir := t.TempDir() + entry := filepath.Join(dir, tt.name) + require.NoError(t, os.MkdirAll(filepath.Dir(entry), 0755)) + require.NoError(t, os.WriteFile(entry, []byte(tt.initial), 0644)) + + cfg := InitConfig{SDKKey: "sdk-KEY", FlagKey: "my-flag"} + first, err := Initializer{}.InjectIntoFile(tt.sdkID, entry, cfg) + require.NoError(t, err) + require.True(t, first.Success) + require.False(t, first.AlreadyInitialized) + afterFirst, err := os.ReadFile(entry) + require.NoError(t, err) + + second, err := Initializer{}.InjectIntoFile(tt.sdkID, entry, cfg) + require.NoError(t, err) + assert.True(t, second.AlreadyInitialized, "second run must report the file was already set up") + assert.True(t, second.Success) + + afterSecond, err := os.ReadFile(entry) + require.NoError(t, err) + assert.Equal(t, string(afterFirst), string(afterSecond), "second run must not modify the file") + }) + } +} + +// A file that only mentions a similarly-named package must still get injected; +// skipping it would leave the user with no initialization at all. +func TestInjectIntoFile_SimilarPackageNameStillInjects(t *testing.T) { + dir := t.TempDir() + entry := filepath.Join(dir, "index.js") + initial := "// TODO: evaluate @launchdarkly/node-server-sdk-metrics\n" + + "const other = require('@launchdarkly/node-server-sdk-metrics');\n" + require.NoError(t, os.WriteFile(entry, []byte(initial), 0644)) + + result, err := Initializer{}.InjectIntoFile("node-server", entry, InitConfig{SDKKey: "sdk-KEY"}) + require.NoError(t, err) + assert.True(t, result.Success) + assert.False(t, result.AlreadyInitialized) + + out, err := os.ReadFile(entry) + require.NoError(t, err) + assert.Contains(t, string(out), "const LaunchDarkly = require('@launchdarkly/node-server-sdk');") +} From 4dc11df986314386b935dd993fc18d552fde8edd Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Thu, 6 Aug 2026 12:58:11 -0400 Subject: [PATCH 8/8] fix(setup): make browser-SDK flags available to the client The API leaves usingEnvironmentId false unless a create request asks otherwise, so the flag the wizard created was invisible to the js and react SDKs it had just installed: variation() returned the fallback while the final screen said the flag was ready. Flag creation now requests client-side availability when the chosen SDK authenticates with the client-side ID, which is read from the SDK's own init template so a new template cannot disagree with a separate list. Create takes options, so the deprecated quickstart path keeps the API defaults. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/setup/commands.go | 2 +- internal/flags/client.go | 35 +++++++++++++++++++++++++- internal/flags/mock_client.go | 7 ++++++ internal/setup/initializer.go | 13 ++++++++++ internal/setup/service.go | 15 +++++++++-- internal/setup/service_test.go | 46 +++++++++++++++++++++++++++++++--- 6 files changed, 111 insertions(+), 7 deletions(-) diff --git a/cmd/setup/commands.go b/cmd/setup/commands.go index 994c07099..8f9e5405b 100644 --- a/cmd/setup/commands.go +++ b/cmd/setup/commands.go @@ -99,7 +99,7 @@ func (m wizardModel) runInstall() tea.Cmd { func (m wizardModel) runCreateFlag() tea.Cmd { return func() tea.Msg { - key, err := m.svc.CreateFlag(m.auth, m.selectedProject, "my-new-flag", "My New Flag") + key, err := m.svc.CreateFlag(m.auth, m.selectedProject, "my-new-flag", "My New Flag", m.detectResult.SDKID) if err != nil { return wizardErrMsg{err: err} } diff --git a/internal/flags/client.go b/internal/flags/client.go index c4cfdd8cb..4f51188f1 100644 --- a/internal/flags/client.go +++ b/internal/flags/client.go @@ -17,8 +17,37 @@ type UpdateInput struct { Value interface{} `json:"value"` } +// ClientSideAvailability says which SDK kinds may evaluate a flag. The API +// defaults usingEnvironmentId to false, so a flag a browser SDK is meant to read +// has to ask for it explicitly. +type ClientSideAvailability struct { + UsingEnvironmentID bool + UsingMobileKey bool +} + +// CreateOption adjusts the flag being created. Callers that pass none get the +// API's own defaults. +type CreateOption func(*createConfig) + +type createConfig struct { + availability *ClientSideAvailability +} + +// WithClientSideAvailability makes the new flag available to the given SDK kinds. +func WithClientSideAvailability(a ClientSideAvailability) CreateOption { + return func(c *createConfig) { c.availability = &a } +} + +func resolveCreateOptions(opts []CreateOption) createConfig { + var cfg createConfig + for _, opt := range opts { + opt(&cfg) + } + return cfg +} + type Client interface { - Create(ctx context.Context, accessToken, baseURI, name, key, projKey string) ([]byte, error) + Create(ctx context.Context, accessToken, baseURI, name, key, projKey string, opts ...CreateOption) ([]byte, error) Get(ctx context.Context, accessToken, baseURI, key, projKey, envKey string) ([]byte, error) Update( ctx context.Context, @@ -49,9 +78,13 @@ func (c FlagsClient) Create( name, key, projectKey string, + opts ...CreateOption, ) ([]byte, error) { client := client.New(accessToken, baseURI, c.cliVersion) post := ldapi.NewFeatureFlagBody(name, key) + if a := resolveCreateOptions(opts).availability; a != nil { + post.SetClientSideAvailability(*ldapi.NewClientSideAvailabilityPost(a.UsingEnvironmentID, a.UsingMobileKey)) + } flag, _, err := client.FeatureFlagsApi.PostFeatureFlag(ctx, projectKey).FeatureFlagBody(*post).Execute() if err != nil { return nil, errors.NewLDAPIError(err) diff --git a/internal/flags/mock_client.go b/internal/flags/mock_client.go index 8dc8e17cf..2cdf70da9 100644 --- a/internal/flags/mock_client.go +++ b/internal/flags/mock_client.go @@ -8,6 +8,9 @@ import ( type MockClient struct { mock.Mock + // CreatedAvailability is the client-side availability the last Create call + // asked for, or nil if it asked for none. + CreatedAvailability *ClientSideAvailability } var _ Client = &MockClient{} @@ -19,7 +22,11 @@ func (c *MockClient) Create( name, key, projKey string, + opts ...CreateOption, ) ([]byte, error) { + // Recorded rather than passed to Called so existing expectations, which set no + // options, keep matching. + c.CreatedAvailability = resolveCreateOptions(opts).availability args := c.Called(accessToken, baseURI, name, key, projKey) return args.Get(0).([]byte), args.Error(1) diff --git a/internal/setup/initializer.go b/internal/setup/initializer.go index 9ce25bb8c..969f40dca 100644 --- a/internal/setup/initializer.go +++ b/internal/setup/initializer.go @@ -168,6 +168,19 @@ func InjectsInPlace(sdkID string) bool { return HasTemplate(sdkID) && appendSafeSDKs[sdkID] } +// UsesClientSideID reports whether an SDK authenticates with the environment's +// client-side ID rather than a server SDK key or a mobile key. It is derived from +// the SDK's own init template, which is the one place that already knows which +// credential the SDK takes, so a new template cannot disagree with a list here. +func UsesClientSideID(sdkID string) bool { + const sentinel = "__ld_client_side_id_probe__" + rendered, err := RenderTemplate(sdkID, InitConfig{ClientSideID: sentinel}) + if err != nil { + return false + } + return strings.Contains(rendered, sentinel) +} + // RenderTemplate renders the initialization code for the given SDK, using the // CommonJS form where an SDK has both. Prefer RenderTemplateForEntry when the // target file is known, so the module syntax matches it. diff --git a/internal/setup/service.go b/internal/setup/service.go index 5f381e030..5645817c8 100644 --- a/internal/setup/service.go +++ b/internal/setup/service.go @@ -154,8 +154,19 @@ func (s Service) Install(dir string, detection *DetectResult) (*InstallResult, e // CreateFlag creates a feature flag, treating an existing flag (conflict) as // success and returning its key. -func (s Service) CreateFlag(a Auth, projectKey, key, name string) (string, error) { - _, err := s.Clients.Flags.Create(context.Background(), a.AccessToken, a.BaseURI, name, key, projectKey) +// CreateFlag creates the flag the wizard hands to the SDK. sdkID decides whether +// the flag has to be available to client-side SDKs: the API leaves +// usingEnvironmentId false by default, which would leave a browser SDK evaluating +// the fallback forever even though setup reported success. +func (s Service) CreateFlag(a Auth, projectKey, key, name, sdkID string) (string, error) { + var opts []flags.CreateOption + if UsesClientSideID(sdkID) { + opts = append(opts, flags.WithClientSideAvailability(flags.ClientSideAvailability{ + UsingEnvironmentID: true, + UsingMobileKey: true, + })) + } + _, err := s.Clients.Flags.Create(context.Background(), a.AccessToken, a.BaseURI, name, key, projectKey, opts...) if err != nil { if je, parseErr := parseJSONError(err); parseErr == nil && je.Code == "conflict" { return key, nil diff --git a/internal/setup/service_test.go b/internal/setup/service_test.go index f5ec648eb..91636f158 100644 --- a/internal/setup/service_test.go +++ b/internal/setup/service_test.go @@ -78,7 +78,7 @@ func TestService_CreateFlag_Success(t *testing.T) { Return([]byte(`{"key":"my-new-flag"}`), nil) svc := Service{Clients: Clients{Flags: mockFlags}} - key, err := svc.CreateFlag(testAuth, "p1", "my-new-flag", "My New Flag") + key, err := svc.CreateFlag(testAuth, "p1", "my-new-flag", "My New Flag", "node-server") require.NoError(t, err) assert.Equal(t, "my-new-flag", key) @@ -90,7 +90,7 @@ func TestService_CreateFlag_ConflictIsSuccess(t *testing.T) { Return([]byte(nil), errors.NewError(`{"code":"conflict","message":"already exists"}`)) svc := Service{Clients: Clients{Flags: mockFlags}} - key, err := svc.CreateFlag(testAuth, "p1", "my-new-flag", "My New Flag") + key, err := svc.CreateFlag(testAuth, "p1", "my-new-flag", "My New Flag", "node-server") require.NoError(t, err) assert.Equal(t, "my-new-flag", key) @@ -102,7 +102,7 @@ func TestService_CreateFlag_OtherErrorPropagates(t *testing.T) { Return([]byte(nil), errors.NewError(`{"code":"internal_error"}`)) svc := Service{Clients: Clients{Flags: mockFlags}} - _, err := svc.CreateFlag(testAuth, "p1", "my-new-flag", "My New Flag") + _, err := svc.CreateFlag(testAuth, "p1", "my-new-flag", "My New Flag", "node-server") assert.Error(t, err) } @@ -196,3 +196,43 @@ func TestService_ListEnvironments_FollowsPagination(t *testing.T) { assert.Len(t, got, listPageSize+1) mockEnvs.AssertExpectations(t) } + +// A flag a browser SDK is meant to read has to be created with client-side +// availability: the API leaves usingEnvironmentId false, so without it the SDK +// evaluates the fallback forever while setup reports success. +func TestService_CreateFlag_ClientSideAvailability(t *testing.T) { + tests := []struct { + sdkID string + want *flags.ClientSideAvailability + }{ + {"js-client-sdk", &flags.ClientSideAvailability{UsingEnvironmentID: true, UsingMobileKey: true}}, + {"react-client-sdk", &flags.ClientSideAvailability{UsingEnvironmentID: true, UsingMobileKey: true}}, + {"node-server", nil}, + {"go-server-sdk", nil}, + {"react-native", nil}, + {"android", nil}, + {"swift-client-sdk", nil}, + } + for _, tt := range tests { + t.Run(tt.sdkID, func(t *testing.T) { + mockFlags := &flags.MockClient{} + mockFlags.On("Create", testAuth.AccessToken, testAuth.BaseURI, "My New Flag", "my-new-flag", "p1"). + Return([]byte(`{"key":"my-new-flag"}`), nil) + svc := Service{Clients: Clients{Flags: mockFlags}} + + _, err := svc.CreateFlag(testAuth, "p1", "my-new-flag", "My New Flag", tt.sdkID) + + require.NoError(t, err) + assert.Equal(t, tt.want, mockFlags.CreatedAvailability) + }) + } +} + +// The classification comes from each SDK's own init template, so this pins which +// credential every known SDK is understood to take. +func TestUsesClientSideID_MatchesTemplateCredentials(t *testing.T) { + clientSide := map[string]bool{"js-client-sdk": true, "react-client-sdk": true} + for _, sdk := range KnownSDKs { + assert.Equal(t, clientSide[sdk.ID], UsesClientSideID(sdk.ID), "sdk %s", sdk.ID) + } +}