Files
gitea-tea/internal/app/app.go
T
bircni 014dba8512 fix(doctor): run every check instead of failing at the first problem
tea doctor printed the resolved instance, account and repository and
checked nothing; it failed with exit code 4 exactly when it was needed,
such as in a clone of an instance without an account, and with a
GITEA_TOKEN of another instance it blamed GITEA_INSTANCE_URL.

It now checks the settings files, whether the instance answers (version
and latency), whether the account's credential works and its token
scopes, whether the repository is readable (default branch), Git and
whether it asks tea for credentials, and where SSH remotes connect. Each
check shows a mark on a terminal and the command that fixes a problem;
piped, each is a tab-separated line. A GITEA_TOKEN for another instance
names both instances. doctor exits with 1 when a check fails, and --json
keeps the target fields and adds checks[].

Signed-off-by: bircni <bircni@icloud.com>
2026-10-11 23:50:44 +02:00

622 lines
23 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package app builds the tea command tree and its injected dependencies.
package app
import (
"cmp"
"context"
"errors"
"fmt"
"os"
"reflect"
"regexp"
"slices"
"strings"
"time"
"unicode"
"gitea.dev/tea/internal/aliases"
"gitea.dev/tea/internal/auth"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/git"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/settings"
"gitea.dev/tea/internal/terminal"
"gitea.dev/tea/internal/transport"
"gitea.dev/tea/internal/version"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// Dependencies supplies external boundaries for a fresh CLI invocation.
type Dependencies struct {
Browser terminal.Browser
Editor terminal.Editor
Pager terminal.Pager
Executable func() (string, error)
Git git.Runner
Now func() time.Time
Settings *settings.Store
Credentials *auth.Store
Streams terminal.Streams
Prompter terminal.Prompter
IssueForm terminal.IssueForm
IssueEditForm terminal.IssueEditForm
PullForm terminal.PullForm
ReviewForm terminal.ReviewForm
MergeForm terminal.MergeForm
MilestoneForm terminal.MilestoneForm
Getenv func(string) string
Client func(string, string, bool) *transport.Client
}
// System connects the CLI to the current environment and terminal.
func System() Dependencies {
streams := terminal.System()
store := settings.New(os.Getenv("TEA_CONFIG_DIR"))
prompter := &terminal.FormPrompter{Streams: streams}
return Dependencies{Browser: terminal.SystemBrowser{ErrOut: streams.ErrOut}, Editor: terminal.SystemEditor{Streams: streams}, Pager: terminal.SystemPager{}, Executable: os.Executable, Git: git.System{In: streams.In, ErrOut: streams.ErrOut}, Now: time.Now, Settings: store, Credentials: &auth.Store{Dir: store.Dir, Keyring: auth.SystemKeyring{}}, Streams: streams, Prompter: prompter, IssueForm: prompter, IssueEditForm: prompter, PullForm: prompter, ReviewForm: prompter, MergeForm: prompter, MilestoneForm: prompter, Getenv: os.Getenv, Client: transport.New}
}
type options struct {
host, user, repo, cwd, remote string
sudo string
json, jq string
noInput, interactive, yes bool
debug bool
}
type application struct {
deps Dependencies
options options
}
// NewRoot builds fresh commands. Construction performs no disk, network, or Git IO.
func NewRoot(deps Dependencies) *cobra.Command {
if deps.Executable == nil {
deps.Executable = os.Executable
}
if deps.Now == nil {
deps.Now = time.Now
}
if deps.Git == nil {
deps.Git = git.System{In: deps.Streams.In, ErrOut: deps.Streams.ErrOut}
}
if deps.Browser == nil {
deps.Browser = terminal.SystemBrowser{ErrOut: deps.Streams.ErrOut}
}
if deps.Editor == nil {
deps.Editor = terminal.SystemEditor{Streams: deps.Streams}
}
a := &application{deps: deps}
if client := deps.Client; client != nil {
a.deps.Client = func(instance, token string, insecure bool) *transport.Client {
if a.options.debug {
return client(instance, token, insecure).Debug(deps.Streams.ErrOut)
}
return client(instance, token, insecure)
}
}
root := &cobra.Command{Use: "tea", Short: "Work with Gitea from your terminal", Long: "Work seamlessly with Gitea from the command line.", Version: version.Format(), SilenceErrors: true, SilenceUsage: true}
root.SetIn(deps.Streams.In)
root.SetOut(deps.Streams.Out)
root.SetErr(deps.Streams.ErrOut)
root.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { return clierr.Wrap(clierr.Usage, suggestFlags(cmd, err)) })
flags := root.PersistentFlags()
flags.StringVar(&a.options.host, "host", "", "Gitea instance `URL`, including any base path")
flags.StringVar(&a.options.user, "user", "", "Account `name` on the selected instance")
flags.StringVar(&a.options.sudo, "sudo", "", "Perform API requests as this `user` (requires an administrator account)")
flags.StringVarP(&a.options.repo, "repo", "R", "", "Select a repository as `OWNER/REPO` or URL")
flags.StringVar(&a.options.cwd, "cwd", "", "Working `directory` for repository discovery")
flags.StringVar(&a.options.remote, "remote", "", "Git `remote` used for repository discovery")
flags.StringVar(&a.options.json, "json", "", "Output JSON with the specified comma-separated `fields`, or all fields")
flags.Lookup("json").NoOptDefVal = "*"
flags.StringVarP(&a.options.jq, "jq", "q", "", "Filter JSON output using a jq `expression`; implies --json")
flags.BoolVar(&a.options.noInput, "no-input", false, "Disable prompts and implicit editor/browser interactions")
flags.BoolVar(&a.options.interactive, "interactive", false, "Allow prompts with machine output")
flags.BoolVarP(&a.options.yes, "yes", "y", false, "Accept confirmations without prompting")
flags.BoolVar(&a.options.debug, "debug", false, "Log the method, URL, status, and duration of each HTTP request to stderr")
_ = root.RegisterFlagCompletionFunc("host", a.completeHosts)
_ = root.RegisterFlagCompletionFunc("user", a.completeAccounts)
_ = root.RegisterFlagCompletionFunc("repo", a.completeRepos())
_ = root.RegisterFlagCompletionFunc("remote", a.completeRemotes)
_ = root.RegisterFlagCompletionFunc("cwd", completeDirectories)
_ = root.RegisterFlagCompletionFunc("json", completeJSONFields)
root.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error {
if err := a.checkOutputFlags(cmd); err != nil {
return err
}
if err := validateEnums(cmd); err != nil {
return err
}
if cmd.Flags().Changed("sudo") {
if a.options.sudo == "" || strings.IndexFunc(a.options.sudo, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) >= 0 {
return clierr.Wrap(clierr.Usage, errors.New("--sudo requires a username"))
}
for c := cmd; c.HasParent(); c = c.Parent() {
if c.Name() == "auth" && !c.Parent().HasParent() {
return clierr.Wrap(clierr.Usage, errors.New("--sudo cannot be used with auth commands"))
}
}
}
if offline(cmd) {
return nil
}
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
return a.policy(snapshot).Validate(a.deps.Streams)
}
root.AddCommand(a.configCommand(), a.aliasCommand(), a.authCommand(), a.apiCommand(), a.repoCommand(), a.issueCommand(), a.prCommand(), a.doctorCommand(), a.workflowCommand(), a.runCommand(), a.labelCommand(), a.milestoneCommand(), a.sshKeyCommand(), a.timeCommand(), a.wikiCommand(), a.actionValueCommand(true), a.actionValueCommand(false), a.releaseCommand(), a.organizationCommand(), a.notificationCommand(), a.teamCommand(), a.branchCommand(), a.webhookCommand(), a.adminCommand(), a.searchCommand(), a.statusCommand(false))
root.AddCommand(a.manCommand())
root.AddCommand(&cobra.Command{Use: "version", Short: "Print tea's version", Example: "$ tea version", Annotations: textOutput("version prints text"), RunE: func(cmd *cobra.Command, _ []string) error {
_, err := fmt.Fprintln(cmd.OutOrStdout(), version.Format())
return err
}})
root.AddCommand(helpTopics(flags)...)
root.SetHelpCommand(a.helpCommand())
root.InitDefaultHelpCmd()
root.InitDefaultCompletionCmd()
completionInstallHelp(root)
root.SetVersionTemplate("{{.Version}}\n")
root.SetHelpFunc(a.writeHelp)
root.CompletionOptions.SetDefaultShellCompDirective(cobra.ShellCompDirectiveNoFileComp)
root.AddGroup(
&cobra.Group{ID: "core", Title: "Core commands"},
&cobra.Group{ID: "actions", Title: "Actions commands"},
&cobra.Group{ID: "additional", Title: "Additional commands"},
&cobra.Group{ID: "alias", Title: "Alias commands"},
)
for _, cmd := range root.Commands() {
if cmd.Annotations[topicAnnotation] == "" {
cmd.GroupID = cmp.Or(rootGroups[cmd.Name()], "additional")
}
}
rejectUnknownSubcommands(root)
addVerbAliases(root)
completeDefaults(root)
return root
}
// rootGroups places the core and Actions commands in their root help groups (D26); every other
// command is additional.
var rootGroups = map[string]string{
"auth": "core", "issue": "core", "org": "core", "pr": "core", "release": "core", "repo": "core",
"run": "actions", "secret": "actions", "variable": "actions", "workflow": "actions",
}
// verbAliases are the gh-style short names that every command with a common verb accepts (D6).
// Commands add other gh verbs, such as new, co, add and get, to their own Aliases.
var verbAliases = map[string]string{"list": "ls", "delete": "rm"}
// addVerbAliases gives every list and delete command its short name.
func addVerbAliases(cmd *cobra.Command) {
if alias, ok := verbAliases[cmd.Name()]; ok && cmd.HasParent() && !slices.Contains(cmd.Aliases, alias) {
cmd.Aliases = append(cmd.Aliases, alias)
}
for _, child := range cmd.Commands() {
addVerbAliases(child)
}
}
// flagAliases makes the flags of cmd accept each key of aliases as another name of the flag it maps
// to, without showing it in help. Renamed flags keep their old names working this way, such as the
// plural --add-labels after the singular gh name --add-label became primary (D8).
func flagAliases(cmd *cobra.Command, aliases map[string]string) {
normalize := cmd.Flags().GetNormalizeFunc()
cmd.Flags().SetNormalizeFunc(func(flags *pflag.FlagSet, name string) pflag.NormalizedName {
if to, ok := aliases[name]; ok {
name = to
}
return normalize(flags, name)
})
}
// suggestFlags adds the flags of cmd with a similar name to an unknown flag error, as in
// "unknown flag: --lable; did you mean --label?".
func suggestFlags(cmd *cobra.Command, err error) error {
name, unknown := strings.CutPrefix(err.Error(), "unknown flag: --")
if !unknown || len(name) < 3 {
return err
}
var suggestions []string
cmd.Flags().VisitAll(func(flag *pflag.Flag) {
if !flag.Hidden && (strings.HasPrefix(flag.Name, name) || editDistance(name, flag.Name) <= 2) {
suggestions = append(suggestions, "--"+flag.Name)
}
})
if len(suggestions) == 0 {
return err
}
return fmt.Errorf("%w; did you mean %s?", err, strings.Join(suggestions, " or "))
}
// editDistance counts the single-character insertions, deletions and substitutions that turn a into b.
func editDistance(a, b string) int {
row := make([]int, len(b)+1)
for j := range row {
row[j] = j
}
for i := 1; i <= len(a); i++ {
diagonal := row[0]
row[0] = i
for j := 1; j <= len(b); j++ {
substitution := diagonal
if a[i-1] != b[j-1] {
substitution++
}
diagonal = row[j]
row[j] = min(row[j]+1, row[j-1]+1, substitution)
}
}
return row[len(b)]
}
// checkOutputFlags validates --json and --jq before a command runs. --jq alone selects every field,
// except on commands that filter their raw JSON themselves (annotation tea.rawjson).
func (a *application) checkOutputFlags(cmd *cobra.Command) error {
selected := ""
switch {
case cmd.Flags().Changed("json"):
if a.options.json == "" {
return clierr.Wrap(clierr.Usage, errors.New("--json= needs field names; use --json alone for every field"))
}
selected = "--json"
case a.options.jq != "" && cmd.Annotations["tea.rawjson"] == "":
selected, a.options.json = "--jq", "*"
}
if reason := cmd.Annotations["tea.text"]; reason != "" && selected != "" {
return clierr.Wrap(clierr.Usage, fmt.Errorf("%s; %s is not supported", reason, selected))
}
if flag := cmd.Flags().Lookup("web"); flag != nil && flag.Value.String() == "true" && (a.options.json != "" || a.options.jq != "") {
return clierr.Wrap(clierr.Usage, errors.New("--web cannot be combined with --json or --jq"))
}
return clierr.Wrap(clierr.Usage, terminal.ValidateOutput(a.options.json, a.options.jq, commandOutputFields(cmd)))
}
// offline reports commands that must work without valid settings.
func offline(cmd *cobra.Command) bool {
if cmd.Annotations["tea.group"] == "true" || cmd.Annotations["tea.offline"] == "true" {
return true
}
switch cmd.Name() {
case "help", "version", "man", cobra.ShellCompRequestCmd, cobra.ShellCompNoDescRequestCmd:
return true
}
return cmd.HasParent() && cmd.Parent().Name() == "completion"
}
// rejectUnknownSubcommands makes `tea bogus` and `tea issue bogus` usage errors instead of printing help.
func rejectUnknownSubcommands(cmd *cobra.Command) {
if cmd.HasSubCommands() && !cmd.Runnable() {
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations["tea.group"] = "true"
cmd.Args, cmd.SuggestionsMinimumDistance = cobra.ArbitraryArgs, 2
cmd.RunE = func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return cmd.Help()
}
err := fmt.Errorf("unknown command %q for %q", args[0], cmd.CommandPath())
if suggestions := cmd.SuggestionsFor(args[0]); len(suggestions) > 0 {
err = fmt.Errorf("%w; did you mean %s?", err, strings.Join(suggestions, " or "))
}
return clierr.Wrap(clierr.Usage, err)
}
}
for _, child := range cmd.Commands() {
rejectUnknownSubcommands(child)
}
}
func builtins(root *cobra.Command) map[string]bool {
names := map[string]bool{"help": true, "completion": true}
for _, cmd := range root.Commands() {
if cmd.Annotations["tea.alias"] != "true" {
names[cmd.Name()] = true
}
}
return names
}
// Execute expands aliases and executes the fresh root without printing errors.
func Execute(ctx context.Context, deps Dependencies, args []string) error {
root := NewRoot(deps)
if at := commandIndex(root, args); at == len(args) || (args[at] != cobra.ShellCompRequestCmd && args[at] != cobra.ShellCompNoDescRequestCmd) {
args = joinJSONFields(root, args)
}
// Broken settings or aliases must not prevent help or recovery commands.
snapshot, loadErr := deps.Settings.Load()
names := builtins(root)
if commandAt := commandIndex(root, args); commandAt < len(args) {
name := args[commandAt]
completion := name == cobra.ShellCompRequestCmd || name == cobra.ShellCompNoDescRequestCmd
switch {
case loadErr != nil:
// An alias cannot be expanded without settings; report why instead of "unknown command".
if !names[name] && !completion {
return loadErr
}
case completion:
completionArgs := args[commandAt+1:]
if aliasAt := commandIndex(root, completionArgs); aliasAt < len(completionArgs) {
if expanded, err := aliases.ExpandCompletion(completionArgs[aliasAt:], snapshot.Preferences.Aliases, names); err == nil {
args = slices.Concat(args[:commandAt+1], completionArgs[:aliasAt], expanded)
}
}
default:
expanded, err := aliases.Expand(args[commandAt:], snapshot.Preferences.Aliases, names)
if err != nil {
return clierr.Wrap(clierr.Usage, err)
}
args = joinJSONFields(root, slices.Concat(args[:commandAt], expanded))
}
if completion {
args = completeJSONValue(args)
}
}
if loadErr == nil {
for name, expansion := range snapshot.Preferences.Aliases {
if aliases.Check(name, snapshot.Preferences.Aliases, names) == nil {
root.AddCommand(&cobra.Command{Use: name, Short: fmt.Sprintf("Alias for %q", expansion), GroupID: "alias", Annotations: map[string]string{"tea.alias": "true"}, RunE: func(*cobra.Command, []string) error {
return clierr.Wrap(clierr.Usage, errors.New("alias must be expanded before execution"))
}})
}
}
}
root.SetArgs(args)
err := root.ExecuteContext(ctx)
if apiErr, ok := errors.AsType[*transport.APIError](err); ok && apiErr.Status == 401 {
return clierr.Wrap(clierr.Authentication, err)
}
return err
}
var jsonFieldList = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*(,[A-Za-z][A-Za-z0-9_]*)*$`)
// joinJSONFields accepts `--json number,title` like `--json=number,title`. A field-shaped word after a
// bare --json stays a positional argument only when the selected command does not output those fields
// and accepts the word as an argument, so a misspelled field reports the available fields rather than
// an argument count. A bare --json still selects every field. Unknown commands, such as aliases before
// expansion, are joined.
func joinJSONFields(root *cobra.Command, args []string) []string {
for i := 0; i < len(args); i++ {
if args[i] == "--" {
break
}
if args[i] != "--json" || i+1 >= len(args) || !jsonFieldList.MatchString(args[i+1]) {
continue
}
cmd, _, err := root.Find(slices.Concat(args[:i+1], args[i+2:]))
if err == nil && terminal.ValidateOutput(args[i+1], "", commandOutputFields(cmd)) != nil && acceptsArgs(args) {
continue
}
args = slices.Concat(args[:i], []string{"--json=" + args[i+1]}, args[i+2:])
}
return args
}
// acceptsArgs reports whether the command that args select accepts their positional arguments. It
// parses a separate command tree, because parsing sets the flags of the one that runs.
func acceptsArgs(args []string) bool {
cmd, rest, err := NewRoot(Dependencies{}).Find(args)
return err == nil && cmd.ParseFlags(rest) == nil && cmd.ValidateArgs(cmd.Flags().Args()) == nil
}
// confirm asks for confirmation under the prompt policy unless --yes was supplied.
func (a *application) confirm(cmd *cobra.Command, description string) error {
s, err := a.deps.Settings.Load()
if err != nil {
return err
}
return terminal.Confirm(cmd.Context(), description, a.options.yes, a.deps.Streams, a.policy(s), a.deps.Prompter)
}
// confirmTyped asks the user to type answer before an irreversible action unless --yes was supplied.
func (a *application) confirmTyped(cmd *cobra.Command, description, answer string) error {
if a.options.yes {
return nil
}
s, err := a.deps.Settings.Load()
if err != nil {
return err
}
if !a.policy(s).CanPrompt(a.deps.Streams) {
return clierr.Wrap(clierr.Usage, errors.New("confirmation required: supply --yes"))
}
typed, err := a.deps.Prompter.Input(cmd.Context(), description+" Type "+answer+" to confirm:")
if err != nil {
return err
}
if !strings.EqualFold(strings.TrimSpace(typed), answer) {
return clierr.Wrap(clierr.Canceled, errors.New("operation canceled"))
}
return nil
}
// require prompts for a missing required value under the prompt policy.
func (a *application) require(cmd *cobra.Command, value *string, flag string) (bool, error) {
s, err := a.deps.Settings.Load()
if err != nil {
return false, err
}
return terminal.Require(cmd.Context(), value, flag, a.deps.Streams, a.policy(s), a.deps.Prompter)
}
func (a *application) policy(snapshot settings.Snapshot) terminal.Policy {
return terminal.Policy{Enabled: snapshot.Preferences.Prompt == "enabled", NoInput: a.options.noInput, Machine: a.options.json != "", Interactive: a.options.interactive}
}
// titleCase capitalizes an ASCII command word for help and status text.
func titleCase(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
// textOutput marks a command whose output is not a JSON resource, so --json is rejected up front.
func textOutput(reason string) map[string]string {
return map[string]string{"tea.text": reason}
}
// commandIndex skips persistent flags without parsing or mutating command state.
func commandIndex(root *cobra.Command, args []string) int {
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--" {
return i + 1
}
if !strings.HasPrefix(arg, "-") || arg == "-" {
return i
}
name := strings.TrimLeft(arg, "-")
if strings.Contains(name, "=") {
continue
}
flag := root.PersistentFlags().Lookup(name)
if flag == nil && len(name) > 0 && !strings.HasPrefix(arg, "--") {
flag = root.PersistentFlags().ShorthandLookup(name[:1])
if len(name) > 1 {
continue
}
}
if flag != nil && flag.NoOptDefVal == "" {
i++
}
}
return len(args)
}
func commandOutputFields(cmd *cobra.Command) []string {
if cmd.Name() == "status" && cmd.Parent() != nil {
if cmd.Parent().Name() == "pr" {
return jsonFields[services.PullStatus]()
}
if cmd.Parent().Parent() == nil {
return jsonFields[services.AccountStatus]()
}
}
if cmd.Parent() == nil {
return nil
}
if cmd.Parent().Name() == "search" {
if cmd.Name() == "repo" {
return jsonFields[services.Repository]()
}
return jsonFields[services.SearchItem]()
}
switch cmd.Parent().Name() {
case "auth":
if cmd.Name() == "status" {
return jsonFields[authStatusRow]()
}
case "config":
if cmd.Name() == "get" || cmd.Name() == "list" {
return configKeys
}
case "tea":
if cmd.Name() == "doctor" {
return jsonFields[doctorReport]()
}
case "attachment":
return jsonFields[services.IssueAttachment]()
case "artifact":
return jsonFields[services.Artifact]()
case "user":
if cmd.Parent().Parent() != nil && cmd.Parent().Parent().Name() == "admin" {
return jsonFields[services.AdminUser]()
}
case "webhook":
return jsonFields[services.Webhook]()
case "branch":
return jsonFields[services.Branch]()
case "team":
return jsonFields[services.Team]()
case "notification":
return jsonFields[services.Notification]()
case "org":
return jsonFields[services.Organization]()
case "member":
return jsonFields[services.OrganizationMember]()
case "asset":
return jsonFields[services.ReleaseAsset]()
case "release":
return jsonFields[services.Release]()
case "secret":
return jsonFields[services.ActionSecret]()
case "variable":
return jsonFields[services.ActionVariable]()
case "wiki":
if cmd.Name() == "revisions" {
return jsonFields[services.WikiRevision]()
}
return jsonFields[services.WikiPage]()
case "time":
return jsonFields[services.TrackedTime]()
case "ssh-key":
return jsonFields[services.SSHKey]()
case "comment":
return jsonFields[services.Comment]()
case "issue":
return jsonFields[services.Issue]()
case "review":
if cmd.Name() == "request" || cmd.Name() == "cancel-request" {
return jsonFields[services.Pull]()
}
if cmd.Name() == "comments" || cmd.Name() == "reply" {
return jsonFields[services.ReviewComment]()
}
return jsonFields[services.Review]()
case "pr":
switch cmd.Name() {
case "checkout":
return jsonFields[git.CheckoutResult]()
case "checks":
return jsonFields[services.Check]()
case "review":
return jsonFields[services.Review]()
case "clean":
return jsonFields[git.CleanResult]()
}
return jsonFields[services.Pull]()
case "repo":
if cmd.Name() == "sync" {
return jsonFields[git.SyncResult]()
}
return jsonFields[services.Repository]()
case "label":
return jsonFields[services.Label]()
case "milestone":
return jsonFields[services.Milestone]()
case "workflow":
if cmd.Name() == "run" {
return jsonFields[services.DispatchResult]()
}
return jsonFields[services.Workflow]()
case "run":
if cmd.Name() == "jobs" {
return jsonFields[services.Job]()
}
return jsonFields[services.Run]()
}
return nil
}
// jsonFields lists the JSON names of T's fields, which --json can select, in alphabetical order.
func jsonFields[T any]() []string {
t := reflect.TypeFor[T]()
fields := make([]string, 0, t.NumField())
for field := range t.Fields() {
if name, _, _ := strings.Cut(field.Tag.Get("json"), ","); name != "" && name != "-" {
fields = append(fields, name)
}
}
slices.Sort(fields)
return fields
}