From 8a5da582253f404a7cab8e78c0ae46c269551b54 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 20:40:54 +0000 Subject: [PATCH 1/4] feat: add hand-rolled get-sdk-active command for environments Adds a custom CLI command to check SDK active status for an environment. This endpoint (GET /api/v2/projects/{projectKey}/environments/{environmentKey}/sdk-active) is hidden in the API spec and cannot be synced via make openapi-spec-update, so it is implemented as a hand-rolled command registered under the environments parent command. The command accepts --project and --environment flags and supports both plaintext and JSON output formats. Co-Authored-By: Ari Salem --- cmd/root.go | 4 ++ cmd/sdk_active/sdk_active.go | 88 +++++++++++++++++++++++++++++++ cmd/sdk_active/sdk_active_test.go | 85 +++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 cmd/sdk_active/sdk_active.go create mode 100644 cmd/sdk_active/sdk_active_test.go diff --git a/cmd/root.go b/cmd/root.go index f62eb6a2..34ffdca9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -21,6 +21,7 @@ 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" signupcmd "github.com/launchdarkly/ldcli/cmd/signup" sourcemapscmd "github.com/launchdarkly/ldcli/cmd/sourcemaps" @@ -227,6 +228,9 @@ func NewRootCommand( if c.Name() == "members" { c.AddCommand(memberscmd.NewMembersInviteCmd(clients.ResourcesClient)) } + if c.Name() == "environments" { + c.AddCommand(sdkactivecmd.NewSdkActiveCmd(clients.ResourcesClient)) + } } rootCmd.Commands = append(rootCmd.Commands, configCmd) diff --git a/cmd/sdk_active/sdk_active.go b/cmd/sdk_active/sdk_active.go new file mode 100644 index 00000000..b4f95723 --- /dev/null +++ b/cmd/sdk_active/sdk_active.go @@ -0,0 +1,88 @@ +package sdk_active + +import ( + "encoding/json" + "fmt" + "net/url" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/launchdarkly/ldcli/cmd/cliflags" + resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/cmd/validators" + "github.com/launchdarkly/ldcli/internal/errors" + "github.com/launchdarkly/ldcli/internal/output" + "github.com/launchdarkly/ldcli/internal/resources" +) + +type sdkActiveResponse struct { + SdkActive bool `json:"sdkActive"` +} + +func NewSdkActiveCmd(client resources.Client) *cobra.Command { + cmd := &cobra.Command{ + Args: validators.Validate(), + Long: "Get SDK active status for an environment. Returns information about whether any SDKs have initialized in the given environment within the past seven days.", + RunE: runGetSdkActive(client), + Short: "Get SDK active status for an environment", + Use: "get-sdk-active", + } + + cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + initFlags(cmd) + + return cmd +} + +func runGetSdkActive(client resources.Client) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + path, _ := url.JoinPath( + viper.GetString(cliflags.BaseURIFlag), + "api/v2/projects", + viper.GetString(cliflags.ProjectFlag), + "environments", + viper.GetString(cliflags.EnvironmentFlag), + "sdk-active", + ) + res, err := client.MakeRequest( + viper.GetString(cliflags.AccessTokenFlag), + "GET", + path, + "application/json", + nil, + nil, + false, + ) + if err != nil { + return output.NewCmdOutputError(err, cliflags.GetOutputKind(cmd)) + } + + outputKind := cliflags.GetOutputKind(cmd) + if outputKind == "json" { + fmt.Fprint(cmd.OutOrStdout(), string(res)+"\n") + return nil + } + + var resp sdkActiveResponse + if err := json.Unmarshal(res, &resp); err != nil { + return errors.NewError(err.Error()) + } + + fmt.Fprintf(cmd.OutOrStdout(), "SDK active: %t\n", resp.SdkActive) + + return nil + } +} + +func initFlags(cmd *cobra.Command) { + cmd.Flags().String(cliflags.ProjectFlag, "", "The project key") + _ = cmd.MarkFlagRequired(cliflags.ProjectFlag) + _ = cmd.Flags().SetAnnotation(cliflags.ProjectFlag, "required", []string{"true"}) + _ = viper.BindPFlag(cliflags.ProjectFlag, cmd.Flags().Lookup(cliflags.ProjectFlag)) + + cmd.Flags().String(cliflags.EnvironmentFlag, "", "The environment key") + _ = cmd.MarkFlagRequired(cliflags.EnvironmentFlag) + _ = cmd.Flags().SetAnnotation(cliflags.EnvironmentFlag, "required", []string{"true"}) + _ = viper.BindPFlag(cliflags.EnvironmentFlag, cmd.Flags().Lookup(cliflags.EnvironmentFlag)) +} diff --git a/cmd/sdk_active/sdk_active_test.go b/cmd/sdk_active/sdk_active_test.go new file mode 100644 index 00000000..93bcb6d0 --- /dev/null +++ b/cmd/sdk_active/sdk_active_test.go @@ -0,0 +1,85 @@ +package sdk_active_test + +import ( + "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" +) + +func TestGetSdkActive(t *testing.T) { + mockClient := &resources.MockClient{ + Response: []byte(`{ + "sdkActive": true, + "lastSeenAt": 1718000000000 + }`), + } + args := []string{ + "environments", "get-sdk-active", + "--access-token", "abcd1234", + "--project", "test-proj", + "--environment", "test-env", + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: mockClient, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Equal(t, "SDK active: true\n", string(output)) +} + +func TestGetSdkActiveJSON(t *testing.T) { + mockClient := &resources.MockClient{ + Response: []byte(`{ + "sdkActive": true, + "lastSeenAt": 1718000000000 + }`), + } + args := []string{ + "environments", "get-sdk-active", + "--access-token", "abcd1234", + "--project", "test-proj", + "--environment", "test-env", + "--output", "json", + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: mockClient, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), `"sdkActive"`) + assert.Contains(t, string(output), `"lastSeenAt"`) +} + +func TestGetSdkActiveMissingRequiredFlags(t *testing.T) { + mockClient := &resources.MockClient{} + args := []string{ + "environments", "get-sdk-active", + "--access-token", "abcd1234", + } + _, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: mockClient, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "required") +} From 6c6c26c95f579fae894206092a1f70bf6a6ea95d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:56:04 +0000 Subject: [PATCH 2/4] fix: correct response field from sdkActive to active The gonfalon UsageSdkActiveRep struct uses json:"active", not json:"sdkActive". Updated the response struct and tests to match the actual API response shape. Co-Authored-By: Ari Salem --- cmd/sdk_active/sdk_active.go | 4 ++-- cmd/sdk_active/sdk_active_test.go | 13 +++---------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/cmd/sdk_active/sdk_active.go b/cmd/sdk_active/sdk_active.go index b4f95723..63e998b3 100644 --- a/cmd/sdk_active/sdk_active.go +++ b/cmd/sdk_active/sdk_active.go @@ -17,7 +17,7 @@ import ( ) type sdkActiveResponse struct { - SdkActive bool `json:"sdkActive"` + Active bool `json:"active"` } func NewSdkActiveCmd(client resources.Client) *cobra.Command { @@ -69,7 +69,7 @@ func runGetSdkActive(client resources.Client) func(*cobra.Command, []string) err return errors.NewError(err.Error()) } - fmt.Fprintf(cmd.OutOrStdout(), "SDK active: %t\n", resp.SdkActive) + fmt.Fprintf(cmd.OutOrStdout(), "SDK active: %t\n", resp.Active) return nil } diff --git a/cmd/sdk_active/sdk_active_test.go b/cmd/sdk_active/sdk_active_test.go index 93bcb6d0..e66637b5 100644 --- a/cmd/sdk_active/sdk_active_test.go +++ b/cmd/sdk_active/sdk_active_test.go @@ -13,10 +13,7 @@ import ( func TestGetSdkActive(t *testing.T) { mockClient := &resources.MockClient{ - Response: []byte(`{ - "sdkActive": true, - "lastSeenAt": 1718000000000 - }`), + Response: []byte(`{"active": true}`), } args := []string{ "environments", "get-sdk-active", @@ -39,10 +36,7 @@ func TestGetSdkActive(t *testing.T) { func TestGetSdkActiveJSON(t *testing.T) { mockClient := &resources.MockClient{ - Response: []byte(`{ - "sdkActive": true, - "lastSeenAt": 1718000000000 - }`), + Response: []byte(`{"active": true}`), } args := []string{ "environments", "get-sdk-active", @@ -61,8 +55,7 @@ func TestGetSdkActiveJSON(t *testing.T) { ) require.NoError(t, err) - assert.Contains(t, string(output), `"sdkActive"`) - assert.Contains(t, string(output), `"lastSeenAt"`) + assert.Contains(t, string(output), `"active"`) } func TestGetSdkActiveMissingRequiredFlags(t *testing.T) { From 9669e40021f662787fba7e3d68764a226ad3a22a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:07:05 +0000 Subject: [PATCH 3/4] feat: add optional sdk-name and sdk-wrapper-name filters The gonfalon endpoint supports sdk_name and sdk_wrapper_name query parameters to narrow the active check to a specific SDK. These are now exposed as optional --sdk-name and --sdk-wrapper-name flags. Co-Authored-By: Ari Salem --- cmd/sdk_active/sdk_active.go | 25 +++++++++++++++- cmd/sdk_active/sdk_active_test.go | 48 +++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/cmd/sdk_active/sdk_active.go b/cmd/sdk_active/sdk_active.go index 63e998b3..f8f5cb9c 100644 --- a/cmd/sdk_active/sdk_active.go +++ b/cmd/sdk_active/sdk_active.go @@ -35,9 +35,14 @@ func NewSdkActiveCmd(client resources.Client) *cobra.Command { return cmd } +const ( + sdkNameFlag = "sdk-name" + sdkWrapperNameFlag = "sdk-wrapper-name" +) + func runGetSdkActive(client resources.Client) func(*cobra.Command, []string) error { return func(cmd *cobra.Command, args []string) error { - path, _ := url.JoinPath( + rawPath, _ := url.JoinPath( viper.GetString(cliflags.BaseURIFlag), "api/v2/projects", viper.GetString(cliflags.ProjectFlag), @@ -45,6 +50,18 @@ func runGetSdkActive(client resources.Client) func(*cobra.Command, []string) err viper.GetString(cliflags.EnvironmentFlag), "sdk-active", ) + + parsed, _ := url.Parse(rawPath) + q := parsed.Query() + if v := viper.GetString(sdkNameFlag); v != "" { + q.Set("sdk_name", v) + } + if v := viper.GetString(sdkWrapperNameFlag); v != "" { + q.Set("sdk_wrapper_name", v) + } + parsed.RawQuery = q.Encode() + path := parsed.String() + res, err := client.MakeRequest( viper.GetString(cliflags.AccessTokenFlag), "GET", @@ -85,4 +102,10 @@ func initFlags(cmd *cobra.Command) { _ = cmd.MarkFlagRequired(cliflags.EnvironmentFlag) _ = cmd.Flags().SetAnnotation(cliflags.EnvironmentFlag, "required", []string{"true"}) _ = viper.BindPFlag(cliflags.EnvironmentFlag, cmd.Flags().Lookup(cliflags.EnvironmentFlag)) + + cmd.Flags().String(sdkNameFlag, "", "Filter by SDK name (e.g. go-server-sdk, node-server-sdk)") + _ = viper.BindPFlag(sdkNameFlag, cmd.Flags().Lookup(sdkNameFlag)) + + cmd.Flags().String(sdkWrapperNameFlag, "", "Filter by SDK wrapper name") + _ = viper.BindPFlag(sdkWrapperNameFlag, cmd.Flags().Lookup(sdkWrapperNameFlag)) } diff --git a/cmd/sdk_active/sdk_active_test.go b/cmd/sdk_active/sdk_active_test.go index e66637b5..602ea073 100644 --- a/cmd/sdk_active/sdk_active_test.go +++ b/cmd/sdk_active/sdk_active_test.go @@ -58,6 +58,54 @@ func TestGetSdkActiveJSON(t *testing.T) { assert.Contains(t, string(output), `"active"`) } +func TestGetSdkActiveWithSdkNameFilter(t *testing.T) { + mockClient := &resources.MockClient{ + Response: []byte(`{"active": true}`), + } + args := []string{ + "environments", "get-sdk-active", + "--access-token", "abcd1234", + "--project", "test-proj", + "--environment", "test-env", + "--sdk-name", "go-server-sdk", + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: mockClient, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Equal(t, "SDK active: true\n", string(output)) +} + +func TestGetSdkActiveWithSdkWrapperNameFilter(t *testing.T) { + mockClient := &resources.MockClient{ + Response: []byte(`{"active": false}`), + } + args := []string{ + "environments", "get-sdk-active", + "--access-token", "abcd1234", + "--project", "test-proj", + "--environment", "test-env", + "--sdk-wrapper-name", "flutter-client-sdk", + } + output, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: mockClient, + }, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Equal(t, "SDK active: false\n", string(output)) +} + func TestGetSdkActiveMissingRequiredFlags(t *testing.T) { mockClient := &resources.MockClient{} args := []string{ From 67097e96bc4795aff540043a449ff6440680a7a8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:11:42 +0000 Subject: [PATCH 4/4] fix: pass query params via MakeRequest instead of URL embedding MakeRequest overwrites req.URL.RawQuery with the query argument, so embedding params in the URL string was silently stripping them. Now sdk_name and sdk_wrapper_name are passed via url.Values through the query parameter. Co-Authored-By: Ari Salem --- cmd/sdk_active/sdk_active.go | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/cmd/sdk_active/sdk_active.go b/cmd/sdk_active/sdk_active.go index f8f5cb9c..fafae077 100644 --- a/cmd/sdk_active/sdk_active.go +++ b/cmd/sdk_active/sdk_active.go @@ -42,7 +42,7 @@ const ( func runGetSdkActive(client resources.Client) func(*cobra.Command, []string) error { return func(cmd *cobra.Command, args []string) error { - rawPath, _ := url.JoinPath( + path, _ := url.JoinPath( viper.GetString(cliflags.BaseURIFlag), "api/v2/projects", viper.GetString(cliflags.ProjectFlag), @@ -51,23 +51,20 @@ func runGetSdkActive(client resources.Client) func(*cobra.Command, []string) err "sdk-active", ) - parsed, _ := url.Parse(rawPath) - q := parsed.Query() + query := url.Values{} if v := viper.GetString(sdkNameFlag); v != "" { - q.Set("sdk_name", v) + query.Set("sdk_name", v) } if v := viper.GetString(sdkWrapperNameFlag); v != "" { - q.Set("sdk_wrapper_name", v) + query.Set("sdk_wrapper_name", v) } - parsed.RawQuery = q.Encode() - path := parsed.String() res, err := client.MakeRequest( viper.GetString(cliflags.AccessTokenFlag), "GET", path, "application/json", - nil, + query, nil, false, )