diff --git a/cmd/api_keys.go b/cmd/api_keys.go index 77374cf6..97477605 100644 --- a/cmd/api_keys.go +++ b/cmd/api_keys.go @@ -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", "") } 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) } @@ -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{} @@ -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) @@ -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", "") } key, err := c.apiKeys.Update(ctx, in.ID, kernel.APIKeyUpdateParams{Name: in.Name}) @@ -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} } @@ -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 } @@ -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 } @@ -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" } @@ -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) diff --git a/cmd/api_keys_test.go b/cmd/api_keys_test.go index 70ce8148..71df26d9 100644 --- a/cmd/api_keys_test.go +++ b/cmd/api_keys_test.go @@ -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" @@ -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) { @@ -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"}) diff --git a/cmd/app.go b/cmd/app.go index 7271dcc5..dd8acb13 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -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") @@ -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 { @@ -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 { diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index 153a4981..3f7fdf79 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -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", "") } if in.ProfileName == "" { - return fmt.Errorf("--profile-name is required") + return util.RequiredFlag("--profile-name", "") } params := kernel.AuthConnectionNewParams{ @@ -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 { @@ -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" { @@ -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) } @@ -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 @@ -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) @@ -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] } diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index eaacc682..a5c81058 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -43,11 +43,7 @@ func (c BrowserPoolsCmd) List(ctx context.Context, in BrowserPoolsListInput) err } if in.Output == "json" { - if pools == nil || len(*pools) == 0 { - fmt.Println("[]") - return nil - } - return util.PrintPrettyJSONSlice(*pools) + return util.PrintPrettyJSONPointerSlice(pools) } if pools == nil || len(*pools) == 0 { @@ -125,8 +121,7 @@ func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) profile, err := buildProfileParam(in.ProfileID, in.ProfileName, in.ProfileSaveChanges) if err != nil { - pterm.Error.Println(err.Error()) - return nil + return err } if profile != nil { params.Profile = *profile @@ -143,8 +138,7 @@ func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) viewport, err := buildViewportParam(in.Viewport) if err != nil { - pterm.Error.Println(err.Error()) - return nil + return err } if viewport != nil { params.Viewport = *viewport @@ -241,7 +235,7 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) return err } if in.StartURL != "" && in.ClearStartURL { - return fmt.Errorf("cannot specify both --start-url and --clear-start-url") + return util.ChooseOnlyOne("--start-url", "--clear-start-url") } params := kernel.BrowserPoolUpdateParams{} @@ -273,8 +267,7 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) profile, err := buildProfileParam(in.ProfileID, in.ProfileName, in.ProfileSaveChanges) if err != nil { - pterm.Error.Println(err.Error()) - return nil + return err } if profile != nil { params.Profile = *profile @@ -293,8 +286,7 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) viewport, err := buildViewportParam(in.Viewport) if err != nil { - pterm.Error.Println(err.Error()) - return nil + return err } if viewport != nil { params.Viewport = *viewport @@ -552,7 +544,7 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { name, _ := cmd.Flags().GetString("name") if len(args) > 0 && args[0] != "" { if cmd.Flags().Changed("name") { - return fmt.Errorf("cannot specify pool name as both a positional argument and --name flag") + return util.ChooseOnlyOne("", "--name") } name = args[0] } @@ -681,7 +673,7 @@ func runBrowserPoolsFlush(cmd *cobra.Command, args []string) error { func buildProfileParam(profileID, profileName string, saveChanges BoolFlag) (*kernel.BrowserProfileParam, error) { if profileID != "" && profileName != "" { - return nil, fmt.Errorf("must specify at most one of --profile-id or --profile-name") + return nil, util.ChooseOnlyOne("--profile-id", "--profile-name") } if profileID == "" && profileName == "" { return nil, nil @@ -700,7 +692,7 @@ func buildProfileParam(profileID, profileName string, saveChanges BoolFlag) (*ke func validateStartURLFlag(startURL string) error { if strings.HasPrefix(startURL, "-") { - return fmt.Errorf("--start-url requires a URL value") + return fmt.Errorf("--start-url requires a URL; use --start-url https://example.com") } return nil } @@ -734,7 +726,7 @@ func buildViewportParam(viewport string) (*kernel.BrowserViewportParam, error) { width, height, refreshRate, err := parseViewport(viewport) if err != nil { - return nil, fmt.Errorf("invalid viewport format: %v", err) + return nil, fmt.Errorf("invalid --viewport %q; use WIDTHxHEIGHT or WIDTHxHEIGHT@RATE: %v", viewport, err) } vp := kernel.BrowserViewportParam{ diff --git a/cmd/browsers.go b/cmd/browsers.go index d73e1159..754b687d 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -217,6 +217,10 @@ type BrowsersCmd struct { playwright BrowserPlaywrightService } +func browserServiceUnavailable(service string) error { + return fmt.Errorf("%s service is unavailable; upgrade the CLI and retry", service) +} + type BrowsersListInput struct { Output string IncludeDeleted bool @@ -242,7 +246,7 @@ func (b BrowsersCmd) List(ctx context.Context, in BrowsersListInput) error { case "all": params.Status = kernel.BrowserListParamsStatusAll default: - return fmt.Errorf("invalid --status value: %s (must be 'active', 'deleted', or 'all')", in.Status) + return util.InvalidChoice("--status", in.Status, "active", "deleted", "all") } } else if in.IncludeDeleted { params.IncludeDeleted = kernel.Opt(true) @@ -356,8 +360,7 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { // Validate profile selection: at most one of profile-id or profile-name must be provided if in.ProfileID != "" && in.ProfileName != "" { - pterm.Error.Println("must specify at most one of --profile-id or --profile-name") - return nil + return util.ChooseOnlyOne("--profile-id", "--profile-name") } else if in.ProfileID != "" || in.ProfileName != "" { params.Profile = kernel.BrowserProfileParam{ SaveChanges: kernel.Opt(in.ProfileSaveChanges.Value), @@ -398,8 +401,7 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { if in.Viewport != "" { width, height, refreshRate, err := parseViewport(in.Viewport) if err != nil { - pterm.Error.Printf("Invalid viewport format: %v\n", err) - return nil + return fmt.Errorf("invalid --viewport %q; use WIDTHxHEIGHT or WIDTHxHEIGHT@RATE: %v", in.Viewport, err) } params.Viewport = kernel.BrowserViewportParam{ Width: width, @@ -553,12 +555,12 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { // Validate profile selection: at most one of profile-id or profile-name must be provided if in.ProfileID != "" && in.ProfileName != "" { - return fmt.Errorf("must specify at most one of --profile-id or --profile-name") + return util.ChooseOnlyOne("--profile-id", "--profile-name") } // Cannot specify both --proxy-id and --clear-proxy if in.ProxyID != "" && in.ClearProxy { - return fmt.Errorf("cannot specify both --proxy-id and --clear-proxy") + return util.ChooseOnlyOne("--proxy-id", "--clear-proxy") } hasProxyChange := in.ProxyID != "" || in.ClearProxy @@ -567,17 +569,17 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { // Validate --save-changes is only used with a profile if in.ProfileSaveChanges.Set && !hasProfileChange { - return fmt.Errorf("--save-changes requires --profile-id or --profile-name") + return fmt.Errorf("--save-changes requires a profile; add --profile-id or --profile-name ") } // Validate --force is only used with a viewport change if in.Force && !hasViewportChange { - return fmt.Errorf("--force requires --viewport") + return fmt.Errorf("--force requires --viewport; add --viewport WIDTHxHEIGHT") } // Validate that at least one update option is provided if !hasProxyChange && !hasProfileChange && !hasViewportChange { - return fmt.Errorf("must specify at least one of: --proxy-id, --clear-proxy, --profile-id, --profile-name, or --viewport") + return util.SetAtLeastOne("--proxy-id", "--clear-proxy", "--profile-id", "--profile-name", "--viewport") } params := kernel.BrowserUpdateParams{} @@ -606,7 +608,7 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { if hasViewportChange { width, height, refreshRate, err := parseViewport(in.Viewport) if err != nil { - return fmt.Errorf("invalid viewport format: %v", err) + return fmt.Errorf("invalid --viewport %q; use WIDTHxHEIGHT or WIDTHxHEIGHT@RATE: %v", in.Viewport, err) } params.Viewport = kernel.BrowserUpdateParamsViewport{ BrowserViewportParam: shared.BrowserViewportParam{ @@ -650,8 +652,7 @@ type BrowsersLogsStreamInput struct { func (b BrowsersCmd) LogsStream(ctx context.Context, in BrowsersLogsStreamInput) error { if b.logs == nil { - pterm.Error.Println("logs service not available") - return nil + return browserServiceUnavailable("logs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -669,8 +670,7 @@ func (b BrowsersCmd) LogsStream(ctx context.Context, in BrowsersLogsStreamInput) } stream := b.logs.StreamStreaming(ctx, br.SessionID, params) if stream == nil { - pterm.Error.Println("failed to open log stream") - return nil + return fmt.Errorf("open log stream failed; check browser %q is running and retry", br.SessionID) } defer stream.Close() for stream.Next() { @@ -776,8 +776,7 @@ type BrowsersComputerWriteClipboardInput struct { func (b BrowsersCmd) ComputerClickMouse(ctx context.Context, in BrowsersComputerClickMouseInput) error { if b.computer == nil { - pterm.Error.Println("computer service not available") - return nil + return browserServiceUnavailable("computer") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -805,8 +804,7 @@ func (b BrowsersCmd) ComputerClickMouse(ctx context.Context, in BrowsersComputer func (b BrowsersCmd) ComputerMoveMouse(ctx context.Context, in BrowsersComputerMoveMouseInput) error { if b.computer == nil { - pterm.Error.Println("computer service not available") - return nil + return browserServiceUnavailable("computer") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -835,8 +833,7 @@ func (b BrowsersCmd) ComputerMoveMouse(ctx context.Context, in BrowsersComputerM func (b BrowsersCmd) ComputerScreenshot(ctx context.Context, in BrowsersComputerScreenshotInput) error { if b.computer == nil { - pterm.Error.Println("computer service not available") - return nil + return browserServiceUnavailable("computer") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -852,18 +849,15 @@ func (b BrowsersCmd) ComputerScreenshot(ctx context.Context, in BrowsersComputer } defer res.Body.Close() if in.To == "" { - pterm.Error.Println("--to is required to save the screenshot") - return nil + return util.RequiredFlag("--to", "") } f, err := os.Create(in.To) if err != nil { - pterm.Error.Printf("Failed to create file: %v\n", err) - return nil + return fmt.Errorf("create screenshot file %q failed; choose a writable --to path: %w", in.To, err) } defer f.Close() if _, err := io.Copy(f, res.Body); err != nil { - pterm.Error.Printf("Failed to write file: %v\n", err) - return nil + return fmt.Errorf("write screenshot file %q failed; check disk space and permissions: %w", in.To, err) } pterm.Success.Printf("Saved screenshot to %s\n", in.To) return nil @@ -871,8 +865,7 @@ func (b BrowsersCmd) ComputerScreenshot(ctx context.Context, in BrowsersComputer func (b BrowsersCmd) ComputerTypeText(ctx context.Context, in BrowsersComputerTypeTextInput) error { if b.computer == nil { - pterm.Error.Println("computer service not available") - return nil + return browserServiceUnavailable("computer") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -891,16 +884,14 @@ func (b BrowsersCmd) ComputerTypeText(ctx context.Context, in BrowsersComputerTy func (b BrowsersCmd) ComputerPressKey(ctx context.Context, in BrowsersComputerPressKeyInput) error { if b.computer == nil { - pterm.Error.Println("computer service not available") - return nil + return browserServiceUnavailable("computer") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { return util.CleanedUpSdkError{Err: err} } if len(in.Keys) == 0 { - pterm.Error.Println("no keys specified") - return nil + return util.RequiredFlag("--key", "") } body := kernel.BrowserComputerPressKeyParams{Keys: in.Keys} if in.Duration > 0 { @@ -918,8 +909,7 @@ func (b BrowsersCmd) ComputerPressKey(ctx context.Context, in BrowsersComputerPr func (b BrowsersCmd) ComputerScroll(ctx context.Context, in BrowsersComputerScrollInput) error { if b.computer == nil { - pterm.Error.Println("computer service not available") - return nil + return browserServiceUnavailable("computer") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -944,16 +934,14 @@ func (b BrowsersCmd) ComputerScroll(ctx context.Context, in BrowsersComputerScro func (b BrowsersCmd) ComputerDragMouse(ctx context.Context, in BrowsersComputerDragMouseInput) error { if b.computer == nil { - pterm.Error.Println("computer service not available") - return nil + return browserServiceUnavailable("computer") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { return util.CleanedUpSdkError{Err: err} } if len(in.Path) < 2 { - pterm.Error.Println("path must include at least two points") - return nil + return fmt.Errorf("drag path needs at least two points; pass --point x,y at least twice") } body := kernel.BrowserComputerDragMouseParams{Path: in.Path} if in.Delay > 0 { @@ -990,8 +978,7 @@ func (b BrowsersCmd) ComputerDragMouse(ctx context.Context, in BrowsersComputerD func (b BrowsersCmd) ComputerSetCursor(ctx context.Context, in BrowsersComputerSetCursorInput) error { if b.computer == nil { - pterm.Error.Println("computer service not available") - return nil + return browserServiceUnavailable("computer") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1012,8 +999,7 @@ func (b BrowsersCmd) ComputerSetCursor(ctx context.Context, in BrowsersComputerS func (b BrowsersCmd) ComputerGetMousePosition(ctx context.Context, in BrowsersComputerGetMousePositionInput) error { if b.computer == nil { - pterm.Error.Println("computer service not available") - return nil + return browserServiceUnavailable("computer") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1034,8 +1020,7 @@ func (b BrowsersCmd) ComputerGetMousePosition(ctx context.Context, in BrowsersCo func (b BrowsersCmd) ComputerBatch(ctx context.Context, in BrowsersComputerBatchInput) error { if b.computer == nil { - pterm.Error.Println("computer service not available") - return nil + return browserServiceUnavailable("computer") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1043,8 +1028,7 @@ func (b BrowsersCmd) ComputerBatch(ctx context.Context, in BrowsersComputerBatch } var body kernel.BrowserComputerBatchParams if err := json.Unmarshal([]byte(in.ActionsJSON), &body); err != nil { - pterm.Error.Printf("Invalid JSON: %v\n", err) - return nil + return fmt.Errorf("invalid actions JSON; pass a valid JSON object or array: %w", err) } if err := b.computer.Batch(ctx, br.SessionID, body); err != nil { return util.CleanedUpSdkError{Err: err} @@ -1058,8 +1042,7 @@ func (b BrowsersCmd) ComputerReadClipboard(ctx context.Context, in BrowsersCompu return err } if b.computer == nil { - pterm.Error.Println("computer service not available") - return nil + return browserServiceUnavailable("computer") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1080,8 +1063,7 @@ func (b BrowsersCmd) ComputerReadClipboard(ctx context.Context, in BrowsersCompu func (b BrowsersCmd) ComputerWriteClipboard(ctx context.Context, in BrowsersComputerWriteClipboardInput) error { if b.computer == nil { - pterm.Error.Println("computer service not available") - return nil + return browserServiceUnavailable("computer") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1129,11 +1111,7 @@ func (b BrowsersCmd) ReplaysList(ctx context.Context, in BrowsersReplaysListInpu } if in.Output == "json" { - if items == nil || len(*items) == 0 { - fmt.Println("[]") - return nil - } - return util.PrintPrettyJSONSlice(*items) + return util.PrintPrettyJSONPointerSlice(items) } if items == nil || len(*items) == 0 { @@ -1204,13 +1182,11 @@ func (b BrowsersCmd) ReplaysDownload(ctx context.Context, in BrowsersReplaysDown } f, err := os.Create(in.Output) if err != nil { - pterm.Error.Printf("Failed to create file: %v\n", err) - return nil + return fmt.Errorf("create replay file %q failed; choose a writable --output path: %w", in.Output, err) } defer f.Close() if _, err := io.Copy(f, res.Body); err != nil { - pterm.Error.Printf("Failed to write file: %v\n", err) - return nil + return fmt.Errorf("write replay file %q failed; check disk space and permissions: %w", in.Output, err) } pterm.Success.Printf("Saved replay to %s\n", in.Output) return nil @@ -1300,8 +1276,7 @@ func (b BrowsersCmd) PlaywrightExecute(ctx context.Context, in BrowsersPlaywrigh } if b.playwright == nil { - pterm.Error.Println("playwright service not available") - return nil + return browserServiceUnavailable("playwright") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1339,7 +1314,7 @@ func (b BrowsersCmd) PlaywrightExecute(ctx context.Context, in BrowsersPlaywrigh } } if !res.Success && res.Error != "" { - pterm.Error.Printf("error: %s\n", res.Error) + return fmt.Errorf("Playwright execution failed: %s", res.Error) } return nil } @@ -1350,8 +1325,7 @@ func (b BrowsersCmd) ProcessExec(ctx context.Context, in BrowsersProcessExecInpu } if b.process == nil { - pterm.Error.Println("process service not available") - return nil + return browserServiceUnavailable("process") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1417,8 +1391,7 @@ func (b BrowsersCmd) ProcessSpawn(ctx context.Context, in BrowsersProcessSpawnIn } if b.process == nil { - pterm.Error.Println("process service not available") - return nil + return browserServiceUnavailable("process") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1456,8 +1429,7 @@ func (b BrowsersCmd) ProcessSpawn(ctx context.Context, in BrowsersProcessSpawnIn func (b BrowsersCmd) ProcessKill(ctx context.Context, in BrowsersProcessKillInput) error { if b.process == nil { - pterm.Error.Println("process service not available") - return nil + return browserServiceUnavailable("process") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1474,8 +1446,7 @@ func (b BrowsersCmd) ProcessKill(ctx context.Context, in BrowsersProcessKillInpu func (b BrowsersCmd) ProcessStatus(ctx context.Context, in BrowsersProcessStatusInput) error { if b.process == nil { - pterm.Error.Println("process service not available") - return nil + return browserServiceUnavailable("process") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1492,8 +1463,7 @@ func (b BrowsersCmd) ProcessStatus(ctx context.Context, in BrowsersProcessStatus func (b BrowsersCmd) ProcessStdin(ctx context.Context, in BrowsersProcessStdinInput) error { if b.process == nil { - pterm.Error.Println("process service not available") - return nil + return browserServiceUnavailable("process") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1509,8 +1479,7 @@ func (b BrowsersCmd) ProcessStdin(ctx context.Context, in BrowsersProcessStdinIn func (b BrowsersCmd) ProcessStdoutStream(ctx context.Context, in BrowsersProcessStdoutStreamInput) error { if b.process == nil { - pterm.Error.Println("process service not available") - return nil + return browserServiceUnavailable("process") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1518,8 +1487,7 @@ func (b BrowsersCmd) ProcessStdoutStream(ctx context.Context, in BrowsersProcess } stream := b.process.StdoutStreamStreaming(ctx, in.ProcessID, kernel.BrowserProcessStdoutStreamParams{ID: br.SessionID}) if stream == nil { - pterm.Error.Println("failed to open stdout stream") - return nil + return fmt.Errorf("open stdout stream failed; check process %q is running and retry", in.ProcessID) } defer stream.Close() for stream.Next() { @@ -1543,8 +1511,7 @@ func (b BrowsersCmd) ProcessStdoutStream(ctx context.Context, in BrowsersProcess func (b BrowsersCmd) ProcessResize(ctx context.Context, in BrowsersProcessResizeInput) error { if b.process == nil { - pterm.Error.Println("process service not available") - return nil + return browserServiceUnavailable("process") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1566,8 +1533,7 @@ func (b BrowsersCmd) FSWatchStart(ctx context.Context, in BrowsersFSWatchStartIn } if b.fsWatch == nil { - pterm.Error.Println("fs watch service not available") - return nil + return browserServiceUnavailable("fs watch") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1592,8 +1558,7 @@ func (b BrowsersCmd) FSWatchStart(ctx context.Context, in BrowsersFSWatchStartIn func (b BrowsersCmd) FSWatchStop(ctx context.Context, in BrowsersFSWatchStopInput) error { if b.fsWatch == nil { - pterm.Error.Println("fs watch service not available") - return nil + return browserServiceUnavailable("fs watch") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1609,8 +1574,7 @@ func (b BrowsersCmd) FSWatchStop(ctx context.Context, in BrowsersFSWatchStopInpu func (b BrowsersCmd) FSWatchEvents(ctx context.Context, in BrowsersFSWatchEventsInput) error { if b.fsWatch == nil { - pterm.Error.Println("fs watch service not available") - return nil + return browserServiceUnavailable("fs watch") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1618,8 +1582,7 @@ func (b BrowsersCmd) FSWatchEvents(ctx context.Context, in BrowsersFSWatchEvents } stream := b.fsWatch.EventsStreaming(ctx, in.WatchID, kernel.BrowserFWatchEventsParams{ID: br.SessionID}) if stream == nil { - pterm.Error.Println("failed to open watch events stream") - return nil + return fmt.Errorf("open watch events stream failed; check watch %q is active and retry", in.WatchID) } defer stream.Close() for stream.Next() { @@ -1718,8 +1681,7 @@ type BrowsersExtensionsUploadInput struct { func (b BrowsersCmd) FSNewDirectory(ctx context.Context, in BrowsersFSNewDirInput) error { if b.fs == nil { - pterm.Error.Println("fs service not available") - return nil + return browserServiceUnavailable("fs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1738,8 +1700,7 @@ func (b BrowsersCmd) FSNewDirectory(ctx context.Context, in BrowsersFSNewDirInpu func (b BrowsersCmd) FSDeleteDirectory(ctx context.Context, in BrowsersFSDeleteDirInput) error { if b.fs == nil { - pterm.Error.Println("fs service not available") - return nil + return browserServiceUnavailable("fs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1754,8 +1715,7 @@ func (b BrowsersCmd) FSDeleteDirectory(ctx context.Context, in BrowsersFSDeleteD func (b BrowsersCmd) FSDeleteFile(ctx context.Context, in BrowsersFSDeleteFileInput) error { if b.fs == nil { - pterm.Error.Println("fs service not available") - return nil + return browserServiceUnavailable("fs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1770,8 +1730,7 @@ func (b BrowsersCmd) FSDeleteFile(ctx context.Context, in BrowsersFSDeleteFileIn func (b BrowsersCmd) FSDownloadDirZip(ctx context.Context, in BrowsersFSDownloadDirZipInput) error { if b.fs == nil { - pterm.Error.Println("fs service not available") - return nil + return browserServiceUnavailable("fs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1789,13 +1748,11 @@ func (b BrowsersCmd) FSDownloadDirZip(ctx context.Context, in BrowsersFSDownload } f, err := os.Create(in.Output) if err != nil { - pterm.Error.Printf("Failed to create file: %v\n", err) - return nil + return fmt.Errorf("create zip file %q failed; choose a writable --output path: %w", in.Output, err) } defer f.Close() if _, err := io.Copy(f, res.Body); err != nil { - pterm.Error.Printf("Failed to write file: %v\n", err) - return nil + return fmt.Errorf("write zip file %q failed; check disk space and permissions: %w", in.Output, err) } pterm.Success.Printf("Saved zip to %s\n", in.Output) return nil @@ -1807,8 +1764,7 @@ func (b BrowsersCmd) FSFileInfo(ctx context.Context, in BrowsersFSFileInfoInput) } if b.fs == nil { - pterm.Error.Println("fs service not available") - return nil + return browserServiceUnavailable("fs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1834,8 +1790,7 @@ func (b BrowsersCmd) FSListFiles(ctx context.Context, in BrowsersFSListFilesInpu } if b.fs == nil { - pterm.Error.Println("fs service not available") - return nil + return browserServiceUnavailable("fs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1847,11 +1802,7 @@ func (b BrowsersCmd) FSListFiles(ctx context.Context, in BrowsersFSListFilesInpu } if in.Output == "json" { - if res == nil || len(*res) == 0 { - fmt.Println("[]") - return nil - } - return util.PrintPrettyJSONSlice(*res) + return util.PrintPrettyJSONPointerSlice(res) } if res == nil || len(*res) == 0 { @@ -1868,8 +1819,7 @@ func (b BrowsersCmd) FSListFiles(ctx context.Context, in BrowsersFSListFilesInpu func (b BrowsersCmd) FSMove(ctx context.Context, in BrowsersFSMoveInput) error { if b.fs == nil { - pterm.Error.Println("fs service not available") - return nil + return browserServiceUnavailable("fs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1884,8 +1834,7 @@ func (b BrowsersCmd) FSMove(ctx context.Context, in BrowsersFSMoveInput) error { func (b BrowsersCmd) FSReadFile(ctx context.Context, in BrowsersFSReadFileInput) error { if b.fs == nil { - pterm.Error.Println("fs service not available") - return nil + return browserServiceUnavailable("fs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1902,13 +1851,11 @@ func (b BrowsersCmd) FSReadFile(ctx context.Context, in BrowsersFSReadFileInput) } f, err := os.Create(in.Output) if err != nil { - pterm.Error.Printf("Failed to create file: %v\n", err) - return nil + return fmt.Errorf("create output file %q failed; choose a writable --output path: %w", in.Output, err) } defer f.Close() if _, err := io.Copy(f, res.Body); err != nil { - pterm.Error.Printf("Failed to write file: %v\n", err) - return nil + return fmt.Errorf("write output file %q failed; check disk space and permissions: %w", in.Output, err) } pterm.Success.Printf("Saved file to %s\n", in.Output) return nil @@ -1916,8 +1863,7 @@ func (b BrowsersCmd) FSReadFile(ctx context.Context, in BrowsersFSReadFileInput) func (b BrowsersCmd) FSSetPermissions(ctx context.Context, in BrowsersFSSetPermsInput) error { if b.fs == nil { - pterm.Error.Println("fs service not available") - return nil + return browserServiceUnavailable("fs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1939,8 +1885,7 @@ func (b BrowsersCmd) FSSetPermissions(ctx context.Context, in BrowsersFSSetPerms func (b BrowsersCmd) FSUpload(ctx context.Context, in BrowsersFSUploadInput) error { if b.fs == nil { - pterm.Error.Println("fs service not available") - return nil + return browserServiceUnavailable("fs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -1951,11 +1896,10 @@ func (b BrowsersCmd) FSUpload(ctx context.Context, in BrowsersFSUploadInput) err for _, m := range in.Mappings { f, err := os.Open(m.Local) if err != nil { - pterm.Error.Printf("Failed to open %s: %v\n", m.Local, err) for _, c := range toClose { _ = c.Close() } - return nil + return fmt.Errorf("open upload source %q failed; check the local path: %w", m.Local, err) } toClose = append(toClose, f) files = append(files, kernel.BrowserFUploadParamsFile{DestPath: m.Dest, File: f}) @@ -1964,11 +1908,10 @@ func (b BrowsersCmd) FSUpload(ctx context.Context, in BrowsersFSUploadInput) err for _, lp := range in.Paths { f, err := os.Open(lp) if err != nil { - pterm.Error.Printf("Failed to open %s: %v\n", lp, err) for _, c := range toClose { _ = c.Close() } - return nil + return fmt.Errorf("open upload source %q failed; check the local path: %w", lp, err) } toClose = append(toClose, f) dest := filepath.Join(in.DestDir, filepath.Base(lp)) @@ -1976,8 +1919,7 @@ func (b BrowsersCmd) FSUpload(ctx context.Context, in BrowsersFSUploadInput) err } } if len(files) == 0 { - pterm.Error.Println("no files specified for upload") - return nil + return fmt.Errorf("no files to upload; pass --file local:remote or --paths with --dest-dir ") } defer func() { for _, c := range toClose { @@ -1997,8 +1939,7 @@ func (b BrowsersCmd) FSUpload(ctx context.Context, in BrowsersFSUploadInput) err func (b BrowsersCmd) FSUploadZip(ctx context.Context, in BrowsersFSUploadZipInput) error { if b.fs == nil { - pterm.Error.Println("fs service not available") - return nil + return browserServiceUnavailable("fs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -2006,8 +1947,7 @@ func (b BrowsersCmd) FSUploadZip(ctx context.Context, in BrowsersFSUploadZipInpu } f, err := os.Open(in.ZipPath) if err != nil { - pterm.Error.Printf("Failed to open zip: %v\n", err) - return nil + return fmt.Errorf("open zip %q failed; check --zip path: %w", in.ZipPath, err) } defer f.Close() if err := b.fs.UploadZip(ctx, br.SessionID, kernel.BrowserFUploadZipParams{DestPath: in.DestDir, ZipFile: f}); err != nil { @@ -2019,8 +1959,7 @@ func (b BrowsersCmd) FSUploadZip(ctx context.Context, in BrowsersFSUploadZipInpu func (b BrowsersCmd) FSWriteFile(ctx context.Context, in BrowsersFSWriteFileInput) error { if b.fs == nil { - pterm.Error.Println("fs service not available") - return nil + return browserServiceUnavailable("fs") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -2030,14 +1969,12 @@ func (b BrowsersCmd) FSWriteFile(ctx context.Context, in BrowsersFSWriteFileInpu if in.SourcePath != "" { f, err := os.Open(in.SourcePath) if err != nil { - pterm.Error.Printf("Failed to open input: %v\n", err) - return nil + return fmt.Errorf("open source file %q failed; check --source: %w", in.SourcePath, err) } defer f.Close() reader = f } else { - pterm.Error.Println("--source is required") - return nil + return util.RequiredFlag("--source", "") } params := kernel.BrowserFWriteFileParams{Path: in.DestPath} if in.Mode != "" { @@ -2052,8 +1989,7 @@ func (b BrowsersCmd) FSWriteFile(ctx context.Context, in BrowsersFSWriteFileInpu func (b BrowsersCmd) ExtensionsUpload(ctx context.Context, in BrowsersExtensionsUploadInput) error { if b.browsers == nil { - pterm.Error.Println("browsers service not available") - return nil + return browserServiceUnavailable("browsers") } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -2061,8 +1997,7 @@ func (b BrowsersCmd) ExtensionsUpload(ctx context.Context, in BrowsersExtensions } if len(in.ExtensionPaths) == 0 { - pterm.Error.Println("no extension paths provided") - return nil + return util.RequiredArg("extension path", "kernel browsers extensions upload ...") } var extensions []kernel.BrowserLoadExtensionsParamsExtension @@ -2081,12 +2016,10 @@ func (b BrowsersCmd) ExtensionsUpload(ctx context.Context, in BrowsersExtensions for _, extPath := range in.ExtensionPaths { info, err := os.Stat(extPath) if err != nil { - pterm.Error.Printf("Failed to stat %s: %v\n", extPath, err) - return nil + return fmt.Errorf("read extension path %q failed; check the directory exists: %w", extPath, err) } if !info.IsDir() { - pterm.Error.Printf("Path %s is not a directory\n", extPath) - return nil + return fmt.Errorf("extension path %q is not a directory; pass an unpacked extension directory", extPath) } extName := generateRandomExtensionName() @@ -2094,15 +2027,13 @@ func (b BrowsersCmd) ExtensionsUpload(ctx context.Context, in BrowsersExtensions pterm.Info.Printf("Zipping %s as %s...\n", extPath, extName) if err := util.ZipDirectory(extPath, tempZipPath, nil); err != nil { - pterm.Error.Printf("Failed to zip %s: %v\n", extPath, err) - return nil + return fmt.Errorf("zip extension %q failed; check the directory contents: %w", extPath, err) } tempZipFiles = append(tempZipFiles, tempZipPath) zipFile, err := os.Open(tempZipPath) if err != nil { - pterm.Error.Printf("Failed to open zip %s: %v\n", tempZipPath, err) - return nil + return fmt.Errorf("open generated extension zip %q failed: %w", tempZipPath, err) } openFiles = append(openFiles, zipFile) @@ -2194,10 +2125,10 @@ Supported operations: Note: Profiles can only be loaded into sessions that don't already have a profile.`, Args: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { - return fmt.Errorf("missing required argument: browser ID\n\nUsage: kernel browsers update [flags]") + return util.RequiredArg("browser ID", "kernel browsers update [flags]") } if len(args) > 1 { - return fmt.Errorf("expected 1 argument (browser ID), got %d", len(args)) + return fmt.Errorf("accepts 1 browser ID, got %d", len(args)) } return nil }, @@ -2566,8 +2497,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { output, _ := cmd.Flags().GetString("output") if poolID != "" && poolName != "" { - pterm.Error.Println("must specify at most one of --pool-id or --pool-name") - return nil + return util.ChooseOnlyOne("--pool-id", "--pool-name") } if poolID != "" || poolName != "" { @@ -2631,8 +2561,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { fmt.Println("null") return nil } - pterm.Error.Println("Acquire request timed out (no browser available). Retry to continue waiting.") - return nil + return fmt.Errorf("acquire timed out because no pooled browser is available; retry or increase --timeout") } if output == "json" { return util.PrintPrettyJSON(resp) @@ -2652,8 +2581,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { WithDefaultText("Select a viewport size:"). Show() if err != nil { - pterm.Error.Printf("Failed to select viewport: %v\n", err) - return nil + return fmt.Errorf("select viewport failed; pass --viewport WIDTHxHEIGHT instead: %w", err) } viewport = selectedViewport } @@ -2914,13 +2842,11 @@ func runBrowsersPlaywrightExecute(cmd *cobra.Command, args []string) error { // Read code from stdin stat, _ := os.Stdin.Stat() if (stat.Mode() & os.ModeCharDevice) != 0 { - pterm.Error.Println("no code provided. Provide code as an argument or pipe via stdin") - return nil + return util.RequiredArg("Playwright code", "kernel browsers playwright execute ''") } data, err := io.ReadAll(os.Stdin) if err != nil { - pterm.Error.Printf("failed to read stdin: %v\n", err) - return nil + return fmt.Errorf("read Playwright code from stdin failed: %w", err) } code = string(data) } @@ -3025,8 +2951,7 @@ func runBrowsersFSUpload(cmd *cobra.Command, args []string) error { // format: local:remote parts := strings.SplitN(m, ":", 2) if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - pterm.Error.Printf("invalid --file mapping: %s\n", m) - return nil + return fmt.Errorf("invalid --file %q; use local_path:remote_path", m) } mappings = append(mappings, struct { Local string @@ -3110,12 +3035,10 @@ func runBrowsersComputerScreenshot(cmd *cobra.Command, args []string) error { useRegion := bx || by || bw || bh if useRegion { if !(bx && by && bw && bh) { - pterm.Error.Println("if specifying region, you must provide --x, --y, --width, and --height") - return nil + return fmt.Errorf("screenshot region requires --x, --y, --width, and --height together") } if w <= 0 || h <= 0 { - pterm.Error.Println("--width and --height must be greater than zero") - return nil + return fmt.Errorf("invalid screenshot region %dx%d; --width and --height must be greater than zero", w, h) } } b := BrowsersCmd{browsers: &svc, computer: &svc.Computer} @@ -3170,18 +3093,15 @@ func runBrowsersComputerDragMouse(cmd *cobra.Command, args []string) error { for _, p := range points { parts := strings.SplitN(p, ",", 2) if len(parts) != 2 { - pterm.Error.Printf("invalid --point value: %s (expected x,y)\n", p) - return nil + return fmt.Errorf("invalid --point %q; use x,y", p) } x, err := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64) if err != nil { - pterm.Error.Printf("invalid x in --point %s: %v\n", p, err) - return nil + return fmt.Errorf("invalid x in --point %q; use integer coordinates: %w", p, err) } y, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64) if err != nil { - pterm.Error.Printf("invalid y in --point %s: %v\n", p, err) - return nil + return fmt.Errorf("invalid y in --point %q; use integer coordinates: %w", p, err) } path = append(path, []int64{x, y}) } @@ -3211,8 +3131,7 @@ func runBrowsersComputerSetCursor(cmd *cobra.Command, args []string) error { case "false", "0", "no": hidden = false default: - pterm.Error.Printf("Invalid value for --hidden: %s (expected true or false)\n", hiddenStr) - return nil + return util.InvalidChoice("--hidden", hiddenStr, "true", "false") } b := BrowsersCmd{browsers: &svc, computer: &svc.Computer} diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 36190f4a..c6c68cba 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -1302,6 +1302,18 @@ func TestBrowsersFSUpload_MappingAndDestDir_Success(t *testing.T) { assert.Equal(t, 2, len(captured.Files)) } +func TestBrowsersFSUpload_NoFilesGuidesToActualFlags(t *testing.T) { + fakeBrowsers := newFakeBrowsersServiceWithSimpleGet() + b := BrowsersCmd{browsers: fakeBrowsers, fs: &FakeFSService{}} + + err := b.FSUpload(context.Background(), BrowsersFSUploadInput{Identifier: "id"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "--file local:remote") + assert.Contains(t, err.Error(), "--paths ") + assert.Contains(t, err.Error(), "--dest-dir ") +} + func TestBrowsersFSUploadZip_Success(t *testing.T) { setupStdoutCapture(t) z := __writeTempFile(t, "zipdata") @@ -1490,6 +1502,17 @@ func TestBrowsersComputerPressKey_PrintsSuccess(t *testing.T) { assert.Contains(t, out, "Pressed keys: Return,Shift") } +func TestBrowsersComputerPressKey_MissingKeysGuidesToKeyFlag(t *testing.T) { + fakeBrowsers := newFakeBrowsersServiceWithSimpleGet() + b := BrowsersCmd{browsers: fakeBrowsers, computer: &FakeComputerService{}} + + err := b.ComputerPressKey(context.Background(), BrowsersComputerPressKeyInput{Identifier: "id"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "--key is required") + assert.Contains(t, err.Error(), "add --key ") +} + func TestBrowsersComputerScroll_PrintsSuccess(t *testing.T) { setupStdoutCapture(t) fakeBrowsers := newFakeBrowsersServiceWithSimpleGet() @@ -1639,7 +1662,8 @@ func TestBrowsersCreate_RejectsStartURLFlagToken(t *testing.T) { }) require.Error(t, err) - assert.Contains(t, err.Error(), "--start-url requires a URL value") + assert.Contains(t, err.Error(), "--start-url requires a URL") + assert.Contains(t, err.Error(), "use --start-url https://example.com") assert.False(t, called) } @@ -1652,9 +1676,9 @@ func TestBrowsersCreate_WithInvalidViewport(t *testing.T) { Viewport: "invalid", }) - assert.NoError(t, err) - out := outBuf.String() - assert.Contains(t, out, "Invalid viewport format") + require.Error(t, err) + assert.Contains(t, err.Error(), `invalid --viewport "invalid"`) + assert.Contains(t, err.Error(), "use WIDTHxHEIGHT") } func TestBrowsersUpdate_WithViewportAndForce(t *testing.T) { diff --git a/cmd/credential_providers.go b/cmd/credential_providers.go index a0f58087..de7a4dde 100644 --- a/cmd/credential_providers.go +++ b/cmd/credential_providers.go @@ -81,11 +81,7 @@ func (c CredentialProvidersCmd) List(ctx context.Context, in CredentialProviders } if in.Output == "json" { - if providers == nil || len(*providers) == 0 { - fmt.Println("[]") - return nil - } - return util.PrintPrettyJSONSlice(*providers) + return util.PrintPrettyJSONPointerSlice(providers) } if providers == nil || len(*providers) == 0 { @@ -142,19 +138,19 @@ func (c CredentialProvidersCmd) Create(ctx context.Context, in CredentialProvide } if in.ProviderType == "" { - return fmt.Errorf("--provider-type is required") + return util.RequiredFlag("--provider-type", "onepassword") } if in.Name == "" { - return fmt.Errorf("--name is required") + return util.RequiredFlag("--name", "") } if in.Token == "" { - return fmt.Errorf("--token is required") + return util.RequiredFlag("--token", "") } // Validate provider type providerType := strings.ToLower(in.ProviderType) if providerType != "onepassword" { - return fmt.Errorf("invalid provider type: %s (must be 'onepassword')", in.ProviderType) + return util.InvalidChoice("--provider-type", in.ProviderType, "onepassword") } params := kernel.CredentialProviderNewParams{ @@ -312,10 +308,6 @@ func (c CredentialProvidersCmd) ListItems(ctx context.Context, in CredentialProv } if in.Output == "json" { - if len(result.Items) == 0 { - fmt.Println("[]") - return nil - } return util.PrintPrettyJSONSlice(result.Items) } @@ -448,7 +440,7 @@ func init() { credentialProvidersUpdateCmd.Flags().Int64("priority", 0, "Priority order for credential lookups (lower numbers are checked first)") // Delete flags - credentialProvidersDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + util.AddSkipConfirmFlag(credentialProvidersDeleteCmd) // Test flags addJSONOutputFlag(credentialProvidersTestCmd) diff --git a/cmd/credentials.go b/cmd/credentials.go index b17a37e7..0edaff7d 100644 --- a/cmd/credentials.go +++ b/cmd/credentials.go @@ -95,10 +95,6 @@ func (c CredentialsCmd) List(ctx context.Context, in CredentialsListInput) error } if in.Output == "json" { - if len(credentials) == 0 { - fmt.Println("[]") - return nil - } return util.PrintPrettyJSONSlice(credentials) } @@ -175,13 +171,13 @@ func (c CredentialsCmd) Create(ctx context.Context, in CredentialsCreateInput) e } if in.Name == "" { - return fmt.Errorf("--name is required") + return util.RequiredFlag("--name", "") } if in.Domain == "" { - return fmt.Errorf("--domain is required") + return util.RequiredFlag("--domain", "") } if len(in.Values) == 0 { - return fmt.Errorf("at least one --value is required") + return util.RequiredFlag("--value", "key=value") } params := kernel.CredentialNewParams{ @@ -424,7 +420,7 @@ func init() { credentialsUpdateCmd.Flags().StringArray("value", []string{}, "Field name=value pair to update (repeatable)") // Delete flags - credentialsDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + util.AddSkipConfirmFlag(credentialsDeleteCmd) // TOTP code flags addJSONOutputFlag(credentialsTotpCodeCmd) @@ -473,7 +469,7 @@ func runCredentialsCreate(cmd *cobra.Command, args []string) error { for _, pair := range valuePairs { parts := strings.SplitN(pair, "=", 2) if len(parts) != 2 { - return fmt.Errorf("invalid value format: %s (expected key=value)", pair) + return fmt.Errorf("invalid --value %q; use key=value", pair) } values[parts[0]] = parts[1] } @@ -503,7 +499,7 @@ func runCredentialsUpdate(cmd *cobra.Command, args []string) error { for _, pair := range valuePairs { parts := strings.SplitN(pair, "=", 2) if len(parts) != 2 { - return fmt.Errorf("invalid value format: %s (expected key=value)", pair) + return fmt.Errorf("invalid --value %q; use key=value", pair) } values[parts[0]] = parts[1] } diff --git a/cmd/deploy.go b/cmd/deploy.go index f399d6c3..82c651c2 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -85,7 +85,7 @@ func init() { deployLogsCmd.Flags().BoolP("with-timestamps", "t", false, "Include timestamps in each log line") deployCmd.AddCommand(deployLogsCmd) - deployDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + util.AddSkipConfirmFlag(deployDeleteCmd) deployCmd.AddCommand(deployDeleteCmd) deployHistoryCmd.Flags().Int("limit", 20, "Max deployments to return (default 20)") @@ -145,7 +145,7 @@ func runDeployGithub(cmd *cobra.Command, args []string) error { for _, kv := range envPairs { parts := strings.SplitN(kv, "=", 2) if len(parts) != 2 { - return fmt.Errorf("invalid env variable format: %s (expected KEY=value)", kv) + return fmt.Errorf("invalid --env %q; use KEY=value", kv) } envVars[parts[0]] = parts[1] } @@ -160,14 +160,14 @@ func runDeployGithub(cmd *cobra.Command, args []string) error { // Manually POST multipart with a JSON 'source' field to match backend expectations apiKey := os.Getenv("KERNEL_API_KEY") if strings.TrimSpace(apiKey) == "" { - return fmt.Errorf("KERNEL_API_KEY is required for github deploy") + return fmt.Errorf("KERNEL_API_KEY is required for GitHub deploy; export KERNEL_API_KEY= and retry") } 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) + return util.InvalidChoice("--region", region, string(kernel.DeploymentNewParamsRegionAwsUsEast1a)) } var body bytes.Buffer @@ -256,7 +256,7 @@ func runDeploy(cmd *cobra.Command, args []string) (err error) { return fmt.Errorf("failed to resolve entrypoint: %w", err) } if _, err := os.Stat(resolvedEntrypoint); err != nil { - return fmt.Errorf("entrypoint %s does not exist", resolvedEntrypoint) + return fmt.Errorf("entrypoint %q does not exist; pass a valid file path", resolvedEntrypoint) } sourceDir := filepath.Dir(resolvedEntrypoint) @@ -305,7 +305,7 @@ func runDeploy(cmd *cobra.Command, args []string) (err error) { for _, kv := range envPairs { parts := strings.SplitN(kv, "=", 2) if len(parts) != 2 { - return fmt.Errorf("invalid env variable format: %s (expected KEY=value)", kv) + return fmt.Errorf("invalid --env %q; use KEY=value", kv) } envVars[parts[0]] = parts[1] } @@ -324,7 +324,7 @@ func runDeploy(cmd *cobra.Command, args []string) (err error) { } if region != "" { if region != string(kernel.DeploymentNewParamsRegionAwsUsEast1a) { - return fmt.Errorf("invalid --region value: %s (must be %s)", region, kernel.DeploymentNewParamsRegionAwsUsEast1a) + return util.InvalidChoice("--region", region, string(kernel.DeploymentNewParamsRegionAwsUsEast1a)) } params.Region = kernel.DeploymentNewParamsRegion(region) } @@ -515,7 +515,7 @@ func runDeployHistory(cmd *cobra.Command, args []string) error { appNameFilter = strings.TrimSpace(args[0]) } if appVersionFilter != "" && appNameFilter == "" { - return fmt.Errorf("--app-version requires app_name") + return fmt.Errorf("--app-version requires app_name; use `kernel deploy history --app-version `") } params := kernel.DeploymentListParams{} @@ -534,16 +534,11 @@ func runDeployHistory(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/version 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 { @@ -572,7 +567,7 @@ func runDeployHistory(cmd *cobra.Command, args []string) error { dep.StatusReason, }) } - pterm.DefaultTable.WithHasHeader().WithData(table).Render() + PrintTableNoPad(table, true) pterm.Printf("\nPage: %d Per-page: %d Items this page: %d Has more: %s\n", page, perPage, itemsThisPage, lo.Ternary(hasMore, "yes", "no")) if hasMore { diff --git a/cmd/extensions.go b/cmd/extensions.go index 7138e0c2..71516c5e 100644 --- a/cmd/extensions.go +++ b/cmd/extensions.go @@ -95,11 +95,7 @@ func (e ExtensionsCmd) List(ctx context.Context, in ExtensionsListInput) error { } if in.Output == "json" { - if items == nil || len(*items) == 0 { - fmt.Println("[]") - return nil - } - return util.PrintPrettyJSONSlice(*items) + return util.PrintPrettyJSONPointerSlice(items) } if items == nil || len(*items) == 0 { @@ -126,8 +122,7 @@ func (e ExtensionsCmd) List(ctx context.Context, in ExtensionsListInput) error { func (e ExtensionsCmd) Delete(ctx context.Context, in ExtensionsDeleteInput) error { if in.Identifier == "" { - pterm.Error.Println("Missing identifier") - return nil + return util.RequiredArg("extension ID or name", "kernel extensions delete ") } if !in.SkipConfirm { @@ -153,8 +148,7 @@ func (e ExtensionsCmd) Delete(ctx context.Context, in ExtensionsDeleteInput) err func (e ExtensionsCmd) Download(ctx context.Context, in ExtensionsDownloadInput) error { if in.Identifier == "" { - pterm.Error.Println("Missing identifier") - return nil + return util.RequiredArg("extension ID or name", "kernel extensions download --to ") } res, err := e.extensions.Download(ctx, in.Identifier) if err != nil { @@ -162,56 +156,48 @@ func (e ExtensionsCmd) Download(ctx context.Context, in ExtensionsDownloadInput) } defer res.Body.Close() if in.Output == "" { - pterm.Error.Println("Missing --to output directory") _, _ = io.Copy(io.Discard, res.Body) - return nil + return util.RequiredFlag("--to", "") } outDir, err := filepath.Abs(in.Output) if err != nil { - pterm.Error.Printf("Failed to resolve output path: %v\n", err) _, _ = io.Copy(io.Discard, res.Body) - return nil + return fmt.Errorf("resolve --to path %q failed; choose a valid directory path: %w", in.Output, err) } // Create directory if not exists; if exists, ensure empty if st, err := os.Stat(outDir); err == nil { if !st.IsDir() { - pterm.Error.Printf("Output path exists and is not a directory: %s\n", outDir) _, _ = io.Copy(io.Discard, res.Body) - return nil + return fmt.Errorf("--to %q is a file; choose an empty directory", outDir) } entries, _ := os.ReadDir(outDir) if len(entries) > 0 { - pterm.Error.Printf("Output directory must be empty: %s\n", outDir) _, _ = io.Copy(io.Discard, res.Body) - return nil + return fmt.Errorf("--to %q is not empty; choose an empty directory", outDir) } } else { if err := os.MkdirAll(outDir, 0o755); err != nil { - pterm.Error.Printf("Failed to create output directory: %v\n", err) _, _ = io.Copy(io.Discard, res.Body) - return nil + return fmt.Errorf("create --to directory %q failed; check parent permissions: %w", outDir, err) } } // Write response to a temp zip, then extract tmpZip, err := os.CreateTemp("", "kernel-ext-*.zip") if err != nil { - pterm.Error.Printf("Failed to create temp zip: %v\n", err) _, _ = io.Copy(io.Discard, res.Body) - return nil + return fmt.Errorf("create temporary extension zip failed; check temp directory permissions: %w", err) } tmpName := tmpZip.Name() defer func() { _ = os.Remove(tmpName) }() if _, err := io.Copy(tmpZip, res.Body); err != nil { _ = tmpZip.Close() - pterm.Error.Printf("Failed to read response: %v\n", err) - return nil + return fmt.Errorf("download extension archive failed while reading response: %w", err) } _ = tmpZip.Close() if err := util.Unzip(tmpName, outDir); err != nil { - pterm.Error.Printf("Failed to extract zip: %v\n", err) - return nil + return fmt.Errorf("extract extension archive into %q failed; choose an empty writable directory: %w", outDir, err) } pterm.Success.Printf("Extracted extension to %s\n", outDir) return nil @@ -219,8 +205,7 @@ func (e ExtensionsCmd) Download(ctx context.Context, in ExtensionsDownloadInput) func (e ExtensionsCmd) DownloadWebStore(ctx context.Context, in ExtensionsDownloadWebStoreInput) error { if in.URL == "" { - pterm.Error.Println("Missing URL argument") - return nil + return util.RequiredArg("Chrome Web Store URL", "kernel extensions download-web-store --to ") } params := kernel.ExtensionDownloadFromChromeStoreParams{URL: in.URL} switch in.OS { @@ -231,8 +216,7 @@ func (e ExtensionsCmd) DownloadWebStore(ctx context.Context, in ExtensionsDownlo case string(kernel.ExtensionDownloadFromChromeStoreParamsOsWin): params.Os = kernel.ExtensionDownloadFromChromeStoreParamsOsWin default: - pterm.Error.Println("--os must be one of mac, win, linux") - return nil + return util.InvalidChoice("--os", in.OS, "linux", "mac", "win") } res, err := e.extensions.DownloadFromChromeStore(ctx, params) @@ -242,59 +226,50 @@ func (e ExtensionsCmd) DownloadWebStore(ctx context.Context, in ExtensionsDownlo defer res.Body.Close() if in.Output == "" { - pterm.Error.Println("Missing --to output directory") _, _ = io.Copy(io.Discard, res.Body) - return nil + return util.RequiredFlag("--to", "") } outDir, err := filepath.Abs(in.Output) if err != nil { - pterm.Error.Printf("Failed to resolve output path: %v\n", err) _, _ = io.Copy(io.Discard, res.Body) - return nil + return fmt.Errorf("resolve --to path %q failed; choose a valid directory path: %w", in.Output, err) } if st, err := os.Stat(outDir); err == nil { if !st.IsDir() { - pterm.Error.Printf("Output path exists and is not a directory: %s\n", outDir) _, _ = io.Copy(io.Discard, res.Body) - return nil + return fmt.Errorf("--to %q is a file; choose an empty directory", outDir) } entries, _ := os.ReadDir(outDir) if len(entries) > 0 { - pterm.Error.Printf("Output directory must be empty: %s\n", outDir) _, _ = io.Copy(io.Discard, res.Body) - return nil + return fmt.Errorf("--to %q is not empty; choose an empty directory", outDir) } } else { if err := os.MkdirAll(outDir, 0o755); err != nil { - pterm.Error.Printf("Failed to create output directory: %v\n", err) _, _ = io.Copy(io.Discard, res.Body) - return nil + return fmt.Errorf("create --to directory %q failed; check parent permissions: %w", outDir, err) } } // Save to temp zip then extract var bodyBuf bytes.Buffer if _, err := io.Copy(&bodyBuf, res.Body); err != nil { - pterm.Error.Printf("Failed to read response: %v\n", err) - return nil + return fmt.Errorf("download Web Store archive failed while reading response: %w", err) } tmpZip, err := os.CreateTemp("", "kernel-webstore-*.zip") if err != nil { - pterm.Error.Printf("Failed to create temp zip: %v\n", err) - return nil + return fmt.Errorf("create temporary Web Store zip failed; check temp directory permissions: %w", err) } tmpName := tmpZip.Name() if _, err := tmpZip.Write(bodyBuf.Bytes()); err != nil { _ = tmpZip.Close() - pterm.Error.Printf("Failed to write temp zip: %v\n", err) - return nil + return fmt.Errorf("write temporary Web Store zip failed; check temp directory permissions: %w", err) } _ = tmpZip.Close() defer os.Remove(tmpName) if err := util.Unzip(tmpName, outDir); err != nil { - pterm.Error.Printf("Failed to extract zip: %v\n", err) - return nil + return fmt.Errorf("extract Web Store archive into %q failed; choose an empty writable directory: %w", outDir, err) } pterm.Success.Printf("Extracted extension to %s\n", outDir) return nil @@ -306,15 +281,15 @@ func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) err } if in.Dir == "" { - return fmt.Errorf("missing directory argument") + return util.RequiredArg("extension directory", "kernel extensions upload ") } absDir, err := filepath.Abs(in.Dir) if err != nil { - return fmt.Errorf("failed to resolve directory: %w", err) + return fmt.Errorf("resolve extension directory %q failed; pass a valid path: %w", in.Dir, err) } stat, err := os.Stat(absDir) if err != nil || !stat.IsDir() { - return fmt.Errorf("directory %s does not exist", absDir) + return fmt.Errorf("extension directory %q does not exist; pass an unpacked extension directory", absDir) } // Pre-flight size check @@ -325,14 +300,13 @@ func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) err tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("kernel_ext_%d.zip", time.Now().UnixNano())) if err := util.ZipDirectory(absDir, tmpFile, &defaultExtensionExclusions); err != nil { - pterm.Error.Println("Failed to zip directory") - return err + return fmt.Errorf("zip extension directory %q failed; check the directory contents: %w", absDir, err) } defer os.Remove(tmpFile) fileInfo, err := os.Stat(tmpFile) if err != nil { - return fmt.Errorf("failed to stat zip: %w", err) + return fmt.Errorf("stat extension bundle %q failed: %w", tmpFile, err) } if in.Output != "json" { @@ -346,12 +320,12 @@ func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) err pterm.Info.Println(" 1. Ensure you're building the extension for production") pterm.Info.Println(" 2. Remove unnecessary assets (large images, videos)") pterm.Info.Println(" 3. Check manifest.json references only needed files") - return fmt.Errorf("bundle exceeds maximum size") + return fmt.Errorf("extension bundle is %s; keep it under %s", util.FormatBytes(fileInfo.Size()), util.FormatBytes(MaxExtensionSizeBytes)) } f, err := os.Open(tmpFile) if err != nil { - return fmt.Errorf("failed to open temp zip: %w", err) + return fmt.Errorf("open extension bundle %q failed: %w", tmpFile, err) } defer f.Close() @@ -519,7 +493,7 @@ func init() { extensionsCmd.AddCommand(extensionsBuildWebBotAuthCmd) addJSONOutputFlag(extensionsListCmd) - extensionsDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + util.AddSkipConfirmFlag(extensionsDeleteCmd) extensionsDownloadCmd.Flags().String("to", "", "Output zip file path") extensionsDownloadWebStoreCmd.Flags().String("to", "", "Output zip file path for the downloaded archive") extensionsDownloadWebStoreCmd.Flags().String("os", "", "Target OS: mac, win, or linux (default linux)") diff --git a/cmd/extensions_test.go b/cmd/extensions_test.go index 8be3c448..1c10dc90 100644 --- a/cmd/extensions_test.go +++ b/cmd/extensions_test.go @@ -100,13 +100,14 @@ func TestExtensionsDelete_NotFound(t *testing.T) { } func TestExtensionsDownload_MissingOutput(t *testing.T) { - buf := capturePtermOutput(t) fake := &FakeExtensionsService{DownloadFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*http.Response, error) { return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("content")), Header: http.Header{}}, nil }} e := ExtensionsCmd{extensions: fake} - _ = e.Download(context.Background(), ExtensionsDownloadInput{Identifier: "e1", Output: ""}) - assert.Contains(t, buf.String(), "Missing --to output directory") + err := e.Download(context.Background(), ExtensionsDownloadInput{Identifier: "e1", Output: ""}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "--to is required") + assert.Contains(t, err.Error(), "add --to ") } func TestExtensionsDownload_ExtractsToDir(t *testing.T) { @@ -158,11 +159,12 @@ func TestExtensionsDownloadWebStore_ExtractsToDir(t *testing.T) { } func TestExtensionsDownloadWebStore_InvalidOS(t *testing.T) { - buf := capturePtermOutput(t) fake := &FakeExtensionsService{} e := ExtensionsCmd{extensions: fake} - _ = e.DownloadWebStore(context.Background(), ExtensionsDownloadWebStoreInput{URL: "https://store/link", Output: "x", OS: "freebsd"}) - assert.Contains(t, buf.String(), "--os must be one of mac, win, linux") + err := e.DownloadWebStore(context.Background(), ExtensionsDownloadWebStoreInput{URL: "https://store/link", Output: "x", OS: "freebsd"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), `invalid --os "freebsd"`) + assert.Contains(t, err.Error(), "linux, mac, win") } func TestExtensionsUpload_Success(t *testing.T) { diff --git a/cmd/invoke.go b/cmd/invoke.go index cca38044..1c840379 100644 --- a/cmd/invoke.go +++ b/cmd/invoke.go @@ -103,7 +103,7 @@ func init() { func runInvoke(cmd *cobra.Command, args []string) error { if len(args) != 2 { - return fmt.Errorf("requires exactly 2 arguments: ") + return util.RequiredArg("app and action", "kernel invoke ") } startTime := time.Now() client := getKernelClient(cmd) @@ -118,7 +118,7 @@ func runInvoke(cmd *cobra.Command, args []string) error { jsonOutput := output == "json" if version == "" { - return fmt.Errorf("version cannot be an empty string") + return fmt.Errorf("--version cannot be empty; omit --version or pass --version ") } isSync, _ := cmd.Flags().GetBool("sync") asyncTimeout, _ := cmd.Flags().GetInt64("async-timeout") @@ -376,7 +376,7 @@ func getPayload(cmd *cobra.Command) (payload string, hasPayload bool, err error) if payloadStr != "" { var v interface{} if err := json.Unmarshal([]byte(payloadStr), &v); err != nil { - return "", false, fmt.Errorf("invalid JSON payload: %w", err) + return "", false, fmt.Errorf("invalid --payload JSON; pass a JSON object, array, string, number, boolean, or null: %w", err) } } return payloadStr, true, nil @@ -390,13 +390,13 @@ func getPayload(cmd *cobra.Command) (payload string, hasPayload bool, err error) // Read from stdin data, err = io.ReadAll(os.Stdin) if err != nil { - return "", false, fmt.Errorf("failed to read payload from stdin: %w", err) + return "", false, fmt.Errorf("read --payload-file - from stdin failed: %w", err) } } else { // Read from file data, err = os.ReadFile(payloadFile) if err != nil { - return "", false, fmt.Errorf("failed to read payload file: %w", err) + return "", false, fmt.Errorf("read --payload-file %q failed; check the path and permissions: %w", payloadFile, err) } } @@ -405,7 +405,7 @@ func getPayload(cmd *cobra.Command) (payload string, hasPayload bool, err error) if payloadStr != "" { var v interface{} if err := json.Unmarshal([]byte(payloadStr), &v); err != nil { - return "", false, fmt.Errorf("invalid JSON in payload file: %w", err) + return "", false, fmt.Errorf("invalid JSON in --payload-file %q; fix the file contents: %w", payloadFile, err) } } return payloadStr, true, nil @@ -468,7 +468,7 @@ func runInvocationHistory(cmd *cobra.Command, args []string) error { case "failed": params.Status = kernel.InvocationListParamsStatusFailed default: - return fmt.Errorf("invalid --status value: %s (must be queued, running, succeeded, or failed)", statusFilter) + return util.InvalidChoice("--status", statusFilter, "queued", "running", "succeeded", "failed") } } @@ -488,15 +488,10 @@ func runInvocationHistory(cmd *cobra.Command, args []string) error { // Make a single API call to get invocations invocations, err := client.Invocations.List(cmd.Context(), params) if err != nil { - pterm.Error.Printf("Failed to list invocations: %v\n", err) - return nil + return util.CleanedUpSdkError{Err: fmt.Errorf("list invocations failed; check the app name or run `kernel app list`: %w", err)} } if output == "json" { - if len(invocations.Items) == 0 { - fmt.Println("[]") - return nil - } return util.PrintPrettyJSONSlice(invocations.Items) } @@ -542,7 +537,7 @@ func runInvocationHistory(cmd *cobra.Command, args []string) error { if len(table) == 1 { pterm.Info.Println("No invocations found.") } else { - pterm.DefaultTable.WithHasHeader().WithData(table).Render() + PrintTableNoPad(table, true) } return nil } @@ -567,10 +562,6 @@ func runInvocationBrowsers(cmd *cobra.Command, args []string) error { } if output == "json" { - if len(resp.Browsers) == 0 { - fmt.Println("[]") - return nil - } return util.PrintPrettyJSONSlice(resp.Browsers) } @@ -600,7 +591,7 @@ func runInvocationBrowsers(cmd *cobra.Command, args []string) error { } pterm.Info.Printf("Browsers for invocation %s:\n", invocationID) - pterm.DefaultTable.WithHasHeader().WithData(table).Render() + PrintTableNoPad(table, true) return nil } @@ -648,7 +639,7 @@ func runInvocationUpdate(cmd *cobra.Command, args []string) error { case "failed": parsedStatus = kernel.InvocationUpdateParamsStatusFailed default: - return fmt.Errorf("invalid --status value: %s (must be succeeded or failed)", status) + return util.InvalidChoice("--status", status, "succeeded", "failed") } params := kernel.InvocationUpdateParams{Status: parsedStatus} @@ -656,7 +647,7 @@ func runInvocationUpdate(cmd *cobra.Command, args []string) error { 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) + return fmt.Errorf("invalid --output JSON; pass a JSON string/object/array or omit --output: %w", err) } } params.Output = kernel.Opt(output) diff --git a/cmd/logs.go b/cmd/logs.go index 4715eeb1..daac8e90 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -141,10 +141,10 @@ func runLogs(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list apps: %w", err) } if apps == nil || len(apps.Items) == 0 { - return fmt.Errorf("app \"%s\" not found", appName) + return util.NotFound("App", appName, "kernel app list") } if len(apps.Items) > 1 { - return fmt.Errorf("multiple apps found for \"%s\", please specify a version", appName) + return fmt.Errorf("multiple app versions found for %q; rerun with --version ", appName) } app := apps.Items[0] diff --git a/cmd/mcp/server.go b/cmd/mcp/server.go index 7dd5f64f..2a7caacd 100644 --- a/cmd/mcp/server.go +++ b/cmd/mcp/server.go @@ -1,6 +1,7 @@ package mcp import ( + "github.com/kernel/cli/pkg/table" "github.com/pterm/pterm" "github.com/spf13/cobra" ) @@ -38,7 +39,7 @@ func runServer(cmd *cobra.Command, args []string) { {"HTTP (recommended)", KernelMCPURL}, {"stdio (via mcp-remote)", "npx -y mcp-remote " + KernelMCPURL}, } - _ = pterm.DefaultTable.WithHasHeader().WithData(rows).Render() + table.PrintTableNoPad(rows, true) pterm.Println() pterm.DefaultSection.Println("Quick Install") diff --git a/cmd/profiles.go b/cmd/profiles.go index bfc055c7..46acb07c 100644 --- a/cmd/profiles.go +++ b/cmd/profiles.go @@ -104,10 +104,6 @@ func (p ProfilesCmd) List(ctx context.Context, in ProfilesListInput) error { itemsThisPage := len(items) if in.Output == "json" { - if len(items) == 0 { - fmt.Println("[]") - return nil - } return util.PrintPrettyJSONSlice(items) } @@ -158,8 +154,7 @@ func (p ProfilesCmd) Get(ctx context.Context, in ProfilesGetInput) error { fmt.Println("null") return nil } - pterm.Error.Printf("Profile '%s' not found\n", in.Identifier) - return nil + return util.NotFound("Profile", in.Identifier, "kernel profiles list") } if in.Output == "json" { @@ -249,7 +244,7 @@ func (p ProfilesCmd) Delete(ctx context.Context, in ProfilesDeleteInput) error { func (p ProfilesCmd) Download(ctx context.Context, in ProfilesDownloadInput) error { if in.To == "" { - return fmt.Errorf("missing required --to for extraction directory") + return util.RequiredFlag("--to", "") } res, err := p.profiles.Download(ctx, in.Identifier) @@ -266,7 +261,7 @@ func (p ProfilesCmd) Download(ctx context.Context, in ProfilesDownloadInput) err if res.StatusCode != http.StatusOK { body, _ := io.ReadAll(res.Body) - return fmt.Errorf("unexpected status %d from profile download: %s", res.StatusCode, strings.TrimSpace(string(body))) + return fmt.Errorf("profile download returned HTTP %d; retry later or run `kernel profiles get %s`: %s", res.StatusCode, in.Identifier, strings.TrimSpace(string(body))) } if err := extractProfileArchive(res.Body, in.To); err != nil { @@ -395,7 +390,7 @@ func init() { addJSONOutputFlag(profilesGetCmd) addJSONOutputFlag(profilesCreateCmd) profilesCreateCmd.Flags().String("name", "", "Optional unique profile name") - profilesDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + util.AddSkipConfirmFlag(profilesDeleteCmd) profilesDownloadCmd.Flags().String("to", "", "Directory to extract the profile into (required)") _ = profilesDownloadCmd.MarkFlagRequired("to") } diff --git a/cmd/profiles_test.go b/cmd/profiles_test.go index d1043845..5fb5b036 100644 --- a/cmd/profiles_test.go +++ b/cmd/profiles_test.go @@ -226,7 +226,8 @@ func TestProfilesDownload_MissingTo(t *testing.T) { p := ProfilesCmd{profiles: fake} err := p.Download(context.Background(), ProfilesDownloadInput{Identifier: "p1", To: ""}) assert.Error(t, err) - assert.Contains(t, err.Error(), "missing required --to") + assert.Contains(t, err.Error(), "--to is required") + assert.Contains(t, err.Error(), "add --to ") } func TestProfilesDownload_ExtractSuccess(t *testing.T) { diff --git a/cmd/projects.go b/cmd/projects.go index 40549614..35fe9329 100644 --- a/cmd/projects.go +++ b/cmd/projects.go @@ -35,7 +35,9 @@ type ProjectsCmd struct { limits ProjectLimitsService } -type ProjectsListInput struct{} +type ProjectsListInput struct { + Output string +} type ProjectsCreateInput struct { Name string @@ -43,6 +45,7 @@ type ProjectsCreateInput struct { type ProjectsGetInput struct { Identifier string + Output string } type ProjectsDeleteInput struct { @@ -77,11 +80,19 @@ func resolveProjectArg(ctx context.Context, projects ProjectListService, val str } func (c ProjectsCmd) List(ctx context.Context, in ProjectsListInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + projects, err := c.projects.List(ctx, kernel.ProjectListParams{}) if err != nil { return util.CleanedUpSdkError{Err: err} } + if in.Output == "json" { + return util.PrintPrettyJSONPageItems(projects) + } + if projects == nil || len(projects.Items) == 0 { pterm.Info.Println("No projects found") return nil @@ -110,6 +121,10 @@ func (c ProjectsCmd) Create(ctx context.Context, in ProjectsCreateInput) error { } func (c ProjectsCmd) Get(ctx context.Context, in ProjectsGetInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + projectID, err := resolveProjectArg(ctx, c.projects, in.Identifier) if err != nil { return err @@ -120,8 +135,16 @@ func (c ProjectsCmd) Get(ctx context.Context, in ProjectsGetInput) error { return util.CleanedUpSdkError{Err: err} } + if in.Output == "json" { + if project == nil { + fmt.Println("null") + return nil + } + return util.PrintPrettyJSON(project) + } + table := pterm.TableData{ - {"Field", "Value"}, + {"Property", "Value"}, {"ID", project.ID}, {"Name", project.Name}, {"Status", string(project.Status)}, @@ -259,7 +282,8 @@ func getProjectsHandler(cmd *cobra.Command) ProjectsCmd { func runProjectsList(cmd *cobra.Command, args []string) error { c := getProjectsHandler(cmd) - return c.List(cmd.Context(), ProjectsListInput{}) + output, _ := cmd.Flags().GetString("output") + return c.List(cmd.Context(), ProjectsListInput{Output: output}) } func runProjectsCreate(cmd *cobra.Command, args []string) error { @@ -269,7 +293,8 @@ func runProjectsCreate(cmd *cobra.Command, args []string) error { func runProjectsGet(cmd *cobra.Command, args []string) error { c := getProjectsHandler(cmd) - return c.Get(cmd.Context(), ProjectsGetInput{Identifier: args[0]}) + output, _ := cmd.Flags().GetString("output") + return c.Get(cmd.Context(), ProjectsGetInput{Identifier: args[0], Output: output}) } func runProjectsDelete(cmd *cobra.Command, args []string) error { @@ -392,6 +417,8 @@ var projectsSetLimitsCompatCmd = &cobra.Command{ } func init() { + addJSONOutputFlag(projectsListCmd) + addJSONOutputFlag(projectsGetCmd) addJSONOutputFlag(projectsLimitsGetCmd) addProjectsLimitsSetFlags(projectsLimitsSetCmd) addJSONOutputFlag(projectsGetLimitsCompatCmd) diff --git a/cmd/projects_test.go b/cmd/projects_test.go index 3855cdf1..1e085c8a 100644 --- a/cmd/projects_test.go +++ b/cmd/projects_test.go @@ -1,8 +1,12 @@ package cmd import ( + "bytes" "context" + "encoding/json" "errors" + "io" + "os" "testing" "github.com/kernel/kernel-go-sdk" @@ -10,6 +14,7 @@ import ( "github.com/kernel/kernel-go-sdk/packages/pagination" "github.com/kernel/kernel-go-sdk/packages/respjson" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type FakeProjectsService struct { @@ -66,6 +71,81 @@ func (f *FakeProjectLimitsService) Update(ctx context.Context, id string, body k return &kernel.ProjectLimits{}, nil } +func TestProjectsList_JSONOutput(t *testing.T) { + project := mustProject(t, `{"id":"a12345678901234567890123","name":"Default","status":"active","created_at":"2026-05-29T12:00:00Z"}`) + c := ProjectsCmd{ + projects: &FakeProjectsService{ + ListFunc: func(ctx context.Context, query kernel.ProjectListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Project], error) { + return &pagination.OffsetPagination[kernel.Project]{Items: []kernel.Project{project}}, nil + }, + }, + limits: &FakeProjectLimitsService{}, + } + + var err error + out := captureStdout(t, func() { + err = c.List(context.Background(), ProjectsListInput{Output: "json"}) + }) + + require.NoError(t, err) + assert.JSONEq(t, `[{"id":"a12345678901234567890123","name":"Default","status":"active","created_at":"2026-05-29T12:00:00Z"}]`, out) +} + +func TestProjectsList_InvalidOutput(t *testing.T) { + c := ProjectsCmd{ + projects: &FakeProjectsService{ + ListFunc: func(ctx context.Context, query kernel.ProjectListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Project], error) { + t.Fatal("List should not be called") + return nil, nil + }, + }, + limits: &FakeProjectLimitsService{}, + } + + err := c.List(context.Background(), ProjectsListInput{Output: "yaml"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported --output value") +} + +func TestProjectsGet_JSONOutput(t *testing.T) { + project := mustProject(t, `{"id":"a12345678901234567890123","name":"Default","status":"active","created_at":"2026-05-29T12:00:00Z"}`) + c := ProjectsCmd{ + projects: &FakeProjectsService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.Project, error) { + assert.Equal(t, "a12345678901234567890123", id) + return &project, nil + }, + }, + limits: &FakeProjectLimitsService{}, + } + + var err error + out := captureStdout(t, func() { + err = c.Get(context.Background(), ProjectsGetInput{Identifier: "a12345678901234567890123", Output: "json"}) + }) + + require.NoError(t, err) + assert.JSONEq(t, `{"id":"a12345678901234567890123","name":"Default","status":"active","created_at":"2026-05-29T12:00:00Z"}`, out) +} + +func TestProjectsGet_InvalidOutput(t *testing.T) { + c := ProjectsCmd{ + projects: &FakeProjectsService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.Project, error) { + t.Fatal("Get should not be called") + return nil, nil + }, + }, + limits: &FakeProjectLimitsService{}, + } + + err := c.Get(context.Background(), ProjectsGetInput{Identifier: "a12345678901234567890123", Output: "yaml"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported --output value") +} + func TestProjectsLimitsGet_DefaultOutput(t *testing.T) { buf := capturePtermOutput(t) limits := &kernel.ProjectLimits{ @@ -194,3 +274,34 @@ func TestResolveProjectByName_PaginatesAcrossResults(t *testing.T) { assert.Equal(t, "proj_target", id) assert.Equal(t, []int64{0, 100}, seenOffsets) } + +func mustProject(t *testing.T, raw string) kernel.Project { + t.Helper() + + var project kernel.Project + require.NoError(t, json.Unmarshal([]byte(raw), &project)) + return project +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + oldStdout := os.Stdout + reader, writer, err := os.Pipe() + require.NoError(t, err) + os.Stdout = writer + t.Cleanup(func() { + os.Stdout = oldStdout + }) + + fn() + + require.NoError(t, writer.Close()) + os.Stdout = oldStdout + + var buf bytes.Buffer + _, err = io.Copy(&buf, reader) + require.NoError(t, err) + require.NoError(t, reader.Close()) + return buf.String() +} diff --git a/cmd/proxies/create.go b/cmd/proxies/create.go index 44de57fc..93de5768 100644 --- a/cmd/proxies/create.go +++ b/cmd/proxies/create.go @@ -31,7 +31,7 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { case "custom": proxyType = kernel.ProxyNewParamsTypeCustom default: - return fmt.Errorf("invalid proxy type: %s", in.Type) + return util.InvalidChoice("--type", in.Type, "datacenter", "isp", "residential", "mobile", "custom") } params := kernel.ProxyNewParams{ @@ -70,7 +70,7 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { // Validate that if city is provided, country must also be provided if in.City != "" && in.Country == "" { - return fmt.Errorf("--country is required when --city is specified") + return fmt.Errorf("--country is required when --city is set; add --country ") } if in.Country != "" { @@ -94,7 +94,7 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { case "windows", "macos", "android": config.Os = in.OS default: - return fmt.Errorf("invalid OS value: %s (must be windows, macos, or android)", in.OS) + return util.InvalidChoice("--os", in.OS, "windows", "macos", "android") } } params.Config = kernel.ProxyNewParamsConfigUnion{ @@ -106,7 +106,7 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { // Validate that if city is provided, country must also be provided if in.City != "" && in.Country == "" { - return fmt.Errorf("--country is required when --city is specified") + return fmt.Errorf("--country is required when --city is set; add --country ") } if in.Country != "" { @@ -134,10 +134,10 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { case kernel.ProxyNewParamsTypeCustom: if in.Host == "" { - return fmt.Errorf("--host is required for custom proxy type") + return fmt.Errorf("--host is required for custom proxies; add --host ") } if in.Port == 0 { - return fmt.Errorf("--port is required for custom proxy type") + return fmt.Errorf("--port is required for custom proxies; add --port ") } config := kernel.ProxyNewParamsConfigCreateCustomProxyConfig{ @@ -164,7 +164,7 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { case "https": params.Protocol = kernel.ProxyNewParamsProtocolHTTPS default: - return fmt.Errorf("invalid protocol: %s (must be http or https)", in.Protocol) + return util.InvalidChoice("--protocol", in.Protocol, "http", "https") } } diff --git a/cmd/proxies/create_test.go b/cmd/proxies/create_test.go index bdb3b306..31e90e13 100644 --- a/cmd/proxies/create_test.go +++ b/cmd/proxies/create_test.go @@ -139,7 +139,8 @@ func TestProxyCreate_Residential_CityWithoutCountry(t *testing.T) { }) assert.Error(t, err) - assert.Contains(t, err.Error(), "--country is required when --city is specified") + assert.Contains(t, err.Error(), "--country is required when --city is set") + assert.Contains(t, err.Error(), "add --country ") } func TestProxyCreate_Residential_InvalidOS(t *testing.T) { @@ -152,7 +153,8 @@ func TestProxyCreate_Residential_InvalidOS(t *testing.T) { }) assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid OS value: linux (must be windows, macos, or android)") + assert.Contains(t, err.Error(), `invalid --os "linux"`) + assert.Contains(t, err.Error(), "windows, macos, android") } func TestProxyCreate_Mobile_Success(t *testing.T) { @@ -236,7 +238,8 @@ func TestProxyCreate_Custom_MissingHost(t *testing.T) { }) assert.Error(t, err) - assert.Contains(t, err.Error(), "--host is required for custom proxy type") + assert.Contains(t, err.Error(), "--host is required for custom proxies") + assert.Contains(t, err.Error(), "add --host ") } func TestProxyCreate_Custom_MissingPort(t *testing.T) { @@ -250,7 +253,8 @@ func TestProxyCreate_Custom_MissingPort(t *testing.T) { }) assert.Error(t, err) - assert.Contains(t, err.Error(), "--port is required for custom proxy type") + assert.Contains(t, err.Error(), "--port is required for custom proxies") + assert.Contains(t, err.Error(), "add --port ") } func TestProxyCreate_InvalidType(t *testing.T) { @@ -262,7 +266,8 @@ func TestProxyCreate_InvalidType(t *testing.T) { }) assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid proxy type: invalid") + assert.Contains(t, err.Error(), `invalid --type "invalid"`) + assert.Contains(t, err.Error(), "datacenter, isp, residential, mobile, custom") } func TestProxyCreate_Protocol_Valid(t *testing.T) { @@ -309,7 +314,8 @@ func TestProxyCreate_Protocol_Invalid(t *testing.T) { }) assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid protocol: ftp") + assert.Contains(t, err.Error(), `invalid --protocol "ftp"`) + assert.Contains(t, err.Error(), "http, https") } func TestProxyCreate_BypassHosts_Normalized(t *testing.T) { diff --git a/cmd/proxies/list.go b/cmd/proxies/list.go index 762503e6..ce21d988 100644 --- a/cmd/proxies/list.go +++ b/cmd/proxies/list.go @@ -27,11 +27,7 @@ func (p ProxyCmd) List(ctx context.Context, in ProxyListInput) error { } if in.Output == "json" { - if items == nil || len(*items) == 0 { - fmt.Println("[]") - return nil - } - return util.PrintPrettyJSONSlice(*items) + return util.PrintPrettyJSONPointerSlice(items) } if items == nil || len(*items) == 0 { diff --git a/cmd/proxies/proxies.go b/cmd/proxies/proxies.go index ee93faf4..8a4b7a56 100644 --- a/cmd/proxies/proxies.go +++ b/cmd/proxies/proxies.go @@ -1,6 +1,7 @@ package proxies import ( + "github.com/kernel/cli/pkg/util" "github.com/spf13/cobra" ) @@ -111,7 +112,7 @@ func init() { proxiesCreateCmd.Flags().StringSlice("bypass-host", nil, "Hostname(s) to bypass proxy and connect directly (repeat or comma-separated)") // Delete flags - proxiesDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + util.AddSkipConfirmFlag(proxiesDeleteCmd) // Check flags addJSONOutputFlag(proxiesCheckCmd) diff --git a/cmd/root.go b/cmd/root.go index 88d6d87c..81682360 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -253,6 +253,9 @@ func isUsageError(err error) bool { "unknown shorthand flag:", "unknown command", "invalid argument", + "accepts ", + "requires at least ", + "requires exactly ", } { if strings.HasPrefix(s, prefix) { return true diff --git a/cmd/status.go b/cmd/status.go index f225e229..39eaf42b 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -45,16 +45,15 @@ func runStatus(cmd *cobra.Command, args []string) error { } client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Get(util.GetBaseURL() + "/status") + statusURL := util.GetBaseURL() + "/status" + resp, err := client.Get(statusURL) if err != nil { - pterm.Error.Println("Could not reach Kernel API. Check https://status.kernel.sh for updates.") - return nil + return fmt.Errorf("could not reach Kernel API at %s; check https://status.kernel.sh and retry: %w", statusURL, err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - pterm.Error.Println("Could not reach Kernel API. Check https://status.kernel.sh for updates.") - return nil + return fmt.Errorf("Kernel API status check returned %s; check https://status.kernel.sh and retry", resp.Status) } var status statusResponse diff --git a/pkg/extensions/webbotauth.go b/pkg/extensions/webbotauth.go index 3f11d684..10ebeb67 100644 --- a/pkg/extensions/webbotauth.go +++ b/pkg/extensions/webbotauth.go @@ -132,14 +132,10 @@ func extractExtensionID(output string) string { // validateToolDependencies checks for required tools (node and npm) func validateToolDependencies() error { if _, err := exec.LookPath("node"); err != nil { - pterm.Error.Println("Node.js is required but not found in PATH") - pterm.Info.Println("Please install Node.js from https://nodejs.org/") - return fmt.Errorf("node not found") + return fmt.Errorf("node is required to build web-bot-auth; install Node.js from https://nodejs.org/ and retry") } if _, err := exec.LookPath("npm"); err != nil { - pterm.Error.Println("npm is required but not found in PATH") - pterm.Info.Println("Please install npm (usually comes with Node.js)") - return fmt.Errorf("npm not found") + return fmt.Errorf("npm is required to build web-bot-auth; install npm and retry") } return nil } diff --git a/pkg/util/errors.go b/pkg/util/errors.go index 76fe233d..acf3c6bd 100644 --- a/pkg/util/errors.go +++ b/pkg/util/errors.go @@ -5,11 +5,12 @@ import ( "errors" "fmt" "io" + "strings" "github.com/kernel/kernel-go-sdk" ) -// CleanedUpSdkError extracts a message field from the raw JSON resposne. +// CleanedUpSdkError extracts a message field from the raw JSON response. // This is the convention we use in the API for error response bodies (400s and 500s) type CleanedUpSdkError struct { Err error @@ -18,24 +19,109 @@ type CleanedUpSdkError struct { var _ error = CleanedUpSdkError{} func (e CleanedUpSdkError) Error() string { + if kerror, ok := e.Err.(*kernel.Error); ok { + return cleanSdkError(kerror) + } + var kerror *kernel.Error if errors.As(e.Err, &kerror) { - var m map[string]interface{} - if err := json.Unmarshal([]byte(kerror.RawJSON()), &m); err == nil { - message, _ := m["message"].(string) - code, _ := m["code"].(string) - return fmt.Sprintf("%s: %s", code, message) - } else if kerror.Response != nil && kerror.Response.Body != nil { - // try response body as text - body, err := io.ReadAll(kerror.Response.Body) - if err == nil && len(body) > 0 { - return string(body) - } + raw := kerror.Error() + cleaned := cleanSdkError(kerror) + if raw != "" && cleaned != raw { + return strings.Replace(e.Err.Error(), raw, cleaned, 1) } } + if cleaned, ok := cleanNonJSONAPIResponseError(e.Err.Error()); ok { + return cleaned + } return e.Err.Error() } func (e CleanedUpSdkError) Unwrap() error { return e.Err } + +func cleanSdkError(kerror *kernel.Error) string { + var m map[string]interface{} + if err := json.Unmarshal([]byte(kerror.RawJSON()), &m); err == nil { + message, _ := m["message"].(string) + code, _ := m["code"].(string) + return fmt.Sprintf("%s: %s", code, message) + } else if cleaned, ok := cleanNonJSONAPIResponseError(kerror.Error()); ok { + return cleaned + } else if kerror.Response != nil && kerror.Response.Body != nil { + // try response body as text + body, err := io.ReadAll(kerror.Response.Body) + if err == nil && len(body) > 0 { + return string(body) + } + } + return kerror.Error() +} + +func cleanNonJSONAPIResponseError(message string) (string, bool) { + if !strings.Contains(message, "not 'application/json'") || + !strings.Contains(message, "content-type 'text/html") { + return "", false + } + + guidance := fmt.Sprintf( + "server returned HTML instead of Kernel API JSON; KERNEL_BASE_URL resolves to %s. Use an API base URL, not the dashboard URL. For production, unset KERNEL_BASE_URL or set it to https://api.onkernel.com.", + GetBaseURL(), + ) + + const sdkDecodeError = ": expected destination type" + if idx := strings.LastIndex(message, sdkDecodeError); idx >= 0 { + prefix := strings.TrimSuffix(message[:idx], "; check your auth and retry") + return prefix + ": " + guidance, true + } + + return guidance, true +} + +func RequiredFlag(flag, valueHint string) error { + if valueHint == "" { + return fmt.Errorf("%s is required; add %s", flag, flag) + } + return fmt.Errorf("%s is required; add %s %s", flag, flag, valueHint) +} + +func RequiredArg(name, usage string) error { + return fmt.Errorf("missing %s; use: %s", name, usage) +} + +func ChooseOne(flags ...string) error { + return fmt.Errorf("choose one of %s", joinOptions(flags)) +} + +func ChooseOnlyOne(flags ...string) error { + return fmt.Errorf("choose only one of %s", joinOptions(flags)) +} + +func SetAtLeastOne(flags ...string) error { + return fmt.Errorf("set at least one of %s", joinOptions(flags)) +} + +func InvalidChoice(flag, value string, choices ...string) error { + return fmt.Errorf("invalid %s %q; use one of: %s", flag, value, strings.Join(choices, ", ")) +} + +func NotFound(resource, id, listCommand string) error { + if listCommand == "" { + return fmt.Errorf("%s %q not found", resource, id) + } + return fmt.Errorf("%s %q not found; run `%s` to find valid IDs", resource, id, listCommand) +} + +func joinOptions(items []string) string { + switch len(items) { + case 0: + return "" + case 1: + return items[0] + case 2: + return items[0] + " or " + items[1] + default: + return strings.Join(items[:len(items)-1], ", ") + ", or " + items[len(items)-1] + } +} diff --git a/pkg/util/errors_test.go b/pkg/util/errors_test.go new file mode 100644 index 00000000..ecac7e4a --- /dev/null +++ b/pkg/util/errors_test.go @@ -0,0 +1,56 @@ +package util + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/kernel/kernel-go-sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUserErrorHelpers(t *testing.T) { + assert.Equal(t, "--name is required; add --name ", RequiredFlag("--name", "").Error()) + assert.Equal(t, "missing browser ID; use: kernel browsers get ", RequiredArg("browser ID", "kernel browsers get ").Error()) + assert.Equal(t, "choose only one of --profile-id or --profile-name", ChooseOnlyOne("--profile-id", "--profile-name").Error()) + assert.Equal(t, "set at least one of --proxy-id, --profile-id, or --viewport", SetAtLeastOne("--proxy-id", "--profile-id", "--viewport").Error()) + assert.Equal(t, "invalid --status \"bad\"; use one of: active, deleted, all", InvalidChoice("--status", "bad", "active", "deleted", "all").Error()) + assert.Equal(t, "Browser \"brw_123\" not found; run `kernel browsers list` to find valid IDs", NotFound("Browser", "brw_123", "kernel browsers list").Error()) +} + +func TestCleanedUpSdkErrorPreservesOuterContext(t *testing.T) { + apiErr := newKernelError(t, `{"code":"not_found","message":"missing app"}`) + + assert.Equal(t, "not_found: missing app", CleanedUpSdkError{Err: apiErr}.Error()) + assert.Equal(t, + "list applications failed; check your auth and retry: not_found: missing app", + CleanedUpSdkError{Err: fmt.Errorf("list applications failed; check your auth and retry: %w", apiErr)}.Error(), + ) +} + +func TestCleanedUpSdkErrorExplainsDashboardBaseURL(t *testing.T) { + t.Setenv("KERNEL_BASE_URL", "https://dashboard.onkernel.com") + + err := CleanedUpSdkError{ + Err: fmt.Errorf("list applications failed; check your auth and retry: expected destination type of 'string' or '[]byte' for responses with content-type 'text/html; charset=utf-8' that is not 'application/json'"), + } + + assert.Equal(t, + "list applications failed: server returned HTML instead of Kernel API JSON; KERNEL_BASE_URL resolves to https://dashboard.onkernel.com. Use an API base URL, not the dashboard URL. For production, unset KERNEL_BASE_URL or set it to https://api.onkernel.com.", + err.Error(), + ) +} + +func newKernelError(t *testing.T, raw string) *kernel.Error { + t.Helper() + + var err kernel.Error + require.NoError(t, json.Unmarshal([]byte(raw), &err)) + req, reqErr := http.NewRequest(http.MethodGet, "https://api.example.test/apps", nil) + require.NoError(t, reqErr) + err.Request = req + err.Response = &http.Response{StatusCode: http.StatusNotFound} + return &err +} diff --git a/pkg/util/json.go b/pkg/util/json.go index aa20d3b5..507c90ef 100644 --- a/pkg/util/json.go +++ b/pkg/util/json.go @@ -4,6 +4,8 @@ import ( "bytes" "encoding/json" "fmt" + + "github.com/kernel/kernel-go-sdk/packages/pagination" ) // RawJSONProvider is an interface for SDK types that provide raw JSON responses. @@ -65,3 +67,23 @@ func PrintPrettyJSONSlice[T RawJSONProvider](items []T) error { fmt.Println(buf.String()) return nil } + +// PrintPrettyJSONPointerSlice prints a pointer-to-slice SDK response as a JSON +// array, treating nil as an empty list. +func PrintPrettyJSONPointerSlice[T RawJSONProvider](items *[]T) error { + if items == nil { + fmt.Println("[]") + return nil + } + return PrintPrettyJSONSlice(*items) +} + +// PrintPrettyJSONPageItems prints the item slice from a paginated SDK response, +// treating a nil page as an empty list. +func PrintPrettyJSONPageItems[T RawJSONProvider](page *pagination.OffsetPagination[T]) error { + if page == nil { + fmt.Println("[]") + return nil + } + return PrintPrettyJSONSlice(page.Items) +} diff --git a/pkg/util/json_test.go b/pkg/util/json_test.go new file mode 100644 index 00000000..c1d1ad08 --- /dev/null +++ b/pkg/util/json_test.go @@ -0,0 +1,88 @@ +package util + +import ( + "bytes" + "io" + "os" + "testing" + + "github.com/kernel/kernel-go-sdk/packages/pagination" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type rawJSONStub string + +func (r rawJSONStub) RawJSON() string { + return string(r) +} + +func captureStdout(t *testing.T, fn func() error) (string, error) { + t.Helper() + + original := os.Stdout + reader, writer, err := os.Pipe() + require.NoError(t, err) + + os.Stdout = writer + t.Cleanup(func() { + os.Stdout = original + }) + + var buf bytes.Buffer + done := make(chan error, 1) + go func() { + _, copyErr := io.Copy(&buf, reader) + done <- copyErr + }() + + fnErr := fn() + require.NoError(t, writer.Close()) + require.NoError(t, <-done) + require.NoError(t, reader.Close()) + os.Stdout = original + + return buf.String(), fnErr +} + +func TestPrintPrettyJSONPointerSliceTreatsNilAsEmptyList(t *testing.T) { + out, err := captureStdout(t, func() error { + return PrintPrettyJSONPointerSlice[rawJSONStub](nil) + }) + + require.NoError(t, err) + assert.Equal(t, "[]\n", out) +} + +func TestPrintPrettyJSONPointerSlicePrintsItems(t *testing.T) { + items := []rawJSONStub{`{"id":"one"}`} + + out, err := captureStdout(t, func() error { + return PrintPrettyJSONPointerSlice(&items) + }) + + require.NoError(t, err) + assert.Equal(t, "[\n {\n \"id\": \"one\"\n }\n]\n", out) +} + +func TestPrintPrettyJSONPageItemsTreatsNilAsEmptyList(t *testing.T) { + out, err := captureStdout(t, func() error { + return PrintPrettyJSONPageItems[rawJSONStub](nil) + }) + + require.NoError(t, err) + assert.Equal(t, "[]\n", out) +} + +func TestPrintPrettyJSONPageItemsPrintsItems(t *testing.T) { + page := &pagination.OffsetPagination[rawJSONStub]{ + Items: []rawJSONStub{`{"id":"one"}`}, + } + + out, err := captureStdout(t, func() error { + return PrintPrettyJSONPageItems(page) + }) + + require.NoError(t, err) + assert.Equal(t, "[\n {\n \"id\": \"one\"\n }\n]\n", out) +} diff --git a/pkg/util/output.go b/pkg/util/output.go index 68ecd584..0860753a 100644 --- a/pkg/util/output.go +++ b/pkg/util/output.go @@ -7,6 +7,7 @@ import ( ) const JSONOutputFlagDescription = "Output format: json for raw API response" +const SkipConfirmFlagDescription = "Skip confirmation prompt" func ValidateJSONOutput(output string) error { if output == "" || output == "json" { @@ -18,3 +19,7 @@ func ValidateJSONOutput(output string) error { func AddJSONOutputFlag(cmd *cobra.Command) { cmd.Flags().StringP("output", "o", "", JSONOutputFlagDescription) } + +func AddSkipConfirmFlag(cmd *cobra.Command) { + cmd.Flags().BoolP("yes", "y", false, SkipConfirmFlagDescription) +}