| @@ -0,0 +1,230 @@ |
| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "io" |
| 6 | "slices" |
| 7 | "strings" |
| 8 | |
| 9 | "gitbay.org/gitbay/internal/protocol" |
| 10 | "gitbay.org/gitbay/internal/termtext" |
| 11 | ) |
| 12 | |
| 13 | func init() { |
| 14 | register(Command{ |
| 15 | Path: []string{"help"}, |
| 16 | Summary: "list available commands", |
| 17 | Usage: "help [<prefix>...]", |
| 18 | Examples: []string{"help", "help repo"}, |
| 19 | ReadOnly: true, |
| 20 | Run: runHelp, |
| 21 | }) |
| 22 | } |
| 23 | |
| 24 | // nounSummaries is one line per distinct first path element in the |
| 25 | // registry, reusing the CLI's group short texts (cmd/gitbay/main.go) |
| 26 | // where the noun matches, so the two agree. |
| 27 | var nounSummaries = map[string]string{ |
| 28 | "account": "export or import your account, for instance migration", |
| 29 | "admin": "instance administration (admins)", |
| 30 | "audit": "instance audit log (admins)", |
| 31 | "build": "CI builds", |
| 32 | "dashboard": "pinned repos, open MRs, assigned issues, recent builds", |
| 33 | "email": "manage email addresses", |
| 34 | "explore": "public repositories on this instance", |
| 35 | "feed": "activity on repositories you can reach", |
| 36 | "help": "list available commands", |
| 37 | "issue": "issues", |
| 38 | "keys": "manage SSH keys", |
| 39 | "label": "issue labels", |
| 40 | "milestone": "group issues and MRs toward a release", |
| 41 | "mr": "merge requests", |
| 42 | "notifications": "your notification inbox", |
| 43 | "org": "organizations", |
| 44 | "pgp": "manage OpenPGP keys", |
| 45 | "profile": "user and org profiles", |
| 46 | "register": "create an account on this instance", |
| 47 | "release": "tag-anchored releases with notes and assets", |
| 48 | "repo": "create and manage repositories", |
| 49 | "runner": "the claim/report loop CI runners use", |
| 50 | "search": "find repositories, issues and merge requests", |
| 51 | "snippet": "shared text files, outside any repository", |
| 52 | "status": "commit statuses (CI)", |
| 53 | "token": "API tokens (minted over SSH, used with the JSON API)", |
| 54 | "web": "browser session", |
| 55 | "webhook": "outbound event delivery", |
| 56 | "whoami": "show the authenticated account", |
| 57 | "wiki": "a repository's wiki pages", |
| 58 | } |
| 59 | |
| 60 | // NounSummaries returns nounSummaries, for the CLI group-text agreement |
| 61 | // test (task 4.5). |
| 62 | func NounSummaries() map[string]string { return nounSummaries } |
| 63 | |
| 64 | // helpEntry is one row of the registry as help reports it. |
| 65 | type helpEntry struct { |
| 66 | Path string `json:"path"` |
| 67 | Summary string `json:"summary"` |
| 68 | Usage string `json:"usage"` |
| 69 | Flags []Flag `json:"flags,omitempty"` |
| 70 | Examples []string `json:"examples,omitempty"` |
| 71 | } |
| 72 | |
| 73 | // runHelp lists the registry, sorted, so a noun's commands sit together. |
| 74 | // Bare `help` stays one line per command. A prefix that names one command |
| 75 | // exactly renders its full USAGE/FLAGS/EXAMPLES; a prefix that names a |
| 76 | // noun with several commands under it renders a READ/WRITE summary. |
| 77 | func runHelp(c *Ctx, args []string) int { |
| 78 | prefix := joinPath(args) |
| 79 | var matched []Command |
| 80 | for _, cmd := range registry { |
| 81 | p := joinPath(cmd.Path) |
| 82 | if prefix == "" || p == prefix || strings.HasPrefix(p, prefix+" ") { |
| 83 | matched = append(matched, cmd) |
| 84 | } |
| 85 | } |
| 86 | if len(matched) == 0 { |
| 87 | return c.fail(protocol.ExitNotFound, "no command matches %q; try: help", prefix) |
| 88 | } |
| 89 | slices.SortFunc(matched, func(a, b Command) int { return strings.Compare(joinPath(a.Path), joinPath(b.Path)) }) |
| 90 | entries := make([]helpEntry, len(matched)) |
| 91 | for i, cmd := range matched { |
| 92 | entries[i] = helpEntry{Path: joinPath(cmd.Path), Summary: cmd.Summary, Usage: cmd.Usage, Flags: cmd.Flags, Examples: cmd.Examples} |
| 93 | } |
| 94 | return c.emit(entries, func(w io.Writer) { |
| 95 | switch { |
| 96 | case prefix == "": |
| 97 | for _, e := range entries { |
| 98 | summary := e.Summary |
| 99 | if c.Term.Cols > 0 { |
| 100 | if avail := c.Term.Cols - max(cells(e.Path), 24) - 1; avail > 0 { |
| 101 | summary = clip(summary, avail) |
| 102 | } |
| 103 | } |
| 104 | fmt.Fprintf(w, "%-24s %s\n", e.Path, summary) |
| 105 | } |
| 106 | case joinPath(matched[0].Path) == prefix: |
| 107 | c.helpVerb(w, matched[0], matched[1:]) |
| 108 | default: |
| 109 | c.helpNoun(w, prefix, matched) |
| 110 | } |
| 111 | }) |
| 112 | } |
| 113 | |
| 114 | // program is how help spells the command it documents: the CLI at a |
| 115 | // terminal (only the CLI sends GITBAY_TERM), ssh otherwise. |
| 116 | func (c *Ctx) program() string { |
| 117 | if c.Term.Cols > 0 { |
| 118 | return "gitbay" |
| 119 | } |
| 120 | return "ssh git@" + hostOf(c.Cfg.Server.SiteURL) |
| 121 | } |
| 122 | |
| 123 | func (c *Ctx) heading(w io.Writer, s string) { |
| 124 | fmt.Fprintln(w, c.Term.paint(sgrBold, s)) |
| 125 | } |
| 126 | |
| 127 | // wrapLine prints prefix+text, wrapping text at Term.Cols with a hanging |
| 128 | // indent under prefix when it would overflow. Plain output never wraps. |
| 129 | func (c *Ctx) wrapLine(w io.Writer, prefix, text string) { |
| 130 | if c.Term.Cols <= 0 || cells(prefix+text) <= c.Term.Cols { |
| 131 | fmt.Fprintln(w, prefix+text) |
| 132 | return |
| 133 | } |
| 134 | indent := cells(prefix) |
| 135 | width := c.Term.Cols - indent |
| 136 | for i, line := range termtext.Wrap(text, width) { |
| 137 | if i == 0 { |
| 138 | fmt.Fprintln(w, prefix+line) |
| 139 | } else { |
| 140 | fmt.Fprintln(w, strings.Repeat(" ", indent)+line) |
| 141 | } |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | func (c *Ctx) helpVerb(w io.Writer, cmd Command, below []Command) { |
| 146 | fmt.Fprintln(w, cmd.Summary) |
| 147 | fmt.Fprintln(w) |
| 148 | c.heading(w, "USAGE") |
| 149 | shape := cmd.Usage |
| 150 | if i := strings.Index(shape, " [--"); i >= 0 { |
| 151 | shape = shape[:i] |
| 152 | } else if i := strings.Index(shape, " --"); i >= 0 { |
| 153 | shape = shape[:i] |
| 154 | } |
| 155 | if c.Term.Cols > 0 { |
| 156 | shape = strings.Replace(shape, "<owner/name>", "[<owner/name>]", 1) |
| 157 | } |
| 158 | if len(cmd.Flags) > 0 { |
| 159 | shape += " [flags]" |
| 160 | } |
| 161 | fmt.Fprintf(w, " %s %s\n", c.program(), shape) |
| 162 | fmt.Fprintln(w) |
| 163 | c.heading(w, "FLAGS") |
| 164 | rows := make([][2]string, 0, len(cmd.Flags)+1) |
| 165 | for _, f := range cmd.Flags { |
| 166 | name := f.Name |
| 167 | if f.Arg != "" { |
| 168 | name += " " + f.Arg |
| 169 | } |
| 170 | desc := f.Desc |
| 171 | if f.Default != "" { |
| 172 | desc += " (default " + f.Default + ")" |
| 173 | } |
| 174 | rows = append(rows, [2]string{name, desc}) |
| 175 | } |
| 176 | rows = append(rows, [2]string{"--json", "machine-readable output"}) |
| 177 | wide := 0 |
| 178 | for _, r := range rows { |
| 179 | wide = max(wide, cells(r[0])) |
| 180 | } |
| 181 | for _, r := range rows { |
| 182 | c.wrapLine(w, " "+pad(r[0], wide)+" ", r[1]) |
| 183 | } |
| 184 | if len(cmd.Examples) > 0 { |
| 185 | fmt.Fprintln(w) |
| 186 | c.heading(w, "EXAMPLES") |
| 187 | for _, ex := range cmd.Examples { |
| 188 | c.wrapLine(w, " "+c.program()+" ", ex) |
| 189 | } |
| 190 | } |
| 191 | if len(below) > 0 { |
| 192 | fmt.Fprintln(w) |
| 193 | c.heading(w, "SEE ALSO") |
| 194 | for _, b := range below { |
| 195 | fmt.Fprintf(w, " %s %s\n", c.program(), joinPath(b.Path)) |
| 196 | } |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | func (c *Ctx) helpNoun(w io.Writer, prefix string, cmds []Command) { |
| 201 | head := nounSummaries[strings.Fields(prefix)[0]] |
| 202 | fmt.Fprintln(w, head) |
| 203 | fmt.Fprintln(w) |
| 204 | c.heading(w, "USAGE") |
| 205 | fmt.Fprintf(w, " %s %s <verb> ...\n", c.program(), prefix) |
| 206 | wide := 0 |
| 207 | for _, cmd := range cmds { |
| 208 | wide = max(wide, cells(strings.TrimPrefix(joinPath(cmd.Path), prefix+" "))) |
| 209 | } |
| 210 | for _, section := range []struct { |
| 211 | title string |
| 212 | read bool |
| 213 | }{{"READ", true}, {"WRITE", false}} { |
| 214 | first := true |
| 215 | for _, cmd := range cmds { |
| 216 | if cmd.ReadOnly != section.read { |
| 217 | continue |
| 218 | } |
| 219 | if first { |
| 220 | fmt.Fprintln(w) |
| 221 | c.heading(w, section.title) |
| 222 | first = false |
| 223 | } |
| 224 | verb := strings.TrimPrefix(joinPath(cmd.Path), prefix+" ") |
| 225 | fmt.Fprintf(w, " %s %s\n", pad(verb, wide), cmd.Summary) |
| 226 | } |
| 227 | } |
| 228 | fmt.Fprintln(w) |
| 229 | fmt.Fprintf(w, "%s %s <verb> --help for flags.\n", c.program(), prefix) |
| 230 | } |