Repository: oras-project/oras
Pull request #736 (2023-01-06 09:23:02)

refactor(oci-layout): use Parser to process option input 
This PR adds a new option parser which can be used to process input before cmd execution. This change will be used in OCI layout support.

codecov-commenter: # [Codecov](https://codecov.io/gh/oras-project/oras/pull/736?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project) Report
> Merging [#736](https://codecov.io/gh/oras-project/oras/pull/736?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project) (2f9aff4) into [main](https://codecov.io/gh/oras-project/oras/commit/165901193e64585543e34c80cdc3de1da21cc59b?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project) (1659011) will **decrease** coverage by `0.16%`.
> The diff coverage is `81.81%`.

```diff
@@            Coverage Diff             @@
##             main     #736      +/-   ##
==========================================
- Coverage   73.17%   73.00%   -0.17%     
==========================================
  Files          16       17       +1     
  Lines         574      589      +15     
==========================================
+ Hits          420      430      +10     
- Misses        123      127       +4     
- Partials       31       32       +1     
```


| [Impacted Files](https://codecov.io/gh/oras-project/oras/pull/736?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project) | Coverage Δ | |
|---|---|---|
| [cmd/oras/internal/option/remote.go](https://codecov.io/gh/oras-project/oras/pull/736?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project#diff-Y21kL29yYXMvaW50ZXJuYWwvb3B0aW9uL3JlbW90ZS5nbw==) | `67.46% <0.00%> (-0.83%)` | :arrow_down: |
| [cmd/oras/internal/option/parser.go](https://codecov.io/gh/oras-project/oras/pull/736?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project#diff-Y21kL29yYXMvaW50ZXJuYWwvb3B0aW9uL3BhcnNlci5nbw==) | `82.35% <82.35%> (ø)` | |
| [cmd/oras/internal/option/applier.go](https://codecov.io/gh/oras-project/oras/pull/736?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project#diff-Y21kL29yYXMvaW50ZXJuYWwvb3B0aW9uL2FwcGxpZXIuZ28=) | `100.00% <100.00%> (ø)` | |
| [cmd/oras/internal/option/platform.go](https://codecov.io/gh/oras-project/oras/pull/736?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project#diff-Y21kL29yYXMvaW50ZXJuYWwvb3B0aW9uL3BsYXRmb3JtLmdv) | `100.00% <100.00%> (ø)` | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project)


Patch:
diff --git a/cmd/oras/attach.go b/cmd/oras/attach.go
index c188a01..ed032ef 100644
--- a/cmd/oras/attach.go
+++ b/cmd/oras/attach.go
@@ -65,7 +65,7 @@ Example - Attach file 'hi.txt' and export the pushed manifest to 'manifest.json'
 `,
 		Args: cobra.MinimumNArgs(1),
 		PreRunE: func(cmd *cobra.Command, args []string) error {
-			return opts.ReadPassword()
+			return option.Parse(&opts)
 		},
 		RunE: func(cmd *cobra.Command, args []string) error {
 			opts.targetRef = args[0]
diff --git a/cmd/oras/blob/delete.go b/cmd/oras/blob/delete.go
index 4f0f641..88bf88c 100644
--- a/cmd/oras/blob/delete.go
+++ b/cmd/oras/blob/delete.go
@@ -59,7 +59,7 @@ Example - Delete a blob and print its descriptor:
 			if opts.OutputDescriptor && !opts.Confirmed {
 				return errors.New("must apply --force to confirm the deletion if the descriptor is outputted")
 			}
-			return opts.ReadPassword()
+			return option.Parse(&opts)
 		},
 		RunE: func(cmd *cobra.Command, args []string) error {
 			opts.targetRef = args[0]
diff --git a/cmd/oras/blob/fetch.go b/cmd/oras/blob/fetch.go
index 8e501c7..71dee5e 100644
--- a/cmd/oras/blob/fetch.go
+++ b/cmd/oras/blob/fetch.go
@@ -70,7 +70,7 @@ Example - Fetch the blob, save it to a local file and print the descriptor:
 				return errors.New("`--output -` cannot be used with `--descriptor` at the same time")
 			}
 
-			return opts.ReadPassword()
+			return option.Parse(&opts)
 		},
 		Aliases: []string{"get"},
 		RunE: func(cmd *cobra.Command, args []string) error {
diff --git a/cmd/oras/blob/push.go b/cmd/oras/blob/push.go
index f0f9537..0ae50e9 100644
--- a/cmd/oras/blob/push.go
+++ b/cmd/oras/blob/push.go
@@ -81,7 +81,7 @@ Example - Push blob without TLS:
 					return errors.New("`--size` must be provided if the blob is read from stdin")
 				}
 			}
-			return opts.ReadPassword()
+			return option.Parse(&opts)
 		},
 		RunE: func(cmd *cobra.Command, args []string) error {
 			return pushBlob(opts)
diff --git a/cmd/oras/cp.go b/cmd/oras/cp.go
index 59efbaa..7fc485d 100644
--- a/cmd/oras/cp.go
+++ b/cmd/oras/cp.go
@@ -68,6 +68,9 @@ Example - Copy the artifact tagged with 'v1' from repository 'localhost:5000/net
   oras cp --concurrency 6 localhost:5000/net-monitor:v1 localhost:5000/net-monitor-copy:v1,tag2,tag3
 `,
 		Args: cobra.ExactArgs(2),
+		PreRunE: func(cmd *cobra.Command, args []string) error {
+			return option.Parse(&opts)
+		},
 		RunE: func(cmd *cobra.Command, args []string) error {
 			opts.srcRef = args[0]
 			refs := strings.Split(args[1], ",")
@@ -88,10 +91,6 @@ Example - Copy the artifact tagged with 'v1' from repository 'localhost:5000/net
 
 func runCopy(opts copyOptions) error {
 	ctx, _ := opts.SetLoggerLevel()
-	targetPlatform, err := opts.Parse()
-	if err != nil {
-		return err
-	}
 
 	// Prepare source
 	src, err := opts.src.NewRepository(opts.srcRef, opts.Common)
@@ -145,8 +144,8 @@ func runCopy(opts copyOptions) error {
 			copyOptions := oras.CopyOptions{
 				CopyGraphOptions: extendedCopyOptions.CopyGraphOptions,
 			}
-			if targetPlatform != nil {
-				copyOptions.WithTargetPlatform(targetPlatform)
+			if opts.Platform.Platform != nil {
+				copyOptions.WithTargetPlatform(opts.Platform.Platform)
 			}
 			desc, err = oras.Copy(ctx, src, opts.srcRef, dst, opts.dstRef, copyOptions)
 		}
diff --git a/cmd/oras/discover.go b/cmd/oras/discover.go
index 5401520..3063c1a 100644
--- a/cmd/oras/discover.go
+++ b/cmd/oras/discover.go
@@ -64,7 +64,7 @@ Example - Discover referrers with type 'test-artifact' of manifest 'hello:latest
 `,
 		Args: cobra.ExactArgs(1),
 		PreRunE: func(cmd *cobra.Command, args []string) error {
-			return opts.ReadPassword()
+			return option.Parse(&opts)
 		},
 		RunE: func(cmd *cobra.Command, args []string) error {
 			opts.targetRef = args[0]
@@ -87,14 +87,10 @@ func runDiscover(opts discoverOptions) error {
 	if repo.Reference.Reference == "" {
 		return errors.NewErrInvalidReference(repo.Reference)
 	}
-	targetPlatform, err := opts.Parse()
-	if err != nil {
-		return err
-	}
 
 	// discover artifacts
 	resolveOpts := oras.DefaultResolveOptions
-	resolveOpts.TargetPlatform = targetPlatform
+	resolveOpts.TargetPlatform = opts.Platform.Platform
 	desc, err := oras.Resolve(ctx, repo, repo.Reference.Reference, resolveOpts)
 	if err != nil {
 		return err
diff --git a/cmd/oras/internal/option/applier.go b/cmd/oras/internal/option/applier.go
index 5803a5f..f47aba1 100644
--- a/cmd/oras/internal/option/applier.go
+++ b/cmd/oras/internal/option/applier.go
@@ -16,8 +16,6 @@ limitations under the License.
 package option
 
 import (
-	"reflect"
-
 	"github.com/spf13/pflag"
 )
 
@@ -31,14 +29,8 @@ type FlagApplier interface {
 // NOTE: The option argument need to be a pointer to the options, so its value
 // becomes addressable.
 func ApplyFlags(optsPtr interface{}, target *pflag.FlagSet) {
-	v := reflect.ValueOf(optsPtr).Elem()
-	for i := 0; i < v.NumField(); i++ {
-		f := v.Field(i)
-		if f.CanSet() {
-			iface := f.Addr().Interface()
-			if a, ok := iface.(FlagApplier); ok {
-				a.ApplyFlags(target)
-			}
-		}
-	}
+	rangeFields(optsPtr, func(fa FlagApplier) error {
+		fa.ApplyFlags(target)
+		return nil
+	})
 }
diff --git a/cmd/oras/internal/option/parser.go b/cmd/oras/internal/option/parser.go
new file mode 100644
index 0000000..43d85dd
--- /dev/null
+++ b/cmd/oras/internal/option/parser.go
@@ -0,0 +1,51 @@
+/*
+Copyright The ORAS Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package option
+
+import (
+	"reflect"
+)
+
+// FlagParser parses flags in an option.
+type FlagParser interface {
+	Parse() error
+}
+
+// Parse parses applicable fields of the passed-in option pointer and returns
+// error during parsing.
+func Parse(optsPtr interface{}) error {
+	return rangeFields(optsPtr, func(fp FlagParser) error {
+		return fp.Parse()
+	})
+}
+
+// rangeFields goes through all fields of ptr, optionally run fn if a field is
+// public AND typed T.
+func rangeFields[T any](ptr any, fn func(T) error) error {
+	v := reflect.ValueOf(ptr).Elem()
+	for i := 0; i < v.NumField(); i++ {
+		f := v.Field(i)
+		if f.CanSet() {
+			iface := f.Addr().Interface()
+			if opts, ok := iface.(T); ok {
+				if err := fn(opts); err != nil {
+					return err
+				}
+			}
+		}
+	}
+	return nil
+}
diff --git a/cmd/oras/internal/option/platform.go b/cmd/oras/internal/option/platform.go
index 6473648..fa0fdf3 100644
--- a/cmd/oras/internal/option/platform.go
+++ b/cmd/oras/internal/option/platform.go
@@ -26,25 +26,26 @@ import (
 
 // Platform option struct.
 type Platform struct {
-	Platform string
+	platform string
+	Platform *ocispec.Platform
 }
 
 // ApplyFlags applies flags to a command flag set.
 func (opts *Platform) ApplyFlags(fs *pflag.FlagSet) {
-	fs.StringVarP(&opts.Platform, "platform", "", "", "request platform in the form of `os[/arch][/variant][:os_version]`")
+	fs.StringVarP(&opts.platform, "platform", "", "", "request platform in the form of `os[/arch][/variant][:os_version]`")
 }
 
 // parse parses the input platform flag to an oci platform type.
-func (opts *Platform) Parse() (*ocispec.Platform, error) {
-	if opts.Platform == "" {
-		return nil, nil
+func (opts *Platform) Parse() error {
+	if opts.platform == "" {
+		return nil
 	}
 
 	// OS[/Arch[/Variant]][:OSVersion]
 	// If Arch is not provided, will use GOARCH instead
 	var platformStr string
 	var p ocispec.Platform
-	platformStr, p.OSVersion, _ = strings.Cut(opts.Platform, ":")
+	platformStr, p.OSVersion, _ = strings.Cut(opts.platform, ":")
 	parts := strings.Split(platformStr, "/")
 	switch len(parts) {
 	case 3:
@@ -55,14 +56,15 @@ func (opts *Platform) Parse() (*ocispec.Platform, error) {
 	case 1:
 		p.Architecture = runtime.GOARCH
 	default:
-		return nil, fmt.Errorf("failed to parse platform %q: expected format os[/arch[/variant]]", opts.Platform)
+		return fmt.Errorf("failed to parse platform %q: expected format os[/arch[/variant]]", opts.platform)
 	}
 	p.OS = parts[0]
 	if p.OS == "" {
-		return nil, fmt.Errorf("invalid platform: OS cannot be empty")
+		return fmt.Errorf("invalid platform: OS cannot be empty")
 	}
 	if p.Architecture == "" {
-		return nil, fmt.Errorf("invalid platform: Architecture cannot be empty")
+		return fmt.Errorf("invalid platform: Architecture cannot be empty")
 	}
-	return &p, nil
+	opts.Platform = &p
+	return nil
 }
diff --git a/cmd/oras/internal/option/remote.go b/cmd/oras/internal/option/remote.go
index 118d755..0391cb3 100644
--- a/cmd/oras/internal/option/remote.go
+++ b/cmd/oras/internal/option/remote.go
@@ -87,8 +87,13 @@ func (opts *Remote) ApplyFlagsWithPrefix(fs *pflag.FlagSet, prefix, description
 	}
 }
 
-// ReadPassword tries to read password with optional cmd prompt.
-func (opts *Remote) ReadPassword() (err error) {
+// Parse tries to read password with optional cmd prompt.
+func (opts *Remote) Parse() error {
+	return opts.readPassword()
+}
+
+// readPassword tries to read password with optional cmd prompt.
+func (opts *Remote) readPassword() (err error) {
 	if opts.Password != "" {
 		fmt.Fprintln(os.Stderr, "WARNING! Using --password via the CLI is insecure. Use --password-stdin.")
 	} else if opts.PasswordFromStdin {
diff --git a/cmd/oras/login.go b/cmd/oras/login.go
index e896d22..b102444 100644
--- a/cmd/oras/login.go
+++ b/cmd/oras/login.go
@@ -61,7 +61,7 @@ Example - Log in with username and password in an interactive terminal and no TL
 `,
 		Args: cobra.ExactArgs(1),
 		PreRunE: func(cmd *cobra.Command, args []string) error {
-			return opts.ReadPassword()
+			return option.Parse(&opts)
 		},
 		RunE: func(cmd *cobra.Command, args []string) error {
 			opts.Hostname = args[0]
@@ -130,9 +130,9 @@ func runLogin(opts loginOptions) (err error) {
 	return nil
 }
 
-func readLine(prompt string, slient bool) (string, error) {
+func readLine(prompt string, silent bool) (string, error) {
 	fmt.Print(prompt)
-	if slient {
+	if silent {
 		fd := os.Stdin.Fd()
 		state, err := term.SaveState(fd)
 		if err != nil {
@@ -147,7 +147,7 @@ func readLine(prompt string, slient bool) (string, error) {
 	if err != nil {
 		return "", err
 	}
-	if slient {
+	if silent {
 		fmt.Println()
 	}
 
diff --git a/cmd/oras/manifest/delete.go b/cmd/oras/manifest/delete.go
index 568eefd..09751eb 100644
--- a/cmd/oras/manifest/delete.go
+++ b/cmd/oras/manifest/delete.go
@@ -63,7 +63,7 @@ Example - Delete a manifest by digest 'sha256:99e4703fbf30916f549cd6bfa9cdbab614
 			if opts.OutputDescriptor && !opts.Confirmed {
 				return errors.New("must apply --force to confirm the deletion if the descriptor is outputted")
 			}
-			return opts.ReadPassword()
+			return option.Parse(&opts)
 		},
 		RunE: func(_ *cobra.Command, args []string) error {
 			opts.targetRef = args[0]
diff --git a/cmd/oras/manifest/fetch.go b/cmd/oras/manifest/fetch.go
index 7173a5c..5715141 100644
--- a/cmd/oras/manifest/fetch.go
+++ b/cmd/oras/manifest/fetch.go
@@ -69,8 +69,7 @@ Example - Fetch manifest with prettified json result:
 			if opts.outputPath == "-" && opts.OutputDescriptor {
 				return errors.New("`--output -` cannot be used with `--descriptor` at the same time")
 			}
-
-			return opts.ReadPassword()
+			return option.Parse(&opts)
 		},
 		Aliases: []string{"get"},
 		RunE: func(cmd *cobra.Command, args []string) error {
@@ -98,11 +97,6 @@ func fetchManifest(opts fetchOptions) (fetchErr error) {
 	}
 	repo.ManifestMediaTypes = opts.mediaTypes
 
-	targetPlatform, err := opts.Parse()
-	if err != nil {
-		return err
-	}
-
 	src, err := opts.CachedTarget(repo)
 	if err != nil {
 		return err
@@ -112,7 +106,7 @@ func fetchManifest(opts fetchOptions) (fetchErr error) {
 	if opts.OutputDescriptor && opts.outputPath == "" {
 		// fetch manifest descriptor only
 		fetchOpts := oras.DefaultResolveOptions
-		fetchOpts.TargetPlatform = targetPlatform
+		fetchOpts.TargetPlatform = opts.Platform.Platform
 		desc, err = oras.Resolve(ctx, src, opts.targetRef, fetchOpts)
 		if err != nil {
 			return err
@@ -121,7 +115,7 @@ func fetchManifest(opts fetchOptions) (fetchErr error) {
 		// fetch manifest content
 		var content []byte
 		fetchOpts := oras.DefaultFetchBytesOptions
-		fetchOpts.TargetPlatform = targetPlatform
+		fetchOpts.TargetPlatform = opts.Platform.Platform
 		desc, content, err = oras.FetchBytes(ctx, src, opts.targetRef, fetchOpts)
 		if err != nil {
 			return err
diff --git a/cmd/oras/manifest/fetch_config.go b/cmd/oras/manifest/fetch_config.go
index d57fc62..542fa81 100644
--- a/cmd/oras/manifest/fetch_config.go
+++ b/cmd/oras/manifest/fetch_config.go
@@ -76,8 +76,7 @@ Example - Fetch and print the prettified descriptor of the config:
 			if opts.outputPath == "-" && opts.OutputDescriptor {
 				return errors.New("`--output -` cannot be used with `--descriptor` at the same time")
 			}
-
-			return opts.ReadPassword()
+			return option.Parse(&opts)
 		},
 		RunE: func(cmd *cobra.Command, args []string) error {
 			opts.targetRef = args[0]
@@ -102,18 +101,13 @@ func fetchConfig(opts fetchConfigOptions) (fetchErr error) {
 		return oerrors.NewErrInvalidReference(repo.Reference)
 	}
 
-	targetPlatform, err := opts.Parse()
-	if err != nil {
-		return err
-	}
-
 	src, err := opts.CachedTarget(repo)
 	if err != nil {
 		return err
 	}
 
 	// fetch config descriptor
-	configDesc, err := fetchConfigDesc(ctx, src, opts.targetRef, targetPlatform)
+	configDesc, err := fetchConfigDesc(ctx, src, opts.targetRef, opts.Platform.Platform)
 	if err != nil {
 		return err
 	}
diff --git a/cmd/oras/manifest/push.go b/cmd/oras/manifest/push.go
index 6249cef..0c172ad 100644
--- a/cmd/oras/manifest/push.go
+++ b/cmd/oras/manifest/push.go
@@ -81,7 +81,7 @@ Example - Push a manifest to repository 'locahost:5000/hello' and tag with 'tag1
 			if opts.fileRef == "-" && opts.PasswordFromStdin {
 				return errors.New("`-` read file from input and `--password-stdin` read password from input cannot be both used")
 			}
-			return opts.ReadPassword()
+			return option.Parse(&opts)
 		},
 		RunE: func(_ *cobra.Command, args []string) error {
 			refs := strings.Split(args[0], ",")
diff --git a/cmd/oras/pull.go b/cmd/oras/pull.go
index c57cc6e..0288b28 100644
--- a/cmd/oras/pull.go
+++ b/cmd/oras/pull.go
@@ -79,7 +79,7 @@ Example - Pull all files with concurrency level tuned:
 `,
 		Args: cobra.ExactArgs(1),
 		PreRunE: func(cmd *cobra.Command, args []string) error {
-			return opts.ReadPassword()
+			return option.Parse(&opts)
 		},
 		RunE: func(cmd *cobra.Command, args []string) error {
 			opts.targetRef = args[0]
@@ -99,10 +99,6 @@ Example - Pull all files with concurrency level tuned:
 
 func runPull(opts pullOptions) error {
 	var printed sync.Map
-	targetPlatform, err := opts.Parse()
-	if err != nil {
-		return err
-	}
 	repo, err := opts.NewRepository(opts.targetRef, opts.Common)
 	if err != nil {
 		return err
@@ -125,8 +121,8 @@ func runPull(opts pullOptions) error {
 			return err
 		}
 	}
-	if targetPlatform != nil {
-		copyOptions.WithTargetPlatform(targetPlatform)
+	if opts.Platform.Platform != nil {
+		copyOptions.WithTargetPlatform(opts.Platform.Platform)
 	}
 	var getConfigOnce sync.Once
 	copyOptions.FindSuccessors = func(ctx context.Context, fetcher content.Fetcher, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
diff --git a/cmd/oras/push.go b/cmd/oras/push.go
index e43c28d..7a9f15a 100644
--- a/cmd/oras/push.go
+++ b/cmd/oras/push.go
@@ -96,7 +96,7 @@ Example - Push file "hi.txt" with multiple tags and concurrency level tuned:
 			if opts.artifactType != "" && opts.manifestConfigRef != "" {
 				return errors.New("--artifact-type and --config cannot both be provided")
 			}
-			return opts.ReadPassword()
+			return option.Parse(&opts)
 		},
 		RunE: func(cmd *cobra.Command, args []string) error {
 			refs := strings.Split(args[0], ",")
diff --git a/cmd/oras/repository/ls.go b/cmd/oras/repository/ls.go
index 40963b4..4355ba3 100644
--- a/cmd/oras/repository/ls.go
+++ b/cmd/oras/repository/ls.go
@@ -47,7 +47,7 @@ Example - List the repositories under the registry that include values lexically
 		Args:    cobra.ExactArgs(1),
 		Aliases: []string{"list"},
 		PreRunE: func(cmd *cobra.Command, args []string) error {
-			return opts.ReadPassword()
+			return option.Parse(&opts)
 		},
 		RunE: func(cmd *cobra.Command, args []string) error {
 			opts.hostname = args[0]
diff --git a/cmd/oras/repository/tags.go b/cmd/oras/repository/tags.go
index abc6cc2..4cbf728 100644
--- a/cmd/oras/repository/tags.go
+++ b/cmd/oras/repository/tags.go
@@ -53,7 +53,7 @@ Example - Show tags of the target repository that include values lexically after
 		Args:    cobra.ExactArgs(1),
 		Aliases: []string{"show-tags"},
 		PreRunE: func(cmd *cobra.Command, args []string) error {
-			return opts.ReadPassword()
+			return option.Parse(&opts)
 		},
 		RunE: func(cmd *cobra.Command, args []string) error {
 			opts.targetRef = args[0]
diff --git a/cmd/oras/tag/tag.go b/cmd/oras/tag/tag.go
index c4fa8a3..bcad5f7 100644
--- a/cmd/oras/tag/tag.go
+++ b/cmd/oras/tag/tag.go
@@ -55,7 +55,7 @@ Example - Tag the manifest 'v1.0.1' in 'localhost:5000/hello' to 'v1.0.1', 'v1.0
 `,
 		Args: cobra.MinimumNArgs(2),
 		PreRunE: func(cmd *cobra.Command, args []string) error {
-			return opts.ReadPassword()
+			return option.Parse(&opts)
 		},
 		RunE: func(_ *cobra.Command, args []string) error {
 			opts.srcRef = args[0]


Test patch:
diff --git a/cmd/oras/internal/option/applier_test.go b/cmd/oras/internal/option/applier_test.go
new file mode 100644
index 0000000..322287f
--- /dev/null
+++ b/cmd/oras/internal/option/applier_test.go
@@ -0,0 +1,49 @@
+/*
+Copyright The ORAS Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package option_test
+
+import (
+	"testing"
+
+	"github.com/spf13/pflag"
+	"oras.land/oras/cmd/oras/internal/option"
+)
+
+func (t *Test) ApplyFlags(fs *pflag.FlagSet) {
+	*t.CntPtr += 1
+}
+
+func TestApplyFlags(t *testing.T) {
+	cnt := 0
+	type args struct {
+		Test
+	}
+	tests := []struct {
+		name    string
+		args    args
+		wantErr bool
+	}{
+		{"flags should be applied once", args{Test{CntPtr: &cnt}}, false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			option.ApplyFlags(&tt.args, nil)
+			if cnt != 1 {
+				t.Errorf("Expect ApplyFlags() to be called once but got %v", cnt)
+			}
+		})
+	}
+}
diff --git a/cmd/oras/internal/option/parser_test.go b/cmd/oras/internal/option/parser_test.go
new file mode 100644
index 0000000..f54a232
--- /dev/null
+++ b/cmd/oras/internal/option/parser_test.go
@@ -0,0 +1,88 @@
+/*
+Copyright The ORAS Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package option_test
+
+import (
+	"errors"
+	"testing"
+
+	"oras.land/oras/cmd/oras/internal/option"
+)
+
+type Test struct {
+	CntPtr *int
+}
+
+func (t *Test) Parse() error {
+	*t.CntPtr += 1
+	if *t.CntPtr == 2 {
+		return errors.New("should not be tried twice")
+	}
+	return nil
+}
+
+func TestParse_once(t *testing.T) {
+	cnt := 0
+	type args struct {
+		Test
+	}
+	tests := []struct {
+		name    string
+		args    args
+		wantErr bool
+	}{
+		{"parse should be called once", args{Test{CntPtr: &cnt}}, false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if err := option.Parse(&tt.args); (err != nil) != tt.wantErr {
+				t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr)
+			}
+
+			if cnt != 1 {
+				t.Errorf("Expect Parse() to be called once but got %v", cnt)
+			}
+		})
+	}
+}
+
+func TestParse_err(t *testing.T) {
+	cnt := 0
+	type args struct {
+		Test1 Test
+		Test2 Test
+		Test3 Test
+		Test4 Test
+	}
+	tests := []struct {
+		name    string
+		args    args
+		wantErr bool
+	}{
+		{"parse should be called twice and aborted with error", args{Test{CntPtr: &cnt}, Test{CntPtr: &cnt}, Test{CntPtr: &cnt}, Test{CntPtr: &cnt}}, true},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if err := option.Parse(&tt.args); (err != nil) != tt.wantErr {
+				t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr)
+			}
+
+			if cnt != 2 {
+				t.Errorf("Expect Parse() to be called twice but got %v", cnt)
+			}
+		})
+	}
+}
diff --git a/cmd/oras/internal/option/platform_test.go b/cmd/oras/internal/option/platform_test.go
index 2469976..8864bbe 100644
--- a/cmd/oras/internal/option/platform_test.go
+++ b/cmd/oras/internal/option/platform_test.go
@@ -3,9 +3,7 @@ Copyright The ORAS Authors.
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
-
 http://www.apache.org/licenses/LICENSE-2.0
-
 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@@ -27,8 +25,8 @@ import (
 func TestPlatform_ApplyFlags(t *testing.T) {
 	var test struct{ Platform }
 	ApplyFlags(&test, pflag.NewFlagSet("oras-test", pflag.ExitOnError))
-	if test.Platform.Platform != "" {
-		t.Fatalf("expecting platform to be empty but got: %v", test.Platform.Platform)
+	if test.Platform.platform != "" {
+		t.Fatalf("expecting platform to be empty but got: %v", test.Platform.platform)
 	}
 }
 
@@ -37,15 +35,15 @@ func TestPlatform_Parse_err(t *testing.T) {
 		name string
 		opts *Platform
 	}{
-		{name: "empty arch 1", opts: &Platform{"os/"}},
-		{name: "empty arch 2", opts: &Platform{"os//variant"}},
-		{name: "empty os", opts: &Platform{"/arch"}},
-		{name: "empty os with variant", opts: &Platform{"/arch/variant"}},
-		{name: "trailing slash", opts: &Platform{"os/arch/variant/llama"}},
+		{name: "empty arch 1", opts: &Platform{"os/", nil}},
+		{name: "empty arch 2", opts: &Platform{"os//variant", nil}},
+		{name: "empty os", opts: &Platform{"/arch", nil}},
+		{name: "empty os with variant", opts: &Platform{"/arch/variant", nil}},
+		{name: "trailing slash", opts: &Platform{"os/arch/variant/llama", nil}},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			_, err := tt.opts.Parse()
+			err := tt.opts.Parse()
 			if err == nil {
 				t.Errorf("Platform.Parse() error = %v, wantErr %v", err, true)
 				return
@@ -60,21 +58,20 @@ func TestPlatform_Parse(t *testing.T) {
 		opts *Platform
 		want *ocispec.Platform
 	}{
-		{name: "empty", opts: &Platform{""}, want: nil},
-		{name: "default arch", opts: &Platform{"os"}, want: &ocispec.Platform{OS: "os", Architecture: runtime.GOARCH}},
-		{name: "os&arch", opts: &Platform{"os/aRcH"}, want: &ocispec.Platform{OS: "os", Architecture: "aRcH"}},
-		{name: "empty variant", opts: &Platform{"os/aRcH/"}, want: &ocispec.Platform{OS: "os", Architecture: "aRcH", Variant: ""}},
-		{name: "os&arch&variant", opts: &Platform{"os/aRcH/vAriAnt"}, want: &ocispec.Platform{OS: "os", Architecture: "aRcH", Variant: "vAriAnt"}},
-		{name: "os version", opts: &Platform{"os/aRcH/vAriAnt:osversion"}, want: &ocispec.Platform{OS: "os", Architecture: "aRcH", Variant: "vAriAnt", OSVersion: "osversion"}},
-		{name: "long os version", opts: &Platform{"os/aRcH"}, want: &ocispec.Platform{OS: "os", Architecture: "aRcH"}},
+		{name: "empty", opts: &Platform{platform: ""}, want: nil},
+		{name: "default arch", opts: &Platform{platform: "os"}, want: &ocispec.Platform{OS: "os", Architecture: runtime.GOARCH}},
+		{name: "os&arch", opts: &Platform{platform: "os/aRcH"}, want: &ocispec.Platform{OS: "os", Architecture: "aRcH"}},
+		{name: "empty variant", opts: &Platform{platform: "os/aRcH/"}, want: &ocispec.Platform{OS: "os", Architecture: "aRcH", Variant: ""}},
+		{name: "os&arch&variant", opts: &Platform{platform: "os/aRcH/vAriAnt"}, want: &ocispec.Platform{OS: "os", Architecture: "aRcH", Variant: "vAriAnt"}},
+		{name: "os version", opts: &Platform{platform: "os/aRcH/vAriAnt:osversion"}, want: &ocispec.Platform{OS: "os", Architecture: "aRcH", Variant: "vAriAnt", OSVersion: "osversion"}},
+		{name: "long os version", opts: &Platform{platform: "os/aRcH"}, want: &ocispec.Platform{OS: "os", Architecture: "aRcH"}},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			got, err := tt.opts.Parse()
-			if err != nil {
+			if err := tt.opts.Parse(); err != nil {
 				t.Errorf("Platform.Parse() error = %v", err)
-				return
 			}
+			got := tt.opts.Platform
 			if !reflect.DeepEqual(got, tt.want) {
 				t.Errorf("Platform.Parse() = %v, want %v", got, tt.want)
 			}