From 3a1d4dd79475fa7a5e8c1d21facd174606dc1013 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Tue, 21 Jul 2026 16:39:04 -0400 Subject: [PATCH 01/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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)) +} From d2de7f27fe8a35ae45bacf07cf1beee51548bccc Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Tue, 21 Jul 2026 16:39:06 -0400 Subject: [PATCH 11/18] chore(setup): add SDK init/injection library and templates Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/setup/initializer.go | 264 ++++++++++++++++++ internal/setup/initializer_test.go | 244 ++++++++++++++++ .../setup/sdk_init_templates/android.tmpl | 10 + .../sdk_init_templates/dotnet-server-sdk.tmpl | 11 + .../sdk_init_templates/go-server-sdk.tmpl | 16 ++ .../sdk_init_templates/java-server-sdk.tmpl | 11 + .../sdk_init_templates/js-client-sdk.tmpl | 12 + .../setup/sdk_init_templates/node-server.tmpl | 15 + .../sdk_init_templates/python-server-sdk.tmpl | 11 + .../sdk_init_templates/react-client-sdk.tmpl | 10 + .../sdk_init_templates/react-native.tmpl | 5 + .../sdk_init_templates/ruby-server-sdk.tmpl | 12 + .../sdk_init_templates/swift-client-sdk.tmpl | 5 + 13 files changed, 626 insertions(+) create mode 100644 internal/setup/initializer.go create mode 100644 internal/setup/initializer_test.go create mode 100644 internal/setup/sdk_init_templates/android.tmpl create mode 100644 internal/setup/sdk_init_templates/dotnet-server-sdk.tmpl create mode 100644 internal/setup/sdk_init_templates/go-server-sdk.tmpl create mode 100644 internal/setup/sdk_init_templates/java-server-sdk.tmpl create mode 100644 internal/setup/sdk_init_templates/js-client-sdk.tmpl create mode 100644 internal/setup/sdk_init_templates/node-server.tmpl create mode 100644 internal/setup/sdk_init_templates/python-server-sdk.tmpl create mode 100644 internal/setup/sdk_init_templates/react-client-sdk.tmpl create mode 100644 internal/setup/sdk_init_templates/react-native.tmpl create mode 100644 internal/setup/sdk_init_templates/ruby-server-sdk.tmpl create mode 100644 internal/setup/sdk_init_templates/swift-client-sdk.tmpl diff --git a/internal/setup/initializer.go b/internal/setup/initializer.go new file mode 100644 index 00000000..48d8ad7d --- /dev/null +++ b/internal/setup/initializer.go @@ -0,0 +1,264 @@ +package setup + +import ( + "bytes" + "embed" + "errors" + "fmt" + "os" + "strings" + "text/template" +) + +//go:embed sdk_init_templates/*.tmpl +var initTemplateFiles embed.FS + +// InitConfig holds the values to interpolate into SDK initialization templates. +type InitConfig struct { + SDKKey string + ClientSideID string + MobileKey string + FlagKey string +} + +// InitResult describes the outcome of injecting SDK initialization code. +// +// Success is true only when initialization code was actually written to a file +// as valid, ready-to-run code. When Success is false, Snippet (if set) holds the +// rendered code the user must place manually, and DocsURL points at the setup +// guide. +type InitResult struct { + SDKID string `json:"sdk_id"` + FilePath string `json:"file_path,omitempty"` + DocsURL string `json:"docs_url,omitempty"` + Snippet string `json:"snippet,omitempty"` + Success bool `json:"success"` +} + +// appendSafeSDKs lists SDKs whose entry file is an interpreted script executed +// top-to-bottom, so initialization statements can be appended at file scope and +// still run. For every other SDK — compiled/scoped languages (Go, Java, C#, +// Swift, Android) whose statements are illegal at file scope, and framework SDKs +// (React, React Native) that must be wired into a component tree — appending +// produces code that does not compile or does not run, so we return the snippet +// as guidance instead of writing a broken file. +var appendSafeSDKs = map[string]bool{ + "node-server": true, + "python-server-sdk": true, + "ruby-server-sdk": true, +} + +// Initializer injects SDK initialization code into a target file. +type Initializer struct{} + +// sdkTemplateInfo maps an SDK ID to the template filename. +type sdkTemplateInfo struct { + TemplateFile string +} + +var sdkTemplates = map[string]sdkTemplateInfo{ + "react-client-sdk": {TemplateFile: "react-client-sdk.tmpl"}, + "react-native": {TemplateFile: "react-native.tmpl"}, + "js-client-sdk": {TemplateFile: "js-client-sdk.tmpl"}, + "swift-client-sdk": {TemplateFile: "swift-client-sdk.tmpl"}, + "android-client-sdk": {TemplateFile: "android.tmpl"}, + "java-server-sdk": {TemplateFile: "java-server-sdk.tmpl"}, + "ruby-server-sdk": {TemplateFile: "ruby-server-sdk.tmpl"}, + "go-server-sdk": {TemplateFile: "go-server-sdk.tmpl"}, + "python-server-sdk": {TemplateFile: "python-server-sdk.tmpl"}, + "dotnet-server-sdk": {TemplateFile: "dotnet-server-sdk.tmpl"}, + "node-server": {TemplateFile: "node-server.tmpl"}, +} + +// sdkDocsPaths maps SDK IDs to their documentation path on launchdarkly.com/docs. +// Covers all SDKs, including those without init templates. +var sdkDocsPaths = map[string]string{ + "akamai-server-edgekv-sdk": "sdk/edge/akamai", + "android": "sdk/client-side/android", + "android-client-sdk": "sdk/client-side/android", + "apex-server-sdk": "sdk/server-side/apex", + "cpp-client-sdk": "sdk/client-side/c-c--", + "cpp-server-sdk": "sdk/server-side/c-c--", + "cloudflare-server-sdk": "sdk/edge/cloudflare", + "dotnet-client-sdk": "sdk/client-side/dotnet", + "dotnet-server-sdk": "sdk/server-side/dotnet", + "electron-client-sdk": "sdk/client-side/electron", + "erlang-server-sdk": "sdk/server-side/erlang", + "flutter-client-sdk": "sdk/client-side/flutter", + "go-server-sdk": "sdk/server-side/go", + "haskell-server-sdk": "sdk/server-side/haskell", + "ios-client-sdk": "sdk/client-side/ios", + "swift-client-sdk": "sdk/client-side/ios", + "java-server-sdk": "sdk/server-side/java", + "js-client-sdk": "sdk/client-side/javascript", + "lua-server-sdk": "sdk/server-side/lua", + "node-client-sdk": "sdk/client-side/node-js", + "node-server": "sdk/server-side/node-js", + "node-server-sdk": "sdk/server-side/node-js", + "php-server-sdk": "sdk/server-side/php", + "python-server-sdk": "sdk/server-side/python", + "react-client-sdk": "sdk/client-side/react", + "react-native": "sdk/client-side/react-native", + "react-native-client-sdk": "sdk/client-side/react-native", + "roku-client-sdk": "sdk/client-side/roku", + "ruby-server-sdk": "sdk/server-side/ruby", + "rust-server-sdk": "sdk/server-side/rust", + "vercel-server-sdk": "sdk/edge/vercel", + "vue-client-sdk": "sdk/client-side/vue", +} + +const docsBaseURL = "https://launchdarkly.com/docs" + +// GetDocsURL returns the full documentation URL for the given SDK ID. +// Falls back to the top-level SDK docs page if the ID is unknown. +func GetDocsURL(sdkID string) string { + if path, ok := sdkDocsPaths[sdkID]; ok { + return docsBaseURL + "/" + path + } + return docsBaseURL + "/sdk" +} + +// SupportedSDKIDs returns the list of SDK IDs that have initialization templates. +func SupportedSDKIDs() []string { + ids := make([]string, 0, len(sdkTemplates)) + for id := range sdkTemplates { + ids = append(ids, id) + } + return ids +} + +// HasTemplate returns true if the given SDK ID has an initialization template. +func HasTemplate(sdkID string) bool { + _, ok := sdkTemplates[sdkID] + return ok +} + +// InjectsInPlace reports whether `init` writes runnable code directly into the +// entry file (true) versus returning a snippet for the user to place manually +// (false). Also indicates whether a live verify step is meaningful afterward. +func InjectsInPlace(sdkID string) bool { + return HasTemplate(sdkID) && appendSafeSDKs[sdkID] +} + +// RenderTemplate renders the initialization code for the given SDK. +func RenderTemplate(sdkID string, cfg InitConfig) (string, error) { + info, ok := sdkTemplates[sdkID] + if !ok { + return "", fmt.Errorf("no initialization template for SDK %q; see docs: %s", sdkID, GetDocsURL(sdkID)) + } + + content, err := initTemplateFiles.ReadFile("sdk_init_templates/" + info.TemplateFile) + if err != nil { + return "", fmt.Errorf("reading template for %s: %w", sdkID, err) + } + + tmpl, err := template.New(sdkID).Parse(string(content)) + if err != nil { + return "", fmt.Errorf("parsing template for %s: %w", sdkID, err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, cfg); err != nil { + return "", fmt.Errorf("executing template for %s: %w", sdkID, err) + } + + return buf.String(), nil +} + +// InjectIntoFile renders the SDK initialization code and, for SDKs whose entry +// file is an interpreted script (see appendSafeSDKs), writes it into filePath: +// imports are placed at the top and init code appended after existing content. +// +// For SDKs that are not append-safe — because file-scope statements would not +// compile (Go, Java, C#, Swift, Android) or because the code must be wired into +// a component tree (React, React Native) — the file is left untouched and the +// result carries the rendered Snippet plus DocsURL as guidance, with +// Success=false so callers do not report a broken file as ready. +// +// If no template exists for the SDK at all, the result carries only the +// documentation URL. +// +// The template output is split into an IMPORTS section and an INIT section by a +// separator line ("// --- init ---" or "# --- init ---" depending on language). +func (i Initializer) InjectIntoFile(sdkID, filePath string, cfg InitConfig) (*InitResult, error) { + if !HasTemplate(sdkID) { + return &InitResult{ + SDKID: sdkID, + DocsURL: GetDocsURL(sdkID), + Success: false, + }, nil + } + + rendered, err := RenderTemplate(sdkID, cfg) + if err != nil { + return nil, err + } + + importSection, initSection := splitInitSections(rendered) + + if !appendSafeSDKs[sdkID] { + return &InitResult{ + SDKID: sdkID, + FilePath: filePath, + DocsURL: GetDocsURL(sdkID), + Snippet: joinSnippet(importSection, initSection), + Success: false, + }, nil + } + + existing, err := os.ReadFile(filePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + var content string + if importSection != "" { + content = importSection + "\n\n" + initSection + "\n" + } else { + content = initSection + "\n" + } + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + return nil, fmt.Errorf("creating %s: %w", filePath, err) + } + return &InitResult{SDKID: sdkID, FilePath: filePath, Success: true}, nil + } + return nil, fmt.Errorf("reading %s: %w", filePath, err) + } + + content := string(existing) + if importSection != "" { + content = importSection + "\n" + content + } + content = content + "\n\n" + initSection + "\n" + + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + return nil, fmt.Errorf("writing %s: %w", filePath, err) + } + + return &InitResult{SDKID: sdkID, FilePath: filePath, Success: true}, nil +} + +// joinSnippet recombines the import and init sections into a single human-readable +// snippet the user can copy into the correct place in their code. +func joinSnippet(importSection, initSection string) string { + if importSection == "" { + return initSection + } + return importSection + "\n\n" + initSection +} + +// initSeparators lists the markers that divide import and init sections in templates. +var initSeparators = []string{ + "// --- init ---", + "# --- init ---", +} + +// splitInitSections splits rendered template output into an import section and an +// init section. It recognises comment-style-appropriate separators so that templates +// for languages like Python and Ruby can use `#` comments. +func splitInitSections(rendered string) (importSection, initSection string) { + for _, sep := range initSeparators { + if parts := strings.SplitN(rendered, sep, 2); len(parts) == 2 { + return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) + } + } + return "", rendered +} diff --git a/internal/setup/initializer_test.go b/internal/setup/initializer_test.go new file mode 100644 index 00000000..70c2eb04 --- /dev/null +++ b/internal/setup/initializer_test.go @@ -0,0 +1,244 @@ +package setup + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderTemplate(t *testing.T) { + cfg := InitConfig{ + SDKKey: "sdk-test-key-123", + ClientSideID: "client-id-456", + MobileKey: "mob-key-789", + FlagKey: "my-test-flag", + } + + tests := []struct { + name string + sdkID string + wantSubstr string + }{ + {"node-server", "node-server", "sdk-test-key-123"}, + {"react-client-sdk", "react-client-sdk", "client-id-456"}, + {"react-native", "react-native", "mob-key-789"}, + {"js-client-sdk", "js-client-sdk", "my-test-flag"}, + {"swift-client-sdk", "swift-client-sdk", "mob-key-789"}, + {"android-client-sdk", "android-client-sdk", "mob-key-789"}, + {"java-server-sdk", "java-server-sdk", "sdk-test-key-123"}, + {"ruby-server-sdk", "ruby-server-sdk", "sdk-test-key-123"}, + {"go-server-sdk", "go-server-sdk", "sdk-test-key-123"}, + {"python-server-sdk", "python-server-sdk", "sdk-test-key-123"}, + {"dotnet-server-sdk", "dotnet-server-sdk", "sdk-test-key-123"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := RenderTemplate(tt.sdkID, cfg) + require.NoError(t, err) + assert.Contains(t, result, tt.wantSubstr) + }) + } +} + +func TestRenderTemplateUnknownSDK(t *testing.T) { + _, err := RenderTemplate("nonexistent-sdk", InitConfig{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no initialization template") + assert.Contains(t, err.Error(), "see docs") +} + +func TestRenderTemplateUnknownSDK_KnownDocsPath(t *testing.T) { + _, err := RenderTemplate("php-server-sdk", InitConfig{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "https://launchdarkly.com/docs/sdk/server-side/php") +} + +func TestHasTemplate(t *testing.T) { + assert.True(t, HasTemplate("node-server")) + assert.True(t, HasTemplate("react-client-sdk")) + assert.True(t, HasTemplate("android-client-sdk")) + assert.False(t, HasTemplate("android")) + assert.False(t, HasTemplate("nonexistent-sdk")) +} + +func TestSupportedSDKIDs(t *testing.T) { + ids := SupportedSDKIDs() + assert.Len(t, ids, 11) + assert.Contains(t, ids, "node-server") + assert.Contains(t, ids, "react-client-sdk") + assert.Contains(t, ids, "go-server-sdk") +} + +func TestInjectIntoFile_NewFile(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "index.js") + + initializer := Initializer{} + result, err := initializer.InjectIntoFile("node-server", filePath, InitConfig{ + SDKKey: "test-key", + FlagKey: "test-flag", + }) + + require.NoError(t, err) + assert.True(t, result.Success) + assert.Equal(t, "node-server", result.SDKID) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Contains(t, string(content), "test-key") + assert.Contains(t, string(content), "test-flag") +} + +func TestInjectIntoFile_ExistingFile(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "app.js") + + err := os.WriteFile(filePath, []byte("// existing code\nconsole.log('hello');\n"), 0644) + require.NoError(t, err) + + initializer := Initializer{} + result, err := initializer.InjectIntoFile("node-server", filePath, InitConfig{ + SDKKey: "test-key", + FlagKey: "test-flag", + }) + + require.NoError(t, err) + assert.True(t, result.Success) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Contains(t, string(content), "existing code") + assert.Contains(t, string(content), "test-key") +} + +func TestInjectIntoFile_NewFile_OmitsSeparator(t *testing.T) { + sdks := []struct { + sdkID string + filename string + }{ + {"python-server-sdk", "init_ld.py"}, + {"ruby-server-sdk", "init_ld.rb"}, + {"node-server", "index.js"}, + } + + for _, tt := range sdks { + t.Run(tt.sdkID, func(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, tt.filename) + + initializer := Initializer{} + result, err := initializer.InjectIntoFile(tt.sdkID, filePath, InitConfig{ + SDKKey: "test-key", + FlagKey: "test-flag", + }) + + require.NoError(t, err) + assert.True(t, result.Success) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.NotContains(t, string(content), "// --- init ---") + }) + } +} + +func TestInjectIntoFile_AndroidClientSdk_ReturnsGuidance(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "MainActivity.java") + + initializer := Initializer{} + result, err := initializer.InjectIntoFile("android-client-sdk", filePath, InitConfig{ + MobileKey: "mob-test-key", + FlagKey: "test-flag", + }) + + require.NoError(t, err) + // Android is a scoped language: statements can't live at file scope, so we + // return guidance rather than write a broken file. + assert.False(t, result.Success) + assert.Equal(t, "android-client-sdk", result.SDKID) + assert.Contains(t, result.Snippet, "mob-test-key") + assert.NotEmpty(t, result.DocsURL) + + // The file must not have been created. + _, statErr := os.Stat(filePath) + assert.True(t, os.IsNotExist(statErr), "guidance-only SDK must not create the file") +} + +func TestInjectIntoFile_Go_ReturnsGuidanceDoesNotModifyFile(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "main.go") + + existing := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"hello\")\n}\n" + err := os.WriteFile(filePath, []byte(existing), 0644) + require.NoError(t, err) + + initializer := Initializer{} + result, err := initializer.InjectIntoFile("go-server-sdk", filePath, InitConfig{ + SDKKey: "sdk-test-key", + FlagKey: "test-flag", + }) + + require.NoError(t, err) + // Go statements are illegal at file scope, so appending would not compile. + // We return the snippet as guidance and leave the file untouched. + assert.False(t, result.Success) + assert.Contains(t, result.Snippet, "sdk-test-key") + assert.Contains(t, result.Snippet, "github.com/launchdarkly/go-server-sdk/v7") + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Equal(t, existing, string(content), "existing file must not be modified") +} + +func TestInjectIntoFile_React_ReturnsGuidanceDoesNotModifyFile(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "App.tsx") + + existing := "export default function App() { return null }\n" + err := os.WriteFile(filePath, []byte(existing), 0644) + require.NoError(t, err) + + initializer := Initializer{} + result, err := initializer.InjectIntoFile("react-client-sdk", filePath, InitConfig{ + ClientSideID: "client-id-456", + FlagKey: "test-flag", + }) + + require.NoError(t, err) + // React init must be wired into the component tree, not appended, so we + // return guidance rather than corrupt the file. + assert.False(t, result.Success) + assert.Contains(t, result.Snippet, "asyncWithLDProvider") + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Equal(t, existing, string(content), "existing file must not be modified") +} + +func TestInjectIntoFile_UnsupportedSDK_ReturnsDocsURL(t *testing.T) { + initializer := Initializer{} + result, err := initializer.InjectIntoFile("php-server-sdk", "/tmp/fake.php", InitConfig{}) + require.NoError(t, err) + assert.False(t, result.Success) + assert.Equal(t, "https://launchdarkly.com/docs/sdk/server-side/php", result.DocsURL) +} + +func TestInjectIntoFile_CompletelyUnknownSDK_ReturnsFallbackDocsURL(t *testing.T) { + initializer := Initializer{} + result, err := initializer.InjectIntoFile("nonexistent-sdk", "/tmp/fake.txt", InitConfig{}) + require.NoError(t, err) + assert.False(t, result.Success) + assert.Equal(t, "https://launchdarkly.com/docs/sdk", result.DocsURL) +} + +func TestGetDocsURL(t *testing.T) { + assert.Equal(t, "https://launchdarkly.com/docs/sdk/server-side/go", GetDocsURL("go-server-sdk")) + assert.Equal(t, "https://launchdarkly.com/docs/sdk/client-side/react", GetDocsURL("react-client-sdk")) + assert.Equal(t, "https://launchdarkly.com/docs/sdk/server-side/python", GetDocsURL("python-server-sdk")) + assert.Equal(t, "https://launchdarkly.com/docs/sdk", GetDocsURL("totally-unknown")) +} diff --git a/internal/setup/sdk_init_templates/android.tmpl b/internal/setup/sdk_init_templates/android.tmpl new file mode 100644 index 00000000..d1672e82 --- /dev/null +++ b/internal/setup/sdk_init_templates/android.tmpl @@ -0,0 +1,10 @@ +import com.launchdarkly.sdk.android.*; +import com.launchdarkly.sdk.*; +// --- init --- +LDConfig ldConfig = new LDConfig.Builder() + .mobileKey("{{.MobileKey}}") + .build(); +LDContext ldContext = LDContext.builder(ContextKind.DEFAULT, "example-user-key") + .name("Example User") + .build(); +LDClient ldClient = LDClient.init(this.getApplication(), ldConfig, ldContext, 5); diff --git a/internal/setup/sdk_init_templates/dotnet-server-sdk.tmpl b/internal/setup/sdk_init_templates/dotnet-server-sdk.tmpl new file mode 100644 index 00000000..da54e9d4 --- /dev/null +++ b/internal/setup/sdk_init_templates/dotnet-server-sdk.tmpl @@ -0,0 +1,11 @@ +using LaunchDarkly.Sdk; +using LaunchDarkly.Sdk.Server; +// --- init --- +var ldClient = new LdClient("{{.SDKKey}}"); + +var context = Context.Builder("example-user-key") + .Name("Example User") + .Build(); + +var flagValue = ldClient.BoolVariation("{{.FlagKey}}", context, false); +Console.WriteLine($"Flag '{{.FlagKey}}' is {flagValue}"); diff --git a/internal/setup/sdk_init_templates/go-server-sdk.tmpl b/internal/setup/sdk_init_templates/go-server-sdk.tmpl new file mode 100644 index 00000000..a1a92ba4 --- /dev/null +++ b/internal/setup/sdk_init_templates/go-server-sdk.tmpl @@ -0,0 +1,16 @@ +import ( + "fmt" + "time" + + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" + ld "github.com/launchdarkly/go-server-sdk/v7" +) +// --- init --- +ldClient, _ := ld.MakeClient("{{.SDKKey}}", 5*time.Second) + +context := ldcontext.NewBuilder("example-user-key"). + Name("Example User"). + Build() + +flagValue, _ := ldClient.BoolVariation("{{.FlagKey}}", context, false) +fmt.Printf("Flag '{{.FlagKey}}' is %t\n", flagValue) diff --git a/internal/setup/sdk_init_templates/java-server-sdk.tmpl b/internal/setup/sdk_init_templates/java-server-sdk.tmpl new file mode 100644 index 00000000..88a0e3a7 --- /dev/null +++ b/internal/setup/sdk_init_templates/java-server-sdk.tmpl @@ -0,0 +1,11 @@ +import com.launchdarkly.sdk.*; +import com.launchdarkly.sdk.server.*; +// --- init --- +LDClient ldClient = new LDClient("{{.SDKKey}}"); + +LDContext context = LDContext.builder("example-user-key") + .name("Example User") + .build(); + +boolean flagValue = ldClient.boolVariation("{{.FlagKey}}", context, false); +System.out.println("Flag '{{.FlagKey}}' is " + flagValue); diff --git a/internal/setup/sdk_init_templates/js-client-sdk.tmpl b/internal/setup/sdk_init_templates/js-client-sdk.tmpl new file mode 100644 index 00000000..f78f1f30 --- /dev/null +++ b/internal/setup/sdk_init_templates/js-client-sdk.tmpl @@ -0,0 +1,12 @@ +import * as LDClient from 'launchdarkly-js-client-sdk'; +// --- init --- +const ldClient = LDClient.initialize('{{.ClientSideID}}', { + kind: 'user', + key: 'example-user-key', + name: 'Example User', +}); + +ldClient.on('ready', () => { + const flagValue = ldClient.variation('{{.FlagKey}}', false); + console.log(`Flag '{{.FlagKey}}' is ${flagValue}`); +}); diff --git a/internal/setup/sdk_init_templates/node-server.tmpl b/internal/setup/sdk_init_templates/node-server.tmpl new file mode 100644 index 00000000..321c0c67 --- /dev/null +++ b/internal/setup/sdk_init_templates/node-server.tmpl @@ -0,0 +1,15 @@ +const LaunchDarkly = require('@launchdarkly/node-server-sdk'); +// --- init --- +const ldClient = LaunchDarkly.init('{{.SDKKey}}'); + +const context = { + kind: 'user', + key: 'example-user-key', + name: 'Example User', +}; + +ldClient.on('ready', () => { + ldClient.variation('{{.FlagKey}}', context, false, (err, flagValue) => { + console.log(`Flag '{{.FlagKey}}' is ${flagValue}`); + }); +}); diff --git a/internal/setup/sdk_init_templates/python-server-sdk.tmpl b/internal/setup/sdk_init_templates/python-server-sdk.tmpl new file mode 100644 index 00000000..4960a98f --- /dev/null +++ b/internal/setup/sdk_init_templates/python-server-sdk.tmpl @@ -0,0 +1,11 @@ +import ldclient +from ldclient import Context +from ldclient.config import Config +# --- init --- +ldclient.set_config(Config("{{.SDKKey}}")) +ld_client = ldclient.get() + +context = Context.builder("example-user-key").name("Example User").build() + +flag_value = ld_client.variation("{{.FlagKey}}", context, False) +print(f"Flag '{{.FlagKey}}' is {flag_value}") diff --git a/internal/setup/sdk_init_templates/react-client-sdk.tmpl b/internal/setup/sdk_init_templates/react-client-sdk.tmpl new file mode 100644 index 00000000..de155630 --- /dev/null +++ b/internal/setup/sdk_init_templates/react-client-sdk.tmpl @@ -0,0 +1,10 @@ +import { asyncWithLDProvider } from 'launchdarkly-react-client-sdk'; +// --- init --- +const LDProvider = await asyncWithLDProvider({ + clientSideID: '{{.ClientSideID}}', + context: { + kind: 'user', + key: 'example-user-key', + name: 'Example User', + }, +}); diff --git a/internal/setup/sdk_init_templates/react-native.tmpl b/internal/setup/sdk_init_templates/react-native.tmpl new file mode 100644 index 00000000..0b137bc6 --- /dev/null +++ b/internal/setup/sdk_init_templates/react-native.tmpl @@ -0,0 +1,5 @@ +import LDClient from '@launchdarkly/react-native-client-sdk'; +// --- init --- +const ldClient = new LDClient(); +const context = { kind: 'user', key: 'example-user-key', name: 'Example User' }; +await ldClient.configure({ mobileKey: '{{.MobileKey}}' }, context); diff --git a/internal/setup/sdk_init_templates/ruby-server-sdk.tmpl b/internal/setup/sdk_init_templates/ruby-server-sdk.tmpl new file mode 100644 index 00000000..38306dbb --- /dev/null +++ b/internal/setup/sdk_init_templates/ruby-server-sdk.tmpl @@ -0,0 +1,12 @@ +require 'ldclient-rb' +# --- init --- +ld_client = LaunchDarkly::LDClient.new("{{.SDKKey}}") + +context = LaunchDarkly::LDContext.create({ + key: "example-user-key", + kind: "user", + name: "Example User" +}) + +flag_value = ld_client.variation("{{.FlagKey}}", context, false) +puts "Flag '{{.FlagKey}}' is #{flag_value}" diff --git a/internal/setup/sdk_init_templates/swift-client-sdk.tmpl b/internal/setup/sdk_init_templates/swift-client-sdk.tmpl new file mode 100644 index 00000000..8fb3c12b --- /dev/null +++ b/internal/setup/sdk_init_templates/swift-client-sdk.tmpl @@ -0,0 +1,5 @@ +import LaunchDarkly +// --- init --- +var ldConfig = LDConfig(mobileKey: "{{.MobileKey}}") +let ldContext = try LDContextBuilder(key: "example-user-key").build().get() +LDClient.start(config: ldConfig, context: ldContext) From 0d6a927dfbc58f6881b210854d6150a38abb1bde Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Thu, 30 Jul 2026 23:48:44 -0400 Subject: [PATCH 12/18] fix(setup): correct the React Native init snippet react-native.tmpl bound the default export of @launchdarkly/react-native-client-sdk, which has no default export, so the snippet could not compile. The client is the named export ReactNativeLDClient. Register the android template under the SDK ID the detector reports, keeping android-client-sdk as an alias. Co-Authored-By: Claude Opus 5 (1M context) --- internal/setup/initializer.go | 1 + internal/setup/initializer_test.go | 6 +++-- internal/setup/installer_test.go | 26 +++++++++++++++++++ .../sdk_init_templates/react-native.tmpl | 7 +++-- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/internal/setup/initializer.go b/internal/setup/initializer.go index 48d8ad7d..528d64b5 100644 --- a/internal/setup/initializer.go +++ b/internal/setup/initializer.go @@ -61,6 +61,7 @@ var sdkTemplates = map[string]sdkTemplateInfo{ "react-native": {TemplateFile: "react-native.tmpl"}, "js-client-sdk": {TemplateFile: "js-client-sdk.tmpl"}, "swift-client-sdk": {TemplateFile: "swift-client-sdk.tmpl"}, + "android": {TemplateFile: "android.tmpl"}, "android-client-sdk": {TemplateFile: "android.tmpl"}, "java-server-sdk": {TemplateFile: "java-server-sdk.tmpl"}, "ruby-server-sdk": {TemplateFile: "ruby-server-sdk.tmpl"}, diff --git a/internal/setup/initializer_test.go b/internal/setup/initializer_test.go index 70c2eb04..3bf153df 100644 --- a/internal/setup/initializer_test.go +++ b/internal/setup/initializer_test.go @@ -60,14 +60,16 @@ func TestRenderTemplateUnknownSDK_KnownDocsPath(t *testing.T) { func TestHasTemplate(t *testing.T) { assert.True(t, HasTemplate("node-server")) assert.True(t, HasTemplate("react-client-sdk")) + // The detector emits "android"; "android-client-sdk" stays as an alias so any + // caller still passing the old ID keeps working. + assert.True(t, HasTemplate("android")) assert.True(t, HasTemplate("android-client-sdk")) - assert.False(t, HasTemplate("android")) assert.False(t, HasTemplate("nonexistent-sdk")) } func TestSupportedSDKIDs(t *testing.T) { ids := SupportedSDKIDs() - assert.Len(t, ids, 11) + assert.Len(t, ids, 12) assert.Contains(t, ids, "node-server") assert.Contains(t, ids, "react-client-sdk") assert.Contains(t, ids, "go-server-sdk") diff --git a/internal/setup/installer_test.go b/internal/setup/installer_test.go index 085913fe..7444e339 100644 --- a/internal/setup/installer_test.go +++ b/internal/setup/installer_test.go @@ -372,3 +372,29 @@ func TestCsprojFiles_SkipsBuildOutput(t *testing.T) { assert.Equal(t, []string{filepath.Join(dir, "src/MyApp/MyApp.csproj")}, csprojFiles(dir)) } + +// The templates import the package InstallArgs installs; a mismatch means the user +// installs one package and the snippet requires another. These are the pairs where +// LaunchDarkly ships both a scoped and an unscoped package for the same SDK. +func TestInstallArgs_PackageMatchesTemplateImport(t *testing.T) { + tests := []struct { + sdkID string + wantImport string + }{ + {"node-server", "@launchdarkly/node-server-sdk"}, + {"react-client-sdk", "launchdarkly-react-client-sdk"}, + {"react-native", "@launchdarkly/react-native-client-sdk"}, + {"js-client-sdk", "launchdarkly-js-client-sdk"}, + } + for _, tt := range tests { + t.Run(tt.sdkID, func(t *testing.T) { + _, pkg := InstallArgs(tt.sdkID, "npm") + assert.Equal(t, tt.wantImport, pkg) + + rendered, err := RenderTemplate(tt.sdkID, InitConfig{}) + require.NoError(t, err) + assert.Contains(t, rendered, "'"+tt.wantImport+"'", + "template must import the package we install") + }) + } +} diff --git a/internal/setup/sdk_init_templates/react-native.tmpl b/internal/setup/sdk_init_templates/react-native.tmpl index 0b137bc6..d31a8e5b 100644 --- a/internal/setup/sdk_init_templates/react-native.tmpl +++ b/internal/setup/sdk_init_templates/react-native.tmpl @@ -1,5 +1,4 @@ -import LDClient from '@launchdarkly/react-native-client-sdk'; +import { AutoEnvAttributes, ReactNativeLDClient } from '@launchdarkly/react-native-client-sdk'; // --- init --- -const ldClient = new LDClient(); -const context = { kind: 'user', key: 'example-user-key', name: 'Example User' }; -await ldClient.configure({ mobileKey: '{{.MobileKey}}' }, context); +const featureClient = new ReactNativeLDClient('{{.MobileKey}}', AutoEnvAttributes.Enabled); +await featureClient.identify({ kind: 'user', key: 'example-user-key', name: 'Example User' }); From 519fe65d2007700810c5355bda401a19b6727e29 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Mon, 3 Aug 2026 14:50:43 -0400 Subject: [PATCH 13/18] fix(setup): pass the required auto-env argument in mobile snippets The Swift and Android snippets did not compile. LDConfig's only public initializer takes autoEnvAttributes, and LDConfig.Builder's only constructor takes AutoEnvAttributes, so neither config could be built as written. These SDKs return a snippet for the user to paste rather than writing a file, so the snippet is the entire deliverable. AutoEnvAttributes is nested in LDConfig.Builder, which the package wildcard import does not cover, so import it explicitly. Add DefaultEntryPoint, naming the file to create for the SDKs that write one when detection found no entry point for them. Co-Authored-By: Claude Opus 5 (1M context) --- internal/setup/initializer.go | 16 +++++++++ internal/setup/initializer_test.go | 33 +++++++++++++++++++ .../setup/sdk_init_templates/android.tmpl | 3 +- .../sdk_init_templates/swift-client-sdk.tmpl | 2 +- 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/internal/setup/initializer.go b/internal/setup/initializer.go index 528d64b5..35ba8d12 100644 --- a/internal/setup/initializer.go +++ b/internal/setup/initializer.go @@ -48,6 +48,22 @@ var appendSafeSDKs = map[string]bool{ "ruby-server-sdk": true, } +// defaultEntryPoints names the file to create for an SDK when there is no detected +// entry point to write into. Only the append-safe SDKs need one, since every other +// SDK returns a snippet and never touches the filesystem. The names match the +// fallbacks detection already suggests for these languages. +var defaultEntryPoints = map[string]string{ + "node-server": "index.js", + "python-server-sdk": "main.py", + "ruby-server-sdk": "main.rb", +} + +// DefaultEntryPoint returns the file to create for sdkID when no entry point was +// detected for it, or an empty string when the SDK does not write to disk. +func DefaultEntryPoint(sdkID string) string { + return defaultEntryPoints[sdkID] +} + // Initializer injects SDK initialization code into a target file. type Initializer struct{} diff --git a/internal/setup/initializer_test.go b/internal/setup/initializer_test.go index 3bf153df..d5d31713 100644 --- a/internal/setup/initializer_test.go +++ b/internal/setup/initializer_test.go @@ -244,3 +244,36 @@ func TestGetDocsURL(t *testing.T) { assert.Equal(t, "https://launchdarkly.com/docs/sdk/server-side/python", GetDocsURL("python-server-sdk")) assert.Equal(t, "https://launchdarkly.com/docs/sdk", GetDocsURL("totally-unknown")) } + +// The mobile SDKs return a snippet the user pastes by hand, so a snippet that does +// not compile is the whole deliverable being wrong. Neither config type can be +// built without its environment-attributes argument: LDConfig's only public +// initializer takes autoEnvAttributes, and LDConfig.Builder's only constructor +// takes AutoEnvAttributes. There is no Swift or Java toolchain here to catch it. +func TestRenderTemplate_MobileConfigCarriesRequiredArguments(t *testing.T) { + tests := []struct { + sdkID string + want []string + }{ + {"swift-client-sdk", []string{`LDConfig(mobileKey: "mob-456", autoEnvAttributes: .enabled)`}}, + {"android", []string{ + "new LDConfig.Builder(AutoEnvAttributes.Enabled)", + // AutoEnvAttributes is nested in LDConfig.Builder, so the package + // wildcard import does not bring it into scope. + "import com.launchdarkly.sdk.android.LDConfig.Builder.AutoEnvAttributes;", + }}, + {"android-client-sdk", []string{"new LDConfig.Builder(AutoEnvAttributes.Enabled)"}}, + } + for _, tt := range tests { + t.Run(tt.sdkID, func(t *testing.T) { + result, err := RenderTemplate(tt.sdkID, InitConfig{MobileKey: "mob-456", FlagKey: "my-flag"}) + + require.NoError(t, err) + for _, want := range tt.want { + assert.Contains(t, result, want) + } + assert.NotContains(t, result, "new LDConfig.Builder()", + "the no-argument Builder constructor does not exist") + }) + } +} diff --git a/internal/setup/sdk_init_templates/android.tmpl b/internal/setup/sdk_init_templates/android.tmpl index d1672e82..897b97b9 100644 --- a/internal/setup/sdk_init_templates/android.tmpl +++ b/internal/setup/sdk_init_templates/android.tmpl @@ -1,7 +1,8 @@ import com.launchdarkly.sdk.android.*; +import com.launchdarkly.sdk.android.LDConfig.Builder.AutoEnvAttributes; import com.launchdarkly.sdk.*; // --- init --- -LDConfig ldConfig = new LDConfig.Builder() +LDConfig ldConfig = new LDConfig.Builder(AutoEnvAttributes.Enabled) .mobileKey("{{.MobileKey}}") .build(); LDContext ldContext = LDContext.builder(ContextKind.DEFAULT, "example-user-key") diff --git a/internal/setup/sdk_init_templates/swift-client-sdk.tmpl b/internal/setup/sdk_init_templates/swift-client-sdk.tmpl index 8fb3c12b..686704ae 100644 --- a/internal/setup/sdk_init_templates/swift-client-sdk.tmpl +++ b/internal/setup/sdk_init_templates/swift-client-sdk.tmpl @@ -1,5 +1,5 @@ import LaunchDarkly // --- init --- -var ldConfig = LDConfig(mobileKey: "{{.MobileKey}}") +var ldConfig = LDConfig(mobileKey: "{{.MobileKey}}", autoEnvAttributes: .enabled) let ldContext = try LDContextBuilder(key: "example-user-key").build().get() LDClient.start(config: ldConfig, context: ldContext) From 8daf7230469a037a7bce74d8a3cb5d53161c10ed Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Mon, 3 Aug 2026 15:46:05 -0400 Subject: [PATCH 14/18] fix(setup): build the Swift context without try MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LDContextBuilder.build returns a Result, and the snippet unwrapped it with `try ...get()`. try only compiles inside a throwing function, and the places this snippet gets pasted — application(_:didFinishLaunchingWithOptions:) and similar startup hooks — do not throw. Match on the Result instead. Co-Authored-By: Claude Opus 5 (1M context) --- internal/setup/initializer_test.go | 9 ++++++++- internal/setup/sdk_init_templates/swift-client-sdk.tmpl | 5 +++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/internal/setup/initializer_test.go b/internal/setup/initializer_test.go index d5d31713..b31aaf1e 100644 --- a/internal/setup/initializer_test.go +++ b/internal/setup/initializer_test.go @@ -255,7 +255,12 @@ func TestRenderTemplate_MobileConfigCarriesRequiredArguments(t *testing.T) { sdkID string want []string }{ - {"swift-client-sdk", []string{`LDConfig(mobileKey: "mob-456", autoEnvAttributes: .enabled)`}}, + {"swift-client-sdk", []string{ + `LDConfig(mobileKey: "mob-456", autoEnvAttributes: .enabled)`, + // build() returns a Result. `try ...get()` only compiles inside a + // throwing function, and the paste sites are not throwing. + `guard case .success(let ldContext) = LDContextBuilder(key: "example-user-key").build()`, + }}, {"android", []string{ "new LDConfig.Builder(AutoEnvAttributes.Enabled)", // AutoEnvAttributes is nested in LDConfig.Builder, so the package @@ -274,6 +279,8 @@ func TestRenderTemplate_MobileConfigCarriesRequiredArguments(t *testing.T) { } assert.NotContains(t, result, "new LDConfig.Builder()", "the no-argument Builder constructor does not exist") + assert.NotContains(t, result, "try ", + "a snippet pasted into a non-throwing function cannot use try") }) } } diff --git a/internal/setup/sdk_init_templates/swift-client-sdk.tmpl b/internal/setup/sdk_init_templates/swift-client-sdk.tmpl index 686704ae..a9068f4f 100644 --- a/internal/setup/sdk_init_templates/swift-client-sdk.tmpl +++ b/internal/setup/sdk_init_templates/swift-client-sdk.tmpl @@ -1,5 +1,6 @@ import LaunchDarkly // --- init --- -var ldConfig = LDConfig(mobileKey: "{{.MobileKey}}", autoEnvAttributes: .enabled) -let ldContext = try LDContextBuilder(key: "example-user-key").build().get() +let ldConfig = LDConfig(mobileKey: "{{.MobileKey}}", autoEnvAttributes: .enabled) +guard case .success(let ldContext) = LDContextBuilder(key: "example-user-key").build() +else { return } LDClient.start(config: ldConfig, context: ldContext) From 9126132cb0faa798a3b3324840f6b6130dcdf6d6 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Tue, 4 Aug 2026 12:48:11 -0400 Subject: [PATCH 15/18] fix(setup): keep the shebang first when injecting imports InjectIntoFile prepended the import section at byte 0, which displaced a leading shebang and an encoding cookie. Django's manage.py is one of the Python entry points detection targets, so `./manage.py` stopped being executable after setup wrote to it, and Python stopped honoring a coding declaration pushed past line 2. Peel the prologue off first and insert imports after it. Co-Authored-By: Claude Opus 5 (1M context) --- internal/setup/initializer.go | 39 +++++++++++++++++- internal/setup/initializer_test.go | 65 ++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/internal/setup/initializer.go b/internal/setup/initializer.go index 35ba8d12..3725b2cc 100644 --- a/internal/setup/initializer.go +++ b/internal/setup/initializer.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "os" + "regexp" "strings" "text/template" ) @@ -242,7 +243,8 @@ func (i Initializer) InjectIntoFile(sdkID, filePath string, cfg InitConfig) (*In content := string(existing) if importSection != "" { - content = importSection + "\n" + content + prologue, body := splitPrologue(content) + content = prologue + importSection + "\n" + body } content = content + "\n\n" + initSection + "\n" @@ -253,6 +255,41 @@ func (i Initializer) InjectIntoFile(sdkID, filePath string, cfg InitConfig) (*In return &InitResult{SDKID: sdkID, FilePath: filePath, Success: true}, nil } +// encodingCookie matches the encoding declaration Python (PEP 263) and Ruby only +// honor on the first or second line of a file. +var encodingCookie = regexp.MustCompile(`^[ \t\f]*#.*coding[:=][ \t]*[-_.a-zA-Z0-9]+`) + +// splitPrologue peels off the leading lines that have to stay at the top of a file: +// a shebang, which stops the file being executable if anything precedes it, and an +// encoding cookie, which is ignored once pushed past the second line. Imports go +// after the prologue rather than at byte 0, so injecting into an entry point like +// Django's manage.py leaves it runnable. +func splitPrologue(content string) (prologue, rest string) { + rest = content + if strings.HasPrefix(rest, "#!") { + var line string + line, rest = takeLine(rest) + prologue += line + } + if encodingCookie.MatchString(rest) { + var line string + line, rest = takeLine(rest) + prologue += line + } + if prologue != "" && !strings.HasSuffix(prologue, "\n") { + prologue += "\n" + } + return prologue, rest +} + +// takeLine splits off the first line of s, keeping the newline with the line. +func takeLine(s string) (line, remainder string) { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i+1], s[i+1:] + } + return s, "" +} + // joinSnippet recombines the import and init sections into a single human-readable // snippet the user can copy into the correct place in their code. func joinSnippet(importSection, initSection string) string { diff --git a/internal/setup/initializer_test.go b/internal/setup/initializer_test.go index b31aaf1e..6c42dd30 100644 --- a/internal/setup/initializer_test.go +++ b/internal/setup/initializer_test.go @@ -3,6 +3,7 @@ package setup import ( "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -117,6 +118,70 @@ func TestInjectIntoFile_ExistingFile(t *testing.T) { assert.Contains(t, string(content), "test-key") } +// A shebang only works as the very first bytes of a file, and Python and Ruby only +// read an encoding cookie on the first two lines. Injecting imports above either one +// leaves the entry point unrunnable, which is how Django's manage.py arrives. +func TestInjectIntoFile_KeepsPrologueFirst(t *testing.T) { + tests := []struct { + name string + sdkID string + fileName string + existing string + wantHead string + }{ + { + name: "shebang stays on the first line", + sdkID: "python-server-sdk", + fileName: "manage.py", + existing: "#!/usr/bin/env python\nimport os\n", + wantHead: "#!/usr/bin/env python\n", + }, + { + name: "encoding cookie stays within the first two lines", + sdkID: "python-server-sdk", + fileName: "main.py", + existing: "#!/usr/bin/env python\n# -*- coding: utf-8 -*-\nimport os\n", + wantHead: "#!/usr/bin/env python\n# -*- coding: utf-8 -*-\n", + }, + { + name: "cookie without a shebang stays first", + sdkID: "ruby-server-sdk", + fileName: "main.rb", + existing: "# coding: utf-8\nputs 'hi'\n", + wantHead: "# coding: utf-8\n", + }, + { + name: "shebang with no trailing newline still gets one", + sdkID: "node-server", + fileName: "cli.js", + existing: "#!/usr/bin/env node", + wantHead: "#!/usr/bin/env node\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, tt.fileName) + require.NoError(t, os.WriteFile(filePath, []byte(tt.existing), 0644)) + + initializer := Initializer{} + result, err := initializer.InjectIntoFile(tt.sdkID, filePath, InitConfig{ + SDKKey: "test-key", + FlagKey: "test-flag", + }) + require.NoError(t, err) + require.True(t, result.Success) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(string(content), tt.wantHead), + "file must still start with %q, got:\n%s", tt.wantHead, content) + assert.Contains(t, string(content), "test-key", "init code must still be injected") + }) + } +} + func TestInjectIntoFile_NewFile_OmitsSeparator(t *testing.T) { sdks := []struct { sdkID string From 51ae9eca7a5e8ebcc523f226d23d9befecbbd92b Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Tue, 4 Aug 2026 12:52:21 -0400 Subject: [PATCH 16/18] fix(setup): inject node imports in the entry file's module syntax The node-server template loaded the SDK with require, but detection targets TypeScript and ESM entry points such as Next.js instrumentation.ts and NestJS src/main.ts, where require is not defined at runtime. Setup wrote that code and reported success, so the failure only showed up when the app started. Pick the CommonJS or ESM template from the entry extension, falling back to the nearest package.json "type" for a plain .js file. Co-Authored-By: Claude Opus 5 (1M context) --- internal/setup/initializer.go | 70 +++++++++++++++- internal/setup/initializer_test.go | 83 +++++++++++++++++++ internal/setup/installer_test.go | 5 ++ .../sdk_init_templates/node-server-esm.tmpl | 15 ++++ 4 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 internal/setup/sdk_init_templates/node-server-esm.tmpl diff --git a/internal/setup/initializer.go b/internal/setup/initializer.go index 3725b2cc..94496cb9 100644 --- a/internal/setup/initializer.go +++ b/internal/setup/initializer.go @@ -3,9 +3,11 @@ package setup import ( "bytes" "embed" + "encoding/json" "errors" "fmt" "os" + "path/filepath" "regexp" "strings" "text/template" @@ -71,6 +73,10 @@ type Initializer struct{} // sdkTemplateInfo maps an SDK ID to the template filename. type sdkTemplateInfo struct { TemplateFile string + // ESMTemplateFile renders the same initialization with ESM import syntax, for + // entry points where a CommonJS require would not run. Empty for SDKs whose + // language has no module-system split. + ESMTemplateFile string } var sdkTemplates = map[string]sdkTemplateInfo{ @@ -85,7 +91,7 @@ var sdkTemplates = map[string]sdkTemplateInfo{ "go-server-sdk": {TemplateFile: "go-server-sdk.tmpl"}, "python-server-sdk": {TemplateFile: "python-server-sdk.tmpl"}, "dotnet-server-sdk": {TemplateFile: "dotnet-server-sdk.tmpl"}, - "node-server": {TemplateFile: "node-server.tmpl"}, + "node-server": {TemplateFile: "node-server.tmpl", ESMTemplateFile: "node-server-esm.tmpl"}, } // sdkDocsPaths maps SDK IDs to their documentation path on launchdarkly.com/docs. @@ -158,14 +164,31 @@ func InjectsInPlace(sdkID string) bool { return HasTemplate(sdkID) && appendSafeSDKs[sdkID] } -// RenderTemplate renders the initialization code for the given SDK. +// RenderTemplate renders the initialization code for the given SDK, using the +// CommonJS form where an SDK has both. Prefer RenderTemplateForEntry when the +// target file is known, so the module syntax matches it. func RenderTemplate(sdkID string, cfg InitConfig) (string, error) { + return renderTemplate(sdkID, cfg, false) +} + +// RenderTemplateForEntry renders the initialization code for the given SDK in the +// module syntax that runs in entryPath. +func RenderTemplateForEntry(sdkID, entryPath string, cfg InitConfig) (string, error) { + return renderTemplate(sdkID, cfg, entryNeedsESM(entryPath)) +} + +func renderTemplate(sdkID string, cfg InitConfig, esm bool) (string, error) { info, ok := sdkTemplates[sdkID] if !ok { return "", fmt.Errorf("no initialization template for SDK %q; see docs: %s", sdkID, GetDocsURL(sdkID)) } - content, err := initTemplateFiles.ReadFile("sdk_init_templates/" + info.TemplateFile) + templateFile := info.TemplateFile + if esm && info.ESMTemplateFile != "" { + templateFile = info.ESMTemplateFile + } + + content, err := initTemplateFiles.ReadFile("sdk_init_templates/" + templateFile) if err != nil { return "", fmt.Errorf("reading template for %s: %w", sdkID, err) } @@ -207,7 +230,7 @@ func (i Initializer) InjectIntoFile(sdkID, filePath string, cfg InitConfig) (*In }, nil } - rendered, err := RenderTemplate(sdkID, cfg) + rendered, err := RenderTemplateForEntry(sdkID, filePath, cfg) if err != nil { return nil, err } @@ -255,6 +278,45 @@ func (i Initializer) InjectIntoFile(sdkID, filePath string, cfg InitConfig) (*In return &InitResult{SDKID: sdkID, FilePath: filePath, Success: true}, nil } +// entryNeedsESM reports whether code written into entryPath has to use ESM import +// syntax. The extension decides it outright for the explicit cases; a plain .js +// entry depends on the enclosing package's "type" field. Detection points Node +// projects at TypeScript and ESM entry points such as Next.js instrumentation.ts +// and NestJS src/main.ts, where a CommonJS require does not run. +func entryNeedsESM(entryPath string) bool { + switch strings.ToLower(filepath.Ext(entryPath)) { + case ".mjs", ".mts", ".ts", ".tsx": + return true + case ".cjs", ".cts": + return false + } + return packageIsESM(entryPath) +} + +// packageIsESM reports whether the nearest package.json above entryPath declares +// "type": "module", which makes every plain .js file in the package ESM. +func packageIsESM(entryPath string) bool { + dir := filepath.Dir(entryPath) + for { + content, err := os.ReadFile(filepath.Join(dir, "package.json")) + if err == nil { + var pkg struct { + Type string `json:"type"` + } + if json.Unmarshal(content, &pkg) == nil { + return pkg.Type == "module" + } + return false + } + + parent := filepath.Dir(dir) + if parent == dir { + return false + } + dir = parent + } +} + // encodingCookie matches the encoding declaration Python (PEP 263) and Ruby only // honor on the first or second line of a file. var encodingCookie = regexp.MustCompile(`^[ \t\f]*#.*coding[:=][ \t]*[-_.a-zA-Z0-9]+`) diff --git a/internal/setup/initializer_test.go b/internal/setup/initializer_test.go index 6c42dd30..336adcce 100644 --- a/internal/setup/initializer_test.go +++ b/internal/setup/initializer_test.go @@ -182,6 +182,89 @@ func TestInjectIntoFile_KeepsPrologueFirst(t *testing.T) { } } +// A CommonJS require does not run in an ESM or TypeScript entry point, and those are +// exactly what detection picks for Next.js and NestJS. Injecting the wrong module +// syntax reports success on code that fails at startup. +func TestInjectIntoFile_MatchesEntryModuleSyntax(t *testing.T) { + tests := []struct { + name string + entry string + packageJSON string + wantImport string + notImport string + }{ + { + name: "typescript entry uses import", + entry: "src/main.ts", + wantImport: "import * as LaunchDarkly from '@launchdarkly/node-server-sdk'", + notImport: "require(", + }, + { + name: "next instrumentation uses import", + entry: "instrumentation.ts", + wantImport: "import * as LaunchDarkly", + notImport: "require(", + }, + { + name: "mjs entry uses import", + entry: "index.mjs", + wantImport: "import * as LaunchDarkly", + notImport: "require(", + }, + { + name: "plain js in a module package uses import", + entry: "index.js", + packageJSON: `{"name":"app","type":"module"}`, + wantImport: "import * as LaunchDarkly", + notImport: "require(", + }, + { + name: "plain js in a commonjs package uses require", + entry: "index.js", + packageJSON: `{"name":"app"}`, + wantImport: "require('@launchdarkly/node-server-sdk')", + notImport: "import * as", + }, + { + name: "cjs entry uses require even in a module package", + entry: "index.cjs", + packageJSON: `{"name":"app","type":"module"}`, + wantImport: "require('@launchdarkly/node-server-sdk')", + notImport: "import * as", + }, + { + name: "js entry with no package.json uses require", + entry: "index.js", + wantImport: "require('@launchdarkly/node-server-sdk')", + notImport: "import * as", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + if tt.packageJSON != "" { + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(tt.packageJSON), 0644)) + } + filePath := filepath.Join(dir, tt.entry) + require.NoError(t, os.MkdirAll(filepath.Dir(filePath), 0755)) + + initializer := Initializer{} + result, err := initializer.InjectIntoFile("node-server", filePath, InitConfig{ + SDKKey: "test-key", + FlagKey: "test-flag", + }) + require.NoError(t, err) + require.True(t, result.Success) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Contains(t, string(content), tt.wantImport) + assert.NotContains(t, string(content), tt.notImport) + }) + } +} + func TestInjectIntoFile_NewFile_OmitsSeparator(t *testing.T) { sdks := []struct { sdkID string diff --git a/internal/setup/installer_test.go b/internal/setup/installer_test.go index 7444e339..59a1b1ed 100644 --- a/internal/setup/installer_test.go +++ b/internal/setup/installer_test.go @@ -395,6 +395,11 @@ func TestInstallArgs_PackageMatchesTemplateImport(t *testing.T) { require.NoError(t, err) assert.Contains(t, rendered, "'"+tt.wantImport+"'", "template must import the package we install") + + esm, err := RenderTemplateForEntry(tt.sdkID, "src/main.ts", InitConfig{}) + require.NoError(t, err) + assert.Contains(t, esm, "'"+tt.wantImport+"'", + "ESM template must import the package we install") }) } } diff --git a/internal/setup/sdk_init_templates/node-server-esm.tmpl b/internal/setup/sdk_init_templates/node-server-esm.tmpl new file mode 100644 index 00000000..7d4fef58 --- /dev/null +++ b/internal/setup/sdk_init_templates/node-server-esm.tmpl @@ -0,0 +1,15 @@ +import * as LaunchDarkly from '@launchdarkly/node-server-sdk'; +// --- init --- +const ldClient = LaunchDarkly.init('{{.SDKKey}}'); + +const context = { + kind: 'user', + key: 'example-user-key', + name: 'Example User', +}; + +ldClient.on('ready', () => { + ldClient.variation('{{.FlagKey}}', context, false, (err, flagValue) => { + console.log(`Flag '{{.FlagKey}}' is ${flagValue}`); + }); +}); From 26827c01da78d7362804f8a3010fb9b238a19e45 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Tue, 4 Aug 2026 13:04:47 -0400 Subject: [PATCH 17/18] fix(setup): keep leading directives above injected imports Injecting imports after only the shebang and encoding cookie broke the other constructs a file has to open with. A Python __future__ import below the injected imports is a SyntaxError, a module docstring pushed down is demoted to a plain expression, and Ruby magic comments and a CommonJS 'use strict' are silently ignored once code precedes them. All of it was written with Success: true. Peel the shebang, the leading comment block, and the language's own leading constructs, then insert imports after them. Co-Authored-By: Claude Opus 5 (1M context) --- internal/setup/initializer.go | 137 +++++++++++++++++++++++------ internal/setup/initializer_test.go | 42 +++++++++ 2 files changed, 154 insertions(+), 25 deletions(-) diff --git a/internal/setup/initializer.go b/internal/setup/initializer.go index 94496cb9..4b225c6b 100644 --- a/internal/setup/initializer.go +++ b/internal/setup/initializer.go @@ -266,7 +266,7 @@ func (i Initializer) InjectIntoFile(sdkID, filePath string, cfg InitConfig) (*In content := string(existing) if importSection != "" { - prologue, body := splitPrologue(content) + prologue, body := splitPrologue(sdkID, content) content = prologue + importSection + "\n" + body } content = content + "\n\n" + initSection + "\n" @@ -317,39 +317,126 @@ func packageIsESM(entryPath string) bool { } } -// encodingCookie matches the encoding declaration Python (PEP 263) and Ruby only -// honor on the first or second line of a file. -var encodingCookie = regexp.MustCompile(`^[ \t\f]*#.*coding[:=][ \t]*[-_.a-zA-Z0-9]+`) - -// splitPrologue peels off the leading lines that have to stay at the top of a file: -// a shebang, which stops the file being executable if anything precedes it, and an -// encoding cookie, which is ignored once pushed past the second line. Imports go -// after the prologue rather than at byte 0, so injecting into an entry point like -// Django's manage.py leaves it runnable. -func splitPrologue(content string) (prologue, rest string) { - rest = content - if strings.HasPrefix(rest, "#!") { - var line string - line, rest = takeLine(rest) - prologue += line +// splitPrologue peels off the leading lines that have to stay above injected +// imports. Every language keeps its shebang, since anything above it stops the file +// being executable, and its leading comment block, which is the only place Python +// encoding cookies (PEP 263) and Ruby magic comments like frozen_string_literal are +// read. Python additionally keeps its module docstring, which is demoted to a plain +// expression if anything precedes it, and its __future__ imports, which are a +// SyntaxError below other code. CommonJS keeps a 'use strict' directive, which is +// ignored unless it is the first statement. +func splitPrologue(sdkID, content string) (prologue, rest string) { + lines := splitLines(content) + + end := 0 + if len(lines) > 0 && strings.HasPrefix(lines[0], "#!") { + end = 1 } - if encodingCookie.MatchString(rest) { - var line string - line, rest = takeLine(rest) - prologue += line + end = skipCommentHeader(lines, end) + + switch sdkID { + case "python-server-sdk": + end = skipPythonHeader(lines, end) + case "node-server": + end = skipUseStrict(lines, end) } + + prologue = strings.Join(lines[:end], "") + rest = strings.Join(lines[end:], "") if prologue != "" && !strings.HasSuffix(prologue, "\n") { prologue += "\n" } return prologue, rest } -// takeLine splits off the first line of s, keeping the newline with the line. -func takeLine(s string) (line, remainder string) { - if i := strings.IndexByte(s, '\n'); i >= 0 { - return s[:i+1], s[i+1:] +// skipCommentHeader advances past blank lines and whole-line comments. +func skipCommentHeader(lines []string, i int) int { + for i < len(lines) { + t := strings.TrimSpace(lines[i]) + if t != "" && !strings.HasPrefix(t, "#") && !strings.HasPrefix(t, "//") { + return i + } + i++ + } + return i +} + +// pythonStringStart matches the opening quote of a module docstring, allowing the +// string prefixes Python permits before it. +var pythonStringStart = regexp.MustCompile(`^[rRuUbBfF]{0,2}("""|'''|"|')`) + +// skipPythonHeader advances past a module docstring and any __future__ imports, +// along with the comments and blank lines between them. +func skipPythonHeader(lines []string, i int) int { + docstringSeen := false + for i < len(lines) { + t := strings.TrimSpace(lines[i]) + switch { + case t == "" || strings.HasPrefix(t, "#"): + i++ + case strings.HasPrefix(t, "from __future__ import"): + i = skipStatement(lines, i) + case !docstringSeen && pythonStringStart.MatchString(t): + docstringSeen = true + quote := pythonStringStart.FindStringSubmatch(t)[1] + body := t[strings.Index(t, quote)+len(quote):] + i++ + if strings.Contains(body, quote) { + continue // the docstring opened and closed on one line + } + for i < len(lines) && !strings.Contains(lines[i], quote) { + i++ + } + if i < len(lines) { + i++ + } + default: + return i + } + } + return i +} + +// skipStatement advances past a statement that may continue over several lines with +// parentheses or a trailing backslash. +func skipStatement(lines []string, i int) int { + depth := 0 + for i < len(lines) { + line := strings.TrimRight(lines[i], "\n") + depth += strings.Count(line, "(") - strings.Count(line, ")") + continued := strings.HasSuffix(line, `\`) + i++ + if depth <= 0 && !continued { + break + } + } + return i +} + +// skipUseStrict advances past a 'use strict' directive. +func skipUseStrict(lines []string, i int) int { + if i >= len(lines) { + return i + } + switch strings.TrimSuffix(strings.TrimSpace(lines[i]), ";") { + case `'use strict'`, `"use strict"`: + return i + 1 + } + return i +} + +// splitLines splits s into lines, keeping each newline with the line it ends. +func splitLines(s string) []string { + var lines []string + for s != "" { + i := strings.IndexByte(s, '\n') + if i < 0 { + return append(lines, s) + } + lines = append(lines, s[:i+1]) + s = s[i+1:] } - return s, "" + return lines } // joinSnippet recombines the import and init sections into a single human-readable diff --git a/internal/setup/initializer_test.go b/internal/setup/initializer_test.go index 336adcce..f5835bfd 100644 --- a/internal/setup/initializer_test.go +++ b/internal/setup/initializer_test.go @@ -157,6 +157,48 @@ func TestInjectIntoFile_KeepsPrologueFirst(t *testing.T) { existing: "#!/usr/bin/env node", wantHead: "#!/usr/bin/env node\n", }, + { + name: "future imports stay above other imports", + sdkID: "python-server-sdk", + fileName: "app.py", + existing: "from __future__ import annotations\n\nimport os\n", + wantHead: "from __future__ import annotations\n", + }, + { + name: "docstring stays first and future imports follow it", + sdkID: "python-server-sdk", + fileName: "svc.py", + existing: "#!/usr/bin/env python3\n\"\"\"Service entry point.\"\"\"\n\nfrom __future__ import annotations\n\nimport os\n", + wantHead: "#!/usr/bin/env python3\n\"\"\"Service entry point.\"\"\"\n\nfrom __future__ import annotations\n", + }, + { + name: "multi-line docstring stays first", + sdkID: "python-server-sdk", + fileName: "multi.py", + existing: "'''\nService entry point.\n'''\nimport os\n", + wantHead: "'''\nService entry point.\n'''\n", + }, + { + name: "parenthesized future import is kept whole", + sdkID: "python-server-sdk", + fileName: "paren.py", + existing: "from __future__ import (\n annotations,\n generator_stop,\n)\nimport os\n", + wantHead: "from __future__ import (\n annotations,\n generator_stop,\n)\n", + }, + { + name: "ruby magic comment stays above code", + sdkID: "ruby-server-sdk", + fileName: "main.rb", + existing: "#!/usr/bin/env ruby\n# frozen_string_literal: true\n\nputs 'hi'\n", + wantHead: "#!/usr/bin/env ruby\n# frozen_string_literal: true\n", + }, + { + name: "use strict stays the first statement", + sdkID: "node-server", + fileName: "index.js", + existing: "'use strict';\nconsole.log('hi');\n", + wantHead: "'use strict';\n", + }, } for _, tt := range tests { From 1d4cf46ac3e174b4822224aae916e30d73a10048 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Tue, 4 Aug 2026 13:22:57 -0400 Subject: [PATCH 18/18] fix(setup): treat block comments as part of the leading header A /* */ license or JSDoc header stopped the prologue scan, so imports landed above it and above the 'use strict' that followed, silently dropping the file out of strict mode. A directive with a trailing same-line comment was missed for the same reason. Co-Authored-By: Claude Opus 5 (1M context) --- internal/setup/initializer.go | 25 +++++++++++++++++++++---- internal/setup/initializer_test.go | 21 +++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/internal/setup/initializer.go b/internal/setup/initializer.go index 4b225c6b..8ce42e2d 100644 --- a/internal/setup/initializer.go +++ b/internal/setup/initializer.go @@ -349,14 +349,24 @@ func splitPrologue(sdkID, content string) (prologue, rest string) { return prologue, rest } -// skipCommentHeader advances past blank lines and whole-line comments. +// skipCommentHeader advances past blank lines and comments, including the /* */ +// block a license or JSDoc header usually opens with. func skipCommentHeader(lines []string, i int) int { for i < len(lines) { t := strings.TrimSpace(lines[i]) - if t != "" && !strings.HasPrefix(t, "#") && !strings.HasPrefix(t, "//") { + switch { + case t == "" || strings.HasPrefix(t, "#") || strings.HasPrefix(t, "//"): + i++ + case strings.HasPrefix(t, "/*"): + for i < len(lines) && !strings.Contains(lines[i], "*/") { + i++ + } + if i < len(lines) { + i++ + } + default: return i } - i++ } return i } @@ -418,7 +428,14 @@ func skipUseStrict(lines []string, i int) int { if i >= len(lines) { return i } - switch strings.TrimSuffix(strings.TrimSpace(lines[i]), ";") { + directive := lines[i] + if j := strings.Index(directive, "//"); j >= 0 { + directive = directive[:j] + } + if j := strings.Index(directive, "/*"); j >= 0 { + directive = directive[:j] + } + switch strings.TrimSuffix(strings.TrimSpace(directive), ";") { case `'use strict'`, `"use strict"`: return i + 1 } diff --git a/internal/setup/initializer_test.go b/internal/setup/initializer_test.go index f5835bfd..a99c571c 100644 --- a/internal/setup/initializer_test.go +++ b/internal/setup/initializer_test.go @@ -199,6 +199,27 @@ func TestInjectIntoFile_KeepsPrologueFirst(t *testing.T) { existing: "'use strict';\nconsole.log('hi');\n", wantHead: "'use strict';\n", }, + { + name: "use strict below a block comment header stays first", + sdkID: "node-server", + fileName: "licensed.js", + existing: "/*\n * Copyright someone.\n */\n'use strict';\nconsole.log('hi');\n", + wantHead: "/*\n * Copyright someone.\n */\n'use strict';\n", + }, + { + name: "single-line block comment header stays first", + sdkID: "node-server", + fileName: "oneline.js", + existing: "/* @flow */\n'use strict';\nconsole.log('hi');\n", + wantHead: "/* @flow */\n'use strict';\n", + }, + { + name: "use strict with a trailing comment stays first", + sdkID: "node-server", + fileName: "trailing.js", + existing: "'use strict'; // required\nconsole.log('hi');\n", + wantHead: "'use strict'; // required\n", + }, } for _, tt := range tests {