Render CLI Shell Plugin: A Guide on Building the Shell Plugin for 1Password.

Maro Maro
12 min read

Introduction:

I started the project a week before the deadline. I was nervous, but I was also excited. I had never created a shell plugin before, but I was confident that I could do it.

The Process:

The first thing I did was learn about the 1Password, its API and the shell plugin. The API is well-documented, so it wasn't too difficult to understand. Once I understood the API, I started writing the code for the shell plugin.

The shell plugin is written in Go. The plugin is also very modular, which made it easy to test and debug.

I spent most of my time working on the authentication code. I wanted to make sure that the plugin was secure. I also spent some time working on the error-handling code. I wanted to make sure that the plugin would gracefully handle any errors that might occur.

The Deadline:

The deadline was approaching, and I was starting to get nervous. I had a lot of code left to write, and I wasn't sure if I would finish it in time. But I kept working, and I eventually finished the plugin with just a few hours to spare.

Here are a few tips for First-Time 1Password Contributors

1Password is a great password manager, but it can be daunting to start contributing to the project. Here are three tips that will help you get started:

Tip 1: Leverage Go tools

1Password shell plugins are written in Go, so you'll need to be familiar with the language. Several great Go tools can help you be more productive, such as GitHub Copilot, VSCode, GoEnv, GolangCI-Lint, and goimports.

GitHub Copilot is an AI agent that suggests the next few lines of code to write. This can be a huge time-saver, especially for repetitive tasks.

VSCode is a popular code editor that has several great features for Go development, such as linting, debugging, and code completion.

GoEnv is a tool that helps you manage multiple Go versions. This is important for 1Password, as it requires Go 1.18.

GolangCI-Lint is a tool that helps you find and fix errors in your Go code.

goimports is a tool that automatically formats your Go code.

Tip 2: Set up your development environment

Once you have the necessary tools installed, you need to set up your development environment. This includes creating a virtual environment and installing the 1Password dependencies.

Tip 3: Start contributing!

The best way to learn how to contribute to 1Password is to start contributing. There are several ways to do this, such as filing bugs, fixing bugs, or adding new features.

The initial release of any integration holds significant importance as it sets the tone for future development. In the case of the 1Password Render Shell Plugin, it's crucial to familiarize yourself with essential documents such as the 1Password Contributor's Guide, 1Password Code of Conduct, and 1Password Individual Contributor License Agreement.

Determining the scope of the first release requires thoughtful consideration. When introducing new functionality through a pull request, it's essential to showcase the value it brings while keeping the implementation simple enough for efficient review. Adding new functionality naturally increases the project's complexity, making it important to adhere to established coding standards and project practices

These are just a few tips to help you get started contributing to 1Password. With a little effort, you'll be able to start contributing to the project in no time.

Here are some additional tips that may be helpful:

  • Read the 1Password documentation.

  • Join the 1Password community forum.

  • Look for open issues that you can help with.

  • Don't be afraid to ask for help.

I hope these tips help you get started contributing to 1Password.

Now, let's get started.

Getting Started

Render.com is a cloud platform that provides a unified, full-stack development environment for building and running apps and websites. It offers a range of features, including free TLS certificates, global CDN, private networks, and auto deploys from Git. Render.com has grown to include immensely talented, kind, and diverse humans with stellar backgrounds at companies like Stripe, Twitter, Uber, Lyft, Twitch, LinkedIn, and Heroku. Tens of thousands of developers and businesses have created more than 300,000 services on Render. Render.com is similar to Heroku in hosting complete server-side web applications (for example, Ruby on Rails).

Requirements for 1Password

A 1Password Shell Plugin should describe the following:

  • The credential offered by a platform

  • The CLI or executable offered by a platform

  • How the credential should be provisioned for the respective CLI to authenticate

  • Which commands for the respective CLI need authentication

  • How credentials stored on the local filesystem can be imported into 1Password

