diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index e05537fb7..8593b5074 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -19,7 +19,10 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: stable + # The version the module declares, not whatever is newest. golangci-lint + # reads type data written by the compiler, and a Go release newer than the + # pinned linter makes it unreadable, which fails every pull request at once. + go-version-file: go.mod - name: build run: go build . diff --git a/cmd/cmdtest.go b/cmd/cmdtest.go index 54ea73bdf..90e65c7fe 100644 --- a/cmd/cmdtest.go +++ b/cmd/cmdtest.go @@ -22,6 +22,40 @@ var StubbedSuccessResponse = `{ // CallCmd runs the root command for integration-style tests. It passes isTerminal always true so // the default --output matches an interactive terminal (plaintext); non-TTY JSON defaults are // covered in root_test.go. +// CallCmdCapturingStderr runs a command and returns stdout and stderr separately, so +// a test can assert on output written deliberately to stderr — a transitional note, +// say — without it being mistaken for parseable output. +func CallCmdCapturingStderr( + t *testing.T, + clients APIClients, + trackerFn analytics.TrackerFn, + args []string, +) (stdout []byte, stderr []byte, err error) { + rootCmd, err := NewRootCommand( + config.NewService(&resources.MockClient{}), + trackerFn, + clients, + "test", + false, + func() bool { return true }, + nil, + ) + require.NoError(t, err) + cmd := rootCmd.Cmd() + out, errOut := bytes.NewBufferString(""), bytes.NewBufferString("") + cmd.SetOut(out) + cmd.SetErr(errOut) + cmd.SetArgs(args) + + tracker := trackerFn("", "", false) + if err := cmd.Execute(); err != nil { + tracker.SendCommandCompletedEvent(analytics.ERROR) + return out.Bytes(), errOut.Bytes(), err + } + tracker.SendCommandCompletedEvent(analytics.SUCCESS) + return out.Bytes(), errOut.Bytes(), nil +} + func CallCmd( t *testing.T, clients APIClients, diff --git a/cmd/root.go b/cmd/root.go index 636f04040..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 { @@ -107,6 +111,7 @@ var authExemptCommands = map[string]bool{ "config": true, "help": true, "login": true, + "setup": true, "signup": true, "whoami": true, } @@ -264,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..85bd479e7 --- /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(dir, m.detectResult.SDKID, m.detectResult.PackageManager) + return installDoneMsg{result: &setup.InstallResult{ + SDKID: m.detectResult.SDKID, + Command: strings.Join(args, " "), + Failed: true, + FailureReason: err.Error(), + }} + } + return installDoneMsg{result: result} + } +} + +func (m wizardModel) runCreateFlag() tea.Cmd { + return func() tea.Msg { + key, err := m.svc.CreateFlag(m.auth, m.selectedProject, "my-new-flag", "My New Flag") + if err != nil { + return wizardErrMsg{err: err} + } + return flagCreatedMsg{key: key} + } +} + +func (m wizardModel) runInit() tea.Cmd { + return func() tea.Msg { + cfg := setup.InitConfig{ + SDKKey: m.sdkKey, + ClientSideID: m.clientSideID, + MobileKey: m.mobileKey, + FlagKey: m.flagKey, + } + result, err := m.svc.Inject(m.detectResult.SDKID, m.detectResult.EntryPoint, cfg) + if err != nil { + return wizardErrMsg{err: err} + } + return initDoneMsg{result: result} + } +} + +func (m wizardModel) runVerify() tea.Cmd { + return func() tea.Msg { + result, err := m.svc.Verify(m.auth, m.selectedProject, m.selectedEnv, m.detectResult.SDKID) + if err != nil { + return wizardErrMsg{err: err} + } + return verifyDoneMsg{result: result} + } +} + +// 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..aa14e8735 --- /dev/null +++ b/cmd/setup/detect.go @@ -0,0 +1,81 @@ +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" { + // Candidates are added here rather than in the detection result: they + // report which tools are on this machine, which is not a fact about the + // project. Callers reading this need both. + payload := struct { + *setup.DetectResult + PackageManagerCandidates []setup.PMCandidate `json:"package_manager_candidates,omitempty"` + }{DetectResult: result} + if result.PackageManagerConfidence == setup.PMAmbiguous { + payload.PackageManagerCandidates = setup.PackageManagerChoiceFor(dir, result.SDKID).Candidates + } + data, _ := json.Marshal(payload) + 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) + } + if result.PackageManagerConfidence == setup.PMAmbiguous { + fmt.Fprintf(cmd.OutOrStdout(), "Package Manager: %s (uncertain — %s)\n", + result.PackageManager, result.PackageManagerReason) + } else { + 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..96628eb4e --- /dev/null +++ b/cmd/setup/install.go @@ -0,0 +1,147 @@ +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 +} + +// candidateList renders the choices for an error message, marking the ones that +// are not installed so the user isn't sent to a tool they'd have to install first. +func candidateList(candidates []setup.PMCandidate) string { + names := make([]string, 0, len(candidates)) + for _, c := range candidates { + if c.Installed { + names = append(names, c.Name) + continue + } + names = append(names, c.Name+" (not installed)") + } + return strings.Join(names, ", ") +} + +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) + + // Without an explicit choice, read the project rather than falling back to + // pip or npm regardless of what the project uses. An ambiguous project is an + // error: guessing here would install with the wrong manager, and this command + // cannot ask. + if pkgMgr == "" { + choice := setup.PackageManagerChoiceFor(dir, sdkID) + if choice.Confidence == setup.PMAmbiguous { + return fmt.Errorf( + "cannot tell which package manager to use: %s\npass --package-manager with one of: %s", + choice.Reason, candidateList(choice.Candidates), + ) + } + pkgMgr = choice.Name + // This used to fall through to npm or pip whatever the project used, so a + // caller that relied on that default now gets a different manager. Say so + // once, on stderr, where it cannot disturb output being parsed. + fmt.Fprintf(cmd.ErrOrStderr(), + "note: --package-manager was not given, so setup read the project and chose %q. "+ + "This previously defaulted to npm or pip. Pass --package-manager to pin it.\n", + pkgMgr) + } + + detection := &setup.DetectResult{ + SDKID: sdkID, + PackageManager: pkgMgr, + } + + var result *setup.InstallResult + if dryRun { + args, pkg := setup.InstallArgs(dir, 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) + if result.Warning != "" { + fmt.Fprintf(cmd.OutOrStdout(), "Warning: %s\n", result.Warning) + } + 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..fe09b7c93 --- /dev/null +++ b/cmd/setup/model.go @@ -0,0 +1,230 @@ +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 + // stepSelectPackageManager is only reached when the project does not identify + // its package manager. A project that does skips straight to the plan. + stepSelectPackageManager + 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 + // pmChoice is the package-manager verdict for the chosen SDK. It is non-nil only + // when the project was ambiguous and the user was asked, which also records that + // going back from the plan should return to the picker rather than the SDK list. + pmChoice *setup.PMChoice + pmList list.Model + pmListBuilt bool + + 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 } + +// pmItem is a package manager the user can pick. Installed state is shown but does +// not disable the row: the user may be about to install the tool, and setup never +// installs tooling on their behalf. +type pmItem struct { + name string + command string + installed bool +} + +func (p pmItem) Title() string { + if p.installed { + return p.name + } + return p.name + " (not installed)" +} +func (p pmItem) Description() string { return p.command } +func (p pmItem) FilterValue() string { return p.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..78a22b781 --- /dev/null +++ b/cmd/setup/setup_test.go @@ -0,0 +1,450 @@ +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", + "--package-manager", "npm", + } + 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", + "--package-manager", "npm", + } + 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", + "--package-manager", "npm", + "--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", + "--package-manager", "npm", + "--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", + "--package-manager", "npm", + } + _, 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") +} + +// Omitting --package-manager used to fall through to npm or pip. The default now +// reads the project, so a caller relying on the old behaviour gets a note — on +// stderr, so output being parsed is untouched. +func TestInstall_AutoSelectedManager_WarnsOnStderr(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), + []byte(`{"packageManager":"pnpm@9.1.0"}`), 0600)) + + args := []string{ + "setup", "install", + "--access-token", "test-token", + "--sdk-id", "node-server", + "--path", dir, + "--dry-run", + } + stdout, stderr, err := cmd.CallCmdCapturingStderr( + t, + cmd.APIClients{ResourcesClient: &resources.MockClient{}}, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(stderr), "--package-manager was not given") + assert.Contains(t, string(stderr), "previously defaulted to npm or pip") + assert.Contains(t, string(stderr), "pnpm") + // The note must not reach output a caller parses. + assert.NotContains(t, string(stdout), "--package-manager was not given") + assert.Contains(t, string(stdout), "pnpm add @launchdarkly/node-server-sdk") +} 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..a484a3c99 --- /dev/null +++ b/cmd/setup/update.go @@ -0,0 +1,480 @@ +package setup + +import ( + "os" + "strings" + + "github.com/charmbracelet/bubbles/key" + "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()) + } + if m.pmListBuilt { + m.pmList.SetSize(m.sdkBoxWidth(), m.pmListHeight()) + } + + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + m.quitting = true + return m, tea.Quit + // esc is not used due to arrow keys becoming the escape sequence. + case "q": + if m.isFiltering() { + break // let the list receive 'q' as filter input + } + 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) + keepEscFromQuitting(&m.projectList) + m.projectList.AdditionalShortHelpKeys = listHints(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) + keepEscFromQuitting(&m.envList) + m.envList.AdditionalShortHelpKeys = listHints(true) + 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 stepSelectPackageManager: + if m.pmListBuilt { + m.pmList, cmd = m.pmList.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 +} + +// keepEscFromQuitting rebinds a list's quit key to q alone. bubbles binds it to +// both q and esc, and returns tea.Quit when either matches, so removing our own esc +// binding was not enough — the key fell through to whichever list was on screen and +// ended the session there instead. esc still clears an active filter, which the list +// matches ahead of quitting. +func keepEscFromQuitting(l *list.Model) { + l.KeyMap.Quit = key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")) +} + +// isFiltering reports whether the current step's list is in filter-typing mode, +// so keys like 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 stepSelectPackageManager: + return m.pmList.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 +} + +// listHints adds the wizard's own bindings to a list's help line. Screens showed +// the list's help and a footer of ours, so every instruction appeared twice; the +// list's help is the one place they belong. +func listHints(back bool) func() []key.Binding { + return func() []key.Binding { + hints := []key.Binding{ + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), + } + if back { + hints = append(hints, key.NewBinding(key.WithKeys("left"), key.WithHelp("←", "back"))) + } + return hints + } +} + +// enterPackageManagerStep builds the picker from the ambiguous verdict. Installed +// managers are listed first and the cursor starts on one, but an uninstalled +// manager stays selectable: the choice is the user's, and setup warns at install +// time rather than installing the tool itself. +func (m *wizardModel) enterPackageManagerStep() { + installed := make([]list.Item, 0, len(m.pmChoice.Candidates)) + missing := make([]list.Item, 0, len(m.pmChoice.Candidates)) + for _, c := range m.pmChoice.Candidates { + item := pmItem{name: c.Name, command: c.Command, installed: c.Installed} + if c.Installed { + installed = append(installed, item) + continue + } + missing = append(missing, item) + } + items := append(installed, missing...) + + m.pmList = list.New(items, list.NewDefaultDelegate(), m.sdkBoxWidth(), m.pmListHeight()) + m.pmList.Title = "Select a package manager:" + m.pmList.SetShowStatusBar(false) + keepEscFromQuitting(&m.pmList) + m.pmList.AdditionalShortHelpKeys = listHints(true) + m.pmListBuilt = true + m.step = stepSelectPackageManager +} + +// enterPlanStep computes the preview shown before anything is written or run. +func (m *wizardModel) enterPlanStep() { + // Resolved against the project directory so the previewed command is the one + // that runs, virtualenv pip included. + dir, _ := os.Getwd() + args, _ := setup.InstallArgs(dir, m.detectResult.SDKID, m.detectResult.PackageManager) + m.planInstallCmd = strings.Join(args, " ") + if dir != "" { + m.planAlready = setup.IsInstalled(dir, m.detectResult.SDKID) + } + m.step = stepPlan +} + +// 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 stepSelectPackageManager: + m.step = stepSelectSDK + case stepPlan: + // The picker only exists for an ambiguous project, so going back must return + // to whichever screen the user actually came from. + if m.pmChoice != nil { + m.step = stepSelectPackageManager + break + } + 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 and package manager describe the language we + // detected, not the one the user picked, so re-derive both for the chosen + // SDK rather than carrying the wrong ones forward. Searching again also + // finds an existing file the SDK's bare default would have missed, so we + // append to the user's entry point instead of adding a second one. + result.Framework = "" + if dir, err := os.Getwd(); err == nil { + result.EntryPoint, result.EntryPointExists = setup.EntryPointFor(dir, chosen.id) + result.PackageManager = setup.PackageManagerFor(dir, chosen.id) + } else { + result.EntryPoint = setup.DefaultEntryPoint(chosen.id) + result.EntryPointExists = false + } + } + m.detectResult = &result + + // Ask which manager to use when the project doesn't say. Picking one for the + // user here is how a yarn project ends up installed with npm. + m.pmChoice = nil + if dir, err := os.Getwd(); err == nil { + if choice := setup.PackageManagerChoiceFor(dir, chosen.id); choice.Confidence == setup.PMAmbiguous { + m.pmChoice = &choice + m.enterPackageManagerStep() + return m, nil + } else if choice.Name != "" { + result.PackageManager = choice.Name + m.detectResult = &result + } + } + m.enterPlanStep() + return m, nil + + case stepSelectPackageManager: + selected, ok := m.pmList.SelectedItem().(pmItem) + if !ok { + return m, nil + } + result := *m.detectResult + result.PackageManager = selected.name + m.detectResult = &result + m.enterPlanStep() + 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..7f1abcd8c --- /dev/null +++ b/cmd/setup/view.go @@ -0,0 +1,391 @@ +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() + + 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() + + case stepDetect: + return m.spinner.View() + " Detecting project type..." + + case stepSelectSDK: + return m.sdkSelectView() + + case stepSelectPackageManager: + return m.packageManagerView() + + 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: + // The newline stays outside the wrap: wrapping pads each line to the full + // width, so a trailing one inside would put a row of spaces in front of the + // path and push it past the edge of the terminal. + lead := "SDK initialization code has been injected into:" + if m.initResult.AlreadyInitialized { + lead = "This file already initializes the LaunchDarkly SDK, so it was left as it is:" + } + return titleStyle.Render("Start your application") + "\n\n" + + m.wrap(lead) + "\n" + + m.wrap(" "+m.initResult.FilePath) + "\n\n" + + m.wrap("Please start your application now, then press Enter to verify the connection.") + "\n" + + case stepVerify: + return m.spinner.View() + " Waiting for your app to start and its 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(addCodeTo("Then add this initialization code", 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(addCodeTo(fmt.Sprintf("Add the following %s initialization code", 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() + + m.installWarning() + + 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) + + m.installWarning() + + 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" + + m.installWarning() + + 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 +} + +// pmListHeight is the height available to the package-manager list. The screen +// draws a title, the reason it is asking and a key hint around the list, so giving +// the list the whole window pushes the hint — including how to go back — off the +// bottom of the terminal. +func (m wizardModel) pmListHeight() int { + chrome := 3 // the question, a blank line, and the list's own trailing row + if m.pmShowReason() { + chrome += 3 // the reason, which wraps to two lines when narrow + } + // The list's help line runs to about seventy columns, so on anything narrower + // it wraps and costs a second row. + if m.width < 72 { + chrome++ + } + h := m.height - chrome + if h < 3 { + h = 3 + } + return h +} + +// pmShowReason reports whether there is room to explain why we are asking. On a +// very short terminal the question and the choices have to win: dropping the +// explanation is better than pushing the key hint off the bottom. +func (m wizardModel) pmShowReason() bool { return m.height >= 14 } + +// packageManagerView asks which package manager to use. It says why it is asking: +// a wizard that stops to ask without explaining itself reads as one that failed to +// look, and the reason is also what tells the user whether our reading of their +// project is wrong. +func (m wizardModel) packageManagerView() string { + reason := "" + if m.pmShowReason() && m.pmChoice != nil && m.pmChoice.Reason != "" { + reason = m.wrap(strings.ToUpper(m.pmChoice.Reason[:1])+m.pmChoice.Reason[1:]+".") + "\n\n" + } + return titleStyle.Render(m.wrap("Which package manager should install the SDK?")) + "\n\n" + + reason + + m.pmList.View() +} + +// installWarning renders something the install left for the user to do even though +// it succeeded, or an empty string. Every screen that can be reached after a +// successful install has to show it, or the note is lost on the paths where init +// needs a manual snippet or verification times out. +func (m wizardModel) installWarning() string { + if m.installResult == nil || m.installResult.Warning == "" { + return "" + } + return "\n" + m.wrap("Note: "+m.installResult.Warning) + "\n" +} + +// addCodeTo phrases an "add this code" instruction. SDKs that only show a snippet +// have no entry point, so naming a destination would print an empty path. +func addCodeTo(instruction, path string) string { + if path == "" { + return instruction + ":" + } + return fmt.Sprintf("%s to %s:", instruction, path) +} + +// 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) + keepEscFromQuitting(&l) + 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 · q 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) { + marker := selectedStyle.Render(fmt.Sprintf("%d.", len(steps)+1)) + // Wrap to leave room for the marker and indent what wraps, so a step too long + // for the terminal still reads as one numbered item instead of overflowing. + lines := strings.Split(wrapText(s, m.width-len("1. ")), "\n") + for i := 1; i < len(lines); i++ { + lines[i] = strings.Repeat(" ", len("1. ")) + lines[i] + } + steps = append(steps, marker+" "+strings.Join(lines, "\n")) + } + + 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 · q 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..09b2fe6f3 --- /dev/null +++ b/cmd/setup/wizard_test.go @@ -0,0 +1,1179 @@ +package setup + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "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) + } +} + +// quitsOn reports whether a returned command would end the program. Checking the +// model's quitting flag is not enough: a list returns tea.Quit itself, without the +// wizard ever knowing. +func quitsOn(cmd tea.Cmd) bool { + if cmd == nil { + return false + } + _, ok := cmd().(tea.QuitMsg) + return ok +} + +// esc arrives on its own whenever an arrow key's escape sequence is split across +// reads, so nothing may treat it as quit. The lists must be populated: an empty one +// never receives the key, which is what let this pass while the bug was live. +func TestWizard_Esc_DoesNotQuit(t *testing.T) { + t.Run("project list", func(t *testing.T) { + m := populatedProjectList(t) + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + assert.False(t, next.(wizardModel).quitting) + assert.False(t, quitsOn(cmd), "the list quit the wizard on esc") + }) + + t.Run("environment list", func(t *testing.T) { + m := populatedProjectList(t) + m.step = stepSelectEnvironment + m.selectedProject = "a" + listed, _ := m.Update(envsFetchedMsg{project: "a", environments: []envItem{ + {key: "production", name: "Production"}, {key: "test", name: "Test"}, + }}) + next, cmd := listed.Update(tea.KeyMsg{Type: tea.KeyEsc}) + assert.False(t, next.(wizardModel).quitting) + assert.False(t, quitsOn(cmd), "the list quit the wizard on esc") + }) + + t.Run("SDK list", func(t *testing.T) { + m := wizardModel{step: stepDetect, width: 80, height: 24} + listed, _ := m.Update(detectFailedMsg{}) + sdk := listed.(wizardModel) + sdk.sdkFocus = 1 // focus the list, so it receives keys + next, cmd := sdk.Update(tea.KeyMsg{Type: tea.KeyEsc}) + assert.False(t, next.(wizardModel).quitting) + assert.False(t, quitsOn(cmd), "the list quit the wizard on esc") + }) +} + +// q must still quit, from the same populated screens. +func TestWizard_Q_QuitsFromLists(t *testing.T) { + m := populatedProjectList(t) + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + assert.True(t, next.(wizardModel).quitting) + assert.True(t, quitsOn(cmd)) +} + +// populatedProjectList returns a model sitting on a project list that has items, so +// keys actually reach the list. +func populatedProjectList(t *testing.T) wizardModel { + t.Helper() + m := wizardModel{step: stepSelectProject, width: 80, height: 24} + sized, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + listed, _ := sized.(wizardModel).Update(projectsFetchedMsg{projects: []projectItem{ + {key: "a", name: "A"}, {key: "b", name: "B"}, + }}) + return listed.(wizardModel) +} + +func TestWizard_Q_Quits(t *testing.T) { + m := wizardModel{step: stepSelectSDK} + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + 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, flat(view), "Add initialization code to src/index.js") + assert.NotContains(t, flat(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, flat(view), "Create instrumentation.ts") + assert.Contains(t, flat(view), "no entry file found") + assert.NotContains(t, flat(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) { + // A Gemfile makes Bundler the project's stated manager, so the picker is skipped. + gemfileProject(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) { + gemfileProject(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) { + // A Gemfile states the manager, so the override lands on the plan without asking. + gemfileProject(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") + // pnpm cannot install a gem, so the manager is re-derived for the chosen SDK. + assert.Equal(t, "bundle", m3.detectResult.PackageManager) +} + +// An override must find the file the project already has, rather than falling back +// to the SDK's bare default and creating a second entry point beside it. +func TestWizard_OverrideSDK_FindsExistingEntryPoint(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "src"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "src/index.js"), []byte("console.log(1)\n"), 0600)) + // A lockfile states the manager, so the override lands on the plan without asking. + require.NoError(t, os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0600)) + // macOS resolves /var to /private/var, and the override path reads os.Getwd, + // so compare against the resolved directory rather than the one we created. + dir = chdir(t, dir) + + m := wizardModel{step: stepDetect, width: 80, height: 30} + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{ + SDKID: "js-client-sdk", + Language: "JavaScript", + }}) + + m2 := selectOtherSDK(t, next.(wizardModel), "node-server") + next2, _ := m2.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m3 := next2.(wizardModel) + + require.Equal(t, stepPlan, m3.step) + assert.Equal(t, filepath.Join(dir, "src/index.js"), m3.detectResult.EntryPoint, + "setup would create a second index.js beside the real entry point") + assert.True(t, m3.detectResult.EntryPointExists) +} + +// gemfileProject moves into a project whose package manager is unambiguous, so the +// package-manager picker does not intervene. +func gemfileProject(t *testing.T) string { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "Gemfile"), []byte("source 'https://rubygems.org'\n"), 0600)) + return chdir(t, dir) +} + +// chdir moves into dir for the duration of the test and returns the working +// directory as the process sees it. The override path reads os.Getwd to re-derive +// the entry point. +func chdir(t *testing.T, dir string) string { + t.Helper() + original, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(dir)) + t.Cleanup(func() { _ = os.Chdir(original) }) + resolved, err := os.Getwd() + require.NoError(t, err) + return resolved +} + +// 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) + // An override into a project that doesn't state its package manager asks first. + // These callers are about entry points, so accept the highlighted manager. + if m3.step == stepSelectPackageManager { + next3, _ := m3.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m3 = next3.(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, flat(view), "Add initialization code to") + assert.NotContains(t, flat(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, flat(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") +} + +// A project that states its manager must not be interrupted; the happy path gains +// no keystrokes from the picker existing. +func TestWizard_DefinitePackageManager_SkipsPicker(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"packageManager":"pnpm@9.1.0"}`), 0600)) + chdir(t, dir) + + m := wizardModel{step: stepDetect, width: 80, height: 30} + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{ + SDKID: "node-server", Language: "JavaScript", PackageManager: "pnpm", + }}) + next2, _ := next.(wizardModel).Update(tea.KeyMsg{Type: tea.KeyEnter}) + m3 := next2.(wizardModel) + + require.Equal(t, stepPlan, m3.step) + assert.Nil(t, m3.pmChoice, "nothing was ambiguous, so nothing was asked") + assert.Equal(t, "pnpm add @launchdarkly/node-server-sdk", m3.planInstallCmd) +} + +// Two lockfiles from different managers is the case no guess can get right. +func TestWizard_ConflictingLockfiles_AsksAndUsesTheAnswer(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{}`), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte(""), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0600)) + chdir(t, dir) + + m := wizardModel{step: stepDetect, width: 80, height: 30} + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{ + SDKID: "node-server", Language: "JavaScript", PackageManager: "yarn", + }}) + next2, _ := next.(wizardModel).Update(tea.KeyMsg{Type: tea.KeyEnter}) + picker := next2.(wizardModel) + + require.Equal(t, stepSelectPackageManager, picker.step) + require.NotNil(t, picker.pmChoice) + assert.Contains(t, picker.pmChoice.Reason, "more than one manager") + + // The view has to say why it is asking, or it reads as a tool that failed to look. + view := picker.View() + assert.Contains(t, view, "Which package manager") + assert.Contains(t, view, "more than one manager") + + // Pick whatever is highlighted and confirm the plan follows the answer. + next3, _ := picker.Update(tea.KeyMsg{Type: tea.KeyEnter}) + planned := next3.(wizardModel) + require.Equal(t, stepPlan, planned.step) + selected := planned.detectResult.PackageManager + assert.Contains(t, planned.planInstallCmd, selected, + "the plan must run the manager the user chose") +} + +// Installed managers come first and the cursor starts on one, but an uninstalled +// manager stays selectable — setup never installs tooling for the user. +func TestWizard_Picker_ListsInstalledFirstAndKeepsMissingSelectable(t *testing.T) { + m := wizardModel{step: stepSelectSDK, width: 80, height: 30} + m.detectResult = &setup.DetectResult{SDKID: "node-server"} + m.pmChoice = &setup.PMChoice{ + Name: "npm", + Confidence: setup.PMAmbiguous, + Reason: "this project doesn't say which package manager it uses", + Candidates: []setup.PMCandidate{ + {Name: "npm", Installed: false, Command: "npm install x"}, + {Name: "yarn", Installed: true, Command: "yarn add x"}, + {Name: "pnpm", Installed: true, Command: "pnpm add x"}, + }, + } + m.enterPackageManagerStep() + + items := m.pmList.Items() + require.Len(t, items, 3) + assert.Equal(t, "yarn", items[0].(pmItem).name, "installed managers come first") + assert.Equal(t, "pnpm", items[1].(pmItem).name) + assert.Equal(t, "npm", items[2].(pmItem).name) + assert.Contains(t, items[2].(pmItem).Title(), "not installed") + + // Selecting the uninstalled one is allowed; the install step warns later. + m.pmList.Select(2) + next, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + chosen := next.(wizardModel) + require.Equal(t, stepPlan, chosen.step) + assert.Equal(t, "npm", chosen.detectResult.PackageManager) +} + +// The picker's list quits on esc for the same reason the others did. +func TestWizard_Picker_EscDoesNotQuit(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{}`), 0600)) + chdir(t, dir) + + m := wizardModel{step: stepDetect, width: 80, height: 24} + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{ + SDKID: "node-server", Language: "JavaScript", + }}) + next2, _ := next.(wizardModel).Update(tea.KeyMsg{Type: tea.KeyEnter}) + picker := next2.(wizardModel) + require.Equal(t, stepSelectPackageManager, picker.step) + + after, cmd := picker.Update(tea.KeyMsg{Type: tea.KeyEsc}) + assert.False(t, after.(wizardModel).quitting) + assert.False(t, quitsOn(cmd), "the list quit the wizard on esc") + assert.Equal(t, stepSelectPackageManager, after.(wizardModel).step) +} + +// Back must return to the picker, not skip over it to the SDK list. +func TestWizard_Picker_BackReturnsToPickerFromPlan(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{}`), 0600)) + chdir(t, dir) + + m := wizardModel{step: stepDetect, width: 80, height: 30} + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{ + SDKID: "node-server", Language: "JavaScript", + }}) + next2, _ := next.(wizardModel).Update(tea.KeyMsg{Type: tea.KeyEnter}) + picker := next2.(wizardModel) + require.Equal(t, stepSelectPackageManager, picker.step) + + next3, _ := picker.Update(tea.KeyMsg{Type: tea.KeyEnter}) + planned := next3.(wizardModel) + require.Equal(t, stepPlan, planned.step) + + back, _ := planned.Update(tea.KeyMsg{Type: tea.KeyLeft}) + assert.Equal(t, stepSelectPackageManager, back.(wizardModel).step) + + backAgain, _ := back.(wizardModel).Update(tea.KeyMsg{Type: tea.KeyLeft}) + assert.Equal(t, stepSelectSDK, backAgain.(wizardModel).step) +} + +// The picker draws its question and the reason for asking around the list, so the +// list has to be sized for less than the whole window or the instructions are +// pushed off the bottom. Rows are counted the way a terminal shows them, with +// over-wide lines wrapping. +// +// Widths below 72 are left out: the list widget's own help line runs to about +// seventy columns and wraps there. That affects every list screen in the wizard, +// not this one, and no height reserve fixes it. +func TestWizard_Picker_FitsTerminalHeight(t *testing.T) { + for _, dims := range [][2]int{{100, 30}, {80, 30}, {80, 24}, {80, 20}, {80, 16}} { + t.Run(fmt.Sprintf("%dx%d", dims[0], dims[1]), func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{}`), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte(""), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0600)) + chdir(t, dir) + + m := wizardModel{step: stepDetect, width: dims[0], height: dims[1]} + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{ + SDKID: "node-server", Language: "JavaScript", + }}) + next2, _ := next.(wizardModel).Update(tea.KeyMsg{Type: tea.KeyEnter}) + picker := next2.(wizardModel) + require.Equal(t, stepSelectPackageManager, picker.step) + + view := picker.View() + assert.LessOrEqual(t, terminalRows(view, dims[0]), dims[1], + "the instructions would be pushed off the bottom") + // However short the terminal, the way out must stay on screen. + assert.Contains(t, view, "back") + assert.Contains(t, view, "Which package manager") + }) + } +} + +// flat collapses whitespace in a rendered view, so assertions about a phrase hold +// wherever wrapping happens to fall. +func flat(view string) string { return strings.Join(strings.Fields(view), " ") } + +// terminalRows counts the rows a terminal of the given width would use, so a line +// wider than the window counts as the several rows it actually occupies. +func terminalRows(view string, width int) int { + rows := 0 + for _, line := range strings.Split(strings.TrimRight(view, "\n"), "\n") { + if w := len([]rune(line)); w > width { + rows += (w + width - 1) / width + continue + } + rows++ + } + return rows +} + +// The plan names an absolute entry-point path and explains why it is creating the +// file, which together run well past a narrow terminal. Overflowing there hides +// the very warning the step exists to give. +func TestWizard_Plan_WrapsStepsToTerminalWidth(t *testing.T) { + for _, width := range []int{100, 80, 60, 40} { + t.Run(fmt.Sprintf("width%d", width), func(t *testing.T) { + m := wizardModel{ + step: stepPlan, + selectedProject: "my-scratch-project", + selectedEnv: "production", + detectResult: &setup.DetectResult{ + SDKID: "python-server-sdk", + EntryPoint: "/Users/someone/code/launchdarkly/test-app/main.py", + EntryPointExists: false, + }, + planInstallCmd: "pip3 install launchdarkly-server-sdk", + width: width, + height: 30, + } + + view := m.planView() + + for _, line := range strings.Split(view, "\n") { + assert.LessOrEqual(t, len([]rune(line)), width, + "a plan step overflows a %d-column terminal", width) + } + // The warning must survive wrapping, not be truncated away. + assert.Contains(t, flat(view), "no entry file found") + assert.Contains(t, flat(view), "main.py") + // Wrapped text is indented under its number so the step still reads as one. + assert.Regexp(t, `(?m)^ {3}\S`, view) + }) + } +} + +// Wrapping pads every line to the full width, so a newline left inside a wrapped +// string put a whole row of spaces in front of the injected file path and pushed it +// off the terminal. +func TestWizard_WaitForApp_WrapsWithoutLeadingPadding(t *testing.T) { + for _, width := range []int{80, 60, 40} { + for _, already := range []bool{false, true} { + t.Run(fmt.Sprintf("width%d_already%v", width, already), func(t *testing.T) { + path := "/Users/someone/code/launchdarkly/test-app/main.py" + m := wizardModel{ + step: stepWaitForApp, + width: width, + height: 24, + initResult: &setup.InitResult{ + FilePath: path, + AlreadyInitialized: already, + }, + } + + view := m.View() + + for _, line := range strings.Split(view, "\n") { + assert.LessOrEqual(t, len([]rune(line)), width, + "a line overflows a %d-column terminal", width) + } + // The path must start near the left edge, not after a row of padding. + for _, line := range strings.Split(view, "\n") { + if idx := strings.Index(line, "/Users/someone"); idx >= 0 { + assert.LessOrEqual(t, idx, 2, "the path is pushed right by padding") + } + } + // A path has no spaces to wrap on, so a narrow terminal hard-breaks it. + // Compare with whitespace removed to check nothing was lost. + assert.Contains(t, strings.Join(strings.Fields(view), ""), path) + if already { + assert.Contains(t, flat(view), "already initializes the LaunchDarkly SDK") + } + }) + } + } +} + +// A successful install can leave the user something to do — the SDK not recorded in +// requirements.txt. Every screen reachable after that install has to say so, or the +// note is lost when init needs a manual snippet or verification times out. +func TestWizard_Done_InstallWarningShownOnEveryReachableScreen(t *testing.T) { + const warning = "pip installed launchdarkly-server-sdk but did not record it in requirements.txt" + + cases := []struct { + name string + model wizardModel + }{ + {"verification succeeded", wizardModel{ + step: stepDone, + detectResult: &setup.DetectResult{SDKID: "python-server-sdk"}, + verifyResult: &setup.VerifyResult{Active: true}, + }}, + {"verification timed out", wizardModel{ + step: stepDone, + detectResult: &setup.DetectResult{SDKID: "python-server-sdk"}, + verifyResult: &setup.VerifyResult{Active: false}, + }}, + {"init needs a manual snippet", wizardModel{ + step: stepDone, + detectResult: &setup.DetectResult{SDKID: "python-server-sdk"}, + initResult: &setup.InitResult{ + SDKID: "python-server-sdk", Success: false, + Snippet: "import ldclient", DocsURL: "https://example.com", + }, + }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + m := c.model + m.width, m.height = 80, 30 + m.installResult = &setup.InstallResult{Success: true, Warning: warning} + + assert.Contains(t, flat(m.View()), warning, + "a successful install left something undone and this screen does not say so") + }) + } +} 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 9caebf8b8..87c1277d2 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,14 @@ go 1.25 require ( github.com/adrg/xdg v0.5.3 + github.com/atotto/clipboard v0.1.4 github.com/blacktop/go-dwarf v1.0.14 github.com/blacktop/go-macho v1.1.282 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.144.0 github.com/google/uuid v1.6.0 github.com/gorilla/handlers v1.5.2 @@ -25,6 +27,7 @@ require ( github.com/muesli/reflow v0.3.0 github.com/oapi-codegen/oapi-codegen/v2 v2.7.1 github.com/oapi-codegen/runtime v1.1.2 + github.com/pelletier/go-toml/v2 v2.2.4 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pkg/errors v0.9.1 github.com/samber/lo v1.51.0 @@ -41,11 +44,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 @@ -81,7 +82,6 @@ require ( github.com/oasdiff/yaml3 v0.0.14 // indirect github.com/onsi/gomega v1.27.6 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.11.0 // 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..5869f0919 --- /dev/null +++ b/internal/setup/detector.go @@ -0,0 +1,849 @@ +package setup + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/pelletier/go-toml/v2" +) + +// 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"` + // PackageManagerConfidence says whether the project identifies its package + // manager or PackageManager is only a conventional default. Callers must not + // install against an ambiguous verdict without asking first. + PackageManagerConfidence PMConfidence `json:"package_manager_confidence,omitempty"` + // PackageManagerReason explains an ambiguous verdict, phrased for the user. + PackageManagerReason string `json:"package_manager_reason,omitempty"` +} + +// 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 { + // One read of the project decides the manager and how sure we are, rather + // than each detector working it out again. An empty name means a language + // this does not model — Java's maven versus gradle — so the detector's own + // answer stands. Candidates carry which tools are installed, which + // describes the machine rather than the project, so they are left to + // callers that need to present a choice. + choice := PackageManagerChoiceFor(dir, result.SDKID) + if choice.Name != "" { + result.PackageManager = choice.Name + } + result.PackageManagerConfidence = choice.Confidence + result.PackageManagerReason = choice.Reason + 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, + } + } + } + + ep, exists := EntryPointFor(dir, "node-server") + return &DetectResult{ + Language: "JavaScript", + PackageManager: pm, + SDKID: "node-server", + EntryPoint: ep, + EntryPointExists: exists, + } +} + +func detectNodePM(dir string) string { + return nodePMSignals(dir).best("npm") +} + +// exactSemver matches the exact versions corepack requires: a bare MAJOR.MINOR.PATCH +// with optional prerelease and build metadata, and no range operator. +var exactSemver = regexp.MustCompile(`^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$`) + +// corepackPM reads the packageManager field, which names the manager and version +// the project expects. It is the most explicit statement a Node project can make, +// so it outranks lockfiles. +// https://nodejs.org/api/corepack.html +func corepackPM(dir string) string { + b, err := os.ReadFile(filepath.Join(dir, "package.json")) + if err != nil { + return "" + } + var pkg struct { + PackageManager string `json:"packageManager"` + } + if json.Unmarshal(b, &pkg) != nil { + return "" + } + // The field must be "@". Corepack accepts nothing else, so + // a missing version ("No version specified for pnpm in packageManager") or a + // range ("Invalid package manager specification (pnpm@^11.13.0); expected a + // semver version") both stop the manager from running at all. Treating either as + // the project's declared manager would route the user into a command that cannot + // work, so the lockfiles decide instead, or the user is asked. + name, version, _ := strings.Cut(pkg.PackageManager, "@") + if !exactSemver.MatchString(version) { + return "" + } + switch name { + case "npm", "yarn", "pnpm", "bun": + return name + } + return "" +} + +// nodePMSignals reports what the project says about its Node package manager. +// Lockfiles: https://bun.com/docs/install/lockfile +func nodePMSignals(dir string) pmSignals { + if declared := corepackPM(dir); declared != "" { + return pmSignals{declared: declared} + } + var s pmSignals + for _, lock := range []struct{ file, pm string }{ + {"pnpm-lock.yaml", "pnpm"}, + {"yarn.lock", "yarn"}, + {"bun.lock", "bun"}, + {"bun.lockb", "bun"}, + {"package-lock.json", "npm"}, + {"npm-shrinkwrap.json", "npm"}, + } { + if _, err := os.Stat(filepath.Join(dir, lock.file)); err == nil { + s.addLocked(lock.pm) + } + } + return s +} + +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 { + ep, exists := EntryPointFor(dir, "python-server-sdk") + return &DetectResult{ + Language: "Python", + // PackageManager is filled in by Detect from the same read. + 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/ + +// pythonPMSignals reports what the project says about its Python package manager. +// A lockfile is treated as the project having committed to a tool; a [tool.*] +// section counts the same way, since the tool owns that config. +func pythonPMSignals(dir string) pmSignals { + var s pmSignals + for _, lock := range []struct{ file, pm string }{ + {"uv.lock", "uv"}, + {"poetry.lock", "poetry"}, + {"pdm.lock", "pdm"}, + {"Pipfile.lock", "pipenv"}, + {"Pipfile", "pipenv"}, + } { + if _, err := os.Stat(filepath.Join(dir, lock.file)); err == nil { + s.addLocked(lock.pm) + } + } + tools := configuredTools(dir) + for _, section := range []struct{ tool, pm string }{ + {"poetry", "poetry"}, + {"uv", "uv"}, + {"pdm", "pdm"}, + // hatch has no dependency-add command, so it is a signal we cannot act on. + // Recording it keeps the project ambiguous instead of silently falling + // through to pip. + {"hatch", ""}, + } { + if tools[section.tool] { + s.addLocked(section.pm) + } + } + return s +} + +// configuredTools reports which tools pyproject.toml configures, by the [tool.*] +// tables it declares. Reading the tables rather than matching text means a comment +// or a string that mentions another tool is not mistaken for a declaration, and +// nested tables need no special case: TOML creates the parent table implicitly, so +// [tool.hatch.build] on its own still declares hatch. +// +// A file we cannot parse declares nothing. That leaves the project ambiguous and the +// user asked, which is the honest answer when we cannot read what manages it. +func configuredTools(dir string) map[string]bool { + b, err := os.ReadFile(filepath.Join(dir, "pyproject.toml")) + if err != nil { + return nil + } + var doc struct { + Tool map[string]any `toml:"tool"` + } + if err := toml.Unmarshal(b, &doc); err != nil { + return nil + } + tools := make(map[string]bool, len(doc.Tool)) + for name := range doc.Tool { + tools[name] = true + } + return tools +} + +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 + } + } + ep, exists := EntryPointFor(dir, "ruby-server-sdk") + return &DetectResult{ + Language: "Ruby", + PackageManager: detectRubyPM(dir), + SDKID: "ruby-server-sdk", + EntryPoint: ep, + EntryPointExists: exists, + } +} + +// detectRubyPM reports whether the project is Bundler-managed, since a bare `gem +// install` would succeed without recording the SDK for the app. +// Gemfile: https://bundler.io/guides/gemfile.html +func detectRubyPM(dir string) string { + return rubyPMSignals(dir).best("gem") +} + +// rubyPMSignals reports what the project says about its Ruby package manager. A +// Gemfile is Bundler's own manifest, so it settles the question; a gemspec alone +// does not, since the gem could be developed either way. +func rubyPMSignals(dir string) pmSignals { + var s pmSignals + if _, err := os.Stat(filepath.Join(dir, "Gemfile")); err == nil { + s.addLocked("bundle") + } + return s +} + +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"}, +} + +// sdkEntryPoints maps each SDK that writes to a file to its entry-point fallback +// and the candidates to look for. Detection and SDK-override both read this table +// so the two can't disagree about where code goes. Framework-specific layouts +// (Next.js, React) stay inline in detectNode: picking an SDK by hand clears the +// detected framework, so only the framework-neutral list can apply afterwards. +var sdkEntryPoints = map[string]struct { + fallback string + candidates []string +}{ + // 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 + "node-server": {"index.js", []string{ + "src/index.ts", "src/index.js", + "src/main.ts", "src/main.js", + "index.ts", "index.js", + "server.ts", "server.js", + "app.ts", "app.js", + }}, + // Django entry: https://docs.djangoproject.com/en/stable/ref/django-admin/ + // Flask entry: https://flask.palletsprojects.com/en/stable/quickstart/ + "python-server-sdk": {"main.py", []string{ + "src/main.py", "manage.py", "app.py", "main.py", + }}, + // config.ru: https://github.com/rack/rack/blob/main/SPEC.rdoc + "ruby-server-sdk": {"main.rb", []string{ + "config.ru", "app.rb", "main.rb", + }}, +} + +// EntryPointFor returns the file sdkID should write to in dir, joined to dir, and +// whether that file already exists. SDKs that only ever show a snippet have no +// entry point and return an empty path. +func EntryPointFor(dir, sdkID string) (string, bool) { + spec, ok := sdkEntryPoints[sdkID] + if !ok { + return "", false + } + return entryPoint(dir, spec.fallback, spec.candidates...) +} + +// PackageManagerFor returns the package manager that should install sdkID in dir. +// It derives the value from the project the way detection does, so choosing an SDK +// by hand doesn't leave the manager describing the language we guessed first. +func PackageManagerFor(dir, sdkID string) string { + switch sdkID { + case "node-server", "js-client-sdk", "react-client-sdk", "react-native": + return detectNodePM(dir) + case "python-server-sdk": + return PackageManagerChoiceFor(dir, sdkID).Name + case "ruby-server-sdk": + return detectRubyPM(dir) + case "go-server-sdk": + return "go" + case "dotnet-server-sdk": + return "dotnet" + default: + // Java, Android and Swift are installed by hand, so there is no command + // whose choice of manager could be wrong. + return "" + } +} + +// 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 "" +} + +// PMConfidence says whether the project itself identifies its package manager. +type PMConfidence string + +const ( + // PMDefinite means the project names one manager and only one. + PMDefinite PMConfidence = "definite" + // PMAmbiguous means the project does not say, or contradicts itself. Callers + // must ask rather than guess. + PMAmbiguous PMConfidence = "ambiguous" +) + +// PMCandidate is a package manager the user could pick, with the command it would +// run. Installed reports whether the tool is on PATH; it describes the machine, not +// the project, so it never makes a candidate more likely to be the right one. +type PMCandidate struct { + Name string `json:"name"` + Installed bool `json:"installed"` + Command string `json:"command"` +} + +// PMChoice is the package manager for a project plus how sure we are. +type PMChoice struct { + Name string `json:"name"` + Confidence PMConfidence `json:"confidence"` + // Reason explains an ambiguous verdict in the words the picker shows the user. + Reason string `json:"reason,omitempty"` + Candidates []PMCandidate `json:"candidates,omitempty"` +} + +// pmSignals collects what a project says about its package manager. declared holds +// an explicit statement, which settles the question on its own; locked holds +// managers implied by lockfiles or tool config, where more than one means the +// project contradicts itself. An empty entry in locked marks a tool we recognise +// but cannot drive. +type pmSignals struct { + declared string + locked []string + unactionable bool +} + +func (s *pmSignals) addLocked(pm string) { + if pm == "" { + s.unactionable = true + return + } + for _, existing := range s.locked { + if existing == pm { + return + } + } + s.locked = append(s.locked, pm) +} + +// best returns the manager to use, falling back to fallback when the project says +// nothing. It preserves the old detection behaviour for callers that only want a +// name, including the first-match-wins ordering when lockfiles conflict. +func (s pmSignals) best(fallback string) string { + if s.declared != "" { + return s.declared + } + if len(s.locked) > 0 { + return s.locked[0] + } + return fallback +} + +// choose turns signals into a verdict. options lists every manager valid for the +// language, in the order the picker should show them, and fallback is the +// conventional default when the project is silent. +func (s pmSignals) choose(options []string, fallback string, argvFor func(string) []string) PMChoice { + candidates := make([]PMCandidate, 0, len(options)) + for _, name := range options { + argv := argvFor(name) + // Installed tracks the executable the command actually runs, not the label. + // "pip" resolves to pip3 on a stock macOS box, and reporting that as missing + // would steer the user away from the option that works. + installed := len(argv) > 0 && onPath(argv[0]) + candidates = append(candidates, PMCandidate{ + Name: name, + Installed: installed, + Command: strings.Join(argv, " "), + }) + } + + switch { + case s.declared != "": + return PMChoice{Name: s.declared, Confidence: PMDefinite} + // A manager the project committed to settles it even when an unactionable tool + // is also configured: hatchling is a common build backend for uv and poetry + // projects, and uv can add the dependency regardless of who builds the wheel. + case len(s.locked) == 1: + return PMChoice{Name: s.locked[0], Confidence: PMDefinite} + case len(s.locked) > 1: + // Say what was actually found. A lockfile, a Pipfile and a [tool.*] table all + // count as a project committing to a manager, so naming lockfiles would send + // the reader looking for files that are not there. + return PMChoice{ + Name: s.locked[0], + Confidence: PMAmbiguous, + Reason: fmt.Sprintf("this project is set up for more than one manager (%s)", + strings.Join(s.locked, ", ")), + Candidates: candidates, + } + case s.unactionable: + return PMChoice{ + Name: fallback, + Confidence: PMAmbiguous, + Reason: "this project is managed by a tool that cannot add dependencies for us", + Candidates: candidates, + } + default: + return PMChoice{ + Name: fallback, + Confidence: PMAmbiguous, + Reason: "this project doesn't say which package manager it uses", + Candidates: candidates, + } + } +} + +// PackageManagerChoiceFor reports the package manager for sdkID in dir and whether +// the project actually identifies it. Languages with a single toolchain are always +// definite; there is nothing to ask. +func PackageManagerChoiceFor(dir, sdkID string) PMChoice { + cmdFor := func(pm string) []string { + args, _ := InstallArgs(dir, sdkID, pm) + return args + } + + switch sdkID { + case "node-server", "js-client-sdk", "react-client-sdk", "react-native": + return nodePMSignals(dir).choose([]string{"npm", "yarn", "pnpm", "bun"}, "npm", cmdFor) + case "python-server-sdk": + return pythonPMSignals(dir).choose([]string{"pip", "uv", "poetry", "pipenv", "pdm"}, "pip", cmdFor) + case "ruby-server-sdk": + return rubyPMSignals(dir).choose([]string{"bundle", "gem"}, "gem", cmdFor) + case "go-server-sdk": + return PMChoice{Name: "go", Confidence: PMDefinite} + case "dotnet-server-sdk": + return PMChoice{Name: "dotnet", Confidence: PMDefinite} + default: + // Java, Android and Swift are installed by hand. + return PMChoice{Name: "", Confidence: PMDefinite} + } +} 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..058370f38 --- /dev/null +++ b/internal/setup/detector_shapes_test.go @@ -0,0 +1,391 @@ +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) + // These shapes assert language, SDK and entry point. Package-manager + // confidence has its own tests, so it is cleared rather than restated on + // every shape. + got := *result + got.PackageManagerConfidence = "" + got.PackageManagerReason = "" + assert.Equal(t, want, got) + }) + } +} + +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..02e6e047d --- /dev/null +++ b/internal/setup/detector_test.go @@ -0,0 +1,1142 @@ +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) +} + +func TestEntryPointFor_FindsExistingFileNotBareDefault(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "src"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "src/index.js"), []byte(""), 0600)) + + ep, exists := EntryPointFor(dir, "node-server") + + assert.Equal(t, filepath.Join(dir, "src/index.js"), ep) + assert.True(t, exists) +} + +func TestEntryPointFor_SuggestsFallbackWhenNothingExists(t *testing.T) { + dir := t.TempDir() + + ep, exists := EntryPointFor(dir, "node-server") + + assert.Equal(t, filepath.Join(dir, "index.js"), ep) + assert.False(t, exists) +} + +func TestEntryPointFor_SnippetOnlySDKsHaveNoEntryPoint(t *testing.T) { + for _, sdkID := range []string{"react-client-sdk", "js-client-sdk", "go-server-sdk", "java-server-sdk"} { + t.Run(sdkID, func(t *testing.T) { + ep, exists := EntryPointFor(t.TempDir(), sdkID) + assert.Empty(t, ep) + assert.False(t, exists) + }) + } +} + +func TestPackageManagerFor_DerivesFromProjectNotDetectedLanguage(t *testing.T) { + t.Run("bundler project uses bundle so the gem is recorded", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "Gemfile"), []byte("source 'https://rubygems.org'\n"), 0600)) + assert.Equal(t, "bundle", PackageManagerFor(dir, "ruby-server-sdk")) + }) + + t.Run("ruby without a Gemfile falls back to gem", func(t *testing.T) { + assert.Equal(t, "gem", PackageManagerFor(t.TempDir(), "ruby-server-sdk")) + }) + + t.Run("node lockfile picks the matching manager", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "pnpm-lock.yaml"), []byte(""), 0600)) + assert.Equal(t, "pnpm", PackageManagerFor(dir, "node-server")) + }) + + t.Run("python uv lockfile", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "uv.lock"), []byte(""), 0600)) + assert.Equal(t, "uv", PackageManagerFor(dir, "python-server-sdk")) + }) + + t.Run("manual-install SDKs have no manager", func(t *testing.T) { + assert.Empty(t, PackageManagerFor(t.TempDir(), "java-server-sdk")) + }) +} + +func TestPackageManagerChoice_Definite(t *testing.T) { + tests := []struct { + name string + files map[string]string + sdkID string + want string + }{ + // The most explicit statement a Node project can make outranks lockfiles. + {"corepack field", map[string]string{ + "package.json": `{"packageManager":"pnpm@9.1.0"}`, + }, "node-server", "pnpm"}, + {"corepack field with prerelease", map[string]string{ + "package.json": `{"packageManager":"pnpm@9.1.0-beta.1"}`, + }, "node-server", "pnpm"}, + // corepack writes this hash form itself when it pins a manager. + {"corepack field with build metadata", map[string]string{ + "package.json": `{"packageManager":"yarn@4.1.0+sha224.abcdef"}`, + }, "node-server", "yarn"}, + {"corepack field beats a conflicting lockfile", map[string]string{ + "package.json": `{"packageManager":"pnpm@9.1.0"}`, + "yarn.lock": "", + }, "node-server", "pnpm"}, + {"single node lockfile", map[string]string{ + "package.json": `{}`, + "pnpm-lock.yaml": "", + }, "node-server", "pnpm"}, + {"package-lock only", map[string]string{ + "package.json": `{}`, + "package-lock.json": "", + }, "node-server", "npm"}, + {"uv lockfile", map[string]string{"uv.lock": ""}, "python-server-sdk", "uv"}, + {"poetry lockfile", map[string]string{"poetry.lock": ""}, "python-server-sdk", "poetry"}, + {"tool.uv section", map[string]string{ + "pyproject.toml": "[project]\nname=\"a\"\n[tool.uv]\n", + }, "python-server-sdk", "uv"}, + {"tool.pdm section", map[string]string{ + "pyproject.toml": "[project]\nname=\"a\"\n[tool.pdm]\n", + }, "python-server-sdk", "pdm"}, + {"Gemfile", map[string]string{"Gemfile": "source 'x'"}, "ruby-server-sdk", "bundle"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + for name, body := range tt.files { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(body), 0600)) + } + + choice := PackageManagerChoiceFor(dir, tt.sdkID) + + assert.Equal(t, PMDefinite, choice.Confidence) + assert.Equal(t, tt.want, choice.Name) + assert.Empty(t, choice.Candidates, "a definite verdict needs no choice") + }) + } +} + +func TestPackageManagerChoice_Ambiguous(t *testing.T) { + tests := []struct { + name string + files map[string]string + sdkID string + wantReason string + wantOptions []string + }{ + // No detection tuning can fix this: the project contradicts itself. + {"conflicting node lockfiles", map[string]string{ + "package.json": `{}`, + "yarn.lock": "", + "package-lock.json": "", + }, "node-server", "more than one manager", []string{"npm", "yarn", "pnpm", "bun"}}, + {"bare package.json", map[string]string{ + "package.json": `{}`, + }, "node-server", "doesn't say", []string{"npm", "yarn", "pnpm", "bun"}}, + // Corepack needs one exact version, so honouring anything else would route the + // user into a manager that refuses to run. + {"packageManager without a version", map[string]string{ + "package.json": `{"packageManager":"pnpm"}`, + }, "node-server", "doesn't say", []string{"npm", "yarn", "pnpm", "bun"}}, + {"packageManager with a caret range", map[string]string{ + "package.json": `{"packageManager":"pnpm@^11.13.0"}`, + }, "node-server", "doesn't say", []string{"npm", "yarn", "pnpm", "bun"}}, + {"packageManager with a comparator range", map[string]string{ + "package.json": `{"packageManager":"pnpm@>=11"}`, + }, "node-server", "doesn't say", []string{"npm", "yarn", "pnpm", "bun"}}, + {"packageManager with a partial version", map[string]string{ + "package.json": `{"packageManager":"pnpm@11"}`, + }, "node-server", "doesn't say", []string{"npm", "yarn", "pnpm", "bun"}}, + // uv does not require committing the lock, and PEP 621 has no uv marker. + {"PEP 621 pyproject only", map[string]string{ + "pyproject.toml": "[project]\nname=\"a\"\n", + }, "python-server-sdk", "doesn't say", []string{"pip", "uv", "poetry", "pipenv", "pdm"}}, + {"requirements.txt only", map[string]string{ + "requirements.txt": "flask\n", + }, "python-server-sdk", "doesn't say", []string{"pip", "uv", "poetry", "pipenv", "pdm"}}, + // hatch cannot add dependencies for us, so we must not pick it or silently pip. + {"hatch project", map[string]string{ + "pyproject.toml": "[project]\nname=\"a\"\n[tool.hatch]\n", + }, "python-server-sdk", "cannot add dependencies", []string{"pip", "uv", "poetry", "pipenv", "pdm"}}, + {"gemspec without Gemfile", map[string]string{ + "a.gemspec": "Gem::Specification.new", + }, "ruby-server-sdk", "doesn't say", []string{"bundle", "gem"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + for name, body := range tt.files { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(body), 0600)) + } + + choice := PackageManagerChoiceFor(dir, tt.sdkID) + + assert.Equal(t, PMAmbiguous, choice.Confidence) + assert.Contains(t, choice.Reason, tt.wantReason) + names := make([]string, 0, len(choice.Candidates)) + for _, c := range choice.Candidates { + names = append(names, c.Name) + assert.NotEmpty(t, c.Command, "every candidate needs the command it would run") + } + assert.Equal(t, tt.wantOptions, names) + }) + } +} + +// Installed state describes the machine, so it must never change the verdict. +func TestPackageManagerChoice_InstalledStateDoesNotDecide(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{}`), 0600)) + + stubPath(t, "pnpm") // the only manager on this machine + choice := PackageManagerChoiceFor(dir, "node-server") + + assert.Equal(t, PMAmbiguous, choice.Confidence, "one installed tool is not evidence about the project") + assert.Equal(t, "npm", choice.Name, "the conventional default stands until the user picks") + for _, c := range choice.Candidates { + assert.Equal(t, c.Name == "pnpm", c.Installed) + } +} + +func TestPackageManagerChoice_SingleToolchainsAreAlwaysDefinite(t *testing.T) { + for sdkID, want := range map[string]string{ + "go-server-sdk": "go", + "dotnet-server-sdk": "dotnet", + "java-server-sdk": "", + } { + t.Run(sdkID, func(t *testing.T) { + choice := PackageManagerChoiceFor(t.TempDir(), sdkID) + assert.Equal(t, PMDefinite, choice.Confidence) + assert.Equal(t, want, choice.Name) + }) + } +} + +// Real pyproject files rarely carry a bare [tool.x] header; the tables that matter +// are nested. Matching only the bare header made these signals near-dead. +func TestPackageManagerChoice_NestedToolTables(t *testing.T) { + tests := []struct { + name string + pyproject string + wantName string + wantDefinite bool + }{ + {"hatch build table only", "[project]\nname=\"a\"\n[tool.hatch.build.targets.wheel]\npackages=[\"a\"]\n", "pip", false}, + {"hatch version table only", "[project]\nname=\"a\"\n[tool.hatch.version]\npath=\"a/__init__.py\"\n", "pip", false}, + {"poetry dependencies table only", "[project]\nname=\"a\"\n[tool.poetry.dependencies]\npython=\"^3.12\"\n", "poetry", true}, + {"uv sources table only", "[project]\nname=\"a\"\n[tool.uv.sources]\nx={git=\"...\"}\n", "uv", true}, + {"pdm dev-dependencies table only", "[project]\nname=\"a\"\n[tool.pdm.dev-dependencies]\ntest=[]\n", "pdm", true}, + // The trailing delimiter matters: [tool.uv] must not match [tool.uvicorn]. + {"uvicorn is not uv", "[project]\nname=\"a\"\n[tool.uvicorn]\nport=8000\n", "pip", false}, + {"hatchling ruff etc are not hatch", "[project]\nname=\"a\"\n[tool.ruff]\nline-length=100\n", "pip", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "pyproject.toml"), []byte(tt.pyproject), 0600)) + + choice := PackageManagerChoiceFor(dir, "python-server-sdk") + + assert.Equal(t, tt.wantName, choice.Name) + if tt.wantDefinite { + assert.Equal(t, PMDefinite, choice.Confidence) + } else { + assert.Equal(t, PMAmbiguous, choice.Confidence) + } + }) + } +} + +// A PDM project that commits only its lockfile is still a PDM project. +func TestPackageManagerChoice_PdmLockfile(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "pdm.lock"), []byte(""), 0600)) + + choice := PackageManagerChoiceFor(dir, "python-server-sdk") + + assert.Equal(t, PMDefinite, choice.Confidence) + assert.Equal(t, "pdm", choice.Name) +} + +// hatchling is a common build backend for uv and poetry projects. The manager the +// project committed to can still add the dependency, whoever builds the wheel. +func TestPackageManagerChoice_ActionableSignalBeatsHatch(t *testing.T) { + tests := []struct { + name string + files map[string]string + want string + }{ + {"uv lockfile alongside hatch build backend", map[string]string{ + "pyproject.toml": "[project]\nname=\"a\"\n[tool.hatch.build.targets.wheel]\npackages=[\"a\"]\n", + "uv.lock": "", + }, "uv"}, + {"uv table alongside hatch", map[string]string{ + "pyproject.toml": "[project]\nname=\"a\"\n[tool.uv]\n[tool.hatch.version]\npath=\"x\"\n", + }, "uv"}, + {"poetry alongside hatch", map[string]string{ + "pyproject.toml": "[project]\nname=\"a\"\n[tool.poetry]\n[tool.hatch.build]\n", + }, "poetry"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + for name, body := range tt.files { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(body), 0600)) + } + + choice := PackageManagerChoiceFor(dir, "python-server-sdk") + + assert.Equal(t, PMDefinite, choice.Confidence, + "setup would refuse to install though %s can add the dependency", tt.want) + assert.Equal(t, tt.want, choice.Name) + }) + } +} + +// pyproject.toml is read as TOML rather than searched as text, so only the tables it +// actually declares count as a project committing to a tool. +func TestPackageManagerChoice_ToolTablesAreParsedNotMatched(t *testing.T) { + tests := []struct { + name string + pyproject string + wantName string + wantDefinite bool + }{ + // A note about the tool a project migrated away from is not a declaration. + {"comment mentions another tool", "[project]\nname=\"a\"\n# migrated away from [tool.poetry] in March\n[tool.uv]\n", "uv", true}, + // Nor is a table name inside a string. + {"multi-line string mentions another tool", + "[project]\nname=\"a\"\ndescription=\"\"\"\nsee [tool.poetry] for history\n\"\"\"\n[tool.uv]\n", "uv", true}, + {"single-quoted string mentions another tool", + "[project]\nname=\"a\"\nsummary='see [tool.poetry]'\n[tool.uv]\n", "uv", true}, + // A trailing comment on the header itself is still a declaration. + {"header with a trailing comment", "[project]\nname=\"a\"\n[tool.uv] # the real one\n", "uv", true}, + // Parent tables are implicit, so a nested table declares its tool. + {"nested table only", "[project]\nname=\"a\"\n[tool.poetry.dependencies]\npython=\"^3.12\"\n", "poetry", true}, + // A different tool whose name merely starts the same way is not a match. + {"similarly named tool", "[project]\nname=\"a\"\n[tool.uvicorn]\nport=8000\n", "pip", false}, + // Unreadable TOML declares nothing, so the user is asked rather than guessed at. + {"malformed toml", "[project\nname=\"a\"\n[tool.uv]\n", "pip", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "pyproject.toml"), []byte(tt.pyproject), 0600)) + + choice := PackageManagerChoiceFor(dir, "python-server-sdk") + + assert.Equal(t, tt.wantName, choice.Name) + if tt.wantDefinite { + assert.Equal(t, PMDefinite, choice.Confidence) + } else { + assert.Equal(t, PMAmbiguous, choice.Confidence) + } + }) + } +} + +// A [tool.*] table and a Pipfile count as commitments too, so the reason must not +// send the reader looking for lockfiles that are not there. +func TestPackageManagerChoice_ConflictReasonNamesWhatWasFound(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "pyproject.toml"), + []byte("[project]\nname=\"a\"\n[tool.uv]\n[tool.poetry]\n"), 0600)) + + choice := PackageManagerChoiceFor(dir, "python-server-sdk") + + require.Equal(t, PMAmbiguous, choice.Confidence) + assert.Contains(t, choice.Reason, "set up for more than one manager") + assert.NotContains(t, choice.Reason, "lockfile", + "neither signal here is a lockfile") +} diff --git a/internal/setup/initializer.go b/internal/setup/initializer.go new file mode 100644 index 000000000..9ce25bb8c --- /dev/null +++ b/internal/setup/initializer.go @@ -0,0 +1,521 @@ +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] +} + +// 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..170c4e14b --- /dev/null +++ b/internal/setup/installer.go @@ -0,0 +1,655 @@ +package setup + +import ( + "bytes" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "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"` + // Warning carries something the user has to act on even though the install + // worked, so success is not reported as though nothing were left to do. + Warning string `json:"warning,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(dir, 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 + } + + // A virtualenv we cannot install into is a clearer thing to report than whatever + // the pip outside it would do. + if reason := pipLessVenvReason(dir, pkg, args); reason != "" { + return &InstallResult{ + SDKID: detection.SDKID, + Package: pkg, + Failed: true, + FailureReason: reason, + }, nil + } + + // Confirm the tool exists before shelling out, so a missing package manager + // warns with what to install instead of surfacing an exec "not found" error. + // We never install the tool ourselves. + if reason := missingToolReason(args[0]); reason != "" { + return &InstallResult{ + SDKID: detection.SDKID, + Package: pkg, + Failed: true, + FailureReason: reason, + }, 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 { + if reason := packageManagerSpecReason(out); reason != "" { + return &InstallResult{ + SDKID: detection.SDKID, + Package: pkg, + Command: command, + Failed: true, + FailureReason: reason, + }, nil + } + if reason := externallyManagedReason(dir, out); reason != "" { + // No Command: the reason says not to run this pip, and the done screen + // offers a non-empty Command as "install it yourself with". + return &InstallResult{ + SDKID: detection.SDKID, + Package: pkg, + Failed: true, + FailureReason: reason, + }, nil + } + return nil, fmt.Errorf("%s: %w\n%s", command, err, strings.TrimSpace(string(out))) + } + return &InstallResult{ + SDKID: detection.SDKID, + Package: pkg, + Command: command, + Warning: unrecordedDependencyWarning(dir, pkg, args), + 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)) + } +} + +// packageManagerSpecReason recognises a Node manager refusing to run because the +// packageManager field in package.json is not a spec corepack accepts: it requires +// an exact version, so both a missing one and a range are rejected. The manifest is +// malformed rather than the command wrong, and repairing someone's manifest is not +// ours to do, so say what is wrong and let them fix it. +func packageManagerSpecReason(out []byte) string { + // package.json has to be named in the output. Both corepack refusals mention it, + // and without that check any failure whose text happens to mention a missing or + // non-semver version — from a gem, a Python package, a Go module — would have its + // real error replaced by advice about a field it does not have. + if !bytes.Contains(out, []byte("package.json")) { + return "" + } + badSpec := bytes.Contains(out, []byte("No version specified")) || + bytes.Contains(out, []byte("expected a semver version")) || + bytes.Contains(out, []byte("Invalid package manager specification")) + if !badSpec { + return "" + } + return "the packageManager field in package.json is not a specification your package " + + "manager accepts: it needs one exact version, so a missing version or a range such as " + + "\"pnpm@^11.13.0\" is refused. Pin it (for example \"pnpm@11.13.0\") or remove the field, " + + "then run setup again." +} + +// externallyManagedReason recognises a PEP 668 refusal and says what to do about +// it. Homebrew and most current Linux distributions mark their Python as managed +// by the OS package manager, so pip declines to write into it. A virtualenv is the +// supported way through, and it is the user's to create: installing into their +// system Python, or passing --break-system-packages to force it, risks breaking +// tools that Python came with. +func externallyManagedReason(dir string, out []byte) string { + if !bytes.Contains(out, []byte("externally-managed-environment")) { + return "" + } + target := dir + if target == "" { + target = "your project" + } + return fmt.Sprintf( + "this Python is managed by your operating system, so pip will not install into it. "+ + "Create a virtual environment in %s and run setup again:\n"+ + " python3 -m venv .venv\n"+ + " source .venv/bin/activate\n"+ + "Setup uses .venv automatically once it exists.", + target, + ) +} + +// pipLessVenvReason explains that the project's virtualenv cannot be installed into. +// It fires only when the command is that virtualenv's own pip and the pip is not +// there — uv, poetry and pipenv own their environments and never reach this. +func pipLessVenvReason(dir, pkg string, args []string) string { + target, exists := venvPipTarget(dir) + if target == "" || exists || len(args) == 0 || args[0] != target { + return "" + } + return fmt.Sprintf( + "the virtual environment at %s has no pip, which is how `uv venv` creates one. "+ + "Install into it with `uv pip install %s`, or recreate it with "+ + "`python3 -m venv .venv`, then run setup again.", + filepath.Dir(filepath.Dir(target)), pkg, + ) +} + +// unrecordedDependencyWarning reports that a bare pip install leaves the project's +// manifest untouched. poetry, uv, pipenv and pdm record the dependency themselves, +// and Ruby gets `bundle add` for the same reason, but pip has no equivalent command: +// editing someone's manifest is not something setup does unasked, so it says what is +// missing instead of writing the file. +func unrecordedDependencyWarning(dir, pkg string, args []string) string { + if len(args) == 0 || filepath.Base(args[0]) != "pip" && filepath.Base(args[0]) != "pip3" && + filepath.Base(args[0]) != "pip.exe" { + return "" + } + manifest := "" + for _, name := range []string{"requirements.txt", "requirements/base.txt"} { + if _, err := os.Stat(filepath.Join(dir, name)); err == nil { + manifest = name + break + } + } + if manifest == "" { + return "" + } + // Whole-name matching, so a related pin such as launchdarkly-server-sdk-otel is + // not read as the SDK itself being recorded. + if fileMentionsPackage(filepath.Join(dir, manifest), pkg) { + return "" + } + return fmt.Sprintf( + "pip installed %s but did not record it in %s, so a fresh checkout and CI will not have it. "+ + "Add a line for %s to %s.", + pkg, manifest, pkg, manifest, + ) +} + +// installHints maps a package-manager executable to how the user can get it. +var installHints = map[string]string{ + "pip": "install Python from https://www.python.org/downloads or your package manager", + "pip3": "install Python from https://www.python.org/downloads or your package manager", + "poetry": "see https://python-poetry.org/docs/#installation", + "uv": "see https://docs.astral.sh/uv/getting-started/installation", + "pipenv": "see https://pipenv.pypa.io/en/latest/installation.html", + "pdm": "see https://pdm-project.org/en/latest/#installation", + "npm": "install Node.js from https://nodejs.org", + "yarn": "see https://yarnpkg.com/getting-started/install", + "pnpm": "see https://pnpm.io/installation", + "bun": "see https://bun.sh/docs/installation", + "bundle": "run `gem install bundler`", + "gem": "install Ruby from https://www.ruby-lang.org/en/documentation/installation", + "go": "install Go from https://go.dev/dl", + "dotnet": "install the .NET SDK from https://dotnet.microsoft.com/download", +} + +// missingToolReason returns an explanation when tool is not on PATH, or an empty +// string when it is available. +func missingToolReason(tool string) string { + if onPath(tool) { + return "" + } + if hint, ok := installHints[tool]; ok { + return fmt.Sprintf("%s is not installed or not on your PATH — %s", tool, hint) + } + return fmt.Sprintf("%s is not installed or not on your PATH", tool) +} + +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. +// dir is the project directory, which decides whether a virtualenv's pip is used; +// pass an empty string when there is no project in mind. +func InstallArgs(dir, 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(dir, 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 + } +} + +// lookPath is indirected so tests can control which executables appear to exist. +var lookPath = exec.LookPath + +// onPath reports whether name is an executable on PATH. +func onPath(name string) bool { + _, err := lookPath(name) + return err == nil +} + +// 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(dir, 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} + case "pdm": + return []string{"pdm", "add", pkg} + default: + return pipInstallCmd(dir, pkg) + } +} + +// virtualEnv reports the active virtualenv, indirected so tests are not affected +// by the environment the suite happens to run in. +var virtualEnv = func() string { return os.Getenv("VIRTUAL_ENV") } + +// venvRoots lists the virtualenvs to consider for dir, the active one first. An +// empty dir means the caller has no project in mind, so relative lookups would +// search whatever directory the process happens to be in. +func venvRoots(dir string) []string { + var roots []string + if active := virtualEnv(); active != "" { + roots = append(roots, active) + } + if dir != "" { + roots = append(roots, filepath.Join(dir, ".venv"), filepath.Join(dir, "venv")) + } + // Absolute, so a root can be compared against the command we build from it and + // so the path we show names the same place whatever the working directory is. + for i, root := range roots { + if abs, err := filepath.Abs(root); err == nil { + roots[i] = abs + } + } + return roots +} + +// venvPipIn returns root's own pip as an absolute path, or an empty string when the +// virtualenv has none. The path has to be absolute because the command runs with its +// working directory set to the project: a relative executable is resolved after that +// change, so "app/.venv/bin/pip" run in "app" would be looked for at +// "app/app/.venv/bin/pip" and the install would fail with the virtualenv found. +func venvPipIn(root string) string { + for _, rel := range venvPipLayouts() { + candidate := filepath.Join(root, rel) + info, err := os.Stat(candidate) + if err != nil || info.IsDir() { + continue + } + return absOrAsGiven(candidate) + } + return "" +} + +// venvPipLayouts lists where a virtualenv keeps pip, this platform's layout first. +// Windows uses Scripts\pip.exe; everything else uses bin/pip. Naming the wrong one +// would point the plan and the previewed command at a path the environment on this +// machine never has. +func venvPipLayouts() []string { + unix := filepath.Join("bin", "pip") + windows := filepath.Join("Scripts", "pip.exe") + if runtime.GOOS == "windows" { + return []string{windows, unix} + } + return []string{unix, windows} +} + +// absOrAsGiven makes a path absolute, falling back to the path itself when the +// working directory cannot be read. +func absOrAsGiven(path string) string { + if abs, err := filepath.Abs(path); err == nil { + return abs + } + return path +} + +// pipInstallCmd returns the pip install command. A virtualenv's pip wins, then +// whichever of pip3 and pip is on PATH: recent macOS and Homebrew installs ship +// pip3 with no bare pip, so a hardcoded `pip` fails outright on a common box. +// +// Only an existing pip is used. Reaching past it — to `python3 -m pip`, or to +// bootstrapping pip with ensurepip — would install tooling onto the user's +// machine, which is not ours to do. When no pip is found the bare form is +// returned so the plan screen has something to show, and Install's pre-flight +// check warns instead of running anything. +func pipInstallCmd(dir, pkg string) []string { + // A virtualenv without pip still names the environment the project set up, and + // naming it is more use than a pip from PATH that Install will refuse to run: + // the plan screen, --dry-run and the picker all read this, and a command shown + // there is one a reader may run by hand. + if pip, _ := venvPipTarget(dir); pip != "" { + return []string{pip, "install", pkg} + } + for _, bin := range []string{"pip3", "pip"} { + if onPath(bin) { + return []string{bin, "install", pkg} + } + } + return []string{"pip", "install", pkg} +} + +// venvPipTarget returns the pip belonging to the project's virtualenv, and whether it +// is actually there. An empty path means there is no virtualenv to install into. +func venvPipTarget(dir string) (path string, exists bool) { + for _, root := range venvRoots(dir) { + if pip := venvPipIn(root); pip != "" { + return pip, true + } + if isVirtualEnv(root) { + // No pip to find, so name where this platform would keep one. + return absOrAsGiven(filepath.Join(root, venvPipLayouts()[0])), false + } + } + return "", false +} + +// isVirtualEnv reports whether root is a virtualenv. pyvenv.cfg is what marks one, +// and it is there whether or not the environment was seeded with pip. +func isVirtualEnv(root string) bool { + _, err := os.Stat(filepath.Join(root, "pyvenv.cfg")) + return err == nil +} + +// 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(dir, 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..963901543 --- /dev/null +++ b/internal/setup/installer_test.go @@ -0,0 +1,941 @@ +package setup + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "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) + }) + } +} + +// stubPath makes only the named executables appear to exist on PATH for the rest +// of the test. +func stubPath(t *testing.T, available ...string) { + t.Helper() + set := make(map[string]bool, len(available)) + for _, name := range available { + set[name] = true + } + original := lookPath + lookPath = func(name string) (string, error) { + if set[name] { + return "/usr/bin/" + name, nil + } + return "", exec.ErrNotFound + } + t.Cleanup(func() { lookPath = original }) +} + +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) { + stubPath(t, "pip", "poetry", "uv", "pipenv") + args, pkg := InstallArgs("", "python-server-sdk", tt.packageManager) + assert.Equal(t, tt.want, args) + assert.Equal(t, "launchdarkly-server-sdk", pkg) + }) + } +} + +func TestInstallArgs_Python_ResolvesAvailableTool(t *testing.T) { + pkg := "launchdarkly-server-sdk" + tests := []struct { + name string + available []string + want []string + }{ + // Recent macOS and Homebrew ship pip3 with no bare pip. + {"only pip3", []string{"pip3", "python3"}, []string{"pip3", "install", pkg}}, + {"only pip", []string{"pip", "python"}, []string{"pip", "install", pkg}}, + // pip3 wins so a stale python2 pip is never chosen. + {"both pip and pip3", []string{"pip", "pip3"}, []string{"pip3", "install", pkg}}, + // An interpreter is not a stand-in for pip: setup will not bootstrap tooling, + // so the bare form is kept and Install warns rather than running it. + {"interpreters but no pip", []string{"python3", "python"}, []string{"pip", "install", pkg}}, + {"nothing available", nil, []string{"pip", "install", pkg}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stubPath(t, tt.available...) + args, _ := InstallArgs("", "python-server-sdk", "") + assert.Equal(t, tt.want, args) + }) + } +} + +func TestInstall_MissingToolReportsFailureNotExecError(t *testing.T) { + stubPath(t) // nothing on PATH + dir := t.TempDir() + installer := PackageInstaller{ + run: func(string, []string) ([]byte, error) { + t.Fatal("must not shell out to a tool that does not exist") + return nil, nil + }, + } + + result, err := installer.Install(dir, &DetectResult{SDKID: "python-server-sdk"}) + + require.NoError(t, err) + assert.True(t, result.Failed) + assert.False(t, result.Success) + assert.Contains(t, result.FailureReason, "pip is not installed or not on your PATH") + assert.Contains(t, result.FailureReason, "python.org") +} + +// A Python interpreter is not a substitute for pip: using it would mean installing +// tooling onto the user's machine, so setup warns instead. +func TestInstall_InterpreterWithoutPipWarnsAndRunsNothing(t *testing.T) { + stubPath(t, "python3", "python") + installer := PackageInstaller{ + run: func(string, []string) ([]byte, error) { + t.Fatal("must not install anything when pip is absent") + return nil, nil + }, + } + + result, err := installer.Install(t.TempDir(), &DetectResult{SDKID: "python-server-sdk"}) + + require.NoError(t, err) + assert.True(t, result.Failed) + assert.Contains(t, result.FailureReason, "pip is not installed or not on your PATH") + assert.NotContains(t, result.FailureReason, "ensurepip") +} + +func TestInstall_MissingNodeToolReportsFailure(t *testing.T) { + stubPath(t) // npm absent + installer := PackageInstaller{ + run: func(string, []string) ([]byte, error) { + t.Fatal("must not shell out to a tool that does not exist") + return nil, nil + }, + } + + result, err := installer.Install(t.TempDir(), &DetectResult{SDKID: "node-server"}) + + require.NoError(t, err) + assert.True(t, result.Failed) + assert.Contains(t, result.FailureReason, "npm is not installed") +} + +func TestInstall_PresentToolRuns(t *testing.T) { + stubPath(t, "npm") + var ran []string + installer := PackageInstaller{ + run: func(_ string, args []string) ([]byte, error) { + ran = args + return nil, nil + }, + } + + result, err := installer.Install(t.TempDir(), &DetectResult{SDKID: "node-server"}) + + require.NoError(t, err) + assert.True(t, result.Success) + assert.False(t, result.Failed) + assert.Equal(t, []string{"npm", "install", "@launchdarkly/node-server-sdk"}, ran) +} + +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) { + stubPath(t, "npm") + 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) { + stubPath(t, "npm") + 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) { + stubPath(t, "dotnet") + 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) { + stubPath(t, "dotnet") + 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) { + stubPath(t, "dotnet") + 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) { + stubPath(t, "dotnet") + 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") + }) + } +} + +// stubVirtualEnv controls what looks like an active virtualenv, so the suite is not +// affected by the environment it happens to run in. +func stubVirtualEnv(t *testing.T, dir string) { + t.Helper() + original := virtualEnv + virtualEnv = func() string { return dir } + t.Cleanup(func() { virtualEnv = original }) +} + +// fakeVenv creates the pip executable a virtualenv would have. +func fakeVenv(t *testing.T, root string) string { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(root, "bin"), 0755)) + pip := filepath.Join(root, "bin", "pip") + require.NoError(t, os.WriteFile(pip, []byte("#!/bin/sh\n"), 0700)) + return pip +} + +// A virtualenv is where a project's dependencies belong, and on a PEP 668 +// interpreter it is the only place pip can write at all. +func TestInstallArgs_Python_PrefersProjectVirtualenv(t *testing.T) { + stubVirtualEnv(t, "") + stubPath(t, "pip3") // a system pip exists and must still lose + dir := t.TempDir() + pip := fakeVenv(t, filepath.Join(dir, ".venv")) + + args, _ := InstallArgs(dir, "python-server-sdk", "") + + assert.Equal(t, []string{pip, "install", "launchdarkly-server-sdk"}, args) +} + +func TestInstallArgs_Python_AcceptsVenvDirectoryName(t *testing.T) { + stubVirtualEnv(t, "") + stubPath(t) + dir := t.TempDir() + pip := fakeVenv(t, filepath.Join(dir, "venv")) + + args, _ := InstallArgs(dir, "python-server-sdk", "") + + assert.Equal(t, []string{pip, "install", "launchdarkly-server-sdk"}, args) +} + +func TestInstallArgs_Python_PrefersActiveVirtualenvOverProjectOne(t *testing.T) { + stubPath(t, "pip3") + active := t.TempDir() + activePip := fakeVenv(t, active) + stubVirtualEnv(t, active) + project := t.TempDir() + fakeVenv(t, filepath.Join(project, ".venv")) + + args, _ := InstallArgs(project, "python-server-sdk", "") + + assert.Equal(t, activePip, args[0], "the environment the user activated wins") +} + +// Without a project directory there is nothing to search, and a relative lookup +// would reach into whatever directory the process happens to be in. +func TestInstallArgs_Python_NoDirDoesNotSearchRelatively(t *testing.T) { + stubVirtualEnv(t, "") + stubPath(t, "pip3") + + args, _ := InstallArgs("", "python-server-sdk", "") + + assert.Equal(t, []string{"pip3", "install", "launchdarkly-server-sdk"}, args) +} + +// PEP 668: Homebrew and most current distros mark their Python as OS-managed, and +// pip refuses to write into it. The raw refusal tells the user nothing actionable. +func TestInstall_ExternallyManagedEnvironment_ExplainsVirtualenv(t *testing.T) { + stubVirtualEnv(t, "") + stubPath(t, "pip3") + dir := t.TempDir() + installer := PackageInstaller{ + run: func(string, []string) ([]byte, error) { + return []byte("error: externally-managed-environment\n\n× This environment is externally managed"), + errors.New("exit status 1") + }, + } + + result, err := installer.Install(dir, &DetectResult{SDKID: "python-server-sdk"}) + + require.NoError(t, err, "a recoverable refusal must not dead-end the flow") + assert.True(t, result.Failed) + assert.Contains(t, result.FailureReason, "managed by your operating system") + assert.Contains(t, result.FailureReason, "python3 -m venv .venv") + assert.Contains(t, result.FailureReason, dir) + // Forcing past the refusal would risk breaking the OS's own Python. + assert.NotContains(t, result.FailureReason, "break-system-packages") +} + +// Any other install failure keeps its existing error path. +func TestInstall_OtherFailuresStillError(t *testing.T) { + stubVirtualEnv(t, "") + stubPath(t, "npm") + installer := PackageInstaller{ + run: func(string, []string) ([]byte, error) { + return []byte("npm ERR! network timeout"), errors.New("exit status 1") + }, + } + + _, err := installer.Install(t.TempDir(), &DetectResult{SDKID: "node-server", PackageManager: "npm"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "network timeout") +} + +// The install runs with its working directory set to the project, and a relative +// executable path is resolved after that change — so a relative project dir would +// have the dir applied twice and the install would fail with the venv found. +func TestInstallArgs_Python_VenvPathIsAbsoluteForARelativeDir(t *testing.T) { + stubVirtualEnv(t, "") + stubPath(t, "pip3") + parent := t.TempDir() + fakeVenv(t, filepath.Join(parent, "app", ".venv")) + chdirTo(t, parent) + + args, _ := InstallArgs("app", "python-server-sdk", "") + + require.NotEmpty(t, args) + assert.True(t, filepath.IsAbs(args[0]), + "a relative pip path is resolved against the command's working directory: %s", args[0]) + assert.FileExists(t, args[0]) +} + +// An absolute project dir must be left as it is. +func TestInstallArgs_Python_VenvPathKeepsAbsoluteDir(t *testing.T) { + stubVirtualEnv(t, "") + stubPath(t, "pip3") + dir := t.TempDir() + pip := fakeVenv(t, filepath.Join(dir, ".venv")) + + args, _ := InstallArgs(dir, "python-server-sdk", "") + + assert.Equal(t, pip, args[0]) +} + +// chdirTo moves into dir for the duration of the test. +func chdirTo(t *testing.T, dir string) { + t.Helper() + original, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(dir)) + t.Cleanup(func() { _ = os.Chdir(original) }) +} + +// `uv venv` creates a virtualenv with no pip in it. Falling through to a pip on +// PATH would install outside the project, or be refused by PEP 668 and then advise +// creating the virtualenv already sitting there. +func TestInstall_VenvWithoutPip_ReportsItRatherThanUsingSystemPip(t *testing.T) { + stubVirtualEnv(t, "") + stubPath(t, "pip3") + dir := t.TempDir() + root := filepath.Join(dir, ".venv") + require.NoError(t, os.MkdirAll(filepath.Join(root, "bin"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "pyvenv.cfg"), []byte("home = /usr\n"), 0600)) + + installer := PackageInstaller{ + run: func(string, []string) ([]byte, error) { + t.Fatal("must not install with a pip outside the project's virtualenv") + return nil, nil + }, + } + + result, err := installer.Install(dir, &DetectResult{SDKID: "python-server-sdk"}) + + require.NoError(t, err) + assert.True(t, result.Failed) + assert.Contains(t, result.FailureReason, "has no pip") + assert.Contains(t, result.FailureReason, root) + assert.Contains(t, result.FailureReason, "uv pip install launchdarkly-server-sdk") + // Do not offer a command that would install outside the virtualenv. + assert.Empty(t, result.Command) +} + +// A manager that owns its own environment is unaffected by a pip-less virtualenv. +func TestInstall_VenvWithoutPip_LeavesUvAlone(t *testing.T) { + stubVirtualEnv(t, "") + stubPath(t, "uv") + dir := t.TempDir() + root := filepath.Join(dir, ".venv") + require.NoError(t, os.MkdirAll(filepath.Join(root, "bin"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "pyvenv.cfg"), []byte("home = /usr\n"), 0600)) + + var ran []string + installer := PackageInstaller{ + run: func(_ string, args []string) ([]byte, error) { ran = args; return nil, nil }, + } + + result, err := installer.Install(dir, &DetectResult{SDKID: "python-server-sdk", PackageManager: "uv"}) + + require.NoError(t, err) + assert.True(t, result.Success) + assert.Equal(t, []string{"uv", "add", "launchdarkly-server-sdk"}, ran) +} + +// The PEP 668 reason says not to run that pip, so the done screen must not offer it +// back as "install it yourself with". +func TestInstall_ExternallyManaged_OffersNoCommand(t *testing.T) { + stubVirtualEnv(t, "") + stubPath(t, "pip3") + installer := PackageInstaller{ + run: func(string, []string) ([]byte, error) { + return []byte("error: externally-managed-environment"), errors.New("exit status 1") + }, + } + + result, err := installer.Install(t.TempDir(), &DetectResult{SDKID: "python-server-sdk"}) + + require.NoError(t, err) + assert.True(t, result.Failed) + assert.Empty(t, result.Command, "the screen would offer the pip the reason says not to run") +} + +// The plan screen, --dry-run and the picker all read InstallArgs, and a command shown +// there is one a reader may run by hand. With a pip-less virtualenv present, none of +// them may name a pip from PATH: running it installs outside the project. +func TestInstallArgs_Python_PipLessVenvNeverPreviewsSystemPip(t *testing.T) { + stubVirtualEnv(t, "") + stubPath(t, "pip3", "pip") + dir := t.TempDir() + root := filepath.Join(dir, ".venv") + require.NoError(t, os.MkdirAll(filepath.Join(root, "bin"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "pyvenv.cfg"), []byte("home = /usr\n"), 0600)) + + args, _ := InstallArgs(dir, "python-server-sdk", "") + + require.NotEmpty(t, args) + assert.Equal(t, filepath.Join(root, "bin", "pip"), args[0], + "the preview names a pip outside the project's virtualenv") + assert.NotContains(t, filepath.Base(args[0]), "pip3") +} + +// A virtualenv that does have pip is still used, and the preview matches. +func TestInstallArgs_Python_SeededVenvPreviewMatchesRun(t *testing.T) { + stubVirtualEnv(t, "") + stubPath(t, "pip3") + dir := t.TempDir() + pip := fakeVenv(t, filepath.Join(dir, ".venv")) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".venv", "pyvenv.cfg"), []byte("home = /usr\n"), 0600)) + + args, _ := InstallArgs(dir, "python-server-sdk", "") + + assert.Equal(t, []string{pip, "install", "launchdarkly-server-sdk"}, args) +} + +// With no virtualenv at all, a pip from PATH is still the right answer. +func TestInstallArgs_Python_NoVenvStillUsesPath(t *testing.T) { + stubVirtualEnv(t, "") + stubPath(t, "pip3") + + args, _ := InstallArgs(t.TempDir(), "python-server-sdk", "") + + assert.Equal(t, []string{"pip3", "install", "launchdarkly-server-sdk"}, args) +} + +// A bare pip install leaves requirements.txt untouched, so a fresh checkout and CI +// do not get the SDK. poetry, uv, pipenv and pdm record it themselves and Ruby gets +// `bundle add`; pip has no equivalent, and editing someone's manifest unasked is not +// something setup does — so it says what is missing. +func TestInstall_PipLeavesManifestUnrecorded_Warns(t *testing.T) { + stubVirtualEnv(t, "") + stubPath(t, "pip3") + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte("flask\n"), 0600)) + installer := PackageInstaller{run: func(string, []string) ([]byte, error) { return nil, nil }} + + result, err := installer.Install(dir, &DetectResult{SDKID: "python-server-sdk"}) + + require.NoError(t, err) + assert.True(t, result.Success) + assert.Contains(t, result.Warning, "did not record it in requirements.txt") + assert.Contains(t, result.Warning, "launchdarkly-server-sdk") +} + +func TestInstall_PipManifestAlreadyRecorded_NoWarning(t *testing.T) { + stubVirtualEnv(t, "") + stubPath(t, "pip3") + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "requirements.txt"), + []byte("flask\nlaunchdarkly-server-sdk\n"), 0600)) + installer := PackageInstaller{run: func(string, []string) ([]byte, error) { return nil, nil }} + + result, err := installer.Install(dir, &DetectResult{SDKID: "python-server-sdk"}) + + require.NoError(t, err) + assert.Empty(t, result.Warning) +} + +// The managers that record the dependency themselves must not be nagged about it. +func TestInstall_ManagersThatRecordDependencies_NoWarning(t *testing.T) { + // pdm arrives with the confidence work; these are the managers this build drives. + for _, pm := range []string{"uv", "poetry", "pipenv"} { + t.Run(pm, func(t *testing.T) { + stubVirtualEnv(t, "") + stubPath(t, pm) + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte("flask\n"), 0600)) + installer := PackageInstaller{run: func(string, []string) ([]byte, error) { return nil, nil }} + + result, err := installer.Install(dir, &DetectResult{SDKID: "python-server-sdk", PackageManager: pm}) + + require.NoError(t, err) + assert.True(t, result.Success) + assert.Empty(t, result.Warning) + }) + } +} + +// Windows keeps a virtualenv's pip in Scripts, so naming bin would point the plan at +// a path the environment never has. +func TestVenvPipLayouts_PlatformFirst(t *testing.T) { + layouts := venvPipLayouts() + require.Len(t, layouts, 2) + if runtime.GOOS == "windows" { + assert.Contains(t, layouts[0], "Scripts") + } else { + assert.Contains(t, layouts[0], "bin") + } + assert.NotEqual(t, layouts[0], layouts[1], "both layouts are still considered") +} + +// A related package pinned in the manifest is not the SDK. Reading it as one would +// suppress the warning while the project still lacks the dependency. +func TestInstall_RelatedPackagePinned_StillWarns(t *testing.T) { + stubVirtualEnv(t, "") + stubPath(t, "pip3") + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "requirements.txt"), + []byte("flask\nlaunchdarkly-server-sdk-otel==1.2.0\n"), 0600)) + installer := PackageInstaller{run: func(string, []string) ([]byte, error) { return nil, nil }} + + result, err := installer.Install(dir, &DetectResult{SDKID: "python-server-sdk"}) + + require.NoError(t, err) + assert.True(t, result.Success) + assert.Contains(t, result.Warning, "did not record it in requirements.txt") +} + +// A pinned version of the SDK itself does count as recorded. +func TestInstall_PinnedSdkVersion_NoWarning(t *testing.T) { + stubVirtualEnv(t, "") + stubPath(t, "pip3") + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "requirements.txt"), + []byte("flask\nlaunchdarkly-server-sdk==9.16.1\n"), 0600)) + installer := PackageInstaller{run: func(string, []string) ([]byte, error) { return nil, nil }} + + result, err := installer.Install(dir, &DetectResult{SDKID: "python-server-sdk"}) + + require.NoError(t, err) + assert.Empty(t, result.Warning) +} + +// Corepack needs one exact version, so both a missing version and a range stop the +// manager running. Repairing someone's manifest is not ours to do, so the failure +// has to say what is wrong. +func TestInstall_BadPackageManagerSpec_ExplainsIt(t *testing.T) { + for _, out := range []string{ + `No version specified for pnpm in "packageManager" of package.json`, + "Invalid package manager specification in package.json (pnpm@^11.13.0); expected a semver version", + } { + t.Run(out[:24], func(t *testing.T) { + stubVirtualEnv(t, "") + stubPath(t, "pnpm") + installer := PackageInstaller{ + run: func(string, []string) ([]byte, error) { + return []byte(out), errors.New("exit status 1") + }, + } + + result, err := installer.Install(t.TempDir(), &DetectResult{ + SDKID: "node-server", PackageManager: "pnpm", + }) + + require.NoError(t, err, "a malformed manifest must not dead-end the flow") + assert.True(t, result.Failed) + assert.Contains(t, result.FailureReason, "packageManager field") + assert.Contains(t, result.FailureReason, "one exact version") + assert.Contains(t, result.FailureReason, "pnpm@11.13.0") + }) + } +} + +// Only a Node failure about package.json may be answered with advice about that +// file. Another ecosystem's error keeps its own text, whatever phrases it contains. +func TestInstall_OtherEcosystemErrorsKeepTheirText(t *testing.T) { + for _, out := range []string{ + "ERROR: Could not find a valid gem 'x' (>= 0), here is why:\n No version specified", + "ERROR: Could not find a version that satisfies the requirement x; expected a semver version", + "go: module x: invalid version: expected a semver version", + } { + t.Run(out[:20], func(t *testing.T) { + stubVirtualEnv(t, "") + stubPath(t, "npm") + installer := PackageInstaller{ + run: func(string, []string) ([]byte, error) { + return []byte(out), errors.New("exit status 1") + }, + } + + _, err := installer.Install(t.TempDir(), &DetectResult{ + SDKID: "node-server", PackageManager: "npm", + }) + + require.Error(t, err, "the real failure must reach the caller") + assert.Contains(t, err.Error(), out, "the real error text is kept") + assert.NotContains(t, err.Error(), "packageManager field") + }) + } +} diff --git a/internal/setup/main_test.go b/internal/setup/main_test.go new file mode 100644 index 000000000..7aa91c947 --- /dev/null +++ b/internal/setup/main_test.go @@ -0,0 +1,16 @@ +package setup + +import ( + "os" + "testing" +) + +// TestMain neutralises the ambient virtualenv for the whole package. pipInstallCmd +// prefers VIRTUAL_ENV over anything on PATH, so a developer running the suite +// inside an activated environment would otherwise see install commands resolve to +// that environment's pip and assertions about pip/pip3 fail. Tests that care about +// an active virtualenv opt in with stubVirtualEnv. +func TestMain(m *testing.M) { + virtualEnv = func() string { return "" } + os.Exit(m.Run()) +} 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..bb5a36180 --- /dev/null +++ b/internal/setup/service.go @@ -0,0 +1,202 @@ +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 the flag the wizard hands to the SDK, available to client-side +// and mobile SDKs alike. The API leaves both availability settings off by default, +// which would leave a browser or mobile SDK evaluating the fallback forever even +// though setup reported success — and which credential a project will reach for is +// not ours to predict from the SDK it starts with. +// +// A flag that already exists is left exactly as it is: it belongs to the project +// rather than to setup, so its settings are not ours to change. +func (s Service) CreateFlag(a Auth, projectKey, key, name string) (string, error) { + opts := []flags.CreateOption{ + 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..1798e11ae --- /dev/null +++ b/internal/setup/service_test.go @@ -0,0 +1,228 @@ +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") + + require.NoError(t, err) + assert.Equal(t, "my-new-flag", key) +} + +// A flag that already exists belongs to the project, so setup reports success and +// leaves it exactly as it is rather than reaching back to change its settings. +func TestService_CreateFlag_ConflictIsSuccess(t *testing.T) { + mockFlags := &flags.MockClient{} + mockFlags.On("Create", testAuth.AccessToken, testAuth.BaseURI, "My New Flag", "my-new-flag", "p1"). + Return([]byte(nil), errors.NewError(`{"code":"conflict","message":"already exists"}`)) + svc := Service{Clients: Clients{Flags: mockFlags}} + + key, err := svc.CreateFlag(testAuth, "p1", "my-new-flag", "My New Flag") + + require.NoError(t, err) + assert.Equal(t, "my-new-flag", key) + mockFlags.AssertNumberOfCalls(t, "Create", 1) + mockFlags.AssertExpectations(t) +} + +func TestService_CreateFlag_OtherErrorPropagates(t *testing.T) { + mockFlags := &flags.MockClient{} + mockFlags.On("Create", testAuth.AccessToken, testAuth.BaseURI, "My New Flag", "my-new-flag", "p1"). + Return([]byte(nil), errors.NewError(`{"code":"internal_error"}`)) + svc := Service{Clients: Clients{Flags: mockFlags}} + + _, err := svc.CreateFlag(testAuth, "p1", "my-new-flag", "My New Flag") + + assert.Error(t, err) +} + +func TestService_Detect(t *testing.T) { + want := &DetectResult{Language: "go", SDKID: "go-server-sdk"} + svc := Service{Detector: fakeDetector{result: want}} + + got, err := svc.Detect("/some/dir") + + require.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestService_Install_Success(t *testing.T) { + want := &InstallResult{SDKID: "node-server", Success: true} + svc := Service{Installer: fakeInstaller{result: want}} + + got, err := svc.Install("/some/dir", &DetectResult{SDKID: "node-server"}) + + require.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestService_Install_ErrorPropagates(t *testing.T) { + // The service returns the installer's error unchanged; the wizard, not the + // service, decides whether to continue past a failed install. + svc := Service{Installer: fakeInstaller{err: errors.NewError("boom")}} + + _, err := svc.Install("/some/dir", &DetectResult{SDKID: "node-server"}) + + assert.Error(t, err) +} + +func TestService_Inject(t *testing.T) { + svc := Service{Initializer: Initializer{}} + filePath := filepath.Join(t.TempDir(), "index.js") + + result, err := svc.Inject("node-server", filePath, InitConfig{SDKKey: "sdk-123"}) + + require.NoError(t, err) + assert.Equal(t, "node-server", result.SDKID) + assert.True(t, result.Success) +} + +func TestService_Verify_Active(t *testing.T) { + svc := Service{Clients: Clients{Resources: &resources.MockClient{Response: []byte(`{"active":true}`)}}} + + result, err := svc.Verify(testAuth, "p1", "production", "node-server") + + require.NoError(t, err) + assert.True(t, result.Active) + assert.Equal(t, 1, result.Attempts) +} + +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) +} + +// Whichever SDK the project starts with, the flag is created available to +// client-side and mobile SDKs. The API leaves both off, so without this a browser or +// mobile SDK evaluates the fallback forever while setup reports success — and the +// SDK a project starts with does not tell us which credential it will end up using. +func TestService_CreateFlag_AlwaysAvailableToClientAndMobile(t *testing.T) { + for _, sdkID := range []string{ + "js-client-sdk", "react-client-sdk", // client-side ID + "react-native", "android", "swift-client-sdk", // mobile key + "node-server", "go-server-sdk", "python-server-sdk", // server-side + } { + t.Run(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") + + require.NoError(t, err) + assert.Equal(t, + &flags.ClientSideAvailability{UsingEnvironmentID: true, UsingMobileKey: true}, + mockFlags.CreatedAvailability) + }) + } +} 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) + } +}