diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index fbb63f8..f445ac3 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 0000000..826ba65 --- /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 9d9a01a..c94601e 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -171,6 +171,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 @@ -205,15 +222,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. @@ -594,7 +612,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 != "" @@ -610,7 +628,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{} @@ -621,6 +639,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 { @@ -1258,18 +1279,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 { @@ -1397,6 +1423,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} @@ -1464,6 +1497,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} @@ -2211,6 +2265,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) @@ -2248,6 +2303,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") @@ -2298,6 +2354,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)") @@ -2306,6 +2363,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)") @@ -2727,6 +2788,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") @@ -2736,15 +2798,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, }) } @@ -2807,6 +2870,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:], " ") @@ -2815,7 +2879,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 { @@ -2827,6 +2891,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" @@ -2834,7 +2902,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 2bb2c71..86d4947 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{} @@ -1444,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/projects.go b/cmd/projects.go new file mode 100644 index 0000000..f67ca89 --- /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 0000000..05fd24d --- /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/proxies/check.go b/cmd/proxies/check.go index 5f47fae..debf1e1 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 8f24ecb..82de52f 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 48f13cf..df49b76 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 2440d3a..3a93843 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 c8e7a38..eb0220e 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/cmd/root.go b/cmd/root.go index 8179d8c..b8de57a 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 bbc599b..4e00a02 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.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 2c777ed..27494e3 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.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=