From fddc6de42ebacb59e474bea74843d2dd502ebfd6 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Tue, 4 Aug 2026 12:40:01 -0400 Subject: [PATCH 1/5] chore(setup): add SDK/project detection library (#749) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(setup): add SDK/project detection library Co-Authored-By: Claude Opus 4.8 (1M context) * 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) * fix(setup): keep the Next.js SDK key out of the browser bundle 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) * 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) * fix(setup): detect backend manifests before package.json 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- internal/setup/detector.go | 533 +++++++++++++++++ internal/setup/detector_ruby_test.go | 33 + internal/setup/detector_shapes_test.go | 385 ++++++++++++ internal/setup/detector_test.go | 794 +++++++++++++++++++++++++ 4 files changed, 1745 insertions(+) create mode 100644 internal/setup/detector.go create mode 100644 internal/setup/detector_ruby_test.go create mode 100644 internal/setup/detector_shapes_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..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) +} From d422630f51fe4562bb6a4d2f53631b0bc778bd8d Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Tue, 4 Aug 2026 12:40:38 -0400 Subject: [PATCH 2/5] chore(setup): add SDK installer library (#750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(setup): add SDK/project detection library Co-Authored-By: Claude Opus 4.8 (1M context) * 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) * fix(setup): keep the Next.js SDK key out of the browser bundle 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) * 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) * fix(setup): detect backend manifests before package.json 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) * 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) * 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) * chore(setup): add SDK installer library Co-Authored-By: Claude Opus 4.8 (1M context) * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- internal/setup/installer.go | 356 +++++++++++++++++++++++++++++ internal/setup/installer_test.go | 374 +++++++++++++++++++++++++++++++ 2 files changed, 730 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..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)) +} From 6fd1dd3509c5ee35d7da8bfcccdf969ca24aef5e Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Wed, 5 Aug 2026 14:06:47 -0400 Subject: [PATCH 3/5] chore(setup): add SDK init/injection library and templates (#751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(setup): add SDK/project detection library Co-Authored-By: Claude Opus 4.8 (1M context) * 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) * fix(setup): keep the Next.js SDK key out of the browser bundle 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) * 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) * fix(setup): detect backend manifests before package.json 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) * 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) * 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) * chore(setup): add SDK installer library Co-Authored-By: Claude Opus 4.8 (1M context) * 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) * 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) * chore(setup): add SDK init/injection library and templates Co-Authored-By: Claude Opus 4.8 (1M context) * 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) * 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) * fix(setup): build the Swift context without try 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) * 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- internal/setup/initializer.go | 484 +++++++++++++++++ internal/setup/initializer_test.go | 497 ++++++++++++++++++ internal/setup/installer_test.go | 31 ++ .../setup/sdk_init_templates/android.tmpl | 11 + .../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 + .../sdk_init_templates/node-server-esm.tmpl | 15 + .../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 | 4 + .../sdk_init_templates/ruby-server-sdk.tmpl | 12 + .../sdk_init_templates/swift-client-sdk.tmpl | 6 + 15 files changed, 1146 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-esm.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..8ce42e2d --- /dev/null +++ b/internal/setup/initializer.go @@ -0,0 +1,484 @@ +package setup + +import ( + "bytes" + "embed" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "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, +} + +// 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{} + +// 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{ + "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": {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"}, + "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", ESMTemplateFile: "node-server-esm.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, 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)) + } + + 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) + } + + 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 := RenderTemplateForEntry(sdkID, filePath, 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 != "" { + prologue, body := splitPrologue(sdkID, content) + content = prologue + importSection + "\n" + body + } + 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 +} + +// 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 + } +} + +// 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 + } + 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 +} + +// 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]) + 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 + } + } + 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 + } + 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 + } + 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 lines +} + +// 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..a99c571c --- /dev/null +++ b/internal/setup/initializer_test.go @@ -0,0 +1,497 @@ +package setup + +import ( + "os" + "path/filepath" + "strings" + "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")) + // 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("nonexistent-sdk")) +} + +func TestSupportedSDKIDs(t *testing.T) { + ids := SupportedSDKIDs() + 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") +} + +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") +} + +// 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", + }, + { + 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", + }, + { + 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 { + 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") + }) + } +} + +// 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 + 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")) +} + +// 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)`, + // 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 + // 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") + assert.NotContains(t, result, "try ", + "a snippet pasted into a non-throwing function cannot use try") + }) + } +} diff --git a/internal/setup/installer_test.go b/internal/setup/installer_test.go index 085913fe..59a1b1ed 100644 --- a/internal/setup/installer_test.go +++ b/internal/setup/installer_test.go @@ -372,3 +372,34 @@ 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") + + 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/android.tmpl b/internal/setup/sdk_init_templates/android.tmpl new file mode 100644 index 00000000..897b97b9 --- /dev/null +++ b/internal/setup/sdk_init_templates/android.tmpl @@ -0,0 +1,11 @@ +import com.launchdarkly.sdk.android.*; +import com.launchdarkly.sdk.android.LDConfig.Builder.AutoEnvAttributes; +import com.launchdarkly.sdk.*; +// --- init --- +LDConfig ldConfig = new LDConfig.Builder(AutoEnvAttributes.Enabled) + .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-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}`); + }); +}); 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..d31a8e5b --- /dev/null +++ b/internal/setup/sdk_init_templates/react-native.tmpl @@ -0,0 +1,4 @@ +import { AutoEnvAttributes, ReactNativeLDClient } from '@launchdarkly/react-native-client-sdk'; +// --- init --- +const featureClient = new ReactNativeLDClient('{{.MobileKey}}', AutoEnvAttributes.Enabled); +await featureClient.identify({ kind: 'user', key: 'example-user-key', name: 'Example User' }); 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..a9068f4f --- /dev/null +++ b/internal/setup/sdk_init_templates/swift-client-sdk.tmpl @@ -0,0 +1,6 @@ +import LaunchDarkly +// --- init --- +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 a1a444f8c3313582b20dae512a92ba2529ec123f Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Wed, 5 Aug 2026 15:30:55 -0400 Subject: [PATCH 4/5] chore(setup): add setup flag verifier (#752) * chore(setup): add setup flag verifier Co-Authored-By: Claude Opus 4.8 (1M context) * fix(setup): narrow the verify check to the configured SDK sdk-active answers for any SDK that initialized in the environment in the past seven days, so verification passed on unrelated traffic and a project already using LaunchDarkly reported success without the new SDK ever connecting. Filter on sdk_name, mapping the setup SDK id to the name the SDK reports itself as. An unknown id sends no filter rather than one that can never match, and VerifyResult records which name was used so an unnarrowed check cannot present itself as narrowed. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- internal/setup/verifier.go | 114 ++++++++++++++++++++++++++++++++ internal/setup/verifier_test.go | 90 +++++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 internal/setup/verifier.go create mode 100644 internal/setup/verifier_test.go diff --git a/internal/setup/verifier.go b/internal/setup/verifier.go new file mode 100644 index 00000000..25e1c290 --- /dev/null +++ b/internal/setup/verifier.go @@ -0,0 +1,114 @@ +package setup + +import ( + "encoding/json" + "fmt" + "net/url" + "time" + + "github.com/launchdarkly/ldcli/internal/resources" +) + +// VerifyResult describes the outcome of verifying SDK connectivity. +type VerifyResult struct { + Active bool `json:"active"` + Attempts int `json:"attempts"` + Elapsed string `json:"elapsed"` + // SDKName is the sdk_name the check was filtered on, empty when the SDK id had + // no known reported name. Empty means Active reports any SDK in the + // environment, not necessarily the one setup just configured. + SDKName string `json:"sdk_name,omitempty"` +} + +// reportedSDKNames maps a setup SDK id to the sdk_name that SDK identifies itself +// as in the events the sdk-active endpoint aggregates. Only the SDKs that reach +// verification need an entry: verify runs after init injected runnable code, which +// only happens for the append-safe SDKs. +var reportedSDKNames = map[string]string{ + "node-server": "node-server-sdk", + "python-server-sdk": "python-server-sdk", + "ruby-server-sdk": "ruby-server-sdk", +} + +// ReportedSDKName returns the sdk_name to filter sdk-active on for sdkID, or an +// empty string when it is unknown and the check cannot be narrowed. +func ReportedSDKName(sdkID string) string { + return reportedSDKNames[sdkID] +} + +// Verifier polls the sdk-active endpoint until the SDK reports as active or a timeout is reached. +type Verifier struct { + Client resources.Client + Interval time.Duration + Timeout time.Duration +} + +// DefaultVerifier returns a Verifier with sensible defaults. +func DefaultVerifier(client resources.Client) *Verifier { + return &Verifier{ + Client: client, + Interval: 5 * time.Second, + Timeout: 120 * time.Second, + } +} + +// Verify polls GET /api/v2/projects/{project}/environments/{env}/sdk-active until +// active=true, narrowed to the SDK sdkID reports itself as. Without the filter the +// endpoint answers for any SDK active in the environment in the past seven days, +// which reports success for a project that was already using LaunchDarkly. +func (v *Verifier) Verify(accessToken, baseURI, projectKey, envKey, sdkID string) (*VerifyResult, error) { + start := time.Now() + deadline := start.Add(v.Timeout) + attempts := 0 + sdkName := ReportedSDKName(sdkID) + + for { + attempts++ + active, err := v.checkOnce(accessToken, baseURI, projectKey, envKey, sdkName) + if err != nil { + return nil, err + } + if active { + return &VerifyResult{ + Active: true, + Attempts: attempts, + Elapsed: time.Since(start).Round(time.Millisecond).String(), + SDKName: sdkName, + }, nil + } + + if time.Now().After(deadline) { + return &VerifyResult{ + Active: false, + Attempts: attempts, + Elapsed: time.Since(start).Round(time.Millisecond).String(), + SDKName: sdkName, + }, nil + } + + time.Sleep(v.Interval) + } +} + +func (v *Verifier) checkOnce(accessToken, baseURI, projectKey, envKey, sdkName string) (bool, error) { + path, _ := url.JoinPath(baseURI, "api/v2/projects", projectKey, "environments", envKey, "sdk-active") + + var query url.Values + if sdkName != "" { + query = url.Values{"sdk_name": []string{sdkName}} + } + + res, err := v.Client.MakeRequest(accessToken, "GET", path, "application/json", query, nil, false) + if err != nil { + return false, fmt.Errorf("checking sdk-active: %w", err) + } + + var resp struct { + Active bool `json:"active"` + } + if err := json.Unmarshal(res, &resp); err != nil { + return false, fmt.Errorf("parsing sdk-active response: %w", err) + } + + return resp.Active, nil +} diff --git a/internal/setup/verifier_test.go b/internal/setup/verifier_test.go new file mode 100644 index 00000000..d820971f --- /dev/null +++ b/internal/setup/verifier_test.go @@ -0,0 +1,90 @@ +package setup + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/resources" +) + +func TestVerify_Active(t *testing.T) { + client := &resources.MockClient{ + Response: []byte(`{"active": true}`), + } + verifier := &Verifier{ + Client: client, + Interval: 10 * time.Millisecond, + Timeout: 1 * time.Second, + } + + result, err := verifier.Verify("token", "https://app.launchdarkly.com", "proj", "env", "node-server") + require.NoError(t, err) + assert.True(t, result.Active) + assert.Equal(t, 1, result.Attempts) +} + +func TestVerify_InactiveTimesOut(t *testing.T) { + client := &resources.MockClient{ + Response: []byte(`{"active": false}`), + } + verifier := &Verifier{ + Client: client, + Interval: 10 * time.Millisecond, + Timeout: 50 * time.Millisecond, + } + + result, err := verifier.Verify("token", "https://app.launchdarkly.com", "proj", "env", "node-server") + require.NoError(t, err) + assert.False(t, result.Active) + assert.Greater(t, result.Attempts, 1) +} + +// Unfiltered, sdk-active answers for any SDK active in the environment in the past +// seven days, so it reports success for a project that already used LaunchDarkly. +func TestVerify_FiltersOnTheConfiguredSDK(t *testing.T) { + client := &resources.MockClient{Response: []byte(`{"active": true}`)} + verifier := &Verifier{Client: client, Interval: time.Millisecond, Timeout: time.Second} + + result, err := verifier.Verify("token", "https://app.launchdarkly.com", "proj", "env", "ruby-server-sdk") + + require.NoError(t, err) + assert.Equal(t, "ruby-server-sdk", client.Query.Get("sdk_name")) + assert.Equal(t, "ruby-server-sdk", result.SDKName) +} + +// The setup id and the name the SDK reports itself as are not always the same. +func TestVerify_UsesTheReportedSDKName(t *testing.T) { + client := &resources.MockClient{Response: []byte(`{"active": true}`)} + verifier := &Verifier{Client: client, Interval: time.Millisecond, Timeout: time.Second} + + _, err := verifier.Verify("token", "https://app.launchdarkly.com", "proj", "env", "node-server") + + require.NoError(t, err) + assert.Equal(t, "node-server-sdk", client.Query.Get("sdk_name")) +} + +// An id with no known reported name must not send a filter that can never match. +func TestVerify_UnknownSDK_SendsNoFilter(t *testing.T) { + client := &resources.MockClient{Response: []byte(`{"active": true}`)} + verifier := &Verifier{Client: client, Interval: time.Millisecond, Timeout: time.Second} + + result, err := verifier.Verify("token", "https://app.launchdarkly.com", "proj", "env", "made-up-sdk") + + require.NoError(t, err) + assert.Empty(t, client.Query.Get("sdk_name")) + assert.Empty(t, result.SDKName, "an unnarrowed check must not claim it was narrowed") +} + +// Every SDK that init writes runnable code for reaches verification, so each needs +// a reported name or its check silently falls back to the whole environment. +func TestReportedSDKName_CoversEverySDKThatVerifies(t *testing.T) { + for _, sdk := range KnownSDKs { + if !InjectsInPlace(sdk.ID) { + continue + } + assert.NotEmpty(t, ReportedSDKName(sdk.ID), "%s reaches verify with no sdk_name", sdk.ID) + } +} From 9276c4c1b22e5841630e755e1edfeb406ec9c77a Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Thu, 6 Aug 2026 13:59:30 -0400 Subject: [PATCH 5/5] feat: add guided setup command (#753) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add guided setup command ldcli setup walks a project through installing a LaunchDarkly SDK: detect the language and package manager, pick a project and environment, install the SDK with the project's own tool, create a flag, write or show initialization code, then poll until the SDK connects. Orchestration lives in internal/setup.Service so the wizard UI and the detect/install/init subcommands share one path. The wizard is split into model, update, view, and commands rather than one file. Environments gains List so the wizard can offer a choice of environments. Co-Authored-By: Claude Opus 5 (1M context) * feat(setup): copy wizard code blocks with c (#771) * REL-15243: add a copy key for wizard code blocks Code blocks are drawn with a left gutter bar, so selecting one by hand copies the gutter characters and the padding lipgloss squares the block off with. The wizard also owns the alternate screen, so the snippet is not in scrollback once it exits. Pressing c writes the raw content to the system clipboard with OSC 52, preferring the snippet over the install command when a screen shows both. Co-Authored-By: Claude Opus 5 (1M context) * REL-15243: copy through the OS clipboard before the terminal OSC 52 alone left the key unreliable: terminals are not required to implement it, Apple Terminal does not, and support cannot be queried, so the confirmation claimed a copy that may never have happened. The OS clipboard works in any terminal and returns an error, so try it first and keep OSC 52 for when it fails — which is the SSH case, where the OS clipboard belongs to the wrong machine. Word the two outcomes apart, since only the first can be confirmed. Co-authored-by: Claude Opus 5 (1M context) --- cmd/root.go | 76 ++- cmd/root_test.go | 34 ++ cmd/setup/commands.go | 201 +++++++ cmd/setup/copy_test.go | 279 ++++++++++ cmd/setup/detect.go | 66 +++ cmd/setup/init.go | 85 +++ cmd/setup/install.go | 107 ++++ cmd/setup/model.go | 203 ++++++++ cmd/setup/setup.go | 57 ++ cmd/setup/setup_test.go | 414 +++++++++++++++ cmd/setup/styles.go | 52 ++ cmd/setup/update.go | 378 ++++++++++++++ cmd/setup/view.go | 314 +++++++++++ cmd/setup/wizard_test.go | 751 +++++++++++++++++++++++++++ cmd/templates.go | 3 +- cmd/templates_test.go | 32 ++ go.mod | 4 +- internal/environments/client.go | 32 ++ internal/environments/mock_client.go | 13 + internal/flags/client.go | 35 +- internal/flags/mock_client.go | 7 + internal/projects/mock.go | 4 +- internal/projects/projects.go | 14 +- internal/setup/initializer.go | 52 +- internal/setup/initializer_test.go | 59 +++ internal/setup/service.go | 201 +++++++ internal/setup/service_test.go | 238 +++++++++ 27 files changed, 3683 insertions(+), 28 deletions(-) create mode 100644 cmd/setup/commands.go create mode 100644 cmd/setup/copy_test.go create mode 100644 cmd/setup/detect.go create mode 100644 cmd/setup/init.go create mode 100644 cmd/setup/install.go create mode 100644 cmd/setup/model.go create mode 100644 cmd/setup/setup.go create mode 100644 cmd/setup/setup_test.go create mode 100644 cmd/setup/styles.go create mode 100644 cmd/setup/update.go create mode 100644 cmd/setup/view.go create mode 100644 cmd/setup/wizard_test.go create mode 100644 cmd/templates_test.go create mode 100644 internal/setup/service.go create mode 100644 internal/setup/service_test.go diff --git a/cmd/root.go b/cmd/root.go index 5c0739a7..a8f2110e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,8 +22,9 @@ import ( flagscmd "github.com/launchdarkly/ldcli/cmd/flags" logincmd "github.com/launchdarkly/ldcli/cmd/login" memberscmd "github.com/launchdarkly/ldcli/cmd/members" - sdkactivecmd "github.com/launchdarkly/ldcli/cmd/sdk_active" resourcecmd "github.com/launchdarkly/ldcli/cmd/resources" + sdkactivecmd "github.com/launchdarkly/ldcli/cmd/sdk_active" + setupcmd "github.com/launchdarkly/ldcli/cmd/setup" signupcmd "github.com/launchdarkly/ldcli/cmd/signup" sourcemapscmd "github.com/launchdarkly/ldcli/cmd/sourcemaps" symbolscmd "github.com/launchdarkly/ldcli/cmd/symbols" @@ -37,6 +38,7 @@ import ( "github.com/launchdarkly/ldcli/internal/members" "github.com/launchdarkly/ldcli/internal/projects" "github.com/launchdarkly/ldcli/internal/resources" + "github.com/launchdarkly/ldcli/internal/setup" ) type APIClients struct { @@ -46,6 +48,8 @@ type APIClients struct { MembersClient members.Client ProjectsClient projects.Client ResourcesClient resources.Client + Detector setup.Detector + Installer setup.Installer } type Command interface { @@ -100,6 +104,33 @@ func forceTTYDefaultOutput(getenv func(string) string) bool { return lookup("FORCE_TTY") != "" || lookup("LD_FORCE_TTY") != "" } +// authExemptCommands are commands (and their subcommands) that don't call the +// LaunchDarkly API and so don't require --access-token. +var authExemptCommands = map[string]bool{ + "completion": true, + "config": true, + "help": true, + "login": true, + "setup": true, + "signup": true, + "whoami": true, +} + +// clearAccessTokenRequirement drops the "required" annotation on --access-token +// for auth-exempt commands, so cobra's required-flag check doesn't reject them. +// We clear the annotation rather than setting DisableFlagParsing, which would +// also suppress validation of the subcommand's own required flags. +func clearAccessTokenRequirement(cmd *cobra.Command) { + for c := cmd; c != nil; c = c.Parent() { + if authExemptCommands[c.Name()] { + if f := cmd.Flags().Lookup(cliflags.AccessTokenFlag); f != nil { + delete(f.Annotations, cobra.BashCompOneRequiredFlag) + } + return + } + } +} + // NewRootCommand constructs the ldcli root command tree. // // isTerminal must be non-nil; it should reflect whether stdout is a TTY (see Execute). When it @@ -126,23 +157,7 @@ func NewRootCommand( Long: "LaunchDarkly CLI to control your feature flags", Version: version, PersistentPreRun: func(cmd *cobra.Command, args []string) { - // disable required flags when running certain commands - for _, name := range []string{ - "completion", - "config", - "help", - "login", - "signup", - "whoami", - } { - if cmd.HasParent() && cmd.Parent().Name() == name { - cmd.DisableFlagParsing = true - } - if cmd.Name() == name { - cmd.DisableFlagParsing = true - } - } - + clearAccessTokenRequirement(cmd) }, Annotations: make(map[string]string), // Handle errors differently based on type. @@ -254,7 +269,30 @@ func NewRootCommand( configCmd := configcmd.NewConfigCmd(configService, analyticsTrackerFn) cmd.AddCommand(configCmd.Cmd()) - cmd.AddCommand(NewQuickStartCmd(analyticsTrackerFn, clients.EnvironmentsClient, clients.FlagsClient)) + detector := clients.Detector + if detector == nil { + detector = setup.FileDetector{} + } + installer := clients.Installer + if installer == nil { + installer = setup.PackageInstaller{} + } + cmd.AddCommand(setupcmd.NewSetupCmd( + analyticsTrackerFn, + setup.Clients{ + Projects: clients.ProjectsClient, + Environments: clients.EnvironmentsClient, + Flags: clients.FlagsClient, + Resources: clients.ResourcesClient, + }, + detector, + installer, + )) + quickStartCmd := NewQuickStartCmd(analyticsTrackerFn, clients.EnvironmentsClient, clients.FlagsClient) + quickStartCmd.Use = "quickstart" + quickStartCmd.Hidden = true + quickStartCmd.Deprecated = "use 'ldcli setup' for the new guided setup experience" + cmd.AddCommand(quickStartCmd) cmd.AddCommand(logincmd.NewLoginCmd(clients.ResourcesClient)) cmd.AddCommand(signupcmd.NewSignupCmd(analyticsTrackerFn)) cmd.AddCommand(resourcecmd.NewResourcesCmd()) diff --git a/cmd/root_test.go b/cmd/root_test.go index 5d3f6ef0..2b34ee65 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -338,3 +338,37 @@ func TestConfigOutputPrecedenceNonTTY(t *testing.T) { assert.Contains(t, string(out), "Key:") assert.Contains(t, string(out), "test-key") } + +// A rebase silently dropped the symbols registration once, so every top-level +// command the CLI ships is asserted here. +func TestNewRootCommand_RegistersTopLevelCommands(t *testing.T) { + rootCmd := newRootCmdWithTerminal(t, func() bool { return false }, nil) + c := rootCmd.Cmd() + // Execute wires this up; NewRootCommand does not. + c.InitDefaultCompletionCmd() + + registered := make(map[string]bool, len(c.Commands())) + for _, sub := range c.Commands() { + registered[sub.Name()] = true + } + + for _, name := range []string{ + "completion", + "config", + "dev-server", + "flags", + "login", + "members", + "projects", + "quickstart", + "resources", + "segments", + "setup", + "signup", + "sourcemaps", + "symbols", + "whoami", + } { + assert.True(t, registered[name], "%s is not registered on the root command", name) + } +} diff --git a/cmd/setup/commands.go b/cmd/setup/commands.go new file mode 100644 index 00000000..8f9e5405 --- /dev/null +++ b/cmd/setup/commands.go @@ -0,0 +1,201 @@ +package setup + +import ( + "fmt" + "io" + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" + "golang.org/x/term" + + "github.com/launchdarkly/ldcli/internal/setup" +) + +func (m wizardModel) fetchProjects() tea.Cmd { + return func() tea.Msg { + ps, err := m.svc.ListProjects(m.auth) + if err != nil { + return wizardErrMsg{err: err} + } + projects := make([]projectItem, len(ps)) + for i, p := range ps { + projects[i] = projectItem{key: p.Key, name: p.Name} + } + return projectsFetchedMsg{projects: projects} + } +} + +func (m wizardModel) fetchEnvironments() tea.Cmd { + // Read the selection here rather than in the goroutine, so the message reports + // what was asked for even after the model has moved on. + project := m.selectedProject + return func() tea.Msg { + es, err := m.svc.ListEnvironments(m.auth, project) + if err != nil { + return wizardErrMsg{err: err} + } + envs := make([]envItem, len(es)) + for i, e := range es { + envs[i] = envItem{key: e.Key, name: e.Name} + } + return envsFetchedMsg{project: project, environments: envs} + } +} + +func (m wizardModel) fetchEnvDetails() tea.Cmd { + project, env := m.selectedProject, m.selectedEnv + return func() tea.Msg { + keys, err := m.svc.EnvKeys(m.auth, project, env) + if err != nil { + return wizardErrMsg{err: err} + } + return envDetailsFetchedMsg{ + project: project, + env: env, + sdkKey: keys.SDKKey, + clientSideID: keys.ClientSideID, + mobileKey: keys.MobileKey, + } + } +} + +func (m wizardModel) runDetect() tea.Cmd { + return func() tea.Msg { + dir, err := os.Getwd() + if err != nil { + return wizardErrMsg{err: err} + } + result, err := m.svc.Detect(dir) + if err != nil { + return detectFailedMsg{} + } + return detectDoneMsg{result: result} + } +} + +func (m wizardModel) runInstall() tea.Cmd { + return func() tea.Msg { + dir, err := os.Getwd() + if err != nil { + return wizardErrMsg{err: err} + } + result, err := m.svc.Install(dir, m.detectResult) + if err != nil { + // Don't dead-end the interactive flow on a failed auto-install (e.g. + // Ruby gem perms, no network): surface the command to run by hand. + args, _ := setup.InstallArgs(m.detectResult.SDKID, m.detectResult.PackageManager) + return installDoneMsg{result: &setup.InstallResult{ + SDKID: m.detectResult.SDKID, + Command: strings.Join(args, " "), + Failed: true, + FailureReason: err.Error(), + }} + } + return installDoneMsg{result: result} + } +} + +func (m wizardModel) runCreateFlag() tea.Cmd { + return func() tea.Msg { + key, err := m.svc.CreateFlag(m.auth, m.selectedProject, "my-new-flag", "My New Flag", m.detectResult.SDKID) + if err != nil { + return wizardErrMsg{err: err} + } + return flagCreatedMsg{key: key} + } +} + +func (m wizardModel) runInit() tea.Cmd { + return func() tea.Msg { + cfg := setup.InitConfig{ + SDKKey: m.sdkKey, + ClientSideID: m.clientSideID, + MobileKey: m.mobileKey, + FlagKey: m.flagKey, + } + result, err := m.svc.Inject(m.detectResult.SDKID, m.detectResult.EntryPoint, cfg) + if err != nil { + return wizardErrMsg{err: err} + } + return initDoneMsg{result: result} + } +} + +func (m wizardModel) runVerify() tea.Cmd { + return func() tea.Msg { + result, err := m.svc.Verify(m.auth, m.selectedProject, m.selectedEnv, m.detectResult.SDKID) + if err != nil { + return wizardErrMsg{err: err} + } + return verifyDoneMsg{result: result} + } +} + +// copyableContent returns the code the current screen is asking the user to copy, +// along with the word the hint uses for it. A screen can show both an install command +// and a snippet; the snippet is the one that has to be pasted verbatim, so it wins. +// Returns false when the screen has nothing to copy. +func (m wizardModel) copyableContent() (content, label string, ok bool) { + if m.step != stepDone { + return "", "", false + } + if m.initResult != nil && !m.initResult.Success && m.initResult.Snippet != "" { + return m.initResult.Snippet, "snippet", true + } + if m.installResult != nil && m.installResult.Failed && m.installResult.Command != "" { + return m.installResult.Command, "command", true + } + return "", "", false +} + +// copyToClipboard puts the content on the clipboard, preferring the operating +// system's own clipboard because it works in every terminal and reports whether it +// succeeded. OSC 52 is the fallback: it asks the terminal to do the copying, which is +// what works over SSH, where the OS clipboard belongs to the wrong machine. Not every +// terminal implements OSC 52 and support cannot be queried, so a copy that goes that +// route is reported as a request rather than a result. +func (m wizardModel) copyToClipboard(content string) tea.Cmd { + return func() tea.Msg { + // Over SSH the OS clipboard is the one on the machine running the code, not + // the one the user pastes into, and it can succeed there — so a remote + // session has to go to the terminal even though the local path would work. + if !m.remoteSession { + if err := m.nativeCopy(content); err == nil { + return copiedMsg{viaTerminal: false} + } + } + fmt.Fprint(m.clipboard, ansi.SetSystemClipboard(content)) + return copiedMsg{viaTerminal: true} + } +} + +// terminalWriter returns the writer to send OSC 52 to. It must not be stdout: +// Bubble Tea owns stdout for frame rendering while the wizard runs, so a +// sequence written there from a command goroutine can land in the middle of a +// frame. Stderr is preferred because it reaches the same terminal without that +// contention, but it may be redirected to a file or pipe, in which case the +// sequence would be swallowed instead of reaching the terminal — so fall back to +// the controlling terminal itself. +func terminalWriter() io.Writer { + if term.IsTerminal(int(os.Stderr.Fd())) { + return os.Stderr + } + if tty, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0); err == nil { + return tty + } + return os.Stderr +} + +// isRemoteSession reports whether the CLI is running over SSH. sshd sets these for +// the session it owns, so they distinguish "the clipboard here is the user's" from +// "the user's clipboard is on the other end of the connection". +func isRemoteSession() bool { + for _, name := range []string{"SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"} { + if os.Getenv(name) != "" { + return true + } + } + return false +} diff --git a/cmd/setup/copy_test.go b/cmd/setup/copy_test.go new file mode 100644 index 00000000..e93621e6 --- /dev/null +++ b/cmd/setup/copy_test.go @@ -0,0 +1,279 @@ +package setup + +import ( + "bytes" + "encoding/base64" + "errors" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/setup" +) + +const snippet = "const LaunchDarkly = require('@launchdarkly/node-server-sdk');\nconst ldClient = LaunchDarkly.init('sdk-key');" + +// copyKey sends "c" with a working OS clipboard, and returns the updated model +// alongside what each path received. +func copyKey(t *testing.T, m wizardModel) (wizardModel, string) { + t.Helper() + var native string + updated, terminal := copyKeyWith(t, m, func(s string) error { + native = s + return nil + }) + return updated, native + terminal +} + +// copyKeyWith sends "c" with the given OS clipboard behaviour, and returns the +// updated model and whatever was written to the terminal as an OSC 52 sequence. +func copyKeyWith(t *testing.T, m wizardModel, native func(string) error) (wizardModel, string) { + t.Helper() + var out bytes.Buffer + m.clipboard = &out + m.nativeCopy = native + + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) + m = next.(wizardModel) + if cmd != nil { + if msg := cmd(); msg != nil { + next, _ = m.Update(msg) + m = next.(wizardModel) + } + } + return m, out.String() +} + +// The snippet has to arrive on the clipboard exactly as the user needs to paste it: +// the gutter bar the code block is drawn with, and the padding lipgloss adds to square +// it off, are display only and must not be copied. +func TestWizard_CopySnippet_CopiesRawContent(t *testing.T) { + m := wizardModel{ + step: stepDone, + width: 80, + initResult: &setup.InitResult{ + SDKID: "go-server-sdk", + FilePath: "/proj/main.go", + Snippet: snippet, + Success: false, + }, + } + + // The rendered block carries the decoration the raw copy must not. + require.Contains(t, m.View(), "│", "the code block is drawn with a gutter bar") + + var native string + updated, _ := copyKeyWith(t, m, func(s string) error { + native = s + return nil + }) + + assert.Equal(t, snippet, native) + assert.Equal(t, copyDone, updated.copyState) + assert.NotContains(t, native, "│", "the gutter bar must not be copied") + assert.NotContains(t, native, " \n", "trailing padding must not be copied") +} + +// A screen can show both an install command and a snippet. The snippet is the one +// that has to be pasted verbatim, so that is what c copies. +func TestWizard_CopySnippet_PrefersSnippetOverInstallCommand(t *testing.T) { + m := wizardModel{ + step: stepDone, + width: 80, + installResult: &setup.InstallResult{ + Command: "npm install @launchdarkly/node-server-sdk", + Failed: true, + }, + initResult: &setup.InitResult{ + SDKID: "node-server", + FilePath: "/proj/index.js", + Snippet: snippet, + Success: false, + }, + } + + _, copied := copyKey(t, m) + assert.Equal(t, snippet, copied) + assert.Contains(t, m.View(), "Press c to copy the snippet.") +} + +// With no snippet to paste, the thing the user still has to carry out of the wizard +// is the install command. +func TestWizard_CopySnippet_FallsBackToInstallCommand(t *testing.T) { + m := wizardModel{ + step: stepDone, + width: 80, + installResult: &setup.InstallResult{ + Command: "npm install @launchdarkly/node-server-sdk", + Failed: true, + }, + initResult: &setup.InitResult{SDKID: "node-server", FilePath: "/proj/index.js", Success: true}, + } + + _, copied := copyKey(t, m) + assert.Equal(t, "npm install @launchdarkly/node-server-sdk", copied) + assert.Contains(t, m.View(), "Press c to copy the command.") +} + +// Offering a copy on a screen with nothing to copy, or writing to the terminal on a +// key the screen does not handle, would both be wrong. +func TestWizard_CopySnippet_NothingToCopy(t *testing.T) { + tests := []struct { + name string + m wizardModel + }{ + { + name: "verification succeeded, no manual step left", + m: wizardModel{ + step: stepDone, + width: 80, + initResult: &setup.InitResult{SDKID: "node-server", Success: true}, + verifyResult: &setup.VerifyResult{Active: true}, + detectResult: &setup.DetectResult{SDKID: "node-server"}, + }, + }, + { + name: "mid-flow screen shows no code", + m: wizardModel{step: stepSelectSDK, width: 80}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + updated, written := copyKey(t, tt.m) + + assert.Empty(t, written, "must not copy anything with nothing to copy") + assert.Equal(t, copyNone, updated.copyState) + assert.NotContains(t, tt.m.View(), "Press c to copy") + }) + } +} + +// The hint has to confirm the copy, otherwise the user has no way to tell whether the +// key did anything. +func TestWizard_CopySnippet_HintConfirmsAfterCopying(t *testing.T) { + m := wizardModel{ + step: stepDone, + width: 80, + initResult: &setup.InitResult{ + SDKID: "go-server-sdk", + FilePath: "/proj/main.go", + Snippet: snippet, + Success: false, + }, + } + + assert.Contains(t, m.View(), "Press c to copy the snippet.") + + updated, _ := copyKey(t, m) + view := updated.View() + assert.Contains(t, view, "Copied the snippet to your clipboard.") + assert.NotContains(t, view, "Press c to copy") +} + +// 'c' is a legal character in a filter query, so the list has to keep receiving it. +func TestWizard_CopySnippet_DoesNotStealCFromFiltering(t *testing.T) { + m := wizardModel{step: stepDetect, width: 80, height: 30} + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{ + SDKID: "node-server", + Language: "JavaScript", + }}) + m2 := next.(wizardModel) + m2.sdkFocus = 1 + + // Open the list filter, then type "c". + filtering, _ := m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + m3 := filtering.(wizardModel) + require.True(t, m3.isFiltering(), "expected the SDK list to be filtering") + + typed, written := copyKey(t, m3) + assert.Empty(t, written, "c must reach the filter, not the clipboard") + assert.Equal(t, copyNone, typed.copyState) +} + +// Over SSH the OS clipboard belongs to the wrong machine, so a failure there falls +// back to asking the terminal. That path cannot be confirmed, so the hint must not +// claim the content is on the clipboard. +func TestWizard_CopySnippet_FallsBackToTerminalWhenOSClipboardFails(t *testing.T) { + m := wizardModel{ + step: stepDone, + width: 80, + initResult: &setup.InitResult{ + SDKID: "go-server-sdk", + FilePath: "/proj/main.go", + Snippet: snippet, + Success: false, + }, + } + + updated, written := copyKeyWith(t, m, func(string) error { + return errors.New("no clipboard on this machine") + }) + + require.NotEmpty(t, written, "a failed OS copy must fall back to OSC 52") + assert.Equal(t, "\x1b]52;c;"+base64.StdEncoding.EncodeToString([]byte(snippet))+"\x07", written) + assert.Equal(t, snippet, decodeOSC52(t, written)) + assert.Equal(t, copyRequested, updated.copyState) + + view := updated.View() + assert.Contains(t, view, "Asked your terminal to copy the snippet.") + assert.NotContains(t, view, "Copied the snippet to your clipboard.", + "OSC 52 support cannot be detected, so the copy must not be claimed as done") +} + +// A remote host can have a perfectly working clipboard — a Mac with pbcopy, a Linux +// box with a display — and writing to it still puts the snippet on the wrong machine. +// Success there is not evidence the user can paste, so it must not be preferred or +// reported as done. +func TestWizard_CopySnippet_RemoteSessionSkipsTheOSClipboard(t *testing.T) { + m := wizardModel{ + step: stepDone, + width: 80, + remoteSession: true, + initResult: &setup.InitResult{ + SDKID: "go-server-sdk", + FilePath: "/proj/main.go", + Snippet: snippet, + Success: false, + }, + } + + nativeCalled := false + updated, written := copyKeyWith(t, m, func(string) error { + nativeCalled = true + return nil // the remote clipboard would accept it + }) + + assert.False(t, nativeCalled, "must not write to the clipboard of the remote machine") + assert.Equal(t, snippet, decodeOSC52(t, written)) + assert.Equal(t, copyRequested, updated.copyState) + assert.Contains(t, updated.View(), "Asked your terminal to copy the snippet.") +} + +// The environment sshd sets for its session is what separates the two cases. +func TestIsRemoteSession(t *testing.T) { + for _, name := range []string{"SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"} { + t.Run(name, func(t *testing.T) { + t.Setenv(name, "10.0.0.1 51234 10.0.0.2 22") + assert.True(t, isRemoteSession()) + }) + } + + t.Run("no ssh variables", func(t *testing.T) { + t.Setenv("SSH_CONNECTION", "") + t.Setenv("SSH_CLIENT", "") + t.Setenv("SSH_TTY", "") + assert.False(t, isRemoteSession()) + }) +} + +func decodeOSC52(t *testing.T, seq string) string { + t.Helper() + require.True(t, len(seq) > len("\x1b]52;c;")+1, "not an OSC 52 sequence: %q", seq) + payload := seq[len("\x1b]52;c;") : len(seq)-1] + decoded, err := base64.StdEncoding.DecodeString(payload) + require.NoError(t, err) + return string(decoded) +} diff --git a/cmd/setup/detect.go b/cmd/setup/detect.go new file mode 100644 index 00000000..b7d0ced2 --- /dev/null +++ b/cmd/setup/detect.go @@ -0,0 +1,66 @@ +package setup + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/launchdarkly/ldcli/cmd/cliflags" + "github.com/launchdarkly/ldcli/internal/setup" +) + +const pathFlag = "path" + +func newDetectCmd(svc setup.Service) *cobra.Command { + cmd := &cobra.Command{ + Use: "detect", + Short: "Detect language, framework, and recommended SDK for a project", + Hidden: true, + RunE: runDetect(svc), + } + + cmd.Flags().String(pathFlag, "", "Path to the project directory (defaults to current directory)") + + return cmd +} + +func runDetect(svc setup.Service) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + dir, _ := cmd.Flags().GetString(pathFlag) + if dir == "" { + var err error + dir, err = os.Getwd() + if err != nil { + return err + } + } + + result, err := svc.Detect(dir) + if err != nil { + return err + } + + outputKind := cliflags.GetOutputKind(cmd) + if outputKind == "json" { + data, _ := json.Marshal(result) + fmt.Fprintln(cmd.OutOrStdout(), string(data)) + return nil + } + + fmt.Fprintf(cmd.OutOrStdout(), "Language: %s\n", result.Language) + if result.Framework != "" { + fmt.Fprintf(cmd.OutOrStdout(), "Framework: %s\n", result.Framework) + } + fmt.Fprintf(cmd.OutOrStdout(), "Package Manager: %s\n", result.PackageManager) + fmt.Fprintf(cmd.OutOrStdout(), "Recommended SDK: %s\n", result.SDKID) + if result.EntryPointExists { + fmt.Fprintf(cmd.OutOrStdout(), "Entry Point: %s\n", result.EntryPoint) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "Entry Point: %s (suggested, does not exist)\n", result.EntryPoint) + } + + return nil + } +} diff --git a/cmd/setup/init.go b/cmd/setup/init.go new file mode 100644 index 00000000..ef96b1ed --- /dev/null +++ b/cmd/setup/init.go @@ -0,0 +1,85 @@ +package setup + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + + "github.com/launchdarkly/ldcli/cmd/cliflags" + "github.com/launchdarkly/ldcli/internal/setup" +) + +func getFlag(cmd *cobra.Command, name string) string { + v, _ := cmd.Flags().GetString(name) + return v +} + +const ( + fileFlag = "file" + sdkKeyFlag = "sdk-key" + clientIDFlag = "client-side-id" + mobileFlag = "mobile-key" + flagKeyFlag = "flag-key" +) + +func newInitCmd(svc setup.Service) *cobra.Command { + cmd := &cobra.Command{ + Use: "init", + Short: "Inject LaunchDarkly SDK initialization code into a file", + Hidden: true, + RunE: runInit(svc), + } + + cmd.Flags().String(sdkIDFlag, "", "SDK identifier (e.g. node-server, react-client-sdk)") + _ = cmd.MarkFlagRequired(sdkIDFlag) + + cmd.Flags().String(fileFlag, "", "Target file to inject initialization code into") + _ = cmd.MarkFlagRequired(fileFlag) + + cmd.Flags().String(sdkKeyFlag, "", "Server-side SDK key") + cmd.Flags().String(clientIDFlag, "", "Client-side environment ID") + cmd.Flags().String(mobileFlag, "", "Mobile SDK key") + cmd.Flags().String(flagKeyFlag, "", "Feature flag key to use in the initialization example") + + return cmd +} + +func runInit(svc setup.Service) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + sdkID, _ := cmd.Flags().GetString(sdkIDFlag) + filePath, _ := cmd.Flags().GetString(fileFlag) + cfg := setup.InitConfig{ + SDKKey: getFlag(cmd, sdkKeyFlag), + ClientSideID: getFlag(cmd, clientIDFlag), + MobileKey: getFlag(cmd, mobileFlag), + FlagKey: getFlag(cmd, flagKeyFlag), + } + + result, err := svc.Inject(sdkID, filePath, cfg) + if err != nil { + return err + } + + outputKind := cliflags.GetOutputKind(cmd) + if outputKind == "json" { + data, _ := json.Marshal(result) + fmt.Fprintln(cmd.OutOrStdout(), string(data)) + return nil + } + + if !result.Success { + if result.Snippet != "" { + fmt.Fprintf(cmd.OutOrStdout(), "Manual setup required for %s — add the following to %s:\n\n%s\n\n", result.SDKID, result.FilePath, result.Snippet) + fmt.Fprintf(cmd.OutOrStdout(), "Follow the setup guide at: %s\n", result.DocsURL) + return nil + } + fmt.Fprintf(cmd.OutOrStdout(), "No initialization template available for %s\n", result.SDKID) + fmt.Fprintf(cmd.OutOrStdout(), "Follow the setup guide at: %s\n", result.DocsURL) + return nil + } + + fmt.Fprintf(cmd.OutOrStdout(), "Injected %s initialization into %s\n", result.SDKID, result.FilePath) + return nil + } +} diff --git a/cmd/setup/install.go b/cmd/setup/install.go new file mode 100644 index 00000000..86684322 --- /dev/null +++ b/cmd/setup/install.go @@ -0,0 +1,107 @@ +package setup + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/launchdarkly/ldcli/cmd/cliflags" + "github.com/launchdarkly/ldcli/internal/setup" +) + +const ( + sdkIDFlag = "sdk-id" + dryRunFlag = "dry-run" +) + +func newInstallCmd(svc setup.Service) *cobra.Command { + cmd := &cobra.Command{ + Use: "install", + Short: "Install the LaunchDarkly SDK package for the detected project", + Hidden: true, + RunE: runInstall(svc), + } + + cmd.Flags().String(pathFlag, "", "Path to the project directory (defaults to current directory)") + cmd.Flags().String(sdkIDFlag, "", "SDK identifier to install (e.g. node-server, react-client-sdk)") + _ = cmd.MarkFlagRequired(sdkIDFlag) + cmd.Flags().String("package-manager", "", "Package manager to use (e.g. npm, pip, go)") + cmd.Flags().Bool(dryRunFlag, false, "Print the install command that would run without executing it") + + return cmd +} + +func runInstall(svc setup.Service) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + dir, _ := cmd.Flags().GetString(pathFlag) + if dir == "" { + var err error + dir, err = os.Getwd() + if err != nil { + return err + } + } + + sdkID, _ := cmd.Flags().GetString(sdkIDFlag) + pkgMgr, _ := cmd.Flags().GetString("package-manager") + dryRun, _ := cmd.Flags().GetBool(dryRunFlag) + detection := &setup.DetectResult{ + SDKID: sdkID, + PackageManager: pkgMgr, + } + + var result *setup.InstallResult + if dryRun { + args, pkg := setup.InstallArgs(sdkID, pkgMgr) + result = &setup.InstallResult{ + SDKID: sdkID, + Package: pkg, + Command: strings.Join(args, " "), + DryRun: true, + } + } else { + var err error + result, err = svc.Install(dir, detection) + if err != nil { + return err + } + } + + outputKind := cliflags.GetOutputKind(cmd) + if outputKind == "json" { + data, _ := json.Marshal(result) + fmt.Fprintln(cmd.OutOrStdout(), string(data)) + return nil + } + + fmt.Fprintf(cmd.OutOrStdout(), "SDK: %s\n", result.SDKID) + if result.Version != "" { + fmt.Fprintf(cmd.OutOrStdout(), "Package: %s@%s\n", result.Package, result.Version) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "Package: %s\n", result.Package) + } + if result.AlreadyInstalled { + fmt.Fprintln(cmd.OutOrStdout(), "Already installed — skipping install.") + return nil + } + if result.Command != "" { + fmt.Fprintf(cmd.OutOrStdout(), "Command: %s\n", result.Command) + } + if result.DryRun { + fmt.Fprintln(cmd.OutOrStdout(), "Dry run: command not executed") + return nil + } + fmt.Fprintf(cmd.OutOrStdout(), "Success: %t\n", result.Success) + switch { + case result.FailureReason != "": + fmt.Fprintf(cmd.OutOrStdout(), "Reason: %s\n", result.FailureReason) + case !result.Success && setup.RequiresManualInstall(result.SDKID): + fmt.Fprintf(cmd.OutOrStdout(), "Reason: %s has no automated install command; add %s to your build configuration by hand.\n", result.SDKID, result.Package) + } + + return nil + } +} diff --git a/cmd/setup/model.go b/cmd/setup/model.go new file mode 100644 index 00000000..15563393 --- /dev/null +++ b/cmd/setup/model.go @@ -0,0 +1,203 @@ +package setup + +import ( + "io" + + "github.com/atotto/clipboard" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/launchdarkly/ldcli/cmd/cliflags" + "github.com/launchdarkly/ldcli/internal/analytics" + "github.com/launchdarkly/ldcli/internal/errors" + "github.com/launchdarkly/ldcli/internal/setup" +) + +// copyState records how the visible snippet was copied, so the view can confirm a +// clipboard write outright but only claim to have asked when the terminal did it. +type copyState int + +const ( + copyNone copyState = iota + copyDone // written to the OS clipboard + copyRequested // handed to the terminal over OSC 52, which cannot confirm +) + +type wizardStep int + +const ( + stepSelectProject wizardStep = iota + stepSelectEnvironment + stepDetect + stepSelectSDK + stepPlan + stepInstall + stepCreateFlag + stepInit + stepWaitForApp + stepVerify + stepDone +) + +type wizardModel struct { + analyticsTrackerFn analytics.TrackerFn + svc setup.Service + auth setup.Auth + + step wizardStep + spinner spinner.Model + err error + width int + height int + + // data gathered through the flow + projects []projectItem + environments []envItem + // projectsLoaded and envsLoaded record that a fetch came back, so a list that + // is legitimately empty is not mistaken for one that is still loading. + projectsLoaded bool + envsLoaded bool + projectList list.Model + envList list.Model + // sdkListBuilt records that sdkList was constructed, since SetSize panics on a + // zero-value list.Model. + sdkListBuilt bool + sdkList list.Model + + selectedProject string + selectedEnv string + sdkKey string + clientSideID string + mobileKey string + + detectComplete bool // detection (run once at launch) has finished + detectedSDKID string // detected SDK id, cached from the one-time detection ("" if none) + // detected is the unmodified result of the one-time detection. detectResult is + // what the rest of the flow acts on: the same values with the SDK the user + // actually chose. Keeping the whole struct means added fields reach the later + // steps without every one having to be copied by hand. + detected *setup.DetectResult + detectResult *setup.DetectResult + detectedSDK *sdkItem // the auto-detected SDK, shown in its own panel; nil if detection failed + sdkFocus int // on the SDK screen: 0 = detected panel, 1 = the list of other SDKs + planInstallCmd string // install command previewed on the plan screen + planAlready bool // whether the SDK is already installed (previewed on the plan screen) + installResult *setup.InstallResult + flagKey string + initResult *setup.InitResult + verifyResult *setup.VerifyResult + + // nativeCopy puts content on the operating system's clipboard, and clipboard + // receives the OSC 52 sequence used when that is not available. Both are fields + // so tests can drive either path without a real clipboard or terminal. + nativeCopy func(string) error + clipboard io.Writer + copyState copyState + // remoteSession suppresses the OS clipboard, because over SSH it is not the one + // the user pastes into even when writing to it succeeds. + remoteSession bool + + quitting bool +} + +type sdkItem struct { + id string + language string + name string +} + +func (s sdkItem) Title() string { + if setup.RequiresManualInstall(s.id) { + return s.name + " (manual install)" + } + return s.name +} +func (s sdkItem) Description() string { return s.language } +func (s sdkItem) FilterValue() string { return s.name } + +type projectItem struct { + key string + name string +} + +func (p projectItem) Title() string { return p.name } +func (p projectItem) Description() string { return p.key } +func (p projectItem) FilterValue() string { return p.name } + +type envItem struct { + key string + name string +} + +func (e envItem) Title() string { return e.name } +func (e envItem) Description() string { return e.key } +func (e envItem) FilterValue() string { return e.name } + +// messages +type projectsFetchedMsg struct{ projects []projectItem } + +// envsFetchedMsg and envDetailsFetchedMsg name the selection their fetch was +// issued for. Nothing cancels a fetch the user has navigated away from, so the +// response has to say what it answers for the model to tell a current reply from +// a superseded one it must drop. +type envsFetchedMsg struct { + project string + environments []envItem +} +type envDetailsFetchedMsg struct { + project string + env string + sdkKey string + clientSideID string + mobileKey string +} +type detectDoneMsg struct{ result *setup.DetectResult } +type detectFailedMsg struct{} +type installDoneMsg struct{ result *setup.InstallResult } +type flagCreatedMsg struct{ key string } +type initDoneMsg struct{ result *setup.InitResult } +type copiedMsg struct{ viaTerminal bool } +type verifyDoneMsg struct{ result *setup.VerifyResult } +type wizardErrMsg struct{ err error } + +func runSetupWizard( + analyticsTrackerFn analytics.TrackerFn, + svc setup.Service, +) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + // Pre-flight: the wizard's first action is an authenticated API call, so + // bail early with clear guidance rather than dumping a raw 401 mid-TUI. + if viper.GetString(cliflags.AccessTokenFlag) == "" { + return errors.NewError("It looks like you're not logged in yet.\n\nRun `ldcli login` to authenticate, then run `ldcli setup` again.\n(Or pass --access-token, or set LD_ACCESS_TOKEN.)") + } + + s := spinner.New() + s.Spinner = spinner.Dot + + m := wizardModel{ + analyticsTrackerFn: analyticsTrackerFn, + svc: svc, + auth: setup.Auth{ + AccessToken: viper.GetString(cliflags.AccessTokenFlag), + BaseURI: viper.GetString(cliflags.BaseURIFlag), + }, + step: stepSelectProject, + spinner: s, + clipboard: terminalWriter(), + nativeCopy: clipboard.WriteAll, + remoteSession: isRemoteSession(), + } + + p := tea.NewProgram(m, tea.WithAltScreen()) + _, err := p.Run() + return err + } +} + +func (m wizardModel) Init() tea.Cmd { + // Detect the project once, up front, so navigating the flow never re-runs it. + return tea.Batch(m.spinner.Tick, m.fetchProjects(), m.runDetect()) +} diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go new file mode 100644 index 00000000..b0f32f95 --- /dev/null +++ b/cmd/setup/setup.go @@ -0,0 +1,57 @@ +package setup + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + cmdAnalytics "github.com/launchdarkly/ldcli/cmd/analytics" + "github.com/launchdarkly/ldcli/cmd/cliflags" + "github.com/launchdarkly/ldcli/internal/analytics" + "github.com/launchdarkly/ldcli/internal/setup" +) + +// NewSetupCmd creates the top-level setup command and registers its hidden subcommands. +func NewSetupCmd( + analyticsTrackerFn analytics.TrackerFn, + clients setup.Clients, + detector setup.Detector, + installer setup.Installer, +) *cobra.Command { + svc := setup.Service{ + Clients: clients, + Detector: detector, + Installer: installer, + Initializer: setup.Initializer{}, + } + cmd := &cobra.Command{ + Use: "setup", + Short: "Set up LaunchDarkly in your project", + Long: `Guided setup to integrate LaunchDarkly into your codebase. + +Detects your project's language and framework, installs the correct SDK, +initializes it with your environment's SDK key, creates a feature flag, +and verifies the connection.`, + PreRun: func(cmd *cobra.Command, args []string) { + // Dim the notice and set it off with a blank line so it reads as a + // transitional notice, visually distinct from command output. + notice := mutedStyle.Render( + "Notice: 'ldcli setup' now runs the new guided setup wizard (project detection, SDK installation, and initialization).\n" + + "The previous quickstart wizard is still available via 'ldcli quickstart' during the transition period.") + fmt.Fprintf(cmd.ErrOrStderr(), "%s\n\n", notice) + analyticsTrackerFn( + viper.GetString(cliflags.AccessTokenFlag), + viper.GetString(cliflags.BaseURIFlag), + viper.GetBool(cliflags.AnalyticsOptOut), + ).SendCommandRunEvent(cmdAnalytics.CmdRunEventProperties(cmd, "setup", nil)) + }, + RunE: runSetupWizard(analyticsTrackerFn, svc), + } + + cmd.AddCommand(newDetectCmd(svc)) + cmd.AddCommand(newInstallCmd(svc)) + cmd.AddCommand(newInitCmd(svc)) + + return cmd +} diff --git a/cmd/setup/setup_test.go b/cmd/setup/setup_test.go new file mode 100644 index 00000000..17130c2f --- /dev/null +++ b/cmd/setup/setup_test.go @@ -0,0 +1,414 @@ +package setup_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/cmd" + "github.com/launchdarkly/ldcli/internal/analytics" + "github.com/launchdarkly/ldcli/internal/resources" + "github.com/launchdarkly/ldcli/internal/setup" +) + +func TestSetup_NoAuth_ReturnsLoginGuidance(t *testing.T) { + // No --access-token and no LD_ACCESS_TOKEN: the wizard must bail before the + // TUI with clear guidance rather than dumping a raw 401. + args := []string{"setup"} + _, err := cmd.CallCmd( + t, + cmd.APIClients{ResourcesClient: &resources.MockClient{}}, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "ldcli login") +} + +func TestInit(t *testing.T) { + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "index.js") + + args := []string{ + "setup", "init", + "--access-token", "test-token", + "--sdk-id", "node-server", + "--file", filePath, + "--sdk-key", "test-sdk-key", + "--flag-key", "test-flag", + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: &resources.MockClient{}, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), "Injected node-server") +} + +func TestInitJSON(t *testing.T) { + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "index.js") + + args := []string{ + "setup", "init", + "--access-token", "test-token", + "--sdk-id", "node-server", + "--file", filePath, + "--sdk-key", "test-sdk-key", + "--flag-key", "test-flag", + "--output", "json", + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: &resources.MockClient{}, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), `"success":true`) +} + +func TestInitUnsupportedSDKPlaintext(t *testing.T) { + tmpDir := t.TempDir() + filePath := tmpDir + "/main.rs" + + args := []string{ + "setup", "init", + "--access-token", "test-token", + "--sdk-id", "rust-server-sdk", + "--file", filePath, + "--sdk-key", "test-sdk-key", + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: &resources.MockClient{}, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), "No initialization template available for rust-server-sdk") + assert.Contains(t, string(output), "setup guide at:") + assert.NotContains(t, string(output), "Injected") +} + +func TestInitUnsupportedSDKJSON(t *testing.T) { + tmpDir := t.TempDir() + filePath := tmpDir + "/main.rs" + + args := []string{ + "setup", "init", + "--access-token", "test-token", + "--sdk-id", "rust-server-sdk", + "--file", filePath, + "--sdk-key", "test-sdk-key", + "--output", "json", + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: &resources.MockClient{}, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), `"success":false`) + assert.Contains(t, string(output), `"docs_url"`) +} + +func TestDetect_UnknownProject_ReturnsError(t *testing.T) { + emptyDir := t.TempDir() + args := []string{ + "setup", "detect", + "--access-token", "test-token", + "--path", emptyDir, + } + _, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: &resources.MockClient{}, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "could not detect") +} + +func TestDetect_GoProject_ReturnsResult(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/app\n\ngo 1.21\n"), 0600) + require.NoError(t, err) + + args := []string{ + "setup", "detect", + "--access-token", "test-token", + "--path", dir, + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: &resources.MockClient{}, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), "go-server-sdk") +} + +func TestDetect_JSON(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/app\n\ngo 1.21\n"), 0600) + require.NoError(t, err) + + args := []string{ + "setup", "detect", + "--access-token", "test-token", + "--path", dir, + "--output", "json", + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ResourcesClient: &resources.MockClient{}}, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), `"sdk_id":"go-server-sdk"`) +} + +// mockInstaller is a simple Installer that returns a canned result, used to exercise +// runInstall output paths without executing real package manager commands. +type mockInstaller struct { + result *setup.InstallResult +} + +func (m mockInstaller) Install(_ string, detection *setup.DetectResult) (*setup.InstallResult, error) { + if m.result != nil { + return m.result, nil + } + return &setup.InstallResult{ + SDKID: detection.SDKID, + Package: "@launchdarkly/node-server-sdk", + Command: "npm install @launchdarkly/node-server-sdk", + Success: true, + }, nil +} + +func TestInstall_Plaintext(t *testing.T) { + args := []string{ + "setup", "install", + "--access-token", "test-token", + "--sdk-id", "node-server", + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: &resources.MockClient{}, + Installer: mockInstaller{}, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), "node-server") + assert.Contains(t, string(output), "@launchdarkly/node-server-sdk") +} + +func TestInstall_Plaintext_WithVersion(t *testing.T) { + args := []string{ + "setup", "install", + "--access-token", "test-token", + "--sdk-id", "node-server", + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: &resources.MockClient{}, + Installer: mockInstaller{result: &setup.InstallResult{ + SDKID: "node-server", + Package: "@launchdarkly/node-server-sdk", + Version: "9.7.0", + Command: "npm install @launchdarkly/node-server-sdk", + Success: true, + }}, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), "@launchdarkly/node-server-sdk@9.7.0") +} + +func TestInstall_Plaintext_PrintsFailureReason(t *testing.T) { + args := []string{ + "setup", "install", + "--access-token", "test-token", + "--sdk-id", "dotnet-server-sdk", + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: &resources.MockClient{}, + Installer: mockInstaller{result: &setup.InstallResult{ + SDKID: "dotnet-server-sdk", + Package: "LaunchDarkly.ServerSdk", + Failed: true, + FailureReason: "no .csproj found; rerun with --project", + }}, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), "Success: false") + assert.Contains(t, string(output), "no .csproj found; rerun with --project") + assert.NotContains(t, string(output), "Command: \n") +} + +func TestInstall_Plaintext_ExplainsManualInstall(t *testing.T) { + args := []string{ + "setup", "install", + "--access-token", "test-token", + "--sdk-id", "java-server-sdk", + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: &resources.MockClient{}, + Installer: mockInstaller{result: &setup.InstallResult{ + SDKID: "java-server-sdk", + Package: "com.launchdarkly:launchdarkly-java-server-sdk", + Success: false, + }}, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), "Success: false") + assert.Contains(t, string(output), "no automated install command") +} + +func TestInstall_DryRun(t *testing.T) { + args := []string{ + "setup", "install", + "--access-token", "test-token", + "--sdk-id", "node-server", + "--dry-run", + } + // No Installer provided: dry-run must not invoke it or shell out. + output, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: &resources.MockClient{}, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), "npm install @launchdarkly/node-server-sdk") + assert.Contains(t, string(output), "Dry run") +} + +func TestInstall_JSON(t *testing.T) { + args := []string{ + "setup", "install", + "--access-token", "test-token", + "--sdk-id", "node-server", + "--output", "json", + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: &resources.MockClient{}, + Installer: mockInstaller{}, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), `"success":true`) +} + +func TestInstallStubReturnsError(t *testing.T) { + args := []string{ + "setup", "install", + "--access-token", "test-token", + "--sdk-id", "node-server", + } + _, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: &resources.MockClient{}, + Installer: setup.StubInstaller{}, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "not yet implemented") +} + +func TestInstallMissingRequiredFlag(t *testing.T) { + args := []string{ + "setup", "install", + "--access-token", "test-token", + } + _, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: &resources.MockClient{}, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "required flag") +} + +func TestInitMissingRequiredFlags(t *testing.T) { + args := []string{ + "setup", "init", + "--access-token", "test-token", + } + _, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: &resources.MockClient{}, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "required flag") +} diff --git a/cmd/setup/styles.go b/cmd/setup/styles.go new file mode 100644 index 00000000..4ace0717 --- /dev/null +++ b/cmd/setup/styles.go @@ -0,0 +1,52 @@ +package setup + +import "github.com/charmbracelet/lipgloss" + +// Shared visual tokens for the setup wizard, aligned with ldcli's existing +// quickstart TUI: selected items use color 170, bordered panels use 62. +var ( + colorSelected = lipgloss.Color("170") // active selection / pointer + colorBorder = lipgloss.Color("62") // focused panel border + colorBlur = lipgloss.Color("240") // unfocused panel border + + titleStyle = lipgloss.NewStyle().Bold(true).MarginBottom(1) + headerStyle = lipgloss.NewStyle().Bold(true) + selectedStyle = lipgloss.NewStyle().Foreground(colorSelected).Bold(true) + mutedStyle = lipgloss.NewStyle().Faint(true) + + // codeStyle marks copy-me code (snippets, commands) with a left gutter bar + // and a distinct foreground, so the user can tell what to copy versus read. + codeStyle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(colorBorder). + Foreground(lipgloss.Color("252")). + PaddingLeft(1) +) + +// code renders a snippet or command as a distinct code block. +func code(s string) string { return codeStyle.Render(s) } + +// wrapText reflows prose to the given width so it doesn't overflow narrow +// terminals. Returns the input unchanged when width is unknown (<=0). +func wrapText(s string, width int) string { + if width <= 0 { + return s + } + if width > 100 { + width = 100 + } + return lipgloss.NewStyle().Width(width).Render(s) +} + +// box returns the panel style used on the SDK screen, highlighted when focused. +func box(focused bool, width int) lipgloss.Style { + border := colorBlur + if focused { + border = colorBorder + } + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(border). + Padding(0, 1). + Width(width) +} diff --git a/cmd/setup/update.go b/cmd/setup/update.go new file mode 100644 index 00000000..a54349a9 --- /dev/null +++ b/cmd/setup/update.go @@ -0,0 +1,378 @@ +package setup + +import ( + "os" + "path/filepath" + "strings" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + + "github.com/launchdarkly/ldcli/internal/setup" +) + +func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + // The lists are built when their data arrives, which can be before or + // after this message, so push the new size into whichever already exist. + // SetSize panics on a zero-value list.Model, hence the built guards. + if m.projectsLoaded { + m.projectList.SetSize(m.width, m.listHeight()) + } + if m.envsLoaded { + m.envList.SetSize(m.width, m.listHeight()) + } + if m.sdkListBuilt { + m.sdkList.SetSize(m.sdkBoxWidth()-2, m.sdkList.Height()) + } + + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + m.quitting = true + return m, tea.Quit + case "q", "esc": + if m.isFiltering() { + break // let the list receive 'q' / clear its filter + } + m.quitting = true + return m, tea.Quit + case "left", "h": + if m.isFiltering() { + break // let the list receive the key as filter input + } + return m.handleBack() + case "c": + if m.isFiltering() { + break // let the list receive the key as filter input + } + content, _, ok := m.copyableContent() + if !ok { + break + } + return m, m.copyToClipboard(content) + case "enter": + return m.handleEnter() + } + + case copiedMsg: + m.copyState = copyDone + if msg.viaTerminal { + m.copyState = copyRequested + } + return m, nil + + case projectsFetchedMsg: + m.projects = msg.projects + m.projectsLoaded = true + items := make([]list.Item, len(msg.projects)) + for i, p := range msg.projects { + items[i] = p + } + delegate := list.NewDefaultDelegate() + m.projectList = list.New(items, delegate, m.width, m.listHeight()) + m.projectList.Title = "Select a project:" + m.projectList.SetShowStatusBar(false) + return m, nil + + case envsFetchedMsg: + if !m.acceptsEnvs(msg) { + return m, nil + } + m.environments = msg.environments + m.envsLoaded = true + items := make([]list.Item, len(msg.environments)) + for i, e := range msg.environments { + items[i] = e + } + delegate := list.NewDefaultDelegate() + m.envList = list.New(items, delegate, m.width, m.listHeight()) + m.envList.Title = "Select an environment:" + m.envList.SetShowStatusBar(false) + return m, nil + + case envDetailsFetchedMsg: + if !m.acceptsEnvDetails(msg) { + return m, nil + } + m.sdkKey = msg.sdkKey + m.clientSideID = msg.clientSideID + m.mobileKey = msg.mobileKey + // Detection was kicked off at launch; go straight to the SDK screen if + // it's already done, otherwise show a brief wait until it lands. + if m.detectComplete { + m.enterSDKStep() + } else { + m.step = stepDetect + } + return m, nil + + case detectFailedMsg: + m.detectComplete = true + m.detectedSDKID = "" + if m.step == stepDetect { + m.enterSDKStep() + } + return m, nil + + case detectDoneMsg: + m.detectComplete = true + m.detectedSDKID = msg.result.SDKID + m.detected = msg.result + if m.step == stepDetect { + m.enterSDKStep() + } + return m, nil + + case installDoneMsg: + m.installResult = msg.result + m.step = stepCreateFlag + return m, m.runCreateFlag() + + case flagCreatedMsg: + m.flagKey = msg.key + m.step = stepInit + return m, m.runInit() + + case initDoneMsg: + m.initResult = msg.result + // Skip the live verify if init didn't inject runnable code, or if the SDK + // wasn't actually installed (auto-install failed) — the app can't connect. + if !msg.result.Success || (m.installResult != nil && m.installResult.Failed) { + m.step = stepDone + return m, nil + } + m.step = stepWaitForApp + return m, nil + + case verifyDoneMsg: + m.verifyResult = msg.result + m.step = stepDone + return m, nil + + case wizardErrMsg: + m.err = msg.err + return m, nil + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } + + // delegate to list models + var cmd tea.Cmd + switch m.step { + case stepSelectProject: + if len(m.projects) > 0 { + m.projectList, cmd = m.projectList.Update(msg) + } + case stepSelectEnvironment: + if len(m.environments) > 0 { + m.envList, cmd = m.envList.Update(msg) + } + case stepSelectSDK: + // Two panels when a detected SDK is shown: the detected panel (focus 0) + // and the list of other SDKs (focus 1). Arrows move focus between them. + if m.detectedSDK != nil { + if km, ok := msg.(tea.KeyMsg); ok { + switch km.String() { + case "down", "tab", "j": + if m.sdkFocus == 0 { + m.sdkFocus = 1 + m.sdkList.SetDelegate(sdkDelegate(true)) + return m, nil + } + case "up", "shift+tab", "k": + if m.sdkFocus == 1 && m.sdkList.Index() == 0 { + m.sdkFocus = 0 + m.sdkList.SetDelegate(sdkDelegate(false)) + return m, nil + } + } + } + if m.sdkFocus == 1 && m.sdkList.Items() != nil { + m.sdkList, cmd = m.sdkList.Update(msg) + } + } else if m.sdkList.Items() != nil { + m.sdkList, cmd = m.sdkList.Update(msg) + } + } + return m, cmd +} + +// isFiltering reports whether the current step's list is in filter-typing mode, +// so keys like esc/q are left for the list instead of triggering back/quit. +func (m wizardModel) isFiltering() bool { + switch m.step { + case stepSelectProject: + return m.projectList.FilterState() == list.Filtering + case stepSelectEnvironment: + return m.envList.FilterState() == list.Filtering + case stepSelectSDK: + return m.sdkList.FilterState() == list.Filtering + } + return false +} + +// enterSDKStep builds the SDK-selection screen from the cached one-time +// detection result and switches to it. Rebuilding the list is cheap and uses +// the current width; detection itself is never re-run. +func (m *wizardModel) enterSDKStep() { + if id := m.detectedSDKID; id != "" { + if det, ok := findKnownSDK(id); ok { + m.detectedSDK = &det + m.sdkFocus = 0 + m.sdkList = m.newSDKList(sdkItemsExcept(det.id), "Other SDKs:", false) + m.sdkListBuilt = true + m.step = stepSelectSDK + return + } + } + m.detectedSDK = nil + m.sdkFocus = 1 + m.sdkList = m.newSDKList(sdkItemsExcept(""), "Select your SDK:", true) + m.sdkListBuilt = true + m.step = stepSelectSDK +} + +// acceptsEnvs reports whether an environment list still describes the project the +// user has selected, and whether the wizard is still choosing one. A list fetched +// for a project the user has since left would otherwise be shown under the new +// project, letting Enter commit an environment key the new project doesn't have. +func (m wizardModel) acceptsEnvs(msg envsFetchedMsg) bool { + if msg.project != m.selectedProject { + return false + } + // Past the environment step the list is only a leftover of a choice already + // made, so rebuilding it would drop the user's place for nothing. + return m.step == stepSelectProject || m.step == stepSelectEnvironment +} + +// acceptsEnvDetails reports whether SDK keys belong to the project and +// environment currently selected, and whether the wizard is still waiting for +// them. Without both checks a response the user has navigated away from — or a +// duplicate arriving after the flow finished — would write another environment's +// keys and yank the flow back to SDK selection. +func (m wizardModel) acceptsEnvDetails(msg envDetailsFetchedMsg) bool { + if msg.project != m.selectedProject || msg.env != m.selectedEnv { + return false + } + return m.step == stepSelectEnvironment || m.step == stepDetect +} + +// resetEnvSelection drops the environments belonging to the previously selected +// project, so the pending fetch shows the loading spinner rather than a list +// Enter would pick a key from that the new project doesn't have (or an empty +// state that makes a non-empty project look empty). envList is zeroed instead of +// left in place because envsLoaded already guards the SetSize call that would +// panic on a zero-value list, and envsFetchedMsg rebuilds it at the width a +// WindowSizeMsg has meanwhile recorded. +func (m *wizardModel) resetEnvSelection() { + m.environments = nil + m.envsLoaded = false + m.envList = list.Model{} + m.selectedEnv = "" +} + +// handleBack returns to the previous selection so the user can change the +// project, environment, or SDK. +func (m wizardModel) handleBack() (tea.Model, tea.Cmd) { + switch m.step { + case stepSelectEnvironment: + m.step = stepSelectProject + m.resetEnvSelection() + case stepSelectSDK: + m.step = stepSelectEnvironment + case stepPlan: + m.step = stepSelectSDK + } + return m, nil +} + +func (m wizardModel) handleEnter() (tea.Model, tea.Cmd) { + switch m.step { + case stepSelectProject: + if len(m.projects) == 0 { + return m, nil + } + selected, ok := m.projectList.SelectedItem().(projectItem) + if !ok { + return m, nil + } + m.selectedProject = selected.key + m.resetEnvSelection() + m.step = stepSelectEnvironment + return m, m.fetchEnvironments() + + case stepSelectEnvironment: + if len(m.environments) == 0 { + return m, nil + } + selected, ok := m.envList.SelectedItem().(envItem) + if !ok { + return m, nil + } + m.selectedEnv = selected.key + return m, m.fetchEnvDetails() + + case stepSelectSDK: + var chosen sdkItem + if m.detectedSDK != nil && m.sdkFocus == 0 { + chosen = *m.detectedSDK + } else { + selected, ok := m.sdkList.SelectedItem().(sdkItem) + if !ok { + return m, nil + } + chosen = selected + } + result := setup.DetectResult{} + if m.detected != nil { + result = *m.detected + } + result.SDKID = chosen.id + result.Language = chosen.language + if chosen.id != m.detectedSDKID { + // The detected entry point belongs to the language we detected, not the + // one the user picked. An append-safe SDK would otherwise write Ruby into + // a Node project's index.js, so start over from that SDK's own default. + result.Framework = "" + result.EntryPoint = setup.DefaultEntryPoint(chosen.id) + result.EntryPointExists = false + if dir, err := os.Getwd(); err == nil && result.EntryPoint != "" { + result.EntryPoint = filepath.Join(dir, result.EntryPoint) + // Injection appends to a file that is already there, so report the + // default as found when it exists. Claiming otherwise would promise + // to create a file and then quietly append to the user's. + if info, err := os.Stat(result.EntryPoint); err == nil && !info.IsDir() { + result.EntryPointExists = true + } + } + } + m.detectResult = &result + // Compute the plan preview shown before any action is taken. + args, _ := setup.InstallArgs(chosen.id, result.PackageManager) + m.planInstallCmd = strings.Join(args, " ") + if dir, err := os.Getwd(); err == nil { + m.planAlready = setup.IsInstalled(dir, chosen.id) + } + m.step = stepPlan + return m, nil + + case stepPlan: + m.step = stepInstall + return m, m.runInstall() + + case stepWaitForApp: + m.step = stepVerify + return m, m.runVerify() + } + return m, nil +} + +// quitHint is appended to terminal (done) screens so the user knows how to exit. diff --git a/cmd/setup/view.go b/cmd/setup/view.go new file mode 100644 index 00000000..440ce5be --- /dev/null +++ b/cmd/setup/view.go @@ -0,0 +1,314 @@ +package setup + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/list" + + "github.com/launchdarkly/ldcli/internal/setup" +) + +var quitHint = "\n" + mutedStyle.Render("Press q to quit.") + "\n" + +// copyHint labels the copy action next to a code block, or confirms the copy once +// it has happened. The block is drawn with a left gutter bar and the wizard owns the +// alternate screen, so selecting the code by hand picks up the gutter characters. +func (m wizardModel) copyHint() string { + _, label, ok := m.copyableContent() + if !ok { + return "" + } + switch m.copyState { + case copyDone: + return mutedStyle.Render(fmt.Sprintf("Copied the %s to your clipboard.", label)) + "\n" + case copyRequested: + return mutedStyle.Render(fmt.Sprintf("Asked your terminal to copy the %s.", label)) + "\n" + } + return mutedStyle.Render(fmt.Sprintf("Press c to copy the %s.", label)) + "\n" +} + +func (m wizardModel) View() string { + if m.quitting { + return "" + } + + if m.err != nil { + return titleStyle.Render("Error") + "\n\n" + m.err.Error() + "\n\nPress ctrl+c to quit." + } + + switch m.step { + case stepSelectProject: + if !m.projectsLoaded { + return m.spinner.View() + " Loading projects..." + } + if len(m.projects) == 0 { + return titleStyle.Render("No projects available") + "\n\n" + + m.wrap("This access token can't see any projects. Create a project in LaunchDarkly, or use a token with access to one, then run this command again.") + "\n" + + quitHint + } + return m.projectList.View() + "\n" + mutedStyle.Render("esc quit") + + case stepSelectEnvironment: + if !m.envsLoaded { + return m.spinner.View() + " Loading environments..." + } + if len(m.environments) == 0 { + return titleStyle.Render("No environments available") + "\n\n" + + m.wrap(fmt.Sprintf("Project %q has no environments this access token can see. Press ← to pick another project.", m.selectedProject)) + "\n" + + mutedStyle.Render("← back · q quit") + "\n" + } + return m.envList.View() + "\n" + mutedStyle.Render("← back · esc quit") + + case stepDetect: + return m.spinner.View() + " Detecting project type..." + + case stepSelectSDK: + return m.sdkSelectView() + + case stepPlan: + return m.planView() + + case stepInstall: + return m.spinner.View() + " Installing SDK..." + + case stepCreateFlag: + return m.spinner.View() + " Creating feature flag..." + + case stepInit: + return m.spinner.View() + " Injecting initialization code..." + + case stepWaitForApp: + lead := "SDK initialization code has been injected into:\n" + if m.initResult.AlreadyInitialized { + lead = "This file already initializes the LaunchDarkly SDK, so it was left as it is:\n" + } + return titleStyle.Render("Start your application") + "\n\n" + + lead + + " " + m.initResult.FilePath + "\n\n" + + "Please start your application now, then press Enter to verify the connection.\n" + + case stepVerify: + return m.spinner.View() + " Waiting for SDK to connect..." + + case stepDone: + if m.installResult != nil && m.installResult.Failed { + body := titleStyle.Render("Manual install needed") + "\n\n" + + m.wrap("The SDK couldn't be installed automatically.") + "\n\n" + if m.installResult.FailureReason != "" { + body += m.wrap("Reason: "+m.installResult.FailureReason) + "\n\n" + } + // The installer only supplies a command when one exists and failed to + // run. When it declines up front, its reason above carries the command + // to use instead, so don't contradict it with a broken one. + if m.installResult.Command != "" { + body += m.wrap("Install it yourself with:") + "\n\n" + + code(m.installResult.Command) + "\n\n" + } + if m.initResult != nil && m.initResult.AlreadyInitialized { + body += m.wrap(fmt.Sprintf("%s already initializes the SDK, so it was left unchanged.", m.initResult.FilePath)) + "\n" + } else if m.initResult != nil && m.initResult.Success { + body += m.wrap(fmt.Sprintf("Initialization code was added to %s.", m.initResult.FilePath)) + "\n" + } else if m.initResult != nil && m.initResult.Snippet != "" { + body += m.wrap(fmt.Sprintf("Then add this initialization code to %s:", m.initResult.FilePath)) + + "\n\n" + code(m.initResult.Snippet) + "\n" + } + body += "\n" + m.wrap(fmt.Sprintf("Flag %q was created in project %q.", m.flagKey, m.selectedProject)) + "\n" + return body + "\n" + m.copyHint() + quitHint + } + if m.initResult != nil && !m.initResult.Success { + body := titleStyle.Render("Manual SDK setup required") + "\n\n" + if m.initResult.Snippet != "" { + body += m.wrap(fmt.Sprintf("Add the following %s initialization code to %s:", m.initResult.SDKID, m.initResult.FilePath)) + + "\n\n" + code(m.initResult.Snippet) + "\n\n" + } else { + body += fmt.Sprintf("No initialization template is available for %s.\n", m.initResult.SDKID) + } + return body + + fmt.Sprintf("Follow the setup guide at: %s\n\n", m.initResult.DocsURL) + + fmt.Sprintf("Flag %q has been created in project %q.\n", m.flagKey, m.selectedProject) + + "Once you've initialized the SDK manually, your flag will be ready to use.\n\n" + + m.copyHint() + + quitHint + } + if m.verifyResult != nil && m.verifyResult.Active && m.detectResult != nil { + appHost := strings.TrimRight(m.auth.BaseURI, "/") + return titleStyle.Render("Setup complete!") + "\n\n" + + fmt.Sprintf("Your %s SDK is connected to LaunchDarkly.\n", m.detectResult.SDKID) + + fmt.Sprintf("Flag %q is ready to use.\n\n", m.flagKey) + + fmt.Sprintf("You can now toggle your flag at %s/projects/%s/flags/%s/targeting?env=%s\n", appHost, m.selectedProject, m.flagKey, m.selectedEnv) + + quitHint + } + return titleStyle.Render("Verification timed out") + "\n\n" + + "The SDK did not report as active within the timeout period.\n" + + "Make sure your application is running and try again.\n" + + quitHint + } + + return "" +} + +// findKnownSDK returns the sdkItem for the given SDK id, if it is one we know. +func findKnownSDK(id string) (sdkItem, bool) { + for _, sdk := range setup.KnownSDKs { + if sdk.ID == id { + return sdkItem{id: sdk.ID, language: sdk.Language, name: sdk.Name}, true + } + } + return sdkItem{}, false +} + +// sdkItemsExcept returns all known SDKs as list items, omitting the given id. +func sdkItemsExcept(exclude string) []list.Item { + items := make([]list.Item, 0, len(setup.KnownSDKs)) + for _, sdk := range setup.KnownSDKs { + if sdk.ID == exclude { + continue + } + items = append(items, sdkItem{id: sdk.ID, language: sdk.Language, name: sdk.Name}) + } + return items +} + +// sdkBoxWidth is the shared width for the detected panel and the SDK list box, +// so both areas line up. +func (m wizardModel) sdkBoxWidth() int { + w := m.width - 4 + if w > 72 { + w = 72 + } + if w < 20 { // never wider than a very narrow terminal can show + w = 20 + } + return w +} + +// listHeight is the height available to a full-screen list. It never returns a +// value below a usable minimum, because a WindowSizeMsg may not have arrived yet +// and m.height-4 would then be negative. +func (m wizardModel) listHeight() int { + h := m.height - 4 + if h < 3 { + h = 3 + } + return h +} + +// wrap reflows prose to the terminal width so it doesn't overflow narrow +// terminals. Code snippets are rendered raw (not passed through here). +func (m wizardModel) wrap(s string) string { + return wrapText(s, m.width) +} + +// sdkDelegate returns the list row renderer. When the list isn't the focused +// area, the selected row is styled like a normal row so it doesn't look active +// while the detected-SDK panel holds focus. +func sdkDelegate(focused bool) list.DefaultDelegate { + d := list.NewDefaultDelegate() + if !focused { + d.Styles.SelectedTitle = d.Styles.NormalTitle + d.Styles.SelectedDesc = d.Styles.NormalDesc + } + return d +} + +// newSDKList builds the list model for the SDK selection screen. +func (m wizardModel) newSDKList(items []list.Item, title string, focused bool) list.Model { + h := m.height - 12 + if h < 3 { + h = 3 + } + l := list.New(items, sdkDelegate(focused), m.sdkBoxWidth()-2, h) + l.Title = title + l.Styles.Title = headerStyle // match the detected-SDK panel header, not the default title bar + l.SetShowStatusBar(false) + l.SetShowHelp(false) // we render a single key hint inside the box instead + return l +} + +// sdkSelectView renders the SDK selection screen. When an SDK was auto-detected +// it shows two areas: an "identified" panel on top and the list of other SDKs +// below; the focused area is highlighted. When detection failed, only the list +// is shown. +func (m wizardModel) sdkSelectView() string { + hint := mutedStyle.Render("↑/↓ move · enter select · ← back · esc quit") + catalog := mutedStyle.Render("Don't see your language? All LaunchDarkly SDKs: https://launchdarkly.com/docs/sdk") + + if m.detectedSDK == nil { + listBox := box(true, m.sdkBoxWidth()).Render(m.sdkList.View() + "\n" + hint) + return listBox + "\n" + catalog + } + + boxW := m.sdkBoxWidth() + panelStyle := box(m.sdkFocus == 0, boxW) + listStyle := box(m.sdkFocus == 1, boxW) + + // Point to the detected SDK when its panel is focused, matching the list's cursor. + label := fmt.Sprintf("%s (%s)", m.detectedSDK.name, m.detectedSDK.language) + if setup.RequiresManualInstall(m.detectedSDK.id) { + label += " — manual install" + } + pointer := " " + if m.sdkFocus == 0 { + pointer, label = selectedStyle.Render("❯ "), selectedStyle.Render(label) + } + panel := panelStyle.Render( + headerStyle.Render("We identified this as your SDK") + "\n" + + pointer + label + "\n" + + mutedStyle.Render("Press Enter to use it")) + + listBox := listStyle.Render(m.sdkList.View() + "\n" + hint) + + return panel + "\n\n" + listBox + "\n" + catalog +} + +// planView lists the steps setup will take, before any of them run, so the user +// knows what's about to happen and can confirm. +func (m wizardModel) planView() string { + if m.detectResult == nil { + return "" + } + name := m.detectResult.SDKID + if nm, ok := findKnownSDK(m.detectResult.SDKID); ok { + name = nm.name + } + + var steps []string + add := func(s string) { + steps = append(steps, selectedStyle.Render(fmt.Sprintf("%d.", len(steps)+1))+" "+s) + } + + switch { + case m.planAlready: + add(fmt.Sprintf("Install the %s SDK — %s", name, mutedStyle.Render("already installed, will skip"))) + case m.planInstallCmd != "": + add(fmt.Sprintf("Install the %s SDK — %s", name, mutedStyle.Render(m.planInstallCmd))) + default: + add(fmt.Sprintf("Add the %s SDK %s", name, mutedStyle.Render("(manual install)"))) + } + add(fmt.Sprintf("Create a feature flag in %s / %s", m.selectedProject, m.selectedEnv)) + if setup.InjectsInPlace(m.detectResult.SDKID) { + // Say when the entry file does not exist yet: a file we create is not loaded + // by the project, so the user needs the chance to back out and point us at + // the real entry point. + if m.detectResult.EntryPointExists { + add(fmt.Sprintf("Add initialization code to %s", m.detectResult.EntryPoint)) + } else { + add(fmt.Sprintf("Create %s with initialization code %s", + m.detectResult.EntryPoint, + mutedStyle.Render("(no entry file found — check this is where your app starts)"))) + } + add("Verify the SDK connects to LaunchDarkly") + } else { + add("Show initialization code for you to add") + } + + return headerStyle.Render("Here's what setup will do:") + "\n\n" + + strings.Join(steps, "\n") + "\n\n" + + mutedStyle.Render("Enter continue · ← back · esc quit") +} + +// Commands that perform async work. Each is a thin tea.Cmd adapter over the +// orchestration service: it calls a step method and maps the result or error +// onto a wizard message. All API/filesystem work and business rules live in +// internal/setup.Service. diff --git a/cmd/setup/wizard_test.go b/cmd/setup/wizard_test.go new file mode 100644 index 00000000..52101d38 --- /dev/null +++ b/cmd/setup/wizard_test.go @@ -0,0 +1,751 @@ +package setup + +import ( + "os" + "path/filepath" + "testing" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/setup" +) + +// detectDoneMsg goes to stepSelectSDK: detected SDK in its own panel, the rest +// in a separate list, focus defaulting to the detected panel. + +func TestWizard_DetectDone_TransitionsToSDKSelection(t *testing.T) { + m := wizardModel{step: stepDetect} + + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{SDKID: "go-server-sdk", Language: "Go"}}) + updated := next.(wizardModel) + + assert.Equal(t, stepSelectSDK, updated.step) + // detected SDK lives in the panel, not the list, so the list has the rest. + assert.Equal(t, len(setup.KnownSDKs)-1, len(updated.sdkList.Items())) +} + +func TestWizard_DetectDone_DetectedSDKInOwnPanel_FocusedFirst(t *testing.T) { + m := wizardModel{step: stepDetect} + + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{SDKID: "go-server-sdk", Language: "Go"}}) + updated := next.(wizardModel) + + require.NotNil(t, updated.detectedSDK) + assert.Equal(t, "go-server-sdk", updated.detectedSDK.id) + assert.Equal(t, 0, updated.sdkFocus) // detected panel focused by default +} + +func TestWizard_DetectDone_ListExcludesDetectedSDK(t *testing.T) { + m := wizardModel{step: stepDetect} + + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{SDKID: "go-server-sdk"}}) + updated := next.(wizardModel) + + for _, item := range updated.sdkList.Items() { + assert.NotEqual(t, "go-server-sdk", item.(sdkItem).id) + } +} + +func TestWizard_DetectDone_DetectResultNotSetUntilUserConfirms(t *testing.T) { + m := wizardModel{step: stepDetect} + + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{SDKID: "go-server-sdk"}}) + updated := next.(wizardModel) + + assert.Nil(t, updated.detectResult) +} + +func TestWizard_DetectDone_ShowsIdentifiedPanel(t *testing.T) { + m := wizardModel{step: stepDetect, width: 80, height: 30} + + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{SDKID: "go-server-sdk", Language: "Go"}}) + updated := next.(wizardModel) + + view := updated.View() + assert.Contains(t, view, "We identified this as your SDK") + assert.Contains(t, view, "❯") // detected choice is pointed to while its panel is focused +} + +// detectFailedMsg goes to stepSelectSDK in default KnownSDKs order. + +func TestWizard_DetectFailed_UsesGenericSDKTitle(t *testing.T) { + m := wizardModel{step: stepDetect} + + next, _ := m.Update(detectFailedMsg{}) + updated := next.(wizardModel) + + assert.Equal(t, "Select your SDK:", updated.sdkList.Title) +} + +func TestWizard_DetectFailed_TransitionsToSDKSelection(t *testing.T) { + m := wizardModel{step: stepDetect} + + next, _ := m.Update(detectFailedMsg{}) + updated := next.(wizardModel) + + assert.Equal(t, stepSelectSDK, updated.step) + assert.Equal(t, len(setup.KnownSDKs), len(updated.sdkList.Items())) +} + +func TestWizard_DetectFailed_ListInDefaultOrder(t *testing.T) { + m := wizardModel{step: stepDetect} + + next, _ := m.Update(detectFailedMsg{}) + updated := next.(wizardModel) + + for i, item := range updated.sdkList.Items() { + sdk := item.(sdkItem) + assert.Equal(t, setup.KnownSDKs[i].ID, sdk.id) + } +} + +// Selecting an SDK always sets detectResult and proceeds to install. + +func TestWizard_SelectSDK_ProceedsToPlanThenInstall(t *testing.T) { + m := wizardModel{step: stepDetect} + + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{SDKID: "go-server-sdk", Language: "Go"}}) + updated := next.(wizardModel) + require.Equal(t, stepSelectSDK, updated.step) + + // Enter accepts the detected SDK and shows the plan (no action taken yet). + next, _ = updated.Update(tea.KeyMsg{Type: tea.KeyEnter}) + planned := next.(wizardModel) + assert.Equal(t, stepPlan, planned.step) + require.NotNil(t, planned.detectResult) + assert.Equal(t, "go-server-sdk", planned.detectResult.SDKID) + + // Enter on the plan proceeds to install. + next, cmd := planned.Update(tea.KeyMsg{Type: tea.KeyEnter}) + installing := next.(wizardModel) + assert.Equal(t, stepInstall, installing.step) + assert.NotNil(t, cmd) +} + +func TestWizard_Plan_ListsSteps(t *testing.T) { + m := wizardModel{ + step: stepPlan, + selectedProject: "default", + selectedEnv: "test", + detectResult: &setup.DetectResult{SDKID: "node-server", EntryPoint: "src/index.js"}, + planInstallCmd: "npm install @launchdarkly/node-server-sdk", + width: 80, + height: 30, + } + + view := m.planView() + assert.Contains(t, view, "Here's what setup will do:") + assert.Contains(t, view, "npm install @launchdarkly/node-server-sdk") + assert.Contains(t, view, "Create a feature flag") + assert.Contains(t, view, "Verify") // node-server injects in place -> verify step listed +} + +func TestWizard_SelectSDK_UserCanOverrideDetection(t *testing.T) { + // Detection said go-server-sdk, but we'll navigate down and pick something else. + // Here we just verify that whatever is selected (not necessarily the detected SDK) + // becomes the detectResult. + m := wizardModel{step: stepDetect} + + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{SDKID: "go-server-sdk"}}) + updated := next.(wizardModel) + + // Move down to the second item + next, _ = updated.Update(tea.KeyMsg{Type: tea.KeyDown}) + updated = next.(wizardModel) + + next, _ = updated.Update(tea.KeyMsg{Type: tea.KeyEnter}) + selected := next.(wizardModel) + + require.NotNil(t, selected.detectResult) + // Second item should not be go-server-sdk + assert.NotEqual(t, "go-server-sdk", selected.detectResult.SDKID) +} + +func TestWizard_DetectDone_EntryPointStoredForLaterUse(t *testing.T) { + m := wizardModel{step: stepDetect} + + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{ + SDKID: "go-server-sdk", + Language: "Go", + EntryPoint: "/my/project/main.go", + }}) + updated := next.(wizardModel) + + // Entry point is not exposed on detectResult yet (user hasn't confirmed) + assert.Nil(t, updated.detectResult) + + // Confirm SDK selection — entry point should now be on detectResult + next, _ = updated.Update(tea.KeyMsg{Type: tea.KeyEnter}) + selected := next.(wizardModel) + + require.NotNil(t, selected.detectResult) + assert.Equal(t, "/my/project/main.go", selected.detectResult.EntryPoint) +} + +func TestWizard_Back_ReturnsToPreviousStep(t *testing.T) { + cases := []struct{ from, want wizardStep }{ + {stepPlan, stepSelectSDK}, + {stepSelectSDK, stepSelectEnvironment}, + {stepSelectEnvironment, stepSelectProject}, + } + for _, c := range cases { + m := wizardModel{step: c.from} + next, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft}) + assert.Equal(t, c.want, next.(wizardModel).step) + } +} + +func TestWizard_Esc_Quits(t *testing.T) { + m := wizardModel{step: stepSelectSDK} + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + assert.True(t, next.(wizardModel).quitting) + assert.NotNil(t, cmd) +} + +func TestSDKItem_Title_MarksManualInstall(t *testing.T) { + assert.Contains(t, sdkItem{id: "java-server-sdk", name: "Java"}.Title(), "manual install") + assert.Equal(t, "Node.js", sdkItem{id: "node-server", name: "Node.js"}.Title()) +} + +func TestWizard_Done_InstallFailed_ShowsManualCommand(t *testing.T) { + m := wizardModel{ + step: stepDone, + width: 80, + height: 30, + flagKey: "my-new-flag", + selectedProject: "default", + installResult: &setup.InstallResult{SDKID: "ruby-server-sdk", Command: "gem install launchdarkly-server-sdk", Failed: true}, + initResult: &setup.InitResult{SDKID: "ruby-server-sdk", FilePath: "app.rb", Success: true}, + } + + v := m.View() + assert.Contains(t, v, "Manual install needed") + assert.Contains(t, v, "gem install launchdarkly-server-sdk") +} + +func TestWizard_Done_Success_ShowsQuitHint(t *testing.T) { + m := wizardModel{ + step: stepDone, + detectResult: &setup.DetectResult{SDKID: "node-server"}, + verifyResult: &setup.VerifyResult{Active: true}, + flagKey: "my-new-flag", + width: 80, + height: 30, + } + + assert.Contains(t, m.View(), "Press q to quit") +} + +func TestWizard_WaitForApp_EnterTriggersVerify(t *testing.T) { + m := wizardModel{ + step: stepWaitForApp, + initResult: &setup.InitResult{SDKID: "go-server-sdk", FilePath: "/tmp/main.go", Success: true}, + } + + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated := next.(wizardModel) + + assert.Equal(t, stepVerify, updated.step) + assert.NotNil(t, cmd) +} + +func TestWizard_SelectSDK_EmptyList_DoesNotPanic(t *testing.T) { + m := wizardModel{step: stepSelectSDK} + + next, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated := next.(wizardModel) + + assert.Equal(t, stepSelectSDK, updated.step) + assert.Nil(t, updated.detectResult) +} + +func TestWizard_Plan_ExistingEntryPoint_SaysAdd(t *testing.T) { + m := wizardModel{ + step: stepPlan, + selectedProject: "default", + selectedEnv: "test", + detectResult: &setup.DetectResult{ + SDKID: "node-server", + EntryPoint: "src/index.js", + EntryPointExists: true, + }, + width: 80, + height: 30, + } + + view := m.planView() + assert.Contains(t, view, "Add initialization code to src/index.js") + assert.NotContains(t, view, "Create src/index.js") +} + +// A guessed entry point means we would write a file the project does not load, so +// the plan has to say so while the user can still back out. +func TestWizard_Plan_MissingEntryPoint_SaysCreate(t *testing.T) { + m := wizardModel{ + step: stepPlan, + selectedProject: "default", + selectedEnv: "test", + detectResult: &setup.DetectResult{ + SDKID: "node-server", + EntryPoint: "instrumentation.ts", + EntryPointExists: false, + }, + width: 80, + height: 30, + } + + view := m.planView() + assert.Contains(t, view, "Create instrumentation.ts") + assert.Contains(t, view, "no entry file found") + assert.NotContains(t, view, "Add initialization code to") +} + +// The SDK screen rebuilds detectResult, and the plan and install steps read it, so +// every detected value has to survive that step — not just the SDK. +func TestWizard_SelectSDK_CarriesDetectionThrough(t *testing.T) { + m := wizardModel{step: stepDetect, width: 80, height: 30} + + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{ + SDKID: "ruby-server-sdk", + Language: "Ruby", + Framework: "Rails", + PackageManager: "bundle", + EntryPoint: "config.ru", + EntryPointExists: true, + }}) + m2 := next.(wizardModel) + require.Equal(t, stepSelectSDK, m2.step) + + next2, _ := m2.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m3 := next2.(wizardModel) + require.Equal(t, stepPlan, m3.step) + + assert.Equal(t, "bundle", m3.detectResult.PackageManager, "install would fall back to gem install") + assert.True(t, m3.detectResult.EntryPointExists, "plan would claim it will create an existing file") + assert.Equal(t, "Rails", m3.detectResult.Framework) + assert.Equal(t, "config.ru", m3.detectResult.EntryPoint) +} + +func TestWizard_SelectSDK_PlanUsesDetectedPackageManager(t *testing.T) { + m := wizardModel{step: stepDetect, width: 80, height: 30} + + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{ + SDKID: "ruby-server-sdk", Language: "Ruby", PackageManager: "bundle", + }}) + next2, _ := next.(wizardModel).Update(tea.KeyMsg{Type: tea.KeyEnter}) + m3 := next2.(wizardModel) + + assert.Equal(t, "bundle add launchdarkly-server-sdk", m3.planInstallCmd) +} + +// selectOtherSDK moves focus to the list of non-detected SDKs and highlights id. +func selectOtherSDK(t *testing.T, m wizardModel, id string) wizardModel { + t.Helper() + next, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab}) + m = next.(wizardModel) + require.Equal(t, 1, m.sdkFocus) + for i, item := range m.sdkList.Items() { + if sdk, ok := item.(sdkItem); ok && sdk.id == id { + m.sdkList.Select(i) + return m + } + } + t.Fatalf("%s is not in the list of other SDKs", id) + return m +} + +// The detected entry point belongs to the detected language. ruby-server-sdk is +// append-safe, so reusing it would append Ruby to a Node project's index.js. +func TestWizard_OverrideSDK_DoesNotReuseDetectedEntryPoint(t *testing.T) { + m := wizardModel{step: stepDetect, width: 80, height: 30} + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{ + SDKID: "node-server", + Language: "JavaScript", + Framework: "Next.js", + PackageManager: "pnpm", + EntryPoint: "/proj/index.js", + EntryPointExists: true, + }}) + + m2 := selectOtherSDK(t, next.(wizardModel), "ruby-server-sdk") + next2, _ := m2.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m3 := next2.(wizardModel) + + require.Equal(t, stepPlan, m3.step) + assert.Equal(t, "ruby-server-sdk", m3.detectResult.SDKID) + assert.NotEqual(t, "/proj/index.js", m3.detectResult.EntryPoint, + "setup would append Ruby to the Node entry file") + assert.False(t, m3.detectResult.EntryPointExists, + "a file we have not found must not be reported as found") + assert.Contains(t, m3.detectResult.EntryPoint, "main.rb") + assert.Empty(t, m3.detectResult.Framework, "Next.js does not describe a Ruby project") + // The package manager describes the project, not the SDK, so it survives. + assert.Equal(t, "pnpm", m3.detectResult.PackageManager) +} + +// SDKs that only ever return a snippet have no file to name. +func TestWizard_OverrideSDK_SnippetOnlySDKHasNoEntryPoint(t *testing.T) { + m := wizardModel{step: stepDetect, width: 80, height: 30} + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{ + SDKID: "node-server", + Language: "JavaScript", + EntryPoint: "/proj/index.js", + EntryPointExists: true, + }}) + + m2 := selectOtherSDK(t, next.(wizardModel), "go-server-sdk") + next2, _ := m2.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m3 := next2.(wizardModel) + + assert.Empty(t, m3.detectResult.EntryPoint) + assert.False(t, setup.InjectsInPlace(m3.detectResult.SDKID)) + assert.Contains(t, m3.View(), "Show initialization code for you to add") +} + +// Confirming the detected SDK is not an override, so its entry point stands. +func TestWizard_KeepDetectedSDK_KeepsEntryPoint(t *testing.T) { + m := wizardModel{step: stepDetect, width: 80, height: 30} + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{ + SDKID: "node-server", + Language: "JavaScript", + Framework: "Next.js", + EntryPoint: "/proj/instrumentation.ts", + EntryPointExists: true, + }}) + + next2, _ := next.(wizardModel).Update(tea.KeyMsg{Type: tea.KeyEnter}) + m3 := next2.(wizardModel) + + assert.Equal(t, "/proj/instrumentation.ts", m3.detectResult.EntryPoint) + assert.True(t, m3.detectResult.EntryPointExists) + assert.Equal(t, "Next.js", m3.detectResult.Framework) +} + +// overrideToSDK runs detection, switches to id, and returns the model on the plan +// screen. +func overrideToSDK(t *testing.T, detected *setup.DetectResult, id string) wizardModel { + t.Helper() + m := wizardModel{step: stepDetect, width: 80, height: 30} + next, _ := m.Update(detectDoneMsg{result: detected}) + m2 := selectOtherSDK(t, next.(wizardModel), id) + next2, _ := m2.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m3 := next2.(wizardModel) + require.Equal(t, stepPlan, m3.step) + return m3 +} + +// Injection appends to a file that already exists, so the plan must not offer to +// create one. Promising to create and then appending edits a file the user did not +// agree to have touched. +func TestWizard_OverrideSDK_DefaultEntryPointAlreadyPresent(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.rb"), []byte("puts 1\n"), 0600)) + t.Chdir(dir) + + m := overrideToSDK(t, &setup.DetectResult{ + SDKID: "node-server", Language: "JavaScript", + EntryPoint: filepath.Join(dir, "index.js"), EntryPointExists: true, + }, "ruby-server-sdk") + + assert.Equal(t, filepath.Join(dir, "main.rb"), m.detectResult.EntryPoint) + assert.True(t, m.detectResult.EntryPointExists) + view := m.View() + assert.Contains(t, view, "Add initialization code to") + assert.NotContains(t, view, "no entry file found") +} + +func TestWizard_OverrideSDK_DefaultEntryPointMissing(t *testing.T) { + t.Chdir(t.TempDir()) + + m := overrideToSDK(t, &setup.DetectResult{ + SDKID: "node-server", Language: "JavaScript", + EntryPoint: "index.js", EntryPointExists: true, + }, "ruby-server-sdk") + + assert.False(t, m.detectResult.EntryPointExists) + assert.Contains(t, m.View(), "no entry file found") +} + +func TestWizard_Done_DeclinedInstall_ShowsReasonWithoutCommand(t *testing.T) { + m := wizardModel{ + step: stepDone, + width: 78, + selectedProject: "default", + flagKey: "my-new-flag", + installResult: &setup.InstallResult{ + SDKID: "dotnet-server-sdk", + Package: "LaunchDarkly.ServerSdk", + Failed: true, + FailureReason: "found 2 projects in this solution", + }, + } + + v := m.View() + assert.Contains(t, v, "Manual install needed") + assert.Contains(t, v, "found 2 projects in this solution") + // No command to offer, so the screen must not render an empty code block or + // promise one. + assert.NotContains(t, v, "Install it yourself with") +} + +func TestWizard_Done_FailedInstall_ShowsCommand(t *testing.T) { + m := wizardModel{ + step: stepDone, + width: 78, + selectedProject: "default", + flagKey: "my-new-flag", + installResult: &setup.InstallResult{ + SDKID: "ruby-server-sdk", + Command: "gem install launchdarkly-server-sdk", + Failed: true, + FailureReason: "permission denied", + }, + } + + v := m.View() + assert.Contains(t, v, "Install it yourself with") + assert.Contains(t, v, "gem install launchdarkly-server-sdk") + assert.Contains(t, v, "permission denied") +} + +func TestWizard_NoProjects_ShowsEmptyStateNotSpinner(t *testing.T) { + m := wizardModel{step: stepSelectProject, width: 78, height: 24, spinner: spinner.New()} + + // Before the fetch lands, the spinner is right. + assert.Contains(t, m.View(), "Loading projects") + + updated, _ := m.Update(projectsFetchedMsg{projects: nil}) + v := updated.(wizardModel).View() + + assert.NotContains(t, v, "Loading projects") + assert.Contains(t, v, "No projects available") +} + +func TestWizard_NoEnvironments_ShowsEmptyStateNotSpinner(t *testing.T) { + m := wizardModel{step: stepSelectEnvironment, width: 78, height: 24, spinner: spinner.New(), selectedProject: "my-proj"} + + assert.Contains(t, m.View(), "Loading environments") + + updated, _ := m.Update(envsFetchedMsg{project: "my-proj", environments: nil}) + v := updated.(wizardModel).View() + + assert.NotContains(t, v, "Loading environments") + assert.Contains(t, v, "No environments available") + assert.Contains(t, v, "my-proj") +} + +// selectProjectAtIndex drives the project list to the given row and presses +// Enter, returning the model with the environment fetch in flight. +func selectProjectAtIndex(t *testing.T, m wizardModel, i int) wizardModel { + t.Helper() + m.projectList.Select(i) + next, _ := m.handleEnter() + return next.(wizardModel) +} + +// wizardWithTwoProjectsAndEnvsFor returns a model that has already selected the +// first project and received its environments. +func wizardWithTwoProjectsAndEnvsFor(t *testing.T, envs []envItem) wizardModel { + t.Helper() + m := wizardModel{step: stepSelectProject, width: 78, height: 24, spinner: spinner.New()} + loaded, _ := m.Update(projectsFetchedMsg{projects: []projectItem{ + {key: "proj-a", name: "A"}, + {key: "proj-b", name: "B"}, + }}) + first := selectProjectAtIndex(t, loaded.(wizardModel), 0) + require.Equal(t, "proj-a", first.selectedProject) + + withEnvs, _ := first.Update(envsFetchedMsg{project: "proj-a", environments: envs}) + return withEnvs.(wizardModel) +} + +func TestWizard_ReselectProject_CannotSelectPreviousProjectsEnvironment(t *testing.T) { + m := wizardWithTwoProjectsAndEnvsFor(t, []envItem{{key: "a-production", name: "A Production"}}) + + back, _ := m.handleBack() + second := selectProjectAtIndex(t, back.(wizardModel), 1) + require.Equal(t, "proj-b", second.selectedProject) + + assert.Empty(t, second.environments) + assert.Empty(t, second.selectedEnv) + + // Enter while the new fetch is in flight must not commit a key from proj-a. + pressed, _ := second.handleEnter() + got := pressed.(wizardModel) + assert.Empty(t, got.selectedEnv) + assert.Equal(t, stepSelectEnvironment, got.step) +} + +func TestWizard_ReselectProject_ShowsSpinnerNotStaleList(t *testing.T) { + m := wizardWithTwoProjectsAndEnvsFor(t, []envItem{{key: "a-production", name: "A Production"}}) + require.Contains(t, m.View(), "A Production") + + back, _ := m.handleBack() + second := selectProjectAtIndex(t, back.(wizardModel), 1) + + v := second.View() + assert.Contains(t, v, "Loading environments") + assert.NotContains(t, v, "A Production") + assert.NotContains(t, v, "No environments available") +} + +func TestWizard_ReselectProject_AfterEmptyList_ShowsSpinnerNotEmptyState(t *testing.T) { + m := wizardWithTwoProjectsAndEnvsFor(t, nil) + require.Contains(t, m.View(), "No environments available") + + back, _ := m.handleBack() + second := selectProjectAtIndex(t, back.(wizardModel), 1) + + assert.Contains(t, second.View(), "Loading environments") + + // The new project's environments still land normally. + withEnvs, _ := second.Update(envsFetchedMsg{project: "proj-b", environments: []envItem{{key: "b-production", name: "B Production"}}}) + assert.Contains(t, withEnvs.(wizardModel).View(), "B Production") +} + +func TestWizard_ReselectProject_WindowSizeDoesNotPanic(t *testing.T) { + m := wizardWithTwoProjectsAndEnvsFor(t, []envItem{{key: "a-production", name: "A Production"}}) + back, _ := m.handleBack() + second := selectProjectAtIndex(t, back.(wizardModel), 1) + + // Resizing with the env list cleared, then again once the fetch lands. + resized, _ := second.Update(tea.WindowSizeMsg{Width: 120, Height: 50}) + withEnvs, _ := resized.(wizardModel).Update(envsFetchedMsg{project: "proj-b", environments: []envItem{{key: "b-production", name: "B Production"}}}) + got := withEnvs.(wizardModel) + assert.Equal(t, 120, got.envList.Width()) + + again, _ := got.Update(tea.WindowSizeMsg{Width: 60, Height: 30}) + assert.Equal(t, 60, again.(wizardModel).envList.Width()) +} + +func TestWizard_BackFromSDK_KeepsEnvironmentList(t *testing.T) { + // Back from the SDK step does not re-fetch, so the env list must survive it. + m := wizardWithTwoProjectsAndEnvsFor(t, []envItem{{key: "a-production", name: "A Production"}}) + m.step = stepSelectSDK + + back, _ := m.handleBack() + got := back.(wizardModel) + + assert.Equal(t, stepSelectEnvironment, got.step) + assert.True(t, got.envsLoaded) + assert.Contains(t, got.View(), "A Production") +} + +func TestWizard_WindowSize_ResizesExistingLists(t *testing.T) { + m := wizardModel{step: stepSelectProject, width: 40, height: 10, spinner: spinner.New()} + withList, _ := m.Update(projectsFetchedMsg{projects: []projectItem{{key: "p1", name: "One"}}}) + + resized, _ := withList.(wizardModel).Update(tea.WindowSizeMsg{Width: 120, Height: 50}) + got := resized.(wizardModel) + + assert.Equal(t, 120, got.projectList.Width()) + assert.Equal(t, got.listHeight(), got.projectList.Height()) +} + +func TestWizard_ListHeight_NeverNegativeBeforeWindowSize(t *testing.T) { + // No WindowSizeMsg yet, so height is still zero and height-4 would be negative. + m := wizardModel{step: stepSelectProject, spinner: spinner.New()} + + assert.GreaterOrEqual(t, m.listHeight(), 3) + + withList, _ := m.Update(projectsFetchedMsg{projects: []projectItem{{key: "p1", name: "One"}}}) + assert.GreaterOrEqual(t, withList.(wizardModel).projectList.Height(), 3) +} + +// envDetailsInFlight returns a model that has selected proj-a/production and is +// waiting on the SDK keys for it. +func envDetailsInFlight(t *testing.T) wizardModel { + t.Helper() + m := wizardWithTwoProjectsAndEnvsFor(t, []envItem{{key: "production", name: "Prod"}}) + next, _ := m.handleEnter() + got := next.(wizardModel) + require.Equal(t, "production", got.selectedEnv) + require.Equal(t, stepSelectEnvironment, got.step) + return got +} + +func TestWizard_EnvDetails_LandingAfterBack_IsIgnored(t *testing.T) { + m := envDetailsInFlight(t) + + // User presses ← before the keys arrive. + back, _ := m.handleBack() + m = back.(wizardModel) + require.Equal(t, stepSelectProject, m.step) + + late, _ := m.Update(envDetailsFetchedMsg{ + project: "proj-a", env: "production", + sdkKey: "sdk-A", clientSideID: "cs-A", mobileKey: "mob-A", + }) + got := late.(wizardModel) + + // Must not yank the user into SDK selection with no environment selected. + assert.Equal(t, stepSelectProject, got.step) + assert.Empty(t, got.sdkKey) + assert.Empty(t, got.selectedEnv) +} + +func TestWizard_EnvDetails_OutOfOrder_KeepsSelectedEnvsKeys(t *testing.T) { + m := envDetailsInFlight(t) // production selected, its fetch in flight + m.detectComplete = true + + // User goes back and selects a different environment before the first lands. + back, _ := m.handleBack() + m = back.(wizardModel) + m = selectProjectAtIndex(t, m, 0) + withEnvs, _ := m.Update(envsFetchedMsg{project: "proj-a", environments: []envItem{ + {key: "production", name: "Prod"}, {key: "test", name: "Test"}, + }}) + m = withEnvs.(wizardModel) + m.envList.Select(1) // test + next, _ := m.handleEnter() + m = next.(wizardModel) + require.Equal(t, "test", m.selectedEnv) + + // The superseded production response lands last and must be dropped. + stale, _ := m.Update(envDetailsFetchedMsg{ + project: "proj-a", env: "production", sdkKey: "sdk-PROD", + }) + m = stale.(wizardModel) + assert.Empty(t, m.sdkKey, "production's key must not be adopted while test is selected") + + // test's own response is still accepted. + fresh, _ := m.Update(envDetailsFetchedMsg{ + project: "proj-a", env: "test", sdkKey: "sdk-TEST", + }) + assert.Equal(t, "sdk-TEST", fresh.(wizardModel).sdkKey) +} + +func TestWizard_EnvDetails_DuplicateOnDoneScreen_IsIgnored(t *testing.T) { + m := wizardModel{ + step: stepDone, width: 78, spinner: spinner.New(), + selectedProject: "proj-a", selectedEnv: "production", + verifyResult: &setup.VerifyResult{Active: true}, + detectResult: &setup.DetectResult{SDKID: "node-server"}, + } + + dup, _ := m.Update(envDetailsFetchedMsg{project: "proj-a", env: "production", sdkKey: "sdk-A"}) + + assert.Equal(t, stepDone, dup.(wizardModel).step, "a duplicate must not reopen SDK selection") +} + +func TestWizard_EnvsFetched_ForSupersededProject_IsIgnored(t *testing.T) { + m := wizardWithTwoProjectsAndEnvsFor(t, []envItem{{key: "only-in-a", name: "Only In A"}}) + + back, _ := m.handleBack() + m = selectProjectAtIndex(t, back.(wizardModel), 1) + require.Equal(t, "proj-b", m.selectedProject) + + // proj-a's in-flight list lands after proj-b was chosen. + stale, _ := m.Update(envsFetchedMsg{project: "proj-a", environments: []envItem{{key: "only-in-a", name: "Only In A"}}}) + m = stale.(wizardModel) + assert.Empty(t, m.environments) + assert.False(t, m.envsLoaded) + assert.Contains(t, m.View(), "Loading environments") + + // proj-b's own list is accepted. + fresh, _ := m.Update(envsFetchedMsg{project: "proj-b", environments: []envItem{{key: "b-prod", name: "B Prod"}}}) + assert.Contains(t, fresh.(wizardModel).View(), "B Prod") +} diff --git a/cmd/templates.go b/cmd/templates.go index b806ed7b..46a5c1f2 100644 --- a/cmd/templates.go +++ b/cmd/templates.go @@ -12,7 +12,8 @@ func getUsageTemplate() string { {{.CommandPath}} [command]{{end}} {{if not .HasParent}} Commands: - {{rpad "setup" 29}} Create your first feature flag using a step-by-step guide + {{rpad "setup" 29}} Set up LaunchDarkly in your project (detect, install, initialize) + {{rpad "quickstart" 29}} Create your first feature flag using a step-by-step guide (deprecated: use setup) {{rpad "config" 29}} View and modify specific configuration values {{rpad "completion" 29}} Enable command autocompletion within supported shells {{rpad "login" 29}} Log in to your LaunchDarkly account diff --git a/cmd/templates_test.go b/cmd/templates_test.go new file mode 100644 index 00000000..a4717eb3 --- /dev/null +++ b/cmd/templates_test.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// The root usage listing is hand-maintained, so a command added to or removed from +// the tree does not update it. The symbols entry was dropped from both at once. +func TestGetUsageTemplate_ListsTopLevelCommands(t *testing.T) { + template := getUsageTemplate() + + for _, name := range []string{ + "setup", + "quickstart", + "config", + "completion", + "login", + "signup", + "dev-server", + "flags", + "environments", + "projects", + "members", + "segments", + "sourcemaps", + "symbols", + } { + assert.Contains(t, template, `"`+name+`"`, "%s is missing from the root usage listing", name) + } +} diff --git a/go.mod b/go.mod index f1d7e9c1..cc0d60d8 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,14 @@ go 1.24.3 require ( github.com/adrg/xdg v0.5.3 + github.com/atotto/clipboard v0.1.4 github.com/blacktop/go-dwarf v1.0.14 github.com/blacktop/go-macho v1.1.282 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/charmbracelet/x/ansi v0.9.3 github.com/getkin/kin-openapi v0.135.0 github.com/google/uuid v1.6.0 github.com/gorilla/handlers v1.5.2 @@ -41,11 +43,9 @@ require ( require ( github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.1 // indirect diff --git a/internal/environments/client.go b/internal/environments/client.go index 6abbc757..734c0a9b 100644 --- a/internal/environments/client.go +++ b/internal/environments/client.go @@ -10,6 +10,7 @@ import ( type Client interface { Get(ctx context.Context, accessToken, baseURI, key, projKey string) ([]byte, error) + List(ctx context.Context, accessToken, baseURI, projKey string, limit, offset int64) ([]byte, error) } type EnvironmentsClient struct { @@ -46,3 +47,34 @@ func (c EnvironmentsClient) Get( return output, nil } + +func (c EnvironmentsClient) List( + ctx context.Context, + accessToken, + baseURI, + projectKey string, + limit, + offset int64, +) ([]byte, error) { + client := client.New(accessToken, baseURI, c.cliVersion) + req := client.EnvironmentsApi.GetEnvironmentsByProject(ctx, projectKey) + if limit > 0 { + req = req.Limit(limit) + } + if offset > 0 { + req = req.Offset(offset) + } + environments, _, err := req.Execute() + if err != nil { + return nil, errors.NewLDAPIError(err) + + } + + output, err := json.Marshal(environments) + if err != nil { + return nil, errors.NewLDAPIError(err) + + } + + return output, nil +} diff --git a/internal/environments/mock_client.go b/internal/environments/mock_client.go index c53ff20c..f4862e6c 100644 --- a/internal/environments/mock_client.go +++ b/internal/environments/mock_client.go @@ -23,3 +23,16 @@ func (c *MockClient) Get( return args.Get(0).([]byte), args.Error(1) } + +func (c *MockClient) List( + ctx context.Context, + accessToken, + baseURI, + projKey string, + limit, + offset int64, +) ([]byte, error) { + args := c.Called(accessToken, baseURI, projKey, limit, offset) + + return args.Get(0).([]byte), args.Error(1) +} diff --git a/internal/flags/client.go b/internal/flags/client.go index c4cfdd8c..4f51188f 100644 --- a/internal/flags/client.go +++ b/internal/flags/client.go @@ -17,8 +17,37 @@ type UpdateInput struct { Value interface{} `json:"value"` } +// ClientSideAvailability says which SDK kinds may evaluate a flag. The API +// defaults usingEnvironmentId to false, so a flag a browser SDK is meant to read +// has to ask for it explicitly. +type ClientSideAvailability struct { + UsingEnvironmentID bool + UsingMobileKey bool +} + +// CreateOption adjusts the flag being created. Callers that pass none get the +// API's own defaults. +type CreateOption func(*createConfig) + +type createConfig struct { + availability *ClientSideAvailability +} + +// WithClientSideAvailability makes the new flag available to the given SDK kinds. +func WithClientSideAvailability(a ClientSideAvailability) CreateOption { + return func(c *createConfig) { c.availability = &a } +} + +func resolveCreateOptions(opts []CreateOption) createConfig { + var cfg createConfig + for _, opt := range opts { + opt(&cfg) + } + return cfg +} + type Client interface { - Create(ctx context.Context, accessToken, baseURI, name, key, projKey string) ([]byte, error) + Create(ctx context.Context, accessToken, baseURI, name, key, projKey string, opts ...CreateOption) ([]byte, error) Get(ctx context.Context, accessToken, baseURI, key, projKey, envKey string) ([]byte, error) Update( ctx context.Context, @@ -49,9 +78,13 @@ func (c FlagsClient) Create( name, key, projectKey string, + opts ...CreateOption, ) ([]byte, error) { client := client.New(accessToken, baseURI, c.cliVersion) post := ldapi.NewFeatureFlagBody(name, key) + if a := resolveCreateOptions(opts).availability; a != nil { + post.SetClientSideAvailability(*ldapi.NewClientSideAvailabilityPost(a.UsingEnvironmentID, a.UsingMobileKey)) + } flag, _, err := client.FeatureFlagsApi.PostFeatureFlag(ctx, projectKey).FeatureFlagBody(*post).Execute() if err != nil { return nil, errors.NewLDAPIError(err) diff --git a/internal/flags/mock_client.go b/internal/flags/mock_client.go index 8dc8e17c..2cdf70da 100644 --- a/internal/flags/mock_client.go +++ b/internal/flags/mock_client.go @@ -8,6 +8,9 @@ import ( type MockClient struct { mock.Mock + // CreatedAvailability is the client-side availability the last Create call + // asked for, or nil if it asked for none. + CreatedAvailability *ClientSideAvailability } var _ Client = &MockClient{} @@ -19,7 +22,11 @@ func (c *MockClient) Create( name, key, projKey string, + opts ...CreateOption, ) ([]byte, error) { + // Recorded rather than passed to Called so existing expectations, which set no + // options, keep matching. + c.CreatedAvailability = resolveCreateOptions(opts).availability args := c.Called(accessToken, baseURI, name, key, projKey) return args.Get(0).([]byte), args.Error(1) diff --git a/internal/projects/mock.go b/internal/projects/mock.go index c8fa59a5..c9452ec8 100644 --- a/internal/projects/mock.go +++ b/internal/projects/mock.go @@ -28,8 +28,10 @@ func (c *MockClient) List( ctx context.Context, accessToken, baseURI string, + limit, + offset int64, ) ([]byte, error) { - args := c.Called(accessToken, baseURI) + args := c.Called(accessToken, baseURI, limit, offset) return args.Get(0).([]byte), args.Error(1) } diff --git a/internal/projects/projects.go b/internal/projects/projects.go index d4e4151b..8a192a45 100644 --- a/internal/projects/projects.go +++ b/internal/projects/projects.go @@ -12,7 +12,7 @@ import ( type Client interface { Create(ctx context.Context, accessToken, baseURI, name, key string) ([]byte, error) - List(ctx context.Context, accessToken, baseURI string) ([]byte, error) + List(ctx context.Context, accessToken, baseURI string, limit, offset int64) ([]byte, error) } type ProjectsClient struct { @@ -52,10 +52,18 @@ func (c ProjectsClient) List( ctx context.Context, accessToken, baseURI string, + limit, + offset int64, ) ([]byte, error) { client := client.New(accessToken, baseURI, c.cliVersion) - projects, _, err := client.ProjectsApi. - GetProjects(ctx).Execute() + req := client.ProjectsApi.GetProjects(ctx) + if limit > 0 { + req = req.Limit(limit) + } + if offset > 0 { + req = req.Offset(offset) + } + projects, _, err := req.Execute() if err != nil { return nil, errors.NewLDAPIError(err) } diff --git a/internal/setup/initializer.go b/internal/setup/initializer.go index 8ce42e2d..969f40dc 100644 --- a/internal/setup/initializer.go +++ b/internal/setup/initializer.go @@ -35,7 +35,11 @@ type InitResult struct { FilePath string `json:"file_path,omitempty"` DocsURL string `json:"docs_url,omitempty"` Snippet string `json:"snippet,omitempty"` - Success bool `json:"success"` + // AlreadyInitialized reports that the entry file initialized the SDK before + // this run, so nothing was written. Setup is complete either way, which is why + // it accompanies Success. + AlreadyInitialized bool `json:"already_initialized,omitempty"` + Success bool `json:"success"` } // appendSafeSDKs lists SDKs whose entry file is an interpreted script executed @@ -164,6 +168,19 @@ func InjectsInPlace(sdkID string) bool { return HasTemplate(sdkID) && appendSafeSDKs[sdkID] } +// UsesClientSideID reports whether an SDK authenticates with the environment's +// client-side ID rather than a server SDK key or a mobile key. It is derived from +// the SDK's own init template, which is the one place that already knows which +// credential the SDK takes, so a new template cannot disagree with a list here. +func UsesClientSideID(sdkID string) bool { + const sentinel = "__ld_client_side_id_probe__" + rendered, err := RenderTemplate(sdkID, InitConfig{ClientSideID: sentinel}) + if err != nil { + return false + } + return strings.Contains(rendered, sentinel) +} + // 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. @@ -265,6 +282,15 @@ func (i Initializer) InjectIntoFile(sdkID, filePath string, cfg InitConfig) (*In } content := string(existing) + if alreadyInitialized(content, importSection, initSection) { + return &InitResult{ + SDKID: sdkID, + FilePath: filePath, + AlreadyInitialized: true, + Success: true, + }, nil + } + if importSection != "" { prologue, body := splitPrologue(sdkID, content) content = prologue + importSection + "\n" + body @@ -278,6 +304,30 @@ func (i Initializer) InjectIntoFile(sdkID, filePath string, cfg InitConfig) (*In return &InitResult{SDKID: sdkID, FilePath: filePath, Success: true}, nil } +// alreadyInitialized reports whether the file already contains the initialization +// this template would add. Injection appends at file scope, so a second copy +// redeclares the same names: in Node that is a SyntaxError that stops the app from +// starting, and in Python and Ruby it silently rebinds the client. Matching the +// template's own import lines keeps the test in whatever language the file is +// written in, and matching any one of them errs toward leaving a half-configured +// file alone rather than appending into it. +func alreadyInitialized(content, importSection, initSection string) bool { + section := importSection + if strings.TrimSpace(section) == "" { + section = initSection + } + for _, line := range strings.Split(section, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "#") { + continue + } + if strings.Contains(content, line) { + return true + } + } + return false +} + // 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 diff --git a/internal/setup/initializer_test.go b/internal/setup/initializer_test.go index a99c571c..364d88e4 100644 --- a/internal/setup/initializer_test.go +++ b/internal/setup/initializer_test.go @@ -495,3 +495,62 @@ func TestRenderTemplate_MobileConfigCarriesRequiredArguments(t *testing.T) { }) } } + +// Running setup twice must not append a second copy. In Node the injected code +// declares const bindings, so a duplicate is a SyntaxError that stops the app. +func TestInjectIntoFile_SecondRunLeavesFileUnchanged(t *testing.T) { + tests := []struct { + sdkID string + name string + initial string + }{ + {"node-server", "index.js", "'use strict'\nconst express = require('express')\n\nexpress()\n"}, + {"node-server", "src/main.ts", "import express from 'express'\n\nexpress()\n"}, + {"python-server-sdk", "app.py", "\"\"\"docstring.\"\"\"\nimport os\n\nprint(os.getcwd())\n"}, + {"ruby-server-sdk", "config.ru", "# frozen_string_literal: true\nrequire 'rack'\n"}, + } + for _, tt := range tests { + t.Run(tt.sdkID+"/"+tt.name, func(t *testing.T) { + dir := t.TempDir() + entry := filepath.Join(dir, tt.name) + require.NoError(t, os.MkdirAll(filepath.Dir(entry), 0755)) + require.NoError(t, os.WriteFile(entry, []byte(tt.initial), 0644)) + + cfg := InitConfig{SDKKey: "sdk-KEY", FlagKey: "my-flag"} + first, err := Initializer{}.InjectIntoFile(tt.sdkID, entry, cfg) + require.NoError(t, err) + require.True(t, first.Success) + require.False(t, first.AlreadyInitialized) + afterFirst, err := os.ReadFile(entry) + require.NoError(t, err) + + second, err := Initializer{}.InjectIntoFile(tt.sdkID, entry, cfg) + require.NoError(t, err) + assert.True(t, second.AlreadyInitialized, "second run must report the file was already set up") + assert.True(t, second.Success) + + afterSecond, err := os.ReadFile(entry) + require.NoError(t, err) + assert.Equal(t, string(afterFirst), string(afterSecond), "second run must not modify the file") + }) + } +} + +// A file that only mentions a similarly-named package must still get injected; +// skipping it would leave the user with no initialization at all. +func TestInjectIntoFile_SimilarPackageNameStillInjects(t *testing.T) { + dir := t.TempDir() + entry := filepath.Join(dir, "index.js") + initial := "// TODO: evaluate @launchdarkly/node-server-sdk-metrics\n" + + "const other = require('@launchdarkly/node-server-sdk-metrics');\n" + require.NoError(t, os.WriteFile(entry, []byte(initial), 0644)) + + result, err := Initializer{}.InjectIntoFile("node-server", entry, InitConfig{SDKKey: "sdk-KEY"}) + require.NoError(t, err) + assert.True(t, result.Success) + assert.False(t, result.AlreadyInitialized) + + out, err := os.ReadFile(entry) + require.NoError(t, err) + assert.Contains(t, string(out), "const LaunchDarkly = require('@launchdarkly/node-server-sdk');") +} diff --git a/internal/setup/service.go b/internal/setup/service.go new file mode 100644 index 00000000..5645817c --- /dev/null +++ b/internal/setup/service.go @@ -0,0 +1,201 @@ +package setup + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/launchdarkly/ldcli/internal/environments" + "github.com/launchdarkly/ldcli/internal/flags" + "github.com/launchdarkly/ldcli/internal/projects" + "github.com/launchdarkly/ldcli/internal/resources" +) + +// Auth carries resolved credentials so the service never reads global config. +type Auth struct { + AccessToken string + BaseURI string +} + +// Clients groups the LaunchDarkly API clients the service depends on. Projects, +// Environments, and Flags use the shared typed clients; Resources backs Verify, +// whose sdk-active endpoint has no typed-client wrapper. +type Clients struct { + Projects projects.Client + Environments environments.Client + Flags flags.Client + Resources resources.Client +} + +// Service orchestrates the setup steps over the LaunchDarkly API and the local +// project. It holds no UI or CLI state; callers resolve credentials into Auth +// and pass them in. +type Service struct { + Clients Clients + Detector Detector + Installer Installer + Initializer Initializer +} + +// ProjectSummary is a project as the setup flow needs it. +type ProjectSummary struct { + Key string + Name string +} + +// EnvSummary is an environment as the setup flow needs it. +type EnvSummary struct { + Key string + Name string +} + +// EnvKeys are the SDK credentials for an environment. +type EnvKeys struct { + SDKKey string + ClientSideID string + MobileKey string +} + +// listPageSize is how many items each list request asks for. The wizard needs +// every project and environment, so the requests page through until a short page +// says there are no more. +const listPageSize = 100 + +// keyedItems is the shape both list endpoints return. +type keyedItems struct { + Items []struct { + Key string `json:"key"` + Name string `json:"name"` + } `json:"items"` +} + +// ListProjects returns the account's projects, following pagination so accounts +// with more projects than a single page are listed in full. +func (s Service) ListProjects(a Auth) ([]ProjectSummary, error) { + var projects []ProjectSummary + for offset := int64(0); ; offset += listPageSize { + res, err := s.Clients.Projects.List(context.Background(), a.AccessToken, a.BaseURI, listPageSize, offset) + if err != nil { + return nil, err + } + + var resp keyedItems + if err := json.Unmarshal(res, &resp); err != nil { + return nil, fmt.Errorf("parsing projects: %w", err) + } + + for _, item := range resp.Items { + projects = append(projects, ProjectSummary{Key: item.Key, Name: item.Name}) + } + if len(resp.Items) < listPageSize { + return projects, nil + } + } +} + +// ListEnvironments returns the environments in a project, following pagination so +// projects with more environments than a single page are listed in full. +func (s Service) ListEnvironments(a Auth, projectKey string) ([]EnvSummary, error) { + var envs []EnvSummary + for offset := int64(0); ; offset += listPageSize { + res, err := s.Clients.Environments.List(context.Background(), a.AccessToken, a.BaseURI, projectKey, listPageSize, offset) + if err != nil { + return nil, err + } + + var resp keyedItems + if err := json.Unmarshal(res, &resp); err != nil { + return nil, fmt.Errorf("parsing environments: %w", err) + } + + for _, item := range resp.Items { + envs = append(envs, EnvSummary{Key: item.Key, Name: item.Name}) + } + if len(resp.Items) < listPageSize { + return envs, nil + } + } +} + +// EnvKeys returns the SDK credentials for an environment. +func (s Service) EnvKeys(a Auth, projectKey, envKey string) (EnvKeys, error) { + res, err := s.Clients.Environments.Get(context.Background(), a.AccessToken, a.BaseURI, envKey, projectKey) + if err != nil { + return EnvKeys{}, err + } + + var resp struct { + SDKKey string `json:"apiKey"` + ClientSideID string `json:"_id"` + MobileKey string `json:"mobileKey"` + } + if err := json.Unmarshal(res, &resp); err != nil { + return EnvKeys{}, fmt.Errorf("parsing environment details: %w", err) + } + + return EnvKeys{ + SDKKey: resp.SDKKey, + ClientSideID: resp.ClientSideID, + MobileKey: resp.MobileKey, + }, nil +} + +// Detect inspects the project directory for language, framework, and SDK. +func (s Service) Detect(dir string) (*DetectResult, error) { + return s.Detector.Detect(dir) +} + +// Install installs the SDK package for the project. It returns the installer's +// error unchanged; callers that must not dead-end (the interactive wizard) apply +// their own fallback, while non-interactive callers surface the error. +func (s Service) Install(dir string, detection *DetectResult) (*InstallResult, error) { + return s.Installer.Install(dir, detection) +} + +// CreateFlag creates a feature flag, treating an existing flag (conflict) as +// success and returning its key. +// CreateFlag creates the flag the wizard hands to the SDK. sdkID decides whether +// the flag has to be available to client-side SDKs: the API leaves +// usingEnvironmentId false by default, which would leave a browser SDK evaluating +// the fallback forever even though setup reported success. +func (s Service) CreateFlag(a Auth, projectKey, key, name, sdkID string) (string, error) { + var opts []flags.CreateOption + if UsesClientSideID(sdkID) { + opts = append(opts, flags.WithClientSideAvailability(flags.ClientSideAvailability{ + UsingEnvironmentID: true, + UsingMobileKey: true, + })) + } + _, err := s.Clients.Flags.Create(context.Background(), a.AccessToken, a.BaseURI, name, key, projectKey, opts...) + if err != nil { + if je, parseErr := parseJSONError(err); parseErr == nil && je.Code == "conflict" { + return key, nil + } + return "", err + } + return key, nil +} + +// Inject writes SDK initialization code into filePath. +func (s Service) Inject(sdkID, filePath string, cfg InitConfig) (*InitResult, error) { + return s.Initializer.InjectIntoFile(sdkID, filePath, cfg) +} + +// Verify polls until the SDK reports as active or a timeout is reached. +func (s Service) Verify(a Auth, projectKey, envKey, sdkID string) (*VerifyResult, error) { + return DefaultVerifier(s.Clients.Resources).Verify(a.AccessToken, a.BaseURI, projectKey, envKey, sdkID) +} + +type jsonError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// parseJSONError decodes a LaunchDarkly API error whose message is a JSON body. +func parseJSONError(err error) (*jsonError, error) { + var je jsonError + if parseErr := json.Unmarshal([]byte(err.Error()), &je); parseErr != nil { + return nil, parseErr + } + return &je, nil +} diff --git a/internal/setup/service_test.go b/internal/setup/service_test.go new file mode 100644 index 00000000..91636f15 --- /dev/null +++ b/internal/setup/service_test.go @@ -0,0 +1,238 @@ +package setup + +import ( + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/environments" + "github.com/launchdarkly/ldcli/internal/errors" + "github.com/launchdarkly/ldcli/internal/flags" + "github.com/launchdarkly/ldcli/internal/projects" + "github.com/launchdarkly/ldcli/internal/resources" +) + +var testAuth = Auth{AccessToken: "token", BaseURI: "https://example.com"} + +// fakeDetector / fakeInstaller let us drive the service's passthrough steps +// without the filesystem or shelling out. +type fakeDetector struct { + result *DetectResult + err error +} + +func (f fakeDetector) Detect(string) (*DetectResult, error) { return f.result, f.err } + +type fakeInstaller struct { + result *InstallResult + err error +} + +func (f fakeInstaller) Install(string, *DetectResult) (*InstallResult, error) { + return f.result, f.err +} + +func TestService_ListProjects(t *testing.T) { + mockProjects := &projects.MockClient{} + mockProjects.On("List", testAuth.AccessToken, testAuth.BaseURI, int64(listPageSize), int64(0)). + Return([]byte(`{"items":[{"key":"p1","name":"Project One"},{"key":"p2","name":"Project Two"}]}`), nil) + svc := Service{Clients: Clients{Projects: mockProjects}} + + got, err := svc.ListProjects(testAuth) + + require.NoError(t, err) + assert.Equal(t, []ProjectSummary{{Key: "p1", Name: "Project One"}, {Key: "p2", Name: "Project Two"}}, got) +} + +func TestService_ListEnvironments(t *testing.T) { + mockEnvs := &environments.MockClient{} + mockEnvs.On("List", testAuth.AccessToken, testAuth.BaseURI, "p1", int64(listPageSize), int64(0)). + Return([]byte(`{"items":[{"key":"production","name":"Production"}]}`), nil) + svc := Service{Clients: Clients{Environments: mockEnvs}} + + got, err := svc.ListEnvironments(testAuth, "p1") + + require.NoError(t, err) + assert.Equal(t, []EnvSummary{{Key: "production", Name: "Production"}}, got) +} + +func TestService_EnvKeys(t *testing.T) { + mockEnvs := &environments.MockClient{} + mockEnvs.On("Get", testAuth.AccessToken, testAuth.BaseURI, "production", "p1"). + Return([]byte(`{"apiKey":"sdk-123","_id":"client-456","mobileKey":"mob-789"}`), nil) + svc := Service{Clients: Clients{Environments: mockEnvs}} + + got, err := svc.EnvKeys(testAuth, "p1", "production") + + require.NoError(t, err) + assert.Equal(t, EnvKeys{SDKKey: "sdk-123", ClientSideID: "client-456", MobileKey: "mob-789"}, got) +} + +func TestService_CreateFlag_Success(t *testing.T) { + mockFlags := &flags.MockClient{} + mockFlags.On("Create", testAuth.AccessToken, testAuth.BaseURI, "My New Flag", "my-new-flag", "p1"). + Return([]byte(`{"key":"my-new-flag"}`), nil) + svc := Service{Clients: Clients{Flags: mockFlags}} + + key, err := svc.CreateFlag(testAuth, "p1", "my-new-flag", "My New Flag", "node-server") + + require.NoError(t, err) + assert.Equal(t, "my-new-flag", key) +} + +func TestService_CreateFlag_ConflictIsSuccess(t *testing.T) { + mockFlags := &flags.MockClient{} + mockFlags.On("Create", testAuth.AccessToken, testAuth.BaseURI, "My New Flag", "my-new-flag", "p1"). + Return([]byte(nil), errors.NewError(`{"code":"conflict","message":"already exists"}`)) + svc := Service{Clients: Clients{Flags: mockFlags}} + + key, err := svc.CreateFlag(testAuth, "p1", "my-new-flag", "My New Flag", "node-server") + + require.NoError(t, err) + assert.Equal(t, "my-new-flag", key) +} + +func TestService_CreateFlag_OtherErrorPropagates(t *testing.T) { + mockFlags := &flags.MockClient{} + mockFlags.On("Create", testAuth.AccessToken, testAuth.BaseURI, "My New Flag", "my-new-flag", "p1"). + Return([]byte(nil), errors.NewError(`{"code":"internal_error"}`)) + svc := Service{Clients: Clients{Flags: mockFlags}} + + _, err := svc.CreateFlag(testAuth, "p1", "my-new-flag", "My New Flag", "node-server") + + assert.Error(t, err) +} + +func TestService_Detect(t *testing.T) { + want := &DetectResult{Language: "go", SDKID: "go-server-sdk"} + svc := Service{Detector: fakeDetector{result: want}} + + got, err := svc.Detect("/some/dir") + + require.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestService_Install_Success(t *testing.T) { + want := &InstallResult{SDKID: "node-server", Success: true} + svc := Service{Installer: fakeInstaller{result: want}} + + got, err := svc.Install("/some/dir", &DetectResult{SDKID: "node-server"}) + + require.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestService_Install_ErrorPropagates(t *testing.T) { + // The service returns the installer's error unchanged; the wizard, not the + // service, decides whether to continue past a failed install. + svc := Service{Installer: fakeInstaller{err: errors.NewError("boom")}} + + _, err := svc.Install("/some/dir", &DetectResult{SDKID: "node-server"}) + + assert.Error(t, err) +} + +func TestService_Inject(t *testing.T) { + svc := Service{Initializer: Initializer{}} + filePath := filepath.Join(t.TempDir(), "index.js") + + result, err := svc.Inject("node-server", filePath, InitConfig{SDKKey: "sdk-123"}) + + require.NoError(t, err) + assert.Equal(t, "node-server", result.SDKID) + assert.True(t, result.Success) +} + +func TestService_Verify_Active(t *testing.T) { + svc := Service{Clients: Clients{Resources: &resources.MockClient{Response: []byte(`{"active":true}`)}}} + + result, err := svc.Verify(testAuth, "p1", "production", "node-server") + + require.NoError(t, err) + assert.True(t, result.Active) + assert.Equal(t, 1, result.Attempts) +} + +func TestService_ListProjects_FollowsPagination(t *testing.T) { + first := make([]string, listPageSize) + for i := range first { + first[i] = fmt.Sprintf(`{"key":"p%d","name":"Project %d"}`, i, i) + } + mockProjects := &projects.MockClient{} + mockProjects.On("List", testAuth.AccessToken, testAuth.BaseURI, int64(listPageSize), int64(0)). + Return([]byte(`{"items":[`+strings.Join(first, ",")+`]}`), nil) + mockProjects.On("List", testAuth.AccessToken, testAuth.BaseURI, int64(listPageSize), int64(listPageSize)). + Return([]byte(`{"items":[{"key":"last","name":"Last"}]}`), nil) + svc := Service{Clients: Clients{Projects: mockProjects}} + + got, err := svc.ListProjects(testAuth) + + require.NoError(t, err) + assert.Len(t, got, listPageSize+1) + assert.Equal(t, ProjectSummary{Key: "last", Name: "Last"}, got[len(got)-1]) + mockProjects.AssertExpectations(t) +} + +func TestService_ListEnvironments_FollowsPagination(t *testing.T) { + first := make([]string, listPageSize) + for i := range first { + first[i] = fmt.Sprintf(`{"key":"e%d","name":"Env %d"}`, i, i) + } + mockEnvs := &environments.MockClient{} + mockEnvs.On("List", testAuth.AccessToken, testAuth.BaseURI, "p1", int64(listPageSize), int64(0)). + Return([]byte(`{"items":[`+strings.Join(first, ",")+`]}`), nil) + mockEnvs.On("List", testAuth.AccessToken, testAuth.BaseURI, "p1", int64(listPageSize), int64(listPageSize)). + Return([]byte(`{"items":[{"key":"last","name":"Last"}]}`), nil) + svc := Service{Clients: Clients{Environments: mockEnvs}} + + got, err := svc.ListEnvironments(testAuth, "p1") + + require.NoError(t, err) + assert.Len(t, got, listPageSize+1) + mockEnvs.AssertExpectations(t) +} + +// A flag a browser SDK is meant to read has to be created with client-side +// availability: the API leaves usingEnvironmentId false, so without it the SDK +// evaluates the fallback forever while setup reports success. +func TestService_CreateFlag_ClientSideAvailability(t *testing.T) { + tests := []struct { + sdkID string + want *flags.ClientSideAvailability + }{ + {"js-client-sdk", &flags.ClientSideAvailability{UsingEnvironmentID: true, UsingMobileKey: true}}, + {"react-client-sdk", &flags.ClientSideAvailability{UsingEnvironmentID: true, UsingMobileKey: true}}, + {"node-server", nil}, + {"go-server-sdk", nil}, + {"react-native", nil}, + {"android", nil}, + {"swift-client-sdk", nil}, + } + for _, tt := range tests { + t.Run(tt.sdkID, func(t *testing.T) { + mockFlags := &flags.MockClient{} + mockFlags.On("Create", testAuth.AccessToken, testAuth.BaseURI, "My New Flag", "my-new-flag", "p1"). + Return([]byte(`{"key":"my-new-flag"}`), nil) + svc := Service{Clients: Clients{Flags: mockFlags}} + + _, err := svc.CreateFlag(testAuth, "p1", "my-new-flag", "My New Flag", tt.sdkID) + + require.NoError(t, err) + assert.Equal(t, tt.want, mockFlags.CreatedAvailability) + }) + } +} + +// The classification comes from each SDK's own init template, so this pins which +// credential every known SDK is understood to take. +func TestUsesClientSideID_MatchesTemplateCredentials(t *testing.T) { + clientSide := map[string]bool{"js-client-sdk": true, "react-client-sdk": true} + for _, sdk := range KnownSDKs { + assert.Equal(t, clientSide[sdk.ID], UsesClientSideID(sdk.ID), "sdk %s", sdk.ID) + } +}