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..8f9e5405b --- /dev/null +++ b/cmd/setup/commands.go @@ -0,0 +1,201 @@ +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" +) + +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 { + // 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, project) + 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{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, project, env) + if err != nil { + return wizardErrMsg{err: err} + } + return envDetailsFetchedMsg{ + project: project, + env: env, + 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", m.detectResult.SDKID) + 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} + } +} + +// 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} + } +} + +// 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". +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/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..866843225 --- /dev/null +++ b/cmd/setup/install.go @@ -0,0 +1,107 @@ +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 + } + if result.Command != "" { + fmt.Fprintf(cmd.OutOrStdout(), "Command: %s\n", result.Command) + } + if result.DryRun { + fmt.Fprintln(cmd.OutOrStdout(), "Dry run: command not executed") + 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/model.go b/cmd/setup/model.go new file mode 100644 index 000000000..15563393d --- /dev/null +++ b/cmd/setup/model.go @@ -0,0 +1,203 @@ +package setup + +import ( + "io" + + "github.com/atotto/clipboard" + "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" +) + +// 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 ( + 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 + // 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 + 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 + + // 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 +} + +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 } + +// 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 +} +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 copiedMsg struct{ viaTerminal bool } +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, + clipboard: terminalWriter(), + nativeCopy: clipboard.WriteAll, + remoteSession: isRemoteSession(), + } + + 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..17130c2f6 --- /dev/null +++ b/cmd/setup/setup_test.go @@ -0,0 +1,414 @@ +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_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", + "--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..a54349a9b --- /dev/null +++ b/cmd/setup/update.go @@ -0,0 +1,378 @@ +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 + // 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() { + 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 "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 + 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.listHeight()) + m.projectList.Title = "Select a project:" + m.projectList.SetShowStatusBar(false) + 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)) + for i, e := range msg.environments { + items[i] = e + } + delegate := list.NewDefaultDelegate() + m.envList = list.New(items, delegate, m.width, m.listHeight()) + m.envList.Title = "Select an environment:" + m.envList.SetShowStatusBar(false) + return m, nil + + case envDetailsFetchedMsg: + if !m.acceptsEnvDetails(msg) { + return m, nil + } + 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.sdkListBuilt = true + m.step = stepSelectSDK + return + } + } + m.detectedSDK = nil + m.sdkFocus = 1 + m.sdkList = m.newSDKList(sdkItemsExcept(""), "Select your SDK:", true) + m.sdkListBuilt = true + 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 +// 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: + 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.resetEnvSelection() + 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..440ce5be1 --- /dev/null +++ b/cmd/setup/view.go @@ -0,0 +1,314 @@ +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" + +// 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 "" + } + + 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 !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 !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: + 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: + 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" + + lead + + " " + 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.") + "\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.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)) + + "\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 + "\n" + m.copyHint() + 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\n" + + m.copyHint() + + 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 +} + +// 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 { + 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..52101d383 --- /dev/null +++ b/cmd/setup/wizard_test.go @@ -0,0 +1,751 @@ +package setup + +import ( + "os" + "path/filepath" + "testing" + + "github.com/charmbracelet/bubbles/spinner" + 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") +} + +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") +} + +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{project: "my-proj", 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") +} + +// 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{project: "proj-a", 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{project: "proj-b", 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{project: "proj-b", 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"}}}) + + 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) +} + +// 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") +} 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/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 diff --git a/internal/environments/client.go b/internal/environments/client.go index 6abbc7578..734c0a9b1 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, limit, offset int64) ([]byte, error) } type EnvironmentsClient struct { @@ -46,3 +47,34 @@ func (c EnvironmentsClient) Get( return output, nil } + +func (c EnvironmentsClient) List( + ctx context.Context, + accessToken, + baseURI, + projectKey string, + limit, + offset int64, +) ([]byte, error) { + client := client.New(accessToken, baseURI, c.cliVersion) + 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) + + } + + 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..f4862e6ce 100644 --- a/internal/environments/mock_client.go +++ b/internal/environments/mock_client.go @@ -23,3 +23,16 @@ func (c *MockClient) Get( return args.Get(0).([]byte), args.Error(1) } + +func (c *MockClient) List( + ctx context.Context, + accessToken, + baseURI, + projKey string, + limit, + offset int64, +) ([]byte, error) { + args := c.Called(accessToken, baseURI, projKey, limit, offset) + + return args.Get(0).([]byte), args.Error(1) +} 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/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/detector.go b/internal/setup/detector.go new file mode 100644 index 000000000..a450fc7e6 --- /dev/null +++ b/internal/setup/detector.go @@ -0,0 +1,533 @@ +package setup + +import ( + "bytes" + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// DetectResult contains information about the user's project detected from the working directory. +type DetectResult struct { + Language string `json:"language"` + Framework string `json:"framework,omitempty"` + PackageManager string `json:"package_manager"` + SDKID string `json:"sdk_id"` + EntryPoint string `json:"entry_point"` + // EntryPointExists distinguishes an entry point we found from one we merely + // suggest. Callers must not write initialization code into a suggested path + // without telling the user, since the project does not load that file. + EntryPointExists bool `json:"entry_point_exists"` +} + +// Detector inspects a directory to determine the language, framework, package manager, +// recommended SDK, and entry point file. +type Detector interface { + Detect(dir string) (*DetectResult, error) +} + +// StubDetector is a placeholder implementation. Replace with real detection logic. +type StubDetector struct{} + +var _ Detector = StubDetector{} + +func (StubDetector) Detect(_ string) (*DetectResult, error) { + return nil, errors.New("detect is not yet implemented: a real Detector must be provided") +} + +// FileDetector implements Detector by scanning the filesystem for known project indicators. +type FileDetector struct{} + +var _ Detector = FileDetector{} + +// Detect scans dir for known project files and returns a DetectResult with language, +// framework, SDK ID, package manager, and a suggested entry point file. +// Returns an error if the project type cannot be determined. +// A root package.json is often only build tooling — Rails with jsbundling, Django +// with Tailwind, a Go binary published to npm — so the backend manifests are +// checked first and Node claims the project only when it is the sole manifest. +func (FileDetector) Detect(dir string) (*DetectResult, error) { + for _, detect := range []func(string) *DetectResult{ + detectGo, + detectPython, + detectRuby, + detectJava, + detectSwift, + detectDotnet, + detectNode, + } { + if result := detect(dir); result != nil { + return result, nil + } + } + return nil, errors.New("could not detect project language from directory; try specifying --sdk-id manually") +} + +func detectNode(dir string) *DetectResult { + pkgBytes, err := os.ReadFile(filepath.Join(dir, "package.json")) + if err != nil { + return nil + } + + var pkg struct { + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` + } + if json.Unmarshal(pkgBytes, &pkg) != nil { + return nil + } + + allDeps := make(map[string]string, len(pkg.Dependencies)+len(pkg.DevDependencies)) + for k, v := range pkg.Dependencies { + allDeps[k] = v + } + for k, v := range pkg.DevDependencies { + allDeps[k] = v + } + + pm := detectNodePM(dir) + + // Next.js apps run a Node server (SSR and API routes), so server-side flag + // evaluation uses the Node server SDK rather than a browser client SDK. + if _, ok := allDeps["next"]; ok { + // Entry point: https://nextjs.org/docs/app/guides/instrumentation + // Only the hook is guaranteed to stay out of the browser bundle; a page or + // route module may carry 'use client' and ship the SDK key to the browser. + ep, exists := entryPoint(dir, "instrumentation.ts", + "instrumentation.ts", "instrumentation.js", + "src/instrumentation.ts", "src/instrumentation.js", + ) + return &DetectResult{ + Language: "JavaScript", + Framework: "Next.js", + PackageManager: pm, + SDKID: "node-server", + EntryPoint: ep, + EntryPointExists: exists, + } + } + + if _, ok := allDeps["react-native"]; ok { + // Entry point: https://reactnative.dev/docs/appregistry + ep, exists := entryPoint(dir, "index.js", + "src/App.tsx", "src/App.jsx", "src/App.js", + "src/index.tsx", "src/index.jsx", "src/index.js", + "App.tsx", "App.js", "index.js", + ) + return &DetectResult{ + Language: "JavaScript", + Framework: "React Native", + PackageManager: pm, + SDKID: "react-native", + EntryPoint: ep, + EntryPointExists: exists, + } + } + if _, ok := allDeps["react"]; ok { + // Vite entry: https://vite.dev/guide/#index-html-and-project-root + // CRA entry: https://create-react-app.dev/docs/folder-structure + // Mounting: https://react.dev/reference/react-dom/client/createRoot + ep, exists := entryPoint(dir, "src/App.tsx", + "src/App.tsx", "src/App.jsx", "src/App.js", + "src/main.tsx", "src/main.jsx", + "src/index.tsx", "src/index.jsx", "src/index.js", + "index.js", + ) + return &DetectResult{ + Language: "JavaScript", + Framework: "React", + PackageManager: pm, + SDKID: "react-client-sdk", + EntryPoint: ep, + EntryPointExists: exists, + } + } + jsClientFrameworks := []struct{ dep, framework string }{ + {"backbone", "Backbone"}, + {"svelte", "Svelte"}, + {"vue", "Vue"}, + {"@angular/core", "Angular"}, + {"ember-source", "Ember"}, + {"preact", "Preact"}, + } + for _, fw := range jsClientFrameworks { + if _, ok := allDeps[fw.dep]; ok { + // Vite entry: https://vite.dev/guide/#index-html-and-project-root + // Angular entry: https://angular.dev/reference/configs/file-structure + ep, exists := entryPoint(dir, "src/main.ts", + "src/App.tsx", "src/App.jsx", "src/App.js", + "src/index.tsx", "src/index.jsx", "src/index.js", + "src/main.ts", "src/main.js", "index.js", + ) + return &DetectResult{ + Language: "JavaScript", + Framework: fw.framework, + PackageManager: pm, + SDKID: "js-client-sdk", + EntryPoint: ep, + EntryPointExists: exists, + } + } + } + + // Entry point: https://docs.npmjs.com/cli/v11/configuring-npm/package-json#main + // NestJS bootstraps from src/main.ts: https://docs.nestjs.com/first-steps + ep, exists := entryPoint(dir, "index.js", + "src/index.ts", "src/index.js", + "src/main.ts", "src/main.js", + "index.ts", "index.js", + "server.ts", "server.js", + "app.ts", "app.js", + ) + return &DetectResult{ + Language: "JavaScript", + PackageManager: pm, + SDKID: "node-server", + EntryPoint: ep, + EntryPointExists: exists, + } +} + +func detectNodePM(dir string) string { + if _, err := os.Stat(filepath.Join(dir, "pnpm-lock.yaml")); err == nil { + return "pnpm" + } + if _, err := os.Stat(filepath.Join(dir, "yarn.lock")); err == nil { + return "yarn" + } + // Lockfiles: https://bun.com/docs/install/lockfile + for _, lock := range []string{"bun.lock", "bun.lockb"} { + if _, err := os.Stat(filepath.Join(dir, lock)); err == nil { + return "bun" + } + } + return "npm" +} + +func detectGo(dir string) *DetectResult { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err != nil { + return nil + } + // Entry point: https://go.dev/ref/spec#Program_execution + ep, exists := entryPoint(dir, "main.go", "main.go", "cmd/main.go") + return &DetectResult{ + Language: "Go", + PackageManager: "go", + SDKID: "go-server-sdk", + EntryPoint: ep, + EntryPointExists: exists, + } +} + +func detectPython(dir string) *DetectResult { + for _, indicator := range []string{"requirements.txt", "pyproject.toml", "setup.py", "Pipfile"} { + if _, err := os.Stat(filepath.Join(dir, indicator)); err == nil { + // Django entry: https://docs.djangoproject.com/en/stable/ref/django-admin/ + // Flask entry: https://flask.palletsprojects.com/en/stable/quickstart/ + ep, exists := entryPoint(dir, "main.py", + "src/main.py", "manage.py", "app.py", "main.py", + ) + return &DetectResult{ + Language: "Python", + PackageManager: detectPythonPM(dir), + SDKID: "python-server-sdk", + EntryPoint: ep, + EntryPointExists: exists, + } + } + } + return nil +} + +// detectPythonPM identifies the tool that manages the project's dependencies, so +// callers install into the project rather than running pip against whatever +// interpreter happens to be on PATH. +// +// https://docs.astral.sh/uv/concepts/projects/layout/ +// https://pipenv.pypa.io/en/latest/ +// https://python-poetry.org/docs/pyproject/ +func detectPythonPM(dir string) string { + if _, err := os.Stat(filepath.Join(dir, "uv.lock")); err == nil { + return "uv" + } + if _, err := os.Stat(filepath.Join(dir, "Pipfile")); err == nil { + return "pipenv" + } + if b, err := os.ReadFile(filepath.Join(dir, "pyproject.toml")); err == nil { + if bytes.Contains(b, []byte("[tool.poetry]")) { + return "poetry" + } + if bytes.Contains(b, []byte("[tool.uv]")) { + return "uv" + } + } + return "pip" +} + +func detectRuby(dir string) *DetectResult { + found := false + for _, indicator := range []string{"Gemfile", "Gemfile.lock", "config.ru"} { + if _, err := os.Stat(filepath.Join(dir, indicator)); err == nil { + found = true + break + } + } + if !found { + if matches, _ := filepath.Glob(filepath.Join(dir, "*.gemspec")); len(matches) == 0 { + return nil + } + } + // Gemfile: https://bundler.io/guides/gemfile.html + pm := "gem" + if _, err := os.Stat(filepath.Join(dir, "Gemfile")); err == nil { + pm = "bundle" + } + // config.ru: https://github.com/rack/rack/blob/main/SPEC.rdoc + ep, exists := entryPoint(dir, "main.rb", "config.ru", "app.rb", "main.rb") + return &DetectResult{ + Language: "Ruby", + PackageManager: pm, + SDKID: "ruby-server-sdk", + EntryPoint: ep, + EntryPointExists: exists, + } +} + +func detectJava(dir string) *DetectResult { + for _, indicator := range []string{"pom.xml", "build.gradle", "build.gradle.kts"} { + if _, err := os.Stat(filepath.Join(dir, indicator)); err == nil { + pm := "gradle" + if indicator == "pom.xml" { + pm = "mvn" + } + // Manifest: https://developer.android.com/guide/topics/manifest/manifest-intro + for _, manifest := range []string{ + "app/src/main/AndroidManifest.xml", + "src/main/AndroidManifest.xml", + } { + if _, err := os.Stat(filepath.Join(dir, manifest)); err != nil { + continue + } + // Entry point: https://developer.android.com/reference/android/app/Activity + // The activity lives under a package directory, so search for it + // rather than guessing the package name. + srcRoot := strings.TrimSuffix(manifest, "/AndroidManifest.xml") + ep, exists := entryPoint(dir, srcRoot+"/java/MainActivity.kt", + findFileUnder(dir, srcRoot+"/java", "MainActivity.kt", "MainActivity.java"), + findFileUnder(dir, srcRoot+"/kotlin", "MainActivity.kt"), + ) + return &DetectResult{ + Language: "Java", + PackageManager: "gradle", + SDKID: "android", + EntryPoint: ep, + EntryPointExists: exists, + } + } + // Gradle layout: https://docs.gradle.org/current/userguide/building_java_projects.html + // Maven layout: https://maven.apache.org/guides/introduction/introduction-to-the-pom.html + ep, exists := entryPoint(dir, "src/main/java/Main.java", + findFileUnder(dir, "src/main/java", "Main.java", "Application.java", "App.java"), + ) + return &DetectResult{ + Language: "Java", + PackageManager: pm, + SDKID: "java-server-sdk", + EntryPoint: ep, + EntryPointExists: exists, + } + } + } + return nil +} + +func detectSwift(dir string) *DetectResult { + pm := "spm" + if _, err := os.Stat(filepath.Join(dir, "Podfile")); err == nil { + pm = "cocoapods" + } + swiftEntryPoint := func(appRoot string) (string, bool) { + return entryPoint(dir, "App.swift", swiftEntryCandidates(dir, appRoot)...) + } + indicators := []string{"Package.swift", "Podfile"} + for _, f := range indicators { + if _, err := os.Stat(filepath.Join(dir, f)); err == nil { + ep, exists := swiftEntryPoint(xcodeAppRoot(dir)) + return &DetectResult{ + Language: "Swift", + PackageManager: pm, + SDKID: "swift-client-sdk", + EntryPoint: ep, + EntryPointExists: exists, + } + } + } + if appRoot := xcodeAppRoot(dir); appRoot != "" { + ep, exists := swiftEntryPoint(appRoot) + return &DetectResult{ + Language: "Swift", + PackageManager: pm, + SDKID: "swift-client-sdk", + EntryPoint: ep, + EntryPointExists: exists, + } + } + return nil +} + +// swiftEntryCandidates lists entry-point paths to try for a Swift project, most +// specific first. appRoot is the Xcode app directory, empty when there is no Xcode +// project. Any-name matches are confined to a package with a single target, where +// the entry file is named after that target; with several targets there is no way to +// tell an entry point from a helper. +// +// App struct: https://developer.apple.com/documentation/swiftui/app +// Package targets: https://developer.apple.com/documentation/packagedescription/target +func swiftEntryCandidates(dir, appRoot string) []string { + candidates := []string{ + "App.swift", "ContentView.swift", "AppDelegate.swift", + findFileUnder(dir, appRoot, "*App.swift", "ContentView.swift", "AppDelegate.swift"), + } + // Searching Sources/ at all is confined to a single-target package. Across + // several targets there is no way to tell an executable's entry file from a + // library's, so report a suggestion instead of an arbitrary hit. + if target := soleSubdir(dir, "Sources"); target != "" { + candidates = append(candidates, findFileUnder(dir, target, + "main.swift", filepath.Base(target)+".swift", "*App.swift", "*.swift", + )) + } + return candidates +} + +// soleSubdir returns the path relative to dir of root's only subdirectory, or an +// empty string when root is missing or holds anything other than exactly one. +func soleSubdir(dir, root string) string { + entries, err := os.ReadDir(filepath.Join(dir, root)) + if err != nil { + return "" + } + var found string + for _, e := range entries { + if !e.IsDir() { + continue + } + if found != "" { + return "" + } + found = filepath.Join(root, e.Name()) + } + return found +} + +// xcodeAppRoot returns the source directory an Xcode project keeps its app code in, +// which the templates name after the project (MyApp.xcodeproj alongside MyApp/). +// Returns an empty string when dir holds no Xcode project. +// +// https://developer.apple.com/documentation/xcode/creating-an-xcode-project-for-an-app +func xcodeAppRoot(dir string) string { + matches, _ := filepath.Glob(filepath.Join(dir, "*.xcodeproj")) + if len(matches) == 0 { + return "" + } + return strings.TrimSuffix(filepath.Base(matches[0]), ".xcodeproj") +} + +func detectDotnet(dir string) *DetectResult { + for _, pattern := range []string{"*.csproj", "*.sln"} { + matches, _ := filepath.Glob(filepath.Join(dir, pattern)) + if len(matches) > 0 { + // Entry point: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/startup + ep, exists := entryPoint(dir, "Program.cs", + "Program.cs", "Startup.cs", "src/Program.cs", + ) + return &DetectResult{ + Language: "C#", + PackageManager: "dotnet", + SDKID: "dotnet-server-sdk", + EntryPoint: ep, + EntryPointExists: exists, + } + } + } + return nil +} + +// SDKOption describes a LaunchDarkly SDK available for use with ldcli setup. +type SDKOption struct { + ID string + Language string + Name string +} + +// KnownSDKs is the ordered list of SDKs available for manual selection when +// auto-detection fails or the user wants to override the detected SDK. +var KnownSDKs = []SDKOption{ + {ID: "node-server", Language: "JavaScript", Name: "Node.js"}, + {ID: "react-client-sdk", Language: "JavaScript", Name: "React"}, + {ID: "react-native", Language: "JavaScript", Name: "React Native"}, + {ID: "js-client-sdk", Language: "JavaScript", Name: "JavaScript (Browser)"}, + {ID: "python-server-sdk", Language: "Python", Name: "Python"}, + {ID: "go-server-sdk", Language: "Go", Name: "Go"}, + {ID: "java-server-sdk", Language: "Java", Name: "Java"}, + {ID: "android", Language: "Java", Name: "Android"}, + {ID: "dotnet-server-sdk", Language: "C#", Name: ".NET"}, + {ID: "swift-client-sdk", Language: "Swift", Name: "iOS/Swift"}, + {ID: "ruby-server-sdk", Language: "Ruby", Name: "Ruby"}, +} + +// entryPoint returns the first candidate that exists as a file under dir, joined +// to dir, together with true. When no candidate exists it returns fallback joined +// to dir and false, so callers can tell a file we found from one we suggest. +// Empty candidates are skipped, which lets callers pass the result of a lookup +// that may have come up empty. +func entryPoint(dir, fallback string, candidates ...string) (string, bool) { + for _, c := range candidates { + if c == "" { + continue + } + if info, err := os.Stat(filepath.Join(dir, c)); err == nil && !info.IsDir() { + return filepath.Join(dir, c), true + } + } + return filepath.Join(dir, fallback), false +} + +// findFileUnder walks root (relative to dir) and returns the first file whose base +// name matches one of names, as a path relative to dir. A name may start with "*" +// to match by suffix, so "*App.swift" finds MyAppApp.swift. Names are tried in +// order so callers can express a preference. Returns an empty string when root is +// missing or contains no match. An empty root yields no match rather than walking +// the whole project. +func findFileUnder(dir, root string, names ...string) string { + if root == "" { + return "" + } + matches := func(base, name string) bool { + if suffix, ok := strings.CutPrefix(name, "*"); ok { + return strings.HasSuffix(base, suffix) + } + return base == name + } + for _, name := range names { + var found string + _ = filepath.WalkDir(filepath.Join(dir, root), func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if !d.IsDir() && matches(d.Name(), name) { + found = path + return fs.SkipAll + } + return nil + }) + if found != "" { + if rel, err := filepath.Rel(dir, found); err == nil { + return rel + } + } + } + return "" +} diff --git a/internal/setup/detector_ruby_test.go b/internal/setup/detector_ruby_test.go new file mode 100644 index 000000000..a38349c69 --- /dev/null +++ b/internal/setup/detector_ruby_test.go @@ -0,0 +1,33 @@ +package setup + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFileDetector_DetectsRuby_Gemfile(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Gemfile", "source 'https://rubygems.org'\n") + writeDetectFile(t, dir, "app.rb", "# app\n") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "ruby-server-sdk", result.SDKID) + assert.Equal(t, "Ruby", result.Language) + assert.Equal(t, "bundle", result.PackageManager) + assert.Equal(t, filepath.Join(dir, "app.rb"), result.EntryPoint) +} + +func TestFileDetector_DetectsRuby_Gemspec(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "mygem.gemspec", "Gem::Specification.new\n") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "ruby-server-sdk", result.SDKID) +} diff --git a/internal/setup/detector_shapes_test.go b/internal/setup/detector_shapes_test.go new file mode 100644 index 000000000..56df04ebb --- /dev/null +++ b/internal/setup/detector_shapes_test.go @@ -0,0 +1,385 @@ +package setup + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// projectShape is a real-world project layout reduced to the files detection reads. +// want.EntryPoint is relative to the materialized directory and joined before the +// comparison. +type projectShape struct { + name string + files map[string]string + dirs []string + want DetectResult + wantErr bool +} + +// pkgJSON builds a package.json listing deps as dependencies; a dep prefixed with +// "dev:" goes to devDependencies instead. +func pkgJSON(deps ...string) string { + prod, dev := "", "" + for _, d := range deps { + if name, ok := cutDevPrefix(d); ok { + dev += `"` + name + `":"1.0.0",` + continue + } + prod += `"` + d + `":"1.0.0",` + } + return `{"dependencies":{` + trimComma(prod) + `},"devDependencies":{` + trimComma(dev) + `}}` +} + +func cutDevPrefix(d string) (string, bool) { + if len(d) > 4 && d[:4] == "dev:" { + return d[4:], true + } + return "", false +} + +func trimComma(s string) string { + if s == "" { + return s + } + return s[:len(s)-1] +} + +// TestFileDetector_ProjectShapes asserts the whole DetectResult for each layout, so a +// field the detector stops populating fails here even when the SDK id stays right. +func TestFileDetector_ProjectShapes(t *testing.T) { + shapes := []projectShape{ + // --- JavaScript / Node --- + { + name: "next app router", + files: map[string]string{ + "package.json": pkgJSON("next", "react"), + "next.config.ts": "export default {}", + "app/layout.tsx": "export default function Layout() {}", + "app/page.tsx": "export default function Page() {}", + "tsconfig.json": "{}", + "next-env.d.ts": "", + }, + // A page module may be browser-bundled, which would ship the SDK key. + want: DetectResult{Language: "JavaScript", Framework: "Next.js", PackageManager: "npm", SDKID: "node-server", EntryPoint: "instrumentation.ts"}, + }, + { + name: "next src dir", + files: map[string]string{ + "package.json": pkgJSON("next", "react"), + "src/app/page.tsx": "export default function Page() {}", + "src/app/layout.tsx": "export default function Layout() {}", + }, + want: DetectResult{Language: "JavaScript", Framework: "Next.js", PackageManager: "npm", SDKID: "node-server", EntryPoint: "instrumentation.ts"}, + }, + { + name: "next src instrumentation", + files: map[string]string{ + "package.json": pkgJSON("next"), + "src/instrumentation.ts": "export function register() {}", + "src/app/page.tsx": "export default function Page() {}", + }, + want: DetectResult{Language: "JavaScript", Framework: "Next.js", PackageManager: "npm", SDKID: "node-server", EntryPoint: "src/instrumentation.ts", EntryPointExists: true}, + }, + { + name: "next root instrumentation", + files: map[string]string{ + "package.json": pkgJSON("next"), + "instrumentation.ts": "export function register() {}", + "app/page.tsx": "export default function Page() {}", + }, + want: DetectResult{Language: "JavaScript", Framework: "Next.js", PackageManager: "npm", SDKID: "node-server", EntryPoint: "instrumentation.ts", EntryPointExists: true}, + }, + { + name: "next pages router", + files: map[string]string{"package.json": pkgJSON("next", "react"), "pages/index.tsx": "export default function Home() {}"}, + want: DetectResult{Language: "JavaScript", Framework: "Next.js", PackageManager: "npm", SDKID: "node-server", EntryPoint: "instrumentation.ts"}, + }, + { + name: "next bare", + files: map[string]string{"package.json": pkgJSON("next")}, + want: DetectResult{Language: "JavaScript", Framework: "Next.js", PackageManager: "npm", SDKID: "node-server", EntryPoint: "instrumentation.ts"}, + }, + { + name: "node bun", + files: map[string]string{"package.json": pkgJSON("hono"), "bun.lockb": ""}, + want: DetectResult{Language: "JavaScript", PackageManager: "bun", SDKID: "node-server", EntryPoint: "index.js"}, + }, + { + name: "node npm", + files: map[string]string{"package.json": pkgJSON("express"), "package-lock.json": "{}", "index.js": "// entry"}, + want: DetectResult{Language: "JavaScript", PackageManager: "npm", SDKID: "node-server", EntryPoint: "index.js", EntryPointExists: true}, + }, + { + name: "node pnpm typescript", + files: map[string]string{"package.json": pkgJSON("express"), "pnpm-lock.yaml": "", "src/index.ts": "// entry"}, + want: DetectResult{Language: "JavaScript", PackageManager: "pnpm", SDKID: "node-server", EntryPoint: "src/index.ts", EntryPointExists: true}, + }, + { + name: "nest bootstraps from src/main.ts", + files: map[string]string{"package.json": pkgJSON("@nestjs/core", "@nestjs/common"), "src/main.ts": "bootstrap()"}, + want: DetectResult{Language: "JavaScript", PackageManager: "npm", SDKID: "node-server", EntryPoint: "src/main.ts", EntryPointExists: true}, + }, + { + name: "node prefers src/index over src/main", + files: map[string]string{"package.json": pkgJSON("express"), "src/index.ts": "// entry", "src/main.ts": "// other"}, + want: DetectResult{Language: "JavaScript", PackageManager: "npm", SDKID: "node-server", EntryPoint: "src/index.ts", EntryPointExists: true}, + }, + { + name: "node yarn server file", + files: map[string]string{"package.json": pkgJSON("fastify"), "yarn.lock": "", "server.js": "// entry"}, + want: DetectResult{Language: "JavaScript", PackageManager: "yarn", SDKID: "node-server", EntryPoint: "server.js", EntryPointExists: true}, + }, + { + name: "react vite mount point only", + files: map[string]string{"package.json": pkgJSON("react", "dev:vite"), "src/main.tsx": "createRoot()"}, + want: DetectResult{Language: "JavaScript", Framework: "React", PackageManager: "npm", SDKID: "react-client-sdk", EntryPoint: "src/main.tsx", EntryPointExists: true}, + }, + { + name: "react vite yarn", + files: map[string]string{"package.json": pkgJSON("react", "dev:vite"), "yarn.lock": "", "src/App.tsx": "// App"}, + want: DetectResult{Language: "JavaScript", Framework: "React", PackageManager: "yarn", SDKID: "react-client-sdk", EntryPoint: "src/App.tsx", EntryPointExists: true}, + }, + { + name: "react vite full scaffold prefers App over mount", + files: map[string]string{ + "package.json": pkgJSON("react", "react-dom", "dev:vite", "dev:@vitejs/plugin-react"), + "index.html": "
", + "vite.config.ts": "export default {}", + "src/App.tsx": "// App", + "src/main.tsx": "createRoot()", + "src/index.css": "", + }, + want: DetectResult{Language: "JavaScript", Framework: "React", PackageManager: "npm", SDKID: "react-client-sdk", EntryPoint: "src/App.tsx", EntryPointExists: true}, + }, + { + name: "react native", + files: map[string]string{"package.json": pkgJSON("react", "react-native"), "App.tsx": "// App", "index.js": "AppRegistry.registerComponent()"}, + want: DetectResult{Language: "JavaScript", Framework: "React Native", PackageManager: "npm", SDKID: "react-native", EntryPoint: "App.tsx", EntryPointExists: true}, + }, + { + name: "vue", + files: map[string]string{"package.json": pkgJSON("vue"), "src/main.ts": "createApp()"}, + want: DetectResult{Language: "JavaScript", Framework: "Vue", PackageManager: "npm", SDKID: "js-client-sdk", EntryPoint: "src/main.ts", EntryPointExists: true}, + }, + { + name: "svelte", + files: map[string]string{"package.json": pkgJSON("svelte"), "src/main.ts": "new App()"}, + want: DetectResult{Language: "JavaScript", Framework: "Svelte", PackageManager: "npm", SDKID: "js-client-sdk", EntryPoint: "src/main.ts", EntryPointExists: true}, + }, + + // --- Go --- + { + name: "go single main", + files: map[string]string{"go.mod": "module example.com/app\n\ngo 1.22\n", "main.go": "package main\n"}, + want: DetectResult{Language: "Go", PackageManager: "go", SDKID: "go-server-sdk", EntryPoint: "main.go", EntryPointExists: true}, + }, + { + name: "go single cmd binary", + files: map[string]string{"go.mod": "module example.com/app\n\ngo 1.22\n", "cmd/server/main.go": "package main\n"}, + want: DetectResult{Language: "Go", PackageManager: "go", SDKID: "go-server-sdk", EntryPoint: "main.go"}, + }, + { + name: "go several cmd binaries", + files: map[string]string{ + "go.mod": "module example.com/app\n\ngo 1.22\n", + "cmd/server/main.go": "package main\n", + "cmd/worker/main.go": "package main\n", + }, + want: DetectResult{Language: "Go", PackageManager: "go", SDKID: "go-server-sdk", EntryPoint: "main.go"}, + }, + + // --- Python --- + { + name: "python pipenv", + files: map[string]string{"Pipfile": "[packages]\n", "main.py": "# main"}, + want: DetectResult{Language: "Python", PackageManager: "pipenv", SDKID: "python-server-sdk", EntryPoint: "main.py", EntryPointExists: true}, + }, + { + name: "python poetry", + files: map[string]string{"pyproject.toml": "[tool.poetry]\nname = \"app\"\n", "app.py": "# app"}, + want: DetectResult{Language: "Python", PackageManager: "poetry", SDKID: "python-server-sdk", EntryPoint: "app.py", EntryPointExists: true}, + }, + { + name: "python uv lockfile", + files: map[string]string{"pyproject.toml": "[project]\nname = \"app\"\n", "uv.lock": "version = 1\n"}, + want: DetectResult{Language: "Python", PackageManager: "uv", SDKID: "python-server-sdk", EntryPoint: "main.py"}, + }, + { + name: "python requirements", + files: map[string]string{"requirements.txt": "flask\n", "src/main.py": "# main"}, + want: DetectResult{Language: "Python", PackageManager: "pip", SDKID: "python-server-sdk", EntryPoint: "src/main.py", EntryPointExists: true}, + }, + + // --- Ruby --- + { + name: "ruby bundler rack", + files: map[string]string{"Gemfile": "source 'https://rubygems.org'\n", "Gemfile.lock": "", "config.ru": "run App"}, + want: DetectResult{Language: "Ruby", PackageManager: "bundle", SDKID: "ruby-server-sdk", EntryPoint: "config.ru", EntryPointExists: true}, + }, + { + name: "ruby gemspec only", + files: map[string]string{"mygem.gemspec": "Gem::Specification.new\n"}, + want: DetectResult{Language: "Ruby", PackageManager: "gem", SDKID: "ruby-server-sdk", EntryPoint: "main.rb"}, + }, + + // --- Java / Android --- + { + name: "java maven", + files: map[string]string{"pom.xml": "", "src/main/java/com/example/app/Application.java": "class Application {}"}, + want: DetectResult{Language: "Java", PackageManager: "mvn", SDKID: "java-server-sdk", EntryPoint: "src/main/java/com/example/app/Application.java", EntryPointExists: true}, + }, + { + name: "java gradle", + files: map[string]string{"build.gradle": "plugins { id 'java' }", "src/main/java/com/example/Main.java": "class Main {}"}, + want: DetectResult{Language: "Java", PackageManager: "gradle", SDKID: "java-server-sdk", EntryPoint: "src/main/java/com/example/Main.java", EntryPointExists: true}, + }, + { + name: "android app module kotlin", + files: map[string]string{ + "build.gradle.kts": "plugins { id(\"com.android.application\") }", + "settings.gradle.kts": "", + "app/src/main/AndroidManifest.xml": "", + "app/src/main/java/com/example/myapp/MainActivity.kt": "class MainActivity", + }, + want: DetectResult{Language: "Java", PackageManager: "gradle", SDKID: "android", EntryPoint: "app/src/main/java/com/example/myapp/MainActivity.kt", EntryPointExists: true}, + }, + { + name: "android single module java", + files: map[string]string{ + "build.gradle": "plugins { id 'com.android.application' }", + "src/main/AndroidManifest.xml": "", + "src/main/java/com/example/MainActivity.java": "class MainActivity {}", + }, + want: DetectResult{Language: "Java", PackageManager: "gradle", SDKID: "android", EntryPoint: "src/main/java/com/example/MainActivity.java", EntryPointExists: true}, + }, + + // --- Swift --- + { + name: "swift package single target", + files: map[string]string{"Package.swift": "// swift-tools-version:5.9", "Sources/MyTool/MyTool.swift": "print(1)", "Tests/MyToolTests/MyToolTests.swift": ""}, + want: DetectResult{Language: "Swift", PackageManager: "spm", SDKID: "swift-client-sdk", EntryPoint: "Sources/MyTool/MyTool.swift", EntryPointExists: true}, + }, + { + name: "swift package sources without target dir", + files: map[string]string{"Package.swift": "// swift-tools-version:5.9", "Sources/main.swift": "print(1)"}, + want: DetectResult{Language: "Swift", PackageManager: "spm", SDKID: "swift-client-sdk", EntryPoint: "App.swift"}, + }, + { + name: "swift package several targets", + files: map[string]string{ + "Package.swift": "// swift-tools-version:5.9", + "Sources/Alpha/Helper.swift": "struct Helper {}", + "Sources/Beta/main.swift": "print(1)", + }, + want: DetectResult{Language: "Swift", PackageManager: "spm", SDKID: "swift-client-sdk", EntryPoint: "App.swift"}, + }, + { + name: "swift xcode project", + files: map[string]string{"MyApp/MyAppApp.swift": "@main struct MyAppApp {}", "MyApp/ContentView.swift": "struct ContentView {}"}, + dirs: []string{"MyApp.xcodeproj"}, + want: DetectResult{Language: "Swift", PackageManager: "spm", SDKID: "swift-client-sdk", EntryPoint: "MyApp/MyAppApp.swift", EntryPointExists: true}, + }, + { + name: "swift cocoapods", + files: map[string]string{"Podfile": "platform :ios, '14.0'"}, + want: DetectResult{Language: "Swift", PackageManager: "cocoapods", SDKID: "swift-client-sdk", EntryPoint: "App.swift"}, + }, + + // --- C# --- + { + name: "dotnet csproj", + files: map[string]string{"MyApp.csproj": "", "Program.cs": "// entry"}, + want: DetectResult{Language: "C#", PackageManager: "dotnet", SDKID: "dotnet-server-sdk", EntryPoint: "Program.cs", EntryPointExists: true}, + }, + { + name: "dotnet solution with nested project", + files: map[string]string{"MyApp.sln": "", "src/MyApp/MyApp.csproj": "", "src/MyApp/Program.cs": "// entry"}, + want: DetectResult{Language: "C#", PackageManager: "dotnet", SDKID: "dotnet-server-sdk", EntryPoint: "Program.cs"}, + }, + + // --- Polyglot: a root package.json is usually build tooling --- + { + name: "rails with jsbundling", + files: map[string]string{ + "Gemfile": "source 'https://rubygems.org'\ngem 'rails'\n", + "config.ru": "run Rails.application", + "package.json": pkgJSON("esbuild"), + }, + want: DetectResult{Language: "Ruby", PackageManager: "bundle", SDKID: "ruby-server-sdk", EntryPoint: "config.ru", EntryPointExists: true}, + }, + { + name: "django with tailwind", + files: map[string]string{ + "requirements.txt": "Django==5.0\n", + "manage.py": "# manage", + "package.json": pkgJSON("dev:tailwindcss"), + }, + want: DetectResult{Language: "Python", PackageManager: "pip", SDKID: "python-server-sdk", EntryPoint: "manage.py", EntryPointExists: true}, + }, + { + name: "go binary published to npm", + files: map[string]string{ + "go.mod": "module example.com/app\n\ngo 1.22\n", + "main.go": "package main\n", + "package.json": `{"name":"app-cli"}`, + }, + want: DetectResult{Language: "Go", PackageManager: "go", SDKID: "go-server-sdk", EntryPoint: "main.go", EntryPointExists: true}, + }, + { + name: "next app carrying a Gemfile", + files: map[string]string{ + "package.json": pkgJSON("next"), + "Gemfile": "source 'https://rubygems.org'\ngem 'rubocop'\n", + }, + // Accepted cost of preferring the backend manifest. The wizard lets the + // user override the SDK, and --sdk-id exists. + want: DetectResult{Language: "Ruby", PackageManager: "bundle", SDKID: "ruby-server-sdk", EntryPoint: "main.rb"}, + }, + { + name: "next app carrying a ruff config", + files: map[string]string{ + "package.json": pkgJSON("next"), + "pyproject.toml": "[tool.ruff]\nline-length = 100\n", + }, + want: DetectResult{Language: "Python", PackageManager: "pip", SDKID: "python-server-sdk", EntryPoint: "main.py"}, + }, + + // --- No manifest at all --- + {name: "empty directory", wantErr: true}, + {name: "malformed package.json", files: map[string]string{"package.json": "not json {{{"}, wantErr: true}, + } + + for _, shape := range shapes { + t.Run(shape.name, func(t *testing.T) { + dir := materialize(t, shape) + + result, err := FileDetector{}.Detect(dir) + + if shape.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "could not detect") + return + } + require.NoError(t, err) + want := shape.want + want.EntryPoint = filepath.Join(dir, want.EntryPoint) + assert.Equal(t, want, *result) + }) + } +} + +func materialize(t *testing.T, shape projectShape) string { + t.Helper() + dir := t.TempDir() + for _, d := range shape.dirs { + require.NoError(t, os.MkdirAll(filepath.Join(dir, d), 0755)) + } + for name, content := range shape.files { + writeDetectFile(t, dir, name, content) + } + return dir +} diff --git a/internal/setup/detector_test.go b/internal/setup/detector_test.go new file mode 100644 index 000000000..cb48d976c --- /dev/null +++ b/internal/setup/detector_test.go @@ -0,0 +1,794 @@ +package setup + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writeDetectFile writes content to a file in dir, creating parent directories as needed. +func writeDetectFile(t *testing.T, dir, name, content string) { + t.Helper() + path := filepath.Join(dir, name) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0600)) +} + +func TestFileDetector_DetectsReact(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0"}}`) + writeDetectFile(t, dir, "src/App.tsx", "// App") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "react-client-sdk", result.SDKID) + assert.Equal(t, "JavaScript", result.Language) + assert.Equal(t, "React", result.Framework) + assert.Equal(t, "npm", result.PackageManager) + assert.Equal(t, filepath.Join(dir, "src/App.tsx"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestFileDetector_DetectsReactNative(t *testing.T) { + dir := t.TempDir() + // React Native projects always list both "react" and "react-native" as deps; + // react-native must be checked first so it takes priority over react. + writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0","react-native":"^0.73.0"}}`) + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "react-native", result.SDKID) + assert.Equal(t, "JavaScript", result.Language) + assert.Equal(t, "React Native", result.Framework) +} + +func TestFileDetector_DetectsNextJs(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0","next":"^14.0.0"}}`) + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "node-server", result.SDKID) + assert.Equal(t, "JavaScript", result.Language) + assert.Equal(t, "Next.js", result.Framework) +} + +func TestFileDetector_DetectsNodeJs(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"express":"^4.0.0"}}`) + writeDetectFile(t, dir, "index.js", "// entry") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "node-server", result.SDKID) + assert.Equal(t, "JavaScript", result.Language) + assert.Empty(t, result.Framework) + assert.Equal(t, filepath.Join(dir, "index.js"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestFileDetector_DetectsGo(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "go.mod", "module example.com/myapp\n\ngo 1.21\n") + writeDetectFile(t, dir, "main.go", "package main\n") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "go-server-sdk", result.SDKID) + assert.Equal(t, "Go", result.Language) + assert.Equal(t, "go", result.PackageManager) + assert.Equal(t, filepath.Join(dir, "main.go"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestFileDetector_DetectsPython_RequirementsTxt(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "requirements.txt", "flask==3.0.0\n") + writeDetectFile(t, dir, "app.py", "# app") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "python-server-sdk", result.SDKID) + assert.Equal(t, "Python", result.Language) + assert.Equal(t, "pip", result.PackageManager) + assert.Equal(t, filepath.Join(dir, "app.py"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestFileDetector_DetectsPython_Pyproject(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "pyproject.toml", "[tool.poetry]\nname = \"myapp\"\n") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "python-server-sdk", result.SDKID) +} + +func TestFileDetector_DetectsJava_PomXml(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "pom.xml", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "java-server-sdk", result.SDKID) + assert.Equal(t, "Java", result.Language) + assert.Equal(t, "mvn", result.PackageManager) +} + +func TestFileDetector_DetectsJava_BuildGradle(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "build.gradle", "plugins { id 'java' }") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "java-server-sdk", result.SDKID) + assert.Equal(t, "gradle", result.PackageManager) +} + +func TestFileDetector_DetectsAndroid_BuildGradle(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "build.gradle", "plugins { id 'com.android.application' }") + writeDetectFile(t, dir, "app/src/main/AndroidManifest.xml", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "android", result.SDKID) + assert.Equal(t, "Java", result.Language) + assert.Equal(t, "gradle", result.PackageManager) +} + +func TestFileDetector_DetectsAndroid_KotlinDsl(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "build.gradle.kts", "plugins { id(\"com.android.application\") }") + writeDetectFile(t, dir, "app/src/main/AndroidManifest.xml", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "android", result.SDKID) + assert.Equal(t, "gradle", result.PackageManager) +} + +func TestFileDetector_DetectsJava_NotAndroid(t *testing.T) { + // build.gradle without AndroidManifest.xml should still return java-server-sdk + dir := t.TempDir() + writeDetectFile(t, dir, "build.gradle", "plugins { id 'java' }") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "java-server-sdk", result.SDKID) +} + +func TestFileDetector_UnknownProject_ReturnsError(t *testing.T) { + dir := t.TempDir() + + _, err := FileDetector{}.Detect(dir) + + require.Error(t, err) + assert.Contains(t, err.Error(), "could not detect") +} + +func TestFileDetector_DetectsNodePM_Pnpm(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{}`) + writeDetectFile(t, dir, "pnpm-lock.yaml", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "pnpm", result.PackageManager) +} + +func TestFileDetector_DetectsNodePM_Yarn(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{}`) + writeDetectFile(t, dir, "yarn.lock", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "yarn", result.PackageManager) +} + +func TestFileDetector_DetectsNodePM_Bun(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{}`) + writeDetectFile(t, dir, "bun.lock", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "bun", result.PackageManager) +} + +func TestFileDetector_DetectsJsClientFramework(t *testing.T) { + tests := []struct { + dep string + framework string + }{ + {"vue", "Vue"}, + {"svelte", "Svelte"}, + {"backbone", "Backbone"}, + {"@angular/core", "Angular"}, + {"ember-source", "Ember"}, + {"preact", "Preact"}, + } + for _, tt := range tests { + t.Run(tt.framework, func(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"`+tt.dep+`":"^1.0.0"}}`) + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "js-client-sdk", result.SDKID) + assert.Equal(t, tt.framework, result.Framework) + }) + } +} + +func TestFileDetector_DetectsSwift_PackageSwift(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "swift-client-sdk", result.SDKID) + assert.Equal(t, "Swift", result.Language) + assert.Equal(t, "spm", result.PackageManager) +} + +func TestFileDetector_DetectsSwift_Podfile(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Podfile", "platform :ios, '14.0'") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "swift-client-sdk", result.SDKID) + assert.Equal(t, "cocoapods", result.PackageManager) +} + +func TestFileDetector_DetectsSwift_XcodeProj(t *testing.T) { + dir := t.TempDir() + // .xcodeproj is a directory in practice, but we use Glob so creating the dir is enough + require.NoError(t, os.MkdirAll(filepath.Join(dir, "MyApp.xcodeproj"), 0755)) + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "swift-client-sdk", result.SDKID) + assert.Equal(t, "Swift", result.Language) +} + +func TestFileDetector_DetectsDotnet_Csproj(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "MyApp.csproj", "") + writeDetectFile(t, dir, "Program.cs", "// entry") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "dotnet-server-sdk", result.SDKID) + assert.Equal(t, "C#", result.Language) + assert.Equal(t, "dotnet", result.PackageManager) + assert.Equal(t, filepath.Join(dir, "Program.cs"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestFileDetector_DetectsDotnet_Sln(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "MyApp.sln", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "dotnet-server-sdk", result.SDKID) + assert.Equal(t, "dotnet", result.PackageManager) +} + +func TestKnownSDKs_ContainsExpectedSDKs(t *testing.T) { + ids := make([]string, len(KnownSDKs)) + for i, sdk := range KnownSDKs { + ids[i] = sdk.ID + } + assert.Contains(t, ids, "node-server") + assert.Contains(t, ids, "react-client-sdk") + assert.Contains(t, ids, "react-native") + assert.Contains(t, ids, "python-server-sdk") + assert.Contains(t, ids, "go-server-sdk") + assert.Contains(t, ids, "java-server-sdk") + assert.Contains(t, ids, "dotnet-server-sdk") + assert.Contains(t, ids, "swift-client-sdk") + assert.Contains(t, ids, "ruby-server-sdk") +} + +func TestFileDetector_EntryPointFallback_WhenNoneExist(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0"}}`) + // No src/App.tsx or other entry point files + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "src/App.tsx"), result.EntryPoint) + assert.False(t, result.EntryPointExists, "a suggested path must not look like one we found") +} + +func TestFileDetector_MalformedPackageJSON_FallsThrough(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `not valid json {{{`) + // No other project indicators + + _, err := FileDetector{}.Detect(dir) + + // detectNode skips invalid JSON; no other indicators → error + require.Error(t, err) + assert.Contains(t, err.Error(), "could not detect") +} + +// A page module may carry 'use client' or be imported by something that does, which +// would ship the server SDK key to the browser, so never target one. +func TestFileDetector_NextJs_AppRouter_SuggestsInstrumentation(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0","next":"^15.0.0"}}`) + writeDetectFile(t, dir, "app/page.tsx", "export default function Page() {}") + writeDetectFile(t, dir, "app/layout.tsx", "export default function Layout() {}") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "node-server", result.SDKID) + assert.Equal(t, filepath.Join(dir, "instrumentation.ts"), result.EntryPoint) + assert.False(t, result.EntryPointExists) +} + +func TestFileDetector_NextJs_PrefersExistingInstrumentation(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"next":"^15.0.0"}}`) + writeDetectFile(t, dir, "instrumentation.ts", "export function register() {}") + writeDetectFile(t, dir, "app/page.tsx", "export default function Page() {}") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "instrumentation.ts"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestFileDetector_NextJs_PagesRouter_SuggestsInstrumentation(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0","next":"^13.0.0"}}`) + writeDetectFile(t, dir, "pages/index.tsx", "export default function Home() {}") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + // pages/* is bundled for the browser, so it is never a server SDK target. + assert.Equal(t, filepath.Join(dir, "instrumentation.ts"), result.EntryPoint) + assert.False(t, result.EntryPointExists) +} + +func TestFileDetector_NextJs_Empty_SuggestsInstrumentation(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"next":"^15.0.0"}}`) + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "instrumentation.ts"), result.EntryPoint) + assert.False(t, result.EntryPointExists) +} + +func TestFileDetector_Android_FindsKotlinActivityInPackageDir(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "build.gradle.kts", "plugins { id(\"com.android.application\") }") + writeDetectFile(t, dir, "app/src/main/AndroidManifest.xml", "") + writeDetectFile(t, dir, "app/src/main/java/com/example/myapp/MainActivity.kt", "class MainActivity") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "android", result.SDKID) + assert.Equal(t, filepath.Join(dir, "app/src/main/java/com/example/myapp/MainActivity.kt"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestFileDetector_Android_KotlinSourceRoot(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "build.gradle.kts", "plugins { id(\"com.android.application\") }") + writeDetectFile(t, dir, "app/src/main/AndroidManifest.xml", "") + writeDetectFile(t, dir, "app/src/main/kotlin/com/example/MainActivity.kt", "class MainActivity") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "app/src/main/kotlin/com/example/MainActivity.kt"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestFileDetector_Android_NoAppModule_UsesMatchedSourceRoot(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "build.gradle", "plugins { id 'com.android.application' }") + writeDetectFile(t, dir, "src/main/AndroidManifest.xml", "") + writeDetectFile(t, dir, "src/main/java/com/example/MainActivity.java", "class MainActivity {}") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + // The old code hardcoded app/src/main/... even for this single-module layout. + assert.Equal(t, filepath.Join(dir, "src/main/java/com/example/MainActivity.java"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestFileDetector_Android_NoActivity_SuggestsUnderMatchedRoot(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "build.gradle", "plugins { id 'com.android.application' }") + writeDetectFile(t, dir, "src/main/AndroidManifest.xml", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "src/main/java/MainActivity.kt"), result.EntryPoint) + assert.False(t, result.EntryPointExists) +} + +func TestFileDetector_Java_FindsMainInPackageDir(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "pom.xml", "") + writeDetectFile(t, dir, "src/main/java/com/example/app/Application.java", "class Application {}") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "java-server-sdk", result.SDKID) + assert.Equal(t, filepath.Join(dir, "src/main/java/com/example/app/Application.java"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestFileDetector_Ruby_GemfileReportsBundler(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Gemfile", "source 'https://rubygems.org'\n") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "bundle", result.PackageManager) +} + +func TestFileDetector_Ruby_NoGemfileReportsGem(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "mygem.gemspec", "Gem::Specification.new\n") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "gem", result.PackageManager) +} + +func TestFileDetector_PythonPackageManagers(t *testing.T) { + tests := []struct { + name string + files map[string]string + want string + }{ + {"pip", map[string]string{"requirements.txt": "flask\n"}, "pip"}, + {"poetry", map[string]string{"pyproject.toml": "[tool.poetry]\nname = \"myapp\"\n"}, "poetry"}, + {"uv lockfile", map[string]string{"pyproject.toml": "[project]\nname = \"myapp\"\n", "uv.lock": "version = 1\n"}, "uv"}, + {"uv section", map[string]string{"pyproject.toml": "[project]\nname = \"a\"\n[tool.uv]\n"}, "uv"}, + {"pipenv", map[string]string{"Pipfile": "[packages]\n"}, "pipenv"}, + {"bare pyproject", map[string]string{"pyproject.toml": "[project]\nname = \"myapp\"\n"}, "pip"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + for name, content := range tt.files { + writeDetectFile(t, dir, name, content) + } + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "python-server-sdk", result.SDKID) + assert.Equal(t, tt.want, result.PackageManager) + }) + } +} + +func TestFileDetector_DetectsNodePM_BunBinaryLockfile(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{}`) + writeDetectFile(t, dir, "bun.lockb", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "bun", result.PackageManager) +} + +func TestKnownSDKs_UsesAndroidID(t *testing.T) { + ids := make([]string, len(KnownSDKs)) + for i, sdk := range KnownSDKs { + ids[i] = sdk.ID + } + assert.Contains(t, ids, "android") + assert.NotContains(t, ids, "android-client-sdk") +} + +func TestEntryPoint_NoCandidateExists_ReturnsFallback(t *testing.T) { + dir := t.TempDir() + + got, exists := entryPoint(dir, "fallback.go", "nonexistent.go", "also-nonexistent.go") + + assert.Equal(t, filepath.Join(dir, "fallback.go"), got) + assert.False(t, exists) +} + +func TestEntryPoint_MatchesFirstExisting(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "second.go", "") + writeDetectFile(t, dir, "first.go", "") + + got, exists := entryPoint(dir, "fallback.go", "first.go", "second.go") + + assert.Equal(t, filepath.Join(dir, "first.go"), got) + assert.True(t, exists) +} + +func TestEntryPoint_SkipsEmptyAndDirectoryCandidates(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "src"), 0755)) + writeDetectFile(t, dir, "real.go", "") + + got, exists := entryPoint(dir, "fallback.go", "", "src", "real.go") + + assert.Equal(t, filepath.Join(dir, "real.go"), got) + assert.True(t, exists) +} + +func TestFindFileUnder(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "src/main/java/com/example/App.java", "") + + assert.Equal(t, filepath.Join("src/main/java/com/example/App.java"), + findFileUnder(dir, "src/main/java", "Main.java", "App.java")) + assert.Empty(t, findFileUnder(dir, "src/main/java", "Missing.java")) + assert.Empty(t, findFileUnder(dir, "does/not/exist", "App.java")) +} + +// Multi-binary repos have no single entry point, so the detector must not pick one +// of them arbitrarily and report it as found. +func TestFileDetector_Go_MultipleBinaries_Suggests(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "go.mod", "module example.com/app\n\ngo 1.22\n") + writeDetectFile(t, dir, "cmd/server/main.go", "package main\n") + writeDetectFile(t, dir, "cmd/worker/main.go", "package main\n") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "main.go"), result.EntryPoint) + assert.False(t, result.EntryPointExists) +} + +// An entry file named after the module, as ld-relay and gonfalon do, is not something +// we can guess at either. +func TestFileDetector_Go_ModuleNamedEntryFile_Suggests(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "go.mod", "module github.com/launchdarkly/ld-relay/v8\n\ngo 1.22\n") + writeDetectFile(t, dir, "ld-relay.go", "package main\n") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.False(t, result.EntryPointExists) +} + +func TestFileDetector_Swift_NestedSourcesTarget(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9") + // `swift package init` names the file after the target, not main.swift. + writeDetectFile(t, dir, "Sources/MyTool/MyTool.swift", "print(1)") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "Sources/MyTool/MyTool.swift"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestFileDetector_Swift_PrefersMainSwiftInSources(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9") + writeDetectFile(t, dir, "Sources/MyTool/Helper.swift", "") + writeDetectFile(t, dir, "Sources/MyTool/main.swift", "print(1)") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "Sources/MyTool/main.swift"), result.EntryPoint) +} + +func TestFileDetector_Swift_XcodeAppNamedDirectory(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "MyApp.xcodeproj"), 0755)) + // Xcode's SwiftUI template puts the app code in a directory named after the project. + writeDetectFile(t, dir, "MyApp/MyAppApp.swift", "@main struct MyAppApp {}") + writeDetectFile(t, dir, "MyApp/ContentView.swift", "struct ContentView {}") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "MyApp/MyAppApp.swift"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestFileDetector_Swift_NoSources_Suggests(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "App.swift"), result.EntryPoint) + assert.False(t, result.EntryPointExists) +} + +func TestFileDetector_React_ViteMountPoint(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0"}}`) + // Vite scaffolds src/main.tsx; without App.tsx the old list fell through to a + // nonexistent src/App.tsx even though the mount point was right there. + writeDetectFile(t, dir, "src/main.tsx", "createRoot()") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "src/main.tsx"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestFindFileUnder_SuffixPattern(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "MyApp/MyAppApp.swift", "") + + assert.Equal(t, filepath.Join("MyApp/MyAppApp.swift"), findFileUnder(dir, "MyApp", "*App.swift")) + assert.Empty(t, findFileUnder(dir, "MyApp", "*.kt")) +} + +// An empty root must not walk the whole project. +func TestFindFileUnder_EmptyRoot(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "deep/nested/App.swift", "") + + assert.Empty(t, findFileUnder(dir, "", "App.swift")) +} + +// With several targets there is no way to tell an entry point from a helper, so the +// detector must not present an arbitrary pick as found. +func TestFileDetector_Swift_MultipleTargets_Suggests(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9") + writeDetectFile(t, dir, "Sources/Alpha/Helper.swift", "struct Helper {}") + writeDetectFile(t, dir, "Sources/Beta/Beta.swift", "@main struct Beta {}") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "App.swift"), result.EntryPoint) + assert.False(t, result.EntryPointExists) +} + +func TestFileDetector_Swift_SingleTarget_PrefersTargetNamedFile(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9") + // Helper.swift sorts first, but MyTool.swift is the entry file. + writeDetectFile(t, dir, "Sources/MyTool/Helper.swift", "struct Helper {}") + writeDetectFile(t, dir, "Sources/MyTool/MyTool.swift", "@main struct MyTool {}") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "Sources/MyTool/MyTool.swift"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} + +func TestSoleSubdir(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "one/Alpha/a.swift", "") + writeDetectFile(t, dir, "two/Alpha/a.swift", "") + writeDetectFile(t, dir, "two/Beta/b.swift", "") + writeDetectFile(t, dir, "files/a.swift", "") + + assert.Equal(t, filepath.Join("one/Alpha"), soleSubdir(dir, "one")) + assert.Empty(t, soleSubdir(dir, "two"), "two subdirectories is ambiguous") + assert.Empty(t, soleSubdir(dir, "files"), "files are not targets") + assert.Empty(t, soleSubdir(dir, "missing")) +} + +// A root package.json is often only build tooling, so a backend manifest wins. +func TestFileDetector_Polyglot_BackendManifestWins(t *testing.T) { + tests := []struct { + name string + files map[string]string + wantSDK string + }{ + {"rails with jsbundling", map[string]string{ + "Gemfile": "source 'https://rubygems.org'\ngem 'rails'\n", "package.json": `{"dependencies":{"esbuild":"0.20.0"}}`, + }, "ruby-server-sdk"}, + {"django with tailwind", map[string]string{ + "requirements.txt": "Django==5.0\n", "package.json": `{"devDependencies":{"tailwindcss":"3.4.0"}}`, + }, "python-server-sdk"}, + {"go binary published to npm", map[string]string{ + "go.mod": "module example.com/app\n\ngo 1.22\n", "package.json": `{"name":"app-cli"}`, + }, "go-server-sdk"}, + {"dotnet with npm assets", map[string]string{ + "App.csproj": "", "package.json": `{"devDependencies":{"vite":"5.0.0"}}`, + }, "dotnet-server-sdk"}, + // package.json is the only manifest, so Node still claims it. + {"plain next.js", map[string]string{ + "package.json": `{"dependencies":{"next":"15.0.0"}}`, + }, "node-server"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + for name, content := range tt.files { + writeDetectFile(t, dir, name, content) + } + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, tt.wantSDK, result.SDKID) + }) + } +} + +// Searching Sources/ is confined to single-target packages, so neither main.swift +// nor a *App.swift in one of several targets may be reported as found. +func TestFileDetector_Swift_MultipleTargets_NeverReportsFound(t *testing.T) { + for _, entry := range []string{"Sources/Beta/main.swift", "Sources/Zeta/ZetaApp.swift", "Sources/Beta/Beta.swift"} { + t.Run(entry, func(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9") + writeDetectFile(t, dir, "Sources/Alpha/Helper.swift", "struct Helper {}") + writeDetectFile(t, dir, entry, "// entry") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "App.swift"), result.EntryPoint) + assert.False(t, result.EntryPointExists) + }) + } +} + +func TestFileDetector_Swift_SingleTarget_PrefersMainSwift(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9") + writeDetectFile(t, dir, "Sources/MyTool/Helper.swift", "") + writeDetectFile(t, dir, "Sources/MyTool/MyTool.swift", "") + writeDetectFile(t, dir, "Sources/MyTool/main.swift", "print(1)") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "Sources/MyTool/main.swift"), result.EntryPoint) + assert.True(t, result.EntryPointExists) +} diff --git a/internal/setup/initializer.go b/internal/setup/initializer.go new file mode 100644 index 000000000..969f40dca --- /dev/null +++ b/internal/setup/initializer.go @@ -0,0 +1,534 @@ +package setup + +import ( + "bytes" + "embed" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "text/template" +) + +//go:embed sdk_init_templates/*.tmpl +var initTemplateFiles embed.FS + +// InitConfig holds the values to interpolate into SDK initialization templates. +type InitConfig struct { + SDKKey string + ClientSideID string + MobileKey string + FlagKey string +} + +// InitResult describes the outcome of injecting SDK initialization code. +// +// Success is true only when initialization code was actually written to a file +// as valid, ready-to-run code. When Success is false, Snippet (if set) holds the +// rendered code the user must place manually, and DocsURL points at the setup +// guide. +type InitResult struct { + SDKID string `json:"sdk_id"` + FilePath string `json:"file_path,omitempty"` + DocsURL string `json:"docs_url,omitempty"` + Snippet string `json:"snippet,omitempty"` + // 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 +// top-to-bottom, so initialization statements can be appended at file scope and +// still run. For every other SDK — compiled/scoped languages (Go, Java, C#, +// Swift, Android) whose statements are illegal at file scope, and framework SDKs +// (React, React Native) that must be wired into a component tree — appending +// produces code that does not compile or does not run, so we return the snippet +// as guidance instead of writing a broken file. +var appendSafeSDKs = map[string]bool{ + "node-server": true, + "python-server-sdk": true, + "ruby-server-sdk": true, +} + +// defaultEntryPoints names the file to create for an SDK when there is no detected +// entry point to write into. Only the append-safe SDKs need one, since every other +// SDK returns a snippet and never touches the filesystem. The names match the +// fallbacks detection already suggests for these languages. +var defaultEntryPoints = map[string]string{ + "node-server": "index.js", + "python-server-sdk": "main.py", + "ruby-server-sdk": "main.rb", +} + +// DefaultEntryPoint returns the file to create for sdkID when no entry point was +// detected for it, or an empty string when the SDK does not write to disk. +func DefaultEntryPoint(sdkID string) string { + return defaultEntryPoints[sdkID] +} + +// Initializer injects SDK initialization code into a target file. +type Initializer struct{} + +// sdkTemplateInfo maps an SDK ID to the template filename. +type sdkTemplateInfo struct { + TemplateFile string + // ESMTemplateFile renders the same initialization with ESM import syntax, for + // entry points where a CommonJS require would not run. Empty for SDKs whose + // language has no module-system split. + ESMTemplateFile string +} + +var sdkTemplates = map[string]sdkTemplateInfo{ + "react-client-sdk": {TemplateFile: "react-client-sdk.tmpl"}, + "react-native": {TemplateFile: "react-native.tmpl"}, + "js-client-sdk": {TemplateFile: "js-client-sdk.tmpl"}, + "swift-client-sdk": {TemplateFile: "swift-client-sdk.tmpl"}, + "android": {TemplateFile: "android.tmpl"}, + "android-client-sdk": {TemplateFile: "android.tmpl"}, + "java-server-sdk": {TemplateFile: "java-server-sdk.tmpl"}, + "ruby-server-sdk": {TemplateFile: "ruby-server-sdk.tmpl"}, + "go-server-sdk": {TemplateFile: "go-server-sdk.tmpl"}, + "python-server-sdk": {TemplateFile: "python-server-sdk.tmpl"}, + "dotnet-server-sdk": {TemplateFile: "dotnet-server-sdk.tmpl"}, + "node-server": {TemplateFile: "node-server.tmpl", ESMTemplateFile: "node-server-esm.tmpl"}, +} + +// sdkDocsPaths maps SDK IDs to their documentation path on launchdarkly.com/docs. +// Covers all SDKs, including those without init templates. +var sdkDocsPaths = map[string]string{ + "akamai-server-edgekv-sdk": "sdk/edge/akamai", + "android": "sdk/client-side/android", + "android-client-sdk": "sdk/client-side/android", + "apex-server-sdk": "sdk/server-side/apex", + "cpp-client-sdk": "sdk/client-side/c-c--", + "cpp-server-sdk": "sdk/server-side/c-c--", + "cloudflare-server-sdk": "sdk/edge/cloudflare", + "dotnet-client-sdk": "sdk/client-side/dotnet", + "dotnet-server-sdk": "sdk/server-side/dotnet", + "electron-client-sdk": "sdk/client-side/electron", + "erlang-server-sdk": "sdk/server-side/erlang", + "flutter-client-sdk": "sdk/client-side/flutter", + "go-server-sdk": "sdk/server-side/go", + "haskell-server-sdk": "sdk/server-side/haskell", + "ios-client-sdk": "sdk/client-side/ios", + "swift-client-sdk": "sdk/client-side/ios", + "java-server-sdk": "sdk/server-side/java", + "js-client-sdk": "sdk/client-side/javascript", + "lua-server-sdk": "sdk/server-side/lua", + "node-client-sdk": "sdk/client-side/node-js", + "node-server": "sdk/server-side/node-js", + "node-server-sdk": "sdk/server-side/node-js", + "php-server-sdk": "sdk/server-side/php", + "python-server-sdk": "sdk/server-side/python", + "react-client-sdk": "sdk/client-side/react", + "react-native": "sdk/client-side/react-native", + "react-native-client-sdk": "sdk/client-side/react-native", + "roku-client-sdk": "sdk/client-side/roku", + "ruby-server-sdk": "sdk/server-side/ruby", + "rust-server-sdk": "sdk/server-side/rust", + "vercel-server-sdk": "sdk/edge/vercel", + "vue-client-sdk": "sdk/client-side/vue", +} + +const docsBaseURL = "https://launchdarkly.com/docs" + +// GetDocsURL returns the full documentation URL for the given SDK ID. +// Falls back to the top-level SDK docs page if the ID is unknown. +func GetDocsURL(sdkID string) string { + if path, ok := sdkDocsPaths[sdkID]; ok { + return docsBaseURL + "/" + path + } + return docsBaseURL + "/sdk" +} + +// SupportedSDKIDs returns the list of SDK IDs that have initialization templates. +func SupportedSDKIDs() []string { + ids := make([]string, 0, len(sdkTemplates)) + for id := range sdkTemplates { + ids = append(ids, id) + } + return ids +} + +// HasTemplate returns true if the given SDK ID has an initialization template. +func HasTemplate(sdkID string) bool { + _, ok := sdkTemplates[sdkID] + return ok +} + +// InjectsInPlace reports whether `init` writes runnable code directly into the +// entry file (true) versus returning a snippet for the user to place manually +// (false). Also indicates whether a live verify step is meaningful afterward. +func InjectsInPlace(sdkID string) bool { + return HasTemplate(sdkID) && appendSafeSDKs[sdkID] +} + +// 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. +func RenderTemplate(sdkID string, cfg InitConfig) (string, error) { + return renderTemplate(sdkID, cfg, false) +} + +// RenderTemplateForEntry renders the initialization code for the given SDK in the +// module syntax that runs in entryPath. +func RenderTemplateForEntry(sdkID, entryPath string, cfg InitConfig) (string, error) { + return renderTemplate(sdkID, cfg, entryNeedsESM(entryPath)) +} + +func renderTemplate(sdkID string, cfg InitConfig, esm bool) (string, error) { + info, ok := sdkTemplates[sdkID] + if !ok { + return "", fmt.Errorf("no initialization template for SDK %q; see docs: %s", sdkID, GetDocsURL(sdkID)) + } + + templateFile := info.TemplateFile + if esm && info.ESMTemplateFile != "" { + templateFile = info.ESMTemplateFile + } + + content, err := initTemplateFiles.ReadFile("sdk_init_templates/" + templateFile) + if err != nil { + return "", fmt.Errorf("reading template for %s: %w", sdkID, err) + } + + tmpl, err := template.New(sdkID).Parse(string(content)) + if err != nil { + return "", fmt.Errorf("parsing template for %s: %w", sdkID, err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, cfg); err != nil { + return "", fmt.Errorf("executing template for %s: %w", sdkID, err) + } + + return buf.String(), nil +} + +// InjectIntoFile renders the SDK initialization code and, for SDKs whose entry +// file is an interpreted script (see appendSafeSDKs), writes it into filePath: +// imports are placed at the top and init code appended after existing content. +// +// For SDKs that are not append-safe — because file-scope statements would not +// compile (Go, Java, C#, Swift, Android) or because the code must be wired into +// a component tree (React, React Native) — the file is left untouched and the +// result carries the rendered Snippet plus DocsURL as guidance, with +// Success=false so callers do not report a broken file as ready. +// +// If no template exists for the SDK at all, the result carries only the +// documentation URL. +// +// The template output is split into an IMPORTS section and an INIT section by a +// separator line ("// --- init ---" or "# --- init ---" depending on language). +func (i Initializer) InjectIntoFile(sdkID, filePath string, cfg InitConfig) (*InitResult, error) { + if !HasTemplate(sdkID) { + return &InitResult{ + SDKID: sdkID, + DocsURL: GetDocsURL(sdkID), + Success: false, + }, nil + } + + rendered, err := RenderTemplateForEntry(sdkID, filePath, cfg) + if err != nil { + return nil, err + } + + importSection, initSection := splitInitSections(rendered) + + if !appendSafeSDKs[sdkID] { + return &InitResult{ + SDKID: sdkID, + FilePath: filePath, + DocsURL: GetDocsURL(sdkID), + Snippet: joinSnippet(importSection, initSection), + Success: false, + }, nil + } + + existing, err := os.ReadFile(filePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + var content string + if importSection != "" { + content = importSection + "\n\n" + initSection + "\n" + } else { + content = initSection + "\n" + } + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + return nil, fmt.Errorf("creating %s: %w", filePath, err) + } + return &InitResult{SDKID: sdkID, FilePath: filePath, Success: true}, nil + } + return nil, fmt.Errorf("reading %s: %w", filePath, err) + } + + content := string(existing) + if 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 + } + content = content + "\n\n" + initSection + "\n" + + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + return nil, fmt.Errorf("writing %s: %w", filePath, err) + } + + return &InitResult{SDKID: sdkID, FilePath: filePath, Success: true}, nil +} + +// 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 +// projects at TypeScript and ESM entry points such as Next.js instrumentation.ts +// and NestJS src/main.ts, where a CommonJS require does not run. +func entryNeedsESM(entryPath string) bool { + switch strings.ToLower(filepath.Ext(entryPath)) { + case ".mjs", ".mts", ".ts", ".tsx": + return true + case ".cjs", ".cts": + return false + } + return packageIsESM(entryPath) +} + +// packageIsESM reports whether the nearest package.json above entryPath declares +// "type": "module", which makes every plain .js file in the package ESM. +func packageIsESM(entryPath string) bool { + dir := filepath.Dir(entryPath) + for { + content, err := os.ReadFile(filepath.Join(dir, "package.json")) + if err == nil { + var pkg struct { + Type string `json:"type"` + } + if json.Unmarshal(content, &pkg) == nil { + return pkg.Type == "module" + } + return false + } + + parent := filepath.Dir(dir) + if parent == dir { + return false + } + dir = parent + } +} + +// splitPrologue peels off the leading lines that have to stay above injected +// imports. Every language keeps its shebang, since anything above it stops the file +// being executable, and its leading comment block, which is the only place Python +// encoding cookies (PEP 263) and Ruby magic comments like frozen_string_literal are +// read. Python additionally keeps its module docstring, which is demoted to a plain +// expression if anything precedes it, and its __future__ imports, which are a +// SyntaxError below other code. CommonJS keeps a 'use strict' directive, which is +// ignored unless it is the first statement. +func splitPrologue(sdkID, content string) (prologue, rest string) { + lines := splitLines(content) + + end := 0 + if len(lines) > 0 && strings.HasPrefix(lines[0], "#!") { + end = 1 + } + end = skipCommentHeader(lines, end) + + switch sdkID { + case "python-server-sdk": + end = skipPythonHeader(lines, end) + case "node-server": + end = skipUseStrict(lines, end) + } + + prologue = strings.Join(lines[:end], "") + rest = strings.Join(lines[end:], "") + if prologue != "" && !strings.HasSuffix(prologue, "\n") { + prologue += "\n" + } + return prologue, rest +} + +// skipCommentHeader advances past blank lines and comments, including the /* */ +// block a license or JSDoc header usually opens with. +func skipCommentHeader(lines []string, i int) int { + for i < len(lines) { + t := strings.TrimSpace(lines[i]) + switch { + case t == "" || strings.HasPrefix(t, "#") || strings.HasPrefix(t, "//"): + i++ + case strings.HasPrefix(t, "/*"): + for i < len(lines) && !strings.Contains(lines[i], "*/") { + i++ + } + if i < len(lines) { + i++ + } + default: + return i + } + } + return i +} + +// pythonStringStart matches the opening quote of a module docstring, allowing the +// string prefixes Python permits before it. +var pythonStringStart = regexp.MustCompile(`^[rRuUbBfF]{0,2}("""|'''|"|')`) + +// skipPythonHeader advances past a module docstring and any __future__ imports, +// along with the comments and blank lines between them. +func skipPythonHeader(lines []string, i int) int { + docstringSeen := false + for i < len(lines) { + t := strings.TrimSpace(lines[i]) + switch { + case t == "" || strings.HasPrefix(t, "#"): + i++ + case strings.HasPrefix(t, "from __future__ import"): + i = skipStatement(lines, i) + case !docstringSeen && pythonStringStart.MatchString(t): + docstringSeen = true + quote := pythonStringStart.FindStringSubmatch(t)[1] + body := t[strings.Index(t, quote)+len(quote):] + i++ + if strings.Contains(body, quote) { + continue // the docstring opened and closed on one line + } + for i < len(lines) && !strings.Contains(lines[i], quote) { + i++ + } + if i < len(lines) { + i++ + } + default: + return i + } + } + return i +} + +// skipStatement advances past a statement that may continue over several lines with +// parentheses or a trailing backslash. +func skipStatement(lines []string, i int) int { + depth := 0 + for i < len(lines) { + line := strings.TrimRight(lines[i], "\n") + depth += strings.Count(line, "(") - strings.Count(line, ")") + continued := strings.HasSuffix(line, `\`) + i++ + if depth <= 0 && !continued { + break + } + } + return i +} + +// skipUseStrict advances past a 'use strict' directive. +func skipUseStrict(lines []string, i int) int { + if i >= len(lines) { + return i + } + directive := lines[i] + if j := strings.Index(directive, "//"); j >= 0 { + directive = directive[:j] + } + if j := strings.Index(directive, "/*"); j >= 0 { + directive = directive[:j] + } + switch strings.TrimSuffix(strings.TrimSpace(directive), ";") { + case `'use strict'`, `"use strict"`: + return i + 1 + } + return i +} + +// splitLines splits s into lines, keeping each newline with the line it ends. +func splitLines(s string) []string { + var lines []string + for s != "" { + i := strings.IndexByte(s, '\n') + if i < 0 { + return append(lines, s) + } + lines = append(lines, s[:i+1]) + s = s[i+1:] + } + return lines +} + +// joinSnippet recombines the import and init sections into a single human-readable +// snippet the user can copy into the correct place in their code. +func joinSnippet(importSection, initSection string) string { + if importSection == "" { + return initSection + } + return importSection + "\n\n" + initSection +} + +// initSeparators lists the markers that divide import and init sections in templates. +var initSeparators = []string{ + "// --- init ---", + "# --- init ---", +} + +// splitInitSections splits rendered template output into an import section and an +// init section. It recognises comment-style-appropriate separators so that templates +// for languages like Python and Ruby can use `#` comments. +func splitInitSections(rendered string) (importSection, initSection string) { + for _, sep := range initSeparators { + if parts := strings.SplitN(rendered, sep, 2); len(parts) == 2 { + return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) + } + } + return "", rendered +} diff --git a/internal/setup/initializer_test.go b/internal/setup/initializer_test.go new file mode 100644 index 000000000..364d88e42 --- /dev/null +++ b/internal/setup/initializer_test.go @@ -0,0 +1,556 @@ +package setup + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderTemplate(t *testing.T) { + cfg := InitConfig{ + SDKKey: "sdk-test-key-123", + ClientSideID: "client-id-456", + MobileKey: "mob-key-789", + FlagKey: "my-test-flag", + } + + tests := []struct { + name string + sdkID string + wantSubstr string + }{ + {"node-server", "node-server", "sdk-test-key-123"}, + {"react-client-sdk", "react-client-sdk", "client-id-456"}, + {"react-native", "react-native", "mob-key-789"}, + {"js-client-sdk", "js-client-sdk", "my-test-flag"}, + {"swift-client-sdk", "swift-client-sdk", "mob-key-789"}, + {"android-client-sdk", "android-client-sdk", "mob-key-789"}, + {"java-server-sdk", "java-server-sdk", "sdk-test-key-123"}, + {"ruby-server-sdk", "ruby-server-sdk", "sdk-test-key-123"}, + {"go-server-sdk", "go-server-sdk", "sdk-test-key-123"}, + {"python-server-sdk", "python-server-sdk", "sdk-test-key-123"}, + {"dotnet-server-sdk", "dotnet-server-sdk", "sdk-test-key-123"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := RenderTemplate(tt.sdkID, cfg) + require.NoError(t, err) + assert.Contains(t, result, tt.wantSubstr) + }) + } +} + +func TestRenderTemplateUnknownSDK(t *testing.T) { + _, err := RenderTemplate("nonexistent-sdk", InitConfig{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no initialization template") + assert.Contains(t, err.Error(), "see docs") +} + +func TestRenderTemplateUnknownSDK_KnownDocsPath(t *testing.T) { + _, err := RenderTemplate("php-server-sdk", InitConfig{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "https://launchdarkly.com/docs/sdk/server-side/php") +} + +func TestHasTemplate(t *testing.T) { + assert.True(t, HasTemplate("node-server")) + assert.True(t, HasTemplate("react-client-sdk")) + // The detector emits "android"; "android-client-sdk" stays as an alias so any + // caller still passing the old ID keeps working. + assert.True(t, HasTemplate("android")) + assert.True(t, HasTemplate("android-client-sdk")) + assert.False(t, HasTemplate("nonexistent-sdk")) +} + +func TestSupportedSDKIDs(t *testing.T) { + ids := SupportedSDKIDs() + assert.Len(t, ids, 12) + assert.Contains(t, ids, "node-server") + assert.Contains(t, ids, "react-client-sdk") + assert.Contains(t, ids, "go-server-sdk") +} + +func TestInjectIntoFile_NewFile(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "index.js") + + initializer := Initializer{} + result, err := initializer.InjectIntoFile("node-server", filePath, InitConfig{ + SDKKey: "test-key", + FlagKey: "test-flag", + }) + + require.NoError(t, err) + assert.True(t, result.Success) + assert.Equal(t, "node-server", result.SDKID) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Contains(t, string(content), "test-key") + assert.Contains(t, string(content), "test-flag") +} + +func TestInjectIntoFile_ExistingFile(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "app.js") + + err := os.WriteFile(filePath, []byte("// existing code\nconsole.log('hello');\n"), 0644) + require.NoError(t, err) + + initializer := Initializer{} + result, err := initializer.InjectIntoFile("node-server", filePath, InitConfig{ + SDKKey: "test-key", + FlagKey: "test-flag", + }) + + require.NoError(t, err) + assert.True(t, result.Success) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Contains(t, string(content), "existing code") + assert.Contains(t, string(content), "test-key") +} + +// A shebang only works as the very first bytes of a file, and Python and Ruby only +// read an encoding cookie on the first two lines. Injecting imports above either one +// leaves the entry point unrunnable, which is how Django's manage.py arrives. +func TestInjectIntoFile_KeepsPrologueFirst(t *testing.T) { + tests := []struct { + name string + sdkID string + fileName string + existing string + wantHead string + }{ + { + name: "shebang stays on the first line", + sdkID: "python-server-sdk", + fileName: "manage.py", + existing: "#!/usr/bin/env python\nimport os\n", + wantHead: "#!/usr/bin/env python\n", + }, + { + name: "encoding cookie stays within the first two lines", + sdkID: "python-server-sdk", + fileName: "main.py", + existing: "#!/usr/bin/env python\n# -*- coding: utf-8 -*-\nimport os\n", + wantHead: "#!/usr/bin/env python\n# -*- coding: utf-8 -*-\n", + }, + { + name: "cookie without a shebang stays first", + sdkID: "ruby-server-sdk", + fileName: "main.rb", + existing: "# coding: utf-8\nputs 'hi'\n", + wantHead: "# coding: utf-8\n", + }, + { + name: "shebang with no trailing newline still gets one", + sdkID: "node-server", + fileName: "cli.js", + existing: "#!/usr/bin/env node", + wantHead: "#!/usr/bin/env node\n", + }, + { + name: "future imports stay above other imports", + sdkID: "python-server-sdk", + fileName: "app.py", + existing: "from __future__ import annotations\n\nimport os\n", + wantHead: "from __future__ import annotations\n", + }, + { + name: "docstring stays first and future imports follow it", + sdkID: "python-server-sdk", + fileName: "svc.py", + existing: "#!/usr/bin/env python3\n\"\"\"Service entry point.\"\"\"\n\nfrom __future__ import annotations\n\nimport os\n", + wantHead: "#!/usr/bin/env python3\n\"\"\"Service entry point.\"\"\"\n\nfrom __future__ import annotations\n", + }, + { + name: "multi-line docstring stays first", + sdkID: "python-server-sdk", + fileName: "multi.py", + existing: "'''\nService entry point.\n'''\nimport os\n", + wantHead: "'''\nService entry point.\n'''\n", + }, + { + name: "parenthesized future import is kept whole", + sdkID: "python-server-sdk", + fileName: "paren.py", + existing: "from __future__ import (\n annotations,\n generator_stop,\n)\nimport os\n", + wantHead: "from __future__ import (\n annotations,\n generator_stop,\n)\n", + }, + { + name: "ruby magic comment stays above code", + sdkID: "ruby-server-sdk", + fileName: "main.rb", + existing: "#!/usr/bin/env ruby\n# frozen_string_literal: true\n\nputs 'hi'\n", + wantHead: "#!/usr/bin/env ruby\n# frozen_string_literal: true\n", + }, + { + name: "use strict stays the first statement", + sdkID: "node-server", + fileName: "index.js", + existing: "'use strict';\nconsole.log('hi');\n", + wantHead: "'use strict';\n", + }, + { + name: "use strict below a block comment header stays first", + sdkID: "node-server", + fileName: "licensed.js", + existing: "/*\n * Copyright someone.\n */\n'use strict';\nconsole.log('hi');\n", + wantHead: "/*\n * Copyright someone.\n */\n'use strict';\n", + }, + { + name: "single-line block comment header stays first", + sdkID: "node-server", + fileName: "oneline.js", + existing: "/* @flow */\n'use strict';\nconsole.log('hi');\n", + wantHead: "/* @flow */\n'use strict';\n", + }, + { + name: "use strict with a trailing comment stays first", + sdkID: "node-server", + fileName: "trailing.js", + existing: "'use strict'; // required\nconsole.log('hi');\n", + wantHead: "'use strict'; // required\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, tt.fileName) + require.NoError(t, os.WriteFile(filePath, []byte(tt.existing), 0644)) + + initializer := Initializer{} + result, err := initializer.InjectIntoFile(tt.sdkID, filePath, InitConfig{ + SDKKey: "test-key", + FlagKey: "test-flag", + }) + require.NoError(t, err) + require.True(t, result.Success) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(string(content), tt.wantHead), + "file must still start with %q, got:\n%s", tt.wantHead, content) + assert.Contains(t, string(content), "test-key", "init code must still be injected") + }) + } +} + +// A CommonJS require does not run in an ESM or TypeScript entry point, and those are +// exactly what detection picks for Next.js and NestJS. Injecting the wrong module +// syntax reports success on code that fails at startup. +func TestInjectIntoFile_MatchesEntryModuleSyntax(t *testing.T) { + tests := []struct { + name string + entry string + packageJSON string + wantImport string + notImport string + }{ + { + name: "typescript entry uses import", + entry: "src/main.ts", + wantImport: "import * as LaunchDarkly from '@launchdarkly/node-server-sdk'", + notImport: "require(", + }, + { + name: "next instrumentation uses import", + entry: "instrumentation.ts", + wantImport: "import * as LaunchDarkly", + notImport: "require(", + }, + { + name: "mjs entry uses import", + entry: "index.mjs", + wantImport: "import * as LaunchDarkly", + notImport: "require(", + }, + { + name: "plain js in a module package uses import", + entry: "index.js", + packageJSON: `{"name":"app","type":"module"}`, + wantImport: "import * as LaunchDarkly", + notImport: "require(", + }, + { + name: "plain js in a commonjs package uses require", + entry: "index.js", + packageJSON: `{"name":"app"}`, + wantImport: "require('@launchdarkly/node-server-sdk')", + notImport: "import * as", + }, + { + name: "cjs entry uses require even in a module package", + entry: "index.cjs", + packageJSON: `{"name":"app","type":"module"}`, + wantImport: "require('@launchdarkly/node-server-sdk')", + notImport: "import * as", + }, + { + name: "js entry with no package.json uses require", + entry: "index.js", + wantImport: "require('@launchdarkly/node-server-sdk')", + notImport: "import * as", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + if tt.packageJSON != "" { + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(tt.packageJSON), 0644)) + } + filePath := filepath.Join(dir, tt.entry) + require.NoError(t, os.MkdirAll(filepath.Dir(filePath), 0755)) + + initializer := Initializer{} + result, err := initializer.InjectIntoFile("node-server", filePath, InitConfig{ + SDKKey: "test-key", + FlagKey: "test-flag", + }) + require.NoError(t, err) + require.True(t, result.Success) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Contains(t, string(content), tt.wantImport) + assert.NotContains(t, string(content), tt.notImport) + }) + } +} + +func TestInjectIntoFile_NewFile_OmitsSeparator(t *testing.T) { + sdks := []struct { + sdkID string + filename string + }{ + {"python-server-sdk", "init_ld.py"}, + {"ruby-server-sdk", "init_ld.rb"}, + {"node-server", "index.js"}, + } + + for _, tt := range sdks { + t.Run(tt.sdkID, func(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, tt.filename) + + initializer := Initializer{} + result, err := initializer.InjectIntoFile(tt.sdkID, filePath, InitConfig{ + SDKKey: "test-key", + FlagKey: "test-flag", + }) + + require.NoError(t, err) + assert.True(t, result.Success) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.NotContains(t, string(content), "// --- init ---") + }) + } +} + +func TestInjectIntoFile_AndroidClientSdk_ReturnsGuidance(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "MainActivity.java") + + initializer := Initializer{} + result, err := initializer.InjectIntoFile("android-client-sdk", filePath, InitConfig{ + MobileKey: "mob-test-key", + FlagKey: "test-flag", + }) + + require.NoError(t, err) + // Android is a scoped language: statements can't live at file scope, so we + // return guidance rather than write a broken file. + assert.False(t, result.Success) + assert.Equal(t, "android-client-sdk", result.SDKID) + assert.Contains(t, result.Snippet, "mob-test-key") + assert.NotEmpty(t, result.DocsURL) + + // The file must not have been created. + _, statErr := os.Stat(filePath) + assert.True(t, os.IsNotExist(statErr), "guidance-only SDK must not create the file") +} + +func TestInjectIntoFile_Go_ReturnsGuidanceDoesNotModifyFile(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "main.go") + + existing := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"hello\")\n}\n" + err := os.WriteFile(filePath, []byte(existing), 0644) + require.NoError(t, err) + + initializer := Initializer{} + result, err := initializer.InjectIntoFile("go-server-sdk", filePath, InitConfig{ + SDKKey: "sdk-test-key", + FlagKey: "test-flag", + }) + + require.NoError(t, err) + // Go statements are illegal at file scope, so appending would not compile. + // We return the snippet as guidance and leave the file untouched. + assert.False(t, result.Success) + assert.Contains(t, result.Snippet, "sdk-test-key") + assert.Contains(t, result.Snippet, "github.com/launchdarkly/go-server-sdk/v7") + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Equal(t, existing, string(content), "existing file must not be modified") +} + +func TestInjectIntoFile_React_ReturnsGuidanceDoesNotModifyFile(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "App.tsx") + + existing := "export default function App() { return null }\n" + err := os.WriteFile(filePath, []byte(existing), 0644) + require.NoError(t, err) + + initializer := Initializer{} + result, err := initializer.InjectIntoFile("react-client-sdk", filePath, InitConfig{ + ClientSideID: "client-id-456", + FlagKey: "test-flag", + }) + + require.NoError(t, err) + // React init must be wired into the component tree, not appended, so we + // return guidance rather than corrupt the file. + assert.False(t, result.Success) + assert.Contains(t, result.Snippet, "asyncWithLDProvider") + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Equal(t, existing, string(content), "existing file must not be modified") +} + +func TestInjectIntoFile_UnsupportedSDK_ReturnsDocsURL(t *testing.T) { + initializer := Initializer{} + result, err := initializer.InjectIntoFile("php-server-sdk", "/tmp/fake.php", InitConfig{}) + require.NoError(t, err) + assert.False(t, result.Success) + assert.Equal(t, "https://launchdarkly.com/docs/sdk/server-side/php", result.DocsURL) +} + +func TestInjectIntoFile_CompletelyUnknownSDK_ReturnsFallbackDocsURL(t *testing.T) { + initializer := Initializer{} + result, err := initializer.InjectIntoFile("nonexistent-sdk", "/tmp/fake.txt", InitConfig{}) + require.NoError(t, err) + assert.False(t, result.Success) + assert.Equal(t, "https://launchdarkly.com/docs/sdk", result.DocsURL) +} + +func TestGetDocsURL(t *testing.T) { + assert.Equal(t, "https://launchdarkly.com/docs/sdk/server-side/go", GetDocsURL("go-server-sdk")) + assert.Equal(t, "https://launchdarkly.com/docs/sdk/client-side/react", GetDocsURL("react-client-sdk")) + assert.Equal(t, "https://launchdarkly.com/docs/sdk/server-side/python", GetDocsURL("python-server-sdk")) + assert.Equal(t, "https://launchdarkly.com/docs/sdk", GetDocsURL("totally-unknown")) +} + +// The mobile SDKs return a snippet the user pastes by hand, so a snippet that does +// not compile is the whole deliverable being wrong. Neither config type can be +// built without its environment-attributes argument: LDConfig's only public +// initializer takes autoEnvAttributes, and LDConfig.Builder's only constructor +// takes AutoEnvAttributes. There is no Swift or Java toolchain here to catch it. +func TestRenderTemplate_MobileConfigCarriesRequiredArguments(t *testing.T) { + tests := []struct { + sdkID string + want []string + }{ + {"swift-client-sdk", []string{ + `LDConfig(mobileKey: "mob-456", autoEnvAttributes: .enabled)`, + // build() returns a Result. `try ...get()` only compiles inside a + // throwing function, and the paste sites are not throwing. + `guard case .success(let ldContext) = LDContextBuilder(key: "example-user-key").build()`, + }}, + {"android", []string{ + "new LDConfig.Builder(AutoEnvAttributes.Enabled)", + // AutoEnvAttributes is nested in LDConfig.Builder, so the package + // wildcard import does not bring it into scope. + "import com.launchdarkly.sdk.android.LDConfig.Builder.AutoEnvAttributes;", + }}, + {"android-client-sdk", []string{"new LDConfig.Builder(AutoEnvAttributes.Enabled)"}}, + } + for _, tt := range tests { + t.Run(tt.sdkID, func(t *testing.T) { + result, err := RenderTemplate(tt.sdkID, InitConfig{MobileKey: "mob-456", FlagKey: "my-flag"}) + + require.NoError(t, err) + for _, want := range tt.want { + assert.Contains(t, result, want) + } + assert.NotContains(t, result, "new LDConfig.Builder()", + "the no-argument Builder constructor does not exist") + assert.NotContains(t, result, "try ", + "a snippet pasted into a non-throwing function cannot use try") + }) + } +} + +// 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');") +} diff --git a/internal/setup/installer.go b/internal/setup/installer.go new file mode 100644 index 000000000..f538ad0d7 --- /dev/null +++ b/internal/setup/installer.go @@ -0,0 +1,356 @@ +package setup + +import ( + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +// InstallResult contains the outcome of installing an SDK package. +type InstallResult struct { + SDKID string `json:"sdk_id"` + Package string `json:"package"` + Version string `json:"version"` + Command string `json:"command"` + DryRun bool `json:"dry_run,omitempty"` + AlreadyInstalled bool `json:"already_installed,omitempty"` + Failed bool `json:"failed,omitempty"` + // FailureReason carries the underlying error when Failed is true, so callers + // can tell the user why the automatic install did not run. + FailureReason string `json:"failure_reason,omitempty"` + Success bool `json:"success"` +} + +// RequiresManualInstall reports whether the SDK has no automated package-manager +// command and must be added by hand (e.g. Java, Android, Swift). +func RequiresManualInstall(sdkID string) bool { + return manualInstallSDKs[sdkID] +} + +// Installer runs the appropriate package manager command to add an SDK dependency. +type Installer interface { + Install(dir string, detection *DetectResult) (*InstallResult, error) +} + +// StubInstaller is a placeholder implementation. Replace with real install logic. +type StubInstaller struct{} + +var _ Installer = StubInstaller{} + +func (StubInstaller) Install(_ string, _ *DetectResult) (*InstallResult, error) { + return nil, errors.New("install is not yet implemented: a real Installer must be provided") +} + +// PackageInstaller implements Installer using the system package manager. +// Its run field can be replaced in tests to avoid executing real commands. +type PackageInstaller struct { + run func(dir string, args []string) ([]byte, error) +} + +var _ Installer = PackageInstaller{} + +// manualInstallSDKs lists SDKs that have no automated package-manager command +// (Java, Android, Swift) but ARE recognised. For these, Install returns +// Success=false without an error so the wizard can proceed and show the package +// identifier. An SDK ID that is neither installable nor in this set is unknown +// and is treated as an error rather than a silent no-op. +var manualInstallSDKs = map[string]bool{ + "java-server-sdk": true, + "android": true, + "android-client-sdk": true, + "swift-client-sdk": true, + "ios-client-sdk": true, +} + +// Install runs the appropriate package manager command to add the SDK dependency. +// For SDKs that require manual installation (e.g. Java, Android, Swift), Install +// returns a result with Success=false without returning an error. An unknown SDK +// ID returns an error. +func (p PackageInstaller) Install(dir string, detection *DetectResult) (*InstallResult, error) { + args, pkg := InstallArgs(detection.SDKID, detection.PackageManager) + if len(args) == 0 { + if !manualInstallSDKs[detection.SDKID] { + return nil, fmt.Errorf("unknown SDK %q: no install command available; specify a supported --sdk-id", detection.SDKID) + } + return &InstallResult{ + SDKID: detection.SDKID, + Package: pkg, + Success: false, + }, nil + } + + // Skip the install if the SDK is already a dependency of the project. + if IsInstalled(dir, detection.SDKID) { + return &InstallResult{ + SDKID: detection.SDKID, + Package: pkg, + AlreadyInstalled: true, + Success: true, + }, nil + } + + if detection.SDKID == "dotnet-server-sdk" { + target, reason := dotnetProjectArg(dir) + if reason != "" { + return &InstallResult{ + SDKID: detection.SDKID, + Package: pkg, + Failed: true, + FailureReason: reason, + }, nil + } + args = append(args, target...) + } + + runner := p.run + if runner == nil { + runner = execRun + } + + out, err := runner(dir, args) + command := strings.Join(args, " ") + if err != nil { + return nil, fmt.Errorf("%s: %w\n%s", command, err, strings.TrimSpace(string(out))) + } + return &InstallResult{ + SDKID: detection.SDKID, + Package: pkg, + Command: command, + Success: true, + }, nil +} + +// dotnetProjectArg returns the extra arguments needed to point `dotnet add +// package` at a project, or a reason the install cannot run unattended. A bare +// `dotnet add package` only works when the working directory holds exactly one +// project file, but detection also accepts a solution whose projects live in +// subdirectories. +func dotnetProjectArg(dir string) (args []string, reason string) { + if matches, _ := filepath.Glob(filepath.Join(dir, "*.csproj")); len(matches) == 1 { + return nil, "" + } + projects := csprojFiles(dir) + switch len(projects) { + case 0: + return nil, "no .csproj file found; add LaunchDarkly.ServerSdk to your project manually" + case 1: + rel, err := filepath.Rel(dir, projects[0]) + if err != nil { + rel = projects[0] + } + return []string{"--project", rel}, "" + default: + // Picking one of several projects would add the SDK to an arbitrary + // assembly, so let the user say which. + return nil, fmt.Sprintf("found %d projects in this solution; run `dotnet add package LaunchDarkly.ServerSdk --project ` for the one that needs the SDK", len(projects)) + } +} + +func execRun(dir string, args []string) ([]byte, error) { + cmd := exec.Command(args[0], args[1:]...) //nolint:gosec + cmd.Dir = dir + return cmd.CombinedOutput() +} + +// InstallArgs returns the command-line arguments and package name for installing the given SDK. +// Returns nil args for SDKs that require manual installation (e.g. Java, Android, Swift). +// packageManager is used for Node.js SDKs; for other runtimes the appropriate tool is chosen automatically. +func InstallArgs(sdkID, packageManager string) (args []string, pkg string) { + switch sdkID { + case "react-client-sdk": + pkg = "launchdarkly-react-client-sdk" + return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg + case "react-native": + pkg = "@launchdarkly/react-native-client-sdk" + return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg + case "node-server": + pkg = "@launchdarkly/node-server-sdk" + return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg + case "js-client-sdk": + // The unscoped v3 package, whose initialize API the init template and the + // quickstart instructions both use. The scoped @launchdarkly/js-client-sdk is + // v4 and exposes createClient instead. + pkg = "launchdarkly-js-client-sdk" + return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg + case "python-server-sdk": + pkg = "launchdarkly-server-sdk" + return pythonInstallCmd(packageManager, pkg), pkg + case "go-server-sdk": + pkg = "github.com/launchdarkly/go-server-sdk/v7" + return []string{"go", "get", pkg}, pkg + case "ruby-server-sdk": + pkg = "launchdarkly-server-sdk" + // Bundler-managed projects need the gem recorded in the Gemfile; a bare + // `gem install` would succeed without making the SDK available to the app. + if packageManager == "bundle" { + return []string{"bundle", "add", pkg}, pkg + } + return []string{"gem", "install", pkg}, pkg + case "dotnet-server-sdk": + pkg = "LaunchDarkly.ServerSdk" + return []string{"dotnet", "add", "package", pkg}, pkg + // SDKs requiring manual installation — return a meaningful package identifier + // so callers can display what the user needs to add. + case "java-server-sdk": + return nil, "com.launchdarkly:launchdarkly-java-server-sdk" + case "android", "android-client-sdk": + return nil, "com.launchdarkly:launchdarkly-android-client-sdk" + case "swift-client-sdk", "ios-client-sdk": + return nil, "LaunchDarkly" // Swift Package Manager / CocoaPods + default: + return nil, sdkID + } +} + +// pythonInstallCmd returns the install command arguments for a Python package +// manager. Anything unrecognised — including the empty string, which IsInstalled +// passes — falls back to pip. +func pythonInstallCmd(pm, pkg string) []string { + switch pm { + case "poetry": + return []string{"poetry", "add", pkg} + case "uv": + return []string{"uv", "add", pkg} + case "pipenv": + return []string{"pipenv", "install", pkg} + default: + return []string{"pip", "install", pkg} + } +} + +// nodeInstallCmd returns the install command arguments for a Node.js package manager. +func nodeInstallCmd(pm, pkg string) []string { + switch pm { + case "yarn": + return []string{"yarn", "add", pkg} + case "pnpm": + return []string{"pnpm", "add", pkg} + case "bun": + return []string{"bun", "add", pkg} + default: + return []string{"npm", "install", pkg} + } +} + +// resolveNodePM normalises the package manager name, defaulting to "npm". +func resolveNodePM(pm string) string { + switch pm { + case "yarn", "pnpm", "bun": + return pm + default: + return "npm" + } +} + +// IsInstalled reports whether the SDK is already a dependency of the project in +// dir, by looking for its package identifier in the relevant manifest(s). Only +// covers SDKs with an automated install command; returns false for manual SDKs +// and unknowns. +func IsInstalled(dir, sdkID string) bool { + _, pkg := InstallArgs(sdkID, "") + if pkg == "" { + return false + } + + var manifests []string + switch sdkID { + case "react-client-sdk", "react-native", "node-server", "js-client-sdk": + manifests = []string{"package.json"} + case "go-server-sdk": + manifests = []string{"go.mod", "go.sum"} + case "python-server-sdk": + manifests = []string{"requirements.txt", "pyproject.toml", "setup.py", "Pipfile", "uv.lock"} + case "ruby-server-sdk": + manifests = []string{"Gemfile", "Gemfile.lock"} + case "dotnet-server-sdk": + for _, f := range csprojFiles(dir) { + if fileMentionsPackage(f, pkg) { + return true + } + } + return false + default: + return false + } + + for _, mf := range manifests { + if fileMentionsPackage(filepath.Join(dir, mf), pkg) { + return true + } + } + return false +} + +func fileMentionsPackage(path, pkg string) bool { + b, err := os.ReadFile(path) + return err == nil && mentionsPackage(string(b), pkg) +} + +// mentionsPackage reports whether content names pkg as a whole dependency rather +// than as the prefix of a longer name. A plain substring test treats +// @launchdarkly/node-server-sdk-redis as proof that @launchdarkly/node-server-sdk +// is installed, so setup skips installing the SDK the integration package needs. +// Every manifest format delimits a dependency name with a quote, whitespace, or a +// comparison operator, so requiring a non-name character on both sides works for +// all of them without parsing each one. +func mentionsPackage(content, pkg string) bool { + for i := 0; ; { + at := strings.Index(content[i:], pkg) + if at < 0 { + return false + } + at += i + end := at + len(pkg) + beforeOK := at == 0 || !isPackageNameChar(rune(content[at-1])) + afterOK := end == len(content) || !isPackageNameChar(rune(content[end])) + if beforeOK && afterOK { + return true + } + i = at + 1 + } +} + +// isPackageNameChar reports whether r can appear inside a package name, and so +// whether it continues a name rather than terminating one. +func isPackageNameChar(r rune) bool { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + return true + case r == '-', r == '_', r == '.', r == '/', r == '@': + return true + } + return false +} + +// csprojFiles returns the project files to consider for a .NET project, preferring +// those in dir. Detection accepts a solution with no project file beside it, so +// fall back to searching for the projects the solution refers to. +func csprojFiles(dir string) []string { + if matches, _ := filepath.Glob(filepath.Join(dir, "*.csproj")); len(matches) > 0 { + return matches + } + var found []string + _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + // Build output holds copies of nothing useful and can be large. + if name := d.Name(); name == "bin" || name == "obj" || name == ".git" { + return fs.SkipDir + } + return nil + } + if strings.HasSuffix(d.Name(), ".csproj") { + found = append(found, path) + } + return nil + }) + sort.Strings(found) + return found +} diff --git a/internal/setup/installer_test.go b/internal/setup/installer_test.go new file mode 100644 index 000000000..59a1b1ed5 --- /dev/null +++ b/internal/setup/installer_test.go @@ -0,0 +1,405 @@ +package setup + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstallArgs_NodeSDKs(t *testing.T) { + tests := []struct { + sdkID string + pm string + wantCmd string + wantPkg string + }{ + {"react-client-sdk", "npm", "npm", "launchdarkly-react-client-sdk"}, + {"react-client-sdk", "yarn", "yarn", "launchdarkly-react-client-sdk"}, + {"react-client-sdk", "pnpm", "pnpm", "launchdarkly-react-client-sdk"}, + {"react-client-sdk", "bun", "bun", "launchdarkly-react-client-sdk"}, + {"react-client-sdk", "", "npm", "launchdarkly-react-client-sdk"}, + {"react-native", "npm", "npm", "@launchdarkly/react-native-client-sdk"}, + {"react-native", "bun", "bun", "@launchdarkly/react-native-client-sdk"}, + {"node-server", "npm", "npm", "@launchdarkly/node-server-sdk"}, + {"node-server", "yarn", "yarn", "@launchdarkly/node-server-sdk"}, + {"node-server", "pnpm", "pnpm", "@launchdarkly/node-server-sdk"}, + {"node-server", "bun", "bun", "@launchdarkly/node-server-sdk"}, + {"node-server", "", "npm", "@launchdarkly/node-server-sdk"}, + {"js-client-sdk", "npm", "npm", "launchdarkly-js-client-sdk"}, + {"js-client-sdk", "bun", "bun", "launchdarkly-js-client-sdk"}, + } + + for _, tt := range tests { + t.Run(tt.sdkID+"/"+tt.pm, func(t *testing.T) { + args, pkg := InstallArgs(tt.sdkID, tt.pm) + require.NotEmpty(t, args) + assert.Equal(t, tt.wantCmd, args[0]) + assert.Equal(t, tt.wantPkg, pkg) + assert.Contains(t, args, pkg) + }) + } +} + +func TestInstallArgs_Python(t *testing.T) { + tests := []struct { + packageManager string + want []string + }{ + // IsInstalled calls InstallArgs with no package manager. + {"", []string{"pip", "install", "launchdarkly-server-sdk"}}, + {"pip", []string{"pip", "install", "launchdarkly-server-sdk"}}, + {"poetry", []string{"poetry", "add", "launchdarkly-server-sdk"}}, + {"uv", []string{"uv", "add", "launchdarkly-server-sdk"}}, + {"pipenv", []string{"pipenv", "install", "launchdarkly-server-sdk"}}, + // Unrecognised values fall back to pip rather than being run as a command. + {"conda", []string{"pip", "install", "launchdarkly-server-sdk"}}, + } + for _, tt := range tests { + t.Run(tt.packageManager, func(t *testing.T) { + args, pkg := InstallArgs("python-server-sdk", tt.packageManager) + assert.Equal(t, tt.want, args) + assert.Equal(t, "launchdarkly-server-sdk", pkg) + }) + } +} + +func TestInstallArgs_Go(t *testing.T) { + args, pkg := InstallArgs("go-server-sdk", "") + require.NotEmpty(t, args) + assert.Equal(t, "go", args[0]) + assert.Equal(t, "get", args[1]) + assert.Equal(t, "github.com/launchdarkly/go-server-sdk/v7", pkg) +} + +func TestInstallArgs_Ruby(t *testing.T) { + args, pkg := InstallArgs("ruby-server-sdk", "") + require.NotEmpty(t, args) + assert.Equal(t, "gem", args[0]) + assert.Equal(t, "launchdarkly-server-sdk", pkg) +} + +// A Gemfile means Bundler owns the project's gems, so the SDK must be added to the +// Gemfile; `gem install` would leave the app unable to require it under bundler. +func TestInstallArgs_Ruby_Bundler(t *testing.T) { + args, pkg := InstallArgs("ruby-server-sdk", "bundle") + assert.Equal(t, []string{"bundle", "add", "launchdarkly-server-sdk"}, args) + assert.Equal(t, "launchdarkly-server-sdk", pkg) +} + +func TestInstallArgs_Android_BothSpellings(t *testing.T) { + for _, id := range []string{"android", "android-client-sdk"} { + args, pkg := InstallArgs(id, "gradle") + assert.Nil(t, args, "Android has no automated install command") + assert.Equal(t, "com.launchdarkly:launchdarkly-android-client-sdk", pkg) + assert.True(t, RequiresManualInstall(id)) + } +} + +func TestInstallArgs_Dotnet(t *testing.T) { + args, pkg := InstallArgs("dotnet-server-sdk", "") + require.NotEmpty(t, args) + assert.Equal(t, "dotnet", args[0]) + assert.Equal(t, "LaunchDarkly.ServerSdk", pkg) +} + +func TestInstallArgs_ManualSDKs(t *testing.T) { + tests := []struct { + sdkID string + wantPkg string + }{ + {"java-server-sdk", "com.launchdarkly:launchdarkly-java-server-sdk"}, + {"android", "com.launchdarkly:launchdarkly-android-client-sdk"}, + {"android-client-sdk", "com.launchdarkly:launchdarkly-android-client-sdk"}, + {"swift-client-sdk", "LaunchDarkly"}, + {"ios-client-sdk", "LaunchDarkly"}, + {"unknown-sdk-xyz", "unknown-sdk-xyz"}, // unknown falls back to SDK ID + } + for _, tt := range tests { + t.Run(tt.sdkID, func(t *testing.T) { + args, pkg := InstallArgs(tt.sdkID, "") + assert.Nil(t, args, "expected nil args for manual SDK %s", tt.sdkID) + assert.Equal(t, tt.wantPkg, pkg) + }) + } +} + +func TestPackageInstaller_Install_Success(t *testing.T) { + var capturedDir string + var capturedArgs []string + + installer := PackageInstaller{ + run: func(dir string, args []string) ([]byte, error) { + capturedDir = dir + capturedArgs = args + return []byte("added 1 package"), nil + }, + } + + result, err := installer.Install("/my/project", &DetectResult{ + SDKID: "node-server", + PackageManager: "npm", + }) + + require.NoError(t, err) + assert.True(t, result.Success) + assert.Equal(t, "node-server", result.SDKID) + assert.Equal(t, "@launchdarkly/node-server-sdk", result.Package) + assert.Equal(t, "npm install @launchdarkly/node-server-sdk", result.Command) + assert.Equal(t, "/my/project", capturedDir) + assert.Equal(t, []string{"npm", "install", "@launchdarkly/node-server-sdk"}, capturedArgs) +} + +func TestPackageInstaller_Install_CommandFailure(t *testing.T) { + installer := PackageInstaller{ + run: func(dir string, args []string) ([]byte, error) { + return []byte("npm ERR! not found"), errors.New("exit status 1") + }, + } + + _, err := installer.Install("/tmp", &DetectResult{ + SDKID: "node-server", + PackageManager: "npm", + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "npm install @launchdarkly/node-server-sdk") + assert.Contains(t, err.Error(), "npm ERR! not found") +} + +func TestPackageInstaller_Install_ManualSDK_ReturnsNoError(t *testing.T) { + installer := PackageInstaller{} + + result, err := installer.Install("/tmp", &DetectResult{SDKID: "java-server-sdk"}) + + require.NoError(t, err) + assert.False(t, result.Success) + assert.Equal(t, "java-server-sdk", result.SDKID) + assert.Empty(t, result.Command) +} + +func TestPackageInstaller_Install_AlreadyInstalled_SkipsCommand(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "package.json"), + []byte(`{"dependencies":{"@launchdarkly/node-server-sdk":"^9.0.0"}}`), 0644)) + + installer := PackageInstaller{ + run: func(_ string, _ []string) ([]byte, error) { + t.Fatal("package manager must not run when the SDK is already installed") + return nil, nil + }, + } + + result, err := installer.Install(dir, &DetectResult{SDKID: "node-server", PackageManager: "npm"}) + + require.NoError(t, err) + assert.True(t, result.AlreadyInstalled) + assert.True(t, result.Success) + assert.Empty(t, result.Command) +} + +func TestRequiresManualInstall(t *testing.T) { + assert.True(t, RequiresManualInstall("java-server-sdk")) + assert.True(t, RequiresManualInstall("swift-client-sdk")) + assert.False(t, RequiresManualInstall("node-server")) + assert.False(t, RequiresManualInstall("ruby-server-sdk")) +} + +func TestPackageInstaller_Install_UnknownSDK_ReturnsError(t *testing.T) { + installer := PackageInstaller{} + + _, err := installer.Install("/tmp", &DetectResult{SDKID: "totally-unknown-sdk"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown SDK") +} + +func TestPackageInstaller_Install_DefaultRunner_UsedWhenNil(t *testing.T) { + // PackageInstaller{} (zero value) should not panic — it uses execRun. + // We test this by using a manual SDK so no real command is executed. + installer := PackageInstaller{} + + result, err := installer.Install("/tmp", &DetectResult{SDKID: "android"}) + + require.NoError(t, err) + assert.False(t, result.Success) +} + +// A related package that starts with the SDK's name is not the SDK. Treating it as +// installed skips the install and leaves the integration package without the SDK +// it depends on. +func TestIsInstalled_RelatedPackageIsNotTheSDK(t *testing.T) { + tests := []struct { + name string + manifest string + content string + sdkID string + want bool + }{ + {"node redis integration only", "package.json", + `{"dependencies":{"@launchdarkly/node-server-sdk-redis":"^4.0.0"}}`, "node-server", false}, + {"node sdk present", "package.json", + `{"dependencies":{"@launchdarkly/node-server-sdk":"^9.13.0"}}`, "node-server", true}, + {"node sdk alongside integration", "package.json", + `{"dependencies":{"@launchdarkly/node-server-sdk":"^9.13.0","@launchdarkly/node-server-sdk-redis":"^4.0.0"}}`, "node-server", true}, + {"python otel plugin only", "requirements.txt", + "launchdarkly-server-sdk-otel==1.0.0\n", "python-server-sdk", false}, + {"python sdk pinned", "requirements.txt", + "launchdarkly-server-sdk==9.16.1\n", "python-server-sdk", true}, + {"ruby sdk in gemfile", "Gemfile", + "gem 'launchdarkly-server-sdk', '~> 8.14'\n", "ruby-server-sdk", true}, + {"ruby related gem only", "Gemfile", + "gem 'launchdarkly-server-sdk-redis-store'\n", "ruby-server-sdk", false}, + {"go module in go.mod", "go.mod", + "require github.com/launchdarkly/go-server-sdk/v7 v7.15.5\n", "go-server-sdk", true}, + {"go sdk name as a prefix", "go.mod", + "require github.com/launchdarkly/go-server-sdk/v7-fork v1.0.0\n", "go-server-sdk", false}, + {"dotnet telemetry package only", "App.csproj", + ``, "dotnet-server-sdk", false}, + {"dotnet sdk present", "App.csproj", + ``, "dotnet-server-sdk", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, tt.manifest), []byte(tt.content), 0600)) + + assert.Equal(t, tt.want, IsInstalled(dir, tt.sdkID)) + }) + } +} + +// Detection accepts a solution with no project file beside it, so the install has +// to find the project the solution refers to. +func TestIsInstalled_Dotnet_FindsNestedProject(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "MyApp.sln"), []byte(""), 0600)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "src/MyApp"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "src/MyApp/MyApp.csproj"), + []byte(``), 0600)) + + assert.True(t, IsInstalled(dir, "dotnet-server-sdk")) +} + +func TestInstall_Dotnet_SolutionLayout_TargetsTheProject(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "MyApp.sln"), []byte(""), 0600)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "src/MyApp"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "src/MyApp/MyApp.csproj"), []byte(""), 0600)) + + var got []string + installer := PackageInstaller{run: func(_ string, args []string) ([]byte, error) { + got = args + return nil, nil + }} + + result, err := installer.Install(dir, &DetectResult{SDKID: "dotnet-server-sdk", PackageManager: "dotnet"}) + + require.NoError(t, err) + assert.True(t, result.Success) + // A bare `dotnet add package` fails when the working directory holds no project. + assert.Equal(t, []string{"dotnet", "add", "package", "LaunchDarkly.ServerSdk", + "--project", filepath.Join("src", "MyApp", "MyApp.csproj")}, got) +} + +func TestInstall_Dotnet_SingleRootProject_RunsBareCommand(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "MyApp.csproj"), []byte(""), 0600)) + + var got []string + installer := PackageInstaller{run: func(_ string, args []string) ([]byte, error) { + got = args + return nil, nil + }} + + _, err := installer.Install(dir, &DetectResult{SDKID: "dotnet-server-sdk", PackageManager: "dotnet"}) + + require.NoError(t, err) + assert.Equal(t, []string{"dotnet", "add", "package", "LaunchDarkly.ServerSdk"}, got) +} + +// Adding the SDK to an arbitrary assembly is worse than saying which projects exist. +func TestInstall_Dotnet_SeveralProjects_ReportsWhyItStopped(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "MyApp.sln"), []byte(""), 0600)) + for _, p := range []string{"src/Api/Api.csproj", "src/Worker/Worker.csproj"} { + require.NoError(t, os.MkdirAll(filepath.Join(dir, filepath.Dir(p)), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, p), []byte(""), 0600)) + } + + ran := false + installer := PackageInstaller{run: func(_ string, _ []string) ([]byte, error) { + ran = true + return nil, nil + }} + + result, err := installer.Install(dir, &DetectResult{SDKID: "dotnet-server-sdk", PackageManager: "dotnet"}) + + require.NoError(t, err) + assert.False(t, ran, "a command that cannot succeed must not run") + assert.True(t, result.Failed) + assert.False(t, result.Success) + assert.Contains(t, result.FailureReason, "--project") + assert.Equal(t, "LaunchDarkly.ServerSdk", result.Package) +} + +func TestInstall_Dotnet_NoProjectAtAll_ReportsWhyItStopped(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "MyApp.sln"), []byte(""), 0600)) + + result, err := PackageInstaller{run: func(_ string, _ []string) ([]byte, error) { + t.Fatal("install must not run without a project") + return nil, nil + }}.Install(dir, &DetectResult{SDKID: "dotnet-server-sdk", PackageManager: "dotnet"}) + + require.NoError(t, err) + assert.True(t, result.Failed) + assert.Contains(t, result.FailureReason, "no .csproj") +} + +// Build output can hold copies of project files and is large enough to matter. +func TestCsprojFiles_SkipsBuildOutput(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "MyApp.sln"), []byte(""), 0600)) + for _, p := range []string{"src/MyApp/MyApp.csproj", "src/MyApp/obj/Copy.csproj", "bin/Debug/Stale.csproj"} { + require.NoError(t, os.MkdirAll(filepath.Join(dir, filepath.Dir(p)), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, p), []byte(""), 0600)) + } + + assert.Equal(t, []string{filepath.Join(dir, "src/MyApp/MyApp.csproj")}, csprojFiles(dir)) +} + +// The templates import the package InstallArgs installs; a mismatch means the user +// installs one package and the snippet requires another. These are the pairs where +// LaunchDarkly ships both a scoped and an unscoped package for the same SDK. +func TestInstallArgs_PackageMatchesTemplateImport(t *testing.T) { + tests := []struct { + sdkID string + wantImport string + }{ + {"node-server", "@launchdarkly/node-server-sdk"}, + {"react-client-sdk", "launchdarkly-react-client-sdk"}, + {"react-native", "@launchdarkly/react-native-client-sdk"}, + {"js-client-sdk", "launchdarkly-js-client-sdk"}, + } + for _, tt := range tests { + t.Run(tt.sdkID, func(t *testing.T) { + _, pkg := InstallArgs(tt.sdkID, "npm") + assert.Equal(t, tt.wantImport, pkg) + + rendered, err := RenderTemplate(tt.sdkID, InitConfig{}) + require.NoError(t, err) + assert.Contains(t, rendered, "'"+tt.wantImport+"'", + "template must import the package we install") + + esm, err := RenderTemplateForEntry(tt.sdkID, "src/main.ts", InitConfig{}) + require.NoError(t, err) + assert.Contains(t, esm, "'"+tt.wantImport+"'", + "ESM template must import the package we install") + }) + } +} diff --git a/internal/setup/sdk_init_templates/android.tmpl b/internal/setup/sdk_init_templates/android.tmpl new file mode 100644 index 000000000..897b97b94 --- /dev/null +++ b/internal/setup/sdk_init_templates/android.tmpl @@ -0,0 +1,11 @@ +import com.launchdarkly.sdk.android.*; +import com.launchdarkly.sdk.android.LDConfig.Builder.AutoEnvAttributes; +import com.launchdarkly.sdk.*; +// --- init --- +LDConfig ldConfig = new LDConfig.Builder(AutoEnvAttributes.Enabled) + .mobileKey("{{.MobileKey}}") + .build(); +LDContext ldContext = LDContext.builder(ContextKind.DEFAULT, "example-user-key") + .name("Example User") + .build(); +LDClient ldClient = LDClient.init(this.getApplication(), ldConfig, ldContext, 5); diff --git a/internal/setup/sdk_init_templates/dotnet-server-sdk.tmpl b/internal/setup/sdk_init_templates/dotnet-server-sdk.tmpl new file mode 100644 index 000000000..da54e9d44 --- /dev/null +++ b/internal/setup/sdk_init_templates/dotnet-server-sdk.tmpl @@ -0,0 +1,11 @@ +using LaunchDarkly.Sdk; +using LaunchDarkly.Sdk.Server; +// --- init --- +var ldClient = new LdClient("{{.SDKKey}}"); + +var context = Context.Builder("example-user-key") + .Name("Example User") + .Build(); + +var flagValue = ldClient.BoolVariation("{{.FlagKey}}", context, false); +Console.WriteLine($"Flag '{{.FlagKey}}' is {flagValue}"); diff --git a/internal/setup/sdk_init_templates/go-server-sdk.tmpl b/internal/setup/sdk_init_templates/go-server-sdk.tmpl new file mode 100644 index 000000000..a1a92ba46 --- /dev/null +++ b/internal/setup/sdk_init_templates/go-server-sdk.tmpl @@ -0,0 +1,16 @@ +import ( + "fmt" + "time" + + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" + ld "github.com/launchdarkly/go-server-sdk/v7" +) +// --- init --- +ldClient, _ := ld.MakeClient("{{.SDKKey}}", 5*time.Second) + +context := ldcontext.NewBuilder("example-user-key"). + Name("Example User"). + Build() + +flagValue, _ := ldClient.BoolVariation("{{.FlagKey}}", context, false) +fmt.Printf("Flag '{{.FlagKey}}' is %t\n", flagValue) diff --git a/internal/setup/sdk_init_templates/java-server-sdk.tmpl b/internal/setup/sdk_init_templates/java-server-sdk.tmpl new file mode 100644 index 000000000..88a0e3a7c --- /dev/null +++ b/internal/setup/sdk_init_templates/java-server-sdk.tmpl @@ -0,0 +1,11 @@ +import com.launchdarkly.sdk.*; +import com.launchdarkly.sdk.server.*; +// --- init --- +LDClient ldClient = new LDClient("{{.SDKKey}}"); + +LDContext context = LDContext.builder("example-user-key") + .name("Example User") + .build(); + +boolean flagValue = ldClient.boolVariation("{{.FlagKey}}", context, false); +System.out.println("Flag '{{.FlagKey}}' is " + flagValue); diff --git a/internal/setup/sdk_init_templates/js-client-sdk.tmpl b/internal/setup/sdk_init_templates/js-client-sdk.tmpl new file mode 100644 index 000000000..f78f1f30e --- /dev/null +++ b/internal/setup/sdk_init_templates/js-client-sdk.tmpl @@ -0,0 +1,12 @@ +import * as LDClient from 'launchdarkly-js-client-sdk'; +// --- init --- +const ldClient = LDClient.initialize('{{.ClientSideID}}', { + kind: 'user', + key: 'example-user-key', + name: 'Example User', +}); + +ldClient.on('ready', () => { + const flagValue = ldClient.variation('{{.FlagKey}}', false); + console.log(`Flag '{{.FlagKey}}' is ${flagValue}`); +}); diff --git a/internal/setup/sdk_init_templates/node-server-esm.tmpl b/internal/setup/sdk_init_templates/node-server-esm.tmpl new file mode 100644 index 000000000..7d4fef58d --- /dev/null +++ b/internal/setup/sdk_init_templates/node-server-esm.tmpl @@ -0,0 +1,15 @@ +import * as LaunchDarkly from '@launchdarkly/node-server-sdk'; +// --- init --- +const ldClient = LaunchDarkly.init('{{.SDKKey}}'); + +const context = { + kind: 'user', + key: 'example-user-key', + name: 'Example User', +}; + +ldClient.on('ready', () => { + ldClient.variation('{{.FlagKey}}', context, false, (err, flagValue) => { + console.log(`Flag '{{.FlagKey}}' is ${flagValue}`); + }); +}); diff --git a/internal/setup/sdk_init_templates/node-server.tmpl b/internal/setup/sdk_init_templates/node-server.tmpl new file mode 100644 index 000000000..321c0c67c --- /dev/null +++ b/internal/setup/sdk_init_templates/node-server.tmpl @@ -0,0 +1,15 @@ +const LaunchDarkly = require('@launchdarkly/node-server-sdk'); +// --- init --- +const ldClient = LaunchDarkly.init('{{.SDKKey}}'); + +const context = { + kind: 'user', + key: 'example-user-key', + name: 'Example User', +}; + +ldClient.on('ready', () => { + ldClient.variation('{{.FlagKey}}', context, false, (err, flagValue) => { + console.log(`Flag '{{.FlagKey}}' is ${flagValue}`); + }); +}); diff --git a/internal/setup/sdk_init_templates/python-server-sdk.tmpl b/internal/setup/sdk_init_templates/python-server-sdk.tmpl new file mode 100644 index 000000000..4960a98f4 --- /dev/null +++ b/internal/setup/sdk_init_templates/python-server-sdk.tmpl @@ -0,0 +1,11 @@ +import ldclient +from ldclient import Context +from ldclient.config import Config +# --- init --- +ldclient.set_config(Config("{{.SDKKey}}")) +ld_client = ldclient.get() + +context = Context.builder("example-user-key").name("Example User").build() + +flag_value = ld_client.variation("{{.FlagKey}}", context, False) +print(f"Flag '{{.FlagKey}}' is {flag_value}") diff --git a/internal/setup/sdk_init_templates/react-client-sdk.tmpl b/internal/setup/sdk_init_templates/react-client-sdk.tmpl new file mode 100644 index 000000000..de1556304 --- /dev/null +++ b/internal/setup/sdk_init_templates/react-client-sdk.tmpl @@ -0,0 +1,10 @@ +import { asyncWithLDProvider } from 'launchdarkly-react-client-sdk'; +// --- init --- +const LDProvider = await asyncWithLDProvider({ + clientSideID: '{{.ClientSideID}}', + context: { + kind: 'user', + key: 'example-user-key', + name: 'Example User', + }, +}); diff --git a/internal/setup/sdk_init_templates/react-native.tmpl b/internal/setup/sdk_init_templates/react-native.tmpl new file mode 100644 index 000000000..d31a8e5b6 --- /dev/null +++ b/internal/setup/sdk_init_templates/react-native.tmpl @@ -0,0 +1,4 @@ +import { AutoEnvAttributes, ReactNativeLDClient } from '@launchdarkly/react-native-client-sdk'; +// --- init --- +const featureClient = new ReactNativeLDClient('{{.MobileKey}}', AutoEnvAttributes.Enabled); +await featureClient.identify({ kind: 'user', key: 'example-user-key', name: 'Example User' }); diff --git a/internal/setup/sdk_init_templates/ruby-server-sdk.tmpl b/internal/setup/sdk_init_templates/ruby-server-sdk.tmpl new file mode 100644 index 000000000..38306dbb6 --- /dev/null +++ b/internal/setup/sdk_init_templates/ruby-server-sdk.tmpl @@ -0,0 +1,12 @@ +require 'ldclient-rb' +# --- init --- +ld_client = LaunchDarkly::LDClient.new("{{.SDKKey}}") + +context = LaunchDarkly::LDContext.create({ + key: "example-user-key", + kind: "user", + name: "Example User" +}) + +flag_value = ld_client.variation("{{.FlagKey}}", context, false) +puts "Flag '{{.FlagKey}}' is #{flag_value}" diff --git a/internal/setup/sdk_init_templates/swift-client-sdk.tmpl b/internal/setup/sdk_init_templates/swift-client-sdk.tmpl new file mode 100644 index 000000000..a9068f4f3 --- /dev/null +++ b/internal/setup/sdk_init_templates/swift-client-sdk.tmpl @@ -0,0 +1,6 @@ +import LaunchDarkly +// --- init --- +let ldConfig = LDConfig(mobileKey: "{{.MobileKey}}", autoEnvAttributes: .enabled) +guard case .success(let ldContext) = LDContextBuilder(key: "example-user-key").build() +else { return } +LDClient.start(config: ldConfig, context: ldContext) diff --git a/internal/setup/service.go b/internal/setup/service.go new file mode 100644 index 000000000..5645817c8 --- /dev/null +++ b/internal/setup/service.go @@ -0,0 +1,201 @@ +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 +} + +// 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) { + 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 keyedItems + if err := json.Unmarshal(res, &resp); err != nil { + return nil, fmt.Errorf("parsing projects: %w", err) + } + + for _, item := range resp.Items { + projects = append(projects, ProjectSummary{Key: item.Key, Name: item.Name}) + } + if len(resp.Items) < listPageSize { + return projects, nil + } + } +} + +// 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) { + 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 keyedItems + if err := json.Unmarshal(res, &resp); err != nil { + return nil, fmt.Errorf("parsing environments: %w", err) + } + + for _, item := range resp.Items { + envs = append(envs, EnvSummary{Key: item.Key, Name: item.Name}) + } + if len(resp.Items) < listPageSize { + 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. +// 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 + } + 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..91636f158 --- /dev/null +++ b/internal/setup/service_test.go @@ -0,0 +1,238 @@ +package setup + +import ( + "fmt" + "path/filepath" + "strings" + "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, int64(listPageSize), int64(0)). + 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", int64(listPageSize), int64(0)). + 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", "node-server") + + 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", "node-server") + + 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", "node-server") + + 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) +} + +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) +} + +// 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) + } +} diff --git a/internal/setup/verifier.go b/internal/setup/verifier.go new file mode 100644 index 000000000..25e1c2900 --- /dev/null +++ b/internal/setup/verifier.go @@ -0,0 +1,114 @@ +package setup + +import ( + "encoding/json" + "fmt" + "net/url" + "time" + + "github.com/launchdarkly/ldcli/internal/resources" +) + +// VerifyResult describes the outcome of verifying SDK connectivity. +type VerifyResult struct { + Active bool `json:"active"` + Attempts int `json:"attempts"` + Elapsed string `json:"elapsed"` + // SDKName is the sdk_name the check was filtered on, empty when the SDK id had + // no known reported name. Empty means Active reports any SDK in the + // environment, not necessarily the one setup just configured. + SDKName string `json:"sdk_name,omitempty"` +} + +// reportedSDKNames maps a setup SDK id to the sdk_name that SDK identifies itself +// as in the events the sdk-active endpoint aggregates. Only the SDKs that reach +// verification need an entry: verify runs after init injected runnable code, which +// only happens for the append-safe SDKs. +var reportedSDKNames = map[string]string{ + "node-server": "node-server-sdk", + "python-server-sdk": "python-server-sdk", + "ruby-server-sdk": "ruby-server-sdk", +} + +// ReportedSDKName returns the sdk_name to filter sdk-active on for sdkID, or an +// empty string when it is unknown and the check cannot be narrowed. +func ReportedSDKName(sdkID string) string { + return reportedSDKNames[sdkID] +} + +// Verifier polls the sdk-active endpoint until the SDK reports as active or a timeout is reached. +type Verifier struct { + Client resources.Client + Interval time.Duration + Timeout time.Duration +} + +// DefaultVerifier returns a Verifier with sensible defaults. +func DefaultVerifier(client resources.Client) *Verifier { + return &Verifier{ + Client: client, + Interval: 5 * time.Second, + Timeout: 120 * time.Second, + } +} + +// Verify polls GET /api/v2/projects/{project}/environments/{env}/sdk-active until +// active=true, narrowed to the SDK sdkID reports itself as. Without the filter the +// endpoint answers for any SDK active in the environment in the past seven days, +// which reports success for a project that was already using LaunchDarkly. +func (v *Verifier) Verify(accessToken, baseURI, projectKey, envKey, sdkID string) (*VerifyResult, error) { + start := time.Now() + deadline := start.Add(v.Timeout) + attempts := 0 + sdkName := ReportedSDKName(sdkID) + + for { + attempts++ + active, err := v.checkOnce(accessToken, baseURI, projectKey, envKey, sdkName) + if err != nil { + return nil, err + } + if active { + return &VerifyResult{ + Active: true, + Attempts: attempts, + Elapsed: time.Since(start).Round(time.Millisecond).String(), + SDKName: sdkName, + }, nil + } + + if time.Now().After(deadline) { + return &VerifyResult{ + Active: false, + Attempts: attempts, + Elapsed: time.Since(start).Round(time.Millisecond).String(), + SDKName: sdkName, + }, nil + } + + time.Sleep(v.Interval) + } +} + +func (v *Verifier) checkOnce(accessToken, baseURI, projectKey, envKey, sdkName string) (bool, error) { + path, _ := url.JoinPath(baseURI, "api/v2/projects", projectKey, "environments", envKey, "sdk-active") + + var query url.Values + if sdkName != "" { + query = url.Values{"sdk_name": []string{sdkName}} + } + + res, err := v.Client.MakeRequest(accessToken, "GET", path, "application/json", query, nil, false) + if err != nil { + return false, fmt.Errorf("checking sdk-active: %w", err) + } + + var resp struct { + Active bool `json:"active"` + } + if err := json.Unmarshal(res, &resp); err != nil { + return false, fmt.Errorf("parsing sdk-active response: %w", err) + } + + return resp.Active, nil +} diff --git a/internal/setup/verifier_test.go b/internal/setup/verifier_test.go new file mode 100644 index 000000000..d820971f1 --- /dev/null +++ b/internal/setup/verifier_test.go @@ -0,0 +1,90 @@ +package setup + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/resources" +) + +func TestVerify_Active(t *testing.T) { + client := &resources.MockClient{ + Response: []byte(`{"active": true}`), + } + verifier := &Verifier{ + Client: client, + Interval: 10 * time.Millisecond, + Timeout: 1 * time.Second, + } + + result, err := verifier.Verify("token", "https://app.launchdarkly.com", "proj", "env", "node-server") + require.NoError(t, err) + assert.True(t, result.Active) + assert.Equal(t, 1, result.Attempts) +} + +func TestVerify_InactiveTimesOut(t *testing.T) { + client := &resources.MockClient{ + Response: []byte(`{"active": false}`), + } + verifier := &Verifier{ + Client: client, + Interval: 10 * time.Millisecond, + Timeout: 50 * time.Millisecond, + } + + result, err := verifier.Verify("token", "https://app.launchdarkly.com", "proj", "env", "node-server") + require.NoError(t, err) + assert.False(t, result.Active) + assert.Greater(t, result.Attempts, 1) +} + +// Unfiltered, sdk-active answers for any SDK active in the environment in the past +// seven days, so it reports success for a project that already used LaunchDarkly. +func TestVerify_FiltersOnTheConfiguredSDK(t *testing.T) { + client := &resources.MockClient{Response: []byte(`{"active": true}`)} + verifier := &Verifier{Client: client, Interval: time.Millisecond, Timeout: time.Second} + + result, err := verifier.Verify("token", "https://app.launchdarkly.com", "proj", "env", "ruby-server-sdk") + + require.NoError(t, err) + assert.Equal(t, "ruby-server-sdk", client.Query.Get("sdk_name")) + assert.Equal(t, "ruby-server-sdk", result.SDKName) +} + +// The setup id and the name the SDK reports itself as are not always the same. +func TestVerify_UsesTheReportedSDKName(t *testing.T) { + client := &resources.MockClient{Response: []byte(`{"active": true}`)} + verifier := &Verifier{Client: client, Interval: time.Millisecond, Timeout: time.Second} + + _, err := verifier.Verify("token", "https://app.launchdarkly.com", "proj", "env", "node-server") + + require.NoError(t, err) + assert.Equal(t, "node-server-sdk", client.Query.Get("sdk_name")) +} + +// An id with no known reported name must not send a filter that can never match. +func TestVerify_UnknownSDK_SendsNoFilter(t *testing.T) { + client := &resources.MockClient{Response: []byte(`{"active": true}`)} + verifier := &Verifier{Client: client, Interval: time.Millisecond, Timeout: time.Second} + + result, err := verifier.Verify("token", "https://app.launchdarkly.com", "proj", "env", "made-up-sdk") + + require.NoError(t, err) + assert.Empty(t, client.Query.Get("sdk_name")) + assert.Empty(t, result.SDKName, "an unnarrowed check must not claim it was narrowed") +} + +// Every SDK that init writes runnable code for reaches verification, so each needs +// a reported name or its check silently falls back to the whole environment. +func TestReportedSDKName_CoversEverySDKThatVerifies(t *testing.T) { + for _, sdk := range KnownSDKs { + if !InjectsInPlace(sdk.ID) { + continue + } + assert.NotEmpty(t, ReportedSDKName(sdk.ID), "%s reaches verify with no sdk_name", sdk.ID) + } +}