diff --git a/go.mod b/go.mod index 4886719..9845daf 100644 --- a/go.mod +++ b/go.mod @@ -11,9 +11,11 @@ require ( github.com/cloudrecipes/packagejson v1.0.0 github.com/digitalocean/godo v1.131.0 github.com/foomo/posh v0.8.2 + github.com/goccy/go-json v0.9.11 github.com/golang-migrate/migrate/v4 v4.18.1 github.com/google/go-github/v47 v47.1.0 github.com/joho/godotenv v1.5.1 + github.com/muesli/go-app-paths v0.2.2 github.com/pkg/errors v0.9.1 github.com/pterm/pterm v0.12.80 github.com/samber/lo v1.47.0 @@ -53,6 +55,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-tty v0.0.3 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/neilotoole/slogt v1.1.0 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect diff --git a/go.sum b/go.sum index 62633f1..f93c0c9 100644 --- a/go.sum +++ b/go.sum @@ -53,6 +53,8 @@ github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiU github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/goccy/go-json v0.9.11 h1:/pAaQDLHEoCq/5FFmSKBswWmK6H0e8g4159Kc/X/nqk= +github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y= github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -123,8 +125,12 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-tty v0.0.3 h1:5OfyWorkyO7xP52Mq7tB36ajHDG5OHrmBGIS/DtakQI= github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/muesli/go-app-paths v0.2.2 h1:NqG4EEZwNIhBq/pREgfBmgDmt3h1Smr1MjZiXbpZUnI= +github.com/muesli/go-app-paths v0.2.2/go.mod h1:SxS3Umca63pcFcLtbjVb+J0oD7cl4ixQWoBKhGEtEho= github.com/neilotoole/slogt v1.1.0 h1:c7qE92sq+V0yvCuaxph+RQ2jOKL61c4hqS1Bv9W7FZE= github.com/neilotoole/slogt v1.1.0/go.mod h1:RCrGXkPc/hYybNulqQrMHRtvlQ7F6NktNVLuLwk6V+w= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= diff --git a/usebruno/bruno/README.md b/usebruno/bruno/README.md new file mode 100644 index 0000000..ac04019 --- /dev/null +++ b/usebruno/bruno/README.md @@ -0,0 +1,78 @@ +# POSH Bruno provider + +## Usage + +### Plugin + +```go +package main + +import ( + "github.com/foomo/posh-providers/onepassword" + "github.com/foomo/posh-providers/usebruno/bruno" + "github.com/foomo/posh/pkg/cache" + "github.com/foomo/posh/pkg/command" +) + +type Plugin struct { + l log.Logger + cache cache.Cache + op *onepassword.OnePassword +} + +func New(l log.Logger) (plugin.Plugin, error) { + var err error + inst := &Plugin{ + l: l, + cache: cache.MemoryCache{}, + commands: command.Commands{}, + } + + // ... + + inst.op, err = onepassword.New(l, inst.cache) + if err != nil { + return nil, errors.Wrap(err, "failed to create onepassword") + } + + // ... + + inst.commands.Add(bruno.NewCommand(l, bruno.CommandWithOnePassword(inst.op))) + + // ... + + return inst, nil +} + +``` + +### Config + +```yaml +## Bruno +bruno: + path: '${PROJECT_ROOT}/.posh/bruno' +``` + +### OnePassword + +To inject secrets from 1Password, create a `bruno.env` file: + +```text +JWT_TOKEN=********************* +``` + +Render the file to `.env`: + +```shell +> bruno env +``` + +And use the secret in your environment `environments/local.bru`: + +```text +vars { + host: http://localhost:5005 + jwtToken: {{process.env.JWT_TOKEN}} +} +``` diff --git a/usebruno/bruno/command.go b/usebruno/bruno/command.go new file mode 100644 index 0000000..55aab3e --- /dev/null +++ b/usebruno/bruno/command.go @@ -0,0 +1,251 @@ +package bruno + +import ( + "context" + "os" + "path" + + "github.com/foomo/posh-providers/onepassword" + "github.com/foomo/posh/pkg/command/tree" + "github.com/foomo/posh/pkg/log" + "github.com/foomo/posh/pkg/prompt/goprompt" + "github.com/foomo/posh/pkg/readline" + "github.com/foomo/posh/pkg/shell" + "github.com/foomo/posh/pkg/util/suggests" + gap "github.com/muesli/go-app-paths" + "github.com/pkg/errors" + "github.com/pterm/pterm" + "github.com/spf13/viper" +) + +type ( + Command struct { + l log.Logger + op *onepassword.OnePassword + name string + appName string + config Config + configKey string + commandTree tree.Root + } + CommandOption func(*Command) +) + +// ------------------------------------------------------------------------------------------------ +// ~ Options +// ------------------------------------------------------------------------------------------------ + +func CommandWithName(v string) CommandOption { + return func(o *Command) { + o.name = v + } +} + +func CommandWithAppName(v string) CommandOption { + return func(o *Command) { + o.appName = v + } +} + +func CommandWithConfigKey(v string) CommandOption { + return func(o *Command) { + o.configKey = v + } +} + +func CommandWithOnePassword(v *onepassword.OnePassword) CommandOption { + return func(o *Command) { + o.op = v + } +} + +// ------------------------------------------------------------------------------------------------ +// ~ Constructor +// ------------------------------------------------------------------------------------------------ + +func NewCommand(l log.Logger, opts ...CommandOption) (*Command, error) { + inst := &Command{ + l: l.Named("bruno"), + name: "bruno", + appName: "Bruno", + configKey: "bruno", + } + + for _, opt := range opts { + if opt != nil { + opt(inst) + } + } + + if err := viper.UnmarshalKey(inst.configKey, &inst.config); err != nil { + return nil, err + } + + inst.commandTree = tree.New(&tree.Node{ + Name: inst.name, + Description: "Run Bruno requests", + Nodes: tree.Nodes{ + { + Name: "list", + Description: "List available requests", + Execute: inst.list, + }, + { + Name: "env", + Description: "Render secrets env", + Execute: inst.env, + }, + { + Name: "run", + Description: "Run the Bruno cli", + Args: tree.Args{ + { + Name: "env", + Description: "Environment name", + Suggest: func(ctx context.Context, t tree.Root, r *readline.Readline) []goprompt.Suggest { + return suggests.List(inst.config.Environments()) + }, + }, + { + Name: "request", + Description: "Request to run", + Repeat: true, + Optional: true, + Suggest: func(ctx context.Context, t tree.Root, r *readline.Readline) []goprompt.Suggest { + return suggests.List(inst.config.Requests()) + }, + }, + }, + Flags: func(ctx context.Context, r *readline.Readline, fs *readline.FlagSets) error { + fs.Default().String("format", "", "Environment variables") + fs.Default().String("env", "", "Overwrite a single environment variable") + fs.Default().String("env-var", "", "Overwrite a single environment variable") + fs.Default().Bool("bail", false, "Stop execution after a failure of a request, test, or assertion") + fs.Default().Bool("insecure", false, "Allow insecure server connections") + fs.Default().Bool("tests-only", false, "Only run requests that have tests") + fs.Default().Bool("verbose", false, "Allow verbose output for debugging purpose") + return nil + }, + Execute: inst.run, + }, + { + Name: "open", + Description: "Open the Bruno app", + Execute: inst.open, + }, + }, + }) + + return inst, nil +} + +// ------------------------------------------------------------------------------------------------ +// ~ Public methods +// ------------------------------------------------------------------------------------------------ + +func (c *Command) Name() string { + return c.commandTree.Node().Name +} + +func (c *Command) Description() string { + return c.commandTree.Node().Description +} + +func (c *Command) Complete(ctx context.Context, r *readline.Readline) []goprompt.Suggest { + return c.commandTree.Complete(ctx, r) +} + +func (c *Command) Execute(ctx context.Context, r *readline.Readline) error { + return c.commandTree.Execute(ctx, r) +} + +func (c *Command) Help(ctx context.Context, r *readline.Readline) string { + return c.commandTree.Help(ctx, r) +} + +// ------------------------------------------------------------------------------------------------ +// ~ Private methods +// ------------------------------------------------------------------------------------------------ + +func (c *Command) list(ctx context.Context, r *readline.Readline) error { + t := pterm.TreeNode{ + Text: c.config.Filename() + ":", + } + { + child := pterm.TreeNode{ + Text: "Environments:", + } + for _, request := range c.config.Environments() { + child.Children = append(child.Children, pterm.TreeNode{ + Text: request, + }) + } + if len(child.Children) > 0 { + t.Children = append(t.Children, child) + } + } + { + child := pterm.TreeNode{ + Text: "Requests:", + } + for _, request := range c.config.Requests() { + child.Children = append(child.Children, pterm.TreeNode{ + Text: request, + }) + } + if len(child.Children) > 0 { + t.Children = append(t.Children, child) + } + } + return pterm.DefaultTree.WithRoot(t).Render() +} + +func (c *Command) run(ctx context.Context, r *readline.Readline) error { + fs := r.FlagSets().Default() + args := []string{ + "--env", r.Args().At(1), + } + if r.Args().LenGte(3) { + args = append(args, r.Args().From(2)...) + } + return shell.New(ctx, c.l, "bru", "run"). + Args(args...). + Args(fs.Visited().Args()...). + Args(r.AdditionalArgs()...). + Dir(c.config.Filename()). + Run() +} + +func (c *Command) env(ctx context.Context, r *readline.Readline) error { + if c.op == nil { + return errors.New("you must provide a one-password configuration") + } + + envFilename := path.Join(c.config.Filename(), ".env") + templateFilename := path.Join(c.config.Filename(), "bruno.env") + if _, err := os.Stat(templateFilename); err != nil { + return err + } + + c.l.Info("rendering secrets file:", envFilename) + return shell.New(ctx, c.l, "op", "inject", "-f", "-i", templateFilename, "-o", envFilename).Quiet().Run() +} + +func (c *Command) open(ctx context.Context, r *readline.Readline) error { + prefFilename, err := gap.NewScope(gap.User, c.name).DataPath("preferences.json") + if err != nil { + return err + } + pref, err := NewPreferences(prefFilename) + if err != nil { + return err + } + if err := pref.AddLastOpenedCollection(c.config.Filename()); err != nil { + return err + } + if err := pref.Save(prefFilename); err != nil { + return err + } + + return shell.New(ctx, c.l, "open", "-a", c.appName).Run() +} diff --git a/usebruno/bruno/config.go b/usebruno/bruno/config.go new file mode 100644 index 0000000..729b71d --- /dev/null +++ b/usebruno/bruno/config.go @@ -0,0 +1,48 @@ +package bruno + +import ( + "io/fs" + "os" + "path" + "strings" + + "golang.org/x/exp/slices" +) + +type Config struct { + Path string `json:"path" yaml:"path" mapstructure:"path"` +} + +func (c Config) Filename() string { + return os.ExpandEnv(c.Path) +} + +func (c Config) Environments() []string { + entries, err := fs.Glob(os.DirFS(path.Join(c.Filename(), "environments")), "*.bru") + if err != nil { + return nil + } + var ret []string + for _, entry := range entries { + ret = append(ret, strings.TrimSuffix(entry, ".bru")) + } + return ret +} + +func (c Config) Requests() []string { + var ret []string + var files []string + if value, err := fs.Glob(os.DirFS(c.Filename()), "*.bru"); err == nil { + files = append(files, value...) + } + if value, err := fs.Glob(os.DirFS(c.Filename()), "**/*.bru"); err == nil { + files = append(files, value...) + } + slices.Sort(files) + for _, entry := range files { + if !strings.HasPrefix(entry, "environments") { + ret = append(ret, entry) + } + } + return ret +} diff --git a/usebruno/bruno/preferences.go b/usebruno/bruno/preferences.go new file mode 100644 index 0000000..a5fedb4 --- /dev/null +++ b/usebruno/bruno/preferences.go @@ -0,0 +1,56 @@ +package bruno + +import ( + "os" + + "github.com/goccy/go-json" + "github.com/mitchellh/mapstructure" + "golang.org/x/exp/slices" +) + +type Preferences struct { + path string + data map[string]any +} + +func NewPreferences(path string) (*Preferences, error) { + file, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var data map[string]any + if err := json.Unmarshal(file, &data); err != nil { + return nil, err + } + + return &Preferences{ + path: path, + data: data, + }, nil +} + +func (p *Preferences) AddLastOpenedCollection(path string) error { + if _, ok := p.data["lastOpenedCollections"]; !ok { + p.data["lastOpenedCollections"] = make(map[string][]any) + } + if lastOpenedCollections, ok := p.data["lastOpenedCollections"].([]any); ok { + var lastOpenedCollectionsStrings []string + if err := mapstructure.Decode(lastOpenedCollections, &lastOpenedCollectionsStrings); err != nil { + return err + } + if !slices.Contains(lastOpenedCollectionsStrings, path) { + lastOpenedCollections = append(lastOpenedCollections, path) + p.data["lastOpenedCollections"] = lastOpenedCollections + } + } + return nil +} + +func (p *Preferences) Save(path string) error { + data, err := json.MarshalIndent(p.data, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0600) +}