Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions plugins/motherduck/access_token.go
Original file line number Diff line number Diff line change
@@ -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"}
55 changes: 55 additions & 0 deletions plugins/motherduck/access_token_test.go
Original file line number Diff line number Diff line change
@@ -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",
},
},
},
},
})
}
59 changes: 59 additions & 0 deletions plugins/motherduck/duckdb.go
Original file line number Diff line number Diff line change
@@ -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,
},
},
}
}
92 changes: 92 additions & 0 deletions plugins/motherduck/duckdb_test.go
Original file line number Diff line number Diff line change
@@ -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,
},
})
})
}
}
22 changes: 22 additions & 0 deletions plugins/motherduck/plugin.go
Original file line number Diff line number Diff line change
@@ -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(),
},
}
}
Loading