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