internal/control/help.go

375 lines · 12902 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()+" ", c.shownAs(registered, 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}