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