Commit d6d78eea2f

d6d78eea2febd692e88894d6833a8f303785d515

parent: ca9fd6021b

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-23 23:30 UTC

CLI: wire forge to the SSH control plane

- passthrough architecture: subcommands map onto server command paths
  with DisableFlagParsing, so the server stays the single source of
  truth for flags; --json and exit codes pass through untouched
- shells out to the system ssh binary (inherits ~/.ssh/config, agent,
  ProxyJump, hardware keys); args quoted for the server-side POSIX
  tokenizer; ssh exit 255 mapped to protocol error
- instance profiles at ~/.config/forge/config.toml (XDG-aware) with
  optional per-instance ssh_options; forge remote add/list
- repo inference: inside a clone, owner/name comes from the origin
  remote (ssh:// and scp-like URLs parsed); explicit owner/name as the
  first positional always wins; configured instances matched by
  host+port so their ssh_options apply
- $EDITOR opens for issue/mr create and comments when no body flag is
  given on a TTY; content travels as --file - over stdin
- local commands: repo clone (instance URL), mr checkout <n> (fetches
  refs/merge-requests/N/head into branch mr/N), init (git init + repo
  create under the authenticated user + origin), man page generation
  via cobra/doc; shell completions built in
- e2e: full flow through the real binary — remote add, whoami --json,
  create/clone, inferred and explicit repo args, exit-code passthrough,
  MR create/checkout/merge, keys add over stdin, forge init, man and
  completion generation

Layout: unified · split

cmd/forge/local.go added +298
@@ -0,0 +1,298 @@
1package main
2
3import (
4 "fmt"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "strings"
9
10 "golang.org/x/term"
11
12 "github.com/krazywarez/forge/internal/cliconfig"
13 "github.com/krazywarez/forge/internal/protocol"
14)
15
16// hasBodyFlag reports whether args already carry body/message input.
17func hasBodyFlag(args []string) bool {
18 for _, a := range args {
19 if a == "--body" || a == "--message" || a == "--file" {
20 return true
21 }
22 }
23 return false
24}
25
26// maybeEditor opens $EDITOR for long text when the command usually wants a
27// body, none was given, and we are on a terminal. The result is passed to
28// the server via --file - on stdin. Returns the (possibly extended) args,
29// the stdin to use, and ok=false if the user aborted.
30func maybeEditor(args []string, kind string) ([]string, *strings.Reader, bool, error) {
31 if hasBodyFlag(args) || !term.IsTerminal(int(os.Stdin.Fd())) {
32 return args, nil, true, nil
33 }
34 editor := os.Getenv("EDITOR")
35 if editor == "" {
36 // No editor configured: proceed with an empty body rather than
37 // failing — bodies are optional everywhere.
38 return args, nil, true, nil
39 }
40 f, err := os.CreateTemp("", "forge-"+kind+"-*.md")
41 if err != nil {
42 return nil, nil, false, err
43 }
44 defer os.Remove(f.Name())
45 fmt.Fprintf(f, "\n# Write the %s body above. Lines starting with '#' are ignored.\n# Save an empty file to skip the body.\n", kind)
46 f.Close()
47
48 ed := exec.Command("sh", "-c", editor+" "+shellQuote(f.Name()))
49 ed.Stdin, ed.Stdout, ed.Stderr = os.Stdin, os.Stdout, os.Stderr
50 if err := ed.Run(); err != nil {
51 return nil, nil, false, fmt.Errorf("editor: %w", err)
52 }
53 raw, err := os.ReadFile(f.Name())
54 if err != nil {
55 return nil, nil, false, err
56 }
57 var body strings.Builder
58 for _, line := range strings.Split(string(raw), "\n") {
59 if strings.HasPrefix(line, "#") {
60 continue
61 }
62 body.WriteString(line + "\n")
63 }
64 text := strings.TrimSpace(body.String())
65 if text == "" {
66 return args, nil, true, nil
67 }
68 return append(args, "--file", "-"), strings.NewReader(text + "\n"), true, nil
69}
70
71func runGitLocal(args ...string) int {
72 cmd := exec.Command("git", args...)
73 cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
74 if err := cmd.Run(); err != nil {
75 if ee, ok := err.(*exec.ExitError); ok {
76 return ee.ExitCode()
77 }
78 fmt.Fprintln(os.Stderr, "forge:", err)
79 return protocol.ExitFailure
80 }
81 return 0
82}
83
84// cmdRepoClone implements `forge repo clone <owner/name> [dir]`.
85func cmdRepoClone(args []string) int {
86 if len(args) < 1 || strings.HasPrefix(args[0], "-") {
87 fmt.Fprintln(os.Stderr, "usage: forge repo clone <owner/name> [dir]")
88 return protocol.ExitUsage
89 }
90 t, err := resolveTarget()
91 if err != nil {
92 fmt.Fprintln(os.Stderr, "forge:", err)
93 return protocol.ExitFailure
94 }
95 gitArgs := append([]string{"clone", t.inst.CloneURL(args[0])}, args[1:]...)
96 if len(t.inst.SSHOptions) > 0 {
97 os.Setenv("GIT_SSH_COMMAND", "ssh "+strings.Join(quoteAll(t.inst.SSHOptions), " "))
98 }
99 return runGitLocal(gitArgs...)
100}
101
102// cmdMRCheckout implements `forge mr checkout <n>`: fetch the MR head from
103// origin and check it out as a local branch.
104func cmdMRCheckout(args []string) int {
105 if len(args) != 1 {
106 fmt.Fprintln(os.Stderr, "usage: forge mr checkout <n>")
107 return protocol.ExitUsage
108 }
109 n := args[0]
110 ref := "refs/merge-requests/" + n + "/head"
111 if code := runGitLocal("fetch", "origin", ref); code != 0 {
112 return code
113 }
114 return runGitLocal("checkout", "-B", "mr/"+n, "FETCH_HEAD")
115}
116
117// cmdInit implements `forge init [name] [--private]`: git init if needed,
118// create the repository on the default instance, and point origin at it.
119func cmdInit(args []string) int {
120 var name string
121 private := false
122 for _, a := range args {
123 switch {
124 case a == "--private":
125 private = true
126 case strings.HasPrefix(a, "-"):
127 fmt.Fprintln(os.Stderr, "usage: forge init [name] [--private]")
128 return protocol.ExitUsage
129 default:
130 name = a
131 }
132 }
133 if name == "" {
134 wd, err := os.Getwd()
135 if err != nil {
136 fmt.Fprintln(os.Stderr, "forge:", err)
137 return protocol.ExitFailure
138 }
139 name = filepath.Base(wd)
140 }
141
142 cfg, err := cliconfig.Load()
143 if err != nil {
144 fmt.Fprintln(os.Stderr, "forge:", err)
145 return protocol.ExitFailure
146 }
147 inst, _, err := cfg.DefaultInstance()
148 if err != nil {
149 fmt.Fprintln(os.Stderr, "forge:", err)
150 return protocol.ExitFailure
151 }
152 t := target{inst: inst}
153
154 // The server requires owner = the authenticated user; ask who that is.
155 whoami, code := captureSSH(t, []string{"whoami"})
156 if code != 0 {
157 return code
158 }
159 username := strings.TrimSpace(whoami)
160 repoPath := username + "/" + name
161
162 createArgs := []string{"repo", "create", repoPath}
163 if private {
164 createArgs = append(createArgs, "--private")
165 }
166 if code := runSSH(t, createArgs, strings.NewReader("")); code != 0 {
167 return code
168 }
169
170 if _, err := os.Stat(".git"); os.IsNotExist(err) {
171 if code := runGitLocal("init", "-q", "-b", "main"); code != 0 {
172 return code
173 }
174 }
175 url := inst.CloneURL(repoPath)
176 if code := runGitLocal("remote", "add", "origin", url); code != 0 {
177 return code
178 }
179 fmt.Printf("origin -> %s\npush with: git push -u origin main\n", url)
180 return 0
181}
182
183// captureSSH runs a server command and returns its stdout.
184func captureSSH(t target, serverArgv []string) (string, int) {
185 args := []string{}
186 if t.inst.Port != 0 && t.inst.Port != 22 {
187 args = append(args, "-p", fmt.Sprint(t.inst.Port))
188 }
189 args = append(args, t.inst.SSHOptions...)
190 quoted := quoteAll(serverArgv)
191 args = append(args, t.inst.SSHUser()+"@"+t.inst.Host, "--", strings.Join(quoted, " "))
192 cmd := exec.Command("ssh", args...)
193 cmd.Stderr = os.Stderr
194 out, err := cmd.Output()
195 if err != nil {
196 if ee, ok := err.(*exec.ExitError); ok {
197 return "", ee.ExitCode()
198 }
199 fmt.Fprintln(os.Stderr, "forge: running ssh:", err)
200 return "", protocol.ExitProtocol
201 }
202 return string(out), 0
203}
204
205func quoteAll(args []string) []string {
206 out := make([]string, len(args))
207 for i, a := range args {
208 out[i] = shellQuote(a)
209 }
210 return out
211}
212
213// cmdRemoteAdd implements `forge remote add <name> <host> [flags]`.
214func cmdRemoteAdd(args []string) int {
215 var name, host, user string
216 var port int
217 var setDefault bool
218 var sshOptions []string
219 i := 0
220 for i < len(args) {
221 a := args[i]
222 switch a {
223 case "--port":
224 if i+1 >= len(args) {
225 fmt.Fprintln(os.Stderr, "--port requires a value")
226 return protocol.ExitUsage
227 }
228 fmt.Sscanf(args[i+1], "%d", &port)
229 i += 2
230 case "--user":
231 if i+1 >= len(args) {
232 fmt.Fprintln(os.Stderr, "--user requires a value")
233 return protocol.ExitUsage
234 }
235 user = args[i+1]
236 i += 2
237 case "--ssh-option":
238 if i+1 >= len(args) {
239 fmt.Fprintln(os.Stderr, "--ssh-option requires a value")
240 return protocol.ExitUsage
241 }
242 sshOptions = append(sshOptions, args[i+1])
243 i += 2
244 case "--default":
245 setDefault = true
246 i++
247 default:
248 if name == "" {
249 name = a
250 } else if host == "" {
251 host = a
252 } else {
253 fmt.Fprintln(os.Stderr, "usage: forge remote add <name> <host> [--port n] [--user u] [--ssh-option opt]... [--default]")
254 return protocol.ExitUsage
255 }
256 i++
257 }
258 }
259 if name == "" || host == "" {
260 fmt.Fprintln(os.Stderr, "usage: forge remote add <name> <host> [--port n] [--user u] [--ssh-option opt]... [--default]")
261 return protocol.ExitUsage
262 }
263 cfg, err := cliconfig.Load()
264 if err != nil {
265 fmt.Fprintln(os.Stderr, "forge:", err)
266 return protocol.ExitFailure
267 }
268 cfg.Instances[name] = cliconfig.Instance{Host: host, Port: port, User: user, SSHOptions: sshOptions}
269 if setDefault || cfg.Default == "" {
270 cfg.Default = name
271 }
272 if err := cliconfig.Save(cfg); err != nil {
273 fmt.Fprintln(os.Stderr, "forge:", err)
274 return protocol.ExitFailure
275 }
276 fmt.Printf("added instance %s (%s)\n", name, host)
277 return 0
278}
279
280func cmdRemoteList() int {
281 cfg, err := cliconfig.Load()
282 if err != nil {
283 fmt.Fprintln(os.Stderr, "forge:", err)
284 return protocol.ExitFailure
285 }
286 for name, inst := range cfg.Instances {
287 def := ""
288 if name == cfg.Default {
289 def = " (default)"
290 }
291 port := inst.Port
292 if port == 0 {
293 port = 22
294 }
295 fmt.Printf("%s\t%s@%s:%d%s\n", name, inst.SSHUser(), inst.Host, port, def)
296 }
297 return 0
298}
cmd/forge/main.go +196 −62
@@ -1,13 +1,17 @@
1// forge is the client CLI. It speaks to a forge server over the system ssh 1// forge is the client CLI. It is ergonomics over a control plane that is
2// binary; it is ergonomics on top of a control plane that is fully usable 2// fully usable from bare OpenSSH: most commands pass through to the server
3// from bare OpenSSH. 3// over the system ssh binary, adding instance resolution, repo inference
4// from the origin remote, and $EDITOR for long text.
4package main 5package main
5 6
6import ( 7import (
7 "fmt" 8 "fmt"
9 "io"
8 "os" 10 "os"
11 "strings"
9 12
10 "github.com/spf13/cobra" 13 "github.com/spf13/cobra"
14 "github.com/spf13/cobra/doc"
11 15
12 "github.com/krazywarez/forge/internal/protocol" 16 "github.com/krazywarez/forge/internal/protocol"
13) 17)
@@ -19,8 +23,6 @@ func main() {
19 SilenceUsage: true, 23 SilenceUsage: true,
20 SilenceErrors: true, 24 SilenceErrors: true,
21 } 25 }
22 root.PersistentFlags().Bool("json", false, "machine-readable output")
23 root.PersistentFlags().String("repo", "", "owner/name (default: inferred from the origin remote)")
24 26
25 root.AddCommand( 27 root.AddCommand(
26 authCmd(), 28 authCmd(),
@@ -28,116 +30,248 @@ func main() {
28 issueCmd(), 30 issueCmd(),
29 mrCmd(), 31 mrCmd(),
30 webCmd(), 32 webCmd(),
31 adminCmd(),
32 remoteCmd(), 33 remoteCmd(),
33 initCmd(), 34 initCmd(),
35 manCmd(root),
34 ) 36 )
35 37
36 if err := root.Execute(); err != nil { 38 if err := root.Execute(); err != nil {
37 fmt.Fprintln(os.Stderr, "forge:", err) 39 fmt.Fprintln(os.Stderr, "forge:", err)
38 os.Exit(protocol.ExitFailure) 40 os.Exit(protocol.ExitUsage)
39 } 41 }
40} 42}
41 43
42// stub returns a leaf command that fails until its milestone lands. 44// passOpts describes how one CLI command maps onto the server command.
43func stub(use, short string) *cobra.Command { 45type passOpts struct {
46 server []string // server-side command path
47 needsRepo bool // prepend inferred owner/name unless given
48 stdinOK bool // wire local stdin through (keys add, --file -)
49 editor string // open $EDITOR for a body when none given
50}
51
52// pass builds a passthrough command. Flags are parsed by the server, which
53// is the single source of truth for them; the CLI stays thin.
54func pass(use, short string, o passOpts) *cobra.Command {
44 return &cobra.Command{ 55 return &cobra.Command{
45 Use: use, 56 Use: use,
46 Short: short, 57 Short: short,
58 DisableFlagParsing: true,
47 RunE: func(cmd *cobra.Command, args []string) error { 59 RunE: func(cmd *cobra.Command, args []string) error {
48 return fmt.Errorf("not implemented") 60 // cobra still owns `forge <cmd> --help`.
61 for _, a := range args {
62 if a == "--help" || a == "-h" {
63 return cmd.Help()
64 }
65 }
66 os.Exit(runPass(o, args))
67 return nil
49 }, 68 },
50 } 69 }
51} 70}
52 71
72func runPass(o passOpts, args []string) int {
73 t, err := resolveTarget()
74 if err != nil {
75 fmt.Fprintln(os.Stderr, "forge:", err)
76 return protocol.ExitFailure
77 }
78 if o.needsRepo {
79 args, err = withRepo(t, args)
80 if err != nil {
81 fmt.Fprintln(os.Stderr, "forge:", err)
82 return protocol.ExitUsage
83 }
84 }
85
86 var stdin io.Reader = strings.NewReader("")
87 if o.editor != "" {
88 extended, body, ok, err := maybeEditor(args, o.editor)
89 if err != nil {
90 fmt.Fprintln(os.Stderr, "forge:", err)
91 return protocol.ExitFailure
92 }
93 if !ok {
94 return protocol.ExitFailure
95 }
96 args = extended
97 if body != nil {
98 stdin = body
99 }
100 }
101 if stdin == nil || isEmptyReader(stdin) {
102 if o.stdinOK && usesStdin(args) {
103 stdin = os.Stdin
104 }
105 }
106 return runSSH(t, append(o.server, args...), stdin)
107}
108
109func isEmptyReader(r io.Reader) bool {
110 sr, ok := r.(*strings.Reader)
111 return ok && sr.Len() == 0
112}
113
114// usesStdin reports whether the arguments request stdin content.
115func usesStdin(args []string) bool {
116 for i, a := range args {
117 if a == "--file" && i+1 < len(args) && args[i+1] == "-" {
118 return true
119 }
120 }
121 return false
122}
123
53func group(use, short string, subs ...*cobra.Command) *cobra.Command { 124func group(use, short string, subs ...*cobra.Command) *cobra.Command {
54 c := &cobra.Command{Use: use, Short: short} 125 c := &cobra.Command{Use: use, Short: short}
55 c.AddCommand(subs...) 126 c.AddCommand(subs...)
56 return c 127 return c
57} 128}
58 129
130// local wraps a locally-implemented command (git plumbing, config).
131func local(use, short string, fn func(args []string) int) *cobra.Command {
132 return &cobra.Command{
133 Use: use,
134 Short: short,
135 DisableFlagParsing: true,
136 RunE: func(cmd *cobra.Command, args []string) error {
137 for _, a := range args {
138 if a == "--help" || a == "-h" {
139 return cmd.Help()
140 }
141 }
142 os.Exit(fn(args))
143 return nil
144 },
145 }
146}
147
59func authCmd() *cobra.Command { 148func authCmd() *cobra.Command {
60 return group("auth", "identity: keys, emails, whoami", 149 keysAdd := pass("add", "register an SSH public key (reads the key from stdin or --file -)",
61 stub("whoami", "show the authenticated account"), 150 passOpts{server: []string{"keys", "add"}, stdinOK: true})
151 // keys add always reads stdin on the server; wire it through directly.
152 keysAdd.RunE = func(cmd *cobra.Command, args []string) error {
153 t, err := resolveTarget()
154 if err != nil {
155 return err
156 }
157 os.Exit(runSSH(t, append([]string{"keys", "add"}, args...), os.Stdin))
158 return nil
159 }
160 pgpAdd := &cobra.Command{
161 Use: "add", Short: "register an OpenPGP public key (armored, on stdin)",
162 DisableFlagParsing: true,
163 RunE: func(cmd *cobra.Command, args []string) error {
164 t, err := resolveTarget()
165 if err != nil {
166 return err
167 }
168 os.Exit(runSSH(t, append([]string{"pgp", "add"}, args...), os.Stdin))
169 return nil
170 },
171 }
172 return group("auth", "identity: whoami, SSH and PGP keys",
173 pass("whoami", "show the authenticated account", passOpts{server: []string{"whoami"}}),
62 group("keys", "manage SSH keys", 174 group("keys", "manage SSH keys",
63 stub("list", "list registered SSH keys"), 175 pass("list", "list registered SSH keys", passOpts{server: []string{"keys", "list"}}),
64 stub("add", "register an SSH key"), 176 keysAdd,
65 stub("remove", "remove an SSH key"), 177 pass("remove", "remove an SSH key by fingerprint", passOpts{server: []string{"keys", "remove"}}),
66 ), 178 ),
67 group("pgp", "manage OpenPGP keys", 179 group("pgp", "manage OpenPGP keys",
68 stub("list", "list registered PGP keys"), 180 pass("list", "list registered PGP keys", passOpts{server: []string{"pgp", "list"}}),
69 stub("add", "register a PGP key"), 181 pgpAdd,
70 stub("remove", "remove a PGP key"), 182 pass("remove", "remove a PGP key by fingerprint", passOpts{server: []string{"pgp", "remove"}}),
71 ),
72 group("email", "manage email addresses",
73 stub("add", "add an address"),
74 stub("verify", "confirm a verification code"),
75 ), 183 ),
76 ) 184 )
77} 185}
78 186
79func repoCmd() *cobra.Command { 187func repoCmd() *cobra.Command {
80 return group("repo", "create and manage repositories", 188 return group("repo", "create and manage repositories",
81 stub("create", "create a repository"), 189 pass("create", "create a repository: forge repo create <owner/name> [--private]",
82 stub("list", "list repositories"), 190 passOpts{server: []string{"repo", "create"}}),
83 stub("show", "show repository details"), 191 pass("list", "list repositories you own or can access", passOpts{server: []string{"repo", "list"}}),
84 stub("clone", "clone via ssh"), 192 pass("show", "show repository details", passOpts{server: []string{"repo", "show"}, needsRepo: true}),
85 stub("rename", "rename a repository"), 193 pass("log", "commit log with signature states", passOpts{server: []string{"repo", "log"}, needsRepo: true}),
86 stub("delete", "delete a repository"), 194 pass("delete", "delete a repository (--yes)", passOpts{server: []string{"repo", "delete"}, needsRepo: true}),
87 stub("fork", "fork a repository"), 195 pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}),
88 stub("import", "server-side mirror from a foreign URL"), 196 local("clone", "clone via ssh: forge repo clone <owner/name> [dir]", cmdRepoClone),
89 stub("settings", "get or set repository settings"), 197 group("access", "manage access grants",
198 pass("grant", "grant access: ... <user> read|write|admin", passOpts{server: []string{"repo", "access", "grant"}, needsRepo: true}),
199 pass("revoke", "revoke access: ... <user>", passOpts{server: []string{"repo", "access", "revoke"}, needsRepo: true}),
200 pass("list", "list access grants", passOpts{server: []string{"repo", "access", "list"}, needsRepo: true}),
201 ),
202 group("settings", "repository settings",
203 pass("show", "show settings", passOpts{server: []string{"repo", "settings", "show"}, needsRepo: true}),
204 pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}),
205 pass("unprotect", "unprotect a branch", passOpts{server: []string{"repo", "settings", "unprotect"}, needsRepo: true}),
206 pass("require-signed", "require verified commit signatures: ... on|off", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}),
207 pass("git-daemon", "expose over git://: ... on|off", passOpts{server: []string{"repo", "settings", "git-daemon"}, needsRepo: true}),
208 ),
90 ) 209 )
91} 210}
92 211
93func issueCmd() *cobra.Command { 212func issueCmd() *cobra.Command {
94 return group("issue", "issues", 213 return group("issue", "issues",
95 stub("create", "open an issue"), 214 pass("create", "open an issue: --title <t> [--body|--file -|$EDITOR]",
96 stub("list", "list issues"), 215 passOpts{server: []string{"issue", "create"}, needsRepo: true, stdinOK: true, editor: "issue"}),
97 stub("show", "show an issue"), 216 pass("list", "list issues [--state open|closed|all]", passOpts{server: []string{"issue", "list"}, needsRepo: true}),
98 stub("comment", "comment on an issue"), 217 pass("show", "show an issue with comments", passOpts{server: []string{"issue", "show"}, needsRepo: true}),
99 stub("close", "close an issue"), 218 pass("comment", "comment on an issue [--message|--file -|$EDITOR]",
100 stub("reopen", "reopen an issue"), 219 passOpts{server: []string{"issue", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
101 stub("label", "add or remove labels"), 220 pass("close", "close an issue", passOpts{server: []string{"issue", "close"}, needsRepo: true}),
102 stub("assign", "assign users"), 221 pass("reopen", "reopen an issue", passOpts{server: []string{"issue", "reopen"}, needsRepo: true}),
222 pass("label", "add or remove labels: [--add <l>]... [--remove <l>]...", passOpts{server: []string{"issue", "label"}, needsRepo: true}),
223 pass("assign", "assign users: [--add <u>]... [--remove <u>]...", passOpts{server: []string{"issue", "assign"}, needsRepo: true}),
103 ) 224 )
104} 225}
105 226
106func mrCmd() *cobra.Command { 227func mrCmd() *cobra.Command {
107 return group("mr", "merge requests", 228 return group("mr", "merge requests",
108 stub("create", "open a merge request"), 229 pass("create", "open a merge request: --source <branch> --target <branch> --title <t>",
109 stub("list", "list merge requests"), 230 passOpts{server: []string{"mr", "create"}, needsRepo: true, stdinOK: true, editor: "merge request"}),
110 stub("show", "show a merge request"), 231 pass("list", "list merge requests [--state ...]", passOpts{server: []string{"mr", "list"}, needsRepo: true}),
111 stub("diff", "show the diff"), 232 pass("show", "show a merge request", passOpts{server: []string{"mr", "show"}, needsRepo: true}),
112 stub("checkout", "fetch and check out the MR head locally"), 233 pass("diff", "show the diff", passOpts{server: []string{"mr", "diff"}, needsRepo: true}),
113 stub("comment", "comment on a merge request"), 234 local("checkout", "fetch and check out the MR head locally: forge mr checkout <n>", cmdMRCheckout),
114 stub("review", "approve or request changes"), 235 pass("comment", "comment on a merge request", passOpts{server: []string{"mr", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
115 stub("merge", "merge (fast-forward or merge-commit)"), 236 pass("review", "review: --approve|--request-changes|--comment", passOpts{server: []string{"mr", "review"}, needsRepo: true}),
116 stub("close", "close without merging"), 237 pass("merge", "merge (fast-forward or merge-commit): [--strategy ff|merge]", passOpts{server: []string{"mr", "merge"}, needsRepo: true}),
238 pass("close", "close without merging", passOpts{server: []string{"mr", "close"}, needsRepo: true}),
117 ) 239 )
118} 240}
119 241
120func webCmd() *cobra.Command { 242func webCmd() *cobra.Command {
121 return group("web", "browser session", 243 return group("web", "browser session",
122 stub("login", "mint a one-time browser login URL over ssh"), 244 pass("login", "mint a one-time browser login URL over ssh", passOpts{server: []string{"web", "login"}}),
123 )
124}
125
126func adminCmd() *cobra.Command {
127 return group("admin", "instance administration (admin accounts only)",
128 stub("user", "manage users"),
129 stub("invite", "issue registration invites"),
130 stub("stats", "instance statistics"),
131 ) 245 )
132} 246}
133 247
134func remoteCmd() *cobra.Command { 248func remoteCmd() *cobra.Command {
135 return group("remote", "local instance profiles (no server contact)", 249 return group("remote", "local instance profiles (no server contact)",
136 stub("add", "add a named forge instance"), 250 local("add", "add a named forge instance: forge remote add <name> <host> [--port n] [--user u] [--ssh-option o]... [--default]",
137 stub("list", "list configured instances"), 251 cmdRemoteAdd),
252 local("list", "list configured instances", func([]string) int { return cmdRemoteList() }),
138 ) 253 )
139} 254}
140 255
141func initCmd() *cobra.Command { 256func initCmd() *cobra.Command {
142 return stub("init", "git init + repo create + set origin, in one step") 257 return local("init", "git init + repo create + set origin, in one step: forge init [name] [--private]", cmdInit)
258}
259
260// manCmd generates man pages; a CLI-first tool without man pages is not
261// CLI-first.
262func manCmd(root *cobra.Command) *cobra.Command {
263 var dir string
264 cmd := &cobra.Command{
265 Use: "man",
266 Short: "generate man pages into a directory",
267 Hidden: true,
268 RunE: func(cmd *cobra.Command, args []string) error {
269 if err := os.MkdirAll(dir, 0o755); err != nil {
270 return err
271 }
272 return doc.GenManTree(root, &doc.GenManHeader{Title: "FORGE", Section: "1"}, dir)
273 },
274 }
275 cmd.Flags().StringVar(&dir, "dir", "man", "output directory")
276 return cmd
143} 277}
cmd/forge/ssh.go added +123
@@ -0,0 +1,123 @@
1package main
2
3import (
4 "fmt"
5 "io"
6 "os"
7 "os/exec"
8 "regexp"
9 "strconv"
10 "strings"
11
12 "github.com/krazywarez/forge/internal/cliconfig"
13 "github.com/krazywarez/forge/internal/protocol"
14)
15
16// context is the resolved target for a command: which instance to talk to
17// and, when run inside a clone of a forge repo, which repository.
18type target struct {
19 inst cliconfig.Instance
20 repo string // owner/name, "" when not inferable
21}
22
23// resolveTarget picks the instance and repo. Inside a git repo whose origin
24// remote points at a configured (or any ssh) forge host, that wins;
25// otherwise the configured default instance.
26func resolveTarget() (target, error) {
27 cfg, err := cliconfig.Load()
28 if err != nil {
29 return target{}, err
30 }
31
32 if url := originURL(); url != "" {
33 if parsed, repo, ok := cliconfig.ParseRemoteURL(url); ok {
34 // Prefer a configured instance for the same host+port: it may
35 // carry ssh_options the bare URL cannot express.
36 norm := func(p int) int {
37 if p == 0 {
38 return 22
39 }
40 return p
41 }
42 for _, inst := range cfg.Instances {
43 if inst.Host == parsed.Host && norm(inst.Port) == norm(parsed.Port) {
44 return target{inst: inst, repo: repo}, nil
45 }
46 }
47 return target{inst: parsed, repo: repo}, nil
48 }
49 }
50
51 inst, _, err := cfg.DefaultInstance()
52 if err != nil {
53 return target{}, err
54 }
55 return target{inst: inst}, nil
56}
57
58func originURL() string {
59 out, err := exec.Command("git", "remote", "get-url", "origin").Output()
60 if err != nil {
61 return ""
62 }
63 return strings.TrimSpace(string(out))
64}
65
66// bareWord matches arguments that need no quoting for the server-side
67// POSIX tokenizer.
68var bareWord = regexp.MustCompile(`^[A-Za-z0-9@%+=:,./_!-]+$`)
69
70// shellQuote quotes one argument for the SSH command string; the server
71// tokenizes with POSIX rules and no expansion.
72func shellQuote(arg string) string {
73 if arg != "" && bareWord.MatchString(arg) {
74 return arg
75 }
76 return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'"
77}
78
79// runSSH executes the server command over the system ssh binary, wiring
80// stdio through. It returns the remote exit code.
81func runSSH(t target, serverArgv []string, stdin io.Reader) int {
82 args := []string{}
83 if t.inst.Port != 0 && t.inst.Port != 22 {
84 args = append(args, "-p", strconv.Itoa(t.inst.Port))
85 }
86 args = append(args, t.inst.SSHOptions...)
87 quoted := make([]string, len(serverArgv))
88 for i, a := range serverArgv {
89 quoted[i] = shellQuote(a)
90 }
91 args = append(args, t.inst.SSHUser()+"@"+t.inst.Host, "--", strings.Join(quoted, " "))
92
93 cmd := exec.Command("ssh", args...)
94 cmd.Stdin = stdin
95 cmd.Stdout = os.Stdout
96 cmd.Stderr = os.Stderr
97 err := cmd.Run()
98 if err == nil {
99 return 0
100 }
101 if ee, ok := err.(*exec.ExitError); ok {
102 code := ee.ExitCode()
103 if code == 255 { // ssh-level failure (connection, auth, host key)
104 return protocol.ExitProtocol
105 }
106 return code
107 }
108 fmt.Fprintln(os.Stderr, "forge: running ssh:", err)
109 return protocol.ExitProtocol
110}
111
112// withRepo prepends the repo path to args unless the user already gave one
113// explicitly (a first argument containing '/'). Commands' server parsers
114// accept the path at any position, so the front is always safe.
115func withRepo(t target, args []string) ([]string, error) {
116 if len(args) > 0 && !strings.HasPrefix(args[0], "-") && strings.Contains(args[0], "/") {
117 return args, nil // explicit owner/name
118 }
119 if t.repo == "" {
120 return nil, fmt.Errorf("no repository given and none inferable: pass <owner/name> or run inside a clone of a forge repository")
121 }
122 return append([]string{t.repo}, args...), nil
123}
e2e/cli_test.go added +205
@@ -0,0 +1,205 @@
1package e2e
2
3import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "testing"
11)
12
13func buildForgeCLI(t *testing.T) string {
14 t.Helper()
15 bin := filepath.Join(t.TempDir(), "forge")
16 cmd := exec.Command("go", "build", "-o", bin, "github.com/krazywarez/forge/cmd/forge")
17 cmd.Dir = ".."
18 if out, err := cmd.CombinedOutput(); err != nil {
19 t.Fatalf("build forge: %v\n%s", err, out)
20 }
21 return bin
22}
23
24// cli runs the forge binary with an isolated config home.
25type cli struct {
26 bin string
27 configDir string
28 inst *instance
29 key string
30}
31
32func (c *cli) run(t *testing.T, dir, stdin string, args ...string) (string, string, int) {
33 t.Helper()
34 cmd := exec.Command(c.bin, args...)
35 cmd.Dir = dir
36 cmd.Env = append(os.Environ(),
37 "XDG_CONFIG_HOME="+c.configDir,
38 "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
39 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
40 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test",
41 "EDITOR=", // no editor in tests: bodies come from flags
42 )
43 if stdin != "" {
44 cmd.Stdin = strings.NewReader(stdin)
45 }
46 var out, errOut strings.Builder
47 cmd.Stdout = &out
48 cmd.Stderr = &errOut
49 err := cmd.Run()
50 code := 0
51 if ee, ok := err.(*exec.ExitError); ok {
52 code = ee.ExitCode()
53 } else if err != nil {
54 t.Fatalf("forge %v: %v", args, err)
55 }
56 return out.String(), errOut.String(), code
57}
58
59func (c *cli) must(t *testing.T, dir, stdin string, args ...string) string {
60 t.Helper()
61 out, errOut, code := c.run(t, dir, stdin, args...)
62 if code != 0 {
63 t.Fatalf("forge %v: exit %d\nstdout: %s\nstderr: %s", args, code, out, errOut)
64 }
65 return out
66}
67
68func TestCLI(t *testing.T) {
69 inst := startInstance(t)
70 aliceKey := inst.newKey(t, "alice")
71 inst.admin(t, "admin", "user", "create", "alice",
72 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
73
74 c := &cli{
75 bin: buildForgeCLI(t),
76 configDir: t.TempDir(),
77 inst: inst,
78 key: aliceKey,
79 }
80
81 // Configure the instance, with ssh options so the test's throwaway key
82 // and known_hosts are used.
83 c.must(t, "", "", "remote", "add", "test", "127.0.0.1",
84 "--port", fmt.Sprint(inst.port),
85 "--ssh-option", "-i", "--ssh-option", aliceKey,
86 "--ssh-option", "-oIdentitiesOnly=yes",
87 "--ssh-option", "-oStrictHostKeyChecking=no",
88 "--ssh-option", "-oUserKnownHostsFile="+filepath.Join(inst.sshDir, "kh"),
89 "--ssh-option", "-oBatchMode=yes",
90 "--default")
91 if out := c.must(t, "", "", "remote", "list"); !strings.Contains(out, "test\tgit@127.0.0.1") || !strings.Contains(out, "(default)") {
92 t.Fatalf("remote list: %s", out)
93 }
94
95 // whoami through the CLI, JSON passthrough intact.
96 out := c.must(t, "", "", "auth", "whoami", "--json")
97 var env struct {
98 ProtocolVersion int `json:"protocol_version"`
99 Data struct {
100 Username string `json:"username"`
101 } `json:"data"`
102 }
103 if err := json.Unmarshal([]byte(out), &env); err != nil || env.Data.Username != "alice" || env.ProtocolVersion != 1 {
104 t.Fatalf("whoami via CLI: %v %s", err, out)
105 }
106
107 // Repo create + clone through the CLI.
108 c.must(t, "", "", "repo", "create", "alice/proj")
109 work := t.TempDir()
110 c.must(t, work, "", "repo", "clone", "alice/proj")
111 dir := filepath.Join(work, "proj")
112 if _, err := os.Stat(filepath.Join(dir, ".git")); err != nil {
113 t.Fatal("clone did not produce a repo")
114 }
115
116 // Push some content (plain git, using the clone's remote).
117 cliGitEnv := inst.gitEnv(aliceKey)
118 os.WriteFile(filepath.Join(dir, "README"), []byte("hi\n"), 0o644)
119 mustGit(t, dir, cliGitEnv, "checkout", "-q", "-b", "main")
120 mustGit(t, dir, cliGitEnv, "add", ".")
121 mustGit(t, dir, cliGitEnv, "commit", "-q", "-m", "init")
122 mustGit(t, dir, cliGitEnv, "push", "-q", "origin", "main")
123
124 // Inside the clone, the repo argument is inferred from origin.
125 c.must(t, dir, "", "issue", "create", "--title", "inferred repo works", "--body", "body")
126 out = c.must(t, dir, "", "issue", "list")
127 if !strings.Contains(out, "inferred repo works") {
128 t.Fatalf("issue list in clone: %s", out)
129 }
130 // Explicit owner/name still works from anywhere.
131 out = c.must(t, "", "", "issue", "show", "alice/proj", "1")
132 if !strings.Contains(out, "inferred repo works") {
133 t.Fatalf("issue show explicit: %s", out)
134 }
135 // Outside a clone with no explicit repo: usage error, not a hang.
136 _, errOut, code := c.run(t, "", "", "issue", "list")
137 if code != 2 || !strings.Contains(errOut, "none inferable") {
138 t.Fatalf("bare issue list outside clone: exit %d, %s", code, errOut)
139 }
140
141 // Exit codes pass through: missing issue is 3.
142 if _, _, code := c.run(t, dir, "", "issue", "show", "99"); code != 3 {
143 t.Fatalf("missing issue via CLI: exit %d, want 3", code)
144 }
145
146 // MR flow: branch, push, create (inferred), checkout, merge.
147 mustGit(t, dir, cliGitEnv, "checkout", "-q", "-b", "feature")
148 os.WriteFile(filepath.Join(dir, "f.txt"), []byte("feature\n"), 0o644)
149 mustGit(t, dir, cliGitEnv, "add", ".")
150 mustGit(t, dir, cliGitEnv, "commit", "-q", "-m", "feature work")
151 mustGit(t, dir, cliGitEnv, "push", "-q", "origin", "feature")
152 c.must(t, dir, "", "mr", "create", "--source", "feature", "--target", "main", "--title", "via cli")
153
154 // mr checkout uses the clone's own git; the MR ref comes from origin.
155 mustGit(t, dir, cliGitEnv, "checkout", "-q", "main")
156 cmd := exec.Command(c.bin, "mr", "checkout", "1")
157 cmd.Dir = dir
158 cmd.Env = append(cliGitEnv, "XDG_CONFIG_HOME="+c.configDir)
159 if out, err := cmd.CombinedOutput(); err != nil {
160 t.Fatalf("mr checkout: %v\n%s", err, out)
161 }
162 branch := strings.TrimSpace(mustGit(t, dir, cliGitEnv, "rev-parse", "--abbrev-ref", "HEAD"))
163 if branch != "mr/1" {
164 t.Fatalf("mr checkout branch = %s", branch)
165 }
166 if _, err := os.Stat(filepath.Join(dir, "f.txt")); err != nil {
167 t.Fatal("mr checkout content missing")
168 }
169
170 c.must(t, dir, "", "mr", "merge", "1")
171 out = c.must(t, dir, "", "mr", "show", "1")
172 if !strings.Contains(out, "merged") {
173 t.Fatalf("mr not merged: %s", out)
174 }
175
176 // keys add reads the public key from CLI stdin.
177 secondKey := inst.newKey(t, "alice2")
178 pub, _ := os.ReadFile(secondKey + ".pub")
179 c.must(t, "", string(pub), "auth", "keys", "add", "--scope", "git")
180 if out = c.must(t, "", "", "auth", "keys", "list"); len(strings.Split(strings.TrimSpace(out), "\n")) != 2 {
181 t.Fatalf("keys list: %s", out)
182 }
183
184 // forge init: new local project, repo created server-side, origin set.
185 proj := filepath.Join(t.TempDir(), "newthing")
186 os.MkdirAll(proj, 0o755)
187 c.must(t, proj, "", "init", "--private")
188 originOut := mustGit(t, proj, cliGitEnv, "remote", "get-url", "origin")
189 if !strings.Contains(originOut, "/alice/newthing.git") {
190 t.Fatalf("init origin: %s", originOut)
191 }
192 if out = c.must(t, "", "", "repo", "show", "alice/newthing"); !strings.Contains(out, "private") {
193 t.Fatalf("init-created repo: %s", out)
194 }
195
196 // Man pages and completions generate.
197 manDir := t.TempDir()
198 c.must(t, "", "", "man", "--dir", manDir)
199 if entries, _ := os.ReadDir(manDir); len(entries) < 10 {
200 t.Fatalf("man pages: only %d generated", len(entries))
201 }
202 if out = c.must(t, "", "", "completion", "zsh"); !strings.Contains(out, "compdef") {
203 t.Fatal("zsh completion missing")
204 }
205}
go.mod +4
@@ -9,11 +9,13 @@ require (
9 github.com/spf13/cobra v1.10.2 9 github.com/spf13/cobra v1.10.2
10 github.com/yuin/goldmark v1.8.5 10 github.com/yuin/goldmark v1.8.5
11 golang.org/x/crypto v0.55.0 11 golang.org/x/crypto v0.55.0
12 golang.org/x/term v0.45.0
12 modernc.org/sqlite v1.57.0 13 modernc.org/sqlite v1.57.0
13) 14)
14 15
15require ( 16require (
16 github.com/cloudflare/circl v1.6.2 // indirect 17 github.com/cloudflare/circl v1.6.2 // indirect
18 github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
17 github.com/dlclark/regexp2/v2 v2.2.1 // indirect 19 github.com/dlclark/regexp2/v2 v2.2.1 // indirect
18 github.com/dustin/go-humanize v1.0.1 // indirect 20 github.com/dustin/go-humanize v1.0.1 // indirect
19 github.com/google/uuid v1.6.0 // indirect 21 github.com/google/uuid v1.6.0 // indirect
@@ -21,7 +23,9 @@ require (
21 github.com/mattn/go-isatty v0.0.24 // indirect 23 github.com/mattn/go-isatty v0.0.24 // indirect
22 github.com/ncruces/go-strftime v1.0.0 // indirect 24 github.com/ncruces/go-strftime v1.0.0 // indirect
23 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 25 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
26 github.com/russross/blackfriday/v2 v2.1.0 // indirect
24 github.com/spf13/pflag v1.0.9 // indirect 27 github.com/spf13/pflag v1.0.9 // indirect
28 go.yaml.in/yaml/v3 v3.0.4 // indirect
25 golang.org/x/sys v0.47.0 // indirect 29 golang.org/x/sys v0.47.0 // indirect
26 modernc.org/libc v1.74.4 // indirect 30 modernc.org/libc v1.74.4 // indirect
27 modernc.org/mathutil v1.7.1 // indirect 31 modernc.org/mathutil v1.7.1 // indirect
go.sum +4
@@ -10,6 +10,7 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
10github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 10github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
11github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= 11github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
12github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= 12github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
13github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
13github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 14github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
14github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= 15github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
15github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= 16github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
@@ -31,6 +32,7 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
31github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 32github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
32github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 33github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
33github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 34github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
35github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
34github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 36github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
35github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= 37github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
36github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 38github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
@@ -38,6 +40,7 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
38github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 40github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
39github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= 41github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
40github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 42github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
43go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
41go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 44go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
42golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= 45golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
43golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= 46golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
@@ -51,6 +54,7 @@ golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
51golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= 54golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
52golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= 55golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
53golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= 56golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
57gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
54gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 58gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
55modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= 59modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
56modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= 60modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
internal/cliconfig/cliconfig.go added +120
@@ -0,0 +1,120 @@
1// Package cliconfig manages the client-side configuration: named forge
2// instances at ~/.config/forge/config.toml, and parsing of origin remote
3// URLs so commands run inside a clone need no --repo argument.
4package cliconfig
5
6import (
7 "fmt"
8 "os"
9 "path/filepath"
10 "regexp"
11 "strings"
12
13 "github.com/BurntSushi/toml"
14)
15
16type Instance struct {
17 Host string `toml:"host"`
18 Port int `toml:"port,omitempty"`
19 User string `toml:"user,omitempty"`
20 // SSHOptions are extra arguments passed to the ssh binary verbatim,
21 // e.g. ["-i", "~/.ssh/forge_ed25519"]. Most setups need none: the
22 // system ssh already honors ~/.ssh/config and the agent.
23 SSHOptions []string `toml:"ssh_options,omitempty"`
24}
25
26func (i Instance) SSHUser() string {
27 if i.User != "" {
28 return i.User
29 }
30 return "git"
31}
32
33// CloneURL returns the ssh:// URL for owner/name on this instance.
34func (i Instance) CloneURL(repo string) string {
35 hostport := i.Host
36 if i.Port != 0 && i.Port != 22 {
37 hostport = fmt.Sprintf("%s:%d", i.Host, i.Port)
38 }
39 return fmt.Sprintf("ssh://%s@%s/%s.git", i.SSHUser(), hostport, repo)
40}
41
42type Config struct {
43 Default string `toml:"default,omitempty"`
44 Instances map[string]Instance `toml:"instances"`
45}
46
47func Path() string {
48 if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
49 return filepath.Join(x, "forge", "config.toml")
50 }
51 home, _ := os.UserHomeDir()
52 return filepath.Join(home, ".config", "forge", "config.toml")
53}
54
55func Load() (Config, error) {
56 cfg := Config{Instances: map[string]Instance{}}
57 raw, err := os.ReadFile(Path())
58 if os.IsNotExist(err) {
59 return cfg, nil
60 }
61 if err != nil {
62 return cfg, err
63 }
64 if err := toml.Unmarshal(raw, &cfg); err != nil {
65 return cfg, fmt.Errorf("%s: %w", Path(), err)
66 }
67 if cfg.Instances == nil {
68 cfg.Instances = map[string]Instance{}
69 }
70 return cfg, nil
71}
72
73func Save(cfg Config) error {
74 p := Path()
75 if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
76 return err
77 }
78 var b strings.Builder
79 if err := toml.NewEncoder(&b).Encode(cfg); err != nil {
80 return err
81 }
82 return os.WriteFile(p, []byte(b.String()), 0o600)
83}
84
85// DefaultInstance returns the configured default (or the only) instance.
86func (c Config) DefaultInstance() (Instance, string, error) {
87 if c.Default != "" {
88 if inst, ok := c.Instances[c.Default]; ok {
89 return inst, c.Default, nil
90 }
91 return Instance{}, "", fmt.Errorf("default instance %q is not configured", c.Default)
92 }
93 if len(c.Instances) == 1 {
94 for name, inst := range c.Instances {
95 return inst, name, nil
96 }
97 }
98 return Instance{}, "", fmt.Errorf("no forge instance configured; run: forge remote add <name> <host>")
99}
100
101var (
102 sshURLPat = regexp.MustCompile(`^ssh://(?:([^@/]+)@)?([^:/]+)(?::(\d+))?/(.+?)(?:\.git)?/?$`)
103 scpPat = regexp.MustCompile(`^(?:([^@/]+)@)?([^:/]+):(.+?)(?:\.git)?$`)
104)
105
106// ParseRemoteURL extracts the instance coordinates and owner/name from a
107// git remote URL in ssh:// or scp-like form.
108func ParseRemoteURL(url string) (Instance, string, bool) {
109 if m := sshURLPat.FindStringSubmatch(url); m != nil {
110 inst := Instance{Host: m[2], User: m[1]}
111 if m[3] != "" {
112 fmt.Sscanf(m[3], "%d", &inst.Port)
113 }
114 return inst, strings.Trim(m[4], "/"), true
115 }
116 if m := scpPat.FindStringSubmatch(url); m != nil && !strings.Contains(url, "://") {
117 return Instance{Host: m[2], User: m[1]}, strings.Trim(m[3], "/"), true
118 }
119 return Instance{}, "", false
120}
internal/cliconfig/cliconfig_test.go added +46
@@ -0,0 +1,46 @@
1package cliconfig
2
3import "testing"
4
5func TestParseRemoteURL(t *testing.T) {
6 cases := []struct {
7 url string
8 host string
9 port int
10 user string
11 repo string
12 ok bool
13 }{
14 {"ssh://git@forge.example/alice/proj.git", "forge.example", 0, "git", "alice/proj", true},
15 {"ssh://git@forge.example:2222/alice/proj.git", "forge.example", 2222, "git", "alice/proj", true},
16 {"ssh://forge.example/alice/proj", "forge.example", 0, "", "alice/proj", true},
17 {"git@forge.example:alice/proj.git", "forge.example", 0, "git", "alice/proj", true},
18 {"git@forge.example:alice/proj", "forge.example", 0, "git", "alice/proj", true},
19 {"https://forge.example/alice/proj.git", "", 0, "", "", false},
20 {"/local/path/repo.git", "", 0, "", "", false},
21 }
22 for _, tc := range cases {
23 inst, repo, ok := ParseRemoteURL(tc.url)
24 if ok != tc.ok {
25 t.Errorf("%s: ok = %v, want %v", tc.url, ok, tc.ok)
26 continue
27 }
28 if !ok {
29 continue
30 }
31 if inst.Host != tc.host || inst.Port != tc.port || inst.User != tc.user || repo != tc.repo {
32 t.Errorf("%s: got host=%s port=%d user=%s repo=%s", tc.url, inst.Host, inst.Port, inst.User, repo)
33 }
34 }
35}
36
37func TestCloneURL(t *testing.T) {
38 i := Instance{Host: "forge.example"}
39 if got := i.CloneURL("a/b"); got != "ssh://git@forge.example/a/b.git" {
40 t.Errorf("CloneURL = %s", got)
41 }
42 i = Instance{Host: "forge.example", Port: 2222, User: "u"}
43 if got := i.CloneURL("a/b"); got != "ssh://u@forge.example:2222/a/b.git" {
44 t.Errorf("CloneURL = %s", got)
45 }
46}