Shell plugins are written in Go and consist of a set of Go structs in a package that together make up the plugin for a certain platform, service, or product. Don't worry if you're not a Go expert – there are lots of examples you can learn from to build your plugin!

Step 1: Use the plugin template

To get started contributing to 1Password Shell Plugins, you can clone or fork the repository on GitHub. This repository contains the current plugin registry, as well as the SDK needed to contribute.

Once you have cloned or forked the repository, you can use the make new-plugin command to create a new plugin. This command will prompt you to enter the following information:

  • Plugin name: The lowercase identifier for the platform, such as aws, github, digitalocean, or azure. This will also be used as the name of the Go package.

  • Platform display name: The display name of the platform, such as AWS, GitHub, DigitalOcean, or Azure.

  • Credential name: The credentials the platform offers, such as Personal Access Token, API Key, or Auth Token. Here's a complete list of available credential names

          API Client Credentials
          API Key
          API Token
          Access Key
          Access Token
          App Password
          App Token
          Auth Token
          CLI Token
          Credential
          Credentials
          Database Credentials
          Login Details
          Personal API Token
          Personal Access Token
          Registry Credentials
          Secret Key
          User Login
    
  • Executable name: The command to invoke, such as aws, gh, doctl, or az.

After filling in the form, you will see a Go package created in the plugins directory. This package will contain separate files for the plugin, credential, and executable. Here's what the directory structure would look like for render. I selected API Key

plugins/
├── aws/
│   ├── plugin.go
│   ├── access_key.go
│   └── aws.go
├── digitalocean/
│   ├── plugin.go
│   ├── personal_access_token.go
│   └── doctl.go
├── render/
│   ├── plugin.go
│   ├── api_key_test.go
│   ├── api_key.go
│   └── render.go

To save you some time, the generated files will be pre-filled with information based on the answers you gave in the Makefile prompts. The code will also contain TODO comments to help you understand what needs to be changed or validated.

Step 2: Edit the plugin definition

The plugin.go file contains basic information about the plugin, such as the platform it represents and the credential types and executables it supports.

package render

import (
    "github.com/1Password/shell-plugins/sdk"
    "github.com/1Password/shell-plugins/sdk/schema"
)

func New() schema.Plugin {
    return schema.Plugin{
        Name: "render",
        Platform: schema.PlatformInfo{
            Name:     "Render",
            Homepage: sdk.URL("https://render.com"),
        },
        Credentials: []schema.CredentialType{
            APIKey(),
        },
        Executables: []schema.Executable{
            RenderCLI(),
        },
    }
}

Step 3: Edit the credential definition

package render

import (
    "context"

    "github.com/1Password/shell-plugins/sdk"
    "github.com/1Password/shell-plugins/sdk/importer"
    "github.com/1Password/shell-plugins/sdk/provision"
    "github.com/1Password/shell-plugins/sdk/schema"
    "github.com/1Password/shell-plugins/sdk/schema/credname"
    "github.com/1Password/shell-plugins/sdk/schema/fieldname"
    "gopkg.in/yaml.v2"
)

type Config struct {
    Profiles map[string]Profile
}

type Profile struct {
    APIKey string `yaml:"apiKey"`
}

func APIKey() schema.CredentialType {
    return schema.CredentialType{
        Name:          credname.APIKey,
        DocsURL:       sdk.URL("https://render.com/docs"),
        ManagementURL: sdk.URL("https://dashboard.render.com/u/settings#api-keys"),
        Fields: []schema.CredentialField{
            {
                Name:                fieldname.APIKey,
                MarkdownDescription: "API Key used to authenticate to Render.",
                Secret:              true,
                Composition: &schema.ValueComposition{
                    Length: 30,
                    Charset: schema.Charset{
                        Uppercase: true,
                        Lowercase: true,
                        Digits:    true,
                    },
                },
            },
        },
        DefaultProvisioner: provision.TempFile(renderConfig, provision.AtFixedPath("~/.render/config.yaml")),
        Importer:           TryRenderConfigFile(),
    }
}

