cmd/gitbay/main.go

472 lines · 24056 bytes

  1// forge is the client CLI. It is ergonomics over a control plane that is
  2// fully usable from bare OpenSSH: most commands pass through to the server
  3// over the system ssh binary, adding instance resolution, repo inference
  4// from the origin remote, and $EDITOR for long text.
  5package main
  6
  7import (
  8	"fmt"
  9	"io"
 10	"os"
 11	"strings"
 12
 13	"github.com/spf13/cobra"
 14	"github.com/spf13/cobra/doc"
 15
 16	"gitbay.org/gitbay/internal/protocol"
 17)
 18
 19func main() {
 20	root := &cobra.Command{
 21		Use:           "gitbay",
 22		Short:         "CLI-first git forge client",
 23		SilenceUsage:  true,
 24		SilenceErrors: true,
 25	}
 26
 27	root.AddCommand(
 28		authCmd(),
 29		group("status", "commit statuses (CI)",
 30			pass("set", "report a status: <sha> --context <c> --state <s> [--description d] [--url u]", passOpts{server: []string{"status", "set"}, needsRepo: true}),
 31			pass("list", "statuses on a commit: <sha>", passOpts{server: []string{"status", "list"}, needsRepo: true}),
 32		),
 33		group("build", "CI builds",
 34			pass("list", "recent builds: <owner/name>", passOpts{server: []string{"build", "list"}, needsRepo: true}),
 35			pass("show", "one build: <owner/name> <n>", passOpts{server: []string{"build", "show"}, needsRepo: true}),
 36			pass("log", "a build's log: <owner/name> <n>", passOpts{server: []string{"build", "log"}, needsRepo: true}),
 37			pass("trigger", "queue a job now: <job>", passOpts{server: []string{"build", "trigger"}, needsRepo: true}),
 38		),
 39		repoCmd(),
 40		issueCmd(),
 41		milestoneCmd(),
 42		mrCmd(),
 43		releaseCmd(),
 44		migrateCmd(),
 45		webCmd(),
 46		orgCmd(),
 47		group("profile", "user and org profiles",
 48			pass("show", "show a profile: [name]", passOpts{server: []string{"profile", "show"}}),
 49			pass("set", "set your profile: [--description d] [--website url]", passOpts{server: []string{"profile", "set"}}),
 50		),
 51		webhookCmd(),
 52		remoteCmd(),
 53		initCmd(),
 54		pass("register", "create an account on the default instance: gitbay register --username <n> --email <a> | --invite <code>",
 55			passOpts{server: []string{"register"}}),
 56		manCmd(root),
 57	)
 58
 59	if err := root.Execute(); err != nil {
 60		fmt.Fprintln(os.Stderr, "gitbay:", err)
 61		os.Exit(protocol.ExitUsage)
 62	}
 63}
 64
 65// passOpts describes how one CLI command maps onto the server command.
 66type passOpts struct {
 67	server      []string // server-side command path
 68	needsRepo   bool     // prepend inferred owner/name unless given
 69	stdinOK     bool     // wire local stdin through (keys add, --file -)
 70	alwaysStdin bool     // stdin is the payload (release asset add)
 71	editor      string   // open $EDITOR for a body when none given
 72}
 73
 74// pass builds a passthrough command. Flags are parsed by the server, which
 75// is the single source of truth for them; the CLI stays thin.
 76func pass(use, short string, o passOpts) *cobra.Command {
 77	return &cobra.Command{
 78		Use:                use,
 79		Short:              short,
 80		DisableFlagParsing: true,
 81		RunE: func(cmd *cobra.Command, args []string) error {
 82			// cobra still owns `forge <cmd> --help`.
 83			for _, a := range args {
 84				if a == "--help" || a == "-h" {
 85					return cmd.Help()
 86				}
 87			}
 88			os.Exit(runPass(o, args))
 89			return nil
 90		},
 91	}
 92}
 93
 94func runPass(o passOpts, args []string) int {
 95	t, err := resolveTarget()
 96	if err != nil {
 97		fmt.Fprintln(os.Stderr, "gitbay:", err)
 98		return protocol.ExitFailure
 99	}
100	if o.needsRepo {
101		args, err = withRepo(t, args)
102		if err != nil {
103			fmt.Fprintln(os.Stderr, "gitbay:", err)
104			return protocol.ExitUsage
105		}
106	}
107
108	var stdin io.Reader = strings.NewReader("")
109	if o.editor != "" {
110		// Issue bodies prefill from the repo's .gitbay/issue-template*.md.
111		var prefill func() string
112		if o.editor == "issue" && len(args) > 0 && strings.Contains(args[0], "/") {
113			repoPath := args[0]
114			prefill = func() string { return fetchIssueTemplate(t, repoPath) }
115		}
116		extended, body, ok, err := maybeEditor(args, o.editor, prefill)
117		if err != nil {
118			fmt.Fprintln(os.Stderr, "gitbay:", err)
119			return protocol.ExitFailure
120		}
121		if !ok {
122			return protocol.ExitFailure
123		}
124		args = extended
125		if body != nil {
126			stdin = body
127		}
128	}
129	if stdin == nil || isEmptyReader(stdin) {
130		if o.alwaysStdin || (o.stdinOK && usesStdin(args)) {
131			stdin = os.Stdin
132		}
133	}
134	return runSSH(t, append(o.server, args...), stdin)
135}
136
137func isEmptyReader(r io.Reader) bool {
138	sr, ok := r.(*strings.Reader)
139	return ok && sr.Len() == 0
140}
141
142// usesStdin reports whether the arguments request stdin content.
143func usesStdin(args []string) bool {
144	for i, a := range args {
145		if a == "--file" && i+1 < len(args) && args[i+1] == "-" {
146			return true
147		}
148		if a == "--token-stdin" {
149			return true
150		}
151	}
152	return false
153}
154
155func group(use, short string, subs ...*cobra.Command) *cobra.Command {
156	c := &cobra.Command{Use: use, Short: short}
157	c.AddCommand(subs...)
158	return c
159}
160
161// local wraps a locally-implemented command (git plumbing, config).
162func local(use, short string, fn func(args []string) int) *cobra.Command {
163	return &cobra.Command{
164		Use:                use,
165		Short:              short,
166		DisableFlagParsing: true,
167		RunE: func(cmd *cobra.Command, args []string) error {
168			for _, a := range args {
169				if a == "--help" || a == "-h" {
170					return cmd.Help()
171				}
172			}
173			os.Exit(fn(args))
174			return nil
175		},
176	}
177}
178
179func authCmd() *cobra.Command {
180	keysAdd := pass("add", "register an SSH public key (reads the key from stdin or --file -)",
181		passOpts{server: []string{"keys", "add"}, stdinOK: true})
182	// keys add always reads stdin on the server; wire it through directly.
183	keysAdd.RunE = func(cmd *cobra.Command, args []string) error {
184		t, err := resolveTarget()
185		if err != nil {
186			return err
187		}
188		os.Exit(runSSH(t, append([]string{"keys", "add"}, args...), os.Stdin))
189		return nil
190	}
191	pgpAdd := &cobra.Command{
192		Use: "add", Short: "register an OpenPGP public key (armored, on stdin)",
193		DisableFlagParsing: true,
194		RunE: func(cmd *cobra.Command, args []string) error {
195			t, err := resolveTarget()
196			if err != nil {
197				return err
198			}
199			os.Exit(runSSH(t, append([]string{"pgp", "add"}, args...), os.Stdin))
200			return nil
201		},
202	}
203	tokens := group("token", "API tokens (minted over SSH, used with the JSON API)",
204		pass("create", "mint a token: --name <n> [--scope full|read] [--ttl 30d]", passOpts{server: []string{"token", "create"}}),
205		pass("list", "list API tokens", passOpts{server: []string{"token", "list"}}),
206		pass("revoke", "revoke a token by name", passOpts{server: []string{"token", "revoke"}}),
207	)
208	return group("auth", "identity: whoami, SSH and PGP keys",
209		pass("export", "write your account bundle (a user-level backup) to stdout",
210			passOpts{server: []string{"account", "export"}}),
211		tokens,
212		pass("whoami", "show the authenticated account", passOpts{server: []string{"whoami"}}),
213		group("keys", "manage SSH keys",
214			pass("list", "list registered SSH keys", passOpts{server: []string{"keys", "list"}}),
215			keysAdd,
216			pass("remove", "remove an SSH key by fingerprint", passOpts{server: []string{"keys", "remove"}}),
217		),
218		group("email", "manage email addresses",
219			pass("add", "add an address and get a verification code by mail", passOpts{server: []string{"email", "add"}}),
220			pass("verify", "confirm a verification code", passOpts{server: []string{"email", "verify"}}),
221		),
222		group("pgp", "manage OpenPGP keys",
223			pass("list", "list registered PGP keys", passOpts{server: []string{"pgp", "list"}}),
224			pgpAdd,
225			pass("remove", "remove a PGP key by fingerprint", passOpts{server: []string{"pgp", "remove"}}),
226		),
227	)
228}
229
230func repoCmd() *cobra.Command {
231	return group("repo", "create and manage repositories",
232		pass("create", "create a repository: gitbay repo create <owner/name> [--private]",
233			passOpts{server: []string{"repo", "create"}}),
234		pass("list", "list repositories you own or can access", passOpts{server: []string{"repo", "list"}}),
235		pass("show", "show repository details", passOpts{server: []string{"repo", "show"}, needsRepo: true}),
236		pass("log", "commit log with signature states", passOpts{server: []string{"repo", "log"}, needsRepo: true}),
237		pass("transfer", "move a repository to another owner: <new-owner>", passOpts{server: []string{"repo", "transfer"}, needsRepo: true}),
238		pass("delete", "delete a repository (--yes)", passOpts{server: []string{"repo", "delete"}, needsRepo: true}),
239		pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}),
240		pass("search", "find repositories by name, description, or topic: <query>", passOpts{server: []string{"repo", "search"}}),
241		pass("grep", "search file contents: <query> [--ref <ref>]", passOpts{server: []string{"repo", "grep"}, needsRepo: true}),
242		pass("pin", "pin a repository to your dashboard", passOpts{server: []string{"repo", "pin"}, needsRepo: true}),
243		pass("unpin", "unpin a repository", passOpts{server: []string{"repo", "unpin"}, needsRepo: true}),
244		pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}),
245		pass("unarchive", "unarchive a repository", passOpts{server: []string{"repo", "unarchive"}, needsRepo: true}),
246		local("clone", "clone via ssh: gitbay repo clone <owner/name> [dir]", cmdRepoClone),
247		importCmd(),
248		pass("import-issues", "import GitHub issue/PR history: --from <ghowner/ghrepo> [--token-stdin]",
249			passOpts{server: []string{"repo", "import-issues"}, needsRepo: true, stdinOK: true}),
250		group("deploy-key", "repository-bound CI keys",
251			pass("add", "bind a key: [--rw] < key.pub", passOpts{server: []string{"repo", "deploy-key", "add"}, needsRepo: true, stdinOK: true}),
252			pass("list", "list deploy keys", passOpts{server: []string{"repo", "deploy-key", "list"}, needsRepo: true}),
253			pass("remove", "remove a deploy key: <fingerprint>", passOpts{server: []string{"repo", "deploy-key", "remove"}, needsRepo: true}),
254		),
255		group("mirror", "sync with a foreign remote",
256			pass("add", "add a mirror: <https-url> --direction push|pull [--username <u>] [--token-stdin]",
257				passOpts{server: []string{"repo", "mirror", "add"}, needsRepo: true, stdinOK: true}),
258			pass("list", "list mirrors with sync status", passOpts{server: []string{"repo", "mirror", "list"}, needsRepo: true}),
259			pass("remove", "remove a mirror: <id>", passOpts{server: []string{"repo", "mirror", "remove"}, needsRepo: true}),
260			pass("sync", "schedule an immediate sync", passOpts{server: []string{"repo", "mirror", "sync"}, needsRepo: true}),
261		),
262		group("secret", "build secrets (values on stdin, injected into build env)",
263			pass("set", "set a secret: <NAME> (value on stdin)", passOpts{server: []string{"repo", "secret", "set"}, needsRepo: true, alwaysStdin: true}),
264			pass("list", "list secret names", passOpts{server: []string{"repo", "secret", "list"}, needsRepo: true}),
265			pass("remove", "remove a secret: <NAME>", passOpts{server: []string{"repo", "secret", "remove"}, needsRepo: true}),
266		),
267		group("domain", "custom domains for the pages branch",
268			pass("add", "claim a domain (verify with a DNS TXT record): <domain>", passOpts{server: []string{"repo", "domain", "add"}, needsRepo: true}),
269			pass("verify", "check the DNS challenge and activate a claim: <domain>", passOpts{server: []string{"repo", "domain", "verify"}, needsRepo: true}),
270			pass("list", "list custom pages domains", passOpts{server: []string{"repo", "domain", "list"}, needsRepo: true}),
271			pass("remove", "remove a custom pages domain: <domain>", passOpts{server: []string{"repo", "domain", "remove"}, needsRepo: true}),
272		),
273		group("topics", "free-form repository tags",
274			pass("list", "list topics", passOpts{server: []string{"repo", "topics"}, needsRepo: true}),
275			pass("add", "add topics: <topic>...", passOpts{server: []string{"repo", "topics", "add"}, needsRepo: true}),
276			pass("remove", "remove topics: <topic>...", passOpts{server: []string{"repo", "topics", "remove"}, needsRepo: true}),
277		),
278		group("access", "manage access grants",
279			pass("grant", "grant access: ... <user> read|write|admin", passOpts{server: []string{"repo", "access", "grant"}, needsRepo: true}),
280			pass("revoke", "revoke access: ... <user>", passOpts{server: []string{"repo", "access", "revoke"}, needsRepo: true}),
281			pass("list", "list access grants", passOpts{server: []string{"repo", "access", "list"}, needsRepo: true}),
282		),
283		group("settings", "repository settings",
284			pass("show", "show settings", passOpts{server: []string{"repo", "settings", "show"}, needsRepo: true}),
285			pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}),
286			pass("unprotect", "unprotect a branch", passOpts{server: []string{"repo", "settings", "unprotect"}, needsRepo: true}),
287			pass("require-approvals", "require N fresh approvals to merge: <n>", passOpts{server: []string{"repo", "settings", "require-approvals"}, needsRepo: true}),
288			pass("require-resolved", "require threads resolved to merge: on|off", passOpts{server: []string{"repo", "settings", "require-resolved"}, needsRepo: true}),
289			pass("require-checks", "gate merges on green statuses: ... on|off", passOpts{server: []string{"repo", "settings", "require-checks"}, needsRepo: true}),
290			pass("require-signed", "require verified commit signatures: ... on|off", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}),
291			pass("description", "set the repository description: <text>", passOpts{server: []string{"repo", "settings", "description"}, needsRepo: true}),
292			pass("website", "set the repository website: <url> ('' clears)", passOpts{server: []string{"repo", "settings", "website"}, needsRepo: true}),
293			pass("git-daemon", "expose over git://: ... on|off", passOpts{server: []string{"repo", "settings", "git-daemon"}, needsRepo: true}),
294		),
295	)
296}
297
298func issueCmd() *cobra.Command {
299	return group("issue", "issues",
300		pass("create", "open an issue: --title <t> [--body|--file -|$EDITOR]",
301			passOpts{server: []string{"issue", "create"}, needsRepo: true, stdinOK: true, editor: "issue"}),
302		pass("list", "list issues [--state open|closed|all]", passOpts{server: []string{"issue", "list"}, needsRepo: true}),
303		pass("show", "show an issue with comments", passOpts{server: []string{"issue", "show"}, needsRepo: true}),
304		pass("comment", "comment on an issue [--message|--file -|$EDITOR]",
305			passOpts{server: []string{"issue", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
306		pass("close", "close an issue", passOpts{server: []string{"issue", "close"}, needsRepo: true}),
307		pass("reopen", "reopen an issue", passOpts{server: []string{"issue", "reopen"}, needsRepo: true}),
308		pass("label", "add or remove labels: [--add <l>]... [--remove <l>]...", passOpts{server: []string{"issue", "label"}, needsRepo: true}),
309		pass("assign", "assign users: [--add <u>]... [--remove <u>]...", passOpts{server: []string{"issue", "assign"}, needsRepo: true}),
310		pass("edit", "edit title or body: <n> [--title <t>] [--body <b>|--file -]", passOpts{server: []string{"issue", "edit"}, needsRepo: true, stdinOK: true}),
311		pass("milestone", "set or clear the milestone: <n> <title|none>", passOpts{server: []string{"issue", "milestone"}, needsRepo: true}),
312		pass("templates", "list issue templates (.gitbay/issue-template*.md)", passOpts{server: []string{"issue", "templates"}, needsRepo: true}),
313	)
314}
315
316func releaseCmd() *cobra.Command {
317	return group("release", "tag-anchored releases with notes and assets",
318		pass("create", "create a release on a pushed tag: <tag> [--title <t>] [--notes|--file -|$EDITOR]",
319			passOpts{server: []string{"release", "create"}, needsRepo: true, stdinOK: true, editor: "release"}),
320		pass("list", "list releases", passOpts{server: []string{"release", "list"}, needsRepo: true}),
321		pass("show", "show a release with assets: <tag>", passOpts{server: []string{"release", "show"}, needsRepo: true}),
322		pass("delete", "delete a release and its assets: <tag> --yes", passOpts{server: []string{"release", "delete"}, needsRepo: true}),
323		group("asset", "binary assets on a release",
324			pass("add", "upload from stdin: <tag> <filename> < file", passOpts{server: []string{"release", "asset", "add"}, needsRepo: true, alwaysStdin: true}),
325			pass("get", "download to stdout: <tag> <filename> > file", passOpts{server: []string{"release", "asset", "get"}, needsRepo: true}),
326			pass("remove", "remove an asset: <tag> <filename>", passOpts{server: []string{"release", "asset", "remove"}, needsRepo: true}),
327		),
328	)
329}
330
331func milestoneCmd() *cobra.Command {
332	return group("milestone", "group issues and MRs toward a release",
333		pass("create", "create a milestone: <title> [--description <d>] [--due YYYY-MM-DD]",
334			passOpts{server: []string{"milestone", "create"}, needsRepo: true}),
335		pass("list", "list milestones with progress [--state open|closed|all]",
336			passOpts{server: []string{"milestone", "list"}, needsRepo: true}),
337		pass("close", "close a milestone: <title>", passOpts{server: []string{"milestone", "close"}, needsRepo: true}),
338		pass("reopen", "reopen a milestone: <title>", passOpts{server: []string{"milestone", "reopen"}, needsRepo: true}),
339	)
340}
341
342func mrCmd() *cobra.Command {
343	return group("mr", "merge requests",
344		pass("create", "open a merge request: --source <branch> --target <branch> --title <t>",
345			passOpts{server: []string{"mr", "create"}, needsRepo: true, stdinOK: true, editor: "merge request"}),
346		pass("list", "list merge requests [--state ...]", passOpts{server: []string{"mr", "list"}, needsRepo: true}),
347		pass("show", "show a merge request", passOpts{server: []string{"mr", "show"}, needsRepo: true}),
348		pass("diff", "show the diff", passOpts{server: []string{"mr", "diff"}, needsRepo: true}),
349		local("checkout", "fetch and check out the MR head locally: gitbay mr checkout <n>", cmdMRCheckout),
350		pass("comment", "comment on a merge request", passOpts{server: []string{"mr", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
351		pass("diff-comment", "comment on a diff line: --path <f> --line <l> [--old] [--reply <id>]", passOpts{server: []string{"mr", "diff-comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
352		pass("threads", "review threads on an MR", passOpts{server: []string{"mr", "threads"}, needsRepo: true}),
353		pass("resolve", "resolve a review thread: <n> <thread-id>", passOpts{server: []string{"mr", "resolve"}, needsRepo: true}),
354		pass("unresolve", "reopen a review thread: <n> <thread-id>", passOpts{server: []string{"mr", "unresolve"}, needsRepo: true}),
355		pass("review", "review: --approve|--request-changes|--comment", passOpts{server: []string{"mr", "review"}, needsRepo: true}),
356		pass("merge", "merge: [--strategy ff|merge|squash|rebase]", passOpts{server: []string{"mr", "merge"}, needsRepo: true}),
357		pass("close", "close without merging", passOpts{server: []string{"mr", "close"}, needsRepo: true}),
358		pass("edit", "edit title or body: <n> [--title <t>] [--body <b>|--file -]", passOpts{server: []string{"mr", "edit"}, needsRepo: true, stdinOK: true}),
359		pass("milestone", "set or clear the milestone: <n> <title|none>", passOpts{server: []string{"mr", "milestone"}, needsRepo: true}),
360	)
361}
362
363// importCmd passes repo import through with stdin wired for --token-stdin.
364func importCmd() *cobra.Command {
365	return &cobra.Command{
366		Use:                "import",
367		Short:              "server-side mirror of a foreign repo: gitbay repo import <owner/name> --from <url> [--private] [--token-stdin]",
368		DisableFlagParsing: true,
369		RunE: func(cmd *cobra.Command, args []string) error {
370			for _, a := range args {
371				if a == "--help" || a == "-h" {
372					return cmd.Help()
373				}
374			}
375			t, err := resolveTarget()
376			if err != nil {
377				return err
378			}
379			var stdin io.Reader = strings.NewReader("")
380			if usesTokenStdin(args) {
381				stdin = os.Stdin
382			}
383			os.Exit(runSSH(t, append([]string{"repo", "import"}, args...), stdin))
384			return nil
385		},
386	}
387}
388
389func usesTokenStdin(args []string) bool {
390	for _, a := range args {
391		if a == "--token-stdin" {
392			return true
393		}
394	}
395	return false
396}
397
398func webCmd() *cobra.Command {
399	return group("web", "browser session",
400		pass("login", "mint a one-time browser login URL over ssh", passOpts{server: []string{"web", "login"}}),
401	)
402}
403
404func webhookCmd() *cobra.Command {
405	return group("webhook", "outbound event delivery",
406		pass("add", "add a webhook: <url> [--secret s] [--events k1,k2|*]", passOpts{server: []string{"webhook", "add"}, needsRepo: true}),
407		pass("list", "list webhooks", passOpts{server: []string{"webhook", "list"}, needsRepo: true}),
408		pass("remove", "remove a webhook: <id>", passOpts{server: []string{"webhook", "remove"}, needsRepo: true}),
409		pass("deliveries", "recent deliveries [--limit n]", passOpts{server: []string{"webhook", "deliveries"}, needsRepo: true}),
410		pass("redeliver", "requeue a delivery: <delivery-id>", passOpts{server: []string{"webhook", "redeliver"}, needsRepo: true}),
411	)
412}
413
414func orgCmd() *cobra.Command {
415	return group("org", "organizations",
416		pass("create", "create an organization", passOpts{server: []string{"org", "create"}}),
417		pass("list", "list organizations you belong to", passOpts{server: []string{"org", "list"}}),
418		pass("show", "show an organization and its members", passOpts{server: []string{"org", "show"}}),
419		pass("rename", "rename an organization: <old> <new>", passOpts{server: []string{"org", "rename"}}),
420		pass("delete", "delete an empty organization (--yes)", passOpts{server: []string{"org", "delete"}}),
421		pass("profile", "show or set an org profile: <org> [--description d] [--website url]", passOpts{server: []string{"org", "profile"}}),
422		group("members", "manage members",
423			pass("add", "add or update a member: <org> <user> [--role member|admin]", passOpts{server: []string{"org", "members", "add"}}),
424			pass("remove", "remove a member: <org> <user>", passOpts{server: []string{"org", "members", "remove"}}),
425			pass("list", "list members: <org>", passOpts{server: []string{"org", "members", "list"}}),
426		),
427		group("team", "scope repository access with teams",
428			pass("create", "create a team: <org> <team>", passOpts{server: []string{"org", "team", "create"}}),
429			pass("delete", "delete a team: <org> <team>", passOpts{server: []string{"org", "team", "delete"}}),
430			pass("list", "list teams: <org>", passOpts{server: []string{"org", "team", "list"}}),
431			pass("show", "show members and grants: <org> <team>", passOpts{server: []string{"org", "team", "show"}}),
432			pass("add", "add org members: <org> <team> <user>...", passOpts{server: []string{"org", "team", "add"}}),
433			pass("remove", "remove members: <org> <team> <user>...", passOpts{server: []string{"org", "team", "remove"}}),
434			pass("grant", "grant a repo role: <org> <team> <owner/name> read|write|admin", passOpts{server: []string{"org", "team", "grant"}}),
435			pass("revoke", "revoke a repo grant: <org> <team> <owner/name>", passOpts{server: []string{"org", "team", "revoke"}}),
436		),
437		group("settings", "organization settings",
438			pass("members-role", "role plain membership implies: <org> write|read|none", passOpts{server: []string{"org", "settings", "members-role"}}),
439		),
440	)
441}
442
443func remoteCmd() *cobra.Command {
444	return group("remote", "local instance profiles (no server contact)",
445		local("add", "add a named gitbay instance: gitbay remote add <name> <host> [--port n] [--user u] [--ssh-option o]... [--default]",
446			cmdRemoteAdd),
447		local("list", "list configured instances", func([]string) int { return cmdRemoteList() }),
448	)
449}
450
451func initCmd() *cobra.Command {
452	return local("init", "git init + repo create + set origin, in one step: gitbay init [name] [--private]", cmdInit)
453}
454
455// manCmd generates man pages; a CLI-first tool without man pages is not
456// CLI-first.
457func manCmd(root *cobra.Command) *cobra.Command {
458	var dir string
459	cmd := &cobra.Command{
460		Use:    "man",
461		Short:  "generate man pages into a directory",
462		Hidden: true,
463		RunE: func(cmd *cobra.Command, args []string) error {
464			if err := os.MkdirAll(dir, 0o755); err != nil {
465				return err
466			}
467			return doc.GenManTree(root, &doc.GenManHeader{Title: "FORGE", Section: "1"}, dir)
468		},
469	}
470	cmd.Flags().StringVar(&dir, "dir", "man", "output directory")
471	return cmd
472}