From 7bc97e1537bbc4964ac820f364986b14eb4885f6 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:04:38 +0000 Subject: [PATCH] CLI: Update SDK to 5e56fc5d99a6 and add missing coverage Expose the new SDK methods and params added since v0.44.0 so the CLI stays aligned with the generated client surface across deploys, invocations, managed auth, browsers, apps, and credential providers. Tested: go test ./..., go build ./..., kernel deploy get eqo2jzrlf8msevs8n6mbn4bf, kernel deploy history ts-basic --app-version latest, kernel app list --query ts-basic, kernel invoke ts-basic get-page-title --since 10m, kernel invoke get apwmow9vgbs220iwnwhc8lc8, kernel invoke history --app ts-basic --action get-page-title --deployment-id eqo2jzrlf8msevs8n6mbn4bf --status succeeded --since 24h, kernel invoke update bmncbxisjg1nv95hr3fa05dw --status failed --output '{"error":"manual test"}', kernel invoke delete-browsers bmncbxisjg1nv95hr3fa05dw, kernel browsers create --invocation-id qkpb5v6chmvm6bbr7j2fkxxg -t 30, kernel browsers get 2l9r4s41c04qy6ge2v5w38s0 --include-deleted, kernel auth connections update 5d71e33b-a099-4a85-a850-a9d1a5d6456b --health-check-interval 30 --save-credentials, kernel credential-providers update cp_hcpp4bm210p1yxxbw61eg7b4 --name cli-coverage-provider-updated Made-with: Cursor --- cmd/app.go | 16 ++- cmd/auth_connections.go | 217 ++++++++++++++++++++++++++++++++++- cmd/auth_connections_test.go | 96 ++++++++++++++++ cmd/browsers.go | 26 ++++- cmd/browsers_test.go | 39 +++++++ cmd/credential_providers.go | 17 +++ cmd/deploy.go | 90 ++++++++++++++- cmd/invoke.go | 212 ++++++++++++++++++++++++++++++++-- go.mod | 2 +- go.sum | 4 +- 10 files changed, 687 insertions(+), 32 deletions(-) diff --git a/cmd/app.go b/cmd/app.go index 9f770ff..08273b5 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -53,6 +53,7 @@ func init() { // Add optional filters for list appListCmd.Flags().String("name", "", "Filter by application name") + appListCmd.Flags().String("query", "", "Search apps by name") appListCmd.Flags().String("version", "", "Filter by version label") appListCmd.Flags().Int("limit", 20, "Max apps to return (default 20)") appListCmd.Flags().Int("per-page", 20, "Items per page (alias of --limit)") @@ -67,6 +68,7 @@ func init() { func runAppList(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) appName, _ := cmd.Flags().GetString("name") + query, _ := cmd.Flags().GetString("query") version, _ := cmd.Flags().GetString("version") lim, _ := cmd.Flags().GetInt("limit") perPage, _ := cmd.Flags().GetInt("per-page") @@ -101,6 +103,9 @@ func runAppList(cmd *cobra.Command, args []string) error { if appName != "" { params.AppName = kernel.Opt(appName) } + if query != "" { + params.Query = kernel.Opt(query) + } if version != "" { params.Version = kernel.Opt(version) } @@ -170,17 +175,20 @@ func runAppList(cmd *cobra.Command, args []string) error { PrintTableNoPad(tableData, true) // Footer with pagination details and next command suggestion - fmt.Printf("\nPage: %d Per-page: %d Items this page: %d Has more: %s\n", page, perPage, itemsThisPage, lo.Ternary(hasMore, "yes", "no")) + pterm.Printf("\nPage: %d Per-page: %d Items this page: %d Has more: %s\n", page, perPage, itemsThisPage, lo.Ternary(hasMore, "yes", "no")) if hasMore { nextPage := page + 1 nextCmd := fmt.Sprintf("kernel app list --page %d --per-page %d", nextPage, perPage) if appName != "" { - nextCmd += fmt.Sprintf(" --name %s", appName) + nextCmd += fmt.Sprintf(" --name %s", quoteIfNeeded(appName)) + } + if query != "" { + nextCmd += fmt.Sprintf(" --query %s", quoteIfNeeded(query)) } if version != "" { - nextCmd += fmt.Sprintf(" --version %s", version) + nextCmd += fmt.Sprintf(" --version %s", quoteIfNeeded(version)) } - fmt.Printf("Next: %s\n", nextCmd) + pterm.Printf("Next: %s\n", nextCmd) } // Concise notes when user-specified per-page/limit/page are outside API-allowed range if cmd.Flags().Changed("per-page") { diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index 26851de..87dd2af 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -19,6 +19,7 @@ import ( 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) + Update(ctx context.Context, id string, body kernel.AuthConnectionUpdateParams, 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) @@ -53,6 +54,29 @@ type AuthConnectionGetInput struct { Output string } +type AuthConnectionUpdateInput struct { + ID string + LoginURL string + LoginURLSet bool + AllowedDomains []string + AllowedDomainsSet bool + CredentialName string + CredentialNameSet bool + CredentialProvider string + CredentialProviderSet bool + CredentialPath string + CredentialPathSet bool + CredentialAuto BoolFlag + ProxyID string + ProxyIDSet bool + ProxyName string + ProxyNameSet bool + SaveCredentials BoolFlag + HealthCheckInterval int + HealthCheckIntervalSet bool + Output string +} + type AuthConnectionListInput struct { Domain string ProfileName string @@ -77,7 +101,9 @@ type AuthConnectionSubmitInput struct { ID string FieldValues map[string]string MfaOptionID string + SignInOptionID string SSOButtonSelector string + SSOProvider string Output string } @@ -159,7 +185,11 @@ func (c AuthConnectionCmd) Create(ctx context.Context, in AuthConnectionCreateIn } pterm.Success.Printf("Created managed auth: %s\n", auth.ID) + printManagedAuthSummary(auth) + return nil +} +func printManagedAuthSummary(auth *kernel.ManagedAuth) { tableData := pterm.TableData{ {"Property", "Value"}, {"ID", auth.ID}, @@ -177,8 +207,91 @@ func (c AuthConnectionCmd) Create(ctx context.Context, in AuthConnectionCreateIn if auth.Credential.Provider != "" { tableData = append(tableData, []string{"Credential Provider", auth.Credential.Provider}) } - + if auth.ProxyID != "" { + tableData = append(tableData, []string{"Proxy ID", auth.ProxyID}) + } PrintTableNoPad(tableData, true) +} + +func (c AuthConnectionCmd) Update(ctx context.Context, in AuthConnectionUpdateInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + + params := kernel.AuthConnectionUpdateParams{ + ManagedAuthUpdateRequest: kernel.ManagedAuthUpdateRequestParam{}, + } + hasChanges := false + + if in.HealthCheckIntervalSet { + params.ManagedAuthUpdateRequest.HealthCheckInterval = kernel.Opt(int64(in.HealthCheckInterval)) + hasChanges = true + } + if in.LoginURLSet { + params.ManagedAuthUpdateRequest.LoginURL = kernel.Opt(in.LoginURL) + hasChanges = true + } + if in.SaveCredentials.Set { + params.ManagedAuthUpdateRequest.SaveCredentials = kernel.Opt(in.SaveCredentials.Value) + hasChanges = true + } + if in.AllowedDomainsSet { + params.ManagedAuthUpdateRequest.AllowedDomains = in.AllowedDomains + hasChanges = true + } + + credentialChanged := in.CredentialNameSet || in.CredentialProviderSet || in.CredentialPathSet || in.CredentialAuto.Set + if credentialChanged { + if strings.TrimSpace(in.CredentialName) != "" && strings.TrimSpace(in.CredentialProvider) != "" { + return fmt.Errorf("credential reference must use either --credential-name or --credential-provider") + } + params.ManagedAuthUpdateRequest.Credential = kernel.ManagedAuthUpdateRequestCredentialParam{} + if in.CredentialNameSet { + params.ManagedAuthUpdateRequest.Credential.Name = kernel.Opt(in.CredentialName) + } + if in.CredentialProviderSet { + params.ManagedAuthUpdateRequest.Credential.Provider = kernel.Opt(in.CredentialProvider) + } + if in.CredentialPathSet { + params.ManagedAuthUpdateRequest.Credential.Path = kernel.Opt(in.CredentialPath) + } + if in.CredentialAuto.Set { + params.ManagedAuthUpdateRequest.Credential.Auto = kernel.Opt(in.CredentialAuto.Value) + } + hasChanges = true + } + + proxyChanged := in.ProxyIDSet || in.ProxyNameSet + if proxyChanged { + params.ManagedAuthUpdateRequest.Proxy = kernel.ManagedAuthUpdateRequestProxyParam{} + if in.ProxyIDSet { + params.ManagedAuthUpdateRequest.Proxy.ID = kernel.Opt(in.ProxyID) + } + if in.ProxyNameSet { + params.ManagedAuthUpdateRequest.Proxy.Name = kernel.Opt(in.ProxyName) + } + hasChanges = true + } + + if !hasChanges { + return fmt.Errorf("must provide at least one field to update") + } + + if in.Output != "json" { + pterm.Info.Printf("Updating managed auth %s...\n", in.ID) + } + + auth, err := c.svc.Update(ctx, in.ID, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(auth) + } + + pterm.Success.Printf("Updated managed auth: %s\n", auth.ID) + printManagedAuthSummary(auth) return nil } @@ -444,10 +557,21 @@ func (c AuthConnectionCmd) Submit(ctx context.Context, in AuthConnectionSubmitIn // Validate that we have some input to submit hasFields := len(in.FieldValues) > 0 hasMfaOption := in.MfaOptionID != "" + hasSignInOption := in.SignInOptionID != "" hasSSOButton := in.SSOButtonSelector != "" + hasSSOProvider := in.SSOProvider != "" + submitModes := 0 + for _, active := range []bool{hasFields, hasMfaOption, hasSignInOption, hasSSOButton, hasSSOProvider} { + if active { + submitModes++ + } + } - if !hasFields && !hasMfaOption && !hasSSOButton { - return fmt.Errorf("must provide at least one of: --field, --mfa-option-id, or --sso-button-selector") + 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") + } + if submitModes > 1 { + return fmt.Errorf("provide exactly one of: --field, --mfa-option-id, --sign-in-option-id, --sso-button-selector, or --sso-provider") } // Resolve MFA option: the user may pass the label (e.g. "Get a text"), the @@ -489,9 +613,15 @@ func (c AuthConnectionCmd) Submit(ctx context.Context, in AuthConnectionSubmitIn if hasMfaOption { params.SubmitFieldsRequest.MfaOptionID = kernel.Opt(in.MfaOptionID) } + if hasSignInOption { + params.SubmitFieldsRequest.SignInOptionID = kernel.Opt(in.SignInOptionID) + } if hasSSOButton { params.SubmitFieldsRequest.SSOButtonSelector = kernel.Opt(in.SSOButtonSelector) } + if hasSSOProvider { + params.SubmitFieldsRequest.SSOProvider = kernel.Opt(in.SSOProvider) + } if in.Output != "json" { pterm.Info.Println("Submitting to managed auth...") @@ -594,6 +724,14 @@ var authConnectionsCreateCmd = &cobra.Command{ RunE: runAuthConnectionsCreate, } +var authConnectionsUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a managed auth connection", + Long: "Update managed authentication settings like login URL, health checks, credential source, and proxy.", + Args: cobra.ExactArgs(1), + RunE: runAuthConnectionsUpdate, +} + var authConnectionsGetCmd = &cobra.Command{ Use: "get ", Short: "Get a managed auth by ID", @@ -666,10 +804,27 @@ func init() { authConnectionsCreateCmd.Flags().Int("health-check-interval", 0, "Interval in seconds between health checks (300-86400)") _ = authConnectionsCreateCmd.MarkFlagRequired("domain") _ = authConnectionsCreateCmd.MarkFlagRequired("profile-name") + authConnectionsCreateCmd.MarkFlagsMutuallyExclusive("credential-name", "credential-provider") // Get flags authConnectionsGetCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + // Update flags + authConnectionsUpdateCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + authConnectionsUpdateCmd.Flags().String("login-url", "", "Login page URL (set to empty string to clear)") + authConnectionsUpdateCmd.Flags().StringSlice("allowed-domain", []string{}, "Additional allowed domains (replaces existing list)") + authConnectionsUpdateCmd.Flags().String("credential-name", "", "Kernel credential name to use") + authConnectionsUpdateCmd.Flags().String("credential-provider", "", "External credential provider name") + authConnectionsUpdateCmd.Flags().String("credential-path", "", "Provider-specific path (e.g., VaultName/ItemName)") + authConnectionsUpdateCmd.Flags().Bool("credential-auto", false, "Lookup by domain from the specified provider") + authConnectionsUpdateCmd.Flags().String("proxy-id", "", "Proxy ID to use") + authConnectionsUpdateCmd.Flags().String("proxy-name", "", "Proxy name to use") + 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.MarkFlagsMutuallyExclusive("credential-name", "credential-provider") + authConnectionsUpdateCmd.MarkFlagsMutuallyExclusive("save-credentials", "no-save-credentials") + // List flags authConnectionsListCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") authConnectionsListCmd.Flags().String("domain", "", "Filter by domain") @@ -689,13 +844,16 @@ func init() { 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("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") + authConnectionsSubmitCmd.Flags().String("sso-provider", "", "SSO provider if user chose an SSO button by provider (e.g. google, github)") // Follow flags authConnectionsFollowCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") // Wire up commands authConnectionsCmd.AddCommand(authConnectionsCreateCmd) + authConnectionsCmd.AddCommand(authConnectionsUpdateCmd) authConnectionsCmd.AddCommand(authConnectionsGetCmd) authConnectionsCmd.AddCommand(authConnectionsListCmd) authConnectionsCmd.AddCommand(authConnectionsDeleteCmd) @@ -753,6 +911,55 @@ func runAuthConnectionsGet(cmd *cobra.Command, args []string) error { }) } +func runAuthConnectionsUpdate(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + 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") + proxyName, _ := cmd.Flags().GetString("proxy-name") + saveCredentials, _ := cmd.Flags().GetBool("save-credentials") + noSaveCredentials, _ := cmd.Flags().GetBool("no-save-credentials") + healthCheckInterval, _ := cmd.Flags().GetInt("health-check-interval") + + saveCredentialsFlag := BoolFlag{} + if cmd.Flags().Changed("save-credentials") { + saveCredentialsFlag = BoolFlag{Set: true, Value: saveCredentials} + } + if cmd.Flags().Changed("no-save-credentials") { + saveCredentialsFlag = BoolFlag{Set: true, Value: !noSaveCredentials} + } + + svc := client.Auth.Connections + c := AuthConnectionCmd{svc: &svc} + return c.Update(cmd.Context(), AuthConnectionUpdateInput{ + ID: args[0], + LoginURL: loginURL, + LoginURLSet: cmd.Flags().Changed("login-url"), + AllowedDomains: allowedDomains, + AllowedDomainsSet: cmd.Flags().Changed("allowed-domain"), + CredentialName: credentialName, + CredentialNameSet: cmd.Flags().Changed("credential-name"), + CredentialProvider: credentialProvider, + CredentialProviderSet: cmd.Flags().Changed("credential-provider"), + CredentialPath: credentialPath, + CredentialPathSet: cmd.Flags().Changed("credential-path"), + CredentialAuto: BoolFlag{Set: cmd.Flags().Changed("credential-auto"), Value: credentialAuto}, + ProxyID: proxyID, + ProxyIDSet: cmd.Flags().Changed("proxy-id"), + ProxyName: proxyName, + ProxyNameSet: cmd.Flags().Changed("proxy-name"), + SaveCredentials: saveCredentialsFlag, + HealthCheckInterval: healthCheckInterval, + HealthCheckIntervalSet: cmd.Flags().Changed("health-check-interval"), + Output: output, + }) +} + func runAuthConnectionsList(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) output, _ := cmd.Flags().GetString("output") @@ -805,7 +1012,9 @@ func runAuthConnectionsSubmit(cmd *cobra.Command, args []string) error { output, _ := cmd.Flags().GetString("output") fieldPairs, _ := cmd.Flags().GetStringArray("field") mfaOptionID, _ := cmd.Flags().GetString("mfa-option-id") + signInOptionID, _ := cmd.Flags().GetString("sign-in-option-id") ssoButtonSelector, _ := cmd.Flags().GetString("sso-button-selector") + ssoProvider, _ := cmd.Flags().GetString("sso-provider") // Parse field pairs into map fieldValues := make(map[string]string) @@ -823,7 +1032,9 @@ func runAuthConnectionsSubmit(cmd *cobra.Command, args []string) error { ID: args[0], FieldValues: fieldValues, MfaOptionID: mfaOptionID, + SignInOptionID: signInOptionID, SSOButtonSelector: ssoButtonSelector, + SSOProvider: ssoProvider, Output: output, }) } diff --git a/cmd/auth_connections_test.go b/cmd/auth_connections_test.go index ca12c91..d6615f7 100644 --- a/cmd/auth_connections_test.go +++ b/cmd/auth_connections_test.go @@ -20,6 +20,7 @@ import ( type FakeAuthConnectionService struct { NewFunc func(ctx context.Context, body kernel.AuthConnectionNewParams, opts ...option.RequestOption) (*kernel.ManagedAuth, error) GetFunc func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) + UpdateFunc func(ctx context.Context, id string, body kernel.AuthConnectionUpdateParams, opts ...option.RequestOption) (*kernel.ManagedAuth, error) ListFunc func(ctx context.Context, query kernel.AuthConnectionListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.ManagedAuth], error) 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) @@ -41,6 +42,13 @@ func (f *FakeAuthConnectionService) Get(ctx context.Context, id string, opts ... return nil, errors.New("not found") } +func (f *FakeAuthConnectionService) Update(ctx context.Context, id string, body kernel.AuthConnectionUpdateParams, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + if f.UpdateFunc != nil { + return f.UpdateFunc(ctx, id, body, opts...) + } + return &kernel.ManagedAuth{ID: id}, nil +} + func (f *FakeAuthConnectionService) List(ctx context.Context, query kernel.AuthConnectionListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.ManagedAuth], error) { if f.ListFunc != nil { return f.ListFunc(ctx, query, opts...) @@ -197,6 +205,56 @@ func TestAuthConnectionsList_JSONOutput_PrintsRawResponse(t *testing.T) { assert.Contains(t, out, "\"raf-leaseweb\"") } +func TestAuthConnectionsUpdate_MapsParams(t *testing.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, + Domain: "example.com", + ProfileName: "profile-1", + Status: kernel.ManagedAuthStatusAuthenticated, + }, nil + }, + } + + c := AuthConnectionCmd{svc: fake} + err := c.Update(context.Background(), AuthConnectionUpdateInput{ + ID: "conn-1", + LoginURL: "https://login.example.com", + LoginURLSet: true, + AllowedDomains: []string{"example.com", "login.example.com"}, + AllowedDomainsSet: true, + CredentialProvider: "vault-provider", + CredentialProviderSet: true, + CredentialPath: "Vault/Item", + CredentialPathSet: true, + CredentialAuto: BoolFlag{Set: true, Value: true}, + ProxyID: "proxy-123", + ProxyIDSet: true, + SaveCredentials: BoolFlag{Set: true, Value: false}, + HealthCheckInterval: 900, + HealthCheckIntervalSet: true, + }) + require.NoError(t, err) + require.True(t, captured.ManagedAuthUpdateRequest.LoginURL.Valid()) + assert.Equal(t, "https://login.example.com", captured.ManagedAuthUpdateRequest.LoginURL.Value) + assert.Equal(t, []string{"example.com", "login.example.com"}, captured.ManagedAuthUpdateRequest.AllowedDomains) + require.True(t, captured.ManagedAuthUpdateRequest.Credential.Provider.Valid()) + assert.Equal(t, "vault-provider", captured.ManagedAuthUpdateRequest.Credential.Provider.Value) + require.True(t, captured.ManagedAuthUpdateRequest.Credential.Path.Valid()) + assert.Equal(t, "Vault/Item", captured.ManagedAuthUpdateRequest.Credential.Path.Value) + require.True(t, captured.ManagedAuthUpdateRequest.Credential.Auto.Valid()) + assert.True(t, captured.ManagedAuthUpdateRequest.Credential.Auto.Value) + require.True(t, captured.ManagedAuthUpdateRequest.Proxy.ID.Valid()) + assert.Equal(t, "proxy-123", captured.ManagedAuthUpdateRequest.Proxy.ID.Value) + require.True(t, captured.ManagedAuthUpdateRequest.SaveCredentials.Valid()) + assert.False(t, captured.ManagedAuthUpdateRequest.SaveCredentials.Value) + require.True(t, captured.ManagedAuthUpdateRequest.HealthCheckInterval.Valid()) + assert.Equal(t, int64(900), captured.ManagedAuthUpdateRequest.HealthCheckInterval.Value) +} + func newFakeWithMfaOptions(options []kernel.ManagedAuthMfaOption) *FakeAuthConnectionService { return &FakeAuthConnectionService{ GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { @@ -233,6 +291,44 @@ func TestSubmit_MfaOptionResolvesType(t *testing.T) { assert.Equal(t, "sms", submittedID) } +func TestSubmit_SSOProviderMapped(t *testing.T) { + var provider string + fake := &FakeAuthConnectionService{ + SubmitFunc: func(ctx context.Context, id string, body kernel.AuthConnectionSubmitParams, opts ...option.RequestOption) (*kernel.SubmitFieldsResponse, error) { + provider = body.SubmitFieldsRequest.SSOProvider.Value + return &kernel.SubmitFieldsResponse{Accepted: true}, nil + }, + } + + c := AuthConnectionCmd{svc: fake} + err := c.Submit(context.Background(), AuthConnectionSubmitInput{ + ID: "conn-1", + SSOProvider: "google", + Output: "json", + }) + require.NoError(t, err) + assert.Equal(t, "google", provider) +} + +func TestSubmit_SignInOptionMapped(t *testing.T) { + var signInOption string + fake := &FakeAuthConnectionService{ + SubmitFunc: func(ctx context.Context, id string, body kernel.AuthConnectionSubmitParams, opts ...option.RequestOption) (*kernel.SubmitFieldsResponse, error) { + signInOption = body.SubmitFieldsRequest.SignInOptionID.Value + return &kernel.SubmitFieldsResponse{Accepted: true}, nil + }, + } + + c := AuthConnectionCmd{svc: fake} + err := c.Submit(context.Background(), AuthConnectionSubmitInput{ + ID: "conn-1", + SignInOptionID: "pick-account", + Output: "json", + }) + require.NoError(t, err) + assert.Equal(t, "pick-account", signInOption) +} + func TestSubmit_MfaOptionResolvesLabel(t *testing.T) { fake := newFakeWithMfaOptions([]kernel.ManagedAuthMfaOption{ {Label: "Get a text", Type: "sms"}, diff --git a/cmd/browsers.go b/cmd/browsers.go index b996b6c..d799667 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -177,6 +177,7 @@ type BrowsersCreateInput struct { Stealth BoolFlag Headless BoolFlag GPU BoolFlag + InvocationID string Kiosk BoolFlag ProfileID string ProfileName string @@ -197,8 +198,9 @@ type BrowsersViewInput struct { } type BrowsersGetInput struct { - Identifier string - Output string + Identifier string + IncludeDeleted bool + Output string } type BrowsersUpdateInput struct { @@ -361,6 +363,9 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { if in.GPU.Set { params.GPU = kernel.Opt(in.GPU.Value) } + if in.InvocationID != "" { + params.InvocationID = kernel.Opt(in.InvocationID) + } if in.Kiosk.Set { params.KioskMode = kernel.Opt(in.Kiosk.Value) } @@ -526,7 +531,12 @@ func (b BrowsersCmd) Get(ctx context.Context, in BrowsersGetInput) error { return fmt.Errorf("unsupported --output value: use 'json'") } - browser, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) + query := kernel.BrowserGetParams{} + if in.IncludeDeleted { + query.IncludeDeleted = kernel.Opt(true) + } + + browser, err := b.browsers.Get(ctx, in.Identifier, query) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -2228,6 +2238,7 @@ func init() { // get flags browsersGetCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + browsersGetCmd.Flags().Bool("include-deleted", false, "Include soft-deleted browser sessions in the lookup") // view flags browsersViewCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") @@ -2494,6 +2505,7 @@ func init() { browsersCreateCmd.Flags().BoolP("stealth", "s", false, "Launch browser in stealth mode to avoid detection") browsersCreateCmd.Flags().BoolP("headless", "H", false, "Launch browser without GUI access") browsersCreateCmd.Flags().Bool("gpu", false, "Launch browser with hardware-accelerated GPU rendering") + browsersCreateCmd.Flags().String("invocation-id", "", "Associate the browser session with an invocation") browsersCreateCmd.Flags().Bool("kiosk", false, "Launch browser in kiosk mode") browsersCreateCmd.Flags().IntP("timeout", "t", 60, "Timeout in seconds for the browser session") browsersCreateCmd.Flags().String("profile-id", "", "Profile ID to load into the browser session (mutually exclusive with --profile-name)") @@ -2540,6 +2552,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { stealthVal, _ := cmd.Flags().GetBool("stealth") headlessVal, _ := cmd.Flags().GetBool("headless") gpuVal, _ := cmd.Flags().GetBool("gpu") + invocationID, _ := cmd.Flags().GetString("invocation-id") kioskVal, _ := cmd.Flags().GetBool("kiosk") timeout, _ := cmd.Flags().GetInt("timeout") profileID, _ := cmd.Flags().GetString("profile-id") @@ -2652,6 +2665,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealthVal}, Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headlessVal}, GPU: BoolFlag{Set: cmd.Flags().Changed("gpu"), Value: gpuVal}, + InvocationID: invocationID, Kiosk: BoolFlag{Set: cmd.Flags().Changed("kiosk"), Value: kioskVal}, ProfileID: profileID, ProfileName: profileName, @@ -2696,12 +2710,14 @@ func runBrowsersView(cmd *cobra.Command, args []string) error { func runBrowsersGet(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) out, _ := cmd.Flags().GetString("output") + includeDeleted, _ := cmd.Flags().GetBool("include-deleted") svc := client.Browsers b := BrowsersCmd{browsers: &svc} return b.Get(cmd.Context(), BrowsersGetInput{ - Identifier: args[0], - Output: out, + Identifier: args[0], + IncludeDeleted: includeDeleted, + Output: out, }) } diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index e37cce1..2bb2c71 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -248,6 +248,25 @@ func TestBrowsersCreate_PrintsResponse(t *testing.T) { assert.Contains(t, out, "pid-new") } +func TestBrowsersCreate_WithInvocationID(t *testing.T) { + setupStdoutCapture(t) + var captured kernel.BrowserNewParams + fake := &FakeBrowsersService{ + NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) { + captured = body + return &kernel.BrowserNewResponse{SessionID: "sess-new", CdpWsURL: "ws://cdp-new"}, nil + }, + } + + b := BrowsersCmd{browsers: fake} + err := b.Create(context.Background(), BrowsersCreateInput{ + InvocationID: "invocation-123", + }) + assert.NoError(t, err) + assert.True(t, captured.InvocationID.Valid()) + assert.Equal(t, "invocation-123", captured.InvocationID.Value) +} + func TestBrowsersCreate_PrintsErrorOnFailure(t *testing.T) { setupStdoutCapture(t) @@ -472,6 +491,26 @@ func TestBrowsersGet_Error(t *testing.T) { assert.Contains(t, err.Error(), "get failed") } +func TestBrowsersGet_WithIncludeDeleted(t *testing.T) { + setupStdoutCapture(t) + var captured kernel.BrowserGetParams + fake := &FakeBrowsersService{ + GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + captured = query + return &kernel.BrowserGetResponse{SessionID: "sess-123"}, nil + }, + } + + b := BrowsersCmd{browsers: fake} + err := b.Get(context.Background(), BrowsersGetInput{ + Identifier: "sess-123", + IncludeDeleted: true, + }) + assert.NoError(t, err) + assert.True(t, captured.IncludeDeleted.Valid()) + assert.True(t, captured.IncludeDeleted.Value) +} + // --- Fakes for sub-services --- type FakeReplaysService struct { diff --git a/cmd/credential_providers.go b/cmd/credential_providers.go index 0ed7133..22f983d 100644 --- a/cmd/credential_providers.go +++ b/cmd/credential_providers.go @@ -38,6 +38,7 @@ type CredentialProvidersGetInput struct { } type CredentialProvidersCreateInput struct { + Name string ProviderType string Token string CacheTtlSeconds int64 @@ -46,6 +47,7 @@ type CredentialProvidersCreateInput struct { type CredentialProvidersUpdateInput struct { ID string + Name string Token string CacheTtlSeconds int64 Enabled BoolFlag @@ -142,6 +144,9 @@ func (c CredentialProvidersCmd) Create(ctx context.Context, in CredentialProvide if in.ProviderType == "" { return fmt.Errorf("--provider-type is required") } + if in.Name == "" { + return fmt.Errorf("--name is required") + } if in.Token == "" { return fmt.Errorf("--token is required") } @@ -154,6 +159,7 @@ func (c CredentialProvidersCmd) Create(ctx context.Context, in CredentialProvide params := kernel.CredentialProviderNewParams{ CreateCredentialProviderRequest: kernel.CreateCredentialProviderRequestParam{ + Name: in.Name, Token: in.Token, ProviderType: kernel.CreateCredentialProviderRequestProviderTypeOnepassword, }, @@ -180,6 +186,7 @@ func (c CredentialProvidersCmd) Create(ctx context.Context, in CredentialProvide tableData := pterm.TableData{ {"Property", "Value"}, {"ID", provider.ID}, + {"Name", provider.Name}, {"Provider Type", string(provider.ProviderType)}, {"Enabled", fmt.Sprintf("%t", provider.Enabled)}, {"Priority", fmt.Sprintf("%d", provider.Priority)}, @@ -200,6 +207,9 @@ func (c CredentialProvidersCmd) Update(ctx context.Context, in CredentialProvide if in.Token != "" { params.UpdateCredentialProviderRequest.Token = kernel.Opt(in.Token) } + if in.Name != "" { + params.UpdateCredentialProviderRequest.Name = kernel.Opt(in.Name) + } if in.CacheTtlSeconds > 0 { params.UpdateCredentialProviderRequest.CacheTtlSeconds = kernel.Opt(in.CacheTtlSeconds) } @@ -421,14 +431,17 @@ func init() { // Create flags credentialProvidersCreateCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + credentialProvidersCreateCmd.Flags().String("name", "", "Human-readable name for this provider instance") credentialProvidersCreateCmd.Flags().String("provider-type", "", "Provider type (e.g., onepassword)") credentialProvidersCreateCmd.Flags().String("token", "", "Service account token for the provider") credentialProvidersCreateCmd.Flags().Int64("cache-ttl", 0, "How long to cache credential lists in seconds (default 300)") + _ = credentialProvidersCreateCmd.MarkFlagRequired("name") _ = credentialProvidersCreateCmd.MarkFlagRequired("provider-type") _ = credentialProvidersCreateCmd.MarkFlagRequired("token") // Update flags credentialProvidersUpdateCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + credentialProvidersUpdateCmd.Flags().String("name", "", "New human-readable name for this provider instance") credentialProvidersUpdateCmd.Flags().String("token", "", "New service account token (to rotate credentials)") credentialProvidersUpdateCmd.Flags().Int64("cache-ttl", 0, "How long to cache credential lists in seconds") credentialProvidersUpdateCmd.Flags().Bool("enabled", true, "Whether the provider is enabled for credential lookups") @@ -470,6 +483,7 @@ func runCredentialProvidersGet(cmd *cobra.Command, args []string) error { func runCredentialProvidersCreate(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) output, _ := cmd.Flags().GetString("output") + name, _ := cmd.Flags().GetString("name") providerType, _ := cmd.Flags().GetString("provider-type") token, _ := cmd.Flags().GetString("token") cacheTtl, _ := cmd.Flags().GetInt64("cache-ttl") @@ -477,6 +491,7 @@ func runCredentialProvidersCreate(cmd *cobra.Command, args []string) error { svc := client.CredentialProviders c := CredentialProvidersCmd{providers: &svc} return c.Create(cmd.Context(), CredentialProvidersCreateInput{ + Name: name, ProviderType: providerType, Token: token, CacheTtlSeconds: cacheTtl, @@ -487,6 +502,7 @@ func runCredentialProvidersCreate(cmd *cobra.Command, args []string) error { func runCredentialProvidersUpdate(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) output, _ := cmd.Flags().GetString("output") + name, _ := cmd.Flags().GetString("name") token, _ := cmd.Flags().GetString("token") cacheTtl, _ := cmd.Flags().GetInt64("cache-ttl") enabled, _ := cmd.Flags().GetBool("enabled") @@ -496,6 +512,7 @@ func runCredentialProvidersUpdate(cmd *cobra.Command, args []string) error { c := CredentialProvidersCmd{providers: &svc} return c.Update(cmd.Context(), CredentialProvidersUpdateInput{ ID: args[0], + Name: name, Token: token, CacheTtlSeconds: cacheTtl, Enabled: BoolFlag{Set: cmd.Flags().Changed("enabled"), Value: enabled}, diff --git a/cmd/deploy.go b/cmd/deploy.go index dd57dae..40417f7 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -31,6 +31,14 @@ var deployDeleteCmd = &cobra.Command{ RunE: runDeployDelete, } +var deployGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a deployment", + Long: "Retrieve detailed information about a deployment.", + Args: cobra.ExactArgs(1), + RunE: runDeployGet, +} + var deployLogsCmd = &cobra.Command{ Use: "logs ", Short: "Stream logs for a deployment", @@ -63,11 +71,15 @@ var deployGithubCmd = &cobra.Command{ func init() { deployCmd.Flags().String("version", "latest", "Specify a version for the app (default: latest)") deployCmd.Flags().Bool("force", false, "Allow overwrite of an existing version with the same name") + deployCmd.Flags().String("region", "", "Deployment region (currently only aws.us-east-1a)") deployCmd.Flags().StringArrayP("env", "e", []string{}, "Set environment variables (e.g., KEY=value). May be specified multiple times") deployCmd.Flags().StringArray("env-file", []string{}, "Read environment variables from a file (.env format). May be specified multiple times") deployCmd.Flags().StringP("output", "o", "", "Output format: json for JSONL streaming output") // Subcommands under deploy + deployGetCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + deployCmd.AddCommand(deployGetCmd) + deployLogsCmd.Flags().BoolP("follow", "f", false, "Follow logs in real-time (stream continuously)") deployLogsCmd.Flags().StringP("since", "s", "", "How far back to retrieve logs. Supports duration formats: ns, us, ms, s, m, h (e.g., 5m, 2h, 1h30m). Note: 'd' not supported; use hours instead. Can also specify timestamps: 2006-01-02, 2006-01-02T15:04, 2006-01-02T15:04:05, 2006-01-02T15:04:05.000. Max lookback ~167h.") deployLogsCmd.Flags().BoolP("with-timestamps", "t", false, "Include timestamps in each log line") @@ -79,6 +91,7 @@ func init() { deployHistoryCmd.Flags().Int("limit", 20, "Max deployments to return (default 20)") deployHistoryCmd.Flags().Int("per-page", 20, "Items per page (alias of --limit)") deployHistoryCmd.Flags().Int("page", 1, "Page number (1-based)") + deployHistoryCmd.Flags().String("app-version", "", "Filter by application version (requires app_name)") deployHistoryCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") deployCmd.AddCommand(deployHistoryCmd) @@ -88,6 +101,7 @@ func init() { deployGithubCmd.Flags().String("entrypoint", "", "Entrypoint within the repo/path (e.g., src/index.ts)") deployGithubCmd.Flags().String("path", "", "Optional subdirectory within the repo (e.g., apps/api)") deployGithubCmd.Flags().String("github-token", "", "GitHub token for private repositories (PAT or installation access token)") + deployGithubCmd.Flags().String("region", "aws.us-east-1a", "Deployment region (currently only aws.us-east-1a)") _ = deployGithubCmd.MarkFlagRequired("url") _ = deployGithubCmd.MarkFlagRequired("ref") _ = deployGithubCmd.MarkFlagRequired("entrypoint") @@ -102,6 +116,7 @@ func runDeployGithub(cmd *cobra.Command, args []string) error { entrypoint, _ := cmd.Flags().GetString("entrypoint") subpath, _ := cmd.Flags().GetString("path") ghToken, _ := cmd.Flags().GetString("github-token") + region, _ := cmd.Flags().GetString("region") version, _ := cmd.Flags().GetString("version") force, _ := cmd.Flags().GetBool("force") @@ -148,12 +163,18 @@ func runDeployGithub(cmd *cobra.Command, args []string) error { return fmt.Errorf("KERNEL_API_KEY is required for github deploy") } baseURL := util.GetBaseURL() + if region == "" { + region = string(kernel.DeploymentNewParamsRegionAwsUsEast1a) + } + if region != string(kernel.DeploymentNewParamsRegionAwsUsEast1a) { + return fmt.Errorf("invalid --region value: %s (must be %s)", region, kernel.DeploymentNewParamsRegionAwsUsEast1a) + } var body bytes.Buffer mw := multipart.NewWriter(&body) // regular fields _ = mw.WriteField("version", version) - _ = mw.WriteField("region", "aws.us-east-1a") + _ = mw.WriteField("region", region) if force { _ = mw.WriteField("force", "true") } else { @@ -220,6 +241,7 @@ func runDeploy(cmd *cobra.Command, args []string) (err error) { entrypoint := args[0] version, _ := cmd.Flags().GetString("version") force, _ := cmd.Flags().GetBool("force") + region, _ := cmd.Flags().GetString("region") output, _ := cmd.Flags().GetString("output") if output != "" && output != "json" { @@ -293,13 +315,21 @@ func runDeploy(cmd *cobra.Command, args []string) (err error) { pterm.Info.Println("Deploying...") } - resp, err := client.Deployments.New(cmd.Context(), kernel.DeploymentNewParams{ + params := kernel.DeploymentNewParams{ File: file, Version: kernel.Opt(version), Force: kernel.Opt(force), EntrypointRelPath: kernel.Opt(filepath.Base(resolvedEntrypoint)), EnvVars: envVars, - }, option.WithMaxRetries(0)) + } + if region != "" { + if region != string(kernel.DeploymentNewParamsRegionAwsUsEast1a) { + return fmt.Errorf("invalid --region value: %s (must be %s)", region, kernel.DeploymentNewParamsRegionAwsUsEast1a) + } + params.Region = kernel.DeploymentNewParamsRegion(region) + } + + resp, err := client.Deployments.New(cmd.Context(), params, option.WithMaxRetries(0)) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -307,6 +337,44 @@ func runDeploy(cmd *cobra.Command, args []string) (err error) { return followDeployment(cmd.Context(), client, resp.ID, startTime, output, option.WithMaxRetries(0)) } +func runDeployGet(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + + if output != "" && output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + + deployment, err := client.Deployments.Get(cmd.Context(), args[0]) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if output == "json" { + return util.PrintPrettyJSON(deployment) + } + + tableData := pterm.TableData{ + {"Property", "Value"}, + {"ID", deployment.ID}, + {"Status", string(deployment.Status)}, + {"Status Reason", deployment.StatusReason}, + {"Region", string(deployment.Region)}, + {"Entrypoint", deployment.EntrypointRelPath}, + {"Created At", util.FormatLocal(deployment.CreatedAt)}, + {"Updated At", util.FormatLocal(deployment.UpdatedAt)}, + } + if len(deployment.EnvVars) > 0 { + envVars := lo.MapToSlice(deployment.EnvVars, func(key, value string) string { + return fmt.Sprintf("%s=%s", key, value) + }) + tableData = append(tableData, []string{"Env Vars", strings.Join(envVars, "\n")}) + } + + PrintTableNoPad(tableData, true) + return nil +} + func quoteIfNeeded(s string) string { if strings.ContainsRune(s, ' ') { return fmt.Sprintf("\"%s\"", s) @@ -418,6 +486,7 @@ func runDeployHistory(cmd *cobra.Command, args []string) error { lim, _ := cmd.Flags().GetInt("limit") perPage, _ := cmd.Flags().GetInt("per-page") page, _ := cmd.Flags().GetInt("page") + appVersionFilter, _ := cmd.Flags().GetString("app-version") output, _ := cmd.Flags().GetString("output") if output != "" && output != "json" { @@ -445,11 +514,17 @@ func runDeployHistory(cmd *cobra.Command, args []string) error { if len(args) == 1 { appNameFilter = strings.TrimSpace(args[0]) } + if appVersionFilter != "" && appNameFilter == "" { + return fmt.Errorf("--app-version requires app_name") + } params := kernel.DeploymentListParams{} if appNameFilter != "" { params.AppName = kernel.Opt(appNameFilter) } + if appVersionFilter != "" { + params.AppVersion = kernel.Opt(appVersionFilter) + } // Request one extra item to detect hasMore params.Limit = kernel.Opt(int64(perPage + 1)) params.Offset = kernel.Opt(int64((page - 1) * perPage)) @@ -499,14 +574,17 @@ func runDeployHistory(cmd *cobra.Command, args []string) error { } pterm.DefaultTable.WithHasHeader().WithData(table).Render() - fmt.Printf("\nPage: %d Per-page: %d Items this page: %d Has more: %s\n", page, perPage, itemsThisPage, lo.Ternary(hasMore, "yes", "no")) + pterm.Printf("\nPage: %d Per-page: %d Items this page: %d Has more: %s\n", page, perPage, itemsThisPage, lo.Ternary(hasMore, "yes", "no")) if hasMore { nextPage := page + 1 nextCmd := fmt.Sprintf("kernel deploy history --page %d --per-page %d", nextPage, perPage) if appNameFilter != "" { - nextCmd = fmt.Sprintf("kernel deploy history %s --page %d --per-page %d", appNameFilter, nextPage, perPage) + nextCmd = fmt.Sprintf("kernel deploy history %s --page %d --per-page %d", quoteIfNeeded(appNameFilter), nextPage, perPage) + } + if appVersionFilter != "" { + nextCmd += fmt.Sprintf(" --app-version %s", quoteIfNeeded(appVersionFilter)) } - fmt.Printf("Next: %s\n", nextCmd) + pterm.Printf("Next: %s\n", nextCmd) } // Concise notes when user-specified per-page/limit/page are outside API-allowed range if cmd.Flags().Changed("per-page") { diff --git a/cmd/invoke.go b/cmd/invoke.go index 20e0066..1c1543e 100644 --- a/cmd/invoke.go +++ b/cmd/invoke.go @@ -43,23 +43,62 @@ var invocationBrowsersCmd = &cobra.Command{ RunE: runInvocationBrowsers, } +var invocationGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get an invocation", + Args: cobra.ExactArgs(1), + RunE: runInvocationGet, +} + +var invocationUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update an invocation", + Long: "Update an invocation status and optional output payload.", + Args: cobra.ExactArgs(1), + RunE: runInvocationUpdate, +} + +var invocationDeleteBrowsersCmd = &cobra.Command{ + Use: "delete-browsers ", + Short: "Delete browser sessions for an invocation", + Long: "Delete all browser sessions associated with an invocation.", + Args: cobra.ExactArgs(1), + RunE: runInvocationDeleteBrowsers, +} + 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().String("since", "", "Show invocation events since the given time when following async execution") invokeCmd.Flags().StringP("output", "o", "", "Output format: json for JSONL streaming output") invokeCmd.MarkFlagsMutuallyExclusive("payload", "payload-file") invocationHistoryCmd.Flags().Int("limit", 100, "Max invocations to return (default 100)") + invocationHistoryCmd.Flags().String("action", "", "Filter by action name") invocationHistoryCmd.Flags().StringP("app", "a", "", "Filter by app name") + invocationHistoryCmd.Flags().String("deployment-id", "", "Filter by deployment ID") + invocationHistoryCmd.Flags().Int("offset", 0, "Number of results to skip") + invocationHistoryCmd.Flags().String("since", "", "Show invocations that started since the given time") + invocationHistoryCmd.Flags().String("status", "", "Filter by invocation status: queued, running, succeeded, failed") 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) + + invocationGetCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + invokeCmd.AddCommand(invocationGetCmd) + + invocationUpdateCmd.Flags().String("status", "", "New invocation status: succeeded or failed") + invocationUpdateCmd.Flags().String("output", "", "Updated invocation output rendered as a JSON string") + _ = invocationUpdateCmd.MarkFlagRequired("status") + invokeCmd.AddCommand(invocationUpdateCmd) + + invokeCmd.AddCommand(invocationDeleteBrowsersCmd) } func runInvoke(cmd *cobra.Command, args []string) error { @@ -83,6 +122,7 @@ func runInvoke(cmd *cobra.Command, args []string) error { } isSync, _ := cmd.Flags().GetBool("sync") asyncTimeout, _ := cmd.Flags().GetInt64("async-timeout") + since, _ := cmd.Flags().GetString("since") params := kernel.InvocationNewParams{ AppName: appName, ActionName: actionName, @@ -185,7 +225,9 @@ func runInvoke(cmd *cobra.Command, args []string) error { }) // Start following events - stream := client.Invocations.FollowStreaming(cmd.Context(), resp.ID, kernel.InvocationFollowParams{}, option.WithMaxRetries(0)) + stream := client.Invocations.FollowStreaming(cmd.Context(), resp.ID, kernel.InvocationFollowParams{ + Since: kernel.Opt(since), + }, option.WithMaxRetries(0)) for stream.Next() { ev := stream.Current() @@ -271,24 +313,53 @@ func handleSdkError(err error) error { } func printResult(success bool, output string) { - var prettyJSON map[string]interface{} - if err := json.Unmarshal([]byte(output), &prettyJSON); err == nil { - // Use a custom encoder to prevent escaping &, <, > as \u0026, \u003c, \u003e - // which breaks copy/paste of URLs in the invoke output. + output = formatJSONValue(output) + // use pterm.Success if succeeded, pterm.Error if failed + if success { + pterm.Success.Printf("Result:\n%s\n", output) + } else { + pterm.Error.Printf("Result:\n%s\n", output) + } +} + +func formatJSONValue(value string) string { + var prettyJSON interface{} + if err := json.Unmarshal([]byte(value), &prettyJSON); err == nil { var buf bytes.Buffer encoder := json.NewEncoder(&buf) encoder.SetEscapeHTML(false) encoder.SetIndent("", " ") if err := encoder.Encode(prettyJSON); err == nil { - output = strings.TrimSuffix(buf.String(), "\n") + return strings.TrimSuffix(buf.String(), "\n") } } - // use pterm.Success if succeeded, pterm.Error if failed - if success { - pterm.Success.Printf("Result:\n%s\n", output) - } else { - pterm.Error.Printf("Result:\n%s\n", output) + return value +} + +func printInvocationDetails(id, appName, actionName, version string, startedAt, finishedAt time.Time, status, statusReason, payload, output string) { + table := pterm.TableData{ + {"Property", "Value"}, + {"ID", id}, + {"App Name", appName}, + {"Action", actionName}, + {"Version", version}, + {"Status", status}, + {"Started At", util.FormatLocal(startedAt)}, + } + if !finishedAt.IsZero() { + table = append(table, []string{"Finished At", util.FormatLocal(finishedAt)}) } + if statusReason != "" { + table = append(table, []string{"Status Reason", statusReason}) + } + if payload != "" { + table = append(table, []string{"Payload", formatJSONValue(payload)}) + } + if output != "" { + table = append(table, []string{"Output", formatJSONValue(output)}) + } + + PrintTableNoPad(table, true) } // getPayload reads the payload from either --payload flag or --payload-file flag. @@ -347,7 +418,12 @@ func runInvocationHistory(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) lim, _ := cmd.Flags().GetInt("limit") + actionFilter, _ := cmd.Flags().GetString("action") appFilter, _ := cmd.Flags().GetString("app") + deploymentID, _ := cmd.Flags().GetString("deployment-id") + offset, _ := cmd.Flags().GetInt("offset") + since, _ := cmd.Flags().GetString("since") + statusFilter, _ := cmd.Flags().GetString("status") versionFilter, _ := cmd.Flags().GetString("version") output, _ := cmd.Flags().GetString("output") @@ -360,15 +436,41 @@ func runInvocationHistory(cmd *cobra.Command, args []string) error { Limit: kernel.Opt(int64(lim)), } + if actionFilter != "" { + params.ActionName = kernel.Opt(actionFilter) + } // Only add app filter if specified if appFilter != "" { params.AppName = kernel.Opt(appFilter) } + if deploymentID != "" { + params.DeploymentID = kernel.Opt(deploymentID) + } + if offset > 0 { + params.Offset = kernel.Opt(int64(offset)) + } + if since != "" { + params.Since = kernel.Opt(since) + } // Only add version filter if specified if versionFilter != "" { params.Version = kernel.Opt(versionFilter) } + if statusFilter != "" { + switch strings.ToLower(statusFilter) { + case "queued": + params.Status = kernel.InvocationListParamsStatusQueued + case "running": + params.Status = kernel.InvocationListParamsStatusRunning + case "succeeded": + params.Status = kernel.InvocationListParamsStatusSucceeded + case "failed": + params.Status = kernel.InvocationListParamsStatusFailed + default: + return fmt.Errorf("invalid --status value: %s (must be queued, running, succeeded, or failed)", statusFilter) + } + } // Build debug message based on filters if output != "json" { @@ -501,3 +603,91 @@ func runInvocationBrowsers(cmd *cobra.Command, args []string) error { pterm.DefaultTable.WithHasHeader().WithData(table).Render() return nil } + +func runInvocationGet(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + + if output != "" && output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + + resp, err := client.Invocations.Get(cmd.Context(), args[0]) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if output == "json" { + return util.PrintPrettyJSON(resp) + } + + printInvocationDetails( + resp.ID, + resp.AppName, + resp.ActionName, + resp.Version, + resp.StartedAt, + resp.FinishedAt, + string(resp.Status), + resp.StatusReason, + resp.Payload, + resp.Output, + ) + return nil +} + +func runInvocationUpdate(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + status, _ := cmd.Flags().GetString("status") + output, _ := cmd.Flags().GetString("output") + + var parsedStatus kernel.InvocationUpdateParamsStatus + switch strings.ToLower(status) { + case "succeeded": + parsedStatus = kernel.InvocationUpdateParamsStatusSucceeded + case "failed": + parsedStatus = kernel.InvocationUpdateParamsStatusFailed + default: + return fmt.Errorf("invalid --status value: %s (must be succeeded or failed)", status) + } + + params := kernel.InvocationUpdateParams{Status: parsedStatus} + if cmd.Flags().Changed("output") { + if strings.TrimSpace(output) != "" { + var parsed interface{} + if err := json.Unmarshal([]byte(output), &parsed); err != nil { + return fmt.Errorf("invalid JSON for --output: %w", err) + } + } + params.Output = kernel.Opt(output) + } + + resp, err := client.Invocations.Update(cmd.Context(), args[0], params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + printInvocationDetails( + resp.ID, + resp.AppName, + resp.ActionName, + resp.Version, + resp.StartedAt, + resp.FinishedAt, + string(resp.Status), + resp.StatusReason, + resp.Payload, + resp.Output, + ) + return nil +} + +func runInvocationDeleteBrowsers(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + if err := client.Invocations.DeleteBrowsers(cmd.Context(), args[0]); err != nil { + return util.CleanedUpSdkError{Err: err} + } + + pterm.Success.Printf("Deleted browsers for invocation %s\n", args[0]) + return nil +} diff --git a/go.mod b/go.mod index 941cc50..bbc599b 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.44.0 + github.com/kernel/kernel-go-sdk v0.44.1-0.20260323174449-5e56fc5d99a6 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 github.com/samber/lo v1.51.0 diff --git a/go.sum b/go.sum index d0346f8..2c777ed 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.44.0 h1:GQKq4UAcI4WBgl2dTXBuuiEBZE3WCSTxe1S96MDru6w= -github.com/kernel/kernel-go-sdk v0.44.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.44.1-0.20260323174449-5e56fc5d99a6 h1:RBlGCN3IagI0b+XrWsb5FOUV/18tniuL6oHFAb7MMHE= +github.com/kernel/kernel-go-sdk v0.44.1-0.20260323174449-5e56fc5d99a6/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/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=