1package cmd
2
3import (
4 "fmt"
5 "net/url"
6 "strings"
7 "text/template"
8 "unicode"
9
10 "github.com/charmbracelet/soft-serve/server/access"
11 "github.com/charmbracelet/soft-serve/server/backend"
12 "github.com/charmbracelet/soft-serve/server/config"
13 "github.com/charmbracelet/soft-serve/server/proto"
14 "github.com/charmbracelet/soft-serve/server/sshutils"
15 "github.com/charmbracelet/soft-serve/server/utils"
16 "github.com/charmbracelet/ssh"
17 "github.com/prometheus/client_golang/prometheus"
18 "github.com/prometheus/client_golang/prometheus/promauto"
19 "github.com/spf13/cobra"
20)
21
22var cliCommandCounter = promauto.NewCounterVec(prometheus.CounterOpts{
23 Namespace: "soft_serve",
24 Subsystem: "cli",
25 Name: "commands_total",
26 Help: "Total times each command was called",
27}, []string{"command"})
28
29var templateFuncs = template.FuncMap{
30 "trim": strings.TrimSpace,
31 "trimRightSpace": trimRightSpace,
32 "trimTrailingWhitespaces": trimRightSpace,
33 "rpad": rpad,
34 "gt": cobra.Gt,
35 "eq": cobra.Eq,
36}
37
38const (
39 usageTmpl = `Usage:{{if .Runnable}}
40 {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
41 {{.SSHCommand}}{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}
42
43Aliases:
44 {{.NameAndAliases}}{{end}}{{if .HasExample}}
45
46Examples:
47{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
48
49Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
50 {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
51
52{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
53 {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
54
55Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
56 {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
57
58Flags:
59{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
60
61Global Flags:
62{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
63
64Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
65 {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
66
67Use "{{.SSHCommand}}{{.CommandPath}} [command] --help" for more information about a command.{{end}}
68`
69)
70
71func trimRightSpace(s string) string {
72 return strings.TrimRightFunc(s, unicode.IsSpace)
73}
74
75// rpad adds padding to the right of a string.
76func rpad(s string, padding int) string {
77 template := fmt.Sprintf("%%-%ds", padding)
78 return fmt.Sprintf(template, s)
79}
80
81func cmdName(args []string) string {
82 if len(args) == 0 {
83 return ""
84 }
85 return args[0]
86}
87
88// RootCommand returns a new cli root command.
89func RootCommand(s ssh.Session) *cobra.Command {
90 ctx := s.Context()
91 cfg := config.FromContext(ctx)
92
93 args := s.Command()
94 cliCommandCounter.WithLabelValues(cmdName(args)).Inc()
95 rootCmd := &cobra.Command{
96 Short: "Soft Serve is a self-hostable Git server for the command line.",
97 SilenceUsage: true,
98 }
99
100 hostname := "localhost"
101 port := "23231"
102 url, err := url.Parse(cfg.SSH.PublicURL)
103 if err == nil {
104 hostname = url.Hostname()
105 port = url.Port()
106 }
107
108 sshCmd := "ssh"
109 if port != "" && port != "22" {
110 sshCmd += " -p " + port
111 }
112
113 sshCmd += " " + hostname
114 rootCmd.SetUsageTemplate(usageTmpl)
115 rootCmd.SetUsageFunc(func(c *cobra.Command) error {
116 t := template.New("usage")
117 t.Funcs(templateFuncs)
118 template.Must(t.Parse(c.UsageTemplate()))
119 return t.Execute(c.OutOrStderr(), struct {
120 *cobra.Command
121 SSHCommand string
122 }{
123 Command: c,
124 SSHCommand: sshCmd,
125 })
126 })
127 rootCmd.CompletionOptions.DisableDefaultCmd = true
128 rootCmd.AddCommand(
129 repoCommand(),
130 )
131
132 rootCmd.SetArgs(args)
133 if len(args) == 0 {
134 // otherwise it'll default to os.Args, which is not what we want.
135 rootCmd.SetArgs([]string{"--help"})
136 }
137 rootCmd.SetIn(s)
138 rootCmd.SetOut(s)
139 rootCmd.CompletionOptions.DisableDefaultCmd = true
140 rootCmd.SetErr(s.Stderr())
141
142 user := proto.UserFromContext(ctx)
143 isAdmin := isPublicKeyAdmin(cfg, s.PublicKey()) || (user != nil && user.IsAdmin())
144 if user != nil || isAdmin {
145 if isAdmin {
146 rootCmd.AddCommand(
147 settingsCommand(),
148 userCommand(),
149 )
150 }
151
152 rootCmd.AddCommand(
153 infoCommand(),
154 pubkeyCommand(),
155 setUsernameCommand(),
156 jwtCommand(),
157 )
158 }
159
160 return rootCmd
161}
162
163func checkIfReadable(cmd *cobra.Command, args []string) error {
164 var repo string
165 if len(args) > 0 {
166 repo = args[0]
167 }
168
169 ctx := cmd.Context()
170 be := backend.FromContext(ctx)
171 rn := utils.SanitizeRepo(repo)
172 user := proto.UserFromContext(ctx)
173 auth := be.AccessLevelForUser(cmd.Context(), rn, user)
174 if auth < access.ReadOnlyAccess {
175 return proto.ErrUnauthorized
176 }
177 return nil
178}
179
180func isPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool {
181 for _, k := range cfg.AdminKeys() {
182 if sshutils.KeysEqual(pk, k) {
183 return true
184 }
185 }
186 return false
187}
188
189func checkIfAdmin(cmd *cobra.Command, _ []string) error {
190 ctx := cmd.Context()
191 cfg := config.FromContext(ctx)
192 pk := sshutils.PublicKeyFromContext(ctx)
193 if isPublicKeyAdmin(cfg, pk) {
194 return nil
195 }
196
197 user := proto.UserFromContext(ctx)
198 if user == nil {
199 return proto.ErrUnauthorized
200 }
201
202 if !user.IsAdmin() {
203 return proto.ErrUnauthorized
204 }
205
206 return nil
207}
208
209func checkIfCollab(cmd *cobra.Command, args []string) error {
210 var repo string
211 if len(args) > 0 {
212 repo = args[0]
213 }
214
215 ctx := cmd.Context()
216 be := backend.FromContext(ctx)
217 rn := utils.SanitizeRepo(repo)
218 user := proto.UserFromContext(ctx)
219 auth := be.AccessLevelForUser(cmd.Context(), rn, user)
220 if auth < access.ReadWriteAccess {
221 return proto.ErrUnauthorized
222 }
223 return nil
224}