func renderConfig(in sdk.ProvisionInput) ([]byte, error) {
    config := Config{
        Profiles: map[string]Profile{
            "default": {
                APIKey: in.ItemFields[fieldname.APIKey],
            },
        },
    }

    contents, err := yaml.Marshal(&config)
    if err != nil {
        return nil, err
    }
    return []byte(contents), nil
}

func TryRenderConfigFile() sdk.Importer {
    return importer.TryFile("~/.render/config.yaml", func(ctx context.Context, contents importer.FileContents, in sdk.ImportInput, out *sdk.ImportAttempt) {
        var config Config
        if err := contents.ToYAML(&config); err != nil {
            out.AddError(err)
            return
        }

        for profileName, profile := range config.Profiles {
            if profile.APIKey == "" {
                continue
            }

            out.AddCandidate(sdk.ImportCandidate{
                Fields: map[sdk.FieldName]string{
                    fieldname.APIKey: profile.APIKey,
                },
                NameHint: importer.SanitizeNameHint(profileName),
            })
        }
    })
}

The credential definition file describes the schema of the credential, how it should be provisioned to executables, and how it can be imported into 1Password.

The first section of the credential definition is where you can add information about the credential, such as its name, documentation URL, and management URL.

The next section is where you define the schema of the credential. This is segmented into fields. Each field has a name, description, whether it is optional or secret, and the length and character set of the value.

The credential definition also specifies how the credential is provisioned to executables. Provisioners are hooks that are executed before and after an executable is run by 1Password CLI. They can be used to set environment variables, create files, add command-line arguments, or even generate temporary credentials.

The SDK provides a few common provisioners out of the box, so in most cases, you don't have to worry about the provisioning internals.

The definition of credentials also allows you to specify importers. Importers are responsible for scanning the user's environment and file system to find the required credentials. When using 1Password CLI, the importer will be executed, prompting the user to import their credentials one by one into 1Password.

It is common for command-line interfaces (CLIs) to store authentication data on disk, often in a hidden configuration file located in the user's home directory. Although CLI documentation may not always mention this, you can follow these tips to determine if such a configuration file exists:

  1. Consult the platform's documentation for any references to configuration files.

  2. Check if the CLI provides commands like login, auth, configure, or setup, which usually involves authentication. If these commands are available, a credential is likely stored in your home directory after completing the authentication process.

  3. If the CLI is open source, examine the source code to see if a configuration file is present.

  4. Look in your home directory or ~/.config directory for files related to the platform. For example, you can use a command like the following to find local render config files.

find ~/.* -maxdepth 3 -path "*render*"

You should see an output of a list of directories that look like this

/Users/mac/.render
/Users/mac/.render/config.yaml

We are interested in The config.yaml file which looks like this.


version: 1
sshPreserveHosts: true
profiles:
  default:
    defaultRegion: oregon
    apiKey: rnd_q7xPOp9NX1FuQNRy7pZs7yxCbu3i

Writing Tests for the Render Shell Plugin

