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
2// binary; it is ergonomics on top of a control plane that is fully usable
3// from bare OpenSSH.
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.
45package main
56
67import (
78 "fmt"
9 "io"
810 "os"
11 "strings"
912
1013 "github.com/spf13/cobra"
14 "github.com/spf13/cobra/doc"
1115
1216 "github.com/krazywarez/forge/internal/protocol"
1317)
@@ -19,8 +23,6 @@ func main() {
1923 SilenceUsage: true,
2024 SilenceErrors: true,
2125 }
22 root.PersistentFlags().Bool("json", false, "machine-readable output")
23 root.PersistentFlags().String("repo", "", "owner/name (default: inferred from the origin remote)")
2426
2527 root.AddCommand(
2628 authCmd(),
@@ -28,116 +30,248 @@ func main() {
2830 issueCmd(),
2931 mrCmd(),
3032 webCmd(),
31 adminCmd(),
3233 remoteCmd(),
3334 initCmd(),
35 manCmd(root),
3436 )
3537
3638 if err := root.Execute(); err != nil {
3739 fmt.Fprintln(os.Stderr, "forge:", err)
38 os.Exit(protocol.ExitFailure)
40 os.Exit(protocol.ExitUsage)
3941 }
4042}
4143
42// stub returns a leaf command that fails until its milestone lands.
43func stub(use, short string) *cobra.Command {
44// passOpts describes how one CLI command maps onto the server 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 {
4455 return &cobra.Command{
45 Use: use,
46 Short: short,
56 Use: use,
57 Short: short,
58 DisableFlagParsing: true,
4759 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
4968 },
5069 }
5170}
5271
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
53124func group(use, short string, subs ...*cobra.Command) *cobra.Command {
54125 c := &cobra.Command{Use: use, Short: short}
55126 c.AddCommand(subs...)
56127 return c
57128}
58129
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
59148func authCmd() *cobra.Command {
60 return group("auth", "identity: keys, emails, whoami",
61 stub("whoami", "show the authenticated account"),
149 keysAdd := pass("add", "register an SSH public key (reads the key from stdin or --file -)",
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"}}),
62174 group("keys", "manage SSH keys",
63 stub("list", "list registered SSH keys"),
64 stub("add", "register an SSH key"),
65 stub("remove", "remove an SSH key"),
175 pass("list", "list registered SSH keys", passOpts{server: []string{"keys", "list"}}),
176 keysAdd,
177 pass("remove", "remove an SSH key by fingerprint", passOpts{server: []string{"keys", "remove"}}),
66178 ),
67179 group("pgp", "manage OpenPGP keys",
68 stub("list", "list registered PGP keys"),
69 stub("add", "register a PGP key"),
70 stub("remove", "remove a PGP key"),
71 ),
72 group("email", "manage email addresses",
73 stub("add", "add an address"),
74 stub("verify", "confirm a verification code"),
180 pass("list", "list registered PGP keys", passOpts{server: []string{"pgp", "list"}}),
181 pgpAdd,
182 pass("remove", "remove a PGP key by fingerprint", passOpts{server: []string{"pgp", "remove"}}),
75183 ),
76184 )
77185}
78186
79187func repoCmd() *cobra.Command {
80188 return group("repo", "create and manage repositories",
81 stub("create", "create a repository"),
82 stub("list", "list repositories"),
83 stub("show", "show repository details"),
84 stub("clone", "clone via ssh"),
85 stub("rename", "rename a repository"),
86 stub("delete", "delete a repository"),
87 stub("fork", "fork a repository"),
88 stub("import", "server-side mirror from a foreign URL"),
89 stub("settings", "get or set repository settings"),
189 pass("create", "create a repository: forge repo create <owner/name> [--private]",
190 passOpts{server: []string{"repo", "create"}}),
191 pass("list", "list repositories you own or can access", passOpts{server: []string{"repo", "list"}}),
192 pass("show", "show repository details", passOpts{server: []string{"repo", "show"}, needsRepo: true}),
193 pass("log", "commit log with signature states", passOpts{server: []string{"repo", "log"}, needsRepo: true}),
194 pass("delete", "delete a repository (--yes)", passOpts{server: []string{"repo", "delete"}, needsRepo: true}),
195 pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}),
196 local("clone", "clone via ssh: forge repo clone <owner/name> [dir]", cmdRepoClone),
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 ),
90209 )
91210}
92211
93212func issueCmd() *cobra.Command {
94213 return group("issue", "issues",
95 stub("create", "open an issue"),
96 stub("list", "list issues"),
97 stub("show", "show an issue"),
98 stub("comment", "comment on an issue"),
99 stub("close", "close an issue"),
100 stub("reopen", "reopen an issue"),
101 stub("label", "add or remove labels"),
102 stub("assign", "assign users"),
214 pass("create", "open an issue: --title <t> [--body|--file -|$EDITOR]",
215 passOpts{server: []string{"issue", "create"}, needsRepo: true, stdinOK: true, editor: "issue"}),
216 pass("list", "list issues [--state open|closed|all]", passOpts{server: []string{"issue", "list"}, needsRepo: true}),
217 pass("show", "show an issue with comments", passOpts{server: []string{"issue", "show"}, needsRepo: true}),
218 pass("comment", "comment on an issue [--message|--file -|$EDITOR]",
219 passOpts{server: []string{"issue", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
220 pass("close", "close an issue", passOpts{server: []string{"issue", "close"}, needsRepo: true}),
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}),
103224 )
104225}
105226
106227func mrCmd() *cobra.Command {
107228 return group("mr", "merge requests",
108 stub("create", "open a merge request"),
109 stub("list", "list merge requests"),
110 stub("show", "show a merge request"),
111 stub("diff", "show the diff"),
112 stub("checkout", "fetch and check out the MR head locally"),
113 stub("comment", "comment on a merge request"),
114 stub("review", "approve or request changes"),
115 stub("merge", "merge (fast-forward or merge-commit)"),
116 stub("close", "close without merging"),
229 pass("create", "open a merge request: --source <branch> --target <branch> --title <t>",
230 passOpts{server: []string{"mr", "create"}, needsRepo: true, stdinOK: true, editor: "merge request"}),
231 pass("list", "list merge requests [--state ...]", passOpts{server: []string{"mr", "list"}, needsRepo: true}),
232 pass("show", "show a merge request", passOpts{server: []string{"mr", "show"}, needsRepo: true}),
233 pass("diff", "show the diff", passOpts{server: []string{"mr", "diff"}, needsRepo: true}),
234 local("checkout", "fetch and check out the MR head locally: forge mr checkout <n>", cmdMRCheckout),
235 pass("comment", "comment on a merge request", passOpts{server: []string{"mr", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
236 pass("review", "review: --approve|--request-changes|--comment", passOpts{server: []string{"mr", "review"}, needsRepo: true}),
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}),
117239 )
118240}
119241
120242func webCmd() *cobra.Command {
121243 return group("web", "browser session",
122 stub("login", "mint a one-time browser login URL over ssh"),
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"),
244 pass("login", "mint a one-time browser login URL over ssh", passOpts{server: []string{"web", "login"}}),
131245 )
132246}
133247
134248func remoteCmd() *cobra.Command {
135249 return group("remote", "local instance profiles (no server contact)",
136 stub("add", "add a named forge instance"),
137 stub("list", "list configured instances"),
250 local("add", "add a named forge instance: forge remote add <name> <host> [--port n] [--user u] [--ssh-option o]... [--default]",
251 cmdRemoteAdd),
252 local("list", "list configured instances", func([]string) int { return cmdRemoteList() }),
138253 )
139254}
140255
141256func 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
143277}
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 (
99 github.com/spf13/cobra v1.10.2
1010 github.com/yuin/goldmark v1.8.5
1111 golang.org/x/crypto v0.55.0
12 golang.org/x/term v0.45.0
1213 modernc.org/sqlite v1.57.0
1314)
1415
1516require (
1617 github.com/cloudflare/circl v1.6.2 // indirect
18 github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
1719 github.com/dlclark/regexp2/v2 v2.2.1 // indirect
1820 github.com/dustin/go-humanize v1.0.1 // indirect
1921 github.com/google/uuid v1.6.0 // indirect
@@ -21,7 +23,9 @@ require (
2123 github.com/mattn/go-isatty v0.0.24 // indirect
2224 github.com/ncruces/go-strftime v1.0.0 // indirect
2325 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
26 github.com/russross/blackfriday/v2 v2.1.0 // indirect
2427 github.com/spf13/pflag v1.0.9 // indirect
28 go.yaml.in/yaml/v3 v3.0.4 // indirect
2529 golang.org/x/sys v0.47.0 // indirect
2630 modernc.org/libc v1.74.4 // indirect
2731 modernc.org/mathutil v1.7.1 // indirect
go.sum +4
@@ -10,6 +10,7 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
1010github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
1111github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
1212github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
13github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
1314github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
1415github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
1516github.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
3132github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
3233github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
3334github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
35github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
3436github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
3537github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
3638github.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=
3840github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
3941github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
4042github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
43go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
4144go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
4245golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
4346golang.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=
5154golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
5255golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
5356golang.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=
5458gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
5559modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
5660modernc.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}