Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 69 additions & 35 deletions cmd/api_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,13 @@ func (c APIKeysCmd) Create(ctx context.Context, in APIKeysCreateInput) error {
return err
}
if in.Name == "" {
return fmt.Errorf("--name is required")
return util.RequiredFlag("--name", "<name>")
}

params := kernel.APIKeyNewParams{Name: in.Name}
if in.DaysToExpire.Set {
if in.DaysToExpire.Value < 1 || in.DaysToExpire.Value > 3650 {
return fmt.Errorf("--days-to-expire must be between 1 and 3650")
return fmt.Errorf("invalid --days-to-expire %d; use a value from 1 to 3650", in.DaysToExpire.Value)
}
params.DaysToExpire = kernel.Int(in.DaysToExpire.Value)
}
Expand All @@ -91,10 +91,10 @@ func (c APIKeysCmd) List(ctx context.Context, in APIKeysListInput) error {
return err
}
if in.Limit < 0 {
return fmt.Errorf("--limit must be non-negative")
return fmt.Errorf("invalid --limit %d; use 0 or a positive number", in.Limit)
}
if in.Offset < 0 {
return fmt.Errorf("--offset must be non-negative")
return fmt.Errorf("invalid --offset %d; use 0 or a positive number", in.Offset)
}

params := kernel.APIKeyListParams{}
Expand Down Expand Up @@ -126,14 +126,15 @@ func (c APIKeysCmd) List(ctx context.Context, in APIKeysListInput) error {

table := pterm.TableData{{"ID", "Name", "Scope", "Project", "Masked Key", "Expires At", "Created At"}}
for _, key := range keys {
display := newAPIKeyDisplay(key)
table = append(table, []string{
key.ID,
key.Name,
formatAPIKeyScope(key),
formatAPIKeyProject(key),
key.MaskedKey,
formatAPIKeyExpiresAt(key),
util.FormatLocal(key.CreatedAt),
display.ID,
display.Name,
display.Scope,
display.Project,
display.MaskedKey,
display.ExpiresAt,
display.CreatedAt,
})
}
PrintTableNoPad(table, true)
Expand Down Expand Up @@ -163,7 +164,7 @@ func (c APIKeysCmd) Update(ctx context.Context, in APIKeysUpdateInput) error {
return err
}
if in.Name == "" {
return fmt.Errorf("--name is required")
return util.RequiredFlag("--name", "<new-name>")
}

key, err := c.apiKeys.Update(ctx, in.ID, kernel.APIKeyUpdateParams{Name: in.Name})
Expand Down Expand Up @@ -192,7 +193,7 @@ func (c APIKeysCmd) Delete(ctx context.Context, in APIKeysDeleteInput) error {

if err := c.apiKeys.Delete(ctx, in.ID); err != nil {
if util.IsNotFound(err) {
return fmt.Errorf("API key %q not found", in.ID)
return util.NotFound("API key", in.ID, "kernel api-keys list")
}
return util.CleanedUpSdkError{Err: err}
}
Expand All @@ -201,36 +202,69 @@ func (c APIKeysCmd) Delete(ctx context.Context, in APIKeysDeleteInput) error {
return nil
}

type apiKeyDisplay struct {
ID string
Name string
PlaintextKey string
Scope string
Project string
MaskedKey string
CreatedBy string
ExpiresAt string
CreatedAt string
}

func renderCreatedAPIKey(key *kernel.CreatedAPIKey) {
display := newCreatedAPIKeyDisplay(key)
rows := pterm.TableData{
{"Field", "Value"},
{"ID", key.ID},
{"Name", key.Name},
{"Key", key.Key},
{"Scope", formatAPIKeyScope(key.APIKey)},
{"Project", formatAPIKeyProject(key.APIKey)},
{"Masked Key", key.MaskedKey},
{"Expires At", formatAPIKeyExpiresAt(key.APIKey)},
{"Property", "Value"},
{"ID", display.ID},
{"Name", display.Name},
{"Key", display.PlaintextKey},
{"Scope", display.Scope},
{"Project", display.Project},
{"Masked Key", display.MaskedKey},
{"Expires At", display.ExpiresAt},
}
PrintTableNoPad(rows, true)
}

func renderAPIKeyDetails(key *kernel.APIKey) {
display := newAPIKeyDisplay(*key)
rows := pterm.TableData{
{"Field", "Value"},
{"ID", key.ID},
{"Name", key.Name},
{"Scope", formatAPIKeyScope(*key)},
{"Project", formatAPIKeyProject(*key)},
{"Masked Key", key.MaskedKey},
{"Created By", formatAPIKeyCreator(*key)},
{"Expires At", formatAPIKeyExpiresAt(*key)},
{"Created At", util.FormatLocal(key.CreatedAt)},
{"Property", "Value"},
{"ID", display.ID},
{"Name", display.Name},
{"Scope", display.Scope},
{"Project", display.Project},
{"Masked Key", display.MaskedKey},
{"Created By", display.CreatedBy},
{"Expires At", display.ExpiresAt},
{"Created At", display.CreatedAt},
}
PrintTableNoPad(rows, true)
}

func formatAPIKeyProject(key kernel.APIKey) string {
func newCreatedAPIKeyDisplay(key *kernel.CreatedAPIKey) apiKeyDisplay {
display := newAPIKeyDisplay(key.APIKey)
display.PlaintextKey = key.Key
return display
}

func newAPIKeyDisplay(key kernel.APIKey) apiKeyDisplay {
return apiKeyDisplay{
ID: key.ID,
Name: key.Name,
Scope: apiKeyScope(key),
Project: apiKeyProject(key),
MaskedKey: key.MaskedKey,
CreatedBy: apiKeyCreator(key),
ExpiresAt: apiKeyExpiresAt(key),
CreatedAt: util.FormatLocal(key.CreatedAt),
}
}

func apiKeyProject(key kernel.APIKey) string {
if key.JSON.ProjectName.Valid() && key.ProjectName != "" {
return key.ProjectName
}
Expand All @@ -240,14 +274,14 @@ func formatAPIKeyProject(key kernel.APIKey) string {
return "-"
}

func formatAPIKeyScope(key kernel.APIKey) string {
func apiKeyScope(key kernel.APIKey) string {
if key.JSON.ProjectID.Valid() && key.ProjectID != "" {
return "Project"
}
return "Org"
}

func formatAPIKeyCreator(key kernel.APIKey) string {
func apiKeyCreator(key kernel.APIKey) string {
if key.CreatedBy.JSON.Name.Valid() && key.CreatedBy.Name != "" {
return key.CreatedBy.Name
}
Expand All @@ -257,7 +291,7 @@ func formatAPIKeyCreator(key kernel.APIKey) string {
return "-"
}

func formatAPIKeyExpiresAt(key kernel.APIKey) string {
func apiKeyExpiresAt(key kernel.APIKey) string {
if !key.JSON.ExpiresAt.Valid() {
return "Never"
}
Expand Down Expand Up @@ -380,7 +414,7 @@ func init() {
apiKeysUpdateCmd.Flags().String("name", "", "New API key name (required)")
_ = apiKeysUpdateCmd.MarkFlagRequired("name")

apiKeysDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
util.AddSkipConfirmFlag(apiKeysDeleteCmd)

apiKeysCmd.AddCommand(apiKeysCreateCmd)
apiKeysCmd.AddCommand(apiKeysListCmd)
Expand Down
30 changes: 29 additions & 1 deletion cmd/api_keys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"testing"

"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"
Expand Down Expand Up @@ -116,7 +117,8 @@ func TestAPIKeysCreateRejectsInvalidDaysToExpire(t *testing.T) {
})

require.Error(t, err)
assert.Contains(t, err.Error(), "--days-to-expire must be between 1 and 3650")
assert.Contains(t, err.Error(), "invalid --days-to-expire 0")
assert.Contains(t, err.Error(), "use a value from 1 to 3650")
}

func TestAPIKeysRejectInvalidOutputBeforeCallingAPI(t *testing.T) {
Expand Down Expand Up @@ -196,6 +198,32 @@ func TestAPIKeysListPassesPaginationAndRendersRows(t *testing.T) {
assert.Contains(t, out, "Never")
}

func TestAPIKeyDisplayNormalizesSDKFields(t *testing.T) {
key := apiKeyFromJSON(`{"id":"key_123","name":"ci","masked_key":"sk_...123","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"dev@example.com","name":"Dev"},"expires_at":"2026-06-27T12:00:00Z","project_id":"proj_123","project_name":"Prod"}`)

display := newAPIKeyDisplay(*key)

assert.Equal(t, "key_123", display.ID)
assert.Equal(t, "ci", display.Name)
assert.Equal(t, "Project", display.Scope)
assert.Equal(t, "Prod", display.Project)
assert.Equal(t, "sk_...123", display.MaskedKey)
assert.Equal(t, "Dev", display.CreatedBy)
assert.Equal(t, util.FormatLocal(key.ExpiresAt), display.ExpiresAt)
assert.Equal(t, util.FormatLocal(key.CreatedAt), display.CreatedAt)
}

func TestAPIKeyDisplayFallsBackForAbsentOptionalFields(t *testing.T) {
key := apiKeyFromJSON(`{"id":"key_123","name":"ci","masked_key":"sk_...123","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"dev@example.com","name":null},"expires_at":null,"project_id":null,"project_name":null}`)

display := newAPIKeyDisplay(*key)

assert.Equal(t, "Org", display.Scope)
assert.Equal(t, "-", display.Project)
assert.Equal(t, "dev@example.com", display.CreatedBy)
assert.Equal(t, "Never", display.ExpiresAt)
}

func TestAPIKeysUpdateRequiresName(t *testing.T) {
c := APIKeysCmd{apiKeys: &FakeAPIKeysService{}}
err := c.Update(context.Background(), APIKeysUpdateInput{ID: "key_123"})
Expand Down
20 changes: 5 additions & 15 deletions cmd/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func init() {

// Flags for delete
appDeleteCmd.Flags().String("version", "", "Only delete deployments for this version (default: all versions)")
appDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
util.AddSkipConfirmFlag(appDeleteCmd)

// Add optional filters for list
appListCmd.Flags().String("name", "", "Filter by application name")
Expand Down Expand Up @@ -115,16 +115,11 @@ func runAppList(cmd *cobra.Command, args []string) error {

apps, err := client.Apps.List(cmd.Context(), params)
if err != nil {
pterm.Error.Printf("Failed to list applications: %v\n", err)
return nil
return util.CleanedUpSdkError{Err: fmt.Errorf("list applications failed; check your auth and retry: %w", err)}
}

if output == "json" {
if apps == nil || len(apps.Items) == 0 {
fmt.Println("[]")
return nil
}
return util.PrintPrettyJSONSlice(apps.Items)
return util.PrintPrettyJSONPageItems(apps)
}

if apps == nil || len(apps.Items) == 0 {
Expand Down Expand Up @@ -318,16 +313,11 @@ func runAppHistory(cmd *cobra.Command, args []string) error {

deployments, err := client.Deployments.List(cmd.Context(), params)
if err != nil {
pterm.Error.Printf("Failed to list deployments: %v\n", err)
return nil
return util.CleanedUpSdkError{Err: fmt.Errorf("list deployments failed; check the app name or run `kernel app list`: %w", err)}
}

if output == "json" {
if deployments == nil || len(deployments.Items) == 0 {
fmt.Println("[]")
return nil
}
return util.PrintPrettyJSONSlice(deployments.Items)
return util.PrintPrettyJSONPageItems(deployments)
}

if deployments == nil || len(deployments.Items) == 0 {
Expand Down
26 changes: 9 additions & 17 deletions cmd/auth_connections.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,10 @@ func (c AuthConnectionCmd) Create(ctx context.Context, in AuthConnectionCreateIn
}

if in.Domain == "" {
return fmt.Errorf("--domain is required")
return util.RequiredFlag("--domain", "<domain>")
}
if in.ProfileName == "" {
return fmt.Errorf("--profile-name is required")
return util.RequiredFlag("--profile-name", "<profile-name>")
}

params := kernel.AuthConnectionNewParams{
Expand Down Expand Up @@ -251,7 +251,7 @@ func (c AuthConnectionCmd) Update(ctx context.Context, in AuthConnectionUpdateIn
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")
return util.ChooseOnlyOne("--credential-name", "--credential-provider")
}
params.ManagedAuthUpdateRequest.Credential = kernel.ManagedAuthUpdateRequestCredentialParam{}
if in.CredentialNameSet {
Expand Down Expand Up @@ -282,7 +282,7 @@ func (c AuthConnectionCmd) Update(ctx context.Context, in AuthConnectionUpdateIn
}

if !hasChanges {
return fmt.Errorf("must provide at least one field to update")
return util.SetAtLeastOne("--login-url", "--allowed-domain", "--credential-name", "--credential-provider", "--credential-path", "--credential-auto", "--proxy-id", "--proxy-name", "--save-credentials", "--no-save-credentials", "--health-check-interval")
}

if in.Output != "json" {
Expand Down Expand Up @@ -455,17 +455,9 @@ func (c AuthConnectionCmd) List(ctx context.Context, in AuthConnectionListInput)
}

if in.Output == "json" {
if page == nil {
fmt.Println("[]")
return nil
}
if page.RawJSON() != "" {
if page != nil && page.RawJSON() != "" {
return util.PrintPrettyJSON(page)
}
if len(auths) == 0 {
fmt.Println("[]")
return nil
}
return util.PrintPrettyJSONSlice(auths)
}

Expand Down Expand Up @@ -576,10 +568,10 @@ func (c AuthConnectionCmd) Submit(ctx context.Context, in AuthConnectionSubmitIn
}

if submitModes == 0 {
return fmt.Errorf("must provide exactly one of: --field, --mfa-option-id, --sign-in-option-id, --sso-button-selector, or --sso-provider")
return util.ChooseOne("--field", "--mfa-option-id", "--sign-in-option-id", "--sso-button-selector", "--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")
return util.ChooseOnlyOne("--field", "--mfa-option-id", "--sign-in-option-id", "--sso-button-selector", "--sso-provider")
}

// Resolve MFA option: the user may pass the label (e.g. "Get a text"), the
Expand Down Expand Up @@ -841,7 +833,7 @@ func init() {
authConnectionsListCmd.Flags().Int("offset", 0, "Number of results to skip")

// Delete flags
authConnectionsDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
util.AddSkipConfirmFlag(authConnectionsDeleteCmd)

// Login flags
addJSONOutputFlag(authConnectionsLoginCmd)
Expand Down Expand Up @@ -1029,7 +1021,7 @@ func runAuthConnectionsSubmit(cmd *cobra.Command, args []string) error {
for _, pair := range fieldPairs {
parts := strings.SplitN(pair, "=", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid field format: %s (expected key=value)", pair)
return fmt.Errorf("invalid --field %q; use key=value", pair)
}
fieldValues[parts[0]] = parts[1]
}
Expand Down
Loading
Loading