Repository: getporter/porter
Pull request #1542 (2021-04-12 20:08:42)

Allow overriding home dir and download url during install
# What does this change
When installing, let the user override with PORTER_HOME and PORTER_MIRROR the location where porter should be installed, or the location from which it should be downloaded.

I have updated the porter CLI to understand the new configuration value PORTER_MIRROR, which and also be specified as a flag or config file setting.

This lets us test out alternate download infra from end-to-end, and also allows a user to set up a mirror of Porter's assets.

I am also getting rid of the troubleshooting I had added for Azure's storage and CDN now that we aren't using it anymore (e.g. dynamically generated PORTER_TRACE user agents).

# What issue does it fix
Fixes https://groups.io/g/porter/message/30

# Notes for the reviewer
N/A

# Checklist
- [x] Unit Tests
- [x] Documentation
- [ ] Schema (porter.yaml)



Patch:
diff --git a/cmd/porter/mixins.go b/cmd/porter/mixins.go
index bde12308..20fcb866 100644
--- a/cmd/porter/mixins.go
+++ b/cmd/porter/mixins.go
@@ -1,8 +1,6 @@
 package main
 
 import (
-	"fmt"
-
 	"get.porter.sh/porter/pkg/mixin"
 	"get.porter.sh/porter/pkg/pkgmgmt"
 	"get.porter.sh/porter/pkg/pkgmgmt/feed"
@@ -57,7 +55,9 @@ func buildMixinsSearchCommand(p *porter.Porter) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "search [QUERY]",
 		Short: "Search available mixins",
-		Long:  "Search available mixins. You can specify an optional mixin name query, where the results are filtered by mixins whose name contains the query term.",
+		Long: `Search available mixins. You can specify an optional mixin name query, where the results are filtered by mixins whose name contains the query term.
+
+By default the community mixin index at https://cdn.porter.sh/mixins/index.json is searched. To search from a mirror, set the environment variable PORTER_MIRROR, or mirror in the Porter config file, with the value to replace https://cdn.porter.sh with.`,
 		Example: `  porter mixin search
   porter mixin search helm
   porter mixin search -o json`,
@@ -69,8 +69,11 @@ func buildMixinsSearchCommand(p *porter.Porter) *cobra.Command {
 		},
 	}
 
-	cmd.Flags().StringVarP(&opts.RawFormat, "output", "o", "table",
+	flags := cmd.Flags()
+	flags.StringVarP(&opts.RawFormat, "output", "o", "table",
 		"Output format, allowed values are: table, json, yaml")
+	flags.StringVar(&opts.Mirror, "mirror", pkgmgmt.DefaultPackageMirror,
+		"Mirror of official Porter assets")
 
 	return cmd
 }
@@ -80,6 +83,9 @@ func BuildMixinInstallCommand(p *porter.Porter) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "install NAME",
 		Short: "Install a mixin",
+		Long: `Install a mixin.
+
+By default mixins are downloaded from the official Porter mixin feed at https://cdn.porter.sh/mixins/atom.xml. To download from a mirror, set the environment variable PORTER_MIRROR, or mirror in the Porter config file, with the value to replace https://cdn.porter.sh with.`,
 		Example: `  porter mixin install helm --url https://cdn.porter.sh/mixins/helm
   porter mixin install helm --feed-url https://cdn.porter.sh/mixins/atom.xml
   porter mixin install azure --version v0.4.0-ralpha.1+dubonnet --url https://cdn.porter.sh/mixins/azure
@@ -92,12 +98,15 @@ func BuildMixinInstallCommand(p *porter.Porter) *cobra.Command {
 		},
 	}
 
-	cmd.Flags().StringVarP(&opts.Version, "version", "v", "latest",
+	flags := cmd.Flags()
+	flags.StringVarP(&opts.Version, "version", "v", "latest",
 		"The mixin version. This can either be a version number, or a tagged release like 'latest' or 'canary'")
-	cmd.Flags().StringVar(&opts.URL, "url", "",
+	flags.StringVar(&opts.URL, "url", "",
 		"URL from where the mixin can be downloaded, for example https://github.com/org/proj/releases/downloads")
-	cmd.Flags().StringVar(&opts.FeedURL, "feed-url", "",
-		fmt.Sprintf(`URL of an atom feed where the mixin can be downloaded (default %s)`, mixin.DefaultFeedUrl))
+	flags.StringVar(&opts.FeedURL, "feed-url", "",
+		"URL of an atom feed where the mixin can be downloaded. Defaults to the official Porter mixin feed.")
+	flags.StringVar(&opts.Mirror, "mirror", pkgmgmt.DefaultPackageMirror,
+		"Mirror of official Porter assets")
 	return cmd
 }
 
diff --git a/cmd/porter/plugins.go b/cmd/porter/plugins.go
index b292769b..2f23105e 100644
--- a/cmd/porter/plugins.go
+++ b/cmd/porter/plugins.go
@@ -1,8 +1,6 @@
 package main
 
 import (
-	"fmt"
-
 	"get.porter.sh/porter/pkg/pkgmgmt"
 	"get.porter.sh/porter/pkg/plugins"
 	"get.porter.sh/porter/pkg/porter"
@@ -57,7 +55,9 @@ func buildPluginSearchCommand(p *porter.Porter) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "search [QUERY]",
 		Short: "Search available plugins",
-		Long:  "Search available plugins. You can specify an optional plugin name query, where the results are filtered by plugins whose name contains the query term.",
+		Long: `Search available plugins. You can specify an optional plugin name query, where the results are filtered by plugins whose name contains the query term.
+
+By default the community plugin index at https://cdn.porter.sh/plugins/index.json is searched. To search from a mirror, set the environment variable PORTER_MIRROR, or mirror in the Porter config file, with the value to replace https://cdn.porter.sh with.`,
 		Example: `  porter plugin search
   porter plugin search azure
   porter plugin search -o json`,
@@ -69,8 +69,11 @@ func buildPluginSearchCommand(p *porter.Porter) *cobra.Command {
 		},
 	}
 
-	cmd.Flags().StringVarP(&opts.RawFormat, "output", "o", "table",
+	flags := cmd.Flags()
+	flags.StringVarP(&opts.RawFormat, "output", "o", "table",
 		"Output format, allowed values are: table, json, yaml")
+	flags.StringVar(&opts.Mirror, "mirror", pkgmgmt.DefaultPackageMirror,
+		"Mirror of official Porter assets")
 
 	return cmd
 }
@@ -100,6 +103,9 @@ func BuildPluginInstallCommand(p *porter.Porter) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "install NAME",
 		Short: "Install a plugin",
