diff --git a/internal/setup/detector.go b/internal/setup/detector.go
new file mode 100644
index 00000000..a450fc7e
--- /dev/null
+++ b/internal/setup/detector.go
@@ -0,0 +1,533 @@
+package setup
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// DetectResult contains information about the user's project detected from the working directory.
+type DetectResult struct {
+ Language string `json:"language"`
+ Framework string `json:"framework,omitempty"`
+ PackageManager string `json:"package_manager"`
+ SDKID string `json:"sdk_id"`
+ EntryPoint string `json:"entry_point"`
+ // EntryPointExists distinguishes an entry point we found from one we merely
+ // suggest. Callers must not write initialization code into a suggested path
+ // without telling the user, since the project does not load that file.
+ EntryPointExists bool `json:"entry_point_exists"`
+}
+
+// Detector inspects a directory to determine the language, framework, package manager,
+// recommended SDK, and entry point file.
+type Detector interface {
+ Detect(dir string) (*DetectResult, error)
+}
+
+// StubDetector is a placeholder implementation. Replace with real detection logic.
+type StubDetector struct{}
+
+var _ Detector = StubDetector{}
+
+func (StubDetector) Detect(_ string) (*DetectResult, error) {
+ return nil, errors.New("detect is not yet implemented: a real Detector must be provided")
+}
+
+// FileDetector implements Detector by scanning the filesystem for known project indicators.
+type FileDetector struct{}
+
+var _ Detector = FileDetector{}
+
+// Detect scans dir for known project files and returns a DetectResult with language,
+// framework, SDK ID, package manager, and a suggested entry point file.
+// Returns an error if the project type cannot be determined.
+// A root package.json is often only build tooling — Rails with jsbundling, Django
+// with Tailwind, a Go binary published to npm — so the backend manifests are
+// checked first and Node claims the project only when it is the sole manifest.
+func (FileDetector) Detect(dir string) (*DetectResult, error) {
+ for _, detect := range []func(string) *DetectResult{
+ detectGo,
+ detectPython,
+ detectRuby,
+ detectJava,
+ detectSwift,
+ detectDotnet,
+ detectNode,
+ } {
+ if result := detect(dir); result != nil {
+ return result, nil
+ }
+ }
+ return nil, errors.New("could not detect project language from directory; try specifying --sdk-id manually")
+}
+
+func detectNode(dir string) *DetectResult {
+ pkgBytes, err := os.ReadFile(filepath.Join(dir, "package.json"))
+ if err != nil {
+ return nil
+ }
+
+ var pkg struct {
+ Dependencies map[string]string `json:"dependencies"`
+ DevDependencies map[string]string `json:"devDependencies"`
+ }
+ if json.Unmarshal(pkgBytes, &pkg) != nil {
+ return nil
+ }
+
+ allDeps := make(map[string]string, len(pkg.Dependencies)+len(pkg.DevDependencies))
+ for k, v := range pkg.Dependencies {
+ allDeps[k] = v
+ }
+ for k, v := range pkg.DevDependencies {
+ allDeps[k] = v
+ }
+
+ pm := detectNodePM(dir)
+
+ // Next.js apps run a Node server (SSR and API routes), so server-side flag
+ // evaluation uses the Node server SDK rather than a browser client SDK.
+ if _, ok := allDeps["next"]; ok {
+ // Entry point: https://nextjs.org/docs/app/guides/instrumentation
+ // Only the hook is guaranteed to stay out of the browser bundle; a page or
+ // route module may carry 'use client' and ship the SDK key to the browser.
+ ep, exists := entryPoint(dir, "instrumentation.ts",
+ "instrumentation.ts", "instrumentation.js",
+ "src/instrumentation.ts", "src/instrumentation.js",
+ )
+ return &DetectResult{
+ Language: "JavaScript",
+ Framework: "Next.js",
+ PackageManager: pm,
+ SDKID: "node-server",
+ EntryPoint: ep,
+ EntryPointExists: exists,
+ }
+ }
+
+ if _, ok := allDeps["react-native"]; ok {
+ // Entry point: https://reactnative.dev/docs/appregistry
+ ep, exists := entryPoint(dir, "index.js",
+ "src/App.tsx", "src/App.jsx", "src/App.js",
+ "src/index.tsx", "src/index.jsx", "src/index.js",
+ "App.tsx", "App.js", "index.js",
+ )
+ return &DetectResult{
+ Language: "JavaScript",
+ Framework: "React Native",
+ PackageManager: pm,
+ SDKID: "react-native",
+ EntryPoint: ep,
+ EntryPointExists: exists,
+ }
+ }
+ if _, ok := allDeps["react"]; ok {
+ // Vite entry: https://vite.dev/guide/#index-html-and-project-root
+ // CRA entry: https://create-react-app.dev/docs/folder-structure
+ // Mounting: https://react.dev/reference/react-dom/client/createRoot
+ ep, exists := entryPoint(dir, "src/App.tsx",
+ "src/App.tsx", "src/App.jsx", "src/App.js",
+ "src/main.tsx", "src/main.jsx",
+ "src/index.tsx", "src/index.jsx", "src/index.js",
+ "index.js",
+ )
+ return &DetectResult{
+ Language: "JavaScript",
+ Framework: "React",
+ PackageManager: pm,
+ SDKID: "react-client-sdk",
+ EntryPoint: ep,
+ EntryPointExists: exists,
+ }
+ }
+ jsClientFrameworks := []struct{ dep, framework string }{
+ {"backbone", "Backbone"},
+ {"svelte", "Svelte"},
+ {"vue", "Vue"},
+ {"@angular/core", "Angular"},
+ {"ember-source", "Ember"},
+ {"preact", "Preact"},
+ }
+ for _, fw := range jsClientFrameworks {
+ if _, ok := allDeps[fw.dep]; ok {
+ // Vite entry: https://vite.dev/guide/#index-html-and-project-root
+ // Angular entry: https://angular.dev/reference/configs/file-structure
+ ep, exists := entryPoint(dir, "src/main.ts",
+ "src/App.tsx", "src/App.jsx", "src/App.js",
+ "src/index.tsx", "src/index.jsx", "src/index.js",
+ "src/main.ts", "src/main.js", "index.js",
+ )
+ return &DetectResult{
+ Language: "JavaScript",
+ Framework: fw.framework,
+ PackageManager: pm,
+ SDKID: "js-client-sdk",
+ EntryPoint: ep,
+ EntryPointExists: exists,
+ }
+ }
+ }
+
+ // Entry point: https://docs.npmjs.com/cli/v11/configuring-npm/package-json#main
+ // NestJS bootstraps from src/main.ts: https://docs.nestjs.com/first-steps
+ ep, exists := entryPoint(dir, "index.js",
+ "src/index.ts", "src/index.js",
+ "src/main.ts", "src/main.js",
+ "index.ts", "index.js",
+ "server.ts", "server.js",
+ "app.ts", "app.js",
+ )
+ return &DetectResult{
+ Language: "JavaScript",
+ PackageManager: pm,
+ SDKID: "node-server",
+ EntryPoint: ep,
+ EntryPointExists: exists,
+ }
+}
+
+func detectNodePM(dir string) string {
+ if _, err := os.Stat(filepath.Join(dir, "pnpm-lock.yaml")); err == nil {
+ return "pnpm"
+ }
+ if _, err := os.Stat(filepath.Join(dir, "yarn.lock")); err == nil {
+ return "yarn"
+ }
+ // Lockfiles: https://bun.com/docs/install/lockfile
+ for _, lock := range []string{"bun.lock", "bun.lockb"} {
+ if _, err := os.Stat(filepath.Join(dir, lock)); err == nil {
+ return "bun"
+ }
+ }
+ return "npm"
+}
+
+func detectGo(dir string) *DetectResult {
+ if _, err := os.Stat(filepath.Join(dir, "go.mod")); err != nil {
+ return nil
+ }
+ // Entry point: https://go.dev/ref/spec#Program_execution
+ ep, exists := entryPoint(dir, "main.go", "main.go", "cmd/main.go")
+ return &DetectResult{
+ Language: "Go",
+ PackageManager: "go",
+ SDKID: "go-server-sdk",
+ EntryPoint: ep,
+ EntryPointExists: exists,
+ }
+}
+
+func detectPython(dir string) *DetectResult {
+ for _, indicator := range []string{"requirements.txt", "pyproject.toml", "setup.py", "Pipfile"} {
+ if _, err := os.Stat(filepath.Join(dir, indicator)); err == nil {
+ // Django entry: https://docs.djangoproject.com/en/stable/ref/django-admin/
+ // Flask entry: https://flask.palletsprojects.com/en/stable/quickstart/
+ ep, exists := entryPoint(dir, "main.py",
+ "src/main.py", "manage.py", "app.py", "main.py",
+ )
+ return &DetectResult{
+ Language: "Python",
+ PackageManager: detectPythonPM(dir),
+ SDKID: "python-server-sdk",
+ EntryPoint: ep,
+ EntryPointExists: exists,
+ }
+ }
+ }
+ return nil
+}
+
+// detectPythonPM identifies the tool that manages the project's dependencies, so
+// callers install into the project rather than running pip against whatever
+// interpreter happens to be on PATH.
+//
+// https://docs.astral.sh/uv/concepts/projects/layout/
+// https://pipenv.pypa.io/en/latest/
+// https://python-poetry.org/docs/pyproject/
+func detectPythonPM(dir string) string {
+ if _, err := os.Stat(filepath.Join(dir, "uv.lock")); err == nil {
+ return "uv"
+ }
+ if _, err := os.Stat(filepath.Join(dir, "Pipfile")); err == nil {
+ return "pipenv"
+ }
+ if b, err := os.ReadFile(filepath.Join(dir, "pyproject.toml")); err == nil {
+ if bytes.Contains(b, []byte("[tool.poetry]")) {
+ return "poetry"
+ }
+ if bytes.Contains(b, []byte("[tool.uv]")) {
+ return "uv"
+ }
+ }
+ return "pip"
+}
+
+func detectRuby(dir string) *DetectResult {
+ found := false
+ for _, indicator := range []string{"Gemfile", "Gemfile.lock", "config.ru"} {
+ if _, err := os.Stat(filepath.Join(dir, indicator)); err == nil {
+ found = true
+ break
+ }
+ }
+ if !found {
+ if matches, _ := filepath.Glob(filepath.Join(dir, "*.gemspec")); len(matches) == 0 {
+ return nil
+ }
+ }
+ // Gemfile: https://bundler.io/guides/gemfile.html
+ pm := "gem"
+ if _, err := os.Stat(filepath.Join(dir, "Gemfile")); err == nil {
+ pm = "bundle"
+ }
+ // config.ru: https://github.com/rack/rack/blob/main/SPEC.rdoc
+ ep, exists := entryPoint(dir, "main.rb", "config.ru", "app.rb", "main.rb")
+ return &DetectResult{
+ Language: "Ruby",
+ PackageManager: pm,
+ SDKID: "ruby-server-sdk",
+ EntryPoint: ep,
+ EntryPointExists: exists,
+ }
+}
+
+func detectJava(dir string) *DetectResult {
+ for _, indicator := range []string{"pom.xml", "build.gradle", "build.gradle.kts"} {
+ if _, err := os.Stat(filepath.Join(dir, indicator)); err == nil {
+ pm := "gradle"
+ if indicator == "pom.xml" {
+ pm = "mvn"
+ }
+ // Manifest: https://developer.android.com/guide/topics/manifest/manifest-intro
+ for _, manifest := range []string{
+ "app/src/main/AndroidManifest.xml",
+ "src/main/AndroidManifest.xml",
+ } {
+ if _, err := os.Stat(filepath.Join(dir, manifest)); err != nil {
+ continue
+ }
+ // Entry point: https://developer.android.com/reference/android/app/Activity
+ // The activity lives under a package directory, so search for it
+ // rather than guessing the package name.
+ srcRoot := strings.TrimSuffix(manifest, "/AndroidManifest.xml")
+ ep, exists := entryPoint(dir, srcRoot+"/java/MainActivity.kt",
+ findFileUnder(dir, srcRoot+"/java", "MainActivity.kt", "MainActivity.java"),
+ findFileUnder(dir, srcRoot+"/kotlin", "MainActivity.kt"),
+ )
+ return &DetectResult{
+ Language: "Java",
+ PackageManager: "gradle",
+ SDKID: "android",
+ EntryPoint: ep,
+ EntryPointExists: exists,
+ }
+ }
+ // Gradle layout: https://docs.gradle.org/current/userguide/building_java_projects.html
+ // Maven layout: https://maven.apache.org/guides/introduction/introduction-to-the-pom.html
+ ep, exists := entryPoint(dir, "src/main/java/Main.java",
+ findFileUnder(dir, "src/main/java", "Main.java", "Application.java", "App.java"),
+ )
+ return &DetectResult{
+ Language: "Java",
+ PackageManager: pm,
+ SDKID: "java-server-sdk",
+ EntryPoint: ep,
+ EntryPointExists: exists,
+ }
+ }
+ }
+ return nil
+}
+
+func detectSwift(dir string) *DetectResult {
+ pm := "spm"
+ if _, err := os.Stat(filepath.Join(dir, "Podfile")); err == nil {
+ pm = "cocoapods"
+ }
+ swiftEntryPoint := func(appRoot string) (string, bool) {
+ return entryPoint(dir, "App.swift", swiftEntryCandidates(dir, appRoot)...)
+ }
+ indicators := []string{"Package.swift", "Podfile"}
+ for _, f := range indicators {
+ if _, err := os.Stat(filepath.Join(dir, f)); err == nil {
+ ep, exists := swiftEntryPoint(xcodeAppRoot(dir))
+ return &DetectResult{
+ Language: "Swift",
+ PackageManager: pm,
+ SDKID: "swift-client-sdk",
+ EntryPoint: ep,
+ EntryPointExists: exists,
+ }
+ }
+ }
+ if appRoot := xcodeAppRoot(dir); appRoot != "" {
+ ep, exists := swiftEntryPoint(appRoot)
+ return &DetectResult{
+ Language: "Swift",
+ PackageManager: pm,
+ SDKID: "swift-client-sdk",
+ EntryPoint: ep,
+ EntryPointExists: exists,
+ }
+ }
+ return nil
+}
+
+// swiftEntryCandidates lists entry-point paths to try for a Swift project, most
+// specific first. appRoot is the Xcode app directory, empty when there is no Xcode
+// project. Any-name matches are confined to a package with a single target, where
+// the entry file is named after that target; with several targets there is no way to
+// tell an entry point from a helper.
+//
+// App struct: https://developer.apple.com/documentation/swiftui/app
+// Package targets: https://developer.apple.com/documentation/packagedescription/target
+func swiftEntryCandidates(dir, appRoot string) []string {
+ candidates := []string{
+ "App.swift", "ContentView.swift", "AppDelegate.swift",
+ findFileUnder(dir, appRoot, "*App.swift", "ContentView.swift", "AppDelegate.swift"),
+ }
+ // Searching Sources/ at all is confined to a single-target package. Across
+ // several targets there is no way to tell an executable's entry file from a
+ // library's, so report a suggestion instead of an arbitrary hit.
+ if target := soleSubdir(dir, "Sources"); target != "" {
+ candidates = append(candidates, findFileUnder(dir, target,
+ "main.swift", filepath.Base(target)+".swift", "*App.swift", "*.swift",
+ ))
+ }
+ return candidates
+}
+
+// soleSubdir returns the path relative to dir of root's only subdirectory, or an
+// empty string when root is missing or holds anything other than exactly one.
+func soleSubdir(dir, root string) string {
+ entries, err := os.ReadDir(filepath.Join(dir, root))
+ if err != nil {
+ return ""
+ }
+ var found string
+ for _, e := range entries {
+ if !e.IsDir() {
+ continue
+ }
+ if found != "" {
+ return ""
+ }
+ found = filepath.Join(root, e.Name())
+ }
+ return found
+}
+
+// xcodeAppRoot returns the source directory an Xcode project keeps its app code in,
+// which the templates name after the project (MyApp.xcodeproj alongside MyApp/).
+// Returns an empty string when dir holds no Xcode project.
+//
+// https://developer.apple.com/documentation/xcode/creating-an-xcode-project-for-an-app
+func xcodeAppRoot(dir string) string {
+ matches, _ := filepath.Glob(filepath.Join(dir, "*.xcodeproj"))
+ if len(matches) == 0 {
+ return ""
+ }
+ return strings.TrimSuffix(filepath.Base(matches[0]), ".xcodeproj")
+}
+
+func detectDotnet(dir string) *DetectResult {
+ for _, pattern := range []string{"*.csproj", "*.sln"} {
+ matches, _ := filepath.Glob(filepath.Join(dir, pattern))
+ if len(matches) > 0 {
+ // Entry point: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/startup
+ ep, exists := entryPoint(dir, "Program.cs",
+ "Program.cs", "Startup.cs", "src/Program.cs",
+ )
+ return &DetectResult{
+ Language: "C#",
+ PackageManager: "dotnet",
+ SDKID: "dotnet-server-sdk",
+ EntryPoint: ep,
+ EntryPointExists: exists,
+ }
+ }
+ }
+ return nil
+}
+
+// SDKOption describes a LaunchDarkly SDK available for use with ldcli setup.
+type SDKOption struct {
+ ID string
+ Language string
+ Name string
+}
+
+// KnownSDKs is the ordered list of SDKs available for manual selection when
+// auto-detection fails or the user wants to override the detected SDK.
+var KnownSDKs = []SDKOption{
+ {ID: "node-server", Language: "JavaScript", Name: "Node.js"},
+ {ID: "react-client-sdk", Language: "JavaScript", Name: "React"},
+ {ID: "react-native", Language: "JavaScript", Name: "React Native"},
+ {ID: "js-client-sdk", Language: "JavaScript", Name: "JavaScript (Browser)"},
+ {ID: "python-server-sdk", Language: "Python", Name: "Python"},
+ {ID: "go-server-sdk", Language: "Go", Name: "Go"},
+ {ID: "java-server-sdk", Language: "Java", Name: "Java"},
+ {ID: "android", Language: "Java", Name: "Android"},
+ {ID: "dotnet-server-sdk", Language: "C#", Name: ".NET"},
+ {ID: "swift-client-sdk", Language: "Swift", Name: "iOS/Swift"},
+ {ID: "ruby-server-sdk", Language: "Ruby", Name: "Ruby"},
+}
+
+// entryPoint returns the first candidate that exists as a file under dir, joined
+// to dir, together with true. When no candidate exists it returns fallback joined
+// to dir and false, so callers can tell a file we found from one we suggest.
+// Empty candidates are skipped, which lets callers pass the result of a lookup
+// that may have come up empty.
+func entryPoint(dir, fallback string, candidates ...string) (string, bool) {
+ for _, c := range candidates {
+ if c == "" {
+ continue
+ }
+ if info, err := os.Stat(filepath.Join(dir, c)); err == nil && !info.IsDir() {
+ return filepath.Join(dir, c), true
+ }
+ }
+ return filepath.Join(dir, fallback), false
+}
+
+// findFileUnder walks root (relative to dir) and returns the first file whose base
+// name matches one of names, as a path relative to dir. A name may start with "*"
+// to match by suffix, so "*App.swift" finds MyAppApp.swift. Names are tried in
+// order so callers can express a preference. Returns an empty string when root is
+// missing or contains no match. An empty root yields no match rather than walking
+// the whole project.
+func findFileUnder(dir, root string, names ...string) string {
+ if root == "" {
+ return ""
+ }
+ matches := func(base, name string) bool {
+ if suffix, ok := strings.CutPrefix(name, "*"); ok {
+ return strings.HasSuffix(base, suffix)
+ }
+ return base == name
+ }
+ for _, name := range names {
+ var found string
+ _ = filepath.WalkDir(filepath.Join(dir, root), func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return nil
+ }
+ if !d.IsDir() && matches(d.Name(), name) {
+ found = path
+ return fs.SkipAll
+ }
+ return nil
+ })
+ if found != "" {
+ if rel, err := filepath.Rel(dir, found); err == nil {
+ return rel
+ }
+ }
+ }
+ return ""
+}
diff --git a/internal/setup/detector_ruby_test.go b/internal/setup/detector_ruby_test.go
new file mode 100644
index 00000000..a38349c6
--- /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 00000000..56df04eb
--- /dev/null
+++ b/internal/setup/detector_shapes_test.go
@@ -0,0 +1,385 @@
+package setup
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// projectShape is a real-world project layout reduced to the files detection reads.
+// want.EntryPoint is relative to the materialized directory and joined before the
+// comparison.
+type projectShape struct {
+ name string
+ files map[string]string
+ dirs []string
+ want DetectResult
+ wantErr bool
+}
+
+// pkgJSON builds a package.json listing deps as dependencies; a dep prefixed with
+// "dev:" goes to devDependencies instead.
+func pkgJSON(deps ...string) string {
+ prod, dev := "", ""
+ for _, d := range deps {
+ if name, ok := cutDevPrefix(d); ok {
+ dev += `"` + name + `":"1.0.0",`
+ continue
+ }
+ prod += `"` + d + `":"1.0.0",`
+ }
+ return `{"dependencies":{` + trimComma(prod) + `},"devDependencies":{` + trimComma(dev) + `}}`
+}
+
+func cutDevPrefix(d string) (string, bool) {
+ if len(d) > 4 && d[:4] == "dev:" {
+ return d[4:], true
+ }
+ return "", false
+}
+
+func trimComma(s string) string {
+ if s == "" {
+ return s
+ }
+ return s[:len(s)-1]
+}
+
+// TestFileDetector_ProjectShapes asserts the whole DetectResult for each layout, so a
+// field the detector stops populating fails here even when the SDK id stays right.
+func TestFileDetector_ProjectShapes(t *testing.T) {
+ shapes := []projectShape{
+ // --- JavaScript / Node ---
+ {
+ name: "next app router",
+ files: map[string]string{
+ "package.json": pkgJSON("next", "react"),
+ "next.config.ts": "export default {}",
+ "app/layout.tsx": "export default function Layout() {}",
+ "app/page.tsx": "export default function Page() {}",
+ "tsconfig.json": "{}",
+ "next-env.d.ts": "",
+ },
+ // A page module may be browser-bundled, which would ship the SDK key.
+ want: DetectResult{Language: "JavaScript", Framework: "Next.js", PackageManager: "npm", SDKID: "node-server", EntryPoint: "instrumentation.ts"},
+ },
+ {
+ name: "next src dir",
+ files: map[string]string{
+ "package.json": pkgJSON("next", "react"),
+ "src/app/page.tsx": "export default function Page() {}",
+ "src/app/layout.tsx": "export default function Layout() {}",
+ },
+ want: DetectResult{Language: "JavaScript", Framework: "Next.js", PackageManager: "npm", SDKID: "node-server", EntryPoint: "instrumentation.ts"},
+ },
+ {
+ name: "next src instrumentation",
+ files: map[string]string{
+ "package.json": pkgJSON("next"),
+ "src/instrumentation.ts": "export function register() {}",
+ "src/app/page.tsx": "export default function Page() {}",
+ },
+ want: DetectResult{Language: "JavaScript", Framework: "Next.js", PackageManager: "npm", SDKID: "node-server", EntryPoint: "src/instrumentation.ts", EntryPointExists: true},
+ },
+ {
+ name: "next root instrumentation",
+ files: map[string]string{
+ "package.json": pkgJSON("next"),
+ "instrumentation.ts": "export function register() {}",
+ "app/page.tsx": "export default function Page() {}",
+ },
+ want: DetectResult{Language: "JavaScript", Framework: "Next.js", PackageManager: "npm", SDKID: "node-server", EntryPoint: "instrumentation.ts", EntryPointExists: true},
+ },
+ {
+ name: "next pages router",
+ files: map[string]string{"package.json": pkgJSON("next", "react"), "pages/index.tsx": "export default function Home() {}"},
+ want: DetectResult{Language: "JavaScript", Framework: "Next.js", PackageManager: "npm", SDKID: "node-server", EntryPoint: "instrumentation.ts"},
+ },
+ {
+ name: "next bare",
+ files: map[string]string{"package.json": pkgJSON("next")},
+ want: DetectResult{Language: "JavaScript", Framework: "Next.js", PackageManager: "npm", SDKID: "node-server", EntryPoint: "instrumentation.ts"},
+ },
+ {
+ name: "node bun",
+ files: map[string]string{"package.json": pkgJSON("hono"), "bun.lockb": ""},
+ want: DetectResult{Language: "JavaScript", PackageManager: "bun", SDKID: "node-server", EntryPoint: "index.js"},
+ },
+ {
+ name: "node npm",
+ files: map[string]string{"package.json": pkgJSON("express"), "package-lock.json": "{}", "index.js": "// entry"},
+ want: DetectResult{Language: "JavaScript", PackageManager: "npm", SDKID: "node-server", EntryPoint: "index.js", EntryPointExists: true},
+ },
+ {
+ name: "node pnpm typescript",
+ files: map[string]string{"package.json": pkgJSON("express"), "pnpm-lock.yaml": "", "src/index.ts": "// entry"},
+ want: DetectResult{Language: "JavaScript", PackageManager: "pnpm", SDKID: "node-server", EntryPoint: "src/index.ts", EntryPointExists: true},
+ },
+ {
+ name: "nest bootstraps from src/main.ts",
+ files: map[string]string{"package.json": pkgJSON("@nestjs/core", "@nestjs/common"), "src/main.ts": "bootstrap()"},
+ want: DetectResult{Language: "JavaScript", PackageManager: "npm", SDKID: "node-server", EntryPoint: "src/main.ts", EntryPointExists: true},
+ },
+ {
+ name: "node prefers src/index over src/main",
+ files: map[string]string{"package.json": pkgJSON("express"), "src/index.ts": "// entry", "src/main.ts": "// other"},
+ want: DetectResult{Language: "JavaScript", PackageManager: "npm", SDKID: "node-server", EntryPoint: "src/index.ts", EntryPointExists: true},
+ },
+ {
+ name: "node yarn server file",
+ files: map[string]string{"package.json": pkgJSON("fastify"), "yarn.lock": "", "server.js": "// entry"},
+ want: DetectResult{Language: "JavaScript", PackageManager: "yarn", SDKID: "node-server", EntryPoint: "server.js", EntryPointExists: true},
+ },
+ {
+ name: "react vite mount point only",
+ files: map[string]string{"package.json": pkgJSON("react", "dev:vite"), "src/main.tsx": "createRoot()"},
+ want: DetectResult{Language: "JavaScript", Framework: "React", PackageManager: "npm", SDKID: "react-client-sdk", EntryPoint: "src/main.tsx", EntryPointExists: true},
+ },
+ {
+ name: "react vite yarn",
+ files: map[string]string{"package.json": pkgJSON("react", "dev:vite"), "yarn.lock": "", "src/App.tsx": "// App"},
+ want: DetectResult{Language: "JavaScript", Framework: "React", PackageManager: "yarn", SDKID: "react-client-sdk", EntryPoint: "src/App.tsx", EntryPointExists: true},
+ },
+ {
+ name: "react vite full scaffold prefers App over mount",
+ files: map[string]string{
+ "package.json": pkgJSON("react", "react-dom", "dev:vite", "dev:@vitejs/plugin-react"),
+ "index.html": "
",
+ "vite.config.ts": "export default {}",
+ "src/App.tsx": "// App",
+ "src/main.tsx": "createRoot()",
+ "src/index.css": "",
+ },
+ want: DetectResult{Language: "JavaScript", Framework: "React", PackageManager: "npm", SDKID: "react-client-sdk", EntryPoint: "src/App.tsx", EntryPointExists: true},
+ },
+ {
+ name: "react native",
+ files: map[string]string{"package.json": pkgJSON("react", "react-native"), "App.tsx": "// App", "index.js": "AppRegistry.registerComponent()"},
+ want: DetectResult{Language: "JavaScript", Framework: "React Native", PackageManager: "npm", SDKID: "react-native", EntryPoint: "App.tsx", EntryPointExists: true},
+ },
+ {
+ name: "vue",
+ files: map[string]string{"package.json": pkgJSON("vue"), "src/main.ts": "createApp()"},
+ want: DetectResult{Language: "JavaScript", Framework: "Vue", PackageManager: "npm", SDKID: "js-client-sdk", EntryPoint: "src/main.ts", EntryPointExists: true},
+ },
+ {
+ name: "svelte",
+ files: map[string]string{"package.json": pkgJSON("svelte"), "src/main.ts": "new App()"},
+ want: DetectResult{Language: "JavaScript", Framework: "Svelte", PackageManager: "npm", SDKID: "js-client-sdk", EntryPoint: "src/main.ts", EntryPointExists: true},
+ },
+
+ // --- Go ---
+ {
+ name: "go single main",
+ files: map[string]string{"go.mod": "module example.com/app\n\ngo 1.22\n", "main.go": "package main\n"},
+ want: DetectResult{Language: "Go", PackageManager: "go", SDKID: "go-server-sdk", EntryPoint: "main.go", EntryPointExists: true},
+ },
+ {
+ name: "go single cmd binary",
+ files: map[string]string{"go.mod": "module example.com/app\n\ngo 1.22\n", "cmd/server/main.go": "package main\n"},
+ want: DetectResult{Language: "Go", PackageManager: "go", SDKID: "go-server-sdk", EntryPoint: "main.go"},
+ },
+ {
+ name: "go several cmd binaries",
+ files: map[string]string{
+ "go.mod": "module example.com/app\n\ngo 1.22\n",
+ "cmd/server/main.go": "package main\n",
+ "cmd/worker/main.go": "package main\n",
+ },
+ want: DetectResult{Language: "Go", PackageManager: "go", SDKID: "go-server-sdk", EntryPoint: "main.go"},
+ },
+
+ // --- Python ---
+ {
+ name: "python pipenv",
+ files: map[string]string{"Pipfile": "[packages]\n", "main.py": "# main"},
+ want: DetectResult{Language: "Python", PackageManager: "pipenv", SDKID: "python-server-sdk", EntryPoint: "main.py", EntryPointExists: true},
+ },
+ {
+ name: "python poetry",
+ files: map[string]string{"pyproject.toml": "[tool.poetry]\nname = \"app\"\n", "app.py": "# app"},
+ want: DetectResult{Language: "Python", PackageManager: "poetry", SDKID: "python-server-sdk", EntryPoint: "app.py", EntryPointExists: true},
+ },
+ {
+ name: "python uv lockfile",
+ files: map[string]string{"pyproject.toml": "[project]\nname = \"app\"\n", "uv.lock": "version = 1\n"},
+ want: DetectResult{Language: "Python", PackageManager: "uv", SDKID: "python-server-sdk", EntryPoint: "main.py"},
+ },
+ {
+ name: "python requirements",
+ files: map[string]string{"requirements.txt": "flask\n", "src/main.py": "# main"},
+ want: DetectResult{Language: "Python", PackageManager: "pip", SDKID: "python-server-sdk", EntryPoint: "src/main.py", EntryPointExists: true},
+ },
+
+ // --- Ruby ---
+ {
+ name: "ruby bundler rack",
+ files: map[string]string{"Gemfile": "source 'https://rubygems.org'\n", "Gemfile.lock": "", "config.ru": "run App"},
+ want: DetectResult{Language: "Ruby", PackageManager: "bundle", SDKID: "ruby-server-sdk", EntryPoint: "config.ru", EntryPointExists: true},
+ },
+ {
+ name: "ruby gemspec only",
+ files: map[string]string{"mygem.gemspec": "Gem::Specification.new\n"},
+ want: DetectResult{Language: "Ruby", PackageManager: "gem", SDKID: "ruby-server-sdk", EntryPoint: "main.rb"},
+ },
+
+ // --- Java / Android ---
+ {
+ name: "java maven",
+ files: map[string]string{"pom.xml": "", "src/main/java/com/example/app/Application.java": "class Application {}"},
+ want: DetectResult{Language: "Java", PackageManager: "mvn", SDKID: "java-server-sdk", EntryPoint: "src/main/java/com/example/app/Application.java", EntryPointExists: true},
+ },
+ {
+ name: "java gradle",
+ files: map[string]string{"build.gradle": "plugins { id 'java' }", "src/main/java/com/example/Main.java": "class Main {}"},
+ want: DetectResult{Language: "Java", PackageManager: "gradle", SDKID: "java-server-sdk", EntryPoint: "src/main/java/com/example/Main.java", EntryPointExists: true},
+ },
+ {
+ name: "android app module kotlin",
+ files: map[string]string{
+ "build.gradle.kts": "plugins { id(\"com.android.application\") }",
+ "settings.gradle.kts": "",
+ "app/src/main/AndroidManifest.xml": "",
+ "app/src/main/java/com/example/myapp/MainActivity.kt": "class MainActivity",
+ },
+ want: DetectResult{Language: "Java", PackageManager: "gradle", SDKID: "android", EntryPoint: "app/src/main/java/com/example/myapp/MainActivity.kt", EntryPointExists: true},
+ },
+ {
+ name: "android single module java",
+ files: map[string]string{
+ "build.gradle": "plugins { id 'com.android.application' }",
+ "src/main/AndroidManifest.xml": "",
+ "src/main/java/com/example/MainActivity.java": "class MainActivity {}",
+ },
+ want: DetectResult{Language: "Java", PackageManager: "gradle", SDKID: "android", EntryPoint: "src/main/java/com/example/MainActivity.java", EntryPointExists: true},
+ },
+
+ // --- Swift ---
+ {
+ name: "swift package single target",
+ files: map[string]string{"Package.swift": "// swift-tools-version:5.9", "Sources/MyTool/MyTool.swift": "print(1)", "Tests/MyToolTests/MyToolTests.swift": ""},
+ want: DetectResult{Language: "Swift", PackageManager: "spm", SDKID: "swift-client-sdk", EntryPoint: "Sources/MyTool/MyTool.swift", EntryPointExists: true},
+ },
+ {
+ name: "swift package sources without target dir",
+ files: map[string]string{"Package.swift": "// swift-tools-version:5.9", "Sources/main.swift": "print(1)"},
+ want: DetectResult{Language: "Swift", PackageManager: "spm", SDKID: "swift-client-sdk", EntryPoint: "App.swift"},
+ },
+ {
+ name: "swift package several targets",
+ files: map[string]string{
+ "Package.swift": "// swift-tools-version:5.9",
+ "Sources/Alpha/Helper.swift": "struct Helper {}",
+ "Sources/Beta/main.swift": "print(1)",
+ },
+ want: DetectResult{Language: "Swift", PackageManager: "spm", SDKID: "swift-client-sdk", EntryPoint: "App.swift"},
+ },
+ {
+ name: "swift xcode project",
+ files: map[string]string{"MyApp/MyAppApp.swift": "@main struct MyAppApp {}", "MyApp/ContentView.swift": "struct ContentView {}"},
+ dirs: []string{"MyApp.xcodeproj"},
+ want: DetectResult{Language: "Swift", PackageManager: "spm", SDKID: "swift-client-sdk", EntryPoint: "MyApp/MyAppApp.swift", EntryPointExists: true},
+ },
+ {
+ name: "swift cocoapods",
+ files: map[string]string{"Podfile": "platform :ios, '14.0'"},
+ want: DetectResult{Language: "Swift", PackageManager: "cocoapods", SDKID: "swift-client-sdk", EntryPoint: "App.swift"},
+ },
+
+ // --- C# ---
+ {
+ name: "dotnet csproj",
+ files: map[string]string{"MyApp.csproj": "", "Program.cs": "// entry"},
+ want: DetectResult{Language: "C#", PackageManager: "dotnet", SDKID: "dotnet-server-sdk", EntryPoint: "Program.cs", EntryPointExists: true},
+ },
+ {
+ name: "dotnet solution with nested project",
+ files: map[string]string{"MyApp.sln": "", "src/MyApp/MyApp.csproj": "", "src/MyApp/Program.cs": "// entry"},
+ want: DetectResult{Language: "C#", PackageManager: "dotnet", SDKID: "dotnet-server-sdk", EntryPoint: "Program.cs"},
+ },
+
+ // --- Polyglot: a root package.json is usually build tooling ---
+ {
+ name: "rails with jsbundling",
+ files: map[string]string{
+ "Gemfile": "source 'https://rubygems.org'\ngem 'rails'\n",
+ "config.ru": "run Rails.application",
+ "package.json": pkgJSON("esbuild"),
+ },
+ want: DetectResult{Language: "Ruby", PackageManager: "bundle", SDKID: "ruby-server-sdk", EntryPoint: "config.ru", EntryPointExists: true},
+ },
+ {
+ name: "django with tailwind",
+ files: map[string]string{
+ "requirements.txt": "Django==5.0\n",
+ "manage.py": "# manage",
+ "package.json": pkgJSON("dev:tailwindcss"),
+ },
+ want: DetectResult{Language: "Python", PackageManager: "pip", SDKID: "python-server-sdk", EntryPoint: "manage.py", EntryPointExists: true},
+ },
+ {
+ name: "go binary published to npm",
+ files: map[string]string{
+ "go.mod": "module example.com/app\n\ngo 1.22\n",
+ "main.go": "package main\n",
+ "package.json": `{"name":"app-cli"}`,
+ },
+ want: DetectResult{Language: "Go", PackageManager: "go", SDKID: "go-server-sdk", EntryPoint: "main.go", EntryPointExists: true},
+ },
+ {
+ name: "next app carrying a Gemfile",
+ files: map[string]string{
+ "package.json": pkgJSON("next"),
+ "Gemfile": "source 'https://rubygems.org'\ngem 'rubocop'\n",
+ },
+ // Accepted cost of preferring the backend manifest. The wizard lets the
+ // user override the SDK, and --sdk-id exists.
+ want: DetectResult{Language: "Ruby", PackageManager: "bundle", SDKID: "ruby-server-sdk", EntryPoint: "main.rb"},
+ },
+ {
+ name: "next app carrying a ruff config",
+ files: map[string]string{
+ "package.json": pkgJSON("next"),
+ "pyproject.toml": "[tool.ruff]\nline-length = 100\n",
+ },
+ want: DetectResult{Language: "Python", PackageManager: "pip", SDKID: "python-server-sdk", EntryPoint: "main.py"},
+ },
+
+ // --- No manifest at all ---
+ {name: "empty directory", wantErr: true},
+ {name: "malformed package.json", files: map[string]string{"package.json": "not json {{{"}, wantErr: true},
+ }
+
+ for _, shape := range shapes {
+ t.Run(shape.name, func(t *testing.T) {
+ dir := materialize(t, shape)
+
+ result, err := FileDetector{}.Detect(dir)
+
+ if shape.wantErr {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "could not detect")
+ return
+ }
+ require.NoError(t, err)
+ want := shape.want
+ want.EntryPoint = filepath.Join(dir, want.EntryPoint)
+ assert.Equal(t, want, *result)
+ })
+ }
+}
+
+func materialize(t *testing.T, shape projectShape) string {
+ t.Helper()
+ dir := t.TempDir()
+ for _, d := range shape.dirs {
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, d), 0755))
+ }
+ for name, content := range shape.files {
+ writeDetectFile(t, dir, name, content)
+ }
+ return dir
+}
diff --git a/internal/setup/detector_test.go b/internal/setup/detector_test.go
new file mode 100644
index 00000000..cb48d976
--- /dev/null
+++ b/internal/setup/detector_test.go
@@ -0,0 +1,794 @@
+package setup
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// writeDetectFile writes content to a file in dir, creating parent directories as needed.
+func writeDetectFile(t *testing.T, dir, name, content string) {
+ t.Helper()
+ path := filepath.Join(dir, name)
+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755))
+ require.NoError(t, os.WriteFile(path, []byte(content), 0600))
+}
+
+func TestFileDetector_DetectsReact(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0"}}`)
+ writeDetectFile(t, dir, "src/App.tsx", "// App")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "react-client-sdk", result.SDKID)
+ assert.Equal(t, "JavaScript", result.Language)
+ assert.Equal(t, "React", result.Framework)
+ assert.Equal(t, "npm", result.PackageManager)
+ assert.Equal(t, filepath.Join(dir, "src/App.tsx"), result.EntryPoint)
+ assert.True(t, result.EntryPointExists)
+}
+
+func TestFileDetector_DetectsReactNative(t *testing.T) {
+ dir := t.TempDir()
+ // React Native projects always list both "react" and "react-native" as deps;
+ // react-native must be checked first so it takes priority over react.
+ writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0","react-native":"^0.73.0"}}`)
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "react-native", result.SDKID)
+ assert.Equal(t, "JavaScript", result.Language)
+ assert.Equal(t, "React Native", result.Framework)
+}
+
+func TestFileDetector_DetectsNextJs(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0","next":"^14.0.0"}}`)
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "node-server", result.SDKID)
+ assert.Equal(t, "JavaScript", result.Language)
+ assert.Equal(t, "Next.js", result.Framework)
+}
+
+func TestFileDetector_DetectsNodeJs(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "package.json", `{"dependencies":{"express":"^4.0.0"}}`)
+ writeDetectFile(t, dir, "index.js", "// entry")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "node-server", result.SDKID)
+ assert.Equal(t, "JavaScript", result.Language)
+ assert.Empty(t, result.Framework)
+ assert.Equal(t, filepath.Join(dir, "index.js"), result.EntryPoint)
+ assert.True(t, result.EntryPointExists)
+}
+
+func TestFileDetector_DetectsGo(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "go.mod", "module example.com/myapp\n\ngo 1.21\n")
+ writeDetectFile(t, dir, "main.go", "package main\n")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "go-server-sdk", result.SDKID)
+ assert.Equal(t, "Go", result.Language)
+ assert.Equal(t, "go", result.PackageManager)
+ assert.Equal(t, filepath.Join(dir, "main.go"), result.EntryPoint)
+ assert.True(t, result.EntryPointExists)
+}
+
+func TestFileDetector_DetectsPython_RequirementsTxt(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "requirements.txt", "flask==3.0.0\n")
+ writeDetectFile(t, dir, "app.py", "# app")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "python-server-sdk", result.SDKID)
+ assert.Equal(t, "Python", result.Language)
+ assert.Equal(t, "pip", result.PackageManager)
+ assert.Equal(t, filepath.Join(dir, "app.py"), result.EntryPoint)
+ assert.True(t, result.EntryPointExists)
+}
+
+func TestFileDetector_DetectsPython_Pyproject(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "pyproject.toml", "[tool.poetry]\nname = \"myapp\"\n")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "python-server-sdk", result.SDKID)
+}
+
+func TestFileDetector_DetectsJava_PomXml(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "pom.xml", "")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "java-server-sdk", result.SDKID)
+ assert.Equal(t, "Java", result.Language)
+ assert.Equal(t, "mvn", result.PackageManager)
+}
+
+func TestFileDetector_DetectsJava_BuildGradle(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "build.gradle", "plugins { id 'java' }")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "java-server-sdk", result.SDKID)
+ assert.Equal(t, "gradle", result.PackageManager)
+}
+
+func TestFileDetector_DetectsAndroid_BuildGradle(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "build.gradle", "plugins { id 'com.android.application' }")
+ writeDetectFile(t, dir, "app/src/main/AndroidManifest.xml", "")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "android", result.SDKID)
+ assert.Equal(t, "Java", result.Language)
+ assert.Equal(t, "gradle", result.PackageManager)
+}
+
+func TestFileDetector_DetectsAndroid_KotlinDsl(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "build.gradle.kts", "plugins { id(\"com.android.application\") }")
+ writeDetectFile(t, dir, "app/src/main/AndroidManifest.xml", "")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "android", result.SDKID)
+ assert.Equal(t, "gradle", result.PackageManager)
+}
+
+func TestFileDetector_DetectsJava_NotAndroid(t *testing.T) {
+ // build.gradle without AndroidManifest.xml should still return java-server-sdk
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "build.gradle", "plugins { id 'java' }")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "java-server-sdk", result.SDKID)
+}
+
+func TestFileDetector_UnknownProject_ReturnsError(t *testing.T) {
+ dir := t.TempDir()
+
+ _, err := FileDetector{}.Detect(dir)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "could not detect")
+}
+
+func TestFileDetector_DetectsNodePM_Pnpm(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "package.json", `{}`)
+ writeDetectFile(t, dir, "pnpm-lock.yaml", "")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "pnpm", result.PackageManager)
+}
+
+func TestFileDetector_DetectsNodePM_Yarn(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "package.json", `{}`)
+ writeDetectFile(t, dir, "yarn.lock", "")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "yarn", result.PackageManager)
+}
+
+func TestFileDetector_DetectsNodePM_Bun(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "package.json", `{}`)
+ writeDetectFile(t, dir, "bun.lock", "")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "bun", result.PackageManager)
+}
+
+func TestFileDetector_DetectsJsClientFramework(t *testing.T) {
+ tests := []struct {
+ dep string
+ framework string
+ }{
+ {"vue", "Vue"},
+ {"svelte", "Svelte"},
+ {"backbone", "Backbone"},
+ {"@angular/core", "Angular"},
+ {"ember-source", "Ember"},
+ {"preact", "Preact"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.framework, func(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "package.json", `{"dependencies":{"`+tt.dep+`":"^1.0.0"}}`)
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "js-client-sdk", result.SDKID)
+ assert.Equal(t, tt.framework, result.Framework)
+ })
+ }
+}
+
+func TestFileDetector_DetectsSwift_PackageSwift(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "swift-client-sdk", result.SDKID)
+ assert.Equal(t, "Swift", result.Language)
+ assert.Equal(t, "spm", result.PackageManager)
+}
+
+func TestFileDetector_DetectsSwift_Podfile(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "Podfile", "platform :ios, '14.0'")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "swift-client-sdk", result.SDKID)
+ assert.Equal(t, "cocoapods", result.PackageManager)
+}
+
+func TestFileDetector_DetectsSwift_XcodeProj(t *testing.T) {
+ dir := t.TempDir()
+ // .xcodeproj is a directory in practice, but we use Glob so creating the dir is enough
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "MyApp.xcodeproj"), 0755))
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "swift-client-sdk", result.SDKID)
+ assert.Equal(t, "Swift", result.Language)
+}
+
+func TestFileDetector_DetectsDotnet_Csproj(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "MyApp.csproj", "")
+ writeDetectFile(t, dir, "Program.cs", "// entry")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "dotnet-server-sdk", result.SDKID)
+ assert.Equal(t, "C#", result.Language)
+ assert.Equal(t, "dotnet", result.PackageManager)
+ assert.Equal(t, filepath.Join(dir, "Program.cs"), result.EntryPoint)
+ assert.True(t, result.EntryPointExists)
+}
+
+func TestFileDetector_DetectsDotnet_Sln(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "MyApp.sln", "")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "dotnet-server-sdk", result.SDKID)
+ assert.Equal(t, "dotnet", result.PackageManager)
+}
+
+func TestKnownSDKs_ContainsExpectedSDKs(t *testing.T) {
+ ids := make([]string, len(KnownSDKs))
+ for i, sdk := range KnownSDKs {
+ ids[i] = sdk.ID
+ }
+ assert.Contains(t, ids, "node-server")
+ assert.Contains(t, ids, "react-client-sdk")
+ assert.Contains(t, ids, "react-native")
+ assert.Contains(t, ids, "python-server-sdk")
+ assert.Contains(t, ids, "go-server-sdk")
+ assert.Contains(t, ids, "java-server-sdk")
+ assert.Contains(t, ids, "dotnet-server-sdk")
+ assert.Contains(t, ids, "swift-client-sdk")
+ assert.Contains(t, ids, "ruby-server-sdk")
+}
+
+func TestFileDetector_EntryPointFallback_WhenNoneExist(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0"}}`)
+ // No src/App.tsx or other entry point files
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, filepath.Join(dir, "src/App.tsx"), result.EntryPoint)
+ assert.False(t, result.EntryPointExists, "a suggested path must not look like one we found")
+}
+
+func TestFileDetector_MalformedPackageJSON_FallsThrough(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "package.json", `not valid json {{{`)
+ // No other project indicators
+
+ _, err := FileDetector{}.Detect(dir)
+
+ // detectNode skips invalid JSON; no other indicators → error
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "could not detect")
+}
+
+// A page module may carry 'use client' or be imported by something that does, which
+// would ship the server SDK key to the browser, so never target one.
+func TestFileDetector_NextJs_AppRouter_SuggestsInstrumentation(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0","next":"^15.0.0"}}`)
+ writeDetectFile(t, dir, "app/page.tsx", "export default function Page() {}")
+ writeDetectFile(t, dir, "app/layout.tsx", "export default function Layout() {}")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "node-server", result.SDKID)
+ assert.Equal(t, filepath.Join(dir, "instrumentation.ts"), result.EntryPoint)
+ assert.False(t, result.EntryPointExists)
+}
+
+func TestFileDetector_NextJs_PrefersExistingInstrumentation(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "package.json", `{"dependencies":{"next":"^15.0.0"}}`)
+ writeDetectFile(t, dir, "instrumentation.ts", "export function register() {}")
+ writeDetectFile(t, dir, "app/page.tsx", "export default function Page() {}")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, filepath.Join(dir, "instrumentation.ts"), result.EntryPoint)
+ assert.True(t, result.EntryPointExists)
+}
+
+func TestFileDetector_NextJs_PagesRouter_SuggestsInstrumentation(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0","next":"^13.0.0"}}`)
+ writeDetectFile(t, dir, "pages/index.tsx", "export default function Home() {}")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ // pages/* is bundled for the browser, so it is never a server SDK target.
+ assert.Equal(t, filepath.Join(dir, "instrumentation.ts"), result.EntryPoint)
+ assert.False(t, result.EntryPointExists)
+}
+
+func TestFileDetector_NextJs_Empty_SuggestsInstrumentation(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "package.json", `{"dependencies":{"next":"^15.0.0"}}`)
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, filepath.Join(dir, "instrumentation.ts"), result.EntryPoint)
+ assert.False(t, result.EntryPointExists)
+}
+
+func TestFileDetector_Android_FindsKotlinActivityInPackageDir(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "build.gradle.kts", "plugins { id(\"com.android.application\") }")
+ writeDetectFile(t, dir, "app/src/main/AndroidManifest.xml", "")
+ writeDetectFile(t, dir, "app/src/main/java/com/example/myapp/MainActivity.kt", "class MainActivity")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "android", result.SDKID)
+ assert.Equal(t, filepath.Join(dir, "app/src/main/java/com/example/myapp/MainActivity.kt"), result.EntryPoint)
+ assert.True(t, result.EntryPointExists)
+}
+
+func TestFileDetector_Android_KotlinSourceRoot(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "build.gradle.kts", "plugins { id(\"com.android.application\") }")
+ writeDetectFile(t, dir, "app/src/main/AndroidManifest.xml", "")
+ writeDetectFile(t, dir, "app/src/main/kotlin/com/example/MainActivity.kt", "class MainActivity")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, filepath.Join(dir, "app/src/main/kotlin/com/example/MainActivity.kt"), result.EntryPoint)
+ assert.True(t, result.EntryPointExists)
+}
+
+func TestFileDetector_Android_NoAppModule_UsesMatchedSourceRoot(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "build.gradle", "plugins { id 'com.android.application' }")
+ writeDetectFile(t, dir, "src/main/AndroidManifest.xml", "")
+ writeDetectFile(t, dir, "src/main/java/com/example/MainActivity.java", "class MainActivity {}")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ // The old code hardcoded app/src/main/... even for this single-module layout.
+ assert.Equal(t, filepath.Join(dir, "src/main/java/com/example/MainActivity.java"), result.EntryPoint)
+ assert.True(t, result.EntryPointExists)
+}
+
+func TestFileDetector_Android_NoActivity_SuggestsUnderMatchedRoot(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "build.gradle", "plugins { id 'com.android.application' }")
+ writeDetectFile(t, dir, "src/main/AndroidManifest.xml", "")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, filepath.Join(dir, "src/main/java/MainActivity.kt"), result.EntryPoint)
+ assert.False(t, result.EntryPointExists)
+}
+
+func TestFileDetector_Java_FindsMainInPackageDir(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "pom.xml", "")
+ writeDetectFile(t, dir, "src/main/java/com/example/app/Application.java", "class Application {}")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "java-server-sdk", result.SDKID)
+ assert.Equal(t, filepath.Join(dir, "src/main/java/com/example/app/Application.java"), result.EntryPoint)
+ assert.True(t, result.EntryPointExists)
+}
+
+func TestFileDetector_Ruby_GemfileReportsBundler(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "Gemfile", "source 'https://rubygems.org'\n")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "bundle", result.PackageManager)
+}
+
+func TestFileDetector_Ruby_NoGemfileReportsGem(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "mygem.gemspec", "Gem::Specification.new\n")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "gem", result.PackageManager)
+}
+
+func TestFileDetector_PythonPackageManagers(t *testing.T) {
+ tests := []struct {
+ name string
+ files map[string]string
+ want string
+ }{
+ {"pip", map[string]string{"requirements.txt": "flask\n"}, "pip"},
+ {"poetry", map[string]string{"pyproject.toml": "[tool.poetry]\nname = \"myapp\"\n"}, "poetry"},
+ {"uv lockfile", map[string]string{"pyproject.toml": "[project]\nname = \"myapp\"\n", "uv.lock": "version = 1\n"}, "uv"},
+ {"uv section", map[string]string{"pyproject.toml": "[project]\nname = \"a\"\n[tool.uv]\n"}, "uv"},
+ {"pipenv", map[string]string{"Pipfile": "[packages]\n"}, "pipenv"},
+ {"bare pyproject", map[string]string{"pyproject.toml": "[project]\nname = \"myapp\"\n"}, "pip"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ dir := t.TempDir()
+ for name, content := range tt.files {
+ writeDetectFile(t, dir, name, content)
+ }
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "python-server-sdk", result.SDKID)
+ assert.Equal(t, tt.want, result.PackageManager)
+ })
+ }
+}
+
+func TestFileDetector_DetectsNodePM_BunBinaryLockfile(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "package.json", `{}`)
+ writeDetectFile(t, dir, "bun.lockb", "")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "bun", result.PackageManager)
+}
+
+func TestKnownSDKs_UsesAndroidID(t *testing.T) {
+ ids := make([]string, len(KnownSDKs))
+ for i, sdk := range KnownSDKs {
+ ids[i] = sdk.ID
+ }
+ assert.Contains(t, ids, "android")
+ assert.NotContains(t, ids, "android-client-sdk")
+}
+
+func TestEntryPoint_NoCandidateExists_ReturnsFallback(t *testing.T) {
+ dir := t.TempDir()
+
+ got, exists := entryPoint(dir, "fallback.go", "nonexistent.go", "also-nonexistent.go")
+
+ assert.Equal(t, filepath.Join(dir, "fallback.go"), got)
+ assert.False(t, exists)
+}
+
+func TestEntryPoint_MatchesFirstExisting(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "second.go", "")
+ writeDetectFile(t, dir, "first.go", "")
+
+ got, exists := entryPoint(dir, "fallback.go", "first.go", "second.go")
+
+ assert.Equal(t, filepath.Join(dir, "first.go"), got)
+ assert.True(t, exists)
+}
+
+func TestEntryPoint_SkipsEmptyAndDirectoryCandidates(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "src"), 0755))
+ writeDetectFile(t, dir, "real.go", "")
+
+ got, exists := entryPoint(dir, "fallback.go", "", "src", "real.go")
+
+ assert.Equal(t, filepath.Join(dir, "real.go"), got)
+ assert.True(t, exists)
+}
+
+func TestFindFileUnder(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "src/main/java/com/example/App.java", "")
+
+ assert.Equal(t, filepath.Join("src/main/java/com/example/App.java"),
+ findFileUnder(dir, "src/main/java", "Main.java", "App.java"))
+ assert.Empty(t, findFileUnder(dir, "src/main/java", "Missing.java"))
+ assert.Empty(t, findFileUnder(dir, "does/not/exist", "App.java"))
+}
+
+// Multi-binary repos have no single entry point, so the detector must not pick one
+// of them arbitrarily and report it as found.
+func TestFileDetector_Go_MultipleBinaries_Suggests(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "go.mod", "module example.com/app\n\ngo 1.22\n")
+ writeDetectFile(t, dir, "cmd/server/main.go", "package main\n")
+ writeDetectFile(t, dir, "cmd/worker/main.go", "package main\n")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, filepath.Join(dir, "main.go"), result.EntryPoint)
+ assert.False(t, result.EntryPointExists)
+}
+
+// An entry file named after the module, as ld-relay and gonfalon do, is not something
+// we can guess at either.
+func TestFileDetector_Go_ModuleNamedEntryFile_Suggests(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "go.mod", "module github.com/launchdarkly/ld-relay/v8\n\ngo 1.22\n")
+ writeDetectFile(t, dir, "ld-relay.go", "package main\n")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.False(t, result.EntryPointExists)
+}
+
+func TestFileDetector_Swift_NestedSourcesTarget(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9")
+ // `swift package init` names the file after the target, not main.swift.
+ writeDetectFile(t, dir, "Sources/MyTool/MyTool.swift", "print(1)")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, filepath.Join(dir, "Sources/MyTool/MyTool.swift"), result.EntryPoint)
+ assert.True(t, result.EntryPointExists)
+}
+
+func TestFileDetector_Swift_PrefersMainSwiftInSources(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9")
+ writeDetectFile(t, dir, "Sources/MyTool/Helper.swift", "")
+ writeDetectFile(t, dir, "Sources/MyTool/main.swift", "print(1)")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, filepath.Join(dir, "Sources/MyTool/main.swift"), result.EntryPoint)
+}
+
+func TestFileDetector_Swift_XcodeAppNamedDirectory(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "MyApp.xcodeproj"), 0755))
+ // Xcode's SwiftUI template puts the app code in a directory named after the project.
+ writeDetectFile(t, dir, "MyApp/MyAppApp.swift", "@main struct MyAppApp {}")
+ writeDetectFile(t, dir, "MyApp/ContentView.swift", "struct ContentView {}")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, filepath.Join(dir, "MyApp/MyAppApp.swift"), result.EntryPoint)
+ assert.True(t, result.EntryPointExists)
+}
+
+func TestFileDetector_Swift_NoSources_Suggests(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, filepath.Join(dir, "App.swift"), result.EntryPoint)
+ assert.False(t, result.EntryPointExists)
+}
+
+func TestFileDetector_React_ViteMountPoint(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0"}}`)
+ // Vite scaffolds src/main.tsx; without App.tsx the old list fell through to a
+ // nonexistent src/App.tsx even though the mount point was right there.
+ writeDetectFile(t, dir, "src/main.tsx", "createRoot()")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, filepath.Join(dir, "src/main.tsx"), result.EntryPoint)
+ assert.True(t, result.EntryPointExists)
+}
+
+func TestFindFileUnder_SuffixPattern(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "MyApp/MyAppApp.swift", "")
+
+ assert.Equal(t, filepath.Join("MyApp/MyAppApp.swift"), findFileUnder(dir, "MyApp", "*App.swift"))
+ assert.Empty(t, findFileUnder(dir, "MyApp", "*.kt"))
+}
+
+// An empty root must not walk the whole project.
+func TestFindFileUnder_EmptyRoot(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "deep/nested/App.swift", "")
+
+ assert.Empty(t, findFileUnder(dir, "", "App.swift"))
+}
+
+// With several targets there is no way to tell an entry point from a helper, so the
+// detector must not present an arbitrary pick as found.
+func TestFileDetector_Swift_MultipleTargets_Suggests(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9")
+ writeDetectFile(t, dir, "Sources/Alpha/Helper.swift", "struct Helper {}")
+ writeDetectFile(t, dir, "Sources/Beta/Beta.swift", "@main struct Beta {}")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, filepath.Join(dir, "App.swift"), result.EntryPoint)
+ assert.False(t, result.EntryPointExists)
+}
+
+func TestFileDetector_Swift_SingleTarget_PrefersTargetNamedFile(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9")
+ // Helper.swift sorts first, but MyTool.swift is the entry file.
+ writeDetectFile(t, dir, "Sources/MyTool/Helper.swift", "struct Helper {}")
+ writeDetectFile(t, dir, "Sources/MyTool/MyTool.swift", "@main struct MyTool {}")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, filepath.Join(dir, "Sources/MyTool/MyTool.swift"), result.EntryPoint)
+ assert.True(t, result.EntryPointExists)
+}
+
+func TestSoleSubdir(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "one/Alpha/a.swift", "")
+ writeDetectFile(t, dir, "two/Alpha/a.swift", "")
+ writeDetectFile(t, dir, "two/Beta/b.swift", "")
+ writeDetectFile(t, dir, "files/a.swift", "")
+
+ assert.Equal(t, filepath.Join("one/Alpha"), soleSubdir(dir, "one"))
+ assert.Empty(t, soleSubdir(dir, "two"), "two subdirectories is ambiguous")
+ assert.Empty(t, soleSubdir(dir, "files"), "files are not targets")
+ assert.Empty(t, soleSubdir(dir, "missing"))
+}
+
+// A root package.json is often only build tooling, so a backend manifest wins.
+func TestFileDetector_Polyglot_BackendManifestWins(t *testing.T) {
+ tests := []struct {
+ name string
+ files map[string]string
+ wantSDK string
+ }{
+ {"rails with jsbundling", map[string]string{
+ "Gemfile": "source 'https://rubygems.org'\ngem 'rails'\n", "package.json": `{"dependencies":{"esbuild":"0.20.0"}}`,
+ }, "ruby-server-sdk"},
+ {"django with tailwind", map[string]string{
+ "requirements.txt": "Django==5.0\n", "package.json": `{"devDependencies":{"tailwindcss":"3.4.0"}}`,
+ }, "python-server-sdk"},
+ {"go binary published to npm", map[string]string{
+ "go.mod": "module example.com/app\n\ngo 1.22\n", "package.json": `{"name":"app-cli"}`,
+ }, "go-server-sdk"},
+ {"dotnet with npm assets", map[string]string{
+ "App.csproj": "", "package.json": `{"devDependencies":{"vite":"5.0.0"}}`,
+ }, "dotnet-server-sdk"},
+ // package.json is the only manifest, so Node still claims it.
+ {"plain next.js", map[string]string{
+ "package.json": `{"dependencies":{"next":"15.0.0"}}`,
+ }, "node-server"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ dir := t.TempDir()
+ for name, content := range tt.files {
+ writeDetectFile(t, dir, name, content)
+ }
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, tt.wantSDK, result.SDKID)
+ })
+ }
+}
+
+// Searching Sources/ is confined to single-target packages, so neither main.swift
+// nor a *App.swift in one of several targets may be reported as found.
+func TestFileDetector_Swift_MultipleTargets_NeverReportsFound(t *testing.T) {
+ for _, entry := range []string{"Sources/Beta/main.swift", "Sources/Zeta/ZetaApp.swift", "Sources/Beta/Beta.swift"} {
+ t.Run(entry, func(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9")
+ writeDetectFile(t, dir, "Sources/Alpha/Helper.swift", "struct Helper {}")
+ writeDetectFile(t, dir, entry, "// entry")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, filepath.Join(dir, "App.swift"), result.EntryPoint)
+ assert.False(t, result.EntryPointExists)
+ })
+ }
+}
+
+func TestFileDetector_Swift_SingleTarget_PrefersMainSwift(t *testing.T) {
+ dir := t.TempDir()
+ writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9")
+ writeDetectFile(t, dir, "Sources/MyTool/Helper.swift", "")
+ writeDetectFile(t, dir, "Sources/MyTool/MyTool.swift", "")
+ writeDetectFile(t, dir, "Sources/MyTool/main.swift", "print(1)")
+
+ result, err := FileDetector{}.Detect(dir)
+
+ require.NoError(t, err)
+ assert.Equal(t, filepath.Join(dir, "Sources/MyTool/main.swift"), result.EntryPoint)
+ assert.True(t, result.EntryPointExists)
+}
diff --git a/internal/setup/installer.go b/internal/setup/installer.go
new file mode 100644
index 00000000..f538ad0d
--- /dev/null
+++ b/internal/setup/installer.go
@@ -0,0 +1,356 @@
+package setup
+
+import (
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+// InstallResult contains the outcome of installing an SDK package.
+type InstallResult struct {
+ SDKID string `json:"sdk_id"`
+ Package string `json:"package"`
+ Version string `json:"version"`
+ Command string `json:"command"`
+ DryRun bool `json:"dry_run,omitempty"`
+ AlreadyInstalled bool `json:"already_installed,omitempty"`
+ Failed bool `json:"failed,omitempty"`
+ // FailureReason carries the underlying error when Failed is true, so callers
+ // can tell the user why the automatic install did not run.
+ FailureReason string `json:"failure_reason,omitempty"`
+ Success bool `json:"success"`
+}
+
+// RequiresManualInstall reports whether the SDK has no automated package-manager
+// command and must be added by hand (e.g. Java, Android, Swift).
+func RequiresManualInstall(sdkID string) bool {
+ return manualInstallSDKs[sdkID]
+}
+
+// Installer runs the appropriate package manager command to add an SDK dependency.
+type Installer interface {
+ Install(dir string, detection *DetectResult) (*InstallResult, error)
+}
+
+// StubInstaller is a placeholder implementation. Replace with real install logic.
+type StubInstaller struct{}
+
+var _ Installer = StubInstaller{}
+
+func (StubInstaller) Install(_ string, _ *DetectResult) (*InstallResult, error) {
+ return nil, errors.New("install is not yet implemented: a real Installer must be provided")
+}
+
+// PackageInstaller implements Installer using the system package manager.
+// Its run field can be replaced in tests to avoid executing real commands.
+type PackageInstaller struct {
+ run func(dir string, args []string) ([]byte, error)
+}
+
+var _ Installer = PackageInstaller{}
+
+// manualInstallSDKs lists SDKs that have no automated package-manager command
+// (Java, Android, Swift) but ARE recognised. For these, Install returns
+// Success=false without an error so the wizard can proceed and show the package
+// identifier. An SDK ID that is neither installable nor in this set is unknown
+// and is treated as an error rather than a silent no-op.
+var manualInstallSDKs = map[string]bool{
+ "java-server-sdk": true,
+ "android": true,
+ "android-client-sdk": true,
+ "swift-client-sdk": true,
+ "ios-client-sdk": true,
+}
+
+// Install runs the appropriate package manager command to add the SDK dependency.
+// For SDKs that require manual installation (e.g. Java, Android, Swift), Install
+// returns a result with Success=false without returning an error. An unknown SDK
+// ID returns an error.
+func (p PackageInstaller) Install(dir string, detection *DetectResult) (*InstallResult, error) {
+ args, pkg := InstallArgs(detection.SDKID, detection.PackageManager)
+ if len(args) == 0 {
+ if !manualInstallSDKs[detection.SDKID] {
+ return nil, fmt.Errorf("unknown SDK %q: no install command available; specify a supported --sdk-id", detection.SDKID)
+ }
+ return &InstallResult{
+ SDKID: detection.SDKID,
+ Package: pkg,
+ Success: false,
+ }, nil
+ }
+
+ // Skip the install if the SDK is already a dependency of the project.
+ if IsInstalled(dir, detection.SDKID) {
+ return &InstallResult{
+ SDKID: detection.SDKID,
+ Package: pkg,
+ AlreadyInstalled: true,
+ Success: true,
+ }, nil
+ }
+
+ if detection.SDKID == "dotnet-server-sdk" {
+ target, reason := dotnetProjectArg(dir)
+ if reason != "" {
+ return &InstallResult{
+ SDKID: detection.SDKID,
+ Package: pkg,
+ Failed: true,
+ FailureReason: reason,
+ }, nil
+ }
+ args = append(args, target...)
+ }
+
+ runner := p.run
+ if runner == nil {
+ runner = execRun
+ }
+
+ out, err := runner(dir, args)
+ command := strings.Join(args, " ")
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w\n%s", command, err, strings.TrimSpace(string(out)))
+ }
+ return &InstallResult{
+ SDKID: detection.SDKID,
+ Package: pkg,
+ Command: command,
+ Success: true,
+ }, nil
+}
+
+// dotnetProjectArg returns the extra arguments needed to point `dotnet add
+// package` at a project, or a reason the install cannot run unattended. A bare
+// `dotnet add package` only works when the working directory holds exactly one
+// project file, but detection also accepts a solution whose projects live in
+// subdirectories.
+func dotnetProjectArg(dir string) (args []string, reason string) {
+ if matches, _ := filepath.Glob(filepath.Join(dir, "*.csproj")); len(matches) == 1 {
+ return nil, ""
+ }
+ projects := csprojFiles(dir)
+ switch len(projects) {
+ case 0:
+ return nil, "no .csproj file found; add LaunchDarkly.ServerSdk to your project manually"
+ case 1:
+ rel, err := filepath.Rel(dir, projects[0])
+ if err != nil {
+ rel = projects[0]
+ }
+ return []string{"--project", rel}, ""
+ default:
+ // Picking one of several projects would add the SDK to an arbitrary
+ // assembly, so let the user say which.
+ return nil, fmt.Sprintf("found %d projects in this solution; run `dotnet add package LaunchDarkly.ServerSdk --project ` for the one that needs the SDK", len(projects))
+ }
+}
+
+func execRun(dir string, args []string) ([]byte, error) {
+ cmd := exec.Command(args[0], args[1:]...) //nolint:gosec
+ cmd.Dir = dir
+ return cmd.CombinedOutput()
+}
+
+// InstallArgs returns the command-line arguments and package name for installing the given SDK.
+// Returns nil args for SDKs that require manual installation (e.g. Java, Android, Swift).
+// packageManager is used for Node.js SDKs; for other runtimes the appropriate tool is chosen automatically.
+func InstallArgs(sdkID, packageManager string) (args []string, pkg string) {
+ switch sdkID {
+ case "react-client-sdk":
+ pkg = "launchdarkly-react-client-sdk"
+ return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg
+ case "react-native":
+ pkg = "@launchdarkly/react-native-client-sdk"
+ return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg
+ case "node-server":
+ pkg = "@launchdarkly/node-server-sdk"
+ return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg
+ case "js-client-sdk":
+ // The unscoped v3 package, whose initialize API the init template and the
+ // quickstart instructions both use. The scoped @launchdarkly/js-client-sdk is
+ // v4 and exposes createClient instead.
+ pkg = "launchdarkly-js-client-sdk"
+ return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg
+ case "python-server-sdk":
+ pkg = "launchdarkly-server-sdk"
+ return pythonInstallCmd(packageManager, pkg), pkg
+ case "go-server-sdk":
+ pkg = "github.com/launchdarkly/go-server-sdk/v7"
+ return []string{"go", "get", pkg}, pkg
+ case "ruby-server-sdk":
+ pkg = "launchdarkly-server-sdk"
+ // Bundler-managed projects need the gem recorded in the Gemfile; a bare
+ // `gem install` would succeed without making the SDK available to the app.
+ if packageManager == "bundle" {
+ return []string{"bundle", "add", pkg}, pkg
+ }
+ return []string{"gem", "install", pkg}, pkg
+ case "dotnet-server-sdk":
+ pkg = "LaunchDarkly.ServerSdk"
+ return []string{"dotnet", "add", "package", pkg}, pkg
+ // SDKs requiring manual installation — return a meaningful package identifier
+ // so callers can display what the user needs to add.
+ case "java-server-sdk":
+ return nil, "com.launchdarkly:launchdarkly-java-server-sdk"
+ case "android", "android-client-sdk":
+ return nil, "com.launchdarkly:launchdarkly-android-client-sdk"
+ case "swift-client-sdk", "ios-client-sdk":
+ return nil, "LaunchDarkly" // Swift Package Manager / CocoaPods
+ default:
+ return nil, sdkID
+ }
+}
+
+// pythonInstallCmd returns the install command arguments for a Python package
+// manager. Anything unrecognised — including the empty string, which IsInstalled
+// passes — falls back to pip.
+func pythonInstallCmd(pm, pkg string) []string {
+ switch pm {
+ case "poetry":
+ return []string{"poetry", "add", pkg}
+ case "uv":
+ return []string{"uv", "add", pkg}
+ case "pipenv":
+ return []string{"pipenv", "install", pkg}
+ default:
+ return []string{"pip", "install", pkg}
+ }
+}
+
+// nodeInstallCmd returns the install command arguments for a Node.js package manager.
+func nodeInstallCmd(pm, pkg string) []string {
+ switch pm {
+ case "yarn":
+ return []string{"yarn", "add", pkg}
+ case "pnpm":
+ return []string{"pnpm", "add", pkg}
+ case "bun":
+ return []string{"bun", "add", pkg}
+ default:
+ return []string{"npm", "install", pkg}
+ }
+}
+
+// resolveNodePM normalises the package manager name, defaulting to "npm".
+func resolveNodePM(pm string) string {
+ switch pm {
+ case "yarn", "pnpm", "bun":
+ return pm
+ default:
+ return "npm"
+ }
+}
+
+// IsInstalled reports whether the SDK is already a dependency of the project in
+// dir, by looking for its package identifier in the relevant manifest(s). Only
+// covers SDKs with an automated install command; returns false for manual SDKs
+// and unknowns.
+func IsInstalled(dir, sdkID string) bool {
+ _, pkg := InstallArgs(sdkID, "")
+ if pkg == "" {
+ return false
+ }
+
+ var manifests []string
+ switch sdkID {
+ case "react-client-sdk", "react-native", "node-server", "js-client-sdk":
+ manifests = []string{"package.json"}
+ case "go-server-sdk":
+ manifests = []string{"go.mod", "go.sum"}
+ case "python-server-sdk":
+ manifests = []string{"requirements.txt", "pyproject.toml", "setup.py", "Pipfile", "uv.lock"}
+ case "ruby-server-sdk":
+ manifests = []string{"Gemfile", "Gemfile.lock"}
+ case "dotnet-server-sdk":
+ for _, f := range csprojFiles(dir) {
+ if fileMentionsPackage(f, pkg) {
+ return true
+ }
+ }
+ return false
+ default:
+ return false
+ }
+
+ for _, mf := range manifests {
+ if fileMentionsPackage(filepath.Join(dir, mf), pkg) {
+ return true
+ }
+ }
+ return false
+}
+
+func fileMentionsPackage(path, pkg string) bool {
+ b, err := os.ReadFile(path)
+ return err == nil && mentionsPackage(string(b), pkg)
+}
+
+// mentionsPackage reports whether content names pkg as a whole dependency rather
+// than as the prefix of a longer name. A plain substring test treats
+// @launchdarkly/node-server-sdk-redis as proof that @launchdarkly/node-server-sdk
+// is installed, so setup skips installing the SDK the integration package needs.
+// Every manifest format delimits a dependency name with a quote, whitespace, or a
+// comparison operator, so requiring a non-name character on both sides works for
+// all of them without parsing each one.
+func mentionsPackage(content, pkg string) bool {
+ for i := 0; ; {
+ at := strings.Index(content[i:], pkg)
+ if at < 0 {
+ return false
+ }
+ at += i
+ end := at + len(pkg)
+ beforeOK := at == 0 || !isPackageNameChar(rune(content[at-1]))
+ afterOK := end == len(content) || !isPackageNameChar(rune(content[end]))
+ if beforeOK && afterOK {
+ return true
+ }
+ i = at + 1
+ }
+}
+
+// isPackageNameChar reports whether r can appear inside a package name, and so
+// whether it continues a name rather than terminating one.
+func isPackageNameChar(r rune) bool {
+ switch {
+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
+ return true
+ case r == '-', r == '_', r == '.', r == '/', r == '@':
+ return true
+ }
+ return false
+}
+
+// csprojFiles returns the project files to consider for a .NET project, preferring
+// those in dir. Detection accepts a solution with no project file beside it, so
+// fall back to searching for the projects the solution refers to.
+func csprojFiles(dir string) []string {
+ if matches, _ := filepath.Glob(filepath.Join(dir, "*.csproj")); len(matches) > 0 {
+ return matches
+ }
+ var found []string
+ _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return nil
+ }
+ if d.IsDir() {
+ // Build output holds copies of nothing useful and can be large.
+ if name := d.Name(); name == "bin" || name == "obj" || name == ".git" {
+ return fs.SkipDir
+ }
+ return nil
+ }
+ if strings.HasSuffix(d.Name(), ".csproj") {
+ found = append(found, path)
+ }
+ return nil
+ })
+ sort.Strings(found)
+ return found
+}
diff --git a/internal/setup/installer_test.go b/internal/setup/installer_test.go
new file mode 100644
index 00000000..085913fe
--- /dev/null
+++ b/internal/setup/installer_test.go
@@ -0,0 +1,374 @@
+package setup
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestInstallArgs_NodeSDKs(t *testing.T) {
+ tests := []struct {
+ sdkID string
+ pm string
+ wantCmd string
+ wantPkg string
+ }{
+ {"react-client-sdk", "npm", "npm", "launchdarkly-react-client-sdk"},
+ {"react-client-sdk", "yarn", "yarn", "launchdarkly-react-client-sdk"},
+ {"react-client-sdk", "pnpm", "pnpm", "launchdarkly-react-client-sdk"},
+ {"react-client-sdk", "bun", "bun", "launchdarkly-react-client-sdk"},
+ {"react-client-sdk", "", "npm", "launchdarkly-react-client-sdk"},
+ {"react-native", "npm", "npm", "@launchdarkly/react-native-client-sdk"},
+ {"react-native", "bun", "bun", "@launchdarkly/react-native-client-sdk"},
+ {"node-server", "npm", "npm", "@launchdarkly/node-server-sdk"},
+ {"node-server", "yarn", "yarn", "@launchdarkly/node-server-sdk"},
+ {"node-server", "pnpm", "pnpm", "@launchdarkly/node-server-sdk"},
+ {"node-server", "bun", "bun", "@launchdarkly/node-server-sdk"},
+ {"node-server", "", "npm", "@launchdarkly/node-server-sdk"},
+ {"js-client-sdk", "npm", "npm", "launchdarkly-js-client-sdk"},
+ {"js-client-sdk", "bun", "bun", "launchdarkly-js-client-sdk"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.sdkID+"/"+tt.pm, func(t *testing.T) {
+ args, pkg := InstallArgs(tt.sdkID, tt.pm)
+ require.NotEmpty(t, args)
+ assert.Equal(t, tt.wantCmd, args[0])
+ assert.Equal(t, tt.wantPkg, pkg)
+ assert.Contains(t, args, pkg)
+ })
+ }
+}
+
+func TestInstallArgs_Python(t *testing.T) {
+ tests := []struct {
+ packageManager string
+ want []string
+ }{
+ // IsInstalled calls InstallArgs with no package manager.
+ {"", []string{"pip", "install", "launchdarkly-server-sdk"}},
+ {"pip", []string{"pip", "install", "launchdarkly-server-sdk"}},
+ {"poetry", []string{"poetry", "add", "launchdarkly-server-sdk"}},
+ {"uv", []string{"uv", "add", "launchdarkly-server-sdk"}},
+ {"pipenv", []string{"pipenv", "install", "launchdarkly-server-sdk"}},
+ // Unrecognised values fall back to pip rather than being run as a command.
+ {"conda", []string{"pip", "install", "launchdarkly-server-sdk"}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.packageManager, func(t *testing.T) {
+ args, pkg := InstallArgs("python-server-sdk", tt.packageManager)
+ assert.Equal(t, tt.want, args)
+ assert.Equal(t, "launchdarkly-server-sdk", pkg)
+ })
+ }
+}
+
+func TestInstallArgs_Go(t *testing.T) {
+ args, pkg := InstallArgs("go-server-sdk", "")
+ require.NotEmpty(t, args)
+ assert.Equal(t, "go", args[0])
+ assert.Equal(t, "get", args[1])
+ assert.Equal(t, "github.com/launchdarkly/go-server-sdk/v7", pkg)
+}
+
+func TestInstallArgs_Ruby(t *testing.T) {
+ args, pkg := InstallArgs("ruby-server-sdk", "")
+ require.NotEmpty(t, args)
+ assert.Equal(t, "gem", args[0])
+ assert.Equal(t, "launchdarkly-server-sdk", pkg)
+}
+
+// A Gemfile means Bundler owns the project's gems, so the SDK must be added to the
+// Gemfile; `gem install` would leave the app unable to require it under bundler.
+func TestInstallArgs_Ruby_Bundler(t *testing.T) {
+ args, pkg := InstallArgs("ruby-server-sdk", "bundle")
+ assert.Equal(t, []string{"bundle", "add", "launchdarkly-server-sdk"}, args)
+ assert.Equal(t, "launchdarkly-server-sdk", pkg)
+}
+
+func TestInstallArgs_Android_BothSpellings(t *testing.T) {
+ for _, id := range []string{"android", "android-client-sdk"} {
+ args, pkg := InstallArgs(id, "gradle")
+ assert.Nil(t, args, "Android has no automated install command")
+ assert.Equal(t, "com.launchdarkly:launchdarkly-android-client-sdk", pkg)
+ assert.True(t, RequiresManualInstall(id))
+ }
+}
+
+func TestInstallArgs_Dotnet(t *testing.T) {
+ args, pkg := InstallArgs("dotnet-server-sdk", "")
+ require.NotEmpty(t, args)
+ assert.Equal(t, "dotnet", args[0])
+ assert.Equal(t, "LaunchDarkly.ServerSdk", pkg)
+}
+
+func TestInstallArgs_ManualSDKs(t *testing.T) {
+ tests := []struct {
+ sdkID string
+ wantPkg string
+ }{
+ {"java-server-sdk", "com.launchdarkly:launchdarkly-java-server-sdk"},
+ {"android", "com.launchdarkly:launchdarkly-android-client-sdk"},
+ {"android-client-sdk", "com.launchdarkly:launchdarkly-android-client-sdk"},
+ {"swift-client-sdk", "LaunchDarkly"},
+ {"ios-client-sdk", "LaunchDarkly"},
+ {"unknown-sdk-xyz", "unknown-sdk-xyz"}, // unknown falls back to SDK ID
+ }
+ for _, tt := range tests {
+ t.Run(tt.sdkID, func(t *testing.T) {
+ args, pkg := InstallArgs(tt.sdkID, "")
+ assert.Nil(t, args, "expected nil args for manual SDK %s", tt.sdkID)
+ assert.Equal(t, tt.wantPkg, pkg)
+ })
+ }
+}
+
+func TestPackageInstaller_Install_Success(t *testing.T) {
+ var capturedDir string
+ var capturedArgs []string
+
+ installer := PackageInstaller{
+ run: func(dir string, args []string) ([]byte, error) {
+ capturedDir = dir
+ capturedArgs = args
+ return []byte("added 1 package"), nil
+ },
+ }
+
+ result, err := installer.Install("/my/project", &DetectResult{
+ SDKID: "node-server",
+ PackageManager: "npm",
+ })
+
+ require.NoError(t, err)
+ assert.True(t, result.Success)
+ assert.Equal(t, "node-server", result.SDKID)
+ assert.Equal(t, "@launchdarkly/node-server-sdk", result.Package)
+ assert.Equal(t, "npm install @launchdarkly/node-server-sdk", result.Command)
+ assert.Equal(t, "/my/project", capturedDir)
+ assert.Equal(t, []string{"npm", "install", "@launchdarkly/node-server-sdk"}, capturedArgs)
+}
+
+func TestPackageInstaller_Install_CommandFailure(t *testing.T) {
+ installer := PackageInstaller{
+ run: func(dir string, args []string) ([]byte, error) {
+ return []byte("npm ERR! not found"), errors.New("exit status 1")
+ },
+ }
+
+ _, err := installer.Install("/tmp", &DetectResult{
+ SDKID: "node-server",
+ PackageManager: "npm",
+ })
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "npm install @launchdarkly/node-server-sdk")
+ assert.Contains(t, err.Error(), "npm ERR! not found")
+}
+
+func TestPackageInstaller_Install_ManualSDK_ReturnsNoError(t *testing.T) {
+ installer := PackageInstaller{}
+
+ result, err := installer.Install("/tmp", &DetectResult{SDKID: "java-server-sdk"})
+
+ require.NoError(t, err)
+ assert.False(t, result.Success)
+ assert.Equal(t, "java-server-sdk", result.SDKID)
+ assert.Empty(t, result.Command)
+}
+
+func TestPackageInstaller_Install_AlreadyInstalled_SkipsCommand(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(
+ filepath.Join(dir, "package.json"),
+ []byte(`{"dependencies":{"@launchdarkly/node-server-sdk":"^9.0.0"}}`), 0644))
+
+ installer := PackageInstaller{
+ run: func(_ string, _ []string) ([]byte, error) {
+ t.Fatal("package manager must not run when the SDK is already installed")
+ return nil, nil
+ },
+ }
+
+ result, err := installer.Install(dir, &DetectResult{SDKID: "node-server", PackageManager: "npm"})
+
+ require.NoError(t, err)
+ assert.True(t, result.AlreadyInstalled)
+ assert.True(t, result.Success)
+ assert.Empty(t, result.Command)
+}
+
+func TestRequiresManualInstall(t *testing.T) {
+ assert.True(t, RequiresManualInstall("java-server-sdk"))
+ assert.True(t, RequiresManualInstall("swift-client-sdk"))
+ assert.False(t, RequiresManualInstall("node-server"))
+ assert.False(t, RequiresManualInstall("ruby-server-sdk"))
+}
+
+func TestPackageInstaller_Install_UnknownSDK_ReturnsError(t *testing.T) {
+ installer := PackageInstaller{}
+
+ _, err := installer.Install("/tmp", &DetectResult{SDKID: "totally-unknown-sdk"})
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unknown SDK")
+}
+
+func TestPackageInstaller_Install_DefaultRunner_UsedWhenNil(t *testing.T) {
+ // PackageInstaller{} (zero value) should not panic — it uses execRun.
+ // We test this by using a manual SDK so no real command is executed.
+ installer := PackageInstaller{}
+
+ result, err := installer.Install("/tmp", &DetectResult{SDKID: "android"})
+
+ require.NoError(t, err)
+ assert.False(t, result.Success)
+}
+
+// A related package that starts with the SDK's name is not the SDK. Treating it as
+// installed skips the install and leaves the integration package without the SDK
+// it depends on.
+func TestIsInstalled_RelatedPackageIsNotTheSDK(t *testing.T) {
+ tests := []struct {
+ name string
+ manifest string
+ content string
+ sdkID string
+ want bool
+ }{
+ {"node redis integration only", "package.json",
+ `{"dependencies":{"@launchdarkly/node-server-sdk-redis":"^4.0.0"}}`, "node-server", false},
+ {"node sdk present", "package.json",
+ `{"dependencies":{"@launchdarkly/node-server-sdk":"^9.13.0"}}`, "node-server", true},
+ {"node sdk alongside integration", "package.json",
+ `{"dependencies":{"@launchdarkly/node-server-sdk":"^9.13.0","@launchdarkly/node-server-sdk-redis":"^4.0.0"}}`, "node-server", true},
+ {"python otel plugin only", "requirements.txt",
+ "launchdarkly-server-sdk-otel==1.0.0\n", "python-server-sdk", false},
+ {"python sdk pinned", "requirements.txt",
+ "launchdarkly-server-sdk==9.16.1\n", "python-server-sdk", true},
+ {"ruby sdk in gemfile", "Gemfile",
+ "gem 'launchdarkly-server-sdk', '~> 8.14'\n", "ruby-server-sdk", true},
+ {"ruby related gem only", "Gemfile",
+ "gem 'launchdarkly-server-sdk-redis-store'\n", "ruby-server-sdk", false},
+ {"go module in go.mod", "go.mod",
+ "require github.com/launchdarkly/go-server-sdk/v7 v7.15.5\n", "go-server-sdk", true},
+ {"go sdk name as a prefix", "go.mod",
+ "require github.com/launchdarkly/go-server-sdk/v7-fork v1.0.0\n", "go-server-sdk", false},
+ {"dotnet telemetry package only", "App.csproj",
+ ``, "dotnet-server-sdk", false},
+ {"dotnet sdk present", "App.csproj",
+ ``, "dotnet-server-sdk", true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, tt.manifest), []byte(tt.content), 0600))
+
+ assert.Equal(t, tt.want, IsInstalled(dir, tt.sdkID))
+ })
+ }
+}
+
+// Detection accepts a solution with no project file beside it, so the install has
+// to find the project the solution refers to.
+func TestIsInstalled_Dotnet_FindsNestedProject(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "MyApp.sln"), []byte(""), 0600))
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "src/MyApp"), 0755))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "src/MyApp/MyApp.csproj"),
+ []byte(``), 0600))
+
+ assert.True(t, IsInstalled(dir, "dotnet-server-sdk"))
+}
+
+func TestInstall_Dotnet_SolutionLayout_TargetsTheProject(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "MyApp.sln"), []byte(""), 0600))
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "src/MyApp"), 0755))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "src/MyApp/MyApp.csproj"), []byte(""), 0600))
+
+ var got []string
+ installer := PackageInstaller{run: func(_ string, args []string) ([]byte, error) {
+ got = args
+ return nil, nil
+ }}
+
+ result, err := installer.Install(dir, &DetectResult{SDKID: "dotnet-server-sdk", PackageManager: "dotnet"})
+
+ require.NoError(t, err)
+ assert.True(t, result.Success)
+ // A bare `dotnet add package` fails when the working directory holds no project.
+ assert.Equal(t, []string{"dotnet", "add", "package", "LaunchDarkly.ServerSdk",
+ "--project", filepath.Join("src", "MyApp", "MyApp.csproj")}, got)
+}
+
+func TestInstall_Dotnet_SingleRootProject_RunsBareCommand(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "MyApp.csproj"), []byte(""), 0600))
+
+ var got []string
+ installer := PackageInstaller{run: func(_ string, args []string) ([]byte, error) {
+ got = args
+ return nil, nil
+ }}
+
+ _, err := installer.Install(dir, &DetectResult{SDKID: "dotnet-server-sdk", PackageManager: "dotnet"})
+
+ require.NoError(t, err)
+ assert.Equal(t, []string{"dotnet", "add", "package", "LaunchDarkly.ServerSdk"}, got)
+}
+
+// Adding the SDK to an arbitrary assembly is worse than saying which projects exist.
+func TestInstall_Dotnet_SeveralProjects_ReportsWhyItStopped(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "MyApp.sln"), []byte(""), 0600))
+ for _, p := range []string{"src/Api/Api.csproj", "src/Worker/Worker.csproj"} {
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, filepath.Dir(p)), 0755))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, p), []byte(""), 0600))
+ }
+
+ ran := false
+ installer := PackageInstaller{run: func(_ string, _ []string) ([]byte, error) {
+ ran = true
+ return nil, nil
+ }}
+
+ result, err := installer.Install(dir, &DetectResult{SDKID: "dotnet-server-sdk", PackageManager: "dotnet"})
+
+ require.NoError(t, err)
+ assert.False(t, ran, "a command that cannot succeed must not run")
+ assert.True(t, result.Failed)
+ assert.False(t, result.Success)
+ assert.Contains(t, result.FailureReason, "--project")
+ assert.Equal(t, "LaunchDarkly.ServerSdk", result.Package)
+}
+
+func TestInstall_Dotnet_NoProjectAtAll_ReportsWhyItStopped(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "MyApp.sln"), []byte(""), 0600))
+
+ result, err := PackageInstaller{run: func(_ string, _ []string) ([]byte, error) {
+ t.Fatal("install must not run without a project")
+ return nil, nil
+ }}.Install(dir, &DetectResult{SDKID: "dotnet-server-sdk", PackageManager: "dotnet"})
+
+ require.NoError(t, err)
+ assert.True(t, result.Failed)
+ assert.Contains(t, result.FailureReason, "no .csproj")
+}
+
+// Build output can hold copies of project files and is large enough to matter.
+func TestCsprojFiles_SkipsBuildOutput(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "MyApp.sln"), []byte(""), 0600))
+ for _, p := range []string{"src/MyApp/MyApp.csproj", "src/MyApp/obj/Copy.csproj", "bin/Debug/Stale.csproj"} {
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, filepath.Dir(p)), 0755))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, p), []byte(""), 0600))
+ }
+
+ assert.Equal(t, []string{filepath.Join(dir, "src/MyApp/MyApp.csproj")}, csprojFiles(dir))
+}