From 0805cc5faa48709d47a137be8a3c45e997781316 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Tue, 21 Jul 2026 16:39:05 -0400 Subject: [PATCH 01/13] chore(setup): add SDK installer library Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/setup/installer.go | 229 +++++++++++++++++++++++++++++++ internal/setup/installer_test.go | 201 +++++++++++++++++++++++++++ 2 files changed, 430 insertions(+) create mode 100644 internal/setup/installer.go create mode 100644 internal/setup/installer_test.go diff --git a/internal/setup/installer.go b/internal/setup/installer.go new file mode 100644 index 00000000..48039a65 --- /dev/null +++ b/internal/setup/installer.go @@ -0,0 +1,229 @@ +package setup + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// InstallResult contains the outcome of installing an SDK package. +type InstallResult struct { + SDKID string `json:"sdk_id"` + Package string `json:"package"` + Version string `json:"version"` + Command string `json:"command"` + DryRun bool `json:"dry_run,omitempty"` + AlreadyInstalled bool `json:"already_installed,omitempty"` + Failed bool `json:"failed,omitempty"` + Success bool `json:"success"` +} + +// RequiresManualInstall reports whether the SDK has no automated package-manager +// command and must be added by hand (e.g. Java, Android, Swift). +func RequiresManualInstall(sdkID string) bool { + return manualInstallSDKs[sdkID] +} + +// Installer runs the appropriate package manager command to add an SDK dependency. +type Installer interface { + Install(dir string, detection *DetectResult) (*InstallResult, error) +} + +// StubInstaller is a placeholder implementation. Replace with real install logic. +type StubInstaller struct{} + +var _ Installer = StubInstaller{} + +func (StubInstaller) Install(_ string, _ *DetectResult) (*InstallResult, error) { + return nil, errors.New("install is not yet implemented: a real Installer must be provided") +} + +// PackageInstaller implements Installer using the system package manager. +// Its run field can be replaced in tests to avoid executing real commands. +type PackageInstaller struct { + run func(dir string, args []string) ([]byte, error) +} + +var _ Installer = PackageInstaller{} + +// manualInstallSDKs lists SDKs that have no automated package-manager command +// (Java, Android, Swift) but ARE recognised. For these, Install returns +// Success=false without an error so the wizard can proceed and show the package +// identifier. An SDK ID that is neither installable nor in this set is unknown +// and is treated as an error rather than a silent no-op. +var manualInstallSDKs = map[string]bool{ + "java-server-sdk": true, + "android": true, + "android-client-sdk": true, + "swift-client-sdk": true, + "ios-client-sdk": true, +} + +// Install runs the appropriate package manager command to add the SDK dependency. +// For SDKs that require manual installation (e.g. Java, Android, Swift), Install +// returns a result with Success=false without returning an error. An unknown SDK +// ID returns an error. +func (p PackageInstaller) Install(dir string, detection *DetectResult) (*InstallResult, error) { + args, pkg := InstallArgs(detection.SDKID, detection.PackageManager) + if len(args) == 0 { + if !manualInstallSDKs[detection.SDKID] { + return nil, fmt.Errorf("unknown SDK %q: no install command available; specify a supported --sdk-id", detection.SDKID) + } + return &InstallResult{ + SDKID: detection.SDKID, + Package: pkg, + Success: false, + }, nil + } + + // Skip the install if the SDK is already a dependency of the project. + if IsInstalled(dir, detection.SDKID) { + return &InstallResult{ + SDKID: detection.SDKID, + Package: pkg, + AlreadyInstalled: true, + Success: true, + }, nil + } + + runner := p.run + if runner == nil { + runner = execRun + } + + out, err := runner(dir, args) + command := strings.Join(args, " ") + if err != nil { + return nil, fmt.Errorf("%s: %w\n%s", command, err, strings.TrimSpace(string(out))) + } + return &InstallResult{ + SDKID: detection.SDKID, + Package: pkg, + Command: command, + Success: true, + }, nil +} + +func execRun(dir string, args []string) ([]byte, error) { + cmd := exec.Command(args[0], args[1:]...) //nolint:gosec + cmd.Dir = dir + return cmd.CombinedOutput() +} + +// InstallArgs returns the command-line arguments and package name for installing the given SDK. +// Returns nil args for SDKs that require manual installation (e.g. Java, Android, Swift). +// packageManager is used for Node.js SDKs; for other runtimes the appropriate tool is chosen automatically. +func InstallArgs(sdkID, packageManager string) (args []string, pkg string) { + switch sdkID { + case "react-client-sdk": + pkg = "launchdarkly-react-client-sdk" + return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg + case "react-native": + pkg = "launchdarkly-react-native-client-sdk" + return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg + case "node-server": + pkg = "@launchdarkly/node-server-sdk" + return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg + case "js-client-sdk": + pkg = "@launchdarkly/js-client-sdk" + return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg + case "python-server-sdk": + pm := packageManager + if pm == "" { + pm = "pip" + } + pkg = "launchdarkly-server-sdk" + return []string{pm, "install", pkg}, pkg + case "go-server-sdk": + pkg = "github.com/launchdarkly/go-server-sdk/v7" + return []string{"go", "get", pkg}, pkg + case "ruby-server-sdk": + pkg = "launchdarkly-server-sdk" + return []string{"gem", "install", pkg}, pkg + case "dotnet-server-sdk": + pkg = "LaunchDarkly.ServerSdk" + return []string{"dotnet", "add", "package", pkg}, pkg + // SDKs requiring manual installation — return a meaningful package identifier + // so callers can display what the user needs to add. + case "java-server-sdk": + return nil, "com.launchdarkly:launchdarkly-java-server-sdk" + case "android", "android-client-sdk": + return nil, "com.launchdarkly:launchdarkly-android-client-sdk" + case "swift-client-sdk", "ios-client-sdk": + return nil, "LaunchDarkly" // Swift Package Manager / CocoaPods + default: + return nil, sdkID + } +} + +// nodeInstallCmd returns the install command arguments for a Node.js package manager. +func nodeInstallCmd(pm, pkg string) []string { + switch pm { + case "yarn": + return []string{"yarn", "add", pkg} + case "pnpm": + return []string{"pnpm", "add", pkg} + case "bun": + return []string{"bun", "add", pkg} + default: + return []string{"npm", "install", pkg} + } +} + +// resolveNodePM normalises the package manager name, defaulting to "npm". +func resolveNodePM(pm string) string { + switch pm { + case "yarn", "pnpm", "bun": + return pm + default: + return "npm" + } +} + +// IsInstalled reports whether the SDK is already a dependency of the project in +// dir, by looking for its package identifier in the relevant manifest(s). Only +// covers SDKs with an automated install command; returns false for manual SDKs +// and unknowns. +func IsInstalled(dir, sdkID string) bool { + _, pkg := InstallArgs(sdkID, "") + if pkg == "" { + return false + } + + var manifests []string + switch sdkID { + case "react-client-sdk", "react-native", "node-server", "js-client-sdk": + manifests = []string{"package.json"} + case "go-server-sdk": + manifests = []string{"go.mod", "go.sum"} + case "python-server-sdk": + manifests = []string{"requirements.txt", "pyproject.toml", "setup.py"} + case "ruby-server-sdk": + manifests = []string{"Gemfile", "Gemfile.lock"} + case "dotnet-server-sdk": + matches, _ := filepath.Glob(filepath.Join(dir, "*.csproj")) + for _, f := range matches { + if fileContains(f, pkg) { + return true + } + } + return false + default: + return false + } + + for _, mf := range manifests { + if fileContains(filepath.Join(dir, mf), pkg) { + return true + } + } + return false +} + +func fileContains(path, substr string) bool { + b, err := os.ReadFile(path) + return err == nil && strings.Contains(string(b), substr) +} diff --git a/internal/setup/installer_test.go b/internal/setup/installer_test.go new file mode 100644 index 00000000..7fa9ccb6 --- /dev/null +++ b/internal/setup/installer_test.go @@ -0,0 +1,201 @@ +package setup + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstallArgs_NodeSDKs(t *testing.T) { + tests := []struct { + sdkID string + pm string + wantCmd string + wantPkg string + }{ + {"react-client-sdk", "npm", "npm", "launchdarkly-react-client-sdk"}, + {"react-client-sdk", "yarn", "yarn", "launchdarkly-react-client-sdk"}, + {"react-client-sdk", "pnpm", "pnpm", "launchdarkly-react-client-sdk"}, + {"react-client-sdk", "bun", "bun", "launchdarkly-react-client-sdk"}, + {"react-client-sdk", "", "npm", "launchdarkly-react-client-sdk"}, + {"react-native", "npm", "npm", "launchdarkly-react-native-client-sdk"}, + {"react-native", "bun", "bun", "launchdarkly-react-native-client-sdk"}, + {"node-server", "npm", "npm", "@launchdarkly/node-server-sdk"}, + {"node-server", "yarn", "yarn", "@launchdarkly/node-server-sdk"}, + {"node-server", "pnpm", "pnpm", "@launchdarkly/node-server-sdk"}, + {"node-server", "bun", "bun", "@launchdarkly/node-server-sdk"}, + {"node-server", "", "npm", "@launchdarkly/node-server-sdk"}, + {"js-client-sdk", "npm", "npm", "@launchdarkly/js-client-sdk"}, + {"js-client-sdk", "bun", "bun", "@launchdarkly/js-client-sdk"}, + } + + for _, tt := range tests { + t.Run(tt.sdkID+"/"+tt.pm, func(t *testing.T) { + args, pkg := InstallArgs(tt.sdkID, tt.pm) + require.NotEmpty(t, args) + assert.Equal(t, tt.wantCmd, args[0]) + assert.Equal(t, tt.wantPkg, pkg) + assert.Contains(t, args, pkg) + }) + } +} + +func TestInstallArgs_Python(t *testing.T) { + args, pkg := InstallArgs("python-server-sdk", "") + require.NotEmpty(t, args) + assert.Equal(t, "pip", args[0]) + assert.Equal(t, "launchdarkly-server-sdk", pkg) + + args2, _ := InstallArgs("python-server-sdk", "pip3") + require.NotEmpty(t, args2) + assert.Equal(t, "pip3", args2[0]) +} + +func TestInstallArgs_Go(t *testing.T) { + args, pkg := InstallArgs("go-server-sdk", "") + require.NotEmpty(t, args) + assert.Equal(t, "go", args[0]) + assert.Equal(t, "get", args[1]) + assert.Equal(t, "github.com/launchdarkly/go-server-sdk/v7", pkg) +} + +func TestInstallArgs_Ruby(t *testing.T) { + args, pkg := InstallArgs("ruby-server-sdk", "") + require.NotEmpty(t, args) + assert.Equal(t, "gem", args[0]) + assert.Equal(t, "launchdarkly-server-sdk", pkg) +} + +func TestInstallArgs_Dotnet(t *testing.T) { + args, pkg := InstallArgs("dotnet-server-sdk", "") + require.NotEmpty(t, args) + assert.Equal(t, "dotnet", args[0]) + assert.Equal(t, "LaunchDarkly.ServerSdk", pkg) +} + +func TestInstallArgs_ManualSDKs(t *testing.T) { + tests := []struct { + sdkID string + wantPkg string + }{ + {"java-server-sdk", "com.launchdarkly:launchdarkly-java-server-sdk"}, + {"android", "com.launchdarkly:launchdarkly-android-client-sdk"}, + {"android-client-sdk", "com.launchdarkly:launchdarkly-android-client-sdk"}, + {"swift-client-sdk", "LaunchDarkly"}, + {"ios-client-sdk", "LaunchDarkly"}, + {"unknown-sdk-xyz", "unknown-sdk-xyz"}, // unknown falls back to SDK ID + } + for _, tt := range tests { + t.Run(tt.sdkID, func(t *testing.T) { + args, pkg := InstallArgs(tt.sdkID, "") + assert.Nil(t, args, "expected nil args for manual SDK %s", tt.sdkID) + assert.Equal(t, tt.wantPkg, pkg) + }) + } +} + +func TestPackageInstaller_Install_Success(t *testing.T) { + var capturedDir string + var capturedArgs []string + + installer := PackageInstaller{ + run: func(dir string, args []string) ([]byte, error) { + capturedDir = dir + capturedArgs = args + return []byte("added 1 package"), nil + }, + } + + result, err := installer.Install("/my/project", &DetectResult{ + SDKID: "node-server", + PackageManager: "npm", + }) + + require.NoError(t, err) + assert.True(t, result.Success) + assert.Equal(t, "node-server", result.SDKID) + assert.Equal(t, "@launchdarkly/node-server-sdk", result.Package) + assert.Equal(t, "npm install @launchdarkly/node-server-sdk", result.Command) + assert.Equal(t, "/my/project", capturedDir) + assert.Equal(t, []string{"npm", "install", "@launchdarkly/node-server-sdk"}, capturedArgs) +} + +func TestPackageInstaller_Install_CommandFailure(t *testing.T) { + installer := PackageInstaller{ + run: func(dir string, args []string) ([]byte, error) { + return []byte("npm ERR! not found"), errors.New("exit status 1") + }, + } + + _, err := installer.Install("/tmp", &DetectResult{ + SDKID: "node-server", + PackageManager: "npm", + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "npm install @launchdarkly/node-server-sdk") + assert.Contains(t, err.Error(), "npm ERR! not found") +} + +func TestPackageInstaller_Install_ManualSDK_ReturnsNoError(t *testing.T) { + installer := PackageInstaller{} + + result, err := installer.Install("/tmp", &DetectResult{SDKID: "java-server-sdk"}) + + require.NoError(t, err) + assert.False(t, result.Success) + assert.Equal(t, "java-server-sdk", result.SDKID) + assert.Empty(t, result.Command) +} + +func TestPackageInstaller_Install_AlreadyInstalled_SkipsCommand(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "package.json"), + []byte(`{"dependencies":{"@launchdarkly/node-server-sdk":"^9.0.0"}}`), 0644)) + + installer := PackageInstaller{ + run: func(_ string, _ []string) ([]byte, error) { + t.Fatal("package manager must not run when the SDK is already installed") + return nil, nil + }, + } + + result, err := installer.Install(dir, &DetectResult{SDKID: "node-server", PackageManager: "npm"}) + + require.NoError(t, err) + assert.True(t, result.AlreadyInstalled) + assert.True(t, result.Success) + assert.Empty(t, result.Command) +} + +func TestRequiresManualInstall(t *testing.T) { + assert.True(t, RequiresManualInstall("java-server-sdk")) + assert.True(t, RequiresManualInstall("swift-client-sdk")) + assert.False(t, RequiresManualInstall("node-server")) + assert.False(t, RequiresManualInstall("ruby-server-sdk")) +} + +func TestPackageInstaller_Install_UnknownSDK_ReturnsError(t *testing.T) { + installer := PackageInstaller{} + + _, err := installer.Install("/tmp", &DetectResult{SDKID: "totally-unknown-sdk"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown SDK") +} + +func TestPackageInstaller_Install_DefaultRunner_UsedWhenNil(t *testing.T) { + // PackageInstaller{} (zero value) should not panic — it uses execRun. + // We test this by using a manual SDK so no real command is executed. + installer := PackageInstaller{} + + result, err := installer.Install("/tmp", &DetectResult{SDKID: "android"}) + + require.NoError(t, err) + assert.False(t, result.Success) +} From 1f13de319352f4e13db24046c79b2daad707f65d Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Thu, 30 Jul 2026 23:48:11 -0400 Subject: [PATCH 02/13] fix(setup): install with the project's own package manager `gem install` left the Gemfile untouched, so the SDK stayed unavailable under bundler and IsInstalled kept returning false. Use `bundle add` when the project is Bundler-managed, and poetry, uv or pipenv when one of those manages the Python dependencies. Unrecognised package managers fall back to pip rather than being run as a command, since the value reaches InstallArgs from the detector. InstallArgs added launchdarkly-react-native-client-sdk, which npm marks deprecated in favour of @launchdarkly/react-native-client-sdk. The unscoped launchdarkly-js-client-sdk is the v3 package whose initialize API the init template uses; the scoped one is v4 and exposes createClient. Co-Authored-By: Claude Opus 5 (1M context) --- internal/setup/installer.go | 36 +++++++++++++++++----- internal/setup/installer_test.go | 53 ++++++++++++++++++++++++-------- 2 files changed, 69 insertions(+), 20 deletions(-) diff --git a/internal/setup/installer.go b/internal/setup/installer.go index 48039a65..7d1541f9 100644 --- a/internal/setup/installer.go +++ b/internal/setup/installer.go @@ -122,26 +122,30 @@ func InstallArgs(sdkID, packageManager string) (args []string, pkg string) { pkg = "launchdarkly-react-client-sdk" return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg case "react-native": - pkg = "launchdarkly-react-native-client-sdk" + pkg = "@launchdarkly/react-native-client-sdk" return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg case "node-server": pkg = "@launchdarkly/node-server-sdk" return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg case "js-client-sdk": - pkg = "@launchdarkly/js-client-sdk" + // The unscoped v3 package, whose initialize API the init template and the + // quickstart instructions both use. The scoped @launchdarkly/js-client-sdk is + // v4 and exposes createClient instead. + pkg = "launchdarkly-js-client-sdk" return nodeInstallCmd(resolveNodePM(packageManager), pkg), pkg case "python-server-sdk": - pm := packageManager - if pm == "" { - pm = "pip" - } pkg = "launchdarkly-server-sdk" - return []string{pm, "install", pkg}, pkg + return pythonInstallCmd(packageManager, pkg), pkg case "go-server-sdk": pkg = "github.com/launchdarkly/go-server-sdk/v7" return []string{"go", "get", pkg}, pkg case "ruby-server-sdk": pkg = "launchdarkly-server-sdk" + // Bundler-managed projects need the gem recorded in the Gemfile; a bare + // `gem install` would succeed without making the SDK available to the app. + if packageManager == "bundle" { + return []string{"bundle", "add", pkg}, pkg + } return []string{"gem", "install", pkg}, pkg case "dotnet-server-sdk": pkg = "LaunchDarkly.ServerSdk" @@ -159,6 +163,22 @@ func InstallArgs(sdkID, packageManager string) (args []string, pkg string) { } } +// pythonInstallCmd returns the install command arguments for a Python package +// manager. Anything unrecognised — including the empty string, which IsInstalled +// passes — falls back to pip. +func pythonInstallCmd(pm, pkg string) []string { + switch pm { + case "poetry": + return []string{"poetry", "add", pkg} + case "uv": + return []string{"uv", "add", pkg} + case "pipenv": + return []string{"pipenv", "install", pkg} + default: + return []string{"pip", "install", pkg} + } +} + // nodeInstallCmd returns the install command arguments for a Node.js package manager. func nodeInstallCmd(pm, pkg string) []string { switch pm { @@ -200,7 +220,7 @@ func IsInstalled(dir, sdkID string) bool { case "go-server-sdk": manifests = []string{"go.mod", "go.sum"} case "python-server-sdk": - manifests = []string{"requirements.txt", "pyproject.toml", "setup.py"} + manifests = []string{"requirements.txt", "pyproject.toml", "setup.py", "Pipfile", "uv.lock"} case "ruby-server-sdk": manifests = []string{"Gemfile", "Gemfile.lock"} case "dotnet-server-sdk": diff --git a/internal/setup/installer_test.go b/internal/setup/installer_test.go index 7fa9ccb6..e8cf482c 100644 --- a/internal/setup/installer_test.go +++ b/internal/setup/installer_test.go @@ -22,15 +22,15 @@ func TestInstallArgs_NodeSDKs(t *testing.T) { {"react-client-sdk", "pnpm", "pnpm", "launchdarkly-react-client-sdk"}, {"react-client-sdk", "bun", "bun", "launchdarkly-react-client-sdk"}, {"react-client-sdk", "", "npm", "launchdarkly-react-client-sdk"}, - {"react-native", "npm", "npm", "launchdarkly-react-native-client-sdk"}, - {"react-native", "bun", "bun", "launchdarkly-react-native-client-sdk"}, + {"react-native", "npm", "npm", "@launchdarkly/react-native-client-sdk"}, + {"react-native", "bun", "bun", "@launchdarkly/react-native-client-sdk"}, {"node-server", "npm", "npm", "@launchdarkly/node-server-sdk"}, {"node-server", "yarn", "yarn", "@launchdarkly/node-server-sdk"}, {"node-server", "pnpm", "pnpm", "@launchdarkly/node-server-sdk"}, {"node-server", "bun", "bun", "@launchdarkly/node-server-sdk"}, {"node-server", "", "npm", "@launchdarkly/node-server-sdk"}, - {"js-client-sdk", "npm", "npm", "@launchdarkly/js-client-sdk"}, - {"js-client-sdk", "bun", "bun", "@launchdarkly/js-client-sdk"}, + {"js-client-sdk", "npm", "npm", "launchdarkly-js-client-sdk"}, + {"js-client-sdk", "bun", "bun", "launchdarkly-js-client-sdk"}, } for _, tt := range tests { @@ -45,14 +45,26 @@ func TestInstallArgs_NodeSDKs(t *testing.T) { } func TestInstallArgs_Python(t *testing.T) { - args, pkg := InstallArgs("python-server-sdk", "") - require.NotEmpty(t, args) - assert.Equal(t, "pip", args[0]) - assert.Equal(t, "launchdarkly-server-sdk", pkg) - - args2, _ := InstallArgs("python-server-sdk", "pip3") - require.NotEmpty(t, args2) - assert.Equal(t, "pip3", args2[0]) + tests := []struct { + packageManager string + want []string + }{ + // IsInstalled calls InstallArgs with no package manager. + {"", []string{"pip", "install", "launchdarkly-server-sdk"}}, + {"pip", []string{"pip", "install", "launchdarkly-server-sdk"}}, + {"poetry", []string{"poetry", "add", "launchdarkly-server-sdk"}}, + {"uv", []string{"uv", "add", "launchdarkly-server-sdk"}}, + {"pipenv", []string{"pipenv", "install", "launchdarkly-server-sdk"}}, + // Unrecognised values fall back to pip rather than being run as a command. + {"conda", []string{"pip", "install", "launchdarkly-server-sdk"}}, + } + for _, tt := range tests { + t.Run(tt.packageManager, func(t *testing.T) { + args, pkg := InstallArgs("python-server-sdk", tt.packageManager) + assert.Equal(t, tt.want, args) + assert.Equal(t, "launchdarkly-server-sdk", pkg) + }) + } } func TestInstallArgs_Go(t *testing.T) { @@ -70,6 +82,23 @@ func TestInstallArgs_Ruby(t *testing.T) { assert.Equal(t, "launchdarkly-server-sdk", pkg) } +// A Gemfile means Bundler owns the project's gems, so the SDK must be added to the +// Gemfile; `gem install` would leave the app unable to require it under bundler. +func TestInstallArgs_Ruby_Bundler(t *testing.T) { + args, pkg := InstallArgs("ruby-server-sdk", "bundle") + assert.Equal(t, []string{"bundle", "add", "launchdarkly-server-sdk"}, args) + assert.Equal(t, "launchdarkly-server-sdk", pkg) +} + +func TestInstallArgs_Android_BothSpellings(t *testing.T) { + for _, id := range []string{"android", "android-client-sdk"} { + args, pkg := InstallArgs(id, "gradle") + assert.Nil(t, args, "Android has no automated install command") + assert.Equal(t, "com.launchdarkly:launchdarkly-android-client-sdk", pkg) + assert.True(t, RequiresManualInstall(id)) + } +} + func TestInstallArgs_Dotnet(t *testing.T) { args, pkg := InstallArgs("dotnet-server-sdk", "") require.NotEmpty(t, args) From a0e8b72372744e7c2f1542fb019e9aec4e2f50c7 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Tue, 21 Jul 2026 16:39:06 -0400 Subject: [PATCH 03/13] chore(setup): add SDK init/injection library and templates Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/setup/initializer.go | 264 ++++++++++++++++++ internal/setup/initializer_test.go | 244 ++++++++++++++++ .../setup/sdk_init_templates/android.tmpl | 10 + .../sdk_init_templates/dotnet-server-sdk.tmpl | 11 + .../sdk_init_templates/go-server-sdk.tmpl | 16 ++ .../sdk_init_templates/java-server-sdk.tmpl | 11 + .../sdk_init_templates/js-client-sdk.tmpl | 12 + .../setup/sdk_init_templates/node-server.tmpl | 15 + .../sdk_init_templates/python-server-sdk.tmpl | 11 + .../sdk_init_templates/react-client-sdk.tmpl | 10 + .../sdk_init_templates/react-native.tmpl | 5 + .../sdk_init_templates/ruby-server-sdk.tmpl | 12 + .../sdk_init_templates/swift-client-sdk.tmpl | 5 + 13 files changed, 626 insertions(+) create mode 100644 internal/setup/initializer.go create mode 100644 internal/setup/initializer_test.go create mode 100644 internal/setup/sdk_init_templates/android.tmpl create mode 100644 internal/setup/sdk_init_templates/dotnet-server-sdk.tmpl create mode 100644 internal/setup/sdk_init_templates/go-server-sdk.tmpl create mode 100644 internal/setup/sdk_init_templates/java-server-sdk.tmpl create mode 100644 internal/setup/sdk_init_templates/js-client-sdk.tmpl create mode 100644 internal/setup/sdk_init_templates/node-server.tmpl create mode 100644 internal/setup/sdk_init_templates/python-server-sdk.tmpl create mode 100644 internal/setup/sdk_init_templates/react-client-sdk.tmpl create mode 100644 internal/setup/sdk_init_templates/react-native.tmpl create mode 100644 internal/setup/sdk_init_templates/ruby-server-sdk.tmpl create mode 100644 internal/setup/sdk_init_templates/swift-client-sdk.tmpl diff --git a/internal/setup/initializer.go b/internal/setup/initializer.go new file mode 100644 index 00000000..48d8ad7d --- /dev/null +++ b/internal/setup/initializer.go @@ -0,0 +1,264 @@ +package setup + +import ( + "bytes" + "embed" + "errors" + "fmt" + "os" + "strings" + "text/template" +) + +//go:embed sdk_init_templates/*.tmpl +var initTemplateFiles embed.FS + +// InitConfig holds the values to interpolate into SDK initialization templates. +type InitConfig struct { + SDKKey string + ClientSideID string + MobileKey string + FlagKey string +} + +// InitResult describes the outcome of injecting SDK initialization code. +// +// Success is true only when initialization code was actually written to a file +// as valid, ready-to-run code. When Success is false, Snippet (if set) holds the +// rendered code the user must place manually, and DocsURL points at the setup +// guide. +type InitResult struct { + SDKID string `json:"sdk_id"` + FilePath string `json:"file_path,omitempty"` + DocsURL string `json:"docs_url,omitempty"` + Snippet string `json:"snippet,omitempty"` + Success bool `json:"success"` +} + +// appendSafeSDKs lists SDKs whose entry file is an interpreted script executed +// top-to-bottom, so initialization statements can be appended at file scope and +// still run. For every other SDK — compiled/scoped languages (Go, Java, C#, +// Swift, Android) whose statements are illegal at file scope, and framework SDKs +// (React, React Native) that must be wired into a component tree — appending +// produces code that does not compile or does not run, so we return the snippet +// as guidance instead of writing a broken file. +var appendSafeSDKs = map[string]bool{ + "node-server": true, + "python-server-sdk": true, + "ruby-server-sdk": true, +} + +// Initializer injects SDK initialization code into a target file. +type Initializer struct{} + +// sdkTemplateInfo maps an SDK ID to the template filename. +type sdkTemplateInfo struct { + TemplateFile string +} + +var sdkTemplates = map[string]sdkTemplateInfo{ + "react-client-sdk": {TemplateFile: "react-client-sdk.tmpl"}, + "react-native": {TemplateFile: "react-native.tmpl"}, + "js-client-sdk": {TemplateFile: "js-client-sdk.tmpl"}, + "swift-client-sdk": {TemplateFile: "swift-client-sdk.tmpl"}, + "android-client-sdk": {TemplateFile: "android.tmpl"}, + "java-server-sdk": {TemplateFile: "java-server-sdk.tmpl"}, + "ruby-server-sdk": {TemplateFile: "ruby-server-sdk.tmpl"}, + "go-server-sdk": {TemplateFile: "go-server-sdk.tmpl"}, + "python-server-sdk": {TemplateFile: "python-server-sdk.tmpl"}, + "dotnet-server-sdk": {TemplateFile: "dotnet-server-sdk.tmpl"}, + "node-server": {TemplateFile: "node-server.tmpl"}, +} + +// sdkDocsPaths maps SDK IDs to their documentation path on launchdarkly.com/docs. +// Covers all SDKs, including those without init templates. +var sdkDocsPaths = map[string]string{ + "akamai-server-edgekv-sdk": "sdk/edge/akamai", + "android": "sdk/client-side/android", + "android-client-sdk": "sdk/client-side/android", + "apex-server-sdk": "sdk/server-side/apex", + "cpp-client-sdk": "sdk/client-side/c-c--", + "cpp-server-sdk": "sdk/server-side/c-c--", + "cloudflare-server-sdk": "sdk/edge/cloudflare", + "dotnet-client-sdk": "sdk/client-side/dotnet", + "dotnet-server-sdk": "sdk/server-side/dotnet", + "electron-client-sdk": "sdk/client-side/electron", + "erlang-server-sdk": "sdk/server-side/erlang", + "flutter-client-sdk": "sdk/client-side/flutter", + "go-server-sdk": "sdk/server-side/go", + "haskell-server-sdk": "sdk/server-side/haskell", + "ios-client-sdk": "sdk/client-side/ios", + "swift-client-sdk": "sdk/client-side/ios", + "java-server-sdk": "sdk/server-side/java", + "js-client-sdk": "sdk/client-side/javascript", + "lua-server-sdk": "sdk/server-side/lua", + "node-client-sdk": "sdk/client-side/node-js", + "node-server": "sdk/server-side/node-js", + "node-server-sdk": "sdk/server-side/node-js", + "php-server-sdk": "sdk/server-side/php", + "python-server-sdk": "sdk/server-side/python", + "react-client-sdk": "sdk/client-side/react", + "react-native": "sdk/client-side/react-native", + "react-native-client-sdk": "sdk/client-side/react-native", + "roku-client-sdk": "sdk/client-side/roku", + "ruby-server-sdk": "sdk/server-side/ruby", + "rust-server-sdk": "sdk/server-side/rust", + "vercel-server-sdk": "sdk/edge/vercel", + "vue-client-sdk": "sdk/client-side/vue", +} + +const docsBaseURL = "https://launchdarkly.com/docs" + +// GetDocsURL returns the full documentation URL for the given SDK ID. +// Falls back to the top-level SDK docs page if the ID is unknown. +func GetDocsURL(sdkID string) string { + if path, ok := sdkDocsPaths[sdkID]; ok { + return docsBaseURL + "/" + path + } + return docsBaseURL + "/sdk" +} + +// SupportedSDKIDs returns the list of SDK IDs that have initialization templates. +func SupportedSDKIDs() []string { + ids := make([]string, 0, len(sdkTemplates)) + for id := range sdkTemplates { + ids = append(ids, id) + } + return ids +} + +// HasTemplate returns true if the given SDK ID has an initialization template. +func HasTemplate(sdkID string) bool { + _, ok := sdkTemplates[sdkID] + return ok +} + +// InjectsInPlace reports whether `init` writes runnable code directly into the +// entry file (true) versus returning a snippet for the user to place manually +// (false). Also indicates whether a live verify step is meaningful afterward. +func InjectsInPlace(sdkID string) bool { + return HasTemplate(sdkID) && appendSafeSDKs[sdkID] +} + +// RenderTemplate renders the initialization code for the given SDK. +func RenderTemplate(sdkID string, cfg InitConfig) (string, error) { + info, ok := sdkTemplates[sdkID] + if !ok { + return "", fmt.Errorf("no initialization template for SDK %q; see docs: %s", sdkID, GetDocsURL(sdkID)) + } + + content, err := initTemplateFiles.ReadFile("sdk_init_templates/" + info.TemplateFile) + if err != nil { + return "", fmt.Errorf("reading template for %s: %w", sdkID, err) + } + + tmpl, err := template.New(sdkID).Parse(string(content)) + if err != nil { + return "", fmt.Errorf("parsing template for %s: %w", sdkID, err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, cfg); err != nil { + return "", fmt.Errorf("executing template for %s: %w", sdkID, err) + } + + return buf.String(), nil +} + +// InjectIntoFile renders the SDK initialization code and, for SDKs whose entry +// file is an interpreted script (see appendSafeSDKs), writes it into filePath: +// imports are placed at the top and init code appended after existing content. +// +// For SDKs that are not append-safe — because file-scope statements would not +// compile (Go, Java, C#, Swift, Android) or because the code must be wired into +// a component tree (React, React Native) — the file is left untouched and the +// result carries the rendered Snippet plus DocsURL as guidance, with +// Success=false so callers do not report a broken file as ready. +// +// If no template exists for the SDK at all, the result carries only the +// documentation URL. +// +// The template output is split into an IMPORTS section and an INIT section by a +// separator line ("// --- init ---" or "# --- init ---" depending on language). +func (i Initializer) InjectIntoFile(sdkID, filePath string, cfg InitConfig) (*InitResult, error) { + if !HasTemplate(sdkID) { + return &InitResult{ + SDKID: sdkID, + DocsURL: GetDocsURL(sdkID), + Success: false, + }, nil + } + + rendered, err := RenderTemplate(sdkID, cfg) + if err != nil { + return nil, err + } + + importSection, initSection := splitInitSections(rendered) + + if !appendSafeSDKs[sdkID] { + return &InitResult{ + SDKID: sdkID, + FilePath: filePath, + DocsURL: GetDocsURL(sdkID), + Snippet: joinSnippet(importSection, initSection), + Success: false, + }, nil + } + + existing, err := os.ReadFile(filePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + var content string + if importSection != "" { + content = importSection + "\n\n" + initSection + "\n" + } else { + content = initSection + "\n" + } + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + return nil, fmt.Errorf("creating %s: %w", filePath, err) + } + return &InitResult{SDKID: sdkID, FilePath: filePath, Success: true}, nil + } + return nil, fmt.Errorf("reading %s: %w", filePath, err) + } + + content := string(existing) + if importSection != "" { + content = importSection + "\n" + content + } + content = content + "\n\n" + initSection + "\n" + + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + return nil, fmt.Errorf("writing %s: %w", filePath, err) + } + + return &InitResult{SDKID: sdkID, FilePath: filePath, Success: true}, nil +} + +// joinSnippet recombines the import and init sections into a single human-readable +// snippet the user can copy into the correct place in their code. +func joinSnippet(importSection, initSection string) string { + if importSection == "" { + return initSection + } + return importSection + "\n\n" + initSection +} + +// initSeparators lists the markers that divide import and init sections in templates. +var initSeparators = []string{ + "// --- init ---", + "# --- init ---", +} + +// splitInitSections splits rendered template output into an import section and an +// init section. It recognises comment-style-appropriate separators so that templates +// for languages like Python and Ruby can use `#` comments. +func splitInitSections(rendered string) (importSection, initSection string) { + for _, sep := range initSeparators { + if parts := strings.SplitN(rendered, sep, 2); len(parts) == 2 { + return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) + } + } + return "", rendered +} diff --git a/internal/setup/initializer_test.go b/internal/setup/initializer_test.go new file mode 100644 index 00000000..70c2eb04 --- /dev/null +++ b/internal/setup/initializer_test.go @@ -0,0 +1,244 @@ +package setup + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderTemplate(t *testing.T) { + cfg := InitConfig{ + SDKKey: "sdk-test-key-123", + ClientSideID: "client-id-456", + MobileKey: "mob-key-789", + FlagKey: "my-test-flag", + } + + tests := []struct { + name string + sdkID string + wantSubstr string + }{ + {"node-server", "node-server", "sdk-test-key-123"}, + {"react-client-sdk", "react-client-sdk", "client-id-456"}, + {"react-native", "react-native", "mob-key-789"}, + {"js-client-sdk", "js-client-sdk", "my-test-flag"}, + {"swift-client-sdk", "swift-client-sdk", "mob-key-789"}, + {"android-client-sdk", "android-client-sdk", "mob-key-789"}, + {"java-server-sdk", "java-server-sdk", "sdk-test-key-123"}, + {"ruby-server-sdk", "ruby-server-sdk", "sdk-test-key-123"}, + {"go-server-sdk", "go-server-sdk", "sdk-test-key-123"}, + {"python-server-sdk", "python-server-sdk", "sdk-test-key-123"}, + {"dotnet-server-sdk", "dotnet-server-sdk", "sdk-test-key-123"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := RenderTemplate(tt.sdkID, cfg) + require.NoError(t, err) + assert.Contains(t, result, tt.wantSubstr) + }) + } +} + +func TestRenderTemplateUnknownSDK(t *testing.T) { + _, err := RenderTemplate("nonexistent-sdk", InitConfig{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no initialization template") + assert.Contains(t, err.Error(), "see docs") +} + +func TestRenderTemplateUnknownSDK_KnownDocsPath(t *testing.T) { + _, err := RenderTemplate("php-server-sdk", InitConfig{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "https://launchdarkly.com/docs/sdk/server-side/php") +} + +func TestHasTemplate(t *testing.T) { + assert.True(t, HasTemplate("node-server")) + assert.True(t, HasTemplate("react-client-sdk")) + assert.True(t, HasTemplate("android-client-sdk")) + assert.False(t, HasTemplate("android")) + assert.False(t, HasTemplate("nonexistent-sdk")) +} + +func TestSupportedSDKIDs(t *testing.T) { + ids := SupportedSDKIDs() + assert.Len(t, ids, 11) + assert.Contains(t, ids, "node-server") + assert.Contains(t, ids, "react-client-sdk") + assert.Contains(t, ids, "go-server-sdk") +} + +func TestInjectIntoFile_NewFile(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "index.js") + + initializer := Initializer{} + result, err := initializer.InjectIntoFile("node-server", filePath, InitConfig{ + SDKKey: "test-key", + FlagKey: "test-flag", + }) + + require.NoError(t, err) + assert.True(t, result.Success) + assert.Equal(t, "node-server", result.SDKID) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Contains(t, string(content), "test-key") + assert.Contains(t, string(content), "test-flag") +} + +func TestInjectIntoFile_ExistingFile(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "app.js") + + err := os.WriteFile(filePath, []byte("// existing code\nconsole.log('hello');\n"), 0644) + require.NoError(t, err) + + initializer := Initializer{} + result, err := initializer.InjectIntoFile("node-server", filePath, InitConfig{ + SDKKey: "test-key", + FlagKey: "test-flag", + }) + + require.NoError(t, err) + assert.True(t, result.Success) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Contains(t, string(content), "existing code") + assert.Contains(t, string(content), "test-key") +} + +func TestInjectIntoFile_NewFile_OmitsSeparator(t *testing.T) { + sdks := []struct { + sdkID string + filename string + }{ + {"python-server-sdk", "init_ld.py"}, + {"ruby-server-sdk", "init_ld.rb"}, + {"node-server", "index.js"}, + } + + for _, tt := range sdks { + t.Run(tt.sdkID, func(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, tt.filename) + + initializer := Initializer{} + result, err := initializer.InjectIntoFile(tt.sdkID, filePath, InitConfig{ + SDKKey: "test-key", + FlagKey: "test-flag", + }) + + require.NoError(t, err) + assert.True(t, result.Success) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.NotContains(t, string(content), "// --- init ---") + }) + } +} + +func TestInjectIntoFile_AndroidClientSdk_ReturnsGuidance(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "MainActivity.java") + + initializer := Initializer{} + result, err := initializer.InjectIntoFile("android-client-sdk", filePath, InitConfig{ + MobileKey: "mob-test-key", + FlagKey: "test-flag", + }) + + require.NoError(t, err) + // Android is a scoped language: statements can't live at file scope, so we + // return guidance rather than write a broken file. + assert.False(t, result.Success) + assert.Equal(t, "android-client-sdk", result.SDKID) + assert.Contains(t, result.Snippet, "mob-test-key") + assert.NotEmpty(t, result.DocsURL) + + // The file must not have been created. + _, statErr := os.Stat(filePath) + assert.True(t, os.IsNotExist(statErr), "guidance-only SDK must not create the file") +} + +func TestInjectIntoFile_Go_ReturnsGuidanceDoesNotModifyFile(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "main.go") + + existing := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"hello\")\n}\n" + err := os.WriteFile(filePath, []byte(existing), 0644) + require.NoError(t, err) + + initializer := Initializer{} + result, err := initializer.InjectIntoFile("go-server-sdk", filePath, InitConfig{ + SDKKey: "sdk-test-key", + FlagKey: "test-flag", + }) + + require.NoError(t, err) + // Go statements are illegal at file scope, so appending would not compile. + // We return the snippet as guidance and leave the file untouched. + assert.False(t, result.Success) + assert.Contains(t, result.Snippet, "sdk-test-key") + assert.Contains(t, result.Snippet, "github.com/launchdarkly/go-server-sdk/v7") + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Equal(t, existing, string(content), "existing file must not be modified") +} + +func TestInjectIntoFile_React_ReturnsGuidanceDoesNotModifyFile(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "App.tsx") + + existing := "export default function App() { return null }\n" + err := os.WriteFile(filePath, []byte(existing), 0644) + require.NoError(t, err) + + initializer := Initializer{} + result, err := initializer.InjectIntoFile("react-client-sdk", filePath, InitConfig{ + ClientSideID: "client-id-456", + FlagKey: "test-flag", + }) + + require.NoError(t, err) + // React init must be wired into the component tree, not appended, so we + // return guidance rather than corrupt the file. + assert.False(t, result.Success) + assert.Contains(t, result.Snippet, "asyncWithLDProvider") + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Equal(t, existing, string(content), "existing file must not be modified") +} + +func TestInjectIntoFile_UnsupportedSDK_ReturnsDocsURL(t *testing.T) { + initializer := Initializer{} + result, err := initializer.InjectIntoFile("php-server-sdk", "/tmp/fake.php", InitConfig{}) + require.NoError(t, err) + assert.False(t, result.Success) + assert.Equal(t, "https://launchdarkly.com/docs/sdk/server-side/php", result.DocsURL) +} + +func TestInjectIntoFile_CompletelyUnknownSDK_ReturnsFallbackDocsURL(t *testing.T) { + initializer := Initializer{} + result, err := initializer.InjectIntoFile("nonexistent-sdk", "/tmp/fake.txt", InitConfig{}) + require.NoError(t, err) + assert.False(t, result.Success) + assert.Equal(t, "https://launchdarkly.com/docs/sdk", result.DocsURL) +} + +func TestGetDocsURL(t *testing.T) { + assert.Equal(t, "https://launchdarkly.com/docs/sdk/server-side/go", GetDocsURL("go-server-sdk")) + assert.Equal(t, "https://launchdarkly.com/docs/sdk/client-side/react", GetDocsURL("react-client-sdk")) + assert.Equal(t, "https://launchdarkly.com/docs/sdk/server-side/python", GetDocsURL("python-server-sdk")) + assert.Equal(t, "https://launchdarkly.com/docs/sdk", GetDocsURL("totally-unknown")) +} diff --git a/internal/setup/sdk_init_templates/android.tmpl b/internal/setup/sdk_init_templates/android.tmpl new file mode 100644 index 00000000..d1672e82 --- /dev/null +++ b/internal/setup/sdk_init_templates/android.tmpl @@ -0,0 +1,10 @@ +import com.launchdarkly.sdk.android.*; +import com.launchdarkly.sdk.*; +// --- init --- +LDConfig ldConfig = new LDConfig.Builder() + .mobileKey("{{.MobileKey}}") + .build(); +LDContext ldContext = LDContext.builder(ContextKind.DEFAULT, "example-user-key") + .name("Example User") + .build(); +LDClient ldClient = LDClient.init(this.getApplication(), ldConfig, ldContext, 5); diff --git a/internal/setup/sdk_init_templates/dotnet-server-sdk.tmpl b/internal/setup/sdk_init_templates/dotnet-server-sdk.tmpl new file mode 100644 index 00000000..da54e9d4 --- /dev/null +++ b/internal/setup/sdk_init_templates/dotnet-server-sdk.tmpl @@ -0,0 +1,11 @@ +using LaunchDarkly.Sdk; +using LaunchDarkly.Sdk.Server; +// --- init --- +var ldClient = new LdClient("{{.SDKKey}}"); + +var context = Context.Builder("example-user-key") + .Name("Example User") + .Build(); + +var flagValue = ldClient.BoolVariation("{{.FlagKey}}", context, false); +Console.WriteLine($"Flag '{{.FlagKey}}' is {flagValue}"); diff --git a/internal/setup/sdk_init_templates/go-server-sdk.tmpl b/internal/setup/sdk_init_templates/go-server-sdk.tmpl new file mode 100644 index 00000000..a1a92ba4 --- /dev/null +++ b/internal/setup/sdk_init_templates/go-server-sdk.tmpl @@ -0,0 +1,16 @@ +import ( + "fmt" + "time" + + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" + ld "github.com/launchdarkly/go-server-sdk/v7" +) +// --- init --- +ldClient, _ := ld.MakeClient("{{.SDKKey}}", 5*time.Second) + +context := ldcontext.NewBuilder("example-user-key"). + Name("Example User"). + Build() + +flagValue, _ := ldClient.BoolVariation("{{.FlagKey}}", context, false) +fmt.Printf("Flag '{{.FlagKey}}' is %t\n", flagValue) diff --git a/internal/setup/sdk_init_templates/java-server-sdk.tmpl b/internal/setup/sdk_init_templates/java-server-sdk.tmpl new file mode 100644 index 00000000..88a0e3a7 --- /dev/null +++ b/internal/setup/sdk_init_templates/java-server-sdk.tmpl @@ -0,0 +1,11 @@ +import com.launchdarkly.sdk.*; +import com.launchdarkly.sdk.server.*; +// --- init --- +LDClient ldClient = new LDClient("{{.SDKKey}}"); + +LDContext context = LDContext.builder("example-user-key") + .name("Example User") + .build(); + +boolean flagValue = ldClient.boolVariation("{{.FlagKey}}", context, false); +System.out.println("Flag '{{.FlagKey}}' is " + flagValue); diff --git a/internal/setup/sdk_init_templates/js-client-sdk.tmpl b/internal/setup/sdk_init_templates/js-client-sdk.tmpl new file mode 100644 index 00000000..f78f1f30 --- /dev/null +++ b/internal/setup/sdk_init_templates/js-client-sdk.tmpl @@ -0,0 +1,12 @@ +import * as LDClient from 'launchdarkly-js-client-sdk'; +// --- init --- +const ldClient = LDClient.initialize('{{.ClientSideID}}', { + kind: 'user', + key: 'example-user-key', + name: 'Example User', +}); + +ldClient.on('ready', () => { + const flagValue = ldClient.variation('{{.FlagKey}}', false); + console.log(`Flag '{{.FlagKey}}' is ${flagValue}`); +}); diff --git a/internal/setup/sdk_init_templates/node-server.tmpl b/internal/setup/sdk_init_templates/node-server.tmpl new file mode 100644 index 00000000..321c0c67 --- /dev/null +++ b/internal/setup/sdk_init_templates/node-server.tmpl @@ -0,0 +1,15 @@ +const LaunchDarkly = require('@launchdarkly/node-server-sdk'); +// --- init --- +const ldClient = LaunchDarkly.init('{{.SDKKey}}'); + +const context = { + kind: 'user', + key: 'example-user-key', + name: 'Example User', +}; + +ldClient.on('ready', () => { + ldClient.variation('{{.FlagKey}}', context, false, (err, flagValue) => { + console.log(`Flag '{{.FlagKey}}' is ${flagValue}`); + }); +}); diff --git a/internal/setup/sdk_init_templates/python-server-sdk.tmpl b/internal/setup/sdk_init_templates/python-server-sdk.tmpl new file mode 100644 index 00000000..4960a98f --- /dev/null +++ b/internal/setup/sdk_init_templates/python-server-sdk.tmpl @@ -0,0 +1,11 @@ +import ldclient +from ldclient import Context +from ldclient.config import Config +# --- init --- +ldclient.set_config(Config("{{.SDKKey}}")) +ld_client = ldclient.get() + +context = Context.builder("example-user-key").name("Example User").build() + +flag_value = ld_client.variation("{{.FlagKey}}", context, False) +print(f"Flag '{{.FlagKey}}' is {flag_value}") diff --git a/internal/setup/sdk_init_templates/react-client-sdk.tmpl b/internal/setup/sdk_init_templates/react-client-sdk.tmpl new file mode 100644 index 00000000..de155630 --- /dev/null +++ b/internal/setup/sdk_init_templates/react-client-sdk.tmpl @@ -0,0 +1,10 @@ +import { asyncWithLDProvider } from 'launchdarkly-react-client-sdk'; +// --- init --- +const LDProvider = await asyncWithLDProvider({ + clientSideID: '{{.ClientSideID}}', + context: { + kind: 'user', + key: 'example-user-key', + name: 'Example User', + }, +}); diff --git a/internal/setup/sdk_init_templates/react-native.tmpl b/internal/setup/sdk_init_templates/react-native.tmpl new file mode 100644 index 00000000..0b137bc6 --- /dev/null +++ b/internal/setup/sdk_init_templates/react-native.tmpl @@ -0,0 +1,5 @@ +import LDClient from '@launchdarkly/react-native-client-sdk'; +// --- init --- +const ldClient = new LDClient(); +const context = { kind: 'user', key: 'example-user-key', name: 'Example User' }; +await ldClient.configure({ mobileKey: '{{.MobileKey}}' }, context); diff --git a/internal/setup/sdk_init_templates/ruby-server-sdk.tmpl b/internal/setup/sdk_init_templates/ruby-server-sdk.tmpl new file mode 100644 index 00000000..38306dbb --- /dev/null +++ b/internal/setup/sdk_init_templates/ruby-server-sdk.tmpl @@ -0,0 +1,12 @@ +require 'ldclient-rb' +# --- init --- +ld_client = LaunchDarkly::LDClient.new("{{.SDKKey}}") + +context = LaunchDarkly::LDContext.create({ + key: "example-user-key", + kind: "user", + name: "Example User" +}) + +flag_value = ld_client.variation("{{.FlagKey}}", context, false) +puts "Flag '{{.FlagKey}}' is #{flag_value}" diff --git a/internal/setup/sdk_init_templates/swift-client-sdk.tmpl b/internal/setup/sdk_init_templates/swift-client-sdk.tmpl new file mode 100644 index 00000000..8fb3c12b --- /dev/null +++ b/internal/setup/sdk_init_templates/swift-client-sdk.tmpl @@ -0,0 +1,5 @@ +import LaunchDarkly +// --- init --- +var ldConfig = LDConfig(mobileKey: "{{.MobileKey}}") +let ldContext = try LDContextBuilder(key: "example-user-key").build().get() +LDClient.start(config: ldConfig, context: ldContext) From 2efb79da16cc4f633b926a7ac2dc558b3a583db3 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Thu, 30 Jul 2026 23:48:44 -0400 Subject: [PATCH 04/13] fix(setup): correct the React Native init snippet react-native.tmpl bound the default export of @launchdarkly/react-native-client-sdk, which has no default export, so the snippet could not compile. The client is the named export ReactNativeLDClient. Register the android template under the SDK ID the detector reports, keeping android-client-sdk as an alias. Co-Authored-By: Claude Opus 5 (1M context) --- internal/setup/initializer.go | 1 + internal/setup/initializer_test.go | 6 +++-- internal/setup/installer_test.go | 26 +++++++++++++++++++ .../sdk_init_templates/react-native.tmpl | 7 +++-- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/internal/setup/initializer.go b/internal/setup/initializer.go index 48d8ad7d..528d64b5 100644 --- a/internal/setup/initializer.go +++ b/internal/setup/initializer.go @@ -61,6 +61,7 @@ var sdkTemplates = map[string]sdkTemplateInfo{ "react-native": {TemplateFile: "react-native.tmpl"}, "js-client-sdk": {TemplateFile: "js-client-sdk.tmpl"}, "swift-client-sdk": {TemplateFile: "swift-client-sdk.tmpl"}, + "android": {TemplateFile: "android.tmpl"}, "android-client-sdk": {TemplateFile: "android.tmpl"}, "java-server-sdk": {TemplateFile: "java-server-sdk.tmpl"}, "ruby-server-sdk": {TemplateFile: "ruby-server-sdk.tmpl"}, diff --git a/internal/setup/initializer_test.go b/internal/setup/initializer_test.go index 70c2eb04..3bf153df 100644 --- a/internal/setup/initializer_test.go +++ b/internal/setup/initializer_test.go @@ -60,14 +60,16 @@ func TestRenderTemplateUnknownSDK_KnownDocsPath(t *testing.T) { func TestHasTemplate(t *testing.T) { assert.True(t, HasTemplate("node-server")) assert.True(t, HasTemplate("react-client-sdk")) + // The detector emits "android"; "android-client-sdk" stays as an alias so any + // caller still passing the old ID keeps working. + assert.True(t, HasTemplate("android")) assert.True(t, HasTemplate("android-client-sdk")) - assert.False(t, HasTemplate("android")) assert.False(t, HasTemplate("nonexistent-sdk")) } func TestSupportedSDKIDs(t *testing.T) { ids := SupportedSDKIDs() - assert.Len(t, ids, 11) + assert.Len(t, ids, 12) assert.Contains(t, ids, "node-server") assert.Contains(t, ids, "react-client-sdk") assert.Contains(t, ids, "go-server-sdk") diff --git a/internal/setup/installer_test.go b/internal/setup/installer_test.go index e8cf482c..5c0d192c 100644 --- a/internal/setup/installer_test.go +++ b/internal/setup/installer_test.go @@ -228,3 +228,29 @@ func TestPackageInstaller_Install_DefaultRunner_UsedWhenNil(t *testing.T) { require.NoError(t, err) assert.False(t, result.Success) } + +// The templates import the package InstallArgs installs; a mismatch means the user +// installs one package and the snippet requires another. These are the pairs where +// LaunchDarkly ships both a scoped and an unscoped package for the same SDK. +func TestInstallArgs_PackageMatchesTemplateImport(t *testing.T) { + tests := []struct { + sdkID string + wantImport string + }{ + {"node-server", "@launchdarkly/node-server-sdk"}, + {"react-client-sdk", "launchdarkly-react-client-sdk"}, + {"react-native", "@launchdarkly/react-native-client-sdk"}, + {"js-client-sdk", "launchdarkly-js-client-sdk"}, + } + for _, tt := range tests { + t.Run(tt.sdkID, func(t *testing.T) { + _, pkg := InstallArgs(tt.sdkID, "npm") + assert.Equal(t, tt.wantImport, pkg) + + rendered, err := RenderTemplate(tt.sdkID, InitConfig{}) + require.NoError(t, err) + assert.Contains(t, rendered, "'"+tt.wantImport+"'", + "template must import the package we install") + }) + } +} diff --git a/internal/setup/sdk_init_templates/react-native.tmpl b/internal/setup/sdk_init_templates/react-native.tmpl index 0b137bc6..d31a8e5b 100644 --- a/internal/setup/sdk_init_templates/react-native.tmpl +++ b/internal/setup/sdk_init_templates/react-native.tmpl @@ -1,5 +1,4 @@ -import LDClient from '@launchdarkly/react-native-client-sdk'; +import { AutoEnvAttributes, ReactNativeLDClient } from '@launchdarkly/react-native-client-sdk'; // --- init --- -const ldClient = new LDClient(); -const context = { kind: 'user', key: 'example-user-key', name: 'Example User' }; -await ldClient.configure({ mobileKey: '{{.MobileKey}}' }, context); +const featureClient = new ReactNativeLDClient('{{.MobileKey}}', AutoEnvAttributes.Enabled); +await featureClient.identify({ kind: 'user', key: 'example-user-key', name: 'Example User' }); From 8a8d586e8b63dc0dc47a5cf92b65ce7b5cdd073f Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Tue, 21 Jul 2026 16:39:07 -0400 Subject: [PATCH 05/13] chore(setup): add setup flag verifier Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/setup/verifier.go | 83 +++++++++++++++++++++++++++++++++ internal/setup/verifier_test.go | 43 +++++++++++++++++ 2 files changed, 126 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..7b265dbf --- /dev/null +++ b/internal/setup/verifier.go @@ -0,0 +1,83 @@ +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"` +} + +// 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. +func (v *Verifier) Verify(accessToken, baseURI, projectKey, envKey string) (*VerifyResult, error) { + start := time.Now() + deadline := start.Add(v.Timeout) + attempts := 0 + + for { + attempts++ + active, err := v.checkOnce(accessToken, baseURI, projectKey, envKey) + if err != nil { + return nil, err + } + if active { + return &VerifyResult{ + Active: true, + Attempts: attempts, + Elapsed: time.Since(start).Round(time.Millisecond).String(), + }, nil + } + + if time.Now().After(deadline) { + return &VerifyResult{ + Active: false, + Attempts: attempts, + Elapsed: time.Since(start).Round(time.Millisecond).String(), + }, nil + } + + time.Sleep(v.Interval) + } +} + +func (v *Verifier) checkOnce(accessToken, baseURI, projectKey, envKey string) (bool, error) { + path, _ := url.JoinPath(baseURI, "api/v2/projects", projectKey, "environments", envKey, "sdk-active") + + res, err := v.Client.MakeRequest(accessToken, "GET", path, "application/json", nil, 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..8ce339af --- /dev/null +++ b/internal/setup/verifier_test.go @@ -0,0 +1,43 @@ +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") + 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") + require.NoError(t, err) + assert.False(t, result.Active) + assert.Greater(t, result.Attempts, 1) +} From e89e3602b71a4f9c6e4f922d8c67a1f312f45a24 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Wed, 22 Jul 2026 13:55:57 -0400 Subject: [PATCH 06/13] feat: add guided setup command Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/root.go | 74 +++- cmd/setup/detect.go | 62 +++ cmd/setup/init.go | 86 ++++ cmd/setup/install.go | 99 +++++ cmd/setup/setup.go | 54 +++ cmd/setup/setup_test.go | 362 ++++++++++++++++ cmd/setup/styles.go | 52 +++ cmd/setup/wizard.go | 871 +++++++++++++++++++++++++++++++++++++++ cmd/setup/wizard_test.go | 260 ++++++++++++ cmd/templates.go | 4 +- 10 files changed, 1901 insertions(+), 23 deletions(-) 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/setup.go create mode 100644 cmd/setup/setup_test.go create mode 100644 cmd/setup/styles.go create mode 100644 cmd/setup/wizard.go create mode 100644 cmd/setup/wizard_test.go diff --git a/cmd/root.go b/cmd/root.go index 5c0739a7..2d4c32ff 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,11 +22,11 @@ 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" whoamicmd "github.com/launchdarkly/ldcli/cmd/whoami" "github.com/launchdarkly/ldcli/internal/analytics" "github.com/launchdarkly/ldcli/internal/config" @@ -37,6 +37,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 +47,8 @@ type APIClients struct { MembersClient members.Client ProjectsClient projects.Client ResourcesClient resources.Client + Detector setup.Detector + Installer setup.Installer } type Command interface { @@ -100,6 +103,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 +156,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,13 +268,31 @@ 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, + clients.ResourcesClient, + clients.FlagsClient, + 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()) cmd.AddCommand(devcmd.NewDevServerCmd(clients.ResourcesClient, analyticsTrackerFn, clients.DevClient)) cmd.AddCommand(sourcemapscmd.NewSourcemapsCmd(clients.ResourcesClient, analyticsTrackerFn)) - cmd.AddCommand(symbolscmd.NewSymbolsCmd(clients.ResourcesClient, analyticsTrackerFn)) cmd.AddCommand(whoamicmd.NewWhoAmICmd(clients.ResourcesClient)) resourcecmd.AddAllResourceCmds(cmd, clients.ResourcesClient, analyticsTrackerFn) diff --git a/cmd/setup/detect.go b/cmd/setup/detect.go new file mode 100644 index 00000000..1ee33123 --- /dev/null +++ b/cmd/setup/detect.go @@ -0,0 +1,62 @@ +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(detector setup.Detector) *cobra.Command { + cmd := &cobra.Command{ + Use: "detect", + Short: "Detect language, framework, and recommended SDK for a project", + Hidden: true, + RunE: runDetect(detector), + } + + cmd.Flags().String(pathFlag, "", "Path to the project directory (defaults to current directory)") + + return cmd +} + +func runDetect(detector setup.Detector) 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 := detector.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) + fmt.Fprintf(cmd.OutOrStdout(), "Entry Point: %s\n", result.EntryPoint) + + return nil + } +} diff --git a/cmd/setup/init.go b/cmd/setup/init.go new file mode 100644 index 00000000..1a07d2f2 --- /dev/null +++ b/cmd/setup/init.go @@ -0,0 +1,86 @@ +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() *cobra.Command { + cmd := &cobra.Command{ + Use: "init", + Short: "Inject LaunchDarkly SDK initialization code into a file", + Hidden: true, + RunE: runInit(), + } + + 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() 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), + } + + initializer := setup.Initializer{} + result, err := initializer.InjectIntoFile(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..12ad67c5 --- /dev/null +++ b/cmd/setup/install.go @@ -0,0 +1,99 @@ +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(installer setup.Installer) *cobra.Command { + cmd := &cobra.Command{ + Use: "install", + Short: "Install the LaunchDarkly SDK package for the detected project", + Hidden: true, + RunE: runInstall(installer), + } + + 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(installer setup.Installer) 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 = installer.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 + } + fmt.Fprintf(cmd.OutOrStdout(), "Command: %s\n", result.Command) + if result.DryRun { + fmt.Fprintln(cmd.OutOrStdout(), "Dry run: command not executed") + } else { + fmt.Fprintf(cmd.OutOrStdout(), "Success: %t\n", result.Success) + } + + return nil + } +} diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go new file mode 100644 index 00000000..ed59797e --- /dev/null +++ b/cmd/setup/setup.go @@ -0,0 +1,54 @@ +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/flags" + "github.com/launchdarkly/ldcli/internal/resources" + "github.com/launchdarkly/ldcli/internal/setup" +) + +// NewSetupCmd creates the top-level setup command and registers its hidden subcommands. +func NewSetupCmd( + analyticsTrackerFn analytics.TrackerFn, + resourcesClient resources.Client, + flagsClient flags.Client, + detector setup.Detector, + installer setup.Installer, +) *cobra.Command { + 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, resourcesClient, flagsClient, detector, installer), + } + + cmd.AddCommand(newDetectCmd(detector)) + cmd.AddCommand(newInstallCmd(installer)) + cmd.AddCommand(newInitCmd()) + + return cmd +} diff --git a/cmd/setup/setup_test.go b/cmd/setup/setup_test.go new file mode 100644 index 00000000..2d7e4cbe --- /dev/null +++ b/cmd/setup/setup_test.go @@ -0,0 +1,362 @@ +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_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/wizard.go b/cmd/setup/wizard.go new file mode 100644 index 00000000..4eec0f85 --- /dev/null +++ b/cmd/setup/wizard.go @@ -0,0 +1,871 @@ +package setup + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "strings" + + "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/flags" + "github.com/launchdarkly/ldcli/internal/resources" + "github.com/launchdarkly/ldcli/internal/setup" +) + +type wizardStep int + +const ( + stepSelectProject wizardStep = iota + stepSelectEnvironment + stepDetect + stepSelectSDK + stepPlan + stepInstall + stepCreateFlag + stepInit + stepWaitForApp + stepVerify + stepDone +) + +type wizardModel struct { + analyticsTrackerFn analytics.TrackerFn + resourcesClient resources.Client + flagsClient flags.Client + detector setup.Detector + installer setup.Installer + + step wizardStep + spinner spinner.Model + err error + width int + height int + + // data gathered through the flow + projects []projectItem + environments []envItem + projectList list.Model + envList list.Model + 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) + detectedEntryPoint string + 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 + + 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 } +type envsFetchedMsg struct{ environments []envItem } +type envDetailsFetchedMsg struct { + 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 verifyDoneMsg struct{ result *setup.VerifyResult } +type wizardErrMsg struct{ err error } + +func runSetupWizard( + analyticsTrackerFn analytics.TrackerFn, + resourcesClient resources.Client, + flagsClient flags.Client, + detector setup.Detector, + installer setup.Installer, +) 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, + resourcesClient: resourcesClient, + flagsClient: flagsClient, + detector: detector, + installer: installer, + step: stepSelectProject, + spinner: s, + } + + 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()) +} + +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 + + 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 "enter": + return m.handleEnter() + } + + case projectsFetchedMsg: + m.projects = msg.projects + 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.height-4) + m.projectList.Title = "Select a project:" + m.projectList.SetShowStatusBar(false) + return m, nil + + case envsFetchedMsg: + m.environments = msg.environments + 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.height-4) + m.envList.Title = "Select an environment:" + m.envList.SetShowStatusBar(false) + return m, nil + + case envDetailsFetchedMsg: + 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.detectedEntryPoint = msg.result.EntryPoint + 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.step = stepSelectSDK + return + } + } + m.detectedSDK = nil + m.sdkFocus = 1 + m.sdkList = m.newSDKList(sdkItemsExcept(""), "Select your SDK:", true) + m.step = stepSelectSDK +} + +// 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 + 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.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 + } + m.detectResult = &setup.DetectResult{ + SDKID: chosen.id, + Language: chosen.language, + EntryPoint: m.detectedEntryPoint, + } + // Compute the plan preview shown before any action is taken. + args, _ := setup.InstallArgs(chosen.id, "") + 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. +var quitHint = "\n" + mutedStyle.Render("Press q to quit.") + "\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 len(m.projects) == 0 { + return m.spinner.View() + " Loading projects..." + } + return m.projectList.View() + "\n" + mutedStyle.Render("esc quit") + + case stepSelectEnvironment: + if len(m.environments) == 0 { + return m.spinner.View() + " Loading environments..." + } + 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: + return titleStyle.Render("Start your application") + "\n\n" + + "SDK initialization code has been injected into:\n" + + " " + 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. Install it yourself with:") + "\n\n" + + code(m.installResult.Command) + "\n\n" + 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 + 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" + + quitHint + } + if m.verifyResult != nil && m.verifyResult.Active && m.detectResult != nil { + 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 https://app.launchdarkly.com/projects/%s/flags/%s/targeting?env=%s\n", 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 +} + +// 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) { + add(fmt.Sprintf("Add initialization code to %s", m.detectResult.EntryPoint)) + 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 + +func (m wizardModel) fetchProjects() tea.Cmd { + return func() tea.Msg { + path, _ := url.JoinPath( + viper.GetString(cliflags.BaseURIFlag), + "api/v2/projects", + ) + res, err := m.resourcesClient.MakeRequest( + viper.GetString(cliflags.AccessTokenFlag), + "GET", path, "application/json", nil, nil, false, + ) + if err != nil { + return wizardErrMsg{err: err} + } + + var resp struct { + Items []struct { + Key string `json:"key"` + Name string `json:"name"` + } `json:"items"` + } + if err := json.Unmarshal(res, &resp); err != nil { + return wizardErrMsg{err: fmt.Errorf("parsing projects: %w", err)} + } + + projects := make([]projectItem, len(resp.Items)) + for i, item := range resp.Items { + projects[i] = projectItem{key: item.Key, name: item.Name} + } + return projectsFetchedMsg{projects: projects} + } +} + +func (m wizardModel) fetchEnvironments() tea.Cmd { + return func() tea.Msg { + path, _ := url.JoinPath( + viper.GetString(cliflags.BaseURIFlag), + "api/v2/projects", m.selectedProject, "environments", + ) + res, err := m.resourcesClient.MakeRequest( + viper.GetString(cliflags.AccessTokenFlag), + "GET", path, "application/json", nil, nil, false, + ) + if err != nil { + return wizardErrMsg{err: err} + } + + var resp struct { + Items []struct { + Key string `json:"key"` + Name string `json:"name"` + } `json:"items"` + } + if err := json.Unmarshal(res, &resp); err != nil { + return wizardErrMsg{err: fmt.Errorf("parsing environments: %w", err)} + } + + envs := make([]envItem, len(resp.Items)) + for i, item := range resp.Items { + envs[i] = envItem{key: item.Key, name: item.Name} + } + return envsFetchedMsg{environments: envs} + } +} + +func (m wizardModel) fetchEnvDetails() tea.Cmd { + return func() tea.Msg { + path, _ := url.JoinPath( + viper.GetString(cliflags.BaseURIFlag), + "api/v2/projects", m.selectedProject, "environments", m.selectedEnv, + ) + res, err := m.resourcesClient.MakeRequest( + viper.GetString(cliflags.AccessTokenFlag), + "GET", path, "application/json", nil, nil, false, + ) + if err != nil { + return wizardErrMsg{err: 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 wizardErrMsg{err: fmt.Errorf("parsing environment details: %w", err)} + } + + return envDetailsFetchedMsg{ + sdkKey: resp.SDKKey, + clientSideID: resp.ClientSideId, + mobileKey: resp.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.detector.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.installer.Install(dir, m.detectResult) + if err != nil { + // Don't dead-end on a failed auto-install (e.g. Ruby gem perms, no + // network): continue and 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, + }} + } + return installDoneMsg{result: result} + } +} + +func (m wizardModel) runCreateFlag() tea.Cmd { + return func() tea.Msg { + flagKey := "my-new-flag" + flagName := "My New Flag" + + _, err := m.flagsClient.Create( + context.Background(), + viper.GetString(cliflags.AccessTokenFlag), + viper.GetString(cliflags.BaseURIFlag), + flagName, + flagKey, + m.selectedProject, + ) + if err != nil { + // If flag already exists (conflict), continue using it + if jsonErr, parseErr := parseJSONError(err); parseErr == nil && jsonErr.Code == "conflict" { + return flagCreatedMsg{key: flagKey} + } + return wizardErrMsg{err: err} + } + return flagCreatedMsg{key: flagKey} + } +} + +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, + } + initializer := setup.Initializer{} + result, err := initializer.InjectIntoFile(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 { + verifier := setup.DefaultVerifier(m.resourcesClient) + result, err := verifier.Verify( + viper.GetString(cliflags.AccessTokenFlag), + viper.GetString(cliflags.BaseURIFlag), + m.selectedProject, + m.selectedEnv, + ) + if err != nil { + return wizardErrMsg{err: err} + } + return verifyDoneMsg{result: result} + } +} + +type jsonError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +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/cmd/setup/wizard_test.go b/cmd/setup/wizard_test.go new file mode 100644 index 00000000..344272f5 --- /dev/null +++ b/cmd/setup/wizard_test.go @@ -0,0 +1,260 @@ +package setup + +import ( + "testing" + + 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) +} diff --git a/cmd/templates.go b/cmd/templates.go index b806ed7b..1d9c9a1e 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 @@ -26,7 +27,6 @@ Common resource commands: {{rpad "members" 29}} Invite new members to an account {{rpad "segments" 29}} List, create, modify, and delete segments {{rpad "sourcemaps" 29}} Manage sourcemaps for error monitoring - {{rpad "symbols" 29}} Manage symbol files for error monitoring {{rpad "..." 29}} To see more resource commands, run 'ldcli resources' Flags: From e9151736d6088e58c78122a6b2f9b1aa49ed0a3f Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Fri, 24 Jul 2026 14:05:31 -0400 Subject: [PATCH 07/13] fix(setup): surface install-failure reason and use configured base URI Address two review findings on the setup wizard: - runInstall discarded the underlying install error, so the manual-install screen showed only the command with no indication of why the automatic install failed. Carry the error onto InstallResult.FailureReason and render it on the failure screen. - The "toggle your flag" link hardcoded https://app.launchdarkly.com, which is wrong on federal/custom instances. Derive the host from the configured --base-uri. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/setup/wizard.go | 13 +++++++++---- internal/setup/installer.go | 5 ++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/cmd/setup/wizard.go b/cmd/setup/wizard.go index 4eec0f85..d7876eb4 100644 --- a/cmd/setup/wizard.go +++ b/cmd/setup/wizard.go @@ -489,6 +489,9 @@ func (m wizardModel) View() string { body := titleStyle.Render("Manual install needed") + "\n\n" + m.wrap("The SDK couldn't be installed automatically. Install it yourself with:") + "\n\n" + code(m.installResult.Command) + "\n\n" + if m.installResult.FailureReason != "" { + body += m.wrap("Reason: "+m.installResult.FailureReason) + "\n\n" + } 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 != "" { @@ -513,10 +516,11 @@ func (m wizardModel) View() string { quitHint } if m.verifyResult != nil && m.verifyResult.Active && m.detectResult != nil { + appHost := strings.TrimRight(viper.GetString(cliflags.BaseURIFlag), "/") 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 https://app.launchdarkly.com/projects/%s/flags/%s/targeting?env=%s\n", m.selectedProject, m.flagKey, m.selectedEnv) + + 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" + @@ -791,9 +795,10 @@ func (m wizardModel) runInstall() tea.Cmd { // network): continue and 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, + SDKID: m.detectResult.SDKID, + Command: strings.Join(args, " "), + Failed: true, + FailureReason: err.Error(), }} } return installDoneMsg{result: result} diff --git a/internal/setup/installer.go b/internal/setup/installer.go index 7d1541f9..e49bb122 100644 --- a/internal/setup/installer.go +++ b/internal/setup/installer.go @@ -18,7 +18,10 @@ type InstallResult struct { DryRun bool `json:"dry_run,omitempty"` AlreadyInstalled bool `json:"already_installed,omitempty"` Failed bool `json:"failed,omitempty"` - Success bool `json:"success"` + // FailureReason carries the underlying error when Failed is true, so callers + // can tell the user why the automatic install did not run. + FailureReason string `json:"failure_reason,omitempty"` + Success bool `json:"success"` } // RequiresManualInstall reports whether the SDK has no automated package-manager From fa402b82e626c9f160a92c03102f435773b82472 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Thu, 30 Jul 2026 23:49:24 -0400 Subject: [PATCH 08/13] fix(setup): warn when the plan will create the entry file A file we create is not loaded by the project, so the user needs the chance to back out at the confirm gate and point setup at the real entry point. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/setup/detect.go | 6 +++++- cmd/setup/wizard.go | 11 ++++++++++- cmd/setup/wizard_test.go | 41 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/cmd/setup/detect.go b/cmd/setup/detect.go index 1ee33123..20d269a3 100644 --- a/cmd/setup/detect.go +++ b/cmd/setup/detect.go @@ -55,7 +55,11 @@ func runDetect(detector setup.Detector) func(*cobra.Command, []string) error { } fmt.Fprintf(cmd.OutOrStdout(), "Package Manager: %s\n", result.PackageManager) fmt.Fprintf(cmd.OutOrStdout(), "Recommended SDK: %s\n", result.SDKID) - fmt.Fprintf(cmd.OutOrStdout(), "Entry Point: %s\n", result.EntryPoint) + 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/wizard.go b/cmd/setup/wizard.go index d7876eb4..8ab182a2 100644 --- a/cmd/setup/wizard.go +++ b/cmd/setup/wizard.go @@ -661,7 +661,16 @@ func (m wizardModel) planView() string { } add(fmt.Sprintf("Create a feature flag in %s / %s", m.selectedProject, m.selectedEnv)) if setup.InjectsInPlace(m.detectResult.SDKID) { - add(fmt.Sprintf("Add initialization code to %s", m.detectResult.EntryPoint)) + // 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") diff --git a/cmd/setup/wizard_test.go b/cmd/setup/wizard_test.go index 344272f5..602ac2c7 100644 --- a/cmd/setup/wizard_test.go +++ b/cmd/setup/wizard_test.go @@ -258,3 +258,44 @@ func TestWizard_SelectSDK_EmptyList_DoesNotPanic(t *testing.T) { 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") +} From ea96e16b502b8eca5e90c839b53bb5ee230944ae Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Fri, 24 Jul 2026 12:45:23 -0400 Subject: [PATCH 09/13] feat(setup): add orchestration service in internal/setup Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/setup/service.go | 173 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 internal/setup/service.go diff --git a/internal/setup/service.go b/internal/setup/service.go new file mode 100644 index 00000000..fc72c6f7 --- /dev/null +++ b/internal/setup/service.go @@ -0,0 +1,173 @@ +package setup + +import ( + "encoding/json" + "fmt" + "net/url" + + "github.com/launchdarkly/ldcli/internal/resources" +) + +// Auth carries resolved credentials so the service never reads global config. +type Auth struct { + AccessToken string + BaseURI string +} + +// 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 { + Client resources.Client + 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 +} + +// ListProjects returns the account's projects. +func (s Service) ListProjects(a Auth) ([]ProjectSummary, error) { + path, _ := url.JoinPath(a.BaseURI, "api/v2/projects") + res, err := s.Client.MakeRequest(a.AccessToken, "GET", path, "application/json", nil, nil, false) + if err != nil { + return nil, err + } + + var resp struct { + Items []struct { + Key string `json:"key"` + Name string `json:"name"` + } `json:"items"` + } + if err := json.Unmarshal(res, &resp); err != nil { + return nil, fmt.Errorf("parsing projects: %w", err) + } + + projects := make([]ProjectSummary, len(resp.Items)) + for i, item := range resp.Items { + projects[i] = ProjectSummary{Key: item.Key, Name: item.Name} + } + return projects, nil +} + +// ListEnvironments returns the environments in a project. +func (s Service) ListEnvironments(a Auth, projectKey string) ([]EnvSummary, error) { + path, _ := url.JoinPath(a.BaseURI, "api/v2/projects", projectKey, "environments") + res, err := s.Client.MakeRequest(a.AccessToken, "GET", path, "application/json", nil, nil, false) + if err != nil { + return nil, err + } + + var resp struct { + Items []struct { + Key string `json:"key"` + Name string `json:"name"` + } `json:"items"` + } + if err := json.Unmarshal(res, &resp); err != nil { + return nil, fmt.Errorf("parsing environments: %w", err) + } + + envs := make([]EnvSummary, len(resp.Items)) + for i, item := range resp.Items { + envs[i] = EnvSummary{Key: item.Key, Name: item.Name} + } + return envs, nil +} + +// EnvKeys returns the SDK credentials for an environment. +func (s Service) EnvKeys(a Auth, projectKey, envKey string) (EnvKeys, error) { + path, _ := url.JoinPath(a.BaseURI, "api/v2/projects", projectKey, "environments", envKey) + res, err := s.Client.MakeRequest(a.AccessToken, "GET", path, "application/json", nil, nil, false) + 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. +func (s Service) CreateFlag(a Auth, projectKey, key, name string) (string, error) { + path, _ := url.JoinPath(a.BaseURI, "api/v2/flags", projectKey) + body, err := json.Marshal(map[string]string{"key": key, "name": name}) + if err != nil { + return "", err + } + + _, err = s.Client.MakeRequest(a.AccessToken, "POST", path, "application/json", nil, body, false) + 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 string) (*VerifyResult, error) { + return DefaultVerifier(s.Client).Verify(a.AccessToken, a.BaseURI, projectKey, envKey) +} + +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 +} From 6113e212123391db0b11a43aec9cb0e760c89457 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Fri, 24 Jul 2026 12:50:39 -0400 Subject: [PATCH 10/13] refactor(setup): drive wizard and subcommands through the service Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/root.go | 1 - cmd/setup/detect.go | 8 +-- cmd/setup/init.go | 9 ++- cmd/setup/install.go | 8 +-- cmd/setup/setup.go | 16 +++-- cmd/setup/wizard.go | 161 +++++++++---------------------------------- 6 files changed, 56 insertions(+), 147 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 2d4c32ff..02356330 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -279,7 +279,6 @@ func NewRootCommand( cmd.AddCommand(setupcmd.NewSetupCmd( analyticsTrackerFn, clients.ResourcesClient, - clients.FlagsClient, detector, installer, )) diff --git a/cmd/setup/detect.go b/cmd/setup/detect.go index 20d269a3..b7d0ced2 100644 --- a/cmd/setup/detect.go +++ b/cmd/setup/detect.go @@ -13,12 +13,12 @@ import ( const pathFlag = "path" -func newDetectCmd(detector setup.Detector) *cobra.Command { +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(detector), + RunE: runDetect(svc), } cmd.Flags().String(pathFlag, "", "Path to the project directory (defaults to current directory)") @@ -26,7 +26,7 @@ func newDetectCmd(detector setup.Detector) *cobra.Command { return cmd } -func runDetect(detector setup.Detector) func(*cobra.Command, []string) error { +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 == "" { @@ -37,7 +37,7 @@ func runDetect(detector setup.Detector) func(*cobra.Command, []string) error { } } - result, err := detector.Detect(dir) + result, err := svc.Detect(dir) if err != nil { return err } diff --git a/cmd/setup/init.go b/cmd/setup/init.go index 1a07d2f2..ef96b1ed 100644 --- a/cmd/setup/init.go +++ b/cmd/setup/init.go @@ -23,12 +23,12 @@ const ( flagKeyFlag = "flag-key" ) -func newInitCmd() *cobra.Command { +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(), + RunE: runInit(svc), } cmd.Flags().String(sdkIDFlag, "", "SDK identifier (e.g. node-server, react-client-sdk)") @@ -45,7 +45,7 @@ func newInitCmd() *cobra.Command { return cmd } -func runInit() func(*cobra.Command, []string) error { +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) @@ -56,8 +56,7 @@ func runInit() func(*cobra.Command, []string) error { FlagKey: getFlag(cmd, flagKeyFlag), } - initializer := setup.Initializer{} - result, err := initializer.InjectIntoFile(sdkID, filePath, cfg) + result, err := svc.Inject(sdkID, filePath, cfg) if err != nil { return err } diff --git a/cmd/setup/install.go b/cmd/setup/install.go index 12ad67c5..455ec716 100644 --- a/cmd/setup/install.go +++ b/cmd/setup/install.go @@ -17,12 +17,12 @@ const ( dryRunFlag = "dry-run" ) -func newInstallCmd(installer setup.Installer) *cobra.Command { +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(installer), + RunE: runInstall(svc), } cmd.Flags().String(pathFlag, "", "Path to the project directory (defaults to current directory)") @@ -34,7 +34,7 @@ func newInstallCmd(installer setup.Installer) *cobra.Command { return cmd } -func runInstall(installer setup.Installer) func(*cobra.Command, []string) error { +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 == "" { @@ -64,7 +64,7 @@ func runInstall(installer setup.Installer) func(*cobra.Command, []string) error } } else { var err error - result, err = installer.Install(dir, detection) + result, err = svc.Install(dir, detection) if err != nil { return err } diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index ed59797e..926c498c 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -9,7 +9,6 @@ import ( cmdAnalytics "github.com/launchdarkly/ldcli/cmd/analytics" "github.com/launchdarkly/ldcli/cmd/cliflags" "github.com/launchdarkly/ldcli/internal/analytics" - "github.com/launchdarkly/ldcli/internal/flags" "github.com/launchdarkly/ldcli/internal/resources" "github.com/launchdarkly/ldcli/internal/setup" ) @@ -18,10 +17,15 @@ import ( func NewSetupCmd( analyticsTrackerFn analytics.TrackerFn, resourcesClient resources.Client, - flagsClient flags.Client, detector setup.Detector, installer setup.Installer, ) *cobra.Command { + svc := setup.Service{ + Client: resourcesClient, + Detector: detector, + Installer: installer, + Initializer: setup.Initializer{}, + } cmd := &cobra.Command{ Use: "setup", Short: "Set up LaunchDarkly in your project", @@ -43,12 +47,12 @@ and verifies the connection.`, viper.GetBool(cliflags.AnalyticsOptOut), ).SendCommandRunEvent(cmdAnalytics.CmdRunEventProperties(cmd, "setup", nil)) }, - RunE: runSetupWizard(analyticsTrackerFn, resourcesClient, flagsClient, detector, installer), + RunE: runSetupWizard(analyticsTrackerFn, svc), } - cmd.AddCommand(newDetectCmd(detector)) - cmd.AddCommand(newInstallCmd(installer)) - cmd.AddCommand(newInitCmd()) + cmd.AddCommand(newDetectCmd(svc)) + cmd.AddCommand(newInstallCmd(svc)) + cmd.AddCommand(newInitCmd(svc)) return cmd } diff --git a/cmd/setup/wizard.go b/cmd/setup/wizard.go index 8ab182a2..a10e9b1f 100644 --- a/cmd/setup/wizard.go +++ b/cmd/setup/wizard.go @@ -1,10 +1,7 @@ package setup import ( - "context" - "encoding/json" "fmt" - "net/url" "os" "strings" @@ -17,8 +14,6 @@ import ( "github.com/launchdarkly/ldcli/cmd/cliflags" "github.com/launchdarkly/ldcli/internal/analytics" "github.com/launchdarkly/ldcli/internal/errors" - "github.com/launchdarkly/ldcli/internal/flags" - "github.com/launchdarkly/ldcli/internal/resources" "github.com/launchdarkly/ldcli/internal/setup" ) @@ -40,10 +35,8 @@ const ( type wizardModel struct { analyticsTrackerFn analytics.TrackerFn - resourcesClient resources.Client - flagsClient flags.Client - detector setup.Detector - installer setup.Installer + svc setup.Service + auth setup.Auth step wizardStep spinner spinner.Model @@ -131,10 +124,7 @@ type wizardErrMsg struct{ err error } func runSetupWizard( analyticsTrackerFn analytics.TrackerFn, - resourcesClient resources.Client, - flagsClient flags.Client, - detector setup.Detector, - installer setup.Installer, + 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 @@ -148,12 +138,13 @@ func runSetupWizard( m := wizardModel{ analyticsTrackerFn: analyticsTrackerFn, - resourcesClient: resourcesClient, - flagsClient: flagsClient, - detector: detector, - installer: installer, - step: stepSelectProject, - spinner: s, + svc: svc, + auth: setup.Auth{ + AccessToken: viper.GetString(cliflags.AccessTokenFlag), + BaseURI: viper.GetString(cliflags.BaseURIFlag), + }, + step: stepSelectProject, + spinner: s, } p := tea.NewProgram(m, tea.WithAltScreen()) @@ -681,35 +672,20 @@ func (m wizardModel) planView() string { mutedStyle.Render("Enter continue · ← back · esc quit") } -// Commands that perform async work +// 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. func (m wizardModel) fetchProjects() tea.Cmd { return func() tea.Msg { - path, _ := url.JoinPath( - viper.GetString(cliflags.BaseURIFlag), - "api/v2/projects", - ) - res, err := m.resourcesClient.MakeRequest( - viper.GetString(cliflags.AccessTokenFlag), - "GET", path, "application/json", nil, nil, false, - ) + ps, err := m.svc.ListProjects(m.auth) if err != nil { return wizardErrMsg{err: err} } - - var resp struct { - Items []struct { - Key string `json:"key"` - Name string `json:"name"` - } `json:"items"` - } - if err := json.Unmarshal(res, &resp); err != nil { - return wizardErrMsg{err: fmt.Errorf("parsing projects: %w", err)} - } - - projects := make([]projectItem, len(resp.Items)) - for i, item := range resp.Items { - projects[i] = projectItem{key: item.Key, name: item.Name} + projects := make([]projectItem, len(ps)) + for i, p := range ps { + projects[i] = projectItem{key: p.Key, name: p.Name} } return projectsFetchedMsg{projects: projects} } @@ -717,31 +693,13 @@ func (m wizardModel) fetchProjects() tea.Cmd { func (m wizardModel) fetchEnvironments() tea.Cmd { return func() tea.Msg { - path, _ := url.JoinPath( - viper.GetString(cliflags.BaseURIFlag), - "api/v2/projects", m.selectedProject, "environments", - ) - res, err := m.resourcesClient.MakeRequest( - viper.GetString(cliflags.AccessTokenFlag), - "GET", path, "application/json", nil, nil, false, - ) + es, err := m.svc.ListEnvironments(m.auth, m.selectedProject) if err != nil { return wizardErrMsg{err: err} } - - var resp struct { - Items []struct { - Key string `json:"key"` - Name string `json:"name"` - } `json:"items"` - } - if err := json.Unmarshal(res, &resp); err != nil { - return wizardErrMsg{err: fmt.Errorf("parsing environments: %w", err)} - } - - envs := make([]envItem, len(resp.Items)) - for i, item := range resp.Items { - envs[i] = envItem{key: item.Key, name: item.Name} + envs := make([]envItem, len(es)) + for i, e := range es { + envs[i] = envItem{key: e.Key, name: e.Name} } return envsFetchedMsg{environments: envs} } @@ -749,31 +707,14 @@ func (m wizardModel) fetchEnvironments() tea.Cmd { func (m wizardModel) fetchEnvDetails() tea.Cmd { return func() tea.Msg { - path, _ := url.JoinPath( - viper.GetString(cliflags.BaseURIFlag), - "api/v2/projects", m.selectedProject, "environments", m.selectedEnv, - ) - res, err := m.resourcesClient.MakeRequest( - viper.GetString(cliflags.AccessTokenFlag), - "GET", path, "application/json", nil, nil, false, - ) + keys, err := m.svc.EnvKeys(m.auth, m.selectedProject, m.selectedEnv) if err != nil { return wizardErrMsg{err: 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 wizardErrMsg{err: fmt.Errorf("parsing environment details: %w", err)} - } - return envDetailsFetchedMsg{ - sdkKey: resp.SDKKey, - clientSideID: resp.ClientSideId, - mobileKey: resp.MobileKey, + sdkKey: keys.SDKKey, + clientSideID: keys.ClientSideID, + mobileKey: keys.MobileKey, } } } @@ -784,7 +725,7 @@ func (m wizardModel) runDetect() tea.Cmd { if err != nil { return wizardErrMsg{err: err} } - result, err := m.detector.Detect(dir) + result, err := m.svc.Detect(dir) if err != nil { return detectFailedMsg{} } @@ -798,10 +739,10 @@ func (m wizardModel) runInstall() tea.Cmd { if err != nil { return wizardErrMsg{err: err} } - result, err := m.installer.Install(dir, m.detectResult) + result, err := m.svc.Install(dir, m.detectResult) if err != nil { - // Don't dead-end on a failed auto-install (e.g. Ruby gem perms, no - // network): continue and surface the command to run by hand. + // 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, @@ -816,25 +757,11 @@ func (m wizardModel) runInstall() tea.Cmd { func (m wizardModel) runCreateFlag() tea.Cmd { return func() tea.Msg { - flagKey := "my-new-flag" - flagName := "My New Flag" - - _, err := m.flagsClient.Create( - context.Background(), - viper.GetString(cliflags.AccessTokenFlag), - viper.GetString(cliflags.BaseURIFlag), - flagName, - flagKey, - m.selectedProject, - ) + key, err := m.svc.CreateFlag(m.auth, m.selectedProject, "my-new-flag", "My New Flag") if err != nil { - // If flag already exists (conflict), continue using it - if jsonErr, parseErr := parseJSONError(err); parseErr == nil && jsonErr.Code == "conflict" { - return flagCreatedMsg{key: flagKey} - } return wizardErrMsg{err: err} } - return flagCreatedMsg{key: flagKey} + return flagCreatedMsg{key: key} } } @@ -846,8 +773,7 @@ func (m wizardModel) runInit() tea.Cmd { MobileKey: m.mobileKey, FlagKey: m.flagKey, } - initializer := setup.Initializer{} - result, err := initializer.InjectIntoFile(m.detectResult.SDKID, m.detectResult.EntryPoint, cfg) + result, err := m.svc.Inject(m.detectResult.SDKID, m.detectResult.EntryPoint, cfg) if err != nil { return wizardErrMsg{err: err} } @@ -857,29 +783,10 @@ func (m wizardModel) runInit() tea.Cmd { func (m wizardModel) runVerify() tea.Cmd { return func() tea.Msg { - verifier := setup.DefaultVerifier(m.resourcesClient) - result, err := verifier.Verify( - viper.GetString(cliflags.AccessTokenFlag), - viper.GetString(cliflags.BaseURIFlag), - m.selectedProject, - m.selectedEnv, - ) + result, err := m.svc.Verify(m.auth, m.selectedProject, m.selectedEnv) if err != nil { return wizardErrMsg{err: err} } return verifyDoneMsg{result: result} } } - -type jsonError struct { - Code string `json:"code"` - Message string `json:"message"` -} - -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 -} From 0f75fda91936821852f0d3c84cd617e29b820fe7 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Fri, 24 Jul 2026 12:52:32 -0400 Subject: [PATCH 11/13] test(setup): cover the orchestration service with stubs and mocks Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/setup/service_test.go | 148 +++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 internal/setup/service_test.go diff --git a/internal/setup/service_test.go b/internal/setup/service_test.go new file mode 100644 index 00000000..87e5e173 --- /dev/null +++ b/internal/setup/service_test.go @@ -0,0 +1,148 @@ +package setup + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/errors" + "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) { + svc := Service{Client: &resources.MockClient{ + Response: []byte(`{"items":[{"key":"p1","name":"Project One"},{"key":"p2","name":"Project Two"}]}`), + }} + + 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) { + svc := Service{Client: &resources.MockClient{ + Response: []byte(`{"items":[{"key":"production","name":"Production"}]}`), + }} + + 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) { + svc := Service{Client: &resources.MockClient{ + Response: []byte(`{"apiKey":"sdk-123","_id":"client-456","mobileKey":"mob-789"}`), + }} + + 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) { + svc := Service{Client: &resources.MockClient{Response: []byte(`{"key":"my-new-flag"}`)}} + + key, err := svc.CreateFlag(testAuth, "p1", "my-new-flag", "My New Flag") + + require.NoError(t, err) + assert.Equal(t, "my-new-flag", key) +} + +func TestService_CreateFlag_ConflictIsSuccess(t *testing.T) { + svc := Service{Client: &resources.MockClient{ + StatusCode: 409, + Err: errors.NewError(`{"code":"conflict","message":"already exists"}`), + }} + + key, err := svc.CreateFlag(testAuth, "p1", "my-new-flag", "My New Flag") + + require.NoError(t, err) + assert.Equal(t, "my-new-flag", key) +} + +func TestService_CreateFlag_OtherErrorPropagates(t *testing.T) { + svc := Service{Client: &resources.MockClient{ + StatusCode: 500, + Err: errors.NewError(`{"code":"internal_error"}`), + }} + + _, err := svc.CreateFlag(testAuth, "p1", "my-new-flag", "My New Flag") + + 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{Client: &resources.MockClient{Response: []byte(`{"active":true}`)}} + + result, err := svc.Verify(testAuth, "p1", "production") + + require.NoError(t, err) + assert.True(t, result.Active) + assert.Equal(t, 1, result.Attempts) +} From 45a2eb9ca5bd8c64c8ed830bf9d9d04542b8860f Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Fri, 24 Jul 2026 12:55:00 -0400 Subject: [PATCH 12/13] refactor(setup): split wizard.go into model, update, view, and commands Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/setup/commands.go | 124 +++++++ cmd/setup/model.go | 155 +++++++++ cmd/setup/update.go | 279 +++++++++++++++ cmd/setup/view.go | 263 ++++++++++++++ cmd/setup/wizard.go | 792 ------------------------------------------ 5 files changed, 821 insertions(+), 792 deletions(-) create mode 100644 cmd/setup/commands.go create mode 100644 cmd/setup/model.go create mode 100644 cmd/setup/update.go create mode 100644 cmd/setup/view.go delete mode 100644 cmd/setup/wizard.go diff --git a/cmd/setup/commands.go b/cmd/setup/commands.go new file mode 100644 index 00000000..311056b3 --- /dev/null +++ b/cmd/setup/commands.go @@ -0,0 +1,124 @@ +package setup + +import ( + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "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 { + return func() tea.Msg { + es, err := m.svc.ListEnvironments(m.auth, m.selectedProject) + 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{environments: envs} + } +} + +func (m wizardModel) fetchEnvDetails() tea.Cmd { + return func() tea.Msg { + keys, err := m.svc.EnvKeys(m.auth, m.selectedProject, m.selectedEnv) + if err != nil { + return wizardErrMsg{err: err} + } + return envDetailsFetchedMsg{ + 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") + 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) + if err != nil { + return wizardErrMsg{err: err} + } + return verifyDoneMsg{result: result} + } +} diff --git a/cmd/setup/model.go b/cmd/setup/model.go new file mode 100644 index 00000000..ddfd2b17 --- /dev/null +++ b/cmd/setup/model.go @@ -0,0 +1,155 @@ +package setup + +import ( + "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" +) + +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 + projectList list.Model + envList list.Model + 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) + detectedEntryPoint string + 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 + + 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 } +type envsFetchedMsg struct{ environments []envItem } +type envDetailsFetchedMsg struct { + 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 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, + } + + 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/update.go b/cmd/setup/update.go new file mode 100644 index 00000000..9344c8c9 --- /dev/null +++ b/cmd/setup/update.go @@ -0,0 +1,279 @@ +package setup + +import ( + "os" + "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 + + 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 "enter": + return m.handleEnter() + } + + case projectsFetchedMsg: + m.projects = msg.projects + 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.height-4) + m.projectList.Title = "Select a project:" + m.projectList.SetShowStatusBar(false) + return m, nil + + case envsFetchedMsg: + m.environments = msg.environments + 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.height-4) + m.envList.Title = "Select an environment:" + m.envList.SetShowStatusBar(false) + return m, nil + + case envDetailsFetchedMsg: + 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.detectedEntryPoint = msg.result.EntryPoint + 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.step = stepSelectSDK + return + } + } + m.detectedSDK = nil + m.sdkFocus = 1 + m.sdkList = m.newSDKList(sdkItemsExcept(""), "Select your SDK:", true) + m.step = stepSelectSDK +} + +// 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 + 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.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 + } + m.detectResult = &setup.DetectResult{ + SDKID: chosen.id, + Language: chosen.language, + EntryPoint: m.detectedEntryPoint, + } + // Compute the plan preview shown before any action is taken. + args, _ := setup.InstallArgs(chosen.id, "") + 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..b939d181 --- /dev/null +++ b/cmd/setup/view.go @@ -0,0 +1,263 @@ +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" + +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 len(m.projects) == 0 { + return m.spinner.View() + " Loading projects..." + } + return m.projectList.View() + "\n" + mutedStyle.Render("esc quit") + + case stepSelectEnvironment: + if len(m.environments) == 0 { + return m.spinner.View() + " Loading environments..." + } + 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: + return titleStyle.Render("Start your application") + "\n\n" + + "SDK initialization code has been injected into:\n" + + " " + 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. Install it yourself with:") + "\n\n" + + code(m.installResult.Command) + "\n\n" + if m.installResult.FailureReason != "" { + body += m.wrap("Reason: "+m.installResult.FailureReason) + "\n\n" + } + 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 + 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" + + 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 +} + +// 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.go b/cmd/setup/wizard.go deleted file mode 100644 index a10e9b1f..00000000 --- a/cmd/setup/wizard.go +++ /dev/null @@ -1,792 +0,0 @@ -package setup - -import ( - "fmt" - "os" - "strings" - - "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" -) - -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 - projectList list.Model - envList list.Model - 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) - detectedEntryPoint string - 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 - - 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 } -type envsFetchedMsg struct{ environments []envItem } -type envDetailsFetchedMsg struct { - 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 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, - } - - 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()) -} - -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 - - 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 "enter": - return m.handleEnter() - } - - case projectsFetchedMsg: - m.projects = msg.projects - 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.height-4) - m.projectList.Title = "Select a project:" - m.projectList.SetShowStatusBar(false) - return m, nil - - case envsFetchedMsg: - m.environments = msg.environments - 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.height-4) - m.envList.Title = "Select an environment:" - m.envList.SetShowStatusBar(false) - return m, nil - - case envDetailsFetchedMsg: - 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.detectedEntryPoint = msg.result.EntryPoint - 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.step = stepSelectSDK - return - } - } - m.detectedSDK = nil - m.sdkFocus = 1 - m.sdkList = m.newSDKList(sdkItemsExcept(""), "Select your SDK:", true) - m.step = stepSelectSDK -} - -// 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 - 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.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 - } - m.detectResult = &setup.DetectResult{ - SDKID: chosen.id, - Language: chosen.language, - EntryPoint: m.detectedEntryPoint, - } - // Compute the plan preview shown before any action is taken. - args, _ := setup.InstallArgs(chosen.id, "") - 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. -var quitHint = "\n" + mutedStyle.Render("Press q to quit.") + "\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 len(m.projects) == 0 { - return m.spinner.View() + " Loading projects..." - } - return m.projectList.View() + "\n" + mutedStyle.Render("esc quit") - - case stepSelectEnvironment: - if len(m.environments) == 0 { - return m.spinner.View() + " Loading environments..." - } - 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: - return titleStyle.Render("Start your application") + "\n\n" + - "SDK initialization code has been injected into:\n" + - " " + 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. Install it yourself with:") + "\n\n" + - code(m.installResult.Command) + "\n\n" - if m.installResult.FailureReason != "" { - body += m.wrap("Reason: "+m.installResult.FailureReason) + "\n\n" - } - 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 + 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" + - quitHint - } - if m.verifyResult != nil && m.verifyResult.Active && m.detectResult != nil { - appHost := strings.TrimRight(viper.GetString(cliflags.BaseURIFlag), "/") - 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 -} - -// 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. - -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 { - return func() tea.Msg { - es, err := m.svc.ListEnvironments(m.auth, m.selectedProject) - 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{environments: envs} - } -} - -func (m wizardModel) fetchEnvDetails() tea.Cmd { - return func() tea.Msg { - keys, err := m.svc.EnvKeys(m.auth, m.selectedProject, m.selectedEnv) - if err != nil { - return wizardErrMsg{err: err} - } - return envDetailsFetchedMsg{ - 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") - 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) - if err != nil { - return wizardErrMsg{err: err} - } - return verifyDoneMsg{result: result} - } -} From f9f0996b63b2ab323018763eeb0b74f4390feea8 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Thu, 30 Jul 2026 12:54:23 -0400 Subject: [PATCH 13/13] refactor(setup): use typed resource clients for setup API calls The setup service built its API requests by hand: url.JoinPath paths, inline response structs, and no shared error handling. Projects, environments, and flags have typed clients wrapping the generated SDK, so use those. Add environments.List, which the client did not expose, and group the service's clients in a setup.Clients struct. Verify keeps using resources.Client because its sdk-active endpoint has no method in the generated SDK. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/root.go | 7 ++++- cmd/setup/setup.go | 5 ++- internal/environments/client.go | 23 ++++++++++++++ internal/environments/mock_client.go | 11 +++++++ internal/setup/service.go | 36 +++++++++++---------- internal/setup/service_test.go | 47 +++++++++++++++++----------- 6 files changed, 90 insertions(+), 39 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 02356330..4bb81d5f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -278,7 +278,12 @@ func NewRootCommand( } cmd.AddCommand(setupcmd.NewSetupCmd( analyticsTrackerFn, - clients.ResourcesClient, + setup.Clients{ + Projects: clients.ProjectsClient, + Environments: clients.EnvironmentsClient, + Flags: clients.FlagsClient, + Resources: clients.ResourcesClient, + }, detector, installer, )) diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 926c498c..b0f32f95 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -9,19 +9,18 @@ import ( cmdAnalytics "github.com/launchdarkly/ldcli/cmd/analytics" "github.com/launchdarkly/ldcli/cmd/cliflags" "github.com/launchdarkly/ldcli/internal/analytics" - "github.com/launchdarkly/ldcli/internal/resources" "github.com/launchdarkly/ldcli/internal/setup" ) // NewSetupCmd creates the top-level setup command and registers its hidden subcommands. func NewSetupCmd( analyticsTrackerFn analytics.TrackerFn, - resourcesClient resources.Client, + clients setup.Clients, detector setup.Detector, installer setup.Installer, ) *cobra.Command { svc := setup.Service{ - Client: resourcesClient, + Clients: clients, Detector: detector, Installer: installer, Initializer: setup.Initializer{}, diff --git a/internal/environments/client.go b/internal/environments/client.go index 6abbc757..46add672 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) ([]byte, error) } type EnvironmentsClient struct { @@ -46,3 +47,25 @@ func (c EnvironmentsClient) Get( return output, nil } + +func (c EnvironmentsClient) List( + ctx context.Context, + accessToken, + baseURI, + projectKey string, +) ([]byte, error) { + client := client.New(accessToken, baseURI, c.cliVersion) + environments, _, err := client.EnvironmentsApi.GetEnvironmentsByProject(ctx, projectKey).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..e6da5bef 100644 --- a/internal/environments/mock_client.go +++ b/internal/environments/mock_client.go @@ -23,3 +23,14 @@ func (c *MockClient) Get( return args.Get(0).([]byte), args.Error(1) } + +func (c *MockClient) List( + ctx context.Context, + accessToken, + baseURI, + projKey string, +) ([]byte, error) { + args := c.Called(accessToken, baseURI, projKey) + + return args.Get(0).([]byte), args.Error(1) +} diff --git a/internal/setup/service.go b/internal/setup/service.go index fc72c6f7..dd3147a8 100644 --- a/internal/setup/service.go +++ b/internal/setup/service.go @@ -1,10 +1,13 @@ package setup import ( + "context" "encoding/json" "fmt" - "net/url" + "github.com/launchdarkly/ldcli/internal/environments" + "github.com/launchdarkly/ldcli/internal/flags" + "github.com/launchdarkly/ldcli/internal/projects" "github.com/launchdarkly/ldcli/internal/resources" ) @@ -14,11 +17,21 @@ type Auth struct { 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 { - Client resources.Client + Clients Clients Detector Detector Installer Installer Initializer Initializer @@ -45,8 +58,7 @@ type EnvKeys struct { // ListProjects returns the account's projects. func (s Service) ListProjects(a Auth) ([]ProjectSummary, error) { - path, _ := url.JoinPath(a.BaseURI, "api/v2/projects") - res, err := s.Client.MakeRequest(a.AccessToken, "GET", path, "application/json", nil, nil, false) + res, err := s.Clients.Projects.List(context.Background(), a.AccessToken, a.BaseURI) if err != nil { return nil, err } @@ -70,8 +82,7 @@ func (s Service) ListProjects(a Auth) ([]ProjectSummary, error) { // ListEnvironments returns the environments in a project. func (s Service) ListEnvironments(a Auth, projectKey string) ([]EnvSummary, error) { - path, _ := url.JoinPath(a.BaseURI, "api/v2/projects", projectKey, "environments") - res, err := s.Client.MakeRequest(a.AccessToken, "GET", path, "application/json", nil, nil, false) + res, err := s.Clients.Environments.List(context.Background(), a.AccessToken, a.BaseURI, projectKey) if err != nil { return nil, err } @@ -95,8 +106,7 @@ func (s Service) ListEnvironments(a Auth, projectKey string) ([]EnvSummary, erro // EnvKeys returns the SDK credentials for an environment. func (s Service) EnvKeys(a Auth, projectKey, envKey string) (EnvKeys, error) { - path, _ := url.JoinPath(a.BaseURI, "api/v2/projects", projectKey, "environments", envKey) - res, err := s.Client.MakeRequest(a.AccessToken, "GET", path, "application/json", nil, nil, false) + res, err := s.Clients.Environments.Get(context.Background(), a.AccessToken, a.BaseURI, envKey, projectKey) if err != nil { return EnvKeys{}, err } @@ -132,13 +142,7 @@ func (s Service) Install(dir string, detection *DetectResult) (*InstallResult, e // CreateFlag creates a feature flag, treating an existing flag (conflict) as // success and returning its key. func (s Service) CreateFlag(a Auth, projectKey, key, name string) (string, error) { - path, _ := url.JoinPath(a.BaseURI, "api/v2/flags", projectKey) - body, err := json.Marshal(map[string]string{"key": key, "name": name}) - if err != nil { - return "", err - } - - _, err = s.Client.MakeRequest(a.AccessToken, "POST", path, "application/json", nil, body, false) + _, err := s.Clients.Flags.Create(context.Background(), a.AccessToken, a.BaseURI, name, key, projectKey) if err != nil { if je, parseErr := parseJSONError(err); parseErr == nil && je.Code == "conflict" { return key, nil @@ -155,7 +159,7 @@ func (s Service) Inject(sdkID, filePath string, cfg InitConfig) (*InitResult, er // Verify polls until the SDK reports as active or a timeout is reached. func (s Service) Verify(a Auth, projectKey, envKey string) (*VerifyResult, error) { - return DefaultVerifier(s.Client).Verify(a.AccessToken, a.BaseURI, projectKey, envKey) + return DefaultVerifier(s.Clients.Resources).Verify(a.AccessToken, a.BaseURI, projectKey, envKey) } type jsonError struct { diff --git a/internal/setup/service_test.go b/internal/setup/service_test.go index 87e5e173..afe76417 100644 --- a/internal/setup/service_test.go +++ b/internal/setup/service_test.go @@ -7,7 +7,10 @@ import ( "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" ) @@ -32,9 +35,10 @@ func (f fakeInstaller) Install(string, *DetectResult) (*InstallResult, error) { } func TestService_ListProjects(t *testing.T) { - svc := Service{Client: &resources.MockClient{ - Response: []byte(`{"items":[{"key":"p1","name":"Project One"},{"key":"p2","name":"Project Two"}]}`), - }} + mockProjects := &projects.MockClient{} + mockProjects.On("List", testAuth.AccessToken, testAuth.BaseURI). + 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) @@ -43,9 +47,10 @@ func TestService_ListProjects(t *testing.T) { } func TestService_ListEnvironments(t *testing.T) { - svc := Service{Client: &resources.MockClient{ - Response: []byte(`{"items":[{"key":"production","name":"Production"}]}`), - }} + mockEnvs := &environments.MockClient{} + mockEnvs.On("List", testAuth.AccessToken, testAuth.BaseURI, "p1"). + Return([]byte(`{"items":[{"key":"production","name":"Production"}]}`), nil) + svc := Service{Clients: Clients{Environments: mockEnvs}} got, err := svc.ListEnvironments(testAuth, "p1") @@ -54,9 +59,10 @@ func TestService_ListEnvironments(t *testing.T) { } func TestService_EnvKeys(t *testing.T) { - svc := Service{Client: &resources.MockClient{ - Response: []byte(`{"apiKey":"sdk-123","_id":"client-456","mobileKey":"mob-789"}`), - }} + 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") @@ -65,7 +71,10 @@ func TestService_EnvKeys(t *testing.T) { } func TestService_CreateFlag_Success(t *testing.T) { - svc := Service{Client: &resources.MockClient{Response: []byte(`{"key":"my-new-flag"}`)}} + 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") @@ -74,10 +83,10 @@ func TestService_CreateFlag_Success(t *testing.T) { } func TestService_CreateFlag_ConflictIsSuccess(t *testing.T) { - svc := Service{Client: &resources.MockClient{ - StatusCode: 409, - Err: errors.NewError(`{"code":"conflict","message":"already exists"}`), - }} + 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") @@ -86,10 +95,10 @@ func TestService_CreateFlag_ConflictIsSuccess(t *testing.T) { } func TestService_CreateFlag_OtherErrorPropagates(t *testing.T) { - svc := Service{Client: &resources.MockClient{ - StatusCode: 500, - Err: errors.NewError(`{"code":"internal_error"}`), - }} + 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") @@ -138,7 +147,7 @@ func TestService_Inject(t *testing.T) { } func TestService_Verify_Active(t *testing.T) { - svc := Service{Client: &resources.MockClient{Response: []byte(`{"active":true}`)}} + svc := Service{Clients: Clients{Resources: &resources.MockClient{Response: []byte(`{"active":true}`)}}} result, err := svc.Verify(testAuth, "p1", "production")