package main import ( "errors" "fmt" "math/rand" "os" "os/user" "path" "path/filepath" "time" "git.tilde.town/tildetown/town/email" tuser "git.tilde.town/tildetown/town/user" "github.com/AlecAivazis/survey/v2" "github.com/spf13/cobra" "gopkg.in/yaml.v3" ) const ( contribRequestPath = "/town/requests/contrib" submitOp = "submit" delOp = "del" updateOp = "update" ) type contribOpts struct { CmdName string Force bool // instantly install, do not make a request Operation string // submit, delete, update ExecPath string } type contrib struct { CmdName string ExecPath string Maintainer string MaintainerUID string Category string ShortDesc string LongDesc string Comments string } func runContrib(opts *contribOpts) error { var action func(*contribOpts) error switch opts.Operation { case submitOp: action = submit case delOp: action = delete case updateOp: action = update } return action(opts) } func validExec(execPath string) error { if !path.IsAbs(execPath) { return fmt.Errorf("'%s' needs to be an absolute path", execPath) } fi, err := os.Stat(execPath) if err != nil { return fmt.Errorf("could not stat '%s'; does it exist? error: %w", execPath, err) } if fi.Mode()&0001 == 0 { return fmt.Errorf("'%s' is not executable", execPath) } return nil } func submit(opts *contribOpts) error { rand.Seed(time.Now().UTC().UnixNano()) var cmdName string var category string var shortDesc string var longDesc string var comments string u, err := user.Current() if err != nil { return fmt.Errorf("that's my purse. I don't know you! %w", err) } // TODO should commands be markable as for admins only? i feel like that can be a parallel, more manual system. isAdmin, err := tuser.IsAdmin(u) if err != nil { return fmt.Errorf("that's my purse. I don't know you! %w", err) } if opts.Force && !isAdmin { return errors.New("must be admin to use --force") } if err := validExec(opts.ExecPath); err != nil { return err } _, defaultName := filepath.Split(opts.ExecPath) if err := survey.AskOne(&survey.Input{ Message: "what is the command's name?", Help: "for example if it's called 'cats' it will be invoked when users run 'town cats'", Default: defaultName}, &cmdName); err != nil { return err } categories := []string{ "art", "social", "game", "utility", "programming", "community", "misc", } var choice int if err := survey.AskOne(&survey.Select{ Message: "what category best describes this command?", Options: categories, }, &choice); err != nil { return err } category = categories[choice] if err := survey.AskOne(&survey.Input{ Message: "in one brief sentence, what does this command do?", }, &shortDesc); err != nil { return err } if err := survey.AskOne(&survey.Editor{ Message: "yr editor is gonna open. in there please write a long description for this command. not like, novel long, but maybe a sentence or two and some examples."}, &longDesc); err != nil { return nil } if err := survey.AskOne(&survey.Multiline{ Message: "any comments for the admins? this won't be public, it's just to give admins any additional context about this command.", }, &comments); err != nil { return nil } // TODO be able to set a maintainer other than caller. this might only be if an admin. // TODO would be fun if it was a Select using a user list -- extract that from stats cmd c := contrib{ CmdName: cmdName, Category: category, ShortDesc: shortDesc, LongDesc: longDesc, ExecPath: opts.ExecPath, // for later validation against file owner Maintainer: u.Username, MaintainerUID: u.Uid, Comments: comments, } bs, err := yaml.Marshal(c) if err != nil { return fmt.Errorf("failed to serialize contrib: %w", err) } fname := fmt.Sprintf("%d.yml", time.Now().Unix()) f, err := os.Create(path.Join(contribRequestPath, fname)) if err != nil { return fmt.Errorf("failed to open contrib file for writing: %w", err) } if _, err = f.Write(bs); err != nil { return fmt.Errorf("failed to write contrib file: %w", err) } err = email.SendLocalEmail("vilmibm", fmt.Sprintf("contrib: %s", cmdName), string(bs)) if err != nil { return fmt.Errorf("could not email vilmibm. ping them on IRC or something. the error was: %w", err) } fmt.Printf("submitted %s for review! thank you~\n", cmdName) return nil } func update(opts *contribOpts) error { return nil } func delete(opts *contribOpts) error { return nil } func rootCmd() *cobra.Command { var updateName string var delName string var force bool rc := &cobra.Command{ Use: "contrib [path to executable]", Short: "Submit new commands to the town launcher", RunE: func(cmd *cobra.Command, args []string) error { // submit - requires path arg // update - requres path arg // delete - no arg needed if updateName != "" && delName != "" { return errors.New("-u and -d are mutually exclusive") } var cmdName string operation := "submit" if updateName != "" { operation = "update" cmdName = updateName } else if delName != "" { operation = "delete" cmdName = delName } if (operation == "update" || operation == "submit") && len(args) == 0 { return errors.New("path to executable required when submitting or updating") } if operation == "delete" && len(args) > 0 { return fmt.Errorf("no arguments expected when deleting; got %d", len(args)) } if len(args) > 1 { return fmt.Errorf("at most one argument is accepted; got %d", len(args)) } var execPath string if len(args) == 1 { execPath = args[0] } opts := &contribOpts{ Operation: operation, Force: force, ExecPath: execPath, CmdName: cmdName, } return runContrib(opts) }, // TODO longer example } rc.Flags().StringVarP(&updateName, "update", "u", "", "Name of command to update") rc.Flags().StringVarP(&delName, "delete", "d", "", "Name of command to delete") // TODO hiding this until i decide i want it or not //rc.Flags().BoolVarP(&force, "force", "f", false, "skip request, just install the command") return rc } func main() { if err := rootCmd().Execute(); err != nil { fmt.Fprintln(os.Stderr, err.Error()) os.Exit(1) } }