internal/control/help.go
376 lines · 12981 bytes
1package control
2
3import (
4 "fmt"
5 "io"
6 "slices"
7 "strings"
8
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/termtext"
11)
12
13func 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.
27var 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 "auth": "whoami, SSH and PGP keys, email, API tokens",
32 "build": "CI builds",
33 "dashboard": "pinned repos, open MRs, assigned issues, recent builds",
34 "email": "manage email addresses",
35 "explore": "public repositories on this instance",
36 "feed": "activity on repositories you can reach",
37 "help": "list available commands",
38 "issue": "issues",
39 "keys": "manage SSH keys",
40 "label": "issue labels",
41 "milestone": "group issues and MRs toward a release",
42 "mr": "merge requests",
43 "notifications": "your notification inbox",
44 "org": "organizations",
45 "pgp": "manage OpenPGP keys",
46 "profile": "user and org profiles",
47 "register": "create an account on this instance",
48 "release": "tag-anchored releases with notes and assets",
49 "repo": "create and manage repositories",
50 "runner": "the claim/report loop CI runners use",
51 "search": "find repositories, issues and merge requests",
52 "query": "saved issue and merge request queries across repositories",
53 "snippet": "shared text files, outside any repository",
54 "status": "commit statuses (CI)",
55 "token": "API tokens (minted over SSH, used with the JSON API)",
56 "web": "browser session",
57 "webhook": "outbound event delivery",
58 "whoami": "show the authenticated account",
59 "wiki": "a repository's wiki pages",
60}
61
62// NounSummaries returns nounSummaries, for the CLI group-text agreement
63// test (task 4.5).
64func NounSummaries() map[string]string { return nounSummaries }
65
66// nounAlias is one bucket of registered commands, reachable under a
67// CLI-only noun that is not itself a registry path (auth, gathering
68// several unrelated registry prefixes): Registered is what runHelp
69// matches against the registry, CLI is the path a gitbay caller
70// actually types to reach it — not always Registered with the alias's
71// own name stitched on (account export -> auth export drops a word),
72// so the two are paired explicitly rather than derived.
73type nounAlias struct {
74 Registered string
75 CLI string
76}
77
78// nounAliases groups a CLI-only noun into the real prefixes it gathers,
79// so `help auth` renders with the same layout a real noun gets instead
80// of falling back to whatever a caller does when help fails. A stock
81// ssh caller — the only one who could ever ask for a bare "auth" and
82// get nothing back from the registry — sees the Registered forms
83// unchanged; the CLI, having sent its own path, sees CLI.
84var nounAliases = map[string][]nounAlias{
85 "auth": {
86 {"account export", "auth export"},
87 {"whoami", "auth whoami"},
88 {"keys", "auth keys"},
89 {"email", "auth email"},
90 {"pgp", "auth pgp"},
91 {"token", "auth token"},
92 },
93}
94
95// NounAliasNames returns the keys of nounAliases, for the CLI's own
96// aliasGroupNames agreement test.
97func NounAliasNames() []string {
98 names := make([]string, 0, len(nounAliases))
99 for name := range nounAliases {
100 names = append(names, name)
101 }
102 return names
103}
104
105// helpEntry is one row of the registry as help reports it.
106type helpEntry struct {
107 Path string `json:"path"`
108 Summary string `json:"summary"`
109 Usage string `json:"usage"`
110 Flags []Flag `json:"flags,omitempty"`
111 Examples []string `json:"examples,omitempty"`
112}
113
114// runHelp lists the registry, sorted, so a noun's commands sit together.
115// Bare `help` stays one line per command. A prefix that names one command
116// exactly renders its full USAGE/FLAGS/EXAMPLES; a prefix that names a
117// noun with several commands under it renders a READ/WRITE summary.
118func runHelp(c *Ctx, args []string) int {
119 prefix := joinPath(args)
120 prefixes := []string{prefix}
121 override := map[string]string{}
122 if aliased, ok := nounAliases[prefix]; ok {
123 prefixes = nil
124 for _, a := range aliased {
125 prefixes = append(prefixes, a.Registered)
126 }
127 if c.CLIPath != "" {
128 for _, cmd := range registry {
129 p := joinPath(cmd.Path)
130 for _, a := range aliased {
131 if p == a.Registered || strings.HasPrefix(p, a.Registered+" ") {
132 override[p] = a.CLI + strings.TrimPrefix(p, a.Registered)
133 break
134 }
135 }
136 }
137 }
138 }
139 var matched []Command
140 for _, cmd := range registry {
141 p := joinPath(cmd.Path)
142 for _, pfx := range prefixes {
143 if pfx == "" || p == pfx || strings.HasPrefix(p, pfx+" ") {
144 matched = append(matched, cmd)
145 break
146 }
147 }
148 }
149 if len(matched) == 0 {
150 return c.fail(protocol.ExitNotFound, "no command matches %q; try: help", prefix)
151 }
152 slices.SortFunc(matched, func(a, b Command) int { return strings.Compare(joinPath(a.Path), joinPath(b.Path)) })
153 entries := make([]helpEntry, len(matched))
154 for i, cmd := range matched {
155 entries[i] = helpEntry{Path: joinPath(cmd.Path), Summary: cmd.Summary, Usage: cmd.Usage, Flags: cmd.Flags, Examples: cmd.Examples}
156 }
157 return c.emit(entries, func(w io.Writer) {
158 switch {
159 case prefix == "":
160 for _, e := range entries {
161 summary := e.Summary
162 if c.Term.Cols > 0 {
163 if avail := c.Term.Cols - max(cells(e.Path), 24) - 1; avail > 0 {
164 summary = clip(summary, avail)
165 }
166 }
167 fmt.Fprintf(w, "%-24s %s\n", e.Path, summary)
168 }
169 case joinPath(matched[0].Path) == prefix:
170 c.helpVerb(w, matched[0], matched[1:])
171 default:
172 c.helpNoun(w, prefix, matched, override)
173 }
174 })
175}
176
177// viaCLI reports whether the caller is the gitbay CLI, as far as the
178// session says: a terminal (only the CLI or a caller passing --term sets
179// one), or a CLI path, which only the CLI sends.
180func (c *Ctx) viaCLI() bool {
181 return c.Term.Cols > 0 || c.CLIPath != ""
182}
183
184// program is how help spells the command it documents: gitbay for the
185// CLI, ssh otherwise.
186func (c *Ctx) program() string {
187 if c.viaCLI() {
188 return "gitbay"
189 }
190 return "ssh git@" + hostOf(c.Cfg.Server.SiteURL)
191}
192
193// cliUsage marks a leading <owner/name> optional in a usage line for the
194// CLI, which infers it inside a clone (cmd/gitbay/ssh.go's withRepo).
195// Stock ssh never does.
196func cliUsage(usage string) string {
197 return strings.Replace(usage, "<owner/name>", "[<owner/name>]", 1)
198}
199
200// shownAs rewrites full, which starts with the registered path, to start
201// with the CLI's path instead when the CLI sent one that differs (#267).
202// The CLI path must name this command: either it regroups it (the same
203// last word, auth keys remove for keys remove) or extends it (repo
204// topics list for repo topics). Arguments after a CLI command can
205// dispatch to a longer registered path (gitbay repo topics list add
206// reaches repo topics add), and that command keeps its own name.
207func (c *Ctx) shownAs(registered, full string) string {
208 rest, ok := strings.CutPrefix(full, registered)
209 if !ok || c.CLIPath == "" || c.CLIPath == registered {
210 return full
211 }
212 reg, cli := strings.Fields(registered), strings.Fields(c.CLIPath)
213 if len(reg) == 0 || len(cli) == 0 || (cli[len(cli)-1] != reg[len(reg)-1] && !strings.HasPrefix(c.CLIPath, registered+" ")) {
214 return full
215 }
216 return c.CLIPath + rest
217}
218
219// shownBelow is how another command listed beside registered prints to
220// this caller. When the CLI only regrouped the command (auth keys remove
221// for keys remove, the same last word) the other command takes the CLI's
222// parent in place of the registered one. When the CLI renamed the last
223// word (repo topics list for repo topics) nothing follows about the
224// other command's name, so it keeps its registered path.
225func (c *Ctx) shownBelow(registered, other string) string {
226 reg, cli, o := strings.Fields(registered), strings.Fields(c.CLIPath), strings.Fields(other)
227 if len(cli) == 0 || len(reg) == 0 || len(o) < len(reg) || cli[len(cli)-1] != reg[len(reg)-1] ||
228 !slices.Equal(o[:len(reg)-1], reg[:len(reg)-1]) {
229 return other
230 }
231 return joinPath(append(slices.Clip(cli[:len(cli)-1]), o[len(reg)-1:]...))
232}
233
234// usageShape is a usage line for the command registered at path as this
235// caller should see it: the CLI's path in place of the registered one
236// where they differ, and a leading <owner/name> optional for the CLI.
237func (c *Ctx) usageShape(path []string, usage string) string {
238 shape := c.shownAs(joinPath(path), usage)
239 if c.viaCLI() {
240 shape = cliUsage(shape)
241 }
242 return shape
243}
244
245// cmdUsage is the running command's usage with the program in front, as
246// a usage refusal prints it.
247func (c *Ctx) cmdUsage() string {
248 return c.program() + " " + c.usageShape(c.Cmd.Path, c.Cmd.Usage)
249}
250
251func (c *Ctx) heading(w io.Writer, s string) {
252 fmt.Fprintln(w, c.Term.paint(sgrBold, s))
253}
254
255// wrapLine prints prefix+text, wrapping text at Term.Cols with a hanging
256// indent under prefix when it would overflow. Plain output never wraps.
257func (c *Ctx) wrapLine(w io.Writer, prefix, text string) {
258 if c.Term.Cols <= 0 || cells(prefix+text) <= c.Term.Cols {
259 fmt.Fprintln(w, prefix+text)
260 return
261 }
262 indent := cells(prefix)
263 width := c.Term.Cols - indent
264 for i, line := range termtext.Wrap(text, width) {
265 if i == 0 {
266 fmt.Fprintln(w, prefix+line)
267 } else {
268 fmt.Fprintln(w, strings.Repeat(" ", indent)+line)
269 }
270 }
271}
272
273func (c *Ctx) helpVerb(w io.Writer, cmd Command, below []Command) {
274 fmt.Fprintln(w, cmd.Summary)
275 fmt.Fprintln(w)
276 c.heading(w, "USAGE")
277 // Cutting at the first optional flag drops the rest of the usage
278 // syntax behind "[flags]" — safe only for what is actually optional.
279 // A required flag (repo delete --yes) or an alternative
280 // (notifications read <id>... | --all) has no " [--" to cut at, so
281 // the usage prints whole.
282 registered := joinPath(cmd.Path)
283 shape := c.usageShape(cmd.Path, cmd.Usage)
284 if i := strings.Index(shape, " [--"); i >= 0 {
285 shape = shape[:i] + " [flags]"
286 }
287 fmt.Fprintf(w, " %s %s\n", c.program(), shape)
288 fmt.Fprintln(w)
289 c.heading(w, "FLAGS")
290 rows := make([][2]string, 0, len(cmd.Flags)+1)
291 for _, f := range cmd.Flags {
292 name := f.Name
293 if f.Arg != "" {
294 name += " " + f.Arg
295 }
296 desc := f.Desc
297 if f.Default != "" {
298 desc += " (default " + f.Default + ")"
299 }
300 rows = append(rows, [2]string{name, desc})
301 }
302 rows = append(rows, [2]string{"--json", "machine-readable output"})
303 wide := 0
304 for _, r := range rows {
305 wide = max(wide, cells(r[0]))
306 }
307 for _, r := range rows {
308 c.wrapLine(w, " "+pad(r[0], wide)+" ", r[1])
309 }
310 if len(cmd.Examples) > 0 {
311 fmt.Fprintln(w)
312 c.heading(w, "EXAMPLES")
313 for _, ex := range cmd.Examples {
314 c.wrapLine(w, " "+c.program()+" ", c.shownAs(registered, ex))
315 }
316 }
317 if len(below) > 0 {
318 fmt.Fprintln(w)
319 c.heading(w, "SEE ALSO")
320 for _, b := range below {
321 fmt.Fprintf(w, " %s %s\n", c.program(), c.shownBelow(registered, joinPath(b.Path)))
322 }
323 }
324}
325
326// helpNoun renders a noun with several commands under it. override, from
327// an aliased noun (auth), gives the full CLI path for a row that is not
328// itself under prefix (keys list, gathered under auth, becomes "auth
329// keys list"); it is empty for an ordinary noun, so every row there
330// still trims to just its own verb.
331func (c *Ctx) helpNoun(w io.Writer, prefix string, cmds []Command, override map[string]string) {
332 head := nounSummaries[strings.Fields(prefix)[0]]
333 fmt.Fprintln(w, head)
334 // An aliased noun is a CLI grouping: over stock ssh there is no
335 // "auth <verb>" to type, and each row already names its full command.
336 _, aliased := nounAliases[prefix]
337 bare := aliased && c.CLIPath == ""
338 display := c.shownAs(prefix, prefix)
339 if !bare {
340 fmt.Fprintln(w)
341 c.heading(w, "USAGE")
342 fmt.Fprintf(w, " %s %s <verb> ...\n", c.program(), display)
343 }
344 rowText := func(cmd Command) string {
345 full := joinPath(cmd.Path)
346 if ov, ok := override[full]; ok {
347 return ov
348 }
349 return strings.TrimPrefix(full, prefix+" ")
350 }
351 wide := 0
352 for _, cmd := range cmds {
353 wide = max(wide, cells(rowText(cmd)))
354 }
355 for _, section := range []struct {
356 title string
357 read bool
358 }{{"READ", true}, {"WRITE", false}} {
359 first := true
360 for _, cmd := range cmds {
361 if cmd.ReadOnly != section.read {
362 continue
363 }
364 if first {
365 fmt.Fprintln(w)
366 c.heading(w, section.title)
367 first = false
368 }
369 fmt.Fprintf(w, " %s %s\n", pad(rowText(cmd), wide), cmd.Summary)
370 }
371 }
372 if !bare {
373 fmt.Fprintln(w)
374 fmt.Fprintf(w, "%s %s <verb> --help for flags.\n", c.program(), display)
375 }
376}