diff --git a/plugins/pypi/api_token.go b/plugins/pypi/api_token.go new file mode 100644 index 00000000..38883b09 --- /dev/null +++ b/plugins/pypi/api_token.go @@ -0,0 +1,38 @@ +package pypi + +import ( + "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 APIToken() schema.CredentialType { + return schema.CredentialType{ + Name: credname.APIToken, + DocsURL: sdk.URL("https://pypi.org/help/#apitoken"), + ManagementURL: sdk.URL("https://pypi.org/manage/account/#api-tokens"), + Fields: []schema.CredentialField{ + { + Name: fieldname.Token, + MarkdownDescription: "API token used to authenticate to PyPI.", + Secret: true, + Composition: &schema.ValueComposition{ + Prefix: "pypi-", + Charset: schema.Charset{ + Uppercase: true, + Lowercase: true, + Digits: true, + Specific: []rune{'-', '_'}, + }, + }, + }, + }, + DefaultProvisioner: PyPIToolProvisioner("TWINE_USERNAME", "TWINE_PASSWORD"), + Importer: importer.TryAll( + importer.TryAllEnvVars(fieldname.Token, "TWINE_PASSWORD", "FLIT_PASSWORD", "HATCH_INDEX_AUTH"), + TryPyPIRCFile(), + ), + } +} diff --git a/plugins/pypi/api_token_test.go b/plugins/pypi/api_token_test.go new file mode 100644 index 00000000..b2b67a93 --- /dev/null +++ b/plugins/pypi/api_token_test.go @@ -0,0 +1,96 @@ +package pypi + +import ( + "testing" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/plugintest" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +func TestAPITokenImporter(t *testing.T) { + plugintest.TestImporter(t, APIToken().Importer, map[string]plugintest.ImportCase{ + "TWINE_PASSWORD environment variable": { + Environment: map[string]string{ + "TWINE_PASSWORD": "pypi-AgEIcHlwaS5vcmc", + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Token: "pypi-AgEIcHlwaS5vcmc", + }, + }, + }, + }, + "FLIT_PASSWORD environment variable": { + Environment: map[string]string{ + "FLIT_PASSWORD": "pypi-flit123abc", + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Token: "pypi-flit123abc", + }, + }, + }, + }, + "HATCH_INDEX_AUTH environment variable": { + Environment: map[string]string{ + "HATCH_INDEX_AUTH": "pypi-hatch789xyz", + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Token: "pypi-hatch789xyz", + }, + }, + }, + }, + ".pypirc file with pypi section": { + Files: map[string]string{ + "~/.pypirc": `[distutils] +index-servers = pypi + +[pypi] +username = __token__ +password = pypi-secret123`, + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Token: "pypi-secret123", + }, + }, + }, + }, + ".pypirc file with server-login section": { + Files: map[string]string{ + "~/.pypirc": `[server-login] +password = pypi-serverlogin456`, + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Token: "pypi-serverlogin456", + }, + }, + }, + }, + }) +} + +func TestAPITokenProvisioner(t *testing.T) { + plugintest.TestProvisioner(t, APIToken().DefaultProvisioner, map[string]plugintest.ProvisionCase{ + "default provisioner sets TWINE env vars": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Token: "pypi-AgEIcHlwaS5vcmc", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "TWINE_USERNAME": "__token__", + "TWINE_PASSWORD": "pypi-AgEIcHlwaS5vcmc", + }, + }, + }, + }) +} diff --git a/plugins/pypi/flit.go b/plugins/pypi/flit.go new file mode 100644 index 00000000..1fdb53e7 --- /dev/null +++ b/plugins/pypi/flit.go @@ -0,0 +1,26 @@ +package pypi + +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 FlitCLI() schema.Executable { + return schema.Executable{ + Name: "Flit", + Runs: []string{"flit"}, + DocsURL: sdk.URL("https://flit.pypa.io"), + NeedsAuth: needsauth.IfAll( + needsauth.NotForHelpOrVersion(), + needsauth.ForCommand("publish"), + ), + Uses: []schema.CredentialUsage{ + { + Name: credname.APIToken, + Provisioner: PyPIToolProvisioner("FLIT_USERNAME", "FLIT_PASSWORD"), + }, + }, + } +} diff --git a/plugins/pypi/flit_test.go b/plugins/pypi/flit_test.go new file mode 100644 index 00000000..56d132fc --- /dev/null +++ b/plugins/pypi/flit_test.go @@ -0,0 +1,46 @@ +package pypi + +import ( + "testing" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/plugintest" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +func TestFlitCLIProvisioner(t *testing.T) { + plugintest.TestProvisioner(t, FlitCLI().Uses[0].Provisioner, map[string]plugintest.ProvisionCase{ + "sets FLIT_USERNAME and FLIT_PASSWORD": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Token: "pypi-flit123abc", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "FLIT_USERNAME": "__token__", + "FLIT_PASSWORD": "pypi-flit123abc", + }, + }, + }, + }) +} + +func TestFlitCLINeedsAuth(t *testing.T) { + plugintest.TestNeedsAuth(t, FlitCLI().NeedsAuth, map[string]plugintest.NeedsAuthCase{ + "requires auth for publish": { + Args: []string{"publish"}, + ExpectedNeedsAuth: true, + }, + "skips auth for help": { + Args: []string{"--help"}, + ExpectedNeedsAuth: false, + }, + "skips auth for version": { + Args: []string{"--version"}, + ExpectedNeedsAuth: false, + }, + "skips auth for build command": { + Args: []string{"build"}, + ExpectedNeedsAuth: false, + }, + }) +} diff --git a/plugins/pypi/hatch.go b/plugins/pypi/hatch.go new file mode 100644 index 00000000..e23c6785 --- /dev/null +++ b/plugins/pypi/hatch.go @@ -0,0 +1,26 @@ +package pypi + +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 HatchCLI() schema.Executable { + return schema.Executable{ + Name: "Hatch", + Runs: []string{"hatch"}, + DocsURL: sdk.URL("https://hatch.pypa.io"), + NeedsAuth: needsauth.IfAll( + needsauth.NotForHelpOrVersion(), + needsauth.ForCommand("publish"), + ), + Uses: []schema.CredentialUsage{ + { + Name: credname.APIToken, + Provisioner: PyPIToolProvisioner("HATCH_INDEX_USER", "HATCH_INDEX_AUTH"), + }, + }, + } +} diff --git a/plugins/pypi/hatch_test.go b/plugins/pypi/hatch_test.go new file mode 100644 index 00000000..965b2a22 --- /dev/null +++ b/plugins/pypi/hatch_test.go @@ -0,0 +1,46 @@ +package pypi + +import ( + "testing" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/plugintest" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +func TestHatchCLIProvisioner(t *testing.T) { + plugintest.TestProvisioner(t, HatchCLI().Uses[0].Provisioner, map[string]plugintest.ProvisionCase{ + "sets HATCH_INDEX_USER and HATCH_INDEX_AUTH": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Token: "pypi-hatch789xyz", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "HATCH_INDEX_USER": "__token__", + "HATCH_INDEX_AUTH": "pypi-hatch789xyz", + }, + }, + }, + }) +} + +func TestHatchCLINeedsAuth(t *testing.T) { + plugintest.TestNeedsAuth(t, HatchCLI().NeedsAuth, map[string]plugintest.NeedsAuthCase{ + "requires auth for publish": { + Args: []string{"publish"}, + ExpectedNeedsAuth: true, + }, + "skips auth for help": { + Args: []string{"--help"}, + ExpectedNeedsAuth: false, + }, + "skips auth for version": { + Args: []string{"--version"}, + ExpectedNeedsAuth: false, + }, + "skips auth for build command": { + Args: []string{"build"}, + ExpectedNeedsAuth: false, + }, + }) +} diff --git a/plugins/pypi/plugin.go b/plugins/pypi/plugin.go new file mode 100644 index 00000000..e4226dea --- /dev/null +++ b/plugins/pypi/plugin.go @@ -0,0 +1,24 @@ +package pypi + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/schema" +) + +func New() schema.Plugin { + return schema.Plugin{ + Name: "pypi", + Platform: schema.PlatformInfo{ + Name: "PyPI", + Homepage: sdk.URL("https://pypi.org"), + }, + Credentials: []schema.CredentialType{ + APIToken(), + }, + Executables: []schema.Executable{ + TwineCLI(), + FlitCLI(), + HatchCLI(), + }, + } +} diff --git a/plugins/pypi/provisioner.go b/plugins/pypi/provisioner.go new file mode 100644 index 00000000..516721ca --- /dev/null +++ b/plugins/pypi/provisioner.go @@ -0,0 +1,37 @@ +package pypi + +import ( + "context" + "fmt" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +// pypiToolProvisioner sets username and password environment variables +// for PyPI publishing tools. The username is always "__token__" when +// using API token authentication. +type pypiToolProvisioner struct { + usernameEnvVar string + passwordEnvVar string +} + +func PyPIToolProvisioner(usernameEnvVar, passwordEnvVar string) sdk.Provisioner { + return pypiToolProvisioner{ + usernameEnvVar: usernameEnvVar, + passwordEnvVar: passwordEnvVar, + } +} + +func (p pypiToolProvisioner) Description() string { + return fmt.Sprintf("Provision PyPI API token via %s and %s environment variables", p.usernameEnvVar, p.passwordEnvVar) +} + +func (p pypiToolProvisioner) Provision(ctx context.Context, in sdk.ProvisionInput, out *sdk.ProvisionOutput) { + out.AddEnvVar(p.usernameEnvVar, "__token__") + out.AddEnvVar(p.passwordEnvVar, in.ItemFields[fieldname.Token]) +} + +func (p pypiToolProvisioner) Deprovision(ctx context.Context, in sdk.DeprovisionInput, out *sdk.DeprovisionOutput) { + // Environment variables are automatically cleaned up when the process ends. +} \ No newline at end of file diff --git a/plugins/pypi/pypirc_importer.go b/plugins/pypi/pypirc_importer.go new file mode 100644 index 00000000..d16de548 --- /dev/null +++ b/plugins/pypi/pypirc_importer.go @@ -0,0 +1,36 @@ +package pypi + +import ( + "context" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/importer" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +func TryPyPIRCFile() sdk.Importer { + return importer.TryFile("~/.pypirc", func(ctx context.Context, contents importer.FileContents, in sdk.ImportInput, out *sdk.ImportAttempt) { + cfg, err := contents.ToINI() + if err != nil { + return + } + + // Try [pypi] section first, then [server-login] + for _, section := range []string{"pypi", "server-login"} { + s := cfg.Section(section) + if s == nil { + continue + } + + password := s.Key("password").String() + if password != "" { + out.AddCandidate(sdk.ImportCandidate{ + Fields: map[sdk.FieldName]string{ + fieldname.Token: password, + }, + }) + return + } + } + }) +} diff --git a/plugins/pypi/twine.go b/plugins/pypi/twine.go new file mode 100644 index 00000000..7630b04a --- /dev/null +++ b/plugins/pypi/twine.go @@ -0,0 +1,26 @@ +package pypi + +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 TwineCLI() schema.Executable { + return schema.Executable{ + Name: "Twine", + Runs: []string{"twine"}, + DocsURL: sdk.URL("https://twine.readthedocs.io"), + NeedsAuth: needsauth.IfAll( + needsauth.NotForHelpOrVersion(), + needsauth.ForCommand("upload"), + ), + Uses: []schema.CredentialUsage{ + { + Name: credname.APIToken, + Provisioner: PyPIToolProvisioner("TWINE_USERNAME", "TWINE_PASSWORD"), + }, + }, + } +} diff --git a/plugins/pypi/twine_test.go b/plugins/pypi/twine_test.go new file mode 100644 index 00000000..b3a2c592 --- /dev/null +++ b/plugins/pypi/twine_test.go @@ -0,0 +1,46 @@ +package pypi + +import ( + "testing" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/plugintest" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +func TestTwineCLIProvisioner(t *testing.T) { + plugintest.TestProvisioner(t, TwineCLI().Uses[0].Provisioner, map[string]plugintest.ProvisionCase{ + "sets TWINE_USERNAME and TWINE_PASSWORD": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Token: "pypi-AgEIcHlwaS5vcmc", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "TWINE_USERNAME": "__token__", + "TWINE_PASSWORD": "pypi-AgEIcHlwaS5vcmc", + }, + }, + }, + }) +} + +func TestTwineCLINeedsAuth(t *testing.T) { + plugintest.TestNeedsAuth(t, TwineCLI().NeedsAuth, map[string]plugintest.NeedsAuthCase{ + "requires auth for upload": { + Args: []string{"upload", "dist/*"}, + ExpectedNeedsAuth: true, + }, + "skips auth for help": { + Args: []string{"--help"}, + ExpectedNeedsAuth: false, + }, + "skips auth for version": { + Args: []string{"--version"}, + ExpectedNeedsAuth: false, + }, + "skips auth for check command": { + Args: []string{"check", "dist/*"}, + ExpectedNeedsAuth: false, + }, + }) +}