Every Plugin created needs to be tested, although there's also a ci/cd pipeline where tests are run against your PR, it is also important to test your code locally.

  • Firstly, create a test-fixtures folder in your plugin directory.

  • Create a config file that contains the expected output. The expected output for the render plugin looks like this. Apikey should not be a live one.

      profiles:
        default:
          apiKey: rnd_Z7xMKp4NX1FoQNRyBpZs9yxDbu3i
    
  • Edit the api_key_test.go file to look like this

      package render
    
      import (
          "testing"
    
          "github.com/1Password/shell-plugins/sdk"
          "github.com/1Password/shell-plugins/sdk/plugintest"
          "github.com/1Password/shell-plugins/sdk/schema/fieldname"
      )
    
      func TestAPIKeyProvisioner(t *testing.T) {
          plugintest.TestProvisioner(t, APIKey().DefaultProvisioner, map[string]plugintest.ProvisionCase{
              "default": {
                  ItemFields: map[sdk.FieldName]string{
                      fieldname.APIKey: "rnd_Z7xMKp4NX1FoQNRyBpZs9yxDbu3i",
                  },
                  ExpectedOutput: sdk.ProvisionOutput{
                      Files: map[string]sdk.OutputFile{
                          "~/.render/config.yaml": {
                              Contents: []byte(plugintest.LoadFixture(t, "config.yaml") + "\n"),
                          },
                      },
                  },
              },
          })
      }
    
      func TestAPIKeyImporter(t *testing.T) {
          plugintest.TestImporter(t, APIKey().Importer, map[string]plugintest.ImportCase{
              "config file": {
                  Files: map[string]string{
                      "~/.render/config.yaml": plugintest.LoadFixture(t, "config.yaml"),
                  },
                  ExpectedCandidates: []sdk.ImportCandidate{
                      {
                          Fields: map[sdk.FieldName]string{
                              fieldname.APIKey: "rnd_Z7xMKp4NX1FoQNRyBpZs9yxDbu3i",
                          },
                      },
                  },
              },
          })
      }
    

Running make test in the bash terminal gives an output like this, note that the render test passed.

go test ./...
ok      github.com/1Password/shell-plugins/cmd/contrib  (cached)
?       github.com/1Password/shell-plugins/cmd/contrib/build    [no test files]
?       github.com/1Password/shell-plugins/cmd/contrib/scripts  [no test files]
ok      github.com/1Password/shell-plugins/plugins      (cached)
ok      github.com/1Password/shell-plugins/plugins/akamai       (cached)
ok      github.com/1Password/shell-plugins/plugins/argocd       (cached)
ok      github.com/1Password/shell-plugins/plugins/atlas        (cached)
ok      github.com/1Password/shell-plugins/plugins/aws  (cached)
ok      github.com/1Password/shell-plugins/plugins/cachix       (cached)
ok      github.com/1Password/shell-plugins/plugins/cargo        (cached)
ok      github.com/1Password/shell-plugins/plugins/circleci     (cached)
ok      github.com/1Password/shell-plugins/plugins/confluent    (cached)
ok      github.com/1Password/shell-plugins/plugins/databricks   (cached)
ok      github.com/1Password/shell-plugins/plugins/datadog      (cached)
ok      github.com/1Password/shell-plugins/plugins/digitalocean (cached)
?       github.com/1Password/shell-plugins/plugins/fossa        [no test files]
ok      github.com/1Password/shell-plugins/plugins/fastly       (cached)
ok      github.com/1Password/shell-plugins/plugins/flyctl       (cached)
ok      github.com/1Password/shell-plugins/plugins/gitea        (cached)
ok      github.com/1Password/shell-plugins/plugins/github       (cached)
?       github.com/1Password/shell-plugins/plugins/homebrew     [no test files]
ok      github.com/1Password/shell-plugins/plugins/gitlab       (cached)
ok      github.com/1Password/shell-plugins/plugins/hcloud       (cached)
ok      github.com/1Password/shell-plugins/plugins/heroku       (cached)
ok      github.com/1Password/shell-plugins/plugins/lacework     (cached)
ok      github.com/1Password/shell-plugins/plugins/laravelforge (cached)
ok      github.com/1Password/shell-plugins/plugins/laravelvapor (cached)
ok      github.com/1Password/shell-plugins/plugins/linode       (cached)
ok      github.com/1Password/shell-plugins/plugins/mysql        (cached)
ok      github.com/1Password/shell-plugins/plugins/ngrok        (cached)
ok      github.com/1Password/shell-plugins/plugins/okta (cached)
ok      github.com/1Password/shell-plugins/plugins/openai       (cached)
ok      github.com/1Password/shell-plugins/plugins/postgresql   (cached)
ok      github.com/1Password/shell-plugins/plugins/pulumi       (cached)
ok      github.com/1Password/shell-plugins/plugins/readme       (cached)
?       github.com/1Password/shell-plugins/plugins/terraform    [no test files]
?       github.com/1Password/shell-plugins/plugins/tugboat      [no test files]
?       github.com/1Password/shell-plugins/plugins/wrangler     [no test files]
?       github.com/1Password/shell-plugins/sdk/importer [no test files]
?       github.com/1Password/shell-plugins/sdk/provision        [no test files]
?       github.com/1Password/shell-plugins/sdk/rpc/proto        [no test files]
?       github.com/1Password/shell-plugins/sdk/rpc/server       [no test files]
?       github.com/1Password/shell-plugins/sdk/schema/fieldname [no test files]
ok      github.com/1Password/shell-plugins/plugins/render       1.352s
ok      github.com/1Password/shell-plugins/plugins/sentry       (cached)
ok      github.com/1Password/shell-plugins/plugins/snowflake    (cached)
ok      github.com/1Password/shell-plugins/plugins/snyk (cached)
ok      github.com/1Password/shell-plugins/plugins/sourcegraph  (cached)
ok      github.com/1Password/shell-plugins/plugins/stripe       (cached)
ok      github.com/1Password/shell-plugins/plugins/treasuredata (cached)
ok      github.com/1Password/shell-plugins/plugins/twilio       (cached)
ok      github.com/1Password/shell-plugins/plugins/vault        (cached)
ok      github.com/1Password/shell-plugins/plugins/vercel       (cached)
ok      github.com/1Password/shell-plugins/plugins/vultr        (cached)
ok      github.com/1Password/shell-plugins/plugins/zendesk      (cached)
ok      github.com/1Password/shell-plugins/sdk  (cached)
ok      github.com/1Password/shell-plugins/sdk/example  (cached)
ok      github.com/1Password/shell-plugins/sdk/needsauth        (cached)
ok      github.com/1Password/shell-plugins/sdk/plugintest       (cached)
ok      github.com/1Password/shell-plugins/sdk/schema   (cached)
ok      github.com/1Password/shell-plugins/sdk/schema/credname  (cached)

