diff --git a/cmd/agents.go b/cmd/agents.go index 718306f..73095b3 100644 --- a/cmd/agents.go +++ b/cmd/agents.go @@ -189,8 +189,11 @@ func (c AgentAuthCmd) Create(ctx context.Context, in AgentAuthCreateInput) error {"Status", string(agent.Status)}, {"Can Reauth", fmt.Sprintf("%t", agent.CanReauth)}, } - if agent.CredentialName != "" { - tableData = append(tableData, []string{"Credential Name", agent.CredentialName}) + if agent.Credential.Name != "" { + tableData = append(tableData, []string{"Credential Name", agent.Credential.Name}) + } + if agent.Credential.Provider != "" { + tableData = append(tableData, []string{"Credential Provider", agent.Credential.Provider}) } PrintTableNoPad(tableData, true) @@ -223,8 +226,11 @@ func (c AgentAuthCmd) Get(ctx context.Context, in AgentAuthGetInput) error { if agent.CredentialID != "" { tableData = append(tableData, []string{"Credential ID", agent.CredentialID}) } - if agent.CredentialName != "" { - tableData = append(tableData, []string{"Credential Name", agent.CredentialName}) + if agent.Credential.Name != "" { + tableData = append(tableData, []string{"Credential Name", agent.Credential.Name}) + } + if agent.Credential.Provider != "" { + tableData = append(tableData, []string{"Credential Provider", agent.Credential.Provider}) } if agent.PostLoginURL != "" { tableData = append(tableData, []string{"Post-Login URL", agent.PostLoginURL}) diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go new file mode 100644 index 0000000..affa011 --- /dev/null +++ b/cmd/auth_connections.go @@ -0,0 +1,711 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/kernel/cli/pkg/util" + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/pagination" + "github.com/kernel/kernel-go-sdk/packages/ssestream" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +// AuthConnectionService defines the subset of the Kernel SDK auth connection client that we use. +type AuthConnectionService interface { + New(ctx context.Context, body kernel.AuthConnectionNewParams, opts ...option.RequestOption) (res *kernel.ManagedAuth, err error) + Get(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.ManagedAuth, err error) + List(ctx context.Context, query kernel.AuthConnectionListParams, opts ...option.RequestOption) (res *pagination.OffsetPagination[kernel.ManagedAuth], err error) + 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) + FollowStreaming(ctx context.Context, id string, opts ...option.RequestOption) (stream *ssestream.Stream[kernel.AuthConnectionFollowResponseUnion]) +} + +// AuthConnectionCmd handles auth connection operations independent of cobra. +type AuthConnectionCmd struct { + svc AuthConnectionService +} + +type AuthConnectionCreateInput struct { + Domain string + ProfileName string + LoginURL string + AllowedDomains []string + CredentialName string + CredentialProvider string + CredentialPath string + CredentialAuto bool + ProxyID string + HealthCheckInterval int + Output string +} + +type AuthConnectionGetInput struct { + ID string + Output string +} + +type AuthConnectionListInput struct { + Domain string + ProfileName string + Limit int + Offset int + Output string +} + +type AuthConnectionDeleteInput struct { + ID string + SkipConfirm bool +} + +type AuthConnectionLoginInput struct { + ID string + SaveCredentialAs string + Output string +} + +type AuthConnectionSubmitInput struct { + ID string + FieldValues map[string]string + MfaOptionID string + SSOButtonSelector string + Output string +} + +type AuthConnectionFollowInput struct { + ID string + Output string +} + +func (c AuthConnectionCmd) Create(ctx context.Context, in AuthConnectionCreateInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + + if in.Domain == "" { + return fmt.Errorf("--domain is required") + } + if in.ProfileName == "" { + return fmt.Errorf("--profile-name is required") + } + + params := kernel.AuthConnectionNewParams{ + ManagedAuthCreateRequest: kernel.ManagedAuthCreateRequestParam{ + Domain: in.Domain, + ProfileName: in.ProfileName, + }, + } + if in.LoginURL != "" { + params.ManagedAuthCreateRequest.LoginURL = kernel.Opt(in.LoginURL) + } + if len(in.AllowedDomains) > 0 { + params.ManagedAuthCreateRequest.AllowedDomains = in.AllowedDomains + } + if in.HealthCheckInterval > 0 { + params.ManagedAuthCreateRequest.HealthCheckInterval = kernel.Opt(int64(in.HealthCheckInterval)) + } + + // Handle credential reference + if in.CredentialName != "" { + params.ManagedAuthCreateRequest.Credential = kernel.ManagedAuthCreateRequestCredentialParam{ + Name: kernel.Opt(in.CredentialName), + } + } else if in.CredentialProvider != "" { + params.ManagedAuthCreateRequest.Credential = kernel.ManagedAuthCreateRequestCredentialParam{ + Provider: kernel.Opt(in.CredentialProvider), + } + if in.CredentialPath != "" { + params.ManagedAuthCreateRequest.Credential.Path = kernel.Opt(in.CredentialPath) + } + if in.CredentialAuto { + params.ManagedAuthCreateRequest.Credential.Auto = kernel.Opt(true) + } + } + + if in.ProxyID != "" { + params.ManagedAuthCreateRequest.Proxy = kernel.ManagedAuthCreateRequestProxyParam{ + ProxyID: kernel.Opt(in.ProxyID), + } + } + + if in.Output != "json" { + pterm.Info.Printf("Creating managed auth for %s...\n", in.Domain) + } + + auth, err := c.svc.New(ctx, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(auth) + } + + pterm.Success.Printf("Created managed auth: %s\n", auth.ID) + + tableData := pterm.TableData{ + {"Property", "Value"}, + {"ID", auth.ID}, + {"Domain", auth.Domain}, + {"Profile Name", auth.ProfileName}, + {"Status", string(auth.Status)}, + {"Can Reauth", fmt.Sprintf("%t", auth.CanReauth)}, + } + if auth.CanReauthReason != "" { + tableData = append(tableData, []string{"Can Reauth Reason", auth.CanReauthReason}) + } + if auth.Credential.Name != "" { + tableData = append(tableData, []string{"Credential Name", auth.Credential.Name}) + } + if auth.Credential.Provider != "" { + tableData = append(tableData, []string{"Credential Provider", auth.Credential.Provider}) + } + + PrintTableNoPad(tableData, true) + return nil +} + +func (c AuthConnectionCmd) Get(ctx context.Context, in AuthConnectionGetInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + + auth, err := c.svc.Get(ctx, in.ID) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(auth) + } + + tableData := pterm.TableData{ + {"Property", "Value"}, + {"ID", auth.ID}, + {"Domain", auth.Domain}, + {"Profile Name", auth.ProfileName}, + {"Status", string(auth.Status)}, + {"Can Reauth", fmt.Sprintf("%t", auth.CanReauth)}, + } + if auth.CanReauthReason != "" { + tableData = append(tableData, []string{"Can Reauth Reason", auth.CanReauthReason}) + } + if auth.Credential.Name != "" { + tableData = append(tableData, []string{"Credential Name", auth.Credential.Name}) + } + if auth.Credential.Provider != "" { + tableData = append(tableData, []string{"Credential Provider", auth.Credential.Provider}) + } + if auth.FlowStatus != "" { + tableData = append(tableData, []string{"Flow Status", string(auth.FlowStatus)}) + } + if auth.FlowStep != "" { + tableData = append(tableData, []string{"Flow Step", string(auth.FlowStep)}) + } + if auth.HostedURL != "" { + tableData = append(tableData, []string{"Hosted URL", auth.HostedURL}) + } + if auth.LiveViewURL != "" { + tableData = append(tableData, []string{"Live View URL", auth.LiveViewURL}) + } + if auth.ErrorMessage != "" { + tableData = append(tableData, []string{"Error Message", auth.ErrorMessage}) + } + if !auth.LastAuthAt.IsZero() { + tableData = append(tableData, []string{"Last Auth At", util.FormatLocal(auth.LastAuthAt)}) + } + if len(auth.AllowedDomains) > 0 { + tableData = append(tableData, []string{"Allowed Domains", strings.Join(auth.AllowedDomains, ", ")}) + } + if auth.HealthCheckInterval > 0 { + tableData = append(tableData, []string{"Health Check Interval", fmt.Sprintf("%d seconds", auth.HealthCheckInterval)}) + } + + PrintTableNoPad(tableData, true) + return nil +} + +func (c AuthConnectionCmd) List(ctx context.Context, in AuthConnectionListInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + + params := kernel.AuthConnectionListParams{} + if in.Domain != "" { + params.Domain = kernel.Opt(in.Domain) + } + if in.ProfileName != "" { + params.ProfileName = kernel.Opt(in.ProfileName) + } + if in.Limit > 0 { + params.Limit = kernel.Opt(int64(in.Limit)) + } + if in.Offset > 0 { + params.Offset = kernel.Opt(int64(in.Offset)) + } + + page, err := c.svc.List(ctx, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + var auths []kernel.ManagedAuth + if page != nil { + auths = page.Items + } + + if in.Output == "json" { + if len(auths) == 0 { + fmt.Println("[]") + return nil + } + return util.PrintPrettyJSONSlice(auths) + } + + if len(auths) == 0 { + pterm.Info.Println("No managed auths found") + return nil + } + + tableData := pterm.TableData{{"ID", "Domain", "Profile Name", "Status", "Can Reauth"}} + for _, auth := range auths { + tableData = append(tableData, []string{ + auth.ID, + auth.Domain, + auth.ProfileName, + string(auth.Status), + fmt.Sprintf("%t", auth.CanReauth), + }) + } + + PrintTableNoPad(tableData, true) + return nil +} + +func (c AuthConnectionCmd) Delete(ctx context.Context, in AuthConnectionDeleteInput) error { + if !in.SkipConfirm { + msg := fmt.Sprintf("Are you sure you want to delete managed auth '%s'?", in.ID) + pterm.DefaultInteractiveConfirm.DefaultText = msg + ok, _ := pterm.DefaultInteractiveConfirm.Show() + if !ok { + pterm.Info.Println("Deletion cancelled") + return nil + } + } + + if err := c.svc.Delete(ctx, in.ID); err != nil { + if util.IsNotFound(err) { + pterm.Info.Printf("Managed auth '%s' not found\n", in.ID) + return nil + } + return util.CleanedUpSdkError{Err: err} + } + pterm.Success.Printf("Deleted managed auth: %s\n", in.ID) + return nil +} + +func (c AuthConnectionCmd) Login(ctx context.Context, in AuthConnectionLoginInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + + params := kernel.AuthConnectionLoginParams{} + if in.SaveCredentialAs != "" { + params.LoginRequest.SaveCredentialAs = kernel.Opt(in.SaveCredentialAs) + } + + if in.Output != "json" { + pterm.Info.Println("Starting login flow...") + } + + resp, err := c.svc.Login(ctx, in.ID, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(resp) + } + + pterm.Success.Printf("Login flow started: %s\n", resp.FlowType) + + tableData := pterm.TableData{ + {"Property", "Value"}, + {"ID", resp.ID}, + {"Flow Type", string(resp.FlowType)}, + {"Hosted URL", resp.HostedURL}, + {"Flow Expires At", util.FormatLocal(resp.FlowExpiresAt)}, + } + if resp.LiveViewURL != "" { + tableData = append(tableData, []string{"Live View URL", resp.LiveViewURL}) + } + + PrintTableNoPad(tableData, true) + return nil +} + +func (c AuthConnectionCmd) Submit(ctx context.Context, in AuthConnectionSubmitInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + + // Validate that we have some input to submit + hasFields := len(in.FieldValues) > 0 + hasMfaOption := in.MfaOptionID != "" + hasSSOButton := in.SSOButtonSelector != "" + + if !hasFields && !hasMfaOption && !hasSSOButton { + return fmt.Errorf("must provide at least one of: --field, --mfa-option-id, or --sso-button-selector") + } + + params := kernel.AuthConnectionSubmitParams{ + SubmitFieldsRequest: kernel.SubmitFieldsRequestParam{ + Fields: in.FieldValues, + }, + } + if hasMfaOption { + params.SubmitFieldsRequest.MfaOptionID = kernel.Opt(in.MfaOptionID) + } + if hasSSOButton { + params.SubmitFieldsRequest.SSOButtonSelector = kernel.Opt(in.SSOButtonSelector) + } + + if in.Output != "json" { + pterm.Info.Println("Submitting to managed auth...") + } + + resp, err := c.svc.Submit(ctx, in.ID, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(resp) + } + + if resp.Accepted { + pterm.Success.Println("Submission accepted") + } else { + pterm.Warning.Println("Submission not accepted") + } + return nil +} + +func (c AuthConnectionCmd) Follow(ctx context.Context, in AuthConnectionFollowInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + + stream := c.svc.FollowStreaming(ctx, in.ID) + defer stream.Close() + + if in.Output != "json" { + pterm.Info.Println("Following managed auth events (Ctrl+C to stop)...") + } + + for stream.Next() { + event := stream.Current() + + if in.Output == "json" { + if err := util.PrintPrettyJSON(event); err != nil { + return err + } + continue + } + + // Human-readable output + switch event.Event { + case "managed_auth_state": + state := event.AsManagedAuthState() + pterm.Info.Printf("[%s] Status: %s, Step: %s\n", + state.Timestamp.Local().Format(time.RFC3339), + state.FlowStatus, + state.FlowStep) + if len(state.DiscoveredFields) > 0 { + var fieldNames []string + for _, f := range state.DiscoveredFields { + fieldNames = append(fieldNames, f.Name) + } + pterm.Info.Printf(" Discovered fields: %s\n", strings.Join(fieldNames, ", ")) + } + if state.ErrorMessage != "" { + pterm.Error.Printf(" Error: %s\n", state.ErrorMessage) + } + if state.WebsiteError != "" { + pterm.Warning.Printf(" Website error: %s\n", state.WebsiteError) + } + case "error": + errEvent := event.AsError() + pterm.Error.Printf("Error: %s\n", errEvent.Error.Message) + case "sse_heartbeat": + // Silently ignore heartbeats for human-readable output + } + } + + if err := stream.Err(); err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output != "json" { + pterm.Success.Println("Stream ended") + } + return nil +} + +// --- Cobra wiring --- + +var authConnectionsCmd = &cobra.Command{ + Use: "connections", + Short: "Manage auth connections (managed auth)", + Long: "Commands for managing authentication connections that keep profiles logged into domains", +} + +var authConnectionsCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a managed auth connection", + Long: "Create managed authentication for a profile and domain combination", + Args: cobra.NoArgs, + RunE: runAuthConnectionsCreate, +} + +var authConnectionsGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a managed auth by ID", + Args: cobra.ExactArgs(1), + RunE: runAuthConnectionsGet, +} + +var authConnectionsListCmd = &cobra.Command{ + Use: "list", + Short: "List managed auths", + Args: cobra.NoArgs, + RunE: runAuthConnectionsList, +} + +var authConnectionsDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a managed auth", + Args: cobra.ExactArgs(1), + RunE: runAuthConnectionsDelete, +} + +var authConnectionsLoginCmd = &cobra.Command{ + Use: "login ", + Short: "Start a login flow", + Long: "Start a login flow for the managed auth, returns a hosted URL for authentication", + Args: cobra.ExactArgs(1), + RunE: runAuthConnectionsLogin, +} + +var authConnectionsSubmitCmd = &cobra.Command{ + Use: "submit ", + Short: "Submit field values to a login flow", + Long: `Submit field values for the login form. Poll the managed auth to track progress. + +Examples: + # Submit field values + kernel auth connections submit --field username=myuser --field password=mypass + + # Select an MFA option + kernel auth connections submit --mfa-option-id + + # Click an SSO button + kernel auth connections submit --sso-button-selector "//button[@id='google-sso']"`, + Args: cobra.ExactArgs(1), + RunE: runAuthConnectionsSubmit, +} + +var authConnectionsFollowCmd = &cobra.Command{ + Use: "follow ", + Short: "Follow login flow events", + Long: "Establish an SSE stream to receive real-time login flow state updates", + Args: cobra.ExactArgs(1), + RunE: runAuthConnectionsFollow, +} + +func init() { + // Create flags + authConnectionsCreateCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + authConnectionsCreateCmd.Flags().String("domain", "", "Target domain for authentication (required)") + authConnectionsCreateCmd.Flags().String("profile-name", "", "Name of the profile to manage (required)") + authConnectionsCreateCmd.Flags().String("login-url", "", "Optional login page URL to skip discovery") + authConnectionsCreateCmd.Flags().StringSlice("allowed-domain", []string{}, "Additional allowed domains (repeatable)") + authConnectionsCreateCmd.Flags().String("credential-name", "", "Kernel credential name to use") + authConnectionsCreateCmd.Flags().String("credential-provider", "", "External credential provider name") + authConnectionsCreateCmd.Flags().String("credential-path", "", "Provider-specific path (e.g., VaultName/ItemName)") + authConnectionsCreateCmd.Flags().Bool("credential-auto", false, "Lookup by domain from the specified provider") + authConnectionsCreateCmd.Flags().String("proxy-id", "", "Optional proxy ID to use") + authConnectionsCreateCmd.Flags().Int("health-check-interval", 0, "Interval in seconds between health checks (300-86400)") + _ = authConnectionsCreateCmd.MarkFlagRequired("domain") + _ = authConnectionsCreateCmd.MarkFlagRequired("profile-name") + + // Get flags + authConnectionsGetCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + + // List flags + authConnectionsListCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + authConnectionsListCmd.Flags().String("domain", "", "Filter by domain") + authConnectionsListCmd.Flags().String("profile-name", "", "Filter by profile name") + authConnectionsListCmd.Flags().Int("limit", 0, "Maximum number of results to return") + authConnectionsListCmd.Flags().Int("offset", 0, "Number of results to skip") + + // Delete flags + authConnectionsDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + + // Login flags + authConnectionsLoginCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + authConnectionsLoginCmd.Flags().String("save-credential-as", "", "Save credentials under this name on success") + + // Submit flags + authConnectionsSubmitCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + authConnectionsSubmitCmd.Flags().StringArray("field", []string{}, "Field name=value pair (repeatable)") + authConnectionsSubmitCmd.Flags().String("mfa-option-id", "", "MFA option ID if user selected an MFA method") + authConnectionsSubmitCmd.Flags().String("sso-button-selector", "", "XPath selector if user chose an SSO button") + + // Follow flags + authConnectionsFollowCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + + // Wire up commands + authConnectionsCmd.AddCommand(authConnectionsCreateCmd) + authConnectionsCmd.AddCommand(authConnectionsGetCmd) + authConnectionsCmd.AddCommand(authConnectionsListCmd) + authConnectionsCmd.AddCommand(authConnectionsDeleteCmd) + authConnectionsCmd.AddCommand(authConnectionsLoginCmd) + authConnectionsCmd.AddCommand(authConnectionsSubmitCmd) + authConnectionsCmd.AddCommand(authConnectionsFollowCmd) + + authCmd.AddCommand(authConnectionsCmd) +} + +func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + domain, _ := cmd.Flags().GetString("domain") + profileName, _ := cmd.Flags().GetString("profile-name") + loginURL, _ := cmd.Flags().GetString("login-url") + allowedDomains, _ := cmd.Flags().GetStringSlice("allowed-domain") + credentialName, _ := cmd.Flags().GetString("credential-name") + credentialProvider, _ := cmd.Flags().GetString("credential-provider") + credentialPath, _ := cmd.Flags().GetString("credential-path") + credentialAuto, _ := cmd.Flags().GetBool("credential-auto") + proxyID, _ := cmd.Flags().GetString("proxy-id") + healthCheckInterval, _ := cmd.Flags().GetInt("health-check-interval") + + svc := client.Auth.Connections + c := AuthConnectionCmd{svc: &svc} + return c.Create(cmd.Context(), AuthConnectionCreateInput{ + Domain: domain, + ProfileName: profileName, + LoginURL: loginURL, + AllowedDomains: allowedDomains, + CredentialName: credentialName, + CredentialProvider: credentialProvider, + CredentialPath: credentialPath, + CredentialAuto: credentialAuto, + ProxyID: proxyID, + HealthCheckInterval: healthCheckInterval, + Output: output, + }) +} + +func runAuthConnectionsGet(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + + svc := client.Auth.Connections + c := AuthConnectionCmd{svc: &svc} + return c.Get(cmd.Context(), AuthConnectionGetInput{ + ID: args[0], + Output: output, + }) +} + +func runAuthConnectionsList(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + domain, _ := cmd.Flags().GetString("domain") + profileName, _ := cmd.Flags().GetString("profile-name") + limit, _ := cmd.Flags().GetInt("limit") + offset, _ := cmd.Flags().GetInt("offset") + + svc := client.Auth.Connections + c := AuthConnectionCmd{svc: &svc} + return c.List(cmd.Context(), AuthConnectionListInput{ + Domain: domain, + ProfileName: profileName, + Limit: limit, + Offset: offset, + Output: output, + }) +} + +func runAuthConnectionsDelete(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + skip, _ := cmd.Flags().GetBool("yes") + + svc := client.Auth.Connections + c := AuthConnectionCmd{svc: &svc} + return c.Delete(cmd.Context(), AuthConnectionDeleteInput{ + ID: args[0], + SkipConfirm: skip, + }) +} + +func runAuthConnectionsLogin(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + saveCredentialAs, _ := cmd.Flags().GetString("save-credential-as") + + svc := client.Auth.Connections + c := AuthConnectionCmd{svc: &svc} + return c.Login(cmd.Context(), AuthConnectionLoginInput{ + ID: args[0], + SaveCredentialAs: saveCredentialAs, + Output: output, + }) +} + +func runAuthConnectionsSubmit(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + fieldPairs, _ := cmd.Flags().GetStringArray("field") + mfaOptionID, _ := cmd.Flags().GetString("mfa-option-id") + ssoButtonSelector, _ := cmd.Flags().GetString("sso-button-selector") + + // Parse field pairs into map + fieldValues := make(map[string]string) + for _, pair := range fieldPairs { + parts := strings.SplitN(pair, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid field format: %s (expected key=value)", pair) + } + fieldValues[parts[0]] = parts[1] + } + + svc := client.Auth.Connections + c := AuthConnectionCmd{svc: &svc} + return c.Submit(cmd.Context(), AuthConnectionSubmitInput{ + ID: args[0], + FieldValues: fieldValues, + MfaOptionID: mfaOptionID, + SSOButtonSelector: ssoButtonSelector, + Output: output, + }) +} + +func runAuthConnectionsFollow(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + + svc := client.Auth.Connections + c := AuthConnectionCmd{svc: &svc} + return c.Follow(cmd.Context(), AuthConnectionFollowInput{ + ID: args[0], + Output: output, + }) +} diff --git a/cmd/browsers.go b/cmd/browsers.go index f3de63c..e96eb12 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -124,9 +124,9 @@ func getAvailableViewports() []string { "1920x1080@25", "1920x1200@25", "1440x900@25", + "1280x800@60", "1024x768@60", "1200x800@60", - "1280x800@60", } } @@ -222,6 +222,7 @@ type BrowsersCmd struct { type BrowsersListInput struct { Output string IncludeDeleted bool + Status string Limit int Offset int } @@ -232,7 +233,19 @@ func (b BrowsersCmd) List(ctx context.Context, in BrowsersListInput) error { } params := kernel.BrowserListParams{} - if in.IncludeDeleted { + // Use new Status parameter if provided, otherwise fall back to deprecated IncludeDeleted + if in.Status != "" { + switch in.Status { + case "active": + params.Status = kernel.BrowserListParamsStatusActive + case "deleted": + params.Status = kernel.BrowserListParamsStatusDeleted + case "all": + params.Status = kernel.BrowserListParamsStatusAll + default: + return fmt.Errorf("invalid --status value: %s (must be 'active', 'deleted', or 'all')", in.Status) + } + } else if in.IncludeDeleted { params.IncludeDeleted = kernel.Opt(true) } if in.Limit > 0 { @@ -263,7 +276,8 @@ func (b BrowsersCmd) List(ctx context.Context, in BrowsersListInput) error { // Prepare table data headers := []string{"Browser ID", "Created At", "Persistent ID", "Profile", "CDP WS URL", "Live View URL"} - if in.IncludeDeleted { + showDeletedAt := in.IncludeDeleted || in.Status == "deleted" || in.Status == "all" + if showDeletedAt { headers = append(headers, "Deleted At") } tableData := pterm.TableData{headers} @@ -290,7 +304,7 @@ func (b BrowsersCmd) List(ctx context.Context, in BrowsersListInput) error { truncateURL(browser.BrowserLiveViewURL, 50), } - if in.IncludeDeleted { + if showDeletedAt { deletedAt := "-" if !browser.DeletedAt.IsZero() { deletedAt = util.FormatLocal(browser.DeletedAt) @@ -2053,7 +2067,8 @@ Note: Profiles can only be loaded into sessions that don't already have a profil func init() { // list flags browsersListCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") - browsersListCmd.Flags().Bool("include-deleted", false, "Include soft-deleted browser sessions in the results") + browsersListCmd.Flags().Bool("include-deleted", false, "DEPRECATED: Use --status instead. Include soft-deleted browser sessions in the results") + browsersListCmd.Flags().String("status", "", "Filter by status: 'active' (default), 'deleted', or 'all'") browsersListCmd.Flags().Int("limit", 0, "Maximum number of results to return (default 20, max 100)") browsersListCmd.Flags().Int("offset", 0, "Number of results to skip (for pagination)") @@ -2322,11 +2337,13 @@ func runBrowsersList(cmd *cobra.Command, args []string) error { b := BrowsersCmd{browsers: &svc} out, _ := cmd.Flags().GetString("output") includeDeleted, _ := cmd.Flags().GetBool("include-deleted") + status, _ := cmd.Flags().GetString("status") limit, _ := cmd.Flags().GetInt("limit") offset, _ := cmd.Flags().GetInt("offset") return b.List(cmd.Context(), BrowsersListInput{ Output: out, IncludeDeleted: includeDeleted, + Status: status, Limit: limit, Offset: offset, }) diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 49d770d..696e29a 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -1151,8 +1151,8 @@ func TestGetAvailableViewports_ReturnsExpectedOptions(t *testing.T) { assert.Contains(t, viewports, "1920x1080@25") assert.Contains(t, viewports, "1920x1200@25") assert.Contains(t, viewports, "1440x900@25") - assert.Contains(t, viewports, "1200x800@60") assert.Contains(t, viewports, "1280x800@60") + assert.Contains(t, viewports, "1200x800@60") assert.Contains(t, viewports, "1024x768@60") } diff --git a/cmd/credential_providers.go b/cmd/credential_providers.go index 1da25f0..0ed7133 100644 --- a/cmd/credential_providers.go +++ b/cmd/credential_providers.go @@ -20,6 +20,7 @@ type CredentialProvidersService interface { List(ctx context.Context, opts ...option.RequestOption) (res *[]kernel.CredentialProvider, err error) Delete(ctx context.Context, id string, opts ...option.RequestOption) (err error) Test(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.CredentialProviderTestResult, err error) + ListItems(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.CredentialProviderListItemsResponse, err error) } // CredentialProvidersCmd handles credential provider operations independent of cobra. @@ -62,6 +63,11 @@ type CredentialProvidersTestInput struct { Output string } +type CredentialProvidersListItemsInput struct { + ID string + Output string +} + func (c CredentialProvidersCmd) List(ctx context.Context, in CredentialProvidersListInput) error { if in.Output != "" && in.Output != "json" { return fmt.Errorf("unsupported --output value: use 'json'") @@ -281,6 +287,51 @@ func (c CredentialProvidersCmd) Test(ctx context.Context, in CredentialProviders return nil } +func (c CredentialProvidersCmd) ListItems(ctx context.Context, in CredentialProvidersListItemsInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + + if in.Output != "json" { + pterm.Info.Printf("Listing items for credential provider '%s'...\n", in.ID) + } + + result, err := c.providers.ListItems(ctx, in.ID) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + if len(result.Items) == 0 { + fmt.Println("[]") + return nil + } + return util.PrintPrettyJSONSlice(result.Items) + } + + if len(result.Items) == 0 { + pterm.Info.Println("No items found") + return nil + } + + tableData := pterm.TableData{{"Path", "Title", "Vault", "URLs"}} + for _, item := range result.Items { + urls := "" + if len(item.URLs) > 0 { + urls = strings.Join(item.URLs, ", ") + } + tableData = append(tableData, []string{ + item.Path, + item.Title, + item.VaultName, + urls, + }) + } + + PrintTableNoPad(tableData, true) + return nil +} + // --- Cobra wiring --- var credentialProvidersCmd = &cobra.Command{ @@ -345,6 +396,14 @@ var credentialProvidersTestCmd = &cobra.Command{ RunE: runCredentialProvidersTest, } +var credentialProvidersListItemsCmd = &cobra.Command{ + Use: "list-items ", + Short: "List items from a credential provider", + Long: `List all credential items available from the specified external credential provider.`, + Args: cobra.ExactArgs(1), + RunE: runCredentialProvidersListItems, +} + func init() { credentialProvidersCmd.AddCommand(credentialProvidersListCmd) credentialProvidersCmd.AddCommand(credentialProvidersGetCmd) @@ -352,6 +411,7 @@ func init() { credentialProvidersCmd.AddCommand(credentialProvidersUpdateCmd) credentialProvidersCmd.AddCommand(credentialProvidersDeleteCmd) credentialProvidersCmd.AddCommand(credentialProvidersTestCmd) + credentialProvidersCmd.AddCommand(credentialProvidersListItemsCmd) // List flags credentialProvidersListCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") @@ -379,6 +439,9 @@ func init() { // Test flags credentialProvidersTestCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + + // ListItems flags + credentialProvidersListItemsCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") } func runCredentialProvidersList(cmd *cobra.Command, args []string) error { @@ -464,3 +527,15 @@ func runCredentialProvidersTest(cmd *cobra.Command, args []string) error { Output: output, }) } + +func runCredentialProvidersListItems(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + + svc := client.CredentialProviders + c := CredentialProvidersCmd{providers: &svc} + return c.ListItems(cmd.Context(), CredentialProvidersListItemsInput{ + ID: args[0], + Output: output, + }) +} diff --git a/cmd/invoke.go b/cmd/invoke.go index 4b80b67..3b5091c 100644 --- a/cmd/invoke.go +++ b/cmd/invoke.go @@ -35,11 +35,20 @@ var invocationHistoryCmd = &cobra.Command{ RunE: runInvocationHistory, } +var invocationBrowsersCmd = &cobra.Command{ + Use: "browsers ", + Short: "List browser sessions for an invocation", + Long: "List all active browser sessions created within a specific invocation.", + Args: cobra.ExactArgs(1), + RunE: runInvocationBrowsers, +} + func init() { invokeCmd.Flags().StringP("version", "v", "latest", "Specify a version of the app to invoke (optional, defaults to 'latest')") invokeCmd.Flags().StringP("payload", "p", "", "JSON payload for the invocation (optional)") invokeCmd.Flags().StringP("payload-file", "f", "", "Path to a JSON file containing the payload (use '-' for stdin)") invokeCmd.Flags().BoolP("sync", "s", false, "Invoke synchronously (default false). A synchronous invocation will open a long-lived HTTP POST to the Kernel API to wait for the invocation to complete. This will time out after 60 seconds, so only use this option if you expect your invocation to complete in less than 60 seconds. The default is to invoke asynchronously, in which case the CLI will open an SSE connection to the Kernel API after submitting the invocation and wait for the invocation to complete.") + invokeCmd.Flags().Int64("async-timeout", 0, "Timeout in seconds for async invocations (min 10, max 3600). Only applies when async mode is used.") invokeCmd.Flags().StringP("output", "o", "", "Output format: json for JSONL streaming output") invokeCmd.MarkFlagsMutuallyExclusive("payload", "payload-file") @@ -48,6 +57,9 @@ func init() { invocationHistoryCmd.Flags().String("version", "", "Filter by invocation version") invocationHistoryCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") invokeCmd.AddCommand(invocationHistoryCmd) + + invocationBrowsersCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + invokeCmd.AddCommand(invocationBrowsersCmd) } func runInvoke(cmd *cobra.Command, args []string) error { @@ -70,12 +82,16 @@ func runInvoke(cmd *cobra.Command, args []string) error { return fmt.Errorf("version cannot be an empty string") } isSync, _ := cmd.Flags().GetBool("sync") + asyncTimeout, _ := cmd.Flags().GetInt64("async-timeout") params := kernel.InvocationNewParams{ AppName: appName, ActionName: actionName, Version: version, Async: kernel.Opt(!isSync), } + if asyncTimeout > 0 { + params.AsyncTimeoutSeconds = kernel.Opt(asyncTimeout) + } payloadStr, hasPayload, err := getPayload(cmd) if err != nil { @@ -428,3 +444,56 @@ func runInvocationHistory(cmd *cobra.Command, args []string) error { } return nil } + +func runInvocationBrowsers(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + invocationID := args[0] + output, _ := cmd.Flags().GetString("output") + + if output != "" && output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + + resp, err := client.Invocations.ListBrowsers(cmd.Context(), invocationID) + if err != nil { + pterm.Error.Printf("Failed to list browsers for invocation: %v\n", err) + return nil + } + + if output == "json" { + if len(resp.Browsers) == 0 { + fmt.Println("[]") + return nil + } + return util.PrintPrettyJSONSlice(resp.Browsers) + } + + if len(resp.Browsers) == 0 { + pterm.Info.Printf("No active browsers found for invocation %s\n", invocationID) + return nil + } + + table := pterm.TableData{{"Session ID", "Created At", "Headless", "Stealth", "Timeout", "CDP WS URL", "Live View URL"}} + + for _, browser := range resp.Browsers { + created := util.FormatLocal(browser.CreatedAt) + liveView := browser.BrowserLiveViewURL + if liveView == "" { + liveView = "-" + } + + table = append(table, []string{ + browser.SessionID, + created, + fmt.Sprintf("%v", browser.Headless), + fmt.Sprintf("%v", browser.Stealth), + fmt.Sprintf("%d", browser.TimeoutSeconds), + truncateURL(browser.CdpWsURL, 40), + truncateURL(liveView, 40), + }) + } + + pterm.Info.Printf("Browsers for invocation %s:\n", invocationID) + pterm.DefaultTable.WithHasHeader().WithData(table).Render() + return nil +} diff --git a/go.mod b/go.mod index b415eb5..4a547e5 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.28.0 + github.com/kernel/kernel-go-sdk v0.32.1-0.20260210174239-c90e1da19efb github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pquerna/otp v1.5.0 github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 82d1c54..799bf0e 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,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.28.0 h1:cvaCWP25UIB5w6oOdQ5J+rVboNGq3VaWYhtmshlPrhg= -github.com/kernel/kernel-go-sdk v0.28.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.32.1-0.20260210174239-c90e1da19efb h1:yu5PSECVvcZ7Erb5lvwbc8wLa4ncrZjT+EF6R9g4zaU= +github.com/kernel/kernel-go-sdk v0.32.1-0.20260210174239-c90e1da19efb/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=