From 37bb50b873949212dfc6134b4d5649fea86c2dd6 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Tue, 21 Jul 2026 16:39:05 -0400 Subject: [PATCH 1/3] chore(setup): add SDK installer library Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/setup/installer.go | 229 +++++++++++++++++++++++++++++++ internal/setup/installer_test.go | 201 +++++++++++++++++++++++++++ 2 files changed, 430 insertions(+) create mode 100644 internal/setup/installer.go create mode 100644 internal/setup/installer_test.go diff --git a/internal/setup/installer.go b/internal/setup/installer.go new file mode 100644 index 00000000..48039a65 --- /dev/null +++ b/internal/setup/installer.go @@ -0,0 +1,229 @@ +package setup + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "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"` + Success bool `json:"success"` +} + +// RequiresManualInstall reports whether the SDK has no automated package-manager +// command and must be added by hand (e.g. Java, Android, Swift). +func RequiresManualInstall(sdkID string) bool { + return manualInstallSDKs[sdkID] +} + +// Installer runs the appropriate package manager command to add an SDK dependency. +type Installer interface { + Install(dir string, detection *DetectResult) (*InstallResult, error) +} + +// StubInstaller is a placeholder implementation. Replace with real install logic. +type StubInstaller struct{} + +var _ Installer = StubInstaller{} + +func (StubInstaller) Install(_ string, _ *DetectResult) (*InstallResult, error) { + return nil, errors.New("install is not yet implemented: a real Installer must be provided") +} + +// PackageInstaller implements Installer using the system package manager. +// Its run field can be replaced in tests to avoid executing real commands. +type PackageInstaller struct { + run func(dir string, args []string) ([]byte, error) +} + +var _ Installer = PackageInstaller{} + +// manualInstallSDKs lists SDKs that have no automated package-manager command +// (Java, Android, Swift) but ARE recognised. For these, Install returns +// Success=false without an error so the wizard can proceed and show the package +// identifier. An SDK ID that is neither installable nor in this set is unknown +// and is treated as an error rather than a silent no-op. +var manualInstallSDKs = map[string]bool{ + "java-server-sdk": true, + "android": true, + "android-client-sdk": true, + "swift-client-sdk": true, + "ios-client-sdk": true, +} + +// Install runs the appropriate package manager command to add the SDK dependency. +// For SDKs that require manual installation (e.g. Java, Android, Swift), Install +// returns a result with Success=false without returning an error. An unknown SDK +// ID returns an error. +func (p PackageInstaller) Install(dir string, detection *DetectResult) (*InstallResult, error) { + args, pkg := InstallArgs(detection.SDKID, detection.PackageManager) + if len(args) == 0 { + if !manualInstallSDKs[detection.SDKID] { + return nil, fmt.Errorf("unknown SDK %q: no install command available; specify a supported --sdk-id", detection.SDKID) + } + return &InstallResult{ + SDKID: detection.SDKID, + Package: pkg, + Success: false, + }, nil + } + + // Skip the install if the SDK is already a dependency of the project. + if IsInstalled(dir, detection.SDKID) { + return &InstallResult{ + SDKID: detection.SDKID, + Package: pkg, + AlreadyInstalled: true, + Success: true, + }, nil + } + + runner := p.run + if runner == nil { + runner = execRun + } + + out, err := runner(dir, args) + command := strings.Join(args, " ") + if err != nil { + return nil, fmt.Errorf("%s: %w\n%s", command, err, strings.TrimSpace(string(out))) + } + return &InstallResult{ + SDKID: detection.SDKID, + Package: pkg, + Command: command, + Success: true, + }, nil +} + +func execRun(dir string, args []string) ([]byte, error) { + cmd := exec.Command(args[0], args[1:]...) //nolint:gosec + cmd.Dir = dir + return cmd.CombinedOutput() +} + +// InstallArgs returns the command-line arguments and package name for installing the given SDK. +// Returns nil args for SDKs that require manual installation (e.g. Java, Android, Swift). +// packageManager is used for Node.js SDKs; for other runtimes the appropriate tool is chosen automatically. +func InstallArgs(sdkID, packageManager string) (args []string, pkg string) { + switch sdkID { + case "react-client-sdk": + pkg = "launchdarkly-react-client-sdk" + return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg + case "react-native": + pkg = "launchdarkly-react-native-client-sdk" + return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg + case "node-server": + pkg = "@launchdarkly/node-server-sdk" + return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg + case "js-client-sdk": + pkg = "@launchdarkly/js-client-sdk" + return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg + case "python-server-sdk": + pm := packageManager + if pm == "" { + pm = "pip" + } + pkg = "launchdarkly-server-sdk" + return []string{pm, "install", 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" + 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 + } +} + +// nodeInstallCmd returns the install command arguments for a Node.js package manager. +func nodeInstallCmd(pm, pkg string) []string { + switch pm { + case "yarn": + return []string{"yarn", "add", pkg} + case "pnpm": + return []string{"pnpm", "add", pkg} + case "bun": + return []string{"bun", "add", pkg} + default: + return []string{"npm", "install", pkg} + } +} + +// resolveNodePM normalises the package manager name, defaulting to "npm". +func resolveNodePM(pm string) string { + switch pm { + case "yarn", "pnpm", "bun": + return pm + default: + return "npm" + } +} + +// IsInstalled reports whether the SDK is already a dependency of the project in +// dir, by looking for its package identifier in the relevant manifest(s). Only +// covers SDKs with an automated install command; returns false for manual SDKs +// and unknowns. +func IsInstalled(dir, sdkID string) bool { + _, pkg := InstallArgs(sdkID, "") + if pkg == "" { + return false + } + + var manifests []string + switch sdkID { + case "react-client-sdk", "react-native", "node-server", "js-client-sdk": + manifests = []string{"package.json"} + case "go-server-sdk": + manifests = []string{"go.mod", "go.sum"} + case "python-server-sdk": + manifests = []string{"requirements.txt", "pyproject.toml", "setup.py"} + case "ruby-server-sdk": + manifests = []string{"Gemfile", "Gemfile.lock"} + case "dotnet-server-sdk": + matches, _ := filepath.Glob(filepath.Join(dir, "*.csproj")) + for _, f := range matches { + if fileContains(f, pkg) { + return true + } + } + return false + default: + return false + } + + for _, mf := range manifests { + if fileContains(filepath.Join(dir, mf), pkg) { + return true + } + } + return false +} + +func fileContains(path, substr string) bool { + b, err := os.ReadFile(path) + return err == nil && strings.Contains(string(b), substr) +} diff --git a/internal/setup/installer_test.go b/internal/setup/installer_test.go new file mode 100644 index 00000000..7fa9ccb6 --- /dev/null +++ b/internal/setup/installer_test.go @@ -0,0 +1,201 @@ +package setup + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstallArgs_NodeSDKs(t *testing.T) { + tests := []struct { + sdkID string + pm string + wantCmd string + wantPkg string + }{ + {"react-client-sdk", "npm", "npm", "launchdarkly-react-client-sdk"}, + {"react-client-sdk", "yarn", "yarn", "launchdarkly-react-client-sdk"}, + {"react-client-sdk", "pnpm", "pnpm", "launchdarkly-react-client-sdk"}, + {"react-client-sdk", "bun", "bun", "launchdarkly-react-client-sdk"}, + {"react-client-sdk", "", "npm", "launchdarkly-react-client-sdk"}, + {"react-native", "npm", "npm", "launchdarkly-react-native-client-sdk"}, + {"react-native", "bun", "bun", "launchdarkly-react-native-client-sdk"}, + {"node-server", "npm", "npm", "@launchdarkly/node-server-sdk"}, + {"node-server", "yarn", "yarn", "@launchdarkly/node-server-sdk"}, + {"node-server", "pnpm", "pnpm", "@launchdarkly/node-server-sdk"}, + {"node-server", "bun", "bun", "@launchdarkly/node-server-sdk"}, + {"node-server", "", "npm", "@launchdarkly/node-server-sdk"}, + {"js-client-sdk", "npm", "npm", "@launchdarkly/js-client-sdk"}, + {"js-client-sdk", "bun", "bun", "@launchdarkly/js-client-sdk"}, + } + + for _, tt := range tests { + t.Run(tt.sdkID+"/"+tt.pm, func(t *testing.T) { + args, pkg := InstallArgs(tt.sdkID, tt.pm) + require.NotEmpty(t, args) + assert.Equal(t, tt.wantCmd, args[0]) + assert.Equal(t, tt.wantPkg, pkg) + assert.Contains(t, args, pkg) + }) + } +} + +func TestInstallArgs_Python(t *testing.T) { + args, pkg := InstallArgs("python-server-sdk", "") + require.NotEmpty(t, args) + assert.Equal(t, "pip", args[0]) + assert.Equal(t, "launchdarkly-server-sdk", pkg) + + args2, _ := InstallArgs("python-server-sdk", "pip3") + require.NotEmpty(t, args2) + assert.Equal(t, "pip3", args2[0]) +} + +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) +} + +func TestInstallArgs_Dotnet(t *testing.T) { + args, pkg := InstallArgs("dotnet-server-sdk", "") + require.NotEmpty(t, args) + assert.Equal(t, "dotnet", args[0]) + assert.Equal(t, "LaunchDarkly.ServerSdk", pkg) +} + +func TestInstallArgs_ManualSDKs(t *testing.T) { + tests := []struct { + sdkID string + wantPkg string + }{ + {"java-server-sdk", "com.launchdarkly:launchdarkly-java-server-sdk"}, + {"android", "com.launchdarkly:launchdarkly-android-client-sdk"}, + {"android-client-sdk", "com.launchdarkly:launchdarkly-android-client-sdk"}, + {"swift-client-sdk", "LaunchDarkly"}, + {"ios-client-sdk", "LaunchDarkly"}, + {"unknown-sdk-xyz", "unknown-sdk-xyz"}, // unknown falls back to SDK ID + } + for _, tt := range tests { + t.Run(tt.sdkID, func(t *testing.T) { + args, pkg := InstallArgs(tt.sdkID, "") + assert.Nil(t, args, "expected nil args for manual SDK %s", tt.sdkID) + assert.Equal(t, tt.wantPkg, pkg) + }) + } +} + +func TestPackageInstaller_Install_Success(t *testing.T) { + var capturedDir string + var capturedArgs []string + + installer := PackageInstaller{ + run: func(dir string, args []string) ([]byte, error) { + capturedDir = dir + capturedArgs = args + return []byte("added 1 package"), nil + }, + } + + result, err := installer.Install("/my/project", &DetectResult{ + SDKID: "node-server", + PackageManager: "npm", + }) + + require.NoError(t, err) + assert.True(t, result.Success) + assert.Equal(t, "node-server", result.SDKID) + assert.Equal(t, "@launchdarkly/node-server-sdk", result.Package) + assert.Equal(t, "npm install @launchdarkly/node-server-sdk", result.Command) + assert.Equal(t, "/my/project", capturedDir) + assert.Equal(t, []string{"npm", "install", "@launchdarkly/node-server-sdk"}, capturedArgs) +} + +func TestPackageInstaller_Install_CommandFailure(t *testing.T) { + installer := PackageInstaller{ + run: func(dir string, args []string) ([]byte, error) { + return []byte("npm ERR! not found"), errors.New("exit status 1") + }, + } + + _, err := installer.Install("/tmp", &DetectResult{ + SDKID: "node-server", + PackageManager: "npm", + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "npm install @launchdarkly/node-server-sdk") + assert.Contains(t, err.Error(), "npm ERR! not found") +} + +func TestPackageInstaller_Install_ManualSDK_ReturnsNoError(t *testing.T) { + installer := PackageInstaller{} + + result, err := installer.Install("/tmp", &DetectResult{SDKID: "java-server-sdk"}) + + require.NoError(t, err) + assert.False(t, result.Success) + assert.Equal(t, "java-server-sdk", result.SDKID) + assert.Empty(t, result.Command) +} + +func TestPackageInstaller_Install_AlreadyInstalled_SkipsCommand(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "package.json"), + []byte(`{"dependencies":{"@launchdarkly/node-server-sdk":"^9.0.0"}}`), 0644)) + + installer := PackageInstaller{ + run: func(_ string, _ []string) ([]byte, error) { + t.Fatal("package manager must not run when the SDK is already installed") + return nil, nil + }, + } + + result, err := installer.Install(dir, &DetectResult{SDKID: "node-server", PackageManager: "npm"}) + + require.NoError(t, err) + assert.True(t, result.AlreadyInstalled) + assert.True(t, result.Success) + assert.Empty(t, result.Command) +} + +func TestRequiresManualInstall(t *testing.T) { + assert.True(t, RequiresManualInstall("java-server-sdk")) + assert.True(t, RequiresManualInstall("swift-client-sdk")) + assert.False(t, RequiresManualInstall("node-server")) + assert.False(t, RequiresManualInstall("ruby-server-sdk")) +} + +func TestPackageInstaller_Install_UnknownSDK_ReturnsError(t *testing.T) { + installer := PackageInstaller{} + + _, err := installer.Install("/tmp", &DetectResult{SDKID: "totally-unknown-sdk"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown SDK") +} + +func TestPackageInstaller_Install_DefaultRunner_UsedWhenNil(t *testing.T) { + // PackageInstaller{} (zero value) should not panic — it uses execRun. + // We test this by using a manual SDK so no real command is executed. + installer := PackageInstaller{} + + result, err := installer.Install("/tmp", &DetectResult{SDKID: "android"}) + + require.NoError(t, err) + assert.False(t, result.Success) +} From 6d3551afbd8f949fd190eb311dde29fdd37e5ce8 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Thu, 30 Jul 2026 23:48:11 -0400 Subject: [PATCH 2/3] fix(setup): install with the project's own package manager `gem install` left the Gemfile untouched, so the SDK stayed unavailable under bundler and IsInstalled kept returning false. Use `bundle add` when the project is Bundler-managed, and poetry, uv or pipenv when one of those manages the Python dependencies. Unrecognised package managers fall back to pip rather than being run as a command, since the value reaches InstallArgs from the detector. InstallArgs added launchdarkly-react-native-client-sdk, which npm marks deprecated in favour of @launchdarkly/react-native-client-sdk. The unscoped launchdarkly-js-client-sdk is the v3 package whose initialize API the init template uses; the scoped one is v4 and exposes createClient. Co-Authored-By: Claude Opus 5 (1M context) --- internal/setup/installer.go | 36 +++++++++++++++++----- internal/setup/installer_test.go | 53 ++++++++++++++++++++++++-------- 2 files changed, 69 insertions(+), 20 deletions(-) diff --git a/internal/setup/installer.go b/internal/setup/installer.go index 48039a65..7d1541f9 100644 --- a/internal/setup/installer.go +++ b/internal/setup/installer.go @@ -122,26 +122,30 @@ func InstallArgs(sdkID, packageManager string) (args []string, pkg string) { pkg = "launchdarkly-react-client-sdk" return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg case "react-native": - pkg = "launchdarkly-react-native-client-sdk" + 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": - pkg = "@launchdarkly/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": - pm := packageManager - if pm == "" { - pm = "pip" - } pkg = "launchdarkly-server-sdk" - return []string{pm, "install", pkg}, pkg + return pythonInstallCmd(packageManager, pkg), pkg case "go-server-sdk": pkg = "github.com/launchdarkly/go-server-sdk/v7" return []string{"go", "get", pkg}, pkg case "ruby-server-sdk": pkg = "launchdarkly-server-sdk" + // Bundler-managed projects need the gem recorded in the Gemfile; a bare + // `gem install` would succeed without making the SDK available to the app. + if packageManager == "bundle" { + return []string{"bundle", "add", pkg}, pkg + } return []string{"gem", "install", pkg}, pkg case "dotnet-server-sdk": pkg = "LaunchDarkly.ServerSdk" @@ -159,6 +163,22 @@ func InstallArgs(sdkID, packageManager string) (args []string, pkg string) { } } +// pythonInstallCmd returns the install command arguments for a Python package +// manager. Anything unrecognised — including the empty string, which IsInstalled +// passes — falls back to pip. +func pythonInstallCmd(pm, pkg string) []string { + switch pm { + case "poetry": + return []string{"poetry", "add", pkg} + case "uv": + return []string{"uv", "add", pkg} + case "pipenv": + return []string{"pipenv", "install", pkg} + default: + return []string{"pip", "install", pkg} + } +} + // nodeInstallCmd returns the install command arguments for a Node.js package manager. func nodeInstallCmd(pm, pkg string) []string { switch pm { @@ -200,7 +220,7 @@ func IsInstalled(dir, sdkID string) bool { case "go-server-sdk": manifests = []string{"go.mod", "go.sum"} case "python-server-sdk": - manifests = []string{"requirements.txt", "pyproject.toml", "setup.py"} + manifests = []string{"requirements.txt", "pyproject.toml", "setup.py", "Pipfile", "uv.lock"} case "ruby-server-sdk": manifests = []string{"Gemfile", "Gemfile.lock"} case "dotnet-server-sdk": diff --git a/internal/setup/installer_test.go b/internal/setup/installer_test.go index 7fa9ccb6..e8cf482c 100644 --- a/internal/setup/installer_test.go +++ b/internal/setup/installer_test.go @@ -22,15 +22,15 @@ func TestInstallArgs_NodeSDKs(t *testing.T) { {"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"}, + {"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"}, + {"js-client-sdk", "npm", "npm", "launchdarkly-js-client-sdk"}, + {"js-client-sdk", "bun", "bun", "launchdarkly-js-client-sdk"}, } for _, tt := range tests { @@ -45,14 +45,26 @@ func TestInstallArgs_NodeSDKs(t *testing.T) { } func TestInstallArgs_Python(t *testing.T) { - args, pkg := InstallArgs("python-server-sdk", "") - require.NotEmpty(t, args) - assert.Equal(t, "pip", args[0]) - assert.Equal(t, "launchdarkly-server-sdk", pkg) - - args2, _ := InstallArgs("python-server-sdk", "pip3") - require.NotEmpty(t, args2) - assert.Equal(t, "pip3", args2[0]) + tests := []struct { + packageManager string + want []string + }{ + // IsInstalled calls InstallArgs with no package manager. + {"", []string{"pip", "install", "launchdarkly-server-sdk"}}, + {"pip", []string{"pip", "install", "launchdarkly-server-sdk"}}, + {"poetry", []string{"poetry", "add", "launchdarkly-server-sdk"}}, + {"uv", []string{"uv", "add", "launchdarkly-server-sdk"}}, + {"pipenv", []string{"pipenv", "install", "launchdarkly-server-sdk"}}, + // Unrecognised values fall back to pip rather than being run as a command. + {"conda", []string{"pip", "install", "launchdarkly-server-sdk"}}, + } + for _, tt := range tests { + t.Run(tt.packageManager, func(t *testing.T) { + args, pkg := InstallArgs("python-server-sdk", tt.packageManager) + assert.Equal(t, tt.want, args) + assert.Equal(t, "launchdarkly-server-sdk", pkg) + }) + } } func TestInstallArgs_Go(t *testing.T) { @@ -70,6 +82,23 @@ func TestInstallArgs_Ruby(t *testing.T) { 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) From 45e58471dab2f4c39ea70de8321cd1ad886242c6 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Mon, 3 Aug 2026 14:49:27 -0400 Subject: [PATCH 3/3] fix(setup): match whole packages and target the right .NET project IsInstalled tested for its package name as a substring, so @launchdarkly/node-server-sdk-redis, launchdarkly-server-sdk-otel, and LaunchDarkly.ServerSdk.Telemetry each counted as the SDK itself and the real install was skipped. Require a non-name character on both sides, which every manifest format supplies. Detection accepts a solution with no project file beside it, but install ran a bare `dotnet add package`, which needs the working directory to hold exactly one project. Resolve the project the solution refers to and pass --project; with none or several, stop and say so rather than adding the SDK to an arbitrary assembly. Co-Authored-By: Claude Opus 5 (1M context) --- internal/setup/installer.go | 121 ++++++++++++++++++++++++-- internal/setup/installer_test.go | 144 +++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+), 7 deletions(-) diff --git a/internal/setup/installer.go b/internal/setup/installer.go index 7d1541f9..f538ad0d 100644 --- a/internal/setup/installer.go +++ b/internal/setup/installer.go @@ -3,9 +3,11 @@ package setup import ( "errors" "fmt" + "io/fs" "os" "os/exec" "path/filepath" + "sort" "strings" ) @@ -18,7 +20,10 @@ type InstallResult struct { DryRun bool `json:"dry_run,omitempty"` AlreadyInstalled bool `json:"already_installed,omitempty"` Failed bool `json:"failed,omitempty"` - Success bool `json:"success"` + // FailureReason carries the underlying error when Failed is true, so callers + // can tell the user why the automatic install did not run. + FailureReason string `json:"failure_reason,omitempty"` + Success bool `json:"success"` } // RequiresManualInstall reports whether the SDK has no automated package-manager @@ -89,6 +94,19 @@ func (p PackageInstaller) Install(dir string, detection *DetectResult) (*Install }, 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 @@ -107,6 +125,32 @@ func (p PackageInstaller) Install(dir string, detection *DetectResult) (*Install }, nil } +// dotnetProjectArg returns the extra arguments needed to point `dotnet add +// package` at a project, or a reason the install cannot run unattended. A bare +// `dotnet add package` only works when the working directory holds exactly one +// project file, but detection also accepts a solution whose projects live in +// subdirectories. +func dotnetProjectArg(dir string) (args []string, reason string) { + if matches, _ := filepath.Glob(filepath.Join(dir, "*.csproj")); len(matches) == 1 { + return nil, "" + } + projects := csprojFiles(dir) + switch len(projects) { + case 0: + return nil, "no .csproj file found; add LaunchDarkly.ServerSdk to your project manually" + case 1: + rel, err := filepath.Rel(dir, projects[0]) + if err != nil { + rel = projects[0] + } + return []string{"--project", rel}, "" + default: + // Picking one of several projects would add the SDK to an arbitrary + // assembly, so let the user say which. + return nil, fmt.Sprintf("found %d projects in this solution; run `dotnet add package LaunchDarkly.ServerSdk --project ` for the one that needs the SDK", len(projects)) + } +} + func execRun(dir string, args []string) ([]byte, error) { cmd := exec.Command(args[0], args[1:]...) //nolint:gosec cmd.Dir = dir @@ -224,9 +268,8 @@ func IsInstalled(dir, sdkID string) bool { case "ruby-server-sdk": manifests = []string{"Gemfile", "Gemfile.lock"} case "dotnet-server-sdk": - matches, _ := filepath.Glob(filepath.Join(dir, "*.csproj")) - for _, f := range matches { - if fileContains(f, pkg) { + for _, f := range csprojFiles(dir) { + if fileMentionsPackage(f, pkg) { return true } } @@ -236,14 +279,78 @@ func IsInstalled(dir, sdkID string) bool { } for _, mf := range manifests { - if fileContains(filepath.Join(dir, mf), pkg) { + if fileMentionsPackage(filepath.Join(dir, mf), pkg) { return true } } return false } -func fileContains(path, substr string) bool { +func fileMentionsPackage(path, pkg string) bool { b, err := os.ReadFile(path) - return err == nil && strings.Contains(string(b), substr) + 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 index e8cf482c..085913fe 100644 --- a/internal/setup/installer_test.go +++ b/internal/setup/installer_test.go @@ -228,3 +228,147 @@ func TestPackageInstaller_Install_DefaultRunner_UsedWhenNil(t *testing.T) { require.NoError(t, err) assert.False(t, result.Success) } + +// A related package that starts with the SDK's name is not the SDK. Treating it as +// installed skips the install and leaves the integration package without the SDK +// it depends on. +func TestIsInstalled_RelatedPackageIsNotTheSDK(t *testing.T) { + tests := []struct { + name string + manifest string + content string + sdkID string + want bool + }{ + {"node redis integration only", "package.json", + `{"dependencies":{"@launchdarkly/node-server-sdk-redis":"^4.0.0"}}`, "node-server", false}, + {"node sdk present", "package.json", + `{"dependencies":{"@launchdarkly/node-server-sdk":"^9.13.0"}}`, "node-server", true}, + {"node sdk alongside integration", "package.json", + `{"dependencies":{"@launchdarkly/node-server-sdk":"^9.13.0","@launchdarkly/node-server-sdk-redis":"^4.0.0"}}`, "node-server", true}, + {"python otel plugin only", "requirements.txt", + "launchdarkly-server-sdk-otel==1.0.0\n", "python-server-sdk", false}, + {"python sdk pinned", "requirements.txt", + "launchdarkly-server-sdk==9.16.1\n", "python-server-sdk", true}, + {"ruby sdk in gemfile", "Gemfile", + "gem 'launchdarkly-server-sdk', '~> 8.14'\n", "ruby-server-sdk", true}, + {"ruby related gem only", "Gemfile", + "gem 'launchdarkly-server-sdk-redis-store'\n", "ruby-server-sdk", false}, + {"go module in go.mod", "go.mod", + "require github.com/launchdarkly/go-server-sdk/v7 v7.15.5\n", "go-server-sdk", true}, + {"go sdk name as a prefix", "go.mod", + "require github.com/launchdarkly/go-server-sdk/v7-fork v1.0.0\n", "go-server-sdk", false}, + {"dotnet telemetry package only", "App.csproj", + ``, "dotnet-server-sdk", false}, + {"dotnet sdk present", "App.csproj", + ``, "dotnet-server-sdk", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, tt.manifest), []byte(tt.content), 0600)) + + assert.Equal(t, tt.want, IsInstalled(dir, tt.sdkID)) + }) + } +} + +// Detection accepts a solution with no project file beside it, so the install has +// to find the project the solution refers to. +func TestIsInstalled_Dotnet_FindsNestedProject(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "MyApp.sln"), []byte(""), 0600)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "src/MyApp"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "src/MyApp/MyApp.csproj"), + []byte(``), 0600)) + + assert.True(t, IsInstalled(dir, "dotnet-server-sdk")) +} + +func TestInstall_Dotnet_SolutionLayout_TargetsTheProject(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "MyApp.sln"), []byte(""), 0600)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "src/MyApp"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "src/MyApp/MyApp.csproj"), []byte(""), 0600)) + + var got []string + installer := PackageInstaller{run: func(_ string, args []string) ([]byte, error) { + got = args + return nil, nil + }} + + result, err := installer.Install(dir, &DetectResult{SDKID: "dotnet-server-sdk", PackageManager: "dotnet"}) + + require.NoError(t, err) + assert.True(t, result.Success) + // A bare `dotnet add package` fails when the working directory holds no project. + assert.Equal(t, []string{"dotnet", "add", "package", "LaunchDarkly.ServerSdk", + "--project", filepath.Join("src", "MyApp", "MyApp.csproj")}, got) +} + +func TestInstall_Dotnet_SingleRootProject_RunsBareCommand(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "MyApp.csproj"), []byte(""), 0600)) + + var got []string + installer := PackageInstaller{run: func(_ string, args []string) ([]byte, error) { + got = args + return nil, nil + }} + + _, err := installer.Install(dir, &DetectResult{SDKID: "dotnet-server-sdk", PackageManager: "dotnet"}) + + require.NoError(t, err) + assert.Equal(t, []string{"dotnet", "add", "package", "LaunchDarkly.ServerSdk"}, got) +} + +// Adding the SDK to an arbitrary assembly is worse than saying which projects exist. +func TestInstall_Dotnet_SeveralProjects_ReportsWhyItStopped(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "MyApp.sln"), []byte(""), 0600)) + for _, p := range []string{"src/Api/Api.csproj", "src/Worker/Worker.csproj"} { + require.NoError(t, os.MkdirAll(filepath.Join(dir, filepath.Dir(p)), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, p), []byte(""), 0600)) + } + + ran := false + installer := PackageInstaller{run: func(_ string, _ []string) ([]byte, error) { + ran = true + return nil, nil + }} + + result, err := installer.Install(dir, &DetectResult{SDKID: "dotnet-server-sdk", PackageManager: "dotnet"}) + + require.NoError(t, err) + assert.False(t, ran, "a command that cannot succeed must not run") + assert.True(t, result.Failed) + assert.False(t, result.Success) + assert.Contains(t, result.FailureReason, "--project") + assert.Equal(t, "LaunchDarkly.ServerSdk", result.Package) +} + +func TestInstall_Dotnet_NoProjectAtAll_ReportsWhyItStopped(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "MyApp.sln"), []byte(""), 0600)) + + result, err := PackageInstaller{run: func(_ string, _ []string) ([]byte, error) { + t.Fatal("install must not run without a project") + return nil, nil + }}.Install(dir, &DetectResult{SDKID: "dotnet-server-sdk", PackageManager: "dotnet"}) + + require.NoError(t, err) + assert.True(t, result.Failed) + assert.Contains(t, result.FailureReason, "no .csproj") +} + +// Build output can hold copies of project files and is large enough to matter. +func TestCsprojFiles_SkipsBuildOutput(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "MyApp.sln"), []byte(""), 0600)) + for _, p := range []string{"src/MyApp/MyApp.csproj", "src/MyApp/obj/Copy.csproj", "bin/Debug/Stale.csproj"} { + require.NoError(t, os.MkdirAll(filepath.Join(dir, filepath.Dir(p)), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, p), []byte(""), 0600)) + } + + assert.Equal(t, []string{filepath.Join(dir, "src/MyApp/MyApp.csproj")}, csprojFiles(dir)) +}