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}