diff --git a/SURFACE.txt b/SURFACE.txt index 45d4400..43c1e99 100644 --- a/SURFACE.txt +++ b/SURFACE.txt @@ -24,6 +24,7 @@ ARG fizzy signup help 00 [command] ARG fizzy skill help 00 [command] ARG fizzy step help 00 [command] ARG fizzy tag help 00 [command] +ARG fizzy token help 00 [command] ARG fizzy upload help 00 [command] ARG fizzy user help 00 [command] ARG fizzy webhook help 00 [command] @@ -194,6 +195,13 @@ CMD fizzy tag CMD fizzy tag help CMD fizzy tag list CMD fizzy tag ls +CMD fizzy token +CMD fizzy token create +CMD fizzy token delete +CMD fizzy token help +CMD fizzy token list +CMD fizzy token ls +CMD fizzy token rm CMD fizzy upload CMD fizzy upload file CMD fizzy upload help @@ -2737,6 +2745,106 @@ FLAG fizzy tag ls --quiet type=bool FLAG fizzy tag ls --styled type=bool FLAG fizzy tag ls --token type=string FLAG fizzy tag ls --verbose type=bool +FLAG fizzy token --agent type=bool +FLAG fizzy token --api-url type=string +FLAG fizzy token --count type=bool +FLAG fizzy token --help type=bool +FLAG fizzy token --ids-only type=bool +FLAG fizzy token --jq type=string +FLAG fizzy token --json type=bool +FLAG fizzy token --limit type=int +FLAG fizzy token --markdown type=bool +FLAG fizzy token --profile type=string +FLAG fizzy token --quiet type=bool +FLAG fizzy token --styled type=bool +FLAG fizzy token --token type=string +FLAG fizzy token --verbose type=bool +FLAG fizzy token create --agent type=bool +FLAG fizzy token create --api-url type=string +FLAG fizzy token create --count type=bool +FLAG fizzy token create --description type=string +FLAG fizzy token create --help type=bool +FLAG fizzy token create --ids-only type=bool +FLAG fizzy token create --jq type=string +FLAG fizzy token create --json type=bool +FLAG fizzy token create --limit type=int +FLAG fizzy token create --markdown type=bool +FLAG fizzy token create --permission type=string +FLAG fizzy token create --profile type=string +FLAG fizzy token create --quiet type=bool +FLAG fizzy token create --styled type=bool +FLAG fizzy token create --token type=string +FLAG fizzy token create --verbose type=bool +FLAG fizzy token delete --agent type=bool +FLAG fizzy token delete --api-url type=string +FLAG fizzy token delete --count type=bool +FLAG fizzy token delete --help type=bool +FLAG fizzy token delete --ids-only type=bool +FLAG fizzy token delete --jq type=string +FLAG fizzy token delete --json type=bool +FLAG fizzy token delete --limit type=int +FLAG fizzy token delete --markdown type=bool +FLAG fizzy token delete --profile type=string +FLAG fizzy token delete --quiet type=bool +FLAG fizzy token delete --styled type=bool +FLAG fizzy token delete --token type=string +FLAG fizzy token delete --verbose type=bool +FLAG fizzy token help --agent type=bool +FLAG fizzy token help --api-url type=string +FLAG fizzy token help --count type=bool +FLAG fizzy token help --help type=bool +FLAG fizzy token help --ids-only type=bool +FLAG fizzy token help --jq type=string +FLAG fizzy token help --json type=bool +FLAG fizzy token help --limit type=int +FLAG fizzy token help --markdown type=bool +FLAG fizzy token help --profile type=string +FLAG fizzy token help --quiet type=bool +FLAG fizzy token help --styled type=bool +FLAG fizzy token help --token type=string +FLAG fizzy token help --verbose type=bool +FLAG fizzy token list --agent type=bool +FLAG fizzy token list --api-url type=string +FLAG fizzy token list --count type=bool +FLAG fizzy token list --help type=bool +FLAG fizzy token list --ids-only type=bool +FLAG fizzy token list --jq type=string +FLAG fizzy token list --json type=bool +FLAG fizzy token list --limit type=int +FLAG fizzy token list --markdown type=bool +FLAG fizzy token list --profile type=string +FLAG fizzy token list --quiet type=bool +FLAG fizzy token list --styled type=bool +FLAG fizzy token list --token type=string +FLAG fizzy token list --verbose type=bool +FLAG fizzy token ls --agent type=bool +FLAG fizzy token ls --api-url type=string +FLAG fizzy token ls --count type=bool +FLAG fizzy token ls --help type=bool +FLAG fizzy token ls --ids-only type=bool +FLAG fizzy token ls --jq type=string +FLAG fizzy token ls --json type=bool +FLAG fizzy token ls --limit type=int +FLAG fizzy token ls --markdown type=bool +FLAG fizzy token ls --profile type=string +FLAG fizzy token ls --quiet type=bool +FLAG fizzy token ls --styled type=bool +FLAG fizzy token ls --token type=string +FLAG fizzy token ls --verbose type=bool +FLAG fizzy token rm --agent type=bool +FLAG fizzy token rm --api-url type=string +FLAG fizzy token rm --count type=bool +FLAG fizzy token rm --help type=bool +FLAG fizzy token rm --ids-only type=bool +FLAG fizzy token rm --jq type=string +FLAG fizzy token rm --json type=bool +FLAG fizzy token rm --limit type=int +FLAG fizzy token rm --markdown type=bool +FLAG fizzy token rm --profile type=string +FLAG fizzy token rm --quiet type=bool +FLAG fizzy token rm --styled type=bool +FLAG fizzy token rm --token type=string +FLAG fizzy token rm --verbose type=bool FLAG fizzy upload --agent type=bool FLAG fizzy upload --api-url type=string FLAG fizzy upload --count type=bool @@ -3385,6 +3493,13 @@ SUB fizzy tag SUB fizzy tag help SUB fizzy tag list SUB fizzy tag ls +SUB fizzy token +SUB fizzy token create +SUB fizzy token delete +SUB fizzy token help +SUB fizzy token list +SUB fizzy token ls +SUB fizzy token rm SUB fizzy upload SUB fizzy upload file SUB fizzy upload help diff --git a/e2e/cli_tests/token_test.go b/e2e/cli_tests/token_test.go new file mode 100644 index 0000000..e346450 --- /dev/null +++ b/e2e/cli_tests/token_test.go @@ -0,0 +1,58 @@ +package clitests + +import ( + "strconv" + "testing" + "time" + + "github.com/basecamp/fizzy-cli/e2e/harness" +) + +func TestAccessTokenCRUD(t *testing.T) { + h := newHarness(t) + description := "CLI Test Token " + strconv.FormatInt(time.Now().UnixNano(), 10) + + create := h.Run("token", "create", "--description", description, "--permission", "read") + assertOK(t, create) + tokenID := create.GetDataString("id") + if tokenID == "" { + t.Fatal("no token ID in create response") + } + deleted := false + t.Cleanup(func() { + if deleted { + return + } + cleanupDelete := newHarness(t).Run("token", "delete", tokenID) + if cleanupDelete.ExitCode != harness.ExitSuccess { + t.Errorf("cleanup failed deleting token %q: exit=%d stdout=%s stderr=%s", tokenID, cleanupDelete.ExitCode, cleanupDelete.Stdout, cleanupDelete.Stderr) + return + } + if !cleanupDelete.GetDataBool("deleted") { + t.Errorf("cleanup delete for token %q did not report deleted=true", tokenID) + } + }) + if create.GetDataString("token") == "" { + t.Fatal("expected raw token value in create response") + } + + list := h.Run("token", "list") + assertOK(t, list) + found := false + for _, item := range list.GetDataArray() { + if mapValueString(asMap(item), "id") == tokenID { + found = true + break + } + } + if !found { + t.Fatalf("expected token list to include %q", tokenID) + } + + deleteResult := h.Run("token", "delete", tokenID) + assertOK(t, deleteResult) + deleted = true + if !deleteResult.GetDataBool("deleted") { + t.Fatal("expected deleted=true") + } +} diff --git a/internal/commands/columns.go b/internal/commands/columns.go index 18ffd67..9c9db9c 100644 --- a/internal/commands/columns.go +++ b/internal/commands/columns.go @@ -91,4 +91,11 @@ var ( {Header: "Created", Field: "created_at"}, {Header: "Updated", Field: "updated_at"}, } + + tokenColumns = render.Columns{ + {Header: "ID", Field: "id"}, + {Header: "Description", Field: "description"}, + {Header: "Permission", Field: "permission"}, + {Header: "Created", Field: "created_at"}, + } ) diff --git a/internal/commands/commands.go b/internal/commands/commands.go index ca164f9..ab79fbb 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -33,7 +33,7 @@ var commandCatalogTitles = map[string]string{ var commandCatalogGroups = map[string][]string{ "core": {"activity", "board", "card", "column", "comment", "search", "step"}, "collaboration": {"notification", "pin", "reaction", "tag", "user"}, - "admin": {"auth", "account", "identity", "webhook", "upload", "migrate"}, + "admin": {"auth", "account", "identity", "token", "webhook", "upload", "migrate"}, "utilities": {"setup", "signup", "completion", "doctor", "config", "skill", "commands", "version"}, } diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index 586589c..e478229 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -70,6 +70,25 @@ func TestCommandsFilterFindsActivity(t *testing.T) { } } +func TestCommandsFilterFindsToken(t *testing.T) { + mock := NewMockClient() + SetTestModeWithSDK(mock) + SetTestFormat(output.FormatStyled) + defer resetTest() + + if err := commandsCmd.RunE(commandsCmd, []string{"token"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + raw := TestOutput() + if !strings.Contains(raw, "token") || !strings.Contains(raw, "create, delete, list") { + t.Fatalf("expected filtered catalog to include token actions, got:\n%s", raw) + } + if strings.Contains(raw, "No commands match") { + t.Fatalf("expected token to be discoverable, got:\n%s", raw) + } +} + func TestCommandsJSONOutputReturnsStructuredCatalog(t *testing.T) { mock := NewMockClient() result := SetTestModeWithSDK(mock) diff --git a/internal/commands/help.go b/internal/commands/help.go index 2d35de0..ef85932 100644 --- a/internal/commands/help.go +++ b/internal/commands/help.go @@ -379,7 +379,7 @@ var rootCommandGroupTitles = map[string]string{ } var rootCommandGroups = map[string][]string{ - "core": {"auth", "activity", "board", "card", "search"}, + "core": {"auth", "token", "activity", "board", "card", "search"}, "collaboration": {"comment", "notification"}, "getting-started": {"setup", "signup"}, "discover": {"doctor", "config", "commands", "version"}, diff --git a/internal/commands/help_test.go b/internal/commands/help_test.go index 33b8a98..7829b79 100644 --- a/internal/commands/help_test.go +++ b/internal/commands/help_test.go @@ -15,7 +15,7 @@ func TestRenderRootHelp(t *testing.T) { renderHelp(rootCmd, &buf) out := buf.String() - for _, want := range []string{"CORE COMMANDS", "activity", "GETTING STARTED", "DISCOVER", "FLAGS", "--profile", "LEARN MORE", "Use `fizzy commands` to see the full command catalog.", "implies --json"} { + for _, want := range []string{"CORE COMMANDS", "activity", "token", "GETTING STARTED", "DISCOVER", "FLAGS", "--profile", "LEARN MORE", "Use `fizzy commands` to see the full command catalog.", "implies --json"} { if !strings.Contains(out, want) { t.Fatalf("expected root help to contain %q, got:\n%s", want, out) } diff --git a/internal/commands/token.go b/internal/commands/token.go new file mode 100644 index 0000000..eef3e2c --- /dev/null +++ b/internal/commands/token.go @@ -0,0 +1,141 @@ +package commands + +import ( + "fmt" + + "github.com/basecamp/fizzy-sdk/go/pkg/generated" + "github.com/spf13/cobra" +) + +var tokenCmd = &cobra.Command{ + Use: "token", + Short: "Manage personal access tokens", + Long: "Commands for managing your personal access tokens.", +} + +var tokenListCmd = &cobra.Command{ + Use: "list", + Short: "List personal access tokens", + Long: "Lists your personal access tokens.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + if err := requireSDK(); err != nil { + return err + } + + ac := getSDKClient() + data, _, err := ac.AccessTokens().List(cmd.Context()) + if err != nil { + return convertSDKError(err) + } + + items := normalizeAny(data) + + count := dataCount(items) + summary := fmt.Sprintf("%d access tokens", count) + + breadcrumbs := []Breadcrumb{ + breadcrumb("create", "fizzy token create --description --permission ", "Create a token"), + breadcrumb("delete", "fizzy token delete ", "Delete a token"), + } + + printList(items, tokenColumns, summary, breadcrumbs) + return nil + }, +} + +var ( + tokenCreateDescription string + tokenCreatePermission string +) + +var tokenCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a personal access token", + Long: "Creates a new personal access token. The token value is shown once at creation and cannot be retrieved later.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + if err := requireSDK(); err != nil { + return err + } + + if tokenCreateDescription == "" { + return newRequiredFlagError("description") + } + if tokenCreatePermission == "" { + return newRequiredFlagError("permission") + } + + ac := getSDKClient() + req := &generated.CreateAccessTokenRequest{ + Description: tokenCreateDescription, + Permission: tokenCreatePermission, + } + raw, _, err := ac.AccessTokens().Create(cmd.Context(), req) + if err != nil { + return convertSDKError(err) + } + + result := normalizeAny(raw) + id := "" + if m, ok := result.(map[string]any); ok { + id = getStringField(m, "id") + } + + breadcrumbs := []Breadcrumb{ + breadcrumb("list", "fizzy token list", "List tokens"), + } + if id != "" { + breadcrumbs = append(breadcrumbs, breadcrumb("delete", fmt.Sprintf("fizzy token delete %s", id), "Delete this token")) + } + + notice := "Save the token now — it will not be shown again." + printMutation(result, notice, breadcrumbs) + return nil + }, +} + +var tokenDeleteCmd = &cobra.Command{ + Use: "delete TOKEN_ID", + Short: "Delete a personal access token", + Long: "Deletes a personal access token by ID.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + if err := requireSDK(); err != nil { + return err + } + + ac := getSDKClient() + if _, err := ac.AccessTokens().Delete(cmd.Context(), args[0]); err != nil { + return convertSDKError(err) + } + + breadcrumbs := []Breadcrumb{ + breadcrumb("list", "fizzy token list", "List remaining tokens"), + } + + printMutation(map[string]any{"deleted": true, "id": args[0]}, "", breadcrumbs) + return nil + }, +} + +func init() { + rootCmd.AddCommand(tokenCmd) + + tokenCmd.AddCommand(tokenListCmd) + + tokenCreateCmd.Flags().StringVar(&tokenCreateDescription, "description", "", "Token description (required)") + tokenCreateCmd.Flags().StringVar(&tokenCreatePermission, "permission", "", "Token permission (required)") + tokenCmd.AddCommand(tokenCreateCmd) + + tokenCmd.AddCommand(tokenDeleteCmd) +} diff --git a/internal/commands/token_test.go b/internal/commands/token_test.go new file mode 100644 index 0000000..d1c762d --- /dev/null +++ b/internal/commands/token_test.go @@ -0,0 +1,21 @@ +package commands + +import "testing" + +func TestTokenListRejectsUnexpectedArgs(t *testing.T) { + if err := tokenListCmd.Args(tokenListCmd, []string{"extra"}); err == nil { + t.Fatal("expected token list to reject unexpected positional args") + } + if err := tokenListCmd.Args(tokenListCmd, []string{}); err != nil { + t.Fatalf("expected token list to allow no positional args, got %v", err) + } +} + +func TestTokenCreateRejectsUnexpectedArgs(t *testing.T) { + if err := tokenCreateCmd.Args(tokenCreateCmd, []string{"extra"}); err == nil { + t.Fatal("expected token create to reject unexpected positional args") + } + if err := tokenCreateCmd.Args(tokenCreateCmd, []string{}); err != nil { + t.Fatalf("expected token create to allow no positional args, got %v", err) + } +}