From f7bdb1e9939f4cb9a96656aa9874689c1dbb694d Mon Sep 17 00:00:00 2001 From: Behroz Saadat Date: Mon, 8 Jun 2026 20:11:26 -0400 Subject: [PATCH 1/2] =?UTF-8?q?Bump=20kernel-go-sdk=20v0.58.0=20=E2=86=92?= =?UTF-8?q?=20v0.65.0;=20migrate=20pools=20+=20proxies=20to=20compile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical migration required by the SDK bump (no new features). The bump is needed for upcoming browser/acquire name+tags support, but v0.65.0 also ships unrelated breaking changes that the cmd packages must absorb to compile: - All list endpoints are now paginated. Update the List interface signatures and call sites for browser pools, proxies, credential providers, and extensions to take *ListParams and read OffsetPagination.Items. Because the pre-bump List returned the full unpaginated slice, these four commands gain --limit/--offset flags so they can page through results (matching the existing browsers/profiles/api-keys list convention) instead of silently truncating to the first page. - Browser pool update Size is now param.Opt[int64]. - Proxy config unions were renamed (ProxyNewParamsConfigDatacenterProxyConfig → ProxyNewParamsConfigDatacenter, OfProxyNewsConfig… → OfDatacenter, etc.). - The proxy API dropped `carrier` from both request (mobile create) and response configs, so the CLI no longer accepts --carrier or displays Carrier (mobile get/list now surface City/State instead). Mobile create also no longer supports zip/asn; passing them warns and they are ignored. The proxy command docs in README.md are trimmed to match. Tests updated to the new fake List signatures and config shapes, plus limit/offset forwarding tests for the proxy, extension, and credential-provider list commands. Co-Authored-By: Claude Opus 4.8 --- README.md | 9 ++--- cmd/browser_pools.go | 36 +++++++++++++---- cmd/credential_providers.go | 33 +++++++++++++--- cmd/credential_providers_test.go | 66 ++++++++++++++++++++++++++++++++ cmd/extensions.go | 33 ++++++++++++---- cmd/extensions_test.go | 28 ++++++++++---- cmd/proxies/check.go | 3 -- cmd/proxies/common_test.go | 10 ++--- cmd/proxies/create.go | 35 +++++++---------- cmd/proxies/create_test.go | 18 ++++----- cmd/proxies/get.go | 3 -- cmd/proxies/get_test.go | 6 +-- cmd/proxies/list.go | 33 ++++++++++++---- cmd/proxies/list_test.go | 33 ++++++++++++---- cmd/proxies/proxies.go | 5 +-- cmd/proxies/types.go | 7 ++-- go.mod | 2 +- go.sum | 4 +- 18 files changed, 262 insertions(+), 102 deletions(-) create mode 100644 cmd/credential_providers_test.go diff --git a/README.md b/README.md index 4962d3d..e5f39bd 100644 --- a/README.md +++ b/README.md @@ -457,10 +457,9 @@ Per-category updates are partial — only categories you name are changed; other - `--country ` - ISO 3166 country code or "EU" (location-based types) - `--city ` - City name (no spaces, e.g. sanfrancisco) (residential, mobile; requires `--country`) - `--state ` - Two-letter state code (residential, mobile) - - `--zip ` - US ZIP code (residential, mobile) - - `--asn ` - Autonomous system number (e.g., AS15169) (residential, mobile) + - `--zip ` - US ZIP code (residential) + - `--asn ` - Autonomous system number (e.g., AS15169) (residential) - `--os ` - Operating system: windows, macos, android (residential) - - `--carrier ` - Mobile carrier (mobile) - `--host ` - Proxy host (custom; required) - `--port ` - Proxy port (custom; required) - `--username ` - Username for proxy authentication (custom) @@ -781,8 +780,8 @@ kernel proxies create --type custom --host proxy.example.com --port 8080 --usern # Create a residential proxy with location and OS kernel proxies create --type residential --country US --city sanfrancisco --state CA --zip 94107 --asn AS15169 --os windows --name "SF Residential" -# Create a mobile proxy with carrier -kernel proxies create --type mobile --country US --carrier verizon --name "US Mobile" +# Create a mobile proxy +kernel proxies create --type mobile --country US --city sanfrancisco --name "US Mobile" # Get proxy details kernel proxies get prx_123 diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index eaacc68..5c22539 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -8,13 +8,14 @@ import ( "github.com/kernel/cli/pkg/util" "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/pagination" "github.com/pterm/pterm" "github.com/spf13/cobra" ) // BrowserPoolsService defines the subset of the Kernel SDK browser pools client that we use. type BrowserPoolsService interface { - List(ctx context.Context, opts ...option.RequestOption) (res *[]kernel.BrowserPool, err error) + List(ctx context.Context, query kernel.BrowserPoolListParams, opts ...option.RequestOption) (res *pagination.OffsetPagination[kernel.BrowserPool], err error) New(ctx context.Context, body kernel.BrowserPoolNewParams, opts ...option.RequestOption) (res *kernel.BrowserPool, err error) Get(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.BrowserPool, err error) Update(ctx context.Context, id string, body kernel.BrowserPoolUpdateParams, opts ...option.RequestOption) (res *kernel.BrowserPool, err error) @@ -29,6 +30,8 @@ type BrowserPoolsCmd struct { } type BrowserPoolsListInput struct { + Limit int + Offset int Output string } @@ -37,20 +40,33 @@ func (c BrowserPoolsCmd) List(ctx context.Context, in BrowserPoolsListInput) err return err } - pools, err := c.client.List(ctx) + params := kernel.BrowserPoolListParams{} + if in.Limit > 0 { + params.Limit = kernel.Int(int64(in.Limit)) + } + if in.Offset > 0 { + params.Offset = kernel.Int(int64(in.Offset)) + } + + page, err := c.client.List(ctx, params) if err != nil { return util.CleanedUpSdkError{Err: err} } + var pools []kernel.BrowserPool + if page != nil { + pools = page.Items + } + if in.Output == "json" { - if pools == nil || len(*pools) == 0 { + if len(pools) == 0 { fmt.Println("[]") return nil } - return util.PrintPrettyJSONSlice(*pools) + return util.PrintPrettyJSONSlice(pools) } - if pools == nil || len(*pools) == 0 { + if len(pools) == 0 { pterm.Info.Println("No browser pools found") return nil } @@ -59,7 +75,7 @@ func (c BrowserPoolsCmd) List(ctx context.Context, in BrowserPoolsListInput) err {"ID", "Name", "Available", "Acquired", "Created At", "Size"}, } - for _, p := range *pools { + for _, p := range pools { tableData = append(tableData, []string{ p.ID, util.OrDash(p.Name), @@ -250,7 +266,7 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) params.Name = kernel.String(in.Name) } if in.Size > 0 { - params.Size = in.Size + params.Size = kernel.Int(in.Size) } if in.FillRate > 0 { params.FillRatePerMinute = kernel.Int(in.FillRate) @@ -482,6 +498,8 @@ var browserPoolsFlushCmd = &cobra.Command{ func init() { addJSONOutputFlag(browserPoolsListCmd) + browserPoolsListCmd.Flags().Int("limit", 0, "Maximum number of pools to return") + browserPoolsListCmd.Flags().Int("offset", 0, "Number of pools to skip (for pagination)") addJSONOutputFlag(browserPoolsCreateCmd) browserPoolsCreateCmd.Flags().String("name", "", "Optional unique name for the pool") @@ -542,8 +560,10 @@ func init() { func runBrowserPoolsList(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) out, _ := cmd.Flags().GetString("output") + limit, _ := cmd.Flags().GetInt("limit") + offset, _ := cmd.Flags().GetInt("offset") c := BrowserPoolsCmd{client: &client.BrowserPools} - return c.List(cmd.Context(), BrowserPoolsListInput{Output: out}) + return c.List(cmd.Context(), BrowserPoolsListInput{Limit: limit, Offset: offset, Output: out}) } func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { diff --git a/cmd/credential_providers.go b/cmd/credential_providers.go index a0f5808..5eca81e 100644 --- a/cmd/credential_providers.go +++ b/cmd/credential_providers.go @@ -8,6 +8,7 @@ import ( "github.com/kernel/cli/pkg/util" "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/pagination" "github.com/pterm/pterm" "github.com/spf13/cobra" ) @@ -17,7 +18,7 @@ type CredentialProvidersService interface { New(ctx context.Context, body kernel.CredentialProviderNewParams, opts ...option.RequestOption) (res *kernel.CredentialProvider, err error) Get(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.CredentialProvider, err error) Update(ctx context.Context, id string, body kernel.CredentialProviderUpdateParams, opts ...option.RequestOption) (res *kernel.CredentialProvider, err error) - List(ctx context.Context, opts ...option.RequestOption) (res *[]kernel.CredentialProvider, err error) + List(ctx context.Context, query kernel.CredentialProviderListParams, opts ...option.RequestOption) (res *pagination.OffsetPagination[kernel.CredentialProvider], err error) Delete(ctx context.Context, id string, opts ...option.RequestOption) (err error) Test(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.CredentialProviderTestResult, err error) ListItems(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.CredentialProviderListItemsResponse, err error) @@ -29,6 +30,8 @@ type CredentialProvidersCmd struct { } type CredentialProvidersListInput struct { + Limit int + Offset int Output string } @@ -75,26 +78,38 @@ func (c CredentialProvidersCmd) List(ctx context.Context, in CredentialProviders return err } - providers, err := c.providers.List(ctx) + params := kernel.CredentialProviderListParams{} + if in.Limit > 0 { + params.Limit = kernel.Int(int64(in.Limit)) + } + if in.Offset > 0 { + params.Offset = kernel.Int(int64(in.Offset)) + } + page, err := c.providers.List(ctx, params) if err != nil { return util.CleanedUpSdkError{Err: err} } + var providers []kernel.CredentialProvider + if page != nil { + providers = page.Items + } + if in.Output == "json" { - if providers == nil || len(*providers) == 0 { + if len(providers) == 0 { fmt.Println("[]") return nil } - return util.PrintPrettyJSONSlice(*providers) + return util.PrintPrettyJSONSlice(providers) } - if providers == nil || len(*providers) == 0 { + if len(providers) == 0 { pterm.Info.Println("No credential providers found") return nil } tableData := pterm.TableData{{"ID", "Provider Type", "Enabled", "Priority", "Created At"}} - for _, p := range *providers { + for _, p := range providers { tableData = append(tableData, []string{ p.ID, string(p.ProviderType), @@ -425,6 +440,8 @@ func init() { // List flags addJSONOutputFlag(credentialProvidersListCmd) + credentialProvidersListCmd.Flags().Int("limit", 0, "Maximum number of credential providers to return") + credentialProvidersListCmd.Flags().Int("offset", 0, "Number of credential providers to skip (for pagination)") // Get flags addJSONOutputFlag(credentialProvidersGetCmd) @@ -460,10 +477,14 @@ func init() { func runCredentialProvidersList(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) output, _ := cmd.Flags().GetString("output") + limit, _ := cmd.Flags().GetInt("limit") + offset, _ := cmd.Flags().GetInt("offset") svc := client.CredentialProviders c := CredentialProvidersCmd{providers: &svc} return c.List(cmd.Context(), CredentialProvidersListInput{ + Limit: limit, + Offset: offset, Output: output, }) } diff --git a/cmd/credential_providers_test.go b/cmd/credential_providers_test.go new file mode 100644 index 0000000..abf3804 --- /dev/null +++ b/cmd/credential_providers_test.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/pagination" + "github.com/stretchr/testify/assert" +) + +// FakeCredentialProvidersService is a configurable fake implementing CredentialProvidersService. +type FakeCredentialProvidersService struct { + ListFunc func(ctx context.Context, query kernel.CredentialProviderListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.CredentialProvider], error) +} + +func (f *FakeCredentialProvidersService) New(ctx context.Context, body kernel.CredentialProviderNewParams, opts ...option.RequestOption) (*kernel.CredentialProvider, error) { + return &kernel.CredentialProvider{}, nil +} + +func (f *FakeCredentialProvidersService) Get(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.CredentialProvider, error) { + return &kernel.CredentialProvider{}, nil +} + +func (f *FakeCredentialProvidersService) Update(ctx context.Context, id string, body kernel.CredentialProviderUpdateParams, opts ...option.RequestOption) (*kernel.CredentialProvider, error) { + return &kernel.CredentialProvider{}, nil +} + +func (f *FakeCredentialProvidersService) List(ctx context.Context, query kernel.CredentialProviderListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.CredentialProvider], error) { + if f.ListFunc != nil { + return f.ListFunc(ctx, query, opts...) + } + return &pagination.OffsetPagination[kernel.CredentialProvider]{Items: []kernel.CredentialProvider{}}, nil +} + +func (f *FakeCredentialProvidersService) Delete(ctx context.Context, id string, opts ...option.RequestOption) error { + return nil +} + +func (f *FakeCredentialProvidersService) Test(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.CredentialProviderTestResult, error) { + return &kernel.CredentialProviderTestResult{}, nil +} + +func (f *FakeCredentialProvidersService) ListItems(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.CredentialProviderListItemsResponse, error) { + return &kernel.CredentialProviderListItemsResponse{}, nil +} + +func TestCredentialProvidersList_ForwardsLimitOffset(t *testing.T) { + setupStdoutCapture(t) + + var captured kernel.CredentialProviderListParams + fake := &FakeCredentialProvidersService{ + ListFunc: func(ctx context.Context, query kernel.CredentialProviderListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.CredentialProvider], error) { + captured = query + return &pagination.OffsetPagination[kernel.CredentialProvider]{Items: []kernel.CredentialProvider{}}, nil + }, + } + + c := CredentialProvidersCmd{providers: fake} + err := c.List(context.Background(), CredentialProvidersListInput{Limit: 5, Offset: 10}) + + assert.NoError(t, err) + assert.Equal(t, int64(5), captured.Limit.Value) + assert.Equal(t, int64(10), captured.Offset.Value) +} diff --git a/cmd/extensions.go b/cmd/extensions.go index 7138e0c..f58b22f 100644 --- a/cmd/extensions.go +++ b/cmd/extensions.go @@ -14,6 +14,7 @@ import ( "github.com/kernel/cli/pkg/util" "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/pagination" "github.com/pterm/pterm" "github.com/spf13/cobra" ) @@ -43,7 +44,7 @@ var defaultExtensionExclusions = util.ZipOptions{ // ExtensionsService defines the subset of the Kernel SDK extension client that we use. type ExtensionsService interface { - List(ctx context.Context, opts ...option.RequestOption) (res *[]kernel.ExtensionListResponse, err error) + List(ctx context.Context, query kernel.ExtensionListParams, opts ...option.RequestOption) (res *pagination.OffsetPagination[kernel.ExtensionListResponse], err error) Delete(ctx context.Context, idOrName string, opts ...option.RequestOption) (err error) Download(ctx context.Context, idOrName string, opts ...option.RequestOption) (res *http.Response, err error) DownloadFromChromeStore(ctx context.Context, query kernel.ExtensionDownloadFromChromeStoreParams, opts ...option.RequestOption) (res *http.Response, err error) @@ -51,6 +52,8 @@ type ExtensionsService interface { } type ExtensionsListInput struct { + Limit int + Offset int Output string } @@ -89,25 +92,37 @@ func (e ExtensionsCmd) List(ctx context.Context, in ExtensionsListInput) error { if in.Output != "json" { pterm.Info.Println("Fetching extensions...") } - items, err := e.extensions.List(ctx) + params := kernel.ExtensionListParams{} + if in.Limit > 0 { + params.Limit = kernel.Int(int64(in.Limit)) + } + if in.Offset > 0 { + params.Offset = kernel.Int(int64(in.Offset)) + } + page, err := e.extensions.List(ctx, params) if err != nil { return util.CleanedUpSdkError{Err: err} } + var items []kernel.ExtensionListResponse + if page != nil { + items = page.Items + } + if in.Output == "json" { - if items == nil || len(*items) == 0 { + if len(items) == 0 { fmt.Println("[]") return nil } - return util.PrintPrettyJSONSlice(*items) + return util.PrintPrettyJSONSlice(items) } - if items == nil || len(*items) == 0 { + if len(items) == 0 { pterm.Info.Println("No extensions found") return nil } rows := pterm.TableData{{"Extension ID", "Name", "Created At", "Size (bytes)", "Last Used At"}} - for _, it := range *items { + for _, it := range items { name := it.Name if name == "" { name = "-" @@ -402,9 +417,11 @@ var extensionsListCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) output, _ := cmd.Flags().GetString("output") + limit, _ := cmd.Flags().GetInt("limit") + offset, _ := cmd.Flags().GetInt("offset") svc := client.Extensions e := ExtensionsCmd{extensions: &svc} - return e.List(cmd.Context(), ExtensionsListInput{Output: output}) + return e.List(cmd.Context(), ExtensionsListInput{Limit: limit, Offset: offset, Output: output}) }, } @@ -519,6 +536,8 @@ func init() { extensionsCmd.AddCommand(extensionsBuildWebBotAuthCmd) addJSONOutputFlag(extensionsListCmd) + extensionsListCmd.Flags().Int("limit", 0, "Maximum number of extensions to return") + extensionsListCmd.Flags().Int("offset", 0, "Number of extensions to skip (for pagination)") extensionsDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") extensionsDownloadCmd.Flags().String("to", "", "Output zip file path") extensionsDownloadWebStoreCmd.Flags().String("to", "", "Output zip file path for the downloaded archive") diff --git a/cmd/extensions_test.go b/cmd/extensions_test.go index 8be3c44..95a6bd1 100644 --- a/cmd/extensions_test.go +++ b/cmd/extensions_test.go @@ -14,24 +14,24 @@ import ( "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/pagination" "github.com/stretchr/testify/assert" ) // FakeExtensionsService implements ExtensionsService type FakeExtensionsService struct { - ListFunc func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.ExtensionListResponse, error) + ListFunc func(ctx context.Context, query kernel.ExtensionListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.ExtensionListResponse], error) DeleteFunc func(ctx context.Context, idOrName string, opts ...option.RequestOption) error DownloadFunc func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*http.Response, error) DownloadFromChromeStoreFn func(ctx context.Context, query kernel.ExtensionDownloadFromChromeStoreParams, opts ...option.RequestOption) (*http.Response, error) UploadFunc func(ctx context.Context, body kernel.ExtensionUploadParams, opts ...option.RequestOption) (*kernel.ExtensionUploadResponse, error) } -func (f *FakeExtensionsService) List(ctx context.Context, opts ...option.RequestOption) (*[]kernel.ExtensionListResponse, error) { +func (f *FakeExtensionsService) List(ctx context.Context, query kernel.ExtensionListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.ExtensionListResponse], error) { if f.ListFunc != nil { - return f.ListFunc(ctx, opts...) + return f.ListFunc(ctx, query, opts...) } - empty := []kernel.ExtensionListResponse{} - return &empty, nil + return &pagination.OffsetPagination[kernel.ExtensionListResponse]{Items: []kernel.ExtensionListResponse{}}, nil } func (f *FakeExtensionsService) Delete(ctx context.Context, idOrName string, opts ...option.RequestOption) error { if f.DeleteFunc != nil { @@ -70,8 +70,8 @@ func TestExtensionsList_WithRows(t *testing.T) { buf := capturePtermOutput(t) created := time.Unix(0, 0) rows := []kernel.ExtensionListResponse{{ID: "e1", Name: "alpha", CreatedAt: created, SizeBytes: 10}, {ID: "e2", Name: "", CreatedAt: created, SizeBytes: 20}} - fake := &FakeExtensionsService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.ExtensionListResponse, error) { - return &rows, nil + fake := &FakeExtensionsService{ListFunc: func(ctx context.Context, query kernel.ExtensionListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.ExtensionListResponse], error) { + return &pagination.OffsetPagination[kernel.ExtensionListResponse]{Items: rows}, nil }} e := ExtensionsCmd{extensions: fake} _ = e.List(context.Background(), ExtensionsListInput{}) @@ -81,6 +81,20 @@ func TestExtensionsList_WithRows(t *testing.T) { assert.Contains(t, out, "e2") } +func TestExtensionsList_ForwardsLimitOffset(t *testing.T) { + _ = capturePtermOutput(t) + var captured kernel.ExtensionListParams + fake := &FakeExtensionsService{ListFunc: func(ctx context.Context, query kernel.ExtensionListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.ExtensionListResponse], error) { + captured = query + return &pagination.OffsetPagination[kernel.ExtensionListResponse]{Items: []kernel.ExtensionListResponse{}}, nil + }} + e := ExtensionsCmd{extensions: fake} + err := e.List(context.Background(), ExtensionsListInput{Limit: 7, Offset: 3}) + assert.NoError(t, err) + assert.Equal(t, int64(7), captured.Limit.Value) + assert.Equal(t, int64(3), captured.Offset.Value) +} + func TestExtensionsDelete_SkipConfirm(t *testing.T) { buf := capturePtermOutput(t) fake := &FakeExtensionsService{} diff --git a/cmd/proxies/check.go b/cmd/proxies/check.go index 2fb2650..fcc81a5 100644 --- a/cmd/proxies/check.go +++ b/cmd/proxies/check.go @@ -128,9 +128,6 @@ func getProxyCheckConfigRows(proxy *kernel.ProxyCheckResponse) [][]string { if config.Asn != "" { rows = append(rows, []string{"ASN", config.Asn}) } - if config.Carrier != "" { - rows = append(rows, []string{"Carrier", config.Carrier}) - } case kernel.ProxyCheckResponseTypeCustom: if config.Host != "" { rows = append(rows, []string{"Host", config.Host}) diff --git a/cmd/proxies/common_test.go b/cmd/proxies/common_test.go index df49b76..5d1f4c3 100644 --- a/cmd/proxies/common_test.go +++ b/cmd/proxies/common_test.go @@ -8,6 +8,7 @@ import ( "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/pagination" "github.com/pterm/pterm" ) @@ -37,19 +38,18 @@ func captureOutput(t *testing.T) *bytes.Buffer { // FakeProxyService implements ProxyService for testing type FakeProxyService struct { - ListFunc func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.ProxyListResponse, error) + ListFunc func(ctx context.Context, query kernel.ProxyListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.ProxyListResponse], error) GetFunc func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ProxyGetResponse, error) NewFunc func(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (*kernel.ProxyNewResponse, error) DeleteFunc func(ctx context.Context, id string, opts ...option.RequestOption) error CheckFunc func(ctx context.Context, id string, body kernel.ProxyCheckParams, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error) } -func (f *FakeProxyService) List(ctx context.Context, opts ...option.RequestOption) (*[]kernel.ProxyListResponse, error) { +func (f *FakeProxyService) List(ctx context.Context, query kernel.ProxyListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.ProxyListResponse], error) { if f.ListFunc != nil { - return f.ListFunc(ctx, opts...) + return f.ListFunc(ctx, query, opts...) } - empty := []kernel.ProxyListResponse{} - return &empty, nil + return &pagination.OffsetPagination[kernel.ProxyListResponse]{Items: []kernel.ProxyListResponse{}}, nil } func (f *FakeProxyService) Get(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ProxyGetResponse, error) { diff --git a/cmd/proxies/create.go b/cmd/proxies/create.go index 44de57f..6611387 100644 --- a/cmd/proxies/create.go +++ b/cmd/proxies/create.go @@ -48,25 +48,25 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { // Build config based on type switch proxyType { case kernel.ProxyNewParamsTypeDatacenter: - config := kernel.ProxyNewParamsConfigDatacenterProxyConfig{} + config := kernel.ProxyNewParamsConfigDatacenter{} if in.Country != "" { config.Country = kernel.Opt(in.Country) } params.Config = kernel.ProxyNewParamsConfigUnion{ - OfProxyNewsConfigDatacenterProxyConfig: &config, + OfDatacenter: &config, } case kernel.ProxyNewParamsTypeIsp: - config := kernel.ProxyNewParamsConfigIspProxyConfig{} + config := kernel.ProxyNewParamsConfigIsp{} if in.Country != "" { config.Country = kernel.Opt(in.Country) } params.Config = kernel.ProxyNewParamsConfigUnion{ - OfProxyNewsConfigIspProxyConfig: &config, + OfIsp: &config, } case kernel.ProxyNewParamsTypeResidential: - config := kernel.ProxyNewParamsConfigResidentialProxyConfig{} + config := kernel.ProxyNewParamsConfigResidential{} // Validate that if city is provided, country must also be provided if in.City != "" && in.Country == "" { @@ -98,16 +98,19 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { } } params.Config = kernel.ProxyNewParamsConfigUnion{ - OfProxyNewsConfigResidentialProxyConfig: &config, + OfResidential: &config, } case kernel.ProxyNewParamsTypeMobile: - config := kernel.ProxyNewParamsConfigMobileProxyConfig{} + config := kernel.ProxyNewParamsConfigMobile{} // Validate that if city is provided, country must also be provided if in.City != "" && in.Country == "" { return fmt.Errorf("--country is required when --city is specified") } + if in.Zip != "" || in.ASN != "" { + pterm.Warning.Println("--zip and --asn are not supported for mobile proxies and will be ignored") + } if in.Country != "" { config.Country = kernel.Opt(in.Country) @@ -118,18 +121,8 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { if in.State != "" { config.State = kernel.Opt(in.State) } - if in.Zip != "" { - config.Zip = kernel.Opt(in.Zip) - } - if in.ASN != "" { - config.Asn = kernel.Opt(in.ASN) - } - if in.Carrier != "" { - // The API will validate the carrier value - config.Carrier = in.Carrier - } params.Config = kernel.ProxyNewParamsConfigUnion{ - OfProxyNewsConfigMobileProxyConfig: &config, + OfMobile: &config, } case kernel.ProxyNewParamsTypeCustom: @@ -140,7 +133,7 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { return fmt.Errorf("--port is required for custom proxy type") } - config := kernel.ProxyNewParamsConfigCreateCustomProxyConfig{ + config := kernel.ProxyNewParamsConfigCustom{ Host: in.Host, Port: int64(in.Port), } @@ -151,7 +144,7 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { config.Password = kernel.Opt(in.Password) } params.Config = kernel.ProxyNewParamsConfigUnion{ - OfProxyNewsConfigCreateCustomProxyConfig: &config, + OfCustom: &config, } } @@ -219,7 +212,6 @@ func runProxiesCreate(cmd *cobra.Command, args []string) error { zip, _ := cmd.Flags().GetString("zip") asn, _ := cmd.Flags().GetString("asn") os, _ := cmd.Flags().GetString("os") - carrier, _ := cmd.Flags().GetString("carrier") host, _ := cmd.Flags().GetString("host") port, _ := cmd.Flags().GetInt("port") username, _ := cmd.Flags().GetString("username") @@ -241,7 +233,6 @@ func runProxiesCreate(cmd *cobra.Command, args []string) error { Zip: zip, ASN: asn, OS: os, - Carrier: carrier, Host: host, Port: port, Username: username, diff --git a/cmd/proxies/create_test.go b/cmd/proxies/create_test.go index bdb3b30..e985015 100644 --- a/cmd/proxies/create_test.go +++ b/cmd/proxies/create_test.go @@ -21,7 +21,7 @@ func TestProxyCreate_Datacenter_Success(t *testing.T) { assert.Equal(t, []string{"localhost", "internal.service.local"}, body.BypassHosts) // Check config - dcConfig := body.Config.OfProxyNewsConfigDatacenterProxyConfig + dcConfig := body.Config.OfDatacenter assert.NotNil(t, dcConfig) assert.Equal(t, "US", dcConfig.Country.Value) @@ -64,7 +64,7 @@ func TestProxyCreate_Datacenter_WithoutCountry(t *testing.T) { assert.Equal(t, "My DC Proxy", body.Name.Value) // Check config - country should not be set (it should be zero/nil) - dcConfig := body.Config.OfProxyNewsConfigDatacenterProxyConfig + dcConfig := body.Config.OfDatacenter assert.NotNil(t, dcConfig) return &kernel.ProxyNewResponse{ @@ -94,7 +94,7 @@ func TestProxyCreate_Residential_Success(t *testing.T) { fake := &FakeProxyService{ NewFunc: func(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (*kernel.ProxyNewResponse, error) { // Verify residential config - resConfig := body.Config.OfProxyNewsConfigResidentialProxyConfig + resConfig := body.Config.OfResidential assert.NotNil(t, resConfig) assert.Equal(t, "US", resConfig.Country.Value) assert.Equal(t, "sanfrancisco", resConfig.City.Value) @@ -161,10 +161,10 @@ func TestProxyCreate_Mobile_Success(t *testing.T) { fake := &FakeProxyService{ NewFunc: func(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (*kernel.ProxyNewResponse, error) { // Verify mobile config - mobConfig := body.Config.OfProxyNewsConfigMobileProxyConfig + mobConfig := body.Config.OfMobile assert.NotNil(t, mobConfig) assert.Equal(t, "US", mobConfig.Country.Value) - assert.Equal(t, "verizon", mobConfig.Carrier) + assert.Equal(t, "sanfrancisco", mobConfig.City.Value) return &kernel.ProxyNewResponse{ ID: "mobile-new", @@ -179,7 +179,7 @@ func TestProxyCreate_Mobile_Success(t *testing.T) { Name: "Mobile Proxy", Type: "mobile", Country: "US", - Carrier: "verizon", + City: "sanfrancisco", }) assert.NoError(t, err) @@ -194,7 +194,7 @@ func TestProxyCreate_Custom_Success(t *testing.T) { fake := &FakeProxyService{ NewFunc: func(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (*kernel.ProxyNewResponse, error) { // Verify custom config - customConfig := body.Config.OfProxyNewsConfigCreateCustomProxyConfig + customConfig := body.Config.OfCustom assert.NotNil(t, customConfig) assert.Equal(t, "proxy.example.com", customConfig.Host) assert.Equal(t, int64(8080), customConfig.Port) @@ -359,7 +359,7 @@ func TestProxyCreate_ISP_Success(t *testing.T) { fake := &FakeProxyService{ NewFunc: func(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (*kernel.ProxyNewResponse, error) { // Verify ISP config - ispConfig := body.Config.OfProxyNewsConfigIspProxyConfig + ispConfig := body.Config.OfIsp assert.NotNil(t, ispConfig) assert.Equal(t, "EU", ispConfig.Country.Value) @@ -390,7 +390,7 @@ func TestProxyCreate_ISP_WithoutCountry(t *testing.T) { fake := &FakeProxyService{ NewFunc: func(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (*kernel.ProxyNewResponse, error) { // Verify ISP config - ispConfig := body.Config.OfProxyNewsConfigIspProxyConfig + ispConfig := body.Config.OfIsp assert.NotNil(t, ispConfig) return &kernel.ProxyNewResponse{ diff --git a/cmd/proxies/get.go b/cmd/proxies/get.go index 83c1f65..c0fc5c2 100644 --- a/cmd/proxies/get.go +++ b/cmd/proxies/get.go @@ -111,9 +111,6 @@ func getProxyConfigRows(proxy *kernel.ProxyGetResponse) [][]string { if config.Asn != "" { rows = append(rows, []string{"ASN", config.Asn}) } - if config.Carrier != "" { - rows = append(rows, []string{"Carrier", config.Carrier}) - } case kernel.ProxyGetResponseTypeCustom: if config.Host != "" { rows = append(rows, []string{"Host", config.Host}) diff --git a/cmd/proxies/get_test.go b/cmd/proxies/get_test.go index 92c9cb9..55af301 100644 --- a/cmd/proxies/get_test.go +++ b/cmd/proxies/get_test.go @@ -101,7 +101,7 @@ func TestProxyGet_Mobile(t *testing.T) { Type: kernel.ProxyGetResponseTypeMobile, Config: kernel.ProxyGetResponseConfigUnion{ Country: "US", - Carrier: "verizon", + City: "sanfrancisco", }, }, nil }, @@ -113,8 +113,8 @@ func TestProxyGet_Mobile(t *testing.T) { assert.NoError(t, err) output := buf.String() - assert.Contains(t, output, "Carrier") - assert.Contains(t, output, "verizon") + assert.Contains(t, output, "City") + assert.Contains(t, output, "sanfrancisco") } func TestProxyGet_Custom(t *testing.T) { diff --git a/cmd/proxies/list.go b/cmd/proxies/list.go index 762503e..a7ff4bc 100644 --- a/cmd/proxies/list.go +++ b/cmd/proxies/list.go @@ -21,20 +21,32 @@ func (p ProxyCmd) List(ctx context.Context, in ProxyListInput) error { pterm.Info.Println("Fetching proxy configurations...") } - items, err := p.proxies.List(ctx) + params := kernel.ProxyListParams{} + if in.Limit > 0 { + params.Limit = kernel.Int(int64(in.Limit)) + } + if in.Offset > 0 { + params.Offset = kernel.Int(int64(in.Offset)) + } + page, err := p.proxies.List(ctx, params) if err != nil { return util.CleanedUpSdkError{Err: err} } + var items []kernel.ProxyListResponse + if page != nil { + items = page.Items + } + if in.Output == "json" { - if items == nil || len(*items) == 0 { + if len(items) == 0 { fmt.Println("[]") return nil } - return util.PrintPrettyJSONSlice(*items) + return util.PrintPrettyJSONSlice(items) } - if items == nil || len(*items) == 0 { + if len(items) == 0 { pterm.Info.Println("No proxy configurations found") return nil } @@ -44,7 +56,7 @@ func (p ProxyCmd) List(ctx context.Context, in ProxyListInput) error { {"ID", "Name", "Type", "Protocol", "Bypass Hosts", "Config", "Status", "Last Checked"}, } - for _, proxy := range *items { + for _, proxy := range items { name := proxy.Name if name == "" { name = "-" @@ -114,8 +126,11 @@ func formatProxyConfig(proxy *kernel.ProxyListResponse) string { if config.Country != "" { parts = append(parts, fmt.Sprintf("Country: %s", config.Country)) } - if config.Carrier != "" { - parts = append(parts, fmt.Sprintf("Carrier: %s", config.Carrier)) + if config.City != "" { + parts = append(parts, fmt.Sprintf("City: %s", config.City)) + } + if config.State != "" { + parts = append(parts, fmt.Sprintf("State: %s", config.State)) } if len(parts) > 0 { return strings.Join(parts, ", ") @@ -135,7 +150,9 @@ func formatProxyConfig(proxy *kernel.ProxyListResponse) string { func runProxiesList(cmd *cobra.Command, args []string) error { client := util.GetKernelClient(cmd) output, _ := cmd.Flags().GetString("output") + limit, _ := cmd.Flags().GetInt("limit") + offset, _ := cmd.Flags().GetInt("offset") svc := client.Proxies p := ProxyCmd{proxies: &svc} - return p.List(cmd.Context(), ProxyListInput{Output: output}) + return p.List(cmd.Context(), ProxyListInput{Limit: limit, Offset: offset, Output: output}) } diff --git a/cmd/proxies/list_test.go b/cmd/proxies/list_test.go index 455069a..abd27b1 100644 --- a/cmd/proxies/list_test.go +++ b/cmd/proxies/list_test.go @@ -7,15 +7,15 @@ import ( "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/pagination" "github.com/stretchr/testify/assert" ) func TestProxyList_Empty(t *testing.T) { buf := captureOutput(t) fake := &FakeProxyService{ - ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.ProxyListResponse, error) { - empty := []kernel.ProxyListResponse{} - return &empty, nil + ListFunc: func(ctx context.Context, query kernel.ProxyListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.ProxyListResponse], error) { + return &pagination.OffsetPagination[kernel.ProxyListResponse]{Items: []kernel.ProxyListResponse{}}, nil }, } @@ -40,7 +40,7 @@ func TestProxyList_WithProxies(t *testing.T) { BypassHosts: []string{"abc"}, Config: kernel.ProxyListResponseConfigUnion{ Country: "US", - Carrier: "verizon", + City: "sanfrancisco", }, }, { @@ -54,8 +54,8 @@ func TestProxyList_WithProxies(t *testing.T) { } fake := &FakeProxyService{ - ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.ProxyListResponse, error) { - return &proxies, nil + ListFunc: func(ctx context.Context, query kernel.ProxyListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.ProxyListResponse], error) { + return &pagination.OffsetPagination[kernel.ProxyListResponse]{Items: proxies}, nil }, } @@ -97,7 +97,7 @@ func TestProxyList_Error(t *testing.T) { _ = captureOutput(t) fake := &FakeProxyService{ - ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.ProxyListResponse, error) { + ListFunc: func(ctx context.Context, query kernel.ProxyListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.ProxyListResponse], error) { return nil, errors.New("API error") }, } @@ -108,3 +108,22 @@ func TestProxyList_Error(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "API error") } + +func TestProxyList_ForwardsLimitOffset(t *testing.T) { + _ = captureOutput(t) + + var captured kernel.ProxyListParams + fake := &FakeProxyService{ + ListFunc: func(ctx context.Context, query kernel.ProxyListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.ProxyListResponse], error) { + captured = query + return &pagination.OffsetPagination[kernel.ProxyListResponse]{Items: []kernel.ProxyListResponse{}}, nil + }, + } + + p := ProxyCmd{proxies: fake} + err := p.List(context.Background(), ProxyListInput{Limit: 5, Offset: 10}) + + assert.NoError(t, err) + assert.Equal(t, int64(5), captured.Limit.Value) + assert.Equal(t, int64(10), captured.Offset.Value) +} diff --git a/cmd/proxies/proxies.go b/cmd/proxies/proxies.go index ee93faf..d6841a4 100644 --- a/cmd/proxies/proxies.go +++ b/cmd/proxies/proxies.go @@ -81,6 +81,8 @@ func init() { // Add output flags addJSONOutputFlag(proxiesListCmd) + proxiesListCmd.Flags().Int("limit", 0, "Maximum number of proxies to return") + proxiesListCmd.Flags().Int("offset", 0, "Number of proxies to skip (for pagination)") addJSONOutputFlag(proxiesGetCmd) addJSONOutputFlag(proxiesCreateCmd) @@ -100,9 +102,6 @@ func init() { // OS flag (residential) proxiesCreateCmd.Flags().String("os", "", "Operating system (windows|macos|android)") - // Carrier flag (mobile) - proxiesCreateCmd.Flags().String("carrier", "", "Mobile carrier (see help for full list)") - // Custom proxy flags proxiesCreateCmd.Flags().String("host", "", "Proxy host address or IP") proxiesCreateCmd.Flags().Int("port", 0, "Proxy port") diff --git a/cmd/proxies/types.go b/cmd/proxies/types.go index bf55d9f..366f149 100644 --- a/cmd/proxies/types.go +++ b/cmd/proxies/types.go @@ -5,11 +5,12 @@ import ( "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/pagination" ) // ProxyService defines the subset of the Kernel SDK proxy client that we use. type ProxyService interface { - List(ctx context.Context, opts ...option.RequestOption) (res *[]kernel.ProxyListResponse, err error) + List(ctx context.Context, query kernel.ProxyListParams, opts ...option.RequestOption) (res *pagination.OffsetPagination[kernel.ProxyListResponse], err error) Get(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.ProxyGetResponse, err error) New(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (res *kernel.ProxyNewResponse, err error) Delete(ctx context.Context, id string, opts ...option.RequestOption) (err error) @@ -23,6 +24,8 @@ type ProxyCmd struct { // Input types for proxy operations type ProxyListInput struct { + Limit int + Offset int Output string } @@ -45,8 +48,6 @@ type ProxyCreateInput struct { Zip string ASN string OS string - // Mobile specific - Carrier string // Custom proxy config Host string Port int diff --git a/go.mod b/go.mod index 5ed2523..838c1b7 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.58.0 + github.com/kernel/kernel-go-sdk v0.65.0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 96b230c..5591e94 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.58.0 h1:FcvqZXgK5D3IbHJarvRJVPJpKE3+Pd7i4z4kBgElpIk= -github.com/kernel/kernel-go-sdk v0.58.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.65.0 h1:/ASWrUxqKjpvDd9LOQ++H6Bp4Cp/lyu2SUSO3K8VzRE= +github.com/kernel/kernel-go-sdk v0.65.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From 188bbf9e49109902f06432e6c34253d51d04eb91 Mon Sep 17 00:00:00 2001 From: Behroz Saadat Date: Mon, 8 Jun 2026 20:21:17 -0400 Subject: [PATCH 2/2] browsers + acquire: support session name and tags Surface the new Kernel API name/tags fields in the CLI, modeled on the profiles command and the hypeman CLI tag convention. browsers: - create: --name and repeatable --tag KEY=VALUE (parsed with a hypeman-style parser that warns on malformed pairs); forwarded to BrowserNewParams. - get/view/update/delete now accept (SDK resolves either); name and tags are shown in the get detail table, the JSON output, and a new Name column in list. - list: --tag KEY=VALUE filter (deepObject, ANDed) and --query now also matches name. - create --pool-id/--pool-name: --name/--tag now apply to the acquired lease. - Note: name/tags are creation-time only. get/list and JSON responses echo them, but update cannot change them (the SDK BrowserUpdateParams has no name/tags), so update offers no such flags. browser-pools acquire: --name and --tag apply per-lease (cleared on release), forwarded to BrowserPoolAcquireParams and shown in the acquired-session table. The per-lease acquire params (name/tags/timeout) are built by a single shared buildAcquireParams helper used by both `browser-pools acquire` and the `browsers create --pool-*` path, so the two cannot silently diverge. Adds parseKeyValueSpecs/tagsFromFlag/formatTags helpers and tests for create, list, get (incl. JSON output), acquire, pool-list limit/offset forwarding, the buildAcquireParams forwarding contract, the parser, and the malformed-tag warning path. README is updated for the new browser and acquire flags. Co-Authored-By: Claude Opus 4.8 --- README.md | 16 +++-- cmd/browser_pools.go | 48 +++++++++++-- cmd/browser_pools_test.go | 130 +++++++++++++++++++++++++++++++++++ cmd/browsers.go | 138 +++++++++++++++++++++++++++++++------- cmd/browsers_test.go | 135 +++++++++++++++++++++++++++++++++++++ 5 files changed, 430 insertions(+), 37 deletions(-) create mode 100644 cmd/browser_pools_test.go diff --git a/README.md b/README.md index e5f39bd..c63a264 100644 --- a/README.md +++ b/README.md @@ -205,25 +205,29 @@ Commands with JSON output support: ### Browser Management - `kernel browsers list` - List running browsers + - `--query ` - Search by name, session ID, profile ID, proxy ID, or pool name + - `--tag ` - Filter by tag, repeatable; a session must match every pair - `--output json`, `-o json` - Output raw JSON array - `kernel browsers create` - Create a new browser session - `-s, --stealth` - Launch browser in stealth mode to avoid detection - `-H, --headless` - Launch browser without GUI access - `--kiosk` - Launch browser in kiosk mode - `--start-url ` - Initial page to open on launch - - `--pool-id ` - Acquire a browser from the specified pool (mutually exclusive with --pool-name; ignores other session flags) + - `--name ` - Optional unique name for the session (set at creation; used to find it later by name) + - `--tag ` - Set a tag on the session, repeatable; up to 50 pairs + - `--pool-id ` - Acquire a browser from the specified pool (mutually exclusive with --pool-name; ignores other session flags). `--name`/`--tag` still apply to the acquired session. - `--pool-name ` - Acquire a browser from the pool name (mutually exclusive with --pool-id; ignores other session flags) - `--telemetry=all` - Enable telemetry for all categories - `--telemetry=off` - Disable telemetry - `--telemetry=` - Per-category config, e.g. `--telemetry=network=on,page=off` - `--output json`, `-o json` - Output raw JSON object - _Note: When a pool is specified, omit other session configuration flags—pool settings determine profile, proxy, viewport, etc._ -- `kernel browsers delete ` - Delete a browser -- `kernel browsers view ` - Get live view URL for a browser +- `kernel browsers delete ` - Delete a browser by ID or name +- `kernel browsers view ` - Get live view URL for a browser by ID or name - `--output json`, `-o json` - Output JSON with liveViewUrl -- `kernel browsers get ` - Get detailed browser session info +- `kernel browsers get ` - Get detailed browser session info by ID or name - `--output json`, `-o json` - Output raw JSON object -- `kernel browsers update ` - Update a running browser session +- `kernel browsers update ` - Update a running browser session by ID or name - `--telemetry=all` - Enable telemetry for all categories - `--telemetry=off` - Disable telemetry - `--telemetry=` - Per-category config, e.g. `--telemetry=network=on,page=off` @@ -264,6 +268,8 @@ Commands with JSON output support: - `--force` - Force delete even if browsers are leased - `kernel browser-pools acquire ` - Acquire a browser from the pool - `--timeout ` - Acquire timeout before returning 204 + - `--name ` - Optional name for the acquired session (applies to this lease; cleared on release) + - `--tag ` - Set a tag on the acquired session, repeatable; applies to this lease - `--output json`, `-o json` - Output raw JSON object - `kernel browser-pools release ` - Release a browser back to the pool - `--session-id ` - Browser session ID to release (required) diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index 5c22539..094920a 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -354,18 +354,34 @@ func (c BrowserPoolsCmd) Delete(ctx context.Context, in BrowserPoolsDeleteInput) type BrowserPoolsAcquireInput struct { IDOrName string TimeoutSeconds int64 + Name string + Tags map[string]string Output string } +// buildAcquireParams builds the SDK params for acquiring a browser from a pool. +// Shared by `browser-pools acquire` and the `browsers create --pool-id/--pool-name` +// path so the per-lease name/tags forwarding cannot silently diverge between them. +func buildAcquireParams(name string, tags map[string]string, timeoutSeconds int64) kernel.BrowserPoolAcquireParams { + params := kernel.BrowserPoolAcquireParams{} + if timeoutSeconds > 0 { + params.AcquireTimeoutSeconds = kernel.Int(timeoutSeconds) + } + if name != "" { + params.Name = kernel.Opt(name) + } + if len(tags) > 0 { + params.Tags = kernel.Tags(tags) + } + return params +} + func (c BrowserPoolsCmd) Acquire(ctx context.Context, in BrowserPoolsAcquireInput) error { if err := validateJSONOutput(in.Output); err != nil { return err } - params := kernel.BrowserPoolAcquireParams{} - if in.TimeoutSeconds > 0 { - params.AcquireTimeoutSeconds = kernel.Int(in.TimeoutSeconds) - } + params := buildAcquireParams(in.Name, in.Tags, in.TimeoutSeconds) resp, err := c.client.Acquire(ctx, in.IDOrName, params) if err != nil { return util.CleanedUpSdkError{Err: err} @@ -386,12 +402,20 @@ func (c BrowserPoolsCmd) Acquire(ctx context.Context, in BrowserPoolsAcquireInpu tableData := pterm.TableData{ {"Property", "Value"}, {"Session ID", resp.SessionID}, - {"CDP WebSocket URL", resp.CdpWsURL}, - {"Live View URL", resp.BrowserLiveViewURL}, } + if resp.Name != "" { + tableData = append(tableData, []string{"Name", resp.Name}) + } + tableData = append(tableData, + []string{"CDP WebSocket URL", resp.CdpWsURL}, + []string{"Live View URL", resp.BrowserLiveViewURL}, + ) if resp.StartURL != "" { tableData = append(tableData, []string{"Start URL", resp.StartURL}) } + if len(resp.Tags) > 0 { + tableData = append(tableData, []string{"Tags", formatTags(resp.Tags)}) + } PrintTableNoPad(tableData, true) return nil } @@ -541,6 +565,8 @@ func init() { browserPoolsDeleteCmd.Flags().Bool("force", false, "Force delete even if browsers are leased") browserPoolsAcquireCmd.Flags().Int64("timeout", 0, "Acquire timeout in seconds") + browserPoolsAcquireCmd.Flags().String("name", "", "Optional name for the acquired session (applies to this lease; cleared on release)") + browserPoolsAcquireCmd.Flags().StringArray("tag", nil, "Set a tag KEY=VALUE on the acquired session (repeatable; applies to this lease)") addJSONOutputFlag(browserPoolsAcquireCmd) browserPoolsReleaseCmd.Flags().String("session-id", "", "Browser session ID to release") @@ -676,9 +702,17 @@ func runBrowserPoolsDelete(cmd *cobra.Command, args []string) error { func runBrowserPoolsAcquire(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) timeout, _ := cmd.Flags().GetInt64("timeout") + name, _ := cmd.Flags().GetString("name") + tags := tagsFromFlag(cmd, "tag") output, _ := cmd.Flags().GetString("output") c := BrowserPoolsCmd{client: &client.BrowserPools} - return c.Acquire(cmd.Context(), BrowserPoolsAcquireInput{IDOrName: args[0], TimeoutSeconds: timeout, Output: output}) + return c.Acquire(cmd.Context(), BrowserPoolsAcquireInput{ + IDOrName: args[0], + TimeoutSeconds: timeout, + Name: name, + Tags: tags, + Output: output, + }) } func runBrowserPoolsRelease(cmd *cobra.Command, args []string) error { diff --git a/cmd/browser_pools_test.go b/cmd/browser_pools_test.go new file mode 100644 index 0000000..9b7ea2a --- /dev/null +++ b/cmd/browser_pools_test.go @@ -0,0 +1,130 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/pagination" + "github.com/stretchr/testify/assert" +) + +// FakeBrowserPoolsService is a configurable fake implementing BrowserPoolsService. +type FakeBrowserPoolsService struct { + AcquireFunc func(ctx context.Context, id string, body kernel.BrowserPoolAcquireParams, opts ...option.RequestOption) (*kernel.BrowserPoolAcquireResponse, error) + ListFunc func(ctx context.Context, query kernel.BrowserPoolListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserPool], error) +} + +func (f *FakeBrowserPoolsService) List(ctx context.Context, query kernel.BrowserPoolListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserPool], error) { + if f.ListFunc != nil { + return f.ListFunc(ctx, query, opts...) + } + return &pagination.OffsetPagination[kernel.BrowserPool]{Items: []kernel.BrowserPool{}}, nil +} + +func (f *FakeBrowserPoolsService) New(ctx context.Context, body kernel.BrowserPoolNewParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) { + return &kernel.BrowserPool{}, nil +} + +func (f *FakeBrowserPoolsService) Get(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.BrowserPool, error) { + return &kernel.BrowserPool{}, nil +} + +func (f *FakeBrowserPoolsService) Update(ctx context.Context, id string, body kernel.BrowserPoolUpdateParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) { + return &kernel.BrowserPool{}, nil +} + +func (f *FakeBrowserPoolsService) Delete(ctx context.Context, id string, body kernel.BrowserPoolDeleteParams, opts ...option.RequestOption) error { + return nil +} + +func (f *FakeBrowserPoolsService) Acquire(ctx context.Context, id string, body kernel.BrowserPoolAcquireParams, opts ...option.RequestOption) (*kernel.BrowserPoolAcquireResponse, error) { + if f.AcquireFunc != nil { + return f.AcquireFunc(ctx, id, body, opts...) + } + return &kernel.BrowserPoolAcquireResponse{}, nil +} + +func (f *FakeBrowserPoolsService) Release(ctx context.Context, id string, body kernel.BrowserPoolReleaseParams, opts ...option.RequestOption) error { + return nil +} + +func (f *FakeBrowserPoolsService) Flush(ctx context.Context, id string, opts ...option.RequestOption) error { + return nil +} + +func TestBrowserPoolsAcquire_WithNameAndTags(t *testing.T) { + setupStdoutCapture(t) + + var capturedID string + var captured kernel.BrowserPoolAcquireParams + fake := &FakeBrowserPoolsService{ + AcquireFunc: func(ctx context.Context, id string, body kernel.BrowserPoolAcquireParams, opts ...option.RequestOption) (*kernel.BrowserPoolAcquireResponse, error) { + capturedID = id + captured = body + return &kernel.BrowserPoolAcquireResponse{ + SessionID: "sess-acq", + CdpWsURL: "ws://cdp-acq", + Name: "lease-name", + Tags: kernel.Tags{"env": "prod"}, + }, nil + }, + } + + c := BrowserPoolsCmd{client: fake} + err := c.Acquire(context.Background(), BrowserPoolsAcquireInput{ + IDOrName: "my-pool", + Name: "lease-name", + Tags: map[string]string{"env": "prod"}, + }) + assert.NoError(t, err) + + // Pool lookup is by id or name; name + tags are forwarded per-lease. + assert.Equal(t, "my-pool", capturedID) + assert.True(t, captured.Name.Valid()) + assert.Equal(t, "lease-name", captured.Name.Value) + assert.Equal(t, "prod", captured.Tags["env"]) + + // And surfaced in the acquired-session table. + out := outBuf.String() + assert.Contains(t, out, "lease-name") + assert.Contains(t, out, "Tags") + assert.Contains(t, out, "env=prod") +} + +func TestBrowserPoolsList_ForwardsLimitOffset(t *testing.T) { + setupStdoutCapture(t) + + var captured kernel.BrowserPoolListParams + fake := &FakeBrowserPoolsService{ + ListFunc: func(ctx context.Context, query kernel.BrowserPoolListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserPool], error) { + captured = query + return &pagination.OffsetPagination[kernel.BrowserPool]{Items: []kernel.BrowserPool{}}, nil + }, + } + + c := BrowserPoolsCmd{client: fake} + err := c.List(context.Background(), BrowserPoolsListInput{Limit: 4, Offset: 8}) + + assert.NoError(t, err) + assert.Equal(t, int64(4), captured.Limit.Value) + assert.Equal(t, int64(8), captured.Offset.Value) +} + +// TestBuildAcquireParams covers the shared name/tags/timeout forwarding used by +// both `browser-pools acquire` and the `browsers create --pool-id` lease path. +func TestBuildAcquireParams(t *testing.T) { + p := buildAcquireParams("lease", map[string]string{"env": "prod"}, 30) + assert.True(t, p.Name.Valid()) + assert.Equal(t, "lease", p.Name.Value) + assert.Equal(t, "prod", p.Tags["env"]) + assert.True(t, p.AcquireTimeoutSeconds.Valid()) + assert.Equal(t, int64(30), p.AcquireTimeoutSeconds.Value) + + // Unset inputs produce an empty params struct (nothing forwarded). + empty := buildAcquireParams("", nil, 0) + assert.False(t, empty.Name.Valid()) + assert.Len(t, empty.Tags, 0) + assert.False(t, empty.AcquireTimeoutSeconds.Valid()) +} diff --git a/cmd/browsers.go b/cmd/browsers.go index b29dedb..9d587db 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strconv" "strings" "time" @@ -31,11 +32,11 @@ import ( // BrowsersService defines the subset of the Kernel SDK browser client that we use. // See https://github.com/kernel/kernel-go-sdk/blob/main/browser.go type BrowsersService interface { - Get(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (res *kernel.BrowserGetResponse, err error) + Get(ctx context.Context, idOrName string, query kernel.BrowserGetParams, opts ...option.RequestOption) (res *kernel.BrowserGetResponse, err error) List(ctx context.Context, query kernel.BrowserListParams, opts ...option.RequestOption) (res *pagination.OffsetPagination[kernel.BrowserListResponse], err error) New(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (res *kernel.BrowserNewResponse, err error) - Update(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (res *kernel.BrowserUpdateResponse, err error) - DeleteByID(ctx context.Context, id string, opts ...option.RequestOption) (err error) + Update(ctx context.Context, idOrName string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (res *kernel.BrowserUpdateResponse, err error) + DeleteByID(ctx context.Context, idOrName string, opts ...option.RequestOption) (err error) HTTPClient(id string, opts ...option.RequestOption) (*http.Client, error) LoadExtensions(ctx context.Context, id string, body kernel.BrowserLoadExtensionsParams, opts ...option.RequestOption) (err error) } @@ -160,6 +161,58 @@ func parseViewport(viewport string) (width, height, refreshRate int64, err error return w, h, refreshRate, nil } +// parseKeyValueSpecs parses repeated KEY=VALUE flag values into a map. It +// returns the parsed pairs along with any specs that were malformed (missing +// "=" or an empty key), mirroring the kernel hypeman CLI convention. +func parseKeyValueSpecs(specs []string) (map[string]string, []string) { + values := make(map[string]string) + var malformed []string + for _, spec := range specs { + parts := strings.SplitN(spec, "=", 2) + if len(parts) != 2 || parts[0] == "" { + malformed = append(malformed, spec) + continue + } + values[parts[0]] = parts[1] + } + return values, malformed +} + +// tagsFromFlag reads a repeated KEY=VALUE flag and parses it into a map, +// warning about any malformed entries. Returns nil when no valid tags are set. +func tagsFromFlag(cmd *cobra.Command, flagName string) map[string]string { + specs, _ := cmd.Flags().GetStringArray(flagName) + if len(specs) == 0 { + return nil + } + tags, malformed := parseKeyValueSpecs(specs) + for _, invalid := range malformed { + pterm.Warning.Printf("Ignoring malformed tag: %s\n", invalid) + } + if len(tags) == 0 { + return nil + } + return tags +} + +// formatTags renders tags as a deterministic "k=v, k2=v2" string with keys +// sorted, for display in detail tables. +func formatTags(tags kernel.Tags) string { + if len(tags) == 0 { + return "" + } + keys := make([]string, 0, len(tags)) + for k := range tags { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(tags)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s=%s", k, tags[k])) + } + return strings.Join(parts, ", ") +} + // Inputs for each command type BrowsersCreateInput struct { TimeoutSeconds int @@ -176,6 +229,8 @@ type BrowsersCreateInput struct { Extensions []string Viewport string Telemetry string + Name string + Tags map[string]string Output string } @@ -227,6 +282,7 @@ type BrowsersListInput struct { Limit int Offset int Query string + Tags map[string]string } func (b BrowsersCmd) List(ctx context.Context, in BrowsersListInput) error { @@ -259,6 +315,9 @@ func (b BrowsersCmd) List(ctx context.Context, in BrowsersListInput) error { if in.Query != "" { params.Query = kernel.Opt(in.Query) } + if len(in.Tags) > 0 { + params.Tags = in.Tags + } page, err := b.browsers.List(ctx, params) if err != nil { @@ -280,7 +339,7 @@ func (b BrowsersCmd) List(ctx context.Context, in BrowsersListInput) error { } // Prepare table data - headers := []string{"Browser ID", "Created At", "Profile", "Pool", "CDP WS URL", "Live View URL"} + headers := []string{"Browser ID", "Name", "Created At", "Profile", "Pool", "CDP WS URL", "Live View URL"} showDeletedAt := in.IncludeDeleted || in.Status == "deleted" || in.Status == "all" if showDeletedAt { headers = append(headers, "Deleted At") @@ -304,6 +363,7 @@ func (b BrowsersCmd) List(ctx context.Context, in BrowsersListInput) error { row := []string{ browser.SessionID, + util.OrDash(browser.Name), util.FormatLocal(browser.CreatedAt), profile, pool, @@ -418,6 +478,13 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { params.Telemetry = t } + if in.Name != "" { + params.Name = kernel.Opt(in.Name) + } + if len(in.Tags) > 0 { + params.Tags = kernel.Tags(in.Tags) + } + if in.Output != "json" { pterm.Info.Println("Creating browser session...") } @@ -430,22 +497,25 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { return util.PrintPrettyJSON(browser) } - printBrowserSessionResult(browser.SessionID, browser.CdpWsURL, browser.BrowserLiveViewURL, browser.Profile, browser.StartURL) + printBrowserSessionResult(browser.SessionID, browser.CdpWsURL, browser.BrowserLiveViewURL, browser.Profile, browser.StartURL, browser.Name, browser.Tags) return nil } -func printBrowserSessionResult(sessionID, cdpURL, liveViewURL string, profile kernel.Profile, startURL string) { - tableData := buildBrowserTableData(sessionID, cdpURL, liveViewURL, profile, startURL) +func printBrowserSessionResult(sessionID, cdpURL, liveViewURL string, profile kernel.Profile, startURL, name string, tags kernel.Tags) { + tableData := buildBrowserTableData(sessionID, cdpURL, liveViewURL, profile, startURL, name, tags) PrintTableNoPad(tableData, true) } // buildBrowserTableData creates a base table with common browser session fields. -func buildBrowserTableData(sessionID, cdpURL, liveViewURL string, profile kernel.Profile, startURL string) pterm.TableData { +func buildBrowserTableData(sessionID, cdpURL, liveViewURL string, profile kernel.Profile, startURL, name string, tags kernel.Tags) pterm.TableData { tableData := pterm.TableData{ {"Property", "Value"}, {"Session ID", sessionID}, - {"CDP WebSocket URL", cdpURL}, } + if name != "" { + tableData = append(tableData, []string{"Name", name}) + } + tableData = append(tableData, []string{"CDP WebSocket URL", cdpURL}) if liveViewURL != "" { tableData = append(tableData, []string{"Live View URL", liveViewURL}) } @@ -459,6 +529,9 @@ func buildBrowserTableData(sessionID, cdpURL, liveViewURL string, profile kernel if startURL != "" { tableData = append(tableData, []string{"Start URL", startURL}) } + if len(tags) > 0 { + tableData = append(tableData, []string{"Tags", formatTags(tags)}) + } return tableData } @@ -530,6 +603,8 @@ func (b BrowsersCmd) Get(ctx context.Context, in BrowsersGetInput) error { browser.BrowserLiveViewURL, browser.Profile, browser.StartURL, + browser.Name, + browser.Tags, ) // Append additional detailed fields @@ -2179,30 +2254,30 @@ var browsersCreateCmd = &cobra.Command{ } var browsersDeleteCmd = &cobra.Command{ - Use: "delete [ids...]", - Short: "Delete a browser", + Use: "delete [ids-or-names...]", + Short: "Delete a browser by ID or name", Args: cobra.MinimumNArgs(1), RunE: runBrowsersDelete, } var browsersViewCmd = &cobra.Command{ - Use: "view ", - Short: "Get the live view URL for a browser", + Use: "view ", + Short: "Get the live view URL for a browser by ID or name", Args: cobra.ExactArgs(1), RunE: runBrowsersView, } var browsersGetCmd = &cobra.Command{ - Use: "get ", - Short: "Get detailed information about a browser session", - Long: "Retrieve and display detailed information about a specific browser session including configuration, URLs, and status.", + Use: "get ", + Short: "Get detailed information about a browser session by ID or name", + Long: "Retrieve and display detailed information about a specific browser session (by ID or name) including configuration, URLs, and status.", Args: cobra.ExactArgs(1), RunE: runBrowsersGet, } var browsersUpdateCmd = &cobra.Command{ - Use: "update ", - Short: "Update a browser session", + Use: "update ", + Short: "Update a browser session by ID or name", Long: `Update a running browser session. Supported operations: @@ -2214,10 +2289,10 @@ Supported operations: Note: Profiles can only be loaded into sessions that don't already have a profile.`, Args: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { - return fmt.Errorf("missing required argument: browser ID\n\nUsage: kernel browsers update [flags]") + return fmt.Errorf("missing required argument: browser ID or name\n\nUsage: kernel browsers update [flags]") } if len(args) > 1 { - return fmt.Errorf("expected 1 argument (browser ID), got %d", len(args)) + return fmt.Errorf("expected 1 argument (browser ID or name), got %d", len(args)) } return nil }, @@ -2231,7 +2306,8 @@ func init() { browsersListCmd.Flags().String("status", "", "Filter by status: 'active' (default), 'deleted', or 'all'") browsersListCmd.Flags().Int("limit", 0, "Maximum number of results to return (default 20, max 100)") browsersListCmd.Flags().Int("offset", 0, "Number of results to skip (for pagination)") - browsersListCmd.Flags().String("query", "", "Search browsers by session ID, profile ID, or proxy ID") + browsersListCmd.Flags().String("query", "", "Search browsers by name, session ID, profile ID, proxy ID, or pool name") + browsersListCmd.Flags().StringArray("tag", nil, "Filter by tag KEY=VALUE (repeatable; a session must match every pair)") // get flags addJSONOutputFlag(browsersGetCmd) @@ -2516,6 +2592,8 @@ func init() { browsersCreateCmd.Flags().String("pool-id", "", "Browser pool ID to acquire from (mutually exclusive with --pool-name)") browsersCreateCmd.Flags().String("pool-name", "", "Browser pool name to acquire from (mutually exclusive with --pool-id)") browsersCreateCmd.Flags().String("telemetry", "", "Configure telemetry: --telemetry=all to enable, --telemetry=off to disable, --telemetry=network=on,page=off for per-category") + browsersCreateCmd.Flags().String("name", "", "Optional unique name for the browser session (used to find it later; set at creation only)") + browsersCreateCmd.Flags().StringArray("tag", nil, "Set a tag KEY=VALUE on the session (repeatable; up to 50 pairs)") // curl curlCmd := &cobra.Command{ @@ -2564,6 +2642,7 @@ func runBrowsersList(cmd *cobra.Command, args []string) error { limit, _ := cmd.Flags().GetInt("limit") offset, _ := cmd.Flags().GetInt("offset") query, _ := cmd.Flags().GetString("query") + tags := tagsFromFlag(cmd, "tag") return b.List(cmd.Context(), BrowsersListInput{ Output: out, IncludeDeleted: includeDeleted, @@ -2571,6 +2650,7 @@ func runBrowsersList(cmd *cobra.Command, args []string) error { Limit: limit, Offset: offset, Query: query, + Tags: tags, }) } @@ -2595,6 +2675,8 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { poolID, _ := cmd.Flags().GetString("pool-id") poolName, _ := cmd.Flags().GetString("pool-name") telemetry, _ := cmd.Flags().GetString("telemetry") + name, _ := cmd.Flags().GetString("name") + tags := tagsFromFlag(cmd, "tag") output, _ := cmd.Flags().GetString("output") if poolID != "" && poolName != "" { @@ -2603,11 +2685,14 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { } if poolID != "" || poolName != "" { - // When using a pool, configuration comes from the pool itself. + // When using a pool, configuration comes from the pool itself, but + // name and tags apply per-lease to the acquired session. allowedFlags := map[string]bool{ "pool-id": true, "pool-name": true, "timeout": true, + "name": true, + "tag": true, "output": true, // Global persistent flags that don't configure browsers "no-color": true, @@ -2649,10 +2734,11 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { } poolSvc := client.BrowserPools - acquireParams := kernel.BrowserPoolAcquireParams{} + var acquireTimeout int64 if cmd.Flags().Changed("timeout") && timeout > 0 { - acquireParams.AcquireTimeoutSeconds = kernel.Int(int64(timeout)) + acquireTimeout = int64(timeout) } + acquireParams := buildAcquireParams(name, tags, acquireTimeout) resp, err := (&poolSvc).Acquire(cmd.Context(), pool, acquireParams) if err != nil { @@ -2669,7 +2755,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { if output == "json" { return util.PrintPrettyJSON(resp) } - printBrowserSessionResult(resp.SessionID, resp.CdpWsURL, resp.BrowserLiveViewURL, resp.Profile, resp.StartURL) + printBrowserSessionResult(resp.SessionID, resp.CdpWsURL, resp.BrowserLiveViewURL, resp.Profile, resp.StartURL, resp.Name, resp.Tags) return nil } @@ -2705,6 +2791,8 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { Extensions: extensions, Viewport: viewport, Telemetry: telemetry, + Name: name, + Tags: tags, Output: output, } diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 6cf892d..8ab33e6 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -20,6 +20,7 @@ import ( "github.com/kernel/kernel-go-sdk/packages/ssestream" "github.com/kernel/kernel-go-sdk/shared" "github.com/pterm/pterm" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -449,6 +450,140 @@ func TestBrowsersList_WithQuery_PassesParam(t *testing.T) { assert.Equal(t, "sess-matched", captured.Query.Value) } +func TestBrowsersCreate_WithNameAndTags(t *testing.T) { + setupStdoutCapture(t) + + var captured kernel.BrowserNewParams + fake := &FakeBrowsersService{ + NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) { + captured = body + return &kernel.BrowserNewResponse{ + SessionID: "sess-new", + CdpWsURL: "ws://cdp-new", + Name: "my-session", + Tags: kernel.Tags{"team": "backend", "env": "staging"}, + }, nil + }, + } + + b := BrowsersCmd{browsers: fake} + err := b.Create(context.Background(), BrowsersCreateInput{ + Name: "my-session", + Tags: map[string]string{"team": "backend", "env": "staging"}, + }) + assert.NoError(t, err) + + // Name and tags are forwarded to the SDK request. + assert.True(t, captured.Name.Valid()) + assert.Equal(t, "my-session", captured.Name.Value) + assert.Equal(t, "backend", captured.Tags["team"]) + assert.Equal(t, "staging", captured.Tags["env"]) + + // And surfaced in the result table (tags rendered sorted). + out := outBuf.String() + assert.Contains(t, out, "my-session") + assert.Contains(t, out, "Tags") + assert.Contains(t, out, "env=staging, team=backend") +} + +func TestBrowsersList_WithTags_PassesParamAndShowsName(t *testing.T) { + setupStdoutCapture(t) + + var captured kernel.BrowserListParams + fake := &FakeBrowsersService{ + ListFunc: func(ctx context.Context, query kernel.BrowserListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserListResponse], error) { + captured = query + return &pagination.OffsetPagination[kernel.BrowserListResponse]{Items: []kernel.BrowserListResponse{ + {SessionID: "sess-1", Name: "alpha"}, + }}, nil + }, + } + b := BrowsersCmd{browsers: fake} + err := b.List(context.Background(), BrowsersListInput{Tags: map[string]string{"team": "backend"}}) + + assert.NoError(t, err) + assert.Equal(t, "backend", captured.Tags["team"]) + + // The Name column is populated. + out := outBuf.String() + assert.Contains(t, out, "alpha") +} + +func TestBrowsersGet_ShowsNameAndTags(t *testing.T) { + setupStdoutCapture(t) + + fake := &FakeBrowsersService{ + GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + // Lookup works by id or name; echo it back. + return &kernel.BrowserGetResponse{ + SessionID: "sess-1", + CdpWsURL: "ws://cdp-1", + Name: "my-session", + Tags: kernel.Tags{"env": "prod"}, + }, nil + }, + } + b := BrowsersCmd{browsers: fake} + err := b.Get(context.Background(), BrowsersGetInput{Identifier: "my-session"}) + + assert.NoError(t, err) + out := outBuf.String() + assert.Contains(t, out, "my-session") + assert.Contains(t, out, "Tags") + assert.Contains(t, out, "env=prod") +} + +func TestParseKeyValueSpecs(t *testing.T) { + tags, malformed := parseKeyValueSpecs([]string{"team=backend", "env=staging", "bad", "=novalue", "k=v=w"}) + + assert.Equal(t, map[string]string{"team": "backend", "env": "staging", "k": "v=w"}, tags) + assert.Equal(t, []string{"bad", "=novalue"}, malformed) +} + +func TestTagsFromFlag_WarnsOnMalformed(t *testing.T) { + setupStdoutCapture(t) + + cmd := &cobra.Command{} + cmd.Flags().StringArray("tag", []string{"team=backend", "oops", "env=staging"}, "") + + tags := tagsFromFlag(cmd, "tag") + + assert.Equal(t, map[string]string{"team": "backend", "env": "staging"}, tags) + assert.Contains(t, outBuf.String(), "Ignoring malformed tag: oops") +} + +func TestBrowsersGet_JSONOutput_IncludesNameAndTags(t *testing.T) { + setupStdoutCapture(t) + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + t.Cleanup(func() { os.Stdout = oldStdout }) + + fake := &FakeBrowsersService{ + GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + // Unmarshal so RawJSON() (used by the json output path) is populated. + jsonData := `{"session_id":"sess-json","cdp_ws_url":"ws://cdp","name":"my-session","tags":{"env":"prod"},"created_at":"2024-01-01T00:00:00Z","headless":false,"stealth":false,"timeout_seconds":60}` + var resp kernel.BrowserGetResponse + if err := json.Unmarshal([]byte(jsonData), &resp); err != nil { + t.Fatalf("failed to unmarshal test response: %v", err) + } + return &resp, nil + }, + } + b := BrowsersCmd{browsers: fake} + _ = b.Get(context.Background(), BrowsersGetInput{Identifier: "my-session", Output: "json"}) + + w.Close() + var stdoutBuf bytes.Buffer + io.Copy(&stdoutBuf, r) + + out := stdoutBuf.String() + assert.Contains(t, out, "\"name\"") + assert.Contains(t, out, "my-session") + assert.Contains(t, out, "\"tags\"") + assert.Contains(t, out, "prod") +} + func TestBrowsersCreate_PrintsResponse(t *testing.T) { setupStdoutCapture(t)