diff --git a/plugins/npm/access_token.go b/plugins/npm/access_token.go new file mode 100644 index 000000000..26413932e --- /dev/null +++ b/plugins/npm/access_token.go @@ -0,0 +1,135 @@ +package npm + +import ( + "context" + "os" + "strings" + + "path/filepath" + + "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" + "gopkg.in/ini.v1" +) + +func AccessToken() schema.CredentialType { + return schema.CredentialType{ + Name: credname.AccessToken, + DocsURL: sdk.URL("https://docs.npmjs.com/creating-and-viewing-access-tokens"), + ManagementURL: sdk.URL("https://www.npmjs.com/settings//tokens/"), + Fields: []schema.CredentialField{ + { + Name: fieldname.Token, + MarkdownDescription: "Token used to authenticate to NPM.", + Secret: true, + Composition: &schema.ValueComposition{ + Length: 36, + Prefix: "npm_", + Charset: schema.Charset{ + Uppercase: true, + Lowercase: true, + Digits: true, + }, + }, + }, + { + Name: fieldname.Organization, + MarkdownDescription: "The organization the access token is scoped for.", + Optional: true, + }, + { + Name: fieldname.Host, + MarkdownDescription: "The registry host for the npm packages.", + Optional: true, + }, + }, + DefaultProvisioner: provision.TempFile(configFile, + provision.Filename(".npmrc"), + provision.AddArgs( + "--userconfig", "{{ .Path }}", + ), + ), + Importer: importer.TryAll( + TryNPMConfigFile(""), + TryGlobalNPMConfigFile("NPM_CONFIG_USERCONFIG", "~"), + ), + } +} + +func configFile(in sdk.ProvisionInput) ([]byte, error) { + contents := "" + + if org, ok := in.ItemFields[fieldname.Organization]; ok && org != "" { + contents += "@" + strings.Trim(org, "@") + ":" + } + if host, ok := in.ItemFields[fieldname.Host]; ok && host != "" { + contents += "//" + strings.Trim(host, "/") + "/:" + } else { + contents += "//registry.npmjs.org/:" + } + + contents += "_authToken=" + + if token, ok := in.ItemFields[fieldname.Token]; ok { + contents += token + } + + return []byte(contents), nil +} + +func TryGlobalNPMConfigFile(env string, defaultPath string) sdk.Importer { + path := os.Getenv(env) + if path == "" { + path = defaultPath + } + return TryNPMConfigFile(path) +} + +func TryNPMConfigFile(path string) sdk.Importer { + return importer.TryFile(filepath.Join(path, ".npmrc"), func(ctx context.Context, contents importer.FileContents, in sdk.ImportInput, out *sdk.ImportAttempt) { + + // don't use colon as a delimiter, since it is used in the .npmrc file as a delimiter + // between the scope, registry and configuration key + configs, err := ini.LoadSources(ini.LoadOptions{KeyValueDelimiters: "="}, []byte(contents)) + if err != nil { + out.AddError(err) + } + + // sections are not supported in .npmrc + section, err := configs.GetSection(ini.DefaultSection) + if err != nil { + out.AddError(err) + } + for _, key := range section.Keys() { + if strings.Contains(key.Name(), "_authToken") { + + keyParts := strings.Split(key.Name(), ":") + + registry := "" + scope := "" + hint := "" + if len(keyParts) == 2 { + registry = strings.Trim(keyParts[0], "/") + hint = registry + } else if len(keyParts) == 3 { + registry = strings.Trim(keyParts[1], "/") + scope = strings.Trim(keyParts[0], "@") + hint = "@" + scope + ":" + registry + } + + out.AddCandidate(sdk.ImportCandidate{ + Fields: map[sdk.FieldName]string{ + fieldname.Token: key.Value(), + fieldname.Host: registry, + fieldname.Organization: scope, + }, + NameHint: importer.SanitizeNameHint(hint), + }) + } + } + }) +} diff --git a/plugins/npm/access_token_test.go b/plugins/npm/access_token_test.go new file mode 100644 index 000000000..3a3209a38 --- /dev/null +++ b/plugins/npm/access_token_test.go @@ -0,0 +1,70 @@ +package npm + +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: "npm_4F1h0xp0lBn0XvZ4RGfpnpoawECBMEXAMPLE", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Files: map[string]sdk.OutputFile{ + "/tmp/.npmrc": {Contents: []byte("//registry.npmjs.org/:_authToken=npm_4F1h0xp0lBn0XvZ4RGfpnpoawECBMEXAMPLE")}, + }, + CommandLine: []string{"--userconfig", "/tmp/.npmrc"}, + }, + }, + "custom registry": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Token: "npm_4F1h0xp0lBn0XvZ4RGfpnpoawECBMEXAMPLE", + fieldname.Host: "my.registry.com", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Files: map[string]sdk.OutputFile{ + "/tmp/.npmrc": {Contents: []byte("//my.registry.com/:_authToken=npm_4F1h0xp0lBn0XvZ4RGfpnpoawECBMEXAMPLE")}, + }, + CommandLine: []string{"--userconfig", "/tmp/.npmrc"}, + }, + }, + "scoped": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Token: "npm_4F1h0xp0lBn0XvZ4RGfpnpoawECBMEXAMPLE", + fieldname.Host: "my.registry.com", + fieldname.Organization: "op", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Files: map[string]sdk.OutputFile{ + "/tmp/.npmrc": {Contents: []byte("@op://my.registry.com/:_authToken=npm_4F1h0xp0lBn0XvZ4RGfpnpoawECBMEXAMPLE")}, + }, + CommandLine: []string{"--userconfig", "/tmp/.npmrc"}, + }, + }, + }) +} + +func TestAccessTokenImporter(t *testing.T) { + plugintest.TestImporter(t, AccessToken().Importer, map[string]plugintest.ImportCase{ + "config file": { + Files: map[string]string{ + "~/.npmrc": plugintest.LoadFixture(t, ".npmrc-default-registry"), + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + NameHint: "registry.npmjs.org", + Fields: map[sdk.FieldName]string{ + fieldname.Token: "npm_4F1h0xp0lBn0XvZ4RGfpnpoawECBMEXAMPLE", + fieldname.Host: "registry.npmjs.org", + fieldname.Organization: "", + }, + }, + }, + }, + }) +} diff --git a/plugins/npm/npm.go b/plugins/npm/npm.go new file mode 100644 index 000000000..d66f54cac --- /dev/null +++ b/plugins/npm/npm.go @@ -0,0 +1,45 @@ +package npm + +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 NPMCLI() schema.Executable { + return schema.Executable{ + Name: "NPM CLI", + Runs: []string{"npm"}, + DocsURL: sdk.URL("https://docs.npmjs.com/cli"), + NeedsAuth: needsauth.IfAll( + // not a complete list of commands that don't + // need auth, but probably the main ones + needsauth.NotForHelpOrVersion(), + needsauth.NotWhenContainsArgs("init"), + needsauth.NotWhenContainsArgs("?"), + needsauth.NotWhenContainsArgs("config"), + needsauth.NotWhenContainsArgs("help-search"), + needsauth.NotWhenContainsArgs("login"), + needsauth.NotWhenContainsArgs("logout"), + needsauth.NotWhenContainsArgs("prune"), + needsauth.NotWhenContainsArgs("shrinkwrap"), + needsauth.NotWhenContainsArgs("start"), + needsauth.NotWhenContainsArgs("run-script"), + needsauth.NotWhenContainsArgs("run"), + needsauth.NotWhenContainsArgs("rum"), + needsauth.NotWhenContainsArgs("urn"), + needsauth.NotWhenContainsArgs("uninstall"), + needsauth.NotWhenContainsArgs("unlink"), + needsauth.NotWhenContainsArgs("remove"), + needsauth.NotWhenContainsArgs("rm"), + needsauth.NotWhenContainsArgs("r"), + needsauth.NotWhenContainsArgs("un"), + ), + Uses: []schema.CredentialUsage{ + { + Name: credname.AccessToken, + }, + }, + } +} diff --git a/plugins/npm/plugin.go b/plugins/npm/plugin.go new file mode 100644 index 000000000..393162a90 --- /dev/null +++ b/plugins/npm/plugin.go @@ -0,0 +1,22 @@ +package npm + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/schema" +) + +func New() schema.Plugin { + return schema.Plugin{ + Name: "npm", + Platform: schema.PlatformInfo{ + Name: "NPM", + Homepage: sdk.URL("https://npmjs.com"), + }, + Credentials: []schema.CredentialType{ + AccessToken(), + }, + Executables: []schema.Executable{ + NPMCLI(), + }, + } +} diff --git a/plugins/npm/test-fixtures/.npmrc-custom-registry b/plugins/npm/test-fixtures/.npmrc-custom-registry new file mode 100644 index 000000000..7fd6cdab4 --- /dev/null +++ b/plugins/npm/test-fixtures/.npmrc-custom-registry @@ -0,0 +1 @@ +//my.registry.com/:_authToken=npm_4F1h0xp0lBn0XvZ4RGfpnpoawECBMEXAMPLE diff --git a/plugins/npm/test-fixtures/.npmrc-default-registry b/plugins/npm/test-fixtures/.npmrc-default-registry new file mode 100644 index 000000000..c9e04c5ab --- /dev/null +++ b/plugins/npm/test-fixtures/.npmrc-default-registry @@ -0,0 +1 @@ +//registry.npmjs.org/:_authToken=npm_4F1h0xp0lBn0XvZ4RGfpnpoawECBMEXAMPLE diff --git a/plugins/npm/test-fixtures/.npmrc-scoped b/plugins/npm/test-fixtures/.npmrc-scoped new file mode 100644 index 000000000..b78c49745 --- /dev/null +++ b/plugins/npm/test-fixtures/.npmrc-scoped @@ -0,0 +1 @@ +@op://my.registry.com/:_authToken=npm_4F1h0xp0lBn0XvZ4RGfpnpoawECBMEXAMPLE