diff --git a/plugins/age/age.go b/plugins/age/age.go new file mode 100644 index 000000000..4552a7368 --- /dev/null +++ b/plugins/age/age.go @@ -0,0 +1,26 @@ +package age + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/needsauth" + "github.com/1Password/shell-plugins/sdk/schema" + "github.com/1Password/shell-plugins/sdk/schema/credname" +) + +func AgeCLI() schema.Executable { + return schema.Executable{ + Name: "Age", + Runs: []string{"age"}, + DocsURL: sdk.URL("https://htmlpreview.github.io/?https://github.com/FiloSottile/age/blob/main/doc/age.1.html"), + NeedsAuth: needsauth.IfAll( + needsauth.NotForHelpOrVersion(), + needsauth.NotWithoutArgs(), + ), + Uses: []schema.CredentialUsage{ + { + Name: credname.SecretKey, + Description: "Public/private key pair to use for encryption/decryption.", + }, + }, + } +} diff --git a/plugins/age/key_pair.go b/plugins/age/key_pair.go new file mode 100644 index 000000000..626bd051b --- /dev/null +++ b/plugins/age/key_pair.go @@ -0,0 +1,75 @@ +package age + +import ( + "fmt" + + "github.com/1Password/shell-plugins/plugins/age/provisioner" + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/importer" + "github.com/1Password/shell-plugins/sdk/schema" + "github.com/1Password/shell-plugins/sdk/schema/credname" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +func KeyPair() schema.CredentialType { + return schema.CredentialType{ + Name: credname.SecretKey, + DocsURL: sdk.URL("https://age-encryption.org/"), + Fields: []schema.CredentialField{ + { + Name: fieldname.PublicKey, + MarkdownDescription: "Age X25519 public key.", + Secret: false, + Composition: &schema.ValueComposition{ + Length: 62, + Charset: schema.Charset{ + Lowercase: true, + Digits: true, + }, + }, + }, + { + Name: fieldname.PrivateKey, + MarkdownDescription: "Age X25519 private key.", + Secret: true, + Composition: &schema.ValueComposition{ + Length: 74, + Charset: schema.Charset{ + Uppercase: true, + Digits: true, + Specific: []rune{'-'}, + }, + }, + }, + }, + DefaultProvisioner: provisioner.KeyPairTempFile(provisioner.NewKeyFiles( + materialisePrivateKeyFile, + materialisePublicKeyFile, + )), + Importer: importer.NoOp(), + } +} + +func materialisePublicKeyFile(in sdk.ProvisionInput) ([]byte, error) { + content := "# generated by: 1password-cli/shell-plugins/plugin/age\n" + + if publicKey, ok := in.ItemFields[fieldname.PublicKey]; ok { + content += publicKey + } + + return []byte(content), nil +} + +func materialisePrivateKeyFile(in sdk.ProvisionInput) ([]byte, error) { + content := "# generated by: 1password-cli/shell-plugins/plugin/age\n" + + if publicKey, ok := in.ItemFields[fieldname.PublicKey]; ok { + content += fmt.Sprintf("# public key: %s\n", publicKey) + } + + if privateKey, ok := in.ItemFields[fieldname.PrivateKey]; ok { + content += privateKey + } + + return []byte(content), nil +} diff --git a/plugins/age/key_pair_test.go b/plugins/age/key_pair_test.go new file mode 100644 index 000000000..c715bdfad --- /dev/null +++ b/plugins/age/key_pair_test.go @@ -0,0 +1,108 @@ +package age + +import ( + "testing" + + "github.com/1Password/shell-plugins/plugins/age/provisioner" + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/plugintest" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +func TestAsymmetricKeyPairProvisioner(t *testing.T) { + plugintest.TestProvisioner(t, KeyPair().DefaultProvisioner, map[string]plugintest.ProvisionCase{ + "defaults-to-encryption-mode": { + ItemFields: map[sdk.FieldName]string{ + fieldname.PrivateKey: "AGE-SECRET-KEY-10000000000000000000000000000000000000000000000000000000000", + fieldname.PublicKey: "age10000000000000000000000000000000000000000000000000000000000", + }, + CommandLine: []string{"age", "-o", "/tmp/encrypted.txt", "/tmp/unencrypted.txt"}, + ExpectedOutput: sdk.ProvisionOutput{ + CommandLine: []string{"age", "-R", "/tmp/age.public.txt", "-o", "/tmp/encrypted.txt", "/tmp/unencrypted.txt"}, + Files: map[string]sdk.OutputFile{ + "/tmp/age.public.txt": { + Contents: []byte(plugintest.LoadFixture(t, "age.public.txt")), + }, + }, + }, + }, + "explicit-encryption-mode-short": { + ItemFields: map[sdk.FieldName]string{ + fieldname.PrivateKey: "AGE-SECRET-KEY-10000000000000000000000000000000000000000000000000000000000", + fieldname.PublicKey: "age10000000000000000000000000000000000000000000000000000000000", + }, + CommandLine: []string{"age", "-e", "-o", "/tmp/encrypted.txt", "/tmp/unencrypted.txt"}, + ExpectedOutput: sdk.ProvisionOutput{ + CommandLine: []string{"age", "-R", "/tmp/age.public.txt", "-e", "-o", "/tmp/encrypted.txt", "/tmp/unencrypted.txt"}, + Files: map[string]sdk.OutputFile{ + "/tmp/age.public.txt": { + Contents: []byte(plugintest.LoadFixture(t, "age.public.txt")), + }, + }, + }, + }, + "explicit-encryption-mode-long": { + ItemFields: map[sdk.FieldName]string{ + fieldname.PrivateKey: "AGE-SECRET-KEY-10000000000000000000000000000000000000000000000000000000000", + fieldname.PublicKey: "age10000000000000000000000000000000000000000000000000000000000", + }, + CommandLine: []string{"age", "--encrypt", "-o", "/tmp/encrypted.txt", "/tmp/unencrypted.txt"}, + ExpectedOutput: sdk.ProvisionOutput{ + CommandLine: []string{"age", "-R", "/tmp/age.public.txt", "--encrypt", "-o", "/tmp/encrypted.txt", "/tmp/unencrypted.txt"}, + Files: map[string]sdk.OutputFile{ + "/tmp/age.public.txt": { + Contents: []byte(plugintest.LoadFixture(t, "age.public.txt")), + }, + }, + }, + }, + "decryption-mode-short": { + ItemFields: map[sdk.FieldName]string{ + fieldname.PrivateKey: "AGE-SECRET-KEY-10000000000000000000000000000000000000000000000000000000000", + fieldname.PublicKey: "age10000000000000000000000000000000000000000000000000000000000", + }, + CommandLine: []string{"age", "-d", "-o", "/tmp/encrypted.txt", "/tmp/unencrypted.txt"}, + ExpectedOutput: sdk.ProvisionOutput{ + CommandLine: []string{"age", "-i", "/tmp/age.private.txt", "-d", "-o", "/tmp/encrypted.txt", "/tmp/unencrypted.txt"}, + Files: map[string]sdk.OutputFile{ + "/tmp/age.private.txt": { + Contents: []byte(plugintest.LoadFixture(t, "age.private.txt")), + }, + }, + }, + }, + "decryption-mode-long": { + ItemFields: map[sdk.FieldName]string{ + fieldname.PrivateKey: "AGE-SECRET-KEY-10000000000000000000000000000000000000000000000000000000000", + fieldname.PublicKey: "age10000000000000000000000000000000000000000000000000000000000", + }, + CommandLine: []string{"age", "--decrypt", "-o", "/tmp/encrypted.txt", "/tmp/unencrypted.txt"}, + ExpectedOutput: sdk.ProvisionOutput{ + CommandLine: []string{"age", "-i", "/tmp/age.private.txt", "--decrypt", "-o", "/tmp/encrypted.txt", "/tmp/unencrypted.txt"}, + Files: map[string]sdk.OutputFile{ + "/tmp/age.private.txt": { + Contents: []byte(plugintest.LoadFixture(t, "age.private.txt")), + }, + }, + }, + }, + "decryption-identity-flag-provided": { + ItemFields: map[sdk.FieldName]string{ + fieldname.PrivateKey: "AGE-SECRET-KEY-10000000000000000000000000000000000000000000000000000000000", + fieldname.PublicKey: "age10000000000000000000000000000000000000000000000000000000000", + }, + CommandLine: []string{"age", "-i", "/tmp/age.user_provided_identity_flag.txt", "--decrypt", "-o", "/tmp/encrypted.txt", "/tmp/unencrypted.txt"}, + ExpectedOutput: sdk.ProvisionOutput{ + Diagnostics: sdk.Diagnostics{ + Errors: []sdk.Error{{Message: provisioner.ErrConflictingIdentityFlag.Error()}}, + }, + CommandLine: []string{"age", "-i", "/tmp/age.private.txt", "-i", "/tmp/age.user_provided_identity_flag.txt", "--decrypt", "-o", "/tmp/encrypted.txt", "/tmp/unencrypted.txt"}, + Files: map[string]sdk.OutputFile{ + "/tmp/age.private.txt": { + Contents: []byte(plugintest.LoadFixture(t, "age.private.txt")), + }, + }, + }, + }, + }) +} diff --git a/plugins/age/operation/operation.go b/plugins/age/operation/operation.go new file mode 100644 index 000000000..7a2122de4 --- /dev/null +++ b/plugins/age/operation/operation.go @@ -0,0 +1,42 @@ +package operation + +const ( + decryptShort = "-d" + decryptLong = "--decrypt" + encryptShort = "-e" + encryptLong = "--encrypt" +) + +const ( + Encrypt Operation = iota + Decrypt +) + +// Operation defines the type of action (encryption or decryption) to be performed. +type Operation int + +// String returns the string representation of an Operation. +func (op Operation) String() string { + switch op { + case Encrypt: + return "encrypt" + case Decrypt: + return "decrypt" + default: + return "unknown" + } +} + +// Detect determines the operation (encrypt or decrypt) based on the provided command-line arguments. +// If no valid operation flags are detected, it defaults to encryption mode. +func Detect(args []string) Operation { + for _, arg := range args { + switch arg { + case decryptShort, decryptLong: + return Decrypt + case encryptShort, encryptLong: + return Encrypt + } + } + return Encrypt +} diff --git a/plugins/age/operation/operation_test.go b/plugins/age/operation/operation_test.go new file mode 100644 index 000000000..c0739861d --- /dev/null +++ b/plugins/age/operation/operation_test.go @@ -0,0 +1,36 @@ +package operation + +import "testing" + +func TestOperationString(t *testing.T) { + tests := []struct { + name string + input Operation + expected string + }{ + { + name: "Encrypt Operation", + input: Encrypt, + expected: "encrypt", + }, + { + name: "Decrypt Operation", + input: Decrypt, + expected: "decrypt", + }, + { + name: "Invalid Operation", + input: Operation(999), + expected: "unknown", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := test.input.String() + if result != test.expected { + t.Errorf("For input %v, expected %q but got %q", test.input, test.expected, result) + } + }) + } +} diff --git a/plugins/age/plugin.go b/plugins/age/plugin.go new file mode 100644 index 000000000..c926317af --- /dev/null +++ b/plugins/age/plugin.go @@ -0,0 +1,22 @@ +package age + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/schema" +) + +func New() schema.Plugin { + return schema.Plugin{ + Name: "age", + Platform: schema.PlatformInfo{ + Name: "Age", + Homepage: sdk.URL("https://age-encryption.org/"), + }, + Credentials: []schema.CredentialType{ + KeyPair(), + }, + Executables: []schema.Executable{ + AgeCLI(), + }, + } +} diff --git a/plugins/age/provisioner/errors.go b/plugins/age/provisioner/errors.go new file mode 100644 index 000000000..4ec9e7ef1 --- /dev/null +++ b/plugins/age/provisioner/errors.go @@ -0,0 +1,7 @@ +package provisioner + +import "fmt" + +var ( + ErrConflictingIdentityFlag = fmt.Errorf("conflict: the -i/--identity flag is automatically added by this plugin. Remove it from your command to continue") +) diff --git a/plugins/age/provisioner/private_key.go b/plugins/age/provisioner/private_key.go new file mode 100644 index 000000000..e7aea83a9 --- /dev/null +++ b/plugins/age/provisioner/private_key.go @@ -0,0 +1,44 @@ +package provisioner + +import ( + "context" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/provision" +) + +// PrivateKeyProvisioner handles the provisioning and deprovisioning of private key files for the age command. +type PrivateKeyProvisioner struct { + privateKey provision.ItemToFileContents + fileOptions []provision.FileOption +} + +// Provision sets up the private key file for age decrypt commands. +func (p PrivateKeyProvisioner) Provision(ctx context.Context, in sdk.ProvisionInput, out *sdk.ProvisionOutput) { + for _, arg := range out.CommandLine { + if arg == "-i" || arg == "--identity" { + out.AddError(ErrConflictingIdentityFlag) + } + } + + fileProvisioner := provision.TempFile(p.privateKey, p.fileOptions...) + fileProvisioner.Provision(ctx, in, out) +} + +// Deprovision performs cleanup after the process completes. +// In this implementation, no cleanup is required as temporary files are automatically removed. +func (p PrivateKeyProvisioner) Deprovision(ctx context.Context, in sdk.DeprovisionInput, out *sdk.DeprovisionOutput) { +} + +func (p PrivateKeyProvisioner) Description() string { + return "Provision temporary file with private key & populate command line arguments." +} + +// PrivateKeyTempFile creates a new PrivateKeyProvisioner for creating private key files for age. +func PrivateKeyTempFile(privateKey provision.ItemToFileContents, opts ...provision.FileOption) PrivateKeyProvisioner { + opts = append(opts, provision.Filename("age.private.txt"), provision.PrependArgs("-i", "{{.Path}}")) + return PrivateKeyProvisioner{ + privateKey: privateKey, + fileOptions: opts, + } +} diff --git a/plugins/age/provisioner/provisioner.go b/plugins/age/provisioner/provisioner.go new file mode 100644 index 000000000..e78f101ae --- /dev/null +++ b/plugins/age/provisioner/provisioner.go @@ -0,0 +1,55 @@ +package provisioner + +import ( + "context" + + "github.com/1Password/shell-plugins/plugins/age/operation" + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/provision" +) + +// KeyFiles holds the private and public key material as file contents. +type KeyFiles struct { + private provision.ItemToFileContents + public provision.ItemToFileContents +} + +// NewKeyFiles creates a new instance of KeyFiles to hold the key material. +func NewKeyFiles(private, public provision.ItemToFileContents) *KeyFiles { + return &KeyFiles{private, public} +} + +// KeyPairProvisioner handles the provisioning and deprovisioning of key files for the age command. +type KeyPairProvisioner struct { + private PrivateKeyProvisioner + public PublicKeyProvisioner +} + +// Provision sets up the necessary key file(s) for the `age` command based on the operation mode (i.e. encrypt or decrypt). +// It determines the mode from the command line arguments and calls the appropriate provisioner. +func (p KeyPairProvisioner) Provision(ctx context.Context, in sdk.ProvisionInput, out *sdk.ProvisionOutput) { + switch operation.Detect(out.CommandLine) { + case operation.Encrypt: + p.public.Provision(ctx, in, out) + case operation.Decrypt: + p.private.Provision(ctx, in, out) + + } +} + +// Deprovision performs cleanup after the process completes. +// In this implementation, no cleanup is required as temporary files are automatically removed. +func (p KeyPairProvisioner) Deprovision(ctx context.Context, in sdk.DeprovisionInput, out *sdk.DeprovisionOutput) { +} + +func (p KeyPairProvisioner) Description() string { + return "Determine operating mode and provision appropriate key files and populate command line arguments." +} + +// KeyPairTempFile creates a new KeyPairProvisioner for handling temporary files for the age command. +func KeyPairTempFile(keys *KeyFiles) sdk.Provisioner { + return KeyPairProvisioner{ + private: PrivateKeyTempFile(keys.private), + public: PublicKeyTempFile(keys.public), + } +} diff --git a/plugins/age/provisioner/public_key.go b/plugins/age/provisioner/public_key.go new file mode 100644 index 000000000..1ec31db2a --- /dev/null +++ b/plugins/age/provisioner/public_key.go @@ -0,0 +1,38 @@ +package provisioner + +import ( + "context" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/provision" +) + +// PublicKeyProvisioner handles the provisioning and deprovisioning of public key files for the age command. +type PublicKeyProvisioner struct { + publicKey provision.ItemToFileContents + fileOptions []provision.FileOption +} + +// Provision sets up the public key file for age encrypt commands. +func (p PublicKeyProvisioner) Provision(ctx context.Context, in sdk.ProvisionInput, out *sdk.ProvisionOutput) { + fileProvisioner := provision.TempFile(p.publicKey, p.fileOptions...) + fileProvisioner.Provision(ctx, in, out) +} + +// Deprovision performs cleanup after the process completes. +// In this implementation, no cleanup is required as temporary files are automatically removed. +func (p PublicKeyProvisioner) Deprovision(ctx context.Context, in sdk.DeprovisionInput, out *sdk.DeprovisionOutput) { +} + +func (p PublicKeyProvisioner) Description() string { + return "Provision temporary file with public key file & populate command line arguments." +} + +// PublicKeyTempFile creates a new PublicKeyProvisioner for creating public key files for age. +func PublicKeyTempFile(publicKey provision.ItemToFileContents, opts ...provision.FileOption) PublicKeyProvisioner { + opts = append(opts, provision.Filename("age.public.txt"), provision.PrependArgs("-R", "{{.Path}}")) + return PublicKeyProvisioner{ + publicKey: publicKey, + fileOptions: opts, + } +} diff --git a/plugins/age/test-fixtures/age.private.txt b/plugins/age/test-fixtures/age.private.txt new file mode 100644 index 000000000..67adc0d23 --- /dev/null +++ b/plugins/age/test-fixtures/age.private.txt @@ -0,0 +1,3 @@ +# generated by: 1password-cli/shell-plugins/plugin/age +# public key: age10000000000000000000000000000000000000000000000000000000000 +AGE-SECRET-KEY-10000000000000000000000000000000000000000000000000000000000 \ No newline at end of file diff --git a/plugins/age/test-fixtures/age.public.txt b/plugins/age/test-fixtures/age.public.txt new file mode 100644 index 000000000..cbefc86da --- /dev/null +++ b/plugins/age/test-fixtures/age.public.txt @@ -0,0 +1,2 @@ +# generated by: 1password-cli/shell-plugins/plugin/age +age10000000000000000000000000000000000000000000000000000000000 \ No newline at end of file diff --git a/plugins/akamai/api_client_credentials.go b/plugins/akamai/api_client_credentials.go index 87fda0dbf..54f23a5d8 100644 --- a/plugins/akamai/api_client_credentials.go +++ b/plugins/akamai/api_client_credentials.go @@ -73,7 +73,7 @@ func APIClientCredentials() schema.CredentialType { }, DefaultProvisioner: provision.TempFile(configFile, provision.Filename(".edgerc"), - provision.AddArgs( + provision.AppendArgs( "--edgerc", "{{ .Path }}", "--section", "default", ), diff --git a/plugins/mysql/database_credentials.go b/plugins/mysql/database_credentials.go index 432d7c38e..fa880fa82 100644 --- a/plugins/mysql/database_credentials.go +++ b/plugins/mysql/database_credentials.go @@ -43,7 +43,7 @@ func DatabaseCredentials() schema.CredentialType { Optional: true, }, }, - DefaultProvisioner: provision.TempFile(mysqlConfig, provision.Filename("my.cnf"), provision.AddArgs("--defaults-file={{ .Path }}")), + DefaultProvisioner: provision.TempFile(mysqlConfig, provision.Filename("my.cnf"), provision.AppendArgs("--defaults-file={{ .Path }}")), Importer: importer.TryAll( TryMySQLConfigFile("/etc/my.cnf"), TryMySQLConfigFile("/etc/mysql/my.cnf"), diff --git a/sdk/provision/file_provisioner.go b/sdk/provision/file_provisioner.go index 4d5d6041d..43acc8457 100644 --- a/sdk/provision/file_provisioner.go +++ b/sdk/provision/file_provisioner.go @@ -20,12 +20,20 @@ type FileProvisioner struct { outpathFixed string outpathEnvVar string outdirEnvVar string - setOutpathAsArg bool + argPlacementMode ArgPlacementMode outpathArgTemplates []string } type ItemToFileContents func(in sdk.ProvisionInput) ([]byte, error) +type ArgPlacementMode int + +const ( + Unset ArgPlacementMode = iota + Prepend + Append +) + // FieldAsFile can be used to store the value of a single field as a file. func FieldAsFile(fieldName sdk.FieldName) ItemToFileContents { return ItemToFileContents(func(in sdk.ProvisionInput) ([]byte, error) { @@ -83,14 +91,30 @@ func SetOutputDirAsEnvVar(envVarName string) FileOption { } } -// AddArgs can be used to add args to the command line. This is useful when the output file path -// should be passed as an arg. The output path is available as "{{ .Path }}" in each arg. +// AppendArgs appends arguments to the command line for a FileProvisioner. +// This is particularly useful when you need to add arguments that reference the output file path. +// The output path is available as "{{ .Path }}" within the provided argument templates. +// For example: +// * `AppendArgs("--log", "{{ .Path }}")` results in `--log /path/to/tempfile`. +// * `AppendArgs("--log={{ .Path }}")` results in `--log=/path/to/tempfile`. +func AppendArgs(argTemplates ...string) FileOption { + return func(p *FileProvisioner) { + p.argPlacementMode = Append + p.outpathArgTemplates = argTemplates + } +} + +// PrependArgs prepends arguments to the command line for a FileProvisioner. +// This is particularly useful when you need to add arguments that reference the output file path. +// The output path is available as "{{ .Path }}" within the provided argument templates. // For example: -// * `AddArgs("--config-file", "{{ .Path }}")` will result in `--config-file /path/to/tempfile`. -// * `AddArgs("--config-file={{ .Path }}")` will result in `--config-file=/path/to/tempfile`. -func AddArgs(argTemplates ...string) FileOption { +// * `PrependArgs("--input", "{{ .Path }}")` results in `--input /path/to/tempfile`. +// * `PrependArgs("--input={{ .Path }}")` results in `--input=/path/to/tempfile`. +// +// The arguments provided are added before any pre-existing arguments in the command line, but after the command itself. +func PrependArgs(argTemplates ...string) FileOption { return func(p *FileProvisioner) { - p.setOutpathAsArg = true + p.argPlacementMode = Prepend p.outpathArgTemplates = argTemplates } } @@ -134,7 +158,7 @@ func (p FileProvisioner) Provision(ctx context.Context, in sdk.ProvisionInput, o } // Add args to specify the output path. - if p.setOutpathAsArg { + if p.argPlacementMode != Unset { tmplData := struct{ Path string }{ Path: outpath, } @@ -159,7 +183,14 @@ func (p FileProvisioner) Provision(ctx context.Context, in sdk.ProvisionInput, o argsResolved[i] = result.String() } - out.AddArgs(argsResolved...) + switch p.argPlacementMode { + case Append: + out.AppendArgs(argsResolved...) + case Prepend: + out.PrependArgs(argsResolved...) + default: + out.AddError(fmt.Errorf("invalid argument placement mode")) + } } } diff --git a/sdk/provisioner.go b/sdk/provisioner.go index 99883ffae..46d74e9ec 100644 --- a/sdk/provisioner.go +++ b/sdk/provisioner.go @@ -102,6 +102,30 @@ func (out *ProvisionOutput) AddEnvVar(name string, value string) { out.Environment[name] = value } +func (out *ProvisionOutput) AddArgsAtIndex(position int, args ...string) { + if position == -1 || position >= len(out.CommandLine) { + out.CommandLine = append(out.CommandLine, args...) + return + } + + if position <= 0 { + out.CommandLine = append(args, out.CommandLine...) + return + } + + out.CommandLine = append(out.CommandLine[:position], append(args, out.CommandLine[position:]...)...) +} + +// PrependArgs can be used to add additional arguments to the command line of the provision output. +func (out *ProvisionOutput) PrependArgs(args ...string) { + out.AddArgsAtIndex(1, args...) +} + +// AppendArgs can be used to add additional arguments to the command line of the provision output. +func (out *ProvisionOutput) AppendArgs(args ...string) { + out.AddArgsAtIndex(-1, args...) +} + // AddArgs can be used to add additional arguments to the command line of the provision output. func (out *ProvisionOutput) AddArgs(args ...string) { out.CommandLine = append(out.CommandLine, args...)