diff --git a/plugins/motherduck/access_token.go b/plugins/motherduck/access_token.go new file mode 100644 index 00000000..d02f35dd --- /dev/null +++ b/plugins/motherduck/access_token.go @@ -0,0 +1,44 @@ +package motherduck + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/importer" + "github.com/1Password/shell-plugins/sdk/provision" + "github.com/1Password/shell-plugins/sdk/schema" + "github.com/1Password/shell-plugins/sdk/schema/credname" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +func AccessToken() schema.CredentialType { + return schema.CredentialType{ + Name: credname.AccessToken, + DocsURL: sdk.URL("https://motherduck.com/docs/key-tasks/authenticating-and-connecting-to-motherduck/authenticating-to-motherduck/#authentication-using-an-access-token"), + ManagementURL: sdk.URL("https://app.motherduck.com/settings/tokens"), + Fields: []schema.CredentialField{ + { + Name: fieldname.Token, + MarkdownDescription: "Token used to authenticate to MotherDuck.", + Secret: true, + Composition: &schema.ValueComposition{ + Charset: schema.Charset{ + Uppercase: true, + Lowercase: true, + Digits: true, + Specific: []rune{'.', '_'}, + }, + }, + }, + }, + DefaultProvisioner: provision.EnvVars(defaultEnvVarMapping), + Importer: importer.TryAll( + importer.TryAllEnvVars(fieldname.Token, tokenEnvVars...), + )} +} + +var defaultEnvVarMapping = map[string]sdk.FieldName{ + "motherduck_token": fieldname.Token, +} + +// tokenEnvVars are the env vars the MotherDuck extension reads a token from. +// When both are set, motherduck_token takes precedence. +var tokenEnvVars = []string{"motherduck_token", "MOTHERDUCK_TOKEN"} diff --git a/plugins/motherduck/access_token_test.go b/plugins/motherduck/access_token_test.go new file mode 100644 index 00000000..52c87635 --- /dev/null +++ b/plugins/motherduck/access_token_test.go @@ -0,0 +1,55 @@ +package motherduck + +import ( + "testing" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/plugintest" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +func TestAccessTokenProvisioner(t *testing.T) { + plugintest.TestProvisioner(t, AccessToken().DefaultProvisioner, map[string]plugintest.ProvisionCase{ + "default": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Token: "TERAkHVPg65C6UGDw42llLlgPtZhBbafxnpqs74fjyuKnDpSt7TZNODw3catnivaruR09REDcNIwystkLMlRw5foxRjvytBFmkk0t0x9iHqY0MBY40Ltbcdw8fvt3OzsCgxmbh89v0XIWrRiwCfALA1dbqWDLaatAZWOLQhJmYcggQR6YBVoKM9H7XBrBjDtP7YJOoU2Z7rc7KWgTTqS9vyCtLx7GDSBitWQLvUYuWzvgh94qk1Wt16oua34jzDtosd59ahNlvA1vEqPtkYqC5mNbDbWqcunwelka4tI4uuEfyojeXowBzkv6izjT48J3usTPIIqTFMYgJnMwUtV6n8UgeuLumEsKd86HVLywapqO37zfNrlrVLzjHSv0rGA2NjDgBAueK2clqEXAMPLE", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "motherduck_token": "TERAkHVPg65C6UGDw42llLlgPtZhBbafxnpqs74fjyuKnDpSt7TZNODw3catnivaruR09REDcNIwystkLMlRw5foxRjvytBFmkk0t0x9iHqY0MBY40Ltbcdw8fvt3OzsCgxmbh89v0XIWrRiwCfALA1dbqWDLaatAZWOLQhJmYcggQR6YBVoKM9H7XBrBjDtP7YJOoU2Z7rc7KWgTTqS9vyCtLx7GDSBitWQLvUYuWzvgh94qk1Wt16oua34jzDtosd59ahNlvA1vEqPtkYqC5mNbDbWqcunwelka4tI4uuEfyojeXowBzkv6izjT48J3usTPIIqTFMYgJnMwUtV6n8UgeuLumEsKd86HVLywapqO37zfNrlrVLzjHSv0rGA2NjDgBAueK2clqEXAMPLE", + }, + }, + }, + }) +} + +func TestAccessTokenImporter(t *testing.T) { + plugintest.TestImporter(t, AccessToken().Importer, map[string]plugintest.ImportCase{ + "environment": { + Environment: map[string]string{ + "motherduck_token": "TERAkHVPg65C6UGDw42llLlgPtZhBbafxnpqs74fjyuKnDpSt7TZNODw3catnivaruR09REDcNIwystkLMlRw5foxRjvytBFmkk0t0x9iHqY0MBY40Ltbcdw8fvt3OzsCgxmbh89v0XIWrRiwCfALA1dbqWDLaatAZWOLQhJmYcggQR6YBVoKM9H7XBrBjDtP7YJOoU2Z7rc7KWgTTqS9vyCtLx7GDSBitWQLvUYuWzvgh94qk1Wt16oua34jzDtosd59ahNlvA1vEqPtkYqC5mNbDbWqcunwelka4tI4uuEfyojeXowBzkv6izjT48J3usTPIIqTFMYgJnMwUtV6n8UgeuLumEsKd86HVLywapqO37zfNrlrVLzjHSv0rGA2NjDgBAueK2clqEXAMPLE", + "MOTHERDUCK_TOKEN": "", + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Token: "TERAkHVPg65C6UGDw42llLlgPtZhBbafxnpqs74fjyuKnDpSt7TZNODw3catnivaruR09REDcNIwystkLMlRw5foxRjvytBFmkk0t0x9iHqY0MBY40Ltbcdw8fvt3OzsCgxmbh89v0XIWrRiwCfALA1dbqWDLaatAZWOLQhJmYcggQR6YBVoKM9H7XBrBjDtP7YJOoU2Z7rc7KWgTTqS9vyCtLx7GDSBitWQLvUYuWzvgh94qk1Wt16oua34jzDtosd59ahNlvA1vEqPtkYqC5mNbDbWqcunwelka4tI4uuEfyojeXowBzkv6izjT48J3usTPIIqTFMYgJnMwUtV6n8UgeuLumEsKd86HVLywapqO37zfNrlrVLzjHSv0rGA2NjDgBAueK2clqEXAMPLE", + }, + }, + }, + }, + "uppercase environment variable": { + Environment: map[string]string{ + "motherduck_token": "", + "MOTHERDUCK_TOKEN": "TERAkHVPg65C6UGDw42llLlgPtZhBbafxnpqs74fjyuKnDpSt7TZNODw3catnivaruR09REDcNIwystkLMlRw5foxRjvytBFmkk0t0x9iHqY0MBY40Ltbcdw8fvt3OzsCgxmbh89v0XIWrRiwCfALA1dbqWDLaatAZWOLQhJmYcggQR6YBVoKM9H7XBrBjDtP7YJOoU2Z7rc7KWgTTqS9vyCtLx7GDSBitWQLvUYuWzvgh94qk1Wt16oua34jzDtosd59ahNlvA1vEqPtkYqC5mNbDbWqcunwelka4tI4uuEfyojeXowBzkv6izjT48J3usTPIIqTFMYgJnMwUtV6n8UgeuLumEsKd86HVLywapqO37zfNrlrVLzjHSv0rGA2NjDgBAueK2clqEXAMPLE", + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Token: "TERAkHVPg65C6UGDw42llLlgPtZhBbafxnpqs74fjyuKnDpSt7TZNODw3catnivaruR09REDcNIwystkLMlRw5foxRjvytBFmkk0t0x9iHqY0MBY40Ltbcdw8fvt3OzsCgxmbh89v0XIWrRiwCfALA1dbqWDLaatAZWOLQhJmYcggQR6YBVoKM9H7XBrBjDtP7YJOoU2Z7rc7KWgTTqS9vyCtLx7GDSBitWQLvUYuWzvgh94qk1Wt16oua34jzDtosd59ahNlvA1vEqPtkYqC5mNbDbWqcunwelka4tI4uuEfyojeXowBzkv6izjT48J3usTPIIqTFMYgJnMwUtV6n8UgeuLumEsKd86HVLywapqO37zfNrlrVLzjHSv0rGA2NjDgBAueK2clqEXAMPLE", + }, + }, + }, + }, + }) +} diff --git a/plugins/motherduck/duckdb.go b/plugins/motherduck/duckdb.go new file mode 100644 index 00000000..34a5c832 --- /dev/null +++ b/plugins/motherduck/duckdb.go @@ -0,0 +1,59 @@ +package motherduck + +import ( + "os" + "regexp" + + "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" +) + +// motherDuckConnection matches MotherDuck connection strings like 'md:', 'md:my_db' or 'motherduck:my_db'. +// DuckDB treats the prefix case-insensitively. The prefix must not follow a word character, +// so text like 'cmd:' inside a query doesn't count. +var motherDuckConnection = regexp.MustCompile(`(?i)(^|[^[:alnum:]_])(md|motherduck):`) + +// connectionStringToken matches a token passed as a connection string parameter, +// e.g. 'md:my_db?motherduck_token=...' or its 'token=' alias. DuckDB uses it over any env var. +var connectionStringToken = regexp.MustCompile(`[?&](motherduck_)?token=`) + +// The plugin is only invoked if: +// - neither the motherduck_token nor the MOTHERDUCK_TOKEN environment variable is set +// - an argument contains an 'md:' or 'motherduck:' connection string that does not include a token +func ForMotherDuckButTokenNotSet() sdk.NeedsAuthentication { + return func(in sdk.NeedsAuthenticationInput) bool { + // If a token is already set in the environment, we don't need to authenticate + for _, envVar := range tokenEnvVars { + if os.Getenv(envVar) != "" { + return false + } + } + + // Otherwise, check if the command uses MotherDuck + for _, arg := range in.CommandArgs { + if motherDuckConnection.MatchString(arg) && !connectionStringToken.MatchString(arg) { + return true + } + } + return false + } +} + +func DuckDBCLI() schema.Executable { + return schema.Executable{ + Name: "DuckDB CLI", + Runs: []string{"duckdb"}, + DocsURL: sdk.URL("https://duckdb.org/docs/api/cli/overview"), + NeedsAuth: needsauth.IfAll( + needsauth.NotForHelpOrVersion(), + ForMotherDuckButTokenNotSet(), + ), + Uses: []schema.CredentialUsage{ + { + Name: credname.AccessToken, + }, + }, + } +} diff --git a/plugins/motherduck/duckdb_test.go b/plugins/motherduck/duckdb_test.go new file mode 100644 index 00000000..5963a08e --- /dev/null +++ b/plugins/motherduck/duckdb_test.go @@ -0,0 +1,92 @@ +package motherduck + +import ( + "testing" + + "github.com/1Password/shell-plugins/sdk/plugintest" +) + +// unsetTokenEnvVars keeps tokens from the developer's own shell from leaking into the tests. +func unsetTokenEnvVars(t *testing.T) { + t.Setenv("motherduck_token", "") + t.Setenv("MOTHERDUCK_TOKEN", "") +} + +func TestDuckDBCLINeedsAuth(t *testing.T) { + unsetTokenEnvVars(t) + + plugintest.TestNeedsAuth(t, DuckDBCLI().NeedsAuth, map[string]plugintest.NeedsAuthCase{ + "no args opens an in-memory database": { + Args: []string{}, + ExpectedNeedsAuth: false, + }, + "local database file": { + Args: []string{"local.ddb"}, + ExpectedNeedsAuth: false, + }, + "local database file with a command": { + Args: []string{"local.ddb", "-c", "select 1"}, + ExpectedNeedsAuth: false, + }, + "md: inside another word in a command": { + Args: []string{"local.ddb", "-c", "select 'cmd:ls'"}, + ExpectedNeedsAuth: false, + }, + "default MotherDuck database": { + Args: []string{"md:"}, + ExpectedNeedsAuth: true, + }, + "named MotherDuck database": { + Args: []string{"md:my_db"}, + ExpectedNeedsAuth: true, + }, + "motherduck: prefix": { + Args: []string{"motherduck:my_db"}, + ExpectedNeedsAuth: true, + }, + "uppercase prefix": { + Args: []string{"MD:my_db"}, + ExpectedNeedsAuth: true, + }, + "MotherDuck database with a command": { + Args: []string{"md:my_db", "-c", "select 1"}, + ExpectedNeedsAuth: true, + }, + "MotherDuck attached from a command": { + Args: []string{"-c", "ATTACH 'md:'"}, + ExpectedNeedsAuth: true, + }, + "MotherDuck attached with the motherduck: prefix": { + Args: []string{"-c", "ATTACH 'motherduck:my_db'"}, + ExpectedNeedsAuth: true, + }, + "token passed in the connection string": { + Args: []string{"md:my_db?motherduck_token=abc"}, + ExpectedNeedsAuth: false, + }, + "token alias passed in the connection string": { + Args: []string{"md:my_db?token=abc"}, + ExpectedNeedsAuth: false, + }, + "token passed after another connection string parameter": { + Args: []string{"md:my_db?attach_mode=single&motherduck_token=abc"}, + ExpectedNeedsAuth: false, + }, + }) +} + +func TestDuckDBCLINeedsAuthWithTokenEnvVar(t *testing.T) { + for _, envVar := range []string{"motherduck_token", "MOTHERDUCK_TOKEN"} { + t.Run(envVar, func(t *testing.T) { + unsetTokenEnvVars(t) + t.Setenv(envVar, "abc") + + plugintest.TestNeedsAuth(t, DuckDBCLI().NeedsAuth, map[string]plugintest.NeedsAuthCase{ + "MotherDuck database": { + Args: []string{"md:my_db"}, + ExpectedNeedsAuth: false, + }, + }) + }) + } +} diff --git a/plugins/motherduck/plugin.go b/plugins/motherduck/plugin.go new file mode 100644 index 00000000..8de964c6 --- /dev/null +++ b/plugins/motherduck/plugin.go @@ -0,0 +1,22 @@ +package motherduck + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/schema" +) + +func New() schema.Plugin { + return schema.Plugin{ + Name: "motherduck", + Platform: schema.PlatformInfo{ + Name: "MotherDuck", + Homepage: sdk.URL("https://motherduck.com"), + }, + Credentials: []schema.CredentialType{ + AccessToken(), + }, + Executables: []schema.Executable{ + DuckDBCLI(), + }, + } +}