+		Long: `Install a plugin.
+
+By default plugins are downloaded from the official Porter plugin feed at https://cdn.porter.sh/plugins/atom.xml. To download from a mirror, set the environment variable PORTER_MIRROR, or mirror in the Porter config file, with the value to replace https://cdn.porter.sh with.`,
 		Example: `  porter plugin install azure  
   porter plugin install azure --url https://cdn.porter.sh/plugins/azure
   porter plugin install azure --feed-url https://cdn.porter.sh/plugins/atom.xml
@@ -113,12 +119,16 @@ func BuildPluginInstallCommand(p *porter.Porter) *cobra.Command {
 		},
 	}
 
-	cmd.Flags().StringVarP(&opts.Version, "version", "v", "latest",
+	flags := cmd.Flags()
+	flags.StringVarP(&opts.Version, "version", "v", "latest",
 		"The plugin version. This can either be a version number, or a tagged release like 'latest' or 'canary'")
-	cmd.Flags().StringVar(&opts.URL, "url", "",
+	flags.StringVar(&opts.URL, "url", "",
 		"URL from where the plugin can be downloaded, for example https://github.com/org/proj/releases/downloads")
-	cmd.Flags().StringVar(&opts.FeedURL, "feed-url", "",
-		fmt.Sprintf(`URL of an atom feed where the plugin can be downloaded (default %s)`, plugins.DefaultFeedUrl))
+	flags.StringVar(&opts.FeedURL, "feed-url", "",
+		"URL of an atom feed where the plugin can be downloaded. Defaults to the official Porter plugin feed.")
+	flags.StringVar(&opts.Mirror, "mirror", pkgmgmt.DefaultPackageMirror,
+		"Mirror of official Porter assets")
+
 	return cmd
 }
 
diff --git a/docs/content/compatible-registries.md b/docs/content/compatible-registries.md
index f62ff1fd..15d3fc51 100644
--- a/docs/content/compatible-registries.md
+++ b/docs/content/compatible-registries.md
@@ -18,7 +18,7 @@ out a particular registry and know that it will work for you.
 | Artifactory | No |
 | **Docker Hub** | **Yes** |
 | **DigitalOcean Container Registry** | **Yes** |
-| Amazon Elastic Container Registry (ECR) | No |
+| **Amazon Elastic Container Registry (ECR)** | **[Yes*](#notes)** |
 | **Google Artifact Registry** | **Yes** | 
 | Google Cloud Registry (GCR) | No | 
 | **GitHub Container Registry (GHCR)** | **Yes** | 
@@ -27,7 +27,12 @@ out a particular registry and know that it will work for you.
 | Nexus | No |
 | Quay | No |
 
-If you are a registry or user and know that this page is out of date, [please
-let us know!](https://github.com/deislabs/porter/issues/new)
+If you test a registry with Porter and find that this page is out of date, [please
+let us know](https://github.com/deislabs/porter/issues/new)!
 
-[cnab-to-oci]: https://github.com/docker/cnab-to-oci
+### Notes
+
+* Amazon Elastic Container Registry (ECR) requires that you create the repository for the installer and the bundle before publishing.
+
+[cnab-to-oci]: https://github.com/cnabio/cnab-to-oci
+[oci-spec]: https://github.com/opencontainers/distribution-spec/blob/master/spec.md
diff --git a/docs/content/install.md b/docs/content/install.md
index 09bb3ce6..50b75f68 100644
--- a/docs/content/install.md
+++ b/docs/content/install.md
@@ -13,9 +13,11 @@ We have a few release types available for you to use:
 * [Older Version](#older-version)
 
 You can also install and manage [mixins](#mixins) and [plugins](#plugins) using
-porter, and use the [Porter VS Code Extension][vscode-ext] for help authoring
+porter, and use the [Porter VS Code Extension][vscode-ext] to help author
 bundles.
 
+All the scripts for Porter v0.37.3+ support [customizing the installation through parameters](#install-script-parameters).
+
 [vscode-ext]: https://marketplace.visualstudio.com/items?itemName=ms-kubernetes-tools.porter-vscode
 [ps-link]: https://www.howtogeek.com/126469/how-to-create-a-powershell-profile/
 [mailing list]: https://groups.io/g/porter
@@ -129,3 +131,64 @@ All of the Porter-authored plugins are published to `https://cdn.porter.sh/plugi
 
 [releases]: https://github.com/getporter/porter/releases
 
+
+
+# Install Script Parameters
+
+The installation scripts provide the following parameters. Parameters can be specified with environment variables for the macOS and Linux scripts, and on Windows they are named parameters in the script.
+
+## PORTER_HOME
+
+Location where Porter is installed (defaults to ~/.porter).
+
+**Posix Shells**
+```bash
+PORTER_HOME=/alt/porter/home curl -L REPLACE_WITH_INSTALL_URL | bash
+```
+
+**PowerShell**
+```powershell
+iwr REPLACE_WITH_INSTALL_URL -OutFile install-porter.ps1 -UseBasicParsing
+.\install-porter.ps1 -PORTER_HOME C:\alt\porter\home
+```
+
+## PORTER_MIRROR
+
+Base URL where Porter assets, such as binaries and atom feeds, are downloaded.
+This lets you set up an internal mirror. Note that atom feeds and index files
+should be updated in the mirror to point to the mirrored location. Porter does
+not alter the contents of these files.
+
+**Posix Shells**
+```bash
+PORTER_MIRROR=https://example.com/porter curl -L REPLACE_WITH_INSTALL_URL | bash
+```
+
+**PowerShell**
+```powershell
+iwr REPLACE_WITH_INSTALL_URL -OutFile install-porter.ps1 -UseBasicParsing
+.\install-porter.ps1 -PORTER_MIRROR https://example.com/porter
+```
+
+### URL Structure
+
+Configuring a mirror of Porter's assets is out of scope of this document.
+Reach out on the Porter [mailing list] for assistance.
+
+Below is the general structure for Porter's asset URLs:
+
+```
+PERMALINK/
+  - install-linux.sh
+  - install-mac.sh
+  - install-windows.ps1
+  - porter-GOOS-GOARCH[FILE_EXT]
+mixins/
+  - atom.xml
+  - index.json
+  - MIXIN/PERMALINK/MIXIN-GOOS-GOARCH[FILE_EXT]
+plugins/
+  - atom.xml
+  - index.json
+  - PLUGIN/PERMALINK/PLUGIN-GOOS-GOARCH[FILE_EXT]
+```
diff --git a/examples/azure-terraform/porter.yaml b/examples/azure-terraform/porter.yaml
index 715751c9..75f3ae27 100644
--- a/examples/azure-terraform/porter.yaml
+++ b/examples/azure-terraform/porter.yaml
@@ -27,7 +27,7 @@ credentials:
   - name: client_secret
     env: AZURE_CLIENT_SECRET
 
-## This section defines what paramters are used by the bundle. These parameters are used by various 
+## This section defines what parameters are used by the bundle. These parameters are used by various 
 ## steps within the bundle
 parameters:
   - name: location
diff --git a/pkg/mixin/install.go b/pkg/mixin/install.go
index 188eb464..34dae3a9 100644
--- a/pkg/mixin/install.go
+++ b/pkg/mixin/install.go
@@ -4,13 +4,11 @@ import (
 	"get.porter.sh/porter/pkg/pkgmgmt"
 )
 
-const DefaultFeedUrl = "https://cdn.porter.sh/mixins/atom.xml"
-
 type InstallOptions struct {
 	pkgmgmt.InstallOptions
 }
 
 func (o *InstallOptions) Validate(args []string) error {
-	o.DefaultFeedURL = DefaultFeedUrl
+	o.PackageType = "mixin"
 	return o.InstallOptions.Validate(args)
 }