To verify the correctness of your plugin, credential, and executable definitions, you can execute the provided Makefile command to validate them. This command will help you ensure that the definitions have been filled out correctly:

make <plugin-name>/validate

If that succeeds, it's now time to locally build and test your plugin! You can do so using the following command:

make <plugin-name>/build

The build artifact will be placed in ~/.op/plugins/local. It should show up in op if you run the following command:

op plugin list

you can use the op plugin init command To see it in action,

The Results:

Once you're done, sign your commits by following this guide, Ensure you format your code with the gofmt command before creating a pull request.

gofmt -s -w plugins/render/YOUR_FILE_NAME.go

Pull requests are typically submitted when the authors are prepared to receive valuable feedback. Skilled developers actively embrace feedback and take necessary steps to address it. The input provided by reviewers during the pull request process is highly significant as it plays a crucial role in enhancing the overall quality of the codebase. This practice of seeking and incorporating feedback is vital for continuous improvement in software development.

If you had issues trying to sign previous commits, you can follow this guide

You can now use 1Password from the command line, which is exactly what I wanted. I'm also very proud of the fact that I was able to create the plugin in a week.

The Conclusion:

If you're thinking about creating a shell plugin for 1Password, I encourage you to go for it. It's a challenging project, but it's also very rewarding. And if you're short on time, don't worry. I was able to create my plugin in a week, so you can too.

The Next Steps:

I'm planning to continue working on the shell plugin. I want to add more features, such as the ability to generate passwords and the ability to store notes. I also want to make the plugin more user-friendly.

I'm also planning to create other shell plugins. I'm excited to see what I can create next.

The Moral of the Story:

If you have an idea for a shell plugin, don't be afraid to try it. It's a challenging project, but it's also very rewarding. And if you're short on time, don't worry. I was able to create my plugin in a week, so you can too.

Github link: https://github.com/1Password/shell-plugins/pull/308

#1Password #BuildWith1Password

267
Subscribe to my newsletter

Read articles from Maro directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Maro
Maro