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)