diff --git a/README.md b/README.md index c73d83b..4dbc4d1 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 diff --git a/cmd/api_keys.go b/cmd/api_keys.go index 24d03d7..c83f140 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 74069dd..23d1216 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.go b/cmd/auth_connections.go index 6dfa0ec..984315a 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "strings" "time" @@ -13,6 +14,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 +27,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 +51,7 @@ type AuthConnectionCreateInput struct { SaveCredentials bool NoSaveCredentials bool HealthCheckInterval int + Telemetry string Output string } @@ -76,6 +80,7 @@ type AuthConnectionUpdateInput struct { SaveCredentials BoolFlag HealthCheckInterval int HealthCheckIntervalSet bool + Telemetry string Output string } @@ -96,12 +101,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 +121,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 +201,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 +248,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 +314,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 +382,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 +503,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 +620,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 +665,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 +718,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 +766,110 @@ 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" { + rawEvents := make([]json.RawMessage, 0, len(events)) + for _, e := range events { + r := e.RawJSON() + if r == "" { + r = "{}" + } + rawEvents = append(rawEvents, json.RawMessage(r)) + } + 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 { + 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 +903,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 +1027,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 +1050,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 +1071,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 +1089,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 +1104,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 +1119,7 @@ func init() { authConnectionsCmd.AddCommand(authConnectionsLoginCmd) authConnectionsCmd.AddCommand(authConnectionsSubmitCmd) authConnectionsCmd.AddCommand(authConnectionsFollowCmd) + authConnectionsCmd.AddCommand(authConnectionsTimelineCmd) authCmd.AddCommand(authConnectionsCmd) } @@ -893,6 +1139,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 +1156,7 @@ func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { ProxyName: proxyName, NoSaveCredentials: noSaveCredentials, HealthCheckInterval: healthCheckInterval, + Telemetry: telemetry, Output: output, }) } @@ -939,6 +1187,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 +1219,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 +1260,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 +1268,7 @@ func runAuthConnectionsLogin(cmd *cobra.Command, args []string) error { ID: args[0], ProxyID: proxyID, ProxyName: proxyName, + Telemetry: telemetry, Output: output, }) } @@ -1025,6 +1277,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 +1294,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 +1325,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 96bb131..edd392e 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,250 @@ 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") +} + +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/browser_pools.go b/cmd/browser_pools.go index 6df9e71..ca753bd 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "strings" @@ -127,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 { @@ -297,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 { @@ -734,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} @@ -790,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} @@ -944,6 +945,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/browsers.go b/cmd/browsers.go index cf910dd..88f520c 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{} @@ -777,6 +798,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 { @@ -1432,18 +1456,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 { @@ -1580,6 +1609,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} @@ -1647,6 +1683,29 @@ 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 { + // 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) + } + 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} @@ -2394,6 +2453,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) @@ -2436,6 +2496,7 @@ func init() { addJSONOutputFlag(browsersUpdateCmd) 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") @@ -2491,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)") @@ -2499,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)") @@ -3010,6 +3076,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") @@ -3024,22 +3091,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, }) } @@ -3102,6 +3170,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:], " ") @@ -3110,7 +3179,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 { @@ -3122,6 +3191,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" @@ -3129,7 +3202,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_telemetry.go b/cmd/browsers_telemetry.go index 276b8a4..3eccd74 100644 --- a/cmd/browsers_telemetry.go +++ b/cmd/browsers_telemetry.go @@ -119,6 +119,42 @@ 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 { + 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 "disabled" +} + // 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/browsers_test.go b/cmd/browsers_test.go index bf29b82..deb2e36 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -1467,6 +1467,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{} @@ -1478,6 +1497,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{} @@ -2107,6 +2154,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/org.go b/cmd/org.go new file mode 100644 index 0000000..1d8c107 --- /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 0000000..42f3a49 --- /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 b4b8643..1820ad1 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 d104384..3546749 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 7baf195..28cedeb 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" @@ -22,6 +23,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 +47,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 +141,61 @@ 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 { + if strings.TrimSpace(in.Name) == "" { + return fmt.Errorf("--name must not be empty") + } + 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 +336,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 +424,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 +477,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 +492,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 3855cdf..8cc2de5 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 fcc81a5..6c6657e 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} } @@ -151,7 +156,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 c130206..82de52f 100644 --- a/cmd/proxies/check_test.go +++ b/cmd/proxies/check_test.go @@ -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 5d1f4c3..a7796e2 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 d6841a4..8820939 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", @@ -66,7 +74,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, } @@ -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,9 +118,15 @@ 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") // Check flags + proxiesCheckCmd.Flags().String("url", "", "Optional public HTTP or HTTPS URL to test reachability against") addJSONOutputFlag(proxiesCheckCmd) } diff --git a/cmd/proxies/types.go b/cmd/proxies/types.go index 5cc7dbd..ca44ec2 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 @@ -65,5 +72,6 @@ type ProxyDeleteInput struct { type ProxyCheckInput struct { ID string + URL string Output string } diff --git a/cmd/proxies/update.go b/cmd/proxies/update.go new file mode 100644 index 0000000..f1afb00 --- /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 0000000..d25d699 --- /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) +} diff --git a/cmd/root.go b/cmd/root.go index de6c0d7..aae3506 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -162,12 +162,13 @@ func init() { rootCmd.AddCommand(browsersCmd) 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 620ffe3..f125d5c 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 62094c5..b7e971f 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=