From 1bdd9249fcce354c69739b126d4bcd78be83f433 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Sat, 30 Jul 2022 13:45:37 +0000 Subject: [PATCH] copy paste launcher --- cmd/launcher/README.md | 50 ++++++++++++ cmd/launcher/main.go | 174 +++++++++++++++++++++++++++++++++++++++++ go.mod | 5 ++ go.sum | 13 +++ 4 files changed, 242 insertions(+) create mode 100644 cmd/launcher/README.md create mode 100644 cmd/launcher/main.go diff --git a/cmd/launcher/README.md b/cmd/launcher/README.md new file mode 100644 index 0000000..cca70ca --- /dev/null +++ b/cmd/launcher/README.md @@ -0,0 +1,50 @@ +This is an implementation of an idea we discussed a while ago; a launcher for town-specific +commands. + +The idea is to put town commands in one of three places: + +- /town/launcher/core +- /town/launcher/contrib +- /town/launcher/admin + +and pair each command with a corresponding .yml file. + +For example, the `aup` command is a simple wrapper around elinks that opens our code of conduct. I +put the executable `aup` in /town/launcher/core and matched it with /town/launcher/aup.yml. The +purpose of the yaml file is to provide documentation for your executable, so `aup.yml` looks like: + +```yaml +shortDesc: View the town's Acceptable Use Policy +longDesc: | + This command will open our code of conduct, a type of document that evokes the Acceptable Use + Policies that governed servers like this in the past. It will open the elinks browser to a + page on the wiki. +examples: | + $ town aup # open the aup + $ town aup --rainbow # open the aup with rainbow colors +maintainer: vilmibm +``` + + and using the launcher is like: + + $ town aup + $ town aup --rainbow + $ town writo + $ town admin ban vilmibm + +You can see all the commands with `town help` as well as their descriptions; `town help +aup` would show you the docs from `aup.yml`. + +I'd love feedback on this approach while I wrap up this implementation. I can put it up on +git.tilde.town if anyone desires to collaborate (and let me know if you want a git.tilde.town +account). + +Remaining TODOs: + +- [ ] make tab completion available for common shells +- [ ] document / script submitting a tool for inclusion in contrib +- [x] make little wrappers for things like `mail` and `chat` +- [x] fix arg passing +- [x] test with a command that makes use of stdin/stdout +- [x] add all existing commands to the buckets +- [x] add to users' paths diff --git a/cmd/launcher/main.go b/cmd/launcher/main.go new file mode 100644 index 0000000..90f4dd6 --- /dev/null +++ b/cmd/launcher/main.go @@ -0,0 +1,174 @@ +package main + +import ( + "fmt" + "io/ioutil" + "os" + "os/exec" + "os/user" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +const binroot = "/town/commands" + +var rootCmd = &cobra.Command{ + Use: "town", + Short: "Run commands unique to tilde.town", +} + +var adminCmd = &cobra.Command{ + Use: "admin", + Short: "Run administrative commands", +} + +func isAdmin() (bool, error) { + u, err := user.Current() + if err != nil { + return false, fmt.Errorf("failed to get information about current user: %w", err) + } + + adminGroup, err := user.LookupGroup("admin") + if err != nil { + return false, fmt.Errorf("failed to get admin group info: %w", err) + } + + groupIds, err := u.GroupIds() + if err != nil { + return false, fmt.Errorf("failed to get groups info: %w", err) + } + + for _, groupId := range groupIds { + if groupId == adminGroup.Gid { + return true, nil + } + } + + return false, nil +} + +func parseCommands(targetCmd *cobra.Command, path string) error { + binPath := filepath.Join(binroot, path) + files, err := ioutil.ReadDir(binPath) + if err != nil { + return fmt.Errorf("failed to list directory %s: %s", binPath, err) + } + + for _, file := range files { + if strings.HasSuffix(file.Name(), "yml") { + parseCommand(targetCmd, filepath.Join(binPath, file.Name())) + } + } + + return nil +} + +type commandDoc struct { + ShortDesc string `yaml:"shortDesc"` + LongDesc string `yaml:"longDesc"` + Examples string + Maintainer string +} + +func parseCommand(targetCmd *cobra.Command, yamlPath string) { + executablePath := strings.TrimSuffix(yamlPath, ".yml") + + // TODO handle when files lack executable bit + _, err := os.Stat(executablePath) + + if err != nil { + fmt.Fprintf(os.Stderr, "could not find matching executable for %s; skipping...\n", yamlPath) + return + } + + yamlBytes, err := ioutil.ReadFile(yamlPath) + if err != nil { + fmt.Fprintf(os.Stderr, "could not read %s; skipping...\n", yamlPath) + return + } + + doc := commandDoc{} + err = yaml.Unmarshal(yamlBytes, &doc) + if err != nil { + fmt.Fprintf(os.Stderr, "could not parse %s; skipping...\n", yamlPath) + return + } + + if doc.Maintainer == "" { + fmt.Fprintf(os.Stderr, "%s is missing maintainer field; skipping...\n", yamlPath) + return + } + + parsedCmd := &cobra.Command{ + Use: filepath.Base(executablePath), + RunE: execWrapper(executablePath), + DisableFlagParsing: true, + } + if doc.ShortDesc != "" { + parsedCmd.Short = doc.ShortDesc + } + + if doc.LongDesc != "" { + parsedCmd.Long = doc.LongDesc + } + + parsedCmd.Long += fmt.Sprintf("\nMaintained by %s; reach out to them via mail or chat with questions", doc.Maintainer) + + if doc.Examples != "" { + parsedCmd.Example = doc.Examples + } + + targetCmd.AddCommand(parsedCmd) +} + +func execWrapper(executablePath string) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + execCmd := exec.Command(executablePath, args...) + execCmd.Stderr = os.Stderr + execCmd.Stdout = os.Stdout + execCmd.Stdin = os.Stdin + return execCmd.Run() + } +} + +func cli() int { + err := parseCommands(rootCmd, "core") + if err != nil { + fmt.Fprintf(os.Stderr, "failed to parse core commands: %s", err) + return 1 + } + + err = parseCommands(rootCmd, "contrib") + if err != nil { + fmt.Fprintf(os.Stderr, "failed to parse contrib commands: %s", err) + return 1 + } + + admin, err := isAdmin() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to check admin status: %s", err) + return 2 + } + + if admin { + rootCmd.AddCommand(adminCmd) + err = parseCommands(adminCmd, "admin") + if err != nil { + fmt.Fprintf(os.Stderr, "failed to parse admin commands: %s", err) + return 1 + } + } + + // I feel like the example/documentation yaml can be frontmatter for non-binary files. to start + // i'll just do the accompanying yaml file. + rootCmd.Execute() + + return 0 +} + +func main() { + os.Exit(cli()) +} diff --git a/go.mod b/go.mod index 79a419a..d6fa8fe 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,8 @@ module git.tilde.town/tildetown/town go 1.14 + +require ( + github.com/spf13/cobra v1.5.0 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/go.sum b/go.sum index e69de29..3a25d71 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,13 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= +github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=