diff --git a/pkg/pkgmgmt/client/install.go b/pkg/pkgmgmt/client/install.go
index f147f53d..0535018d 100644
--- a/pkg/pkgmgmt/client/install.go
+++ b/pkg/pkgmgmt/client/install.go
@@ -9,9 +9,7 @@ import (
 	"path"
 	"path/filepath"
 	"runtime"
-	"time"
 
-	"get.porter.sh/porter/pkg"
 	"get.porter.sh/porter/pkg/pkgmgmt"
 	"get.porter.sh/porter/pkg/pkgmgmt/feed"
 	"github.com/pkg/errors"
@@ -166,20 +164,12 @@ func (fs *FileSystem) downloadFile(url url.URL, destPath string, executable bool
 		return errors.Wrapf(err, "error creating web request to %s", url.String())
 	}
 
-	// Add debugging headers to our request
-	req.Header.Set("X-Azure-DebugInfo", "1")
-	userAgent := fmt.Sprintf("porter/%s porter_trace_%d %s", pkg.Version, time.Now().UnixNano(), req.UserAgent())
-	if fs.Debug {
-		fmt.Fprintln(fs.Err, "PORTER_TRACE:", userAgent)
-	}
-
-	req.Header.Set("User-Agent", userAgent)
 	resp, err := http.DefaultClient.Do(req)
 	if err != nil {
-		return errors.Wrapf(err, "error downloading %s\nPlease include the following information in any bug reports:\nPORTER_TRACE: %s", url.String(), userAgent)
+		return errors.Wrapf(err, "error downloading %s", url.String())
 	}
 	if resp.StatusCode != 200 {
-		return errors.Errorf("bad status returned when downloading %s (%d) %s\nPlease include the following information in any bug reports:\nPORTER_TRACE: %s\nHEADERS: %#v", url.String(), resp.StatusCode, resp.Status, userAgent, resp.Header)
+		return errors.Errorf("bad status returned when downloading %s (%d) %s", url.String(), resp.StatusCode, resp.Status)
 	}
 	defer resp.Body.Close()
 
diff --git a/pkg/pkgmgmt/download_options.go b/pkg/pkgmgmt/download_options.go
new file mode 100644
index 00000000..49f1a82b
--- /dev/null
+++ b/pkg/pkgmgmt/download_options.go
@@ -0,0 +1,45 @@
+package pkgmgmt
+
+import (
+	"net/url"
+
+	"github.com/pkg/errors"
+)
+
+// DefaultPackageMirror is the default location from which to download Porter assets, such as binaries, atom feeds and package indexes.
+const DefaultPackageMirror = "https://cdn.porter.sh"
+
+// GetDefaultPackageMirrorURL returns DefaultPackageMirror parsed as a url.URL
+func GetDefaultPackageMirrorURL() url.URL {
+	defaultMirror, _ := url.Parse(DefaultPackageMirror)
+	return *defaultMirror
+}
+
+// PackageDownloadOptions are options for downloading Porter packages, such as mixins and plugins.
+type PackageDownloadOptions struct {
+	Mirror       string
+	parsedMirror *url.URL
+}
+
+func (o *PackageDownloadOptions) Validate() error {
+	if o.Mirror == "" {
+		o.Mirror = DefaultPackageMirror
+	}
+
+	mirrorURL, err := url.Parse(o.Mirror)
+	if err != nil {
+		return errors.Wrapf(err, "Invalid --mirror %s", o.Mirror)
+	}
+	o.parsedMirror = mirrorURL
+
+	return nil
+}
+
+// GetMirror returns a copy of of the parsed Mirror that is safe to modify.
+func (o *PackageDownloadOptions) GetMirror() url.URL {
+	if o.parsedMirror == nil {
+		return GetDefaultPackageMirrorURL()
+	}
+
+	return *o.parsedMirror
+}
diff --git a/pkg/pkgmgmt/install.go b/pkg/pkgmgmt/install.go
index f71ff801..f5fd5d51 100644
--- a/pkg/pkgmgmt/install.go
+++ b/pkg/pkgmgmt/install.go
@@ -2,12 +2,15 @@ package pkgmgmt
 
 import (
 	"net/url"
+	"path"
 	"strings"
 
 	"github.com/pkg/errors"
 )
 
 type InstallOptions struct {
+	PackageDownloadOptions
+
 	Name          string
 	URL           string
 	FeedURL       string
@@ -15,24 +18,47 @@ type InstallOptions struct {
 	parsedURL     *url.URL
 	parsedFeedURL *url.URL
 
-	DefaultFeedURL string
+	PackageType string
 }
 
 // GetParsedURL returns a copy of of the parsed URL that is safe to modify.
 func (o *InstallOptions) GetParsedURL() url.URL {
+	if o.parsedURL == nil {
+		return url.URL{}
+	}
+
 	return *o.parsedURL
 }
 
 func (o *InstallOptions) GetParsedFeedURL() url.URL {
+	if o.parsedFeedURL == nil {
+		return o.defaultFeedURL()
+	}
+
 	return *o.parsedFeedURL
 }
 
+func (o *InstallOptions) defaultFeedURL() url.URL {
+	mirror := o.GetMirror()
+	mirror.Path = path.Join(mirror.Path, o.PackageType+"s", "atom.xml")
+	return mirror
+}
+
 func (o *InstallOptions) Validate(args []string) error {
+	if o.PackageType != "mixin" && o.PackageType != "plugin" {
+		return errors.Errorf("invalid package type %q. Please report this as a bug to Porter!", o.PackageType)
+	}
+
 	err := o.validateName(args)
 	if err != nil {
 		return err
 	}
 
+	err = o.PackageDownloadOptions.Validate()
+	if err != nil {
+		return err
+	}
+
 	err = o.validateFeedURL()
 	if err != nil {
 		return err
@@ -64,7 +90,8 @@ func (o *InstallOptions) validateURL() error {
 
 func (o *InstallOptions) validateFeedURL() error {
 	if o.URL == "" && o.FeedURL == "" {
-		o.FeedURL = o.DefaultFeedURL
+		feedURL := o.defaultFeedURL()
+		o.FeedURL = feedURL.String()
 	}
 
 	if o.FeedURL != "" {
diff --git a/pkg/pkgmgmt/pkgmgmt.go b/pkg/pkgmgmt/pkgmgmt.go
index 6ceed331..8bb3ec43 100644
--- a/pkg/pkgmgmt/pkgmgmt.go
+++ b/pkg/pkgmgmt/pkgmgmt.go
@@ -1,8 +1,9 @@
 package pkgmgmt
 
 import (
-	"fmt"
+	"net/url"
 	"os/exec"
+	"path"
 
 	"get.porter.sh/porter/pkg/context"
 )
@@ -41,7 +42,8 @@ type CommandOptions struct {
 	PreRun PreRunHandler
 }
 
-// GetPackageListURL returns the URL for package listings of the provided type
-func GetPackageListURL(pkgType string) string {
-	return fmt.Sprintf("https://cdn.porter.sh/%ss/index.json", pkgType)
+// GetPackageListURL returns the URL for package listings of the provided type.
+func GetPackageListURL(mirror url.URL, pkgType string) string {
+	mirror.Path = path.Join(mirror.Path, pkgType+"s", "index.json")
+	return mirror.String()
 }
diff --git a/pkg/plugins/install.go b/pkg/plugins/install.go
index 5b774903..b5a8554b 100644
--- a/pkg/plugins/install.go
+++ b/pkg/plugins/install.go
@@ -4,13 +4,11 @@ import (
 	"get.porter.sh/porter/pkg/pkgmgmt"
 )
 
-const DefaultFeedUrl = "https://cdn.porter.sh/plugins/atom.xml"
-
 type InstallOptions struct {
 	pkgmgmt.InstallOptions
 }
 
 func (o *InstallOptions) Validate(args []string) error {
-	o.DefaultFeedURL = DefaultFeedUrl
+	o.PackageType = "plugin"
 	return o.InstallOptions.Validate(args)
 }
diff --git a/pkg/porter/explain.go b/pkg/porter/explain.go
index 21e56ab0..f3b63264 100644
--- a/pkg/porter/explain.go
+++ b/pkg/porter/explain.go
@@ -38,6 +38,7 @@ type PrintableCredential struct {
 	Name        string `json:"name" yaml:"name"`
 	Description string `json:"description" yaml:"description"`
 	Required    bool   `json:"required" yaml:"required"`
+	ApplyTo     string `json:"applyTo" yaml:"applyTo"`
 }
 
 type SortPrintableCredential []PrintableCredential
@@ -204,7 +205,7 @@ func generatePrintable(bun bundle.Bundle, action string) (*PrintableBundle, erro
 		PorterVersion: stamp.Version,
 	}
 
-	actions := []PrintableAction{}
+	actions := make([]PrintableAction, 0, len(bun.Actions))
 	for a, v := range bun.Actions {
 		pa := PrintableAction{}
 		pa.Name = a
@@ -215,18 +216,21 @@ func generatePrintable(bun bundle.Bundle, action string) (*PrintableBundle, erro
 	}
 	sort.Sort(SortPrintableAction(actions))
 
-	creds := []PrintableCredential{}
+	creds := make([]PrintableCredential, 0, len(bun.Credentials))
 	for c, v := range bun.Credentials {
 		pc := PrintableCredential{}
 		pc.Name = c
 		pc.Description = v.Description
 		pc.Required = v.Required
+		pc.ApplyTo = generateApplyToString(v.ApplyTo)
 
-		creds = append(creds, pc)
+		if action == "" || v.AppliesTo(action) {
+			creds = append(creds, pc)
+		}
 	}
 	sort.Sort(SortPrintableCredential(creds))
 
-	params := []PrintableParameter{}
+	params := make([]PrintableParameter, 0, len(bun.Parameters))
 	for p, v := range bun.Parameters {
 		if parameters.IsInternal(p, bun) {
 			continue
@@ -246,13 +250,13 @@ func generatePrintable(bun bundle.Bundle, action string) (*PrintableBundle, erro
 		pp.Required = v.Required
 		pp.Description = v.Description
 
-		if v.AppliesTo(action) {
+		if action == "" || v.AppliesTo(action) {
 			params = append(params, pp)
 		}
 	}
 	sort.Sort(SortPrintableParameter(params))
 
-	outputs := []PrintableOutput{}
+	outputs := make([]PrintableOutput, 0, len(bun.Outputs))
 	for o, v := range bun.Outputs {
 		def, ok := bun.Definitions[v.Definition]
 		if !ok {
@@ -273,13 +277,13 @@ func generatePrintable(bun bundle.Bundle, action string) (*PrintableBundle, erro
 	}
 	sort.Sort(SortPrintableOutput(outputs))
 
-	dependencies := []PrintableDependency{}
 	solver := &extensions.DependencySolver{}
 	deps, err := solver.ResolveDependencies(bun)
 	if err != nil {
 		return nil, errors.Wrapf(err, "error executing dependencies")
 	}
 
+	dependencies := make([]PrintableDependency, 0, len(deps))
 	for _, dep := range deps {
 		pd := PrintableDependency{}
 		pd.Alias = dep.Alias
@@ -341,9 +345,9 @@ func (p *Porter) printCredentialsExplainTable(bun *PrintableBundle) error {
 			if !ok {
 				return nil
 			}
-			return []interface{}{c.Name, c.Description, c.Required}
+			return []interface{}{c.Name, c.Description, c.Required, c.ApplyTo}
 		}
-	return printer.PrintTable(p.Out, bun.Credentials, printCredRow, "Name", "Description", "Required")
+	return printer.PrintTable(p.Out, bun.Credentials, printCredRow, "Name", "Description", "Required", "Applies To")
 }
 
 func (p *Porter) printParametersExplainBlock(bun *PrintableBundle) error {
diff --git a/pkg/porter/packages.go b/pkg/porter/packages.go
index af3589b1..5c02e52e 100644
--- a/pkg/porter/packages.go
+++ b/pkg/porter/packages.go
@@ -15,6 +15,7 @@ type SearchOptions struct {
 	Name string
 	Type string
 	printer.PrintOptions
+	pkgmgmt.PackageDownloadOptions
 }
 
 // Validate validates the arguments provided to a search command
@@ -28,6 +29,11 @@ func (o *SearchOptions) Validate(args []string) error {
 		return err
 	}
 
+	err = o.PackageDownloadOptions.Validate()
+	if err != nil {
+		return err
+	}
+
 	return o.ParseFormat()
 }
 
@@ -46,7 +52,7 @@ func (o *SearchOptions) validatePackageName(args []string) error {
 
 // SearchPackages searches the provided package list according to the provided options
 func (p *Porter) SearchPackages(opts SearchOptions) error {
-	url := pkgmgmt.GetPackageListURL(opts.Type)
+	url := pkgmgmt.GetPackageListURL(opts.GetMirror(), opts.Type)
 	list, err := pkgmgmt.GetPackageListings(url)
 	if err != nil {
 		return err
diff --git a/scripts/install/install-linux.sh b/scripts/install/install-linux.sh
index cdd55c9c..f0f1f9ad 100755
--- a/scripts/install/install-linux.sh
+++ b/scripts/install/install-linux.sh
@@ -1,17 +1,23 @@
 #!/usr/bin/env bash
 set -xeuo pipefail
 
-PORTER_HOME=~/.porter
-PORTER_URL=https://cdn.porter.sh
+# Installs the porter CLI for a single user.
+# PORTER_HOME:      Location where Porter is installed (defaults to ~/.porter).
+# PORTER_MIRROR:       Base URL where Porter assets, such as binaries and atom feeds, are downloaded. This lets you
+#                   setup an internal mirror.
+# PORTER_PERMALINK: The version of Porter to install, such as vX.Y.Z, latest or canary.
+# PKG_PERMALINK:    The version of mixins and plugins to install, such as latest or canary.
+
+export PORTER_HOME=${PORTER_HOME:-~/.porter}
+export PORTER_MIRROR=${PORTER_MIRROR:-https://cdn.porter.sh}
 PORTER_PERMALINK=${PORTER_PERMALINK:-latest}
 PKG_PERMALINK=${PKG_PERMALINK:-latest}
-PORTER_TRACE=$(date +%s_%N)
-echo "Installing porter to $PORTER_HOME"
-echo "PORTER_TRACE: $PORTER_TRACE"
+
+echo "Installing porter@$PORTER_PERMALINK to $PORTER_HOME from $PORTER_MIRROR"
 
 mkdir -p $PORTER_HOME/runtimes
 
-curl --http1.1 -v -H "X-Azure-DebugInfo: 1" -A "curl porter_install/$PORTER_PERMALINK porter_trace_$PORTER_TRACE"  -fsSLo $PORTER_HOME/porter $PORTER_URL/$PORTER_PERMALINK/porter-linux-amd64
+curl -fsSLo $PORTER_HOME/porter $PORTER_MIRROR/$PORTER_PERMALINK/porter-linux-amd64
 chmod +x $PORTER_HOME/porter
 cp $PORTER_HOME/porter $PORTER_HOME/runtimes/porter-runtime
 echo Installed `$PORTER_HOME/porter version`
diff --git a/scripts/install/install-mac.sh b/scripts/install/install-mac.sh
index 2a8033f4..35bc9b3f 100755
--- a/scripts/install/install-mac.sh
+++ b/scripts/install/install-mac.sh
@@ -1,18 +1,24 @@
 #!/usr/bin/env bash
 set -xeuo pipefail
 
-PORTER_HOME=~/.porter
-PORTER_URL=https://cdn.porter.sh
+# Installs the porter CLI for a single user.
+# PORTER_HOME:      Location where Porter is installed (defaults to ~/.porter).
+# PORTER_MIRROR:       Base URL where Porter assets, such as binaries and atom feeds, are downloaded. This lets you
+#                   setup an internal mirror.
+# PORTER_PERMALINK: The version of Porter to install, such as vX.Y.Z, latest or canary.
+# PKG_PERMALINK:    The version of mixins and plugins to install, such as latest or canary.
+
+export PORTER_HOME=${PORTER_HOME:-~/.porter}
+export PORTER_MIRROR=${PORTER_MIRROR:-https://cdn.porter.sh}
 PORTER_PERMALINK=${PORTER_PERMALINK:-latest}
 PKG_PERMALINK=${PKG_PERMALINK:-latest}
-PORTER_TRACE=$(date +%s_%N)
-echo "Installing porter to $PORTER_HOME"
-echo "PORTER_TRACE: $PORTER_TRACE"
+
+echo "Installing porter@$PORTER_PERMALINK to $PORTER_HOME from $PORTER_MIRROR"
 
 mkdir -p $PORTER_HOME/runtimes
 
-curl --http1.1 -v -H "X-Azure-DebugInfo: 1" -A "curl porter_install/$PORTER_PERMALINK porter_trace_$PORTER_TRACE" -fsSLo $PORTER_HOME/porter $PORTER_URL/$PORTER_PERMALINK/porter-darwin-amd64
-curl --http1.1 -v -H "X-Azure-DebugInfo: 1" -A "curl porter_install/$PORTER_PERMALINK porter_trace_$PORTER_TRACE" -fsSLo $PORTER_HOME/runtimes/porter-runtime $PORTER_URL/$PORTER_PERMALINK/porter-linux-amd64
+curl -fsSLo $PORTER_HOME/porter $PORTER_MIRROR/$PORTER_PERMALINK/porter-darwin-amd64
+curl -fsSLo $PORTER_HOME/runtimes/porter-runtime $PORTER_MIRROR/$PORTER_PERMALINK/porter-linux-amd64
 chmod +x $PORTER_HOME/porter
 chmod +x $PORTER_HOME/runtimes/porter-runtime
 echo Installed `$PORTER_HOME/porter version`
diff --git a/scripts/install/install-windows.ps1 b/scripts/install/install-windows.ps1
index c11cea4a..d4f49400 100755
--- a/scripts/install/install-windows.ps1
+++ b/scripts/install/install-windows.ps1
@@ -1,14 +1,26 @@
-param([String]$PORTER_PERMALINK='latest', [String]$PKG_PERMALINK='latest')
+# Installs the porter CLI for a single user.
+param(
+    [String]
+    # The version of Porter to install, such as vX.Y.Z, latest or canary.
+    $PORTER_PERMALINK='latest',
+    [String]
+    # The version of mixins and plugins to install, such as latest or canary.
+    $PKG_PERMALINK='latest',
+    [String]
+    # Location where Porter is installed (defaults to ~/.porter).
+    $PORTER_HOME="$env:USERPROFILE\.porter",
+    [String]
+    # Base URL where Porter assets, such as binaries and atom feeds, are downloaded. This lets you setup an internal mirror.
+    $PORTER_MIRROR="https://cdn.porter.sh")
 
-$PORTER_HOME="$env:USERPROFILE\.porter"
-$PORTER_URL="https://cdn.porter.sh"
-
-echo "Installing porter to $PORTER_HOME"
+echo "Installing porter@$PORTER_PERMALINK to $PORTER_HOME from $PORTER_MIRROR"
 
+$env:PORTER_HOME=$PORTER_HOME
+$env:PORTER_MIRROR=$PORTER_MIRROR
 mkdir -f $PORTER_HOME/runtimes
 
-(new-object System.Net.WebClient).DownloadFile("$PORTER_URL/$PORTER_PERMALINK/porter-windows-amd64.exe", "$PORTER_HOME\porter.exe")
-(new-object System.Net.WebClient).DownloadFile("$PORTER_URL/$PORTER_PERMALINK/porter-linux-amd64", "$PORTER_HOME\runtimes\porter-runtime")
+(new-object System.Net.WebClient).DownloadFile("$PORTER_MIRROR/$PORTER_PERMALINK/porter-windows-amd64.exe", "$PORTER_HOME\porter.exe")
+(new-object System.Net.WebClient).DownloadFile("$PORTER_MIRROR/$PORTER_PERMALINK/porter-linux-amd64", "$PORTER_HOME\runtimes\porter-runtime")
 echo "Installed $(& $PORTER_HOME\porter.exe version)"
 
 & $PORTER_HOME/porter mixin install exec --version $PKG_PERMALINK
diff --git a/workshop/porter-tf-aci/azure/bundle/porter.yaml b/workshop/porter-tf-aci/azure/bundle/porter.yaml
index 8802a24e..149ddae4 100644
--- a/workshop/porter-tf-aci/azure/bundle/porter.yaml
+++ b/workshop/porter-tf-aci/azure/bundle/porter.yaml
@@ -23,7 +23,7 @@ credentials:
   - name: client_secret
     env: AZURE_CLIENT_SECRET
 
-## This section defines what paramters are used by the bundle. These parameters are used by various 
+## This section defines what parameters are used by the bundle. These parameters are used by various 
 ## steps within the bundle
 parameters:
   - name: location
diff --git a/workshop/porter-tf/azure/porter.yaml b/workshop/porter-tf/azure/porter.yaml
index affd51e3..08975abb 100644
--- a/workshop/porter-tf/azure/porter.yaml
+++ b/workshop/porter-tf/azure/porter.yaml
@@ -23,7 +23,7 @@ credentials:
   - name: client_secret
     env: AZURE_CLIENT_SECRET
 
-## This section defines what paramters are used by the bundle. These parameters are used by various 
+## This section defines what parameters are used by the bundle. These parameters are used by various 
 ## steps within the bundle
 parameters:
   - name: location


Test patch:
diff --git a/pkg/mixin/install_test.go b/pkg/mixin/install_test.go
index 64296ecd..f59ccb90 100644
--- a/pkg/mixin/install_test.go
+++ b/pkg/mixin/install_test.go
@@ -12,5 +12,5 @@ func TestInstallOptions_Validate(t *testing.T) {
 	opts := InstallOptions{}
 	err := opts.Validate([]string{"pkg1"})
 	require.NoError(t, err, "Validate failed")
-	assert.Equal(t, DefaultFeedUrl, opts.FeedURL, "Feed URL was not defaulted to the mixin feed URL")
+	assert.NotEmpty(t, opts.FeedURL, "Feed URL was not defaulted")
 }
diff --git a/pkg/pkgmgmt/client/install_test.go b/pkg/pkgmgmt/client/install_test.go
index 7f717f68..ca490483 100644
--- a/pkg/pkgmgmt/client/install_test.go
+++ b/pkg/pkgmgmt/client/install_test.go
@@ -19,11 +19,7 @@ import (
 func TestFileSystem_InstallFromUrl(t *testing.T) {
 	// serve out a fake package
 	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		if r.Header.Get("X-Azure-DebugInfo") == "" ||
-			!strings.Contains(r.Header.Get("User-Agent"), "porter_trace") {
-			w.WriteHeader(400)
-		}
-		fmt.Fprintf(w, "#!/usr/bin/env bash\necho i am a mixxin\n")
+		fmt.Fprintf(w, "#!/usr/bin/env bash\necho i am a random package\n")
 	}))
 	defer ts.Close()
 
@@ -31,17 +27,19 @@ func TestFileSystem_InstallFromUrl(t *testing.T) {
 	p := NewFileSystem(c.Config, "packages")
 
 	opts := pkgmgmt.InstallOptions{
-		Version: "latest",
-		URL:     ts.URL,
+		PackageType: "mixin",
+		Version:     "latest",
+		URL:         ts.URL,
 	}
-	opts.Validate([]string{"mixxin"})
+	err := opts.Validate([]string{"mypkg"})
+	require.NoError(t, err, "Validate failed")
 
-	err := p.Install(opts)
+	err = p.Install(opts)
 	require.NoError(t, err)
 
-	clientExists, _ := p.FileSystem.Exists("/root/.porter/packages/mixxin/mixxin")
+	clientExists, _ := p.FileSystem.Exists("/root/.porter/packages/mypkg/mypkg")
 	assert.True(t, clientExists)
-	runtimeExists, _ := p.FileSystem.Exists("/root/.porter/packages/mixxin/runtimes/mixxin-runtime")
+	runtimeExists, _ := p.FileSystem.Exists("/root/.porter/packages/mypkg/runtimes/mypkg-runtime")
 	assert.True(t, runtimeExists)
 }
 
@@ -68,10 +66,12 @@ func TestFileSystem_InstallFromFeedUrl(t *testing.T) {
 	p := NewFileSystem(c.Config, "packages")
 
 	opts := pkgmgmt.InstallOptions{
-		Version: "v1.2.4",
-		FeedURL: ts.URL + "/atom.xml",
+		PackageType: "plugin",
+		Version:     "v1.2.4",
+		FeedURL:     ts.URL + "/atom.xml",
 	}
-	opts.Validate([]string{"helm"})
+	err = opts.Validate([]string{"helm"})
+	require.NoError(t, err, "Validate failed")
 
 	err = p.Install(opts)
 	require.NoError(t, err)
@@ -88,7 +88,7 @@ func TestFileSystem_Install_RollbackMissingRuntime(t *testing.T) {
 		if strings.Contains(r.RequestURI, "linux-amd64") {
 			w.WriteHeader(400)
 		} else {
-			fmt.Fprintf(w, "#!/usr/bin/env bash\necho i am a client mixxin\n")
+			fmt.Fprintf(w, "#!/usr/bin/env bash\necho i am a client mypkg\n")
 		}
 	}))
 	defer ts.Close()
@@ -97,18 +97,19 @@ func TestFileSystem_Install_RollbackMissingRuntime(t *testing.T) {
 	p := NewFileSystem(c.Config, "packages")
 
 	parentDir, _ := p.GetPackagesDir()
-	pkgDir := path.Join(parentDir, "mixxin")
+	pkgDir := path.Join(parentDir, "mypkg")
 
 	opts := pkgmgmt.InstallOptions{
-		Version: "latest",
-		URL:     ts.URL,
+		PackageType: "mixin",
+		Version:     "latest",
+		URL:         ts.URL,
 	}
-	opts.Validate([]string{"mixxin"})
+	err := opts.Validate([]string{"mypkg"})
+	require.NoError(t, err, "Validate failed")
 
-	err := p.Install(opts)
+	err = p.Install(opts)
 	require.Error(t, err)
 	assert.Contains(t, err.Error(), "bad status returned when downloading")
-	assert.Contains(t, err.Error(), "porter_trace_", "The error message should contain our special debug user agent")
 
 	// Make sure the package directory was removed
 	dirExists, _ := p.FileSystem.DirExists(pkgDir)
@@ -121,17 +122,19 @@ func TestFileSystem_Install_PackageInfoSavedWhenNoFileExists(t *testing.T) {
 
 	packageURL := "https://cdn.porter.sh/mixins/helm"
 	opts := pkgmgmt.InstallOptions{
-		Version: "v1.2.4",
-		URL:     packageURL,
+		PackageType: "plugin",
+		Version:     "v1.2.4",
+		URL:         packageURL,
 	}
 	name := "helm"
-	opts.Validate([]string{name})
+	err := opts.Validate([]string{name})
+	require.NoError(t, err, "Validate failed")
 
 	// ensure cache.json does not exist (yet)
 	cacheExists, _ := p.FileSystem.Exists("/root/.porter/packages/cache.json")
 	assert.False(t, cacheExists)
 
-	err := p.savePackageInfo(opts)
+	err = p.savePackageInfo(opts)
 	require.NoError(t, err)
 
 	// cache.json should have been created
diff --git a/pkg/pkgmgmt/download_options_test.go b/pkg/pkgmgmt/download_options_test.go
new file mode 100644
index 00000000..9d586302
--- /dev/null
+++ b/pkg/pkgmgmt/download_options_test.go
@@ -0,0 +1,45 @@
+package pkgmgmt
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestPackageDownloadOptions_Validate(t *testing.T) {
+	t.Run("unset", func(t *testing.T) {
+		opts := PackageDownloadOptions{}
+		require.NoError(t, opts.Validate())
+		assert.Equal(t, DefaultPackageMirror, opts.Mirror)
+	})
+
+	t.Run("valid url", func(t *testing.T) {
+		exampleMirror := "https://example.com"
+		opts := PackageDownloadOptions{Mirror: exampleMirror}
+		require.NoError(t, opts.Validate())
+		assert.Equal(t, exampleMirror, opts.Mirror)
+	})
+
+	t.Run("invalid url", func(t *testing.T) {
+		opts := PackageDownloadOptions{Mirror: "$://example.com"}
+		require.Error(t, opts.Validate())
+	})
+}
+
+func TestPackageDownloadOptions_GetMirror(t *testing.T) {
+	t.Run("mirror unset", func(t *testing.T) {
+		opts := PackageDownloadOptions{}
+		mirror := opts.GetMirror()
+		assert.Equal(t, DefaultPackageMirror, mirror.String())
+	})
+
+	t.Run("mirror set", func(t *testing.T) {
+		exampleMirror := "https://example.com"
+		opts := PackageDownloadOptions{Mirror: exampleMirror}
+		require.NoError(t, opts.Validate())
+
+		result := opts.GetMirror()
+		assert.Equal(t, exampleMirror, result.String())
+	})
+}
diff --git a/pkg/pkgmgmt/install_test.go b/pkg/pkgmgmt/install_test.go
index 815c9c0b..3e5393bb 100644
--- a/pkg/pkgmgmt/install_test.go
+++ b/pkg/pkgmgmt/install_test.go
@@ -41,23 +41,37 @@ func TestInstallOptions_DefaultVersion(t *testing.T) {
 }
 
 func TestInstallOptions_ValidateFeedURL(t *testing.T) {
-	t.Run("default feed url", func(t *testing.T) {
-		defaultFeedURL := "https://example.com/atom.xml"
+	t.Run("feed url unset, mirror unset", func(t *testing.T) {
 		opts := InstallOptions{
-			DefaultFeedURL: defaultFeedURL,
+			PackageType: "mixin",
 		}
-		err := opts.validateFeedURL()
+		err := opts.Validate([]string{"mypkg"})
 		require.NoError(t, err)
-		assert.Equal(t, defaultFeedURL, opts.FeedURL, "fallback to the default feed url when nothing is specified")
+		wantFeedURL := "https://cdn.porter.sh/mixins/atom.xml"
+		assert.Equal(t, wantFeedURL, opts.FeedURL, "fallback to the default feed url when nothing is specified")
 
 		parsedFeedURL := opts.GetParsedFeedURL()
-		assert.Equal(t, defaultFeedURL, parsedFeedURL.String(), "validateFeedURL should parse the feed url")
+		assert.Equal(t, wantFeedURL, parsedFeedURL.String(), "validateFeedURL should parse the feed url")
+	})
+	t.Run("feed url unset, mirror set", func(t *testing.T) {
+		opts := InstallOptions{
+			PackageType:            "plugin",
+			PackageDownloadOptions: PackageDownloadOptions{Mirror: "https://example.com:81/porter"},
+		}
+		err := opts.Validate([]string{"mypkg"})
+		require.NoError(t, err)
+		wantFeedURL := "https://example.com:81/porter/plugins/atom.xml"
+		assert.Equal(t, wantFeedURL, opts.FeedURL, "fallback to the mirror when nothing is specified")
+
+		parsedFeedURL := opts.GetParsedFeedURL()
+		assert.Equal(t, wantFeedURL, parsedFeedURL.String(), "validateFeedURL should parse the feed url")
 	})
 	t.Run("user specified feed url", func(t *testing.T) {
 		opts := InstallOptions{
-			FeedURL: "https://example.com/atom.xml",
+			PackageType: "mixin",
+			FeedURL:     "https://example.com/atom.xml",
 		}
-		err := opts.validateFeedURL()
+		err := opts.Validate([]string{"mypkg"})
 		require.NoError(t, err)
 
 		parsedFeedURL := opts.GetParsedFeedURL()
@@ -65,24 +79,32 @@ func TestInstallOptions_ValidateFeedURL(t *testing.T) {
 	})
 	t.Run("user specified url", func(t *testing.T) {
 		opts := InstallOptions{
-			URL: "https://example.com/mymixin",
+			PackageType: "plugin",
+			URL:         "https://example.com/mymixin",
 		}
-		err := opts.validateFeedURL()
+		err := opts.Validate([]string{"mypkg"})
 		require.NoError(t, err)
 		assert.Nil(t, opts.parsedFeedURL, "validateFeedURL shouldn't try to parse an empty URL")
 	})
 	t.Run("invalid feed url specified", func(t *testing.T) {
 		opts := InstallOptions{
-			FeedURL: "$://example.com",
+			PackageType: "mixin",
+			FeedURL:     "$://example.com",
 		}
-		err := opts.validateFeedURL()
+		err := opts.Validate([]string{"mypkg"})
 		assert.Contains(t, err.Error(), fmt.Sprintf("invalid --feed-url %s", opts.FeedURL))
 		assert.Contains(t, err.Error(), "first path segment in URL cannot contain colon")
 	})
 }
 
 func TestInstallOptions_ValidateURL(t *testing.T) {
-	t.Run("url not specified", func(t *testing.T) {
+	t.Run("url unset, mirror unset", func(t *testing.T) {
+		opts := InstallOptions{}
+		err := opts.validateURL()
+		require.NoError(t, err)
+		assert.Nil(t, opts.parsedURL, "validateURL shouldn't try to parse an empty URL")
+	})
+	t.Run("url unset, mirror set", func(t *testing.T) {
 		opts := InstallOptions{}
 		err := opts.validateURL()
 		require.NoError(t, err)
@@ -108,11 +130,32 @@ func TestInstallOptions_ValidateURL(t *testing.T) {
 }
 
 func TestInstallOptions_Validate(t *testing.T) {
-	opts := InstallOptions{
-		DefaultFeedURL: "http://example.com/atom.xml", // this isn't set by the user but by the package manager
-	}
-	err := opts.Validate([]string{"pkg"})
-	require.NoError(t, err, "Validate failed")
-	assert.NotEmpty(t, opts.FeedURL, "Validate should have defaulted the feed")
-	assert.NotEmpty(t, opts.Version, "Validate should have defaulted the version")
+	t.Run("mixin", func(t *testing.T) {
+		opts := InstallOptions{
+			PackageType: "mixin",
+		}
+		err := opts.Validate([]string{"pkg"})
+		require.NoError(t, err, "Validate failed")
+		assert.NotEmpty(t, opts.FeedURL, "Validate should have defaulted the feed")
+		assert.NotEmpty(t, opts.Version, "Validate should have defaulted the version")
+		assert.NotEmpty(t, opts.Mirror, "Validate should have defaulted the mirror")
+	})
+	t.Run("plugin", func(t *testing.T) {
+		opts := InstallOptions{
+			PackageType: "mixin",
+		}
+		err := opts.Validate([]string{"pkg"})
+		require.NoError(t, err, "Validate failed")
+		assert.NotEmpty(t, opts.FeedURL, "Validate should have defaulted the feed")
+		assert.NotEmpty(t, opts.Version, "Validate should have defaulted the version")
+		assert.NotEmpty(t, opts.Mirror, "Validate should have defaulted the mirror")
+	})
+	t.Run("invalid package type", func(t *testing.T) {
+		opts := InstallOptions{
+			PackageType: "oops",
+		}
+		err := opts.Validate([]string{"pkg"})
+		require.Error(t, err, "Validate should have failed")
+		assert.Contains(t, err.Error(), `invalid package type "oops"`)
+	})
 }
diff --git a/pkg/plugins/install_test.go b/pkg/plugins/install_test.go
index 91a5dcaa..85c15230 100644
--- a/pkg/plugins/install_test.go
+++ b/pkg/plugins/install_test.go
@@ -12,5 +12,5 @@ func TestInstallOptions_Validate(t *testing.T) {
 	opts := InstallOptions{}
 	err := opts.Validate([]string{"pkg1"})
 	require.NoError(t, err, "Validate failed")
-	assert.Equal(t, DefaultFeedUrl, opts.FeedURL, "Feed URL was not defaulted to the plugins feed URL")
+	assert.NotEmpty(t, opts.FeedURL, "Feed URL was not defaulted to the plugins feed URL")
 }
diff --git a/pkg/porter/explain_test.go b/pkg/porter/explain_test.go
index 850e79db..bebca494 100644
--- a/pkg/porter/explain_test.go
+++ b/pkg/porter/explain_test.go
@@ -43,7 +43,7 @@ func TestExplain_generateCredentialsTableNoCreds(t *testing.T) {
 	p := NewTestPorter(t)
 
 	p.printCredentialsExplainTable(&bun)
-	expected := "Name   Description   Required\n"
+	expected := "Name   Description   Required   Applies To\n"
 	gotOutput := p.TestConfig.TestContext.GetOutput()
 	assert.Equal(t, expected, gotOutput)
 	t.Log(gotOutput)
@@ -268,26 +268,43 @@ func TestExplain_generatePrintableBundleParamsWithAction(t *testing.T) {
 		},
 	}
 
-	pb, err := generatePrintable(bun, "install")
-	require.NoError(t, err)
+	t.Run("action applies", func(t *testing.T) {
+		pb, err := generatePrintable(bun, "install")
+		require.NoError(t, err)
 
-	require.Equal(t, 2, len(pb.Parameters), "expected 2 parameters")
-	d := pb.Parameters[0]
-	require.Equal(t, "debug", d.Name)
-	assert.Equal(t, "clippy", fmt.Sprintf("%v", d.Default))
-	assert.Equal(t, "string", d.Type)
-	f := pb.Parameters[1]
-	require.Equal(t, "tfstate", f.Name)
-	assert.Equal(t, "file", f.Type)
+		require.Equal(t, 2, len(pb.Parameters), "expected 2 parameters")
 
-	assert.Equal(t, 0, len(pb.Outputs))
-	assert.Equal(t, 0, len(pb.Credentials))
-	assert.Equal(t, 0, len(pb.Actions))
+		d := pb.Parameters[0]
+		require.Equal(t, "debug", d.Name)
+		assert.Equal(t, "install", d.ApplyTo)
 
-	pb2, err := generatePrintable(bun, "upgrade")
-	require.NoError(t, err)
+		f := pb.Parameters[1]
+		require.Equal(t, "tfstate", f.Name)
+		assert.Equal(t, "All Actions", f.ApplyTo)
+	})
+
+	t.Run("action does not apply", func(t *testing.T) {
+		pb, err := generatePrintable(bun, "upgrade")
+		require.NoError(t, err)
+
+		require.Equal(t, 1, len(pb.Parameters), "expected only 1 parameter since debug parameter doesn't apply to upgrade command")
+		require.Equal(t, "tfstate", pb.Parameters[0].Name)
+	})
+
+	t.Run("all actions", func(t *testing.T) {
+		pb, err := generatePrintable(bun, "")
+		require.NoError(t, err)
+
+		require.Equal(t, 2, len(pb.Parameters), "expected 2 parameters")
 
-	require.Equal(t, 1, len(pb2.Parameters), "expected only 1 parameter since debug parameter doesn't apply to upgrade command")
+		d := pb.Parameters[0]
+		require.Equal(t, "debug", d.Name)
+		assert.Equal(t, "install", d.ApplyTo)
+
+		f := pb.Parameters[1]
+		require.Equal(t, "tfstate", f.Name)
+		assert.Equal(t, "All Actions", f.ApplyTo)
+	})
 }
 
 func TestExplain_generatePrintableBundleOutputs(t *testing.T) {
@@ -326,9 +343,13 @@ func TestExplain_generatePrintableBundleOutputs(t *testing.T) {
 func TestExplain_generatePrintableBundleCreds(t *testing.T) {
 	bun := bundle.Bundle{
 		Credentials: map[string]bundle.Credential{
-			"debug": {
+			"kubeconfig": {
 				Required:    true,
 				Description: "a cred",
+				ApplyTo:     []string{"install"},
+			},
+			"password": {
+				Description: "another cred",
 			},
 		},
 		Custom: map[string]interface{}{
@@ -340,16 +361,43 @@ func TestExplain_generatePrintableBundleCreds(t *testing.T) {
 		},
 	}
 
-	pb, err := generatePrintable(bun, "")
-	assert.NoError(t, err)
+	t.Run("action applies", func(t *testing.T) {
+		pb, err := generatePrintable(bun, "install")
+		require.NoError(t, err)
 
-	assert.Equal(t, 1, len(pb.Credentials))
-	d := pb.Credentials[0]
-	assert.True(t, d.Required)
-	assert.Equal(t, "a cred", d.Description)
-	assert.Equal(t, 0, len(pb.Parameters))
-	assert.Equal(t, 0, len(pb.Outputs))
-	assert.Equal(t, 0, len(pb.Actions))
+		require.Equal(t, 2, len(pb.Credentials), "expected 2 credentials")
+
+		d := pb.Credentials[0]
+		require.Equal(t, "kubeconfig", d.Name)
+		assert.Equal(t, "install", d.ApplyTo)
+
+		f := pb.Credentials[1]
+		require.Equal(t, "password", f.Name)
+		assert.Equal(t, "All Actions", f.ApplyTo)
+	})
+
+	t.Run("action does not apply", func(t *testing.T) {
+		pb, err := generatePrintable(bun, "upgrade")
+		require.NoError(t, err)
+
+		require.Equal(t, 1, len(pb.Credentials), "expected only 1 credential since kubeconfig credential doesn't apply to upgrade command")
+		require.Equal(t, "password", pb.Credentials[0].Name)
+	})
+
+	t.Run("all actions", func(t *testing.T) {
+		pb, err := generatePrintable(bun, "")
+		require.NoError(t, err)
+
+		require.Equal(t, 2, len(pb.Credentials), "expected 2 credentials")
+
+		d := pb.Credentials[0]
+		require.Equal(t, "kubeconfig", d.Name)
+		assert.Equal(t, "install", d.ApplyTo)
+
+		f := pb.Credentials[1]
+		require.Equal(t, "password", f.Name)
+		assert.Equal(t, "All Actions", f.ApplyTo)
+	})
 }
 
 func TestExplain_generatePrintableBundlePorterVersion(t *testing.T) {
diff --git a/pkg/porter/packages_test.go b/pkg/porter/packages_test.go
index 408e1810..e8d60c98 100644
--- a/pkg/porter/packages_test.go
+++ b/pkg/porter/packages_test.go
@@ -175,7 +175,7 @@ func TestPorter_SearchPackages_Plugins(t *testing.T) {
 // fetchFullListBytes fetches the full package list according to the
 // provided package type, sorts the list, and returns its marshaled byte form
 func fetchFullListBytes(pkgType string) ([]byte, error) {
-	url := pkgmgmt.GetPackageListURL(pkgType)
+	url := pkgmgmt.GetPackageListURL(pkgmgmt.GetDefaultPackageMirrorURL(), pkgType)
 	packageList, err := pkgmgmt.GetPackageListings(url)
 	if err != nil {
 		return nil, err