├── .gitignore ├── pkg ├── envsec │ ├── doc.go │ ├── delete.go │ ├── envsec.go │ ├── projects.go │ ├── auth.go │ ├── store.go │ ├── upload.go │ ├── download.go │ ├── list.go │ ├── init.go │ └── set.go ├── envcli │ ├── root_test.go │ ├── info.go │ ├── rm.go │ ├── set.go │ ├── upload.go │ ├── download.go │ ├── version.go │ ├── init.go │ ├── ls.go │ ├── exec.go │ ├── gen-docs.go │ ├── root.go │ ├── usage.go │ ├── auth.go │ └── flags.go ├── stores │ ├── ssmstore │ │ ├── config.go │ │ ├── ssmstore.go │ │ └── parameter_store.go │ └── jetstore │ │ └── jetstore.go └── awsfed │ └── awsfed.go ├── internal ├── tux │ ├── README.md │ ├── table.go │ ├── tux.go │ └── style.go ├── git │ ├── git_test.go │ └── git.go ├── build │ └── build.go └── flow │ └── init.go ├── docs ├── envsec_auth_login.md ├── envsec_auth_logout.md ├── envsec_init.md ├── envsec_auth_whoami.md ├── envsec_auth.md ├── envsec_rm.md ├── envsec_set.md ├── envsec_upload.md ├── envsec_exec.md ├── envsec_completion_powershell.md ├── envsec_download.md ├── envsec_completion_fish.md ├── envsec_ls.md ├── envsec_completion.md ├── envsec_completion_bash.md ├── envsec_completion_zsh.md └── envsec.md ├── cmd └── envsec │ └── main.go ├── README.md ├── .github └── workflows │ ├── tests.yml │ └── release.yml ├── devbox.json ├── .goreleaser.yaml ├── devbox.lock ├── go.mod ├── CODE_OF_CONDUCT.md ├── LICENSE └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /pkg/envsec/doc.go: -------------------------------------------------------------------------------- 1 | // package envsec contains library functions for working with envsec. 2 | // if you want to include envsec in your own project, use this package. 3 | package envsec 4 | -------------------------------------------------------------------------------- /internal/tux/README.md: -------------------------------------------------------------------------------- 1 | # TUX: Terminal UX 2 | 3 | This package contains utilities that we used to render messages in our CLI app. 4 | For now, starting it within envsec, but if it grows, and we use it beyond envsec, 5 | it could become a top-level module, that we open source as well. -------------------------------------------------------------------------------- /docs/envsec_auth_login.md: -------------------------------------------------------------------------------- 1 | ## envsec auth login 2 | 3 | Login to envsec 4 | 5 | ``` 6 | envsec auth login [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for login 13 | ``` 14 | 15 | ### SEE ALSO 16 | 17 | * [envsec auth](envsec_auth.md) - envsec auth commands 18 | 19 | -------------------------------------------------------------------------------- /docs/envsec_auth_logout.md: -------------------------------------------------------------------------------- 1 | ## envsec auth logout 2 | 3 | logout from envsec 4 | 5 | ``` 6 | envsec auth logout [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for logout 13 | ``` 14 | 15 | ### SEE ALSO 16 | 17 | * [envsec auth](envsec_auth.md) - envsec auth commands 18 | 19 | -------------------------------------------------------------------------------- /docs/envsec_init.md: -------------------------------------------------------------------------------- 1 | ## envsec init 2 | 3 | initialize directory and envsec project 4 | 5 | ``` 6 | envsec init [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for init 13 | ``` 14 | 15 | ### SEE ALSO 16 | 17 | * [envsec](envsec.md) - Manage environment variables and secrets 18 | 19 | -------------------------------------------------------------------------------- /pkg/envcli/root_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Jetify Inc. and contributors. All rights reserved. 2 | // Use of this source code is governed by the license in the LICENSE file. 3 | 4 | package envcli 5 | 6 | import ( 7 | "context" 8 | ) 9 | 10 | func ExampleExecute() { 11 | ctx := context.Background() 12 | Execute(ctx) 13 | } 14 | -------------------------------------------------------------------------------- /cmd/envsec/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Jetify Inc. and contributors. All rights reserved. 2 | // Use of this source code is governed by the license in the LICENSE file. 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "os" 9 | 10 | "go.jetify.com/envsec/pkg/envcli" 11 | ) 12 | 13 | func main() { 14 | os.Exit(envcli.Execute(context.Background())) 15 | } 16 | -------------------------------------------------------------------------------- /docs/envsec_auth_whoami.md: -------------------------------------------------------------------------------- 1 | ## envsec auth whoami 2 | 3 | Show the current user 4 | 5 | ``` 6 | envsec auth whoami [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for whoami 13 | --show-tokens Show the access, id, and refresh tokens 14 | ``` 15 | 16 | ### SEE ALSO 17 | 18 | * [envsec auth](envsec_auth.md) - envsec auth commands 19 | 20 | -------------------------------------------------------------------------------- /docs/envsec_auth.md: -------------------------------------------------------------------------------- 1 | ## envsec auth 2 | 3 | envsec auth commands 4 | 5 | ### Options 6 | 7 | ``` 8 | -h, --help help for auth 9 | ``` 10 | 11 | ### SEE ALSO 12 | 13 | * [envsec](envsec.md) - Manage environment variables and secrets 14 | * [envsec auth login](envsec_auth_login.md) - Login to envsec 15 | * [envsec auth logout](envsec_auth_logout.md) - logout from envsec 16 | * [envsec auth whoami](envsec_auth_whoami.md) - Show the current user 17 | 18 | -------------------------------------------------------------------------------- /internal/tux/table.go: -------------------------------------------------------------------------------- 1 | package tux 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/olekukonko/tablewriter" 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | func FTable(w io.Writer, rows [][]string) error { 11 | table := tablewriter.NewWriter(w) 12 | for _, row := range rows { 13 | if err := table.Append(row); err != nil { 14 | return errors.WithStack(err) 15 | } 16 | } 17 | if err := table.Render(); err != nil { 18 | return errors.WithStack(err) 19 | } 20 | return nil 21 | } 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Envsec: Securely store environment variables in your cloud 2 | 3 | Envsec is a tool that securely stores environment variables in the cloud of your choice. 4 | 5 | Envsec is designed with developer experience in mind and it's meant to be a pleasure to use. 6 | It comes with a command line tool that you can start using right away, and an accompanying 7 | `go` library. 8 | 9 | ## Related Work 10 | + [Chamber](https://github.com/segmentio/chamber) 11 | + [Credstash](https://github.com/fugue/credstash) 12 | + [Sops](https://github.com/mozilla/sops) -------------------------------------------------------------------------------- /pkg/envcli/info.go: -------------------------------------------------------------------------------- 1 | package envcli 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func infoCmd() *cobra.Command { 10 | command := &cobra.Command{ 11 | Use: "info", 12 | Short: "Show info about the current project", 13 | RunE: func(cmd *cobra.Command, args []string) error { 14 | workingDir, err := os.Getwd() 15 | if err != nil { 16 | return err 17 | } 18 | return defaultEnvsec(cmd, workingDir). 19 | DescribeCurrentProject(cmd.Context(), cmd.OutOrStdout()) 20 | }, 21 | } 22 | 23 | return command 24 | } 25 | -------------------------------------------------------------------------------- /pkg/envsec/delete.go: -------------------------------------------------------------------------------- 1 | package envsec 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "go.jetify.com/envsec/internal/tux" 8 | ) 9 | 10 | func (e *Envsec) DeleteAll(ctx context.Context, envNames ...string) error { 11 | if err := e.Store.DeleteAll(ctx, e.EnvID, envNames); err != nil { 12 | return err 13 | } 14 | return tux.WriteHeader(e.Stderr, 15 | "[DONE] Deleted environment %s %v in environment: %s\n", 16 | tux.Plural(envNames, "variable", "variables"), 17 | strings.Join(tux.QuotedTerms(envNames), ", "), 18 | strings.ToLower(e.EnvID.EnvName), 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /docs/envsec_rm.md: -------------------------------------------------------------------------------- 1 | ## envsec rm 2 | 3 | Delete one or more environment variables 4 | 5 | ### Synopsis 6 | 7 | Delete one or more environment variables that are stored. 8 | 9 | ``` 10 | envsec rm []... [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | --environment string Environment name, such as dev or prod (default "dev") 17 | -h, --help help for rm 18 | --org-id string Organization id to namespace secrets by 19 | --project-id string Project id to namespace secrets by 20 | ``` 21 | 22 | ### SEE ALSO 23 | 24 | * [envsec](envsec.md) - Manage environment variables and secrets 25 | 26 | -------------------------------------------------------------------------------- /pkg/envsec/envsec.go: -------------------------------------------------------------------------------- 1 | package envsec 2 | 3 | import ( 4 | "context" 5 | "io" 6 | 7 | "go.jetify.com/pkg/auth/session" 8 | ) 9 | 10 | type Envsec struct { 11 | APIHost string 12 | Auth AuthConfig 13 | EnvID EnvID 14 | IsDev bool 15 | Stderr io.Writer 16 | Store Store 17 | WorkingDir string 18 | } 19 | 20 | type AuthConfig struct { 21 | Audience []string 22 | Issuer string 23 | ClientID string 24 | SuccessRedirect string 25 | // TODO Audiences and Scopes 26 | } 27 | 28 | func (e *Envsec) InitForUser(ctx context.Context) (*session.Token, error) { 29 | return e.Store.InitForUser(ctx, e) 30 | } 31 | -------------------------------------------------------------------------------- /docs/envsec_set.md: -------------------------------------------------------------------------------- 1 | ## envsec set 2 | 3 | Securely store one or more environment variables 4 | 5 | ### Synopsis 6 | 7 | Securely store one or more environment variables. To test contents of a file as a secret use set=@ 8 | 9 | ``` 10 | envsec set = [=]... [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | --environment string Environment name, such as dev or prod (default "dev") 17 | -h, --help help for set 18 | --org-id string Organization id to namespace secrets by 19 | --project-id string Project id to namespace secrets by 20 | ``` 21 | 22 | ### SEE ALSO 23 | 24 | * [envsec](envsec.md) - Manage environment variables and secrets 25 | 26 | -------------------------------------------------------------------------------- /docs/envsec_upload.md: -------------------------------------------------------------------------------- 1 | ## envsec upload 2 | 3 | Upload variables defined in a .env file 4 | 5 | ### Synopsis 6 | 7 | Upload variables defined in one or more .env files. The files should have one NAME=VALUE per line. 8 | 9 | ``` 10 | envsec upload []... [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | --environment string Environment name, such as dev or prod (default "dev") 17 | -f, --format string File format: env or json (default "env") 18 | -h, --help help for upload 19 | --org-id string Organization id to namespace secrets by 20 | --project-id string Project id to namespace secrets by 21 | ``` 22 | 23 | ### SEE ALSO 24 | 25 | * [envsec](envsec.md) - Manage environment variables and secrets 26 | 27 | -------------------------------------------------------------------------------- /docs/envsec_exec.md: -------------------------------------------------------------------------------- 1 | ## envsec exec 2 | 3 | Execute a command with Jetify-stored environment variables 4 | 5 | ### Synopsis 6 | 7 | Execute a specified command with remote environment variables being present for the duration of the command. If an environment variable exists both locally and in remote storage, the remotely stored one is prioritized. 8 | 9 | ``` 10 | envsec exec [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | --environment string Environment name, such as dev or prod (default "dev") 17 | -h, --help help for exec 18 | --org-id string Organization id to namespace secrets by 19 | --project-id string Project id to namespace secrets by 20 | ``` 21 | 22 | ### SEE ALSO 23 | 24 | * [envsec](envsec.md) - Manage environment variables and secrets 25 | 26 | -------------------------------------------------------------------------------- /docs/envsec_completion_powershell.md: -------------------------------------------------------------------------------- 1 | ## envsec completion powershell 2 | 3 | Generate the autocompletion script for powershell 4 | 5 | ### Synopsis 6 | 7 | Generate the autocompletion script for powershell. 8 | 9 | To load completions in your current shell session: 10 | 11 | envsec completion powershell | Out-String | Invoke-Expression 12 | 13 | To load completions for every new session, add the output of the above command 14 | to your powershell profile. 15 | 16 | 17 | ``` 18 | envsec completion powershell [flags] 19 | ``` 20 | 21 | ### Options 22 | 23 | ``` 24 | -h, --help help for powershell 25 | --no-descriptions disable completion descriptions 26 | ``` 27 | 28 | ### SEE ALSO 29 | 30 | * [envsec completion](envsec_completion.md) - Generate the autocompletion script for the specified shell 31 | 32 | -------------------------------------------------------------------------------- /docs/envsec_download.md: -------------------------------------------------------------------------------- 1 | ## envsec download 2 | 3 | Download environment variables into the specified file 4 | 5 | ### Synopsis 6 | 7 | Download environment variables stored into the specified file (most commonly a .env file). The format of the file is one NAME=VALUE per line. 8 | 9 | ``` 10 | envsec download [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | --environment string Environment name, such as dev or prod (default "dev") 17 | -f, --format string File format: env or json (default "env") 18 | -h, --help help for download 19 | --org-id string Organization id to namespace secrets by 20 | --project-id string Project id to namespace secrets by 21 | ``` 22 | 23 | ### SEE ALSO 24 | 25 | * [envsec](envsec.md) - Manage environment variables and secrets 26 | 27 | -------------------------------------------------------------------------------- /docs/envsec_completion_fish.md: -------------------------------------------------------------------------------- 1 | ## envsec completion fish 2 | 3 | Generate the autocompletion script for fish 4 | 5 | ### Synopsis 6 | 7 | Generate the autocompletion script for the fish shell. 8 | 9 | To load completions in your current shell session: 10 | 11 | envsec completion fish | source 12 | 13 | To load completions for every new session, execute once: 14 | 15 | envsec completion fish > ~/.config/fish/completions/envsec.fish 16 | 17 | You will need to start a new shell for this setup to take effect. 18 | 19 | 20 | ``` 21 | envsec completion fish [flags] 22 | ``` 23 | 24 | ### Options 25 | 26 | ``` 27 | -h, --help help for fish 28 | --no-descriptions disable completion descriptions 29 | ``` 30 | 31 | ### SEE ALSO 32 | 33 | * [envsec completion](envsec_completion.md) - Generate the autocompletion script for the specified shell 34 | 35 | -------------------------------------------------------------------------------- /docs/envsec_ls.md: -------------------------------------------------------------------------------- 1 | ## envsec ls 2 | 3 | List all stored environment variables 4 | 5 | ### Synopsis 6 | 7 | List all stored environment variables. If no environment flag is provided, variables in all environments will be listed. 8 | 9 | ``` 10 | envsec ls [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | --environment string Environment name, such as dev or prod (default "dev") 17 | -f, --format string Display the key values in key=value format (default "table") 18 | -h, --help help for ls 19 | --org-id string Organization id to namespace secrets by 20 | --project-id string Project id to namespace secrets by 21 | -s, --show Display the value of each environment variable (secrets included) 22 | ``` 23 | 24 | ### SEE ALSO 25 | 26 | * [envsec](envsec.md) - Manage environment variables and secrets 27 | 28 | -------------------------------------------------------------------------------- /docs/envsec_completion.md: -------------------------------------------------------------------------------- 1 | ## envsec completion 2 | 3 | Generate the autocompletion script for the specified shell 4 | 5 | ### Synopsis 6 | 7 | Generate the autocompletion script for envsec for the specified shell. 8 | See each sub-command's help for details on how to use the generated script. 9 | 10 | 11 | ### Options 12 | 13 | ``` 14 | -h, --help help for completion 15 | ``` 16 | 17 | ### SEE ALSO 18 | 19 | * [envsec](envsec.md) - Manage environment variables and secrets 20 | * [envsec completion bash](envsec_completion_bash.md) - Generate the autocompletion script for bash 21 | * [envsec completion fish](envsec_completion_fish.md) - Generate the autocompletion script for fish 22 | * [envsec completion powershell](envsec_completion_powershell.md) - Generate the autocompletion script for powershell 23 | * [envsec completion zsh](envsec_completion_zsh.md) - Generate the autocompletion script for zsh 24 | 25 | -------------------------------------------------------------------------------- /pkg/envcli/rm.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Jetify Inc. and contributors. All rights reserved. 2 | // Use of this source code is governed by the license in the LICENSE file. 3 | 4 | package envcli 5 | 6 | import ( 7 | "github.com/pkg/errors" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | type removeCmdFlags struct { 12 | configFlags 13 | } 14 | 15 | func RemoveCmd() *cobra.Command { 16 | flags := &removeCmdFlags{} 17 | command := &cobra.Command{ 18 | Use: "rm []...", 19 | Short: "Delete one or more environment variables", 20 | Long: "Delete one or more environment variables that are stored.", 21 | Args: cobra.MinimumNArgs(1), 22 | RunE: func(cmd *cobra.Command, envNames []string) error { 23 | cmdCfg, err := flags.genConfig(cmd) 24 | if err != nil { 25 | return errors.WithStack(err) 26 | } 27 | return cmdCfg.envsec.DeleteAll(cmd.Context(), envNames...) 28 | }, 29 | } 30 | flags.register(command) 31 | 32 | return command 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | workflow_call: 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | pull-requests: read 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout Monorepo 17 | uses: actions/checkout@v4 18 | 19 | - name: Install devbox 20 | uses: jetify-com/devbox-install-action@v0.13.0 21 | with: 22 | enable-cache: true 23 | 24 | - name: Mount golang cache 25 | uses: actions/cache@v4 26 | with: 27 | path: | 28 | ~/.cache/golangci-lint 29 | ~/.cache/go-build 30 | ~/go/pkg 31 | key: ${{ runner.os }}-tests-${{ hashFiles('**/*.sum') }} 32 | restore-keys: | 33 | ${{ runner.os }}-tests 34 | ${{ runner.os }} 35 | 36 | - name: Build 37 | run: devbox run build 38 | 39 | - name: Test 40 | run: devbox run test 41 | -------------------------------------------------------------------------------- /pkg/stores/ssmstore/config.go: -------------------------------------------------------------------------------- 1 | package ssmstore 2 | 3 | import ( 4 | "path" 5 | 6 | "go.jetify.com/envsec/pkg/envsec" 7 | ) 8 | 9 | const pathPrefix = "/jetpack-data/env" 10 | 11 | type SSMConfig struct { 12 | Region string 13 | AccessKeyID string 14 | SecretAccessKey string 15 | SessionToken string 16 | KmsKeyID string 17 | 18 | VarPathFn func(envId envsec.EnvID, varName string) string 19 | PathNamespaceFn func(envId envsec.EnvID) string 20 | } 21 | 22 | func (c *SSMConfig) varPath(envID envsec.EnvID, varName string) string { 23 | if c.VarPathFn != nil { 24 | return c.VarPathFn(envID, varName) 25 | } 26 | return path.Join( 27 | c.pathNamespace(envID), 28 | envID.ProjectID, 29 | envID.EnvName, 30 | varName, 31 | ) 32 | } 33 | 34 | func (c *SSMConfig) pathNamespace(envID envsec.EnvID) string { 35 | if c.PathNamespaceFn != nil { 36 | return c.PathNamespaceFn(envID) 37 | } 38 | return path.Join(pathPrefix, envID.OrgID) 39 | } 40 | 41 | func (c *SSMConfig) hasDefaultPaths() bool { 42 | return c.VarPathFn == nil && c.PathNamespaceFn == nil 43 | } 44 | -------------------------------------------------------------------------------- /docs/envsec_completion_bash.md: -------------------------------------------------------------------------------- 1 | ## envsec completion bash 2 | 3 | Generate the autocompletion script for bash 4 | 5 | ### Synopsis 6 | 7 | Generate the autocompletion script for the bash shell. 8 | 9 | This script depends on the 'bash-completion' package. 10 | If it is not installed already, you can install it via your OS's package manager. 11 | 12 | To load completions in your current shell session: 13 | 14 | source <(envsec completion bash) 15 | 16 | To load completions for every new session, execute once: 17 | 18 | #### Linux: 19 | 20 | envsec completion bash > /etc/bash_completion.d/envsec 21 | 22 | #### macOS: 23 | 24 | envsec completion bash > $(brew --prefix)/etc/bash_completion.d/envsec 25 | 26 | You will need to start a new shell for this setup to take effect. 27 | 28 | 29 | ``` 30 | envsec completion bash 31 | ``` 32 | 33 | ### Options 34 | 35 | ``` 36 | -h, --help help for bash 37 | --no-descriptions disable completion descriptions 38 | ``` 39 | 40 | ### SEE ALSO 41 | 42 | * [envsec completion](envsec_completion.md) - Generate the autocompletion script for the specified shell 43 | 44 | -------------------------------------------------------------------------------- /devbox.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | "go": "latest", 4 | "golangci-lint": "latest" 5 | }, 6 | "shell": { 7 | "init_hook": [ 8 | "export \"GOROOT=$(go env GOROOT)\"", 9 | "export \"PATH=$(pwd)/dist:$PATH\"" 10 | ], 11 | "scripts": { 12 | "build": "go build -o dist/envsec cmd/envsec/main.go", 13 | "lint": "golangci-lint run -c ../.golangci.yml", 14 | "test": "go test ./...", 15 | "login-dev": [ 16 | "echo 'WARNING: auth-service from frontend must be running locally'", 17 | "export ENVSEC_CLIENT_ID=3945b320-bd31-4313-af27-846b67921acb", 18 | "export ENVSEC_ISSUER=https://laughing-agnesi-vzh2rap9f6.projects.oryapis.com", 19 | "export ENVSEC_JETPACK_API_HOST=https://apisvc-6no3bdensq-uk.a.run.app", 20 | // set ENVSEC_JETPACK_API_HOST to localhost:8080 if running apisvc locally 21 | // "export ENVSEC_JETPACK_API_HOST=http://localhost:8080", 22 | "devbox run build", 23 | "dist/envsec auth login", 24 | ], 25 | "fmt": "golangci-lint run", 26 | } 27 | }, 28 | "nixpkgs": { 29 | "commit": "f80ac848e3d6f0c12c52758c0f25c10c97ca3b62" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /docs/envsec_completion_zsh.md: -------------------------------------------------------------------------------- 1 | ## envsec completion zsh 2 | 3 | Generate the autocompletion script for zsh 4 | 5 | ### Synopsis 6 | 7 | Generate the autocompletion script for the zsh shell. 8 | 9 | If shell completion is not already enabled in your environment you will need 10 | to enable it. You can execute the following once: 11 | 12 | echo "autoload -U compinit; compinit" >> ~/.zshrc 13 | 14 | To load completions in your current shell session: 15 | 16 | source <(envsec completion zsh) 17 | 18 | To load completions for every new session, execute once: 19 | 20 | #### Linux: 21 | 22 | envsec completion zsh > "${fpath[1]}/_envsec" 23 | 24 | #### macOS: 25 | 26 | envsec completion zsh > $(brew --prefix)/share/zsh/site-functions/_envsec 27 | 28 | You will need to start a new shell for this setup to take effect. 29 | 30 | 31 | ``` 32 | envsec completion zsh [flags] 33 | ``` 34 | 35 | ### Options 36 | 37 | ``` 38 | -h, --help help for zsh 39 | --no-descriptions disable completion descriptions 40 | ``` 41 | 42 | ### SEE ALSO 43 | 44 | * [envsec completion](envsec_completion.md) - Generate the autocompletion script for the specified shell 45 | 46 | -------------------------------------------------------------------------------- /pkg/envcli/set.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Jetify Inc. and contributors. All rights reserved. 2 | // Use of this source code is governed by the license in the LICENSE file. 3 | 4 | package envcli 5 | 6 | import ( 7 | "github.com/pkg/errors" 8 | "github.com/spf13/cobra" 9 | "go.jetify.com/envsec/pkg/envsec" 10 | ) 11 | 12 | type setCmdFlags struct { 13 | configFlags 14 | } 15 | 16 | func SetCmd() *cobra.Command { 17 | flags := &setCmdFlags{} 18 | command := &cobra.Command{ 19 | Use: "set = [=]...", 20 | Short: "Securely store one or more environment variables", 21 | Long: "Securely store one or more environment variables. To test contents of a file as a secret use set=@", 22 | Args: cobra.MinimumNArgs(1), 23 | PreRunE: func(cmd *cobra.Command, args []string) error { 24 | return envsec.ValidateSetArgs(args) 25 | }, 26 | RunE: func(cmd *cobra.Command, args []string) error { 27 | ctx := cmd.Context() 28 | cmdCfg, err := flags.genConfig(cmd) 29 | if err != nil { 30 | return errors.WithStack(err) 31 | } 32 | 33 | return cmdCfg.envsec.SetFromArgs(ctx, args) 34 | }, 35 | } 36 | flags.register(command) 37 | return command 38 | } 39 | -------------------------------------------------------------------------------- /internal/git/git_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestNormalizeGitRepoURL(t *testing.T) { 8 | tests := []struct { 9 | input string 10 | expected string 11 | }{ 12 | {"git@github.com:username/repo.git", "github.com/username/repo"}, 13 | {"https://github.com/username/repo.git", "github.com/username/repo"}, 14 | {"http://github.com/username/repo.git", "github.com/username/repo"}, 15 | {"https://www.github.com/username/repo.git", "github.com/username/repo"}, 16 | {"http://www.github.com/username/repo.git", "github.com/username/repo"}, 17 | {"git@github.com:username/repo", "github.com/username/repo"}, 18 | {"https://github.com/username/repo", "github.com/username/repo"}, 19 | {"http://github.com/username/repo", "github.com/username/repo"}, 20 | {"https://www.github.com/username/repo", "github.com/username/repo"}, 21 | {"http://www.github.com/username/repo", "github.com/username/repo"}, 22 | } 23 | 24 | for _, test := range tests { 25 | t.Run(test.input, func(t *testing.T) { 26 | result := normalizeGitRepoURL(test.input) 27 | if result != test.expected { 28 | t.Errorf("Expected %s, but got %s", test.expected, result) 29 | } 30 | }) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /pkg/envsec/projects.go: -------------------------------------------------------------------------------- 1 | package envsec 2 | 3 | import ( 4 | "context" 5 | "io" 6 | 7 | "connectrpc.com/connect" 8 | "go.jetify.com/envsec/internal/tux" 9 | "go.jetify.com/pkg/api" 10 | v1alpha1 "go.jetify.com/pkg/api/gen/priv/projects/v1alpha1" 11 | ) 12 | 13 | func (e *Envsec) DescribeCurrentProject( 14 | ctx context.Context, 15 | w io.Writer, 16 | ) error { 17 | project, err := e.ProjectConfig() 18 | if err != nil { 19 | return err 20 | } 21 | 22 | authClient, err := e.AuthClient() 23 | if err != nil { 24 | return err 25 | } 26 | 27 | tok, err := authClient.LoginFlowIfNeededForOrg(ctx, project.OrgID.String()) 28 | if err != nil { 29 | return err 30 | } 31 | 32 | apiClient := api.NewClient(ctx, e.APIHost, tok) 33 | response, err := apiClient.ProjectsClient().GetProject(ctx, 34 | connect.NewRequest(&v1alpha1.GetProjectRequest{ 35 | Id: project.ProjectID.String(), 36 | })) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | err = tux.FTable(w, [][]string{ 42 | {"Project", response.Msg.GetProject().GetName()}, 43 | {"project ID", project.ProjectID.String()}, 44 | {"Org ID", project.OrgID.String()}, 45 | }) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /docs/envsec.md: -------------------------------------------------------------------------------- 1 | ## envsec 2 | 3 | Manage environment variables and secrets 4 | 5 | ### Synopsis 6 | 7 | Manage environment variables and secrets 8 | 9 | Securely stores and retrieves environment variables on the cloud. 10 | Environment variables are always encrypted, which makes it possible to 11 | store values that contain passwords and other secrets. 12 | 13 | 14 | ``` 15 | envsec [flags] 16 | ``` 17 | 18 | ### Options 19 | 20 | ``` 21 | -h, --help help for envsec 22 | ``` 23 | 24 | ### SEE ALSO 25 | 26 | * [envsec auth](envsec_auth.md) - envsec auth commands 27 | * [envsec completion](envsec_completion.md) - Generate the autocompletion script for the specified shell 28 | * [envsec download](envsec_download.md) - Download environment variables into the specified file 29 | * [envsec exec](envsec_exec.md) - Execute a command with Jetify-stored environment variables 30 | * [envsec init](envsec_init.md) - initialize directory and envsec project 31 | * [envsec ls](envsec_ls.md) - List all stored environment variables 32 | * [envsec rm](envsec_rm.md) - Delete one or more environment variables 33 | * [envsec set](envsec_set.md) - Securely store one or more environment variables 34 | * [envsec upload](envsec_upload.md) - Upload variables defined in a .env file 35 | 36 | -------------------------------------------------------------------------------- /pkg/envcli/upload.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Jetify Inc. and contributors. All rights reserved. 2 | // Use of this source code is governed by the license in the LICENSE file. 3 | 4 | package envcli 5 | 6 | import ( 7 | "github.com/spf13/cobra" 8 | "go.jetify.com/envsec/pkg/envsec" 9 | ) 10 | 11 | type uploadCmdFlags struct { 12 | configFlags 13 | format string 14 | } 15 | 16 | func UploadCmd() *cobra.Command { 17 | flags := &uploadCmdFlags{} 18 | command := &cobra.Command{ 19 | Use: "upload []...", 20 | Short: "Upload variables defined in a .env file", 21 | Long: "Upload variables defined in one or more .env files. The files " + 22 | "should have one NAME=VALUE per line.", 23 | Args: cobra.MinimumNArgs(1), 24 | PreRunE: func(cmd *cobra.Command, args []string) error { 25 | return envsec.ValidateFormat(flags.format) 26 | }, 27 | RunE: func(cmd *cobra.Command, paths []string) error { 28 | cmdCfg, err := flags.genConfig(cmd) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | return cmdCfg.envsec.Upload(cmd.Context(), paths, flags.format) 34 | }, 35 | } 36 | 37 | command.Flags().StringVarP( 38 | &flags.format, "format", "f", "", "File format: dotenv or json") 39 | flags.register(command) 40 | 41 | return command 42 | } 43 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | project_name: envsec 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | builds: 8 | - main: ./cmd/envsec/main.go 9 | binary: envsec 10 | flags: 11 | - -trimpath 12 | mod_timestamp: "{{ .CommitTimestamp }}" # For reproducible builds 13 | ldflags: 14 | - -s -w # Strip debug 15 | - -X go.jetify.com/envsec/internal/build.Version={{.Version}} 16 | - -X go.jetify.com/envsec/internal/build.Commit={{.Commit}} 17 | - -X go.jetify.com/envsec/internal/build.CommitDate={{.CommitDate}} 18 | env: 19 | - CGO_ENABLED=0 20 | - GO111MODULE=on 21 | goos: 22 | - darwin 23 | - linux 24 | - windows 25 | goarch: 26 | - "386" 27 | - amd64 28 | - arm 29 | - arm64 30 | ignore: 31 | - goos: darwin 32 | goarch: "386" 33 | 34 | archives: 35 | - files: 36 | - no-files-will-match-* # Glob that does not match to create archive with only binaries. 37 | name_template: "{{ .ProjectName }}_{{ .Tag }}_{{ .Os }}_{{ .Arch }}" 38 | 39 | checksum: 40 | name_template: "checksums.txt" 41 | algorithm: sha256 42 | 43 | release: 44 | prerelease: auto 45 | draft: true 46 | github: 47 | owner: jetify-com 48 | name: envsec 49 | 50 | snapshot: 51 | name_template: "{{ .Tag }}-devel" 52 | -------------------------------------------------------------------------------- /pkg/envcli/download.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Jetify Inc. and contributors. All rights reserved. 2 | // Use of this source code is governed by the license in the LICENSE file. 3 | 4 | package envcli 5 | 6 | import ( 7 | "github.com/pkg/errors" 8 | "github.com/spf13/cobra" 9 | "go.jetify.com/envsec/pkg/envsec" 10 | ) 11 | 12 | type downloadCmdFlags struct { 13 | configFlags 14 | format string 15 | } 16 | 17 | func DownloadCmd() *cobra.Command { 18 | flags := &downloadCmdFlags{} 19 | command := &cobra.Command{ 20 | Use: "download ", 21 | Short: "Download environment variables into the specified file", 22 | Long: "Download environment variables stored into the specified file (most commonly a .env file). The format of the file is one NAME=VALUE per line.", 23 | Args: cobra.ExactArgs(1), 24 | PreRunE: func(cmd *cobra.Command, args []string) error { 25 | return envsec.ValidateFormat(flags.format) 26 | }, 27 | RunE: func(cmd *cobra.Command, args []string) error { 28 | cmdCfg, err := flags.genConfig(cmd) 29 | if err != nil { 30 | return errors.WithStack(err) 31 | } 32 | return cmdCfg.envsec.Download(cmd.Context(), args[0], flags.format) 33 | }, 34 | } 35 | 36 | flags.register(command) 37 | command.Flags().StringVarP( 38 | &flags.format, "format", "f", "env", "file format: dotenv or json") 39 | 40 | return command 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.ref || github.run_id }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | # Build/Release on demand 9 | workflow_dispatch: 10 | push: 11 | tags: 12 | - "*" # Tags that trigger a new release version 13 | 14 | permissions: 15 | contents: write 16 | pull-requests: read 17 | 18 | jobs: 19 | tests: 20 | uses: ./.github/workflows/tests.yml 21 | release: 22 | needs: tests 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout Monorepo 26 | uses: actions/checkout@v4 27 | 28 | - name: Set up Go 29 | uses: actions/setup-go@v5 30 | with: 31 | go-version-file: ./go.mod 32 | cache: false 33 | 34 | - name: Mount golang cache 35 | uses: actions/cache@v4 36 | with: 37 | path: | 38 | ~/.cache/go-build 39 | ~/go/pkg 40 | key: ${{ runner.os }}-release-${{ hashFiles('**/*.sum') }} 41 | restore-keys: | 42 | ${{ runner.os }}- 43 | 44 | 45 | - name: Release with goreleaser 46 | uses: goreleaser/goreleaser-action@v5 47 | with: 48 | distribution: goreleaser 49 | version: latest 50 | args: release --clean 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | -------------------------------------------------------------------------------- /internal/build/build.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Jetify Inc. and contributors. All rights reserved. 2 | // Use of this source code is governed by the license in the LICENSE file. 3 | 4 | package build 5 | 6 | import ( 7 | "os" 8 | "strings" 9 | ) 10 | 11 | // These variables are set by the build script. 12 | var ( 13 | IsDev = Version == "0.0.0-dev" 14 | Version = "0.0.0-dev" 15 | Commit = "none" 16 | CommitDate = "unknown" 17 | ) 18 | 19 | func init() { 20 | buildEnv := strings.ToLower(os.Getenv("ENVSEC_BUILD_ENV")) 21 | switch buildEnv { 22 | case "prod": 23 | IsDev = false 24 | case "dev": 25 | IsDev = true 26 | } 27 | } 28 | 29 | func Issuer() string { 30 | if IsDev { 31 | return "https://laughing-agnesi-vzh2rap9f6.projects.oryapis.com" 32 | } 33 | return "https://accounts.jetify.com" 34 | } 35 | 36 | func Audience() string { 37 | return "https://api.jetify.com" 38 | } 39 | 40 | func ClientID() string { 41 | if IsDev { 42 | return "3945b320-bd31-4313-af27-846b67921acb" 43 | } 44 | return "ff3d4c9c-1ac8-42d9-bef1-f5218bb1a9f6" 45 | } 46 | 47 | func JetpackAPIHost() string { 48 | if IsDev { 49 | return "https://api.jetpack.dev" 50 | } 51 | return "https://api.jetpack.io" 52 | } 53 | 54 | func BuildEnv() string { 55 | if IsDev { 56 | return "dev" 57 | } 58 | return "prod" 59 | } 60 | 61 | func SuccessRedirect() string { 62 | if IsDev { 63 | return "https://auth.dev-jetify.com/account/login/success" 64 | } 65 | return "https://auth.jetify.com/account/login/success" 66 | } 67 | -------------------------------------------------------------------------------- /pkg/envcli/version.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Jetify Inc. and contributors. All rights reserved. 2 | // Use of this source code is governed by the license in the LICENSE file. 3 | 4 | package envcli 5 | 6 | import ( 7 | "fmt" 8 | "runtime" 9 | 10 | "github.com/spf13/cobra" 11 | "go.jetify.com/envsec/internal/build" 12 | ) 13 | 14 | type versionFlags struct { 15 | verbose bool 16 | } 17 | 18 | func versionCmd() *cobra.Command { 19 | flags := versionFlags{} 20 | command := &cobra.Command{ 21 | Use: "version", 22 | Short: "Print version information", 23 | Args: cobra.NoArgs, 24 | RunE: func(cmd *cobra.Command, args []string) error { 25 | return versionCmdFunc(cmd, args, flags) 26 | }, 27 | } 28 | 29 | command.Flags().BoolVarP(&flags.verbose, "verbose", "v", false, // value 30 | "displays additional version information", 31 | ) 32 | return command 33 | } 34 | 35 | func versionCmdFunc(cmd *cobra.Command, _ []string, flags versionFlags) error { 36 | w := cmd.OutOrStdout() 37 | if flags.verbose { 38 | _, _ = fmt.Fprintf(w, "Version: %v\n", build.Version) 39 | _, _ = fmt.Fprintf(w, "Build Env: %v\n", build.BuildEnv()) 40 | _, _ = fmt.Fprintf(w, "Platform: %v\n", fmt.Sprintf("%s_%s", runtime.GOOS, runtime.GOARCH)) 41 | _, _ = fmt.Fprintf(w, "Commit: %v\n", build.Commit) 42 | _, _ = fmt.Fprintf(w, "Commit Time: %v\n", build.CommitDate) 43 | _, _ = fmt.Fprintf(w, "Go Version: %v\n", runtime.Version()) 44 | } else { 45 | _, _ = fmt.Fprintf(w, "%v\n", build.Version) 46 | } 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /pkg/envcli/init.go: -------------------------------------------------------------------------------- 1 | package envcli 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spf13/cobra" 7 | "go.jetify.com/envsec/internal/build" 8 | "go.jetify.com/envsec/pkg/envsec" 9 | "go.jetify.com/pkg/envvar" 10 | ) 11 | 12 | type initCmdFlags struct { 13 | force bool 14 | } 15 | 16 | func initCmd() *cobra.Command { 17 | flags := &initCmdFlags{} 18 | command := &cobra.Command{ 19 | Use: "init", 20 | Short: "Initialize directory and envsec project", 21 | Args: cobra.ExactArgs(0), 22 | RunE: func(cmd *cobra.Command, args []string) error { 23 | workingDir, err := os.Getwd() 24 | if err != nil { 25 | return err 26 | } 27 | 28 | e := defaultEnvsec(cmd, workingDir) 29 | return e.NewProject(cmd.Context(), flags.force) 30 | }, 31 | } 32 | 33 | command.Flags().BoolVarP( 34 | &flags.force, 35 | "force", 36 | "f", 37 | false, 38 | "force initialization even if already initialized", 39 | ) 40 | 41 | return command 42 | } 43 | 44 | func defaultEnvsec(cmd *cobra.Command, workingDir string) *envsec.Envsec { 45 | return &envsec.Envsec{ 46 | APIHost: build.JetpackAPIHost(), 47 | Auth: envsec.AuthConfig{ 48 | Audience: []string{envvar.Get("ENVSEC_AUDIENCE", build.Audience())}, 49 | ClientID: envvar.Get("ENVSEC_CLIENT_ID", build.ClientID()), 50 | Issuer: envvar.Get("ENVSEC_ISSUER", build.Issuer()), 51 | SuccessRedirect: envvar.Get("ENVSEC_SUCCESS_REDIRECT", build.SuccessRedirect()), 52 | }, 53 | IsDev: build.IsDev, 54 | Stderr: cmd.ErrOrStderr(), 55 | WorkingDir: workingDir, 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /pkg/envcli/ls.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Jetify Inc. and contributors. All rights reserved. 2 | // Use of this source code is governed by the license in the LICENSE file. 3 | 4 | package envcli 5 | 6 | import ( 7 | "github.com/spf13/cobra" 8 | "go.jetify.com/envsec/pkg/envsec" 9 | ) 10 | 11 | const environmentFlagName = "environment" 12 | 13 | type listCmdFlags struct { 14 | configFlags 15 | ShowValues bool 16 | Format string 17 | } 18 | 19 | func ListCmd() *cobra.Command { 20 | flags := &listCmdFlags{} 21 | 22 | command := &cobra.Command{ 23 | Use: "ls", 24 | Aliases: []string{"list"}, 25 | Short: "List all stored environment variables", 26 | Long: "List all stored environment variables. If no environment flag is provided, variables in all environments will be listed.", 27 | Args: cobra.NoArgs, 28 | RunE: func(cmd *cobra.Command, _ []string) error { 29 | cmdCfg, err := flags.genConfig(cmd) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | secrets, err := cmdCfg.envsec.List(cmd.Context()) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | return envsec.PrintEnvVar( 40 | cmd.OutOrStdout(), cmdCfg.envsec.EnvID, secrets, flags.ShowValues, flags.Format) 41 | }, 42 | } 43 | 44 | command.Flags().BoolVarP( 45 | &flags.ShowValues, 46 | "show", 47 | "s", 48 | false, 49 | "display the value of each environment variable (secrets included)", 50 | ) 51 | command.Flags().StringVarP( 52 | &flags.Format, 53 | "format", 54 | "f", 55 | "table", 56 | "format to use for displaying keys and values, one of: table, dotenv, json", 57 | ) 58 | flags.register(command) 59 | 60 | return command 61 | } 62 | -------------------------------------------------------------------------------- /pkg/envsec/auth.go: -------------------------------------------------------------------------------- 1 | package envsec 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | 8 | "go.jetify.com/pkg/api" 9 | "go.jetify.com/pkg/auth" 10 | ) 11 | 12 | func (e *Envsec) AuthClient() (*auth.Client, error) { 13 | return auth.NewClient( 14 | e.Auth.Issuer, 15 | e.Auth.ClientID, 16 | []string{"openid", "offline_access", "email", "profile"}, 17 | e.Auth.SuccessRedirect, 18 | e.Auth.Audience, 19 | ) 20 | } 21 | 22 | func (e *Envsec) WhoAmI( 23 | ctx context.Context, 24 | w io.Writer, 25 | showTokens bool, 26 | ) error { 27 | authClient, err := e.AuthClient() 28 | if err != nil { 29 | return err 30 | } 31 | 32 | tok, err := authClient.GetSession(ctx) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | idClaims := tok.IDClaims() 38 | 39 | _, _ = fmt.Fprintf(w, "Logged in\n") 40 | _, _ = fmt.Fprintf(w, "User ID: %s\n", idClaims.Subject) 41 | 42 | if idClaims.OrgID != "" { 43 | _, _ = fmt.Fprintf(w, "Org ID: %s\n", idClaims.OrgID) 44 | } 45 | 46 | if idClaims.Email != "" { 47 | _, _ = fmt.Fprintf(w, "Email: %s\n", idClaims.Email) 48 | } 49 | 50 | if idClaims.Name != "" { 51 | _, _ = fmt.Fprintf(w, "Name: %s\n", idClaims.Name) 52 | } 53 | 54 | apiClient := api.NewClient(ctx, e.APIHost, tok) 55 | 56 | member, err := apiClient.GetMember(ctx, tok.IDClaims().Subject) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | _, _ = fmt.Fprintf(w, "Org name: %s\n", member.Organization.Name) 62 | 63 | if showTokens { 64 | _, _ = fmt.Fprintf(w, "Access Token: %s\n", tok.AccessToken) 65 | _, _ = fmt.Fprintf(w, "ID Token: %s\n", tok.IDToken) 66 | _, _ = fmt.Fprintf(w, "Refresh Token: %s\n", tok.RefreshToken) 67 | } 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /pkg/envcli/exec.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Jetify Inc. and contributors. All rights reserved. 2 | // Use of this source code is governed by the license in the LICENSE file. 3 | 4 | package envcli 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "os/exec" 10 | "strings" 11 | 12 | "github.com/pkg/errors" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | type execCmdFlags struct { 17 | configFlags 18 | } 19 | 20 | func ExecCmd() *cobra.Command { 21 | flags := &execCmdFlags{} 22 | command := &cobra.Command{ 23 | Use: "exec ", 24 | Short: "Execute a command with Jetify-stored environment variables", 25 | Long: "Execute a specified command with remote environment variables being present for the duration of the command. If an environment variable exists both locally and in remote storage, the remotely stored one is prioritized.", 26 | Args: cobra.MinimumNArgs(1), 27 | RunE: func(cmd *cobra.Command, args []string) error { 28 | cmdCfg, err := flags.genConfig(cmd) 29 | if err != nil { 30 | return err 31 | } 32 | commandString := strings.Join(args, " ") 33 | commandToRun := exec.Command("/bin/sh", "-c", commandString) 34 | 35 | // Get list of stored env variables 36 | envVars, err := cmdCfg.envsec.List(cmd.Context()) 37 | if err != nil { 38 | return errors.WithStack(err) 39 | } 40 | // Attach stored env variables to the command environment 41 | commandToRun.Env = os.Environ() 42 | for _, envVar := range envVars { 43 | commandToRun.Env = append(commandToRun.Env, fmt.Sprintf("%s=%s", envVar.Name, envVar.Value)) 44 | } 45 | commandToRun.Stdin = cmd.InOrStdin() 46 | commandToRun.Stdout = cmd.OutOrStdout() 47 | commandToRun.Stderr = cmd.ErrOrStderr() 48 | return commandToRun.Run() 49 | }, 50 | } 51 | flags.register(command) 52 | return command 53 | } 54 | -------------------------------------------------------------------------------- /pkg/envsec/store.go: -------------------------------------------------------------------------------- 1 | package envsec 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "go.jetify.com/pkg/auth/session" 8 | ) 9 | 10 | // Uniquely identifies an environment in which we store environment variables. 11 | type EnvID struct { 12 | // A string that uniquely identifies the project to which the environment belongs. 13 | ProjectID string 14 | // A string that uniquely identifies the organization to which the environment belongs. 15 | OrgID string 16 | // A name that uniquely identifies the environment within the project. 17 | // Usually one of: 'dev', 'prod'. 18 | EnvName string 19 | } 20 | 21 | func NewEnvID(projectID, orgID, envName string) (EnvID, error) { 22 | if projectID == "" { 23 | return EnvID{}, errors.New("ProjectId can not be empty") 24 | } 25 | return EnvID{ 26 | ProjectID: projectID, 27 | OrgID: orgID, 28 | EnvName: envName, 29 | }, nil 30 | } 31 | 32 | type Store interface { 33 | // List all environmnent variables and their values associated with the given envId. 34 | List(ctx context.Context, envID EnvID) ([]EnvVar, error) 35 | // Set the value of an environment variable. 36 | Set(ctx context.Context, envID EnvID, name, value string) error 37 | // Set the values of multiple environment variables. 38 | SetAll(ctx context.Context, envID EnvID, values map[string]string) error 39 | // Get the value of an environment variable. 40 | Get(ctx context.Context, envID EnvID, name string) (string, error) 41 | // Get the values of multiple environment variables. 42 | GetAll(ctx context.Context, envID EnvID, names []string) ([]EnvVar, error) 43 | // Delete an environment variable. 44 | Delete(ctx context.Context, envID EnvID, name string) error 45 | // Delete multiple environment variables. 46 | DeleteAll(ctx context.Context, envID EnvID, names []string) error 47 | // InitForUser initializes the store for current user. 48 | InitForUser(ctx context.Context, e *Envsec) (*session.Token, error) 49 | } 50 | 51 | type EnvVar struct { 52 | Name string 53 | Value string 54 | } 55 | -------------------------------------------------------------------------------- /internal/git/git.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "strings" 9 | ) 10 | 11 | func CreateGitIgnore(path string) error { 12 | gitIgnorePath := filepath.Join(path, ".gitignore") 13 | return os.WriteFile(gitIgnorePath, []byte("*"), 0o600) 14 | } 15 | 16 | func GitRepoURL(wd string) (string, error) { 17 | cmd := exec.Command("git", "config", "--get", "remote.origin.url") 18 | cmd.Dir = wd 19 | output, err := cmd.CombinedOutput() 20 | if err != nil { 21 | return "", fmt.Errorf("failed to get git remote origin url: %w", err) 22 | } 23 | return normalizeGitRepoURL(string(output)), nil 24 | } 25 | 26 | func GitSubdirectory(wd string) (string, error) { 27 | cmd := exec.Command("git", "rev-parse", "--show-prefix") 28 | cmd.Dir = wd 29 | output, err := cmd.CombinedOutput() 30 | if err != nil { 31 | return "", err 32 | } 33 | return filepath.Clean(strings.TrimSpace(string(output))), nil 34 | } 35 | 36 | // github 37 | // git format git@github.com:jetify-com/opensource.git 38 | // https format https://github.com/jetify-com/opensource.git 39 | 40 | // bitbucket 41 | 42 | // git@bitbucket.org:fargo3d/public.git 43 | // https://bitbucket.org/fargo3d/public.git 44 | 45 | // gh format is same as git 46 | // 47 | // normalized: github.com/jetify-com/opensource 48 | func normalizeGitRepoURL(repoURL string) string { 49 | result := strings.TrimSpace(repoURL) 50 | if strings.HasPrefix(result, "git@") { 51 | result = strings.TrimPrefix(strings.Replace(result, ":", "/", 1), "git@") 52 | } else { 53 | result = strings.TrimPrefix(result, "https://") 54 | result = strings.TrimPrefix(result, "http://") 55 | } 56 | 57 | // subdomain www is rarely used but the big 3 (github, gitlab, bitbucket) 58 | // allow it. Obviously this doesn't work for all subdomains. 59 | return strings.TrimSuffix(strings.TrimPrefix(result, "www."), ".git") 60 | } 61 | 62 | func IsInGitRepo(wd string) bool { 63 | cmd := exec.Command("git", "rev-parse", "--is-inside-work-tree") 64 | cmd.Dir = wd 65 | output, err := cmd.CombinedOutput() 66 | if err != nil { 67 | return false 68 | } 69 | return strings.TrimSpace(string(output)) == "true" 70 | } 71 | -------------------------------------------------------------------------------- /pkg/envcli/gen-docs.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Jetify Inc. and contributors. All rights reserved. 2 | // Use of this source code is governed by the license in the LICENSE file. 3 | 4 | package envcli 5 | 6 | import ( 7 | "io/fs" 8 | "os" 9 | "path/filepath" 10 | 11 | "github.com/pkg/errors" 12 | "github.com/spf13/cobra" 13 | "github.com/spf13/cobra/doc" 14 | ) 15 | 16 | func genDocsCmd() *cobra.Command { 17 | genDocsCmd := &cobra.Command{ 18 | Use: "gen-docs ", 19 | Short: "[Internal] Generate documentation for the CLI", 20 | Long: "[Internal] Generates the documentation for the CLI's Cobra commands. " + 21 | "Docs are placed in the directory specified by .", 22 | Hidden: true, 23 | Args: cobra.ExactArgs(1), 24 | RunE: func(cmd *cobra.Command, args []string) error { 25 | wd, err := os.Getwd() 26 | if err != nil { 27 | return errors.WithStack(err) 28 | } 29 | docsPath := filepath.Join(wd, args[0] /* relative path */) 30 | 31 | // We clear out the existing directory so that the doc-pages for 32 | // commands that have been deleted in the CLI will also be removed 33 | // after we re-generate the docs below 34 | if err := clearDir(docsPath); err != nil { 35 | return err 36 | } 37 | 38 | rootCmd := cmd 39 | for rootCmd.HasParent() { 40 | rootCmd = rootCmd.Parent() 41 | } 42 | 43 | // Removes the line in the generated docs of the form: 44 | // ###### Auto generated by spf13/cobra on 18-Jul-2022 45 | rootCmd.DisableAutoGenTag = true 46 | 47 | return errors.WithStack(doc.GenMarkdownTree(rootCmd, docsPath)) 48 | }, 49 | } 50 | 51 | return genDocsCmd 52 | } 53 | 54 | func clearDir(dir string) error { 55 | // if the dir doesn't exist, use default filemode 0755 to create it 56 | // if the dir exists, use its own filemode to re-create it 57 | var mode os.FileMode 58 | f, err := os.Stat(dir) 59 | if err == nil { 60 | mode = f.Mode() 61 | } else if errors.Is(err, fs.ErrNotExist) { 62 | mode = 0o755 63 | } else { 64 | return errors.WithStack(err) 65 | } 66 | 67 | if err := os.RemoveAll(dir); err != nil { 68 | return errors.WithStack(err) 69 | } 70 | return errors.WithStack(os.MkdirAll(dir, mode)) 71 | } 72 | -------------------------------------------------------------------------------- /pkg/envsec/upload.go: -------------------------------------------------------------------------------- 1 | package envsec 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/joho/godotenv" 10 | "github.com/pkg/errors" 11 | "go.jetify.com/pkg/fileutil" 12 | ) 13 | 14 | // Upload uploads the environment variables for the environment specified from 15 | // the given paths. 16 | // If format is empty, we default to dotenv format unless path ends in .json 17 | func (e *Envsec) Upload(ctx context.Context, paths []string, format string) error { 18 | if err := ValidateFormat(format); err != nil { 19 | return err 20 | } 21 | 22 | filePaths := []string{} 23 | for _, path := range paths { 24 | if !filepath.IsAbs(path) { 25 | path = filepath.Join(e.WorkingDir, path) 26 | } 27 | 28 | if !fileutil.Exists(path) { 29 | return errors.Errorf("could not find file at path: %s", path) 30 | } 31 | filePaths = append(filePaths, path) 32 | } 33 | 34 | envMap := map[string]string{} 35 | var err error 36 | for _, path := range filePaths { 37 | var newVars map[string]string 38 | if format == "json" || (format == "" && filepath.Ext(path) == ".json") { 39 | newVars, err = loadFromJSON([]string{path}) 40 | if err != nil { 41 | return errors.Wrap( 42 | err, 43 | "failed to load from JSON. Ensure the file is a flat key-value "+ 44 | "JSON formatted file", 45 | ) 46 | } 47 | } else { 48 | newVars, err = godotenv.Read(path) 49 | if err != nil { 50 | return errors.WithStack(err) 51 | } 52 | } 53 | for k, v := range newVars { 54 | envMap[k] = v 55 | } 56 | } 57 | 58 | return e.SetMap(ctx, envMap) 59 | } 60 | 61 | func loadFromJSON(filePaths []string) (map[string]string, error) { 62 | envMap := map[string]string{} 63 | for _, filePath := range filePaths { 64 | content, err := os.ReadFile(filePath) 65 | if err != nil { 66 | return nil, errors.WithStack(err) 67 | } 68 | if err = json.Unmarshal(content, &envMap); err != nil { 69 | return nil, errors.WithStack(err) 70 | } 71 | for k, v := range envMap { 72 | envMap[k] = v 73 | } 74 | } 75 | return envMap, nil 76 | } 77 | 78 | func ValidateFormat(format string) error { 79 | if format != "" && format != "json" && format != "dotenv" { 80 | return errors.Errorf("incorrect format. Must be one of json|dotenv") 81 | } 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /pkg/envsec/download.go: -------------------------------------------------------------------------------- 1 | package envsec 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/joho/godotenv" 12 | "github.com/pkg/errors" 13 | "go.jetify.com/envsec/internal/tux" 14 | ) 15 | 16 | // Download downloads the environment variables for the environment specified. 17 | // If format is empty, we default to dotenv format unless path ends in .json 18 | func (e *Envsec) Download(ctx context.Context, path, format string) error { 19 | if err := ValidateFormat(format); err != nil { 20 | return err 21 | } 22 | 23 | envVars, err := e.List(ctx) 24 | if err != nil { 25 | return errors.WithStack(err) 26 | } 27 | 28 | if len(envVars) == 0 { 29 | err = tux.WriteHeader(e.Stderr, 30 | "[DONE] There are no environment variables to download for environment: %s\n", 31 | strings.ToLower(e.EnvID.EnvName), 32 | ) 33 | return errors.WithStack(err) 34 | } 35 | 36 | if !filepath.IsAbs(path) { 37 | path = filepath.Join(e.WorkingDir, path) 38 | } 39 | 40 | envVarMap := map[string]string{} 41 | for _, envVar := range envVars { 42 | envVarMap[envVar.Name] = envVar.Value 43 | } 44 | 45 | if format == "" && filepath.Ext(path) == ".json" { 46 | format = "json" 47 | } 48 | 49 | var contents []byte 50 | if format == "json" { 51 | contents, err = encodeToJSON(envVarMap) 52 | } else { 53 | contents, err = encodeToDotEnv(envVarMap) 54 | } 55 | 56 | if err != nil { 57 | return errors.WithStack(err) 58 | } 59 | 60 | err = os.WriteFile(path, contents, 0o644) 61 | if err != nil { 62 | return errors.WithStack(err) 63 | } 64 | err = tux.WriteHeader(e.Stderr, 65 | "[DONE] Downloaded environment variables to %q for environment: %s\n", 66 | path, 67 | strings.ToLower(e.EnvID.EnvName), 68 | ) 69 | if err != nil { 70 | return errors.WithStack(err) 71 | } 72 | 73 | return nil 74 | } 75 | 76 | func encodeToJSON(m map[string]string) ([]byte, error) { 77 | b := new(bytes.Buffer) 78 | encoder := json.NewEncoder(b) 79 | encoder.SetEscapeHTML(false) 80 | encoder.SetIndent("", " ") 81 | if err := encoder.Encode(m); err != nil { 82 | return nil, errors.WithStack(err) 83 | } 84 | return b.Bytes(), nil 85 | } 86 | 87 | func encodeToDotEnv(m map[string]string) ([]byte, error) { 88 | envContents, err := godotenv.Marshal(m) 89 | if err != nil { 90 | return nil, errors.WithStack(err) 91 | } 92 | return []byte(envContents), nil 93 | } 94 | -------------------------------------------------------------------------------- /pkg/envcli/root.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Jetify Inc. and contributors. All rights reserved. 2 | // Use of this source code is governed by the license in the LICENSE file. 3 | 4 | package envcli 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "fmt" 10 | "io" 11 | "os" 12 | 13 | "github.com/MakeNowJust/heredoc/v2" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | type rootCmdFlags struct { 18 | jsonErrors bool 19 | } 20 | 21 | func RootCmd(flags *rootCmdFlags) *cobra.Command { 22 | command := &cobra.Command{ 23 | Use: "envsec", 24 | Short: "Manage environment variables and secrets", 25 | Long: heredoc.Doc(` 26 | Manage environment variables and secrets 27 | 28 | Securely stores and retrieves environment variables on the cloud. 29 | Environment variables are always encrypted, which makes it possible to 30 | store values that contain passwords and other secrets. 31 | `), 32 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 33 | if flags.jsonErrors { 34 | // Don't print anything to stderr so we can print the error in json 35 | cmd.SetErr(io.Discard) 36 | } 37 | return nil 38 | }, 39 | RunE: func(cmd *cobra.Command, args []string) error { 40 | return cmd.Help() 41 | }, 42 | // we're manually showing usage 43 | SilenceUsage: true, 44 | // We manually capture errors so we can print different formats 45 | SilenceErrors: true, 46 | } 47 | 48 | command.PersistentFlags().BoolVar( 49 | &flags.jsonErrors, 50 | "json-errors", false, "Print errors in json format", 51 | ) 52 | command.Flag("json-errors").Hidden = true 53 | 54 | command.AddCommand(authCmd()) 55 | command.AddCommand(DownloadCmd()) 56 | command.AddCommand(ExecCmd()) 57 | command.AddCommand(genDocsCmd()) 58 | command.AddCommand(initCmd()) 59 | command.AddCommand(ListCmd()) 60 | command.AddCommand(infoCmd()) 61 | command.AddCommand(RemoveCmd()) 62 | command.AddCommand(SetCmd()) 63 | command.AddCommand(UploadCmd()) 64 | command.AddCommand(versionCmd()) 65 | command.SetUsageFunc(UsageFunc) 66 | return command 67 | } 68 | 69 | func Execute(ctx context.Context) int { 70 | flags := &rootCmdFlags{} 71 | cmd := RootCmd(flags) 72 | err := cmd.ExecuteContext(ctx) 73 | if err == nil { 74 | return 0 75 | } 76 | if flags.jsonErrors { 77 | var jsonErr struct { 78 | Error string `json:"error"` 79 | } 80 | jsonErr.Error = err.Error() 81 | b, err := json.Marshal(jsonErr) 82 | if err != nil { 83 | fmt.Fprintln(os.Stderr, err) 84 | } else { 85 | fmt.Fprintln(os.Stderr, string(b)) 86 | } 87 | } else { 88 | fmt.Fprintln(os.Stderr, err) 89 | } 90 | return 1 91 | } 92 | -------------------------------------------------------------------------------- /pkg/envsec/list.go: -------------------------------------------------------------------------------- 1 | package envsec 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "strings" 9 | 10 | "github.com/olekukonko/tablewriter" 11 | "github.com/pkg/errors" 12 | "go.jetify.com/envsec/internal/tux" 13 | ) 14 | 15 | func (e *Envsec) List(ctx context.Context) ([]EnvVar, error) { 16 | return e.Store.List(ctx, e.EnvID) 17 | } 18 | 19 | func PrintEnvVar( 20 | w io.Writer, 21 | envID EnvID, 22 | envVars []EnvVar, // list of (name, value) pairs 23 | expose bool, 24 | format string, 25 | ) error { 26 | envVarsMaskedValue := []EnvVar{} 27 | // Masking envVar values if printValue flag isn't set 28 | for _, envVar := range envVars { 29 | valueToPrint := "*****" 30 | if expose { 31 | valueToPrint = envVar.Value 32 | } 33 | envVarsMaskedValue = append(envVarsMaskedValue, EnvVar{ 34 | Name: envVar.Name, 35 | Value: valueToPrint, 36 | }) 37 | 38 | } 39 | 40 | switch format { 41 | case "table": 42 | return printTableFormat(w, envID, envVarsMaskedValue) 43 | case "dotenv": 44 | return printDotenvFormat(envVarsMaskedValue) 45 | case "json": 46 | return printJSONFormat(envVarsMaskedValue) 47 | default: 48 | return errors.New("incorrect format. Must be one of table|dotenv|json") 49 | } 50 | } 51 | 52 | func printTableFormat(w io.Writer, envID EnvID, envVars []EnvVar) error { 53 | err := tux.WriteHeader(w, "Environment: %s\n", strings.ToLower(envID.EnvName)) 54 | if err != nil { 55 | return errors.WithStack(err) 56 | } 57 | table := tablewriter.NewWriter(w) 58 | table.Header("Name", "Value") 59 | tableValues := [][]string{} 60 | for _, envVar := range envVars { 61 | tableValues = append(tableValues, []string{envVar.Name /*name*/, envVar.Value}) 62 | } 63 | if err := table.Bulk(tableValues); err != nil { 64 | return errors.WithStack(err) 65 | } 66 | 67 | if len(tableValues) == 0 { 68 | fmt.Println("No environment variables currently defined.") 69 | } else { 70 | if err := table.Render(); err != nil { 71 | return errors.WithStack(err) 72 | } 73 | } 74 | 75 | // Add an empty line after the table is rendered. 76 | fmt.Println() 77 | 78 | return nil 79 | } 80 | 81 | func printDotenvFormat(envVars []EnvVar) error { 82 | keyValsToPrint := "" 83 | for _, envVar := range envVars { 84 | keyValsToPrint += fmt.Sprintf("%s=%q\n", envVar.Name, envVar.Value) 85 | } 86 | 87 | // Add an empty line after the table is rendered. 88 | fmt.Println(keyValsToPrint) 89 | 90 | return nil 91 | } 92 | 93 | func printJSONFormat(envVars []EnvVar) error { 94 | data, err := json.MarshalIndent(envVars, "", " ") 95 | if err != nil { 96 | return err 97 | } 98 | fmt.Println(string(data)) 99 | 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /pkg/envcli/usage.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Jetify Inc. and contributors. All rights reserved. 2 | // Use of this source code is governed by the license in the LICENSE file. 3 | 4 | package envcli 5 | 6 | import ( 7 | "github.com/MakeNowJust/heredoc/v2" 8 | "github.com/spf13/cobra" 9 | "go.jetify.com/envsec/internal/tux" 10 | ) 11 | 12 | // TODO: move to file 13 | var usageTmpl = heredoc.Doc(` 14 | {{ "Usage:" | style "h2" }} 15 | {{if .Runnable}}{{.UseLine | style "command" }}{{end}} 16 | {{- if .HasAvailableSubCommands}} {{"" | style "subcommand"}}{{end}} 17 | 18 | 19 | {{- if gt (len .Aliases) 0}} 20 | 21 | {{ "Aliases:" | style "h2" }} 22 | {{.NameAndAliases}} 23 | {{- end}} 24 | 25 | 26 | {{- if .HasExample}} 27 | 28 | {{ "Examples:" | style "h2" }} 29 | {{.Example}} 30 | {{- end}} 31 | 32 | 33 | {{- if .HasAvailableSubCommands}} 34 | 35 | {{ "Available Commands:" | style "h2" }} 36 | {{- range .Commands}} 37 | {{- if (or .IsAvailableCommand (eq .Name "help"))}} 38 | {{rpad .Name .NamePadding | style "subcommand"}} {{.Short}} 39 | {{- end}} 40 | {{- end}} 41 | {{- end}} 42 | 43 | 44 | {{- if .HasAvailableLocalFlags}} 45 | 46 | {{ "Flags:" | style "h2" }} 47 | {{ .LocalFlags.FlagUsages | trimTrailingWhitespaces}} 48 | {{- end}} 49 | 50 | 51 | {{- if .HasAvailableInheritedFlags}} 52 | 53 | {{ "Global Flags:" | style "h2" }} 54 | {{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}} 55 | {{- end}} 56 | 57 | 58 | {{- if .HasHelpSubCommands}} 59 | 60 | {{ "Additional help topics:" | style "h2" }} 61 | {{- range .Commands}} 62 | {{- if .IsAdditionalHelpTopicCommand}} 63 | {{rpad .CommandPath .CommandPathPadding}} {{.Short}} 64 | {{- end}} 65 | {{- end}} 66 | {{- end}} 67 | 68 | 69 | {{- if .HasAvailableSubCommands}} 70 | 71 | Use "{{.CommandPath}} [command] --help" for more information about a command. 72 | {{- end}} 73 | `) 74 | 75 | var baseStyle = tux.StyleSheet{ 76 | Styles: map[string]tux.StyleRule{ 77 | "h1": { 78 | Bold: true, 79 | Foreground: "$purple", 80 | }, 81 | "h2": { 82 | Bold: true, 83 | // Foreground: "$purple", 84 | }, 85 | "command": { 86 | Foreground: "$cyan", 87 | }, 88 | "subcommand": { 89 | Foreground: "$magenta", 90 | }, 91 | "flag": { 92 | Bold: true, 93 | Foreground: "$purple", 94 | }, 95 | }, 96 | Tokens: map[string]string{ 97 | "$purple": "#bd93f9", 98 | "$yellow": "#ffb86c", 99 | "$cyan": "51", 100 | "$magenta": "#ff79c6", 101 | "$green": "#50fa7b", 102 | }, 103 | } 104 | 105 | func UsageFunc(cmd *cobra.Command) error { 106 | t := tux.New() 107 | t.SetIn(cmd.InOrStdin()) 108 | t.SetOut(cmd.OutOrStdout()) 109 | t.SetErr(cmd.ErrOrStderr()) 110 | t.SetStyleSheet(baseStyle) 111 | t.PrintT(usageTmpl, cmd) 112 | return nil 113 | } 114 | -------------------------------------------------------------------------------- /pkg/awsfed/awsfed.go: -------------------------------------------------------------------------------- 1 | package awsfed 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/aws/aws-sdk-go-v2/service/cognitoidentity" 10 | "github.com/aws/aws-sdk-go-v2/service/cognitoidentity/types" 11 | "go.jetify.com/pkg/auth/session" 12 | "go.jetify.com/pkg/envvar" 13 | "go.jetify.com/pkg/filecache" 14 | ) 15 | 16 | const cacheKeyPrefix = "awsfed" 17 | 18 | type AWSFed struct { 19 | AccountID string 20 | IdentityPoolID string 21 | LegacyProvider string 22 | Provider string 23 | Region string 24 | } 25 | 26 | func New() *AWSFed { 27 | return &AWSFed{ 28 | AccountID: "984256416385", 29 | IdentityPoolID: "us-west-2:8111c156-085b-4ac5-b94d-f823205f6261", 30 | LegacyProvider: "auth.jetpack.io", 31 | Provider: envvar.Get( 32 | "ENVSEC_AUTH_DOMAIN", 33 | "accounts.jetify.com", 34 | ), 35 | Region: "us-west-2", 36 | } 37 | } 38 | 39 | func (a *AWSFed) AWSCredsWithLocalCache( 40 | ctx context.Context, 41 | tok *session.Token, 42 | ) (*types.Credentials, error) { 43 | cache := filecache.New[*types.Credentials]("envsec/aws-creds") 44 | return cache.GetOrSetWithTime( 45 | cacheKey(tok), 46 | func() (*types.Credentials, time.Time, error) { 47 | outputCreds, err := a.AWSCreds(ctx, tok.IDToken) 48 | if err != nil { 49 | return nil, time.Time{}, err 50 | } 51 | return outputCreds, *outputCreds.Expiration, nil 52 | }, 53 | ) 54 | } 55 | 56 | // AWSCreds behaves similar to AWSCredsWithLocalCache but it takes a JWT from input 57 | // rather than reading from a file or cache. This is to allow web services use 58 | // this package without having to write every user's JWT in a cache or a file. 59 | func (a *AWSFed) AWSCreds( 60 | ctx context.Context, 61 | idToken string, 62 | ) (*types.Credentials, error) { 63 | svc := cognitoidentity.New( 64 | cognitoidentity.Options{ 65 | Region: a.Region, 66 | }, 67 | ) 68 | 69 | logins := map[string]string{ 70 | a.Provider: idToken, 71 | } 72 | 73 | getIdoutput, err := svc.GetId( 74 | ctx, 75 | &cognitoidentity.GetIdInput{ 76 | AccountId: &a.AccountID, 77 | IdentityPoolId: &a.IdentityPoolID, 78 | Logins: logins, 79 | }, 80 | ) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | output, err := svc.GetCredentialsForIdentity( 86 | ctx, 87 | &cognitoidentity.GetCredentialsForIdentityInput{ 88 | IdentityId: getIdoutput.IdentityId, 89 | Logins: logins, 90 | }, 91 | ) 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | return output.Credentials, nil 97 | } 98 | 99 | func cacheKey(t *session.Token) string { 100 | id := "" 101 | if claims := t.IDClaims(); claims != nil && claims.OrgID != "" { 102 | id = claims.OrgID 103 | } else { 104 | id = fmt.Sprintf("%x", sha256.Sum256([]byte(t.IDToken))) 105 | } 106 | 107 | return fmt.Sprintf("%s-%s", cacheKeyPrefix, id) 108 | } 109 | -------------------------------------------------------------------------------- /pkg/envsec/init.go: -------------------------------------------------------------------------------- 1 | package envsec 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "os" 8 | "path/filepath" 9 | 10 | "go.jetify.com/envsec/internal/flow" 11 | "go.jetify.com/envsec/internal/git" 12 | "go.jetify.com/pkg/api" 13 | "go.jetify.com/pkg/ids" 14 | ) 15 | 16 | var ( 17 | ErrProjectAlreadyInitialized = errors.New("project already initialized") 18 | errProjectNotInitialized = errors.New("project not initialized") 19 | ) 20 | 21 | const ( 22 | dirName = ".jetify" 23 | configName = "project.json" 24 | devConfigName = "dev.project.json" 25 | ) 26 | 27 | type projectConfig struct { 28 | ProjectID ids.ProjectID `json:"project_id"` 29 | OrgID ids.OrgID `json:"org_id"` 30 | } 31 | 32 | func (e *Envsec) NewProject(ctx context.Context, force bool) error { 33 | var err error 34 | 35 | authClient, err := e.AuthClient() 36 | if err != nil { 37 | return err 38 | } 39 | 40 | tok, err := authClient.LoginFlowIfNeeded(ctx) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | projectID, err := (&flow.Init{ 46 | Client: api.NewClient(ctx, e.APIHost, tok), 47 | PromptOverwriteConfig: !force && e.configExists(), 48 | Token: tok, 49 | WorkingDir: e.WorkingDir, 50 | }).Run(ctx) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | dirPath := filepath.Join(e.WorkingDir, dirName) 56 | if err = os.MkdirAll(dirPath, 0o700); err != nil { 57 | return err 58 | } 59 | 60 | if err = git.CreateGitIgnore(dirPath); err != nil { 61 | return err 62 | } 63 | 64 | orgID, err := ids.ParseOrgID(tok.IDClaims().OrgID) 65 | if err != nil { 66 | return err 67 | } 68 | return e.saveConfig(projectID, orgID) 69 | } 70 | 71 | func (e *Envsec) ProjectConfig() (*projectConfig, error) { 72 | data, err := os.ReadFile(e.configPath(e.WorkingDir)) 73 | if errors.Is(err, os.ErrNotExist) { 74 | return nil, errProjectNotInitialized 75 | } else if err != nil { 76 | return nil, err 77 | } 78 | var cfg projectConfig 79 | if err := json.Unmarshal(data, &cfg); err != nil { 80 | return nil, err 81 | } 82 | return &cfg, nil 83 | } 84 | 85 | func (e *Envsec) configPath(wd string) string { 86 | return filepath.Join(wd, dirName, e.configName()) 87 | } 88 | 89 | func (e *Envsec) configName() string { 90 | if e.IsDev { 91 | return devConfigName 92 | } 93 | return configName 94 | } 95 | 96 | func (e *Envsec) saveConfig(projectID ids.ProjectID, orgID ids.OrgID) error { 97 | cfg := projectConfig{ProjectID: projectID, OrgID: orgID} 98 | data, err := json.MarshalIndent(cfg, "", " ") 99 | if err != nil { 100 | return err 101 | } 102 | dirPath := filepath.Join(e.WorkingDir, dirName) 103 | return os.WriteFile(filepath.Join(dirPath, e.configName()), data, 0o600) 104 | } 105 | 106 | func (e *Envsec) configExists() bool { 107 | _, err := os.Stat(e.configPath(e.WorkingDir)) 108 | return err == nil 109 | } 110 | -------------------------------------------------------------------------------- /internal/tux/tux.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Jetify Inc. and contributors. All rights reserved. 2 | // Use of this source code is governed by the license in the LICENSE file. 3 | 4 | package tux 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | "os" 10 | "strings" 11 | "text/template" 12 | "unicode" 13 | 14 | "github.com/charmbracelet/lipgloss" 15 | "github.com/fatih/color" 16 | "github.com/muesli/termenv" 17 | "github.com/pkg/errors" 18 | ) 19 | 20 | type Tux struct { 21 | inReader io.Reader 22 | outWriter io.Writer 23 | errWriter io.Writer 24 | styleSheet StyleSheet 25 | } 26 | 27 | func New() *Tux { 28 | // For now hardcoding the profile (because it's not working otherwise) 29 | // but need to change this to auto-detect appropriately. 30 | lipgloss.SetColorProfile(termenv.ANSI256) 31 | return &Tux{ 32 | inReader: os.Stdin, 33 | outWriter: os.Stdout, 34 | errWriter: os.Stderr, 35 | } 36 | } 37 | 38 | func (tux *Tux) SetOut(w io.Writer) { 39 | tux.outWriter = w 40 | } 41 | 42 | func (tux *Tux) SetErr(w io.Writer) { 43 | tux.errWriter = w 44 | } 45 | 46 | func (tux *Tux) SetIn(r io.Reader) { 47 | tux.inReader = r 48 | } 49 | 50 | func (tux *Tux) SetStyleSheet(styleSheet StyleSheet) { 51 | tux.styleSheet = styleSheet 52 | } 53 | 54 | func trimRight(s string) string { 55 | return strings.TrimRightFunc(s, unicode.IsSpace) 56 | } 57 | 58 | // rpad adds padding to the right of a string. 59 | func rpad(s string, padding int) string { 60 | template := fmt.Sprintf("%%-%ds", padding) 61 | return fmt.Sprintf(template, s) 62 | } 63 | 64 | func (tux *Tux) PrintT(text string, data any) { 65 | // TODO: Initialize once when creating the tux object? 66 | templateFuncs := template.FuncMap{ 67 | "trimTrailingWhitespaces": trimRight, 68 | "rpad": rpad, 69 | "style": StyleFunc(tux.styleSheet), 70 | } 71 | tpl := template.Must(template.New("tpl").Funcs(templateFuncs).Parse(text)) 72 | err := tpl.Execute(tux.outWriter, data) 73 | if err != nil { 74 | tux.MustPrintErr(err) 75 | return 76 | } 77 | } 78 | 79 | func (tux *Tux) MustPrintErr(a ...any) { 80 | _, err := fmt.Fprint(tux.errWriter, a...) 81 | if err != nil { 82 | panic(err) 83 | } 84 | } 85 | 86 | // TODO: Migrate to style sheets 87 | func WriteHeader(w io.Writer, format string, a ...any) error { 88 | headerPrintfFunc := color.New(color.FgHiCyan, color.Bold).SprintfFunc() 89 | message := headerPrintfFunc(format, a...) 90 | _, err := io.WriteString(w, message) 91 | if err != nil { 92 | return errors.WithStack(err) 93 | } 94 | return nil 95 | } 96 | 97 | // QuotedTerms will wrap each term in single-quotation marks 98 | func QuotedTerms(terms []string) []string { 99 | q := []string{} 100 | for _, term := range terms { 101 | // wrap the term in single-quote 102 | q = append(q, "'"+term+"'") 103 | } 104 | return q 105 | } 106 | 107 | func Plural[T any](items []T, singular, plural string) string { 108 | if len(items) == 1 { 109 | return singular 110 | } 111 | return plural 112 | } 113 | -------------------------------------------------------------------------------- /pkg/envcli/auth.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Jetify Inc. and contributors. All rights reserved. 2 | // Use of this source code is governed by the license in the LICENSE file. 3 | 4 | package envcli 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | 10 | "github.com/spf13/cobra" 11 | "go.jetify.com/envsec/internal/build" 12 | "go.jetify.com/pkg/auth" 13 | "go.jetify.com/pkg/envvar" 14 | ) 15 | 16 | func authCmd() *cobra.Command { 17 | cmd := &cobra.Command{ 18 | Use: "auth", 19 | Short: "Authentication commands for envsec", 20 | } 21 | 22 | cmd.AddCommand(loginCmd()) 23 | cmd.AddCommand(logoutCmd()) 24 | cmd.AddCommand(whoAmICmd()) 25 | 26 | return cmd 27 | } 28 | 29 | func loginCmd() *cobra.Command { 30 | cmd := &cobra.Command{ 31 | Use: "login", 32 | Short: "Log in to envsec", 33 | Args: cobra.ExactArgs(0), 34 | RunE: func(cmd *cobra.Command, args []string) error { 35 | client, err := newAuthClient() 36 | if err != nil { 37 | return err 38 | } 39 | 40 | _, err = client.LoginFlow() 41 | if err == nil { 42 | _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Logged in successfully") 43 | } 44 | return err 45 | }, 46 | } 47 | 48 | return cmd 49 | } 50 | 51 | func logoutCmd() *cobra.Command { 52 | cmd := &cobra.Command{ 53 | Use: "logout", 54 | Short: "Log out from envsec", 55 | Args: cobra.ExactArgs(0), 56 | RunE: func(cmd *cobra.Command, args []string) error { 57 | client, err := newAuthClient() 58 | if err != nil { 59 | return err 60 | } 61 | 62 | err = client.LogoutFlow() 63 | if err == nil { 64 | _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Logged out successfully") 65 | } 66 | return err 67 | }, 68 | } 69 | 70 | return cmd 71 | } 72 | 73 | type whoAmICmdFlags struct { 74 | showTokens bool 75 | } 76 | 77 | func whoAmICmd() *cobra.Command { 78 | flags := &whoAmICmdFlags{} 79 | cmd := &cobra.Command{ 80 | Use: "whoami", 81 | Short: "Show the current user", 82 | Args: cobra.ExactArgs(0), 83 | RunE: func(cmd *cobra.Command, args []string) error { 84 | workingDir, err := os.Getwd() 85 | if err != nil { 86 | return err 87 | } 88 | return defaultEnvsec(cmd, workingDir). 89 | WhoAmI(cmd.Context(), cmd.OutOrStdout(), flags.showTokens) 90 | }, 91 | } 92 | 93 | cmd.Flags().BoolVar( 94 | &flags.showTokens, 95 | "show-tokens", 96 | false, 97 | "Show the access, id, and refresh tokens", 98 | ) 99 | 100 | return cmd 101 | } 102 | 103 | func newAuthClient() (*auth.Client, error) { 104 | issuer := envvar.Get("ENVSEC_ISSUER", build.Issuer()) 105 | clientID := envvar.Get("ENVSEC_CLIENT_ID", build.ClientID()) 106 | // TODO: Consider making scopes and audience configurable: 107 | // "ENVSEC_AUTH_SCOPE" = "openid offline_access email profile" 108 | // "ENVSEC_AUTH_AUDIENCE" = "https://api.jetify.com", 109 | return auth.NewClient( 110 | issuer, 111 | clientID, 112 | []string{"openid", "offline_access", "email", "profile"}, 113 | envvar.Get("ENVSEC_SUCCESS_REDIRECT", build.SuccessRedirect()), 114 | []string{envvar.Get("ENVSEC_AUDIENCE", build.Audience())}, 115 | ) 116 | } 117 | -------------------------------------------------------------------------------- /pkg/envsec/set.go: -------------------------------------------------------------------------------- 1 | package envsec 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/pkg/errors" 10 | "github.com/samber/lo" 11 | "go.jetify.com/envsec/internal/tux" 12 | ) 13 | 14 | func (e *Envsec) Set(ctx context.Context, name, value string) error { 15 | return e.SetMap(ctx, map[string]string{name: value}) 16 | } 17 | 18 | func (e *Envsec) SetMap(ctx context.Context, envMap map[string]string) error { 19 | err := ensureValidNames(lo.Keys(envMap)) 20 | if err != nil { 21 | return errors.WithStack(err) 22 | } 23 | 24 | err = e.Store.SetAll(ctx, e.EnvID, envMap) 25 | if err != nil { 26 | return errors.WithStack(err) 27 | } 28 | insertedNames := lo.Keys(envMap) 29 | return tux.WriteHeader(e.Stderr, 30 | "[DONE] Set environment %s %v in environment: %s\n", 31 | tux.Plural(insertedNames, "variable", "variables"), 32 | strings.Join(tux.QuotedTerms(insertedNames), ", "), 33 | strings.ToLower(e.EnvID.EnvName), 34 | ) 35 | } 36 | 37 | func (e *Envsec) SetFromArgs(ctx context.Context, args []string) error { 38 | envMap, err := parseSetArgs(args) 39 | if err != nil { 40 | return errors.WithStack(err) 41 | } 42 | return e.SetMap(ctx, envMap) 43 | } 44 | 45 | func ValidateSetArgs(args []string) error { 46 | for _, arg := range args { 47 | k, _, ok := strings.Cut(arg, "=") 48 | if !ok || k == "" { 49 | return errors.Errorf( 50 | "argument %s must have an '=' to be of the form NAME=VALUE", 51 | arg, 52 | ) 53 | } 54 | } 55 | 56 | return nil 57 | } 58 | 59 | func parseSetArgs(args []string) (map[string]string, error) { 60 | envMap := map[string]string{} 61 | for _, arg := range args { 62 | key, val, _ := strings.Cut(arg, "=") 63 | if strings.HasPrefix(val, "\\@") { 64 | val = strings.TrimPrefix(val, "\\") 65 | } else if strings.HasPrefix(val, "@") { 66 | file := strings.TrimPrefix(val, "@") 67 | if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) { 68 | return nil, errors.Errorf( 69 | "@ syntax is used for setting a secret from a file. file %s "+ 70 | "does not exist. If your value starts with @, escape it with "+ 71 | "a backslash, e.g. %s='\\%s'", 72 | file, 73 | key, 74 | val, 75 | ) 76 | } 77 | c, err := os.ReadFile(file) 78 | if err != nil { 79 | return nil, errors.Wrapf(err, "failed to read file %s", file) 80 | } 81 | val = string(c) 82 | } 83 | envMap[key] = val 84 | } 85 | return envMap, nil 86 | } 87 | 88 | const nameRegexStr = "^[a-zA-Z_][a-zA-Z0-9_]*" 89 | 90 | var nameRegex = regexp.MustCompile(nameRegexStr) 91 | 92 | func ensureValidNames(names []string) error { 93 | for _, name := range names { 94 | 95 | // Any variation of jetpack_ or JETPACK_ prefix is not allowed 96 | lowerName := strings.ToLower(name) 97 | if strings.HasPrefix(lowerName, "jetpack_") { 98 | return errors.Errorf( 99 | "name %s cannot start with JETPACK_ (or lowercase)", 100 | name, 101 | ) 102 | } 103 | 104 | if !nameRegex.MatchString(name) { 105 | return errors.Errorf( 106 | "name %s must match the regular expression: %s ", 107 | name, 108 | nameRegexStr, 109 | ) 110 | } 111 | } 112 | return nil 113 | } 114 | -------------------------------------------------------------------------------- /internal/tux/style.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Jetify Inc. and contributors. All rights reserved. 2 | // Use of this source code is governed by the license in the LICENSE file. 3 | 4 | package tux 5 | 6 | import ( 7 | "strings" 8 | 9 | "github.com/charmbracelet/lipgloss" 10 | ) 11 | 12 | type StyleSheet struct { 13 | Styles map[string]StyleRule 14 | Tokens map[string]string 15 | } 16 | 17 | type StyleRule struct { 18 | Bold bool 19 | Italic bool 20 | Underline bool 21 | Strikethrough bool 22 | Blink bool 23 | Faint bool 24 | Foreground string 25 | ForegroundInverted string 26 | Background string 27 | BackgroundInverted string 28 | PaddingTop int 29 | PaddingRight int 30 | PaddingBottom int 31 | PaddingLeft int 32 | MarginTop int 33 | MarginRight int 34 | MarginBottom int 35 | MarginLeft int 36 | } 37 | 38 | func Render(styleSheet StyleSheet, class, text string) string { 39 | return text 40 | } 41 | 42 | type StyleRenderer interface { 43 | Render(str ...string) string 44 | } 45 | 46 | func Renderer(styleRule StyleRule, tokens map[string]string) StyleRenderer { 47 | renderer := lipgloss.NewStyle() 48 | renderer = renderer.Bold(styleRule.Bold) 49 | renderer = renderer.Italic(styleRule.Italic) 50 | renderer = renderer.Underline(styleRule.Underline) 51 | renderer = renderer.Strikethrough(styleRule.Strikethrough) 52 | renderer = renderer.Blink(styleRule.Blink) 53 | renderer = renderer.Faint(styleRule.Faint) 54 | if styleRule.Foreground != "" { 55 | renderer = renderer.Foreground(getColor(styleRule.Foreground, styleRule.ForegroundInverted, tokens)) 56 | } 57 | if styleRule.Background != "" { 58 | renderer = renderer.Background(getColor(styleRule.Background, styleRule.BackgroundInverted, tokens)) 59 | } 60 | renderer = renderer.PaddingTop(styleRule.PaddingTop) 61 | renderer = renderer.PaddingRight(styleRule.PaddingRight) 62 | renderer = renderer.PaddingBottom(styleRule.PaddingBottom) 63 | renderer = renderer.PaddingLeft(styleRule.PaddingLeft) 64 | renderer = renderer.MarginTop(styleRule.MarginTop) 65 | renderer = renderer.MarginRight(styleRule.MarginRight) 66 | renderer = renderer.MarginBottom(styleRule.MarginBottom) 67 | renderer = renderer.MarginLeft(styleRule.MarginLeft) 68 | return renderer 69 | } 70 | 71 | func getColor(token, invertedToken string, tokens map[string]string) lipgloss.TerminalColor { 72 | color := resolveToken(token, tokens) 73 | invertedColor := resolveToken(invertedToken, tokens) 74 | 75 | if invertedColor == "" { 76 | return lipgloss.Color(color) 77 | } 78 | return lipgloss.AdaptiveColor{ 79 | Dark: color, 80 | Light: invertedColor, 81 | } 82 | } 83 | 84 | func resolveToken(token string, tokens map[string]string) string { 85 | if strings.HasPrefix(token, "$") { 86 | if resolved, ok := tokens[token]; ok { 87 | return resolved 88 | } 89 | return ansiColors[token] 90 | } 91 | return token 92 | } 93 | 94 | func StyleFunc(styleSheet StyleSheet) func(class, text string) string { 95 | return func(class, text string) string { 96 | styleRule, exists := styleSheet.Styles[class] 97 | // Return the text as is if the class is not found. 98 | if !exists { 99 | return text 100 | } 101 | result := Renderer(styleRule, styleSheet.Tokens).Render(text) 102 | return result 103 | } 104 | } 105 | 106 | // TODO: Add list of default ANSI named colors 107 | var ansiColors = map[string]string{} 108 | -------------------------------------------------------------------------------- /devbox.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfile_version": "1", 3 | "packages": { 4 | "github:NixOS/nixpkgs/f80ac848e3d6f0c12c52758c0f25c10c97ca3b62": { 5 | "resolved": "github:NixOS/nixpkgs/f80ac848e3d6f0c12c52758c0f25c10c97ca3b62" 6 | }, 7 | "go@latest": { 8 | "last_modified": "2025-02-12T00:10:52Z", 9 | "resolved": "github:NixOS/nixpkgs/83a2581c81ff5b06f7c1a4e7cc736a455dfcf7b4#go_1_24", 10 | "source": "devbox-search", 11 | "version": "1.24.0", 12 | "systems": { 13 | "aarch64-darwin": { 14 | "outputs": [ 15 | { 16 | "name": "out", 17 | "path": "/nix/store/qldcnifalkvyah0wnv7m4zb854yd9l88-go-1.24.0", 18 | "default": true 19 | } 20 | ], 21 | "store_path": "/nix/store/qldcnifalkvyah0wnv7m4zb854yd9l88-go-1.24.0" 22 | }, 23 | "aarch64-linux": { 24 | "outputs": [ 25 | { 26 | "name": "out", 27 | "path": "/nix/store/rrxgml7w4pfmibjbspkdvrw8vd2vnarb-go-1.24.0", 28 | "default": true 29 | } 30 | ], 31 | "store_path": "/nix/store/rrxgml7w4pfmibjbspkdvrw8vd2vnarb-go-1.24.0" 32 | }, 33 | "x86_64-darwin": { 34 | "outputs": [ 35 | { 36 | "name": "out", 37 | "path": "/nix/store/7imv22pl4qrjwvi6jzlfb305rc2min45-go-1.24.0", 38 | "default": true 39 | } 40 | ], 41 | "store_path": "/nix/store/7imv22pl4qrjwvi6jzlfb305rc2min45-go-1.24.0" 42 | }, 43 | "x86_64-linux": { 44 | "outputs": [ 45 | { 46 | "name": "out", 47 | "path": "/nix/store/vh5d5bj1sljdhdypy80x1ydx2jx6rv2q-go-1.24.0", 48 | "default": true 49 | } 50 | ], 51 | "store_path": "/nix/store/vh5d5bj1sljdhdypy80x1ydx2jx6rv2q-go-1.24.0" 52 | } 53 | } 54 | }, 55 | "golangci-lint@latest": { 56 | "last_modified": "2025-02-16T21:44:05Z", 57 | "resolved": "github:NixOS/nixpkgs/f0204ef4baa3b6317dee1c84ddeffbd293638836#golangci-lint", 58 | "source": "devbox-search", 59 | "version": "1.64.5", 60 | "systems": { 61 | "aarch64-darwin": { 62 | "outputs": [ 63 | { 64 | "name": "out", 65 | "path": "/nix/store/jh2f466rbi0pgk6f3w8jdzy4qyccybz3-golangci-lint-1.64.5", 66 | "default": true 67 | } 68 | ], 69 | "store_path": "/nix/store/jh2f466rbi0pgk6f3w8jdzy4qyccybz3-golangci-lint-1.64.5" 70 | }, 71 | "aarch64-linux": { 72 | "outputs": [ 73 | { 74 | "name": "out", 75 | "path": "/nix/store/63mvzwlqana7bfcy8jzmn3fvkn46k0p6-golangci-lint-1.64.5", 76 | "default": true 77 | } 78 | ], 79 | "store_path": "/nix/store/63mvzwlqana7bfcy8jzmn3fvkn46k0p6-golangci-lint-1.64.5" 80 | }, 81 | "x86_64-darwin": { 82 | "outputs": [ 83 | { 84 | "name": "out", 85 | "path": "/nix/store/d662i9k8p2alplmxc3bqypc646x7wy1b-golangci-lint-1.64.5", 86 | "default": true 87 | } 88 | ], 89 | "store_path": "/nix/store/d662i9k8p2alplmxc3bqypc646x7wy1b-golangci-lint-1.64.5" 90 | }, 91 | "x86_64-linux": { 92 | "outputs": [ 93 | { 94 | "name": "out", 95 | "path": "/nix/store/25732rsdh49iwjrik69sb9cfhiza00b5-golangci-lint-1.64.5", 96 | "default": true 97 | } 98 | ], 99 | "store_path": "/nix/store/25732rsdh49iwjrik69sb9cfhiza00b5-golangci-lint-1.64.5" 100 | } 101 | } 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /pkg/envcli/flags.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Jetify Inc. and contributors. All rights reserved. 2 | // Use of this source code is governed by the license in the LICENSE file. 3 | 4 | package envcli 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | 10 | "github.com/pkg/errors" 11 | "github.com/spf13/cobra" 12 | "go.jetify.com/envsec/internal/build" 13 | "go.jetify.com/envsec/pkg/envsec" 14 | "go.jetify.com/envsec/pkg/stores/jetstore" 15 | "go.jetify.com/envsec/pkg/stores/ssmstore" 16 | "go.jetify.com/pkg/envvar" 17 | "go.jetify.com/pkg/ids" 18 | ) 19 | 20 | // to be composed into xyzCmdFlags structs 21 | type configFlags struct { 22 | projectID string 23 | orgID string 24 | envName string 25 | } 26 | 27 | func (f *configFlags) register(cmd *cobra.Command) { 28 | cmd.PersistentFlags().StringVar( 29 | &f.projectID, 30 | "project-id", 31 | "", 32 | "project id by which to namespace secrets", 33 | ) 34 | 35 | cmd.PersistentFlags().StringVar( 36 | &f.orgID, 37 | "org-id", 38 | "", 39 | "organization id by which to namespace secrets", 40 | ) 41 | 42 | cmd.PersistentFlags().StringVar( 43 | &f.envName, 44 | "environment", 45 | "dev", 46 | "environment name, one of: dev, preview, prod", 47 | ) 48 | } 49 | 50 | func (f *configFlags) validateProjectID(orgID ids.OrgID) (string, error) { 51 | if f.projectID != "" { 52 | return f.projectID, nil 53 | } 54 | wd, err := os.Getwd() 55 | if err != nil { 56 | return "", errors.WithStack(err) 57 | } 58 | config, err := (&envsec.Envsec{ 59 | WorkingDir: wd, 60 | IsDev: build.IsDev, 61 | }).ProjectConfig() 62 | if errors.Is(err, os.ErrNotExist) { 63 | return "", fmt.Errorf( 64 | "project ID not specified. You must run `envsec init` or specify --project-id in this directory", 65 | ) 66 | } else if err != nil { 67 | return "", errors.WithStack(err) 68 | } 69 | 70 | if config.OrgID != orgID { 71 | // Validate that the project ID belongs to the org ID 72 | return "", errors.Errorf( 73 | "Project ID %s does not belong to organization %s", 74 | config.ProjectID, 75 | orgID, 76 | ) 77 | } 78 | return config.ProjectID.String(), nil 79 | } 80 | 81 | type CmdConfig struct { 82 | envsec *envsec.Envsec 83 | envNames []string 84 | } 85 | 86 | func (f *configFlags) genConfig(cmd *cobra.Command) (*CmdConfig, error) { 87 | if bootstrappedConfig != nil { 88 | return bootstrappedConfig, nil 89 | } 90 | 91 | wd, err := os.Getwd() 92 | if err != nil { 93 | return nil, errors.WithStack(err) 94 | } 95 | envsecInstance := defaultEnvsec(cmd, wd) 96 | 97 | if envvar.Bool("ENVSEC_USE_AWS_STORE") { 98 | // Legacy, temporary hack to enable the AWS store 99 | envsecInstance.Store = &ssmstore.SSMStore{} 100 | } else { 101 | envsecInstance.Store = &jetstore.JetpackAPIStore{} 102 | } 103 | 104 | tok, err := envsecInstance.InitForUser(cmd.Context()) 105 | if err != nil { 106 | return nil, errors.WithStack(err) 107 | } 108 | 109 | if tok != nil && f.orgID == "" { 110 | f.orgID = tok.IDClaims().OrgID 111 | } 112 | 113 | orgID, err := ids.ParseOrgID(f.orgID) 114 | if err != nil { 115 | return nil, errors.WithStack(err) 116 | } 117 | 118 | projectID, err := f.validateProjectID(orgID) 119 | if err != nil { 120 | return nil, errors.WithStack(err) 121 | } 122 | 123 | envid, err := envsec.NewEnvID(projectID, f.orgID, f.envName) 124 | if err != nil { 125 | return nil, errors.WithStack(err) 126 | } 127 | 128 | envsecInstance.EnvID = envid 129 | 130 | envNames := []string{"dev", "prod", "preview"} 131 | if cmd.Flags().Changed(environmentFlagName) { 132 | envNames = []string{envid.EnvName} 133 | } 134 | 135 | return &CmdConfig{ 136 | envsec: envsecInstance, 137 | envNames: envNames, 138 | }, nil 139 | } 140 | 141 | var bootstrappedConfig *CmdConfig 142 | 143 | // BootstrapConfig is used to set the config for all commands that use genConfig 144 | // Useful for using envsec programmatically. 145 | func BootstrapConfig(cmdConfig *CmdConfig) { 146 | bootstrappedConfig = cmdConfig 147 | } 148 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.jetify.com/envsec 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | connectrpc.com/connect v1.19.1 7 | github.com/AlecAivazis/survey/v2 v2.3.7 8 | github.com/MakeNowJust/heredoc/v2 v2.0.1 9 | github.com/aws/aws-sdk-go-v2 v1.40.0 10 | github.com/aws/aws-sdk-go-v2/config v1.32.2 11 | github.com/aws/aws-sdk-go-v2/credentials v1.19.2 12 | github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.33.14 13 | github.com/aws/aws-sdk-go-v2/service/ssm v1.67.4 14 | github.com/aws/smithy-go v1.24.0 15 | github.com/charmbracelet/lipgloss v1.1.0 16 | github.com/fatih/color v1.18.0 17 | github.com/hashicorp/go-multierror v1.1.1 18 | github.com/joho/godotenv v1.5.1 19 | github.com/muesli/termenv v0.16.0 20 | github.com/olekukonko/tablewriter v1.1.2 21 | github.com/pkg/errors v0.9.1 22 | github.com/samber/lo v1.52.0 23 | github.com/spf13/cobra v1.10.1 24 | go.jetify.com/pkg v0.0.0-20251201231142-abe4fc632859 25 | golang.org/x/text v0.31.0 26 | ) 27 | 28 | require ( 29 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 // indirect 30 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect 31 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect 32 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect 33 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect 34 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect 35 | github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 // indirect 36 | github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 // indirect 37 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 // indirect 38 | github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 // indirect 39 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 40 | github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect 41 | github.com/charmbracelet/colorprofile v0.3.3 // indirect 42 | github.com/charmbracelet/x/ansi v0.11.2 // indirect 43 | github.com/charmbracelet/x/cellbuf v0.0.14 // indirect 44 | github.com/charmbracelet/x/term v0.2.2 // indirect 45 | github.com/clipperhouse/displaywidth v0.6.1 // indirect 46 | github.com/clipperhouse/stringish v0.1.1 // indirect 47 | github.com/clipperhouse/uax29/v2 v2.3.0 // indirect 48 | github.com/coreos/go-oidc/v3 v3.17.0 // indirect 49 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 50 | github.com/creack/pty v1.1.24 // indirect 51 | github.com/go-jose/go-jose/v4 v4.1.3 // indirect 52 | github.com/goccy/go-yaml v1.19.0 // indirect 53 | github.com/gofrs/uuid/v5 v5.4.0 // indirect 54 | github.com/google/renameio/v2 v2.0.1 // indirect 55 | github.com/gosimple/slug v1.15.0 // indirect 56 | github.com/gosimple/unidecode v1.0.1 // indirect 57 | github.com/hashicorp/errwrap v1.1.0 // indirect 58 | github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 // indirect 59 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 60 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 61 | github.com/kr/text v0.2.0 // indirect 62 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect 63 | github.com/mattn/go-colorable v0.1.14 // indirect 64 | github.com/mattn/go-isatty v0.0.20 // indirect 65 | github.com/mattn/go-runewidth v0.0.19 // indirect 66 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect 67 | github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect 68 | github.com/olekukonko/errors v1.1.0 // indirect 69 | github.com/olekukonko/ll v0.1.3 // indirect 70 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 71 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect 72 | github.com/rivo/uniseg v0.4.7 // indirect 73 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 74 | github.com/spf13/pflag v1.0.10 // indirect 75 | github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a // indirect 76 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 77 | go.jetify.com/typeid/v2 v2.0.0-alpha.3 // indirect 78 | golang.org/x/exp v0.0.0-20250717185816-542afb5b7346 // indirect 79 | golang.org/x/oauth2 v0.33.0 // indirect 80 | golang.org/x/sys v0.38.0 // indirect 81 | golang.org/x/term v0.37.0 // indirect 82 | google.golang.org/protobuf v1.36.10 // indirect 83 | gopkg.in/yaml.v3 v3.0.1 // indirect 84 | ) 85 | -------------------------------------------------------------------------------- /pkg/stores/ssmstore/ssmstore.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Jetify Inc. and contributors. All rights reserved. 2 | // Use of this source code is governed by the license in the LICENSE file. 3 | 4 | package ssmstore 5 | 6 | import ( 7 | "context" 8 | 9 | cognitoTypes "github.com/aws/aws-sdk-go-v2/service/cognitoidentity/types" 10 | "github.com/aws/aws-sdk-go-v2/service/ssm/types" 11 | "github.com/hashicorp/go-multierror" 12 | "github.com/pkg/errors" 13 | "github.com/samber/lo" 14 | "go.jetify.com/envsec/pkg/awsfed" 15 | "go.jetify.com/envsec/pkg/envsec" 16 | "go.jetify.com/pkg/auth/session" 17 | ) 18 | 19 | type SSMStore struct { 20 | store *parameterStore 21 | } 22 | 23 | // SSMStore implements interface Store (compile-time check) 24 | var _ envsec.Store = (*SSMStore)(nil) 25 | 26 | func (s *SSMStore) InitForUser(ctx context.Context, e *envsec.Envsec) (*session.Token, error) { 27 | client, err := e.AuthClient() 28 | if err != nil { 29 | return nil, errors.WithStack(err) 30 | } 31 | tok, err := client.LoginFlowIfNeeded(ctx) 32 | if err != nil { 33 | return nil, errors.WithStack(err) 34 | } 35 | ssmConfig, err := genSSMConfigFromToken(ctx, tok, true /*useCache*/) 36 | if err != nil { 37 | return nil, errors.WithStack(err) 38 | } 39 | paramStore, err := newParameterStore(ctx, ssmConfig) 40 | if err != nil { 41 | return nil, errors.WithStack(err) 42 | } 43 | s.store = paramStore 44 | return tok, nil 45 | } 46 | 47 | func (s *SSMStore) List(ctx context.Context, envID envsec.EnvID) ([]envsec.EnvVar, error) { 48 | if s.store.config.hasDefaultPaths() { 49 | return s.store.listByPath(ctx, envID) 50 | } 51 | return s.store.listByTags(ctx, envID) 52 | } 53 | 54 | func (s *SSMStore) Get(ctx context.Context, envID envsec.EnvID, name string) (string, error) { 55 | vars, err := s.GetAll(ctx, envID, []string{name}) 56 | if err != nil { 57 | return "", errors.WithStack(err) 58 | } 59 | if len(vars) == 0 { 60 | return "", nil 61 | } 62 | return vars[0].Value, nil 63 | } 64 | 65 | func (s *SSMStore) GetAll(ctx context.Context, envID envsec.EnvID, names []string) ([]envsec.EnvVar, error) { 66 | return s.store.getAll(ctx, envID, names) 67 | } 68 | 69 | func (s *SSMStore) Set( 70 | ctx context.Context, 71 | envID envsec.EnvID, 72 | name string, 73 | value string, 74 | ) error { 75 | path := s.store.config.varPath(envID, name) 76 | 77 | // New parameter definition 78 | tags := buildTags(envID, name) 79 | parameter := ¶meter{ 80 | tags: tags, 81 | id: path, 82 | } 83 | return s.store.newParameter(ctx, parameter, value) 84 | } 85 | 86 | func (s *SSMStore) SetAll(ctx context.Context, envID envsec.EnvID, values map[string]string) error { 87 | // For now we implement by issuing multiple calls to Set() 88 | // Make more efficient either by implementing a batch call to the underlying API, or 89 | // by concurrently calling Set() 90 | 91 | var multiErr error 92 | for name, value := range values { 93 | err := s.Set(ctx, envID, name, value) 94 | if err != nil { 95 | multiErr = multierror.Append(multiErr, err) 96 | } 97 | } 98 | return multiErr 99 | } 100 | 101 | func (s *SSMStore) Delete(ctx context.Context, envID envsec.EnvID, name string) error { 102 | return s.DeleteAll(ctx, envID, []string{name}) 103 | } 104 | 105 | func (s *SSMStore) DeleteAll(ctx context.Context, envID envsec.EnvID, names []string) error { 106 | return s.store.deleteAll(ctx, envID, names) 107 | } 108 | 109 | func buildTags(envID envsec.EnvID, varName string) []types.Tag { 110 | tags := []types.Tag{} 111 | if envID.ProjectID != "" { 112 | tags = append(tags, types.Tag{ 113 | Key: lo.ToPtr("project-id"), 114 | Value: lo.ToPtr(envID.ProjectID), 115 | }) 116 | } 117 | if envID.OrgID != "" { 118 | tags = append(tags, types.Tag{ 119 | Key: lo.ToPtr("org-id"), 120 | Value: lo.ToPtr(envID.OrgID), 121 | }) 122 | } 123 | if envID.EnvName != "" { 124 | tags = append(tags, types.Tag{ 125 | Key: lo.ToPtr("env-name"), 126 | Value: lo.ToPtr(envID.EnvName), 127 | }) 128 | } 129 | 130 | if varName != "" { 131 | tags = append(tags, types.Tag{ 132 | Key: lo.ToPtr("name"), 133 | Value: lo.ToPtr(varName), 134 | }) 135 | } 136 | 137 | return tags 138 | } 139 | 140 | func genSSMConfigFromToken( 141 | ctx context.Context, 142 | tok *session.Token, 143 | useCache bool, 144 | ) (*SSMConfig, error) { 145 | if tok == nil { 146 | return &SSMConfig{}, nil 147 | } 148 | fed := awsfed.New() 149 | var creds *cognitoTypes.Credentials 150 | var err error 151 | if useCache { 152 | creds, err = fed.AWSCredsWithLocalCache(ctx, tok) 153 | } else { 154 | creds, err = fed.AWSCreds(ctx, tok.IDToken) 155 | } 156 | if err != nil { 157 | return nil, errors.WithStack(err) 158 | } 159 | return &SSMConfig{ 160 | AccessKeyID: *creds.AccessKeyId, 161 | SecretAccessKey: *creds.SecretKey, 162 | SessionToken: *creds.SessionToken, 163 | Region: fed.Region, 164 | }, nil 165 | } 166 | -------------------------------------------------------------------------------- /pkg/stores/jetstore/jetstore.go: -------------------------------------------------------------------------------- 1 | package jetstore 2 | 3 | import ( 4 | "context" 5 | 6 | "connectrpc.com/connect" 7 | "go.jetify.com/envsec/pkg/envsec" 8 | "go.jetify.com/pkg/api" 9 | secretsv1alpha1 "go.jetify.com/pkg/api/gen/priv/secrets/v1alpha1" 10 | "go.jetify.com/pkg/api/gen/priv/secrets/v1alpha1/secretsv1alpha1connect" 11 | "go.jetify.com/pkg/auth/session" 12 | ) 13 | 14 | type JetpackAPIStore struct { 15 | client secretsv1alpha1connect.SecretsServiceClient 16 | } 17 | 18 | // JetpackAPIStore implements interface Store (compile-time check) 19 | var _ envsec.Store = (*JetpackAPIStore)(nil) 20 | 21 | func (j *JetpackAPIStore) InitForUser( 22 | ctx context.Context, 23 | envsec *envsec.Envsec, 24 | ) (*session.Token, error) { 25 | project, err := envsec.ProjectConfig() 26 | if project == nil { 27 | return nil, err 28 | } 29 | 30 | authClient, err := envsec.AuthClient() 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | tok, err := authClient.LoginFlowIfNeededForOrg(ctx, project.OrgID.String()) 36 | if err != nil { 37 | return nil, err 38 | } 39 | j.client = api.NewClient(ctx, envsec.APIHost, tok).SecretsService() 40 | return tok, nil 41 | } 42 | 43 | func (j JetpackAPIStore) List(ctx context.Context, envID envsec.EnvID) ([]envsec.EnvVar, error) { 44 | resp, err := j.client.ListSecrets( 45 | ctx, 46 | connect.NewRequest(&secretsv1alpha1.ListSecretsRequest{ProjectId: envID.ProjectID}), 47 | ) 48 | if err != nil { 49 | return nil, err 50 | } 51 | result := []envsec.EnvVar{} 52 | for _, secret := range resp.Msg.Secrets { 53 | if v := secret.EnvironmentValues[envID.EnvName]; len(v) > 0 { 54 | result = append( 55 | result, envsec.EnvVar{ 56 | Name: secret.Name, 57 | Value: string(v), 58 | }, 59 | ) 60 | } 61 | } 62 | return result, nil 63 | } 64 | 65 | func (j JetpackAPIStore) Set(ctx context.Context, envID envsec.EnvID, name, value string) error { 66 | _, err := j.client.PatchSecret( 67 | ctx, connect.NewRequest( 68 | &secretsv1alpha1.PatchSecretRequest{ 69 | ProjectId: envID.ProjectID, 70 | Secret: &secretsv1alpha1.Secret{ 71 | Name: name, 72 | EnvironmentValues: map[string][]byte{ 73 | envID.EnvName: []byte(value), 74 | }, 75 | }, 76 | }, 77 | ), 78 | ) 79 | return err 80 | } 81 | 82 | func (j JetpackAPIStore) SetAll(ctx context.Context, envID envsec.EnvID, values map[string]string) error { 83 | patchActions := []*secretsv1alpha1.Action{} 84 | for name, value := range values { 85 | patchActions = append( 86 | patchActions, &secretsv1alpha1.Action{ 87 | Action: &secretsv1alpha1.Action_PatchSecret{ 88 | PatchSecret: &secretsv1alpha1.PatchSecretRequest{ 89 | ProjectId: envID.ProjectID, 90 | Secret: &secretsv1alpha1.Secret{ 91 | Name: name, 92 | EnvironmentValues: map[string][]byte{ 93 | envID.EnvName: []byte(value), 94 | }, 95 | }, 96 | }, 97 | }, 98 | }, 99 | ) 100 | } 101 | 102 | _, err := j.client.Batch( 103 | ctx, connect.NewRequest(&secretsv1alpha1.BatchRequest{Actions: patchActions}), 104 | ) 105 | return err 106 | } 107 | 108 | func (j JetpackAPIStore) Get(ctx context.Context, envID envsec.EnvID, name string) (string, error) { 109 | vars, err := j.List(ctx, envID) 110 | if err != nil { 111 | return "", err 112 | } 113 | for _, v := range vars { 114 | if v.Name == name { 115 | return v.Value, nil 116 | } 117 | } 118 | return "", nil 119 | } 120 | 121 | func (j JetpackAPIStore) GetAll(ctx context.Context, envID envsec.EnvID, names []string) ([]envsec.EnvVar, error) { 122 | vars, err := j.List(ctx, envID) 123 | if err != nil { 124 | return nil, err 125 | } 126 | result := []envsec.EnvVar{} 127 | for _, v := range vars { 128 | for _, name := range names { 129 | if v.Name == name { 130 | result = append(result, v) 131 | } 132 | } 133 | } 134 | return result, nil 135 | } 136 | 137 | func (j JetpackAPIStore) Delete(ctx context.Context, envID envsec.EnvID, name string) error { 138 | _, err := j.client.DeleteSecret( 139 | ctx, connect.NewRequest( 140 | &secretsv1alpha1.DeleteSecretRequest{ 141 | ProjectId: envID.ProjectID, 142 | SecretName: name, 143 | Environments: []string{envID.EnvName}, 144 | }, 145 | ), 146 | ) 147 | return err 148 | } 149 | 150 | func (j JetpackAPIStore) DeleteAll(ctx context.Context, envID envsec.EnvID, names []string) error { 151 | deleteActions := []*secretsv1alpha1.Action{} 152 | for _, name := range names { 153 | deleteActions = append( 154 | deleteActions, &secretsv1alpha1.Action{ 155 | Action: &secretsv1alpha1.Action_DeleteSecret{ 156 | DeleteSecret: &secretsv1alpha1.DeleteSecretRequest{ 157 | ProjectId: envID.ProjectID, 158 | SecretName: name, 159 | Environments: []string{envID.EnvName}, 160 | }, 161 | }, 162 | }, 163 | ) 164 | } 165 | 166 | _, err := j.client.Batch( 167 | ctx, connect.NewRequest(&secretsv1alpha1.BatchRequest{Actions: deleteActions}), 168 | ) 169 | return err 170 | } 171 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement. Use the 63 | "Report to repository admins" functionality on GitHub to report. 64 | 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 127 | [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /internal/flow/init.go: -------------------------------------------------------------------------------- 1 | package flow 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "sort" 10 | "strings" 11 | 12 | "github.com/AlecAivazis/survey/v2" 13 | "github.com/fatih/color" 14 | "go.jetify.com/envsec/internal/git" 15 | "go.jetify.com/pkg/api" 16 | membersv1alpha1 "go.jetify.com/pkg/api/gen/priv/members/v1alpha1" 17 | projectsv1alpha1 "go.jetify.com/pkg/api/gen/priv/projects/v1alpha1" 18 | "go.jetify.com/pkg/auth/session" 19 | "go.jetify.com/pkg/ids" 20 | ) 21 | 22 | // flow: 23 | // 0. Ask if you want to overwrite existing config [y/N] 24 | // 1. Link to an existing project? [Y/n] 25 | // 2a. What project would you like to link to? (sorted by repo/dir match) 26 | // 2b. What’s the name of your new project? 27 | 28 | type Init struct { 29 | Client *api.Client 30 | PromptOverwriteConfig bool 31 | Token *session.Token 32 | WorkingDir string 33 | } 34 | 35 | func (i *Init) Run(ctx context.Context) (ids.ProjectID, error) { 36 | createProject, err := i.confirmSetupProjectPrompt() 37 | if err != nil { 38 | return ids.ProjectID{}, err 39 | } 40 | if !createProject { 41 | return ids.ProjectID{}, errors.New("aborted") 42 | } 43 | 44 | member, err := i.Client.GetMember(ctx, i.Token.IDClaims().Subject) 45 | if err != nil { 46 | return ids.ProjectID{}, err 47 | } 48 | 49 | // TODO: printOrgNotice will be a team picker once that is implemented. 50 | i.printOrgNotice(member) 51 | orgID, err := ids.ParseOrgID(i.Token.IDClaims().OrgID) 52 | if err != nil { 53 | return ids.ProjectID{}, err 54 | } 55 | 56 | projects, err := i.Client.ListProjects(ctx, orgID) 57 | if err != nil { 58 | return ids.ProjectID{}, err 59 | } 60 | if len(projects) > 0 { 61 | linkToExisting, err := i.linkToExistingPrompt() 62 | if err != nil { 63 | return ids.ProjectID{}, err 64 | } 65 | if linkToExisting { 66 | return i.showExistingListPrompt(projects) 67 | } 68 | } 69 | return i.createNewPrompt(ctx, member) 70 | } 71 | 72 | func (i *Init) confirmSetupProjectPrompt() (bool, error) { 73 | if i.PromptOverwriteConfig { 74 | return boolPrompt( 75 | fmt.Sprintf("Project already exists. Reset project in %s", i.WorkingDir), 76 | false, 77 | ) 78 | } 79 | return boolPrompt( 80 | fmt.Sprintf("Setup project in %s", i.WorkingDir), 81 | true, 82 | ) 83 | } 84 | 85 | func (i *Init) printOrgNotice(member *membersv1alpha1.Member) { 86 | fmt.Fprintf( 87 | os.Stderr, 88 | "Initializing project in org %s\n", 89 | member.Organization.Name, 90 | ) 91 | } 92 | 93 | func (i *Init) linkToExistingPrompt() (bool, error) { 94 | return boolPrompt("Link to an existing project", true) 95 | } 96 | 97 | func (i *Init) showExistingListPrompt( 98 | projects []*projectsv1alpha1.Project, 99 | ) (ids.ProjectID, error) { 100 | // Ignore errors, it's fine if not in repo or git not installed. 101 | repo, _ := git.GitRepoURL(i.WorkingDir) 102 | directory, _ := git.GitSubdirectory(i.WorkingDir) 103 | 104 | sort.SliceStable(projects, func(i, j int) bool { 105 | if projects[i].GetRepo() == repo && 106 | projects[i].GetDirectory() == directory { 107 | return true 108 | } 109 | return projects[i].GetRepo() == repo && projects[j].GetRepo() != repo 110 | }) 111 | 112 | prompt := &survey.Select{ 113 | Message: "What project would you like to link to?", 114 | Options: formatProjectItems(projects), 115 | } 116 | 117 | idx := 0 118 | if err := survey.AskOne(prompt, &idx); err != nil { 119 | return ids.ProjectID{}, err 120 | } 121 | 122 | projectID, err := ids.ParseProjectID(projects[idx].GetId()) 123 | if err != nil { 124 | return ids.ProjectID{}, err 125 | } 126 | name := projects[idx].GetName() 127 | if name == "" { 128 | name = "untitled" 129 | } 130 | fmt.Fprintf(os.Stderr, "Linked to project %s\n", name) 131 | return projectID, nil 132 | } 133 | 134 | func (i *Init) createNewPrompt( 135 | ctx context.Context, 136 | member *membersv1alpha1.Member, 137 | ) (ids.ProjectID, error) { 138 | prompt := &survey.Input{ 139 | Message: "What’s the name of your new project?", 140 | Default: filepath.Base(i.WorkingDir), 141 | } 142 | 143 | name := "" 144 | if err := survey.AskOne(prompt, &name); err != nil { 145 | return ids.ProjectID{}, err 146 | } 147 | 148 | orgID, err := ids.ParseOrgID(i.Token.IDClaims().OrgID) 149 | if err != nil { 150 | return ids.ProjectID{}, err 151 | } 152 | 153 | // Ignore errors, it's fine if not in repo or git not installed. 154 | repo, _ := git.GitRepoURL(i.WorkingDir) 155 | directory, _ := git.GitSubdirectory(i.WorkingDir) 156 | 157 | project, err := i.Client.CreateProject( 158 | ctx, 159 | orgID, 160 | repo, 161 | directory, 162 | strings.TrimSpace(name), 163 | ) 164 | if err != nil { 165 | return ids.ProjectID{}, err 166 | } 167 | 168 | projectID, err := ids.ParseProjectID(project.GetId()) 169 | if err != nil { 170 | return ids.ProjectID{}, err 171 | } 172 | 173 | fmt.Fprintf( 174 | os.Stderr, 175 | "Created project %s in org %s\n", 176 | project.GetName(), 177 | member.GetOrganization().GetName(), 178 | ) 179 | return projectID, nil 180 | } 181 | 182 | func boolPrompt(label string, defaultResult bool) (bool, error) { 183 | result := false 184 | prompt := &survey.Confirm{ 185 | Message: label, 186 | Default: defaultResult, 187 | } 188 | return result, survey.AskOne(prompt, &result) 189 | } 190 | 191 | func formatProjectItems(projects []*projectsv1alpha1.Project) []string { 192 | longestNameLength := 0 193 | for _, proj := range projects { 194 | name := proj.GetName() 195 | if name == "" { 196 | name = "untitled" 197 | } 198 | if l := len(name); l > longestNameLength { 199 | longestNameLength = l 200 | } 201 | } 202 | // Add padding 203 | table := make([][]string, len(projects)) 204 | for idx, proj := range projects { 205 | name := strings.TrimSpace(proj.GetName()) 206 | if name == "" { 207 | name = "untitled" 208 | } 209 | 210 | table[idx] = []string{ 211 | color.HiGreenString( 212 | fmt.Sprintf("%-"+fmt.Sprintf("%d", longestNameLength)+"s", name), 213 | ), 214 | color.HiBlueString("id:"), 215 | proj.GetId(), 216 | } 217 | } 218 | 219 | rows := []string{} 220 | for _, cols := range table { 221 | rows = append(rows, strings.Join(cols, " ")) 222 | } 223 | return rows 224 | } 225 | -------------------------------------------------------------------------------- /pkg/stores/ssmstore/parameter_store.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Jetify Inc. and contributors. All rights reserved. 2 | // Use of this source code is governed by the license in the LICENSE file. 3 | 4 | package ssmstore 5 | 6 | import ( 7 | "context" 8 | "strings" 9 | 10 | "github.com/aws/aws-sdk-go-v2/aws" 11 | awsconfig "github.com/aws/aws-sdk-go-v2/config" 12 | "github.com/aws/aws-sdk-go-v2/credentials" 13 | "github.com/aws/aws-sdk-go-v2/service/ssm" 14 | "github.com/aws/aws-sdk-go-v2/service/ssm/types" 15 | "github.com/aws/smithy-go" 16 | "github.com/hashicorp/go-multierror" 17 | "github.com/pkg/errors" 18 | "github.com/samber/lo" 19 | "go.jetify.com/envsec/pkg/envsec" 20 | "golang.org/x/text/collate" 21 | "golang.org/x/text/language" 22 | ) 23 | 24 | const emptyStringValuePlaceholder = "__###EMPTY_STRING###__" 25 | 26 | type parameter struct { 27 | id string 28 | description string 29 | tags []types.Tag 30 | } 31 | 32 | type parameterStore struct { 33 | config *SSMConfig 34 | client *ssm.Client 35 | } 36 | 37 | // Parameter values are limited in size to 4KB 38 | const parameterValueMaxLength = 4 * 1024 39 | 40 | var FaultyParamError = errors.New("Faulty Parameter") 41 | 42 | // New parameter store for current user/organization. 43 | func newParameterStore(ctx context.Context, config *SSMConfig) (*parameterStore, error) { 44 | awsConfig, err := awsconfig.LoadDefaultConfig(ctx) 45 | if err != nil { 46 | return nil, errors.WithStack(err) 47 | } 48 | 49 | client := ssm.NewFromConfig(awsConfig, func(o *ssm.Options) { 50 | if config.Region != "" { 51 | o.Region = config.Region 52 | } 53 | 54 | if (config.AccessKeyID != "" && config.SecretAccessKey != "") || config.SessionToken != "" { 55 | o.Credentials = credentials.NewStaticCredentialsProvider( 56 | config.AccessKeyID, 57 | config.SecretAccessKey, 58 | config.SessionToken, 59 | ) 60 | } 61 | }) 62 | 63 | return ¶meterStore{ 64 | config: config, 65 | client: client, 66 | }, nil /* no error */ 67 | } 68 | 69 | // Defines a new stored parameter. 70 | // parameter values are limited in size to 4 KB. 71 | func (s *parameterStore) newParameter(ctx context.Context, param *parameter, value string) error { 72 | if parameterValueMaxLength < len(value) { 73 | return errors.New("parameter values are limited in size to 4KB") 74 | } 75 | 76 | input := &ssm.PutParameterInput{ 77 | Name: aws.String(param.id), 78 | Description: aws.String(param.description), 79 | Type: types.ParameterTypeSecureString, 80 | Value: awsSSMParamStoreValue(value), 81 | Tags: param.tags, 82 | } 83 | 84 | // Set the KmsKeyId only when it is present. Otherwise, aws sdk uses the default KMS key 85 | // since we specify "SecureString" type. 86 | if s.config.KmsKeyID != "" { 87 | input.KeyId = aws.String(s.config.KmsKeyID) 88 | } 89 | 90 | _, err := s.client.PutParameter(ctx, input) 91 | if err != nil { 92 | var paeError *types.ParameterAlreadyExists 93 | if errors.As(err, &paeError) { 94 | // parameter already exists calling put parameter with overwrite flag 95 | return s.overwriteParameterValue(ctx, param, value) 96 | } 97 | return errors.WithStack(err) 98 | } 99 | return errors.WithStack(err) 100 | } 101 | 102 | // Updates a stored parameter. 103 | func (s *parameterStore) overwriteParameterValue(ctx context.Context, v *parameter, value string) error { 104 | input := &ssm.PutParameterInput{ 105 | Name: aws.String(v.id), 106 | Description: aws.String(v.description), 107 | Overwrite: lo.ToPtr(true), 108 | Value: awsSSMParamStoreValue(value), 109 | } 110 | _, err := s.client.PutParameter(ctx, input) 111 | return errors.WithStack(err) 112 | } 113 | 114 | func (s *parameterStore) listByPath(ctx context.Context, id envsec.EnvID) ([]envsec.EnvVar, error) { 115 | // Create the request object: 116 | req := &ssm.GetParametersByPathInput{ 117 | Path: aws.String(s.config.varPath(id, "")), 118 | WithDecryption: lo.ToPtr(true), 119 | Recursive: lo.ToPtr(true), 120 | } 121 | 122 | // Start with empty results 123 | results := []envsec.EnvVar{} 124 | 125 | // Paginate through the results: 126 | paginator := ssm.NewGetParametersByPathPaginator(s.client, req) 127 | for paginator.HasMorePages() { 128 | // Issue the request for the next page: 129 | resp, err := paginator.NextPage(ctx) 130 | if err != nil { 131 | return results, errors.WithStack(err) 132 | } 133 | 134 | // Append results: 135 | params := resp.Parameters 136 | for _, p := range params { 137 | results = append(results, envsec.EnvVar{ 138 | Name: nameFromPath(aws.ToString(p.Name)), 139 | Value: awsSSMParamStoreValueToString(p.Value), 140 | }) 141 | } 142 | } 143 | sort(results) 144 | 145 | return results, nil 146 | } 147 | 148 | func (s *parameterStore) listByTags(ctx context.Context, envID envsec.EnvID) ([]envsec.EnvVar, error) { 149 | // Create the request object: 150 | req := &ssm.DescribeParametersInput{ 151 | ParameterFilters: s.buildFilters(envID), 152 | } 153 | 154 | varNames := []string{} 155 | // Paginate through the results: 156 | paginator := ssm.NewDescribeParametersPaginator(s.client, req) 157 | for paginator.HasMorePages() { 158 | // Issue the request for the next page: 159 | resp, err := paginator.NextPage(ctx) 160 | if err != nil { 161 | return []envsec.EnvVar{}, errors.WithStack(err) 162 | } 163 | // Append results: 164 | for _, p := range resp.Parameters { 165 | // AWS returns the parameter path as its "name": 166 | varName := nameFromPath(aws.ToString(p.Name)) 167 | varNames = append(varNames, varName) 168 | } 169 | } 170 | 171 | return s.getAll(ctx, envID, varNames) 172 | } 173 | 174 | func (s *parameterStore) buildFilters(envID envsec.EnvID) []types.ParameterStringFilter { 175 | filters := []types.ParameterStringFilter{ 176 | { 177 | Key: lo.ToPtr("Path"), 178 | Option: lo.ToPtr("Recursive"), 179 | Values: []string{s.config.pathNamespace(envID)}, 180 | }, 181 | } 182 | if envID.ProjectID != "" { 183 | filters = append(filters, types.ParameterStringFilter{ 184 | Key: lo.ToPtr("tag:project-id"), 185 | Values: []string{envID.ProjectID}, 186 | }) 187 | } 188 | if envID.OrgID != "" { 189 | filters = append(filters, types.ParameterStringFilter{ 190 | Key: lo.ToPtr("tag:org-id"), 191 | Values: []string{envID.OrgID}, 192 | }) 193 | } 194 | if envID.EnvName != "" { 195 | filters = append(filters, types.ParameterStringFilter{ 196 | Key: lo.ToPtr("tag:env-name"), 197 | Values: []string{envID.EnvName}, 198 | }) 199 | } 200 | 201 | return filters 202 | } 203 | 204 | func (s *parameterStore) getAll(ctx context.Context, envID envsec.EnvID, varNames []string) ([]envsec.EnvVar, error) { 205 | // Start with empty results 206 | results := []envsec.EnvVar{} 207 | paths := lo.Map(varNames, func(name string, _ int) string { 208 | return s.config.varPath(envID, name) 209 | }) 210 | 211 | // Due to AWS API limits, chunk into groups of 10 212 | chunks := lo.Chunk(paths, 10) 213 | for _, chunk := range chunks { 214 | 215 | // Create the request object: 216 | req := &ssm.GetParametersInput{ 217 | Names: chunk, 218 | WithDecryption: lo.ToPtr(true), 219 | } 220 | // Issue the request: 221 | resp, err := s.client.GetParameters(ctx, req) 222 | if err != nil { 223 | // For now an error short circuits the entire thing, but we could be more careful 224 | // and return values that were successfully retrieved, even if others failed. 225 | return results, errors.WithStack(err) 226 | } 227 | 228 | // Append results: 229 | for _, p := range resp.Parameters { 230 | results = append(results, envsec.EnvVar{ 231 | Name: nameFromPath(aws.ToString(p.Name)), 232 | Value: awsSSMParamStoreValueToString(p.Value), 233 | }) 234 | } 235 | } 236 | sort(results) 237 | return results, nil 238 | } 239 | 240 | func (s *parameterStore) deleteAll(ctx context.Context, envID envsec.EnvID, varNames []string) error { 241 | paths := lo.Map(varNames, func(name string, _ int) string { 242 | return s.config.varPath(envID, name) 243 | }) 244 | // Due to AWS API limits, chunk into groups of 10 245 | chunks := lo.Chunk(paths, 10) 246 | var multiErr error 247 | for _, chunk := range chunks { 248 | // Create the request object: 249 | req := &ssm.DeleteParametersInput{ 250 | Names: chunk, 251 | } 252 | 253 | // Issue the request: 254 | _, err := s.client.DeleteParameters(ctx, req) 255 | if err != nil { 256 | var awsErr smithy.APIError 257 | if errors.As(err, &awsErr) { 258 | if awsErr.ErrorCode() == "AccessDeniedException" { 259 | faultyParam := getFaultyParameter(awsErr.ErrorMessage()) 260 | return errors.Wrap(FaultyParamError, faultyParam) 261 | } 262 | } 263 | multiErr = multierror.Append(multiErr, err) 264 | continue 265 | } 266 | } 267 | // We could also return the list of deleted parameters 268 | return multiErr 269 | } 270 | 271 | // Implement interface Lister from text/collate 272 | type envVars []envsec.EnvVar 273 | 274 | func (e envVars) Len() int { 275 | return len(e) 276 | } 277 | 278 | func (e envVars) Bytes(i int) []byte { 279 | return []byte(e[i].Name) 280 | } 281 | 282 | func (e envVars) Swap(i, j int) { 283 | e[i], e[j] = e[j], e[i] 284 | } 285 | 286 | func sort(vars envVars) { 287 | c := collate.New(language.English, collate.Loose, collate.Numeric) 288 | c.Sort(vars) 289 | } 290 | 291 | func getFaultyParameter(message string) string { 292 | resourceParts := strings.Split(message, "/") 293 | nameParts := strings.Split(resourceParts[len(resourceParts)-1], " ") 294 | return nameParts[0] 295 | } 296 | 297 | // AWS SSM Param store doesn't allow empty strings so we use a placeholder 298 | // instead 299 | func awsSSMParamStoreValue(s string) *string { 300 | if s == "" { 301 | return aws.String(emptyStringValuePlaceholder) 302 | } 303 | return aws.String(s) 304 | } 305 | 306 | func awsSSMParamStoreValueToString(s *string) string { 307 | if *s == emptyStringValuePlaceholder { 308 | return "" 309 | } 310 | return *s 311 | } 312 | 313 | func nameFromPath(path string) string { 314 | subpaths := strings.Split(path, "/") 315 | if len(subpaths) == 0 { 316 | return "" 317 | } 318 | return subpaths[len(subpaths)-1] 319 | } 320 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= 2 | connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= 3 | github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= 4 | github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= 5 | github.com/MakeNowJust/heredoc/v2 v2.0.1 h1:rlCHh70XXXv7toz95ajQWOWQnN4WNLt0TdpZYIR/J6A= 6 | github.com/MakeNowJust/heredoc/v2 v2.0.1/go.mod h1:6/2Abh5s+hc3g9nbWLe9ObDIOhaRrqsyY9MWy+4JdRM= 7 | github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= 8 | github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= 9 | github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc= 10 | github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= 11 | github.com/aws/aws-sdk-go-v2/config v1.32.2 h1:4liUsdEpUUPZs5WVapsJLx5NPmQhQdez7nYFcovrytk= 12 | github.com/aws/aws-sdk-go-v2/config v1.32.2/go.mod h1:l0hs06IFz1eCT+jTacU/qZtC33nvcnLADAPL/XyrkZI= 13 | github.com/aws/aws-sdk-go-v2/credentials v1.19.2 h1:qZry8VUyTK4VIo5aEdUcBjPZHL2v4FyQ3QEOaWcFLu4= 14 | github.com/aws/aws-sdk-go-v2/credentials v1.19.2/go.mod h1:YUqm5a1/kBnoK+/NY5WEiMocZihKSo15/tJdmdXnM5g= 15 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA= 16 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14/go.mod h1:Dadl9QO0kHgbrH1GRqGiZdYtW5w+IXXaBNCHTIaheM4= 17 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I= 18 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0= 19 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY= 20 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM= 21 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= 22 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= 23 | github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.33.14 h1:142j9+o9v5mIkXUZOIs1QSsAV2p7RB2DvjAuolK8XgI= 24 | github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.33.14/go.mod h1:Na4x4vWmhGhowGbS8CpEv8i2dy7LqMIDihGMnyYuWbU= 25 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= 26 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= 27 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY= 28 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E= 29 | github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 h1:MxMBdKTYBjPQChlJhi4qlEueqB1p1KcbTEa7tD5aqPs= 30 | github.com/aws/aws-sdk-go-v2/service/signin v1.0.2/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk= 31 | github.com/aws/aws-sdk-go-v2/service/ssm v1.67.4 h1:pOwUUY5FzKUsxtxGR6qsczZP7MuZMVlMbAOPQOcmJlo= 32 | github.com/aws/aws-sdk-go-v2/service/ssm v1.67.4/go.mod h1:+nlWvcgDPQ56mChEBzTC0puAMck+4onOFaHg5cE+Lgg= 33 | github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 h1:ksUT5KtgpZd3SAiFJNJ0AFEJVva3gjBmN7eXUZjzUwQ= 34 | github.com/aws/aws-sdk-go-v2/service/sso v1.30.5/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw= 35 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 h1:GtsxyiF3Nd3JahRBJbxLCCdYW9ltGQYrFWg8XdkGDd8= 36 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug= 37 | github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 h1:a5UTtD4mHBU3t0o6aHQZFJTNKVfxFWfPX7J0Lr7G+uY= 38 | github.com/aws/aws-sdk-go-v2/service/sts v1.41.2/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso= 39 | github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= 40 | github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= 41 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 42 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 43 | github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= 44 | github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 45 | github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI= 46 | github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4= 47 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 48 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 49 | github.com/charmbracelet/x/ansi v0.11.2 h1:XAG3FSjiVtFvgEgGrNBkCNNYrsucAt8c6bfxHyROLLs= 50 | github.com/charmbracelet/x/ansi v0.11.2/go.mod h1:9tY2bzX5SiJCU0iWyskjBeI2BRQfvPqI+J760Mjf+Rg= 51 | github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4= 52 | github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA= 53 | github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= 54 | github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= 55 | github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY= 56 | github.com/clipperhouse/displaywidth v0.6.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= 57 | github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= 58 | github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= 59 | github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= 60 | github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= 61 | github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= 62 | github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= 63 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 64 | github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= 65 | github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 66 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 67 | github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 68 | github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= 69 | github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 70 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 71 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 72 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 73 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 74 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 75 | github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= 76 | github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= 77 | github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE= 78 | github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 79 | github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0= 80 | github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= 81 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 82 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 83 | github.com/google/renameio/v2 v2.0.1 h1:HyOM6qd9gF9sf15AvhbptGHUnaLTpEI9akAFFU3VyW0= 84 | github.com/google/renameio/v2 v2.0.1/go.mod h1:BtmJXm5YlszgC+TD4HOEEUFgkJP3nLxehU6hfe7jRt4= 85 | github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo= 86 | github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= 87 | github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= 88 | github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= 89 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 90 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 91 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 92 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 93 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 94 | github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= 95 | github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY= 96 | github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= 97 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 98 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 99 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 100 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 101 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= 102 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= 103 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 104 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 105 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 106 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 107 | github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= 108 | github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 109 | github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 110 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 111 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 112 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 113 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 114 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 115 | github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= 116 | github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 117 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= 118 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= 119 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= 120 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 121 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 122 | github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= 123 | github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= 124 | github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= 125 | github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= 126 | github.com/olekukonko/ll v0.1.3 h1:sV2jrhQGq5B3W0nENUISCR6azIPf7UBUpVq0x/y70Fg= 127 | github.com/olekukonko/ll v0.1.3/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= 128 | github.com/olekukonko/tablewriter v1.1.2 h1:L2kI1Y5tZBct/O/TyZK1zIE9GlBj/TVs+AY5tZDCDSc= 129 | github.com/olekukonko/tablewriter v1.1.2/go.mod h1:z7SYPugVqGVavWoA2sGsFIoOVNmEHxUAAMrhXONtfkg= 130 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 131 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 132 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= 133 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= 134 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 135 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 136 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 137 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 138 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 139 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 140 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 141 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 142 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 143 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 144 | github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= 145 | github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= 146 | github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= 147 | github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= 148 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 149 | github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= 150 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 151 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 152 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 153 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 154 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 155 | github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a h1:a6TNDN9CgG+cYjaeN8l2mc4kSz2iMiCDQxPEyltUV/I= 156 | github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= 157 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 158 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 159 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 160 | go.jetify.com/pkg v0.0.0-20251201231142-abe4fc632859 h1:opdRo9847AH1/OmuXvWQUSO3gfnrfl7QaeS8dC3UYwg= 161 | go.jetify.com/pkg v0.0.0-20251201231142-abe4fc632859/go.mod h1:qR6Mz3JVuEXEINbNIoDCMpKgkNG69mtCbDKbu4iB1GM= 162 | go.jetify.com/typeid/v2 v2.0.0-alpha.3 h1:T6RPx6bNl10lp0JN2Xz/XcgLZWSlVmL58Xqy9cgTCcc= 163 | go.jetify.com/typeid/v2 v2.0.0-alpha.3/go.mod h1:zfD1ZDHDJNgXZANsO9jDOD81XRRQ0zAOnDBEHmIV/Gw= 164 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 165 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 166 | golang.org/x/exp v0.0.0-20250717185816-542afb5b7346 h1:vuCObX8mQzik1tfEcYxWZBuVsmQtD1IjxCyPKM18Bh4= 167 | golang.org/x/exp v0.0.0-20250717185816-542afb5b7346/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= 168 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 169 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 170 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 171 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 172 | golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= 173 | golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 174 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 175 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 176 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 177 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 178 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 179 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 180 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 181 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 182 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 183 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 184 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 185 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 186 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 187 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 188 | golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= 189 | golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= 190 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 191 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 192 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 193 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 194 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 195 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 196 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 197 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 198 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 199 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 200 | google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= 201 | google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 202 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 203 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 204 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 205 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 206 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 207 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 208 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 209 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 210 | --------------------------------------------------------------------------------