From 3a1d4dd79475fa7a5e8c1d21facd174606dc1013 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Tue, 21 Jul 2026 16:39:04 -0400 Subject: [PATCH 01/10] chore(setup): add SDK/project detection library Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/setup/detector.go | 351 ++++++++++++++++++++++++++ internal/setup/detector_ruby_test.go | 33 +++ internal/setup/detector_test.go | 357 +++++++++++++++++++++++++++ 3 files changed, 741 insertions(+) create mode 100644 internal/setup/detector.go create mode 100644 internal/setup/detector_ruby_test.go create mode 100644 internal/setup/detector_test.go diff --git a/internal/setup/detector.go b/internal/setup/detector.go new file mode 100644 index 00000000..cf201c8a --- /dev/null +++ b/internal/setup/detector.go @@ -0,0 +1,351 @@ +package setup + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) + +// 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"` +} + +// 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. +func (FileDetector) Detect(dir string) (*DetectResult, error) { + if result := detectNode(dir); result != nil { + return result, nil + } + if result := detectGo(dir); result != nil { + return result, nil + } + if result := detectPython(dir); result != nil { + return result, nil + } + if result := detectRuby(dir); result != nil { + return result, nil + } + if result := detectJava(dir); result != nil { + return result, nil + } + if result := detectSwift(dir); result != nil { + return result, nil + } + if result := detectDotnet(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 { + return &DetectResult{ + Language: "JavaScript", + Framework: "Next.js", + PackageManager: pm, + SDKID: "node-server", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "src/index.ts", "src/index.js", + "pages/index.tsx", "pages/index.ts", "pages/index.js", + "index.js", + })), + } + } + + if _, ok := allDeps["react-native"]; ok { + return &DetectResult{ + Language: "JavaScript", + Framework: "React Native", + PackageManager: pm, + SDKID: "react-native", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "src/App.tsx", "src/App.jsx", "src/App.js", + "src/index.tsx", "src/index.jsx", "src/index.js", + "index.js", + })), + } + } + if _, ok := allDeps["react"]; ok { + return &DetectResult{ + Language: "JavaScript", + Framework: "React", + PackageManager: pm, + SDKID: "react-client-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "src/App.tsx", "src/App.jsx", "src/App.js", + "src/index.tsx", "src/index.jsx", "src/index.js", + "index.js", + })), + } + } + 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 { + return &DetectResult{ + Language: "JavaScript", + Framework: fw.framework, + PackageManager: pm, + SDKID: "js-client-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "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", + PackageManager: pm, + SDKID: "node-server", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "src/index.ts", "src/index.js", + "index.ts", "index.js", + "server.ts", "server.js", + "app.ts", "app.js", + })), + } +} + +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" + } + if _, err := os.Stat(filepath.Join(dir, "bun.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 + } + return &DetectResult{ + Language: "Go", + PackageManager: "go", + SDKID: "go-server-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{"cmd/main.go", "main.go"})), + } +} + +func detectPython(dir string) *DetectResult { + for _, indicator := range []string{"requirements.txt", "pyproject.toml", "setup.py"} { + if _, err := os.Stat(filepath.Join(dir, indicator)); err == nil { + return &DetectResult{ + Language: "Python", + PackageManager: "pip", + SDKID: "python-server-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "src/main.py", "manage.py", "app.py", "main.py", + })), + } + } + } + return nil +} + +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 + } + } + return &DetectResult{ + Language: "Ruby", + PackageManager: "gem", + SDKID: "ruby-server-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "config.ru", "app.rb", "main.rb", + })), + } +} + +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" + } + // Android projects use Gradle but are distinguished by AndroidManifest.xml. + for _, manifest := range []string{ + "app/src/main/AndroidManifest.xml", + "src/main/AndroidManifest.xml", + } { + if _, err := os.Stat(filepath.Join(dir, manifest)); err == nil { + return &DetectResult{ + Language: "Java", + PackageManager: "gradle", + SDKID: "android-client-sdk", + EntryPoint: filepath.Join(dir, "app/src/main/java/MainActivity.java"), + } + } + } + return &DetectResult{ + Language: "Java", + PackageManager: pm, + SDKID: "java-server-sdk", + EntryPoint: filepath.Join(dir, "src/main/java/Main.java"), + } + } + } + return nil +} + +func detectSwift(dir string) *DetectResult { + pm := "spm" + if _, err := os.Stat(filepath.Join(dir, "Podfile")); err == nil { + pm = "cocoapods" + } + indicators := []string{"Package.swift", "Podfile"} + for _, f := range indicators { + if _, err := os.Stat(filepath.Join(dir, f)); err == nil { + return &DetectResult{ + Language: "Swift", + PackageManager: pm, + SDKID: "swift-client-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "Sources/main.swift", "App.swift", "ContentView.swift", "AppDelegate.swift", + })), + } + } + } + matches, _ := filepath.Glob(filepath.Join(dir, "*.xcodeproj")) + if len(matches) > 0 { + return &DetectResult{ + Language: "Swift", + PackageManager: pm, + SDKID: "swift-client-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "Sources/main.swift", "App.swift", "ContentView.swift", "AppDelegate.swift", + })), + } + } + return nil +} + +func detectDotnet(dir string) *DetectResult { + for _, pattern := range []string{"*.csproj", "*.sln"} { + matches, _ := filepath.Glob(filepath.Join(dir, pattern)) + if len(matches) > 0 { + return &DetectResult{ + Language: "C#", + PackageManager: "dotnet", + SDKID: "dotnet-server-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "Program.cs", "Startup.cs", "src/Program.cs", + })), + } + } + } + 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-client-sdk", 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"}, +} + +// firstExistingIn returns the first candidate that exists as a file in dir, +// or the last candidate if none exist (as a suggested path). +// Returns an empty string if candidates is empty. +func firstExistingIn(dir string, candidates []string) string { + if len(candidates) == 0 { + return "" + } + for _, c := range candidates { + if _, err := os.Stat(filepath.Join(dir, c)); err == nil { + return c + } + } + return candidates[len(candidates)-1] +} diff --git a/internal/setup/detector_ruby_test.go b/internal/setup/detector_ruby_test.go new file mode 100644 index 00000000..9dadbbc4 --- /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, "gem", 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_test.go b/internal/setup/detector_test.go new file mode 100644 index 00000000..ce8945ea --- /dev/null +++ b/internal/setup/detector_test.go @@ -0,0 +1,357 @@ +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) +} + +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) +} + +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) +} + +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) +} + +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-client-sdk", 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-client-sdk", 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) +} + +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) + // Falls back to last candidate + assert.NotEmpty(t, result.EntryPoint) +} + +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") +} + +func TestFirstExistingIn_EmptySlice_ReturnsEmpty(t *testing.T) { + result := firstExistingIn(t.TempDir(), []string{}) + assert.Empty(t, result) +} + +func TestFirstExistingIn_NoMatch_ReturnLastCandidate(t *testing.T) { + dir := t.TempDir() + result := firstExistingIn(dir, []string{"nonexistent.go", "also-nonexistent.go"}) + assert.Equal(t, "also-nonexistent.go", result) +} + +func TestFirstExistingIn_MatchesFirst(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "second.go", "") + writeDetectFile(t, dir, "first.go", "") + result := firstExistingIn(dir, []string{"first.go", "second.go"}) + assert.Equal(t, "first.go", result) +} From 9efecc77c2fe1774c59264be37e2f999b7096441 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Thu, 30 Jul 2026 23:47:34 -0400 Subject: [PATCH 02/10] fix(setup): report whether the detected entry point exists DetectResult carries EntryPointExists so callers can tell an entry file the detector found from one it merely suggests, and never write initialization code into a path the project does not load. PackageManager names the tool that manages the project's dependencies: bundle rather than gem when a Gemfile is present, and poetry, uv or pipenv rather than always pip. Locate MainActivity and Main under their real package directory rather than assuming an unqualified class name, derive the Android source root from whichever manifest matched, find the Swift entry point where SwiftPM and Xcode nest it, look for src/main.tsx where Vite mounts a React app, and recognise bun.lockb. Rename the Android SDK ID to android for consistency with the other IDs. Co-Authored-By: Claude Opus 5 (1M context) --- internal/setup/detector.go | 379 ++++++++++++++++++-------- internal/setup/detector_ruby_test.go | 2 +- internal/setup/detector_test.go | 390 ++++++++++++++++++++++++++- 3 files changed, 652 insertions(+), 119 deletions(-) diff --git a/internal/setup/detector.go b/internal/setup/detector.go index cf201c8a..cb5d85fe 100644 --- a/internal/setup/detector.go +++ b/internal/setup/detector.go @@ -1,10 +1,13 @@ 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. @@ -14,6 +17,10 @@ type DetectResult struct { 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, @@ -91,43 +98,59 @@ func detectNode(dir string) *DetectResult { // 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 { + // instrumentation.ts is Next's server-startup hook, which runs once before + // any request and is the only entry file that suits a server SDK in both + // the App Router and the Pages Router. It is also what we create when the + // project has no suitable file yet. + ep, exists := entryPoint(dir, "instrumentation.ts", + "instrumentation.ts", "instrumentation.js", + "src/instrumentation.ts", "src/instrumentation.js", + "app/page.tsx", "src/app/page.tsx", + "pages/index.tsx", "pages/index.ts", "pages/index.js", + "src/index.ts", "src/index.js", + ) return &DetectResult{ - Language: "JavaScript", - Framework: "Next.js", - PackageManager: pm, - SDKID: "node-server", - EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ - "src/index.ts", "src/index.js", - "pages/index.tsx", "pages/index.ts", "pages/index.js", - "index.js", - })), + Language: "JavaScript", + Framework: "Next.js", + PackageManager: pm, + SDKID: "node-server", + EntryPoint: ep, + EntryPointExists: exists, } } if _, ok := allDeps["react-native"]; ok { + 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: filepath.Join(dir, firstExistingIn(dir, []string{ - "src/App.tsx", "src/App.jsx", "src/App.js", - "src/index.tsx", "src/index.jsx", "src/index.js", - "index.js", - })), + Language: "JavaScript", + Framework: "React Native", + PackageManager: pm, + SDKID: "react-native", + EntryPoint: ep, + EntryPointExists: exists, } } if _, ok := allDeps["react"]; ok { + // src/main.tsx is where Vite mounts the app and src/index.tsx is where + // Create React App does; either is a better home for the provider than a + // component file, but App.tsx works and is the more familiar edit. + 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: filepath.Join(dir, firstExistingIn(dir, []string{ - "src/App.tsx", "src/App.jsx", "src/App.js", - "src/index.tsx", "src/index.jsx", "src/index.js", - "index.js", - })), + Language: "JavaScript", + Framework: "React", + PackageManager: pm, + SDKID: "react-client-sdk", + EntryPoint: ep, + EntryPointExists: exists, } } jsClientFrameworks := []struct{ dep, framework string }{ @@ -140,30 +163,34 @@ func detectNode(dir string) *DetectResult { } for _, fw := range jsClientFrameworks { if _, ok := allDeps[fw.dep]; ok { + 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: filepath.Join(dir, firstExistingIn(dir, []string{ - "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", - })), + Language: "JavaScript", + Framework: fw.framework, + PackageManager: pm, + SDKID: "js-client-sdk", + EntryPoint: ep, + EntryPointExists: exists, } } } + ep, exists := entryPoint(dir, "index.js", + "src/index.ts", "src/index.js", + "index.ts", "index.js", + "server.ts", "server.js", + "app.ts", "app.js", + ) return &DetectResult{ - Language: "JavaScript", - PackageManager: pm, - SDKID: "node-server", - EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ - "src/index.ts", "src/index.js", - "index.ts", "index.js", - "server.ts", "server.js", - "app.ts", "app.js", - })), + Language: "JavaScript", + PackageManager: pm, + SDKID: "node-server", + EntryPoint: ep, + EntryPointExists: exists, } } @@ -174,8 +201,11 @@ func detectNodePM(dir string) string { if _, err := os.Stat(filepath.Join(dir, "yarn.lock")); err == nil { return "yarn" } - if _, err := os.Stat(filepath.Join(dir, "bun.lock")); err == nil { - return "bun" + // bun.lock is the text lockfile from Bun 1.2 onwards; bun.lockb is the older binary one. + for _, lock := range []string{"bun.lock", "bun.lockb"} { + if _, err := os.Stat(filepath.Join(dir, lock)); err == nil { + return "bun" + } } return "npm" } @@ -184,30 +214,55 @@ func detectGo(dir string) *DetectResult { if _, err := os.Stat(filepath.Join(dir, "go.mod")); err != nil { return nil } + ep, exists := entryPoint(dir, "main.go", "main.go", "cmd/main.go") return &DetectResult{ - Language: "Go", - PackageManager: "go", - SDKID: "go-server-sdk", - EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{"cmd/main.go", "main.go"})), + 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"} { + for _, indicator := range []string{"requirements.txt", "pyproject.toml", "setup.py", "Pipfile"} { if _, err := os.Stat(filepath.Join(dir, indicator)); err == nil { + ep, exists := entryPoint(dir, "main.py", + "src/main.py", "manage.py", "app.py", "main.py", + ) return &DetectResult{ - Language: "Python", - PackageManager: "pip", - SDKID: "python-server-sdk", - EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ - "src/main.py", "manage.py", "app.py", "main.py", - })), + 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. +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"} { @@ -221,13 +276,19 @@ func detectRuby(dir string) *DetectResult { return nil } } + // A Gemfile means Bundler manages the project's gems, so the SDK has to be + // added to the Gemfile rather than installed into the global gem set. + pm := "gem" + if _, err := os.Stat(filepath.Join(dir, "Gemfile")); err == nil { + pm = "bundle" + } + ep, exists := entryPoint(dir, "main.rb", "config.ru", "app.rb", "main.rb") return &DetectResult{ - Language: "Ruby", - PackageManager: "gem", - SDKID: "ruby-server-sdk", - EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ - "config.ru", "app.rb", "main.rb", - })), + Language: "Ruby", + PackageManager: pm, + SDKID: "ruby-server-sdk", + EntryPoint: ep, + EntryPointExists: exists, } } @@ -243,20 +304,34 @@ func detectJava(dir string) *DetectResult { "app/src/main/AndroidManifest.xml", "src/main/AndroidManifest.xml", } { - if _, err := os.Stat(filepath.Join(dir, manifest)); err == nil { - return &DetectResult{ - Language: "Java", - PackageManager: "gradle", - SDKID: "android-client-sdk", - EntryPoint: filepath.Join(dir, "app/src/main/java/MainActivity.java"), - } + if _, err := os.Stat(filepath.Join(dir, manifest)); err != nil { + continue + } + // The manifest tells us which source root this project uses; the + // activity itself 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, } } + 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: filepath.Join(dir, "src/main/java/Main.java"), + Language: "Java", + PackageManager: pm, + SDKID: "java-server-sdk", + EntryPoint: ep, + EntryPointExists: exists, } } } @@ -268,44 +343,99 @@ func detectSwift(dir string) *DetectResult { 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: filepath.Join(dir, firstExistingIn(dir, []string{ - "Sources/main.swift", "App.swift", "ContentView.swift", "AppDelegate.swift", - })), + Language: "Swift", + PackageManager: pm, + SDKID: "swift-client-sdk", + EntryPoint: ep, + EntryPointExists: exists, } } } - matches, _ := filepath.Glob(filepath.Join(dir, "*.xcodeproj")) - if len(matches) > 0 { + if appRoot := xcodeAppRoot(dir); appRoot != "" { + ep, exists := swiftEntryPoint(appRoot) return &DetectResult{ - Language: "Swift", - PackageManager: pm, - SDKID: "swift-client-sdk", - EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ - "Sources/main.swift", "App.swift", "ContentView.swift", "AppDelegate.swift", - })), + 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. +func swiftEntryCandidates(dir, appRoot string) []string { + candidates := []string{ + "App.swift", "ContentView.swift", "AppDelegate.swift", + findFileUnder(dir, appRoot, "*App.swift", "ContentView.swift", "AppDelegate.swift"), + findFileUnder(dir, "Sources", "main.swift", "*App.swift"), + } + if target := soleSubdir(dir, "Sources"); target != "" { + candidates = append(candidates, + findFileUnder(dir, target, filepath.Base(target)+".swift"), + findFileUnder(dir, target, "*.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. +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 { + ep, exists := entryPoint(dir, "Program.cs", + "Program.cs", "Startup.cs", "src/Program.cs", + ) return &DetectResult{ - Language: "C#", - PackageManager: "dotnet", - SDKID: "dotnet-server-sdk", - EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ - "Program.cs", "Startup.cs", "src/Program.cs", - })), + Language: "C#", + PackageManager: "dotnet", + SDKID: "dotnet-server-sdk", + EntryPoint: ep, + EntryPointExists: exists, } } } @@ -329,23 +459,62 @@ var KnownSDKs = []SDKOption{ {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-client-sdk", Language: "Java", Name: "Android"}, + {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"}, } -// firstExistingIn returns the first candidate that exists as a file in dir, -// or the last candidate if none exist (as a suggested path). -// Returns an empty string if candidates is empty. -func firstExistingIn(dir string, candidates []string) string { - if len(candidates) == 0 { +// 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 "" } - for _, c := range candidates { - if _, err := os.Stat(filepath.Join(dir, c)); err == nil { - return c + 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 candidates[len(candidates)-1] + return "" } diff --git a/internal/setup/detector_ruby_test.go b/internal/setup/detector_ruby_test.go index 9dadbbc4..a38349c6 100644 --- a/internal/setup/detector_ruby_test.go +++ b/internal/setup/detector_ruby_test.go @@ -18,7 +18,7 @@ func TestFileDetector_DetectsRuby_Gemfile(t *testing.T) { require.NoError(t, err) assert.Equal(t, "ruby-server-sdk", result.SDKID) assert.Equal(t, "Ruby", result.Language) - assert.Equal(t, "gem", result.PackageManager) + assert.Equal(t, "bundle", result.PackageManager) assert.Equal(t, filepath.Join(dir, "app.rb"), result.EntryPoint) } diff --git a/internal/setup/detector_test.go b/internal/setup/detector_test.go index ce8945ea..42e25f37 100644 --- a/internal/setup/detector_test.go +++ b/internal/setup/detector_test.go @@ -30,6 +30,7 @@ func TestFileDetector_DetectsReact(t *testing.T) { 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) { @@ -70,6 +71,7 @@ func TestFileDetector_DetectsNodeJs(t *testing.T) { 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) { @@ -84,6 +86,7 @@ func TestFileDetector_DetectsGo(t *testing.T) { 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) { @@ -98,6 +101,7 @@ func TestFileDetector_DetectsPython_RequirementsTxt(t *testing.T) { 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) { @@ -141,7 +145,7 @@ func TestFileDetector_DetectsAndroid_BuildGradle(t *testing.T) { result, err := FileDetector{}.Detect(dir) require.NoError(t, err) - assert.Equal(t, "android-client-sdk", result.SDKID) + assert.Equal(t, "android", result.SDKID) assert.Equal(t, "Java", result.Language) assert.Equal(t, "gradle", result.PackageManager) } @@ -154,7 +158,7 @@ func TestFileDetector_DetectsAndroid_KotlinDsl(t *testing.T) { result, err := FileDetector{}.Detect(dir) require.NoError(t, err) - assert.Equal(t, "android-client-sdk", result.SDKID) + assert.Equal(t, "android", result.SDKID) assert.Equal(t, "gradle", result.PackageManager) } @@ -284,6 +288,7 @@ func TestFileDetector_DetectsDotnet_Csproj(t *testing.T) { 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) { @@ -321,8 +326,8 @@ func TestFileDetector_EntryPointFallback_WhenNoneExist(t *testing.T) { result, err := FileDetector{}.Detect(dir) require.NoError(t, err) - // Falls back to last candidate - assert.NotEmpty(t, result.EntryPoint) + 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) { @@ -337,21 +342,380 @@ func TestFileDetector_MalformedPackageJSON_FallsThrough(t *testing.T) { assert.Contains(t, err.Error(), "could not detect") } -func TestFirstExistingIn_EmptySlice_ReturnsEmpty(t *testing.T) { - result := firstExistingIn(t.TempDir(), []string{}) - assert.Empty(t, result) +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) + // An App Router project has no pages/ or src/index, so the old candidate list + // fell through to a nonexistent index.js at the repo root. + assert.Equal(t, filepath.Join(dir, "app/page.tsx"), result.EntryPoint) + assert.True(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(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) + assert.Equal(t, filepath.Join(dir, "pages/index.tsx"), result.EntryPoint) + assert.True(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 TestFirstExistingIn_NoMatch_ReturnLastCandidate(t *testing.T) { +func TestEntryPoint_NoCandidateExists_ReturnsFallback(t *testing.T) { dir := t.TempDir() - result := firstExistingIn(dir, []string{"nonexistent.go", "also-nonexistent.go"}) - assert.Equal(t, "also-nonexistent.go", result) + + 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 TestFirstExistingIn_MatchesFirst(t *testing.T) { +func TestEntryPoint_MatchesFirstExisting(t *testing.T) { dir := t.TempDir() writeDetectFile(t, dir, "second.go", "") writeDetectFile(t, dir, "first.go", "") - result := firstExistingIn(dir, []string{"first.go", "second.go"}) - assert.Equal(t, "first.go", result) + + 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")) } From 225934961a9d1acfa84cf32fea2b7868d9df0845 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Fri, 31 Jul 2026 13:57:33 -0400 Subject: [PATCH 03/10] fix(setup): keep the Next.js SDK key out of the browser bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next.js detection targeted whichever page module happened to exist, and node-server is append-safe, so setup wrote server SDK init — including the SDK key — into app/page.tsx or pages/index.tsx. A page module may carry 'use client' or be imported by something that does, which bundles it for the browser, and nothing in the detector can tell which. Only instrumentation.ts, Next's server-startup hook, is guaranteed to stay server-side, so suggest creating it rather than picking a page. Co-Authored-By: Claude Opus 5 (1M context) --- internal/setup/detector.go | 12 +++++------- internal/setup/detector_test.go | 15 ++++++++------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/internal/setup/detector.go b/internal/setup/detector.go index cb5d85fe..6f9352f4 100644 --- a/internal/setup/detector.go +++ b/internal/setup/detector.go @@ -98,16 +98,14 @@ func detectNode(dir string) *DetectResult { // 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 { - // instrumentation.ts is Next's server-startup hook, which runs once before - // any request and is the only entry file that suits a server SDK in both - // the App Router and the Pages Router. It is also what we create when the - // project has no suitable file yet. + // Only instrumentation.ts, Next's server-startup hook, is guaranteed to stay + // out of the browser bundle. A page or route module may carry 'use client' or + // be imported by something that does, which would ship the server SDK key to + // the browser, and nothing here can tell which. Suggest creating the hook + // rather than picking a page that happens to exist. ep, exists := entryPoint(dir, "instrumentation.ts", "instrumentation.ts", "instrumentation.js", "src/instrumentation.ts", "src/instrumentation.js", - "app/page.tsx", "src/app/page.tsx", - "pages/index.tsx", "pages/index.ts", "pages/index.js", - "src/index.ts", "src/index.js", ) return &DetectResult{ Language: "JavaScript", diff --git a/internal/setup/detector_test.go b/internal/setup/detector_test.go index 42e25f37..06178c90 100644 --- a/internal/setup/detector_test.go +++ b/internal/setup/detector_test.go @@ -342,6 +342,8 @@ func TestFileDetector_MalformedPackageJSON_FallsThrough(t *testing.T) { 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"}}`) @@ -352,10 +354,8 @@ func TestFileDetector_NextJs_AppRouter_SuggestsInstrumentation(t *testing.T) { require.NoError(t, err) assert.Equal(t, "node-server", result.SDKID) - // An App Router project has no pages/ or src/index, so the old candidate list - // fell through to a nonexistent index.js at the repo root. - assert.Equal(t, filepath.Join(dir, "app/page.tsx"), result.EntryPoint) - assert.True(t, result.EntryPointExists) + assert.Equal(t, filepath.Join(dir, "instrumentation.ts"), result.EntryPoint) + assert.False(t, result.EntryPointExists) } func TestFileDetector_NextJs_PrefersExistingInstrumentation(t *testing.T) { @@ -371,7 +371,7 @@ func TestFileDetector_NextJs_PrefersExistingInstrumentation(t *testing.T) { assert.True(t, result.EntryPointExists) } -func TestFileDetector_NextJs_PagesRouter(t *testing.T) { +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() {}") @@ -379,8 +379,9 @@ func TestFileDetector_NextJs_PagesRouter(t *testing.T) { result, err := FileDetector{}.Detect(dir) require.NoError(t, err) - assert.Equal(t, filepath.Join(dir, "pages/index.tsx"), result.EntryPoint) - assert.True(t, result.EntryPointExists) + // 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) { From 3fefe374144bb030df1e2808b536b36df946ba20 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Fri, 31 Jul 2026 14:24:46 -0400 Subject: [PATCH 04/10] docs(setup): cite the sources for the entry-point candidates Each candidate list encodes a claim about where a toolchain puts its entry file. Link the documentation that claim rests on so it can be rechecked when the frameworks move. Co-Authored-By: Claude Opus 5 (1M context) --- internal/setup/detector.go | 45 ++++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/internal/setup/detector.go b/internal/setup/detector.go index 6f9352f4..31f849ea 100644 --- a/internal/setup/detector.go +++ b/internal/setup/detector.go @@ -98,11 +98,9 @@ func detectNode(dir string) *DetectResult { // 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 { - // Only instrumentation.ts, Next's server-startup hook, is guaranteed to stay - // out of the browser bundle. A page or route module may carry 'use client' or - // be imported by something that does, which would ship the server SDK key to - // the browser, and nothing here can tell which. Suggest creating the hook - // rather than picking a page that happens to exist. + // 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", @@ -118,6 +116,7 @@ func detectNode(dir string) *DetectResult { } 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", @@ -133,9 +132,9 @@ func detectNode(dir string) *DetectResult { } } if _, ok := allDeps["react"]; ok { - // src/main.tsx is where Vite mounts the app and src/index.tsx is where - // Create React App does; either is a better home for the provider than a - // component file, but App.tsx works and is the more familiar edit. + // 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", @@ -161,6 +160,8 @@ func detectNode(dir string) *DetectResult { } 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", @@ -177,6 +178,7 @@ func detectNode(dir string) *DetectResult { } } + // Entry point: https://docs.npmjs.com/cli/v11/configuring-npm/package-json#main ep, exists := entryPoint(dir, "index.js", "src/index.ts", "src/index.js", "index.ts", "index.js", @@ -199,7 +201,7 @@ func detectNodePM(dir string) string { if _, err := os.Stat(filepath.Join(dir, "yarn.lock")); err == nil { return "yarn" } - // bun.lock is the text lockfile from Bun 1.2 onwards; bun.lockb is the older binary one. + // 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" @@ -212,6 +214,7 @@ 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", @@ -225,6 +228,8 @@ func detectGo(dir string) *DetectResult { 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", ) @@ -243,6 +248,10 @@ func detectPython(dir string) *DetectResult { // 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" @@ -274,12 +283,12 @@ func detectRuby(dir string) *DetectResult { return nil } } - // A Gemfile means Bundler manages the project's gems, so the SDK has to be - // added to the Gemfile rather than installed into the global gem set. + // 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", @@ -297,7 +306,7 @@ func detectJava(dir string) *DetectResult { if indicator == "pom.xml" { pm = "mvn" } - // Android projects use Gradle but are distinguished by AndroidManifest.xml. + // Manifest: https://developer.android.com/guide/topics/manifest/manifest-intro for _, manifest := range []string{ "app/src/main/AndroidManifest.xml", "src/main/AndroidManifest.xml", @@ -305,8 +314,8 @@ func detectJava(dir string) *DetectResult { if _, err := os.Stat(filepath.Join(dir, manifest)); err != nil { continue } - // The manifest tells us which source root this project uses; the - // activity itself lives under a package directory, so search for it + // 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", @@ -321,6 +330,8 @@ func detectJava(dir string) *DetectResult { 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"), ) @@ -375,6 +386,9 @@ func detectSwift(dir string) *DetectResult { // 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", @@ -413,6 +427,8 @@ func soleSubdir(dir, root string) string { // 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 { @@ -425,6 +441,7 @@ 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", ) From c276645a88ee865752eff0489998612136f50429 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Fri, 31 Jul 2026 16:00:33 -0400 Subject: [PATCH 05/10] fix(setup): detect backend manifests before package.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A root package.json is often only build tooling — Rails with jsbundling, Django with Tailwind, a Go binary published to npm — so preferring Node whenever one parsed meant those projects were handed the Node SDK. This repo hit it too. Confine the Sources/ search to single-target Swift packages. Across several targets there is no way to tell an executable's entry file from a library's, so an arbitrary hit was reported as found. Co-Authored-By: Claude Opus 5 (1M context) --- internal/setup/detector.go | 46 ++++++++++----------- internal/setup/detector_test.go | 72 +++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 25 deletions(-) diff --git a/internal/setup/detector.go b/internal/setup/detector.go index 31f849ea..293d4a3e 100644 --- a/internal/setup/detector.go +++ b/internal/setup/detector.go @@ -46,27 +46,22 @@ 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) { - if result := detectNode(dir); result != nil { - return result, nil - } - if result := detectGo(dir); result != nil { - return result, nil - } - if result := detectPython(dir); result != nil { - return result, nil - } - if result := detectRuby(dir); result != nil { - return result, nil - } - if result := detectJava(dir); result != nil { - return result, nil - } - if result := detectSwift(dir); result != nil { - return result, nil - } - if result := detectDotnet(dir); result != nil { - return result, nil + 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") } @@ -393,13 +388,14 @@ func swiftEntryCandidates(dir, appRoot string) []string { candidates := []string{ "App.swift", "ContentView.swift", "AppDelegate.swift", findFileUnder(dir, appRoot, "*App.swift", "ContentView.swift", "AppDelegate.swift"), - findFileUnder(dir, "Sources", "main.swift", "*App.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, filepath.Base(target)+".swift"), - findFileUnder(dir, target, "*.swift"), - ) + candidates = append(candidates, findFileUnder(dir, target, + "main.swift", filepath.Base(target)+".swift", "*App.swift", "*.swift", + )) } return candidates } diff --git a/internal/setup/detector_test.go b/internal/setup/detector_test.go index 06178c90..cb48d976 100644 --- a/internal/setup/detector_test.go +++ b/internal/setup/detector_test.go @@ -720,3 +720,75 @@ func TestSoleSubdir(t *testing.T) { 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) +} From 0b6163979d1f8fd4af711fcc35b8a01e1c4df623 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Mon, 3 Aug 2026 14:48:05 -0400 Subject: [PATCH 06/10] test(setup): assert the whole result per project shape The existing tests check one or two fields each, so a field detection stops populating passes as long as the SDK id stays right. Compare the full DetectResult across the project layouts real toolchains produce. Co-Authored-By: Claude Opus 5 (1M context) --- internal/setup/detector_shapes_test.go | 375 +++++++++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 internal/setup/detector_shapes_test.go diff --git a/internal/setup/detector_shapes_test.go b/internal/setup/detector_shapes_test.go new file mode 100644 index 00000000..bc8302b7 --- /dev/null +++ b/internal/setup/detector_shapes_test.go @@ -0,0 +1,375 @@ +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: "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 +} From b5d737c707a6c197b2ea1c7781ceb7b742718d9a Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Mon, 3 Aug 2026 15:45:18 -0400 Subject: [PATCH 07/10] fix(setup): find the src/main entry a Node app bootstraps from NestJS and similar apps start from src/main.ts, which the candidate list skipped, so detection suggested a nonexistent index.js. node-server appends to the entry file, so setup created that index.js and left the real entry point without the SDK. Co-Authored-By: Claude Opus 5 (1M context) --- internal/setup/detector.go | 2 ++ internal/setup/detector_shapes_test.go | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/internal/setup/detector.go b/internal/setup/detector.go index 293d4a3e..a450fc7e 100644 --- a/internal/setup/detector.go +++ b/internal/setup/detector.go @@ -174,8 +174,10 @@ func detectNode(dir string) *DetectResult { } // 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", diff --git a/internal/setup/detector_shapes_test.go b/internal/setup/detector_shapes_test.go index bc8302b7..56df04eb 100644 --- a/internal/setup/detector_shapes_test.go +++ b/internal/setup/detector_shapes_test.go @@ -118,6 +118,16 @@ func TestFileDetector_ProjectShapes(t *testing.T) { 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"}, From 37bb50b873949212dfc6134b4d5649fea86c2dd6 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Tue, 21 Jul 2026 16:39:05 -0400 Subject: [PATCH 08/10] 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 09/10] 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 10/10] 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)) +}