Commit d6d78eea2f
Verified · cmc
Layout: unified · split
cmd/forge/local.go added +298
| @@ -0,0 +1,298 @@ | ||
| 1 | package main | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 17 | func 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. | |
| 30 | func 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 | ||
| 71 | func 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]`. | |
| 85 | func 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. | |
| 104 | func 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. | |
| 119 | func 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. | |
| 184 | func 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 | ||
| 205 | func 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]`. | |
| 214 | func 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 | ||
| 280 | func 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. | |
| 4 | 5 | package main |
| 5 | 6 | |
| 6 | 7 | import ( |
| 7 | 8 | "fmt" |
| 9 | "io" | |
| 8 | 10 | "os" |
| 11 | "strings" | |
| 9 | 12 | |
| 10 | 13 | "github.com/spf13/cobra" |
| 14 | "github.com/spf13/cobra/doc" | |
| 11 | 15 | |
| 12 | 16 | "github.com/krazywarez/forge/internal/protocol" |
| 13 | 17 | ) |
| @@ -19,8 +23,6 @@ func main() { | ||
| 19 | 23 | SilenceUsage: true, |
| 20 | 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 | 27 | root.AddCommand( |
| 26 | 28 | authCmd(), |
| @@ -28,116 +30,248 @@ func main() { | ||
| 28 | 30 | issueCmd(), |
| 29 | 31 | mrCmd(), |
| 30 | 32 | webCmd(), |
| 31 | adminCmd(), | |
| 32 | 33 | remoteCmd(), |
| 33 | 34 | initCmd(), |
| 35 | manCmd(root), | |
| 34 | 36 | ) |
| 35 | 37 | |
| 36 | 38 | if err := root.Execute(); err != nil { |
| 37 | 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. | |
| 43 | func stub(use, short string) *cobra.Command { | |
| 44 | // passOpts describes how one CLI command maps onto the server command. | |
| 45 | type 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. | |
| 54 | func pass(use, short string, o passOpts) *cobra.Command { | |
| 44 | 55 | return &cobra.Command{ |
| 45 | Use: use, | |
| 46 | Short: short, | |
| 56 | Use: use, | |
| 57 | Short: short, | |
| 58 | DisableFlagParsing: true, | |
| 47 | 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 | |
| 72 | func 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 | ||
| 109 | func 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. | |
| 115 | func 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 | ||
| 53 | 124 | func group(use, short string, subs ...*cobra.Command) *cobra.Command { |
| 54 | 125 | c := &cobra.Command{Use: use, Short: short} |
| 55 | 126 | c.AddCommand(subs...) |
| 56 | 127 | return c |
| 57 | 128 | } |
| 58 | 129 | |
| 130 | // local wraps a locally-implemented command (git plumbing, config). | |
| 131 | func 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 | ||
| 59 | 148 | func 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"}}), | |
| 62 | 174 | 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"}}), | |
| 66 | 178 | ), |
| 67 | 179 | 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"}}), | |
| 75 | 183 | ), |
| 76 | 184 | ) |
| 77 | 185 | } |
| 78 | 186 | |
| 79 | 187 | func repoCmd() *cobra.Command { |
| 80 | 188 | 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 | ), | |
| 90 | 209 | ) |
| 91 | 210 | } |
| 92 | 211 | |
| 93 | 212 | func issueCmd() *cobra.Command { |
| 94 | 213 | 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}), | |
| 103 | 224 | ) |
| 104 | 225 | } |
| 105 | 226 | |
| 106 | 227 | func mrCmd() *cobra.Command { |
| 107 | 228 | 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}), | |
| 117 | 239 | ) |
| 118 | 240 | } |
| 119 | 241 | |
| 120 | 242 | func webCmd() *cobra.Command { |
| 121 | 243 | return group("web", "browser session", |
| 122 | stub("login", "mint a one-time browser login URL over ssh"), | |
| 123 | ) | |
| 124 | } | |
| 125 | ||
| 126 | func 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"}}), | |
| 131 | 245 | ) |
| 132 | 246 | } |
| 133 | 247 | |
| 134 | 248 | func remoteCmd() *cobra.Command { |
| 135 | 249 | 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() }), | |
| 138 | 253 | ) |
| 139 | 254 | } |
| 140 | 255 | |
| 141 | 256 | func 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. | |
| 262 | func 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 @@ | ||
| 1 | package main | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 18 | type 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. | |
| 26 | func 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 | ||
| 58 | func 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. | |
| 68 | var 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. | |
| 72 | func 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. | |
| 81 | func 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. | |
| 115 | func 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 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "encoding/json" | |
| 5 | "fmt" | |
| 6 | "os" | |
| 7 | "os/exec" | |
| 8 | "path/filepath" | |
| 9 | "strings" | |
| 10 | "testing" | |
| 11 | ) | |
| 12 | ||
| 13 | func 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. | |
| 25 | type cli struct { | |
| 26 | bin string | |
| 27 | configDir string | |
| 28 | inst *instance | |
| 29 | key string | |
| 30 | } | |
| 31 | ||
| 32 | func (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 | ||
| 59 | func (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 | ||
| 68 | func 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 | 9 | github.com/spf13/cobra v1.10.2 |
| 10 | 10 | github.com/yuin/goldmark v1.8.5 |
| 11 | 11 | golang.org/x/crypto v0.55.0 |
| 12 | golang.org/x/term v0.45.0 | |
| 12 | 13 | modernc.org/sqlite v1.57.0 |
| 13 | 14 | ) |
| 14 | 15 | |
| 15 | 16 | require ( |
| 16 | 17 | github.com/cloudflare/circl v1.6.2 // indirect |
| 18 | github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect | |
| 17 | 19 | github.com/dlclark/regexp2/v2 v2.2.1 // indirect |
| 18 | 20 | github.com/dustin/go-humanize v1.0.1 // indirect |
| 19 | 21 | github.com/google/uuid v1.6.0 // indirect |
| @@ -21,7 +23,9 @@ require ( | ||
| 21 | 23 | github.com/mattn/go-isatty v0.0.24 // indirect |
| 22 | 24 | github.com/ncruces/go-strftime v1.0.0 // indirect |
| 23 | 25 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| 26 | github.com/russross/blackfriday/v2 v2.1.0 // indirect | |
| 24 | 27 | github.com/spf13/pflag v1.0.9 // indirect |
| 28 | go.yaml.in/yaml/v3 v3.0.4 // indirect | |
| 25 | 29 | golang.org/x/sys v0.47.0 // indirect |
| 26 | 30 | modernc.org/libc v1.74.4 // indirect |
| 27 | 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 | ||
| 10 | 10 | github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= |
| 11 | 11 | github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= |
| 12 | 12 | github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= |
| 13 | github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= | |
| 13 | 14 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= |
| 14 | 15 | github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= |
| 15 | 16 | github.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 | ||
| 31 | 32 | github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
| 32 | 33 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| 33 | 34 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| 35 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= | |
| 34 | 36 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= |
| 35 | 37 | github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= |
| 36 | 38 | github.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= | ||
| 38 | 40 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= |
| 39 | 41 | github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= |
| 40 | 42 | github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= |
| 43 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= | |
| 41 | 44 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= |
| 42 | 45 | golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= |
| 43 | 46 | golang.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= | ||
| 51 | 54 | golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= |
| 52 | 55 | golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= |
| 53 | 56 | golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= |
| 57 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= | |
| 54 | 58 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
| 55 | 59 | modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= |
| 56 | 60 | modernc.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. | |
| 4 | package cliconfig | |
| 5 | ||
| 6 | import ( | |
| 7 | "fmt" | |
| 8 | "os" | |
| 9 | "path/filepath" | |
| 10 | "regexp" | |
| 11 | "strings" | |
| 12 | ||
| 13 | "github.com/BurntSushi/toml" | |
| 14 | ) | |
| 15 | ||
| 16 | type 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 | ||
| 26 | func (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. | |
| 34 | func (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 | ||
| 42 | type Config struct { | |
| 43 | Default string `toml:"default,omitempty"` | |
| 44 | Instances map[string]Instance `toml:"instances"` | |
| 45 | } | |
| 46 | ||
| 47 | func 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 | ||
| 55 | func 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 | ||
| 73 | func 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. | |
| 86 | func (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 | ||
| 101 | var ( | |
| 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. | |
| 108 | func 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 @@ | ||
| 1 | package cliconfig | |
| 2 | ||
| 3 | import "testing" | |
| 4 | ||
| 5 | func 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 | ||
| 37 | func 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 | } | |