From a7716d902cedc5f88ba6a3173191f4768d8e9df0 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:35:53 +0000 Subject: [PATCH 01/10] CLI: Update SDK to 9e90177b921114c93e264ca9792537bf2d8de754 and add missing flags Keep the CLI aligned with the latest kernel-go-sdk release while exposing browser process env/TTY options and browser pool chrome policy support that were already present in the SDK. Tested: go test ./cmd/... && go build ./... Tested: kernel browsers process exec --env Tested: kernel browsers process spawn --allocate-tty --cols --rows --env Tested: kernel browser-pools create/update --chrome-policy Made-with: Cursor --- cmd/browser_pools.go | 56 +++++++++++++++++++++++ cmd/browser_pools_test.go | 72 ++++++++++++++++++++++++++++++ cmd/browsers.go | 93 ++++++++++++++++++++++++++++++++++----- cmd/browsers_test.go | 47 ++++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- 6 files changed, 261 insertions(+), 13 deletions(-) create mode 100644 cmd/browser_pools_test.go diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index fbb63f86..f445ac31 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "strings" @@ -86,6 +87,7 @@ type BrowserPoolsCreateInput struct { ProfileName string ProfileSaveChanges BoolFlag ProxyID string + ChromePolicy string Extensions []string Viewport string Output string @@ -131,6 +133,14 @@ func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) if in.ProxyID != "" { params.ProxyID = kernel.String(in.ProxyID) } + chromePolicy, err := parseChromePolicy(in.ChromePolicy) + if err != nil { + pterm.Error.Println(err.Error()) + return nil + } + if len(chromePolicy) > 0 { + params.ChromePolicy = chromePolicy + } params.Extensions = buildExtensionsParam(in.Extensions) @@ -196,6 +206,7 @@ func (c BrowserPoolsCmd) Get(ctx context.Context, in BrowserPoolsGetInput) error {"Kiosk Mode", fmt.Sprintf("%t", cfg.KioskMode)}, {"Profile", formatProfile(cfg.Profile)}, {"Proxy ID", util.OrDash(cfg.ProxyID)}, + {"Chrome Policy", formatChromePolicy(cfg.ChromePolicy)}, {"Extensions", formatExtensions(cfg.Extensions)}, {"Viewport", formatViewport(cfg.Viewport)}, } @@ -217,6 +228,7 @@ type BrowserPoolsUpdateInput struct { ProfileName string ProfileSaveChanges BoolFlag ProxyID string + ChromePolicy string Extensions []string Viewport string DiscardAllIdle BoolFlag @@ -267,6 +279,14 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) if in.ProxyID != "" { params.ProxyID = kernel.String(in.ProxyID) } + chromePolicy, err := parseChromePolicy(in.ChromePolicy) + if err != nil { + pterm.Error.Println(err.Error()) + return nil + } + if len(chromePolicy) > 0 { + params.ChromePolicy = chromePolicy + } params.Extensions = buildExtensionsParam(in.Extensions) @@ -472,6 +492,7 @@ func init() { browserPoolsCreateCmd.Flags().String("profile-name", "", "Profile name") browserPoolsCreateCmd.Flags().Bool("save-changes", false, "Save changes to profile") browserPoolsCreateCmd.Flags().String("proxy-id", "", "Proxy ID") + browserPoolsCreateCmd.Flags().String("chrome-policy", "", "JSON object of Chrome enterprise policy overrides to apply to all browsers in the pool") browserPoolsCreateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names") browserPoolsCreateCmd.Flags().String("viewport", "", "Viewport size (e.g. 1280x800)") @@ -488,6 +509,7 @@ func init() { browserPoolsUpdateCmd.Flags().String("profile-name", "", "Profile name") browserPoolsUpdateCmd.Flags().Bool("save-changes", false, "Save changes to profile") browserPoolsUpdateCmd.Flags().String("proxy-id", "", "Proxy ID") + browserPoolsUpdateCmd.Flags().String("chrome-policy", "", "JSON object of Chrome enterprise policy overrides to apply to all browsers in the pool") browserPoolsUpdateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names") browserPoolsUpdateCmd.Flags().String("viewport", "", "Viewport size (e.g. 1280x800)") browserPoolsUpdateCmd.Flags().Bool("discard-all-idle", false, "Discard all idle browsers") @@ -539,6 +561,7 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { profileName, _ := cmd.Flags().GetString("profile-name") saveChanges, _ := cmd.Flags().GetBool("save-changes") proxyID, _ := cmd.Flags().GetString("proxy-id") + chromePolicy, _ := cmd.Flags().GetString("chrome-policy") extensions, _ := cmd.Flags().GetStringSlice("extension") viewport, _ := cmd.Flags().GetString("viewport") output, _ := cmd.Flags().GetString("output") @@ -555,6 +578,7 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { ProfileName: profileName, ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges}, ProxyID: proxyID, + ChromePolicy: chromePolicy, Extensions: extensions, Viewport: viewport, Output: output, @@ -585,6 +609,7 @@ func runBrowserPoolsUpdate(cmd *cobra.Command, args []string) error { profileName, _ := cmd.Flags().GetString("profile-name") saveChanges, _ := cmd.Flags().GetBool("save-changes") proxyID, _ := cmd.Flags().GetString("proxy-id") + chromePolicy, _ := cmd.Flags().GetString("chrome-policy") extensions, _ := cmd.Flags().GetStringSlice("extension") viewport, _ := cmd.Flags().GetString("viewport") discardIdle, _ := cmd.Flags().GetBool("discard-all-idle") @@ -603,6 +628,7 @@ func runBrowserPoolsUpdate(cmd *cobra.Command, args []string) error { ProfileName: profileName, ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges}, ProxyID: proxyID, + ChromePolicy: chromePolicy, Extensions: extensions, Viewport: viewport, DiscardAllIdle: BoolFlag{Set: cmd.Flags().Changed("discard-all-idle"), Value: discardIdle}, @@ -687,6 +713,23 @@ func buildExtensionsParam(extensions []string) []kernel.BrowserExtensionParam { return result } +func parseChromePolicy(raw string) (map[string]any, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + + var policy map[string]any + if err := json.Unmarshal([]byte(raw), &policy); err != nil { + return nil, fmt.Errorf("invalid --chrome-policy JSON: %w", err) + } + if policy == nil { + return nil, fmt.Errorf("--chrome-policy must be a JSON object") + } + + return policy, nil +} + func buildViewportParam(viewport string) (*kernel.BrowserViewportParam, error) { if viewport == "" { return nil, nil @@ -735,6 +778,19 @@ func formatExtensions(extensions []kernel.BrowserExtension) string { return util.JoinOrDash(names...) } +func formatChromePolicy(policy map[string]any) string { + if len(policy) == 0 { + return "-" + } + + data, err := json.Marshal(policy) + if err != nil { + return fmt.Sprintf("%v", policy) + } + + return string(data) +} + func formatViewport(viewport kernel.BrowserViewport) string { if viewport.Width == 0 || viewport.Height == 0 { return "-" diff --git a/cmd/browser_pools_test.go b/cmd/browser_pools_test.go new file mode 100644 index 00000000..826ba656 --- /dev/null +++ b/cmd/browser_pools_test.go @@ -0,0 +1,72 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/stretchr/testify/assert" +) + +type fakeBrowserPoolsService struct { + newFunc func(ctx context.Context, body kernel.BrowserPoolNewParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) +} + +func (f *fakeBrowserPoolsService) List(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserPool, error) { + return &[]kernel.BrowserPool{}, nil +} + +func (f *fakeBrowserPoolsService) New(ctx context.Context, body kernel.BrowserPoolNewParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) { + if f.newFunc != nil { + return f.newFunc(ctx, body, opts...) + } + 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) { + 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 TestBrowserPoolsCreate_MapsChromePolicy(t *testing.T) { + setupStdoutCapture(t) + + fake := &fakeBrowserPoolsService{ + newFunc: func(ctx context.Context, body kernel.BrowserPoolNewParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) { + assert.Equal(t, map[string]any{ + "HomepageLocation": "https://example.com", + "ShowHomeButton": true, + }, body.ChromePolicy) + return &kernel.BrowserPool{ID: "pool_123", Name: "test-pool"}, nil + }, + } + + cmd := BrowserPoolsCmd{client: fake} + err := cmd.Create(context.Background(), BrowserPoolsCreateInput{ + Name: "test-pool", + Size: 1, + ChromePolicy: `{"HomepageLocation":"https://example.com","ShowHomeButton":true}`, + }) + + assert.NoError(t, err) +} diff --git a/cmd/browsers.go b/cmd/browsers.go index d799667f..9103082e 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -170,6 +170,23 @@ func parseViewport(viewport string) (width, height, refreshRate int64, err error return w, h, refreshRate, nil } +func parseStringMapFlag(values []string, flagName string) (map[string]string, error) { + if len(values) == 0 { + return nil, nil + } + + parsed := make(map[string]string, len(values)) + for _, pair := range values { + parts := strings.SplitN(pair, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid %s value: %s (expected KEY=value)", flagName, pair) + } + parsed[parts[0]] = parts[1] + } + + return parsed, nil +} + // Inputs for each command type BrowsersCreateInput struct { PersistenceID string @@ -1257,18 +1274,23 @@ type BrowsersProcessExecInput struct { Timeout int AsUser string AsRoot BoolFlag + Env []string Output string } type BrowsersProcessSpawnInput struct { - Identifier string - Command string - Args []string - Cwd string - Timeout int - AsUser string - AsRoot BoolFlag - Output string + Identifier string + Command string + Args []string + Cwd string + Timeout int + AsUser string + AsRoot BoolFlag + AllocateTTY BoolFlag + Cols int64 + Rows int64 + Env []string + Output string } type BrowsersProcessKillInput struct { @@ -1396,6 +1418,13 @@ func (b BrowsersCmd) ProcessExec(ctx context.Context, in BrowsersProcessExecInpu if in.AsRoot.Set { params.AsRoot = kernel.Opt(in.AsRoot.Value) } + env, err := parseStringMapFlag(in.Env, "--env") + if err != nil { + return err + } + if len(env) > 0 { + params.Env = env + } res, err := b.process.Exec(ctx, br.SessionID, params) if err != nil { return util.CleanedUpSdkError{Err: err} @@ -1463,6 +1492,27 @@ func (b BrowsersCmd) ProcessSpawn(ctx context.Context, in BrowsersProcessSpawnIn if in.AsRoot.Set { params.AsRoot = kernel.Opt(in.AsRoot.Value) } + if in.AllocateTTY.Set { + params.AllocateTty = kernel.Opt(in.AllocateTTY.Value) + } + if in.Cols > 0 || in.Rows > 0 { + if !in.AllocateTTY.Set || !in.AllocateTTY.Value { + return fmt.Errorf("--cols and --rows require --allocate-tty") + } + if in.Cols > 0 { + params.Cols = kernel.Opt(in.Cols) + } + if in.Rows > 0 { + params.Rows = kernel.Opt(in.Rows) + } + } + env, err := parseStringMapFlag(in.Env, "--env") + if err != nil { + return err + } + if len(env) > 0 { + params.Env = env + } res, err := b.process.Spawn(ctx, br.SessionID, params) if err != nil { return util.CleanedUpSdkError{Err: err} @@ -2297,6 +2347,7 @@ func init() { procExec.Flags().Int("timeout", 0, "Timeout in seconds") procExec.Flags().String("as-user", "", "Run as user") procExec.Flags().Bool("as-root", false, "Run as root") + procExec.Flags().StringArray("env", nil, "Environment variable in KEY=value format (repeatable)") procExec.Flags().StringP("output", "o", "", "Output format: json for raw API response") procSpawn := &cobra.Command{Use: "spawn [--] [command...]", Short: "Execute a command asynchronously", Args: cobra.MinimumNArgs(1), RunE: runBrowsersProcessSpawn} procSpawn.Flags().String("command", "", "Command to execute (optional; if omitted, trailing args are executed via /bin/bash -c)") @@ -2305,6 +2356,10 @@ func init() { procSpawn.Flags().Int("timeout", 0, "Timeout in seconds") procSpawn.Flags().String("as-user", "", "Run as user") procSpawn.Flags().Bool("as-root", false, "Run as root") + procSpawn.Flags().Bool("allocate-tty", false, "Allocate a pseudo-terminal (PTY) for interactive shells") + procSpawn.Flags().Int64("cols", 0, "Initial terminal columns when --allocate-tty is enabled") + procSpawn.Flags().Int64("rows", 0, "Initial terminal rows when --allocate-tty is enabled") + procSpawn.Flags().StringArray("env", nil, "Environment variable in KEY=value format (repeatable)") procSpawn.Flags().StringP("output", "o", "", "Output format: json for raw API response") procKill := &cobra.Command{Use: "kill ", Short: "Send a signal to a process", Args: cobra.ExactArgs(2), RunE: runBrowsersProcessKill} procKill.Flags().String("signal", "TERM", "Signal to send (TERM, KILL, INT, HUP)") @@ -2806,6 +2861,7 @@ func runBrowsersProcessExec(cmd *cobra.Command, args []string) error { timeout, _ := cmd.Flags().GetInt("timeout") asUser, _ := cmd.Flags().GetString("as-user") asRoot, _ := cmd.Flags().GetBool("as-root") + env, _ := cmd.Flags().GetStringArray("env") if command == "" && len(args) > 1 { // Treat trailing args after identifier as a shell command shellCmd := strings.Join(args[1:], " ") @@ -2814,7 +2870,7 @@ func runBrowsersProcessExec(cmd *cobra.Command, args []string) error { } output, _ := cmd.Flags().GetString("output") b := BrowsersCmd{browsers: &svc, process: &svc.Process} - return b.ProcessExec(cmd.Context(), BrowsersProcessExecInput{Identifier: args[0], Command: command, Args: argv, Cwd: cwd, Timeout: timeout, AsUser: asUser, AsRoot: BoolFlag{Set: cmd.Flags().Changed("as-root"), Value: asRoot}, Output: output}) + return b.ProcessExec(cmd.Context(), BrowsersProcessExecInput{Identifier: args[0], Command: command, Args: argv, Cwd: cwd, Timeout: timeout, AsUser: asUser, AsRoot: BoolFlag{Set: cmd.Flags().Changed("as-root"), Value: asRoot}, Env: env, Output: output}) } func runBrowsersProcessSpawn(cmd *cobra.Command, args []string) error { @@ -2826,6 +2882,10 @@ func runBrowsersProcessSpawn(cmd *cobra.Command, args []string) error { timeout, _ := cmd.Flags().GetInt("timeout") asUser, _ := cmd.Flags().GetString("as-user") asRoot, _ := cmd.Flags().GetBool("as-root") + allocateTTY, _ := cmd.Flags().GetBool("allocate-tty") + cols, _ := cmd.Flags().GetInt64("cols") + rows, _ := cmd.Flags().GetInt64("rows") + env, _ := cmd.Flags().GetStringArray("env") if command == "" && len(args) > 1 { shellCmd := strings.Join(args[1:], " ") command = "/bin/bash" @@ -2833,7 +2893,20 @@ func runBrowsersProcessSpawn(cmd *cobra.Command, args []string) error { } output, _ := cmd.Flags().GetString("output") b := BrowsersCmd{browsers: &svc, process: &svc.Process} - return b.ProcessSpawn(cmd.Context(), BrowsersProcessSpawnInput{Identifier: args[0], Command: command, Args: argv, Cwd: cwd, Timeout: timeout, AsUser: asUser, AsRoot: BoolFlag{Set: cmd.Flags().Changed("as-root"), Value: asRoot}, Output: output}) + return b.ProcessSpawn(cmd.Context(), BrowsersProcessSpawnInput{ + Identifier: args[0], + Command: command, + Args: argv, + Cwd: cwd, + Timeout: timeout, + AsUser: asUser, + AsRoot: BoolFlag{Set: cmd.Flags().Changed("as-root"), Value: asRoot}, + AllocateTTY: BoolFlag{Set: cmd.Flags().Changed("allocate-tty"), Value: allocateTTY}, + Cols: cols, + Rows: rows, + Env: env, + Output: output, + }) } func runBrowsersProcessKill(cmd *cobra.Command, args []string) error { diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 2bb2c71d..125179c5 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -907,6 +907,25 @@ func TestBrowsersProcessExec_PrintsSummary(t *testing.T) { assert.Contains(t, out, "Duration") } +func TestBrowsersProcessExec_MapsEnv(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeProcessService{ + ExecFunc: func(ctx context.Context, id string, body kernel.BrowserProcessExecParams, opts ...option.RequestOption) (*kernel.BrowserProcessExecResponse, error) { + assert.Equal(t, "id", id) + assert.Equal(t, map[string]string{"FOO": "bar", "HELLO": "world"}, body.Env) + return &kernel.BrowserProcessExecResponse{ExitCode: 0, DurationMs: 10}, nil + }, + } + fakeBrowsers := newFakeBrowsersServiceWithSimpleGet() + b := BrowsersCmd{browsers: fakeBrowsers, process: fake} + err := b.ProcessExec(context.Background(), BrowsersProcessExecInput{ + Identifier: "id", + Command: "env", + Env: []string{"FOO=bar", "HELLO=world"}, + }) + assert.NoError(t, err) +} + func TestBrowsersProcessSpawn_PrintsInfo(t *testing.T) { setupStdoutCapture(t) fake := &FakeProcessService{} @@ -918,6 +937,34 @@ func TestBrowsersProcessSpawn_PrintsInfo(t *testing.T) { assert.Contains(t, out, "PID") } +func TestBrowsersProcessSpawn_MapsTTYAndEnv(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeProcessService{ + SpawnFunc: func(ctx context.Context, id string, body kernel.BrowserProcessSpawnParams, opts ...option.RequestOption) (*kernel.BrowserProcessSpawnResponse, error) { + assert.Equal(t, "id", id) + assert.True(t, body.AllocateTty.Valid()) + assert.True(t, body.AllocateTty.Value) + assert.True(t, body.Cols.Valid()) + assert.Equal(t, int64(120), body.Cols.Value) + assert.True(t, body.Rows.Valid()) + assert.Equal(t, int64(40), body.Rows.Value) + assert.Equal(t, map[string]string{"FOO": "bar"}, body.Env) + return &kernel.BrowserProcessSpawnResponse{ProcessID: "proc-1", Pid: 123, StartedAt: time.Now()}, nil + }, + } + fakeBrowsers := newFakeBrowsersServiceWithSimpleGet() + b := BrowsersCmd{browsers: fakeBrowsers, process: fake} + err := b.ProcessSpawn(context.Background(), BrowsersProcessSpawnInput{ + Identifier: "id", + Command: "bash", + AllocateTTY: BoolFlag{Set: true, Value: true}, + Cols: 120, + Rows: 40, + Env: []string{"FOO=bar"}, + }) + assert.NoError(t, err) +} + func TestBrowsersProcessKill_PrintsSuccess(t *testing.T) { setupStdoutCapture(t) fake := &FakeProcessService{} diff --git a/go.mod b/go.mod index bbc599bd..b8264249 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.44.1-0.20260323174449-5e56fc5d99a6 + github.com/kernel/kernel-go-sdk v0.45.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 github.com/samber/lo v1.51.0 diff --git a/go.sum b/go.sum index 2c777edb..d7c31ef8 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.44.1-0.20260323174449-5e56fc5d99a6 h1:RBlGCN3IagI0b+XrWsb5FOUV/18tniuL6oHFAb7MMHE= -github.com/kernel/kernel-go-sdk v0.44.1-0.20260323174449-5e56fc5d99a6/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.45.0 h1:RIFpSDmhAWllo692FZL3Os3TRce5oHvyj8LPfwXce5Y= +github.com/kernel/kernel-go-sdk v0.45.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= From 26d22ee28767fcd6d72d33e9a5997b6d041ff450 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:05:05 +0000 Subject: [PATCH 02/10] CLI: Update SDK to 91f2aa6572a40330669e39ec4d40cd0b1ee75812 and add missing flags Align the CLI with the latest kernel-go-sdk by exposing browser default stealth proxy control and the new proxy health check URL parameter. This also updates the CLI dependency to the SDK release that includes these API changes. Tested: go test ./cmd ./cmd/proxies -run 'TestBrowsersUpdate_|TestProxyCheck_' Tested: go build ./... Tested: /tmp/kernel-cli/bin/kernel browsers create --headless --stealth -t 30 -o json Tested: /tmp/kernel-cli/bin/kernel browsers update --disable-default-proxy -o json Tested: /tmp/kernel-cli/bin/kernel proxies create --type datacenter --country US --name -o json Tested: /tmp/kernel-cli/bin/kernel proxies check --url https://example.com -o json Made-with: Cursor --- cmd/browsers.go | 48 ++++++++++++++++++++++---------------- cmd/browsers_test.go | 19 +++++++++++++++ cmd/proxies/check.go | 10 ++++++-- cmd/proxies/check_test.go | 27 ++++++++++++++++++++- cmd/proxies/common_test.go | 6 ++--- cmd/proxies/proxies.go | 3 ++- cmd/proxies/types.go | 3 ++- go.mod | 2 +- go.sum | 4 ++-- 9 files changed, 91 insertions(+), 31 deletions(-) diff --git a/cmd/browsers.go b/cmd/browsers.go index 9103082e..22c3a46f 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -221,15 +221,16 @@ type BrowsersGetInput struct { } type BrowsersUpdateInput struct { - Identifier string - ProxyID string - ClearProxy bool - ProfileID string - ProfileName string - ProfileSaveChanges BoolFlag - Viewport string - Force bool - Output string + Identifier string + ProxyID string + ClearProxy bool + DisableDefaultProxy BoolFlag + ProfileID string + ProfileName string + ProfileSaveChanges BoolFlag + Viewport string + Force bool + Output string } // BrowsersCmd is a cobra-independent command handler for browsers operations. @@ -610,7 +611,7 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { return fmt.Errorf("cannot specify both --proxy-id and --clear-proxy") } - hasProxyChange := in.ProxyID != "" || in.ClearProxy + hasProxyChange := in.ProxyID != "" || in.ClearProxy || in.DisableDefaultProxy.Set hasProfileChange := in.ProfileID != "" || in.ProfileName != "" hasViewportChange := in.Viewport != "" @@ -626,7 +627,7 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { // Validate that at least one update option is provided if !hasProxyChange && !hasProfileChange && !hasViewportChange { - return fmt.Errorf("must specify at least one of: --proxy-id, --clear-proxy, --profile-id, --profile-name, or --viewport") + return fmt.Errorf("must specify at least one of: --proxy-id, --clear-proxy, --disable-default-proxy, --profile-id, --profile-name, or --viewport") } params := kernel.BrowserUpdateParams{} @@ -637,6 +638,9 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { } else if in.ProxyID != "" { params.ProxyID = kernel.Opt(in.ProxyID) } + if in.DisableDefaultProxy.Set { + params.DisableDefaultProxy = kernel.Opt(in.DisableDefaultProxy.Value) + } // Handle profile changes if hasProfileChange { @@ -2260,6 +2264,7 @@ var browsersUpdateCmd = &cobra.Command{ Supported operations: - Change or remove proxy (--proxy-id or --clear-proxy) + - Disable the default stealth proxy (--disable-default-proxy) - Load a profile into a session that doesn't have one (--profile-id or --profile-name) - Change viewport dimensions (--viewport) - Force viewport resize during active live view or recording (--force with --viewport) @@ -2297,6 +2302,7 @@ func init() { browsersUpdateCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") browsersUpdateCmd.Flags().String("proxy-id", "", "ID of the proxy to use for the browser session") browsersUpdateCmd.Flags().Bool("clear-proxy", false, "Remove the proxy from the browser session") + browsersUpdateCmd.Flags().Bool("disable-default-proxy", false, "Disable the default stealth proxy so the browser connects directly; use --disable-default-proxy=false to re-enable it") browsersUpdateCmd.Flags().String("profile-id", "", "Profile ID to load into the browser session (mutually exclusive with --profile-name)") browsersUpdateCmd.Flags().String("profile-name", "", "Profile name to load into the browser session (mutually exclusive with --profile-id)") browsersUpdateCmd.Flags().Bool("save-changes", false, "If set, save changes back to the profile when the session ends") @@ -2781,6 +2787,7 @@ func runBrowsersUpdate(cmd *cobra.Command, args []string) error { out, _ := cmd.Flags().GetString("output") proxyID, _ := cmd.Flags().GetString("proxy-id") clearProxy, _ := cmd.Flags().GetBool("clear-proxy") + disableDefaultProxy, _ := cmd.Flags().GetBool("disable-default-proxy") profileID, _ := cmd.Flags().GetString("profile-id") profileName, _ := cmd.Flags().GetString("profile-name") saveChanges, _ := cmd.Flags().GetBool("save-changes") @@ -2790,15 +2797,16 @@ func runBrowsersUpdate(cmd *cobra.Command, args []string) error { svc := client.Browsers b := BrowsersCmd{browsers: &svc} return b.Update(cmd.Context(), BrowsersUpdateInput{ - Identifier: args[0], - ProxyID: proxyID, - ClearProxy: clearProxy, - ProfileID: profileID, - ProfileName: profileName, - ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges}, - Viewport: viewport, - Force: force, - Output: out, + Identifier: args[0], + ProxyID: proxyID, + ClearProxy: clearProxy, + DisableDefaultProxy: BoolFlag{Set: cmd.Flags().Changed("disable-default-proxy"), Value: disableDefaultProxy}, + ProfileID: profileID, + ProfileName: profileName, + ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges}, + Viewport: viewport, + Force: force, + Output: out, }) } diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 125179c5..86d4947d 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -1491,6 +1491,25 @@ func TestBrowsersUpdate_WithViewportNoForce(t *testing.T) { assert.False(t, captured.Viewport.Force.Valid()) } +func TestBrowsersUpdate_WithDisableDefaultProxy(t *testing.T) { + setupStdoutCapture(t) + var captured kernel.BrowserUpdateParams + fake := &FakeBrowsersService{UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (*kernel.BrowserUpdateResponse, error) { + captured = body + return &kernel.BrowserUpdateResponse{SessionID: "session123"}, nil + }} + b := BrowsersCmd{browsers: fake} + + err := b.Update(context.Background(), BrowsersUpdateInput{ + Identifier: "session123", + DisableDefaultProxy: BoolFlag{Set: true, Value: true}, + }) + + assert.NoError(t, err) + assert.True(t, captured.DisableDefaultProxy.Valid()) + assert.True(t, captured.DisableDefaultProxy.Value) +} + func TestBrowsersUpdate_ForceWithoutViewport_Errors(t *testing.T) { setupStdoutCapture(t) fake := &FakeBrowsersService{} diff --git a/cmd/proxies/check.go b/cmd/proxies/check.go index 5f47fae6..debf1e17 100644 --- a/cmd/proxies/check.go +++ b/cmd/proxies/check.go @@ -20,7 +20,12 @@ func (p ProxyCmd) Check(ctx context.Context, in ProxyCheckInput) error { pterm.Info.Printf("Running health check on proxy %s...\n", in.ID) } - proxy, err := p.proxies.Check(ctx, in.ID) + params := kernel.ProxyCheckParams{} + if in.URL != "" { + params.URL = kernel.Opt(in.URL) + } + + proxy, err := p.proxies.Check(ctx, in.ID, params) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -154,7 +159,8 @@ func getProxyCheckConfigRows(proxy *kernel.ProxyCheckResponse) [][]string { func runProxiesCheck(cmd *cobra.Command, args []string) error { client := util.GetKernelClient(cmd) output, _ := cmd.Flags().GetString("output") + url, _ := cmd.Flags().GetString("url") svc := client.Proxies p := ProxyCmd{proxies: &svc} - return p.Check(cmd.Context(), ProxyCheckInput{ID: args[0], Output: output}) + return p.Check(cmd.Context(), ProxyCheckInput{ID: args[0], URL: url, Output: output}) } diff --git a/cmd/proxies/check_test.go b/cmd/proxies/check_test.go index 8f24ecbf..82de52f4 100644 --- a/cmd/proxies/check_test.go +++ b/cmd/proxies/check_test.go @@ -13,7 +13,7 @@ func TestProxyCheck_ShowsBypassHosts(t *testing.T) { buf := captureOutput(t) fake := &FakeProxyService{ - CheckFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error) { + CheckFunc: func(ctx context.Context, id string, body kernel.ProxyCheckParams, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error) { return &kernel.ProxyCheckResponse{ ID: id, Name: "Proxy 1", @@ -34,3 +34,28 @@ func TestProxyCheck_ShowsBypassHosts(t *testing.T) { assert.Contains(t, output, "internal.service.local") assert.Contains(t, output, "Proxy health check passed") } + +func TestProxyCheck_PassesURL(t *testing.T) { + buf := captureOutput(t) + var captured kernel.ProxyCheckParams + + fake := &FakeProxyService{ + CheckFunc: func(ctx context.Context, id string, body kernel.ProxyCheckParams, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error) { + captured = body + return &kernel.ProxyCheckResponse{ + ID: id, + Name: "Proxy 1", + Type: kernel.ProxyCheckResponseTypeDatacenter, + Status: kernel.ProxyCheckResponseStatusAvailable, + }, nil + }, + } + + p := ProxyCmd{proxies: fake} + err := p.Check(context.Background(), ProxyCheckInput{ID: "proxy-1", URL: "https://example.com"}) + + assert.NoError(t, err) + assert.True(t, captured.URL.Valid()) + assert.Equal(t, "https://example.com", captured.URL.Value) + assert.Contains(t, buf.String(), "Proxy health check passed") +} diff --git a/cmd/proxies/common_test.go b/cmd/proxies/common_test.go index 48f13cf5..df49b769 100644 --- a/cmd/proxies/common_test.go +++ b/cmd/proxies/common_test.go @@ -41,7 +41,7 @@ type FakeProxyService struct { 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, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, 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) { @@ -73,9 +73,9 @@ func (f *FakeProxyService) Delete(ctx context.Context, id string, opts ...option return nil } -func (f *FakeProxyService) Check(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error) { +func (f *FakeProxyService) Check(ctx context.Context, id string, body kernel.ProxyCheckParams, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error) { if f.CheckFunc != nil { - return f.CheckFunc(ctx, id, opts...) + return f.CheckFunc(ctx, id, body, opts...) } return &kernel.ProxyCheckResponse{ID: id, Type: kernel.ProxyCheckResponseTypeDatacenter}, nil } diff --git a/cmd/proxies/proxies.go b/cmd/proxies/proxies.go index 2440d3a9..3a93843b 100644 --- a/cmd/proxies/proxies.go +++ b/cmd/proxies/proxies.go @@ -66,7 +66,7 @@ var proxiesDeleteCmd = &cobra.Command{ var proxiesCheckCmd = &cobra.Command{ Use: "check ", Short: "Run a health check on a proxy", - Long: "Run a health check on a proxy to verify it's working and update its status.", + Long: "Run a health check on a proxy to verify it's working and update its status. Optionally test against a specific public URL.", Args: cobra.ExactArgs(1), RunE: runProxiesCheck, } @@ -115,4 +115,5 @@ func init() { // Check flags proxiesCheckCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + proxiesCheckCmd.Flags().String("url", "", "Optional public HTTP or HTTPS URL to test reachability against") } diff --git a/cmd/proxies/types.go b/cmd/proxies/types.go index c8e7a38d..eb0220e0 100644 --- a/cmd/proxies/types.go +++ b/cmd/proxies/types.go @@ -13,7 +13,7 @@ type ProxyService interface { 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) - Check(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.ProxyCheckResponse, err error) + Check(ctx context.Context, id string, body kernel.ProxyCheckParams, opts ...option.RequestOption) (res *kernel.ProxyCheckResponse, err error) } // ProxyCmd handles proxy operations independent of cobra. @@ -62,5 +62,6 @@ type ProxyDeleteInput struct { type ProxyCheckInput struct { ID string + URL string Output string } diff --git a/go.mod b/go.mod index b8264249..ec1bab5f 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.45.0 + github.com/kernel/kernel-go-sdk v0.46.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 github.com/samber/lo v1.51.0 diff --git a/go.sum b/go.sum index d7c31ef8..431809d5 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.45.0 h1:RIFpSDmhAWllo692FZL3Os3TRce5oHvyj8LPfwXce5Y= -github.com/kernel/kernel-go-sdk v0.45.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.46.0 h1:S6OICIzyc6zTy1UdDgWFe9FFOsyJAcPaewKR/U0ZSHA= +github.com/kernel/kernel-go-sdk v0.46.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= From 69570a75ef9ffe1b015a14196dbf22aa81e85fc3 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:55:37 +0000 Subject: [PATCH 03/10] CLI: Update Go SDK to c223294ecc21cee581e9095306b75f069cfd92b8 Bring the CLI onto the latest kernel-go-sdk release so it stays aligned with the updated SDK. A full SDK/CLI coverage audit found no missing commands or flags; tested with `go build ./...`. Made-with: Cursor --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ec1bab5f..5cff256d 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.46.0 + github.com/kernel/kernel-go-sdk v0.47.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 github.com/samber/lo v1.51.0 diff --git a/go.sum b/go.sum index 431809d5..a2db6387 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.46.0 h1:S6OICIzyc6zTy1UdDgWFe9FFOsyJAcPaewKR/U0ZSHA= -github.com/kernel/kernel-go-sdk v0.46.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.47.0 h1:4bCalC81XACIybgXBxuRGzX7yZ9QnxABG8LSGni7wF8= +github.com/kernel/kernel-go-sdk v0.47.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= From 6d2fab0eaec9ca73bf02fc374816941610186491 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:55:18 +0000 Subject: [PATCH 04/10] CLI: Update SDK to 82b88d8f8050949f53eee233bfb1b67d6f9fe49e and add project commands Add CLI coverage for the hidden-but-supported project and project-limit endpoints while bumping the Go SDK to the latest release containing this revision. Tested: go test ./cmd/..., go build ./..., kernel projects create/get/update/list/limits get/limits update/delete Made-with: Cursor --- cmd/projects.go | 572 +++++++++++++++++++++++++++++++++++++++++++ cmd/projects_test.go | 138 +++++++++++ cmd/root.go | 1 + go.mod | 2 +- go.sum | 4 +- 5 files changed, 714 insertions(+), 3 deletions(-) create mode 100644 cmd/projects.go create mode 100644 cmd/projects_test.go diff --git a/cmd/projects.go b/cmd/projects.go new file mode 100644 index 00000000..f67ca896 --- /dev/null +++ b/cmd/projects.go @@ -0,0 +1,572 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + "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/samber/lo" + "github.com/spf13/cobra" +) + +// ProjectsService defines the subset of the Kernel SDK project client that we use. +type ProjectsService interface { + New(ctx context.Context, body kernel.ProjectNewParams, opts ...option.RequestOption) (res *kernel.Project, err error) + Get(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.Project, err error) + Update(ctx context.Context, id string, body kernel.ProjectUpdateParams, opts ...option.RequestOption) (res *kernel.Project, err error) + List(ctx context.Context, query kernel.ProjectListParams, opts ...option.RequestOption) (res *pagination.OffsetPagination[kernel.Project], err error) + Delete(ctx context.Context, id string, opts ...option.RequestOption) error +} + +// ProjectLimitsService defines the subset of the Kernel SDK project limits client that we use. +type ProjectLimitsService interface { + Get(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.ProjectLimits, err error) + Update(ctx context.Context, id string, body kernel.ProjectLimitUpdateParams, opts ...option.RequestOption) (res *kernel.ProjectLimits, err error) +} + +// ProjectsCmd handles project operations independent of cobra. +type ProjectsCmd struct { + projects ProjectsService + limits ProjectLimitsService +} + +type ProjectsListInput struct { + Output string + Page int + PerPage int +} + +type ProjectsGetInput struct { + ID string + Output string +} + +type ProjectsCreateInput struct { + Name string + Output string +} + +type ProjectsUpdateInput struct { + ID string + Name string + Status string + Output string +} + +type ProjectsDeleteInput struct { + ID string + SkipConfirm bool +} + +type ProjectLimitsGetInput struct { + ID string + Output string +} + +type ProjectLimitsUpdateInput struct { + ID string + MaxConcurrentInvocations Int64Flag + MaxConcurrentSessions Int64Flag + MaxPersistentSessions Int64Flag + MaxPooledSessions Int64Flag + Output string +} + +func (p ProjectsCmd) List(ctx context.Context, in ProjectsListInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + + page := in.Page + perPage := in.PerPage + if page <= 0 { + page = 1 + } + if perPage <= 0 { + perPage = 20 + } + + params := kernel.ProjectListParams{ + Limit: kernel.Opt(int64(perPage + 1)), + Offset: kernel.Opt(int64((page - 1) * perPage)), + } + + result, err := p.projects.List(ctx, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + var items []kernel.Project + if result != nil { + items = result.Items + } + + hasMore := len(items) > perPage + if hasMore { + items = items[:perPage] + } + itemsThisPage := len(items) + + if in.Output == "json" { + if len(items) == 0 { + fmt.Println("[]") + return nil + } + return util.PrintPrettyJSONSlice(items) + } + + if len(items) == 0 { + pterm.Info.Println("No projects found") + return nil + } + + rows := pterm.TableData{{"Project ID", "Name", "Status", "Created At", "Updated At"}} + for _, project := range items { + rows = append(rows, []string{ + project.ID, + project.Name, + string(project.Status), + util.FormatLocal(project.CreatedAt), + util.FormatLocal(project.UpdatedAt), + }) + } + PrintTableNoPad(rows, true) + + pterm.Printf("\nPage: %d Per-page: %d Items this page: %d Has more: %s\n", page, perPage, itemsThisPage, lo.Ternary(hasMore, "yes", "no")) + if hasMore { + pterm.Printf("Next: kernel projects list --page %d --per-page %d\n", page+1, perPage) + } + + return nil +} + +func (p ProjectsCmd) Get(ctx context.Context, in ProjectsGetInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + + project, err := p.projects.Get(ctx, in.ID) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(project) + } + + rows := projectTable(project) + PrintTableNoPad(rows, true) + return nil +} + +func (p ProjectsCmd) Create(ctx context.Context, in ProjectsCreateInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + if in.Name == "" { + return fmt.Errorf("--name is required") + } + + project, err := p.projects.New(ctx, kernel.ProjectNewParams{ + CreateProjectRequest: kernel.CreateProjectRequestParam{Name: in.Name}, + }) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(project) + } + + pterm.Success.Printf("Created project: %s\n", project.ID) + PrintTableNoPad(projectTable(project), true) + return nil +} + +func (p ProjectsCmd) Update(ctx context.Context, in ProjectsUpdateInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + + params := kernel.ProjectUpdateParams{ + UpdateProjectRequest: kernel.UpdateProjectRequestParam{}, + } + changed := false + + if in.Name != "" { + params.UpdateProjectRequest.Name = kernel.Opt(in.Name) + changed = true + } + + if in.Status != "" { + status, err := parseProjectStatus(in.Status) + if err != nil { + return err + } + params.UpdateProjectRequest.Status = status + changed = true + } + + if !changed { + return fmt.Errorf("at least one of --name or --status is required") + } + + project, err := p.projects.Update(ctx, in.ID, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(project) + } + + pterm.Success.Printf("Updated project: %s\n", project.ID) + PrintTableNoPad(projectTable(project), true) + return nil +} + +func (p ProjectsCmd) Delete(ctx context.Context, in ProjectsDeleteInput) error { + if !in.SkipConfirm { + msg := fmt.Sprintf("Are you sure you want to delete project '%s'? The project must be empty and this cannot be undone.", in.ID) + pterm.DefaultInteractiveConfirm.DefaultText = msg + ok, _ := pterm.DefaultInteractiveConfirm.Show() + if !ok { + pterm.Info.Println("Deletion cancelled") + return nil + } + } + + if err := p.projects.Delete(ctx, in.ID); err != nil { + if util.IsNotFound(err) { + pterm.Info.Printf("Project '%s' not found\n", in.ID) + return nil + } + return util.CleanedUpSdkError{Err: err} + } + + pterm.Success.Printf("Deleted project: %s\n", in.ID) + return nil +} + +func (p ProjectsCmd) GetLimits(ctx context.Context, in ProjectLimitsGetInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + + limits, err := p.limits.Get(ctx, in.ID) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(limits) + } + + PrintTableNoPad(projectLimitsTable(limits), true) + return nil +} + +func (p ProjectsCmd) UpdateLimits(ctx context.Context, in ProjectLimitsUpdateInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + + params := kernel.ProjectLimitUpdateParams{ + UpdateProjectLimitsRequest: kernel.UpdateProjectLimitsRequestParam{}, + } + changed := false + + if in.MaxConcurrentInvocations.Set { + params.UpdateProjectLimitsRequest.MaxConcurrentInvocations = kernel.Opt(in.MaxConcurrentInvocations.Value) + changed = true + } + if in.MaxConcurrentSessions.Set { + params.UpdateProjectLimitsRequest.MaxConcurrentSessions = kernel.Opt(in.MaxConcurrentSessions.Value) + changed = true + } + if in.MaxPersistentSessions.Set { + params.UpdateProjectLimitsRequest.MaxPersistentSessions = kernel.Opt(in.MaxPersistentSessions.Value) + changed = true + } + if in.MaxPooledSessions.Set { + params.UpdateProjectLimitsRequest.MaxPooledSessions = kernel.Opt(in.MaxPooledSessions.Value) + changed = true + } + + if !changed { + return fmt.Errorf("at least one limit flag is required") + } + + limits, err := p.limits.Update(ctx, in.ID, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(limits) + } + + pterm.Success.Printf("Updated project limits for: %s\n", in.ID) + PrintTableNoPad(projectLimitsTable(limits), true) + return nil +} + +func parseProjectStatus(status string) (kernel.UpdateProjectRequestStatus, error) { + switch strings.ToLower(strings.TrimSpace(status)) { + case "active": + return kernel.UpdateProjectRequestStatusActive, nil + case "archived": + return kernel.UpdateProjectRequestStatusArchived, nil + default: + return "", fmt.Errorf("invalid --status value: %s (must be 'active' or 'archived')", status) + } +} + +func projectTable(project *kernel.Project) pterm.TableData { + return pterm.TableData{ + {"Property", "Value"}, + {"ID", project.ID}, + {"Name", project.Name}, + {"Status", string(project.Status)}, + {"Created At", util.FormatLocal(project.CreatedAt)}, + {"Updated At", util.FormatLocal(project.UpdatedAt)}, + } +} + +func projectLimitsTable(limits *kernel.ProjectLimits) pterm.TableData { + return pterm.TableData{ + {"Property", "Value"}, + {"Max Concurrent Invocations", formatProjectLimit(limits.MaxConcurrentInvocations)}, + {"Max Concurrent Sessions", formatProjectLimit(limits.MaxConcurrentSessions)}, + {"Max Persistent Sessions", formatProjectLimit(limits.MaxPersistentSessions)}, + {"Max Pooled Sessions", formatProjectLimit(limits.MaxPooledSessions)}, + } +} + +func formatProjectLimit(value int64) string { + if value == 0 { + return "-" + } + return fmt.Sprintf("%d", value) +} + +var projectsCmd = &cobra.Command{ + Use: "projects", + Aliases: []string{"project"}, + Short: "Manage organization projects", + Long: "Commands for managing Kernel projects and project-level resource limits", +} + +var projectsListCmd = &cobra.Command{ + Use: "list", + Short: "List projects", + Args: cobra.NoArgs, + RunE: runProjectsList, +} + +var projectsGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a project by ID", + Args: cobra.ExactArgs(1), + RunE: runProjectsGet, +} + +var projectsCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new project", + Args: cobra.NoArgs, + RunE: runProjectsCreate, +} + +var projectsUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a project's name or status", + Args: cobra.ExactArgs(1), + RunE: runProjectsUpdate, +} + +var projectsDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a project", + Args: cobra.ExactArgs(1), + RunE: runProjectsDelete, +} + +var projectLimitsCmd = &cobra.Command{ + Use: "limits", + Short: "Manage project resource limits", +} + +var projectLimitsGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get project resource limits", + Args: cobra.ExactArgs(1), + RunE: runProjectLimitsGet, +} + +var projectLimitsUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update project resource limits", + Args: cobra.ExactArgs(1), + RunE: runProjectLimitsUpdate, +} + +func init() { + projectsCmd.AddCommand(projectsListCmd) + projectsCmd.AddCommand(projectsGetCmd) + projectsCmd.AddCommand(projectsCreateCmd) + projectsCmd.AddCommand(projectsUpdateCmd) + projectsCmd.AddCommand(projectsDeleteCmd) + projectsCmd.AddCommand(projectLimitsCmd) + + projectLimitsCmd.AddCommand(projectLimitsGetCmd) + projectLimitsCmd.AddCommand(projectLimitsUpdateCmd) + + projectsListCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + projectsListCmd.Flags().Int("per-page", 20, "Items per page (default 20)") + projectsListCmd.Flags().Int("page", 1, "Page number (1-based)") + + projectsGetCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + + projectsCreateCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + projectsCreateCmd.Flags().String("name", "", "Project name") + _ = projectsCreateCmd.MarkFlagRequired("name") + + projectsUpdateCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + projectsUpdateCmd.Flags().String("name", "", "New project name") + projectsUpdateCmd.Flags().String("status", "", "New project status: active or archived") + + projectsDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + + projectLimitsGetCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + + projectLimitsUpdateCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + projectLimitsUpdateCmd.Flags().Int64("max-concurrent-invocations", 0, "Maximum concurrent app invocations for this project; set to 0 to remove the cap") + projectLimitsUpdateCmd.Flags().Int64("max-concurrent-sessions", 0, "Maximum concurrent browser sessions for this project; set to 0 to remove the cap") + projectLimitsUpdateCmd.Flags().Int64("max-persistent-sessions", 0, "Maximum persistent browser sessions for this project; set to 0 to remove the cap") + projectLimitsUpdateCmd.Flags().Int64("max-pooled-sessions", 0, "Maximum pooled browser sessions for this project; set to 0 to remove the cap") +} + +func runProjectsList(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + page, _ := cmd.Flags().GetInt("page") + perPage, _ := cmd.Flags().GetInt("per-page") + + projectSvc := client.Projects + limitSvc := client.Projects.Limits + p := ProjectsCmd{projects: &projectSvc, limits: &limitSvc} + return p.List(cmd.Context(), ProjectsListInput{ + Output: output, + Page: page, + PerPage: perPage, + }) +} + +func runProjectsGet(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + + projectSvc := client.Projects + limitSvc := client.Projects.Limits + p := ProjectsCmd{projects: &projectSvc, limits: &limitSvc} + return p.Get(cmd.Context(), ProjectsGetInput{ + ID: args[0], + Output: output, + }) +} + +func runProjectsCreate(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + name, _ := cmd.Flags().GetString("name") + + projectSvc := client.Projects + limitSvc := client.Projects.Limits + p := ProjectsCmd{projects: &projectSvc, limits: &limitSvc} + return p.Create(cmd.Context(), ProjectsCreateInput{ + Name: name, + Output: output, + }) +} + +func runProjectsUpdate(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + name, _ := cmd.Flags().GetString("name") + status, _ := cmd.Flags().GetString("status") + + projectSvc := client.Projects + limitSvc := client.Projects.Limits + p := ProjectsCmd{projects: &projectSvc, limits: &limitSvc} + return p.Update(cmd.Context(), ProjectsUpdateInput{ + ID: args[0], + Name: name, + Status: status, + Output: output, + }) +} + +func runProjectsDelete(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + skipConfirm, _ := cmd.Flags().GetBool("yes") + + projectSvc := client.Projects + limitSvc := client.Projects.Limits + p := ProjectsCmd{projects: &projectSvc, limits: &limitSvc} + return p.Delete(cmd.Context(), ProjectsDeleteInput{ + ID: args[0], + SkipConfirm: skipConfirm, + }) +} + +func runProjectLimitsGet(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + + projectSvc := client.Projects + limitSvc := client.Projects.Limits + p := ProjectsCmd{projects: &projectSvc, limits: &limitSvc} + return p.GetLimits(cmd.Context(), ProjectLimitsGetInput{ + ID: args[0], + Output: output, + }) +} + +func runProjectLimitsUpdate(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + maxConcurrentInvocations, _ := cmd.Flags().GetInt64("max-concurrent-invocations") + maxConcurrentSessions, _ := cmd.Flags().GetInt64("max-concurrent-sessions") + maxPersistentSessions, _ := cmd.Flags().GetInt64("max-persistent-sessions") + maxPooledSessions, _ := cmd.Flags().GetInt64("max-pooled-sessions") + + projectSvc := client.Projects + limitSvc := client.Projects.Limits + p := ProjectsCmd{projects: &projectSvc, limits: &limitSvc} + return p.UpdateLimits(cmd.Context(), ProjectLimitsUpdateInput{ + ID: args[0], + MaxConcurrentInvocations: Int64Flag{ + Set: cmd.Flags().Changed("max-concurrent-invocations"), + Value: maxConcurrentInvocations, + }, + MaxConcurrentSessions: Int64Flag{ + Set: cmd.Flags().Changed("max-concurrent-sessions"), + Value: maxConcurrentSessions, + }, + MaxPersistentSessions: Int64Flag{ + Set: cmd.Flags().Changed("max-persistent-sessions"), + Value: maxPersistentSessions, + }, + MaxPooledSessions: Int64Flag{ + Set: cmd.Flags().Changed("max-pooled-sessions"), + Value: maxPooledSessions, + }, + Output: output, + }) +} diff --git a/cmd/projects_test.go b/cmd/projects_test.go new file mode 100644 index 00000000..05fd24dd --- /dev/null +++ b/cmd/projects_test.go @@ -0,0 +1,138 @@ +package cmd + +import ( + "context" + "fmt" + "testing" + "time" + + "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" + "github.com/stretchr/testify/require" +) + +type FakeProjectsService struct { + NewFunc func(ctx context.Context, body kernel.ProjectNewParams, opts ...option.RequestOption) (*kernel.Project, error) + GetFunc func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.Project, error) + UpdateFunc func(ctx context.Context, id string, body kernel.ProjectUpdateParams, opts ...option.RequestOption) (*kernel.Project, error) + ListFunc func(ctx context.Context, query kernel.ProjectListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Project], error) + DeleteFunc func(ctx context.Context, id string, opts ...option.RequestOption) error +} + +func (f *FakeProjectsService) New(ctx context.Context, body kernel.ProjectNewParams, opts ...option.RequestOption) (*kernel.Project, error) { + if f.NewFunc != nil { + return f.NewFunc(ctx, body, opts...) + } + return &kernel.Project{ID: "proj-new", Name: body.CreateProjectRequest.Name}, nil +} + +func (f *FakeProjectsService) Get(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.Project, error) { + if f.GetFunc != nil { + return f.GetFunc(ctx, id, opts...) + } + return &kernel.Project{ID: id, Name: "project", Status: kernel.ProjectStatusActive}, nil +} + +func (f *FakeProjectsService) Update(ctx context.Context, id string, body kernel.ProjectUpdateParams, opts ...option.RequestOption) (*kernel.Project, error) { + if f.UpdateFunc != nil { + return f.UpdateFunc(ctx, id, body, opts...) + } + return &kernel.Project{ID: id, Name: body.UpdateProjectRequest.Name.Value, Status: kernel.ProjectStatusActive}, nil +} + +func (f *FakeProjectsService) List(ctx context.Context, query kernel.ProjectListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Project], error) { + if f.ListFunc != nil { + return f.ListFunc(ctx, query, opts...) + } + return &pagination.OffsetPagination[kernel.Project]{Items: []kernel.Project{}}, nil +} + +func (f *FakeProjectsService) Delete(ctx context.Context, id string, opts ...option.RequestOption) error { + if f.DeleteFunc != nil { + return f.DeleteFunc(ctx, id, opts...) + } + return nil +} + +type FakeProjectLimitsService struct { + GetFunc func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ProjectLimits, error) + UpdateFunc func(ctx context.Context, id string, body kernel.ProjectLimitUpdateParams, opts ...option.RequestOption) (*kernel.ProjectLimits, error) +} + +func (f *FakeProjectLimitsService) Get(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ProjectLimits, error) { + if f.GetFunc != nil { + return f.GetFunc(ctx, id, opts...) + } + return &kernel.ProjectLimits{}, nil +} + +func (f *FakeProjectLimitsService) Update(ctx context.Context, id string, body kernel.ProjectLimitUpdateParams, opts ...option.RequestOption) (*kernel.ProjectLimits, error) { + if f.UpdateFunc != nil { + return f.UpdateFunc(ctx, id, body, opts...) + } + return &kernel.ProjectLimits{}, nil +} + +func TestProjectsList_HasMore(t *testing.T) { + buf := captureProfilesOutput(t) + created := time.Unix(0, 0) + items := make([]kernel.Project, 3) + for i := range items { + items[i] = kernel.Project{ + ID: fmt.Sprintf("proj-%d", i), + Name: fmt.Sprintf("Project %d", i), + Status: kernel.ProjectStatusActive, + CreatedAt: created, + UpdatedAt: created, + } + } + + fakeProjects := &FakeProjectsService{ + ListFunc: func(ctx context.Context, query kernel.ProjectListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Project], error) { + require.True(t, query.Limit.Valid()) + require.True(t, query.Offset.Valid()) + assert.Equal(t, int64(3), query.Limit.Value) + assert.Equal(t, int64(0), query.Offset.Value) + return &pagination.OffsetPagination[kernel.Project]{Items: items}, nil + }, + } + + p := ProjectsCmd{projects: fakeProjects, limits: &FakeProjectLimitsService{}} + err := p.List(context.Background(), ProjectsListInput{Page: 1, PerPage: 2}) + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "proj-0") + assert.Contains(t, out, "proj-1") + assert.NotContains(t, out, "proj-2") + assert.Contains(t, out, "Has more: yes") + assert.Contains(t, out, "Next: kernel projects list --page 2 --per-page 2") +} + +func TestProjectsUpdateLimits_OnlyChangedFields(t *testing.T) { + fakeLimits := &FakeProjectLimitsService{ + UpdateFunc: func(ctx context.Context, id string, body kernel.ProjectLimitUpdateParams, opts ...option.RequestOption) (*kernel.ProjectLimits, error) { + assert.Equal(t, "proj_123", id) + assert.True(t, body.UpdateProjectLimitsRequest.MaxConcurrentInvocations.Valid()) + assert.Equal(t, int64(15), body.UpdateProjectLimitsRequest.MaxConcurrentInvocations.Value) + assert.False(t, body.UpdateProjectLimitsRequest.MaxConcurrentSessions.Valid()) + assert.False(t, body.UpdateProjectLimitsRequest.MaxPersistentSessions.Valid()) + assert.True(t, body.UpdateProjectLimitsRequest.MaxPooledSessions.Valid()) + assert.Equal(t, int64(0), body.UpdateProjectLimitsRequest.MaxPooledSessions.Value) + + return &kernel.ProjectLimits{ + MaxConcurrentInvocations: 15, + }, nil + }, + } + + p := ProjectsCmd{projects: &FakeProjectsService{}, limits: fakeLimits} + err := p.UpdateLimits(context.Background(), ProjectLimitsUpdateInput{ + ID: "proj_123", + MaxConcurrentInvocations: Int64Flag{Set: true, Value: 15}, + MaxPooledSessions: Int64Flag{Set: true, Value: 0}, + }) + require.NoError(t, err) +} diff --git a/cmd/root.go b/cmd/root.go index 8179d8cd..b8de57a8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -139,6 +139,7 @@ func init() { rootCmd.AddCommand(browsersCmd) rootCmd.AddCommand(browserPoolsCmd) rootCmd.AddCommand(appCmd) + rootCmd.AddCommand(projectsCmd) rootCmd.AddCommand(profilesCmd) rootCmd.AddCommand(proxies.ProxiesCmd) rootCmd.AddCommand(extensionsCmd) diff --git a/go.mod b/go.mod index 5cff256d..8da7e41e 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.47.0 + github.com/kernel/kernel-go-sdk v0.48.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 github.com/samber/lo v1.51.0 diff --git a/go.sum b/go.sum index a2db6387..2b1d6dd9 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.47.0 h1:4bCalC81XACIybgXBxuRGzX7yZ9QnxABG8LSGni7wF8= -github.com/kernel/kernel-go-sdk v0.47.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.48.0 h1:XX1VVs8D5q+rBMkZovXmKAQa94w+6oEJzxBLikfPaxw= +github.com/kernel/kernel-go-sdk v0.48.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= From fe8d7e7adbbd4b8cac5814333e7499cb951829a7 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:34:33 +0000 Subject: [PATCH 05/10] CLI: Update Go SDK to c04f920b6dc5892ae2e2188f57a9df5373caa5cd A full SDK and CLI coverage enumeration found no missing commands or flags, so this updates the dependency to the v0.49.0 SDK release for commit c04f920b6dc5892ae2e2188f57a9df5373caa5cd. Tested: go build ./... Tested: go build -o /tmp/kernel-cli/bin/kernel ./cmd/kernel Made-with: Cursor --- go.mod | 4 ++-- go.sum | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 8da7e41e..4e00a02f 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.48.0 + github.com/kernel/kernel-go-sdk v0.49.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 github.com/samber/lo v1.51.0 @@ -20,6 +20,7 @@ require ( golang.org/x/crypto v0.47.0 golang.org/x/oauth2 v0.30.0 golang.org/x/sync v0.19.0 + golang.org/x/term v0.39.0 ) require ( @@ -55,7 +56,6 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.40.0 // indirect - golang.org/x/term v0.39.0 // indirect golang.org/x/text v0.33.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 2b1d6dd9..27494e3d 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.48.0 h1:XX1VVs8D5q+rBMkZovXmKAQa94w+6oEJzxBLikfPaxw= -github.com/kernel/kernel-go-sdk v0.48.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.49.0 h1:vJxqkbvEKhrGOESTWluHvEd3Y8ReyAc9VuHnF1aGu3k= +github.com/kernel/kernel-go-sdk v0.49.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= From e463928e97293aee80ce2098e14990a43f4f4ba8 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:52:13 +0000 Subject: [PATCH 06/10] CLI: Update Go SDK to c42a036c30ae4d319c5a11e00fe41106ba10ab33 Bump the CLI to kernel-go-sdk v0.50.0 from commit c42a036c30ae4d319c5a11e00fe41106ba10ab33. A full SDK and CLI enumeration found no missing command or flag coverage to add in this update. Tested: go build ./..., go build -o /tmp/kernel-cli/bin/kernel ./cmd/kernel Made-with: Cursor --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 4e00a02f..1aa28f76 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.49.0 + github.com/kernel/kernel-go-sdk v0.50.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 github.com/samber/lo v1.51.0 diff --git a/go.sum b/go.sum index 27494e3d..943c9615 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.49.0 h1:vJxqkbvEKhrGOESTWluHvEd3Y8ReyAc9VuHnF1aGu3k= -github.com/kernel/kernel-go-sdk v0.49.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.50.0 h1:WbfPaB71EdFzHZEt2gLEf1yD9EtK9rpp8ewDGRoYrKw= +github.com/kernel/kernel-go-sdk v0.50.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= From 9aaab96ba4833411b760c27e197fa214aefeaf7a Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:55:41 +0000 Subject: [PATCH 07/10] Update Go SDK to 12b3ec6 and close CLI coverage gaps Bumps kernel-go-sdk from v0.79.0 to v0.83.1-0.20260724213320-12b3ec62f63e (12b3ec6). The branch did not compile before this change: the earlier "Merge main into cli-coverage-update" commit dropped several branch-side lines while keeping their callers. Those are repaired first, then the SDK diff and a full api.md enumeration are covered. Merge repairs (broken on this branch independent of the SDK bump): - cmd/browsers.go: restore parseStringMapFlag, which two call sites used - cmd/browser_pools.go: drop the stale 1-arg parseChromePolicy that collided with the 2-arg version in browsers.go - cmd/browsers.go: restore BrowsersUpdateInput.DisableDefaultProxy and count it in hasProxyChange so --disable-default-proxy works again - cmd/proxies: register the missing --url flag on `proxies check` and actually forward it into ProxyCheckParams - cmd/root.go: drop the duplicate rootCmd.AddCommand(projectsCmd) New flags for SDK params added since v0.79.0: - `profiles download --format tar.zst|tar` for ProfileDownloadParams.Format; extraction now skips zstd when the server already decompressed - `auth connections create/update/login --telemetry` for the new browser_telemetry configs on ManagedAuthCreateRequest, ManagedAuthUpdateRequest, and AuthConnectionLoginParams - `auth connections submit --field-value` and `--choice-id` for the canonical SubmitFieldsRequest.FieldValues / SelectedChoiceID, which supersede the legacy fields/mfa-option/sso selectors - `auth connections get` and `follow` now render canonical Fields and Choices, plus Browser Session ID and Browser Telemetry New commands closing pre-existing api.md gaps: - `auth connections timeline` for Auth.Connections.Timeline, with the page/per-page +1 pagination UX - `profiles update` for Profiles.Update - `projects update` for Projects.Update - `org limits get` / `org limits set` for Organization.Limits.Get/Update A full enumeration of api.md against the CLI now shows every SDK method covered; /auth/connections/{id}/exchange stays out (x-cli-skip). Tested against the live API: profiles create/update/get/delete; profiles download (default tar.zst and --format tar produce identical trees, --format zip rejected); auth connections create --telemetry console,network, update --telemetry all/off, login --telemetry screenshot, get (telemetry + browser session rows), timeline (real login event with browser_session_id, --type login, --type bogus rejected), submit --choice-id and --field-value (payloads accepted by the API); projects update --name by ID and --status archived by name, plus both validation errors; org limits get/set (set 50, verified, restored to no default); proxies check --url; browsers update --disable-default-proxy on stealth and non-stealth browsers. go build, go vet, and go test all pass; new unit tests cover each addition. Co-Authored-By: Claude Opus 5 --- cmd/auth_connections.go | 295 +++++++++++++++++++++++++++++++++-- cmd/auth_connections_test.go | 220 ++++++++++++++++++++++++++ cmd/browser_pools.go | 123 +++++++-------- cmd/browsers.go | 90 +++++++---- cmd/browsers_telemetry.go | 31 ++++ cmd/org.go | 173 ++++++++++++++++++++ cmd/org_test.go | 123 +++++++++++++++ cmd/profiles.go | 92 +++++++++-- cmd/profiles_test.go | 104 +++++++++++- cmd/projects.go | 87 +++++++++++ cmd/projects_test.go | 85 ++++++++++ cmd/proxies/check.go | 7 +- cmd/proxies/proxies.go | 1 + cmd/root.go | 2 +- go.mod | 2 +- go.sum | 4 +- 16 files changed, 1296 insertions(+), 143 deletions(-) create mode 100644 cmd/org.go create mode 100644 cmd/org_test.go diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index 6dfa0ec5..e4bc6866 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -13,6 +13,7 @@ import ( "github.com/kernel/kernel-go-sdk/packages/pagination" "github.com/kernel/kernel-go-sdk/packages/ssestream" "github.com/pterm/pterm" + "github.com/samber/lo" "github.com/spf13/cobra" ) @@ -25,6 +26,7 @@ type AuthConnectionService interface { Delete(ctx context.Context, id string, opts ...option.RequestOption) (err error) Login(ctx context.Context, id string, body kernel.AuthConnectionLoginParams, opts ...option.RequestOption) (res *kernel.LoginResponse, err error) Submit(ctx context.Context, id string, body kernel.AuthConnectionSubmitParams, opts ...option.RequestOption) (res *kernel.SubmitFieldsResponse, err error) + Timeline(ctx context.Context, id string, query kernel.AuthConnectionTimelineParams, opts ...option.RequestOption) (res *pagination.OffsetPagination[kernel.ManagedAuthTimelineEvent], err error) FollowStreaming(ctx context.Context, id string, opts ...option.RequestOption) (stream *ssestream.Stream[kernel.AuthConnectionFollowResponseUnion]) } @@ -48,6 +50,7 @@ type AuthConnectionCreateInput struct { SaveCredentials bool NoSaveCredentials bool HealthCheckInterval int + Telemetry string Output string } @@ -76,6 +79,7 @@ type AuthConnectionUpdateInput struct { SaveCredentials BoolFlag HealthCheckInterval int HealthCheckIntervalSet bool + Telemetry string Output string } @@ -96,12 +100,19 @@ type AuthConnectionLoginInput struct { ID string ProxyID string ProxyName string + Telemetry string Output string } type AuthConnectionSubmitInput struct { - ID string - FieldValues map[string]string + ID string + // FieldValues holds legacy --field name=value pairs, submitted as `fields`. + FieldValues map[string]string + // CanonicalFieldValues holds --field-value id=value pairs, submitted as the + // canonical `field_values` keyed by the field IDs the API returned. + CanonicalFieldValues map[string]string + // SelectedChoiceID is the canonical choice ID from the API's `choices` list. + SelectedChoiceID string MfaOptionID string SignInOptionID string SSOButtonSelector string @@ -109,6 +120,14 @@ type AuthConnectionSubmitInput struct { Output string } +type AuthConnectionTimelineInput struct { + ID string + Type string + Page int + PerPage int + Output string +} + type AuthConnectionFollowInput struct { ID string Output string @@ -181,6 +200,14 @@ func (c AuthConnectionCmd) Create(ctx context.Context, in AuthConnectionCreateIn params.ManagedAuthCreateRequest.SaveCredentials = kernel.Opt(false) } + if in.Telemetry != "" { + t, err := buildAuthConnectionCreateTelemetryParam(in.Telemetry) + if err != nil { + return err + } + params.ManagedAuthCreateRequest.BrowserTelemetry = t + } + if in.Output != "json" { pterm.Info.Printf("Creating managed auth for %s...\n", in.Domain) } @@ -220,6 +247,9 @@ func printManagedAuthSummary(auth *kernel.ManagedAuth) { if auth.ProxyID != "" { tableData = append(tableData, []string{"Proxy ID", auth.ProxyID}) } + if auth.BrowserTelemetry.Enabled || len(telemetryEnabledCategories(kernel.BrowserTelemetryConfig{Browser: auth.BrowserTelemetry.Browser})) > 0 { + tableData = append(tableData, []string{"Browser Telemetry", formatManagedAuthTelemetry(auth.BrowserTelemetry)}) + } PrintTableNoPad(tableData, true) } @@ -283,6 +313,15 @@ func (c AuthConnectionCmd) Update(ctx context.Context, in AuthConnectionUpdateIn hasChanges = true } + if in.Telemetry != "" { + t, err := buildAuthConnectionUpdateTelemetryParam(in.Telemetry) + if err != nil { + return err + } + params.ManagedAuthUpdateRequest.BrowserTelemetry = t + hasChanges = true + } + if !hasChanges { return fmt.Errorf("must provide at least one field to update") } @@ -342,6 +381,47 @@ func (c AuthConnectionCmd) Get(ctx context.Context, in AuthConnectionGetInput) e if auth.FlowStep != "" { tableData = append(tableData, []string{"Flow Step", string(auth.FlowStep)}) } + // Canonical fields/choices supersede discovered_fields, mfa_options and + // pending_sso_buttons. Show them first so the IDs needed by `submit + // --field-value` and `submit --choice-id` are the first thing visible. + if len(auth.Fields) > 0 { + fields := make([]string, 0, len(auth.Fields)) + for _, f := range auth.Fields { + meta := make([]string, 0, 3) + if f.Type != "" { + meta = append(meta, f.Type) + } + if f.Ref != "" { + meta = append(meta, "ref="+f.Ref) + } + if f.Required { + meta = append(meta, "required") + } + entry := f.ID + if f.Label != "" { + entry = fmt.Sprintf("%s (%s)", f.ID, f.Label) + } + if len(meta) > 0 { + entry = fmt.Sprintf("%s [%s]", entry, strings.Join(meta, ", ")) + } + fields = append(fields, entry) + } + tableData = append(tableData, []string{"Fields", strings.Join(fields, "; ")}) + } + if len(auth.Choices) > 0 { + choices := make([]string, 0, len(auth.Choices)) + for _, ch := range auth.Choices { + entry := ch.ID + if ch.Label != "" { + entry = fmt.Sprintf("%s (%s)", ch.ID, ch.Label) + } + if ch.Type != "" { + entry = fmt.Sprintf("%s [%s]", entry, ch.Type) + } + choices = append(choices, entry) + } + tableData = append(tableData, []string{"Choices", strings.Join(choices, "; ")}) + } if len(auth.DiscoveredFields) > 0 { discoveredFields := make([]string, 0, len(auth.DiscoveredFields)) for _, field := range auth.DiscoveredFields { @@ -422,6 +502,12 @@ func (c AuthConnectionCmd) Get(ctx context.Context, in AuthConnectionGetInput) e if auth.HealthCheckInterval > 0 { tableData = append(tableData, []string{"Health Check Interval", fmt.Sprintf("%d seconds", auth.HealthCheckInterval)}) } + if auth.BrowserSessionID != "" { + tableData = append(tableData, []string{"Browser Session ID", auth.BrowserSessionID}) + } + if auth.BrowserTelemetry.Enabled || len(telemetryEnabledCategories(kernel.BrowserTelemetryConfig{Browser: auth.BrowserTelemetry.Browser})) > 0 { + tableData = append(tableData, []string{"Browser Telemetry", formatManagedAuthTelemetry(auth.BrowserTelemetry)}) + } PrintTableNoPad(tableData, true) return nil @@ -533,6 +619,14 @@ func (c AuthConnectionCmd) Login(ctx context.Context, in AuthConnectionLoginInpu } } + if in.Telemetry != "" { + t, err := buildAuthConnectionLoginTelemetryParam(in.Telemetry) + if err != nil { + return err + } + params.BrowserTelemetry = t + } + if in.Output != "json" { pterm.Info.Println("Starting login flow...") } @@ -570,22 +664,25 @@ func (c AuthConnectionCmd) Submit(ctx context.Context, in AuthConnectionSubmitIn // Validate that we have some input to submit hasFields := len(in.FieldValues) > 0 + hasCanonicalFields := len(in.CanonicalFieldValues) > 0 + hasChoice := in.SelectedChoiceID != "" hasMfaOption := in.MfaOptionID != "" hasSignInOption := in.SignInOptionID != "" hasSSOButton := in.SSOButtonSelector != "" hasSSOProvider := in.SSOProvider != "" submitModes := 0 - for _, active := range []bool{hasFields, hasMfaOption, hasSignInOption, hasSSOButton, hasSSOProvider} { + for _, active := range []bool{hasFields, hasCanonicalFields, hasChoice, hasMfaOption, hasSignInOption, hasSSOButton, hasSSOProvider} { if active { submitModes++ } } + const submitModeFlags = "--field-value, --choice-id, --field, --mfa-option-id, --sign-in-option-id, --sso-button-selector, or --sso-provider" if submitModes == 0 { - return fmt.Errorf("must provide exactly one of: --field, --mfa-option-id, --sign-in-option-id, --sso-button-selector, or --sso-provider") + return fmt.Errorf("must provide exactly one of: %s", submitModeFlags) } if submitModes > 1 { - return fmt.Errorf("provide exactly one of: --field, --mfa-option-id, --sign-in-option-id, --sso-button-selector, or --sso-provider") + return fmt.Errorf("provide exactly one of: %s", submitModeFlags) } // Resolve MFA option: the user may pass the label (e.g. "Get a text"), the @@ -620,9 +717,19 @@ func (c AuthConnectionCmd) Submit(ctx context.Context, in AuthConnectionSubmitIn } params := kernel.AuthConnectionSubmitParams{ - SubmitFieldsRequest: kernel.SubmitFieldsRequestParam{ - Fields: in.FieldValues, - }, + SubmitFieldsRequest: kernel.SubmitFieldsRequestParam{}, + } + // Only attach legacy `fields` when it carries values. An empty-but-non-nil map + // still marshals as `"fields": {}`, which the API reads as a second submit mode + // alongside whichever canonical or legacy selector the user actually chose. + if hasFields { + params.SubmitFieldsRequest.Fields = in.FieldValues + } + if hasCanonicalFields { + params.SubmitFieldsRequest.FieldValues = in.CanonicalFieldValues + } + if hasChoice { + params.SubmitFieldsRequest.SelectedChoiceID = kernel.Opt(in.SelectedChoiceID) } if hasMfaOption { params.SubmitFieldsRequest.MfaOptionID = kernel.Opt(in.MfaOptionID) @@ -658,6 +765,95 @@ func (c AuthConnectionCmd) Submit(ctx context.Context, in AuthConnectionSubmitIn return nil } +func (c AuthConnectionCmd) Timeline(ctx context.Context, in AuthConnectionTimelineInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + page := in.Page + perPage := in.PerPage + if page <= 0 { + page = 1 + } + if perPage <= 0 { + perPage = 20 + } + + params := kernel.AuthConnectionTimelineParams{} + if in.Type != "" { + switch in.Type { + case string(kernel.AuthConnectionTimelineParamsTypeLogin), + string(kernel.AuthConnectionTimelineParamsTypeReauth), + string(kernel.AuthConnectionTimelineParamsTypeHealthCheck): + params.Type = kernel.AuthConnectionTimelineParamsType(in.Type) + default: + return fmt.Errorf("invalid --type %q: must be one of login, reauth, health_check", in.Type) + } + } + // Request one extra event so we can report whether another page exists + // without spending a second round trip on the pagination headers. + params.Limit = kernel.Opt(int64(perPage + 1)) + params.Offset = kernel.Opt(int64((page - 1) * perPage)) + + result, err := c.svc.Timeline(ctx, in.ID, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + var events []kernel.ManagedAuthTimelineEvent + if result != nil { + events = result.Items + } + + hasMore := len(events) > perPage + if hasMore { + events = events[:perPage] + } + + if in.Output == "json" { + if len(events) == 0 { + fmt.Println("[]") + return nil + } + return util.PrintPrettyJSONSlice(events) + } + + if len(events) == 0 { + pterm.Info.Println("No timeline events found") + return nil + } + + tableData := pterm.TableData{{"Timestamp", "Type", "Status", "Step", "Browser Session", "Details"}} + for _, e := range events { + details := e.ErrorMessage + if details == "" { + details = e.WebsiteError + } + if details == "" && e.PreviousStatus != "" { + details = fmt.Sprintf("%s -> %s", e.PreviousStatus, e.Status) + } + tableData = append(tableData, []string{ + util.FormatLocal(e.Timestamp), + string(e.Type), + string(e.Status), + string(e.Step), + util.OrDash(e.BrowserSessionID), + util.OrDash(details), + }) + } + PrintTableNoPad(tableData, true) + + pterm.Printf("\nPage: %d Per-page: %d Items this page: %d Has more: %s\n", page, perPage, len(events), lo.Ternary(hasMore, "yes", "no")) + if hasMore { + next := fmt.Sprintf("kernel auth connections timeline %s --page %d --per-page %d", in.ID, page+1, perPage) + if in.Type != "" { + next += fmt.Sprintf(" --type %q", in.Type) + } + pterm.Printf("Next: %s\n", next) + } + return nil +} + func (c AuthConnectionCmd) Follow(ctx context.Context, in AuthConnectionFollowInput) error { if err := validateJSONOutput(in.Output); err != nil { return err @@ -691,6 +887,20 @@ func (c AuthConnectionCmd) Follow(ctx context.Context, in AuthConnectionFollowIn state.Timestamp.Local().Format(time.RFC3339), state.FlowStatus, state.FlowStep) + if len(state.Fields) > 0 { + fieldIDs := make([]string, 0, len(state.Fields)) + for _, f := range state.Fields { + fieldIDs = append(fieldIDs, f.ID) + } + pterm.Info.Printf(" Fields: %s\n", strings.Join(fieldIDs, ", ")) + } + if len(state.Choices) > 0 { + choiceIDs := make([]string, 0, len(state.Choices)) + for _, ch := range state.Choices { + choiceIDs = append(choiceIDs, ch.ID) + } + pterm.Info.Printf(" Choices: %s\n", strings.Join(choiceIDs, ", ")) + } if len(state.DiscoveredFields) > 0 { var fieldNames []string for _, f := range state.DiscoveredFields { @@ -801,6 +1011,14 @@ var authConnectionsFollowCmd = &cobra.Command{ RunE: runAuthConnectionsFollow, } +var authConnectionsTimelineCmd = &cobra.Command{ + Use: "timeline ", + Short: "List login, reauth, and health-check events", + Long: "List the managed auth connection's history of login, reauth, and health-check events, most recent first.", + Args: cobra.ExactArgs(1), + RunE: runAuthConnectionsTimeline, +} + func init() { // Create flags addJSONOutputFlag(authConnectionsCreateCmd) @@ -816,6 +1034,7 @@ func init() { authConnectionsCreateCmd.Flags().String("proxy-name", "", "Proxy name to use") authConnectionsCreateCmd.Flags().Bool("no-save-credentials", false, "Disable saving credentials after successful login") authConnectionsCreateCmd.Flags().Int("health-check-interval", 0, "Interval in seconds between health checks (300-86400)") + authConnectionsCreateCmd.Flags().String("telemetry", "", "Configure telemetry for this connection's browser sessions (opt-in): --telemetry=all (default set), --telemetry=off (disable), or --telemetry=console,network (capture exactly those categories)") _ = authConnectionsCreateCmd.MarkFlagRequired("domain") _ = authConnectionsCreateCmd.MarkFlagRequired("profile-name") authConnectionsCreateCmd.MarkFlagsMutuallyExclusive("credential-name", "credential-provider") @@ -836,6 +1055,7 @@ func init() { authConnectionsUpdateCmd.Flags().Bool("save-credentials", false, "Enable saving credentials after successful login") authConnectionsUpdateCmd.Flags().Bool("no-save-credentials", false, "Disable saving credentials after successful login") authConnectionsUpdateCmd.Flags().Int("health-check-interval", 0, "Interval in seconds between health checks") + authConnectionsUpdateCmd.Flags().String("telemetry", "", "Update telemetry for future browser sessions: --telemetry=all (reset to default set), --telemetry=off (disable), or --telemetry=console,network (merge those categories into the current selection)") authConnectionsUpdateCmd.MarkFlagsMutuallyExclusive("credential-name", "credential-provider") authConnectionsUpdateCmd.MarkFlagsMutuallyExclusive("save-credentials", "no-save-credentials") @@ -853,10 +1073,13 @@ func init() { addJSONOutputFlag(authConnectionsLoginCmd) authConnectionsLoginCmd.Flags().String("proxy-id", "", "Proxy ID to use for this login") authConnectionsLoginCmd.Flags().String("proxy-name", "", "Proxy name to use for this login") + authConnectionsLoginCmd.Flags().String("telemetry", "", "Telemetry override for this login only, merged onto the connection's config: --telemetry=all, --telemetry=off, or --telemetry=console,network") // Submit flags addJSONOutputFlag(authConnectionsSubmitCmd) - authConnectionsSubmitCmd.Flags().StringArray("field", []string{}, "Field name=value pair (repeatable)") + authConnectionsSubmitCmd.Flags().StringArray("field-value", []string{}, "Canonical field-id=value pair from the connection's `fields` list (repeatable)") + authConnectionsSubmitCmd.Flags().String("choice-id", "", "Canonical choice ID from the connection's `choices` list") + authConnectionsSubmitCmd.Flags().StringArray("field", []string{}, "Legacy field name=value pair (repeatable); prefer --field-value") authConnectionsSubmitCmd.Flags().String("mfa-option-id", "", "MFA option ID if user selected an MFA method") authConnectionsSubmitCmd.Flags().String("sign-in-option-id", "", "Sign-in option ID if the flow returned non-MFA choices") authConnectionsSubmitCmd.Flags().String("sso-button-selector", "", "XPath selector if user chose an SSO button") @@ -865,6 +1088,12 @@ func init() { // Follow flags addJSONOutputFlag(authConnectionsFollowCmd) + // Timeline flags + addJSONOutputFlag(authConnectionsTimelineCmd) + authConnectionsTimelineCmd.Flags().String("type", "", "Filter to a single event type: login, reauth, or health_check") + authConnectionsTimelineCmd.Flags().Int("page", 1, "Page number (1-based)") + authConnectionsTimelineCmd.Flags().Int("per-page", 20, "Items per page (default 20)") + // Wire up commands authConnectionsCmd.AddCommand(authConnectionsCreateCmd) authConnectionsCmd.AddCommand(authConnectionsUpdateCmd) @@ -874,6 +1103,7 @@ func init() { authConnectionsCmd.AddCommand(authConnectionsLoginCmd) authConnectionsCmd.AddCommand(authConnectionsSubmitCmd) authConnectionsCmd.AddCommand(authConnectionsFollowCmd) + authConnectionsCmd.AddCommand(authConnectionsTimelineCmd) authCmd.AddCommand(authConnectionsCmd) } @@ -893,6 +1123,7 @@ func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { proxyName, _ := cmd.Flags().GetString("proxy-name") noSaveCredentials, _ := cmd.Flags().GetBool("no-save-credentials") healthCheckInterval, _ := cmd.Flags().GetInt("health-check-interval") + telemetry, _ := cmd.Flags().GetString("telemetry") svc := client.Auth.Connections c := AuthConnectionCmd{svc: &svc} @@ -909,6 +1140,7 @@ func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { ProxyName: proxyName, NoSaveCredentials: noSaveCredentials, HealthCheckInterval: healthCheckInterval, + Telemetry: telemetry, Output: output, }) } @@ -939,6 +1171,7 @@ func runAuthConnectionsUpdate(cmd *cobra.Command, args []string) error { saveCredentials, _ := cmd.Flags().GetBool("save-credentials") noSaveCredentials, _ := cmd.Flags().GetBool("no-save-credentials") healthCheckInterval, _ := cmd.Flags().GetInt("health-check-interval") + telemetry, _ := cmd.Flags().GetString("telemetry") saveCredentialsFlag := BoolFlag{} if cmd.Flags().Changed("save-credentials") { @@ -970,6 +1203,7 @@ func runAuthConnectionsUpdate(cmd *cobra.Command, args []string) error { SaveCredentials: saveCredentialsFlag, HealthCheckInterval: healthCheckInterval, HealthCheckIntervalSet: cmd.Flags().Changed("health-check-interval"), + Telemetry: telemetry, Output: output, }) } @@ -1010,6 +1244,7 @@ func runAuthConnectionsLogin(cmd *cobra.Command, args []string) error { output, _ := cmd.Flags().GetString("output") proxyID, _ := cmd.Flags().GetString("proxy-id") proxyName, _ := cmd.Flags().GetString("proxy-name") + telemetry, _ := cmd.Flags().GetString("telemetry") svc := client.Auth.Connections c := AuthConnectionCmd{svc: &svc} @@ -1017,6 +1252,7 @@ func runAuthConnectionsLogin(cmd *cobra.Command, args []string) error { ID: args[0], ProxyID: proxyID, ProxyName: proxyName, + Telemetry: telemetry, Output: output, }) } @@ -1025,6 +1261,8 @@ func runAuthConnectionsSubmit(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) output, _ := cmd.Flags().GetString("output") fieldPairs, _ := cmd.Flags().GetStringArray("field") + canonicalFieldPairs, _ := cmd.Flags().GetStringArray("field-value") + choiceID, _ := cmd.Flags().GetString("choice-id") mfaOptionID, _ := cmd.Flags().GetString("mfa-option-id") signInOptionID, _ := cmd.Flags().GetString("sign-in-option-id") ssoButtonSelector, _ := cmd.Flags().GetString("sso-button-selector") @@ -1040,16 +1278,23 @@ func runAuthConnectionsSubmit(cmd *cobra.Command, args []string) error { fieldValues[parts[0]] = parts[1] } + canonicalFieldValues, err := parseStringMapFlag(canonicalFieldPairs, "--field-value") + if err != nil { + return err + } + svc := client.Auth.Connections c := AuthConnectionCmd{svc: &svc} return c.Submit(cmd.Context(), AuthConnectionSubmitInput{ - ID: args[0], - FieldValues: fieldValues, - MfaOptionID: mfaOptionID, - SignInOptionID: signInOptionID, - SSOButtonSelector: ssoButtonSelector, - SSOProvider: ssoProvider, - Output: output, + ID: args[0], + FieldValues: fieldValues, + CanonicalFieldValues: canonicalFieldValues, + SelectedChoiceID: choiceID, + MfaOptionID: mfaOptionID, + SignInOptionID: signInOptionID, + SSOButtonSelector: ssoButtonSelector, + SSOProvider: ssoProvider, + Output: output, }) } @@ -1064,3 +1309,21 @@ func runAuthConnectionsFollow(cmd *cobra.Command, args []string) error { Output: output, }) } + +func runAuthConnectionsTimeline(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + eventType, _ := cmd.Flags().GetString("type") + page, _ := cmd.Flags().GetInt("page") + perPage, _ := cmd.Flags().GetInt("per-page") + + svc := client.Auth.Connections + c := AuthConnectionCmd{svc: &svc} + return c.Timeline(cmd.Context(), AuthConnectionTimelineInput{ + ID: args[0], + Type: eventType, + Page: page, + PerPage: perPage, + Output: output, + }) +} diff --git a/cmd/auth_connections_test.go b/cmd/auth_connections_test.go index 96bb1315..947286e1 100644 --- a/cmd/auth_connections_test.go +++ b/cmd/auth_connections_test.go @@ -25,9 +25,17 @@ type FakeAuthConnectionService struct { DeleteFunc func(ctx context.Context, id string, opts ...option.RequestOption) error LoginFunc func(ctx context.Context, id string, body kernel.AuthConnectionLoginParams, opts ...option.RequestOption) (*kernel.LoginResponse, error) SubmitFunc func(ctx context.Context, id string, body kernel.AuthConnectionSubmitParams, opts ...option.RequestOption) (*kernel.SubmitFieldsResponse, error) + TimelineFunc func(ctx context.Context, id string, query kernel.AuthConnectionTimelineParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.ManagedAuthTimelineEvent], error) FollowStreamingFunc func(ctx context.Context, id string, opts ...option.RequestOption) *ssestream.Stream[kernel.AuthConnectionFollowResponseUnion] } +func (f *FakeAuthConnectionService) Timeline(ctx context.Context, id string, query kernel.AuthConnectionTimelineParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.ManagedAuthTimelineEvent], error) { + if f.TimelineFunc != nil { + return f.TimelineFunc(ctx, id, query, opts...) + } + return &pagination.OffsetPagination[kernel.ManagedAuthTimelineEvent]{}, nil +} + func (f *FakeAuthConnectionService) New(ctx context.Context, body kernel.AuthConnectionNewParams, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { if f.NewFunc != nil { return f.NewFunc(ctx, body, opts...) @@ -543,3 +551,215 @@ func TestSubmit_MfaOptionRejectsUnknown(t *testing.T) { assert.Contains(t, err.Error(), "carrier pigeon") assert.Contains(t, err.Error(), "Get a text (sms)") } + +func TestCreate_TelemetryCategoriesOptIn(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionNewParams + fake := &FakeAuthConnectionService{ + NewFunc: func(ctx context.Context, body kernel.AuthConnectionNewParams, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + captured = body + return &kernel.ManagedAuth{ID: "auth_1"}, nil + }, + } + c := AuthConnectionCmd{svc: fake} + err := c.Create(context.Background(), AuthConnectionCreateInput{ + Domain: "example.com", + ProfileName: "prof", + Telemetry: "console,network", + }) + require.NoError(t, err) + + tel := captured.ManagedAuthCreateRequest.BrowserTelemetry + assert.False(t, tel.Enabled.Valid()) + assert.True(t, tel.Browser.Console.Enabled.Value) + assert.True(t, tel.Browser.Network.Enabled.Value) + assert.False(t, tel.Browser.Page.Enabled.Valid()) +} + +func TestCreate_TelemetryOff(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionNewParams + fake := &FakeAuthConnectionService{ + NewFunc: func(ctx context.Context, body kernel.AuthConnectionNewParams, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + captured = body + return &kernel.ManagedAuth{ID: "auth_1"}, nil + }, + } + c := AuthConnectionCmd{svc: fake} + require.NoError(t, c.Create(context.Background(), AuthConnectionCreateInput{ + Domain: "example.com", ProfileName: "prof", Telemetry: "off", + })) + + tel := captured.ManagedAuthCreateRequest.BrowserTelemetry + require.True(t, tel.Enabled.Valid()) + assert.False(t, tel.Enabled.Value) +} + +func TestCreate_TelemetryUnknownCategoryErrors(t *testing.T) { + capturePtermOutput(t) + c := AuthConnectionCmd{svc: &FakeAuthConnectionService{}} + err := c.Create(context.Background(), AuthConnectionCreateInput{ + Domain: "example.com", ProfileName: "prof", Telemetry: "bogus", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown category") +} + +func TestUpdate_TelemetryCountsAsChange(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionUpdateParams + fake := &FakeAuthConnectionService{ + UpdateFunc: func(ctx context.Context, id string, body kernel.AuthConnectionUpdateParams, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + captured = body + return &kernel.ManagedAuth{ID: id}, nil + }, + } + c := AuthConnectionCmd{svc: fake} + // --telemetry alone must satisfy the "at least one field" guard. + require.NoError(t, c.Update(context.Background(), AuthConnectionUpdateInput{ID: "auth_1", Telemetry: "all"})) + + tel := captured.ManagedAuthUpdateRequest.BrowserTelemetry + require.True(t, tel.Enabled.Valid()) + assert.True(t, tel.Enabled.Value) +} + +func TestLogin_TelemetryOverride(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionLoginParams + fake := &FakeAuthConnectionService{ + LoginFunc: func(ctx context.Context, id string, body kernel.AuthConnectionLoginParams, opts ...option.RequestOption) (*kernel.LoginResponse, error) { + captured = body + return &kernel.LoginResponse{ID: id}, nil + }, + } + c := AuthConnectionCmd{svc: fake} + require.NoError(t, c.Login(context.Background(), AuthConnectionLoginInput{ID: "auth_1", Telemetry: "screenshot"})) + assert.True(t, captured.BrowserTelemetry.Browser.Screenshot.Enabled.Value) +} + +func TestSubmit_CanonicalChoiceID(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionSubmitParams + fake := &FakeAuthConnectionService{ + SubmitFunc: func(ctx context.Context, id string, body kernel.AuthConnectionSubmitParams, opts ...option.RequestOption) (*kernel.SubmitFieldsResponse, error) { + captured = body + return &kernel.SubmitFieldsResponse{Accepted: true}, nil + }, + } + c := AuthConnectionCmd{svc: fake} + require.NoError(t, c.Submit(context.Background(), AuthConnectionSubmitInput{ + ID: "auth_1", + SelectedChoiceID: "choice_sms", + })) + require.True(t, captured.SubmitFieldsRequest.SelectedChoiceID.Valid()) + assert.Equal(t, "choice_sms", captured.SubmitFieldsRequest.SelectedChoiceID.Value) + // Legacy fields must stay absent so the API sees exactly one submit mode. + assert.Nil(t, captured.SubmitFieldsRequest.Fields) +} + +func TestSubmit_CanonicalFieldValues(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionSubmitParams + fake := &FakeAuthConnectionService{ + SubmitFunc: func(ctx context.Context, id string, body kernel.AuthConnectionSubmitParams, opts ...option.RequestOption) (*kernel.SubmitFieldsResponse, error) { + captured = body + return &kernel.SubmitFieldsResponse{Accepted: true}, nil + }, + } + c := AuthConnectionCmd{svc: fake} + require.NoError(t, c.Submit(context.Background(), AuthConnectionSubmitInput{ + ID: "auth_1", + CanonicalFieldValues: map[string]string{"field_email": "me@example.com"}, + })) + assert.Equal(t, map[string]string{"field_email": "me@example.com"}, captured.SubmitFieldsRequest.FieldValues) + assert.Nil(t, captured.SubmitFieldsRequest.Fields) +} + +func TestSubmit_CanonicalAndLegacyAreMutuallyExclusive(t *testing.T) { + capturePtermOutput(t) + c := AuthConnectionCmd{svc: &FakeAuthConnectionService{}} + err := c.Submit(context.Background(), AuthConnectionSubmitInput{ + ID: "auth_1", + FieldValues: map[string]string{"email": "a@b.com"}, + CanonicalFieldValues: map[string]string{"field_email": "a@b.com"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "provide exactly one of") +} + +func TestTimeline_RendersEventsAndPagination(t *testing.T) { + buf := capturePtermOutput(t) + var captured kernel.AuthConnectionTimelineParams + fake := &FakeAuthConnectionService{ + TimelineFunc: func(ctx context.Context, id string, query kernel.AuthConnectionTimelineParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.ManagedAuthTimelineEvent], error) { + captured = query + // Return perPage+1 events so the CLI reports another page exists. + return &pagination.OffsetPagination[kernel.ManagedAuthTimelineEvent]{ + Items: []kernel.ManagedAuthTimelineEvent{ + {ID: "e1", Type: "login", Status: "SUCCESS", BrowserSessionID: "browser_1"}, + {ID: "e2", Type: "reauth", Status: "FAILED", ErrorMessage: "boom"}, + {ID: "e3", Type: "health_check", Status: "SUCCESS"}, + }, + }, nil + }, + } + c := AuthConnectionCmd{svc: fake} + require.NoError(t, c.Timeline(context.Background(), AuthConnectionTimelineInput{ID: "auth_1", Page: 1, PerPage: 2})) + + // The +1 trick: request one more than a page to detect hasMore. + require.True(t, captured.Limit.Valid()) + assert.Equal(t, int64(3), captured.Limit.Value) + assert.Equal(t, int64(0), captured.Offset.Value) + + out := buf.String() + assert.Contains(t, out, "browser_1") + assert.Contains(t, out, "boom") + // The third event is truncated off the page. + assert.NotContains(t, out, "health_check") + assert.Contains(t, out, "Has more: yes") + assert.Contains(t, out, "kernel auth connections timeline auth_1 --page 2 --per-page 2") +} + +func TestTimeline_TypeFilterValidated(t *testing.T) { + capturePtermOutput(t) + c := AuthConnectionCmd{svc: &FakeAuthConnectionService{}} + err := c.Timeline(context.Background(), AuthConnectionTimelineInput{ID: "auth_1", Type: "bogus"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --type") + + var captured kernel.AuthConnectionTimelineParams + fake := &FakeAuthConnectionService{ + TimelineFunc: func(ctx context.Context, id string, query kernel.AuthConnectionTimelineParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.ManagedAuthTimelineEvent], error) { + captured = query + return &pagination.OffsetPagination[kernel.ManagedAuthTimelineEvent]{}, nil + }, + } + c = AuthConnectionCmd{svc: fake} + require.NoError(t, c.Timeline(context.Background(), AuthConnectionTimelineInput{ID: "auth_1", Type: "health_check"})) + assert.Equal(t, kernel.AuthConnectionTimelineParamsTypeHealthCheck, captured.Type) +} + +func TestGet_ShowsCanonicalFieldsAndChoices(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeAuthConnectionService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + return &kernel.ManagedAuth{ + ID: id, + Domain: "example.com", + Fields: []kernel.ManagedAuthField{ + {ID: "field_email", Ref: "email", Type: "identifier", Label: "Email", Required: true}, + }, + Choices: []kernel.ManagedAuthChoice{ + {ID: "choice_sms", Label: "Text me", Type: "mfa_method"}, + }, + }, nil + }, + } + c := AuthConnectionCmd{svc: fake} + require.NoError(t, c.Get(context.Background(), AuthConnectionGetInput{ID: "auth_1"})) + + out := buf.String() + assert.Contains(t, out, "field_email") + assert.Contains(t, out, "ref=email") + assert.Contains(t, out, "choice_sms") +} diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index 767a2852..ca753bda 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -128,15 +128,15 @@ type BrowserPoolsCreateInput struct { Kiosk BoolFlag RefreshOnProfileUpdate BoolFlag ProfileID string - ProfileName string - ProxyID string - StartURL string - Extensions []string - Viewport string - ChromePolicy string - ChromePolicyFile string - Telemetry string - Output string + ProfileName string + ProxyID string + StartURL string + Extensions []string + Viewport string + ChromePolicy string + ChromePolicyFile string + Telemetry string + Output string } func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) error { @@ -298,17 +298,17 @@ type BrowserPoolsUpdateInput struct { Kiosk BoolFlag RefreshOnProfileUpdate BoolFlag ProfileID string - ProfileName string - ProxyID string - StartURL string - ClearStartURL bool - Extensions []string - Viewport string - ChromePolicy string - ChromePolicyFile string - Telemetry string - DiscardAllIdle BoolFlag - Output string + ProfileName string + ProxyID string + StartURL string + ClearStartURL bool + Extensions []string + Viewport string + ChromePolicy string + ChromePolicyFile string + Telemetry string + DiscardAllIdle BoolFlag + Output string } func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) error { @@ -735,24 +735,24 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { output, _ := cmd.Flags().GetString("output") in := BrowserPoolsCreateInput{ - Name: name, - Size: size, - FillRate: fillRate, - TimeoutSeconds: timeout, - Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealth}, - Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headless}, + Name: name, + Size: size, + FillRate: fillRate, + TimeoutSeconds: timeout, + Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealth}, + Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headless}, Kiosk: BoolFlag{Set: cmd.Flags().Changed("kiosk"), Value: kiosk}, RefreshOnProfileUpdate: BoolFlag{Set: cmd.Flags().Changed("refresh-on-profile-update"), Value: refreshOnProfileUpdate}, ProfileID: profileID, - ProfileName: profileName, - ProxyID: proxyID, - StartURL: startURL, - Extensions: extensions, - Viewport: viewport, - ChromePolicy: chromePolicy, - ChromePolicyFile: chromePolicyFile, - Telemetry: telemetry, - Output: output, + ProfileName: profileName, + ProxyID: proxyID, + StartURL: startURL, + Extensions: extensions, + Viewport: viewport, + ChromePolicy: chromePolicy, + ChromePolicyFile: chromePolicyFile, + Telemetry: telemetry, + Output: output, } c := BrowserPoolsCmd{client: &client.BrowserPools} @@ -791,27 +791,27 @@ func runBrowserPoolsUpdate(cmd *cobra.Command, args []string) error { output, _ := cmd.Flags().GetString("output") in := BrowserPoolsUpdateInput{ - IDOrName: args[0], - Name: name, - Size: size, - FillRate: fillRate, - TimeoutSeconds: timeout, - Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealth}, - Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headless}, + IDOrName: args[0], + Name: name, + Size: size, + FillRate: fillRate, + TimeoutSeconds: timeout, + Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealth}, + Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headless}, Kiosk: BoolFlag{Set: cmd.Flags().Changed("kiosk"), Value: kiosk}, RefreshOnProfileUpdate: BoolFlag{Set: cmd.Flags().Changed("refresh-on-profile-update"), Value: refreshOnProfileUpdate}, ProfileID: profileID, - ProfileName: profileName, - ProxyID: proxyID, - StartURL: startURL, - ClearStartURL: clearStartURL, - Extensions: extensions, - Viewport: viewport, - ChromePolicy: chromePolicy, - ChromePolicyFile: chromePolicyFile, - Telemetry: telemetry, - DiscardAllIdle: BoolFlag{Set: cmd.Flags().Changed("discard-all-idle"), Value: discardIdle}, - Output: output, + ProfileName: profileName, + ProxyID: proxyID, + StartURL: startURL, + ClearStartURL: clearStartURL, + Extensions: extensions, + Viewport: viewport, + ChromePolicy: chromePolicy, + ChromePolicyFile: chromePolicyFile, + Telemetry: telemetry, + DiscardAllIdle: BoolFlag{Set: cmd.Flags().Changed("discard-all-idle"), Value: discardIdle}, + Output: output, } c := BrowserPoolsCmd{client: &client.BrowserPools} @@ -904,23 +904,6 @@ func buildExtensionsParam(extensions []string) []kernel.BrowserExtensionParam { return result } -func parseChromePolicy(raw string) (map[string]any, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return nil, nil - } - - var policy map[string]any - if err := json.Unmarshal([]byte(raw), &policy); err != nil { - return nil, fmt.Errorf("invalid --chrome-policy JSON: %w", err) - } - if policy == nil { - return nil, fmt.Errorf("--chrome-policy must be a JSON object") - } - - return policy, nil -} - func buildViewportParam(viewport string) (*kernel.BrowserViewportParam, error) { if viewport == "" { return nil, nil diff --git a/cmd/browsers.go b/cmd/browsers.go index 4c71c8c1..218ba7b3 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -162,6 +162,26 @@ func parseViewport(viewport string) (width, height, refreshRate int64, err error return w, h, refreshRate, nil } +// parseStringMapFlag parses repeated KEY=value flag values into a map. It returns a nil +// map when no values were given, so callers can distinguish "flag absent" from "flag set +// to an empty map". +func parseStringMapFlag(values []string, flagName string) (map[string]string, error) { + if len(values) == 0 { + return nil, nil + } + + parsed := make(map[string]string, len(values)) + for _, pair := range values { + parts := strings.SplitN(pair, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid %s value: %s (expected KEY=value)", flagName, pair) + } + parsed[parts[0]] = parts[1] + } + + return parsed, nil +} + // parseChromePolicy resolves the --chrome-policy / --chrome-policy-file inputs into a // custom Chrome enterprise policy object. The two inputs are mutually exclusive (enforced // by cobra); a file path of "-" reads stdin. It returns a nil map when neither input is @@ -293,22 +313,23 @@ type BrowsersGetInput struct { } type BrowsersUpdateInput struct { - Identifier string - ProxyID string - ClearProxy bool - ProfileID string - ProfileName string - ProfileSaveChanges BoolFlag - Viewport string - Force bool - Telemetry string - Name string - SetName bool - ClearName bool - Tags map[string]string - TagsProvided bool - ClearTags bool - Output string + Identifier string + ProxyID string + ClearProxy bool + DisableDefaultProxy BoolFlag + ProfileID string + ProfileName string + ProfileSaveChanges BoolFlag + Viewport string + Force bool + Telemetry string + Name string + SetName bool + ClearName bool + Tags map[string]string + TagsProvided bool + ClearTags bool + Output string } // BrowsersCmd is a cobra-independent command handler for browsers operations. @@ -732,7 +753,7 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { return fmt.Errorf("--name requires a non-empty value; use --clear-name to clear the name") } - hasProxyChange := in.ProxyID != "" || in.ClearProxy + hasProxyChange := in.ProxyID != "" || in.ClearProxy || in.DisableDefaultProxy.Set hasProfileChange := in.ProfileID != "" || in.ProfileName != "" hasViewportChange := in.Viewport != "" // By this point a set name is guaranteed non-empty (the guard above rejects --name ""). @@ -751,7 +772,7 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { // Validate that at least one update option is provided if !hasProxyChange && !hasProfileChange && !hasViewportChange && in.Telemetry == "" && !hasNameChange && !hasTagsChange { - return fmt.Errorf("must specify at least one of: --proxy-id, --clear-proxy, --profile-id, --profile-name, --viewport, --telemetry, --name, --clear-name, --tag, or --clear-tags") + return fmt.Errorf("must specify at least one of: --proxy-id, --clear-proxy, --disable-default-proxy, --profile-id, --profile-name, --viewport, --telemetry, --name, --clear-name, --tag, or --clear-tags") } params := kernel.BrowserUpdateParams{} @@ -3063,22 +3084,23 @@ func runBrowsersUpdate(cmd *cobra.Command, args []string) error { svc := client.Browsers b := BrowsersCmd{browsers: &svc} return b.Update(cmd.Context(), BrowsersUpdateInput{ - Identifier: args[0], - ProxyID: proxyID, - ClearProxy: clearProxy, - ProfileID: profileID, - ProfileName: profileName, - ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges}, - Viewport: viewport, - Force: force, - Telemetry: telemetry, - Name: name, - SetName: cmd.Flags().Changed("name"), - ClearName: clearName, - Tags: tags, - TagsProvided: tagsProvided, - ClearTags: clearTags, - Output: out, + Identifier: args[0], + ProxyID: proxyID, + ClearProxy: clearProxy, + DisableDefaultProxy: BoolFlag{Set: cmd.Flags().Changed("disable-default-proxy"), Value: disableDefaultProxy}, + ProfileID: profileID, + ProfileName: profileName, + ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges}, + Viewport: viewport, + Force: force, + Telemetry: telemetry, + Name: name, + SetName: cmd.Flags().Changed("name"), + ClearName: clearName, + Tags: tags, + TagsProvided: tagsProvided, + ClearTags: clearTags, + Output: out, }) } diff --git a/cmd/browsers_telemetry.go b/cmd/browsers_telemetry.go index 276b8a4e..940ccbce 100644 --- a/cmd/browsers_telemetry.go +++ b/cmd/browsers_telemetry.go @@ -119,6 +119,37 @@ func buildUpdateTelemetryParam(s string) (kernel.BrowserUpdateParamsTelemetry, e return kernel.BrowserUpdateParamsTelemetry{Enabled: enabled, Browser: browser}, err } +// buildAuthConnectionCreateTelemetryParam converts a --telemetry flag value to the +// browser telemetry default stored on a new auth connection. +func buildAuthConnectionCreateTelemetryParam(s string) (kernel.ManagedAuthCreateRequestBrowserTelemetryParam, error) { + enabled, browser, err := resolveTelemetryFlag(s) + return kernel.ManagedAuthCreateRequestBrowserTelemetryParam{Enabled: enabled, Browser: browser}, err +} + +// buildAuthConnectionUpdateTelemetryParam converts a --telemetry flag value to the +// browser telemetry default for future sessions of an existing auth connection. +func buildAuthConnectionUpdateTelemetryParam(s string) (kernel.ManagedAuthUpdateRequestBrowserTelemetryParam, error) { + enabled, browser, err := resolveTelemetryFlag(s) + return kernel.ManagedAuthUpdateRequestBrowserTelemetryParam{Enabled: enabled, Browser: browser}, err +} + +// buildAuthConnectionLoginTelemetryParam converts a --telemetry flag value to the +// per-login browser telemetry override. +func buildAuthConnectionLoginTelemetryParam(s string) (kernel.AuthConnectionLoginParamsBrowserTelemetry, error) { + enabled, browser, err := resolveTelemetryFlag(s) + return kernel.AuthConnectionLoginParamsBrowserTelemetry{Enabled: enabled, Browser: browser}, err +} + +// formatManagedAuthTelemetry renders an auth connection's default browser telemetry +// config for the details table. +func formatManagedAuthTelemetry(cfg kernel.ManagedAuthBrowserTelemetry) string { + on := telemetryEnabledCategories(kernel.BrowserTelemetryConfig{Browser: cfg.Browser}) + if len(on) == 0 { + return "disabled" + } + return strings.Join(on, ", ") +} + // settableCategories are the categories accepted by --telemetry=. // The monitor category is not settable: it is collector-health metadata that // flows automatically whenever a CDP category is captured. diff --git a/cmd/org.go b/cmd/org.go new file mode 100644 index 00000000..1d8c107b --- /dev/null +++ b/cmd/org.go @@ -0,0 +1,173 @@ +package cmd + +import ( + "context" + "fmt" + + "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/param" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +// OrgLimitsService defines the subset of the Kernel SDK organization limits client that we use. +type OrgLimitsService interface { + Get(ctx context.Context, opts ...option.RequestOption) (res *kernel.OrgLimits, err error) + Update(ctx context.Context, body kernel.OrganizationLimitUpdateParams, opts ...option.RequestOption) (res *kernel.OrgLimits, err error) +} + +type OrgCmd struct { + limits OrgLimitsService +} + +type OrgLimitsGetInput struct { + Output string +} + +type OrgLimitsSetInput struct { + DefaultProjectMaxConcurrentSessions Int64Flag + Output string +} + +func (c OrgCmd) LimitsGet(ctx context.Context, in OrgLimitsGetInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + limits, err := c.limits.Get(ctx) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + if limits == nil { + fmt.Println("null") + return nil + } + return util.PrintPrettyJSON(limits) + } + + renderOrgLimits(limits) + return nil +} + +func (c OrgCmd) LimitsSet(ctx context.Context, in OrgLimitsSetInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + if !in.DefaultProjectMaxConcurrentSessions.Set { + return fmt.Errorf("must provide --default-project-max-concurrent-sessions") + } + if in.DefaultProjectMaxConcurrentSessions.Value < 0 { + return fmt.Errorf("--default-project-max-concurrent-sessions must be non-negative (got %d); use 0 to remove the default", in.DefaultProjectMaxConcurrentSessions.Value) + } + + limits, err := c.limits.Update(ctx, kernel.OrganizationLimitUpdateParams{ + UpdateOrgLimitsRequest: kernel.UpdateOrgLimitsRequestParam{ + DefaultProjectMaxConcurrentSessions: param.NewOpt(in.DefaultProjectMaxConcurrentSessions.Value), + }, + }) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + if limits == nil { + fmt.Println("null") + return nil + } + return util.PrintPrettyJSON(limits) + } + + pterm.Success.Println("Organization limits updated:") + renderOrgLimits(limits) + return nil +} + +func renderOrgLimits(limits *kernel.OrgLimits) { + if limits == nil { + pterm.Info.Println("No organization limits found") + return + } + + rows := pterm.TableData{ + {"Limit", "Value"}, + // max_concurrent_sessions is read-only and always present; only the + // per-project default is nullable, so reuse the "unlimited" rendering. + {"Max Concurrent Sessions", fmt.Sprintf("%d", limits.MaxConcurrentSessions)}, + {"Default Project Max Concurrent Sessions", formatProjectLimitValue(limits.DefaultProjectMaxConcurrentSessions, limits.JSON.DefaultProjectMaxConcurrentSessions)}, + } + PrintTableNoPad(rows, true) +} + +// --- Cobra wiring --- + +var orgCmd = &cobra.Command{ + Use: "org", + Aliases: []string{"organization"}, + Short: "Manage organization-wide settings", + Run: func(cmd *cobra.Command, args []string) { + _ = cmd.Help() + }, +} + +var orgLimitsCmd = &cobra.Command{ + Use: "limits", + Short: "Manage organization concurrency limits", + Run: func(cmd *cobra.Command, args []string) { + _ = cmd.Help() + }, +} + +var orgLimitsGetCmd = &cobra.Command{ + Use: "get", + Short: "Get organization concurrency limits", + Long: "Show the organization's concurrency limit and the default per-project cap applied to projects without an explicit override.", + Args: cobra.NoArgs, + RunE: runOrgLimitsGet, +} + +var orgLimitsSetCmd = &cobra.Command{ + Use: "set", + Short: "Set the default per-project concurrency cap", + Long: "Set the default per-project concurrency cap applied to projects without an explicit override. Use 0 to remove the default. The default cannot exceed the organization's concurrency limit.", + Args: cobra.NoArgs, + RunE: runOrgLimitsSet, +} + +func getOrgHandler(cmd *cobra.Command) OrgCmd { + client := getKernelClient(cmd) + return OrgCmd{limits: &client.Organization.Limits} +} + +func runOrgLimitsGet(cmd *cobra.Command, args []string) error { + c := getOrgHandler(cmd) + output, _ := cmd.Flags().GetString("output") + return c.LimitsGet(cmd.Context(), OrgLimitsGetInput{Output: output}) +} + +func runOrgLimitsSet(cmd *cobra.Command, args []string) error { + c := getOrgHandler(cmd) + defaultMax, _ := cmd.Flags().GetInt64("default-project-max-concurrent-sessions") + output, _ := cmd.Flags().GetString("output") + return c.LimitsSet(cmd.Context(), OrgLimitsSetInput{ + DefaultProjectMaxConcurrentSessions: Int64Flag{ + Set: cmd.Flags().Changed("default-project-max-concurrent-sessions"), + Value: defaultMax, + }, + Output: output, + }) +} + +func init() { + addJSONOutputFlag(orgLimitsGetCmd) + orgLimitsSetCmd.Flags().Int64("default-project-max-concurrent-sessions", 0, "Default maximum concurrent browsers for projects without an explicit override (0 to remove the default)") + addJSONOutputFlag(orgLimitsSetCmd) + + orgLimitsCmd.AddCommand(orgLimitsGetCmd) + orgLimitsCmd.AddCommand(orgLimitsSetCmd) + orgCmd.AddCommand(orgLimitsCmd) +} diff --git a/cmd/org_test.go b/cmd/org_test.go new file mode 100644 index 00000000..42f3a496 --- /dev/null +++ b/cmd/org_test.go @@ -0,0 +1,123 @@ +package cmd + +import ( + "context" + "errors" + "testing" + + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/respjson" + "github.com/stretchr/testify/assert" +) + +type FakeOrgLimitsService struct { + GetFunc func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgLimits, error) + UpdateFunc func(ctx context.Context, body kernel.OrganizationLimitUpdateParams, opts ...option.RequestOption) (*kernel.OrgLimits, error) +} + +func (f *FakeOrgLimitsService) Get(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgLimits, error) { + if f.GetFunc != nil { + return f.GetFunc(ctx, opts...) + } + return &kernel.OrgLimits{MaxConcurrentSessions: 100}, nil +} + +func (f *FakeOrgLimitsService) Update(ctx context.Context, body kernel.OrganizationLimitUpdateParams, opts ...option.RequestOption) (*kernel.OrgLimits, error) { + if f.UpdateFunc != nil { + return f.UpdateFunc(ctx, body, opts...) + } + return &kernel.OrgLimits{MaxConcurrentSessions: 100}, nil +} + +func TestOrgLimitsGet_RendersLimits(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeOrgLimitsService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgLimits, error) { + limits := &kernel.OrgLimits{MaxConcurrentSessions: 100, DefaultProjectMaxConcurrentSessions: 25} + limits.JSON.DefaultProjectMaxConcurrentSessions = respjson.NewField("25") + return limits, nil + }, + } + c := OrgCmd{limits: fake} + assert.NoError(t, c.LimitsGet(context.Background(), OrgLimitsGetInput{})) + + out := buf.String() + assert.Contains(t, out, "Max Concurrent Sessions") + assert.Contains(t, out, "100") + assert.Contains(t, out, "25") +} + +func TestOrgLimitsGet_NullDefaultShownAsUnlimited(t *testing.T) { + buf := capturePtermOutput(t) + c := OrgCmd{limits: &FakeOrgLimitsService{}} + assert.NoError(t, c.LimitsGet(context.Background(), OrgLimitsGetInput{})) + assert.Contains(t, buf.String(), "unlimited") +} + +func TestOrgLimitsGet_SurfacesAPIError(t *testing.T) { + capturePtermOutput(t) + fake := &FakeOrgLimitsService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgLimits, error) { + return nil, errors.New("boom") + }, + } + c := OrgCmd{limits: fake} + assert.Error(t, c.LimitsGet(context.Background(), OrgLimitsGetInput{})) +} + +func TestOrgLimitsSet_SendsDefault(t *testing.T) { + buf := capturePtermOutput(t) + var captured kernel.OrganizationLimitUpdateParams + fake := &FakeOrgLimitsService{ + UpdateFunc: func(ctx context.Context, body kernel.OrganizationLimitUpdateParams, opts ...option.RequestOption) (*kernel.OrgLimits, error) { + captured = body + limits := &kernel.OrgLimits{MaxConcurrentSessions: 100, DefaultProjectMaxConcurrentSessions: 50} + limits.JSON.DefaultProjectMaxConcurrentSessions = respjson.NewField("50") + return limits, nil + }, + } + c := OrgCmd{limits: fake} + assert.NoError(t, c.LimitsSet(context.Background(), OrgLimitsSetInput{ + DefaultProjectMaxConcurrentSessions: Int64Flag{Set: true, Value: 50}, + })) + + req := captured.UpdateOrgLimitsRequest + assert.True(t, req.DefaultProjectMaxConcurrentSessions.Valid()) + assert.Equal(t, int64(50), req.DefaultProjectMaxConcurrentSessions.Value) + assert.Contains(t, buf.String(), "Organization limits updated") +} + +func TestOrgLimitsSet_ZeroRemovesDefault(t *testing.T) { + capturePtermOutput(t) + var captured kernel.OrganizationLimitUpdateParams + fake := &FakeOrgLimitsService{ + UpdateFunc: func(ctx context.Context, body kernel.OrganizationLimitUpdateParams, opts ...option.RequestOption) (*kernel.OrgLimits, error) { + captured = body + return &kernel.OrgLimits{MaxConcurrentSessions: 100}, nil + }, + } + c := OrgCmd{limits: fake} + assert.NoError(t, c.LimitsSet(context.Background(), OrgLimitsSetInput{ + DefaultProjectMaxConcurrentSessions: Int64Flag{Set: true, Value: 0}, + })) + // 0 must still be sent explicitly — it is how the default gets removed. + assert.True(t, captured.UpdateOrgLimitsRequest.DefaultProjectMaxConcurrentSessions.Valid()) + assert.Equal(t, int64(0), captured.UpdateOrgLimitsRequest.DefaultProjectMaxConcurrentSessions.Value) +} + +func TestOrgLimitsSet_RequiresFlag(t *testing.T) { + c := OrgCmd{limits: &FakeOrgLimitsService{}} + err := c.LimitsSet(context.Background(), OrgLimitsSetInput{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "must provide --default-project-max-concurrent-sessions") +} + +func TestOrgLimitsSet_RejectsNegative(t *testing.T) { + c := OrgCmd{limits: &FakeOrgLimitsService{}} + err := c.LimitsSet(context.Background(), OrgLimitsSetInput{ + DefaultProjectMaxConcurrentSessions: Int64Flag{Set: true, Value: -1}, + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "must be non-negative") +} diff --git a/cmd/profiles.go b/cmd/profiles.go index b4b86437..1820ad14 100644 --- a/cmd/profiles.go +++ b/cmd/profiles.go @@ -28,7 +28,8 @@ type ProfilesService interface { List(ctx context.Context, query kernel.ProfileListParams, opts ...option.RequestOption) (res *pagination.OffsetPagination[kernel.Profile], err error) Delete(ctx context.Context, idOrName string, opts ...option.RequestOption) (err error) New(ctx context.Context, body kernel.ProfileNewParams, opts ...option.RequestOption) (res *kernel.Profile, err error) - Download(ctx context.Context, idOrName string, opts ...option.RequestOption) (res *http.Response, err error) + Update(ctx context.Context, idOrName string, body kernel.ProfileUpdateParams, opts ...option.RequestOption) (res *kernel.Profile, err error) + Download(ctx context.Context, idOrName string, query kernel.ProfileDownloadParams, opts ...option.RequestOption) (res *http.Response, err error) } type ProfilesGetInput struct { @@ -48,6 +49,12 @@ type ProfilesCreateInput struct { Output string } +type ProfilesUpdateInput struct { + Identifier string + Name string + Output string +} + type ProfilesDeleteInput struct { Identifier string SkipConfirm bool @@ -56,6 +63,7 @@ type ProfilesDeleteInput struct { type ProfilesDownloadInput struct { Identifier string To string + Format string } // ProfilesCmd handles profile operations independent of cobra. @@ -253,12 +261,45 @@ func (p ProfilesCmd) Delete(ctx context.Context, in ProfilesDeleteInput) error { return nil } +func (p ProfilesCmd) Update(ctx context.Context, in ProfilesUpdateInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + if strings.TrimSpace(in.Name) == "" { + return fmt.Errorf("--name is required") + } + + profile, err := p.profiles.Update(ctx, in.Identifier, kernel.ProfileUpdateParams{ + Name: in.Name, + }) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(profile) + } + + pterm.Success.Printf("Renamed profile %s to %s\n", profile.ID, profile.Name) + return nil +} + func (p ProfilesCmd) Download(ctx context.Context, in ProfilesDownloadInput) error { if in.To == "" { return fmt.Errorf("missing required --to for extraction directory") } - res, err := p.profiles.Download(ctx, in.Identifier) + format := in.Format + if format == "" { + format = string(kernel.ProfileDownloadParamsFormatTarZst) + } + if format != string(kernel.ProfileDownloadParamsFormatTarZst) && format != string(kernel.ProfileDownloadParamsFormatTar) { + return fmt.Errorf("invalid --format %q: must be one of tar.zst, tar", in.Format) + } + + res, err := p.profiles.Download(ctx, in.Identifier, kernel.ProfileDownloadParams{ + Format: kernel.ProfileDownloadParamsFormat(format), + }) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -275,7 +316,7 @@ func (p ProfilesCmd) Download(ctx context.Context, in ProfilesDownloadInput) err return fmt.Errorf("unexpected status %d from profile download: %s", res.StatusCode, strings.TrimSpace(string(body))) } - if err := extractProfileArchive(res.Body, in.To); err != nil { + if err := extractProfileArchive(res.Body, in.To, format == string(kernel.ProfileDownloadParamsFormatTarZst)); err != nil { return fmt.Errorf("extract profile archive: %w", err) } @@ -283,10 +324,11 @@ func (p ProfilesCmd) Download(ctx context.Context, in ProfilesDownloadInput) err return nil } -// extractProfileArchive streams a zstd-compressed tar archive into destDir. +// extractProfileArchive streams a tar archive into destDir, decompressing it +// first when compressed is true (the server's default tar.zst format). // Files and directories are created relative to destDir; symlinks and other // special entry types are skipped. Path-traversal entries are rejected. -func extractProfileArchive(r io.Reader, destDir string) error { +func extractProfileArchive(r io.Reader, destDir string, compressed bool) error { if err := os.MkdirAll(destDir, 0o755); err != nil { return fmt.Errorf("create destination: %w", err) } @@ -296,13 +338,16 @@ func extractProfileArchive(r io.Reader, destDir string) error { return fmt.Errorf("resolve destination: %w", err) } - decoder, err := zstd.NewReader(r) - if err != nil { - return fmt.Errorf("zstd init: %w", err) + if compressed { + decoder, err := zstd.NewReader(r) + if err != nil { + return fmt.Errorf("zstd init: %w", err) + } + defer decoder.Close() + r = decoder } - defer decoder.Close() - tr := tar.NewReader(decoder) + tr := tar.NewReader(r) for { header, err := tr.Next() if errors.Is(err, io.EOF) { @@ -372,6 +417,14 @@ var profilesCreateCmd = &cobra.Command{ RunE: runProfilesCreate, } +var profilesUpdateCmd = &cobra.Command{ + Use: "update --name ", + Short: "Rename a profile", + Long: "Rename a profile. The new name must be unique within the project.", + Args: cobra.ExactArgs(1), + RunE: runProfilesUpdate, +} + var profilesDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete a profile by ID or name", @@ -382,7 +435,7 @@ var profilesDeleteCmd = &cobra.Command{ var profilesDownloadCmd = &cobra.Command{ Use: "download --to ", Short: "Download a profile and extract it to a directory", - Long: "Download a profile and extract its zstd-compressed user-data tar archive into the directory given by --to. The directory is created if it does not exist.", + Long: "Download a profile and extract its user-data tar archive into the directory given by --to. The directory is created if it does not exist. Archives are zstd-compressed by default; pass --format tar to have the server decompress them during download.", Args: cobra.ExactArgs(1), RunE: runProfilesDownload, } @@ -391,6 +444,7 @@ func init() { profilesCmd.AddCommand(profilesListCmd) profilesCmd.AddCommand(profilesGetCmd) profilesCmd.AddCommand(profilesCreateCmd) + profilesCmd.AddCommand(profilesUpdateCmd) profilesCmd.AddCommand(profilesDeleteCmd) profilesCmd.AddCommand(profilesDownloadCmd) @@ -401,8 +455,12 @@ func init() { addJSONOutputFlag(profilesGetCmd) addJSONOutputFlag(profilesCreateCmd) profilesCreateCmd.Flags().String("name", "", "Optional unique profile name") + addJSONOutputFlag(profilesUpdateCmd) + profilesUpdateCmd.Flags().String("name", "", "New unique profile name (required)") + _ = profilesUpdateCmd.MarkFlagRequired("name") profilesDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") profilesDownloadCmd.Flags().String("to", "", "Directory to extract the profile into (required)") + profilesDownloadCmd.Flags().String("format", "tar.zst", "Archive format to request: tar.zst (compressed, default) or tar (decompressed server-side)") _ = profilesDownloadCmd.MarkFlagRequired("to") } @@ -448,10 +506,20 @@ func runProfilesDelete(cmd *cobra.Command, args []string) error { return p.Delete(cmd.Context(), ProfilesDeleteInput{Identifier: args[0], SkipConfirm: skip}) } +func runProfilesUpdate(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + name, _ := cmd.Flags().GetString("name") + out, _ := cmd.Flags().GetString("output") + svc := client.Profiles + p := ProfilesCmd{profiles: &svc} + return p.Update(cmd.Context(), ProfilesUpdateInput{Identifier: args[0], Name: name, Output: out}) +} + func runProfilesDownload(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) to, _ := cmd.Flags().GetString("to") + format, _ := cmd.Flags().GetString("format") svc := client.Profiles p := ProfilesCmd{profiles: &svc} - return p.Download(cmd.Context(), ProfilesDownloadInput{Identifier: args[0], To: to}) + return p.Download(cmd.Context(), ProfilesDownloadInput{Identifier: args[0], To: to, Format: format}) } diff --git a/cmd/profiles_test.go b/cmd/profiles_test.go index d1043845..35467493 100644 --- a/cmd/profiles_test.go +++ b/cmd/profiles_test.go @@ -27,7 +27,15 @@ type FakeProfilesService struct { ListFunc func(ctx context.Context, query kernel.ProfileListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Profile], error) DeleteFunc func(ctx context.Context, idOrName string, opts ...option.RequestOption) error NewFunc func(ctx context.Context, body kernel.ProfileNewParams, opts ...option.RequestOption) (*kernel.Profile, error) - DownloadFunc func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*http.Response, error) + UpdateFunc func(ctx context.Context, idOrName string, body kernel.ProfileUpdateParams, opts ...option.RequestOption) (*kernel.Profile, error) + DownloadFunc func(ctx context.Context, idOrName string, query kernel.ProfileDownloadParams, opts ...option.RequestOption) (*http.Response, error) +} + +func (f *FakeProfilesService) Update(ctx context.Context, idOrName string, body kernel.ProfileUpdateParams, opts ...option.RequestOption) (*kernel.Profile, error) { + if f.UpdateFunc != nil { + return f.UpdateFunc(ctx, idOrName, body, opts...) + } + return &kernel.Profile{ID: idOrName, Name: body.Name}, nil } func (f *FakeProfilesService) Get(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.Profile, error) { @@ -48,9 +56,9 @@ func (f *FakeProfilesService) Delete(ctx context.Context, idOrName string, opts } return nil } -func (f *FakeProfilesService) Download(ctx context.Context, idOrName string, opts ...option.RequestOption) (*http.Response, error) { +func (f *FakeProfilesService) Download(ctx context.Context, idOrName string, query kernel.ProfileDownloadParams, opts ...option.RequestOption) (*http.Response, error) { if f.DownloadFunc != nil { - return f.DownloadFunc(ctx, idOrName, opts...) + return f.DownloadFunc(ctx, idOrName, query, opts...) } return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("")), Header: http.Header{}}, nil } @@ -239,7 +247,7 @@ func TestProfilesDownload_ExtractSuccess(t *testing.T) { "Default/Preferences": "{\"k\":1}", "Local State": "local", }) - fake := &FakeProfilesService{DownloadFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*http.Response, error) { + fake := &FakeProfilesService{DownloadFunc: func(ctx context.Context, idOrName string, query kernel.ProfileDownloadParams, opts ...option.RequestOption) (*http.Response, error) { return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(archive)), Header: http.Header{}}, nil }} p := ProfilesCmd{profiles: fake} @@ -263,7 +271,7 @@ func TestProfilesDownload_202NoData(t *testing.T) { assert.NoError(t, err) defer os.RemoveAll(dir) - fake := &FakeProfilesService{DownloadFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*http.Response, error) { + fake := &FakeProfilesService{DownloadFunc: func(ctx context.Context, idOrName string, query kernel.ProfileDownloadParams, opts ...option.RequestOption) (*http.Response, error) { return &http.Response{StatusCode: http.StatusAccepted, Body: io.NopCloser(strings.NewReader("")), Header: http.Header{}}, nil }} p := ProfilesCmd{profiles: fake} @@ -283,7 +291,7 @@ func TestProfilesDownload_PathTraversalRejected(t *testing.T) { archive := makeProfileArchive(t, map[string]string{ "../escape": "nope", }) - fake := &FakeProfilesService{DownloadFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*http.Response, error) { + fake := &FakeProfilesService{DownloadFunc: func(ctx context.Context, idOrName string, query kernel.ProfileDownloadParams, opts ...option.RequestOption) (*http.Response, error) { return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(archive)), Header: http.Header{}}, nil }} p := ProfilesCmd{profiles: fake} @@ -291,3 +299,87 @@ func TestProfilesDownload_PathTraversalRejected(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "illegal entry path") } + +// makeUncompressedProfileArchive builds a plain (uncompressed) tar archive, as +// returned by the download endpoint when --format tar is requested. +func makeUncompressedProfileArchive(t *testing.T, files map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for name, content := range files { + hdr := &tar.Header{Name: name, Mode: 0o644, Size: int64(len(content)), Typeflag: tar.TypeReg} + assert.NoError(t, tw.WriteHeader(hdr)) + _, err := tw.Write([]byte(content)) + assert.NoError(t, err) + } + assert.NoError(t, tw.Close()) + return buf.Bytes() +} + +func TestProfilesDownload_DefaultsToTarZstFormat(t *testing.T) { + capturePtermOutput(t) + dir, err := os.MkdirTemp("", "profile-*") + assert.NoError(t, err) + defer os.RemoveAll(dir) + + var captured kernel.ProfileDownloadParams + archive := makeProfileArchive(t, map[string]string{"Local State": "local"}) + fake := &FakeProfilesService{DownloadFunc: func(ctx context.Context, idOrName string, query kernel.ProfileDownloadParams, opts ...option.RequestOption) (*http.Response, error) { + captured = query + return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(archive)), Header: http.Header{}}, nil + }} + p := ProfilesCmd{profiles: fake} + assert.NoError(t, p.Download(context.Background(), ProfilesDownloadInput{Identifier: "p1", To: dir})) + assert.Equal(t, kernel.ProfileDownloadParamsFormatTarZst, captured.Format) +} + +func TestProfilesDownload_TarFormatSkipsDecompression(t *testing.T) { + capturePtermOutput(t) + dir, err := os.MkdirTemp("", "profile-*") + assert.NoError(t, err) + defer os.RemoveAll(dir) + + var captured kernel.ProfileDownloadParams + archive := makeUncompressedProfileArchive(t, map[string]string{"Local State": "local"}) + fake := &FakeProfilesService{DownloadFunc: func(ctx context.Context, idOrName string, query kernel.ProfileDownloadParams, opts ...option.RequestOption) (*http.Response, error) { + captured = query + return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(archive)), Header: http.Header{}}, nil + }} + p := ProfilesCmd{profiles: fake} + assert.NoError(t, p.Download(context.Background(), ProfilesDownloadInput{Identifier: "p1", To: dir, Format: "tar"})) + assert.Equal(t, kernel.ProfileDownloadParamsFormatTar, captured.Format) + + b, readErr := os.ReadFile(filepath.Join(dir, "Local State")) + assert.NoError(t, readErr) + assert.Equal(t, "local", string(b)) +} + +func TestProfilesDownload_RejectsUnknownFormat(t *testing.T) { + p := ProfilesCmd{profiles: &FakeProfilesService{}} + err := p.Download(context.Background(), ProfilesDownloadInput{Identifier: "p1", To: "/tmp", Format: "zip"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid --format") +} + +func TestProfilesUpdate_RenamesProfile(t *testing.T) { + buf := capturePtermOutput(t) + var capturedID string + var capturedBody kernel.ProfileUpdateParams + fake := &FakeProfilesService{UpdateFunc: func(ctx context.Context, idOrName string, body kernel.ProfileUpdateParams, opts ...option.RequestOption) (*kernel.Profile, error) { + capturedID = idOrName + capturedBody = body + return &kernel.Profile{ID: "prof_1", Name: body.Name}, nil + }} + p := ProfilesCmd{profiles: fake} + assert.NoError(t, p.Update(context.Background(), ProfilesUpdateInput{Identifier: "old-name", Name: "new-name"})) + assert.Equal(t, "old-name", capturedID) + assert.Equal(t, "new-name", capturedBody.Name) + assert.Contains(t, buf.String(), "Renamed profile prof_1 to new-name") +} + +func TestProfilesUpdate_RequiresName(t *testing.T) { + p := ProfilesCmd{profiles: &FakeProfilesService{}} + err := p.Update(context.Background(), ProfilesUpdateInput{Identifier: "p1", Name: " "}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "--name is required") +} diff --git a/cmd/projects.go b/cmd/projects.go index 7baf195f..38a702af 100644 --- a/cmd/projects.go +++ b/cmd/projects.go @@ -22,6 +22,7 @@ type ProjectsService interface { ProjectListService New(ctx context.Context, body kernel.ProjectNewParams, opts ...option.RequestOption) (res *kernel.Project, err error) Get(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.Project, err error) + Update(ctx context.Context, id string, body kernel.ProjectUpdateParams, opts ...option.RequestOption) (res *kernel.Project, err error) Delete(ctx context.Context, id string, opts ...option.RequestOption) (err error) } @@ -45,6 +46,14 @@ type ProjectsGetInput struct { Identifier string } +type ProjectsUpdateInput struct { + Identifier string + Name string + NameSet bool + Status string + Output string +} + type ProjectsDeleteInput struct { Identifier string } @@ -131,6 +140,58 @@ func (c ProjectsCmd) Get(ctx context.Context, in ProjectsGetInput) error { return nil } +func (c ProjectsCmd) Update(ctx context.Context, in ProjectsUpdateInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + inner := kernel.UpdateProjectRequestParam{} + if in.NameSet { + inner.Name = param.NewOpt(in.Name) + } + if in.Status != "" { + switch in.Status { + case string(kernel.UpdateProjectRequestStatusActive), string(kernel.UpdateProjectRequestStatusArchived): + inner.Status = kernel.UpdateProjectRequestStatus(in.Status) + default: + return fmt.Errorf("invalid --status %q: must be one of active, archived", in.Status) + } + } + if !in.NameSet && in.Status == "" { + return fmt.Errorf("must provide at least one of --name or --status") + } + + // The PATCH endpoint takes an ID only, so resolve names client-side the way + // delete and the limits endpoints do. + projectID, err := resolveProjectArg(ctx, c.projects, in.Identifier) + if err != nil { + return err + } + + project, err := c.projects.Update(ctx, projectID, kernel.ProjectUpdateParams{ + UpdateProjectRequest: inner, + }) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(project) + } + + pterm.Success.Printf("Updated project: %s (ID: %s)\n", project.Name, project.ID) + table := pterm.TableData{ + {"Field", "Value"}, + {"ID", project.ID}, + {"Name", project.Name}, + {"Status", string(project.Status)}, + {"Created At", util.FormatLocal(project.CreatedAt)}, + {"Updated At", util.FormatLocal(project.UpdatedAt)}, + } + PrintTableNoPad(table, true) + return nil +} + func (c ProjectsCmd) Delete(ctx context.Context, in ProjectsDeleteInput) error { projectID, err := resolveProjectArg(ctx, c.projects, in.Identifier) if err != nil { @@ -271,6 +332,20 @@ func runProjectsGet(cmd *cobra.Command, args []string) error { return c.Get(cmd.Context(), ProjectsGetInput{Identifier: args[0]}) } +func runProjectsUpdate(cmd *cobra.Command, args []string) error { + c := getProjectsHandler(cmd) + name, _ := cmd.Flags().GetString("name") + status, _ := cmd.Flags().GetString("status") + output, _ := cmd.Flags().GetString("output") + return c.Update(cmd.Context(), ProjectsUpdateInput{ + Identifier: args[0], + Name: name, + NameSet: cmd.Flags().Changed("name"), + Status: status, + Output: output, + }) +} + func runProjectsDelete(cmd *cobra.Command, args []string) error { c := getProjectsHandler(cmd) return c.Delete(cmd.Context(), ProjectsDeleteInput{Identifier: args[0]}) @@ -345,6 +420,13 @@ var projectsGetCmd = &cobra.Command{ RunE: runProjectsGet, } +var projectsUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a project's name or status", + Args: cobra.ExactArgs(1), + RunE: runProjectsUpdate, +} + var projectsDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete a project", @@ -391,6 +473,10 @@ var projectsSetLimitsCompatCmd = &cobra.Command{ } func init() { + projectsUpdateCmd.Flags().String("name", "", "New project name (1-255 characters)") + projectsUpdateCmd.Flags().String("status", "", "New project status: active or archived") + addJSONOutputFlag(projectsUpdateCmd) + addJSONOutputFlag(projectsLimitsGetCmd) addProjectsLimitsSetFlags(projectsLimitsSetCmd) addJSONOutputFlag(projectsGetLimitsCompatCmd) @@ -402,6 +488,7 @@ func init() { projectsCmd.AddCommand(projectsListCmd) projectsCmd.AddCommand(projectsCreateCmd) projectsCmd.AddCommand(projectsGetCmd) + projectsCmd.AddCommand(projectsUpdateCmd) projectsCmd.AddCommand(projectsDeleteCmd) projectsCmd.AddCommand(projectsLimitsCmd) projectsCmd.AddCommand(projectsGetLimitsCompatCmd) diff --git a/cmd/projects_test.go b/cmd/projects_test.go index 3855cdf1..8cc2de5f 100644 --- a/cmd/projects_test.go +++ b/cmd/projects_test.go @@ -16,9 +16,17 @@ type FakeProjectsService struct { ListFunc func(ctx context.Context, query kernel.ProjectListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Project], error) NewFunc func(ctx context.Context, body kernel.ProjectNewParams, opts ...option.RequestOption) (*kernel.Project, error) GetFunc func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.Project, error) + UpdateFunc func(ctx context.Context, id string, body kernel.ProjectUpdateParams, opts ...option.RequestOption) (*kernel.Project, error) DeleteFunc func(ctx context.Context, id string, opts ...option.RequestOption) error } +func (f *FakeProjectsService) Update(ctx context.Context, id string, body kernel.ProjectUpdateParams, opts ...option.RequestOption) (*kernel.Project, error) { + if f.UpdateFunc != nil { + return f.UpdateFunc(ctx, id, body, opts...) + } + return &kernel.Project{ID: id}, nil +} + func (f *FakeProjectsService) List(ctx context.Context, query kernel.ProjectListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Project], error) { if f.ListFunc != nil { return f.ListFunc(ctx, query, opts...) @@ -194,3 +202,80 @@ func TestResolveProjectByName_PaginatesAcrossResults(t *testing.T) { assert.Equal(t, "proj_target", id) assert.Equal(t, []int64{0, 100}, seenOffsets) } + +func TestProjectsUpdate_RenamesByID(t *testing.T) { + buf := capturePtermOutput(t) + var capturedID string + var capturedBody kernel.ProjectUpdateParams + fakeProjects := &FakeProjectsService{ + UpdateFunc: func(ctx context.Context, id string, body kernel.ProjectUpdateParams, opts ...option.RequestOption) (*kernel.Project, error) { + capturedID = id + capturedBody = body + return &kernel.Project{ID: id, Name: "renamed", Status: kernel.ProjectStatusActive}, nil + }, + } + c := ProjectsCmd{projects: fakeProjects, limits: &FakeProjectLimitsService{}} + err := c.Update(context.Background(), ProjectsUpdateInput{ + Identifier: "cm5xk8n2p0000abcdefghijk", + Name: "renamed", + NameSet: true, + }) + assert.NoError(t, err) + assert.Equal(t, "cm5xk8n2p0000abcdefghijk", capturedID) + assert.True(t, capturedBody.UpdateProjectRequest.Name.Valid()) + assert.Equal(t, "renamed", capturedBody.UpdateProjectRequest.Name.Value) + assert.Contains(t, buf.String(), "Updated project: renamed") +} + +func TestProjectsUpdate_ResolvesNameToID(t *testing.T) { + capturePtermOutput(t) + var capturedID string + fakeProjects := &FakeProjectsService{ + ListFunc: func(ctx context.Context, query kernel.ProjectListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Project], error) { + return &pagination.OffsetPagination[kernel.Project]{ + Items: []kernel.Project{{ID: "proj_target", Name: "my-project"}}, + }, nil + }, + UpdateFunc: func(ctx context.Context, id string, body kernel.ProjectUpdateParams, opts ...option.RequestOption) (*kernel.Project, error) { + capturedID = id + return &kernel.Project{ID: id, Name: "my-project", Status: kernel.ProjectStatusArchived}, nil + }, + } + c := ProjectsCmd{projects: fakeProjects, limits: &FakeProjectLimitsService{}} + err := c.Update(context.Background(), ProjectsUpdateInput{Identifier: "my-project", Status: "archived"}) + assert.NoError(t, err) + assert.Equal(t, "proj_target", capturedID) +} + +func TestProjectsUpdate_SetsStatus(t *testing.T) { + capturePtermOutput(t) + var capturedBody kernel.ProjectUpdateParams + fakeProjects := &FakeProjectsService{ + UpdateFunc: func(ctx context.Context, id string, body kernel.ProjectUpdateParams, opts ...option.RequestOption) (*kernel.Project, error) { + capturedBody = body + return &kernel.Project{ID: id, Status: kernel.ProjectStatusArchived}, nil + }, + } + c := ProjectsCmd{projects: fakeProjects, limits: &FakeProjectLimitsService{}} + err := c.Update(context.Background(), ProjectsUpdateInput{ + Identifier: "cm5xk8n2p0000abcdefghijk", + Status: "archived", + }) + assert.NoError(t, err) + assert.Equal(t, kernel.UpdateProjectRequestStatusArchived, capturedBody.UpdateProjectRequest.Status) + assert.False(t, capturedBody.UpdateProjectRequest.Name.Valid()) +} + +func TestProjectsUpdate_RejectsUnknownStatus(t *testing.T) { + c := ProjectsCmd{projects: &FakeProjectsService{}, limits: &FakeProjectLimitsService{}} + err := c.Update(context.Background(), ProjectsUpdateInput{Identifier: "p1", Status: "deleted"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid --status") +} + +func TestProjectsUpdate_RequiresAtLeastOneField(t *testing.T) { + c := ProjectsCmd{projects: &FakeProjectsService{}, limits: &FakeProjectLimitsService{}} + err := c.Update(context.Background(), ProjectsUpdateInput{Identifier: "p1"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "at least one of --name or --status") +} diff --git a/cmd/proxies/check.go b/cmd/proxies/check.go index 7ef31c22..6c6657e2 100644 --- a/cmd/proxies/check.go +++ b/cmd/proxies/check.go @@ -20,7 +20,12 @@ func (p ProxyCmd) Check(ctx context.Context, in ProxyCheckInput) error { pterm.Info.Printf("Running health check on proxy %s...\n", in.ID) } - proxy, err := p.proxies.Check(ctx, in.ID, kernel.ProxyCheckParams{}) + params := kernel.ProxyCheckParams{} + if in.URL != "" { + params.URL = kernel.Opt(in.URL) + } + + proxy, err := p.proxies.Check(ctx, in.ID, params) if err != nil { return util.CleanedUpSdkError{Err: err} } diff --git a/cmd/proxies/proxies.go b/cmd/proxies/proxies.go index 8d46ecbf..a2909076 100644 --- a/cmd/proxies/proxies.go +++ b/cmd/proxies/proxies.go @@ -113,5 +113,6 @@ func init() { proxiesDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") // Check flags + proxiesCheckCmd.Flags().String("url", "", "Optional public HTTP or HTTPS URL to test reachability against") addJSONOutputFlag(proxiesCheckCmd) } diff --git a/cmd/root.go b/cmd/root.go index ea20fe45..aae35062 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -163,12 +163,12 @@ func init() { rootCmd.AddCommand(browserPoolsCmd) rootCmd.AddCommand(appCmd) rootCmd.AddCommand(projectsCmd) + rootCmd.AddCommand(orgCmd) rootCmd.AddCommand(profilesCmd) rootCmd.AddCommand(proxies.ProxiesCmd) rootCmd.AddCommand(extensionsCmd) rootCmd.AddCommand(credentialsCmd) rootCmd.AddCommand(credentialProvidersCmd) - rootCmd.AddCommand(projectsCmd) rootCmd.AddCommand(createCmd) rootCmd.AddCommand(mcp.MCPCmd) rootCmd.AddCommand(upgradeCmd) diff --git a/go.mod b/go.mod index 620ffe3f..f125d5cb 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.79.0 + github.com/kernel/kernel-go-sdk v0.83.1-0.20260724213320-12b3ec62f63e 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 62094c51..b7e971f8 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.79.0 h1:1vBV1MWL508p2EGs+PXSztiJOnAQW2cqCDOcSB1ZypQ= -github.com/kernel/kernel-go-sdk v0.79.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.83.1-0.20260724213320-12b3ec62f63e h1:Pe1fRnIM4Zf7ddVg/wd+qTD0X4t2NmXRIeXyY98AILI= +github.com/kernel/kernel-go-sdk v0.83.1-0.20260724213320-12b3ec62f63e/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 9f76db75b291b0369f0ee297416aef880dcc56fb Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:05:47 +0000 Subject: [PATCH 08/10] Close remaining api.md gaps: api-keys rotate, proxies update, process env/tty flags A concurrent run of this workflow landed the SDK bump and most of the coverage in the parent commit. A fresh enumeration of api.md against the resulting command tree still showed uncovered surface, which this adds. SDK methods that had no CLI command: - `api-keys rotate` for client.APIKeys.Rotate, with --days-to-expire, --expire-in-days, and a confirmation prompt (the rotated key stops working after its grace period, so this is not freely reversible) - `proxies update` for client.Proxies.Update SDK params that had no CLI flag: - `api-keys get --include-deleted` for APIKeyGetParams.IncludeDeleted - `browsers process exec --env` and `browsers process spawn --env --allocate-tty --cols --rows`. The parent commit restored the code that reads these, but the flags were never registered, so cobra returned zero values and the plumbing was unreachable: --env could not set an environment variable and --allocate-tty could not request a PTY. Also fixes `auth connections get`/`follow` reporting an enabled connection as telemetry "disabled". The API preserves the create-browser config verbatim instead of resolving it, so enabling telemetry without naming categories comes back as {"enabled": true} with no category object. formatManagedAuthTelemetry only inspected the categories, so that response rendered as "disabled" -- the opposite of the truth. It now reports "enabled (default categories)"; telemetry that is genuinely off still omits the row. Reworded the spawn TTY validation error to lead with a word rather than a flag, since the error renderer uppercases the first letter and printed "--Cols and --rows require --allocate-tty". Tested against the live API on top of the parent commit: proxies create/ update/delete; api-keys create/rotate --expire-in-days 0/get --include-deleted/delete (both keys cleaned up); browsers process exec --env (verified the variable reached the process), spawn --allocate-tty --cols --rows --env, and the --cols-without-tty rejection; auth connections update --telemetry all then get (now "enabled (default categories)"), --telemetry console,network (lists categories), and --telemetry off (row omitted). go build, go vet, and go test pass. Co-Authored-By: Claude Opus 5 --- cmd/api_keys.go | 104 +++++++++++++++++++++++++++++++++-- cmd/api_keys_test.go | 85 ++++++++++++++++++++++++++++ cmd/auth_connections_test.go | 35 ++++++++++++ cmd/browsers.go | 9 ++- cmd/browsers_telemetry.go | 13 +++-- cmd/proxies/common_test.go | 8 +++ cmd/proxies/proxies.go | 14 +++++ cmd/proxies/types.go | 7 +++ cmd/proxies/update.go | 70 +++++++++++++++++++++++ cmd/proxies/update_test.go | 70 +++++++++++++++++++++++ 10 files changed, 406 insertions(+), 9 deletions(-) create mode 100644 cmd/proxies/update.go create mode 100644 cmd/proxies/update_test.go diff --git a/cmd/api_keys.go b/cmd/api_keys.go index 24d03d7e..c83f1401 100644 --- a/cmd/api_keys.go +++ b/cmd/api_keys.go @@ -18,6 +18,7 @@ type APIKeysService interface { Get(ctx context.Context, id string, query kernel.APIKeyGetParams, opts ...option.RequestOption) (*kernel.APIKey, error) Update(ctx context.Context, id string, body kernel.APIKeyUpdateParams, opts ...option.RequestOption) (*kernel.APIKey, error) List(ctx context.Context, query kernel.APIKeyListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.APIKey], error) + Rotate(ctx context.Context, id string, body kernel.APIKeyRotateParams, opts ...option.RequestOption) (*kernel.CreatedAPIKey, error) Delete(ctx context.Context, id string, opts ...option.RequestOption) error } @@ -40,8 +41,17 @@ type APIKeysListInput struct { } type APIKeysGetInput struct { - ID string - Output string + ID string + IncludeDeleted bool + Output string +} + +type APIKeysRotateInput struct { + ID string + DaysToExpire Int64Flag + ExpireInDays Int64Flag + SkipConfirm bool + Output string } type APIKeysUpdateInput struct { @@ -147,7 +157,12 @@ func (c APIKeysCmd) Get(ctx context.Context, in APIKeysGetInput) error { return err } - key, err := c.apiKeys.Get(ctx, in.ID, kernel.APIKeyGetParams{}) + params := kernel.APIKeyGetParams{} + if in.IncludeDeleted { + params.IncludeDeleted = kernel.Bool(true) + } + + key, err := c.apiKeys.Get(ctx, in.ID, params) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -181,6 +196,56 @@ func (c APIKeysCmd) Update(ctx context.Context, in APIKeysUpdateInput) error { return nil } +// Rotate issues a replacement API key. The rotated key keeps working until its +// grace period elapses, so this is disruptive but not immediately breaking -- +// it still prompts, since the old key does eventually stop working. +func (c APIKeysCmd) Rotate(ctx context.Context, in APIKeysRotateInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + params := kernel.APIKeyRotateParams{} + if in.DaysToExpire.Set { + if in.DaysToExpire.Value < 1 || in.DaysToExpire.Value > 3650 { + return fmt.Errorf("--days-to-expire must be between 1 and 3650") + } + params.DaysToExpire = kernel.Int(in.DaysToExpire.Value) + } + if in.ExpireInDays.Set { + if in.ExpireInDays.Value < 0 { + return fmt.Errorf("--expire-in-days must be non-negative; use 0 to expire the rotated key immediately") + } + params.ExpireInDays = kernel.Int(in.ExpireInDays.Value) + } + + if !in.SkipConfirm { + ok, err := c.prompter.Confirm( + fmt.Sprintf("rotate API key '%s'", in.ID), + fmt.Sprintf("Are you sure you want to rotate API key '%s'? The current key stops working after its grace period.", in.ID), + ) + if err != nil { + return err + } + if !ok { + pterm.Info.Println("Rotation cancelled") + return nil + } + } + + key, err := c.apiKeys.Rotate(ctx, in.ID, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(key) + } + + pterm.Success.Printf("Rotated API key %s into new key: %s\n", in.ID, key.ID) + renderCreatedAPIKey(key) + return nil +} + func (c APIKeysCmd) Delete(ctx context.Context, in APIKeysDeleteInput) error { if !in.SkipConfirm { ok, err := c.prompter.Confirm( @@ -308,7 +373,23 @@ func runAPIKeysList(cmd *cobra.Command, args []string) error { func runAPIKeysGet(cmd *cobra.Command, args []string) error { c := getAPIKeysHandler(cmd) output, _ := cmd.Flags().GetString("output") - return c.Get(cmd.Context(), APIKeysGetInput{ID: args[0], Output: output}) + includeDeleted, _ := cmd.Flags().GetBool("include-deleted") + return c.Get(cmd.Context(), APIKeysGetInput{ID: args[0], IncludeDeleted: includeDeleted, Output: output}) +} + +func runAPIKeysRotate(cmd *cobra.Command, args []string) error { + c := getAPIKeysHandler(cmd) + daysToExpire, _ := cmd.Flags().GetInt64("days-to-expire") + expireInDays, _ := cmd.Flags().GetInt64("expire-in-days") + skip, _ := cmd.Flags().GetBool("yes") + output, _ := cmd.Flags().GetString("output") + return c.Rotate(cmd.Context(), APIKeysRotateInput{ + ID: args[0], + DaysToExpire: Int64Flag{Set: cmd.Flags().Changed("days-to-expire"), Value: daysToExpire}, + ExpireInDays: Int64Flag{Set: cmd.Flags().Changed("expire-in-days"), Value: expireInDays}, + SkipConfirm: skip, + Output: output, + }) } func runAPIKeysUpdate(cmd *cobra.Command, args []string) error { @@ -362,6 +443,14 @@ var apiKeysUpdateCmd = &cobra.Command{ RunE: runAPIKeysUpdate, } +var apiKeysRotateCmd = &cobra.Command{ + Use: "rotate ", + Short: "Rotate an API key", + Long: "Issue a replacement API key. The rotated key keeps working for a grace period (7 days by default) so callers can migrate; use --expire-in-days 0 to revoke it immediately.", + Args: cobra.ExactArgs(1), + RunE: runAPIKeysRotate, +} + var apiKeysDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete an API key", @@ -381,17 +470,24 @@ func init() { apiKeysListCmd.Flags().Int("offset", 0, "Number of results to skip") addJSONOutputFlag(apiKeysGetCmd) + apiKeysGetCmd.Flags().Bool("include-deleted", false, "Include soft-deleted API keys in the lookup") addJSONOutputFlag(apiKeysUpdateCmd) apiKeysUpdateCmd.Flags().String("name", "", "New API key name (required)") _ = apiKeysUpdateCmd.MarkFlagRequired("name") + addJSONOutputFlag(apiKeysRotateCmd) + apiKeysRotateCmd.Flags().Int64("days-to-expire", 0, "Lifetime in days for the new key (1-3650); omit to reuse the rotated key's lifetime") + apiKeysRotateCmd.Flags().Int64("expire-in-days", 0, "Grace period in days before the rotated key expires; 0 expires it immediately, omit for the default 7 days") + apiKeysRotateCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + apiKeysDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") apiKeysCmd.AddCommand(apiKeysCreateCmd) apiKeysCmd.AddCommand(apiKeysListCmd) apiKeysCmd.AddCommand(apiKeysGetCmd) apiKeysCmd.AddCommand(apiKeysUpdateCmd) + apiKeysCmd.AddCommand(apiKeysRotateCmd) apiKeysCmd.AddCommand(apiKeysDeleteCmd) rootCmd.AddCommand(apiKeysCmd) diff --git a/cmd/api_keys_test.go b/cmd/api_keys_test.go index 74069ddb..23d12167 100644 --- a/cmd/api_keys_test.go +++ b/cmd/api_keys_test.go @@ -19,6 +19,7 @@ type FakeAPIKeysService struct { GetFunc func(ctx context.Context, id string, query kernel.APIKeyGetParams, opts ...option.RequestOption) (*kernel.APIKey, error) UpdateFunc func(ctx context.Context, id string, body kernel.APIKeyUpdateParams, opts ...option.RequestOption) (*kernel.APIKey, error) ListFunc func(ctx context.Context, query kernel.APIKeyListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.APIKey], error) + RotateFunc func(ctx context.Context, id string, body kernel.APIKeyRotateParams, opts ...option.RequestOption) (*kernel.CreatedAPIKey, error) DeleteFunc func(ctx context.Context, id string, opts ...option.RequestOption) error } @@ -50,6 +51,13 @@ func (f *FakeAPIKeysService) List(ctx context.Context, query kernel.APIKeyListPa return &pagination.OffsetPagination[kernel.APIKey]{Items: []kernel.APIKey{}}, nil } +func (f *FakeAPIKeysService) Rotate(ctx context.Context, id string, body kernel.APIKeyRotateParams, opts ...option.RequestOption) (*kernel.CreatedAPIKey, error) { + if f.RotateFunc != nil { + return f.RotateFunc(ctx, id, body, opts...) + } + return createdAPIKeyFromJSON(`{"id":"key_rotated","name":"default","key":"sk_rotated","masked_key":"sk_...ated","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"dev@example.com","name":"Dev"},"expires_at":null,"project_id":null,"project_name":null}`), nil +} + func (f *FakeAPIKeysService) Delete(ctx context.Context, id string, opts ...option.RequestOption) error { if f.DeleteFunc != nil { return f.DeleteFunc(ctx, id, opts...) @@ -267,3 +275,80 @@ func TestAPIKeysDeleteReturnsAPIError(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "API error") } + +func TestAPIKeysRotateBuildsParamsAndPrintsNewKey(t *testing.T) { + buf := capturePtermOutput(t) + var capturedID string + var captured kernel.APIKeyRotateParams + fake := &FakeAPIKeysService{ + RotateFunc: func(ctx context.Context, id string, body kernel.APIKeyRotateParams, opts ...option.RequestOption) (*kernel.CreatedAPIKey, error) { + capturedID = id + captured = body + return createdAPIKeyFromJSON(`{"id":"key_new","name":"ci","key":"sk_new","masked_key":"sk_...new","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"dev@example.com","name":"Dev"},"expires_at":null,"project_id":null,"project_name":null}`), nil + }, + } + c := APIKeysCmd{apiKeys: fake} + + err := c.Rotate(context.Background(), APIKeysRotateInput{ + ID: "key_123", + DaysToExpire: Int64Flag{Set: true, Value: 30}, + ExpireInDays: Int64Flag{Set: true, Value: 0}, + SkipConfirm: true, + }) + require.NoError(t, err) + + assert.Equal(t, "key_123", capturedID) + assert.True(t, captured.DaysToExpire.Valid()) + assert.Equal(t, int64(30), captured.DaysToExpire.Value) + // 0 must reach the API as an explicit value, not be dropped: it means + // "revoke the rotated key now" rather than "use the default grace period". + assert.True(t, captured.ExpireInDays.Valid()) + assert.Equal(t, int64(0), captured.ExpireInDays.Value) + + out := buf.String() + assert.Contains(t, out, "Rotated API key key_123 into new key: key_new") + assert.Contains(t, out, "sk_new") +} + +func TestAPIKeysRotateRejectsOutOfRangeDaysToExpire(t *testing.T) { + c := APIKeysCmd{apiKeys: &FakeAPIKeysService{}} + err := c.Rotate(context.Background(), APIKeysRotateInput{ + ID: "key_123", + DaysToExpire: Int64Flag{Set: true, Value: 4000}, + SkipConfirm: true, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "--days-to-expire must be between 1 and 3650") +} + +func TestAPIKeysRotateOmitsUnsetParams(t *testing.T) { + capturePtermOutput(t) + var captured kernel.APIKeyRotateParams + fake := &FakeAPIKeysService{ + RotateFunc: func(ctx context.Context, id string, body kernel.APIKeyRotateParams, opts ...option.RequestOption) (*kernel.CreatedAPIKey, error) { + captured = body + return createdAPIKeyFromJSON(`{"id":"key_new","name":"ci","key":"sk_new","masked_key":"sk_...new","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"dev@example.com","name":"Dev"},"expires_at":null,"project_id":null,"project_name":null}`), nil + }, + } + c := APIKeysCmd{apiKeys: fake} + + require.NoError(t, c.Rotate(context.Background(), APIKeysRotateInput{ID: "key_123", SkipConfirm: true})) + assert.False(t, captured.DaysToExpire.Valid()) + assert.False(t, captured.ExpireInDays.Valid()) +} + +func TestAPIKeysGetPassesIncludeDeleted(t *testing.T) { + capturePtermOutput(t) + var captured kernel.APIKeyGetParams + fake := &FakeAPIKeysService{ + GetFunc: func(ctx context.Context, id string, query kernel.APIKeyGetParams, opts ...option.RequestOption) (*kernel.APIKey, error) { + captured = query + return apiKeyFromJSON(`{"id":"` + id + `","name":"default","masked_key":"sk_...test","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"dev@example.com","name":"Dev"},"expires_at":null,"project_id":null,"project_name":null}`), nil + }, + } + c := APIKeysCmd{apiKeys: fake} + + require.NoError(t, c.Get(context.Background(), APIKeysGetInput{ID: "key_123", IncludeDeleted: true})) + assert.True(t, captured.IncludeDeleted.Valid()) + assert.True(t, captured.IncludeDeleted.Value) +} diff --git a/cmd/auth_connections_test.go b/cmd/auth_connections_test.go index 947286e1..edd392e7 100644 --- a/cmd/auth_connections_test.go +++ b/cmd/auth_connections_test.go @@ -763,3 +763,38 @@ func TestGet_ShowsCanonicalFieldsAndChoices(t *testing.T) { assert.Contains(t, out, "ref=email") assert.Contains(t, out, "choice_sms") } + +func TestAuthConnectionsGet_TelemetryEnabledWithoutCategories(t *testing.T) { + setupStdoutCapture(t) + // The API preserves the create-browser config verbatim, so an enabled + // connection can come back as {"enabled": true} with no category object. + // Rendering that as "disabled" would invert the connection's real state. + fake := &FakeAuthConnectionService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + var auth kernel.ManagedAuth + require.NoError(t, json.Unmarshal([]byte(`{"id":"conn-1","browser_telemetry":{"enabled":true}}`), &auth)) + return &auth, nil + }, + } + c := AuthConnectionCmd{svc: fake} + + require.NoError(t, c.Get(context.Background(), AuthConnectionGetInput{ID: "conn-1"})) + assert.Contains(t, outBuf.String(), "enabled (default categories)") +} + +func TestAuthConnectionsGet_TelemetryRowOmittedWhenOff(t *testing.T) { + setupStdoutCapture(t) + // Telemetry that is off is not reported at all, rather than shown as a + // "disabled" row alongside the connection's real settings. + fake := &FakeAuthConnectionService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + var auth kernel.ManagedAuth + require.NoError(t, json.Unmarshal([]byte(`{"id":"conn-1","browser_telemetry":{"enabled":false}}`), &auth)) + return &auth, nil + }, + } + c := AuthConnectionCmd{svc: fake} + + require.NoError(t, c.Get(context.Background(), AuthConnectionGetInput{ID: "conn-1"})) + assert.NotContains(t, outBuf.String(), "Browser Telemetry") +} diff --git a/cmd/browsers.go b/cmd/browsers.go index 218ba7b3..88f520cb 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -1688,7 +1688,9 @@ func (b BrowsersCmd) ProcessSpawn(ctx context.Context, in BrowsersProcessSpawnIn } if in.Cols > 0 || in.Rows > 0 { if !in.AllocateTTY.Set || !in.AllocateTTY.Value { - return fmt.Errorf("--cols and --rows require --allocate-tty") + // Phrased to lead with a word, not a flag: the error renderer + // uppercases the first letter, which would print "--Cols". + return fmt.Errorf("setting --cols or --rows requires --allocate-tty") } if in.Cols > 0 { params.Cols = kernel.Opt(in.Cols) @@ -2550,6 +2552,7 @@ func init() { procExec.Flags().Int("timeout", 0, "Timeout in seconds") procExec.Flags().String("as-user", "", "Run as user") procExec.Flags().Bool("as-root", false, "Run as root") + procExec.Flags().StringArray("env", []string{}, "Environment variable to set for the process as KEY=VALUE (repeatable)") addJSONOutputFlag(procExec) procSpawn := &cobra.Command{Use: "spawn [--] [command...]", Short: "Execute a command asynchronously", Args: cobra.MinimumNArgs(1), RunE: runBrowsersProcessSpawn} procSpawn.Flags().String("command", "", "Command to execute (optional; if omitted, trailing args are executed via /bin/bash -c)") @@ -2558,6 +2561,10 @@ func init() { procSpawn.Flags().Int("timeout", 0, "Timeout in seconds") procSpawn.Flags().String("as-user", "", "Run as user") procSpawn.Flags().Bool("as-root", false, "Run as root") + procSpawn.Flags().Bool("allocate-tty", false, "Allocate a pseudo-terminal (PTY) for interactive shells") + procSpawn.Flags().Int64("cols", 0, "Initial terminal columns (requires --allocate-tty)") + procSpawn.Flags().Int64("rows", 0, "Initial terminal rows (requires --allocate-tty)") + procSpawn.Flags().StringArray("env", []string{}, "Environment variable to set for the process as KEY=VALUE (repeatable)") addJSONOutputFlag(procSpawn) procKill := &cobra.Command{Use: "kill ", Short: "Send a signal to a process", Args: cobra.ExactArgs(2), RunE: runBrowsersProcessKill} procKill.Flags().String("signal", "TERM", "Signal to send (TERM, KILL, INT, HUP)") diff --git a/cmd/browsers_telemetry.go b/cmd/browsers_telemetry.go index 940ccbce..3eccd748 100644 --- a/cmd/browsers_telemetry.go +++ b/cmd/browsers_telemetry.go @@ -143,11 +143,16 @@ func buildAuthConnectionLoginTelemetryParam(s string) (kernel.AuthConnectionLogi // formatManagedAuthTelemetry renders an auth connection's default browser telemetry // config for the details table. func formatManagedAuthTelemetry(cfg kernel.ManagedAuthBrowserTelemetry) string { - on := telemetryEnabledCategories(kernel.BrowserTelemetryConfig{Browser: cfg.Browser}) - if len(on) == 0 { - return "disabled" + if on := telemetryEnabledCategories(kernel.BrowserTelemetryConfig{Browser: cfg.Browser}); len(on) > 0 { + return strings.Join(on, ", ") + } + // The API preserves the create-browser config verbatim rather than resolving + // it, so `{"enabled": true}` with no per-category settings means the default + // set. Reporting that as "disabled" would invert the connection's state. + if cfg.Enabled { + return "enabled (default categories)" } - return strings.Join(on, ", ") + return "disabled" } // settableCategories are the categories accepted by --telemetry=. diff --git a/cmd/proxies/common_test.go b/cmd/proxies/common_test.go index 5d1f4c3f..a7796e24 100644 --- a/cmd/proxies/common_test.go +++ b/cmd/proxies/common_test.go @@ -42,6 +42,7 @@ type FakeProxyService struct { 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 + UpdateFunc func(ctx context.Context, id string, body kernel.ProxyUpdateParams, opts ...option.RequestOption) (*kernel.ProxyUpdateResponse, error) CheckFunc func(ctx context.Context, id string, body kernel.ProxyCheckParams, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error) } @@ -66,6 +67,13 @@ func (f *FakeProxyService) New(ctx context.Context, body kernel.ProxyNewParams, return &kernel.ProxyNewResponse{ID: "new-proxy", Type: kernel.ProxyNewResponseTypeDatacenter}, nil } +func (f *FakeProxyService) Update(ctx context.Context, id string, body kernel.ProxyUpdateParams, opts ...option.RequestOption) (*kernel.ProxyUpdateResponse, error) { + if f.UpdateFunc != nil { + return f.UpdateFunc(ctx, id, body, opts...) + } + return &kernel.ProxyUpdateResponse{ID: id, Name: body.Name, Type: kernel.ProxyUpdateResponseTypeDatacenter}, nil +} + func (f *FakeProxyService) Delete(ctx context.Context, id string, opts ...option.RequestOption) error { if f.DeleteFunc != nil { return f.DeleteFunc(ctx, id, opts...) diff --git a/cmd/proxies/proxies.go b/cmd/proxies/proxies.go index a2909076..88209397 100644 --- a/cmd/proxies/proxies.go +++ b/cmd/proxies/proxies.go @@ -56,6 +56,14 @@ Examples: RunE: runProxiesCreate, } +var proxiesUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Rename a proxy configuration", + Long: "Rename a proxy configuration. Only the name can be changed; recreate the proxy to change its type or config.", + Args: cobra.ExactArgs(1), + RunE: runProxiesUpdate, +} + var proxiesDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete a proxy configuration", @@ -76,6 +84,7 @@ func init() { ProxiesCmd.AddCommand(proxiesListCmd) ProxiesCmd.AddCommand(proxiesGetCmd) ProxiesCmd.AddCommand(proxiesCreateCmd) + ProxiesCmd.AddCommand(proxiesUpdateCmd) ProxiesCmd.AddCommand(proxiesDeleteCmd) ProxiesCmd.AddCommand(proxiesCheckCmd) @@ -109,6 +118,11 @@ func init() { proxiesCreateCmd.Flags().String("password", "", "Password for proxy authentication") proxiesCreateCmd.Flags().StringSlice("bypass-host", nil, "Hostname(s) to bypass proxy and connect directly (repeat or comma-separated)") + // Update flags + addJSONOutputFlag(proxiesUpdateCmd) + proxiesUpdateCmd.Flags().String("name", "", "New proxy name (required)") + _ = proxiesUpdateCmd.MarkFlagRequired("name") + // Delete flags proxiesDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") diff --git a/cmd/proxies/types.go b/cmd/proxies/types.go index 63162e9e..ca44ec2b 100644 --- a/cmd/proxies/types.go +++ b/cmd/proxies/types.go @@ -14,6 +14,7 @@ type ProxyService interface { 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) + Update(ctx context.Context, id string, body kernel.ProxyUpdateParams, opts ...option.RequestOption) (res *kernel.ProxyUpdateResponse, err error) Delete(ctx context.Context, id string, opts ...option.RequestOption) (err error) Check(ctx context.Context, id string, body kernel.ProxyCheckParams, opts ...option.RequestOption) (res *kernel.ProxyCheckResponse, err error) } @@ -58,6 +59,12 @@ type ProxyCreateInput struct { Output string } +type ProxyUpdateInput struct { + ID string + Name string + Output string +} + type ProxyDeleteInput struct { ID string SkipConfirm bool diff --git a/cmd/proxies/update.go b/cmd/proxies/update.go new file mode 100644 index 00000000..f1afb006 --- /dev/null +++ b/cmd/proxies/update.go @@ -0,0 +1,70 @@ +package proxies + +import ( + "context" + "fmt" + + "github.com/kernel/cli/pkg/table" + "github.com/kernel/cli/pkg/util" + "github.com/kernel/kernel-go-sdk" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +func (p ProxyCmd) Update(ctx context.Context, in ProxyUpdateInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + if in.Name == "" { + return fmt.Errorf("--name is required") + } + + item, err := p.proxies.Update(ctx, in.ID, kernel.ProxyUpdateParams{Name: in.Name}) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(item) + } + + pterm.Success.Printf("Renamed proxy %s to %s\n", item.ID, item.Name) + + rows := pterm.TableData{{"Property", "Value"}} + rows = append(rows, []string{"ID", item.ID}) + rows = append(rows, []string{"Name", util.OrDash(item.Name)}) + rows = append(rows, []string{"Type", string(item.Type)}) + rows = append(rows, []string{"Bypass Hosts", formatBypassHosts(item.BypassHosts)}) + + protocol := string(item.Protocol) + if protocol == "" { + protocol = "https" + } + rows = append(rows, []string{"Protocol", protocol}) + + status := string(item.Status) + switch item.Status { + case kernel.ProxyUpdateResponseStatusAvailable: + status = pterm.Green(status) + case kernel.ProxyUpdateResponseStatusUnavailable: + status = pterm.Red(status) + default: + if status == "" { + status = "-" + } + } + rows = append(rows, []string{"Status", status}) + rows = append(rows, []string{"Last Checked", util.FormatLocal(item.LastChecked)}) + + table.PrintTableNoPad(rows, true) + return nil +} + +func runProxiesUpdate(cmd *cobra.Command, args []string) error { + client := util.GetKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + name, _ := cmd.Flags().GetString("name") + svc := client.Proxies + p := ProxyCmd{proxies: &svc} + return p.Update(cmd.Context(), ProxyUpdateInput{ID: args[0], Name: name, Output: output}) +} diff --git a/cmd/proxies/update_test.go b/cmd/proxies/update_test.go new file mode 100644 index 00000000..d25d6994 --- /dev/null +++ b/cmd/proxies/update_test.go @@ -0,0 +1,70 @@ +package proxies + +import ( + "context" + "errors" + "testing" + + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/stretchr/testify/assert" +) + +func TestProxyUpdate_RenamesProxy(t *testing.T) { + buf := captureOutput(t) + + var capturedID string + var captured kernel.ProxyUpdateParams + fake := &FakeProxyService{ + UpdateFunc: func(ctx context.Context, id string, body kernel.ProxyUpdateParams, opts ...option.RequestOption) (*kernel.ProxyUpdateResponse, error) { + capturedID = id + captured = body + return &kernel.ProxyUpdateResponse{ + ID: id, + Name: body.Name, + Type: kernel.ProxyUpdateResponseTypeDatacenter, + Status: kernel.ProxyUpdateResponseStatusAvailable, + }, nil + }, + } + + p := ProxyCmd{proxies: fake} + err := p.Update(context.Background(), ProxyUpdateInput{ID: "proxy-1", Name: "New Name"}) + + assert.NoError(t, err) + assert.Equal(t, "proxy-1", capturedID) + assert.Equal(t, "New Name", captured.Name) + + out := buf.String() + assert.Contains(t, out, "Renamed proxy proxy-1 to New Name") + assert.Contains(t, out, "datacenter") +} + +func TestProxyUpdate_RequiresName(t *testing.T) { + p := ProxyCmd{proxies: &FakeProxyService{}} + err := p.Update(context.Background(), ProxyUpdateInput{ID: "proxy-1"}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "--name is required") +} + +func TestProxyUpdate_SurfacesAPIError(t *testing.T) { + fake := &FakeProxyService{ + UpdateFunc: func(ctx context.Context, id string, body kernel.ProxyUpdateParams, opts ...option.RequestOption) (*kernel.ProxyUpdateResponse, error) { + return nil, errors.New("proxy not found") + }, + } + + p := ProxyCmd{proxies: fake} + err := p.Update(context.Background(), ProxyUpdateInput{ID: "missing", Name: "New Name"}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "proxy not found") +} + +func TestProxyUpdate_InvalidOutput(t *testing.T) { + p := ProxyCmd{proxies: &FakeProxyService{}} + err := p.Update(context.Background(), ProxyUpdateInput{ID: "proxy-1", Name: "New Name", Output: "yaml"}) + + assert.Error(t, err) +} From d87ff386ad240d679b80c0e514437e21840a2686 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Fri, 24 Jul 2026 20:34:20 -0400 Subject: [PATCH 09/10] fix: address bugbot review (process flags, timeline JSON pagination, projects empty name) - browsers process exec/spawn flags (--env, --allocate-tty, --cols, --rows): already registered on the Cobra commands in the previous commit; verified against the run logic and --help output. - auth connections timeline --output json now prints a JSON envelope with events, page, per_page, and has_more (matching the telemetry events envelope pattern) instead of a bare array; table output unchanged. - projects update rejects an empty/whitespace --name client-side, mirroring the profiles update validation. --- cmd/auth_connections.go | 24 ++++++++++++++++++++---- cmd/projects.go | 4 ++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index e4bc6866..984315aa 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "strings" "time" @@ -811,11 +812,26 @@ func (c AuthConnectionCmd) Timeline(ctx context.Context, in AuthConnectionTimeli } if in.Output == "json" { - if len(events) == 0 { - fmt.Println("[]") - return nil + rawEvents := make([]json.RawMessage, 0, len(events)) + for _, e := range events { + r := e.RawJSON() + if r == "" { + r = "{}" + } + rawEvents = append(rawEvents, json.RawMessage(r)) } - return util.PrintPrettyJSONSlice(events) + payload := struct { + Events []json.RawMessage `json:"events"` + Page int `json:"page"` + PerPage int `json:"per_page"` + HasMore bool `json:"has_more"` + }{Events: rawEvents, Page: page, PerPage: perPage, HasMore: hasMore} + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil } if len(events) == 0 { diff --git a/cmd/projects.go b/cmd/projects.go index 38a702af..28cedeb6 100644 --- a/cmd/projects.go +++ b/cmd/projects.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "strings" "github.com/kernel/cli/pkg/util" "github.com/kernel/kernel-go-sdk" @@ -147,6 +148,9 @@ func (c ProjectsCmd) Update(ctx context.Context, in ProjectsUpdateInput) error { inner := kernel.UpdateProjectRequestParam{} if in.NameSet { + if strings.TrimSpace(in.Name) == "" { + return fmt.Errorf("--name must not be empty") + } inner.Name = param.NewOpt(in.Name) } if in.Status != "" { From db12c8af889a827dec4474c1657b0071253eb7b8 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Fri, 24 Jul 2026 20:35:11 -0400 Subject: [PATCH 10/10] docs: document new commands/flags from SDK update in README --- README.md | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c73d83b4..4dbc4d15 100644 --- a/README.md +++ b/README.md @@ -122,10 +122,13 @@ kernel deploy index.ts -o json Commands with JSON output support: - **Browsers**: `create`, `list`, `get`, `view` - **Browser Pools**: `create`, `list`, `get`, `update`, `acquire` -- **Profiles**: `create`, `list`, `get` +- **Profiles**: `create`, `list`, `get`, `update` - **Extensions**: `upload`, `list` -- **Proxies**: `create`, `list`, `get` -- **API Keys**: `create`, `list`, `get`, `update` +- **Proxies**: `create`, `list`, `get`, `update`, `check` +- **API Keys**: `create`, `list`, `get`, `update`, `rotate` +- **Auth Connections**: `timeline` +- **Projects**: `update` +- **Org**: `limits get/set` - **Apps**: `list`, `history` - **Deploy**: `deploy` (JSONL streaming), `history` - **Invoke**: `invoke` (JSONL streaming), `history` @@ -237,6 +240,7 @@ Commands with JSON output support: - `--telemetry=all` - Enable telemetry for all categories - `--telemetry=off` - Disable telemetry - `--telemetry=` - Per-category config, e.g. `--telemetry=network=on,page=off` + - `--disable-default-proxy` - Disable the default stealth proxy so the browser connects directly; use `--disable-default-proxy=false` to re-enable it - `--output json`, `-o json` - Output raw JSON object - `kernel browsers curl ` - Make HTTP requests through a browser session's Chrome network stack - `-X, --request ` - HTTP method (default: GET; defaults to POST when `--data` is set) @@ -341,6 +345,7 @@ Per-category updates are partial — only categories you name are changed; other - `--timeout ` - Timeout in seconds - `--as-user ` - Run as user - `--as-root` - Run as root + - `--env ` - Environment variable to set for the process (repeatable) - `--output json`, `-o json` - Output raw JSON object - `kernel browsers process spawn [--] [command...]` - Execute a command asynchronously - `--command ` - Command to execute (optional; if omitted, trailing args are executed via /bin/bash -c) @@ -349,6 +354,10 @@ Per-category updates are partial — only categories you name are changed; other - `--timeout ` - Timeout in seconds - `--as-user ` - Run as user - `--as-root` - Run as root + - `--env ` - Environment variable to set for the process (repeatable) + - `--allocate-tty` - Allocate a pseudo-terminal (PTY) for interactive shells + - `--cols ` - Initial terminal columns (requires `--allocate-tty`) + - `--rows ` - Initial terminal rows (requires `--allocate-tty`) - `--output json`, `-o json` - Output raw JSON object - `kernel browsers process kill ` - Send a signal to a process - `--signal ` - Signal to send: TERM, KILL, INT, HUP (default: TERM) @@ -452,6 +461,22 @@ Per-category updates are partial — only categories you name are changed; other - `--timeout ` - Maximum execution time in seconds (defaults server-side) - If `[code]` is omitted, code is read from stdin +### Profiles + +- `kernel profiles update --name ` - Rename a profile + - `--name ` - New unique profile name (required) + - `--output json`, `-o json` - Output raw JSON object +- `kernel profiles download --to ` - Download a profile archive + - `--to ` - Directory to extract the profile into (required) + - `--format ` - Archive format to request: `tar.zst` (compressed, default) or `tar` (decompressed server-side) + +### Projects + +- `kernel projects update ` - Update a project's name or status + - `--name ` - New project name (1-255 characters) + - `--status ` - New project status: `active` or `archived` + - `--output json`, `-o json` - Output raw JSON object + ### Extension Management - `kernel extensions list` - List all uploaded extensions @@ -492,9 +517,34 @@ Per-category updates are partial — only categories you name are changed; other - `--username ` - Username for proxy authentication (custom) - `--password ` - Password for proxy authentication (custom) +- `kernel proxies update ` - Rename a proxy configuration (recreate the proxy to change its type or config) + - `--name ` - New proxy name (required) + - `--output json`, `-o json` - Output raw JSON object +- `kernel proxies check ` - Run a health check on a proxy to verify it's working and update its status + - `--url ` - Optional public HTTP or HTTPS URL to test reachability against + - `--output json`, `-o json` - Output raw JSON object - `kernel proxies delete ` - Delete a proxy configuration - `-y, --yes` - Skip confirmation prompt +### Auth Connections + +Managed auth connections (`kernel auth connections`). The commands below are new or gained new flags; run `kernel auth connections --help` for the full command list. + +- `kernel auth connections timeline ` - List the connection's login, reauth, and health-check events, most recent first + - `--type ` - Filter to a single event type: `login`, `reauth`, or `health_check` + - `--page ` - Page number (1-based, default: 1) + - `--per-page ` - Items per page (default: 20) + - `--output json`, `-o json` - Output raw JSON array +- `kernel auth connections create` - New flag: + - `--telemetry=all` / `--telemetry=off` / `--telemetry=` - Default telemetry for this connection's browser sessions. Same semantics as `kernel browsers create` +- `kernel auth connections update ` - New flag: + - `--telemetry=all` / `--telemetry=off` / `--telemetry=` - Update telemetry for future browser sessions +- `kernel auth connections login ` - New flag: + - `--telemetry=all` / `--telemetry=off` / `--telemetry=` - Telemetry override for this login only, merged onto the connection's config +- `kernel auth connections submit ` - New flags: + - `--field-value ` - Canonical field-id=value pair from the connection's `fields` list (repeatable); preferred over the legacy `--field` + - `--choice-id ` - Canonical choice ID from the connection's `choices` list + ### Agent Auth Automated authentication for web services. The `run` command orchestrates the full auth flow automatically. @@ -579,15 +629,30 @@ Automated authentication for web services. The `run` command orchestrates the fu - `--output json`, `-o json` - Output raw JSON array - `kernel api-keys get ` - Get an API key + - `--include-deleted` - Include soft-deleted API keys in the lookup - `--output json`, `-o json` - Output raw JSON object - `kernel api-keys update ` - Update an API key - `--name ` - New API key name - `--output json`, `-o json` - Output raw JSON object +- `kernel api-keys rotate ` - Issue a replacement API key; the rotated key keeps working for a grace period (7 days by default) so callers can migrate + - `--days-to-expire ` - Lifetime in days for the new key (1-3650); omit to reuse the rotated key's lifetime + - `--expire-in-days ` - Grace period in days before the rotated key expires; `0` expires it immediately, omit for the default 7 days + - `-y, --yes` - Skip confirmation prompt + - `--output json`, `-o json` - Output raw JSON object, including the one-time plaintext key + - `kernel api-keys delete ` - Delete an API key - `-y, --yes` - Skip confirmation prompt +### Org + +- `kernel org limits get` - Show the organization's concurrency limit and the default per-project cap applied to projects without an explicit override + - `--output json`, `-o json` - Output raw JSON object +- `kernel org limits set` - Set the default per-project concurrency cap applied to projects without an explicit override + - `--default-project-max-concurrent-sessions ` - Default maximum concurrent browsers for projects without an explicit override (`0` to remove the default) + - `--output json`, `-o json` - Output raw JSON object + ## Examples ### Create a new app